Skip to content

Commit 67678cb

Browse files
committed
Merge branch 'v0.8.0' of https://github.com/IfcOpenShell/IfcOpenShell into v0.8.0
2 parents 4d68817 + f05b0d9 commit 67678cb

34 files changed

Lines changed: 948 additions & 169 deletions

File tree

src/bonsai/bonsai/bim/data/assets/patterns.svg

Lines changed: 32 additions & 5 deletions
Loading

src/bonsai/bonsai/bim/data/pset/EPset_Drawing.ifc

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@ DATA;
1313
#6=IFCSIMPLEPROPERTYTEMPLATE('0AK5C2UpL4$eaac2LszAx$',$,'HasUnderlay','',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
1414
#7=IFCSIMPLEPROPERTYTEMPLATE('2j2ZEZR8X5tONm7kli5hM6',$,'HasLinework','',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
1515
#8=IFCSIMPLEPROPERTYTEMPLATE('1ttChRysH9UuEX2FeMj5Hu',$,'HasAnnotation','',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
16-
#9=IFCSIMPLEPROPERTYTEMPLATE('2NPPxuABv1huDTVh32TFgw',$,'GlobalReferencing','',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
16+
#9=IFCSIMPLEPROPERTYTEMPLATE('2NPPxuABv1huDTVh32TFgw',$,'GlobalReferencing','Whether or not this drawing can be referenced in other drawings.',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
1717
#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.);
18-
#11=IFCSIMPLEPROPERTYTEMPLATE('3Z0BXPSG5CWgtI33ioV7aj',$,'Include','Selector expression to include ifc elements in the drawing',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.);
19-
#12=IFCSIMPLEPROPERTYTEMPLATE('1RVts_g3PAw98PJA2yL3bO',$,'Exclude','Selector expression to exclude ifc elements in the drawing',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.);
18+
#11=IFCSIMPLEPROPERTYTEMPLATE('3Z0BXPSG5CWgtI33ioV7aj',$,'Include','Selector expression to include elements in the drawing',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.);
19+
#12=IFCSIMPLEPROPERTYTEMPLATE('1RVts_g3PAw98PJA2yL3bO',$,'Exclude','Selector expression to exclude elements in the drawing',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.);
2020
#13=IFCSIMPLEPROPERTYTEMPLATE('0c1$8NpYDEaBiJrj16jHIo',$,'Stylesheet','',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.);
2121
#14=IFCSIMPLEPROPERTYTEMPLATE('3mRF52q81FQB$h4oTh7M45',$,'Markers','',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.);
2222
#15=IFCSIMPLEPROPERTYTEMPLATE('1rhr_0N3LDtuORcEJP0KXM',$,'Symbols','',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.);

src/bonsai/bonsai/bim/export_ifc.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
from mathutils import Vector
3737
from typing import Union
3838
from logging import Logger
39+
from math import radians
3940

4041

4142
class IfcExporter:
@@ -104,7 +105,17 @@ def sync_all_objects(self) -> list[ifcopenshell.entity_instance]:
104105

105106
def sync_object_placement(self, obj: bpy.types.Object) -> Union[ifcopenshell.entity_instance, None]:
106107
element = self.file.by_id(tool.Blender.get_object_bim_props(obj).ifc_definition_id)
107-
if tool.Geometry.is_scaled(obj):
108+
# Handle camera scales specially
109+
if obj.type == "CAMERA":
110+
# Check if this is a reflected ceiling plan camera
111+
camera = tool.Ifc.get_entity(obj)
112+
if ifcopenshell.util.element.get_pset(camera, "EPset_Drawing", "TargetView") == "REFLECTED_PLAN_VIEW":
113+
# Ensure reflected ceiling cameras have the correct scale
114+
if obj.scale != (-1, -1, -1):
115+
obj.scale = (-1, -1, -1)
116+
obj.rotation_euler = (0.0, 0.0, radians(180))
117+
# Skip all other scale handling for cameras
118+
elif tool.Geometry.is_scaled(obj):
108119
bpy.ops.bim.update_representation(obj=obj.name)
109120
# update_representation might not apply scale if the object has openings
110121
# reset it, so let user know that the scale wasn't saved.

src/bonsai/bonsai/bim/module/aggregate/operator.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,13 +94,20 @@ class BIM_OT_aggregate_unassign_object(bpy.types.Operator, tool.Ifc.Operator):
9494
bl_options = {"REGISTER", "UNDO"}
9595

9696
def _execute(self, context):
97+
aggregates_to_check = set()
98+
99+
# First pass: unassign all parts and track their aggregates
97100
for obj in tool.Blender.get_selected_objects():
98101
element = tool.Ifc.get_entity(obj)
99102
if not element:
100103
continue
101104
aggregate = ifcopenshell.util.element.get_aggregate(element)
102105
if not aggregate:
103106
continue
107+
108+
# Track this aggregate for later checking
109+
aggregates_to_check.add(aggregate)
110+
104111
core.unassign_object(
105112
tool.Ifc,
106113
tool.Aggregate,
@@ -116,6 +123,29 @@ def _execute(self, context):
116123
pset = tool.Ifc.get().by_id(pset["id"])
117124
ifcopenshell.api.pset.remove_pset(tool.Ifc.get(), product=element, pset=pset)
118125

126+
# Second pass: delete aggregates that now have no parts
127+
deleted_aggregates = []
128+
for aggregate in aggregates_to_check:
129+
related_objects = ifcopenshell.util.element.get_parts(aggregate)
130+
if len(related_objects) == 0:
131+
aggregate_name = aggregate.Name or f"{aggregate.is_a()} #{aggregate.id()}"
132+
deleted_aggregates.append(aggregate_name)
133+
134+
aggregate_obj = tool.Ifc.get_object(aggregate)
135+
if aggregate_obj:
136+
ifcopenshell.api.root.remove_product(tool.Ifc.get(), product=aggregate)
137+
bpy.data.objects.remove(aggregate_obj, do_unlink=True)
138+
139+
# Show info message if aggregates were deleted
140+
if deleted_aggregates:
141+
if len(deleted_aggregates) == 1:
142+
self.report(
143+
{"INFO"}, f"Aggregate '{deleted_aggregates[0]}' was deleted because it had no remaining parts"
144+
)
145+
else:
146+
aggregate_list = ", ".join(f"'{name}'" for name in deleted_aggregates)
147+
self.report({"INFO"}, f"Aggregates {aggregate_list} were deleted because they had no remaining parts")
148+
119149

120150
class BIM_OT_enable_editing_aggregate(bpy.types.Operator):
121151
"""Enable editing aggregation relationship"""

src/bonsai/bonsai/bim/module/debug/operator.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -818,26 +818,49 @@ def _execute(self, context):
818818
class MergeIdenticalObjects(bpy.types.Operator, tool.Ifc.Operator):
819819
bl_idname = "bim.merge_identical_objects"
820820
bl_label = "Merge Identical Objects"
821-
bl_description = "For materials currently only IfcMaterials are supported"
821+
bl_description = (
822+
"Merge identical IFC objects (that match all attributes).\n"
823+
"\n"
824+
"SHIFT + CLICK to merge by name/identification attribute only.\n"
825+
"Merges names with number suffix, as well (ex: foo, foo.001, foo.002)\n"
826+
)
822827
bl_options = {"REGISTER", "UNDO"}
823828

824829
object_type: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
825830
name="Object Type",
826831
items=((s, s.capitalize(), "") for s in get_args(tool.Debug.PurgeMergeObjectType)),
827832
)
828833

834+
by_name_or_identification_only: bpy.props.BoolProperty(
835+
name="By Name/Identification Only",
836+
description="Merge based only on Name or Identification attribute, ignoring other properties",
837+
default=False,
838+
)
839+
829840
if TYPE_CHECKING:
830841
object_type: tool.Debug.PurgeMergeObjectType
831842

843+
def invoke(self, context, event):
844+
# Check if shift key is pressed
845+
if event.shift:
846+
self.by_name_or_identification_only = True
847+
else:
848+
self.by_name_or_identification_only = False
849+
850+
return self.execute(context)
851+
832852
def _execute(self, context):
833853
object_type: str = self.object_type
834854
if object_type in ("PROFILE", "TYPE"):
835855
self.report({"ERROR"}, f"Unsupported object type {object_type}.")
836856
return {"CANCELLED"}
837857

838-
merged_data = tool.Debug.merge_identical_objects(object_type)
858+
merged_data = tool.Debug.merge_identical_objects(
859+
object_type, by_name_or_identification_only=self.by_name_or_identification_only
860+
)
839861
plural_object_type = f"{object_type.lower().replace('_', ' ')}s"
840862
if merged_data:
863+
merge_mode = " by name/identification" if self.by_name_or_identification_only else ""
841864
for element_type, element_names in merged_data.items():
842865
print(f"- {element_type}:")
843866
for name in element_names:
@@ -846,7 +869,8 @@ def _execute(self, context):
846869
merged = sum(len(v) for v in merged_data.values())
847870

848871
msg = " See system console for details." if merged else ""
849-
self.report({"INFO"}, f"{merged} identical {plural_object_type} were merged.{msg}")
872+
merge_mode = " (by name/identification)" if self.by_name_or_identification_only else ""
873+
self.report({"INFO"}, f"{merged} identical {plural_object_type} were merged{merge_mode}.{msg}")
850874

851875
if merged == 0:
852876
return

src/bonsai/bonsai/bim/module/drawing/operator.py

Lines changed: 64 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,8 @@ def generate_underlay(self, context: bpy.types.Context) -> Union[str, None]:
442442
context.scene.render.filepath = str(Path(svg_path).with_suffix(".png"))
443443
assert (drawing_style := self.cprops.get_active_drawing_style())
444444

445+
tool.Blender.sync_render_visibility()
446+
445447
if drawing_style.render_type == "DEFAULT":
446448
bpy.ops.render.render(write_still=True)
447449
else:
@@ -625,7 +627,7 @@ def generate_bisect_linework(self, context: bpy.types.Context, root) -> None:
625627
path.attrib["d"] = d
626628
group.append(g)
627629

628-
def generate_wall_layers(self, context: bpy.types.Context, root) -> None:
630+
def generate_material_layers(self, context: bpy.types.Context, root) -> None:
629631
for el in root.findall(".//{http://www.w3.org/2000/svg}g[@{http://www.ifcopenshell.org/ns}guid]"):
630632
if "projection" in el.get("class", "").split():
631633
continue
@@ -794,8 +796,9 @@ def generate_freestyle_linework(self, context: bpy.types.Context) -> str | None:
794796
edge_bm.to_mesh(edge_mesh)
795797
edge_bm.free()
796798

797-
actual_path = svg_path[0:-4] + "0001.svg"
799+
freestyle_svg_exporter = tool.Blender.get_addon("freestyle_svg_exporter")
798800
context.scene.render.filepath = svg_path[0:-4]
801+
actual_path = freestyle_svg_exporter.create_path(bpy.context.scene)
799802
bpy.ops.render.render(write_still=False)
800803

801804
os.replace(actual_path, svg_path)
@@ -839,7 +842,8 @@ def generate_freestyle_linework(self, context: bpy.types.Context) -> str | None:
839842

840843
if tool.Drawing.is_camera_orthographic():
841844
self.generate_bisect_linework(context, root)
842-
self.generate_wall_layers(context, root)
845+
if self.cprops.generate_material_layers:
846+
self.generate_material_layers(context, root)
843847
self.merge_linework_and_add_metadata(root)
844848
self.move_elements_to_top(root)
845849

@@ -937,12 +941,14 @@ def generate_linework(self, context: bpy.types.Context) -> Union[str, None]:
937941
if self.cprops.cut_mode == "BISECT":
938942
self.remove_cut_linework(root)
939943
self.generate_bisect_linework(context, root)
940-
self.generate_wall_layers(context, root)
944+
if self.cprops.generate_material_layers:
945+
self.generate_material_layers(context, root)
941946
self.merge_linework_and_add_metadata(root)
942947
self.move_elements_to_top(root)
943948
elif self.cprops.cut_mode == "OPENCASCADE":
944949
self.move_projection_to_bottom(root)
945-
self.generate_wall_layers(context, root)
950+
if self.cprops.generate_material_layers:
951+
self.generate_material_layers(context, root)
946952
self.merge_linework_and_add_metadata(root)
947953
self.move_elements_to_top(root)
948954

@@ -1348,7 +1354,7 @@ def merge_linework_and_add_metadata(self, root):
13481354
join_criteria = join_criteria.split(",")
13491355
else:
13501356
# Drawing convention states that same objects classes with the same material are merged when cut.
1351-
join_criteria = ["class", "material.Name", "/Pset_.*Common/.Status", "EPset_Status.Status", "Material.Name"]
1357+
join_criteria = ["class", "material.Name", "/Pset_.*Common/.Status", "EPset_Status.Status", "EPset_Status.UserDefinedStatus"]
13521358

13531359
group = root.find("{http://www.w3.org/2000/svg}g")
13541360
joined_paths = {}
@@ -1477,11 +1483,10 @@ def merge_linework_and_add_metadata(self, root):
14771483
joined_paths.setdefault(hash_keys, []).append(el)
14781484

14791485
for key, els in joined_paths.items():
1480-
polygons = []
1481-
classes = set()
1486+
queue = []
14821487

14831488
for el in els:
1484-
classes.update(el.attrib["class"].split())
1489+
classes = set(el.attrib["class"].split())
14851490
classes.add(el.attrib["{http://www.ifcopenshell.org/ns}guid"])
14861491
is_closed_polygon = False
14871492
for path in el.findall("{http://www.w3.org/2000/svg}path"):
@@ -1496,31 +1501,30 @@ def merge_linework_and_add_metadata(self, root):
14961501
coords.append(coords[0])
14971502
if len(coords) > 2 and coords[0] == coords[-1]:
14981503
is_closed_polygon = True
1499-
polygons.append(shapely.Polygon(coords))
1504+
queue.append((shapely.Polygon(coords), classes))
15001505
if is_closed_polygon:
15011506
el.getparent().remove(el)
15021507

1503-
try:
1504-
merged_polygons = shapely.ops.unary_union(polygons)
1505-
except:
1506-
print("Warning. Portions of the merge failed. Please report a bug!", polygons)
1507-
merged_polygons = polygons
1508-
1509-
if type(merged_polygons) == shapely.MultiPolygon:
1510-
merged_polygons = merged_polygons.geoms
1511-
elif type(merged_polygons) == shapely.Polygon:
1512-
merged_polygons = [merged_polygons]
1513-
else:
1514-
merged_polygons = []
1508+
while queue:
1509+
polygon, polygon_classes = queue.pop()
1510+
for polygon2, polygon2_classes in queue[:]:
1511+
try:
1512+
merged_polygon = shapely.union(polygon, polygon2)
1513+
except:
1514+
print("Warning. Portions of the merge failed. Please report a bug!", polygon, polygon2)
1515+
continue
1516+
if type(merged_polygon) == shapely.Polygon:
1517+
polygon = merged_polygon
1518+
polygon_classes.update(polygon2_classes)
1519+
queue.remove((polygon2, polygon2_classes))
15151520

1516-
for polygon in merged_polygons:
15171521
g = etree.Element("g")
15181522
path = etree.SubElement(g, "path")
15191523
d = "M" + " L".join([",".join([str(o) for o in co]) for co in polygon.exterior.coords[0:-1]]) + " Z"
15201524
for interior in polygon.interiors:
15211525
d += " M" + " L".join([",".join([str(o) for o in co]) for co in interior.coords[0:-1]]) + " Z"
15221526
path.attrib["d"] = d
1523-
g.set("class", " ".join(list(classes)))
1527+
g.set("class", " ".join(list(polygon_classes)))
15241528
group.append(g)
15251529

15261530
def drawing_to_model_co(self, x: float, y: float) -> Vector:
@@ -1862,13 +1866,15 @@ class AddDrawingToSheet(bpy.types.Operator, tool.Ifc.Operator):
18621866
@classmethod
18631867
def poll(cls, context):
18641868
props = tool.Drawing.get_document_props()
1865-
# Won't be visible in UI anyway.
1866-
prefs = tool.Blender.get_addon_preferences()
1867-
if not props.sheets or not prefs.data_dir:
1868-
return False
18691869
if not tool.Drawing.get_active_drawing_item():
18701870
cls.poll_message_set("No drawing selected.")
18711871
return False
1872+
if not props.sheets:
1873+
cls.poll_message_set("No sheets available.")
1874+
return False
1875+
if not tool.Blender.get_user_data_dir():
1876+
cls.poll_message_set("BIM data directory not set.")
1877+
return False
18721878
return True
18731879

18741880
def _execute(self, context):
@@ -2222,6 +2228,7 @@ def refine_elements(
22222228
)
22232229

22242230
tool.Blender.reset_object_visibility()
2231+
tool.Drawing.hide_all_drawing_collections()
22252232
tool.Blender.update_viewport()
22262233
bonsai.bim.handler.refresh_ui_data()
22272234

@@ -2318,9 +2325,8 @@ def _execute(self, context) -> set["rna_enums.OperatorReturnItems"]:
23182325

23192326
dprops.active_drawing_id = self.drawing
23202327
dprops.drawing_styles.clear()
2321-
if ifcopenshell.util.element.get_pset(drawing, "EPset_Drawing", "HasUnderlay"):
2322-
bpy.ops.bim.reload_drawing_styles()
2323-
bpy.ops.bim.activate_drawing_style()
2328+
bpy.ops.bim.reload_drawing_styles()
2329+
bpy.ops.bim.activate_drawing_style()
23242330

23252331
if tool.Drawing.is_camera_orthographic():
23262332
core.sync_references(tool.Ifc, tool.Collector, tool.Drawing, drawing=tool.Ifc.get().by_id(self.drawing))
@@ -2333,10 +2339,21 @@ def _execute(self, context) -> set["rna_enums.OperatorReturnItems"]:
23332339
camera = context.scene.camera
23342340
assert camera
23352341
camera_props = tool.Drawing.get_camera_props(camera)
2342+
# Check if this is a reflected ceiling camera and preserve its scale
2343+
camera_element = tool.Ifc.get_entity(camera)
2344+
is_reflected = False
2345+
if camera_element:
2346+
is_reflected = ifcopenshell.util.element.get_pset(camera_element, "EPset_Drawing", "TargetView") == "REFLECTED_PLAN_VIEW"
2347+
if is_reflected and camera.scale != (-1, -1, -1):
2348+
camera.scale = (-1, -1, -1)
2349+
camera.rotation_euler = (0.0, 0.0, radians(180))
2350+
23362351
if camera_props.update_representation(camera.matrix_world):
23372352
bpy.ops.bim.update_representation(obj=camera.name, ifc_representation_class="")
2338-
# See 6452 and 6478.
2339-
# bpy.ops.bim.refresh_clipping_planes("INVOKE_DEFAULT")
2353+
# Restore the scale after update if needed
2354+
if is_reflected:
2355+
camera.scale = (-1, -1, -1)
2356+
camera.rotation_euler = (0.0, 0.0, radians(180))
23402357

23412358
return {"FINISHED"}
23422359

@@ -2812,8 +2829,13 @@ def poll(cls, context):
28122829
if not props.schedules:
28132830
cls.poll_message_set("No schedule selected.")
28142831
return False
2815-
prefs = tool.Blender.get_addon_preferences()
2816-
return props.schedules and props.sheets and prefs.data_dir
2832+
if not props.sheets:
2833+
cls.poll_message_set("No sheets available.")
2834+
return False
2835+
if not tool.Blender.get_user_data_dir():
2836+
cls.poll_message_set("BIM data directory not set.")
2837+
return False
2838+
return True
28172839

28182840
def _execute(self, context):
28192841
props = tool.Drawing.get_document_props()
@@ -2880,8 +2902,13 @@ def poll(cls, context):
28802902
if not props.references:
28812903
cls.poll_message_set("No reference selected.")
28822904
return False
2883-
bim_props = tool.Blender.get_bim_props()
2884-
return props.references and props.sheets and bim_props.data_dir
2905+
if not props.sheets:
2906+
cls.poll_message_set("No sheets available.")
2907+
return False
2908+
if not tool.Blender.get_user_data_dir():
2909+
cls.poll_message_set("BIM data directory not set.")
2910+
return False
2911+
return True
28852912

28862913
def _execute(self, context):
28872914
props = tool.Drawing.get_document_props()

src/bonsai/bonsai/bim/module/drawing/prop.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,11 @@ class BIMCameraProperties(PropertyGroup):
498498
name="Linework Mode",
499499
update=get_update_layer_callback("linework_mode", "LineworkMode"),
500500
)
501+
generate_material_layers: bpy.props.BoolProperty(
502+
name="Generate Material Layers",
503+
description="Generate material layer linework in drawings",
504+
default=True
505+
)
501506
fill_mode: EnumProperty(
502507
items=[
503508
("NONE", "None", "Disable filling areas seen in projection"),

0 commit comments

Comments
 (0)