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:
parent
34f7dae397
commit
30b86a9ef7
103
pipeline/arcade_measure.mjs
Normal file
103
pipeline/arcade_measure.mjs
Normal 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, '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.');
|
||||||
@ -104,6 +104,15 @@ FACADE_TYPES = {
|
|||||||
"market-fruit": ["stall"], "market-edge": ["stall"],
|
"market-fruit": ["stall"], "market-edge": ["stall"],
|
||||||
"warehouse-roller": ["pawn", "general"], "warehouse-tin": ["pawn", "general"],
|
"warehouse-roller": ["pawn", "general"], "warehouse-tin": ["pawn", "general"],
|
||||||
"arcade-entry": ["general"], # arcade portal, not a shop — Lane B places by key
|
"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)
|
# residential fronts for use:'house' lots (incl. the milkbar corner-shop's street)
|
||||||
"res-terrace": ["house"], "res-weatherboard": ["house"],
|
"res-terrace": ["house"], "res-weatherboard": ["house"],
|
||||||
"res-fibro": ["house"], "res-brickveneer": ["house"],
|
"res-fibro": ["house"], "res-brickveneer": ["house"],
|
||||||
@ -126,6 +135,19 @@ GROUND_USE = {
|
|||||||
# without a vocabulary change.
|
# without a vocabulary change.
|
||||||
"grass-dry": "yard", "grass-temperate": "yard", "coastal-sand": "yard",
|
"grass-dry": "yard", "grass-temperate": "yard", "coastal-sand": "yard",
|
||||||
"bitumen-reddust": "road",
|
"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 ───────────────────────────
|
# ── curated: interior floor / surface split of the tex-* skins ───────────────────────────
|
||||||
FLOOR_TEX = ["carpet-swirl", "carpet-mustard", "carpet-greygreen", "lino-check", "lino-cork",
|
FLOOR_TEX = ["carpet-swirl", "carpet-mustard", "carpet-greygreen", "lino-check", "lino-cork",
|
||||||
|
|||||||
@ -93,6 +93,42 @@ GROUNDS = { # slug (→ web/assets/gen/ground-<slug>.jpg) : subject
|
|||||||
"beach sand",
|
"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 = {
|
PROMPTS = {
|
||||||
# ── shop-type facades that the registry is thin on ──────────────────────────────────
|
# ── shop-type facades that the registry is thin on ──────────────────────────────────
|
||||||
**{f"facade-{k}": FACADE.format(v=v) for k, v in {
|
**{f"facade-{k}": FACADE.format(v=v) for k, v in {
|
||||||
@ -222,6 +258,24 @@ def harvest():
|
|||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
if "--harvest" in sys.argv:
|
if "--harvest" in sys.argv:
|
||||||
harvest(); sys.exit(0)
|
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)
|
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()
|
todo = {f"ground-{s}": GROUND.format(v=v) for s, v in GROUNDS.items()
|
||||||
if not have(f"ground-{s}")}
|
if not have(f"ground-{s}")}
|
||||||
|
|||||||
29
pipeline/meshgod_batch_r39.json
Normal file
29
pipeline/meshgod_batch_r39.json
Normal 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
101
pipeline/seamless_tile.py
Normal 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()
|
||||||
BIN
web/assets/gen/facade-shuttered.jpg
Normal file
BIN
web/assets/gen/facade-shuttered.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
BIN
web/assets/gen/ground-arcade-floor.jpg
Normal file
BIN
web/assets/gen/ground-arcade-floor.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 97 KiB |
BIN
web/assets/gen/ground-arcade-roof.jpg
Normal file
BIN
web/assets/gen/ground-arcade-roof.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
@ -304,6 +304,13 @@
|
|||||||
],
|
],
|
||||||
"signboard": "blank"
|
"signboard": "blank"
|
||||||
},
|
},
|
||||||
|
"shuttered": {
|
||||||
|
"file": "gen/facade-shuttered.jpg",
|
||||||
|
"types": [
|
||||||
|
"general"
|
||||||
|
],
|
||||||
|
"signboard": "blank"
|
||||||
|
},
|
||||||
"res-terrace": {
|
"res-terrace": {
|
||||||
"file": "gen/facade-res-terrace.jpg",
|
"file": "gen/facade-res-terrace.jpg",
|
||||||
"types": [
|
"types": [
|
||||||
@ -394,6 +401,14 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"ground": {
|
"ground": {
|
||||||
|
"arcade-floor": {
|
||||||
|
"file": "gen/ground-arcade-floor.jpg",
|
||||||
|
"use": "arcade-floor"
|
||||||
|
},
|
||||||
|
"arcade-roof": {
|
||||||
|
"file": "gen/ground-arcade-roof.jpg",
|
||||||
|
"use": "arcade-ceiling"
|
||||||
|
},
|
||||||
"asphalt1": {
|
"asphalt1": {
|
||||||
"file": "gen/ground-asphalt1.jpg",
|
"file": "gen/ground-asphalt1.jpg",
|
||||||
"use": "road"
|
"use": "road"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user