first commit
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 earthtojake
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,102 @@
|
||||
---
|
||||
name: cad
|
||||
description: Create, modify, inspect, and validate STEP-first parametric CAD parts and assemblies. Use for natural-language CAD specs, reference images, 2D technical drawings, STEP/STP generation or direct inspection, Python CAD source, source-level joints, selector references, geometry facts, measurements, mating deltas, snapshots, and secondary STL/3MF/native GLB outputs from CAD geometry.
|
||||
---
|
||||
|
||||
# CAD generation, inspection, and validation
|
||||
|
||||
Provenance: maintained in [earthtojake/text-to-cad](https://github.com/earthtojake/text-to-cad).
|
||||
Use the installed local skill files as the runtime source of truth; the
|
||||
repository link is only for provenance and release review.
|
||||
|
||||
## Purpose
|
||||
|
||||
Create or modify parametric CAD models from natural-language requirements, generate validated STEP/STP artifacts, inspect geometry references, and return checked outputs. Treat STEP as the primary CAD artifact. Treat STL, 3MF, and native GLB as secondary export workflows that branch from a STEP-first process. For assemblies, prefer `cadpy.assembly.AssemblyHelper` with source-level build123d joints, named mating datums, and native labels when the parts have functional assembly relationships.
|
||||
|
||||
There are two ways into the STEP workflow: generate from build123d Python source (the default when designing from scratch or modifying a generated model), or import an existing STEP/STP file directly (when no generator exists or the user explicitly targets the STEP file). Both produce the same inspectable artifacts.
|
||||
|
||||
## Use this skill when
|
||||
|
||||
Use this skill when the user asks for CAD files, STEP/STP files, build123d source, selector refs such as `#o1.2.f1`, mechanical parts, assemblies, enclosures, brackets, fixtures, holes, counterbores, countersinks, slots, pockets, bosses, standoffs, ribs, fillets, chamfers, shells, source-level joints, mating, or measurements. Also use it when the user supplies reference images or 2D technical drawings of a part to reproduce or take design intent from.
|
||||
|
||||
Also use it when the user asks for STL, 3MF, or native GLB output from CAD geometry. Keep those workflows secondary and load `supported-exports.md` for details. For 2D DXF drawings, use the `$dxf` skill; when a DXF projects from a 3D part, this skill owns the STEP geometry and `$dxf` owns the drawing.
|
||||
|
||||
Do not use this skill for render-only concept art, CAM toolpaths, engineering certification, FEA conclusions, architectural BIM, or freehand illustration unless the user also needs CAD geometry.
|
||||
|
||||
## Default assumptions
|
||||
|
||||
Use these defaults unless the user specifies otherwise. These are first-pass modeling defaults, not manufacturability, tolerance, or certification claims:
|
||||
|
||||
- Units: millimeters.
|
||||
- Origin: per the part-type defaults in `references/positioning.md`; center of the main part or assembly when nothing better applies.
|
||||
- Base plane: XY.
|
||||
- Up/extrusion axis: positive Z.
|
||||
- Output geometry: closed, positive-volume solids unless the user requests surfaces or construction geometry.
|
||||
- STEP structure: one valid solid, a compound of solids, or a labeled assembly compound.
|
||||
- Assembly structure: fixed root part, part-local frames, named mating datums, `AssemblyHelper` relationships backed by build123d joints where applicable, explicit generated placements, and verbose native labels.
|
||||
- Small plastic enclosure wall: 2.0-3.0 mm when unspecified.
|
||||
- Cosmetic fillet: 1.0-3.0 mm when safe for local geometry.
|
||||
- M3/M4/M5 normal clearance holes: 3.4/4.5/5.5 mm unless another standard is requested.
|
||||
|
||||
Ask one focused clarification question only when missing information makes the model impossible, fit-critical, safety-critical, or compliance-bound. Otherwise proceed with explicit assumptions.
|
||||
|
||||
## Tools and paths
|
||||
|
||||
From the CAD skill directory, the launcher shape is:
|
||||
|
||||
```bash
|
||||
python scripts/step ... # STEP generation, GLB/topology artifacts, mesh sidecars
|
||||
python scripts/inspect ... # refs, measure, align, frame, diff
|
||||
python scripts/snapshot ... # PNG/GIF visual review packets
|
||||
```
|
||||
|
||||
Use the active project Python interpreter; treat `python` in examples as an interpreter placeholder. Use `python scripts/<tool> --help` for the complete current command interface; reference docs show recommended workflows, not every flag.
|
||||
|
||||
Target paths resolve from the command's current working directory, not from the skill directory. Run commands from the workspace that owns the artifacts and pass cwd-relative target paths so project CAD files never resolve accidentally under the skill directory. Keep a STEP output and its Python generator in the same directory with the same basename unless the user explicitly requests otherwise.
|
||||
|
||||
CAD references are `#...` selector tokens local to a target, for example `#o1.2` or `#o1.2.f1`. Pass the STEP/CAD file as a separate target argument when using CAD CLIs.
|
||||
|
||||
## Required workflow
|
||||
|
||||
Scale depth to the task: a simple part needs a short brief and few spec-driven checks; assemblies and fit-critical work need full positioning and alignment validation.
|
||||
|
||||
1. **Classify the task.** New part, new assembly, source modification, direct STEP/STP inspection, reference selection, measurement/alignment check, snapshot review, or secondary output request.
|
||||
2. **Load only the needed references.** Use the triggers below instead of reading the whole reference set.
|
||||
3. **Write a natural-language CAD brief.** Extract dimensions, units, coordinate convention, feature intent, output paths, assumptions, and validation targets from all provided inputs — prose, reference images, technical drawings. Use `references/cad-brief.md`.
|
||||
4. **Check named purchasable components.** When an assembly includes named off-the-shelf actuators, servos, motors, electronics boards, connectors, or other purchasable components, search `$step-parts` before creating simplified placeholder geometry. If no exact match is found, record the miss and then use a documented envelope.
|
||||
5. **Plan before coding.** Define parameters, intent labels, source paths, expected bounding boxes, and any mating/positioning datums before editing.
|
||||
6. **Edit source, not generated artifacts.** Author build123d Python with `gen_step()`. When a Python generator exists, run `scripts/step` on the generator, never on its exported STEP. Use direct STEP/STP targets (`--kind part|assembly`) only for imports with no generator or when the user explicitly identifies the STEP/STP file as the target.
|
||||
7. **Generate explicit targets.** Run `scripts/step` on explicit targets only; do not run directory-wide generation.
|
||||
8. **Validate geometrically.** Run `scripts/inspect refs <step-or-cad-target> --facts --planes --positioning` as the baseline, then verify the dimensions and relationships the user's spec calls out with targeted `measure`, `align`, `frame`, or `diff` checks.
|
||||
9. **Snapshot the primary STEP — snapshot validation is mandatory.** After creating or visibly updating a primary STEP/STP part or assembly, ALWAYS run CAD `scripts/snapshot` against it and review the output; deterministic checks passing is not a reason to skip. The only skip cases are documented in `references/snapshot-review.md` (no visible geometry changed, or no valid artifact exists); report the reason when skipping.
|
||||
10. **Repair and rerun.** If a check fails, change the smallest responsible source section, regenerate, and rerun the failed validation.
|
||||
|
||||
## Handoff
|
||||
|
||||
After completing CAD work that creates or modifies `.step`, `.stp`, `.stl`, `.3mf`, or native `.glb` artifacts, you must ALWAYS hand the explicit file path(s) to `$cad-viewer` when that skill is installed. `$cad-viewer` must start CAD Viewer if it is not already running and return link(s) to the relevant created or updated file(s); include those live viewer link(s) in the final response. If `$cad-viewer` is unavailable or startup fails, report that and rely on CLI inspection plus snapshots instead of silently omitting the handoff. This rule applies to every workflow in this skill, including secondary STL/3MF/GLB outputs.
|
||||
|
||||
When verification snapshots are generated, include the saved PNG/GIF snapshot(s) in the final response. If no snapshot applies, or if snapshot generation fails, say why and report the deterministic validation that still ran.
|
||||
|
||||
## Non-negotiables
|
||||
|
||||
- Keep STEP as the primary validated CAD artifact. Generated STEP/STP, STL, 3MF, GLB/topology outputs, and render sidecars are derived artifacts; STL/3MF are secondary unless the user explicitly says otherwise.
|
||||
- Use named parameters, closed solids, verbose native build123d labels, and source-controlled geometry intent.
|
||||
- Author assembly positioning in source. `references/positioning.md` is authoritative for `AssemblyHelper`, build123d joints, explicit `Location` transforms, and alignment validation.
|
||||
- Do not use `git status`, `git diff`, or file-size churn as CAD comparison for large exported STEP/STP, GLB/topology, STL, or 3MF artifacts. Compare source changes, `scripts/inspect` summaries, snapshots, or generated topology output instead; use path-limited git status only for bookkeeping.
|
||||
- Report only checks that actually ran or are directly supported by tool output.
|
||||
|
||||
## Progressive references
|
||||
|
||||
Load these files only when their trigger applies:
|
||||
|
||||
- `references/cad-brief.md` — converting prose, reference images, and technical drawings into a CAD brief.
|
||||
- `references/build123d-modeling.md` — build123d modeling patterns, topology, selectors, features, labels.
|
||||
- `references/step-generation.md` — STEP generation from Python source, direct STEP/STP imports, and post-generation steps.
|
||||
- `references/inspection-and-validation.md` — validation sequence, selector refs, facts, planes, measurements, alignment, diff, frame, and validation reporting.
|
||||
- `references/snapshot-review.md` — mandatory snapshot policy, packet sizing, targeted views, and converting visual findings into geometry checks.
|
||||
- `references/positioning.md` — part-local datums and origins, assembly transforms, build123d joints, CLI alignment validation, and positioning reports.
|
||||
- `references/parameters.md` — parameterizing or animating a STEP model: source parameters, `.step.js` sidecar modules, viewer controls, and animation design.
|
||||
- `references/supported-exports.md` — secondary STL/3MF/native GLB sidecar workflows.
|
||||
- `references/repair-loop.md` — diagnosis and repair procedures.
|
||||
|
||||
Final responses should include generated files, returned `$cad-viewer` viewer links, verification snapshots, validation actually run, assumptions, and caveats. Use `references/inspection-and-validation.md` for report structure.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "CAD"
|
||||
short_description: "Generate and validate CAD artifacts."
|
||||
default_prompt: "Use $cad to create, regenerate, inspect, and validate explicit CAD files and selector refs, handing supported outputs to $cad-viewer when available."
|
||||
@@ -0,0 +1,155 @@
|
||||
# build123d modeling patterns
|
||||
|
||||
Read this file when writing or repairing build123d Python source.
|
||||
|
||||
## Modeling objective
|
||||
|
||||
Create a valid STEP-ready BREP model, not a visual mesh. Prefer closed solids, explicit labels, and stable parametric dimensions. Define `gen_step()` returning the STEP-ready shape or labeled compound; the CLI owns output paths (see `step-generation.md`).
|
||||
|
||||
## Design strategy
|
||||
|
||||
Decide how the part is constructed before writing geometry code:
|
||||
|
||||
- **Choose the construction that makes the spec's dimensions direct parameters.** Profile-driven shapes get one closed sketch plus `extrude`/`revolve`/`sweep`/`loft`; block-and-feature parts get a base solid plus subtractive features. Prefer whichever construction lets the user's controlling dimensions appear as named parameters instead of derived values.
|
||||
- **Decide part vs assembly before modeling.** Bodies that are separately manufactured, purchased, or movable belong in a labeled assembly (see `positioning.md`); monolithic manufacturing intent gets a single fused solid. Avoid unlabeled compounds of solids — multi-body output without occurrence labels loses traceability in inspection and viewer review.
|
||||
- **Pick the origin and orientation from the functional datum before sculpting.** Model on the mating interface, mounting plane, or symmetry axis; see `positioning.md` for part-type origin defaults.
|
||||
- **Order operations so fragile steps come last and failures localize.** Base solid → major additions → subtractive features → shell → through-wall holes → fillets and chamfers last. Fillets are the most failure-prone operation and every boolean invalidates selectors, so postpone them. Structure the source so each feature is a named step — a per-feature function or a distinct intermediate variable — so a failed operation points at exactly one feature and a parameter change touches one obvious place.
|
||||
- **Overshoot boolean tools.** Extend cutting tools past the faces they enter and exit; for through-cuts, go roughly 1 mm beyond both faces. Coincident or coplanar tool/target faces are a classic kernel failure. Cut repeated or patterned features in one combined operation.
|
||||
- **Sanity-check proportions before generating.** Compare the expected bounding box against the real-world object, wall thickness against overall size, and feature positions against edges and neighboring features. Order-of-magnitude and collision errors pass geometric validation but fail visual review.
|
||||
|
||||
## Topology stack
|
||||
|
||||
Think in this order:
|
||||
|
||||
```text
|
||||
Vertex → Edge → Wire → Face → Shell → Solid → Compound
|
||||
```
|
||||
|
||||
For assemblies, use these repo topology terms consistently:
|
||||
|
||||
- **Occurrence**: a placed node in the assembly tree. An occurrence has a parent, transform, path, and user-facing role such as `lid` or `m3_screw:front_left`.
|
||||
- **Shape**: an exported geometry/body inside an occurrence. Shape rows own topology; faces and edges belong to a shape, and the shape belongs to an occurrence.
|
||||
- **Face/edge**: selectable topology owned by a shape. Do not assume arbitrary faces or edges have persistent intent labels; inspect them by occurrence, shape, ordinal, surface/curve type, and measured geometry.
|
||||
|
||||
When inspecting topology, follow `assembly occurrence -> shape/body -> faces -> edges`. Every face/edge row should be traceable through both `occurrenceId` and `shapeId`.
|
||||
|
||||
For normal STEP output, return one of:
|
||||
|
||||
- a valid `Solid`
|
||||
- a compound of valid solids
|
||||
- a labeled assembly compound
|
||||
|
||||
Avoid returning loose wires, open faces, or construction surfaces unless the user explicitly requested them.
|
||||
|
||||
## Parameters first
|
||||
|
||||
Put meaningful dimensions in named variables:
|
||||
|
||||
```python
|
||||
width = 80.0
|
||||
depth = 50.0
|
||||
thickness = 6.0
|
||||
hole_diameter = 4.5
|
||||
hole_offset_x = 30.0
|
||||
hole_offset_y = 17.5
|
||||
```
|
||||
|
||||
Avoid burying important numbers inside geometry calls.
|
||||
|
||||
## Coordinate system
|
||||
|
||||
Declare or comment the convention:
|
||||
|
||||
```text
|
||||
Origin: center of primary part or chosen mating datum
|
||||
XY: main base/sketch plane
|
||||
+Z: up/extrusion direction
|
||||
```
|
||||
|
||||
Use `Location`, `Plane`, and `Axis` intentionally. For positioning-sensitive tasks and source-level assembly relationships, read `positioning.md`.
|
||||
|
||||
## Builder contexts
|
||||
|
||||
Use the context that matches the geometry:
|
||||
|
||||
```python
|
||||
with BuildLine() as path:
|
||||
...
|
||||
|
||||
with BuildSketch() as profile:
|
||||
...
|
||||
|
||||
with BuildPart() as part:
|
||||
...
|
||||
```
|
||||
|
||||
Typical flow:
|
||||
|
||||
```text
|
||||
curves/paths → sketches/profiles → solids/features → labels → STEP
|
||||
```
|
||||
|
||||
## Selection practices
|
||||
|
||||
Avoid fragile topology order when possible. Select by:
|
||||
|
||||
- axis or normal
|
||||
- location or bounding position
|
||||
- plane grouping
|
||||
- feature intent
|
||||
- stable construction plane
|
||||
- inspected local selector ref for downstream validation
|
||||
|
||||
For source operations, prefer robust selectors such as top/bottom by axis or position rather than arbitrary list indexes.
|
||||
|
||||
|
||||
## Assemblies and positioning
|
||||
|
||||
For assemblies, keep this file focused on BREP modeling patterns and labels. Use `positioning.md` as the single source of truth for:
|
||||
|
||||
- part-local coordinate conventions
|
||||
- when to use `cadpy.assembly.AssemblyHelper`, build123d joints, or explicit `Location` transforms
|
||||
- `connect_to()` behavior
|
||||
- CLI `inspect align` as read-only selector-pair alignment validation
|
||||
- frame, measure, and positioning report expectations
|
||||
|
||||
## Labels and assemblies
|
||||
|
||||
Label every exported part and assembly child with native build123d labels. Prefer concise intent labels through `cadpy.assembly` helpers:
|
||||
|
||||
```python
|
||||
from cadpy.assembly import AssemblyHelper, label_shape
|
||||
|
||||
asm = AssemblyHelper("electronics_enclosure")
|
||||
base = asm.add(make_base(), "base")
|
||||
lid = asm.add(make_lid(), "lid")
|
||||
|
||||
boss = label_shape(Cylinder(radius=3.0, height=12.0), "m3_boss", "front_left")
|
||||
```
|
||||
|
||||
Do not prefix labels with topology categories like assembly, component, feature, datum, mate, or hardware. The assembly tree and topology inspection already expose those structural categories. Use labels for the intent topology cannot reliably infer: role, placement, interface, repetition, or mating purpose. Feature labels survive STEP export best when the feature remains a labeled child shape in a `Compound`; boolean-subtracted or fused feature history should be represented by source parameters, named datums, and validation refs instead of assumed persistent feature labels.
|
||||
|
||||
Label for inspection:
|
||||
|
||||
- Label the root assembly.
|
||||
- Label every exported part, subassembly/module, and repeated component occurrence.
|
||||
- Use occurrence labels for assembly role and placement, especially repeated parts: `m3_screw:front_left`, `m3_screw:rear_right`.
|
||||
- Use shape labels for retained exported geometry/body roles where useful.
|
||||
- Use feature/datum labels only when that geometry remains exported as a child shape.
|
||||
- Use named mate datums for source-level positioning intent, then validate the exported STEP topology and occurrence frames.
|
||||
|
||||
Occurrence and shape labels are exported through STEP names and surfaced in `STEP_topology` when available. The viewer uses occurrence labels for assembly/tree references and shape labels for shape references. Faces and edges inherit their context from `occurrenceId` and `shapeId`; do not promise persistent face/edge intent labels unless explicit tested support exists.
|
||||
|
||||
For repeated parts, keep occurrence labels, transforms, or joint connections explicit and inspect frames/positioning after generation.
|
||||
|
||||
## Common failure modes
|
||||
|
||||
- Fillet radius larger than local edge geometry.
|
||||
- Open sketch profile produces invalid or missing face.
|
||||
- Face selector changes after a boolean or fillet.
|
||||
- Part origin is arbitrary and later alignment checks become ambiguous.
|
||||
- Source-level joints are treated as if they were persistent STEP constraints rather than one-time source placement operations.
|
||||
- Joint labels are missing, duplicated, or attached to the wrong local datum.
|
||||
- `.connect_to()` fixes the wrong side of the relationship, moving the part intended to remain fixed.
|
||||
|
||||
Use `repair-loop.md` when generation or validation fails.
|
||||
@@ -0,0 +1,130 @@
|
||||
# CAD brief
|
||||
|
||||
Read this file when converting a user's request — prose, reference images, technical drawings, or a combination — into a CAD brief. The brief is an internal note-taking scaffold; do not ask the user to fill it out, and do not require the user to provide JSON. If the user supplied JSON voluntarily, extract the same information but continue the workflow in prose notes and build123d source.
|
||||
|
||||
## Goal
|
||||
|
||||
Convert the request into an actionable modeling brief before writing source or running tools. Every input modality funnels into the same brief; the downstream workflow does not change.
|
||||
|
||||
The brief should answer:
|
||||
|
||||
- What is being modeled, and is it a part, assembly, modification, inspection task, or secondary output request?
|
||||
- What dimensions and units are specified, and which missing dimensions are inferable?
|
||||
- Which features are required?
|
||||
- Which faces, axes, origins, joints, or interfaces control positioning?
|
||||
- What output files are requested?
|
||||
- What must be validated before success is reported?
|
||||
|
||||
When inputs conflict, dimensioned sources win over image proportions. When two dimensioned sources conflict — prose says one value, a drawing callout says another — flag the conflict instead of silently choosing.
|
||||
|
||||
## Reference images
|
||||
|
||||
An image without stated dimensions is design intent, not a spec:
|
||||
|
||||
- Establish scale from one stated dimension or a known object in frame; if neither exists and fit matters, that is the one clarification question to ask.
|
||||
- Estimate remaining proportions from the image and record them as assumptions like any other inferred value.
|
||||
- Distinguish reproduction ("model this part") from inspiration ("something like this") in the brief; reproduction raises fidelity expectations, inspiration leaves freedom.
|
||||
- For reproduction, plan a snapshot from the reference image's viewpoint and compare it against the image during visual review.
|
||||
|
||||
## Technical drawings
|
||||
|
||||
A drawing is a dimensioned contract. Extract it systematically:
|
||||
|
||||
- Read the title block and notes first: units, projection convention, revision, disclaimers.
|
||||
- Identify which view is which — front/top/side, sections, details, iso — and which model axes each maps to before extracting numbers. Trust callouts and view labels, not layout conventions. Section views are the source of truth for internal features: bores, counterbore and blind-hole depths, wall sections.
|
||||
- Convert every dimension callout into a named parameter and a validation target. Multiplicity (`4X`), `TYP.`, and thread/counterbore/countersink callouts expand into features plus checks.
|
||||
- Never scale undimensioned geometry off the image. Derive it from stated dimensions when constrained; otherwise assume and report.
|
||||
- Cross-check features across views; when views disagree, prefer the dimensioned view and flag the conflict.
|
||||
- Success for a drawing-driven model: every drawing dimension is either verified by `measure`/`refs` after generation or explicitly reported as not verified.
|
||||
|
||||
## Brief format
|
||||
|
||||
Use concise Markdown notes, not a user-facing structured schema:
|
||||
|
||||
```text
|
||||
CAD brief:
|
||||
- Model: <part or assembly name>
|
||||
- Task type: <new part, assembly, modification, inspection, secondary output>
|
||||
- Inputs: <reference images or drawing views used; omit when prose-only>
|
||||
- Units: <explicit or assumed>
|
||||
- Coordinate convention: <origin, base plane, up axis>
|
||||
- Overall dimensions: <width/depth/height or equivalent>
|
||||
- Functional features: <holes, slots, ribs, bosses, pockets, shells, text, etc.>
|
||||
- Manufacturing assumptions: <only when relevant>
|
||||
- Positioning/mating: <interfaces, datums, child placements, joints, alignment rules>
|
||||
- Paths: <generator .py, STEP target, secondary outputs if requested>
|
||||
- Validation targets: <bbox, solid count, labels, spec-driven measurements, refs>
|
||||
- Assumptions: <only meaningful inferred choices>
|
||||
```
|
||||
|
||||
## Example: simple part
|
||||
|
||||
User says:
|
||||
|
||||
```text
|
||||
Make a 100 mm by 60 mm by 6 mm mounting plate with rounded corners, four M4 clearance holes 10 mm in from the corners, and a 20 by 12 mm rectangular cutout in the center.
|
||||
```
|
||||
|
||||
Agent brief:
|
||||
|
||||
```text
|
||||
CAD brief:
|
||||
- Model: mounting_plate, single STEP part.
|
||||
- Units: millimeters.
|
||||
- Origin: center of plate; base plane XY; +Z is thickness direction.
|
||||
- Body: rounded rectangular plate, 100 × 60 × 6 mm.
|
||||
- Corner radius: not specified; assume 3 mm.
|
||||
- Holes: four 4.5 mm M4 clearance through-holes, 10 mm in from each corner.
|
||||
- Cutout: centered rectangular through-cut, 20 × 12 mm.
|
||||
- Validation: one positive-volume solid, bbox 100 × 60 × 6 mm, four holes, one center cutout, label mounting_plate.
|
||||
```
|
||||
|
||||
## Example: assembly
|
||||
|
||||
User says:
|
||||
|
||||
```text
|
||||
Design a two-piece enclosure, 120 by 80 by 35 mm, with a lid that sits on top and four screw bosses aligned between base and lid.
|
||||
```
|
||||
|
||||
Agent brief:
|
||||
|
||||
```text
|
||||
CAD brief:
|
||||
- Model: enclosure assembly with base and lid.
|
||||
- Units: millimeters.
|
||||
- Assembly origin: center of enclosure footprint; +Z upward.
|
||||
- Base: hollow lower shell, exterior 120 × 80 mm footprint; height derived from total height minus lid thickness.
|
||||
- Lid: separate plate on top; assume 3 mm lid thickness unless user gave another value.
|
||||
- Bosses: four aligned screw bosses; assume M3 unless unspecified dimensions make this unsafe.
|
||||
- Positioning: base top face and lid bottom face are mating datums; screw axes must align; native build123d joints may be used if they clarify reusable mount points or motion.
|
||||
- Validation: labeled base and lid children, bbox near 120 x 80 x 35 mm, aligned hole/boss axes.
|
||||
```
|
||||
|
||||
## Clarification policy
|
||||
|
||||
Ask one focused question only when the missing information affects fit, safety, compliance, or makes the part impossible to model. Otherwise proceed with assumptions and report them.
|
||||
|
||||
Ask when:
|
||||
|
||||
- No dimensions are provided for a physical object, and no scale reference exists in the supplied images.
|
||||
- A mating interface is described but the mating geometry is unspecified.
|
||||
- The part is safety-critical, load-bearing, pressure-bearing, medical, or compliance-bound.
|
||||
- The requested output depends on an absent source file or missing imported geometry.
|
||||
|
||||
Do not ask when:
|
||||
|
||||
- A default clearance hole standard is sufficient.
|
||||
- A cosmetic fillet radius can be safely assumed.
|
||||
- Origin/orientation can be chosen and reported.
|
||||
- The user is asking for a conceptual first-pass CAD model.
|
||||
|
||||
## Success criteria
|
||||
|
||||
A brief is ready for modeling when it contains enough information to define:
|
||||
|
||||
- source file path and STEP target path
|
||||
- units and local coordinate system
|
||||
- named parameters
|
||||
- feature plan and labels
|
||||
- expected bounding box or key measurements
|
||||
@@ -0,0 +1,143 @@
|
||||
# Inspection and validation
|
||||
|
||||
Read this file for every generated STEP artifact and whenever the user asks for geometry facts, references, dimensions, mating, diffing, or frame inspection.
|
||||
|
||||
## Principle
|
||||
|
||||
Deterministic geometry checks decide pass/fail; mandatory snapshot review (see `snapshot-review.md`) catches semantic errors the deterministic checks did not encode. Scale the deterministic checks to the user's spec: every dimension, clearance, or relationship the user specified — including dimensions taken from a technical drawing — must be verified with `measure`, `align`, or `frame`. The facts/planes/positioning baseline runs for every generated artifact regardless of spec.
|
||||
|
||||
## Tool
|
||||
|
||||
The launcher lives in the CAD skill directory:
|
||||
|
||||
```bash
|
||||
python scripts/inspect {refs|diff|frame|measure|align|worker|batch} ...
|
||||
```
|
||||
|
||||
Inspection targets resolve from the command cwd; pass cwd-relative target paths. Common data-output flags: `--format json|text` (default is machine-readable), `--quiet`, `--verbose`.
|
||||
|
||||
Accepted target forms:
|
||||
|
||||
```text
|
||||
path/to/entry
|
||||
path/to/entry.step
|
||||
```
|
||||
|
||||
Selector refs are local to the STEP/CAD entry target passed to the command. They do not include file paths:
|
||||
|
||||
```text
|
||||
#o1.2
|
||||
#o1.2.f1
|
||||
#f1
|
||||
```
|
||||
|
||||
Pass selector refs as `#...` tokens. The STEP/CAD file path or entry target is a separate CLI argument.
|
||||
|
||||
## Validation sequence
|
||||
|
||||
1. Generation completed and the STEP/STP file exists.
|
||||
2. `refs --facts --planes --positioning` confirms scale, labels, major planes, and placement-ready references. Run this for every generated artifact.
|
||||
3. Spec-driven checks: `measure` for every user-specified dimension, offset, or clearance; `align` for interfaces that should be flush or centered; `frame` for orientation and occurrence-placement expectations; `diff` for modifications that could affect unrelated geometry.
|
||||
4. Snapshot the primary STEP/STP per `snapshot-review.md`, then convert every visual concern into a deterministic geometry check before it becomes a validation claim.
|
||||
|
||||
## Reference discovery
|
||||
|
||||
Compact facts and planes:
|
||||
|
||||
```bash
|
||||
python scripts/inspect refs path/to/model.step \
|
||||
--facts --planes --positioning
|
||||
```
|
||||
|
||||
Detailed selector inspection:
|
||||
|
||||
```bash
|
||||
python scripts/inspect refs path/to/model.step '#selector' \
|
||||
--detail --positioning
|
||||
```
|
||||
|
||||
Topology enumeration, only when needed:
|
||||
|
||||
```bash
|
||||
python scripts/inspect refs path/to/model.step --topology
|
||||
```
|
||||
|
||||
Plane options:
|
||||
|
||||
```bash
|
||||
--plane-coordinate-tolerance FLOAT
|
||||
--plane-min-area-ratio FLOAT
|
||||
--plane-limit INT
|
||||
```
|
||||
|
||||
Use lower plane limits and compact facts for normal validation. Use topology enumeration only for selector discovery, complex debugging, or when a feature cannot be verified through facts/planes/measurements; it can be expensive on large models.
|
||||
|
||||
## Measurement checks
|
||||
|
||||
Use `measure` for bounding distances, clearances, offsets, part spacing, plate thickness, hole-to-face distances, and alignment verification.
|
||||
|
||||
```bash
|
||||
python scripts/inspect measure path/to/model.step \
|
||||
--from '#selector_a' \
|
||||
--to '#selector_b' \
|
||||
--axis x
|
||||
```
|
||||
|
||||
Axis may be inferred when possible, but specify `x`, `y`, or `z` for deterministic checks.
|
||||
|
||||
## Alignment checks
|
||||
|
||||
Use `align` when two exported STEP references should be flush or centered. It returns a translation delta between the selected refs; apply any required correction in the build123d source (see `positioning.md`), regenerate, and re-inspect.
|
||||
|
||||
```bash
|
||||
python scripts/inspect align path/to/assembly.step \
|
||||
--moving '#moving_selector' \
|
||||
--target '#target_selector' \
|
||||
--mode flush \
|
||||
--axis z
|
||||
```
|
||||
|
||||
## Frame inspection
|
||||
|
||||
Use `frame` to validate occurrence transforms and selected-reference world frames:
|
||||
|
||||
```bash
|
||||
python scripts/inspect frame path/to/model.step '#selector'
|
||||
```
|
||||
|
||||
Frame output is useful for assemblies, part-local-to-world conversion, and placement debugging.
|
||||
|
||||
## Diff checks
|
||||
|
||||
For modification tasks, compare before and after artifacts:
|
||||
|
||||
```bash
|
||||
python scripts/inspect diff path/to/before.step path/to/after.step --planes
|
||||
```
|
||||
|
||||
Use diff when a repair, feature addition, or source edit could affect unrelated geometry.
|
||||
|
||||
## Validation report content
|
||||
|
||||
Report only checks that were actually run or directly supported by tool output. If an important selector was inspected, return the local selector ref beside the owning CAD Viewer link.
|
||||
|
||||
Use this structure:
|
||||
|
||||
```text
|
||||
Validation:
|
||||
- STEP generation: passed/partial/failed
|
||||
- Solids/assembly: <counts and labels>
|
||||
- Bounding box: <dimensions and units>
|
||||
- Major planes/refs: <summary>
|
||||
- Positioning: <frame/measure/align results if relevant>
|
||||
- Feature checks: <holes, cutouts, bosses, etc.>
|
||||
- Visual review: `$cad-viewer` viewer link returned; CAD `scripts/snapshot` PNG/GIF included or skipped with reason; follow-up geometry checks for any visual findings
|
||||
```
|
||||
|
||||
Do not claim:
|
||||
|
||||
- structural safety
|
||||
- process certification
|
||||
- tolerance compliance
|
||||
- manufacturability beyond geometric plausibility
|
||||
unless the relevant analysis or manufacturing data was explicitly performed.
|
||||
@@ -0,0 +1,138 @@
|
||||
# CAD parameters
|
||||
|
||||
Read this file when the user asks to parameterize or animate a STEP model, or when designing or reviewing CAD source parameters, `.step.js` sidecar parameters, CAD Viewer controls, or animation controls.
|
||||
|
||||
## Principle
|
||||
|
||||
Parameters are part of the model contract. A good parameter makes design intent explicit, maps to named geometry or motion, stays inside a valid range, and gives both users and LLMs enough context to predict what changing it will do.
|
||||
|
||||
Prefer parameter logic that preserves the mechanism or part constraints over logic that only looks plausible from one camera angle.
|
||||
|
||||
## Parameter Brief
|
||||
|
||||
Before coding, write a compact internal parameter brief:
|
||||
|
||||
- What geometry or motion each parameter controls.
|
||||
- Units, defaults, min/max, step size, and whether the value is dimensionless.
|
||||
- Which named features, datums, pivots, axes, faces, or local selector refs each parameter affects.
|
||||
- Which values are independent inputs and which are derived from constraints.
|
||||
- What validation proves the parameter is correct.
|
||||
|
||||
For assemblies and mechanisms, identify fixed pivots, moving pivots, link lengths, gear ratios, axes, joint limits, and branch choices before creating controls.
|
||||
|
||||
## Naming
|
||||
|
||||
Use snake_case semantic names that describe intent, matching the build123d Python source convention:
|
||||
|
||||
- Prefer `wall_thickness`, `bearing_clearance`, `hinge_angle_deg`, `lid_open`, `gear_ratio`, `link_travel`.
|
||||
- Avoid names like `offset2`, `magic_scale`, `fix_angle`, `slider_a`, unless the source model itself uses a meaningful matching term.
|
||||
- Encode units in names only when the value could otherwise be ambiguous, such as `_deg`, `_sec`, or `_mm` suffixes.
|
||||
- Keep sidecar parameter ids aligned with the Python source parameters they mirror, and keep source constants, manifest feature ids, UI labels, and comments aligned enough that an LLM can trace a control to geometry.
|
||||
- Module schema field names such as `schemaVersion`, `manifest.step.path`, and `durationSeconds` are fixed by the step-module schema; the snake_case convention applies to the parameter and feature ids you define.
|
||||
|
||||
For STEP sidecars, strongly prefer an explicit target link in the module manifest:
|
||||
|
||||
```js
|
||||
export default {
|
||||
manifest: {
|
||||
schemaVersion: 1,
|
||||
step: {
|
||||
path: "models/path/to/model.step"
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
`manifest.step.path` must be a workspace-relative path, never an absolute path, URL, or path with `..` segments. This link is provenance for humans and tools, not a freshness contract; do not add hashes or staleness checks to STEP parameter modules. Keep the sidecar named `.<step-stem>.step.js` when it lives next to its STEP file so existing viewers can fall back to the same-filename convention if `manifest.step.path` is absent.
|
||||
|
||||
## Defaults And Bounds
|
||||
|
||||
Defaults should produce a useful, valid model or pose. Bounds should protect the model from impossible, self-intersecting, or misleading states.
|
||||
|
||||
- Use physically valid ranges where possible: joint limits, positive dimensions, manufacturable wall thickness, realistic clearances.
|
||||
- Clamp in code even when the UI already declares `min` and `max`.
|
||||
- Make `step` match the useful precision of the underlying model, not just the UI.
|
||||
- Use booleans for true binary state, selects for discrete modes, colors for style-only values, and numbers only for ordered quantities.
|
||||
- Keep debug parameters available when useful, but label them as inspection controls if they do not represent real design degrees of freedom.
|
||||
|
||||
## Derive, Do Not Drift
|
||||
|
||||
Compute dependent values from the real constraints:
|
||||
|
||||
- Use pivots, axes, centers, bounds, and measured link lengths instead of eyeballed translations.
|
||||
- Compose assembly transforms around the correct local datum or joint, not around visual centers unless that is the actual design datum.
|
||||
- For linkages, solve the kinematics from fixed pivots and link lengths. Do not interpolate through impossible intermediate points.
|
||||
- For gears, preserve pitch-circle relationships, tooth counts, and angular ratios instead of tuning rotations by sight.
|
||||
- For repeated features, derive positions from count, pitch, radius, and pattern axes.
|
||||
|
||||
If a parameter changes a source-level CAD generator, regenerate STEP and validate the exported geometry. If a STEP sidecar changes only viewer-time presentation, say so in labels/descriptions when ambiguity matters.
|
||||
|
||||
## Features And Refs
|
||||
|
||||
Named features are the bridge between parameters and geometry.
|
||||
|
||||
- Label source parts and assembly children explicitly.
|
||||
- Expose sidecar `manifest.features` with stable local refs such as `#o1.2`; keep file identity in `manifest.step.path`.
|
||||
- Prefer feature ids like `lid`, `hinge_pin`, `input_gear`, `lower_rocker`, not occurrence ids as public names.
|
||||
- In code, group constants and transforms by feature role so the logic reads like the mechanism.
|
||||
- Resolve and inspect refs when a parameter targets a specific face, edge, part, pivot, or assembly child.
|
||||
|
||||
## Animations
|
||||
|
||||
Animation parameters should drive the smallest real degrees of freedom and derive everything else.
|
||||
|
||||
- Use one normalized travel parameter for a mechanism when possible, then derive all dependent transforms from it.
|
||||
- Make loops exact: the final pose must equal the initial pose, or the animation should ping-pong through a periodic function.
|
||||
- Do not blend between incompatible kinematic branches. Switch branches only at a physically valid tangent, over-center, or singular pose.
|
||||
- Keep hinge centers, mating faces, gear contacts, belt paths, and slider axes coincident throughout the animation.
|
||||
- Separate style controls from mechanism controls: colors, visibility, highlights, clip/explode, speed, play/pause, and scrub should not alter the mechanical truth.
|
||||
- Preserve source STEP/GLB material colors by default. Only override colors, add color controls, or assign viewer-time color styles when the user explicitly asks for recoloring, presentation styling, or diagnostic color coding.
|
||||
- Use comments for non-obvious kinematic choices, especially branch selection, sign conventions, datum origin, and derived ratios.
|
||||
|
||||
For STEP sidecars, use JavaScript for live CAD Viewer interaction and Three.js hooks. Use Python/build123d as the source of truth for regenerating geometry. Python may generate `.step.js` modules, but CAD Viewer controls should not imply regeneration unless that workflow exists.
|
||||
|
||||
## Controls
|
||||
|
||||
Expose controls that make the model understandable, not every constant.
|
||||
|
||||
- Numeric dimensions: slider plus number input when the range is bounded and interactive; number input when the range is broad or precision-heavy.
|
||||
- Angles and normalized travel: sliders with clear min/max and units.
|
||||
- Visibility, enablement, and optional details: switches.
|
||||
- Discrete modes: select or segmented control.
|
||||
- Colors: color controls only when explicitly requested for viewer styling; otherwise keep imported material colors.
|
||||
- Animation: play/pause, scrub, loop, reset, and speed controls.
|
||||
|
||||
Use concise labels and descriptions. A good description says what changes and what stays constrained.
|
||||
|
||||
## Validation
|
||||
|
||||
Validate parameter behavior at representative values:
|
||||
|
||||
- Defaults.
|
||||
- Min and max.
|
||||
- Mid travel.
|
||||
- Boundary or branch-change poses.
|
||||
- Values involved in user-reported failures.
|
||||
|
||||
Use deterministic checks first:
|
||||
|
||||
- `scripts/inspect refs --facts --planes --positioning` for scale, labels, frames, and major datums.
|
||||
- `scripts/inspect frame`, `measure`, or `align` for pivots, axes, mating faces, and distances.
|
||||
- Source-level assertions for derived dimensions or joint limits when practical.
|
||||
|
||||
Use CAD `scripts/snapshot` review for visual semantics, following `snapshot-review.md` for packet sizing and PNG-vs-GIF mode selection:
|
||||
|
||||
- Review several parameter poses, with GIFs for motion/animation review.
|
||||
- Compare sidecar enabled vs disabled when viewer-time presentation is involved.
|
||||
- Check for disconnected hinges, drifting pivots, collisions, impossible branch blends, and looping jumps.
|
||||
- Convert visual concerns into measurements or explicit geometric facts before calling them fixed.
|
||||
|
||||
## Common Failure Patterns
|
||||
|
||||
- Eyeballed keyframes that violate real link lengths or mating constraints.
|
||||
- A UI parameter that controls a visual transform but is named like a geometry parameter.
|
||||
- Interpolating between two valid poses through invalid intermediate geometry.
|
||||
- Transforming a part around its bounding-box center instead of its hinge, mate, or local frame.
|
||||
- Letting a debug scale or offset create collisions outside the real design envelope.
|
||||
- Using absolute paths, URLs, parent-directory escapes, or stale renamed STEP paths in sidecar `manifest.step.path`.
|
||||
- Hiding a geometry issue with color, transparency, camera angle, or exploded spacing.
|
||||
@@ -0,0 +1,260 @@
|
||||
# Positioning logic, joints, and mating
|
||||
|
||||
Read this file when geometry has mating interfaces, repeated features, assembly children, axes, datums, motion, or user-specified alignment. This is the authoritative reference for assembly positioning, part-local origins, build123d joints, explicit `Location` transforms, CLI `inspect align`, and positioning report content.
|
||||
|
||||
## Core rule
|
||||
|
||||
Positioning is authored in source and validated after generation. Do not position parts by visually dragging or by editing exported STEP geometry. Use build123d parameters, local coordinate systems, `Location` transforms, `Plane`/`Axis` datums, `cadpy.assembly.AssemblyHelper` relationships, source-level `Joint` objects when useful, and labeled assembly children.
|
||||
|
||||
## Terminology
|
||||
|
||||
Use these terms carefully:
|
||||
|
||||
- **AssemblyHelper** is the preferred generated-script wrapper from `cadpy.assembly`. It records semantic relationships such as `face_to_face`, `coaxial`, `revolute`, and `linear`, then realizes them with native build123d joints.
|
||||
- **build123d joints** are source-level objects such as `RigidJoint`, `RevoluteJoint`, `LinearJoint`, `CylindricalJoint`, and `BallJoint`. They are attached to `Solid` or `Compound` objects and can reposition parts with `connect_to()`.
|
||||
- **CLI `inspect align`** is a selector-pair validation tool. It computes a read-only translation delta between selected local refs in a STEP/CAD entry. It does not edit source code, patch exported STEP files, or represent an authored mate feature. This is the one place that distinction is defined; the rest of the skill assumes it.
|
||||
- **Mating intent** is the design relationship: flush, centered, coaxial, offset, hinge-like, slider-like, or otherwise datum-driven.
|
||||
|
||||
Use `AssemblyHelper` and build123d joints to express and compute source assembly placement where appropriate, then use CLI inspection to validate the generated STEP.
|
||||
|
||||
## Preferred assembly structure
|
||||
|
||||
For assemblies, prefer a mate/joint-driven structure over arbitrary transforms:
|
||||
|
||||
```text
|
||||
root component
|
||||
→ part-local coordinate systems
|
||||
→ named datums / joint locations
|
||||
→ AssemblyHelper semantic relationships backed by native build123d joints
|
||||
→ labeled Compound assembly with verbose native labels
|
||||
→ refs/measure/frame/align validation
|
||||
```
|
||||
|
||||
A numeric `Location(...)` should usually correspond to a stated datum, offset, clearance, screw axis, face contact, or joint relationship.
|
||||
|
||||
Group a functional unit — a bearing, a gearbox stage, a fastener set — into a sub-assembly node with `asm.add_module(name, children)` when it is placed, reasoned about, or repeated as a unit; nested occurrence refs such as `#o1.12.1` then stay meaningful.
|
||||
|
||||
## Part-local positioning
|
||||
|
||||
For each part, define a local coordinate convention before modeling:
|
||||
|
||||
```text
|
||||
- Origin: center, base datum, mounting interface, or functional axis.
|
||||
- XY plane: main sketch/base plane unless another datum is dominant.
|
||||
- +Z: extrusion/up direction.
|
||||
- Named dimensions: offsets, hole spacing, boss spacing, clearances.
|
||||
- Datum features: mating faces, screw axes, centerlines, locating tabs, rails.
|
||||
```
|
||||
|
||||
Good defaults:
|
||||
|
||||
- Symmetric standalone parts: origin at body center.
|
||||
- Plates: origin at footprint center; thickness along Z.
|
||||
- Enclosures: origin at footprint center; base/lid mating surfaces controlled by Z parameters.
|
||||
- Shaft/knob/axisymmetric parts: origin on rotational axis.
|
||||
- Mating adapter plates: origin on the primary mounting datum or center of the bolt pattern.
|
||||
|
||||
## Feature placement inside a part
|
||||
|
||||
Use named parameters and local coordinates:
|
||||
|
||||
```python
|
||||
hole_offset_x = 30
|
||||
hole_offset_y = 17.5
|
||||
hole_positions = [
|
||||
(-hole_offset_x, -hole_offset_y),
|
||||
( hole_offset_x, -hole_offset_y),
|
||||
(-hole_offset_x, hole_offset_y),
|
||||
( hole_offset_x, hole_offset_y),
|
||||
]
|
||||
|
||||
with Locations(*hole_positions):
|
||||
Hole(radius=hole_diameter / 2)
|
||||
```
|
||||
|
||||
Avoid untraceable placement constants inside geometry calls. Put all meaningful offsets into parameters.
|
||||
|
||||
## AssemblyHelper pattern
|
||||
|
||||
Use `AssemblyHelper` for generated assembly scripts. It keeps the LLM-facing code intent-focused while still using native build123d labels, `Joint` objects, and `Compound` assemblies.
|
||||
|
||||
```python
|
||||
from build123d import *
|
||||
from cadpy.assembly import AssemblyHelper
|
||||
|
||||
base_height = 30.0
|
||||
lid_thickness = 3.0
|
||||
gasket_gap = 0.5
|
||||
|
||||
asm = AssemblyHelper("enclosure")
|
||||
base = asm.add(make_base(), "base")
|
||||
lid = asm.add(make_lid(), "lid")
|
||||
|
||||
base_seat = asm.rigid_frame(
|
||||
base,
|
||||
"lid_seat",
|
||||
Location((0, 0, base_height / 2)),
|
||||
)
|
||||
lid_underside = asm.rigid_frame(
|
||||
lid,
|
||||
"underside",
|
||||
Location((0, 0, -lid_thickness / 2)),
|
||||
)
|
||||
|
||||
asm.face_to_face(base_seat, lid_underside, offset=gasket_gap)
|
||||
|
||||
def gen_step():
|
||||
return asm.build()
|
||||
```
|
||||
|
||||
The fixed target is listed first and the moving target second. In the example above, the base stays fixed and the lid moves. The helper records the relationship in source and calls native build123d `connect_to()` under the hood; exported STEP contains the resolved static placement and native assembly labels, not persistent external constraints.
|
||||
|
||||
Use helper labels intentionally:
|
||||
|
||||
```python
|
||||
standoff = asm.feature(Cylinder(radius=3.0, height=12.0), "m3_standoff", "front_left")
|
||||
hinge_axis = asm.rigid_frame(lid, "hinge_axis", Location((0, -25, 0)))
|
||||
```
|
||||
|
||||
Assembly labels name the root occurrence. `asm.add()` labels child component occurrences and their exported shape context. For repeated hardware or library parts, use role/location labels such as `front_left` and `rear_right` so STEP topology and viewer selections remain traceable after export.
|
||||
|
||||
Feature labels survive best when the labeled geometry remains a child shape in a `Compound`. Labels on boolean-subtracted or fused feature history are not reliable STEP feature history.
|
||||
|
||||
Use the frame method that matches native build123d joint inputs: `rigid_frame()` and `ball_frame()` take a `Location`; `revolute_frame()`, `linear_frame()`, and `cylindrical_frame()` take an `Axis` plus optional native range/reference arguments.
|
||||
|
||||
## Imported components
|
||||
|
||||
For purchased or downloaded parts (see `$step-parts`), import the STEP file and add it like any authored part:
|
||||
|
||||
```python
|
||||
from build123d import import_step
|
||||
|
||||
servo = asm.add(import_step("models/parts/sg90_servo.step"), "servo")
|
||||
```
|
||||
|
||||
Imported geometry was not authored here, so do not assume its origin or orientation. Derive mating frames from inspected geometry: run `refs --facts --planes --positioning` and `measure` against the imported part, then define `asm.rigid_frame(...)` locations from the measured faces, axes, and bolt patterns. Validate the resulting mate exactly like an authored one.
|
||||
|
||||
## When to use build123d joints
|
||||
|
||||
Use `AssemblyHelper`/build123d joints when assembly intent is clearer as a relationship between part datums than as a raw transform:
|
||||
|
||||
- lid-to-base, cover-to-frame, bracket-to-rail, flange-to-pipe, pin-to-hole, shaft-to-bearing
|
||||
- hinge, slider, screw-like, cylindrical, ball/gimbal, or other motion-positioned assemblies
|
||||
- repeated or library components that already expose joints
|
||||
- source assemblies where a change to one dimension should recompute part placement
|
||||
|
||||
Direct `Location(...)` transforms are acceptable for simple static layouts when they are parameterized and documented, such as a row of identical spacers or a visual exploded view.
|
||||
|
||||
Raw build123d joints are acceptable for advanced cases not covered by `AssemblyHelper`, but preserve the same fixed-first directionality: call `connect_to()` on the fixed/root joint and pass the moving part's joint as `other`. `connect_to()` is a source-generation operation. It repositions the moving part for the generated model; it is not a persistent external constraint in the exported STEP file.
|
||||
|
||||
## Joint type selection
|
||||
|
||||
Use the simplest joint that expresses the source-level relationship:
|
||||
|
||||
- `RigidJoint` / `asm.rigid_frame()`: fixed placement, face-to-face seating, mounting datums, imported components with known interfaces.
|
||||
- `RevoluteJoint` / `asm.revolute_frame()`: hinge or rotational pose; define with an `Axis` and drive with an angle parameter for a static STEP pose.
|
||||
- `LinearJoint` / `asm.linear_frame()`: slider, latch, telescoping component; define with an `Axis` and drive with a position parameter.
|
||||
- `CylindricalJoint` / `asm.cylindrical_frame()`: combined axial translation and rotation, such as screw-like or pin-in-slot relationships.
|
||||
- `BallJoint` / `asm.ball_frame()`: gimbal or spherical orientation relationship; define with a `Location` and angular ranges.
|
||||
|
||||
When only final static placement matters and no meaningful joint datum exists, use explicit `Location` transforms and validate them.
|
||||
|
||||
## Assembly positioning workflow
|
||||
|
||||
1. Choose the fixed/root component.
|
||||
2. Define part-local frames and datums before modeling child placement.
|
||||
3. Identify functional datums such as mating faces, screw axes, hinge axes, sliding axes, locating tabs, gasket offsets, or contact planes.
|
||||
4. Name source-level joints or mating datums on each child with `asm.rigid_frame()`, `asm.revolute_frame()`, `asm.linear_frame()`, or another helper frame method.
|
||||
5. Use `AssemblyHelper` relationship methods where they improve source clarity, otherwise use parameterized `Location` transforms.
|
||||
6. Build a labeled `Compound` assembly with `asm.build()`.
|
||||
7. Generate the assembly through the Python source, not by re-importing the generated STEP (see `step-generation.md`):
|
||||
|
||||
```bash
|
||||
python scripts/step path/to/assembly.py
|
||||
python scripts/inspect refs path/to/assembly.step --facts --planes --positioning
|
||||
```
|
||||
|
||||
## CLI alignment validation
|
||||
|
||||
After generation, select moving and target refs from the local selector refs returned by `refs --positioning` and compute deltas:
|
||||
|
||||
```bash
|
||||
python scripts/inspect align path/to/assembly.step \
|
||||
--moving '#moving_selector' \
|
||||
--target '#target_selector' \
|
||||
--mode flush \
|
||||
--axis z
|
||||
```
|
||||
|
||||
Use `--mode flush` for coplanar face alignment. Use `--mode center` for centerline, plane-center, or symmetrical alignment where supported by the selected references. If the returned delta is outside tolerance, apply a source-level correction (see below), regenerate, and rerun inspection.
|
||||
|
||||
## Frame validation
|
||||
|
||||
Use `frame` to inspect an occurrence or selector's world frame:
|
||||
|
||||
```bash
|
||||
python scripts/inspect frame path/to/assembly.step '#selector'
|
||||
```
|
||||
|
||||
Use this when:
|
||||
|
||||
- a child appears in the wrong orientation
|
||||
- a mating face is offset in world coordinates
|
||||
- an axis is expected to align with X/Y/Z
|
||||
- repeated parts should share orientation
|
||||
- a downstream task needs a stable coordinate frame
|
||||
|
||||
## Measurement validation
|
||||
|
||||
Use `measure` for scalar checks:
|
||||
|
||||
```bash
|
||||
python scripts/inspect measure path/to/assembly.step \
|
||||
--from '#selector_a' \
|
||||
--to '#selector_b' \
|
||||
--axis z
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
- lid bottom face to base top face should be 0 mm for flush contact
|
||||
- two screw axes should have matching X/Y positions
|
||||
- bracket mounting face should sit a specified distance from a datum plane
|
||||
- spacer height should equal requested offset
|
||||
|
||||
## Source-level positioning corrections
|
||||
|
||||
When a positioning check fails, fix one of these in source:
|
||||
|
||||
- child `Location` translation
|
||||
- child `Location` rotation
|
||||
- `AssemblyHelper` relationship fixed/moving order or offset
|
||||
- build123d joint location or axis
|
||||
- part-local origin convention
|
||||
- feature offset parameter
|
||||
- sketch plane
|
||||
- workplane selection
|
||||
- assembly hierarchy
|
||||
- symmetric placement signs
|
||||
|
||||
Then regenerate. Do not patch the exported STEP directly.
|
||||
|
||||
## Reporting positioning
|
||||
|
||||
In the final response, report only checks that were run:
|
||||
|
||||
```text
|
||||
Positioning/joints:
|
||||
- source used RigidJoint lid_seat → underside
|
||||
- base/lid Z mate flush, delta 0.00 mm
|
||||
- screw boss axis alignment: checked in XY by measurement
|
||||
- lid occurrence frame: +Z up, origin at assembly centerline
|
||||
```
|
||||
|
||||
If no positioning-sensitive features exist, say:
|
||||
|
||||
```text
|
||||
Positioning: not applicable beyond centered part-local origin.
|
||||
```
|
||||
|
||||
If a mate or alignment was intended but not checked, say `not checked`; do not imply success.
|
||||
@@ -0,0 +1,169 @@
|
||||
# Repair loop
|
||||
|
||||
Read this file when generation, export, inspection, positioning, snapshot review, CAD Viewer setup, or documentation validation fails.
|
||||
|
||||
## Loop
|
||||
|
||||
1. Read the failing command output.
|
||||
2. Classify the failure.
|
||||
3. Make the smallest responsible source or command change.
|
||||
4. Rerun the failed command.
|
||||
5. Rerun any dependent validation checks.
|
||||
6. Report remaining risk or deliberate deviations.
|
||||
|
||||
## Failure classes and fixes
|
||||
|
||||
### Source import or syntax failure
|
||||
|
||||
Likely causes:
|
||||
|
||||
- invalid Python syntax
|
||||
- missing import
|
||||
- wrong build123d symbol
|
||||
- function not named `gen_step()`
|
||||
- executable code outside the intended function has side effects
|
||||
|
||||
Fix:
|
||||
|
||||
- correct imports and syntax
|
||||
- ensure `gen_step()` returns the STEP-ready shape or compound
|
||||
- keep output paths in CLI commands, not inside `gen_step()`
|
||||
|
||||
### Invalid or missing geometry
|
||||
|
||||
Likely causes:
|
||||
|
||||
- open sketch
|
||||
- subtractive profile outside target
|
||||
- zero thickness
|
||||
- boolean operation failed
|
||||
- construction geometry used as exported geometry
|
||||
|
||||
Fix:
|
||||
|
||||
- close profiles intended to become faces
|
||||
- verify dimensions are positive
|
||||
- make subtractive tools pass through when through-cuts are intended
|
||||
- simplify the failing feature and rebuild incrementally
|
||||
|
||||
### Fillet or chamfer failure
|
||||
|
||||
Likely causes:
|
||||
|
||||
- radius/length exceeds local geometry
|
||||
- selected edges include tiny or unintended edges
|
||||
- boolean operation created complex edge topology
|
||||
|
||||
Fix:
|
||||
|
||||
- reduce radius/length
|
||||
- filter selected edges more narrowly
|
||||
- apply fillets later in the model
|
||||
- split edge groups by feature intent
|
||||
|
||||
### Wrong scale or bounding box
|
||||
|
||||
Likely causes:
|
||||
|
||||
- units mismatch
|
||||
- mistaken diameter/radius
|
||||
- extrusion direction or amount wrong
|
||||
- part not centered as assumed
|
||||
- direct imported STEP uses unexpected units
|
||||
|
||||
Fix:
|
||||
|
||||
- check parameter values
|
||||
- inspect facts and planes
|
||||
- measure critical extents
|
||||
- correct source dimensions or import handling
|
||||
|
||||
### Missing feature
|
||||
|
||||
Likely causes:
|
||||
|
||||
- wrong `Mode.ADD`/`Mode.SUBTRACT`
|
||||
- feature profile not inside target
|
||||
- blind cut too shallow
|
||||
- selector changed after prior operation
|
||||
|
||||
Fix:
|
||||
|
||||
- confirm feature mode
|
||||
- increase cut length for through-cuts
|
||||
- inspect topology or planes
|
||||
- regenerate and measure/check feature-specific refs
|
||||
|
||||
### Selector fragility
|
||||
|
||||
Likely causes:
|
||||
|
||||
- arbitrary index selection
|
||||
- topology changed after fillet or boolean
|
||||
- similar faces/edges are indistinguishable
|
||||
|
||||
Fix:
|
||||
|
||||
- select by axis, plane, position, normal, or inspected reference
|
||||
- use `refs --facts --planes --positioning` to rediscover stable references
|
||||
- add construction datums or simplify operations if needed
|
||||
|
||||
### Positioning or joint mismatch
|
||||
|
||||
Likely causes: wrong part-local origin or datum, reversed `AssemblyHelper` fixed/moving order, `.connect_to()` moving the wrong part, inverted joint axis, sign errors in symmetric placement, an explicit `Location` not recomputed after a parameter change, or a joint defined in world coordinates when a part-local datum was intended.
|
||||
|
||||
Fix:
|
||||
|
||||
- inspect `refs --positioning`, then `frame` and `align` on the relevant selectors
|
||||
- verify the source-level `AssemblyHelper` target order, joint labels, and `joint_location` definitions
|
||||
- apply the smallest source correction from the list in `positioning.md` (Source-level positioning corrections)
|
||||
- regenerate the assembly from the Python source and rerun the failed check
|
||||
|
||||
### CAD Viewer startup or link failure
|
||||
|
||||
Likely causes:
|
||||
|
||||
- Node/npm unavailable
|
||||
- CAD Viewer app not built or cannot start
|
||||
- active Viewer URL is missing the absolute `?dir=` for the project
|
||||
- returned link is missing an absolute `file=` path or points outside `?dir=`
|
||||
|
||||
Fix:
|
||||
|
||||
- rerun `$cad-viewer` with the same absolute `?dir=` for the project and an absolute `file=` path for each artifact
|
||||
- return one documented Viewer link per requested file
|
||||
- if unresolved, report the startup failure and rely on CLI facts/measurements plus snapshots for validation
|
||||
|
||||
### CAD `scripts/snapshot` failure
|
||||
|
||||
Likely causes:
|
||||
|
||||
- target input path is wrong, missing, or not a STEP/STP file or same-stem Python generator
|
||||
- adjacent CAD Viewer GLB/topology artifact missing
|
||||
- invalid render flags
|
||||
|
||||
Fix:
|
||||
|
||||
- generate STEP first, then snapshot the primary `.step`/`.stp` artifact
|
||||
- retry only with simpler supported snapshot jobs, starting with a single `view` output before wireframe display or `section`
|
||||
- choose modes and packet size per `snapshot-review.md`
|
||||
|
||||
## Diff after repair
|
||||
|
||||
Use `diff` when the fix might have affected unrelated geometry:
|
||||
|
||||
```bash
|
||||
python scripts/inspect diff path/to/before.step path/to/after.step --planes
|
||||
```
|
||||
|
||||
## Reporting failed repairs
|
||||
|
||||
If a check cannot be repaired in the current environment, report:
|
||||
|
||||
```text
|
||||
- what failed
|
||||
- what was tried
|
||||
- which artifact is still usable
|
||||
- which validation claims cannot be made
|
||||
- what the next source-level correction should be
|
||||
```
|
||||
@@ -0,0 +1,84 @@
|
||||
# Snapshot review
|
||||
|
||||
Read this file when choosing saved CAD `scripts/snapshot` outputs for primary STEP/STP artifacts.
|
||||
|
||||
## Policy
|
||||
|
||||
Snapshot validation is mandatory. Every created or visibly updated primary STEP/STP part or assembly gets at least one reviewed PNG snapshot; deterministic checks passing is not a reason to skip. Use CAD `scripts/snapshot` rather than opening the viewer manually or using Playwright; snapshots are faster, lighter, more precise, and more agent-friendly. Use PNGs for static reviews and GIFs for motion/animation reviews, including STEP-module parameter animation.
|
||||
|
||||
Skip saved snapshots only when no visible geometry was created or updated, or no valid artifact exists:
|
||||
|
||||
- pure format/export requests where geometry is unchanged
|
||||
- source changes that do not alter visible geometry
|
||||
- inspection-only tasks (for example direct measurement questions) that create or update nothing
|
||||
- failed Python or STEP generation before a valid artifact exists
|
||||
|
||||
When skipping, report the reason and the deterministic evidence that still ran.
|
||||
|
||||
Do not loop on snapshots. Rerender only when a source repair changed visible geometry or when a specific visual finding needs confirmation.
|
||||
|
||||
## Packet sizing
|
||||
|
||||
One PNG is enough for a simple static part. Use the small multi-view packet when semantic errors are plausible from shape complexity or prompt intent:
|
||||
|
||||
- assemblies or more than one body/part
|
||||
- holes on multiple faces or multiple axes
|
||||
- shells, internal cavities, bores, passages, open enclosures, or section-critical features
|
||||
- ribs, gussets, bosses, standoffs, slots, cutouts, lightening holes, fins, blades, or repeated patterns
|
||||
- source repairs after a geometry, boolean, selector, or feature failure
|
||||
- prompts where "looks like the requested object" is part of the task
|
||||
- deterministic checks pass but visible semantics are still uncertain
|
||||
|
||||
## Small packet
|
||||
|
||||
Prefer a single `view` JSON job with these outputs:
|
||||
|
||||
```json
|
||||
{
|
||||
"input": "models/part.step",
|
||||
"mode": "view",
|
||||
"outputs": [
|
||||
{ "path": "/tmp/render/iso.png", "camera": "iso" },
|
||||
{ "path": "/tmp/render/iso_opposite.png", "camera": { "direction": [-1, 1, -0.8] } },
|
||||
{ "path": "/tmp/render/top_ortho.png", "camera": "top" },
|
||||
{ "path": "/tmp/render/front_ortho.png", "camera": "front" }
|
||||
],
|
||||
"render": { "viewLabels": true, "padding": 0.12, "sizeProfile": "diagnostic" }
|
||||
}
|
||||
```
|
||||
|
||||
The two opposed isometric views guarantee every face appears in at least one image — rear, left, and bottom features are covered by default, not by suspicion. The top ortho is the primary pattern/symmetry check and the front ortho the profile check.
|
||||
|
||||
Set `input` to the primary STEP/STP artifact using a relative or absolute path. The snapshot CLI derives its internal render root from that input path. It defaults to `appearance: "workbench"` and `display.mode: "solid"`, matching CAD Viewer; labeled/section views default to 1600x1200 when dimensions are omitted. Use `render.sizeProfile: "assembly"` or `"assembly-large"` for complex assemblies that need 1800x1200 or 1920x1440. For CAD review packets, use still-image render modes `view` and `section`; set `display.mode` to `solid`, `transparent`, `hidden_edges`, `hidden_lines_removed`, or `wireframe` when the visual check benefits from explicit CAD linework.
|
||||
|
||||
Use `--focus '#o1.2' ...` to render only specific part or subassembly occurrence refs, or `--hide '#o1.2' ...` to omit them. Do not combine focus and hide in the same snapshot command or job. These filters accept occurrence refs only, not face, edge, vertex, or shape selectors.
|
||||
|
||||
The snapshot CLI appends one shared UTC seconds timestamp before each output file extension when saving a packet, so readable paths like `iso_solid.png` become names such as `iso_solid_20260527T163012Z.png`.
|
||||
|
||||
## Targeted additions
|
||||
|
||||
Add views only when the brief or a failure mode calls for them:
|
||||
|
||||
- reference-image reproduction: one snapshot from the reference image's viewpoint for side-by-side comparison
|
||||
- `section`: shell, bore, internal cavity, passage, blind hole, enclosure, or wall/floor relationship
|
||||
- `display.mode: "solid"`: shaded CAD view with explicit edge linework
|
||||
- `display.mode: "rendered"`: shaded material view without edge overlay
|
||||
- `display.mode: "transparent"`: overlap, collision, enclosure readability, or hidden contact checks when transparency adds information and wireframe is too noisy
|
||||
- `display.mode: "hidden_edges"`: opaque shaded context with hidden/occluded CAD edges visible through solids
|
||||
- `display.mode: "hidden_lines_removed"`: line-focused review where hidden/occluded edges should be suppressed
|
||||
- `display.mode: "wireframe"`: internal overlap, hidden interference, or assembly collision suspicion when full triangle wire is useful
|
||||
- labeled or annotated review: use supported CAD Viewer refs, selections, screenshots, or GUI review links
|
||||
|
||||
Exploded or labeled review is an intent, not a render mode. Satisfy it through supported CAD Viewer mechanisms, supported JSON job settings, or the GUI link.
|
||||
|
||||
## Diagnostic review
|
||||
|
||||
Visual review is diagnostic, not authoritative. Convert every visual concern into a follow-up geometry check before using it as a validation claim:
|
||||
|
||||
- hole pattern appears asymmetric -> measure hole centers and compare offsets
|
||||
- lid, child part, or occurrence appears offset -> inspect frames and mating deltas
|
||||
- gusset, boss, standoff, rib, or plate may be floating -> inspect solid count, labels, connectivity, contact, or relevant distances
|
||||
- cavity, bore, or blind hole looks wrong -> run section review, then measure wall thickness, depth, or through-condition
|
||||
- repeated pattern looks uneven -> measure pattern centers, angular spacing, or occurrence frames
|
||||
|
||||
Final reports should include the generated snapshot PNG/GIFs or the documented skip reason, and state which deterministic checks support any visual finding.
|
||||
@@ -0,0 +1,59 @@
|
||||
# STEP generation
|
||||
|
||||
Read this file when generating or regenerating STEP/STP artifacts from build123d Python source or from direct STEP/STP targets.
|
||||
|
||||
## Tool
|
||||
|
||||
The launcher lives in the CAD skill directory:
|
||||
|
||||
```bash
|
||||
python scripts/step [--kind {part|assembly}] targets... [flags]
|
||||
```
|
||||
|
||||
Use explicit target paths only; target paths resolve from the command cwd unless absolute. Do not rely on directory-wide generation.
|
||||
|
||||
Plain generated Python targets write sibling `.step` outputs. Use `-o`/`--output` only with one plain generated Python target, or use `SOURCE.py=OUTPUT.step` positional pairs for per-target custom outputs. Paired output paths resolve from the command cwd and are valid only for generated Python sources, not direct STEP/STP inputs. Do not put output paths in the `gen_step()` return value; the CLI owns output paths.
|
||||
|
||||
## Generated Python source
|
||||
|
||||
This is the default path when designing from scratch or modifying a generated model. Generated build123d sources define:
|
||||
|
||||
```python
|
||||
def gen_step():
|
||||
...
|
||||
return step_ready_shape_or_labeled_compound
|
||||
```
|
||||
|
||||
Generated Python targets infer their kind from the source metadata and `gen_step()` return value; pass the source path directly:
|
||||
|
||||
```bash
|
||||
python scripts/step path/to/part.py
|
||||
python scripts/step path/to/part.py -o path/to/custom.step
|
||||
python scripts/step path/to/a.py=out/a.step path/to/b.py=out/b.step
|
||||
python scripts/step path/to/assembly.py
|
||||
```
|
||||
|
||||
Passing a generated assembly `.step` directly treats it as imported native STEP and loses source-level assembly composition; pass the `.py` assembly source. For generated build123d assemblies, prefer `cadpy.assembly.AssemblyHelper` in the Python source so native labels, named mate frames, and source-level relationships are preserved before STEP export (see `positioning.md`).
|
||||
|
||||
## Direct STEP/STP imports
|
||||
|
||||
Use a direct STEP/STP target when no generator exists (imported or downloaded STEP) or the user explicitly identifies a STEP/STP file as the target. The GLB/topology artifacts are then generated from the STEP file itself:
|
||||
|
||||
```bash
|
||||
python scripts/step --kind part path/to/imported.step
|
||||
```
|
||||
|
||||
Direct targets support the same mesh sidecar flags as generator targets; read `supported-exports.md` for STL and 3MF sidecars.
|
||||
|
||||
## Viewer artifacts
|
||||
|
||||
Every `scripts/step` run also writes hidden adjacent GLB/topology artifacts as part of the normal build. They power CAD Viewer review, `$cad-viewer` workflows, and `scripts/inspect` refs, and are not optional in the STEP workflow.
|
||||
|
||||
## After generation
|
||||
|
||||
- Confirm the process succeeded and the STEP file exists and is non-empty.
|
||||
- Run the baseline inspection and any spec-driven checks per `inspection-and-validation.md`:
|
||||
|
||||
```bash
|
||||
python scripts/inspect refs path/to/model.step --facts --planes --positioning
|
||||
```
|
||||
@@ -0,0 +1,75 @@
|
||||
# Supported exports
|
||||
|
||||
Read this file when the user requests STL, 3MF, or native GLB output from CAD geometry. For 2D DXF output, use the `$dxf` skill; DXF uses a separate `gen_dxf()` source contract.
|
||||
|
||||
## Policy
|
||||
|
||||
STL, 3MF, and native GLB are mesh sidecars, not substitutes for STEP. Generate and validate STEP first, then export requested sidecars from the same `scripts/step` run. Do not treat sidecar renders as CAD validation; inspect and snapshot the primary STEP per the standard workflow.
|
||||
|
||||
Native GLB sidecars are ordinary glTF 2.0 binary files for external tools: Y-up, meter-scaled, and free of the CAD Viewer `STEP_topology` extension. Do not confuse them with the hidden `.<name>.step.glb` CAD Viewer topology artifact.
|
||||
|
||||
## Tool
|
||||
|
||||
Use `scripts/step` with a generated Python source:
|
||||
|
||||
```bash
|
||||
python scripts/step path/to/model.py \
|
||||
--stl meshes/model.stl \
|
||||
--3mf meshes/model.3mf \
|
||||
--glb meshes/model.glb
|
||||
```
|
||||
|
||||
When a generator exists, use the generator form. Use direct STEP/STP targets only when the generator is unavailable or the user explicitly identifies that file as the target:
|
||||
|
||||
```bash
|
||||
python scripts/step --kind part path/to/model.step \
|
||||
--stl meshes/model.stl \
|
||||
--3mf meshes/model.3mf \
|
||||
--glb meshes/model.glb
|
||||
```
|
||||
|
||||
Sidecar paths must be relative `.stl`, `.3mf`, or `.glb` paths and are resolved beside the STEP output.
|
||||
|
||||
## Mesh tolerance
|
||||
|
||||
The default mesh density is `0.02` linear deflection and `0.05` angular deflection.
|
||||
|
||||
Use these flags when the default mesh density is wrong for the part:
|
||||
|
||||
```bash
|
||||
--mesh-tolerance FLOAT
|
||||
--mesh-angular-tolerance FLOAT
|
||||
```
|
||||
|
||||
Use tighter tolerances for small curved parts or visual fidelity. Use looser tolerances for large simple geometry when file size matters.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Generate STEP from `gen_step()` with the requested sidecar flag(s).
|
||||
2. Run facts/planes/positioning inspection on the STEP.
|
||||
3. Report the STEP and the requested sidecar files.
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
python scripts/step models/bracket.py \
|
||||
--stl meshes/bracket.stl \
|
||||
--glb meshes/bracket.glb \
|
||||
--mesh-tolerance 0.2 \
|
||||
--mesh-angular-tolerance 0.2
|
||||
|
||||
python scripts/inspect refs models/bracket.step --facts --planes --positioning
|
||||
```
|
||||
|
||||
## Reporting
|
||||
|
||||
```text
|
||||
Files:
|
||||
- STEP: /absolute/project/models/bracket.step
|
||||
- STL: /absolute/project/models/meshes/bracket.stl
|
||||
- GLB: /absolute/project/models/meshes/bracket.glb
|
||||
|
||||
Validation:
|
||||
- STEP geometry validated; STL/3MF/native GLB generated as requested sidecars.
|
||||
- Primary STEP/STP snapshot packet run/skipped and why.
|
||||
```
|
||||
@@ -0,0 +1,2 @@
|
||||
--editable ./scripts/packages/cadpy
|
||||
playwright
|
||||
@@ -0,0 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
TOOL_DIR = Path(__file__).resolve().parent
|
||||
tool_path = str(TOOL_DIR)
|
||||
if tool_path not in sys.path:
|
||||
sys.path.insert(0, tool_path)
|
||||
|
||||
from cli import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
if __package__ in {None, ""}:
|
||||
tool_dir = Path(__file__).resolve().parent
|
||||
if str(tool_dir) not in sys.path:
|
||||
sys.path.insert(0, str(tool_dir))
|
||||
|
||||
from inspect_refs.cli import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1 @@
|
||||
"""Selector reference inspection helpers."""
|
||||
@@ -0,0 +1,641 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import shlex
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Sequence
|
||||
|
||||
if __package__ in {None, ""}:
|
||||
package_dir = Path(__file__).resolve().parent
|
||||
tool_dir = package_dir.parent
|
||||
if str(tool_dir) not in sys.path:
|
||||
sys.path.insert(0, str(tool_dir))
|
||||
|
||||
from cadpy.cli_logging import CliLogger
|
||||
|
||||
|
||||
def _inspect_api():
|
||||
if __package__ in {None, ""}:
|
||||
from inspect_refs import inspect
|
||||
else:
|
||||
from . import inspect
|
||||
|
||||
return inspect
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="inspect",
|
||||
description="Inspect selector refs, geometry facts, and measurements.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=(
|
||||
"examples:\n"
|
||||
" inspect refs STEP/foo.step '#f9' --detail --facts\n"
|
||||
" inspect measure STEP/foo.step --from '#f1' --to '#f2' --axis z\n"
|
||||
" inspect align STEP/foo.step --moving '#f1' --target '#f2' --mode flush --axis z\n"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Show detailed progress and timing information.",
|
||||
)
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
refs_parser = subparsers.add_parser(
|
||||
"refs",
|
||||
help="Resolve whole-entry or selector refs from generated GLB topology.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=(
|
||||
"examples:\n"
|
||||
" inspect refs STEP/foo.step '#f9' --detail --facts\n"
|
||||
" inspect refs STEP/foo.step '#f1' '#e2' --positioning\n"
|
||||
" inspect refs STEP/foo.step --input-file /tmp/refs.txt --planes\n"
|
||||
),
|
||||
)
|
||||
refs_parser.add_argument(
|
||||
"inputs",
|
||||
nargs="*",
|
||||
help="STEP/CAD entry target followed by optional selector refs like #o1.2.f1.",
|
||||
)
|
||||
refs_parser.add_argument(
|
||||
"--input-file",
|
||||
type=Path,
|
||||
help="Read token text from a file instead of CLI input or stdin.",
|
||||
)
|
||||
refs_parser.add_argument(
|
||||
"--detail",
|
||||
action="store_true",
|
||||
help="Include detailed geometry facts for selected face/edge refs.",
|
||||
)
|
||||
refs_parser.add_argument(
|
||||
"--facts",
|
||||
action="store_true",
|
||||
help="Include compact geometry facts for whole-entry refs and resolved selectors.",
|
||||
)
|
||||
refs_parser.add_argument(
|
||||
"--positioning",
|
||||
action="store_true",
|
||||
help="Include placement-ready frame, point, plane, axis, and coordinate facts.",
|
||||
)
|
||||
refs_parser.add_argument(
|
||||
"--planes",
|
||||
action="store_true",
|
||||
help="Include grouped major planar faces for each whole entry.",
|
||||
)
|
||||
_add_plane_report_arguments(refs_parser)
|
||||
refs_parser.add_argument(
|
||||
"--topology",
|
||||
action="store_true",
|
||||
help="Include full face/edge selector lists for whole-entry refs. Expensive on large topology GLBs.",
|
||||
)
|
||||
_add_output_arguments(refs_parser)
|
||||
refs_parser.set_defaults(handler=run_refs)
|
||||
|
||||
diff_parser = subparsers.add_parser(
|
||||
"diff",
|
||||
help="Compare two CAD STEP refs and summarize selector-level changes.",
|
||||
)
|
||||
diff_parser.add_argument("left", help="Left CAD STEP path.")
|
||||
diff_parser.add_argument("right", help="Right CAD STEP path.")
|
||||
diff_parser.add_argument(
|
||||
"--planes",
|
||||
action="store_true",
|
||||
help="Include major planar face groups for both sides.",
|
||||
)
|
||||
_add_plane_report_arguments(diff_parser)
|
||||
_add_output_arguments(diff_parser)
|
||||
diff_parser.set_defaults(handler=run_diff)
|
||||
|
||||
frame_parser = subparsers.add_parser(
|
||||
"frame",
|
||||
help="Return the world frame for an occurrence or selector's owning occurrence.",
|
||||
)
|
||||
frame_parser.add_argument("entry", help="CAD STEP path or CAD entry target.")
|
||||
frame_parser.add_argument("selector", nargs="?", default="", help="Optional selector ref such as #o1.2.")
|
||||
_add_output_arguments(frame_parser)
|
||||
frame_parser.set_defaults(handler=run_frame)
|
||||
|
||||
measure_parser = subparsers.add_parser(
|
||||
"measure",
|
||||
help="Measure signed coordinate distance between two selectors in one STEP entry.",
|
||||
)
|
||||
measure_parser.add_argument("entry", help="CAD STEP path or CAD entry target.")
|
||||
measure_parser.add_argument("--from", dest="from_selector", required=True, help="Moving/source selector ref.")
|
||||
measure_parser.add_argument("--to", dest="to_selector", required=True, help="Target selector ref.")
|
||||
measure_parser.add_argument("--axis", choices=("x", "y", "z"), help="Axis to measure along. Inferred when possible.")
|
||||
_add_output_arguments(measure_parser)
|
||||
measure_parser.set_defaults(handler=run_measure)
|
||||
|
||||
align_parser = subparsers.add_parser(
|
||||
"align",
|
||||
help="Calculate a read-only translation delta for simple selector alignment.",
|
||||
)
|
||||
align_parser.add_argument("entry", help="CAD STEP path or CAD entry target.")
|
||||
align_parser.add_argument("--moving", required=True, help="Moving/source selector ref.")
|
||||
align_parser.add_argument("--target", required=True, help="Target selector ref.")
|
||||
align_parser.add_argument("--mode", choices=("flush", "center"), default="flush", help="Alignment mode. Default: flush.")
|
||||
align_parser.add_argument("--offset", type=float, default=0.0, help="Offset in mm. For flush, applies along target normal when axis-aligned.")
|
||||
align_parser.add_argument("--axis", choices=("x", "y", "z"), help="Axis to use for flush or one-axis center alignment.")
|
||||
_add_output_arguments(align_parser)
|
||||
align_parser.set_defaults(handler=run_align)
|
||||
|
||||
worker_parser = subparsers.add_parser(
|
||||
"worker",
|
||||
help="Run a persistent JSONL inspect worker.",
|
||||
description=(
|
||||
"Read JSONL requests from stdin and write one JSONL response per request. "
|
||||
"Each request is an object with argv: [<inspect-subcommand>, ...] and optional id."
|
||||
),
|
||||
)
|
||||
worker_parser.set_defaults(handler=run_worker)
|
||||
|
||||
batch_parser = subparsers.add_parser(
|
||||
"batch",
|
||||
help="Run JSONL inspect requests from stdin in one process.",
|
||||
description=worker_parser.description,
|
||||
)
|
||||
batch_parser.set_defaults(handler=run_worker)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def _add_output_arguments(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument("--format", choices=("json", "text"), default="json", help="Output format. Default: json.")
|
||||
parser.add_argument("--quiet", action="store_true", help="Reduce nonessential output.")
|
||||
parser.add_argument(
|
||||
"--verbose",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Include extra human-readable detail where available.",
|
||||
)
|
||||
|
||||
|
||||
def _add_plane_report_arguments(
|
||||
parser: argparse.ArgumentParser,
|
||||
*,
|
||||
prefix: str = "plane-",
|
||||
) -> None:
|
||||
coordinate_flags = [f"--{prefix}coordinate-tolerance"]
|
||||
area_flags = [f"--{prefix}min-area-ratio"]
|
||||
limit_flags = [f"--{prefix}limit"]
|
||||
parser.add_argument(
|
||||
*coordinate_flags,
|
||||
dest="plane_coordinate_tolerance",
|
||||
type=float,
|
||||
default=1e-3,
|
||||
help="Merge planar face groups whose axis coordinate differs by at most this value. Default: 0.001",
|
||||
)
|
||||
parser.add_argument(
|
||||
*area_flags,
|
||||
dest="plane_min_area_ratio",
|
||||
type=float,
|
||||
default=0.05,
|
||||
help="Drop planar groups smaller than this fraction of total planar area. Default: 0.05",
|
||||
)
|
||||
parser.add_argument(
|
||||
*limit_flags,
|
||||
dest="plane_limit",
|
||||
type=int,
|
||||
default=12,
|
||||
help="Maximum number of plane groups to emit. Default: 12",
|
||||
)
|
||||
|
||||
|
||||
def run_refs(args: argparse.Namespace) -> int:
|
||||
inspect = _inspect_api()
|
||||
try:
|
||||
entry_target, refs_text = _read_refs_input(args)
|
||||
result = inspect.inspect_cad_refs(
|
||||
entry_target,
|
||||
refs_text,
|
||||
detail=bool(args.detail),
|
||||
include_topology=bool(args.topology),
|
||||
facts=bool(args.facts),
|
||||
positioning=bool(args.positioning),
|
||||
planes=bool(args.planes),
|
||||
plane_coordinate_tolerance=float(args.plane_coordinate_tolerance),
|
||||
plane_min_area_ratio=float(args.plane_min_area_ratio),
|
||||
plane_limit=int(args.plane_limit),
|
||||
)
|
||||
except inspect.CadRefError as exc:
|
||||
result = {
|
||||
"ok": False,
|
||||
"tokens": [],
|
||||
"errors": [inspect.cad_ref_error_payload(exc)],
|
||||
}
|
||||
|
||||
_emit_result(args, result, _format_refs_text)
|
||||
return 0 if bool(result.get("ok")) else 2
|
||||
|
||||
|
||||
def run_diff(args: argparse.Namespace) -> int:
|
||||
inspect = _inspect_api()
|
||||
try:
|
||||
result = inspect.diff_entry_targets(
|
||||
args.left,
|
||||
args.right,
|
||||
planes=bool(args.planes),
|
||||
plane_coordinate_tolerance=float(args.plane_coordinate_tolerance),
|
||||
plane_min_area_ratio=float(args.plane_min_area_ratio),
|
||||
plane_limit=int(args.plane_limit),
|
||||
)
|
||||
except inspect.CadRefError as exc:
|
||||
result = {
|
||||
"ok": False,
|
||||
"left": {"cadPath": _safe_cad_path(args.left)},
|
||||
"right": {"cadPath": _safe_cad_path(args.right)},
|
||||
"errors": [inspect.cad_ref_error_payload(exc)],
|
||||
}
|
||||
|
||||
_emit_result(args, result, _format_diff_text)
|
||||
return 0 if bool(result.get("ok")) else 2
|
||||
|
||||
|
||||
def run_frame(args: argparse.Namespace) -> int:
|
||||
inspect = _inspect_api()
|
||||
try:
|
||||
result = inspect.inspect_target_frame(args.entry, args.selector)
|
||||
except inspect.CadRefError as exc:
|
||||
result = {
|
||||
"ok": False,
|
||||
"target": args.entry,
|
||||
"errors": [inspect.cad_ref_error_payload(exc)],
|
||||
}
|
||||
|
||||
_emit_result(args, result, _format_frame_text)
|
||||
return 0 if bool(result.get("ok")) else 2
|
||||
|
||||
|
||||
def run_measure(args: argparse.Namespace) -> int:
|
||||
inspect = _inspect_api()
|
||||
try:
|
||||
result = inspect.measure_targets(args.entry, args.from_selector, args.to_selector, axis=args.axis)
|
||||
except inspect.CadRefError as exc:
|
||||
result = {
|
||||
"ok": False,
|
||||
"entry": args.entry,
|
||||
"from": args.from_selector,
|
||||
"to": args.to_selector,
|
||||
"errors": [inspect.cad_ref_error_payload(exc)],
|
||||
}
|
||||
|
||||
_emit_result(args, result, _format_measure_text)
|
||||
return 0 if bool(result.get("ok")) else 2
|
||||
|
||||
|
||||
def run_align(args: argparse.Namespace) -> int:
|
||||
inspect = _inspect_api()
|
||||
try:
|
||||
result = inspect.align_targets(
|
||||
args.entry,
|
||||
args.moving,
|
||||
args.target,
|
||||
mode=args.mode,
|
||||
offset=float(args.offset),
|
||||
axis=args.axis,
|
||||
)
|
||||
except inspect.CadRefError as exc:
|
||||
result = {
|
||||
"ok": False,
|
||||
"entry": args.entry,
|
||||
"moving": args.moving,
|
||||
"target": args.target,
|
||||
"errors": [inspect.cad_ref_error_payload(exc)],
|
||||
}
|
||||
|
||||
_emit_result(args, result, _format_align_text)
|
||||
return 0 if bool(result.get("ok")) else 2
|
||||
|
||||
|
||||
def run_worker(args: argparse.Namespace) -> int:
|
||||
_ = args
|
||||
for raw_line in sys.stdin:
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
response = _worker_response(line)
|
||||
print(json.dumps(response, separators=(",", ":")), flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
def _worker_response(line: str) -> dict[str, object]:
|
||||
request_id: object = None
|
||||
try:
|
||||
request = json.loads(line)
|
||||
argv = _worker_request_argv(request)
|
||||
if isinstance(request, dict):
|
||||
request_id = request.get("id")
|
||||
exit_code, result = inspect_command_result(argv)
|
||||
except Exception as exc:
|
||||
exit_code = 2
|
||||
result = {
|
||||
"ok": False,
|
||||
"errors": [_exception_error_payload(exc)],
|
||||
}
|
||||
response: dict[str, object] = {
|
||||
"ok": exit_code == 0,
|
||||
"exitCode": exit_code,
|
||||
"result": result,
|
||||
}
|
||||
if request_id is not None:
|
||||
response["id"] = request_id
|
||||
return response
|
||||
|
||||
|
||||
def _worker_request_argv(request: object) -> list[str]:
|
||||
if isinstance(request, dict):
|
||||
raw_argv = request.get("argv")
|
||||
else:
|
||||
raw_argv = request
|
||||
if isinstance(raw_argv, str):
|
||||
return shlex.split(raw_argv)
|
||||
if isinstance(raw_argv, list) and all(isinstance(item, (str, int, float)) for item in raw_argv):
|
||||
return [str(item) for item in raw_argv]
|
||||
raise ValueError("Worker request must be a JSON object with argv, a JSON argv array, or a shell-style argv string.")
|
||||
|
||||
|
||||
def inspect_command_result(argv: Sequence[str]) -> tuple[int, dict[str, object]]:
|
||||
command_argv = [str(item) for item in argv]
|
||||
if not command_argv:
|
||||
return 2, {"ok": False, "errors": [{"message": "empty inspect command"}]}
|
||||
if command_argv[0] in {"worker", "batch"}:
|
||||
return 2, {"ok": False, "errors": [{"message": f"Unsupported worker command: {command_argv[0]}"}]}
|
||||
stderr = io.StringIO()
|
||||
try:
|
||||
parser = build_parser()
|
||||
with contextlib.redirect_stderr(stderr):
|
||||
args = parser.parse_args(command_argv)
|
||||
except SystemExit as exc:
|
||||
return _system_exit_result(exc, stderr=stderr.getvalue())
|
||||
|
||||
try:
|
||||
if args.command == "refs":
|
||||
if not args.inputs and not args.input_file:
|
||||
raise _inspect_api().CadRefError("No STEP/CAD entry target provided.")
|
||||
entry_target, refs_text = _read_refs_input(args)
|
||||
inspect = _inspect_api()
|
||||
result = inspect.inspect_cad_refs(
|
||||
entry_target,
|
||||
refs_text,
|
||||
detail=bool(args.detail),
|
||||
include_topology=bool(args.topology),
|
||||
facts=bool(args.facts),
|
||||
positioning=bool(args.positioning),
|
||||
planes=bool(args.planes),
|
||||
plane_coordinate_tolerance=float(args.plane_coordinate_tolerance),
|
||||
plane_min_area_ratio=float(args.plane_min_area_ratio),
|
||||
plane_limit=int(args.plane_limit),
|
||||
)
|
||||
elif args.command == "diff":
|
||||
inspect = _inspect_api()
|
||||
result = inspect.diff_entry_targets(
|
||||
args.left,
|
||||
args.right,
|
||||
planes=bool(args.planes),
|
||||
plane_coordinate_tolerance=float(args.plane_coordinate_tolerance),
|
||||
plane_min_area_ratio=float(args.plane_min_area_ratio),
|
||||
plane_limit=int(args.plane_limit),
|
||||
)
|
||||
elif args.command == "frame":
|
||||
result = _inspect_api().inspect_target_frame(args.entry, args.selector)
|
||||
elif args.command == "measure":
|
||||
result = _inspect_api().measure_targets(args.entry, args.from_selector, args.to_selector, axis=args.axis)
|
||||
elif args.command == "align":
|
||||
result = _inspect_api().align_targets(
|
||||
args.entry,
|
||||
args.moving,
|
||||
args.target,
|
||||
mode=args.mode,
|
||||
offset=float(args.offset),
|
||||
axis=args.axis,
|
||||
)
|
||||
else:
|
||||
raise _inspect_api().CadRefError(f"Unsupported inspect command: {args.command}")
|
||||
except _inspect_api().CadRefError as exc:
|
||||
result = {"ok": False, "errors": [_inspect_api().cad_ref_error_payload(exc)]}
|
||||
except Exception as exc:
|
||||
result = {"ok": False, "errors": [_exception_error_payload(exc)]}
|
||||
return (0 if bool(result.get("ok")) else 2), result
|
||||
|
||||
|
||||
def _system_exit_result(exc: SystemExit, *, stderr: str = "") -> tuple[int, dict[str, object]]:
|
||||
try:
|
||||
exit_code = int(exc.code or 0)
|
||||
except (TypeError, ValueError):
|
||||
exit_code = 2
|
||||
ok = exit_code == 0
|
||||
message = stderr.strip() or str(exc)
|
||||
return exit_code, {"ok": ok, "errors": [] if ok else [{"message": message}]}
|
||||
|
||||
|
||||
def _exception_error_payload(exc: Exception) -> dict[str, object]:
|
||||
inspect = _inspect_api()
|
||||
if isinstance(exc, inspect.CadRefError):
|
||||
return inspect.cad_ref_error_payload(exc)
|
||||
return {
|
||||
"type": type(exc).__name__,
|
||||
"message": str(exc),
|
||||
}
|
||||
|
||||
|
||||
def _emit_result(args: argparse.Namespace, result: dict[str, object], text_formatter) -> None:
|
||||
if getattr(args, "format", "json") == "text":
|
||||
text = text_formatter(
|
||||
result,
|
||||
quiet=bool(getattr(args, "quiet", False)),
|
||||
verbose=bool(getattr(args, "verbose", False)),
|
||||
)
|
||||
if text:
|
||||
print(text)
|
||||
return
|
||||
indent = None if bool(getattr(args, "quiet", False)) else 2
|
||||
print(json.dumps(result, indent=indent, sort_keys=False))
|
||||
|
||||
|
||||
def _format_refs_text(result: dict[str, object], *, quiet: bool, verbose: bool) -> str:
|
||||
if not result.get("ok"):
|
||||
return _format_errors(result)
|
||||
lines: list[str] = []
|
||||
for token in result.get("tokens", []):
|
||||
if not isinstance(token, dict):
|
||||
continue
|
||||
summary = token.get("summary") if isinstance(token.get("summary"), dict) else {}
|
||||
headline = f"{token.get('cadPath')} faces={summary.get('faceCount')} edges={summary.get('edgeCount')}"
|
||||
lines.append(headline)
|
||||
if quiet:
|
||||
continue
|
||||
entry_facts = token.get("entryFacts") if isinstance(token.get("entryFacts"), dict) else {}
|
||||
if entry_facts:
|
||||
lines.append(f" facts: {_format_entry_facts_text(entry_facts)}")
|
||||
entry_positioning = token.get("entryPositioning") if isinstance(token.get("entryPositioning"), dict) else {}
|
||||
if entry_positioning:
|
||||
bbox_facts = entry_positioning.get("bboxFacts") if isinstance(entry_positioning.get("bboxFacts"), dict) else {}
|
||||
if bbox_facts and bbox_facts != entry_facts:
|
||||
lines.append(f" positioning: {_format_entry_facts_text(bbox_facts)}")
|
||||
planes = token.get("planes") if isinstance(token.get("planes"), list) else []
|
||||
if planes:
|
||||
lines.extend(_format_planes_text(planes))
|
||||
for selection in token.get("selections", []):
|
||||
if isinstance(selection, dict):
|
||||
lines.append(f" {selection.get('displaySelector')}: {selection.get('summary')}")
|
||||
if verbose and selection.get("copyText"):
|
||||
lines.append(f" {selection.get('copyText')}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_number(value: object) -> str:
|
||||
if isinstance(value, float):
|
||||
return f"{value:.6g}"
|
||||
return str(value)
|
||||
|
||||
|
||||
def _format_vector(value: object) -> str:
|
||||
if not isinstance(value, list):
|
||||
return str(value)
|
||||
return "[" + ", ".join(_format_number(component) for component in value) + "]"
|
||||
|
||||
|
||||
def _format_entry_facts_text(facts: dict[str, object]) -> str:
|
||||
parts: list[str] = []
|
||||
for key in ("size", "center", "extentAxis", "diag", "kind"):
|
||||
if key not in facts:
|
||||
continue
|
||||
value = facts.get(key)
|
||||
if isinstance(value, list):
|
||||
parts.append(f"{key}={_format_vector(value)}")
|
||||
else:
|
||||
parts.append(f"{key}={_format_number(value)}")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def _format_planes_text(planes: list[object], *, limit: int = 3) -> list[str]:
|
||||
lines = [f" planes: {len(planes)} major groups"]
|
||||
for plane in planes[:limit]:
|
||||
if not isinstance(plane, dict):
|
||||
continue
|
||||
axis = plane.get("axis")
|
||||
coordinate = _format_number(plane.get("coordinate"))
|
||||
normal_sign = plane.get("normalSign")
|
||||
face_count = plane.get("faceCount")
|
||||
area = _format_number(plane.get("totalArea"))
|
||||
lines.append(
|
||||
f" {axis}={coordinate} normalSign={normal_sign} faces={face_count} area={area}"
|
||||
)
|
||||
if len(planes) > limit:
|
||||
lines.append(f" ... {len(planes) - limit} more")
|
||||
return lines
|
||||
|
||||
|
||||
def _format_diff_text(result: dict[str, object], *, quiet: bool, verbose: bool) -> str:
|
||||
if not result.get("ok"):
|
||||
return _format_errors(result)
|
||||
diff = result.get("diff") if isinstance(result.get("diff"), dict) else {}
|
||||
fields = ("topologyChanged", "geometryChanged", "bboxChanged", "kindChanged")
|
||||
lines = [", ".join(f"{field}={diff.get(field)}" for field in fields)]
|
||||
if not quiet:
|
||||
lines.append(f"faceDelta={diff.get('faceCountDelta')} edgeDelta={diff.get('edgeCountDelta')}")
|
||||
if verbose:
|
||||
lines.append(f"sizeDelta={diff.get('sizeDelta')} centerDelta={diff.get('centerDelta')}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_frame_text(result: dict[str, object], *, quiet: bool, verbose: bool) -> str:
|
||||
if not result.get("ok"):
|
||||
return _format_errors(result)
|
||||
frame = result.get("frame") if isinstance(result.get("frame"), dict) else {}
|
||||
lines = [f"{result.get('copyText', result.get('cadPath'))} translation={frame.get('translation')}"]
|
||||
if verbose and not quiet:
|
||||
lines.append(f"localAxes={frame.get('localAxes')}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_measure_text(result: dict[str, object], *, quiet: bool, verbose: bool) -> str:
|
||||
if not result.get("ok"):
|
||||
return _format_errors(result)
|
||||
measurement = result.get("measurement") if isinstance(result.get("measurement"), dict) else {}
|
||||
lines = [
|
||||
f"axis={result.get('axis')} signed={measurement.get('signedDistance')} absolute={measurement.get('absoluteDistance')}"
|
||||
]
|
||||
if verbose and not quiet:
|
||||
lines.append(f"euclidean={measurement.get('euclideanDistance')} vector={measurement.get('vectorRelationship')}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_align_text(result: dict[str, object], *, quiet: bool, verbose: bool) -> str:
|
||||
if not result.get("ok"):
|
||||
return _format_errors(result)
|
||||
alignment = result.get("alignment") if isinstance(result.get("alignment"), dict) else {}
|
||||
lines = [f"mode={result.get('mode')} axis={result.get('axis')} translation={alignment.get('translationVector')}"]
|
||||
if verbose and not quiet:
|
||||
lines.append(f"transformTranslationDelta={alignment.get('transformTranslationDelta')}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_errors(result: dict[str, object]) -> str:
|
||||
errors = result.get("errors") if isinstance(result.get("errors"), list) else []
|
||||
messages = [str(error.get("message")) for error in errors if isinstance(error, dict) and error.get("message")]
|
||||
return "\n".join(messages) if messages else "error"
|
||||
|
||||
|
||||
def _read_refs_input(args: argparse.Namespace) -> tuple[str, str]:
|
||||
inspect = _inspect_api()
|
||||
raw_inputs = [str(value) for value in getattr(args, "inputs", ()) if str(value).strip()]
|
||||
if args.input_file:
|
||||
if len(raw_inputs) != 1:
|
||||
raise inspect.CadRefError("Pass exactly one STEP/CAD entry target with --input-file.")
|
||||
try:
|
||||
text = args.input_file.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
raise inspect.CadRefError(f"Failed to read input file: {args.input_file}") from exc
|
||||
entry_target = raw_inputs[0]
|
||||
else:
|
||||
if not raw_inputs:
|
||||
raise inspect.CadRefError("No STEP/CAD entry target provided.")
|
||||
entry_target = raw_inputs[0]
|
||||
text = "\n".join(raw_inputs[1:])
|
||||
|
||||
try:
|
||||
inspect.entry_target_from_target(entry_target)
|
||||
except inspect.CadRefError as exc:
|
||||
raise inspect.CadRefError(f"Invalid STEP/CAD entry target: {entry_target}") from exc
|
||||
|
||||
if not str(text).strip():
|
||||
return entry_target, ""
|
||||
|
||||
nonempty_lines = [line.strip() for line in str(text).splitlines() if line.strip()]
|
||||
for line in nonempty_lines:
|
||||
parsed_tokens = inspect.syntax.parse_cad_tokens(line)
|
||||
if len(parsed_tokens) != 1 or parsed_tokens[0].token.strip() != line:
|
||||
raise inspect.CadRefError(f"Invalid selector ref {line!r}; expected #o1.2, #f1, or #o1.2.f1.")
|
||||
return entry_target, "\n".join(nonempty_lines)
|
||||
|
||||
|
||||
def _safe_cad_path(target: str) -> str:
|
||||
inspect = _inspect_api()
|
||||
try:
|
||||
return inspect.cad_path_from_target(target)
|
||||
except inspect.CadRefError:
|
||||
return str(target)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
command_label = str(getattr(args, "command", "inspect") or "inspect")
|
||||
logger = CliLogger("scripts/inspect", verbose=bool(getattr(args, "verbose", False)))
|
||||
try:
|
||||
with logger.timed(command_label):
|
||||
return int(args.handler(args))
|
||||
except _inspect_api().CadRefError as exc:
|
||||
_emit_result(args, {"ok": False, "errors": [_inspect_api().cad_ref_error_payload(exc)]}, _format_errors)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,988 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from cadpy import cad_ref_syntax as syntax
|
||||
from cadpy.reporting import (
|
||||
EntryReportOptions,
|
||||
entry_facts_payload,
|
||||
entry_positioning_payload,
|
||||
entry_report_payload,
|
||||
entry_summary_payload,
|
||||
major_planes_payload,
|
||||
)
|
||||
from cadpy.selector_types import SelectorProfile
|
||||
from cadpy.step_targets import (
|
||||
CadRefError,
|
||||
ResolvedStepTarget,
|
||||
cad_path_from_target,
|
||||
cad_ref_error_payload,
|
||||
entry_target_from_target,
|
||||
resolve_step_target,
|
||||
step_path_from_target,
|
||||
)
|
||||
from cadpy import analysis
|
||||
from cadpy import lookup
|
||||
|
||||
REPO_ROOT = Path.cwd().resolve()
|
||||
|
||||
|
||||
@dataclass
|
||||
class EntryContext:
|
||||
cad_path: str
|
||||
kind: str
|
||||
source_path: Path
|
||||
step_path: Path | None
|
||||
manifest: dict[str, object]
|
||||
selector_index: lookup.SelectorIndex | None
|
||||
|
||||
|
||||
@dataclass
|
||||
class TargetSelection:
|
||||
context: EntryContext
|
||||
selector_type: str
|
||||
row: dict[str, object]
|
||||
normalized_selector: str
|
||||
display_selector: str
|
||||
copy_text: str
|
||||
|
||||
|
||||
EntryContextProvider = Callable[[str, SelectorProfile], EntryContext | None]
|
||||
|
||||
|
||||
def inspect_cad_refs(
|
||||
entry_target: str,
|
||||
refs_text: str = "",
|
||||
*,
|
||||
detail: bool = False,
|
||||
include_topology: bool = False,
|
||||
facts: bool = False,
|
||||
positioning: bool = False,
|
||||
planes: bool = False,
|
||||
plane_coordinate_tolerance: float = 1e-3,
|
||||
plane_min_area_ratio: float = 0.05,
|
||||
plane_limit: int = 12,
|
||||
context_provider: EntryContextProvider | None = None,
|
||||
) -> dict[str, object]:
|
||||
entry = entry_target_from_target(entry_target)
|
||||
parsed_tokens = _parse_entry_ref_tokens(entry.cad_path, refs_text)
|
||||
if not parsed_tokens:
|
||||
raise CadRefError(
|
||||
"No selector ref found. Expected refs like #o<path>, #o<path>.s<n>, #o<path>.f<n>, "
|
||||
"#o<path>.e<n>, #o<path>.v<n>, #m<n>, or #s<n>/#f<n>/#e<n>/#v<n> "
|
||||
"for single-occurrence entries."
|
||||
)
|
||||
|
||||
contexts: dict[str, EntryContext] = {}
|
||||
errors: list[dict[str, object]] = []
|
||||
token_results: list[dict[str, object]] = []
|
||||
refs_required_by_cad_path: dict[str, bool] = {}
|
||||
report_options = EntryReportOptions(
|
||||
facts=facts,
|
||||
positioning=positioning,
|
||||
planes=planes,
|
||||
topology=include_topology,
|
||||
plane_coordinate_tolerance=plane_coordinate_tolerance,
|
||||
plane_min_area_ratio=plane_min_area_ratio,
|
||||
plane_limit=plane_limit,
|
||||
)
|
||||
|
||||
for parsed in parsed_tokens:
|
||||
selectors = parsed.selectors or ()
|
||||
refs_required_by_cad_path[parsed.cad_path] = (
|
||||
refs_required_by_cad_path.get(parsed.cad_path, False)
|
||||
or bool(selectors)
|
||||
or report_options.refs_required
|
||||
)
|
||||
|
||||
for parsed in parsed_tokens:
|
||||
context = contexts.get(parsed.cad_path)
|
||||
if context is None:
|
||||
try:
|
||||
context = _load_entry_context(
|
||||
parsed.cad_path,
|
||||
profile=SelectorProfile.REFS if refs_required_by_cad_path.get(parsed.cad_path) else SelectorProfile.SUMMARY,
|
||||
context_provider=context_provider,
|
||||
)
|
||||
except CadRefError as exc:
|
||||
error = cad_ref_error_payload(exc)
|
||||
error.setdefault("line", parsed.line)
|
||||
error.setdefault("cadPath", parsed.cad_path)
|
||||
error.setdefault("selector", None)
|
||||
error.setdefault("kind", "input")
|
||||
errors.append(error)
|
||||
token_results.append(
|
||||
{
|
||||
"line": parsed.line,
|
||||
"token": parsed.token,
|
||||
"cadPath": parsed.cad_path,
|
||||
"stepPath": "",
|
||||
"summary": {},
|
||||
"selections": [],
|
||||
"warnings": [],
|
||||
}
|
||||
)
|
||||
continue
|
||||
contexts[parsed.cad_path] = context
|
||||
|
||||
token_result: dict[str, object] = {
|
||||
"line": parsed.line,
|
||||
"token": parsed.token,
|
||||
"cadPath": parsed.cad_path,
|
||||
"stepPath": _relative_to_repo(context.step_path) if context.step_path is not None else "",
|
||||
"stepHash": context.manifest.get("stepHash"),
|
||||
"summary": _entry_summary(context),
|
||||
"selections": [],
|
||||
"warnings": [],
|
||||
}
|
||||
report_payload = entry_report_payload(
|
||||
context.manifest,
|
||||
kind=context.kind,
|
||||
options=report_options,
|
||||
selector_index=context.selector_index,
|
||||
)
|
||||
token_result["summary"] = report_payload["summary"]
|
||||
if facts and "entryFacts" in report_payload:
|
||||
token_result["entryFacts"] = report_payload["entryFacts"]
|
||||
if planes and "planes" in report_payload:
|
||||
token_result["planes"] = report_payload["planes"]
|
||||
|
||||
if parsed.selectors:
|
||||
for raw_selector in parsed.selectors:
|
||||
selection, selection_error = _inspect_selector(
|
||||
parsed.cad_path,
|
||||
raw_selector,
|
||||
context,
|
||||
detail=detail,
|
||||
facts=facts,
|
||||
positioning=positioning,
|
||||
)
|
||||
token_result["selections"].append(selection)
|
||||
if selection_error is not None:
|
||||
errors.append(
|
||||
{
|
||||
"line": parsed.line,
|
||||
"cadPath": parsed.cad_path,
|
||||
"selector": raw_selector,
|
||||
**selection_error,
|
||||
}
|
||||
)
|
||||
else:
|
||||
if include_topology and "topology" in report_payload:
|
||||
token_result["topology"] = report_payload["topology"]
|
||||
if positioning and "entryPositioning" in report_payload:
|
||||
token_result["entryPositioning"] = report_payload["entryPositioning"]
|
||||
|
||||
token_results.append(token_result)
|
||||
|
||||
return {
|
||||
"ok": not errors,
|
||||
"tokens": token_results,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
|
||||
def _parse_entry_ref_tokens(cad_path: str, refs_text: str = "") -> list[syntax.ParsedToken]:
|
||||
text = str(refs_text or "").strip()
|
||||
if not text:
|
||||
return [
|
||||
syntax.ParsedToken(
|
||||
line=1,
|
||||
token=syntax.build_cad_token(cad_path),
|
||||
cad_path=cad_path,
|
||||
selectors=(),
|
||||
)
|
||||
]
|
||||
tokens: list[syntax.ParsedToken] = []
|
||||
for line_no, line in enumerate(text.splitlines(), start=1):
|
||||
normalized_line = line.strip()
|
||||
if not normalized_line:
|
||||
continue
|
||||
parsed_tokens = syntax.parse_cad_tokens(normalized_line)
|
||||
if len(parsed_tokens) != 1 or parsed_tokens[0].token.strip() != normalized_line:
|
||||
raise CadRefError(f"Invalid selector ref {normalized_line!r}; expected #o1.2, #f1, #m1, or #o1.2.f1.")
|
||||
parsed = parsed_tokens[0]
|
||||
tokens.append(
|
||||
syntax.ParsedToken(
|
||||
line=line_no,
|
||||
token=parsed.token,
|
||||
cad_path=cad_path,
|
||||
selectors=parsed.selectors,
|
||||
)
|
||||
)
|
||||
return tokens
|
||||
|
||||
|
||||
def _relative_to_repo(path: Path) -> str:
|
||||
resolved = path.resolve()
|
||||
try:
|
||||
return resolved.relative_to(REPO_ROOT).as_posix()
|
||||
except ValueError:
|
||||
return resolved.as_posix()
|
||||
|
||||
|
||||
def _load_entry_context(
|
||||
cad_path: str,
|
||||
*,
|
||||
profile: SelectorProfile,
|
||||
context_provider: EntryContextProvider | None = None,
|
||||
) -> EntryContext:
|
||||
if context_provider is not None:
|
||||
context = context_provider(cad_path, profile)
|
||||
if context is not None:
|
||||
return context
|
||||
|
||||
target = resolve_step_target(cad_path)
|
||||
return _load_step_context(target, profile=profile)
|
||||
|
||||
|
||||
def _load_step_context(
|
||||
target: ResolvedStepTarget,
|
||||
*,
|
||||
profile: SelectorProfile,
|
||||
) -> EntryContext:
|
||||
from cadpy.step_artifacts import ensure_step_topology_artifact
|
||||
|
||||
artifact = ensure_step_topology_artifact(
|
||||
target,
|
||||
require_selector=(profile != SelectorProfile.SUMMARY),
|
||||
)
|
||||
if artifact.selector_bundle is None:
|
||||
manifest = artifact.manifest
|
||||
selector_index = None
|
||||
else:
|
||||
manifest = artifact.selector_bundle.manifest
|
||||
selector_index = lookup.build_selector_index(manifest, buffers=artifact.selector_bundle.buffers)
|
||||
|
||||
resolved_kind = _entry_kind_from_manifest(
|
||||
manifest,
|
||||
fallback=_entry_kind_from_manifest(artifact.manifest, fallback=target.kind),
|
||||
)
|
||||
return EntryContext(
|
||||
cad_path=target.cad_path,
|
||||
kind=resolved_kind,
|
||||
source_path=target.source_path,
|
||||
step_path=target.step_path,
|
||||
manifest=manifest,
|
||||
selector_index=selector_index,
|
||||
)
|
||||
|
||||
|
||||
def _entry_kind_from_manifest(manifest: dict[str, object], *, fallback: str) -> str:
|
||||
entry_kind = str(manifest.get("entryKind") or "").strip().lower()
|
||||
if entry_kind in {"part", "assembly"}:
|
||||
return entry_kind
|
||||
assembly = manifest.get("assembly")
|
||||
if isinstance(assembly, dict):
|
||||
return "assembly"
|
||||
return fallback if fallback in {"part", "assembly"} else "part"
|
||||
|
||||
|
||||
def _entry_summary(context: EntryContext) -> dict[str, object]:
|
||||
return entry_summary_payload(
|
||||
context.manifest,
|
||||
kind=context.kind,
|
||||
selector_index=context.selector_index,
|
||||
)
|
||||
|
||||
|
||||
def _selection_label(selector_type: str, display_selector: str) -> str:
|
||||
noun = {
|
||||
"occurrence": "Occurrence",
|
||||
"shape": "Shape",
|
||||
"face": "Face",
|
||||
"edge": "Edge",
|
||||
"vertex": "Corner",
|
||||
}.get(selector_type, "Reference")
|
||||
return f"{noun} {display_selector}"
|
||||
|
||||
|
||||
def _selection_summary(selector_type: str, row: dict[str, object]) -> str:
|
||||
if selector_type == "occurrence":
|
||||
name = str(row.get("name") or row.get("sourceName") or "").strip()
|
||||
return name or str(row.get("id") or "")
|
||||
if selector_type == "shape":
|
||||
kind = str(row.get("kind") or "shape")
|
||||
volume = row.get("volume")
|
||||
area = row.get("area")
|
||||
if volume not in {None, ""}:
|
||||
return f"{kind} volume={volume}"
|
||||
if area not in {None, ""}:
|
||||
return f"{kind} area={area}"
|
||||
return kind
|
||||
if selector_type == "face":
|
||||
return f"{row.get('surfaceType')} area={row.get('area')}"
|
||||
if selector_type == "edge":
|
||||
return f"{row.get('curveType')} length={row.get('length')}"
|
||||
return f"corner edges={row.get('edgeCount')}"
|
||||
|
||||
|
||||
def _assembly_mate_rows(manifest: dict[str, object]) -> list[dict[str, object]]:
|
||||
rows = manifest.get("assemblyMates")
|
||||
if not isinstance(rows, list):
|
||||
return []
|
||||
return [dict(row) for row in rows if isinstance(row, dict)]
|
||||
|
||||
|
||||
def _assembly_mate_by_selector(context: EntryContext, raw_selector: str) -> dict[str, object] | None:
|
||||
normalized_selector = str(raw_selector or "").strip().replace("#", "", 1)
|
||||
if not normalized_selector:
|
||||
return None
|
||||
for row in _assembly_mate_rows(context.manifest):
|
||||
if str(row.get("id") or "").strip() == normalized_selector:
|
||||
return row
|
||||
return None
|
||||
|
||||
|
||||
def _looks_like_assembly_mate_selector(raw_selector: str) -> bool:
|
||||
selector = str(raw_selector or "").strip().replace("#", "", 1)
|
||||
return len(selector) > 1 and selector[0] == "m" and selector[1:].isdigit()
|
||||
|
||||
|
||||
def _assembly_mate_label(row: dict[str, object], selector: str) -> str:
|
||||
return (
|
||||
str(row.get("sourceLabel") or "").strip()
|
||||
or str(row.get("name") or "").strip()
|
||||
or str(row.get("label") or "").strip()
|
||||
or selector
|
||||
)
|
||||
|
||||
|
||||
def _assembly_mate_summary(row: dict[str, object]) -> str:
|
||||
relation = str(row.get("type") or row.get("relation") or "mate").strip() or "mate"
|
||||
fixed = str(row.get("fixed") or "").strip()
|
||||
moving = str(row.get("moving") or "").strip()
|
||||
endpoints = f"{fixed} -> {moving}" if fixed and moving else fixed or moving
|
||||
return " ".join(part for part in (relation, endpoints) if part)
|
||||
|
||||
|
||||
def _assembly_mate_detail(row: dict[str, object]) -> dict[str, object]:
|
||||
payload: dict[str, object] = {
|
||||
"id": row.get("id"),
|
||||
"label": row.get("label"),
|
||||
"sourceLabel": row.get("sourceLabel"),
|
||||
"type": row.get("type") or row.get("relation"),
|
||||
"fixed": row.get("fixed"),
|
||||
"moving": row.get("moving"),
|
||||
}
|
||||
for key in ("parameters", "fixedEndpoint", "movingEndpoint"):
|
||||
if key in row:
|
||||
payload[key] = row.get(key)
|
||||
return payload
|
||||
|
||||
|
||||
def _inspect_assembly_mate(
|
||||
cad_path: str,
|
||||
raw_selector: str,
|
||||
context: EntryContext,
|
||||
*,
|
||||
detail: bool,
|
||||
positioning: bool,
|
||||
) -> tuple[dict[str, object], dict[str, object] | None]:
|
||||
selector = str(raw_selector or "").strip().replace("#", "", 1)
|
||||
row = _assembly_mate_by_selector(context, selector)
|
||||
if row is None:
|
||||
return (
|
||||
{
|
||||
"status": "error",
|
||||
"selectorType": "mate" if _looks_like_assembly_mate_selector(selector) else "opaque",
|
||||
"normalizedSelector": selector,
|
||||
"displaySelector": selector,
|
||||
},
|
||||
{
|
||||
"kind": "selector",
|
||||
"message": f"Selector '{raw_selector}' did not resolve against {cad_path}.",
|
||||
},
|
||||
)
|
||||
|
||||
mate_id = str(row.get("id") or selector).strip() or selector
|
||||
selection: dict[str, object] = {
|
||||
"status": "resolved",
|
||||
"selectorType": "mate",
|
||||
"normalizedSelector": mate_id,
|
||||
"displaySelector": mate_id,
|
||||
"copyText": syntax.build_cad_token(cad_path, mate_id),
|
||||
"label": f"Mate {_assembly_mate_label(row, mate_id)}",
|
||||
"summary": _assembly_mate_summary(row),
|
||||
}
|
||||
if detail:
|
||||
selection["detail"] = _assembly_mate_detail(row)
|
||||
if positioning:
|
||||
selection["positioning"] = {
|
||||
"selectorType": "mate",
|
||||
"selector": mate_id,
|
||||
"fixedEndpoint": row.get("fixedEndpoint"),
|
||||
"movingEndpoint": row.get("movingEndpoint"),
|
||||
}
|
||||
return selection, None
|
||||
|
||||
|
||||
def _occurrence_detail(row: dict[str, object], selector_index: lookup.SelectorIndex) -> dict[str, object]:
|
||||
occurrence_id = str(row.get("id") or "").strip()
|
||||
child_rows = [
|
||||
child
|
||||
for child in selector_index.occurrences
|
||||
if str(child.get("parentId") or "").strip() == occurrence_id
|
||||
]
|
||||
descendant_ids: list[str] = []
|
||||
stack = list(reversed(child_rows))
|
||||
while stack:
|
||||
child = stack.pop()
|
||||
child_id = str(child.get("id") or "").strip()
|
||||
if child_id:
|
||||
descendant_ids.append(lookup.display_selector(child_id, selector_index))
|
||||
stack.extend(
|
||||
grandchild
|
||||
for grandchild in reversed(selector_index.occurrences)
|
||||
if str(grandchild.get("parentId") or "").strip() == child_id
|
||||
)
|
||||
return {
|
||||
"path": row.get("path"),
|
||||
"name": row.get("name"),
|
||||
"sourceName": row.get("sourceName"),
|
||||
"parentId": lookup.display_selector(str(row.get("parentId") or ""), selector_index),
|
||||
"childCount": len(child_rows),
|
||||
"descendantOccurrenceIds": descendant_ids,
|
||||
"transform": row.get("transform"),
|
||||
"bbox": row.get("bbox"),
|
||||
"shapeCount": row.get("shapeCount"),
|
||||
"faceCount": row.get("faceCount"),
|
||||
"edgeCount": row.get("edgeCount"),
|
||||
"vertexCount": row.get("vertexCount"),
|
||||
}
|
||||
|
||||
|
||||
def _shape_detail(row: dict[str, object], selector_index: lookup.SelectorIndex) -> dict[str, object]:
|
||||
return {
|
||||
"occurrenceId": lookup.display_selector(str(row.get("occurrenceId") or ""), selector_index),
|
||||
"kind": row.get("kind"),
|
||||
"bbox": row.get("bbox"),
|
||||
"center": row.get("center"),
|
||||
"area": row.get("area"),
|
||||
"volume": row.get("volume"),
|
||||
"faceCount": row.get("faceCount"),
|
||||
"edgeCount": row.get("edgeCount"),
|
||||
"vertexCount": row.get("vertexCount"),
|
||||
}
|
||||
|
||||
|
||||
def _face_detail(row: dict[str, object], selector_index: lookup.SelectorIndex) -> dict[str, object]:
|
||||
adjacent_edges = [
|
||||
lookup.display_selector(selector, selector_index)
|
||||
for selector in lookup.face_adjacent_edge_selectors(row, selector_index)
|
||||
]
|
||||
return {
|
||||
"occurrenceId": lookup.display_selector(str(row.get("occurrenceId") or ""), selector_index),
|
||||
"shapeId": lookup.display_selector(str(row.get("shapeId") or ""), selector_index),
|
||||
"surfaceType": row.get("surfaceType"),
|
||||
"area": row.get("area"),
|
||||
"center": row.get("center"),
|
||||
"normal": row.get("normal"),
|
||||
"bbox": row.get("bbox"),
|
||||
"params": row.get("params"),
|
||||
"adjacentEdgeSelectors": adjacent_edges,
|
||||
}
|
||||
|
||||
|
||||
def _edge_detail(row: dict[str, object], selector_index: lookup.SelectorIndex) -> dict[str, object]:
|
||||
adjacent_faces = [
|
||||
lookup.display_selector(selector, selector_index)
|
||||
for selector in lookup.edge_adjacent_face_selectors(row, selector_index)
|
||||
]
|
||||
adjacent_vertices = [
|
||||
lookup.display_selector(selector, selector_index)
|
||||
for selector in lookup.edge_adjacent_vertex_selectors(row, selector_index)
|
||||
]
|
||||
return {
|
||||
"occurrenceId": lookup.display_selector(str(row.get("occurrenceId") or ""), selector_index),
|
||||
"shapeId": lookup.display_selector(str(row.get("shapeId") or ""), selector_index),
|
||||
"curveType": row.get("curveType"),
|
||||
"length": row.get("length"),
|
||||
"center": row.get("center"),
|
||||
"bbox": row.get("bbox"),
|
||||
"params": row.get("params"),
|
||||
"adjacentFaceSelectors": adjacent_faces,
|
||||
"adjacentVertexSelectors": adjacent_vertices,
|
||||
}
|
||||
|
||||
|
||||
def _vertex_detail(row: dict[str, object], selector_index: lookup.SelectorIndex) -> dict[str, object]:
|
||||
adjacent_edges = [
|
||||
lookup.display_selector(selector, selector_index)
|
||||
for selector in lookup.vertex_adjacent_edge_selectors(row, selector_index)
|
||||
]
|
||||
adjacent_faces = [
|
||||
lookup.display_selector(selector, selector_index)
|
||||
for selector in lookup.vertex_adjacent_face_selectors(row, selector_index)
|
||||
]
|
||||
return {
|
||||
"occurrenceId": lookup.display_selector(str(row.get("occurrenceId") or ""), selector_index),
|
||||
"shapeId": lookup.display_selector(str(row.get("shapeId") or ""), selector_index),
|
||||
"center": row.get("center"),
|
||||
"bbox": row.get("bbox"),
|
||||
"adjacentEdgeSelectors": adjacent_edges,
|
||||
"adjacentFaceSelectors": adjacent_faces,
|
||||
}
|
||||
|
||||
|
||||
def _inspect_selector(
|
||||
cad_path: str,
|
||||
raw_selector: str,
|
||||
context: EntryContext,
|
||||
*,
|
||||
detail: bool,
|
||||
facts: bool,
|
||||
positioning: bool,
|
||||
) -> tuple[dict[str, object], dict[str, object] | None]:
|
||||
parsed_selector = syntax.parse_selector(raw_selector)
|
||||
if parsed_selector is None:
|
||||
return (
|
||||
{
|
||||
"status": "error",
|
||||
"selectorType": "unknown",
|
||||
"normalizedSelector": raw_selector,
|
||||
"displaySelector": raw_selector,
|
||||
},
|
||||
{
|
||||
"kind": "selector",
|
||||
"message": f"Unsupported selector '{raw_selector}'.",
|
||||
},
|
||||
)
|
||||
|
||||
if parsed_selector.selector_type == "opaque":
|
||||
mate_selection, mate_error = _inspect_assembly_mate(
|
||||
cad_path,
|
||||
raw_selector,
|
||||
context,
|
||||
detail=detail,
|
||||
positioning=positioning,
|
||||
)
|
||||
if mate_error is None or _looks_like_assembly_mate_selector(raw_selector):
|
||||
return mate_selection, mate_error
|
||||
|
||||
if context.selector_index is None:
|
||||
raise CadRefError(f"Selector index unavailable for {cad_path}")
|
||||
|
||||
lookup_result = lookup.lookup_selector(raw_selector, context.selector_index)
|
||||
normalized_selector = lookup.canonicalize_selector(raw_selector, context.selector_index) or parsed_selector.canonical
|
||||
display_selector = lookup.display_selector(normalized_selector, context.selector_index)
|
||||
if lookup_result is None:
|
||||
return (
|
||||
{
|
||||
"status": "error",
|
||||
"selectorType": parsed_selector.selector_type,
|
||||
"normalizedSelector": normalized_selector,
|
||||
"displaySelector": display_selector,
|
||||
},
|
||||
{
|
||||
"kind": "selector",
|
||||
"message": f"Selector '{raw_selector}' did not resolve against {cad_path}.",
|
||||
},
|
||||
)
|
||||
|
||||
selector_type, row = lookup_result
|
||||
selection: dict[str, object] = {
|
||||
"status": "resolved",
|
||||
"selectorType": selector_type,
|
||||
"normalizedSelector": normalized_selector,
|
||||
"displaySelector": display_selector,
|
||||
"copyText": syntax.build_cad_token(cad_path, display_selector),
|
||||
"label": _selection_label(selector_type, display_selector),
|
||||
"summary": _selection_summary(selector_type, row),
|
||||
}
|
||||
if detail:
|
||||
if selector_type == "occurrence":
|
||||
selection["detail"] = _occurrence_detail(row, context.selector_index)
|
||||
elif selector_type == "shape":
|
||||
selection["detail"] = _shape_detail(row, context.selector_index)
|
||||
elif selector_type == "face":
|
||||
selection["detail"] = _face_detail(row, context.selector_index)
|
||||
elif selector_type == "edge":
|
||||
selection["detail"] = _edge_detail(row, context.selector_index)
|
||||
elif selector_type == "vertex":
|
||||
selection["detail"] = _vertex_detail(row, context.selector_index)
|
||||
if facts:
|
||||
selection["geometryFacts"] = analysis.geometry_facts_for_row(selector_type, row, context.selector_index)
|
||||
if positioning:
|
||||
selection["positioning"] = analysis.positioning_facts_for_row(selector_type, row, context.selector_index)
|
||||
return selection, None
|
||||
|
||||
|
||||
def _entry_facts(context: EntryContext) -> dict[str, object]:
|
||||
return entry_facts_payload(context.manifest, kind=context.kind, selector_index=context.selector_index)
|
||||
|
||||
|
||||
def _entry_positioning(context: EntryContext) -> dict[str, object]:
|
||||
return entry_positioning_payload(context.manifest, kind=context.kind, selector_index=context.selector_index)
|
||||
|
||||
|
||||
def load_entry_context_for_target(
|
||||
target: str,
|
||||
*,
|
||||
profile: SelectorProfile = SelectorProfile.REFS,
|
||||
context_provider: EntryContextProvider | None = None,
|
||||
) -> EntryContext:
|
||||
return _load_entry_context(cad_path_from_target(target), profile=profile, context_provider=context_provider)
|
||||
|
||||
|
||||
def _parse_single_target_token(entry_target_text: str, selector: str = "") -> syntax.ParsedToken:
|
||||
entry_target = entry_target_from_target(entry_target_text)
|
||||
selector_text = str(selector or "").strip()
|
||||
if not selector_text:
|
||||
selectors: tuple[str, ...] = ()
|
||||
else:
|
||||
parsed_tokens = syntax.parse_cad_tokens(selector_text)
|
||||
if len(parsed_tokens) != 1 or parsed_tokens[0].token.strip() != selector_text:
|
||||
raise CadRefError(f"Expected exactly one selector ref such as #o1.2.f1; got {selector!r}.")
|
||||
selectors = parsed_tokens[0].selectors
|
||||
return syntax.ParsedToken(
|
||||
line=1,
|
||||
token=syntax.build_cad_token(entry_target.cad_path, ",".join(selectors)),
|
||||
cad_path=entry_target.cad_path,
|
||||
selectors=selectors,
|
||||
)
|
||||
|
||||
|
||||
def _default_root_occurrence(index: lookup.SelectorIndex) -> dict[str, object]:
|
||||
roots = [
|
||||
row
|
||||
for row in index.occurrences
|
||||
if row.get("parentId") in {None, ""}
|
||||
]
|
||||
if len(roots) == 1:
|
||||
return roots[0]
|
||||
if len(index.occurrences) == 1:
|
||||
return index.occurrences[0]
|
||||
raise CadRefError("Target has no selector and does not have exactly one root occurrence.")
|
||||
|
||||
|
||||
def resolve_target_selection(
|
||||
entry_target: str,
|
||||
selector: str = "",
|
||||
*,
|
||||
default_root_occurrence: bool = False,
|
||||
context_provider: EntryContextProvider | None = None,
|
||||
) -> TargetSelection:
|
||||
parsed = _parse_single_target_token(entry_target, selector)
|
||||
context = _load_entry_context(parsed.cad_path, profile=SelectorProfile.REFS, context_provider=context_provider)
|
||||
if context.selector_index is None:
|
||||
raise CadRefError(f"Selector index unavailable for {parsed.cad_path}")
|
||||
|
||||
selectors = parsed.selectors
|
||||
if not selectors:
|
||||
if not default_root_occurrence:
|
||||
raise CadRefError(f"Expected a selector ref for target {entry_target!r}.")
|
||||
row = _default_root_occurrence(context.selector_index)
|
||||
normalized_selector = str(row.get("id") or "")
|
||||
display_selector = lookup.display_selector(normalized_selector, context.selector_index)
|
||||
return TargetSelection(
|
||||
context=context,
|
||||
selector_type="occurrence",
|
||||
row=row,
|
||||
normalized_selector=normalized_selector,
|
||||
display_selector=display_selector,
|
||||
copy_text=syntax.build_cad_token(parsed.cad_path, display_selector),
|
||||
)
|
||||
if len(selectors) != 1:
|
||||
raise CadRefError(f"Expected exactly one selector in target {entry_target!r}.")
|
||||
|
||||
raw_selector = selectors[0]
|
||||
lookup_result = lookup.lookup_selector(raw_selector, context.selector_index)
|
||||
if lookup_result is None:
|
||||
raise CadRefError(f"Selector '{raw_selector}' did not resolve against {parsed.cad_path}.")
|
||||
selector_type, row = lookup_result
|
||||
normalized_selector = lookup.canonicalize_selector(raw_selector, context.selector_index) or raw_selector
|
||||
display_selector = lookup.display_selector(normalized_selector, context.selector_index)
|
||||
return TargetSelection(
|
||||
context=context,
|
||||
selector_type=selector_type,
|
||||
row=row,
|
||||
normalized_selector=normalized_selector,
|
||||
display_selector=display_selector,
|
||||
copy_text=syntax.build_cad_token(parsed.cad_path, display_selector),
|
||||
)
|
||||
|
||||
|
||||
def _selection_positioning_payload(selection: TargetSelection) -> dict[str, object]:
|
||||
return analysis.positioning_facts_for_row(
|
||||
selection.selector_type,
|
||||
selection.row,
|
||||
selection.context.selector_index,
|
||||
)
|
||||
|
||||
|
||||
def _selection_result_payload(selection: TargetSelection) -> dict[str, object]:
|
||||
return {
|
||||
"cadPath": selection.context.cad_path,
|
||||
"stepPath": _relative_to_repo(selection.context.step_path) if selection.context.step_path is not None else "",
|
||||
"selectorType": selection.selector_type,
|
||||
"normalizedSelector": selection.normalized_selector,
|
||||
"displaySelector": selection.display_selector,
|
||||
"copyText": selection.copy_text,
|
||||
}
|
||||
|
||||
|
||||
def _axis_or_infer(axis: str | None, *positioning: dict[str, object]) -> str:
|
||||
if axis is not None and str(axis).strip():
|
||||
normalized = str(axis).strip().lower()
|
||||
if normalized not in analysis.AXIS_INDEX:
|
||||
raise CadRefError(f"Axis must be one of x, y, z; got {axis!r}.")
|
||||
return normalized
|
||||
inferred = analysis.infer_positioning_axis(*positioning)
|
||||
if inferred is None:
|
||||
raise CadRefError("Could not infer a shared axis; pass --axis x, --axis y, or --axis z.")
|
||||
return inferred
|
||||
|
||||
|
||||
def _primary_vector(positioning: dict[str, object]) -> object:
|
||||
for key in ("normal", "direction", "axisVector"):
|
||||
value = positioning.get(key)
|
||||
if value is not None and value != "":
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def inspect_target_frame(
|
||||
entry_target: str,
|
||||
selector: str = "",
|
||||
*,
|
||||
context_provider: EntryContextProvider | None = None,
|
||||
) -> dict[str, object]:
|
||||
selection = resolve_target_selection(entry_target, selector, default_root_occurrence=True, context_provider=context_provider)
|
||||
if selection.selector_type == "occurrence":
|
||||
occurrence_row = selection.row
|
||||
else:
|
||||
occurrence_id = str(selection.row.get("occurrenceId") or "")
|
||||
occurrence_row = selection.context.selector_index.occurrence_by_id.get(occurrence_id) if selection.context.selector_index else None
|
||||
if occurrence_row is None:
|
||||
raise CadRefError(f"Target {entry_target!r} does not resolve to an occurrence-backed selector.")
|
||||
frame = analysis.positioning_facts_for_row("occurrence", occurrence_row, selection.context.selector_index)
|
||||
result = {
|
||||
"ok": True,
|
||||
**_selection_result_payload(selection),
|
||||
"frame": frame,
|
||||
}
|
||||
if selection.selector_type != "occurrence":
|
||||
result["selectionPositioning"] = _selection_positioning_payload(selection)
|
||||
return result
|
||||
|
||||
|
||||
def measure_targets(
|
||||
entry_target: str,
|
||||
from_selector: str,
|
||||
to_selector: str,
|
||||
*,
|
||||
axis: str | None = None,
|
||||
context_provider: EntryContextProvider | None = None,
|
||||
) -> dict[str, object]:
|
||||
from_selection = resolve_target_selection(entry_target, from_selector, default_root_occurrence=True, context_provider=context_provider)
|
||||
to_selection = resolve_target_selection(entry_target, to_selector, default_root_occurrence=True, context_provider=context_provider)
|
||||
from_positioning = _selection_positioning_payload(from_selection)
|
||||
to_positioning = _selection_positioning_payload(to_selection)
|
||||
resolved_axis = _axis_or_infer(axis, from_positioning, to_positioning)
|
||||
from_coordinate = analysis.positioning_coordinate(from_positioning, resolved_axis)
|
||||
to_coordinate = analysis.positioning_coordinate(to_positioning, resolved_axis)
|
||||
if from_coordinate is None or to_coordinate is None:
|
||||
raise CadRefError(f"Could not compute {resolved_axis}-axis coordinates for both targets.")
|
||||
|
||||
from_point = analysis.positioning_point(from_positioning)
|
||||
to_point = analysis.positioning_point(to_positioning)
|
||||
euclidean_distance = None
|
||||
if from_point is not None and to_point is not None:
|
||||
euclidean_distance = sum(
|
||||
(float(to_point[index]) - float(from_point[index])) ** 2
|
||||
for index in range(3)
|
||||
) ** 0.5
|
||||
|
||||
vector_relationship = analysis.vector_relationship(
|
||||
_primary_vector(from_positioning),
|
||||
_primary_vector(to_positioning),
|
||||
)
|
||||
signed_distance = float(to_coordinate[0] - from_coordinate[0])
|
||||
return {
|
||||
"ok": True,
|
||||
"axis": resolved_axis,
|
||||
"from": {
|
||||
**_selection_result_payload(from_selection),
|
||||
"positioning": from_positioning,
|
||||
"coordinate": from_coordinate[0],
|
||||
"coordinateSource": from_coordinate[1],
|
||||
},
|
||||
"to": {
|
||||
**_selection_result_payload(to_selection),
|
||||
"positioning": to_positioning,
|
||||
"coordinate": to_coordinate[0],
|
||||
"coordinateSource": to_coordinate[1],
|
||||
},
|
||||
"measurement": {
|
||||
"signedDistance": signed_distance,
|
||||
"absoluteDistance": abs(signed_distance),
|
||||
"euclideanDistance": euclidean_distance,
|
||||
"vectorRelationship": vector_relationship,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def align_targets(
|
||||
entry_target: str,
|
||||
moving_selector: str,
|
||||
target_selector: str,
|
||||
*,
|
||||
mode: str = "flush",
|
||||
offset: float = 0.0,
|
||||
axis: str | None = None,
|
||||
context_provider: EntryContextProvider | None = None,
|
||||
) -> dict[str, object]:
|
||||
moving_selection = resolve_target_selection(
|
||||
entry_target,
|
||||
moving_selector,
|
||||
default_root_occurrence=True,
|
||||
context_provider=context_provider,
|
||||
)
|
||||
target_selection = resolve_target_selection(
|
||||
entry_target,
|
||||
target_selector,
|
||||
default_root_occurrence=True,
|
||||
context_provider=context_provider,
|
||||
)
|
||||
moving_positioning = _selection_positioning_payload(moving_selection)
|
||||
target_positioning = _selection_positioning_payload(target_selection)
|
||||
normalized_mode = str(mode or "flush").strip().lower()
|
||||
if normalized_mode not in {"flush", "center"}:
|
||||
raise CadRefError(f"Unsupported alignment mode {mode!r}; expected 'flush' or 'center'.")
|
||||
|
||||
if normalized_mode == "center":
|
||||
moving_point = analysis.positioning_point(moving_positioning)
|
||||
target_point = analysis.positioning_point(target_positioning)
|
||||
if moving_point is None or target_point is None:
|
||||
raise CadRefError("Center alignment requires both targets to expose a point, center, origin, or translation.")
|
||||
translation_vector = [float(target_point[index]) - float(moving_point[index]) for index in range(3)]
|
||||
resolved_axis = None
|
||||
if axis is not None and str(axis).strip():
|
||||
resolved_axis = _axis_or_infer(axis)
|
||||
axis_index = analysis.AXIS_INDEX[resolved_axis]
|
||||
translation_vector = [
|
||||
value if index == axis_index else 0.0
|
||||
for index, value in enumerate(translation_vector)
|
||||
]
|
||||
else:
|
||||
resolved_axis = _axis_or_infer(axis, moving_positioning, target_positioning)
|
||||
moving_coordinate = analysis.positioning_coordinate(moving_positioning, resolved_axis)
|
||||
target_coordinate = analysis.positioning_coordinate(target_positioning, resolved_axis)
|
||||
if moving_coordinate is None or target_coordinate is None:
|
||||
raise CadRefError(f"Could not compute {resolved_axis}-axis coordinates for both alignment targets.")
|
||||
offset_sign = 1
|
||||
target_alignment = target_positioning.get("axisAlignment")
|
||||
if isinstance(target_alignment, dict) and target_alignment.get("axis") == resolved_axis:
|
||||
offset_sign = int(target_alignment.get("sign") or 1)
|
||||
distance = float(target_coordinate[0] + (float(offset) * offset_sign) - moving_coordinate[0])
|
||||
translation_vector = [0.0, 0.0, 0.0]
|
||||
translation_vector[analysis.AXIS_INDEX[resolved_axis]] = distance
|
||||
|
||||
vector_relationship = analysis.vector_relationship(
|
||||
_primary_vector(moving_positioning),
|
||||
_primary_vector(target_positioning),
|
||||
)
|
||||
rotation_required = None
|
||||
if vector_relationship is not None:
|
||||
rotation_required = vector_relationship.get("relation") != "opposed"
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"mode": normalized_mode,
|
||||
"axis": resolved_axis,
|
||||
"offset": float(offset),
|
||||
"moving": {
|
||||
**_selection_result_payload(moving_selection),
|
||||
"positioning": moving_positioning,
|
||||
},
|
||||
"target": {
|
||||
**_selection_result_payload(target_selection),
|
||||
"positioning": target_positioning,
|
||||
},
|
||||
"alignment": {
|
||||
"translationVector": translation_vector,
|
||||
"transformTranslationDelta": {
|
||||
"3": translation_vector[0],
|
||||
"7": translation_vector[1],
|
||||
"11": translation_vector[2],
|
||||
},
|
||||
"vectorRelationship": vector_relationship,
|
||||
"rotationRequired": rotation_required,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def diff_entry_targets(
|
||||
left_target: str,
|
||||
right_target: str,
|
||||
*,
|
||||
planes: bool = False,
|
||||
plane_coordinate_tolerance: float = 1e-3,
|
||||
plane_min_area_ratio: float = 0.05,
|
||||
plane_limit: int = 12,
|
||||
context_provider: EntryContextProvider | None = None,
|
||||
) -> dict[str, object]:
|
||||
left_context = load_entry_context_for_target(
|
||||
left_target,
|
||||
profile=SelectorProfile.REFS,
|
||||
context_provider=context_provider,
|
||||
)
|
||||
right_context = load_entry_context_for_target(
|
||||
right_target,
|
||||
profile=SelectorProfile.REFS,
|
||||
context_provider=context_provider,
|
||||
)
|
||||
|
||||
diff_payload = analysis.selector_manifest_diff(left_context.manifest, right_context.manifest)
|
||||
bbox_left = left_context.manifest.get("bbox")
|
||||
bbox_right = right_context.manifest.get("bbox")
|
||||
size_left = analysis.bbox_size(bbox_left)
|
||||
size_right = analysis.bbox_size(bbox_right)
|
||||
center_left = analysis.bbox_center(bbox_left)
|
||||
center_right = analysis.bbox_center(bbox_right)
|
||||
|
||||
result: dict[str, object] = {
|
||||
"ok": True,
|
||||
"left": {
|
||||
"cadPath": left_context.cad_path,
|
||||
"kind": left_context.kind,
|
||||
"stepPath": _relative_to_repo(left_context.step_path) if left_context.step_path is not None else "",
|
||||
"summary": _entry_summary(left_context),
|
||||
"entryFacts": _entry_facts(left_context),
|
||||
},
|
||||
"right": {
|
||||
"cadPath": right_context.cad_path,
|
||||
"kind": right_context.kind,
|
||||
"stepPath": _relative_to_repo(right_context.step_path) if right_context.step_path is not None else "",
|
||||
"summary": _entry_summary(right_context),
|
||||
"entryFacts": _entry_facts(right_context),
|
||||
},
|
||||
"diff": {
|
||||
"kindChanged": left_context.kind != right_context.kind,
|
||||
**diff_payload,
|
||||
"sizeDelta": (
|
||||
[float(size_right[index] - size_left[index]) for index in range(3)]
|
||||
if size_left is not None and size_right is not None
|
||||
else None
|
||||
),
|
||||
"centerDelta": (
|
||||
[float(center_right[index] - center_left[index]) for index in range(3)]
|
||||
if center_left is not None and center_right is not None
|
||||
else None
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
if planes and left_context.selector_index is not None and right_context.selector_index is not None:
|
||||
report_options = EntryReportOptions(
|
||||
planes=True,
|
||||
plane_coordinate_tolerance=plane_coordinate_tolerance,
|
||||
plane_min_area_ratio=plane_min_area_ratio,
|
||||
plane_limit=plane_limit,
|
||||
)
|
||||
result["diff"]["leftMajorPlanes"] = major_planes_payload(left_context.selector_index, report_options)
|
||||
result["diff"]["rightMajorPlanes"] = major_planes_payload(right_context.selector_index, report_options)
|
||||
return result
|
||||
@@ -0,0 +1,23 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "cadpy"
|
||||
version = "0.3.9"
|
||||
description = "Shared STEP/GLB topology artifact generation runtime for CAD skills."
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"build123d",
|
||||
"cadquery-ocp",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
cadpy-step-artifact = "cadpy.step_artifact:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
include = ["cadpy*"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
cadpy = ["py.typed"]
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Shared CAD artifact generation runtime."""
|
||||
|
||||
__all__ = [
|
||||
"AssemblyHelper",
|
||||
"MateRelation",
|
||||
"MateTarget",
|
||||
"ensure_step_glb_artifact",
|
||||
"label_text",
|
||||
"label_shape",
|
||||
"target",
|
||||
"validate_step_glb_artifact",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
if name in {"ensure_step_glb_artifact", "validate_step_glb_artifact"}:
|
||||
from cadpy.api import ensure_step_glb_artifact, validate_step_glb_artifact
|
||||
|
||||
return {
|
||||
"ensure_step_glb_artifact": ensure_step_glb_artifact,
|
||||
"validate_step_glb_artifact": validate_step_glb_artifact,
|
||||
}[name]
|
||||
if name in {"AssemblyHelper", "MateRelation", "MateTarget", "label_shape", "label_text", "target"}:
|
||||
from cadpy.assembly import AssemblyHelper, MateRelation, MateTarget, label_shape, label_text, target
|
||||
|
||||
return {
|
||||
"AssemblyHelper": AssemblyHelper,
|
||||
"MateRelation": MateRelation,
|
||||
"MateTarget": MateTarget,
|
||||
"label_text": label_text,
|
||||
"label_shape": label_shape,
|
||||
"target": target,
|
||||
}[name]
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -0,0 +1,683 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from . import lookup
|
||||
|
||||
|
||||
AXIS_NAMES = ("x", "y", "z")
|
||||
AXIS_INDEX = {name: index for index, name in enumerate(AXIS_NAMES)}
|
||||
AXIS_ALIGNMENT_THRESHOLD = 0.985
|
||||
|
||||
|
||||
def _float_triplet(value: object) -> tuple[float, float, float] | None:
|
||||
if not isinstance(value, (list, tuple)) or len(value) != 3:
|
||||
return None
|
||||
try:
|
||||
return (float(value[0]), float(value[1]), float(value[2]))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _normalize(vector: tuple[float, float, float] | None) -> tuple[float, float, float] | None:
|
||||
if vector is None:
|
||||
return None
|
||||
length = math.sqrt(sum(component * component for component in vector))
|
||||
if length <= 1e-12:
|
||||
return None
|
||||
return tuple(component / length for component in vector)
|
||||
|
||||
|
||||
def dominant_axis(
|
||||
vector: object,
|
||||
*,
|
||||
aligned_threshold: float = AXIS_ALIGNMENT_THRESHOLD,
|
||||
) -> dict[str, object] | None:
|
||||
normalized = _normalize(_float_triplet(vector))
|
||||
if normalized is None:
|
||||
return None
|
||||
magnitudes = [abs(component) for component in normalized]
|
||||
axis_index = max(range(3), key=lambda index: magnitudes[index])
|
||||
component = normalized[axis_index]
|
||||
return {
|
||||
"axis": AXIS_NAMES[axis_index],
|
||||
"index": axis_index,
|
||||
"sign": 1 if component >= 0.0 else -1,
|
||||
"component": component,
|
||||
"magnitude": magnitudes[axis_index],
|
||||
"aligned": magnitudes[axis_index] >= aligned_threshold,
|
||||
}
|
||||
|
||||
|
||||
def bbox_size(bbox: object) -> list[float] | None:
|
||||
if not isinstance(bbox, dict):
|
||||
return None
|
||||
min_point = _float_triplet(bbox.get("min"))
|
||||
max_point = _float_triplet(bbox.get("max"))
|
||||
if min_point is None or max_point is None:
|
||||
return None
|
||||
return [
|
||||
float(max_point[0] - min_point[0]),
|
||||
float(max_point[1] - min_point[1]),
|
||||
float(max_point[2] - min_point[2]),
|
||||
]
|
||||
|
||||
|
||||
def bbox_center(bbox: object) -> list[float] | None:
|
||||
if not isinstance(bbox, dict):
|
||||
return None
|
||||
min_point = _float_triplet(bbox.get("min"))
|
||||
max_point = _float_triplet(bbox.get("max"))
|
||||
if min_point is None or max_point is None:
|
||||
return None
|
||||
return [
|
||||
float((min_point[0] + max_point[0]) * 0.5),
|
||||
float((min_point[1] + max_point[1]) * 0.5),
|
||||
float((min_point[2] + max_point[2]) * 0.5),
|
||||
]
|
||||
|
||||
|
||||
def bbox_diag(bbox: object) -> float | None:
|
||||
size = bbox_size(bbox)
|
||||
if size is None:
|
||||
return None
|
||||
return math.sqrt(sum(component * component for component in size))
|
||||
|
||||
|
||||
def bbox_facts(bbox: object) -> dict[str, object]:
|
||||
facts: dict[str, object] = {}
|
||||
if not isinstance(bbox, dict):
|
||||
return facts
|
||||
size = bbox_size(bbox)
|
||||
center = bbox_center(bbox)
|
||||
diag = bbox_diag(bbox)
|
||||
if size is not None:
|
||||
facts["size"] = size
|
||||
extent_axis = dominant_axis(size, aligned_threshold=0.0)
|
||||
if extent_axis is not None:
|
||||
facts["extentAxis"] = extent_axis["axis"]
|
||||
if center is not None:
|
||||
facts["center"] = center
|
||||
if diag is not None:
|
||||
facts["diag"] = diag
|
||||
return facts
|
||||
|
||||
|
||||
def _dot(left: tuple[float, float, float], right: tuple[float, float, float]) -> float:
|
||||
return (left[0] * right[0]) + (left[1] * right[1]) + (left[2] * right[2])
|
||||
|
||||
|
||||
def _transform_tuple(value: object) -> tuple[float, ...] | None:
|
||||
if not isinstance(value, (list, tuple)) or len(value) != 16:
|
||||
return None
|
||||
try:
|
||||
transform = tuple(float(item) for item in value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not all(math.isfinite(item) for item in transform):
|
||||
return None
|
||||
return transform
|
||||
|
||||
|
||||
def _axis_alignment_payload(vector: object) -> dict[str, object] | None:
|
||||
alignment = dominant_axis(vector)
|
||||
if alignment is None:
|
||||
return None
|
||||
return {
|
||||
"axis": alignment["axis"],
|
||||
"sign": alignment["sign"],
|
||||
"aligned": alignment["aligned"],
|
||||
"magnitude": alignment["magnitude"],
|
||||
}
|
||||
|
||||
|
||||
def transform_frame_facts(transform: object) -> dict[str, object] | None:
|
||||
matrix = _transform_tuple(transform)
|
||||
if matrix is None:
|
||||
return None
|
||||
local_x = _normalize((matrix[0], matrix[4], matrix[8]))
|
||||
local_y = _normalize((matrix[1], matrix[5], matrix[9]))
|
||||
local_z = _normalize((matrix[2], matrix[6], matrix[10]))
|
||||
return {
|
||||
"translation": [matrix[3], matrix[7], matrix[11]],
|
||||
"localAxes": {
|
||||
"x": list(local_x) if local_x is not None else [matrix[0], matrix[4], matrix[8]],
|
||||
"y": list(local_y) if local_y is not None else [matrix[1], matrix[5], matrix[9]],
|
||||
"z": list(local_z) if local_z is not None else [matrix[2], matrix[6], matrix[10]],
|
||||
},
|
||||
"transform": list(matrix),
|
||||
}
|
||||
|
||||
|
||||
def positioning_facts_for_row(
|
||||
selector_type: str,
|
||||
row: dict[str, object],
|
||||
index: lookup.SelectorIndex | None = None,
|
||||
) -> dict[str, object]:
|
||||
bbox = row.get("bbox")
|
||||
center = _float_triplet(row.get("center")) or _float_triplet(bbox_center(bbox))
|
||||
facts: dict[str, object] = {
|
||||
"selectorType": selector_type,
|
||||
}
|
||||
if index is not None and row.get("id"):
|
||||
facts["selector"] = lookup.display_selector(str(row["id"]), index)
|
||||
if isinstance(bbox, dict):
|
||||
facts["bbox"] = bbox
|
||||
bbox_fact_payload = bbox_facts(bbox)
|
||||
if bbox_fact_payload:
|
||||
facts["bboxFacts"] = bbox_fact_payload
|
||||
if center is not None:
|
||||
facts["center"] = list(center)
|
||||
|
||||
if selector_type == "occurrence":
|
||||
facts["kind"] = "frame"
|
||||
name = str(row.get("name") or row.get("sourceName") or "").strip()
|
||||
if name:
|
||||
facts["name"] = name
|
||||
frame = transform_frame_facts(row.get("transform"))
|
||||
if frame is not None:
|
||||
facts.update(frame)
|
||||
return facts
|
||||
|
||||
if selector_type == "shape":
|
||||
facts["kind"] = str(row.get("kind") or "shape")
|
||||
if index is not None and row.get("occurrenceId"):
|
||||
facts["occurrenceId"] = lookup.display_selector(str(row["occurrenceId"]), index)
|
||||
return facts
|
||||
|
||||
if selector_type == "vertex":
|
||||
facts["kind"] = "point"
|
||||
if center is not None:
|
||||
facts["point"] = list(center)
|
||||
if index is not None and row.get("occurrenceId"):
|
||||
facts["occurrenceId"] = lookup.display_selector(str(row["occurrenceId"]), index)
|
||||
return facts
|
||||
|
||||
params = row.get("params") if isinstance(row.get("params"), dict) else {}
|
||||
if index is not None and row.get("occurrenceId"):
|
||||
facts["occurrenceId"] = lookup.display_selector(str(row["occurrenceId"]), index)
|
||||
if index is not None and row.get("shapeId"):
|
||||
facts["shapeId"] = lookup.display_selector(str(row["shapeId"]), index)
|
||||
|
||||
if selector_type == "face":
|
||||
surface_type = str(row.get("surfaceType") or "").lower()
|
||||
facts["kind"] = surface_type or "face"
|
||||
if row.get("area") not in {None, ""}:
|
||||
facts["area"] = float(row["area"])
|
||||
|
||||
normal = _normalize(
|
||||
_float_triplet(row.get("normal"))
|
||||
or _float_triplet(params.get("normal"))
|
||||
or _float_triplet(params.get("axis"))
|
||||
)
|
||||
point = _float_triplet(params.get("origin")) or center
|
||||
if point is not None:
|
||||
facts["origin"] = list(point)
|
||||
if normal is not None:
|
||||
facts["normal"] = list(normal)
|
||||
alignment = _axis_alignment_payload(normal)
|
||||
if alignment is not None:
|
||||
facts["axisAlignment"] = alignment
|
||||
facts["normalAxis"] = {
|
||||
"axis": alignment["axis"],
|
||||
"sign": alignment["sign"],
|
||||
"aligned": alignment["aligned"],
|
||||
}
|
||||
if bool(alignment["aligned"]) and point is not None:
|
||||
axis = str(alignment["axis"])
|
||||
facts["axis"] = axis
|
||||
facts["coordinate"] = float(point[AXIS_INDEX[axis]])
|
||||
if point is not None:
|
||||
facts["planeOffset"] = _dot(normal, point)
|
||||
|
||||
radius = params.get("radius")
|
||||
if radius not in {None, ""}:
|
||||
facts["radius"] = float(radius)
|
||||
if surface_type in {"cylinder", "cone", "torus"}:
|
||||
axis_vector = _normalize(_float_triplet(params.get("axis")))
|
||||
if axis_vector is not None:
|
||||
facts["axisVector"] = list(axis_vector)
|
||||
facts["axisAlignment"] = _axis_alignment_payload(axis_vector)
|
||||
if surface_type == "sphere":
|
||||
sphere_center = _float_triplet(params.get("center")) or center
|
||||
if sphere_center is not None:
|
||||
facts["center"] = list(sphere_center)
|
||||
return facts
|
||||
|
||||
curve_type = str(row.get("curveType") or "").lower()
|
||||
facts["kind"] = curve_type or "edge"
|
||||
if row.get("length") not in {None, ""}:
|
||||
facts["length"] = float(row["length"])
|
||||
if curve_type == "line":
|
||||
origin = _float_triplet(params.get("origin")) or center
|
||||
direction = _normalize(_float_triplet(params.get("direction")))
|
||||
if origin is not None:
|
||||
facts["origin"] = list(origin)
|
||||
if direction is not None:
|
||||
facts["direction"] = list(direction)
|
||||
alignment = _axis_alignment_payload(direction)
|
||||
if alignment is not None:
|
||||
facts["axisAlignment"] = alignment
|
||||
if bool(alignment["aligned"]) and center is not None:
|
||||
axis = str(alignment["axis"])
|
||||
facts["axis"] = axis
|
||||
facts["coordinate"] = float(center[AXIS_INDEX[axis]])
|
||||
elif curve_type in {"circle", "ellipse"}:
|
||||
circle_center = _float_triplet(params.get("center")) or center
|
||||
axis_vector = _normalize(_float_triplet(params.get("axis")))
|
||||
if circle_center is not None:
|
||||
facts["center"] = list(circle_center)
|
||||
if axis_vector is not None:
|
||||
facts["axisVector"] = list(axis_vector)
|
||||
facts["axisAlignment"] = _axis_alignment_payload(axis_vector)
|
||||
for radius_key in ("radius", "majorRadius", "minorRadius"):
|
||||
if params.get(radius_key) not in {None, ""}:
|
||||
facts[radius_key] = float(params[radius_key])
|
||||
return facts
|
||||
|
||||
|
||||
def positioning_point(facts: dict[str, object]) -> list[float] | None:
|
||||
for key in ("point", "origin", "center", "translation"):
|
||||
point = _float_triplet(facts.get(key))
|
||||
if point is not None:
|
||||
return list(point)
|
||||
bbox = facts.get("bbox")
|
||||
center = bbox_center(bbox)
|
||||
return center if center is not None else None
|
||||
|
||||
|
||||
def positioning_coordinate(
|
||||
facts: dict[str, object],
|
||||
axis: str,
|
||||
) -> tuple[float, str] | None:
|
||||
normalized_axis = str(axis or "").strip().lower()
|
||||
if normalized_axis not in AXIS_INDEX:
|
||||
return None
|
||||
if str(facts.get("axis") or "") == normalized_axis and facts.get("coordinate") not in {None, ""}:
|
||||
return float(facts["coordinate"]), "coordinate"
|
||||
point = positioning_point(facts)
|
||||
if point is not None:
|
||||
return float(point[AXIS_INDEX[normalized_axis]]), "point"
|
||||
return None
|
||||
|
||||
|
||||
def infer_positioning_axis(*fact_payloads: dict[str, object]) -> str | None:
|
||||
axes: list[str] = []
|
||||
for facts in fact_payloads:
|
||||
alignment = facts.get("axisAlignment")
|
||||
if not isinstance(alignment, dict) or not bool(alignment.get("aligned")):
|
||||
continue
|
||||
axis = str(alignment.get("axis") or "")
|
||||
if axis in AXIS_INDEX:
|
||||
axes.append(axis)
|
||||
if axes and all(axis == axes[0] for axis in axes):
|
||||
return axes[0]
|
||||
for facts in fact_payloads:
|
||||
axis = str(facts.get("axis") or "")
|
||||
if axis in AXIS_INDEX:
|
||||
return axis
|
||||
return None
|
||||
|
||||
|
||||
def vector_relationship(
|
||||
left: object,
|
||||
right: object,
|
||||
*,
|
||||
aligned_threshold: float = AXIS_ALIGNMENT_THRESHOLD,
|
||||
) -> dict[str, object] | None:
|
||||
left_vector = _normalize(_float_triplet(left))
|
||||
right_vector = _normalize(_float_triplet(right))
|
||||
if left_vector is None or right_vector is None:
|
||||
return None
|
||||
dot = _dot(left_vector, right_vector)
|
||||
abs_dot = abs(dot)
|
||||
if dot <= -aligned_threshold:
|
||||
relation = "opposed"
|
||||
elif dot >= aligned_threshold:
|
||||
relation = "parallel"
|
||||
elif abs_dot <= 1.0 - aligned_threshold:
|
||||
relation = "perpendicular"
|
||||
else:
|
||||
relation = "angled"
|
||||
return {
|
||||
"relation": relation,
|
||||
"dot": dot,
|
||||
"aligned": abs_dot >= aligned_threshold,
|
||||
}
|
||||
|
||||
|
||||
def geometry_facts_for_row(
|
||||
selector_type: str,
|
||||
row: dict[str, object],
|
||||
index: lookup.SelectorIndex | None = None,
|
||||
) -> dict[str, object]:
|
||||
facts = bbox_facts(row.get("bbox"))
|
||||
if selector_type in {"occurrence", "shape"}:
|
||||
return facts
|
||||
|
||||
params = row.get("params") if isinstance(row.get("params"), dict) else {}
|
||||
center = _float_triplet(row.get("center"))
|
||||
if center is not None:
|
||||
facts.setdefault("center", list(center))
|
||||
|
||||
if selector_type == "vertex":
|
||||
if row.get("edgeCount") not in {None, ""}:
|
||||
facts["edgeCount"] = int(row["edgeCount"])
|
||||
if index is not None and row.get("id"):
|
||||
facts["selector"] = lookup.display_selector(str(row["id"]), index)
|
||||
return facts
|
||||
|
||||
axis_vector = _float_triplet(params.get("axis"))
|
||||
direction_vector = _float_triplet(params.get("direction"))
|
||||
normal_vector = _float_triplet(row.get("normal")) or _float_triplet(params.get("normal")) or axis_vector
|
||||
|
||||
if selector_type == "face":
|
||||
surface_type = str(row.get("surfaceType") or "")
|
||||
if surface_type:
|
||||
facts["surfaceType"] = surface_type
|
||||
if row.get("area") not in {None, ""}:
|
||||
facts["area"] = float(row["area"])
|
||||
normal_axis = dominant_axis(normal_vector)
|
||||
if normal_axis is not None:
|
||||
facts["normalAxis"] = {
|
||||
"axis": normal_axis["axis"],
|
||||
"sign": normal_axis["sign"],
|
||||
"aligned": normal_axis["aligned"],
|
||||
}
|
||||
if center is not None and bool(normal_axis["aligned"]):
|
||||
facts["planeCoordinate"] = center[int(normal_axis["index"])]
|
||||
radius = params.get("radius")
|
||||
if radius not in {None, ""}:
|
||||
facts["radius"] = float(radius)
|
||||
if surface_type == "plane" and axis_vector is not None:
|
||||
facts["axis"] = list(axis_vector)
|
||||
if index is not None and row.get("id"):
|
||||
facts["selector"] = lookup.display_selector(str(row["id"]), index)
|
||||
return facts
|
||||
|
||||
curve_type = str(row.get("curveType") or "")
|
||||
if curve_type:
|
||||
facts["curveType"] = curve_type
|
||||
if row.get("length") not in {None, ""}:
|
||||
facts["length"] = float(row["length"])
|
||||
direction_axis = dominant_axis(direction_vector or axis_vector or bbox_size(row.get("bbox")))
|
||||
if direction_axis is not None:
|
||||
facts["directionAxis"] = {
|
||||
"axis": direction_axis["axis"],
|
||||
"sign": direction_axis["sign"],
|
||||
"aligned": direction_axis["aligned"],
|
||||
}
|
||||
radius = params.get("radius")
|
||||
if radius not in {None, ""}:
|
||||
facts["radius"] = float(radius)
|
||||
if index is not None and row.get("id"):
|
||||
facts["selector"] = lookup.display_selector(str(row["id"]), index)
|
||||
return facts
|
||||
|
||||
|
||||
def _merge_bboxes(boxes: list[dict[str, object]]) -> dict[str, object]:
|
||||
min_x = min(float(box["min"][0]) for box in boxes)
|
||||
min_y = min(float(box["min"][1]) for box in boxes)
|
||||
min_z = min(float(box["min"][2]) for box in boxes)
|
||||
max_x = max(float(box["max"][0]) for box in boxes)
|
||||
max_y = max(float(box["max"][1]) for box in boxes)
|
||||
max_z = max(float(box["max"][2]) for box in boxes)
|
||||
return {
|
||||
"min": [min_x, min_y, min_z],
|
||||
"max": [max_x, max_y, max_z],
|
||||
}
|
||||
|
||||
|
||||
def major_planar_face_groups(
|
||||
index: lookup.SelectorIndex,
|
||||
*,
|
||||
coordinate_tolerance: float = 1e-3,
|
||||
min_area_ratio: float = 0.05,
|
||||
limit: int = 12,
|
||||
) -> list[dict[str, object]]:
|
||||
planar_rows = [
|
||||
row for row in index.faces if str(row.get("surfaceType") or "").lower() == "plane"
|
||||
]
|
||||
total_planar_area = sum(float(row.get("area") or 0.0) for row in planar_rows)
|
||||
grouped: dict[tuple[str, int], dict[str, object]] = {}
|
||||
|
||||
for row in planar_rows:
|
||||
facts = geometry_facts_for_row("face", row, index)
|
||||
normal_axis = facts.get("normalAxis")
|
||||
plane_coordinate = facts.get("planeCoordinate")
|
||||
if not isinstance(normal_axis, dict) or plane_coordinate in {None, ""}:
|
||||
continue
|
||||
axis = str(normal_axis.get("axis") or "")
|
||||
if axis not in AXIS_INDEX:
|
||||
continue
|
||||
coordinate = float(plane_coordinate)
|
||||
bucket = int(round(coordinate / coordinate_tolerance)) if coordinate_tolerance > 0 else 0
|
||||
key = (axis, bucket)
|
||||
bbox = row.get("bbox")
|
||||
if not isinstance(bbox, dict):
|
||||
continue
|
||||
group = grouped.get(key)
|
||||
if group is None:
|
||||
group = {
|
||||
"axis": axis,
|
||||
"coordinate": 0.0,
|
||||
"normalSign": int(normal_axis.get("sign") or 1),
|
||||
"faceCount": 0,
|
||||
"totalArea": 0.0,
|
||||
"bboxParts": [],
|
||||
"selectors": [],
|
||||
}
|
||||
grouped[key] = group
|
||||
area = float(row.get("area") or 0.0)
|
||||
group["coordinate"] = float(group["coordinate"]) + (coordinate * max(area, 1e-9))
|
||||
group["faceCount"] = int(group["faceCount"]) + 1
|
||||
group["totalArea"] = float(group["totalArea"]) + area
|
||||
group["bboxParts"].append(bbox)
|
||||
selector = lookup.display_selector(str(row.get("id") or ""), index)
|
||||
if selector:
|
||||
group["selectors"].append(selector)
|
||||
|
||||
result: list[dict[str, object]] = []
|
||||
for group in grouped.values():
|
||||
total_area = float(group["totalArea"])
|
||||
if total_planar_area > 0.0 and total_area / total_planar_area < min_area_ratio:
|
||||
continue
|
||||
weighted_coordinate = float(group["coordinate"]) / max(total_area, 1e-9)
|
||||
merged_bbox = _merge_bboxes(list(group["bboxParts"]))
|
||||
result.append(
|
||||
{
|
||||
"axis": group["axis"],
|
||||
"coordinate": weighted_coordinate,
|
||||
"normalSign": group["normalSign"],
|
||||
"faceCount": group["faceCount"],
|
||||
"totalArea": total_area,
|
||||
"bbox": merged_bbox,
|
||||
"selectors": sorted(set(str(selector) for selector in group["selectors"] if selector)),
|
||||
}
|
||||
)
|
||||
|
||||
result.sort(key=lambda item: (-float(item["totalArea"]), str(item["axis"]), float(item["coordinate"])))
|
||||
return result[: max(int(limit), 0)]
|
||||
|
||||
|
||||
def _table_rows(manifest: dict[str, Any], table_name: str, columns_name: str) -> list[dict[str, Any]]:
|
||||
columns = manifest.get("tables", {}).get(columns_name)
|
||||
rows = manifest.get(table_name)
|
||||
if not isinstance(columns, list) or not isinstance(rows, list):
|
||||
return []
|
||||
materialized: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
if not isinstance(row, list):
|
||||
continue
|
||||
materialized.append({str(columns[index]): row[index] for index in range(min(len(columns), len(row)))})
|
||||
return materialized
|
||||
|
||||
|
||||
def _stable_hash(payload: object) -> str:
|
||||
encoded = json.dumps(payload, separators=(",", ":"), sort_keys=True, ensure_ascii=True)
|
||||
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def selector_manifest_diff(
|
||||
old_manifest: dict[str, Any] | None,
|
||||
new_manifest: dict[str, Any],
|
||||
) -> dict[str, object]:
|
||||
if old_manifest is None:
|
||||
return {
|
||||
"hasPrevious": False,
|
||||
"topologyChanged": False,
|
||||
"geometryChanged": False,
|
||||
"bboxChanged": False,
|
||||
"countDelta": {},
|
||||
}
|
||||
|
||||
old_faces = _table_rows(old_manifest, "faces", "faceColumns")
|
||||
new_faces = _table_rows(new_manifest, "faces", "faceColumns")
|
||||
old_edges = _table_rows(old_manifest, "edges", "edgeColumns")
|
||||
new_edges = _table_rows(new_manifest, "edges", "edgeColumns")
|
||||
old_vertices = _table_rows(old_manifest, "vertices", "vertexColumns")
|
||||
new_vertices = _table_rows(new_manifest, "vertices", "vertexColumns")
|
||||
|
||||
old_topology = {
|
||||
"occurrences": [row.get("id") for row in _table_rows(old_manifest, "occurrences", "occurrenceColumns")],
|
||||
"shapes": [row.get("id") for row in _table_rows(old_manifest, "shapes", "shapeColumns")],
|
||||
"faces": [row.get("id") for row in old_faces],
|
||||
"edges": [row.get("id") for row in old_edges],
|
||||
"vertices": [row.get("id") for row in old_vertices],
|
||||
"faceCount": old_manifest.get("stats", {}).get("faceCount"),
|
||||
"edgeCount": old_manifest.get("stats", {}).get("edgeCount"),
|
||||
"vertexCount": old_manifest.get("stats", {}).get("vertexCount"),
|
||||
}
|
||||
new_topology = {
|
||||
"occurrences": [row.get("id") for row in _table_rows(new_manifest, "occurrences", "occurrenceColumns")],
|
||||
"shapes": [row.get("id") for row in _table_rows(new_manifest, "shapes", "shapeColumns")],
|
||||
"faces": [row.get("id") for row in new_faces],
|
||||
"edges": [row.get("id") for row in new_edges],
|
||||
"vertices": [row.get("id") for row in new_vertices],
|
||||
"faceCount": new_manifest.get("stats", {}).get("faceCount"),
|
||||
"edgeCount": new_manifest.get("stats", {}).get("edgeCount"),
|
||||
"vertexCount": new_manifest.get("stats", {}).get("vertexCount"),
|
||||
}
|
||||
old_geometry = {
|
||||
"bbox": old_manifest.get("bbox"),
|
||||
"faces": [
|
||||
{
|
||||
"id": row.get("id"),
|
||||
"surfaceType": row.get("surfaceType"),
|
||||
"center": row.get("center"),
|
||||
"normal": row.get("normal"),
|
||||
"bbox": row.get("bbox"),
|
||||
"area": row.get("area"),
|
||||
}
|
||||
for row in old_faces
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": row.get("id"),
|
||||
"curveType": row.get("curveType"),
|
||||
"center": row.get("center"),
|
||||
"bbox": row.get("bbox"),
|
||||
"length": row.get("length"),
|
||||
}
|
||||
for row in old_edges
|
||||
],
|
||||
"vertices": [
|
||||
{
|
||||
"id": row.get("id"),
|
||||
"center": row.get("center"),
|
||||
"bbox": row.get("bbox"),
|
||||
}
|
||||
for row in old_vertices
|
||||
],
|
||||
}
|
||||
new_geometry = {
|
||||
"bbox": new_manifest.get("bbox"),
|
||||
"faces": [
|
||||
{
|
||||
"id": row.get("id"),
|
||||
"surfaceType": row.get("surfaceType"),
|
||||
"center": row.get("center"),
|
||||
"normal": row.get("normal"),
|
||||
"bbox": row.get("bbox"),
|
||||
"area": row.get("area"),
|
||||
}
|
||||
for row in new_faces
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": row.get("id"),
|
||||
"curveType": row.get("curveType"),
|
||||
"center": row.get("center"),
|
||||
"bbox": row.get("bbox"),
|
||||
"length": row.get("length"),
|
||||
}
|
||||
for row in new_edges
|
||||
],
|
||||
"vertices": [
|
||||
{
|
||||
"id": row.get("id"),
|
||||
"center": row.get("center"),
|
||||
"bbox": row.get("bbox"),
|
||||
}
|
||||
for row in new_vertices
|
||||
],
|
||||
}
|
||||
|
||||
count_delta = {
|
||||
"faceCount": int(new_manifest.get("stats", {}).get("faceCount") or 0)
|
||||
- int(old_manifest.get("stats", {}).get("faceCount") or 0),
|
||||
"edgeCount": int(new_manifest.get("stats", {}).get("edgeCount") or 0)
|
||||
- int(old_manifest.get("stats", {}).get("edgeCount") or 0),
|
||||
"vertexCount": int(new_manifest.get("stats", {}).get("vertexCount") or 0)
|
||||
- int(old_manifest.get("stats", {}).get("vertexCount") or 0),
|
||||
"shapeCount": int(new_manifest.get("stats", {}).get("shapeCount") or 0)
|
||||
- int(old_manifest.get("stats", {}).get("shapeCount") or 0),
|
||||
}
|
||||
|
||||
return {
|
||||
"hasPrevious": True,
|
||||
"topologyChanged": _stable_hash(old_topology) != _stable_hash(new_topology),
|
||||
"geometryChanged": _stable_hash(old_geometry) != _stable_hash(new_geometry),
|
||||
"bboxChanged": old_manifest.get("bbox") != new_manifest.get("bbox"),
|
||||
"countDelta": count_delta,
|
||||
}
|
||||
|
||||
|
||||
def view_name_for_axis(axis: str, sign: int) -> str:
|
||||
normalized_sign = 1 if sign >= 0 else -1
|
||||
if axis == "x":
|
||||
return "right" if normalized_sign > 0 else "left"
|
||||
if axis == "y":
|
||||
return "top" if normalized_sign > 0 else "bottom"
|
||||
return "front" if normalized_sign > 0 else "back"
|
||||
|
||||
|
||||
def aligned_view_name_for_facts(
|
||||
selector_type: str,
|
||||
facts: dict[str, object],
|
||||
) -> str | None:
|
||||
if selector_type == "face":
|
||||
normal_axis = facts.get("normalAxis")
|
||||
if isinstance(normal_axis, dict):
|
||||
axis = str(normal_axis.get("axis") or "")
|
||||
sign = int(normal_axis.get("sign") or 1)
|
||||
if axis in AXIS_INDEX:
|
||||
return view_name_for_axis(axis, sign)
|
||||
return None
|
||||
|
||||
if selector_type != "edge":
|
||||
return None
|
||||
direction_axis = facts.get("directionAxis")
|
||||
if not isinstance(direction_axis, dict):
|
||||
return None
|
||||
axis = str(direction_axis.get("axis") or "")
|
||||
if axis == "x":
|
||||
return "front"
|
||||
if axis == "y":
|
||||
return "front"
|
||||
if axis == "z":
|
||||
return "top"
|
||||
return None
|
||||
@@ -0,0 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from cadpy.step_artifacts import ensure_step_topology_artifact as ensure_step_glb_artifact
|
||||
from cadpy.step_targets import validate_step_topology_artifact as validate_step_glb_artifact
|
||||
|
||||
__all__ = ["ensure_step_glb_artifact", "validate_step_glb_artifact"]
|
||||
@@ -0,0 +1,436 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MateTarget:
|
||||
"""A named native build123d joint on a part-like shape."""
|
||||
|
||||
part: Any
|
||||
frame: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MateRelation:
|
||||
"""A source-level placement relationship recorded by AssemblyHelper."""
|
||||
|
||||
label: str
|
||||
relation: str
|
||||
fixed: str
|
||||
moving: str
|
||||
parameters: Mapping[str, Any] = field(default_factory=dict)
|
||||
fixed_endpoint: Mapping[str, Any] = field(default_factory=dict)
|
||||
moving_endpoint: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def label_text(name: str, *details: object) -> str:
|
||||
"""Build a compact STEP-friendly label such as m3_standoff:front_left."""
|
||||
|
||||
tokens = [_normalize_label_token(name, field_name="name")]
|
||||
tokens.extend(_normalize_label_token(detail, field_name="detail") for detail in details)
|
||||
return ":".join(tokens)
|
||||
|
||||
|
||||
def label_shape(
|
||||
shape: Any,
|
||||
name: str,
|
||||
*details: object,
|
||||
color: Any | None = None,
|
||||
) -> Any:
|
||||
"""Assign native build123d label/color metadata and return the shape."""
|
||||
|
||||
shape.label = label_text(name, *details)
|
||||
if color is not None:
|
||||
shape.color = color
|
||||
return shape
|
||||
|
||||
|
||||
def mate_label(name: str) -> str:
|
||||
"""Return the native joint label used for named mate frames."""
|
||||
|
||||
return label_text(name)
|
||||
|
||||
|
||||
def target(part: Any, frame: str) -> MateTarget:
|
||||
return MateTarget(part=part, frame=label_text(frame))
|
||||
|
||||
|
||||
def assembly_mate_payload(relations: Sequence[MateRelation]) -> list[dict[str, Any]]:
|
||||
"""Return JSON-safe source mate metadata for STEP topology artifacts."""
|
||||
|
||||
payload: list[dict[str, Any]] = []
|
||||
for index, relation in enumerate(relations, start=1):
|
||||
fallback_label = f"mate_{index}"
|
||||
label = str(relation.label or fallback_label).strip() or fallback_label
|
||||
mate_id = f"m{index}"
|
||||
relation_type = str(relation.relation or "mate").strip() or "mate"
|
||||
fixed = str(relation.fixed or "fixed").strip() or "fixed"
|
||||
moving = str(relation.moving or "moving").strip() or "moving"
|
||||
payload.append(
|
||||
{
|
||||
"id": mate_id,
|
||||
"label": mate_id,
|
||||
"sourceLabel": label,
|
||||
"type": relation_type,
|
||||
"relation": relation_type,
|
||||
"fixed": fixed,
|
||||
"moving": moving,
|
||||
"parameters": _json_safe(relation.parameters),
|
||||
}
|
||||
)
|
||||
fixed_endpoint = _json_safe(relation.fixed_endpoint)
|
||||
moving_endpoint = _json_safe(relation.moving_endpoint)
|
||||
if fixed_endpoint:
|
||||
payload[-1]["fixedEndpoint"] = fixed_endpoint
|
||||
if moving_endpoint:
|
||||
payload[-1]["movingEndpoint"] = moving_endpoint
|
||||
return payload
|
||||
|
||||
|
||||
class AssemblyHelper:
|
||||
"""Small semantic wrapper around native build123d joints and compounds.
|
||||
|
||||
Generated CAD scripts should express named part-local frames and source
|
||||
relationships here; this helper realizes those relationships with native
|
||||
build123d Joint objects and returns a labeled Compound assembly.
|
||||
"""
|
||||
|
||||
def __init__(self, name: str) -> None:
|
||||
self.label = label_text(name)
|
||||
self.children: list[Any] = []
|
||||
self.relations: list[MateRelation] = []
|
||||
|
||||
def add(
|
||||
self,
|
||||
shape: Any,
|
||||
name: str,
|
||||
*details: object,
|
||||
color: Any | None = None,
|
||||
) -> Any:
|
||||
label_shape(shape, name, *details, color=color)
|
||||
self.children.append(shape)
|
||||
return shape
|
||||
|
||||
def add_module(self, name: str, children: Sequence[Any], *details: object, color: Any | None = None) -> Any:
|
||||
module = self.compound(children, label=label_text(name, *details))
|
||||
if color is not None:
|
||||
module.color = color
|
||||
self.children.append(module)
|
||||
return module
|
||||
|
||||
def feature(
|
||||
self,
|
||||
shape: Any,
|
||||
name: str,
|
||||
*details: object,
|
||||
color: Any | None = None,
|
||||
) -> Any:
|
||||
return label_shape(shape, name, *details, color=color)
|
||||
|
||||
def datum(
|
||||
self,
|
||||
shape: Any,
|
||||
name: str,
|
||||
*details: object,
|
||||
color: Any | None = None,
|
||||
) -> Any:
|
||||
return label_shape(shape, name, *details, color=color)
|
||||
|
||||
def rigid_frame(self, part: Any, name: str, location: Any) -> MateTarget:
|
||||
return add_rigid_frame(part, name, location)
|
||||
|
||||
def revolute_frame(self, part: Any, name: str, axis: Any, **joint_options: Any) -> MateTarget:
|
||||
return add_axis_frame(part, name, axis, joint_type="RevoluteJoint", **joint_options)
|
||||
|
||||
def linear_frame(self, part: Any, name: str, axis: Any, **joint_options: Any) -> MateTarget:
|
||||
return add_axis_frame(part, name, axis, joint_type="LinearJoint", **joint_options)
|
||||
|
||||
def cylindrical_frame(self, part: Any, name: str, axis: Any, **joint_options: Any) -> MateTarget:
|
||||
return add_axis_frame(part, name, axis, joint_type="CylindricalJoint", **joint_options)
|
||||
|
||||
def ball_frame(self, part: Any, name: str, location: Any, **joint_options: Any) -> MateTarget:
|
||||
return add_joint_frame(
|
||||
part,
|
||||
name,
|
||||
joint_type="BallJoint",
|
||||
joint_location=location,
|
||||
**joint_options,
|
||||
)
|
||||
|
||||
def connect(
|
||||
self,
|
||||
fixed: MateTarget | tuple[Any, str],
|
||||
moving: MateTarget | tuple[Any, str],
|
||||
*,
|
||||
relation: str = "rigid",
|
||||
label: str | None = None,
|
||||
**connect_options: Any,
|
||||
) -> MateRelation:
|
||||
fixed_target = _normalize_target(fixed)
|
||||
moving_target = _normalize_target(moving)
|
||||
fixed_joint_label, fixed_joint = _joint_for_target(fixed_target)
|
||||
moving_joint_label, moving_joint = _joint_for_target(moving_target)
|
||||
options = {key: value for key, value in connect_options.items() if value is not None}
|
||||
fixed_joint.connect_to(moving_joint, **options)
|
||||
relation_record = MateRelation(
|
||||
label=label_text(label) if label is not None else label_text(relation, fixed_joint_label, moving_joint_label),
|
||||
relation=relation,
|
||||
fixed=fixed_joint_label,
|
||||
moving=moving_joint_label,
|
||||
parameters=options,
|
||||
fixed_endpoint=_mate_endpoint_payload(fixed_target, fixed_joint_label, fixed_joint),
|
||||
moving_endpoint=_mate_endpoint_payload(moving_target, moving_joint_label, moving_joint),
|
||||
)
|
||||
self.relations.append(relation_record)
|
||||
return relation_record
|
||||
|
||||
def face_to_face(
|
||||
self,
|
||||
fixed: MateTarget | tuple[Any, str],
|
||||
moving: MateTarget | tuple[Any, str],
|
||||
*,
|
||||
offset: float | Sequence[float] | Any | None = None,
|
||||
label: str | None = None,
|
||||
) -> MateRelation:
|
||||
fixed_target = _normalize_target(fixed)
|
||||
if offset is not None:
|
||||
fixed_target = offset_target(fixed_target, offset, label=label)
|
||||
return self.connect(
|
||||
fixed_target,
|
||||
moving,
|
||||
relation="face_to_face",
|
||||
label=label,
|
||||
)
|
||||
|
||||
def coaxial(
|
||||
self,
|
||||
fixed: MateTarget | tuple[Any, str],
|
||||
moving: MateTarget | tuple[Any, str],
|
||||
*,
|
||||
offset: float | Sequence[float] | Any | None = None,
|
||||
label: str | None = None,
|
||||
) -> MateRelation:
|
||||
fixed_target = _normalize_target(fixed)
|
||||
if offset is not None:
|
||||
fixed_target = offset_target(fixed_target, offset, label=label)
|
||||
return self.connect(
|
||||
fixed_target,
|
||||
moving,
|
||||
relation="coaxial",
|
||||
label=label,
|
||||
)
|
||||
|
||||
def revolute(
|
||||
self,
|
||||
fixed: MateTarget | tuple[Any, str],
|
||||
moving: MateTarget | tuple[Any, str],
|
||||
*,
|
||||
angle: float | None = None,
|
||||
label: str | None = None,
|
||||
) -> MateRelation:
|
||||
return self.connect(fixed, moving, relation="revolute", label=label, angle=angle)
|
||||
|
||||
def linear(
|
||||
self,
|
||||
fixed: MateTarget | tuple[Any, str],
|
||||
moving: MateTarget | tuple[Any, str],
|
||||
*,
|
||||
position: float | None = None,
|
||||
label: str | None = None,
|
||||
) -> MateRelation:
|
||||
return self.connect(fixed, moving, relation="linear", label=label, position=position)
|
||||
|
||||
def compound(self, children: Sequence[Any] | None = None, *, label: str | None = None) -> Any:
|
||||
build123d = _import_build123d()
|
||||
compound = build123d.Compound(
|
||||
label=label or self.label,
|
||||
children=list(children if children is not None else self.children),
|
||||
)
|
||||
if children is None:
|
||||
assembly_mates = assembly_mate_payload(self.relations)
|
||||
if assembly_mates:
|
||||
compound.assembly_mates = assembly_mates
|
||||
return compound
|
||||
|
||||
def build(self) -> Any:
|
||||
return self.compound()
|
||||
|
||||
|
||||
def add_rigid_frame(part: Any, name: str, location: Any) -> MateTarget:
|
||||
return add_joint_frame(
|
||||
part,
|
||||
name,
|
||||
joint_type="RigidJoint",
|
||||
joint_location=location,
|
||||
)
|
||||
|
||||
|
||||
def add_axis_frame(part: Any, name: str, axis: Any, *, joint_type: str, **joint_options: Any) -> MateTarget:
|
||||
return add_joint_frame(
|
||||
part,
|
||||
name,
|
||||
joint_type=joint_type,
|
||||
axis=axis,
|
||||
**joint_options,
|
||||
)
|
||||
|
||||
|
||||
def add_joint_frame(part: Any, name: str, *, joint_type: str, **joint_options: Any) -> MateTarget:
|
||||
build123d = _import_build123d()
|
||||
joint_cls = getattr(build123d, joint_type)
|
||||
label = mate_label(name)
|
||||
joint_cls(
|
||||
label=label,
|
||||
to_part=part,
|
||||
**{key: value for key, value in joint_options.items() if value is not None},
|
||||
)
|
||||
return MateTarget(part=part, frame=label)
|
||||
|
||||
|
||||
def offset_target(
|
||||
fixed: MateTarget | tuple[Any, str],
|
||||
offset: float | Sequence[float] | Any,
|
||||
*,
|
||||
label: str | None = None,
|
||||
) -> MateTarget:
|
||||
fixed_target = _normalize_target(fixed)
|
||||
fixed_joint_label, fixed_joint = _joint_for_target(fixed_target)
|
||||
build123d = _import_build123d()
|
||||
location = getattr(fixed_joint, "location", None)
|
||||
if location is None:
|
||||
location = getattr(fixed_joint, "joint_location", None)
|
||||
if location is None:
|
||||
raise ValueError(f"Joint {fixed_joint_label!r} does not expose a location")
|
||||
offset_location = _offset_location(offset)
|
||||
target_location = location * offset_location
|
||||
target_label = label_text(label or fixed_joint_label, "offset")
|
||||
build123d.RigidJoint(
|
||||
label=target_label,
|
||||
to_part=fixed_target.part,
|
||||
joint_location=target_location,
|
||||
)
|
||||
return MateTarget(part=fixed_target.part, frame=target_label)
|
||||
|
||||
|
||||
def _normalize_target(value: MateTarget | tuple[Any, str]) -> MateTarget:
|
||||
if isinstance(value, MateTarget):
|
||||
return value
|
||||
if isinstance(value, tuple) and len(value) == 2:
|
||||
return MateTarget(part=value[0], frame=label_text(value[1]))
|
||||
raise TypeError("Mate target must be MateTarget or (part, frame_name)")
|
||||
|
||||
|
||||
def _joint_for_target(target_value: MateTarget) -> tuple[str, Any]:
|
||||
joints = getattr(target_value.part, "joints", None)
|
||||
if not isinstance(joints, Mapping):
|
||||
raise ValueError("Mate target part does not expose a build123d joints mapping")
|
||||
joint = joints.get(target_value.frame)
|
||||
if joint is not None:
|
||||
return target_value.frame, joint
|
||||
raise KeyError(f"Part does not define mate frame {target_value.frame!r}")
|
||||
|
||||
|
||||
def _mate_endpoint_payload(target_value: MateTarget, frame: str, joint: Any) -> dict[str, Any]:
|
||||
endpoint: dict[str, Any] = {
|
||||
"part": _part_label(target_value.part),
|
||||
"frame": frame,
|
||||
}
|
||||
location = getattr(joint, "location", None)
|
||||
if location is None:
|
||||
location = getattr(joint, "joint_location", None)
|
||||
location_payload = _location_payload(location)
|
||||
if location_payload:
|
||||
endpoint.update(location_payload)
|
||||
return endpoint
|
||||
|
||||
|
||||
def _part_label(part: Any) -> str:
|
||||
label = str(getattr(part, "label", "") or "").strip()
|
||||
if label:
|
||||
return label
|
||||
return type(part).__name__
|
||||
|
||||
|
||||
def _location_payload(location: Any) -> dict[str, Any]:
|
||||
if location is None:
|
||||
return {}
|
||||
payload: dict[str, Any] = {}
|
||||
position = _vector_payload(getattr(location, "position", None))
|
||||
orientation = _vector_payload(getattr(location, "orientation", None))
|
||||
if position is not None:
|
||||
payload["position"] = position
|
||||
if orientation is not None:
|
||||
payload["orientation"] = orientation
|
||||
axes: dict[str, list[float]] = {}
|
||||
for axis_name, payload_key in (("x_axis", "x"), ("y_axis", "y"), ("z_axis", "z")):
|
||||
axis = getattr(location, axis_name, None)
|
||||
direction = _vector_payload(getattr(axis, "direction", None))
|
||||
if direction is not None:
|
||||
axes[payload_key] = direction
|
||||
if axes:
|
||||
payload["axes"] = axes
|
||||
return payload
|
||||
|
||||
|
||||
def _vector_payload(value: Any) -> list[float] | None:
|
||||
if value is None:
|
||||
return None
|
||||
components: list[Any] = []
|
||||
for attr in ("X", "Y", "Z"):
|
||||
if hasattr(value, attr):
|
||||
components.append(getattr(value, attr))
|
||||
if len(components) != 3:
|
||||
for attr in ("x", "y", "z"):
|
||||
if hasattr(value, attr):
|
||||
components.append(getattr(value, attr))
|
||||
if len(components) > 3:
|
||||
components = components[-3:]
|
||||
if len(components) != 3:
|
||||
try:
|
||||
components = list(value)
|
||||
except TypeError:
|
||||
return None
|
||||
if len(components) < 3:
|
||||
return None
|
||||
try:
|
||||
return [float(components[0]), float(components[1]), float(components[2])]
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _offset_location(offset: float | Sequence[float] | Any) -> Any:
|
||||
build123d = _import_build123d()
|
||||
if hasattr(offset, "wrapped"):
|
||||
return offset
|
||||
if isinstance(offset, (int, float)):
|
||||
return build123d.Location((0.0, 0.0, float(offset)))
|
||||
if isinstance(offset, Sequence) and not isinstance(offset, (str, bytes)):
|
||||
return build123d.Location(tuple(float(value) for value in offset))
|
||||
return offset
|
||||
|
||||
|
||||
def _normalize_label_token(value: object, *, field_name: str) -> str:
|
||||
token = str(value).strip()
|
||||
if not token:
|
||||
raise ValueError(f"Semantic label {field_name} must be non-empty")
|
||||
return "_".join(token.replace(":", "_").split())
|
||||
|
||||
|
||||
def _json_safe(value: Any) -> Any:
|
||||
if value is None or isinstance(value, (str, int, float, bool)):
|
||||
return value
|
||||
if isinstance(value, Mapping):
|
||||
return {str(key): _json_safe(child) for key, child in value.items()}
|
||||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
||||
return [_json_safe(child) for child in value]
|
||||
return str(value)
|
||||
|
||||
|
||||
def _import_build123d() -> Any:
|
||||
try:
|
||||
import build123d
|
||||
except ModuleNotFoundError as exc:
|
||||
raise RuntimeError("cadpy.assembly requires build123d at runtime") from exc
|
||||
return build123d
|
||||
+1269
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,974 @@
|
||||
from __future__ import annotations
|
||||
import copy
|
||||
from contextlib import nullcontext
|
||||
import hashlib
|
||||
from dataclasses import dataclass, field
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Sequence
|
||||
|
||||
from cadpy.assembly_flatten import AssemblyResolutionError, CatalogEntry
|
||||
from cadpy.assembly_spec import (
|
||||
REPO_ROOT,
|
||||
AssemblySpec,
|
||||
AssemblySpecError,
|
||||
AssemblyNodeSpec,
|
||||
IDENTITY_TRANSFORM,
|
||||
assembly_spec_from_payload,
|
||||
assembly_spec_children,
|
||||
read_assembly_spec,
|
||||
)
|
||||
from cadpy.catalog import (
|
||||
CadSource,
|
||||
cad_ref_from_step_path,
|
||||
iter_cad_sources,
|
||||
normalize_cad_ref,
|
||||
source_from_path,
|
||||
)
|
||||
from cadpy.glb import read_step_topology_manifest_from_glb
|
||||
from cadpy.render import existing_part_glb_path, part_glb_path
|
||||
from cadpy.step_export import create_bin_xcaf_doc, export_xcaf_doc_step_scene, quantity_color_rgba_from_color
|
||||
from cadpy.step_scene import (
|
||||
LoadedStepScene,
|
||||
_shape_hash,
|
||||
load_step_scene,
|
||||
load_step_scene_cached,
|
||||
load_step_scene_from_xcaf_doc,
|
||||
occurrence_selector_id,
|
||||
)
|
||||
|
||||
|
||||
GIT_LFS_POINTER_PREFIX = b"version https://git-lfs.github.com/spec/v1\n"
|
||||
|
||||
|
||||
@dataclass
|
||||
class _AssemblyBuildCache:
|
||||
resolver: "_AssemblyCatalogResolver"
|
||||
step_shapes: dict[tuple[Path, bool, tuple[float, float, float, float] | None], object] = field(default_factory=dict)
|
||||
step_has_assembly: dict[Path, bool] = field(default_factory=dict)
|
||||
step_scenes: dict[Path, LoadedStepScene] = field(default_factory=dict)
|
||||
source_colors: dict[str, tuple[float, float, float, float] | None] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _AssemblyCatalogResolver:
|
||||
sources: tuple[CadSource, ...] | None = None
|
||||
by_path: dict[Path, CadSource] = field(default_factory=dict)
|
||||
by_cad_ref: dict[str, CadSource] = field(default_factory=dict)
|
||||
entries: dict[Path, CatalogEntry | None] = field(default_factory=dict)
|
||||
assembly_specs: dict[Path, AssemblySpec] = field(default_factory=dict)
|
||||
|
||||
def _ensure_index(self) -> None:
|
||||
if self.sources is not None:
|
||||
return
|
||||
self.sources = iter_cad_sources()
|
||||
for source in self.sources:
|
||||
self.by_cad_ref[source.cad_ref] = source
|
||||
for path in (
|
||||
source.source_path,
|
||||
source.step_path,
|
||||
source.script_path,
|
||||
source.dxf_path,
|
||||
source.urdf_path,
|
||||
*source.generated_paths,
|
||||
):
|
||||
if path is not None:
|
||||
self.by_path[path.resolve()] = source
|
||||
|
||||
def source_for_path(self, source_path: Path) -> CadSource | None:
|
||||
self._ensure_index()
|
||||
resolved = source_path.resolve()
|
||||
source = self.by_path.get(resolved)
|
||||
if source is not None:
|
||||
return source
|
||||
return source_from_path(resolved) if resolved.exists() else None
|
||||
|
||||
def source_color_for_cad_ref(self, cad_ref: str):
|
||||
self._ensure_index()
|
||||
normalized = normalize_cad_ref(cad_ref)
|
||||
source = self.by_cad_ref.get(normalized or "")
|
||||
return source.color if source is not None else None
|
||||
|
||||
def entry_for_path(self, source_path: Path) -> CatalogEntry | None:
|
||||
resolved = source_path.resolve()
|
||||
if resolved in self.entries:
|
||||
return self.entries[resolved]
|
||||
entry = self._entry_for_resolved_path(resolved)
|
||||
self.entries[resolved] = entry
|
||||
return entry
|
||||
|
||||
def _entry_for_resolved_path(self, resolved: Path) -> CatalogEntry | None:
|
||||
source = self.source_for_path(resolved)
|
||||
if source is None:
|
||||
if not resolved.is_file() or resolved.suffix.lower() not in {".step", ".stp"}:
|
||||
return None
|
||||
cad_ref = cad_ref_from_step_path(resolved)
|
||||
return CatalogEntry(
|
||||
cad_ref=cad_ref,
|
||||
source_ref=_relative_to_repo(resolved),
|
||||
kind="part",
|
||||
source_path=resolved,
|
||||
step_path=resolved,
|
||||
glb_path=existing_part_glb_path(resolved) or part_glb_path(resolved),
|
||||
)
|
||||
if source.kind == "assembly" and source.script_path is not None:
|
||||
try:
|
||||
assembly_spec = self._assembly_spec_for_source(source)
|
||||
except AssemblySpecError as exc:
|
||||
raise AssemblyResolutionError(str(exc)) from exc
|
||||
return CatalogEntry(
|
||||
cad_ref=source.cad_ref,
|
||||
source_ref=source.source_ref,
|
||||
kind="assembly",
|
||||
source_path=source.source_path,
|
||||
step_path=source.step_path,
|
||||
assembly_spec=assembly_spec,
|
||||
)
|
||||
if source.step_path is None:
|
||||
return None
|
||||
return CatalogEntry(
|
||||
cad_ref=source.cad_ref,
|
||||
source_ref=source.source_ref,
|
||||
kind="part",
|
||||
source_path=source.source_path,
|
||||
step_path=source.step_path,
|
||||
stl_path=source.stl_path,
|
||||
glb_path=existing_part_glb_path(source.step_path) or part_glb_path(source.step_path),
|
||||
)
|
||||
|
||||
def _assembly_spec_for_source(self, source: CadSource) -> AssemblySpec:
|
||||
resolved = source.source_path.resolve()
|
||||
cached = self.assembly_specs.get(resolved)
|
||||
if cached is None:
|
||||
cached = read_assembly_spec(resolved)
|
||||
self.assembly_specs[resolved] = cached
|
||||
return cached
|
||||
|
||||
|
||||
def _relative_to_repo(path: Path) -> str:
|
||||
resolved = path.resolve()
|
||||
try:
|
||||
return resolved.relative_to(REPO_ROOT).as_posix()
|
||||
except ValueError:
|
||||
return resolved.as_posix()
|
||||
|
||||
|
||||
def _file_sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.expanduser().resolve().open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _is_git_lfs_pointer(path: Path) -> bool:
|
||||
try:
|
||||
with path.open("rb") as handle:
|
||||
return handle.read(len(GIT_LFS_POINTER_PREFIX)) == GIT_LFS_POINTER_PREFIX
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _location_from_transform(transform: tuple[float, ...]):
|
||||
import build123d
|
||||
from OCP.gp import gp_Trsf
|
||||
|
||||
trsf = gp_Trsf()
|
||||
trsf.SetValues(
|
||||
transform[0],
|
||||
transform[1],
|
||||
transform[2],
|
||||
transform[3],
|
||||
transform[4],
|
||||
transform[5],
|
||||
transform[6],
|
||||
transform[7],
|
||||
transform[8],
|
||||
transform[9],
|
||||
transform[10],
|
||||
transform[11],
|
||||
)
|
||||
return build123d.Location(trsf)
|
||||
|
||||
|
||||
@lru_cache(maxsize=4096)
|
||||
def _toploc_from_transform(transform: tuple[float, ...]):
|
||||
from OCP.gp import gp_Trsf
|
||||
from OCP.TopLoc import TopLoc_Location
|
||||
|
||||
trsf = gp_Trsf()
|
||||
trsf.SetValues(
|
||||
transform[0],
|
||||
transform[1],
|
||||
transform[2],
|
||||
transform[3],
|
||||
transform[4],
|
||||
transform[5],
|
||||
transform[6],
|
||||
transform[7],
|
||||
transform[8],
|
||||
transform[9],
|
||||
transform[10],
|
||||
transform[11],
|
||||
)
|
||||
return TopLoc_Location(trsf)
|
||||
|
||||
|
||||
def _topods_shape_without_location(shape: object) -> object:
|
||||
from OCP.TopLoc import TopLoc_Location
|
||||
|
||||
located = getattr(shape, "Located", None)
|
||||
if not callable(located):
|
||||
return shape
|
||||
try:
|
||||
return located(TopLoc_Location())
|
||||
except Exception:
|
||||
return shape
|
||||
|
||||
|
||||
def _component_name(instance_path: tuple[str, ...]) -> str:
|
||||
return "__".join(instance_path) or "root"
|
||||
|
||||
|
||||
def _load_step_shape(step_path: Path):
|
||||
if not step_path.exists():
|
||||
raise FileNotFoundError(f"Referenced STEP file is missing: {_relative_to_repo(step_path)}")
|
||||
if _is_git_lfs_pointer(step_path):
|
||||
raise RuntimeError(f"Referenced STEP file is a Git LFS pointer: {_relative_to_repo(step_path)}")
|
||||
|
||||
import build123d
|
||||
|
||||
try:
|
||||
return build123d.import_step(step_path)
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"Failed to load referenced STEP file: {_relative_to_repo(step_path)}") from exc
|
||||
|
||||
|
||||
def _step_has_assembly_artifact(step_path: Path) -> bool | None:
|
||||
payload = read_step_topology_manifest_from_glb(existing_part_glb_path(step_path) or part_glb_path(step_path))
|
||||
if payload is None:
|
||||
return None
|
||||
step_hash = str(payload.get("stepHash") or "").strip()
|
||||
if not step_hash or step_hash != _file_sha256(step_path):
|
||||
return None
|
||||
root = payload.get("assembly", {}).get("root") if isinstance(payload.get("assembly"), dict) else None
|
||||
return isinstance(root, dict) and bool(root.get("children"))
|
||||
|
||||
|
||||
def _scene_has_assembly_structure(scene: LoadedStepScene) -> bool:
|
||||
roots = list(getattr(scene, "roots", []) or [])
|
||||
if len(roots) > 1:
|
||||
return True
|
||||
stack = roots[:]
|
||||
while stack:
|
||||
node = stack.pop()
|
||||
children = list(getattr(node, "children", []) or [])
|
||||
if children:
|
||||
return True
|
||||
stack.extend(children)
|
||||
return False
|
||||
|
||||
|
||||
def _cached_step_has_assembly_artifact(step_path: Path, *, cache: _AssemblyBuildCache) -> bool:
|
||||
resolved = step_path.resolve()
|
||||
cached = cache.step_has_assembly.get(resolved)
|
||||
if cached is None:
|
||||
artifact_has_assembly = _step_has_assembly_artifact(resolved)
|
||||
if artifact_has_assembly is None:
|
||||
try:
|
||||
artifact_has_assembly = _scene_has_assembly_structure(_cached_step_scene(resolved, cache=cache))
|
||||
except Exception:
|
||||
artifact_has_assembly = False
|
||||
cached = artifact_has_assembly
|
||||
cache.step_has_assembly[resolved] = cached
|
||||
return cached
|
||||
|
||||
|
||||
def _cached_step_scene(step_path: Path, *, cache: _AssemblyBuildCache) -> LoadedStepScene:
|
||||
resolved = step_path.resolve()
|
||||
cached = cache.step_scenes.get(resolved)
|
||||
if cached is None:
|
||||
cached = load_step_scene_cached(resolved)
|
||||
cache.step_scenes[resolved] = cached
|
||||
return cached
|
||||
|
||||
|
||||
def _load_step_assembly_shape(step_path: Path, *, label: str):
|
||||
import build123d
|
||||
|
||||
from cadpy.step_scene import scene_occurrence_shape
|
||||
|
||||
scene = load_step_scene(step_path)
|
||||
|
||||
def node_label(node: object) -> str:
|
||||
return str(getattr(node, "name", None) or getattr(node, "source_name", None) or occurrence_selector_id(node)).strip()
|
||||
|
||||
def build_node(node: object):
|
||||
children = list(getattr(node, "children", []) or [])
|
||||
if children:
|
||||
child_shapes = [build_node(child) for child in children]
|
||||
return build123d.Compound(
|
||||
children=child_shapes,
|
||||
label=node_label(node),
|
||||
)
|
||||
shape = build123d.Shape(obj=scene_occurrence_shape(scene, node))
|
||||
shape.label = node_label(node)
|
||||
return shape
|
||||
|
||||
roots = [build_node(root) for root in scene.roots]
|
||||
if not roots:
|
||||
return _load_step_shape(step_path)
|
||||
return build123d.Compound(children=roots, label=label)
|
||||
|
||||
|
||||
def _cached_source_color_for_cad_ref(cad_ref: str, *, cache: _AssemblyBuildCache) -> tuple[float, float, float, float] | None:
|
||||
if cad_ref not in cache.source_colors:
|
||||
color = cache.resolver.source_color_for_cad_ref(cad_ref)
|
||||
cache.source_colors[cad_ref] = None if color is None else tuple(float(value) for value in color)
|
||||
return cache.source_colors[cad_ref]
|
||||
|
||||
|
||||
def _clear_shape_colors(shape: object) -> None:
|
||||
if hasattr(shape, "color"):
|
||||
shape.color = None
|
||||
for child in getattr(shape, "children", []) or []:
|
||||
_clear_shape_colors(child)
|
||||
|
||||
|
||||
def _apply_source_color(shape: object, cad_ref: str, *, use_source_colors: bool, cache: _AssemblyBuildCache) -> None:
|
||||
import build123d
|
||||
|
||||
source_color = _cached_source_color_for_cad_ref(cad_ref, cache=cache)
|
||||
if not use_source_colors:
|
||||
_clear_shape_colors(shape)
|
||||
elif source_color is not None:
|
||||
shape.color = build123d.Color(*source_color)
|
||||
|
||||
|
||||
def _copy_cached_shape_tree(shape: object):
|
||||
import build123d
|
||||
from OCP.BRepBuilderAPI import BRepBuilderAPI_Copy
|
||||
|
||||
if getattr(shape, "children", None):
|
||||
copied = copy.copy(shape)
|
||||
copied.label = getattr(shape, "label", None)
|
||||
else:
|
||||
copier = BRepBuilderAPI_Copy(shape.wrapped, True, True)
|
||||
copied_shape = copier.Shape()
|
||||
copied = build123d.Shape(obj=copied_shape)
|
||||
copied.label = getattr(shape, "label", None)
|
||||
if hasattr(copied, "color"):
|
||||
copied.color = getattr(shape, "color", None)
|
||||
return copied
|
||||
|
||||
|
||||
def _shape_for_part_entry(entry: CatalogEntry, *, use_source_colors: bool, cache: _AssemblyBuildCache):
|
||||
step_path = entry.step_path.resolve() if entry.step_path is not None else None
|
||||
if step_path is None:
|
||||
raise RuntimeError(f"Part source {entry.source_ref} is missing STEP source path")
|
||||
source_color = _cached_source_color_for_cad_ref(entry.cad_ref, cache=cache) if use_source_colors else None
|
||||
key = (step_path, bool(use_source_colors), source_color)
|
||||
cached = cache.step_shapes.get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
shape = (
|
||||
_load_step_assembly_shape(step_path, label=Path(step_path).stem)
|
||||
if _cached_step_has_assembly_artifact(step_path, cache=cache)
|
||||
else _load_step_shape(step_path)
|
||||
)
|
||||
_apply_source_color(shape, entry.cad_ref, use_source_colors=use_source_colors, cache=cache)
|
||||
cache.step_shapes[key] = shape
|
||||
return shape
|
||||
|
||||
|
||||
def _build_node_shape(
|
||||
node: AssemblyNodeSpec,
|
||||
*,
|
||||
resolve_entry,
|
||||
instance_path: tuple[str, ...],
|
||||
parent_use_source_colors: bool,
|
||||
stack: tuple[str, ...],
|
||||
cache: _AssemblyBuildCache,
|
||||
):
|
||||
import build123d
|
||||
|
||||
component_path = (*instance_path, node.instance_id) if instance_path else (node.instance_id,)
|
||||
label = _component_name(component_path)
|
||||
use_source_colors = parent_use_source_colors and node.use_source_colors
|
||||
copy_before_move = False
|
||||
|
||||
if node.children:
|
||||
child_shapes = [
|
||||
_build_node_shape(
|
||||
child,
|
||||
resolve_entry=resolve_entry,
|
||||
instance_path=component_path,
|
||||
parent_use_source_colors=use_source_colors,
|
||||
stack=stack,
|
||||
cache=cache,
|
||||
)
|
||||
for child in node.children
|
||||
]
|
||||
shape = build123d.Compound(children=child_shapes, label=label)
|
||||
else:
|
||||
if node.source_path is None or node.path is None:
|
||||
raise RuntimeError(f"Assembly node {label} is missing a STEP source path")
|
||||
child_entry = resolve_entry(node.source_path)
|
||||
if child_entry is None:
|
||||
raise RuntimeError(f"Assembly node {label} references missing CAD source {node.path}")
|
||||
if child_entry.kind == "assembly":
|
||||
if child_entry.assembly_spec is None:
|
||||
raise RuntimeError(f"Assembly source {child_entry.source_ref} is missing assembly spec data")
|
||||
stack_key = child_entry.source_ref
|
||||
if stack_key in stack:
|
||||
cycle = " -> ".join((*stack, stack_key))
|
||||
raise RuntimeError(f"Assembly cycle detected: {cycle}")
|
||||
shape = _compound_from_nodes(
|
||||
assembly_spec_children(child_entry.assembly_spec),
|
||||
label=label,
|
||||
resolve_entry=resolve_entry,
|
||||
instance_path=component_path,
|
||||
parent_use_source_colors=use_source_colors,
|
||||
stack=(*stack, stack_key),
|
||||
cache=cache,
|
||||
)
|
||||
elif child_entry.kind == "part":
|
||||
shape = _shape_for_part_entry(child_entry, use_source_colors=use_source_colors, cache=cache)
|
||||
copy_before_move = True
|
||||
else:
|
||||
raise RuntimeError(f"Assembly node {label} resolved to unsupported CAD source kind: {child_entry.kind}")
|
||||
|
||||
if copy_before_move:
|
||||
shape = _copy_cached_shape_tree(shape)
|
||||
moved = shape.moved(_location_from_transform(node.transform))
|
||||
moved.label = label
|
||||
if not use_source_colors:
|
||||
_clear_shape_colors(moved)
|
||||
return moved
|
||||
|
||||
|
||||
def _compound_from_nodes(
|
||||
nodes: Sequence[AssemblyNodeSpec],
|
||||
*,
|
||||
label: str,
|
||||
resolve_entry,
|
||||
instance_path: tuple[str, ...] = (),
|
||||
parent_use_source_colors: bool,
|
||||
stack: tuple[str, ...],
|
||||
cache: _AssemblyBuildCache,
|
||||
):
|
||||
import build123d
|
||||
|
||||
children = [
|
||||
_build_node_shape(
|
||||
node,
|
||||
resolve_entry=resolve_entry,
|
||||
instance_path=instance_path,
|
||||
parent_use_source_colors=parent_use_source_colors,
|
||||
stack=stack,
|
||||
cache=cache,
|
||||
)
|
||||
for node in nodes
|
||||
]
|
||||
if not children:
|
||||
raise RuntimeError(f"Assembly {label} has no resolved STEP instances")
|
||||
return build123d.Compound(
|
||||
children=children,
|
||||
label=label,
|
||||
)
|
||||
|
||||
|
||||
class _DirectXcafAssemblyWriter:
|
||||
def __init__(
|
||||
self,
|
||||
assembly_spec: AssemblySpec,
|
||||
*,
|
||||
label: str,
|
||||
resolver: _AssemblyCatalogResolver | None = None,
|
||||
) -> None:
|
||||
from OCP.XCAFDoc import XCAFDoc_DocumentTool
|
||||
|
||||
self.assembly_spec = assembly_spec
|
||||
self.label = label
|
||||
self.doc = create_bin_xcaf_doc()
|
||||
self.shape_tool = XCAFDoc_DocumentTool.ShapeTool_s(self.doc.Main())
|
||||
self.color_tool = XCAFDoc_DocumentTool.ColorTool_s(self.doc.Main())
|
||||
self.resolver = resolver or _AssemblyCatalogResolver()
|
||||
self.cache = _AssemblyBuildCache(resolver=self.resolver)
|
||||
self.shape_definitions: dict[int, object] = {}
|
||||
self.step_scene_definitions: dict[tuple[Path, bool, tuple[float, float, float, float] | None], object] = {}
|
||||
self.step_scene_node_definitions: dict[
|
||||
tuple[Path, tuple[int, ...], bool, tuple[float, float, float, float] | None],
|
||||
object,
|
||||
] = {}
|
||||
self.step_scene_prototype_definitions: dict[
|
||||
tuple[Path, int, bool, tuple[float, float, float, float] | None],
|
||||
object,
|
||||
] = {}
|
||||
self.colors_by_rgba: dict[tuple[float, float, float, float], object] = {}
|
||||
|
||||
def build_doc(self):
|
||||
root_label = self.shape_tool.NewShape()
|
||||
self._set_label_name(root_label, self.label)
|
||||
root_source = self.resolver.source_for_path(self.assembly_spec.assembly_path)
|
||||
root_source_ref = (
|
||||
root_source.source_ref
|
||||
if root_source is not None
|
||||
else _relative_to_repo(self.assembly_spec.assembly_path)
|
||||
)
|
||||
self._add_nodes_to_assembly(
|
||||
root_label,
|
||||
assembly_spec_children(self.assembly_spec),
|
||||
instance_path=(),
|
||||
parent_use_source_colors=True,
|
||||
stack=(root_source_ref,),
|
||||
)
|
||||
self.shape_tool.UpdateAssemblies()
|
||||
return self.doc
|
||||
|
||||
def _set_label_name(self, label: object, name: str | None) -> None:
|
||||
if not name:
|
||||
return
|
||||
from OCP.TCollection import TCollection_ExtendedString
|
||||
from OCP.TDataStd import TDataStd_Name
|
||||
|
||||
TDataStd_Name.Set_s(label, TCollection_ExtendedString(str(name)))
|
||||
|
||||
def _set_label_color(self, label: object, color: object | None) -> None:
|
||||
if color is None:
|
||||
return
|
||||
from OCP.XCAFDoc import XCAFDoc_ColorType
|
||||
|
||||
wrapped = (
|
||||
self._quantity_color_rgba(color)
|
||||
if isinstance(color, tuple)
|
||||
else quantity_color_rgba_from_color(color)
|
||||
)
|
||||
if wrapped is None:
|
||||
return
|
||||
self.color_tool.SetColor(label, wrapped, XCAFDoc_ColorType.XCAFDoc_ColorSurf)
|
||||
|
||||
def _set_shape_face_colors(
|
||||
self,
|
||||
shape_label: object,
|
||||
shape: object,
|
||||
face_colors: dict[int, tuple[float, float, float, float]],
|
||||
) -> None:
|
||||
if not face_colors:
|
||||
return
|
||||
from OCP.TopAbs import TopAbs_FACE
|
||||
from OCP.TopExp import TopExp_Explorer
|
||||
from OCP.TopoDS import TopoDS
|
||||
|
||||
explorer = TopExp_Explorer(shape, TopAbs_FACE)
|
||||
while explorer.More():
|
||||
face = TopoDS.Face_s(explorer.Current())
|
||||
color = face_colors.get(_shape_hash(face))
|
||||
if color is not None:
|
||||
face_label = self.shape_tool.AddSubShape(shape_label, face)
|
||||
self._set_label_color(face_label, color)
|
||||
explorer.Next()
|
||||
|
||||
def _quantity_color_rgba(self, color: tuple[float, ...]) -> object:
|
||||
values = tuple(max(0.0, min(1.0, float(component))) for component in color)
|
||||
if len(values) == 3:
|
||||
rgba = (values[0], values[1], values[2], 1.0)
|
||||
elif len(values) >= 4:
|
||||
rgba = (values[0], values[1], values[2], values[3])
|
||||
else:
|
||||
rgba = (0.72, 0.72, 0.72, 1.0)
|
||||
cached = self.colors_by_rgba.get(rgba)
|
||||
if cached is None:
|
||||
cached = quantity_color_rgba_from_color(rgba)
|
||||
self.colors_by_rgba[rgba] = cached
|
||||
return cached
|
||||
|
||||
def _part_definition_for_entry(self, entry: CatalogEntry, *, use_source_colors: bool) -> object:
|
||||
step_path = entry.step_path.resolve() if entry.step_path is not None else None
|
||||
if step_path is None:
|
||||
raise RuntimeError(f"Part source {entry.source_ref} is missing STEP source path")
|
||||
if _cached_step_has_assembly_artifact(step_path, cache=self.cache):
|
||||
return self._step_scene_definition_for_entry(
|
||||
entry,
|
||||
step_path=step_path,
|
||||
use_source_colors=use_source_colors,
|
||||
)
|
||||
shape = _shape_for_part_entry(entry, use_source_colors=use_source_colors, cache=self.cache)
|
||||
return self._shape_definition_for_tree(shape)
|
||||
|
||||
def _step_scene_definition_for_entry(
|
||||
self,
|
||||
entry: CatalogEntry,
|
||||
*,
|
||||
step_path: Path,
|
||||
use_source_colors: bool,
|
||||
) -> object:
|
||||
source_color = _cached_source_color_for_cad_ref(entry.cad_ref, cache=self.cache) if use_source_colors else None
|
||||
key = (step_path, bool(use_source_colors), source_color)
|
||||
cached = self.step_scene_definitions.get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
scene = _cached_step_scene(step_path, cache=self.cache)
|
||||
definition_label = self._new_assembly_definition(Path(step_path).stem)
|
||||
self.step_scene_definitions[key] = definition_label
|
||||
if source_color is not None:
|
||||
self._set_label_color(definition_label, source_color)
|
||||
|
||||
for root in scene.roots:
|
||||
root_definition = self._step_scene_node_definition(
|
||||
scene,
|
||||
root,
|
||||
use_source_colors=use_source_colors,
|
||||
source_color=source_color,
|
||||
)
|
||||
root_component = self.shape_tool.AddComponent(
|
||||
definition_label,
|
||||
root_definition,
|
||||
_toploc_from_transform(root.local_transform),
|
||||
)
|
||||
self._set_label_name(root_component, self._step_scene_node_name(root))
|
||||
if use_source_colors:
|
||||
self._set_label_color(root_component, source_color or root.color)
|
||||
return definition_label
|
||||
|
||||
def _step_scene_node_definition(
|
||||
self,
|
||||
scene: LoadedStepScene,
|
||||
node: object,
|
||||
*,
|
||||
use_source_colors: bool,
|
||||
source_color: tuple[float, float, float, float] | None,
|
||||
) -> object:
|
||||
key = (
|
||||
scene.step_path.resolve(),
|
||||
tuple(getattr(node, "path", ())),
|
||||
bool(use_source_colors),
|
||||
source_color,
|
||||
)
|
||||
cached = self.step_scene_node_definitions.get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
children = list(getattr(node, "children", []) or [])
|
||||
if children:
|
||||
definition_label = self._new_assembly_definition(self._step_scene_node_name(node))
|
||||
self.step_scene_node_definitions[key] = definition_label
|
||||
if use_source_colors:
|
||||
self._set_label_color(definition_label, source_color or getattr(node, "color", None))
|
||||
for child in children:
|
||||
child_definition = self._step_scene_node_definition(
|
||||
scene,
|
||||
child,
|
||||
use_source_colors=use_source_colors,
|
||||
source_color=source_color,
|
||||
)
|
||||
child_component = self.shape_tool.AddComponent(
|
||||
definition_label,
|
||||
child_definition,
|
||||
_toploc_from_transform(getattr(child, "local_transform", IDENTITY_TRANSFORM)),
|
||||
)
|
||||
self._set_label_name(child_component, self._step_scene_node_name(child))
|
||||
if use_source_colors:
|
||||
self._set_label_color(child_component, source_color or getattr(child, "color", None))
|
||||
return definition_label
|
||||
|
||||
prototype_key = getattr(node, "prototype_key", None)
|
||||
if prototype_key is None or prototype_key not in scene.prototype_shapes:
|
||||
raise RuntimeError(f"Imported STEP occurrence {occurrence_selector_id(node)} has no prototype shape")
|
||||
definition_label = self._step_scene_prototype_definition(
|
||||
scene,
|
||||
int(prototype_key),
|
||||
use_source_colors=use_source_colors,
|
||||
source_color=source_color,
|
||||
)
|
||||
self.step_scene_node_definitions[key] = definition_label
|
||||
return definition_label
|
||||
|
||||
def _step_scene_prototype_definition(
|
||||
self,
|
||||
scene: LoadedStepScene,
|
||||
prototype_key: int,
|
||||
*,
|
||||
use_source_colors: bool,
|
||||
source_color: tuple[float, float, float, float] | None,
|
||||
) -> object:
|
||||
key = (scene.step_path.resolve(), int(prototype_key), bool(use_source_colors), source_color)
|
||||
cached = self.step_scene_prototype_definitions.get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
definition_shape = _topods_shape_without_location(scene.prototype_shapes[prototype_key])
|
||||
definition_label = self.shape_tool.AddShape(definition_shape, False)
|
||||
self.step_scene_prototype_definitions[key] = definition_label
|
||||
self._set_label_name(definition_label, scene.prototype_names.get(prototype_key))
|
||||
if use_source_colors:
|
||||
self._set_label_color(definition_label, source_color or scene.prototype_colors.get(prototype_key))
|
||||
if source_color is None:
|
||||
self._set_shape_face_colors(
|
||||
definition_label,
|
||||
definition_shape,
|
||||
scene.prototype_face_colors.get(prototype_key, {}),
|
||||
)
|
||||
return definition_label
|
||||
|
||||
def _step_scene_node_name(self, node: object) -> str:
|
||||
return str(
|
||||
getattr(node, "name", None)
|
||||
or getattr(node, "source_name", None)
|
||||
or occurrence_selector_id(node)
|
||||
).strip()
|
||||
|
||||
def _add_nodes_to_assembly(
|
||||
self,
|
||||
assembly_label: object,
|
||||
nodes: Sequence[AssemblyNodeSpec],
|
||||
*,
|
||||
instance_path: tuple[str, ...],
|
||||
parent_use_source_colors: bool,
|
||||
stack: tuple[str, ...],
|
||||
) -> None:
|
||||
for node in nodes:
|
||||
self._add_node_to_assembly(
|
||||
assembly_label,
|
||||
node,
|
||||
instance_path=instance_path,
|
||||
parent_use_source_colors=parent_use_source_colors,
|
||||
stack=stack,
|
||||
)
|
||||
|
||||
def _add_node_to_assembly(
|
||||
self,
|
||||
assembly_label: object,
|
||||
node: AssemblyNodeSpec,
|
||||
*,
|
||||
instance_path: tuple[str, ...],
|
||||
parent_use_source_colors: bool,
|
||||
stack: tuple[str, ...],
|
||||
) -> object:
|
||||
component_path = (*instance_path, node.instance_id) if instance_path else (node.instance_id,)
|
||||
label = _component_name(component_path)
|
||||
use_source_colors = parent_use_source_colors and node.use_source_colors
|
||||
|
||||
if node.children:
|
||||
definition_label = self._new_assembly_definition(label)
|
||||
self._add_nodes_to_assembly(
|
||||
definition_label,
|
||||
node.children,
|
||||
instance_path=component_path,
|
||||
parent_use_source_colors=use_source_colors,
|
||||
stack=stack,
|
||||
)
|
||||
else:
|
||||
if node.source_path is None or node.path is None:
|
||||
raise RuntimeError(f"Assembly node {label} is missing a STEP source path")
|
||||
child_entry = self.resolver.entry_for_path(node.source_path)
|
||||
if child_entry is None:
|
||||
raise RuntimeError(f"Assembly node {label} references missing CAD source {node.path}")
|
||||
if child_entry.kind == "assembly":
|
||||
definition_label = self._assembly_definition_for_entry(
|
||||
child_entry,
|
||||
instance_path=component_path,
|
||||
parent_use_source_colors=use_source_colors,
|
||||
stack=stack,
|
||||
)
|
||||
elif child_entry.kind == "part":
|
||||
definition_label = self._part_definition_for_entry(
|
||||
child_entry,
|
||||
use_source_colors=use_source_colors,
|
||||
)
|
||||
else:
|
||||
raise RuntimeError(f"Assembly node {label} resolved to unsupported CAD source kind: {child_entry.kind}")
|
||||
|
||||
component_label = self.shape_tool.AddComponent(
|
||||
assembly_label,
|
||||
definition_label,
|
||||
_toploc_from_transform(node.transform),
|
||||
)
|
||||
self._set_label_name(component_label, label)
|
||||
return component_label
|
||||
|
||||
def _assembly_definition_for_entry(
|
||||
self,
|
||||
entry: CatalogEntry,
|
||||
*,
|
||||
instance_path: tuple[str, ...],
|
||||
parent_use_source_colors: bool,
|
||||
stack: tuple[str, ...],
|
||||
) -> object:
|
||||
assembly_spec = entry.assembly_spec
|
||||
if assembly_spec is None:
|
||||
raise RuntimeError(f"Assembly source {entry.source_ref} is missing assembly spec data")
|
||||
stack_key = entry.source_ref
|
||||
if stack_key in stack:
|
||||
cycle = " -> ".join((*stack, stack_key))
|
||||
raise RuntimeError(f"Assembly cycle detected: {cycle}")
|
||||
definition_label = self._new_assembly_definition(_component_name(instance_path))
|
||||
self._add_nodes_to_assembly(
|
||||
definition_label,
|
||||
assembly_spec_children(assembly_spec),
|
||||
instance_path=instance_path,
|
||||
parent_use_source_colors=parent_use_source_colors,
|
||||
stack=(*stack, stack_key),
|
||||
)
|
||||
return definition_label
|
||||
|
||||
def _new_assembly_definition(self, name: str | None = None) -> object:
|
||||
definition_label = self.shape_tool.NewShape()
|
||||
self._set_label_name(definition_label, name)
|
||||
return definition_label
|
||||
|
||||
def _shape_definition_for_tree(self, shape: object) -> object:
|
||||
key = id(shape)
|
||||
cached = self.shape_definitions.get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
children = list(getattr(shape, "children", []) or [])
|
||||
if children:
|
||||
definition_label = self._new_assembly_definition(getattr(shape, "label", None))
|
||||
self.shape_definitions[key] = definition_label
|
||||
self._set_label_color(definition_label, getattr(shape, "color", None))
|
||||
for child in children:
|
||||
child_definition = self._shape_definition_for_tree(child)
|
||||
child_component = self.shape_tool.AddComponent(
|
||||
definition_label,
|
||||
child_definition,
|
||||
self._shape_location(child),
|
||||
)
|
||||
self._set_label_name(child_component, getattr(child, "label", None))
|
||||
self._set_label_color(child_component, getattr(child, "color", None))
|
||||
return definition_label
|
||||
definition_label = self.shape_tool.AddShape(_topods_shape_without_location(shape.wrapped), False)
|
||||
self.shape_definitions[key] = definition_label
|
||||
self._set_label_name(definition_label, getattr(shape, "label", None))
|
||||
self._set_label_color(definition_label, getattr(shape, "color", None))
|
||||
return definition_label
|
||||
|
||||
def _shape_location(self, shape: object) -> object:
|
||||
location = getattr(getattr(shape, "wrapped", None), "Location", None)
|
||||
if not callable(location):
|
||||
return _toploc_from_transform(IDENTITY_TRANSFORM)
|
||||
try:
|
||||
return location()
|
||||
except Exception:
|
||||
return _toploc_from_transform(IDENTITY_TRANSFORM)
|
||||
|
||||
|
||||
def export_direct_assembly_step_scene(
|
||||
assembly_spec: AssemblySpec,
|
||||
output_path: Path,
|
||||
*,
|
||||
text_to_cad_entry_kind: str | None = "assembly",
|
||||
source_path: str | None = None,
|
||||
source_hash: str | None = None,
|
||||
resolver: _AssemblyCatalogResolver | None = None,
|
||||
logger: object | None = None,
|
||||
) -> LoadedStepScene:
|
||||
output_path = output_path.expanduser().resolve()
|
||||
writer = _DirectXcafAssemblyWriter(assembly_spec, label=output_path.stem, resolver=resolver)
|
||||
with (logger.timed(f"build assembly XCAF {output_path.name}") if logger is not None else nullcontext()):
|
||||
doc = writer.build_doc()
|
||||
with (logger.timed(f"export assembly STEP {output_path.name}") if logger is not None else nullcontext()):
|
||||
scene = export_xcaf_doc_step_scene(
|
||||
doc,
|
||||
output_path,
|
||||
label=output_path.stem,
|
||||
originating_system="tom-cad direct XCAF assembly",
|
||||
text_to_cad_entry_kind=text_to_cad_entry_kind,
|
||||
source_path=source_path,
|
||||
source_hash=source_hash,
|
||||
logger=logger,
|
||||
)
|
||||
return scene
|
||||
|
||||
|
||||
def build_direct_assembly_step_scene(
|
||||
assembly_spec: AssemblySpec,
|
||||
output_path: Path,
|
||||
*,
|
||||
source_kind: str = "step",
|
||||
source_hash: str | None = None,
|
||||
resolver: _AssemblyCatalogResolver | None = None,
|
||||
logger: object | None = None,
|
||||
) -> LoadedStepScene:
|
||||
output_path = output_path.expanduser().resolve()
|
||||
writer = _DirectXcafAssemblyWriter(assembly_spec, label=output_path.stem, resolver=resolver)
|
||||
with (logger.timed(f"build assembly XCAF {output_path.name}") if logger is not None else nullcontext()):
|
||||
doc = writer.build_doc()
|
||||
with (logger.timed(f"load assembly scene from XCAF {output_path.name}") if logger is not None else nullcontext()):
|
||||
return load_step_scene_from_xcaf_doc(
|
||||
output_path,
|
||||
doc,
|
||||
source_kind=source_kind,
|
||||
source_hash=source_hash,
|
||||
)
|
||||
|
||||
|
||||
def build_assembly_compound(assembly_spec: AssemblySpec, *, label: str | None = None):
|
||||
resolver = _AssemblyCatalogResolver()
|
||||
root_source = resolver.source_for_path(assembly_spec.assembly_path)
|
||||
root_source_ref = root_source.source_ref if root_source is not None else _relative_to_repo(assembly_spec.assembly_path)
|
||||
cache = _AssemblyBuildCache(resolver=resolver)
|
||||
return _compound_from_nodes(
|
||||
assembly_spec_children(assembly_spec),
|
||||
label=label or Path(assembly_spec.assembly_path).stem,
|
||||
resolve_entry=resolver.entry_for_path,
|
||||
parent_use_source_colors=True,
|
||||
stack=(root_source_ref,),
|
||||
cache=cache,
|
||||
)
|
||||
|
||||
|
||||
def export_assembly_step_scene(
|
||||
assembly_spec: AssemblySpec,
|
||||
output_path: Path,
|
||||
*,
|
||||
text_to_cad_entry_kind: str | None = "assembly",
|
||||
source_path: str | None = None,
|
||||
source_hash: str | None = None,
|
||||
resolver: _AssemblyCatalogResolver | None = None,
|
||||
logger: object | None = None,
|
||||
) -> LoadedStepScene:
|
||||
try:
|
||||
scene = export_direct_assembly_step_scene(
|
||||
assembly_spec,
|
||||
output_path,
|
||||
text_to_cad_entry_kind=text_to_cad_entry_kind,
|
||||
source_path=source_path,
|
||||
source_hash=source_hash,
|
||||
resolver=resolver,
|
||||
logger=logger,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"Failed to write assembly STEP file: {_relative_to_repo(output_path)}") from exc
|
||||
return scene
|
||||
|
||||
|
||||
def export_assembly_step(assembly_spec: AssemblySpec, output_path: Path) -> Path:
|
||||
export_assembly_step_scene(assembly_spec, output_path)
|
||||
return output_path
|
||||
|
||||
|
||||
def export_assembly_step_from_payload(
|
||||
payload: object,
|
||||
*,
|
||||
assembly_path: Path,
|
||||
output_path: Path,
|
||||
) -> Path:
|
||||
return export_assembly_step(
|
||||
assembly_spec_from_payload(assembly_path, payload),
|
||||
output_path,
|
||||
)
|
||||
|
||||
|
||||
def export_assembly_step_scene_from_payload(
|
||||
payload: object,
|
||||
*,
|
||||
assembly_path: Path,
|
||||
output_path: Path,
|
||||
text_to_cad_entry_kind: str | None = "assembly",
|
||||
) -> LoadedStepScene:
|
||||
return export_assembly_step_scene(
|
||||
assembly_spec_from_payload(assembly_path, payload),
|
||||
output_path,
|
||||
text_to_cad_entry_kind=text_to_cad_entry_kind,
|
||||
)
|
||||
@@ -0,0 +1,249 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from cadpy.assembly_spec import (
|
||||
AssemblySpec,
|
||||
AssemblySpecError,
|
||||
AssemblyNodeSpec,
|
||||
IDENTITY_TRANSFORM,
|
||||
assembly_spec_children,
|
||||
multiply_transforms,
|
||||
read_assembly_spec,
|
||||
)
|
||||
from cadpy.catalog import cad_ref_from_step_path, find_source_by_path, source_from_path
|
||||
from cadpy.render import existing_part_glb_path, part_glb_path, relative_to_repo
|
||||
|
||||
|
||||
class AssemblyResolutionError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CatalogEntry:
|
||||
cad_ref: str
|
||||
source_ref: str
|
||||
kind: str
|
||||
source_path: Path
|
||||
step_path: Path | None = None
|
||||
assembly_spec: AssemblySpec | None = None
|
||||
stl_path: Path | None = None
|
||||
glb_path: Path | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolvedPartInstance:
|
||||
instance_path: tuple[str, ...]
|
||||
cad_ref: str
|
||||
name: str | None
|
||||
color: str | None
|
||||
use_source_colors: bool
|
||||
step_path: Path
|
||||
stl_path: Path | None
|
||||
glb_path: Path
|
||||
transform: tuple[float, ...]
|
||||
|
||||
|
||||
EntryResolver = Callable[[Path], CatalogEntry | None]
|
||||
|
||||
|
||||
def filesystem_entry(source_path: Path) -> CatalogEntry | None:
|
||||
resolved = source_path.resolve()
|
||||
if resolved.suffix.lower() in {".step", ".stp"}:
|
||||
source = find_source_by_path(resolved)
|
||||
if source is not None and source.kind == "assembly" and source.script_path is not None:
|
||||
try:
|
||||
assembly_spec = read_assembly_spec(source.source_path)
|
||||
except AssemblySpecError as exc:
|
||||
raise AssemblyResolutionError(str(exc)) from exc
|
||||
return CatalogEntry(
|
||||
cad_ref=source.cad_ref,
|
||||
source_ref=source.source_ref,
|
||||
kind="assembly",
|
||||
source_path=source.source_path,
|
||||
step_path=source.step_path,
|
||||
assembly_spec=assembly_spec,
|
||||
)
|
||||
if source is not None and source.step_path is not None:
|
||||
return CatalogEntry(
|
||||
cad_ref=source.cad_ref,
|
||||
source_ref=source.source_ref,
|
||||
kind="part",
|
||||
source_path=source.source_path,
|
||||
step_path=source.step_path,
|
||||
stl_path=source.stl_path,
|
||||
glb_path=existing_part_glb_path(source.step_path) or part_glb_path(source.step_path),
|
||||
)
|
||||
if not resolved.is_file():
|
||||
return None
|
||||
cad_ref = cad_ref_from_step_path(resolved)
|
||||
return CatalogEntry(
|
||||
cad_ref=cad_ref,
|
||||
source_ref=relative_to_repo(resolved),
|
||||
kind="part",
|
||||
source_path=resolved,
|
||||
step_path=resolved,
|
||||
glb_path=existing_part_glb_path(resolved) or part_glb_path(resolved),
|
||||
)
|
||||
source = source_from_path(resolved) if resolved.exists() else None
|
||||
if source is None:
|
||||
source = find_source_by_path(source_path)
|
||||
if source is None:
|
||||
return None
|
||||
if source.kind == "assembly" and source.script_path is not None:
|
||||
try:
|
||||
assembly_spec = read_assembly_spec(source.source_path)
|
||||
except AssemblySpecError as exc:
|
||||
raise AssemblyResolutionError(str(exc)) from exc
|
||||
return CatalogEntry(
|
||||
cad_ref=source.cad_ref,
|
||||
source_ref=source.source_ref,
|
||||
kind="assembly",
|
||||
source_path=source.source_path,
|
||||
assembly_spec=assembly_spec,
|
||||
)
|
||||
if source.step_path is None:
|
||||
return None
|
||||
return CatalogEntry(
|
||||
cad_ref=source.cad_ref,
|
||||
source_ref=source.source_ref,
|
||||
kind="part",
|
||||
source_path=source.source_path,
|
||||
step_path=source.step_path,
|
||||
stl_path=source.stl_path,
|
||||
glb_path=existing_part_glb_path(source.step_path) or part_glb_path(source.step_path),
|
||||
)
|
||||
|
||||
|
||||
def flatten_entry(
|
||||
entry: CatalogEntry,
|
||||
*,
|
||||
resolve_entry: EntryResolver,
|
||||
parent_transform: tuple[float, ...] = IDENTITY_TRANSFORM,
|
||||
parent_color: str | None = None,
|
||||
parent_use_source_colors: bool = True,
|
||||
instance_path: tuple[str, ...] = (),
|
||||
stack: tuple[str, ...] = (),
|
||||
) -> tuple[ResolvedPartInstance, ...]:
|
||||
if entry.kind == "part":
|
||||
if entry.step_path is None or entry.glb_path is None:
|
||||
raise AssemblyResolutionError(f"Part source {entry.source_ref} is missing STEP or GLB source paths")
|
||||
return (
|
||||
ResolvedPartInstance(
|
||||
instance_path=instance_path or (entry.cad_ref,),
|
||||
cad_ref=entry.cad_ref,
|
||||
name=None,
|
||||
color=parent_color,
|
||||
use_source_colors=parent_use_source_colors,
|
||||
step_path=entry.step_path,
|
||||
stl_path=entry.stl_path,
|
||||
glb_path=entry.glb_path,
|
||||
transform=parent_transform,
|
||||
),
|
||||
)
|
||||
|
||||
assembly_spec = entry.assembly_spec
|
||||
if assembly_spec is None:
|
||||
raise AssemblyResolutionError(f"Assembly source {entry.source_ref} is missing assembly spec data")
|
||||
if entry.source_ref in stack:
|
||||
cycle = " -> ".join((*stack, entry.source_ref))
|
||||
raise AssemblyResolutionError(f"Assembly cycle detected: {cycle}")
|
||||
|
||||
resolved_parts: list[ResolvedPartInstance] = []
|
||||
next_stack = (*stack, entry.source_ref)
|
||||
for child in assembly_spec_children(assembly_spec):
|
||||
resolved_parts.extend(
|
||||
_flatten_node(
|
||||
child,
|
||||
parent_source_ref=entry.source_ref,
|
||||
resolve_entry=resolve_entry,
|
||||
parent_transform=parent_transform,
|
||||
parent_color=parent_color,
|
||||
parent_use_source_colors=parent_use_source_colors,
|
||||
instance_path=instance_path,
|
||||
stack=next_stack,
|
||||
)
|
||||
)
|
||||
return tuple(resolved_parts)
|
||||
|
||||
|
||||
def _flatten_node(
|
||||
node: AssemblyNodeSpec,
|
||||
*,
|
||||
parent_source_ref: str,
|
||||
resolve_entry: EntryResolver,
|
||||
parent_transform: tuple[float, ...],
|
||||
parent_color: str | None,
|
||||
parent_use_source_colors: bool,
|
||||
instance_path: tuple[str, ...],
|
||||
stack: tuple[str, ...],
|
||||
) -> tuple[ResolvedPartInstance, ...]:
|
||||
child_transform = multiply_transforms(parent_transform, node.transform)
|
||||
child_color = parent_color
|
||||
child_use_source_colors = parent_use_source_colors and node.use_source_colors
|
||||
child_instance_path = (*instance_path, node.instance_id) if instance_path else (node.instance_id,)
|
||||
|
||||
if node.children:
|
||||
resolved_parts: list[ResolvedPartInstance] = []
|
||||
for child in node.children:
|
||||
resolved_parts.extend(
|
||||
_flatten_node(
|
||||
child,
|
||||
parent_source_ref=parent_source_ref,
|
||||
resolve_entry=resolve_entry,
|
||||
parent_transform=child_transform,
|
||||
parent_color=child_color,
|
||||
parent_use_source_colors=child_use_source_colors,
|
||||
instance_path=child_instance_path,
|
||||
stack=stack,
|
||||
)
|
||||
)
|
||||
return tuple(resolved_parts)
|
||||
|
||||
if node.source_path is None or node.path is None:
|
||||
raise AssemblyResolutionError(f"Assembly source {parent_source_ref} contains node {node.name!r} without a STEP path")
|
||||
child_entry = resolve_entry(node.source_path)
|
||||
if child_entry is None:
|
||||
raise AssemblyResolutionError(
|
||||
f"Assembly source {parent_source_ref} references missing CAD source {node.path}"
|
||||
)
|
||||
if child_entry.kind == "part":
|
||||
if child_entry.step_path is None or child_entry.glb_path is None:
|
||||
raise AssemblyResolutionError(
|
||||
f"Part source {child_entry.source_ref} is missing STEP or GLB source paths"
|
||||
)
|
||||
return (
|
||||
ResolvedPartInstance(
|
||||
instance_path=child_instance_path,
|
||||
cad_ref=child_entry.cad_ref,
|
||||
name=node.name,
|
||||
color=child_color,
|
||||
use_source_colors=child_use_source_colors,
|
||||
step_path=child_entry.step_path,
|
||||
stl_path=child_entry.stl_path,
|
||||
glb_path=child_entry.glb_path,
|
||||
transform=child_transform,
|
||||
),
|
||||
)
|
||||
return flatten_entry(
|
||||
child_entry,
|
||||
resolve_entry=resolve_entry,
|
||||
parent_transform=child_transform,
|
||||
parent_color=child_color,
|
||||
parent_use_source_colors=child_use_source_colors,
|
||||
instance_path=child_instance_path,
|
||||
stack=stack,
|
||||
)
|
||||
|
||||
|
||||
def flatten_source_path(
|
||||
source_path: Path,
|
||||
*,
|
||||
resolve_entry: EntryResolver = filesystem_entry,
|
||||
) -> tuple[ResolvedPartInstance, ...]:
|
||||
entry = resolve_entry(source_path)
|
||||
if entry is None:
|
||||
raise AssemblyResolutionError(f"CAD source not found: {source_path}")
|
||||
return flatten_entry(entry, resolve_entry=resolve_entry)
|
||||
@@ -0,0 +1,458 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import math
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from pathlib import PurePosixPath
|
||||
|
||||
from cadpy.catalog import find_source_by_cad_ref
|
||||
from cadpy.metadata import parse_generator_metadata
|
||||
|
||||
|
||||
REPO_ROOT = Path.cwd().resolve()
|
||||
CAD_ROOT = REPO_ROOT
|
||||
STEP_SUFFIXES = (".step", ".stp")
|
||||
INSTANCE_NAME_PATTERN = re.compile(r"^[A-Za-z0-9._-]+$")
|
||||
IDENTITY_TRANSFORM = (
|
||||
1.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
1.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
1.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
1.0,
|
||||
)
|
||||
|
||||
|
||||
class AssemblySpecError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AssemblyInstanceSpec:
|
||||
instance_id: str
|
||||
source_path: Path
|
||||
path: str
|
||||
name: str
|
||||
transform: tuple[float, ...]
|
||||
use_source_colors: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AssemblyNodeSpec:
|
||||
instance_id: str
|
||||
name: str
|
||||
transform: tuple[float, ...]
|
||||
use_source_colors: bool = True
|
||||
source_path: Path | None = None
|
||||
path: str | None = None
|
||||
children: tuple["AssemblyNodeSpec", ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AssemblySpec:
|
||||
assembly_path: Path
|
||||
instances: tuple[AssemblyInstanceSpec, ...]
|
||||
children: tuple[AssemblyNodeSpec, ...] = ()
|
||||
|
||||
|
||||
def _display_path(path: Path) -> str:
|
||||
resolved = path.resolve()
|
||||
try:
|
||||
return resolved.relative_to(REPO_ROOT).as_posix()
|
||||
except ValueError:
|
||||
return resolved.as_posix()
|
||||
|
||||
|
||||
def find_step_path(cad_ref: str) -> Path | None:
|
||||
source = find_source_by_cad_ref(cad_ref)
|
||||
if source is not None and source.kind in {"part", "assembly"}:
|
||||
return source.step_path.resolve() if source.step_path is not None else None
|
||||
return None
|
||||
|
||||
|
||||
def resolve_cad_source_path(cad_ref: str) -> tuple[str, Path] | None:
|
||||
source = find_source_by_cad_ref(cad_ref)
|
||||
if source is not None:
|
||||
if source.kind == "assembly":
|
||||
return "assembly", source.source_path
|
||||
if source.kind == "part":
|
||||
step_path = source.step_path
|
||||
return ("part", step_path) if step_path is not None else None
|
||||
return None
|
||||
|
||||
|
||||
def multiply_transforms(left: tuple[float, ...], right: tuple[float, ...]) -> tuple[float, ...]:
|
||||
product: list[float] = []
|
||||
for row in range(4):
|
||||
for column in range(4):
|
||||
total = 0.0
|
||||
for offset in range(4):
|
||||
total += left[(row * 4) + offset] * right[(offset * 4) + column]
|
||||
product.append(total)
|
||||
return tuple(product)
|
||||
|
||||
|
||||
def read_assembly_spec(assembly_path: Path) -> AssemblySpec:
|
||||
resolved_path = assembly_path.resolve()
|
||||
payload = _run_assembly_generator(resolved_path)
|
||||
return assembly_spec_from_payload(resolved_path, payload)
|
||||
|
||||
|
||||
def assembly_spec_from_payload(assembly_path: Path, payload: object) -> AssemblySpec:
|
||||
resolved_path = assembly_path.resolve()
|
||||
|
||||
if isinstance(payload, list):
|
||||
payload = {"children": payload}
|
||||
if not isinstance(payload, dict):
|
||||
raise AssemblySpecError(f"{_display_path(resolved_path)} gen_step() must return an assembly list or object")
|
||||
|
||||
allowed_fields = {"instances", "children"}
|
||||
extra_fields = sorted(str(key) for key in payload if key not in allowed_fields)
|
||||
if extra_fields:
|
||||
joined = ", ".join(extra_fields)
|
||||
raise AssemblySpecError(
|
||||
f"{_display_path(resolved_path)} has unsupported assembly field(s): {joined}"
|
||||
)
|
||||
|
||||
raw_instances = payload.get("instances")
|
||||
raw_children = payload.get("children")
|
||||
has_instances = "instances" in payload
|
||||
has_children = "children" in payload
|
||||
if has_instances == has_children:
|
||||
raise AssemblySpecError(
|
||||
f"{_display_path(resolved_path)} must define exactly one of non-empty instances or children"
|
||||
)
|
||||
|
||||
if has_instances:
|
||||
if not isinstance(raw_instances, list) or not raw_instances:
|
||||
raise AssemblySpecError(f"{_display_path(resolved_path)} must define a non-empty instances array")
|
||||
instances = tuple(
|
||||
_parse_instance_spec(resolved_path, raw_instance, field_name=f"instances[{index}]")
|
||||
for index, raw_instance in enumerate(raw_instances, start=1)
|
||||
)
|
||||
_reject_duplicate_sibling_names(resolved_path, instances, field_name="instances")
|
||||
children = tuple(_node_from_instance(instance) for instance in instances)
|
||||
else:
|
||||
if not isinstance(raw_children, list) or not raw_children:
|
||||
raise AssemblySpecError(f"{_display_path(resolved_path)} must define a non-empty children array")
|
||||
children = tuple(
|
||||
_parse_node_spec(resolved_path, raw_child, field_name=f"children[{index}]")
|
||||
for index, raw_child in enumerate(raw_children, start=1)
|
||||
)
|
||||
_reject_duplicate_sibling_names(resolved_path, children, field_name="children")
|
||||
instances = tuple(_leaf_instances_from_nodes(children))
|
||||
|
||||
return AssemblySpec(
|
||||
assembly_path=resolved_path,
|
||||
instances=instances,
|
||||
children=children,
|
||||
)
|
||||
|
||||
|
||||
def assembly_spec_children(assembly_spec: AssemblySpec) -> tuple[AssemblyNodeSpec, ...]:
|
||||
if assembly_spec.children:
|
||||
return assembly_spec.children
|
||||
return tuple(_node_from_instance(instance) for instance in assembly_spec.instances)
|
||||
|
||||
|
||||
def _node_from_instance(instance: AssemblyInstanceSpec) -> AssemblyNodeSpec:
|
||||
return AssemblyNodeSpec(
|
||||
instance_id=instance.instance_id,
|
||||
name=instance.name,
|
||||
transform=instance.transform,
|
||||
use_source_colors=instance.use_source_colors,
|
||||
source_path=instance.source_path,
|
||||
path=instance.path,
|
||||
children=(),
|
||||
)
|
||||
|
||||
|
||||
def _leaf_instances_from_nodes(nodes: tuple[AssemblyNodeSpec, ...]) -> tuple[AssemblyInstanceSpec, ...]:
|
||||
instances: list[AssemblyInstanceSpec] = []
|
||||
for node in nodes:
|
||||
if node.children:
|
||||
instances.extend(_leaf_instances_from_nodes(node.children))
|
||||
continue
|
||||
if node.source_path is None or node.path is None:
|
||||
raise AssemblySpecError(f"Assembly leaf node {node.instance_id!r} is missing a STEP path")
|
||||
instances.append(
|
||||
AssemblyInstanceSpec(
|
||||
instance_id=node.instance_id,
|
||||
source_path=node.source_path,
|
||||
path=node.path,
|
||||
name=node.name,
|
||||
transform=node.transform,
|
||||
use_source_colors=node.use_source_colors,
|
||||
)
|
||||
)
|
||||
return tuple(instances)
|
||||
|
||||
|
||||
def _run_assembly_generator(assembly_path: Path) -> object:
|
||||
try:
|
||||
generator_metadata = parse_generator_metadata(assembly_path)
|
||||
except Exception as exc:
|
||||
raise AssemblySpecError(f"Failed to parse {_display_path(assembly_path)}") from exc
|
||||
if generator_metadata is None or generator_metadata.kind != "assembly":
|
||||
raise AssemblySpecError(
|
||||
f"{_display_path(assembly_path)} must define a gen_step() assembly return"
|
||||
)
|
||||
|
||||
module_name = (
|
||||
"_cad_assembly_"
|
||||
+ _display_path(assembly_path).replace("/", "_").replace("\\", "_").replace("-", "_").replace(".", "_")
|
||||
)
|
||||
module_spec = importlib.util.spec_from_file_location(module_name, assembly_path)
|
||||
if module_spec is None or module_spec.loader is None:
|
||||
raise AssemblySpecError(f"Failed to load assembly generator: {_display_path(assembly_path)}")
|
||||
|
||||
module = importlib.util.module_from_spec(module_spec)
|
||||
original_sys_path = list(sys.path)
|
||||
search_paths = [
|
||||
str(REPO_ROOT),
|
||||
str(CAD_ROOT),
|
||||
str(assembly_path.parent),
|
||||
]
|
||||
for parent in assembly_path.parents:
|
||||
if parent == REPO_ROOT.parent:
|
||||
break
|
||||
if (
|
||||
(parent / "STEP" / "__init__.py").is_file()
|
||||
or (parent / "robot_common" / "__init__.py").is_file()
|
||||
):
|
||||
search_paths.append(str(parent))
|
||||
for candidate in reversed(search_paths):
|
||||
if candidate not in sys.path:
|
||||
sys.path.insert(0, candidate)
|
||||
|
||||
try:
|
||||
sys.modules[module_name] = module
|
||||
module_spec.loader.exec_module(module)
|
||||
except Exception as exc:
|
||||
raise AssemblySpecError(f"{_display_path(assembly_path)} failed while loading") from exc
|
||||
finally:
|
||||
sys.path[:] = original_sys_path
|
||||
|
||||
gen_step = getattr(module, "gen_step", None)
|
||||
if not callable(gen_step):
|
||||
raise AssemblySpecError(f"{_display_path(assembly_path)} does not define a callable gen_step()")
|
||||
try:
|
||||
envelope = gen_step()
|
||||
except Exception as exc:
|
||||
raise AssemblySpecError(f"{_display_path(assembly_path)} gen_step() failed") from exc
|
||||
if isinstance(envelope, list):
|
||||
return {"children": envelope}
|
||||
if not isinstance(envelope, dict) or ("instances" not in envelope and "children" not in envelope):
|
||||
raise AssemblySpecError(
|
||||
f"{_display_path(assembly_path)} gen_step() must return an assembly list or legacy envelope with instances or children"
|
||||
)
|
||||
return {key: envelope[key] for key in ("instances", "children") if key in envelope}
|
||||
|
||||
|
||||
def _parse_instance_spec(assembly_path: Path, raw_instance: object, *, field_name: str) -> AssemblyInstanceSpec:
|
||||
if not isinstance(raw_instance, dict):
|
||||
raise AssemblySpecError(f"{_display_path(assembly_path)} {field_name} must be an object")
|
||||
allowed_instance_fields = {"path", "name", "transform", "use_source_colors"}
|
||||
extra_instance_fields = sorted(str(key) for key in raw_instance if key not in allowed_instance_fields)
|
||||
if extra_instance_fields:
|
||||
joined = ", ".join(extra_instance_fields)
|
||||
raise AssemblySpecError(
|
||||
f"{_display_path(assembly_path)} {field_name} has unsupported field(s): {joined}"
|
||||
)
|
||||
name = _normalize_instance_name(
|
||||
assembly_path,
|
||||
raw_instance.get("name"),
|
||||
field_name=f"{field_name}.name",
|
||||
)
|
||||
raw_path = _require_text(
|
||||
assembly_path,
|
||||
raw_instance.get("path"),
|
||||
field_name=f"{field_name}.path",
|
||||
)
|
||||
source_path, normalized_path = _resolve_instance_step_path(
|
||||
assembly_path,
|
||||
raw_path,
|
||||
field_name=f"{field_name}.path",
|
||||
)
|
||||
return AssemblyInstanceSpec(
|
||||
instance_id=name,
|
||||
source_path=source_path,
|
||||
path=normalized_path,
|
||||
name=name,
|
||||
transform=_normalize_transform(
|
||||
assembly_path,
|
||||
raw_instance.get("transform"),
|
||||
field_name=f"{field_name}.transform",
|
||||
),
|
||||
use_source_colors=_normalize_bool(
|
||||
assembly_path,
|
||||
raw_instance.get("use_source_colors", True),
|
||||
field_name=f"{field_name}.use_source_colors",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _parse_node_spec(assembly_path: Path, raw_node: object, *, field_name: str) -> AssemblyNodeSpec:
|
||||
if not isinstance(raw_node, dict):
|
||||
raise AssemblySpecError(f"{_display_path(assembly_path)} {field_name} must be an object")
|
||||
allowed_node_fields = {"path", "name", "transform", "use_source_colors", "children"}
|
||||
extra_node_fields = sorted(str(key) for key in raw_node if key not in allowed_node_fields)
|
||||
if extra_node_fields:
|
||||
joined = ", ".join(extra_node_fields)
|
||||
raise AssemblySpecError(
|
||||
f"{_display_path(assembly_path)} {field_name} has unsupported field(s): {joined}"
|
||||
)
|
||||
name = _normalize_instance_name(
|
||||
assembly_path,
|
||||
raw_node.get("name"),
|
||||
field_name=f"{field_name}.name",
|
||||
)
|
||||
transform = _normalize_transform(
|
||||
assembly_path,
|
||||
raw_node.get("transform"),
|
||||
field_name=f"{field_name}.transform",
|
||||
)
|
||||
use_source_colors = _normalize_bool(
|
||||
assembly_path,
|
||||
raw_node.get("use_source_colors", True),
|
||||
field_name=f"{field_name}.use_source_colors",
|
||||
)
|
||||
source_path: Path | None = None
|
||||
normalized_path: str | None = None
|
||||
if "path" in raw_node:
|
||||
raw_path = _require_text(
|
||||
assembly_path,
|
||||
raw_node.get("path"),
|
||||
field_name=f"{field_name}.path",
|
||||
)
|
||||
source_path, normalized_path = _resolve_instance_step_path(
|
||||
assembly_path,
|
||||
raw_path,
|
||||
field_name=f"{field_name}.path",
|
||||
)
|
||||
|
||||
raw_children = raw_node.get("children")
|
||||
children: tuple[AssemblyNodeSpec, ...] = ()
|
||||
if "children" in raw_node:
|
||||
if not isinstance(raw_children, list) or not raw_children:
|
||||
raise AssemblySpecError(f"{_display_path(assembly_path)} {field_name}.children must be a non-empty array")
|
||||
children = tuple(
|
||||
_parse_node_spec(assembly_path, child, field_name=f"{field_name}.children[{index}]")
|
||||
for index, child in enumerate(raw_children, start=1)
|
||||
)
|
||||
_reject_duplicate_sibling_names(assembly_path, children, field_name=f"{field_name}.children")
|
||||
elif source_path is None:
|
||||
raise AssemblySpecError(f"{_display_path(assembly_path)} {field_name} must define path or children")
|
||||
|
||||
return AssemblyNodeSpec(
|
||||
instance_id=name,
|
||||
name=name,
|
||||
transform=transform,
|
||||
use_source_colors=use_source_colors,
|
||||
source_path=source_path,
|
||||
path=normalized_path,
|
||||
children=children,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_instance_name(assembly_path: Path, raw_value: object, *, field_name: str) -> str:
|
||||
name = _require_text(assembly_path, raw_value, field_name=field_name)
|
||||
if not INSTANCE_NAME_PATTERN.fullmatch(name):
|
||||
raise AssemblySpecError(
|
||||
f"{_display_path(assembly_path)} {field_name} must contain only "
|
||||
"letters, numbers, '.', '_', or '-'"
|
||||
)
|
||||
return name
|
||||
|
||||
|
||||
def _reject_duplicate_sibling_names(
|
||||
assembly_path: Path,
|
||||
nodes: tuple[AssemblyInstanceSpec, ...] | tuple[AssemblyNodeSpec, ...],
|
||||
*,
|
||||
field_name: str,
|
||||
) -> None:
|
||||
seen: set[str] = set()
|
||||
for index, node in enumerate(nodes, start=1):
|
||||
name = str(node.instance_id)
|
||||
if name in seen:
|
||||
raise AssemblySpecError(
|
||||
f"{_display_path(assembly_path)} {field_name}[{index}].name duplicates {name!r}"
|
||||
)
|
||||
seen.add(name)
|
||||
|
||||
|
||||
def _require_text(assembly_path: Path, raw_value: object, *, field_name: str) -> str:
|
||||
if not isinstance(raw_value, str) or not raw_value.strip():
|
||||
raise AssemblySpecError(
|
||||
f"{_display_path(assembly_path)} {field_name} must be a non-empty string"
|
||||
)
|
||||
return raw_value.strip()
|
||||
|
||||
|
||||
def _normalize_bool(assembly_path: Path, raw_value: object, *, field_name: str) -> bool:
|
||||
if isinstance(raw_value, bool):
|
||||
return raw_value
|
||||
raise AssemblySpecError(f"{_display_path(assembly_path)} {field_name} must be true or false")
|
||||
|
||||
|
||||
def _normalize_transform(
|
||||
assembly_path: Path,
|
||||
raw_value: object,
|
||||
*,
|
||||
field_name: str,
|
||||
) -> tuple[float, ...]:
|
||||
if not isinstance(raw_value, (list, tuple)) or len(raw_value) != 16:
|
||||
raise AssemblySpecError(
|
||||
f"{_display_path(assembly_path)} {field_name} must be a 16-number array"
|
||||
)
|
||||
values: list[float] = []
|
||||
for index, raw_number in enumerate(raw_value, start=1):
|
||||
if isinstance(raw_number, bool) or not isinstance(raw_number, (int, float)):
|
||||
raise AssemblySpecError(
|
||||
f"{_display_path(assembly_path)} {field_name}[{index}] must be a number"
|
||||
)
|
||||
value = float(raw_number)
|
||||
if not math.isfinite(value):
|
||||
raise AssemblySpecError(
|
||||
f"{_display_path(assembly_path)} {field_name}[{index}] must be finite"
|
||||
)
|
||||
values.append(value)
|
||||
return tuple(values)
|
||||
|
||||
|
||||
def _resolve_instance_step_path(
|
||||
assembly_path: Path,
|
||||
raw_path: str,
|
||||
*,
|
||||
field_name: str,
|
||||
) -> tuple[Path, str]:
|
||||
if "\\" in raw_path:
|
||||
raise AssemblySpecError(f"{_display_path(assembly_path)} {field_name} must use POSIX '/' separators")
|
||||
pure = PurePosixPath(raw_path)
|
||||
if pure.is_absolute() or any(part in {"", "."} for part in pure.parts):
|
||||
raise AssemblySpecError(
|
||||
f"{_display_path(assembly_path)} {field_name} must be a relative STEP path"
|
||||
)
|
||||
if pure.suffix.lower() not in STEP_SUFFIXES:
|
||||
raise AssemblySpecError(f"{_display_path(assembly_path)} {field_name} must end in .step or .stp")
|
||||
|
||||
resolved = (assembly_path.parent / Path(*pure.parts)).resolve()
|
||||
if resolved.is_file():
|
||||
return resolved, pure.as_posix()
|
||||
|
||||
raise AssemblySpecError(
|
||||
f"{_display_path(assembly_path)} {field_name} does not resolve to a STEP file: {raw_path!r}"
|
||||
)
|
||||
@@ -0,0 +1,141 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
CAD_TOKEN_RE = re.compile(r"^\s*#([^\s]*)")
|
||||
OCCURRENCE_SELECTOR_RE = re.compile(r"^o((?:\d+)(?:\.\d+)*)$")
|
||||
OCCURRENCE_ENTITY_SELECTOR_RE = re.compile(r"^o((?:\d+)(?:\.\d+)*)\.([sfev])(\d+)$")
|
||||
ENTITY_SELECTOR_RE = re.compile(r"^([sfev])(\d+)$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParsedSelector:
|
||||
selector_type: str
|
||||
occurrence_id: str
|
||||
ordinal: int | None
|
||||
canonical: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParsedToken:
|
||||
line: int
|
||||
token: str
|
||||
cad_path: str
|
||||
selectors: tuple[str, ...]
|
||||
|
||||
|
||||
def _selector_type_for_kind(kind: str) -> str:
|
||||
if kind == "s":
|
||||
return "shape"
|
||||
if kind == "f":
|
||||
return "face"
|
||||
if kind == "e":
|
||||
return "edge"
|
||||
return "vertex"
|
||||
|
||||
|
||||
def parse_cad_tokens(text: str) -> list[ParsedToken]:
|
||||
tokens: list[ParsedToken] = []
|
||||
for line_no, line in enumerate(text.splitlines() or [text], start=1):
|
||||
match = CAD_TOKEN_RE.match(line)
|
||||
if match is None:
|
||||
continue
|
||||
selector_text = str(match.group(1) or "")
|
||||
tokens.append(
|
||||
ParsedToken(
|
||||
line=line_no,
|
||||
token=match.group(0),
|
||||
cad_path="",
|
||||
selectors=tuple(normalize_selector_list(selector_text)),
|
||||
)
|
||||
)
|
||||
return tokens
|
||||
|
||||
|
||||
def normalize_cad_path(raw_cad_path: str) -> str | None:
|
||||
normalized = str(raw_cad_path or "").replace("\\", "/").strip().strip("/")
|
||||
if not normalized:
|
||||
return None
|
||||
for suffix in (".step", ".stp"):
|
||||
if normalized.lower().endswith(suffix):
|
||||
normalized = normalized[: -len(suffix)]
|
||||
break
|
||||
parts = normalized.split("/")
|
||||
if any(not part or part in {".", ".."} for part in parts):
|
||||
return None
|
||||
return "/".join(parts)
|
||||
|
||||
|
||||
def parse_selector(raw_selector: str, *, inherited_occurrence_id: str = "") -> ParsedSelector | None:
|
||||
selector = str(raw_selector or "").strip().replace("#", "", 1)
|
||||
if not selector:
|
||||
return None
|
||||
|
||||
occurrence_entity_match = OCCURRENCE_ENTITY_SELECTOR_RE.match(selector)
|
||||
if occurrence_entity_match:
|
||||
occurrence_id = f"o{occurrence_entity_match.group(1)}"
|
||||
kind = str(occurrence_entity_match.group(2))
|
||||
ordinal = int(occurrence_entity_match.group(3))
|
||||
return ParsedSelector(
|
||||
selector_type=_selector_type_for_kind(kind),
|
||||
occurrence_id=occurrence_id,
|
||||
ordinal=ordinal,
|
||||
canonical=f"{occurrence_id}.{kind}{ordinal}",
|
||||
)
|
||||
|
||||
occurrence_match = OCCURRENCE_SELECTOR_RE.match(selector)
|
||||
if occurrence_match:
|
||||
occurrence_id = f"o{occurrence_match.group(1)}"
|
||||
return ParsedSelector(
|
||||
selector_type="occurrence",
|
||||
occurrence_id=occurrence_id,
|
||||
ordinal=None,
|
||||
canonical=occurrence_id,
|
||||
)
|
||||
|
||||
entity_match = ENTITY_SELECTOR_RE.match(selector)
|
||||
if entity_match:
|
||||
kind = str(entity_match.group(1))
|
||||
ordinal = int(entity_match.group(2))
|
||||
if inherited_occurrence_id:
|
||||
return ParsedSelector(
|
||||
selector_type=_selector_type_for_kind(kind),
|
||||
occurrence_id=inherited_occurrence_id,
|
||||
ordinal=ordinal,
|
||||
canonical=f"{inherited_occurrence_id}.{kind}{ordinal}",
|
||||
)
|
||||
return ParsedSelector(
|
||||
selector_type=_selector_type_for_kind(kind),
|
||||
occurrence_id="",
|
||||
ordinal=ordinal,
|
||||
canonical=f"{kind}{ordinal}",
|
||||
)
|
||||
|
||||
return ParsedSelector(
|
||||
selector_type="opaque",
|
||||
occurrence_id="",
|
||||
ordinal=None,
|
||||
canonical=selector,
|
||||
)
|
||||
|
||||
|
||||
def normalize_selector_list(raw_selector_list: str) -> list[str]:
|
||||
normalized: list[str] = []
|
||||
inherited_occurrence_id = ""
|
||||
for raw_selector in str(raw_selector_list or "").split(","):
|
||||
parsed = parse_selector(raw_selector, inherited_occurrence_id=inherited_occurrence_id)
|
||||
if parsed is None:
|
||||
continue
|
||||
normalized.append(parsed.canonical)
|
||||
if parsed.occurrence_id:
|
||||
inherited_occurrence_id = parsed.occurrence_id
|
||||
return normalized
|
||||
|
||||
|
||||
def build_cad_token(cad_path: str, selector: str = "") -> str:
|
||||
_ = cad_path
|
||||
if not selector:
|
||||
return "#"
|
||||
return f"#{selector}"
|
||||
@@ -0,0 +1,570 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from fnmatch import fnmatch
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
from .metadata import GeneratorMetadata, normalize_mesh_numeric, parse_generator_metadata
|
||||
|
||||
|
||||
REPO_ROOT = Path.cwd().resolve()
|
||||
CAD_ROOT = REPO_ROOT
|
||||
STEP_SUFFIXES = (".step", ".stp")
|
||||
EXPLORER_ARTIFACT_FILENAMES = {
|
||||
".glb": "model.glb",
|
||||
".topology.json": "topology.json",
|
||||
".topology.bin": "topology.bin",
|
||||
}
|
||||
IGNORED_DISCOVERY_DIR_NAMES = {
|
||||
"__pycache__",
|
||||
".cache",
|
||||
".eggs",
|
||||
".env",
|
||||
".git",
|
||||
".hg",
|
||||
".mypy_cache",
|
||||
".pytest_cache",
|
||||
".ruff_cache",
|
||||
".svn",
|
||||
".tox",
|
||||
".venv",
|
||||
"build",
|
||||
"dist",
|
||||
"env",
|
||||
"node_modules",
|
||||
"site-packages",
|
||||
"venv",
|
||||
}
|
||||
GENERATOR_NAME_MARKERS = (b"gen_step",)
|
||||
|
||||
|
||||
class CadSourceError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StepImportOptions:
|
||||
stl: str | None = None
|
||||
three_mf: str | None = None
|
||||
glb: str | None = None
|
||||
mesh_tolerance: float | None = None
|
||||
mesh_angular_tolerance: float | None = None
|
||||
|
||||
@property
|
||||
def has_metadata(self) -> bool:
|
||||
return any(
|
||||
(
|
||||
self.stl is not None,
|
||||
self.three_mf is not None,
|
||||
self.glb is not None,
|
||||
self.mesh_tolerance is not None,
|
||||
self.mesh_angular_tolerance is not None,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CadSource:
|
||||
source_ref: str
|
||||
cad_ref: str
|
||||
kind: str
|
||||
source_path: Path
|
||||
source: str
|
||||
origin_path: Path
|
||||
script_path: Path | None = None
|
||||
generator_metadata: GeneratorMetadata | None = None
|
||||
step_path: Path | None = None
|
||||
stl_path: Path | None = None
|
||||
three_mf_path: Path | None = None
|
||||
native_glb_path: Path | None = None
|
||||
dxf_path: Path | None = None
|
||||
urdf_path: Path | None = None
|
||||
sdf_path: Path | None = None
|
||||
mesh_tolerance: float | None = None
|
||||
mesh_angular_tolerance: float | None = None
|
||||
color: tuple[float, float, float, float] | None = None
|
||||
|
||||
@property
|
||||
def glb_path(self) -> Path | None:
|
||||
return explorer_artifact_path_for_step_path(self.step_path, ".glb") if self.step_path is not None else None
|
||||
|
||||
@property
|
||||
def generated_paths(self) -> tuple[Path, ...]:
|
||||
paths: list[Path] = []
|
||||
if self.source == "generated":
|
||||
if self.step_path is not None:
|
||||
paths.append(self.step_path)
|
||||
if self.dxf_path is not None:
|
||||
paths.append(self.dxf_path)
|
||||
if self.urdf_path is not None:
|
||||
paths.append(self.urdf_path)
|
||||
if self.sdf_path is not None:
|
||||
paths.append(self.sdf_path)
|
||||
if self.stl_path is not None:
|
||||
paths.append(self.stl_path)
|
||||
if self.three_mf_path is not None:
|
||||
paths.append(self.three_mf_path)
|
||||
if self.native_glb_path is not None:
|
||||
paths.append(self.native_glb_path)
|
||||
if self.glb_path is not None:
|
||||
paths.append(self.glb_path)
|
||||
return tuple(path.resolve() for path in paths)
|
||||
|
||||
|
||||
def iter_cad_sources(root: Path | None = None) -> tuple[CadSource, ...]:
|
||||
root = CAD_ROOT if root is None else root
|
||||
resolved_root = root.resolve()
|
||||
python_sources = _iter_python_sources(resolved_root)
|
||||
generated_step_paths = {
|
||||
source.step_path.resolve()
|
||||
for source in python_sources
|
||||
if source.step_path is not None
|
||||
}
|
||||
sources = [
|
||||
*python_sources,
|
||||
*_iter_step_sources(resolved_root, excluded_step_paths=generated_step_paths),
|
||||
]
|
||||
by_cad_ref: dict[str, CadSource] = {}
|
||||
by_source_ref: dict[str, CadSource] = {}
|
||||
by_step_path: dict[Path, CadSource] = {}
|
||||
by_generated_path: dict[Path, CadSource] = {}
|
||||
for source in sources:
|
||||
existing = by_cad_ref.get(source.cad_ref)
|
||||
if existing is not None:
|
||||
raise CadSourceError(
|
||||
"Duplicate CAD STEP ref "
|
||||
f"{source.cad_ref!r}: {_source_label(existing)} and {_source_label(source)}"
|
||||
)
|
||||
by_cad_ref[source.cad_ref] = source
|
||||
existing_source = by_source_ref.get(source.source_ref)
|
||||
if existing_source is not None:
|
||||
raise CadSourceError(
|
||||
"Duplicate CAD source ref "
|
||||
f"{source.source_ref!r}: {_source_label(existing_source)} and {_source_label(source)}"
|
||||
)
|
||||
by_source_ref[source.source_ref] = source
|
||||
if source.step_path is not None:
|
||||
existing_step = by_step_path.get(source.step_path.resolve())
|
||||
if existing_step is not None:
|
||||
raise CadSourceError(
|
||||
"Duplicate CAD STEP source "
|
||||
f"{_relative_to_repo(source.step_path)}: {_source_label(existing_step)} and {_source_label(source)}"
|
||||
)
|
||||
by_step_path[source.step_path.resolve()] = source
|
||||
for generated_path in source.generated_paths:
|
||||
resolved_generated_path = generated_path.resolve()
|
||||
existing_generated = by_generated_path.get(resolved_generated_path)
|
||||
if existing_generated is not None and existing_generated.source_ref != source.source_ref:
|
||||
raise CadSourceError(
|
||||
"Duplicate CAD generated output "
|
||||
f"{_relative_to_repo(generated_path)}: "
|
||||
f"{_source_label(existing_generated)} and {_source_label(source)}"
|
||||
)
|
||||
by_generated_path[resolved_generated_path] = source
|
||||
return tuple(sorted(by_cad_ref.values(), key=lambda source: source.source_ref))
|
||||
|
||||
|
||||
def source_from_path(
|
||||
path: Path,
|
||||
*,
|
||||
step_kind: str = "part",
|
||||
step_options: StepImportOptions | None = None,
|
||||
) -> CadSource | None:
|
||||
resolved = path.resolve()
|
||||
if resolved.suffix.lower() == ".py":
|
||||
return _read_python_source(resolved, allow_dxf_only=True)
|
||||
if resolved.suffix.lower() in STEP_SUFFIXES:
|
||||
return _read_step_source(resolved, kind=step_kind, options=step_options)
|
||||
return None
|
||||
|
||||
|
||||
def source_by_cad_ref(root: Path | None = None) -> dict[str, CadSource]:
|
||||
return {source.cad_ref: source for source in iter_cad_sources(root)}
|
||||
|
||||
|
||||
def find_source_by_cad_ref(cad_ref: str, root: Path | None = None) -> CadSource | None:
|
||||
normalized = normalize_cad_ref(cad_ref)
|
||||
return source_by_cad_ref(root).get(normalized or "")
|
||||
|
||||
|
||||
def find_source_by_source_ref(source_ref: str, root: Path | None = None) -> CadSource | None:
|
||||
normalized = normalize_source_ref(source_ref)
|
||||
if not normalized:
|
||||
return None
|
||||
for source in iter_cad_sources(root):
|
||||
if source.source_ref == normalized:
|
||||
return source
|
||||
return None
|
||||
|
||||
|
||||
def find_source_by_path(path: Path, root: Path | None = None) -> CadSource | None:
|
||||
resolved_path = path.resolve()
|
||||
for source in iter_cad_sources(root):
|
||||
paths = [
|
||||
source.source_path,
|
||||
source.step_path,
|
||||
source.script_path,
|
||||
source.dxf_path,
|
||||
source.urdf_path,
|
||||
source.sdf_path,
|
||||
*source.generated_paths,
|
||||
]
|
||||
if any(candidate is not None and candidate.resolve() == resolved_path for candidate in paths):
|
||||
return source
|
||||
return None
|
||||
|
||||
|
||||
def source_ref_from_path(path: Path) -> str:
|
||||
resolved = path.resolve()
|
||||
try:
|
||||
relative = resolved.relative_to(CAD_ROOT.resolve())
|
||||
except ValueError:
|
||||
return resolved.as_posix()
|
||||
return relative.as_posix()
|
||||
|
||||
|
||||
def cad_ref_from_step_path(path: Path) -> str:
|
||||
resolved = path.resolve()
|
||||
try:
|
||||
relative = resolved.relative_to(CAD_ROOT.resolve())
|
||||
except ValueError:
|
||||
relative = PurePosixPath(resolved.as_posix())
|
||||
name = relative.name
|
||||
suffix = relative.suffix.lower()
|
||||
if suffix in STEP_SUFFIXES:
|
||||
return relative.with_suffix("").as_posix()
|
||||
raise CadSourceError(f"{_relative_to_repo(path)} is not a CAD STEP source")
|
||||
|
||||
|
||||
def normalize_source_ref(raw_ref: str) -> str | None:
|
||||
normalized = str(raw_ref or "").replace("\\", "/").strip().strip("/")
|
||||
if not normalized:
|
||||
return None
|
||||
parts = normalized.split("/")
|
||||
if any(not part or part in {".", ".."} for part in parts):
|
||||
return None
|
||||
return "/".join(parts)
|
||||
|
||||
|
||||
def normalize_cad_ref(raw_ref: str) -> str | None:
|
||||
normalized = normalize_source_ref(raw_ref)
|
||||
if not normalized:
|
||||
return None
|
||||
suffix = PurePosixPath(normalized).suffix.lower()
|
||||
if suffix in {".py", *STEP_SUFFIXES}:
|
||||
normalized = str(PurePosixPath(normalized).with_suffix(""))
|
||||
return normalized
|
||||
|
||||
|
||||
def artifact_path_for_step_path(step_path: Path, suffix: str) -> Path:
|
||||
return step_path.resolve().with_suffix(suffix)
|
||||
|
||||
|
||||
def hidden_artifact_path_for_step_path(step_path: Path, suffix: str) -> Path:
|
||||
base = step_path.resolve()
|
||||
return base.with_name(f".{base.stem}{suffix}").resolve()
|
||||
|
||||
|
||||
def explorer_directory_for_step_path(step_path: Path) -> Path:
|
||||
base = step_path.resolve()
|
||||
return (base.parent / f".{base.name}").resolve()
|
||||
|
||||
|
||||
def hidden_glb_path_for_step_path(step_path: Path) -> Path:
|
||||
base = step_path.resolve()
|
||||
return (base.parent / f".{base.name}.glb").resolve()
|
||||
|
||||
|
||||
def legacy_explorer_artifact_path_for_step_path(step_path: Path, suffix: str) -> Path:
|
||||
base = step_path.resolve()
|
||||
artifact_name = EXPLORER_ARTIFACT_FILENAMES.get(suffix)
|
||||
if artifact_name is None:
|
||||
raise ValueError(f"Unsupported STEP explorer artifact suffix: {suffix}")
|
||||
return (explorer_directory_for_step_path(base) / artifact_name).resolve()
|
||||
|
||||
|
||||
def explorer_artifact_path_for_step_path(step_path: Path, suffix: str) -> Path:
|
||||
if suffix == ".glb":
|
||||
return hidden_glb_path_for_step_path(step_path)
|
||||
return legacy_explorer_artifact_path_for_step_path(step_path, suffix)
|
||||
|
||||
|
||||
def _iter_python_sources(root: Path) -> tuple[CadSource, ...]:
|
||||
sources: list[CadSource] = []
|
||||
for script_path in _iter_paths(root, "*.py"):
|
||||
if not _looks_like_generator_script(script_path):
|
||||
continue
|
||||
source = _read_python_source(script_path)
|
||||
if source is not None:
|
||||
sources.append(source)
|
||||
return tuple(sources)
|
||||
|
||||
|
||||
def _read_python_source(script_path: Path, *, allow_dxf_only: bool = False) -> CadSource | None:
|
||||
resolved_script_path = script_path.resolve()
|
||||
metadata = parse_generator_metadata(resolved_script_path)
|
||||
if metadata is None:
|
||||
return None
|
||||
if not metadata.has_gen_step:
|
||||
# Standalone DXF drafting source: valid as an explicit gen_dxf target,
|
||||
# but it has no STEP entry, so directory catalogs skip it.
|
||||
if not allow_dxf_only:
|
||||
return None
|
||||
return CadSource(
|
||||
source_ref=source_ref_from_path(resolved_script_path),
|
||||
cad_ref="",
|
||||
kind="dxf",
|
||||
source_path=resolved_script_path,
|
||||
source="generated",
|
||||
origin_path=resolved_script_path,
|
||||
script_path=resolved_script_path,
|
||||
generator_metadata=metadata,
|
||||
step_path=None,
|
||||
stl_path=None,
|
||||
three_mf_path=None,
|
||||
native_glb_path=None,
|
||||
dxf_path=resolved_script_path.with_suffix(".dxf"),
|
||||
urdf_path=None,
|
||||
sdf_path=None,
|
||||
mesh_tolerance=None,
|
||||
mesh_angular_tolerance=None,
|
||||
)
|
||||
if metadata.kind not in {"part", "assembly"}:
|
||||
raise CadSourceError(
|
||||
f"{_relative_to_repo(resolved_script_path)} must define a part or assembly gen_step() entry"
|
||||
)
|
||||
step_path = resolved_script_path.with_suffix(".step")
|
||||
dxf_path = resolved_script_path.with_suffix(".dxf") if metadata.has_gen_dxf else None
|
||||
urdf_path = resolved_script_path.with_suffix(".urdf") if metadata.has_gen_urdf else None
|
||||
sdf_path = resolved_script_path.with_suffix(".sdf") if metadata.has_gen_sdf else None
|
||||
return CadSource(
|
||||
source_ref=source_ref_from_path(resolved_script_path),
|
||||
cad_ref=cad_ref_from_step_path(step_path),
|
||||
kind=metadata.kind,
|
||||
source_path=resolved_script_path,
|
||||
source="generated",
|
||||
origin_path=resolved_script_path,
|
||||
script_path=resolved_script_path,
|
||||
generator_metadata=metadata,
|
||||
step_path=step_path,
|
||||
stl_path=None,
|
||||
three_mf_path=None,
|
||||
native_glb_path=None,
|
||||
dxf_path=dxf_path,
|
||||
urdf_path=urdf_path,
|
||||
sdf_path=sdf_path,
|
||||
mesh_tolerance=None,
|
||||
mesh_angular_tolerance=None,
|
||||
)
|
||||
|
||||
|
||||
def _iter_step_sources(root: Path, *, excluded_step_paths: set[Path]) -> tuple[CadSource, ...]:
|
||||
sources: list[CadSource] = []
|
||||
for pattern in ("*.step", "*.stp"):
|
||||
for step_path in _iter_paths(root, pattern):
|
||||
if step_path.resolve() in excluded_step_paths:
|
||||
continue
|
||||
sources.append(_read_step_source(step_path, kind="part"))
|
||||
return tuple(sorted(sources, key=lambda source: source.source_ref))
|
||||
|
||||
|
||||
def _read_step_source(
|
||||
step_path: Path,
|
||||
*,
|
||||
kind: str,
|
||||
options: StepImportOptions | None = None,
|
||||
) -> CadSource:
|
||||
resolved_step_path = step_path.resolve()
|
||||
options = options or StepImportOptions()
|
||||
if kind not in {"part", "assembly"}:
|
||||
raise CadSourceError(f"{_relative_to_repo(resolved_step_path)} kind must be 'part' or 'assembly'")
|
||||
if resolved_step_path.suffix.lower() not in STEP_SUFFIXES:
|
||||
raise CadSourceError(f"{_relative_to_repo(resolved_step_path)} source must end in .step or .stp")
|
||||
if not resolved_step_path.is_file():
|
||||
raise CadSourceError(
|
||||
f"{_relative_to_repo(resolved_step_path)} source does not exist"
|
||||
)
|
||||
stl_path = (
|
||||
_resolve_configured_artifact_path(
|
||||
options.stl,
|
||||
base_path=resolved_step_path,
|
||||
default_path=None,
|
||||
expected_suffixes=(".stl",),
|
||||
field_name="stl",
|
||||
)
|
||||
if options.stl is not None
|
||||
else None
|
||||
)
|
||||
three_mf_path = (
|
||||
_resolve_configured_artifact_path(
|
||||
options.three_mf,
|
||||
base_path=resolved_step_path,
|
||||
default_path=None,
|
||||
expected_suffixes=(".3mf",),
|
||||
field_name="3mf",
|
||||
)
|
||||
if options.three_mf is not None
|
||||
else None
|
||||
)
|
||||
native_glb_path = (
|
||||
_resolve_configured_artifact_path(
|
||||
options.glb,
|
||||
base_path=resolved_step_path,
|
||||
default_path=None,
|
||||
expected_suffixes=(".glb",),
|
||||
field_name="glb",
|
||||
)
|
||||
if options.glb is not None
|
||||
else None
|
||||
)
|
||||
|
||||
cad_ref = cad_ref_from_step_path(resolved_step_path)
|
||||
|
||||
return CadSource(
|
||||
source_ref=source_ref_from_path(resolved_step_path),
|
||||
cad_ref=cad_ref,
|
||||
kind=str(kind),
|
||||
source_path=resolved_step_path,
|
||||
source="imported",
|
||||
origin_path=resolved_step_path,
|
||||
step_path=resolved_step_path,
|
||||
stl_path=stl_path,
|
||||
three_mf_path=three_mf_path,
|
||||
native_glb_path=native_glb_path,
|
||||
mesh_tolerance=normalize_step_numeric(
|
||||
options.mesh_tolerance,
|
||||
base_path=resolved_step_path,
|
||||
field_name="mesh_tolerance",
|
||||
),
|
||||
mesh_angular_tolerance=normalize_step_numeric(
|
||||
options.mesh_angular_tolerance,
|
||||
base_path=resolved_step_path,
|
||||
field_name="mesh_angular_tolerance",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _iter_paths(root: Path, pattern: str) -> tuple[Path, ...]:
|
||||
paths: list[Path] = []
|
||||
for current_root, dirnames, filenames in os.walk(root):
|
||||
dirnames[:] = sorted(
|
||||
dirname
|
||||
for dirname in dirnames
|
||||
if dirname not in IGNORED_DISCOVERY_DIR_NAMES
|
||||
)
|
||||
for filename in sorted(filenames):
|
||||
if not fnmatch(filename, pattern):
|
||||
continue
|
||||
path = (Path(current_root) / filename).resolve()
|
||||
if path.is_file():
|
||||
paths.append(path)
|
||||
return tuple(paths)
|
||||
|
||||
|
||||
def _looks_like_generator_script(script_path: Path) -> bool:
|
||||
try:
|
||||
source_bytes = script_path.read_bytes()
|
||||
except OSError:
|
||||
return False
|
||||
return any(marker in source_bytes for marker in GENERATOR_NAME_MARKERS)
|
||||
|
||||
|
||||
def normalize_step_numeric(raw_value: object, *, base_path: Path, field_name: str) -> float | None:
|
||||
try:
|
||||
return normalize_mesh_numeric(raw_value, field_name=field_name)
|
||||
except ValueError as exc:
|
||||
raise CadSourceError(f"{_relative_to_repo(base_path)} {exc}") from exc
|
||||
|
||||
|
||||
def normalize_step_color(
|
||||
raw_value: object,
|
||||
*,
|
||||
base_path: Path,
|
||||
field_name: str,
|
||||
) -> tuple[float, float, float, float] | None:
|
||||
if raw_value is None:
|
||||
return None
|
||||
if isinstance(raw_value, str):
|
||||
value = raw_value.strip()
|
||||
if value.startswith("#"):
|
||||
value = value[1:]
|
||||
if len(value) not in {6, 8}:
|
||||
raise CadSourceError(f"{_relative_to_repo(base_path)} {field_name} must be #RRGGBB or #RRGGBBAA")
|
||||
try:
|
||||
components = [int(value[index : index + 2], 16) / 255.0 for index in range(0, len(value), 2)]
|
||||
except ValueError as exc:
|
||||
raise CadSourceError(f"{_relative_to_repo(base_path)} {field_name} must be valid hex") from exc
|
||||
elif (
|
||||
isinstance(raw_value, Sequence)
|
||||
and not isinstance(raw_value, (bytes, bytearray))
|
||||
and len(raw_value) in {3, 4}
|
||||
):
|
||||
components = []
|
||||
for component in raw_value:
|
||||
try:
|
||||
number = float(component)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise CadSourceError(
|
||||
f"{_relative_to_repo(base_path)} {field_name} components must be numeric"
|
||||
) from exc
|
||||
if not 0.0 <= number <= 1.0:
|
||||
raise CadSourceError(
|
||||
f"{_relative_to_repo(base_path)} {field_name} components must be between 0 and 1"
|
||||
)
|
||||
components.append(number)
|
||||
else:
|
||||
raise CadSourceError(f"{_relative_to_repo(base_path)} {field_name} must be an RGB/RGBA array or hex string")
|
||||
if len(components) == 3:
|
||||
components.append(1.0)
|
||||
return (float(components[0]), float(components[1]), float(components[2]), float(components[3]))
|
||||
|
||||
|
||||
def _resolve_configured_artifact_path(
|
||||
raw_value: object,
|
||||
*,
|
||||
base_path: Path,
|
||||
default_path: Path | None,
|
||||
expected_suffixes: tuple[str, ...],
|
||||
field_name: str,
|
||||
) -> Path:
|
||||
if raw_value is None:
|
||||
if default_path is None:
|
||||
raise CadSourceError(f"{_relative_to_repo(base_path)} {field_name} is required")
|
||||
resolved = default_path.resolve()
|
||||
else:
|
||||
if not isinstance(raw_value, str) or not raw_value.strip():
|
||||
raise CadSourceError(f"{_relative_to_repo(base_path)} {field_name} must be a non-empty string")
|
||||
value = raw_value.strip()
|
||||
if "\\" in value:
|
||||
raise CadSourceError(f"{_relative_to_repo(base_path)} {field_name} must use POSIX '/' separators")
|
||||
pure = PurePosixPath(value)
|
||||
if pure.is_absolute() or any(part in {"", "."} for part in pure.parts):
|
||||
raise CadSourceError(f"{_relative_to_repo(base_path)} {field_name} must be relative")
|
||||
resolved = (base_path.parent.resolve() / Path(*pure.parts)).resolve()
|
||||
suffix = resolved.suffix.lower()
|
||||
if suffix not in expected_suffixes:
|
||||
joined = " or ".join(expected_suffixes)
|
||||
raise CadSourceError(f"{_relative_to_repo(base_path)} {field_name} must end in {joined}")
|
||||
return resolved
|
||||
|
||||
|
||||
def _required_output(raw_value: str | None, *, script_path: Path, field_name: str) -> str:
|
||||
if raw_value is None:
|
||||
raise CadSourceError(f"{_relative_to_repo(script_path)} {field_name} is required")
|
||||
return raw_value
|
||||
|
||||
|
||||
def _source_label(source: CadSource) -> str:
|
||||
if source.script_path is not None:
|
||||
return _relative_to_repo(source.script_path)
|
||||
return _relative_to_repo(source.source_path)
|
||||
|
||||
|
||||
def _relative_to_repo(path: Path) -> str:
|
||||
resolved = path.resolve()
|
||||
try:
|
||||
return resolved.relative_to(REPO_ROOT).as_posix()
|
||||
except ValueError:
|
||||
return resolved.as_posix()
|
||||
@@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass, field
|
||||
import sys
|
||||
import time
|
||||
from typing import Iterator, TextIO
|
||||
|
||||
|
||||
def format_elapsed(seconds: float) -> str:
|
||||
milliseconds = seconds * 1000.0
|
||||
if milliseconds < 1000.0:
|
||||
return f"{milliseconds:.0f}ms"
|
||||
if seconds < 60.0:
|
||||
return f"{seconds:.2f}s"
|
||||
minutes, remainder = divmod(seconds, 60.0)
|
||||
return f"{int(minutes)}m {remainder:.1f}s"
|
||||
|
||||
|
||||
@dataclass
|
||||
class CliLogger:
|
||||
name: str
|
||||
verbose: bool = False
|
||||
stream: TextIO | None = None
|
||||
_started_at: float = field(default_factory=time.perf_counter)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.stream is None:
|
||||
self.stream = sys.stderr
|
||||
|
||||
def info(self, message: str) -> None:
|
||||
print(self._line(message), file=self.stream)
|
||||
|
||||
def warning(self, message: str) -> None:
|
||||
print(self._line(f"warning: {message}"), file=self.stream)
|
||||
|
||||
def debug(self, message: str) -> None:
|
||||
if self.verbose:
|
||||
self.info(message)
|
||||
|
||||
def timing(self, label: str, elapsed: float) -> None:
|
||||
if self.verbose:
|
||||
self.info(f"{label} completed in {format_elapsed(elapsed)}")
|
||||
|
||||
@contextmanager
|
||||
def timed(self, label: str) -> Iterator[None]:
|
||||
started_at = time.perf_counter()
|
||||
self.debug(f"{label} started")
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self.timing(label, time.perf_counter() - started_at)
|
||||
|
||||
def total(self, label: str = "total") -> None:
|
||||
self.timing(label, time.perf_counter() - self._started_at)
|
||||
|
||||
def _line(self, message: str) -> str:
|
||||
return f"[{self.name}] {message}"
|
||||
@@ -0,0 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
TEXT_TO_CAD_METADATA_PREFIX = "cadpy:"
|
||||
TEXT_TO_CAD_SOURCE_PATH_KEY = "sourcePath"
|
||||
TEXT_TO_CAD_SOURCE_HASH_KEY = "sourceHash"
|
||||
|
||||
|
||||
def text_to_cad_identity_metadata(
|
||||
*,
|
||||
source_path: str,
|
||||
source_hash: str,
|
||||
) -> dict[str, str]:
|
||||
return {
|
||||
TEXT_TO_CAD_SOURCE_PATH_KEY: source_path,
|
||||
TEXT_TO_CAD_SOURCE_HASH_KEY: source_hash,
|
||||
}
|
||||
|
||||
|
||||
def write_dxf_text_to_cad_metadata(dxf_path: Path, metadata: dict[str, str]) -> None:
|
||||
path = dxf_path.expanduser().resolve()
|
||||
text = path.read_text(encoding="utf-8")
|
||||
lines = text.splitlines(keepends=True)
|
||||
start = 0
|
||||
while start + 1 < len(lines) and lines[start].strip() == "999":
|
||||
value = lines[start + 1].strip()
|
||||
if not value.startswith(TEXT_TO_CAD_METADATA_PREFIX):
|
||||
break
|
||||
start += 2
|
||||
body = "".join(lines[start:])
|
||||
prefix = "".join(
|
||||
f"999\n{TEXT_TO_CAD_METADATA_PREFIX}{key}={value}\n"
|
||||
for key, value in metadata.items()
|
||||
if str(value or "").strip()
|
||||
)
|
||||
path.write_text(prefix + body, encoding="utf-8")
|
||||
|
||||
|
||||
def read_dxf_text_to_cad_metadata(dxf_path: Path) -> dict[str, str]:
|
||||
metadata: dict[str, str] = {}
|
||||
try:
|
||||
lines = dxf_path.expanduser().resolve().read_text(encoding="utf-8").splitlines()
|
||||
except OSError:
|
||||
return metadata
|
||||
index = 0
|
||||
while index + 1 < len(lines):
|
||||
if lines[index].strip() != "999":
|
||||
index += 1
|
||||
continue
|
||||
value = lines[index + 1].strip()
|
||||
index += 2
|
||||
if not value.startswith(TEXT_TO_CAD_METADATA_PREFIX):
|
||||
continue
|
||||
key_value = value[len(TEXT_TO_CAD_METADATA_PREFIX):]
|
||||
key, separator, raw_value = key_value.partition("=")
|
||||
if separator and re.fullmatch(r"[A-Za-z][A-Za-z0-9]*", key):
|
||||
metadata[key] = raw_value.strip()
|
||||
return metadata
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,179 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Iterator, Sequence
|
||||
|
||||
|
||||
GENERATION_STATUS_SCHEMA_VERSION = 1
|
||||
GENERATION_LOCK_SUFFIX = ".generation.lock.json"
|
||||
_HEARTBEAT_INTERVAL_SEC = 1.0
|
||||
_ACTIVE_RUN = threading.local()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GenerationOutput:
|
||||
path: Path
|
||||
kind: str
|
||||
|
||||
|
||||
def generation_lock_path(output_path: Path | str, run_id: str) -> Path:
|
||||
resolved_output = Path(output_path).expanduser().resolve()
|
||||
return resolved_output.parent / f".{resolved_output.name}.{_safe_lock_token(run_id)}{GENERATION_LOCK_SUFFIX}"
|
||||
|
||||
|
||||
def track_generation_run(
|
||||
*,
|
||||
source_path: Path | None,
|
||||
generator: str,
|
||||
outputs: Sequence[GenerationOutput | tuple[Path, str] | Path | str],
|
||||
repo_root: Path | None = None,
|
||||
) -> contextlib.AbstractContextManager[None]:
|
||||
tracker = _GenerationStatusTracker(
|
||||
repo_root=repo_root or Path.cwd(),
|
||||
source_path=source_path,
|
||||
generator=generator,
|
||||
outputs=outputs,
|
||||
)
|
||||
return tracker.run()
|
||||
|
||||
|
||||
class _GenerationStatusTracker:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
repo_root: Path,
|
||||
source_path: Path | None,
|
||||
generator: str,
|
||||
outputs: Sequence[GenerationOutput | tuple[Path, str] | Path | str],
|
||||
) -> None:
|
||||
self.repo_root = repo_root.expanduser().resolve()
|
||||
self.source_path = source_path.expanduser().resolve() if source_path is not None else None
|
||||
self.generator = str(generator or "").strip()
|
||||
self.outputs = tuple(_normalize_output(output) for output in outputs)
|
||||
self.run_id = f"{os.getpid()}-{uuid.uuid4().hex}"
|
||||
self.status_paths = tuple(
|
||||
dict.fromkeys(
|
||||
generation_lock_path(output.path, self.run_id)
|
||||
for output in self.outputs
|
||||
if output.path is not None
|
||||
)
|
||||
)
|
||||
self.status_path = self.status_paths[0] if self.status_paths else None
|
||||
self._started_at = _now_iso()
|
||||
self._stop = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
|
||||
@contextlib.contextmanager
|
||||
def run(self) -> Iterator[None]:
|
||||
active_depth = int(getattr(_ACTIVE_RUN, "depth", 0) or 0)
|
||||
if active_depth > 0:
|
||||
yield
|
||||
return
|
||||
|
||||
_ACTIVE_RUN.depth = active_depth + 1
|
||||
self._start()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
try:
|
||||
self._finish()
|
||||
finally:
|
||||
_ACTIVE_RUN.depth = active_depth
|
||||
|
||||
def _start(self) -> None:
|
||||
if not self.status_paths:
|
||||
return
|
||||
self._write_status()
|
||||
self._thread = threading.Thread(target=self._heartbeat, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def _finish(self) -> None:
|
||||
self._stop.set()
|
||||
if self._thread is not None:
|
||||
self._thread.join(timeout=0.25)
|
||||
for status_path in self.status_paths:
|
||||
try:
|
||||
status_path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError:
|
||||
self._write_status(status="finished", status_paths=(status_path,))
|
||||
|
||||
def _heartbeat(self) -> None:
|
||||
while not self._stop.wait(_HEARTBEAT_INTERVAL_SEC):
|
||||
self._write_status()
|
||||
|
||||
def _write_status(
|
||||
self,
|
||||
*,
|
||||
status: str = "running",
|
||||
status_paths: Sequence[Path] | None = None,
|
||||
) -> None:
|
||||
for status_path in tuple(status_paths or self.status_paths):
|
||||
try:
|
||||
status_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = status_path.with_name(f"{status_path.name}.{os.getpid()}.tmp")
|
||||
payload = self._status_payload(status=status, status_path=status_path)
|
||||
tmp_path.write_text(json.dumps(payload, sort_keys=True) + "\n", encoding="utf-8")
|
||||
tmp_path.replace(status_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _status_payload(self, *, status: str, status_path: Path) -> dict[str, object]:
|
||||
base_dir = status_path.parent
|
||||
return {
|
||||
"schemaVersion": GENERATION_STATUS_SCHEMA_VERSION,
|
||||
"id": self.run_id,
|
||||
"status": status,
|
||||
"pid": os.getpid(),
|
||||
"startedAt": self._started_at,
|
||||
"updatedAt": _now_iso(),
|
||||
"sourcePath": _display_path(self.source_path, base_dir),
|
||||
"generator": self.generator,
|
||||
"outputs": [
|
||||
{
|
||||
"path": _display_path(output.path, base_dir),
|
||||
"kind": output.kind,
|
||||
}
|
||||
for output in self.outputs
|
||||
if output.path is not None
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _normalize_output(output: GenerationOutput | tuple[Path, str] | Path | str) -> GenerationOutput:
|
||||
if isinstance(output, GenerationOutput):
|
||||
return output
|
||||
if isinstance(output, tuple):
|
||||
path, kind = output
|
||||
return GenerationOutput(path=Path(path), kind=str(kind or "").strip())
|
||||
path = Path(output)
|
||||
kind = path.suffix.lower().replace(".", "") or "output"
|
||||
return GenerationOutput(path=path, kind=kind)
|
||||
|
||||
|
||||
def _safe_lock_token(value: object) -> str:
|
||||
token = "".join(
|
||||
character if character.isalnum() or character in {"-", "_"} else "_"
|
||||
for character in str(value or "").strip()
|
||||
).strip("._")
|
||||
return token or uuid.uuid4().hex
|
||||
|
||||
|
||||
def _display_path(path: Path | None, repo_root: Path) -> str:
|
||||
if path is None:
|
||||
return ""
|
||||
resolved = path.expanduser().resolve()
|
||||
return os.path.relpath(resolved, start=repo_root.expanduser().resolve()).replace(os.sep, "/")
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.fromtimestamp(time.time(), tz=timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,431 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from array import array
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from OCP.BRep import BRep_Tool
|
||||
from OCP.TopAbs import TopAbs_FACE, TopAbs_REVERSED
|
||||
from OCP.TopExp import TopExp_Explorer
|
||||
from OCP.TopLoc import TopLoc_Location
|
||||
from OCP.TopoDS import TopoDS
|
||||
|
||||
|
||||
ColorRGBA = tuple[float, float, float, float]
|
||||
CAD_TO_GLB_SCALE = 0.001
|
||||
DEFAULT_MATERIAL: ColorRGBA = (0.72, 0.72, 0.72, 1.0)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ShapeGlbMeshPayload:
|
||||
positions: array
|
||||
normals: array
|
||||
barycentrics: array
|
||||
edge_classes: array
|
||||
primitives: list[tuple[ColorRGBA, array]]
|
||||
minimum: list[float]
|
||||
maximum: list[float]
|
||||
face_runs_by_hash: dict[int, tuple[int, int, int]]
|
||||
surface_half_edges_by_face_ordinal: dict[int, list[tuple[int, int, int, int, int]]]
|
||||
|
||||
|
||||
def normalize_rgba(color: ColorRGBA | tuple[float, ...] | None) -> ColorRGBA:
|
||||
if color is None:
|
||||
return DEFAULT_MATERIAL
|
||||
values = tuple(max(0.0, min(1.0, float(component))) for component in color)
|
||||
if len(values) == 3:
|
||||
return (values[0], values[1], values[2], 1.0)
|
||||
if len(values) >= 4:
|
||||
return (values[0], values[1], values[2], values[3])
|
||||
return DEFAULT_MATERIAL
|
||||
|
||||
|
||||
def color_key(color: ColorRGBA) -> tuple[int, int, int, int]:
|
||||
return tuple(int(round(max(0.0, min(1.0, component)) * 255.0)) for component in color)
|
||||
|
||||
|
||||
def occurrence_color_for_id(
|
||||
occurrence_id: str,
|
||||
occurrence_colors: Mapping[str, ColorRGBA],
|
||||
) -> ColorRGBA | None:
|
||||
current = occurrence_id
|
||||
while current:
|
||||
color = occurrence_colors.get(current)
|
||||
if color is not None:
|
||||
return normalize_rgba(color)
|
||||
if "." not in current:
|
||||
break
|
||||
current = current.rsplit(".", 1)[0]
|
||||
return None
|
||||
|
||||
|
||||
def _empty_payload() -> ShapeGlbMeshPayload:
|
||||
return ShapeGlbMeshPayload(
|
||||
positions=array("f"),
|
||||
normals=array("f"),
|
||||
barycentrics=array("f"),
|
||||
edge_classes=array("B"),
|
||||
primitives=[],
|
||||
minimum=[0.0, 0.0, 0.0],
|
||||
maximum=[0.0, 0.0, 0.0],
|
||||
face_runs_by_hash={},
|
||||
surface_half_edges_by_face_ordinal={},
|
||||
)
|
||||
|
||||
|
||||
def _triangle_side_key(left: int, right: int) -> tuple[int, int]:
|
||||
a = max(0, int(left))
|
||||
b = max(0, int(right))
|
||||
return (a, b) if a < b else (b, a)
|
||||
|
||||
|
||||
def _triangle_area_twice(
|
||||
a: Sequence[float],
|
||||
b: Sequence[float],
|
||||
c: Sequence[float],
|
||||
) -> float:
|
||||
abx = b[0] - a[0]
|
||||
aby = b[1] - a[1]
|
||||
abz = b[2] - a[2]
|
||||
acx = c[0] - a[0]
|
||||
acy = c[1] - a[1]
|
||||
acz = c[2] - a[2]
|
||||
cross_x = aby * acz - abz * acy
|
||||
cross_y = abz * acx - abx * acz
|
||||
cross_z = abx * acy - aby * acx
|
||||
return math.sqrt(cross_x * cross_x + cross_y * cross_y + cross_z * cross_z)
|
||||
|
||||
|
||||
def _transform_point_from_occ(point: object, location: TopLoc_Location) -> list[float]:
|
||||
transformed = point.Transformed(location.Transformation())
|
||||
return [float(transformed.X()), float(transformed.Y()), float(transformed.Z())]
|
||||
|
||||
|
||||
def transform_normal_from_occ(
|
||||
normal: object,
|
||||
location: TopLoc_Location,
|
||||
*,
|
||||
reversed_face: bool,
|
||||
) -> tuple[float, float, float]:
|
||||
transformed = normal.Transformed(location.Transformation())
|
||||
x = float(transformed.X())
|
||||
y = float(transformed.Y())
|
||||
z = float(transformed.Z())
|
||||
if reversed_face:
|
||||
x, y, z = -x, -y, -z
|
||||
length = math.sqrt((x * x) + (y * y) + (z * z))
|
||||
if length <= 1e-15 or not math.isfinite(length):
|
||||
return (0.0, 0.0, 1.0)
|
||||
return (x / length, y / length, z / length)
|
||||
|
||||
|
||||
def _append_face_payload(
|
||||
*,
|
||||
positions: array,
|
||||
normals: array,
|
||||
barycentrics: array,
|
||||
edge_classes: array,
|
||||
primitive_indices_by_key: dict[tuple[int, int, int, int], tuple[int, ColorRGBA, array]],
|
||||
face_runs_by_hash: dict[int, tuple[int, int, int]],
|
||||
surface_half_edges_by_face_ordinal: dict[int, list[tuple[int, int, int, int, int]]],
|
||||
min_values: list[float],
|
||||
max_values: list[float],
|
||||
face_hash: int,
|
||||
face_ordinal: int,
|
||||
default_color: ColorRGBA,
|
||||
face_colors: Mapping[int, ColorRGBA],
|
||||
edge_side_ordinals: Mapping[tuple[int, int], int],
|
||||
edge_surface_class_codes: Mapping[int, int],
|
||||
include_surface_edges: bool,
|
||||
nodes: Sequence[Sequence[float]],
|
||||
node_normals: Sequence[Sequence[float]],
|
||||
triangles: Sequence[Sequence[int]],
|
||||
) -> None:
|
||||
if not nodes or not triangles:
|
||||
return
|
||||
|
||||
normalized_color = normalize_rgba(face_colors.get(face_hash, default_color))
|
||||
key = color_key(normalized_color)
|
||||
bucket = primitive_indices_by_key.get(key)
|
||||
if bucket is None:
|
||||
bucket = (len(primitive_indices_by_key), normalized_color, array("I"))
|
||||
primitive_indices_by_key[key] = bucket
|
||||
primitive_index, _primitive_color, primitive_indices = bucket
|
||||
|
||||
triangle_start = len(primitive_indices) // 3
|
||||
fallback_normal = (0.0, 0.0, 1.0)
|
||||
if len(node_normals) != len(nodes):
|
||||
node_normals = [fallback_normal] * len(nodes)
|
||||
if not include_surface_edges:
|
||||
vertex_offset = len(positions) // 3
|
||||
for node_index in range(len(nodes)):
|
||||
point = nodes[node_index]
|
||||
x = float(point[0]) * CAD_TO_GLB_SCALE
|
||||
y = float(point[1]) * CAD_TO_GLB_SCALE
|
||||
z = float(point[2]) * CAD_TO_GLB_SCALE
|
||||
positions.extend((x, y, z))
|
||||
min_values[0] = min(min_values[0], x)
|
||||
min_values[1] = min(min_values[1], y)
|
||||
min_values[2] = min(min_values[2], z)
|
||||
max_values[0] = max(max_values[0], x)
|
||||
max_values[1] = max(max_values[1], y)
|
||||
max_values[2] = max(max_values[2], z)
|
||||
normal = node_normals[node_index]
|
||||
normals.extend((float(normal[0]), float(normal[1]), float(normal[2])))
|
||||
for triangle in triangles:
|
||||
primitive_indices.extend(vertex_offset + int(node_index) for node_index in triangle)
|
||||
face_runs_by_hash[face_hash] = (primitive_index, triangle_start, len(triangles))
|
||||
return
|
||||
|
||||
barycentric_triplet = ((1, 0, 0), (0, 1, 0), (0, 0, 1))
|
||||
for triangle in triangles:
|
||||
triangle_index = len(primitive_indices) // 3
|
||||
side_pairs = (
|
||||
(int(triangle[1]), int(triangle[2])),
|
||||
(int(triangle[2]), int(triangle[0])),
|
||||
(int(triangle[0]), int(triangle[1])),
|
||||
)
|
||||
side_classes_list = [0, 0, 0]
|
||||
for side, (left, right) in enumerate(side_pairs):
|
||||
edge_ordinal = edge_side_ordinals.get((left, right) if left < right else (right, left))
|
||||
if edge_ordinal is None:
|
||||
continue
|
||||
class_code = int(edge_surface_class_codes.get(int(edge_ordinal), 0) or 0)
|
||||
side_classes_list[side] = max(0, class_code)
|
||||
if class_code:
|
||||
surface_half_edges_by_face_ordinal.setdefault(face_ordinal, []).append(
|
||||
(
|
||||
int(edge_ordinal),
|
||||
int(primitive_index),
|
||||
int(triangle_index),
|
||||
int(side),
|
||||
int(class_code),
|
||||
)
|
||||
)
|
||||
side_classes = (side_classes_list[0], side_classes_list[1], side_classes_list[2])
|
||||
for local_vertex, node_index in enumerate(triangle):
|
||||
source_node_index = int(node_index)
|
||||
point = nodes[source_node_index]
|
||||
x = float(point[0]) * CAD_TO_GLB_SCALE
|
||||
y = float(point[1]) * CAD_TO_GLB_SCALE
|
||||
z = float(point[2]) * CAD_TO_GLB_SCALE
|
||||
positions.extend((x, y, z))
|
||||
min_values[0] = min(min_values[0], x)
|
||||
min_values[1] = min(min_values[1], y)
|
||||
min_values[2] = min(min_values[2], z)
|
||||
max_values[0] = max(max_values[0], x)
|
||||
max_values[1] = max(max_values[1], y)
|
||||
max_values[2] = max(max_values[2], z)
|
||||
normal = node_normals[source_node_index]
|
||||
normals.extend((float(normal[0]), float(normal[1]), float(normal[2])))
|
||||
barycentrics.extend(barycentric_triplet[local_vertex])
|
||||
edge_classes.extend(side_classes)
|
||||
primitive_indices.append((len(positions) // 3) - 1)
|
||||
face_runs_by_hash[face_hash] = (primitive_index, triangle_start, len(triangles))
|
||||
|
||||
|
||||
def prototype_glb_mesh_payload(
|
||||
prototype: Mapping[str, Any],
|
||||
*,
|
||||
default_color: ColorRGBA,
|
||||
face_colors: Mapping[int, ColorRGBA],
|
||||
include_surface_edges: bool = False,
|
||||
) -> ShapeGlbMeshPayload:
|
||||
positions = array("f")
|
||||
normals = array("f")
|
||||
barycentrics = array("f")
|
||||
edge_classes = array("B")
|
||||
primitive_indices_by_key: dict[tuple[int, int, int, int], tuple[int, ColorRGBA, array]] = {}
|
||||
face_runs_by_hash: dict[int, tuple[int, int, int]] = {}
|
||||
surface_half_edges_by_face_ordinal: dict[int, list[tuple[int, int, int, int, int]]] = {}
|
||||
min_values = [math.inf, math.inf, math.inf]
|
||||
max_values = [-math.inf, -math.inf, -math.inf]
|
||||
edge_surface_class_codes = {
|
||||
int(edge_entry.get("ordinal") or 0): int(edge_entry.get("surfaceClassCode") or 0)
|
||||
for edge_entry in prototype.get("edges", [])
|
||||
if isinstance(edge_entry, Mapping)
|
||||
}
|
||||
|
||||
for face_entry in prototype.get("faces", []):
|
||||
if not isinstance(face_entry, Mapping):
|
||||
continue
|
||||
face_hash = int(face_entry.get("shapeHash") or 0)
|
||||
face_ordinal = int(face_entry.get("ordinal") or 0)
|
||||
triangles = face_entry.get("triangles", ())
|
||||
nodes = face_entry.get("triangleNodes", ())
|
||||
node_normals = face_entry.get("triangleNormals", ())
|
||||
if not node_normals:
|
||||
normal = face_entry.get("normal") or (0.0, 0.0, 1.0)
|
||||
node_normals = [normal] * len(nodes)
|
||||
_append_face_payload(
|
||||
positions=positions,
|
||||
normals=normals,
|
||||
barycentrics=barycentrics,
|
||||
edge_classes=edge_classes,
|
||||
primitive_indices_by_key=primitive_indices_by_key,
|
||||
face_runs_by_hash=face_runs_by_hash,
|
||||
surface_half_edges_by_face_ordinal=surface_half_edges_by_face_ordinal,
|
||||
min_values=min_values,
|
||||
max_values=max_values,
|
||||
face_hash=face_hash,
|
||||
face_ordinal=face_ordinal,
|
||||
default_color=default_color,
|
||||
face_colors=face_colors,
|
||||
edge_side_ordinals=face_entry.get("edgeSideOrdinals") or {},
|
||||
edge_surface_class_codes=edge_surface_class_codes,
|
||||
include_surface_edges=include_surface_edges,
|
||||
nodes=nodes,
|
||||
node_normals=node_normals,
|
||||
triangles=triangles,
|
||||
)
|
||||
|
||||
if not positions or not all(math.isfinite(value) for value in min_values + max_values):
|
||||
return _empty_payload()
|
||||
primitives = [
|
||||
(primitive_color, primitive_indices)
|
||||
for _index, primitive_color, primitive_indices in sorted(
|
||||
primitive_indices_by_key.values(),
|
||||
key=lambda item: item[0],
|
||||
)
|
||||
]
|
||||
return ShapeGlbMeshPayload(
|
||||
positions=positions,
|
||||
normals=normals,
|
||||
barycentrics=barycentrics,
|
||||
edge_classes=edge_classes,
|
||||
primitives=primitives,
|
||||
minimum=min_values,
|
||||
maximum=max_values,
|
||||
face_runs_by_hash=face_runs_by_hash,
|
||||
surface_half_edges_by_face_ordinal=surface_half_edges_by_face_ordinal,
|
||||
)
|
||||
|
||||
|
||||
def shape_glb_mesh_payload(
|
||||
shape: object,
|
||||
*,
|
||||
default_color: ColorRGBA,
|
||||
face_colors: Mapping[int, ColorRGBA],
|
||||
include_surface_edges: bool = False,
|
||||
) -> ShapeGlbMeshPayload:
|
||||
face_entries: list[dict[str, Any]] = []
|
||||
explorer = TopExp_Explorer(shape, TopAbs_FACE)
|
||||
while explorer.More():
|
||||
face = TopoDS.Face_s(explorer.Current())
|
||||
location = TopLoc_Location()
|
||||
triangulation = BRep_Tool.Triangulation_s(face, location)
|
||||
if triangulation is None:
|
||||
explorer.Next()
|
||||
continue
|
||||
if not triangulation.HasNormals():
|
||||
triangulation.ComputeNormals()
|
||||
reversed_face = face.Orientation() == TopAbs_REVERSED
|
||||
nodes = [
|
||||
_transform_point_from_occ(triangulation.Node(index), location)
|
||||
for index in range(1, triangulation.NbNodes() + 1)
|
||||
]
|
||||
node_normals = [
|
||||
transform_normal_from_occ(triangulation.Normal(index), location, reversed_face=reversed_face)
|
||||
for index in range(1, triangulation.NbNodes() + 1)
|
||||
]
|
||||
triangles: list[tuple[int, int, int]] = []
|
||||
for index in range(1, triangulation.NbTriangles() + 1):
|
||||
node_a, node_b, node_c = triangulation.Triangle(index).Get()
|
||||
triangle = [node_a - 1, node_b - 1, node_c - 1]
|
||||
if reversed_face:
|
||||
triangle[1], triangle[2] = triangle[2], triangle[1]
|
||||
vertices = [nodes[node_index] for node_index in triangle]
|
||||
if _triangle_area_twice(vertices[0], vertices[1], vertices[2]) <= 1e-9:
|
||||
continue
|
||||
triangles.append((triangle[0], triangle[1], triangle[2]))
|
||||
if triangles:
|
||||
face_entries.append(
|
||||
{
|
||||
"shapeHash": hash(face),
|
||||
"triangleNodes": nodes,
|
||||
"triangleNormals": node_normals,
|
||||
"triangles": triangles,
|
||||
}
|
||||
)
|
||||
explorer.Next()
|
||||
return prototype_glb_mesh_payload(
|
||||
{"faces": face_entries},
|
||||
default_color=default_color,
|
||||
face_colors=face_colors,
|
||||
include_surface_edges=include_surface_edges,
|
||||
)
|
||||
|
||||
|
||||
def scene_glb_mesh_payload_key(
|
||||
scene: object,
|
||||
prototype_key: int,
|
||||
*,
|
||||
default_color: ColorRGBA,
|
||||
suppress_face_colors: bool,
|
||||
include_surface_edges: bool = False,
|
||||
surface_edge_class_signature: Sequence[str] | tuple[str, ...] = (),
|
||||
) -> tuple[object, ...]:
|
||||
return (
|
||||
int(prototype_key),
|
||||
color_key(normalize_rgba(default_color)),
|
||||
bool(suppress_face_colors),
|
||||
bool(include_surface_edges),
|
||||
tuple(str(item) for item in surface_edge_class_signature),
|
||||
getattr(scene, "mesh_signature", None),
|
||||
)
|
||||
|
||||
|
||||
def scene_glb_mesh_payload(
|
||||
scene: object,
|
||||
prototype_key: int,
|
||||
*,
|
||||
default_color: ColorRGBA,
|
||||
suppress_face_colors: bool,
|
||||
prototype: Mapping[str, Any] | None = None,
|
||||
include_surface_edges: bool = False,
|
||||
surface_edge_class_signature: Sequence[str] | tuple[str, ...] = (),
|
||||
) -> ShapeGlbMeshPayload:
|
||||
key = scene_glb_mesh_payload_key(
|
||||
scene,
|
||||
prototype_key,
|
||||
default_color=default_color,
|
||||
suppress_face_colors=suppress_face_colors,
|
||||
include_surface_edges=include_surface_edges,
|
||||
surface_edge_class_signature=surface_edge_class_signature,
|
||||
)
|
||||
cache = getattr(scene, "glb_mesh_payloads", None)
|
||||
if cache is None:
|
||||
cache = {}
|
||||
setattr(scene, "glb_mesh_payloads", cache)
|
||||
cached = cache.get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
face_colors = (
|
||||
{}
|
||||
if suppress_face_colors
|
||||
else getattr(scene, "prototype_face_colors", {}).get(prototype_key, {})
|
||||
)
|
||||
if prototype is not None:
|
||||
payload = prototype_glb_mesh_payload(
|
||||
prototype,
|
||||
default_color=normalize_rgba(default_color),
|
||||
face_colors=face_colors,
|
||||
include_surface_edges=include_surface_edges,
|
||||
)
|
||||
else:
|
||||
shape = getattr(scene, "prototype_shapes", {}).get(prototype_key)
|
||||
payload = (
|
||||
_empty_payload()
|
||||
if shape is None
|
||||
else shape_glb_mesh_payload(
|
||||
shape,
|
||||
default_color=normalize_rgba(default_color),
|
||||
face_colors=face_colors,
|
||||
include_surface_edges=include_surface_edges,
|
||||
)
|
||||
)
|
||||
cache[key] = payload
|
||||
return payload
|
||||
@@ -0,0 +1,499 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
from array import array
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from cadpy.selector_types import SelectorBundle
|
||||
|
||||
|
||||
STEP_TOPOLOGY_EXTENSION = "STEP_topology"
|
||||
STEP_TOPOLOGY_SCHEMA_VERSION = 2
|
||||
STEP_TOPOLOGY_EDGE_CLASSIFICATION_ALGORITHM = "oc-brep-continuity-v1"
|
||||
STEP_TOPOLOGY_SURFACE_EDGE_ALGORITHM = "oc-polygon-on-triangulation-v1"
|
||||
STEP_TOPOLOGY_EDGE_ANGULAR_TOLERANCE_DEG = 2
|
||||
STEP_TOPOLOGY_EDGE_SAMPLE_COUNT = 3
|
||||
STEP_EDGE_BARYCENTRIC_ATTRIBUTE = "_CAD_EDGE_BARYCENTRIC"
|
||||
STEP_EDGE_CLASS_ATTRIBUTE = "_CAD_EDGE_CLASS"
|
||||
STEP_EDGE_FLAGS = {
|
||||
"DEGENERATE": 1 << 1,
|
||||
"SEAM": 1 << 2,
|
||||
"NOT_REFERENCEABLE": 1 << 3,
|
||||
"BOUNDARY": 1 << 4,
|
||||
"NON_MANIFOLD": 1 << 5,
|
||||
"HARD": 1 << 6,
|
||||
"TANGENT": 1 << 7,
|
||||
"UNKNOWN_CONTINUITY": 1 << 8,
|
||||
}
|
||||
STEP_EDGE_VISIBILITY_CLASSES = {
|
||||
"FEATURE": "feature",
|
||||
"TANGENT": "tangent",
|
||||
"SEAM": "seam",
|
||||
"DEGENERATE": "degenerate",
|
||||
"BOUNDARY": "boundary",
|
||||
"NON_MANIFOLD": "nonManifold",
|
||||
"UNKNOWN": "unknown",
|
||||
}
|
||||
STEP_EDGE_RENDER_VISIBILITY_CLASSES = (
|
||||
STEP_EDGE_VISIBILITY_CLASSES["FEATURE"],
|
||||
STEP_EDGE_VISIBILITY_CLASSES["TANGENT"],
|
||||
STEP_EDGE_VISIBILITY_CLASSES["SEAM"],
|
||||
STEP_EDGE_VISIBILITY_CLASSES["DEGENERATE"],
|
||||
)
|
||||
STEP_EDGE_DEFAULT_RENDER_VISIBILITY_CLASSES = STEP_EDGE_RENDER_VISIBILITY_CLASSES
|
||||
STEP_EDGE_SURFACE_CLASS_CODES = {
|
||||
"none": 0,
|
||||
"feature": 1,
|
||||
"tangent": 2,
|
||||
"seam": 3,
|
||||
"degenerate": 4,
|
||||
"boundary": 5,
|
||||
"nonManifold": 6,
|
||||
"unknown": 7,
|
||||
}
|
||||
STEP_SURFACE_HALF_EDGE_COLUMNS = (
|
||||
"edgeRow",
|
||||
"faceRow",
|
||||
"occurrenceRow",
|
||||
"primitiveIndex",
|
||||
"triangleIndex",
|
||||
"side",
|
||||
"classCode",
|
||||
)
|
||||
GLB_MAGIC = 0x46546C67
|
||||
GLB_VERSION = 2
|
||||
REPO_ROOT = Path.cwd().resolve()
|
||||
UNSIGNED_BYTE = 5121
|
||||
|
||||
|
||||
def _display_path(path: Path) -> str:
|
||||
resolved = path.resolve()
|
||||
try:
|
||||
return resolved.relative_to(REPO_ROOT).as_posix()
|
||||
except ValueError:
|
||||
return resolved.as_posix()
|
||||
|
||||
|
||||
def _parse_glb_header(path: Path, payload: bytes) -> tuple[int, int]:
|
||||
if len(payload) < 20:
|
||||
raise ValueError(f"Not a GLB file: {_display_path(path)}")
|
||||
magic, version, length = struct.unpack_from("<III", payload, 0)
|
||||
if magic != GLB_MAGIC or version != GLB_VERSION or length > len(payload):
|
||||
raise ValueError(f"Not a GLB v2 file: {_display_path(path)}")
|
||||
return version, length
|
||||
|
||||
|
||||
def _read_glb_chunks(path: Path) -> tuple[dict[str, Any], bytes]:
|
||||
payload = path.expanduser().resolve().read_bytes()
|
||||
_, length = _parse_glb_header(path, payload)
|
||||
offset = 12
|
||||
json_payload: bytes | None = None
|
||||
binary_payload = b""
|
||||
while offset + 8 <= length:
|
||||
chunk_length, chunk_type = struct.unpack_from("<I4s", payload, offset)
|
||||
offset += 8
|
||||
chunk = payload[offset : offset + chunk_length]
|
||||
offset += chunk_length
|
||||
if chunk_type == b"JSON":
|
||||
json_payload = chunk
|
||||
elif chunk_type == b"BIN\0":
|
||||
binary_payload = chunk
|
||||
if json_payload is None:
|
||||
raise ValueError(f"GLB is missing JSON chunk: {_display_path(path)}")
|
||||
gltf = json.loads(json_payload.decode("utf-8").rstrip(" \t\r\n\0"))
|
||||
if not isinstance(gltf, dict):
|
||||
raise ValueError(f"GLB JSON chunk is not an object: {_display_path(path)}")
|
||||
return gltf, binary_payload
|
||||
|
||||
|
||||
def _read_glb_json_and_bin_location(path: Path) -> tuple[dict[str, Any], int, int]:
|
||||
resolved = path.expanduser().resolve()
|
||||
with resolved.open("rb") as handle:
|
||||
header = handle.read(12)
|
||||
if len(header) != 12:
|
||||
raise ValueError(f"Not a GLB file: {_display_path(path)}")
|
||||
magic, version, length = struct.unpack("<III", header)
|
||||
if magic != GLB_MAGIC or version != GLB_VERSION:
|
||||
raise ValueError(f"Not a GLB v2 file: {_display_path(path)}")
|
||||
file_size = resolved.stat().st_size
|
||||
if length > file_size:
|
||||
raise ValueError(f"Not a GLB v2 file: {_display_path(path)}")
|
||||
offset = 12
|
||||
json_payload: bytes | None = None
|
||||
binary_offset = 0
|
||||
binary_length = 0
|
||||
while offset + 8 <= length:
|
||||
handle.seek(offset)
|
||||
chunk_header = handle.read(8)
|
||||
if len(chunk_header) != 8:
|
||||
break
|
||||
chunk_length, chunk_type = struct.unpack("<I4s", chunk_header)
|
||||
offset += 8
|
||||
if offset + chunk_length > length:
|
||||
raise ValueError(f"Invalid GLB chunk length: {_display_path(path)}")
|
||||
if chunk_type == b"JSON":
|
||||
json_payload = handle.read(chunk_length)
|
||||
elif chunk_type == b"BIN\0":
|
||||
binary_offset = offset
|
||||
binary_length = chunk_length
|
||||
handle.seek(chunk_length, os.SEEK_CUR)
|
||||
else:
|
||||
handle.seek(chunk_length, os.SEEK_CUR)
|
||||
offset += chunk_length
|
||||
if json_payload is None:
|
||||
raise ValueError(f"GLB is missing JSON chunk: {_display_path(path)}")
|
||||
gltf = json.loads(json_payload.decode("utf-8").rstrip(" \t\r\n\0"))
|
||||
if not isinstance(gltf, dict):
|
||||
raise ValueError(f"GLB JSON chunk is not an object: {_display_path(path)}")
|
||||
return gltf, binary_offset, binary_length
|
||||
|
||||
|
||||
def _buffer_view_range(gltf: Mapping[str, Any], binary_offset: int, binary_length: int, view_index: object) -> tuple[int, int]:
|
||||
if not isinstance(view_index, int):
|
||||
raise ValueError("GLB bufferView index must be an integer")
|
||||
buffer_views = gltf.get("bufferViews")
|
||||
if not isinstance(buffer_views, list) or not (0 <= view_index < len(buffer_views)):
|
||||
raise ValueError(f"GLB bufferView index is out of range: {view_index}")
|
||||
view = buffer_views[view_index]
|
||||
if not isinstance(view, Mapping):
|
||||
raise ValueError(f"GLB bufferView is not an object: {view_index}")
|
||||
if int(view.get("buffer") or 0) != 0:
|
||||
raise ValueError("STEP topology only supports GLB buffer 0")
|
||||
byte_offset = int(view.get("byteOffset") or 0)
|
||||
byte_length = int(view.get("byteLength") or 0)
|
||||
if byte_offset < 0 or byte_length < 0 or byte_offset + byte_length > binary_length:
|
||||
raise ValueError(f"GLB bufferView range is invalid: {view_index}")
|
||||
return binary_offset + byte_offset, byte_length
|
||||
|
||||
|
||||
def _read_file_range(path: Path, byte_offset: int, byte_length: int) -> bytes:
|
||||
with path.expanduser().resolve().open("rb") as handle:
|
||||
handle.seek(byte_offset)
|
||||
payload = handle.read(byte_length)
|
||||
if len(payload) != byte_length:
|
||||
raise ValueError("GLB bufferView range is invalid")
|
||||
return payload
|
||||
|
||||
|
||||
def _buffer_view_bytes(gltf: Mapping[str, Any], binary_payload: bytes, view_index: object) -> bytes:
|
||||
byte_offset, byte_length = _buffer_view_range(gltf, 0, len(binary_payload), view_index)
|
||||
return binary_payload[byte_offset : byte_offset + byte_length]
|
||||
|
||||
|
||||
def _array_from_view(gltf: Mapping[str, Any], binary_payload: bytes, view: Mapping[str, Any]) -> array:
|
||||
dtype = str(view.get("dtype") or "")
|
||||
typecode = "f" if dtype == "float32" else "I" if dtype == "uint32" else ""
|
||||
if not typecode:
|
||||
raise ValueError(f"Unsupported STEP topology buffer dtype: {dtype}")
|
||||
raw = _buffer_view_bytes(gltf, binary_payload, view.get("bufferView"))
|
||||
byte_offset = int(view.get("byteOffset") or 0)
|
||||
count = int(view.get("count") or 0)
|
||||
item_size = int(view.get("itemSize") or array(typecode).itemsize)
|
||||
byte_length = int(view.get("byteLength") or (count * item_size))
|
||||
if byte_offset < 0 or byte_length < 0 or byte_offset + byte_length > len(raw):
|
||||
raise ValueError("STEP topology buffer view range is invalid")
|
||||
values = array(typecode)
|
||||
values.frombytes(raw[byte_offset : byte_offset + byte_length])
|
||||
if count >= 0 and len(values) > count:
|
||||
del values[count:]
|
||||
return values
|
||||
|
||||
|
||||
def _array_from_legacy_binary_view(binary_payload: bytes, view: Mapping[str, Any]) -> array:
|
||||
dtype = str(view.get("dtype") or "")
|
||||
typecode = "f" if dtype == "float32" else "I" if dtype == "uint32" else ""
|
||||
if not typecode:
|
||||
raise ValueError(f"Unsupported STEP topology buffer dtype: {dtype}")
|
||||
byte_offset = int(view.get("byteOffset") or 0)
|
||||
count = int(view.get("count") or 0)
|
||||
item_size = int(view.get("itemSize") or array(typecode).itemsize)
|
||||
byte_length = int(view.get("byteLength") or (count * item_size))
|
||||
if byte_offset < 0 or byte_length < 0 or byte_offset + byte_length > len(binary_payload):
|
||||
raise ValueError("STEP topology buffer view range is invalid")
|
||||
values = array(typecode)
|
||||
values.frombytes(binary_payload[byte_offset : byte_offset + byte_length])
|
||||
if count >= 0 and len(values) > count:
|
||||
del values[count:]
|
||||
return values
|
||||
|
||||
|
||||
def _step_topology_extension(gltf: Mapping[str, Any]) -> Mapping[str, Any] | None:
|
||||
extensions = gltf.get("extensions")
|
||||
extension = extensions.get(STEP_TOPOLOGY_EXTENSION) if isinstance(extensions, Mapping) else None
|
||||
if not isinstance(extension, Mapping):
|
||||
return None
|
||||
if str(extension.get("encoding") or "utf-8").lower() != "utf-8":
|
||||
return None
|
||||
return extension
|
||||
|
||||
|
||||
def normalize_step_edge_render_visibility_classes(value: object) -> tuple[str, ...]:
|
||||
if value is None:
|
||||
return STEP_EDGE_DEFAULT_RENDER_VISIBILITY_CLASSES
|
||||
raw_values = value if isinstance(value, (list, tuple, set, frozenset)) else [value]
|
||||
valid_values = set(STEP_EDGE_VISIBILITY_CLASSES.values())
|
||||
normalized: list[str] = []
|
||||
for raw in raw_values:
|
||||
normalized_value = str(raw or "").strip()
|
||||
if normalized_value in valid_values and normalized_value not in normalized:
|
||||
normalized.append(normalized_value)
|
||||
if STEP_EDGE_VISIBILITY_CLASSES["FEATURE"] not in normalized:
|
||||
normalized.insert(0, STEP_EDGE_VISIBILITY_CLASSES["FEATURE"])
|
||||
ordered = [
|
||||
class_id
|
||||
for class_id in STEP_EDGE_RENDER_VISIBILITY_CLASSES
|
||||
if class_id in normalized
|
||||
]
|
||||
extras = [
|
||||
class_id
|
||||
for class_id in normalized
|
||||
if class_id not in STEP_EDGE_RENDER_VISIBILITY_CLASSES
|
||||
]
|
||||
return tuple(ordered + extras)
|
||||
|
||||
|
||||
def step_topology_capabilities(
|
||||
edge_visibility_classes: object = None,
|
||||
) -> dict[str, Any]:
|
||||
visibility_classes = normalize_step_edge_render_visibility_classes(edge_visibility_classes)
|
||||
return {
|
||||
"edgeClassification": {
|
||||
"algorithm": STEP_TOPOLOGY_EDGE_CLASSIFICATION_ALGORITHM,
|
||||
"angularToleranceDeg": STEP_TOPOLOGY_EDGE_ANGULAR_TOLERANCE_DEG,
|
||||
"samples": STEP_TOPOLOGY_EDGE_SAMPLE_COUNT,
|
||||
},
|
||||
"surfaceEdgeRendering": {
|
||||
"algorithm": STEP_TOPOLOGY_SURFACE_EDGE_ALGORITHM,
|
||||
"primitiveAttributes": {
|
||||
"barycentric": STEP_EDGE_BARYCENTRIC_ATTRIBUTE,
|
||||
"class": STEP_EDGE_CLASS_ATTRIBUTE,
|
||||
},
|
||||
"classCodes": STEP_EDGE_SURFACE_CLASS_CODES,
|
||||
"visibilityClasses": list(visibility_classes),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def step_edge_surface_class_code(
|
||||
edge: Mapping[str, Any],
|
||||
*,
|
||||
enabled_visibility_classes: object = None,
|
||||
) -> int:
|
||||
flags = int(edge.get("flags") or 0)
|
||||
visibility_class = str(edge.get("visibilityClass") or "").strip()
|
||||
if enabled_visibility_classes is not None:
|
||||
enabled = set(normalize_step_edge_render_visibility_classes(enabled_visibility_classes))
|
||||
if visibility_class not in enabled:
|
||||
return STEP_EDGE_SURFACE_CLASS_CODES["none"]
|
||||
if flags & STEP_EDGE_FLAGS["DEGENERATE"] or visibility_class == STEP_EDGE_VISIBILITY_CLASSES["DEGENERATE"]:
|
||||
return STEP_EDGE_SURFACE_CLASS_CODES["degenerate"]
|
||||
if flags & STEP_EDGE_FLAGS["SEAM"] or visibility_class == STEP_EDGE_VISIBILITY_CLASSES["SEAM"]:
|
||||
return STEP_EDGE_SURFACE_CLASS_CODES["seam"]
|
||||
if flags & STEP_EDGE_FLAGS["BOUNDARY"] or visibility_class == STEP_EDGE_VISIBILITY_CLASSES["BOUNDARY"]:
|
||||
return STEP_EDGE_SURFACE_CLASS_CODES["boundary"]
|
||||
if flags & STEP_EDGE_FLAGS["NON_MANIFOLD"] or visibility_class == STEP_EDGE_VISIBILITY_CLASSES["NON_MANIFOLD"]:
|
||||
return STEP_EDGE_SURFACE_CLASS_CODES["nonManifold"]
|
||||
if flags & STEP_EDGE_FLAGS["UNKNOWN_CONTINUITY"] or visibility_class == STEP_EDGE_VISIBILITY_CLASSES["UNKNOWN"]:
|
||||
return STEP_EDGE_SURFACE_CLASS_CODES["unknown"]
|
||||
if flags & STEP_EDGE_FLAGS["TANGENT"] or visibility_class == STEP_EDGE_VISIBILITY_CLASSES["TANGENT"]:
|
||||
return STEP_EDGE_SURFACE_CLASS_CODES["tangent"]
|
||||
if flags & STEP_EDGE_FLAGS["HARD"] or visibility_class == STEP_EDGE_VISIBILITY_CLASSES["FEATURE"]:
|
||||
return STEP_EDGE_SURFACE_CLASS_CODES["feature"]
|
||||
return STEP_EDGE_SURFACE_CLASS_CODES["feature"]
|
||||
|
||||
|
||||
def is_displayable_step_edge_surface_class_code(value: object) -> bool:
|
||||
try:
|
||||
code = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return code not in {STEP_EDGE_SURFACE_CLASS_CODES["none"], STEP_EDGE_SURFACE_CLASS_CODES["degenerate"]}
|
||||
|
||||
|
||||
def _legacy_topology_manifest_path_for_glb(glb_path: Path) -> Path | None:
|
||||
resolved = glb_path.expanduser().resolve()
|
||||
if resolved.name != "model.glb":
|
||||
return None
|
||||
return resolved.parent / "topology.json"
|
||||
|
||||
|
||||
def _read_legacy_topology_manifest(glb_path: Path) -> dict[str, Any] | None:
|
||||
manifest_path = _legacy_topology_manifest_path_for_glb(glb_path)
|
||||
if manifest_path is None or not manifest_path.is_file():
|
||||
return None
|
||||
try:
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
return manifest if isinstance(manifest, dict) else None
|
||||
|
||||
|
||||
def _read_legacy_topology_bundle(glb_path: Path) -> SelectorBundle | None:
|
||||
manifest = _read_legacy_topology_manifest(glb_path)
|
||||
if manifest is None:
|
||||
return None
|
||||
buffers: dict[str, array] = {}
|
||||
buffer_spec = manifest.get("buffers")
|
||||
views = buffer_spec.get("views") if isinstance(buffer_spec, Mapping) else None
|
||||
uri = str(buffer_spec.get("uri") or "") if isinstance(buffer_spec, Mapping) else ""
|
||||
if isinstance(views, Mapping) and uri:
|
||||
manifest_path = _legacy_topology_manifest_path_for_glb(glb_path)
|
||||
bin_path = manifest_path.parent / uri if manifest_path is not None else Path(uri)
|
||||
try:
|
||||
binary_payload = bin_path.read_bytes()
|
||||
except OSError:
|
||||
binary_payload = b""
|
||||
if binary_payload:
|
||||
for name, view in views.items():
|
||||
if not isinstance(view, Mapping):
|
||||
continue
|
||||
try:
|
||||
buffers[str(name)] = _array_from_legacy_binary_view(binary_payload, view)
|
||||
except ValueError:
|
||||
continue
|
||||
return SelectorBundle(manifest=manifest, buffers=buffers)
|
||||
|
||||
|
||||
def read_step_topology_bundle_from_glb(glb_path: Path) -> SelectorBundle | None:
|
||||
try:
|
||||
gltf, binary_payload = _read_glb_chunks(glb_path)
|
||||
except (OSError, ValueError, json.JSONDecodeError):
|
||||
return _read_legacy_topology_bundle(glb_path)
|
||||
extension = _step_topology_extension(gltf)
|
||||
if extension is None:
|
||||
return _read_legacy_topology_bundle(glb_path)
|
||||
try:
|
||||
manifest_bytes = _buffer_view_bytes(gltf, binary_payload, extension.get("selectorView"))
|
||||
manifest = json.loads(manifest_bytes.decode("utf-8"))
|
||||
except (ValueError, UnicodeDecodeError, json.JSONDecodeError):
|
||||
return _read_legacy_topology_bundle(glb_path)
|
||||
if not isinstance(manifest, dict):
|
||||
return None
|
||||
buffers: dict[str, array] = {}
|
||||
views = manifest.get("buffers", {}).get("views") if isinstance(manifest.get("buffers"), Mapping) else None
|
||||
if isinstance(views, Mapping):
|
||||
for name, view in views.items():
|
||||
if not isinstance(view, Mapping):
|
||||
continue
|
||||
try:
|
||||
buffers[str(name)] = _array_from_view(gltf, binary_payload, view)
|
||||
except ValueError:
|
||||
continue
|
||||
return SelectorBundle(manifest=manifest, buffers=buffers)
|
||||
|
||||
|
||||
def read_step_topology_index_from_glb(glb_path: Path) -> dict[str, Any] | None:
|
||||
try:
|
||||
gltf, binary_offset, binary_length = _read_glb_json_and_bin_location(glb_path)
|
||||
except (OSError, ValueError, json.JSONDecodeError):
|
||||
return _read_legacy_topology_manifest(glb_path)
|
||||
extension = _step_topology_extension(gltf)
|
||||
if extension is None:
|
||||
return _read_legacy_topology_manifest(glb_path)
|
||||
try:
|
||||
manifest_offset, manifest_length = _buffer_view_range(
|
||||
gltf,
|
||||
binary_offset,
|
||||
binary_length,
|
||||
extension.get("indexView"),
|
||||
)
|
||||
manifest = json.loads(_read_file_range(glb_path, manifest_offset, manifest_length).decode("utf-8"))
|
||||
except (ValueError, UnicodeDecodeError, json.JSONDecodeError, OSError):
|
||||
return None
|
||||
return manifest if isinstance(manifest, dict) else None
|
||||
|
||||
|
||||
def read_step_display_edge_manifest_from_glb(glb_path: Path) -> dict[str, Any] | None:
|
||||
try:
|
||||
gltf, binary_offset, binary_length = _read_glb_json_and_bin_location(glb_path)
|
||||
except (OSError, ValueError, json.JSONDecodeError):
|
||||
return None
|
||||
extension = _step_topology_extension(gltf)
|
||||
if extension is None:
|
||||
return None
|
||||
try:
|
||||
manifest_offset, manifest_length = _buffer_view_range(
|
||||
gltf,
|
||||
binary_offset,
|
||||
binary_length,
|
||||
extension.get("edgeView", extension.get("displayEdgeView")),
|
||||
)
|
||||
manifest = json.loads(_read_file_range(glb_path, manifest_offset, manifest_length).decode("utf-8"))
|
||||
except (ValueError, UnicodeDecodeError, json.JSONDecodeError, OSError):
|
||||
return None
|
||||
return manifest if isinstance(manifest, dict) else None
|
||||
|
||||
|
||||
def read_step_topology_manifest_from_glb(glb_path: Path) -> dict[str, Any] | None:
|
||||
return read_step_topology_index_from_glb(glb_path)
|
||||
|
||||
|
||||
def glb_primitives_have_surface_edge_attributes(glb_path: Path) -> bool:
|
||||
try:
|
||||
gltf, _binary_offset, _binary_length = _read_glb_json_and_bin_location(glb_path)
|
||||
except (OSError, ValueError, json.JSONDecodeError):
|
||||
return False
|
||||
primitive_count = 0
|
||||
meshes = gltf.get("meshes")
|
||||
if not isinstance(meshes, list):
|
||||
return False
|
||||
for mesh in meshes:
|
||||
primitives = mesh.get("primitives") if isinstance(mesh, Mapping) else None
|
||||
if not isinstance(primitives, list):
|
||||
continue
|
||||
for primitive in primitives:
|
||||
primitive_count += 1
|
||||
attributes = primitive.get("attributes") if isinstance(primitive, Mapping) else None
|
||||
if not isinstance(attributes, Mapping):
|
||||
return False
|
||||
if STEP_EDGE_BARYCENTRIC_ATTRIBUTE not in attributes or STEP_EDGE_CLASS_ATTRIBUTE not in attributes:
|
||||
return False
|
||||
return primitive_count > 0
|
||||
|
||||
|
||||
def glb_surface_edge_class_has_nonzero_values(glb_path: Path) -> bool:
|
||||
try:
|
||||
gltf, binary_offset, binary_length = _read_glb_json_and_bin_location(glb_path)
|
||||
except (OSError, ValueError, json.JSONDecodeError):
|
||||
return False
|
||||
meshes = gltf.get("meshes")
|
||||
accessors = gltf.get("accessors")
|
||||
if not isinstance(meshes, list) or not isinstance(accessors, list):
|
||||
return False
|
||||
for mesh in meshes:
|
||||
primitives = mesh.get("primitives") if isinstance(mesh, Mapping) else None
|
||||
if not isinstance(primitives, list):
|
||||
continue
|
||||
for primitive in primitives:
|
||||
attributes = primitive.get("attributes") if isinstance(primitive, Mapping) else None
|
||||
accessor_index = attributes.get(STEP_EDGE_CLASS_ATTRIBUTE) if isinstance(attributes, Mapping) else None
|
||||
if not isinstance(accessor_index, int) or accessor_index < 0 or accessor_index >= len(accessors):
|
||||
continue
|
||||
accessor = accessors[accessor_index]
|
||||
if not isinstance(accessor, Mapping):
|
||||
continue
|
||||
if int(accessor.get("componentType") or 0) != UNSIGNED_BYTE or accessor.get("type") != "VEC3":
|
||||
continue
|
||||
try:
|
||||
view_offset, view_length = _buffer_view_range(
|
||||
gltf,
|
||||
binary_offset,
|
||||
binary_length,
|
||||
accessor.get("bufferView"),
|
||||
)
|
||||
except ValueError:
|
||||
continue
|
||||
byte_offset = int(accessor.get("byteOffset") or 0)
|
||||
count = int(accessor.get("count") or 0) * 3
|
||||
if byte_offset < 0 or count <= 0 or byte_offset + count > view_length:
|
||||
continue
|
||||
try:
|
||||
if any(_read_file_range(glb_path, view_offset + byte_offset, count)):
|
||||
return True
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
return False
|
||||
@@ -0,0 +1,243 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from cadpy import cad_ref_syntax as syntax
|
||||
|
||||
|
||||
def _table_rows(manifest: dict[str, Any], table_name: str, columns_name: str) -> list[dict[str, Any]]:
|
||||
columns = manifest.get("tables", {}).get(columns_name)
|
||||
rows = manifest.get(table_name)
|
||||
if not isinstance(columns, list) or not isinstance(rows, list):
|
||||
return []
|
||||
materialized: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
if not isinstance(row, list):
|
||||
continue
|
||||
materialized.append({str(columns[index]): row[index] for index in range(min(len(columns), len(row)))})
|
||||
return materialized
|
||||
|
||||
|
||||
def _buffer_rows(buffers: Mapping[str, Any] | None, view_name: object) -> list[int]:
|
||||
if not isinstance(view_name, str) or buffers is None:
|
||||
return []
|
||||
values = buffers.get(view_name)
|
||||
if values is None:
|
||||
return []
|
||||
try:
|
||||
return [int(value) for value in values]
|
||||
except (TypeError, ValueError):
|
||||
return []
|
||||
|
||||
|
||||
def _relations(manifest: dict[str, Any], buffers: Mapping[str, Any] | None = None) -> dict[str, list[int]]:
|
||||
relations = manifest.get("relations")
|
||||
if not isinstance(relations, dict):
|
||||
return {"faceEdgeRows": [], "edgeFaceRows": [], "edgeVertexRows": [], "vertexEdgeRows": []}
|
||||
|
||||
def rows(name: str) -> list[int]:
|
||||
inline = relations.get(name)
|
||||
if isinstance(inline, list):
|
||||
try:
|
||||
return [int(value) for value in inline]
|
||||
except (TypeError, ValueError):
|
||||
return []
|
||||
return _buffer_rows(buffers, relations.get(f"{name}View"))
|
||||
|
||||
return {
|
||||
"faceEdgeRows": rows("faceEdgeRows"),
|
||||
"edgeFaceRows": rows("edgeFaceRows"),
|
||||
"edgeVertexRows": rows("edgeVertexRows"),
|
||||
"vertexEdgeRows": rows("vertexEdgeRows"),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SelectorIndex:
|
||||
manifest: dict[str, Any]
|
||||
occurrences: list[dict[str, Any]]
|
||||
shapes: list[dict[str, Any]]
|
||||
faces: list[dict[str, Any]]
|
||||
edges: list[dict[str, Any]]
|
||||
vertices: list[dict[str, Any]]
|
||||
relations: dict[str, list[int]]
|
||||
occurrence_by_id: dict[str, dict[str, Any]]
|
||||
shape_by_id: dict[str, dict[str, Any]]
|
||||
face_by_id: dict[str, dict[str, Any]]
|
||||
edge_by_id: dict[str, dict[str, Any]]
|
||||
vertex_by_id: dict[str, dict[str, Any]]
|
||||
single_occurrence_id: str
|
||||
leaf_occurrence_ids: tuple[str, ...]
|
||||
|
||||
|
||||
def build_selector_index(manifest: dict[str, Any], *, buffers: Mapping[str, Any] | None = None) -> SelectorIndex:
|
||||
occurrences = _table_rows(manifest, "occurrences", "occurrenceColumns")
|
||||
shapes = _table_rows(manifest, "shapes", "shapeColumns")
|
||||
faces = _table_rows(manifest, "faces", "faceColumns")
|
||||
edges = _table_rows(manifest, "edges", "edgeColumns")
|
||||
vertices = _table_rows(manifest, "vertices", "vertexColumns")
|
||||
relations = _relations(manifest, buffers=buffers)
|
||||
|
||||
occurrence_by_id = {str(row.get("id")): row for row in occurrences if row.get("id")}
|
||||
shape_by_id = {str(row.get("id")): row for row in shapes if row.get("id")}
|
||||
face_by_id = {str(row.get("id")): row for row in faces if row.get("id")}
|
||||
edge_by_id = {str(row.get("id")): row for row in edges if row.get("id")}
|
||||
vertex_by_id = {str(row.get("id")): row for row in vertices if row.get("id")}
|
||||
|
||||
leaf_occurrence_ids = tuple(
|
||||
sorted(
|
||||
{
|
||||
str(row.get("occurrenceId"))
|
||||
for row in shapes
|
||||
if isinstance(row.get("occurrenceId"), str) and row.get("occurrenceId")
|
||||
}
|
||||
)
|
||||
)
|
||||
single_occurrence_id = leaf_occurrence_ids[0] if len(leaf_occurrence_ids) == 1 else ""
|
||||
|
||||
return SelectorIndex(
|
||||
manifest=manifest,
|
||||
occurrences=occurrences,
|
||||
shapes=shapes,
|
||||
faces=faces,
|
||||
edges=edges,
|
||||
vertices=vertices,
|
||||
relations=relations,
|
||||
occurrence_by_id=occurrence_by_id,
|
||||
shape_by_id=shape_by_id,
|
||||
face_by_id=face_by_id,
|
||||
edge_by_id=edge_by_id,
|
||||
vertex_by_id=vertex_by_id,
|
||||
single_occurrence_id=single_occurrence_id,
|
||||
leaf_occurrence_ids=leaf_occurrence_ids,
|
||||
)
|
||||
|
||||
|
||||
def entry_summary(index: SelectorIndex) -> dict[str, Any]:
|
||||
stats = index.manifest.get("stats") if isinstance(index.manifest.get("stats"), dict) else {}
|
||||
bbox = index.manifest.get("bbox") if isinstance(index.manifest.get("bbox"), dict) else None
|
||||
return {
|
||||
"kind": "part",
|
||||
"occurrenceCount": int(stats.get("occurrenceCount") or len(index.occurrences)),
|
||||
"leafOccurrenceCount": int(stats.get("leafOccurrenceCount") or len(index.leaf_occurrence_ids)),
|
||||
"shapeCount": int(stats.get("shapeCount") or len(index.shapes)),
|
||||
"faceCount": int(stats.get("faceCount") or len(index.faces)),
|
||||
"edgeCount": int(stats.get("edgeCount") or len(index.edges)),
|
||||
"vertexCount": int(stats.get("vertexCount") or len(index.vertices)),
|
||||
"bounds": bbox,
|
||||
}
|
||||
|
||||
|
||||
def canonicalize_selector(raw_selector: str, index: SelectorIndex) -> str | None:
|
||||
parsed = syntax.parse_selector(raw_selector)
|
||||
if parsed is None:
|
||||
return None
|
||||
if parsed.selector_type == "opaque":
|
||||
return parsed.canonical
|
||||
if parsed.occurrence_id:
|
||||
return parsed.canonical
|
||||
if not index.single_occurrence_id:
|
||||
return None
|
||||
if parsed.selector_type == "shape":
|
||||
return f"{index.single_occurrence_id}.s{parsed.ordinal}"
|
||||
if parsed.selector_type == "face":
|
||||
return f"{index.single_occurrence_id}.f{parsed.ordinal}"
|
||||
if parsed.selector_type == "edge":
|
||||
return f"{index.single_occurrence_id}.e{parsed.ordinal}"
|
||||
if parsed.selector_type == "vertex":
|
||||
return f"{index.single_occurrence_id}.v{parsed.ordinal}"
|
||||
return parsed.canonical
|
||||
|
||||
|
||||
def display_selector(selector: str, index: SelectorIndex) -> str:
|
||||
if not selector or not index.single_occurrence_id:
|
||||
return selector
|
||||
prefix = f"{index.single_occurrence_id}."
|
||||
if selector.startswith(prefix):
|
||||
tail = selector[len(prefix) :]
|
||||
if tail.startswith(("s", "f", "e", "v")):
|
||||
return tail
|
||||
return selector
|
||||
|
||||
|
||||
def lookup_selector(selector: str, index: SelectorIndex) -> tuple[str, dict[str, Any]] | None:
|
||||
normalized = canonicalize_selector(selector, index)
|
||||
if not normalized:
|
||||
return None
|
||||
if normalized in index.occurrence_by_id:
|
||||
return "occurrence", index.occurrence_by_id[normalized]
|
||||
if normalized in index.shape_by_id:
|
||||
return "shape", index.shape_by_id[normalized]
|
||||
if normalized in index.face_by_id:
|
||||
return "face", index.face_by_id[normalized]
|
||||
if normalized in index.edge_by_id:
|
||||
return "edge", index.edge_by_id[normalized]
|
||||
if normalized in index.vertex_by_id:
|
||||
return "vertex", index.vertex_by_id[normalized]
|
||||
return None
|
||||
|
||||
|
||||
def face_adjacent_edge_selectors(face_row: dict[str, Any], index: SelectorIndex) -> list[str]:
|
||||
start = int(face_row.get("edgeStart") or 0)
|
||||
count = int(face_row.get("edgeCount") or 0)
|
||||
selectors: list[str] = []
|
||||
for edge_row_index in index.relations.get("faceEdgeRows", [])[start : start + count]:
|
||||
if 0 <= int(edge_row_index) < len(index.edges):
|
||||
selectors.append(str(index.edges[int(edge_row_index)].get("id")))
|
||||
return selectors
|
||||
|
||||
|
||||
def edge_adjacent_face_selectors(edge_row: dict[str, Any], index: SelectorIndex) -> list[str]:
|
||||
start = int(edge_row.get("faceStart") or 0)
|
||||
count = int(edge_row.get("faceCount") or 0)
|
||||
selectors: list[str] = []
|
||||
for face_row_index in index.relations.get("edgeFaceRows", [])[start : start + count]:
|
||||
if 0 <= int(face_row_index) < len(index.faces):
|
||||
selectors.append(str(index.faces[int(face_row_index)].get("id")))
|
||||
return selectors
|
||||
|
||||
|
||||
def edge_adjacent_vertex_selectors(edge_row: dict[str, Any], index: SelectorIndex) -> list[str]:
|
||||
start = int(edge_row.get("vertexStart") or 0)
|
||||
count = int(edge_row.get("vertexCount") or 0)
|
||||
selectors: list[str] = []
|
||||
for vertex_row_index in index.relations.get("edgeVertexRows", [])[start : start + count]:
|
||||
if 0 <= int(vertex_row_index) < len(index.vertices):
|
||||
selectors.append(str(index.vertices[int(vertex_row_index)].get("id")))
|
||||
return selectors
|
||||
|
||||
|
||||
def vertex_adjacent_edge_selectors(vertex_row: dict[str, Any], index: SelectorIndex) -> list[str]:
|
||||
start = int(vertex_row.get("edgeStart") or 0)
|
||||
count = int(vertex_row.get("edgeCount") or 0)
|
||||
selectors: list[str] = []
|
||||
for edge_row_index in index.relations.get("vertexEdgeRows", [])[start : start + count]:
|
||||
if 0 <= int(edge_row_index) < len(index.edges):
|
||||
selectors.append(str(index.edges[int(edge_row_index)].get("id")))
|
||||
return selectors
|
||||
|
||||
|
||||
def vertex_adjacent_face_selectors(vertex_row: dict[str, Any], index: SelectorIndex) -> list[str]:
|
||||
selectors: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for edge_selector in vertex_adjacent_edge_selectors(vertex_row, index):
|
||||
edge_row = index.edge_by_id.get(edge_selector)
|
||||
if edge_row is None:
|
||||
continue
|
||||
for face_selector in edge_adjacent_face_selectors(edge_row, index):
|
||||
if face_selector not in seen:
|
||||
seen.add(face_selector)
|
||||
selectors.append(face_selector)
|
||||
return selectors
|
||||
|
||||
|
||||
def topology_payload(index: SelectorIndex) -> dict[str, list[str]]:
|
||||
return {
|
||||
"occurrences": [display_selector(str(row.get("id")), index) for row in index.occurrences if row.get("id")],
|
||||
"shapes": [display_selector(str(row.get("id")), index) for row in index.shapes if row.get("id")],
|
||||
"faces": [display_selector(str(row.get("id")), index) for row in index.faces if row.get("id")],
|
||||
"edges": [display_selector(str(row.get("id")), index) for row in index.edges if row.get("id")],
|
||||
"vertices": [display_selector(str(row.get("id")), index) for row in index.vertices if row.get("id")],
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path.cwd().resolve()
|
||||
CAD_ROOT = REPO_ROOT
|
||||
DEFAULT_MESH_TOLERANCE = 0.02
|
||||
DEFAULT_MESH_ANGULAR_TOLERANCE = 0.6
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MeshSettings:
|
||||
tolerance: float
|
||||
angular_tolerance: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GeneratorMetadata:
|
||||
script_path: Path
|
||||
kind: str
|
||||
display_name: str | None
|
||||
generator_names: tuple[str, ...]
|
||||
has_gen_step: bool
|
||||
has_gen_dxf: bool
|
||||
has_gen_urdf: bool
|
||||
has_gen_sdf: bool
|
||||
stl: str | None
|
||||
three_mf: str | None
|
||||
mesh_tolerance: float | None
|
||||
mesh_angular_tolerance: float | None
|
||||
|
||||
|
||||
STEP_ENVELOPE_FIELDS = {
|
||||
"shape",
|
||||
"instances",
|
||||
"children",
|
||||
"assembly_mates",
|
||||
"stl",
|
||||
"3mf",
|
||||
"mesh_tolerance",
|
||||
"mesh_angular_tolerance",
|
||||
}
|
||||
DXF_ENVELOPE_FIELDS = {"document"}
|
||||
URDF_ENVELOPE_FIELDS = {"xml"}
|
||||
SDF_ENVELOPE_FIELDS = {"xml"}
|
||||
|
||||
|
||||
DEFAULT_MESH_SETTINGS = MeshSettings(
|
||||
tolerance=DEFAULT_MESH_TOLERANCE,
|
||||
angular_tolerance=DEFAULT_MESH_ANGULAR_TOLERANCE,
|
||||
)
|
||||
|
||||
|
||||
def _display_path(path: Path) -> str:
|
||||
resolved = path.resolve()
|
||||
try:
|
||||
return resolved.relative_to(REPO_ROOT).as_posix()
|
||||
except ValueError:
|
||||
return resolved.as_posix()
|
||||
|
||||
|
||||
def normalize_mesh_numeric(value: object, *, field_name: str) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise ValueError(f"{field_name} must be a number")
|
||||
normalized = float(value)
|
||||
if not math.isfinite(normalized):
|
||||
raise ValueError(f"{field_name} must be finite")
|
||||
if normalized <= 0.0:
|
||||
raise ValueError(f"{field_name} must be greater than 0")
|
||||
return normalized
|
||||
|
||||
|
||||
def resolve_mesh_settings(
|
||||
*,
|
||||
cad_ref: str,
|
||||
generator_metadata: GeneratorMetadata | None,
|
||||
mesh_tolerance: float | None = None,
|
||||
mesh_angular_tolerance: float | None = None,
|
||||
) -> MeshSettings:
|
||||
tolerance = DEFAULT_MESH_SETTINGS.tolerance
|
||||
angular_tolerance = DEFAULT_MESH_SETTINGS.angular_tolerance
|
||||
if mesh_tolerance is not None:
|
||||
tolerance = mesh_tolerance
|
||||
if mesh_angular_tolerance is not None:
|
||||
angular_tolerance = mesh_angular_tolerance
|
||||
return MeshSettings(
|
||||
tolerance=tolerance,
|
||||
angular_tolerance=angular_tolerance,
|
||||
)
|
||||
|
||||
|
||||
def parse_generator_metadata(script_path: Path) -> GeneratorMetadata | None:
|
||||
try:
|
||||
tree = ast.parse(script_path.read_text(), filename=str(script_path))
|
||||
except (FileNotFoundError, SyntaxError, UnicodeDecodeError) as exc:
|
||||
raise RuntimeError(f"Failed to parse {_display_path(script_path)}") from exc
|
||||
|
||||
display_name: str | None = None
|
||||
kind: str | None = None
|
||||
has_gen_step = False
|
||||
has_gen_dxf = False
|
||||
has_gen_urdf = False
|
||||
has_gen_sdf = False
|
||||
generator_names: list[str] = []
|
||||
for node in tree.body:
|
||||
target: ast.expr | None = None
|
||||
value: ast.AST | None = None
|
||||
if isinstance(node, ast.Assign) and len(node.targets) == 1:
|
||||
target = node.targets[0]
|
||||
value = node.value
|
||||
elif isinstance(node, ast.AnnAssign):
|
||||
target = node.target
|
||||
value = node.value
|
||||
if isinstance(target, ast.Name) and value is not None:
|
||||
if target.id == "DISPLAY_NAME" and isinstance(value, ast.Constant) and isinstance(value.value, str):
|
||||
display_name = value.value.strip()
|
||||
|
||||
if not isinstance(node, ast.FunctionDef) or node.name not in {"gen_step", "gen_dxf", "gen_urdf", "gen_sdf"}:
|
||||
continue
|
||||
generator_names.append(node.name)
|
||||
|
||||
if node.args.args or node.args.posonlyargs or node.args.kwonlyargs:
|
||||
raise ValueError(
|
||||
f"{_display_path(script_path)} {node.name}() must not require arguments"
|
||||
)
|
||||
if node.args.vararg or node.args.kwarg:
|
||||
raise ValueError(
|
||||
f"{_display_path(script_path)} {node.name}() must not accept variadic arguments"
|
||||
)
|
||||
|
||||
if node.decorator_list:
|
||||
raise ValueError(
|
||||
f"{_display_path(script_path)} {node.name}() must not use CAD generator decorators; "
|
||||
"return the generated content directly instead"
|
||||
)
|
||||
|
||||
if node.name == "gen_step":
|
||||
kind = _parse_step_return_metadata(
|
||||
script_path=script_path,
|
||||
function=node,
|
||||
)
|
||||
has_gen_step = True
|
||||
elif node.name == "gen_dxf":
|
||||
_parse_dxf_envelope_metadata(
|
||||
script_path=script_path,
|
||||
function=node,
|
||||
)
|
||||
has_gen_dxf = True
|
||||
elif node.name == "gen_urdf":
|
||||
_parse_urdf_envelope_metadata(
|
||||
script_path=script_path,
|
||||
function=node,
|
||||
)
|
||||
has_gen_urdf = True
|
||||
else:
|
||||
_parse_sdf_envelope_metadata(
|
||||
script_path=script_path,
|
||||
function=node,
|
||||
)
|
||||
has_gen_sdf = True
|
||||
|
||||
if not has_gen_step and not has_gen_dxf and not has_gen_urdf and not has_gen_sdf:
|
||||
return None
|
||||
if not has_gen_step and (has_gen_urdf or has_gen_sdf):
|
||||
raise ValueError(
|
||||
f"{_display_path(script_path)} gen_urdf() and gen_sdf() require gen_step()"
|
||||
)
|
||||
|
||||
return GeneratorMetadata(
|
||||
script_path=script_path.resolve(),
|
||||
kind=kind,
|
||||
display_name=display_name,
|
||||
generator_names=tuple(generator_names),
|
||||
has_gen_step=has_gen_step,
|
||||
has_gen_dxf=has_gen_dxf,
|
||||
has_gen_urdf=has_gen_urdf,
|
||||
has_gen_sdf=has_gen_sdf,
|
||||
stl=None,
|
||||
three_mf=None,
|
||||
mesh_tolerance=None,
|
||||
mesh_angular_tolerance=None,
|
||||
)
|
||||
|
||||
|
||||
def _parse_step_return_metadata(
|
||||
*,
|
||||
script_path: Path,
|
||||
function: ast.FunctionDef,
|
||||
) -> str:
|
||||
return_node = _single_return_value(script_path=script_path, function=function)
|
||||
local_assignments = _function_local_assignments(function)
|
||||
if not isinstance(return_node, ast.Dict):
|
||||
return _parse_bare_step_return(
|
||||
script_path=script_path,
|
||||
function=function,
|
||||
return_node=return_node,
|
||||
local_assignments=local_assignments,
|
||||
)
|
||||
|
||||
envelope = _parse_literal_return_envelope(script_path=script_path, function=function)
|
||||
_reject_unsupported_fields(
|
||||
script_path=script_path,
|
||||
function_name=function.name,
|
||||
envelope=envelope,
|
||||
allowed_fields=STEP_ENVELOPE_FIELDS,
|
||||
)
|
||||
has_shape = "shape" in envelope
|
||||
has_instances = "instances" in envelope
|
||||
has_children = "children" in envelope
|
||||
has_assembly = has_instances or has_children
|
||||
if has_instances and has_children:
|
||||
raise ValueError(
|
||||
f"{_display_path(script_path)} gen_step() envelope must define only one of "
|
||||
"'instances' or 'children'"
|
||||
)
|
||||
if has_shape == has_assembly:
|
||||
raise ValueError(
|
||||
f"{_display_path(script_path)} gen_step() envelope must define exactly one of "
|
||||
"'shape', 'instances', or 'children'"
|
||||
)
|
||||
if has_shape:
|
||||
return (
|
||||
"assembly"
|
||||
if _is_compound_assembly_expression(
|
||||
envelope["shape"],
|
||||
local_assignments=local_assignments,
|
||||
)
|
||||
else "part"
|
||||
)
|
||||
return "assembly"
|
||||
|
||||
|
||||
def _parse_bare_step_return(
|
||||
*,
|
||||
script_path: Path,
|
||||
function: ast.FunctionDef,
|
||||
return_node: ast.expr,
|
||||
local_assignments: dict[str, ast.expr] | None = None,
|
||||
) -> str:
|
||||
if isinstance(return_node, ast.List):
|
||||
return "assembly"
|
||||
if isinstance(return_node, ast.Name) and return_node.id in {"instances", "children"}:
|
||||
return "assembly"
|
||||
if _is_compound_assembly_expression(
|
||||
return_node,
|
||||
local_assignments=local_assignments or {},
|
||||
):
|
||||
return "assembly"
|
||||
if isinstance(return_node, ast.Constant) and return_node.value is None:
|
||||
raise ValueError(
|
||||
f"{_display_path(script_path)} {function.name}() must return a shape, assembly list, "
|
||||
"or legacy envelope dict"
|
||||
)
|
||||
return "part"
|
||||
|
||||
|
||||
def _function_local_assignments(function: ast.FunctionDef) -> dict[str, ast.expr]:
|
||||
assignments: dict[str, ast.expr] = {}
|
||||
for statement in function.body:
|
||||
if isinstance(statement, ast.Return):
|
||||
break
|
||||
target: ast.expr | None = None
|
||||
value: ast.expr | None = None
|
||||
if isinstance(statement, ast.Assign) and len(statement.targets) == 1:
|
||||
target = statement.targets[0]
|
||||
value = statement.value
|
||||
elif isinstance(statement, ast.AnnAssign) and isinstance(statement.value, ast.expr):
|
||||
target = statement.target
|
||||
value = statement.value
|
||||
if isinstance(target, ast.Name) and value is not None:
|
||||
assignments[target.id] = value
|
||||
return assignments
|
||||
|
||||
|
||||
def _is_compound_assembly_expression(
|
||||
expression: ast.expr,
|
||||
*,
|
||||
local_assignments: dict[str, ast.expr],
|
||||
seen_names: set[str] | None = None,
|
||||
) -> bool:
|
||||
if isinstance(expression, ast.Name):
|
||||
seen_names = set(seen_names or set())
|
||||
if expression.id in seen_names:
|
||||
return False
|
||||
target = local_assignments.get(expression.id)
|
||||
if target is None:
|
||||
return False
|
||||
seen_names.add(expression.id)
|
||||
return _is_compound_assembly_expression(
|
||||
target,
|
||||
local_assignments=local_assignments,
|
||||
seen_names=seen_names,
|
||||
)
|
||||
if not isinstance(expression, ast.Call) or _call_tail_name(expression.func) != "Compound":
|
||||
return False
|
||||
if expression.args and _is_multi_item_sequence_expression(
|
||||
expression.args[0],
|
||||
local_assignments=local_assignments,
|
||||
):
|
||||
return True
|
||||
for keyword in expression.keywords:
|
||||
if keyword.arg == "children" and _is_nonempty_expression(keyword.value):
|
||||
return True
|
||||
if keyword.arg == "obj" and _is_multi_item_sequence_expression(
|
||||
keyword.value,
|
||||
local_assignments=local_assignments,
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _call_tail_name(function: ast.expr) -> str | None:
|
||||
if isinstance(function, ast.Name):
|
||||
return function.id
|
||||
if isinstance(function, ast.Attribute):
|
||||
return function.attr
|
||||
return None
|
||||
|
||||
|
||||
def _is_nonempty_expression(expression: ast.expr) -> bool:
|
||||
if isinstance(expression, ast.Constant) and expression.value is None:
|
||||
return False
|
||||
if isinstance(expression, (ast.List, ast.Tuple, ast.Set)):
|
||||
return bool(expression.elts)
|
||||
return True
|
||||
|
||||
|
||||
def _is_multi_item_sequence_expression(
|
||||
expression: ast.expr,
|
||||
*,
|
||||
local_assignments: dict[str, ast.expr],
|
||||
seen_names: set[str] | None = None,
|
||||
) -> bool:
|
||||
if isinstance(expression, ast.Name):
|
||||
seen_names = set(seen_names or set())
|
||||
if expression.id in seen_names:
|
||||
return False
|
||||
target = local_assignments.get(expression.id)
|
||||
if target is None:
|
||||
return False
|
||||
seen_names.add(expression.id)
|
||||
return _is_multi_item_sequence_expression(
|
||||
target,
|
||||
local_assignments=local_assignments,
|
||||
seen_names=seen_names,
|
||||
)
|
||||
if isinstance(expression, (ast.List, ast.Tuple, ast.Set)):
|
||||
return len(expression.elts) > 1
|
||||
return False
|
||||
|
||||
|
||||
def _parse_dxf_envelope_metadata(
|
||||
*,
|
||||
script_path: Path,
|
||||
function: ast.FunctionDef,
|
||||
) -> str | None:
|
||||
return_node = _single_return_value(script_path=script_path, function=function)
|
||||
if not isinstance(return_node, ast.Dict):
|
||||
return None
|
||||
envelope = _parse_literal_return_envelope(script_path=script_path, function=function)
|
||||
_reject_unsupported_fields(
|
||||
script_path=script_path,
|
||||
function_name=function.name,
|
||||
envelope=envelope,
|
||||
allowed_fields=DXF_ENVELOPE_FIELDS,
|
||||
)
|
||||
if "document" not in envelope:
|
||||
raise ValueError(f"{_display_path(script_path)} gen_dxf() envelope must define 'document'")
|
||||
return None
|
||||
|
||||
|
||||
def _parse_urdf_envelope_metadata(
|
||||
*,
|
||||
script_path: Path,
|
||||
function: ast.FunctionDef,
|
||||
) -> str | None:
|
||||
return_node = _single_return_value(script_path=script_path, function=function)
|
||||
if not isinstance(return_node, ast.Dict):
|
||||
return None
|
||||
envelope = _parse_literal_return_envelope(script_path=script_path, function=function)
|
||||
_reject_unsupported_fields(
|
||||
script_path=script_path,
|
||||
function_name=function.name,
|
||||
envelope=envelope,
|
||||
allowed_fields=URDF_ENVELOPE_FIELDS,
|
||||
)
|
||||
if "xml" not in envelope:
|
||||
raise ValueError(f"{_display_path(script_path)} gen_urdf() envelope must define 'xml'")
|
||||
return None
|
||||
|
||||
|
||||
def _parse_sdf_envelope_metadata(
|
||||
*,
|
||||
script_path: Path,
|
||||
function: ast.FunctionDef,
|
||||
) -> str | None:
|
||||
return_node = _single_return_value(script_path=script_path, function=function)
|
||||
if not isinstance(return_node, ast.Dict):
|
||||
return None
|
||||
envelope = _parse_literal_return_envelope(script_path=script_path, function=function)
|
||||
_reject_unsupported_fields(
|
||||
script_path=script_path,
|
||||
function_name=function.name,
|
||||
envelope=envelope,
|
||||
allowed_fields=SDF_ENVELOPE_FIELDS,
|
||||
)
|
||||
if "xml" not in envelope:
|
||||
raise ValueError(f"{_display_path(script_path)} gen_sdf() envelope must define 'xml'")
|
||||
return None
|
||||
|
||||
|
||||
def _parse_literal_return_envelope(
|
||||
*,
|
||||
script_path: Path,
|
||||
function: ast.FunctionDef,
|
||||
) -> dict[str, ast.expr]:
|
||||
value = _single_return_value(script_path=script_path, function=function)
|
||||
if not isinstance(value, ast.Dict):
|
||||
raise ValueError(
|
||||
f"{_display_path(script_path)} {function.name}() must return a generator envelope dict"
|
||||
)
|
||||
envelope: dict[str, ast.expr] = {}
|
||||
for key_node, value_node in zip(value.keys, value.values, strict=True):
|
||||
if not isinstance(key_node, ast.Constant) or not isinstance(key_node.value, str):
|
||||
raise ValueError(
|
||||
f"{_display_path(script_path)} {function.name}() envelope keys must be string literals"
|
||||
)
|
||||
key = key_node.value
|
||||
if key in envelope:
|
||||
raise ValueError(
|
||||
f"{_display_path(script_path)} {function.name}() envelope duplicate field: {key}"
|
||||
)
|
||||
envelope[key] = value_node
|
||||
return envelope
|
||||
|
||||
|
||||
def _single_return_value(
|
||||
*,
|
||||
script_path: Path,
|
||||
function: ast.FunctionDef,
|
||||
) -> ast.expr:
|
||||
returns = [statement for statement in function.body if isinstance(statement, ast.Return)]
|
||||
if len(returns) != 1 or returns[0].value is None:
|
||||
raise ValueError(
|
||||
f"{_display_path(script_path)} {function.name}() must return one value"
|
||||
)
|
||||
return returns[0].value
|
||||
|
||||
|
||||
def _reject_unsupported_fields(
|
||||
*,
|
||||
script_path: Path,
|
||||
function_name: str,
|
||||
envelope: dict[str, ast.expr],
|
||||
allowed_fields: set[str],
|
||||
) -> None:
|
||||
extra_fields = sorted(key for key in envelope if key not in allowed_fields)
|
||||
if extra_fields:
|
||||
joined = ", ".join(extra_fields)
|
||||
raise ValueError(
|
||||
f"{_display_path(script_path)} {function_name}() envelope has unsupported field(s): {joined}"
|
||||
)
|
||||
|
||||
|
||||
def _literal_field(
|
||||
*,
|
||||
script_path: Path,
|
||||
function_name: str,
|
||||
envelope: dict[str, ast.expr],
|
||||
field_name: str,
|
||||
) -> object | None:
|
||||
if field_name not in envelope:
|
||||
return None
|
||||
try:
|
||||
return ast.literal_eval(envelope[field_name])
|
||||
except (ValueError, SyntaxError) as exc:
|
||||
raise ValueError(
|
||||
f"{_display_path(script_path)} {function_name}() envelope {field_name} must be a literal"
|
||||
) from exc
|
||||
|
||||
|
||||
def _parse_path_field(
|
||||
*,
|
||||
script_path: Path,
|
||||
function_name: str,
|
||||
envelope: dict[str, ast.expr],
|
||||
field_name: str,
|
||||
) -> str | None:
|
||||
value = _literal_field(
|
||||
script_path=script_path,
|
||||
function_name=function_name,
|
||||
envelope=envelope,
|
||||
field_name=field_name,
|
||||
)
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError(
|
||||
f"{_display_path(script_path)} {function_name}() envelope {field_name} "
|
||||
"must be a non-empty string"
|
||||
)
|
||||
if "\\" in value:
|
||||
raise ValueError(
|
||||
f"{_display_path(script_path)} {function_name}() envelope {field_name} "
|
||||
"must use POSIX '/' separators"
|
||||
)
|
||||
return value.strip()
|
||||
@@ -0,0 +1,97 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from cadpy.catalog import (
|
||||
cad_ref_from_step_path as fallback_cad_ref_from_step_path,
|
||||
find_source_by_cad_ref,
|
||||
find_source_by_path,
|
||||
explorer_artifact_path_for_step_path,
|
||||
explorer_directory_for_step_path,
|
||||
legacy_explorer_artifact_path_for_step_path,
|
||||
)
|
||||
|
||||
|
||||
REPO_ROOT = Path.cwd().resolve()
|
||||
CAD_ROOT = REPO_ROOT
|
||||
|
||||
|
||||
def _source_for_step_path(step_path: Path):
|
||||
source = find_source_by_path(step_path)
|
||||
if source is not None:
|
||||
return source
|
||||
cad_ref = fallback_cad_ref_from_step_path(step_path)
|
||||
source = find_source_by_cad_ref(cad_ref)
|
||||
if source is None:
|
||||
raise ValueError(f"CAD STEP ref not found: {cad_ref}")
|
||||
return source
|
||||
|
||||
|
||||
def part_stl_path(step_path: Path) -> Path:
|
||||
source = _source_for_step_path(step_path)
|
||||
if source.stl_path is None:
|
||||
raise ValueError(f"CAD STEP ref has no configured STL output: {source.cad_ref}")
|
||||
return source.stl_path
|
||||
|
||||
|
||||
def part_3mf_path(step_path: Path) -> Path:
|
||||
source = _source_for_step_path(step_path)
|
||||
if source.three_mf_path is None:
|
||||
raise ValueError(f"CAD STEP ref has no configured 3MF output: {source.cad_ref}")
|
||||
return source.three_mf_path
|
||||
|
||||
|
||||
def part_native_glb_path(step_path: Path) -> Path:
|
||||
source = _source_for_step_path(step_path)
|
||||
if source.native_glb_path is None:
|
||||
raise ValueError(f"CAD STEP ref has no configured native GLB output: {source.cad_ref}")
|
||||
return source.native_glb_path
|
||||
|
||||
|
||||
def native_component_glb_dir(step_path: Path) -> Path:
|
||||
return explorer_directory_for_step_path(step_path) / "components"
|
||||
|
||||
|
||||
def part_glb_path(step_path: Path) -> Path:
|
||||
return explorer_artifact_path_for_step_path(step_path, ".glb")
|
||||
|
||||
|
||||
def legacy_part_glb_path(step_path: Path) -> Path:
|
||||
return legacy_explorer_artifact_path_for_step_path(step_path, ".glb")
|
||||
|
||||
|
||||
def existing_part_glb_path(step_path: Path) -> Path | None:
|
||||
preferred_path = part_glb_path(step_path)
|
||||
if preferred_path.is_file():
|
||||
return preferred_path
|
||||
legacy_path = legacy_part_glb_path(step_path)
|
||||
return legacy_path if legacy_path.is_file() else None
|
||||
|
||||
|
||||
def relative_to_repo(path: Path) -> str:
|
||||
resolved = path.resolve()
|
||||
try:
|
||||
return resolved.relative_to(REPO_ROOT).as_posix()
|
||||
except ValueError:
|
||||
return resolved.as_posix()
|
||||
|
||||
|
||||
def relative_to_directory(path: Path, base_dir: Path) -> str:
|
||||
return os.path.relpath(
|
||||
path.expanduser().resolve(),
|
||||
start=base_dir.expanduser().resolve(),
|
||||
).replace(os.sep, "/")
|
||||
|
||||
|
||||
def relative_to_file(path: Path, owner_path: Path) -> str:
|
||||
return relative_to_directory(path, owner_path.expanduser().resolve().parent)
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
@@ -0,0 +1,133 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from cadpy import analysis, lookup
|
||||
from cadpy.validators import geometry_summary_from_manifest
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EntryReportOptions:
|
||||
json: bool = False
|
||||
facts: bool = False
|
||||
positioning: bool = False
|
||||
planes: bool = False
|
||||
topology: bool = False
|
||||
plane_coordinate_tolerance: float = 1e-3
|
||||
plane_min_area_ratio: float = 0.05
|
||||
plane_limit: int = 12
|
||||
|
||||
@property
|
||||
def requested(self) -> bool:
|
||||
return self.json or self.facts or self.positioning or self.planes or self.topology
|
||||
|
||||
@property
|
||||
def refs_required(self) -> bool:
|
||||
return self.facts or self.positioning or self.planes or self.topology
|
||||
|
||||
|
||||
def entry_summary_payload(
|
||||
manifest: dict[str, Any],
|
||||
*,
|
||||
kind: str,
|
||||
selector_index: lookup.SelectorIndex | None = None,
|
||||
) -> dict[str, object]:
|
||||
if selector_index is not None:
|
||||
summary = lookup.entry_summary(selector_index)
|
||||
summary["kind"] = kind
|
||||
return summary
|
||||
summary = geometry_summary_from_manifest(manifest)
|
||||
return {
|
||||
"kind": kind,
|
||||
"occurrenceCount": summary.get("occurrenceCount"),
|
||||
"shapeCount": summary.get("shapeCount"),
|
||||
"faceCount": summary.get("faceCount"),
|
||||
"edgeCount": summary.get("edgeCount"),
|
||||
"bounds": summary.get("bbox"),
|
||||
}
|
||||
|
||||
|
||||
def major_planes_payload(
|
||||
selector_index: lookup.SelectorIndex | None,
|
||||
options: EntryReportOptions,
|
||||
) -> list[dict[str, object]]:
|
||||
if selector_index is None:
|
||||
return []
|
||||
return analysis.major_planar_face_groups(
|
||||
selector_index,
|
||||
coordinate_tolerance=options.plane_coordinate_tolerance,
|
||||
min_area_ratio=options.plane_min_area_ratio,
|
||||
limit=options.plane_limit,
|
||||
)
|
||||
|
||||
|
||||
def entry_facts_payload(
|
||||
manifest: dict[str, Any],
|
||||
*,
|
||||
kind: str,
|
||||
selector_index: lookup.SelectorIndex | None = None,
|
||||
options: EntryReportOptions = EntryReportOptions(),
|
||||
major_planes: list[dict[str, object]] | None = None,
|
||||
) -> dict[str, object]:
|
||||
facts = analysis.bbox_facts(manifest.get("bbox"))
|
||||
facts["kind"] = kind
|
||||
return facts
|
||||
|
||||
|
||||
def entry_positioning_payload(
|
||||
manifest: dict[str, Any],
|
||||
*,
|
||||
kind: str,
|
||||
selector_index: lookup.SelectorIndex | None = None,
|
||||
options: EntryReportOptions = EntryReportOptions(),
|
||||
major_planes: list[dict[str, object]] | None = None,
|
||||
) -> dict[str, object]:
|
||||
payload: dict[str, object] = {
|
||||
"kind": kind,
|
||||
"bbox": manifest.get("bbox"),
|
||||
}
|
||||
bbox_facts = analysis.bbox_facts(manifest.get("bbox"))
|
||||
if bbox_facts:
|
||||
payload["bboxFacts"] = bbox_facts
|
||||
if selector_index is not None and major_planes is not None:
|
||||
payload["majorPlanes"] = major_planes
|
||||
return payload
|
||||
|
||||
|
||||
def entry_report_payload(
|
||||
manifest: dict[str, Any],
|
||||
*,
|
||||
kind: str,
|
||||
options: EntryReportOptions,
|
||||
selector_index: lookup.SelectorIndex | None = None,
|
||||
) -> dict[str, object]:
|
||||
payload: dict[str, object] = {
|
||||
"summary": entry_summary_payload(manifest, kind=kind, selector_index=selector_index),
|
||||
}
|
||||
major_planes = (
|
||||
major_planes_payload(selector_index, options)
|
||||
if selector_index is not None and options.planes
|
||||
else None
|
||||
)
|
||||
if options.facts:
|
||||
payload["entryFacts"] = entry_facts_payload(
|
||||
manifest,
|
||||
kind=kind,
|
||||
selector_index=selector_index,
|
||||
options=options,
|
||||
major_planes=major_planes,
|
||||
)
|
||||
if options.positioning:
|
||||
payload["entryPositioning"] = entry_positioning_payload(
|
||||
manifest,
|
||||
kind=kind,
|
||||
selector_index=selector_index,
|
||||
options=options,
|
||||
major_planes=major_planes,
|
||||
)
|
||||
if options.planes:
|
||||
payload["planes"] = major_planes if major_planes is not None else []
|
||||
if options.topology and selector_index is not None:
|
||||
payload["topology"] = lookup.topology_payload(selector_index)
|
||||
return payload
|
||||
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from array import array
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
|
||||
class SelectorProfile(str, Enum):
|
||||
SUMMARY = "summary"
|
||||
REFS = "refs"
|
||||
ARTIFACT = "artifact"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SelectorBundle:
|
||||
manifest: dict[str, Any]
|
||||
buffers: dict[str, array] = field(default_factory=dict)
|
||||
@@ -0,0 +1,62 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from cadpy import catalog
|
||||
|
||||
|
||||
_PACKAGE_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PythonSourceHash:
|
||||
source_path: str
|
||||
source_hash: str
|
||||
|
||||
|
||||
def python_source_hash(script_path: Path) -> PythonSourceHash:
|
||||
"""Hash the generator script content and record its metadata path."""
|
||||
resolved_script = script_path.expanduser().resolve()
|
||||
return PythonSourceHash(
|
||||
source_path=_manifest_path(resolved_script),
|
||||
source_hash=_sha256_file(resolved_script),
|
||||
)
|
||||
|
||||
|
||||
def _manifest_roots() -> tuple[Path, ...]:
|
||||
return tuple(_dedupe_paths([
|
||||
catalog.CAD_ROOT.resolve(),
|
||||
catalog.REPO_ROOT.resolve(),
|
||||
_PACKAGE_ROOT.resolve(),
|
||||
]))
|
||||
|
||||
|
||||
def _dedupe_paths(paths: list[Path]) -> list[Path]:
|
||||
result: list[Path] = []
|
||||
seen: set[Path] = set()
|
||||
for path in paths:
|
||||
resolved = path.resolve()
|
||||
if resolved not in seen:
|
||||
seen.add(resolved)
|
||||
result.append(resolved)
|
||||
return result
|
||||
|
||||
|
||||
def _manifest_path(path: Path) -> str:
|
||||
resolved = path.resolve()
|
||||
for root in _manifest_roots():
|
||||
try:
|
||||
return resolved.relative_to(root).as_posix()
|
||||
except ValueError:
|
||||
continue
|
||||
return resolved.as_posix()
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
@@ -0,0 +1,367 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import replace
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from cadpy.cli_logging import CliLogger
|
||||
from cadpy.generation import (
|
||||
EntrySpec,
|
||||
_existing_topology_artifact_matches_spec_without_scene,
|
||||
_entry_spec_from_source,
|
||||
_generate_part_outputs,
|
||||
run_script_generator,
|
||||
)
|
||||
from cadpy.metadata import DEFAULT_MESH_ANGULAR_TOLERANCE, DEFAULT_MESH_TOLERANCE, normalize_mesh_numeric
|
||||
from cadpy.render import part_glb_path, relative_to_repo
|
||||
from cadpy.step_export import write_xcaf_doc_step_file
|
||||
from cadpy.step_metadata import read_text_to_cad_step_metadata
|
||||
from cadpy.step_scene import LoadedStepScene, load_step_scene, step_file_hash
|
||||
from cadpy.catalog import iter_cad_sources, source_from_path
|
||||
from cadpy.step_targets import (
|
||||
ResolvedStepTarget,
|
||||
StepTopologyArtifact,
|
||||
StepTopologyArtifactError,
|
||||
validate_step_topology_artifact,
|
||||
)
|
||||
|
||||
|
||||
def _repo_relative(repo_root: Path, path: Path) -> str:
|
||||
resolved = path.resolve()
|
||||
try:
|
||||
return resolved.relative_to(repo_root).as_posix()
|
||||
except ValueError:
|
||||
return resolved.as_posix()
|
||||
|
||||
|
||||
def _cad_ref_for_step(repo_root: Path, step_path: Path) -> str:
|
||||
relative = _repo_relative(repo_root, step_path)
|
||||
suffix = step_path.suffix
|
||||
return relative[: -len(suffix)] if suffix else relative
|
||||
|
||||
|
||||
def _scene_has_assembly_structure(scene: LoadedStepScene) -> bool:
|
||||
stack = list(scene.roots)
|
||||
if len(stack) > 1:
|
||||
return True
|
||||
while stack:
|
||||
node = stack.pop()
|
||||
if node.children:
|
||||
return True
|
||||
stack.extend(node.children)
|
||||
return False
|
||||
|
||||
|
||||
def _infer_entry_kind(step_path: Path, scene: LoadedStepScene) -> str:
|
||||
metadata_kind = None
|
||||
try:
|
||||
metadata_kind = read_text_to_cad_step_metadata(step_path).get("entryKind")
|
||||
except Exception:
|
||||
metadata_kind = None
|
||||
if metadata_kind in {"part", "assembly"}:
|
||||
return metadata_kind
|
||||
return "assembly" if _scene_has_assembly_structure(scene) else "part"
|
||||
|
||||
|
||||
def _build_entry_spec(
|
||||
repo_root: Path,
|
||||
step_path: Path,
|
||||
scene: LoadedStepScene,
|
||||
*,
|
||||
kind: str,
|
||||
mesh_tolerance: float | None = None,
|
||||
mesh_angular_tolerance: float | None = None,
|
||||
) -> EntrySpec:
|
||||
cad_ref = _cad_ref_for_step(repo_root, step_path)
|
||||
return EntrySpec(
|
||||
source_ref=_repo_relative(repo_root, step_path),
|
||||
cad_ref=cad_ref,
|
||||
kind=kind,
|
||||
source_path=step_path,
|
||||
display_name=step_path.stem,
|
||||
source="imported",
|
||||
step_path=step_path,
|
||||
mesh_tolerance=mesh_tolerance if mesh_tolerance is not None else DEFAULT_MESH_TOLERANCE,
|
||||
mesh_angular_tolerance=(
|
||||
mesh_angular_tolerance
|
||||
if mesh_angular_tolerance is not None
|
||||
else DEFAULT_MESH_ANGULAR_TOLERANCE
|
||||
),
|
||||
mesh_tolerance_explicit=mesh_tolerance is not None,
|
||||
mesh_angular_tolerance_explicit=mesh_angular_tolerance is not None,
|
||||
)
|
||||
|
||||
|
||||
def _entries_by_step_path_for_repo(repo_root: Path, spec: EntrySpec) -> dict[Path, EntrySpec]:
|
||||
entries: dict[Path, EntrySpec] = {}
|
||||
try:
|
||||
for source in iter_cad_sources(repo_root):
|
||||
entry_spec = _entry_spec_from_source(source)
|
||||
if entry_spec.step_path is not None:
|
||||
entries[entry_spec.step_path.resolve()] = entry_spec
|
||||
except Exception:
|
||||
entries = {}
|
||||
if spec.step_path is not None:
|
||||
entries[spec.step_path.resolve()] = spec
|
||||
return entries
|
||||
|
||||
|
||||
def _result_payload(
|
||||
spec: EntrySpec,
|
||||
*,
|
||||
entry_kind: str,
|
||||
source_kind: str,
|
||||
glb_path: Path,
|
||||
step_hash: str | None = None,
|
||||
source_hash: str | None = None,
|
||||
stats: dict[str, object] | None = None,
|
||||
load_elapsed_ms: float | None = None,
|
||||
skipped: bool = False,
|
||||
) -> dict[str, object]:
|
||||
payload: dict[str, object] = {
|
||||
"ok": True,
|
||||
"stepPath": relative_to_repo(spec.step_path),
|
||||
"glbPath": relative_to_repo(glb_path),
|
||||
"entryKind": entry_kind,
|
||||
"sourceKind": source_kind,
|
||||
"stats": stats or {},
|
||||
"sourceRef": spec.source_ref,
|
||||
"cadPath": spec.cad_ref,
|
||||
}
|
||||
if step_hash:
|
||||
payload["stepHash"] = step_hash
|
||||
if source_hash:
|
||||
payload["sourceHash"] = source_hash
|
||||
if load_elapsed_ms is not None:
|
||||
payload["loadElapsedMs"] = round(load_elapsed_ms, 1)
|
||||
if skipped:
|
||||
payload["skipped"] = True
|
||||
return payload
|
||||
|
||||
|
||||
def _generated_result_payload(spec: EntrySpec, scene: LoadedStepScene, stats: dict[str, object] | None = None) -> dict[str, object]:
|
||||
glb_path = part_glb_path(spec.step_path)
|
||||
source_kind = str(getattr(scene, "source_kind", "step") or "step").strip().lower()
|
||||
step_hash = str(getattr(scene, "step_hash", "") or "").strip()
|
||||
if not step_hash and spec.step_path is not None and spec.step_path.is_file():
|
||||
step_hash = step_file_hash(spec.step_path)
|
||||
return _result_payload(
|
||||
spec,
|
||||
entry_kind=spec.kind,
|
||||
source_kind=source_kind,
|
||||
step_hash=step_hash or None,
|
||||
source_hash=getattr(scene, "source_hash", None) if source_kind == "python" else None,
|
||||
glb_path=glb_path,
|
||||
stats=stats,
|
||||
load_elapsed_ms=scene.load_elapsed * 1000.0,
|
||||
)
|
||||
|
||||
|
||||
def _existing_result_payload(spec: EntrySpec, artifact: StepTopologyArtifact) -> dict[str, object]:
|
||||
entry_kind = str(artifact.manifest.get("entryKind") or spec.kind)
|
||||
source_kind = str(artifact.manifest.get("sourceKind") or "step").strip().lower()
|
||||
step_hash = str(artifact.manifest.get("stepHash") or "")
|
||||
source_hash = str(artifact.manifest.get("sourceHash") or "")
|
||||
if source_kind != "python" and not step_hash:
|
||||
step_hash = step_file_hash(spec.step_path)
|
||||
stats = artifact.manifest.get("stats")
|
||||
return _result_payload(
|
||||
spec,
|
||||
entry_kind=entry_kind,
|
||||
source_kind=source_kind,
|
||||
step_hash=step_hash or None,
|
||||
source_hash=source_hash or None,
|
||||
glb_path=artifact.glb_path,
|
||||
stats=stats if isinstance(stats, dict) else {},
|
||||
skipped=True,
|
||||
)
|
||||
|
||||
|
||||
def _write_step_after_artifact(spec: EntrySpec, scene: LoadedStepScene, *, logger: CliLogger) -> str:
|
||||
if scene.doc is None:
|
||||
raise RuntimeError(f"Cannot write STEP after GLB artifact; scene has no XCAF document: {spec.step_path}")
|
||||
source_hash = (
|
||||
str(getattr(scene, "source_hash", "") or "").strip()
|
||||
if str(getattr(scene, "source_kind", "") or "").strip().lower() == "python"
|
||||
else ""
|
||||
)
|
||||
source_path = (
|
||||
str(getattr(scene, "source_path", "") or "").strip()
|
||||
if str(getattr(scene, "source_kind", "") or "").strip().lower() == "python"
|
||||
else ""
|
||||
)
|
||||
return write_xcaf_doc_step_file(
|
||||
scene.doc,
|
||||
spec.step_path,
|
||||
label=spec.step_path.stem,
|
||||
originating_system="tom-cad direct XCAF assembly" if spec.kind == "assembly" else "build123d",
|
||||
text_to_cad_entry_kind=spec.kind,
|
||||
source_path=source_path or None,
|
||||
source_hash=source_hash or None,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
|
||||
def _current_artifact_for_spec(spec: EntrySpec) -> StepTopologyArtifact | None:
|
||||
if not _existing_topology_artifact_matches_spec_without_scene(spec):
|
||||
return None
|
||||
try:
|
||||
return validate_step_topology_artifact(
|
||||
ResolvedStepTarget(
|
||||
cad_path=spec.cad_ref,
|
||||
kind=spec.kind,
|
||||
source_path=spec.source_path,
|
||||
step_path=spec.step_path,
|
||||
),
|
||||
glb_path=part_glb_path(spec.step_path),
|
||||
require_selector=True,
|
||||
)
|
||||
except StepTopologyArtifactError:
|
||||
return None
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="python -m cadpy.step_artifact",
|
||||
description="Generate the hidden CAD Viewer GLB/topology artifact for one STEP/STP file.",
|
||||
)
|
||||
parser.add_argument("--repo-root", required=True, help="Repository/workspace root for relative STEP metadata.")
|
||||
parser.add_argument("--step", required=True, help="STEP/STP source file to process.")
|
||||
parser.add_argument("--source-path", help="Python gen_step() source path for a logical STEP output.")
|
||||
parser.add_argument("--kind", choices=("part", "assembly"), help="Override inferred STEP entry kind.")
|
||||
parser.add_argument("--skip-step-write", action="store_true", help="Generate from same-stem Python generator without writing STEP.")
|
||||
parser.add_argument("--write-step-after-artifact", action="store_true", help="With --skip-step-write, write/update the STEP file after the GLB artifact is ready.")
|
||||
parser.add_argument("--force", action="store_true", help="Regenerate even if a current artifact exists.")
|
||||
parser.add_argument("--mesh-tolerance", type=float, help="Override automatic mesh linear deflection.")
|
||||
parser.add_argument("--mesh-angular-tolerance", type=float, help="Override automatic mesh angular deflection.")
|
||||
parser.add_argument("--verbose", action="store_true", help="Show detailed timing on stderr.")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
if bool(args.write_step_after_artifact) and not bool(args.skip_step_write):
|
||||
raise ValueError("--write-step-after-artifact requires --skip-step-write")
|
||||
repo_root = Path(args.repo_root).expanduser().resolve()
|
||||
step_path = Path(args.step).expanduser().resolve()
|
||||
if bool(args.skip_step_write):
|
||||
script_path = (
|
||||
Path(args.source_path).expanduser().resolve()
|
||||
if args.source_path
|
||||
else step_path.with_suffix(".py")
|
||||
)
|
||||
if not script_path.is_file():
|
||||
raise FileNotFoundError(f"Python generator does not exist for logical STEP path: {script_path}")
|
||||
source = source_from_path(script_path)
|
||||
if source is None:
|
||||
raise RuntimeError(f"Python generator is not a gen_step() CAD source: {script_path}")
|
||||
spec = _entry_spec_from_source(source)
|
||||
if spec.step_path is None or spec.step_path.resolve() != step_path:
|
||||
if not args.source_path or spec.step_path is None:
|
||||
raise RuntimeError(f"Python generator does not map to logical STEP path: {step_path}")
|
||||
spec = replace(
|
||||
spec,
|
||||
cad_ref=_cad_ref_for_step(repo_root, step_path),
|
||||
display_name=step_path.stem,
|
||||
step_path=step_path,
|
||||
)
|
||||
if args.kind is not None and args.kind != spec.kind:
|
||||
raise ValueError(f"Requested --kind {args.kind!r} does not match generator kind {spec.kind!r}")
|
||||
elif not step_path.is_file():
|
||||
raise FileNotFoundError(f"STEP file does not exist: {step_path}")
|
||||
if step_path.suffix.lower() not in {".step", ".stp"}:
|
||||
raise ValueError(f"Expected a STEP/STP file: {step_path}")
|
||||
|
||||
logger = CliLogger("step-artifact", verbose=bool(args.verbose))
|
||||
mesh_tolerance = normalize_mesh_numeric(args.mesh_tolerance, field_name="mesh_tolerance")
|
||||
mesh_angular_tolerance = normalize_mesh_numeric(
|
||||
args.mesh_angular_tolerance,
|
||||
field_name="mesh_angular_tolerance",
|
||||
)
|
||||
if bool(args.skip_step_write):
|
||||
existing_spec = spec
|
||||
if mesh_tolerance is not None or mesh_angular_tolerance is not None:
|
||||
existing_spec = replace(
|
||||
existing_spec,
|
||||
mesh_tolerance=mesh_tolerance if mesh_tolerance is not None else existing_spec.mesh_tolerance,
|
||||
mesh_angular_tolerance=(
|
||||
mesh_angular_tolerance
|
||||
if mesh_angular_tolerance is not None
|
||||
else existing_spec.mesh_angular_tolerance
|
||||
),
|
||||
mesh_tolerance_explicit=mesh_tolerance is not None,
|
||||
mesh_angular_tolerance_explicit=mesh_angular_tolerance is not None,
|
||||
)
|
||||
else:
|
||||
existing_spec = EntrySpec(
|
||||
source_ref=_repo_relative(repo_root, step_path),
|
||||
cad_ref=_cad_ref_for_step(repo_root, step_path),
|
||||
kind=args.kind or "part",
|
||||
source_path=step_path,
|
||||
display_name=step_path.stem,
|
||||
source="imported",
|
||||
step_path=step_path,
|
||||
mesh_tolerance=mesh_tolerance if mesh_tolerance is not None else DEFAULT_MESH_TOLERANCE,
|
||||
mesh_angular_tolerance=(
|
||||
mesh_angular_tolerance
|
||||
if mesh_angular_tolerance is not None
|
||||
else DEFAULT_MESH_ANGULAR_TOLERANCE
|
||||
),
|
||||
mesh_tolerance_explicit=mesh_tolerance is not None,
|
||||
mesh_angular_tolerance_explicit=mesh_angular_tolerance is not None,
|
||||
)
|
||||
if not args.force:
|
||||
existing_artifact = _current_artifact_for_spec(existing_spec)
|
||||
if existing_artifact is not None:
|
||||
print(json.dumps(_existing_result_payload(existing_spec, existing_artifact), separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
glb_path = part_glb_path(step_path)
|
||||
if bool(args.skip_step_write):
|
||||
scene = run_script_generator(
|
||||
existing_spec,
|
||||
"gen_step",
|
||||
logger=logger,
|
||||
force=bool(args.force),
|
||||
load_current_scene=False,
|
||||
skip_step_write=True,
|
||||
)
|
||||
if scene is None:
|
||||
raise RuntimeError(f"Python generator did not produce a STEP scene: {existing_spec.source_ref}")
|
||||
spec = existing_spec
|
||||
if bool(args.write_step_after_artifact):
|
||||
step_hash = _write_step_after_artifact(spec, scene, logger=logger)
|
||||
scene.step_hash = step_hash
|
||||
else:
|
||||
with logger.timed(f"load STEP {relative_to_repo(step_path)}"):
|
||||
scene = load_step_scene(step_path)
|
||||
kind = args.kind or _infer_entry_kind(step_path, scene)
|
||||
spec = _build_entry_spec(
|
||||
repo_root,
|
||||
step_path,
|
||||
scene,
|
||||
kind=kind,
|
||||
mesh_tolerance=mesh_tolerance,
|
||||
mesh_angular_tolerance=mesh_angular_tolerance,
|
||||
)
|
||||
result = _generate_part_outputs(
|
||||
spec,
|
||||
entries_by_step_path=_entries_by_step_path_for_repo(repo_root, spec),
|
||||
preloaded_scene=scene,
|
||||
require_step_file=not bool(args.skip_step_write),
|
||||
force=bool(args.force),
|
||||
logger=logger,
|
||||
)
|
||||
stats = result.selector_bundle.manifest.get("stats") if result.selector_bundle is not None else {}
|
||||
payload = _generated_result_payload(spec, scene, stats if isinstance(stats, dict) else {})
|
||||
if bool(args.write_step_after_artifact):
|
||||
print(json.dumps({**payload, "stepWrite": {"status": "complete", "stepHash": payload.get("stepHash", "")}}, separators=(",", ":")))
|
||||
else:
|
||||
print(json.dumps(payload, separators=(",", ":")))
|
||||
logger.total()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,245 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
|
||||
from cadpy.assembly_spec import REPO_ROOT
|
||||
from cadpy.catalog import source_from_path
|
||||
from cadpy.cli_logging import CliLogger
|
||||
from cadpy.generation import (
|
||||
EntrySpec,
|
||||
_entry_spec_from_source,
|
||||
_existing_topology_artifact_matches_spec_without_scene,
|
||||
_generate_part_outputs,
|
||||
run_script_generator,
|
||||
)
|
||||
from cadpy.metadata import DEFAULT_MESH_ANGULAR_TOLERANCE, DEFAULT_MESH_TOLERANCE
|
||||
from cadpy.render import part_glb_path
|
||||
from cadpy.step_metadata import read_text_to_cad_step_metadata
|
||||
from cadpy.step_scene import LoadedStepScene, load_step_scene
|
||||
from cadpy.step_targets import (
|
||||
REGENERATE_STEP_COMMAND,
|
||||
REGENERATE_STEP_PROMPT,
|
||||
ResolvedStepTarget,
|
||||
StepTopologyArtifact,
|
||||
StepTopologyArtifactError,
|
||||
validate_step_topology_artifact,
|
||||
)
|
||||
|
||||
|
||||
def cad_ref_for_step_path(repo_root: Path, step_path: Path) -> str:
|
||||
relative = _repo_relative(repo_root, step_path)
|
||||
suffix = step_path.suffix
|
||||
return relative[: -len(suffix)] if suffix else relative
|
||||
|
||||
|
||||
def ensure_step_topology_artifact(
|
||||
target: ResolvedStepTarget,
|
||||
*,
|
||||
glb_path: Path | None = None,
|
||||
require_selector: bool = False,
|
||||
force: bool = False,
|
||||
logger: CliLogger | None = None,
|
||||
mesh_tolerance: float | None = None,
|
||||
mesh_angular_tolerance: float | None = None,
|
||||
owner: str = "cadpy-step-artifact",
|
||||
) -> StepTopologyArtifact:
|
||||
spec = _entry_spec_for_target(
|
||||
target,
|
||||
mesh_tolerance=mesh_tolerance,
|
||||
mesh_angular_tolerance=mesh_angular_tolerance,
|
||||
)
|
||||
resolved_glb_path = glb_path or part_glb_path(spec.step_path)
|
||||
if not force:
|
||||
artifact = _current_artifact_for_spec(spec, glb_path=resolved_glb_path, require_selector=require_selector)
|
||||
if artifact is not None:
|
||||
return artifact
|
||||
|
||||
try:
|
||||
spec, scene = _scene_for_regeneration(spec, logger=logger, force=force)
|
||||
_generate_part_outputs(
|
||||
spec,
|
||||
entries_by_step_path={spec.step_path: spec},
|
||||
preloaded_scene=scene,
|
||||
require_step_file=(spec.source != "generated"),
|
||||
force=True,
|
||||
logger=logger,
|
||||
)
|
||||
except StepTopologyArtifactError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise StepTopologyArtifactError(
|
||||
code="glb_regeneration_failed",
|
||||
cad_path=spec.cad_ref,
|
||||
step_path=spec.step_path,
|
||||
glb_path=resolved_glb_path,
|
||||
regenerate_command=REGENERATE_STEP_COMMAND,
|
||||
message=(
|
||||
f"Failed to regenerate GLB/topology artifact for {spec.cad_ref}: {exc}.\n"
|
||||
f"{REGENERATE_STEP_PROMPT}"
|
||||
),
|
||||
) from exc
|
||||
return validate_step_topology_artifact(
|
||||
ResolvedStepTarget(
|
||||
cad_path=spec.cad_ref,
|
||||
kind=spec.kind,
|
||||
source_path=spec.source_path,
|
||||
step_path=spec.step_path,
|
||||
),
|
||||
glb_path=resolved_glb_path,
|
||||
require_selector=require_selector,
|
||||
)
|
||||
|
||||
|
||||
def _entry_spec_for_target(
|
||||
target: ResolvedStepTarget,
|
||||
*,
|
||||
mesh_tolerance: float | None,
|
||||
mesh_angular_tolerance: float | None,
|
||||
) -> EntrySpec:
|
||||
python_source = _python_source_for_target(target)
|
||||
if python_source is not None:
|
||||
source = source_from_path(python_source)
|
||||
if source is None:
|
||||
raise RuntimeError(f"Python generator is not a gen_step() CAD source: {python_source}")
|
||||
spec = _entry_spec_from_source(source)
|
||||
return _with_mesh_overrides(spec, mesh_tolerance=mesh_tolerance, mesh_angular_tolerance=mesh_angular_tolerance)
|
||||
|
||||
if not target.step_path.is_file():
|
||||
raise FileNotFoundError(f"STEP file does not exist: {target.step_path}")
|
||||
return EntrySpec(
|
||||
source_ref=_repo_relative(REPO_ROOT, target.step_path),
|
||||
cad_ref=target.cad_path,
|
||||
kind=target.kind if target.kind in {"part", "assembly"} else "part",
|
||||
source_path=target.step_path,
|
||||
display_name=target.step_path.stem,
|
||||
source="imported",
|
||||
step_path=target.step_path,
|
||||
mesh_tolerance=mesh_tolerance if mesh_tolerance is not None else DEFAULT_MESH_TOLERANCE,
|
||||
mesh_angular_tolerance=(
|
||||
mesh_angular_tolerance
|
||||
if mesh_angular_tolerance is not None
|
||||
else DEFAULT_MESH_ANGULAR_TOLERANCE
|
||||
),
|
||||
mesh_tolerance_explicit=mesh_tolerance is not None,
|
||||
mesh_angular_tolerance_explicit=mesh_angular_tolerance is not None,
|
||||
)
|
||||
|
||||
|
||||
def _scene_for_regeneration(
|
||||
spec: EntrySpec,
|
||||
*,
|
||||
logger: CliLogger | None,
|
||||
force: bool,
|
||||
) -> tuple[EntrySpec, LoadedStepScene]:
|
||||
if spec.source == "generated":
|
||||
scene = run_script_generator(
|
||||
spec,
|
||||
"gen_step",
|
||||
logger=logger,
|
||||
force=force,
|
||||
load_current_scene=False,
|
||||
skip_step_write=True,
|
||||
)
|
||||
if scene is None:
|
||||
raise RuntimeError(f"Python generator did not produce a STEP scene: {spec.source_ref}")
|
||||
return spec, scene
|
||||
|
||||
with (logger.timed(f"load STEP {spec.cad_ref}") if logger is not None else _null_context()):
|
||||
scene = load_step_scene(spec.step_path)
|
||||
inferred_kind = _infer_entry_kind(spec.step_path, scene)
|
||||
if inferred_kind != spec.kind:
|
||||
spec = replace(spec, kind=inferred_kind)
|
||||
return spec, scene
|
||||
|
||||
|
||||
def _current_artifact_for_spec(
|
||||
spec: EntrySpec,
|
||||
*,
|
||||
glb_path: Path,
|
||||
require_selector: bool,
|
||||
) -> StepTopologyArtifact | None:
|
||||
if not _existing_topology_artifact_matches_spec_without_scene(spec, require_selector=require_selector):
|
||||
return None
|
||||
try:
|
||||
return validate_step_topology_artifact(
|
||||
ResolvedStepTarget(
|
||||
cad_path=spec.cad_ref,
|
||||
kind=spec.kind,
|
||||
source_path=spec.source_path,
|
||||
step_path=spec.step_path,
|
||||
),
|
||||
glb_path=glb_path,
|
||||
require_selector=require_selector,
|
||||
)
|
||||
except StepTopologyArtifactError:
|
||||
return None
|
||||
|
||||
|
||||
def _python_source_for_target(target: ResolvedStepTarget) -> Path | None:
|
||||
if target.step_path.is_file():
|
||||
return None
|
||||
if target.source_path.suffix.lower() == ".py" and target.source_path.is_file():
|
||||
return target.source_path
|
||||
candidate = target.step_path.with_suffix(".py")
|
||||
return candidate if candidate.is_file() else None
|
||||
|
||||
|
||||
def _with_mesh_overrides(
|
||||
spec: EntrySpec,
|
||||
*,
|
||||
mesh_tolerance: float | None,
|
||||
mesh_angular_tolerance: float | None,
|
||||
) -> EntrySpec:
|
||||
if mesh_tolerance is None and mesh_angular_tolerance is None:
|
||||
return spec
|
||||
return replace(
|
||||
spec,
|
||||
mesh_tolerance=mesh_tolerance if mesh_tolerance is not None else spec.mesh_tolerance,
|
||||
mesh_angular_tolerance=(
|
||||
mesh_angular_tolerance
|
||||
if mesh_angular_tolerance is not None
|
||||
else spec.mesh_angular_tolerance
|
||||
),
|
||||
mesh_tolerance_explicit=mesh_tolerance is not None,
|
||||
mesh_angular_tolerance_explicit=mesh_angular_tolerance is not None,
|
||||
)
|
||||
|
||||
|
||||
def _scene_has_assembly_structure(scene: LoadedStepScene) -> bool:
|
||||
stack = list(scene.roots)
|
||||
if len(stack) > 1:
|
||||
return True
|
||||
while stack:
|
||||
node = stack.pop()
|
||||
if node.children:
|
||||
return True
|
||||
stack.extend(node.children)
|
||||
return False
|
||||
|
||||
|
||||
def _infer_entry_kind(step_path: Path, scene: LoadedStepScene) -> str:
|
||||
metadata_kind = None
|
||||
try:
|
||||
metadata_kind = read_text_to_cad_step_metadata(step_path).get("entryKind")
|
||||
except Exception:
|
||||
metadata_kind = None
|
||||
if metadata_kind in {"part", "assembly"}:
|
||||
return metadata_kind
|
||||
return "assembly" if _scene_has_assembly_structure(scene) else "part"
|
||||
|
||||
|
||||
def _repo_relative(repo_root: Path, path: Path) -> str:
|
||||
resolved = path.resolve()
|
||||
try:
|
||||
return resolved.relative_to(repo_root.resolve()).as_posix()
|
||||
except ValueError:
|
||||
return resolved.as_posix()
|
||||
|
||||
|
||||
class _null_context:
|
||||
def __enter__(self) -> None:
|
||||
return None
|
||||
|
||||
def __exit__(self, *_args: object) -> None:
|
||||
return None
|
||||
@@ -0,0 +1,386 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from contextlib import nullcontext
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from cadpy.step_scene import LoadedStepScene, load_step_scene_from_xcaf_doc, step_file_hash
|
||||
from cadpy.step_metadata import TEXT_TO_CAD_GENERATOR, inject_text_to_cad_step_metadata
|
||||
|
||||
|
||||
def _collect_assembly_mates(shape: Any) -> list[dict[str, Any]]:
|
||||
mates: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def visit(node: Any) -> None:
|
||||
raw_mates = getattr(node, "assembly_mates", None)
|
||||
if isinstance(raw_mates, list):
|
||||
for raw_mate in raw_mates:
|
||||
if not isinstance(raw_mate, dict):
|
||||
continue
|
||||
key = repr(raw_mate)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
mate = dict(raw_mate)
|
||||
mate_id = f"m{len(mates) + 1}"
|
||||
source_label = str(
|
||||
mate.get("sourceLabel") or
|
||||
mate.get("name") or
|
||||
mate.get("label") or
|
||||
mate.get("id") or
|
||||
""
|
||||
).strip()
|
||||
mate["id"] = mate_id
|
||||
mate["label"] = mate_id
|
||||
if source_label and source_label != mate_id:
|
||||
mate["sourceLabel"] = source_label
|
||||
mates.append(mate)
|
||||
for child in list(getattr(node, "children", []) or []):
|
||||
visit(child)
|
||||
|
||||
visit(shape)
|
||||
return mates
|
||||
|
||||
|
||||
def _attach_assembly_mates(scene: LoadedStepScene, shape: Any) -> LoadedStepScene:
|
||||
assembly_mates = _collect_assembly_mates(shape)
|
||||
if assembly_mates:
|
||||
scene.assembly_mates = assembly_mates
|
||||
return scene
|
||||
|
||||
|
||||
def create_bin_xcaf_doc() -> Any:
|
||||
from OCP.BinXCAFDrivers import BinXCAFDrivers
|
||||
from build123d.exporters3d import (
|
||||
TCollection_ExtendedString,
|
||||
TDocStd_Document,
|
||||
UNITS_PER_METER,
|
||||
Unit,
|
||||
XCAFApp_Application,
|
||||
XCAFDoc_DocumentTool,
|
||||
)
|
||||
|
||||
doc = TDocStd_Document(TCollection_ExtendedString("BinXCAF"))
|
||||
application = XCAFApp_Application.GetApplication_s()
|
||||
BinXCAFDrivers.DefineFormat_s(application)
|
||||
application.NewDocument(TCollection_ExtendedString("BinXCAF"), doc)
|
||||
application.InitDocument(doc)
|
||||
XCAFDoc_DocumentTool.SetLengthUnit_s(doc, 1 / UNITS_PER_METER[Unit.MM])
|
||||
return doc
|
||||
|
||||
|
||||
def quantity_color_rgba_from_color(color: object) -> object | None:
|
||||
"""Return a Quantity_ColorRGBA with explicit linear RGB semantics.
|
||||
|
||||
build123d.Color exposes its values through Quantity_ColorRGBA.GetRGB().
|
||||
Different OCP versions can serialize that wrapped color with different
|
||||
implicit color-space assumptions, so normalize through Quantity_TOC_RGB
|
||||
before writing XCAF labels.
|
||||
"""
|
||||
if color is None:
|
||||
return None
|
||||
|
||||
if isinstance(color, tuple):
|
||||
values = tuple(max(0.0, min(1.0, float(component))) for component in color)
|
||||
if len(values) == 3:
|
||||
rgba = (values[0], values[1], values[2], 1.0)
|
||||
elif len(values) >= 4:
|
||||
rgba = (values[0], values[1], values[2], values[3])
|
||||
else:
|
||||
return None
|
||||
else:
|
||||
wrapped = getattr(color, "wrapped", None)
|
||||
if wrapped is None:
|
||||
return None
|
||||
try:
|
||||
rgb = wrapped.GetRGB()
|
||||
rgba = (
|
||||
max(0.0, min(1.0, float(rgb.Red()))),
|
||||
max(0.0, min(1.0, float(rgb.Green()))),
|
||||
max(0.0, min(1.0, float(rgb.Blue()))),
|
||||
max(0.0, min(1.0, float(wrapped.Alpha()))),
|
||||
)
|
||||
except Exception:
|
||||
return wrapped
|
||||
|
||||
from OCP.Quantity import Quantity_Color, Quantity_ColorRGBA, Quantity_TOC_RGB
|
||||
|
||||
rgb_color = Quantity_Color(rgba[0], rgba[1], rgba[2], Quantity_TOC_RGB)
|
||||
wrapped_rgba = Quantity_ColorRGBA(rgb_color)
|
||||
wrapped_rgba.SetAlpha(rgba[3])
|
||||
return wrapped_rgba
|
||||
|
||||
|
||||
def _create_bin_xcaf_doc(to_export: Any) -> Any:
|
||||
import warnings
|
||||
|
||||
from OCP.TopLoc import TopLoc_Location
|
||||
from build123d.exporters3d import (
|
||||
Compound,
|
||||
Curve,
|
||||
Part,
|
||||
PreOrderIter,
|
||||
Sketch,
|
||||
TCollection_ExtendedString,
|
||||
TDataStd_Name,
|
||||
TopExp_Explorer,
|
||||
XCAFDoc_ColorType,
|
||||
XCAFDoc_DocumentTool,
|
||||
ta,
|
||||
)
|
||||
|
||||
doc = create_bin_xcaf_doc()
|
||||
shape_tool = XCAFDoc_DocumentTool.ShapeTool_s(doc.Main())
|
||||
color_tool = XCAFDoc_DocumentTool.ColorTool_s(doc.Main())
|
||||
is_assembly = isinstance(to_export, Compound) and len(to_export.children) > 0
|
||||
shape_definitions: dict[int, object] = {}
|
||||
|
||||
def set_label_name(label: object, name: str | None) -> None:
|
||||
if name and not label.IsNull():
|
||||
TDataStd_Name.Set_s(label, TCollection_ExtendedString(str(name)))
|
||||
|
||||
def set_label_color(label: object, color: object | None) -> None:
|
||||
if color is None or label.IsNull():
|
||||
return
|
||||
wrapped = quantity_color_rgba_from_color(color)
|
||||
if wrapped is None:
|
||||
return
|
||||
color_tool.SetColor(
|
||||
label,
|
||||
wrapped,
|
||||
XCAFDoc_ColorType.XCAFDoc_ColorSurf,
|
||||
)
|
||||
|
||||
def shape_location(shape: object) -> object:
|
||||
wrapped = getattr(shape, "wrapped", None)
|
||||
if wrapped is None:
|
||||
return TopLoc_Location()
|
||||
location = getattr(wrapped, "Location", None)
|
||||
if not callable(location):
|
||||
return TopLoc_Location()
|
||||
try:
|
||||
return location()
|
||||
except Exception:
|
||||
return TopLoc_Location()
|
||||
|
||||
def shape_without_location(shape: object) -> object:
|
||||
wrapped = getattr(shape, "wrapped", None)
|
||||
if wrapped is None:
|
||||
return shape
|
||||
located = getattr(wrapped, "Located", None)
|
||||
if not callable(located):
|
||||
return wrapped
|
||||
try:
|
||||
return located(TopLoc_Location())
|
||||
except Exception:
|
||||
return wrapped
|
||||
|
||||
def shape_definition_for_tree(shape: object) -> object:
|
||||
key = id(shape)
|
||||
cached = shape_definitions.get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
children = list(getattr(shape, "children", []) or [])
|
||||
if children:
|
||||
definition_label = shape_tool.NewShape()
|
||||
shape_definitions[key] = definition_label
|
||||
set_label_name(definition_label, getattr(shape, "label", None))
|
||||
set_label_color(definition_label, getattr(shape, "color", None))
|
||||
for child in children:
|
||||
child_definition = shape_definition_for_tree(child)
|
||||
child_component = shape_tool.AddComponent(
|
||||
definition_label,
|
||||
child_definition,
|
||||
shape_location(child),
|
||||
)
|
||||
set_label_name(child_component, getattr(child, "label", None))
|
||||
set_label_color(child_component, getattr(child, "color", None))
|
||||
return definition_label
|
||||
|
||||
definition_label = shape_tool.AddShape(shape_without_location(shape), False)
|
||||
shape_definitions[key] = definition_label
|
||||
set_label_name(definition_label, getattr(shape, "label", None))
|
||||
set_label_color(definition_label, getattr(shape, "color", None))
|
||||
return definition_label
|
||||
|
||||
if is_assembly:
|
||||
shape_definition_for_tree(to_export)
|
||||
shape_tool.UpdateAssemblies()
|
||||
return doc
|
||||
|
||||
shape_tool.AddShape(to_export.wrapped, is_assembly)
|
||||
|
||||
for node in PreOrderIter(to_export):
|
||||
if not node.label and node.color is None:
|
||||
continue
|
||||
|
||||
node_label = shape_tool.FindShape(node.wrapped, findInstance=False)
|
||||
sub_node_labels = []
|
||||
if node.color is not None and isinstance(node, Compound) and not node.children:
|
||||
sub_nodes = []
|
||||
if isinstance(node, Part):
|
||||
explorer = TopExp_Explorer(node.wrapped, ta.TopAbs_SOLID)
|
||||
elif isinstance(node, Sketch):
|
||||
explorer = TopExp_Explorer(node.wrapped, ta.TopAbs_FACE)
|
||||
elif isinstance(node, Curve):
|
||||
explorer = TopExp_Explorer(node.wrapped, ta.TopAbs_EDGE)
|
||||
else:
|
||||
warnings.warn("Unknown Compound type, color not set", stacklevel=2)
|
||||
explorer = TopExp_Explorer()
|
||||
|
||||
while explorer.More():
|
||||
sub_nodes.append(explorer.Current())
|
||||
explorer.Next()
|
||||
|
||||
sub_node_labels = [
|
||||
shape_tool.FindShape(sub_node, findInstance=False)
|
||||
for sub_node in sub_nodes
|
||||
]
|
||||
set_label_name(node_label, node.label)
|
||||
|
||||
if node.color is not None:
|
||||
for label in [node_label] + sub_node_labels:
|
||||
set_label_color(label, node.color)
|
||||
|
||||
shape_tool.UpdateAssemblies()
|
||||
return doc
|
||||
|
||||
|
||||
def export_xcaf_doc_step_scene(
|
||||
doc: Any,
|
||||
output_path: Path,
|
||||
*,
|
||||
label: str | None = None,
|
||||
originating_system: str = "build123d",
|
||||
text_to_cad_entry_kind: str | None = None,
|
||||
source_path: str | None = None,
|
||||
source_hash: str | None = None,
|
||||
logger: object | None = None,
|
||||
) -> LoadedStepScene:
|
||||
step_hash = write_xcaf_doc_step_file(
|
||||
doc,
|
||||
output_path,
|
||||
label=label,
|
||||
originating_system=originating_system,
|
||||
text_to_cad_entry_kind=text_to_cad_entry_kind,
|
||||
source_path=source_path,
|
||||
source_hash=source_hash,
|
||||
logger=logger,
|
||||
)
|
||||
with (logger.timed(f"load scene from XCAF {output_path.name}") if logger is not None else nullcontext()):
|
||||
return load_step_scene_from_xcaf_doc(
|
||||
output_path,
|
||||
doc,
|
||||
step_hash=step_hash,
|
||||
)
|
||||
|
||||
|
||||
def write_xcaf_doc_step_file(
|
||||
doc: Any,
|
||||
output_path: Path,
|
||||
*,
|
||||
label: str | None = None,
|
||||
originating_system: str = "build123d",
|
||||
text_to_cad_entry_kind: str | None = None,
|
||||
source_path: str | None = None,
|
||||
source_hash: str | None = None,
|
||||
logger: object | None = None,
|
||||
) -> str:
|
||||
from build123d.exporters3d import (
|
||||
APIHeaderSection_MakeHeader,
|
||||
IFSelect_ReturnStatus,
|
||||
IGESControl_Controller,
|
||||
Interface_Static,
|
||||
Message,
|
||||
Message_Gravity,
|
||||
PrecisionMode,
|
||||
STEPCAFControl_Controller,
|
||||
STEPCAFControl_Writer,
|
||||
STEPControl_Controller,
|
||||
STEPControl_StepModelType,
|
||||
TCollection_HAsciiString,
|
||||
XSControl_WorkSession,
|
||||
)
|
||||
|
||||
output_path = output_path.expanduser().resolve()
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
messenger = Message.DefaultMessenger_s()
|
||||
for printer in messenger.Printers():
|
||||
printer.SetTraceLevel(Message_Gravity(Message_Gravity.Message_Fail))
|
||||
|
||||
session = XSControl_WorkSession()
|
||||
writer = STEPCAFControl_Writer(session, False)
|
||||
writer.SetColorMode(True)
|
||||
writer.SetLayerMode(True)
|
||||
writer.SetNameMode(True)
|
||||
|
||||
header = APIHeaderSection_MakeHeader(writer.Writer().Model())
|
||||
if label:
|
||||
header.SetName(TCollection_HAsciiString(label))
|
||||
header.SetOriginatingSystem(
|
||||
TCollection_HAsciiString(TEXT_TO_CAD_GENERATOR if text_to_cad_entry_kind else originating_system)
|
||||
)
|
||||
|
||||
STEPCAFControl_Controller.Init_s()
|
||||
STEPControl_Controller.Init_s()
|
||||
IGESControl_Controller.Init_s()
|
||||
Interface_Static.SetIVal_s("write.surfacecurve.mode", 1)
|
||||
Interface_Static.SetIVal_s("write.precision.mode", PrecisionMode.AVERAGE.value)
|
||||
with (logger.timed(f"transfer XCAF to STEP model {output_path.name}") if logger is not None else nullcontext()):
|
||||
writer.Transfer(doc, STEPControl_StepModelType.STEPControl_AsIs)
|
||||
|
||||
with (logger.timed(f"write STEP file {output_path.name}") if logger is not None else nullcontext()):
|
||||
if writer.Write(os.fspath(output_path)) != IFSelect_ReturnStatus.IFSelect_RetDone:
|
||||
raise RuntimeError(f"Failed to write STEP file: {output_path}")
|
||||
if not output_path.exists() or output_path.stat().st_size <= 0:
|
||||
raise RuntimeError(f"STEP export did not create {output_path}")
|
||||
if text_to_cad_entry_kind:
|
||||
with (logger.timed(f"inject STEP metadata {output_path.name}") if logger is not None else nullcontext()):
|
||||
inject_text_to_cad_step_metadata(
|
||||
output_path,
|
||||
entry_kind=text_to_cad_entry_kind,
|
||||
source_path=source_path,
|
||||
source_hash=source_hash,
|
||||
)
|
||||
return step_file_hash(output_path)
|
||||
|
||||
|
||||
def export_build123d_step_scene(
|
||||
to_export: Any,
|
||||
output_path: Path,
|
||||
*,
|
||||
text_to_cad_entry_kind: str | None = None,
|
||||
source_path: str | None = None,
|
||||
source_hash: str | None = None,
|
||||
) -> LoadedStepScene:
|
||||
doc = _create_bin_xcaf_doc(to_export)
|
||||
scene = export_xcaf_doc_step_scene(
|
||||
doc,
|
||||
output_path,
|
||||
label=getattr(to_export, "label", None),
|
||||
text_to_cad_entry_kind=text_to_cad_entry_kind,
|
||||
source_path=source_path,
|
||||
source_hash=source_hash,
|
||||
)
|
||||
return _attach_assembly_mates(scene, to_export)
|
||||
|
||||
|
||||
def build_build123d_step_scene(
|
||||
to_export: Any,
|
||||
output_path: Path,
|
||||
*,
|
||||
source_kind: str = "step",
|
||||
source_hash: str | None = None,
|
||||
) -> LoadedStepScene:
|
||||
doc = _create_bin_xcaf_doc(to_export)
|
||||
scene = load_step_scene_from_xcaf_doc(
|
||||
output_path,
|
||||
doc,
|
||||
source_kind=source_kind,
|
||||
source_hash=source_hash,
|
||||
)
|
||||
return _attach_assembly_mates(scene, to_export)
|
||||
@@ -0,0 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def step_file_hash(step_path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with step_path.expanduser().resolve().open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
@@ -0,0 +1,321 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import posixpath
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
TEXT_TO_CAD_GENERATOR = "cadpy"
|
||||
TEXT_TO_CAD_GENERATOR_PROPERTY = "cadpy:generator"
|
||||
TEXT_TO_CAD_ENTRY_KIND_PROPERTY = "cadpy:entryKind"
|
||||
TEXT_TO_CAD_SOURCE_PATH_PROPERTY = "cadpy:sourcePath"
|
||||
TEXT_TO_CAD_SOURCE_HASH_PROPERTY = "cadpy:sourceHash"
|
||||
TEXT_TO_CAD_METADATA_GROUP = "cadpy metadata"
|
||||
TEXT_TO_CAD_METADATA_TAIL_BYTES = 1024 * 1024
|
||||
TEXT_TO_CAD_METADATA_HEAD_BYTES = 2 * 1024 * 1024
|
||||
|
||||
|
||||
_STEP_STRING_PATTERN = r"'(?:''|[^'])*'"
|
||||
|
||||
|
||||
def _step_escape(value: object) -> str:
|
||||
return str(value).replace("'", "''")
|
||||
|
||||
|
||||
def _step_unescape(value: str) -> str:
|
||||
raw = value.strip()
|
||||
if len(raw) >= 2 and raw[0] == "'" and raw[-1] == "'":
|
||||
raw = raw[1:-1]
|
||||
return raw.replace("''", "'")
|
||||
|
||||
|
||||
def normalize_text_to_cad_entry_kind(value: object) -> str | None:
|
||||
normalized = str(value or "").strip().lower()
|
||||
return normalized if normalized in {"part", "assembly"} else None
|
||||
|
||||
|
||||
def normalize_text_to_cad_source_path(value: object) -> str | None:
|
||||
raw = str(value or "").strip().replace("\\", "/")
|
||||
if not raw or raw.startswith("/") or re.match(r"^[A-Za-z]:/", raw):
|
||||
return None
|
||||
normalized = posixpath.normpath(raw)
|
||||
if normalized in {"", ".", ".."}:
|
||||
return None
|
||||
return normalized
|
||||
|
||||
|
||||
def _max_entity_id(step_text: str) -> int:
|
||||
ids = [int(match) for match in re.findall(r"#(\d+)\s*=", step_text)]
|
||||
return max(ids, default=0)
|
||||
|
||||
|
||||
def _root_product_definition_ref(step_text: str) -> str | None:
|
||||
shape_definition_match = re.search(
|
||||
r"#\d+\s*=\s*SHAPE_DEFINITION_REPRESENTATION\s*\(\s*(#\d+)\s*,\s*#\d+\s*\)\s*;",
|
||||
step_text,
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
if shape_definition_match:
|
||||
shape_definition_ref = re.escape(shape_definition_match.group(1).lstrip("#"))
|
||||
product_shape_match = re.search(
|
||||
rf"#{shape_definition_ref}\s*=\s*PRODUCT_DEFINITION_SHAPE\s*\(.*?,\s*(#\d+)\s*\)\s*;",
|
||||
step_text,
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
if product_shape_match:
|
||||
return product_shape_match.group(1)
|
||||
match = re.search(r"#(\d+)\s*=\s*PRODUCT_DEFINITION\s*\(", step_text, re.IGNORECASE)
|
||||
return f"#{match.group(1)}" if match else None
|
||||
|
||||
|
||||
def _shape_representation_context_ref(step_text: str) -> str | None:
|
||||
match = re.search(
|
||||
r"#\d+\s*=\s*(?:ADVANCED_BREP_SHAPE_REPRESENTATION|SHAPE_REPRESENTATION)\s*\(.*?,\s*#(\d+)\s*\)\s*;",
|
||||
step_text,
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
return f"#{match.group(1)}" if match else None
|
||||
|
||||
|
||||
def _metadata_entities(
|
||||
*,
|
||||
first_id: int,
|
||||
product_definition_ref: str,
|
||||
representation_context_ref: str,
|
||||
properties: list[tuple[str, str]],
|
||||
) -> list[str]:
|
||||
next_id = first_id
|
||||
entities: list[str] = []
|
||||
for property_name, property_value in properties:
|
||||
item_id = next_id
|
||||
repr_id = next_id + 1
|
||||
prop_id = next_id + 2
|
||||
link_id = next_id + 3
|
||||
next_id += 4
|
||||
property_name_escaped = _step_escape(property_name)
|
||||
property_value_escaped = _step_escape(property_value)
|
||||
metadata_group_escaped = _step_escape(TEXT_TO_CAD_METADATA_GROUP)
|
||||
entities.extend(
|
||||
[
|
||||
(
|
||||
f"#{item_id}=DESCRIPTIVE_REPRESENTATION_ITEM("
|
||||
f"'{property_name_escaped}','{property_value_escaped}');"
|
||||
),
|
||||
(
|
||||
f"#{repr_id}=REPRESENTATION("
|
||||
f"'{property_name_escaped}',(#{item_id}),{representation_context_ref});"
|
||||
),
|
||||
(
|
||||
f"#{prop_id}=PROPERTY_DEFINITION("
|
||||
f"'{metadata_group_escaped}','{property_name_escaped}',{product_definition_ref});"
|
||||
),
|
||||
f"#{link_id}=PROPERTY_DEFINITION_REPRESENTATION(#{prop_id},#{repr_id});",
|
||||
]
|
||||
)
|
||||
return entities
|
||||
|
||||
|
||||
def inject_text_to_cad_step_metadata(
|
||||
step_path: Path,
|
||||
*,
|
||||
entry_kind: str,
|
||||
generator: str = TEXT_TO_CAD_GENERATOR,
|
||||
source_path: str | None = None,
|
||||
source_hash: str | None = None,
|
||||
) -> None:
|
||||
normalized_entry_kind = normalize_text_to_cad_entry_kind(entry_kind)
|
||||
if normalized_entry_kind is None:
|
||||
raise ValueError(f"Unsupported cadpy STEP entry kind: {entry_kind!r}")
|
||||
normalized_source_path = normalize_text_to_cad_source_path(source_path)
|
||||
if source_path and normalized_source_path is None:
|
||||
raise ValueError(f"Unsupported cadpy STEP source path: {source_path!r}")
|
||||
|
||||
step_path = step_path.expanduser().resolve()
|
||||
if _try_inject_text_to_cad_step_metadata_tail(
|
||||
step_path,
|
||||
entry_kind=normalized_entry_kind,
|
||||
generator=generator,
|
||||
source_path=normalized_source_path,
|
||||
source_hash=source_hash,
|
||||
):
|
||||
return
|
||||
|
||||
step_text = step_path.read_text(encoding="utf-8")
|
||||
product_definition_ref = _root_product_definition_ref(step_text)
|
||||
representation_context_ref = _shape_representation_context_ref(step_text)
|
||||
data_end = list(re.finditer(r"ENDSEC\s*;", step_text, re.IGNORECASE))
|
||||
if not product_definition_ref or not representation_context_ref or not data_end:
|
||||
raise RuntimeError(f"Could not locate STEP product metadata insertion point in {step_path}")
|
||||
|
||||
properties = [
|
||||
(TEXT_TO_CAD_GENERATOR_PROPERTY, generator),
|
||||
(TEXT_TO_CAD_ENTRY_KIND_PROPERTY, normalized_entry_kind),
|
||||
]
|
||||
if normalized_source_path:
|
||||
properties.append((TEXT_TO_CAD_SOURCE_PATH_PROPERTY, normalized_source_path))
|
||||
if source_hash:
|
||||
properties.append((TEXT_TO_CAD_SOURCE_HASH_PROPERTY, source_hash))
|
||||
|
||||
entities = _metadata_entities(
|
||||
first_id=_max_entity_id(step_text) + 1,
|
||||
product_definition_ref=product_definition_ref,
|
||||
representation_context_ref=representation_context_ref,
|
||||
properties=properties,
|
||||
)
|
||||
insertion = "\n" + "\n".join(entities) + "\n"
|
||||
insert_at = data_end[-1].start()
|
||||
step_path.write_text(step_text[:insert_at] + insertion + step_text[insert_at:], encoding="utf-8")
|
||||
|
||||
|
||||
def _read_step_head_text(step_path: Path, *, head_bytes: int = TEXT_TO_CAD_METADATA_HEAD_BYTES) -> str:
|
||||
with step_path.expanduser().resolve().open("rb") as handle:
|
||||
payload = handle.read(max(1, int(head_bytes)))
|
||||
return payload.decode("utf-8", errors="ignore")
|
||||
|
||||
|
||||
def _try_inject_text_to_cad_step_metadata_tail(
|
||||
step_path: Path,
|
||||
*,
|
||||
entry_kind: str,
|
||||
generator: str,
|
||||
source_path: str | None,
|
||||
source_hash: str | None,
|
||||
) -> bool:
|
||||
tail_payload, offset, is_full_file = _read_step_tail_payload(step_path)
|
||||
try:
|
||||
tail_text = tail_payload.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return False
|
||||
data_end = list(re.finditer(r"ENDSEC\s*;", tail_text, re.IGNORECASE))
|
||||
if not data_end:
|
||||
return False
|
||||
|
||||
header_text = tail_text if is_full_file else _read_step_head_text(step_path)
|
||||
product_definition_ref = _root_product_definition_ref(header_text)
|
||||
representation_context_ref = _shape_representation_context_ref(header_text)
|
||||
max_entity_id = _max_entity_id(tail_text)
|
||||
if not product_definition_ref or not representation_context_ref or max_entity_id <= 0:
|
||||
return False
|
||||
|
||||
properties = [
|
||||
(TEXT_TO_CAD_GENERATOR_PROPERTY, generator),
|
||||
(TEXT_TO_CAD_ENTRY_KIND_PROPERTY, entry_kind),
|
||||
]
|
||||
if source_path:
|
||||
properties.append((TEXT_TO_CAD_SOURCE_PATH_PROPERTY, source_path))
|
||||
if source_hash:
|
||||
properties.append((TEXT_TO_CAD_SOURCE_HASH_PROPERTY, source_hash))
|
||||
entities = _metadata_entities(
|
||||
first_id=max_entity_id + 1,
|
||||
product_definition_ref=product_definition_ref,
|
||||
representation_context_ref=representation_context_ref,
|
||||
properties=properties,
|
||||
)
|
||||
insertion = "\n" + "\n".join(entities) + "\n"
|
||||
insert_local = data_end[-1].start()
|
||||
encoded_tail = (tail_text[:insert_local] + insertion + tail_text[insert_local:]).encode("utf-8")
|
||||
with step_path.open("r+b") as handle:
|
||||
handle.seek(offset)
|
||||
handle.write(encoded_tail)
|
||||
handle.truncate()
|
||||
return True
|
||||
|
||||
|
||||
def _read_step_tail_payload(
|
||||
step_path: Path,
|
||||
*,
|
||||
tail_bytes: int = TEXT_TO_CAD_METADATA_TAIL_BYTES,
|
||||
) -> tuple[bytes, int, bool]:
|
||||
resolved = step_path.expanduser().resolve()
|
||||
size = resolved.stat().st_size
|
||||
offset = max(0, size - max(1, int(tail_bytes)))
|
||||
with resolved.open("rb") as handle:
|
||||
handle.seek(offset)
|
||||
payload = handle.read()
|
||||
return payload, offset, offset == 0
|
||||
|
||||
|
||||
def read_text_to_cad_step_metadata_text(step_text: str) -> dict[str, str]:
|
||||
descriptive_items: dict[str, tuple[str, str]] = {}
|
||||
for match in re.finditer(
|
||||
rf"#(\d+)\s*=\s*DESCRIPTIVE_REPRESENTATION_ITEM\s*\(\s*({_STEP_STRING_PATTERN})\s*,\s*({_STEP_STRING_PATTERN})\s*\)\s*;",
|
||||
step_text,
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
):
|
||||
descriptive_items[f"#{match.group(1)}"] = (_step_unescape(match.group(2)), _step_unescape(match.group(3)))
|
||||
|
||||
representations: dict[str, tuple[str, list[str]]] = {}
|
||||
for match in re.finditer(
|
||||
rf"#(\d+)\s*=\s*REPRESENTATION\s*\(\s*({_STEP_STRING_PATTERN})\s*,\s*\(([^)]*)\)\s*,\s*#\d+\s*\)\s*;",
|
||||
step_text,
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
):
|
||||
item_refs = re.findall(r"#\d+", match.group(3))
|
||||
representations[f"#{match.group(1)}"] = (_step_unescape(match.group(2)), item_refs)
|
||||
|
||||
property_definitions: dict[str, str] = {}
|
||||
for match in re.finditer(
|
||||
rf"#(\d+)\s*=\s*PROPERTY_DEFINITION\s*\(\s*({_STEP_STRING_PATTERN})\s*,\s*({_STEP_STRING_PATTERN})\s*,\s*#[0-9]+\s*\)\s*;",
|
||||
step_text,
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
):
|
||||
property_name = _step_unescape(match.group(3))
|
||||
if property_name in {
|
||||
TEXT_TO_CAD_GENERATOR_PROPERTY,
|
||||
TEXT_TO_CAD_ENTRY_KIND_PROPERTY,
|
||||
TEXT_TO_CAD_SOURCE_PATH_PROPERTY,
|
||||
TEXT_TO_CAD_SOURCE_HASH_PROPERTY,
|
||||
"cadpy:entry_kind",
|
||||
}:
|
||||
property_definitions[f"#{match.group(1)}"] = property_name
|
||||
|
||||
metadata: dict[str, str] = {}
|
||||
for match in re.finditer(
|
||||
r"#\d+\s*=\s*PROPERTY_DEFINITION_REPRESENTATION\s*\(\s*(#\d+)\s*,\s*(#\d+)\s*\)\s*;",
|
||||
step_text,
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
):
|
||||
property_name = property_definitions.get(match.group(1))
|
||||
representation = representations.get(match.group(2))
|
||||
if not property_name or not representation:
|
||||
continue
|
||||
_, item_refs = representation
|
||||
value = ""
|
||||
for item_ref in item_refs:
|
||||
item = descriptive_items.get(item_ref)
|
||||
if item is not None:
|
||||
value = item[1]
|
||||
break
|
||||
if property_name == TEXT_TO_CAD_GENERATOR_PROPERTY:
|
||||
metadata["generator"] = value
|
||||
elif property_name in {TEXT_TO_CAD_ENTRY_KIND_PROPERTY, "cadpy:entry_kind"}:
|
||||
normalized_entry_kind = normalize_text_to_cad_entry_kind(value)
|
||||
if normalized_entry_kind is not None:
|
||||
metadata["entryKind"] = normalized_entry_kind
|
||||
elif property_name == TEXT_TO_CAD_SOURCE_PATH_PROPERTY:
|
||||
normalized_source_path = normalize_text_to_cad_source_path(value)
|
||||
if normalized_source_path is not None:
|
||||
metadata["sourcePath"] = normalized_source_path
|
||||
elif property_name == TEXT_TO_CAD_SOURCE_HASH_PROPERTY:
|
||||
metadata["sourceHash"] = value
|
||||
return metadata
|
||||
|
||||
|
||||
def _read_step_tail_text(step_path: Path, *, tail_bytes: int = TEXT_TO_CAD_METADATA_TAIL_BYTES) -> tuple[str, bool]:
|
||||
payload, _offset, is_full_file = _read_step_tail_payload(step_path, tail_bytes=tail_bytes)
|
||||
return payload.decode("utf-8", errors="ignore"), is_full_file
|
||||
|
||||
|
||||
def read_text_to_cad_step_metadata(step_path: Path) -> dict[str, str]:
|
||||
tail_text, is_full_file = _read_step_tail_text(step_path)
|
||||
if (
|
||||
TEXT_TO_CAD_GENERATOR_PROPERTY not in tail_text
|
||||
and TEXT_TO_CAD_ENTRY_KIND_PROPERTY not in tail_text
|
||||
and TEXT_TO_CAD_SOURCE_PATH_PROPERTY not in tail_text
|
||||
and TEXT_TO_CAD_SOURCE_HASH_PROPERTY not in tail_text
|
||||
):
|
||||
return {}
|
||||
metadata = read_text_to_cad_step_metadata_text(tail_text)
|
||||
if metadata or is_full_file:
|
||||
return metadata
|
||||
return read_text_to_cad_step_metadata_text(step_path.read_text(encoding="utf-8"))
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,483 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from cadpy.assembly_spec import REPO_ROOT, find_step_path, resolve_cad_source_path
|
||||
from cadpy.cad_ref_syntax import normalize_cad_path, parse_cad_tokens
|
||||
from cadpy.catalog import find_source_by_cad_ref
|
||||
from cadpy.glb_topology import (
|
||||
STEP_EDGE_BARYCENTRIC_ATTRIBUTE,
|
||||
STEP_EDGE_CLASS_ATTRIBUTE,
|
||||
STEP_EDGE_VISIBILITY_CLASSES,
|
||||
STEP_TOPOLOGY_SCHEMA_VERSION,
|
||||
glb_primitives_have_surface_edge_attributes,
|
||||
glb_surface_edge_class_has_nonzero_values,
|
||||
normalize_step_edge_render_visibility_classes,
|
||||
read_step_display_edge_manifest_from_glb,
|
||||
read_step_topology_bundle_from_glb,
|
||||
read_step_topology_manifest_from_glb,
|
||||
)
|
||||
from cadpy.render import existing_part_glb_path, part_glb_path
|
||||
from cadpy.selector_types import SelectorBundle
|
||||
from cadpy.step_hash import step_file_hash
|
||||
|
||||
|
||||
STEP_SUFFIXES = (".step", ".stp")
|
||||
REGENERATE_STEP_COMMAND = "python scripts/step"
|
||||
REGENERATE_STEP_PROMPT = "Regenerate STEP artifacts with the following command using the CAD skill:"
|
||||
|
||||
|
||||
class CadRefError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EntryTarget:
|
||||
cad_path: str
|
||||
selectors: tuple[str, ...] = ()
|
||||
|
||||
@property
|
||||
def token(self) -> str:
|
||||
from cadpy.cad_ref_syntax import build_cad_token
|
||||
|
||||
if not self.selectors:
|
||||
return build_cad_token(self.cad_path)
|
||||
return build_cad_token(self.cad_path, ",".join(self.selectors))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolvedStepTarget:
|
||||
cad_path: str
|
||||
kind: str
|
||||
source_path: Path
|
||||
step_path: Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StepTopologyArtifact:
|
||||
cad_path: str
|
||||
kind: str
|
||||
source_path: Path
|
||||
step_path: Path
|
||||
glb_path: Path
|
||||
manifest: dict[str, object]
|
||||
selector_bundle: SelectorBundle | None = None
|
||||
|
||||
|
||||
class StepTopologyArtifactError(CadRefError):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
code: str,
|
||||
message: str,
|
||||
cad_path: str,
|
||||
step_path: Path,
|
||||
glb_path: Path,
|
||||
regenerate_command: str,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.cad_path = cad_path
|
||||
self.step_path = step_path
|
||||
self.glb_path = glb_path
|
||||
self.regenerate_command = regenerate_command
|
||||
|
||||
def to_error(self) -> dict[str, object]:
|
||||
return {
|
||||
"code": self.code,
|
||||
"message": str(self),
|
||||
"cadPath": self.cad_path,
|
||||
"stepPath": _relative_to_repo(self.step_path),
|
||||
"glbPath": _relative_to_repo(self.glb_path),
|
||||
"regenerateCommand": self.regenerate_command,
|
||||
}
|
||||
|
||||
|
||||
def cad_ref_error_payload(exc: CadRefError) -> dict[str, object]:
|
||||
if isinstance(exc, StepTopologyArtifactError):
|
||||
return exc.to_error()
|
||||
return {"message": str(exc)}
|
||||
|
||||
|
||||
def cad_path_from_target(target: str) -> str:
|
||||
return entry_target_from_target(target).cad_path
|
||||
|
||||
|
||||
def entry_target_from_target(target: str) -> EntryTarget:
|
||||
parsed_tokens = parse_cad_tokens(target)
|
||||
if parsed_tokens:
|
||||
raise CadRefError("Selector refs require an explicit STEP target argument.")
|
||||
raw_target = str(target or "").strip()
|
||||
if _raw_step_path(raw_target) is not None:
|
||||
normalized = normalize_cad_path(raw_target)
|
||||
if normalized is not None:
|
||||
return EntryTarget(normalized)
|
||||
normalized = normalize_cad_path(target)
|
||||
if normalized is None:
|
||||
raise CadRefError(f"Invalid CAD entry target: {target}")
|
||||
return EntryTarget(normalized)
|
||||
|
||||
|
||||
def step_path_from_target(target: str) -> Path:
|
||||
raw_step_path = _raw_step_path(str(target or "").strip())
|
||||
if raw_step_path is not None:
|
||||
return raw_step_path
|
||||
|
||||
entry_target = entry_target_from_target(target)
|
||||
lookup_cad_path = _lookup_cad_path(entry_target.cad_path)
|
||||
step_path = find_step_path(lookup_cad_path)
|
||||
if step_path is not None:
|
||||
return step_path
|
||||
|
||||
direct_step_path = _direct_step_path(entry_target.cad_path)
|
||||
if direct_step_path is not None:
|
||||
return direct_step_path
|
||||
|
||||
raise CadRefError(f"STEP file not found for target '{target}'.")
|
||||
|
||||
|
||||
def resolve_step_target(target: str) -> ResolvedStepTarget:
|
||||
entry_target = entry_target_from_target(target)
|
||||
cad_path = entry_target.cad_path
|
||||
raw_step_path = _raw_step_path(str(target or "").strip())
|
||||
if raw_step_path is not None:
|
||||
lookup_cad_path = _lookup_cad_path(cad_path)
|
||||
source = find_source_by_cad_ref(lookup_cad_path)
|
||||
resolved_step_path = source.step_path if source is not None else None
|
||||
if source is not None and resolved_step_path is not None and resolved_step_path.resolve() == raw_step_path.resolve():
|
||||
return ResolvedStepTarget(
|
||||
cad_path=cad_path,
|
||||
kind=source.kind,
|
||||
source_path=source.source_path,
|
||||
step_path=raw_step_path,
|
||||
)
|
||||
return ResolvedStepTarget(
|
||||
cad_path=cad_path,
|
||||
kind="part",
|
||||
source_path=raw_step_path,
|
||||
step_path=raw_step_path,
|
||||
)
|
||||
|
||||
lookup_cad_path = _lookup_cad_path(cad_path)
|
||||
source = find_source_by_cad_ref(lookup_cad_path)
|
||||
if source is not None and source.kind in {"part", "assembly"}:
|
||||
if source.step_path is None:
|
||||
raise CadRefError(f"STEP file not found for ref '{cad_path}'.")
|
||||
return ResolvedStepTarget(
|
||||
cad_path=cad_path,
|
||||
kind=source.kind,
|
||||
source_path=source.source_path,
|
||||
step_path=source.step_path.resolve(),
|
||||
)
|
||||
if source is not None:
|
||||
raise CadRefError(f"CAD target '{cad_path}' is not STEP-backed.")
|
||||
|
||||
direct_step_path = _direct_step_path(cad_path)
|
||||
if direct_step_path is not None:
|
||||
return ResolvedStepTarget(
|
||||
cad_path=cad_path,
|
||||
kind="part",
|
||||
source_path=direct_step_path,
|
||||
step_path=direct_step_path,
|
||||
)
|
||||
|
||||
raise CadRefError(f"CAD STEP ref not found for '{cad_path}'.")
|
||||
|
||||
|
||||
def validate_step_topology_artifact(
|
||||
target: ResolvedStepTarget,
|
||||
*,
|
||||
glb_path: Path | None = None,
|
||||
require_selector: bool = False,
|
||||
) -> StepTopologyArtifact:
|
||||
resolved_glb_path = glb_path or existing_part_glb_path(target.step_path) or part_glb_path(target.step_path)
|
||||
if not resolved_glb_path.is_file():
|
||||
raise _topology_artifact_error(
|
||||
code="missing_glb",
|
||||
reason="STEP topology validation requires the generated GLB artifact, but it is missing",
|
||||
cad_path=target.cad_path,
|
||||
kind=target.kind,
|
||||
source_path=target.source_path,
|
||||
step_path=target.step_path,
|
||||
glb_path=resolved_glb_path,
|
||||
)
|
||||
|
||||
manifest = read_step_topology_manifest_from_glb(resolved_glb_path)
|
||||
if manifest is None:
|
||||
raise _topology_artifact_error(
|
||||
code="missing_step_topology",
|
||||
reason="STEP topology validation requires readable STEP_topology indexView in the GLB",
|
||||
cad_path=target.cad_path,
|
||||
kind=target.kind,
|
||||
source_path=target.source_path,
|
||||
step_path=target.step_path,
|
||||
glb_path=resolved_glb_path,
|
||||
)
|
||||
try:
|
||||
schema_version = int(manifest.get("schemaVersion") or 0)
|
||||
except (TypeError, ValueError):
|
||||
schema_version = 0
|
||||
if schema_version != STEP_TOPOLOGY_SCHEMA_VERSION:
|
||||
raise _topology_artifact_error(
|
||||
code="unsupported_step_topology",
|
||||
reason=f"STEP topology validation requires STEP_topology schemaVersion {STEP_TOPOLOGY_SCHEMA_VERSION} in the GLB",
|
||||
cad_path=target.cad_path,
|
||||
kind=target.kind,
|
||||
source_path=target.source_path,
|
||||
step_path=target.step_path,
|
||||
glb_path=resolved_glb_path,
|
||||
)
|
||||
source_kind = str(manifest.get("sourceKind") or "step").strip().lower()
|
||||
manifest_source_path = _source_path_from_manifest(manifest, glb_path=resolved_glb_path)
|
||||
if manifest_source_path is None:
|
||||
raise _topology_artifact_error(
|
||||
code="missing_source_path",
|
||||
reason="GLB STEP_topology is missing required sourcePath identity",
|
||||
cad_path=target.cad_path,
|
||||
kind=target.kind,
|
||||
source_path=target.source_path,
|
||||
step_path=target.step_path,
|
||||
glb_path=resolved_glb_path,
|
||||
)
|
||||
if source_kind == "python":
|
||||
if manifest_source_path.suffix.lower() != ".py":
|
||||
raise _topology_artifact_error(
|
||||
code="missing_source_path",
|
||||
reason="GLB STEP_topology Python sourcePath must point at a Python generator",
|
||||
cad_path=target.cad_path,
|
||||
kind=target.kind,
|
||||
source_path=target.source_path,
|
||||
step_path=target.step_path,
|
||||
glb_path=resolved_glb_path,
|
||||
)
|
||||
step_hash = str(manifest.get("stepHash") or "").strip()
|
||||
if target.step_path.is_file():
|
||||
if not step_hash:
|
||||
raise _topology_artifact_error(
|
||||
code="missing_step_hash",
|
||||
reason="GLB STEP_topology is missing STEP file identity",
|
||||
cad_path=target.cad_path,
|
||||
kind=target.kind,
|
||||
source_path=target.source_path,
|
||||
step_path=target.step_path,
|
||||
glb_path=resolved_glb_path,
|
||||
)
|
||||
current_step_hash = step_file_hash(target.step_path)
|
||||
if step_hash != current_step_hash:
|
||||
raise _topology_artifact_error(
|
||||
code="stale_step_artifact",
|
||||
reason="Generated GLB doesn't match the hash of the STEP file",
|
||||
cad_path=target.cad_path,
|
||||
kind=target.kind,
|
||||
source_path=target.source_path,
|
||||
step_path=target.step_path,
|
||||
glb_path=resolved_glb_path,
|
||||
)
|
||||
|
||||
edge_manifest = read_step_display_edge_manifest_from_glb(resolved_glb_path)
|
||||
if edge_manifest is None:
|
||||
raise _topology_artifact_error(
|
||||
code="missing_edge_topology",
|
||||
reason="STEP topology validation requires readable STEP_topology edgeView in the GLB",
|
||||
cad_path=target.cad_path,
|
||||
kind=target.kind,
|
||||
source_path=target.source_path,
|
||||
step_path=target.step_path,
|
||||
glb_path=resolved_glb_path,
|
||||
)
|
||||
try:
|
||||
edge_schema_version = int(edge_manifest.get("schemaVersion") or 0)
|
||||
except (TypeError, ValueError):
|
||||
edge_schema_version = 0
|
||||
primitive_attributes = edge_manifest.get("primitiveAttributes")
|
||||
edge_rendering = edge_manifest.get("edgeRendering")
|
||||
index_edge_rendering = manifest.get("edgeRendering")
|
||||
edge_visibility_classes = (
|
||||
normalize_step_edge_render_visibility_classes(edge_rendering.get("visibilityClasses"))
|
||||
if isinstance(edge_rendering, dict)
|
||||
else ()
|
||||
)
|
||||
index_edge_visibility_classes = (
|
||||
normalize_step_edge_render_visibility_classes(index_edge_rendering.get("visibilityClasses"))
|
||||
if isinstance(index_edge_rendering, dict)
|
||||
else ()
|
||||
)
|
||||
edge_matches_source = (
|
||||
not step_hash
|
||||
or str(edge_manifest.get("stepHash") or "").strip() == step_hash
|
||||
)
|
||||
if (
|
||||
edge_schema_version != STEP_TOPOLOGY_SCHEMA_VERSION
|
||||
or edge_manifest.get("profile") != "surface-edges"
|
||||
or str(edge_manifest.get("sourcePath") or "").strip() != str(manifest.get("sourcePath") or "").strip()
|
||||
or not edge_matches_source
|
||||
or not edge_visibility_classes
|
||||
or edge_visibility_classes != index_edge_visibility_classes
|
||||
or STEP_EDGE_VISIBILITY_CLASSES["FEATURE"] not in edge_visibility_classes
|
||||
or not isinstance(edge_manifest.get("buffers"), dict)
|
||||
or not isinstance(edge_manifest.get("buffers", {}).get("views"), dict)
|
||||
or "surfaceHalfEdges" not in edge_manifest.get("buffers", {}).get("views", {})
|
||||
):
|
||||
raise _topology_artifact_error(
|
||||
code="missing_edge_topology",
|
||||
reason=f"STEP topology validation requires STEP_topology edgeView schemaVersion {STEP_TOPOLOGY_SCHEMA_VERSION} in the GLB",
|
||||
cad_path=target.cad_path,
|
||||
kind=target.kind,
|
||||
source_path=target.source_path,
|
||||
step_path=target.step_path,
|
||||
glb_path=resolved_glb_path,
|
||||
)
|
||||
if not (
|
||||
isinstance(primitive_attributes, dict)
|
||||
and primitive_attributes.get("barycentric") == STEP_EDGE_BARYCENTRIC_ATTRIBUTE
|
||||
and primitive_attributes.get("class") == STEP_EDGE_CLASS_ATTRIBUTE
|
||||
and glb_primitives_have_surface_edge_attributes(resolved_glb_path)
|
||||
):
|
||||
raise _topology_artifact_error(
|
||||
code="missing_surface_edge_attributes",
|
||||
reason=f"STEP topology validation requires {STEP_EDGE_BARYCENTRIC_ATTRIBUTE} and {STEP_EDGE_CLASS_ATTRIBUTE} on STEP mesh primitives",
|
||||
cad_path=target.cad_path,
|
||||
kind=target.kind,
|
||||
source_path=target.source_path,
|
||||
step_path=target.step_path,
|
||||
glb_path=resolved_glb_path,
|
||||
)
|
||||
edge_stats = edge_manifest.get("stats")
|
||||
surface_half_edge_count = (
|
||||
int(edge_stats.get("surfaceHalfEdgeCount") or 0)
|
||||
if isinstance(edge_stats, dict)
|
||||
else 0
|
||||
)
|
||||
generated_counts = (
|
||||
edge_rendering.get("generatedVisibilityClassCounts")
|
||||
if isinstance(edge_rendering, dict) and isinstance(edge_rendering.get("generatedVisibilityClassCounts"), dict)
|
||||
else {}
|
||||
)
|
||||
expects_surface_edge_classes = surface_half_edge_count > 0 or any(
|
||||
int(value or 0) > 0 for value in generated_counts.values()
|
||||
)
|
||||
if expects_surface_edge_classes and not glb_surface_edge_class_has_nonzero_values(resolved_glb_path):
|
||||
raise _topology_artifact_error(
|
||||
code="missing_surface_edge_attributes",
|
||||
reason=f"STEP topology validation requires nonzero {STEP_EDGE_CLASS_ATTRIBUTE} values for generated surface edges",
|
||||
cad_path=target.cad_path,
|
||||
kind=target.kind,
|
||||
source_path=target.source_path,
|
||||
step_path=target.step_path,
|
||||
glb_path=resolved_glb_path,
|
||||
)
|
||||
|
||||
selector_bundle = None
|
||||
if require_selector:
|
||||
selector_bundle = read_step_topology_bundle_from_glb(resolved_glb_path)
|
||||
if selector_bundle is None:
|
||||
raise _topology_artifact_error(
|
||||
code="missing_selector_topology",
|
||||
reason="STEP topology validation requires readable STEP_topology selectorView in the GLB",
|
||||
cad_path=target.cad_path,
|
||||
kind=target.kind,
|
||||
source_path=target.source_path,
|
||||
step_path=target.step_path,
|
||||
glb_path=resolved_glb_path,
|
||||
)
|
||||
|
||||
return StepTopologyArtifact(
|
||||
cad_path=target.cad_path,
|
||||
kind=target.kind,
|
||||
source_path=target.source_path,
|
||||
step_path=target.step_path,
|
||||
glb_path=resolved_glb_path,
|
||||
manifest=manifest,
|
||||
selector_bundle=selector_bundle,
|
||||
)
|
||||
|
||||
|
||||
def _direct_step_path(cad_path: str) -> Path | None:
|
||||
for suffix in STEP_SUFFIXES:
|
||||
candidate = (REPO_ROOT / f"{cad_path}{suffix}").resolve()
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _raw_step_path(target: str) -> Path | None:
|
||||
if not target:
|
||||
return None
|
||||
path = Path(target).expanduser()
|
||||
if path.suffix.lower() not in STEP_SUFFIXES:
|
||||
return None
|
||||
resolved = path.resolve() if path.is_absolute() else (REPO_ROOT / path).resolve()
|
||||
return resolved if resolved.is_file() else None
|
||||
|
||||
|
||||
def _source_path_from_manifest(manifest: dict[str, object] | None, *, glb_path: Path) -> Path | None:
|
||||
raw_path = str((manifest or {}).get("sourcePath") or "").strip()
|
||||
if not raw_path:
|
||||
return None
|
||||
return _resolved_manifest_path(raw_path, base_dir=glb_path.parent)
|
||||
|
||||
|
||||
def _resolved_manifest_path(raw_path: str, *, base_dir: Path) -> Path | None:
|
||||
if not raw_path:
|
||||
return None
|
||||
candidates = (
|
||||
_resolve_manifest_path_from_base(raw_path, base_dir),
|
||||
_resolve_manifest_path_from_base(raw_path, REPO_ROOT),
|
||||
)
|
||||
existing = next((candidate for candidate in candidates if candidate is not None and candidate.is_file()), None)
|
||||
if existing is not None:
|
||||
return existing
|
||||
return next((candidate for candidate in candidates if candidate is not None), None)
|
||||
|
||||
|
||||
def _resolve_manifest_path_from_base(raw_path: str, base_dir: Path) -> Path | None:
|
||||
path = Path(str(raw_path).replace("\\", "/"))
|
||||
resolved = path.resolve() if path.is_absolute() else (base_dir / path).resolve()
|
||||
try:
|
||||
resolved.relative_to(REPO_ROOT)
|
||||
except ValueError:
|
||||
return None
|
||||
return resolved
|
||||
|
||||
|
||||
def _topology_artifact_error(
|
||||
*,
|
||||
code: str,
|
||||
reason: str,
|
||||
cad_path: str,
|
||||
kind: str,
|
||||
source_path: Path,
|
||||
step_path: Path,
|
||||
glb_path: Path,
|
||||
) -> StepTopologyArtifactError:
|
||||
return StepTopologyArtifactError(
|
||||
code=code,
|
||||
cad_path=cad_path,
|
||||
step_path=step_path,
|
||||
glb_path=glb_path,
|
||||
regenerate_command=REGENERATE_STEP_COMMAND,
|
||||
message=(
|
||||
f"{reason}: {_relative_to_repo(glb_path)}.\n"
|
||||
f"{REGENERATE_STEP_PROMPT}"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _cad_path_lookup_candidates(cad_path: str) -> tuple[str, ...]:
|
||||
return (cad_path,) if cad_path else ()
|
||||
|
||||
|
||||
def _lookup_cad_path(cad_path: str) -> str:
|
||||
for candidate in _cad_path_lookup_candidates(cad_path):
|
||||
if resolve_cad_source_path(candidate) is not None:
|
||||
return candidate
|
||||
return cad_path
|
||||
|
||||
|
||||
def _relative_to_repo(path: Path) -> str:
|
||||
resolved = path.resolve()
|
||||
try:
|
||||
return resolved.relative_to(REPO_ROOT).as_posix()
|
||||
except ValueError:
|
||||
return resolved.as_posix()
|
||||
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from OCP.StlAPI import StlAPI_Writer
|
||||
|
||||
from cadpy.render import REPO_ROOT, part_stl_path
|
||||
from cadpy.step_scene import (
|
||||
LoadedStepScene,
|
||||
scene_export_shape,
|
||||
)
|
||||
|
||||
|
||||
def _display_path(path: Path) -> str:
|
||||
resolved = path.resolve()
|
||||
try:
|
||||
return resolved.relative_to(REPO_ROOT).as_posix()
|
||||
except ValueError:
|
||||
return resolved.as_posix()
|
||||
|
||||
|
||||
def export_part_stl_from_scene(step_path: Path, scene: LoadedStepScene, *, target_path: Path | None = None) -> Path:
|
||||
target_path = target_path or part_stl_path(step_path)
|
||||
export_shape_stl(scene_export_shape(scene), target_path)
|
||||
return target_path
|
||||
|
||||
def export_shape_stl(shape: object, target_path: Path) -> Path:
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
writer = StlAPI_Writer()
|
||||
writer.ASCIIMode = False
|
||||
if not writer.Write(shape, str(target_path)):
|
||||
raise RuntimeError(f"Failed to write STL output: {_display_path(target_path)}")
|
||||
return target_path
|
||||
@@ -0,0 +1,565 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import zipfile
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from OCP.BRep import BRep_Tool
|
||||
from OCP.TopAbs import TopAbs_FACE, TopAbs_REVERSED
|
||||
from OCP.TopExp import TopExp_Explorer
|
||||
from OCP.TopLoc import TopLoc_Location
|
||||
from OCP.TopoDS import TopoDS
|
||||
|
||||
from cadpy.render import REPO_ROOT, part_3mf_path
|
||||
from cadpy.step_scene import ColorRGBA, LoadedStepScene, OccurrenceNode, _shape_hash, occurrence_selector_id
|
||||
|
||||
|
||||
CORE_NS = "http://schemas.microsoft.com/3dmanufacturing/core/2015/02"
|
||||
CONTENT_TYPES_NS = "http://schemas.openxmlformats.org/package/2006/content-types"
|
||||
RELATIONSHIPS_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
|
||||
MODEL_REL_TYPE = "http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel"
|
||||
DEFAULT_MATERIAL: ColorRGBA = (0.4677837961, 0.5520114015, 0.6172065624, 1.0)
|
||||
MATERIAL_RESOURCE_ID = "1"
|
||||
|
||||
ET.register_namespace("", CORE_NS)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _FaceMesh:
|
||||
face_hash: int
|
||||
vertices: list[tuple[float, float, float]]
|
||||
triangles: list[tuple[int, int, int]]
|
||||
|
||||
|
||||
class _MaterialRegistry:
|
||||
def __init__(self, default_color: ColorRGBA = DEFAULT_MATERIAL) -> None:
|
||||
self._materials: list[tuple[str, ColorRGBA]] = []
|
||||
self._indices: dict[tuple[int, int, int, int], int] = {}
|
||||
self._default_color = _normalize_rgba(default_color)
|
||||
|
||||
@staticmethod
|
||||
def color_key(color: ColorRGBA) -> tuple[int, int, int, int]:
|
||||
red, green, blue, alpha = _normalize_rgba(color)
|
||||
return (
|
||||
_srgb_byte_from_linear(red),
|
||||
_srgb_byte_from_linear(green),
|
||||
_srgb_byte_from_linear(blue),
|
||||
_byte_from_unit_channel(alpha),
|
||||
)
|
||||
|
||||
def index(self, color: ColorRGBA, *, name: str | None = None) -> int:
|
||||
normalized = _normalize_rgba(color)
|
||||
key = self.color_key(normalized)
|
||||
existing = self._indices.get(key)
|
||||
if existing is not None:
|
||||
return existing
|
||||
index = len(self._materials)
|
||||
self._indices[key] = index
|
||||
default_key = self.color_key(self._default_color)
|
||||
material_name = name or (
|
||||
"Default" if key == default_key else f"Color {key[0]:02X}{key[1]:02X}{key[2]:02X}{key[3]:02X}"
|
||||
)
|
||||
self._materials.append((material_name, normalized))
|
||||
return index
|
||||
|
||||
def append_xml(self, resources: ET.Element) -> None:
|
||||
base_materials = ET.SubElement(resources, f"{{{CORE_NS}}}basematerials", {"id": MATERIAL_RESOURCE_ID})
|
||||
for name, color in self._materials:
|
||||
ET.SubElement(
|
||||
base_materials,
|
||||
f"{{{CORE_NS}}}base",
|
||||
{
|
||||
"name": name,
|
||||
"displaycolor": _display_color(color),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _display_path(path: Path) -> str:
|
||||
resolved = path.resolve()
|
||||
try:
|
||||
return resolved.relative_to(REPO_ROOT).as_posix()
|
||||
except ValueError:
|
||||
return resolved.as_posix()
|
||||
|
||||
|
||||
def export_part_3mf_from_scene(
|
||||
step_path: Path,
|
||||
scene: LoadedStepScene,
|
||||
*,
|
||||
target_path: Path | None = None,
|
||||
color: ColorRGBA | None = None,
|
||||
occurrence_colors: Mapping[str, ColorRGBA] | None = None,
|
||||
) -> Path:
|
||||
target_path = target_path or part_3mf_path(step_path)
|
||||
export_scene_3mf(scene, target_path, color=color, occurrence_colors=occurrence_colors)
|
||||
return target_path
|
||||
|
||||
|
||||
def _normalize_rgba(color: ColorRGBA | tuple[float, float, float] | None) -> ColorRGBA:
|
||||
if color is None:
|
||||
return DEFAULT_MATERIAL
|
||||
if len(color) == 3:
|
||||
red, green, blue = color
|
||||
alpha = 1.0
|
||||
else:
|
||||
red, green, blue, alpha = color
|
||||
return (
|
||||
max(0.0, min(1.0, float(red))),
|
||||
max(0.0, min(1.0, float(green))),
|
||||
max(0.0, min(1.0, float(blue))),
|
||||
max(0.0, min(1.0, float(alpha))),
|
||||
)
|
||||
|
||||
|
||||
def _byte_from_unit_channel(channel: float) -> int:
|
||||
return max(0, min(255, int(round(channel * 255.0))))
|
||||
|
||||
|
||||
def _srgb_byte_from_linear(channel: float) -> int:
|
||||
if channel <= 0.0031308:
|
||||
encoded = 12.92 * channel
|
||||
else:
|
||||
encoded = 1.055 * math.pow(channel, 1.0 / 2.4) - 0.055
|
||||
return _byte_from_unit_channel(encoded)
|
||||
|
||||
|
||||
def _display_color(color: ColorRGBA) -> str:
|
||||
red, green, blue, alpha = _MaterialRegistry.color_key(color)
|
||||
return f"#{red:02X}{green:02X}{blue:02X}{alpha:02X}"
|
||||
|
||||
|
||||
def _point_from_occ(point: object, location: TopLoc_Location) -> tuple[float, float, float]:
|
||||
transformed = point.Transformed(location.Transformation())
|
||||
return (float(transformed.X()), float(transformed.Y()), float(transformed.Z()))
|
||||
|
||||
|
||||
def _triangle_area_twice(a: tuple[float, float, float], b: tuple[float, float, float], c: tuple[float, float, float]) -> float:
|
||||
abx = b[0] - a[0]
|
||||
aby = b[1] - a[1]
|
||||
abz = b[2] - a[2]
|
||||
acx = c[0] - a[0]
|
||||
acy = c[1] - a[1]
|
||||
acz = c[2] - a[2]
|
||||
cross_x = aby * acz - abz * acy
|
||||
cross_y = abz * acx - abx * acz
|
||||
cross_z = abx * acy - aby * acx
|
||||
return math.sqrt(cross_x * cross_x + cross_y * cross_y + cross_z * cross_z)
|
||||
|
||||
|
||||
def _format_number(value: float) -> str:
|
||||
if not math.isfinite(value):
|
||||
raise ValueError(f"3MF mesh coordinate must be finite, got {value!r}")
|
||||
return f"{value:.9g}"
|
||||
|
||||
|
||||
def _iter_shape_face_meshes(shape: object):
|
||||
explorer = TopExp_Explorer(shape, TopAbs_FACE)
|
||||
while explorer.More():
|
||||
face = TopoDS.Face_s(explorer.Current())
|
||||
location = TopLoc_Location()
|
||||
triangulation = BRep_Tool.Triangulation_s(face, location)
|
||||
if triangulation is not None:
|
||||
nodes = [
|
||||
_point_from_occ(triangulation.Node(index), location)
|
||||
for index in range(1, triangulation.NbNodes() + 1)
|
||||
]
|
||||
triangles: list[tuple[int, int, int]] = []
|
||||
for index in range(1, triangulation.NbTriangles() + 1):
|
||||
node_a, node_b, node_c = triangulation.Triangle(index).Get()
|
||||
vertex_indices = [node_a - 1, node_b - 1, node_c - 1]
|
||||
if face.Orientation() == TopAbs_REVERSED:
|
||||
vertex_indices[1], vertex_indices[2] = vertex_indices[2], vertex_indices[1]
|
||||
vertices = [nodes[node_index] for node_index in vertex_indices]
|
||||
if _triangle_area_twice(vertices[0], vertices[1], vertices[2]) > 1e-12:
|
||||
triangles.append(tuple(vertex_indices))
|
||||
if triangles:
|
||||
yield _FaceMesh(_shape_hash(face), nodes, triangles)
|
||||
explorer.Next()
|
||||
|
||||
|
||||
def _object_name(name: str | None, fallback: str) -> str:
|
||||
normalized = " ".join((name or "").split())
|
||||
return normalized or fallback
|
||||
|
||||
|
||||
def _identity_transform() -> tuple[float, ...]:
|
||||
return (
|
||||
1.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
1.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
1.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
1.0,
|
||||
)
|
||||
|
||||
|
||||
def _is_identity_transform(transform: tuple[float, ...] | None) -> bool:
|
||||
if transform is None or len(transform) != 16:
|
||||
return True
|
||||
identity = _identity_transform()
|
||||
return all(abs(float(actual) - expected) <= 1e-12 for actual, expected in zip(transform, identity))
|
||||
|
||||
|
||||
def _format_transform(transform: tuple[float, ...] | None) -> str | None:
|
||||
if _is_identity_transform(transform):
|
||||
return None
|
||||
if transform is None or len(transform) != 16:
|
||||
raise ValueError("3MF component transform must be a 4x4 matrix")
|
||||
values = (
|
||||
transform[0],
|
||||
transform[4],
|
||||
transform[8],
|
||||
transform[1],
|
||||
transform[5],
|
||||
transform[9],
|
||||
transform[2],
|
||||
transform[6],
|
||||
transform[10],
|
||||
transform[3],
|
||||
transform[7],
|
||||
transform[11],
|
||||
)
|
||||
return " ".join(_format_number(value) for value in values)
|
||||
|
||||
|
||||
def _append_mesh_object(
|
||||
resources: ET.Element,
|
||||
*,
|
||||
object_id: int,
|
||||
name: str,
|
||||
shape: object,
|
||||
materials: _MaterialRegistry,
|
||||
default_color: ColorRGBA,
|
||||
face_colors: dict[int, ColorRGBA],
|
||||
) -> None:
|
||||
face_meshes = list(_iter_shape_face_meshes(shape))
|
||||
if not face_meshes:
|
||||
raise RuntimeError(f"Cannot write empty 3MF mesh object: {name}")
|
||||
|
||||
triangle_materials: list[int] = []
|
||||
for face_mesh in face_meshes:
|
||||
triangle_materials.extend(
|
||||
[materials.index(face_colors.get(face_mesh.face_hash, default_color))] * len(face_mesh.triangles)
|
||||
)
|
||||
unique_materials = set(triangle_materials)
|
||||
|
||||
object_attributes = {
|
||||
"id": str(object_id),
|
||||
"type": "model",
|
||||
"name": name,
|
||||
}
|
||||
if len(unique_materials) == 1:
|
||||
object_attributes["pid"] = MATERIAL_RESOURCE_ID
|
||||
object_attributes["pindex"] = str(next(iter(unique_materials)))
|
||||
model_object = ET.SubElement(resources, f"{{{CORE_NS}}}object", object_attributes)
|
||||
mesh = ET.SubElement(model_object, f"{{{CORE_NS}}}mesh")
|
||||
vertices_element = ET.SubElement(mesh, f"{{{CORE_NS}}}vertices")
|
||||
triangles_element = ET.SubElement(mesh, f"{{{CORE_NS}}}triangles")
|
||||
|
||||
vertex_index = 0
|
||||
material_index = 0
|
||||
for face_mesh in face_meshes:
|
||||
for x, y, z in face_mesh.vertices:
|
||||
ET.SubElement(
|
||||
vertices_element,
|
||||
f"{{{CORE_NS}}}vertex",
|
||||
{
|
||||
"x": _format_number(x),
|
||||
"y": _format_number(y),
|
||||
"z": _format_number(z),
|
||||
},
|
||||
)
|
||||
for a, b, c in face_mesh.triangles:
|
||||
triangle_attributes = {
|
||||
"v1": str(vertex_index + a),
|
||||
"v2": str(vertex_index + b),
|
||||
"v3": str(vertex_index + c),
|
||||
}
|
||||
if len(unique_materials) > 1:
|
||||
pindex = str(triangle_materials[material_index])
|
||||
triangle_attributes.update(
|
||||
{
|
||||
"pid": MATERIAL_RESOURCE_ID,
|
||||
"p1": pindex,
|
||||
"p2": pindex,
|
||||
"p3": pindex,
|
||||
}
|
||||
)
|
||||
ET.SubElement(triangles_element, f"{{{CORE_NS}}}triangle", triangle_attributes)
|
||||
material_index += 1
|
||||
vertex_index += len(face_mesh.vertices)
|
||||
|
||||
|
||||
def _append_component_object(
|
||||
resources: ET.Element,
|
||||
*,
|
||||
object_id: int,
|
||||
name: str,
|
||||
components: list[tuple[int, tuple[float, ...] | None]],
|
||||
) -> None:
|
||||
model_object = ET.SubElement(
|
||||
resources,
|
||||
f"{{{CORE_NS}}}object",
|
||||
{
|
||||
"id": str(object_id),
|
||||
"type": "model",
|
||||
"name": name,
|
||||
},
|
||||
)
|
||||
components_element = ET.SubElement(model_object, f"{{{CORE_NS}}}components")
|
||||
for component_object_id, transform in components:
|
||||
attributes = {"objectid": str(component_object_id)}
|
||||
transform_value = _format_transform(transform)
|
||||
if transform_value is not None:
|
||||
attributes["transform"] = transform_value
|
||||
ET.SubElement(components_element, f"{{{CORE_NS}}}component", attributes)
|
||||
|
||||
|
||||
class _SceneObjectWriter:
|
||||
def __init__(
|
||||
self,
|
||||
scene: LoadedStepScene,
|
||||
resources: ET.Element,
|
||||
materials: _MaterialRegistry,
|
||||
*,
|
||||
source_color: ColorRGBA | None,
|
||||
occurrence_colors: Mapping[str, ColorRGBA] | None,
|
||||
) -> None:
|
||||
self.scene = scene
|
||||
self.resources = resources
|
||||
self.materials = materials
|
||||
self.source_color = _normalize_rgba(source_color) if source_color is not None else None
|
||||
self.occurrence_colors = {
|
||||
str(key): _normalize_rgba(value)
|
||||
for key, value in (occurrence_colors or {}).items()
|
||||
}
|
||||
self.next_object_id = 2
|
||||
self.mesh_objects: dict[tuple[int, tuple[int, int, int, int]], int] = {}
|
||||
|
||||
def _allocate_object_id(self) -> int:
|
||||
object_id = self.next_object_id
|
||||
self.next_object_id += 1
|
||||
return object_id
|
||||
|
||||
def _occurrence_color_for_node(self, node: OccurrenceNode) -> ColorRGBA | None:
|
||||
occurrence_id = occurrence_selector_id(node)
|
||||
while occurrence_id:
|
||||
color = self.occurrence_colors.get(occurrence_id)
|
||||
if color is not None:
|
||||
return color
|
||||
if "." not in occurrence_id:
|
||||
return None
|
||||
occurrence_id = occurrence_id.rsplit(".", 1)[0]
|
||||
return None
|
||||
|
||||
def _default_color_for_node(self, node: OccurrenceNode) -> ColorRGBA:
|
||||
occurrence_color = self._occurrence_color_for_node(node)
|
||||
if occurrence_color is not None:
|
||||
return occurrence_color
|
||||
if node.color is not None:
|
||||
return _normalize_rgba(node.color)
|
||||
if node.prototype_key is not None and node.prototype_key in self.scene.prototype_colors:
|
||||
return _normalize_rgba(self.scene.prototype_colors[node.prototype_key])
|
||||
if self.source_color is not None:
|
||||
return self.source_color
|
||||
return DEFAULT_MATERIAL
|
||||
|
||||
def _mesh_object_for_node(self, node: OccurrenceNode) -> int:
|
||||
if node.prototype_key is None:
|
||||
raise RuntimeError(f"3MF leaf object has no prototype: {node.name or node.source_name or node.path}")
|
||||
shape = self.scene.prototype_shapes.get(node.prototype_key)
|
||||
if shape is None:
|
||||
raise RuntimeError(f"3MF leaf object has no prototype shape: {node.prototype_key}")
|
||||
default_color = self._default_color_for_node(node)
|
||||
object_key = (node.prototype_key, _MaterialRegistry.color_key(default_color))
|
||||
existing = self.mesh_objects.get(object_key)
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
object_id = self._allocate_object_id()
|
||||
self.mesh_objects[object_key] = object_id
|
||||
name = _object_name(
|
||||
node.source_name or self.scene.prototype_names.get(node.prototype_key) or node.name,
|
||||
f"mesh-{object_id}",
|
||||
)
|
||||
face_colors = self.scene.prototype_face_colors.get(node.prototype_key, {})
|
||||
_append_mesh_object(
|
||||
self.resources,
|
||||
object_id=object_id,
|
||||
name=name,
|
||||
shape=shape,
|
||||
materials=self.materials,
|
||||
default_color=default_color,
|
||||
face_colors=face_colors,
|
||||
)
|
||||
return object_id
|
||||
|
||||
def object_for_node(self, node: OccurrenceNode) -> int:
|
||||
if not node.children:
|
||||
return self._mesh_object_for_node(node)
|
||||
|
||||
components: list[tuple[int, tuple[float, ...] | None]] = []
|
||||
for child in node.children:
|
||||
components.append((self.object_for_node(child), child.local_transform))
|
||||
object_id = self._allocate_object_id()
|
||||
_append_component_object(
|
||||
self.resources,
|
||||
object_id=object_id,
|
||||
name=_object_name(node.name or node.source_name, f"component-{object_id}"),
|
||||
components=components,
|
||||
)
|
||||
return object_id
|
||||
|
||||
|
||||
def _build_model_root() -> tuple[ET.Element, ET.Element, ET.Element]:
|
||||
model = ET.Element(
|
||||
f"{{{CORE_NS}}}model",
|
||||
{
|
||||
"unit": "millimeter",
|
||||
"{http://www.w3.org/XML/1998/namespace}lang": "en-US",
|
||||
},
|
||||
)
|
||||
metadata = ET.SubElement(model, f"{{{CORE_NS}}}metadata", {"name": "Application"})
|
||||
metadata.text = "tom-cad"
|
||||
resources = ET.SubElement(model, f"{{{CORE_NS}}}resources")
|
||||
build = ET.SubElement(model, f"{{{CORE_NS}}}build")
|
||||
return model, resources, build
|
||||
|
||||
|
||||
def _build_shape_model_xml(shape: object, *, color: ColorRGBA | None = None) -> bytes:
|
||||
model, resources, build = _build_model_root()
|
||||
materials = _MaterialRegistry(_normalize_rgba(color))
|
||||
_append_mesh_object(
|
||||
resources,
|
||||
object_id=2,
|
||||
name="Model",
|
||||
shape=shape,
|
||||
materials=materials,
|
||||
default_color=_normalize_rgba(color),
|
||||
face_colors={},
|
||||
)
|
||||
objects = [child for child in list(resources) if child.tag == f"{{{CORE_NS}}}object"]
|
||||
for child in objects:
|
||||
resources.remove(child)
|
||||
materials.append_xml(resources)
|
||||
for child in objects:
|
||||
resources.append(child)
|
||||
ET.SubElement(build, f"{{{CORE_NS}}}item", {"objectid": "2"})
|
||||
return ET.tostring(model, encoding="utf-8", xml_declaration=True)
|
||||
|
||||
|
||||
def _build_scene_model_xml(
|
||||
scene: LoadedStepScene,
|
||||
*,
|
||||
color: ColorRGBA | None = None,
|
||||
occurrence_colors: Mapping[str, ColorRGBA] | None = None,
|
||||
) -> bytes:
|
||||
if not scene.roots:
|
||||
raise RuntimeError(f"No CAD geometry available for 3MF export: {scene.step_path}")
|
||||
|
||||
model, resources, build = _build_model_root()
|
||||
materials = _MaterialRegistry(_normalize_rgba(color))
|
||||
writer = _SceneObjectWriter(scene, resources, materials, source_color=color, occurrence_colors=occurrence_colors)
|
||||
|
||||
root_components = [(writer.object_for_node(root), root.local_transform) for root in scene.roots]
|
||||
if len(root_components) == 1:
|
||||
root_object_id, root_transform = root_components[0]
|
||||
attributes = {"objectid": str(root_object_id)}
|
||||
transform = _format_transform(root_transform)
|
||||
if transform is not None:
|
||||
attributes["transform"] = transform
|
||||
ET.SubElement(build, f"{{{CORE_NS}}}item", attributes)
|
||||
else:
|
||||
root_object_id = writer._allocate_object_id()
|
||||
_append_component_object(
|
||||
resources,
|
||||
object_id=root_object_id,
|
||||
name=_object_name(scene.step_path.stem, f"component-{root_object_id}"),
|
||||
components=root_components,
|
||||
)
|
||||
ET.SubElement(build, f"{{{CORE_NS}}}item", {"objectid": str(root_object_id)})
|
||||
|
||||
# 3MF resources can be referenced before they appear in the XML, but keeping
|
||||
# materials first makes the package easier to inspect and friendlier to parsers.
|
||||
objects = [child for child in list(resources) if child.tag == f"{{{CORE_NS}}}object"]
|
||||
for child in objects:
|
||||
resources.remove(child)
|
||||
materials.append_xml(resources)
|
||||
for child in objects:
|
||||
resources.append(child)
|
||||
|
||||
return ET.tostring(model, encoding="utf-8", xml_declaration=True)
|
||||
|
||||
|
||||
def _content_types_xml() -> bytes:
|
||||
types = ET.Element(f"{{{CONTENT_TYPES_NS}}}Types")
|
||||
ET.SubElement(
|
||||
types,
|
||||
f"{{{CONTENT_TYPES_NS}}}Default",
|
||||
{
|
||||
"Extension": "rels",
|
||||
"ContentType": "application/vnd.openxmlformats-package.relationships+xml",
|
||||
},
|
||||
)
|
||||
ET.SubElement(
|
||||
types,
|
||||
f"{{{CONTENT_TYPES_NS}}}Default",
|
||||
{
|
||||
"Extension": "model",
|
||||
"ContentType": "application/vnd.ms-package.3dmanufacturing-3dmodel+xml",
|
||||
},
|
||||
)
|
||||
return ET.tostring(types, encoding="utf-8", xml_declaration=True)
|
||||
|
||||
|
||||
def _relationships_xml() -> bytes:
|
||||
relationships = ET.Element(f"{{{RELATIONSHIPS_NS}}}Relationships")
|
||||
ET.SubElement(
|
||||
relationships,
|
||||
f"{{{RELATIONSHIPS_NS}}}Relationship",
|
||||
{
|
||||
"Target": "/3D/3dmodel.model",
|
||||
"Id": "rel0",
|
||||
"Type": MODEL_REL_TYPE,
|
||||
},
|
||||
)
|
||||
return ET.tostring(relationships, encoding="utf-8", xml_declaration=True)
|
||||
|
||||
|
||||
def _write_package(target_path: Path, model_xml: bytes) -> Path:
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with zipfile.ZipFile(target_path, "w", compression=zipfile.ZIP_DEFLATED) as package:
|
||||
package.writestr("[Content_Types].xml", _content_types_xml())
|
||||
package.writestr("_rels/.rels", _relationships_xml())
|
||||
package.writestr("3D/3dmodel.model", model_xml)
|
||||
if not target_path.exists() or target_path.stat().st_size <= 0:
|
||||
raise RuntimeError(f"Failed to write 3MF output: {_display_path(target_path)}")
|
||||
return target_path
|
||||
|
||||
|
||||
def export_scene_3mf(
|
||||
scene: LoadedStepScene,
|
||||
target_path: Path,
|
||||
*,
|
||||
color: ColorRGBA | None = None,
|
||||
occurrence_colors: Mapping[str, ColorRGBA] | None = None,
|
||||
) -> Path:
|
||||
return _write_package(target_path, _build_scene_model_xml(scene, color=color, occurrence_colors=occurrence_colors))
|
||||
|
||||
|
||||
def export_shape_3mf(shape: object, target_path: Path) -> Path:
|
||||
return _write_package(target_path, _build_shape_model_xml(shape))
|
||||
@@ -0,0 +1,119 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from cadpy.analysis import AXIS_INDEX, bbox_center, bbox_size, major_planar_face_groups
|
||||
from cadpy.lookup import build_selector_index
|
||||
|
||||
|
||||
def axis_index(axis: str) -> int:
|
||||
normalized = str(axis or "").strip().lower()
|
||||
if normalized not in AXIS_INDEX:
|
||||
raise ValueError(f"Axis must be one of x, y, z; got {axis!r}")
|
||||
return int(AXIS_INDEX[normalized])
|
||||
|
||||
|
||||
def _coerce_bbox(bbox: object) -> dict[str, object]:
|
||||
if not isinstance(bbox, dict):
|
||||
raise ValueError("Expected bbox dict with min/max coordinates.")
|
||||
if not isinstance(bbox.get("min"), list) or not isinstance(bbox.get("max"), list):
|
||||
raise ValueError("BBox dict must include min/max coordinate lists.")
|
||||
return bbox
|
||||
|
||||
|
||||
def bbox_coordinate(bbox: object, axis: str, side: str) -> float:
|
||||
normalized_bbox = _coerce_bbox(bbox)
|
||||
axis_idx = axis_index(axis)
|
||||
normalized_side = str(side or "").strip().lower()
|
||||
if normalized_side not in {"min", "max"}:
|
||||
raise ValueError(f"Side must be 'min' or 'max'; got {side!r}")
|
||||
return float(normalized_bbox[normalized_side][axis_idx])
|
||||
|
||||
|
||||
def bbox_span(bbox: object, axis: str) -> float:
|
||||
size = bbox_size(_coerce_bbox(bbox))
|
||||
if size is None:
|
||||
raise ValueError("Failed to compute bbox span.")
|
||||
return float(size[axis_index(axis)])
|
||||
|
||||
|
||||
def assert_close(actual: float, expected: float, *, tol: float = 1e-6, label: str = "value") -> float:
|
||||
actual_value = float(actual)
|
||||
expected_value = float(expected)
|
||||
if abs(actual_value - expected_value) > float(tol):
|
||||
raise AssertionError(
|
||||
f"{label} mismatch: expected {expected_value:.6f}, got {actual_value:.6f} (tol={float(tol):.6f})"
|
||||
)
|
||||
return actual_value
|
||||
|
||||
|
||||
def assert_bbox_coordinate(
|
||||
bbox: object,
|
||||
axis: str,
|
||||
side: str,
|
||||
expected: float,
|
||||
*,
|
||||
tol: float = 1e-6,
|
||||
label: str | None = None,
|
||||
) -> float:
|
||||
actual = bbox_coordinate(bbox, axis, side)
|
||||
return assert_close(actual, expected, tol=tol, label=label or f"bbox {side} {axis}")
|
||||
|
||||
|
||||
def assert_bbox_span(
|
||||
bbox: object,
|
||||
axis: str,
|
||||
expected: float,
|
||||
*,
|
||||
tol: float = 1e-6,
|
||||
label: str | None = None,
|
||||
) -> float:
|
||||
actual = bbox_span(bbox, axis)
|
||||
return assert_close(actual, expected, tol=tol, label=label or f"bbox span {axis}")
|
||||
|
||||
|
||||
def selector_count(manifest: dict[str, Any], selector_type: str) -> int:
|
||||
stats = manifest.get("stats")
|
||||
if not isinstance(stats, dict):
|
||||
return 0
|
||||
if selector_type == "shape":
|
||||
return int(stats.get("shapeCount") or 0)
|
||||
if selector_type == "face":
|
||||
return int(stats.get("faceCount") or 0)
|
||||
if selector_type == "edge":
|
||||
return int(stats.get("edgeCount") or 0)
|
||||
if selector_type == "occurrence":
|
||||
return int(stats.get("occurrenceCount") or 0)
|
||||
raise ValueError(f"Unsupported selector_type {selector_type!r}")
|
||||
|
||||
|
||||
def assert_selector_count(
|
||||
manifest: dict[str, Any],
|
||||
selector_type: str,
|
||||
expected: int,
|
||||
*,
|
||||
label: str | None = None,
|
||||
) -> int:
|
||||
actual = selector_count(manifest, selector_type)
|
||||
if actual != int(expected):
|
||||
raise AssertionError(f"{label or selector_type} count mismatch: expected {int(expected)}, got {actual}")
|
||||
return actual
|
||||
|
||||
|
||||
def geometry_summary_from_manifest(manifest: dict[str, Any]) -> dict[str, object]:
|
||||
bbox = _coerce_bbox(manifest.get("bbox"))
|
||||
summary: dict[str, object] = {
|
||||
"bbox": bbox,
|
||||
"center": bbox_center(bbox),
|
||||
"size": bbox_size(bbox),
|
||||
"occurrenceCount": selector_count(manifest, "occurrence"),
|
||||
"shapeCount": selector_count(manifest, "shape"),
|
||||
"faceCount": selector_count(manifest, "face"),
|
||||
"edgeCount": selector_count(manifest, "edge"),
|
||||
}
|
||||
try:
|
||||
index = build_selector_index(manifest)
|
||||
except Exception:
|
||||
return summary
|
||||
summary["majorPlanes"] = major_planar_face_groups(index)
|
||||
return summary
|
||||
@@ -0,0 +1 @@
|
||||
"""Wrapper package for the CAD skill snapshot command."""
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>CAD snapshot render</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 100%;
|
||||
min-height: 100%;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<script type="module" src="/snapshot-render.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
"""Unified STEP generation CLI."""
|
||||
@@ -0,0 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
if __package__ in {None, ""}:
|
||||
tool_dir = Path(__file__).resolve().parent
|
||||
if str(tool_dir) not in sys.path:
|
||||
sys.path.insert(0, str(tool_dir))
|
||||
from cli import main
|
||||
else:
|
||||
from .cli import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,137 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from collections.abc import Sequence
|
||||
|
||||
from cadpy.catalog import StepImportOptions
|
||||
from cadpy.metadata import normalize_mesh_numeric
|
||||
|
||||
|
||||
def generate_step_targets(*args, **kwargs):
|
||||
from cadpy.generation import generate_step_targets as generate
|
||||
|
||||
return generate(*args, **kwargs)
|
||||
|
||||
|
||||
def targets_include_output_pairs(targets) -> bool:
|
||||
from cadpy.generation import targets_include_output_pairs as includes_pairs
|
||||
|
||||
return includes_pairs(targets)
|
||||
|
||||
|
||||
def _normalize_cli_numeric(value: object, *, field_name: str, parser: argparse.ArgumentParser) -> float | None:
|
||||
try:
|
||||
return normalize_mesh_numeric(value, field_name=field_name)
|
||||
except ValueError as exc:
|
||||
parser.error(str(exc))
|
||||
return None
|
||||
|
||||
|
||||
def _add_step_arguments(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument(
|
||||
"targets",
|
||||
nargs="+",
|
||||
help="Explicit Python generator, STEP/STP file path, or SOURCE.py=OUTPUT.step pair to generate.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--kind",
|
||||
choices=("part", "assembly"),
|
||||
help="Required for direct STEP/STP targets. Generated Python targets infer kind from gen_step().",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-o",
|
||||
"--output",
|
||||
metavar="PATH",
|
||||
help="Write the generated STEP file to this path. Valid only with one plain generated Python target.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--stl",
|
||||
metavar="OUTPUT",
|
||||
help="Export an STL sidecar to this relative .stl path.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--3mf",
|
||||
dest="three_mf",
|
||||
metavar="OUTPUT",
|
||||
help="Export a 3MF sidecar to this relative .3mf path.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--glb",
|
||||
metavar="OUTPUT",
|
||||
help="Export a native Y-up GLB sidecar to this relative .glb path.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-step-write",
|
||||
action="store_true",
|
||||
help="For Python gen_step() targets, generate the hidden GLB/topology artifact without writing STEP.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Regenerate STEP and hidden CAD Viewer GLB/topology outputs even when current artifacts match.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--mesh-tolerance",
|
||||
type=float,
|
||||
help="Positive shared mesh linear deflection for GLB/topology, native GLB, STL, and 3MF artifacts.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--mesh-angular-tolerance",
|
||||
type=float,
|
||||
help="Positive shared mesh angular deflection for GLB/topology, native GLB, STL, and 3MF artifacts.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose",
|
||||
action="store_true",
|
||||
help="Show detailed progress and timing information.",
|
||||
)
|
||||
|
||||
|
||||
def _step_import_options_from_args(args: argparse.Namespace, *, parser: argparse.ArgumentParser) -> StepImportOptions:
|
||||
return StepImportOptions(
|
||||
stl=args.stl,
|
||||
three_mf=args.three_mf,
|
||||
glb=args.glb,
|
||||
mesh_tolerance=_normalize_cli_numeric(
|
||||
args.mesh_tolerance,
|
||||
field_name="mesh_tolerance",
|
||||
parser=parser,
|
||||
),
|
||||
mesh_angular_tolerance=_normalize_cli_numeric(
|
||||
args.mesh_angular_tolerance,
|
||||
field_name="mesh_angular_tolerance",
|
||||
parser=parser,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="scripts/step",
|
||||
description="Generate explicit CAD STEP targets and their CAD Viewer artifacts.",
|
||||
)
|
||||
_add_step_arguments(parser)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(list(argv) if argv is not None else None)
|
||||
if args.output is not None:
|
||||
if targets_include_output_pairs(args.targets):
|
||||
parser.error("--output cannot be combined with SOURCE=OUTPUT targets")
|
||||
if len(args.targets) != 1:
|
||||
parser.error("--output can only be used with exactly one target")
|
||||
return generate_step_targets(
|
||||
args.targets,
|
||||
direct_step_kind=args.kind,
|
||||
step_options=_step_import_options_from_args(args, parser=parser),
|
||||
output=args.output,
|
||||
skip_step_write=bool(args.skip_step_write),
|
||||
force=bool(args.force),
|
||||
verbose=bool(args.verbose),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user