agents/openai.yaml
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."
LICENSE
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.
references/build123d-modeling.md
# 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.
references/cad-brief.md
# 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
references/inspection-and-validation.md
# 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.
references/parameters.md
# 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.
references/positioning.md
# 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.
references/repair-loop.md
# 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
```
references/snapshot-review.md
# 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.
references/step-generation.md
# 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
```
references/supported-exports.md
# 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.
```
requirements.txt
--editable ./scripts/packages/cadpy
playwright
scripts/inspect/__main__.py
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())
scripts/inspect/cli.py
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())
scripts/inspect/inspect_refs/__init__.py
"""Selector reference inspection helpers."""
scripts/inspect/inspect_refs/cli.py
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())
scripts/inspect/inspect_refs/inspect.py
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
scripts/packages/cadpy/pyproject.toml
[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"]
scripts/packages/cadpy/src/cadpy/__init__.py
"""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}")
scripts/packages/cadpy/src/cadpy/analysis.py
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
scripts/packages/cadpy/src/cadpy/api.py
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"]
scripts/packages/cadpy/src/cadpy/assembly_composition.py
from __future__ import annotations
import os
from pathlib import Path
from typing import Any, Callable, Mapping, Sequence
from cadpy.assembly_spec import (
IDENTITY_TRANSFORM,
AssemblySpec,
assembly_spec_children,
multiply_transforms,
)
from cadpy.glb_topology import read_step_topology_manifest_from_glb
from cadpy.render import existing_part_glb_path, part_glb_path, relative_to_repo, sha256_file
ASSEMBLY_COMPOSITION_SCHEMA_VERSION = 1
TOPOLOGY_COUNT_KEYS = ("shapeCount", "faceCount", "edgeCount")
_SOURCE_TOPOLOGY_COUNTS_CACHE: dict[tuple[str, int, int], dict[str, int]] = {}
_SOURCE_ASSEMBLY_PAYLOAD_CACHE: dict[tuple[str, int, int], dict[str, Any] | None] = {}
class AssemblyCompositionError(ValueError):
pass
def component_name(instance_path: Sequence[str]) -> str:
return "__".join(str(part) for part in instance_path if str(part)) or "root"
def _relative_to_topology(topology_path: Path, target_path: Path) -> str:
return os.path.relpath(target_path.resolve(), start=topology_path.resolve().parent).replace(os.sep, "/")
def _versioned_relative_url(topology_path: Path, target_path: Path, content_hash: str) -> str:
suffix = f"?v={content_hash}" if content_hash else ""
return f"{_relative_to_topology(topology_path, target_path)}{suffix}"
def _assembly_mesh_payload(topology_path: Path, mesh_path: Path) -> dict[str, Any]:
mesh_hash = sha256_file(mesh_path) if mesh_path.exists() else ""
return {
"url": _versioned_relative_url(topology_path, mesh_path, mesh_hash)
if mesh_hash
else _relative_to_topology(topology_path, mesh_path),
"hash": mesh_hash,
"addressing": "gltf-node-extras",
"occurrenceIdKey": "cadOccurrenceId",
}
def build_linked_assembly_composition(
*,
cad_ref: str,
topology_path: Path,
topology_manifest: dict[str, Any],
assembly_spec: AssemblySpec,
entries_by_step_path: Mapping[Path, object],
read_assembly_spec: Callable[[Path], AssemblySpec],
mesh_path: Path,
) -> dict[str, Any]:
occurrences = _rows(topology_manifest, "occurrences", "occurrenceColumns")
if not occurrences:
raise AssemblyCompositionError(f"Assembly topology has no occurrences: {cad_ref}")
component_occurrences = _component_occurrences(topology_manifest)
root_occurrence = occurrences[0]
children = [
_linked_instance_node(
cad_ref=cad_ref,
topology_path=topology_path,
instance=instance,
instance_path=(instance.instance_id,),
target_occurrence=None,
parent_world_transform=IDENTITY_TRANSFORM,
parent_use_source_colors=True,
all_occurrences=occurrences,
component_occurrences=component_occurrences,
entries_by_step_path=entries_by_step_path,
read_assembly_spec=read_assembly_spec,
stack=(assembly_spec.assembly_path.resolve().as_posix(),),
)
for instance in assembly_spec_children(assembly_spec)
]
if not children:
raise AssemblyCompositionError(f"Assembly {cad_ref} has no component instances")
return {
"schemaVersion": ASSEMBLY_COMPOSITION_SCHEMA_VERSION,
"mode": "linked",
"mesh": _assembly_mesh_payload(topology_path, mesh_path),
"root": _assembly_root_node(cad_ref, root_occurrence, children),
}
def build_native_assembly_composition(
*,
cad_ref: str,
topology_path: Path,
topology_manifest: dict[str, Any],
mesh_path: Path,
) -> dict[str, Any]:
occurrences = _rows(topology_manifest, "occurrences", "occurrenceColumns")
if not occurrences:
raise AssemblyCompositionError(f"Assembly topology has no occurrences: {cad_ref}")
by_id = {
str(row.get("id") or "").strip(): row
for row in occurrences
if str(row.get("id") or "").strip()
}
children_by_parent: dict[str, list[dict[str, Any]]] = {}
top_level: list[dict[str, Any]] = []
for row in occurrences:
parent_id = str(row.get("parentId") or "").strip()
if parent_id:
children_by_parent.setdefault(parent_id, []).append(row)
else:
top_level.append(row)
root_occurrence = top_level[0] if len(top_level) == 1 else occurrences[0]
root_children = top_level
if len(top_level) == 1 and not children_by_parent.get(str(top_level[0].get("id") or "").strip()):
root_children = top_level
elif len(top_level) == 1:
root_children = children_by_parent.get(str(top_level[0].get("id") or "").strip(), [])
children = [
_native_occurrence_node(
row,
children_by_parent=children_by_parent,
topology_path=topology_path,
parent_world_transform=IDENTITY_TRANSFORM,
)
for row in root_children
]
if not children:
row = root_occurrence
children = [
_native_part_node(
row,
topology_path=topology_path,
parent_world_transform=IDENTITY_TRANSFORM,
)
]
return {
"schemaVersion": ASSEMBLY_COMPOSITION_SCHEMA_VERSION,
"mode": "native",
"mesh": _assembly_mesh_payload(topology_path, mesh_path),
"root": _assembly_root_node(cad_ref, root_occurrence, children),
}
def _linked_instance_node(
*,
cad_ref: str,
topology_path: Path,
instance: object,
instance_path: tuple[str, ...],
target_occurrence: Mapping[str, Any] | None,
parent_world_transform: tuple[float, ...],
parent_use_source_colors: bool,
all_occurrences: Sequence[dict[str, Any]],
component_occurrences: Sequence[dict[str, Any]],
entries_by_step_path: Mapping[Path, object],
read_assembly_spec: Callable[[Path], AssemblySpec],
stack: tuple[str, ...],
) -> dict[str, Any]:
instance_source_path = Path(instance.source_path).resolve() if instance.source_path is not None else None
source_spec = entries_by_step_path.get(instance_source_path) if instance_source_path is not None else None
child_kind = str(getattr(source_spec, "kind", "") or "")
instance_transform = tuple(float(value) for value in instance.transform)
world_transform = multiply_transforms(parent_world_transform, instance_transform)
source_step_path = getattr(source_spec, "step_path", None) if source_spec is not None else None
source_path = _relative_to_topology(topology_path, Path(source_step_path)) if source_step_path is not None else (instance.path or "")
# Instance names are the authored assembly labels; fall back to source/path
# stems only for legacy or incomplete specs.
display_name = str(
instance.name
or (
Path(source_step_path).stem
if source_step_path is not None
else Path(instance.path or "").stem
)
or instance_path[-1]
).strip()
use_source_colors = parent_use_source_colors and bool(instance.use_source_colors)
if instance.children:
occurrence = target_occurrence or _find_occurrence_by_component_name(
component_name(instance_path),
all_occurrences,
cad_ref,
)
occurrence_id = str(occurrence.get("id") or component_name(instance_path)).strip() if occurrence else component_name(instance_path)
occurrence_world_transform = (
tuple(float(value) for value in occurrence.get("transform"))
if occurrence and isinstance(occurrence.get("transform"), list) and len(occurrence.get("transform")) == 16
else world_transform
)
target_children_by_parent = _children_by_parent(all_occurrences)
target_children = target_children_by_parent.get(occurrence_id, [])
children = [
_linked_instance_node(
cad_ref=cad_ref,
topology_path=topology_path,
instance=child_instance,
instance_path=(*instance_path, child_instance.instance_id),
target_occurrence=_match_occurrence_child_for_instance(
target_children,
child_instance,
index,
(*instance_path, child_instance.instance_id),
),
parent_world_transform=occurrence_world_transform,
parent_use_source_colors=use_source_colors,
all_occurrences=all_occurrences,
component_occurrences=component_occurrences,
entries_by_step_path=entries_by_step_path,
read_assembly_spec=read_assembly_spec,
stack=stack,
)
for index, child_instance in enumerate(instance.children)
]
return _assembly_node(
id=occurrence_id,
occurrence_id=occurrence_id,
display_name=display_name,
source_kind="catalog",
source_path=source_path,
instance_path=".".join(instance_path),
use_source_colors=use_source_colors,
local_transform=instance_transform,
world_transform=occurrence_world_transform,
bbox=(occurrence.get("bbox") if occurrence else None) or _merge_bbox([child.get("bbox") for child in children]),
topology_counts=_sum_public_counts(children),
children=children,
)
if source_spec is None or instance_source_path is None:
raise AssemblyCompositionError(
f"{cad_ref} assembly component {component_name(instance_path)} references missing CAD source {instance.path}"
)
if child_kind == "assembly":
stack_key = instance_source_path.as_posix()
if stack_key in stack:
cycle = " -> ".join((*stack, stack_key))
raise AssemblyCompositionError(f"Assembly cycle detected: {cycle}")
source_source_path = getattr(source_spec, "source_path", None)
script_path = getattr(source_spec, "script_path", None)
if source_source_path is None or script_path is None:
raise AssemblyCompositionError(
f"{cad_ref} nested assembly {instance.path} must be a generated assembly source"
)
child_spec = read_assembly_spec(Path(source_source_path))
occurrence = target_occurrence or _find_occurrence_by_component_name(
component_name(instance_path),
all_occurrences,
cad_ref,
)
occurrence_id = str(occurrence.get("id") or component_name(instance_path)).strip() if occurrence else component_name(instance_path)
occurrence_world_transform = (
tuple(float(value) for value in occurrence.get("transform"))
if occurrence and isinstance(occurrence.get("transform"), list) and len(occurrence.get("transform")) == 16
else world_transform
)
target_children_by_parent = _children_by_parent(all_occurrences)
target_children = target_children_by_parent.get(occurrence_id, [])
children = [
_linked_instance_node(
cad_ref=cad_ref,
topology_path=topology_path,
instance=child_instance,
instance_path=(*instance_path, child_instance.instance_id),
target_occurrence=_match_occurrence_child_for_instance(
target_children,
child_instance,
index,
(*instance_path, child_instance.instance_id),
),
parent_world_transform=occurrence_world_transform,
parent_use_source_colors=use_source_colors,
all_occurrences=all_occurrences,
component_occurrences=component_occurrences,
entries_by_step_path=entries_by_step_path,
read_assembly_spec=read_assembly_spec,
stack=(*stack, stack_key),
)
for index, child_instance in enumerate(assembly_spec_children(child_spec))
]
return _assembly_node(
id=occurrence_id,
occurrence_id=occurrence_id,
display_name=display_name,
source_kind="catalog",
source_path=_relative_to_topology(topology_path, Path(source_step_path)) if source_step_path is not None else (instance.path or ""),
instance_path=".".join(instance_path),
use_source_colors=use_source_colors,
local_transform=instance_transform,
world_transform=occurrence_world_transform,
bbox=(occurrence.get("bbox") if occurrence else None) or _merge_bbox([child.get("bbox") for child in children]),
topology_counts=_sum_public_counts(children),
children=children,
)
native_source_assembly = _source_assembly_payload(Path(source_step_path)) if source_step_path is not None else None
if native_source_assembly is not None:
occurrence = target_occurrence or _find_occurrence_by_component_name(
component_name(instance_path),
all_occurrences,
cad_ref,
)
if occurrence is None:
raise AssemblyCompositionError(
f"{cad_ref} assembly topology is missing occurrence {component_name(instance_path)!r}"
)
occurrence_id = str(occurrence.get("id") or "").strip()
return _linked_native_assembly_node(
topology_path=topology_path,
source_topology_path=existing_part_glb_path(Path(source_step_path)) or part_glb_path(Path(source_step_path)),
source_assembly=native_source_assembly,
source_path=source_path,
occurrence=occurrence,
occurrence_id=occurrence_id,
all_occurrences=all_occurrences,
display_name=display_name,
instance_path=".".join(instance_path),
use_source_colors=use_source_colors,
local_transform=instance_transform,
world_transform=tuple(float(value) for value in occurrence.get("transform") or world_transform),
)
if child_kind == "part":
occurrence = target_occurrence or _find_occurrence_by_component_name(
component_name(instance_path),
component_occurrences,
cad_ref,
)
if occurrence is None:
raise AssemblyCompositionError(
f"{cad_ref} assembly topology is missing occurrence {component_name(instance_path)!r}"
)
if source_step_path is None:
raise AssemblyCompositionError(f"{cad_ref} component {component_name(instance_path)} is missing STEP source")
source_counts = _source_topology_counts(existing_part_glb_path(Path(source_step_path)) or part_glb_path(Path(source_step_path)))
occurrence_counts = _occurrence_topology_counts(occurrence)
if source_counts != occurrence_counts:
raise AssemblyCompositionError(
f"{cad_ref} assembly occurrence {occurrence.get('id')!r} count mismatch for "
f"{source_path}: source={source_counts} assembly={occurrence_counts}"
)
occurrence_id = str(occurrence.get("id") or "").strip()
return _part_node(
id=occurrence_id,
occurrence_id=occurrence_id,
display_name=display_name,
source_kind="catalog",
source_path=source_path,
instance_path=".".join(instance_path),
use_source_colors=use_source_colors,
local_transform=instance_transform,
world_transform=tuple(float(value) for value in occurrence.get("transform") or world_transform),
bbox=occurrence.get("bbox"),
topology_counts=_public_topology_counts(occurrence_counts),
)
raise AssemblyCompositionError(
f"{cad_ref} component {component_name(instance_path)} must resolve to a STEP part or assembly source"
)
def _source_assembly_payload(step_path: Path) -> dict[str, Any] | None:
source_topology_path = existing_part_glb_path(step_path) or part_glb_path(step_path)
cache_key = _file_cache_key(source_topology_path)
if cache_key in _SOURCE_ASSEMBLY_PAYLOAD_CACHE:
return _SOURCE_ASSEMBLY_PAYLOAD_CACHE[cache_key]
payload = read_step_topology_manifest_from_glb(source_topology_path)
if payload is None:
_SOURCE_ASSEMBLY_PAYLOAD_CACHE[cache_key] = None
return None
assembly = payload.get("assembly")
root = assembly.get("root") if isinstance(assembly, dict) else None
if isinstance(root, dict) and root.get("children"):
_SOURCE_ASSEMBLY_PAYLOAD_CACHE[cache_key] = assembly
return assembly
if not _manifest_has_native_assembly_structure(payload):
_SOURCE_ASSEMBLY_PAYLOAD_CACHE[cache_key] = None
return None
native_payload = build_native_assembly_composition(
cad_ref=relative_to_repo(step_path.with_suffix("")),
topology_path=source_topology_path,
topology_manifest=payload,
mesh_path=source_topology_path,
)
native_root = native_payload.get("root")
if not isinstance(native_root, dict) or not native_root.get("children"):
_SOURCE_ASSEMBLY_PAYLOAD_CACHE[cache_key] = None
return None
_SOURCE_ASSEMBLY_PAYLOAD_CACHE[cache_key] = native_payload
return native_payload
def _manifest_has_native_assembly_structure(payload: Mapping[str, Any]) -> bool:
occurrences = _rows(dict(payload), "occurrences", "occurrenceColumns")
if len(occurrences) <= 1:
return False
occurrence_ids = {
str(row.get("id") or "").strip()
for row in occurrences
if str(row.get("id") or "").strip()
}
for row in occurrences:
parent_id = str(row.get("parentId") or "").strip()
if parent_id and parent_id in occurrence_ids:
return True
return False
def _linked_native_assembly_node(
*,
topology_path: Path,
source_topology_path: Path,
source_assembly: Mapping[str, Any],
source_path: str,
occurrence: Mapping[str, Any],
occurrence_id: str,
all_occurrences: Sequence[dict[str, Any]],
display_name: str,
instance_path: str,
use_source_colors: bool,
local_transform: Sequence[float],
world_transform: tuple[float, ...],
) -> dict[str, Any]:
source_root = source_assembly.get("root")
if not isinstance(source_root, Mapping):
raise AssemblyCompositionError(f"Native source assembly is missing root: {relative_to_repo(source_topology_path)}")
source_root_occurrence_id = str(source_root.get("occurrenceId") or source_root.get("id") or "").strip()
source_children = source_root.get("children")
if not isinstance(source_children, list) or not source_children:
raise AssemblyCompositionError(f"Native source assembly has no children: {relative_to_repo(source_topology_path)}")
target_children_by_parent = _children_by_parent(all_occurrences)
target_children = target_children_by_parent.get(occurrence_id, [])
child_nodes = [
_clone_native_source_node(
source_node=_source_node_for_native_target_child(
source_root,
source_children,
target_children,
target_children_by_parent,
child,
index,
),
target_row=child,
target_children_by_parent=target_children_by_parent,
topology_path=topology_path,
source_topology_path=source_topology_path,
source_root_occurrence_id=source_root_occurrence_id,
source_path=source_path,
source_root_target_occurrence_id=occurrence_id,
target_parent_occurrence_id=occurrence_id,
parent_world_transform=world_transform,
parent_instance_path=instance_path,
parent_use_source_colors=use_source_colors,
)
for index, child in enumerate(target_children)
]
if not child_nodes:
child_nodes = [
_clone_native_source_node(
source_node=child,
target_row=None,
target_children_by_parent={},
topology_path=topology_path,
source_topology_path=source_topology_path,
source_root_occurrence_id=source_root_occurrence_id,
target_parent_occurrence_id=occurrence_id,
parent_world_transform=world_transform,
parent_instance_path=instance_path,
parent_use_source_colors=use_source_colors,
)
for child in source_children
if isinstance(child, Mapping)
]
source_root_target_occurrence_id = occurrence_id
for child in child_nodes:
if str(child.get("sourceOccurrenceId") or "").strip() == source_root_occurrence_id:
source_root_target_occurrence_id = str(child.get("occurrenceId") or child.get("id") or occurrence_id).strip()
break
child_counts = _sum_public_counts(child_nodes)
node = _assembly_node(
id=occurrence_id,
occurrence_id=occurrence_id,
display_name=display_name,
source_kind="native",
source_path=source_path,
instance_path=instance_path,
use_source_colors=use_source_colors,
local_transform=local_transform,
world_transform=world_transform,
bbox=occurrence.get("bbox") or _merge_bbox([child.get("bbox") for child in child_nodes]),
topology_counts=child_counts if _counts_have_values(child_counts) else _public_topology_counts(_occurrence_topology_counts(occurrence)),
children=child_nodes,
)
_attach_native_source_metadata(
node,
source_occurrence_id=source_root_occurrence_id,
source_root_occurrence_id=source_root_occurrence_id,
source_root_target_occurrence_id=source_root_target_occurrence_id,
)
return node
def _clone_native_source_node(
*,
source_node: Mapping[str, Any],
target_row: Mapping[str, Any] | None,
target_children_by_parent: Mapping[str, list[dict[str, Any]]],
topology_path: Path,
source_topology_path: Path,
source_root_occurrence_id: str,
source_path: str,
source_root_target_occurrence_id: str,
target_parent_occurrence_id: str,
parent_world_transform: tuple[float, ...],
parent_instance_path: str,
parent_use_source_colors: bool,
) -> dict[str, Any]:
source_occurrence_id = str(source_node.get("occurrenceId") or source_node.get("id") or "").strip()
occurrence_id = (
str(target_row.get("id") or "").strip()
if target_row is not None
else _prefix_native_occurrence_id(
source_occurrence_id,
source_root_occurrence_id=source_root_occurrence_id,
target_parent_occurrence_id=target_parent_occurrence_id,
)
)
next_source_root_target_occurrence_id = (
occurrence_id
if source_root_occurrence_id and source_occurrence_id == source_root_occurrence_id
else source_root_target_occurrence_id
)
source_world_transform = _transform_tuple(source_node.get("worldTransform"), IDENTITY_TRANSFORM)
source_local_transform = _transform_tuple(source_node.get("localTransform"), source_world_transform)
world_transform = _row_transform(target_row) if target_row is not None else multiply_transforms(parent_world_transform, source_local_transform)
local_transform = (
_relative_transform(parent_world_transform, world_transform)
if target_row is not None
else source_local_transform
)
source_children = source_node.get("children")
target_children = target_children_by_parent.get(occurrence_id, []) if occurrence_id else []
if target_children and not _source_node_has_children(source_node):
return _native_source_part_node(
source_node=source_node,
target_row=target_row,
occurrence_id=occurrence_id,
source_path=source_path,
source_occurrence_id=source_occurrence_id,
source_root_occurrence_id=source_root_occurrence_id,
source_root_target_occurrence_id=next_source_root_target_occurrence_id,
display_name=_occurrence_display_name(target_row) if target_row is not None else "",
instance_path=".".join(
part
for part in (
parent_instance_path,
str(target_row.get("path") or "") if target_row is not None else str(source_node.get("instancePath") or source_occurrence_id),
)
if part
),
use_source_colors=parent_use_source_colors and source_node.get("useSourceColors") is not False,
local_transform=local_transform,
world_transform=world_transform,
bbox=target_row.get("bbox") if target_row is not None else _transform_bbox(parent_world_transform, source_node.get("bbox")),
)
node_children = [
_clone_native_source_node(
source_node=_match_source_child_for_target(source_children, child, index),
target_row=child,
target_children_by_parent=target_children_by_parent,
topology_path=topology_path,
source_topology_path=source_topology_path,
source_root_occurrence_id=source_root_occurrence_id,
source_path=source_path,
source_root_target_occurrence_id=next_source_root_target_occurrence_id,
target_parent_occurrence_id=target_parent_occurrence_id,
parent_world_transform=world_transform,
parent_instance_path=parent_instance_path,
parent_use_source_colors=parent_use_source_colors,
)
for index, child in enumerate(target_children)
]
if not node_children and isinstance(source_children, list) and source_children:
node_children = [
_clone_native_source_node(
source_node=child,
target_row=None,
target_children_by_parent={},
topology_path=topology_path,
source_topology_path=source_topology_path,
source_root_occurrence_id=source_root_occurrence_id,
source_path=source_path,
source_root_target_occurrence_id=next_source_root_target_occurrence_id,
target_parent_occurrence_id=occurrence_id,
parent_world_transform=world_transform,
parent_instance_path=parent_instance_path,
parent_use_source_colors=parent_use_source_colors,
)
for child in source_children
if isinstance(child, Mapping)
]
display_name = str(
_occurrence_display_name(target_row)
if target_row is not None
else source_node.get("displayName") or source_node.get("name") or occurrence_id
).strip()
instance_path = ".".join(
part
for part in (
parent_instance_path,
str(target_row.get("path") or "") if target_row is not None else str(source_node.get("instancePath") or source_occurrence_id),
)
if part
)
topology_counts = source_node.get("topologyCounts") if isinstance(source_node.get("topologyCounts"), Mapping) else {}
bbox = target_row.get("bbox") if target_row is not None else _transform_bbox(parent_world_transform, source_node.get("bbox"))
use_source_colors = parent_use_source_colors and source_node.get("useSourceColors") is not False
if node_children:
child_counts = _sum_public_counts(node_children)
node = _assembly_node(
id=occurrence_id,
occurrence_id=occurrence_id,
display_name=display_name,
source_kind="native",
source_path=source_path,
instance_path=instance_path,
use_source_colors=use_source_colors,
local_transform=local_transform,
world_transform=world_transform,
bbox=bbox or _merge_bbox([child.get("bbox") for child in node_children]),
topology_counts=child_counts if _counts_have_values(child_counts) else _public_topology_counts(topology_counts),
children=node_children,
)
_attach_native_source_metadata(
node,
source_occurrence_id=source_occurrence_id,
source_root_occurrence_id=source_root_occurrence_id,
source_root_target_occurrence_id=next_source_root_target_occurrence_id,
)
return node
return _native_source_part_node(
source_node=source_node,
target_row=target_row,
occurrence_id=occurrence_id,
source_path=source_path,
source_occurrence_id=source_occurrence_id,
source_root_occurrence_id=source_root_occurrence_id,
source_root_target_occurrence_id=next_source_root_target_occurrence_id,
display_name=display_name,
instance_path=instance_path,
use_source_colors=use_source_colors,
local_transform=local_transform,
world_transform=world_transform,
bbox=bbox,
)
def _source_node_has_children(source_node: Mapping[str, Any]) -> bool:
source_children = source_node.get("children")
return isinstance(source_children, list) and bool(source_children)
def _native_source_part_node(
*,
source_node: Mapping[str, Any],
target_row: Mapping[str, Any] | None,
occurrence_id: str,
display_name: str,
instance_path: str,
use_source_colors: bool,
local_transform: Sequence[float],
world_transform: Sequence[float],
bbox: Any,
source_path: str = "",
source_occurrence_id: str = "",
source_root_occurrence_id: str = "",
source_root_target_occurrence_id: str = "",
) -> dict[str, Any]:
topology_counts = (
_occurrence_topology_counts(target_row)
if target_row is not None
else source_node.get("topologyCounts") if isinstance(source_node.get("topologyCounts"), Mapping) else {}
)
node = _part_node(
id=occurrence_id,
occurrence_id=occurrence_id,
display_name=display_name,
source_kind="native",
source_path=source_path,
instance_path=instance_path,
use_source_colors=use_source_colors,
local_transform=local_transform,
world_transform=world_transform,
bbox=bbox,
topology_counts=_public_topology_counts(topology_counts),
)
_attach_native_source_metadata(
node,
source_occurrence_id=source_occurrence_id,
source_root_occurrence_id=source_root_occurrence_id,
source_root_target_occurrence_id=source_root_target_occurrence_id,
)
return node
def _attach_native_source_metadata(
node: dict[str, Any],
*,
source_occurrence_id: str,
source_root_occurrence_id: str,
source_root_target_occurrence_id: str,
) -> None:
if source_occurrence_id:
node["sourceOccurrenceId"] = source_occurrence_id
if source_root_occurrence_id:
node["sourceRootOccurrenceId"] = source_root_occurrence_id
if source_root_target_occurrence_id:
node["sourceRootTargetOccurrenceId"] = source_root_target_occurrence_id
def _prefix_native_occurrence_id(
source_occurrence_id: str,
*,
source_root_occurrence_id: str,
target_parent_occurrence_id: str,
) -> str:
if source_root_occurrence_id and source_occurrence_id == source_root_occurrence_id:
return target_parent_occurrence_id
prefix = f"{source_root_occurrence_id}."
if source_root_occurrence_id and source_occurrence_id.startswith(prefix):
return f"{target_parent_occurrence_id}.{source_occurrence_id[len(prefix):]}"
suffix = source_occurrence_id[1:] if source_occurrence_id.startswith("o") else source_occurrence_id
return f"{target_parent_occurrence_id}.{suffix}" if suffix else target_parent_occurrence_id
def _children_by_parent(occurrences: Sequence[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
children_by_parent: dict[str, list[dict[str, Any]]] = {}
for row in occurrences:
parent_id = str(row.get("parentId") or "").strip()
if parent_id:
children_by_parent.setdefault(parent_id, []).append(row)
return children_by_parent
def _match_source_child_for_target(
source_children: object,
target_row: Mapping[str, Any],
index: int,
) -> Mapping[str, Any]:
candidates = [child for child in source_children if isinstance(child, Mapping)] if isinstance(source_children, list) else []
if not candidates:
return {}
target_names = {
str(target_row.get("name") or "").strip(),
str(target_row.get("sourceName") or "").strip(),
str(target_row.get("id") or "").strip(),
}
target_names.discard("")
for candidate in candidates:
candidate_names = {
str(candidate.get("displayName") or "").strip(),
str(candidate.get("name") or "").strip(),
str(candidate.get("occurrenceId") or "").strip(),
str(candidate.get("id") or "").strip(),
}
if target_names.intersection(candidate_names):
return candidate
if 0 <= index < len(candidates):
return candidates[index]
return candidates[-1]
def _source_node_for_native_target_child(
source_root: Mapping[str, Any],
source_children: object,
target_siblings: Sequence[Mapping[str, Any]],
target_children_by_parent: Mapping[str, list[dict[str, Any]]],
target_row: Mapping[str, Any],
index: int,
) -> Mapping[str, Any]:
target_row_id = str(target_row.get("id") or "").strip()
target_child_rows = target_children_by_parent.get(target_row_id, []) if target_row_id else []
source_child_count = len(source_children) if isinstance(source_children, list) else 0
if (
len(target_siblings) == 1
and len(target_child_rows) > 1
and source_child_count > 1
and isinstance(source_root, Mapping)
):
return source_root
return _match_source_child_for_target(source_children, target_row, index)
def _match_occurrence_child_for_instance(
target_children: Sequence[Mapping[str, Any]],
instance: object,
index: int,
instance_path: tuple[str, ...],
) -> Mapping[str, Any] | None:
if not target_children:
return None
expected_names = {
str(getattr(instance, "instance_id", "") or "").strip(),
str(getattr(instance, "name", "") or "").strip(),
component_name(instance_path),
}
expected_names.discard("")
for child in target_children:
child_names = {
str(child.get("name") or "").strip(),
str(child.get("sourceName") or "").strip(),
str(child.get("id") or "").strip(),
}
if expected_names.intersection(child_names):
return child
if 0 <= index < len(target_children):
return target_children[index]
return target_children[-1]
def _native_occurrence_node(
row: dict[str, Any],
*,
children_by_parent: Mapping[str, list[dict[str, Any]]],
topology_path: Path,
parent_world_transform: tuple[float, ...],
) -> dict[str, Any]:
row_id = str(row.get("id") or "").strip()
children = children_by_parent.get(row_id, [])
if not children:
return _native_part_node(
row,
topology_path=topology_path,
parent_world_transform=parent_world_transform,
)
world_transform = _row_transform(row)
child_nodes = [
_native_occurrence_node(
child,
children_by_parent=children_by_parent,
topology_path=topology_path,
parent_world_transform=world_transform,
)
for child in children
]
return _assembly_node(
id=row_id,
occurrence_id=row_id,
display_name=_occurrence_display_name(row),
source_kind="native",
source_path="",
instance_path=str(row.get("path") or row_id),
use_source_colors=True,
local_transform=_relative_transform(parent_world_transform, world_transform),
world_transform=world_transform,
bbox=row.get("bbox") or _merge_bbox([child.get("bbox") for child in child_nodes]),
topology_counts=_public_topology_counts(_occurrence_topology_counts(row)),
children=child_nodes,
)
def _native_part_node(
row: dict[str, Any],
*,
topology_path: Path,
parent_world_transform: tuple[float, ...],
) -> dict[str, Any]:
occurrence_id = str(row.get("id") or "").strip()
if not occurrence_id:
raise AssemblyCompositionError("Native assembly occurrence is missing an id")
world_transform = _row_transform(row)
return _part_node(
id=occurrence_id,
occurrence_id=occurrence_id,
display_name=_occurrence_display_name(row),
source_kind="native",
source_path="",
instance_path=str(row.get("path") or occurrence_id),
use_source_colors=True,
local_transform=_relative_transform(parent_world_transform, world_transform),
world_transform=world_transform,
bbox=row.get("bbox"),
topology_counts=_public_topology_counts(_occurrence_topology_counts(row)),
)
def _part_node(
*,
id: str,
occurrence_id: str,
display_name: str,
source_kind: str,
source_path: str,
instance_path: str,
use_source_colors: bool,
local_transform: Sequence[float],
world_transform: Sequence[float],
bbox: Any,
topology_counts: Mapping[str, int],
asset_url: str = "",
asset_hash: str = "",
) -> dict[str, Any]:
node = {
"id": id,
"occurrenceId": occurrence_id,
"nodeType": "part",
"displayName": display_name,
"sourceKind": source_kind,
"sourcePath": source_path,
"instancePath": instance_path,
"useSourceColors": use_source_colors,
"localTransform": _transform_list(local_transform),
"worldTransform": _transform_list(world_transform),
"bbox": bbox,
"topologyCounts": _public_topology_counts(topology_counts),
"leafPartIds": [id],
"children": [],
}
if asset_url:
node["assets"] = {
"glb": {
"url": asset_url,
"hash": asset_hash,
}
}
return node
def _assembly_node(
*,
id: str,
occurrence_id: str,
display_name: str,
source_kind: str,
source_path: str,
instance_path: str,
use_source_colors: bool,
local_transform: Sequence[float],
world_transform: Sequence[float],
bbox: Any,
topology_counts: Mapping[str, int],
children: Sequence[dict[str, Any]],
) -> dict[str, Any]:
leaf_part_ids = _leaf_part_ids(children)
return {
"id": id,
"occurrenceId": occurrence_id,
"nodeType": "assembly",
"displayName": display_name,
"sourceKind": source_kind,
"sourcePath": source_path,
"instancePath": instance_path,
"useSourceColors": use_source_colors,
"localTransform": _transform_list(local_transform),
"worldTransform": _transform_list(world_transform),
"bbox": bbox,
"topologyCounts": _public_topology_counts(topology_counts),
"leafPartIds": leaf_part_ids,
"children": list(children),
}
def _leaf_part_ids(children: Sequence[Mapping[str, Any]]) -> list[str]:
ids: list[str] = []
for child in children:
child_ids = child.get("leafPartIds")
if isinstance(child_ids, list):
ids.extend(str(value) for value in child_ids if str(value or "").strip())
continue
child_id = str(child.get("id") or "").strip()
if child_id:
ids.append(child_id)
return ids
def _assembly_root_node(cad_ref: str, root_occurrence: dict[str, Any], children: Sequence[dict[str, Any]]) -> dict[str, Any]:
counts = _sum_public_counts(children)
if not _counts_have_values(counts):
counts = _public_topology_counts(_occurrence_topology_counts(root_occurrence))
return _assembly_node(
id="root",
occurrence_id=str(root_occurrence.get("id") or "root").strip() or "root",
display_name=_root_display_name(cad_ref, root_occurrence),
source_kind="catalog",
source_path="",
instance_path="",
use_source_colors=True,
local_transform=IDENTITY_TRANSFORM,
world_transform=IDENTITY_TRANSFORM,
bbox=root_occurrence.get("bbox") or _merge_bbox([child.get("bbox") for child in children]),
topology_counts=counts,
children=children,
)
def _root_display_name(cad_ref: str, root_occurrence: Mapping[str, Any]) -> str:
display_name = str(root_occurrence.get("name") or "").strip()
if not display_name or display_name.lower() == "root":
return cad_ref.rsplit("/", 1)[-1]
return display_name
def _rows(manifest: dict[str, Any], row_key: str, columns_key: str) -> list[dict[str, Any]]:
columns = manifest.get("tables", {}).get(columns_key)
rows = manifest.get(row_key)
if not isinstance(columns, list) or not isinstance(rows, list):
return []
output: list[dict[str, Any]] = []
for row in rows:
if isinstance(row, list):
output.append({str(column): row[index] if index < len(row) else None for index, column in enumerate(columns)})
return output
def _component_occurrences(manifest: dict[str, Any]) -> list[dict[str, Any]]:
occurrences = _rows(manifest, "occurrences", "occurrenceColumns")
parent_ids = {
str(row.get("parentId") or "").strip()
for row in occurrences
if str(row.get("parentId") or "").strip()
}
candidate_occurrences = [
row
for row in occurrences
if str(row.get("parentId") or "").strip() and int(row.get("shapeCount") or 0) > 0
]
if candidate_occurrences:
return candidate_occurrences
leaf_occurrences = [
row
for row in occurrences
if str(row.get("id") or "").strip() not in parent_ids and int(row.get("shapeCount") or 0) > 0
]
if not leaf_occurrences:
leaf_occurrences = [
row
for row in occurrences
if int(row.get("shapeCount") or 0) > 0
]
if not leaf_occurrences:
raise AssemblyCompositionError("Assembly topology has no component occurrences")
return leaf_occurrences
def _find_occurrence_by_component_name(
component: str,
occurrences: Sequence[dict[str, Any]],
cad_ref: str,
) -> dict[str, Any] | None:
matches = []
for occurrence in occurrences:
names = {
str(occurrence.get("name") or "").strip(),
str(occurrence.get("sourceName") or "").strip(),
}
if component in names:
matches.append(occurrence)
if len(matches) > 1:
raise AssemblyCompositionError(
f"Assembly topology has duplicate component occurrence name for {cad_ref}: {component}"
)
return matches[0] if matches else None
def _read_json(path: Path) -> dict[str, Any]:
import json
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise AssemblyCompositionError(f"Failed to read JSON: {relative_to_repo(path)}") from exc
if not isinstance(payload, dict):
raise AssemblyCompositionError(f"Expected JSON object: {relative_to_repo(path)}")
return payload
def _file_cache_key(path: Path) -> tuple[str, int, int]:
resolved = path.resolve()
try:
stat = resolved.stat()
except OSError:
return (resolved.as_posix(), -1, -1)
return (resolved.as_posix(), int(stat.st_size), int(stat.st_mtime_ns))
def _source_topology_counts(topology_manifest_path: Path) -> dict[str, int]:
cache_key = _file_cache_key(topology_manifest_path)
cached = _SOURCE_TOPOLOGY_COUNTS_CACHE.get(cache_key)
if cached is not None:
return dict(cached)
manifest = read_step_topology_manifest_from_glb(topology_manifest_path)
if manifest is None:
raise AssemblyCompositionError(
f"Source GLB topology is missing: {relative_to_repo(topology_manifest_path)}"
)
stats = manifest.get("stats")
if not isinstance(stats, dict):
raise AssemblyCompositionError(
f"Source topology is missing stats: {relative_to_repo(topology_manifest_path)}"
)
counts = {
"shapes": int(stats.get("shapeCount") or 0),
"faces": int(stats.get("faceCount") or 0),
"edges": int(stats.get("edgeCount") or 0),
}
if any(value <= 0 for value in counts.values()):
raise AssemblyCompositionError(
f"Source topology has invalid counts in {relative_to_repo(topology_manifest_path)}: {counts}"
)
_SOURCE_TOPOLOGY_COUNTS_CACHE[cache_key] = counts
return counts
def _occurrence_topology_counts(occurrence: Mapping[str, Any]) -> dict[str, int]:
return {
"shapes": int(occurrence.get("shapeCount") or 0),
"faces": int(occurrence.get("faceCount") or 0),
"edges": int(occurrence.get("edgeCount") or 0),
}
def _public_topology_counts(counts: Mapping[str, int]) -> dict[str, int]:
return {
"shapes": int(counts.get("shapes") or 0),
"faces": int(counts.get("faces") or 0),
"edges": int(counts.get("edges") or 0),
}
def _sum_public_counts(children: Sequence[Mapping[str, Any]]) -> dict[str, int]:
total = {"shapes": 0, "faces": 0, "edges": 0}
for child in children:
counts = child.get("topologyCounts")
if not isinstance(counts, Mapping):
continue
for key in total:
total[key] += int(counts.get(key) or 0)
return total
def _counts_have_values(counts: Mapping[str, int]) -> bool:
return any(int(counts.get(key) or 0) > 0 for key in ("shapes", "faces", "edges"))
def _occurrence_display_name(row: Mapping[str, Any]) -> str:
name = str(row.get("name") or "").strip()
source_name = str(row.get("sourceName") or "").strip()
if name and not (name.startswith("=>[") and name.endswith("]")):
return name
return source_name or name or str(row.get("path") or row.get("id") or "component").strip()
def _row_transform(row: Mapping[str, Any]) -> tuple[float, ...]:
raw_transform = row.get("transform")
if not isinstance(raw_transform, list) or len(raw_transform) != 16:
return IDENTITY_TRANSFORM
return tuple(float(value) for value in raw_transform)
def _transform_tuple(raw_transform: Any, fallback: Sequence[float]) -> tuple[float, ...]:
if not isinstance(raw_transform, list) or len(raw_transform) != 16:
return tuple(float(value) for value in fallback)
return tuple(float(value) for value in raw_transform)
def _transform_list(transform: Sequence[float]) -> list[float]:
return [float(value) for value in transform]
def _transform_point(transform: Sequence[float], point: Sequence[float]) -> list[float]:
x = float(point[0])
y = float(point[1])
z = float(point[2])
return [
(float(transform[0]) * x) + (float(transform[1]) * y) + (float(transform[2]) * z) + float(transform[3]),
(float(transform[4]) * x) + (float(transform[5]) * y) + (float(transform[6]) * z) + float(transform[7]),
(float(transform[8]) * x) + (float(transform[9]) * y) + (float(transform[10]) * z) + float(transform[11]),
]
def _transform_bbox(transform: Sequence[float], bbox: Any) -> dict[str, Any] | None:
if not isinstance(bbox, Mapping) or not isinstance(bbox.get("min"), list) or not isinstance(bbox.get("max"), list):
return None
mins = bbox["min"]
maxs = bbox["max"]
corners = [
[mins[0], mins[1], mins[2]],
[mins[0], mins[1], maxs[2]],
[mins[0], maxs[1], mins[2]],
[mins[0], maxs[1], maxs[2]],
[maxs[0], mins[1], mins[2]],
[maxs[0], mins[1], maxs[2]],
[maxs[0], maxs[1], mins[2]],
[maxs[0], maxs[1], maxs[2]],
]
transformed = [_transform_point(transform, corner) for corner in corners]
return {
"min": [min(point[index] for point in transformed) for index in range(3)],
"max": [max(point[index] for point in transformed) for index in range(3)],
}
def _merge_bbox(boxes: Sequence[Any]) -> dict[str, Any]:
valid_boxes = [
box
for box in boxes
if isinstance(box, Mapping) and isinstance(box.get("min"), list) and isinstance(box.get("max"), list)
]
if not valid_boxes:
return {"min": [0.0, 0.0, 0.0], "max": [0.0, 0.0, 0.0]}
mins = [list(box["min"]) for box in valid_boxes]
maxs = [list(box["max"]) for box in valid_boxes]
return {
"min": [min(float(point[index]) for point in mins) for index in range(3)],
"max": [max(float(point[index]) for point in maxs) for index in range(3)],
}
def _relative_transform(parent_world_transform: tuple[float, ...], world_transform: tuple[float, ...]) -> tuple[float, ...]:
return multiply_transforms(_invert_affine_transform(parent_world_transform), world_transform)
def _invert_affine_transform(transform: tuple[float, ...]) -> tuple[float, ...]:
a, b, c, tx, d, e, f, ty, g, h, i, tz = transform[:12]
det = (
a * (e * i - f * h)
- b * (d * i - f * g)
+ c * (d * h - e * g)
)
if abs(det) <= 1e-12:
return IDENTITY_TRANSFORM
inv_det = 1.0 / det
r00 = (e * i - f * h) * inv_det
r01 = (c * h - b * i) * inv_det
r02 = (b * f - c * e) * inv_det
r10 = (f * g - d * i) * inv_det
r11 = (a * i - c * g) * inv_det
r12 = (c * d - a * f) * inv_det
r20 = (d * h - e * g) * inv_det
r21 = (b * g - a * h) * inv_det
r22 = (a * e - b * d) * inv_det
return (
r00,
r01,
r02,
-((r00 * tx) + (r01 * ty) + (r02 * tz)),
r10,
r11,
r12,
-((r10 * tx) + (r11 * ty) + (r12 * tz)),
r20,
r21,
r22,
-((r20 * tx) + (r21 * ty) + (r22 * tz)),
0.0,
0.0,
0.0,
1.0,
)
scripts/packages/cadpy/src/cadpy/assembly_export.py
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,
)
scripts/packages/cadpy/src/cadpy/assembly_flatten.py
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)
scripts/packages/cadpy/src/cadpy/assembly_spec.py
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}"
)
scripts/packages/cadpy/src/cadpy/assembly.py
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
scripts/packages/cadpy/src/cadpy/cad_ref_syntax.py
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}"
scripts/packages/cadpy/src/cadpy/catalog.py
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()
scripts/packages/cadpy/src/cadpy/cli_logging.py
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}"
scripts/packages/cadpy/src/cadpy/file_metadata.py
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
scripts/packages/cadpy/src/cadpy/generation_status.py
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")
scripts/packages/cadpy/src/cadpy/generation.py
from __future__ import annotations
import argparse
import contextlib
import io
import importlib.util
import json
import math
import shutil
import sys
from collections.abc import Callable, Mapping
from dataclasses import dataclass, replace
from pathlib import Path, PurePosixPath
from typing import Sequence
from cadpy.analysis import selector_manifest_diff
from cadpy.assembly_composition import (
AssemblyCompositionError,
build_linked_assembly_composition,
build_native_assembly_composition,
component_name,
)
from cadpy.assembly_spec import REPO_ROOT, assembly_spec_children, read_assembly_spec
from cadpy.assembly_spec import assembly_spec_from_payload
from cadpy.catalog import (
CAD_ROOT,
CadSource,
STEP_SUFFIXES,
StepImportOptions,
cad_ref_from_step_path,
find_source_by_path,
iter_cad_sources,
normalize_step_color,
normalize_cad_ref,
normalize_source_ref,
source_from_path,
)
from cadpy.cli_logging import CliLogger
from cadpy.file_metadata import text_to_cad_identity_metadata, write_dxf_text_to_cad_metadata
from cadpy.glb import (
build_step_topology_index_manifest,
export_assembly_glb_from_scene,
export_native_glb_from_scene,
export_part_glb_from_scene,
)
from cadpy.glb import read_step_topology_manifest_from_glb
from cadpy.glb_topology import (
STEP_EDGE_VISIBILITY_CLASSES,
normalize_step_edge_render_visibility_classes,
)
from cadpy.generation_status import GenerationOutput, track_generation_run
from cadpy.metadata import (
DEFAULT_MESH_ANGULAR_TOLERANCE,
DEFAULT_MESH_TOLERANCE,
GeneratorMetadata,
resolve_mesh_settings,
)
from cadpy.render import (
native_component_glb_dir,
part_glb_path,
relative_to_file,
relative_to_repo,
)
from cadpy.source_hash import PythonSourceHash, python_source_hash
from cadpy.stl import export_part_stl_from_scene
from cadpy.step_export import build_build123d_step_scene, export_build123d_step_scene
from cadpy.threemf import export_part_3mf_from_scene
from cadpy.step_scene import (
ColorRGBA,
LoadedStepScene,
SelectorBundle,
SelectorOptions,
SelectorProfile,
adaptive_mesh_resolution_from_hints,
adaptive_mesh_resolution_for_scene,
extract_selectors_from_scene,
load_step_scene,
mesh_step_scene,
occurrence_selector_id,
scene_export_shape,
scene_leaf_occurrences,
step_file_hash,
)
GIT_LFS_POINTER_PREFIX = b"version https://git-lfs.github.com/spec/v1\n"
@dataclass(frozen=True)
class EntrySpec:
source_ref: str
cad_ref: str
kind: str
source_path: Path
display_name: str
source: str
step_path: Path | None = None
script_path: Path | None = None
generator_metadata: GeneratorMetadata | None = None
dxf_path: Path | None = None
urdf_path: Path | None = None
stl_path: Path | None = None
three_mf_path: Path | None = None
native_glb_path: Path | None = None
sdf_path: Path | None = None
mesh_tolerance: float = DEFAULT_MESH_TOLERANCE
mesh_angular_tolerance: float = DEFAULT_MESH_ANGULAR_TOLERANCE
mesh_tolerance_explicit: bool = False
mesh_angular_tolerance_explicit: bool = False
color: tuple[float, float, float, float] | None = None
@dataclass
class GeneratedStepResult:
spec: EntrySpec
scene: LoadedStepScene | None
selector_bundle: SelectorBundle | None = None
@dataclass(frozen=True)
class _CliTargetSpec:
target: str
output_path: Path | None = None
@dataclass
class _AssemblyArtifactContext:
spec: EntrySpec
scene: LoadedStepScene
entries_by_step_path: dict[Path, EntrySpec]
_occurrence_colors: dict[str, ColorRGBA] | None = None
_composition: dict[str, object] | None = None
_composition_resolved: bool = False
def occurrence_colors(self) -> dict[str, ColorRGBA]:
if self._occurrence_colors is None:
self._occurrence_colors = _generated_assembly_source_occurrence_colors(
self.spec,
self.scene,
entries_by_step_path=self.entries_by_step_path,
)
return self._occurrence_colors
def composition_for_topology(self, topology_manifest: dict[str, object]) -> dict[str, object] | None:
if not self._composition_resolved:
self._composition = _assembly_composition_for_spec(
self.spec,
entries_by_step_path=self.entries_by_step_path,
topology_manifest=topology_manifest,
scene=self.scene,
)
self._composition_resolved = True
return self._composition
class InlineStatusBoard:
def __init__(self, labels: Sequence[str], *, initial_status: str, stream: object | None = None) -> None:
self._stream = stream or sys.stdout
self._is_tty = getattr(self._stream, "isatty", lambda: False)()
self._labels = list(labels)
self._statuses = {label: initial_status for label in self._labels}
self._rendered_rows = 0
if self._labels and self._is_tty:
self._render()
else:
for label in self._labels:
print(self._row(label), file=self._stream)
def set(self, label: str, status: str) -> None:
previous = self._statuses.get(label)
if previous == status:
return
if label not in self._statuses:
self._labels.append(label)
self._statuses[label] = status
if self._is_tty:
self._render()
else:
print(self._row(label), file=self._stream)
def _row(self, label: str) -> str:
width = max(len(item) for item in self._labels)
return f"{label:<{width}} : {self._statuses.get(label, '')}"
def _render(self) -> None:
if not self._labels:
return
rows = [self._row(label) for label in self._labels]
if self._rendered_rows:
print(f"\x1b[{self._rendered_rows}F", end="", file=self._stream)
for row in rows:
print(f"\x1b[2K{row}", file=self._stream)
if self._rendered_rows > len(rows):
for _ in range(self._rendered_rows - len(rows)):
print("\x1b[2K", file=self._stream)
self._rendered_rows = len(rows)
self._stream.flush()
def _display_name_for_path(path: Path) -> str:
return path.stem
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 _resolve_cli_output_path(
raw_output: str | Path | None,
*,
expected_suffixes: tuple[str, ...],
tool_name: str,
option_label: str = "--output",
) -> Path | None:
if raw_output is None:
return None
value = str(raw_output).strip()
if not value:
raise ValueError(f"{tool_name} {option_label} must be a non-empty path")
if "\\" in value:
raise ValueError(f"{tool_name} {option_label} must use POSIX '/' separators")
output_path = Path(value).expanduser()
resolved = output_path.resolve() if output_path.is_absolute() else (Path.cwd() / output_path).resolve()
if resolved.suffix.lower() not in expected_suffixes:
joined = " or ".join(expected_suffixes)
raise ValueError(f"{tool_name} {option_label} must end in {joined}")
return resolved
def targets_include_output_pairs(targets: Sequence[str]) -> bool:
return any("=" in str(target or "") for target in targets)
def _parse_cli_target_specs(
targets: Sequence[str],
*,
expected_suffixes: tuple[str, ...],
tool_name: str,
) -> list[_CliTargetSpec]:
specs: list[_CliTargetSpec] = []
for target in targets:
target_text = str(target or "").strip()
if "=" not in target_text:
specs.append(_CliTargetSpec(target=target_text))
continue
raw_source, raw_output = target_text.split("=", 1)
source = raw_source.strip()
if not source:
raise ValueError(f"{tool_name} output pair must use SOURCE=OUTPUT")
output_path = _resolve_cli_output_path(
raw_output,
expected_suffixes=expected_suffixes,
tool_name=tool_name,
option_label="output pair",
)
if output_path is None:
raise ValueError(f"{tool_name} output pair must use SOURCE=OUTPUT")
specs.append(_CliTargetSpec(target=source, output_path=output_path))
return specs
def _resolve_step_option_output_path(
raw_output: str,
*,
base_step_path: Path,
expected_suffixes: tuple[str, ...],
field_name: str,
) -> Path:
value = str(raw_output or "").strip()
if not value:
raise ValueError(f"{field_name} must be a non-empty path")
if "\\" in value:
raise ValueError(f"{field_name} must use POSIX '/' separators")
pure = PurePosixPath(value)
if pure.is_absolute() or any(part in {"", "."} for part in pure.parts):
raise ValueError(f"{field_name} must be relative")
resolved = (base_step_path.resolve().parent / Path(*pure.parts)).resolve()
if resolved.suffix.lower() not in expected_suffixes:
joined = " or ".join(expected_suffixes)
raise ValueError(f"{field_name} must end in {joined}")
return resolved
def _apply_step_options_to_spec(spec: EntrySpec, step_options: StepImportOptions) -> EntrySpec:
if not step_options.has_metadata or spec.step_path is None:
return spec
stl_path = spec.stl_path
three_mf_path = spec.three_mf_path
native_glb_path = spec.native_glb_path
if step_options.stl is not None:
stl_path = _resolve_step_option_output_path(
step_options.stl,
base_step_path=spec.step_path,
expected_suffixes=(".stl",),
field_name="stl",
)
if step_options.three_mf is not None:
three_mf_path = _resolve_step_option_output_path(
step_options.three_mf,
base_step_path=spec.step_path,
expected_suffixes=(".3mf",),
field_name="3mf",
)
if step_options.glb is not None:
native_glb_path = _resolve_step_option_output_path(
step_options.glb,
base_step_path=spec.step_path,
expected_suffixes=(".glb",),
field_name="glb",
)
return replace(
spec,
stl_path=stl_path,
three_mf_path=three_mf_path,
native_glb_path=native_glb_path,
mesh_tolerance=step_options.mesh_tolerance if step_options.mesh_tolerance is not None else spec.mesh_tolerance,
mesh_angular_tolerance=(
step_options.mesh_angular_tolerance
if step_options.mesh_angular_tolerance is not None
else spec.mesh_angular_tolerance
),
mesh_tolerance_explicit=spec.mesh_tolerance_explicit or step_options.mesh_tolerance is not None,
mesh_angular_tolerance_explicit=(
spec.mesh_angular_tolerance_explicit or step_options.mesh_angular_tolerance is not None
),
)
def _spec_output_paths(spec: EntrySpec) -> tuple[Path, ...]:
paths: list[Path] = []
if spec.step_path is not None:
paths.append(spec.step_path)
paths.append(part_glb_path(spec.step_path))
for path in (spec.dxf_path, spec.urdf_path, spec.sdf_path, spec.stl_path, spec.three_mf_path, spec.native_glb_path):
if path is not None:
paths.append(path)
return tuple(path.resolve() for path in paths)
def _validate_cli_output_override(
spec: EntrySpec,
*,
output_path: Path,
all_specs: Sequence[EntrySpec],
tool_name: str,
) -> None:
resolved_output = output_path.resolve()
for candidate in all_specs:
if candidate.source_ref == spec.source_ref:
continue
if resolved_output in _spec_output_paths(candidate):
raise ValueError(
f"{tool_name} --output would overwrite another CAD output: "
f"{_display_path(output_path)} belongs to {candidate.source_ref}"
)
def _validate_duplicate_cli_output_overrides(
output_paths: Sequence[Path | None],
*,
tool_name: str,
) -> None:
seen: dict[Path, Path] = {}
for output_path in output_paths:
if output_path is None:
continue
resolved = output_path.resolve()
previous = seen.get(resolved)
if previous is not None:
raise ValueError(f"{tool_name} output path is used more than once: {_display_path(output_path)}")
seen[resolved] = output_path
def _apply_step_output_overrides(
selected_specs: Sequence[EntrySpec],
*,
output_paths: Sequence[Path | None],
all_specs: Sequence[EntrySpec],
tool_name: str,
) -> list[EntrySpec]:
if not any(output_path is not None for output_path in output_paths):
return list(selected_specs)
if len(output_paths) != len(selected_specs):
raise ValueError(f"{tool_name} output override count must match target count")
_validate_duplicate_cli_output_overrides(output_paths, tool_name=tool_name)
updated_specs: list[EntrySpec] = []
for spec, output_path in zip(selected_specs, output_paths, strict=True):
if output_path is None:
updated_specs.append(spec)
continue
if spec.source != "generated":
raise ValueError(f"{tool_name} output pairs can only be used with generated Python targets")
_validate_cli_output_override(spec, output_path=output_path, all_specs=all_specs, tool_name=tool_name)
updated_specs.append(
replace(
spec,
cad_ref=cad_ref_from_step_path(output_path),
display_name=_display_name_for_path(output_path),
step_path=output_path,
)
)
return updated_specs
def _apply_step_output_override(
selected_specs: Sequence[EntrySpec],
*,
output_path: Path | None,
all_specs: Sequence[EntrySpec],
tool_name: str,
) -> list[EntrySpec]:
if output_path is None:
return list(selected_specs)
if len(selected_specs) != 1:
raise ValueError(f"{tool_name} --output can only be used with exactly one target")
spec = selected_specs[0]
if spec.source != "generated":
raise ValueError(f"{tool_name} --output can only be used with generated Python targets")
return _apply_step_output_overrides(
selected_specs,
output_paths=[output_path],
all_specs=all_specs,
tool_name=tool_name,
)
def _apply_dxf_output_overrides(
selected_specs: Sequence[EntrySpec],
*,
output_paths: Sequence[Path | None],
all_specs: Sequence[EntrySpec],
tool_name: str,
) -> list[EntrySpec]:
if not any(output_path is not None for output_path in output_paths):
return list(selected_specs)
if len(output_paths) != len(selected_specs):
raise ValueError(f"{tool_name} output override count must match target count")
_validate_duplicate_cli_output_overrides(output_paths, tool_name=tool_name)
updated_specs: list[EntrySpec] = []
for spec, output_path in zip(selected_specs, output_paths, strict=True):
if output_path is None:
updated_specs.append(spec)
continue
if spec.source != "generated":
raise ValueError(f"{tool_name} output pairs can only be used with generated Python targets")
_validate_cli_output_override(spec, output_path=output_path, all_specs=all_specs, tool_name=tool_name)
updated_specs.append(replace(spec, dxf_path=output_path))
return updated_specs
def _apply_dxf_output_override(
selected_specs: Sequence[EntrySpec],
*,
output_path: Path | None,
all_specs: Sequence[EntrySpec],
tool_name: str,
) -> list[EntrySpec]:
if output_path is None:
return list(selected_specs)
if len(selected_specs) != 1:
raise ValueError(f"{tool_name} --output can only be used with exactly one target")
spec = selected_specs[0]
if spec.source != "generated":
raise ValueError(f"{tool_name} --output can only be used with generated Python targets")
return _apply_dxf_output_overrides(
selected_specs,
output_paths=[output_path],
all_specs=all_specs,
tool_name=tool_name,
)
def _resolve_discovery_root(root: Path | str) -> Path:
candidate = Path(root)
resolved = candidate.resolve() if candidate.is_absolute() else (Path.cwd() / candidate).resolve()
if not resolved.exists():
raise FileNotFoundError(f"CAD discovery directory does not exist: {relative_to_repo(resolved)}")
if not resolved.is_dir():
raise NotADirectoryError(f"CAD discovery path is not a directory: {relative_to_repo(resolved)}")
return resolved
def list_entry_specs(root: Path | None = None, *, validate: bool = True) -> list[EntrySpec]:
root = CAD_ROOT if root is None else root
specs = [_entry_spec_from_source(source) for source in iter_cad_sources(_resolve_discovery_root(root))]
if validate:
_validate_part_render_output_paths(specs)
return sorted(specs, key=lambda spec: spec.source_ref)
def _entry_spec_from_source(source: CadSource) -> EntrySpec:
generator_metadata = source.generator_metadata
script_path = source.script_path
kind = source.kind
step_path = source.step_path
mesh_settings = resolve_mesh_settings(
cad_ref=source.cad_ref,
generator_metadata=generator_metadata,
mesh_tolerance=source.mesh_tolerance,
mesh_angular_tolerance=source.mesh_angular_tolerance,
)
display_path = step_path if step_path is not None else source.source_path
urdf_path = source.urdf_path
return EntrySpec(
source_ref=source.source_ref,
cad_ref=source.cad_ref,
kind=kind,
source_path=source.source_path,
display_name=(
generator_metadata.display_name
if generator_metadata is not None and generator_metadata.display_name
else _display_name_for_path(display_path)
),
source=source.source,
step_path=step_path,
script_path=script_path,
generator_metadata=generator_metadata,
dxf_path=source.dxf_path,
urdf_path=urdf_path,
sdf_path=source.sdf_path,
stl_path=source.stl_path,
three_mf_path=source.three_mf_path,
native_glb_path=source.native_glb_path,
mesh_tolerance=mesh_settings.tolerance,
mesh_angular_tolerance=mesh_settings.angular_tolerance,
mesh_tolerance_explicit=source.mesh_tolerance is not None,
mesh_angular_tolerance_explicit=source.mesh_angular_tolerance is not None,
color=source.color,
)
def _validate_part_render_output_paths(specs: Sequence[EntrySpec]) -> None:
sources_by_stl_path: dict[Path, str] = {}
sources_by_3mf_path: dict[Path, str] = {}
sources_by_native_glb_path: dict[Path, str] = {}
for spec in specs:
if spec.kind not in {"part", "assembly"} or spec.step_path is None:
continue
if spec.stl_path is not None:
stl_path = spec.stl_path.resolve()
existing_source_ref = sources_by_stl_path.get(stl_path)
if existing_source_ref is not None and existing_source_ref != spec.source_ref:
raise ValueError(
"STL output collision between "
f"{existing_source_ref} and {spec.source_ref}: {_display_path(stl_path)}"
)
sources_by_stl_path[stl_path] = spec.source_ref
if spec.three_mf_path is not None:
three_mf_path = spec.three_mf_path.resolve()
existing_source_ref = sources_by_3mf_path.get(three_mf_path)
if existing_source_ref is not None and existing_source_ref != spec.source_ref:
raise ValueError(
"3MF output collision between "
f"{existing_source_ref} and {spec.source_ref}: {_display_path(three_mf_path)}"
)
sources_by_3mf_path[three_mf_path] = spec.source_ref
if spec.native_glb_path is not None:
native_glb_path = spec.native_glb_path.resolve()
topology_glb_path = part_glb_path(spec.step_path).resolve()
if native_glb_path == topology_glb_path:
raise ValueError(
"Native GLB output would overwrite the STEP topology GLB artifact for "
f"{spec.source_ref}: {_display_path(native_glb_path)}"
)
existing_source_ref = sources_by_native_glb_path.get(native_glb_path)
if existing_source_ref is not None and existing_source_ref != spec.source_ref:
raise ValueError(
"Native GLB output collision between "
f"{existing_source_ref} and {spec.source_ref}: {_display_path(native_glb_path)}"
)
sources_by_native_glb_path[native_glb_path] = spec.source_ref
def selected_entry_specs(all_specs: Sequence[EntrySpec], source_refs: Sequence[str]) -> list[EntrySpec]:
if not source_refs:
raise ValueError("At least one CAD target is required")
by_source = {spec.source_ref: spec for spec in all_specs}
by_cad_ref = {spec.cad_ref: spec for spec in all_specs}
by_step_path = {
spec.step_path.resolve(): spec
for spec in all_specs
if spec.step_path is not None
}
selected: list[EntrySpec] = []
for source_ref in source_refs:
spec = _spec_for_source_ref(source_ref, by_source=by_source, by_cad_ref=by_cad_ref, by_step_path=by_step_path)
if spec is None:
raise FileNotFoundError(f"CAD source not found: {source_ref}")
selected.append(spec)
return selected
def _spec_for_source_ref(
raw_ref: str,
*,
by_source: dict[str, EntrySpec],
by_cad_ref: dict[str, EntrySpec],
by_step_path: dict[Path, EntrySpec],
) -> EntrySpec | None:
source_ref = normalize_source_ref(raw_ref)
if source_ref and source_ref in by_source:
return by_source[source_ref]
cad_ref = normalize_cad_ref(raw_ref)
if cad_ref and cad_ref in by_cad_ref:
return by_cad_ref[cad_ref]
candidate = Path(str(raw_ref or "").strip())
if candidate:
resolved = candidate.resolve() if candidate.is_absolute() else (
Path.cwd() / candidate
)
resolved = resolved.resolve()
if resolved in by_step_path:
return by_step_path[resolved]
source = find_source_by_path(resolved)
if source is not None:
return by_source.get(source.source_ref)
return None
def _mesh_tolerance_is_explicit(spec: EntrySpec) -> bool:
return bool(spec.mesh_tolerance_explicit) or not math.isclose(
float(spec.mesh_tolerance),
float(DEFAULT_MESH_TOLERANCE),
rel_tol=1e-12,
abs_tol=1e-12,
)
def _mesh_angular_tolerance_is_explicit(spec: EntrySpec) -> bool:
return bool(spec.mesh_angular_tolerance_explicit) or not math.isclose(
float(spec.mesh_angular_tolerance),
float(DEFAULT_MESH_ANGULAR_TOLERANCE),
rel_tol=1e-12,
abs_tol=1e-12,
)
def _selector_options_for_part(spec: EntrySpec, *, scene: LoadedStepScene | None = None) -> SelectorOptions:
defaults = SelectorOptions()
linear_deflection = spec.mesh_tolerance
angular_deflection = spec.mesh_angular_tolerance
resolution: dict[str, object] = {
"mode": "explicit",
"profile": "custom",
"linearExplicit": True,
"angularExplicit": True,
}
linear_explicit = _mesh_tolerance_is_explicit(spec)
angular_explicit = _mesh_angular_tolerance_is_explicit(spec)
edge_visibility_classes = normalize_step_edge_render_visibility_classes(None)
if isinstance(scene, LoadedStepScene):
adaptive = adaptive_mesh_resolution_for_scene(scene)
if not linear_explicit:
linear_deflection = adaptive.settings.tolerance
if not angular_explicit:
angular_deflection = adaptive.settings.angular_tolerance
edge_visibility_classes = _edge_visibility_classes_for_resolution(adaptive.profile, adaptive.hints)
resolution = {
"mode": "auto",
"profile": adaptive.profile,
"linearExplicit": linear_explicit,
"angularExplicit": angular_explicit,
"hints": adaptive.hints,
}
return SelectorOptions(
linear_deflection=linear_deflection,
angular_deflection=angular_deflection,
relative=defaults.relative,
edge_deflection=defaults.edge_deflection,
edge_deflection_ratio=defaults.edge_deflection_ratio,
max_edge_points=defaults.max_edge_points,
digits=defaults.digits,
mesh_resolution=resolution,
edge_visibility_classes=edge_visibility_classes,
)
def _edge_visibility_classes_for_resolution(profile: str, hints: Mapping[str, object] | None) -> tuple[str, ...]:
normalized_profile = str(profile or "").strip().lower()
hint_values = hints if isinstance(hints, Mapping) else {}
occurrence_edge_count = _hint_int(hint_values.get("occurrenceEdgeCount"))
feature_only = (
normalized_profile in {"large-topology", "coarse-assembly"}
or occurrence_edge_count >= 8000
)
if feature_only:
return (STEP_EDGE_VISIBILITY_CLASSES["FEATURE"],)
return normalize_step_edge_render_visibility_classes(None)
def _hint_float(value: object) -> float:
try:
return float(value or 0.0)
except (TypeError, ValueError):
return 0.0
def _hint_int(value: object) -> int:
return int(_hint_float(value))
def _load_generator_module(script_path: Path) -> object:
resolved_script_path = script_path.resolve()
module_name = (
"_cad_tool_"
+ _display_path(resolved_script_path).replace("/", "_").replace("\\", "_").replace("-", "_").replace(".", "_")
)
module_spec = importlib.util.spec_from_file_location(module_name, resolved_script_path)
if module_spec is None or module_spec.loader is None:
raise RuntimeError(f"Failed to load generator module from {_display_path(resolved_script_path)}")
module = importlib.util.module_from_spec(module_spec)
original_sys_path = list(sys.path)
search_paths = [
str(REPO_ROOT),
str(CAD_ROOT),
str(REPO_ROOT / "skills" / "cad" / "scripts"),
str(resolved_script_path.parent),
]
for parent in resolved_script_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)
finally:
sys.path[:] = original_sys_path
return module
def _normalize_step_payload(
result: object,
*,
script_path: Path,
) -> dict[str, object]:
from build123d import Shape as Build123dShape
if isinstance(result, Build123dShape):
return {"shape": result}
if isinstance(result, list):
return {"children": result}
if isinstance(result, dict):
allowed_fields = {
"shape",
"instances",
"children",
"assembly_mates",
"stl",
"3mf",
"mesh_tolerance",
"mesh_angular_tolerance",
}
extra_fields = sorted(str(key) for key in result if key not in allowed_fields)
if extra_fields:
joined = ", ".join(extra_fields)
raise TypeError(f"{_display_path(script_path)} gen_step() envelope has unsupported field(s): {joined}")
content_fields = [key for key in ("shape", "instances", "children") if key in result]
if len(content_fields) != 1:
raise TypeError(
f"{_display_path(script_path)} gen_step() envelope must define exactly one of "
"'shape', 'instances', or 'children'"
)
normalized = {content_fields[0]: result[content_fields[0]]}
if "assembly_mates" in result:
raw_mates = result["assembly_mates"]
if not isinstance(raw_mates, list):
raise TypeError(f"{_display_path(script_path)} gen_step() assembly_mates must be a list")
normalized["assembly_mates"] = raw_mates
return normalized
raise TypeError(
f"{_display_path(script_path)} gen_step() must return a build123d Shape, assembly list, "
"or legacy envelope dict"
)
def _normalize_dxf_payload(result: object, *, script_path: Path) -> dict[str, object]:
if isinstance(result, dict):
allowed_fields = {"document"}
extra_fields = sorted(str(key) for key in result if key not in allowed_fields)
if extra_fields:
joined = ", ".join(extra_fields)
raise TypeError(f"{_display_path(script_path)} gen_dxf() envelope has unsupported field(s): {joined}")
if "document" not in result:
raise TypeError(f"{_display_path(script_path)} gen_dxf() envelope must define 'document'")
return {"document": result["document"]}
return {"document": result}
def _shape_payload_entry_kind(shape: object, *, fallback: str) -> str:
if fallback not in {"part", "assembly"}:
raise RuntimeError(f"Unsupported generated STEP kind: {fallback}")
if (
fallback == "assembly"
or _shape_has_explicit_children(shape)
or _shape_is_multi_child_compound(shape)
):
return "assembly"
return "part"
def _shape_has_explicit_children(shape: object) -> bool:
try:
from build123d import Shape as Build123dShape
except Exception:
return False
if not isinstance(shape, Build123dShape):
return False
try:
return bool(tuple(getattr(shape, "children", ()) or ()))
except TypeError:
return False
def _shape_is_multi_child_compound(shape: object) -> bool:
try:
from OCP.TopAbs import TopAbs_COMPOUND
from OCP.TopoDS import TopoDS_Iterator
from build123d import Shape as Build123dShape
except Exception:
return False
if not isinstance(shape, Build123dShape):
return False
wrapped = getattr(shape, "wrapped", None)
if wrapped is None:
return False
try:
if wrapped.ShapeType() != TopAbs_COMPOUND:
return False
except Exception:
return False
iterator = TopoDS_Iterator(wrapped)
count = 0
while iterator.More():
count += 1
if count > 1:
return True
iterator.Next()
return False
def _mark_scene_step_payload(
scene: LoadedStepScene,
*,
entry_kind: str,
payload_kind: str,
) -> LoadedStepScene:
if isinstance(scene, LoadedStepScene):
scene.text_to_cad_entry_kind = entry_kind
scene.step_payload_kind = payload_kind
return scene
def _scene_entry_kind(scene: LoadedStepScene | None) -> str | None:
if scene is None:
return None
entry_kind = str(getattr(scene, "text_to_cad_entry_kind", "") or "").strip().lower()
return entry_kind if entry_kind in {"part", "assembly"} else None
def _effective_step_spec_for_scene(spec: EntrySpec, scene: LoadedStepScene | None) -> EntrySpec:
entry_kind = _scene_entry_kind(scene)
if entry_kind is None or entry_kind == spec.kind:
return spec
return replace(spec, kind=entry_kind)
def _write_shape_step_payload(
envelope: dict[str, object],
*,
output_path: Path,
script_path: Path,
logger: CliLogger,
entry_kind: str,
skip_step_write: bool = False,
) -> LoadedStepScene:
shape = envelope.get("shape")
from build123d import Shape as Build123dShape
if not isinstance(shape, Build123dShape):
raise TypeError(
f"{_display_path(script_path)} gen_step() envelope field 'shape' must be a build123d Shape, "
f"got {type(shape).__name__}"
)
source_identity = python_source_hash(script_path)
if skip_step_write:
scene = build_build123d_step_scene(
shape,
output_path,
source_kind="python",
source_hash=source_identity.source_hash,
)
_mark_scene_python_backed(scene, source_identity=source_identity, source_path=script_path)
_mark_scene_step_payload(scene, entry_kind=entry_kind, payload_kind="shape")
logger.debug(f"built STEP scene without writing STEP: {_display_path(output_path)}")
return scene
scene = export_build123d_step_scene(
shape,
output_path,
text_to_cad_entry_kind=entry_kind,
source_path=relative_to_file(script_path, output_path),
source_hash=source_identity.source_hash,
)
_mark_scene_python_backed(scene, source_identity=source_identity, source_path=script_path)
_mark_scene_step_payload(scene, entry_kind=entry_kind, payload_kind="shape")
logger.debug(f"wrote STEP: {_display_path(output_path)}")
return scene
def _mark_scene_python_backed(
scene: LoadedStepScene,
*,
source_identity: PythonSourceHash,
source_path: Path,
) -> LoadedStepScene:
if not isinstance(scene, LoadedStepScene):
return scene
scene.source_kind = "python"
scene.source_hash = source_identity.source_hash
scene.source_path = relative_to_file(source_path, scene.step_path)
return scene
def _write_assembly_step_payload(
envelope: dict[str, object],
*,
output_path: Path,
script_path: Path,
logger: CliLogger,
force: bool = False,
load_current_scene: bool = True,
skip_step_write: bool = False,
) -> LoadedStepScene | None:
from .assembly_export import (
_AssemblyCatalogResolver,
build_direct_assembly_step_scene,
export_assembly_step_scene,
)
if "instances" not in envelope and "children" not in envelope:
raise TypeError(
f"{_display_path(script_path)} gen_step() envelope must define 'instances' or 'children'"
)
payload = {key: envelope[key] for key in ("instances", "children") if key in envelope}
assembly_spec = assembly_spec_from_payload(script_path, payload)
resolver = _AssemblyCatalogResolver()
source_identity = python_source_hash(script_path)
if skip_step_write:
with logger.timed(f"build assembly scene {relative_to_repo(output_path)}"):
scene = build_direct_assembly_step_scene(
assembly_spec,
output_path=output_path,
source_kind="python",
source_hash=source_identity.source_hash,
resolver=resolver,
logger=logger,
)
_mark_scene_python_backed(scene, source_identity=source_identity, source_path=script_path)
_mark_scene_step_payload(scene, entry_kind="assembly", payload_kind="assembly_spec")
_attach_envelope_assembly_mates(scene, envelope, script_path=script_path)
return scene
with logger.timed(f"write assembly STEP {relative_to_repo(output_path)}"):
scene = export_assembly_step_scene(
assembly_spec,
output_path=output_path,
text_to_cad_entry_kind="assembly",
source_path=relative_to_file(script_path, output_path),
source_hash=source_identity.source_hash,
resolver=resolver,
logger=logger,
)
_mark_scene_python_backed(scene, source_identity=source_identity, source_path=script_path)
_mark_scene_step_payload(scene, entry_kind="assembly", payload_kind="assembly_spec")
_attach_envelope_assembly_mates(scene, envelope, script_path=script_path)
return scene
def _attach_envelope_assembly_mates(
scene: LoadedStepScene | None,
envelope: Mapping[str, object],
*,
script_path: Path,
) -> None:
if not isinstance(scene, LoadedStepScene) or "assembly_mates" not in envelope:
return
raw_mates = envelope["assembly_mates"]
if not isinstance(raw_mates, list):
raise TypeError(f"{_display_path(script_path)} gen_step() assembly_mates must be a list")
mates: list[dict[str, object]] = []
for index, raw_mate in enumerate(raw_mates, start=1):
if not isinstance(raw_mate, Mapping):
raise TypeError(f"{_display_path(script_path)} gen_step() assembly_mates[{index}] must be an object")
mate = dict(raw_mate)
mate_id = f"m{index}"
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)
if mates:
scene.assembly_mates = mates
def _write_dxf_payload(
envelope: dict[str, object],
*,
output_path: Path,
script_path: Path,
logger: CliLogger,
) -> None:
document = envelope.get("document")
saveas = getattr(document, "saveas", None)
if not callable(saveas):
raise TypeError(
f"{_display_path(script_path)} gen_dxf() envelope field 'document' must be a DXF document, "
f"got {type(document).__name__}"
)
output_path.parent.mkdir(parents=True, exist_ok=True)
saveas(str(output_path))
source_identity = python_source_hash(script_path)
write_dxf_text_to_cad_metadata(
output_path,
text_to_cad_identity_metadata(
source_path=relative_to_file(script_path, output_path),
source_hash=source_identity.source_hash,
),
)
logger.debug(f"wrote DXF: {_display_path(output_path)}")
def run_script_generator(
spec: EntrySpec,
generator_name: str,
*,
logger: CliLogger | None = None,
force: bool = False,
load_current_scene: bool = True,
skip_step_write: bool = False,
) -> LoadedStepScene | None:
logger = logger or CliLogger("cad")
if generator_name not in {"gen_step", "gen_dxf"}:
raise RuntimeError(f"Unsupported generator: {generator_name}")
if spec.script_path is None or spec.generator_metadata is None:
raise ValueError(f"{spec.source_ref} is not a generated Python CAD source")
with _track_spec_generation(spec, generator_name):
return _run_script_generator_inner(
spec,
generator_name,
logger=logger,
force=force,
load_current_scene=load_current_scene,
skip_step_write=skip_step_write,
)
def _run_script_generator_inner(
spec: EntrySpec,
generator_name: str,
*,
logger: CliLogger,
force: bool = False,
load_current_scene: bool = True,
skip_step_write: bool = False,
) -> LoadedStepScene | None:
generated_scene: LoadedStepScene | None = None
with logger.timed(f"load generator {spec.source_ref}"):
module = _load_generator_module(spec.script_path)
generator = getattr(module, generator_name, None)
if not callable(generator):
raise RuntimeError(f"{_display_path(spec.script_path)} does not define callable {generator_name}()")
with logger.timed(f"run {generator_name} {spec.source_ref}"):
raw_payload = generator()
if generator_name == "gen_step":
envelope = _normalize_step_payload(raw_payload, script_path=spec.script_path)
if spec.step_path is None:
raise RuntimeError(f"{spec.source_ref} has no configured STEP output")
if "shape" in envelope:
generated_scene = _write_shape_step_payload(
envelope,
output_path=spec.step_path,
script_path=spec.script_path,
logger=logger,
entry_kind=_shape_payload_entry_kind(envelope.get("shape"), fallback=spec.kind),
skip_step_write=skip_step_write,
)
elif "instances" in envelope or "children" in envelope:
generated_scene = _write_assembly_step_payload(
envelope,
output_path=spec.step_path,
script_path=spec.script_path,
logger=logger,
force=force,
load_current_scene=load_current_scene,
skip_step_write=skip_step_write,
)
logger.debug(
f"ready STEP scene: {_display_path(spec.step_path)}"
if skip_step_write
else f"ready STEP: {_display_path(spec.step_path)}"
)
else:
raise RuntimeError(f"{spec.source_ref} has unsupported generated kind: {spec.kind}")
elif generator_name == "gen_dxf":
envelope = _normalize_dxf_payload(raw_payload, script_path=spec.script_path)
if spec.dxf_path is None:
raise RuntimeError(f"{spec.source_ref} has no configured DXF output")
_write_dxf_payload(envelope, output_path=spec.dxf_path, script_path=spec.script_path, logger=logger)
if (
generator_name == "gen_step"
and spec.step_path is not None
and not skip_step_write
and not spec.step_path.exists()
):
raise RuntimeError(
f"{_display_path(spec.script_path)} did not write {_display_path(spec.step_path)}"
)
if generator_name == "gen_dxf" and spec.dxf_path is not None and not spec.dxf_path.exists():
raise RuntimeError(
f"{_display_path(spec.script_path)} did not write {_display_path(spec.dxf_path)}"
)
return generated_scene if generator_name == "gen_step" else None
def _is_git_lfs_pointer(step_path: Path) -> bool:
try:
with step_path.open("rb") as handle:
return handle.read(len(GIT_LFS_POINTER_PREFIX)) == GIT_LFS_POINTER_PREFIX
except OSError:
return False
def _ensure_step_ready(step_path: Path) -> None:
if not step_path.exists():
raise FileNotFoundError(f"STEP file is missing: {_display_path(step_path)}")
if _is_git_lfs_pointer(step_path):
raise RuntimeError(
f"{_display_path(step_path)} is a Git LFS pointer, not the real STEP file.\n"
"Fetch Git LFS objects before generating CAD artifacts.\n"
"For Vercel Git deployments, enable Git LFS in Project Settings > Git and redeploy."
)
def _read_json_payload(path: Path) -> dict[str, object] | None:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
return payload if isinstance(payload, dict) else None
def _report_selector_manifest_change(
spec: EntrySpec,
previous_manifest: dict[str, object] | None,
next_manifest: dict[str, object],
*,
logger: CliLogger,
) -> None:
change = selector_manifest_diff(previous_manifest, next_manifest)
if not bool(change.get("hasPrevious")):
return
if bool(change.get("topologyChanged")):
logger.warning(
f"{spec.cad_ref} selector topology changed; re-resolve selector refs before using old face or edge selectors."
)
return
if bool(change.get("geometryChanged")):
logger.info(
f"notice: {spec.cad_ref} selector geometry changed; re-check cached geometry facts from older refs."
)
def _assembly_composition_for_spec(
spec: EntrySpec,
*,
entries_by_step_path: dict[Path, EntrySpec],
topology_manifest: dict[str, object],
scene: LoadedStepScene,
) -> dict[str, object] | None:
if spec.kind != "assembly" or spec.step_path is None:
return None
if spec.source == "imported":
return build_native_assembly_composition(
cad_ref=spec.cad_ref,
topology_path=part_glb_path(spec.step_path),
topology_manifest=topology_manifest,
mesh_path=part_glb_path(spec.step_path),
)
if spec.source == "generated" and getattr(scene, "step_payload_kind", None) == "shape":
return build_native_assembly_composition(
cad_ref=spec.cad_ref,
topology_path=part_glb_path(spec.step_path),
topology_manifest=topology_manifest,
mesh_path=part_glb_path(spec.step_path),
)
if spec.source_path is None:
return None
assembly_spec = read_assembly_spec(spec.source_path)
return build_linked_assembly_composition(
cad_ref=spec.cad_ref,
topology_path=part_glb_path(spec.step_path),
topology_manifest=topology_manifest,
assembly_spec=assembly_spec,
entries_by_step_path=entries_by_step_path,
read_assembly_spec=read_assembly_spec,
mesh_path=part_glb_path(spec.step_path),
)
def _script_step_material_colors(spec: EntrySpec) -> dict[str, ColorRGBA]:
if spec.script_path is None:
return {}
try:
module = _load_generator_module(spec.script_path)
except Exception:
return {}
raw_materials = getattr(module, "URDF_MATERIALS", {})
raw_step_materials = getattr(module, "URDF_STEP_MATERIALS", {})
if not isinstance(raw_materials, Mapping) or not isinstance(raw_step_materials, Mapping):
return {}
colors: dict[str, ColorRGBA] = {}
for raw_step_path, raw_material_name in raw_step_materials.items():
if not isinstance(raw_step_path, str) or not isinstance(raw_material_name, str):
continue
raw_color = raw_materials.get(raw_material_name)
try:
color = normalize_step_color(raw_color, base_path=spec.source_path, field_name=f"URDF_MATERIALS.{raw_material_name}")
except Exception:
color = None
if color is not None:
colors[Path(raw_step_path).as_posix()] = color
return colors
def _color_key(color: ColorRGBA) -> tuple[int, int, int, int]:
return tuple(max(0, min(255, int(round(float(channel) * 255)))) for channel in color)
def _uniform_source_step_color(step_path: Path) -> ColorRGBA | None:
try:
scene = load_step_scene(step_path)
except Exception:
return None
colors: list[ColorRGBA] = []
colors.extend(tuple(float(value) for value in color) for color in scene.prototype_colors.values())
for face_colors in scene.prototype_face_colors.values():
colors.extend(tuple(float(value) for value in color) for color in face_colors.values())
colors.extend(
tuple(float(value) for value in node.color)
for node in scene_leaf_occurrences(scene)
if node.color is not None
)
by_key = {_color_key(color): color for color in colors}
if len(by_key) == 1:
return next(iter(by_key.values()))
return None
def _uniform_scene_node_color(scene: LoadedStepScene, node: object | None) -> tuple[bool, ColorRGBA | None]:
if node is None:
return False, None
colors_by_key: dict[tuple[int, int, int, int], ColorRGBA] = {}
def add_color(raw_color: object) -> None:
if raw_color is None:
return
try:
color = tuple(float(value) for value in raw_color)
except (TypeError, ValueError):
return
if len(color) != 4:
return
colors_by_key[_color_key(color)] = color
def collect(current: object) -> None:
add_color(getattr(current, "color", None))
prototype_key = getattr(current, "prototype_key", None)
if prototype_key is not None:
add_color(scene.prototype_colors.get(int(prototype_key)))
for face_color in scene.prototype_face_colors.get(int(prototype_key), {}).values():
add_color(face_color)
for child in getattr(current, "children", []) or []:
collect(child)
collect(node)
if not colors_by_key:
return False, None
if len(colors_by_key) == 1:
return True, next(iter(colors_by_key.values()))
return True, None
def _generated_assembly_source_occurrence_colors(
spec: EntrySpec,
scene: LoadedStepScene,
*,
entries_by_step_path: dict[Path, EntrySpec],
) -> dict[str, ColorRGBA]:
if spec.kind != "assembly" or spec.source != "generated" or spec.step_path is None or spec.source_path is None:
return {}
try:
assembly_spec = read_assembly_spec(spec.source_path)
except Exception:
return {}
step_root = spec.step_path.parent.resolve()
script_step_colors = _script_step_material_colors(spec)
source_color_cache: dict[Path, ColorRGBA | None] = {}
occurrence_colors: dict[str, ColorRGBA] = {}
def color_for_source(
source_path: Path | None,
*,
use_source_colors: bool,
scene_node: object | None,
) -> ColorRGBA | None:
if not use_source_colors or source_path is None:
return None
resolved = Path(source_path).resolve()
try:
step_key = resolved.relative_to(step_root).as_posix()
except ValueError:
step_key = resolved.as_posix()
material_color = script_step_colors.get(step_key)
if material_color is not None:
return material_color
scene_has_colors, scene_color = _uniform_scene_node_color(scene, scene_node)
if scene_has_colors:
return scene_color
if resolved not in source_color_cache:
source_color_cache[resolved] = _uniform_source_step_color(resolved)
return source_color_cache[resolved]
def source_spec_for(source_path: Path | None) -> EntrySpec | None:
if source_path is None:
return None
return entries_by_step_path.get(Path(source_path).resolve())
def candidate_scene_roots() -> list[object]:
roots = list(getattr(scene, "roots", []) or [])
if len(roots) == 1 and getattr(roots[0], "prototype_key", None) is None and getattr(roots[0], "children", None):
return list(roots[0].children)
return roots
def match_scene_node(candidates: Sequence[object], instance_path: tuple[str, ...], index: int) -> object | None:
expected_name = component_name(instance_path)
for candidate in candidates:
if getattr(candidate, "name", None) == expected_name or getattr(candidate, "source_name", None) == expected_name:
return candidate
if 0 <= index < len(candidates):
return candidates[index]
return None
def collect(
spec_nodes: Sequence[object],
scene_nodes: Sequence[object],
*,
instance_path: tuple[str, ...],
parent_use_source_colors: bool,
stack: tuple[str, ...],
) -> None:
for index, node_spec in enumerate(spec_nodes):
node_instance_id = str(getattr(node_spec, "instance_id", "") or getattr(node_spec, "name", "") or index + 1)
node_path = (*instance_path, node_instance_id)
scene_node = match_scene_node(scene_nodes, node_path, index)
use_source_colors = parent_use_source_colors and bool(getattr(node_spec, "use_source_colors", True))
node_children = tuple(getattr(node_spec, "children", ()) or ())
child_scene_nodes = list(getattr(scene_node, "children", []) or []) if scene_node is not None else []
if node_children:
collect(
node_children,
child_scene_nodes,
instance_path=node_path,
parent_use_source_colors=use_source_colors,
stack=stack,
)
continue
source_path = getattr(node_spec, "source_path", None)
source_spec = source_spec_for(source_path)
if source_spec is not None and source_spec.kind == "assembly" and source_spec.source_path is not None:
stack_key = Path(source_spec.source_path).resolve().as_posix()
if stack_key in stack:
continue
try:
child_spec = read_assembly_spec(source_spec.source_path)
except Exception:
child_spec = None
if child_spec is not None:
collect(
assembly_spec_children(child_spec),
child_scene_nodes,
instance_path=node_path,
parent_use_source_colors=use_source_colors,
stack=(*stack, stack_key),
)
continue
color = color_for_source(
Path(source_path) if source_path is not None else None,
use_source_colors=use_source_colors,
scene_node=scene_node,
)
if color is not None and scene_node is not None:
occurrence_colors[occurrence_selector_id(scene_node)] = color
collect(
assembly_spec_children(assembly_spec),
candidate_scene_roots(),
instance_path=(),
parent_use_source_colors=True,
stack=(assembly_spec.assembly_path.resolve().as_posix(),),
)
return occurrence_colors
@dataclass(frozen=True)
class _ArtifactJob:
name: str
run: Callable[[], object]
def _run_artifact_jobs(
jobs: Sequence[_ArtifactJob],
*,
logger: CliLogger | None = None,
) -> dict[str, object]:
results: dict[str, object] = {}
for job in jobs:
if logger is not None:
with logger.timed(f"write {job.name}"):
results[job.name] = job.run()
else:
results[job.name] = job.run()
return results
def _mesh_values_match(
mesh: Mapping[str, object],
*,
linear_deflection: float,
angular_deflection: float,
relative: bool,
) -> bool:
try:
artifact_linear = float(mesh.get("linearDeflection"))
artifact_angular = float(mesh.get("angularDeflection"))
except (TypeError, ValueError):
return False
return (
math.isclose(artifact_linear, float(linear_deflection), rel_tol=1e-9, abs_tol=1e-12)
and math.isclose(artifact_angular, float(angular_deflection), rel_tol=1e-9, abs_tol=1e-12)
and bool(mesh.get("relative", True)) == bool(relative)
)
def _selector_options_from_topology_manifest(spec: EntrySpec, manifest: Mapping[str, object]) -> SelectorOptions | None:
mesh = manifest.get("mesh")
if not isinstance(mesh, Mapping):
return None
defaults = SelectorOptions()
linear_explicit = _mesh_tolerance_is_explicit(spec)
angular_explicit = _mesh_angular_tolerance_is_explicit(spec)
linear_deflection = spec.mesh_tolerance
angular_deflection = spec.mesh_angular_tolerance
if not linear_explicit or not angular_explicit:
resolution = mesh.get("resolution")
hints = resolution.get("hints") if isinstance(resolution, Mapping) else None
if not isinstance(hints, dict):
return None
adaptive = adaptive_mesh_resolution_from_hints(hints)
if not linear_explicit:
linear_deflection = adaptive.settings.tolerance
if not angular_explicit:
angular_deflection = adaptive.settings.angular_tolerance
return SelectorOptions(
linear_deflection=linear_deflection,
angular_deflection=angular_deflection,
relative=bool(mesh.get("relative", defaults.relative)),
edge_deflection=defaults.edge_deflection,
edge_deflection_ratio=defaults.edge_deflection_ratio,
max_edge_points=defaults.max_edge_points,
digits=defaults.digits,
mesh_resolution=mesh.get("resolution") if isinstance(mesh.get("resolution"), dict) else None,
edge_visibility_classes=_edge_visibility_classes_from_topology_manifest(manifest),
)
def _edge_visibility_classes_from_topology_manifest(manifest: Mapping[str, object]) -> tuple[str, ...]:
edge_rendering = manifest.get("edgeRendering")
if isinstance(edge_rendering, Mapping):
classes = edge_rendering.get("visibilityClasses")
if classes is not None:
return normalize_step_edge_render_visibility_classes(classes)
mesh = manifest.get("mesh")
resolution = mesh.get("resolution") if isinstance(mesh, Mapping) else None
hints = resolution.get("hints") if isinstance(resolution, Mapping) else None
profile = resolution.get("profile") if isinstance(resolution, Mapping) else ""
if isinstance(hints, Mapping):
return _edge_visibility_classes_for_resolution(str(profile or ""), hints)
return normalize_step_edge_render_visibility_classes(None)
def _edge_visibility_classes_match_manifest(
manifest: Mapping[str, object],
selector_options: SelectorOptions,
) -> bool:
edge_rendering = manifest.get("edgeRendering")
if not isinstance(edge_rendering, Mapping):
return False
return tuple(edge_rendering.get("visibilityClasses") or ()) == tuple(selector_options.edge_visibility_classes)
def _artifact_source_kind_matches_spec(spec: EntrySpec, manifest: Mapping[str, object]) -> bool:
source_kind = str(manifest.get("sourceKind") or "step").strip().lower()
if spec.source != "generated" and spec.step_path is not None and spec.step_path.is_file():
if source_kind == "python":
return bool(str(manifest.get("stepHash") or "").strip())
return source_kind == "step"
expected = "python" if spec.source == "generated" and spec.script_path is not None else "step"
return source_kind == expected
def _artifact_step_hash_matches_spec(spec: EntrySpec, manifest: Mapping[str, object]) -> bool:
if spec.step_path is None or not spec.step_path.is_file():
return True
expected_hash = step_file_hash(spec.step_path)
return str(manifest.get("stepHash") or "").strip() == expected_hash
def _existing_topology_artifact_matches_spec_without_scene(
spec: EntrySpec,
*,
require_selector: bool = True,
) -> bool:
if spec.step_path is None or spec.kind not in {"part", "assembly"}:
return False
from cadpy.step_targets import (
ResolvedStepTarget,
StepTopologyArtifactError,
validate_step_topology_artifact,
)
try:
artifact = 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=require_selector,
)
except StepTopologyArtifactError:
return False
if not _artifact_source_kind_matches_spec(spec, artifact.manifest):
return False
if not _artifact_step_hash_matches_spec(spec, artifact.manifest):
return False
mesh = artifact.manifest.get("mesh")
if not isinstance(mesh, Mapping):
return False
selector_options = _selector_options_from_topology_manifest(spec, artifact.manifest)
if selector_options is None:
return False
return (
_mesh_values_match(
mesh,
linear_deflection=selector_options.linear_deflection,
angular_deflection=selector_options.angular_deflection,
relative=selector_options.relative,
)
and _edge_visibility_classes_match_manifest(artifact.manifest, selector_options)
)
def _existing_topology_artifact_matches_options(spec: EntrySpec, selector_options: SelectorOptions) -> bool:
if spec.step_path is None or spec.kind not in {"part", "assembly"}:
return False
from cadpy.step_targets import (
ResolvedStepTarget,
StepTopologyArtifactError,
validate_step_topology_artifact,
)
try:
artifact = 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=False,
)
except StepTopologyArtifactError:
return False
if not _artifact_source_kind_matches_spec(spec, artifact.manifest):
return False
if not _artifact_step_hash_matches_spec(spec, artifact.manifest):
return False
mesh = artifact.manifest.get("mesh")
if not isinstance(mesh, Mapping):
return False
return (
_mesh_values_match(
mesh,
linear_deflection=selector_options.linear_deflection,
angular_deflection=selector_options.angular_deflection,
relative=selector_options.relative,
)
and _edge_visibility_classes_match_manifest(artifact.manifest, selector_options)
)
def _reset_step_artifact_dir(step_path: Path) -> None:
part_glb_path(step_path).unlink(missing_ok=True)
legacy_artifact_dir = native_component_glb_dir(step_path).parent
if legacy_artifact_dir.is_dir():
shutil.rmtree(legacy_artifact_dir)
def _generate_part_outputs(
spec: EntrySpec,
*,
entries_by_step_path: dict[Path, EntrySpec],
preloaded_scene: LoadedStepScene | None = None,
require_step_file: bool = True,
force: bool = False,
logger: CliLogger | None = None,
) -> GeneratedStepResult:
logger = logger or CliLogger("cad")
if spec.kind not in {"part", "assembly"} or spec.step_path is None:
return GeneratedStepResult(spec=spec, scene=None)
if require_step_file:
_ensure_step_ready(spec.step_path)
if preloaded_scene is not None:
if preloaded_scene.step_path != spec.step_path.expanduser().resolve():
raise RuntimeError(
f"Preloaded STEP scene path {preloaded_scene.step_path} does not match {_display_path(spec.step_path)}"
)
has_mesh_sidecars = any(
path is not None
for path in (spec.stl_path, spec.three_mf_path, spec.native_glb_path)
)
if (
preloaded_scene is None
and not has_mesh_sidecars
and not force
and _existing_topology_artifact_matches_spec_without_scene(spec)
):
logger.debug(f"reused current GLB/topology: {_display_path(part_glb_path(spec.step_path))}")
return GeneratedStepResult(spec=spec, scene=None)
if preloaded_scene is not None:
scene = preloaded_scene
else:
with logger.timed(f"load STEP {spec.cad_ref}"):
scene = load_step_scene(spec.step_path)
if spec.source == "generated" and spec.script_path is not None:
_mark_scene_python_backed(
scene,
source_identity=python_source_hash(spec.script_path),
source_path=spec.script_path,
)
spec = _effective_step_spec_for_scene(spec, scene)
entries_by_step_path = {
**entries_by_step_path,
spec.step_path.resolve(): spec,
}
selector_options = _selector_options_for_part(spec, scene=scene)
if (
not has_mesh_sidecars
and not force
and _existing_topology_artifact_matches_options(spec, selector_options)
):
logger.debug(f"reused current GLB/topology: {_display_path(part_glb_path(spec.step_path))}")
return GeneratedStepResult(spec=spec, scene=scene)
glb_path = part_glb_path(spec.step_path)
previous_manifest: dict[str, object] | None = read_step_topology_manifest_from_glb(glb_path) if glb_path.exists() else None
with logger.timed(f"mesh STEP {spec.cad_ref}"):
mesh_step_scene(
scene,
linear_deflection=selector_options.linear_deflection,
angular_deflection=selector_options.angular_deflection,
relative=selector_options.relative,
)
scene_export_shape(scene)
_reset_step_artifact_dir(spec.step_path)
assembly_context = (
_AssemblyArtifactContext(spec=spec, scene=scene, entries_by_step_path=entries_by_step_path)
if spec.kind == "assembly"
else None
)
jobs: list[_ArtifactJob] = []
def export_glb(selector_bundle: SelectorBundle | None = None) -> Path:
if spec.kind == "assembly":
occurrence_colors = assembly_context.occurrence_colors() if assembly_context is not None else None
exported_glb_path = export_assembly_glb_from_scene(
spec.step_path,
scene,
linear_deflection=selector_options.linear_deflection,
angular_deflection=selector_options.angular_deflection,
color=spec.color,
occurrence_colors=occurrence_colors,
selector_bundle=selector_bundle,
include_selector_topology=selector_bundle is not None,
)
stale_components_dir = native_component_glb_dir(spec.step_path)
if stale_components_dir.is_dir():
shutil.rmtree(stale_components_dir)
return exported_glb_path
return export_part_glb_from_scene(
spec.step_path,
scene,
linear_deflection=selector_options.linear_deflection,
angular_deflection=selector_options.angular_deflection,
color=spec.color,
selector_bundle=selector_bundle,
include_selector_topology=selector_bundle is not None,
)
artifact_results: dict[str, object] = {}
if spec.stl_path is not None:
def stl_sidecar_job() -> Path:
return export_part_stl_from_scene(spec.step_path, scene, target_path=spec.stl_path)
jobs.append(_ArtifactJob("STL", stl_sidecar_job))
if spec.three_mf_path is not None:
def three_mf_sidecar_job() -> Path:
kwargs: dict[str, object] = {
"target_path": spec.three_mf_path,
"color": spec.color,
}
if assembly_context is not None:
kwargs["occurrence_colors"] = assembly_context.occurrence_colors()
return export_part_3mf_from_scene(spec.step_path, scene, **kwargs)
jobs.append(_ArtifactJob("3MF", three_mf_sidecar_job))
if spec.native_glb_path is not None:
def native_glb_sidecar_job() -> Path:
kwargs: dict[str, object] = {
"target_path": spec.native_glb_path,
"linear_deflection": selector_options.linear_deflection,
"angular_deflection": selector_options.angular_deflection,
"color": spec.color,
}
if assembly_context is not None:
kwargs["occurrence_colors"] = assembly_context.occurrence_colors()
return export_native_glb_from_scene(spec.step_path, scene, **kwargs)
jobs.append(_ArtifactJob("native GLB", native_glb_sidecar_job))
def export_glb_with_topology() -> SelectorBundle:
occurrence_colors = assembly_context.occurrence_colors() if assembly_context is not None else {}
bundle = extract_selectors_from_scene(
scene,
cad_ref=spec.cad_ref,
profile=SelectorProfile.ARTIFACT,
options=selector_options,
color=spec.color,
occurrence_colors=occurrence_colors,
)
assembly_composition: dict[str, object] | None = None
if assembly_context is not None:
try:
assembly_composition = assembly_context.composition_for_topology(bundle.manifest)
except AssemblyCompositionError:
raise
except Exception as exc:
raise RuntimeError(f"Failed to build assembly composition for {spec.source_ref}") from exc
if assembly_composition is not None:
bundle.manifest["assembly"] = assembly_composition
next_manifest = build_step_topology_index_manifest(bundle.manifest, entry_kind=spec.kind)
export_glb(bundle)
_report_selector_manifest_change(spec, previous_manifest, next_manifest, logger=logger)
return bundle
jobs.append(_ArtifactJob("GLB/topology", export_glb_with_topology))
artifact_results.update(_run_artifact_jobs(jobs, logger=logger))
selector_bundle = next(
(result for result in artifact_results.values() if isinstance(result, SelectorBundle)),
None,
)
return GeneratedStepResult(spec=spec, scene=scene, selector_bundle=selector_bundle)
def _generate_step_outputs(
spec: EntrySpec,
*,
entries_by_step_path: dict[Path, EntrySpec],
skip_step_write: bool = False,
force: bool = False,
logger: CliLogger | None = None,
) -> GeneratedStepResult:
preloaded_scene: LoadedStepScene | None = None
has_mesh_sidecars = any(
path is not None
for path in (spec.stl_path, spec.three_mf_path, spec.native_glb_path)
)
if (
spec.source == "generated"
and skip_step_write
and not force
and not has_mesh_sidecars
and _existing_topology_artifact_matches_spec_without_scene(spec)
):
if logger is not None:
logger.debug(f"reused current GLB/topology: {_display_path(part_glb_path(spec.step_path))}")
return GeneratedStepResult(spec=spec, scene=None)
if spec.source == "generated":
preloaded_scene = run_script_generator(
spec,
"gen_step",
logger=logger,
force=force,
load_current_scene=False,
skip_step_write=skip_step_write,
)
spec = _effective_step_spec_for_scene(spec, preloaded_scene)
if spec.step_path is not None:
entries_by_step_path = {
**entries_by_step_path,
spec.step_path.resolve(): spec,
}
output_kwargs: dict[str, object] = {
"entries_by_step_path": entries_by_step_path,
"preloaded_scene": preloaded_scene,
"force": force,
}
if skip_step_write:
output_kwargs["require_step_file"] = False
if logger is not None:
output_kwargs["logger"] = logger
return _generate_part_outputs(spec, **output_kwargs)
def _generate_step_outputs_for_cli(
spec: EntrySpec,
*,
entries_by_step_path: dict[Path, EntrySpec],
logger: CliLogger,
skip_step_write: bool = False,
force: bool = False,
) -> GeneratedStepResult:
kwargs: dict[str, object] = {
"entries_by_step_path": entries_by_step_path,
}
if skip_step_write:
kwargs["skip_step_write"] = True
if force:
kwargs["force"] = True
if logger.verbose:
kwargs["logger"] = logger
return _generate_step_outputs(spec, **kwargs)
def _selected_specs_for_targets(
targets: Sequence[str],
*,
direct_step_kind: str = "part",
step_options: StepImportOptions | None = None,
expected_output_suffixes: tuple[str, ...] | None = None,
tool_name: str = "CAD",
include_output_paths: bool = False,
) -> tuple[list[EntrySpec], list[EntrySpec]] | tuple[list[EntrySpec], list[EntrySpec], list[Path | None]]:
step_options = step_options or StepImportOptions()
target_specs = (
_parse_cli_target_specs(
targets,
expected_suffixes=expected_output_suffixes,
tool_name=tool_name,
)
if expected_output_suffixes is not None
else [_CliTargetSpec(target=str(target or "").strip()) for target in targets]
)
explicit_specs: list[EntrySpec] = []
output_paths: list[Path | None] = []
unresolved_targets: list[str] = []
for target_spec in target_specs:
target_text = target_spec.target
target_path = Path(target_text)
resolved = target_path.resolve() if target_path.is_absolute() else (Path.cwd() / target_path).resolve()
source = (
source_from_path(
resolved,
step_kind=direct_step_kind,
step_options=step_options,
)
if resolved.exists()
else None
)
if source is None:
unresolved_targets.append(target_text)
continue
explicit_specs.append(_apply_step_options_to_spec(_entry_spec_from_source(source), step_options))
output_paths.append(target_spec.output_path)
if not unresolved_targets:
expanded_specs = _expand_specs_with_file_dependencies(explicit_specs)
if include_output_paths:
return expanded_specs, explicit_specs, output_paths
return expanded_specs, explicit_specs
unresolved = ", ".join(unresolved_targets)
raise FileNotFoundError(
"CAD target path not found or not a supported source file: "
f"{unresolved}. Pass a Python generator or STEP/STP file path."
)
def _expand_specs_with_file_dependencies(specs: Sequence[EntrySpec]) -> list[EntrySpec]:
expanded: list[EntrySpec] = list(specs)
seen_step_paths = {
spec.step_path.resolve()
for spec in expanded
if spec.step_path is not None
}
seen_source_refs = {spec.source_ref for spec in expanded}
queue = list(expanded)
source_cache: dict[Path, CadSource | None] = {}
discovered_sources_by_path: dict[Path, CadSource] | None = None
def source_for_path(path: Path) -> CadSource | None:
nonlocal discovered_sources_by_path
resolved = path.resolve()
if resolved in source_cache:
return source_cache[resolved]
if discovered_sources_by_path is None:
discovered_sources_by_path = _source_lookup_by_path()
source = discovered_sources_by_path.get(resolved)
if source is None:
source = source_from_path(resolved)
source_cache[resolved] = source
return source
while queue:
spec = queue.pop(0)
if spec.kind != "assembly" or spec.source_path is None:
continue
try:
assembly_spec = read_assembly_spec(spec.source_path)
except Exception:
continue
# Walk the flattened leaf view rather than top-level children. Compound
# grouping nodes have no source_path, but every flattened instance does.
for instance in assembly_spec.instances:
if instance.source_path.resolve() in seen_step_paths:
continue
source = source_for_path(instance.source_path)
if source is None:
continue
child_spec = _entry_spec_from_source(source)
if child_spec.source_ref in seen_source_refs:
continue
expanded.append(child_spec)
queue.append(child_spec)
seen_source_refs.add(child_spec.source_ref)
if child_spec.step_path is not None:
seen_step_paths.add(child_spec.step_path.resolve())
return expanded
def _source_lookup_by_path() -> dict[Path, CadSource]:
sources_by_path: dict[Path, CadSource] = {}
for source in iter_cad_sources():
candidates = [
source.source_path,
source.origin_path,
source.script_path,
source.step_path,
source.dxf_path,
source.urdf_path,
source.sdf_path,
source.stl_path,
source.three_mf_path,
source.native_glb_path,
*source.generated_paths,
]
for candidate in candidates:
if candidate is not None:
sources_by_path.setdefault(candidate.resolve(), source)
return sources_by_path
def _entries_by_step_path(specs: Sequence[EntrySpec]) -> dict[Path, EntrySpec]:
return {
spec.step_path.resolve(): spec
for spec in specs
if spec.step_path is not None
}
def _refreshed_selected_specs(selected_specs: Sequence[EntrySpec]) -> list[EntrySpec]:
refreshed: list[EntrySpec] = []
for spec in selected_specs:
if spec.source == "imported":
refreshed.append(spec)
continue
source_path = spec.script_path or spec.source_path
source = source_from_path(source_path) if source_path is not None and source_path.exists() else None
refreshed.append(_entry_spec_from_source(source) if source is not None else spec)
return refreshed
def _validate_step_target(
spec: EntrySpec,
*,
direct_step_kind: str | None,
tool_name: str,
) -> None:
if spec.step_path is None:
raise ValueError(f"{tool_name} target has no STEP path: {spec.source_ref}")
if spec.source == "generated":
metadata = spec.generator_metadata
if metadata is None or not metadata.has_gen_step:
raise ValueError(f"{tool_name} target does not define gen_step(): {spec.source_ref}")
return
if direct_step_kind is None:
raise ValueError(f"{tool_name} --kind is required for direct STEP/STP targets: {spec.source_ref}")
def _existing_direct_step_targets(targets: Sequence[str]) -> list[str]:
direct_targets: list[str] = []
for target in targets:
target_text = str(target or "").strip()
if "=" in target_text:
target_text = target_text.split("=", 1)[0].strip()
target_path = Path(target_text)
resolved = target_path.resolve() if target_path.is_absolute() else (Path.cwd() / target_path).resolve()
if resolved.exists() and resolved.suffix.lower() in STEP_SUFFIXES:
direct_targets.append(target_text)
return direct_targets
def _validate_dxf_target(spec: EntrySpec) -> None:
metadata = spec.generator_metadata
if spec.source != "generated" or spec.script_path is None or metadata is None:
raise ValueError(f"dxf expected a generated Python source target: {spec.source_ref}")
if not metadata.has_gen_dxf:
raise ValueError(f"dxf target does not define gen_dxf(): {spec.source_ref}")
if spec.dxf_path is None:
raise ValueError(f"dxf target has no configured DXF output: {spec.source_ref}")
def _generated_output_summary(spec: EntrySpec) -> str:
if spec.step_path is not None:
return f"generated {spec.kind} STEP: {_display_path(spec.step_path)}"
return f"processed: {spec.source_ref}"
def _generated_python_glb_summary(spec: EntrySpec) -> str:
if spec.step_path is not None:
return f"generated {spec.kind} GLB/topology artifact: {_display_path(part_glb_path(spec.step_path))}"
return f"processed: {spec.source_ref}"
def _generated_dxf_summary(spec: EntrySpec) -> str:
if spec.dxf_path is not None:
return f"generated DXF: {_display_path(spec.dxf_path)}"
return f"processed: {spec.source_ref}"
def _generation_outputs_for_spec(spec: EntrySpec, generator_name: str) -> tuple[GenerationOutput, ...]:
outputs: list[GenerationOutput] = []
if generator_name == "gen_step" and spec.step_path is not None:
outputs.append(GenerationOutput(spec.step_path, "step"))
outputs.append(GenerationOutput(part_glb_path(spec.step_path), "glb"))
if spec.stl_path is not None:
outputs.append(GenerationOutput(spec.stl_path, "stl"))
if spec.three_mf_path is not None:
outputs.append(GenerationOutput(spec.three_mf_path, "3mf"))
if spec.native_glb_path is not None:
outputs.append(GenerationOutput(spec.native_glb_path, "glb"))
elif generator_name == "gen_dxf" and spec.dxf_path is not None:
outputs.append(GenerationOutput(spec.dxf_path, "dxf"))
return tuple(outputs)
def _track_spec_generation(spec: EntrySpec, generator_name: str) -> contextlib.AbstractContextManager[None]:
return track_generation_run(
source_path=spec.script_path or spec.source_path,
generator=generator_name,
outputs=_generation_outputs_for_spec(spec, generator_name),
repo_root=REPO_ROOT,
)
def _run_with_spec_generation_status(
spec: EntrySpec,
generator_name: str,
action: Callable[[EntrySpec], object],
) -> object:
with _track_spec_generation(spec, generator_name):
return action(spec)
def _run_selected_specs(
selected_specs: Sequence[EntrySpec],
*,
initial_status: str = "Queued",
action_status: str = "Generating...",
done_status: str = "Generated",
action: Callable[[EntrySpec], object],
quiet: bool = False,
status_stream: object | None = None,
action_stdout: object | None = None,
logger: CliLogger | None = None,
success_message: Callable[[EntrySpec], str] | None = _generated_output_summary,
) -> list[object]:
results: list[object] = []
if quiet:
for spec in selected_specs:
with contextlib.redirect_stdout(io.StringIO()):
results.append(action(spec))
return results
if logger is not None:
for spec in selected_specs:
logger.debug(f"{action_status} {spec.source_ref}")
with logger.timed(f"{done_status.lower()} {spec.source_ref}"):
if action_stdout is None:
result = action(spec)
else:
with contextlib.redirect_stdout(action_stdout):
result = action(spec)
results.append(result)
if success_message is not None:
message_spec = result.spec if isinstance(result, GeneratedStepResult) else spec
logger.info(success_message(message_spec))
return results
status_board = InlineStatusBoard(
[spec.source_ref for spec in selected_specs],
initial_status=initial_status,
stream=status_stream,
)
for spec in selected_specs:
status_board.set(spec.source_ref, action_status)
if action_stdout is None:
result = action(spec)
else:
with contextlib.redirect_stdout(action_stdout):
result = action(spec)
results.append(result)
status_board.set(spec.source_ref, done_status)
return results
def generate_step_targets(
targets: Sequence[str],
*,
direct_step_kind: str | None = None,
step_options: StepImportOptions | None = None,
output: str | Path | None = None,
skip_step_write: bool = False,
force: bool = False,
verbose: bool = False,
) -> int:
tool_name = "scripts/step"
if direct_step_kind is not None and direct_step_kind not in {"part", "assembly"}:
raise ValueError(f"{tool_name} --kind must be 'part' or 'assembly'")
if direct_step_kind is None:
direct_targets = _existing_direct_step_targets(targets)
if direct_targets:
joined = ", ".join(direct_targets)
raise ValueError(f"{tool_name} --kind is required for direct STEP/STP targets: {joined}")
logger = CliLogger("scripts/step", verbose=verbose)
if output is not None and targets_include_output_pairs(targets):
raise ValueError(f"{tool_name} --output cannot be combined with SOURCE=OUTPUT targets")
output_path = _resolve_cli_output_path(output, expected_suffixes=(".step",), tool_name=tool_name)
all_specs, selected_specs, target_output_paths = _selected_specs_for_targets(
targets,
direct_step_kind=direct_step_kind or "part",
step_options=step_options,
expected_output_suffixes=(".step",),
tool_name=tool_name,
include_output_paths=True,
)
for spec in selected_specs:
_validate_step_target(spec, direct_step_kind=direct_step_kind, tool_name=tool_name)
selected_specs = _apply_step_output_override(
selected_specs,
output_path=output_path,
all_specs=all_specs,
tool_name=tool_name,
)
selected_specs = _apply_step_output_overrides(
selected_specs,
output_paths=target_output_paths,
all_specs=all_specs,
tool_name=tool_name,
)
if skip_step_write:
if output_path is not None or any(path is not None for path in target_output_paths):
raise ValueError(f"{tool_name} --skip-step-write cannot be combined with STEP output overrides")
invalid_specs = [
spec.source_ref
for spec in selected_specs
if spec.source != "generated" or spec.script_path is None
]
if invalid_specs:
joined = ", ".join(invalid_specs)
raise ValueError(f"{tool_name} --skip-step-write is valid only for Python gen_step() targets: {joined}")
if step_options is not None and step_options.has_metadata:
selected_specs = [_apply_step_options_to_spec(spec, step_options) for spec in selected_specs]
_validate_part_render_output_paths([*all_specs, *selected_specs])
entries_by_step_path = _entries_by_step_path([*all_specs, *selected_specs])
def generate_step(spec: EntrySpec) -> object:
return _run_with_spec_generation_status(
spec,
"gen_step",
lambda tracked_spec: _generate_step_outputs_for_cli(
tracked_spec,
entries_by_step_path=entries_by_step_path,
logger=logger,
skip_step_write=skip_step_write,
force=force,
),
)
_run_selected_specs(
selected_specs,
action=generate_step,
logger=logger,
success_message=_generated_python_glb_summary if skip_step_write else _generated_output_summary,
)
logger.total()
return 0
def generate_dxf_targets(
targets: Sequence[str],
*,
output: str | Path | None = None,
verbose: bool = False,
) -> int:
tool_name = "dxf"
logger = CliLogger("scripts/dxf", verbose=verbose)
if output is not None and targets_include_output_pairs(targets):
raise ValueError(f"{tool_name} --output cannot be combined with SOURCE=OUTPUT targets")
output_path = _resolve_cli_output_path(output, expected_suffixes=(".dxf",), tool_name=tool_name)
all_specs, selected_specs, target_output_paths = _selected_specs_for_targets(
targets,
expected_output_suffixes=(".dxf",),
tool_name=tool_name,
include_output_paths=True,
)
for spec in selected_specs:
_validate_dxf_target(spec)
selected_specs = _apply_dxf_output_override(
selected_specs,
output_path=output_path,
all_specs=all_specs,
tool_name=tool_name,
)
selected_specs = _apply_dxf_output_overrides(
selected_specs,
output_paths=target_output_paths,
all_specs=all_specs,
tool_name=tool_name,
)
_run_selected_specs(
selected_specs,
action=lambda spec: _run_with_spec_generation_status(
spec,
"gen_dxf",
lambda tracked_spec: run_script_generator(tracked_spec, "gen_dxf", logger=logger),
),
logger=logger,
success_message=_generated_dxf_summary,
)
logger.total()
return 0
def run_tool_cli(
argv: Sequence[str] | None,
*,
prog: str,
description: str,
action: Callable[..., int],
target_help: str | None = None,
output_help: str | None = None,
) -> int:
parser = argparse.ArgumentParser(prog=prog, description=description)
parser.add_argument(
"targets",
nargs="+",
help=target_help or "Explicit Python generator or STEP/STP file path to generate.",
)
if output_help is not None:
parser.add_argument("-o", "--output", metavar="PATH", help=output_help)
parser.add_argument(
"--verbose",
action="store_true",
help="Show detailed progress and timing information.",
)
args = parser.parse_args(list(argv) if argv is not None else None)
if output_help is not 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 action(args.targets, output=args.output, verbose=bool(args.verbose))
return action(args.targets, verbose=bool(args.verbose))
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="CAD generation support library.")
parser.parse_args(list(argv) if argv is not None else None)
parser.error("cadpy.generation is a library module.")
return 2
if __name__ == "__main__":
raise SystemExit(main())
scripts/packages/cadpy/src/cadpy/glb_mesh_payload.py
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
scripts/packages/cadpy/src/cadpy/glb_topology.py
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
scripts/packages/cadpy/src/cadpy/glb.py
from __future__ import annotations
import json
import os
import struct
import time
from array import array
from collections.abc import Mapping
from pathlib import Path
from typing import Any
from cadpy.glb_mesh_payload import (
CAD_TO_GLB_SCALE,
DEFAULT_MATERIAL,
ShapeGlbMeshPayload,
color_key,
normalize_rgba,
occurrence_color_for_id,
scene_glb_mesh_payload,
scene_glb_mesh_payload_key,
)
from cadpy.glb_topology import (
STEP_EDGE_BARYCENTRIC_ATTRIBUTE,
STEP_EDGE_CLASS_ATTRIBUTE,
STEP_EDGE_SURFACE_CLASS_CODES,
STEP_SURFACE_HALF_EDGE_COLUMNS,
STEP_TOPOLOGY_EXTENSION,
STEP_TOPOLOGY_SCHEMA_VERSION,
step_topology_capabilities,
)
from cadpy.render import REPO_ROOT, part_glb_path, part_native_glb_path
from cadpy.step_scene import ColorRGBA, LoadedStepScene, OccurrenceNode, SelectorBundle, occurrence_selector_id
ARRAY_BUFFER = 34962
ELEMENT_ARRAY_BUFFER = 34963
FLOAT = 5126
UNSIGNED_BYTE = 5121
UNSIGNED_INT = 5125
TRIANGLES = 4
STEP_TOPOLOGY_LEGACY_IDENTITY_KEYS = frozenset({"cadRef", "cadPath"})
GLB_MAGIC = 0x46546C67
GLB_VERSION = 2
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,
)
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_glb_from_scene(
step_path: Path,
scene: LoadedStepScene,
*,
linear_deflection: float,
angular_deflection: float,
color: tuple[float, float, float, float] | None = None,
selector_bundle: SelectorBundle | None = None,
include_selector_topology: bool = True,
) -> Path:
target_path = part_glb_path(step_path)
# The hierarchical writer handles plain part scenes too, and unlike the
# build123d GLB exporter it preserves XCAF colors and occurrence ids.
_ = (linear_deflection, angular_deflection)
return _HierarchicalGlbWriter(scene, color=color).write(
target_path,
selector_bundle=selector_bundle,
include_selector_topology=include_selector_topology,
entry_kind="part",
)
def export_assembly_glb_from_scene(
step_path: Path,
scene: LoadedStepScene,
*,
linear_deflection: float,
angular_deflection: float,
color: tuple[float, float, float, float] | None = None,
occurrence_colors: Mapping[str, ColorRGBA] | None = None,
selector_bundle: SelectorBundle | None = None,
include_selector_topology: bool = True,
) -> Path:
target_path = part_glb_path(step_path)
# The caller meshes the scene before scheduling artifact jobs. Keep the
# deflection args on this API so assembly/part exports share one contract.
_ = (linear_deflection, angular_deflection)
return _HierarchicalGlbWriter(scene, color=color, occurrence_colors=occurrence_colors).write(
target_path,
selector_bundle=selector_bundle,
include_selector_topology=include_selector_topology,
entry_kind="assembly",
)
def export_native_glb_from_scene(
step_path: Path,
scene: LoadedStepScene,
*,
target_path: Path | None = None,
linear_deflection: float,
angular_deflection: float,
color: tuple[float, float, float, float] | None = None,
occurrence_colors: Mapping[str, ColorRGBA] | None = None,
) -> Path:
target_path = target_path or part_native_glb_path(step_path)
# The caller meshes the scene before scheduling sidecar jobs. Keep the
# deflection args on this API so all mesh sidecars share one contract.
_ = (linear_deflection, angular_deflection)
return _HierarchicalGlbWriter(
scene,
color=color,
occurrence_colors=occurrence_colors,
native_y_up=True,
include_cad_extras=False,
).write(target_path)
def write_empty_glb(target_path: Path) -> Path:
json_chunk = b'{"asset":{"version":"2.0"},"scenes":[{"nodes":[]}],"scene":0,"nodes":[]}'
json_chunk += b" " * ((4 - (len(json_chunk) % 4)) % 4)
chunk_header = len(json_chunk).to_bytes(4, "little") + b"JSON"
payload = b"glTF" + (2).to_bytes(4, "little") + (12 + len(chunk_header) + len(json_chunk)).to_bytes(4, "little")
_atomic_write_bytes(target_path, payload + chunk_header + json_chunk)
return target_path
def _atomic_write_bytes(target_path: Path, payload: bytes) -> None:
target_path.parent.mkdir(parents=True, exist_ok=True)
temp_path = target_path.with_name(f"{target_path.name}.{os.getpid()}.{time.time_ns()}.tmp")
try:
temp_path.write_bytes(payload)
temp_path.replace(target_path)
finally:
temp_path.unlink(missing_ok=True)
def build_step_topology_index_manifest(
manifest: Mapping[str, Any],
*,
entry_kind: str | None = None,
) -> dict[str, Any]:
resolved_entry_kind = str(entry_kind or "").strip().lower()
assembly = manifest.get("assembly")
if not resolved_entry_kind:
resolved_entry_kind = "assembly" if isinstance(assembly, Mapping) else "part"
if resolved_entry_kind not in {"part", "assembly"}:
resolved_entry_kind = "part"
tables = manifest.get("tables") if isinstance(manifest.get("tables"), Mapping) else {}
occurrence_columns = tables.get("occurrenceColumns") if isinstance(tables, Mapping) else None
index: dict[str, Any] = {
"schemaVersion": STEP_TOPOLOGY_SCHEMA_VERSION,
"profile": "index",
"entryKind": resolved_entry_kind,
}
for key in (
"capabilities",
"sourceKind",
"sourcePath",
"sourceHash",
"generatedAt",
"stepPath",
"stepHash",
"bbox",
"stats",
"edgeRendering",
"mesh",
"assemblyMates",
):
value = manifest.get(key)
if value is not None:
index[key] = value
if isinstance(occurrence_columns, list):
index["tables"] = {"occurrenceColumns": occurrence_columns}
occurrences = manifest.get("occurrences")
if isinstance(occurrences, list):
index["occurrences"] = occurrences
if isinstance(assembly, Mapping):
index["assembly"] = assembly
index["entryKind"] = "assembly"
return index
def _pick_buffer_views(buffer_views: Mapping[str, Any], names: tuple[str, ...]) -> dict[str, Any]:
return {name: buffer_views[name] for name in names if name in buffer_views}
def build_step_surface_edge_manifest(
manifest: Mapping[str, Any],
*,
buffer_views: Mapping[str, Any],
) -> dict[str, Any]:
stats = manifest.get("stats") if isinstance(manifest.get("stats"), Mapping) else {}
edge_rendering = manifest.get("edgeRendering") if isinstance(manifest.get("edgeRendering"), Mapping) else {}
generated_class_counts = (
edge_rendering.get("generatedVisibilityClassCounts")
if isinstance(edge_rendering.get("generatedVisibilityClassCounts"), Mapping)
else {}
)
return {
"schemaVersion": STEP_TOPOLOGY_SCHEMA_VERSION,
"profile": "surface-edges",
"sourceKind": manifest.get("sourceKind", "step"),
"sourcePath": manifest.get("sourcePath"),
"sourceHash": manifest.get("sourceHash"),
"stepPath": manifest.get("stepPath"),
"stepHash": manifest.get("stepHash"),
"bbox": manifest.get("bbox"),
"classCodes": STEP_EDGE_SURFACE_CLASS_CODES,
"primitiveAttributes": {
"barycentric": STEP_EDGE_BARYCENTRIC_ATTRIBUTE,
"class": STEP_EDGE_CLASS_ATTRIBUTE,
},
"halfEdgeColumns": list(STEP_SURFACE_HALF_EDGE_COLUMNS),
"halfEdgesView": "surfaceHalfEdges",
"edgeRendering": edge_rendering,
"stats": {
"edgeCount": int(stats.get("edgeCount") or 0),
"surfaceHalfEdgeCount": int(stats.get("surfaceHalfEdgeCount") or 0),
"generatedVisibilityClassCounts": dict(generated_class_counts),
},
"buffers": {
"littleEndian": True,
"views": _pick_buffer_views(buffer_views, ("surfaceHalfEdges",)),
},
}
def _gltf_matrix_from_transform(transform: tuple[float, ...]) -> list[float]:
if len(transform) != 16:
transform = IDENTITY_TRANSFORM
return [
float(transform[0]), float(transform[4]), float(transform[8]), 0.0,
float(transform[1]), float(transform[5]), float(transform[9]), 0.0,
float(transform[2]), float(transform[6]), float(transform[10]), 0.0,
float(transform[3]) * CAD_TO_GLB_SCALE,
float(transform[7]) * CAD_TO_GLB_SCALE,
float(transform[11]) * CAD_TO_GLB_SCALE,
1.0,
]
def _matmul3(a: tuple[tuple[float, float, float], ...], b: tuple[tuple[float, float, float], ...]) -> tuple[tuple[float, float, float], ...]:
return tuple(
tuple(sum(a[row][inner] * b[inner][col] for inner in range(3)) for col in range(3))
for row in range(3)
)
def _native_y_up_vector(x: float, y: float, z: float) -> tuple[float, float, float]:
return (float(x), float(z), -float(y))
def _native_y_up_matrix_from_transform(transform: tuple[float, ...]) -> list[float]:
if len(transform) != 16:
transform = IDENTITY_TRANSFORM
rotation = (
(float(transform[0]), float(transform[1]), float(transform[2])),
(float(transform[4]), float(transform[5]), float(transform[6])),
(float(transform[8]), float(transform[9]), float(transform[10])),
)
cad_to_y_up = (
(1.0, 0.0, 0.0),
(0.0, 0.0, 1.0),
(0.0, -1.0, 0.0),
)
y_up_to_cad = (
(1.0, 0.0, 0.0),
(0.0, 0.0, -1.0),
(0.0, 1.0, 0.0),
)
converted = _matmul3(_matmul3(cad_to_y_up, rotation), y_up_to_cad)
tx, ty, tz = _native_y_up_vector(
float(transform[3]) * CAD_TO_GLB_SCALE,
float(transform[7]) * CAD_TO_GLB_SCALE,
float(transform[11]) * CAD_TO_GLB_SCALE,
)
return [
converted[0][0], converted[1][0], converted[2][0], 0.0,
converted[0][1], converted[1][1], converted[2][1], 0.0,
converted[0][2], converted[1][2], converted[2][2], 0.0,
tx, ty, tz, 1.0,
]
def _native_y_up_mesh_payload(payload: ShapeGlbMeshPayload) -> ShapeGlbMeshPayload:
if not payload.positions:
return payload
positions = array("f")
min_values = [float("inf"), float("inf"), float("inf")]
max_values = [-float("inf"), -float("inf"), -float("inf")]
for index in range(0, len(payload.positions), 3):
x, y, z = _native_y_up_vector(
payload.positions[index],
payload.positions[index + 1],
payload.positions[index + 2],
)
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)
normals = array("f")
for index in range(0, len(payload.normals), 3):
normals.extend(
_native_y_up_vector(
payload.normals[index],
payload.normals[index + 1],
payload.normals[index + 2],
)
)
return ShapeGlbMeshPayload(
positions=positions,
normals=normals,
barycentrics=array("f", payload.barycentrics),
edge_classes=array("B", payload.edge_classes),
primitives=list(payload.primitives),
minimum=min_values,
maximum=max_values,
face_runs_by_hash=dict(payload.face_runs_by_hash),
surface_half_edges_by_face_ordinal={
int(face_ordinal): list(half_edges)
for face_ordinal, half_edges in payload.surface_half_edges_by_face_ordinal.items()
},
)
def _selector_occurrence_ids_by_row(selector_bundle: SelectorBundle | None) -> dict[int, str]:
if selector_bundle is None:
return {}
manifest = selector_bundle.manifest if isinstance(selector_bundle.manifest, Mapping) else {}
columns = manifest.get("tables", {}).get("occurrenceColumns") if isinstance(manifest.get("tables"), Mapping) else None
id_column = columns.index("id") if isinstance(columns, list) and "id" in columns else 0
occurrence_ids: dict[int, str] = {}
occurrences = manifest.get("occurrences")
if not isinstance(occurrences, list):
return occurrence_ids
for row_index, row in enumerate(occurrences):
if not isinstance(row, list) or id_column >= len(row):
continue
occurrence_id = str(row[id_column] or "").strip()
if occurrence_id:
occurrence_ids[row_index] = occurrence_id
return occurrence_ids
def _surface_half_edges_by_occurrence_id(
selector_bundle: SelectorBundle | None,
) -> dict[str, list[tuple[int, int, int, int]]]:
if selector_bundle is None:
return {}
occurrence_ids = _selector_occurrence_ids_by_row(selector_bundle)
surface_half_edges = selector_bundle.buffers.get("surfaceHalfEdges")
if not surface_half_edges:
return {}
grouped: dict[str, list[tuple[int, int, int, int]]] = {}
for offset in range(0, len(surface_half_edges) - 6, 7):
occurrence_id = occurrence_ids.get(int(surface_half_edges[offset + 2]))
if not occurrence_id:
continue
class_code = int(surface_half_edges[offset + 6])
if class_code <= 0:
continue
grouped.setdefault(occurrence_id, []).append(
(
int(surface_half_edges[offset + 3]),
int(surface_half_edges[offset + 4]),
int(surface_half_edges[offset + 5]),
class_code,
)
)
return grouped
def _surface_edge_class_signature(selector_bundle: SelectorBundle | None) -> tuple[str, ...]:
edge_rendering = (
selector_bundle.manifest.get("edgeRendering")
if selector_bundle is not None and isinstance(selector_bundle.manifest, Mapping)
else None
)
visibility_classes = edge_rendering.get("visibilityClasses") if isinstance(edge_rendering, Mapping) else None
if not isinstance(visibility_classes, list):
return ()
return tuple(str(item or "").strip() for item in visibility_classes if str(item or "").strip())
def _apply_surface_edge_classes_to_payload(
payload: ShapeGlbMeshPayload,
surface_half_edges: list[tuple[int, int, int, int]],
) -> ShapeGlbMeshPayload:
if not surface_half_edges or not payload.edge_classes or len(payload.edge_classes) != len(payload.positions):
return payload
for primitive_index, triangle_index, side, class_code in surface_half_edges:
if primitive_index < 0 or primitive_index >= len(payload.primitives):
continue
if side < 0 or side > 2:
continue
indices = payload.primitives[primitive_index][1]
index_offset = triangle_index * 3
if index_offset < 0 or index_offset + 2 >= len(indices):
continue
for local_index in range(3):
vertex_index = int(indices[index_offset + local_index])
component_index = (vertex_index * 3) + side
if 0 <= component_index < len(payload.edge_classes):
payload.edge_classes[component_index] = max(int(payload.edge_classes[component_index]), int(class_code))
return payload
class _GlbBuilder:
def __init__(self) -> None:
self.json: dict[str, object] = {
"asset": {"version": "2.0", "generator": "tom-cad"},
"scene": 0,
"scenes": [{"nodes": []}],
"nodes": [],
"meshes": [],
"materials": [],
"buffers": [{"byteLength": 0}],
"bufferViews": [],
"accessors": [],
}
self.binary = bytearray()
def add_material(self, color: ColorRGBA, *, source_color: bool | None = None) -> int:
materials = self.json["materials"]
assert isinstance(materials, list)
material: dict[str, object] = {
"pbrMetallicRoughness": {
"baseColorFactor": [float(color[0]), float(color[1]), float(color[2]), float(color[3])],
"metallicFactor": 0.0,
"roughnessFactor": 0.55,
},
"doubleSided": True,
}
if source_color is not None:
material["extras"] = {"cadSourceColor": bool(source_color)}
materials.append(material)
return len(materials) - 1
def add_buffer_view(self, payload: bytes, *, target: int | None = None) -> int:
while len(self.binary) % 4:
self.binary.append(0)
offset = len(self.binary)
self.binary.extend(payload)
while len(self.binary) % 4:
self.binary.append(0)
buffer_views = self.json["bufferViews"]
assert isinstance(buffer_views, list)
view: dict[str, object] = {
"buffer": 0,
"byteOffset": offset,
"byteLength": len(payload),
}
if target is not None:
view["target"] = target
buffer_views.append(view)
return len(buffer_views) - 1
def add_accessor(
self,
values: array,
*,
component_type: int,
accessor_type: str,
target: int,
count: int,
minimum: list[float] | None = None,
maximum: list[float] | None = None,
) -> int:
buffer_view = self.add_buffer_view(values.tobytes(), target=target)
accessor: dict[str, object] = {
"bufferView": buffer_view,
"byteOffset": 0,
"componentType": component_type,
"count": count,
"type": accessor_type,
}
if minimum is not None:
accessor["min"] = minimum
if maximum is not None:
accessor["max"] = maximum
accessors = self.json["accessors"]
assert isinstance(accessors, list)
accessors.append(accessor)
return len(accessors) - 1
def add_mesh(
self,
positions: array,
normals: array,
primitives: list[tuple[array, int]],
*,
minimum: list[float],
maximum: list[float],
name: str,
barycentrics: array | None = None,
edge_classes: array | None = None,
) -> int | None:
vertex_count = len(positions) // 3
if vertex_count <= 0:
return None
position_accessor = self.add_accessor(
positions,
component_type=FLOAT,
accessor_type="VEC3",
target=ARRAY_BUFFER,
count=vertex_count,
minimum=minimum,
maximum=maximum,
)
normal_accessor = None
if len(normals) == len(positions):
normal_accessor = self.add_accessor(
normals,
component_type=FLOAT,
accessor_type="VEC3",
target=ARRAY_BUFFER,
count=vertex_count,
)
barycentric_accessor = None
if barycentrics is not None and len(barycentrics) == len(positions):
barycentric_accessor = self.add_accessor(
barycentrics,
component_type=FLOAT,
accessor_type="VEC3",
target=ARRAY_BUFFER,
count=vertex_count,
)
edge_class_accessor = None
if edge_classes is not None and len(edge_classes) == len(positions):
edge_class_accessor = self.add_accessor(
edge_classes,
component_type=UNSIGNED_BYTE,
accessor_type="VEC3",
target=ARRAY_BUFFER,
count=vertex_count,
)
mesh_primitives = []
for indices, material in primitives:
if not indices:
continue
index_accessor = self.add_accessor(
indices,
component_type=UNSIGNED_INT,
accessor_type="SCALAR",
target=ELEMENT_ARRAY_BUFFER,
count=len(indices),
)
mesh_primitives.append(
{
"attributes": {
"POSITION": position_accessor,
**({"NORMAL": normal_accessor} if normal_accessor is not None else {}),
**(
{STEP_EDGE_BARYCENTRIC_ATTRIBUTE: barycentric_accessor}
if barycentric_accessor is not None
else {}
),
**(
{STEP_EDGE_CLASS_ATTRIBUTE: edge_class_accessor}
if edge_class_accessor is not None
else {}
),
},
"indices": index_accessor,
"material": material,
"mode": TRIANGLES,
}
)
if not mesh_primitives:
return None
meshes = self.json["meshes"]
assert isinstance(meshes, list)
meshes.append(
{
"name": name,
"primitives": mesh_primitives,
}
)
return len(meshes) - 1
def add_node(self, node: dict[str, object]) -> int:
nodes = self.json["nodes"]
assert isinstance(nodes, list)
nodes.append(node)
return len(nodes) - 1
def set_scene_nodes(self, node_indices: list[int]) -> None:
scenes = self.json["scenes"]
assert isinstance(scenes, list)
scene = scenes[0]
assert isinstance(scene, dict)
scene["nodes"] = node_indices
def add_step_topology(
self,
bundle: SelectorBundle,
*,
include_selector_topology: bool = True,
entry_kind: str | None = None,
) -> None:
selector_manifest = dict(bundle.manifest)
selector_manifest["schemaVersion"] = STEP_TOPOLOGY_SCHEMA_VERSION
selector_manifest["profile"] = "selector"
selector_manifest.pop("assembly", None)
selector_manifest.pop("buffers", None)
for key in STEP_TOPOLOGY_LEGACY_IDENTITY_KEYS:
selector_manifest.pop(key, None)
index_manifest = build_step_topology_index_manifest(bundle.manifest, entry_kind=entry_kind)
selector_manifest["entryKind"] = index_manifest["entryKind"]
index_payload = json.dumps(index_manifest, separators=(",", ":")).encode("utf-8")
index_view = self.add_buffer_view(index_payload)
buffer_views: dict[str, object] = {}
if bundle.buffers:
for name, values in bundle.buffers.items():
buffer_views[name] = {
"dtype": "float32" if values.typecode == "f" else "uint32",
"bufferView": self.add_buffer_view(values.tobytes()),
"byteOffset": 0,
"byteLength": len(values) * values.itemsize,
"count": len(values),
"itemSize": values.itemsize,
}
edge_manifest = build_step_surface_edge_manifest(bundle.manifest, buffer_views=buffer_views)
edge_payload = json.dumps(edge_manifest, separators=(",", ":")).encode("utf-8")
edge_view = self.add_buffer_view(edge_payload)
selector_view: int | None = None
if include_selector_topology:
if bundle.buffers:
selector_manifest["buffers"] = {
"littleEndian": True,
"views": buffer_views,
}
selector_payload = json.dumps(selector_manifest, separators=(",", ":")).encode("utf-8")
selector_view = self.add_buffer_view(selector_payload)
extensions_used = self.json.setdefault("extensionsUsed", [])
assert isinstance(extensions_used, list)
if STEP_TOPOLOGY_EXTENSION not in extensions_used:
extensions_used.append(STEP_TOPOLOGY_EXTENSION)
extensions = self.json.setdefault("extensions", {})
assert isinstance(extensions, dict)
extension: dict[str, object] = {
"schemaVersion": STEP_TOPOLOGY_SCHEMA_VERSION,
"entryKind": index_manifest.get("entryKind", "part"),
"indexView": index_view,
"edgeView": edge_view,
"encoding": "utf-8",
}
if isinstance(index_manifest.get("capabilities"), Mapping):
extension["capabilities"] = index_manifest["capabilities"]
else:
extension["capabilities"] = step_topology_capabilities()
if isinstance(index_manifest.get("stats"), Mapping):
extension["stats"] = index_manifest["stats"]
if selector_view is not None:
extension["selectorView"] = selector_view
extensions[STEP_TOPOLOGY_EXTENSION] = extension
bundle.manifest = selector_manifest if include_selector_topology else index_manifest
def write(self, target_path: Path) -> Path:
if not self.binary:
return write_empty_glb(target_path)
buffers = self.json["buffers"]
assert isinstance(buffers, list)
buffer = buffers[0]
assert isinstance(buffer, dict)
buffer["byteLength"] = len(self.binary)
json_chunk = json.dumps(self.json, separators=(",", ":")).encode("utf-8")
json_chunk += b" " * ((4 - (len(json_chunk) % 4)) % 4)
binary_chunk = bytes(self.binary)
binary_chunk += b"\0" * ((4 - (len(binary_chunk) % 4)) % 4)
payload_length = 12 + 8 + len(json_chunk) + 8 + len(binary_chunk)
_atomic_write_bytes(
target_path,
b"glTF"
+ struct.pack("<II", 2, payload_length)
+ struct.pack("<I4s", len(json_chunk), b"JSON")
+ json_chunk
+ struct.pack("<I4s", len(binary_chunk), b"BIN\0")
+ binary_chunk,
)
return target_path
class _HierarchicalGlbWriter:
def __init__(
self,
scene: LoadedStepScene,
*,
color: tuple[float, float, float, float] | None,
occurrence_colors: Mapping[str, ColorRGBA] | None = None,
native_y_up: bool = False,
include_cad_extras: bool = True,
) -> None:
self.scene = scene
self.color = color
self.occurrence_colors = dict(occurrence_colors or {})
self.native_y_up = native_y_up
self.include_cad_extras = include_cad_extras
self.builder = _GlbBuilder()
self.materials_by_color: dict[tuple[tuple[int, int, int, int], bool | None], int] = {}
self.meshes_by_key: dict[tuple[object, ...], int | None] = {}
self.include_surface_edges = False
self.surface_edge_class_signature: tuple[str, ...] = ()
self.surface_half_edges_by_occurrence_id: dict[str, list[tuple[int, int, int, int]]] = {}
def write(
self,
target_path: Path,
*,
selector_bundle: SelectorBundle | None = None,
include_selector_topology: bool = True,
entry_kind: str | None = None,
) -> Path:
self.include_surface_edges = selector_bundle is not None and not self.native_y_up
self.surface_edge_class_signature = _surface_edge_class_signature(selector_bundle)
self.surface_half_edges_by_occurrence_id = _surface_half_edges_by_occurrence_id(selector_bundle)
root_nodes = [self._node_index(root) for root in self.scene.roots]
self.builder.set_scene_nodes(root_nodes)
if selector_bundle is not None:
self.builder.add_step_topology(
selector_bundle,
include_selector_topology=include_selector_topology,
entry_kind=entry_kind,
)
return self.builder.write(target_path)
def _occurrence_color_for_node(self, node: OccurrenceNode) -> ColorRGBA | None:
occurrence_id = occurrence_selector_id(node)
return occurrence_color_for_id(occurrence_id, self.occurrence_colors)
def _color_for_node(self, node: OccurrenceNode) -> ColorRGBA:
if self.color is not None:
return normalize_rgba(self.color)
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])
return DEFAULT_MATERIAL
def _node_default_color_is_source(self, node: OccurrenceNode) -> bool:
if self.color is not None:
return True
if self._occurrence_color_for_node(node) is not None:
return True
if node.color is not None:
return True
return node.prototype_key is not None and node.prototype_key in self.scene.prototype_colors
def _material_index(self, color: ColorRGBA, *, source_color: bool | None = None) -> int:
key = (color_key(color), source_color)
material = self.materials_by_color.get(key)
if material is None:
material = self.builder.add_material(color, source_color=source_color)
self.materials_by_color[key] = material
return material
def _mesh_index(self, node: OccurrenceNode) -> int | None:
if node.prototype_key is None:
return None
color = self._color_for_node(node)
occurrence_color = self._occurrence_color_for_node(node)
suppress_face_colors = self.color is not None or occurrence_color is not None
key = scene_glb_mesh_payload_key(
self.scene,
node.prototype_key,
default_color=color,
suppress_face_colors=suppress_face_colors,
include_surface_edges=self.include_surface_edges,
surface_edge_class_signature=self.surface_edge_class_signature,
)
if key in self.meshes_by_key:
return self.meshes_by_key[key]
payload = scene_glb_mesh_payload(
self.scene,
node.prototype_key,
default_color=color,
suppress_face_colors=suppress_face_colors,
include_surface_edges=self.include_surface_edges,
surface_edge_class_signature=self.surface_edge_class_signature,
)
if self.include_surface_edges:
payload = _apply_surface_edge_classes_to_payload(
payload,
self.surface_half_edges_by_occurrence_id.get(occurrence_selector_id(node), []),
)
if self.native_y_up:
payload = _native_y_up_mesh_payload(payload)
face_colors = (
{}
if suppress_face_colors
else getattr(self.scene, "prototype_face_colors", {}).get(node.prototype_key, {})
)
source_face_color_keys = {color_key(normalize_rgba(color)) for color in face_colors.values()}
default_color_is_source = self._node_default_color_is_source(node)
mesh = self.builder.add_mesh(
payload.positions,
payload.normals,
[
(
indices,
self._material_index(
primitive_color,
source_color=default_color_is_source or color_key(normalize_rgba(primitive_color)) in source_face_color_keys,
),
)
for primitive_color, indices in payload.primitives
],
minimum=payload.minimum,
maximum=payload.maximum,
name=self.scene.prototype_names.get(node.prototype_key) or occurrence_selector_id(node),
barycentrics=payload.barycentrics,
edge_classes=payload.edge_classes,
)
self.meshes_by_key[key] = mesh
return mesh
def _node_index(self, occurrence: OccurrenceNode) -> int:
occurrence_id = occurrence_selector_id(occurrence)
native_name = " ".join(str(occurrence.name or occurrence.source_name or occurrence_id).split()) or occurrence_id
node: dict[str, object] = {
"name": (
occurrence_id
if self.include_cad_extras
else native_name
),
}
if self.include_cad_extras:
node["extras"] = {
"cadOccurrenceId": occurrence_id,
"cadName": occurrence.name or occurrence.source_name or "",
}
matrix_for_transform = _native_y_up_matrix_from_transform if self.native_y_up else _gltf_matrix_from_transform
matrix = matrix_for_transform(occurrence.local_transform)
if matrix != matrix_for_transform(IDENTITY_TRANSFORM):
node["matrix"] = matrix
mesh = self._mesh_index(occurrence)
if mesh is not None:
node["mesh"] = mesh
children = [self._node_index(child) for child in occurrence.children]
if children:
node["children"] = children
return self.builder.add_node(node)
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 _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)
scripts/packages/cadpy/src/cadpy/lookup.py
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")],
}
scripts/packages/cadpy/src/cadpy/metadata.py
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()
scripts/packages/cadpy/src/cadpy/py.typed
scripts/packages/cadpy/src/cadpy/render.py
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()
scripts/packages/cadpy/src/cadpy/reporting.py
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
scripts/packages/cadpy/src/cadpy/selector_types.py
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)
scripts/packages/cadpy/src/cadpy/source_hash.py
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()
scripts/packages/cadpy/src/cadpy/step_artifact.py
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())
scripts/packages/cadpy/src/cadpy/step_artifacts.py
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
scripts/packages/cadpy/src/cadpy/step_export.py
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)
scripts/packages/cadpy/src/cadpy/step_hash.py
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()
scripts/packages/cadpy/src/cadpy/step_metadata.py
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"))
scripts/packages/cadpy/src/cadpy/step_scene.py
from __future__ import annotations
from datetime import datetime, timezone
import json
import math
import os
import shutil
import tempfile
import time
from array import array
from dataclasses import dataclass, field
from functools import lru_cache
from pathlib import Path
from typing import Any
from OCP.BinXCAFDrivers import BinXCAFDrivers
from OCP.Bnd import Bnd_Box
from OCP.BRep import BRep_Builder, BRep_Tool
from OCP.BRepAdaptor import BRepAdaptor_Curve, BRepAdaptor_Curve2d, BRepAdaptor_Surface
from OCP.BRepBndLib import BRepBndLib
from OCP.BRepGProp import BRepGProp
from OCP.BRepLProp import BRepLProp_SLProps
from OCP.BRepMesh import BRepMesh_IncrementalMesh
from OCP.GCPnts import GCPnts_QuasiUniformDeflection
from OCP.GProp import GProp_GProps
from OCP.IFSelect import IFSelect_RetDone
from OCP.STEPCAFControl import STEPCAFControl_Reader
from OCP.STEPControl import STEPControl_Reader
from OCP.TCollection import TCollection_ExtendedString
from OCP.TDataStd import TDataStd_Name
from OCP.Quantity import Quantity_ColorRGBA
from OCP.TDF import TDF_ChildIterator, TDF_Label, TDF_LabelSequence
from OCP.TDocStd import TDocStd_Document
from OCP.TopAbs import (
TopAbs_EDGE,
TopAbs_FACE,
TopAbs_REVERSED,
TopAbs_SHELL,
TopAbs_SOLID,
TopAbs_VERTEX,
)
from OCP.TopExp import TopExp, TopExp_Explorer
from OCP.TopLoc import TopLoc_Location
from OCP.TopTools import TopTools_FormatVersion, TopTools_IndexedMapOfShape
from OCP.TopoDS import TopoDS, TopoDS_Compound, TopoDS_Shape
from OCP.XCAFApp import XCAFApp_Application
from OCP.XCAFDoc import (
XCAFDoc_ColorCurv,
XCAFDoc_ColorGen,
XCAFDoc_ColorSurf,
XCAFDoc_ColorTool,
XCAFDoc_DocumentTool,
XCAFDoc_ShapeTool,
)
from cadpy.glb_mesh_payload import (
DEFAULT_MATERIAL as DEFAULT_TOPOLOGY_MATERIAL,
normalize_rgba as _normalize_rgba,
occurrence_color_for_id as _occurrence_color_for_id,
scene_glb_mesh_payload,
transform_normal_from_occ as _transform_normal_from_occ,
)
from cadpy.glb_topology import (
STEP_EDGE_DEFAULT_RENDER_VISIBILITY_CLASSES,
STEP_EDGE_FLAGS,
STEP_EDGE_VISIBILITY_CLASSES,
STEP_TOPOLOGY_EDGE_ANGULAR_TOLERANCE_DEG,
STEP_TOPOLOGY_EDGE_SAMPLE_COUNT,
STEP_TOPOLOGY_SCHEMA_VERSION,
is_displayable_step_edge_surface_class_code,
normalize_step_edge_render_visibility_classes,
step_edge_surface_class_code,
step_topology_capabilities,
)
from cadpy.metadata import DEFAULT_MESH_ANGULAR_TOLERANCE, DEFAULT_MESH_TOLERANCE, MeshSettings
from cadpy.selector_types import SelectorBundle, SelectorProfile
from cadpy.step_hash import step_file_hash
REPO_ROOT = Path.cwd().resolve()
ColorRGBA = tuple[float, float, float, float]
STEP_SCENE_CACHE_SCHEMA_VERSION = 1
@dataclass(frozen=True)
class SelectorOptions:
linear_deflection: float = DEFAULT_MESH_TOLERANCE
angular_deflection: float = DEFAULT_MESH_ANGULAR_TOLERANCE
relative: bool = True
edge_deflection: float | None = None
edge_deflection_ratio: float = 0.00075
max_edge_points: int = 96
digits: int | None = 6
mesh_resolution: dict[str, Any] | None = None
edge_visibility_classes: tuple[str, ...] = STEP_EDGE_DEFAULT_RENDER_VISIBILITY_CLASSES
@dataclass
class LoadedStepScene:
step_path: Path
roots: list["OccurrenceNode"]
prototype_shapes: dict[int, Any]
prototype_names: dict[int, str | None] = field(default_factory=dict)
prototype_colors: dict[int, ColorRGBA] = field(default_factory=dict)
prototype_face_colors: dict[int, dict[int, ColorRGBA]] = field(default_factory=dict)
load_elapsed: float = 0.0
step_hash: str | None = None
source_kind: str = "step"
source_path: str | None = None
source_hash: str | None = None
mesh_signature: tuple[float, float, bool] | None = None
glb_mesh_payloads: dict[tuple[object, ...], Any] = field(default_factory=dict)
assembly_mates: list[dict[str, Any]] = field(default_factory=list)
export_shape: Any | None = None
doc: Any | None = None
@dataclass(frozen=True)
class AdaptiveMeshResolution:
settings: MeshSettings
profile: str
hints: dict[str, Any]
@dataclass
class OccurrenceNode:
path: tuple[int, ...]
name: str | None
source_name: str | None
transform: tuple[float, ...]
prototype_key: int | None
local_transform: tuple[float, ...] = field(default_factory=lambda: _identity_transform_matrix())
color: ColorRGBA | None = None
location: object | None = None
children: list["OccurrenceNode"] = field(default_factory=list)
row_index: int = -1
@lru_cache(maxsize=512)
def _enum_name_from_text(text: str, prefix: str) -> str:
name = text.split(".")[-1]
if name.startswith(prefix):
return name[len(prefix) :].lower()
return name.lower()
def _enum_name(value: Any, prefix: str) -> str:
return _enum_name_from_text(str(value), prefix)
def _round_value(value: float, digits: int | None) -> float:
if digits is None:
return float(value)
return round(float(value), digits)
def _round_point(point: list[float] | tuple[float, float, float], digits: int | None) -> list[float]:
if digits is None:
return [float(point[0]), float(point[1]), float(point[2])]
return [round(float(point[0]), digits), round(float(point[1]), digits), round(float(point[2]), digits)]
def _round_transform(matrix: tuple[float, ...], digits: int | None) -> list[float]:
if digits is None:
return [float(value) for value in matrix]
return [round(float(value), digits) for value in matrix]
def _normalize(vector: tuple[float, float, float] | list[float]) -> list[float] | None:
x, y, z = vector
length = math.sqrt(x * x + y * y + z * z)
if length <= 1e-12:
return None
return [x / length, y / length, z / length]
def _cross(a: list[float], b: list[float], c: list[float]) -> tuple[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]
return (
aby * acz - abz * acy,
abz * acx - abx * acz,
abx * acy - aby * acx,
)
def _distance(a: list[float], b: list[float]) -> float:
dx = a[0] - b[0]
dy = a[1] - b[1]
dz = a[2] - b[2]
return math.sqrt(dx * dx + dy * dy + dz * dz)
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 _angle_between_vectors_deg(left: list[float] | tuple[float, ...] | None, right: list[float] | tuple[float, ...] | None) -> float | None:
left_normal = _normalize(left or (0.0, 0.0, 0.0))
right_normal = _normalize(right or (0.0, 0.0, 0.0))
if left_normal is None or right_normal is None:
return None
dot = max(-1.0, min(1.0, sum(left_normal[index] * right_normal[index] for index in range(3))))
return math.degrees(math.acos(dot))
def _bbox_from_points(points: list[list[float]]) -> dict[str, Any]:
if not points:
zero = [0.0, 0.0, 0.0]
return {"min": zero[:], "max": zero[:], "center": zero[:], "size": zero[:], "diag": 0.0}
min_x = max_x = points[0][0]
min_y = max_y = points[0][1]
min_z = max_z = points[0][2]
for x, y, z in points[1:]:
if x < min_x:
min_x = x
if x > max_x:
max_x = x
if y < min_y:
min_y = y
if y > max_y:
max_y = y
if z < min_z:
min_z = z
if z > max_z:
max_z = z
size = [max_x - min_x, max_y - min_y, max_z - min_z]
center = [min_x + size[0] * 0.5, min_y + size[1] * 0.5, min_z + size[2] * 0.5]
return {
"min": [min_x, min_y, min_z],
"max": [max_x, max_y, max_z],
"center": center,
"size": size,
"diag": math.sqrt(size[0] * size[0] + size[1] * size[1] + size[2] * size[2]),
}
def _merge_bbox(boxes: list[dict[str, Any]]) -> dict[str, Any]:
points: list[list[float]] = []
for box in boxes:
points.append(list(box["min"]))
points.append(list(box["max"]))
return _bbox_from_points(points)
def _compact_bbox(box: dict[str, Any], digits: int | None) -> dict[str, Any]:
return {
"min": _round_point(box["min"], digits),
"max": _round_point(box["max"], digits),
}
def _bbox_from_shape(shape: Any) -> dict[str, Any]:
box = Bnd_Box()
BRepBndLib.AddOptimal_s(shape, box, False, False)
if box.IsVoid():
return _bbox_from_points([])
min_x, min_y, min_z, max_x, max_y, max_z = box.Get()
return _bbox_from_points(
[
[min_x, min_y, min_z],
[max_x, max_y, max_z],
]
)
def _transform_point_from_occ(point: Any, location: TopLoc_Location) -> list[float]:
transformed = point.Transformed(location.Transformation())
return [transformed.X(), transformed.Y(), transformed.Z()]
def _point_from_occ(point: Any) -> list[float]:
return [point.X(), point.Y(), point.Z()]
def _apply_transform_point(transform: tuple[float, ...], point: list[float]) -> list[float]:
x, y, z = point
return [
(transform[0] * x) + (transform[1] * y) + (transform[2] * z) + transform[3],
(transform[4] * x) + (transform[5] * y) + (transform[6] * z) + transform[7],
(transform[8] * x) + (transform[9] * y) + (transform[10] * z) + transform[11],
]
def _apply_transform_vector(transform: tuple[float, ...], vector: list[float]) -> list[float] | None:
x, y, z = vector
return _normalize(
(
(transform[0] * x) + (transform[1] * y) + (transform[2] * z),
(transform[4] * x) + (transform[5] * y) + (transform[6] * z),
(transform[8] * x) + (transform[9] * y) + (transform[10] * z),
)
)
def _transform_bbox(box: dict[str, Any], transform: tuple[float, ...]) -> dict[str, Any]:
min_x, min_y, min_z = box["min"]
max_x, max_y, max_z = box["max"]
corners = [
[min_x, min_y, min_z],
[min_x, min_y, max_z],
[min_x, max_y, min_z],
[min_x, max_y, max_z],
[max_x, min_y, min_z],
[max_x, min_y, max_z],
[max_x, max_y, min_z],
[max_x, max_y, max_z],
]
return _bbox_from_points([_apply_transform_point(transform, corner) for corner in corners])
def _transform_param_dict(params: dict[str, Any], transform: tuple[float, ...], digits: int | None) -> dict[str, Any]:
point_keys = {"origin", "center", "location"}
vector_keys = {"axis", "direction", "normal"}
transformed: dict[str, Any] = {}
for key, value in params.items():
if key in point_keys and isinstance(value, list) and len(value) == 3:
transformed[key] = _round_point(_apply_transform_point(transform, value), digits)
elif key in vector_keys and isinstance(value, list) and len(value) == 3:
vector = _apply_transform_vector(transform, value)
transformed[key] = _round_point(vector or value, digits)
else:
transformed[key] = value
return transformed
def _dedupe_consecutive(points: list[list[float]], tolerance: float) -> list[list[float]]:
if not points:
return points
deduped = [points[0]]
for point in points[1:]:
if _distance(deduped[-1], point) > tolerance:
deduped.append(point)
return deduped
def _decimate_polyline(points: list[list[float]], max_points: int) -> list[list[float]]:
if max_points <= 1 or len(points) <= max_points:
return points
stride = (len(points) - 1) / float(max_points - 1)
result = []
last_index = -1
for i in range(max_points):
index = int(round(i * stride))
if index >= len(points):
index = len(points) - 1
if index != last_index:
result.append(points[index])
last_index = index
if result[-1] != points[-1]:
result[-1] = points[-1]
return result
def _polyline_length(points: list[list[float]], closed: bool) -> float:
if len(points) < 2:
return 0.0
total = 0.0
for left, right in zip(points, points[1:]):
total += _distance(left, right)
if closed and _distance(points[0], points[-1]) > 1e-9:
total += _distance(points[-1], points[0])
return total
def _polyline_center(points: list[list[float]]) -> list[float]:
if not points:
return [0.0, 0.0, 0.0]
total = [0.0, 0.0, 0.0]
for point in points:
total[0] += point[0]
total[1] += point[1]
total[2] += point[2]
inv = 1.0 / len(points)
return [total[0] * inv, total[1] * inv, total[2] * inv]
def _curve_params(adaptor: BRepAdaptor_Curve, digits: int | None) -> dict[str, Any]:
curve_type = _enum_name(adaptor.GetType(), "GeomAbs_")
params: dict[str, Any] = {}
if curve_type == "line":
line = adaptor.Line()
params["origin"] = _round_point(_point_from_occ(line.Location()), digits)
params["direction"] = _round_point(_point_from_occ(line.Direction()), digits)
elif curve_type == "circle":
circle = adaptor.Circle()
params["center"] = _round_point(_point_from_occ(circle.Location()), digits)
params["axis"] = _round_point(_point_from_occ(circle.Axis().Direction()), digits)
params["radius"] = _round_value(circle.Radius(), digits)
elif curve_type == "ellipse":
ellipse = adaptor.Ellipse()
params["center"] = _round_point(_point_from_occ(ellipse.Location()), digits)
params["axis"] = _round_point(_point_from_occ(ellipse.Axis().Direction()), digits)
params["majorRadius"] = _round_value(ellipse.MajorRadius(), digits)
params["minorRadius"] = _round_value(ellipse.MinorRadius(), digits)
elif curve_type == "hyperbola":
hyperbola = adaptor.Hyperbola()
params["center"] = _round_point(_point_from_occ(hyperbola.Location()), digits)
params["axis"] = _round_point(_point_from_occ(hyperbola.Axis().Direction()), digits)
params["majorRadius"] = _round_value(hyperbola.MajorRadius(), digits)
params["minorRadius"] = _round_value(hyperbola.MinorRadius(), digits)
elif curve_type == "parabola":
parabola = adaptor.Parabola()
params["center"] = _round_point(_point_from_occ(parabola.Location()), digits)
params["axis"] = _round_point(_point_from_occ(parabola.Axis().Direction()), digits)
params["focal"] = _round_value(parabola.Focal(), digits)
elif curve_type in {"beziercurve", "bsplinecurve"}:
params["degree"] = int(adaptor.Degree())
params["periodic"] = bool(adaptor.IsPeriodic())
params["rational"] = bool(adaptor.IsRational())
return params
def _surface_params(adaptor: BRepAdaptor_Surface, digits: int | None) -> dict[str, Any]:
surface_type = _enum_name(adaptor.GetType(), "GeomAbs_")
params: dict[str, Any] = {}
if surface_type == "plane":
plane = adaptor.Plane()
params["origin"] = _round_point(_point_from_occ(plane.Location()), digits)
params["axis"] = _round_point(_point_from_occ(plane.Axis().Direction()), digits)
elif surface_type == "cylinder":
cylinder = adaptor.Cylinder()
params["origin"] = _round_point(_point_from_occ(cylinder.Location()), digits)
params["axis"] = _round_point(_point_from_occ(cylinder.Axis().Direction()), digits)
params["radius"] = _round_value(cylinder.Radius(), digits)
elif surface_type == "cone":
cone = adaptor.Cone()
params["origin"] = _round_point(_point_from_occ(cone.Location()), digits)
params["axis"] = _round_point(_point_from_occ(cone.Axis().Direction()), digits)
params["semiAngleRad"] = _round_value(cone.SemiAngle(), digits)
elif surface_type == "sphere":
sphere = adaptor.Sphere()
params["center"] = _round_point(_point_from_occ(sphere.Location()), digits)
params["radius"] = _round_value(sphere.Radius(), digits)
elif surface_type == "torus":
torus = adaptor.Torus()
params["center"] = _round_point(_point_from_occ(torus.Location()), digits)
params["axis"] = _round_point(_point_from_occ(torus.Axis().Direction()), digits)
params["majorRadius"] = _round_value(torus.MajorRadius(), digits)
params["minorRadius"] = _round_value(torus.MinorRadius(), digits)
elif surface_type in {"beziersurface", "bsplinesurface"}:
params["uClosed"] = bool(adaptor.IsUPeriodic())
params["vClosed"] = bool(adaptor.IsVPeriodic())
return params
def _extract_face_geometry(face: Any) -> dict[str, Any]:
location = TopLoc_Location()
triangulation = BRep_Tool.Triangulation_s(face, location)
if triangulation is None:
return {
"nodes": [],
"normals": [],
"triangles": [],
"triangleCount": 0,
"area": 0.0,
"center": [0.0, 0.0, 0.0],
"normal": None,
"bbox": _bbox_from_points([]),
"triangulation": None,
"location": location,
}
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)]
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]] = []
area_sum = 0.0
centroid_sum = [0.0, 0.0, 0.0]
normal_sum = [0.0, 0.0, 0.0]
for index in range(1, triangulation.NbTriangles() + 1):
node_a, node_b, node_c = triangulation.Triangle(index).Get()
point_a = nodes[node_a - 1]
point_b = nodes[node_b - 1]
point_c = nodes[node_c - 1]
normal_x, normal_y, normal_z = _cross(point_a, point_b, point_c)
twice_area = math.sqrt((normal_x * normal_x) + (normal_y * normal_y) + (normal_z * normal_z))
# GLB export filters in meters with a 1e-15 twice-area floor. Selector
# extraction stores CAD units, so use the equivalent millimeter-scale
# threshold to keep v3 face runs aligned with GLB primitive triangles.
if twice_area <= 1e-9:
continue
area = twice_area * 0.5
centroid_sum[0] += (point_a[0] + point_b[0] + point_c[0]) * area / 3.0
centroid_sum[1] += (point_a[1] + point_b[1] + point_c[1]) * area / 3.0
centroid_sum[2] += (point_a[2] + point_b[2] + point_c[2]) * area / 3.0
normal_sum[0] += normal_x
normal_sum[1] += normal_y
normal_sum[2] += normal_z
area_sum += area
triangle = [node_a - 1, node_b - 1, node_c - 1]
if reversed_face:
triangle[1], triangle[2] = triangle[2], triangle[1]
triangles.append((triangle[0], triangle[1], triangle[2]))
if not nodes:
center = [0.0, 0.0, 0.0]
elif area_sum > 1e-12:
center = [
centroid_sum[0] / area_sum,
centroid_sum[1] / area_sum,
centroid_sum[2] / area_sum,
]
else:
center = _bbox_from_points(nodes)["center"]
normal = _normalize((normal_sum[0], normal_sum[1], normal_sum[2]))
if normal and reversed_face:
normal = [-normal[0], -normal[1], -normal[2]]
return {
"nodes": nodes,
"normals": normals,
"triangles": triangles,
"triangleCount": len(triangles),
"area": area_sum,
"center": center,
"normal": normal,
"bbox": _bbox_from_points(nodes),
"triangulation": triangulation,
"location": location,
}
def _edge_polygon_node_indices_from_face_mesh(edge: Any, face_mesh: dict[str, Any]) -> list[int]:
triangulation = face_mesh["triangulation"]
if triangulation is None:
return []
polygon = BRep_Tool.PolygonOnTriangulation_s(edge, triangulation, face_mesh["location"])
if polygon is None:
return []
return [int(polygon.Node(index)) - 1 for index in range(1, polygon.NbNodes() + 1)]
def _edge_points_from_face_polygon(face_mesh: dict[str, Any], node_indices: list[int], max_points: int) -> list[list[float]]:
points = [
face_mesh["nodes"][node_index]
for node_index in node_indices
if 0 <= node_index < len(face_mesh["nodes"])
]
points = _dedupe_consecutive(points, 1e-9)
if points and max_points > 1:
points = _decimate_polyline(points, max_points)
return points
def _extract_edge_points_from_curve(edge: Any, deflection: float, max_points: int) -> list[list[float]]:
adaptor = BRepAdaptor_Curve(edge)
curve_type = _enum_name(adaptor.GetType(), "GeomAbs_")
if curve_type == "line":
points = [
_point_from_occ(adaptor.Value(adaptor.FirstParameter())),
_point_from_occ(adaptor.Value(adaptor.LastParameter())),
]
return _dedupe_consecutive(points, max(deflection * 0.25, 1e-9))
points: list[list[float]] = []
try:
sampler = GCPnts_QuasiUniformDeflection(
adaptor,
deflection,
adaptor.FirstParameter(),
adaptor.LastParameter(),
)
if sampler.IsDone():
points = [_point_from_occ(sampler.Value(index)) for index in range(1, sampler.NbPoints() + 1)]
except Exception:
points = []
if not points:
vertex_points = []
explorer = TopExp_Explorer(edge, TopAbs_VERTEX)
while explorer.More():
vertex = TopoDS.Vertex_s(explorer.Current())
vertex_points.append(_point_from_occ(BRep_Tool.Pnt_s(vertex)))
explorer.Next()
points = vertex_points
points = _dedupe_consecutive(points, max(deflection * 0.25, 1e-9))
if points and max_points > 1:
points = _decimate_polyline(points, max_points)
return points
def _face_flags(face_data: dict[str, Any]) -> int:
return 1 if not face_data.get("referenceable", True) else 0
def _edge_flags(edge_data: dict[str, Any]) -> int:
flags = 0
if edge_data.get("closed", False):
flags |= 1
if edge_data.get("degenerated", False):
flags |= STEP_EDGE_FLAGS["DEGENERATE"]
if edge_data.get("seam", False):
flags |= STEP_EDGE_FLAGS["SEAM"]
if not edge_data.get("referenceable", True):
flags |= STEP_EDGE_FLAGS["NOT_REFERENCEABLE"]
return flags
def _is_smooth_continuity(value: object) -> bool:
return str(value or "").lower() in {"g1", "c1", "g2", "c2", "c3", "cn"}
def _edge_continuity_name(edge: Any, face_shapes: list[Any]) -> str:
if len(face_shapes) != 2:
return ""
try:
if not BRep_Tool.HasContinuity_s(edge, face_shapes[0], face_shapes[1]):
return ""
return _enum_name(BRep_Tool.Continuity_s(edge, face_shapes[0], face_shapes[1]), "GeomAbs_")
except Exception:
return ""
def _face_normal_at_edge_fraction(edge: Any, face: Any, fraction: float) -> list[float] | None:
curve = BRepAdaptor_Curve2d(edge, face)
first = float(curve.FirstParameter())
last = float(curve.LastParameter())
if not math.isfinite(first) or not math.isfinite(last) or abs(last - first) <= 1e-12:
return None
uv = curve.Value(first + ((last - first) * fraction))
surface = BRepAdaptor_Surface(face, True)
props = BRepLProp_SLProps(surface, 1, 1e-6)
props.SetParameters(float(uv.X()), float(uv.Y()))
if not props.IsNormalDefined():
return None
normal = _point_from_occ(props.Normal())
if face.Orientation() == TopAbs_REVERSED:
normal = [-normal[0], -normal[1], -normal[2]]
return _normalize(normal)
def _sampled_edge_dihedral_deg(edge: Any, face_shapes: list[Any], fallback_normals: list[list[float] | None]) -> float | None:
if len(face_shapes) != 2:
return None
max_angle: float | None = None
denominator = STEP_TOPOLOGY_EDGE_SAMPLE_COUNT + 1
for index in range(1, STEP_TOPOLOGY_EDGE_SAMPLE_COUNT + 1):
fraction = index / denominator
try:
left_normal = _face_normal_at_edge_fraction(edge, face_shapes[0], fraction)
right_normal = _face_normal_at_edge_fraction(edge, face_shapes[1], fraction)
except Exception:
left_normal = None
right_normal = None
angle = _angle_between_vectors_deg(left_normal, right_normal)
if angle is not None and math.isfinite(angle):
max_angle = angle if max_angle is None else max(max_angle, angle)
if max_angle is not None:
return max_angle
return _angle_between_vectors_deg(fallback_normals[0], fallback_normals[1]) if len(fallback_normals) == 2 else None
def _classify_edge(
edge_data: dict[str, Any],
*,
edge: Any,
face_shapes: list[Any],
face_normals: list[list[float] | None],
face_use_counts: dict[int, int],
) -> None:
flags = _edge_flags(edge_data)
adjacent_face_count = len(edge_data.get("faceOrdinals", ()))
continuity = ""
dihedral_deg: float | None = None
visibility_class = STEP_EDGE_VISIBILITY_CLASSES["FEATURE"]
if edge_data.get("degenerated", False) or len(edge_data.get("points", ())) < 2 or float(edge_data.get("length") or 0.0) <= 1e-9:
flags |= STEP_EDGE_FLAGS["DEGENERATE"]
visibility_class = STEP_EDGE_VISIBILITY_CLASSES["DEGENERATE"]
continuity = "degenerate"
elif edge_data.get("seam", False) or any(int(count) > 1 for count in face_use_counts.values()):
flags |= STEP_EDGE_FLAGS["SEAM"]
visibility_class = STEP_EDGE_VISIBILITY_CLASSES["SEAM"]
continuity = "seam"
elif adjacent_face_count <= 0:
flags |= STEP_EDGE_FLAGS["NOT_REFERENCEABLE"] | STEP_EDGE_FLAGS["UNKNOWN_CONTINUITY"]
continuity = "unknown"
elif adjacent_face_count == 1:
flags |= STEP_EDGE_FLAGS["BOUNDARY"]
continuity = "boundary"
elif adjacent_face_count > 2:
flags |= STEP_EDGE_FLAGS["NON_MANIFOLD"]
visibility_class = STEP_EDGE_VISIBILITY_CLASSES["NON_MANIFOLD"]
continuity = "non_manifold"
else:
continuity = _edge_continuity_name(edge, face_shapes)
if continuity == "c0":
flags |= STEP_EDGE_FLAGS["HARD"]
dihedral_deg = _angle_between_vectors_deg(face_normals[0], face_normals[1]) if len(face_normals) == 2 else None
elif _is_smooth_continuity(continuity):
flags |= STEP_EDGE_FLAGS["TANGENT"]
visibility_class = STEP_EDGE_VISIBILITY_CLASSES["TANGENT"]
dihedral_deg = _angle_between_vectors_deg(face_normals[0], face_normals[1]) if len(face_normals) == 2 else None
else:
dihedral_deg = _sampled_edge_dihedral_deg(edge, face_shapes, face_normals)
if dihedral_deg is not None:
if dihedral_deg > STEP_TOPOLOGY_EDGE_ANGULAR_TOLERANCE_DEG:
flags |= STEP_EDGE_FLAGS["HARD"]
continuity = "sampled_hard"
else:
flags |= STEP_EDGE_FLAGS["TANGENT"]
visibility_class = STEP_EDGE_VISIBILITY_CLASSES["TANGENT"]
continuity = "sampled_tangent"
else:
flags |= STEP_EDGE_FLAGS["UNKNOWN_CONTINUITY"]
visibility_class = STEP_EDGE_VISIBILITY_CLASSES["UNKNOWN"]
continuity = "unknown"
edge_data["flags"] = flags
edge_data["adjacentFaceCount"] = adjacent_face_count
edge_data["continuity"] = continuity
edge_data["dihedralDeg"] = None if dihedral_deg is None else _round_value(dihedral_deg, 3)
edge_data["visibilityClass"] = visibility_class
def _shape_hash(shape: Any) -> int:
return hash(shape)
def _shape_location(topods_shape: object) -> object | None:
location = getattr(topods_shape, "Location", None)
if not callable(location):
return None
try:
return location()
except Exception:
return None
def _compose_locations(parent_location: object | None, child_location: object | None) -> object | None:
if parent_location is None:
return child_location
if child_location is None:
return parent_location
try:
return parent_location.Multiplied(child_location)
except Exception:
return child_location
def _located_shape(topods_shape: object, location: object | None) -> object:
if location is None:
return topods_shape
located = getattr(topods_shape, "Located", None)
if not callable(located):
return topods_shape
try:
return located(location)
except Exception:
return topods_shape
def _unlocated_shape(topods_shape: object) -> object:
located = getattr(topods_shape, "Located", None)
if not callable(located):
return topods_shape
try:
return located(TopLoc_Location())
except Exception:
return topods_shape
def _identity_transform_matrix() -> 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 _location_transform_matrix(location: object | None) -> tuple[float, ...]:
if location is None:
return _identity_transform_matrix()
transformation = getattr(location, "Transformation", None)
if not callable(transformation):
return _identity_transform_matrix()
try:
trsf = transformation()
except Exception:
return _identity_transform_matrix()
rows: list[float] = []
try:
for row in range(1, 4):
rows.extend(float(trsf.Value(row, column)) for column in range(1, 5))
except Exception:
return _identity_transform_matrix()
rows.extend((0.0, 0.0, 0.0, 1.0))
return tuple(rows)
@lru_cache(maxsize=8192)
def _location_from_transform_matrix(transform: tuple[float, ...]) -> TopLoc_Location:
from OCP.gp import gp_Trsf
if len(transform) != 16:
return 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 _normalize_label_name(raw_name: object) -> str | None:
if raw_name is None:
return None
text = " ".join(str(raw_name).split())
if not text:
return None
lowered = text.lower()
if lowered.startswith("open cascade step translator"):
return None
if lowered in {"assembly", "solid", "compound", "compsolid", "shell", "face", "wire", "edge", "vertex"}:
return None
if text.isdigit():
return None
return text
def _label_name(label: object) -> str | None:
name = TDataStd_Name()
if not label.FindAttribute(TDataStd_Name.GetID_s(), name):
return None
return _normalize_label_name(name.Get().ToExtString())
def _resolve_referred_label(shape_tool: Any, label: object) -> object:
if not shape_tool.IsReference_s(label):
return label
referred = TDF_Label()
if shape_tool.GetReferredShape_s(label, referred):
return referred
return label
def _color_tuple(color: Quantity_ColorRGBA) -> ColorRGBA:
rgb = color.GetRGB()
return (
float(rgb.Red()),
float(rgb.Green()),
float(rgb.Blue()),
float(color.Alpha()),
)
def _color_from_label(color_tool: Any, label: object) -> ColorRGBA | None:
color = Quantity_ColorRGBA()
for color_type in (XCAFDoc_ColorSurf, XCAFDoc_ColorGen, XCAFDoc_ColorCurv):
try:
if XCAFDoc_ColorTool.GetColor_s(label, color_type, color):
return _color_tuple(color)
except Exception:
continue
return None
def _color_from_shape(color_tool: Any, shape: object) -> ColorRGBA | None:
if getattr(shape, "IsNull", lambda: True)():
return None
color = Quantity_ColorRGBA()
for color_type in (XCAFDoc_ColorSurf, XCAFDoc_ColorGen, XCAFDoc_ColorCurv):
try:
if color_tool.GetColor(shape, color_type, color):
return _color_tuple(color)
except Exception:
pass
try:
if color_tool.GetInstanceColor(shape, color_type, color):
return _color_tuple(color)
except Exception:
pass
return None
def _face_color_map_from_label(shape_tool: Any, color_tool: Any, label: object) -> dict[int, ColorRGBA]:
face_colors: dict[int, ColorRGBA] = {}
def collect(colored_label: object) -> None:
label_color = _color_from_label(color_tool, colored_label)
if label_color is not None:
try:
shape = shape_tool.GetShape_s(colored_label)
except Exception:
shape = None
if shape is not None and not shape.IsNull():
explorer = TopExp_Explorer(shape, TopAbs_FACE)
while explorer.More():
face_colors[_shape_hash(TopoDS.Face_s(explorer.Current()))] = label_color
explorer.Next()
iterator = TDF_ChildIterator(colored_label, False)
while iterator.More():
collect(iterator.Value())
iterator.Next()
collect(label)
return face_colors
def _xcaf_children(shape_tool: Any, label: object, resolved_label: object) -> list[object]:
children = TDF_LabelSequence()
has_children = XCAFDoc_ShapeTool.GetComponents_s(label, children, False)
if (not has_children or children.Length() <= 0) and resolved_label != label:
children = TDF_LabelSequence()
has_children = XCAFDoc_ShapeTool.GetComponents_s(resolved_label, children, False)
if not has_children or children.Length() <= 0:
return []
return [children.Value(index) for index in range(1, children.Length() + 1)]
def _load_occurrence_tree(
step_path: Path,
) -> tuple[
list[OccurrenceNode],
dict[int, Any],
dict[int, str | None],
dict[int, ColorRGBA],
dict[int, dict[int, ColorRGBA]],
Any | None,
]:
app = XCAFApp_Application.GetApplication_s()
BinXCAFDrivers.DefineFormat_s(app)
doc = TDocStd_Document(TCollection_ExtendedString("BinXCAF"))
app.NewDocument(TCollection_ExtendedString("BinXCAF"), doc)
reader = STEPCAFControl_Reader()
reader.SetColorMode(True)
reader.SetNameMode(True)
for mode_name in ("SetMatMode", "SetLayerMode", "SetSHUOMode"):
mode = getattr(reader, mode_name, None)
if callable(mode):
mode(True)
read_status = reader.ReadFile(str(step_path))
if int(read_status) != int(IFSelect_RetDone):
return (*_load_fallback_occurrence_tree(step_path), None)
if not reader.Transfer(doc):
return (*_load_fallback_occurrence_tree(step_path), None)
loaded = _load_occurrence_tree_from_xcaf_doc(step_path, doc)
if loaded is None:
return (*_load_fallback_occurrence_tree(step_path), None)
return (*loaded, doc)
def _load_occurrence_tree_from_xcaf_doc(
step_path: Path,
doc: Any,
) -> tuple[
list[OccurrenceNode],
dict[int, Any],
dict[int, str | None],
dict[int, ColorRGBA],
dict[int, dict[int, ColorRGBA]],
] | None:
shape_tool = XCAFDoc_DocumentTool.ShapeTool_s(doc.Main())
color_tool = XCAFDoc_DocumentTool.ColorTool_s(doc.Main())
free_labels = TDF_LabelSequence()
shape_tool.GetFreeShapes(free_labels)
if free_labels.Length() <= 0:
return None
prototypes: dict[int, Any] = {}
prototype_names: dict[int, str | None] = {}
prototype_colors: dict[int, ColorRGBA] = {}
prototype_face_colors: dict[int, dict[int, ColorRGBA]] = {}
def collect(label: object, *, path: tuple[int, ...], parent_location: object | None = None) -> OccurrenceNode | None:
resolved_label = _resolve_referred_label(shape_tool, label)
instance_shape = shape_tool.GetShape_s(label)
resolved_shape = shape_tool.GetShape_s(resolved_label)
base_shape = instance_shape if not instance_shape.IsNull() else resolved_shape
local_location = _shape_location(base_shape)
current_location = _compose_locations(parent_location, local_location)
children = _xcaf_children(shape_tool, label, resolved_label)
name = _label_name(label) or _label_name(resolved_label)
source_name = _label_name(resolved_label) or name
occurrence_color = (
_color_from_label(color_tool, label)
or _color_from_shape(color_tool, instance_shape)
or _color_from_label(color_tool, resolved_label)
or _color_from_shape(color_tool, resolved_shape)
)
prototype_key: int | None = None
if not children and not resolved_shape.IsNull():
prototype_shape = _unlocated_shape(resolved_shape)
prototype_key = _shape_hash(prototype_shape)
prototypes.setdefault(prototype_key, prototype_shape)
elif not children and not base_shape.IsNull():
prototype_shape = _unlocated_shape(base_shape)
prototype_key = _shape_hash(prototype_shape)
prototypes.setdefault(prototype_key, prototype_shape)
if prototype_key is not None:
prototype_names.setdefault(prototype_key, source_name or name)
prototype_color = _color_from_label(color_tool, resolved_label) or _color_from_shape(color_tool, resolved_shape)
if prototype_color is not None:
prototype_colors.setdefault(prototype_key, prototype_color)
face_colors = _face_color_map_from_label(shape_tool, color_tool, resolved_label)
if label != resolved_label:
face_colors.update(_face_color_map_from_label(shape_tool, color_tool, label))
if face_colors:
prototype_face_colors.setdefault(prototype_key, {}).update(face_colors)
child_nodes = [
child_node
for index, child in enumerate(children, start=1)
if (child_node := collect(child, path=(*path, index), parent_location=current_location)) is not None
]
if prototype_key is None and not child_nodes:
return None
return OccurrenceNode(
path=path,
name=name,
source_name=source_name,
transform=_location_transform_matrix(current_location),
prototype_key=prototype_key,
local_transform=_location_transform_matrix(local_location),
color=occurrence_color,
location=current_location,
children=child_nodes,
)
roots = [
node
for index in range(1, free_labels.Length() + 1)
if (node := collect(free_labels.Value(index), path=(index,))) is not None
]
if not roots:
return None
return roots, prototypes, prototype_names, prototype_colors, prototype_face_colors
def load_step_scene_from_xcaf_doc(
step_path: Path,
doc: Any,
*,
step_hash: str | None = None,
source_kind: str = "step",
source_hash: str | None = None,
load_elapsed: float | None = None,
) -> LoadedStepScene:
resolved_step_path = step_path.expanduser().resolve()
load_started = time.perf_counter()
loaded = _load_occurrence_tree_from_xcaf_doc(resolved_step_path, doc)
if loaded is None:
raise RuntimeError(f"XCAF document contains no STEP geometry: {resolved_step_path}")
(
roots,
prototype_shapes,
prototype_names,
prototype_colors,
prototype_face_colors,
) = loaded
return LoadedStepScene(
step_path=resolved_step_path,
roots=roots,
prototype_shapes=prototype_shapes,
prototype_names=prototype_names,
prototype_colors=prototype_colors,
prototype_face_colors=prototype_face_colors,
load_elapsed=time.perf_counter() - load_started if load_elapsed is None else load_elapsed,
step_hash=step_hash,
source_kind=source_kind,
source_hash=source_hash,
doc=doc,
)
def _load_fallback_occurrence_tree(
step_path: Path,
) -> tuple[list[OccurrenceNode], dict[int, Any], dict[int, str | None], dict[int, ColorRGBA], dict[int, dict[int, ColorRGBA]]]:
reader = STEPControl_Reader()
status = reader.ReadFile(str(step_path))
if status != IFSelect_RetDone:
raise RuntimeError(f"failed to read STEP file: {step_path}")
reader.TransferRoots()
shape = reader.OneShape()
if shape.IsNull():
raise RuntimeError(f"STEP file produced no shape: {step_path}")
prototype_key = _shape_hash(shape)
return (
[
OccurrenceNode(
path=(1,),
name=step_path.stem,
source_name=step_path.stem,
transform=_identity_transform_matrix(),
prototype_key=prototype_key,
local_transform=_identity_transform_matrix(),
location=None,
)
],
{prototype_key: shape},
{prototype_key: step_path.stem},
{},
{},
)
def load_step_scene(step_path: Path) -> LoadedStepScene:
resolved_step_path = step_path.expanduser().resolve()
if not resolved_step_path.exists():
raise FileNotFoundError(f"STEP file does not exist: {resolved_step_path}")
load_started = time.perf_counter()
(
roots,
prototype_shapes,
prototype_names,
prototype_colors,
prototype_face_colors,
doc,
) = _load_occurrence_tree(resolved_step_path)
return LoadedStepScene(
step_path=resolved_step_path,
roots=roots,
prototype_shapes=prototype_shapes,
prototype_names=prototype_names,
prototype_colors=prototype_colors,
prototype_face_colors=prototype_face_colors,
load_elapsed=time.perf_counter() - load_started,
doc=doc,
)
def _step_scene_cache_root() -> Path | None:
enabled = os.environ.get("TEXT_TO_CAD_STEP_SCENE_CACHE", "1").strip().lower()
if enabled in {"0", "false", "no", "off"}:
return None
configured = os.environ.get("TEXT_TO_CAD_STEP_SCENE_CACHE_DIR")
if configured:
return Path(configured).expanduser().resolve()
init_cwd = str(os.environ.get("INIT_CWD") or "").strip()
cache_root = Path(init_cwd).expanduser().resolve() if init_cwd else REPO_ROOT
if _path_is_skill_runtime(cache_root):
return Path(tempfile.gettempdir()).resolve() / "cadpy-step-scene-cache"
return cache_root / "tmp" / "step-scene-cache"
def _path_is_skill_runtime(path: Path) -> bool:
resolved = path.expanduser().resolve()
return any((candidate / "SKILL.md").is_file() for candidate in (resolved, *resolved.parents))
def _step_scene_cache_dir(root: Path, step_hash: str) -> Path:
return root / f"v{STEP_SCENE_CACHE_SCHEMA_VERSION}" / step_hash[:2] / step_hash
def _rgba_to_cache_value(color: ColorRGBA | None) -> list[float] | None:
return None if color is None else [float(component) for component in color]
def _rgba_from_cache_value(value: object) -> ColorRGBA | None:
if not isinstance(value, list) or len(value) < 3:
return None
rgba = [float(component) for component in value[:4]]
if len(rgba) == 3:
rgba.append(1.0)
return (rgba[0], rgba[1], rgba[2], rgba[3])
def _node_to_cache_payload(node: OccurrenceNode) -> dict[str, Any]:
return {
"path": [int(value) for value in node.path],
"name": node.name,
"sourceName": node.source_name,
"transform": [float(value) for value in node.transform],
"localTransform": [float(value) for value in node.local_transform],
"prototypeKey": node.prototype_key,
"color": _rgba_to_cache_value(node.color),
"children": [_node_to_cache_payload(child) for child in node.children],
}
def _node_from_cache_payload(payload: object) -> OccurrenceNode:
if not isinstance(payload, dict):
raise ValueError("cached occurrence node must be an object")
transform = tuple(float(value) for value in payload.get("transform", _identity_transform_matrix()))
local_transform = tuple(float(value) for value in payload.get("localTransform", _identity_transform_matrix()))
if len(transform) != 16 or len(local_transform) != 16:
raise ValueError("cached occurrence node has an invalid transform")
prototype_key = payload.get("prototypeKey")
return OccurrenceNode(
path=tuple(int(value) for value in payload.get("path", ())),
name=payload.get("name") if payload.get("name") is None else str(payload.get("name")),
source_name=payload.get("sourceName") if payload.get("sourceName") is None else str(payload.get("sourceName")),
transform=transform,
local_transform=local_transform,
prototype_key=None if prototype_key is None else int(prototype_key),
color=_rgba_from_cache_value(payload.get("color")),
location=_location_from_transform_matrix(transform),
children=[_node_from_cache_payload(child) for child in payload.get("children", [])],
)
def _face_index_color_payload(shape: object, face_colors: dict[int, ColorRGBA]) -> list[list[object]]:
if not face_colors:
return []
payload: list[list[object]] = []
explorer = TopExp_Explorer(shape, TopAbs_FACE)
face_index = 0
while explorer.More():
face_hash = _shape_hash(TopoDS.Face_s(explorer.Current()))
color = face_colors.get(face_hash)
if color is not None:
payload.append([face_index, _rgba_to_cache_value(color)])
face_index += 1
explorer.Next()
return payload
def _face_colors_from_index_payload(shape: object, payload: object) -> dict[int, ColorRGBA]:
if not isinstance(payload, list) or not payload:
return {}
colors_by_index: dict[int, ColorRGBA] = {}
for raw_item in payload:
if not isinstance(raw_item, list) or len(raw_item) != 2:
continue
color = _rgba_from_cache_value(raw_item[1])
if color is None:
continue
colors_by_index[int(raw_item[0])] = color
face_colors: dict[int, ColorRGBA] = {}
explorer = TopExp_Explorer(shape, TopAbs_FACE)
face_index = 0
while explorer.More():
color = colors_by_index.get(face_index)
if color is not None:
face_colors[_shape_hash(TopoDS.Face_s(explorer.Current()))] = color
face_index += 1
explorer.Next()
return face_colors
def _read_step_scene_cache(step_path: Path, *, step_hash: str, root: Path) -> LoadedStepScene | None:
from OCP.BRepTools import BRepTools
started = time.perf_counter()
cache_dir = _step_scene_cache_dir(root, step_hash)
meta_path = cache_dir / "scene.json"
if not meta_path.is_file():
return None
try:
metadata = json.loads(meta_path.read_text(encoding="utf-8"))
if metadata.get("schemaVersion") != STEP_SCENE_CACHE_SCHEMA_VERSION:
return None
if metadata.get("stepHash") != step_hash:
return None
prototypes = metadata.get("prototypes")
if not isinstance(prototypes, list):
return None
prototype_shapes: dict[int, Any] = {}
prototype_names: dict[int, str | None] = {}
prototype_colors: dict[int, ColorRGBA] = {}
prototype_face_colors: dict[int, dict[int, ColorRGBA]] = {}
for index, prototype in enumerate(prototypes):
if not isinstance(prototype, dict):
return None
prototype_key = int(prototype["key"])
brep_file = str(prototype.get("file") or f"prototype-{index}.brep")
if "/" in brep_file or "\\" in brep_file:
return None
brep_path = cache_dir / brep_file
if not brep_path.is_file():
return None
shape = TopoDS_Shape()
if not BRepTools.Read_s(shape, os.fspath(brep_path), BRep_Builder()) or shape.IsNull():
return None
prototype_shapes[prototype_key] = shape
name = prototype.get("name")
prototype_names[prototype_key] = None if name is None else str(name)
color = _rgba_from_cache_value(prototype.get("color"))
if color is not None:
prototype_colors[prototype_key] = color
face_colors = _face_colors_from_index_payload(shape, prototype.get("faceIndexColors"))
if face_colors:
prototype_face_colors[prototype_key] = face_colors
roots = [_node_from_cache_payload(node) for node in metadata.get("roots", [])]
if not roots or not prototype_shapes:
return None
return LoadedStepScene(
step_path=step_path,
roots=roots,
prototype_shapes=prototype_shapes,
prototype_names=prototype_names,
prototype_colors=prototype_colors,
prototype_face_colors=prototype_face_colors,
load_elapsed=time.perf_counter() - started,
step_hash=step_hash,
)
except Exception:
return None
def _write_step_scene_cache(scene: LoadedStepScene, *, step_hash: str, root: Path) -> None:
from OCP.BRepTools import BRepTools
cache_dir = _step_scene_cache_dir(root, step_hash)
if (cache_dir / "scene.json").is_file():
return
temp_dir = cache_dir.parent / f".{cache_dir.name}.{os.getpid()}.tmp"
try:
cache_dir.parent.mkdir(parents=True, exist_ok=True)
if temp_dir.exists():
shutil.rmtree(temp_dir, ignore_errors=True)
temp_dir.mkdir(parents=True, exist_ok=False)
prototypes: list[dict[str, Any]] = []
for index, (prototype_key, shape) in enumerate(scene.prototype_shapes.items()):
brep_file = f"prototype-{index}.brep"
if not BRepTools.Write_s(
shape,
os.fspath(temp_dir / brep_file),
False,
False,
TopTools_FormatVersion.TopTools_FormatVersion_VERSION_1,
):
raise RuntimeError("failed to write cached BREP prototype")
prototypes.append(
{
"key": int(prototype_key),
"file": brep_file,
"name": scene.prototype_names.get(prototype_key),
"color": _rgba_to_cache_value(scene.prototype_colors.get(prototype_key)),
"faceIndexColors": _face_index_color_payload(
shape,
scene.prototype_face_colors.get(prototype_key, {}),
),
}
)
metadata = {
"schemaVersion": STEP_SCENE_CACHE_SCHEMA_VERSION,
"stepHash": step_hash,
"sourcePath": _relative_path_from_directory(scene.step_path, temp_dir),
"roots": [_node_to_cache_payload(root_node) for root_node in scene.roots],
"prototypes": prototypes,
}
(temp_dir / "scene.json").write_text(
json.dumps(metadata, sort_keys=True, separators=(",", ":")),
encoding="utf-8",
)
try:
temp_dir.rename(cache_dir)
except FileExistsError:
shutil.rmtree(temp_dir, ignore_errors=True)
except Exception:
shutil.rmtree(temp_dir, ignore_errors=True)
def load_step_scene_cached(step_path: Path) -> LoadedStepScene:
resolved_step_path = step_path.expanduser().resolve()
if not resolved_step_path.exists():
raise FileNotFoundError(f"STEP file does not exist: {resolved_step_path}")
step_hash = _step_hash(resolved_step_path)
cache_root = _step_scene_cache_root()
if cache_root is not None:
cached = _read_step_scene_cache(resolved_step_path, step_hash=step_hash, root=cache_root)
if cached is not None:
return cached
scene = load_step_scene(resolved_step_path)
scene.step_hash = step_hash
if cache_root is not None:
_write_step_scene_cache(scene, step_hash=step_hash, root=cache_root)
return scene
def _scene_step_hash(scene: LoadedStepScene) -> str:
if scene.step_hash is None:
scene.step_hash = _step_hash(scene.step_path)
return scene.step_hash
def mesh_step_scene(
scene: LoadedStepScene,
*,
linear_deflection: float,
angular_deflection: float,
relative: bool,
) -> None:
signature = (float(linear_deflection), float(angular_deflection), bool(relative))
if scene.mesh_signature == signature:
return
for shape in scene.prototype_shapes.values():
BRepMesh_IncrementalMesh(
shape,
signature[0],
signature[2],
signature[1],
True,
)
scene.mesh_signature = signature
def _iter_leaf_occurrences(nodes: list[OccurrenceNode]) -> list[OccurrenceNode]:
leaves: list[OccurrenceNode] = []
stack = list(reversed(nodes))
while stack:
node = stack.pop()
if node.prototype_key is not None:
leaves.append(node)
if node.children:
stack.extend(reversed(node.children))
return leaves
def occurrence_selector_id(node: OccurrenceNode) -> str:
return _selector_id(node.path)
def scene_leaf_occurrences(scene: LoadedStepScene) -> list[OccurrenceNode]:
return _iter_leaf_occurrences(scene.roots)
def scene_occurrence_shape(scene: LoadedStepScene, node: OccurrenceNode) -> Any:
if node.prototype_key is None or node.prototype_key not in scene.prototype_shapes:
raise RuntimeError(f"Occurrence {occurrence_selector_id(node)} has no prototype shape")
return _located_shape(scene.prototype_shapes[node.prototype_key], node.location)
def scene_occurrence_prototype_shape(scene: LoadedStepScene, node: OccurrenceNode) -> Any:
if node.prototype_key is None or node.prototype_key not in scene.prototype_shapes:
raise RuntimeError(f"Occurrence {occurrence_selector_id(node)} has no prototype shape")
return scene.prototype_shapes[node.prototype_key]
def scene_export_shape(scene: LoadedStepScene) -> Any:
if scene.export_shape is not None:
return scene.export_shape
leaf_shapes = [
scene_occurrence_shape(scene, node)
for node in _iter_leaf_occurrences(scene.roots)
if node.prototype_key is not None and node.prototype_key in scene.prototype_shapes
]
if not leaf_shapes:
raise RuntimeError(f"No CAD geometry available for STL export: {scene.step_path}")
if len(leaf_shapes) == 1:
scene.export_shape = leaf_shapes[0]
return scene.export_shape
builder = BRep_Builder()
compound = TopoDS_Compound()
builder.MakeCompound(compound)
for shape in leaf_shapes:
builder.Add(compound, shape)
scene.export_shape = compound
return scene.export_shape
def _scene_mesh_resolution_hints(scene: LoadedStepScene) -> dict[str, Any]:
prototype_face_counts: dict[int, int] = {}
prototype_edge_counts: dict[int, int] = {}
prototype_curved_face_counts: dict[int, int] = {}
prototype_curved_edge_counts: dict[int, int] = {}
for key, shape in scene.prototype_shapes.items():
face_map = TopTools_IndexedMapOfShape()
edge_map = TopTools_IndexedMapOfShape()
TopExp.MapShapes_s(shape, TopAbs_FACE, face_map)
TopExp.MapShapes_s(shape, TopAbs_EDGE, edge_map)
prototype_face_counts[key] = int(face_map.Extent())
prototype_edge_counts[key] = int(edge_map.Extent())
curved_faces = 0
for face_index in range(1, face_map.Extent() + 1):
try:
surface = BRepAdaptor_Surface(TopoDS.Face_s(face_map.FindKey(face_index)))
if _enum_name(surface.GetType(), "GeomAbs_") != "plane":
curved_faces += 1
except Exception:
curved_faces += 1
curved_edges = 0
for edge_index in range(1, edge_map.Extent() + 1):
try:
curve = BRepAdaptor_Curve(TopoDS.Edge_s(edge_map.FindKey(edge_index)))
if _enum_name(curve.GetType(), "GeomAbs_") != "line":
curved_edges += 1
except Exception:
curved_edges += 1
prototype_curved_face_counts[key] = curved_faces
prototype_curved_edge_counts[key] = curved_edges
leaves = scene_leaf_occurrences(scene)
occurrence_face_count = sum(
prototype_face_counts.get(int(node.prototype_key), 0)
for node in leaves
if node.prototype_key is not None
)
occurrence_edge_count = sum(
prototype_edge_counts.get(int(node.prototype_key), 0)
for node in leaves
if node.prototype_key is not None
)
occurrence_curved_face_count = sum(
prototype_curved_face_counts.get(int(node.prototype_key), 0)
for node in leaves
if node.prototype_key is not None
)
occurrence_curved_edge_count = sum(
prototype_curved_edge_counts.get(int(node.prototype_key), 0)
for node in leaves
if node.prototype_key is not None
)
prototype_face_count = sum(prototype_face_counts.values())
prototype_edge_count = sum(prototype_edge_counts.values())
prototype_curved_face_count = sum(prototype_curved_face_counts.values())
prototype_curved_edge_count = sum(prototype_curved_edge_counts.values())
complexity_score = (
float(occurrence_face_count)
+ (float(occurrence_edge_count) * 0.35)
+ (float(prototype_face_count) * 0.5)
+ (float(len(leaves)) * 24.0)
)
curvature_pressure_score = (
(float(occurrence_curved_face_count) * 1.6)
+ (float(occurrence_curved_edge_count) * 0.9)
+ (float(prototype_curved_face_count) * 0.8)
+ (float(prototype_curved_edge_count) * 0.4)
)
high_complexity = occurrence_face_count >= 8000 or occurrence_edge_count >= 22000
diagonal: float | None = None
scale_factor = 1.0
if not high_complexity:
prototype_boxes = {
key: _bbox_from_shape(shape)
for key, shape in scene.prototype_shapes.items()
}
occurrence_boxes = [
_transform_bbox(prototype_boxes[int(node.prototype_key)], node.transform)
for node in leaves
if node.prototype_key is not None and int(node.prototype_key) in prototype_boxes
]
bbox = _merge_bbox(occurrence_boxes) if occurrence_boxes else _bbox_from_points([])
diagonal = float(bbox.get("diag") or 0.0)
if diagonal <= 50.0:
scale_factor = 0.65
elif diagonal <= 150.0:
scale_factor = 0.8
elif diagonal <= 500.0:
scale_factor = 1.0
elif diagonal <= 1500.0:
scale_factor = 1.18
else:
scale_factor = 1.35
return {
"bboxDiag": None if diagonal is None else round(diagonal, 3),
"prototypeFaceCount": prototype_face_count,
"prototypeEdgeCount": prototype_edge_count,
"prototypeCurvedFaceCount": prototype_curved_face_count,
"prototypeCurvedEdgeCount": prototype_curved_edge_count,
"occurrenceFaceCount": occurrence_face_count,
"occurrenceEdgeCount": occurrence_edge_count,
"occurrenceCurvedFaceCount": occurrence_curved_face_count,
"occurrenceCurvedEdgeCount": occurrence_curved_edge_count,
"leafOccurrenceCount": len(leaves),
"complexityScore": round(complexity_score, 3),
"effectiveComplexityScore": round(complexity_score * scale_factor, 3),
"curvaturePressureScore": round(curvature_pressure_score * scale_factor, 3),
}
def adaptive_mesh_resolution_from_hints(hints: dict[str, Any]) -> AdaptiveMeshResolution:
effective_score = float(hints["effectiveComplexityScore"])
curvature_pressure = float(hints["curvaturePressureScore"])
leaf_count = int(hints["leafOccurrenceCount"])
face_count = int(hints["occurrenceFaceCount"])
edge_count = int(hints["occurrenceEdgeCount"])
if face_count >= 20000 or edge_count >= 55000 or effective_score >= 45000 or curvature_pressure >= 45000:
profile = "large-topology"
settings = MeshSettings(tolerance=0.025, angular_tolerance=0.75)
elif (
face_count >= 8000
or edge_count >= 22000
or effective_score >= 28000
or curvature_pressure >= 18000
or (leaf_count >= 80 and effective_score >= 22000)
):
profile = "coarse-assembly"
settings = MeshSettings(tolerance=0.02, angular_tolerance=0.6)
elif (
face_count >= 2500
or edge_count >= 8000
or effective_score >= 6000
or curvature_pressure >= 9000
or (leaf_count >= 80 and effective_score >= 6000)
or (leaf_count >= 24 and effective_score >= 3500)
):
profile = "balanced-assembly"
settings = MeshSettings(tolerance=0.016, angular_tolerance=0.5)
elif face_count >= 800 or edge_count >= 2500 or effective_score >= 1800 or curvature_pressure >= 3500:
profile = "medium"
settings = MeshSettings(tolerance=0.014, angular_tolerance=0.45)
elif face_count >= 180 or edge_count >= 600 or effective_score >= 450 or curvature_pressure >= 900:
profile = "fine"
settings = MeshSettings(tolerance=0.008, angular_tolerance=0.3)
else:
profile = "extra-fine"
settings = MeshSettings(tolerance=0.006, angular_tolerance=0.2)
hints = dict(hints)
hints["profile"] = profile
return AdaptiveMeshResolution(settings=settings, profile=profile, hints=hints)
def adaptive_mesh_resolution_for_scene(scene: LoadedStepScene) -> AdaptiveMeshResolution:
return adaptive_mesh_resolution_from_hints(_scene_mesh_resolution_hints(scene))
def _face_ordinals_from_shape(shape: Any, face_ord_by_hash: dict[int, int]) -> list[int]:
explorer = TopExp_Explorer(shape, TopAbs_FACE)
ordinals: list[int] = []
seen: set[int] = set()
while explorer.More():
ordinal = face_ord_by_hash.get(_shape_hash(explorer.Current()))
if ordinal is not None and ordinal not in seen:
ordinals.append(ordinal)
seen.add(ordinal)
explorer.Next()
return ordinals
def _edge_ordinals_from_shape(shape: Any, edge_ord_by_hash: dict[int, int]) -> list[int]:
explorer = TopExp_Explorer(shape, TopAbs_EDGE)
ordinals: list[int] = []
seen: set[int] = set()
while explorer.More():
ordinal = edge_ord_by_hash.get(_shape_hash(explorer.Current()))
if ordinal is not None and ordinal not in seen:
ordinals.append(ordinal)
seen.add(ordinal)
explorer.Next()
return ordinals
def _prototype_shape_entries(root_shape: Any) -> tuple[str, list[dict[str, Any]], dict[int, int], dict[int, int]]:
solid_map = TopTools_IndexedMapOfShape()
shell_map = TopTools_IndexedMapOfShape()
TopExp.MapShapes_s(root_shape, TopAbs_SOLID, solid_map)
TopExp.MapShapes_s(root_shape, TopAbs_SHELL, shell_map)
entries: list[dict[str, Any]] = []
face_to_shape: dict[int, int] = {}
edge_to_shape: dict[int, int] = {}
if solid_map.Extent() > 0:
kind = "solid"
map_source = solid_map
elif shell_map.Extent() > 0:
kind = "shell"
map_source = shell_map
else:
kind = "compound"
map_source = None
if map_source is None:
entries.append({"ordinal": 1, "shape": root_shape, "kind": kind})
return kind, entries, face_to_shape, edge_to_shape
for ordinal in range(1, map_source.Extent() + 1):
entries.append({"ordinal": ordinal, "shape": map_source.FindKey(ordinal), "kind": kind})
return kind, entries, face_to_shape, edge_to_shape
def _extract_summary_prototype(root_shape: Any, options: SelectorOptions) -> dict[str, Any]:
face_map = TopTools_IndexedMapOfShape()
edge_map = TopTools_IndexedMapOfShape()
TopExp.MapShapes_s(root_shape, TopAbs_FACE, face_map)
TopExp.MapShapes_s(root_shape, TopAbs_EDGE, edge_map)
kind, shape_entries, _face_to_shape, _edge_to_shape = _prototype_shape_entries(root_shape)
return {
"kind": kind,
"bbox": _bbox_from_shape(root_shape),
"shapeCount": len(shape_entries) if shape_entries else 0,
"faceCount": face_map.Extent(),
"edgeCount": edge_map.Extent(),
}
def _extract_refs_prototype(
root_shape: Any,
options: SelectorOptions,
*,
include_buffers: bool,
already_meshed: bool,
) -> dict[str, Any]:
if not already_meshed:
BRepMesh_IncrementalMesh(
root_shape,
options.linear_deflection,
options.relative,
options.angular_deflection,
True,
)
face_map = TopTools_IndexedMapOfShape()
edge_map = TopTools_IndexedMapOfShape()
TopExp.MapShapes_s(root_shape, TopAbs_FACE, face_map)
TopExp.MapShapes_s(root_shape, TopAbs_EDGE, edge_map)
face_ord_by_hash = {_shape_hash(face_map.FindKey(index)): index for index in range(1, face_map.Extent() + 1)}
edge_ord_by_hash = {_shape_hash(edge_map.FindKey(index)): index for index in range(1, edge_map.Extent() + 1)}
kind, shape_entries, _face_to_shape, _edge_to_shape = _prototype_shape_entries(root_shape)
if not shape_entries and (face_map.Extent() > 0 or edge_map.Extent() > 0):
shape_entries = [{"ordinal": 1, "shape": root_shape, "kind": "compound"}]
shape_local_by_face: dict[int, int] = {}
shape_local_by_edge: dict[int, int] = {}
for shape_entry in shape_entries:
face_ordinals = _face_ordinals_from_shape(shape_entry["shape"], face_ord_by_hash)
edge_ordinals = _edge_ordinals_from_shape(shape_entry["shape"], edge_ord_by_hash)
shape_entry["faceOrdinals"] = face_ordinals
shape_entry["edgeOrdinals"] = edge_ordinals
for ordinal in face_ordinals:
shape_local_by_face.setdefault(ordinal, shape_entry["ordinal"])
for ordinal in edge_ordinals:
shape_local_by_edge.setdefault(ordinal, shape_entry["ordinal"])
face_edge_ordinals: dict[int, list[int]] = {}
edge_face_ordinals: dict[int, list[int]] = {}
edge_face_use_counts: dict[int, dict[int, int]] = {}
face_edge_polygon_nodes: dict[int, dict[int, list[int]]] = {}
face_boxes: dict[int, dict[str, Any]] = {}
face_meshes: dict[int, dict[str, Any]] = {}
total_face_area = 0.0
faces: list[dict[str, Any]] = []
for face_ordinal in range(1, face_map.Extent() + 1):
face = TopoDS.Face_s(face_map.FindKey(face_ordinal))
surface = BRepAdaptor_Surface(face)
geometry = _extract_face_geometry(face)
raw_edge_ordinals: list[int] = []
edge_polygons: dict[int, list[int]] = {}
edge_side_ordinals: dict[str, int] = {}
edge_explorer = TopExp_Explorer(face, TopAbs_EDGE)
while edge_explorer.More():
edge = TopoDS.Edge_s(edge_explorer.Current())
edge_ordinal = edge_ord_by_hash.get(_shape_hash(edge))
if edge_ordinal is not None:
raw_edge_ordinals.append(edge_ordinal)
use_counts = edge_face_use_counts.setdefault(edge_ordinal, {})
use_counts[face_ordinal] = use_counts.get(face_ordinal, 0) + 1
polygon_nodes = _edge_polygon_node_indices_from_face_mesh(edge, geometry)
if polygon_nodes:
edge_polygons.setdefault(edge_ordinal, polygon_nodes)
for left, right in zip(polygon_nodes, polygon_nodes[1:]):
edge_side_ordinals[_triangle_side_key(left, right)] = edge_ordinal
edge_explorer.Next()
edge_ordinals = list(dict.fromkeys(raw_edge_ordinals))
face_edge_ordinals[face_ordinal] = edge_ordinals
for edge_ordinal in edge_ordinals:
edge_face_ordinals.setdefault(edge_ordinal, []).append(face_ordinal)
face_edge_polygon_nodes[face_ordinal] = edge_polygons
face_boxes[face_ordinal] = geometry["bbox"]
face_meshes[face_ordinal] = geometry
total_face_area += geometry["area"]
face_data = {
"ordinal": face_ordinal,
"shapeOrdinal": shape_local_by_face.get(face_ordinal, 1),
"shapeHash": _shape_hash(face),
"surfaceType": _enum_name(surface.GetType(), "GeomAbs_"),
"area": geometry["area"],
"center": geometry["center"],
"normal": geometry["normal"],
"bbox": geometry["bbox"],
"edgeOrdinals": tuple(face_edge_ordinals.get(face_ordinal, [])),
"edgeSideOrdinals": edge_side_ordinals,
"triangleNodes": geometry["nodes"],
"triangleNormals": geometry["normals"],
"triangles": geometry["triangles"],
}
if not (geometry["triangleCount"] > 0 and geometry["area"] > 1e-12):
face_data["referenceable"] = False
params = _surface_params(surface, options.digits)
if params:
face_data["params"] = params
faces.append(face_data)
global_box = _merge_bbox(list(face_boxes.values())) if face_boxes else _bbox_from_shape(root_shape)
diag = max(global_box["diag"], 1e-9)
edge_deflection = options.edge_deflection if options.edge_deflection is not None else diag * options.edge_deflection_ratio
edge_deflection = max(edge_deflection, 1e-7)
total_edge_length = 0.0
edge_boxes: dict[int, dict[str, Any]] = {}
edges: list[dict[str, Any]] = []
for edge_ordinal in range(1, edge_map.Extent() + 1):
edge = TopoDS.Edge_s(edge_map.FindKey(edge_ordinal))
curve = BRepAdaptor_Curve(edge)
points: list[list[float]] = []
for face_ordinal in edge_face_ordinals.get(edge_ordinal, []):
polygon_nodes = face_edge_polygon_nodes.get(face_ordinal, {}).get(edge_ordinal, [])
points = _edge_points_from_face_polygon(face_meshes[face_ordinal], polygon_nodes, options.max_edge_points)
if points:
break
if not points:
points = _extract_edge_points_from_curve(edge, edge_deflection, options.max_edge_points)
closed = bool(BRep_Tool.IsClosed_s(edge))
length = _polyline_length(points, closed)
total_edge_length += length
bbox = _bbox_from_points(points)
edge_boxes[edge_ordinal] = bbox
seam = any(BRep_Tool.IsClosed_s(edge, TopoDS.Face_s(face_map.FindKey(face_ordinal))) for face_ordinal in edge_face_ordinals.get(edge_ordinal, []))
degenerated = bool(BRep_Tool.Degenerated_s(edge))
edge_data = {
"ordinal": edge_ordinal,
"shapeOrdinal": shape_local_by_edge.get(edge_ordinal, 1),
"curveType": _enum_name(curve.GetType(), "GeomAbs_"),
"length": length,
"center": _polyline_center(points),
"bbox": bbox,
"faceOrdinals": tuple(edge_face_ordinals.get(edge_ordinal, [])),
"points": points,
}
if closed:
edge_data["closed"] = True
if degenerated:
edge_data["degenerated"] = True
if seam:
edge_data["seam"] = True
if degenerated or len(points) < 2:
edge_data["referenceable"] = False
params = _curve_params(curve, options.digits)
if params:
edge_data["params"] = params
face_shapes = [
TopoDS.Face_s(face_map.FindKey(face_ordinal))
for face_ordinal in edge_face_ordinals.get(edge_ordinal, [])
if 1 <= face_ordinal <= face_map.Extent()
]
face_normals = [
face_meshes.get(face_ordinal, {}).get("normal")
for face_ordinal in edge_face_ordinals.get(edge_ordinal, [])
]
_classify_edge(
edge_data,
edge=edge,
face_shapes=face_shapes,
face_normals=face_normals,
face_use_counts=edge_face_use_counts.get(edge_ordinal, {}),
)
edge_data["surfaceClassCode"] = step_edge_surface_class_code(
edge_data,
enabled_visibility_classes=options.edge_visibility_classes,
)
edges.append(edge_data)
total_area = max(total_face_area, 1e-12)
total_length = max(total_edge_length, 1e-12)
size_floor = max(diag * diag * 1e-6, 1e-12)
length_floor = max(diag * 1e-5, 1e-12)
for face_data in faces:
area = float(face_data["area"])
score = 100.0 * math.sqrt(max(area, 0.0) / total_area)
if face_data["surfaceType"] in {"plane", "cylinder", "cone", "sphere", "torus"}:
score += 8.0
if area < size_floor:
score -= 45.0
if not face_data.get("referenceable", True):
score = 0.0
face_data["relevance"] = max(0, min(100, int(round(score))))
face_data["flags"] = _face_flags(face_data)
for edge_data in edges:
length = float(edge_data["length"])
score = 100.0 * math.sqrt(max(length, 0.0) / total_length)
if edge_data["curveType"] in {"line", "circle", "ellipse"}:
score += 10.0
if edge_data.get("seam", False):
score -= 30.0
if edge_data.get("degenerated", False):
score -= 80.0
if length < length_floor:
score -= 35.0
if not edge_data.get("referenceable", True):
score = 0.0
edge_data["relevance"] = max(0, min(100, int(round(score))))
for shape_entry in shape_entries:
shape = shape_entry["shape"]
face_ordinals = shape_entry.get("faceOrdinals", [])
boxes = [face_boxes[ordinal] for ordinal in face_ordinals if ordinal in face_boxes]
bbox = _merge_bbox(boxes) if boxes else _bbox_from_shape(shape)
shape_entry["bbox"] = bbox
shape_entry["area"] = sum(faces[ordinal - 1]["area"] for ordinal in face_ordinals)
if shape_entry["kind"] == "solid":
props = GProp_GProps()
BRepGProp.VolumeProperties_s(shape, props, False, False, True)
shape_entry["volume"] = props.Mass()
shape_entry["center"] = _point_from_occ(props.CentreOfMass())
else:
shape_entry["center"] = bbox["center"]
return {
"kind": kind,
"bbox": global_box,
"shapeCount": len(shape_entries),
"faceCount": len(faces),
"edgeCount": len(edges),
"shapes": shape_entries,
"faces": faces,
"edges": edges,
"includeBuffers": include_buffers,
}
def _selector_id(path: tuple[int, ...]) -> str:
return "o" + ".".join(str(segment) for segment in path)
def _relative_path_from_directory(path: Path, base_dir: Path) -> str:
return os.path.relpath(
path.expanduser().resolve(),
start=base_dir.expanduser().resolve(),
).replace(os.sep, "/")
def _artifact_relative_manifest_path(raw_path: str, artifact_dir: Path) -> str:
value = str(raw_path or "").strip().replace("\\", "/")
if not value:
return ""
path = Path(value)
if path.is_absolute():
return _relative_path_from_directory(path, artifact_dir)
artifact_candidate = (artifact_dir / path).resolve()
repo_candidate = (REPO_ROOT / path).resolve()
try:
repo_candidate.relative_to(REPO_ROOT)
except ValueError:
return value
if repo_candidate.exists() and repo_candidate != artifact_candidate:
return _relative_path_from_directory(repo_candidate, artifact_dir)
return value
def _step_hash(step_path: Path) -> str:
return step_file_hash(step_path)
def _normalize_selector_options(options: SelectorOptions | None) -> SelectorOptions:
normalized_options = options or SelectorOptions()
if normalized_options.digits is not None and normalized_options.digits < 0:
return SelectorOptions(
linear_deflection=normalized_options.linear_deflection,
angular_deflection=normalized_options.angular_deflection,
relative=normalized_options.relative,
edge_deflection=normalized_options.edge_deflection,
edge_deflection_ratio=normalized_options.edge_deflection_ratio,
max_edge_points=normalized_options.max_edge_points,
digits=None,
mesh_resolution=normalized_options.mesh_resolution,
edge_visibility_classes=normalize_step_edge_render_visibility_classes(
normalized_options.edge_visibility_classes
),
)
return SelectorOptions(
linear_deflection=normalized_options.linear_deflection,
angular_deflection=normalized_options.angular_deflection,
relative=normalized_options.relative,
edge_deflection=normalized_options.edge_deflection,
edge_deflection_ratio=normalized_options.edge_deflection_ratio,
max_edge_points=normalized_options.max_edge_points,
digits=normalized_options.digits,
mesh_resolution=normalized_options.mesh_resolution,
edge_visibility_classes=normalize_step_edge_render_visibility_classes(
normalized_options.edge_visibility_classes
),
)
def _extract_prototype(
shape: Any,
profile: SelectorProfile,
options: SelectorOptions,
*,
already_meshed: bool = False,
) -> dict[str, Any]:
if profile == SelectorProfile.SUMMARY:
return _extract_summary_prototype(shape, options)
return _extract_refs_prototype(
shape,
options,
include_buffers=(profile == SelectorProfile.ARTIFACT),
already_meshed=already_meshed,
)
def extract_selectors_from_scene(
scene: LoadedStepScene,
*,
cad_ref: str | None = None,
profile: SelectorProfile = SelectorProfile.ARTIFACT,
options: SelectorOptions | None = None,
color: ColorRGBA | tuple[float, ...] | None = None,
occurrence_colors: dict[str, ColorRGBA] | None = None,
) -> SelectorBundle:
started = time.perf_counter()
resolved_step_path = scene.step_path
# Retain the argument for existing callers, but topology artifacts are
# identified by their colocated STEP file plus stepHash, not by a stored
# repo-relative CAD target.
_ = cad_ref
normalized_options = _normalize_selector_options(options)
if profile != SelectorProfile.SUMMARY:
mesh_step_scene(
scene,
linear_deflection=normalized_options.linear_deflection,
angular_deflection=normalized_options.angular_deflection,
relative=normalized_options.relative,
)
prototype_started = time.perf_counter()
prototypes = {
key: _extract_prototype(
shape,
profile,
normalized_options,
already_meshed=(profile != SelectorProfile.SUMMARY),
)
for key, shape in scene.prototype_shapes.items()
}
prototype_elapsed = time.perf_counter() - prototype_started
load_elapsed = scene.load_elapsed
roots = scene.roots
override_color = None if color is None else _normalize_rgba(color)
normalized_occurrence_colors = {
str(key): _normalize_rgba(value)
for key, value in (occurrence_colors or {}).items()
}
occurrence_columns = [
"id",
"path",
"name",
"sourceName",
"parentId",
"transform",
"bbox",
"shapeStart",
"shapeCount",
"faceStart",
"faceCount",
"edgeStart",
"edgeCount",
]
shape_columns = [
"id",
"occurrenceId",
"ordinal",
"kind",
"name",
"sourceName",
"bbox",
"center",
"area",
"volume",
"faceStart",
"faceCount",
"edgeStart",
"edgeCount",
]
shape_face_start_column = shape_columns.index("faceStart")
shape_edge_start_column = shape_columns.index("edgeStart")
face_columns = [
"id",
"occurrenceId",
"shapeId",
"ordinal",
"surfaceType",
"area",
"center",
"normal",
"bbox",
"edgeStart",
"edgeCount",
"relevance",
"flags",
"params",
"triangleStart",
"triangleCount",
]
edge_columns = [
"id",
"occurrenceId",
"shapeId",
"ordinal",
"curveType",
"length",
"center",
"bbox",
"faceStart",
"faceCount",
"relevance",
"flags",
"params",
"segmentStart",
"segmentCount",
"adjacentFaceCount",
"continuity",
"dihedralDeg",
"visibilityClass",
"surfaceHalfEdgeStart",
"surfaceHalfEdgeCount",
]
occurrence_rows: list[list[Any]] = []
shape_rows: list[list[Any]] = []
face_rows: list[list[Any]] = []
edge_rows: list[list[Any]] = []
face_edge_rows = array("I")
edge_face_rows = array("I")
face_proxy_runs = array("I")
edge_proxy_positions = array("f")
edge_proxy_indices = array("I")
edge_proxy_ids = array("I")
surface_half_edges = array("I")
entry_bbox_boxes: list[dict[str, Any]] = []
leaf_occurrence_count = 0
summary_shape_count = 0
summary_face_count = 0
summary_edge_count = 0
unmapped_surface_edges: list[str] = []
edge_visibility_class_counts: dict[str, int] = {}
generated_edge_visibility_class_counts: dict[str, int] = {}
def append_occurrence_row(node: OccurrenceNode) -> str:
occurrence_id = _selector_id(node.path)
parent_id = _selector_id(node.path[:-1]) if len(node.path) > 1 else None
node.row_index = len(occurrence_rows)
occurrence_rows.append(
[
occurrence_id,
".".join(str(segment) for segment in node.path),
node.name,
node.source_name,
parent_id,
_round_transform(node.transform, normalized_options.digits),
None,
0,
0,
0,
0,
0,
0,
]
)
return occurrence_id
def finalize_occurrence_row(node: OccurrenceNode, bbox: dict[str, Any], ranges: dict[str, int]) -> None:
occurrence_rows[node.row_index][6] = _compact_bbox(bbox, normalized_options.digits)
occurrence_rows[node.row_index][7] = ranges["shapeStart"]
occurrence_rows[node.row_index][8] = ranges["shapeCount"]
occurrence_rows[node.row_index][9] = ranges["faceStart"]
occurrence_rows[node.row_index][10] = ranges["faceCount"]
occurrence_rows[node.row_index][11] = ranges["edgeStart"]
occurrence_rows[node.row_index][12] = ranges["edgeCount"]
def glb_default_color_for_node(node: OccurrenceNode, occurrence_id: str) -> tuple[ColorRGBA, bool]:
if override_color is not None:
return override_color, True
occurrence_color = _occurrence_color_for_id(occurrence_id, normalized_occurrence_colors)
if occurrence_color is not None:
return occurrence_color, True
if node.color is not None:
return _normalize_rgba(node.color), False
if node.prototype_key is not None and node.prototype_key in scene.prototype_colors:
return _normalize_rgba(scene.prototype_colors[node.prototype_key]), False
return DEFAULT_TOPOLOGY_MATERIAL, False
def glb_face_runs_for_node(
node: OccurrenceNode,
occurrence_id: str,
prototype: dict[str, Any],
) -> tuple[dict[int, tuple[int, int, int]], Any | None]:
if node.prototype_key is None:
return {}, None
default_color, suppress_face_colors = glb_default_color_for_node(node, occurrence_id)
payload = scene_glb_mesh_payload(
scene,
node.prototype_key,
default_color=default_color,
suppress_face_colors=suppress_face_colors,
prototype=prototype,
include_surface_edges=(profile == SelectorProfile.ARTIFACT),
surface_edge_class_signature=normalized_options.edge_visibility_classes,
)
runs: dict[int, tuple[int, int, int]] = {}
for face_entry in prototype.get("faces", []):
face_hash = int(face_entry.get("shapeHash") or 0)
runs[int(face_entry["ordinal"])] = payload.face_runs_by_hash.get(face_hash, (0, 0, 0))
return runs, payload
def emit_leaf(node: OccurrenceNode, occurrence_id: str, prototype: dict[str, Any]) -> dict[str, Any]:
nonlocal leaf_occurrence_count, summary_shape_count, summary_face_count, summary_edge_count
leaf_occurrence_count += 1
start_shape = len(shape_rows)
start_face = len(face_rows)
start_edge = len(edge_rows)
shape_count = len(prototype.get("shapes", []))
prototype_name = (
scene.prototype_names.get(node.prototype_key)
if node.prototype_key is not None
else None
)
occurrence_shape_name = node.name or node.source_name or prototype_name
def scoped_shape_name(base: str | None, ordinal: int) -> str | None:
text = str(base or "").strip()
if not text:
return None
if shape_count <= 1:
return text
return f"{text}:s{ordinal}"
if profile == SelectorProfile.SUMMARY:
summary_shape_count += int(prototype.get("shapeCount") or 0)
summary_face_count += int(prototype.get("faceCount") or 0)
summary_edge_count += int(prototype.get("edgeCount") or 0)
bbox = _transform_bbox(prototype["bbox"], node.transform)
entry_bbox_boxes.append(bbox)
return {
"bbox": bbox,
"shapeStart": 0,
"shapeCount": int(prototype.get("shapeCount") or 0),
"faceStart": 0,
"faceCount": int(prototype.get("faceCount") or 0),
"edgeStart": 0,
"edgeCount": int(prototype.get("edgeCount") or 0),
}
local_shape_index_to_global_row: dict[int, int] = {}
for shape_entry in prototype.get("shapes", []):
shape_ordinal = int(shape_entry["ordinal"])
local_shape_index_to_global_row[shape_ordinal] = len(shape_rows)
shape_rows.append(
[
f"{occurrence_id}.s{shape_ordinal}",
occurrence_id,
shape_ordinal,
shape_entry["kind"],
scoped_shape_name(occurrence_shape_name, shape_ordinal),
scoped_shape_name(prototype_name or node.source_name, shape_ordinal),
_compact_bbox(_transform_bbox(shape_entry["bbox"], node.transform), normalized_options.digits),
_round_point(_apply_transform_point(node.transform, shape_entry["center"]), normalized_options.digits),
_round_value(shape_entry.get("area", 0.0), normalized_options.digits),
None if shape_entry.get("volume") is None else _round_value(shape_entry["volume"], normalized_options.digits),
0,
len(shape_entry.get("faceOrdinals", [])),
0,
len(shape_entry.get("edgeOrdinals", [])),
]
)
local_face_index_to_global_row: dict[int, int] = {}
for face_entry in prototype.get("faces", []):
local_face_index_to_global_row[int(face_entry["ordinal"])] = len(face_rows)
edge_start = len(face_edge_rows)
face_rows.append(
[
f"{occurrence_id}.f{face_entry['ordinal']}",
occurrence_id,
f"{occurrence_id}.s{face_entry['shapeOrdinal']}",
int(face_entry["ordinal"]),
face_entry["surfaceType"],
_round_value(face_entry["area"], normalized_options.digits),
_round_point(_apply_transform_point(node.transform, face_entry["center"]), normalized_options.digits),
None
if face_entry.get("normal") is None
else _round_point(_apply_transform_vector(node.transform, face_entry["normal"]) or face_entry["normal"], normalized_options.digits),
_compact_bbox(_transform_bbox(face_entry["bbox"], node.transform), normalized_options.digits),
edge_start,
len(face_entry["edgeOrdinals"]),
int(face_entry.get("relevance", 0)),
int(face_entry.get("flags", 0)),
None
if face_entry.get("params") is None
else _transform_param_dict(face_entry["params"], node.transform, normalized_options.digits),
0,
0,
]
)
local_edge_index_to_global_row: dict[int, int] = {}
for edge_entry in prototype.get("edges", []):
local_edge_index_to_global_row[int(edge_entry["ordinal"])] = len(edge_rows)
visibility_class = str(edge_entry.get("visibilityClass") or STEP_EDGE_VISIBILITY_CLASSES["FEATURE"])
edge_visibility_class_counts[visibility_class] = edge_visibility_class_counts.get(visibility_class, 0) + 1
if int(edge_entry.get("surfaceClassCode") or 0) > 0:
generated_edge_visibility_class_counts[visibility_class] = (
generated_edge_visibility_class_counts.get(visibility_class, 0) + 1
)
face_start = len(edge_face_rows)
edge_rows.append(
[
f"{occurrence_id}.e{edge_entry['ordinal']}",
occurrence_id,
f"{occurrence_id}.s{edge_entry['shapeOrdinal']}",
int(edge_entry["ordinal"]),
edge_entry["curveType"],
_round_value(edge_entry["length"], normalized_options.digits),
_round_point(_apply_transform_point(node.transform, edge_entry["center"]), normalized_options.digits),
_compact_bbox(_transform_bbox(edge_entry["bbox"], node.transform), normalized_options.digits),
face_start,
len(edge_entry["faceOrdinals"]),
int(edge_entry.get("relevance", 0)),
int(edge_entry.get("flags", 0)),
None
if edge_entry.get("params") is None
else _transform_param_dict(edge_entry["params"], node.transform, normalized_options.digits),
0,
0,
int(edge_entry.get("adjacentFaceCount") or 0),
str(edge_entry.get("continuity") or ""),
edge_entry.get("dihedralDeg"),
visibility_class,
0,
0,
]
)
for shape_entry in prototype.get("shapes", []):
global_shape_row = local_shape_index_to_global_row[int(shape_entry["ordinal"])]
if shape_entry.get("faceOrdinals"):
first_face_global = local_face_index_to_global_row[shape_entry["faceOrdinals"][0]]
else:
first_face_global = len(face_rows)
if shape_entry.get("edgeOrdinals"):
first_edge_global = local_edge_index_to_global_row[shape_entry["edgeOrdinals"][0]]
else:
first_edge_global = len(edge_rows)
shape_rows[global_shape_row][shape_face_start_column] = first_face_global
shape_rows[global_shape_row][shape_edge_start_column] = first_edge_global
for face_entry in prototype.get("faces", []):
global_face_row = local_face_index_to_global_row[int(face_entry["ordinal"])]
edge_start = len(face_edge_rows)
face_rows[global_face_row][9] = edge_start
for edge_ordinal in face_entry["edgeOrdinals"]:
face_edge_rows.append(local_edge_index_to_global_row[int(edge_ordinal)])
for edge_entry in prototype.get("edges", []):
global_edge_row = local_edge_index_to_global_row[int(edge_entry["ordinal"])]
face_start = len(edge_face_rows)
edge_rows[global_edge_row][8] = face_start
for face_ordinal in edge_entry["faceOrdinals"]:
edge_face_rows.append(local_face_index_to_global_row[int(face_ordinal)])
if profile == SelectorProfile.ARTIFACT:
face_runs, glb_payload = glb_face_runs_for_node(node, occurrence_id, prototype)
for face_entry in prototype.get("faces", []):
global_face_row = local_face_index_to_global_row[int(face_entry["ordinal"])]
primitive_index, triangle_start, triangle_count = face_runs.get(int(face_entry["ordinal"]), (0, 0, 0))
face_rows[global_face_row][14] = triangle_start
face_rows[global_face_row][15] = triangle_count
if triangle_count > 0:
face_proxy_runs.extend([
int(node.row_index),
int(primitive_index),
int(triangle_start),
int(triangle_count),
int(global_face_row),
])
for face_ordinal, half_edges in (getattr(glb_payload, "surface_half_edges_by_face_ordinal", {}) or {}).items():
global_face_row = local_face_index_to_global_row.get(int(face_ordinal))
if not isinstance(global_face_row, int):
continue
for edge_ordinal, primitive_index, triangle_index, side, class_code in half_edges:
global_edge_row = local_edge_index_to_global_row.get(int(edge_ordinal))
if not isinstance(global_edge_row, int):
continue
current_count = int(edge_rows[global_edge_row][20] or 0)
if current_count == 0:
edge_rows[global_edge_row][19] = len(surface_half_edges) // 7
edge_rows[global_edge_row][20] = current_count + 1
surface_half_edges.extend(
[
int(global_edge_row),
int(global_face_row),
int(node.row_index),
int(primitive_index),
int(triangle_index),
int(side),
int(class_code),
]
)
unmapped_edges = []
for edge_entry in prototype.get("edges", []):
class_code = int(edge_entry.get("surfaceClassCode") or 0)
if not is_displayable_step_edge_surface_class_code(class_code):
continue
global_edge_row = local_edge_index_to_global_row.get(int(edge_entry["ordinal"]))
if isinstance(global_edge_row, int) and int(edge_rows[global_edge_row][20] or 0) <= 0:
unmapped_edges.append(f"{occurrence_id}.e{edge_entry['ordinal']}")
if unmapped_edges:
unmapped_surface_edges.extend(unmapped_edges)
for edge_entry in prototype.get("edges", []):
global_edge_row = local_edge_index_to_global_row[int(edge_entry["ordinal"])]
points = edge_entry["points"]
if len(points) < 2:
continue
vertex_offset = len(edge_proxy_positions) // 3
segment_start = len(edge_proxy_ids)
for point in points:
transformed = _apply_transform_point(node.transform, point)
edge_proxy_positions.extend(_round_point(transformed, normalized_options.digits))
for local_index in range(len(points) - 1):
edge_proxy_indices.extend([vertex_offset + local_index, vertex_offset + local_index + 1])
edge_proxy_ids.append(global_edge_row)
if edge_entry.get("closed", False) and _distance(points[0], points[-1]) > 1e-9:
edge_proxy_indices.extend([vertex_offset + len(points) - 1, vertex_offset])
edge_proxy_ids.append(global_edge_row)
edge_rows[global_edge_row][13] = segment_start
edge_rows[global_edge_row][14] = len(edge_proxy_ids) - segment_start
bbox = _transform_bbox(prototype["bbox"], node.transform)
entry_bbox_boxes.append(bbox)
return {
"bbox": bbox,
"shapeStart": start_shape,
"shapeCount": len(shape_rows) - start_shape,
"faceStart": start_face,
"faceCount": len(face_rows) - start_face,
"edgeStart": start_edge,
"edgeCount": len(edge_rows) - start_edge,
}
def emit_node(node: OccurrenceNode) -> dict[str, Any]:
occurrence_id = append_occurrence_row(node)
shape_start = len(shape_rows)
face_start = len(face_rows)
edge_start = len(edge_rows)
child_boxes: list[dict[str, Any]] = []
aggregated_shape_count = 0
aggregated_face_count = 0
aggregated_edge_count = 0
if node.prototype_key is not None:
leaf_result = emit_leaf(node, occurrence_id, prototypes[node.prototype_key])
child_boxes.append(leaf_result["bbox"])
aggregated_shape_count += int(leaf_result["shapeCount"])
aggregated_face_count += int(leaf_result["faceCount"])
aggregated_edge_count += int(leaf_result["edgeCount"])
for child in node.children:
child_result = emit_node(child)
child_boxes.append(child_result["bbox"])
aggregated_shape_count += int(child_result["shapeCount"])
aggregated_face_count += int(child_result["faceCount"])
aggregated_edge_count += int(child_result["edgeCount"])
bbox = _merge_bbox(child_boxes) if child_boxes else _bbox_from_points([])
ranges = {
"shapeStart": shape_start if profile != SelectorProfile.SUMMARY else 0,
"shapeCount": aggregated_shape_count if profile == SelectorProfile.SUMMARY else len(shape_rows) - shape_start,
"faceStart": face_start if profile != SelectorProfile.SUMMARY else 0,
"faceCount": aggregated_face_count if profile == SelectorProfile.SUMMARY else len(face_rows) - face_start,
"edgeStart": edge_start if profile != SelectorProfile.SUMMARY else 0,
"edgeCount": aggregated_edge_count if profile == SelectorProfile.SUMMARY else len(edge_rows) - edge_start,
}
finalize_occurrence_row(node, bbox, ranges)
return {"bbox": bbox, **ranges}
for root in roots:
emit_node(root)
overall_bbox = _merge_bbox(entry_bbox_boxes) if entry_bbox_boxes else _bbox_from_points([])
elapsed = load_elapsed + (time.perf_counter() - started)
stats = {
"occurrenceCount": len(occurrence_rows),
"leafOccurrenceCount": leaf_occurrence_count,
"shapeCount": summary_shape_count if profile == SelectorProfile.SUMMARY else len(shape_rows),
"faceCount": summary_face_count if profile == SelectorProfile.SUMMARY else len(face_rows),
"edgeCount": summary_edge_count if profile == SelectorProfile.SUMMARY else len(edge_rows),
"faceProxyRunCount": len(face_proxy_runs) // 5 if profile == SelectorProfile.ARTIFACT else 0,
"edgeProxyPointCount": len(edge_proxy_positions) // 3 if profile == SelectorProfile.ARTIFACT else 0,
"edgeProxySegmentCount": len(edge_proxy_ids) if profile == SelectorProfile.ARTIFACT else 0,
"surfaceHalfEdgeCount": len(surface_half_edges) // 7 if profile == SelectorProfile.ARTIFACT else 0,
"unmappedSurfaceEdgeCount": (
len(unmapped_surface_edges) if profile == SelectorProfile.ARTIFACT else 0
),
"timingMs": {
"load": round(load_elapsed * 1000.0, 1),
"extract": round(prototype_elapsed * 1000.0, 1),
"total": round(elapsed * 1000.0, 1),
},
}
if unmapped_surface_edges and profile == SelectorProfile.ARTIFACT:
stats["unmappedSurfaceEdgePreview"] = unmapped_surface_edges[:20]
edge_rendering_manifest: dict[str, Any] = {
"visibilityClasses": list(normalized_options.edge_visibility_classes),
"generatedVisibilityClasses": [
class_id
for class_id in normalized_options.edge_visibility_classes
if generated_edge_visibility_class_counts.get(class_id, 0) > 0
],
"visibilityClassCounts": dict(sorted(edge_visibility_class_counts.items())),
"generatedVisibilityClassCounts": dict(sorted(generated_edge_visibility_class_counts.items())),
}
mesh_manifest: dict[str, Any] = {
"linearDeflection": float(normalized_options.linear_deflection),
"angularDeflection": float(normalized_options.angular_deflection),
"relative": bool(normalized_options.relative),
}
if isinstance(normalized_options.mesh_resolution, dict):
mesh_manifest["resolution"] = normalized_options.mesh_resolution
source_kind = str(getattr(scene, "source_kind", "step") or "step").strip().lower()
if source_kind not in {"step", "python"}:
source_kind = "step"
artifact_dir = resolved_step_path.parent
source_path = _artifact_relative_manifest_path(str(getattr(scene, "source_path", "") or ""), artifact_dir)
if not source_path and source_kind != "python":
source_path = _relative_path_from_directory(resolved_step_path, artifact_dir)
if not source_path:
raise RuntimeError(f"STEP_topology artifact sourcePath is required for {resolved_step_path}")
manifest: dict[str, Any] = {
"schemaVersion": STEP_TOPOLOGY_SCHEMA_VERSION,
"profile": profile.value,
"capabilities": step_topology_capabilities(normalized_options.edge_visibility_classes),
"sourceKind": source_kind,
"sourcePath": source_path,
"stepPath": _relative_path_from_directory(resolved_step_path, artifact_dir),
"bbox": _compact_bbox(overall_bbox, normalized_options.digits),
"stats": stats,
"edgeRendering": edge_rendering_manifest,
"mesh": mesh_manifest,
"tables": {
"occurrenceColumns": occurrence_columns,
"shapeColumns": shape_columns,
"faceColumns": face_columns,
"edgeColumns": edge_columns,
},
"occurrences": occurrence_rows,
"shapes": shape_rows,
"faces": face_rows,
"edges": edge_rows,
}
if source_kind == "python":
source_hash = str(getattr(scene, "source_hash", "") or "").strip()
if source_hash:
manifest["sourceHash"] = source_hash
manifest["generatedAt"] = datetime.now(timezone.utc).isoformat(timespec="seconds")
step_hash = str(getattr(scene, "step_hash", "") or "").strip()
if not step_hash and scene.step_path.is_file():
step_hash = _scene_step_hash(scene)
if step_hash:
manifest["stepHash"] = step_hash
assembly_mates = getattr(scene, "assembly_mates", None)
if isinstance(assembly_mates, list) and assembly_mates:
manifest["assemblyMates"] = assembly_mates
if profile != SelectorProfile.SUMMARY:
if profile == SelectorProfile.ARTIFACT:
manifest["faceProxy"] = {
"source": f".{scene.step_path.name}.glb",
"runsView": "faceRuns",
"runColumns": ["occurrenceRow", "primitiveIndex", "triangleStart", "triangleCount", "faceRow"],
}
manifest["edgeProxy"] = {
"positionsView": "edgePositions",
"indicesView": "edgeIndices",
"edgeIdsView": "edgeIds",
}
manifest["relations"] = {
"faceEdgeRowsView": "faceEdgeRows",
"edgeFaceRowsView": "edgeFaceRows",
}
buffers = {
"faceRuns": face_proxy_runs,
"edgePositions": edge_proxy_positions,
"edgeIndices": edge_proxy_indices,
"edgeIds": edge_proxy_ids,
"faceEdgeRows": face_edge_rows,
"edgeFaceRows": edge_face_rows,
"surfaceHalfEdges": surface_half_edges,
}
return SelectorBundle(manifest=manifest, buffers=buffers)
manifest["relations"] = {
"faceEdgeRows": list(face_edge_rows),
"edgeFaceRows": list(edge_face_rows),
}
return SelectorBundle(manifest=manifest)
scripts/packages/cadpy/src/cadpy/step_targets.py
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()
scripts/packages/cadpy/src/cadpy/stl.py
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
scripts/packages/cadpy/src/cadpy/threemf.py
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))
scripts/packages/cadpy/src/cadpy/validators.py
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
scripts/snapshot/__init__.py
"""Wrapper package for the CAD skill snapshot command."""
scripts/snapshot/__main__.py
from __future__ import annotations
import asyncio
import base64
import copy
import json
import mimetypes
import os
import re
import sys
import time
from collections.abc import Iterable, Mapping, Sequence
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from urllib.parse import quote, unquote, urlparse
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
PACKAGES_DIR = SCRIPTS_DIR / "packages"
CADPY_SRC_DIR = PACKAGES_DIR / "cadpy" / "src"
for runtime_path in (SCRIPTS_DIR, PACKAGES_DIR, CADPY_SRC_DIR):
runtime_path_text = str(runtime_path)
if runtime_path_text not in sys.path:
sys.path.insert(0, runtime_path_text)
import cadpy.cad_ref_syntax as cad_ref_syntax
import cadpy.lookup as lookup
from cadpy.render import existing_part_glb_path, part_glb_path
from cadpy.step_targets import ResolvedStepTarget, StepTopologyArtifact, StepTopologyArtifactError
SNAPSHOT_ORIGIN = "http://snapshot.local"
SNAPSHOT_RENDER_URL = f"{SNAPSHOT_ORIGIN}/render.html"
SNAPSHOT_ROUTE_GLOB = f"{SNAPSHOT_ORIGIN}/**"
RUNTIME_DIR = Path(__file__).resolve().parent / "runtime"
RENDER_HTML_PATH = RUNTIME_DIR / "render.html"
DEFAULT_RENDER_THEME_ID = "workbench"
DEFAULT_TIMEOUT_SECONDS = 300
RENDER_BROWSER_STARTUP_TIMEOUT_MS = 15_000
SUPPORTED_RENDER_MODES = {"view", "orbit", "section", "list"}
WORKBENCH_RENDER_THEME_IDS = {DEFAULT_RENDER_THEME_ID}
SIMPLE_RENDER_WIDTH = 1200
SIMPLE_RENDER_HEIGHT = 900
SIMPLE_SQUARE_RENDER_WIDTH = 1024
SIMPLE_SQUARE_RENDER_HEIGHT = 1024
DIAGNOSTIC_RENDER_WIDTH = 1600
DIAGNOSTIC_RENDER_HEIGHT = 1200
COMPLEX_ASSEMBLY_RENDER_WIDTH = 1800
COMPLEX_ASSEMBLY_RENDER_HEIGHT = 1200
COMPLEX_ASSEMBLY_LARGE_RENDER_WIDTH = 1920
COMPLEX_ASSEMBLY_LARGE_RENDER_HEIGHT = 1440
PRESENTATION_RENDER_WIDTH = 2400
PRESENTATION_RENDER_HEIGHT = 1600
PRESENTATION_LARGE_RENDER_WIDTH = 2800
PRESENTATION_LARGE_RENDER_HEIGHT = 1800
ORBIT_RENDER_WIDTH = 960
ORBIT_RENDER_HEIGHT = 640
CONTACT_SHEET_RENDER_WIDTH = 2400
CONTACT_SHEET_RENDER_HEIGHT = 1600
DISPLAY_OPTION_KEYS = {"projection", "mode", "clip", "exploded", "edges"}
DISPLAY_MODE_ALIASES = {
"solid": "solid",
"edges": "solid",
"edge": "solid",
"shaded_edges": "solid",
"shaded_with_edges": "solid",
"with_edges": "solid",
"shaded": "rendered",
"shaded_without_edges": "rendered",
"without_edges": "rendered",
"transparent": "transparent",
"translucent": "transparent",
"xray": "transparent",
"x_ray": "transparent",
"see_through": "transparent",
"hidden_edges": "hidden_edges",
"hidden_edge": "hidden_edges",
"hidden_edges_visible": "hidden_edges",
"hidden_edge_display": "hidden_edges",
"shaded_hidden_edges": "hidden_edges",
"hidden_lines_removed": "hidden_lines_removed",
"hidden_line_removed": "hidden_lines_removed",
"hidden_lines": "hidden_lines_removed",
"hidden_edges_removed": "hidden_lines_removed",
"visible_edges": "hidden_lines_removed",
"visible_edges_only": "hidden_lines_removed",
"unshaded": "unshaded",
"flat": "unshaded",
"rendered": "rendered",
"appearance": "rendered",
"material": "rendered",
"materials": "rendered",
"wireframe": "wireframe",
"wire_frame": "wireframe",
"wire": "wireframe",
}
APPEARANCE_OPTION_KEYS = {
"materials",
"background",
"floor",
"environment",
"lighting",
}
ensure_step_topology_artifact = None
@dataclass
class SnapshotOptions:
job: str = ""
input: str = ""
output: str = ""
mode: str = "view"
appearance: object = DEFAULT_RENDER_THEME_ID
appearance_specified: bool = False
display: object = ""
display_specified: bool = False
camera: object = "iso"
camera_specified: bool = False
width: int | None = None
height: int | None = None
size_profile: str = ""
params: object = None
params_specified: bool = False
focus: list[str] | None = None
hide: list[str] | None = None
view_labels: bool = False
json: bool = False
help: bool = False
class SnapshotError(RuntimeError):
pass
class RouteFileError(SnapshotError):
def __init__(self, message: str, *, status: int = 404) -> None:
super().__init__(message)
self.status = status
def help_text() -> str:
return """Usage:
python scripts/snapshot --job render-job.json
python scripts/snapshot --job -
python scripts/snapshot --input models/part.step --output /tmp/part.png --appearance workbench
Shortcut flags are for common STEP/STP snapshots. --job accepts one render job, an array of render jobs, or { "jobs": [...] }. Every job input must be a relative or absolute .step/.stp path, or a same-stem Python generator; direct GLB/STL/3MF/DXF/G-code/robot-description inputs are unsupported. The default appearance is the workbench saved theme. --appearance accepts a saved theme name, an inline JSON appearance settings object, or a JSON appearance settings file path. --display accepts solid, rendered, transparent, hidden_edges, hidden_lines_removed, unshaded, wireframe, an inline JSON display settings object, or a JSON display settings file path. Projection, exploded view, and edge styling belong in display JSON, for example {"projection":"orthographic","mode":"rendered","exploded":{"enabled":true,"axis":"z","spacing":1.6},"edges":{"color":"#132232"}}. Use {"axis":"radial"} for outward radial disassembly. --camera accepts a preset, azimuth:elevation pair, or JSON object with preset, position, target, up, and zoom fields. --focus and --hide accept one or more selector refs such as #o1.2 for parts or subassemblies; pass the flag repeatedly or list refs after the flag. Option JSON is direct settings JSON, not a wrapped job fragment. Full JSON jobs use top-level appearance and display. Use --view-labels to burn the camera/view label into shortcut outputs. Use --params with STEP parameter sidecar JSON values, and --size-profile for default dimensions such as simple, diagnostic, labeled, assembly, presentation, orbit, or contact-sheet. Output file names are saved with a shared UTC seconds timestamp before the extension.
"""
def positive_integer(value: object, label: str) -> int:
try:
parsed = int(str(value or ""), 10)
except ValueError as exc:
raise SnapshotError(f"{label} must be a positive integer") from exc
if parsed <= 0:
raise SnapshotError(f"{label} must be a positive integer")
return parsed
def parse_required_value(argv: Sequence[str], index: int, flag: str) -> str:
try:
value = argv[index + 1]
except IndexError as exc:
raise SnapshotError(f"{flag} requires a value") from exc
if not value or value.startswith("--"):
raise SnapshotError(f"{flag} requires a value")
return value
def parse_required_values(argv: Sequence[str], index: int, flag: str) -> tuple[list[str], int]:
values: list[str] = []
cursor = index + 1
while cursor < len(argv):
value = argv[cursor]
if value.startswith("--"):
break
if value:
values.append(value)
cursor += 1
if not values:
raise SnapshotError(f"{flag} requires at least one value")
return values, cursor - index - 1
def parse_snapshot_args(argv: Sequence[str]) -> SnapshotOptions:
if argv and argv[0] == "daemon":
raise SnapshotError("snapshot daemon commands have been removed; use a batch --job snapshot instead")
options = SnapshotOptions()
index = 0
while index < len(argv):
arg = argv[index]
if arg in {"--help", "-h"}:
options.help = True
elif arg == "--json":
options.json = True
elif arg == "--no-daemon":
raise SnapshotError("--no-daemon has been removed; snapshot no longer uses a daemon")
elif arg == "--socket" or arg.startswith("--socket="):
raise SnapshotError("--socket has been removed; snapshot no longer uses a daemon")
elif arg == "--view-labels":
options.view_labels = True
elif arg == "--job":
options.job = parse_required_value(argv, index, arg)
index += 1
elif arg.startswith("--job="):
options.job = arg[len("--job=") :]
elif arg == "--input":
options.input = parse_required_value(argv, index, arg)
index += 1
elif arg.startswith("--input="):
options.input = arg[len("--input=") :]
elif arg in {"--output", "-o"}:
options.output = parse_required_value(argv, index, arg)
index += 1
elif arg.startswith("--output="):
options.output = arg[len("--output=") :]
elif arg == "--mode":
options.mode = parse_required_value(argv, index, arg)
index += 1
elif arg.startswith("--mode="):
options.mode = arg[len("--mode=") :]
elif arg == "--appearance":
options.appearance = parse_required_value(argv, index, arg)
options.appearance_specified = True
index += 1
elif arg.startswith("--appearance="):
options.appearance = arg[len("--appearance=") :]
options.appearance_specified = True
elif arg == "--display":
options.display = parse_required_value(argv, index, arg)
options.display_specified = True
index += 1
elif arg.startswith("--display="):
options.display = arg[len("--display=") :]
options.display_specified = True
elif arg == "--params":
options.params = parse_required_value(argv, index, arg)
options.params_specified = True
index += 1
elif arg.startswith("--params="):
options.params = arg[len("--params=") :]
options.params_specified = True
elif arg == "--focus":
values, consumed = parse_required_values(argv, index, arg)
options.focus = [*(options.focus or []), *values]
index += consumed
elif arg.startswith("--focus="):
value = arg[len("--focus=") :]
if not value:
raise SnapshotError("--focus requires at least one value")
options.focus = [*(options.focus or []), value]
elif arg == "--hide":
values, consumed = parse_required_values(argv, index, arg)
options.hide = [*(options.hide or []), *values]
index += consumed
elif arg.startswith("--hide="):
value = arg[len("--hide=") :]
if not value:
raise SnapshotError("--hide requires at least one value")
options.hide = [*(options.hide or []), value]
elif arg == "--size-profile":
options.size_profile = parse_required_value(argv, index, arg)
index += 1
elif arg.startswith("--size-profile="):
options.size_profile = arg[len("--size-profile=") :]
elif arg == "--camera":
options.camera = parse_required_value(argv, index, arg)
options.camera_specified = True
index += 1
elif arg.startswith("--camera="):
options.camera = arg[len("--camera=") :]
options.camera_specified = True
elif arg == "--width":
options.width = positive_integer(parse_required_value(argv, index, arg), arg)
index += 1
elif arg.startswith("--width="):
options.width = positive_integer(arg[len("--width=") :], "--width")
elif arg == "--height":
options.height = positive_integer(parse_required_value(argv, index, arg), arg)
index += 1
elif arg.startswith("--height="):
options.height = positive_integer(arg[len("--height=") :], "--height")
else:
raise SnapshotError(f"Unknown argument: {arg}")
index += 1
if options.focus and options.hide:
raise SnapshotError("--focus and --hide cannot be used in the same snapshot command")
return options
def is_plain_object(value: object) -> bool:
return isinstance(value, dict)
def load_json_text(text: str, source_label: str) -> object:
try:
return json.loads(text)
except json.JSONDecodeError as exc:
raise SnapshotError(f"Failed to parse JSON from {source_label}: {exc}") from exc
def parse_camera_option(raw_camera: object) -> object:
camera = str(raw_camera or "").strip()
if not camera:
raise SnapshotError("--camera requires a preset, azimuth:elevation pair, or JSON camera object")
if not camera.startswith("{"):
return camera
parsed = load_json_text(camera, "--camera")
if not is_plain_object(parsed):
raise SnapshotError("--camera must be a preset, azimuth:elevation pair, or JSON object")
return parsed
def parse_params_option(raw_params: object) -> dict[str, object]:
parsed = load_json_text(str(raw_params or ""), "--params")
if not is_plain_object(parsed):
raise SnapshotError("--params must be a STEP parameter JSON object")
return parsed
def option_focus_hide_specified(options: SnapshotOptions) -> bool:
return bool(options.focus or options.hide)
def merge_focus_hide_options(job: dict[str, object], options: SnapshotOptions) -> None:
if not option_focus_hide_specified(options):
return
if options.focus and options.hide:
raise SnapshotError("--focus and --hide cannot be used in the same snapshot command")
selection = dict(job.get("selection") if is_plain_object(job.get("selection")) else {})
if options.focus:
selection["focus"] = list(options.focus)
if options.hide:
selection["hide"] = list(options.hide)
job["selection"] = selection
def validate_direct_settings_payload(
parsed: object,
*,
option_name: str,
source_label: str,
allowed_keys: set[str],
setting_label: str,
) -> dict[str, object]:
if not is_plain_object(parsed):
raise SnapshotError(f"{option_name} JSON must be a {setting_label} object: {source_label}")
unknown_keys = [key for key in parsed if key not in allowed_keys]
if unknown_keys:
raise SnapshotError(
f"{option_name} JSON must be the {setting_label} object directly; "
f"unsupported keys: {', '.join(unknown_keys)}"
)
if not parsed:
raise SnapshotError(f"{option_name} JSON must include at least one {setting_label} field: {source_label}")
return dict(parsed)
def load_display_option(raw_display: object, *, cwd: Path) -> dict[str, object]:
display = str(raw_display or "").strip()
if not display:
raise SnapshotError("--display requires a JSON object, JSON file path, or display mode")
if display.startswith("{"):
return validate_direct_settings_payload(
load_json_text(display, "--display"),
option_name="--display",
source_label="--display",
allowed_keys=DISPLAY_OPTION_KEYS,
setting_label="display settings",
)
display_path = Path(display) if Path(display).is_absolute() else cwd / display
looks_like_file = display.lower().endswith(".json") or "/" in display or "\\" in display
if not looks_like_file and not display_path.exists():
normalized_mode = re.sub(r"[\s-]+", "_", display.lower())
if normalized_mode not in DISPLAY_MODE_ALIASES:
supported = ", ".join(sorted(set(DISPLAY_MODE_ALIASES.values())))
raise SnapshotError(f"Unsupported display mode: {display}. Supported modes: {supported}")
return {"mode": DISPLAY_MODE_ALIASES[normalized_mode]}
if not display_path.exists():
raise SnapshotError(f"Display JSON file does not exist: {display}")
return validate_direct_settings_payload(
load_json_text(display_path.read_text(encoding="utf-8"), str(display_path)),
option_name="--display",
source_label=str(display_path),
allowed_keys=DISPLAY_OPTION_KEYS,
setting_label="display settings",
)
def load_appearance_option(raw_appearance: object, *, cwd: Path) -> object:
appearance = str(raw_appearance or DEFAULT_RENDER_THEME_ID).strip() or DEFAULT_RENDER_THEME_ID
if appearance.startswith("{"):
return validate_direct_settings_payload(
load_json_text(appearance, "--appearance"),
option_name="--appearance",
source_label="--appearance",
allowed_keys=APPEARANCE_OPTION_KEYS,
setting_label="appearance settings",
)
appearance_path = Path(appearance) if Path(appearance).is_absolute() else cwd / appearance
looks_like_file = appearance.lower().endswith(".json") or "/" in appearance or "\\" in appearance
if not looks_like_file and not appearance_path.exists():
return appearance
if not appearance_path.exists():
raise SnapshotError(f"Appearance JSON file does not exist: {appearance}")
return validate_direct_settings_payload(
load_json_text(appearance_path.read_text(encoding="utf-8"), str(appearance_path)),
option_name="--appearance",
source_label=str(appearance_path),
allowed_keys=APPEARANCE_OPTION_KEYS,
setting_label="appearance settings",
)
def apply_option_overrides_to_job(job: object, options: SnapshotOptions, *, cwd: Path) -> object:
if not is_plain_object(job):
return job
if not any(
[
options.view_labels,
options.size_profile,
options.params_specified,
options.display_specified,
options.appearance_specified,
options.camera_specified,
option_focus_hide_specified(options),
]
):
return job
next_job = copy.deepcopy(job)
merge_focus_hide_options(next_job, options)
if options.appearance_specified:
next_job["appearance"] = load_appearance_option(options.appearance, cwd=cwd)
if options.params_specified:
next_job["stepParameters"] = parse_params_option(options.params)
if options.display_specified:
next_job["display"] = load_display_option(options.display, cwd=cwd)
if options.camera_specified:
next_job["camera"] = parse_camera_option(options.camera)
render = dict(next_job.get("render") if is_plain_object(next_job.get("render")) else {})
if options.view_labels:
render["viewLabels"] = True
if options.size_profile:
render["sizeProfile"] = options.size_profile
next_job["render"] = render
return next_job
def apply_option_overrides_to_payload(payload: object, options: SnapshotOptions, *, cwd: Path) -> object:
if isinstance(payload, list):
return [apply_option_overrides_to_job(job, options, cwd=cwd) for job in payload]
if is_plain_object(payload) and isinstance(payload.get("jobs"), list):
next_payload = copy.deepcopy(payload)
next_payload["jobs"] = [apply_option_overrides_to_job(job, options, cwd=cwd) for job in payload["jobs"]]
return next_payload
return apply_option_overrides_to_job(payload, options, cwd=cwd)
def load_job_from_options(
options: SnapshotOptions,
*,
stdin: Any = sys.stdin,
cwd: Path | None = None,
) -> object:
resolved_cwd = (cwd or Path.cwd()).resolve()
if options.job:
if options.job == "-":
text = stdin.read()
source_label = "stdin"
else:
job_path = (resolved_cwd / options.job).resolve()
text = job_path.read_text(encoding="utf-8")
source_label = str(job_path)
return apply_option_overrides_to_payload(load_json_text(text, source_label), options, cwd=resolved_cwd)
if not stdin.isatty() and not options.input:
text = stdin.read()
if text.strip():
return apply_option_overrides_to_payload(load_json_text(text, "stdin"), options, cwd=resolved_cwd)
if not options.input:
raise SnapshotError("render requires --job, stdin JSON, or --input")
if options.mode != "list" and not options.output:
raise SnapshotError("render shortcut requires --output for non-list modes")
output: dict[str, object] = {
"path": options.output,
"camera": parse_camera_option(options.camera),
}
if options.width:
output["width"] = options.width
if options.height:
output["height"] = options.height
job: dict[str, object] = {
"input": options.input,
"mode": options.mode,
"outputs": [] if options.mode == "list" else [output],
"appearance": load_appearance_option(options.appearance, cwd=resolved_cwd),
"render": {"viewLabels": options.view_labels},
}
if options.size_profile:
job["render"]["sizeProfile"] = options.size_profile
if options.display_specified:
job["display"] = load_display_option(options.display, cwd=resolved_cwd)
if options.params_specified:
job["stepParameters"] = parse_params_option(options.params)
merge_focus_hide_options(job, options)
return job
def path_is_inside_or_equal(child: Path, parent: Path) -> bool:
resolved_child = child.resolve()
resolved_parent = parent.resolve()
try:
resolved_child.relative_to(resolved_parent)
return True
except ValueError:
return False
def input_kind(file_path: Path) -> str:
suffix = file_path.suffix.lower()
if suffix == ".step":
return "step"
if suffix == ".stp":
return "stp"
if suffix == ".py":
return "python"
return ""
def logical_step_path_for_python_source(source_path: Path) -> Path:
return source_path.with_suffix(".step")
def same_stem_python_generator_path(step_path: Path) -> Path | None:
candidate = step_path.with_suffix(".py")
try:
return candidate if re.search(r"\bgen_step\s*\(", candidate.read_text(encoding="utf-8")) else None
except OSError:
return None
def resolve_input_path(raw_input: object, *, cwd: Path) -> Path:
input_text = str(raw_input or "").strip()
if not input_text:
raise SnapshotError("render job is missing input")
raw_path = Path(input_text)
selected = raw_path.resolve() if raw_path.is_absolute() else (cwd / raw_path).resolve()
if not selected.exists():
if selected.suffix.lower() in {".step", ".stp"} and same_stem_python_generator_path(selected):
return selected
raise SnapshotError(f"Render input does not exist: {input_text}")
return selected
def encode_path_param(value: str) -> str:
return "/".join(quote(part) for part in value.replace(os.sep, "/").split("/"))
def asset_url_for_path(file_path: Path, root_path: Path) -> str:
if not path_is_inside_or_equal(file_path, root_path):
raise SnapshotError(f"Render asset must be inside the snapshot render root: {file_path}")
return f"/__render_asset/{encode_path_param(file_path.resolve().relative_to(root_path.resolve()).as_posix())}"
def step_parameter_path_for_step_source(source_path: Path) -> Path:
return source_path.with_name(f".{source_path.stem}.step.js")
def has_step_parameter_render_values(value: object) -> bool:
return value is not None
def step_parameter_render_values_are_animated(value: object) -> bool:
return is_plain_object(value) and is_plain_object(value.get("animate")) and bool(value["animate"])
def appearance_theme_id_for_job(job: Mapping[str, object]) -> str:
appearance = job.get("appearance")
if isinstance(appearance, str):
return appearance.strip().lower() or DEFAULT_RENDER_THEME_ID
return DEFAULT_RENDER_THEME_ID
def normalize_size_profile(value: object) -> str:
return str(value or "").strip().lower().replace("_", "-")
def explicit_size_profile(job: Mapping[str, object], output: Mapping[str, object]) -> str:
render = job.get("render") if is_plain_object(job.get("render")) else {}
return normalize_size_profile(output.get("sizeProfile") or render.get("sizeProfile") or job.get("sizeProfile") or "")
def default_render_size(job: Mapping[str, object], output: Mapping[str, object]) -> tuple[int, int]:
mode = str(job.get("mode") or "view").strip().lower()
profile = explicit_size_profile(job, output)
if profile in {"simple-square", "square"}:
return SIMPLE_SQUARE_RENDER_WIDTH, SIMPLE_SQUARE_RENDER_HEIGHT
if profile in {"simple", "simple-part", "unlabeled"}:
return SIMPLE_RENDER_WIDTH, SIMPLE_RENDER_HEIGHT
if profile in {"presentation-large", "hero", "large-presentation"}:
return PRESENTATION_LARGE_RENDER_WIDTH, PRESENTATION_LARGE_RENDER_HEIGHT
if profile == "presentation":
return PRESENTATION_RENDER_WIDTH, PRESENTATION_RENDER_HEIGHT
if profile in {"complex-assembly-large", "assembly-large"}:
return COMPLEX_ASSEMBLY_LARGE_RENDER_WIDTH, COMPLEX_ASSEMBLY_LARGE_RENDER_HEIGHT
if profile in {"complex-assembly", "assembly"}:
return COMPLEX_ASSEMBLY_RENDER_WIDTH, COMPLEX_ASSEMBLY_RENDER_HEIGHT
if profile in {"contact-sheet", "contactsheet"}:
return CONTACT_SHEET_RENDER_WIDTH, CONTACT_SHEET_RENDER_HEIGHT
if profile == "orbit" or mode == "orbit" or step_parameter_render_values_are_animated(job.get("stepParameters")):
return ORBIT_RENDER_WIDTH, ORBIT_RENDER_HEIGHT
render = job.get("render") if is_plain_object(job.get("render")) else {}
if (
profile in {"dimensioned", "section", "labeled"}
or mode == "section"
or render.get("viewLabels") is True
or output.get("viewLabel")
or output.get("label")
):
return DIAGNOSTIC_RENDER_WIDTH, DIAGNOSTIC_RENDER_HEIGHT
if profile == "diagnostic" or appearance_theme_id_for_job(job) in WORKBENCH_RENDER_THEME_IDS:
return DIAGNOSTIC_RENDER_WIDTH, DIAGNOSTIC_RENDER_HEIGHT
return SIMPLE_RENDER_WIDTH, SIMPLE_RENDER_HEIGHT
def resolve_output_size(job: Mapping[str, object], output: Mapping[str, object]) -> tuple[int, int]:
default_width, default_height = default_render_size(job, output)
return (
positive_integer(output.get("width") or job.get("width") or default_width, "output width"),
positive_integer(output.get("height") or job.get("height") or default_height, "output height"),
)
def snapshot_timestamp() -> str:
return datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
def timestamp_output_path(output_path: str, timestamp: str) -> str:
if not output_path:
return ""
path = Path(output_path)
return str(path.with_name(f"{path.stem}_{timestamp}{path.suffix}"))
def normalize_snapshot_job_packet(raw_payload: object) -> tuple[bool, list[object]]:
if isinstance(raw_payload, list):
return False, raw_payload
if is_plain_object(raw_payload) and isinstance(raw_payload.get("jobs"), list):
return False, list(raw_payload["jobs"])
return True, [raw_payload]
def reference_root_for_input(input_path: Path, cwd: Path) -> Path:
return cwd if path_is_inside_or_equal(input_path, cwd) else input_path.parent
def cad_ref_for_step_path(repo_root: Path, step_path: Path) -> str:
try:
relative = step_path.resolve().relative_to(repo_root.resolve()).as_posix()
except ValueError:
relative = step_path.resolve().as_posix()
suffix = step_path.suffix
return relative[: -len(suffix)] if suffix else relative
def load_ensure_step_topology_artifact():
global ensure_step_topology_artifact
if ensure_step_topology_artifact is None:
from cadpy.step_artifacts import ensure_step_topology_artifact as imported_ensure
ensure_step_topology_artifact = imported_ensure
return ensure_step_topology_artifact
def selection_value_list(value: object) -> list[str]:
if isinstance(value, list):
values: list[str] = []
for item in value:
values.extend(selection_value_list(item))
return values
text = str(value or "").strip()
if not text:
return []
return [entry.strip() for entry in text.split(",") if entry.strip()]
def selection_filter_values(job: Mapping[str, object]) -> list[str]:
selection = job.get("selection") if is_plain_object(job.get("selection")) else {}
values: list[str] = []
for key in ("focus", "refs", "hide"):
values.extend(selection_value_list(selection.get(key)))
return values
def selector_value_requires_topology(value: str) -> bool:
text = str(value or "").strip()
if not text:
return False
parsed = cad_ref_syntax.parse_selector(text)
return parsed is not None and parsed.selector_type != "opaque"
def selection_requires_selector_topology(job: Mapping[str, object]) -> bool:
return any(selector_value_requires_topology(value) for value in selection_filter_values(job))
def ensure_render_job_step_artifact(
job: Mapping[str, object],
*,
reference_root: Path,
input_path: Path,
step_path: Path,
require_selector: bool = False,
) -> StepTopologyArtifact:
target = ResolvedStepTarget(
cad_path=cad_ref_for_step_path(reference_root, step_path),
kind="part",
source_path=input_path,
step_path=step_path,
)
try:
ensure_artifact = load_ensure_step_topology_artifact()
return ensure_artifact(target, owner="cad-snapshot", require_selector=require_selector)
except StepTopologyArtifactError as exc:
raise SnapshotError(str(exc)) from exc
def artifact_selector_index(artifact: StepTopologyArtifact | None) -> lookup.SelectorIndex | None:
selector_bundle = artifact.selector_bundle if artifact is not None else None
if selector_bundle is None:
return None
manifest = selector_bundle.manifest if isinstance(selector_bundle.manifest, dict) else None
if manifest is None:
return None
buffers = selector_bundle.buffers if isinstance(selector_bundle.buffers, Mapping) else None
return lookup.build_selector_index(manifest, buffers=buffers)
def validate_occurrence_selector(selector: str, *, selector_index: lookup.SelectorIndex | None, source_label: str) -> None:
if selector_index is None:
return
if selector not in selector_index.occurrence_by_id:
raise SnapshotError(f"{source_label} references unknown part/subassembly occurrence selector: {selector}")
def normalize_selection_selector(
raw_value: str,
*,
selector_index: lookup.SelectorIndex | None,
source_label: str,
) -> list[str]:
text = str(raw_value or "").strip()
if not text:
return []
parsed = cad_ref_syntax.parse_selector(text)
if parsed is None:
return []
if parsed.selector_type == "opaque":
return [parsed.canonical]
if parsed.selector_type != "occurrence":
raise SnapshotError(
f"{source_label} supports only part/subassembly occurrence refs; "
f"got {parsed.selector_type} selector {text!r}"
)
validate_occurrence_selector(parsed.canonical, selector_index=selector_index, source_label=source_label)
return [parsed.canonical]
def normalize_selection_filter_values(
value: object,
*,
expected_cad_path: str,
selector_index: lookup.SelectorIndex | None,
source_label: str,
) -> list[str]:
_ = expected_cad_path
selectors: list[str] = []
for raw_value in selection_value_list(value):
selectors.extend(
normalize_selection_selector(raw_value, selector_index=selector_index, source_label=source_label)
)
return selectors
def normalize_render_job_selection(
job: Mapping[str, object],
*,
expected_cad_path: str,
selector_index: lookup.SelectorIndex | None,
) -> dict[str, object] | None:
selection = job.get("selection") if is_plain_object(job.get("selection")) else None
if selection is None:
return None
if any(selection_value_list(selection.get(key)) for key in ("focus", "refs")) and selection_value_list(
selection.get("hide")
):
raise SnapshotError("selection.focus/refs and selection.hide cannot be used in the same snapshot job")
normalized = dict(selection)
for key in ("focus", "refs", "hide"):
if key not in selection:
continue
normalized[key] = normalize_selection_filter_values(
selection.get(key),
expected_cad_path=expected_cad_path,
selector_index=selector_index,
source_label=f"selection.{key}",
)
return normalized
def resolve_render_job(
raw_job: object,
*,
cwd: Path | None = None,
timestamp: str | None = None,
) -> dict[str, object]:
if not is_plain_object(raw_job):
raise SnapshotError("render job must be an object")
job = copy.deepcopy(raw_job)
if "theme" in job:
raise SnapshotError("render jobs use appearance; theme is reserved for saved appearance settings")
if "params" in job:
raise SnapshotError("render jobs use stepParameters; params is reserved for shortcut --params parsing")
forbidden_root_fields = [field for field in ("workspaceRoot", "rootDir") if field in job]
if forbidden_root_fields:
raise SnapshotError(
"snapshot jobs no longer accept workspaceRoot or rootDir; pass a relative or absolute input path instead"
)
resolved_cwd = (cwd or Path.cwd()).resolve()
raw_input = str(job.get("input") or "").strip()
if not raw_input:
raise SnapshotError("render job is missing input")
input_path = resolve_input_path(raw_input, cwd=resolved_cwd)
root_path = input_path.parent.resolve()
reference_root = reference_root_for_input(input_path, resolved_cwd)
kind = input_kind(input_path)
source_path = input_path
if kind == "python":
input_path = logical_step_path_for_python_source(input_path)
root_path = input_path.parent.resolve()
kind = "step"
if kind not in {"step", "stp"}:
raise SnapshotError("Snapshot supports only STEP/STP inputs or same-stem Python generators")
artifact = ensure_render_job_step_artifact(
job,
reference_root=reference_root,
input_path=source_path,
step_path=input_path,
require_selector=selection_requires_selector_topology(job),
)
expected_cad_path = cad_ref_for_step_path(reference_root, input_path)
normalized_selection = normalize_render_job_selection(
job,
expected_cad_path=expected_cad_path,
selector_index=artifact_selector_index(artifact),
)
glb_path = existing_part_glb_path(input_path) or part_glb_path(input_path)
if not glb_path.exists():
raise SnapshotError(f"STEP/STP render input is missing its CAD Viewer GLB artifact: {glb_path}")
has_param_render = has_step_parameter_render_values(job.get("stepParameters"))
animated_params = step_parameter_render_values_are_animated(job.get("stepParameters"))
step_parameter_path = step_parameter_path_for_step_source(input_path)
mode = str(job.get("mode") or "view").strip().lower()
if mode not in SUPPORTED_RENDER_MODES:
raise SnapshotError(f"Unsupported render mode: {mode or '(missing)'}")
if has_param_render and mode != "view":
raise SnapshotError("stepParameters support only view mode; set display.mode for display-style changes")
if has_param_render and not step_parameter_path.exists():
raise SnapshotError(
f"STEP/STP render stepParameters require a CAD Viewer STEP parameter sidecar: {step_parameter_path}"
)
outputs = job.get("outputs") if isinstance(job.get("outputs"), list) else []
if mode != "list" and not outputs:
raise SnapshotError("render job must include outputs for non-list modes")
if animated_params and len(outputs) != 1:
raise SnapshotError("animated stepParameters require exactly one output")
normalized_render = dict(job.get("render") if is_plain_object(job.get("render")) else {})
normalized_render.pop("clip", None)
normalized_render.pop("clipSettings", None)
raw_scale = str(
normalized_render.get("scale")
or normalized_render.get("sceneScale")
or normalized_render.get("sceneScaleMode")
or job.get("scale")
or job.get("sceneScale")
or ""
).strip().lower()
if raw_scale:
normalized_render["scale"] = "cad"
normalized_outputs: list[dict[str, object]] = []
resolved_timestamp = timestamp or snapshot_timestamp()
for output in outputs:
output_object = dict(output if is_plain_object(output) else {})
width, height = resolve_output_size({**job, "mode": mode}, output_object)
output_path = str(output_object.get("path") or "")
timestamped_output_path = timestamp_output_path(output_path, resolved_timestamp)
normalized_outputs.append(
{
**output_object,
"path": str((resolved_cwd / timestamped_output_path).resolve()) if timestamped_output_path else "",
"width": width,
"height": height,
"camera": output_object.get("camera") or job.get("camera") or "iso",
}
)
job.pop("clip", None)
job.pop("clipSettings", None)
resolved: dict[str, object] = {
"rootPath": str(root_path),
"inputPath": str(input_path),
"inputUrl": asset_url_for_path(input_path, root_path),
"kind": kind,
"glbPath": str(glb_path),
"glbUrl": asset_url_for_path(glb_path, root_path),
}
if step_parameter_path.exists():
resolved["stepParameterPath"] = str(step_parameter_path)
resolved["stepParameterUrl"] = asset_url_for_path(step_parameter_path, root_path)
if normalized_selection is not None:
job["selection"] = normalized_selection
return {
**job,
"mode": mode,
"display": job.get("display") if is_plain_object(job.get("display")) else {"mode": "solid"},
"render": normalized_render,
"outputs": normalized_outputs,
"resolved": resolved,
}
def resolve_render_job_packet(raw_payload: object, *, cwd: Path | None = None) -> dict[str, object]:
single, jobs = normalize_snapshot_job_packet(raw_payload)
timestamp = snapshot_timestamp()
return {
"single": single,
"jobs": [resolve_render_job(job, cwd=cwd, timestamp=timestamp) for job in jobs],
}
def content_type_for_path(path: Path) -> str:
if path.suffix.lower() == ".mjs":
return "text/javascript; charset=utf-8"
if path.suffix.lower() == ".js":
return "text/javascript; charset=utf-8"
if path.suffix.lower() == ".html":
return "text/html; charset=utf-8"
if path.suffix.lower() == ".wasm":
return "application/wasm"
if path.suffix.lower() == ".glb":
return "model/gltf-binary"
guessed, _ = mimetypes.guess_type(path)
return guessed or "application/octet-stream"
def route_file(pathname: str, prefix: str, root: Path) -> Path:
relative_path = unquote(pathname[len(prefix) :])
file_path = (root / relative_path.lstrip("/")).resolve()
if not path_is_inside_or_equal(file_path, root):
raise RouteFileError(f"forbidden route path: {pathname}", status=403)
return file_path
def resolve_snapshot_route_file(raw_url: str, *, active_root_path: Path | None = None) -> Path:
parsed = urlparse(raw_url)
origin = f"{parsed.scheme}://{parsed.netloc}"
if origin != SNAPSHOT_ORIGIN:
raise RouteFileError(f"unsupported snapshot origin: {origin}", status=403)
if parsed.path == "/render.html":
return RENDER_HTML_PATH
if parsed.path.startswith("/__render_asset/"):
if active_root_path is None:
raise RouteFileError("snapshot render asset requested without an active render root")
return route_file(parsed.path, "/__render_asset/", active_root_path)
if parsed.path == "/snapshot-render.js":
return RUNTIME_DIR / "snapshot-render.js"
raise RouteFileError(f"snapshot route not found: {parsed.path}")
def max_output_size(job: Mapping[str, object]) -> tuple[int, int]:
outputs = job.get("outputs") if isinstance(job.get("outputs"), list) and job.get("outputs") else []
if not outputs:
return SIMPLE_RENDER_WIDTH, SIMPLE_RENDER_HEIGHT
widths = [int(output.get("width") or SIMPLE_RENDER_WIDTH) for output in outputs if is_plain_object(output)]
heights = [int(output.get("height") or SIMPLE_RENDER_HEIGHT) for output in outputs if is_plain_object(output)]
return max(widths or [SIMPLE_RENDER_WIDTH], default=SIMPLE_RENDER_WIDTH), max(heights or [SIMPLE_RENDER_HEIGHT], default=SIMPLE_RENDER_HEIGHT)
async def with_snapshot_timeout(awaitable: Any, timeout_seconds: object, label: str = "snapshot") -> object:
timeout = max(1, float(timeout_seconds or DEFAULT_TIMEOUT_SECONDS))
try:
return await asyncio.wait_for(awaitable, timeout=timeout)
except asyncio.TimeoutError as exc:
raise SnapshotError(f"{label} timed out after {timeout_seconds}s") from exc
class BatchSnapshotRenderer:
def __init__(self) -> None:
self.playwright = None
self.browser = None
self.context = None
self.page = None
self.active_root_path: Path | None = None
self.started = False
async def start(self) -> None:
if self.started:
return
try:
try:
from playwright.async_api import async_playwright
except ImportError as exc:
raise SnapshotError(
"CAD snapshot requires the Python playwright package. "
"Install the CAD skill requirements, then run `python -m playwright install chromium` if needed."
) from exc
self.playwright = await async_playwright().start()
self.browser = await self.playwright.chromium.launch(
headless=True,
timeout=RENDER_BROWSER_STARTUP_TIMEOUT_MS,
)
self.context = await self.browser.new_context(
viewport={"width": SIMPLE_RENDER_WIDTH, "height": SIMPLE_RENDER_HEIGHT},
device_scale_factor=1,
)
self.page = await self.context.new_page()
await self.page.route(SNAPSHOT_ROUTE_GLOB, self.handle_route)
await self.page.goto(SNAPSHOT_RENDER_URL, wait_until="load", timeout=DEFAULT_TIMEOUT_SECONDS * 1000)
await self.page.wait_for_function(
"typeof window.__snapshotRender === 'function'",
timeout=DEFAULT_TIMEOUT_SECONDS * 1000,
)
self.started = True
except Exception:
await self.close()
raise
async def handle_route(self, route: Any) -> None:
request = route.request
if request.method != "GET":
await route.fulfill(status=405, content_type="text/plain; charset=utf-8", body="method not allowed")
return
try:
file_path = resolve_snapshot_route_file(request.url, active_root_path=self.active_root_path)
except RouteFileError as exc:
await route.fulfill(status=exc.status, content_type="text/plain; charset=utf-8", body=str(exc))
return
except Exception as exc:
await route.fulfill(status=500, content_type="text/plain; charset=utf-8", body=str(exc))
return
if not file_path.is_file():
await route.fulfill(status=404, content_type="text/plain; charset=utf-8", body="not found")
return
await route.fulfill(
status=200,
content_type=content_type_for_path(file_path),
headers={"cache-control": "no-store"},
body=file_path.read_bytes(),
)
async def render(self, job: Mapping[str, object]) -> dict[str, object]:
await self.start()
resolved = job.get("resolved") if is_plain_object(job.get("resolved")) else {}
self.active_root_path = Path(str(resolved.get("rootPath") or "")).resolve()
width, height = max_output_size(job)
await self.page.set_viewport_size({"width": width, "height": height})
timeout_seconds = job.get("timeoutSeconds") or DEFAULT_TIMEOUT_SECONDS
result = await with_snapshot_timeout(
self.page.evaluate("(renderJob) => window.__snapshotRender(renderJob)", dict(job)),
timeout_seconds,
)
if not is_plain_object(result) or not result.get("ok"):
message = result.get("error") if is_plain_object(result) else ""
raise SnapshotError(str(message or "unknown browser snapshot failure"))
return result
async def close(self) -> None:
if self.context is not None:
try:
await self.context.close()
except Exception:
pass
self.context = None
if self.browser is not None:
try:
await self.browser.close()
except Exception:
pass
self.browser = None
if self.playwright is not None:
try:
await self.playwright.stop()
except Exception:
pass
self.playwright = None
self.page = None
self.started = False
async def render_resolved_job_packet(packet: Mapping[str, object], *, renderer: BatchSnapshotRenderer | None = None) -> dict[str, object]:
snapshot_renderer = renderer or BatchSnapshotRenderer()
started = time.perf_counter()
results: list[dict[str, object]] = []
try:
for job in packet["jobs"]:
result = await snapshot_renderer.render(job)
results.append(result if packet["single"] else {"input": job.get("input"), **result})
finally:
await snapshot_renderer.close()
if packet["single"]:
return results[0]
return {
"ok": all(result.get("ok") is not False for result in results),
"jobs": results,
"timings": {
"jobCount": len(results),
"totalMs": (time.perf_counter() - started) * 1000,
},
}
def write_output_payload(output: Mapping[str, object]) -> None:
output_path = str(output.get("path") or "")
if not output_path:
return
path = Path(output_path)
path.parent.mkdir(parents=True, exist_ok=True)
text = output.get("text")
if isinstance(text, str):
path.write_text(text, encoding="utf-8")
return
data_url = str(output.get("dataUrl") or "")
match = re.match(r"^data:([^;]+);base64,(.+)$", data_url)
if not match:
raise SnapshotError(f"Snapshot output did not include a base64 data URL: {output_path}")
path.write_bytes(base64.b64decode(match.group(2)))
def write_render_outputs(result: Mapping[str, object]) -> None:
if isinstance(result.get("jobs"), list):
for job_result in result["jobs"]:
if is_plain_object(job_result):
write_render_outputs(job_result)
return
outputs = result.get("outputs") if isinstance(result.get("outputs"), list) else []
for output in outputs:
if is_plain_object(output):
write_output_payload(output)
def print_render_result(result: Mapping[str, object], *, json_output: bool = False, stdout: Any = sys.stdout) -> None:
if json_output:
stdout.write(f"{json.dumps(result, indent=2)}\n")
return
if isinstance(result.get("jobs"), list):
for job_result in result["jobs"]:
if is_plain_object(job_result):
print_render_result(job_result, json_output=False, stdout=stdout)
for warning in result.get("warnings") or []:
stdout.write(f"warning: {warning}\n")
return
outputs = result.get("outputs")
if not isinstance(outputs, list):
stdout.write(f"{json.dumps(result, indent=2)}\n")
return
if result.get("mode") == "list":
stdout.write(f"{json.dumps(result.get('parts') or [], indent=2)}\n")
return
for output in outputs:
if is_plain_object(output) and output.get("path"):
stdout.write(f"saved snapshot: {output['path']}\n")
for warning in result.get("warnings") or []:
stdout.write(f"warning: {warning}\n")
async def run_render_cli_async(
argv: Sequence[str],
*,
cwd: Path | None = None,
stdout: Any = sys.stdout,
stdin: Any = sys.stdin,
) -> int:
options = parse_snapshot_args(argv)
if options.help:
stdout.write(help_text())
return 0
raw_payload = load_job_from_options(options, stdin=stdin, cwd=cwd)
packet = resolve_render_job_packet(raw_payload, cwd=cwd)
result = await render_resolved_job_packet(packet)
write_render_outputs(result)
print_render_result(result, json_output=options.json, stdout=stdout)
return 0
def run_render_cli(
argv: Sequence[str],
*,
cwd: Path | None = None,
stdout: Any = sys.stdout,
stderr: Any = sys.stderr,
stdin: Any = sys.stdin,
) -> int:
try:
return asyncio.run(run_render_cli_async(argv, cwd=cwd, stdout=stdout, stdin=stdin))
except SnapshotError as exc:
stderr.write(f"{exc}\n")
return 1
except Exception as exc:
stderr.write(f"{exc}\n")
return 1
def main(argv: Sequence[str] | None = None) -> int:
return run_render_cli(list(sys.argv[1:] if argv is None else argv))
if __name__ == "__main__":
raise SystemExit(main())
scripts/snapshot/runtime/render.html
<!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>
scripts/step/__init__.py
"""Unified STEP generation CLI."""
scripts/step/__main__.py
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())
scripts/step/cli.py
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())