#!/usr/bin/env python """ Download CC0 PBR texture sets from ambientCG and resize them to 512px JPEGs suitable for the house-plan-maker 3D view. Outputs land in apps/client/public/textures/{floors,walls}//{color,normal,roughness}.jpg Texture credit: ambientCG (https://ambientcg.com), CC0 1.0 Universal. Run from repo root: py -3 scripts/fetch-textures.py Re-running is idempotent — files are skipped if they already exist. """ import io import os import sys import urllib.request import zipfile from pathlib import Path try: from PIL import Image except ImportError: print("ERROR: PIL/Pillow is required. Install with: py -3 -m pip install pillow", file=sys.stderr) sys.exit(1) REPO_ROOT = Path(__file__).resolve().parent.parent OUT_ROOT = REPO_ROOT / "apps" / "client" / "public" / "textures" TARGET_SIZE = 512 # Map our enum values to ambientCG asset ids. The names follow the # Camera/Wood/Tile/etc. categories on https://ambientcg.com. FLOORS = { # FloorType -> ambientCG asset "CONCRETE": "Concrete034", "WOOD_LIGHT": "WoodFloor043", "WOOD_MEDIUM": "WoodFloor041", "WOOD_DARK": "WoodFloor051", "WOOD_HERRINGBONE": "WoodFloor057", "TILE_WHITE": "Tiles074", "TILE_GRAY": "Tiles101", "LAMINATE": "WoodFloor044", } WALLS = { # WallFinish -> ambientCG asset (PAINT skips textures, uses wallColor) "PLASTER": "Plaster001", "BRICK": "Bricks023", "CONCRETE": "Concrete019", "WOOD_PANEL": "WoodSiding001", "WALLPAPER": "Fabric030", } # Maps we want from each set. ambientCG file naming: _1K-JPG_.jpg MAPS = { "color": "Color", "normal": "NormalGL", "roughness": "Roughness", } def download_zip(asset_id: str) -> bytes: url = f"https://ambientcg.com/get?file={asset_id}_1K-JPG.zip" print(f" fetching {url}") req = urllib.request.Request(url, headers={"User-Agent": "house-plan-maker/1.0"}) with urllib.request.urlopen(req, timeout=60) as resp: return resp.read() def extract_and_resize(zip_bytes: bytes, asset_id: str, out_dir: Path) -> bool: out_dir.mkdir(parents=True, exist_ok=True) try: zf = zipfile.ZipFile(io.BytesIO(zip_bytes)) except zipfile.BadZipFile: # Server returned an HTML error page, not a zip. print(f" ERROR: {asset_id} did not return a zip — preview: {zip_bytes[:120]!r}") return False names = {n.lower(): n for n in zf.namelist()} success = True for out_name, ambient_map in MAPS.items(): out_path = out_dir / f"{out_name}.jpg" if out_path.exists(): print(f" skip (exists): {out_path.relative_to(REPO_ROOT)}") continue # Find the matching file in the zip (case-insensitive substring match). needle = f"_{ambient_map}.jpg".lower() match_lower = next((k for k in names if k.endswith(needle)), None) if not match_lower: print(f" WARN: no {ambient_map} map in {asset_id}") success = False continue with zf.open(names[match_lower]) as src: img = Image.open(src) img = img.convert("RGB") img = img.resize((TARGET_SIZE, TARGET_SIZE), Image.LANCZOS) img.save(out_path, "JPEG", quality=82, optimize=True) print(f" wrote {out_path.relative_to(REPO_ROOT)} ({out_path.stat().st_size // 1024} KB)") return success def fetch_set(category: str, label: str, asset_id: str) -> None: out_dir = OUT_ROOT / category / label.lower() print(f"[{category}/{label}] -> {asset_id}") # Skip whole asset if all three target files already exist. if all((out_dir / f"{m}.jpg").exists() for m in MAPS): print(f" all maps already present, skipping fetch") return try: zip_bytes = download_zip(asset_id) except Exception as e: print(f" ERROR fetching {asset_id}: {e}") return extract_and_resize(zip_bytes, asset_id, out_dir) def main() -> None: print(f"writing to {OUT_ROOT}") OUT_ROOT.mkdir(parents=True, exist_ok=True) for label, asset_id in FLOORS.items(): fetch_set("floors", label, asset_id) for label, asset_id in WALLS.items(): fetch_set("walls", label, asset_id) print("done.") if __name__ == "__main__": main()