diff --git a/CHANGELOG.md b/CHANGELOG.md index 89e8171a..6069e1d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +* Added methods to merge multiple models. + ### Changed ### Removed diff --git a/src/compas_model/models/model.py b/src/compas_model/models/model.py index 9a1dab50..3de0a79e 100644 --- a/src/compas_model/models/model.py +++ b/src/compas_model/models/model.py @@ -528,9 +528,259 @@ def assign_material( element.material = material # ============================================================================= - # Other models + # Branching # ============================================================================= + def duplicate(self) -> "Model": + """Return a fully independent copy of this model (elements get new guids). + + Unlike :meth:`copy` (and ``deepcopy``), which reproduce each element's + guid verbatim - so two copies share guids and cannot coexist in one + model - ``duplicate`` re-clones every element with a fresh guid and + rewires the tree/graph. The result is independent, so it can be placed + and :meth:`merge`-d alongside the original. + + Returns + ------- + Model + An independent copy of this model. + + """ + new = type(self)(name=self.name) + new.transformation = self.transformation + + # Materials + materials: dict[str, Material] = {} + for material in self.materials(): + materials[str(material.guid)] = new.add_or_get_material(material.copy()) + + # Elements (cloned with fresh guids, hierarchy preserved) + elements: dict[str, Element] = {} + + def _clone(source_node: ElementNode, parent: Optional[Element]) -> None: + for child in source_node.children: + source = child.element + element = source.copy() # fresh guid -> independent + new.add_element(element, parent=parent) + elements[str(source.guid)] = element + if source._material and str(source._material) in materials: + new.assign_material(materials[str(source._material)], element=element) + _clone(child, element) + + _clone(self.tree.root, None) + + # Interactions (with their modifiers/contacts) + for edge in self.graph.edges(): + a = elements[str(self.graph.node_element(edge[0]).guid)] + b = elements[str(self.graph.node_element(edge[1]).guid)] + new_edge = new.add_interaction(a, b) + for attr in ("modifiers", "contacts"): + values = self.graph.edge_attribute(edge, name=attr) + if values: + new.graph.edge_attribute(new_edge, name=attr, value=[v.copy() for v in values]) + + return new + + def merge(self, models: list["Model"], parent: Optional[Element] = None) -> "Model": + """Merge a list of models into this model, each under its own group. + + Each input is added under a new group (named after it) nested under + ``parent`` - or under the model root when ``parent`` is ``None``. For + every input its materials, element tree (hierarchy preserved) and + interactions (with their modifiers/contacts) are brought in. The inputs + are consumed - their elements are moved in, not copied - so + :meth:`duplicate` them first if you need to keep the originals (or to + merge several instances of one model). + + Build a new combined model by merging into a fresh one, optionally in one + line: ``Model(name="columns").merge(column_models)``. Insert into an + existing model under a chosen group/element by passing ``parent``. + + Parameters + ---------- + models + The list of models to merge into this one. + parent + The group/element to nest the merged models under. Root if ``None``. + + Returns + ------- + Model + This model (returned for chaining). + + """ + + def _move(source_node: ElementNode, target: Optional[Element], materials: dict) -> None: + for child in source_node.children: + element = child.element + self.add_element(element, parent=target) + if element._material and str(element._material) in materials: + self.assign_material(materials[str(element._material)], element=element) + _move(child, element, materials) + + for other in models: + group = self.add_element(Group(name=other.name or "model"), parent=parent) + if other.transformation is not None: + group.transformation = other.transformation + + # Materials + materials: dict[str, Material] = {} + for material in other.materials(): + materials[str(material.guid)] = self.add_or_get_material(material) + + # Elements (moved in, hierarchy preserved) + _move(other.tree.root, group, materials) + + # Interactions (with their modifiers/contacts) + for edge in other.graph.edges(): + a = other.graph.node_element(edge[0]) + b = other.graph.node_element(edge[1]) + new_edge = self.add_interaction(a, b) + for attr in ("modifiers", "contacts"): + values = other.graph.edge_attribute(edge, name=attr) + if values: + self.graph.edge_attribute(new_edge, name=attr, value=list(values)) + + return self + + def compute_contacts_between_groups( + self, + groups: list[str], + groups_b: Optional[list[str]] = None, + tolerance: float = 1e-6, + minimum_area: float = 1e-2, + contacttype: type[Contact] = Contact, + ) -> None: + """Compute contacts only between elements of *different* named groups. + + Like :meth:`compute_contacts`, this is a spatial (BVH) search that adds + a contact interaction wherever two element geometries touch. Unlike it, + only the named groups take part, and pairs within a single group are + never tested. + + Two modes: + + - **All-pairs** (``groups_b`` omitted): detection runs between every + unordered pair of *distinct* groups in ``groups``. This is the natural + companion to :meth:`merge` - every merged sub-model becomes its own + group, so passing those names finds the seams between the sub-models. + + - **Two-sided** (``groups_b`` given): detection runs only between an + element of a ``groups`` group and an element of a ``groups_b`` group. + Pairs within ``groups`` and pairs within ``groups_b`` are skipped. + Use this to contact one set against another - e.g. columns against + only the outer ribs - without the ribs also contacting each other. + + Each element is assigned to the *nearest* enclosing group whose name is + requested, so nested groups behave predictably: pass an outer group name + to treat all its descendants as one group, or the inner names to keep + them apart. + + Parameters + ---------- + groups + Names of the groups on the first side. + groups_b + Names of the groups on the second side. If ``None``, ``groups`` is + tested against itself (all distinct pairs). + tolerance + The distance tolerance. + minimum_area + The minimum contact size. + contacttype + The contact class to use for the generated contacts. + + Raises + ------ + ValueError + All-pairs: if fewer than two named groups contain any elements. + Two-sided: if either side has no elements. + + """ + groupset_a = set(groups) + groupset_b = set(groups_b) if groups_b else None + all_names = groupset_a | (groupset_b or set()) + + def group_of(element: Element) -> Optional[str]: + # Nearest enclosing ancestor group whose name was requested. + node = element.treenode + parent = node.parent if node is not None else None + while parent is not None and not parent.is_root: + if parent.element.name in all_names: + return parent.element.name + parent = parent.parent + return None + + keyof: dict[int, str] = {} + participants: list[Element] = [] + for element in self.elements(): + if isinstance(element, Group): + continue + key = group_of(element) + if key is not None: + keyof[id(element)] = key + participants.append(element) + + present = set(keyof.values()) + if groupset_b is None: + if len(present) < 2: + raise ValueError( + "compute_contacts_between_groups needs at least two non-empty groups to form a pair; " + "found elements only for {} of the requested {}.".format(sorted(present), sorted(groupset_a)) + ) + + def accept(key_u: str, key_v: str) -> bool: + return key_u != key_v + else: + if not (present & groupset_a) or not (present & groupset_b): + raise ValueError( + "compute_contacts_between_groups (two-sided) needs elements on both sides; " + "found {} for side A {} and {} for side B {}.".format( + sorted(present & groupset_a), sorted(groupset_a), sorted(present & groupset_b), sorted(groupset_b) + ) + ) + + def accept(key_u: str, key_v: str) -> bool: + return (key_u in groupset_a and key_v in groupset_b) or (key_u in groupset_b and key_v in groupset_a) + + # Spatial search over participants only; same dedup logic as compute_contacts. + bvh = ElementBVH.from_elements(participants) + + for element in participants: + u = element.graphnode + key_u = keyof[id(element)] + + for nbr in bvh.nearest_neighbors(element): + key_v = keyof.get(id(nbr)) + if key_v is None or not accept(key_u, key_v): + # a non-participant, same side / same group, or the element itself + continue + + v = nbr.graphnode + + if not self.graph.has_edge((u, v), directed=False): + contacts = element.compute_contacts( + nbr, + tolerance=tolerance, + minimum_area=minimum_area, + contacttype=contacttype, + ) + if contacts: + self.graph.add_edge(u, v, contacts=contacts) + + else: + edge = (u, v) if self.graph.has_edge((u, v)) else (v, u) + contacts = self.graph.edge_attribute(edge, name="contacts") + if not contacts: + contacts = element.compute_contacts( + nbr, + tolerance=tolerance, + minimum_area=minimum_area, + contacttype=contacttype, + ) + if contacts: + self.graph.edge_attribute(edge, name="contacts", value=contacts) + # ============================================================================= # Contacts (with contacts a specific type of interaction) # =============================================================================