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
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.
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.
Dictionary of collections of SymPart parts that can be treated as unique assemblies.
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.
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.
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.
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.
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.
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 toNone, all parts will be configured in their default state.
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.
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.
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.
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.
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.
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.
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.
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).
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).
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).
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).
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).
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).
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).
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).
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).