diff --git a/src/bonsai/scripts/waldo.py b/src/bonsai/scripts/waldo.py index b53ae6b21f..15d8efcaa5 100644 --- a/src/bonsai/scripts/waldo.py +++ b/src/bonsai/scripts/waldo.py @@ -166,9 +166,9 @@ def test_wall(offset, p1, p2, p3, p4, a1=None, a2=None): related_connection="ATSTART", ) - Foo(f, body, axis).regenerate(wall_a, angle=a1) - Foo(f, body, axis).regenerate(wall_b, angle=a1) - Foo(f, body, axis).regenerate(wall_c, angle=a1) + ifcopenshell.api.geometry.regenerate_wall_representation(f, wall=wall_a, angle=a1) + ifcopenshell.api.geometry.regenerate_wall_representation(f, wall=wall_b, angle=a1) + ifcopenshell.api.geometry.regenerate_wall_representation(f, wall=wall_c, angle=a1) def create_type(name, layers): @@ -216,7 +216,7 @@ def test_atpath(offset, angle=None): relating_connection="ATEND", related_connection="ATPATH", ) - Foo(f, body, axis).regenerate(wall, angle=angle) + ifcopenshell.api.geometry.regenerate_wall_representation(f, wall=wall, angle=angle) create_branch("B", 1, 1, 1, 1, 1, -75) create_branch("C", 1, 2, 3, 2, 1, -75) @@ -230,503 +230,7 @@ def test_atpath(offset, angle=None): create_branch("E", 4, 4, 4, 3.5, -1, 75) create_branch("F", 4, 2, 4, 4.5, -1, 75) - Foo(f, body, axis).regenerate(wall_a, angle=angle) - - -PrioritisedLayer = namedtuple("PrioritisedLayer", "priority thickness") - - -class Foo: - def __init__(self, file, body, axis): - self.file = file - self.body = body - self.axis = axis - self.is_angled = False - - def regenerate(self, wall, angle=None): - print("-" * 100) - print(wall) - self.fallback_angle = angle - layers = self.get_layers(wall) - if not layers: - return - reference = self.get_reference_line(wall) - self.reference_p1, self.reference_p2 = reference - self.wall_vectors = self.get_wall_vectors(wall) - axes = self.get_axes(wall, reference, layers, self.wall_vectors["a"]) - self.miny = axes[0][0][1] - self.maxy = axes[-1][0][1] - self.end_point = None - self.start_points = [] - self.start_vector = np.array((0.0, 0.0, 1.0)) - self.start_offset = 0.0 - self.atpath_points = [] - self.split_points = [] - self.maxpath_points = [] - self.minpath_points = [] - self.end_points = [] - self.end_vector = np.array((0.0, 0.0, 1.0)) - self.end_offset = 0.0 - for rel in wall.ConnectedTo: - if rel.is_a("IfcRelConnectsPathElements"): - wall2 = rel.RelatedElement - layers1 = self.combine_layers(layers.copy(), rel.RelatingPriorities) - layers2 = self.combine_layers(self.get_layers(wall2), rel.RelatedPriorities) - if not layers1 or not layers2: - continue - self.join(wall, wall2, layers1, layers2, rel.RelatingConnectionType, rel.RelatedConnectionType) - - for rel in wall.ConnectedFrom: - if rel.is_a("IfcRelConnectsPathElements"): - wall2 = rel.RelatingElement - layers1 = self.combine_layers(layers.copy(), rel.RelatedPriorities) - layers2 = self.combine_layers(self.get_layers(wall2), rel.RelatingPriorities) - if not layers1 or not layers2: - continue - self.join(wall, wall2, layers1, layers2, rel.RelatedConnectionType, rel.RelatingConnectionType) - - if not self.start_points: - minx = axes[0][0][0] - self.start_points = [ - np.array((minx, axes[0][0][1])), - np.array((minx, axes[-1][0][1])), - ] - if not self.end_points: - maxx = axes[0][1][0] - self.end_points = [ - np.array((maxx, axes[0][0][1])), - np.array((maxx, axes[-1][0][1])), - ] - print("FINISHED") - print(self.start_points) - print(self.end_points) - - if self.start_points[0][1] > self.start_points[-1][1]: # Canonicalise to the +Y direction - self.start_points.reverse() - if self.end_points[0][1] > self.end_points[-1][1]: # Canonicalise to the +Y direction - self.end_points.reverse() - - builder = ifcopenshell.util.shape_builder.ShapeBuilder(wall.file) - - if self.is_angled: - start_points = [p.copy() for p in self.start_points] - end_points = [p.copy() for p in self.end_points] - if self.end_offset > 0: - for point in end_points: - point[0] += self.end_offset - if self.start_offset < 0: - for point in start_points: - point[0] += self.start_offset - points = [] - points.extend(start_points) - end_points.reverse() - points.extend(end_points) - item = builder.extrude( - builder.polyline(points, closed=True), - magnitude=self.wall_vectors["d"], - extrusion_vector=self.wall_vectors["z"], - ) - - operands = [] - if not np.allclose(self.start_vector, np.array((0.0, 0.0, 1.0))): - points = self.start_points.copy() - while ifcopenshell.util.shape_builder.is_x(points[0][1], points[1][1]): - points.pop(0) - while ifcopenshell.util.shape_builder.is_x(points[-1][1], points[-2][1]): - points.pop() - newx = min([p[0] for p in points]) - abs(self.start_offset) - p1 = points[-1].copy() - p1[0] = newx - p2 = p1.copy() - p2[1] = points[0][1] - points.extend((p1, p2)) - magnitude = np.linalg.norm(self.start_vector * (self.wall_vectors["h"] / self.start_vector[2])) - operands.append( - builder.extrude( - builder.polyline(points, closed=True), magnitude=magnitude, extrusion_vector=self.start_vector - ) - ) - - if not np.allclose(self.end_vector, np.array((0.0, 0.0, 1.0))): - points = self.end_points.copy() - while ifcopenshell.util.shape_builder.is_x(points[0][1], points[1][1]): - points.pop(0) - while ifcopenshell.util.shape_builder.is_x(points[-1][1], points[-2][1]): - points.pop() - - newx = max([p[0] for p in points]) + abs(self.end_offset) - p1 = points[-1].copy() - p1[0] = newx - p2 = p1.copy() - p2[1] = points[0][1] - points.extend((p1, p2)) - magnitude = np.linalg.norm(self.end_vector * (self.wall_vectors["h"] / self.end_vector[2])) - operands.append( - builder.extrude( - builder.polyline(points, closed=True), magnitude=magnitude, extrusion_vector=self.end_vector - ) - ) - - for atpath_vector, points in self.atpath_points: - if len(points) <= 2: - continue - magnitude = np.linalg.norm(atpath_vector * (self.wall_vectors["h"] / atpath_vector[2])) - operands.append( - builder.extrude( - builder.polyline(points, closed=True), magnitude=magnitude, extrusion_vector=atpath_vector - ) - ) - - if operands: - item = ifcopenshell.api.geometry.add_boolean(wall.file, first_item=item, second_items=operands)[-1] - else: - # A wall footprint may be multiple profiles if the wall is split into two due to an ATPATH connection - profiles = [] - split_points = sorted(self.split_points, key=lambda x: x[0][0]) # Sort islands in the +X direction - start_points = [p.copy() for p in self.start_points] - end_points = [p.copy() for p in self.end_points] - split_points.insert(0, start_points) - split_points.append(end_points) - split_points = iter(split_points) - - while True: - # Draw each profile as clockwise starting from (minx, miny) - start_split = next(split_points, None) - if not start_split: - break - end_split = next(split_points, None) - if not end_split: - break - maxy_minx = start_split[-1][0] - maxy_maxx = end_split[-1][0] - miny_minx = start_split[0][0] - miny_maxx = end_split[0][0] - # Do more defensive checks here - points = start_split - - remaining_path_points = [] - for maxpath_points in self.maxpath_points: - if maxpath_points[0][0] > maxy_minx and maxpath_points[-1][0] < maxy_maxx: - print("adding maxpath points", maxpath_points) - points.extend(maxpath_points) - else: - remaining_path_points.append(maxpath_points) - self.maxpath_points = remaining_path_points - - points.extend(end_split[::-1]) - - remaining_path_points = [] - for minpath_points in self.minpath_points: - if minpath_points[0][0] < miny_maxx and minpath_points[-1][0] > miny_minx: - points.extend(minpath_points) - else: - remaining_path_points.append(minpath_points) - self.minpath_points = remaining_path_points - - profiles.append(builder.profile(builder.polyline(points, closed=True))) - - for points in self.maxpath_points + self.minpath_points: - profiles.append(builder.profile(builder.polyline(points, closed=True))) - - if len(profiles) > 1: - profile = wall.file.createIfcCompositeProfileDef("AREA", Profiles=profiles) - else: - profile = profiles[0] - - item = builder.extrude(profile, magnitude=self.wall_vectors["d"], extrusion_vector=self.wall_vectors["z"]) - rep = builder.get_representation(self.body, items=[item]) - if old_rep := ifcopenshell.util.representation.get_representation(wall, self.body): - ifcopenshell.util.element.replace_element(old_rep, rep) - else: - ifcopenshell.api.geometry.assign_representation(self.file, product=wall, representation=rep) - - item = builder.polyline([self.reference_p1, self.reference_p2]) - rep = builder.get_representation(self.axis, items=[item]) - if old_rep := ifcopenshell.util.representation.get_representation(wall, self.axis): - ifcopenshell.util.element.replace_element(old_rep, rep) - else: - ifcopenshell.api.geometry.assign_representation(self.file, product=wall, representation=rep) - - def join(self, wall1, wall2, layers1, layers2, connection1, connection2): - if connection1 == "NOTDEFINED" or connection2 == "NOTDEFINED": - return - if connection1 == "ATPATH" and connection2 == "ATPATH": - return - print("joining", wall1, layers1, connection1) - print("to", wall2, layers2, connection2) - - # axes = self.get_axes(wall2, layers2) - reference1 = self.get_reference_line(wall1) - reference2 = self.get_reference_line(wall2) - wall_vectors2 = self.get_wall_vectors(wall2) - axes1 = self.get_axes(wall1, reference1, layers1, self.wall_vectors["a"]) - axes2 = self.get_axes(wall2, reference2, layers2, wall_vectors2["a"]) - matrix1i = np.linalg.inv(ifcopenshell.util.placement.get_local_placement(wall1.ObjectPlacement)) - matrix2 = ifcopenshell.util.placement.get_local_placement(wall2.ObjectPlacement) - print(axes1) - print(axes2) - - # Convert wall2 data to wall1 local coordinates - for axis in axes2: - axis[0] = (matrix1i @ matrix2 @ np.concatenate((axis[0], (0, 1))))[:2] - axis[1] = (matrix1i @ matrix2 @ np.concatenate((axis[1], (0, 1))))[:2] - reference2[0] = (matrix1i @ matrix2 @ np.concatenate((reference2[0], (0, 1))))[:2] - reference2[1] = (matrix1i @ matrix2 @ np.concatenate((reference2[1], (0, 1))))[:2] - wall_vectors2["z"] = (matrix1i @ matrix2 @ np.append(wall_vectors2["z"], 0.0))[:3] - wall_vectors2["y"] = (matrix1i @ matrix2 @ np.append(wall_vectors2["y"], 0.0))[:3] - - # Sort axes from interior to exterior - if connection1 == "ATEND": - if axes2[0][0][0] > axes2[-1][0][0]: # We process layers in a +X direction - axes2 = list(reversed(axes2)) - layers2 = list(reversed(layers2)) - elif connection1 == "ATSTART": - if axes2[-1][0][0] > axes2[0][0][0]: # We process layers in a -X direction - axes2 = list(reversed(axes2)) - layers2 = list(reversed(layers2)) - - # wall2_x = matrix2[:,0][:2] - axis2 = axes2[0] # Take an arbitrary axis - if connection2 == "ATSTART": - axis2 = [axis2[1], axis2[0]] # Flip direction so the axis "points" in the direction of join - if axis2[0][1] < axis2[1][1]: # Pointing +Y - if axes1[-1][0][1] < axes1[0][0][1]: # We process layers1 in a +Y direction - axes1 = list(reversed(axes1)) - layers1 = list(reversed(layers1)) - else: # Pointing -Y - if axes1[0][0][1] < axes1[-1][0][1]: # We process layers1 in a -Y direction - axes1 = list(reversed(axes1)) - layers1 = list(reversed(layers1)) - - print("modified") - print(axes1) - print(axes2) - # Checked - if connection1 == "ATPATH": - first_axis2 = axes2[0] - last_axis2 = axes2[-1] - first_y = axes1[0][0][1] - last_y = axes1[-1][0][1] - p0 = np.array((self.intersect_axis(*first_axis2, y=first_y), first_y)) - pN = np.array((self.intersect_axis(*last_axis2, y=first_y), first_y)) - - # Generate CurveOnRelating/RelatedElement - points = [p0] - axes2 = iter(axes2) - axis2 = next(axes2) - for layer2 in layers2: - ys = iter([a[0][1] for a in axes1]) - y = next(ys) - for layer1 in layers1: - if layer2.priority <= layer1.priority: - break - y = next(ys) - p1 = np.array((self.intersect_axis(*axis2, y=y), y)) - axis2 = next(axes2) - p2 = np.array((self.intersect_axis(*axis2, y=y), y)) - if points and np.allclose(points[-1], p1): - points[-1] = p2 # Just slide along previous point - else: - points.extend((p1, p2)) - - # The curve must end at pN - if not np.allclose(points[-1], pN): - points.append(pN) - - # Categorise our points into a segment that either splits or cuts the wall - split_ys = {first_y, last_y} - segment = [] - atpath_vector = self.get_join_vector(self.wall_vectors["y"], wall_vectors2["y"]) - self.atpath_points.append((atpath_vector, points)) - for point in points: - segment.append(point) - if len(segment) == 1: # Not enough points to categorise the segment - continue - elif {segment[0][1], segment[-1][1]} == split_ys: # This segment splits the wall - if segment[0][1] > segment[-1][1]: # Go in the +Y direction - segment.reverse() - self.split_points.append(segment) - segment = [] - elif segment[0][1] == segment[-1][1]: # This segment cuts some of the wall - if segment[0][1] == self.maxy: # Go in the +X direction - if segment[0][0] > segment[-1][0]: - segment.reverse() - self.maxpath_points.append(segment) - elif segment[0][1] == self.miny: # Go in the -X direction - if segment[-1][0] > segment[0][0]: - segment.reverse() - self.minpath_points.append(segment) - segment = [] - elif connection2 == "ATPATH": - points = [] - ys = iter([a[0][1] for a in axes1]) - y = next(ys) - for layer1 in layers1: - axes2_iter = iter(axes2) - axis2 = next(axes2_iter) - for layer2 in layers2: - if layer1.priority <= layer2.priority: - break - axis2 = next(axes2_iter) - x = self.intersect_axis(*axis2, y=y) - p1 = np.array((x, y)) - y = next(ys) - x = self.intersect_axis(*axis2, y=y) - p2 = np.array((x, y)) - if points and np.allclose(points[-1], p1): - points.append(p2) - else: - points.extend((p1, p2)) - - if connection1 == "ATSTART": - self.start_points = points - self.start_vector = self.get_join_vector(self.wall_vectors["y"], wall_vectors2["y"]) - self.start_offset = (self.start_vector * (self.wall_vectors["h"] / self.start_vector[2]))[0] - self.reference_p1[0] = self.intersect_axis(*reference2, y=reference1[0][1]) - elif connection1 == "ATEND": - self.end_points = points - self.end_vector = self.get_join_vector(self.wall_vectors["y"], wall_vectors2["y"]) - self.end_offset = (self.end_vector * (self.wall_vectors["h"] / self.end_vector[2]))[0] - self.reference_p2[0] = self.intersect_axis(*reference2, y=reference1[0][1]) - else: - last_y = axes1[-1][0][1] - ys = iter([a[0][1] for a in axes1]) - - last_axis2 = axes2[-1] - axes2 = iter(axes2) - axis2 = next(axes2) - y = next(ys) - x = self.intersect_axis(*axis2, y=y) - points = [np.array((x, y))] - - layers1 = iter(layers1) - layers2 = iter(layers2) - layer1 = next(layers1, None) - layer2 = next(layers2, None) - - # This creates "mitering" behaviour which is an ambiguity by bSI. - while layer1 and layer2: - print("considering", layer1, layer2) - if layer1.priority > layer2.priority: - axis2 = next(axes2) - x = self.intersect_axis(*axis2, y=y) - layer2 = next(layers2, None) - elif layer2.priority > layer1.priority: - y = next(ys) - x = self.intersect_axis(*axis2, y=y) - layer1 = next(layers1, None) - else: - y = next(ys) - x = self.intersect_axis(*next(axes2), y=y) - layer1 = next(layers1, None) - layer2 = next(layers2, None) - points.append(np.array((x, y))) - - print("points", points) - if points[-1][1] != last_y: - points.append(np.array((self.intersect_axis(*last_axis2, y=last_y), last_y))) - - if connection1 == "ATSTART": - self.start_points = points - self.start_vector = self.get_join_vector(self.wall_vectors["y"], wall_vectors2["y"]) - self.start_offset = (self.start_vector * (self.wall_vectors["h"] / self.start_vector[2]))[0] - self.reference_p1[0] = self.intersect_axis(*reference2, y=reference1[0][1]) - elif connection1 == "ATEND": - self.end_points = points - self.end_vector = self.get_join_vector(self.wall_vectors["y"], wall_vectors2["y"]) - self.end_offset = (self.end_vector * (self.wall_vectors["h"] / self.end_vector[2]))[0] - self.reference_p2[0] = self.intersect_axis(*reference2, y=reference1[0][1]) - - def get_layers(self, wall) -> list: - material = ifcopenshell.util.element.get_material(wall, should_skip_usage=True) - if not material or not material.is_a("IfcMaterialLayerSet"): - return [] - return [PrioritisedLayer(l.Priority or 0, l.LayerThickness) for l in material.MaterialLayers] - - def combine_layers(self, layers, override_priorities): - results = [] - if override_priorities: - for i, priority in enumerate(override_priorities[: len(layers)]): - layers[i][0] = priority - if not layers: - return [] - results = [layers.pop(0)] - for layer in layers: - if not layer.thickness: - continue - if layer.priority == results[-1].priority: - results[-1] = PrioritisedLayer(layer.priority, results[-1].thickness + layer.thickness) - else: - results.append(layer) - return results - - def intersect_axis(self, p1, p2, y=0): - # Assumes lines are horizontal - x1, y1 = p1 - x2, y2 = p2 - t = (y - y1) / (y2 - y1) - return x1 + t * (x2 - x1) - - def get_reference_line(self, wall): - if axis := ifcopenshell.util.representation.get_representation(wall, "Plan", "Axis", "GRAPH_VIEW"): - for item in ifcopenshell.util.representation.resolve_representation(axis).Items: - if item.is_a("IfcPolyline"): - points = item.Points - elif item.is_a("IfcIndexedPolyCurve"): - points = item.Points.CoordList - else: - continue - if points[0][0] < points[1][0]: # An axis always goes in the +X direction - return [np.array(points[0]), np.array(points[1])] - return [np.array(points[1]), np.array(points[0])] - return [np.array((0.0, 0.0)), np.array((1.0, 0.0))] - - def get_wall_vectors(self, wall): - if body := ifcopenshell.util.representation.get_representation(wall, "Model", "Body", "MODEL_VIEW"): - for item in ifcopenshell.util.representation.resolve_representation(body).Items: - while item.is_a("IfcBooleanResult"): - item = item.FirstOperand - if item.is_a("IfcExtrudedAreaSolid"): - z = np.array(item.ExtrudedDirection.DirectionRatios) - z /= np.linalg.norm(z) - y = np.cross(z, np.array((1.0, 0.0, 0.0))) - d = item.Depth - h = (z * d)[2] - a = ifcopenshell.util.shape_builder.np_angle_signed(np.array((0.0, 1.0)), z[1:]) - if not ifcopenshell.util.shape_builder.is_x(a, 0): - self.is_angled = True - return {"z": z, "y": y, "a": a, "d": d, "h": h} - elif self.fallback_angle: - a = self.fallback_angle - z = np.array([0.0, sin(a), cos(a)]) - y = np.cross(z, np.array((1.0, 0.0, 0.0))) - h = 1.0 # unit scale - d = np.linalg.norm(z * (h / z[2])) - if not ifcopenshell.util.shape_builder.is_x(a, 0): - self.is_angled = True - return {"z": z, "y": y, "a": a, "d": d, "h": h} - # unit scale - return {"z": np.array((0.0, 0.0, 1.0)), "y": np.array((0.0, 1.0, 0.0)), "a": 0.0, "d": 1.0, "h": 1.0} - - def get_join_vector(self, y1, y2): - result = np.cross(y1, y2) - if result[2] < 0: - return result * -1 - return result - - def get_axes(self, wall, reference, layers: list[PrioritisedLayer], angle: float): - axes = [[p.copy() for p in reference]] - # Apply usage to convert the Reference line into MlsBase - sense_factor = 1 - if (usage := ifcopenshell.util.element.get_material(wall)) and usage.is_a("IfcMaterialLayerSetUage"): - for point in axes[0]: - point[1] += usage.OffsetFromReferenceLine - sense_factor = 1 if usage.DirectionSense == "POSITIVE" else -1 - - for layer in layers: - y_offset = (layer.thickness * sense_factor) / cos(angle) - axes.append([p.copy() + np.array((0.0, y_offset)) for p in axes[-1]]) - return axes + ifcopenshell.api.geometry.regenerate_wall_representation(f, wall=wall_a, angle=angle) test_wall(0, 1, 1, 1, 1, radians(10)) diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/__init__.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/__init__.py index 49bfe5f758..3dea99d8d2 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/__init__.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/__init__.py @@ -48,6 +48,7 @@ from .disconnect_element import disconnect_element from .disconnect_path import disconnect_path from .edit_object_placement import edit_object_placement from .map_representation import map_representation +from .regenerate_wall_representation import regenerate_wall_representation from .remove_boolean import remove_boolean from .remove_representation import remove_representation from .unassign_representation import unassign_representation @@ -76,6 +77,7 @@ __all__ = [ "disconnect_path", "edit_object_placement", "map_representation", + "regenerate_wall_representation", "remove_boolean", "remove_representation", "unassign_representation", diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/regenerate_wall_representation.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/regenerate_wall_representation.py new file mode 100644 index 0000000000..c5ffbe5998 --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/regenerate_wall_representation.py @@ -0,0 +1,583 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + +import numpy as np +import ifcopenshell +import ifcopenshell.api.geometry +import ifcopenshell.util.shape_builder +import ifcopenshell.util.element +import ifcopenshell.util.unit +from collections import namedtuple +from math import sin, cos +from typing import Optional + +# https://stackoverflow.com/a/9184560/9627415 +# Possible optimisation to linalg.norm? + +PrioritisedLayer = namedtuple("PrioritisedLayer", "priority thickness") + + +def regenerate_wall_representation( + file: ifcopenshell.file, + wall: ifcopenshell.entity_instance, + length: float = 1.0, + height: float = 1.0, + angle: Optional[float] = None, +) -> ifcopenshell.entity_instance: + """ + Regenerate the body representation of a wall taking into account connections. + + IFC defines how a standard (case) wall should behave that has a material + layer set and connections to other walls using IfcRelConnectsPathElements. + This function will regenerate the body geometry of a wall taking into + account the notches, butts, mitres, etc in the wall due to connections with + other walls. + + A standard wall has a 2D axis line as well as parameters defined in terms + of layer thicknesses and priorities. The body geometry is defined as a 2D + XY profile which is extruded in the +Z direction. For this function to + work, a wall must have these defined and the project must have an axis and + body representation context. + + For non-sloped walls, a 2D profile is generated and extruded in the +Z + direction. The profile may be a composite profile, if the wall is split due + to wall joins along the path of the wall that protrude all the way through + the wall. + + For sloped walls, a basic rectangular 2D profile is extruded, and then + additional extrusions are generated for each connection that boolean + difference the base extrusion. + + :param wall: The IfcWall for the representation, + only Model/Body/MODEL_VIEW type of representations are currently supported. + :param length: If the wall doesn't have an axis length, this is the default + length in SI units. + :param height: If the wall doesn't already have a height, this is the + default height in SI units. + :param angle: If the wall doesn't already have a slope, this is the default + angle in radians. Left as none or 0 defines no slope. + :return: The newly generated body IfcShapeRepresentation + """ + return Regenerator(file).regenerate(wall, length=length, height=height, angle=angle) + + +class Regenerator: + def __init__(self, file): + self.file = file + self.body = ifcopenshell.util.representation.get_context(file, "Model", "Body", "MODEL_VIEW") + self.axis = ifcopenshell.util.representation.get_context(file, "Plan", "Axis", "GRAPH_VIEW") + self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file) + self.is_angled = False + + def regenerate(self, wall, length=1.0, height=1.0, angle=None): + print("-" * 100) + print(wall) + self.fallback_length = length / self.unit_scale + self.fallback_height = height / self.unit_scale + self.fallback_angle = angle + layers = self.get_layers(wall) + if not layers: + return + reference = self.get_reference_line(wall) + self.reference_p1, self.reference_p2 = reference + self.wall_vectors = self.get_wall_vectors(wall) + axes = self.get_axes(wall, reference, layers, self.wall_vectors["a"]) + self.miny = axes[0][0][1] + self.maxy = axes[-1][0][1] + self.end_point = None + self.start_points = [] + self.start_vector = np.array((0.0, 0.0, 1.0)) + self.start_offset = 0.0 + self.atpath_points = [] + self.split_points = [] + self.maxpath_points = [] + self.minpath_points = [] + self.end_points = [] + self.end_vector = np.array((0.0, 0.0, 1.0)) + self.end_offset = 0.0 + for rel in wall.ConnectedTo: + if rel.is_a("IfcRelConnectsPathElements"): + wall2 = rel.RelatedElement + layers1 = self.combine_layers(layers.copy(), rel.RelatingPriorities) + layers2 = self.combine_layers(self.get_layers(wall2), rel.RelatedPriorities) + if not layers1 or not layers2: + continue + self.join(wall, wall2, layers1, layers2, rel.RelatingConnectionType, rel.RelatedConnectionType) + + for rel in wall.ConnectedFrom: + if rel.is_a("IfcRelConnectsPathElements"): + wall2 = rel.RelatingElement + layers1 = self.combine_layers(layers.copy(), rel.RelatedPriorities) + layers2 = self.combine_layers(self.get_layers(wall2), rel.RelatingPriorities) + if not layers1 or not layers2: + continue + self.join(wall, wall2, layers1, layers2, rel.RelatedConnectionType, rel.RelatingConnectionType) + + if not self.start_points: + minx = axes[0][0][0] + self.start_points = [ + np.array((minx, axes[0][0][1])), + np.array((minx, axes[-1][0][1])), + ] + if not self.end_points: + maxx = axes[0][1][0] + self.end_points = [ + np.array((maxx, axes[0][0][1])), + np.array((maxx, axes[-1][0][1])), + ] + print("FINISHED") + print(self.start_points) + print(self.end_points) + + if self.start_points[0][1] > self.start_points[-1][1]: # Canonicalise to the +Y direction + self.start_points.reverse() + if self.end_points[0][1] > self.end_points[-1][1]: # Canonicalise to the +Y direction + self.end_points.reverse() + + builder = ifcopenshell.util.shape_builder.ShapeBuilder(self.file) + + if self.is_angled: + start_points = [p.copy() for p in self.start_points] + end_points = [p.copy() for p in self.end_points] + if self.end_offset > 0: + for point in end_points: + point[0] += self.end_offset + if self.start_offset < 0: + for point in start_points: + point[0] += self.start_offset + points = [] + points.extend(start_points) + end_points.reverse() + points.extend(end_points) + item = builder.extrude( + builder.polyline(points, closed=True), + magnitude=self.wall_vectors["d"], + extrusion_vector=self.wall_vectors["z"], + ) + + operands = [] + if not np.allclose(self.start_vector, np.array((0.0, 0.0, 1.0))): + points = self.start_points.copy() + while ifcopenshell.util.shape_builder.is_x(points[0][1], points[1][1]): + points.pop(0) + while ifcopenshell.util.shape_builder.is_x(points[-1][1], points[-2][1]): + points.pop() + newx = min([p[0] for p in points]) - abs(self.start_offset) + p1 = points[-1].copy() + p1[0] = newx + p2 = p1.copy() + p2[1] = points[0][1] + points.extend((p1, p2)) + magnitude = np.linalg.norm(self.start_vector * (self.wall_vectors["h"] / self.start_vector[2])) + operands.append( + builder.extrude( + builder.polyline(points, closed=True), magnitude=magnitude, extrusion_vector=self.start_vector + ) + ) + + if not np.allclose(self.end_vector, np.array((0.0, 0.0, 1.0))): + points = self.end_points.copy() + while ifcopenshell.util.shape_builder.is_x(points[0][1], points[1][1]): + points.pop(0) + while ifcopenshell.util.shape_builder.is_x(points[-1][1], points[-2][1]): + points.pop() + + newx = max([p[0] for p in points]) + abs(self.end_offset) + p1 = points[-1].copy() + p1[0] = newx + p2 = p1.copy() + p2[1] = points[0][1] + points.extend((p1, p2)) + magnitude = np.linalg.norm(self.end_vector * (self.wall_vectors["h"] / self.end_vector[2])) + operands.append( + builder.extrude( + builder.polyline(points, closed=True), magnitude=magnitude, extrusion_vector=self.end_vector + ) + ) + + for atpath_vector, points in self.atpath_points: + if len(points) <= 2: + continue + magnitude = np.linalg.norm(atpath_vector * (self.wall_vectors["h"] / atpath_vector[2])) + operands.append( + builder.extrude( + builder.polyline(points, closed=True), magnitude=magnitude, extrusion_vector=atpath_vector + ) + ) + + if operands: + item = ifcopenshell.api.geometry.add_boolean(self.file, first_item=item, second_items=operands)[-1] + else: + # A wall footprint may be multiple profiles if the wall is split into two due to an ATPATH connection + profiles = [] + split_points = sorted(self.split_points, key=lambda x: x[0][0]) # Sort islands in the +X direction + start_points = [p.copy() for p in self.start_points] + end_points = [p.copy() for p in self.end_points] + split_points.insert(0, start_points) + split_points.append(end_points) + split_points = iter(split_points) + + while True: + # Draw each profile as clockwise starting from (minx, miny) + start_split = next(split_points, None) + if not start_split: + break + end_split = next(split_points, None) + if not end_split: + break + maxy_minx = start_split[-1][0] + maxy_maxx = end_split[-1][0] + miny_minx = start_split[0][0] + miny_maxx = end_split[0][0] + # Do more defensive checks here + points = start_split + + remaining_path_points = [] + for maxpath_points in self.maxpath_points: + if maxpath_points[0][0] > maxy_minx and maxpath_points[-1][0] < maxy_maxx: + points.extend(maxpath_points) + else: + remaining_path_points.append(maxpath_points) + self.maxpath_points = remaining_path_points + + points.extend(end_split[::-1]) + + remaining_path_points = [] + for minpath_points in self.minpath_points: + if minpath_points[0][0] < miny_maxx and minpath_points[-1][0] > miny_minx: + points.extend(minpath_points) + else: + remaining_path_points.append(minpath_points) + self.minpath_points = remaining_path_points + + profiles.append(builder.profile(builder.polyline(points, closed=True))) + + for points in self.maxpath_points + self.minpath_points: + profiles.append(builder.profile(builder.polyline(points, closed=True))) + + if len(profiles) > 1: + profile = self.file.createIfcCompositeProfileDef("AREA", Profiles=profiles) + else: + profile = profiles[0] + + item = builder.extrude(profile, magnitude=self.wall_vectors["d"], extrusion_vector=self.wall_vectors["z"]) + body_rep = builder.get_representation(self.body, items=[item]) + if old_rep := ifcopenshell.util.representation.get_representation(wall, self.body): + ifcopenshell.util.element.replace_element(old_rep, body_rep) + ifcopenshell.util.element.remove_deep2(self.file, old_rep) + else: + ifcopenshell.api.geometry.assign_representation(self.file, product=wall, representation=body_rep) + + item = builder.polyline([self.reference_p1, self.reference_p2]) + axis_rep = builder.get_representation(self.axis, items=[item]) + if old_rep := ifcopenshell.util.representation.get_representation(wall, self.axis): + ifcopenshell.util.element.replace_element(old_rep, axis_rep) + ifcopenshell.util.element.remove_deep2(self.file, old_rep) + else: + ifcopenshell.api.geometry.assign_representation(self.file, product=wall, representation=axis_rep) + return body_rep + + def join(self, wall1, wall2, layers1, layers2, connection1, connection2): + if connection1 == "NOTDEFINED" or connection2 == "NOTDEFINED": + return + if connection1 == "ATPATH" and connection2 == "ATPATH": + return + print("joining", wall1, layers1, connection1) + print("to", wall2, layers2, connection2) + + reference1 = self.get_reference_line(wall1) + reference2 = self.get_reference_line(wall2) + wall_vectors2 = self.get_wall_vectors(wall2) + axes1 = self.get_axes(wall1, reference1, layers1, self.wall_vectors["a"]) + axes2 = self.get_axes(wall2, reference2, layers2, wall_vectors2["a"]) + matrix1i = np.linalg.inv(ifcopenshell.util.placement.get_local_placement(wall1.ObjectPlacement)) + matrix2 = ifcopenshell.util.placement.get_local_placement(wall2.ObjectPlacement) + print(axes1) + print(axes2) + + # Convert wall2 data to wall1 local coordinates + for axis in axes2: + axis[0] = (matrix1i @ matrix2 @ np.concatenate((axis[0], (0, 1))))[:2] + axis[1] = (matrix1i @ matrix2 @ np.concatenate((axis[1], (0, 1))))[:2] + reference2[0] = (matrix1i @ matrix2 @ np.concatenate((reference2[0], (0, 1))))[:2] + reference2[1] = (matrix1i @ matrix2 @ np.concatenate((reference2[1], (0, 1))))[:2] + wall_vectors2["z"] = (matrix1i @ matrix2 @ np.append(wall_vectors2["z"], 0.0))[:3] + wall_vectors2["y"] = (matrix1i @ matrix2 @ np.append(wall_vectors2["y"], 0.0))[:3] + + axis2 = axes2[0] # Take an arbitrary axis of wall2 + if ifcopenshell.util.shape_builder.is_x(axis2[0][1], axis2[1][1]): + return # Parallel + + # Sort axes from interior to exterior + if connection1 == "ATEND": + if axes2[0][0][0] > axes2[-1][0][0]: # We process layers in a +X direction + axes2 = list(reversed(axes2)) + layers2 = list(reversed(layers2)) + elif connection1 == "ATSTART": + if axes2[-1][0][0] > axes2[0][0][0]: # We process layers in a -X direction + axes2 = list(reversed(axes2)) + layers2 = list(reversed(layers2)) + + axis2 = axes2[0] # Take an arbitrary axis of wall2 + if connection2 == "ATSTART": + axis2 = [axis2[1], axis2[0]] # Flip direction so the axis "points" in the direction of join + if axis2[0][1] < axis2[1][1]: # Pointing +Y + if axes1[-1][0][1] < axes1[0][0][1]: # We process layers1 in a +Y direction + axes1 = list(reversed(axes1)) + layers1 = list(reversed(layers1)) + else: # Pointing -Y + if axes1[0][0][1] < axes1[-1][0][1]: # We process layers1 in a -Y direction + axes1 = list(reversed(axes1)) + layers1 = list(reversed(layers1)) + + print("modified") + print(axes1) + print(axes2) + if connection1 == "ATPATH": + first_axis2 = axes2[0] + last_axis2 = axes2[-1] + first_y = axes1[0][0][1] + last_y = axes1[-1][0][1] + p0 = np.array((ifcopenshell.util.shape_builder.intersect_x_axis_2d(*first_axis2, y=first_y), first_y)) + pN = np.array((ifcopenshell.util.shape_builder.intersect_x_axis_2d(*last_axis2, y=first_y), first_y)) + + # Generate CurveOnRelating/RelatedElement + points = [p0] + axes2 = iter(axes2) + axis2 = next(axes2) + for layer2 in layers2: + ys = iter([a[0][1] for a in axes1]) + y = next(ys) + for layer1 in layers1: + if layer2.priority <= layer1.priority: + break + y = next(ys) + p1 = np.array((ifcopenshell.util.shape_builder.intersect_x_axis_2d(*axis2, y=y), y)) + axis2 = next(axes2) + p2 = np.array((ifcopenshell.util.shape_builder.intersect_x_axis_2d(*axis2, y=y), y)) + if points and np.allclose(points[-1], p1): + points[-1] = p2 # Just slide along previous point + else: + points.extend((p1, p2)) + + # The curve must end at pN + if not np.allclose(points[-1], pN): + points.append(pN) + + # Categorise our points into a segment that either splits or cuts the wall + split_ys = {first_y, last_y} + segment = [] + atpath_vector = self.get_join_vector(self.wall_vectors["y"], wall_vectors2["y"]) + self.atpath_points.append((atpath_vector, points)) + for point in points: + segment.append(point) + if len(segment) == 1: # Not enough points to categorise the segment + continue + elif {segment[0][1], segment[-1][1]} == split_ys: # This segment splits the wall + if segment[0][1] > segment[-1][1]: # Go in the +Y direction + segment.reverse() + self.split_points.append(segment) + segment = [] + elif segment[0][1] == segment[-1][1]: # This segment cuts some of the wall + if segment[0][1] == self.maxy: # Go in the +X direction + if segment[0][0] > segment[-1][0]: + segment.reverse() + self.maxpath_points.append(segment) + elif segment[0][1] == self.miny: # Go in the -X direction + if segment[-1][0] > segment[0][0]: + segment.reverse() + self.minpath_points.append(segment) + segment = [] + elif connection2 == "ATPATH": + points = [] + ys = iter([a[0][1] for a in axes1]) + y = next(ys) + for layer1 in layers1: + axes2_iter = iter(axes2) + axis2 = next(axes2_iter) + for layer2 in layers2: + if layer1.priority <= layer2.priority: + break + axis2 = next(axes2_iter) + x = ifcopenshell.util.shape_builder.intersect_x_axis_2d(*axis2, y=y) + p1 = np.array((x, y)) + y = next(ys) + x = ifcopenshell.util.shape_builder.intersect_x_axis_2d(*axis2, y=y) + p2 = np.array((x, y)) + if points and np.allclose(points[-1], p1): + points.append(p2) + else: + points.extend((p1, p2)) + + if connection1 == "ATSTART": + self.start_points = points + self.start_vector = self.get_join_vector(self.wall_vectors["y"], wall_vectors2["y"]) + self.start_offset = (self.start_vector * (self.wall_vectors["h"] / self.start_vector[2]))[0] + self.reference_p1[0] = ifcopenshell.util.shape_builder.intersect_x_axis_2d( + *reference2, y=reference1[0][1] + ) + elif connection1 == "ATEND": + self.end_points = points + self.end_vector = self.get_join_vector(self.wall_vectors["y"], wall_vectors2["y"]) + self.end_offset = (self.end_vector * (self.wall_vectors["h"] / self.end_vector[2]))[0] + self.reference_p2[0] = ifcopenshell.util.shape_builder.intersect_x_axis_2d( + *reference2, y=reference1[0][1] + ) + else: # A connection at either end of both walls + last_y = axes1[-1][0][1] + ys = iter([a[0][1] for a in axes1]) + + last_axis2 = axes2[-1] + axes2 = iter(axes2) + axis2 = next(axes2) + y = next(ys) + x = ifcopenshell.util.shape_builder.intersect_x_axis_2d(*axis2, y=y) + points = [np.array((x, y))] + + layers1 = iter(layers1) + layers2 = iter(layers2) + layer1 = next(layers1, None) + layer2 = next(layers2, None) + + # This creates "mitering" behaviour which is an ambiguity by bSI. + while layer1 and layer2: + print("considering", layer1, layer2) + if layer1.priority > layer2.priority: + axis2 = next(axes2) + x = ifcopenshell.util.shape_builder.intersect_x_axis_2d(*axis2, y=y) + layer2 = next(layers2, None) + elif layer2.priority > layer1.priority: + y = next(ys) + x = ifcopenshell.util.shape_builder.intersect_x_axis_2d(*axis2, y=y) + layer1 = next(layers1, None) + else: + y = next(ys) + x = ifcopenshell.util.shape_builder.intersect_x_axis_2d(*next(axes2), y=y) + layer1 = next(layers1, None) + layer2 = next(layers2, None) + points.append(np.array((x, y))) + + print("points", points) + if points[-1][1] != last_y: + points.append( + np.array((ifcopenshell.util.shape_builder.intersect_x_axis_2d(*last_axis2, y=last_y), last_y)) + ) + + if connection1 == "ATSTART": + self.start_points = points + self.start_vector = self.get_join_vector(self.wall_vectors["y"], wall_vectors2["y"]) + self.start_offset = (self.start_vector * (self.wall_vectors["h"] / self.start_vector[2]))[0] + self.reference_p1[0] = ifcopenshell.util.shape_builder.intersect_x_axis_2d( + *reference2, y=reference1[0][1] + ) + elif connection1 == "ATEND": + self.end_points = points + self.end_vector = self.get_join_vector(self.wall_vectors["y"], wall_vectors2["y"]) + self.end_offset = (self.end_vector * (self.wall_vectors["h"] / self.end_vector[2]))[0] + self.reference_p2[0] = ifcopenshell.util.shape_builder.intersect_x_axis_2d( + *reference2, y=reference1[0][1] + ) + + def get_layers(self, wall) -> list: + material = ifcopenshell.util.element.get_material(wall, should_skip_usage=True) + if not material or not material.is_a("IfcMaterialLayerSet"): + return [] + return [PrioritisedLayer(l.Priority or 0, l.LayerThickness) for l in material.MaterialLayers] + + def combine_layers(self, layers, override_priorities): + results = [] + if override_priorities: + for i, priority in enumerate(override_priorities[: len(layers)]): + layers[i][0] = priority + if not layers: + return [] + results = [layers.pop(0)] + for layer in layers: + if not layer.thickness: + continue + if layer.priority == results[-1].priority: + results[-1] = PrioritisedLayer(layer.priority, results[-1].thickness + layer.thickness) + else: + results.append(layer) + return results + + def get_reference_line(self, wall): + if axis := ifcopenshell.util.representation.get_representation(wall, "Plan", "Axis", "GRAPH_VIEW"): + for item in ifcopenshell.util.representation.resolve_representation(axis).Items: + if item.is_a("IfcPolyline"): + points = item.Points + elif item.is_a("IfcIndexedPolyCurve"): + points = item.Points.CoordList + else: + continue + if points[0][0] < points[1][0]: # An axis always goes in the +X direction + return [np.array(points[0]), np.array(points[1])] + return [np.array(points[1]), np.array(points[0])] + return [np.array((0.0, 0.0)), np.array((self.fallback_length, 0.0))] + + def get_wall_vectors(self, wall): + if body := ifcopenshell.util.representation.get_representation(wall, "Model", "Body", "MODEL_VIEW"): + for item in ifcopenshell.util.representation.resolve_representation(body).Items: + while item.is_a("IfcBooleanResult"): + item = item.FirstOperand + if item.is_a("IfcExtrudedAreaSolid"): + z = np.array(item.ExtrudedDirection.DirectionRatios) + z /= np.linalg.norm(z) + y = np.cross(z, np.array((1.0, 0.0, 0.0))) + d = item.Depth + h = (z * d)[2] + a = ifcopenshell.util.shape_builder.np_angle_signed(np.array((0.0, 1.0)), z[1:]) + if not ifcopenshell.util.shape_builder.is_x(a, 0): + self.is_angled = True + return {"z": z, "y": y, "a": a, "d": d, "h": h} + elif self.fallback_angle: + a = self.fallback_angle + z = np.array([0.0, sin(a), cos(a)]) + y = np.cross(z, np.array((1.0, 0.0, 0.0))) + h = self.fallback_height + d = np.linalg.norm(z * (h / z[2])) + if not ifcopenshell.util.shape_builder.is_x(a, 0): + self.is_angled = True + return {"z": z, "y": y, "a": a, "d": d, "h": h} + return { + "z": np.array((0.0, 0.0, 1.0)), + "y": np.array((0.0, 1.0, 0.0)), + "a": 0.0, + "d": self.fallback_height, + "h": self.fallback_height, + } + + def get_join_vector(self, y1, y2): + result = np.cross(y1, y2) + if result[2] < 0: + return result * -1 + return result + + def get_axes(self, wall, reference, layers: list[PrioritisedLayer], angle: float): + axes = [[p.copy() for p in reference]] + # Apply usage to convert the Reference line into MlsBase + sense_factor = 1 + if (usage := ifcopenshell.util.element.get_material(wall)) and usage.is_a("IfcMaterialLayerSetUage"): + for point in axes[0]: + point[1] += usage.OffsetFromReferenceLine + sense_factor = 1 if usage.DirectionSense == "POSITIVE" else -1 + + for layer in layers: + y_offset = (layer.thickness * sense_factor) / cos(angle) + axes.append([p.copy() + np.array((0.0, y_offset)) for p in axes[-1]]) + return axes