Skip to content

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.


  • An OAuth application configured for Client Credentials
  • Scopes: read:basic, read:hierarchy, download:scan
  • Your client_id and client_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.

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 typeFormatDescription
OgcPointCloudHlodOGC 3D Tiles (open standard)Point cloud data. tileset.json entry point with .glb tile files
RealityMeshHlodPrevu3D HLODMesh data. hlod_tree.json entry point with .pvt tile files
RealityPhotosphereJPEG imagesPhotosphere data. stations.json manifest with .jpeg images
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"]
StepEndpointWhat you get
AuthenticatePOST /oauth/tokenAccess token
Resolve API URLGET /oauth/api-infoRegional apiUrl and organization ID
Find siteGET /v1/nodes/{id}/browseSite ID from the content hierarchy
List bundlesGET /v1/site/{siteId}/bundlesBundle IDs for the site
Get componentsGET /v1/site/{siteId}/bundles/{bundleId}signedLink and roots per component
Download filesGET on the constructed CDN URLEntry files, tiles, and images

Obtain an access token with Client Credentials:

POST https://cloud-api.prevu3d.com/oauth/token
Authorization: Basic base64(client_id:client_secret)
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials

Resolve your regional API URL:

GET https://cloud-api.prevu3d.com/oauth/api-info
Authorization: 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.

Browse the node hierarchy starting from your organization:

GET {api_url}/v1/nodes/{organizationId}/browse
Authorization: Bearer {access_token}

Traverse the tree recursively until you find a node with type: "Site". Note its id.

GET {api_url}/v1/site/{siteId}/bundles
Authorization: Bearer {access_token}

Returns a paginated list of bundles for that site.

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.

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.

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).content

Traverse the tree recursively to download all tiles.

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).content

Traverse the children array recursively to discover all model_path values at every level of detail.

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 alongside stations.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).content

Parse stations.json to discover all station directories and their associated image files.

This script authenticates, finds a site, fetches the first bundle, downloads entry-point files, and follows one referenced tile or image per component.

import base64
import json
import os
from 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 = None
visited = 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")
break