symcad.core.GraphAPI

The GraphAPI module provides methods and functionality for interacting with the graph-based data representation of a symcad.core.Assembly in the SymCAD library.

All methods within this module are for internal use only and should not be directly accessed by any external module. Interacting with graph-based Assembly representations should be done via the symcad.core.Assembly.Assembly.save() and symcad.core.Assembly.Assembly.load() methods.

The SymCAD Graph Schema for an Assembly is JSON-based, as follows:

{
   "name": str,
   "parts": [
      {
         "name": str,
         "type": str,
         "geometry": {
            "property1": str | float,
         },
         "material_density": float,
         "static_origin": null | {
            "x": str | float,
            "y": str | float,
            "z": str | float
         },
         "static_placement": null | {
            "x": str | float,
            "y": str | float,
            "z": str | float
         },
         "attachment_points": [
            {
               "name": str,
               "x": float,
               "y": float,
               "z": float
            }
         ],
         "connection_ports": [
            {
               "name": str,
               "x": float,
               "y": float,
               "z": float
            }
         ],
         "orientation": {
            "roll": str | float,
            "pitch": str | float,
            "yaw": str | float
         },
         "is_exposed": true | false
      }
   ],
   "attachments": [
      {
         "source_node": str,
         "source_attachment": str,
         "destination_node": str,
         "destination_attachment": str
      }
   ],
   "connections": [
      {
         "source_node": str,
         "source_connection": str,
         "destination_node": str,
         "destination_connection": str
      }
   ]
}
  1#!/usr/bin/env python3
  2# Copyright (C) 2022, Will Hedgecock
  3#
  4# This program is free software: you can redistribute it and/or modify
  5# it under the terms of the GNU General Public License as published by
  6# the Free Software Foundation, either version 3 of the License, or
  7# (at your option) any later version.
  8#
  9# This program is distributed in the hope that it will be useful,
 10# but WITHOUT ANY WARRANTY; without even the implied warranty of
 11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 12# GNU General Public License for more details.
 13#
 14# You should have received a copy of the GNU General Public License
 15# along with this program.  If not, see <http://www.gnu.org/licenses/>.
 16
 17"""
 18The GraphAPI module provides methods and functionality for interacting with the graph-based
 19data representation of a `symcad.core.Assembly` in the SymCAD library.
 20
 21All methods within this module are for internal use only and should not be directly accessed
 22by any external module. Interacting with graph-based `Assembly` representations should be
 23done via the `symcad.core.Assembly.Assembly.save()` and `symcad.core.Assembly.Assembly.load()`
 24methods.
 25
 26The SymCAD Graph Schema for an `Assembly` is JSON-based, as follows:
 27
 28```python
 29{
 30   "name": str,
 31   "parts": [
 32      {
 33         "name": str,
 34         "type": str,
 35         "geometry": {
 36            "property1": str | float,
 37         },
 38         "material_density": float,
 39         "static_origin": null | {
 40            "x": str | float,
 41            "y": str | float,
 42            "z": str | float
 43         },
 44         "static_placement": null | {
 45            "x": str | float,
 46            "y": str | float,
 47            "z": str | float
 48         },
 49         "attachment_points": [
 50            {
 51               "name": str,
 52               "x": float,
 53               "y": float,
 54               "z": float
 55            }
 56         ],
 57         "connection_ports": [
 58            {
 59               "name": str,
 60               "x": float,
 61               "y": float,
 62               "z": float
 63            }
 64         ],
 65         "orientation": {
 66            "roll": str | float,
 67            "pitch": str | float,
 68            "yaw": str | float
 69         },
 70         "is_exposed": true | false
 71      }
 72   ],
 73   "attachments": [
 74      {
 75         "source_node": str,
 76         "source_attachment": str,
 77         "destination_node": str,
 78         "destination_attachment": str
 79      }
 80   ],
 81   "connections": [
 82      {
 83         "source_node": str,
 84         "source_connection": str,
 85         "destination_node": str,
 86         "destination_connection": str
 87      }
 88   ]
 89}
 90```
 91"""
 92
 93from __future__ import annotations
 94from .Coordinate import Coordinate
 95from .Geometry import Geometry
 96from .Assembly import Assembly
 97from typing import Any
 98import json, math, sympy
 99
100
101def _isfloat(num: Any) -> bool:
102   """Private helper function to test if a value is float-convertible."""
103   try:
104      float(num)
105      return True
106   except TypeError:
107      return False
108
109
110def export_to_json(assembly: Assembly) -> str:
111   """Returns a string-based JSON representation of the specified `Assembly`."""
112
113   # Iterate through all SymCAD parts in the assembly
114   json_dict = { 'name': assembly.name, 'parts': [], 'attachments': [], 'connections': [] }
115   for part in assembly.parts:
116
117      # Create static origin and placement structures
118      if part.static_origin is not None:
119         static_origin = {
120            'x': str(part.static_origin.x)
121                    if not _isfloat(part.static_origin.x) else
122                 float(part.static_origin.x),
123            'y': str(part.static_origin.y)
124                    if not _isfloat(part.static_origin.y) else
125                 float(part.static_origin.y),
126            'z': str(part.static_origin.z)
127                    if not _isfloat(part.static_origin.z) else
128                 float(part.static_origin.z)
129         }
130      else:
131         static_origin = None
132      if part.static_placement is not None:
133         static_placement = {
134            'x': str(part.static_placement.x)
135                    if not _isfloat(part.static_placement.x) else
136                 float(part.static_placement.x),
137            'y': str(part.static_placement.y)
138                    if not _isfloat(part.static_placement.y) else
139                 float(part.static_placement.y),
140            'z': str(part.static_placement.z)
141                    if not _isfloat(part.static_placement.z) else
142                 float(part.static_placement.z)
143         }
144      else:
145         static_placement = None
146
147      # Create a JSON structure for the current part
148      component = {
149         'name': part.name,
150         'type': '.'.join(str(part.__class__).split('\'')[1].split('.')[2:-1]),
151         'geometry': {k: str(part.geometry.__dict__[k])
152                             if not _isfloat(part.geometry.__dict__[k]) else
153                          part.geometry.__dict__[k]
154                      for k in set(list(part.geometry.__dict__.keys())) - {'name'}},
155         'material_density': part.material_density,
156         'static_origin': static_origin,
157         'static_placement': static_placement,
158         'attachment_points': [pt.__dict__ for pt in part.attachment_points],
159         'connection_ports': [pt.__dict__ for pt in part.connection_ports],
160         'orientation': {
161            'roll': str(part.orientation.roll)
162                       if not _isfloat(part.orientation.roll) else
163                    math.degrees(part.orientation.roll),
164            'pitch': str(part.orientation.pitch)
165                        if not _isfloat(part.orientation.pitch) else
166                     math.degrees(part.orientation.pitch),
167            'yaw': str(part.orientation.yaw)
168                      if not _isfloat(part.orientation.yaw) else
169                   math.degrees(part.orientation.yaw)
170         },
171         'is_exposed': part.is_exposed
172      }
173
174      # Create an attachment structure for each attachment in the current part
175      attachments = []
176      for local_attachment, remote_attachment in part.attachments.items():
177         attachments.append({
178            'source_node': part.name,
179            'source_attachment': local_attachment,
180            'destination_node': remote_attachment.split('#')[0],
181            'destination_attachment': remote_attachment.split('#')[1]
182         })
183
184      # Create a connection structure for each connection in the current part
185      connections = []
186      for local_connection, remote_connection in part.connections.items():
187         connections.append({
188            'source_node': part.name,
189            'source_connection': local_connection,
190            'destination_node': remote_connection.split('#')[0],
191            'destination_connection': remote_connection.split('#')[1]
192         })
193
194      # Append the part component and its attachments to the JSON structure
195      json_dict['parts'].append(component)
196      for new_attach in attachments:
197         already_exists = False
198         for attach2 in json_dict['attachments']:
199            if new_attach['source_node'] == attach2['destination_node'] and \
200                  new_attach['source_attachment'] == attach2['destination_attachment'] and \
201                  new_attach['destination_node'] == attach2['source_node'] and \
202                  new_attach['destination_attachment'] == attach2['source_attachment']:
203               already_exists = True
204         if not already_exists:
205            json_dict['attachments'].append(new_attach)
206
207      # Append the part connections to the JSON structure
208      for new_connect in connections:
209         already_exists = False
210         for connect2 in json_dict['connections']:
211            if new_connect['source_node'] == connect2['destination_node'] and \
212                  new_connect['source_connection'] == connect2['destination_connection'] and \
213                  new_connect['destination_node'] == connect2['source_node'] and \
214                  new_connect['destination_connection'] == connect2['source_connection']:
215               already_exists = True
216         if not already_exists:
217            json_dict['connections'].append(new_connect)
218
219   # Return a string representation of the complete JSON structure
220   return json.dumps(json_dict, indent=3)
221
222
223def import_from_json(json_str: str) -> Assembly:
224   """Returns a new `Assembly` parsed from its JSON string representation in `json_str`."""
225
226   # Parse the JSON string as an actual JSON dictionary
227   json_dict = json.loads(json_str)
228   assembly = Assembly(json_dict['name'])
229
230   # Iterate through all parts in the JSON structure
231   for part in json_dict['parts']:
232
233      # Convert the JSON part into its SymCAD representation
234      part_class = getattr(__import__('symcad'), 'parts')
235      for comp in part['type'].split('.'):
236         part_class = getattr(part_class, comp)
237      shape = part_class(part['name'])
238      geometry = Geometry(part['name'])
239      for property, value in part['geometry'].items():
240         setattr(geometry, property, sympy.Symbol(value) if isinstance(value, str) else value)
241      setattr(shape, 'geometry', geometry)
242      setattr(shape, 'material_density', part['material_density'])
243      if part['static_origin'] is not None:
244         setattr(shape, 'static_origin',
245            Coordinate(part['name'] + '_origin',
246                       x=sympy.Symbol(part['static_origin']['x'])
247                            if isinstance(part['static_origin']['x'], str) else
248                         part['static_origin']['x'],
249                       y=sympy.Symbol(part['static_origin']['y'])
250                            if isinstance(part['static_origin']['y'], str) else
251                         part['static_origin']['y'],
252                       z=sympy.Symbol(part['static_origin']['z'])
253                            if isinstance(part['static_origin']['z'], str) else
254                         part['static_origin']['z']))
255      if part['static_placement'] is not None:
256         setattr(shape, 'static_placement',
257            Coordinate(part['name'] + '_placement',
258                       x=sympy.Symbol(part['static_placement']['x'])
259                            if isinstance(part['static_placement']['x'], str) else
260                         part['static_placement']['x'],
261                       y=sympy.Symbol(part['static_placement']['y'])
262                            if isinstance(part['static_placement']['y'], str) else
263                         part['static_placement']['y'],
264                       z=sympy.Symbol(part['static_placement']['z'])
265                            if isinstance(part['static_placement']['z'], str) else
266                         part['static_placement']['z']))
267      attachment_points = []
268      for attachment_point in part['attachment_points']:
269         attachment_points.append(Coordinate(attachment_point['name'],
270                                             x=attachment_point['x'],
271                                             y=attachment_point['y'],
272                                             z=attachment_point['z']))
273      connection_ports = []
274      for connection_port in part['connection_ports']:
275         connection_ports.append(Coordinate(connection_port['name'],
276                                            x=connection_port['x'],
277                                            y=connection_port['y'],
278                                            z=connection_port['z']))
279      setattr(shape, 'attachment_points', attachment_points)
280      setattr(shape, 'connection_ports', connection_ports)
281      setattr(getattr(shape, 'orientation'), 'roll',
282              sympy.Symbol(part['orientation']['roll'])
283                 if isinstance(part['orientation']['roll'], str) else
284               math.radians(part['orientation']['roll']))
285      setattr(getattr(shape, 'orientation'), 'pitch',
286              sympy.Symbol(part['orientation']['pitch'])
287                 if isinstance(part['orientation']['pitch'], str) else
288               math.radians(part['orientation']['pitch']))
289      setattr(getattr(shape, 'orientation'), 'yaw',
290              sympy.Symbol(part['orientation']['yaw'])
291                 if isinstance(part['orientation']['yaw'], str) else
292               math.radians(part['orientation']['yaw']))
293      setattr(shape, 'is_exposed', part['is_exposed'])
294      assembly.add_part(shape)
295
296   # Make all necessary part attachments
297   for attachment in json_dict['attachments']:
298      for part in assembly.parts:
299         if part.name == attachment['source_node']:
300            source = part
301         elif part.name == attachment['destination_node']:
302            dest = part
303      source.attach(attachment['source_attachment'], dest, attachment['destination_attachment'])
304
305   # Make all necessary part connections
306   for connection in json_dict['connections']:
307      for part in assembly.parts:
308         if part.name == connection['source_node']:
309            source = part
310         elif part.name == connection['destination_node']:
311            dest = part
312      source.connect(connection['source_connection'], dest, connection['destination_connection'])
313
314   return assembly
def export_to_json(assembly: symcad.core.Assembly.Assembly) -> str:
111def export_to_json(assembly: Assembly) -> str:
112   """Returns a string-based JSON representation of the specified `Assembly`."""
113
114   # Iterate through all SymCAD parts in the assembly
115   json_dict = { 'name': assembly.name, 'parts': [], 'attachments': [], 'connections': [] }
116   for part in assembly.parts:
117
118      # Create static origin and placement structures
119      if part.static_origin is not None:
120         static_origin = {
121            'x': str(part.static_origin.x)
122                    if not _isfloat(part.static_origin.x) else
123                 float(part.static_origin.x),
124            'y': str(part.static_origin.y)
125                    if not _isfloat(part.static_origin.y) else
126                 float(part.static_origin.y),
127            'z': str(part.static_origin.z)
128                    if not _isfloat(part.static_origin.z) else
129                 float(part.static_origin.z)
130         }
131      else:
132         static_origin = None
133      if part.static_placement is not None:
134         static_placement = {
135            'x': str(part.static_placement.x)
136                    if not _isfloat(part.static_placement.x) else
137                 float(part.static_placement.x),
138            'y': str(part.static_placement.y)
139                    if not _isfloat(part.static_placement.y) else
140                 float(part.static_placement.y),
141            'z': str(part.static_placement.z)
142                    if not _isfloat(part.static_placement.z) else
143                 float(part.static_placement.z)
144         }
145      else:
146         static_placement = None
147
148      # Create a JSON structure for the current part
149      component = {
150         'name': part.name,
151         'type': '.'.join(str(part.__class__).split('\'')[1].split('.')[2:-1]),
152         'geometry': {k: str(part.geometry.__dict__[k])
153                             if not _isfloat(part.geometry.__dict__[k]) else
154                          part.geometry.__dict__[k]
155                      for k in set(list(part.geometry.__dict__.keys())) - {'name'}},
156         'material_density': part.material_density,
157         'static_origin': static_origin,
158         'static_placement': static_placement,
159         'attachment_points': [pt.__dict__ for pt in part.attachment_points],
160         'connection_ports': [pt.__dict__ for pt in part.connection_ports],
161         'orientation': {
162            'roll': str(part.orientation.roll)
163                       if not _isfloat(part.orientation.roll) else
164                    math.degrees(part.orientation.roll),
165            'pitch': str(part.orientation.pitch)
166                        if not _isfloat(part.orientation.pitch) else
167                     math.degrees(part.orientation.pitch),
168            'yaw': str(part.orientation.yaw)
169                      if not _isfloat(part.orientation.yaw) else
170                   math.degrees(part.orientation.yaw)
171         },
172         'is_exposed': part.is_exposed
173      }
174
175      # Create an attachment structure for each attachment in the current part
176      attachments = []
177      for local_attachment, remote_attachment in part.attachments.items():
178         attachments.append({
179            'source_node': part.name,
180            'source_attachment': local_attachment,
181            'destination_node': remote_attachment.split('#')[0],
182            'destination_attachment': remote_attachment.split('#')[1]
183         })
184
185      # Create a connection structure for each connection in the current part
186      connections = []
187      for local_connection, remote_connection in part.connections.items():
188         connections.append({
189            'source_node': part.name,
190            'source_connection': local_connection,
191            'destination_node': remote_connection.split('#')[0],
192            'destination_connection': remote_connection.split('#')[1]
193         })
194
195      # Append the part component and its attachments to the JSON structure
196      json_dict['parts'].append(component)
197      for new_attach in attachments:
198         already_exists = False
199         for attach2 in json_dict['attachments']:
200            if new_attach['source_node'] == attach2['destination_node'] and \
201                  new_attach['source_attachment'] == attach2['destination_attachment'] and \
202                  new_attach['destination_node'] == attach2['source_node'] and \
203                  new_attach['destination_attachment'] == attach2['source_attachment']:
204               already_exists = True
205         if not already_exists:
206            json_dict['attachments'].append(new_attach)
207
208      # Append the part connections to the JSON structure
209      for new_connect in connections:
210         already_exists = False
211         for connect2 in json_dict['connections']:
212            if new_connect['source_node'] == connect2['destination_node'] and \
213                  new_connect['source_connection'] == connect2['destination_connection'] and \
214                  new_connect['destination_node'] == connect2['source_node'] and \
215                  new_connect['destination_connection'] == connect2['source_connection']:
216               already_exists = True
217         if not already_exists:
218            json_dict['connections'].append(new_connect)
219
220   # Return a string representation of the complete JSON structure
221   return json.dumps(json_dict, indent=3)

Returns a string-based JSON representation of the specified Assembly.

def import_from_json(json_str: str) -> symcad.core.Assembly.Assembly:
224def import_from_json(json_str: str) -> Assembly:
225   """Returns a new `Assembly` parsed from its JSON string representation in `json_str`."""
226
227   # Parse the JSON string as an actual JSON dictionary
228   json_dict = json.loads(json_str)
229   assembly = Assembly(json_dict['name'])
230
231   # Iterate through all parts in the JSON structure
232   for part in json_dict['parts']:
233
234      # Convert the JSON part into its SymCAD representation
235      part_class = getattr(__import__('symcad'), 'parts')
236      for comp in part['type'].split('.'):
237         part_class = getattr(part_class, comp)
238      shape = part_class(part['name'])
239      geometry = Geometry(part['name'])
240      for property, value in part['geometry'].items():
241         setattr(geometry, property, sympy.Symbol(value) if isinstance(value, str) else value)
242      setattr(shape, 'geometry', geometry)
243      setattr(shape, 'material_density', part['material_density'])
244      if part['static_origin'] is not None:
245         setattr(shape, 'static_origin',
246            Coordinate(part['name'] + '_origin',
247                       x=sympy.Symbol(part['static_origin']['x'])
248                            if isinstance(part['static_origin']['x'], str) else
249                         part['static_origin']['x'],
250                       y=sympy.Symbol(part['static_origin']['y'])
251                            if isinstance(part['static_origin']['y'], str) else
252                         part['static_origin']['y'],
253                       z=sympy.Symbol(part['static_origin']['z'])
254                            if isinstance(part['static_origin']['z'], str) else
255                         part['static_origin']['z']))
256      if part['static_placement'] is not None:
257         setattr(shape, 'static_placement',
258            Coordinate(part['name'] + '_placement',
259                       x=sympy.Symbol(part['static_placement']['x'])
260                            if isinstance(part['static_placement']['x'], str) else
261                         part['static_placement']['x'],
262                       y=sympy.Symbol(part['static_placement']['y'])
263                            if isinstance(part['static_placement']['y'], str) else
264                         part['static_placement']['y'],
265                       z=sympy.Symbol(part['static_placement']['z'])
266                            if isinstance(part['static_placement']['z'], str) else
267                         part['static_placement']['z']))
268      attachment_points = []
269      for attachment_point in part['attachment_points']:
270         attachment_points.append(Coordinate(attachment_point['name'],
271                                             x=attachment_point['x'],
272                                             y=attachment_point['y'],
273                                             z=attachment_point['z']))
274      connection_ports = []
275      for connection_port in part['connection_ports']:
276         connection_ports.append(Coordinate(connection_port['name'],
277                                            x=connection_port['x'],
278                                            y=connection_port['y'],
279                                            z=connection_port['z']))
280      setattr(shape, 'attachment_points', attachment_points)
281      setattr(shape, 'connection_ports', connection_ports)
282      setattr(getattr(shape, 'orientation'), 'roll',
283              sympy.Symbol(part['orientation']['roll'])
284                 if isinstance(part['orientation']['roll'], str) else
285               math.radians(part['orientation']['roll']))
286      setattr(getattr(shape, 'orientation'), 'pitch',
287              sympy.Symbol(part['orientation']['pitch'])
288                 if isinstance(part['orientation']['pitch'], str) else
289               math.radians(part['orientation']['pitch']))
290      setattr(getattr(shape, 'orientation'), 'yaw',
291              sympy.Symbol(part['orientation']['yaw'])
292                 if isinstance(part['orientation']['yaw'], str) else
293               math.radians(part['orientation']['yaw']))
294      setattr(shape, 'is_exposed', part['is_exposed'])
295      assembly.add_part(shape)
296
297   # Make all necessary part attachments
298   for attachment in json_dict['attachments']:
299      for part in assembly.parts:
300         if part.name == attachment['source_node']:
301            source = part
302         elif part.name == attachment['destination_node']:
303            dest = part
304      source.attach(attachment['source_attachment'], dest, attachment['destination_attachment'])
305
306   # Make all necessary part connections
307   for connection in json_dict['connections']:
308      for part in assembly.parts:
309         if part.name == connection['source_node']:
310            source = part
311         elif part.name == connection['destination_node']:
312            dest = part
313      source.connect(connection['source_connection'], dest, connection['destination_connection'])
314
315   return assembly

Returns a new Assembly parsed from its JSON string representation in json_str.