Files
AIGame/Tools/homecamp_ground_textures.py
T

137 lines
5.2 KiB
Python

"""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()