├── .ci
└── test-examples.py
├── .github
├── CODEOWNERS
├── media
│ └── readme_screenshot.png
└── workflows
│ ├── python-publish.yml
│ └── validate.yml
├── .gitignore
├── LICENSE
├── README.md
├── examples
├── 00-cylinder
│ └── cylinder.py
├── 01-cup
│ └── cup.ipynb
└── 02-lamp
│ └── lamp.py
├── guide.md
├── pyproject.toml
├── src
└── onpy
│ ├── __init__.py
│ ├── api
│ ├── __init__.py
│ ├── endpoints.py
│ ├── rest_api.py
│ ├── schema.py
│ └── versioning.py
│ ├── client.py
│ ├── document.py
│ ├── elements
│ ├── __init__.py
│ ├── assembly.py
│ ├── base.py
│ └── partstudio.py
│ ├── entities
│ ├── __init__.py
│ ├── entities.py
│ ├── filter.py
│ ├── protocols.py
│ └── queries.py
│ ├── features
│ ├── __init__.py
│ ├── base.py
│ ├── extrude.py
│ ├── loft.py
│ ├── planes.py
│ ├── sketch
│ │ ├── __init__.py
│ │ ├── sketch.py
│ │ └── sketch_items.py
│ └── translate.py
│ ├── part.py
│ └── util
│ ├── __init__.py
│ ├── credentials.py
│ ├── exceptions.py
│ └── misc.py
└── tests
├── test_documents.py
└── test_features.py
/.ci/test-examples.py:
--------------------------------------------------------------------------------
1 | """Run the examples in examples/ and check for a good status code"""
2 |
3 | import os
4 | from pathlib import Path
5 | import subprocess
6 | import sys
7 |
8 | from loguru import logger
9 |
10 |
11 | examples_path = Path(__file__).parent.parent.joinpath("examples").resolve()
12 |
13 |
14 | examples: list[Path] = []
15 | for dir in examples_path.iterdir():
16 | if dir.is_dir():
17 | examples.extend(
18 | [
19 | f
20 | for f in dir.iterdir()
21 | if f.suffix in (".ipynb", ".py") and not f.name.endswith("-tmp.py")
22 | ]
23 | )
24 |
25 | # Use nbconvert to convert notebooks into python files
26 | for example in examples:
27 |
28 | if example.suffix != ".ipynb":
29 | continue
30 |
31 | logger.info(f"Converting '{example.name}' to python.")
32 |
33 | output = example.name.replace(".ipynb", "-tmp.py")
34 |
35 | process = subprocess.run(
36 | [
37 | "jupyter",
38 | "nbconvert",
39 | "--to",
40 | "python",
41 | str(example.resolve()),
42 | "--output",
43 | output,
44 | ],
45 | check=False,
46 | )
47 |
48 | if process.returncode != 0:
49 | logger.error(f"Failed to convert notebook '{example.name}' to python")
50 | sys.exit(1)
51 |
52 | logger.info(f"\n\nTesting the following files:\n{[f.name for f in examples]}\n\n")
53 |
54 | for example in examples:
55 |
56 | filename = (
57 | example.name
58 | if example.suffix == ".py"
59 | else example.name.replace(".ipynb", "-tmp.py")
60 | )
61 | file = example.parent.joinpath(filename)
62 |
63 | logger.debug(f"Running file '{filename}'...")
64 |
65 | # change dir into example dir
66 | process = subprocess.run(
67 | [sys.executable, file.resolve()], check=False, env=os.environ
68 | )
69 | if process.returncode != 0:
70 | logger.error(f"Error in example '{example}'")
71 | exit(1)
72 |
73 | else:
74 | logger.debug(f"Test passed with code {process.returncode}")
75 | if file.name.endswith("-tmp.py"):
76 | file.unlink()
77 |
78 |
79 | exit(0)
80 |
--------------------------------------------------------------------------------
/.github/CODEOWNERS:
--------------------------------------------------------------------------------
1 | * @kyle-tennison
--------------------------------------------------------------------------------
/.github/media/readme_screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kyle-tennison/onpy/7cfb61b6f58ecbb0c88291b76d1dc8f81a6fde7d/.github/media/readme_screenshot.png
--------------------------------------------------------------------------------
/.github/workflows/python-publish.yml:
--------------------------------------------------------------------------------
1 | # This workflow will upload a Python Package using Twine when a release is created
2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries
3 |
4 | # This workflow uses actions that are not certified by GitHub.
5 | # They are provided by a third-party and are governed by
6 | # separate terms of service, privacy policy, and support
7 | # documentation.
8 |
9 | name: Upload Python Package
10 |
11 | on:
12 | release:
13 | types: [published]
14 |
15 | permissions:
16 | contents: read
17 |
18 | jobs:
19 | deploy:
20 | runs-on: ubuntu-latest
21 |
22 | steps:
23 | - uses: actions/checkout@v4
24 | - name: Set up Python
25 | uses: actions/setup-python@v3
26 | with:
27 | python-version: "3.12"
28 | - name: Install dependencies
29 | run: |
30 | python -m pip install --upgrade pip
31 | python -m pip install -e '.[dev]'
32 | python -m pip install build
33 |
34 | - name: Build package
35 | run: python -m build
36 | - name: Publish package
37 | uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29
38 | with:
39 | user: __token__
40 | password: ${{ secrets.PYPI_API_TOKEN }}
41 |
--------------------------------------------------------------------------------
/.github/workflows/validate.yml:
--------------------------------------------------------------------------------
1 | name: ci tests
2 |
3 | on:
4 | pull_request:
5 | push:
6 | branches: ["main"]
7 |
8 | jobs:
9 | validate:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - name: Checkout
13 | uses: actions/checkout@v3
14 |
15 | - name: Install Python 3.12
16 | uses: astral-sh/setup-uv@v5
17 | with:
18 | python-version: "3.12"
19 |
20 | - name: Install dependencies
21 | run: uv pip install -e '.[dev]'
22 |
23 | - name: Check Linting
24 | run: |
25 | ruff format --check
26 | ruff check .
27 | pyright .
28 |
29 | - name: Run Pytest
30 | run: pytest -s tests/
31 | env:
32 | ONSHAPE_DEV_SECRET: ${{ secrets.ONSHAPE_DEV_SECRET }}
33 | ONSHAPE_DEV_ACCESS: ${{ secrets.ONSHAPE_DEV_ACCESS }}
34 |
35 | - name: Test examples
36 | run: python .ci/test-examples.py
37 | env:
38 | ONSHAPE_DEV_SECRET: ${{ secrets.ONSHAPE_DEV_SECRET }}
39 | ONSHAPE_DEV_ACCESS: ${{ secrets.ONSHAPE_DEV_ACCESS }}
40 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .vscode
2 | __pycache__/
3 | *.DS_Store
4 | *-tmp.py
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2024 Kyle Tennison
2 |
3 | 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:
4 |
5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6 |
7 | 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.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # OnPy
2 |
3 | [](https://github.com/kyle-tennison/onpy/actions/workflows/validate.yml)
4 | [](https://opensource.org/license/mit)
5 | [](https://pypi.org/project/onpy/)
6 | [](https://github.com/psf/black)
7 |
8 | ## Overview
9 |
10 | **OnPy is an unofficial Python API for building 3D models in [Onshape](https://onshape.com).**
11 |
12 | With OnPy, you can:
13 |
14 | - Build 2D sketches
15 | - Extrude to create 3D geometries
16 | - Interface with other Onshape features
17 |
18 | ## Installation & Authentication
19 |
20 | Install OnPy using pip:
21 |
22 | ```bash
23 | pip install onpy
24 | ```
25 |
26 | The first time you run OnPy, you will need to load your [Onshape developer keys](https://dev-portal.onshape.com/keys). OnPy will automatically prompt you for these keys. You can trigger this dialogue manually with:
27 |
28 | ```bash
29 | python -c "from onpy import Client; Client()"
30 | ```
31 |
32 | You’ll then be prompted to provide your keys:
33 |
34 | ```plaintext
35 | OnPy needs your Onshape credentials.
36 | Navigate to https://dev-portal.onshape.com/keys and generate a pair of access & secret keys. Paste them here when prompted:
37 |
38 | Secret key: ...
39 | Access key: ...
40 | ```
41 |
42 | Alternatively, you can set your Onshape keys as environment variables:
43 |
44 | - `ONSHAPE_DEV_SECRET` - Your developer secret key
45 | - `ONSHAPE_DEV_ACCESS` - Your developer access key
46 |
47 | ### What is OnPy for?
48 |
49 | OnPy is a high-level Python API for Onshape. It enables third-party apps to use the same features as the Onshape modeler directly from Python.
50 |
51 | Onshape natively supports [FeatureScript](https://cad.onshape.com/FsDoc/), a scripting language for defining Onshape features. FeatureScript is powerful, and many of its strengths are leveraged in this package. However, it is designed to define individual features and cannot parametrically generate entire designs.
52 |
53 | OnPy bridges this gap by interfacing with Onshape's APIs to create designs equivalent to those made in the web UI.
54 |
55 | ### Disclaimer
56 |
57 | OnPy connects to your Onshape account using developer keys that you provide, which are generated via Onshape's [developer portal](https://cad.onshape.com/appstore/dev-portal). By generating and using these keys, you agree to [Onshape's Terms of Use](https://www.onshape.com/en/legal/terms-of-use).
58 |
59 | When using OnPy, all API calls to Onshape are made on your behalf. As a user, you are responsible for adhering to Onshape's Terms of Use and accept full accountability for any actions taken through OnPy.
60 |
61 | OnPy is provided "as is," without warranty of any kind, either express or implied. It assumes no responsibility for any violations of Onshape's Terms of Use arising from its use. Additionally, OnPy is not liable for any consequences, damages, or losses resulting from misuse of the API or non-compliance with Onshape's Terms of Use.
62 |
63 | By using OnPy, you agree to these terms and accept all associated risks and liabilities.
64 |
65 | ### Syntax Overview
66 |
67 | The following example is from [`examples/cylinder.py`](examples/cylinder.py):
68 |
69 | ```python
70 | import onpy
71 |
72 | # Create a new document, then reference the default Part Studio
73 | document = onpy.create_document("Cylinder Example")
74 | partstudio = document.get_partstudio()
75 |
76 | # Define a sketch
77 | sketch = partstudio.add_sketch(
78 | plane=partstudio.features.top_plane, # Define the plane to draw on
79 | name="New Sketch", # Name the sketch
80 | )
81 |
82 | # Draw a circle in the sketch
83 | sketch.add_circle(center=(0, 0), radius=0.5) # Default units are inches
84 |
85 | # Extrude the sketch
86 | extrude = partstudio.add_extrude(
87 | faces=sketch, # Extrude the entire sketch ...
88 | distance=1, # ... by one inch
89 | )
90 | ```
91 |
92 | After running the above code, you’ll see a new document in your browser, aptly named "Cylinder Example."
93 |
94 | 
95 |
96 | ## User Guide
97 |
98 | For a more in-depth guide on how to use OnPy, refer to the [user guide](/guide.md). It provides a detailed explanation of all of OnPy's features.
99 |
100 | ## Contributing
101 |
102 | OnPy is still in its earliest stages, and all contributions are welcome.
103 |
104 | This module is designed to be as idiomatic as possible while following some of Onshape's layout quirks. There are no strict rules for contributing, but it’s a good idea to align with the existing structure.
--------------------------------------------------------------------------------
/examples/00-cylinder/cylinder.py:
--------------------------------------------------------------------------------
1 | """
2 |
3 | In this example, we draw a circle, then extrude it.
4 |
5 | """
6 |
7 | import onpy
8 |
9 | # we'll create a new document, then reference the default partstudio
10 | document = onpy.create_document("Cylinder Example")
11 | partstudio = document.get_partstudio()
12 |
13 | # now, we'll define a sketch
14 | sketch = partstudio.add_sketch(
15 | plane=partstudio.features.top_plane, # we define the plane to draw on
16 | name="New Sketch", # and we name the sketch
17 | )
18 |
19 | # in this new sketch, we'll draw a circle
20 | sketch.add_circle(center=(0, 0), radius=0.5) # the default units are inches
21 |
22 | # next, we'll extrude the sketch. the syntax is similar
23 | extrude = partstudio.add_extrude(
24 | faces=sketch, # we'll extrude the entire sketch ...
25 | distance=1, # ... by one inch
26 | )
27 |
--------------------------------------------------------------------------------
/examples/02-lamp/lamp.py:
--------------------------------------------------------------------------------
1 | import onpy
2 |
3 | document = onpy.create_document("Lamp Example")
4 | partstudio = document.get_partstudio()
5 | partstudio.wipe()
6 |
7 |
8 | # Create the lamp base
9 | lower_base_sketch = partstudio.add_sketch(
10 | plane=partstudio.features.top_plane, name="Base Bottom Sketch"
11 | )
12 | lower_base_sketch.add_corner_rectangle(corner_1=(-2.5, -2.5), corner_2=(2.5, 2.5))
13 |
14 | # Create an offset plane 1.25 inches above the lower base sketch
15 | upper_base_plane = partstudio.add_offset_plane(
16 | target=partstudio.features.top_plane, distance=1.25, name="Upper Base Plane"
17 | )
18 |
19 | # Then, make a new sketch on this new plane
20 | upper_base_sketch = partstudio.add_sketch(
21 | plane=upper_base_plane, name="Upper Base Sketch"
22 | )
23 |
24 | # Add a slightly smaller rectangle on this elevated plane
25 | upper_base_sketch.add_corner_rectangle(corner_1=(-2.25, -2.25), corner_2=(2.25, 2.25))
26 |
27 | # Then, create a loft to blend the two base profiles to get a model bottom
28 | loft = partstudio.add_loft(
29 | start=lower_base_sketch, end=upper_base_sketch, name="Base Loft"
30 | )
31 |
32 | # Next, create a sketch with a circle to represent the lamp rod
33 | rod_profile = partstudio.add_sketch(plane=partstudio.features.top_plane)
34 | rod_profile.add_circle(center=(0, 0), radius=0.5)
35 |
36 | # First, cut a hole through the base
37 | partstudio.add_extrude(
38 | faces=rod_profile,
39 | distance=2, # a bit excess, just to be safe
40 | subtract_from=loft.get_created_parts()[0],
41 | )
42 |
43 | # Then, extrude up 15 inches
44 | rod_extrude = partstudio.add_extrude(faces=rod_profile, distance=15, name="Lamp Rod")
45 | rod = rod_extrude.get_created_parts()[0] # save a reference to the part
46 |
47 | # Get a reference to the top of the rod
48 | top_of_rod = rod.faces.closest_to((0, 0, 100))
49 |
50 | # ... then, create a new sketch off of it
51 | lampshade_top = partstudio.add_sketch(plane=top_of_rod, name="Lampshade Top Profile")
52 |
53 | # Go down 4.5 inches and create another sketch
54 | lampshade_bottom = partstudio.add_sketch(
55 | plane=partstudio.add_offset_plane(target=lampshade_top.plane, distance=-4.5),
56 | name="Lampshade Bottom Profile",
57 | )
58 |
59 | # Add a small circle on top and a larger one on the bottom to create a taper
60 | lampshade_top.add_circle(center=(0, 0), radius=3)
61 | lampshade_bottom.add_circle(center=(0, 0), radius=4)
62 |
63 | # Loft between the two profiles to create a lampshade
64 | lampshade_loft = partstudio.add_loft(
65 | start=lampshade_bottom, end=lampshade_top, name="Lampshade Loft"
66 | )
67 |
--------------------------------------------------------------------------------
/guide.md:
--------------------------------------------------------------------------------
1 | # OnPy
2 |
3 | **A comprehensive, all-in-one guide to modeling in OnPy**
4 |
5 | ## What is OnPy & What is it for?
6 |
7 | OnPy is a Python API for creating 3D models in OnShape, a popular cloud-based CAD tool. OnShape is a parametric tool, meaning that parts are built via a series of _features_. This concept is fundamental to parametric modeling.
8 |
9 | OnPy provides the ability to define basic features in Python. Currently, many features are not supported, so all modeling must happen through a few basic features.
10 |
11 | OnPy is _not_ intended to create complex designs; it functions entirely to provide a simple interface to OnShape through python. Do not attempt to make complex models using OnPy.
12 |
13 | ## OnShape Documents
14 |
15 | OnShape documents contain everything related to a project. In OnPy, you can fetch or create documents with the following syntax:
16 |
17 | ```python
18 | import onpy
19 |
20 | document = onpy.create_document("my document") # creates a new document
21 |
22 | document = onpy.get_document(name="my document") # get a document by name
23 |
24 | document = onpy.get_document("5aaf074sd1sabcc2bebb6ecf") # or, reference by id
25 | ```
26 |
27 | ## OnShape Elements
28 |
29 | In OnShape, there are a few types of elements, all of which are contained under a single document. If you are familiar with the GUI, elements are effectively the tabs at the bottom of the window. Most commonly, these are partstudios and assemblies, however, they can take other forms.
30 |
31 | Because OnPy is intended for simple models, it currently only supports partstudios.
32 |
33 | ### The Partstudio
34 |
35 | In OnShape, the partstudio is the element where all part modeling takes place.
36 | Here, you can control parts and features. A document can have any number of partstudios, but there is usually only one. To get the default partstudio, you can use:
37 |
38 | ```python
39 | partstudio = document.get_partstudio()
40 | ```
41 |
42 | This partstudio will be the entry to everything else that happens in OnPy.
43 |
44 | ## Creating Models
45 |
46 | ### Overview
47 |
48 | As mentioned, in OnPy, only a select number of features are supported. These will be detailed soon. In general, however, use this following logic to guide your project development:
49 |
50 | Firstly, start with sketches. Sketches house 2D geometries and are always the starting point for 3D geometries. In OnPy, there are no sketch constraints. Whatever is defined in OnPy code is immutable.
51 |
52 | Depending on the circumstance, you can move sketches to other planes using offset planes. These are very useful and can be used extensively. They do not interfere with future geometry in any way.
53 |
54 | After you have created a sketch, perform some 3D operation on it. Currently, OnPy only supports extrusions and lofts, but these are powerful in their own right. If your sketch has multiple regions, you can query them based on their properties. Queries will be discussed later.
55 |
56 | After you have created a 3D object, also known as a Part, you can perform additional queries and operations on it, such as translate (Transform/Translate by XYZ). You can reference part faces, edges, vertices, etc. in order to define additional sketches.
57 |
58 | ## Defining a Sketch
59 |
60 | Sketches are features that belong to a partstudio. We can create a sketch on our partstudio with:
61 |
62 | ```python
63 | partstudio.add_sketch(plane=partstudio.features.top_plane, name="My Sketch")
64 | ```
65 |
66 | Here, we provide the `.add_sketch` method two parameters: a plane to define the plane on, and an optional name for the feature.
67 |
68 | > Feature names are always optional. They are visible in the UI and are generally good practice, but do not actually influence the model.
69 |
70 | Because we haven't defined any other features yet, we need to select one of the **default planes** to define our first sketch on. In OnShape, there are three default planes, all orthogonal to each other:
71 |
72 | 1. The top plane, on the x-y axis
73 | 2. The front plane, on the x-z axis
74 | 3. The right plane, on the y-z axis
75 |
76 | We can get a reference to these planes with:
77 |
78 | ```python
79 | partstudio.features.top_plane
80 | partstudio.features.front_plane
81 | partstudio.features.right_plane
82 | ```
83 |
84 | In the example above, we create a sketch on the top plane, i.e., on the x-y plane.
85 |
86 | ### Sketch Items
87 |
88 | We build a sketch by using a series of `SketchItem`s. Sketches are made up of:
89 |
90 | 1. Line segmnets
91 | 2. Circles
92 | 3. Arcs
93 |
94 | > Note, in future releases of OnPy, more sketch items will be added.
95 |
96 | From a `Sketch` object, we can add these different objects with a few different methods.
97 |
98 | The following are the function signatures for the methods on the sketch object:
99 |
100 | ```python
101 | def add_circle(
102 | self,
103 | center: tuple[float, float],
104 | radius: float,
105 | units: UnitSystem | None = None,
106 | ) -> SketchCircle:
107 | """Adds a circle to the sketch
108 |
109 | Args:
110 | center: An (x,y) pair of the center of the circle
111 | radius: The radius of the circle
112 | units: An optional other unit system to use
113 | """
114 | ...
115 |
116 |
117 | def add_line(
118 | self, start: tuple[float, float], end: tuple[float, float]
119 | ) -> SketchLine:
120 | """Adds a line to the sketch
121 |
122 | Args:
123 | start: The starting point of the line
124 | end: The ending point of the line
125 | """
126 | ...
127 |
128 | def add_centerpoint_arc(
129 | self,
130 | centerpoint: tuple[float, float],
131 | radius: float,
132 | start_angle: float,
133 | end_angle: float,
134 | ) -> SketchArc:
135 | """Adds a centerpoint arc to the sketch
136 |
137 | Args:
138 | centerpoint: The centerpoint of the arc
139 | radius: The radius of the arc
140 | start_angle: The angle to start drawing the arc at
141 | end_angle: The angle to stop drawing the arc at
142 | """
143 | ...
144 |
145 | def add_fillet(
146 | self,
147 | line_1: SketchLine,
148 | line_2: SketchLine,
149 | radius: float,
150 | ) -> SketchArc:
151 | """Creates a fillet between two lines by shortening them and adding an
152 | arc in between. Returns the added arc.
153 |
154 | Args:
155 | line_1: Line to fillet
156 | line_2: Other line to fillet
157 | radius: Radius of the fillet
158 |
159 | Returns
160 | A SketchArc of the added arc. Updates line_1 and line_2
161 | """
162 | ...
163 |
164 |
165 | def trace_points(
166 | self, *points: tuple[float, float], end_connect: bool = True
167 | ) -> list[SketchLine]:
168 | """Traces a series of points
169 |
170 | Args:
171 | points: A list of points to trace. Uses list order for line
172 | end_connect: Connects end points of the trace with an extra segment
173 | to create a closed loop. Defaults to True.
174 |
175 | Returns:
176 | A list of the added SketchLine objects
177 | """
178 | ```
179 |
180 | These functions are the primary tools to build sketches, and they should
181 | be used extensively.
182 |
183 | However, sometimes complex math is required to describe relationships in sketches.
184 | It would be error prone to attempt to line up values manually, so OnPy
185 | provides a set of tools that allows some basic operations:
186 |
187 | These are:
188 |
189 | 1. Mirroring
190 | 2. Translations
191 | 3. Rotations
192 | 4. Linear Pattern
193 | 5. Circular Pattern
194 |
195 | You can use these with the following functions. The signatures are shown:
196 |
197 | ```python
198 | def mirror[T: SketchItem](
199 | self,
200 | items: Sequence[T],
201 | line_point: tuple[float, float],
202 | line_dir: tuple[float, float],
203 | copy: bool = True,
204 | ) -> list[T]:
205 | """Mirrors sketch items about a line
206 |
207 | Args:
208 | items: Any number of sketch items to mirror
209 | line_point: Any point that lies on the mirror line
210 | line_dir: The direction of the mirror line
211 | copy: Whether or not to save a copy of the original entity. Defaults
212 | to True.
213 |
214 | Returns:
215 | A lit of the new items added
216 | """
217 | ...
218 |
219 | def rotate[T: SketchItem](
220 | self,
221 | items: Sequence[T],
222 | origin: tuple[float, float],
223 | theta: float,
224 | copy: bool = False,
225 | ) -> list[T]:
226 | """Rotates sketch items about a point
227 |
228 | Args:
229 | items: Any number of sketch items to rotate
230 | origin: The point to pivot about
231 | theta: The degrees to rotate by
232 | copy: Whether or not to save a copy of the original entity. Defaults
233 | to False.
234 |
235 | Returns:
236 | A lit of the new items added
237 | """
238 | ...
239 |
240 | def translate[T: SketchItem](
241 | self, items: Sequence[T], x: float = 0, y: float = 0, copy: bool = False
242 | ) -> list[T]:
243 | """Translates sketch items in a cartesian system
244 |
245 | Args:
246 | items: Any number of sketch items to translate
247 | x: The amount to translate in the x-axis
248 | y: The amount to translate in the y-axis
249 | copy: Whether or not to save a copy of the original entity. Defaults
250 | to False.
251 |
252 | Returns:
253 | A lit of the new items added
254 | """
255 | ...
256 |
257 | def circular_pattern[T: SketchItem](
258 | self, items: Sequence[T], origin: tuple[float, float], num_steps: int, theta: float
259 | ) -> list[T]:
260 | """Creates a circular pattern of sketch items
261 |
262 | Args:
263 | items: Any number of sketch items to include in the pattern
264 | num_steps: The number of steps to take. Does not include original position
265 | theta: The degrees to rotate per step
266 |
267 | Returns:
268 | A list of the entities that compose the circular pattern, including the
269 | original items.
270 | """
271 | ...
272 |
273 | def linear_pattern[T: SketchItem](
274 | self, items: Sequence[T], num_steps: int, x: float = 0, y: float =0
275 | ) -> list[T]:
276 | """Creates a linear pattern of sketch items
277 |
278 | Args:
279 | items: Any number of sketch items to include in the pattern
280 | num_steps: THe number of steps to take. Does not include original position
281 | x: The x distance to travel each step. Defaults to zero
282 | y: The y distance to travel each step. Defaults to zero
283 |
284 | Returns:
285 | A list of the entities that compose the linear pattern, including the
286 | original item.
287 | """
288 | ...
289 | ```
290 |
291 | The general idea is to pass sketch items that were previously created and
292 | perform operations on them. When performing operations, you can use the
293 | copy keyword argument to tell OnPy if it should copy the items (and perform
294 | the operation on the copy), or if it should modify the original item.
295 |
296 | Consider the following examples:
297 |
298 | 1. Here, we use the `translate`, and `mirror` operations on SketchItems
299 | created by `add_centerpoint_arc` and `add_line` to create two hearts.
300 |
301 | ```python
302 | sketch = partstudio.add_sketch(plane=partstudio.features.top_plane, name="Heart Sketch")
303 |
304 | # Define a line and an arc
305 | line = sketch.add_line((0,-1), (1, 1))
306 | arc = sketch.add_centerpoint_arc(centerpoint=(0.5, 1), radius=0.5, start_angle=0, end_angle=180)
307 |
308 | # Mirror across vertical axis
309 | new_items = sketch.mirror(items=[line, arc], line_point=(0,0), line_dir=(0,1))
310 |
311 | # Translate and copy the entire heart
312 | sketch.translate(
313 | items=[*new_items, line, arc],
314 | x=3,
315 | y=0,
316 | copy=True
317 | )
318 |
319 | # Extrude the heart closest to the origin
320 | heart_extrude = partstudio.add_extrude(sketch.faces.closest_to((0,0,0)), distance=1, name="Heart extrude")
321 | ```
322 |
323 | 2. In this next example, we use the `circular_pattern` tool so create a hexagon,
324 | and then we use the `add_fillet` method to add fillets on the corners of the
325 | hexagon.
326 |
327 | ```python
328 | import math
329 | sketch = partstudio.add_sketch(plane=partstudio.features.top_plane, name="Hexagon Profile")
330 |
331 | # Keep a list of hexagon sides
332 | sides = []
333 |
334 | # A side of a unit hexagon can be defined as:
335 | hexagon_side = sketch.add_line(
336 | (-0.5, math.sqrt(3)/2),
337 | (0.5, math.sqrt(3)/2),
338 | )
339 |
340 | # Rotate and copy this side five times to create the full hexagon
341 | sides = hexagon_side.circular_pattern(origin=(0,0), num_steps=5, theta=60)
342 |
343 | # Fillet between each side
344 | for i in range(len(sides)):
345 | side_1 = sides[i-1]
346 | side_2 = sides[i]
347 |
348 | sketch.add_fillet(side_1, side_2, radius=0.1)
349 |
350 | # Add a hole in the middle of the hexagon
351 | sketch.add_circle(center=(0,0), radius=5/32)
352 |
353 | # Extrude the part of the sketch that isn't the hole
354 | partstudio.add_extrude(sketch.faces.largest(), distance=3)
355 | ```
356 |
357 | ### Sketch Entities
358 |
359 | As you build a OnShape sketch with sketch items, more geometry is being created
360 | under the hood. For instance, when you add a circle and a line to a sketch,
361 | you are actually defining two regions and two new vertices atop the circle
362 | and line.
363 |
364 | To differentiate between sketch items added by the user and entities created by
365 | the sketch, OnPy uses two different terminologies:
366 |
367 | **Sketch Item**—An item added to the sketch with one of the functions listed above
368 | **Sketch Entity**—An entity that was created as the result of sketch items being
369 | added to the sketch.
370 |
371 | > Sketch entities are no different than OnShape entities in general. Other entities, like those that belong to a part, behave exactly the same.
372 |
373 | SketchEntities are how the user interacts with a sketch; this is how you can
374 | select which regions to extrude, loft, etc. To select a sketch entity, we use
375 | an `EntityFilter`. The `EntityFilter` is an object that contains a list of
376 | entities (usually of a certain type) with methods that allow us to filter
377 | the entities it contains through different queries.
378 |
379 | To get the EntityFilter that contains entities from a sketch, you can use:
380 |
381 | ```python
382 | sketch.faces # EntityFilter[FaceEntity]
383 | sketch.edges # EntityFilter[EdgeEntity]
384 | sketch.vertices # EntityFilter[VertexEntity]
385 | ```
386 |
387 | > Entities and entity types are covered later in this document
388 |
389 | With an `EntityFilter` object, we can call the following methods to query
390 | the objects it contains. The signatures are shown:
391 |
392 | ```python
393 | def intersects(
394 | self, origin: tuple[float, float, float], direction: tuple[float, float, float]
395 | ) -> "EntityFilter":
396 | """Get the queries that intersect an infinite line
397 |
398 | Args:
399 | origin: The origin on the line. This can be any point that lays on
400 | the line.
401 | direction: The direction vector of the line
402 | """
403 | ...
404 |
405 | def smallest(self) -> "EntityFilter":
406 | """Get the smallest entity"""
407 | ...
408 |
409 | def largest(self) -> "EntityFilter":
410 | """Get the largest entity"""
411 | ...
412 |
413 | def closest_to(self, point: tuple[float, float, float]) -> "EntityFilter":
414 | """Get the entity closest to the point
415 |
416 | Args:
417 | point: The point to use for filtering
418 | """
419 | ...
420 |
421 | def contains_point(self, point: tuple[float, float, float]) -> "EntityFilter":
422 | """Filters out all queries that don't contain the provided point
423 |
424 | Args:
425 | point: The point to use for filtering
426 | """
427 | ...
428 | ```
429 |
430 | Notice how each query returns an EntityFilter object? We can leverage this
431 | behavior to stack multiple queries atop each other. Consider the following
432 | example:
433 |
434 | ```python
435 | sketch.faces.contains_point((0,0,0)).largest()
436 | ```
437 |
438 | Here, we first query all of the faces that contain the origin (on the sketch),
439 | then we get the largest of that set. We would not be able to achieve this
440 | behavior with one query.
441 |
442 | To apply a query, return the `EntityFilter` object to the corresponding function.
443 |
444 | ```python
445 | partstudio.add_extrude(faces=leg_sketch.faces, distance=32, name="legs")
446 | ```
447 |
448 | ## Features
449 |
450 | Once we have defined a sketch feature, we can continue onto defining more
451 | complex, 3D features. OnPy supports the following features, all of which
452 | are added through the `partstudio` object. The signatures are shown:
453 |
454 | ```python
455 |
456 | def add_sketch(
457 | self, plane: Plane | FaceEntityConvertible, name: str = "New Sketch"
458 | ) -> Sketch:
459 | """Adds a new sketch to the partstudio
460 |
461 | Args:
462 | plane: The plane to base the sketch off of
463 | name: An optional name for the sketch
464 |
465 | Returns:
466 | A Sketch object
467 | """
468 | ...
469 |
470 | def add_extrude(
471 | self,
472 | faces: FaceEntityConvertible,
473 | distance: float,
474 | name: str = "New Extrude",
475 | merge_with: BodyEntityConvertible | None = None,
476 | subtract_from: BodyEntityConvertible | None = None,
477 | ) -> Extrude:
478 | """Adds a new blind extrude feature to the partstudio
479 |
480 | Args:
481 | faces: The faces to extrude
482 | distance: The distance to extrude
483 | name: An optional name for the extrusion
484 |
485 | Returns:
486 | An Extrude object
487 | """
488 | ...
489 |
490 | def add_translate(
491 | self,
492 | part: Part,
493 | name: str = "New Translate",
494 | x: float = 0,
495 | y: float = 0,
496 | z: float = 0,
497 | copy: bool = False,
498 | ) -> Translate:
499 | """Add a new transform of type translate_xyz feature to the partstudio.
500 |
501 | Args:
502 | part: The part to be translated
503 | name: The name of the extrusion feature
504 | x: The distance to move in x direction
505 | y: The distance to move in y direction
506 | z: The distance to move in z direction
507 | copy: Bool to indicate part should be copied
508 |
509 | Returns:
510 | A Translated object
511 |
512 | """
513 |
514 | def add_loft(
515 | self,
516 | start: FaceEntityConvertible,
517 | end: FaceEntityConvertible,
518 | name: str = "Loft",
519 | ) -> Loft:
520 | """Adds a new loft feature to the partstudio
521 |
522 | Args:
523 | start: The start face(s) of the loft
524 | end: The end face(s) of the loft
525 | name: An optional name for the loft feature
526 |
527 | Returns:
528 | A Loft object
529 | """
530 | ...
531 |
532 | def add_offset_plane(
533 | self,
534 | target: Plane | FaceEntityConvertible,
535 | distance: float,
536 | name: str = "Offset Plane",
537 | ) -> OffsetPlane:
538 | """Adds a new offset plane to the partstudio
539 |
540 | Args:
541 | target: The plane to offset from
542 | distance: The distance to offset
543 | name: An optional name for the plane
544 |
545 | Returns:
546 | An OffsetPlane object
547 | """
548 | ...
549 |
550 | ```
551 |
552 | > Note, the `FaceEntityConvertible` type identifies that the object can be converted into a list of face entities. `Sketch` and `EntityFilter` are both subclasses of this type.
553 |
554 | Before adding a new feature, check here for the function signature. Most are similar, but there
555 | are some subtle differences.
556 |
557 | Examples for adding features are shown below.
558 |
559 | ### Extrusions
560 |
561 | Extrusions are the most basic of 3D operations. They allow you to pull a 2D shape by a linear distance
562 | to create a 3D shape.
563 |
564 | OnShape supports three types of extrusions:
565 |
566 | 1. New Extrusion
567 | 2. Add Extrusions
568 | 3. Subtract Extrusions
569 |
570 | New extrusions _always_ create a new part. Parts will be discussed in more
571 | detail later.
572 |
573 | Add extrusions _add_ geometry to an existing part. In OnPy, you can add
574 | to an existing part by passing it to the `merge_with` parameter. If you
575 | try to add geometry that does not touch the existing part, OnShape will throw an
576 | error. Each part must be one, continuous body; no disconnections are allowed.
577 |
578 | Subtract extrusions _remove_ geometry from an existing part. In OnPy, you can
579 | subtract from a part by passing it to the `subtract_from` parameter. By default,
580 | all extrusions are blind, i.e., you specify how "deep" to extrude/cut. If you
581 | want to preform a through cut, you should provide a large value to the
582 | distance parameter.
583 |
584 | The following example shows how a user can use extrusions to create a moderately
585 | complex part:
586 |
587 | ```python
588 | sketch = partstudio.add_sketch(plane=partstudio.features.top_plane, name="Main Sketch")
589 |
590 | # create a 2x10 bar
591 | lines = sketch.trace_points(
592 | (-5, -1), (-5,1), (5, 1), (5, -1), end_connect=True
593 | )
594 |
595 | # fillet the sides of the bar
596 | for i in range(len(lines)):
597 | side_1 = lines[i-1]
598 | side_2 = lines[i]
599 |
600 | sketch.add_fillet(side_1, side_2, radius=0.1)
601 |
602 | # add holes every half inch, starting at (-4, 0)
603 | hole_profile = sketch.add_circle(center=(-4, 0), radius=0.125)
604 | sketch.linear_pattern([hole_profile], num_steps=15, x=0.5)
605 |
606 | # add a line through the center
607 | sketch.add_line((0, -5), (0, 5))
608 |
609 | # extrude the left part of the bar by 1 inch
610 | extrusion_left = partstudio.add_extrude(
611 | faces=sketch.faces.closest_to((-100,0,0)), # pick the furthest left region
612 | distance=2,
613 | name="Left Extrude"
614 | )
615 |
616 | # get a reference to the new part
617 | bar_part = extrusion_left.get_created_parts()[0]
618 |
619 | # extrude the right side by 1.25 inches and add it to the first part
620 | extrusion_right = partstudio.add_extrude(
621 | faces=sketch.faces.closest_to((100,0,0)), # pick the furthest right region
622 | distance=1.25,
623 | merge_with=bar_part, # <-- add to the bar part
624 | name="Right Extrude",
625 | )
626 |
627 | # create a new sketch on top of the part
628 | sketch_on_part = partstudio.add_sketch(
629 | plane=bar_part.faces.closest_to((0,0,100)), # get the highest face
630 | name="Sketch on part"
631 | )
632 |
633 | # draw a slot
634 | sketch_on_part.add_corner_rectangle((-5, 0.25), (5, -0.25))
635 |
636 | # cut extrude this slot into the existing slot
637 | cut_extrude = partstudio.add_extrude(
638 | faces=sketch_on_part,
639 | distance=-1, # <-- flip the direction of the extrude using a negative sign
640 | subtract_from=bar_part, # <-- subtract from the bar part
641 | name="Slot cut"
642 | )
643 | ```
644 |
645 | ### Offset Plane
646 |
647 | In OnShape, planes are construction geometry; they are used to define models,
648 | but are not part of the model itself. Offset planes allow you to create a new plane
649 | based off another plane.
650 |
651 | The following example shows how to create and reference an offset plane. On their
652 | own, these planes are not very useful, but they are integral to using more advanced
653 | features like Lofts.
654 |
655 | ```python
656 | offset_plane = partstudio.add_offset_plane(
657 | target=partstudio.features.top_plane, # <-- based on the default top plane
658 | distance=3.0 # offset by a distance of 3 inches
659 | )
660 |
661 | bottom_sketch = partstudio.add_sketch(plane=partstudio.features.top_plane, name="Bottom Profile")
662 | top_sketch = partstudio.add_sketch(
663 | plane=offset_plane # <-- create this sketch on the offset plane
664 | )
665 |
666 | bottom_sketch.add_circle((0,0), radius=2)
667 | top_sketch.add_circle((0,0), radius=0.1)
668 |
669 | partstudio.add_loft(bottom_sketch, top_sketch) # loft between the two sketches, offset by the offset plane
670 | ```
671 |
672 | Here, we create an offset plane. We add one sketch to it, and another sketch
673 | on the default plane under it. Then, because these sketches are offset, we can loft between these
674 | two profiles.
675 |
676 | ### Lofts
677 |
678 | Lofts are an easy way to create a solid that extends between two offset
679 | profiles. These profiles must be orthogional, but they can be apart by any
680 | distance.
681 |
682 | Lofts are usually used to loft between one profile to another, but sometimes
683 | they can split into multiple profiles. However, this is discouraged as it
684 | often leads to feature errors.
685 |
686 | Lofts take two parameters, start and end, with a third, optional name parameter.
687 | The example above in the "Offset Plane" section exemplifies how to use lofts.
688 |
689 | ### Translate
690 |
691 | A translate operates on an existing part (an object in part studio). This is the "Transform" function of type
692 | "Translate by XYZ" present in the GUI.
693 |
694 | This will esentially move a part by the specified x,y,z units (uses default units of the document), with an option
695 | to "copy" the part (same as "Copy Part" checkbox in GUI)
696 |
697 | ```python
698 | # get the part from part studio with the name of "triangle"
699 | part = partstudio.parts.get('triangle')
700 |
701 | # create a copy of the part and move it 10,80,30 in the x,y,z directions
702 | partstudio.add_translate(part,x=10,y=80,z=30,copy=True)
703 | ```
704 |
705 | ## Parts
706 |
707 | Sometimes, features will result in a new part. For instance, when you extrude a sketch for the first time, a new part is made.
708 | We will often want to reference this part so that we can query it's faces and entities to create even more complex geometries.
709 |
710 | To get the part(s) created by a feature, you can run the `.get_created_parts()` function. This returns a list of Part objects.
711 |
712 | If you want to get a list of all parts in a partstudio, you can run `partstudio.parts`. This returns an object used to interface
713 | with parts. If you want to get an idea of what parts are available, you can run:
714 |
715 | ```txt
716 | >>> print(partstudio.parts)
717 | +-------+----------------+---------+
718 | | Index | Part Name | Part ID |
719 | +-------+----------------+---------+
720 | | 0 | Bearing Holder | JKD |
721 | | 1 | Impact Plate | JPD |
722 | | 2 | Housing | JVD |
723 | +-------+----------------+---------+
724 | ```
725 |
726 | This will display the parts available. Then, you can access the parts by index (`partstudio.parts[0]`),
727 | name (`partstudio.parts.get("Housing")`), or id (`partstudio.parts.get_id("JKD")`)
728 |
729 | If you want to manually iterate over a list of parts, you can call `partstudio.list_parts()`
730 | and this will return a plain list of Part objects.
731 |
732 | ## Example Model
733 |
734 | Using everything discussed so far, let's see OnPy in work. This script
735 | will use many of the features we discussed to create a tri-part lamp model.
736 |
737 | ```python
738 | import onpy
739 |
740 | document = onpy.create_document("Lamp Example")
741 | partstudio = document.get_partstudio()
742 |
743 | # Create the lamp base
744 | lower_base_sketch = partstudio.add_sketch(
745 | plane=partstudio.features.top_plane, name="Base Bottom Sketch"
746 | )
747 | lower_base_sketch.add_corner_rectangle(corner_1=(-2.5, -2.5), corner_2=(2.5, 2.5))
748 |
749 | # Create an offset plane 1.25 inches above the lower base sketch
750 | upper_base_plane = partstudio.add_offset_plane(
751 | target=partstudio.features.top_plane, distance=1.25, name="Upper Base Plane"
752 | )
753 |
754 | # Then, make a new sketch on this new plane
755 | upper_base_sketch = partstudio.add_sketch(
756 | plane=upper_base_plane, name="Upper Base Sketch"
757 | )
758 |
759 | # Add a slightly smaller rectangle on this elevated plane
760 | upper_base_sketch.add_corner_rectangle(corner_1=(-2.25, -2.25), corner_2=(2.25, 2.25))
761 |
762 | # Then, create a loft to blend the two base profiles to get a model bottom
763 | loft = partstudio.add_loft(
764 | start=lower_base_sketch, end=upper_base_sketch, name="Base Loft"
765 | )
766 |
767 | # Next, create a sketch with a circle to represent the lamp rod
768 | rod_profile = partstudio.add_sketch(plane=partstudio.features.top_plane)
769 | rod_profile.add_circle(center=(0, 0), radius=0.5)
770 |
771 | # First, cut a hole through the base
772 | partstudio.add_extrude(
773 | faces=rod_profile,
774 | distance=2, # a bit excess, just to be safe
775 | subtract_from=loft.get_created_parts()[0],
776 | )
777 |
778 | # Then, extrude up 15 inches
779 | rod_extrude = partstudio.add_extrude(faces=rod_profile, distance=15, name="Lamp Rod")
780 | rod = rod_extrude.get_created_parts()[0] # save a reference to the part
781 |
782 | # Get a reference to the top of the rod
783 | top_of_rod = rod.faces.closest_to((0, 0, 100))
784 |
785 | # ... then, create a new sketch off of it
786 | lampshade_top = partstudio.add_sketch(plane=top_of_rod, name="Lampshade Top Profile")
787 |
788 | # Go down 4.5 inches and create another sketch
789 | lampshade_bottom = partstudio.add_sketch(
790 | plane=partstudio.add_offset_plane(target=lampshade_top.plane, distance=-4.5),
791 | name="Lampshade Bottom Profile",
792 | )
793 |
794 | # Add a small circle on top and a larger one on the bottom to create a taper
795 | lampshade_top.add_circle(center=(0, 0), radius=3)
796 | lampshade_bottom.add_circle(center=(0, 0), radius=4)
797 |
798 | # Loft between the two profiles to create a lampshade
799 | lampshade_loft = partstudio.add_loft(
800 | start=lampshade_bottom, end=lampshade_top, name="Lampshade Loft"
801 | )
802 | ```
803 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | requires = ["hatchling"]
3 | build-backend = "hatchling.build"
4 |
5 | [project]
6 | name = "onpy"
7 | version = "0.0.6"
8 | requires-python = ">=3.12"
9 | authors = [
10 | {name = "Kyle Tennison", email = "kyletennison@gmail.com"},
11 | ]
12 | maintainers = [
13 | {name = "Kyle Tennison", email = "kyletennison@gmail.com"},
14 | ]
15 | description = "A Python API for building models in OnShape"
16 | readme = "README.md"
17 | license = {file = "LICENSE"}
18 | classifiers = [
19 | "Programming Language :: Python"
20 | ]
21 | dependencies = [
22 | "loguru",
23 | "requests",
24 | "pydantic",
25 | "numpy",
26 | "prettytable"
27 | ]
28 | [project.optional-dependencies]
29 | dev = [
30 | "pytest",
31 | "ruff",
32 | "pyright",
33 | "types-requests",
34 | "nbconvert",
35 | ]
36 |
37 | [tool.ruff]
38 | include = ["src/*"]
39 | line-length = 100
40 |
41 |
42 | [tool.ruff.lint]
43 | select = ["ALL"]
44 | ignore = [
45 | "D205", # overly verbose to make docstrings like this
46 | "D203", # overly verbose to make docstrings like this
47 | "D213", # overly verbose to make docstrings like this
48 | "SLF001", # private apis are only used internally, which is okay
49 | "PLR0913", # complexity is unavoidable for math-intensive methods
50 | "PLR0912", # complexity is unavoidable for math-intensive methods
51 | "PLR0915", # complexity is unavoidable for math-intensive methods
52 | "C901", # complexity is unavoidable for math-intensive methods
53 | "FIX002", # allow TODO notes
54 | "TD003", # don't require tickets for TODO notes
55 | "N815", # onshape api schema is not controlled by onpy
56 | "COM812", # issues with formatter
57 | ]
58 | per-file-ignores = {"src/onpy/entities/queries.py" = ["N801"]}
59 |
--------------------------------------------------------------------------------
/src/onpy/__init__.py:
--------------------------------------------------------------------------------
1 | """OnPy, an unofficial OnShape API for Python."""
2 |
3 | import sys
4 |
5 | from loguru import logger
6 |
7 | from onpy.client import Client
8 | from onpy.document import Document
9 | from onpy.util.credentials import CredentialManager
10 |
11 | # --- configure loguru ---
12 | logger.remove()
13 | logger.add(
14 | sys.stdout,
15 | colorize=True,
16 | format="{level: <8} | {message}",
17 | )
18 |
19 | # --- some quality of life functions ---
20 |
21 |
22 | def get_document(document_id: str | None = None, name: str | None = None) -> Document:
23 | """Get a document by name or id. Instantiates a new client.
24 |
25 | Args:
26 | document_id: The id of the document to fetch
27 | name: The name of the document to fetch
28 |
29 | Returns:
30 | A list of Document objects
31 |
32 | """
33 | return Client().get_document(document_id, name)
34 |
35 |
36 | def create_document(name: str, description: str | None = None) -> Document:
37 | """Create a new document. Instantiates a new client.
38 |
39 | Args:
40 | name: The name of the new document
41 | description: The description of document
42 |
43 | Returns:
44 | A Document object of the new document
45 |
46 | """
47 | return Client().create_document(name, description)
48 |
49 |
50 | def configure() -> None:
51 | """CLI interface to update OnShape credentials."""
52 | tokens = CredentialManager.prompt_tokens()
53 | CredentialManager.configure_file(*tokens)
54 |
--------------------------------------------------------------------------------
/src/onpy/api/__init__.py:
--------------------------------------------------------------------------------
1 | """Interface to OnShape's open REST api."""
2 |
--------------------------------------------------------------------------------
/src/onpy/api/endpoints.py:
--------------------------------------------------------------------------------
1 | """Different endpoints exposed to the RestApi object.
2 |
3 | The api is built in two parts; a request centric, utility part, and a part
4 | dedicated to wrapping the many OnShape endpoints. This script completes the
5 | ladder; it uses the utilities from rest_api.py to wrap OnShape endpoints
6 | in Python.
7 |
8 | OnPy - May 2024 - Kyle Tennison
9 |
10 | """
11 |
12 | from typing import TYPE_CHECKING
13 |
14 | from onpy.api import schema
15 | from onpy.api.versioning import VersionTarget
16 |
17 | if TYPE_CHECKING:
18 | from onpy.api.rest_api import RestApi
19 |
20 |
21 | class EndpointContainer:
22 | """Container for different OnShape endpoints and their schemas."""
23 |
24 | def __init__(self, rest_api: "RestApi") -> None:
25 | """Construct a container instance from a rest api instance."""
26 | self.api = rest_api
27 |
28 | def documents(self) -> list[schema.Document]:
29 | """Fetch a list of documents that belong to the current user."""
30 | r = self.api.get(endpoint="/documents", response_type=schema.DocumentsResponse)
31 | return r.items
32 |
33 | def document_create(self, name: str, description: str | None) -> schema.Document:
34 | """Create a new document."""
35 | if description is None:
36 | description = "Created with onpy"
37 |
38 | return self.api.post(
39 | endpoint="/documents",
40 | payload=schema.DocumentCreateRequest(name=name, description=description),
41 | response_type=schema.Document,
42 | )
43 |
44 | def document_delete(self, document_id: str) -> None:
45 | """Delete a document."""
46 | self.api.delete(endpoint=f"/documents/{document_id}", response_type=str)
47 |
48 | def document_elements(
49 | self,
50 | document_id: str,
51 | version: VersionTarget,
52 | ) -> list[schema.Element]:
53 | """Fetch all of the elements in the specified document."""
54 | return self.api.list_get(
55 | endpoint=f"/documents/d/{document_id}/{version.wvm}/{version.wvmid}/elements",
56 | response_type=schema.Element,
57 | )
58 |
59 | def eval_featurescript[T: str | schema.ApiModel](
60 | self,
61 | document_id: str,
62 | version: VersionTarget,
63 | element_id: str,
64 | script: str,
65 | return_type: type[T] = str,
66 | ) -> T:
67 | """Evaluate a snipit of featurescript."""
68 | return self.api.post(
69 | endpoint=f"/partstudios/d/{document_id}/{version.wvm}/{version.wvmid}/e/{element_id}/featurescript",
70 | response_type=return_type,
71 | payload=schema.FeaturescriptUpload(script=script),
72 | )
73 |
74 | def list_features(
75 | self,
76 | document_id: str,
77 | version: VersionTarget,
78 | element_id: str,
79 | ) -> list[schema.Feature]:
80 | """List all the features in a partstudio."""
81 | feature_list = self.api.get(
82 | endpoint=f"/partstudios/d/{document_id}/{version.wvm}/{version.wvmid}/e/{element_id}/features",
83 | response_type=schema.FeatureListResponse,
84 | )
85 |
86 | return feature_list.features
87 |
88 | def add_feature(
89 | self,
90 | document_id: str,
91 | version: VersionTarget,
92 | element_id: str,
93 | feature: schema.Feature,
94 | ) -> schema.FeatureAddResponse:
95 | """Add a feature to the partstudio."""
96 | return self.api.post(
97 | endpoint=f"/partstudios/d/{document_id}/{version.wvm}/{version.wvmid}/e/{element_id}/features",
98 | response_type=schema.FeatureAddResponse,
99 | payload=schema.FeatureAddRequest(
100 | feature=feature.model_dump(exclude_none=True),
101 | ),
102 | )
103 |
104 | def update_feature(
105 | self,
106 | document_id: str,
107 | workspace_id: str,
108 | element_id: str,
109 | feature: schema.Feature,
110 | ) -> schema.FeatureAddResponse:
111 | """Update an existing feature."""
112 | return self.api.post(
113 | endpoint=f"/partstudios/d/{document_id}/w/{workspace_id}/e/{element_id}/features/featureid/{feature.featureId}",
114 | response_type=schema.FeatureAddResponse,
115 | payload=schema.FeatureAddRequest(
116 | feature=feature.model_dump(exclude_none=True),
117 | ),
118 | )
119 |
120 | def delete_feature(
121 | self,
122 | document_id: str,
123 | workspace_id: str,
124 | element_id: str,
125 | feature_id: str,
126 | ) -> None:
127 | """Delete a feature."""
128 | self.api.delete(
129 | endpoint=f"/partstudios/d/{document_id}/w/{workspace_id}/e/{element_id}/features/featureid/{feature_id}",
130 | response_type=str,
131 | )
132 |
133 | def list_versions(self, document_id: str) -> list[schema.DocumentVersion]:
134 | """List the versions in a document in reverse-chronological order."""
135 | versions = self.api.list_get(
136 | endpoint=f"/documents/d/{document_id}/versions",
137 | response_type=schema.DocumentVersion,
138 | )
139 |
140 | return sorted(versions, key=lambda v: v.createdAt, reverse=True)
141 |
142 | def create_version(
143 | self,
144 | document_id: str,
145 | workspace_id: str,
146 | name: str,
147 | ) -> schema.DocumentVersion:
148 | """Create a new version from a workspace."""
149 | return self.api.post(
150 | f"/documents/d/{document_id}/versions",
151 | response_type=schema.DocumentVersion,
152 | payload=schema.DocumentVersionUpload(
153 | documentId=document_id,
154 | name=name,
155 | workspaceId=workspace_id,
156 | ),
157 | )
158 |
159 | def list_parts(
160 | self,
161 | document_id: str,
162 | version: VersionTarget,
163 | element_id: str,
164 | ) -> list[schema.Part]:
165 | """List all the parts in an element."""
166 | return self.api.list_get(
167 | endpoint=f"/parts/d/{document_id}/{version.wvm}/{version.wvmid}/e/{element_id}",
168 | response_type=schema.Part,
169 | )
170 |
--------------------------------------------------------------------------------
/src/onpy/api/rest_api.py:
--------------------------------------------------------------------------------
1 | """RestApi interface to the OnShape server.
2 |
3 | The api is built in two parts; a request centric, utility part, and a part
4 | dedicated to wrapping the many OnShape endpoints. This script is the utility
5 | part; different HTTP request methods are implemented here, based around
6 | the Python requests module.
7 |
8 | OnPy - May 2024 - Kyle Tennison
9 |
10 | """
11 |
12 | import json
13 | import time
14 | from typing import TYPE_CHECKING, cast
15 |
16 | import requests
17 | from loguru import logger
18 | from requests.auth import HTTPBasicAuth
19 |
20 | from onpy.api.endpoints import EndpointContainer
21 | from onpy.api.schema import ApiModel, HttpMethod
22 | from onpy.util.exceptions import OnPyApiError, OnPyInternalError
23 |
24 | if TYPE_CHECKING:
25 | from collections.abc import Callable
26 |
27 | from onpy.client import Client
28 |
29 |
30 | class RestApi:
31 | """Interface for OnShape API Requests."""
32 |
33 | BASE_URL = "https://cad.onshape.com/api/v6"
34 |
35 | def __init__(self, client: "Client") -> None:
36 | """Construct a new rest api interface instance.
37 |
38 | Args:
39 | client: A reference to the client.
40 |
41 | """
42 | self.endpoints = EndpointContainer(self)
43 | self.client = client
44 |
45 | def get_auth(self) -> HTTPBasicAuth:
46 | """Get the basic HTTP the authentication object."""
47 | access_key, secret_key = self.client._credentials
48 | return HTTPBasicAuth(access_key, secret_key)
49 |
50 | def http_wrap[T: ApiModel | str](
51 | self,
52 | http_method: HttpMethod,
53 | endpoint: str,
54 | response_type: type[T],
55 | payload: ApiModel | None,
56 | retry_delay: int = 1,
57 | ) -> T:
58 | """Wrap requests' POST/GET/DELETE with pydantic serializations & deserializations.
59 |
60 | Args:
61 | http_method: The HTTP Method to use, like GET/POST/DELETE.
62 | endpoint: The endpoint to target. e.g., /documents/
63 | response_type: The ApiModel to deserialize the response into.
64 | payload: The optional payload to send with the request
65 | retry_delay: Set internally when recursing on too many requests (429)
66 | HTTP error.
67 |
68 | Returns:
69 | The response deserialized into the response_type type
70 |
71 | """
72 | # check endpoint formatting
73 | if not endpoint.startswith("/"):
74 | msg = f"Endpoint '{endpoint}' missing '/' prefix"
75 | raise OnPyInternalError(msg)
76 |
77 | # match method enum to requests function
78 | requests_func: Callable[..., requests.Response] = {
79 | HttpMethod.Post: requests.post,
80 | HttpMethod.Get: requests.get,
81 | HttpMethod.Delete: requests.delete,
82 | HttpMethod.Put: requests.put,
83 | }[http_method]
84 |
85 | payload_json = None
86 |
87 | if isinstance(payload, ApiModel):
88 | payload_json = payload.model_dump(exclude_none=True)
89 |
90 | logger.debug(f"{http_method.name} {endpoint}")
91 | logger.trace(
92 | f"Calling {http_method.name} {endpoint}"
93 | + (f" with payload:\n{json.dumps(payload_json, indent=4)}" if payload else ""),
94 | )
95 |
96 | # TODO @kyle-tennison: wrap this in a try/except to catch timeouts
97 | r = requests_func(
98 | url=self.BASE_URL + endpoint,
99 | json=payload_json,
100 | auth=self.get_auth(),
101 | )
102 |
103 | if not r.ok:
104 | if r.status_code == requests.codes.too_many_requests:
105 | logger.warning(f"Hit request throttle, retrying in {retry_delay * 2} seconds")
106 | time.sleep(retry_delay * 2)
107 | return self.http_wrap(
108 | http_method, endpoint, response_type, payload, retry_delay * 2
109 | )
110 |
111 | msg = f"Bad response {r.status_code}"
112 | raise OnPyApiError(msg, r)
113 |
114 | # deserialize response
115 | try:
116 | if r.text.strip() == "":
117 | response_dict: dict = {} # allow empty responses
118 | else:
119 | response_dict = r.json()
120 | logger.trace(
121 | f"{http_method.name} {endpoint} responded with:\n"
122 | f"{json.dumps(response_dict, indent=4)}",
123 | )
124 | except requests.JSONDecodeError as e:
125 | msg = "Response is not json"
126 | raise OnPyApiError(msg, r) from e
127 |
128 | if issubclass(response_type, ApiModel):
129 | return cast("T", response_type(**response_dict))
130 |
131 | if issubclass(response_type, str):
132 | return cast("T", response_type(r.text))
133 |
134 | msg = f"Illegal response type: {response_type.__name__}"
135 | raise OnPyInternalError(msg)
136 |
137 | def http_wrap_list[T: ApiModel | str](
138 | self,
139 | http_method: HttpMethod,
140 | endpoint: str,
141 | response_type: type[T],
142 | payload: ApiModel | None,
143 | ) -> list[T]:
144 | """Interfaces with http_wrap to deserialize into a list of the expected class.
145 |
146 | Args:
147 | http_method: The HTTP Method to use, like GET/POST/DELETE.
148 | endpoint: The endpoint to target. e.g., /documents/
149 | response_type: The ApiModel to deserialize the response into.
150 | payload: The optional payload to send with the request
151 |
152 | Returns:
153 | The response deserialized into the response_type type
154 |
155 | """
156 | response_raw = self.http_wrap(http_method, endpoint, str, payload)
157 |
158 | response_list = json.loads(response_raw)
159 |
160 | if not isinstance(response_list, list):
161 | msg = f"Endpoint {endpoint} expected list response"
162 | raise OnPyApiError(msg)
163 |
164 | return [cast("T", response_type(**i)) for i in response_list]
165 |
166 | def post[T: ApiModel | str](
167 | self, endpoint: str, response_type: type[T], payload: ApiModel
168 | ) -> T:
169 | """Run a POST request to the specified endpoint. Deserializes into response_type type.
170 |
171 | Args:
172 | endpoint: The endpoint to target. e.g., /documents/
173 | response_type: The ApiModel to deserialize the response into.
174 | payload: The payload to send with the request
175 |
176 | Returns:
177 | The response deserialized into the response_type type
178 |
179 | """
180 | return self.http_wrap(HttpMethod.Post, endpoint, response_type, payload)
181 |
182 | def get[T: ApiModel | str](
183 | self,
184 | endpoint: str,
185 | response_type: type[T],
186 | payload: ApiModel | None = None,
187 | ) -> T:
188 | """Run a GET request to the specified endpoint. Deserializes into response_type type.
189 |
190 | Args:
191 | endpoint: The endpoint to target. e.g., /documents/
192 | response_type: The ApiModel to deserialize the response into.
193 | payload: The payload to include with the request, if applicable.
194 |
195 | Returns:
196 | The response deserialized into the response_type type
197 |
198 | """
199 | return self.http_wrap(HttpMethod.Get, endpoint, response_type, payload)
200 |
201 | def put[T: ApiModel | str](self, endpoint: str, response_type: type[T], payload: ApiModel) -> T:
202 | """Run a PUT request to the specified endpoint. Deserializes into response_type type.
203 |
204 | Args:
205 | endpoint: The endpoint to target. e.g., /documents/
206 | response_type: The ApiModel to deserialize the response into.
207 | payload: The payload to send with the request
208 |
209 | Returns:
210 | The response deserialized into the response_type type
211 |
212 | """
213 | return self.http_wrap(HttpMethod.Put, endpoint, response_type, payload)
214 |
215 | def delete[T: ApiModel | str](
216 | self,
217 | endpoint: str,
218 | response_type: type[T],
219 | payload: ApiModel | None = None,
220 | ) -> T:
221 | """Run a DELETE request to the specified endpoint. Deserializes into response_type type.
222 |
223 | Args:
224 | endpoint: The endpoint to target. e.g., /documents/
225 | response_type: The ApiModel to deserialize the response into.
226 | payload: The payload to send with the request
227 |
228 | Returns:
229 | The response deserialized into the response_type type
230 |
231 | """
232 | return self.http_wrap(HttpMethod.Delete, endpoint, response_type, payload)
233 |
234 | def list_post[T: ApiModel | str](
235 | self, endpoint: str, response_type: type[T], payload: ApiModel
236 | ) -> list[T]:
237 | """Run a POST request to the specified endpoint. Deserializes into
238 | a list of the response_type type.
239 |
240 | Args:
241 | endpoint: The endpoint to target. e.g., /documents/
242 | response_type: The ApiModel to deserialize the response into.
243 | payload: The payload to send with the request
244 |
245 | Returns:
246 | The response, deserialized into a list of the response_type type
247 |
248 | """
249 | return self.http_wrap_list(HttpMethod.Post, endpoint, response_type, payload)
250 |
251 | def list_get[T: ApiModel | str](
252 | self,
253 | endpoint: str,
254 | response_type: type[T],
255 | payload: ApiModel | None = None,
256 | ) -> list[T]:
257 | """Run a GET request to the specified endpoint. Deserializes into
258 | a list of the response_type type.
259 |
260 | Args:
261 | endpoint: The endpoint to target. e.g., /documents/
262 | response_type: The ApiModel to deserialize the response into.
263 | payload: The payload to send with the request
264 |
265 | Returns:
266 | The response, deserialized into a list of the response_type type
267 |
268 | """
269 | return self.http_wrap_list(HttpMethod.Get, endpoint, response_type, payload)
270 |
271 | def list_put[T: ApiModel | str](
272 | self, endpoint: str, response_type: type[T], payload: ApiModel
273 | ) -> list[T]:
274 | """Run a PUT request to the specified endpoint. Deserializes into
275 | a list of the response_type type.
276 |
277 | Args:
278 | endpoint: The endpoint to target. e.g., /documents/
279 | response_type: The ApiModel to deserialize the response into.
280 | payload: The payload to send with the request
281 |
282 | Returns:
283 | The response, deserialized into a list of the response_type type
284 |
285 | """
286 | return self.http_wrap_list(HttpMethod.Put, endpoint, response_type, payload)
287 |
288 | def list_delete[T: ApiModel | str](
289 | self,
290 | endpoint: str,
291 | response_type: type[T],
292 | payload: ApiModel | None = None,
293 | ) -> list[T]:
294 | """Run a DELETE request to the specified endpoint. Deserializes into
295 | a list of the response_type type.
296 |
297 | Args:
298 | endpoint: The endpoint to target. e.g., /documents/
299 | response_type: The ApiModel to deserialize the response into.
300 | payload: The payload to send with the request
301 |
302 | Returns:
303 | The response, deserialized into a list of the response_type type
304 |
305 | """
306 | return self.http_wrap_list(HttpMethod.Delete, endpoint, response_type, payload)
307 |
--------------------------------------------------------------------------------
/src/onpy/api/schema.py:
--------------------------------------------------------------------------------
1 | """Models for OnShape API payloads & responses.
2 |
3 | The API uses pydantic to serialize and deserialize API calls. The models
4 | to do this are stored here.
5 |
6 | OnPy - May 2024 - Kyle Tennison
7 |
8 | """
9 |
10 | from abc import abstractmethod
11 | from datetime import datetime
12 | from enum import Enum
13 | from typing import Protocol
14 |
15 | from pydantic import BaseModel, ConfigDict
16 |
17 |
18 | class HttpMethod(Enum):
19 | """Enumeration of available HTTP methods."""
20 |
21 | Post = "post"
22 | Get = "get"
23 | Put = "put"
24 | Delete = "delete"
25 |
26 |
27 | class NameIdFetchable(Protocol):
28 | """A protocol for an object that can be fetched by name or id."""
29 |
30 | @property
31 | @abstractmethod
32 | def name(self) -> str:
33 | """The name of the item."""
34 | ...
35 |
36 | @property
37 | @abstractmethod
38 | def id(self) -> str:
39 | """The ID of the item."""
40 | ...
41 |
42 |
43 | class ApiModel(BaseModel):
44 | """Base model for OnShape APIs."""
45 |
46 | model_config = ConfigDict(extra="ignore")
47 |
48 |
49 | class UserReference(ApiModel):
50 | """Represents a reference to a user."""
51 |
52 | href: str
53 | id: str
54 | name: str
55 |
56 |
57 | class Workspace(ApiModel):
58 | """Represents an instance of OnShape's workspace versioning."""
59 |
60 | name: str
61 | id: str
62 |
63 |
64 | class Document(ApiModel):
65 | """Represents surface-level document information."""
66 |
67 | createdAt: datetime
68 | createdBy: UserReference
69 | href: str
70 | id: str
71 | name: str
72 | owner: UserReference
73 | defaultWorkspace: Workspace
74 |
75 |
76 | class DocumentsResponse(ApiModel):
77 | """Response model of GET /documents."""
78 |
79 | items: list[Document]
80 |
81 |
82 | class DocumentCreateRequest(ApiModel):
83 | """Request model of POST /documents."""
84 |
85 | name: str
86 | description: str | None
87 | isPublic: bool | None = True
88 |
89 |
90 | class DocumentVersion(ApiModel):
91 | """Represents a document version."""
92 |
93 | documentId: str
94 | name: str
95 | id: str
96 | microversion: str
97 | createdAt: datetime
98 | description: str | None = ""
99 |
100 |
101 | class DocumentVersionUpload(ApiModel):
102 | """Represents a partial document version, used for upload."""
103 |
104 | documentId: str
105 | name: str
106 | workspaceId: str
107 |
108 |
109 | class Element(ApiModel):
110 | """Represents an OnShape element."""
111 |
112 | angleUnits: str | None
113 | areaUnits: str | None
114 | lengthUnits: str | None
115 | massUnits: str | None
116 | volumeUnits: str | None
117 | elementType: str
118 | id: str
119 | name: str
120 |
121 |
122 | class FeatureParameter(ApiModel):
123 | """Represents a feature parameter."""
124 |
125 | btType: str
126 | queries: list[dict | ApiModel]
127 | parameterId: str
128 |
129 |
130 | class FeatureParameterQueryList(FeatureParameter):
131 | """Represents a BTMParameterQueryList-148."""
132 |
133 | btType: str = "BTMParameterQueryList-148"
134 |
135 |
136 | class FeatureEntity(ApiModel):
137 | """Represents a feature entity."""
138 |
139 | btType: str | None = None
140 | entityId: str
141 |
142 |
143 | class SketchCurveEntity(FeatureEntity):
144 | """Represents a sketch's curve."""
145 |
146 | geometry: dict
147 | centerId: str
148 | btType: str | None = "BTMSketchCurve-4"
149 |
150 |
151 | class SketchCurveSegmentEntity(FeatureEntity):
152 | """Represents a sketch curve segment."""
153 |
154 | btType: str | None = "BTMSketchCurveSegment-155"
155 | startPointId: str
156 | endPointId: str
157 | startParam: float
158 | endParam: float
159 | geometry: dict
160 | centerId: str | None = None
161 |
162 |
163 | class Feature(ApiModel):
164 | """Represents an OnShape feature."""
165 |
166 | name: str
167 | namespace: str | None = None
168 | featureType: str
169 | suppressed: bool
170 | parameters: list[dict] | None = [] # dict is FeatureParameter
171 |
172 | # TODO @kyle-tennison: use the actual models again
173 | featureId: str | None = None
174 |
175 | # TODO @kyle-tennison: is there any way to use inheritance in
176 | # pydantic w/o filtering off attributes
177 |
178 |
179 | class FeatureState(ApiModel):
180 | """Contains information about the health of a feature."""
181 |
182 | featureStatus: str
183 | inactive: bool
184 |
185 |
186 | class FeatureAddRequest(ApiModel):
187 | """API Request to add a feature."""
188 |
189 | feature: dict
190 |
191 |
192 | class FeatureAddResponse(ApiModel):
193 | """API Response after adding a feature."""
194 |
195 | feature: Feature
196 | featureState: FeatureState
197 |
198 |
199 | class FeatureListResponse(ApiModel):
200 | """API Response of GET /partstudios/DWE/features."""
201 |
202 | features: list[Feature]
203 | defaultFeatures: list[Feature]
204 |
205 |
206 | class FeaturescriptUpload(ApiModel):
207 | """Request model of POST /partstudios/DWE/featurescript."""
208 |
209 | script: str
210 |
211 |
212 | class FeaturescriptResponse(ApiModel):
213 | """The response from a featurescript upload."""
214 |
215 | result: dict | None
216 |
217 |
218 | class Sketch(Feature):
219 | """Represents a Sketch Feature."""
220 |
221 | btType: str = "BTMSketch-151"
222 | featureType: str = "newSketch"
223 | constraints: list[dict] | None = []
224 | entities: list[dict] | None # dict is FeatureEntity
225 |
226 |
227 | class Extrude(Feature):
228 | """Represents an Extrude Feature."""
229 |
230 | btType: str = "BTMFeature-134"
231 | featureType: str = "extrude"
232 |
233 |
234 | class Translate(Feature):
235 | """Represents an Translate Feature."""
236 |
237 | btType: str = "BTMFeature-134"
238 | featureType: str = "transform"
239 |
240 |
241 | class Plane(Feature):
242 | """Represents a Plane Feature."""
243 |
244 | btType: str = "BTMFeature-134"
245 | featureType: str = "cPlane"
246 |
247 |
248 | class Loft(Feature):
249 | """Represents a Loft Feature."""
250 |
251 | btType: str = "BTMFeature-134"
252 | featureType: str = "loft"
253 |
254 |
255 | class Part(ApiModel):
256 | """Represents a Part."""
257 |
258 | name: str
259 | partId: str
260 | bodyType: str
261 | partQuery: str
262 |
--------------------------------------------------------------------------------
/src/onpy/api/versioning.py:
--------------------------------------------------------------------------------
1 | """Interface for controlling OnShape API versions.
2 |
3 | For each API call, OnShape requests a version; this can be a workspace,
4 | a version, or a microversion. Each of these will have a corresponding ID.
5 | This script adds scripts that make it easy to interface with any of these
6 | versions.
7 |
8 | OnPy - May 2024 - Kyle Tennison
9 |
10 | """
11 |
12 | from abc import ABC, abstractmethod
13 |
14 |
15 | class VersionTarget(ABC):
16 | """Abstract base class for OnShape version targets."""
17 |
18 | @property
19 | @abstractmethod
20 | def wvm(self) -> str:
21 | """Convert versioning method into {wvm} block."""
22 | ...
23 |
24 | @property
25 | @abstractmethod
26 | def wvmid(self) -> str:
27 | """Convert versioning method into {wvmid} block."""
28 | ...
29 |
30 |
31 | class MicroversionWVM(VersionTarget):
32 | """Microversion Target for WVM."""
33 |
34 | def __init__(self, microversion_id: str) -> None:
35 | """Construct a Microversion type WVM version target from a workspace id."""
36 | self.id = microversion_id
37 |
38 | @property
39 | def wvm(self) -> str:
40 | """The {wvm} of the version."""
41 | return "m"
42 |
43 | @property
44 | def wvmid(self) -> str:
45 | """The {wvmid} of the version."""
46 | return self.id
47 |
48 |
49 | class VersionWVM(VersionTarget):
50 | """Version Target for WVM."""
51 |
52 | def __init__(self, version_id: str) -> None:
53 | """Construct a Version type WVM version target from a workspace id."""
54 | self.id = version_id
55 |
56 | @property
57 | def wvm(self) -> str:
58 | """The {wvm} of the version."""
59 | return "v"
60 |
61 | @property
62 | def wvmid(self) -> str:
63 | """The {wvmid} of the version."""
64 | return self.id
65 |
66 |
67 | class WorkspaceWVM(VersionTarget):
68 | """Workspace Target for WVM."""
69 |
70 | def __init__(self, workspace_id: str) -> None:
71 | """Construct a Workspace type WVM version target from a workspace id."""
72 | self.id = workspace_id
73 |
74 | @property
75 | def wvm(self) -> str:
76 | """The {wvm} of the version."""
77 | return "w"
78 |
79 | @property
80 | def wvmid(self) -> str:
81 | """The {wvmid} of the version."""
82 | return self.id
83 |
--------------------------------------------------------------------------------
/src/onpy/client.py:
--------------------------------------------------------------------------------
1 | """Client Object - Entry Point.
2 |
3 | The Client object is the entry point to OnPy. It handles authentication and
4 | document management.
5 |
6 | OnPy - May 2024 - Kyle Tennison
7 |
8 | """
9 |
10 | import requests
11 |
12 | from onpy.api.rest_api import RestApi
13 | from onpy.document import Document
14 | from onpy.util.credentials import CredentialManager
15 | from onpy.util.exceptions import OnPyApiError, OnPyAuthError, OnPyParameterError
16 | from onpy.util.misc import UnitSystem, find_by_name_or_id
17 |
18 |
19 | class Client:
20 | """Handles project management, authentication, and other related items."""
21 |
22 | def __init__(
23 | self,
24 | units: str = "inch",
25 | onshape_access_token: str | None = None,
26 | onshape_secret_token: str | None = None,
27 | ) -> None:
28 | """Args:
29 | units: The unit system to use. Supports 'inch' and 'metric'.
30 |
31 | """
32 | self.units = UnitSystem.from_string(units)
33 |
34 | if onshape_access_token and onshape_secret_token:
35 | self._credentials = (onshape_access_token, onshape_secret_token)
36 | else:
37 | self._credentials = CredentialManager.fetch_or_prompt()
38 | self._api = RestApi(self)
39 |
40 | # check that a request can be made
41 | try:
42 | self.list_documents()
43 | except OnPyApiError as e:
44 | if e.response is not None and e.response.status_code == requests.codes.unauthorized:
45 | msg = (
46 | "The provided API token is not valid or is no longer valid. "
47 | "Run onpy.configure() to update your API tokens."
48 | )
49 | raise OnPyAuthError(
50 | msg,
51 | ) from e
52 | raise
53 |
54 | def list_documents(self) -> list[Document]:
55 | """Get a list of available documents.
56 |
57 | Returns:
58 | A list of Document objects
59 |
60 | """
61 | return [Document(self, model) for model in self._api.endpoints.documents()]
62 |
63 | def get_document(
64 | self,
65 | document_id: str | None = None,
66 | name: str | None = None,
67 | ) -> Document:
68 | """Get a document by name or id.
69 |
70 | Args:
71 | document_id: The id of the document to fetch
72 | name: The name of the document to fetch
73 |
74 | Returns:
75 | A list of Document objects
76 |
77 | """
78 | candidate = find_by_name_or_id(document_id, name, self.list_documents())
79 |
80 | if candidate is None:
81 | raise OnPyParameterError(
82 | "Unable to find a document with " + (f"name {name}" if name else f"id {id}"),
83 | )
84 |
85 | return candidate
86 |
87 | def create_document(self, name: str, description: str | None = None) -> Document:
88 | """Create a new document.
89 |
90 | Args:
91 | name: The name of the new document
92 | description: The description of document
93 |
94 | Returns:
95 | A Document object of the new document
96 |
97 | """
98 | return Document(self, self._api.endpoints.document_create(name, description))
99 |
--------------------------------------------------------------------------------
/src/onpy/document.py:
--------------------------------------------------------------------------------
1 | """OnShape Document interface.
2 |
3 | OnShape Documents contain multiple elements and versions. This script defines
4 | methods to interact and control these items.
5 |
6 | OnPy - May 2024 - Kyle Tennison
7 |
8 | """
9 |
10 | import re
11 | from typing import TYPE_CHECKING
12 |
13 | from loguru import logger
14 |
15 | from onpy.api import schema
16 | from onpy.api.versioning import WorkspaceWVM
17 | from onpy.elements.assembly import Assembly
18 | from onpy.elements.partstudio import PartStudio
19 | from onpy.util.exceptions import OnPyParameterError
20 | from onpy.util.misc import find_by_name_or_id
21 |
22 | if TYPE_CHECKING:
23 | from onpy.client import Client
24 |
25 |
26 | class Document(schema.NameIdFetchable):
27 | """Represents an OnShape document. Houses PartStudios, Assemblies, etc."""
28 |
29 | def __init__(self, client: "Client", model: schema.Document) -> None:
30 | """Construct a new OnShape document from it's schema model.
31 |
32 | Args:
33 | client: A reference to the client.
34 | model: The schema model of the document.
35 |
36 | """
37 | self._model = model
38 | self._client = client
39 |
40 | @property
41 | def id(self) -> str:
42 | """The document's id."""
43 | return self._model.id
44 |
45 | @property
46 | def name(self) -> str:
47 | """The document's name."""
48 | return self._model.name
49 |
50 | @property
51 | def default_workspace(self) -> schema.Workspace:
52 | """The document's default workspace."""
53 | return self._model.defaultWorkspace
54 |
55 | @property
56 | def elements(self) -> list[PartStudio | Assembly]:
57 | """Get the elements that exist on this document."""
58 | workspace_version = WorkspaceWVM(self.default_workspace.id)
59 | elements_model_list = self._client._api.endpoints.document_elements(
60 | self.id,
61 | workspace_version,
62 | )
63 |
64 | element_objects: list[PartStudio | Assembly] = []
65 | for element in elements_model_list:
66 | if element.elementType == "PARTSTUDIO":
67 | element_objects.append(PartStudio(self, element))
68 |
69 | if element.elementType == "ASSEMBLY":
70 | element_objects.append(Assembly(self, element))
71 |
72 | return element_objects
73 |
74 | def delete(self) -> None:
75 | """Delete the current document."""
76 | self._client._api.endpoints.document_delete(self.id)
77 |
78 | def list_partstudios(self) -> list[PartStudio]:
79 | """Get a list of PartStudios that belong to this document."""
80 | return [e for e in self.elements if isinstance(e, PartStudio)]
81 |
82 | def get_partstudio(
83 | self,
84 | element_id: str | None = None,
85 | name: str | None = None,
86 | *,
87 | wipe: bool = True,
88 | ) -> PartStudio:
89 | """Fetch a partstudio by name or id. By default, the partstudio
90 | will be wiped of all features.
91 |
92 | Args:
93 | element_id: The id of the partstudio OR
94 | name: The name of the partstudio
95 | wipe: Wipes the partstudio of all features. DEFAULTS TO TRUE!
96 |
97 | """
98 | if name is None and element_id is None:
99 | return self.list_partstudios()[0]
100 |
101 | match = find_by_name_or_id(element_id, name, self.list_partstudios())
102 |
103 | if match is None:
104 | raise OnPyParameterError(
105 | "Unable to find a partstudio with "
106 | + (f"name {name}" if name else f"id {element_id}"),
107 | )
108 |
109 | if wipe:
110 | match.wipe()
111 |
112 | return match
113 |
114 | def create_version(self, name: str | None = None) -> None:
115 | """Create a version from the current workspace.
116 |
117 | Args:
118 | name: An optional name of the version. Defaults to v1, v2, etc.
119 |
120 | """
121 | if name is None:
122 | versions = self._client._api.endpoints.list_versions(self.id)
123 | for version in versions:
124 | pattern = r"^V(\d+)$"
125 | match = re.match(pattern, version.name)
126 | if match:
127 | name = f"V{int(match.group(1)) + 1}"
128 | break
129 |
130 | if name is None:
131 | name = "V1"
132 |
133 | self._client._api.endpoints.create_version(
134 | document_id=self.id,
135 | workspace_id=self.default_workspace.id,
136 | name=name,
137 | )
138 |
139 | logger.info(f"Created new version {name}")
140 |
141 | def __eq__(self, other: object) -> bool:
142 | """Check if two documents are the same."""
143 | return type(other) is type(self) and self.id == getattr(other, "id", None)
144 |
--------------------------------------------------------------------------------
/src/onpy/elements/__init__.py:
--------------------------------------------------------------------------------
1 | """OnShape elements."""
2 |
--------------------------------------------------------------------------------
/src/onpy/elements/assembly.py:
--------------------------------------------------------------------------------
1 | """Assembly element interface.
2 |
3 | Assemblies combine multiple parts together to create a complete product. As of
4 | now (May 2024), OnPy does not directly interface with assemblies, hence
5 | the lack of code.
6 |
7 | OnPy - May 2024 - Kyle Tennison
8 |
9 | """
10 |
11 | from typing import TYPE_CHECKING, override
12 |
13 | from onpy.api import schema
14 | from onpy.elements.base import Element
15 |
16 | if TYPE_CHECKING:
17 | from onpy.document import Document
18 |
19 |
20 | class Assembly(Element):
21 | """Represents an OnShape assembly."""
22 |
23 | def __init__(self, document: "Document", model: schema.Element) -> None:
24 | """Construct an assembly object from it's schema model.
25 |
26 | Args:
27 | document: The document that owns the assembly
28 | model: The schema model of the assembly
29 |
30 | """
31 | self._model = model
32 | self._document = document
33 |
34 | @property
35 | @override
36 | def document(self) -> "Document":
37 | return self._document
38 |
39 | @property
40 | @override
41 | def id(self) -> str:
42 | """The element id of the Assembly."""
43 | return self._model.id
44 |
45 | @property
46 | @override
47 | def name(self) -> str:
48 | """The name of the Assembly."""
49 | return self._model.name
50 |
51 | def __repr__(self) -> str:
52 | """Printable representation of the assembly."""
53 | return super().__repr__()
54 |
--------------------------------------------------------------------------------
/src/onpy/elements/base.py:
--------------------------------------------------------------------------------
1 | """Abstract base class for OnShape elements.
2 |
3 | In OnShape, each "tab" you see in the GUI is an "element." For instance,
4 | a partstudio is an element, so is an assembly, and so is a drawing. This script
5 | is the abstract base class for these OnShape elements.
6 |
7 | OnPy - May 2024 - Kyle Tennison
8 |
9 | """
10 |
11 | from abc import ABC, abstractmethod
12 | from typing import TYPE_CHECKING
13 |
14 | from onpy.api import schema
15 |
16 | if TYPE_CHECKING:
17 | from onpy.api.rest_api import RestApi
18 | from onpy.client import Client
19 | from onpy.document import Document
20 |
21 |
22 | class Element(ABC, schema.NameIdFetchable):
23 | """An abstract base class for OnShape elements."""
24 |
25 | @property
26 | @abstractmethod
27 | def document(self) -> "Document":
28 | """A reference to the current document."""
29 | ...
30 |
31 | @property
32 | def _client(self) -> "Client":
33 | """A reference tot he current client."""
34 | return self.document._client
35 |
36 | @property
37 | def _api(self) -> "RestApi":
38 | """A reference tot he current api."""
39 | return self._client._api
40 |
41 | @property
42 | @abstractmethod
43 | def id(self) -> str:
44 | """The id of the element."""
45 | ...
46 |
47 | @property
48 | @abstractmethod
49 | def name(self) -> str:
50 | """The name of the element."""
51 | ...
52 |
53 | @abstractmethod
54 | def __repr__(self) -> str:
55 | """Printable representation of the element."""
56 | return f"{self.__class__.__name__}(id={self.id})"
57 |
58 | def __str__(self) -> str:
59 | """Pretty string representation of the element."""
60 | return repr(self)
61 |
62 | def __eq__(self, other: object) -> bool:
63 | """Check if two elements are equal."""
64 | if isinstance(other, type(self)):
65 | return other.id == self.id and type(other) is type(self)
66 | return False
67 |
--------------------------------------------------------------------------------
/src/onpy/elements/partstudio.py:
--------------------------------------------------------------------------------
1 | """Partstudio element interface.
2 |
3 | In OnShape, partstudios are the place where parts are modeled. This is
4 | partstudios are fundamental to how OnPy operates; besides documents,
5 | they are effectively the entry point to creating a schema. All features
6 | are created by a partstudio, and they all live in the partstudio.
7 |
8 | OnPy - May 2024 - Kyle Tennison
9 |
10 | """
11 |
12 | from typing import TYPE_CHECKING, override
13 |
14 | from onpy.api import schema
15 | from onpy.api.versioning import WorkspaceWVM
16 | from onpy.elements.base import Element
17 | from onpy.entities.protocols import BodyEntityConvertible, FaceEntityConvertible
18 | from onpy.features import Extrude, Loft, OffsetPlane, Plane, Sketch, Translate
19 | from onpy.features.base import Feature, FeatureList
20 | from onpy.features.planes import DefaultPlane, DefaultPlaneOrientation
21 | from onpy.part import Part, PartList
22 | from onpy.util.misc import unwrap
23 |
24 | if TYPE_CHECKING:
25 | from onpy.document import Document
26 |
27 |
28 | class PartStudio(Element):
29 | """Represents a part studio."""
30 |
31 | def __init__(
32 | self,
33 | document: "Document",
34 | model: schema.Element,
35 | ) -> None:
36 | """Construct a new PartStudio object from a partstudio schema.
37 |
38 | Args:
39 | document: The document that owns the partstudio.
40 | model: The schema of the partstudio.
41 |
42 | """
43 | self._model = model
44 | self._document = document
45 | self._features: list[Feature] = []
46 |
47 | self._features.extend(self._get_default_planes())
48 |
49 | @property
50 | @override
51 | def document(self) -> "Document":
52 | return self._document
53 |
54 | @property
55 | @override
56 | def id(self) -> str:
57 | """The element id of the PartStudio."""
58 | return self._model.id
59 |
60 | @property
61 | @override
62 | def name(self) -> str:
63 | """The name of the PartStudio."""
64 | return self._model.name
65 |
66 | @property
67 | def features(self) -> FeatureList:
68 | """A list of the partstudio's features."""
69 | return FeatureList(self._features)
70 |
71 | def _get_default_planes(self) -> list[DefaultPlane]:
72 | """Get the default planes from the PartStudio."""
73 | return [DefaultPlane(self, orientation) for orientation in DefaultPlaneOrientation]
74 |
75 | def add_sketch(
76 | self,
77 | plane: Plane | FaceEntityConvertible,
78 | name: str = "New Sketch",
79 | ) -> Sketch:
80 | """Add a new sketch to the partstudio.
81 |
82 | Args:
83 | plane: The plane to base the sketch off of
84 | name: An optional name for the sketch
85 |
86 | Returns:
87 | A Sketch object
88 |
89 | """
90 | return Sketch(partstudio=self, plane=plane, name=name)
91 |
92 | def add_extrude(
93 | self,
94 | faces: FaceEntityConvertible,
95 | distance: float,
96 | name: str = "New Extrude",
97 | merge_with: BodyEntityConvertible | None = None,
98 | subtract_from: BodyEntityConvertible | None = None,
99 | ) -> Extrude:
100 | """Add a new blind extrude feature to the partstudio.
101 |
102 | Args:
103 | faces: The faces to extrude
104 | distance: The distance to extrude
105 | name: An optional name for the extrusion
106 | merge_with: An optional body to merge with
107 | subtract_from: An optional body to subtract the extruded volume from.
108 | This will make the extrusion into a subtraction.
109 |
110 | Returns:
111 | An Extrude object
112 |
113 | """
114 | return Extrude(
115 | partstudio=self,
116 | faces=faces,
117 | distance=distance,
118 | name=name,
119 | merge_with=merge_with,
120 | subtract_from=subtract_from,
121 | )
122 |
123 | def add_translate(
124 | self,
125 | part: Part,
126 | name: str = "New Translate",
127 | x: float = 0,
128 | y: float = 0,
129 | z: float = 0,
130 | *,
131 | copy: bool = False,
132 | ) -> Translate:
133 | """Add a new transform of type translate_xyz feature to the partstudio.
134 |
135 | Args:
136 | part: The part to be translated
137 | name: The name of the translation feature
138 | x: The distance to move in x direction
139 | y: The distance to move in y direction
140 | z: The distance to move in z direction
141 | copy: Bool to indicate if part should be copied
142 |
143 | Returns:
144 | An Translated object
145 |
146 | """
147 | return Translate(
148 | partstudio=self,
149 | part=part,
150 | name=name,
151 | x=x,
152 | y=y,
153 | z=z,
154 | copy=copy,
155 | )
156 |
157 | def add_loft(
158 | self,
159 | start: FaceEntityConvertible,
160 | end: FaceEntityConvertible,
161 | name: str = "Loft",
162 | ) -> Loft:
163 | """Add a new loft feature to the partstudio.
164 |
165 | Args:
166 | start: The start face(s) of the loft
167 | end: The end face(s) of the loft
168 | name: An optional name for the loft feature
169 |
170 | Returns:
171 | A Loft object
172 |
173 | """
174 | return Loft(partstudio=self, start_face=start, end_face=end, name=name)
175 |
176 | def add_offset_plane(
177 | self,
178 | target: Plane | FaceEntityConvertible,
179 | distance: float,
180 | name: str = "Offset Plane",
181 | ) -> OffsetPlane:
182 | """Add a new offset plane to the partstudio.
183 |
184 | Args:
185 | target: The plane to offset from
186 | distance: The distance to offset
187 | name: An optional name for the plane
188 |
189 | Returns:
190 | An OffsetPlane object
191 |
192 | """
193 | return OffsetPlane(partstudio=self, owner=target, distance=distance, name=name)
194 |
195 | def list_parts(self) -> list[Part]:
196 | """Get a list of parts attached to the partstudio."""
197 | parts = self._api.endpoints.list_parts(
198 | document_id=self.document.id,
199 | version=WorkspaceWVM(self.document.default_workspace.id),
200 | element_id=self.id,
201 | )
202 |
203 | return [Part(self, pmodel) for pmodel in parts]
204 |
205 | @property
206 | def parts(self) -> PartList:
207 | """A PartList object used to list available parts."""
208 | return PartList(self.list_parts())
209 |
210 | def wipe(self) -> None:
211 | """Remove all features from the current partstudio. Stores in another version."""
212 | self.document.create_version()
213 |
214 | features = self._api.endpoints.list_features(
215 | document_id=self._document.id,
216 | version=WorkspaceWVM(self._document.default_workspace.id),
217 | element_id=self.id,
218 | )
219 |
220 | features.reverse()
221 |
222 | for feature in features:
223 | self._api.endpoints.delete_feature(
224 | document_id=self._document.id,
225 | workspace_id=self._document.default_workspace.id,
226 | element_id=self.id,
227 | feature_id=unwrap(feature.featureId),
228 | )
229 |
230 | def __repr__(self) -> str:
231 | """Printable representation of the partstudio."""
232 | return super().__repr__()
233 |
--------------------------------------------------------------------------------
/src/onpy/entities/__init__.py:
--------------------------------------------------------------------------------
1 | """Onshape entity interface."""
2 |
3 | from onpy.entities.entities import (
4 | BodyEntity,
5 | EdgeEntity,
6 | Entity,
7 | FaceEntity,
8 | VertexEntity,
9 | )
10 | from onpy.entities.filter import EntityFilter
11 |
12 | __all__ = [
13 | "BodyEntity",
14 | "EdgeEntity",
15 | "Entity",
16 | "EntityFilter",
17 | "FaceEntity",
18 | "VertexEntity",
19 | ]
20 |
--------------------------------------------------------------------------------
/src/onpy/entities/entities.py:
--------------------------------------------------------------------------------
1 | """Interface to OnShape Entities.
2 |
3 | In OnShape, there are four types of entities:
4 | - Vertex Entities
5 | - Line Entities
6 | - Face Entities
7 | - Body Entities
8 |
9 | Different features can create different entities; e.g., an Extrude will create
10 | a BodyEntity. The different entity types, along with their base class, are
11 | defined here.
12 |
13 | OnPy - May 2024 - Kyle Tennison
14 |
15 | """
16 |
17 | from typing import override
18 |
19 | from onpy.entities.protocols import (
20 | BodyEntityConvertible,
21 | EdgeEntityConvertible,
22 | FaceEntityConvertible,
23 | VertexEntityConvertible,
24 | )
25 |
26 |
27 | class Entity:
28 | """A generic OnShape entity."""
29 |
30 | def __init__(self, transient_id: str) -> None:
31 | """Construct an entity from its transient id."""
32 | self.transient_id = transient_id
33 |
34 | @classmethod
35 | def as_featurescript(cls) -> str:
36 | """Convert the EntityType variant into a Featurescript expression."""
37 | msg = "An entity of unknown type should never be parsed into featurescript"
38 | raise NotImplementedError(
39 | msg,
40 | )
41 |
42 | @staticmethod
43 | def match_string_type(string: str) -> type["Entity"]:
44 | """Match a string to the corresponding entity class."""
45 | match = {
46 | "VERTEX": VertexEntity,
47 | "EDGE": EdgeEntity,
48 | "FACE": FaceEntity,
49 | "BODY": BodyEntity,
50 | }.get(string.upper())
51 |
52 | if match is None:
53 | msg = f"'{string}' is not a valid entity type"
54 | raise TypeError(msg)
55 |
56 | return match
57 |
58 | def __str__(self) -> str:
59 | """Pretty string representation of the entity."""
60 | return repr(self)
61 |
62 | def __repr__(self) -> str:
63 | """Printable representation of the entity."""
64 | return f"{self.__class__.__name__}({self.transient_id})"
65 |
66 |
67 | class VertexEntity(Entity, VertexEntityConvertible):
68 | """An entity that is a vertex."""
69 |
70 | @classmethod
71 | @override
72 | def as_featurescript(cls) -> str:
73 | return "EntityType.VERTEX"
74 |
75 | @override
76 | def _vertex_entities(self) -> list["VertexEntity"]:
77 | return [self]
78 |
79 |
80 | class EdgeEntity(Entity, EdgeEntityConvertible):
81 | """An entity that is an edge."""
82 |
83 | @classmethod
84 | @override
85 | def as_featurescript(cls) -> str:
86 | return "EntityType.EDGE"
87 |
88 | @override
89 | def _edge_entities(self) -> list["EdgeEntity"]:
90 | return [self]
91 |
92 |
93 | class FaceEntity(Entity, FaceEntityConvertible):
94 | """An entity that is a face."""
95 |
96 | @classmethod
97 | @override
98 | def as_featurescript(cls) -> str:
99 | return "EntityType.FACE"
100 |
101 | @override
102 | def _face_entities(self) -> list["FaceEntity"]:
103 | return [self]
104 |
105 |
106 | class BodyEntity(Entity, BodyEntityConvertible):
107 | """An entity that is a body."""
108 |
109 | @classmethod
110 | @override
111 | def as_featurescript(cls) -> str:
112 | return "EntityType.BODY"
113 |
114 | @override
115 | def _body_entities(self) -> list["BodyEntity"]:
116 | return [self]
117 |
--------------------------------------------------------------------------------
/src/onpy/entities/filter.py:
--------------------------------------------------------------------------------
1 | """Filtering mechanism for selecting entities.
2 |
3 | In OnShape, entities are usually interacted with through a series of queries.
4 | For instance, one might query the entity that is closest to a point, or the
5 | largest in a set of other entities. The functionality to query, and to join
6 | queries, is defined in EntityFilter.
7 |
8 | OnPy - May 2024 - Kyle Tennison
9 |
10 | """
11 |
12 | from textwrap import dedent
13 | from typing import TYPE_CHECKING, cast, override
14 |
15 | import onpy.entities.queries as qtypes
16 | from onpy.api import schema
17 | from onpy.api.versioning import WorkspaceWVM
18 | from onpy.entities import Entity, FaceEntity
19 | from onpy.entities.protocols import FaceEntityConvertible
20 | from onpy.util.exceptions import OnPyInternalError
21 | from onpy.util.misc import unwrap
22 |
23 | if TYPE_CHECKING:
24 | from onpy.elements.partstudio import PartStudio
25 |
26 |
27 | class EntityFilter[T: Entity](FaceEntityConvertible):
28 | """Object used to list and filter queries."""
29 |
30 | def __init__(self, partstudio: "PartStudio", available: list[T]) -> None:
31 | """Construct an EntityFilter object.
32 |
33 | Args:
34 | partstudio: The owning partstudio.
35 | available: A list of available entities.
36 |
37 | """
38 | self._available = available
39 | self._partstudio = partstudio
40 | self._client = partstudio._client
41 | self._api = partstudio._api
42 |
43 | @property
44 | def _entity_type(self) -> type[T]:
45 | """The class of the generic type T."""
46 | orig_class = getattr(self, "__orig__class__", None)
47 |
48 | etype = cast("T", orig_class.__args__[0]) if orig_class else Entity
49 |
50 | if not callable(etype):
51 | etype = Entity # default to generic entity
52 |
53 | if not issubclass(etype, Entity) or etype is not Entity:
54 | msg = f"Found illegal etype: {etype}"
55 | raise OnPyInternalError(msg)
56 |
57 | return cast("type[T]", etype)
58 |
59 | @override
60 | def _face_entities(self) -> list[FaceEntity]:
61 | return self.is_type(FaceEntity)._available
62 |
63 | def _apply_query(self, query: "qtypes.QueryType") -> list[T]:
64 | """Build the featurescript to evaluate a query and evaluates the featurescript.
65 |
66 | Args:
67 | query: The query to apply
68 |
69 | Returns:
70 | A list of resulting Entity instances
71 |
72 | """
73 | script = dedent(
74 | f"""
75 |
76 | function(context is Context, queries){{
77 |
78 | // Combine all transient ids into one query
79 | const transient_ids = {[e.transient_id for e in self._available]};
80 | var element_queries is array = makeArray(size(transient_ids));
81 |
82 | var idx = 0;
83 | for (var tid in transient_ids)
84 | {{
85 | var query = {{ "queryType" : QueryType.TRANSIENT, "transientId" : tid }} as Query;
86 | element_queries[idx] = query;
87 | idx += 1;
88 | }}
89 |
90 | var cumulative_query = qUnion(element_queries);
91 |
92 | // Apply specific query
93 | var specific_query = {query.inject_featurescript("cumulative_query")};
94 | var matching_entities = evaluateQuery(context, specific_query);
95 | return transientQueriesToStrings(matching_entities);
96 |
97 | }}
98 |
99 | """,
100 | )
101 |
102 | result = unwrap(
103 | self._api.endpoints.eval_featurescript(
104 | self._partstudio.document.id,
105 | version=WorkspaceWVM(self._partstudio.document.default_workspace.id),
106 | element_id=unwrap(self._partstudio.id),
107 | script=script,
108 | return_type=schema.FeaturescriptResponse,
109 | ).result,
110 | message=f"Query raised error when evaluating fs. Script:\n\n{script}",
111 | )
112 |
113 | transient_ids = [i["value"] for i in result["value"]]
114 | return [self._entity_type(transient_id=tid) for tid in transient_ids]
115 |
116 | def contains_point(self, point: tuple[float, float, float]) -> "EntityFilter":
117 | """Filter out all queries that don't contain the provided point.
118 |
119 | Args:
120 | point: The point to use for filtering
121 |
122 | """
123 | query = qtypes.qContainsPoint(point=point, units=self._client.units)
124 |
125 | return EntityFilter[T](
126 | partstudio=self._partstudio,
127 | available=self._apply_query(query),
128 | )
129 |
130 | def closest_to(self, point: tuple[float, float, float]) -> "EntityFilter":
131 | """Get the entity closest to the point.
132 |
133 | Args:
134 | point: The point to use for filtering
135 |
136 | """
137 | query = qtypes.qClosestTo(point=point, units=self._client.units)
138 |
139 | return EntityFilter[T](
140 | partstudio=self._partstudio,
141 | available=self._apply_query(query),
142 | )
143 |
144 | def largest(self) -> "EntityFilter":
145 | """Get the largest entity."""
146 | query = qtypes.qLargest()
147 |
148 | return EntityFilter[T](
149 | partstudio=self._partstudio,
150 | available=self._apply_query(query),
151 | )
152 |
153 | def smallest(self) -> "EntityFilter":
154 | """Get the smallest entity."""
155 | query = qtypes.qSmallest()
156 |
157 | return EntityFilter[T](
158 | partstudio=self._partstudio,
159 | available=self._apply_query(query),
160 | )
161 |
162 | def intersects(
163 | self,
164 | origin: tuple[float, float, float],
165 | direction: tuple[float, float, float],
166 | ) -> "EntityFilter":
167 | """Get the queries that intersect an infinite line.
168 |
169 | Args:
170 | origin: The origin on the line. This can be any point that lays on
171 | the line.
172 | direction: The direction vector of the line
173 |
174 | """
175 | query = qtypes.qIntersectsLine(
176 | line_origin=origin,
177 | line_direction=direction,
178 | units=self._client.units,
179 | )
180 |
181 | return EntityFilter[T](
182 | partstudio=self._partstudio,
183 | available=self._apply_query(query),
184 | )
185 |
186 | def is_type[E: Entity](self, entity_type: type[E]) -> "EntityFilter[E]":
187 | """Get the queries of a specific type.
188 |
189 | Args:
190 | entity_type: The entity type to filter. Supports VERTEX, EDGE,
191 | FACE, and BODY (case insensitive)
192 |
193 | """
194 | query = qtypes.qEntityType(entity_type=entity_type)
195 |
196 | available: list[E] = [entity_type(e.transient_id) for e in self._apply_query(query)]
197 |
198 | return EntityFilter[E](partstudio=self._partstudio, available=available)
199 |
200 | def __str__(self) -> str:
201 | """Pretty string representation of the filter."""
202 | return f"EntityFilter({self._available})"
203 |
--------------------------------------------------------------------------------
/src/onpy/entities/protocols.py:
--------------------------------------------------------------------------------
1 | """Protocols/traits that different objects can display.
2 |
3 | This system was inspired by Rust's trait system, which is similar to Python's
4 | Protocol system. Sometimes, we want to be able to target an object
5 | that is not directly an entity—such as wanting to extrude the total area of
6 | a sketch. We can do this by making the Sketch object implement the
7 | FaceEntityConversable trait, which allows it to convert itself into a set
8 | of face entities. The same can be done for all other entity types. The
9 | underlying traits are defined here.
10 |
11 | OnPy - May 2024 - Kyle Tennison
12 |
13 | """
14 |
15 | from abc import abstractmethod
16 | from typing import TYPE_CHECKING, Protocol, runtime_checkable
17 |
18 | if TYPE_CHECKING:
19 | from onpy.entities import BodyEntity, EdgeEntity, FaceEntity, VertexEntity
20 |
21 |
22 | @runtime_checkable
23 | class FaceEntityConvertible(Protocol):
24 | """A protocol used for items that can be converted into a list of face entities."""
25 |
26 | @abstractmethod
27 | def _face_entities(self) -> list["FaceEntity"]:
28 | """Convert the current object into a list of face entities."""
29 | ...
30 |
31 |
32 | @runtime_checkable
33 | class VertexEntityConvertible(Protocol):
34 | """A protocol used for items that can be converted into a list of vertex entities."""
35 |
36 | @abstractmethod
37 | def _vertex_entities(self) -> list["VertexEntity"]:
38 | """Convert the current object into a list of vertex entities."""
39 | ...
40 |
41 |
42 | @runtime_checkable
43 | class EdgeEntityConvertible(Protocol):
44 | """A protocol used for items that can be converted into a list of edge entities."""
45 |
46 | @abstractmethod
47 | def _edge_entities(self) -> list["EdgeEntity"]:
48 | """Convert the current object into a list of edge entities."""
49 | ...
50 |
51 |
52 | @runtime_checkable
53 | class BodyEntityConvertible(Protocol):
54 | """A protocol used for items that can be converted into a list of body entities."""
55 |
56 | @abstractmethod
57 | def _body_entities(self) -> list["BodyEntity"]:
58 | """Convert the current object into a list of body entities."""
59 | ...
60 |
--------------------------------------------------------------------------------
/src/onpy/entities/queries.py:
--------------------------------------------------------------------------------
1 | """Python wrappings of OnShape queries.
2 |
3 | The queries used by entities.py are defined here. They are used to filter
4 | entities.
5 |
6 | OnPy - May 2024 - Kyle Tennison
7 |
8 | """
9 |
10 | from abc import ABC, abstractmethod
11 | from dataclasses import dataclass
12 | from typing import override
13 |
14 | from onpy.entities import Entity
15 | from onpy.util.misc import UnitSystem
16 |
17 |
18 | class QueryType(ABC):
19 | """Used to represent the type of a query."""
20 |
21 | @abstractmethod
22 | def inject_featurescript(self, q_to_filter: str) -> str:
23 | """Generate featurescript that will create a Query object of this type.
24 |
25 | Args:
26 | q_to_filter: The internal query to filter
27 |
28 | """
29 | ...
30 |
31 | @staticmethod
32 | def make_point_vector(point: tuple[float, float, float], units: UnitSystem) -> str:
33 | """Make a point into a dimensioned Featurescript vector.
34 |
35 | Args:
36 | point: The point to convert
37 | units: The UnitSystem to dimension with
38 |
39 | Returns:
40 | The Featurescript expression for the point with units
41 |
42 | """
43 | return f"(vector([{point[0]}, {point[1]}, {point[2]}]) * {units.fs_name})"
44 |
45 | @classmethod
46 | def make_line(
47 | cls,
48 | origin: tuple[float, float, float],
49 | direction: tuple[float, float, float],
50 | units: UnitSystem,
51 | ) -> str:
52 | """Make a Featurescript line.
53 |
54 | Args:
55 | origin: The origin of the line
56 | direction: The direction of the line
57 | units: The UnitSystem to dimension with
58 |
59 | Returns:
60 | The Featurescript expression for the point with units
61 |
62 | """
63 | return f'({{"origin": {cls.make_point_vector(origin, units)}, "direction": vector([{direction[0]}, {direction[1]}, {direction[2]}]) }} as Line)' # noqa: E501
64 |
65 | @staticmethod
66 | def add_units(value: float, units: UnitSystem) -> str:
67 | """Add units to a number for Featurescript.
68 |
69 | Args:
70 | value: The value to add units to
71 | units: The UnitSystem to dimension with
72 |
73 | Returns:
74 | The Featurescript expression for the value with units
75 |
76 | """
77 | return f"({value}*{units.fs_name})"
78 |
79 |
80 | @dataclass
81 | class qContainsPoint(QueryType):
82 | """Wrap the OnShape qContainsPoint query."""
83 |
84 | point: tuple[float, float, float]
85 | units: UnitSystem
86 |
87 | @override
88 | def inject_featurescript(self, q_to_filter: str) -> str:
89 | return f"qContainsPoint({q_to_filter}, {self.make_point_vector(self.point, self.units)})"
90 |
91 |
92 | @dataclass
93 | class qClosestTo(QueryType):
94 | """Wrap the OnShape qClosestTo query."""
95 |
96 | point: tuple[float, float, float]
97 | units: UnitSystem
98 |
99 | @override
100 | def inject_featurescript(self, q_to_filter: str) -> str:
101 | return f"qClosestTo({q_to_filter}, {self.make_point_vector(self.point, self.units)})"
102 |
103 |
104 | @dataclass
105 | class qLargest(QueryType):
106 | """Wrap the OnShape qLargest query."""
107 |
108 | @override
109 | def inject_featurescript(self, q_to_filter: str) -> str:
110 | return f"qLargest({q_to_filter})"
111 |
112 |
113 | @dataclass
114 | class qSmallest(QueryType):
115 | """Wrap the OnShape qSmallest query."""
116 |
117 | @override
118 | def inject_featurescript(self, q_to_filter: str) -> str:
119 | return f"qSmallest({q_to_filter})"
120 |
121 |
122 | @dataclass
123 | class qWithinRadius(QueryType):
124 | """Wrap the OnShape qWithinRadius query."""
125 |
126 | point: tuple[float, float, float]
127 | radius: float
128 | units: UnitSystem
129 |
130 | @override
131 | def inject_featurescript(self, q_to_filter: str) -> str:
132 | return f"qWithinRadius({q_to_filter})"
133 |
134 |
135 | @dataclass
136 | class qIntersectsLine(QueryType):
137 | """Wrap the OnShape qIntersectsLine query."""
138 |
139 | line_origin: tuple[float, float, float]
140 | line_direction: tuple[float, float, float]
141 | units: UnitSystem
142 |
143 | @override
144 | def inject_featurescript(self, q_to_filter: str) -> str:
145 | return f"qIntersectsLine({q_to_filter}, {self.make_line(self.line_origin, self.line_direction, self.units)})" # noqa: E501
146 |
147 |
148 | @dataclass
149 | class qEntityType(QueryType):
150 | """Wrap the OnShape qEntityType query."""
151 |
152 | entity_type: type[Entity]
153 |
154 | @override
155 | def inject_featurescript(self, q_to_filter: str) -> str:
156 | return f"qEntityFilter({q_to_filter}, {self.entity_type.as_featurescript()})"
157 |
--------------------------------------------------------------------------------
/src/onpy/features/__init__.py:
--------------------------------------------------------------------------------
1 | """OnPy interfaces to OnShape Features."""
2 |
3 | from onpy.features.extrude import Extrude
4 | from onpy.features.loft import Loft
5 | from onpy.features.planes import DefaultPlane, OffsetPlane, Plane
6 | from onpy.features.sketch.sketch import Sketch
7 | from onpy.features.translate import Translate
8 |
9 | __all__ = [
10 | "DefaultPlane",
11 | "Extrude",
12 | "Loft",
13 | "OffsetPlane",
14 | "Plane",
15 | "Sketch",
16 | "Translate",
17 | ]
18 |
--------------------------------------------------------------------------------
/src/onpy/features/base.py:
--------------------------------------------------------------------------------
1 | """Abstract base class for features.
2 |
3 | Features are the operations taken to create some geometry; they are
4 | fundamental to the idea of parametric cad. The base class for OnShape Features
5 | is defined here.
6 |
7 | OnPy - May 2024 - Kyle Tennison
8 |
9 | """
10 |
11 | from abc import ABC, abstractmethod
12 | from textwrap import dedent
13 | from typing import TYPE_CHECKING, cast
14 |
15 | from loguru import logger
16 |
17 | from onpy.api import schema
18 | from onpy.api.versioning import WorkspaceWVM
19 | from onpy.part import Part
20 | from onpy.util.exceptions import OnPyFeatureError, OnPyParameterError
21 | from onpy.util.misc import unwrap
22 |
23 | if TYPE_CHECKING:
24 | from onpy.api.rest_api import RestApi
25 | from onpy.client import Client
26 | from onpy.document import Document
27 | from onpy.elements.partstudio import PartStudio
28 | from onpy.entities import EntityFilter
29 | from onpy.features.planes import Plane
30 |
31 |
32 | class Feature(ABC):
33 | """An abstract base class for OnShape elements."""
34 |
35 | @property
36 | @abstractmethod
37 | def partstudio(self) -> "PartStudio":
38 | """A reference to the owning PartStudio."""
39 |
40 | @property
41 | def document(self) -> "Document":
42 | """A reference to the owning document."""
43 | return self.partstudio.document
44 |
45 | @property
46 | def _client(self) -> "Client":
47 | """A reference to the underlying client."""
48 | return self.document._client
49 |
50 | @property
51 | def _api(self) -> "RestApi":
52 | """A reference to the api."""
53 | return self._client._api
54 |
55 | @property
56 | @abstractmethod
57 | def id(self) -> str | None:
58 | """The id of the feature."""
59 | ...
60 |
61 | @property
62 | @abstractmethod
63 | def name(self) -> str:
64 | """The name of the feature."""
65 | ...
66 |
67 | @property
68 | @abstractmethod
69 | def entities(self) -> "EntityFilter":
70 | """An object used for interfacing with entities that belong to the object."""
71 | ...
72 |
73 | @abstractmethod
74 | def _to_model(self) -> schema.Feature:
75 | """Convert the feature into the corresponding api schema."""
76 | ...
77 |
78 | @abstractmethod
79 | def _load_response(self, response: schema.FeatureAddResponse) -> None:
80 | """Load the feature add response to the feature metadata."""
81 | ...
82 |
83 | def _upload_feature(self) -> None:
84 | """Add a feature to the partstudio.
85 |
86 | Raises:
87 | OnPyFeatureError if the feature fails to load
88 |
89 | """
90 | response = self._api.endpoints.add_feature(
91 | document_id=self.document.id,
92 | version=WorkspaceWVM(self.document.default_workspace.id),
93 | element_id=self.partstudio.id,
94 | feature=self._to_model(),
95 | )
96 |
97 | self.partstudio._features.append(self)
98 |
99 | if response.featureState.featureStatus != "OK":
100 | if response.featureState.featureStatus == "WARNING":
101 | logger.warning("Feature loaded with warning")
102 | else:
103 | msg = "Feature errored on upload"
104 | raise OnPyFeatureError(msg)
105 | else:
106 | logger.debug(f"Successfully uploaded feature '{self.name}'")
107 |
108 | self._load_response(response)
109 |
110 | def _update_feature(self) -> None:
111 | """Update the feature in the cloud."""
112 | response = self._api.endpoints.update_feature(
113 | document_id=self.document.id,
114 | workspace_id=self.document.default_workspace.id,
115 | element_id=self.partstudio.id,
116 | feature=self._to_model(),
117 | )
118 |
119 | if response.featureState.featureStatus != "OK":
120 | if response.featureState.featureStatus == "WARNING":
121 | logger.warning("Feature loaded with warning")
122 | else:
123 | msg = "Feature errored on update"
124 | raise OnPyFeatureError(msg)
125 | else:
126 | logger.debug(f"Successfully updated feature '{self.name}'")
127 |
128 | def _get_created_parts_inner(self) -> list[Part]:
129 | """Get the parts created by the current feature. Wrap this
130 | function in `get_created_parts` to expose it to the user, ONLY if
131 | it is applicable to the feature.
132 |
133 | Returns:
134 | A list of Part objects
135 |
136 | """
137 | script = dedent(
138 | f"""
139 | function(context is Context, queries) {{
140 | var query = qCreatedBy(makeId("{self.id}"), EntityType.BODY);
141 |
142 | return transientQueriesToStrings( evaluateQuery(context, query) );
143 | }}
144 | """,
145 | )
146 |
147 | response = self._client._api.endpoints.eval_featurescript(
148 | document_id=self.partstudio.document.id,
149 | version=WorkspaceWVM(self.partstudio.document.default_workspace.id),
150 | element_id=self.partstudio.id,
151 | script=script,
152 | return_type=schema.FeaturescriptResponse,
153 | )
154 |
155 | part_ids_raw = unwrap(
156 | response.result,
157 | message="Featurescript failed get parts created by feature",
158 | )["value"]
159 |
160 | part_ids = [i["value"] for i in part_ids_raw]
161 |
162 | available_parts = self._api.endpoints.list_parts(
163 | document_id=self.partstudio.document.id,
164 | version=WorkspaceWVM(self.partstudio.document.default_workspace.id),
165 | element_id=self.partstudio.id,
166 | )
167 |
168 | return [Part(self.partstudio, part) for part in available_parts if part.partId in part_ids]
169 |
170 |
171 | class FeatureList:
172 | """Wrapper around a list of features."""
173 |
174 | def __init__(self, features: list[Feature]) -> None:
175 | """Construct a FeatureList wrapper from a list[Features] object."""
176 | self._features = features
177 |
178 | def __len__(self) -> int:
179 | """Get the number of features in the list."""
180 | return len(self._features)
181 |
182 | def __getitem__(self, name: str) -> Feature:
183 | """Get an item by its name."""
184 | matches = [f for f in self._features if f.name == name]
185 |
186 | if len(matches) == 0:
187 | msg = f"No feature named '{name}'"
188 | raise OnPyParameterError(msg)
189 | if len(matches) > 1:
190 | msg = f"Multiple '{name}' features. Use .get(id=...)"
191 | raise OnPyParameterError(msg)
192 | return matches[0]
193 |
194 | def __str__(self) -> str:
195 | """Pretty string representation of the feature list."""
196 | msg = "FeatureList(\n"
197 | for f in self._features:
198 | msg += f" {f}\n"
199 | msg += ")"
200 | return msg
201 |
202 | @property
203 | def front_plane(self) -> "Plane":
204 | """A reference to the front default plane."""
205 | return cast("Plane", self["Front Plane"])
206 |
207 | @property
208 | def top_plane(self) -> "Plane":
209 | """A reference to the top default plane."""
210 | return cast("Plane", self["Top Plane"])
211 |
212 | @property
213 | def right_plane(self) -> "Plane":
214 | """A reference to the right default plane."""
215 | return cast("Plane", self["Right Plane"])
216 |
--------------------------------------------------------------------------------
/src/onpy/features/extrude.py:
--------------------------------------------------------------------------------
1 | """Interface to the Extrude Feature.
2 |
3 | This script defines the Extrude feature. Currently, only blind, solid extrusions
4 | are supported.
5 |
6 | OnPy - May 2024 - Kyle Tennison
7 |
8 | """
9 |
10 | from typing import TYPE_CHECKING, override
11 |
12 | from onpy.api import schema
13 | from onpy.api.schema import FeatureAddResponse
14 | from onpy.entities import EntityFilter
15 | from onpy.entities.protocols import BodyEntityConvertible, FaceEntityConvertible
16 | from onpy.features.base import Feature
17 | from onpy.part import Part
18 | from onpy.util.misc import unwrap
19 |
20 | if TYPE_CHECKING:
21 | from onpy.elements.partstudio import PartStudio
22 |
23 |
24 | class Extrude(Feature):
25 | """Represents an extrusion feature."""
26 |
27 | def __init__(
28 | self,
29 | partstudio: "PartStudio",
30 | faces: FaceEntityConvertible,
31 | distance: float,
32 | name: str = "Extrusion",
33 | merge_with: BodyEntityConvertible | None = None,
34 | subtract_from: BodyEntityConvertible | None = None,
35 | ) -> None:
36 | """Construct an extrude feature.
37 |
38 | Args:
39 | partstudio: The owning partstudio
40 | faces: The faces to extrude
41 | distance: The distance to extrude the faces.
42 | name: The name of the extrusion feature
43 | merge_with: Optional body to merge the extrude with.
44 | subtract_from: Optional body to subtract the extrude volume from.
45 | Makes the extrusion act as a subtraction.
46 |
47 | """
48 | self.targets = faces._face_entities()
49 | self._id: str | None = None
50 | self._partstudio = partstudio
51 | self._name = name
52 | self.distance = distance
53 | self._merge_with = merge_with
54 | self._subtract_from = subtract_from
55 |
56 | self._upload_feature()
57 |
58 | def get_created_parts(self) -> list[Part]:
59 | """Get a list of the parts this feature created."""
60 | return self._get_created_parts_inner()
61 |
62 | @property
63 | @override
64 | def id(self) -> str | None:
65 | return unwrap(self._id, message="Extrude feature id unbound")
66 |
67 | @property
68 | @override
69 | def partstudio(self) -> "PartStudio":
70 | return self._partstudio
71 |
72 | @property
73 | @override
74 | def name(self) -> str:
75 | return self._name
76 |
77 | @property
78 | @override
79 | def entities(self) -> EntityFilter:
80 | # TODO @kyle-tennison: Not currently implemented. Load with items
81 | return EntityFilter(self.partstudio, available=[])
82 |
83 | @property
84 | def _extrude_bool_type(self) -> str:
85 | """Get the boolean type of the extrude. Can be NEW, ADD, or REMOVE."""
86 | if self._subtract_from is not None:
87 | return "REMOVE"
88 |
89 | if self._merge_with is not None:
90 | return "ADD"
91 |
92 | return "NEW"
93 |
94 | @property
95 | def _boolean_scope(self) -> list[str]:
96 | """Returns a list of transient ids that define the boolean scope of
97 | the extrude.
98 | """
99 | if self._subtract_from is not None:
100 | return [e.transient_id for e in self._subtract_from._body_entities()]
101 |
102 | if self._merge_with is not None:
103 | return [e.transient_id for e in self._merge_with._body_entities()]
104 |
105 | return []
106 |
107 | @override
108 | def _to_model(self) -> schema.Extrude:
109 | return schema.Extrude(
110 | name=self.name,
111 | featureId=self._id,
112 | suppressed=False,
113 | parameters=[
114 | {
115 | "btType": "BTMParameterEnum-145",
116 | "parameterId": "bodyType",
117 | "value": "SOLID",
118 | "enumName": "ExtendedToolBodyType",
119 | },
120 | {
121 | "btType": "BTMParameterEnum-145",
122 | "value": self._extrude_bool_type,
123 | "enumName": "NewBodyOperationType",
124 | "parameterId": "operationType",
125 | },
126 | {
127 | "btType": "BTMParameterQueryList-148",
128 | "queries": [
129 | {
130 | "btType": "BTMIndividualQuery-138",
131 | "deterministicIds": [e.transient_id for e in self.targets],
132 | },
133 | ],
134 | "parameterId": "entities",
135 | },
136 | {
137 | "btType": "BTMParameterEnum-145",
138 | "enumName": "BoundingType",
139 | "value": "BLIND",
140 | "parameterId": "endBound",
141 | },
142 | {
143 | "btType": "BTMParameterQuantity-147",
144 | "expression": f"{self.distance} {self._client.units.extension}",
145 | "parameterId": "depth",
146 | },
147 | {
148 | "btType": "BTMParameterQueryList-148",
149 | "queries": [
150 | {
151 | "btType": "BTMIndividualQuery-138",
152 | "deterministicIds": self._boolean_scope,
153 | },
154 | ],
155 | "parameterId": "booleanScope",
156 | },
157 | ],
158 | )
159 |
160 | @override
161 | def _load_response(self, response: FeatureAddResponse) -> None:
162 | self._id = response.feature.featureId
163 |
--------------------------------------------------------------------------------
/src/onpy/features/loft.py:
--------------------------------------------------------------------------------
1 | """Interface to the Loft Feature.
2 |
3 | This script defines the Loft feature. Lofts generate a solid between
4 | two offset 2D regions
5 |
6 | OnPy - May 2024 - Kyle Tennison
7 |
8 | """
9 |
10 | from typing import TYPE_CHECKING, override
11 |
12 | from onpy.api import schema
13 | from onpy.api.schema import FeatureAddResponse
14 | from onpy.entities import EntityFilter, FaceEntity
15 | from onpy.entities.protocols import FaceEntityConvertible
16 | from onpy.features.base import Feature
17 | from onpy.part import Part
18 | from onpy.util.misc import unwrap
19 |
20 | if TYPE_CHECKING:
21 | from onpy.elements.partstudio import PartStudio
22 |
23 |
24 | class Loft(Feature):
25 | """Interface to lofting between two 2D profiles."""
26 |
27 | def __init__(
28 | self,
29 | partstudio: "PartStudio",
30 | start_face: FaceEntityConvertible,
31 | end_face: FaceEntityConvertible,
32 | name: str = "Loft",
33 | ) -> None:
34 | """Construct a loft feature.
35 |
36 | Args:
37 | partstudio: The owning partstudio
38 | start_face: The face to start the loft on
39 | end_face: The face to end the loft on
40 | name: The name of the loft feature
41 |
42 | """
43 | self._partstudio = partstudio
44 | self._id: str | None = None
45 | self._name = name
46 |
47 | self.start_faces: list[FaceEntity] = start_face._face_entities()
48 | self.end_faces: list[FaceEntity] = end_face._face_entities()
49 |
50 | self._upload_feature()
51 |
52 | def get_created_parts(self) -> list[Part]:
53 | """Get a list of the parts this feature created."""
54 | return self._get_created_parts_inner()
55 |
56 | @property
57 | @override
58 | def partstudio(self) -> "PartStudio":
59 | return self._partstudio
60 |
61 | @property
62 | @override
63 | def id(self) -> str:
64 | return unwrap(self._id, "Loft feature id unbound")
65 |
66 | @property
67 | @override
68 | def name(self) -> str:
69 | return self._name
70 |
71 | @property
72 | @override
73 | def entities(self) -> EntityFilter:
74 | # TODO @kyle-tennison: Not currently implemented. Need to load with items.
75 | return EntityFilter(self.partstudio, available=[])
76 |
77 | @override
78 | def _load_response(self, response: FeatureAddResponse) -> None:
79 | self._id = response.feature.featureId
80 |
81 | @override
82 | def _to_model(self) -> schema.Loft:
83 | return schema.Loft(
84 | name=self.name,
85 | suppressed=False,
86 | parameters=[
87 | {
88 | "btType": "BTMParameterEnum-145",
89 | "namespace": "",
90 | "enumName": "ExtendedToolBodyType",
91 | "value": "SOLID",
92 | "parameterId": "bodyType",
93 | },
94 | {
95 | "btType": "BTMParameterEnum-145",
96 | "namespace": "",
97 | "enumName": "NewBodyOperationType",
98 | "value": "NEW",
99 | "parameterId": "operationType",
100 | },
101 | {
102 | "btType": "BTMParameterEnum-145",
103 | "namespace": "",
104 | "enumName": "NewSurfaceOperationType",
105 | "value": "NEW",
106 | "parameterId": "surfaceOperationType",
107 | },
108 | {
109 | "btType": "BTMParameterArray-2025",
110 | "items": [
111 | {
112 | "btType": "BTMArrayParameterItem-1843",
113 | "parameters": [
114 | {
115 | "btType": "BTMParameterQueryList-148",
116 | "queries": [
117 | {
118 | "btType": "BTMIndividualQuery-138",
119 | "deterministicIds": [
120 | e.transient_id for e in self.start_faces
121 | ],
122 | },
123 | ],
124 | "parameterId": "sheetProfileEntities",
125 | },
126 | ],
127 | },
128 | {
129 | "btType": "BTMArrayParameterItem-1843",
130 | "parameters": [
131 | {
132 | "btType": "BTMParameterQueryList-148",
133 | "queries": [
134 | {
135 | "btType": "BTMIndividualQuery-138",
136 | "deterministicIds": [
137 | e.transient_id for e in self.end_faces
138 | ],
139 | },
140 | ],
141 | "parameterId": "sheetProfileEntities",
142 | },
143 | ],
144 | },
145 | ],
146 | "parameterId": "sheetProfilesArray",
147 | },
148 | ],
149 | )
150 |
--------------------------------------------------------------------------------
/src/onpy/features/planes.py:
--------------------------------------------------------------------------------
1 | """Interface to OnShape Planes.
2 |
3 | There are multiple types of planes in OnShape, all of which are defined here.
4 | There is an abstract plane class used to reconcile these multiple classes.
5 |
6 | OnPy - May 2024 - Kyle Tennison
7 |
8 | """
9 |
10 | from abc import abstractmethod
11 | from enum import Enum
12 | from textwrap import dedent
13 | from typing import TYPE_CHECKING, Never, override
14 |
15 | from onpy.api import schema
16 | from onpy.api.versioning import WorkspaceWVM
17 | from onpy.entities import EntityFilter
18 | from onpy.entities.protocols import FaceEntityConvertible
19 | from onpy.features.base import Feature
20 | from onpy.util.misc import unwrap
21 |
22 | if TYPE_CHECKING:
23 | from onpy.elements.partstudio import PartStudio
24 |
25 |
26 | class Plane(Feature):
27 | """Abstract Base Class for all Planes."""
28 |
29 | @property
30 | @override
31 | def entities(self) -> EntityFilter:
32 | """The entities in the plane.
33 |
34 | Plane cannot contain entities, this will always be empty.
35 |
36 | Returns:
37 | An EntityFilter object.
38 |
39 | """
40 | return EntityFilter(self.partstudio, available=[])
41 |
42 | @property
43 | @abstractmethod
44 | def transient_id(self) -> str:
45 | """Get the transient ID of the plane."""
46 | ...
47 |
48 | def __repr__(self) -> str:
49 | """Printable representation of the string."""
50 | return f'Plane("{self.name}")'
51 |
52 | def __str__(self) -> str:
53 | """Pretty string representation of the string."""
54 | return repr(self)
55 |
56 |
57 | class DefaultPlaneOrientation(Enum):
58 | """The different orientations that the DefaultPlane can be."""
59 |
60 | TOP = "Top"
61 | FRONT = "Front"
62 | RIGHT = "Right"
63 |
64 |
65 | class DefaultPlane(Plane):
66 | """Used to reference the default planes that OnShape generates."""
67 |
68 | def __init__(
69 | self,
70 | partstudio: "PartStudio",
71 | orientation: DefaultPlaneOrientation,
72 | ) -> None:
73 | """Construct a default plane.
74 |
75 | Args:
76 | partstudio: The owning partstudio
77 | orientation: The orientation of the plane.
78 |
79 | """
80 | self._partstudio = partstudio
81 | self.orientation = orientation
82 |
83 | @property
84 | @override
85 | def partstudio(self) -> "PartStudio":
86 | return self._partstudio
87 |
88 | @property
89 | @override
90 | def id(self) -> str:
91 | return self.transient_id # we don't need the feature id of the default plane
92 |
93 | @property
94 | @override
95 | def transient_id(self) -> str:
96 | return self._load_plane_id()
97 |
98 | @property
99 | @override
100 | def name(self) -> str:
101 | return f"{self.orientation.value} Plane"
102 |
103 | def _load_plane_id(self) -> str:
104 | """Load the plane id.
105 |
106 | Returns:
107 | The plane ID
108 |
109 | """
110 | plane_script = dedent(
111 | """
112 | function(context is Context, queries) {
113 | return transientQueriesToStrings(evaluateQuery(context, qCreatedBy(makeId("ORIENTATION"), EntityType.FACE)));
114 | }
115 | """.replace( # noqa: E501
116 | "ORIENTATION",
117 | self.orientation.value,
118 | ),
119 | )
120 |
121 | response = self._client._api.endpoints.eval_featurescript(
122 | document_id=self.document.id,
123 | version=WorkspaceWVM(self.document.default_workspace.id),
124 | element_id=self.partstudio.id,
125 | script=plane_script,
126 | return_type=schema.FeaturescriptResponse,
127 | )
128 |
129 | return unwrap(
130 | response.result,
131 | message="Featurescript failed to load default plane",
132 | )["value"][0]["value"]
133 |
134 | @override
135 | def _to_model(self) -> Never:
136 | msg = "Default planes cannot be converted to a model"
137 | raise NotImplementedError(msg)
138 |
139 | @override
140 | def _load_response(self, response: schema.FeatureAddResponse) -> None:
141 | msg = "DefaultPlane should not receive a response object"
142 | raise NotImplementedError(msg)
143 |
144 |
145 | class OffsetPlane(Plane):
146 | """Represents a linearly offset plane."""
147 |
148 | def __init__(
149 | self,
150 | partstudio: "PartStudio",
151 | owner: Plane | FaceEntityConvertible,
152 | distance: float,
153 | name: str = "Offset Plane",
154 | ) -> None:
155 | """Construct an offset plane.
156 |
157 | Args:
158 | partstudio: The owning partstudio
159 | owner: The entity to base the offset plane off of
160 | distance: The offset distance
161 | name: The name of the offset plane entity
162 |
163 | """
164 | self._partstudio = partstudio
165 | self._owner = owner
166 | self._name = name
167 |
168 | self._id: str | None = None
169 | self.distance = distance
170 |
171 | self._upload_feature()
172 |
173 | @property
174 | def owner(self) -> Plane | FaceEntityConvertible:
175 | """The owning entity of the offset plane."""
176 | return self._owner
177 |
178 | @property
179 | @override
180 | def partstudio(self) -> "PartStudio":
181 | """The owning partstudio."""
182 | return self._partstudio
183 |
184 | @property
185 | @override
186 | def name(self) -> str:
187 | """The name of the offset plane."""
188 | return self._name
189 |
190 | @property
191 | @override
192 | def id(self) -> str:
193 | """The element id of the plane."""
194 | return unwrap(self._id, "Plane id unbound")
195 |
196 | @property
197 | @override
198 | def transient_id(self) -> str:
199 | """The transient ID of the plane."""
200 | script = dedent(
201 | f"""
202 |
203 | function(context is Context, queries) {{
204 |
205 | var feature_id = makeId("{self.id}");
206 | var face = evaluateQuery(context, qCreatedBy(feature_id, EntityType.FACE))[0];
207 | return transientQueriesToStrings(face);
208 |
209 | }}
210 | """,
211 | )
212 |
213 | response = self._client._api.endpoints.eval_featurescript(
214 | document_id=self.document.id,
215 | version=WorkspaceWVM(self.document.default_workspace.id),
216 | element_id=self.partstudio.id,
217 | script=script,
218 | return_type=schema.FeaturescriptResponse,
219 | )
220 |
221 | return unwrap(
222 | response.result,
223 | message="Featurescript failed to load offset plane transient id",
224 | )["value"]
225 |
226 | def _get_owner_transient_ids(self) -> list[str]:
227 | """Get the transient id(s) of the owner."""
228 | if isinstance(self.owner, Plane):
229 | return [self.owner.transient_id]
230 | return [e.transient_id for e in self.owner._face_entities()]
231 |
232 | @override
233 | def _to_model(self) -> schema.Plane:
234 | return schema.Plane(
235 | name=self.name,
236 | parameters=[
237 | {
238 | "btType": "BTMParameterQueryList-148",
239 | "queries": [
240 | {
241 | "btType": "BTMIndividualQuery-138",
242 | "deterministicIds": self._get_owner_transient_ids(),
243 | },
244 | ],
245 | "parameterId": "entities",
246 | },
247 | {
248 | "btType": "BTMParameterEnum-145",
249 | "namespace": "",
250 | "enumName": "CPlaneType",
251 | "value": "OFFSET",
252 | "parameterId": "cplaneType",
253 | },
254 | {
255 | "btType": "BTMParameterQuantity-147",
256 | "isInteger": False,
257 | "value": 0,
258 | "units": "",
259 | "expression": f"{abs(self.distance)} {self._client.units.extension}",
260 | "parameterId": "offset",
261 | },
262 | {
263 | "btType": "BTMParameterBoolean-144",
264 | "value": self.distance < 0,
265 | "nodeId": "MMaw54aRdL0c7OmQp",
266 | "parameterId": "oppositeDirection",
267 | },
268 | ],
269 | suppressed=False,
270 | )
271 |
272 | @override
273 | def _load_response(self, response: schema.FeatureAddResponse) -> None:
274 | self._id = response.feature.featureId
275 |
--------------------------------------------------------------------------------
/src/onpy/features/sketch/__init__.py:
--------------------------------------------------------------------------------
1 | """Sketch features and entities."""
2 |
--------------------------------------------------------------------------------
/src/onpy/features/sketch/sketch.py:
--------------------------------------------------------------------------------
1 | """Interface to the Sketch Feature.
2 |
3 | This script defines the Sketch feature. Because sketches are naturally the
4 | most complex feature, it has been moved to its own submodule.
5 |
6 | OnPy - May 2024 - Kyle Tennison
7 |
8 | """
9 |
10 | import copy
11 | import math
12 | from collections.abc import Sequence
13 | from textwrap import dedent
14 | from typing import TYPE_CHECKING, override
15 |
16 | import numpy as np
17 | from loguru import logger
18 |
19 | from onpy.api import schema
20 | from onpy.api.versioning import WorkspaceWVM
21 | from onpy.entities import EdgeEntity, Entity, EntityFilter, FaceEntity, VertexEntity
22 | from onpy.entities.protocols import FaceEntityConvertible
23 | from onpy.features.base import Feature
24 | from onpy.features.sketch.sketch_items import (
25 | SketchArc,
26 | SketchCircle,
27 | SketchItem,
28 | SketchLine,
29 | )
30 | from onpy.util.exceptions import OnPyFeatureError
31 | from onpy.util.misc import Point2D, UnitSystem, unwrap
32 |
33 | if TYPE_CHECKING:
34 | from onpy.elements.partstudio import PartStudio
35 | from onpy.features.planes import Plane
36 |
37 |
38 | class Sketch(Feature, FaceEntityConvertible):
39 | """The OnShape Sketch Feature, used to build 2D geometries."""
40 |
41 | def __init__(
42 | self,
43 | partstudio: "PartStudio",
44 | plane: "Plane|FaceEntityConvertible",
45 | name: str = "New Sketch",
46 | ) -> None:
47 | """Construct a new sketch.
48 |
49 | Args:
50 | partstudio: The partstudio that owns the sketch.
51 | plane: The plane to base the sketch on.
52 | name: The name of the sketch entity.
53 |
54 | """
55 | self.plane = plane
56 | self._partstudio = partstudio
57 | self._name = name
58 | self._id: str | None = None
59 | self._items: set[SketchItem] = set()
60 |
61 | self._upload_feature()
62 |
63 | @property
64 | @override
65 | def partstudio(self) -> "PartStudio":
66 | return self._partstudio
67 |
68 | @property
69 | @override
70 | def id(self) -> str:
71 | return unwrap(self._id, message="Feature id unbound")
72 |
73 | @property
74 | def name(self) -> str:
75 | """The name of the sketch."""
76 | return self._name
77 |
78 | @property
79 | def sketch_items(self) -> Sequence[SketchItem]:
80 | """A list of items that were added to the sketch."""
81 | return list(self._items)
82 |
83 | def add_circle(
84 | self,
85 | center: tuple[float, float],
86 | radius: float,
87 | units: UnitSystem | None = None,
88 | ) -> SketchCircle:
89 | """Add a circle to the sketch.
90 |
91 | Args:
92 | center: An (x,y) pair of the center of the circle
93 | radius: The radius of the circle
94 | units: An optional other unit system to use
95 |
96 | """
97 | center_point = Point2D.from_pair(center)
98 |
99 | units = units if units else self._client.units
100 |
101 | # API expects metric values
102 | if units is UnitSystem.INCH:
103 | center_point *= 0.0254
104 | radius *= 0.0254
105 |
106 | item = SketchCircle(
107 | sketch=self,
108 | radius=radius,
109 | center=center_point,
110 | units=self._client.units,
111 | )
112 |
113 | logger.info(f"Added circle to sketch: {item}")
114 | self._items.add(item)
115 | self._update_feature()
116 |
117 | return item
118 |
119 | def add_line(
120 | self,
121 | start: tuple[float, float],
122 | end: tuple[float, float],
123 | ) -> SketchLine:
124 | """Add a line to the sketch.
125 |
126 | Args:
127 | start: The starting point of the line
128 | end: The ending point of the line
129 |
130 | """
131 | start_point = Point2D.from_pair(start)
132 | end_point = Point2D.from_pair(end)
133 |
134 | if self._client.units is UnitSystem.INCH:
135 | start_point *= 0.0254
136 | end_point *= 0.0254
137 |
138 | item = SketchLine(self, start_point, end_point, self._client.units)
139 |
140 | logger.info(f"Added line to sketch: {item}")
141 |
142 | self._items.add(item)
143 | self._update_feature()
144 | return item
145 |
146 | def trace_points(
147 | self,
148 | *points: tuple[float, float],
149 | end_connect: bool = True,
150 | ) -> list[SketchLine]:
151 | """Traces a series of points.
152 |
153 | Args:
154 | points: A list of points to trace. Uses list order for line
155 | end_connect: Connects end points of the trace with an extra segment
156 | to create a closed loop. Defaults to True.
157 |
158 | """
159 | segments: list[tuple[Point2D, Point2D]] = []
160 |
161 | for idx in range(1, len(points)):
162 | p1 = Point2D.from_pair(points[idx - 1])
163 | p2 = Point2D.from_pair(points[idx])
164 |
165 | segments.append((p1, p2))
166 |
167 | if end_connect:
168 | segments.append(
169 | (Point2D.from_pair(points[0]), Point2D.from_pair(points[-1])),
170 | )
171 |
172 | lines = []
173 |
174 | for p1, p2 in segments:
175 | lines.append(self.add_line(p1.as_tuple, p2.as_tuple))
176 |
177 | return lines
178 |
179 | def add_corner_rectangle(
180 | self,
181 | corner_1: tuple[float, float],
182 | corner_2: tuple[float, float],
183 | ) -> None:
184 | """Add a corner rectangle to the sketch.
185 |
186 | Args:
187 | corner_1: The point of the first corner
188 | corner_2: The point of the corner opposite to corner 1
189 |
190 | """
191 | p1 = Point2D.from_pair(corner_1)
192 | p2 = Point2D.from_pair(corner_2)
193 |
194 | self.trace_points((p1.x, p1.y), (p2.x, p1.y), (p2.x, p2.y), (p1.x, p2.y))
195 |
196 | def add_centerpoint_arc(
197 | self,
198 | centerpoint: tuple[float, float],
199 | radius: float,
200 | start_angle: float,
201 | end_angle: float,
202 | ) -> SketchArc:
203 | """Add a centerpoint arc to the sketch.
204 |
205 | Args:
206 | centerpoint: The centerpoint of the arc
207 | radius: The radius of the arc
208 | start_angle: The angle to start drawing the arc at
209 | end_angle: The angle to stop drawing the arc at
210 |
211 | """
212 | center = Point2D.from_pair(centerpoint)
213 |
214 | if self._client.units is UnitSystem.INCH:
215 | radius *= 0.0254
216 | center *= 0.0254
217 |
218 | item = SketchArc(
219 | sketch=self,
220 | radius=radius,
221 | center=center,
222 | theta_interval=(math.radians(start_angle), math.radians(end_angle)),
223 | units=self._client.units,
224 | )
225 |
226 | self._items.add(item)
227 | logger.info("Successfully added arc to sketch")
228 | self._update_feature()
229 | return item
230 |
231 | def add_fillet(
232 | self,
233 | line_1: SketchLine,
234 | line_2: SketchLine,
235 | radius: float,
236 | ) -> SketchArc:
237 | """Create a fillet between two lines by shortening them and adding an
238 | arc in between. Returns the added arc.
239 |
240 | Args:
241 | line_1: Line to fillet
242 | line_2: Other line to fillet
243 | radius: Radius of the fillet
244 |
245 | Returns:
246 | A SketchArc of the added arc. Updates line_1 and line_2
247 |
248 | """
249 | if line_1 == line_2:
250 | msg = "Cannot create a fillet between the same line"
251 | raise OnPyFeatureError(msg)
252 |
253 | if self._client.units is UnitSystem.INCH:
254 | radius *= 0.0254
255 |
256 | if Point2D.approx(line_1.start, line_2.start):
257 | line_1.start = line_2.start
258 | center = line_1.start
259 | vertex_1 = line_1.end
260 | vertex_2 = line_2.end
261 | elif Point2D.approx(line_1.end, line_2.start):
262 | line_1.end = line_2.start
263 | center = line_1.end
264 | vertex_1 = line_1.start
265 | vertex_2 = line_2.end
266 | elif Point2D.approx(line_1.start, line_2.end):
267 | line_1.start = line_2.end
268 | center = line_1.start
269 | vertex_1 = line_1.end
270 | vertex_2 = line_2.start
271 | elif Point2D.approx(line_1.end, line_2.end):
272 | line_1.end = line_2.end
273 | center = line_1.end
274 | vertex_1 = line_1.start
275 | vertex_2 = line_2.start
276 | else:
277 | msg = "Line entities need to share a point for a fillet"
278 | raise OnPyFeatureError(msg)
279 |
280 | # draw a triangle to find the angle between the two lines using law of cosines
281 | a = math.sqrt((vertex_1.x - center.x) ** 2 + (vertex_1.y - center.y) ** 2)
282 | b = math.sqrt((vertex_2.x - center.x) ** 2 + (vertex_2.y - center.y) ** 2)
283 | c = math.sqrt((vertex_1.x - vertex_2.x) ** 2 + (vertex_1.y - vertex_2.y) ** 2)
284 |
285 | opening_angle = math.acos((a**2 + b**2 - c**2) / (2 * a * b))
286 |
287 | # find the vector that is between the two lines
288 | line_1_vec = np.array(((vertex_1.x - center.x), (vertex_1.y - center.y)))
289 | line_2_vec = np.array(((vertex_2.x - center.x), (vertex_2.y - center.y)))
290 |
291 | line_1_angle = math.atan2(line_1_vec[1], line_1_vec[0]) % (math.pi * 2)
292 | line_2_angle = math.atan2(line_2_vec[1], line_2_vec[0]) % (math.pi * 2)
293 |
294 | center_angle = np.average((line_1_angle, line_2_angle)) # relative to x-axis
295 |
296 | # find the distance of the fillet centerpoint from the intersection point
297 | arc_center_offset = radius / math.sin(opening_angle / 2)
298 | line_dir = Point2D(
299 | math.cos(center_angle),
300 | math.sin(center_angle),
301 | ) # really is a vector, not a point
302 |
303 | # find which direction to apply the offset
304 | arc_center = line_dir * arc_center_offset + center # make an initial guess
305 |
306 | # find the closest point to the line
307 | t = (arc_center.x - line_1.start.x) * math.cos(line_1_angle) + (
308 | arc_center.y - line_1.start.y
309 | ) * math.sin(line_1_angle)
310 | line_1_tangent_point = Point2D(
311 | math.cos(line_1_angle) * t + line_1.start.x,
312 | math.sin(line_1_angle) * t + line_1.start.y,
313 | )
314 | t = (arc_center.x - line_2.start.x) * math.cos(line_2_angle) + (
315 | arc_center.y - line_2.start.y
316 | ) * math.sin(line_2_angle)
317 | line_2_tangent_point = Point2D(
318 | math.cos(line_2_angle) * t + line_2.start.x,
319 | math.sin(line_2_angle) * t + line_2.start.y,
320 | )
321 |
322 | # check to see if distance increased or decreased
323 |
324 | line_1_copy = copy.copy(line_1)
325 | line_2_copy = copy.copy(line_2)
326 |
327 | # Try shortening lines
328 | if Point2D.approx(line_1.start, line_2.start):
329 | line_1_copy.start = line_1_tangent_point
330 | line_2_copy.start = line_2_tangent_point
331 | elif Point2D.approx(line_1.end, line_2.start):
332 | line_1_copy.end = line_1_tangent_point
333 | line_2_copy.start = line_2_tangent_point
334 | elif Point2D.approx(line_1.start, line_2.end):
335 | line_1_copy.start = line_1_tangent_point
336 | line_2_copy.end = line_2_tangent_point
337 | elif Point2D.approx(line_1.end, line_2.end):
338 | line_1_copy.end = line_1_tangent_point
339 | line_2_copy.end = line_2_tangent_point
340 |
341 | # Check if lines got bigger
342 | if line_1_copy.length > line_1.length or line_2_copy.length > line_2.length:
343 | arc_center = line_dir * -arc_center_offset + center # make an initial guess
344 | t = (arc_center.x - line_1.start.x) * math.cos(line_1_angle) + (
345 | arc_center.y - line_1.start.y
346 | ) * math.sin(line_1_angle)
347 | line_1_tangent_point = Point2D(
348 | math.cos(line_1_angle) * t + line_1.start.x,
349 | math.sin(line_1_angle) * t + line_1.start.y,
350 | )
351 | t = (arc_center.x - line_2.start.x) * math.cos(line_2_angle) + (
352 | arc_center.y - line_2.start.y
353 | ) * math.sin(line_2_angle)
354 | line_2_tangent_point = Point2D(
355 | math.cos(line_2_angle) * t + line_2.start.x,
356 | math.sin(line_2_angle) * t + line_2.start.y,
357 | )
358 |
359 | # Shorten lines
360 | if Point2D.approx(line_1.start, line_2.start):
361 | line_1.start = line_1_tangent_point
362 | line_2.start = line_2_tangent_point
363 | elif Point2D.approx(line_1.end, line_2.start):
364 | line_1.end = line_1_tangent_point
365 | line_2.start = line_2_tangent_point
366 | elif Point2D.approx(line_1.start, line_2.end):
367 | line_1.start = line_1_tangent_point
368 | line_2.end = line_2_tangent_point
369 | elif Point2D.approx(line_1.end, line_2.end):
370 | line_1.end = line_1_tangent_point
371 | line_2.end = line_2_tangent_point
372 |
373 | # add arc
374 | arc = SketchArc.three_point_with_midpoint(
375 | sketch=self,
376 | radius=radius,
377 | center=arc_center,
378 | endpoint_1=line_1_tangent_point,
379 | endpoint_2=line_2_tangent_point,
380 | units=self._client.units,
381 | )
382 | self._items.add(arc)
383 |
384 | self._update_feature()
385 |
386 | return arc
387 |
388 | @override
389 | def _to_model(self) -> schema.Sketch:
390 | if isinstance(self.plane, FaceEntityConvertible):
391 | transient_ids = [e.transient_id for e in self.plane._face_entities()]
392 | else:
393 | transient_ids = [self.plane.transient_id]
394 |
395 | return schema.Sketch(
396 | name=self.name,
397 | featureId=self._id,
398 | suppressed=False,
399 | parameters=[
400 | schema.FeatureParameterQueryList(
401 | queries=[
402 | {
403 | "btType": "BTMIndividualQuery-138",
404 | "deterministicIds": transient_ids,
405 | },
406 | ],
407 | parameterId="sketchPlane",
408 | ).model_dump(exclude_none=True),
409 | {
410 | "btType": "BTMParameterBoolean-144",
411 | "value": True,
412 | "parameterId": "disableImprinting",
413 | },
414 | ],
415 | entities=[i.to_model().model_dump(exclude_none=True) for i in self._items],
416 | )
417 |
418 | @override
419 | def _load_response(self, response: schema.FeatureAddResponse) -> None:
420 | """Load the feature id from the response."""
421 | self._id = unwrap(response.feature.featureId)
422 |
423 | @property
424 | @override
425 | def entities(self) -> EntityFilter:
426 | """All of the entities on this sketch.
427 |
428 | Returns:
429 | An EntityFilter object used to query entities
430 |
431 | """
432 | script = dedent(
433 | f"""
434 | function(context is Context, queries) {{
435 | var feature_id = makeId("{self.id}");
436 | var faces = evaluateQuery(context, qCreatedBy(feature_id));
437 | return transientQueriesToStrings(faces);
438 | }}
439 | """,
440 | )
441 |
442 | response = self._client._api.endpoints.eval_featurescript(
443 | document_id=self._partstudio.document.id,
444 | version=WorkspaceWVM(self._partstudio.document.default_workspace.id),
445 | element_id=self._partstudio.id,
446 | script=script,
447 | return_type=schema.FeaturescriptResponse,
448 | )
449 |
450 | transient_ids_raw = unwrap(
451 | response.result,
452 | message="Featurescript failed get entities owned by part",
453 | )["value"]
454 |
455 | entities = [Entity(i["value"]) for i in transient_ids_raw]
456 |
457 | return EntityFilter(partstudio=self.partstudio, available=entities)
458 |
459 | @override
460 | def _face_entities(self) -> list[FaceEntity]:
461 | return self.faces._available
462 |
463 | @property
464 | def vertices(self) -> EntityFilter[VertexEntity]:
465 | """An object used for interfacing with vertex entities on this sketch."""
466 | return EntityFilter(
467 | partstudio=self._partstudio,
468 | available=self.entities.is_type(VertexEntity)._available,
469 | )
470 |
471 | @property
472 | def edges(self) -> EntityFilter[EdgeEntity]:
473 | """An object used for interfacing with edge entities on this sketch."""
474 | return EntityFilter(
475 | partstudio=self._partstudio,
476 | available=self.entities.is_type(EdgeEntity)._available,
477 | )
478 |
479 | @property
480 | def faces(self) -> EntityFilter[FaceEntity]:
481 | """An object used for interfacing with face entities on this sketch."""
482 | return EntityFilter(
483 | partstudio=self._partstudio,
484 | available=self.entities.is_type(FaceEntity)._available,
485 | )
486 |
487 | def mirror[T: SketchItem](
488 | self,
489 | items: Sequence[T],
490 | line_point: tuple[float, float],
491 | line_dir: tuple[float, float],
492 | *,
493 | copy: bool = True,
494 | ) -> list[T]:
495 | """Mirrors sketch items about a line.
496 |
497 | Args:
498 | items: Any number of sketch items to mirror
499 | line_point: Any point that lies on the mirror line
500 | line_dir: The direction of the mirror line
501 | copy: Whether or not to save a copy of the original entity. Defaults
502 | to True.
503 |
504 | Returns:
505 | A list of the new items added
506 |
507 | """
508 | if copy:
509 | items = tuple([i.clone() for i in items])
510 |
511 | return [i.mirror(line_point, line_dir) for i in items]
512 |
513 | def rotate[T: SketchItem](
514 | self,
515 | items: Sequence[T],
516 | origin: tuple[float, float],
517 | theta: float,
518 | *,
519 | copy: bool = False,
520 | ) -> list[T]:
521 | """Rotates sketch items about a point.
522 |
523 | Args:
524 | items: Any number of sketch items to rotate
525 | origin: The point to pivot about
526 | theta: The degrees to rotate by
527 | copy: Whether or not to save a copy of the original entity. Defaults
528 | to False.
529 |
530 | Returns:
531 | A list of the new items added
532 |
533 | """
534 | if copy:
535 | items = tuple([i.clone() for i in items])
536 |
537 | return [i.rotate(origin, theta) for i in items]
538 |
539 | def translate[T: SketchItem](
540 | self,
541 | items: Sequence[T],
542 | x: float = 0,
543 | y: float = 0,
544 | *,
545 | copy: bool = False,
546 | ) -> list[T]:
547 | """Translate sketch items in a cartesian system.
548 |
549 | Args:
550 | items: Any number of sketch items to translate
551 | x: The amount to translate in the x-axis
552 | y: The amount to translate in the y-axis
553 | copy: Whether or not to save a copy of the original entity. Defaults
554 | to False.
555 |
556 | Returns:
557 | A list of the new items added
558 |
559 | """
560 | if copy:
561 | items = tuple([i.clone() for i in items])
562 |
563 | return [i.translate(x, y) for i in items]
564 |
565 | def circular_pattern[T: SketchItem](
566 | self,
567 | items: Sequence[T],
568 | origin: tuple[float, float],
569 | num_steps: int,
570 | theta: float,
571 | ) -> list[T]:
572 | """Create a circular pattern of sketch items.
573 |
574 | Args:
575 | items: Any number of sketch items to include in the pattern
576 | origin: The point to center the circular pattern about.
577 | num_steps: The number of steps to take. Does not include original position
578 | theta: The degrees to rotate per step
579 |
580 | Returns:
581 | A list of the entities that compose the circular pattern, including the
582 | original items.
583 |
584 | """
585 | new_items = []
586 |
587 | for item in items:
588 | new_items.extend(item.circular_pattern(origin, num_steps, theta))
589 |
590 | self._items.update(new_items)
591 | return new_items
592 |
593 | def linear_pattern[T: SketchItem](
594 | self, items: Sequence[T], num_steps: int, x: float = 0, y: float = 0
595 | ) -> list[T]:
596 | """Create a linear pattern of sketch items.
597 |
598 | Args:
599 | items: Any number of sketch items to include in the pattern
600 | num_steps: THe number of steps to take. Does not include original position
601 | x: The x distance to travel each step. Defaults to zero
602 | y: The y distance to travel each step. Defaults to zero
603 |
604 | Returns:
605 | A list of the entities that compose the linear pattern, including the
606 | original item.
607 |
608 | """
609 | new_items = []
610 |
611 | for item in items:
612 | new_items.extend(item.linear_pattern(num_steps, x, y))
613 |
614 | self._items.update(new_items)
615 | return new_items
616 |
617 | def __str__(self) -> str:
618 | """Pretty string representation of the sketch."""
619 | return repr(self)
620 |
621 | def __repr__(self) -> str:
622 | """Printable representation of the sketch."""
623 | return f'Sketch("{self.name}")'
624 |
--------------------------------------------------------------------------------
/src/onpy/features/sketch/sketch_items.py:
--------------------------------------------------------------------------------
1 | """Various items that can belong to a sketch.
2 |
3 | As a user builds a sketch, they are adding sketch items. This is importantly
4 | different from adding entities--which they are also adding. Sketch items
5 | *cannot* be queried or used for any type of other feature; they are internal
6 | to the sketch. Entities, on the other hand, are derived from the way sketch
7 | items are added; these can be queried and used in other features.
8 |
9 | OnPy - May 2024 - Kyle Tennison
10 |
11 | """
12 |
13 | import copy
14 | import math
15 | import uuid
16 | from abc import ABC, abstractmethod
17 | from typing import TYPE_CHECKING, Self, override
18 |
19 | import numpy as np
20 | from loguru import logger
21 |
22 | from onpy.api import schema
23 | from onpy.util.exceptions import OnPyParameterError
24 | from onpy.util.misc import Point2D, UnitSystem
25 |
26 | if TYPE_CHECKING:
27 | from onpy.features import Sketch
28 |
29 |
30 | class SketchItem(ABC):
31 | """Represents an item that the user added to the sketch. *Not* the same
32 | as an entity.
33 | """
34 |
35 | @property
36 | @abstractmethod
37 | def sketch(self) -> "Sketch":
38 | """A reference to the owning sketch."""
39 |
40 | @abstractmethod
41 | def to_model(self) -> schema.ApiModel:
42 | """Convert the item into the corresponding api schema."""
43 | ...
44 |
45 | @abstractmethod
46 | def translate(self, x: float = 0, y: float = 0) -> Self:
47 | """Linear translation of the entity.
48 |
49 | Args:
50 | x: The distance to translate along the x-axis
51 | y: The distance to translate along the y-axis
52 |
53 | Returns:
54 | A new sketch object
55 |
56 | """
57 | ...
58 |
59 | @abstractmethod
60 | def rotate(self, origin: tuple[float, float], theta: float) -> Self:
61 | """Rotates the entity about a point.
62 |
63 | Args:
64 | origin: The point to rotate about
65 | theta: The degrees to rotate by. Positive is ccw
66 |
67 | Returns:
68 | A new sketch object
69 |
70 | """
71 | ...
72 |
73 | @abstractmethod
74 | def mirror(
75 | self,
76 | line_start: tuple[float, float],
77 | line_end: tuple[float, float],
78 | ) -> Self:
79 | """Mirror the entity about a line.
80 |
81 | Args:
82 | line_start: The starting point of the line
83 | line_end: The ending point of the line
84 |
85 | Returns:
86 | A new entity object
87 |
88 | """
89 | ...
90 |
91 | @staticmethod
92 | def _mirror_point(
93 | point: Point2D,
94 | line_start: Point2D,
95 | line_end: Point2D,
96 | ) -> Point2D:
97 | """Mirrors the point across a line.
98 |
99 | Args:
100 | point: The point to mirror
101 | line_start: The point where the line starts
102 | line_end: The point where the line ends
103 |
104 | Returns:
105 | The mirrored point
106 |
107 | """
108 | q_i = np.array(line_start.as_tuple)
109 | q_j = np.array(line_end.as_tuple)
110 | p_0 = np.array(point.as_tuple)
111 |
112 | a = q_i[1] - q_j[1]
113 | b = q_j[0] - q_i[0]
114 | c = -(a * q_i[0] + b * q_i[1])
115 |
116 | p_k = (
117 | np.array([[b**2 - a**2, -2 * a * b], [-2 * a * b, a**2 - b**2]]) @ p_0
118 | - 2 * c * np.array([a, b])
119 | ) / (a**2 + b**2)
120 |
121 | return Point2D.from_pair(p_k)
122 |
123 | @staticmethod
124 | def _rotate_point(point: Point2D, pivot: Point2D, degrees: float) -> Point2D:
125 | """Rotates a point about another point.
126 |
127 | Args:
128 | point: The point to rotate
129 | pivot: The pivot to rotate about
130 | degrees: The degrees to rotate by
131 |
132 | Returns:
133 | The rotated point
134 |
135 | """
136 | dx = point.x - pivot.x
137 | dy = point.y - pivot.y
138 |
139 | radius = math.sqrt(dx**2 + dy**2)
140 | start_angle = math.atan2(dy, dx)
141 | end_angle = start_angle + math.radians(degrees)
142 |
143 | new_x = radius * math.cos(end_angle)
144 | new_y = radius * math.sin(end_angle)
145 |
146 | return Point2D(new_x, new_y)
147 |
148 | def _replace_entity(self, new_entity: "SketchItem") -> None:
149 | """Replace the existing entity with a new entity and refreshes the
150 | feature.
151 |
152 | Args:
153 | new_entity: The entity to replace with
154 |
155 | """
156 | self.sketch._items.remove(self)
157 | self.sketch._items.add(new_entity)
158 | self.sketch._update_feature()
159 |
160 | def clone(self) -> Self:
161 | """Create a copy of the entity."""
162 | logger.debug(f"Created a close of {self}")
163 |
164 | new_entity = copy.copy(self)
165 | self.sketch._items.add(new_entity)
166 | return new_entity
167 |
168 | def linear_pattern(
169 | self,
170 | num_steps: int,
171 | x_step: float,
172 | y_step: float,
173 | ) -> list[Self]:
174 | """Create a linear pattern of the sketch entity.
175 |
176 | Args:
177 | num_steps: The number of steps to make. Does not include original entity
178 | x_step: The x distance to translate per step
179 | y_step: The y distance to translate per step
180 |
181 | Returns:
182 | A list of the entities that compose the linear pattern, including the
183 | original item.
184 |
185 | """
186 | logger.debug(f"Creating a linear pattern of {self} for {num_steps}")
187 |
188 | entities: list[Self] = [self]
189 |
190 | for _ in range(num_steps):
191 | entities.append(entities[-1].clone().translate(x_step, y_step))
192 |
193 | return entities
194 |
195 | def circular_pattern(
196 | self,
197 | origin: tuple[float, float],
198 | num_steps: int,
199 | theta: float,
200 | ) -> list[Self]:
201 | """Create a circular pattern of the sketch entity about a point.
202 |
203 | Args:
204 | origin: The origin of the circular rotation
205 | num_steps: The number of steps to make. Does not include original entity
206 | theta: The degrees to rotate per step
207 |
208 | Returns:
209 | A list of entities that compose the circular pattern, including the
210 | original item.
211 |
212 | """
213 | logger.debug(f"Creating a circular pattern of {self} for {num_steps}")
214 |
215 | entities: list[Self] = [self]
216 |
217 | for _ in range(num_steps):
218 | entities.append(entities[-1].clone().rotate(origin, theta))
219 |
220 | return entities
221 |
222 | def _generate_entity_id(self) -> str:
223 | """Generate a random entity id."""
224 | return str(uuid.uuid4()).replace("-", "")
225 |
226 | def __str__(self) -> str:
227 | """Pretty representation of the sketch item."""
228 | return repr(self)
229 |
230 | @abstractmethod
231 | def __repr__(self) -> str:
232 | """Printable representation of the sketch item."""
233 | ...
234 |
235 |
236 | class SketchCircle(SketchItem):
237 | """A sketch circle."""
238 |
239 | def __init__(
240 | self,
241 | sketch: "Sketch",
242 | radius: float,
243 | center: Point2D,
244 | units: UnitSystem,
245 | direction: tuple[float, float] = (1, 0),
246 | *,
247 | clockwise: bool = False,
248 | ) -> None:
249 | """Args:
250 | sketch: A reference to the owning sketch
251 | radius: The radius of the arc
252 | center: The centerpoint of the arc
253 | units: The unit system to use
254 | direction: An optional direction to specify. Defaults to +x axis
255 | clockwise: Whether or not the arc is clockwise. Defaults to false.
256 |
257 | """
258 | self._sketch = sketch
259 | self.radius = radius
260 | self.center = center
261 | self.units = units
262 | self.direction = Point2D.from_pair(direction)
263 | self.clockwise = clockwise
264 | self.entity_id = self._generate_entity_id()
265 |
266 | @override
267 | def to_model(self) -> schema.SketchCurveEntity:
268 | return schema.SketchCurveEntity(
269 | geometry={
270 | "btType": "BTCurveGeometryCircle-115",
271 | "radius": self.radius,
272 | "xCenter": self.center.x,
273 | "yCenter": self.center.y,
274 | "xDir": self.direction.x,
275 | "yDir": self.direction.y,
276 | "clockwise": self.clockwise,
277 | },
278 | centerId=f"{self.entity_id}.center",
279 | entityId=f"{self.entity_id}",
280 | )
281 |
282 | @property
283 | @override
284 | def sketch(self) -> "Sketch":
285 | """A reference to the owning sketch."""
286 | return self._sketch
287 |
288 | @override
289 | def rotate(self, origin: tuple[float, float], theta: float) -> "SketchCircle":
290 | new_center = self._rotate_point(self.center, Point2D.from_pair(origin), theta)
291 | new_entity = SketchCircle(
292 | sketch=self.sketch,
293 | radius=self.radius,
294 | center=new_center,
295 | units=self.units,
296 | direction=self.direction.as_tuple,
297 | clockwise=self.clockwise,
298 | )
299 | self._replace_entity(new_entity)
300 | return new_entity
301 |
302 | @override
303 | def translate(self, x: float = 0, y: float = 0) -> "SketchCircle":
304 | if self._sketch._client.units is UnitSystem.INCH:
305 | x *= 0.0254
306 | y *= 0.0254
307 |
308 | new_center = Point2D(self.center.x + x, self.center.y + y)
309 | new_entity = SketchCircle(
310 | sketch=self.sketch,
311 | radius=self.radius,
312 | center=new_center,
313 | units=self.units,
314 | direction=self.direction.as_tuple,
315 | clockwise=self.clockwise,
316 | )
317 | self._replace_entity(new_entity)
318 | return new_entity
319 |
320 | @override
321 | def mirror(
322 | self,
323 | line_start: tuple[float, float],
324 | line_end: tuple[float, float],
325 | ) -> "SketchCircle":
326 | mirror_start = Point2D.from_pair(line_start)
327 | mirror_end = Point2D.from_pair(line_end)
328 |
329 | # to avoid confusion
330 | del line_start
331 | del line_end
332 |
333 | new_center = self._mirror_point(self.center, mirror_start, mirror_end)
334 |
335 | new_entity = SketchCircle(
336 | sketch=self.sketch,
337 | radius=self.radius,
338 | center=new_center,
339 | units=self.units,
340 | direction=self.direction.as_tuple,
341 | clockwise=self.clockwise,
342 | )
343 |
344 | self._replace_entity(new_entity)
345 | return new_entity
346 |
347 | @override
348 | def __repr__(self) -> str:
349 | return f"Circle(radius={self.radius}, center={self.center})"
350 |
351 |
352 | class SketchLine(SketchItem):
353 | """A straight sketch line segment."""
354 |
355 | def __init__(
356 | self,
357 | sketch: "Sketch",
358 | start_point: Point2D,
359 | end_point: Point2D,
360 | units: UnitSystem,
361 | ) -> None:
362 | """Args:
363 | sketch: A reference to the owning sketch
364 | start_point: The starting point of the line
365 | end_point: The ending point of the line
366 | units: The unit system to use.
367 |
368 | """
369 | self._sketch = sketch
370 | self.start = start_point
371 | self.end = end_point
372 | self.units = units
373 | self.entity_id = self._generate_entity_id()
374 |
375 | @property
376 | def dx(self) -> float:
377 | """The x-component of the line."""
378 | return self.end.x - self.start.x
379 |
380 | @property
381 | def dy(self) -> float:
382 | """The y-component of the line."""
383 | return self.end.y - self.start.y
384 |
385 | @property
386 | def length(self) -> float:
387 | """The length of the line."""
388 | return abs(math.sqrt(self.dx**2 + self.dy**2))
389 |
390 | @property
391 | def theta(self) -> float:
392 | """The angle of the line relative to the x-axis."""
393 | return math.atan2(self.dy, self.dx)
394 |
395 | @property
396 | def direction(self) -> Point2D:
397 | """A vector pointing in the direction of the line."""
398 | return Point2D(math.cos(self.theta), math.sin(self.theta))
399 |
400 | @property
401 | @override
402 | def sketch(self) -> "Sketch":
403 | """A reference to the owning sketch."""
404 | return self._sketch
405 |
406 | @override
407 | def rotate(self, origin: tuple[float, float], theta: float) -> "SketchLine":
408 | new_start = self._rotate_point(
409 | self.start,
410 | Point2D.from_pair(origin),
411 | degrees=theta,
412 | )
413 | new_end = self._rotate_point(self.end, Point2D.from_pair(origin), degrees=theta)
414 |
415 | new_entity = SketchLine(
416 | sketch=self._sketch,
417 | start_point=new_start,
418 | end_point=new_end,
419 | units=self.units,
420 | )
421 | self._replace_entity(new_entity)
422 | return new_entity
423 |
424 | @override
425 | def mirror(
426 | self,
427 | line_start: tuple[float, float],
428 | line_end: tuple[float, float],
429 | ) -> "SketchLine":
430 | mirror_start = Point2D.from_pair(line_start)
431 | mirror_end = Point2D.from_pair(line_end)
432 |
433 | new_start = self._mirror_point(self.start, mirror_start, mirror_end)
434 | new_end = self._mirror_point(self.end, mirror_start, mirror_end)
435 |
436 | new_entity = SketchLine(
437 | sketch=self._sketch,
438 | start_point=new_start,
439 | end_point=new_end,
440 | units=self.units,
441 | )
442 | self._replace_entity(new_entity)
443 | return new_entity
444 |
445 | @override
446 | def translate(self, x: float = 0, y: float = 0) -> "SketchLine":
447 | if self._sketch._client.units is UnitSystem.INCH:
448 | x *= 0.0254
449 | y *= 0.0254
450 |
451 | new_start = Point2D(self.start.x + x, self.start.y + y)
452 | new_end = Point2D(self.end.x + x, self.end.y + y)
453 |
454 | new_entity = SketchLine(
455 | sketch=self._sketch,
456 | start_point=new_start,
457 | end_point=new_end,
458 | units=self.units,
459 | )
460 | self._replace_entity(new_entity)
461 | return new_entity
462 |
463 | @override
464 | def to_model(self) -> schema.SketchCurveSegmentEntity:
465 | return schema.SketchCurveSegmentEntity(
466 | entityId=self.entity_id,
467 | startPointId=f"{self.entity_id}.start",
468 | endPointId=f"{self.entity_id}.end",
469 | startParam=0,
470 | endParam=self.length,
471 | geometry={
472 | "btType": "BTCurveGeometryLine-117",
473 | "pntX": self.start.x,
474 | "pntY": self.start.y,
475 | "dirX": self.direction.x,
476 | "dirY": self.direction.y,
477 | },
478 | )
479 |
480 | @override
481 | def __repr__(self) -> str:
482 | return f"Line(start={self.start}, end={self.end})"
483 |
484 |
485 | class SketchArc(SketchItem):
486 | """A sketch arc."""
487 |
488 | def __init__(
489 | self,
490 | sketch: "Sketch",
491 | radius: float,
492 | center: Point2D,
493 | theta_interval: tuple[float, float],
494 | units: UnitSystem,
495 | direction: tuple[float, float] = (1, 0),
496 | *,
497 | clockwise: bool = False,
498 | ) -> None:
499 | """Args:
500 | sketch: A reference to the owning sketch
501 | radius: The radius of the arc
502 | center: The centerpoint of the arc
503 | theta_interval: The theta interval, in degrees
504 | units: The unit system to use
505 | direction: An optional direction to specify. Defaults to +x axis
506 | clockwise: Whether or not the arc is clockwise. Defaults to false.
507 |
508 | """
509 | self._sketch = sketch
510 | self.radius = radius
511 | self.center = center
512 | self.theta_interval = theta_interval
513 | self.direction = direction
514 | self.clockwise = clockwise
515 | self.entity_id = self._generate_entity_id()
516 | self.units = units
517 |
518 | @property
519 | @override
520 | def sketch(self) -> "Sketch":
521 | """A reference to the owning sketch."""
522 | return self._sketch
523 |
524 | @override
525 | def mirror(
526 | self,
527 | line_start: tuple[float, float],
528 | line_end: tuple[float, float],
529 | ) -> "SketchArc":
530 | mirror_start = Point2D.from_pair(line_start)
531 | mirror_end = Point2D.from_pair(line_end)
532 |
533 | # to avoid confusion
534 | del line_start
535 | del line_end
536 |
537 | start_point = Point2D(
538 | self.radius * math.cos(self.theta_interval[0]) + self.center.x,
539 | self.radius * math.sin(self.theta_interval[0]) + self.center.y,
540 | )
541 |
542 | start_point = self._mirror_point(start_point, mirror_start, mirror_end)
543 | new_center = self._mirror_point(self.center, mirror_start, mirror_end)
544 |
545 | arc_start_vector = np.array(
546 | [start_point.x - new_center.x, start_point.y - new_center.y],
547 | )
548 | mirror_line_vector = np.array(
549 | [mirror_end.x - mirror_start.x, mirror_end.y - mirror_start.y],
550 | )
551 |
552 | angle_offset = 2 * math.acos(
553 | np.dot(arc_start_vector, mirror_line_vector)
554 | / (np.linalg.norm(arc_start_vector) * np.linalg.norm(mirror_line_vector)),
555 | )
556 |
557 | d_theta = self.theta_interval[1] - self.theta_interval[0]
558 |
559 | new_theta = (
560 | (self.theta_interval[0] - angle_offset - d_theta),
561 | (self.theta_interval[0] - angle_offset),
562 | )
563 |
564 | new_entity = SketchArc(
565 | sketch=self._sketch,
566 | radius=self.radius,
567 | center=new_center,
568 | theta_interval=new_theta,
569 | units=self.units,
570 | direction=self.direction,
571 | clockwise=self.clockwise,
572 | )
573 | self._replace_entity(new_entity)
574 | return new_entity
575 |
576 | @override
577 | def translate(self, x: float = 0, y: float = 0) -> "SketchArc":
578 | if self._sketch._client.units is UnitSystem.INCH:
579 | x *= 0.0254
580 | y *= 0.0254
581 |
582 | new_center = Point2D(self.center.x + x, self.center.y + y)
583 | new_entity = SketchArc(
584 | sketch=self._sketch,
585 | radius=self.radius,
586 | center=new_center,
587 | theta_interval=self.theta_interval,
588 | units=self.units,
589 | direction=self.direction,
590 | clockwise=self.clockwise,
591 | )
592 | self._replace_entity(new_entity)
593 | return new_entity
594 |
595 | @override
596 | def rotate(self, origin: tuple[float, float], theta: float) -> "SketchArc":
597 | pivot = Point2D.from_pair(origin)
598 |
599 | start_point = Point2D(
600 | self.radius * math.cos(self.theta_interval[0]) + self.center.x,
601 | self.radius * math.sin(self.theta_interval[0]) + self.center.y,
602 | )
603 | end_point = Point2D(
604 | self.radius * math.cos(self.theta_interval[1]) + self.center.x,
605 | self.radius * math.sin(self.theta_interval[1]) + self.center.y,
606 | )
607 |
608 | new_center = self._rotate_point(self.center, pivot, theta)
609 | start_point = self._rotate_point(start_point, pivot, theta)
610 | end_point = self._rotate_point(end_point, pivot, theta)
611 |
612 | new_entity = SketchArc.three_point_with_midpoint(
613 | sketch=self._sketch,
614 | center=new_center,
615 | radius=self.radius,
616 | endpoint_1=start_point,
617 | endpoint_2=end_point,
618 | units=self.units,
619 | direction=self.direction,
620 | clockwise=self.clockwise,
621 | )
622 |
623 | self._replace_entity(new_entity)
624 | return new_entity
625 |
626 | @override
627 | def to_model(self) -> schema.SketchCurveSegmentEntity:
628 | return schema.SketchCurveSegmentEntity(
629 | startPointId=f"{self.entity_id}.start",
630 | endPointId=f"{self.entity_id}.end",
631 | startParam=self.theta_interval[0],
632 | endParam=self.theta_interval[1],
633 | centerId=f"{self.entity_id}.center",
634 | entityId=f"{self.entity_id}",
635 | geometry={
636 | "btType": "BTCurveGeometryCircle-115",
637 | "radius": self.radius,
638 | "xCenter": self.center.x,
639 | "yCenter": self.center.y,
640 | "xDir": self.direction[0],
641 | "yDir": self.direction[1],
642 | },
643 | )
644 |
645 | @classmethod
646 | def three_point_with_midpoint(
647 | cls,
648 | sketch: "Sketch",
649 | center: Point2D,
650 | radius: float | None,
651 | endpoint_1: Point2D,
652 | endpoint_2: Point2D,
653 | units: UnitSystem,
654 | direction: tuple[float, float] = (1, 0),
655 | *,
656 | clockwise: bool = False,
657 | ) -> "SketchArc":
658 | """Construct a new instance of a SketchArc using endpoints instead
659 | of a theta interval.
660 |
661 | Args:
662 | sketch: A reference to the owning sketch
663 | radius: The radius of the arc. If unprovided, it will be solved for
664 | center: The centerpoint of the arc
665 | endpoint_1: One of the endpoints of the arc
666 | endpoint_2: The other endpoint of the arc
667 | units: The unit system to use
668 | direction: An optional direction to specify. Defaults to +x axis
669 | clockwise: Whether or not the arc is clockwise. Defaults to false
670 |
671 | Returns:
672 | A new SketchArc instance
673 |
674 | """
675 | # verify that a valid arc can be found
676 | if not math.isclose(
677 | math.sqrt((center.x - endpoint_1.x) ** 2 + (center.y - endpoint_1.y) ** 2),
678 | math.sqrt((center.x - endpoint_2.x) ** 2 + (center.y - endpoint_2.y) ** 2),
679 | ):
680 | msg = "No valid arc can be created from provided endpoints"
681 | raise OnPyParameterError(msg)
682 |
683 | # find radius
684 | radius_from_endpoints = math.sqrt(
685 | (center.x - endpoint_1.x) ** 2 + (center.y - endpoint_1.y) ** 2,
686 | )
687 | if radius is None:
688 | radius = radius_from_endpoints
689 | elif not math.isclose(radius, radius_from_endpoints):
690 | msg = "Endpoints do not match the provided radius"
691 | raise OnPyParameterError(msg)
692 |
693 | # create line vectors from center to endpoints
694 | vec_1 = Point2D(endpoint_1.x - center.x, endpoint_1.y - center.y)
695 | vec_2 = Point2D(endpoint_2.x - center.x, endpoint_2.y - center.y)
696 |
697 | # measure the angle of these segments
698 | theta_1 = math.atan2(vec_1.y, vec_1.x) % (2 * math.pi)
699 | theta_2 = math.atan2(vec_2.y, vec_2.x) % (2 * math.pi)
700 |
701 | # calculate the difference in angles
702 | diff_theta = abs(theta_1 - theta_2)
703 |
704 | # Choose the smaller arc
705 | if diff_theta <= math.pi:
706 | # if the difference is less than or equal to pi, choose the smaller arc
707 | if clockwise:
708 | # for clockwise arcs, choose the larger angle as the starting point
709 | theta_start = max(theta_1, theta_2)
710 | theta_end = min(theta_1, theta_2)
711 | else:
712 | # for counterclockwise arcs, choose the smaller angle as the
713 | # starting point
714 | theta_start = min(theta_1, theta_2)
715 | theta_end = max(theta_1, theta_2)
716 | elif clockwise:
717 | # for clockwise arcs, choose the smaller angle as the starting point
718 | theta_start = min(theta_1, theta_2)
719 | theta_end = max(theta_1, theta_2)
720 | else:
721 | # for counterclockwise arcs, choose the larger angle as the
722 | # starting point
723 | theta_start = max(theta_1, theta_2)
724 | theta_end = min(theta_1, theta_2)
725 |
726 | # create theta interval
727 | theta_interval = (theta_start, theta_end)
728 |
729 | # create an arc using this interval
730 | return SketchArc(
731 | sketch=sketch,
732 | radius=radius,
733 | center=center,
734 | theta_interval=theta_interval,
735 | units=units,
736 | direction=direction,
737 | clockwise=clockwise,
738 | )
739 |
740 | @override
741 | def __repr__(self) -> str:
742 | return (
743 | f"Arc(center={self.center}, radius={self.radius}, "
744 | f"interval={self.theta_interval[0]}<θ<{self.theta_interval[1]})"
745 | )
746 |
--------------------------------------------------------------------------------
/src/onpy/features/translate.py:
--------------------------------------------------------------------------------
1 | """Interface to the Translate Feature.
2 |
3 | This script defines the Transform - Translate by XYZ feature.
4 |
5 | OnPy - May 2025 - David Burns
6 |
7 | """
8 |
9 | from typing import TYPE_CHECKING, override
10 |
11 | from onpy.api import schema
12 | from onpy.api.schema import FeatureAddResponse
13 | from onpy.entities import EntityFilter
14 | from onpy.features.base import Feature
15 | from onpy.part import Part
16 | from onpy.util.misc import unwrap
17 |
18 | if TYPE_CHECKING:
19 | from onpy.elements.partstudio import PartStudio
20 |
21 |
22 | class Translate(Feature):
23 | """Represents an translation feature."""
24 |
25 | def __init__(
26 | self,
27 | part: Part,
28 | partstudio: "PartStudio",
29 | x: float,
30 | y: float,
31 | z: float,
32 | *,
33 | copy: bool,
34 | name: str = "Translation",
35 | ) -> None:
36 | """Construct an translate feature.
37 |
38 | Args:
39 | partstudio: The owning partstudio
40 | part: The part to be tranlsated
41 | name: The name of the translation feature
42 | x: The distance to move in x direction
43 | y: The distance to move in y direction
44 | z: The distance to move in z direction
45 | copy: Bool to indicate part should be copied
46 |
47 | """
48 | self._id: str | None = None
49 | self.part = part
50 | self._partstudio = partstudio
51 | self._name = name
52 | self.x = x
53 | self.y = y
54 | self.z = z
55 | self.copy = copy
56 |
57 | self._upload_feature()
58 |
59 | @property
60 | @override
61 | def id(self) -> str | None:
62 | return unwrap(self._id, message="Translate feature id unbound")
63 |
64 | @property
65 | @override
66 | def partstudio(self) -> "PartStudio":
67 | return self._partstudio
68 |
69 | @property
70 | @override
71 | def name(self) -> str:
72 | return self._name
73 |
74 | @property
75 | @override
76 | def entities(self) -> EntityFilter:
77 | # TODO @kyle-tennison: Not currently implemented. Load with items
78 | return EntityFilter(self.partstudio, available=[])
79 |
80 | @override
81 | def _to_model(self) -> schema.Translate:
82 | return schema.Translate(
83 | name=self.name,
84 | featureId=self._id,
85 | featureType="transform",
86 | suppressed=False,
87 | parameters=[
88 | {
89 | "btType": "BTMParameterQueryList-148",
90 | "parameterId": "entities",
91 | "queries": [
92 | {"btType": "BTMIndividualQuery-138", "deterministicIds": [self.part.id]}
93 | ],
94 | },
95 | {
96 | "btType": "BTMParameterEnum-145",
97 | "namespace": "",
98 | "enumName": "TransformType",
99 | "value": "TRANSLATION_3D",
100 | "parameterId": "transformType",
101 | },
102 | {"btType": "BTMParameterQuantity-147", "value": self.x, "parameterId": "dx"},
103 | {"btType": "BTMParameterQuantity-147", "value": self.y, "parameterId": "dy"},
104 | {"btType": "BTMParameterQuantity-147", "value": self.z, "parameterId": "dz"},
105 | {
106 | "btType": "BTMParameterBoolean-144",
107 | "value": self.copy,
108 | "parameterId": "makeCopy",
109 | },
110 | ],
111 | )
112 |
113 | @override
114 | def _load_response(self, response: FeatureAddResponse) -> None:
115 | self._id = response.feature.featureId
116 |
--------------------------------------------------------------------------------
/src/onpy/part.py:
--------------------------------------------------------------------------------
1 | """Part interface.
2 |
3 | In OnShape, Parts are 3D bodies that belong to a partstudio. The OnPy part
4 | interface is defined here
5 |
6 | OnPy - May 2024 - Kyle Tennison
7 |
8 | """
9 |
10 | from textwrap import dedent
11 | from typing import TYPE_CHECKING, override
12 |
13 | from prettytable import PrettyTable
14 |
15 | from onpy.api import schema
16 | from onpy.api.versioning import WorkspaceWVM
17 | from onpy.entities import BodyEntity, EdgeEntity, FaceEntity, VertexEntity
18 | from onpy.entities.filter import EntityFilter
19 | from onpy.entities.protocols import BodyEntityConvertible
20 | from onpy.util.misc import unwrap
21 |
22 | if TYPE_CHECKING:
23 | from onpy.elements.partstudio import PartStudio
24 |
25 |
26 | class Part(BodyEntityConvertible):
27 | """Represents a Part in an OnShape partstudio."""
28 |
29 | def __init__(self, partstudio: "PartStudio", model: schema.Part) -> None:
30 | """Construct a part from it's OnShape model."""
31 | self._partstudio = partstudio
32 | self._model = model
33 | self._api = self._partstudio._api
34 | self._client = self._partstudio._client
35 |
36 | @property
37 | def id(self) -> str:
38 | """The part id."""
39 | return self._model.partId
40 |
41 | @property
42 | def name(self) -> str:
43 | """The name of the part."""
44 | return self._model.name
45 |
46 | def _owned_by_type(self, type_name: str) -> list[str]:
47 | """Get the transient ids of the entities owned by this part of a
48 | certain type.
49 |
50 | Args:
51 | type_name: The type of entity to query. Accepts VERTEX, EDGE, FACE, BODY
52 |
53 | Returns:
54 | A list of transient ids of the resulting queries
55 |
56 | """
57 | script = dedent(
58 | f"""
59 | function(context is Context, queries) {{
60 | var part = {{ "queryType" : QueryType.TRANSIENT, "transientId" : "{self.id}" }} as Query;
61 | var part_faces = qOwnedByBody(part, EntityType.{type_name.upper()});
62 |
63 | return transientQueriesToStrings( evaluateQuery(context, part_faces) );
64 | }}
65 | """, # noqa: E501
66 | )
67 |
68 | response = self._client._api.endpoints.eval_featurescript(
69 | document_id=self._partstudio.document.id,
70 | version=WorkspaceWVM(self._partstudio.document.default_workspace.id),
71 | element_id=self._partstudio.id,
72 | script=script,
73 | return_type=schema.FeaturescriptResponse,
74 | )
75 |
76 | transient_ids_raw = unwrap(
77 | response.result,
78 | message="Featurescript failed get entities owned by part",
79 | )["value"]
80 |
81 | return [i["value"] for i in transient_ids_raw]
82 |
83 | def _vertex_entities(self) -> list[VertexEntity]:
84 | """All of the vertices on this part."""
85 | return [VertexEntity(tid) for tid in self._owned_by_type("VERTEX")]
86 |
87 | def _edge_entities(self) -> list[EdgeEntity]:
88 | """All of the edges on this part."""
89 | return [EdgeEntity(tid) for tid in self._owned_by_type("EDGE")]
90 |
91 | def _face_entities(self) -> list[FaceEntity]:
92 | """All of the faces on this part."""
93 | return [FaceEntity(tid) for tid in self._owned_by_type("FACE")]
94 |
95 | def _body_entity(self) -> BodyEntity:
96 | """Get the body entity of this part."""
97 | return BodyEntity(self.id)
98 |
99 | @override
100 | def _body_entities(self) -> list["BodyEntity"]:
101 | """Convert the current object into a list of body entities."""
102 | return [self._body_entity()]
103 |
104 | @property
105 | def vertices(self) -> EntityFilter[VertexEntity]:
106 | """An object used for interfacing with vertex entities on this part."""
107 | return EntityFilter(
108 | partstudio=self._partstudio,
109 | available=self._vertex_entities(),
110 | )
111 |
112 | @property
113 | def edges(self) -> EntityFilter[EdgeEntity]:
114 | """An object used for interfacing with edge entities on this part."""
115 | return EntityFilter(
116 | partstudio=self._partstudio,
117 | available=self._edge_entities(),
118 | )
119 |
120 | @property
121 | def faces(self) -> EntityFilter[FaceEntity]:
122 | """An object used for interfacing with face entities on this part."""
123 | return EntityFilter(
124 | partstudio=self._partstudio,
125 | available=self._face_entities(),
126 | )
127 |
128 | def __repr__(self) -> str:
129 | """Printable representation of the part."""
130 | return f"Part({self.id})"
131 |
132 | def __str__(self) -> str:
133 | """Pretty representation of the part."""
134 | return repr(self)
135 |
136 |
137 | class PartList:
138 | """Interface to listing/getting parts."""
139 |
140 | def __init__(self, parts: list[Part]) -> None:
141 | """Construct a PartList wrapper from a list[Part] object."""
142 | self.parts = parts
143 |
144 | def __getitem__(self, index: int) -> Part:
145 | """Get a part by its index in the part list."""
146 | return self.parts[index]
147 |
148 | def get(self, name: str) -> Part:
149 | """Get a part by name. Raises KeyError if not found."""
150 | try:
151 | return next(filter(lambda x: x.name.lower() == name.lower(), self.parts))
152 | except StopIteration:
153 | msg = f"No part named '{name}'"
154 | raise KeyError(msg) from None
155 |
156 | def get_id(self, part_id: str) -> Part:
157 | """Get a part by id. Raises KeyError if not found."""
158 | try:
159 | return next(filter(lambda x: x.id == part_id.upper(), self.parts))
160 | except StopIteration:
161 | msg = f"No part with id '{part_id}'"
162 | raise KeyError(msg) from None
163 |
164 | def __str__(self) -> str:
165 | """Pretty string representation of the part list."""
166 | table = PrettyTable(field_names=("Index", "Part Name", "Part ID"))
167 | for i, part in enumerate(self.parts):
168 | table.add_row([i, part.name, part.id])
169 |
170 | return str(table)
171 |
172 | def __repr__(self) -> str:
173 | """Printable representation of the part list."""
174 | return f"PartList({[p.id for p in self.parts]})"
175 |
--------------------------------------------------------------------------------
/src/onpy/util/__init__.py:
--------------------------------------------------------------------------------
1 | """Miscellaneous utilities for OnPy."""
2 |
--------------------------------------------------------------------------------
/src/onpy/util/credentials.py:
--------------------------------------------------------------------------------
1 | """Manages OnShape credentials.
2 |
3 | The credentials used in OnPy can be stored locally in a file, or through
4 | environment variables. The CredentialManager defined here is a utility used
5 | to interface with these credentials.
6 |
7 | OnPy - May 2024 - Kyle Tennison
8 |
9 | """
10 |
11 | import json
12 | import os
13 | from pathlib import Path
14 |
15 | from loguru import logger
16 |
17 | from onpy.util.exceptions import OnPyAuthError
18 | from onpy.util.misc import unwrap
19 |
20 | ACCESS_KEY_LEN = 24 # expected length of an OnShape access key
21 | SECRET_KEY_LEN = 48 # expected length of an OnShape secret key
22 |
23 |
24 | class CredentialManager:
25 | """Manages token retrieval and credential storing."""
26 |
27 | credential_path = Path("~/.onpy/config.json").expanduser()
28 |
29 | @staticmethod
30 | def is_secret_key(token: str | None) -> bool:
31 | """Check if dev secret key regex matches expected pattern.
32 |
33 | Args:
34 | token: The token to check
35 |
36 | Returns:
37 | True if regex matches, False otherwise
38 |
39 | """
40 | if not token:
41 | return False
42 |
43 | return len(token) == SECRET_KEY_LEN
44 |
45 | @staticmethod
46 | def is_access_key(token: str | None) -> bool:
47 | """Check if dev access key regex matches expected pattern.
48 |
49 | Args:
50 | token: The token to check
51 |
52 | Returns:
53 | True if regex matches, False otherwise
54 |
55 | """
56 | if not token:
57 | return False
58 |
59 | return len(token) == ACCESS_KEY_LEN
60 |
61 | @staticmethod
62 | def fetch_dev_tokens() -> tuple[str, str] | None:
63 | """Fetch dev secret and access tokens from file or env var.
64 |
65 | Returns:
66 | A tuple of the (access_key, secret_key), or None if nothing is found
67 |
68 | """
69 | dev_secret = os.environ.get("ONSHAPE_DEV_SECRET")
70 | dev_access = os.environ.get("ONSHAPE_DEV_ACCESS")
71 |
72 | # look in file if no env var set
73 | if dev_secret and dev_access:
74 | if not (p := CredentialManager.credential_path).exists():
75 | logger.warning(
76 | f"Credentials are set in both '{p}' and in env vars. "
77 | "Using the env vars; ignoring config file.",
78 | )
79 | logger.trace(
80 | f"Using tokens from environment vars:\n{dev_access}, {dev_secret}",
81 | )
82 |
83 | else:
84 | logger.trace("Using tokens from environment vars.")
85 |
86 | if not CredentialManager.credential_path.exists():
87 | return None
88 |
89 | with CredentialManager.credential_path.open("r") as f:
90 | data = json.load(f)
91 | dev_secret = str(data["dev_secret"])
92 | dev_access = str(data["dev_access"])
93 |
94 | if not CredentialManager.is_access_key(dev_access):
95 | msg = "Dev access key does not follow expected pattern"
96 | raise OnPyAuthError(msg)
97 | if not CredentialManager.is_secret_key(dev_secret):
98 | msg = "Dev secret key does not follow expected pattern"
99 | raise OnPyAuthError(msg)
100 |
101 | return (dev_access, dev_secret)
102 |
103 | @staticmethod
104 | def configure_file(access_token: str, secret_token: str) -> None:
105 | """Create a configuration file at `~/.onpy/config.json`.
106 |
107 | Args:
108 | access_token: The access token/key from OnShape dev portal
109 | secret_token: The secret token/key from OnShape dev portal
110 |
111 | """
112 | # verify before adding
113 | if not CredentialManager.is_access_key(access_token):
114 | msg = f"Cannot add token {access_token} to credentials file. Not a valid access key."
115 | raise OnPyAuthError(msg)
116 | if not CredentialManager.is_secret_key(secret_token):
117 | msg = f"Cannot add token {secret_token} to credentials file. Not a valid secret key."
118 | raise OnPyAuthError(msg)
119 |
120 | CredentialManager.credential_path.parent.mkdir(exist_ok=True)
121 |
122 | with CredentialManager.credential_path.open("w") as f:
123 | contents = {"dev_access": access_token, "dev_secret": secret_token}
124 | json.dump(contents, f)
125 |
126 | @staticmethod
127 | def prompt_tokens() -> tuple[str, str]:
128 | """Prompt the user in the CLI for secret tokens.
129 |
130 | Returns:
131 | A tuple of the (access_key, secret_key)
132 |
133 | """
134 | logger.info(
135 | "OnPy needs your OnShape credentials. \n"
136 | "navigate to https://dev-portal.onshape.com/keys and generate a pair of "
137 | "access & secret keys. Paste them here when prompted:\n",
138 | )
139 |
140 | while True:
141 | secret_key = input("secret key: ")
142 |
143 | if not CredentialManager.is_secret_key(secret_key):
144 | logger.error(
145 | "the key you entered does not match the expected pattern "
146 | "of a secret key. please try again.",
147 | )
148 | continue
149 |
150 | access_key = input("access key: ")
151 |
152 | if not CredentialManager.is_access_key(access_key):
153 | logger.error(
154 | "the key you entered does not match the expected pattern "
155 | "of a access key. please try again.",
156 | )
157 | continue
158 |
159 | break
160 |
161 | return (access_key, secret_key)
162 |
163 | @classmethod
164 | def fetch_or_prompt(cls) -> tuple[str, str]:
165 | """Fetch the dev and secret tokens if available. Prompts user through
166 | CLI otherwise.
167 |
168 | Returns:
169 | A tuple of the (access_key, secret_key)
170 |
171 | """
172 | tokens = CredentialManager.fetch_dev_tokens()
173 | if tokens:
174 | return tokens
175 |
176 | access_key, secret_key = cls.prompt_tokens()
177 |
178 | CredentialManager.configure_file(access_key, secret_key)
179 | tokens = CredentialManager.fetch_dev_tokens()
180 |
181 | return unwrap(tokens)
182 |
--------------------------------------------------------------------------------
/src/onpy/util/exceptions.py:
--------------------------------------------------------------------------------
1 | """Custom Exceptions.
2 |
3 | OnPy features some custom exceptions, which are defined here. Their implementation
4 | is aimed at being maximally readable and descriptive.
5 |
6 | OnPy - May 2024 - Kyle Tennison
7 |
8 | """
9 |
10 | from __future__ import annotations
11 |
12 | import json
13 | import sys
14 | from abc import ABC, abstractmethod
15 | from typing import TYPE_CHECKING, cast, override
16 |
17 | from loguru import logger
18 |
19 | if TYPE_CHECKING:
20 | from types import TracebackType
21 |
22 | from requests import Response
23 |
24 |
25 | class OnPyError(Exception, ABC):
26 | """An abstract method for OnPy exceptions."""
27 |
28 | def __init__(self, message: str) -> None:
29 | """Construct a base OnPyError.
30 |
31 | Args:
32 | message: The message of the exception
33 |
34 | """
35 | self.message = message
36 | super().__init__(message)
37 |
38 | @abstractmethod
39 | def display(self) -> str:
40 | """Display the exception as a user-friendly string."""
41 | ...
42 |
43 |
44 | class OnPyAuthError(OnPyError):
45 | """Represents an error caused by bad authentication tokens."""
46 |
47 | @override
48 | def display(self) -> str:
49 | """Display the exception as a user-friendly string."""
50 | return f"\nOnPyAuthError({self.message})"
51 |
52 |
53 | class OnPyApiError(OnPyError):
54 | """Represents an error caused by an API calls. Should only be used internally."""
55 |
56 | def __init__(self, message: str, response: Response | None = None) -> None:
57 | """Construct an OnPyApiError.
58 |
59 | Args:
60 | message: The message of the error
61 | response: An optional HTTP response to include
62 |
63 | """
64 | self.response = response
65 | super().__init__(message)
66 |
67 | @override
68 | def display(self) -> str:
69 | """Display the exception as a user-friendly string."""
70 | response_pretty = ""
71 | url = "None"
72 |
73 | if self.response is not None:
74 | url = self.response.url
75 | try:
76 | response_pretty = json.dumps(self.response.json(), indent=4)
77 | except json.JSONDecodeError:
78 | response_pretty = self.response.text
79 | else:
80 | response_pretty = "Undefined"
81 |
82 | return (
83 | f"\nOnPyApiError: (\n"
84 | f" message: {self.message}\n"
85 | f" url: {url}\n"
86 | f" response: {response_pretty}\n"
87 | f")"
88 | )
89 |
90 |
91 | class OnPyInternalError(OnPyError):
92 | """Represents an error caused by an internal OnPy bug."""
93 |
94 | @override
95 | def display(self) -> str:
96 | """Display the exception as a user-friendly string."""
97 | return f"\nOnPyInternalError({self.message})"
98 |
99 |
100 | class OnPyParameterError(OnPyError):
101 | """Represents an error caused by an invalid user input."""
102 |
103 | @override
104 | def display(self) -> str:
105 | """Display the exception as a user-friendly string."""
106 | return f"\nOnPyParameterError({self.message})"
107 |
108 |
109 | class OnPyFeatureError(OnPyError):
110 | """Represents an error caused by an OnShape feature during (re)generation."""
111 |
112 | @override
113 | def display(self) -> str:
114 | """Display the exception as a user-friendly string."""
115 | return f"\nOnPyFeatureError({self.message})"
116 |
117 |
118 | def is_interactive() -> bool:
119 | """Check if the script is being run in an interactive Python shell."""
120 | return hasattr(sys, "ps1")
121 |
122 |
123 | def maybe_exit(exit_code: int) -> None:
124 | """Exit the program if running from a Python file.
125 |
126 | Will not exit if running in an interactive shell.
127 |
128 | Args:
129 | exit_code: Bash exit code
130 |
131 | """
132 | if not is_interactive():
133 | sys.exit(exit_code)
134 |
135 |
136 | def handle_exception(
137 | exc_type: type[BaseException],
138 | exc_value: BaseException,
139 | exc_traceback: TracebackType | None,
140 | ) -> None:
141 | """Handle an exception.
142 |
143 | Used to override the default system excepthook to catch OnPy errors.
144 |
145 | Args:
146 | exc_type: The type of the exception
147 | exc_value: The instance of the exception
148 | exc_traceback: A traceback object
149 |
150 | """
151 | if issubclass(exc_type, OnPyError):
152 | logger.trace(str(exc_traceback))
153 | exc_value = cast("OnPyError", exc_value)
154 | logger.error(exc_value.display())
155 | maybe_exit(1)
156 | return
157 |
158 | sys.__excepthook__(exc_type, exc_value, exc_traceback)
159 |
160 |
161 | sys.excepthook = handle_exception
162 |
--------------------------------------------------------------------------------
/src/onpy/util/misc.py:
--------------------------------------------------------------------------------
1 | """Miscellaneous Tools.
2 |
3 | Misc tools should go here
4 |
5 | OnPy - May 2024 - Kyle Tennison
6 |
7 | """
8 |
9 | import math
10 | from dataclasses import dataclass
11 | from enum import Enum
12 | from typing import Self
13 |
14 | from onpy.api.schema import NameIdFetchable
15 | from onpy.util.exceptions import OnPyParameterError
16 |
17 |
18 | def find_by_name_or_id[T: NameIdFetchable](
19 | target_id: str | None, name: str | None, items: list[T]
20 | ) -> T | None:
21 | """Given a list of values and a name & id, find the first match.
22 |
23 | Only the name or id needs to be provided.
24 |
25 | Args:
26 | target_id: The id to search for
27 | name: The name to search for
28 | items: A list of items to search through
29 |
30 | Returns:
31 | The matching item, if found. Returns None if no match is found.
32 |
33 | Raises:
34 | OnPyParameterError if neither the id nor name were provided.
35 |
36 | """
37 | if name is None and target_id is None:
38 | msg = "A name or id is required to fetch"
39 | raise OnPyParameterError(msg)
40 |
41 | if len(items) == 0:
42 | return None
43 |
44 | candidate: T | None = None
45 |
46 | if name:
47 | filtered = [i for i in items if i.name == name]
48 | if len(filtered) > 1:
49 | msg = f"Duplicate names '{name}'. Use id instead to fetch."
50 | raise OnPyParameterError(msg)
51 | if len(filtered) == 0:
52 | return None
53 |
54 | candidate = filtered[0]
55 |
56 | if target_id:
57 | for i in items:
58 | if i.id == target_id:
59 | candidate = i
60 | break
61 |
62 | return candidate
63 |
64 |
65 | def unwrap_type[T](target: object, expected_type: type[T]) -> T:
66 | """Return the object if the type matches. Raises error otherwise."""
67 | if isinstance(target, expected_type):
68 | return target
69 | msg = (
70 | f"Failed to unwrap type. Got {type(target).__name__}, expected {expected_type.__name__}",
71 | )
72 | raise TypeError(msg)
73 |
74 |
75 | def unwrap[T](target: T | None, message: str | None = None, default: T | None = None) -> T:
76 | """Take the object out of an Option[T].
77 |
78 | Args:
79 | target: The object to unwrap
80 | message: An optional message to show on error
81 | default: An optional value to use instead of throwing an error
82 |
83 | Returns:
84 | The object of the value, if it is not None. Returns the default if the
85 | object is None and a default value is provided
86 |
87 | Raises:
88 | TypeError if the object is None and no default value is provided.
89 |
90 | """
91 | if target is not None:
92 | return target
93 | if default is not None:
94 | return default
95 | raise TypeError(message if message else "Failed to unwrap")
96 |
97 |
98 | class UnitSystem(Enum):
99 | """Enumeration of available OnShape unit systems for length."""
100 |
101 | INCH = "inch"
102 | METRIC = "metric"
103 |
104 | @classmethod
105 | def from_string(cls, string: str) -> Self:
106 | """Get corresponding enum variant from string.
107 |
108 | Raises:
109 | TypeError if the string does not match the expected type
110 |
111 | """
112 | string = string.lower()
113 |
114 | if string not in UnitSystem:
115 | msg = f"'{string}' is not a valid unit system"
116 | raise TypeError(msg)
117 | return cls(string)
118 |
119 | @property
120 | def extension(self) -> str:
121 | """Get the extension of the unit; e.g., 'in' for inches."""
122 | return {UnitSystem.INCH: "in", UnitSystem.METRIC: "m"}[self]
123 |
124 | @property
125 | def fs_name(self) -> str:
126 | """The featurescript name of the unit system."""
127 | return {UnitSystem.INCH: "inch", UnitSystem.METRIC: "meter"}[self]
128 |
129 |
130 | @dataclass
131 | class Point2D:
132 | """Represents a 2D point."""
133 |
134 | x: float
135 | y: float
136 |
137 | def __mul__(self, value: float) -> "Point2D":
138 | """Multiply a point by a scalar."""
139 | return Point2D(x=self.x * value, y=self.y * value)
140 |
141 | def __truediv__(self, value: float) -> "Point2D":
142 | """Divide a point by a scalar."""
143 | return Point2D(x=self.x / value, y=self.y / value)
144 |
145 | def __add__(self, other: "Point2D") -> "Point2D":
146 | """Add two points together."""
147 | return Point2D(self.x + other.x, self.y + other.y)
148 |
149 | def __sub__(self, other: "Point2D") -> "Point2D":
150 | """Subtract one point from another."""
151 | return Point2D(self.x - other.x, self.y - other.y)
152 |
153 | def __eq__(self, other: object) -> bool:
154 | """Check if two points are equal."""
155 | if isinstance(other, Point2D):
156 | return self.x == other.x and self.y == other.y
157 | return False
158 |
159 | @classmethod
160 | def from_pair(cls, pair: tuple[float, float]) -> Self:
161 | """Create a point from an ordered pair."""
162 | return cls(*pair)
163 |
164 | @property
165 | def as_tuple(self) -> tuple[float, float]:
166 | """The point as an ordered pair tuple."""
167 | return (self.x, self.y)
168 |
169 | @staticmethod
170 | def approx(point1: "Point2D", point2: "Point2D", error: float = 1e-8) -> bool:
171 | """Check if two points are approximately equal."""
172 | distance = math.sqrt((point1.x - point2.x) ** 2 + (point1.y - point2.y) ** 2)
173 | return distance < error
174 |
--------------------------------------------------------------------------------
/tests/test_documents.py:
--------------------------------------------------------------------------------
1 | """Tests document management"""
2 |
3 | from onpy import Client
4 |
5 | import pytest
6 | import uuid
7 |
8 | from onpy.util.exceptions import OnPyParameterError
9 |
10 | client = Client()
11 |
12 |
13 | def test_lifecycle():
14 | """Tests document creation, retrieval, and deletion"""
15 |
16 | document_name = f"test_document_{uuid.uuid4()}"
17 |
18 | document = client.create_document(document_name)
19 |
20 | doc_copy1 = client.get_document(name=document_name)
21 | assert doc_copy1 == document
22 |
23 | doc_copy2 = client.get_document(document_id=document.id)
24 | assert doc_copy2 == document
25 |
26 | new_document = client.create_document(document_name + "_modified")
27 | assert new_document != document
28 |
29 | new_document.delete()
30 | document.delete()
31 |
32 |
33 | def test_exceptions():
34 | """Tests exertions relating to documents"""
35 |
36 | with pytest.raises(OnPyParameterError) as _:
37 | client.get_document(document_id="not a id")
38 |
39 | with pytest.raises(OnPyParameterError) as _:
40 | client.get_document()
41 |
42 | with pytest.raises(OnPyParameterError) as _:
43 | client.get_document(document_id="123", name="123")
44 |
45 |
46 | def test_elements():
47 | """Tests element fetching"""
48 |
49 | document = client.create_document("test_documents::test_elements")
50 |
51 | try:
52 | main_ps = document.get_partstudio(name="Part Studio 1")
53 |
54 | assert main_ps == document.get_partstudio(element_id=main_ps.id)
55 | assert main_ps in document.elements
56 | finally:
57 | document.delete()
58 |
59 |
60 | def test_versions():
61 | """Test the ability to create versions"""
62 |
63 | document = client.create_document("test_documents::test_versions")
64 |
65 | document.create_version("V33")
66 | assert "V33" in [v.name for v in client._api.endpoints.list_versions(document.id)]
67 |
68 | document.create_version()
69 | assert "V34" in [v.name for v in client._api.endpoints.list_versions(document.id)]
70 |
71 | document.delete()
72 |
--------------------------------------------------------------------------------
/tests/test_features.py:
--------------------------------------------------------------------------------
1 | import onpy
2 | from onpy import Client
3 | from onpy.api.versioning import WorkspaceWVM
4 |
5 |
6 | def test_sketch_extrude():
7 | """Tests the ability to extrude a sketch"""
8 |
9 | client = Client()
10 |
11 | doc = client.create_document(name="test_features::test_sketch_extrude")
12 | partstudio = doc.get_partstudio(name="Part Studio 1")
13 |
14 | # draw a circle
15 | sketch = partstudio.add_sketch(
16 | plane=partstudio.features.front_plane, name="Base Sketch"
17 | )
18 | sketch.add_circle((-1, 0), 1)
19 | sketch.add_circle((1, 0), 1)
20 |
21 | partstudio.add_extrude(faces=sketch, distance=1, name="new extrude")
22 |
23 | new_sketch = partstudio.add_sketch(
24 | plane=partstudio.features.top_plane, name="Second Sketch"
25 | )
26 |
27 | # a box with an arc
28 | new_sketch.trace_points((3, 4), (3, 3), (4, 3))
29 | new_sketch.add_centerpoint_arc(
30 | centerpoint=(3, 3), radius=1, start_angle=0, end_angle=90
31 | )
32 |
33 | partstudio.add_extrude(
34 | faces=new_sketch.faces.contains_point((3.5, 3.5, 0)),
35 | distance=1,
36 | )
37 |
38 | # test extrude on a new plane
39 | new_plane = partstudio.add_offset_plane(
40 | target=partstudio.features.top_plane, distance=3
41 | )
42 | offset_sketch = partstudio.add_sketch(new_plane)
43 | offset_sketch.add_circle((0, 0), 1)
44 | partstudio.add_extrude(
45 | faces=offset_sketch.faces.contains_point((0, 0, 3)), distance=-3
46 | )
47 |
48 | # try to extrude between the sketches
49 | loft_start = partstudio.add_sketch(partstudio.features.top_plane)
50 | loft_start.add_circle((1, 0), 2)
51 | partstudio.add_loft(loft_start.faces.largest(), offset_sketch.faces.largest())
52 |
53 | doc.delete()
54 |
55 |
56 | def test_sketch_queries():
57 | """Test the ability to query sketch regions"""
58 |
59 | document = Client().create_document("test_features::test_sketch_queries")
60 |
61 | partstudio = document.get_partstudio()
62 | partstudio.wipe()
63 |
64 | sketch = partstudio.add_sketch(plane=partstudio.features.top_plane)
65 | sketch.trace_points((-2, -2), (-2, 2), (2, 2), (2, -2))
66 | sketch.add_circle(center=(0, 0), radius=1)
67 |
68 | sketch.add_line((-2, -1), (2, 2))
69 |
70 | print(sketch.faces.contains_point((1, -1, 0)))
71 |
72 | partstudio.add_extrude(faces=sketch.faces.largest(), distance=1)
73 | partstudio.add_extrude(faces=sketch.faces.smallest(), distance=0.5)
74 | partstudio.add_extrude(faces=sketch.faces.contains_point((0, 0, 0)), distance=-3)
75 |
76 | document.delete()
77 |
78 |
79 | def test_feature_wipe():
80 | """Test the ability to remove features"""
81 |
82 | client = Client()
83 |
84 | document = client.create_document("test_features::test_feature_wipe")
85 | partstudio = document.get_partstudio()
86 |
87 | # add dummy feature(s)
88 | sketch = partstudio.add_sketch(
89 | plane=partstudio.features.top_plane,
90 | name="Overlapping Sketch",
91 | )
92 | sketch.add_circle(center=(-0.5, 0), radius=1)
93 | sketch.add_circle(center=(0.5, 0), radius=1)
94 | partstudio.add_extrude(faces=sketch, distance=1)
95 |
96 | partstudio.wipe()
97 |
98 | features = client._api.endpoints.list_features(
99 | document_id=document.id,
100 | version=WorkspaceWVM(document.default_workspace.id),
101 | element_id=partstudio.id,
102 | )
103 |
104 | assert len(features) == 0
105 |
106 | document.delete()
107 |
108 |
109 | def test_pseudo_elements():
110 | """Tests the ability to mirror, rotate, and translate items, along with
111 | pseudo elements like fillets."""
112 |
113 | document = Client().create_document("test_features::test_pseudo_elements")
114 | partstudio = document.get_partstudio()
115 |
116 | partstudio.wipe()
117 |
118 | sketch = partstudio.add_sketch(plane=partstudio.features.top_plane)
119 |
120 | lines = sketch.trace_points((-1, 0), (-0.5, 1), (0.5, 1), end_connect=False)
121 | fillet_arc = sketch.add_fillet(lines[0], lines[1], radius=0.2)
122 | main_arc = sketch.add_centerpoint_arc(
123 | centerpoint=(0.5, 0), radius=1, start_angle=0, end_angle=90
124 | )
125 |
126 | sketch.mirror([*lines, fillet_arc, main_arc], line_point=(0, 0), line_dir=(1, 0))
127 |
128 | sketch.translate(sketch.sketch_items, x=1, y=1)
129 | sketch.rotate(sketch.sketch_items, origin=(0, 0), theta=180)
130 | partstudio.add_extrude(faces=sketch.faces.contains_point((-1, -1, 0)), distance=1)
131 |
132 | document.delete()
133 |
134 |
135 | def test_part_query():
136 | """Tests the ability to query a part"""
137 |
138 | document = onpy.create_document("test_features::test_part_query")
139 | partstudio = document.get_partstudio()
140 | partstudio.wipe()
141 |
142 | sketch1 = partstudio.add_sketch(partstudio.features.top_plane)
143 | sketch1.add_circle((0, 0), radius=1)
144 |
145 | extrude = partstudio.add_extrude(sketch1, distance=3)
146 |
147 | new_part = extrude.get_created_parts()[0]
148 |
149 | sketch3 = partstudio.add_sketch(new_part.faces.closest_to((0, 0, 1000)))
150 | sketch3.add_corner_rectangle((0.5, 0.5), (-0.5, -0.5))
151 |
152 | extrude = partstudio.add_extrude(sketch3, distance=0.5, merge_with=new_part)
153 |
154 | sketch4 = partstudio.add_sketch(plane=new_part.faces.closest_to((0, 0, 10000)))
155 | sketch4.add_circle((0, 0), radius=3)
156 | sketch4.add_circle((0, 4), radius=1)
157 |
158 | extrude = partstudio.add_extrude(sketch4.faces.smallest(), distance=3)
159 |
160 | sketch_cut = partstudio.add_sketch(partstudio.features.top_plane)
161 | sketch_cut.add_circle((0, 0), 0.4)
162 |
163 | main_part = partstudio.parts[0]
164 |
165 | partstudio.add_extrude(sketch_cut, distance=4, subtract_from=main_part)
166 |
167 | print("available parts")
168 | print(partstudio.parts)
169 |
170 | document.delete()
171 |
172 | def test_part_translate():
173 | """Tests the ability to translate (move and copy) a part"""
174 |
175 | document = onpy.create_document("test_features::test_part_translate")
176 | partstudio = document.get_partstudio()
177 | partstudio.wipe()
178 |
179 | sketch1 = partstudio.add_sketch(partstudio.features.top_plane)
180 | sketch1.add_circle((0, 0), radius=1)
181 |
182 | extrude = partstudio.add_extrude(sketch1, distance=3)
183 |
184 | new_part = extrude.get_created_parts()[0]
185 |
186 | partstudio.add_translate(new_part, x=10, y=80, z=30, copy=True)
187 | partstudio.add_translate(new_part, x=-10, y=-80, z=-30, copy=False)
188 |
189 | assert len(partstudio.parts.parts) == 2
190 |
191 | document.delete()
192 |
--------------------------------------------------------------------------------