302 lines
10 KiB
Python
302 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""Extract a private, backend-neutral case record from an analytic STEP model."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import math
|
|
from collections import Counter, defaultdict
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
def _rounded(value: float) -> float:
|
|
return round(float(value), 6)
|
|
|
|
|
|
def _component(value: Any, name: str) -> float:
|
|
component = getattr(value, name)
|
|
return float(component() if callable(component) else component)
|
|
|
|
|
|
def _point(value: Any) -> list[float]:
|
|
return [_rounded(_component(value, name)) for name in ("X", "Y", "Z")]
|
|
|
|
|
|
def _direction(value: Any) -> list[float]:
|
|
return _point(value)
|
|
|
|
|
|
def _canonical_axis(direction: list[float]) -> str:
|
|
index = max(range(3), key=lambda item: abs(direction[item]))
|
|
return ("x", "y", "z")[index]
|
|
|
|
|
|
def _radial_offset(location: list[float], center: list[float], axis: str) -> float:
|
|
axes = {"x": (1, 2), "y": (0, 2), "z": (0, 1)}
|
|
first, second = axes[axis]
|
|
return math.hypot(
|
|
location[first] - center[first],
|
|
location[second] - center[second],
|
|
)
|
|
|
|
|
|
def _surface_record(face: Any, index: int) -> dict[str, Any]:
|
|
from OCP.BRepAdaptor import BRepAdaptor_Surface
|
|
from OCP.GeomAbs import (
|
|
GeomAbs_Cone,
|
|
GeomAbs_Cylinder,
|
|
GeomAbs_Plane,
|
|
GeomAbs_Sphere,
|
|
GeomAbs_Torus,
|
|
)
|
|
|
|
adaptor = BRepAdaptor_Surface(face.wrapped)
|
|
kind = adaptor.GetType()
|
|
record: dict[str, Any] = {
|
|
"surface_id": f"face_{index}",
|
|
"area": _rounded(face.area),
|
|
"center": _point(face.center()),
|
|
}
|
|
if kind == GeomAbs_Plane:
|
|
plane = adaptor.Plane()
|
|
record.update(
|
|
{
|
|
"type": "plane",
|
|
"location": _point(plane.Location()),
|
|
"axis": _direction(plane.Axis().Direction()),
|
|
}
|
|
)
|
|
elif kind == GeomAbs_Cylinder:
|
|
cylinder = adaptor.Cylinder()
|
|
record.update(
|
|
{
|
|
"type": "cylinder",
|
|
"location": _point(cylinder.Location()),
|
|
"axis": _direction(cylinder.Axis().Direction()),
|
|
"radius": _rounded(cylinder.Radius()),
|
|
}
|
|
)
|
|
elif kind == GeomAbs_Cone:
|
|
cone = adaptor.Cone()
|
|
record.update(
|
|
{
|
|
"type": "cone",
|
|
"location": _point(cone.Location()),
|
|
"axis": _direction(cone.Axis().Direction()),
|
|
"reference_radius": _rounded(cone.RefRadius()),
|
|
"semi_angle_radians": _rounded(cone.SemiAngle()),
|
|
}
|
|
)
|
|
elif kind == GeomAbs_Sphere:
|
|
sphere = adaptor.Sphere()
|
|
record.update(
|
|
{
|
|
"type": "sphere",
|
|
"location": _point(sphere.Location()),
|
|
"radius": _rounded(sphere.Radius()),
|
|
}
|
|
)
|
|
elif kind == GeomAbs_Torus:
|
|
torus = adaptor.Torus()
|
|
record.update(
|
|
{
|
|
"type": "torus",
|
|
"location": _point(torus.Location()),
|
|
"axis": _direction(torus.Axis().Direction()),
|
|
"major_radius": _rounded(torus.MajorRadius()),
|
|
"minor_radius": _rounded(torus.MinorRadius()),
|
|
}
|
|
)
|
|
else:
|
|
record["type"] = "other"
|
|
return record
|
|
|
|
|
|
def _semantic_summary(
|
|
surfaces: list[dict[str, Any]], bbox_center: list[float], bbox_size: list[float]
|
|
) -> tuple[str, list[dict[str, Any]], list[dict[str, Any]], dict[str, Any]]:
|
|
cylinders = [item for item in surfaces if item["type"] == "cylinder"]
|
|
cones = [item for item in surfaces if item["type"] == "cone"]
|
|
axis_votes: Counter[str] = Counter()
|
|
for surface in cylinders + cones:
|
|
axis_votes[_canonical_axis(surface["axis"])] += max(surface["area"], 1.0)
|
|
dominant_axis = axis_votes.most_common(1)[0][0] if axis_votes else "z"
|
|
scale = max(bbox_size) if bbox_size else 1.0
|
|
coaxial_tolerance = max(scale * 0.005, 1e-4)
|
|
|
|
parallel_cylinders: list[dict[str, Any]] = []
|
|
coaxial_cylinders: list[dict[str, Any]] = []
|
|
off_axis_cylinders: list[dict[str, Any]] = []
|
|
for surface in cylinders:
|
|
if _canonical_axis(surface["axis"]) != dominant_axis:
|
|
continue
|
|
enriched = dict(surface)
|
|
enriched["radial_offset"] = _rounded(
|
|
_radial_offset(surface["location"], bbox_center, dominant_axis)
|
|
)
|
|
parallel_cylinders.append(enriched)
|
|
if enriched["radial_offset"] <= coaxial_tolerance:
|
|
coaxial_cylinders.append(enriched)
|
|
else:
|
|
off_axis_cylinders.append(enriched)
|
|
|
|
distinct_coaxial_radii = {
|
|
round(surface["radius"], 4) for surface in coaxial_cylinders
|
|
}
|
|
repeated_groups: dict[float, list[dict[str, Any]]] = defaultdict(list)
|
|
for surface in off_axis_cylinders:
|
|
repeated_groups[round(surface["radius"], 4)].append(surface)
|
|
repeated_group_sizes = sorted(
|
|
(len(rows) for rows in repeated_groups.values()), reverse=True
|
|
)
|
|
parallel_cones = [
|
|
item for item in cones if _canonical_axis(item["axis"]) == dominant_axis
|
|
]
|
|
has_rotational_stack = len(distinct_coaxial_radii) >= 2
|
|
has_repeated_axial_holes = bool(repeated_group_sizes and repeated_group_sizes[0] >= 3)
|
|
has_multi_axis_passages = any(
|
|
_canonical_axis(item["axis"]) != dominant_axis for item in cylinders
|
|
)
|
|
|
|
features: list[dict[str, Any]] = []
|
|
if has_rotational_stack:
|
|
features.extend(
|
|
[
|
|
{"type": "rotational_body"},
|
|
{"type": "coaxial_cylindrical_stack"},
|
|
]
|
|
)
|
|
if len(distinct_coaxial_radii) >= 3:
|
|
features.append({"type": "central_passage_candidate"})
|
|
if parallel_cones:
|
|
features.append({"type": "conical_transition"})
|
|
if has_repeated_axial_holes:
|
|
features.append({"type": "repeated_axial_hole_pattern"})
|
|
if has_multi_axis_passages:
|
|
features.append({"type": "multi_axis_passage"})
|
|
|
|
constraints: list[dict[str, Any]] = []
|
|
if has_rotational_stack:
|
|
constraints.extend(
|
|
[
|
|
{"id": "dominant_axis_alignment", "type": "axis_alignment"},
|
|
{"id": "coaxial_stack", "type": "coaxial"},
|
|
]
|
|
)
|
|
if has_repeated_axial_holes:
|
|
constraints.append(
|
|
{"id": "repeated_radius_group", "type": "repeated_feature"}
|
|
)
|
|
if parallel_cones:
|
|
constraints.append(
|
|
{"id": "transition_axis_continuity", "type": "axis_alignment"}
|
|
)
|
|
|
|
if has_rotational_stack and parallel_cones and has_repeated_axial_holes:
|
|
family = "flanged_hub_adapter_candidate"
|
|
elif has_rotational_stack:
|
|
family = "rotational_part_candidate"
|
|
elif has_repeated_axial_holes:
|
|
family = "patterned_prismatic_part_candidate"
|
|
else:
|
|
family = "unclassified_part"
|
|
|
|
summary = {
|
|
"dominant_axis": dominant_axis,
|
|
"coaxial_cylinder_surface_count": len(coaxial_cylinders),
|
|
"distinct_coaxial_radius_count": len(distinct_coaxial_radii),
|
|
"off_axis_parallel_cylinder_surface_count": len(off_axis_cylinders),
|
|
"largest_repeated_radius_group": repeated_group_sizes[0]
|
|
if repeated_group_sizes
|
|
else 0,
|
|
"parallel_cone_surface_count": len(parallel_cones),
|
|
"has_multi_axis_passages": has_multi_axis_passages,
|
|
}
|
|
return family, features, constraints, summary
|
|
|
|
|
|
def extract_step_case(source: Path) -> dict[str, Any]:
|
|
from build123d import import_step
|
|
|
|
resolved = source.expanduser().resolve()
|
|
source_bytes = resolved.read_bytes()
|
|
shape = import_step(str(resolved))
|
|
bbox = shape.bounding_box()
|
|
bbox_min = _point(bbox.min)
|
|
bbox_max = _point(bbox.max)
|
|
bbox_size = _point(bbox.size)
|
|
bbox_center = [
|
|
_rounded((lower + upper) / 2.0)
|
|
for lower, upper in zip(bbox_min, bbox_max)
|
|
]
|
|
surfaces = [
|
|
_surface_record(face, index) for index, face in enumerate(shape.faces())
|
|
]
|
|
surface_counts = Counter(surface["type"] for surface in surfaces)
|
|
family, features, constraints, semantic_summary = _semantic_summary(
|
|
surfaces, bbox_center, bbox_size
|
|
)
|
|
digest = hashlib.sha256(source_bytes).hexdigest()
|
|
return {
|
|
"schema_version": "1.0",
|
|
"case_kind": "private_step_evidence",
|
|
"case_id": digest,
|
|
"provenance": {
|
|
"source_path": str(resolved),
|
|
"source_sha256": digest,
|
|
"source_format": "step",
|
|
},
|
|
"geometry_evidence": {
|
|
"valid": bool(shape.is_valid),
|
|
"solid_count": len(shape.solids()),
|
|
"face_count": len(shape.faces()),
|
|
"volume": _rounded(shape.volume),
|
|
"bounding_box": {
|
|
"min": bbox_min,
|
|
"max": bbox_max,
|
|
"size": bbox_size,
|
|
"center": bbox_center,
|
|
},
|
|
"analytic_surface_counts": dict(sorted(surface_counts.items())),
|
|
"analytic_surfaces": surfaces,
|
|
},
|
|
"design_ir": {
|
|
"part_family": family,
|
|
"features": features,
|
|
"constraints": constraints,
|
|
"normalized_observations": [],
|
|
"semantic_summary": semantic_summary,
|
|
},
|
|
"experience": {
|
|
"rules": [],
|
|
"validation_targets": [],
|
|
"promotion_state": "evidence_only",
|
|
},
|
|
}
|
|
|
|
|
|
def write_json(path: Path, payload: dict[str, Any]) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(
|
|
json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
|
|
)
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(
|
|
description="Extract a private case JSON from a STEP file."
|
|
)
|
|
parser.add_argument("source", type=Path)
|
|
parser.add_argument("--output", type=Path, required=True)
|
|
args = parser.parse_args(argv)
|
|
write_json(args.output, extract_step_case(args.source))
|
|
print(args.output)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|