This commit is contained in:
Thomas Krijnen
2025-10-20 10:27:50 +02:00
34 changed files with 945 additions and 166 deletions
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 70 KiB

@@ -13,10 +13,10 @@ DATA;
#6=IFCSIMPLEPROPERTYTEMPLATE('0AK5C2UpL4$eaac2LszAx$',$,'HasUnderlay','',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
#7=IFCSIMPLEPROPERTYTEMPLATE('2j2ZEZR8X5tONm7kli5hM6',$,'HasLinework','',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
#8=IFCSIMPLEPROPERTYTEMPLATE('1ttChRysH9UuEX2FeMj5Hu',$,'HasAnnotation','',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
#9=IFCSIMPLEPROPERTYTEMPLATE('2NPPxuABv1huDTVh32TFgw',$,'GlobalReferencing','',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
#9=IFCSIMPLEPROPERTYTEMPLATE('2NPPxuABv1huDTVh32TFgw',$,'GlobalReferencing','Whether or not this drawing can be referenced in other drawings.',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
#10=IFCSIMPLEPROPERTYTEMPLATE('10hT_1zrzEbRRKMXYAWvtD',$,'Metadata','Comma separated list of selector expressions to evaluate for each drawing elementand add results to their ''class'' attribute.\X2\000A\X0\E.g. ''Name, id'' would add to ''class'' value similar to ''Name-Wall id-1220''.\X2\000A\X0\Then it can be used to applied css styles based on the resulting class.\X2\000A\X0\If attribute is not present on the element, then it won''t be added to it''s ''class''.',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.);
#11=IFCSIMPLEPROPERTYTEMPLATE('3Z0BXPSG5CWgtI33ioV7aj',$,'Include','Selector expression to include ifc elements in the drawing',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.);
#12=IFCSIMPLEPROPERTYTEMPLATE('1RVts_g3PAw98PJA2yL3bO',$,'Exclude','Selector expression to exclude ifc elements in the drawing',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.);
#11=IFCSIMPLEPROPERTYTEMPLATE('3Z0BXPSG5CWgtI33ioV7aj',$,'Include','Selector expression to include elements in the drawing',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.);
#12=IFCSIMPLEPROPERTYTEMPLATE('1RVts_g3PAw98PJA2yL3bO',$,'Exclude','Selector expression to exclude elements in the drawing',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.);
#13=IFCSIMPLEPROPERTYTEMPLATE('0c1$8NpYDEaBiJrj16jHIo',$,'Stylesheet','',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.);
#14=IFCSIMPLEPROPERTYTEMPLATE('3mRF52q81FQB$h4oTh7M45',$,'Markers','',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.);
#15=IFCSIMPLEPROPERTYTEMPLATE('1rhr_0N3LDtuORcEJP0KXM',$,'Symbols','',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.);
+12 -1
View File
@@ -36,6 +36,7 @@ from bonsai.bim.ifc import IfcStore
from mathutils import Vector
from typing import Union
from logging import Logger
from math import radians
class IfcExporter:
@@ -104,7 +105,17 @@ class IfcExporter:
def sync_object_placement(self, obj: bpy.types.Object) -> Union[ifcopenshell.entity_instance, None]:
element = self.file.by_id(tool.Blender.get_object_bim_props(obj).ifc_definition_id)
if tool.Geometry.is_scaled(obj):
# Handle camera scales specially
if obj.type == "CAMERA":
# Check if this is a reflected ceiling plan camera
camera = tool.Ifc.get_entity(obj)
if ifcopenshell.util.element.get_pset(camera, "EPset_Drawing", "TargetView") == "REFLECTED_PLAN_VIEW":
# Ensure reflected ceiling cameras have the correct scale
if obj.scale != (-1, -1, -1):
obj.scale = (-1, -1, -1)
obj.rotation_euler = (0.0, 0.0, radians(180))
# Skip all other scale handling for cameras
elif tool.Geometry.is_scaled(obj):
bpy.ops.bim.update_representation(obj=obj.name)
# update_representation might not apply scale if the object has openings
# reset it, so let user know that the scale wasn't saved.
@@ -94,6 +94,9 @@ class BIM_OT_aggregate_unassign_object(bpy.types.Operator, tool.Ifc.Operator):
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
aggregates_to_check = set()
# First pass: unassign all parts and track their aggregates
for obj in tool.Blender.get_selected_objects():
element = tool.Ifc.get_entity(obj)
if not element:
@@ -101,6 +104,10 @@ class BIM_OT_aggregate_unassign_object(bpy.types.Operator, tool.Ifc.Operator):
aggregate = ifcopenshell.util.element.get_aggregate(element)
if not aggregate:
continue
# Track this aggregate for later checking
aggregates_to_check.add(aggregate)
core.unassign_object(
tool.Ifc,
tool.Aggregate,
@@ -116,6 +123,29 @@ class BIM_OT_aggregate_unassign_object(bpy.types.Operator, tool.Ifc.Operator):
pset = tool.Ifc.get().by_id(pset["id"])
ifcopenshell.api.pset.remove_pset(tool.Ifc.get(), product=element, pset=pset)
# Second pass: delete aggregates that now have no parts
deleted_aggregates = []
for aggregate in aggregates_to_check:
related_objects = ifcopenshell.util.element.get_parts(aggregate)
if len(related_objects) == 0:
aggregate_name = aggregate.Name or f"{aggregate.is_a()} #{aggregate.id()}"
deleted_aggregates.append(aggregate_name)
aggregate_obj = tool.Ifc.get_object(aggregate)
if aggregate_obj:
ifcopenshell.api.root.remove_product(tool.Ifc.get(), product=aggregate)
bpy.data.objects.remove(aggregate_obj, do_unlink=True)
# Show info message if aggregates were deleted
if deleted_aggregates:
if len(deleted_aggregates) == 1:
self.report(
{"INFO"}, f"Aggregate '{deleted_aggregates[0]}' was deleted because it had no remaining parts"
)
else:
aggregate_list = ", ".join(f"'{name}'" for name in deleted_aggregates)
self.report({"INFO"}, f"Aggregates {aggregate_list} were deleted because they had no remaining parts")
class BIM_OT_enable_editing_aggregate(bpy.types.Operator):
"""Enable editing aggregation relationship"""
+27 -3
View File
@@ -818,7 +818,12 @@ class PurgeUnusedObjects(bpy.types.Operator, tool.Ifc.Operator):
class MergeIdenticalObjects(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.merge_identical_objects"
bl_label = "Merge Identical Objects"
bl_description = "For materials currently only IfcMaterials are supported"
bl_description = (
"Merge identical IFC objects (that match all attributes).\n"
"\n"
"SHIFT + CLICK to merge by name/identification attribute only.\n"
"Merges names with number suffix, as well (ex: foo, foo.001, foo.002)\n"
)
bl_options = {"REGISTER", "UNDO"}
object_type: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
@@ -826,18 +831,36 @@ class MergeIdenticalObjects(bpy.types.Operator, tool.Ifc.Operator):
items=((s, s.capitalize(), "") for s in get_args(tool.Debug.PurgeMergeObjectType)),
)
by_name_or_identification_only: bpy.props.BoolProperty(
name="By Name/Identification Only",
description="Merge based only on Name or Identification attribute, ignoring other properties",
default=False,
)
if TYPE_CHECKING:
object_type: tool.Debug.PurgeMergeObjectType
def invoke(self, context, event):
# Check if shift key is pressed
if event.shift:
self.by_name_or_identification_only = True
else:
self.by_name_or_identification_only = False
return self.execute(context)
def _execute(self, context):
object_type: str = self.object_type
if object_type in ("PROFILE", "TYPE"):
self.report({"ERROR"}, f"Unsupported object type {object_type}.")
return {"CANCELLED"}
merged_data = tool.Debug.merge_identical_objects(object_type)
merged_data = tool.Debug.merge_identical_objects(
object_type, by_name_or_identification_only=self.by_name_or_identification_only
)
plural_object_type = f"{object_type.lower().replace('_', ' ')}s"
if merged_data:
merge_mode = " by name/identification" if self.by_name_or_identification_only else ""
for element_type, element_names in merged_data.items():
print(f"- {element_type}:")
for name in element_names:
@@ -846,7 +869,8 @@ class MergeIdenticalObjects(bpy.types.Operator, tool.Ifc.Operator):
merged = sum(len(v) for v in merged_data.values())
msg = " See system console for details." if merged else ""
self.report({"INFO"}, f"{merged} identical {plural_object_type} were merged.{msg}")
merge_mode = " (by name/identification)" if self.by_name_or_identification_only else ""
self.report({"INFO"}, f"{merged} identical {plural_object_type} were merged{merge_mode}.{msg}")
if merged == 0:
return
@@ -442,6 +442,8 @@ class CreateDrawing(bpy.types.Operator):
context.scene.render.filepath = str(Path(svg_path).with_suffix(".png"))
assert (drawing_style := self.cprops.get_active_drawing_style())
tool.Blender.sync_render_visibility()
if drawing_style.render_type == "DEFAULT":
bpy.ops.render.render(write_still=True)
else:
@@ -625,7 +627,7 @@ class CreateDrawing(bpy.types.Operator):
path.attrib["d"] = d
group.append(g)
def generate_wall_layers(self, context: bpy.types.Context, root) -> None:
def generate_material_layers(self, context: bpy.types.Context, root) -> None:
for el in root.findall(".//{http://www.w3.org/2000/svg}g[@{http://www.ifcopenshell.org/ns}guid]"):
if "projection" in el.get("class", "").split():
continue
@@ -794,8 +796,9 @@ class CreateDrawing(bpy.types.Operator):
edge_bm.to_mesh(edge_mesh)
edge_bm.free()
actual_path = svg_path[0:-4] + "0001.svg"
freestyle_svg_exporter = tool.Blender.get_addon("freestyle_svg_exporter")
context.scene.render.filepath = svg_path[0:-4]
actual_path = freestyle_svg_exporter.create_path(bpy.context.scene)
bpy.ops.render.render(write_still=False)
os.replace(actual_path, svg_path)
@@ -839,7 +842,8 @@ class CreateDrawing(bpy.types.Operator):
if tool.Drawing.is_camera_orthographic():
self.generate_bisect_linework(context, root)
self.generate_wall_layers(context, root)
if self.cprops.generate_material_layers:
self.generate_material_layers(context, root)
self.merge_linework_and_add_metadata(root)
self.move_elements_to_top(root)
@@ -937,12 +941,14 @@ class CreateDrawing(bpy.types.Operator):
if self.cprops.cut_mode == "BISECT":
self.remove_cut_linework(root)
self.generate_bisect_linework(context, root)
self.generate_wall_layers(context, root)
if self.cprops.generate_material_layers:
self.generate_material_layers(context, root)
self.merge_linework_and_add_metadata(root)
self.move_elements_to_top(root)
elif self.cprops.cut_mode == "OPENCASCADE":
self.move_projection_to_bottom(root)
self.generate_wall_layers(context, root)
if self.cprops.generate_material_layers:
self.generate_material_layers(context, root)
self.merge_linework_and_add_metadata(root)
self.move_elements_to_top(root)
@@ -1348,7 +1354,7 @@ class CreateDrawing(bpy.types.Operator):
join_criteria = join_criteria.split(",")
else:
# Drawing convention states that same objects classes with the same material are merged when cut.
join_criteria = ["class", "material.Name", "/Pset_.*Common/.Status", "EPset_Status.Status", "Material.Name"]
join_criteria = ["class", "material.Name", "/Pset_.*Common/.Status", "EPset_Status.Status", "EPset_Status.UserDefinedStatus"]
group = root.find("{http://www.w3.org/2000/svg}g")
joined_paths = {}
@@ -1477,11 +1483,10 @@ class CreateDrawing(bpy.types.Operator):
joined_paths.setdefault(hash_keys, []).append(el)
for key, els in joined_paths.items():
polygons = []
classes = set()
queue = []
for el in els:
classes.update(el.attrib["class"].split())
classes = set(el.attrib["class"].split())
classes.add(el.attrib["{http://www.ifcopenshell.org/ns}guid"])
is_closed_polygon = False
for path in el.findall("{http://www.w3.org/2000/svg}path"):
@@ -1496,31 +1501,30 @@ class CreateDrawing(bpy.types.Operator):
coords.append(coords[0])
if len(coords) > 2 and coords[0] == coords[-1]:
is_closed_polygon = True
polygons.append(shapely.Polygon(coords))
queue.append((shapely.Polygon(coords), classes))
if is_closed_polygon:
el.getparent().remove(el)
try:
merged_polygons = shapely.ops.unary_union(polygons)
except:
print("Warning. Portions of the merge failed. Please report a bug!", polygons)
merged_polygons = polygons
while queue:
polygon, polygon_classes = queue.pop()
for polygon2, polygon2_classes in queue[:]:
try:
merged_polygon = shapely.union(polygon, polygon2)
except:
print("Warning. Portions of the merge failed. Please report a bug!", polygon, polygon2)
continue
if type(merged_polygon) == shapely.Polygon:
polygon = merged_polygon
polygon_classes.update(polygon2_classes)
queue.remove((polygon2, polygon2_classes))
if type(merged_polygons) == shapely.MultiPolygon:
merged_polygons = merged_polygons.geoms
elif type(merged_polygons) == shapely.Polygon:
merged_polygons = [merged_polygons]
else:
merged_polygons = []
for polygon in merged_polygons:
g = etree.Element("g")
path = etree.SubElement(g, "path")
d = "M" + " L".join([",".join([str(o) for o in co]) for co in polygon.exterior.coords[0:-1]]) + " Z"
for interior in polygon.interiors:
d += " M" + " L".join([",".join([str(o) for o in co]) for co in interior.coords[0:-1]]) + " Z"
path.attrib["d"] = d
g.set("class", " ".join(list(classes)))
g.set("class", " ".join(list(polygon_classes)))
group.append(g)
def drawing_to_model_co(self, x: float, y: float) -> Vector:
@@ -1862,13 +1866,15 @@ class AddDrawingToSheet(bpy.types.Operator, tool.Ifc.Operator):
@classmethod
def poll(cls, context):
props = tool.Drawing.get_document_props()
# Won't be visible in UI anyway.
prefs = tool.Blender.get_addon_preferences()
if not props.sheets or not prefs.data_dir:
return False
if not tool.Drawing.get_active_drawing_item():
cls.poll_message_set("No drawing selected.")
return False
if not props.sheets:
cls.poll_message_set("No sheets available.")
return False
if not tool.Blender.get_user_data_dir():
cls.poll_message_set("BIM data directory not set.")
return False
return True
def _execute(self, context):
@@ -2222,6 +2228,7 @@ class ActivateModel(bpy.types.Operator):
)
tool.Blender.reset_object_visibility()
tool.Drawing.hide_all_drawing_collections()
tool.Blender.update_viewport()
bonsai.bim.handler.refresh_ui_data()
@@ -2318,9 +2325,8 @@ class ActivateDrawingBase(tool.Ifc.Operator):
dprops.active_drawing_id = self.drawing
dprops.drawing_styles.clear()
if ifcopenshell.util.element.get_pset(drawing, "EPset_Drawing", "HasUnderlay"):
bpy.ops.bim.reload_drawing_styles()
bpy.ops.bim.activate_drawing_style()
bpy.ops.bim.reload_drawing_styles()
bpy.ops.bim.activate_drawing_style()
if tool.Drawing.is_camera_orthographic():
core.sync_references(tool.Ifc, tool.Collector, tool.Drawing, drawing=tool.Ifc.get().by_id(self.drawing))
@@ -2333,10 +2339,21 @@ class ActivateDrawingBase(tool.Ifc.Operator):
camera = context.scene.camera
assert camera
camera_props = tool.Drawing.get_camera_props(camera)
# Check if this is a reflected ceiling camera and preserve its scale
camera_element = tool.Ifc.get_entity(camera)
is_reflected = False
if camera_element:
is_reflected = ifcopenshell.util.element.get_pset(camera_element, "EPset_Drawing", "TargetView") == "REFLECTED_PLAN_VIEW"
if is_reflected and camera.scale != (-1, -1, -1):
camera.scale = (-1, -1, -1)
camera.rotation_euler = (0.0, 0.0, radians(180))
if camera_props.update_representation(camera.matrix_world):
bpy.ops.bim.update_representation(obj=camera.name, ifc_representation_class="")
# See 6452 and 6478.
# bpy.ops.bim.refresh_clipping_planes("INVOKE_DEFAULT")
# Restore the scale after update if needed
if is_reflected:
camera.scale = (-1, -1, -1)
camera.rotation_euler = (0.0, 0.0, radians(180))
return {"FINISHED"}
@@ -2812,8 +2829,13 @@ class AddScheduleToSheet(bpy.types.Operator, tool.Ifc.Operator):
if not props.schedules:
cls.poll_message_set("No schedule selected.")
return False
prefs = tool.Blender.get_addon_preferences()
return props.schedules and props.sheets and prefs.data_dir
if not props.sheets:
cls.poll_message_set("No sheets available.")
return False
if not tool.Blender.get_user_data_dir():
cls.poll_message_set("BIM data directory not set.")
return False
return True
def _execute(self, context):
props = tool.Drawing.get_document_props()
@@ -2880,8 +2902,13 @@ class AddReferenceToSheet(bpy.types.Operator, tool.Ifc.Operator):
if not props.references:
cls.poll_message_set("No reference selected.")
return False
bim_props = tool.Blender.get_bim_props()
return props.references and props.sheets and bim_props.data_dir
if not props.sheets:
cls.poll_message_set("No sheets available.")
return False
if not tool.Blender.get_user_data_dir():
cls.poll_message_set("BIM data directory not set.")
return False
return True
def _execute(self, context):
props = tool.Drawing.get_document_props()
@@ -498,6 +498,11 @@ class BIMCameraProperties(PropertyGroup):
name="Linework Mode",
update=get_update_layer_callback("linework_mode", "LineworkMode"),
)
generate_material_layers: bpy.props.BoolProperty(
name="Generate Material Layers",
description="Generate material layer linework in drawings",
default=True
)
fill_mode: EnumProperty(
items=[
("NONE", "None", "Disable filling areas seen in projection"),
+2 -1
View File
@@ -99,12 +99,13 @@ class BIM_PT_camera(Panel):
row = self.layout.row()
row.prop(props, "linework_mode")
row = self.layout.row()
row.prop(props, "generate_material_layers")
if props.linework_mode == "OPENCASCADE":
row = self.layout.row()
row.prop(props, "fill_mode")
row = self.layout.row()
row.prop(props, "cut_mode")
row = self.layout.row()
row.prop(props, "width")
row = self.layout.row()
@@ -299,6 +299,8 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator):
return
related_objects = bpy.context.selected_objects
created_objects = []
for related_object in related_objects:
obj = core.add_annotation(
tool.Ifc,
@@ -312,6 +314,14 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator):
enable_editing=False,
)
tool.Drawing.setup_annotation_object(obj, object_type, related_object)
created_objects.append(obj)
# Select the created annotation objects
bpy.ops.object.select_all(action='DESELECT')
for obj in created_objects:
obj.select_set(True)
if created_objects:
bpy.context.view_layer.objects.active = created_objects[-1]
def hotkey_S_A(self):
if bpy.ops.bim.add_annotation.poll():
@@ -109,8 +109,10 @@ def block_scale(scene: bpy.types.Scene) -> None:
if obj.type == "CAMERA":
camera = tool.Ifc.get_entity(obj)
if ifcopenshell.util.element.get_pset(camera, "EPset_Drawing", "TargetView") == "REFLECTED_PLAN_VIEW":
obj.scale = (-1, -1, -1)
obj.rotation_euler = (0.0, 0.0, math.radians(180))
# Only update if scale isn't already (-1, -1, -1)
if obj.scale != (-1, -1, -1):
obj.scale = (-1, -1, -1)
obj.rotation_euler = (0.0, 0.0, math.radians(180))
else:
if obj.scale != (1, 1, 1):
obj.scale = (1, 1, 1)
@@ -787,7 +787,6 @@ def calc_delete_is_batch(ifc_file: ifcopenshell.file, context: bpy.types.Context
is_batch = total_elements > 500000 and total_polygons > 2000
return is_batch
class OverrideDelete(bpy.types.Operator):
bl_idname = "bim.override_object_delete"
bl_label = "IFC Delete"
@@ -858,6 +857,10 @@ class OverrideDelete(bpy.types.Operator):
objects_to_remove = context.selected_objects
self.process_arrays(context)
# Track aggregates before deleting their parts
aggregates_to_check = self.track_aggregates(objects_to_remove)
clear_active_object = True
for i, obj in enumerate(objects_to_remove, 1):
@@ -882,13 +885,25 @@ class OverrideDelete(bpy.types.Operator):
continue
if ifcopenshell.util.element.get_pset(element, "BBIM_Array"):
self.report({"INFO"}, "Elements that are part of an array cannot be deleted.")
return {"FINISHED"}
continue
if element.is_a("IfcGridAxis"):
# Deleting the last W axis is OK
if ((grid := element.PartOfU) and len(grid[0].UAxes) == 1) or (
(grid := element.PartOfV) and len(grid[0].VAxes) == 1
):
self.report(
{"INFO"}, "The last grid axis of a grid cannot be deleted. Delete the grid instead."
)
continue
tool.Geometry.delete_ifc_object(obj)
elif tool.Geometry.is_representation_item(obj):
tool.Geometry.delete_ifc_item(obj)
else:
bpy.data.objects.remove(obj)
# Delete empty aggregates after deleting their parts
self.delete_empty_aggregates(aggregates_to_check)
for opening in tool.Model.get_model_props().openings:
if opening.obj is not None and not tool.Ifc.get_entity(opening.obj):
bpy.data.objects.remove(opening.obj)
@@ -921,6 +936,47 @@ class OverrideDelete(bpy.types.Operator):
data["old_file"].redo()
tool.Ifc.set(data["new_file"])
def track_aggregates(self, objects_to_remove):
"""Track aggregates that contain objects being deleted"""
aggregates_to_check = set()
for obj in objects_to_remove:
if not tool.Blender.is_valid_data_block(obj):
continue
element = tool.Ifc.get_entity(obj)
if not element:
continue
aggregate = ifcopenshell.util.element.get_aggregate(element)
if aggregate:
aggregates_to_check.add(aggregate)
return aggregates_to_check
def delete_empty_aggregates(self, aggregates_to_check):
"""Delete aggregates that now have no parts"""
deleted_aggregates = []
for aggregate in aggregates_to_check:
# Check if aggregate still exists (might have been deleted already)
try:
aggregate.id()
except:
continue
related_objects = ifcopenshell.util.element.get_parts(aggregate)
if len(related_objects) == 0:
aggregate_name = aggregate.Name or f"{aggregate.is_a()} #{aggregate.id()}"
deleted_aggregates.append(aggregate_name)
aggregate_obj = tool.Ifc.get_object(aggregate)
if aggregate_obj and tool.Blender.is_valid_data_block(aggregate_obj):
tool.Geometry.delete_ifc_object(aggregate_obj)
# Show info message if aggregates were deleted
if deleted_aggregates:
if len(deleted_aggregates) == 1:
self.report({'INFO'}, f"Aggregate '{deleted_aggregates[0]}' was deleted because it had no remaining parts")
else:
aggregate_list = ", ".join(f"'{name}'" for name in deleted_aggregates)
self.report({'INFO'}, f"Aggregates {aggregate_list} were deleted because they had no remaining parts")
def process_arrays(self, context: bpy.types.Context) -> None:
ifc_file = tool.Ifc.get()
selected_objects = set(context.selected_objects)
@@ -946,7 +1002,6 @@ class OverrideDelete(bpy.types.Operator):
else:
break # allows to remove only n last layers of an array
class SelectedIdsData(NamedTuple):
objects: set[bpy.types.Object]
collections: set[bpy.types.Collection]
@@ -2973,6 +3028,7 @@ class ImportRepresentationItems(bpy.types.Operator, tool.Ifc.Operator):
item_mesh = bpy.data.meshes.new("tmp")
tool.Ifc.link(item, item_mesh)
item_obj = bpy.data.objects.new("tmp", item_mesh)
tool.Geometry.lock_scale(item_obj)
tool.Geometry.name_item_object(item_obj, item)
item_obj.matrix_world = obj.matrix_world
bpy.context.collection.objects.link(item_obj)
+1 -1
View File
@@ -41,7 +41,7 @@ class SolarData:
@classmethod
def sun_position(cls):
return tool.Blender.get_sun_position_addon()
return tool.Blender.get_addon("sun_position")
@classmethod
def sites(cls):
@@ -202,7 +202,7 @@ class RadianceRender(bpy.types.Operator):
print(f"Camera position: {camera_position}")
print(f"Camera direction: {camera_direction}")
# sun_position = tool.Blender.get_sun_position_addon()
# sun_position = tool.Blender.get_addon("sun_position")
# azimuth, elevation = sun_position.sun_calc.get_sun_coordinates(
# sun_pos_props.time,
# sun_pos_props.latitude,
+1 -1
View File
@@ -40,7 +40,7 @@ from bpy.types import PropertyGroup
from bonsai.bim.module.light.data import SolarData
from bonsai.bim.module.light.decorator import SolarDecorator
sun_position = tool.Blender.get_sun_position_addon()
sun_position = tool.Blender.get_addon("sun_position")
now = datetime.datetime.now()
with open(os.path.join(os.path.dirname(__file__), "spectraldb.json"), "r") as f:
+36 -3
View File
@@ -373,7 +373,12 @@ class BIMStairProperties(PropertyGroup):
if self.stair_type != "WOOD/STEEL" and self.nosing_length < 0:
self["nosing_length"] = 0
non_si_units_props = ("is_editing", "number_of_treads", "has_top_nib", "stair_type")
def update_custom_tread_lock(self, context: bpy.types.Context) -> None:
"""When lock is enabled, sync custom treads with tread_run"""
if self.custom_tread_lock:
self["custom_first_last_tread_run"] = (self.tread_run, self.tread_run)
non_si_units_props = ("is_editing", "number_of_treads", "has_top_nib", "stair_type", "custom_tread_lock")
is_editing: bpy.props.BoolProperty(default=False)
width: bpy.props.FloatProperty(name="Width", default=1.2, soft_min=0.01, subtype="DISTANCE")
@@ -407,6 +412,12 @@ class BIMStairProperties(PropertyGroup):
default="CONCRETE",
update=validate_nosing_value,
)
custom_tread_lock: bpy.props.BoolProperty(
name="Lock First/Last Treads to Tread Run",
description="When enabled, first and last treads automatically use the Tread Run value",
default=True,
update=update_custom_tread_lock,
)
custom_first_last_tread_run: bpy.props.FloatVectorProperty(
name="Custom First / Last Treads Widths",
description='Specify custom first / last treads widths, different from the general "Tread Run". Leave 0 to disable.',
@@ -442,6 +453,7 @@ class BIMStairProperties(PropertyGroup):
top_slab_depth: float
has_top_nib: bool
stair_type: str
custom_tread_lock: bool
custom_first_last_tread_run: tuple[float, float]
nosing_length: float
nosing_depth: float
@@ -480,17 +492,38 @@ class BIMStairProperties(PropertyGroup):
}
stair_kwargs.update(generic_props)
# defined here to appear last in UI
stair_kwargs["custom_first_last_tread_run"] = self.custom_first_last_tread_run
# If locked, use tread_run for both first and last treads
if self.custom_tread_lock:
stair_kwargs["custom_first_last_tread_run"] = (self.tread_run, self.tread_run)
else:
stair_kwargs["custom_first_last_tread_run"] = self.custom_first_last_tread_run
if not convert_to_project_units:
return stair_kwargs
stair_kwargs = tool.Model.convert_data_to_project_units(stair_kwargs, self.non_si_units_props)
return stair_kwargs
def get_props_kwargs_for_ifc_export(self, convert_to_project_units=False, stair_type=None):
"""Get props including custom_tread_lock for saving to IFC"""
stair_kwargs = self.get_props_kwargs(convert_to_project_units, stair_type)
# Add the lock state for IFC storage (after getting base kwargs to avoid passing to generate function)
stair_kwargs["custom_tread_lock"] = self.custom_tread_lock
return stair_kwargs
def set_props_kwargs_from_ifc_data(self, kwargs):
kwargs = tool.Model.convert_data_to_si_units(kwargs, self.non_si_units_props)
# Determine lock state based on whether custom treads match tread_run
# If custom_tread_lock wasn't saved (old files), infer it from the data
if "custom_tread_lock" not in kwargs:
custom_treads = kwargs.get("custom_first_last_tread_run", (0.0, 0.0))
tread_run = kwargs.get("tread_run", 0.3)
# Lock is off if either custom tread differs from tread_run and is not 0
kwargs["custom_tread_lock"] = not any(
ct != 0.0 and ct != tread_run for ct in custom_treads
)
for prop_name in kwargs:
setattr(self, prop_name, kwargs[prop_name])
+4 -2
View File
@@ -188,7 +188,8 @@ class AddStair(bpy.types.Operator, tool.Ifc.Operator):
props = tool.Model.get_stair_props(obj)
ifc_file = tool.Ifc.get()
stair_data = props.get_props_kwargs(convert_to_project_units=True)
# Use the special method that includes custom_tread_lock for IFC storage
stair_data = props.get_props_kwargs_for_ifc_export(convert_to_project_units=True)
pset = tool.Pset.get_element_pset(element, "BBIM_Stair")
if not pset:
pset = ifcopenshell.api.pset.add_pset(ifc_file, product=element, name="BBIM_Stair")
@@ -241,7 +242,8 @@ class FinishEditingStair(bpy.types.Operator, tool.Ifc.Operator):
assert element
props = tool.Model.get_stair_props(obj)
data = props.get_props_kwargs(convert_to_project_units=True)
# Use the special method that includes custom_tread_lock for IFC storage
data = props.get_props_kwargs_for_ifc_export(convert_to_project_units=True)
props.is_editing = False
regenerate_stair_mesh(obj)
tool.Model.add_body_representation(obj)
+24 -1
View File
@@ -302,13 +302,36 @@ class BIM_PT_stair(bpy.types.Panel):
row.operator("bim.cancel_editing_stair", icon="CANCEL", text="")
row = self.layout.row(align=True)
for prop_name in props.get_props_kwargs():
# Skip custom_tread_lock as it's handled with custom_first_last_tread_run
if prop_name == "custom_tread_lock":
continue
prop_value = getattr(props, prop_name)
if isinstance(prop_value, Iterable) and not isinstance(prop_value, str):
# Special handling for custom_first_last_tread_run
if prop_name == "custom_first_last_tread_run":
# Draw the lock toggle
row_lock = self.layout.row(align=True)
lock_text = "Lock First/Last Treads" if not props.custom_tread_lock else "Unlock First/Last Treads"
row_lock.prop(
props,
"custom_tread_lock",
text=lock_text,
icon="LOCKED" if props.custom_tread_lock else "UNLOCKED",
)
# Only show the custom values input if unlocked
if not props.custom_tread_lock:
prop_readable_name = props.bl_rna.properties[prop_name].name
self.layout.label(text=f"{prop_readable_name}:")
self.layout.prop(props, prop_name, text="")
elif isinstance(prop_value, Iterable) and not isinstance(prop_value, str):
prop_readable_name = props.bl_rna.properties[prop_name].name
self.layout.label(text=f"{prop_readable_name}:")
self.layout.prop(props, prop_name, text="")
else:
self.layout.prop(props, prop_name)
if prop_name == "height": # Weak but we just want to insert this inside props drawing
row_length = self.layout.row(align=True)
row_length.prop(props, "total_length_target")
+2 -1
View File
@@ -653,4 +653,5 @@ class BIM_PT_purge(Panel):
row = layout.row(align=True)
row.label(text=f"{object_type.replace('_', ' ').capitalize()}:")
row.operator("bim.purge_unused_objects", text="Purge Unused").object_type = object_type
row.operator("bim.merge_identical_objects", text="Merge Identical").object_type = object_type
merge_op = row.operator("bim.merge_identical_objects", text="Merge Identical")
merge_op.object_type = object_type
@@ -317,6 +317,7 @@ class AssignClass(bpy.types.Operator, tool.Ifc.Operator):
f"Mesh '{obj.data.name}' has loose geometry, loose geometry will be ignored to save mesh to IFC as a tessellation.",
)
representation = tool.Geometry.export_mesh_to_tessellation(obj, ifc_context)
element = core.assign_class(
tool.Ifc,
tool.Collector,
@@ -326,13 +327,9 @@ class AssignClass(bpy.types.Operator, tool.Ifc.Operator):
predefined_type=predefined_type,
should_add_representation=False,
)
representation = tool.Geometry.export_mesh_to_tessellation(obj, ifc_context)
ifcopenshell.api.geometry.assign_representation(tool.Ifc.get(), element, representation)
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
obj=obj,
representation=representation,
tool.Ifc, tool.Geometry, obj=obj, representation=representation
)
else:
-4
View File
@@ -108,10 +108,6 @@ def add_sheet(ifc: type[tool.Ifc], drawing: type[tool.Drawing], titleblock: ifco
def regenerate_sheet(
drawing: type[tool.Drawing], sheet: ifcopenshell.entity_instance
) -> Union[list[tool.Drawing.SheetWarningType], None]:
warnings = drawing.validate_sheet_files(sheet)
if warnings:
return warnings
titleblock_uri = drawing.get_document_uri(sheet, "TITLEBLOCK")
assert titleblock_uri
+23 -12
View File
@@ -1506,28 +1506,21 @@ class Blender(bonsai.core.tool.Blender):
return bpy.context.preferences.addons[blender_package_name].preferences
@classmethod
def get_sun_position_addon(cls) -> Union[types.ModuleType, None]:
# Check if it's installed as legacy Blender addon.
def get_addon(cls, name: str) -> Union[types.ModuleType, None]:
import importlib
try:
sun_position = importlib.import_module("sun_position")
return importlib.import_module(name) # Legacy Blender addon
except ImportError:
sun_position = None
if sun_position:
return sun_position
pass
for package_name in bpy.context.preferences.addons.keys():
if package_name.endswith(".sun_position"):
if package_name.endswith(f".{name}"):
try:
sun_position = importlib.import_module(package_name)
return sun_position
return importlib.import_module(package_name)
except ModuleNotFoundError:
pass
return sun_position
@classmethod
def get_sun_props(cls) -> Union[SunPosProperties, None]:
assert (scene := bpy.context.scene)
@@ -1889,6 +1882,24 @@ class Blender(bonsai.core.tool.Blender):
obj.select_set(True)
bpy.context.view_layer.objects.active = previously_active
@classmethod
def sync_render_visibility(cls):
# Doing bpy.ops.object.hide_render_clear_all() or
# bpy.ops.object.isolate_type_render() is extremely slow.
# Hopefully this doesn't crash on Windows, it doesn't crash on Linux.
should_hides = [0 if obj.visible_get() else 1 for obj in bpy.data.objects]
should_hides = np.fromiter(should_hides, dtype=np.uint8, count=len(should_hides))
bpy.data.objects.foreach_set("hide_render", should_hides)
return # Otherwise...
# for obj in bpy.data.objects:
# if not obj.data:
# continue
# # For speed, check equality prior to change to prevent needless updates
# if (is_visible := obj.visible_get()) and obj.hide_render is True:
# obj.hide_render = False
# elif not is_visible and obj.hide_render is False:
# obj.hide_render = True
@classmethod
def hide_objects(cls, objs):
previously_selected = {o.name for o in bpy.context.selected_objects}
+10 -3
View File
@@ -51,9 +51,13 @@ class Collector(bonsai.core.tool.Collector):
if tool.Geometry.is_locked(element):
tool.Geometry.lock_object(obj)
element = (element.PartOfU or element.PartOfV or element.PartOfW)[0]
if not tool.Spatial.get_grid_props().is_visible:
obj.hide_viewport = True
elif element.is_a("IfcGrid"):
if tool.Geometry.is_locked(element):
tool.Geometry.lock_object(obj)
if not tool.Spatial.get_grid_props().is_visible:
obj.hide_viewport = True
if element.is_a("IfcProject"):
if tool.Geometry.is_locked(element):
@@ -69,7 +73,8 @@ class Collector(bonsai.core.tool.Collector):
tool.Geometry.lock_object(obj)
collection = cls._create_project_child_collection("IfcSpace")
cls.link_collection_object_safe(collection, obj)
obj.hide_viewport = True
if not tool.Spatial.get_spatial_props().is_visible:
obj.hide_viewport = True
elif element.is_a("IfcStructuralItem"):
collection = cls._create_project_child_collection("IfcStructuralItem")
cls.link_collection_object_safe(collection, obj)
@@ -93,7 +98,8 @@ class Collector(bonsai.core.tool.Collector):
cls.link_collection_object_safe(collection, obj)
project_obj = tool.Ifc.get_object(tool.Ifc.get().by_type("IfcProject")[0])
cls.link_collection_child_safe(tool.Blender.get_object_bim_props(project_obj).collection, collection)
obj.hide_viewport = True
if not tool.Spatial.get_spatial_props().is_visible:
obj.hide_viewport = True
elif (
tool.Ifc.get_schema() != "IFC2X3"
and element.is_a("IfcSpatialElement")
@@ -105,7 +111,8 @@ class Collector(bonsai.core.tool.Collector):
cls.link_collection_object_safe(collection, obj)
project_obj = tool.Ifc.get_object(tool.Ifc.get().by_type("IfcProject")[0])
cls.link_collection_child_safe(tool.Blender.get_object_bim_props(project_obj).collection, collection)
obj.hide_viewport = True
if not tool.Spatial.get_spatial_props().is_visible:
obj.hide_viewport = True
elif element.is_a("IfcAnnotation") and element.ObjectType == "DRAWING":
if collection := cls._create_own_collection(obj):
cls.link_collection_object_safe(collection, obj)
+104 -17
View File
@@ -18,6 +18,7 @@
from __future__ import annotations
import os
import re
import json
import bmesh
import bpy
@@ -33,7 +34,7 @@ import bonsai.tool as tool
from bonsai.bim.ifc import IfcStore
from mathutils import Vector
from collections import defaultdict
from typing import Literal, TYPE_CHECKING, assert_never
from typing import Literal, TYPE_CHECKING, assert_never, Union
from collections.abc import Iterable
if TYPE_CHECKING:
@@ -136,12 +137,30 @@ class Debug(bonsai.core.tool.Debug):
"PERSON",
"PERSON_AND_ORGANIZATION",
],
by_name_or_identification_only: bool = False,
) -> dict[str, list[str]]:
"""Merge identical objects.
Note that Styles UI (or other UI) should be updated manually after using this method.
Args:
object_type: The type of object to merge
by_name_or_identification_only: If True, merge based only on Name attribute (or equivalent identifier).
Strips .XXX suffix patterns (e.g., 'foo.001' matches 'foo', 'foo.002').
For PERSON, uses Identification. For APPLICATION, uses ApplicationFullName.
For PERSON_AND_ORGANIZATION, uses combination of person and organization identifiers.
"""
def normalize_name(name: str) -> str:
"""Remove .XXX suffix pattern from names (e.g., 'foo.001' -> 'foo')"""
if not name:
return ""
# Match pattern: name ending with .digits
match = re.match(r"^(.+)\.\d+$", name)
if match:
return match.group(1)
return name
def get_hash(element: ifcopenshell.entity_instance) -> int:
data = element.get_info_2(include_identifier=False, recursive=True)
if object_type == "APPLICATION":
@@ -152,6 +171,49 @@ class Debug(bonsai.core.tool.Debug):
data["TheOrganization"] = element.TheOrganization.id()
return hash(json.dumps(data, sort_keys=True))
def get_name_key(element: ifcopenshell.entity_instance) -> str:
"""Get key based on name/identifier attribute for the given object type"""
if object_type == "STYLE":
name = element.Name if element.Name else ""
return normalize_name(name)
elif object_type == "MATERIAL":
name = element.Name if element.Name else ""
return normalize_name(name)
elif object_type == "ORGANIZATION":
name = element.Name if element.Name else ""
return normalize_name(name)
elif object_type == "APPLICATION":
name = element.ApplicationFullName if element.ApplicationFullName else ""
return normalize_name(name)
elif object_type == "PERSON":
ident = element.Identification if element.Identification else ""
return normalize_name(ident)
elif object_type == "PERSON_AND_ORGANIZATION":
person_id = element.ThePerson.Identification if element.ThePerson.Identification else ""
org_name = element.TheOrganization.Name if element.TheOrganization.Name else ""
return f"{normalize_name(person_id)}|{normalize_name(org_name)}"
else:
assert_never(object_type)
def get_element_name(element: ifcopenshell.entity_instance) -> str:
"""Get the actual name/identifier from element for sorting purposes"""
if object_type == "STYLE":
return element.Name if element.Name else ""
elif object_type == "MATERIAL":
return element.Name if element.Name else ""
elif object_type == "ORGANIZATION":
return element.Name if element.Name else ""
elif object_type == "APPLICATION":
return element.ApplicationFullName if element.ApplicationFullName else ""
elif object_type == "PERSON":
return element.Identification if element.Identification else ""
elif object_type == "PERSON_AND_ORGANIZATION":
person_id = element.ThePerson.Identification if element.ThePerson.Identification else ""
org_name = element.TheOrganization.Name if element.TheOrganization.Name else ""
return f"{person_id}|{org_name}"
else:
assert_never(object_type)
ifc_file = tool.Ifc.get()
merged_element_types: dict[str, list[str]] = {}
@@ -179,22 +241,47 @@ class Debug(bonsai.core.tool.Debug):
for element_type in element_types:
elements = ifc_file.by_type(element_type, include_subtypes=False)
# Calculate hashes.
hash_to_elements: defaultdict[int, list[ifcopenshell.entity_instance]] = defaultdict(list)
for element in elements:
# Except for styles, ignore unnamed elements as they may be not safe to merge
merge_optional_names = ("STYLE", "PERSON")
not_optional_name = ("APPLICATION", "ORGANIZATION")
has_no_name = ("PERSON_AND_ORGANIZATION",)
if (
object_type not in merge_optional_names
and object_type not in not_optional_name
and object_type not in has_no_name
and not element.Name
):
continue
element_hash = get_hash(element)
hash_to_elements[element_hash].append(element)
# Calculate hashes or name keys.
hash_to_elements: defaultdict[Union[int, str], list[ifcopenshell.entity_instance]]
if by_name_or_identification_only:
# Group by name/identifier only (with .XXX suffix normalization)
hash_to_elements = defaultdict(list)
for element in elements:
name_key = get_name_key(element)
# Skip elements without a valid identifier
if not name_key:
continue
hash_to_elements[name_key].append(element)
# Sort elements within each group to keep the one without suffix (or lowest suffix)
for name_key in hash_to_elements:
# Sort by: 1) prefer names without .XXX suffix, 2) then by original name
def sort_key(el):
name = get_element_name(el)
# Check if name has .XXX suffix
has_suffix = bool(re.match(r"^.+\.\d+$", name))
# Return tuple: (has_suffix, name) - sort by no suffix first, then alphabetically
return (has_suffix, name)
hash_to_elements[name_key].sort(key=sort_key)
else:
# Group by full hash
hash_to_elements = defaultdict(list)
for element in elements:
# Except for styles, ignore unnamed elements as they may be not safe to merge
merge_optional_names = ("STYLE", "PERSON")
not_optional_name = ("APPLICATION", "ORGANIZATION")
has_no_name = ("PERSON_AND_ORGANIZATION",)
if (
object_type not in merge_optional_names
and object_type not in not_optional_name
and object_type not in has_no_name
and not element.Name
):
continue
element_hash = get_hash(element)
hash_to_elements[element_hash].append(element)
merged_elements_names: list[str] = []
# Merge elements.
+38 -8
View File
@@ -208,6 +208,25 @@ class Drawing(bonsai.core.tool.Drawing):
return obj
@classmethod
def get_annotation_drawing(cls, element: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance | None:
for rel in element.HasAssignments:
if rel.is_a("IfcRelAssignsToGroup") and rel.RelatingGroup.ObjectType == "DRAWING":
for e in rel.RelatedObjects:
if e.ObjectType == "DRAWING":
return e
@classmethod
def exclude_annotation_from_drawing(cls, element: ifcopenshell.entity_instance, drawing: ifcopenshell.entity_instance) -> None:
pset = tool.Pset.get_element_pset(drawing, "EPset_Drawing")
if not pset:
pset = ifcopenshell.api.pset.add_pset(ifc_file, product=drawing, name="EPset_Drawing")
exclude = ifcopenshell.util.element.get_property_definition(pset, prop="Exclude") or ""
if exclude:
exclude += "+"
exclude += element.GlobalId
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Exclude": exclude})
@classmethod
def ensure_annotation_in_drawing_plane(
cls, obj: bpy.types.Object, camera: Optional[bpy.types.Object] = None
@@ -218,11 +237,7 @@ class Drawing(bonsai.core.tool.Drawing):
entity = tool.Ifc.get_entity(obj)
if not entity:
return
for rel in entity.HasAssignments:
if rel.is_a("IfcRelAssignsToGroup"):
for e in rel.RelatedObjects:
if e.ObjectType == "DRAWING":
return tool.Ifc.get_object(e)
return tool.Ifc.get_object(cls.get_annotation_drawing(entity))
if not camera:
camera = get_camera_from_annotation_object(obj) or bpy.context.scene.camera
@@ -1401,7 +1416,10 @@ class Drawing(bonsai.core.tool.Drawing):
cls, drawing: ifcopenshell.entity_instance
) -> list[ifcopenshell.entity_instance]:
elements = []
existing_references = cls.get_group_elements(cls.get_drawing_group(drawing))
existing_references = set(cls.get_group_elements(cls.get_drawing_group(drawing)))
if exclude := ifcopenshell.util.element.get_pset(drawing, "EPset_Drawing", "Exclude"):
existing_references.update(ifcopenshell.util.selector.filter_elements(tool.Ifc.get(), exclude))
for element in tool.Ifc.get().by_type("IfcAnnotation"):
if element in existing_references or element == drawing:
continue
@@ -1411,8 +1429,13 @@ class Drawing(bonsai.core.tool.Drawing):
"GlobalReferencing", False
):
elements.append(element)
for element in tool.Ifc.get().by_type("IfcGridAxis"):
elements.append(element)
for element in tool.Ifc.get().by_type("IfcGrid"):
if element in existing_references:
continue
for axis in element.UAxes + element.VAxes + (element.WAxes or tuple()):
if axis in existing_references:
continue
elements.append(element)
target_view = tool.Drawing.get_drawing_target_view(drawing)
if target_view in ("SECTION_VIEW", "ELEVATION_VIEW"):
for element in tool.Ifc.get().by_type("IfcBuildingStorey"):
@@ -2568,3 +2591,10 @@ class Drawing(bonsai.core.tool.Drawing):
ifcopenshell.api.document.remove_reference(tool.Ifc.get(), reference=reference)
tool.Drawing.import_sheets()
@classmethod
def hide_all_drawing_collections(cls) -> None:
for element in tool.Ifc.get().by_type("IfcAnnotation"):
if element.ObjectType == "DRAWING" and (obj := tool.Ifc.get_object(element)):
print('found element', element)
tool.Blender.get_layer_collection(obj.users_collection[0]).hide_viewport = True
+5 -5
View File
@@ -233,20 +233,20 @@ class Geometry(bonsai.core.tool.Geometry):
element = tool.Ifc.get_entity(obj)
if not element:
return
elif element.is_a("IfcAnnotation") and element.ObjectType == "DRAWING":
return bonsai.core.drawing.remove_drawing(tool.Ifc, tool.Drawing, drawing=element)
elif element.is_a("IfcAnnotation"):
if element.ObjectType == "DRAWING":
return bonsai.core.drawing.remove_drawing(tool.Ifc, tool.Drawing, drawing=element)
elif (referenced_element := tool.Drawing.get_annotation_element(element)) and (drawing := tool.Drawing.get_annotation_drawing(element)):
tool.Drawing.exclude_annotation_from_drawing(referenced_element, drawing)
elif element.is_a("IfcRelSpaceBoundary"):
ifcopenshell.api.boundary.remove_boundary(ifc_file, boundary=element)
tool.Boundary.undecorate_boundary(obj)
return bpy.data.objects.remove(obj)
elif element.is_a("IfcGridAxis"):
is_last_axis = False
# Deleting the last W axis is OK
if ((grid := element.PartOfU) and len(grid[0].UAxes) == 1) or (
(grid := element.PartOfV) and len(grid[0].VAxes) == 1
):
is_last_axis = True
if is_last_axis:
return
ifcopenshell.api.grid.remove_grid_axis(ifc_file, axis=element)
return bpy.data.objects.remove(obj)
+59 -37
View File
@@ -1399,7 +1399,6 @@ class Model(bonsai.core.tool.Model):
number_of_risers = number_of_treads + 1
tread_rise = height / number_of_risers
custom_tread_run = any(run != 0 for run in custom_first_last_tread_run)
nosing_overlap = max(nosing_length, 0)
nosing_tread_gap = -min(nosing_length, 0)
nosing_overlap_offset = -V_(nosing_overlap, 0)
@@ -1430,19 +1429,26 @@ class Model(bonsai.core.tool.Model):
default_tread_offset = Vector([tread_run, tread_rise])
def get_tread_data(i):
if custom_tread_run:
current_tread_run = None
if i == 0:
current_tread_run = custom_first_last_tread_run[0]
elif i == number_of_risers - 1:
current_tread_run = custom_first_last_tread_run[1]
# Check if this is first or last tread with custom run
current_tread_run = None
if i == 0 and custom_first_last_tread_run[0] is not None:
current_tread_run = custom_first_last_tread_run[0]
elif i == number_of_risers - 1 and custom_first_last_tread_run[1] is not None:
current_tread_run = custom_first_last_tread_run[1]
if current_tread_run:
tread_offset = default_tread_offset.copy()
tread_offset.x = current_tread_run
tread_verts = deepcopy(default_tread_verts)
tread_verts[-1].x = current_tread_run
return tread_offset, tread_verts
if current_tread_run is not None:
tread_offset = default_tread_offset.copy()
tread_offset.x = current_tread_run
# Handle zero-width treads
if current_tread_run == 0:
# For zero width, just return vertical offset with no horizontal tread
return tread_offset, ()
tread_verts = deepcopy(default_tread_verts)
tread_verts[-1].x = current_tread_run
return tread_offset, tread_verts
return default_tread_offset, default_tread_verts
# treads
@@ -1450,9 +1456,13 @@ class Model(bonsai.core.tool.Model):
for i in range(number_of_risers):
last_vert_i = len(vertices) - 1
tread_offset, tread_verts = get_tread_data(i)
current_tread_verts = [v + current_offset for v in tread_verts]
edges.extend(default_tread_edges + last_vert_i)
vertices.extend(current_tread_verts)
# Skip adding vertices/edges for zero-width treads
if tread_verts:
current_tread_verts = [v + current_offset for v in tread_verts]
edges.extend(default_tread_edges + last_vert_i)
vertices.extend(current_tread_verts)
current_offset += tread_offset
if stair_type == "WOOD/STEEL":
@@ -1467,35 +1477,47 @@ class Model(bonsai.core.tool.Model):
default_tread_offset = V_(tread_run + nosing_tread_gap, tread_rise)
def get_tread_data(i):
if custom_tread_run:
current_tread_run = None
if i == 0 and custom_first_last_tread_run[0] != 0:
current_tread_run = custom_first_last_tread_run[0]
elif i == number_of_risers - 1 and custom_first_last_tread_run[1] != 0:
current_tread_run = custom_first_last_tread_run[1]
# Check if this is first or last tread with custom run
current_tread_run = None
if i == 0 and custom_first_last_tread_run[0] is not None:
current_tread_run = custom_first_last_tread_run[0]
elif i == number_of_risers - 1 and custom_first_last_tread_run[1] is not None:
current_tread_run = custom_first_last_tread_run[1]
if current_tread_run:
tread_offset = default_tread_offset.copy()
tread_offset.x = current_tread_run + nosing_tread_gap
tread_verts = get_tread_verts(size=V_(current_tread_run + nosing_overlap, tread_depth))
return tread_offset, tread_verts
if current_tread_run is not None:
tread_offset = default_tread_offset.copy()
tread_offset.x = current_tread_run + nosing_tread_gap
# Handle zero-width treads
if current_tread_run == 0:
return tread_offset, ()
tread_verts = get_tread_verts(size=V_(current_tread_run + nosing_overlap, tread_depth))
return tread_offset, tread_verts
return default_tread_offset, default_tread_verts
# each tread is a separate shape
cur_offset = V_(0, 0)
tread_index = 0
for i in range(number_of_risers):
tread_offset, tread_verts = get_tread_data(i)
cur_trade_shape = [v + cur_offset + nosing_overlap_offset for v in tread_verts]
vertices.extend(cur_trade_shape)
# Skip adding vertices/edges for zero-width treads
if tread_verts:
cur_trade_shape = [v + cur_offset + nosing_overlap_offset for v in tread_verts]
vertices.extend(cur_trade_shape)
cur_vertex = i * 4
verts_to_add = (
(cur_vertex, cur_vertex + 1),
(cur_vertex + 1, cur_vertex + 2),
(cur_vertex + 2, cur_vertex + 3),
(cur_vertex + 3, cur_vertex),
)
edges.extend(verts_to_add)
cur_vertex = tread_index * 4
verts_to_add = (
(cur_vertex, cur_vertex + 1),
(cur_vertex + 1, cur_vertex + 2),
(cur_vertex + 2, cur_vertex + 3),
(cur_vertex + 3, cur_vertex),
)
edges.extend(verts_to_add)
tread_index += 1
cur_offset += tread_offset
elif stair_type == "GENERIC":
+238
View File
@@ -176,6 +176,27 @@ class TestStairCalculatedParams(NewFile):
calculated_data["Length"] += -0.2 + 0.1
self.compare_data(pset_data, calculated_data)
# zero-width first tread
pset_data = pset_data_base.copy()
calculated_data = calculated_data_base.copy()
pset_data["custom_first_last_tread_run"] = (0.0, 0.0)
calculated_data["Length"] = 0.9 # Only 3 treads at 0.3 each
self.compare_data(pset_data, calculated_data)
# zero-width last tread
pset_data = pset_data_base.copy()
calculated_data = calculated_data_base.copy()
pset_data["custom_first_last_tread_run"] = (0.3, 0.0)
calculated_data["Length"] = 0.9 # Only 3 treads at 0.3 each
self.compare_data(pset_data, calculated_data)
# both first and last treads zero-width
pset_data = pset_data_base.copy()
calculated_data = calculated_data_base.copy()
pset_data["custom_first_last_tread_run"] = (0.0, 0.0)
calculated_data["Length"] = 0.6 # Only 2 middle treads at 0.3 each
self.compare_data(pset_data, calculated_data)
# overlap affects stair length only by first tread
pset_data = pset_data_base.copy()
calculated_data = calculated_data_base.copy()
@@ -296,6 +317,94 @@ class TestGenerateStair2DProfile(NewFile):
generated_profile = subject.generate_stair_2d_profile(**kwargs)
self.compare_data(generated_profile, expected_profile)
def test_create_concrete_stair_zero_width_first_tread(self):
"""Test concrete stair with zero-width first tread"""
kwargs = {
"base_slab_depth": 0.25,
"has_top_nib": False,
"height": 1.0,
"number_of_treads": 3,
"stair_type": "CONCRETE",
"top_slab_depth": 0.25,
"tread_depth": 0.25,
"tread_run": 0.3,
"width": 1.2,
"custom_first_last_tread_run": (0.0, 0.0),
}
verts_data = (
V(0.0, 0, 0.0),
# First tread skipped - goes straight to second tread
V(0.0, 0, 0.5),
V(0.3, 0, 0.5),
V(0.3, 0, 0.75),
V(0.6, 0, 0.75),
V(0.6, 0, 1.0),
V(0.9, 0, 1.0),
V(0.9, 0, 0.67457),
V(0.0, 0, -0.25),
)
edges_data = (
(0, 1),
(1, 2),
(2, 3),
(3, 4),
(4, 5),
(5, 6),
(6, 7),
(8, 0),
(7, 8),
)
edges_data = [e[::-1] for e in edges_data]
faces_data = ()
expected_profile = (verts_data, edges_data, faces_data)
generated_profile = subject.generate_stair_2d_profile(**kwargs)
self.compare_data(generated_profile, expected_profile)
def test_create_concrete_stair_zero_width_last_tread(self):
"""Test concrete stair with zero-width last tread"""
kwargs = {
"base_slab_depth": 0.25,
"has_top_nib": False,
"height": 1.0,
"number_of_treads": 3,
"stair_type": "CONCRETE",
"top_slab_depth": 0.25,
"tread_depth": 0.25,
"tread_run": 0.3,
"width": 1.2,
"custom_first_last_tread_run": (0.0, 0.0),
}
verts_data = (
V(0.0, 0, 0.0),
V(0.0, 0, 0.25),
V(0.3, 0, 0.25),
V(0.3, 0, 0.5),
V(0.6, 0, 0.5),
V(0.6, 0, 0.75),
V(0.9, 0, 0.75),
# Last tread skipped
V(0.9, 0, 0.67457),
V(0.1, 0, -0.25),
V(0.0, 0, -0.25),
)
edges_data = (
(0, 1),
(1, 2),
(2, 3),
(3, 4),
(4, 5),
(5, 6),
(6, 7),
(9, 0),
(8, 9),
(7, 8),
)
edges_data = [e[::-1] for e in edges_data]
faces_data = ()
expected_profile = (verts_data, edges_data, faces_data)
generated_profile = subject.generate_stair_2d_profile(**kwargs)
self.compare_data(generated_profile, expected_profile)
def test_create_wood_steel_stair(self):
kwargs = {
"height": 1.0,
@@ -348,6 +457,100 @@ class TestGenerateStair2DProfile(NewFile):
generated_profile = subject.generate_stair_2d_profile(**kwargs)
self.compare_data(generated_profile, expected_profile)
def test_create_wood_steel_stair_zero_width_first_tread(self):
"""Test wood/steel stair with zero-width first tread"""
kwargs = {
"height": 1.0,
"number_of_treads": 3,
"stair_type": "WOOD/STEEL",
"tread_depth": 0.25,
"tread_run": 0.3,
"width": 1.2,
"custom_first_last_tread_run": (0.0, 0.0),
}
verts_data = (
# First tread skipped - start at second tread
V(0.0, 0, 0.25),
V(0.3, 0, 0.25),
V(0.3, 0, 0.5),
V(0.0, 0, 0.5),
V(0.3, 0, 0.5),
V(0.6, 0, 0.5),
V(0.6, 0, 0.75),
V(0.3, 0, 0.75),
V(0.6, 0, 0.75),
V(0.9, 0, 0.75),
V(0.9, 0, 1.0),
V(0.6, 0, 1.0),
)
edges_data = (
(0, 1),
(1, 2),
(2, 3),
(3, 0),
(4, 5),
(5, 6),
(6, 7),
(7, 4),
(8, 9),
(9, 10),
(10, 11),
(11, 8),
)
faces_data = ()
expected_profile = (verts_data, edges_data, faces_data)
generated_profile = subject.generate_stair_2d_profile(**kwargs)
self.compare_data(generated_profile, expected_profile)
def test_create_wood_steel_stair_zero_width_last_tread(self):
"""Test wood/steel stair with zero-width last tread"""
kwargs = {
"height": 1.0,
"number_of_treads": 3,
"stair_type": "WOOD/STEEL",
"tread_depth": 0.25,
"tread_run": 0.3,
"width": 1.2,
"custom_first_last_tread_run": (0.0, 0.0),
}
verts_data = (
V(0.0, 0, 0.0),
V(0.3, 0, 0.0),
V(0.3, 0, 0.25),
V(0.0, 0, 0.25),
V(0.3, 0, 0.25),
V(0.6, 0, 0.25),
V(0.6, 0, 0.5),
V(0.3, 0, 0.5),
V(0.6, 0, 0.5),
V(0.9, 0, 0.5),
V(0.9, 0, 0.75),
V(0.6, 0, 0.75),
# Last tread skipped
)
edges_data = (
(0, 1),
(1, 2),
(2, 3),
(3, 0),
(4, 5),
(5, 6),
(6, 7),
(7, 4),
(8, 9),
(9, 10),
(10, 11),
(11, 8),
)
faces_data = ()
expected_profile = (verts_data, edges_data, faces_data)
generated_profile = subject.generate_stair_2d_profile(**kwargs)
self.compare_data(generated_profile, expected_profile)
def test_create_generic_stair(self):
kwargs = {"height": 1.0, "number_of_treads": 3, "stair_type": "GENERIC", "tread_run": 0.3, "width": 1.2}
verts_data = (
@@ -381,6 +584,41 @@ class TestGenerateStair2DProfile(NewFile):
generated_profile = subject.generate_stair_2d_profile(**kwargs)
self.compare_data(generated_profile, expected_profile)
def test_create_generic_stair_zero_width_treads(self):
"""Test generic stair with zero-width first and last treads"""
kwargs = {
"height": 1.0,
"number_of_treads": 3,
"stair_type": "GENERIC",
"tread_run": 0.3,
"width": 1.2,
"custom_first_last_tread_run": (0.0, 0.0),
}
verts_data = (
V(0.0, 0, 0.0),
# First tread skipped
V(0.0, 0, 0.5),
V(0.3, 0, 0.5),
V(0.3, 0, 0.75),
V(0.6, 0, 0.75),
# Last tread skipped
V(0.6, 0, 0.0),
)
edges_data = (
(0, 1),
(1, 2),
(2, 3),
(3, 4),
(4, 5),
(5, 0),
)
edges_data = [e[::-1] for e in edges_data]
faces_data = ()
expected_profile = (verts_data, edges_data, faces_data)
generated_profile = subject.generate_stair_2d_profile(**kwargs)
self.compare_data(generated_profile, expected_profile)
class TestUsingArrays(NewFile):
def setup_array(self, add_second_layer=False, sync_children=False):
@@ -565,6 +565,40 @@ def get_predefined_type(element: ifcopenshell.entity_instance) -> Union[str, Non
return predefined_type
def is_userdefined_type(element: ifcopenshell.entity_instance) -> bool:
"""Checks if the predefined type is userdefined
:param element: The IFC Element entity
:return: True if userdefined
Example:
.. code:: python
element = ifcopenshell.by_type("IfcWall")[0]
is_userdefined_type = ifcopenshell.util.element.is_userdefined_type(element)
"""
if element_type := get_type(element):
predefined_type = getattr(element_type, "PredefinedType", None)
if predefined_type == "USERDEFINED":
return True
elif not predefined_type:
predefined_type = getattr(element_type, "ElementType", ...)
if predefined_type == ...:
predefined_type = getattr(element_type, "ProcessType", None)
if predefined_type:
return True
if predefined_type and predefined_type != "NOTDEFINED":
return False
predefined_type = getattr(element, "PredefinedType", None)
if predefined_type == "USERDEFINED":
return True
elif not predefined_type:
return bool(getattr(element, "ObjectType", None))
return False
def get_type(element: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]:
"""Retrieves the construction type element of an element occurrence.
@@ -369,6 +369,65 @@ class TestGetPredefinedTypeIFC4(test.bootstrap.IFC4):
assert subject.get_predefined_type(element_type) == "NOTDEFINED"
class TestIsUserdefinedTypeIFC4(test.bootstrap.IFC4):
def test_getting_a_predefined_element(self):
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
element.PredefinedType = "PARTITIONING"
assert not subject.is_userdefined_type(element)
def test_getting_an_element_userdefined_type(self):
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
element.PredefinedType = "USERDEFINED"
element.ObjectType = "FOOBAR"
assert subject.is_userdefined_type(element)
def test_getting_an_element_type_without_a_predefined_type_attribute(self):
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcAnnotation")
element.ObjectType = "FOOBAR"
assert subject.is_userdefined_type(element)
def test_getting_an_inherited_predefined_type(self):
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
element_type = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWallType")
ifcopenshell.api.type.assign_type(self.file, related_objects=[element], relating_type=element_type)
element_type.PredefinedType = "PARTITIONING"
assert not subject.is_userdefined_type(element)
def test_getting_an_inherited_userdefined_type_for_an_element_type(self):
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
element_type = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWallType")
ifcopenshell.api.type.assign_type(self.file, related_objects=[element], relating_type=element_type)
element_type.PredefinedType = "USERDEFINED"
element_type.ElementType = "FOOBAR"
assert subject.is_userdefined_type(element)
def test_getting_an_overriden_predefined_type(self):
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
element_type = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWallType")
ifcopenshell.api.type.assign_type(self.file, related_objects=[element], relating_type=element_type)
element_type.PredefinedType = "NOTDEFINED"
element.PredefinedType = "PARTITIONING"
assert not subject.is_userdefined_type(element)
def test_getting_an_inherited_userdefined_type_for_a_process_type(self):
element = ifcopenshell.api.sequence.add_task(self.file)
element_type = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcTaskType")
ifcopenshell.api.type.assign_type(self.file, related_objects=[element], relating_type=element_type)
element_type.PredefinedType = "USERDEFINED"
element_type.ProcessType = "FOOBAR"
assert subject.is_userdefined_type(element)
def test_getting_an_element_type_predefined_type(self):
element_type = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWallType")
element_type.PredefinedType = "PARTITIONING"
assert not subject.is_userdefined_type(element_type)
def test_getting_an_element_type_null_predefined_type(self):
element_type = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWallType")
element_type.PredefinedType = "NOTDEFINED"
assert not subject.is_userdefined_type(element_type)
class TestGetTypeIFC4(test.bootstrap.IFC4):
def test_getting_the_type_of_a_product(self):
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
+1 -1
View File
@@ -165,7 +165,7 @@ namespace IfcParse {
}
}
#if IFOPSH_WITH_ROCKSDB
#ifdef IFOPSH_WITH_ROCKSDB
namespace impl {
+1 -1
View File
@@ -274,7 +274,7 @@ public:
}
iterator& operator++() {
#if IFOPSH_WITH_ROCKSDB
#ifdef IFOPSH_WITH_ROCKSDB
if (it_) {
it_->Next();
check_valid();
+4 -3
View File
@@ -44,11 +44,12 @@ class Patcher(ifcpatch.BasePatcher):
uniques = {}
i = 0
for element in self.file.by_type(ifc_class):
data = "-".join([str(a) for a in element])
if unique := uniques.get(data, None):
ifc_class = element.is_a()
key = "-".join([str(a) for a in element])
if unique := uniques.get(ifc_class, {}).get(key, None):
ifcopenshell.util.element.replace_element(element, unique)
self.file.remove(element)
i += 1
else:
uniques[data] = element
uniques.setdefault(ifc_class, {})[key] = element
print(f"Replaced {i} {ifc_class}")
+22 -3
View File
@@ -206,6 +206,12 @@ class Entity(Facet):
except:
# If the user has specified a class that doesn't exist in the version
results = []
if not self.name.endswith("TYPE"):
try:
for element_type in ifc_file.by_type(f"{self.name}Type"):
results.extend(ifcopenshell.util.element.get_types(element_type))
except:
pass
else:
results = []
ifc_classes = [t for t in ifc_file.wrapped_data.types() if t.upper() == self.name]
@@ -223,12 +229,25 @@ class Entity(Facet):
is_pass = inst.is_a().upper() == self.name
reason = None
if not is_pass:
if (
not is_pass
and inst.file.schema == "IFC2X3"
and not self.name.endswith("TYPE")
and (element_type := ifcopenshell.util.element.get_type(inst))
):
is_pass = element_type.is_a().upper() == f"{self.name}TYPE"
reason = {"type": "NAME", "actual": element_type.is_a().upper()[:-4]}
elif not is_pass:
reason = {"type": "NAME", "actual": inst.is_a().upper()}
if is_pass and self.predefinedType:
predefined_type = ifcopenshell.util.element.get_predefined_type(inst)
is_pass = predefined_type == self.predefinedType
if self.predefinedType == "USERDEFINED":
is_pass = ifcopenshell.util.element.is_userdefined_type(inst)
if not is_pass:
predefined_type = ifcopenshell.util.element.get_predefined_type(inst)
else:
predefined_type = ifcopenshell.util.element.get_predefined_type(inst)
is_pass = predefined_type == self.predefinedType
if not is_pass:
reason = {"type": "PREDEFINEDTYPE", "actual": predefined_type}
+28 -2
View File
@@ -172,10 +172,10 @@ class TestEntity:
facet = Entity(name="IFCWALL", predefinedType="USERDEFINED")
ifc = ifcopenshell.file()
run(
"A predefined type must always specify a meaningful type, not USERDEFINED itself",
"A predefined type may specify USERDEFINED itself",
facet=facet,
inst=ifc.createIfcWall(PredefinedType="USERDEFINED", ObjectType="WALDO"),
expected=False,
expected=True,
)
ifc = ifcopenshell.file()
@@ -230,6 +230,32 @@ class TestEntity:
wall3 = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWall", predefined_type="BAZFOO")
run("Restrictions an be specified for the predefined type 3/3", facet=facet, inst=wall3, expected=False)
def test_ifc2x3_occurrence_type_mapping(self):
set_facet("entity")
ifc = ifcopenshell.file(schema="IFC2X3")
application = ifcopenshell.api.owner.add_application(ifc)
person = ifcopenshell.api.owner.add_person(
ifc, identification="LPARTEE", family_name="Partee", given_name="Leeable"
)
organisation = ifcopenshell.api.owner.add_organisation(
ifc, identification="AWB", name="Architects Without Ballpens"
)
user = ifcopenshell.api.owner.add_person_and_organisation(ifc, person=person, organisation=organisation)
ifcopenshell.api.owner.settings.get_user = lambda x: user
ifcopenshell.api.owner.settings.get_application = lambda x: application
element = ifcopenshell.api.root.create_entity(ifc, "IfcFlowTerminal")
element_type = ifcopenshell.api.root.create_entity(ifc, "IfcAirTerminalType")
ifcopenshell.api.type.assign_type(ifc, related_objects=[element], relating_type=element_type)
facet = Entity(name="IFCAIRTERMINAL")
assert facet.filter(ifc) == [element]
run("In IFC2X3 the type class is checked instead 1/2", facet=facet, inst=element, expected=True)
facet = Entity(name="IFCELECTRICAPPLIANCE")
assert facet.filter(ifc) == []
run("In IFC2X3 the type class is checked instead 2/2", facet=facet, inst=element, expected=False)
def test_to_string_required_applicability(self):
spec = ifctester.ids.Specification(name="Foo", minOccurs=1, maxOccurs="unbounded")
facet = Entity(name="IFCWALL")