Skip to content

Commit c765892

Browse files
authored
Embed catalog meshes as base64 manifest entries (#456)
1 parent 2811a0b commit c765892

File tree

16 files changed

+1190
-608
lines changed

16 files changed

+1190
-608
lines changed

backend/api/main.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
)
2727
from api.routes import router as api_router
2828
from api.routes_catalog import router as catalog_router
29+
from api.routes_catalog import router_mesh as catalog_mesh_router
2930
from api.routes_cnc import router as cnc_router
3031
from api.routes_materials import router as materials_router
3132
from api.routes_modules import router as modules_router
@@ -120,6 +121,8 @@ async def dispatch(self, request: Request, call_next): # type: ignore[override]
120121

121122
# Include API router
122123
app.include_router(api_router)
124+
app.include_router(catalog_router)
125+
app.include_router(catalog_mesh_router)
123126
app.include_router(materials_router)
124127
app.include_router(modules_router)
125128
app.include_router(pricing_router)

backend/api/routes_catalog.py

Lines changed: 147 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,28 @@
22

33
from __future__ import annotations
44

5+
import base64
6+
import binascii
57
import json
68
import os
9+
import struct
710
from functools import lru_cache
811
from pathlib import Path
9-
from typing import Any
12+
from typing import Any, Dict, Optional
1013

1114
from fastapi import APIRouter, HTTPException
15+
from fastapi.responses import Response
1216

1317
router = APIRouter(prefix="/api/catalog", tags=["catalog"])
18+
router_mesh = APIRouter(prefix="/api/catalog", tags=["catalog"])
1419

1520
_DEFAULT_CATALOG_PATH = Path(__file__).resolve().parents[1] / "catalog" / "option_graph.json"
1621
CATALOG_PATH = Path(os.environ.get("CPQ_CATALOG_PATH") or _DEFAULT_CATALOG_PATH)
22+
_DEFAULT_MESH_MANIFEST_PATH = (
23+
Path(__file__).resolve().parents[2] / "frontend" / "public" / "models" / "manifest.json"
24+
)
25+
MESH_MANIFEST_PATH = Path(os.environ.get("CPQ_MESH_MANIFEST_PATH") or _DEFAULT_MESH_MANIFEST_PATH)
26+
JSON_CHUNK = 0x4E4F534A
1727

1828

1929
@lru_cache(maxsize=1)
@@ -27,8 +37,144 @@ def _load_catalog(path: Path) -> Any:
2737
raise HTTPException(status_code=500, detail="catalog file is invalid") from exc
2838

2939

40+
@lru_cache(maxsize=1)
41+
def _load_mesh_manifest(path: Path) -> Dict[str, Any]:
42+
try:
43+
with path.open("r", encoding="utf-8") as fh:
44+
return json.load(fh)
45+
except FileNotFoundError as exc: # pragma: no cover - protective guard
46+
raise HTTPException(status_code=500, detail="mesh manifest not found") from exc
47+
except json.JSONDecodeError as exc: # pragma: no cover - protective guard
48+
raise HTTPException(status_code=500, detail="mesh manifest is invalid") from exc
49+
50+
51+
def _find_manifest_entry(manifest: Dict[str, Any], variant_id: str) -> Dict[str, Any]:
52+
models = manifest.get("models")
53+
if not isinstance(models, list):
54+
raise HTTPException(status_code=500, detail="mesh manifest missing models")
55+
56+
primary_match: Optional[Dict[str, Any]] = None
57+
for entry in models:
58+
if not isinstance(entry, dict):
59+
continue
60+
entry_id = entry.get("id")
61+
if isinstance(entry_id, str) and entry_id == variant_id:
62+
return entry
63+
variant = entry.get("variant")
64+
if isinstance(variant, str) and variant == variant_id:
65+
lod = entry.get("lod")
66+
if lod in (None, 0):
67+
return entry
68+
primary_match = entry
69+
70+
if primary_match is not None:
71+
return primary_match
72+
73+
for entry in models:
74+
if not isinstance(entry, dict):
75+
continue
76+
entry_id = entry.get("id")
77+
variant = entry.get("variant")
78+
lod = entry.get("lod")
79+
if isinstance(entry_id, str) and entry_id == variant_id:
80+
return entry
81+
if (
82+
isinstance(variant, str)
83+
and lod is not None
84+
and f"{variant}@lod{lod}" == variant_id
85+
):
86+
return entry
87+
88+
raise HTTPException(status_code=404, detail="mesh variant not found")
89+
90+
91+
def _decode_mesh_bytes(entry: Dict[str, Any]) -> bytes:
92+
mesh_value = entry.get("meshBase64")
93+
if not isinstance(mesh_value, str) or not mesh_value:
94+
raise HTTPException(status_code=500, detail="mesh manifest missing mesh data")
95+
96+
try:
97+
return base64.b64decode(mesh_value)
98+
except binascii.Error as exc: # pragma: no cover - protective guard
99+
raise HTTPException(status_code=500, detail="mesh data is invalid") from exc
100+
101+
102+
def _parse_glb_document(data: bytes) -> Dict[str, Any]:
103+
if len(data) < 12 or data[:4] != b"glTF":
104+
raise HTTPException(status_code=500, detail="invalid GLB header")
105+
106+
total_length = struct.unpack_from("<I", data, 8)[0]
107+
offset = 12
108+
while offset + 8 <= len(data) and offset < total_length:
109+
chunk_length, chunk_type = struct.unpack_from("<II", data, offset)
110+
offset += 8
111+
chunk = data[offset : offset + chunk_length]
112+
offset += chunk_length
113+
if chunk_type == JSON_CHUNK:
114+
try:
115+
return json.loads(chunk.decode("utf-8"))
116+
except json.JSONDecodeError as exc:
117+
raise HTTPException(status_code=500, detail="invalid GLB metadata") from exc
118+
119+
raise HTTPException(status_code=500, detail="GLB missing JSON chunk")
120+
121+
122+
def _extract_variant_extras(document: Dict[str, Any]) -> Dict[str, Any]:
123+
nodes = document.get("nodes")
124+
if isinstance(nodes, list):
125+
for node in nodes:
126+
if not isinstance(node, dict):
127+
continue
128+
extras = node.get("extras")
129+
if isinstance(extras, dict) and "paform:variant" in extras:
130+
return extras
131+
raise HTTPException(status_code=500, detail="paform:variant extras missing")
132+
133+
30134
@router.get("/option-graph")
31135
def get_option_graph() -> Any:
32136
"""Return the option graph catalog used by CPQ pricing flows."""
33137

34138
return _load_catalog(CATALOG_PATH)
139+
140+
141+
@router_mesh.get("/{variant_id}/mesh")
142+
def get_catalog_mesh(variant_id: str) -> Dict[str, Any]:
143+
"""Return the GLB payload and variant metadata for the requested catalog entry."""
144+
145+
manifest = _load_mesh_manifest(MESH_MANIFEST_PATH)
146+
entry = _find_manifest_entry(manifest, variant_id)
147+
data = _decode_mesh_bytes(entry)
148+
149+
document = _parse_glb_document(data)
150+
extras = _extract_variant_extras(document)
151+
variant_meta = extras.get("paform:variant")
152+
if not isinstance(variant_meta, dict):
153+
raise HTTPException(status_code=500, detail="mesh missing paform:variant metadata")
154+
155+
payload: Dict[str, Any] = {
156+
"id": entry.get("id"),
157+
"variant": variant_meta.get("code") or entry.get("variant") or variant_id,
158+
"lod": entry.get("lod"),
159+
"sha256": entry.get("sha256"),
160+
"bytes": entry.get("bytes") or len(data),
161+
"mesh": entry.get("meshBase64"),
162+
"metadata": variant_meta,
163+
"extras": extras,
164+
}
165+
166+
return payload
167+
168+
169+
@router_mesh.get("/{variant_id}/mesh.glb")
170+
def download_catalog_mesh(variant_id: str) -> Response:
171+
"""Return the raw GLB binary for the requested catalog entry."""
172+
173+
manifest = _load_mesh_manifest(MESH_MANIFEST_PATH)
174+
entry = _find_manifest_entry(manifest, variant_id)
175+
data = _decode_mesh_bytes(entry)
176+
177+
filename = f"{(entry.get('id') or entry.get('variant') or variant_id)}.glb"
178+
headers = {"Content-Disposition": f"attachment; filename=\"{filename}\""}
179+
180+
return Response(content=data, media_type="model/gltf-binary", headers=headers)
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
from __future__ import annotations
2+
3+
import base64
4+
5+
from fastapi.testclient import TestClient
6+
7+
from api.main import app
8+
9+
10+
def test_get_catalog_mesh_returns_glb_and_metadata() -> None:
11+
client = TestClient(app)
12+
13+
response = client.get("/api/catalog/base_600/mesh")
14+
assert response.status_code == 200
15+
16+
payload = response.json()
17+
assert payload["variant"] == "base_600"
18+
assert payload["id"] == "base_600"
19+
assert isinstance(payload.get("mesh"), str)
20+
21+
binary = base64.b64decode(payload["mesh"]) if payload.get("mesh") else b""
22+
assert len(binary) == payload["bytes"]
23+
24+
metadata = payload.get("metadata") or {}
25+
assert isinstance(metadata, dict)
26+
width_param = metadata.get("parameters", {}).get("width")
27+
assert isinstance(width_param, dict)
28+
assert width_param.get("initial") == 800
29+
viewer_transform = metadata.get("viewerTransform")
30+
assert isinstance(viewer_transform, dict)
31+
assert viewer_transform.get("scaleParameter", {}).get("axis") == "x"
32+
33+
34+
def test_get_catalog_mesh_glb_downloads_binary() -> None:
35+
client = TestClient(app)
36+
37+
response = client.get("/api/catalog/base_600/mesh.glb")
38+
assert response.status_code == 200
39+
assert response.headers["content-type"].startswith("model/gltf-binary")
40+
assert response.headers["content-disposition"].endswith("base_600.glb\"")
41+
assert len(response.content) > 1000
42+
43+
44+
def test_get_catalog_mesh_missing_variant_returns_404() -> None:
45+
client = TestClient(app)
46+
47+
response = client.get("/api/catalog/unknown_variant/mesh")
48+
assert response.status_code == 404
-4.26 KB
Binary file not shown.
-3.96 KB
Binary file not shown.

0 commit comments

Comments
 (0)