""" Material Library API Scans huggingfaceapi/material_api/Materials/ (structured like the CityEngine lib) and serves .cgamat metadata + texture files. Run: .venv/Scripts/python.exe -m uvicorn huggingfaceapi.material_api.main:app --port 8001 --reload """ from __future__ import annotations # enables str|None, dict[...], list[...] on Python 3.9 import os from pathlib import Path from typing import Any, Dict, Optional, Tuple from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse # ── Config ───────────────────────────────────────────────────────────────── # Materials directory sits alongside this script, just like the rpk/ folder in app.py MATERIALS_ROOT = Path(os.path.join(os.path.dirname(__file__), "Materials")) # Ensure it exists (create on first run) MATERIALS_ROOT.mkdir(parents=True, exist_ok=True) # ── Known structure (authoritative folder tree) ────────────────────────────── # Drop your .cgamat + Textures/ folders into these leaf directories. KNOWN_STRUCTURE: dict[str, dict[str, list[str]]] = { "Architectural": { "Cladding": ["Aluminium", "Ceramic", "Corrugated", "Corten", "FiberCement", "Terracotta", "Travertine", "Wood"], "Exterior": ["Pebbles"], "Misc": ["SolarPanels"], "Roofing": ["Concrete", "Slate", "Terracotta"], "Walls": ["Brick", "Concrete", "Plaster"], }, "Generic": { "": ["Asphalt", "Glass", "Slate", "Terracotta", "Test_Patterns", "Wood"], "Metal": ["Aluminum", "Steel"], "Plastic": ["PVC"], }, "Street": { "Road": ["Asphalt", "Markings"], "Sidewalk": ["Concrete", "Paver"], }, "Vegetation": { "": ["Grass", "Soil"], }, } def _ensure_known_dirs() -> None: """Create all leaf directories from KNOWN_STRUCTURE so users can just drop files in.""" for cat, subs in KNOWN_STRUCTURE.items(): for sub, groups in subs.items(): for grp in groups: leaf = MATERIALS_ROOT / cat / sub / grp if sub else MATERIALS_ROOT / cat / grp leaf.mkdir(parents=True, exist_ok=True) _ensure_known_dirs() app = FastAPI(title="Material Library API") app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], ) # ── .cgamat parser ─────────────────────────────────────────────────────────── def parse_cgamat(filepath: Path) -> dict[str, Any]: """Parse a CityEngine .cgamat key,value file into a PBR descriptor dict.""" props: dict[str, str] = {} try: for line in filepath.read_text(encoding="utf-8", errors="ignore").splitlines(): line = line.strip() if not line or line.startswith("#"): continue parts = line.split(",", 1) if len(parts) == 2: props[parts[0].strip()] = parts[1].strip() except Exception: pass # Textures are relative to the .cgamat file's parent directory base_dir = filepath.parent def tex_url(key: str) -> Optional[str]: """ Resolve a texture path from a .cgamat property to a /texture/... URL. Handles two issues: 1. .tif/.tiff files — browsers cannot decode TIFF natively, so we look for a .jpg/.png/.webp sibling with the same stem before giving up. 2. Missing files — quietly returns None so the material still renders with its base colour. """ raw = props.get(key, "").strip() if not raw: return None abs_path = (base_dir / raw).resolve() # .tif/.tiff → try browser-compatible alternatives if abs_path.suffix.lower() in (".tif", ".tiff"): for alt_ext in (".jpg", ".jpeg", ".png", ".webp"): alt = abs_path.with_suffix(alt_ext) if alt.exists(): abs_path = alt break else: # no browser-friendly alternative found return None if not abs_path.exists(): return None try: rel = abs_path.relative_to(MATERIALS_ROOT) return "/texture/" + rel.as_posix() except ValueError: return None # Tiling: use colormap scale factors as primary, fall back to roughnessmap's tiling_u = float(props.get("colormap.su") or props.get("roughnessmap.su") or 1) tiling_v = float(props.get("colormap.sv") or props.get("roughnessmap.sv") or 1) # opacitymap.mode: "blend" = transparent (glass), "opaque" = solid opacity_mode = props.get("opacitymap.mode", "opaque").strip().lower() return { "name": props.get("name", filepath.stem), "color": [ float(props.get("color.r", 1)), float(props.get("color.g", 1)), float(props.get("color.b", 1)), ], "roughness": float(props.get("roughness", 0.8)), "metalness": float(props.get("metallic", 0.0)), "opacity": float(props.get("opacity", 1.0)), # opaque = solid surface; blend = alpha-transparent (glass, foliage, etc.) "opacityMode": opacity_mode, "colormap": tex_url("colormap"), "normalmap": tex_url("normalmap"), # Combined MetallicRoughness texture: G-channel = roughness, B-channel = metalness "roughnessmap": tex_url("roughnessmap"), # Separate metallic-only map (rare; most materials use roughnessmap B-channel) "metallicmap": tex_url("metallicmap"), "tilingU": tiling_u, "tilingV": tiling_v, } # ── Preview lookup ─────────────────────────────────────────────────────────── def find_preview(cgamat: Path) -> Optional[str]: """ Look for a preview image for this .cgamat in: /Textures/.prvw/*.(jpg|png) The .prvw folder contains thumbnail images named after the cgamat stem, e.g. CorrugatedPanels_Borga_1x3_cAgateGrey_Color.jpg or CorrugatedPanels_Borga_1x3_cAgateGrey_Color.jpg.jpg (double-ext variant) """ stem = cgamat.stem.lower() prvw_dir = cgamat.parent / "Textures" / ".prvw" if not prvw_dir.is_dir(): return None best: Optional[Path] = None for img in prvw_dir.iterdir(): name_lower = img.name.lower() # Match on stem presence; prefer files without double extension if stem in name_lower and img.suffix.lower() in (".jpg", ".jpeg", ".png", ".webp"): # Prefer non-double-extension files if best is None or (not name_lower.endswith(".jpg.jpg") and best.name.lower().endswith(".jpg.jpg")): best = img if best is not None: try: rel = best.relative_to(MATERIALS_ROOT) return "/texture/" + rel.as_posix() except ValueError: pass return None # ── Directory structure mapping ────────────────────────────────────────────── # # Actual folder layout (variable depth): # # Materials/ # / # [/] ← 0 or more intermediate folders # / ← leaf folder containing .cgamat files # File.cgamat # Textures/ # .prvw/ # # Examples: # Generic/Asphalt/Asphalt_1x1.cgamat # → parts = ('Generic', 'Asphalt', 'Asphalt_1x1.cgamat') # → category='Generic' subcategory='' group='Asphalt' # # Architectural/Cladding/Corrugated/File.cgamat # → parts = ('Architectural', 'Cladding', 'Corrugated', 'File.cgamat') # → category='Architectural' subcategory='Cladding' group='Corrugated' # # Street/Road/Asphalt/File.cgamat # → parts = ('Street', 'Road', 'Asphalt', 'File.cgamat') # → category='Street' subcategory='Road' group='Asphalt' # # Rule: # parts[0] = category # parts[-2] = group (the leaf folder) # parts[1:-2] = subcategory intermediate parts def _classify(rel: Path) -> Tuple[str, str, str]: """Return (category, subcategory, group) from a relative cgamat path.""" parts = rel.parts # includes the filename as last element if len(parts) < 2: return ("Other", "", "") category = parts[0] group = parts[-2] # leaf folder subcategory = "/".join(parts[1:-2]) # empty string if only 1 intermediate level return (category, subcategory, group) # ── Build material index at startup ───────────────────────────────────────── def scan_materials() -> list[dict]: results = [] if not MATERIALS_ROOT.exists(): return results for cgamat in sorted(MATERIALS_ROOT.rglob("*.cgamat")): # Skip anything that accidentally ended up inside a Textures sub-folder if "Textures" in cgamat.parts: continue rel = cgamat.relative_to(MATERIALS_ROOT) category, subcategory, group = _classify(rel) props = parse_cgamat(cgamat) preview_url = find_preview(cgamat) results.append({ "id": str(rel).replace("\\", "/"), "category": category, "subcategory": subcategory, # e.g. "Cladding" or "Road" or "" "group": group, # e.g. "Corrugated" or "Asphalt" "name": props["name"], "preview_url": preview_url, "props": props, }) return results MATERIAL_INDEX: list[dict] = [] @app.on_event("startup") def on_startup(): global MATERIAL_INDEX MATERIAL_INDEX = scan_materials() print(f"[Material API] Loaded {len(MATERIAL_INDEX)} materials from {MATERIALS_ROOT}") # ── Category tree ───────────────────────────────────────────────────────────── @app.get("/materials/categories") def list_categories(): """ Returns a 3-level tree: { "Architectural": { "Cladding": ["Corrugated", "Aluminium", ...], "Walls": ["Brick", "Concrete", ...] }, "Generic": { "": ["Asphalt", "Glass", "Metal", ...] }, ... } """ tree: Dict[str, Dict[str, set]] = {} for m in MATERIAL_INDEX: cat = m["category"] sub = m["subcategory"] grp = m["group"] tree.setdefault(cat, {}) tree[cat].setdefault(sub, set()) tree[cat][sub].add(grp) # Convert sets to sorted lists return { cat: {sub: sorted(grps) for sub, grps in subs.items()} for cat, subs in sorted(tree.items()) } # ── Material list with filtering ───────────────────────────────────────────── @app.get("/materials") def list_materials( category: Optional[str] = None, subcategory: Optional[str] = None, group: Optional[str] = None, q: Optional[str] = None, ): results = MATERIAL_INDEX if category: results = [m for m in results if m["category"].lower() == category.lower()] if subcategory is not None: results = [m for m in results if m["subcategory"].lower() == subcategory.lower()] if group: results = [m for m in results if m["group"].lower() == group.lower()] if q: q_lower = q.lower() results = [m for m in results if q_lower in m["name"].lower()] return {"total": len(results), "materials": results} # ── Texture file serving ────────────────────────────────────────────────────── @app.get("/texture/{filepath:path}") def serve_texture(filepath: str): abs_path = MATERIALS_ROOT / filepath.replace("/", os.sep) if not abs_path.exists() or not abs_path.is_file(): raise HTTPException(status_code=404, detail=f"Texture not found: {filepath}") ext = abs_path.suffix.lower().lstrip(".") # ".jpg" → "jpg" media_types = {"jpg": "image/jpeg", "jpeg": "image/jpeg", "png": "image/png", "webp": "image/webp"} return FileResponse(str(abs_path), media_type=media_types.get(ext, "application/octet-stream")) @app.get("/health") def health(): return {"status": "ok", "materials_root": str(MATERIALS_ROOT), "count": len(MATERIAL_INDEX)}