Spaces:
Running
Running
| """ | |
| 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: | |
| <cgamat_folder>/Textures/.prvw/<stem>*.(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/ | |
| # <category>/ | |
| # [<subcategory>/] β 0 or more intermediate folders | |
| # <group>/ β 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] = [] | |
| def on_startup(): | |
| global MATERIAL_INDEX | |
| MATERIAL_INDEX = scan_materials() | |
| print(f"[Material API] Loaded {len(MATERIAL_INDEX)} materials from {MATERIALS_ROOT}") | |
| # ββ Category tree βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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 βββββββββββββββββββββββββββββββββββββββββββββ | |
| 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 ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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")) | |
| def health(): | |
| return {"status": "ok", "materials_root": str(MATERIALS_ROOT), "count": len(MATERIAL_INDEX)} | |