Lane E R39 (2/n): THE ARCADE, MEASURED THEN DRESSED — 3 skins live ($0, on-device, green), and the instance count taken BEFORE generating

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>
This commit is contained in:
m3ultra 2026-08-03 20:46:43 +10:00
parent 34f7dae397
commit 30b86a9ef7
9 changed files with 324 additions and 0 deletions

103
pipeline/arcade_measure.mjs Normal file
View File

@ -0,0 +1,103 @@
#!/usr/bin/env node
// PROCITY Lane E — arcade_measure.mjs (R39, item 39.4)
//
// THE SIZING LAW, MADE RUNNABLE. R38 paid for the law with `paling_fence`: a perfectly good
// 1,121-tri panel became a REJECTED asset the moment Lane B counted the placement rule (5 runs ×
// 181 lots = 905 instances = 1.01 M triangles). The law is therefore: an asset's budget is
// `tris × the instance count the consuming lane's placement rule implies`, and you get that number
// BEFORE you generate. This script is that number for the arcade, so no later round has to trust a
// prose figure.
//
// node pipeline/arcade_measure.mjs [seed ...]
//
// Everything here is read out of Lane A's own `generatePlan` and Lane B's own constants; nothing
// is estimated. Constants mirrored from web/js/world/buildings.js are named at the point of use —
// if they move, this file is the second place to change (the R27 same-literal-in-two-files trap,
// declared rather than hidden).
import { generatePlan } from '../web/js/citygen/plan.js';
const AWNING_DEPTH = 2.2, AWNING_Y = 2.95, POST_INSET = 0.2; // buildings.js:21-22, :643
const FOOT = 3.5; // ground.js:14
const seeds = process.argv.slice(2).length ? process.argv.slice(2).map(Number)
: [20261990, 7, 123456, 999, 20260101];
const rows = [];
for (const s of seeds) {
const plan = generatePlan(s);
const arc = plan.districts.find((d) => d.kind === 'arcade');
const nodeById = new Map(plan.streets.nodes.map((n) => [n.id, n]));
const aEdges = plan.streets.edges.filter((e) => e.kind === 'arcade');
const len = aEdges.reduce((t, e) => {
const a = nodeById.get(e.a), b = nodeById.get(e.b);
return t + Math.hypot(b.x - a.x, b.z - a.z);
}, 0);
const width = aEdges[0].width;
const aBlocks = plan.blocks.filter((b) => b.district === arc.id);
const bIds = new Set(aBlocks.map((b) => b.id));
const lots = plan.lots.filter((l) => bIds.has(l.block));
const lotIds = new Set(lots.map((l) => l.id));
const shops = plan.shops.filter((sh) => lotIds.has(sh.lot));
const byType = {};
for (const sh of shops) byType[sh.type] = (byType[sh.type] || 0) + 1;
const skins = new Set(plan.shops.map((sh) => String(sh.facadeSkin).replace(/\.jpe?g$/, '').replace(/^facade-/, '')));
rows.push({
seed: s, len: +len.toFixed(2), width, blocks: aBlocks.length, lots: lots.length,
shops: shops.length, byType,
frontage: [Math.min(...lots.map((l) => l.w)), Math.max(...lots.map((l) => l.w))],
meanFrontage: lots.reduce((t, l) => t + l.w, 0) / lots.length,
depth: [Math.min(...lots.map((l) => l.d)), Math.max(...lots.map((l) => l.d))],
twoStorey: shops.filter((sh) => sh.storeys >= 2).length,
townShops: plan.shops.length, townLots: plan.lots.length,
distinctSkins: skins.size,
unmarked: Math.max(2, Math.min(12, Math.round(plan.shops.length * 0.06))),
});
}
const f = (n, d = 2) => Number(n).toFixed(d);
console.log('THE ARCADE, MEASURED (synthetic only — plan_osm.js:305 is one unconditional');
console.log('addDistrict("mainstreet"), so no cache town has an arcade DISTRICT. Real towns do have');
console.log('1,102 arcade-KIND edges = 39 km of ordinary suburban footpath: key any arcade dressing');
console.log('on district.kind, never on edge.kind. That is the v9 cut list as a code rule.\n');
console.log('seed lane blocks lots shops 2-storey frontage(m) depth(m) town shops skins unmarked');
for (const r of rows) {
console.log(`${String(r.seed).padEnd(11)} ${f(r.len)} m × ${r.width} m ${r.blocks} ${String(r.lots).padStart(2)} ${String(r.shops).padStart(2)} ${String(r.twoStorey).padStart(2)} `
+ `${f(r.frontage[0])}${f(r.frontage[1])}${f(r.meanFrontage)}) ${f(r.depth[0])}${f(r.depth[1])} ${String(r.townShops).padStart(4)} ${r.distinctSkins} ${r.unmarked}`);
}
const sh = rows.map((r) => r.shops);
console.log(`\nSHOPS PER ARCADE over ${rows.length} seeds: ${Math.min(...sh)}${Math.max(...sh)} (charter says 17; seed 20261990 = ${rows[0].shops})`);
console.log('types seen:', JSON.stringify(rows.reduce((a, r) => { for (const [k, v] of Object.entries(r.byType)) a[k] = (a[k] || 0) + v; return a; }, {})));
// ── the geometry the dressing has to live inside ────────────────────────────────────────────────
const half = rows[0].width / 2;
console.log('\nTHE LANE, from Lane B\'s own constants:');
console.log(` lot faces stand at ±${f(half)} m from the centreline ⇒ WALKABLE ${f(half * 2)} m between shopfronts`);
console.log(` buildings.js gives every use:'shop' lot an awning ${AWNING_DEPTH} m deep at y=${AWNING_Y} m, so the two`);
console.log(` slabs reach to ±${f(half - AWNING_DEPTH)} m ⇒ THE ARCADE IS ALREADY ROOFED EXCEPT A ${f((half - AWNING_DEPTH) * 2)} m SLOT`);
console.log(` down the centreline. Ceiling height is ${AWNING_Y} m — a hanging sign must clear EYE 1.62 and sit under it.`);
console.log(` ⚠ THE POSTS ARE IN THE WRONG PLACE. buildings.js:643 plants the two awning posts at`);
console.log(` d/2 + ${AWNING_DEPTH} ${POST_INSET} = ${f(AWNING_DEPTH - POST_INSET)} m out from the lot face, i.e. ±${f(half - (AWNING_DEPTH - POST_INSET))} m from the centreline —`);
console.log(` TWO ROWS OF POSTS ${f((half - (AWNING_DEPTH - POST_INSET)) * 2)} m APART DOWN THE MIDDLE OF A ${f(half * 2)} m LANE. They carry no collider`);
console.log(' (colliders.push(lotCollider(lot)) only), so the lane is walkable and the charter\'s 4.99 m');
console.log(' stands — but visually the arcade is a colonnade planted in its own doorway. AWNING_DEPTH');
console.log(' 2.2 was sized for a 3.5 m FOOT band, not a 2.5 m half-lane. → Lane B ask, see LANE_E_NOTES.');
console.log(` ground.js pave: the arcade branch emits ONE footpath quad ${f(rows[0].len)} × ${f(rows[0].width + FOOT * 2)} m`);
console.log(` = ${f(rows[0].len * (rows[0].width + FOOT * 2), 0)} m² — the single biggest surface in the district, and it wears footSkin today.`);
// ── the budget line every arcade asset has to answer ────────────────────────────────────────────
console.log('\nINSTANCE COUNTS THE CONSUMING LANE IMPLIES (the number to generate against):');
const n = rows[0].shops;
const rows2 = [
['hanging blade sign', 'one per arcade shop', n, 'synthetic only'],
['shuttered facade skin', 'the dead tenant(s)', 1, '12 of the arcade\'s shops'],
['arcade floor skin', 'one merged ground class', 1, 'one town-wide mesh'],
['arcade roof/ceiling', 'one quad over the lane', 1, '12 chunks hold arcade lots'],
['A-frame board', 'arcade ~4 + v9 unmarked cue', 4 + rows[0].unmarked, `${4 + rows[0].unmarked}/town (unmarked = clamp(round(shops×0.06),2,12))`],
['key-cutter sign', 'the arcade\'s one trade sign', 1, 'landmark, not a repeat'],
];
for (const [what, rule, cnt, note] of rows2) {
console.log(` ${what.padEnd(22)} ${String(cnt).padStart(2)} × ${rule.padEnd(28)} ${note}`);
}
console.log('\nA GLB CANNOT RIDE boxMats (BoxGeometry + one shared untextured shell material), so each');
console.log('distinct GLB geometry is +1 draw. Ride it as ONE TOWN-WIDE InstancedMesh (magpie.js\'s');
console.log('pattern, count 0 or 1..N) and it is +1 total; make it a per-chunk furniture class and it is');
console.log('+1 × up to 25 live chunks, which at 8 draws of margin is a breach, not a cost.');

View File

@ -104,6 +104,15 @@ FACADE_TYPES = {
"market-fruit": ["stall"], "market-edge": ["stall"],
"warehouse-roller": ["pawn", "general"], "warehouse-tin": ["pawn", "general"],
"arcade-entry": ["general"], # arcade portal, not a shop — Lane B places by key
# ── R39 / v9 39.4 — the arcade's dead tenant. PLACED BY KEY, never drawn from a type pool:
# `shuttered` is deliberately absent from registry.js's SHOP_TYPES[*].facades, so no shop can
# roll it, and a shop that is OPEN must never wear a shutter. It is shot at 768x595 (1.29:1),
# the measured arcade lot aspect (mean frontage 3.87-4.05 m against FACADE_H 3.0), not the
# main-street pool's 1.78:1 — skins.js paints every facade into a SQUARE atlas slot, so a
# 1.78:1 source on a 1.29:1 quad is a 38% horizontal stretch. Costs +0 draws, +0 tris and one
# of the 14 FREE facade-atlas slots (the synthetic uses 22 of 36; skins.js:facadeAtlasUV warns
# and REUSES a slot past 36 — that is the real budget for a facade skin, not bytes).
"shuttered": ["general"],
# residential fronts for use:'house' lots (incl. the milkbar corner-shop's street)
"res-terrace": ["house"], "res-weatherboard": ["house"],
"res-fibro": ["house"], "res-brickveneer": ["house"],
@ -126,6 +135,19 @@ GROUND_USE = {
# without a vocabulary change.
"grass-dry": "yard", "grass-temperate": "yard", "coastal-sand": "yard",
"bitumen-reddust": "road",
# ── R39 / v9 39.4 — THE ARCADE'S TWO SURFACES. These two `use` values name slots that DO NOT
# EXIST YET, and unlike gravel→"verge" / reddust→"outback" (R38: selected by nothing, by
# accident) that is DELIBERATE and costed. The arcade cannot ride an existing slot: `footpath`
# is one town-wide merged mesh, so re-using it would lay terrazzo over every footpath in town.
# Each therefore needs its own class, and the price is stated:
# arcade-floor +1 draw TOWN-WIDE (ground.js builds once, not per chunk: a 6th merged mesh),
# +0 tris — the 42.00 x 12.00 m = 504 m2 lane quad MOVES out of footGeos.
# arcade-ceiling +1 draw in the 1-2 chunks that hold arcade lots, +2 tris (one 42 x 5 m quad).
# BOTH are synthetic-only by construction: plan_osm.js:305 is one unconditional
# addDistrict('mainstreet'), so no cache town has an arcade DISTRICT. Key on district.kind and
# never on edge.kind — the corpus has 1,102 arcade-KIND edges = 39 km of ordinary suburban
# footpath (v9 cut list). And both must be classic-gated: ?classic=1 does not move on dressing.
"arcade-floor": "arcade-floor", "arcade-roof": "arcade-ceiling",
}
# ── curated: interior floor / surface split of the tex-* skins ───────────────────────────
FLOOR_TEX = ["carpet-swirl", "carpet-mustard", "carpet-greygreen", "lino-check", "lino-cork",

View File

@ -93,6 +93,42 @@ GROUNDS = { # slug (→ web/assets/gen/ground-<slug>.jpg) : subject
"beach sand",
}
# ── round-39 (v9 39.4): THE ARCADE'S SKINS ──────────────────────────────────────────────────────
# The synthetic's arcade is a 42.00 m covered lane, 16-17 shops, 2 blocks, already awning-roofed by
# accident (buildings.js AWNING_DEPTH 2.2 from each lot face, lane half-width 2.5 ⇒ the two slabs
# meet 0.6 m apart over the centreline) and dressed as NOTHING. These three are the cheapest three
# surfaces in it, in ascending draw cost:
# facade-shuttered +0 draws, +0 tris — one of the 14 FREE facade-atlas slots (the synthetic uses
# 22 of 36; skins.js:facadeAtlasUV warns and reuses a slot past 36)
# ground-arcade-tile +1 draw TOWN-WIDE (a 6th merged ground class; ground.js builds once, not per
# chunk), +0 tris — the lane quad moves out of footGeos into its own class
# tex-arcade-roof +1 draw in the 1-2 chunks that hold arcade lots, +2 tris (one 42x5 m quad)
# Aspect is not decoration: skins.js paints every facade into a SQUARE 341 px atlas slot, and arcade
# lots are 3.87 x 3.0 m ⇒ ~1.29:1, not the 1.78:1 the main-street pool is shot at. `facade-shuttered`
# is generated 4:3 so it lands on an arcade lot almost undistorted (1024x768 -> square -> 1.29:1 quad
# ≈ 1.03 net). Recorded, not retro-fitted to the other 48.
CEILING = ("Straight-up overhead photograph of {v}, camera pointing vertically at the ceiling, "
"completely flat-on with no perspective, evenly lit with no glare, no objects hanging "
"down, a uniform repeating surface texture filling the entire frame edge to edge")
SHUT = ("Straight-on photograph of a CLOSED and vacant small Australian shop front, {v}, no glass, "
"no people, no text or lettering anywhere, overcast daylight, flat front elevation filling "
"the entire frame edge to edge, nothing else visible")
ARCADE = {
# slug : (template, subject, width, height, style)
"ground-arcade-tile": (GROUND,
"the tiled floor of a 1970s Australian shopping arcade, small terrazzo and mosaic floor "
"tiles in beige, terracotta and cream laid in a simple repeating geometric pattern, "
"grouted joints, polished and worn smooth by foot traffic", 1024, 1024, "GROUND_STYLE"),
"tex-arcade-roof": (CEILING,
"the underside of a covered shopping arcade roof, cream pressed-metal ceiling panels in a "
"repeating square pattern between painted steel beams, with panels of ribbed wired glass "
"letting grey daylight through", 1024, 1024, "GROUND_STYLE"),
"facade-shuttered": (SHUT,
"a corrugated steel roller shutter pulled all the way down over the whole shopfront, faded "
"cream paint with rust streaks and scuff marks along the bottom, a blank empty signboard "
"above it, a scuffed tiled stall-riser at the base", 1024, 768, None),
}
PROMPTS = {
# ── shop-type facades that the registry is thin on ──────────────────────────────────
**{f"facade-{k}": FACADE.format(v=v) for k, v in {
@ -222,6 +258,24 @@ def harvest():
if __name__ == "__main__":
if "--harvest" in sys.argv:
harvest(); sys.exit(0)
if "--arcade" in sys.argv: # round-39 arcade kit (local only) — see ARCADE above
todo = {s: v for s, v in ARCADE.items() if not have(s)}
print(f"{len(todo)} arcade skins (MODELBEAST flux2-klein-4b, local·free)")
if "--dry-run" in sys.argv:
for s in sorted(todo):
print(f" {s} {todo[s][2]}x{todo[s][3]}")
sys.exit(0)
if not local_available():
print(f"ERROR: MODELBEAST flux_local missing ({FLUX_RUN})."); sys.exit(2)
for i, (slug, (tpl, subj, w, h, sty)) in enumerate(sorted(todo.items()), 1):
try:
fn = gen_local(slug, tpl.format(v=subj), w=w, h=h, seed=39,
style=GROUND_STYLE if sty == "GROUND_STYLE" else None)
print(f"[{i}/{len(todo)}] {slug} {w}x{h} ({os.path.getsize(fn)//1024}KB)")
except Exception as e:
print(f"[{i}/{len(todo)}] {slug} FAILED: {str(e)[:90]}")
print("done. Review .genraw/, then --harvest → web/assets/gen/.")
sys.exit(0)
if "--grounds" in sys.argv: # round-38 ground palette (square tile, local only)
todo = {f"ground-{s}": GROUND.format(v=v) for s, v in GROUNDS.items()
if not have(f"ground-{s}")}

View File

@ -0,0 +1,29 @@
{
"_comment": "ROUND 39 (v9 39.4) THE ARCADE'S KIT for pipeline/gen_props.py --batch. The synthetic town's arcade is a 42.00 m covered lane, ONE edge of kind 'arcade' at width 5, 2 blocks, 16-17 shops (measured over 5 seeds: 17 @ 20261990, 16 @ 7/123456/999, 17 @ 20260101), mean frontage 3.87-4.05 m, depth 5.1-8.0 m. Path: flux_local -> bg_remove_local -> trellis2_mlx -> normalize.py -> bake_lowpoly.py. $0, on-device, no cloud key. GENERATE NOW, WIRE LATER (R39 brief): nothing here is on a lane's critical path this round.",
"_sizing_law": "R38's law, applied BEFORE generating: budget = tris x the instance count the placement rule implies, and can it ride an existing merged list? Measured counts from web/js/citygen/plan.js via pipeline/arcade_measure.mjs. blade-sign: 1 per arcade shop = 17, synthetic only. aframe-board: ~4 in the arcade PLUS v9 Layer 4's unmarked-shop cue at clamp(round(shops*0.06),2,12) = up to 12/town => size for <=16. keycutter-sign: 1. A GLB cannot ride boxMats (BoxGeometry, one shared untextured shell material), so each of these is +1 TOWN-WIDE InstancedMesh draw against the 8-draw margin - one per geometry, not one per chunk. State the draw before wiring.",
"_thin_structure_guard": "R38 proved thin structures are NOT a TRELLIS class (hills_hoist wire -> thousands of disconnected fragments; a voxel remesh coarse enough for 600 tris erases the pole). So every prompt here is deliberately CHUNKY: the blade sign is a boxy perspex light-box on a stub, not a plate on a wrought-iron arm; the key sign is a thick solid cut-out slab, not a wire silhouette. The bracket arms are Lane B's boxMats primitives at +0 draws.",
"_no_baked_text": "House law since v1: the game overlays the name, the asset never bakes it. Every sign face here is BLANK.",
"assets": [
{
"name": "aframe-board",
"prompt": "a two-sided A-frame sandwich board sign standing open on a footpath, two thick hinged plywood panels leaning against each other, painted bright chipped yellow around a plain blank white sign face on each panel, a short chain between the legs, a cheap discount-shop pavement sign, whole object from feet to top hinge",
"height_m": 0.9,
"tris": 400,
"for": "39.4 the arcade's two-dollar shop AND v9 Layer 4's unmarked-shop cue (<=16 instances/town)"
},
{
"name": "blade-sign",
"prompt": "a boxy rectangular illuminated perspex shop sign that projects out from a wall, a thick white light-box with a bright red plastic frame and completely blank empty faces, mounted on a short square stub at the back, a 1970s Australian arcade projecting sign, whole object",
"height_m": 0.55,
"tris": 400,
"for": "39.4 the arcade's hanging shopfront signage (17 instances) - SEE the +0-draw alternative in LANE_E_NOTES before wiring this"
},
{
"name": "keycutter-sign",
"prompt": "a large thick flat shop sign cut out in the shape of an old-fashioned door key, a solid chunky slab silhouette of a key with a round scalloped bow and a simple square-toothed bit, painted worn brass gold with a dark outline, a key cutter's trade sign, whole object, no lettering",
"height_m": 0.6,
"tris": 500,
"for": "39.4 the arcade's one landmark trade sign (1 instance) - lowest value per draw in the kit, see the ranking"
}
]
}

101
pipeline/seamless_tile.py Normal file
View File

@ -0,0 +1,101 @@
#!/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()

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View File

@ -304,6 +304,13 @@
],
"signboard": "blank"
},
"shuttered": {
"file": "gen/facade-shuttered.jpg",
"types": [
"general"
],
"signboard": "blank"
},
"res-terrace": {
"file": "gen/facade-res-terrace.jpg",
"types": [
@ -394,6 +401,14 @@
}
],
"ground": {
"arcade-floor": {
"file": "gen/ground-arcade-floor.jpg",
"use": "arcade-floor"
},
"arcade-roof": {
"file": "gen/ground-arcade-roof.jpg",
"use": "arcade-ceiling"
},
"asphalt1": {
"file": "gen/ground-asphalt1.jpg",
"use": "road"