diff --git a/pipeline/arcade_measure.mjs b/pipeline/arcade_measure.mjs new file mode 100644 index 0000000..1af68f2 --- /dev/null +++ b/pipeline/arcade_measure.mjs @@ -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, '1–2 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, '1–2 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.'); diff --git a/pipeline/build_manifest.py b/pipeline/build_manifest.py index 2857d5c..e5f5924 100644 --- a/pipeline/build_manifest.py +++ b/pipeline/build_manifest.py @@ -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", diff --git a/pipeline/gen_skins.py b/pipeline/gen_skins.py index a185810..5d78096 100644 --- a/pipeline/gen_skins.py +++ b/pipeline/gen_skins.py @@ -93,6 +93,42 @@ GROUNDS = { # slug (→ web/assets/gen/ground-.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}")} diff --git a/pipeline/meshgod_batch_r39.json b/pipeline/meshgod_batch_r39.json new file mode 100644 index 0000000..60352b7 --- /dev/null +++ b/pipeline/meshgod_batch_r39.json @@ -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" + } + ] +} diff --git a/pipeline/seamless_tile.py b/pipeline/seamless_tile.py new file mode 100644 index 0000000..270e488 --- /dev/null +++ b/pipeline/seamless_tile.py @@ -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() diff --git a/web/assets/gen/facade-shuttered.jpg b/web/assets/gen/facade-shuttered.jpg new file mode 100644 index 0000000..fa8eb43 Binary files /dev/null and b/web/assets/gen/facade-shuttered.jpg differ diff --git a/web/assets/gen/ground-arcade-floor.jpg b/web/assets/gen/ground-arcade-floor.jpg new file mode 100644 index 0000000..38faa2b Binary files /dev/null and b/web/assets/gen/ground-arcade-floor.jpg differ diff --git a/web/assets/gen/ground-arcade-roof.jpg b/web/assets/gen/ground-arcade-roof.jpg new file mode 100644 index 0000000..4e7247e Binary files /dev/null and b/web/assets/gen/ground-arcade-roof.jpg differ diff --git a/web/assets/manifest.json b/web/assets/manifest.json index 1685959..64369d1 100644 --- a/web/assets/manifest.json +++ b/web/assets/manifest.json @@ -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"