Downloading Scan Files
This guide explains how to download processed scan outputs from the RealityConnect API: point clouds, meshes, and photosphere images. The workflow uses the Client Credentials flow and the download:scan scope.
Prerequisites
Section titled “Prerequisites”- An OAuth application configured for Client Credentials
- Scopes:
read:basic,read:hierarchy,download:scan - Your
client_idandclient_secret - Content access to the site that holds the bundles you want to download
read:twin and read:asset are not required for scan file download.
How scan data is organized
Section titled “How scan data is organized”Processed scan data lives in bundles attached to a site. Each bundle contains one or more components, where each component is a different output format:
| Component type | Format | Description |
|---|---|---|
OgcPointCloudHlod | OGC 3D Tiles (open standard) | Point cloud data. tileset.json entry point with .glb tile files |
RealityMeshHlod | Prevu3D HLOD | Mesh data. hlod_tree.json entry point with .pvt tile files |
RealityPhotosphere | JPEG images | Photosphere data. stations.json manifest with .jpeg images |
End-to-end flow
Section titled “End-to-end flow”flowchart LR A["1. Authenticate"] --> B["2. Find a site"] B --> C["3. List bundles"] C --> D["4. Get bundle details"] D --> E["5. Download entry files"] E --> F["6. Follow file references"]
| Step | Endpoint | What you get |
|---|---|---|
| Authenticate | POST /oauth/token | Access token |
| Resolve API URL | GET /oauth/api-info | Regional apiUrl and organization ID |
| Find site | GET /v1/nodes/{id}/browse | Site ID from the content hierarchy |
| List bundles | GET /v1/site/{siteId}/bundles | Bundle IDs for the site |
| Get components | GET /v1/site/{siteId}/bundles/{bundleId} | signedLink and roots per component |
| Download files | GET on the constructed CDN URL | Entry files, tiles, and images |
Step 1 — Authenticate
Section titled “Step 1 — Authenticate”Obtain an access token with Client Credentials:
POST https://cloud-api.prevu3d.com/oauth/tokenAuthorization: Basic base64(client_id:client_secret)Content-Type: application/x-www-form-urlencoded
grant_type=client_credentialsResolve your regional API URL:
GET https://cloud-api.prevu3d.com/oauth/api-infoAuthorization: Bearer {access_token}The response contains apiUrl (for example https://api-ue1.prevu3d.com/realityconnect-api). Use this as the base URL for all subsequent API calls.
Step 2 — Find a site
Section titled “Step 2 — Find a site”Browse the node hierarchy starting from your organization:
GET {api_url}/v1/nodes/{organizationId}/browseAuthorization: Bearer {access_token}Traverse the tree recursively until you find a node with type: "Site". Note its id.
Step 3 — List bundles
Section titled “Step 3 — List bundles”GET {api_url}/v1/site/{siteId}/bundlesAuthorization: Bearer {access_token}Returns a paginated list of bundles for that site.
Step 4 — Get bundle details
Section titled “Step 4 — Get bundle details”GET {api_url}/v1/site/{siteId}/bundles/{bundleId}Authorization: Bearer {access_token}Example response:
{ "id": "f75de673-...", "name": "My Bundle", "components": [ { "id": "abc123", "type": "OgcPointCloudHlod", "signedLink": "https://cdn.prevu3d.com/.../OgcPointCloudHlod/*?Policy=...&Signature=...&Key-Pair-Id=...", "roots": [ "sessions/0f4a60cb09a8/tileset.json", "sessions/2a5ed597dc0f/tileset.json" ] }, { "id": "def456", "type": "RealityMeshHlod", "signedLink": "https://cdn.prevu3d.com/.../RealityMeshHlod/*?Policy=...&Signature=...&Key-Pair-Id=...", "roots": ["hlod_tree.json"] }, { "id": "ghi789", "type": "RealityPhotosphere", "signedLink": "https://cdn.prevu3d.com/.../RealityPhotosphere/*?Policy=...&Signature=...&Key-Pair-Id=...", "roots": ["stations.json"] } ]}Each component exposes two fields you need for downloading:
signedLink: a CDN signed URL ending in/*. The*is a placeholder. The signature grants read access to all files under this prefix, not just a single file.roots: an array of relative paths to the entry-point file(s) for this component.
Step 5 — Download files
Section titled “Step 5 — Download files”Construct a downloadable URL by replacing the * in signedLink with a file path:
download_url = component["signedLink"].replace("*", root)response = requests.get(download_url)The query parameters (Policy, Signature, Key-Pair-Id) stay attached and authenticate the request. Download file bytes directly from the CDN with no Authorization header on the GET.
Component-specific details
Section titled “Component-specific details”OgcPointCloudHlod (point clouds)
Section titled “OgcPointCloudHlod (point clouds)”Entry point: each value in roots is a path to a tileset.json file (one per scan session).
for root in component["roots"]: url = component["signedLink"].replace("*", root) tileset = requests.get(url).json()Tile traversal: tileset.json follows the OGC 3D Tiles specification. It contains a tree of tile nodes. Each node may have a content field with a uri pointing to a .glb file relative to the tileset directory:
{ "asset": { "version": "1.0" }, "root": { "boundingVolume": { }, "content": { "uri": "cell.glb" }, "children": [ { "content": { "uri": "cell0.glb" }, "children": [ ] } ] }}Resolve tile paths relative to the tileset directory:
session_dir = root.rsplit("/tileset.json", 1)[0]tile_path = f"{session_dir}/{tile_uri}"tile_url = component["signedLink"].replace("*", tile_path)tile_data = requests.get(tile_url).contentTraverse the tree recursively to download all tiles.
RealityMeshHlod (meshes)
Section titled “RealityMeshHlod (meshes)”Entry point: roots contains ["hlod_tree.json"].
url = component["signedLink"].replace("*", "hlod_tree.json")hlod_tree = requests.get(url).json()Tree structure: hlod_tree.json contains an HLOD tree:
{ "environment_offset": [ ], "version": 1, "root": { "center": [1.17, -1.22, 5.63], "size": [50.54, 46.61, 19.63], "model_path": "cell", "density": 6.73, "children": [ { "model_path": "cell0", "children": [ ] } ], "textures": [ { "name": "color", "min_mip": 5, "max_mip": 11, "path": "cell" }, { "name": "ao", "min_mip": 5, "max_mip": 11, "path": "cell_ao" } ] }}Tile file naming: geometry and texture files use the pattern {model_path}_{mip_level}.pvt:
model_path = node["model_path"]mip_level = texture["min_mip"]tile_file = f"{model_path}_{mip_level}.pvt"
tile_url = component["signedLink"].replace("*", tile_file)tile_data = requests.get(tile_url).contentTraverse the children array recursively to discover all model_path values at every level of detail.
RealityPhotosphere (photospheres)
Section titled “RealityPhotosphere (photospheres)”Entry point: roots contains ["stations.json"].
url = component["signedLink"].replace("*", "stations.json")stations = requests.get(url).json()Additional files:
index.tsv: a tab-separated index file alongsidestations.json- Photosphere images follow the pattern
station_{n}/H_{face}_{x}_{y}.jpeg
image_path = "station_1/H_0_0_0.jpeg"image_url = component["signedLink"].replace("*", image_path)image_data = requests.get(image_url).contentParse stations.json to discover all station directories and their associated image files.
Complete example
Section titled “Complete example”This script authenticates, finds a site, fetches the first bundle, downloads entry-point files, and follows one referenced tile or image per component.
import base64import jsonimport osfrom pathlib import Path
import requests
CLIENT_ID = "your-client-id"CLIENT_SECRET = "your-client-secret"CLOUD_API_BASE = "https://cloud-api.prevu3d.com"OUTPUT_DIR = Path("downloads")
credentials = base64.b64encode(f"{CLIENT_ID}:{CLIENT_SECRET}".encode()).decode()token_response = requests.post( f"{CLOUD_API_BASE}/oauth/token", data={"grant_type": "client_credentials"}, headers={"Authorization": f"Basic {credentials}"},)token_response.raise_for_status()access_token = token_response.json()["access_token"]headers = {"Authorization": f"Bearer {access_token}"}
api_info = requests.get(f"{CLOUD_API_BASE}/oauth/api-info", headers=headers).json()api_url = api_info["apiUrl"].rstrip("/")organization_id = api_info["organization"]["id"]
def api_get(path: str) -> dict: response = requests.get(f"{api_url}{path}", headers=headers) response.raise_for_status() return response.json()
def download(url: str, dest: Path) -> bool: response = requests.get(url, timeout=30) if not response.ok: return False dest.parent.mkdir(parents=True, exist_ok=True) dest.write_bytes(response.content) return True
def find_tile_uri(node: dict, depth: int = 0): if depth > 5 or not isinstance(node, dict): return None content = node.get("content", {}) uri = content.get("uri") or content.get("url") if isinstance(uri, str) and not uri.startswith("http"): return uri for value in node.values(): if isinstance(value, dict): found = find_tile_uri(value, depth + 1) if found: return found elif isinstance(value, list): for item in value: found = find_tile_uri(item, depth + 1) if found: return found return None
queue = api_get(f"/v1/nodes/{organization_id}/browse").get("items", [])site_id = Nonevisited = set()
while queue and not site_id: node = queue.pop(0) if node["id"] in visited: continue visited.add(node["id"]) browse = api_get(f"/v1/nodes/{node['id']}/browse") current = browse.get("node", {}) if current.get("type", "").lower() == "site": site_id = current["id"] break queue.extend(browse.get("items", []))
if not site_id: raise RuntimeError("No site found in hierarchy")
bundles = api_get(f"/v1/site/{site_id}/bundles")bundle_id = bundles["items"][0]["id"]bundle = api_get(f"/v1/site/{site_id}/bundles/{bundle_id}")
for component in bundle.get("components", []): comp_type = component.get("type", "unknown") signed_link = component.get("signedLink", "") roots = component.get("roots", [])
if not signed_link or not roots: continue
for root in roots: entry_url = signed_link.replace("*", root) entry_dest = OUTPUT_DIR / comp_type / root.replace("/", os.sep) if not download(entry_url, entry_dest): continue
if root.endswith("tileset.json"): tileset = json.loads(entry_dest.read_bytes()) tile_uri = find_tile_uri(tileset.get("root", tileset)) if tile_uri: session_dir = root.rsplit("/tileset.json", 1)[0] tile_path = f"{session_dir}/{tile_uri}" if session_dir else tile_uri download(signed_link.replace("*", tile_path), OUTPUT_DIR / comp_type / tile_path.replace("/", os.sep))
elif root == "hlod_tree.json": tree = json.loads(entry_dest.read_bytes()) model_path = tree.get("root", {}).get("model_path", "") if model_path: tile_file = f"{model_path}_5.pvt" download(signed_link.replace("*", tile_file), OUTPUT_DIR / comp_type / tile_file)
elif root == "stations.json": download(signed_link.replace("*", "index.tsv"), OUTPUT_DIR / comp_type / "index.tsv") download(signed_link.replace("*", "station_1/H_0_0_0.jpeg"), OUTPUT_DIR / comp_type / "station_1" / "H_0_0_0.jpeg")
breakWhat’s next?
Section titled “What’s next?”- Set up authentication end to end in the Client Credentials Flow guide.
- Upload and process new captures with Data Bundle Workflows.
- Browse every operation in the API reference.