mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-13 02:47:48 +00:00
Merge branch 'v0.8.0' of https://github.com/IfcOpenShell/IfcOpenShell into v0.8.0
This commit is contained in:
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.);
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -165,7 +165,7 @@ namespace IfcParse {
|
||||
}
|
||||
}
|
||||
|
||||
#if IFOPSH_WITH_ROCKSDB
|
||||
#ifdef IFOPSH_WITH_ROCKSDB
|
||||
|
||||
namespace impl {
|
||||
|
||||
|
||||
@@ -274,7 +274,7 @@ public:
|
||||
}
|
||||
|
||||
iterator& operator++() {
|
||||
#if IFOPSH_WITH_ROCKSDB
|
||||
#ifdef IFOPSH_WITH_ROCKSDB
|
||||
if (it_) {
|
||||
it_->Next();
|
||||
check_valid();
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user