"""Build HomeCamp tileable ground layers and the XTerrain control map. Inputs are the generated top-down texture sources in Doc/Design/Scene/HomeCamp/. Outputs (same folder): ground_tile_stone.png, ground_tile_grass.png, ground_tile_grass_dark.png, ground_control.png. """ import argparse import hashlib import json import shutil from pathlib import Path import numpy as np from PIL import Image ROOT = Path(__file__).resolve().parents[1] DESIGN = ROOT / "Doc/Design/Scene/HomeCamp" GROUND_SIZE = 42.0 TILE = 1024 CONTROL = 512 def overlap_wrap(img: np.ndarray, width: int, axis: int) -> np.ndarray: # Cross-fade the trailing strip into the leading strip, then drop it, so the last kept # column flows into the first; a wide blend would ghost structured patterns like stones. n = img.shape[axis] head = np.take(img, range(width), axis=axis) tail = np.take(img, range(n - width, n), axis=axis) shape = [1, 1, 1] shape[axis] = width t = np.linspace(0, 1, width).reshape(shape) body = np.take(img, range(width, n - width), axis=axis) return np.concatenate([tail * (1 - t) + head * t, body], axis=axis) def seamless(src: Path, tint=(1.0, 1.0, 1.0), width: int = 32) -> np.ndarray: img = np.asarray(Image.open(src).convert("RGB").resize((TILE, TILE), Image.LANCZOS), dtype=np.float32) out = overlap_wrap(overlap_wrap(img, width, 1), width, 0) out = np.asarray( Image.fromarray(np.clip(out, 0, 255).astype(np.uint8)).resize((TILE, TILE), Image.LANCZOS), dtype=np.float32, ) return np.clip(out * np.asarray(tint, dtype=np.float32), 0, 255).astype(np.uint8) def value_noise(size: int, cells: int, seed: int) -> np.ndarray: rng = np.random.default_rng(seed) grid = rng.random((cells + 1, cells + 1)) x = np.linspace(0, cells, size, endpoint=False) i = x.astype(int) f = x - i f = f * f * (3 - 2 * f) a = grid[i][:, i] b = grid[i][:, i + 1] c = grid[i + 1][:, i] d = grid[i + 1][:, i + 1] fx = f[None, :] fy = f[:, None] return (a * (1 - fx) + b * fx) * (1 - fy) + (c * (1 - fx) + d * fx) * fy def segment_distance(px, pz, a, b): ax, az = a bx, bz = b vx, vz = bx - ax, bz - az t = np.clip(((px - ax) * vx + (pz - az) * vz) / (vx * vx + vz * vz), 0, 1) return np.hypot(px - (ax + t * vx), pz - (az + t * vz)) def control_map() -> np.ndarray: half = GROUND_SIZE / 2 coords = (np.arange(CONTROL) + 0.5) / CONTROL * GROUND_SIZE - half x = coords[None, :] z = coords[:, None] # Plaza around the fountain; front edge stops at z=-3.5 so lawn fills the lower third. plaza = np.hypot(x / 9.5, (z - 3.5) / 7.0) - 1.0 plaza *= 8.0 path = np.minimum( segment_distance(x, z, (0.0, -3.0), (1.8, -10.0)), segment_distance(x, z, (1.8, -10.0), (3.2, -21.5)), ) - 1.2 dist = np.minimum(plaza, path) edge = value_noise(CONTROL, 24, 7) * 0.9 + value_noise(CONTROL, 64, 11) * 0.45 dist = dist + (edge - 0.67) * 1.4 stone = np.clip(0.5 - dist / 0.9, 0, 1) # Split non-stone into warm lawn (G) and cooler/darker patches (B) so the lawn is not flat green. patch = value_noise(CONTROL, 10, 19) * 0.55 + value_noise(CONTROL, 28, 23) * 0.45 dark = np.clip((patch - 0.42) / 0.35, 0, 1) * (1 - stone) warm = np.clip(1 - stone - dark, 0, 1) total = stone + warm + dark + 1e-5 rgba = np.zeros((CONTROL, CONTROL, 4), dtype=np.float32) rgba[..., 0] = stone / total rgba[..., 1] = warm / total rgba[..., 2] = dark / total # Image row 0 is the top (v=1 => +Z); flip so +Z lands at the top of the PNG. return (np.flipud(rgba) * 255 + 0.5).astype(np.uint8) def sha(path: Path) -> str: return hashlib.sha256(path.read_bytes()).hexdigest() def main(): parser = argparse.ArgumentParser() parser.add_argument("--stone", default=str(DESIGN / "ground_tile_stone_source.jpg")) parser.add_argument("--grass", default=str(DESIGN / "ground_tile_grass_source.jpg")) parser.add_argument("--grass-v2", default=str(DESIGN / "ground_tile_grass_v2_source.jpg")) args = parser.parse_args() src_assets = Path.home() / ".cursor/projects/d-UD-AI-AIGame/assets/ground_tile_grass_v2_source.jpg" grass_v2 = Path(args.grass_v2) if src_assets.exists(): shutil.copy2(src_assets, grass_v2) grass_src = grass_v2 if grass_v2.exists() else Path(args.grass) # Warm olive lawn (not neon); darker mossy patches for layer 2. outputs = { "ground_tile_stone.png": seamless(Path(args.stone), (1.0, 0.93, 0.82)), "ground_tile_grass.png": seamless(grass_src, (0.72, 0.78, 0.58)), "ground_tile_grass_dark.png": seamless(grass_src, (0.52, 0.62, 0.42)), } for name, data in outputs.items(): Image.fromarray(data).save(DESIGN / name) Image.fromarray(control_map()).save(DESIGN / "ground_control.png") receipt = {name: sha(DESIGN / name) for name in [*outputs, "ground_control.png"]} receipt["groundSizeMeters"] = GROUND_SIZE receipt["grassSource"] = grass_src.relative_to(ROOT).as_posix() out_dir = ROOT / "Tools/output/homecamp_review" out_dir.mkdir(parents=True, exist_ok=True) (out_dir / "ground_layers.json").write_text(json.dumps(receipt, indent=2), encoding="utf-8") print(json.dumps(receipt, indent=2)) if __name__ == "__main__": main()