
Cad
- 6.8k installs
- 11k repo stars
- Updated July 11, 2026
- earthtojake/text-to-cad
Parametric CAD models (STEP/STP) with Python source, selector refs, measurements, and validated snapshots from natural-language specs or reference geometry.
About
text-to-cad is a Python-backed CAD generation and inspection toolkit that converts natural-language requirements, reference images, and 2D technical drawings into validated STEP-first parametric models via build123d source code. Developers use it to author mechanical parts, enclosures, fixtures, and assemblies with named parameters, source-level joints, and explicit positioning; the workflow emphasizes editing Python source rather than generated artifacts. Core operations include STEP/STP generation from build123d Python, direct STEP/STP imports, geometry inspection with selector refs and measurements, validation snapshots, and secondary exports to STL, 3MF, and native GLB. Assembly work leverages AssemblyHelper with named mating datums and build123d joints for functional relationships. --- name: cad description: Create, modify, inspect, and validate STEP-first parametric CAD parts and assemblies. Use for natural-language CAD specs, reference images, 2D technical drawings, STEP/STP generation or direct inspection, Python CAD source, source-level joints, selector references, geometry facts, measurements, mating deltas, snapshots, and secondary STL/3MF/native GLB outputs from CAD ge.
- STEP-first parametric CAD from build123d Python source with named parameters and verbose labels
- Natural-language CAD brief conversion: prose, reference images, 2D technical drawings to geometry specs
- Geometry inspection via selector refs (e.g. #o1.2.f1), facts, planes, measurements, alignment, and diff validation
- Assembly positioning with source-level build123d joints, AssemblyHelper, named mating datums, and explicit Location tran
- Mandatory snapshot validation (PNG/GIF) after model creation or visible updates; secondary STL/3MF/GLB exports from STEP
Cad by the numbers
- 6,839 all-time installs (skills.sh)
- +881 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #61 of 1,896 Design & UI/UX skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Jul 28, 2026 (Skillselion catalog sync)
cad capabilities & compatibility
- Capabilities
- generate step/stp from natural language cad brie · inspect geometry via selector refs, facts, plane · measure dimensions, features, and mating deltas · validate assemblies with source level joints and · export to stl, 3mf, and native glb from step geo · generate deterministic snapshot validation (png/ · create parametric models with named parameters a
- Platforms
- macOS · Windows · Linux · WSL
- Runs
- Runs locally
- Pricing
- Free
What cad says it does
Create or modify parametric CAD models from natural-language requirements, generate validated STEP/STP artifacts, inspect geometry references, and return checked outputs.
Treat STEP as the primary CAD artifact. Treat STL, 3MF, and native GLB as secondary export workflows that branch from a STEP-first process.
npx skills add https://github.com/earthtojake/text-to-cad --skill cadAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 6.8k |
|---|---|
| repo stars | ★ 11k |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 11, 2026 |
| Repository | earthtojake/text-to-cad ↗ |
What it does
Generate, modify, and validate parametric 3D CAD parts and assemblies from natural-language specs, reference images, or technical drawings, outputting STEP/STP geometry with optional STL/3MF/GLB
Who is it for?
Parametric mechanical design, enclosures, fixtures, assemblies, reference-driven part modeling, geometry validation before manufacturing.
Skip if: Render-only concept art, CAM toolpaths, engineering FEA certification, architectural BIM, freehand illustration (unless CAD geometry also required), 2D DXF drawings (use $dxf skill).
When should I use this skill?
User asks for CAD files, STEP/STP, build123d source, mechanical parts, assemblies, holes, joints, measurements, or supplies reference images / technical drawings to reproduce.
What you get
Validated STEP/STP files with matching Python source, geometry facts, measurements, and snapshot reviews; optional STL/3MF/GLB sidecars.
- CAD model files
- STEP exports
- Validation results
By the numbers
- Default enclosure wall thickness: 2.0-3.0 mm when unspecified
- Cosmetic fillet range: 1.0-3.0 mm when safe for local geometry
- M3/M4/M5 normal clearance holes: 3.4/4.5/5.5 mm unless another standard requested
Files
CAD generation, inspection, and validation
Provenance: maintained in earthtojake/text-to-cad. Use the installed local skill files as the runtime source of truth; the repository link is only for provenance and release review.
Purpose
Create or modify parametric CAD models from natural-language requirements, generate validated STEP/STP artifacts, inspect geometry references, and return checked outputs. Treat STEP as the primary CAD artifact. Treat STL, 3MF, and native GLB as secondary export workflows that branch from a STEP-first process. For assemblies, prefer cadpy.assembly.AssemblyHelper with source-level build123d joints, named mating datums, and native labels when the parts have functional assembly relationships.
There are two ways into the STEP workflow: generate from build123d Python source (the default when designing from scratch or modifying a generated model), or import an existing STEP/STP file directly (when no generator exists or the user explicitly targets the STEP file). Both produce the same inspectable artifacts.
Use this skill when
Use this skill when the user asks for CAD files, STEP/STP files, build123d source, selector refs such as #o1.2.f1, mechanical parts, assemblies, enclosures, brackets, fixtures, holes, counterbores, countersinks, slots, pockets, bosses, standoffs, ribs, fillets, chamfers, shells, source-level joints, mating, or measurements. Also use it when the user supplies reference images or 2D technical drawings of a part to reproduce or take design intent from.
Also use it when the user asks for STL, 3MF, or native GLB output from CAD geometry. Keep those workflows secondary and load supported-exports.md for details. For 2D DXF drawings, use the $dxf skill; when a DXF projects from a 3D part, this skill owns the STEP geometry and $dxf owns the drawing.
Do not use this skill for render-only concept art, CAM toolpaths, engineering certification, FEA conclusions, architectural BIM, or freehand illustration unless the user also needs CAD geometry.
Default assumptions
Use these defaults unless the user specifies otherwise. These are first-pass modeling defaults, not manufacturability, tolerance, or certification claims:
- Units: millimeters.
- Origin: per the part-type defaults in
references/positioning.md; center of the main part or assembly when nothing better applies. - Base plane: XY.
- Up/extrusion axis: positive Z.
- Output geometry: closed, positive-volume solids unless the user requests surfaces or construction geometry.
- STEP structure: one valid solid, a compound of solids, or a labeled assembly compound.
- Assembly structure: fixed root part, part-local frames, named mating datums,
AssemblyHelperrelationships backed by build123d joints where applicable, explicit generated placements, and verbose native labels. - Small plastic enclosure wall: 2.0-3.0 mm when unspecified.
- Cosmetic fillet: 1.0-3.0 mm when safe for local geometry.
- M3/M4/M5 normal clearance holes: 3.4/4.5/5.5 mm unless another standard is requested.
Ask one focused clarification question only when missing information makes the model impossible, fit-critical, safety-critical, or compliance-bound. Otherwise proceed with explicit assumptions.
Tools and paths
From the CAD skill directory, the launcher shape is:
python scripts/step ... # STEP generation, GLB/topology artifacts, mesh sidecars
python scripts/inspect ... # refs, measure, align, frame, diff
python scripts/snapshot ... # PNG/GIF visual review packetsUse the active project Python interpreter; treat python in examples as an interpreter placeholder. Use python scripts/<tool> --help for the complete current command interface; reference docs show recommended workflows, not every flag.
Target paths resolve from the command's current working directory, not from the skill directory. Run commands from the workspace that owns the artifacts and pass cwd-relative target paths so project CAD files never resolve accidentally under the skill directory. Keep a STEP output and its Python generator in the same directory with the same basename unless the user explicitly requests otherwise.
CAD references are #... selector tokens local to a target, for example #o1.2 or #o1.2.f1. Pass the STEP/CAD file as a separate target argument when using CAD CLIs.
Required workflow
Scale depth to the task: a simple part needs a short brief and few spec-driven checks; assemblies and fit-critical work need full positioning and alignment validation.
1. Classify the task. New part, new assembly, source modification, direct STEP/STP inspection, reference selection, measurement/alignment check, snapshot review, or secondary output request. 2. Load only the needed references. Use the triggers below instead of reading the whole reference set. 3. Write a natural-language CAD brief. Extract dimensions, units, coordinate convention, feature intent, output paths, assumptions, and validation targets from all provided inputs — prose, reference images, technical drawings. Use references/cad-brief.md. 4. Check named purchasable components. When an assembly includes named off-the-shelf actuators, servos, motors, electronics boards, connectors, or other purchasable components, search $step-parts before creating simplified placeholder geometry. If no exact match is found, record the miss and then use a documented envelope. 5. Plan before coding. Define parameters, intent labels, source paths, expected bounding boxes, and any mating/positioning datums before editing. 6. Edit source, not generated artifacts. Author build123d Python with gen_step(). When a Python generator exists, run scripts/step on the generator, never on its exported STEP. Use direct STEP/STP targets (--kind part|assembly) only for imports with no generator or when the user explicitly identifies the STEP/STP file as the target. 7. Generate explicit targets. Run scripts/step on explicit targets only; do not run directory-wide generation. 8. Validate geometrically. Run scripts/inspect refs <step-or-cad-target> --facts --planes --positioning as the baseline, then verify the dimensions and relationships the user's spec calls out with targeted measure, align, frame, or diff checks. 9. Snapshot the primary STEP — snapshot validation is mandatory. After creating or visibly updating a primary STEP/STP part or assembly, ALWAYS run CAD scripts/snapshot against it and review the output; deterministic checks passing is not a reason to skip. The only skip cases are documented in references/snapshot-review.md (no visible geometry changed, or no valid artifact exists); report the reason when skipping. 10. Repair and rerun. If a check fails, change the smallest responsible source section, regenerate, and rerun the failed validation.
Handoff
After completing CAD work that creates or modifies .step, .stp, .stl, .3mf, or native .glb artifacts, you must ALWAYS hand the explicit file path(s) to $cad-viewer when that skill is installed. $cad-viewer must start CAD Viewer if it is not already running and return link(s) to the relevant created or updated file(s); include those live viewer link(s) in the final response. If $cad-viewer is unavailable or startup fails, report that and rely on CLI inspection plus snapshots instead of silently omitting the handoff. This rule applies to every workflow in this skill, including secondary STL/3MF/GLB outputs.
When verification snapshots are generated, include the saved PNG/GIF snapshot(s) in the final response. If no snapshot applies, or if snapshot generation fails, say why and report the deterministic validation that still ran.
Non-negotiables
- Keep STEP as the primary validated CAD artifact. Generated STEP/STP, STL, 3MF, GLB/topology outputs, and render sidecars are derived artifacts; STL/3MF are secondary unless the user explicitly says otherwise.
- Use named parameters, closed solids, verbose native build123d labels, and source-controlled geometry intent.
- Author assembly positioning in source.
references/positioning.mdis authoritative forAssemblyHelper, build123d joints, explicitLocationtransforms, and alignment validation. - Do not use
git status,git diff, or file-size churn as CAD comparison for large exported STEP/STP, GLB/topology, STL, or 3MF artifacts. Compare source changes,scripts/inspectsummaries, snapshots, or generated topology output instead; use path-limited git status only for bookkeeping. - Report only checks that actually ran or are directly supported by tool output.
Progressive references
Load these files only when their trigger applies:
references/cad-brief.md— converting prose, reference images, and technical drawings into a CAD brief.references/build123d-modeling.md— build123d modeling patterns, topology, selectors, features, labels.references/step-generation.md— STEP generation from Python source, direct STEP/STP imports, and post-generation steps.references/inspection-and-validation.md— validation sequence, selector refs, facts, planes, measurements, alignment, diff, frame, and validation reporting.references/snapshot-review.md— mandatory snapshot policy, packet sizing, targeted views, and converting visual findings into geometry checks.references/positioning.md— part-local datums and origins, assembly transforms, build123d joints, CLI alignment validation, and positioning reports.references/parameters.md— parameterizing or animating a STEP model: source parameters,.step.jssidecar modules, viewer controls, and animation design.references/supported-exports.md— secondary STL/3MF/native GLB sidecar workflows.references/repair-loop.md— diagnosis and repair procedures.
Final responses should include generated files, returned $cad-viewer viewer links, verification snapshots, validation actually run, assumptions, and caveats. Use references/inspection-and-validation.md for report structure.
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."
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.
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.mdfor 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:
Vertex → Edge → Wire → Face → Shell → Solid → CompoundFor 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
lidorm3_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:
width = 80.0
depth = 50.0
thickness = 6.0
hole_diameter = 4.5
hole_offset_x = 30.0
hole_offset_y = 17.5Avoid burying important numbers inside geometry calls.
Coordinate system
Declare or comment the convention:
Origin: center of primary part or chosen mating datum
XY: main base/sketch plane
+Z: up/extrusion directionUse 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:
with BuildLine() as path:
...
with BuildSketch() as profile:
...
with BuildPart() as part:
...Typical flow:
curves/paths → sketches/profiles → solids/features → labels → STEPSelection 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 explicitLocationtransforms connect_to()behavior- CLI
inspect alignas 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:
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.
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/refsafter generation or explicitly reported as not verified.
Brief format
Use concise Markdown notes, not a user-facing structured schema:
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:
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:
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:
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:
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
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:
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:
path/to/entry
path/to/entry.stepSelector refs are local to the STEP/CAD entry target passed to the command. They do not include file paths:
#o1.2
#o1.2.f1
#f1Pass 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:
python scripts/inspect refs path/to/model.step \
--facts --planes --positioningDetailed selector inspection:
python scripts/inspect refs path/to/model.step '#selector' \
--detail --positioningTopology enumeration, only when needed:
python scripts/inspect refs path/to/model.step --topologyPlane options:
--plane-coordinate-tolerance FLOAT
--plane-min-area-ratio FLOAT
--plane-limit INTUse 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.
python scripts/inspect measure path/to/model.step \
--from '#selector_a' \
--to '#selector_b' \
--axis xAxis 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.
python scripts/inspect align path/to/assembly.step \
--moving '#moving_selector' \
--target '#target_selector' \
--mode flush \
--axis zFrame inspection
Use frame to validate occurrence transforms and selected-reference world frames:
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:
python scripts/inspect diff path/to/before.step path/to/after.step --planesUse 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:
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 findingsDo not claim:
- structural safety
- process certification
- tolerance compliance
- manufacturability beyond geometric plausibility
unless the relevant analysis or manufacturing data was explicitly performed.
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_mmsuffixes. - 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, anddurationSecondsare 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:
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
minandmax. - Make
stepmatch 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.featureswith stable local refs such as#o1.2; keep file identity inmanifest.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 --positioningfor scale, labels, frames, and major datums.scripts/inspect frame,measure, oralignfor 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.
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 asface_to_face,coaxial,revolute, andlinear, then realizes them with native build123d joints. - build123d joints are source-level objects such as
RigidJoint,RevoluteJoint,LinearJoint,CylindricalJoint, andBallJoint. They are attached toSolidorCompoundobjects and can reposition parts withconnect_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:
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 validationA 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:
- 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:
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.
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:
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:
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 anAxisand drive with an angle parameter for a static STEP pose.LinearJoint/asm.linear_frame(): slider, latch, telescoping component; define with anAxisand 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 aLocationand 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):
python scripts/step path/to/assembly.py
python scripts/inspect refs path/to/assembly.step --facts --planes --positioningCLI alignment validation
After generation, select moving and target refs from the local selector refs returned by refs --positioning and compute deltas:
python scripts/inspect align path/to/assembly.step \
--moving '#moving_selector' \
--target '#target_selector' \
--mode flush \
--axis zUse --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:
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:
python scripts/inspect measure path/to/assembly.step \
--from '#selector_a' \
--to '#selector_b' \
--axis zExamples:
- 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
Locationtranslation - child
Locationrotation AssemblyHelperrelationship 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:
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 centerlineIf no positioning-sensitive features exist, say:
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.
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 --positioningto 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, thenframeandalignon the relevant selectors - verify the source-level
AssemblyHelpertarget order, joint labels, andjoint_locationdefinitions - 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-viewerwith the same absolute?dir=for the project and an absolutefile=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/.stpartifact - retry only with simpler supported snapshot jobs, starting with a single
viewoutput before wireframe display orsection - choose modes and packet size per
snapshot-review.md
Diff after repair
Use diff when the fix might have affected unrelated geometry:
python scripts/inspect diff path/to/before.step path/to/after.step --planesReporting failed repairs
If a check cannot be repaired in the current environment, report:
- what failed
- what was tried
- which artifact is still usable
- which validation claims cannot be made
- what the next source-level correction should beSnapshot 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:
{
"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 relationshipdisplay.mode: "solid": shaded CAD view with explicit edge lineworkdisplay.mode: "rendered": shaded material view without edge overlaydisplay.mode: "transparent": overlap, collision, enclosure readability, or hidden contact checks when transparency adds information and wireframe is too noisydisplay.mode: "hidden_edges": opaque shaded context with hidden/occluded CAD edges visible through solidsdisplay.mode: "hidden_lines_removed": line-focused review where hidden/occluded edges should be suppresseddisplay.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.
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:
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:
def gen_step():
...
return step_ready_shape_or_labeled_compoundGenerated Python targets infer their kind from the source metadata and gen_step() return value; pass the source path directly:
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.pyPassing 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:
python scripts/step --kind part path/to/imported.stepDirect 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:
python scripts/inspect refs path/to/model.step --facts --planes --positioningSupported 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:
python scripts/step path/to/model.py \
--stl meshes/model.stl \
--3mf meshes/model.3mf \
--glb meshes/model.glbWhen 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:
python scripts/step --kind part path/to/model.step \
--stl meshes/model.stl \
--3mf meshes/model.3mf \
--glb meshes/model.glbSidecar 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:
--mesh-tolerance FLOAT
--mesh-angular-tolerance FLOATUse 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:
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 --positioningReporting
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.--editable ./scripts/packages/cadpy
playwright
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())
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())
"""Selector reference inspection helpers."""
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())
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
"""Wrapper package for the CAD skill snapshot command."""
"""Unified STEP generation CLI."""
Related skills
How it compares
Choose cad over image-generation skills when the deliverable must be measurable geometry files such as STEP rather than visual renders.
FAQ
What is the primary CAD artifact?
STEP/STP files are primary; STL, 3MF, and GLB are secondary exports. Always edit and validate the Python generator source, not the exported STEP.
Do I need to run snapshot validation?
Yes - snapshot validation is mandatory after creating or visibly updating a primary STEP/STP part or assembly unless documented in references/snapshot-review.md (no visible geometry changed or no valid artifact exists).
How do I define assembly positioning?
Author positioning in Python source using AssemblyHelper, build123d joints, named mating datums, explicit Location transforms, and verbose native labels. Use references/positioning.md for authoritative patterns.
Is Cad safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.