MEASURED FIRST (pipeline/arcade_measure.mjs, over 5 seeds, off Lane A's own generatePlan):
the arcade is ONE 42.00 m x 5 m edge, 2 blocks, 16-17 shops (17 at the default seed), frontage
3.00-4.94 m (mean 3.87-4.05), depth 5.1-8.0 m, 4-11 of them two-storey. Walkable 5.00 m between
lot faces. AWNING_DEPTH 2.2 from each face means the two slabs already meet 0.60 m apart over the
centreline — the arcade IS roofed, and the roof is an accident.
⚠ AND THE POSTS ARE IN THE WRONG PLACE: buildings.js:643 puts them 2.00 m out from each lot face,
i.e. +/-0.50 m from the centreline — two rows of posts 1.00 m apart down a 5.00 m lane. No
collider, so it walks; it just reads as a colonnade planted in its own doorway. Lane B ask filed.
SHIPPED (all riding existing shared materials or stating their draw):
facade-shuttered 768x595 (the 1.29:1 arcade lot aspect, not the pool's 1.78:1) — the dead
tenant. +0 draws, +0 tris, 1 of the 14 FREE facade-atlas slots (22 of 36 used).
ground-arcade-floor 512^2 terrazzo — +1 draw TOWN-WIDE, +0 tris (the 504 m2 lane quad moves out
of footGeos). use:'arcade-floor' names a slot that does not exist yet, on purpose.
ground-arcade-roof 512^2 pressed-metal + wired glass — +1 draw in the 1-2 arcade chunks, +2 tris.
pipeline/seamless_tile.py: cut a regular-pattern skin to a whole number of its own periods instead
of feathering the joint. It has a guard, because on this pair the naive version made things WORSE:
the roof's x seam went 26.7 -> 8.3 (kept), the diffusion-drawn terrazzo floor is not periodic enough
to cut on (autocorr r=0.26) and 27.9 -> 31.9 was rejected and passed through. Recorded, not hidden.
manifest: facades 48->49, grounds 14->16. validate_manifest.py 0 errors, 0 warnings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
102 lines
4.6 KiB
Python
102 lines
4.6 KiB
Python
#!/usr/bin/env python3
|
|
"""PROCITY Lane E — seamless_tile.py (R39)
|
|
|
|
Make a REGULAR-PATTERN skin tile without a seam, by cropping it to a whole number of its own
|
|
periods instead of blurring the joint.
|
|
|
|
Why this exists: every ground skin in `web/assets/gen/` tiles through `RepeatWrapping` with the tile
|
|
scale baked into the mesh UVs (`ground.js` TILE = 5 m). Grass and bitumen hide the joint because
|
|
they have no structure. A TILED FLOOR does not — the arcade's 42 x 12 m lane is 504 m2, the single
|
|
biggest surface in the district, and a terrazzo grid whose lines jump at every repeat reads as a
|
|
mistake rather than as a floor. The usual fix (offset + feather) is wrong for a grid: it smears the
|
|
grout lines. The right fix is arithmetic — find the pattern's pitch and cut on it.
|
|
|
|
PY=~/Documents/MODELBEAST/venvs/mflux/bin/python
|
|
$PY pipeline/seamless_tile.py IN.png OUT.png [--check OUT_3x3.png]
|
|
|
|
Pitch is found by autocorrelating the mean-absolute column/row gradient (a grout line is a gradient
|
|
spike; the spacing of the spikes IS the pitch), searching 24..W/3 px. Prints the residual seam error
|
|
so the result is falsifiable: the mean |difference| between the left and right edge columns before
|
|
and after. If the image has no periodic structure the correlation is flat and the script says so and
|
|
copies the input through rather than cropping something arbitrary.
|
|
"""
|
|
import sys
|
|
import numpy as np
|
|
from PIL import Image
|
|
|
|
|
|
def pitch(sig, lo=24):
|
|
"""Dominant period of a 1-D signal, by normalised autocorrelation."""
|
|
x = sig - sig.mean()
|
|
n = len(x)
|
|
hi = max(lo + 1, n // 3)
|
|
ac = np.correlate(x, x, mode="full")[n - 1:]
|
|
ac = ac / (ac[0] or 1)
|
|
band = ac[lo:hi]
|
|
if band.max() < 0.12: # no periodic structure worth cutting on
|
|
return None, float(band.max())
|
|
return int(lo + band.argmax()), float(band.max())
|
|
|
|
|
|
def edge_err(a, axis):
|
|
"""Mean |difference| across the wrap seam, 0-255."""
|
|
if axis == 1:
|
|
return float(np.abs(a[:, 0].astype(np.float32) - a[:, -1].astype(np.float32)).mean())
|
|
return float(np.abs(a[0].astype(np.float32) - a[-1].astype(np.float32)).mean())
|
|
|
|
|
|
def main():
|
|
src, dst = sys.argv[1], sys.argv[2]
|
|
im = Image.open(src).convert("RGB")
|
|
a = np.asarray(im).astype(np.float32)
|
|
g = a.mean(axis=2)
|
|
gx = np.abs(np.diff(g, axis=1)).mean(axis=0) # column gradient profile -> vertical lines
|
|
gy = np.abs(np.diff(g, axis=0)).mean(axis=1) # row gradient profile -> horizontal lines
|
|
px, cx = pitch(gx)
|
|
py, cy = pitch(gy)
|
|
before = (edge_err(np.asarray(im), 1), edge_err(np.asarray(im), 0))
|
|
print(f"in {im.size} pitch x={px} (r={cx:.2f}) y={py} (r={cy:.2f}) seam before x={before[0]:.1f} y={before[1]:.1f}")
|
|
if px is None and py is None:
|
|
print("no periodic structure found — passing through unchanged")
|
|
im.save(dst)
|
|
return
|
|
|
|
# PER-AXIS, AND ONLY IF IT ACTUALLY HELPS. Measured on the R39 arcade pair: a diffusion-drawn
|
|
# tiled floor is NOT periodic enough to cut on (autocorrelation peaked at r=0.26/0.30 and the
|
|
# "period" it found was not the grout pitch), and cropping to it made the seam WORSE — 27.9→31.9
|
|
# across x. So the crop is a proposal that has to beat the measurement it is trying to improve,
|
|
# per axis, or it is discarded. A tool that can only make a number worse is worse than no tool.
|
|
def try_axis(p, axis, full):
|
|
if not p or full // p < 2:
|
|
return full, None
|
|
return (full // p) * p, None
|
|
W, _ = try_axis(px, 1, im.width)
|
|
H, _ = try_axis(py, 0, im.height)
|
|
cand = im.crop(((im.width - W) // 2, (im.height - H) // 2,
|
|
(im.width - W) // 2 + W, (im.height - H) // 2 + H))
|
|
c = np.asarray(cand)
|
|
trial = (edge_err(c, 1), edge_err(c, 0))
|
|
keep_x, keep_y = trial[0] < before[0], trial[1] < before[1]
|
|
if not keep_x:
|
|
W = im.width
|
|
if not keep_y:
|
|
H = im.height
|
|
x0, y0 = (im.width - W) // 2, (im.height - H) // 2
|
|
out = im.crop((x0, y0, x0 + W, y0 + H))
|
|
b = np.asarray(out)
|
|
after = (edge_err(b, 1), edge_err(b, 0))
|
|
print(f"out {out.size} keep x={keep_x} y={keep_y} seam after x={after[0]:.1f} y={after[1]:.1f}"
|
|
+ ("" if (keep_x or keep_y) else " ← NO IMPROVEMENT AVAILABLE, passed through"))
|
|
out.save(dst)
|
|
if "--check" in sys.argv:
|
|
chk = sys.argv[sys.argv.index("--check") + 1]
|
|
t = Image.new("RGB", (out.width * 3, out.height * 3))
|
|
for i in range(3):
|
|
for j in range(3):
|
|
t.paste(out, (i * out.width, j * out.height))
|
|
t.resize((out.width, out.height), Image.LANCZOS).save(chk)
|
|
print(f"3x3 tiling check -> {chk}")
|
|
|
|
|
|
main()
|