refactor: add DesignIR distillation pipeline

This commit is contained in:
Jerry
2026-07-27 17:24:04 +08:00
parent 0adaac2b2a
commit 550b339bcc
1128 changed files with 10248 additions and 426666 deletions
@@ -0,0 +1,134 @@
---
name: cad-experience-builder
description: Batch-extract STEP files into private JSON, use the active large model to semantically distill transferable CAD methods from a sanitized batch, and deterministically verify and publish a backend-neutral experience library. Use when distilling STEP/STP corpora, reviewing candidate methods, inducing feature motifs and constraints, auditing instance-data leakage, or preparing promoted experience for cad-router and CAD backends.
---
# CAD Experience Builder
Induce methods, not copied answers. Keep source-part evidence outside the
published library and require repeated support before promoting any experience.
## Required workflow
1. Put source `.step` or `.stp` files in
`workspace/teacher/inbox/`. Only deterministic ingestion and Agent B may
access this vault.
2. Parse every new, content-unique STEP into visible private JSON under
`workspace/teacher/evidence/`:
```bash
../../scripts/cad-experience extract-folder
```
3. Prepare a sanitized semantic batch:
```bash
../../scripts/cad-experience prepare
```
4. Read `workspace/promotion/candidates/semantic-batch.json` completely. Act as the semantic
distiller: compare families, parameter roles, datum roles, canonical
reconstruction stages, feature cardinality classes, relations, prior
vocabulary, failure rules, and validation targets; then author
`workspace/promotion/candidates/experience-draft.json` using
`references/llm-review-contract.md`.
Propose methods, invariants, and family-level `reconstruction_grammar`
records, not raw co-occurrence pairs. A reconstruction grammar is a
canonical backend-neutral strategy inferred from repeated geometry; it is
not the source model's recovered feature history. Do not infer
manufacturing intent or failure causes that final B-Rep evidence cannot
support.
5. Publish only after the model draft exists:
```bash
../../scripts/cad-experience publish \
--draft ../../workspace/promotion/candidates/experience-draft.json
```
The publisher recomputes support from the private corpus, rejects numeric
instance answers, keeps weak proposals as candidates, and marks the library
router-consumable only after this verification.
6. Audit the public library. Treat any failure as a hard publishing failure:
```bash
python scripts/cad_experience.py audit \
../../workspace/promotion/promoted/library.json
```
7. Query only the published library for creation, modification, or rebuilding:
```bash
python scripts/cad_experience.py query \
../../workspace/promotion/promoted/library.json \
--family flanged_hub_adapter \
--features base_flange,hollow_sleeve,counterbore
```
8. Pass the query result, never the teacher vault, unrelated private evidence,
or a case JSON,
to `$cad-router` or a backend.
Direct `distill` without an LLM-authored draft is intentionally blocked. The
active Codex model supplies semantic judgment; no separate web API key is
required.
## STEP extraction boundary
- The V2 extractor reads analytic B-Rep surfaces, bounding boxes, topology
counts, axes, radii, and locations into a private evidence record.
- It derives scale-independent body classes, part families, rotational and
prismatic motifs, passage and pattern relationships, complexity roles, and
normalized ratio observations before sanitization.
- It also derives private exact cardinality evidence plus sanitized symbolic
parameter roles, datum roles, feature-count classes, and canonical
reconstruction stages. These are the evidence used to learn how to rebuild a
family without publishing any teacher dimensions.
- Exact source paths, hashes, dimensions, coordinates, and face references are
permitted in private case JSON and forbidden in the published library.
- Keep input/output as a human-visible staging area and induction corpus. Never
use it directly during creation, modification, or reconstruction.
- A semantic candidate is not a trusted design method until repeated,
independently sourced cases satisfy the promotion policy.
## Promotion rules
- One case is evidence, not experience.
- Publish generalized, dimension-free observations below threshold as visible
`candidate_experiences`, but never expose them to generation consumers.
- Require `min_support` independent cases and conditional `min_confidence`
within the proposal's semantic context before promotion.
- Promote feature compositions, semantic constraints, dimensionless
distributions, validation checks, and failure-repair methods.
- Reject absolute dimensions, coordinates, face references, source paths,
source hashes, and full parameter dictionaries from published experience.
- Keep generated examples out of the trusted corpus until independently
validated.
- Evaluate generalization on held-out cases that were not used for induction.
## Backend contract
The library is backend-neutral. A consumer receives:
- `feature_motif`: reusable compositions of feature types;
- `constraint`: invariant semantic relationships;
- `design_rule`: conditional modeling guidance;
- `dimensionless_distribution`: multi-case ratio ranges;
- `validation_rule`: checks independent of one part's coordinates;
- `failure_repair`: reusable detection and repair methods.
- `reconstruction_grammar`: symbolic parameters, datums, canonical feature
stages, optional/required feature roles, and validation roles for a family.
build123d, SimpleCADAPI, or another backend translates the same queried
experience into its own operations. Experience must never contain executable
teacher geometry.
## References
- Read `references/case-input-contract.md` before producing batch input.
- Read `references/experience-library-schema.md` before extending the output
schema or adding a consumer.
- Read `references/llm-review-contract.md` before authoring or revising a
semantic proposal draft.
@@ -0,0 +1,81 @@
# Case JSON input contract
Case JSON is private evidence used only during induction. It may contain exact
parameters, but the inducer never copies those fields into published experience.
Store source STEP files in `workspace/teacher/inbox` and generated private
evidence in `workspace/teacher/evidence`.
Do not pass either folder or a case JSON to cad-router or a CAD backend.
Creation, modification, and reconstruction may consume only the promoted public
experience library.
Supported fields:
```json
{
"provenance": {"source_sha256": "..."},
"design_ir": {
"part_family": "flanged_hub_adapter",
"features": [{"type": "base_flange"}, {"type": "hollow_sleeve"}],
"constraints": [{"id": "coaxial_stack", "type": "coaxial"}],
"reconstruction_evidence": {
"parameter_roles": ["overall_long_span", "coaxial_diameter_roles"],
"datum_roles": ["part_center", "primary_axis"],
"feature_roles": ["rotational_body", "coaxial_cylindrical_stack"],
"relation_roles": ["coaxial_stack"],
"canonical_stages": [
{
"id": "establish_reference_frame",
"operation": "define_datums",
"feature_roles": [],
"reference_roles": ["part_center", "primary_axis"]
}
],
"private_cardinality_evidence": {
"feature_role_count": 5,
"feature_role_count_class": "repeated"
},
"validation_roles": ["closed_solid", "coaxial_stack"]
},
"normalized_observations": [
{
"name": "sleeve_od_to_base_od",
"value": 0.35,
"numerator_role": "sleeve_outer_diameter",
"denominator_role": "base_outer_diameter"
}
]
},
"experience": {
"rules": [
{
"id": "bound_counterbore_to_host",
"scope": ["counterbore"],
"statement": "Terminate a counterbore at its host boundary.",
"repair": "Bind the cutter extent to the host feature."
}
],
"validation_targets": [
{
"id": "counterbore_host_boundary",
"check": "A counterbore does not cross an unrelated adjacent feature."
}
]
}
}
```
Rules and checks containing numeric literals are rejected because they are
likely instance answers. Ratios are accepted only through
`normalized_observations`, with an explicit numerator and denominator role.
Legacy distilled JSON can be used when it contains the fields above. Exact
`parameters`, `evidence`, centers, axes, and surface references are ignored.
V3 extractor output should also provide several independent semantic signals
per case: body-aspect class, topology-complexity role, analytic feature
networks, part family, relationship candidates, and role-named dimensionless
observations. It additionally provides symbolic reconstruction roles, canonical
reconstruction stages, and private exact cardinality evidence. Sanitization
converts exact counts into qualitative classes before LLM review. Do not encode
an absolute source dimension as a feature token.
@@ -0,0 +1,84 @@
# Published experience library
The published library uses schema version `2.0` and contains only promoted,
generalized experience:
```json
{
"schema_version": "2.0",
"library_kind": "generalized_cad_experience",
"induction_mode": "llm_proposals_with_deterministic_evidence_verification",
"policy": {
"instance_parameters_allowed": false,
"absolute_coordinates_allowed": false,
"llm_semantic_review_required": true,
"draft_evidence_verified": true,
"router_consumable": true,
"minimum_support": 20
},
"corpus_summary": {
"accepted_case_count": 1000,
"duplicate_case_count": 12
},
"experience_summary": {
"llm_proposal_count": 24,
"promoted_experience_count": 18,
"candidate_experience_count": 6,
"by_kind": [{"kind": "feature_motif", "count": 7}],
"by_scope": [{"scope": "flanged_hub_adapter", "count": 5}]
},
"experiences": [
{
"id": "motif.base_flange+hollow_sleeve",
"kind": "feature_motif",
"scope": ["flanged_hub_adapter"],
"when": {"features": ["base_flange", "hollow_sleeve"]},
"guidance": "Treat these feature roles as a reusable composition.",
"support": 243,
"confidence": 0.91
}
],
"candidate_experiences": [
{
"id": "motif.flanged_hub_adapter.base_flange+hollow_sleeve",
"kind": "feature_motif",
"scope": ["flanged_hub_adapter"],
"when": {"features": ["base_flange", "hollow_sleeve"]},
"support": 7,
"required_support": 20,
"remaining_support": 13,
"promotion_state": "candidate",
"consumer_policy": "visible_for_review_but_not_available_to_cad_router"
}
]
}
```
Allowed numeric values describe evidence strength or dimensionless
distributions: support counts, confidence, frequency, percentiles, and sample
counts. Absolute lengths, diameters, heights, radii, angles tied to a single
case, positions, and coordinates are forbidden.
Consumers query this library by requested family and feature vocabulary. They
must not receive the induction corpus, private case JSON, or
`candidate_experiences`. Only `experiences` is executable guidance.
## Reconstruction grammar
`reconstruction_grammar` is the executable bridge between generalized
experience and a backend-neutral design plan. It may contain only:
- symbolic `parameter_roles`;
- symbolic `datum_roles`;
- required or optional semantic feature and relation roles;
- canonical stages with tokenized operation, feature, and reference roles;
- qualitative cardinality classes such as `single`, `paired`, `repeated`, or
`dense`;
- validation roles.
It must not contain parameter values, exact member counts, absolute
coordinates, source identifiers, face references, or executable teacher
geometry. The publisher verifies that every promoted role and stage is present
in enough independent private cases. A consumer instantiates the grammar using
the user's dimensions, inferred dimensionless distributions, and backend
operations.
@@ -0,0 +1,97 @@
# LLM semantic review contract
Read the sanitized `work/review/semantic-batch.json`, not raw STEP geometry.
Maintain stable proposal identifiers when an existing candidate expresses the
same method.
Write `work/review/experience-draft.json` with this shape:
```json
{
"schema_version": "1.0",
"draft_kind": "llm_generalized_experience_proposals",
"proposals": [
{
"id": "constraint.part_family.shared_axis",
"kind": "constraint",
"scope": ["part_family"],
"evidence_query": {
"required_features": ["rotational_body", "central_passage"],
"required_relations": ["dominant_axis_alignment"],
"context_features": ["rotational_body", "central_passage"]
},
"guidance": "Preserve a shared axis across the participating feature roles.",
"semantic_rationale": "The relationship remains meaningful when individual dimensions change.",
"check": "Confirm that participating features retain their intended common axis."
}
]
}
```
Allowed kinds are `feature_motif`, `constraint`, `design_rule`,
`validation_rule`, `failure_repair`, `dimensionless_distribution`, and
`reconstruction_grammar`.
Distribution proposals also provide `observation_name`; the publisher computes
the distribution from private evidence and never accepts values from the draft.
A `reconstruction_grammar` proposal must additionally contain symbolic,
number-free reconstruction content:
```json
{
"id": "reconstruction.patterned_prismatic_part.primary",
"kind": "reconstruction_grammar",
"scope": ["patterned_prismatic_part"],
"evidence_query": {
"required_features": ["planar_dominant_body", "cylindrical_feature_network"],
"required_relations": [],
"context_features": ["planar_dominant_body"]
},
"guidance": "Build a datum-driven envelope before adding the passage network and repeated features.",
"semantic_rationale": "The staged method transfers across dimensional variants while preserving feature relationships.",
"reconstruction_grammar": {
"parameter_roles": ["overall_long_span", "overall_middle_span", "overall_short_span"],
"datum_roles": ["part_center", "primary_axis", "primary_transverse_plane"],
"feature_roles": ["planar_dominant_body", "cylindrical_feature_network"],
"relation_roles": [],
"canonical_stages": [
{
"id": "establish_reference_frame",
"operation": "define_datums",
"feature_roles": [],
"reference_roles": ["part_center", "primary_axis"]
},
{
"id": "construct_primary_envelope",
"operation": "extrude_profile",
"feature_roles": ["planar_dominant_body"],
"reference_roles": ["part_center", "primary_axis"]
}
],
"cardinality_classes": ["plane_dense", "cylinder_dense"],
"validation_roles": ["closed_solid", "surface_mix_consistency"]
}
}
```
Follow these rules:
- Require an evidence query composed only of semantic feature and relation
tokens present in the sanitized batch.
- Use scopes to prevent unrelated part families from supporting a proposal.
- Use `context_features` to define the conditional population used for
confidence, so a valid subtype method is not diluted by unrelated variants
in the same broad family.
- Explain why a proposal transfers across dimensions and variants.
- Avoid dimensions, coordinates, source identifiers, face references, and
numeric literals in prose.
- Do not infer build order or causal failure mechanisms from final geometry.
- Treat `canonical_stages` as a recommended reconstruction order, never as a
claim about the source CAD history.
- For reconstruction grammars, retain only roles and stages repeated by the
scoped evidence. Do not insert family knowledge that the sanitized batch
does not support.
- Prefer a small set of meaningful methods over every possible feature pair.
- Preserve earlier proposal IDs when evidence still supports the same meaning.
- Let the publisher calculate support and confidence; never claim them in the
draft.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,767 @@
#!/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())