symcad.core.Assembly

  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
 17from __future__ import annotations
 18from PyFreeCAD.FreeCAD import FreeCAD, Part
 19from .Coordinate import Coordinate
 20from .SymPart import SymPart
 21from .CAD import CadGeneral
 22from typing import Any, Dict, List, Literal
 23from typing import Optional, Set, Tuple, Union
 24from collections import defaultdict
 25from pathlib import Path
 26from sympy import Expr
 27
 28def _isfloat(num: Any) -> bool:
 29   """Private helper function to test if a value is float-convertible."""
 30   try:
 31      float(num)
 32      return True
 33   except Exception:
 34      return False
 35
 36
 37class Assembly(object):
 38   """Class representing an assembly of individual `SymPart` parts.
 39
 40   Parts can be added to an assembly using the `add_part()` method and may include
 41   placements or orientations that are symbolic; however, an assembly cannot be exported to a
 42   CAD model until all parameters have been set to concrete values, either directly within
 43   each assembled part, or by passing a dictionary of `key: value` pairs to the `export`
 44   method for each symbolic parameter within the model.
 45   """
 46
 47   # Public attributes ----------------------------------------------------------------------------
 48
 49   name: str
 50   """Unique identifying name for the `Assembly`."""
 51
 52   parts: List[SymPart]
 53   """List of `SymPart` parts within the assembly."""
 54
 55   collections: Dict[str, List[str]]
 56   """Dictionary of collections of `SymPart` parts that can be treated as unique assemblies."""
 57
 58
 59   # Constructor ----------------------------------------------------------------------------------
 60
 61   def __init__(self, assembly_name: str) -> None:
 62      """Initializes an `Assembly` object with the specified `assembly_name`."""
 63      self.name = assembly_name
 64      self.parts = []
 65      self.collections = defaultdict(list)
 66
 67
 68   # Built-in method implementations --------------------------------------------------------------
 69
 70   def __eq__(self, other: Assembly) -> bool:
 71      these_parts = {part.name: part for part in self.parts}
 72      those_parts = {part.name: part for part in other.parts}
 73      return self.name == other.name and these_parts == those_parts and \
 74             self.collections == other.collections
 75
 76
 77   # Private helper methods -----------------------------------------------------------------------
 78
 79   def _make_concrete(self, params: Dict[str, float]) -> None:
 80      """Concretizes as many symbolic parameters as possible given the `key: value` pairs
 81      in `params`."""
 82      for part in self.parts:
 83         if part.static_placement is None:
 84            part.static_placement = Coordinate(part.name + '_placement')
 85         if part.static_origin is None:
 86            part.static_origin = Coordinate(part.name + '_origin')
 87         for point in part.attachment_points:
 88            for key, val in [(k, v) for k, v in point.__dict__.items() if k != 'name']:
 89               if isinstance(val, Expr):
 90                  val = val.subs(list(params.items()))
 91                  setattr(point, key, val)
 92               if not _isfloat(val) and str(val) in params:
 93                  setattr(point, key, params[str(val)])
 94         for point in part.connection_ports:
 95            for key, val in [(k, v) for k, v in point.__dict__.items() if k != 'name']:
 96               if isinstance(val, Expr):
 97                  val = val.subs(list(params.items()))
 98                  setattr(point, key, val)
 99               if not _isfloat(val) and str(val) in params:
100                  setattr(point, key, params[str(val)])
101         for key, val in [(k, v) for k, v in part.geometry.__dict__.items() if k != 'name']:
102            if isinstance(val, Expr):
103               val = val.subs(list(params.items()))
104               setattr(part.geometry, key, val)
105            if not _isfloat(val) and str(val) in params:
106               setattr(part.geometry, key, params[str(val)])
107         for key, val in [(k, v) for k, v in part.orientation.__dict__.items() if k != 'name']:
108            if isinstance(val, Expr):
109               val = val.subs(list(params.items()))
110               setattr(part.orientation, key, val)
111            if not _isfloat(val) and str(val) in params:
112               setattr(part.orientation, key, params[str(val)])
113         for key, val in \
114             [(k, v) for k, v in part.static_origin.__dict__.items() if k != 'name']:
115            if isinstance(val, Expr):
116               val = val.subs(list(params.items()))
117               setattr(part.static_origin, key, val)
118            if not _isfloat(val) and str(val) in params:
119               setattr(part.static_origin, key, params[str(val)])
120         for key, val in \
121             [(k, v) for k, v in part.static_placement.__dict__.items() if k != 'name']:
122            if isinstance(val, Expr):
123               val = val.subs(list(params.items()))
124               setattr(part.static_placement, key, val)
125            if not _isfloat(val) and str(val) in params:
126               setattr(part.static_placement, key, params[str(val)])
127
128
129   @staticmethod
130   def _verify_fully_concrete(part: SymPart, raise_error_if_symbolic: bool) -> Set[str]:
131      """Ensures that the placement, origin, geometry, and orientation of the specified part
132      all have concrete values."""
133      free_parameters = set()
134      for key, val in part.static_origin.__dict__.items():
135         if key != 'name' and not _isfloat(val):
136            free_parameters.update([str(symbol) for symbol in val.free_symbols])
137      for key, val in part.static_placement.__dict__.items():
138         if key != 'name' and not _isfloat(val):
139            free_parameters.update([str(symbol) for symbol in val.free_symbols])
140      for key, val in part.orientation.__dict__.items():
141         if key != 'name' and not _isfloat(val):
142            free_parameters.update([str(symbol) for symbol in val.free_symbols])
143      for key, val in part.geometry.__dict__.items():
144         if key != 'name' and not _isfloat(val):
145            free_parameters.update([str(symbol) for symbol in val.free_symbols])
146      if free_parameters and raise_error_if_symbolic:
147         raise RuntimeError('Symbolic parameters still remain in the assembly: {}'
148                            .format(free_parameters))
149      return free_parameters
150
151
152   def _collect_unique_assemblies(self) -> List[List[SymPart]]:
153      """Creates a collection of unique assemblies by identifying all parts which rigidly attach
154      to form a single contiguous sub-assembly."""
155      assemblies = []
156      remaining_parts = [part.name for part in self.parts]
157      for part in self.parts:
158         if part.name in remaining_parts:
159            assembly = []
160            self._collect_unique_assembly(assembly, part, remaining_parts)
161            assemblies.append(assembly)
162      return assemblies
163
164
165   def _collect_unique_assembly(self, assembly: List[SymPart],
166                                      root_part: SymPart,
167                                      remaining_parts: List[str]) -> None:
168      """Recursively adds rigidly attached parts to the current unique assembly of parts."""
169      if root_part.name in remaining_parts:
170         assembly.append(root_part)
171         remaining_parts.remove(root_part.name)
172         for attachment_name in root_part.attachments.values():
173            attached_part_name = attachment_name.split('#')[0]
174            attached_part = [part for part in self.parts if part.name == attached_part_name]
175            if not attached_part:
176               raise RuntimeError('A SymPart attachment ({}) to "{}" is not present in the '
177                                    'current assembly'.format(attached_part_name, root_part.name))
178            self._collect_unique_assembly(assembly, attached_part[0], remaining_parts)
179
180
181   @staticmethod
182   def _find_best_root_part(assembly: List[SymPart]) -> SymPart:
183      """Searches for the part in the assembly with the greatest number of concrete placement
184      parameters."""
185      best_part = None
186      most_concrete = -1
187      for part in assembly:
188         num_concrete = sum([1 for key, val in part.static_origin.__dict__.items()
189                             if key != 'name' and _isfloat(val)]) + \
190                        sum([1 for key, val in part.static_placement.__dict__.items()
191                             if key != 'name' and _isfloat(val)]) \
192                        if part.static_placement is not None else 0
193         if num_concrete > most_concrete:
194            most_concrete = num_concrete
195            best_part = part
196      for part in assembly:
197         if part.name != best_part.name:
198            part.static_origin = part.static_placement = None
199      return best_part
200
201
202   def _place_parts(self) -> None:
203      """Updates the global placement of all assembled parts based on their rigid attachments
204      to other parts.
205      """
206      for assembly in self._collect_unique_assemblies():
207         root_part = Assembly._find_best_root_part(assembly)
208         if root_part.static_placement is None:
209            root_part.set_placement(placement=(None, None, None), local_origin=(None, None, None))
210         self._solve_rigid_placements(None, root_part)
211
212
213   def _solve_rigid_placements(self, previous_part: Union[SymPart, None],
214                                     current_part: SymPart) -> None:
215      """Recursively updates the global placement of all parts rigidly attached to the
216      current part.
217
218      Parameters
219      ----------
220      previous_part : `Union[SymPart, None]`
221         The previous part in the assembly chain or `None` if this is the first part whose
222         attachments are being placed.
223      current_part : `SymPart`
224         The part whose attachments are being placed.
225      """
226
227      # Iterate through all attachments to the current part
228      for local_name, remote_name in current_part.attachments.items():
229
230         # Search for the remotely attached part
231         remote_part_name, remote_attachment_name = remote_name.split('#')
232         remote_part = [part for part in self.parts if part.name == remote_part_name]
233         if not remote_part:
234            raise RuntimeError('A SymPart attachment ({}) to "{}" is not present in the current '
235                               'assembly'.format(remote_part_name, current_part.name))
236         remote_part = remote_part[0]
237         remote_attachment_point = [point for point in remote_part.attachment_points
238                                    if point.name == remote_attachment_name]
239         if not remote_attachment_point:
240            raise RuntimeError('The remote attachment point "{}" does not exist on the remote '
241                               'part "{}"'.format(remote_attachment_name, remote_part.name))
242
243         # Calculate the placement of the remotely attached part if not already placed
244         for local_attachment_point in current_part.attachment_points:
245            if local_attachment_point.name == local_name and \
246               not (previous_part and previous_part.name == remote_part_name):
247
248               # Compute the center of placement of the attachment in the global coordinate space
249               current_origin = current_part.static_origin
250               current_placement = current_part.static_placement
251               center_of_placement = Coordinate('Placement',
252                     x = current_placement.x + ((local_attachment_point.x - current_origin.x)
253                                                * current_part.unoriented_length),
254                     y = current_placement.y + ((local_attachment_point.y - current_origin.y)
255                                                * current_part.unoriented_width),
256                     z = current_placement.z + ((local_attachment_point.z - current_origin.z)
257                                                * current_part.unoriented_height))
258               rotated_x, rotated_y, rotated_z = \
259                  current_part.orientation.rotate_point(current_placement.as_tuple(),
260                                                        center_of_placement.as_tuple())
261
262               # Update the placement of the attached part and continue solving
263               if remote_part.static_placement is None:
264                  remote_part.static_origin = remote_attachment_point[0].clone()
265                  remote_part.static_placement = Coordinate(remote_part.name + '_placement',
266                                                            x=rotated_x, y=rotated_y, z=rotated_z)
267                  self._solve_rigid_placements(current_part, remote_part)
268               else:
269                  # TODO: Something here to add an additional constraint for solving for unknowns
270                  pass
271
272
273   # Public methods -------------------------------------------------------------------------------
274
275   def clone(self) -> Assembly:
276      """Returns an exact clone of this `Assembly` instance."""
277      cloned = Assembly(self.name)
278      for part in self.parts:
279         cloned.parts.append(part.clone())
280      for collection_name, collection in self.collections.items():
281         cloned.collections[collection_name] = collection.copy()
282      return cloned
283
284
285   def add_part(self, shape: SymPart, include_in_collections: List[str] = []) -> None:
286      """Adds a `SymPart` to the current assembly.
287
288      Every part within an assembly must have a unique name or this method will fail with a
289      `KeyError`.
290
291      Parameters
292      ----------
293      shape : `SymPart`
294         Part to add to the assembly.
295      include_in_collections : `List[str]`, optional
296         List of collections to which to add the part.
297
298      Raises
299      ------
300      `KeyError`
301         If a part within the assembly contains the same name as the part being added.
302      """
303      for part in self.parts:
304         if part.name == shape.name:
305            raise KeyError('A part with the name "{}" already exists in this assembly'
306                           .format(shape.name))
307      self.parts.append(shape)
308      for collection in include_in_collections:
309         self.collections[collection].append(shape.name)
310
311
312   def remove_part_from_collection(self, shape: SymPart, collection: str) -> None:
313      """Removes a `SymPart` from the specified `collection` in the current assembly.
314
315      Parameters
316      ----------
317      shape : `SymPart`
318         Part to remove from a collection.
319      collection : `str`
320         Name of the collection from which to remove the part.
321
322      Raises
323      ------
324      `ValueError`
325         If the part does not exist within the specified collection.
326      """
327      self.collections[collection].remove(shape.name)
328
329
330   def get_free_parameters(self) -> List[str]:
331      """Returns a list of all free parameters present inside the assembly."""
332      free_parameters = set()
333      assembly = self.clone()
334      assembly._place_parts()
335      for part in assembly.parts:
336         free_parameters.update(assembly._verify_fully_concrete(part, False))
337      return sorted(free_parameters)
338
339
340   def get_valid_states(self) -> List[str]:
341      """Returns a list of all possible geometric states for which the assembly can be
342      configured."""
343      valid_states = set()
344      for part in self.parts:
345         valid_states.update(part.get_valid_states())
346      return list(valid_states)
347
348
349   def set_state(self, state_names: Union[List[str], None]) -> None:
350      """Sets the geometric configuration of the assembly according to the indicated `state_names`.
351
352      If a part within the assembly does not recognize a state listed in the `state_names`
353      parameter, the part will simply ignore that state. Note that `state_names` may contain any
354      number of states for which to configure the assembly.
355
356      Parameters
357      ----------
358      state_names : `Union[List[str], None]`
359         List of geometric states for which all parts in the assembly should be configured. If
360         set to `None`, all parts will be configured in their default state.
361      """
362      for part in self.parts:
363         part.set_state(state_names)
364
365
366   def make_concrete(self, params: Optional[Dict[str, float]] = None) -> Assembly:
367      """
368      Creates a copy of the current `Assembly` with all free parameters set to their concrete
369      values as specified in the `params` parameter.
370
371      Parameters
372      ----------
373      params : `Dict[str, float]`, optional, default=None
374         Dictionary of free variables along with their desired concrete values.
375
376      Returns
377      -------
378      `Assembly`
379         A copy of the current Assembly containing as many concrete parameters and
380         placements as possible.
381      """
382      concrete_assembly = self.clone()
383      concrete_assembly._make_concrete({} if params is None else params)
384      concrete_assembly._place_parts()
385      return concrete_assembly
386
387
388   def export(self, file_save_path: str,
389                    model_type: Literal['freecad', 'step', 'stl'],
390                    create_displacement_model: Optional[bool] = False) -> None:
391      """Exports the current assembly as a CAD file.
392
393      Note that all parameters in the assembly must be concrete with no free variables remaining.
394      This can be achieved by first calling `make_concrete(params)` on the assembly object and
395      then calling `export()` on the resulting concrete assembly.
396
397      If any free parameter is missing a corresponding concrete value in the `params`
398      dictionary, this method will raise a `RuntimeError`.
399
400      Parameters
401      ----------
402      file_save_path : `str`
403         Relative or absolute path of the desired output file.
404      model_type : {'freecad', 'step', 'stl'}
405         Desired format of the exported CAD model.
406      create_displacement_model : `bool`, optional, default=False
407         Whether to create a model representing total environmental displacement.
408
409      Raises
410      ------
411      `RuntimeError`
412         If a free parameter within the assembly does not contain a corresponding concrete value.
413      """
414
415      # Create any necessary path directories
416      file_path = Path(file_save_path).absolute().resolve()
417      if not file_path.parent.exists():
418         file_path.parent.mkdir()
419
420      # Create a new assembly document and add all concrete CAD parts to it
421      assembly = self.clone()
422      assembly._place_parts()
423      doc = FreeCAD.newDocument(self.name)
424      for part in assembly.parts:
425         Assembly._verify_fully_concrete(part, True)
426         part.__cad__.add_to_assembly(part.name,
427                                      doc,
428                                      part.geometry.__dict__,
429                                      part.static_origin.as_tuple(),
430                                      part.static_placement.as_tuple(),
431                                      part.orientation.as_tuple(),
432                                      create_displacement_model)
433
434      # Recompute and create the requested version of the model
435      doc.recompute()
436      CadGeneral.save_assembly(file_path, model_type, doc)
437      FreeCAD.closeDocument(doc.Name)
438
439
440   def get_aggregate_model(self, create_displacement_model: Optional[bool] = False) -> Part.Solid:
441      """Retrieves the current assembly as a standalone CAD model.
442
443      Note that all parameters in the assembly must be concrete with no free variables remaining.
444      This can be achieved by first calling `make_concrete(params)` on the assembly object and
445      then calling `get_aggregate_model()` on the resulting concrete assembly.
446
447      If any free parameter is missing a corresponding concrete value in the `params`
448      dictionary, this method will raise a `RuntimeError`.
449
450      Parameters
451      ----------
452      create_displacement_model : `bool`, optional, default=False
453         Whether to create a model representing total environmental displacement.
454
455      Raises
456      ------
457      `RuntimeError`
458         If a free parameter within the assembly does not contain a corresponding concrete value.
459      """
460
461      # Create a new assembly document and add all concrete CAD parts to it
462      assembly = self.clone()
463      assembly._place_parts()
464      doc = FreeCAD.newDocument(self.name)
465      for part in assembly.parts:
466         Assembly._verify_fully_concrete(part, True)
467         part.__cad__.add_to_assembly(part.name,
468                                      doc,
469                                      part.geometry.__dict__,
470                                      part.static_origin.as_tuple(),
471                                      part.static_placement.as_tuple(),
472                                      part.orientation.as_tuple(),
473                                      create_displacement_model)
474
475      # Recompute and create the requested version of the model
476      doc.recompute()
477      model = Part.makeCompound([obj.Shape for obj in doc.Objects])
478      FreeCAD.closeDocument(doc.Name)
479      return model
480
481
482   def check_interferences(self) -> List[Tuple[str, str]]:
483      """Checks whether any of the parts contained within the Assembly interfere with any
484      other contained parts.
485
486      Returns
487      -------
488      `List[Tuple[str, str]]`
489         A list of tuples containing parts that interfere with one another.
490      """
491
492      # Create an assembly document and iterate through all CAD parts
493      assembly = self.clone()
494      assembly._place_parts()
495      doc = FreeCAD.newDocument(self.name)
496      for part in assembly.parts:
497
498         # Ensure that the part is fully concrete, and add it to the current assembly
499         Assembly._verify_fully_concrete(part, True)
500         part.__cad__.add_to_assembly(part.name,
501                                      doc,
502                                      part.geometry.__dict__,
503                                      part.static_origin.as_tuple(),
504                                      part.static_placement.as_tuple(),
505                                      part.orientation.as_tuple(),
506                                      False)
507
508      # Recompute and check for interferences in the resulting model
509      doc.recompute()
510      interferences = CadGeneral.retrieve_interferences(doc)
511      FreeCAD.closeDocument(doc.Name)
512      return interferences
513
514
515   def get_cad_physical_properties(self,
516                                   of_collections: Optional[List[str]] = None) -> Dict[str, float]:
517      """Returns all physical properties of the Assembly as reported by the underlying CAD model.
518
519      Parameters
520      ----------
521      of_collections : `List[str]`, optional
522         List of part collections to include in the physical property retrieval results.
523
524      Returns
525      -------
526      `Dict[str, float]`
527         A dictionary containing all physical properties of the underlying CAD model.
528      """
529
530      # Create an assembly document and iterate through all CAD parts
531      material_densities = {}
532      assembly = self.clone()
533      assembly._place_parts()
534      doc = FreeCAD.newDocument(self.name)
535      displacement_doc = FreeCAD.newDocument(self.name + '_displacement')
536      valid_parts = [part.name for part in self.parts] if of_collections is None else \
537                    [part for collection_name, parts in self.collections.items()
538                     if collection_name in of_collections for part in parts]
539      for part in assembly.parts:
540
541         # Ensure that the part is valid and fully concrete, and add it to the current assembly
542         if part.name in valid_parts:
543            Assembly._verify_fully_concrete(part, True)
544            material_densities[part.name] = part.material_density
545            part.__cad__.add_to_assembly(part.name,
546                                       doc,
547                                       part.geometry.__dict__,
548                                       part.static_origin.as_tuple(),
549                                       part.static_placement.as_tuple(),
550                                       part.orientation.as_tuple(),
551                                       False)
552            if part.is_exposed:
553               part.__cad__.add_to_assembly(part.name,
554                                          displacement_doc,
555                                          part.geometry.__dict__,
556                                          part.static_origin.as_tuple(),
557                                          part.static_placement.as_tuple(),
558                                          part.orientation.as_tuple(),
559                                          True)
560
561      # Recompute and calculate the physical properties of the resulting model
562      doc.recompute()
563      displacement_doc.recompute()
564      physical_properties = CadGeneral.fetch_assembly_physical_properties(doc,
565                                                                          displacement_doc,
566                                                                          material_densities)
567      FreeCAD.closeDocument(doc.Name)
568      FreeCAD.closeDocument(displacement_doc.Name)
569      return physical_properties
570
571
572   def save(self, file_save_path: str) -> None:
573      """Saves the current assembly as a JSON graph file.
574
575      Parameters
576      ----------
577      file_save_path : `str`
578         Relative or absolute path of the desired output file.
579      """
580
581      # Create any necessary path directories
582      file_path = Path(file_save_path).absolute().resolve()
583      if not file_path.parent.exists():
584         file_path.parent.mkdir()
585
586      # Export the assembly to a JSON string and store as a file
587      from .GraphAPI import export_to_json
588      json_out = export_to_json(self)
589      file_path.write_text(json_out)
590
591
592   @staticmethod
593   def load(file_name: str) -> Assembly:
594      """Loads an assembly from the given JSON graph file.
595
596      Parameters
597      ----------
598      file_name: `str`
599         Relative or absolute path of the file containing a JSON-based Assembly.
600
601      Returns
602      -------
603      `Assembly`
604         The deserialized Assembly object represented by the specified file.
605      """
606      from .GraphAPI import import_from_json
607      file_path = Path(file_name).absolute().resolve()
608      if not file_path.exists():
609         raise ValueError('The JSON graph file at "{}" does not exist'.format(str(file_path)))
610      return import_from_json(file_path.read_text())
611
612
613   # Cumulative properties of the assembly --------------------------------------------------------
614
615   def mass(self, of_collections: Optional[List[str]] = None) -> float:
616      """Mass (in `kg`) of the parts in the specified collections or of the cumulative
617      Assembly (read-only)."""
618      valid_parts = [part.name for part in self.parts] if of_collections is None else \
619                    [part for collection_name, parts in self.collections.items()
620                     if collection_name in of_collections for part in parts]
621      return sum([part.mass for part in self.parts if part.name in valid_parts])
622
623   def material_volume(self, of_collections: Optional[List[str]] = None) -> float:
624      """Material volume (in `m^3`) of the parts in the specified collections or of the
625      cumulative Assembly (read-only)."""
626      valid_parts = [part.name for part in self.parts] if of_collections is None else \
627                    [part for collection_name, parts in self.collections.items()
628                     if collection_name in of_collections for part in parts]
629      return sum([part.material_volume for part in self.parts if part.name in valid_parts])
630
631   def displaced_volume(self, of_collections: Optional[List[str]] = None) -> float:
632      """Displaced volume (in `m^3`) of the parts in the specified collections or of the
633      cumulative Assembly (read-only)."""
634      valid_parts = [part.name for part in self.parts] if of_collections is None else \
635                    [part for collection_name, parts in self.collections.items()
636                     if collection_name in of_collections for part in parts]
637      return sum([part.displaced_volume for part in self.parts if part.is_exposed and
638                                                                  part.name in valid_parts])
639
640   def surface_area(self, of_collections: Optional[List[str]] = None) -> float:
641      """Surface/wetted area (in `m^2`) of the parts in the specified collections or of the
642      cumulative Assembly (read-only)."""
643      valid_parts = [part.name for part in self.parts] if of_collections is None else \
644                    [part for collection_name, parts in self.collections.items()
645                     if collection_name in of_collections for part in parts]
646      return sum([part.surface_area for part in self.parts if part.is_exposed and
647                                                              part.name in valid_parts])
648
649   def center_of_gravity(self, of_collections: Optional[List[str]] = None) -> Coordinate:
650      """Center of gravity (in `m`) of the parts in the specified collections or of the
651      cumulative Assembly (read-only)."""
652      assembly = self.clone()
653      assembly._place_parts()
654      valid_parts = [part.name for part in self.parts] if of_collections is None else \
655                    [part for collection_name, parts in self.collections.items()
656                     if collection_name in of_collections for part in parts]
657      mass, center_of_gravity_x, center_of_gravity_y, center_of_gravity_z = (0.0, 0.0, 0.0, 0.0)
658      for part in assembly.parts:
659         if part.name in valid_parts:
660            part_mass = part.mass
661            part_placement = part.static_placement
662            part_center_of_gravity = part.oriented_center_of_gravity
663            center_of_gravity_x += ((part_placement.x + part_center_of_gravity[0]) * part_mass)
664            center_of_gravity_y += ((part_placement.y + part_center_of_gravity[1]) * part_mass)
665            center_of_gravity_z += ((part_placement.z + part_center_of_gravity[2]) * part_mass)
666            mass += part_mass
667      return Coordinate(assembly.name + '_center_of_gravity',
668                        x=center_of_gravity_x / mass,
669                        y=center_of_gravity_y / mass,
670                        z=center_of_gravity_z / mass)
671
672   def center_of_buoyancy(self, of_collections: Optional[List[str]] = None) -> Coordinate:
673      """Center of buoyancy (in `m`) of the parts in the specified collections or of the
674      cumulative Assembly (read-only)."""
675      assembly = self.clone()
676      assembly._place_parts()
677      displaced_volume = 0.0
678      valid_parts = [part.name for part in self.parts] if of_collections is None else \
679                    [part for collection_name, parts in self.collections.items()
680                     if collection_name in of_collections for part in parts]
681      center_of_buoyancy_x, center_of_buoyancy_y, center_of_buoyancy_z = (0.0, 0.0, 0.0)
682      for part in assembly.parts:
683         if part.is_exposed and part.name in valid_parts:
684            part_placement = part.static_placement
685            part_displaced_volume = part.displaced_volume
686            part_center_of_buoyancy = part.oriented_center_of_buoyancy
687            center_of_buoyancy_x += ((part_placement.x + part_center_of_buoyancy[0])
688                                    * part_displaced_volume)
689            center_of_buoyancy_y += ((part_placement.y + part_center_of_buoyancy[1])
690                                    * part_displaced_volume)
691            center_of_buoyancy_z += ((part_placement.z + part_center_of_buoyancy[2])
692                                    * part_displaced_volume)
693            displaced_volume += part_displaced_volume
694      return Coordinate(assembly.name + '_center_of_buoyancy',
695                        x=center_of_buoyancy_x / displaced_volume,
696                        y=center_of_buoyancy_y / displaced_volume,
697                        z=center_of_buoyancy_z / displaced_volume)
698
699   def length(self, of_collections: Optional[List[str]] = None) -> float:
700      """X-axis length (in `m`) of the bounding box of the parts in the specified collections
701      or of the cumulative Assembly (read-only)."""
702      assembly = self.clone()
703      assembly._place_parts()
704      valid_parts = [part.name for part in self.parts] if of_collections is None else \
705                    [part for collection_name, parts in self.collections.items()
706                     if collection_name in of_collections for part in parts]
707      for part in assembly.parts:
708         if part.name in valid_parts:
709            pass  # TODO: Implement this
710      return 0
711
712   def width(self, of_collections: Optional[List[str]] = None) -> float:
713      """Y-axis width (in `m`) of the bounding box of the parts in the specified collections
714      or of the cumulative Assembly (read-only)."""
715      assembly = self.clone()
716      assembly._place_parts()
717      valid_parts = [part.name for part in self.parts] if of_collections is None else \
718                    [part for collection_name, parts in self.collections.items()
719                     if collection_name in of_collections for part in parts]
720      for part in assembly.parts:
721         if part.name in valid_parts:
722            pass  # TODO: Implement this
723      return 0
724
725   def height(self, of_collections: Optional[List[str]] = None) -> float:
726      """Z-axis height (in `m`) of the bounding box of the parts in the specified collections
727      or of the cumulative Assembly (read-only)."""
728      assembly = self.clone()
729      assembly._place_parts()
730      valid_parts = [part.name for part in self.parts] if of_collections is None else \
731                    [part for collection_name, parts in self.collections.items()
732                     if collection_name in of_collections for part in parts]
733      for part in assembly.parts:
734         if part.name in valid_parts:
735            pass  # TODO: Implement this
736         #placement_center = \
737         #   part._reorient_coordinate(part.static_origin.x * part.unoriented_length,
738         #                             part.static_origin.y * part.unoriented_width,
739         #                             part.static_origin.z * part.unoriented_height)
740         #placement_z = part.static_placement.z - placement_center[2]
741         #minimum_extents_list.append(placement_z)
742         #maximum_extents_list.append(placement_z + part.oriented_height)
743      return 0
class Assembly:
 38class Assembly(object):
 39   """Class representing an assembly of individual `SymPart` parts.
 40
 41   Parts can be added to an assembly using the `add_part()` method and may include
 42   placements or orientations that are symbolic; however, an assembly cannot be exported to a
 43   CAD model until all parameters have been set to concrete values, either directly within
 44   each assembled part, or by passing a dictionary of `key: value` pairs to the `export`
 45   method for each symbolic parameter within the model.
 46   """
 47
 48   # Public attributes ----------------------------------------------------------------------------
 49
 50   name: str
 51   """Unique identifying name for the `Assembly`."""
 52
 53   parts: List[SymPart]
 54   """List of `SymPart` parts within the assembly."""
 55
 56   collections: Dict[str, List[str]]
 57   """Dictionary of collections of `SymPart` parts that can be treated as unique assemblies."""
 58
 59
 60   # Constructor ----------------------------------------------------------------------------------
 61
 62   def __init__(self, assembly_name: str) -> None:
 63      """Initializes an `Assembly` object with the specified `assembly_name`."""
 64      self.name = assembly_name
 65      self.parts = []
 66      self.collections = defaultdict(list)
 67
 68
 69   # Built-in method implementations --------------------------------------------------------------
 70
 71   def __eq__(self, other: Assembly) -> bool:
 72      these_parts = {part.name: part for part in self.parts}
 73      those_parts = {part.name: part for part in other.parts}
 74      return self.name == other.name and these_parts == those_parts and \
 75             self.collections == other.collections
 76
 77
 78   # Private helper methods -----------------------------------------------------------------------
 79
 80   def _make_concrete(self, params: Dict[str, float]) -> None:
 81      """Concretizes as many symbolic parameters as possible given the `key: value` pairs
 82      in `params`."""
 83      for part in self.parts:
 84         if part.static_placement is None:
 85            part.static_placement = Coordinate(part.name + '_placement')
 86         if part.static_origin is None:
 87            part.static_origin = Coordinate(part.name + '_origin')
 88         for point in part.attachment_points:
 89            for key, val in [(k, v) for k, v in point.__dict__.items() if k != 'name']:
 90               if isinstance(val, Expr):
 91                  val = val.subs(list(params.items()))
 92                  setattr(point, key, val)
 93               if not _isfloat(val) and str(val) in params:
 94                  setattr(point, key, params[str(val)])
 95         for point in part.connection_ports:
 96            for key, val in [(k, v) for k, v in point.__dict__.items() if k != 'name']:
 97               if isinstance(val, Expr):
 98                  val = val.subs(list(params.items()))
 99                  setattr(point, key, val)
100               if not _isfloat(val) and str(val) in params:
101                  setattr(point, key, params[str(val)])
102         for key, val in [(k, v) for k, v in part.geometry.__dict__.items() if k != 'name']:
103            if isinstance(val, Expr):
104               val = val.subs(list(params.items()))
105               setattr(part.geometry, key, val)
106            if not _isfloat(val) and str(val) in params:
107               setattr(part.geometry, key, params[str(val)])
108         for key, val in [(k, v) for k, v in part.orientation.__dict__.items() if k != 'name']:
109            if isinstance(val, Expr):
110               val = val.subs(list(params.items()))
111               setattr(part.orientation, key, val)
112            if not _isfloat(val) and str(val) in params:
113               setattr(part.orientation, key, params[str(val)])
114         for key, val in \
115             [(k, v) for k, v in part.static_origin.__dict__.items() if k != 'name']:
116            if isinstance(val, Expr):
117               val = val.subs(list(params.items()))
118               setattr(part.static_origin, key, val)
119            if not _isfloat(val) and str(val) in params:
120               setattr(part.static_origin, key, params[str(val)])
121         for key, val in \
122             [(k, v) for k, v in part.static_placement.__dict__.items() if k != 'name']:
123            if isinstance(val, Expr):
124               val = val.subs(list(params.items()))
125               setattr(part.static_placement, key, val)
126            if not _isfloat(val) and str(val) in params:
127               setattr(part.static_placement, key, params[str(val)])
128
129
130   @staticmethod
131   def _verify_fully_concrete(part: SymPart, raise_error_if_symbolic: bool) -> Set[str]:
132      """Ensures that the placement, origin, geometry, and orientation of the specified part
133      all have concrete values."""
134      free_parameters = set()
135      for key, val in part.static_origin.__dict__.items():
136         if key != 'name' and not _isfloat(val):
137            free_parameters.update([str(symbol) for symbol in val.free_symbols])
138      for key, val in part.static_placement.__dict__.items():
139         if key != 'name' and not _isfloat(val):
140            free_parameters.update([str(symbol) for symbol in val.free_symbols])
141      for key, val in part.orientation.__dict__.items():
142         if key != 'name' and not _isfloat(val):
143            free_parameters.update([str(symbol) for symbol in val.free_symbols])
144      for key, val in part.geometry.__dict__.items():
145         if key != 'name' and not _isfloat(val):
146            free_parameters.update([str(symbol) for symbol in val.free_symbols])
147      if free_parameters and raise_error_if_symbolic:
148         raise RuntimeError('Symbolic parameters still remain in the assembly: {}'
149                            .format(free_parameters))
150      return free_parameters
151
152
153   def _collect_unique_assemblies(self) -> List[List[SymPart]]:
154      """Creates a collection of unique assemblies by identifying all parts which rigidly attach
155      to form a single contiguous sub-assembly."""
156      assemblies = []
157      remaining_parts = [part.name for part in self.parts]
158      for part in self.parts:
159         if part.name in remaining_parts:
160            assembly = []
161            self._collect_unique_assembly(assembly, part, remaining_parts)
162            assemblies.append(assembly)
163      return assemblies
164
165
166   def _collect_unique_assembly(self, assembly: List[SymPart],
167                                      root_part: SymPart,
168                                      remaining_parts: List[str]) -> None:
169      """Recursively adds rigidly attached parts to the current unique assembly of parts."""
170      if root_part.name in remaining_parts:
171         assembly.append(root_part)
172         remaining_parts.remove(root_part.name)
173         for attachment_name in root_part.attachments.values():
174            attached_part_name = attachment_name.split('#')[0]
175            attached_part = [part for part in self.parts if part.name == attached_part_name]
176            if not attached_part:
177               raise RuntimeError('A SymPart attachment ({}) to "{}" is not present in the '
178                                    'current assembly'.format(attached_part_name, root_part.name))
179            self._collect_unique_assembly(assembly, attached_part[0], remaining_parts)
180
181
182   @staticmethod
183   def _find_best_root_part(assembly: List[SymPart]) -> SymPart:
184      """Searches for the part in the assembly with the greatest number of concrete placement
185      parameters."""
186      best_part = None
187      most_concrete = -1
188      for part in assembly:
189         num_concrete = sum([1 for key, val in part.static_origin.__dict__.items()
190                             if key != 'name' and _isfloat(val)]) + \
191                        sum([1 for key, val in part.static_placement.__dict__.items()
192                             if key != 'name' and _isfloat(val)]) \
193                        if part.static_placement is not None else 0
194         if num_concrete > most_concrete:
195            most_concrete = num_concrete
196            best_part = part
197      for part in assembly:
198         if part.name != best_part.name:
199            part.static_origin = part.static_placement = None
200      return best_part
201
202
203   def _place_parts(self) -> None:
204      """Updates the global placement of all assembled parts based on their rigid attachments
205      to other parts.
206      """
207      for assembly in self._collect_unique_assemblies():
208         root_part = Assembly._find_best_root_part(assembly)
209         if root_part.static_placement is None:
210            root_part.set_placement(placement=(None, None, None), local_origin=(None, None, None))
211         self._solve_rigid_placements(None, root_part)
212
213
214   def _solve_rigid_placements(self, previous_part: Union[SymPart, None],
215                                     current_part: SymPart) -> None:
216      """Recursively updates the global placement of all parts rigidly attached to the
217      current part.
218
219      Parameters
220      ----------
221      previous_part : `Union[SymPart, None]`
222         The previous part in the assembly chain or `None` if this is the first part whose
223         attachments are being placed.
224      current_part : `SymPart`
225         The part whose attachments are being placed.
226      """
227
228      # Iterate through all attachments to the current part
229      for local_name, remote_name in current_part.attachments.items():
230
231         # Search for the remotely attached part
232         remote_part_name, remote_attachment_name = remote_name.split('#')
233         remote_part = [part for part in self.parts if part.name == remote_part_name]
234         if not remote_part:
235            raise RuntimeError('A SymPart attachment ({}) to "{}" is not present in the current '
236                               'assembly'.format(remote_part_name, current_part.name))
237         remote_part = remote_part[0]
238         remote_attachment_point = [point for point in remote_part.attachment_points
239                                    if point.name == remote_attachment_name]
240         if not remote_attachment_point:
241            raise RuntimeError('The remote attachment point "{}" does not exist on the remote '
242                               'part "{}"'.format(remote_attachment_name, remote_part.name))
243
244         # Calculate the placement of the remotely attached part if not already placed
245         for local_attachment_point in current_part.attachment_points:
246            if local_attachment_point.name == local_name and \
247               not (previous_part and previous_part.name == remote_part_name):
248
249               # Compute the center of placement of the attachment in the global coordinate space
250               current_origin = current_part.static_origin
251               current_placement = current_part.static_placement
252               center_of_placement = Coordinate('Placement',
253                     x = current_placement.x + ((local_attachment_point.x - current_origin.x)
254                                                * current_part.unoriented_length),
255                     y = current_placement.y + ((local_attachment_point.y - current_origin.y)
256                                                * current_part.unoriented_width),
257                     z = current_placement.z + ((local_attachment_point.z - current_origin.z)
258                                                * current_part.unoriented_height))
259               rotated_x, rotated_y, rotated_z = \
260                  current_part.orientation.rotate_point(current_placement.as_tuple(),
261                                                        center_of_placement.as_tuple())
262
263               # Update the placement of the attached part and continue solving
264               if remote_part.static_placement is None:
265                  remote_part.static_origin = remote_attachment_point[0].clone()
266                  remote_part.static_placement = Coordinate(remote_part.name + '_placement',
267                                                            x=rotated_x, y=rotated_y, z=rotated_z)
268                  self._solve_rigid_placements(current_part, remote_part)
269               else:
270                  # TODO: Something here to add an additional constraint for solving for unknowns
271                  pass
272
273
274   # Public methods -------------------------------------------------------------------------------
275
276   def clone(self) -> Assembly:
277      """Returns an exact clone of this `Assembly` instance."""
278      cloned = Assembly(self.name)
279      for part in self.parts:
280         cloned.parts.append(part.clone())
281      for collection_name, collection in self.collections.items():
282         cloned.collections[collection_name] = collection.copy()
283      return cloned
284
285
286   def add_part(self, shape: SymPart, include_in_collections: List[str] = []) -> None:
287      """Adds a `SymPart` to the current assembly.
288
289      Every part within an assembly must have a unique name or this method will fail with a
290      `KeyError`.
291
292      Parameters
293      ----------
294      shape : `SymPart`
295         Part to add to the assembly.
296      include_in_collections : `List[str]`, optional
297         List of collections to which to add the part.
298
299      Raises
300      ------
301      `KeyError`
302         If a part within the assembly contains the same name as the part being added.
303      """
304      for part in self.parts:
305         if part.name == shape.name:
306            raise KeyError('A part with the name "{}" already exists in this assembly'
307                           .format(shape.name))
308      self.parts.append(shape)
309      for collection in include_in_collections:
310         self.collections[collection].append(shape.name)
311
312
313   def remove_part_from_collection(self, shape: SymPart, collection: str) -> None:
314      """Removes a `SymPart` from the specified `collection` in the current assembly.
315
316      Parameters
317      ----------
318      shape : `SymPart`
319         Part to remove from a collection.
320      collection : `str`
321         Name of the collection from which to remove the part.
322
323      Raises
324      ------
325      `ValueError`
326         If the part does not exist within the specified collection.
327      """
328      self.collections[collection].remove(shape.name)
329
330
331   def get_free_parameters(self) -> List[str]:
332      """Returns a list of all free parameters present inside the assembly."""
333      free_parameters = set()
334      assembly = self.clone()
335      assembly._place_parts()
336      for part in assembly.parts:
337         free_parameters.update(assembly._verify_fully_concrete(part, False))
338      return sorted(free_parameters)
339
340
341   def get_valid_states(self) -> List[str]:
342      """Returns a list of all possible geometric states for which the assembly can be
343      configured."""
344      valid_states = set()
345      for part in self.parts:
346         valid_states.update(part.get_valid_states())
347      return list(valid_states)
348
349
350   def set_state(self, state_names: Union[List[str], None]) -> None:
351      """Sets the geometric configuration of the assembly according to the indicated `state_names`.
352
353      If a part within the assembly does not recognize a state listed in the `state_names`
354      parameter, the part will simply ignore that state. Note that `state_names` may contain any
355      number of states for which to configure the assembly.
356
357      Parameters
358      ----------
359      state_names : `Union[List[str], None]`
360         List of geometric states for which all parts in the assembly should be configured. If
361         set to `None`, all parts will be configured in their default state.
362      """
363      for part in self.parts:
364         part.set_state(state_names)
365
366
367   def make_concrete(self, params: Optional[Dict[str, float]] = None) -> Assembly:
368      """
369      Creates a copy of the current `Assembly` with all free parameters set to their concrete
370      values as specified in the `params` parameter.
371
372      Parameters
373      ----------
374      params : `Dict[str, float]`, optional, default=None
375         Dictionary of free variables along with their desired concrete values.
376
377      Returns
378      -------
379      `Assembly`
380         A copy of the current Assembly containing as many concrete parameters and
381         placements as possible.
382      """
383      concrete_assembly = self.clone()
384      concrete_assembly._make_concrete({} if params is None else params)
385      concrete_assembly._place_parts()
386      return concrete_assembly
387
388
389   def export(self, file_save_path: str,
390                    model_type: Literal['freecad', 'step', 'stl'],
391                    create_displacement_model: Optional[bool] = False) -> None:
392      """Exports the current assembly as a CAD file.
393
394      Note that all parameters in the assembly must be concrete with no free variables remaining.
395      This can be achieved by first calling `make_concrete(params)` on the assembly object and
396      then calling `export()` on the resulting concrete assembly.
397
398      If any free parameter is missing a corresponding concrete value in the `params`
399      dictionary, this method will raise a `RuntimeError`.
400
401      Parameters
402      ----------
403      file_save_path : `str`
404         Relative or absolute path of the desired output file.
405      model_type : {'freecad', 'step', 'stl'}
406         Desired format of the exported CAD model.
407      create_displacement_model : `bool`, optional, default=False
408         Whether to create a model representing total environmental displacement.
409
410      Raises
411      ------
412      `RuntimeError`
413         If a free parameter within the assembly does not contain a corresponding concrete value.
414      """
415
416      # Create any necessary path directories
417      file_path = Path(file_save_path).absolute().resolve()
418      if not file_path.parent.exists():
419         file_path.parent.mkdir()
420
421      # Create a new assembly document and add all concrete CAD parts to it
422      assembly = self.clone()
423      assembly._place_parts()
424      doc = FreeCAD.newDocument(self.name)
425      for part in assembly.parts:
426         Assembly._verify_fully_concrete(part, True)
427         part.__cad__.add_to_assembly(part.name,
428                                      doc,
429                                      part.geometry.__dict__,
430                                      part.static_origin.as_tuple(),
431                                      part.static_placement.as_tuple(),
432                                      part.orientation.as_tuple(),
433                                      create_displacement_model)
434
435      # Recompute and create the requested version of the model
436      doc.recompute()
437      CadGeneral.save_assembly(file_path, model_type, doc)
438      FreeCAD.closeDocument(doc.Name)
439
440
441   def get_aggregate_model(self, create_displacement_model: Optional[bool] = False) -> Part.Solid:
442      """Retrieves the current assembly as a standalone CAD model.
443
444      Note that all parameters in the assembly must be concrete with no free variables remaining.
445      This can be achieved by first calling `make_concrete(params)` on the assembly object and
446      then calling `get_aggregate_model()` on the resulting concrete assembly.
447
448      If any free parameter is missing a corresponding concrete value in the `params`
449      dictionary, this method will raise a `RuntimeError`.
450
451      Parameters
452      ----------
453      create_displacement_model : `bool`, optional, default=False
454         Whether to create a model representing total environmental displacement.
455
456      Raises
457      ------
458      `RuntimeError`
459         If a free parameter within the assembly does not contain a corresponding concrete value.
460      """
461
462      # Create a new assembly document and add all concrete CAD parts to it
463      assembly = self.clone()
464      assembly._place_parts()
465      doc = FreeCAD.newDocument(self.name)
466      for part in assembly.parts:
467         Assembly._verify_fully_concrete(part, True)
468         part.__cad__.add_to_assembly(part.name,
469                                      doc,
470                                      part.geometry.__dict__,
471                                      part.static_origin.as_tuple(),
472                                      part.static_placement.as_tuple(),
473                                      part.orientation.as_tuple(),
474                                      create_displacement_model)
475
476      # Recompute and create the requested version of the model
477      doc.recompute()
478      model = Part.makeCompound([obj.Shape for obj in doc.Objects])
479      FreeCAD.closeDocument(doc.Name)
480      return model
481
482
483   def check_interferences(self) -> List[Tuple[str, str]]:
484      """Checks whether any of the parts contained within the Assembly interfere with any
485      other contained parts.
486
487      Returns
488      -------
489      `List[Tuple[str, str]]`
490         A list of tuples containing parts that interfere with one another.
491      """
492
493      # Create an assembly document and iterate through all CAD parts
494      assembly = self.clone()
495      assembly._place_parts()
496      doc = FreeCAD.newDocument(self.name)
497      for part in assembly.parts:
498
499         # Ensure that the part is fully concrete, and add it to the current assembly
500         Assembly._verify_fully_concrete(part, True)
501         part.__cad__.add_to_assembly(part.name,
502                                      doc,
503                                      part.geometry.__dict__,
504                                      part.static_origin.as_tuple(),
505                                      part.static_placement.as_tuple(),
506                                      part.orientation.as_tuple(),
507                                      False)
508
509      # Recompute and check for interferences in the resulting model
510      doc.recompute()
511      interferences = CadGeneral.retrieve_interferences(doc)
512      FreeCAD.closeDocument(doc.Name)
513      return interferences
514
515
516   def get_cad_physical_properties(self,
517                                   of_collections: Optional[List[str]] = None) -> Dict[str, float]:
518      """Returns all physical properties of the Assembly as reported by the underlying CAD model.
519
520      Parameters
521      ----------
522      of_collections : `List[str]`, optional
523         List of part collections to include in the physical property retrieval results.
524
525      Returns
526      -------
527      `Dict[str, float]`
528         A dictionary containing all physical properties of the underlying CAD model.
529      """
530
531      # Create an assembly document and iterate through all CAD parts
532      material_densities = {}
533      assembly = self.clone()
534      assembly._place_parts()
535      doc = FreeCAD.newDocument(self.name)
536      displacement_doc = FreeCAD.newDocument(self.name + '_displacement')
537      valid_parts = [part.name for part in self.parts] if of_collections is None else \
538                    [part for collection_name, parts in self.collections.items()
539                     if collection_name in of_collections for part in parts]
540      for part in assembly.parts:
541
542         # Ensure that the part is valid and fully concrete, and add it to the current assembly
543         if part.name in valid_parts:
544            Assembly._verify_fully_concrete(part, True)
545            material_densities[part.name] = part.material_density
546            part.__cad__.add_to_assembly(part.name,
547                                       doc,
548                                       part.geometry.__dict__,
549                                       part.static_origin.as_tuple(),
550                                       part.static_placement.as_tuple(),
551                                       part.orientation.as_tuple(),
552                                       False)
553            if part.is_exposed:
554               part.__cad__.add_to_assembly(part.name,
555                                          displacement_doc,
556                                          part.geometry.__dict__,
557                                          part.static_origin.as_tuple(),
558                                          part.static_placement.as_tuple(),
559                                          part.orientation.as_tuple(),
560                                          True)
561
562      # Recompute and calculate the physical properties of the resulting model
563      doc.recompute()
564      displacement_doc.recompute()
565      physical_properties = CadGeneral.fetch_assembly_physical_properties(doc,
566                                                                          displacement_doc,
567                                                                          material_densities)
568      FreeCAD.closeDocument(doc.Name)
569      FreeCAD.closeDocument(displacement_doc.Name)
570      return physical_properties
571
572
573   def save(self, file_save_path: str) -> None:
574      """Saves the current assembly as a JSON graph file.
575
576      Parameters
577      ----------
578      file_save_path : `str`
579         Relative or absolute path of the desired output file.
580      """
581
582      # Create any necessary path directories
583      file_path = Path(file_save_path).absolute().resolve()
584      if not file_path.parent.exists():
585         file_path.parent.mkdir()
586
587      # Export the assembly to a JSON string and store as a file
588      from .GraphAPI import export_to_json
589      json_out = export_to_json(self)
590      file_path.write_text(json_out)
591
592
593   @staticmethod
594   def load(file_name: str) -> Assembly:
595      """Loads an assembly from the given JSON graph file.
596
597      Parameters
598      ----------
599      file_name: `str`
600         Relative or absolute path of the file containing a JSON-based Assembly.
601
602      Returns
603      -------
604      `Assembly`
605         The deserialized Assembly object represented by the specified file.
606      """
607      from .GraphAPI import import_from_json
608      file_path = Path(file_name).absolute().resolve()
609      if not file_path.exists():
610         raise ValueError('The JSON graph file at "{}" does not exist'.format(str(file_path)))
611      return import_from_json(file_path.read_text())
612
613
614   # Cumulative properties of the assembly --------------------------------------------------------
615
616   def mass(self, of_collections: Optional[List[str]] = None) -> float:
617      """Mass (in `kg`) of the parts in the specified collections or of the cumulative
618      Assembly (read-only)."""
619      valid_parts = [part.name for part in self.parts] if of_collections is None else \
620                    [part for collection_name, parts in self.collections.items()
621                     if collection_name in of_collections for part in parts]
622      return sum([part.mass for part in self.parts if part.name in valid_parts])
623
624   def material_volume(self, of_collections: Optional[List[str]] = None) -> float:
625      """Material volume (in `m^3`) of the parts in the specified collections or of the
626      cumulative Assembly (read-only)."""
627      valid_parts = [part.name for part in self.parts] if of_collections is None else \
628                    [part for collection_name, parts in self.collections.items()
629                     if collection_name in of_collections for part in parts]
630      return sum([part.material_volume for part in self.parts if part.name in valid_parts])
631
632   def displaced_volume(self, of_collections: Optional[List[str]] = None) -> float:
633      """Displaced volume (in `m^3`) of the parts in the specified collections or of the
634      cumulative Assembly (read-only)."""
635      valid_parts = [part.name for part in self.parts] if of_collections is None else \
636                    [part for collection_name, parts in self.collections.items()
637                     if collection_name in of_collections for part in parts]
638      return sum([part.displaced_volume for part in self.parts if part.is_exposed and
639                                                                  part.name in valid_parts])
640
641   def surface_area(self, of_collections: Optional[List[str]] = None) -> float:
642      """Surface/wetted area (in `m^2`) of the parts in the specified collections or of the
643      cumulative Assembly (read-only)."""
644      valid_parts = [part.name for part in self.parts] if of_collections is None else \
645                    [part for collection_name, parts in self.collections.items()
646                     if collection_name in of_collections for part in parts]
647      return sum([part.surface_area for part in self.parts if part.is_exposed and
648                                                              part.name in valid_parts])
649
650   def center_of_gravity(self, of_collections: Optional[List[str]] = None) -> Coordinate:
651      """Center of gravity (in `m`) of the parts in the specified collections or of the
652      cumulative Assembly (read-only)."""
653      assembly = self.clone()
654      assembly._place_parts()
655      valid_parts = [part.name for part in self.parts] if of_collections is None else \
656                    [part for collection_name, parts in self.collections.items()
657                     if collection_name in of_collections for part in parts]
658      mass, center_of_gravity_x, center_of_gravity_y, center_of_gravity_z = (0.0, 0.0, 0.0, 0.0)
659      for part in assembly.parts:
660         if part.name in valid_parts:
661            part_mass = part.mass
662            part_placement = part.static_placement
663            part_center_of_gravity = part.oriented_center_of_gravity
664            center_of_gravity_x += ((part_placement.x + part_center_of_gravity[0]) * part_mass)
665            center_of_gravity_y += ((part_placement.y + part_center_of_gravity[1]) * part_mass)
666            center_of_gravity_z += ((part_placement.z + part_center_of_gravity[2]) * part_mass)
667            mass += part_mass
668      return Coordinate(assembly.name + '_center_of_gravity',
669                        x=center_of_gravity_x / mass,
670                        y=center_of_gravity_y / mass,
671                        z=center_of_gravity_z / mass)
672
673   def center_of_buoyancy(self, of_collections: Optional[List[str]] = None) -> Coordinate:
674      """Center of buoyancy (in `m`) of the parts in the specified collections or of the
675      cumulative Assembly (read-only)."""
676      assembly = self.clone()
677      assembly._place_parts()
678      displaced_volume = 0.0
679      valid_parts = [part.name for part in self.parts] if of_collections is None else \
680                    [part for collection_name, parts in self.collections.items()
681                     if collection_name in of_collections for part in parts]
682      center_of_buoyancy_x, center_of_buoyancy_y, center_of_buoyancy_z = (0.0, 0.0, 0.0)
683      for part in assembly.parts:
684         if part.is_exposed and part.name in valid_parts:
685            part_placement = part.static_placement
686            part_displaced_volume = part.displaced_volume
687            part_center_of_buoyancy = part.oriented_center_of_buoyancy
688            center_of_buoyancy_x += ((part_placement.x + part_center_of_buoyancy[0])
689                                    * part_displaced_volume)
690            center_of_buoyancy_y += ((part_placement.y + part_center_of_buoyancy[1])
691                                    * part_displaced_volume)
692            center_of_buoyancy_z += ((part_placement.z + part_center_of_buoyancy[2])
693                                    * part_displaced_volume)
694            displaced_volume += part_displaced_volume
695      return Coordinate(assembly.name + '_center_of_buoyancy',
696                        x=center_of_buoyancy_x / displaced_volume,
697                        y=center_of_buoyancy_y / displaced_volume,
698                        z=center_of_buoyancy_z / displaced_volume)
699
700   def length(self, of_collections: Optional[List[str]] = None) -> float:
701      """X-axis length (in `m`) of the bounding box of the parts in the specified collections
702      or of the cumulative Assembly (read-only)."""
703      assembly = self.clone()
704      assembly._place_parts()
705      valid_parts = [part.name for part in self.parts] if of_collections is None else \
706                    [part for collection_name, parts in self.collections.items()
707                     if collection_name in of_collections for part in parts]
708      for part in assembly.parts:
709         if part.name in valid_parts:
710            pass  # TODO: Implement this
711      return 0
712
713   def width(self, of_collections: Optional[List[str]] = None) -> float:
714      """Y-axis width (in `m`) of the bounding box of the parts in the specified collections
715      or of the cumulative Assembly (read-only)."""
716      assembly = self.clone()
717      assembly._place_parts()
718      valid_parts = [part.name for part in self.parts] if of_collections is None else \
719                    [part for collection_name, parts in self.collections.items()
720                     if collection_name in of_collections for part in parts]
721      for part in assembly.parts:
722         if part.name in valid_parts:
723            pass  # TODO: Implement this
724      return 0
725
726   def height(self, of_collections: Optional[List[str]] = None) -> float:
727      """Z-axis height (in `m`) of the bounding box of the parts in the specified collections
728      or of the cumulative Assembly (read-only)."""
729      assembly = self.clone()
730      assembly._place_parts()
731      valid_parts = [part.name for part in self.parts] if of_collections is None else \
732                    [part for collection_name, parts in self.collections.items()
733                     if collection_name in of_collections for part in parts]
734      for part in assembly.parts:
735         if part.name in valid_parts:
736            pass  # TODO: Implement this
737         #placement_center = \
738         #   part._reorient_coordinate(part.static_origin.x * part.unoriented_length,
739         #                             part.static_origin.y * part.unoriented_width,
740         #                             part.static_origin.z * part.unoriented_height)
741         #placement_z = part.static_placement.z - placement_center[2]
742         #minimum_extents_list.append(placement_z)
743         #maximum_extents_list.append(placement_z + part.oriented_height)
744      return 0

Class representing an assembly of individual SymPart parts.

Parts can be added to an assembly using the add_part() method and may include placements or orientations that are symbolic; however, an assembly cannot be exported to a CAD model until all parameters have been set to concrete values, either directly within each assembled part, or by passing a dictionary of key: value pairs to the export method for each symbolic parameter within the model.

Assembly(assembly_name: str)
62   def __init__(self, assembly_name: str) -> None:
63      """Initializes an `Assembly` object with the specified `assembly_name`."""
64      self.name = assembly_name
65      self.parts = []
66      self.collections = defaultdict(list)

Initializes an Assembly object with the specified assembly_name.

name: str

Unique identifying name for the Assembly.

List of SymPart parts within the assembly.

collections: Dict[str, List[str]]

Dictionary of collections of SymPart parts that can be treated as unique assemblies.

def clone(self) -> Assembly:
276   def clone(self) -> Assembly:
277      """Returns an exact clone of this `Assembly` instance."""
278      cloned = Assembly(self.name)
279      for part in self.parts:
280         cloned.parts.append(part.clone())
281      for collection_name, collection in self.collections.items():
282         cloned.collections[collection_name] = collection.copy()
283      return cloned

Returns an exact clone of this Assembly instance.

def add_part( self, shape: symcad.core.SymPart.SymPart, include_in_collections: List[str] = []) -> None:
286   def add_part(self, shape: SymPart, include_in_collections: List[str] = []) -> None:
287      """Adds a `SymPart` to the current assembly.
288
289      Every part within an assembly must have a unique name or this method will fail with a
290      `KeyError`.
291
292      Parameters
293      ----------
294      shape : `SymPart`
295         Part to add to the assembly.
296      include_in_collections : `List[str]`, optional
297         List of collections to which to add the part.
298
299      Raises
300      ------
301      `KeyError`
302         If a part within the assembly contains the same name as the part being added.
303      """
304      for part in self.parts:
305         if part.name == shape.name:
306            raise KeyError('A part with the name "{}" already exists in this assembly'
307                           .format(shape.name))
308      self.parts.append(shape)
309      for collection in include_in_collections:
310         self.collections[collection].append(shape.name)

Adds a SymPart to the current assembly.

Every part within an assembly must have a unique name or this method will fail with a KeyError.

Parameters
  • shape (SymPart): Part to add to the assembly.
  • include_in_collections (List[str], optional): List of collections to which to add the part.
Raises
  • KeyError: If a part within the assembly contains the same name as the part being added.
def remove_part_from_collection(self, shape: symcad.core.SymPart.SymPart, collection: str) -> None:
313   def remove_part_from_collection(self, shape: SymPart, collection: str) -> None:
314      """Removes a `SymPart` from the specified `collection` in the current assembly.
315
316      Parameters
317      ----------
318      shape : `SymPart`
319         Part to remove from a collection.
320      collection : `str`
321         Name of the collection from which to remove the part.
322
323      Raises
324      ------
325      `ValueError`
326         If the part does not exist within the specified collection.
327      """
328      self.collections[collection].remove(shape.name)

Removes a SymPart from the specified collection in the current assembly.

Parameters
  • shape (SymPart): Part to remove from a collection.
  • collection (str): Name of the collection from which to remove the part.
Raises
  • ValueError: If the part does not exist within the specified collection.
def get_free_parameters(self) -> List[str]:
331   def get_free_parameters(self) -> List[str]:
332      """Returns a list of all free parameters present inside the assembly."""
333      free_parameters = set()
334      assembly = self.clone()
335      assembly._place_parts()
336      for part in assembly.parts:
337         free_parameters.update(assembly._verify_fully_concrete(part, False))
338      return sorted(free_parameters)

Returns a list of all free parameters present inside the assembly.

def get_valid_states(self) -> List[str]:
341   def get_valid_states(self) -> List[str]:
342      """Returns a list of all possible geometric states for which the assembly can be
343      configured."""
344      valid_states = set()
345      for part in self.parts:
346         valid_states.update(part.get_valid_states())
347      return list(valid_states)

Returns a list of all possible geometric states for which the assembly can be configured.

def set_state(self, state_names: Optional[List[str]]) -> None:
350   def set_state(self, state_names: Union[List[str], None]) -> None:
351      """Sets the geometric configuration of the assembly according to the indicated `state_names`.
352
353      If a part within the assembly does not recognize a state listed in the `state_names`
354      parameter, the part will simply ignore that state. Note that `state_names` may contain any
355      number of states for which to configure the assembly.
356
357      Parameters
358      ----------
359      state_names : `Union[List[str], None]`
360         List of geometric states for which all parts in the assembly should be configured. If
361         set to `None`, all parts will be configured in their default state.
362      """
363      for part in self.parts:
364         part.set_state(state_names)

Sets the geometric configuration of the assembly according to the indicated state_names.

If a part within the assembly does not recognize a state listed in the state_names parameter, the part will simply ignore that state. Note that state_names may contain any number of states for which to configure the assembly.

Parameters
  • state_names (Union[List[str], None]): List of geometric states for which all parts in the assembly should be configured. If set to None, all parts will be configured in their default state.
def make_concrete( self, params: Optional[Dict[str, float]] = None) -> Assembly:
367   def make_concrete(self, params: Optional[Dict[str, float]] = None) -> Assembly:
368      """
369      Creates a copy of the current `Assembly` with all free parameters set to their concrete
370      values as specified in the `params` parameter.
371
372      Parameters
373      ----------
374      params : `Dict[str, float]`, optional, default=None
375         Dictionary of free variables along with their desired concrete values.
376
377      Returns
378      -------
379      `Assembly`
380         A copy of the current Assembly containing as many concrete parameters and
381         placements as possible.
382      """
383      concrete_assembly = self.clone()
384      concrete_assembly._make_concrete({} if params is None else params)
385      concrete_assembly._place_parts()
386      return concrete_assembly

Creates a copy of the current Assembly with all free parameters set to their concrete values as specified in the params parameter.

Parameters
  • params (Dict[str, float], optional, default=None): Dictionary of free variables along with their desired concrete values.
Returns
  • Assembly: A copy of the current Assembly containing as many concrete parameters and placements as possible.
def export( self, file_save_path: str, model_type: Literal['freecad', 'step', 'stl'], create_displacement_model: Optional[bool] = False) -> None:
389   def export(self, file_save_path: str,
390                    model_type: Literal['freecad', 'step', 'stl'],
391                    create_displacement_model: Optional[bool] = False) -> None:
392      """Exports the current assembly as a CAD file.
393
394      Note that all parameters in the assembly must be concrete with no free variables remaining.
395      This can be achieved by first calling `make_concrete(params)` on the assembly object and
396      then calling `export()` on the resulting concrete assembly.
397
398      If any free parameter is missing a corresponding concrete value in the `params`
399      dictionary, this method will raise a `RuntimeError`.
400
401      Parameters
402      ----------
403      file_save_path : `str`
404         Relative or absolute path of the desired output file.
405      model_type : {'freecad', 'step', 'stl'}
406         Desired format of the exported CAD model.
407      create_displacement_model : `bool`, optional, default=False
408         Whether to create a model representing total environmental displacement.
409
410      Raises
411      ------
412      `RuntimeError`
413         If a free parameter within the assembly does not contain a corresponding concrete value.
414      """
415
416      # Create any necessary path directories
417      file_path = Path(file_save_path).absolute().resolve()
418      if not file_path.parent.exists():
419         file_path.parent.mkdir()
420
421      # Create a new assembly document and add all concrete CAD parts to it
422      assembly = self.clone()
423      assembly._place_parts()
424      doc = FreeCAD.newDocument(self.name)
425      for part in assembly.parts:
426         Assembly._verify_fully_concrete(part, True)
427         part.__cad__.add_to_assembly(part.name,
428                                      doc,
429                                      part.geometry.__dict__,
430                                      part.static_origin.as_tuple(),
431                                      part.static_placement.as_tuple(),
432                                      part.orientation.as_tuple(),
433                                      create_displacement_model)
434
435      # Recompute and create the requested version of the model
436      doc.recompute()
437      CadGeneral.save_assembly(file_path, model_type, doc)
438      FreeCAD.closeDocument(doc.Name)

Exports the current assembly as a CAD file.

Note that all parameters in the assembly must be concrete with no free variables remaining. This can be achieved by first calling make_concrete(params) on the assembly object and then calling export() on the resulting concrete assembly.

If any free parameter is missing a corresponding concrete value in the params dictionary, this method will raise a RuntimeError.

Parameters
  • file_save_path (str): Relative or absolute path of the desired output file.
  • model_type ({'freecad', 'step', 'stl'}): Desired format of the exported CAD model.
  • create_displacement_model (bool, optional, default=False): Whether to create a model representing total environmental displacement.
Raises
  • RuntimeError: If a free parameter within the assembly does not contain a corresponding concrete value.
def get_aggregate_model(self, create_displacement_model: Optional[bool] = False) -> Part.Solid:
441   def get_aggregate_model(self, create_displacement_model: Optional[bool] = False) -> Part.Solid:
442      """Retrieves the current assembly as a standalone CAD model.
443
444      Note that all parameters in the assembly must be concrete with no free variables remaining.
445      This can be achieved by first calling `make_concrete(params)` on the assembly object and
446      then calling `get_aggregate_model()` on the resulting concrete assembly.
447
448      If any free parameter is missing a corresponding concrete value in the `params`
449      dictionary, this method will raise a `RuntimeError`.
450
451      Parameters
452      ----------
453      create_displacement_model : `bool`, optional, default=False
454         Whether to create a model representing total environmental displacement.
455
456      Raises
457      ------
458      `RuntimeError`
459         If a free parameter within the assembly does not contain a corresponding concrete value.
460      """
461
462      # Create a new assembly document and add all concrete CAD parts to it
463      assembly = self.clone()
464      assembly._place_parts()
465      doc = FreeCAD.newDocument(self.name)
466      for part in assembly.parts:
467         Assembly._verify_fully_concrete(part, True)
468         part.__cad__.add_to_assembly(part.name,
469                                      doc,
470                                      part.geometry.__dict__,
471                                      part.static_origin.as_tuple(),
472                                      part.static_placement.as_tuple(),
473                                      part.orientation.as_tuple(),
474                                      create_displacement_model)
475
476      # Recompute and create the requested version of the model
477      doc.recompute()
478      model = Part.makeCompound([obj.Shape for obj in doc.Objects])
479      FreeCAD.closeDocument(doc.Name)
480      return model

Retrieves the current assembly as a standalone CAD model.

Note that all parameters in the assembly must be concrete with no free variables remaining. This can be achieved by first calling make_concrete(params) on the assembly object and then calling get_aggregate_model() on the resulting concrete assembly.

If any free parameter is missing a corresponding concrete value in the params dictionary, this method will raise a RuntimeError.

Parameters
  • create_displacement_model (bool, optional, default=False): Whether to create a model representing total environmental displacement.
Raises
  • RuntimeError: If a free parameter within the assembly does not contain a corresponding concrete value.
def check_interferences(self) -> List[Tuple[str, str]]:
483   def check_interferences(self) -> List[Tuple[str, str]]:
484      """Checks whether any of the parts contained within the Assembly interfere with any
485      other contained parts.
486
487      Returns
488      -------
489      `List[Tuple[str, str]]`
490         A list of tuples containing parts that interfere with one another.
491      """
492
493      # Create an assembly document and iterate through all CAD parts
494      assembly = self.clone()
495      assembly._place_parts()
496      doc = FreeCAD.newDocument(self.name)
497      for part in assembly.parts:
498
499         # Ensure that the part is fully concrete, and add it to the current assembly
500         Assembly._verify_fully_concrete(part, True)
501         part.__cad__.add_to_assembly(part.name,
502                                      doc,
503                                      part.geometry.__dict__,
504                                      part.static_origin.as_tuple(),
505                                      part.static_placement.as_tuple(),
506                                      part.orientation.as_tuple(),
507                                      False)
508
509      # Recompute and check for interferences in the resulting model
510      doc.recompute()
511      interferences = CadGeneral.retrieve_interferences(doc)
512      FreeCAD.closeDocument(doc.Name)
513      return interferences

Checks whether any of the parts contained within the Assembly interfere with any other contained parts.

Returns
  • List[Tuple[str, str]]: A list of tuples containing parts that interfere with one another.
def get_cad_physical_properties(self, of_collections: Optional[List[str]] = None) -> Dict[str, float]:
516   def get_cad_physical_properties(self,
517                                   of_collections: Optional[List[str]] = None) -> Dict[str, float]:
518      """Returns all physical properties of the Assembly as reported by the underlying CAD model.
519
520      Parameters
521      ----------
522      of_collections : `List[str]`, optional
523         List of part collections to include in the physical property retrieval results.
524
525      Returns
526      -------
527      `Dict[str, float]`
528         A dictionary containing all physical properties of the underlying CAD model.
529      """
530
531      # Create an assembly document and iterate through all CAD parts
532      material_densities = {}
533      assembly = self.clone()
534      assembly._place_parts()
535      doc = FreeCAD.newDocument(self.name)
536      displacement_doc = FreeCAD.newDocument(self.name + '_displacement')
537      valid_parts = [part.name for part in self.parts] if of_collections is None else \
538                    [part for collection_name, parts in self.collections.items()
539                     if collection_name in of_collections for part in parts]
540      for part in assembly.parts:
541
542         # Ensure that the part is valid and fully concrete, and add it to the current assembly
543         if part.name in valid_parts:
544            Assembly._verify_fully_concrete(part, True)
545            material_densities[part.name] = part.material_density
546            part.__cad__.add_to_assembly(part.name,
547                                       doc,
548                                       part.geometry.__dict__,
549                                       part.static_origin.as_tuple(),
550                                       part.static_placement.as_tuple(),
551                                       part.orientation.as_tuple(),
552                                       False)
553            if part.is_exposed:
554               part.__cad__.add_to_assembly(part.name,
555                                          displacement_doc,
556                                          part.geometry.__dict__,
557                                          part.static_origin.as_tuple(),
558                                          part.static_placement.as_tuple(),
559                                          part.orientation.as_tuple(),
560                                          True)
561
562      # Recompute and calculate the physical properties of the resulting model
563      doc.recompute()
564      displacement_doc.recompute()
565      physical_properties = CadGeneral.fetch_assembly_physical_properties(doc,
566                                                                          displacement_doc,
567                                                                          material_densities)
568      FreeCAD.closeDocument(doc.Name)
569      FreeCAD.closeDocument(displacement_doc.Name)
570      return physical_properties

Returns all physical properties of the Assembly as reported by the underlying CAD model.

Parameters
  • of_collections (List[str], optional): List of part collections to include in the physical property retrieval results.
Returns
  • Dict[str, float]: A dictionary containing all physical properties of the underlying CAD model.
def save(self, file_save_path: str) -> None:
573   def save(self, file_save_path: str) -> None:
574      """Saves the current assembly as a JSON graph file.
575
576      Parameters
577      ----------
578      file_save_path : `str`
579         Relative or absolute path of the desired output file.
580      """
581
582      # Create any necessary path directories
583      file_path = Path(file_save_path).absolute().resolve()
584      if not file_path.parent.exists():
585         file_path.parent.mkdir()
586
587      # Export the assembly to a JSON string and store as a file
588      from .GraphAPI import export_to_json
589      json_out = export_to_json(self)
590      file_path.write_text(json_out)

Saves the current assembly as a JSON graph file.

Parameters
  • file_save_path (str): Relative or absolute path of the desired output file.
@staticmethod
def load(file_name: str) -> Assembly:
593   @staticmethod
594   def load(file_name: str) -> Assembly:
595      """Loads an assembly from the given JSON graph file.
596
597      Parameters
598      ----------
599      file_name: `str`
600         Relative or absolute path of the file containing a JSON-based Assembly.
601
602      Returns
603      -------
604      `Assembly`
605         The deserialized Assembly object represented by the specified file.
606      """
607      from .GraphAPI import import_from_json
608      file_path = Path(file_name).absolute().resolve()
609      if not file_path.exists():
610         raise ValueError('The JSON graph file at "{}" does not exist'.format(str(file_path)))
611      return import_from_json(file_path.read_text())

Loads an assembly from the given JSON graph file.

Parameters
  • file_name (str): Relative or absolute path of the file containing a JSON-based Assembly.
Returns
  • Assembly: The deserialized Assembly object represented by the specified file.
def mass(self, of_collections: Optional[List[str]] = None) -> float:
616   def mass(self, of_collections: Optional[List[str]] = None) -> float:
617      """Mass (in `kg`) of the parts in the specified collections or of the cumulative
618      Assembly (read-only)."""
619      valid_parts = [part.name for part in self.parts] if of_collections is None else \
620                    [part for collection_name, parts in self.collections.items()
621                     if collection_name in of_collections for part in parts]
622      return sum([part.mass for part in self.parts if part.name in valid_parts])

Mass (in kg) of the parts in the specified collections or of the cumulative Assembly (read-only).

def material_volume(self, of_collections: Optional[List[str]] = None) -> float:
624   def material_volume(self, of_collections: Optional[List[str]] = None) -> float:
625      """Material volume (in `m^3`) of the parts in the specified collections or of the
626      cumulative Assembly (read-only)."""
627      valid_parts = [part.name for part in self.parts] if of_collections is None else \
628                    [part for collection_name, parts in self.collections.items()
629                     if collection_name in of_collections for part in parts]
630      return sum([part.material_volume for part in self.parts if part.name in valid_parts])

Material volume (in m^3) of the parts in the specified collections or of the cumulative Assembly (read-only).

def displaced_volume(self, of_collections: Optional[List[str]] = None) -> float:
632   def displaced_volume(self, of_collections: Optional[List[str]] = None) -> float:
633      """Displaced volume (in `m^3`) of the parts in the specified collections or of the
634      cumulative Assembly (read-only)."""
635      valid_parts = [part.name for part in self.parts] if of_collections is None else \
636                    [part for collection_name, parts in self.collections.items()
637                     if collection_name in of_collections for part in parts]
638      return sum([part.displaced_volume for part in self.parts if part.is_exposed and
639                                                                  part.name in valid_parts])

Displaced volume (in m^3) of the parts in the specified collections or of the cumulative Assembly (read-only).

def surface_area(self, of_collections: Optional[List[str]] = None) -> float:
641   def surface_area(self, of_collections: Optional[List[str]] = None) -> float:
642      """Surface/wetted area (in `m^2`) of the parts in the specified collections or of the
643      cumulative Assembly (read-only)."""
644      valid_parts = [part.name for part in self.parts] if of_collections is None else \
645                    [part for collection_name, parts in self.collections.items()
646                     if collection_name in of_collections for part in parts]
647      return sum([part.surface_area for part in self.parts if part.is_exposed and
648                                                              part.name in valid_parts])

Surface/wetted area (in m^2) of the parts in the specified collections or of the cumulative Assembly (read-only).

def center_of_gravity( self, of_collections: Optional[List[str]] = None) -> symcad.core.Coordinate.Coordinate:
650   def center_of_gravity(self, of_collections: Optional[List[str]] = None) -> Coordinate:
651      """Center of gravity (in `m`) of the parts in the specified collections or of the
652      cumulative Assembly (read-only)."""
653      assembly = self.clone()
654      assembly._place_parts()
655      valid_parts = [part.name for part in self.parts] if of_collections is None else \
656                    [part for collection_name, parts in self.collections.items()
657                     if collection_name in of_collections for part in parts]
658      mass, center_of_gravity_x, center_of_gravity_y, center_of_gravity_z = (0.0, 0.0, 0.0, 0.0)
659      for part in assembly.parts:
660         if part.name in valid_parts:
661            part_mass = part.mass
662            part_placement = part.static_placement
663            part_center_of_gravity = part.oriented_center_of_gravity
664            center_of_gravity_x += ((part_placement.x + part_center_of_gravity[0]) * part_mass)
665            center_of_gravity_y += ((part_placement.y + part_center_of_gravity[1]) * part_mass)
666            center_of_gravity_z += ((part_placement.z + part_center_of_gravity[2]) * part_mass)
667            mass += part_mass
668      return Coordinate(assembly.name + '_center_of_gravity',
669                        x=center_of_gravity_x / mass,
670                        y=center_of_gravity_y / mass,
671                        z=center_of_gravity_z / mass)

Center of gravity (in m) of the parts in the specified collections or of the cumulative Assembly (read-only).

def center_of_buoyancy( self, of_collections: Optional[List[str]] = None) -> symcad.core.Coordinate.Coordinate:
673   def center_of_buoyancy(self, of_collections: Optional[List[str]] = None) -> Coordinate:
674      """Center of buoyancy (in `m`) of the parts in the specified collections or of the
675      cumulative Assembly (read-only)."""
676      assembly = self.clone()
677      assembly._place_parts()
678      displaced_volume = 0.0
679      valid_parts = [part.name for part in self.parts] if of_collections is None else \
680                    [part for collection_name, parts in self.collections.items()
681                     if collection_name in of_collections for part in parts]
682      center_of_buoyancy_x, center_of_buoyancy_y, center_of_buoyancy_z = (0.0, 0.0, 0.0)
683      for part in assembly.parts:
684         if part.is_exposed and part.name in valid_parts:
685            part_placement = part.static_placement
686            part_displaced_volume = part.displaced_volume
687            part_center_of_buoyancy = part.oriented_center_of_buoyancy
688            center_of_buoyancy_x += ((part_placement.x + part_center_of_buoyancy[0])
689                                    * part_displaced_volume)
690            center_of_buoyancy_y += ((part_placement.y + part_center_of_buoyancy[1])
691                                    * part_displaced_volume)
692            center_of_buoyancy_z += ((part_placement.z + part_center_of_buoyancy[2])
693                                    * part_displaced_volume)
694            displaced_volume += part_displaced_volume
695      return Coordinate(assembly.name + '_center_of_buoyancy',
696                        x=center_of_buoyancy_x / displaced_volume,
697                        y=center_of_buoyancy_y / displaced_volume,
698                        z=center_of_buoyancy_z / displaced_volume)

Center of buoyancy (in m) of the parts in the specified collections or of the cumulative Assembly (read-only).

def length(self, of_collections: Optional[List[str]] = None) -> float:
700   def length(self, of_collections: Optional[List[str]] = None) -> float:
701      """X-axis length (in `m`) of the bounding box of the parts in the specified collections
702      or of the cumulative Assembly (read-only)."""
703      assembly = self.clone()
704      assembly._place_parts()
705      valid_parts = [part.name for part in self.parts] if of_collections is None else \
706                    [part for collection_name, parts in self.collections.items()
707                     if collection_name in of_collections for part in parts]
708      for part in assembly.parts:
709         if part.name in valid_parts:
710            pass  # TODO: Implement this
711      return 0

X-axis length (in m) of the bounding box of the parts in the specified collections or of the cumulative Assembly (read-only).

def width(self, of_collections: Optional[List[str]] = None) -> float:
713   def width(self, of_collections: Optional[List[str]] = None) -> float:
714      """Y-axis width (in `m`) of the bounding box of the parts in the specified collections
715      or of the cumulative Assembly (read-only)."""
716      assembly = self.clone()
717      assembly._place_parts()
718      valid_parts = [part.name for part in self.parts] if of_collections is None else \
719                    [part for collection_name, parts in self.collections.items()
720                     if collection_name in of_collections for part in parts]
721      for part in assembly.parts:
722         if part.name in valid_parts:
723            pass  # TODO: Implement this
724      return 0

Y-axis width (in m) of the bounding box of the parts in the specified collections or of the cumulative Assembly (read-only).

def height(self, of_collections: Optional[List[str]] = None) -> float:
726   def height(self, of_collections: Optional[List[str]] = None) -> float:
727      """Z-axis height (in `m`) of the bounding box of the parts in the specified collections
728      or of the cumulative Assembly (read-only)."""
729      assembly = self.clone()
730      assembly._place_parts()
731      valid_parts = [part.name for part in self.parts] if of_collections is None else \
732                    [part for collection_name, parts in self.collections.items()
733                     if collection_name in of_collections for part in parts]
734      for part in assembly.parts:
735         if part.name in valid_parts:
736            pass  # TODO: Implement this
737         #placement_center = \
738         #   part._reorient_coordinate(part.static_origin.x * part.unoriented_length,
739         #                             part.static_origin.y * part.unoriented_width,
740         #                             part.static_origin.z * part.unoriented_height)
741         #placement_z = part.static_placement.z - placement_center[2]
742         #minimum_extents_list.append(placement_z)
743         #maximum_extents_list.append(placement_z + part.oriented_height)
744      return 0

Z-axis height (in m) of the bounding box of the parts in the specified collections or of the cumulative Assembly (read-only).