22
33from __future__ import annotations
44
5+ import base64
6+ import binascii
57import json
68import os
9+ import struct
710from functools import lru_cache
811from pathlib import Path
9- from typing import Any
12+ from typing import Any , Dict , Optional
1013
1114from fastapi import APIRouter , HTTPException
15+ from fastapi .responses import Response
1216
1317router = 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"
1621CATALOG_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" )
31135def 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 )
0 commit comments