Merge branch 'v0.8.0' into ifcmax/initial-refresh

This commit is contained in:
Josef Wienerroither
2025-12-17 12:49:13 +01:00
27 changed files with 1011 additions and 41 deletions
+42
View File
@@ -889,6 +889,48 @@ class IfcImporter:
obj, tool.Loader.apply_blender_offset_to_matrix_world(obj, self.get_element_matrix(element))
)
if element.is_a("IfcAnnotation") and getattr(element, "ObjectType", None) == "IMAGE":
image = None
if obj.data and obj.data.materials and obj.data.materials[0]:
material = obj.data.materials[0]
if material.use_nodes and material.node_tree:
for node in material.node_tree.nodes:
if node.type == "TEX_IMAGE" and node.image:
image = node.image
break
if image:
import bmesh
bm = bmesh.new()
bm.from_mesh(obj.data)
if not bm.loops.layers.uv:
uv_layer = bm.loops.layers.uv.new()
else:
uv_layer = bm.loops.layers.uv.active
if bm.verts:
min_x = min(v.co.x for v in bm.verts)
max_x = max(v.co.x for v in bm.verts)
min_y = min(v.co.y for v in bm.verts)
max_y = max(v.co.y for v in bm.verts)
width = max_x - min_x
height = max_y - min_y
for face in bm.faces:
for loop in face.loops:
vert = loop.vert
u = (vert.co.x - min_x) / width if width > 0 else 0.5
v = (vert.co.y - min_y) / height if height > 0 else 0.5
u = max(0.0, min(1.0, u))
v = max(0.0, min(1.0, v))
loop[uv_layer].uv = (u, v)
bm.to_mesh(obj.data)
bm.free()
obj.data.update()
return obj
def load_existing_meshes(self) -> None:
+1 -1
View File
@@ -58,7 +58,7 @@ def draw_ui(context: bpy.types.Context, layout: bpy.types.UILayout, attributes)
class BIM_PT_object_attributes(Panel):
bl_label = "Attributes"
bl_label = "Object Attributes"
bl_idname = "BIM_PT_object_attributes"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
@@ -104,6 +104,7 @@ class ReferencesData:
if element:
for reference in ifcopenshell.util.classification.get_references(element):
data = reference.get_info()
data["ifcClassificationReference"] = reference
del data["ReferencedSource"]
results.append(data)
return results
@@ -257,7 +257,9 @@ class EnableEditingClassificationReference(bpy.types.Operator):
def execute(self, context):
props = tool.Classification.get_classification_reference_props()
props.reference_attributes.clear()
bonsai.bim.helper.import_attributes(tool.Ifc.get().by_id(self.reference), props.reference_attributes)
ifc_reference = tool.Ifc.get().by_id(self.reference)
bonsai.bim.helper.import_attributes(ifc_reference, props.reference_attributes)
props.classification_system_name = ifc_reference.ReferencedSource.Name or ""
props.active_reference_id = self.reference
return {"FINISHED"}
@@ -324,9 +326,13 @@ class EditClassificationReference(bpy.types.Operator, tool.Ifc.Operator):
props = tool.Classification.get_classification_reference_props()
attributes = bonsai.bim.helper.export_attributes(props.reference_attributes)
ifc_file = tool.Ifc.get()
reference_entity = ifc_file.by_id(props.active_reference_id)
referenced_source = reference_entity.ReferencedSource
if props.classification_system_name:
referenced_source.Name = props.classification_system_name
ifcopenshell.api.classification.edit_reference(
ifc_file,
reference=ifc_file.by_id(props.active_reference_id),
reference=reference_entity,
attributes=attributes,
)
bpy.ops.bim.disable_editing_classification_reference()
@@ -95,6 +95,7 @@ class BIMClassificationReferenceProperties(PropertyGroup):
classifications: EnumProperty(items=get_classifications, name="Classifications")
reference_attributes: CollectionProperty(name="Reference Attributes", type=Attribute)
active_reference_id: IntProperty(name="Active Reference Id")
classification_system_name: StringProperty(name="Classification System Name")
if TYPE_CHECKING:
is_adding: bool
@@ -21,6 +21,8 @@ import bpy
import bonsai.bim.helper
import bonsai.tool as tool
import bonsai.bim.module.classification.prop as classification_prop
import ifcopenshell.util.classification
from bpy.types import Panel, UIList
from bonsai.bim.module.classification.data import (
ClassificationsData,
@@ -152,7 +154,15 @@ class ReferenceUI:
row = self.layout.row(align=True)
row.label(text="No References")
for reference in self.data.data["references"]:
def get_classification_name(reference):
classification_entity = ifcopenshell.util.classification.get_classification(
reference["ifcClassificationReference"]
)
return classification_entity.Name if classification_entity else ""
sorted_references = sorted(self.data.data["references"], key=get_classification_name)
for reference in sorted_references:
if self.props.active_reference_id == reference["id"]:
self.draw_editable_ui()
else:
@@ -248,10 +258,20 @@ class ReferenceUI:
row = self.layout.row(align=True)
row.operator("bim.edit_classification_reference", text="Save changes", icon="CHECKMARK")
row.operator("bim.disable_editing_classification_reference", text="", icon="CANCEL")
row = self.layout.row()
row.prop(self.props, "classification_system_name", text="Classification System Name")
bonsai.bim.helper.draw_attributes(self.props.reference_attributes, self.layout)
def draw_reference_ui(self, reference: dict[str, Any]) -> None:
row = self.layout.row(align=True)
classification_entity = ifcopenshell.util.classification.get_classification(
reference["ifcClassificationReference"]
)
classification_name = classification_entity.Name if classification_entity else ""
row.label(text=classification_name, icon="OUTLINER_COLLECTION")
if self.file.schema == "IFC2X3":
name = reference["ItemReference"] or "No Identification"
else:
@@ -3716,18 +3716,100 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper):
options={"SKIP_SAVE"},
)
x_length: bpy.props.FloatProperty(
name="X Length",
description="Width of the reference image in project units",
default=1.0,
min=0.001,
soft_min=0.01,
precision=3,
)
y_length: bpy.props.FloatProperty(
name="Y Length",
description="Height of the reference image in project units",
default=1.0,
min=0.001,
soft_min=0.01,
precision=3,
)
show_dimensions_dialog: bpy.props.BoolProperty(default=False, options={"HIDDEN", "SKIP_SAVE"})
def draw(self, context):
layout = self.layout
if Path(tool.Ifc.get_path()).is_file():
layout.prop(self, "use_relative_path")
if getattr(self, "show_dimensions_dialog", False):
if tool.Ifc.get():
length_unit = ifcopenshell.util.unit.get_project_unit(tool.Ifc.get(), "LENGTHUNIT")
if length_unit:
unit_name = ifcopenshell.util.unit.get_full_unit_name(length_unit).lower()
else:
unit_name = "project units"
layout.label(text=f"Set Reference Image Dimensions (in {unit_name}):")
else:
layout.label(text="Set Reference Image Dimensions (in project units):")
layout.separator()
layout.prop(self, "x_length")
layout.prop(self, "y_length")
else:
self.use_relative_path = False
layout.label(text="Save the .ifc file first ")
layout.label(text="to use relative paths.")
layout.prop(self, "override_existing_image")
layout.prop(self, "use_existing_object_by_name")
if Path(tool.Ifc.get_path()).is_file():
layout.prop(self, "use_relative_path")
else:
self.use_relative_path = False
layout.label(text="Save the .ifc file first ")
layout.label(text="to use relative paths.")
layout.prop(self, "override_existing_image")
layout.prop(self, "use_existing_object_by_name")
def invoke(self, context, event):
if not getattr(self, "show_dimensions_dialog", False):
context.window_manager.fileselect_add(self)
return {"RUNNING_MODAL"}
else:
return context.window_manager.invoke_props_dialog(self)
def execute(self, context):
if not getattr(self, "show_dimensions_dialog", False):
abs_path = Path(self.filepath).absolute().resolve()
if self.override_existing_image:
params = {"check_existing": True, "force_reload": True}
else:
params = {"check_existing": False}
try:
image = load_image(abs_path.name, str(abs_path.parent), **params)
image_width_px = image.size[0]
image_height_px = image.size[1]
aspect_ratio = image_width_px / image_height_px
if aspect_ratio >= 1.0:
self.x_length = 1.0
self.y_length = 1.0 / aspect_ratio
else:
self.x_length = aspect_ratio
self.y_length = 1.0
bpy.data.images.remove(image)
except Exception as e:
self.report({"ERROR"}, f"Failed to load image: {str(e)}")
return {"CANCELLED"}
self.show_dimensions_dialog = True
return context.window_manager.invoke_props_dialog(self)
return self._execute(context)
def _execute(self, context):
space = tool.Blender.get_view3d_space()
if space.shading.color_type != "TEXTURE":
space.shading.color_type = "TEXTURE"
self.report(
{"WARNING"},
'"Object Color" for Viewport Shading: Solid changed to "Texture" to see the reference image properly.',
)
abs_path = Path(self.filepath).absolute().resolve()
image_filepath = Path(tool.Ifc.get_uri(self.filepath, use_relative_path=self.use_relative_path))
ifc_file = tool.Ifc.get()
@@ -3736,13 +3818,39 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper):
params = {"check_existing": True, "force_reload": True}
else:
params = {"check_existing": False}
image = load_image(abs_path.name, abs_path.parent, **params)
image = load_image(abs_path.name, str(abs_path.parent), **params)
def bm_add_image_plane(mesh):
bm = tool.Blender.get_bmesh_for_mesh(mesh, clean=True)
plane_scale = (Vector(image.size) / min(image.size)).to_3d()
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file)
plane_scale = Vector((self.x_length * unit_scale / 2.0, self.y_length * unit_scale / 2.0, 1.0))
matrix = Matrix.LocRotScale(None, None, plane_scale)
bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=1, matrix=matrix, calc_uvs=False)
if not bm.loops.layers.uv:
uv_layer = bm.loops.layers.uv.new()
else:
uv_layer = bm.loops.layers.uv.active
min_x = min(v.co.x for v in bm.verts)
max_x = max(v.co.x for v in bm.verts)
min_y = min(v.co.y for v in bm.verts)
max_y = max(v.co.y for v in bm.verts)
width = max_x - min_x
height = max_y - min_y
for face in bm.faces:
for loop in face.loops:
vert = loop.vert
u = (vert.co.x - min_x) / width if width > 0 else 0.5
v = (vert.co.y - min_y) / height if height > 0 else 0.5
u = max(0.0, min(1.0, u))
v = max(0.0, min(1.0, v))
loop[uv_layer].uv = (u, v)
tool.Blender.apply_bmesh(mesh, bm)
if self.use_existing_object_by_name:
@@ -3763,6 +3871,23 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper):
)
tool.Blender.remove_data_block(temp_mesh)
element = tool.Ifc.get_entity(obj)
if element and isinstance(obj.data, bpy.types.Mesh):
representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
if representation and representation.Items:
item_id = representation.Items[0].id()
num_faces = len(obj.data.polygons)
obj.data["ios_item_ids"] = [item_id] * num_faces
tool.Blender.Attribute.fill_attribute(obj.data, "ios_item_ids", "FACE", "INT", [item_id] * num_faces)
for item in representation.Items:
if item.is_a("IfcPolygonalFaceSet") and item.Coordinates:
new_coords = []
for vertex in obj.data.vertices:
co = obj.matrix_world @ vertex.co
new_coords.append([co.x, co.y, co.z])
item.Coordinates.CoordList = new_coords
tool.Blender.set_active_object(obj)
material = bpy.data.materials.new(name=image_filepath.stem)
@@ -3803,6 +3928,8 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper):
tool.Style.reload_material_from_ifc(material)
tool.Geometry.record_object_materials(obj)
return {"FINISHED"}
class ConvertSVGToDXF(bpy.types.Operator):
bl_idname = "bim.convert_svg_to_dxf"
@@ -42,6 +42,7 @@ classes = (
operator.ExportIFC,
operator.FlipClippingPlane,
operator.IFCFileHandlerOperator,
operator.ImageScalingTool,
operator.LinkIfc,
operator.LoadLink,
operator.LoadLinkedProject,
@@ -119,6 +119,8 @@ class NewProject(bpy.types.Operator):
bpy.context.scene.unit_settings.length_unit = "MILLIMETERS"
bim_props.area_unit = "SQUARE_METRE"
bim_props.volume_unit = "CUBIC_METRE"
bim_props.mass_unit = "KILOGRAM"
bim_props.time_unit = "SECOND"
pprops.template_file = "IFC4 Demo Template.ifc"
if self.preset != "wizard":
@@ -2894,3 +2896,201 @@ class ClearMeasurement(bpy.types.Operator):
MeasureDecorator.uninstall()
tool.Blender.update_viewport()
return {"FINISHED"}
class ImageScalingTool(bpy.types.Operator, PolylineOperator):
bl_idname = "bim.image_scaling_tool"
bl_label = "Image Scaling Tool"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
return context.space_data.type == "VIEW_3D"
def __init__(self, *args, **kwargs):
bpy.types.Operator.__init__(self, *args, **kwargs)
PolylineOperator.__init__(self)
self.input_options = ["DISTANCE"]
self.input_ui = tool.Polyline.create_input_ui(input_options=self.input_options)
self.selected_points = []
self.target_object = None
self.current_distance_value = ""
self.is_typing_distance = False
self.calculated_distance = 0.0
if tool.Ifc.get():
self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
else:
self.unit_scale = tool.Blender.get_unit_scale()
def modal(self, context, event):
if not self.target_object or not context.active_object or context.active_object != self.target_object:
self.report({"ERROR"}, "Image annotation was deselected. Tool cancelled.")
return self.cancel_tool(context)
PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0])
tool.Blender.update_viewport()
self.handle_lock_axis(context, event)
if event.type in {"MIDDLEMOUSE", "WHEELUPMOUSE", "WHEELDOWNMOUSE"}:
self.handle_mouse_move(context, event)
return {"PASS_THROUGH"}
self.handle_custom_instructions(context)
self.handle_mouse_move(context, event)
self.choose_axis(event, z=True)
self.choose_plane(event)
self.handle_snap_selection(context, event)
if event.type == "LEFTMOUSE" and event.value == "PRESS":
if len(self.selected_points) < 2:
snapped_point = self.snapping_points[0]
point_3d = snapped_point["point"].copy()
self.selected_points.append(point_3d)
if len(self.selected_points) == 2:
self.calculate_distance()
self.current_distance_value = f"{self.calculated_distance:.3f}"
self.is_typing_distance = False
self.input_ui.set_value("DISTANCE", self.calculated_distance)
elif len(self.selected_points) == 2:
if event.type in {"RET", "NUMPAD_ENTER"} and event.value == "PRESS":
return self.apply_scaling(context)
if event.unicode and event.unicode.isprintable() and event.value == "PRESS":
if event.unicode.isdigit() or event.unicode == ".":
if not self.is_typing_distance:
self.current_distance_value = event.unicode
self.is_typing_distance = True
else:
self.current_distance_value += event.unicode
distance_value = float(self.current_distance_value)
self.input_ui.set_value("DISTANCE", distance_value)
elif event.type in {"BACK_SPACE", "DEL"} and event.value == "PRESS":
if len(self.current_distance_value) > 0:
self.current_distance_value = self.current_distance_value[:-1]
distance_value = (
float(self.current_distance_value) if self.current_distance_value else self.calculated_distance
)
self.input_ui.set_value("DISTANCE", distance_value)
self.handle_keyboard_input(context, event)
result = self.handle_cancelation(context, event)
if result is not None:
return result
return {"RUNNING_MODAL"}
def invoke(self, context, event):
active_obj = context.active_object
self.target_object = active_obj
super().invoke(context, event)
return {"RUNNING_MODAL"}
def cancel_tool(self, context):
context.workspace.status_text_set(text=None)
if hasattr(self, "tool_state"):
self.tool_state.plane_method = None
PolylineDecorator.uninstall()
tool.Blender.update_viewport()
return {"CANCELLED"}
def handle_custom_instructions(self, context):
if len(self.selected_points) == 0:
instruction_text = "Click First Point on Image"
elif len(self.selected_points) == 1:
instruction_text = "Click Second Point on Image"
elif len(self.selected_points) == 2:
if self.is_typing_distance:
instruction_text = f"Distance: {self.current_distance_value} - Press Enter to Apply"
else:
instruction_text = f"Measured: {self.calculated_distance:.3f} - Type New Distance or Press Enter"
else:
instruction_text = "Image Scaling Tool"
context.workspace.status_text_set(text=instruction_text)
def calculate_distance(self):
if len(self.selected_points) == 2:
point1 = self.selected_points[0]
point2 = self.selected_points[1]
distance_3d = (point2 - point1).length
self.calculated_distance = distance_3d / self.unit_scale
def apply_scaling(self, context):
if len(self.selected_points) != 2:
self.report({"ERROR"}, "Two points must be selected")
return {"CANCELLED"}
target_distance = float(self.current_distance_value)
if target_distance <= 0:
self.report({"ERROR"}, "Distance must be positive")
return {"CANCELLED"}
if self.calculated_distance <= 0:
self.report({"ERROR"}, "Selected points are too close together")
return {"CANCELLED"}
scale_factor = target_distance / self.calculated_distance
if self.target_object:
import bmesh
mesh = self.target_object.data
bm = bmesh.new()
bm.from_mesh(mesh)
bmesh.ops.scale(bm, vec=(scale_factor, scale_factor, 1.0), verts=bm.verts)
if bm.loops.layers.uv:
uv_layer = bm.loops.layers.uv.active
min_x = min(v.co.x for v in bm.verts)
max_x = max(v.co.x for v in bm.verts)
min_y = min(v.co.y for v in bm.verts)
max_y = max(v.co.y for v in bm.verts)
width = max_x - min_x
height = max_y - min_y
for face in bm.faces:
for loop in face.loops:
vert = loop.vert
u = (vert.co.x - min_x) / width if width > 0 else 0.5
v = (vert.co.y - min_y) / height if height > 0 else 0.5
u = max(0.0, min(1.0, u))
v = max(0.0, min(1.0, v))
loop[uv_layer].uv = (u, v)
bm.to_mesh(mesh)
bm.free()
mesh.update()
element = tool.Ifc.get_entity(self.target_object)
if element and element.Representation:
for representation in element.Representation.Representations:
for item in representation.Items:
if item.is_a("IfcPolygonalFaceSet") and item.Coordinates:
new_coords = []
for vertex in mesh.vertices:
co = self.target_object.matrix_world @ vertex.co
new_coords.append([co.x, co.y, co.z])
item.Coordinates.CoordList = new_coords
self.report({"INFO"}, f"Applied scale factor: {scale_factor:.4f}")
context.workspace.status_text_set(text=None)
self.tool_state.plane_method = None
PolylineDecorator.uninstall()
tool.Blender.update_viewport()
return {"FINISHED"}
@@ -356,8 +356,23 @@ class BIM_PT_new_project_wizard(Panel):
row.prop(props, "area_unit", text="Area Unit")
row = self.layout.row()
row.prop(props, "volume_unit", text="Volume Unit")
row = self.layout.row()
prop_with_search(self.layout, pprops, "template_file", text="Template")
if tool.Blender.get_addon_preferences().mass_time_units_in_wizard:
header, body = self.layout.panel("Mass and Time Units", default_closed=True)
if header:
header.label(text="Mass and Time Units")
if body:
label = "Add Mass and Time Units" if not props.add_mass_time_units else "Remove Mass and Time Units"
body.prop(props, "add_mass_time_units", toggle=True, text=label)
if props.add_mass_time_units:
row = body.row()
row.prop(props, "mass_unit", text="Mass Unit")
row = body.row()
row.prop(props, "time_unit", text="Time Unit")
self.layout.use_property_split = True
row = self.layout.row()
row.operator("bim.create_project")
@@ -38,6 +38,7 @@ class ExploreTool(bpy.types.WorkSpaceTool):
("bim.explore_hotkey", {"type": "F", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_F")]}),
("bim.explore_hotkey", {"type": "C", "value": "PRESS", "alt": True}, {"properties": [("hotkey", "A_C")]}),
("bim.explore_hotkey", {"type": "M", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_M")]}),
("bim.explore_hotkey", {"type": "S", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_S")]}),
)
def draw_settings(context, layout, ws_tool):
@@ -71,6 +72,14 @@ class ExploreTool(bpy.types.WorkSpaceTool):
row = layout.row(align=True)
op = row.operator("bim.clear_measurement", text="", icon="X")
row = layout.row(align=True)
row.label(text="", icon="EVENT_SHIFT")
row.label(text="", icon="EVENT_S")
row = layout.row(align=True)
op = row.operator("bim.explore_hotkey", text="Image Scaling Tool", icon="IMAGE_PLANE")
op.hotkey = "S_S"
op.description = "Scale Image Annotation. Allows to scale an IfcReferenceImage. Select image, select tool. Check lower left corner instructions to select two points and provide real distance between them"
class ExploreHotkey(bpy.types.Operator):
bl_idname = "bim.explore_hotkey"
@@ -110,3 +119,20 @@ class ExploreHotkey(bpy.types.Operator):
bpy.ops.bim.measure_face_area_tool("INVOKE_DEFAULT")
else:
bpy.ops.bim.measure_tool("INVOKE_DEFAULT", measure_type=measure_type)
def hotkey_S_S(self):
active_obj = bpy.context.active_object
selected_objects = tool.Blender.get_selected_objects()
element = tool.Ifc.get_entity(active_obj) if active_obj else None
if (
not active_obj
or not element
or not element.is_a("IfcAnnotation")
or len(selected_objects) != 1
or not tool.Drawing.is_annotation_object_type(element, "IMAGE")
):
self.report({"ERROR"}, "Please select one image annotation first.")
return
bpy.ops.bim.image_scaling_tool("INVOKE_DEFAULT")
@@ -23,8 +23,11 @@ classes = (
operator.AssignType,
operator.AutoRenameOccurrences,
operator.DisableEditingType,
operator.DisableEditingTypeAttributes,
operator.DuplicateType,
operator.EditTypeAttributes,
operator.EnableEditingType,
operator.EnableEditingTypeAttributes,
operator.RemoveType,
operator.RenameType,
operator.SelectSimilarType,
@@ -33,6 +36,7 @@ classes = (
operator.UnassignType,
prop.BIMTypeProperties,
ui.BIM_PT_type,
ui.BIM_PT_type_attributes,
)
+23
View File
@@ -42,6 +42,7 @@ class TypeData:
"is_product": cls.is_product(),
"total_instances": cls.total_instances(),
"relating_type": cls.relating_type(),
"relating_type_attributes": cls.relating_type_attributes(),
}
)
@@ -92,3 +93,25 @@ class TypeData:
element_type = ifcopenshell.util.element.get_type(element)
if element_type:
return {"id": element_type.id(), "name": f"{element_type.is_a()}/{element_type.Name or 'Unnamed'}"}
@classmethod
def relating_type_attributes(cls):
results = []
element = tool.Ifc.get_entity(bpy.context.active_object)
element_type = ifcopenshell.util.element.get_type(element)
if not element_type:
return results
data = element_type.get_info()
if "GlobalId" in data:
excluded_keys = ["id", "type"]
else:
excluded_keys = ["type"]
exclude_value_types = (tuple, ifcopenshell.entity_instance)
for key, value in data.items():
if value is None or isinstance(value, exclude_value_types) or key in excluded_keys:
continue
if key == "id":
key = "STEP ID"
results.append({"name": key, "value": str(value)})
return results
@@ -24,6 +24,7 @@ import ifcopenshell.util.representation
import ifcopenshell.util.type
import ifcopenshell.util.unit
import ifcopenshell.api
import ifcopenshell.api.attribute
import ifcopenshell.api.type
import bonsai.bim.helper
import bonsai.tool as tool
@@ -416,3 +417,80 @@ class DuplicateType(bpy.types.Operator, tool.Ifc.Operator):
self.layout.prop(
self, "assign_selected_objects", text=f"Assign {len(ifc_objects)} Selected Object(s) to New Type"
)
class EnableEditingTypeAttributes(bpy.types.Operator):
bl_idname = "bim.enable_editing_type_attributes"
bl_label = "Enable Editing Type Attributes"
bl_description = "Enable editing the attributes of the relating type"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
obj = context.active_object
if not obj:
return {"CANCELLED"}
element = tool.Ifc.get_entity(obj)
if not element:
return {"CANCELLED"}
element_type = ifcopenshell.util.element.get_type(element)
if not element_type:
return {"CANCELLED"}
props = tool.Type.get_object_type_props(obj)
props.type_attributes.clear()
bonsai.bim.helper.import_attributes(element_type, props.type_attributes)
props.is_editing_type_attributes = True
return {"FINISHED"}
class DisableEditingTypeAttributes(bpy.types.Operator):
bl_idname = "bim.disable_editing_type_attributes"
bl_label = "Disable Editing Type Attributes"
bl_description = "Disable editing the attributes of the relating type"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
obj = context.active_object
if not obj:
return {"CANCELLED"}
props = tool.Type.get_object_type_props(obj)
props.type_attributes.clear()
props.property_unset("is_editing_type_attributes")
return {"FINISHED"}
class EditTypeAttributes(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.edit_type_attributes"
bl_label = "Edit Type Attributes"
bl_description = "Save the changes to the relating type's attributes"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
obj = context.active_object
if not obj:
return {"CANCELLED"}
element = tool.Ifc.get_entity(obj)
if not element:
return {"CANCELLED"}
element_type = ifcopenshell.util.element.get_type(element)
if not element_type:
return {"CANCELLED"}
props = tool.Type.get_object_type_props(obj)
attributes = bonsai.bim.helper.export_attributes(props.type_attributes)
ifcopenshell.api.attribute.edit_attributes(tool.Ifc.get(), product=element_type, attributes=attributes)
type_obj = tool.Ifc.get_object(element_type)
if type_obj:
tool.Root.set_object_name(type_obj, element_type)
bpy.ops.bim.disable_editing_type_attributes()
return {"FINISHED"}
@@ -20,6 +20,7 @@ import bpy
import ifcopenshell.util.element
import ifcopenshell.util.type
from bonsai.bim.module.type.data import TypeData
from bonsai.bim.prop import Attribute
import bonsai.tool as tool
from typing import TYPE_CHECKING, Union
from bpy.types import PropertyGroup
@@ -90,9 +91,13 @@ class BIMTypeProperties(PropertyGroup):
update=update_relating_type_from_object,
poll=is_object_class_applicable,
)
is_editing_type_attributes: BoolProperty(name="Is Editing Type Attributes")
type_attributes: CollectionProperty(type=Attribute, name="Type Attributes")
if TYPE_CHECKING:
is_editing_type: bool
relating_type_class: str
relating_type: str
relating_type_object: Union[bpy.types.Object, None]
is_editing_type_attributes: bool
type_attributes: bpy.types.bpy_prop_collection_idprop[Attribute]
+46 -1
View File
@@ -20,7 +20,7 @@ import bpy
import bonsai.tool as tool
import bonsai.bim.module.type.prop as type_prop
from bpy.types import Panel
from bonsai.bim.helper import prop_with_search
from bonsai.bim.helper import prop_with_search, get_display_value
from bonsai.bim.module.type.data import TypeData
@@ -107,5 +107,50 @@ class BIM_PT_type(Panel):
row.operator("bim.enable_editing_type", icon="GREASEPENCIL", text="")
class BIM_PT_type_attributes(Panel):
bl_label = "Type Attributes"
bl_idname = "BIM_PT_type_attributes"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "object"
bl_parent_id = "BIM_PT_type"
@classmethod
def poll(cls, context):
if not TypeData.is_loaded:
TypeData.load()
return bool(TypeData.data.get("is_product") and TypeData.data.get("relating_type"))
def draw(self, context):
if not TypeData.is_loaded:
TypeData.load()
assert (layout := self.layout)
assert (obj := context.active_object)
if not TypeData.data.get("relating_type"):
layout.label(text="No Relating Type", icon="INFO")
return
props = tool.Type.get_object_type_props(obj)
if props.is_editing_type_attributes:
row = layout.row(align=True)
row.operator("bim.edit_type_attributes", icon="CHECKMARK", text="Save Attributes")
row.operator("bim.disable_editing_type_attributes", icon="CANCEL", text="")
import bonsai.bim.helper
bonsai.bim.helper.draw_attributes(props.type_attributes, layout)
else:
row = layout.row()
row.operator("bim.enable_editing_type_attributes", icon="GREASEPENCIL", text="Edit")
for attribute in TypeData.data["relating_type_attributes"]:
row = layout.row(align=True)
row.label(text=attribute["name"])
value = get_display_value(attribute["value"])
row.label(text=value)
def add_object_button(self, context):
self.layout.operator("bim.add_occurrence", icon="PLUGIN")
+28
View File
@@ -586,7 +586,33 @@ class BIMProperties(PropertyGroup):
],
name="IFC Volume Unit",
)
add_mass_time_units: bpy.props.BoolProperty(
name="Add Mass and Time Units",
description="Enable to define mass and time units for the project",
default=False,
)
mass_unit: EnumProperty(
items=[
("KILOGRAM", "Kilogram", "Kilograms"),
("GRAM", "Gram", "Grams"),
("POUND", "Pound", "Pounds"),
("OUNCE", "Ounce", "Ounces"),
("TONNE", "Tonne", "Metric Tons"),
],
name="Mass Unit",
default="KILOGRAM",
)
time_unit: EnumProperty(
items=[
("SECOND", "Second", "Seconds"),
("MINUTE", "Minute", "Minutes"),
("HOUR", "Hour", "Hours"),
("DAY", "Day", "Days"),
],
name="Time Unit",
default="HOUR",
)
if TYPE_CHECKING:
is_dirty: bool
schema_dir: str
@@ -599,6 +625,8 @@ class BIMProperties(PropertyGroup):
section_line_decorator_width: float
area_unit: str
volume_unit: str
mass_unit: str
time_unit: str
class IfcParameter(PropertyGroup):
+9 -1
View File
@@ -684,6 +684,12 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
default=False,
)
mass_time_units_in_wizard: BoolProperty(
name="Mass and time units in project wizard",
description="Show mass and time units section in the new project wizard panel",
default=False,
)
if TYPE_CHECKING:
svg2pdf_command: str
svg2dxf_command: str
@@ -720,6 +726,7 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
doc: DocPreferences
default_parameters: DefaultParameters
container_hide_show_isolate: bool
mass_time_units_in_wizard: bool
def draw(self, context: bpy.types.Context) -> None:
layout = self.layout
@@ -901,6 +908,7 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
def draw_extras_settings(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
layout.prop(self, "container_hide_show_isolate")
layout.prop(self, "mass_time_units_in_wizard")
# Scene panel groups
@@ -1355,7 +1363,7 @@ class BIM_PT_tab_sandbox(Panel):
# Object panel groups
class BIM_PT_tab_object_metadata(Panel):
bl_label = "Object Metadata"
bl_label = "Object"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
+1 -1
View File
@@ -1123,7 +1123,7 @@ class Unit:
def set_active_unit(cls, unit): pass
def get_project_currency_unit(cls): pass
def get_currency_name(cls): pass
def add_mass_and_time_units(cls): pass
@interface
class Voider:
+32 -9
View File
@@ -28,20 +28,43 @@ if TYPE_CHECKING:
def assign_scene_units(ifc: type[tool.Ifc], unit: type[tool.Unit]) -> None:
if unit.is_scene_unit_metric():
prefix = unit.get_scene_unit_si_prefix("LENGTHUNIT")
lengthunit = ifc.run("unit.add_si_unit", unit_type="LENGTHUNIT", prefix=prefix)
prefix = unit.get_scene_unit_si_prefix("AREAUNIT")
areaunit = ifc.run("unit.add_si_unit", unit_type="AREAUNIT", prefix=prefix)
prefix = unit.get_scene_unit_si_prefix("VOLUMEUNIT")
volumeunit = ifc.run("unit.add_si_unit", unit_type="VOLUMEUNIT", prefix=prefix)
lengthunit = ifc.run("unit.add_si_unit", unit_type="LENGTHUNIT", prefix=unit.get_scene_unit_si_prefix("LENGTHUNIT"))
areaunit = ifc.run("unit.add_si_unit", unit_type="AREAUNIT", prefix=unit.get_scene_unit_si_prefix("AREAUNIT"))
volumeunit = ifc.run("unit.add_si_unit", unit_type="VOLUMEUNIT", prefix=unit.get_scene_unit_si_prefix("VOLUMEUNIT"))
planeangleunit = ifc.run("unit.add_conversion_based_unit", name="degree")
units = [lengthunit, areaunit, volumeunit, planeangleunit]
if unit.add_mass_and_time_units():
prefix = unit.get_scene_unit_si_prefix("MASSUNIT")
if prefix == "CONVERSION":
massunit = ifc.run("unit.add_conversion_based_unit", name=unit.get_scene_unit_name("MASSUNIT").lower())
else:
massunit = ifc.run("unit.add_si_unit", unit_type="MASSUNIT", prefix=prefix)
prefix = unit.get_scene_unit_si_prefix("TIMEUNIT")
if prefix == "CONVERSION":
timeunit = ifc.run("unit.add_conversion_based_unit", name=unit.get_scene_unit_name("TIMEUNIT").lower())
else:
timeunit = ifc.run("unit.add_si_unit", unit_type="TIMEUNIT", prefix=prefix)
units += [massunit, timeunit]
else:
lengthunit = ifc.run("unit.add_conversion_based_unit", name=unit.get_scene_unit_name("LENGTHUNIT"))
areaunit = ifc.run("unit.add_conversion_based_unit", name=unit.get_scene_unit_name("AREAUNIT"))
volumeunit = ifc.run("unit.add_conversion_based_unit", name=unit.get_scene_unit_name("VOLUMEUNIT"))
planeangleunit = ifc.run("unit.add_conversion_based_unit", name="degree")
units = [lengthunit, areaunit, volumeunit, planeangleunit]
planeangleunit = ifc.run("unit.add_conversion_based_unit", name="degree")
ifc.run("unit.assign_unit", units=[lengthunit, areaunit, volumeunit, planeangleunit])
if unit.add_mass_and_time_units():
massunit = ifc.run("unit.add_conversion_based_unit", name=unit.get_scene_unit_name("MASSUNIT").lower())
time_unit_name = unit.get_scene_unit_name("TIMEUNIT")
if time_unit_name == "SECOND":
timeunit = ifc.run("unit.add_si_unit", unit_type="TIMEUNIT", prefix=None)
else:
timeunit = ifc.run("unit.add_conversion_based_unit", name=time_unit_name.lower())
units += [massunit, timeunit]
print("Add mass and time units:", unit.add_mass_and_time_units())
print("Assigning units:", units)
ifc.run("unit.assign_unit", units=units)
def assign_unit(ifc: type[tool.Ifc], unit_tool: type[tool.Unit], unit: ifcopenshell.entity_instance) -> None:
+29 -1
View File
@@ -266,7 +266,7 @@ def parse_distance_string(input_string: str, use_project_unit: bool = True) -> t
class Unit(bonsai.core.tool.Unit):
UNIT_TYPE = Literal["LENGTHUNIT", "AREAUNIT", "VOLUMEUNIT"]
UNIT_TYPE = Literal["LENGTHUNIT", "AREAUNIT", "VOLUMEUNIT", "MASSUNIT", "TIMEUNIT"]
@staticmethod
def format_distance(meters: float, use_imperial: bool = None, **kwargs) -> str:
@@ -343,6 +343,10 @@ class Unit(bonsai.core.tool.Unit):
return bim_props.area_unit
elif unit_type == "VOLUMEUNIT":
return bim_props.volume_unit
elif unit_type == "MASSUNIT":
return bim_props.mass_unit.lower()
elif unit_type == "TIMEUNIT":
return bim_props.time_unit.lower()
else:
assert_never(unit_type)
@@ -359,6 +363,24 @@ class Unit(bonsai.core.tool.Unit):
unit = bim_props.area_unit
elif unit_type == "VOLUMEUNIT":
unit = bim_props.volume_unit
elif unit_type == "MASSUNIT":
unit = bim_props.mass_unit
if unit == "GRAM":
return None
elif unit == "KILOGRAM":
return "KILO"
elif unit == "TONNE":
return "MEGA"
elif unit in ["POUND", "OUNCE"]:
return "CONVERSION"
else:
return None
elif unit_type == "TIMEUNIT":
unit = bim_props.time_unit
if unit == "SECOND":
return None
else:
return "CONVERSION"
else:
assert_never(unit_type)
if "/" in unit:
@@ -477,3 +499,9 @@ class Unit(bonsai.core.tool.Unit):
elif ifc_class == "IfcMonetaryUnit":
return "COPY_ID"
return "MOD_MESHDEFORM"
@classmethod
def add_mass_and_time_units(cls) -> bool:
"""Return True if the user wants to add mass and time units, False otherwise."""
bim_props = tool.Blender.get_bim_props()
return getattr(bim_props, "add_mass_time_units", False)
+92 -11
View File
@@ -26,38 +26,119 @@ class TestAssignSceneUnits:
unit.get_scene_unit_si_prefix("LENGTHUNIT").should_be_called().will_return("prefix")
unit.get_scene_unit_si_prefix("AREAUNIT").should_be_called().will_return("prefix")
unit.get_scene_unit_si_prefix("VOLUMEUNIT").should_be_called().will_return("prefix")
unit.add_mass_and_time_units().should_be_called().will_return(True)
unit.get_scene_unit_si_prefix("MASSUNIT").should_be_called().will_return("KILO")
unit.get_scene_unit_si_prefix("TIMEUNIT").should_be_called().will_return(None)
ifc.run("unit.add_si_unit", unit_type="LENGTHUNIT", prefix="prefix").should_be_called().will_return(
"lengthunit"
)
ifc.run("unit.add_si_unit", unit_type="AREAUNIT", prefix="prefix").should_be_called().will_return("areaunit")
ifc.run("unit.add_si_unit", unit_type="VOLUMEUNIT", prefix="prefix").should_be_called().will_return(
"volumeunit"
)
ifc.run("unit.add_si_unit", unit_type="MASSUNIT", prefix="KILO").should_be_called().will_return("massunit")
ifc.run("unit.add_si_unit", unit_type="TIMEUNIT", prefix=None).should_be_called().will_return("timeunit")
ifc.run("unit.add_conversion_based_unit", name="degree").should_be_called().will_return("planeangleunit")
ifc.run(
"unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "planeangleunit", "massunit", "timeunit"]
).should_be_called()
subject.assign_scene_units(ifc, unit)
def test_creating_and_assigning_metric_units_without_mass_and_time(self, ifc, unit):
unit.is_scene_unit_metric().should_be_called().will_return(True)
unit.get_scene_unit_si_prefix("LENGTHUNIT").should_be_called().will_return("CENTI")
unit.get_scene_unit_si_prefix("AREAUNIT").should_be_called().will_return("CENTI")
unit.get_scene_unit_si_prefix("VOLUMEUNIT").should_be_called().will_return("CENTI")
unit.add_mass_and_time_units().should_be_called().will_return(False)
ifc.run("unit.add_si_unit", unit_type="LENGTHUNIT", prefix="CENTI").should_be_called().will_return("lengthunit")
ifc.run("unit.add_si_unit", unit_type="AREAUNIT", prefix="CENTI").should_be_called().will_return("areaunit")
ifc.run("unit.add_si_unit", unit_type="VOLUMEUNIT", prefix="CENTI").should_be_called().will_return("volumeunit")
ifc.run("unit.add_conversion_based_unit", name="degree").should_be_called().will_return("planeangleunit")
ifc.run("unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "planeangleunit"]).should_be_called()
subject.assign_scene_units(ifc, unit)
def test_creating_and_assigning_imperial_units(self, ifc, unit):
unit.is_scene_unit_metric().should_be_called().will_return(False)
unit.get_scene_unit_name("LENGTHUNIT").should_be_called().will_return("lengthname")
ifc.run("unit.add_conversion_based_unit", name="lengthname").should_be_called().will_return("lengthunit")
unit.get_scene_unit_name("AREAUNIT").should_be_called().will_return("areaname")
ifc.run("unit.add_conversion_based_unit", name="areaname").should_be_called().will_return("areaunit")
unit.get_scene_unit_name("VOLUMEUNIT").should_be_called().will_return("volumename")
ifc.run("unit.add_conversion_based_unit", name="volumename").should_be_called().will_return("volumeunit")
unit.get_scene_unit_name("LENGTHUNIT").should_be_called().will_return("foot")
unit.get_scene_unit_name("AREAUNIT").should_be_called().will_return("square foot")
unit.get_scene_unit_name("VOLUMEUNIT").should_be_called().will_return("cubic foot")
unit.add_mass_and_time_units().should_be_called().will_return(True)
unit.get_scene_unit_name("MASSUNIT").should_be_called().will_return("pound")
unit.get_scene_unit_name("TIMEUNIT").should_be_called().will_return("SECOND")
ifc.run("unit.add_conversion_based_unit", name="foot").should_be_called().will_return("lengthunit")
ifc.run("unit.add_conversion_based_unit", name="square foot").should_be_called().will_return("areaunit")
ifc.run("unit.add_conversion_based_unit", name="cubic foot").should_be_called().will_return("volumeunit")
ifc.run("unit.add_conversion_based_unit", name="pound").should_be_called().will_return("massunit")
ifc.run("unit.add_si_unit", unit_type="TIMEUNIT", prefix=None).should_be_called().will_return("timeunit")
ifc.run("unit.add_conversion_based_unit", name="degree").should_be_called().will_return("planeangleunit")
ifc.run(
"unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "planeangleunit", "massunit", "timeunit"]
).should_be_called()
subject.assign_scene_units(ifc, unit)
def test_creating_and_assigning_imperial_units_without_mass_and_time(self, ifc, unit):
unit.is_scene_unit_metric().should_be_called().will_return(False)
unit.get_scene_unit_name("LENGTHUNIT").should_be_called().will_return("yard")
unit.get_scene_unit_name("AREAUNIT").should_be_called().will_return("square yard")
unit.get_scene_unit_name("VOLUMEUNIT").should_be_called().will_return("cubic yard")
unit.add_mass_and_time_units().should_be_called().will_return(False)
ifc.run("unit.add_conversion_based_unit", name="yard").should_be_called().will_return("lengthunit")
ifc.run("unit.add_conversion_based_unit", name="square yard").should_be_called().will_return("areaunit")
ifc.run("unit.add_conversion_based_unit", name="cubic yard").should_be_called().will_return("volumeunit")
ifc.run("unit.add_conversion_based_unit", name="degree").should_be_called().will_return("planeangleunit")
ifc.run("unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "planeangleunit"]).should_be_called()
subject.assign_scene_units(ifc, unit)
def test_creating_metric_units_with_conversion_based_mass_and_time(self, ifc, unit):
unit.is_scene_unit_metric().should_be_called().will_return(True)
unit.get_scene_unit_si_prefix("LENGTHUNIT").should_be_called().will_return("MILLI")
unit.get_scene_unit_si_prefix("AREAUNIT").should_be_called().will_return(None)
unit.get_scene_unit_si_prefix("VOLUMEUNIT").should_be_called().will_return(None)
unit.add_mass_and_time_units().should_be_called().will_return(True)
unit.get_scene_unit_si_prefix("MASSUNIT").should_be_called().will_return("CONVERSION")
unit.get_scene_unit_name("MASSUNIT").should_be_called().will_return("tonne")
unit.get_scene_unit_si_prefix("TIMEUNIT").should_be_called().will_return("CONVERSION")
unit.get_scene_unit_name("TIMEUNIT").should_be_called().will_return("minute")
ifc.run("unit.add_si_unit", unit_type="LENGTHUNIT", prefix="MILLI").should_be_called().will_return("lengthunit")
ifc.run("unit.add_si_unit", unit_type="AREAUNIT", prefix=None).should_be_called().will_return("areaunit")
ifc.run("unit.add_si_unit", unit_type="VOLUMEUNIT", prefix=None).should_be_called().will_return("volumeunit")
ifc.run("unit.add_conversion_based_unit", name="tonne").should_be_called().will_return("massunit")
ifc.run("unit.add_conversion_based_unit", name="minute").should_be_called().will_return("timeunit")
ifc.run("unit.add_conversion_based_unit", name="degree").should_be_called().will_return("planeangleunit")
ifc.run(
"unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "planeangleunit", "massunit", "timeunit"]
).should_be_called()
subject.assign_scene_units(ifc, unit)
def test_creating_imperial_units_with_conversion_based_units(self, ifc, unit):
unit.is_scene_unit_metric().should_be_called().will_return(False)
unit.get_scene_unit_name("LENGTHUNIT").should_be_called().will_return("inch")
unit.get_scene_unit_name("AREAUNIT").should_be_called().will_return("square inch")
unit.get_scene_unit_name("VOLUMEUNIT").should_be_called().will_return("cubic inch")
unit.add_mass_and_time_units().should_be_called().will_return(True)
unit.get_scene_unit_name("MASSUNIT").should_be_called().will_return("ounce")
unit.get_scene_unit_name("TIMEUNIT").should_be_called().will_return("hour")
ifc.run("unit.add_conversion_based_unit", name="inch").should_be_called().will_return("lengthunit")
ifc.run("unit.add_conversion_based_unit", name="square inch").should_be_called().will_return("areaunit")
ifc.run("unit.add_conversion_based_unit", name="cubic inch").should_be_called().will_return("volumeunit")
ifc.run("unit.add_conversion_based_unit", name="ounce").should_be_called().will_return("massunit")
ifc.run("unit.add_conversion_based_unit", name="hour").should_be_called().will_return("timeunit")
ifc.run("unit.add_conversion_based_unit", name="degree").should_be_called().will_return("planeangleunit")
ifc.run(
"unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "planeangleunit", "massunit", "timeunit"]
).should_be_called()
subject.assign_scene_units(ifc, unit)
class TestAssignUnit:
def test_run(self, ifc, unit):
ifc.run("unit.assign_unit", units=["unit"]).should_be_called()
+143
View File
@@ -135,6 +135,35 @@ class TestGetSceneUnitName(NewFile):
bpy.context.scene.unit_settings.system = "NONE"
assert subject.get_scene_unit_name("LENGTHUNIT") == "foot"
def test_getting_mass_unit_names(self):
"""Test getting mass unit names for different systems"""
assert bpy.context.scene
props = tool.Blender.get_bim_props()
props.mass_unit = "GRAM"
assert subject.get_scene_unit_name("MASSUNIT") == "gram"
props.mass_unit = "KILOGRAM"
assert subject.get_scene_unit_name("MASSUNIT") == "kilogram"
props.mass_unit = "POUND"
assert subject.get_scene_unit_name("MASSUNIT") == "pound"
props.mass_unit = "OUNCE"
assert subject.get_scene_unit_name("MASSUNIT") == "ounce"
props.mass_unit = "TONNE"
assert subject.get_scene_unit_name("MASSUNIT") == "tonne"
def test_getting_time_unit_names(self):
"""Test getting time unit names for different systems"""
assert bpy.context.scene
props = tool.Blender.get_bim_props()
props.time_unit = "SECOND"
assert subject.get_scene_unit_name("TIMEUNIT") == "second"
props.time_unit = "MINUTE"
assert subject.get_scene_unit_name("TIMEUNIT") == "minute"
props.time_unit = "HOUR"
assert subject.get_scene_unit_name("TIMEUNIT") == "hour"
props.time_unit = "DAY"
assert subject.get_scene_unit_name("TIMEUNIT") == "day"
class TestGetSceneUnitSIPrefix:
def test_run(self):
@@ -162,6 +191,30 @@ class TestGetSceneUnitSIPrefix:
props.volume_unit = "MILLI/CUBIC_METRE"
assert subject.get_scene_unit_si_prefix("VOLUMEUNIT") == "MILLI"
def test_mass_and_time_unit_prefixes(self):
assert bpy.context.scene
props = tool.Blender.get_bim_props()
props.mass_unit = "KILOGRAM"
assert subject.get_scene_unit_si_prefix("MASSUNIT") == "KILO"
props.mass_unit = "GRAM"
assert subject.get_scene_unit_si_prefix("MASSUNIT") is None
props.mass_unit = "POUND"
assert subject.get_scene_unit_si_prefix("MASSUNIT") == "CONVERSION"
props.mass_unit = "OUNCE"
assert subject.get_scene_unit_si_prefix("MASSUNIT") == "CONVERSION"
props.mass_unit = "TONNE"
assert subject.get_scene_unit_si_prefix("MASSUNIT") == "MEGA"
props.time_unit = "SECOND"
assert subject.get_scene_unit_si_prefix("TIMEUNIT") is None
props.time_unit = "MINUTE"
assert subject.get_scene_unit_si_prefix("TIMEUNIT") == "CONVERSION"
props.time_unit = "HOUR"
assert subject.get_scene_unit_si_prefix("TIMEUNIT") == "CONVERSION"
props.time_unit = "DAY"
assert subject.get_scene_unit_si_prefix("TIMEUNIT") == "CONVERSION"
class TestImportUnitAttributes(NewFile):
def test_importing_derived_units(self):
@@ -298,6 +351,96 @@ class TestImportUnits(NewFile):
assert props.units[5].unit_type == unit6.UnitType
assert props.units[5].ifc_class == unit6.is_a()
def test_importing_mass_and_time_units(self):
"""Test importing mass and time conversion based units"""
ifc = ifcopenshell.api.project.create_file()
tool.Ifc.set(ifc)
tonne_unit = ifcopenshell.api.unit.add_conversion_based_unit(ifc, name="tonne")
pound_unit = ifcopenshell.api.unit.add_conversion_based_unit(ifc, name="pound")
ounce_unit = ifcopenshell.api.unit.add_conversion_based_unit(ifc, name="ounce")
minute_unit = ifcopenshell.api.unit.add_conversion_based_unit(ifc, name="minute")
hour_unit = ifcopenshell.api.unit.add_conversion_based_unit(ifc, name="hour")
day_unit = ifcopenshell.api.unit.add_conversion_based_unit(ifc, name="day")
kg_unit = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="MASSUNIT", prefix="KILO")
gram_unit = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="MASSUNIT")
second_unit = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="TIMEUNIT")
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
ifcopenshell.api.unit.assign_unit(ifc, units=[tonne_unit, minute_unit, kg_unit])
subject.import_units()
props = tool.Unit.get_unit_props()
assert len(props.units) == 15
unit_ids = [u.ifc_definition_id for u in props.units]
assert tonne_unit.id() in unit_ids
assert pound_unit.id() in unit_ids
assert ounce_unit.id() in unit_ids
assert minute_unit.id() in unit_ids
assert hour_unit.id() in unit_ids
assert day_unit.id() in unit_ids
assert kg_unit.id() in unit_ids
assert gram_unit.id() in unit_ids
assert second_unit.id() in unit_ids
tonne_prop = next(u for u in props.units if u.ifc_definition_id == tonne_unit.id())
assert tonne_prop.name == "tonne"
assert tonne_prop.unit_type == "MASSUNIT"
assert tonne_prop.is_assigned is True
assert tonne_prop.ifc_class == "IfcConversionBasedUnit"
pound_prop = next(u for u in props.units if u.ifc_definition_id == pound_unit.id())
assert pound_prop.name == "pound"
assert pound_prop.unit_type == "MASSUNIT"
assert pound_prop.is_assigned is False
assert pound_prop.ifc_class == "IfcConversionBasedUnit"
ounce_prop = next(u for u in props.units if u.ifc_definition_id == ounce_unit.id())
assert ounce_prop.name == "ounce"
assert ounce_prop.unit_type == "MASSUNIT"
assert ounce_prop.is_assigned is False
assert ounce_prop.ifc_class == "IfcConversionBasedUnit"
kg_prop = next(u for u in props.units if u.ifc_definition_id == kg_unit.id())
assert kg_prop.name == "KILOGRAM"
assert kg_prop.unit_type == "MASSUNIT"
assert kg_prop.is_assigned is True
assert kg_prop.ifc_class == "IfcSIUnit"
gram_prop = next(u for u in props.units if u.ifc_definition_id == gram_unit.id())
assert gram_prop.name == "GRAM"
assert gram_prop.unit_type == "MASSUNIT"
assert gram_prop.is_assigned is False
assert gram_prop.ifc_class == "IfcSIUnit"
minute_prop = next(u for u in props.units if u.ifc_definition_id == minute_unit.id())
assert minute_prop.name == "minute"
assert minute_prop.unit_type == "TIMEUNIT"
assert minute_prop.is_assigned is True
assert minute_prop.ifc_class == "IfcConversionBasedUnit"
hour_prop = next(u for u in props.units if u.ifc_definition_id == hour_unit.id())
assert hour_prop.name == "hour"
assert hour_prop.unit_type == "TIMEUNIT"
assert hour_prop.is_assigned is False
assert hour_prop.ifc_class == "IfcConversionBasedUnit"
day_prop = next(u for u in props.units if u.ifc_definition_id == day_unit.id())
assert day_prop.name == "day"
assert day_prop.unit_type == "TIMEUNIT"
assert day_prop.is_assigned is False
assert day_prop.ifc_class == "IfcConversionBasedUnit"
second_prop = next(u for u in props.units if u.ifc_definition_id == second_unit.id())
assert second_prop.name == "SECOND"
assert second_prop.unit_type == "TIMEUNIT"
assert second_prop.is_assigned is False
assert second_prop.ifc_class == "IfcSIUnit"
class TestIsSceneUnitMetric(NewFile):
def test_run(self):
@@ -32,7 +32,7 @@ def add_conversion_based_unit(
function. You can choose from one of: inch, foot, yard, mile, square
inch, square foot, square yard, acre, square mile, cubic inch, cubic
foot, cubic yard, litre, fluid ounce UK, fluid ounce US, pint UK, pint
US, gallon UK, gallon US, degree, ounce, pound, ton UK, ton US, lbf,
US, gallon UK, gallon US, degree, ounce, pound, ton UK, ton US, tonne, lbf,
kip, psi, ksi, minute, hour, day, btu, and fahrenheit.
:param name: A converted name chosen from the list above.
@@ -61,7 +61,11 @@ def add_conversion_based_unit(
dimensions = ifcopenshell.util.unit.named_dimensions[unit_type]
exponents = file.createIfcDimensionalExponents(*dimensions)
si_name = ifcopenshell.util.unit.si_type_names[unit_type]
si_unit = file.createIfcSIUnit(UnitType=unit_type, Name=si_name)
if unit_type == "MASSUNIT":
si_unit = file.createIfcSIUnit(UnitType=unit_type, Name=si_name, Prefix="KILO")
else:
si_unit = file.createIfcSIUnit(UnitType=unit_type, Name=si_name)
conversion_real = ifcopenshell.util.unit.si_conversions.get(name, 1)
value_component = file.create_entity("IfcReal", **{"wrappedValue": conversion_real})
@@ -55,8 +55,12 @@ def assign_unit(
length = ifcopenshell.api.unit.add_si_unit(model, unit_type="LENGTHUNIT", prefix="MILLI")
area = ifcopenshell.api.unit.add_si_unit(model, unit_type="AREAUNIT")
# Make it our default units, if we are doing a metric building
ifcopenshell.api.unit.assign_unit(model, units=[length, area])
# Optionally, add mass and time units
mass = ifcopenshell.api.unit.add_si_unit(model, unit_type="MASSUNIT", prefix="KILO")
time = ifcopenshell.api.unit.add_si_unit(model, unit_type="TIMEUNIT")
# Make these the default units for the project
ifcopenshell.api.unit.assign_unit(model, units=[length, area, mass, time])
# Alternatively, you may specify without any arguments to
# automatically create millimeters, square meters, and cubic meters
@@ -139,6 +143,7 @@ class Usecase:
elif unit_type == "volume":
dimensional_exponents = self.file.createIfcDimensionalExponents(3, 0, 0, 0, 0, 0, 0)
name_prefix = "cubic"
si_unit = self.file.createIfcSIUnit(
None,
"{}UNIT".format(unit_type.upper()),
@@ -209,6 +209,7 @@ si_conversions = {
"pound": 0.454,
"ton UK": 1016.0469088,
"ton US": 907.18474,
"tonne": 1000.0,
"lbf": 4.4482216153,
"kip": 4448.2216153,
"psi": 6894.7572932,
@@ -253,6 +254,7 @@ imperial_types = {
"pound": "MASSUNIT",
"ton UK": "MASSUNIT",
"ton US": "MASSUNIT",
"tonne": "MASSUNIT",
"lbf": "FORCEUNIT",
"kip": "FORCEUNIT",
"psi": "PRESSUREUNIT",
@@ -323,6 +325,7 @@ unit_symbols = {
"pound": "lb",
"ton UK": "ton",
"ton US": "ton",
"tonne": "t",
"lbf": "lbf",
"kip": "kip",
"psi": "psi",
@@ -40,6 +40,54 @@ class TestAddConversionBasedUnitIFC2X3(test.bootstrap.IFC2X3):
assert si_unit.Prefix is None
assert si_unit.Name == "METRE"
def test_adding_mass_units_creates_proper_massunit(self):
mass_units = [
("tonne", 1000.0),
("pound", 0.454),
("ounce", 0.02835),
("ton UK", 1016.0469088),
("ton US", 907.18474),
]
for name, expected_conversion in mass_units:
unit = ifcopenshell.api.unit.add_conversion_based_unit(self.file, name=name)
assert unit.is_a("IfcConversionBasedUnit")
assert unit.UnitType == "MASSUNIT"
assert unit.Name == name
actual_conversion = unit.ConversionFactor.ValueComponent.wrappedValue
assert actual_conversion == expected_conversion
target_unit = unit.ConversionFactor.UnitComponent
assert target_unit.is_a("IfcSIUnit")
assert target_unit.UnitType == "MASSUNIT"
assert target_unit.Name == "GRAM"
assert target_unit.Prefix == "KILO"
def test_adding_time_units_creates_proper_timeunit(self):
time_units = [
("minute", 60),
("hour", 3600),
("day", 86400),
]
for name, expected_conversion in time_units:
unit = ifcopenshell.api.unit.add_conversion_based_unit(self.file, name=name)
assert unit.is_a("IfcConversionBasedUnit")
assert unit.UnitType == "TIMEUNIT"
assert unit.Name == name
actual_conversion = unit.ConversionFactor.ValueComponent.wrappedValue
assert actual_conversion == expected_conversion
target_unit = unit.ConversionFactor.UnitComponent
assert target_unit.is_a("IfcSIUnit")
assert target_unit.UnitType == "TIMEUNIT"
assert target_unit.Name == "SECOND"
assert target_unit.Prefix is None
class TestAddConversionBasedUnitIFC4(test.bootstrap.IFC4, TestAddConversionBasedUnitIFC2X3):
def test_adding_a_unit_with_offset(self):
@@ -61,3 +109,8 @@ class TestAddConversionBasedUnitIFC4(test.bootstrap.IFC4, TestAddConversionBased
assert si_unit.Prefix is None
assert si_unit.Name == "KELVIN"
assert unit.ConversionOffset == -459.67
def test_unknown_units_fall_back_to_userdefined(self):
unknown_unit = ifcopenshell.api.unit.add_conversion_based_unit(self.file, name="unknown_unit")
assert unknown_unit.UnitType == "USERDEFINED"
assert unknown_unit.Name == "unknown_unit"