add_railing_representation - remove mathutils dependency #5192

This commit is contained in:
Andrej730
2024-12-17 18:05:14 +05:00
parent f22636d880
commit d6e6a6d559
@@ -16,14 +16,24 @@
# You should have received a copy of the GNU Lesser General Public License # You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>. # along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import numpy as np
import ifcopenshell.util.unit import ifcopenshell.util.unit
from ifcopenshell.util.shape_builder import ShapeBuilder, V from ifcopenshell.util.shape_builder import (
from itertools import chain ShapeBuilder,
from mathutils import Vector, Matrix V,
import collections SequenceOfVectors,
import mathutils is_x,
np_angle,
np_angle_signed,
np_normalized,
np_to_3d,
np_normal,
np_intersect_line_line,
np_lerp,
)
from math import pi, cos, sin, tan, radians from math import pi, cos, sin, tan, radians
from typing import Literal, Optional, Any from typing import Literal, Optional, Any
from typing_extensions import assert_never
def mm(x: float) -> float: def mm(x: float) -> float:
@@ -31,24 +41,27 @@ def mm(x: float) -> float:
return x / 1000 return x / 1000
TERMINAL_TYPE = Literal[
"180",
"TO_END_POST",
"TO_WALL",
"TO_FLOOR",
"TO_END_POST_AND_FLOOR",
]
def add_railing_representation( def add_railing_representation(
file: ifcopenshell.file, file: ifcopenshell.file,
*, # keywords only as this API implementation is probably not final *, # keywords only as this API implementation is probably not final
# IfcGeometricRepresentationContext # IfcGeometricRepresentationContext
context: ifcopenshell.entity_instance, context: ifcopenshell.entity_instance,
railing_type: Literal["WALL_MOUNTED_HANDRAIL"] = "WALL_MOUNTED_HANDRAIL", railing_type: Literal["WALL_MOUNTED_HANDRAIL"] = "WALL_MOUNTED_HANDRAIL",
railing_path: list[Vector], railing_path: SequenceOfVectors,
use_manual_supports: bool = False, use_manual_supports: bool = False,
support_spacing: Optional[float] = None, support_spacing: Optional[float] = None,
railing_diameter: Optional[float] = None, railing_diameter: Optional[float] = None,
clear_width: Optional[float] = None, clear_width: Optional[float] = None,
terminal_type: Literal[ terminal_type: TERMINAL_TYPE = "180",
"180",
"TO_END_POST",
"TO_WALL",
"TO_FLOOR",
"TO_END_POST_AND_FLOOR",
] = "180",
height: Optional[float] = None, height: Optional[float] = None,
looped_path: bool = False, looped_path: bool = False,
unit_scale: Optional[float] = None, unit_scale: Optional[float] = None,
@@ -57,35 +70,22 @@ def add_railing_representation(
Units are expected to be in IFC project units. Units are expected to be in IFC project units.
:param context: IfcGeometricRepresentationContext for the representation. :param context: IfcGeometricRepresentationContext for the representation.
:type context: ifcopenshell.entity_instance
:param railing_type: Type of the railing. Defaults to "WALL_MOUNTED_HANDRAIL". :param railing_type: Type of the railing. Defaults to "WALL_MOUNTED_HANDRAIL".
:type railing_type: Literal["WALL_MOUNTED_HANDRAIL"], optional
:param railing_path: A list of points coordinates for the railing path, :param railing_path: A list of points coordinates for the railing path,
coordinates are expected to be at the top of the railing, not at the center. coordinates are expected to be at the top of the railing, not at the center.
If not provided, default path [(0, 0, 1), (1, 0, 1), (2, 0, 1)] (in meters) will be used If not provided, default path [(0, 0, 1), (1, 0, 1), (2, 0, 1)] (in meters) will be used
:type railing_path: list[Vector], optional.
:param use_manual_supports: If enabled, supports are added on every vertex on the edges of the railing path. :param use_manual_supports: If enabled, supports are added on every vertex on the edges of the railing path.
If disabled, supports are added automatically based on the support spacing. Default to False. If disabled, supports are added automatically based on the support spacing. Default to False.
:type use_manual_supports: bool, optional
:param support_spacing: Distance between supports if automatic supports are used. Defaults to 1m. :param support_spacing: Distance between supports if automatic supports are used. Defaults to 1m.
:type support_spacing: float, optional
:param railing_diameter: Railing diameter. Defaults to 50mm. :param railing_diameter: Railing diameter. Defaults to 50mm.
:type railing_diameter: float, optional
:param clear_width: Clear width between the railing and the wall. Defaults to 40mm. :param clear_width: Clear width between the railing and the wall. Defaults to 40mm.
:type clear_width: float, optional
:param terminal_type: type of the cap. Defaults to "180". :param terminal_type: type of the cap. Defaults to "180".
:type terminal_type: Literal["180","TO_END_POST","TO_WALL","TO_FLOOR","TO_END_POST_AND_FLOOR"], optional
:param height: defaults to 1m :param height: defaults to 1m
:type height: float, optional
:param looped_path: Whether to end the railing on the first point of `railing_path`. Defaults to False. :param looped_path: Whether to end the railing on the first point of `railing_path`. Defaults to False.
:type looped_path: bool, optional
:param unit_scale: The unit scale as calculated by :param unit_scale: The unit scale as calculated by
ifcopenshell.util.unit.calculate_unit_scale. If not provided, it ifcopenshell.util.unit.calculate_unit_scale. If not provided, it
will be automatically calculated for you. will be automatically calculated for you.
:type unit_scale: float, optional
:return: IfcShapeRepresentation for a railing. :return: IfcShapeRepresentation for a railing.
:rtype: ifcopenshell.entity_instance
""" """
usecase = Usecase() usecase = Usecase()
usecase.file = file usecase.file = file
@@ -100,7 +100,7 @@ def add_railing_representation(
"railing_path": ( "railing_path": (
railing_path railing_path
if railing_path is not None if railing_path is not None
else usecase.path_si_to_units([V(0, 0, 1), V(1, 0, 1), V(2, 0, 1)]) else usecase.path_si_to_units(V([(0, 0, 1), (1, 0, 1), (2, 0, 1)]))
), ),
"use_manual_supports": use_manual_supports, "use_manual_supports": use_manual_supports,
"support_spacing": support_spacing if support_spacing is not None else usecase.convert_si_to_unit(mm(1000)), "support_spacing": support_spacing if support_spacing is not None else usecase.convert_si_to_unit(mm(1000)),
@@ -121,25 +121,29 @@ def add_railing_representation(
class Usecase: class Usecase:
file: ifcopenshell.file
settings: dict[str, Any]
def execute(self): def execute(self):
arc_points = [] arc_points: list[np.ndarray] = []
items_3d = [] items_3d: list[ifcopenshell.entity_instance] = []
builder = ShapeBuilder(self.file) builder = ShapeBuilder(self.file)
z_down = V(0, 0, -1) z_down = V(0, 0, -1)
# measurements # measurements
# from settings # from settings
use_manual_supports = self.settings["use_manual_supports"] use_manual_supports: bool = self.settings["use_manual_supports"]
railing_radius = self.settings["railing_diameter"] / 2 railing_radius: float = self.settings["railing_diameter"] / 2
support_spacing = self.settings["support_spacing"] support_spacing: float = self.settings["support_spacing"]
clear_width = self.settings["clear_width"] clear_width: float = self.settings["clear_width"]
# for calculations purposes we use height without railing radius # for calculations purposes we use height without railing radius
height = self.settings["height"] - railing_radius height: float = self.settings["height"] - railing_radius
cap_type = self.settings["terminal_type"] cap_type: TERMINAL_TYPE = self.settings["terminal_type"]
ifc_context = self.settings["context"] ifc_context: ifcopenshell.entity_instance = self.settings["context"]
railing_coords = self.settings["railing_path"] railing_coords: SequenceOfVectors = self.settings["railing_path"]
looped_path = self.settings["looped_path"] looped_path: bool = self.settings["looped_path"]
railing_coords = [p - z_down * railing_radius for p in railing_coords] railing_coords: np.ndarray
railing_coords = np.subtract(railing_coords, z_down * railing_radius)
# constant # constant
terminal_radius = self.convert_si_to_unit(mm(150)) terminal_radius = self.convert_si_to_unit(mm(150))
@@ -150,59 +154,70 @@ class Usecase:
support_disk_depth = self.convert_si_to_unit(mm(20)) support_disk_depth = self.convert_si_to_unit(mm(20))
# util functions # util functions
float_is_zero = lambda f: 0.0001 >= f >= -0.0001 def collinear(d0: np.ndarray, d1: np.ndarray) -> bool:
collinear = lambda d0, d1: float_is_zero(d0.angle(d1)) return is_x(np_angle(d0, d1), 0)
def add_support_on_point(point, railing_direction): np_Z = 2
np_XY = slice(2)
np_YX = [1, 0]
def add_support_on_point(
point: np.ndarray, railing_direction: np.ndarray
) -> tuple[ifcopenshell.entity_instance, ...]:
"""create a support arc and a disk based on the position and direction of the railing""" """create a support arc and a disk based on the position and direction of the railing"""
ortho_dir = (railing_direction.yx * V(1, -1)).to_3d().normalized() ortho_dir = railing_direction[np_YX] * (1, -1)
ortho_dir = np_normalized(np_to_3d(ortho_dir))
arc_center = point + ortho_dir * support_length arc_center = point + ortho_dir * support_length
support_points = [ support_points: list[np.ndarray] = [
point, point,
arc_center - ortho_dir * support_length * cos(pi / 4) + z_down * support_length * sin(pi / 4), arc_center - ortho_dir * support_length * cos(pi / 4) + z_down * support_length * sin(pi / 4),
arc_center + z_down * support_length, arc_center + z_down * support_length,
] ]
polyline = builder.polyline(support_points, closed=False, arc_points=[1]) polyline = builder.polyline(support_points, closed=False, arc_points=(1,))
solid = builder.create_swept_disk_solid(polyline, support_radius) solid = builder.create_swept_disk_solid(polyline, support_radius)
support_disk_circle = builder.circle(radius=support_disk_radius) support_disk_circle = builder.circle(radius=support_disk_radius)
angle = V(0, 1).angle_signed(ortho_dir.xy) angle = np_angle_signed((0, 1), ortho_dir[np_XY])
y_extrusion_kwargs = builder.rotate_extrusion_kwargs_by_z(builder.extrude_kwargs("Y"), angle) y_extrusion_kwargs = builder.rotate_extrusion_kwargs_by_z(builder.extrude_kwargs("Y"), angle)
support_disk = builder.extrude( support_disk = builder.extrude(
support_disk_circle, support_disk_depth, position=support_points[-1], **y_extrusion_kwargs support_disk_circle, support_disk_depth, position=support_points[-1], **y_extrusion_kwargs
) )
return [solid, support_disk] return (solid, support_disk)
def get_fillet_points(v0, v1, v2, radius): def get_fillet_points(v0: np.ndarray, v1: np.ndarray, v2: np.ndarray, radius: float) -> list[np.ndarray]:
"""get fillet points between edges v0v1 and v1v2""" """get fillet points between edges v0v1 and v1v2"""
dir1 = (v0 - v1).normalized() dir1 = np_normalized(v0 - v1)
dir2 = (v2 - v1).normalized() dir2 = np_normalized(v2 - v1)
edge_angle = dir1.angle(dir2) edge_angle = np_angle(dir1, dir2)
slide_distance = radius / tan(edge_angle / 2) slide_distance = radius / tan(edge_angle / 2)
fillet_v1co = v1 + (dir1 * slide_distance) fillet_v1co = v1 + (dir1 * slide_distance)
fillet_v2co = v1 + (dir2 * slide_distance) fillet_v2co = v1 + (dir2 * slide_distance)
normal = mathutils.geometry.normal([v0, v1, v2]) normal = np_normal([v0, v1, v2])
center = mathutils.geometry.intersect_line_line( center = np_intersect_line_line(
fillet_v1co, fillet_v1co + normal.cross(dir1), fillet_v2co, fillet_v2co + normal.cross(dir2) fillet_v1co,
fillet_v1co + np.cross(normal, dir1),
fillet_v2co,
fillet_v2co + np.cross(normal, dir2),
)[0] )[0]
midpointco = center + ((fillet_v1co.lerp(fillet_v2co, 0.5) - center).normalized() * radius) dir_ = np_normalized(np_lerp(fillet_v1co, fillet_v2co, 0.5) - center)
midpointco = center + dir_ * radius
return [fillet_v1co, midpointco, fillet_v2co] return [fillet_v1co, midpointco, fillet_v2co]
def add_arcs_on_turnings_points(base_points): def add_arcs_on_turnings_points(base_points: np.ndarray) -> np.ndarray:
"""add 3 point fillet arcs on turning points of the railing path""" """add 3 point fillet arcs on turning points of the railing path"""
if len(base_points) < 3: if len(base_points) < 3:
return base_points return base_points
# looking for turning points by checking non-collinear edges # looking for turning points by checking non-collinear edges
output_points = base_points[:1] output_points: list[np.ndarray] = list(base_points[:1])
prev_dir = (base_points[1] - base_points[0]).normalized() prev_dir = np_normalized(base_points[1] - base_points[0])
i = 1 i = 1
while i < len(base_points) - 1: while i < len(base_points) - 1:
cur_dir = (base_points[i + 1] - base_points[i]).normalized() cur_dir = np_normalized(base_points[i + 1] - base_points[i])
if collinear(cur_dir, prev_dir): if collinear(cur_dir, prev_dir):
output_points.append(base_points[i]) output_points.append(base_points[i])
@@ -220,19 +235,21 @@ class Usecase:
output_points[0] = output_points[-1] output_points[0] = output_points[-1]
else: else:
output_points.append(base_points[-1]) output_points.append(base_points[-1])
return output_points return V(output_points)
def create_supports_items(railing_coords, manual_supports=False): def create_supports_items(
railing_coords: np.ndarray, manual_supports: bool = False
) -> list[ifcopenshell.entity_instance]:
"""create supports items based on the railing coordinates""" """create supports items based on the railing coordinates"""
supports_items = [] supports_items: list[ifcopenshell.entity_instance] = []
# simplified_coords is a list of points that form non-collinear edges # simplified_coords is a list of points that form non-collinear edges
simplified_coords = [railing_coords[0]] simplified_coords: list[np.ndarray] = [railing_coords[0]]
prev_dir = (railing_coords[1] - railing_coords[0]).normalized() prev_dir = np_normalized(railing_coords[1] - railing_coords[0])
# iterating over each edge of the railing path # iterating over each edge of the railing path
for i in range(1, len(railing_coords) - 1): for i in range(1, len(railing_coords) - 1):
cur_dir = (railing_coords[i + 1] - railing_coords[i]).normalized() cur_dir = np_normalized(railing_coords[i + 1] - railing_coords[i])
if not collinear(cur_dir, prev_dir): if not collinear(cur_dir, prev_dir):
simplified_coords.append(railing_coords[i]) simplified_coords.append(railing_coords[i])
@@ -252,8 +269,8 @@ class Usecase:
for i in range(0, len(simplified_coords) - 1): for i in range(0, len(simplified_coords) - 1):
v0, v1 = simplified_coords[i : i + 2] v0, v1 = simplified_coords[i : i + 2]
edge = v1 - v0 edge = v1 - v0
length = edge.length length: float = np.linalg.norm(edge)
edge_dir = edge.normalized() edge_dir = np_normalized(edge)
n_supports, support_offset = divmod(length, support_spacing) n_supports, support_offset = divmod(length, support_spacing)
n_supports = int(n_supports) + 1 n_supports = int(n_supports) + 1
support_offset /= 2 support_offset /= 2
@@ -265,14 +282,17 @@ class Usecase:
return supports_items return supports_items
def add_cap(railing_coords, arc_points, start=False): def add_cap(railing_coords: np.ndarray, arc_points: list[np.ndarray], start: bool = False):
"""add handrail terminal cap""" """add handrail terminal cap"""
railing_coords_for_cap = railing_coords[::-1] if start else railing_coords railing_coords_for_cap = railing_coords[::-1] if start else railing_coords
arc_points = arc_points[::-1] if start else arc_points
start_point = railing_coords_for_cap[-1] start_point: np.ndarray = railing_coords_for_cap[-1]
cap_dir = (railing_coords_for_cap[-1] - railing_coords_for_cap[-2]).to_3d().normalized() cap_dir = railing_coords_for_cap[-1] - railing_coords_for_cap[-2]
ortho_dir = (cap_dir.yx * V(1, -1)).to_3d().normalized() cap_dir = np_normalized(cap_dir)
local_z_down = cap_dir.cross(ortho_dir) ortho_dir = np_to_3d(cap_dir[np_YX] * (1, -1))
ortho_dir = np_normalized(ortho_dir)
local_z_down = np.cross(cap_dir, ortho_dir)
if start: if start:
ortho_dir = -ortho_dir ortho_dir = -ortho_dir
@@ -285,7 +305,7 @@ class Usecase:
if cap_type == "TO_END_POST": if cap_type == "TO_END_POST":
end_point = railing_coords_for_cap[-2].copy() end_point = railing_coords_for_cap[-2].copy()
end_point.z -= terminal_radius * 2 end_point[np_Z] -= terminal_radius * 2
cap_coords.append(end_point) cap_coords.append(end_point)
elif cap_type == "TO_WALL": elif cap_type == "TO_WALL":
@@ -319,17 +339,20 @@ class Usecase:
arc_points.append(first_arc_coords[1]) arc_points.append(first_arc_coords[1])
end_point = railing_coords_for_cap[-2].copy() end_point = railing_coords_for_cap[-2].copy()
end_point.z -= height end_point[np_Z] -= height
second_arc_coords = get_fillet_points( second_arc_coords = get_fillet_points(
first_arc_end, first_arc_end + local_z_down * terminal_radius, end_point, terminal_radius first_arc_end, first_arc_end + local_z_down * terminal_radius, end_point, terminal_radius
) )
arc_points.append(second_arc_coords[1]) arc_points.append(second_arc_coords[1])
cap_coords = [start_point] + first_arc_coords + second_arc_coords + [end_point] cap_coords = [start_point] + first_arc_coords + second_arc_coords + [end_point]
else:
assert_never(cap_type)
railing_coords = railing_coords_for_cap + cap_coords railing_coords = np.vstack((railing_coords_for_cap, cap_coords))
if start: if start:
railing_coords = railing_coords[::-1] railing_coords = railing_coords[::-1]
arc_points = arc_points[::-1]
return railing_coords, arc_points return railing_coords, arc_points
# need to add first two points to the path # need to add first two points to the path
@@ -344,17 +367,37 @@ class Usecase:
railing_coords, arc_points = add_cap(railing_coords, arc_points, start=True) railing_coords, arc_points = add_cap(railing_coords, arc_points, start=True)
railing_coords, arc_points = add_cap(railing_coords, arc_points, start=False) railing_coords, arc_points = add_cap(railing_coords, arc_points, start=False)
def get_arc_indices(points: np.ndarray, arc_points: list[np.ndarray]) -> list[int]:
points_ = points.copy()
arc_indices = []
i_base = 0
for arc_point in arc_points:
for i, point in enumerate(points_):
if np.allclose(arc_point, point):
current_index = i + i_base
arc_indices.append(current_index)
i_base = current_index + 1
break
else:
raise Exception(
f"Arc point '{arc_point}' is not present in points:\n{points_}\nFull points data:\n{points}"
)
points_ = points_[i + 1 :]
return arc_indices
railing_path = builder.polyline( railing_path = builder.polyline(
railing_coords, closed=False, arc_points=[railing_coords.index(p) for p in arc_points] railing_coords,
closed=False,
arc_points=get_arc_indices(railing_coords, arc_points),
) )
railing_solid = builder.create_swept_disk_solid(railing_path, railing_radius) railing_solid = builder.create_swept_disk_solid(railing_path, railing_radius)
items_3d.append(railing_solid) items_3d.append(railing_solid)
representation = builder.get_representation(ifc_context, items=items_3d) representation = builder.get_representation(ifc_context, items=items_3d)
return representation return representation
def convert_si_to_unit(self, value): def convert_si_to_unit(self, value: float) -> float:
return value / self.settings["unit_scale"] return value / self.settings["unit_scale"]
def path_si_to_units(self, path): def path_si_to_units(self, path: np.ndarray) -> np.ndarray:
"""converts list of vectors from SI to ifc project units""" """converts list of vectors from SI to ifc project units"""
return [self.convert_si_to_unit(v) for v in path] return path / self.settings["unit_scale"]