From 1dcf74eb6093de3c32704c88bd7e1bddcd362c0a Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Mon, 24 Apr 2023 10:57:56 +0500 Subject: [PATCH] WALL_MOUNTED_HANDRAIL type for Railing modifier Added WALL_MOUNTED_HANDRAIL type for railing modifier. It's still a bit work in progress - still need to add support for different railing termination types. Here's the short demonstration - https://user-images.githubusercontent.com/9417531/234582621-6c6f948b-9cdc-4be6-b380-17ddbf5803bf.mp4 --- .../blenderbim/bim/module/model/__init__.py | 1 + .../blenderbim/bim/module/model/prop.py | 48 +++- .../blenderbim/bim/module/model/railing.py | 224 ++++++++++++----- .../blenderbim/bim/module/model/ui.py | 5 +- .../blenderbim/bim/module/model/workspace.py | 35 +-- .../geometry/add_railing_representation.py | 238 ++++++++++++++++++ .../ifcopenshell/util/shape_builder.py | 59 +++-- 7 files changed, 510 insertions(+), 100 deletions(-) create mode 100644 src/ifcopenshell-python/ifcopenshell/api/geometry/add_railing_representation.py diff --git a/src/blenderbim/blenderbim/bim/module/model/__init__.py b/src/blenderbim/blenderbim/bim/module/model/__init__.py index 3fb2595179..f074ab1e2e 100644 --- a/src/blenderbim/blenderbim/bim/module/model/__init__.py +++ b/src/blenderbim/blenderbim/bim/module/model/__init__.py @@ -148,6 +148,7 @@ classes = ( railing.AddRailing, railing.CancelEditingRailing, railing.FinishEditingRailing, + railing.FlipRailingPathOrder, railing.EnableEditingRailing, railing.CancelEditingRailingPath, railing.FinishEditingRailingPath, diff --git a/src/blenderbim/blenderbim/bim/module/model/prop.py b/src/blenderbim/blenderbim/bim/module/model/prop.py index 80f4743e48..3987a8df13 100644 --- a/src/blenderbim/blenderbim/bim/module/model/prop.py +++ b/src/blenderbim/blenderbim/bim/module/model/prop.py @@ -494,7 +494,18 @@ class BIMDoorProperties(PropertyGroup): class BIMRailingProperties(PropertyGroup): - railing_types = (("FRAMELESS_PANEL", "FRAMELESS_PANEL", ""),) + railing_types = ( + ("FRAMELESS_PANEL", "FRAMELESS_PANEL", ""), + ("WALL_MOUNTED_HANDRAIL", "WALL_MOUNTED_HANDRAIL", ""), + ) + cap_types = ( + ("none", "none", ""), + ("180", "180", ""), + ("to_wall", "to_wall", ""), + ("to_floor", "to_floor", ""), + ("to_end_post", "to_end_post", ""), + ("to_end_post_and_floor", "to_end_post_and_floor", ""), + ) railing_added_previously: bpy.props.BoolProperty(default=False) is_editing: bpy.props.IntProperty(default=-1) @@ -505,13 +516,42 @@ class BIMRailingProperties(PropertyGroup): thickness: bpy.props.FloatProperty(name="Thickness", default=0.050) spacing: bpy.props.FloatProperty(name="Spacing", default=0.050) + # wall mounted handrail specific properties + use_manual_supports: bpy.props.BoolProperty( + name="Use Manual Supports", + default=False, + description="If enabled, supports are added on every vertex on the edges of the railing path.\n" + "If disabled, supports are added automatically based on the support spacing", + ) + support_spacing: bpy.props.FloatProperty( + name="Support Spacing", default=1.0, description="Distance between supports if automatic supports are used" + ) + railing_diameter: bpy.props.FloatProperty(name="Railing Diameter", default=0.050) + clear_width: bpy.props.FloatProperty( + name="Clear Width", default=0.040, description="Clear width between the railing and the wall" + ) + terminal_type: bpy.props.EnumProperty(name="Terminal Type", items=cap_types, default="180") + def get_general_kwargs(self): - return { + base_kwargs = { "railing_type": self.railing_type, "height": self.height, - "thickness": self.thickness, - "spacing": self.spacing, } + additional_kwargs = {} + if self.railing_type == "FRAMELESS_PANEL": + additional_kwargs = { + "thickness": self.thickness, + "spacing": self.spacing, + } + elif self.railing_type == "WALL_MOUNTED_HANDRAIL": + additional_kwargs = { + "railing_diameter": self.railing_diameter, + "clear_width": self.clear_width, + "use_manual_supports": self.use_manual_supports, + "support_spacing": self.support_spacing, + "terminal_type": self.terminal_type, + } + return base_kwargs | additional_kwargs class BIMRoofProperties(PropertyGroup): diff --git a/src/blenderbim/blenderbim/bim/module/model/railing.py b/src/blenderbim/blenderbim/bim/module/model/railing.py index 8a4625fa62..726c58c4f7 100644 --- a/src/blenderbim/blenderbim/bim/module/model/railing.py +++ b/src/blenderbim/blenderbim/bim/module/model/railing.py @@ -18,14 +18,11 @@ import bpy -from bpy.types import Operator import bmesh - import ifcopenshell from ifcopenshell.util.shape_builder import V import blenderbim import blenderbim.tool as tool -import blenderbim.core.geometry as core from blenderbim.bim.helper import convert_property_group_from_si from blenderbim.bim.ifc import IfcStore from blenderbim.bim.module.model.door import bm_sort_out_geom @@ -41,6 +38,15 @@ import json # https://ifc43-docs.standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/lexical/IfcRailingType.htm +NON_SI_RAILING_PROPS = ( + "is_editing", + "railing_type", + "railing_added_previously", + "use_manual_supports", + "terminal_type", +) + + def bm_split_edge_at_offset(edge, offset): v0, v1 = edge.verts @@ -82,7 +88,31 @@ def update_railing_modifier_ifc_data(context): "Height": props.height, }, ) - tool.Ifc.edit(obj) + + if props.railing_type == "WALL_MOUNTED_HANDRAIL": + body = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW") + railing_path = [Vector(v) for v in RailingData.data["parameters"]["data_dict"]["path_data"]["verts"]] + representation_data = { + "railing_type": props.railing_type, + "context": body, + "railing_path": railing_path, + "use_manual_supports": props.use_manual_supports, + "support_spacing": props.support_spacing, + "railing_diameter": props.railing_diameter, + "clear_width": props.clear_width, + "terminal_type": props.terminal_type, + "height": props.height, + } + model_representation = ifcopenshell.api.run( + "geometry.add_railing_representation", ifc_file, **representation_data + ) + tool.Model.replace_object_ifc_representation(body, obj, model_representation) + + # hacky way to ensure tha ifc representation won't get tessellated at project save + IfcStore.edited_objs.discard(obj) + + elif props.railing_type == "FRAMELESS_PANEL": + tool.Ifc.edit(obj) def update_bbim_railing_pset(element, railing_data): @@ -118,77 +148,114 @@ def update_railing_modifier_bmesh(context): tool.Blender.apply_bmesh(obj.data, bm) return - # generating the entire railing - height = props.height * si_conversion - thickness = props.thickness * si_conversion - spacing = props.spacing * si_conversion + if props.railing_type != "FRAMELESS_PANEL": + return - # spacing - # split each edge in 3 segments by 0.5 * spacing by x-y plane - main_edges = bm.edges[:] - for main_edge in main_edges: - bm_split_edge_at_offset(main_edge, spacing) + def generate_frameless_panel_railing(): + # generating FRAMELESS_PANEL railing + height = props.height * si_conversion + thickness = props.thickness * si_conversion + spacing = props.spacing * si_conversion - # thickness - # keep track of translated verts so we won't translate the same - # vert twice - edge_dissolving_verts = [] - for main_edge in main_edges: - v0, v1 = main_edge.verts - edge_dissolving_verts.extend([v0, v1]) + # spacing + # split each edge in 3 segments by 0.5 * spacing by x-y plane + main_edges = bm.edges[:] + for main_edge in main_edges: + bm_split_edge_at_offset(main_edge, spacing) - edge_dir = ((v1.co - v0.co) * V(1, 1, 0)).normalized() - ortho_vector = edge_dir.cross(V(0, 0, 1)) + # thickness + # keep track of translated verts so we won't translate the same + # vert twice + edge_dissolving_verts = [] + for main_edge in main_edges: + v0, v1 = main_edge.verts + edge_dissolving_verts.extend([v0, v1]) - extruded_geom = bmesh.ops.extrude_edge_only(bm, edges=[main_edge])["geom"] + edge_dir = ((v1.co - v0.co) * V(1, 1, 0)).normalized() + ortho_vector = edge_dir.cross(V(0, 0, 1)) + + extruded_geom = bmesh.ops.extrude_edge_only(bm, edges=[main_edge])["geom"] + extruded_verts = bm_sort_out_geom(extruded_geom)["verts"] + bmesh.ops.translate(bm, vec=ortho_vector * (-thickness / 2), verts=extruded_verts) + + extruded_geom = bmesh.ops.extrude_edge_only(bm, edges=[main_edge])["geom"] + extruded_verts = bm_sort_out_geom(extruded_geom)["verts"] + bmesh.ops.translate(bm, vec=ortho_vector * (thickness / 2), verts=extruded_verts) + + # dissolve middle edge + bmesh.ops.dissolve_edges(bm, edges=[main_edge]) + + # height + extruded_geom = bmesh.ops.extrude_face_region(bm, geom=bm.faces)["geom"] extruded_verts = bm_sort_out_geom(extruded_geom)["verts"] - bmesh.ops.translate(bm, vec=ortho_vector * (-thickness / 2), verts=extruded_verts) + extrusion_vector = Vector((0, 0, 1)) * height + bmesh.ops.translate(bm, vec=extrusion_vector, verts=extruded_verts) - extruded_geom = bmesh.ops.extrude_edge_only(bm, edges=[main_edge])["geom"] - extruded_verts = bm_sort_out_geom(extruded_geom)["verts"] - bmesh.ops.translate(bm, vec=ortho_vector * (thickness / 2), verts=extruded_verts) + # dissolve middle edges + edges_to_dissolve = [] + verts_to_dissolve = [] + for v in edge_dissolving_verts: + for e in v.link_edges: + other_vert = e.other_vert(v) + if other_vert in extruded_verts: + edges_to_dissolve.append(e) + verts_to_dissolve.append(other_vert) + bmesh.ops.dissolve_edges(bm, edges=edges_to_dissolve) + bmesh.ops.dissolve_verts(bm, verts=verts_to_dissolve) - # dissolve middle edge - bmesh.ops.dissolve_edges(bm, edges=[main_edge]) + # to remove unnecessary verts in 0 spacing case + bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.0001) - # height - extruded_geom = bmesh.ops.extrude_face_region(bm, geom=bm.faces)["geom"] - extruded_verts = bm_sort_out_geom(extruded_geom)["verts"] - extrusion_vector = Vector((0, 0, 1)) * height - bmesh.ops.translate(bm, vec=extrusion_vector, verts=extruded_verts) + tool.Blender.apply_bmesh(obj.data, bm) - # dissolve middle edges - edges_to_dissolve = [] - verts_to_dissolve = [] - for v in edge_dissolving_verts: - for e in v.link_edges: - other_vert = e.other_vert(v) - if other_vert in extruded_verts: - edges_to_dissolve.append(e) - verts_to_dissolve.append(other_vert) - bmesh.ops.dissolve_edges(bm, edges=edges_to_dissolve) - bmesh.ops.dissolve_verts(bm, verts=verts_to_dissolve) - - # to remove unnecessary verts in 0 spacing case - bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.0001) - - tool.Blender.apply_bmesh(obj.data, bm) + generate_frameless_panel_railing() def get_path_data(obj): si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) - if obj.mode == "EDIT": - # otherwise mesh may not contain all changes - # added in edit mode - obj.update_from_editmode() - mesh = obj.data - path_data = dict() - path_data["edges"] = [e.vertices[:] for e in mesh.edges] - path_data["verts"] = [v.co / si_conversion for v in mesh.vertices] - - if not path_data["edges"] or not path_data["verts"]: + bm = tool.Blender.get_bmesh_for_mesh(obj.data) + end_points = [v for v in bm.verts if len(v.link_edges) == 1] + if not end_points: return None + + # if we have some previous data then we try to match + # start or end of the path with the previous path + previous_data = False + if previous_data: + previous_start = previous_data[0] + previous_end = previous_data[-1] + + potential_start = min([(v, (v.co - previous_start).length) for v in end_points], key=lambda v_data: v_data[1]) + potential_end = min([(v, (v.co - previous_end).length) for v in end_points], key=lambda v_data: v_data[1]) + + if potential_start[1] < potential_end[1]: + start_point = potential_start[0] + else: + start_point = next(v for v in end_points if v != potential_start[0]) + else: + start_point = min(end_points, key=lambda v: v.index) + + # walking through the path + # to make sure all verts and in consequent order + edge = start_point.link_edges[0] + v = edge.other_vert(start_point) + points = [start_point.co, v.co] + segments = [(0, 1)] + i = 2 + + other_edge = lambda edges, edge: next(e for e in edges if e != edge) + + while len(link_edges := v.link_edges) != 1: + link_edges = v.link_edges + edge = other_edge(link_edges, edge) + v = edge.other_vert(v) + points.append(v.co) + segments.append((i - 1, i)) + i += 1 + + path_data = {"edges": segments, "verts": [p / si_conversion for p in points]} + return path_data @@ -248,8 +315,7 @@ class AddRailing(bpy.types.Operator, tool.Ifc.Operator): # need to make sure all default props will have correct units if not props.railing_added_previously: - skip_props = ("is_editing", "railing_type", "railing_added_previously") - convert_property_group_from_si(props, skip_props=skip_props) + convert_property_group_from_si(props, skip_props=NON_SI_RAILING_PROPS) railing_data = props.get_general_kwargs() path_data = get_path_data(obj) @@ -289,8 +355,7 @@ class EnableEditingRailing(bpy.types.Operator, tool.Ifc.Operator): # need to make sure all props that weren't used before # will have correct units - skip_props = ("is_editing", "railing_type", "railing_added_previously") - skip_props += tuple(data.keys()) + skip_props = NON_SI_RAILING_PROPS + tuple(data.keys()) convert_property_group_from_si(props, skip_props=skip_props) props.is_editing = 1 @@ -349,6 +414,37 @@ class FinishEditingRailing(bpy.types.Operator, tool.Ifc.Operator): return {"FINISHED"} +class FlipRailingPathOrder(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.flip_railing_path_order" + bl_label = "Flip Railing Path Order" + bl_description = "Can be useful to maintain railing supports direction" + bl_options = {"REGISTER", "UNDO"} + + def _execute(self, context): + obj = context.active_object + element = tool.Ifc.get_entity(obj) + props = obj.BIMRailingProperties + + if not RailingData.is_loaded: + RailingData.load() + path_data = RailingData.data["parameters"]["data_dict"]["path_data"] + + # flip the vertex order and edges + path_data["verts"] = path_data["verts"][::-1] + last_vert_i = len(path_data["verts"]) - 1 + edges = [] + for edge in path_data["edges"][::-1]: + edge = [abs(vi - last_vert_i) for vi in edge[::-1]] + edges.append(edge) + + railing_data = props.get_general_kwargs() + railing_data["path_data"] = path_data + + update_bbim_railing_pset(element, railing_data) + update_railing_modifier_ifc_data(context) + return {"FINISHED"} + + class EnableEditingRailingPath(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.enable_editing_railing_path" bl_label = "Enable Editing Railing Path" diff --git a/src/blenderbim/blenderbim/bim/module/model/ui.py b/src/blenderbim/blenderbim/bim/module/model/ui.py index a3d9c12617..96641eaf5b 100644 --- a/src/blenderbim/blenderbim/bim/module/model/ui.py +++ b/src/blenderbim/blenderbim/bim/module/model/ui.py @@ -543,6 +543,9 @@ class BIM_PT_railing(bpy.types.Panel): else: row.operator("bim.enable_editing_railing", icon="GREASEPENCIL", text="") row.operator("bim.enable_editing_railing_path", icon="ANIM", text="") + # TODO: good for preview but probably should move to .is_editing == -1 + # since it's writing to ifc + row.operator("bim.flip_railing_path_order", icon="ARROW_LEFTRIGHT", text="") row.operator("bim.remove_railing", icon="X", text="") box = self.layout.box() @@ -611,7 +614,7 @@ class BIM_PT_roof(bpy.types.Panel): prop_value = round(prop_value, 5) if type(prop_value) is float else prop_value row = box.row(align=True) row.label(text=f"{props.bl_rna.properties[prop].name}") - if prop == 'angle': + if prop == "angle": prop_value = round(degrees(prop_value), 2) row.label(text=str(prop_value)) else: diff --git a/src/blenderbim/blenderbim/bim/module/model/workspace.py b/src/blenderbim/blenderbim/bim/module/model/workspace.py index 063a77bc3d..db29afe44a 100644 --- a/src/blenderbim/blenderbim/bim/module/model/workspace.py +++ b/src/blenderbim/blenderbim/bim/module/model/workspace.py @@ -202,6 +202,15 @@ class BimToolUI: add_layout_hotkey_operator(cls.layout, "Regen", "S_G", bpy.ops.bim.recalculate_profile.__doc__) row.operator("bim.extend_profile", icon="X", text="").join_type = "" + elif ( + (RailingData.is_loaded or not RailingData.load()) + and RailingData.data["parameters"] + and not context.active_object.BIMRailingProperties.is_editing_path + ): + # NOTE: should be above "active_representation_type" = "SweptSolid" check + # because it could be a SweptSolid too + add_layout_hotkey_operator(cls.layout, "Edit Railing Path", "S_E", "") + elif AuthoringData.data["active_representation_type"] == "SweptSolid": add_layout_hotkey_operator(cls.layout, "Edit Profile", "S_E", "") @@ -232,13 +241,6 @@ class BimToolUI: ): add_layout_hotkey_operator(cls.layout, "Extend", "S_E", "") - elif ( - (RailingData.is_loaded or not RailingData.load()) - and RailingData.data["parameters"] - and not context.active_object.BIMRailingProperties.is_editing_path - ): - add_layout_hotkey_operator(cls.layout, "Edit Railing Path", "S_E", "") - elif ( (RoofData.is_loaded or not RoofData.load()) and RoofData.data["parameters"] @@ -407,6 +409,16 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator): if not bpy.context.selected_objects: return + # NOTE: placing it before the other operations because railing can also be SweptSolid + # and it might conflict with one of the conditions below + if ( + (RailingData.is_loaded or not RailingData.load()) + and RailingData.data["parameters"] + and not bpy.context.active_object.BIMRailingProperties.is_editing_path + ): + bpy.ops.bim.enable_editing_railing_path() + return + selected_usages = {} for obj in bpy.context.selected_objects: element = tool.Ifc.get_entity(obj) @@ -461,15 +473,6 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator): [o.select_set(False) for o in selected_usages.get("LAYER2", [])] bpy.ops.bim.extend_profile(join_type="T") - elif ( - (RailingData.is_loaded or not RailingData.load()) - and RailingData.data["parameters"] - and not bpy.context.active_object.BIMRailingProperties.is_editing_path - ): - # undo the unselection done above because railing has no usage type 🙃 - bpy.context.object.select_set(True) - bpy.ops.bim.enable_editing_railing_path() - elif ( (RoofData.is_loaded or not RoofData.load()) and RoofData.data["parameters"] diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_railing_representation.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_railing_representation.py new file mode 100644 index 0000000000..ab7914cfd6 --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_railing_representation.py @@ -0,0 +1,238 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2023 @Andrej730 +# +# 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 ifcopenshell.util.unit +from ifcopenshell.util.shape_builder import ShapeBuilder, V +from itertools import chain +from mathutils import Vector +import collections +import mathutils +from pprint import pprint +from math import pi, cos, sin, tan + + +def mm(x): + """mm to meters shortcut for readability""" + return x / 1000 + + +class Usecase: + def __init__(self, file, **settings): + """ + units in settings expected to be in ifc project units + + `railing_path` is a list of point coordinates for the railing path, + coordinates are expected to be at the top of the railing, not at the center + + `railing_path` is expected to be a list of Vector objects + """ + self.file = file + self.settings = {"unit_scale": ifcopenshell.util.unit.calculate_unit_scale(self.file)} + self.settings.update( + { + "context": None, # IfcGeometricRepresentationContext + "railing_type": "WALL_MOUNTED_HANDRAIL", + "railing_path": self.path_si_to_units([V(0, 0, 1), V(1, 0, 1), V(2, 0, 1)]), + "use_manual_supports": False, + "support_spacing": self.convert_si_to_unit(mm(1000)), + "railing_diameter": self.convert_si_to_unit(mm(50)), + "clear_width": self.convert_si_to_unit(mm(40)), + "terminal_type": "180", + "height": self.convert_si_to_unit(mm(1000)), + } + ) + + for key, value in settings.items(): + self.settings[key] = value + + if self.settings["railing_type"] != "WALL_MOUNTED_HANDRAIL": + raise Exception('Only "WALL_MOUNTED_HANDRAIL" railing_type is supported at the moment.') + + def execute(self): + arc_points = [] + items_3d = [] + builder = ShapeBuilder(self.file) + z_down = V(0, 0, -1) + + # measurements + # from settings + use_manual_supports = self.settings["use_manual_supports"] + railing_radius = self.settings["railing_diameter"] / 2 + support_spacing = self.settings["support_spacing"] + clear_width = self.settings["clear_width"] + height = self.settings["height"] + cap_type = self.settings["terminal_type"] + ifc_context = self.settings["context"] + railing_coords = self.settings["railing_path"] + railing_coords = [p - z_down * railing_radius for p in railing_coords] + + # constant + terminal_radius = self.convert_si_to_unit(mm(150)) + railing_fillet_radius = self.convert_si_to_unit(mm(100)) + support_length = clear_width + railing_radius + support_radius = self.convert_si_to_unit(mm(10)) + support_disk_radius = railing_radius + support_disk_depth = self.convert_si_to_unit(mm(20)) + + # util functions + float_is_zero = lambda f: 0.0001 >= f >= -0.0001 + collinear = lambda d0, d1: float_is_zero(d0.angle(d1)) + + def add_support_on_point(point, railing_direction): + """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() + arc_center = point + ortho_dir * support_length + support_points = [ + point, + arc_center - ortho_dir * support_length * cos(pi / 4) + z_down * support_length * sin(pi / 4), + arc_center + z_down * support_length, + ] + polyline = builder.polyline(support_points, closed=False, arc_points=[1]) + solid = builder.create_swept_disk_solid(polyline, support_radius) + + support_disk_circle = builder.circle(radius=support_disk_radius) + support_disk = builder.extrude( + support_disk_circle, support_disk_depth, position=support_points[-1], **builder.extrude_by_y_kwargs() + ) + return [solid, support_disk] + + def get_fillet_points(v0, v1, v2, radius): + """get fillet points between edges v0v1 and v1v2""" + dir1 = (v0 - v1).normalized() + dir2 = (v2 - v1).normalized() + edge_angle = dir1.angle(dir2) + slide_distance = radius / tan(edge_angle / 2) + + fillet_v1co = v1 + (dir1 * slide_distance) + fillet_v2co = v1 + (dir2 * slide_distance) + + normal = mathutils.geometry.normal([v0, v1, v2]) + center = mathutils.geometry.intersect_line_line( + fillet_v1co, fillet_v1co + normal.cross(dir1), fillet_v2co, fillet_v2co + normal.cross(dir2) + )[0] + + midpointco = center + ((fillet_v1co.lerp(fillet_v2co, 0.5) - center).normalized() * radius) + return fillet_v1co, midpointco, fillet_v2co + + def add_arcs_on_turnings_points(base_points): + """add 3 point fillet arcs on turning points of the railing path""" + if len(base_points) < 3: + return base_points + + # looking for turning points by checking non-collinear edges + output_points = base_points[:1] + prev_dir = (base_points[1] - base_points[0]).normalized() + i = 1 + while i < len(base_points) - 1: + cur_dir = (base_points[i + 1] - base_points[i]).normalized() + + if collinear(cur_dir, prev_dir): + output_points.append(base_points[i]) + else: + fillet_points = get_fillet_points( + base_points[i - 1], base_points[i], base_points[i + 1], railing_fillet_radius + ) + output_points.extend(fillet_points) + arc_points.append(fillet_points[1]) + + prev_dir = cur_dir + i = i + 1 + + output_points.append(base_points[-1]) + return output_points + + def create_supports_items(railing_coords, manual_supports=False): + """create supports items based on the railing coordinates""" + supports_items = [] + + # simplified_coords is a list of points that form non-collinear edges + simplified_coords = [railing_coords[0]] + prev_dir = (railing_coords[1] - railing_coords[0]).normalized() + + # iterating over each edge of the railing path + for i in range(1, len(railing_coords) - 1): + cur_dir = (railing_coords[i + 1] - railing_coords[i]).normalized() + + if not collinear(cur_dir, prev_dir): + simplified_coords.append(railing_coords[i]) + prev_dir = cur_dir + + # for manual supports each vertex on the railing path edge + # will be a point for a support + elif manual_supports: + supports_items.extend(add_support_on_point(point=railing_coords[i], railing_direction=cur_dir)) + + simplified_coords.append(railing_coords[-1]) + + if manual_supports: + return supports_items + + # create automatic supports based on the support spacing + for i in range(0, len(simplified_coords) - 1): + v0, v1 = simplified_coords[i : i + 2] + edge = v1 - v0 + length = edge.length + edge_dir = edge.normalized() + n_supports, support_offset = divmod(length, support_spacing) + n_supports = int(n_supports) + 1 + support_offset /= 2 + + start_position = v0 + support_offset * edge_dir + for support_i in range(n_supports): + support_position = start_position + support_i * support_spacing * edge_dir + supports_items.extend(add_support_on_point(point=support_position, railing_direction=edge)) + + return supports_items + + def add_cap(railing_coords, arc_points, start=False): + """add handrail terminal cap""" + # TODO: implement more cap types + railing_coords_for_cap = railing_coords[::-1] if start else railing_coords + + start = railing_coords_for_cap[-1] + cap_dir = (railing_coords_for_cap[-1] - railing_coords_for_cap[-2]).xy.to_3d().normalized() + arc_point = start + cap_dir * terminal_radius + terminal_radius * z_down + arc_points.append(arc_point) + cap_coords = [arc_point, start + terminal_radius * 2 * z_down] + + railing_coords = railing_coords_for_cap + cap_coords + + if start: + railing_coords = railing_coords[::-1] + return railing_coords, arc_points + + items_3d.extend(create_supports_items(railing_coords, manual_supports=use_manual_supports)) + railing_coords = add_arcs_on_turnings_points(railing_coords) + + 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_path = builder.polyline( + railing_coords, closed=False, arc_points=[railing_coords.index(p) for p in arc_points] + ) + railing_solid = builder.create_swept_disk_solid(railing_path, railing_radius) + items_3d.append(railing_solid) + representation = builder.get_representation(ifc_context, items=items_3d) + return representation + + def convert_si_to_unit(self, value): + return value / self.settings["unit_scale"] + + def path_si_to_units(self, path): + """converts list of vectors from SI to ifc project units""" + return [self.convert_si_to_unit(v) for v in path] diff --git a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py index bad97b2c41..b2e814278b 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py +++ b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py @@ -35,10 +35,21 @@ class ShapeBuilder: def __init__(self, ifc_file): self.file = ifc_file - def polyline(self, points, closed=False, position_offset=None): + def polyline(self, points, closed=False, position_offset=None, arc_points=[]): # > points - list of points formatted like ( (x0, y0), (x1, y1) ) # < IfcIndexedPolyCurve - segments = [(i, i + 1) for i in range(1, len(points))] + segments = [] + + cur_i = 0 + while cur_i < len(points) - 1: + cur_i_ifc = cur_i + 1 + if cur_i + 1 in arc_points: + segments.append((cur_i_ifc, cur_i_ifc + 1, cur_i_ifc + 2)) + cur_i += 2 + else: + segments.append((cur_i_ifc, cur_i_ifc + 1)) + cur_i += 1 + if closed: segments.append((len(points), 1)) if position_offset: @@ -50,7 +61,12 @@ class ShapeBuilder: elif dimensions == 3: ifc_points = self.file.createIfcCartesianPointList3D(points) - ifc_segments = [self.file.createIfcLineIndex(segment) for segment in segments] + ifc_segments = [] + for segment in segments: + if len(segment) == 2: + ifc_segments.append(self.file.createIfcLineIndex(segment)) + elif len(segment) == 3: + ifc_segments.append(self.file.createIfcArcIndex(segment)) ifc_curve = self.file.createIfcIndexedPolyCurve(Points=ifc_points, Segments=ifc_segments) return ifc_curve @@ -187,28 +203,20 @@ class ShapeBuilder: # < returns IfcArbitraryClosedProfileDef or IfcArbitraryProfileDefWithVoids if outer_curve.Dim != 2: - # TODO: replace with exception - print( - f"WARNING. Outer curve for IfcArbitraryClosedProfileDef/IfcIfcArbitraryProfileDefWithVoid should be 2D to be valid, currently it has {outer_curve.Dim} dimensions.\n" + raise Exception( + f"Outer curve for IfcArbitraryClosedProfileDef/IfcIfcArbitraryProfileDefWithVoid should be 2D to be valid, currently it has {outer_curve.Dim} dimensions.\n" "Ref: https://ifc43-docs.standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/lexical/IfcArbitraryClosedProfileDef.htm#8.15.3.1.4-Formal-propositions" ) - import traceback - - traceback.print_stack() if inner_curves: if not isinstance(inner_curves, collections.abc.Iterable): inner_curves = [inner_curves] - # TODO: replace with exception if any(curve.Dim != 2 for curve in inner_curves): - print( + raise Exception( "WARNING. InnerCurve for IfcIfcArbitraryProfileDefWithVoid sould be 2D to be valid, " "currently on one of the inner curves is using different amount of dimensions.\n" "Ref: https://ifc43-docs.standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/lexical/IfcArbitraryClosedProfileDef.htm#8.15.3.1.4-Formal-propositions" ) - import traceback - - traceback.print_stack() profile = self.file.createIfcArbitraryProfileDefWithVoids( ProfileName=name, ProfileType=profile_type, OuterCurve=outer_curve, InnerCurves=inner_curves @@ -515,6 +523,17 @@ class ShapeBuilder: ) return extruded_area + def create_swept_disk_solid(self, path_curve, radius): + """Create IfcSweptDiskSolid from `path_curve` (must be 3D) and `radius`""" + if path_curve.Dim != 3: + raise Exception( + f"Path curve for IfcSweptDiskSolid should be 3D to be valid, currently it has {path_curve.Dim} dimensions.\n" + "Ref: https://ifc43-docs.standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/lexical/IfcSweptDiskSolid.htm#8.8.3.42.4-Formal-propositions" + ) + + disk_solid = self.file.createIfcSweptDiskSolid(Directrix=path_curve, Radius=radius) + return disk_solid + def get_representation(self, context, items, representation_type=None): # > items - could be a list or single curve/IfcExtrudedAreaSolid # < IfcShapeRepresentation @@ -522,7 +541,7 @@ class ShapeBuilder: items = [items] if not representation_type: - if items[0].is_a("IfcExtrudedAreaSolid"): + if items[0].is_a() in ("IfcExtrudedAreaSolid", "IfcSweptDiskSolid"): representation_type = "SweptSolid" elif items[0].is_a("IfcCurve") and items[0].Dim == 3: representation_type = "Curve3D" @@ -539,3 +558,13 @@ class ShapeBuilder: def deep_copy(self, element): return ifcopenshell.util.element.copy_deep(self.file, element) + + # UTILITIES + def extrude_by_y_kwargs(self): + """shortcut for `ShapeBuilder.extrude` to extrude by y axis. + it assumes you have 2d profile in xz plane and trying to extrude it by y axis""" + return { + "position_x_axis": Vector((1, 0, 0)), + "position_z_axis": Vector((0, -1, 0)), + "extrusion_vector": Vector((0, 0, -1)), + }