Ir al contenido

Descargar archivos de escaneo

Esta guía explica cómo descargar salidas de escaneo procesadas desde la API de RealityConnect: nubes de puntos, mallas e imágenes de fotosferas. El flujo de trabajo usa el flujo de Client Credentials y el ámbito download:scan.


  • Una aplicación OAuth configurada para Client Credentials
  • Ámbitos: read:basic, read:hierarchy, download:scan
  • Tu client_id y client_secret
  • Acceso al contenido del sitio que contiene los bundles que quieres descargar

read:twin y read:asset no son necesarios para la descarga de archivos de escaneo.

Los datos de escaneo procesados residen en bundles asociados a un sitio. Cada bundle contiene uno o más componentes, donde cada componente es un formato de salida diferente:

Tipo de componenteFormatoDescripción
OgcPointCloudHlodOGC 3D Tiles (estándar abierto)Datos de nube de puntos. Punto de entrada tileset.json con archivos de tesela .glb
RealityMeshHlodHLOD de Prevu3DDatos de malla. Punto de entrada hlod_tree.json con archivos de tesela .pvt
RealityPhotosphereImágenes JPEGDatos de fotosferas. Manifiesto stations.json con imágenes .jpeg
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"]
PasoEndpointQué obtienes
AutenticarPOST /oauth/tokenToken de acceso
Resolver la URL de la APIGET /oauth/api-infoapiUrl regional e ID de la organización
Encontrar el sitioGET /v1/nodes/{id}/browseID del sitio desde la jerarquía de contenido
Listar bundlesGET /v1/site/{siteId}/bundlesIDs de bundle del sitio
Obtener componentesGET /v1/site/{siteId}/bundles/{bundleId}signedLink y roots por componente
Descargar archivosGET sobre la URL de CDN construidaArchivos de entrada, teselas e imágenes

Obtén un token de acceso con 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

Resuelve la URL regional de tu API:

GET https://cloud-api.prevu3d.com/oauth/api-info
Authorization: Bearer {access_token}

La respuesta contiene apiUrl (por ejemplo https://api-ue1.prevu3d.com/realityconnect-api). Úsala como URL base para todas las llamadas posteriores a la API.

Explora la jerarquía de nodos partiendo de tu organización:

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

Recorre el árbol de forma recursiva hasta que encuentres un nodo con type: "Site". Anota su id.

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

Devuelve una lista paginada de los bundles de ese sitio.

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

Ejemplo de respuesta:

{
"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"]
}
]
}

Cada componente expone dos campos que necesitas para la descarga:

  • signedLink: una URL firmada de CDN que termina en /*. El * es un marcador de posición. La firma concede acceso de lectura a todos los archivos bajo este prefijo, no solo a un único archivo.
  • roots: un array de rutas relativas al archivo o archivos de punto de entrada de este componente.

Construye una URL descargable reemplazando el * de signedLink por una ruta de archivo:

download_url = component["signedLink"].replace("*", root)
response = requests.get(download_url)

Los parámetros de consulta (Policy, Signature, Key-Pair-Id) permanecen adjuntos y autentican la solicitud. Descarga los bytes del archivo directamente desde el CDN sin cabecera Authorization en el GET.

Punto de entrada: cada valor de roots es una ruta a un archivo tileset.json (uno por sesión de escaneo).

for root in component["roots"]:
url = component["signedLink"].replace("*", root)
tileset = requests.get(url).json()

Recorrido de teselas: tileset.json sigue la especificación OGC 3D Tiles. Contiene un árbol de nodos de tesela. Cada nodo puede tener un campo content con un uri que apunta a un archivo .glb relativo al directorio del tileset:

{
"asset": { "version": "1.0" },
"root": {
"boundingVolume": { },
"content": { "uri": "cell.glb" },
"children": [
{
"content": { "uri": "cell0.glb" },
"children": [ ]
}
]
}
}

Resuelve las rutas de las teselas de forma relativa al directorio del tileset:

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

Recorre el árbol de forma recursiva para descargar todas las teselas.

Punto de entrada: roots contiene ["hlod_tree.json"].

url = component["signedLink"].replace("*", "hlod_tree.json")
hlod_tree = requests.get(url).json()

Estructura del árbol: hlod_tree.json contiene un árbol HLOD:

{
"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" }
]
}
}

Nomenclatura de los archivos de tesela: los archivos de geometría y textura usan el patrón {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

Recorre el array children de forma recursiva para descubrir todos los valores de model_path en cada nivel de detalle.

Punto de entrada: roots contiene ["stations.json"].

url = component["signedLink"].replace("*", "stations.json")
stations = requests.get(url).json()

Archivos adicionales:

  • index.tsv: un archivo de índice separado por tabulaciones junto a stations.json
  • Las imágenes de fotosferas siguen el patrón 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

Analiza stations.json para descubrir todos los directorios de estaciones y sus archivos de imagen asociados.

Este script se autentica, encuentra un sitio, obtiene el primer bundle, descarga los archivos de punto de entrada y sigue una tesela o imagen referenciada por componente.

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