feat: unify CAD workflows on DesignIR 3.0

This commit is contained in:
Jerry
2026-07-28 14:41:26 +08:00
parent 6e33bc0e11
commit b5738e9109
36 changed files with 9690 additions and 1305 deletions
@@ -11,7 +11,7 @@ from collections import Counter, defaultdict
from pathlib import Path
from typing import Any
EXTRACTOR_VERSION = "3.0"
EXTRACTOR_VERSION = "4.0"
def _rounded(value: float) -> float:
@@ -71,6 +71,184 @@ def _cardinality_class(count: int) -> str:
return "dense"
def _parameter_candidate(
name: str,
value: float | int,
unit: str,
confidence: float,
affects: list[str],
expected_change: str,
evidence_kind: str,
aliases: list[str] | None = None,
) -> dict[str, Any]:
"""Create an explicitly inferred, editable semantic parameter candidate."""
return {
"name": name,
"value": value if unit == "count" else _rounded(float(value)),
"unit": unit,
"editable_candidate": True,
"epistemic_status": "inferred_from_final_brep",
"confidence": _rounded(confidence),
"semantic_aliases": sorted(set(aliases or [])),
"affects_feature_roles": sorted(set(affects)),
"edit_contract": {
"expected_change": expected_change,
"preserve_roles": ["part_center", "primary_reference_frame"],
},
"source_evidence": {"kind": evidence_kind},
}
def _semantic_parameter_candidates(
surfaces: list[dict[str, Any]],
bbox_center: list[float],
bbox_size: list[float],
features: list[dict[str, Any]],
semantic_summary: dict[str, Any],
) -> list[dict[str, Any]]:
"""Name editable dimensions without pretending that STEP stored parameters."""
if not bbox_size or max(bbox_size) <= 1e-9:
return []
feature_roles = {
str(item.get("type"))
for item in features
if isinstance(item, dict) and item.get("type")
}
ordered_axes = sorted(
zip(("x", "y", "z"), bbox_size), key=lambda item: item[1], reverse=True
)
candidates = [
_parameter_candidate(
"overall_length",
ordered_axes[0][1],
"mm",
0.98,
["primary_envelope"],
"overall_length_changes",
f"bounding_box_{ordered_axes[0][0]}_span",
),
_parameter_candidate(
"overall_width",
ordered_axes[1][1],
"mm",
0.92,
["primary_envelope"],
"overall_width_changes",
f"bounding_box_{ordered_axes[1][0]}_span",
),
_parameter_candidate(
"overall_thickness",
ordered_axes[2][1],
"mm",
0.88,
["primary_envelope"],
"overall_thickness_changes",
f"bounding_box_{ordered_axes[2][0]}_span",
["plate_thickness"]
if "plate_like_body" in feature_roles
else ["minimum_envelope_span"],
),
]
dominant_axis = str(semantic_summary.get("dominant_axis", "z"))
transverse_axes = {"x": (1, 2), "y": (0, 2), "z": (0, 1)}[dominant_axis]
scale = max(bbox_size)
center_tolerance = max(scale * 0.005, 1e-4)
repeated_groups: dict[float, list[dict[str, Any]]] = defaultdict(list)
coaxial: list[dict[str, Any]] = []
for surface in surfaces:
if (
surface.get("type") != "cylinder"
or _canonical_axis(surface["axis"]) != dominant_axis
):
continue
radial_offset = _radial_offset(
surface["location"], bbox_center, dominant_axis
)
if radial_offset <= center_tolerance:
coaxial.append(surface)
else:
repeated_groups[round(float(surface["radius"]), 4)].append(surface)
repeated = max(repeated_groups.values(), key=len) if repeated_groups else []
unique_centers = {
(
round(float(item["location"][transverse_axes[0]]), 4),
round(float(item["location"][transverse_axes[1]]), 4),
)
for item in repeated
}
if len(unique_centers) >= 3:
member_radius = float(repeated[0]["radius"])
offsets = [
math.hypot(
first - bbox_center[transverse_axes[0]],
second - bbox_center[transverse_axes[1]],
)
for first, second in unique_centers
]
mean_offset = sum(offsets) / len(offsets)
circular = (
mean_offset > center_tolerance
and (max(offsets) - min(offsets)) / mean_offset <= 0.08
)
candidates.extend(
[
_parameter_candidate(
"repeated_feature_count",
len(unique_centers),
"count",
0.72,
["repeated_cylindrical_feature_pattern"],
"pattern_member_count_changes",
"unique_equal_radius_parallel_cylinder_axes",
["hole_count", "boss_count"],
),
_parameter_candidate(
"repeated_feature_diameter",
member_radius * 2.0,
"mm",
0.68,
["repeated_cylindrical_feature_pattern"],
"pattern_member_diameter_changes",
"equal_radius_cylindrical_surfaces",
["hole_diameter", "boss_diameter"],
),
]
)
if circular:
candidates.append(
_parameter_candidate(
"bolt_circle_diameter",
mean_offset * 2.0,
"mm",
0.82,
["circular_equal_radius_pattern"],
"pattern_radius_changes",
"equal_radial_offset_cylinder_axes",
["hole_spacing", "pattern_diameter"],
)
)
coaxial_radii = sorted(
{round(float(item["radius"]), 6) for item in coaxial}
)
if coaxial_radii:
candidates.append(
_parameter_candidate(
"coaxial_inner_diameter",
coaxial_radii[0] * 2.0,
"mm",
0.58,
["coaxial_cylindrical_stack"],
"coaxial_inner_diameter_changes",
"smallest_centered_cylindrical_surface",
["bore_diameter", "shaft_diameter"],
)
)
return candidates
def _surface_record(face: Any, index: int) -> dict[str, Any]:
from OCP.BRepAdaptor import BRepAdaptor_Surface
from OCP.GeomAbs import (
@@ -702,8 +880,18 @@ def extract_step_case(source: Path) -> dict[str, Any]:
reconstruction_evidence = _reconstruction_evidence(
surfaces, features, constraints, semantic_summary
)
semantic_parameters = _semantic_parameter_candidates(
surfaces,
bbox_center,
bbox_size,
features,
semantic_summary,
)
digest = hashlib.sha256(source_bytes).hexdigest()
normalized_observations = semantic_summary.pop("normalized_observations", [])
solid_count = len(shape.solids())
volume = _rounded(shape.volume)
usable_solid = bool(shape.is_valid) and solid_count > 0 and volume > 0.0
return {
"schema_version": "2.0",
"extractor_version": EXTRACTOR_VERSION,
@@ -715,10 +903,22 @@ def extract_step_case(source: Path) -> dict[str, Any]:
"source_format": "step",
},
"geometry_evidence": {
"valid": bool(shape.is_valid),
"solid_count": len(shape.solids()),
"kernel_valid": bool(shape.is_valid),
"valid": usable_solid,
"reconstruction_eligible": usable_solid and solid_count == 1,
"rejection_reasons": [
reason
for condition, reason in (
(not bool(shape.is_valid), "kernel_invalid"),
(solid_count == 0, "no_closed_solid"),
(volume <= 0.0, "zero_volume"),
(solid_count > 1, "multi_solid_requires_segmentation"),
)
if condition
],
"solid_count": solid_count,
"face_count": len(shape.faces()),
"volume": _rounded(shape.volume),
"volume": volume,
"bounding_box": {
"min": bbox_min,
"max": bbox_max,
@@ -729,9 +929,11 @@ def extract_step_case(source: Path) -> dict[str, Any]:
"analytic_surfaces": surfaces,
},
"design_ir": {
"epistemic_status": "inferred_design_hypothesis_not_source_history",
"part_family": family,
"features": features,
"constraints": constraints,
"semantic_parameter_candidates": semantic_parameters,
"normalized_observations": normalized_observations,
"semantic_summary": semantic_summary,
"reconstruction_evidence": reconstruction_evidence,