diff --git a/TODO.md b/TODO.md index e6303cc..d256230 100644 --- a/TODO.md +++ b/TODO.md @@ -393,10 +393,14 @@ facade evidence is still the 2009 CC BY frame. The 8 downloaded candidates are i - [ ] Run the assisted Blender pipeline end to end on the first block (OSM import → `inspect` → retopo/UV assist → atlas → `audit`), and document the exact commands in `docs/asset-tooling.md`. -- [ ] Building-appearance realism pass, measured against the reference set: bring the masonic +- [-] Building-appearance realism pass, measured against the reference set: bring the masonic facade's material response (limestone value/roughness, joint lines, weathering streaks), the banner/canopy and marquee materials, and the night emissive balance to the level of the 2009 facade photo and the 2026 street frames; capture before/after and record each delta. + (Ashlar field landed: `limestone_ashlar` tile generated reference-anchored from the facade + photo, 768 px over ~4.6 m of wall = ~167 px/m, promoted via the picks pipeline; facade + field + tint warm-shift live behind `VENUE_SPECS`, recapture committed and the day board + rebuilt. Still open: night emissive balance and banner/canopy materials.) - [ ] Upgrade the facade from flat material bands to photoreal detail: either texture-projected detail (Path B below) or AI-generated material tiles derived from the facade photo (with provenance), then verify texel density, tiling and the night look. diff --git a/assets/textures/ai/game_ready/v2/picks/limestone_ashlar.png b/assets/textures/ai/game_ready/v2/picks/limestone_ashlar.png new file mode 100644 index 0000000..380dc23 Binary files /dev/null and b/assets/textures/ai/game_ready/v2/picks/limestone_ashlar.png differ diff --git a/assets/textures/ai/game_ready/v2/picks/limestone_ashlar_normal.png b/assets/textures/ai/game_ready/v2/picks/limestone_ashlar_normal.png new file mode 100644 index 0000000..285918c Binary files /dev/null and b/assets/textures/ai/game_ready/v2/picks/limestone_ashlar_normal.png differ diff --git a/docs/hollywood-boulevard-map.md b/docs/hollywood-boulevard-map.md index 190c257..9f3b3fc 100644 --- a/docs/hollywood-boulevard-map.md +++ b/docs/hollywood-boulevard-map.md @@ -73,6 +73,10 @@ not texture or geometry sources. - Local lights use linear attenuation (`omni_attenuation = 1.0`). Godot treats `0.0` as constant falloff, which previously turned the night facade into a flat yellow wall; the sconces now pool light instead. +- The masonic facade field uses the `limestone_ashlar` pick: a seamless ashlar + tile generated reference-anchored on the 2009 facade photo (CC BY 2.0), + 768 px over ~4.6 m of wall (~167 px/m), so the wall carries course joints and + per-block value variation instead of flat plaster. ## References diff --git a/docs/screens/boards/README.md b/docs/screens/boards/README.md index 183abc2..40f9cc3 100644 --- a/docs/screens/boards/README.md +++ b/docs/screens/boards/README.md @@ -13,6 +13,6 @@ attribution lives in the caption and the [refs sidecars](../refs/README.md)). | Board | Caption | | --- | --- | -| ![three-up board: elcapitan day](elcapitan-day-board.jpg) | El Capitan facade, day. Photo: [CC BY 2.0, woo, 2009](https://commons.wikimedia.org/wiki/File:El_Capitan_Theater_frontal_fachade-The_Masonic_Temple.jpg) via `refs/hollywood`. Concept: photo-anchored generation. Capture: current build. | +| ![three-up board: elcapitan day](elcapitan-day-board.jpg) | El Capitan facade, day. Photo: [CC BY 2.0, woo, 2009](https://commons.wikimedia.org/wiki/File:El_Capitan_Theater_frontal_fachade-The_Masonic_Temple.jpg) via `refs/hollywood`. Concept: photo-anchored generation. Capture: current build with the ashlar limestone field (`limestone_ashlar`, anchored on the same photo). | | ![three-up board: elcapitan night](elcapitan-night-board.jpg) | El Capitan block, night. Photo: CC BY-SA 3.0 (reviewed; redistribution only, no derived assets) via `refs/hollywood`. Concept: photo-anchored night restage. Capture: current build. | | ![three-up board: jokeshop day](jokeshop-day-board.jpg) | Joke Shop frontage, day. Photo: public domain (2006) via `refs/joke-shop`. Concept: generated. Capture: current build. | diff --git a/docs/screens/boards/elcapitan-day-board.jpg b/docs/screens/boards/elcapitan-day-board.jpg index a6bfd19..d47c2a8 100644 Binary files a/docs/screens/boards/elcapitan-day-board.jpg and b/docs/screens/boards/elcapitan-day-board.jpg differ diff --git a/docs/screens/boards/elcapitan-day-board.json b/docs/screens/boards/elcapitan-day-board.json index f438643..7521a28 100644 --- a/docs/screens/boards/elcapitan-day-board.json +++ b/docs/screens/boards/elcapitan-day-board.json @@ -1,6 +1,6 @@ { "board": "elcapitan-day-board.jpg", - "caption": "El Capitan facade, day: 2009 CC BY photo | photo-anchored concept | current capture", + "caption": "El Capitan facade, day. Photo: CC BY 2.0 2009 via refs/hollywood. Concept: photo-anchored. Capture: ashlar limestone pass (limestone_ashlar, 768 px tile, ~167 px/m).", "photo": "docs/screens/refs/hollywood/el-capitan-facade-day-wikimedia-2009-10-27.jpg", "concept": "docs/screens/concepts/hollywood_boulevard/hollywood_boulevard_entrance_day-v2.png", "capture": "docs/screenshots/01-venue-overview.png" diff --git a/docs/screenshots/01-venue-overview.png b/docs/screenshots/01-venue-overview.png index dbc1ca2..9615035 100644 Binary files a/docs/screenshots/01-venue-overview.png and b/docs/screenshots/01-venue-overview.png differ diff --git a/docs/screenshots/02-targeting-npc.png b/docs/screenshots/02-targeting-npc.png index bf61bd0..7434ad6 100644 Binary files a/docs/screenshots/02-targeting-npc.png and b/docs/screenshots/02-targeting-npc.png differ diff --git a/docs/screenshots/05-late-night-avenue.png b/docs/screenshots/05-late-night-avenue.png index 263d3f6..61ace1d 100644 Binary files a/docs/screenshots/05-late-night-avenue.png and b/docs/screenshots/05-late-night-avenue.png differ diff --git a/docs/screenshots/06-night-flashlight.png b/docs/screenshots/06-night-flashlight.png index a2b0269..9d26b36 100644 Binary files a/docs/screenshots/06-night-flashlight.png and b/docs/screenshots/06-night-flashlight.png differ diff --git a/docs/screenshots/08-escalation-hud.png b/docs/screenshots/08-escalation-hud.png index 97b0385..fa1e770 100644 Binary files a/docs/screenshots/08-escalation-hud.png and b/docs/screenshots/08-escalation-hud.png differ diff --git a/docs/screenshots/09-body-camera-timeline.png b/docs/screenshots/09-body-camera-timeline.png index 3d01e53..41ab879 100644 Binary files a/docs/screenshots/09-body-camera-timeline.png and b/docs/screenshots/09-body-camera-timeline.png differ diff --git a/docs/screenshots/11-precision-walk.png b/docs/screenshots/11-precision-walk.png index b414f34..7b77edf 100644 Binary files a/docs/screenshots/11-precision-walk.png and b/docs/screenshots/11-precision-walk.png differ diff --git a/docs/screenshots/12-joke-shop.png b/docs/screenshots/12-joke-shop.png index fe02156..f83f616 100644 Binary files a/docs/screenshots/12-joke-shop.png and b/docs/screenshots/12-joke-shop.png differ diff --git a/docs/screenshots/13-studio-arrival.png b/docs/screenshots/13-studio-arrival.png index 8edb9e1..817cad0 100644 Binary files a/docs/screenshots/13-studio-arrival.png and b/docs/screenshots/13-studio-arrival.png differ diff --git a/docs/screenshots/19-radial-menu.png b/docs/screenshots/19-radial-menu.png index 0c5689a..632e81a 100644 Binary files a/docs/screenshots/19-radial-menu.png and b/docs/screenshots/19-radial-menu.png differ diff --git a/docs/screenshots/20-crowd-panic.png b/docs/screenshots/20-crowd-panic.png index 2b52df6..7454975 100644 Binary files a/docs/screenshots/20-crowd-panic.png and b/docs/screenshots/20-crowd-panic.png differ diff --git a/docs/screenshots/21-responder-arrival.png b/docs/screenshots/21-responder-arrival.png index 88611e4..a97b994 100644 Binary files a/docs/screenshots/21-responder-arrival.png and b/docs/screenshots/21-responder-arrival.png differ diff --git a/src/game/materials.gd b/src/game/materials.gd index c57ec3a..9acce1d 100644 --- a/src/game/materials.gd +++ b/src/game/materials.gd @@ -28,6 +28,14 @@ const T_CONCRETE_NORMAL := preload(PICK_DIR + "sidewalk_pavers_normal.png") const T_ASPHALT_NORMAL := preload(PICK_DIR + "asphalt_normal.png") const T_ASPHALT_WET_NORMAL := preload(PICK_DIR + "asphalt_wet_normal.png") const T_LIMESTONE_NORMAL := preload(PICK_DIR + "stone_steps_normal.png") +## Ashlar limestone for the masonic temple field wall: large dressed blocks with +## thin course joints, generated against the licensed facade photo (the +## `stone_steps` tile reads as small pavers, not wall ashlar). +## Ashlar limestone for the masonic temple field wall: large dressed blocks with +## thin course joints, generated against the licensed facade photo (the +## `stone_steps` tile reads as small pavers, not wall ashlar). +const T_ASHLAR := preload(PICK_DIR + "limestone_ashlar.png") +const T_ASHLAR_NORMAL := preload(PICK_DIR + "limestone_ashlar_normal.png") ## Roughness / metallic pairs per surface role. Exposed so tests and tooling can ## assert the profiles instead of hard-coding numbers twice. @@ -107,6 +115,9 @@ static func concrete(tint: Color = Color(1, 1, 1), uv_scale: Vector2 = Vector2(9 static func limestone(tint: Color = Color(1, 1, 1), uv_scale: Vector2 = Vector2(6, 3)) -> StandardMaterial3D: return textured(T_LIMESTONE, tint, uv_scale, "stone", T_LIMESTONE_NORMAL) +static func ashlar(tint: Color = Color(1, 1, 1), uv_scale: Vector2 = Vector2(6, 3)) -> StandardMaterial3D: + return textured(T_ASHLAR, tint, uv_scale, "stone", T_ASHLAR_NORMAL) + static func asphalt(tint: Color = Color(1, 1, 1), uv_scale: Vector2 = Vector2(30, 7), wet: bool = false) -> StandardMaterial3D: var texture: Texture2D = T_ASPHALT_WET if wet else T_ASPHALT diff --git a/src/game/world_builder.gd b/src/game/world_builder.gd index 6f68590..d959493 100644 --- a/src/game/world_builder.gd +++ b/src/game/world_builder.gd @@ -9,6 +9,7 @@ const T_CONCRETE := preload(PICK_DIR + "sidewalk_pavers.png") const T_ASPHALT := preload(PICK_DIR + "asphalt.png") const T_AWNING := preload(PICK_DIR + "awning_canvas.png") const T_LIMESTONE := preload(PICK_DIR + "stone_steps.png") +const T_ASHLAR := preload(PICK_DIR + "limestone_ashlar.png") ## One skyline panorama per shift phase (realism pass): hazy daylight, warm dusk ## with lit windows, and a night city. The old single strip was a black silhouette. const T_SKYLINE_DAY := preload(PICK_DIR + "skyline_day.png") @@ -86,8 +87,10 @@ const VENUE_SPECS := { ## a recessed central entrance and two street-level side doors. "architecture": "masonic", "facade_width": 29.6, "facade_height": 14.5, "facade_depth": 8.0, - "facade_center_z": -8.0, "facade_texture": T_LIMESTONE, - "facade_tint": Color(0.88, 0.86, 0.82), "facade_role": "stone", + "facade_center_z": -8.0, "facade_texture": T_ASHLAR, + # Photo target: warm cream limestone, mid value ~0.78 (the old tint was + # a flat cool 0.88 that read as plaster in the day capture). + "facade_tint": Color(0.85, 0.81, 0.74), "facade_role": "stone", "window_columns": [], "window_rows": [], "stair_width": 12.0, "stair_count": 8, "stair_tread": 0.38, "stair_height": 0.10, "landing_depth": 4.0, "landing_center_z": -1.8, @@ -929,8 +932,10 @@ func _build_masonic_building() -> void: # Main stone mass. The real building is brick/concrete clad in a light # limestone-like render; granite/limestone keeps the block consistent with # the existing PBR surface library. + # Ashlar courses are ~0.55 m tall: the 768 px tile covers ~4.4 m of wall + # height, so the facade repeats it 3.2× vertically and 6.4× across. var facade := _add_box(building, Vector3(w, h, d), Vector3(0, h * 0.5, cz), - stone, false, T_LIMESTONE, Vector2(w * 0.22, h * 0.18), "stone") + stone, false, T_ASHLAR, Vector2(w * 0.216, h * 0.22), "stone") facade.name = "MasonicFacade" # Rusticated base and string courses. _add_box(building, Vector3(w + 0.3, 1.0, d + 0.3), Vector3(0, 0.5, cz + 0.12), diff --git a/tools/generate_textures.py b/tools/generate_textures.py index f636293..a787882 100644 --- a/tools/generate_textures.py +++ b/tools/generate_textures.py @@ -539,9 +539,21 @@ def _fit(img: Image.Image, max_dim: int = MAX_DIM) -> Image.Image: def generate_ai_textures(prompt: str, name: str, model: str, size: int, - transparent: bool = False) -> None: - """Generate one texture via OpenRouter and save it under assets/textures/.""" - img = generate_image(prompt, model, size, transparent) + transparent: bool = False, + reference_path=None) -> None: + """Generate one texture via OpenRouter and save it under assets/textures/. + + With `reference_path`, the request is image-to-image (licence-cleared + reference photo anchoring value and detail); the returned frame is then + centre-cropped to the requested square so tile wraps stay predictable. + """ + img = generate_image(prompt, model, size, transparent, + reference_path=reference_path) + if reference_path is not None: + side = min(img.size) + left = (img.width - side) // 2 + top = (img.height - side) // 2 + img = img.crop((left, top, left + side, top + side)) img = _fit(img) if transparent: img = img.convert("RGBA") @@ -556,7 +568,11 @@ def _run_ai(args: argparse.Namespace) -> None: if args.prompt: if not args.name: sys.exit("error: --name is required when --prompt is given") - generate_ai_textures(args.prompt, args.name, args.model, args.size) + if bool(args.reference) != bool(args.reference_licence): + sys.exit("error: --reference and --reference-licence must be given " + "together (the licence note records the sidecar clearance)") + generate_ai_textures(args.prompt, args.name, args.model, args.size, + reference_path=args.reference or None) return for name, prompt, transparent in AI_TEXTURES: print(f"generating {name} ...") @@ -579,6 +595,14 @@ def _build_arg_parser() -> argparse.ArgumentParser: parser.add_argument( "--model", default=DEFAULT_IMAGE_MODEL, help=f"image model slug (default: {DEFAULT_IMAGE_MODEL})") + parser.add_argument( + "--reference", default="", + help="licence-cleared photo (listed in a refs-board SIDECAR.md) used " + "as an image-to-image anchor; requires --reference-licence") + parser.add_argument( + "--reference-licence", default="", + help="licence label from the photo's sidecar row, recorded in the " + "generation provenance") parser.add_argument( "--size", type=int, default=GEN_SIZE, help=f"square size requested from the API in px (min {GEN_SIZE}); " diff --git a/tools/scene_concepts.py b/tools/scene_concepts.py index f0d6ccf..f8c22d7 100644 --- a/tools/scene_concepts.py +++ b/tools/scene_concepts.py @@ -230,6 +230,7 @@ PICKS = { "skyline_night": "skyline_night-v4", "brick_facade": "brick_facade", "stone_steps": "stone_steps-v2", + "limestone_ashlar": "limestone_ashlar", "sidewalk_pavers": "sidewalk_pavers", "asphalt": "asphalt", "asphalt_wet": "asphalt_wet", diff --git a/tools/test_generate_textures.py b/tools/test_generate_textures.py new file mode 100644 index 0000000..34c0191 --- /dev/null +++ b/tools/test_generate_textures.py @@ -0,0 +1,52 @@ +"""Tests for the reference-anchored texture path (no network).""" + +import unittest +from unittest import mock + +from PIL import Image + +import generate_textures as gt + + +class ReferenceGateTests(unittest.TestCase): + def test_reference_without_licence_note_is_refused(self): + for argv in (["--ai", "--prompt", "x", "--name", "a.png", + "--reference", "r.jpg"], + ["--ai", "--prompt", "x", "--name", "a.png", + "--reference-licence", "CC BY 2.0"]): + with mock.patch.object(gt, "generate_ai_textures") as gen: + with self.assertRaises(SystemExit): + gt._run_ai(gt._build_arg_parser().parse_args(argv)) + gen.assert_not_called() + + def test_reference_pair_calls_with_the_path(self): + argv = ["--ai", "--prompt", "x", "--name", "a.png", + "--reference", "r.jpg", "--reference-licence", "CC BY 2.0"] + with mock.patch.object(gt, "generate_ai_textures") as gen: + gt._run_ai(gt._build_arg_parser().parse_args(argv)) + gen.assert_called_once_with("x", "a.png", gt.DEFAULT_IMAGE_MODEL, + gt.GEN_SIZE, reference_path="r.jpg") + + +class ReferenceCropTests(unittest.TestCase): + def test_reference_results_are_cropped_square_before_save(self): + wide = Image.new("RGB", (1344, 768), (10, 20, 30)) + with mock.patch.object(gt, "generate_image", return_value=wide) as gen, \ + mock.patch.object(gt, "save") as saved: + gt.generate_ai_textures("p", "t.png", "m", 1024, + reference_path="r.jpg") + gen.assert_called_once() + img = saved.call_args[0][0] + self.assertEqual(img.size, (768, 768)) + + def test_plain_results_keep_their_size(self): + wide = Image.new("RGB", (1344, 768), (10, 20, 30)) + with mock.patch.object(gt, "generate_image", return_value=wide), \ + mock.patch.object(gt, "save") as saved: + gt.generate_ai_textures("p", "t.png", "m", 1024) + img = saved.call_args[0][0] + self.assertEqual(img.size, (1024, 585)) # _fit preserves aspect ratio + + +if __name__ == "__main__": + unittest.main()