177 lines
6.2 KiB
Python
Executable File
177 lines
6.2 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Low-RAM live CAD parity gate for manifest/model part-key consistency."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import struct
|
|
import sys
|
|
from collections import Counter
|
|
|
|
import requests
|
|
|
|
|
|
DEFAULT_HOST = "http://localhost:8888"
|
|
DEFAULT_EMAIL = "admin@hartomat.com"
|
|
DEFAULT_PASSWORD = "Admin1234!"
|
|
DEFAULT_TIMEOUT = 60
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(
|
|
description=(
|
|
"Verify that the live CAD scene manifest and served GLB expose the same "
|
|
"renderable part-key set, without duplicates or missing assignments."
|
|
)
|
|
)
|
|
parser.add_argument("--host", default=DEFAULT_HOST, help="Backend base URL.")
|
|
parser.add_argument("--email", default=DEFAULT_EMAIL, help="Login email.")
|
|
parser.add_argument("--password", default=DEFAULT_PASSWORD, help="Login password.")
|
|
parser.add_argument("--cad-id", required=True, help="CAD file id to inspect.")
|
|
parser.add_argument(
|
|
"--timeout",
|
|
type=int,
|
|
default=DEFAULT_TIMEOUT,
|
|
help="HTTP timeout in seconds.",
|
|
)
|
|
parser.add_argument(
|
|
"--allow-extra-non-mesh-keys",
|
|
action="store_true",
|
|
help=(
|
|
"Only enforce mesh-node parity. Kept for forward compatibility if the "
|
|
"manifest ever intentionally contains helper-only entries."
|
|
),
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
def login(session: requests.Session, *, host: str, email: str, password: str, timeout: int) -> None:
|
|
response = session.post(
|
|
f"{host}/api/auth/login",
|
|
json={"email": email, "password": password},
|
|
timeout=timeout,
|
|
)
|
|
response.raise_for_status()
|
|
payload = response.json()
|
|
session.headers["Authorization"] = f"Bearer {payload['access_token']}"
|
|
|
|
|
|
def fetch_manifest_part_keys(session: requests.Session, *, host: str, cad_id: str, timeout: int) -> set[str]:
|
|
response = session.get(f"{host}/api/cad/{cad_id}/scene-manifest", timeout=timeout)
|
|
response.raise_for_status()
|
|
payload = response.json()
|
|
return {
|
|
str(part["part_key"]).strip()
|
|
for part in payload.get("parts", [])
|
|
if part.get("part_key")
|
|
}
|
|
|
|
|
|
def fetch_glb_payload(session: requests.Session, *, host: str, cad_id: str, timeout: int) -> dict:
|
|
response = session.get(f"{host}/api/cad/{cad_id}/model", timeout=timeout)
|
|
response.raise_for_status()
|
|
data = response.content
|
|
if len(data) < 20:
|
|
raise RuntimeError("GLB payload is too small to contain a JSON chunk header")
|
|
json_len, json_type = struct.unpack_from("<II", data, 12)
|
|
if json_type != 0x4E4F534A:
|
|
raise RuntimeError(f"Unexpected GLB JSON chunk type: {json_type}")
|
|
return json.loads(data[20 : 20 + json_len])
|
|
|
|
|
|
def build_report(manifest_part_keys: set[str], glb_payload: dict) -> dict:
|
|
mesh_nodes = [node for node in glb_payload.get("nodes", []) if "mesh" in node]
|
|
live_part_keys = [
|
|
node.get("extras", {}).get("partKey")
|
|
for node in mesh_nodes
|
|
if node.get("extras", {}).get("partKey")
|
|
]
|
|
live_part_keys = [str(part_key).strip() for part_key in live_part_keys if str(part_key).strip()]
|
|
unique_live_part_keys = set(live_part_keys)
|
|
duplicate_live_part_keys = {
|
|
key: count for key, count in Counter(live_part_keys).items() if count > 1
|
|
}
|
|
missing_manifest_part_keys = sorted(manifest_part_keys - unique_live_part_keys)
|
|
extra_live_part_keys = sorted(unique_live_part_keys - manifest_part_keys)
|
|
return {
|
|
"manifest_parts": len(manifest_part_keys),
|
|
"mesh_nodes": len(mesh_nodes),
|
|
"live_part_keys": len(live_part_keys),
|
|
"unique_live_part_keys": len(unique_live_part_keys),
|
|
"missing_manifest_part_keys": len(missing_manifest_part_keys),
|
|
"extra_live_part_keys": len(extra_live_part_keys),
|
|
"duplicate_live_part_keys": len(duplicate_live_part_keys),
|
|
"sample_missing": missing_manifest_part_keys[:20],
|
|
"sample_extra": extra_live_part_keys[:20],
|
|
"sample_dupes": list(sorted(duplicate_live_part_keys.items())[:20]),
|
|
}
|
|
|
|
|
|
def evaluate_report(report: dict, *, allow_extra_non_mesh_keys: bool) -> list[str]:
|
|
failures: list[str] = []
|
|
if report["mesh_nodes"] != report["manifest_parts"]:
|
|
failures.append(
|
|
f"mesh_nodes={report['mesh_nodes']} does not match manifest_parts={report['manifest_parts']}"
|
|
)
|
|
if report["live_part_keys"] != report["mesh_nodes"]:
|
|
failures.append(
|
|
f"live_part_keys={report['live_part_keys']} does not match mesh_nodes={report['mesh_nodes']}"
|
|
)
|
|
if report["unique_live_part_keys"] != report["manifest_parts"]:
|
|
failures.append(
|
|
"unique_live_part_keys does not match manifest_parts"
|
|
)
|
|
if report["missing_manifest_part_keys"] != 0:
|
|
failures.append(
|
|
f"missing_manifest_part_keys={report['missing_manifest_part_keys']}"
|
|
)
|
|
if not allow_extra_non_mesh_keys and report["extra_live_part_keys"] != 0:
|
|
failures.append(
|
|
f"extra_live_part_keys={report['extra_live_part_keys']}"
|
|
)
|
|
if report["duplicate_live_part_keys"] != 0:
|
|
failures.append(
|
|
f"duplicate_live_part_keys={report['duplicate_live_part_keys']}"
|
|
)
|
|
return failures
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
session = requests.Session()
|
|
login(
|
|
session,
|
|
host=args.host,
|
|
email=args.email,
|
|
password=args.password,
|
|
timeout=args.timeout,
|
|
)
|
|
manifest_part_keys = fetch_manifest_part_keys(
|
|
session,
|
|
host=args.host,
|
|
cad_id=args.cad_id,
|
|
timeout=args.timeout,
|
|
)
|
|
glb_payload = fetch_glb_payload(
|
|
session,
|
|
host=args.host,
|
|
cad_id=args.cad_id,
|
|
timeout=args.timeout,
|
|
)
|
|
report = build_report(manifest_part_keys, glb_payload)
|
|
failures = evaluate_report(
|
|
report,
|
|
allow_extra_non_mesh_keys=args.allow_extra_non_mesh_keys,
|
|
)
|
|
print(json.dumps(report, indent=2, sort_keys=True))
|
|
if failures:
|
|
for failure in failures:
|
|
print(f"[cad-parity] {failure}", file=sys.stderr)
|
|
return 1
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|