#!/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())