스캔 파일 다운로드
이 가이드에서는 RealityConnect API에서 처리된 스캔 출력물(포인트 클라우드, 메시, 포토스피어 이미지)을 다운로드하는 방법을 설명합니다. 이 워크플로는 Client Credentials 플로우와 download:scan 스코프를 사용합니다.
사전 요구 사항
섹션 제목: “사전 요구 사항”- Client Credentials로 구성된 OAuth 애플리케이션
- 스코프:
read:basic,read:hierarchy,download:scan client_id와client_secret- 다운로드하려는 번들이 있는 사이트에 대한 콘텐츠 접근 권한
스캔 파일 다운로드에는 read:twin과 read:asset이 필요하지 않습니다.
스캔 데이터의 구성 방식
섹션 제목: “스캔 데이터의 구성 방식”처리된 스캔 데이터는 사이트에 연결된 번들에 존재합니다. 각 번들에는 하나 이상의 컴포넌트가 포함되며, 각 컴포넌트는 서로 다른 출력 형식입니다.
| 컴포넌트 유형 | 형식 | 설명 |
|---|---|---|
OgcPointCloudHlod | OGC 3D Tiles(개방형 표준) | 포인트 클라우드 데이터. .glb 타일 파일이 있는 tileset.json 진입점 |
RealityMeshHlod | Prevu3D HLOD | 메시 데이터. .pvt 타일 파일이 있는 hlod_tree.json 진입점 |
RealityPhotosphere | JPEG 이미지 | 포토스피어 데이터. .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} | 컴포넌트별 signedLink와 roots |
| 파일 다운로드 | 구성한 CDN URL에 대한 GET | 진입 파일, 타일, 이미지 |
1단계 — 인증
섹션 제목: “1단계 — 인증”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_credentials지역별 API URL을 확인합니다.
GET https://cloud-api.prevu3d.com/oauth/api-infoAuthorization: Bearer {access_token}응답에는 apiUrl(예: https://api-ue1.prevu3d.com/realityconnect-api)이 포함됩니다. 이후의 모든 API 호출에 이 값을 기본 URL로 사용하세요.
2단계 — 사이트 찾기
섹션 제목: “2단계 — 사이트 찾기”조직에서 시작하여 노드 계층 구조를 탐색합니다.
GET {api_url}/v1/nodes/{organizationId}/browseAuthorization: Bearer {access_token}type: "Site"인 노드를 찾을 때까지 트리를 재귀적으로 탐색합니다. 해당 노드의 id를 기록해 두세요.
3단계 — 번들 나열
섹션 제목: “3단계 — 번들 나열”GET {api_url}/v1/site/{siteId}/bundlesAuthorization: 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: 이 컴포넌트의 진입점 파일에 대한 상대 경로 배열입니다.
5단계 — 파일 다운로드
섹션 제목: “5단계 — 파일 다운로드”signedLink의 *를 파일 경로로 바꿔 다운로드 가능한 URL을 구성합니다.
download_url = component["signedLink"].replace("*", root)response = requests.get(download_url)쿼리 매개변수(Policy, Signature, Key-Pair-Id)는 그대로 유지되어 요청을 인증합니다. GET 요청에 Authorization 헤더 없이 CDN에서 직접 파일 바이트를 다운로드하세요.
컴포넌트별 세부 정보
섹션 제목: “컴포넌트별 세부 정보”OgcPointCloudHlod(포인트 클라우드)
섹션 제목: “OgcPointCloudHlod(포인트 클라우드)”진입점: roots의 각 값은 tileset.json 파일에 대한 경로입니다(스캔 세션당 하나).
for root in component["roots"]: url = component["signedLink"].replace("*", root) tileset = requests.get(url).json()타일 탐색: tileset.json은 OGC 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트리를 재귀적으로 탐색하여 모든 타일을 다운로드하세요.
RealityMeshHlod(메시)
섹션 제목: “RealityMeshHlod(메시)”진입점: 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).contentchildren 배열을 재귀적으로 탐색하여 모든 상세 수준의 모든 model_path 값을 찾으세요.
RealityPhotosphere(포토스피어)
섹션 제목: “RealityPhotosphere(포토스피어)”진입점: 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).contentstations.json을 파싱하여 모든 스테이션 디렉터리와 관련 이미지 파일을 찾으세요.
전체 예제
섹션 제목: “전체 예제”이 스크립트는 인증하고, 사이트를 찾고, 첫 번째 번들을 가져오고, 진입점 파일을 다운로드한 다음, 컴포넌트당 참조된 타일 또는 이미지 하나를 따라갑니다.
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")
break다음 단계
섹션 제목: “다음 단계”- Client Credentials 플로우 가이드에서 인증을 엔드투엔드로 설정하세요.
- Data Bundle 워크플로로 새 캡처를 업로드하고 처리하세요.
- API 참조에서 모든 작업을 살펴보세요.