768 lines
26 KiB
Python
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())
|