137 lines
5.2 KiB
Python
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()
|