380 lines
18 KiB
Python
380 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
"""Download every relevant public API artifact for one Onshape Part Studio.
|
|
|
|
The output is a self-describing directory for developing and validating an
|
|
Onshape-feature-tree-to-CDSL converter. It intentionally collects both the
|
|
editable source representation (features, sketches, parameters and queries)
|
|
and independent reconstruction targets (STEP, Parasolid, meshes, topology and
|
|
mass properties). Credentials are read from environment variables or hidden
|
|
terminal prompts and are never written to the output directory.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import base64
|
|
import getpass
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import ssl
|
|
import sys
|
|
import time
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
from dataclasses import asdict, dataclass
|
|
from pathlib import Path
|
|
from typing import Any, Callable
|
|
|
|
from download_onshape_samples import PartStudioRef, parse_part_studio_url
|
|
|
|
|
|
API_VERSION = "v17"
|
|
DEFAULT_OUTPUT = Path("json_to_cdsl/input/onshape_complete")
|
|
DEFAULT_SAMPLE_ID = "00000352"
|
|
DEFAULT_SAMPLE_URL = (
|
|
"https://cad.onshape.com/documents/4185972a944744d8a7a0f2b4/"
|
|
"w/d82d7eef8edf4342b7e49732/e/b6d6b562e8b64e7ea50d8325"
|
|
)
|
|
JSON_ACCEPT = "application/json;charset=UTF-8; qs=0.09"
|
|
USER_AGENT = "cdsl-cad-onshape-complete-downloader/1.0"
|
|
|
|
|
|
@dataclass
|
|
class DownloadResult:
|
|
name: str
|
|
status: str
|
|
path: str | None = None
|
|
url: str | None = None
|
|
bytes: int | None = None
|
|
sha256: str | None = None
|
|
http_status: int | None = None
|
|
content_type: str | None = None
|
|
message: str | None = None
|
|
|
|
|
|
class OnshapeClient:
|
|
def __init__(self, ref: PartStudioRef, authorization: str, timeout: float) -> None:
|
|
self.ref = ref
|
|
self.authorization = authorization
|
|
self.timeout = timeout
|
|
self.context = ssl.create_default_context(cafile=self._ca_bundle())
|
|
|
|
@staticmethod
|
|
def _ca_bundle() -> str | None:
|
|
try:
|
|
import certifi
|
|
except ImportError:
|
|
return None
|
|
return certifi.where()
|
|
|
|
def url(self, path: str, query: dict[str, Any] | None = None) -> str:
|
|
encoded = urllib.parse.urlencode(query or {}, doseq=True)
|
|
suffix = f"?{encoded}" if encoded else ""
|
|
return f"https://{self.ref.stack}/api/{API_VERSION}{path}{suffix}"
|
|
|
|
def request(
|
|
self,
|
|
method: str,
|
|
path: str,
|
|
query: dict[str, Any] | None = None,
|
|
body: dict[str, Any] | None = None,
|
|
accept: str = JSON_ACCEPT,
|
|
) -> tuple[bytes, str | None, str]:
|
|
payload = None if body is None else json.dumps(body).encode("utf-8")
|
|
request = urllib.request.Request(
|
|
self.url(path, query),
|
|
data=payload,
|
|
method=method,
|
|
headers={
|
|
"Accept": accept,
|
|
"Authorization": self.authorization,
|
|
"Content-Type": JSON_ACCEPT,
|
|
"User-Agent": USER_AGENT,
|
|
},
|
|
)
|
|
with urllib.request.urlopen(request, timeout=self.timeout, context=self.context) as response:
|
|
return response.read(), response.headers.get_content_type(), response.geturl()
|
|
|
|
def get_json(self, path: str, query: dict[str, Any] | None = None) -> Any:
|
|
raw, _, _ = self.request("GET", path, query)
|
|
return json.loads(raw.decode("utf-8"))
|
|
|
|
|
|
def credentials() -> tuple[str, str]:
|
|
access_key = os.environ.get("ONSHAPE_ACCESS_KEY") or getpass.getpass("Onshape access key: ")
|
|
secret_key = os.environ.get("ONSHAPE_SECRET_KEY") or getpass.getpass("Onshape secret key: ")
|
|
if not access_key or not secret_key:
|
|
raise ValueError("both Onshape access and secret keys are required")
|
|
return access_key, secret_key
|
|
|
|
|
|
def write_json(path: Path, value: Any) -> None:
|
|
path.write_text(json.dumps(value, ensure_ascii=True, indent=2) + "\n", encoding="utf-8")
|
|
|
|
|
|
def file_info(path: Path) -> tuple[int, str]:
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as stream:
|
|
for block in iter(lambda: stream.read(1024 * 1024), b""):
|
|
digest.update(block)
|
|
return path.stat().st_size, digest.hexdigest()
|
|
|
|
|
|
def relative_path(path: Path, root: Path) -> str:
|
|
return path.relative_to(root).as_posix()
|
|
|
|
|
|
def save_json(
|
|
client: OnshapeClient,
|
|
root: Path,
|
|
name: str,
|
|
path: str,
|
|
filename: str,
|
|
query: dict[str, Any] | None = None,
|
|
) -> DownloadResult:
|
|
try:
|
|
value = client.get_json(path, query)
|
|
destination = root / filename
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
write_json(destination, value)
|
|
size, digest = file_info(destination)
|
|
return DownloadResult(name, "downloaded", relative_path(destination, root), client.url(path, query), size, digest)
|
|
except urllib.error.HTTPError as exc:
|
|
return DownloadResult(name, "http_error", url=client.url(path, query), http_status=exc.code, message=exc.read().decode("utf-8", "replace")[:1000])
|
|
except (urllib.error.URLError, TimeoutError, OSError, ValueError) as exc:
|
|
return DownloadResult(name, "error", url=client.url(path, query), message=str(exc))
|
|
|
|
|
|
def save_binary(
|
|
client: OnshapeClient,
|
|
root: Path,
|
|
name: str,
|
|
path: str,
|
|
filename: str,
|
|
query: dict[str, Any] | None = None,
|
|
accept: str = "application/octet-stream",
|
|
) -> DownloadResult:
|
|
try:
|
|
payload, content_type, final_url = client.request("GET", path, query, accept=accept)
|
|
destination = root / filename
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
destination.write_bytes(payload)
|
|
size, digest = file_info(destination)
|
|
return DownloadResult(name, "downloaded", relative_path(destination, root), final_url, size, digest, content_type=content_type)
|
|
except urllib.error.HTTPError as exc:
|
|
return DownloadResult(name, "http_error", url=client.url(path, query), http_status=exc.code, message=exc.read().decode("utf-8", "replace")[:1000])
|
|
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
|
return DownloadResult(name, "error", url=client.url(path, query), message=str(exc))
|
|
|
|
|
|
def part_studio_path(ref: PartStudioRef, suffix: str) -> str:
|
|
return f"/partstudios/d/{ref.did}/{ref.wvm}/{ref.wvmid}/e/{ref.eid}{suffix}"
|
|
|
|
|
|
def collect_sketch_artifacts(
|
|
client: OnshapeClient,
|
|
root: Path,
|
|
sketches: Any,
|
|
) -> list[DownloadResult]:
|
|
if not isinstance(sketches, list):
|
|
return []
|
|
results: list[DownloadResult] = []
|
|
seen_ids: set[str] = set()
|
|
for sketch in sketches:
|
|
if not isinstance(sketch, dict):
|
|
continue
|
|
sketch_id = sketch.get("sketchId") or sketch.get("featureId")
|
|
if not isinstance(sketch_id, str) or not sketch_id or sketch_id in seen_ids:
|
|
continue
|
|
seen_ids.add(sketch_id)
|
|
quoted_id = urllib.parse.quote(sketch_id, safe="")
|
|
base = part_studio_path(client.ref, f"/sketches/{quoted_id}")
|
|
results.append(
|
|
save_json(client, root, f"sketch/{sketch_id}/bounding_box", f"{base}/boundingboxes", f"sketches/{sketch_id}/bounding_box.json")
|
|
)
|
|
results.append(
|
|
save_json(client, root, f"sketch/{sketch_id}/tessellation", f"{base}/tessellatedentities", f"sketches/{sketch_id}/tessellated_entities.json")
|
|
)
|
|
return results
|
|
|
|
|
|
def export_step(client: OnshapeClient, root: Path, sample_id: str, poll_seconds: float, poll_limit: int) -> DownloadResult:
|
|
path = part_studio_path(client.ref, "/export/step")
|
|
body = {
|
|
"destinationName": f"{sample_id}.step",
|
|
"grouping": True,
|
|
"notifyUser": False,
|
|
"storeInDocument": False,
|
|
"triggerAutoDownload": False,
|
|
"stepUnit": "METER",
|
|
"stepVersionString": "AP242",
|
|
}
|
|
try:
|
|
raw, _, _ = client.request("POST", path, body=body)
|
|
translation = json.loads(raw.decode("utf-8"))
|
|
translation_id = translation.get("id")
|
|
if not isinstance(translation_id, str) or not translation_id:
|
|
return DownloadResult("step", "error", url=client.url(path), message="STEP export response has no translation id")
|
|
request_path = "/translations/" + urllib.parse.quote(translation_id, safe="")
|
|
for _ in range(poll_limit):
|
|
state = client.get_json(request_path)
|
|
request_state = state.get("requestState") if isinstance(state, dict) else None
|
|
if request_state == "DONE":
|
|
external_ids = state.get("resultExternalDataIds", [])
|
|
if not isinstance(external_ids, list) or not external_ids:
|
|
return DownloadResult("step", "error", url=client.url(request_path), message="completed STEP export has no external data id")
|
|
external_id = external_ids[0]
|
|
if not isinstance(external_id, str):
|
|
return DownloadResult("step", "error", url=client.url(request_path), message="invalid STEP external data id")
|
|
return save_binary(
|
|
client,
|
|
root,
|
|
"step",
|
|
f"/documents/d/{client.ref.did}/externaldata/{urllib.parse.quote(external_id, safe='')}",
|
|
"model.step",
|
|
accept="application/step, application/octet-stream",
|
|
)
|
|
if request_state == "FAILED":
|
|
return DownloadResult("step", "http_error", url=client.url(request_path), message=str(state.get("failureReason", "translation failed")))
|
|
time.sleep(poll_seconds)
|
|
return DownloadResult("step", "error", url=client.url(request_path), message=f"STEP translation did not finish after {poll_limit} polls")
|
|
except urllib.error.HTTPError as exc:
|
|
return DownloadResult("step", "http_error", url=client.url(path), http_status=exc.code, message=exc.read().decode("utf-8", "replace")[:1000])
|
|
except (urllib.error.URLError, TimeoutError, OSError, ValueError) as exc:
|
|
return DownloadResult("step", "error", url=client.url(path), message=str(exc))
|
|
|
|
|
|
def export_stl(client: OnshapeClient, root: Path) -> DownloadResult:
|
|
"""Export a Part Studio STL, retrying the two API-supported encodings.
|
|
|
|
Some older public documents reject one encoding even though the Part Studio
|
|
can otherwise be read. The retry is recorded in the URL and the manifest
|
|
retains a useful error if neither server-side export succeeds.
|
|
"""
|
|
path = part_studio_path(client.ref, "/stl")
|
|
attempts = (
|
|
{"mode": "binary", "grouping": "true", "units": "METER"},
|
|
{"mode": "text", "grouping": "true", "units": "METER"},
|
|
{"mode": "binary", "grouping": "false", "units": "METER"},
|
|
)
|
|
failures: list[str] = []
|
|
for query in attempts:
|
|
result = save_binary(client, root, "stl", path, "model.stl", query)
|
|
if result.status == "downloaded":
|
|
return result
|
|
detail = result.message or result.status
|
|
failures.append(f"{urllib.parse.urlencode(query)}: {detail}")
|
|
return DownloadResult("stl", "error", url=client.url(path), message=" | ".join(failures))
|
|
|
|
|
|
def save_shaded_preview(root: Path) -> DownloadResult:
|
|
"""Extract the API's base64 PNG view as a locally inspectable preview."""
|
|
shaded_path = root / "shaded_views.json"
|
|
try:
|
|
value = json.loads(shaded_path.read_text(encoding="utf-8"))
|
|
images = value.get("images", []) if isinstance(value, dict) else []
|
|
encoded = images[0] if isinstance(images, list) and images else None
|
|
if not isinstance(encoded, str):
|
|
return DownloadResult("shaded_preview", "error", message="shaded view response contains no PNG")
|
|
payload = base64.b64decode(encoded, validate=True)
|
|
if not payload.startswith(b"\x89PNG\r\n\x1a\n"):
|
|
return DownloadResult("shaded_preview", "error", message="shaded view response is not a PNG")
|
|
destination = root / "shaded_preview.png"
|
|
destination.write_bytes(payload)
|
|
size, digest = file_info(destination)
|
|
return DownloadResult("shaded_preview", "downloaded", relative_path(destination, root), "shaded_views.json#images[0]", size, digest, content_type="image/png")
|
|
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
return DownloadResult("shaded_preview", "error", message=str(exc))
|
|
|
|
|
|
def complete_download(ref: PartStudioRef, authorization: str, output: Path, timeout: float, poll_seconds: float, poll_limit: int) -> dict[str, Any]:
|
|
root = output / ref.sample_id
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
client = OnshapeClient(ref, authorization, timeout)
|
|
request_info = {
|
|
**asdict(ref),
|
|
"api_version": API_VERSION,
|
|
"download_contract": "all relevant data exposed by the public API for this Part Studio, not an internal Onshape document backup",
|
|
}
|
|
write_json(root / "request.json", request_info)
|
|
|
|
common = {"rollbackBarIndex": -1}
|
|
resources: list[tuple[str, str, str, dict[str, Any] | None]] = [
|
|
("document", f"/documents/{ref.did}", "document.json", None),
|
|
("workspaces", f"/documents/d/{ref.did}/workspaces", "workspaces.json", None),
|
|
("elements", f"/documents/d/{ref.did}/{ref.wvm}/{ref.wvmid}/elements", "elements.json", None),
|
|
("unit_info", f"/documents/d/{ref.did}/{ref.wvm}/{ref.wvmid}/unitinfo", "unit_info.json", None),
|
|
("configuration", f"/elements/d/{ref.did}/{ref.wvm}/{ref.wvmid}/e/{ref.eid}/configuration", "configuration.json", None),
|
|
("parts", f"/parts/d/{ref.did}/{ref.wvm}/{ref.wvmid}/e/{ref.eid}", "parts.json", {"withThumbnails": "true", "includeFlatParts": "true"}),
|
|
("features", part_studio_path(ref, "/features"), "features.json", {**common, "includeGeometryIds": "true", "noSketchGeometry": "false"}),
|
|
("featurescript_representation", part_studio_path(ref, "/featurescriptrepresentation"), "featurescript_representation.json", common),
|
|
("feature_specs", part_studio_path(ref, "/featurespecs"), "feature_specs.json", None),
|
|
("body_details", part_studio_path(ref, "/bodydetails"), "body_details.json", {**common, "includeSurfaces": "true", "includeCompositeParts": "true", "includeGeometricData": "true"}),
|
|
("bounding_boxes", part_studio_path(ref, "/boundingboxes"), "bounding_boxes.json", {"includeHidden": "true", "includeWireBodies": "true"}),
|
|
("mass_properties", part_studio_path(ref, "/massproperties"), "mass_properties.json", {**common, "massAsGroup": "true"}),
|
|
("sketches", part_studio_path(ref, "/sketches"), "sketches.json", {"includeGeometry": "true", "output3D": "true", "curvePoints": "true"}),
|
|
("named_views", f"/partstudios/d/{ref.did}/e/{ref.eid}/namedViews", "named_views.json", None),
|
|
("tessellated_faces", part_studio_path(ref, "/tessellatedfaces"), "tessellated_faces.json", {**common, "outputVertexNormals": "true", "outputFacetNormals": "true", "outputIndexTable": "true", "outputErrorFaces": "true"}),
|
|
("tessellated_edges", part_studio_path(ref, "/tessellatededges"), "tessellated_edges.json", common),
|
|
("shaded_views", part_studio_path(ref, "/shadedviews"), "shaded_views.json", {"viewMatrix": "front", "outputWidth": 512, "outputHeight": 512, "edges": "show", "showAllParts": "true", "includeSurfaces": "true", "useAntiAliasing": "true"}),
|
|
]
|
|
results = [save_json(client, root, *resource) for resource in resources]
|
|
sketches_result = next((item for item in results if item.name == "sketches" and item.status == "downloaded"), None)
|
|
if sketches_result and sketches_result.path:
|
|
sketches = json.loads((root / sketches_result.path).read_text(encoding="utf-8"))
|
|
results.extend(collect_sketch_artifacts(client, root, sketches))
|
|
|
|
results.extend(
|
|
[
|
|
save_binary(client, root, "parasolid", part_studio_path(ref, "/parasolid"), "model.x_t", {"version": "0", "includeExportIds": "true", "binaryExport": "false"}, "text/plain, application/octet-stream"),
|
|
export_stl(client, root),
|
|
save_binary(client, root, "gltf", part_studio_path(ref, "/gltf"), "model.gltf", {**common, "outputSeparateFaceNodes": "true", "outputFaceAppearances": "true"}, "model/gltf+json, model/gltf-binary, application/octet-stream"),
|
|
save_binary(client, root, "thumbnail", f"/thumbnails/d/{ref.did}/{ref.wvm}/{ref.wvmid}/e/{ref.eid}/s/512x512", "thumbnail.png", {"rejectEmpty": "true"}, "image/png, image/*, application/octet-stream"),
|
|
]
|
|
)
|
|
results.append(save_shaded_preview(root))
|
|
results.append(export_step(client, root, ref.sample_id, poll_seconds, poll_limit))
|
|
manifest = {
|
|
"schema": "onshape.complete_sample.v1",
|
|
"source": request_info,
|
|
"resource_count": len(results),
|
|
"downloaded_count": sum(item.status == "downloaded" for item in results),
|
|
"unavailable_count": sum(item.status != "downloaded" for item in results),
|
|
"resources": [asdict(item) for item in results],
|
|
}
|
|
write_json(root / "manifest.json", manifest)
|
|
return manifest
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--url", default=DEFAULT_SAMPLE_URL, help="Onshape Part Studio URL")
|
|
parser.add_argument("--id", default=DEFAULT_SAMPLE_ID, help="local sample identifier")
|
|
parser.add_argument("--out", type=Path, default=DEFAULT_OUTPUT, help="output root")
|
|
parser.add_argument("--timeout", type=float, default=60.0, help="per-request timeout in seconds")
|
|
parser.add_argument("--poll-seconds", type=float, default=3.0, help="STEP translation poll interval")
|
|
parser.add_argument("--poll-limit", type=int, default=40, help="maximum STEP translation polls")
|
|
return parser
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
args = build_parser().parse_args(argv)
|
|
if args.poll_seconds <= 0 or args.poll_limit < 1:
|
|
raise ValueError("poll interval must be positive and poll limit must be at least 1")
|
|
ref = parse_part_studio_url(args.id, args.url)
|
|
access_key, secret_key = credentials()
|
|
token = base64.b64encode(f"{access_key}:{secret_key}".encode("utf-8")).decode("ascii")
|
|
manifest = complete_download(ref, f"Basic {token}", args.out, args.timeout, args.poll_seconds, args.poll_limit)
|
|
print(json.dumps({key: manifest[key] for key in ("resource_count", "downloaded_count", "unavailable_count")}, indent=2))
|
|
return 0 if manifest["downloaded_count"] else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
raise SystemExit(main())
|
|
except (OSError, ValueError) as exc:
|
|
print(f"error: {exc}", file=sys.stderr)
|
|
raise SystemExit(2)
|