Files
cadSet/cad-experience-plugin/skills/cad-experience-builder/scripts/step_to_case.py
T

768 lines
26 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
EXTRACTOR_VERSION = "3.0"
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 _ratio(numerator: float, denominator: float) -> float:
return _rounded(numerator / denominator) if denominator > 1e-9 else 0.0
def _observation(
name: str, value: float, numerator_role: str, denominator_role: str
) -> dict[str, Any]:
return {
"name": name,
"value": _rounded(value),
"numerator_role": numerator_role,
"denominator_role": denominator_role,
}
def _cardinality_class(count: int) -> str:
if count <= 0:
return "absent"
if count == 1:
return "single"
if count == 2:
return "paired"
if count <= 6:
return "repeated"
return "dense"
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
sorted_spans = sorted((max(float(value), 1e-9) for value in bbox_size))
short_span, middle_span, long_span = sorted_spans
long_to_middle = _ratio(long_span, middle_span)
middle_to_short = _ratio(middle_span, short_span)
short_to_long = _ratio(short_span, long_span)
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
)
largest_repeated_group = (
max(repeated_groups.values(), key=len) if repeated_groups else []
)
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
)
planes = [item for item in surfaces if item["type"] == "plane"]
toruses = [item for item in surfaces if item["type"] == "torus"]
spheres = [item for item in surfaces if item["type"] == "sphere"]
other_surfaces = [item for item in surfaces if item["type"] == "other"]
total_area = sum(max(float(item.get("area", 0.0)), 0.0) for item in surfaces)
planar_area = sum(max(float(item.get("area", 0.0)), 0.0) for item in planes)
cylindrical_area = sum(
max(float(item.get("area", 0.0)), 0.0) for item in cylinders
)
planar_fraction = _ratio(planar_area, total_area)
cylindrical_fraction = _ratio(cylindrical_area, total_area)
elongated = long_to_middle >= 3.0
plate_like = middle_to_short >= 4.0 and long_to_middle < 3.0
compact = long_to_middle < 2.0 and middle_to_short < 2.5
transverse_axes = {
"x": (1, 2),
"y": (0, 2),
"z": (0, 1),
}[dominant_axis]
transverse_span = max(bbox_size[index] for index in transverse_axes)
dominant_span = bbox_size[{"x": 0, "y": 1, "z": 2}[dominant_axis]]
largest_coaxial_radius = max(
(surface["radius"] for surface in coaxial_cylinders), default=0.0
)
smallest_coaxial_radius = min(
(surface["radius"] for surface in coaxial_cylinders), default=0.0
)
pattern_offsets = [
_radial_offset(surface["location"], bbox_center, dominant_axis)
for surface in largest_repeated_group
]
pattern_mean_offset = (
sum(pattern_offsets) / len(pattern_offsets) if pattern_offsets else 0.0
)
pattern_spread = (
max(pattern_offsets) - min(pattern_offsets) if pattern_offsets else 0.0
)
has_circular_pattern = bool(
len(pattern_offsets) >= 3
and pattern_mean_offset > coaxial_tolerance
and pattern_spread / pattern_mean_offset <= 0.08
)
# Multiple radii sharing a transverse center are a robust final-B-Rep
# signal for stepped bores/counterbores, without claiming operation order.
transverse_groups: dict[tuple[int, int, int], set[float]] = defaultdict(set)
axis_index = {"x": 0, "y": 1, "z": 2}[dominant_axis]
for surface in parallel_cylinders:
location = surface["location"]
key = (
int(round(location[transverse_axes[0]] / max(coaxial_tolerance, 1e-6))),
int(round(location[transverse_axes[1]] / max(coaxial_tolerance, 1e-6))),
axis_index,
)
transverse_groups[key].add(round(surface["radius"], 4))
has_stepped_cylindrical_passage = any(
len(radii) >= 2 for radii in transverse_groups.values()
)
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"})
features.append(
{
"type": (
"elongated_body"
if elongated
else "plate_like_body"
if plate_like
else "compact_body"
if compact
else "moderate_aspect_body"
)
}
)
if planar_fraction >= 0.55:
features.append({"type": "planar_dominant_body"})
if cylindrical_fraction >= 0.55:
features.append({"type": "cylindrical_dominant_body"})
if len(planes) >= 6:
features.append({"type": "multi_level_planar_profile"})
if len(cylinders) >= 2:
features.append({"type": "cylindrical_feature_network"})
if toruses:
features.append({"type": "toroidal_blend_or_groove"})
if spheres:
features.append({"type": "spherical_surface_feature"})
if other_surfaces:
features.append({"type": "freeform_surface_region"})
if has_circular_pattern:
features.append({"type": "circular_equal_radius_pattern"})
if has_stepped_cylindrical_passage:
features.append({"type": "stepped_cylindrical_passage"})
if has_rotational_stack:
features.append({"type": "stepped_rotational_profile"})
if elongated:
features.append({"type": "shaft_like_body"})
if len(distinct_coaxial_radii) >= 3 and elongated:
features.append({"type": "sleeve_or_hollow_shaft_candidate"})
if transverse_span > 0 and dominant_span / transverse_span < 0.8:
features.append({"type": "flange_like_rotational_body"})
if len(surfaces) >= 40:
features.append({"type": "high_topological_complexity"})
elif len(surfaces) >= 16:
features.append({"type": "medium_topological_complexity"})
else:
features.append({"type": "low_topological_complexity"})
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_circular_pattern:
constraints.append(
{"id": "common_pattern_radius", "type": "radial_pattern"}
)
if has_multi_axis_passages:
constraints.append(
{"id": "multiple_axis_system", "type": "axis_network"}
)
if has_stepped_cylindrical_passage:
constraints.append(
{"id": "shared_passage_axis", "type": "coaxial"}
)
if has_rotational_stack and has_repeated_axial_holes:
family = "flanged_rotational_part"
elif has_rotational_stack and elongated and len(distinct_coaxial_radii) >= 3:
family = "hollow_or_stepped_shaft"
elif has_rotational_stack and elongated:
family = "shaft"
elif has_rotational_stack:
family = "rotational_part"
elif has_repeated_axial_holes and plate_like:
family = "patterned_plate"
elif has_repeated_axial_holes:
family = "patterned_prismatic_part"
elif has_multi_axis_passages and len(cylinders) >= 3:
family = "multi_axis_manifold"
elif plate_like and planes:
family = "plate_or_bracket"
elif planar_fraction >= 0.55:
family = "prismatic_block"
elif other_surfaces or toruses or spheres:
family = "hybrid_freeform_part"
else:
family = "general_mechanical_part"
observations = [
_observation(
"bbox_short_to_long",
short_to_long,
"short_bounding_span",
"long_bounding_span",
),
_observation(
"bbox_middle_to_long",
_ratio(middle_span, long_span),
"middle_bounding_span",
"long_bounding_span",
),
_observation(
"planar_area_fraction",
planar_fraction,
"planar_surface_area",
"total_surface_area",
),
_observation(
"cylindrical_area_fraction",
cylindrical_fraction,
"cylindrical_surface_area",
"total_surface_area",
),
]
if largest_coaxial_radius > 0:
observations.append(
_observation(
"largest_coaxial_radius_to_transverse_span",
_ratio(largest_coaxial_radius, transverse_span),
"largest_coaxial_radius",
"transverse_bounding_span",
)
)
if smallest_coaxial_radius > 0 and largest_coaxial_radius > 0:
observations.append(
_observation(
"smallest_to_largest_coaxial_radius",
_ratio(smallest_coaxial_radius, largest_coaxial_radius),
"smallest_coaxial_radius",
"largest_coaxial_radius",
)
)
if pattern_mean_offset > 0:
observations.append(
_observation(
"pattern_radius_to_transverse_span",
_ratio(pattern_mean_offset, transverse_span),
"pattern_radius",
"transverse_bounding_span",
)
)
if largest_repeated_group:
observations.append(
_observation(
"repeated_feature_radius_to_transverse_span",
_ratio(largest_repeated_group[0]["radius"], transverse_span),
"repeated_feature_radius",
"transverse_bounding_span",
)
)
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,
"shape_class": (
"elongated" if elongated else "plate_like" if plate_like else "compact"
if compact
else "moderate_aspect"
),
"normalized_observations": observations,
}
return family, features, constraints, summary
def _reconstruction_evidence(
surfaces: list[dict[str, Any]],
features: list[dict[str, Any]],
constraints: list[dict[str, Any]],
semantic_summary: dict[str, Any],
) -> dict[str, Any]:
"""Describe a canonical reconstruction grammar without claiming CAD history."""
feature_roles = sorted(
{
str(item.get("type"))
for item in features
if isinstance(item, dict) and item.get("type")
}
)
relation_roles = sorted(
{
str(item.get("id") or item.get("type"))
for item in constraints
if isinstance(item, dict) and (item.get("id") or item.get("type"))
}
)
surface_counts = Counter(item.get("type", "other") for item in surfaces)
parameter_roles = [
"overall_long_span",
"overall_middle_span",
"overall_short_span",
"primary_axis_span",
"primary_transverse_span",
"secondary_transverse_span",
]
if "cylindrical_feature_network" in feature_roles:
parameter_roles.extend(
[
"passage_diameter_roles",
"passage_axis_offset_roles",
"passage_extent_roles",
]
)
if "coaxial_cylindrical_stack" in feature_roles:
parameter_roles.extend(
[
"coaxial_diameter_roles",
"axial_segment_length_roles",
"shoulder_position_roles",
]
)
if "repeated_axial_hole_pattern" in feature_roles:
parameter_roles.extend(
[
"pattern_member_diameter_role",
"pattern_radius_or_spacing_role",
"pattern_angular_or_linear_phase_role",
]
)
if "multi_level_planar_profile" in feature_roles:
parameter_roles.extend(
[
"planar_level_offset_roles",
"profile_width_roles",
"profile_length_roles",
]
)
if "conical_transition" in feature_roles:
parameter_roles.append("transition_slope_role")
stages: list[dict[str, Any]] = [
{
"id": "establish_reference_frame",
"operation": "define_datums",
"feature_roles": [],
"reference_roles": [
"part_center",
"primary_axis",
"primary_transverse_plane",
"secondary_transverse_plane",
],
},
{
"id": "construct_primary_envelope",
"operation": (
"revolve_profile"
if "rotational_body" in feature_roles
else "extrude_profile"
),
"feature_roles": [
role
for role in (
"rotational_body",
"planar_dominant_body",
"elongated_body",
"plate_like_body",
"compact_body",
"moderate_aspect_body",
)
if role in feature_roles
],
"reference_roles": ["part_center", "primary_axis"],
},
]
if "multi_level_planar_profile" in feature_roles:
stages.append(
{
"id": "establish_planar_levels",
"operation": "add_or_remove_profile_levels",
"feature_roles": ["multi_level_planar_profile"],
"reference_roles": ["primary_transverse_plane"],
}
)
if "coaxial_cylindrical_stack" in feature_roles:
stages.append(
{
"id": "construct_coaxial_stack",
"operation": "add_or_cut_coaxial_profiles",
"feature_roles": ["coaxial_cylindrical_stack"],
"reference_roles": ["primary_axis"],
}
)
if "cylindrical_feature_network" in feature_roles:
stages.append(
{
"id": "construct_passage_network",
"operation": "cut_semantic_passages",
"feature_roles": [
role
for role in (
"cylindrical_feature_network",
"central_passage_candidate",
"stepped_cylindrical_passage",
"multi_axis_passage",
)
if role in feature_roles
],
"reference_roles": ["primary_axis", "part_center"],
}
)
if "repeated_axial_hole_pattern" in feature_roles:
stages.append(
{
"id": "construct_repeated_feature_pattern",
"operation": "pattern_semantic_feature",
"feature_roles": [
role
for role in (
"repeated_axial_hole_pattern",
"circular_equal_radius_pattern",
)
if role in feature_roles
],
"reference_roles": ["primary_axis", "part_center"],
}
)
transition_roles = [
role
for role in (
"conical_transition",
"toroidal_blend_or_groove",
"spherical_surface_feature",
"freeform_surface_region",
)
if role in feature_roles
]
if transition_roles:
stages.append(
{
"id": "resolve_transitions_and_finishing",
"operation": "apply_transitions_or_blends",
"feature_roles": transition_roles,
"reference_roles": ["primary_axis"],
}
)
stages.append(
{
"id": "validate_reconstructed_shape",
"operation": "validate_geometry_and_relations",
"feature_roles": feature_roles,
"reference_roles": ["part_center", "primary_axis"],
}
)
return {
"interpretation": "canonical_reconstruction_plan_not_recovered_history",
"parameter_roles": sorted(set(parameter_roles)),
"datum_roles": [
"part_center",
"primary_axis",
"primary_transverse_plane",
"secondary_transverse_plane",
],
"feature_roles": feature_roles,
"relation_roles": relation_roles,
"canonical_stages": stages,
"private_cardinality_evidence": {
"surface_type_counts": dict(sorted(surface_counts.items())),
"surface_type_classes": {
kind: _cardinality_class(count)
for kind, count in sorted(surface_counts.items())
},
"feature_role_count": len(feature_roles),
"feature_role_count_class": _cardinality_class(len(feature_roles)),
"coaxial_radius_role_count": int(
semantic_summary.get("distinct_coaxial_radius_count", 0)
),
"coaxial_radius_role_count_class": _cardinality_class(
int(semantic_summary.get("distinct_coaxial_radius_count", 0))
),
"repeated_member_count": int(
semantic_summary.get("largest_repeated_radius_group", 0)
),
"repeated_member_count_class": _cardinality_class(
int(semantic_summary.get("largest_repeated_radius_group", 0))
),
},
"validation_roles": sorted(
{
"closed_solid",
"bounding_proportion_consistency",
"surface_mix_consistency",
*relation_roles,
}
),
}
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
)
reconstruction_evidence = _reconstruction_evidence(
surfaces, features, constraints, semantic_summary
)
digest = hashlib.sha256(source_bytes).hexdigest()
normalized_observations = semantic_summary.pop("normalized_observations", [])
return {
"schema_version": "2.0",
"extractor_version": EXTRACTOR_VERSION,
"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": normalized_observations,
"semantic_summary": semantic_summary,
"reconstruction_evidence": reconstruction_evidence,
},
"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())