material_space / app.py
Imrao's picture
update1.4
654be33 verified
"""
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] = []
@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)}