feat: integrate SimpleCADAPI 2.0.2 CAD workflows
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate and semantically patch coordinate-free Assembly DesignIR."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import jsonschema
|
||||
|
||||
|
||||
FORBIDDEN_PLACEMENT_KEYS = {
|
||||
"transform",
|
||||
"transforms",
|
||||
"matrix",
|
||||
"matrices",
|
||||
"placement",
|
||||
"placements",
|
||||
"position",
|
||||
"positions",
|
||||
"location",
|
||||
"locations",
|
||||
"xyz",
|
||||
"rpy",
|
||||
"quaternion",
|
||||
"translation",
|
||||
}
|
||||
|
||||
|
||||
def walk_forbidden(value: Any, path: str = "$") -> list[str]:
|
||||
failures: list[str] = []
|
||||
if isinstance(value, dict):
|
||||
for key, child in value.items():
|
||||
child_path = f"{path}.{key}"
|
||||
if key.lower() in FORBIDDEN_PLACEMENT_KEYS:
|
||||
failures.append(child_path)
|
||||
failures.extend(walk_forbidden(child, child_path))
|
||||
elif isinstance(value, list):
|
||||
for index, child in enumerate(value):
|
||||
failures.extend(walk_forbidden(child, f"{path}[{index}]"))
|
||||
return failures
|
||||
|
||||
|
||||
def component_interfaces(payload: dict[str, Any]) -> dict[str, set[str]]:
|
||||
return {
|
||||
component["id"]: {interface["id"] for interface in component["interfaces"]}
|
||||
for component in payload["components"]
|
||||
}
|
||||
|
||||
|
||||
def validate_references(payload: dict[str, Any]) -> list[str]:
|
||||
failures: list[str] = []
|
||||
interfaces = component_interfaces(payload)
|
||||
component_ids = set(interfaces)
|
||||
if len(component_ids) != len(payload["components"]):
|
||||
failures.append("component ids are not unique")
|
||||
|
||||
relation_ids: set[str] = set()
|
||||
adjacency = {component_id: set() for component_id in component_ids}
|
||||
for relation in payload["relations"]:
|
||||
if relation["id"] in relation_ids:
|
||||
failures.append(f"duplicate relation id: {relation['id']}")
|
||||
relation_ids.add(relation["id"])
|
||||
endpoints: list[tuple[str, str]] = []
|
||||
for side in ("a", "b"):
|
||||
component_id, interface_id = relation[side].split(".", 1)
|
||||
endpoints.append((component_id, interface_id))
|
||||
if component_id not in interfaces:
|
||||
failures.append(
|
||||
f"{relation['id']}.{side}: unknown component {component_id}"
|
||||
)
|
||||
elif interface_id not in interfaces[component_id]:
|
||||
failures.append(
|
||||
f"{relation['id']}.{side}: unknown interface "
|
||||
f"{component_id}.{interface_id}"
|
||||
)
|
||||
if all(component_id in component_ids for component_id, _ in endpoints):
|
||||
adjacency[endpoints[0][0]].add(endpoints[1][0])
|
||||
adjacency[endpoints[1][0]].add(endpoints[0][0])
|
||||
|
||||
anchor = payload["kinematics"]["anchor_component"]
|
||||
if anchor not in component_ids:
|
||||
failures.append(f"unknown anchor component: {anchor}")
|
||||
elif component_ids:
|
||||
visited: set[str] = set()
|
||||
frontier = [anchor]
|
||||
while frontier:
|
||||
current = frontier.pop()
|
||||
if current in visited:
|
||||
continue
|
||||
visited.add(current)
|
||||
frontier.extend(adjacency[current] - visited)
|
||||
disconnected = sorted(component_ids - visited)
|
||||
if disconnected:
|
||||
failures.append(
|
||||
"constraint graph disconnected: " + ", ".join(disconnected)
|
||||
)
|
||||
|
||||
group_ids: set[str] = set()
|
||||
for group in payload["kinematics"]["rigid_groups"]:
|
||||
if group["id"] in group_ids:
|
||||
failures.append(f"duplicate rigid group id: {group['id']}")
|
||||
group_ids.add(group["id"])
|
||||
for member in group["members"]:
|
||||
if member not in component_ids:
|
||||
failures.append(
|
||||
f"rigid group {group['id']} has unknown member {member}"
|
||||
)
|
||||
|
||||
joint_ids: set[str] = set()
|
||||
for joint in payload["kinematics"]["joints"]:
|
||||
joint_ids.add(joint["id"])
|
||||
for side in ("parent_group", "child_group"):
|
||||
if joint[side] not in group_ids:
|
||||
failures.append(
|
||||
f"joint {joint['id']} has unknown {side} {joint[side]}"
|
||||
)
|
||||
for ref_key in ("axis_ref", "station_ref"):
|
||||
component_id, interface_id = joint[ref_key].split(".", 1)
|
||||
if (
|
||||
component_id not in interfaces
|
||||
or interface_id not in interfaces[component_id]
|
||||
):
|
||||
failures.append(
|
||||
f"joint {joint['id']} has unknown {ref_key} "
|
||||
f"{joint[ref_key]}"
|
||||
)
|
||||
|
||||
for transmission in payload["kinematics"]["transmissions"]:
|
||||
for key in ("input_joint", "output_joint"):
|
||||
if transmission[key] not in joint_ids:
|
||||
failures.append(
|
||||
f"transmission {transmission['id']} has unknown "
|
||||
f"{key} {transmission[key]}"
|
||||
)
|
||||
fixed_member = transmission.get("fixed_member")
|
||||
if fixed_member and fixed_member not in component_ids:
|
||||
failures.append(
|
||||
f"transmission {transmission['id']} has unknown fixed member "
|
||||
f"{fixed_member}"
|
||||
)
|
||||
|
||||
parameter_ids = set(payload["parameters"])
|
||||
for parameter_id in payload["edit_interface"]["editable_parameters"]:
|
||||
if parameter_id not in parameter_ids:
|
||||
failures.append(f"unknown editable parameter: {parameter_id}")
|
||||
elif not payload["parameters"][parameter_id]["editable"]:
|
||||
failures.append(f"parameter is not editable: {parameter_id}")
|
||||
|
||||
return failures
|
||||
|
||||
|
||||
def apply_patch(payload: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]:
|
||||
if patch["target_model"] != payload["model_id"]:
|
||||
raise ValueError("Patch target_model does not match base model")
|
||||
result = copy.deepcopy(payload)
|
||||
groups = {
|
||||
group["id"]: group for group in result["kinematics"]["rigid_groups"]
|
||||
}
|
||||
for operation in patch["operations"]:
|
||||
kind = operation["op"]
|
||||
if kind == "add_parameter":
|
||||
result["parameters"][operation["id"]] = operation["value"]
|
||||
result["edit_interface"]["editable_parameters"].append(
|
||||
operation["id"]
|
||||
)
|
||||
elif kind == "add_component":
|
||||
result["components"].append(operation["value"])
|
||||
elif kind == "add_relation":
|
||||
result["relations"].append(operation["value"])
|
||||
elif kind == "add_rigid_group_member":
|
||||
groups[operation["group"]]["members"].append(
|
||||
operation["component"]
|
||||
)
|
||||
elif kind == "add_preserved_interface":
|
||||
result["edit_interface"]["preserved_interfaces"].append(
|
||||
operation["value"]
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unsupported semantic patch operation: {kind}")
|
||||
result["model_id"] = f"{payload['model_id']}_with_output_shaft"
|
||||
return result
|
||||
|
||||
|
||||
def validate(
|
||||
payload: dict[str, Any], schema: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
schema_errors = sorted(
|
||||
jsonschema.Draft202012Validator(schema).iter_errors(payload),
|
||||
key=lambda error: list(error.absolute_path),
|
||||
)
|
||||
failures = [
|
||||
f"{'.'.join(map(str, error.absolute_path))}: {error.message}"
|
||||
for error in schema_errors
|
||||
]
|
||||
forbidden = walk_forbidden(payload)
|
||||
failures.extend(
|
||||
f"coordinate placement key is forbidden: {path}" for path in forbidden
|
||||
)
|
||||
if not schema_errors:
|
||||
failures.extend(validate_references(payload))
|
||||
return {
|
||||
"status": "pass" if not failures else "fail",
|
||||
"schema_version": payload.get("schema_version"),
|
||||
"model_id": payload.get("model_id"),
|
||||
"coordinate_free": not forbidden,
|
||||
"component_count": len(payload.get("components", [])),
|
||||
"relation_count": len(payload.get("relations", [])),
|
||||
"joint_count": len(payload.get("kinematics", {}).get("joints", [])),
|
||||
"transmission_count": len(
|
||||
payload.get("kinematics", {}).get("transmissions", [])
|
||||
),
|
||||
"failures": failures,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("model", type=Path)
|
||||
parser.add_argument("--schema", type=Path, required=True)
|
||||
parser.add_argument("--patch", type=Path)
|
||||
parser.add_argument("--resolved-output", type=Path)
|
||||
parser.add_argument("--report", type=Path)
|
||||
args = parser.parse_args()
|
||||
|
||||
payload = json.loads(args.model.read_text(encoding="utf-8"))
|
||||
schema = json.loads(args.schema.read_text(encoding="utf-8"))
|
||||
if args.patch:
|
||||
patch = json.loads(args.patch.read_text(encoding="utf-8"))
|
||||
payload = apply_patch(payload, patch)
|
||||
if args.resolved_output:
|
||||
args.resolved_output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.resolved_output.write_text(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
report = validate(payload, schema)
|
||||
if args.report:
|
||||
args.report.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.report.write_text(
|
||||
json.dumps(report, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(json.dumps(report, ensure_ascii=False))
|
||||
raise SystemExit(0 if report["status"] == "pass" else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user