refactor: add DesignIR distillation pipeline
This commit is contained in:
@@ -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.
|
||||
+84
@@ -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.
|
||||
+1258
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())
|
||||
Reference in New Issue
Block a user