├── .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 | [![CI Tests](https://github.com/kyle-tennison/onpy/actions/workflows/validate.yml/badge.svg)](https://github.com/kyle-tennison/onpy/actions/workflows/validate.yml) 4 | [![MIT License](https://img.shields.io/github/license/kyle-tennison/onpy?color=yellow)](https://opensource.org/license/mit) 5 | [![PyPi Version](https://img.shields.io/pypi/v/onpy?color=blue)](https://pypi.org/project/onpy/) 6 | [![black](https://img.shields.io/badge/code%20style-black-000000.svg?style=flat)](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 | ![A screenshot of the code output](.github/media/readme_screenshot.png) 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 | --------------------------------------------------------------------------------