콘텐츠로 이동

스캔 파일 다운로드

이 가이드에서는 RealityConnect API에서 처리된 스캔 출력물(포인트 클라우드, 메시, 포토스피어 이미지)을 다운로드하는 방법을 설명합니다. 이 워크플로는 Client Credentials 플로우download:scan 스코프를 사용합니다.


  • Client Credentials로 구성된 OAuth 애플리케이션
  • 스코프: read:basic, read:hierarchy, download:scan
  • client_idclient_secret
  • 다운로드하려는 번들이 있는 사이트에 대한 콘텐츠 접근 권한

스캔 파일 다운로드에는 read:twinread:asset필요하지 않습니다.

처리된 스캔 데이터는 사이트에 연결된 번들에 존재합니다. 각 번들에는 하나 이상의 컴포넌트가 포함되며, 각 컴포넌트는 서로 다른 출력 형식입니다.

컴포넌트 유형형식설명
OgcPointCloudHlodOGC 3D Tiles(개방형 표준)포인트 클라우드 데이터. .glb 타일 파일이 있는 tileset.json 진입점
RealityMeshHlodPrevu3D HLOD메시 데이터. .pvt 타일 파일이 있는 hlod_tree.json 진입점
RealityPhotosphereJPEG 이미지포토스피어 데이터. .jpeg 이미지가 있는 stations.json 매니페스트
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"]
단계엔드포인트얻는 것
인증POST /oauth/token액세스 토큰
API URL 확인GET /oauth/api-info지역별 apiUrl 및 조직 ID
사이트 찾기GET /v1/nodes/{id}/browse콘텐츠 계층 구조의 사이트 ID
번들 나열GET /v1/site/{siteId}/bundles사이트의 번들 ID
컴포넌트 가져오기GET /v1/site/{siteId}/bundles/{bundleId}컴포넌트별 signedLinkroots
파일 다운로드구성한 CDN URL에 대한 GET진입 파일, 타일, 이미지

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

지역별 API URL을 확인합니다.

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

응답에는 apiUrl(예: https://api-ue1.prevu3d.com/realityconnect-api)이 포함됩니다. 이후의 모든 API 호출에 이 값을 기본 URL로 사용하세요.

조직에서 시작하여 노드 계층 구조를 탐색합니다.

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

type: "Site"인 노드를 찾을 때까지 트리를 재귀적으로 탐색합니다. 해당 노드의 id를 기록해 두세요.

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

해당 사이트의 번들이 페이지 단위 목록으로 반환됩니다.

4단계 — 번들 세부 정보 가져오기

섹션 제목: “4단계 — 번들 세부 정보 가져오기”
GET {api_url}/v1/site/{siteId}/bundles/{bundleId}
Authorization: Bearer {access_token}

예시 응답:

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

각 컴포넌트는 다운로드에 필요한 두 개의 필드를 노출합니다.

  • signedLink: /*로 끝나는 CDN 서명 URL입니다. *는 자리 표시자입니다. 서명은 단일 파일이 아니라 이 접두사 아래의 모든 파일에 대한 읽기 접근 권한을 부여합니다.
  • roots: 이 컴포넌트의 진입점 파일에 대한 상대 경로 배열입니다.

signedLink*를 파일 경로로 바꿔 다운로드 가능한 URL을 구성합니다.

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

쿼리 매개변수(Policy, Signature, Key-Pair-Id)는 그대로 유지되어 요청을 인증합니다. GET 요청에 Authorization 헤더 없이 CDN에서 직접 파일 바이트를 다운로드하세요.

진입점: roots의 각 값은 tileset.json 파일에 대한 경로입니다(스캔 세션당 하나).

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

타일 탐색: tileset.jsonOGC 3D Tiles 사양을 따릅니다. 타일 노드 트리를 포함합니다. 각 노드에는 tileset 디렉터리를 기준으로 한 .glb 파일을 가리키는 uri가 있는 content 필드가 있을 수 있습니다.

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

타일 경로를 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

트리를 재귀적으로 탐색하여 모든 타일을 다운로드하세요.

진입점: roots에는 ["hlod_tree.json"]이 포함됩니다.

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

트리 구조: hlod_tree.json에는 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" }
]
}
}

타일 파일 이름 지정: 지오메트리 및 텍스처 파일은 {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

children 배열을 재귀적으로 탐색하여 모든 상세 수준의 모든 model_path 값을 찾으세요.

진입점: roots에는 ["stations.json"]이 포함됩니다.

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

추가 파일:

  • index.tsv: stations.json과 함께 있는 탭 구분 인덱스 파일
  • 포토스피어 이미지는 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

stations.json을 파싱하여 모든 스테이션 디렉터리와 관련 이미지 파일을 찾으세요.

이 스크립트는 인증하고, 사이트를 찾고, 첫 번째 번들을 가져오고, 진입점 파일을 다운로드한 다음, 컴포넌트당 참조된 타일 또는 이미지 하나를 따라갑니다.

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