Merge remote-tracking branch 'origin/v0.8.0' into tfk-rocksdb-storage

This commit is contained in:
Thomas Krijnen
2025-09-10 10:06:01 +02:00
181 changed files with 12075 additions and 2648 deletions
+17 -2
View File
@@ -441,7 +441,19 @@ if(NOT MINIMAL_BUILD)
# libxml2 is required for IFCXML (optional) and SVGFILL (mandatory)
clear_wasm_sysroot()
if(IFCXML_SUPPORT)
find_package(LibXml2 REQUIRED)
if((NOT LIBXML2_INCLUDE_DIR AND NOT LIBXML2_LIBRARIES))
find_package(LibXml2 CONFIG REQUIRED)
message(STATUS "Found LibXml2 config: ${LibXml2_DIR}")
get_target_property(LIBXML2_INCLUDE_DIR LibXml2::LibXml2 INTERFACE_INCLUDE_DIRECTORIES)
get_target_property(_libxml2_debug LibXml2::LibXml2 IMPORTED_LOCATION_DEBUG)
get_target_property(_libxml2_release LibXml2::LibXml2 IMPORTED_LOCATION_RELEASE)
set(LIBXML2_LIBRARIES
optimized ${_libxml2_release}
debug ${_libxml2_debug}
)
else()
find_package(LibXml2 REQUIRED)
endif()
endif()
restore_wasm_sysroot()
endif()
@@ -452,7 +464,7 @@ if(IFCXML_SUPPORT)
endif()
if(BUILD_IFCGEOM)
if(MSVC)
if(MSVC AND NOT LibXml2_DIR)
add_debug_variants(LIBXML2_LIBRARIES "${LIBXML2_LIBRARIES}" d)
endif()
@@ -1097,6 +1109,9 @@ add_library(IfcParse ${IFCPARSE_FILES})
target_link_libraries(IfcParse ${ROCKSDB_LIBRARY} ${ZSTD_LIBRARY} ${RPCRT_LIBRARIES} ${STDCPPFS})
set_target_properties(IfcParse PROPERTIES COMPILE_FLAGS -DIFC_PARSE_EXPORTS VERSION "${PROJECT_VERSION}" SOVERSION "${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}")
if(LibXml2_DIR)
target_compile_definitions(IfcParse PRIVATE ${LIBXML2_DEFINITIONS})
endif()
if(WASM_BUILD)
target_link_libraries(IfcParse ${BCRYPT_LIBRARIES} ${LIBXML2_LIBRARIES})
else()
+9 -3
View File
@@ -29,7 +29,7 @@ from ifcopenshell.util.doc import get_attribute_doc, get_predefined_type_doc, ge
import bonsai.tool as tool
from types import EllipsisType
from typing import Optional, Any, Union, TYPE_CHECKING
from collections.abc import Callable, Iterable
from collections.abc import Callable, Iterable, Sequence
if TYPE_CHECKING:
import bonsai.bim.prop
@@ -51,7 +51,7 @@ if TYPE_CHECKING:
def draw_attributes(
props: bpy.types.bpy_prop_collection_idprop[Attribute],
props: Union[bpy.types.bpy_prop_collection_idprop[Attribute], Sequence[Attribute]],
layout: bpy.types.UILayout,
copy_operator: Optional[str] = None,
popup_active_attribute: Optional[bonsai.bim.prop.Attribute] = None,
@@ -296,7 +296,13 @@ def add_attribute_enum_items_descriptions(
new_enum_description.name = description
def add_attribute_description(attribute_blender: bonsai.bim.prop.Attribute, attribute_ifc=None):
def add_attribute_description(
attribute_blender: bonsai.bim.prop.Attribute,
attribute_ifc: Union[ifcopenshell.entity_instance, None] = None,
) -> None:
"""
:param attribute_ifc: IFC Entity to use as a fallback source of description (using "Description" attribute).
"""
if not attribute_blender.name:
return
version = tool.Ifc.get_schema()
+50 -10
View File
@@ -219,9 +219,7 @@ class IfcImporter:
self.gross_elements: set[ifcopenshell.entity_instance] = set()
self.element_types: set[ifcopenshell.entity_instance] = set()
self.spatial_elements: set[ifcopenshell.entity_instance] = set()
self.type_products = {}
self.meshes: dict[str, OBJECT_DATA_TYPE] = {}
self.mesh_shapes = {}
self.time = 0
self.unit_scale = 1.0
# ifc definition ids to blender elements mapping
@@ -463,6 +461,10 @@ class IfcImporter:
return False
def calculate_model_offset(self) -> None:
# TODO:
if isinstance(self.file, ifcopenshell.sqlite):
print("WARNING. Calculating model offset for IFCSQLite is not supported.")
return
props = tool.Georeference.get_georeference_props()
if self.ifc_import_settings.false_origin_mode == "MANUAL":
tool.Loader.set_manual_blender_offset(self.file)
@@ -516,11 +518,52 @@ class IfcImporter:
self.set_matrix_world(obj, tool.Loader.apply_blender_offset_to_matrix_world(obj, grid_placement))
def create_element_types(self):
# TODO:
if isinstance(self.file, ifcopenshell.sqlite):
self.create_element_types_sqlite(self.element_types)
return
for element_type in self.element_types:
if not element_type:
continue
self.create_element_type(element_type)
def create_element_types_sqlite(self, element_types: set[ifcopenshell.entity_instance]) -> None:
assert isinstance(self.file, ifcopenshell.sqlite)
geometry_cache = self.file.get_geometry([e.id() for e in element_types])
geometry_meshes: dict[str, bpy.types.Mesh] = {}
for geometry_id, geometry in geometry_cache["geometry"].items():
verts = geometry["verts"]
fake_geometry = type("Geometry", (), {"id": geometry_id})
mesh_name = tool.Loader.get_mesh_name_from_shape(fake_geometry) # pyright: ignore[reportArgumentType]
mesh = bpy.data.meshes.new(mesh_name)
if geometry["faces"].size:
mesh = tool.Loader.create_mesh_from_shape(
mesh=mesh, faces=geometry["faces"].reshape(-1, 3), verts=verts.reshape(-1, 3)
)
else:
vertices = verts.reshape(-1, 3).tolist()
edges = geometry["edges"].reshape(-1, 2).tolist()
mesh.from_pydata(vertices, edges, [])
tool.Loader.link_mesh(fake_geometry, mesh) # pyright: ignore[reportArgumentType]
mesh["ios_materials"] = geometry["materials"]
mesh["ios_material_ids"] = geometry["material_ids"]
self.meshes[mesh_name] = mesh
geometry_meshes[geometry_id] = mesh
shapes = geometry_cache["shapes"]
for element in element_types:
# Allow missing element types to accomodate older ifcsqlite files
# that didn't store element types geometry.
shape = shapes.get(element.id())
if shape:
geometry_id = shapes[element.id()]["geometry"]
else:
geometry_id = None
mesh = None if geometry_id is None else geometry_meshes[geometry_id]
obj = bpy.data.objects.new(tool.Loader.get_name(element), mesh)
self.link_element(element, obj)
self.material_creator.create(element, obj, mesh, False)
def create_element_type(self, element: ifcopenshell.entity_instance) -> None:
self.ifc_import_settings.logger.info("Creating object %s", element)
mesh = None
@@ -547,7 +590,6 @@ class IfcImporter:
obj = bpy.data.objects.new(tool.Loader.get_name(element), mesh)
self.link_element(element, obj)
self.material_creator.create(element, obj, mesh, False)
self.type_products[element.GlobalId] = obj
def create_native_elements(self):
if not self.ifc_import_settings.should_load_geometry:
@@ -631,15 +673,13 @@ class IfcImporter:
verts = geometry["verts"]
mesh["has_cartesian_point_offset"] = False
if geometry["faces"]:
if geometry["faces"].size:
mesh = tool.Loader.create_mesh_from_shape(
mesh=mesh, faces=geometry["faces"].reshape(-1, 3), verts=verts.reshape(-1, 3)
)
else:
e = geometry["edges"]
v = verts
vertices = [[v[i], v[i + 1], v[i + 2]] for i in range(0, len(v), 3)]
edges = [[e[i], e[i + 1]] for i in range(0, len(e), 2)]
vertices = verts.reshape(-1, 3).tolist()
edges = geometry["edges"].reshape(-1, 2).tolist()
mesh.from_pydata(vertices, edges, [])
mesh["ios_materials"] = geometry["materials"]
@@ -40,6 +40,7 @@ from mathutils import Vector, Matrix
from bonsai.bim.ifc import IfcStore
from bonsai.bim.module.model.decorator import ProfileDecorator
from bonsai.bim.module.boundary.decorator import BoundaryDecorator
from ifcopenshell.util.shape_builder import ShapeBuilder
import bonsai.core
import bonsai.core.geometry
from typing import Union, Optional
@@ -1058,15 +1059,11 @@ class AddBoundary(bpy.types.Operator, tool.Ifc.Operator):
self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
tool.Model.unit_scale = self.unit_scale
builder = ShapeBuilder(tool.Ifc.get())
surface = tool.Ifc.get().createIfcCurveBoundedPlane()
surface.BasisSurface = tool.Ifc.get().createIfcPlane(
tool.Ifc.get().createIfcAxis2Placement3D(
tool.Ifc.get().createIfcCartesianPoint([o / self.unit_scale for o in p1]),
tool.Ifc.get().createIfcDirection([float(o) for o in z_axis]),
tool.Ifc.get().createIfcDirection([float(o) for o in x_axis]),
)
)
placement = builder.create_axis2_placement_3d([o / self.unit_scale for o in p1], z_axis, x_axis)
surface.BasisSurface = tool.Ifc.get().create_entity("IfcPlane", placement)
if tool.Ifc.get().schema != "IFC2X3":
points = [tool.Model.convert_si_to_unit(list(co)) for co in polygon.exterior.coords]
@@ -56,6 +56,7 @@ class ClassificationsData:
data = element.get_info()
if tool.Ifc.get().schema == "IFC2X3" and element.EditionDate:
data["EditionDate"] = ifcopenshell.util.date.ifc2datetime(data["EditionDate"])
data["Name"] = data["Name"] or "Unnamed"
results.append(data)
return results
@@ -17,7 +17,6 @@
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import bpy
import json
import ifcopenshell
import ifcopenshell.api
import ifcopenshell.api.pset
@@ -192,21 +191,11 @@ class EnableEditingClassification(bpy.types.Operator):
classification: bpy.props.IntProperty()
def execute(self, context):
def callback(name, prop, data):
if name == "ReferenceTokens":
geo_props = tool.Georeference.get_georeference_props()
new = geo_props.projected_crs.add()
new.name = name
new.data_type = "string"
new.is_null = data[name] is None
new.is_optional = True
new.string_value = "" if new.is_null else json.dumps(data[name])
return True
props = tool.Classification.get_classification_props()
props.classification_attributes.clear()
bonsai.bim.helper.import_attributes(
tool.Ifc.get().by_id(self.classification), props.classification_attributes, callback
tool.Ifc.get().by_id(self.classification),
props.classification_attributes,
)
props.active_classification_id = self.classification
return {"FINISHED"}
@@ -248,13 +237,7 @@ class EditClassification(bpy.types.Operator, tool.Ifc.Operator):
def _execute(self, context):
props = tool.Classification.get_classification_props()
def callback(attributes, prop):
if prop.name == "ReferenceTokens":
attributes[prop.name] = json.loads(prop.string_value)
return True
attributes = bonsai.bim.helper.export_attributes(props.classification_attributes, callback=callback)
attributes = bonsai.bim.helper.export_attributes(props.classification_attributes)
ifc_file = tool.Ifc.get()
ifcopenshell.api.classification.edit_classification(
ifc_file,
@@ -73,6 +73,7 @@ class BIM_PT_classifications(Panel):
self.draw_ui(classification)
def draw_add_manual_ui(self, context):
assert self.layout
if self.props.is_adding:
bonsai.bim.helper.draw_attributes(self.props.classification_attributes, self.layout)
row = self.layout.row(align=True)
@@ -83,10 +84,12 @@ class BIM_PT_classifications(Panel):
row.operator("bim.enable_adding_manual_classification", text="Add Classification", icon="ADD")
def draw_add_bsdd_ui(self, context):
assert self.layout
row = self.layout.row()
row.operator("bim.add_classification_from_bsdd", icon="ADD")
def draw_add_file_ui(self, context):
assert self.layout
if ClassificationsData.data["has_classification_file"]:
row = self.layout.row(align=True)
row.prop(self.props, "available_classifications", text="")
@@ -97,13 +100,15 @@ class BIM_PT_classifications(Panel):
row.label(text="No Active Classification Library")
row.operator("bim.load_classification_library", text="", icon="IMPORT")
def draw_editable_ui(self):
def draw_editable_ui(self) -> None:
assert self.layout
row = self.layout.row(align=True)
row.operator("bim.edit_classification", text="Save changes", icon="CHECKMARK")
row.operator("bim.disable_editing_classification", text="", icon="CANCEL")
bonsai.bim.helper.draw_attributes(self.props.classification_attributes, self.layout)
def draw_ui(self, classification):
def draw_ui(self, classification: dict[str, Any]) -> None:
assert self.layout
row = self.layout.row(align=True)
row.label(text=classification["Name"], icon="ASSET_MANAGER")
if not self.props.active_classification_id:
+8 -11
View File
@@ -332,7 +332,7 @@ class DecoratorData:
returns font size in mm for current ifc text object"""
element = tool.Ifc.get_entity(obj)
assert element
props = tool.Drawing.get_text_props(obj)
# getting font size
pset_data = ifcopenshell.util.element.get_pset(element, "EPset_Annotation") or {}
# use `regular` as default
@@ -353,20 +353,17 @@ class DecoratorData:
newline_at = pset_data.get("Newline_At", 0)
# other attributes
props_literals = props.literals
props_literals_n = len(props.literals)
literals = tool.Drawing.get_text_literal(obj, return_list=True)
literals_data = []
for i, literal in enumerate(literals):
assert isinstance(literals, list)
literals_data: list[dict[str, Any]] = []
product = tool.Drawing.get_assigned_product(element) or element
for literal in literals:
literal_value = literal.Literal
literal_data = {
"Literal": literal.Literal,
"Literal": literal_value,
"BoxAlignment": literal.BoxAlignment,
"CurrentValue": tool.Drawing.replace_text_literal_variables(literal_value, product),
}
if i < props_literals_n:
literal_data["CurrentValue"] = props_literals[i].value
else:
literal_data["CurrentValue"] = literal.Literal
literals_data.append(literal_data)
return {"Literals": literals_data, "FontSize": font_size, "Symbol": symbol, "Newline_At": newline_at}
@@ -601,7 +601,7 @@ class BaseDecorator:
props = tool.Drawing.get_text_props(obj)
text_data = DecoratorData.data["text"].get(obj.name, None)
if props.is_editing:
text_data = text_data | props.get_text_edited_data()
text_data = props.get_text_edited_data()
literals_data = text_data["Literals"]
symbol = text_data["Symbol"]
newline_at = text_data["Newline_At"]
@@ -2971,47 +2971,9 @@ class EditTextPopup(bpy.types.Operator):
first_run: bpy.props.BoolProperty(default=True)
def draw(self, context):
# shares most of the code with BIM_PT_text.draw()
# need to keep them in sync or move to some common function
# NOTE: that `popup_active_attribute` is used here when it's not used in `BIM_PT_text.draw()`
from bonsai.bim.module.drawing.ui import BIM_PT_text
assert self.layout
obj = context.active_object
assert obj
props = tool.Drawing.get_text_props(obj)
row = self.layout.row(align=True)
row.operator("bim.add_text_literal", icon="ADD", text="Add Literal")
row = self.layout.row(align=True)
row.prop(props, "font_size")
for i, literal_props in enumerate(props.literals):
box = self.layout.box()
row = self.layout.row(align=True)
row = box.row(align=True)
row.label(text=f"Literal[{i}]:")
row.operator("bim.remove_text_literal", icon="X", text="").literal_prop_id = i
# skip BoxAlignment since we're going to format it ourselves
attributes = [a for a in literal_props.attributes if a.name != "BoxAlignment"]
bonsai.bim.helper.draw_attributes(attributes, box, popup_active_attribute=attributes[0])
row = box.row(align=True)
cols = [row.column(align=True) for i in range(3)]
for i in range(9):
cols[i % 3].prop(
literal_props,
"box_alignment",
text="",
index=i,
icon="RADIOBUT_ON" if literal_props.box_alignment[i] else "RADIOBUT_OFF",
)
col = row.column(align=True)
col.label(text=" Text box alignment:")
col.label(text=f' {literal_props.attributes["BoxAlignment"].string_value}')
BIM_PT_text.draw_text_editing_ui(self, context, popup_mode=True)
def cancel(self, context):
# disable editing when dialog is closed
@@ -3066,7 +3028,6 @@ class DisableEditingText(bpy.types.Operator, tool.Ifc.Operator):
# force update this object's font size for viewport display
DecoratorData.data.pop(obj.name, None)
tool.Drawing.update_text_value(obj)
tool.Blender.update_viewport()
@@ -3123,6 +3084,7 @@ class RemoveTextLiteral(bpy.types.Operator):
assert obj
props = tool.Drawing.get_text_props(obj)
props.literals.remove(self.literal_prop_id)
tool.Blender.update_viewport()
return {"FINISHED"}
@@ -3140,6 +3102,7 @@ class OrderTextLiteralUp(bpy.types.Operator):
assert obj
props = tool.Drawing.get_text_props(obj)
props.literals.move(self.literal_prop_id, self.literal_prop_id - 1)
tool.Blender.update_viewport()
return {"FINISHED"}
@@ -3157,6 +3120,7 @@ class OrderTextLiteralDown(bpy.types.Operator):
assert obj
props = tool.Drawing.get_text_props(obj)
props.literals.move(self.literal_prop_id, self.literal_prop_id + 1)
tool.Blender.update_viewport()
return {"FINISHED"}
+34 -12
View File
@@ -267,12 +267,6 @@ def update_titleblocks(self, context):
def update_should_draw_decorations(self, context: bpy.types.Context) -> None:
if self.should_draw_decorations:
# TODO: design a proper text variable templating renderer
collection = tool.Blender.get_object_bim_props(context.scene.camera).collection
for obj in collection.objects:
element = tool.Ifc.get_entity(obj)
if not element or not tool.Drawing.is_annotation_object_type(element, ["TEXT", "TEXT_LEADER"]):
continue
tool.Drawing.update_text_value(obj)
refresh_drawing_data()
if bpy.app.background:
return
@@ -709,16 +703,12 @@ class LiteralProps(PropertyGroup):
return self.get("box_alignment", DEFAULT_BOX_ALIGNMENT)
attributes: CollectionProperty(name="Attributes", type=Attribute)
# Current text value with evaluated expressions stored in `value`.
# The original (Literal) value stored in `attributes['Literal']`
# and can be accessed with `get_text()`
value: StringProperty(name="Value", default="TEXT")
box_alignment: BoolVectorProperty(
name="Box alignment", size=9, set=set_box_alignment, get=get_box_alignment, default=DEFAULT_BOX_ALIGNMENT
)
ifc_definition_id: IntProperty(name="IFC definition ID", default=0)
def get_literal_edited_data(self):
def get_literal_edited_data(self) -> dict[str, str]:
text_data = {
"CurrentValue": self.attributes["Literal"].string_value,
"Literal": self.attributes["Literal"].string_value,
@@ -729,7 +719,7 @@ class LiteralProps(PropertyGroup):
if TYPE_CHECKING:
attributes: bpy.types.bpy_prop_collection_idprop[Attribute]
value: str
box_alignment: str
box_alignment: tuple[bool, bool, bool, bool, bool, bool, bool, bool, bool]
ifc_definition_id: int
@@ -748,12 +738,43 @@ class BIMTextProperties(PropertyGroup):
name="Font Size",
)
newline_at: IntProperty(name="Newline At")
symbol: EnumProperty( # pyright: ignore[reportRedeclaration]
name="Symbol",
description="Symbol from symbols.svg to use for this text.",
items=[(s, s, "") for s in ["NO SYMBOL", "CUSTOM SYMBOL"] + tool.Drawing.DEFAULT_SYMBOLS],
default="NO SYMBOL",
)
custom_symbol: StringProperty( # pyright: ignore[reportRedeclaration]
name="Custom Symbol",
description="Non-default symbol to use for this text.",
)
if TYPE_CHECKING:
is_editing: bool
literals: bpy.types.bpy_prop_collection_idprop[LiteralProps]
font_size: str
newline_at: int
symbol: Union[str, Literal["NO SYMBOL", "CUSTOM SYMBOL"]]
custom_symbol: str
def get_symbol(self) -> Union[str, None]:
if self.symbol == "NO SYMBOL":
return None
elif self.symbol == "CUSTOM SYMBOL":
return self.custom_symbol or None
else:
return self.symbol
def set_symbol(self, symbol: Union[str, None]):
if not symbol:
self.property_unset("symbol")
self.property_unset("custom_symbol")
elif symbol in tool.Drawing.DEFAULT_SYMBOLS:
self.symbol = symbol
self.property_unset("custom_symbol")
else:
self.symbol = "CUSTOM SYMBOL"
self.custom_symbol = symbol
def get_text_edited_data(self) -> dict[str, Any]:
"""should be called only if `is_editing`
@@ -768,6 +789,7 @@ class BIMTextProperties(PropertyGroup):
"Literals": literals_data,
"FontSize": float(self.font_size),
"Newline_At": int(self.newline_at),
"Symbol": self.get_symbol(),
}
return text_data
@@ -823,6 +823,7 @@ class SvgWriter:
x_offset = self.raw_width / 2
y_offset = self.raw_height / 2
element = tool.Ifc.get_entity(text_obj)
assert element
text_literals = tool.Drawing.get_text_literal(text_obj, return_list=True)
product = tool.Drawing.get_assigned_product(element)
+66 -45
View File
@@ -29,7 +29,7 @@ from bonsai.bim.module.drawing.data import (
ElementFiltersData,
DecoratorData,
)
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Union
if TYPE_CHECKING:
from bonsai.bim.module.drawing.prop import DocProperties, Drawing, Sheet
@@ -565,56 +565,77 @@ class BIM_PT_text(Panel):
return
return tool.Drawing.is_annotation_object_type(element, ["TEXT", "TEXT_LEADER"])
def draw_text_editing_ui(
self: Union[bpy.types.Panel, bpy.types.Operator],
context: bpy.types.Context,
*,
popup_mode: bool = False,
) -> None:
# The method is also used in EditTextPopup.draw().
assert self.layout
obj = context.active_object
assert obj
props = tool.Drawing.get_text_props(obj)
row = self.layout.row(align=True)
if popup_mode:
row.operator("bim.add_text_literal", icon="ADD", text="Add Literal")
else:
row.operator("bim.edit_text", icon="CHECKMARK")
row.operator("bim.add_text_literal", icon="ADD", text="")
row.operator("bim.disable_editing_text", icon="CANCEL", text="")
row = self.layout.row(align=True)
row.prop(props, "font_size")
row = self.layout.row(align=True)
row.prop(props, "newline_at")
row = self.layout.row(align=True)
row.prop(props, "symbol")
if props.symbol == "CUSTOM SYMBOL":
row = self.layout.row(align=True)
row.prop(props, "custom_symbol", text="")
for i, literal_props in enumerate(props.literals):
box = self.layout.box()
row = self.layout.row(align=True)
row = box.row(align=True)
row.label(text=f"Literal[{i}]:")
if i > 0:
row.operator("bim.order_text_literal_up", icon="TRIA_UP", text="").literal_prop_id = i
if i < len(props.literals) - 1:
row.operator("bim.order_text_literal_down", icon="TRIA_DOWN", text="").literal_prop_id = i
row.operator("bim.remove_text_literal", icon="X", text="").literal_prop_id = i
# skip BoxAlignment since we're going to format it ourselves
attributes = [a for a in literal_props.attributes if a.name != "BoxAlignment"]
popup_active_attribute = attributes[0] if popup_mode else None
bonsai.bim.helper.draw_attributes(attributes, box, popup_active_attribute=popup_active_attribute)
row = box.row(align=True)
cols = [row.column(align=True) for i in range(3)]
for i in range(9):
cols[i % 3].prop(
literal_props,
"box_alignment",
text="",
index=i,
icon="RADIOBUT_ON" if literal_props.box_alignment[i] else "RADIOBUT_OFF",
)
col = row.column(align=True)
col.label(text=" Text box alignment:")
col.label(text=f' {literal_props.attributes["BoxAlignment"].string_value}')
def draw(self, context):
obj = context.active_object
assert obj
props = tool.Drawing.get_text_props(obj)
if props.is_editing:
# shares most of the code with EditTextPopup.draw()
# need to keep them in sync or move to some common function
row = self.layout.row(align=True)
row.operator("bim.edit_text", icon="CHECKMARK")
row.operator("bim.add_text_literal", icon="ADD", text="")
row.operator("bim.disable_editing_text", icon="CANCEL", text="")
row = self.layout.row(align=True)
row.prop(props, "font_size")
row = self.layout.row(align=True)
row.prop(props, "newline_at")
for i, literal_props in enumerate(props.literals):
box = self.layout.box()
row = self.layout.row(align=True)
row = box.row(align=True)
row.label(text=f"Literal[{i}]:")
if i > 0:
row.operator("bim.order_text_literal_up", icon="TRIA_UP", text="").literal_prop_id = i
if i < len(props.literals) - 1:
row.operator("bim.order_text_literal_down", icon="TRIA_DOWN", text="").literal_prop_id = i
row.operator("bim.remove_text_literal", icon="X", text="").literal_prop_id = i
# skip BoxAlignment since we're going to format it ourselves
attributes = [a for a in literal_props.attributes if a.name != "BoxAlignment"]
bonsai.bim.helper.draw_attributes(attributes, box)
row = box.row(align=True)
cols = [row.column(align=True) for i in range(3)]
for i in range(9):
cols[i % 3].prop(
literal_props,
"box_alignment",
text="",
index=i,
icon="RADIOBUT_ON" if literal_props.box_alignment[i] else "RADIOBUT_OFF",
)
col = row.column(align=True)
col.label(text=" Text box alignment:")
col.label(text=f' {literal_props.attributes["BoxAlignment"].string_value}')
self.draw_text_editing_ui(context)
else:
text_data = DecoratorData.get_text_data(obj)
@@ -28,6 +28,7 @@ import bonsai.tool as tool
from math import pi, pow
from mathutils import Vector, Matrix, geometry
from typing import Union, Any, TypeVar, Optional
from ifcopenshell.util.shape_builder import ShapeBuilder
T = TypeVar("T")
@@ -35,6 +36,7 @@ T = TypeVar("T")
class Helper:
def __init__(self, file: ifcopenshell.file):
self.file = file
self.builder = ShapeBuilder(file)
self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(self.file)
# We can detect a rectangular extrusion by picking any face, then find an
@@ -340,8 +342,10 @@ class Helper:
def create_extruded_area_solid(
self, mesh: bpy.types.Mesh, extrusion_indices: list[int], profile_def: dict[str, Any]
) -> ifcopenshell.entity_instance:
position = self.create_ifc_axis_2_placement_3d(
profile_def["curve_ucs"]["center"], profile_def["curve_ucs"]["z_axis"], profile_def["curve_ucs"]["x_axis"]
position = self.builder.create_axis2_placement_3d(
self.convert_si_to_unit(profile_def["curve_ucs"]["center"]),
profile_def["curve_ucs"]["z_axis"],
profile_def["curve_ucs"]["x_axis"],
)
direction = self.get_extrusion_direction(mesh, extrusion_indices, profile_def["curve_ucs"])
unit_direction = direction.normalized()
@@ -505,12 +509,3 @@ class Helper:
return self.file.createIfcAxis2Placement2D(
self.create_cartesian_point(point.x, point.y), self.file.createIfcDirection((forward.x, forward.y))
)
def create_ifc_axis_2_placement_3d(
self, point: Vector, up: Vector, forward: Vector
) -> ifcopenshell.entity_instance:
return self.file.createIfcAxis2Placement3D(
self.create_cartesian_point(point.x, point.y, point.z),
self.file.createIfcDirection((up.x, up.y, up.z)),
self.file.createIfcDirection((forward.x, forward.y, forward.z)),
)
@@ -1452,16 +1452,20 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator):
self.report({"ERROR"}, "No LAYER2 objects selected")
return
if layer2_bases and len(set(layer2_bases)) > 1:
# --- tolerance check ---
tolerance = 1e-5 # to provide a little wiggle room
if layer2_bases and (max(layer2_bases) - min(layer2_bases)) > tolerance:
min_base = min(layer2_bases)
max_base = max(layer2_bases)
self.report(
{"ERROR"},
f"Selected LAYER2 objects have different base heights ({min_base:.3f}m to {max_base:.3f}m). All objects must be at the exact same base level.",
f"Selected LAYER2 objects have different base heights ({min_base:.3f}m to {max_base:.3f}m). "
f"All objects must be at the exact same base level (tolerance {tolerance}).",
)
return
common_base = layer2_bases[0]
# use the mean base as the "common" one to avoid floating-point mismatches
common_base = sum(layer2_bases) / len(layer2_bases)
new_height = cursor_z - common_base
if new_height > 0:
+7 -3
View File
@@ -19,6 +19,7 @@
from __future__ import annotations
import bpy
import os
import ifcopenshell
import bonsai.bim
import bonsai.tool as tool
from bonsai.bim.helper import prop_with_search, draw_attributes
@@ -259,10 +260,10 @@ class BIM_PT_project(Panel):
def draw_editable_file_info(self, context):
pprops = self.props
if tool.Ifc.get():
if ifc_file := tool.Ifc.get():
row = self.layout.row(align=True)
row.label(text="IFC Schema", icon="FILE_CACHE")
row.label(text=tool.Ifc.get().schema)
row.label(text=ifc_file.schema)
if pprops.is_editing:
row = self.layout.row(align=True)
@@ -283,7 +284,10 @@ class BIM_PT_project(Panel):
else:
row = self.layout.row(align=True)
row.label(text="IFC MVD", icon="FILE_HIDDEN")
mvd = "".join(tool.Ifc.get().wrapped_data.header.file_description.description)
if isinstance(ifc_file, ifcopenshell.sqlite):
mvd = ifc_file.mvd_str
else:
mvd = "".join(ifc_file.wrapped_data.header.file_description.description)
if "[" in mvd:
mvd = mvd.split("[")[1][0:-1]
row.label(text=mvd)
@@ -203,6 +203,7 @@ class AssignClass(bpy.types.Operator, tool.Ifc.Operator):
predefined_type: str
userdefined_type: str
context_id: int
props_to_pset: bool
should_add_representation: bool
ifc_representation_class: str
@@ -385,13 +386,8 @@ class AssignClass(bpy.types.Operator, tool.Ifc.Operator):
# TODO: reload representation might lead to the object being replaced by object of the other type.
# We probably should track it somehow and keep the original selection.
# Validation selection.
new_selected_objects = list(filter(tool.Blender.is_valid_data_block, current_selection[2]))
active_object = current_selection[1]
if active_object and not tool.Blender.is_valid_data_block(active_object):
active_object = None
current_selection = (current_selection[0], active_object, new_selected_objects)
# Validate selection and reapply it.
current_selection = tool.Blender.validate_object_selection(*current_selection)
tool.Blender.set_objects_selection(*current_selection)
@@ -21,6 +21,9 @@ from . import ui, prop, operator
classes = (
operator.ExecuteIfcTester,
operator.StartIfcTesterWebapp,
operator.StopIfcTesterWebapp,
operator.OpenIfcTesterWebapp,
operator.SelectRequirement,
operator.SelectFailedEntities,
operator.ExportBcf,
@@ -22,6 +22,13 @@ import time
import tempfile
import webbrowser
import traceback
import subprocess
import socket
import sys
import threading
import asyncio
import socketio
from aiohttp import web
import ifctester
import ifctester.ids
import ifctester.reporter
@@ -32,6 +39,167 @@ from bpy_extras.io_utils import ExportHelper
from pathlib import Path
from typing import Union
webapp_process = None
websocket_server_thread = None
websocket_app = None
websocket_runner = None
class IfcTesterWebSocketServer:
def __init__(self, port):
self.port = port
self.sio = socketio.AsyncServer(cors_allowed_origins="*", async_mode="aiohttp")
self.app = web.Application()
self.sio.attach(self.app)
self.runner = None
self.site = None
self.loop = None
self.shutdown_event = None
# Register namespace
self.sio.register_namespace(IfcTesterNamespace("/ifctester"))
# Add health check route
self.app.router.add_get("/health", self.health_check)
async def health_check(self, request):
return web.Response(text="OK", content_type="text/plain")
async def start_server(self):
self.loop = asyncio.get_event_loop()
self.shutdown_event = asyncio.Event()
self.runner = web.AppRunner(self.app)
await self.runner.setup()
self.site = web.TCPSite(self.runner, "127.0.0.1", self.port)
await self.site.start()
print(f"IfcTester WebSocket server started on 127.0.0.1:{self.port}")
try:
# Wait for shutdown signal
await self.shutdown_event.wait()
except asyncio.CancelledError:
print("WebSocket server received cancellation")
finally:
await self._cleanup()
async def _cleanup(self):
try:
# Disconnect all clients
print("Shutting down SocketIO...")
try:
await self.sio.shutdown()
except Exception as e:
print(f"Error shutting down socketio: {e}")
# Stop the web server
if self.site:
print("Stopping web server...")
await asyncio.wait_for(self.site.stop(), timeout=2.0)
self.site = None
# Clean up the runner
if self.runner:
print("Cleaning up runner...")
await asyncio.wait_for(self.runner.cleanup(), timeout=2.0)
self.runner = None
print("IfcTester WebSocket server stopped")
except TimeoutError:
print("Websocket server cleanup timed out, forcing shutdown")
except Exception as e:
print(f"Error during websocket cleanup: {e}")
def stop_server(self):
if self.loop and self.shutdown_event and not self.shutdown_event.is_set():
try:
self.loop.call_soon_threadsafe(self.shutdown_event.set)
except Exception as e:
print(f"Error sending shutdown signal: {e}")
class IfcTesterNamespace(socketio.AsyncNamespace):
def __init__(self, namespace):
super().__init__(namespace)
async def on_connect(self, sid, environ):
print(f"IfcTester webapp client connected: {sid}")
await self.emit("status", {"connected": True}, room=sid)
async def on_disconnect(self, sid):
print(f"IfcTester webapp client disconnected: {sid}")
async def on_audit_ids(self, sid, data):
request_id = None
try:
request_id = data.get("id")
ids_string = data.get("ids")
if not request_id:
await self.emit("error", {"error": "No request ID provided"}, room=sid)
return
if not ids_string:
await self.emit("error", {"id": request_id, "error": "No IDS XML string provided"}, room=sid)
return
print(f"Processing IDS audit request {request_id}")
# Check if IFC is loaded in Bonsai
ifc = tool.Ifc.get()
if not ifc:
await self.emit(
"error", {"id": request_id, "error": "No IFC model is currently loaded in Bonsai"}, room=sid
)
return
# Parse IDS from string
try:
ids = ifctester.ids.from_string(ids_string)
except Exception as e:
await self.emit("error", {"id": request_id, "error": f"Failed to parse IDS XML: {str(e)}"}, room=sid)
return
# Validate IFC against IDS
try:
ids.validate(ifc)
except Exception as e:
await self.emit("error", {"id": request_id, "error": f"Validation failed: {str(e)}"}, room=sid)
return
# Generate reports
try:
# JSON report
json_reporter = ifctester.reporter.Json(ids)
json_reporter.report()
json_report = json_reporter.to_string()
# HTML report
html_reporter = ifctester.reporter.Html(ids)
html_reporter.report()
html_report = html_reporter.to_string()
# Send results back
await self.emit(
"audit_result", {"id": request_id, "json_report": json_report, "html_report": html_report}, room=sid
)
print(f"Successfully processed IDS audit request {request_id}")
except Exception as e:
await self.emit("error", {"id": request_id, "error": f"Failed to generate reports: {str(e)}"}, room=sid)
except Exception as e:
print(f"Error processing audit request: {str(e)}")
import traceback
print(traceback.format_exc())
await self.emit("error", {"id": request_id, "error": f"Internal server error: {str(e)}"}, room=sid)
async def on_ping(self, sid, data):
await self.emit("pong", {"timestamp": data.get("timestamp")}, room=sid)
class ExecuteIfcTester(bpy.types.Operator):
bl_idname = "bim.execute_ifc_tester"
@@ -124,6 +292,171 @@ class ExecuteIfcTester(bpy.types.Operator):
new_spec.status = spec["status"]
class StartIfcTesterWebapp(bpy.types.Operator):
bl_idname = "bim.start_ifc_tester_webapp"
bl_label = "Start IfcTester Webapp"
bl_description = "Start the IfcTester webapp server and open it in the default browser"
def execute(self, context):
global webapp_process, websocket_server_thread, websocket_app
props = tool.Tester.get_tester_props()
if webapp_process is not None or websocket_server_thread is not None:
self.report({"WARNING"}, "IfcTester webapp is already running")
return {"CANCELLED"}
try:
import ifctester.webapp.serve
except ImportError:
self.report(
{"ERROR"}, "IfcTester webapp not available. Please ensure the latest version of ifctester is installed."
)
return {"CANCELLED"}
webapp_port = self.find_free_port()
websocket_port = self.find_free_port()
# Get the path to the serve.py module
webapp_serve_path = ifctester.webapp.serve.__file__
try:
# Start the websocket server in a thread
websocket_app = IfcTesterWebSocketServer(websocket_port)
def run_websocket_server():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(websocket_app.start_server())
except Exception as e:
print(f"WebSocket server error: {e}")
finally:
loop.close()
websocket_server_thread = threading.Thread(target=run_websocket_server, daemon=True)
websocket_server_thread.start()
# Start the Flask server as subprocess
webapp_process = subprocess.Popen(
[sys.executable, webapp_serve_path, "--host", "127.0.0.1", "--port", str(webapp_port)]
)
# Update properties
props.webapp_server_port = webapp_port
props.websocket_server_port = websocket_port
props.webapp_is_running = True
# Wait a moment for servers to start, then open browser
def delayed_open_browser():
import time
time.sleep(1.5)
webbrowser.open(f"http://127.0.0.1:{webapp_port}?bonsai_server={websocket_port}")
browser_thread = threading.Thread(target=delayed_open_browser, daemon=True)
browser_thread.start()
self.report(
{"INFO"}, f"IfcTester webapp started at http://127.0.0.1:{webapp_port} (Websocket: {websocket_port})"
)
return {"FINISHED"}
except Exception as e:
# Clean up on error
if webapp_process:
webapp_process.terminate()
webapp_process = None
if websocket_server_thread and websocket_app:
# The websocket server will be cleaned up when the thread ends
websocket_server_thread = None
websocket_app = None
self.report({"ERROR"}, f"Failed to start servers: {str(e)}")
return {"CANCELLED"}
def find_free_port(self):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("", 0))
s.listen(1)
port = s.getsockname()[1]
return port
class StopIfcTesterWebapp(bpy.types.Operator):
bl_idname = "bim.stop_ifc_tester_webapp"
bl_label = "Stop IfcTester Webapp"
bl_description = "Stop the IfcTester webapp server"
def execute(self, context):
global webapp_process, websocket_server_thread, websocket_app
props = tool.Tester.get_tester_props()
if webapp_process is None and websocket_server_thread is None:
self.report({"WARNING"}, "No IfcTester servers are running")
return {"CANCELLED"}
errors = []
# Stop webapp server
if webapp_process:
try:
webapp_process.terminate()
webapp_process.wait(timeout=5)
except subprocess.TimeoutExpired:
webapp_process.kill()
except Exception as e:
errors.append(f"Error stopping webapp server: {str(e)}")
finally:
webapp_process = None
# Stop websocket server
if websocket_app and websocket_server_thread:
try:
print("Stopping WebSocket server...")
# Signal shutdown using thread-safe method
websocket_app.stop_server()
# Wait for the websocket thread to finish
websocket_server_thread.join(timeout=5)
except Exception as e:
errors.append(f"Error during websocket shutdown: {str(e)}")
finally:
websocket_app = None
websocket_server_thread = None
# Update properties
props.webapp_server_port = 0
props.websocket_server_port = 0
props.webapp_is_running = False
if errors:
self.report({"WARNING"}, f"IfcTester webapp and server stopped with errors: {'; '.join(errors)}")
else:
self.report({"INFO"}, "IfcTester webapp and server stopped")
return {"FINISHED"}
class OpenIfcTesterWebapp(bpy.types.Operator):
bl_idname = "bim.open_ifc_tester_webapp"
bl_label = "Open IfcTester Webapp"
bl_description = "Open the IfcTester webapp in the default browser"
def execute(self, context):
props = tool.Tester.get_tester_props()
if not props.webapp_is_running:
self.report({"ERROR"}, "IfcTester webapp is not running. Please start it first.")
return {"CANCELLED"}
webbrowser.open(f"http://127.0.0.1:{props.webapp_server_port}?bonsai_server={props.websocket_server_port}")
return {"FINISHED"}
class SelectRequirement(bpy.types.Operator):
bl_idname = "bim.select_requirement"
bl_label = "Select Specification"
@@ -77,6 +77,9 @@ class IfcTesterProperties(PropertyGroup):
failed_entities: CollectionProperty(name="FailedEntities", type=FailedEntities)
has_entities: BoolProperty(default=False, name="")
n_entities: IntProperty(name="", default=0)
webapp_server_port: IntProperty(name="Webapp Server Port", default=0)
webapp_is_running: BoolProperty(default=False, name="Webapp Is Running", options=set())
websocket_server_port: IntProperty(name="WebSocket Server Port", default=0)
if TYPE_CHECKING:
specs: MultipleFileSelect
@@ -92,3 +95,6 @@ class IfcTesterProperties(PropertyGroup):
failed_entities: bpy.types.bpy_prop_collection_idprop[FailedEntities]
has_entities: bool
n_entities: int
webapp_server_port: int
webapp_is_running: bool
websocket_server_port: int
+14
View File
@@ -71,6 +71,20 @@ class BIM_PT_tester(Panel):
row = self.layout.row()
row.operator("bim.execute_ifc_tester")
self.layout.separator()
# IfcTester Webapp controls
if props.webapp_is_running:
row = self.layout.row()
row.label(text=f"Webapp: {props.webapp_server_port} | Server: {props.websocket_server_port}")
row = self.layout.row(align=True)
row.operator("bim.stop_ifc_tester_webapp")
row.operator("bim.open_ifc_tester_webapp", icon="URL", text="")
else:
row = self.layout.row()
row.operator("bim.start_ifc_tester_webapp")
if TesterData.data["has_report"]:
self.layout.template_list(
"BIM_UL_tester_specifications",
+4 -1
View File
@@ -1057,7 +1057,10 @@ class BIM_OT_enum_property_search(bpy.types.Operator):
self.layout.context_pointer_set(name="data", data=self.data)
# NOTE: activate_init don't work with prop_search, so cannot activate field for typing,
# though it would fit perfectly.
self.layout.prop_search(self, "dummy_name", self, "collection_names")
if self.dummy_name:
self.layout.label(text=f"Current: {self.dummy_name}")
self.layout.prop_search(self, "dummy_name", self, "collection_names", text=self.prop_name)
def execute(self, context):
return {"FINISHED"}
+21 -5
View File
@@ -36,7 +36,8 @@ import bonsai.bim
import bonsai.tool as tool
from ifcopenshell.util.file import IfcHeaderExtractor
from bonsai.bim.prop import Attribute
from bonsai.bim.module.bsdd.prop import BIMBSDDProperties
from bonsai.bim.module.bsdd.prop import BIMBSDDProperties, BSDDProperty
from bonsai.bim.module.pset.prop import IfcProperty
from typing import Optional, TYPE_CHECKING, Literal
from natsort import natsorted
@@ -1424,8 +1425,20 @@ def draw_custom_context_menu(self: bpy.types.Menu, context: bpy.types.Context) -
if isinstance(prop_struct, Attribute):
attr = prop_struct
# Hacky way to get Attribute containing description for enumerated values.
pset_enum_identifier = ".enumerated_value.enumerated_values["
attr_path = prop_struct.path_from_id()
if pset_enum_identifier in attr_path:
attr_path = attr_path.partition(pset_enum_identifier)[0]
assert (data_block := prop_struct.id_data)
attr = data_block.path_resolve(attr_path)
assert isinstance(attr, IfcProperty)
attr = attr.metadata
description = attr.description
ifc_class = attr.ifc_class
url = ""
if ifc_class:
try:
url = get_entity_doc(version, ifc_class).get("spec_url", "")
@@ -1445,10 +1458,13 @@ def draw_custom_context_menu(self: bpy.types.Menu, context: bpy.types.Context) -
if attr_name:
op = layout.operator("bim.copy_text_to_clipboard", text="Copy Attribute Name", icon="COPYDOWN")
op.text = attr_name
elif isinstance(prop_struct, BIMBSDDProperties) and hasattr(context, "active_bsdd_property"):
elif isinstance(prop_struct, BIMBSDDProperties) and (
active_bsdd_property := getattr(context, "active_bsdd_property", None)
):
# Context Menu for bSDD Properties
assert isinstance(active_bsdd_property, BSDDProperty)
op_description = layout.operator("bim.show_bsdd_description", text="bSDD Description", icon="INFO")
op_description.url = context.active_bsdd_property.uri
op_description.url = active_bsdd_property.uri
else:
# Basically context menu for any Blender property will end up here,
# and will check 3 types of docs.
@@ -1536,7 +1552,7 @@ class BIM_PT_snappping(Panel):
return context.mode == "OBJECT"
def draw(self, context):
prop = context.scene.BIMSnapProperties
prop = tool.Snap.get_snap_props()
layout = self.layout
col = layout.column(align=True)
col.prop(prop, "vertex", toggle=True, icon="SNAP_VERTEX")
@@ -1544,7 +1560,7 @@ class BIM_PT_snappping(Panel):
col.prop(prop, "edge_center", toggle=True, icon="SNAP_MIDPOINT")
col.prop(prop, "edge_intersection", toggle=True, icon="SNAP_GRID")
col.prop(prop, "face", toggle=True, icon="SNAP_FACE")
groups = context.scene.BIMSnapGroups
groups = tool.Snap.get_snap_groups()
row = layout.row(align=True)
row.label(text="Bonsai Target Selection")
row = layout.row(align=True)
+7 -12
View File
@@ -135,10 +135,8 @@ def assign_cost_item_type(
List of found product types.
"""
product_types = list(spatial.get_selected_product_types())
rels = [
ifc.run("control.assign_control", relating_control=cost_item, related_object=product_type)
for product_type in product_types
]
ifc.run("control.assign_control", relating_control=cost_item, related_objects=product_types)
cost.load_cost_item_types(cost_item)
return product_types
@@ -156,10 +154,7 @@ def unassign_cost_item_type(
"""
if not product_types:
product_types = list(spatial.get_selected_product_types())
[
ifc.run("control.unassign_control", relating_control=cost_item, related_object=product_type)
for product_type in product_types
]
ifc.run("control.unassign_control", relating_control=cost_item, related_objects=product_types)
cost.load_cost_item_types(cost_item)
return product_types
@@ -173,7 +168,7 @@ def assign_cost_item_quantity(
ifc: type[tool.Ifc],
cost: type[tool.Cost],
cost_item: ifcopenshell.entity_instance,
related_object_type: type[tool.Cost].RELATED_OBJECT_TYPE,
related_object_type: tool.Cost.RELATED_OBJECT_TYPE,
prop_name: str,
) -> bool:
products = cost.get_products(related_object_type)
@@ -213,10 +208,10 @@ def assign_cost_value(
ifc.run("cost.assign_cost_value", cost_item=cost_item, cost_rate=cost_rate)
existing_cost_rate = cost.get_assigned_rate_cost_item(cost_item)
if existing_cost_rate is None:
ifc.run("control.assign_control", relating_control=cost_rate, related_object=cost_item)
ifc.run("control.assign_control", relating_control=cost_rate, related_objects=[cost_item])
else:
ifc.run("control.unassign_control", relating_control=existing_cost_rate, related_object=cost_item)
ifc.run("control.assign_control", relating_control=cost_rate, related_object=cost_item)
ifc.run("control.unassign_control", relating_control=existing_cost_rate, related_objects=[cost_item])
ifc.run("control.assign_control", relating_control=cost_rate, related_objects=[cost_item])
def load_schedule_of_rates(cost: type[tool.Cost], schedule_of_rates: ifcopenshell.entity_instance) -> None:
+3 -6
View File
@@ -39,8 +39,7 @@ def disable_editing_text(drawing: type[tool.Drawing], obj: bpy.types.Object) ->
def edit_text(drawing: type[tool.Drawing], obj: bpy.types.Object) -> None:
drawing.synchronise_ifc_and_text_attributes(obj)
drawing.update_text_size_pset(obj)
drawing.update_newline_at(obj)
drawing.update_text_value(obj)
drawing.update_newline_at_and_symbol(obj)
drawing.disable_editing_text(obj)
@@ -67,8 +66,6 @@ def edit_assigned_product(
ifc.run("drawing.unassign_product", relating_product=existing_product, related_object=element)
if product:
ifc.run("drawing.assign_product", relating_product=product, related_object=element)
if drawing.is_annotation_object_type(element, ("TEXT", "TEXT_LEADER")):
drawing.update_text_value(obj)
drawing.disable_editing_assigned_product(obj)
@@ -200,7 +197,7 @@ def disable_editing_references(drawing: type[tool.Drawing]) -> None:
def add_document(
ifc: type[tool.Ifc], drawing: type[tool.Drawing], document_type: type[tool.Drawing].DOCUMENT_TYPE, uri: str
ifc: type[tool.Ifc], drawing: type[tool.Drawing], document_type: tool.Drawing.DOCUMENT_TYPE, uri: str
) -> None:
document = ifc.run("document.add_information")
reference = ifc.run("document.add_reference", information=document)
@@ -217,7 +214,7 @@ def add_document(
def remove_document(
ifc: type[tool.Ifc],
drawing: type[tool.Drawing],
document_type: type[tool.Drawing].DOCUMENT_TYPE,
document_type: tool.Drawing.DOCUMENT_TYPE,
document: ifcopenshell.entity_instance,
) -> None:
ifc.run("document.remove_information", information=document)
+6 -6
View File
@@ -52,7 +52,7 @@ def add_pset(
pset: type[tool.Pset],
blender: type[tool.Blender],
obj_name: str,
obj_type: type[tool.Ifc].OBJECT_TYPE,
obj_type: tool.Ifc.OBJECT_TYPE,
) -> None:
pset_name = pset.get_pset_name(obj_name, obj_type, pset_type="PSET")
if obj_type == "Object":
@@ -71,9 +71,9 @@ def enable_pset_editing(
pset_tool: type[tool.Pset],
pset: Union[ifcopenshell.entity_instance, None],
pset_name: str,
pset_type: type[tool.Pset].PSET_TYPE,
pset_type: tool.Pset.PSET_TYPE,
obj_name: str,
obj_type: type[tool.Ifc].OBJECT_TYPE,
obj_type: tool.Ifc.OBJECT_TYPE,
) -> None:
props = pset_tool.get_pset_props(obj_name, obj_type)
pset_tool.clear_blender_pset_properties(props)
@@ -87,14 +87,14 @@ def enable_pset_editing(
has_template = False
if pset:
pset_tool.import_pset_from_existing(pset, props)
pset_tool.import_pset_from_existing(pset, props, pset_template)
pset_tool.set_active_pset(props, pset, has_template)
else:
pset_tool.enable_proposed_pset(props, pset_name, pset_type, has_template)
def add_proposed_prop(
pset: type[tool.Pset], obj_name: str, obj_type: type[tool.Ifc].OBJECT_TYPE, name: str, value: Any
pset: type[tool.Pset], obj_name: str, obj_type: tool.Ifc.OBJECT_TYPE, name: str, value: Any
) -> Union[None, str]:
props = pset.get_pset_props(obj_name, obj_type)
res = pset.add_proposed_property(name, pset.cast_string_to_primitive(value), props)
@@ -103,7 +103,7 @@ def add_proposed_prop(
def unshare_pset(
ifc: type[tool.Ifc], pset_tool: type[tool.Pset], obj_type: type[tool.Ifc].OBJECT_TYPE, obj_name: str, pset_id: int
ifc: type[tool.Ifc], pset_tool: type[tool.Pset], obj_type: tool.Ifc.OBJECT_TYPE, obj_name: str, pset_id: int
) -> None:
elements: list[ifcopenshell.entity_instance]
pset = ifc.get_entity_by_id(pset_id)
+2 -2
View File
@@ -422,7 +422,7 @@ def edit_task_calendar(
task: ifcopenshell.entity_instance,
work_calendar: ifcopenshell.entity_instance,
) -> None:
ifc.run("control.assign_control", relating_control=work_calendar, related_object=task)
ifc.run("control.assign_control", relating_control=work_calendar, related_objects=[task])
ifc.run("sequence.cascade_schedule", task=task)
sequence.load_task_properties()
@@ -433,7 +433,7 @@ def remove_task_calendar(
task: ifcopenshell.entity_instance,
work_calendar: ifcopenshell.entity_instance,
) -> None:
ifc.run("control.unassign_control", relating_control=work_calendar, related_object=task)
ifc.run("control.unassign_control", relating_control=work_calendar, related_objects=[task])
ifc.run("sequence.cascade_schedule", task=task)
sequence.load_task_properties()
+2 -4
View File
@@ -307,7 +307,7 @@ class Document:
@interface
class Drawing:
def activate_drawing(cls, camera): pass
def add_literal_to_annotation(cls, obj, Literal='Literal', Path='RIGHT', BoxAlignment='bottom-left'): pass
def add_literal(cls, **attributes): pass
def copy_representation(cls, source, dest): pass
def create_annotation_context(cls, target_view, object_type=None): pass
def create_annotation_object(cls, drawing, object_type): pass
@@ -382,7 +382,6 @@ class Drawing:
def open_spreadsheet(cls, uri): pass
def open_svg(cls, filepath): pass
def reload_representation(cls, obj, representation): pass
def remove_literal_from_annotation(cls, obj, literal): pass
def run_drawing_activate_model(cls): pass
def run_root_assign_class(cls, obj=None, ifc_class=None, predefined_type=None, should_add_representation=True, context=None, ifc_representation_class=None): pass
def run_type_assign_type(cls, element=None, relating_type=None): pass
@@ -395,9 +394,8 @@ class Drawing:
def sync_object_placement(cls, obj): pass
def synchronise_ifc_and_text_attributes(cls, obj): pass
def update_embedded_svg_location(cls, uri, old_location, new_location): pass
def update_newline_at(cls, obj): pass
def update_newline_at_and_symbol(cls, obj): pass
def update_text_size_pset(cls, obj): pass
def update_text_value(cls, obj): pass
@interface
+31 -3
View File
@@ -56,7 +56,7 @@ from collections.abc import Iterable, Callable, Generator, Sequence, Sized
if TYPE_CHECKING:
from sun_position.properties import SunPosProperties
import bpy.stub_internal.rna_enums as rna_enums
from bonsai.bim.prop import BIMProperties, BIMObjectProperties
from bonsai.bim.prop import BIMProperties, BIMObjectProperties, BIMSnapProperties
from bonsai.bim.module.attribute.prop import BIMAttributeProperties
from bonsai.bim.module.constraint.prop import BIMConstraintProperties, BIMObjectConstraintProperties
from bonsai.bim.module.covetool.prop import CoveToolProperties
@@ -690,6 +690,29 @@ class Blender(bonsai.core.tool.Blender):
if active_object:
active_object.select_set(True)
@classmethod
def validate_object_selection(
cls,
context: bpy.types.Context,
active_object: Union[bpy.types.Object, None] = None,
selected_objects: Sequence[bpy.types.Object] = (),
) -> tuple[bpy.types.Context, Union[bpy.types.Object, None], list[bpy.types.Object]]:
"""Validate object selection and return only valid objects.
Can be used before ``set_objects_selection`` to avoid errors
trying to select or set as active already removed objects
or objects that are not in the current view layer (their collection is unchecked).
"""
assert context.view_layer
view_layer_objects = set(context.view_layer.objects)
new_selected_objects = [o for o in selected_objects if cls.is_valid_data_block(o) and o in view_layer_objects]
if active_object and (not cls.is_valid_data_block(active_object) or active_object not in view_layer_objects):
active_object = None
return context, active_object, new_selected_objects
@classmethod
def clear_objects_selection(cls) -> None:
"""Clear objects selection and active object."""
@@ -1558,6 +1581,11 @@ class Blender(bonsai.core.tool.Blender):
return repr(bpy_prop)
return repr(bpy_struct)
@classmethod
def get_props_attribute_name(cls, props: bpy.types.PropertyGroup) -> str:
"""E.g. `bpy.data.objects['IfcAnnotation/TEXT'].BIMTextProperties` -> `BIMTextProperties`"""
return repr(props).rpartition(".")[-1]
@classmethod
def resolve_data_path_to_data_attr(cls, data_path: str) -> tuple[bpy.types.bpy_struct, str]:
"""
@@ -1568,8 +1596,8 @@ class Blender(bonsai.core.tool.Blender):
:return: Resolved tuple of Blender Struct and property name.
Examples:
- `(preferences.prop_group, "string_prop)`
- `(scene, "string_prop)`
- `(preferences.prop_group, "string_prop")`
- `(scene, "string_prop")`
"""
# Get data to modify.
+73 -64
View File
@@ -58,6 +58,7 @@ from fractions import Fraction
from typing import Optional, Union, Any, Literal, TYPE_CHECKING, NamedTuple
from collections.abc import Iterable, Sequence
from pathlib import Path
from ifcopenshell.util.shape_builder import ShapeBuilder
if TYPE_CHECKING:
from bonsai.bim.module.drawing.prop import (
@@ -109,6 +110,27 @@ class Drawing(bonsai.core.tool.Drawing):
}
# fmt: on
DEFAULT_SYMBOLS = [
"rectangle-tag",
"triangle-tag",
"hexagon-tag",
"capsule-tag",
"circle-tag",
"door-tag",
"window-tag",
"space-tag",
"elevation-arrow",
"elevation-tag",
"section-arrow",
"section-tag",
"dot",
"setout-tag",
"setout-point",
"control-point",
"traverse-point",
"spot-elevation",
]
@classmethod
def get_document_props(cls) -> DocProperties:
assert (scene := bpy.context.scene)
@@ -286,9 +308,6 @@ class Drawing(bonsai.core.tool.Drawing):
ifc_file, relating_product=related_entity, related_object=obj_entity
)
if object_type == "TEXT":
tool.Drawing.update_text_value(obj)
@classmethod
def is_annotation_object_type(
cls, element: ifcopenshell.entity_instance, object_types: Union[str, Sequence[str]]
@@ -436,7 +455,7 @@ class Drawing(bonsai.core.tool.Drawing):
@classmethod
def disable_editing_text(cls, obj: bpy.types.Object) -> None:
props = tool.Drawing.get_text_props(obj)
props.is_editing = False
obj.property_unset(tool.Blender.get_props_attribute_name(props))
@classmethod
def disable_editing_assigned_product(cls, obj: bpy.types.Object) -> None:
@@ -505,7 +524,7 @@ class Drawing(bonsai.core.tool.Drawing):
@classmethod
def export_text_literal_attributes(cls, obj: bpy.types.Object) -> list[dict[str, Any]]:
literals = []
literals: list[dict[str, Any]] = []
props = tool.Drawing.get_text_props(obj)
for literal_props in props.literals:
literal_data = bonsai.bim.helper.export_attributes(literal_props.attributes)
@@ -738,32 +757,25 @@ class Drawing(bonsai.core.tool.Drawing):
props = tool.Drawing.get_document_props()
return props.is_editing_sheets
@classmethod
def remove_literal_from_annotation(cls, obj: bpy.types.Object, literal: ifcopenshell.entity_instance) -> None:
element = tool.Ifc.get_entity(obj)
if not element:
return
rep = cls.get_annotation_representation(element)
if not rep:
return
ifc_file = tool.Ifc.get()
rep.Items = [l for l in rep.Items if l != literal]
ifcopenshell.util.element.remove_deep2(ifc_file, literal)
@classmethod
def synchronise_ifc_and_text_attributes(cls, obj: bpy.types.Object) -> None:
literals = cls.get_text_literal(obj, return_list=True)
assert (element := tool.Ifc.get_entity(obj))
assert (rep := cls.get_annotation_representation(element))
old_literals = cls.get_text_literal(obj, return_list=True)
assert isinstance(old_literals, list)
literals_attributes = cls.export_text_literal_attributes(obj)
props = cls.get_text_props(obj)
defined_ifc_ids = [l.ifc_definition_id for l in props.literals]
ifc_file = tool.Ifc.get()
added_literals: list[ifcopenshell.entity_instance] = []
new_literals: list[ifcopenshell.entity_instance] = []
for ifc_definition_id, attributes in zip(defined_ifc_ids, literals_attributes):
# making sure all literals from text edit exist in ifc
if ifc_definition_id == 0:
literal = cls.add_literal_to_annotation(obj, **attributes)
literal = cls.add_literal(**attributes)
added_literals.append(literal)
else:
literal = ifc_file.by_id(ifc_definition_id)
ifcopenshell.api.drawing.edit_text_literal(
@@ -771,38 +783,32 @@ class Drawing(bonsai.core.tool.Drawing):
text_literal=literal,
attributes=attributes,
)
new_literals.append(literal)
# remove from ifc the literals that were removed during the edit
for literal in literals:
if literal.id() not in defined_ifc_ids:
cls.remove_literal_from_annotation(obj, literal)
removed_literals = set(old_literals) - set(new_literals)
# Add new literals and keep the order as defined in text props.
items = [i for i in rep.Items if i not in removed_literals] + added_literals
items.sort(key=lambda x: new_literals.index(x) if x in new_literals else -1)
rep.Items = items
# Remove from ifc the literals that were removed during the edit.
for literal in removed_literals:
ifcopenshell.util.element.remove_deep2(ifc_file, literal)
@classmethod
def add_literal_to_annotation(
cls, obj: bpy.types.Object, Literal: str = "Literal", Path: str = "RIGHT", BoxAlignment: str = "bottom-left"
) -> Union[ifcopenshell.entity_instance, None]:
element = tool.Ifc.get_entity(obj)
if not element:
return
rep = cls.get_annotation_representation(element)
if not rep:
return
def add_literal(cls, **attributes: str) -> ifcopenshell.entity_instance:
ifc_file = tool.Ifc.get()
origin = ifc_file.createIfcAxis2Placement3D(
ifc_file.createIfcCartesianPoint((0.0, 0.0, 0.0)),
ifc_file.createIfcDirection((0.0, 0.0, 1.0)),
ifc_file.createIfcDirection((1.0, 0.0, 0.0)),
builder = ShapeBuilder(ifc_file)
origin = builder.create_axis2_placement_3d()
ifc_literal = ifc_file.create_entity(
"IfcTextLiteralWithExtent",
attributes.get("Literal", "Literal"),
origin,
attributes.get("Path", "RIGHT"),
ifc_file.create_entity("IfcPlanarExtent", 1000, 1000),
attributes.get("BoxAlignment", "bottom-left"),
)
ifc_literal = ifc_file.createIfcTextLiteralWithExtent(
Literal, origin, Path, ifc_file.createIfcPlanarExtent(1000, 1000), BoxAlignment
)
rep.Items = rep.Items + (ifc_literal,)
return ifc_literal
@classmethod
@@ -1073,14 +1079,17 @@ class Drawing(bonsai.core.tool.Drawing):
props = cls.get_text_props(obj)
props.literals.clear()
for ifc_literal in cls.get_text_literal(obj, return_list=True):
ifc_literals = cls.get_text_literal(obj, return_list=True)
assert isinstance(ifc_literals, list)
for ifc_literal in ifc_literals:
literal_props = props.literals.add()
bonsai.bim.helper.import_attributes(ifc_literal, literal_props.attributes)
box_alignment_mask = [False] * 9
position_string = literal_props.attributes["BoxAlignment"].string_value
box_alignment_mask[BOX_ALIGNMENT_POSITIONS.index(position_string)] = True
literal_props.box_alignment = box_alignment_mask
literal_props.box_alignment = box_alignment_mask # pyright: ignore[reportAttributeAccessIssue]
literal_props.ifc_definition_id = ifc_literal.id()
from bonsai.bim.module.drawing.data import DecoratorData
@@ -1088,6 +1097,7 @@ class Drawing(bonsai.core.tool.Drawing):
text_data = DecoratorData.get_text_data(obj)
props.font_size = str(text_data["FontSize"])
props.newline_at = text_data["Newline_At"]
props.set_symbol(text_data["Symbol"])
@classmethod
def import_assigned_product(cls, obj: bpy.types.Object) -> None:
@@ -1186,15 +1196,6 @@ class Drawing(bonsai.core.tool.Drawing):
props = tool.Drawing.get_document_props()
props.should_draw_decorations = True
@classmethod
def update_text_value(cls, obj: bpy.types.Object) -> None:
props = cls.get_text_props(obj)
literals = cls.get_text_literal(obj, return_list=True)
cls.import_text_attributes(obj)
for i, literal in enumerate(literals):
product = cls.get_assigned_product(tool.Ifc.get_entity(obj)) or tool.Ifc.get_entity(obj)
props.literals[i].value = cls.replace_text_literal_variables(literal.Literal, product)
@classmethod
def update_text_size_pset(cls, obj: bpy.types.Object) -> None:
"""updates pset `EPset_Annotation.Classes` value
@@ -1204,19 +1205,22 @@ class Drawing(bonsai.core.tool.Drawing):
props = cls.get_text_props(obj)
element = tool.Ifc.get_entity(obj)
assert element
# updating text font size in EPset_Annotation.Classes
font_size = float(props.font_size)
font_size_str = next((key for key in FONT_SIZES if FONT_SIZES[key] == font_size), None)
classes = ifcopenshell.util.element.get_pset(element, "EPset_Annotation", "Classes")
assert isinstance(classes, Union[str, None])
classes_split = classes.split() if classes else []
different_font_sizes = [c for c in classes_split if c in FONT_SIZES and c != font_size_str]
# we do need to change pset value in ifc
# only if there are different font sizes in classes already
# We do need to change pset value in ifc,
# but only if there are different font sizes in classes already
# or if the current font size is not present in classes
# (except regular font size because it's default)
# (except regular font size because it's default).
if different_font_sizes or (font_size_str not in classes_split and font_size_str != "regular"):
assert font_size_str is not None
classes_split = [c for c in classes_split if c not in FONT_SIZES] + [font_size_str]
classes = " ".join(classes_split)
@@ -1231,10 +1235,12 @@ class Drawing(bonsai.core.tool.Drawing):
)
@classmethod
def update_newline_at(cls, obj: bpy.types.Object) -> None:
def update_newline_at_and_symbol(cls, obj: bpy.types.Object) -> None:
props = cls.get_text_props(obj)
element = tool.Ifc.get_entity(obj)
assert element
newline_at = int(props.newline_at)
symbol = props.get_symbol()
ifc_file = tool.Ifc.get()
pset = tool.Pset.get_element_pset(element, "EPset_Annotation")
if not pset:
@@ -1242,7 +1248,10 @@ class Drawing(bonsai.core.tool.Drawing):
ifcopenshell.api.pset.edit_pset(
ifc_file,
pset=pset,
properties={"Newline_At": newline_at},
properties={
"Newline_At": newline_at,
"Symbol": symbol,
},
)
# TODO below this point is highly experimental prototype code with no tests
+2 -2
View File
@@ -86,8 +86,8 @@ class Ifc(bonsai.core.tool.Ifc):
@classmethod
def get_schema(cls) -> ifcopenshell.util.schema.IFC_SCHEMA:
if IfcStore.get_file():
return IfcStore.get_file().schema
if ifc_file := IfcStore.get_file():
return ifc_file.schema
@classmethod
def clear_history(cls) -> None:
+9 -14
View File
@@ -238,6 +238,8 @@ class Model(bonsai.core.tool.Model):
@classmethod
def export_surface(cls, obj: bpy.types.Object) -> Union[ifcopenshell.entity_instance, None]:
ifc_file = tool.Ifc.get()
builder = ShapeBuilder(ifc_file)
p1, p2, p3 = [v.co.copy() for v in obj.data.vertices[0:3]]
edge1 = p2 - p1
@@ -271,13 +273,8 @@ class Model(bonsai.core.tool.Model):
cls.bm.edges.ensure_lookup_table()
surface = tool.Ifc.get().createIfcCurveBoundedPlane()
surface.BasisSurface = tool.Ifc.get().createIfcPlane(
tool.Ifc.get().createIfcAxis2Placement3D(
tool.Ifc.get().createIfcCartesianPoint([o / cls.unit_scale for o in p1]),
tool.Ifc.get().createIfcDirection([float(o) for o in z_axis]),
tool.Ifc.get().createIfcDirection([float(o) for o in x_axis]),
)
)
placement = builder.create_axis2_placement_3d([o / cls.unit_scale for o in p1], z_axis, x_axis)
surface.BasisSurface = ifc_file.create_entity("IfcPlane", placement)
surface.OuterBoundary = tool.Ifc.get().add(profile_def.OuterCurve)
if profile_def.is_a("IfcArbitraryProfileDefWithVoids"):
@@ -1088,6 +1085,9 @@ class Model(bonsai.core.tool.Model):
# No need to preview to update, Blender will do it in background,
# `preview.icon_id` doesn't change after `asset_generate_preview()`.
obj.asset_generate_preview()
# Avoid issues with sqlite files.
elif type(tool.Ifc.get()) is not ifcopenshell.file:
return
else:
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
size = 128
@@ -2124,13 +2124,8 @@ class Model(bonsai.core.tool.Model):
@classmethod
def add_extrusion_position(cls, extrusion: ifcopenshell.entity_instance, position: Vector) -> None:
ifc_file = tool.Ifc.get()
new_position = ifc_file.createIfcAxis2Placement3D(
ifc_file.createIfcCartesianPoint(position),
ifc_file.createIfcDirection((0.0, 0.0, 1.0)),
ifc_file.createIfcDirection((1.0, 0.0, 0.0)),
)
builder = ShapeBuilder(ifc_file)
new_position = builder.create_axis2_placement_3d(position)
extrusion.Position = new_position
@classmethod
+25 -3
View File
@@ -30,6 +30,7 @@ from typing import Union, Literal, Any, TYPE_CHECKING, assert_never
if TYPE_CHECKING:
from bonsai.bim.prop import Attribute
from bonsai.bim.module.pset.prop import (
PsetProperties,
GlobalPsetProperties,
@@ -171,8 +172,16 @@ class Pset(bonsai.core.tool.Pset):
return special_type
@classmethod
def import_pset_from_existing(cls, pset: ifcopenshell.entity_instance, props: PsetProperties) -> None:
pset_props = []
def import_pset_from_existing(
cls,
pset: ifcopenshell.entity_instance,
props: PsetProperties,
pset_template: Union[ifcopenshell.entity_instance, None],
) -> None:
"""
:param pset_template: Pset Template to use as a source for descriptions.
"""
pset_props: tuple[ifcopenshell.entity_instance, ...] = ()
if pset.is_a("IfcElementQuantity"):
pset_props = pset.Quantities
elif pset.is_a("IfcPropertySet"):
@@ -180,6 +189,16 @@ class Pset(bonsai.core.tool.Pset):
elif pset.is_a("IfcMaterialProperties") or pset.is_a("IfcProfileProperties"):
pset_props = pset.Properties
prop_templates: dict[str, ifcopenshell.entity_instance] = {}
if pset_template:
prop_templates = {prop.Name: prop for prop in pset_template.HasPropertyTemplates}
def process_prop_description(metadata: Attribute) -> None:
prop_name = metadata.name
if prop_name not in prop_templates:
return
bonsai.bim.helper.add_attribute_description(metadata, prop_templates[prop_name])
for prop in sorted(pset_props, key=lambda p: p.Name):
if props.properties.get(prop.Name):
continue # This property has already been added from a template
@@ -191,6 +210,7 @@ class Pset(bonsai.core.tool.Pset):
metadata.name = prop.Name
metadata.is_null = len(simple_prop.enumerated_value.enumerated_values) == 0
metadata.is_optional = True
process_prop_description(metadata)
enum_reference = prop.EnumerationReference
selected_enum_items = [v.wrappedValue for v in (prop.EnumerationValues or ())]
@@ -230,6 +250,7 @@ class Pset(bonsai.core.tool.Pset):
metadata.is_optional = True
metadata.special_type = cls.get_special_type_for_prop(prop)
metadata.set_value(metadata.get_value_default() if metadata.is_null else value)
process_prop_description(metadata)
@classmethod
def get_prop_template_primitive_type(cls, prop_template: ifcopenshell.entity_instance) -> str:
@@ -277,6 +298,7 @@ class Pset(bonsai.core.tool.Pset):
metadata.is_null = data.get(prop_template.Name, None) is None
metadata.is_optional = True
metadata.special_type = "URI" if prop_template.PrimaryMeasureType == "IfcURIReference" else ""
bonsai.bim.helper.add_attribute_description(metadata, prop_template)
# Cute hack to abuse the metadata to find the Blender data_type
metadata.set_value(enum_items[0])
@@ -333,7 +355,7 @@ class Pset(bonsai.core.tool.Pset):
# For every prop we first ensure that existing prop value type matches the template value type
# to prevent data loss and error casting data.
# Property will be added later by import_pset_from_existing.
# Existing property will be added later by import_pset_from_existing.
for prop_template in sorted(pset_template.HasPropertyTemplates, key=lambda p: p.Name):
if (
not prop_template.is_a("IfcSimplePropertyTemplate")
+17 -3
View File
@@ -16,6 +16,7 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
import bpy
import ifcopenshell
import ifcopenshell.util.unit
@@ -26,13 +27,26 @@ import math
import mathutils
from mathutils import Matrix, Vector
from lark import Lark, Transformer
from typing import Union, Any
from typing import Union, Any, TYPE_CHECKING
if TYPE_CHECKING:
from bonsai.bim.prop import BIMSnapGroups, BIMSnapProperties
class Snap(bonsai.core.tool.Snap):
tool_state = None
snap_plane_method = None
@classmethod
def get_snap_props(cls) -> BIMSnapProperties:
assert (scene := bpy.context.scene)
return scene.BIMSnapProperties # pyright: ignore[reportAttributeAccessIssue]
@classmethod
def get_snap_groups(cls) -> BIMSnapGroups:
assert (scene := bpy.context.scene)
return scene.BIMSnapGroups # pyright: ignore[reportAttributeAccessIssue]
@classmethod
def set_snap_plane_method(cls, value=True):
cls.snap_plane_method = value
@@ -484,7 +498,7 @@ class Snap(bonsai.core.tool.Snap):
def select_snapping_points(cls, context, event, tool_state, detected_snaps):
def filter_snapping_points_by_type(snapping_points):
options = ["Plane", "Axis"]
props = context.scene.BIMSnapProperties
props = tool.Snap.get_snap_props()
for prop in props.__annotations__.keys():
if getattr(props, prop):
options.append(props.rna_type.properties[prop].name)
@@ -494,7 +508,7 @@ class Snap(bonsai.core.tool.Snap):
def filter_snapping_points_by_group(detected_snaps):
options = ["Wireframe", "Axis", "Plane"]
props = context.scene.BIMSnapGroups
props = tool.Snap.get_snap_groups()
for prop in props.__annotations__.keys():
if getattr(props, prop):
options.append(props.rna_type.properties[prop].name)
+1 -3
View File
@@ -37,8 +37,7 @@ class TestEditText:
def test_run(self, drawing):
drawing.synchronise_ifc_and_text_attributes("obj").should_be_called()
drawing.update_text_size_pset("obj").should_be_called()
drawing.update_newline_at("obj").should_be_called()
drawing.update_text_value("obj").should_be_called()
drawing.update_newline_at_and_symbol("obj").should_be_called()
drawing.disable_editing_text("obj").should_be_called()
subject.edit_text(drawing, obj="obj")
@@ -65,7 +64,6 @@ class TestEditAssignedProduct:
).should_be_called()
ifc.run("drawing.assign_product", relating_product="product", related_object="element").should_be_called()
drawing.is_annotation_object_type("element", ("TEXT", "TEXT_LEADER")).should_be_called().will_return(True)
drawing.update_text_value("obj").should_be_called()
drawing.disable_editing_assigned_product("obj").should_be_called()
subject.edit_assigned_product(ifc, drawing, obj="obj", product="product")
+90 -106
View File
@@ -622,16 +622,105 @@ class TestImportTextAttributes(NewFile):
item = ifc.createIfcTextLiteralWithExtent(Literal="Literal", Path="RIGHT", BoxAlignment="bottom-left")
representation = ifc.createIfcShapeRepresentation(ContextOfItems=context, Items=[item])
element.Representation.Representations = [representation]
element.ObjectType = "TEXT" # TODO: double check if it's valid to set this
element.ObjectType = "TEXT"
tool.Ifc.link(element, obj)
subject.import_text_attributes(obj)
props = tool.Drawing.get_text_props(obj)
assert props.font_size == "2.5"
literal_props = props.literals[0]
assert literal_props.ifc_definition_id == item.id()
assert literal_props.box_alignment[:] == tuple([False] * 6 + [True] + [False] * 2)
assert literal_props.attributes["Literal"].string_value == "Literal"
assert literal_props.attributes["Path"].enum_value == "RIGHT"
assert literal_props.attributes["BoxAlignment"].string_value == "bottom-left"
class TestReplaceTextLiteralVariables(NewFile):
def test_using_attribute_variables(self):
ifc = ifcopenshell.file()
wall = ifc.create_entity("IfcWall", Name="Baz")
text = "Foo {{Name}} Bar"
updated_text = subject.replace_text_literal_variables(text, wall)
assert updated_text == "Foo Baz Bar"
def test_using_property_variables(self):
ifc = ifcopenshell.file()
wall = ifc.createIfcWall()
pset = ifcopenshell.api.pset.add_pset(ifc, name="Custom_Pset", product=wall)
ifcopenshell.api.pset.edit_pset(ifc, pset=pset, properties={"Key": "Baz"})
text = "Foo {{Custom_Pset.Key}} Bar"
updated_text = subject.replace_text_literal_variables(text, wall)
assert updated_text == "Foo Baz Bar"
class TestEditText(NewFile):
def test_change_text_font_size(self):
TestGetTextLiteral().test_run()
obj = bpy.data.objects["Object"]
assert DecoratorData.get_text_data(obj)["FontSize"] == 2.5
with bpy.context.temp_override(active_object=obj):
bpy.ops.bim.enable_editing_text()
props = tool.Drawing.get_text_props(obj)
props.font_size = "7.0"
bpy.ops.bim.edit_text()
annotation_classes = ifcopenshell.util.element.get_pset(tool.Ifc.get_entity(obj), "EPset_Annotation", "Classes")
assert "title" in annotation_classes
assert DecoratorData.get_text_data(obj)["FontSize"] == 7.0
def test_add_second_literal(self, setup=True):
if setup:
TestGetTextLiteral().test_run()
obj = bpy.data.objects["Object"]
with bpy.context.temp_override(active_object=obj):
bpy.ops.bim.enable_editing_text()
bpy.ops.bim.add_text_literal()
props = tool.Drawing.get_text_props(obj)
literal = props.literals[1]
literal.attributes["Literal"].string_value = "test_value"
bpy.ops.bim.edit_text()
ifc = tool.Ifc.get()
assert ifc.by_type("IfcTextLiteralWithExtent")[1].Literal == "test_value"
class TestDisableTextEditing(NewFile):
def test_disable_text_editing(self):
# add second literal and change font size to test disable editing keeps those changes.
TestEditText.test_change_text_font_size(self) # Set font size to "7.0".
TestEditText.test_add_second_literal(self, setup=False)
obj = bpy.data.objects["Object"]
props = tool.Drawing.get_text_props(obj)
assert obj is not None, obj
with bpy.context.temp_override(active_object=obj):
bpy.ops.bim.enable_editing_text()
bpy.ops.bim.remove_text_literal(literal_prop_id=1)
props.literals[0].attributes["Literal"].string_value = "changed_value"
props.font_size = "2.5"
bpy.ops.bim.disable_editing_text()
ifc = tool.Ifc.get()
# Test font size.
annotation_classes = ifcopenshell.util.element.get_pset(tool.Ifc.get_entity(obj), "EPset_Annotation", "Classes")
assert "title" in annotation_classes
text_data = DecoratorData.get_text_data(obj)
assert text_data["FontSize"] == 7.0
# Test second literal is present.
assert text_data["Literals"][1]["Literal"] == "test_value"
assert ifc.by_type("IfcTextLiteralWithExtent")[1].Literal == "test_value"
# Test first literal value is unchanged.
assert text_data["Literals"][0]["Literal"] == "Literal"
assert ifc.by_type("IfcTextLiteralWithExtent")[0].Literal == "Literal"
class TestImportAssignedProduct(NewFile):
def test_run(self):
ifc = ifcopenshell.file()
@@ -775,111 +864,6 @@ class TestDrawingMaintainingSheetPosition(NewFile):
assert drawing_data["view-title"] == (30.0, 535.0, 50.22, 10.0)
class TestUpdateTextValue(NewFile):
def test_updating_arbitrary_strings(self):
TestGetTextLiteral().test_run()
ifc = tool.Ifc.get()
obj = bpy.data.objects["Object"]
subject.update_text_value(obj)
props = tool.Drawing.get_text_props(obj)
literal = props.literals[0]
assert props.font_size == "2.5"
assert literal.value == "Literal"
assert literal.box_alignment[:] == tuple([False] * 6 + [True] + [False] * 2)
assert literal.ifc_definition_id == ifc.by_type("IfcTextLiteralWithExtent")[0].id()
def test_using_attribute_variables(self):
TestGetTextLiteral().test_run()
obj = bpy.data.objects["Object"]
ifc = tool.Ifc.get()
wall = ifc.createIfcWall(Name="Baz")
label = ifc.by_type("IfcAnnotation")[0]
ifcopenshell.api.drawing.assign_product(ifc, relating_product=wall, related_object=label)
ifc.by_type("IfcTextLiteralWithExtent")[0].Literal = "Foo {{Name}} Bar"
subject.update_text_value(obj)
props = tool.Drawing.get_text_props(obj)
assert props.literals[0].value == "Foo Baz Bar"
def test_using_property_variables(self):
TestGetTextLiteral().test_run()
obj = bpy.data.objects["Object"]
ifc = tool.Ifc.get()
wall = ifc.createIfcWall()
pset = ifcopenshell.api.pset.add_pset(ifc, name="Custom_Pset", product=wall)
ifcopenshell.api.pset.edit_pset(ifc, pset=pset, properties={"Key": "Baz"})
label = ifc.by_type("IfcAnnotation")[0]
ifcopenshell.api.drawing.assign_product(ifc, relating_product=wall, related_object=label)
ifc.by_type("IfcTextLiteralWithExtent")[0].Literal = "Foo {{Custom_Pset.Key}} Bar"
subject.update_text_value(obj)
props = tool.Drawing.get_text_props(obj)
assert props.literals[0].value == "Foo Baz Bar"
def test_update_text_font_size(self):
TestGetTextLiteral().test_run()
obj = bpy.data.objects["Object"]
with bpy.context.temp_override(active_object=obj):
bpy.ops.bim.enable_editing_text()
props = tool.Drawing.get_text_props(obj)
props.font_size = "7.0"
bpy.ops.bim.edit_text()
annotation_classes = ifcopenshell.util.element.get_pset(tool.Ifc.get_entity(obj), "EPset_Annotation", "Classes")
assert "title" in annotation_classes
assert DecoratorData.get_text_data(obj)["FontSize"] == 7.0
def test_add_second_literal(self, setup=True):
if setup:
TestGetTextLiteral().test_run()
obj = bpy.data.objects["Object"]
with bpy.context.temp_override(active_object=obj):
bpy.ops.bim.enable_editing_text()
bpy.ops.bim.add_text_literal()
props = tool.Drawing.get_text_props(obj)
literal = props.literals[1]
literal.attributes["Literal"].string_value = "test_value"
bpy.ops.bim.edit_text()
ifc = tool.Ifc.get()
assert ifc.by_type("IfcTextLiteralWithExtent")[1].Literal == "test_value"
def test_disable_text_editing(self):
# add second literal and change font size to test changing them
self.test_update_text_font_size() # sets font size to "7.0"
self.test_add_second_literal(setup=False)
obj = bpy.data.objects["Object"]
props = tool.Drawing.get_text_props(obj)
assert obj is not None, obj
with bpy.context.temp_override(active_object=obj):
bpy.ops.bim.enable_editing_text()
bpy.ops.bim.remove_text_literal(literal_prop_id=1)
props.literals[0].attributes["Literal"].string_value = "changed_value"
props.font_size = "2.5"
bpy.ops.bim.disable_editing_text()
ifc = tool.Ifc.get()
# test font size
annotation_classes = ifcopenshell.util.element.get_pset(tool.Ifc.get_entity(obj), "EPset_Annotation", "Classes")
assert props.font_size == "7.0"
assert "title" in annotation_classes
assert DecoratorData.get_text_data(obj)["FontSize"] == 7.0
# test second literal is present
assert props.literals[1].attributes["Literal"].string_value == "test_value"
assert ifc.by_type("IfcTextLiteralWithExtent")[1].Literal == "test_value"
# test first literal value is unchanged
assert props.literals[0].attributes["Literal"].string_value == "Literal"
assert ifc.by_type("IfcTextLiteralWithExtent")[0].Literal == "Literal"
class TestDrawingStyles(NewFile):
def setup_project_with_drawing(self):
bpy.ops.bim.create_project()
+51
View File
@@ -16,6 +16,7 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import contextlib
import bpy
import ifcopenshell
import ifcopenshell.api.context
@@ -25,6 +26,8 @@ import ifcopenshell.api.unit
import bonsai.core.tool
import bonsai.tool as tool
import tempfile
import ifcpatch
from ifcpatch.recipes import Ifc2Sql
from test.bim.bootstrap import NewFile
from bonsai.tool.project import Project as subject
from pathlib import Path
@@ -348,3 +351,51 @@ class TestSaveLinkedModelsToIfc(NewFile):
assert documents[0].id() == document_id
assert documents[0].Name == "BBIM_Linked_Models"
assert len(ifc.by_type("IfcDocumentReference")) == 0
class TestLoadingIfcSqlite(NewFile):
def test_run(self):
filepath = Path("test/files/basic.ifc")
ifc_file: ifcopenshell.file
ifc_file = ifcopenshell.open(filepath)
patcher = Ifc2Sql.Patcher(
ifc_file,
sql_type="SQLite",
)
patcher.patch()
tmp_file = Path(tempfile.mktemp(suffix=".ifcsqlite"))
ifcpatch.write(patcher.get_output(), tmp_file)
elements_with_meshes = [
# Types.
"IfcSlabType/Slab",
"IfcWallType/Wall",
# Occurrences.
"IfcBeam/Beam",
"IfcWall/Wall",
]
elements_without_meshes = (
"IfcProject/My Project",
"IfcSite/My Site",
"IfcBuilding/My Building",
"IfcBuildingStorey/Ground Floor",
)
def clean_up() -> None:
if isinstance(ifc_file := tool.Ifc.get(), ifcopenshell.sqlite):
ifc_file.db.close()
tmp_file.unlink(missing_ok=True)
with contextlib.ExitStack() as stack:
stack.callback(clean_up)
bpy.ops.bim.load_project(filepath=tmp_file.as_posix())
assert isinstance(tool.Ifc.get(), ifcopenshell.sqlite)
for element_name in elements_with_meshes:
assert element_name in bpy.data.objects
assert bpy.data.objects[element_name].data
for element_name in elements_without_meshes:
assert element_name in bpy.data.objects
assert not bpy.data.objects[element_name].data
+3 -1
View File
@@ -11,11 +11,13 @@ ifeq ($(OS),Windows_NT)
PYTHON:=python
PIP:=pip
VENV_BIN:=Scripts
endif
else
UNAME_S:=$(shell uname -s)
ifeq ($(UNAME_S),Darwin)
SED:=sed -i '' -e
PYTHON:=python3
endif
endif
VENV_ACTIVATE:=$(VENV_BIN)/activate
+2 -1
View File
@@ -261,6 +261,7 @@ class ScheduleIfcGenerator:
wbs: Union[WBSEntry, None],
work_schedule: Union[ifcopenshell.entity_instance, None],
) -> None:
assert self.file
activity["ifc"] = ifcopenshell.api.sequence.add_task(
self.file,
work_schedule=None if wbs else work_schedule,
@@ -283,7 +284,7 @@ class ScheduleIfcGenerator:
ifcopenshell.api.control.assign_control(
self.file,
relating_control=calendar["ifc"],
related_object=activity["ifc"],
related_objects=[activity["ifc"]],
)
ifcopenshell.api.sequence.edit_task_time(
self.file,
+1 -1
View File
@@ -263,7 +263,7 @@ class MSP2Ifc:
ifcopenshell.api.control.assign_control(
self.file,
relating_control=calendar,
related_object=task["ifc"],
related_objects=[task["ifc"]],
)
ifcopenshell.api.sequence.edit_task(
+64
View File
@@ -20,11 +20,13 @@ from __future__ import annotations
import csv
import ifcopenshell
import ifcopenshell.api
import ifcopenshell.api.control
import ifcopenshell.api.cost
import ifcopenshell.api.root
import ifcopenshell.util.unit
import ifcopenshell.util.selector
import ifcopenshell.util.element
import ifcopenshell.util.cost
import locale
from pathlib import Path
from typing import Union, Optional, TypedDict
@@ -47,6 +49,10 @@ class CsvHeader(TypedDict):
Property: NotRequired[int]
Query: NotRequired[int]
# Assigning rates
RateSchedule: NotRequired[str]
RateID : NotRequired[str]
# Currently we assume that if column is not part of the main header,
# then it is a cost value category. So here we list any additional column
@@ -98,6 +104,7 @@ class Csv2Ifc:
units: dict[str, ifcopenshell.entity_instance]
categories: dict[str, int]
has_categories: bool
has_rates: bool
def __init__(
self,
@@ -152,7 +159,10 @@ class Csv2Ifc:
# parse header
if not self.headers:
self.has_categories = True
self.has_rates = False
self.headers = {col: i for i, col in enumerate(row) if col}
if "RateSchedule" in self.headers and "RateID" in self.headers:
self.has_rates = True
if "Value" in self.headers:
self.has_categories = False
else:
@@ -219,6 +229,15 @@ class Csv2Ifc:
assert "Value" in self.headers
cost_values = row[self.headers["Value"]]
cost_values = float(cost_values) if cost_values else None
if self.has_rates:
cost_rate = {
"Schedule": row[(self.headers["RateSchedule"])] if "RateSchedule" in self.headers else None,
"RateID": row[(self.headers["RateID"])] if "RateID" in self.headers else None,
}
else:
cost_rate = None
return {
"Identification": str(identification) if identification else None,
"Name": str(name) if name else None,
@@ -228,6 +247,7 @@ class Csv2Ifc:
"Property": property_name,
"Query": query,
"children": [],
"CostRate": cost_rate,
}
def create_ifc(self) -> None:
@@ -297,6 +317,50 @@ class Csv2Ifc:
cost_value.UnitBasis = self.file.createIfcMeasureWithUnit(value_component, unit_component)
if self.has_rates:
cost_rate = cost_item["CostRate"]
assert isinstance(cost_rate, dict)
if cost_rate.get("Schedule") and cost_rate.get("RateID"):
# if cost_rate["Schedule"] is not "":
schedules = self.file.by_type("IfcCostSchedule")
for schedule in schedules:
if schedule.Name == cost_rate["Schedule"]:
rate_cost_schedule = schedule
break
if rate_cost_schedule:
items = list(ifcopenshell.util.cost.get_schedule_cost_items(rate_cost_schedule))
rate_cost_item = None
for item in items:
if item.Identification == cost_rate["RateID"]:
rate_cost_item = item
break
if rate_cost_item:
# next 9 lines are the same as core.cost.assign_cost_value
ifcopenshell.api.cost.assign_cost_value(
self.file, cost_item=cost_item["ifc"], cost_rate=rate_cost_item
)
existing_cost_rate = None
for assignment in cost_item["ifc"].HasAssignments:
if assignment.RelatingControl.is_a() == "IfcCostItem":
existing_cost_rate = assignment.RelatingControl
if existing_cost_rate is None:
ifcopenshell.api.control.assign_control(
self.file, relating_control=rate_cost_item, related_objects=[cost_item["ifc"]]
)
else:
ifcopenshell.api.control.unassign_control(
self.file, relating_control=existing_cost_rate, related_objects=[cost_item["ifc"]]
)
ifcopenshell.api.control.assign_control(
self.file, relating_control=rate_cost_item, related_objects=[cost_item["ifc"]]
)
else:
print(f"No cost item found with RateID={cost_rate['RateID']}")
else:
print(f"No cost schedule found with Name={cost_rate['Schedule']}")
quantity = None
quantity_class = ifcopenshell.util.unit.get_symbol_quantity_class(cost_item["Unit"])
prop_name = cost_item["Property"]
@@ -234,7 +234,7 @@ def step_impl(context, guid, number):
site = util.assert_guid(IfcStore.file, guid)
util.assert_type(site, "IfcSite")
ref = util.assert_attribute(site, "RefLongitude")
number = ifcopenshell.util.geolocation.dd2dms(number, use_ms=(len(ref) == 4))
number = ifcopenshell.util.geolocation.dd2dms(number, use_us=(len(ref) == 4))
util.assert_attribute(site, "RefLongitude", number)
@@ -244,7 +244,7 @@ def step_impl(context, guid, number):
site = util.assert_guid(IfcStore.file, guid)
util.assert_type(site, "IfcSite")
ref = util.assert_attribute(site, "RefLatitude")
number = ifcopenshell.util.geolocation.dd2dms(number, use_ms=(len(ref) == 4))
number = ifcopenshell.util.geolocation.dd2dms(number, use_us=(len(ref) == 4))
util.assert_attribute(site, "RefLatitude", number)
@@ -53,6 +53,22 @@ pre_listeners: dict[str, dict] = {}
post_listeners: dict[str, dict] = {}
def batching_argument_deprecation(
usecase_path: str, settings: dict, prev_argument: str, new_argument: str, replace_usecase: Optional[str] = None
) -> tuple[str, dict]:
if replace_usecase is not None:
print(f"WARNING. `{usecase_path}` api method is deprecated and should be replaced with `{replace_usecase}`.")
if prev_argument in settings:
print(
f"WARNING. `{prev_argument}` argument is deprecated for API method "
f'"{usecase_path}" and should be replaced with `{new_argument}`.'
)
settings = settings | {new_argument: [settings[prev_argument]]}
settings.pop(prev_argument)
return (replace_usecase or usecase_path, settings)
def renamed_arguments_deprecation(
usecase_path: str, settings: dict, arguments_remapped: dict[str, str]
) -> tuple[str, dict]:
@@ -71,7 +87,11 @@ def renamed_arguments_deprecation(
# "group.add_group": partial(
# renamed_arguments_deprecation, arguments_remapped={"Name": "name", "Description": "description"}
# ),
ARGUMENTS_DEPRECATION: dict[str, Callable[[str, dict[str, Any]], tuple[str, dict[str, Any]]]] = {}
ARGUMENTS_DEPRECATION: dict[str, Callable[[str, dict[str, Any]], tuple[str, dict[str, Any]]]] = {
"control.assign_control": partial(
batching_argument_deprecation, prev_argument="related_object", new_argument="related_objects"
),
}
CACHED_USECASE_CLASSES: dict[str, Callable] = {}
@@ -91,43 +91,42 @@ def _add_segment_to_layout(file: ifcopenshell.file, layout: entity_instance, seg
segment_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(layout)
zero_length_segment = segment_nest.RelatedObjects[-1]
# DesignParameters.StartPoint for IfcAlignmentHorizontalSegment is automatically updated when the
# geometric representation is updated because the semantic and geometric data use the same IfcPoint.
# This is not the case of IfcAlignmentVerticalSegment and IfcAlignmentCantSegment. For these
# segment types, the design parameters of the zero length segment must be updated explicitly.
if zero_length_segment.DesignParameters.is_a(
"IfcAlignmentVerticalSegment"
) or zero_length_segment.DesignParameters.is_a("IfcAlignmentCantSegment"):
# get the geometric representation for the new segment
mapped_segments = ifcopenshell.api.alignment.get_mapped_segments(segment)
mapped_segment = mapped_segments[0] if mapped_segments[1] == None else mapped_segments[1]
mapped_segments = ifcopenshell.api.alignment.get_mapped_segments(segment)
mapped_segment = mapped_segments[0] if mapped_segments[1] == None else mapped_segments[1]
# compute the end point matrix
settings = ifcopenshell.geom.settings()
segment_fn = ifcopenshell_wrapper.map_shape(settings, mapped_segment.wrapped_data)
segment_evaluator = ifcopenshell_wrapper.function_item_evaluator(settings, segment_fn)
e = segment_evaluator.evaluate(segment_fn.end())
end = np.array(e)
# compute the end point matrix
settings = ifcopenshell.geom.settings()
segment_fn = ifcopenshell_wrapper.map_shape(settings, mapped_segment.wrapped_data)
segment_evaluator = ifcopenshell_wrapper.function_item_evaluator(settings, segment_fn)
e = segment_evaluator.evaluate(segment_fn.end())
end = np.array(e)
# update the zero length segment semantic representation parameters
if zero_length_segment.DesignParameters.is_a("IfcAlignmentVerticalSegment"):
y = float(end[1, 3]) / unit_scale
zero_length_segment.DesignParameters.StartHeight = y
dx = float(end[0, 0])
dy = float(end[1, 0])
zero_length_segment.DesignParameters.StartGradient = dy / dx
zero_length_segment.DesignParameters.EndGradient = zero_length_segment.DesignParameters.StartGradient
else:
z = float(end[2, 3]) / unit_scale
dx = float(end[0, 1])
dy = float(end[1, 1])
dz = float(end[2, 1])
ds = math.sqrt(dx * dx + dy * dy)
slope = dz / ds
railhead = layout.RailHeadDistance
# update the zero length segment semantic representation parameters
if zero_length_segment.DesignParameters.is_a("IfcAlignmentHorizontalSegment"):
x = float(end[0, 3]) / unit_scale
y = float(end[1, 3]) / unit_scale
dx = float(end[0, 0])
dy = float(end[1, 0])
zero_length_segment.DesignParameters.StartPoint.Coordinates = (x,y)
zero_length_segment.DesignParameters.StartDirection = dy / dx
elif zero_length_segment.DesignParameters.is_a("IfcAlignmentVerticalSegment"):
y = float(end[1, 3]) / unit_scale
zero_length_segment.DesignParameters.StartHeight = y
dx = float(end[0, 0])
dy = float(end[1, 0])
zero_length_segment.DesignParameters.StartGradient = dy / dx
zero_length_segment.DesignParameters.EndGradient = zero_length_segment.DesignParameters.StartGradient
else:
z = float(end[2, 3]) / unit_scale
dx = float(end[0, 1])
dy = float(end[1, 1])
dz = float(end[2, 1])
ds = math.sqrt(dx * dx + dy * dy)
slope = dz / ds
railhead = layout.RailHeadDistance
zero_length_segment.DesignParameters.StartCantLeft = z + slope * railhead / 2.0
zero_length_segment.DesignParameters.StartCantRight = z - slope * railhead / 2.0
zero_length_segment.DesignParameters.StartCantLeft = z + slope * railhead / 2.0
zero_length_segment.DesignParameters.StartCantRight = z - slope * railhead / 2.0
# updated the referent's name because the referent is now at a new station
start_dist_along = 0.0
@@ -57,4 +57,4 @@ def get_mapped_segments(layout_segment: entity_instance) -> Sequence[entity_inst
if segment_count == 1:
return (curve.Segments[index - segment_count], None)
else:
return (curve.Segments[index - segment_count], curve.Segment[index])
return (curve.Segments[index - segment_count], curve.Segments[index])
@@ -40,6 +40,8 @@ def layout_horizontal_alignment_by_pi_method(
if not (len(hpoints) - 2 == len(radii)):
raise ValueError("radii should have two fewer elements that hpoints")
angle_unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file, "PLANEANGLEUNIT")
xBT, yBT = hpoints[0]
xPI, yPI = hpoints[1]
@@ -84,7 +86,7 @@ def layout_horizontal_alignment_by_pi_method(
StartTag=None,
EndTag=None,
StartPoint=pt,
StartDirection=angleBT,
StartDirection=angleBT / angle_unit_scale,
StartRadiusOfCurvature=0.0,
EndRadiusOfCurvature=0.0,
SegmentLength=tangent_run,
@@ -102,7 +104,7 @@ def layout_horizontal_alignment_by_pi_method(
StartTag=None,
EndTag=None,
StartPoint=pc,
StartDirection=angleBT,
StartDirection=angleBT / angle_unit_scale,
StartRadiusOfCurvature=float(radius),
EndRadiusOfCurvature=float(radius),
SegmentLength=lc,
@@ -130,7 +132,7 @@ def layout_horizontal_alignment_by_pi_method(
StartTag=None,
EndTag=None,
StartPoint=pt,
StartDirection=angleBT,
StartDirection=angleBT / angle_unit_scale,
StartRadiusOfCurvature=0.0,
EndRadiusOfCurvature=0.0,
SegmentLength=tangent_run,
@@ -25,9 +25,9 @@ from typing import Union
def assign_control(
file: ifcopenshell.file,
relating_control: ifcopenshell.entity_instance,
related_object: ifcopenshell.entity_instance,
related_objects: list[ifcopenshell.entity_instance],
) -> Union[ifcopenshell.entity_instance, None]:
"""Assigns a planning control or constraint to an object
"""Assigns a planning control or constraint to a list of objects.
IFC can describe concepts that control other objects. For example, a
planning calendar controls the availability of working days for
@@ -42,7 +42,7 @@ def assign_control(
:param relating_control: The IfcControl entity that is creating the
control or constraint
:param related_object: The IfcObjectDefinition that is being controlled
:param related_objects: The list of IfcObjectDefinition that is being controlled
:return: The newly created IfcRelAssignsToControl. If relationship already
existed before and wasn't changed then returns None.
@@ -59,7 +59,7 @@ def assign_control(
# All subtasks will inherit this calendar, so assigning a single
# calendar to the root task effectively defines a "default" calendar
ifcopenshell.api.control.assign_control(model,
relating_control=calendar, related_object=task)
relating_control=calendar, related_objects=[task])
# Another common example might be relating a cost item and a product
wall = ifcopenshell.api.root.create_entity(model, ifc_class="IfcWall")
@@ -67,22 +67,33 @@ def assign_control(
cost_item = ifcopenshell.api.cost.add_cost_item(model,
cost_schedule=schedule)
ifcopenshell.api.control.assign_control(model,
relating_control=cost_item, related_object=wall)
relating_control=cost_item, related_objects=[wall])
"""
if related_object.HasAssignments:
for assignment in related_object.HasAssignments:
if assignment.is_a("IfcRelAssignsToControl") and assignment.RelatingControl == relating_control:
return
# Filter out already assigned objects.
related_objects_set = set(related_objects)
objects_to_assign: set[ifcopenshell.entity_instance] = set()
control_assignments = set(relating_control.Controls)
if control_assignments:
for obj in related_objects_set:
existing_assignment = next((a for a in obj.HasAssignments if a in control_assignments), None)
# Skip objects already assigned to this control.
if existing_assignment:
continue
objects_to_assign.add(obj)
else:
objects_to_assign = related_objects_set
if not objects_to_assign:
return None
controls: Union[ifcopenshell.entity_instance, None]
controls = next(iter(relating_control.Controls), None)
if controls:
if related_object in controls.RelatedObjects:
return
related_objects = set(controls.RelatedObjects)
related_objects.add(related_object)
controls.RelatedObjects = list(related_objects)
related_objects_new: list[ifcopenshell.entity_instance] = list(controls.RelatedObjects)
related_objects_new.extend(objects_to_assign)
controls.RelatedObjects = list(related_objects_new)
ifcopenshell.api.owner.update_owner_history(file, element=controls)
else:
controls = file.create_entity(
@@ -90,7 +101,7 @@ def assign_control(
**{
"GlobalId": ifcopenshell.guid.new(),
"OwnerHistory": ifcopenshell.api.owner.create_owner_history(file),
"RelatedObjects": [related_object],
"RelatedObjects": list(objects_to_assign),
"RelatingControl": relating_control,
},
)
@@ -19,21 +19,19 @@
import ifcopenshell
import ifcopenshell.api.owner
import ifcopenshell.util.element
from typing import Union
def unassign_control(
file: ifcopenshell.file,
relating_control: ifcopenshell.entity_instance,
related_object: ifcopenshell.entity_instance,
) -> Union[ifcopenshell.entity_instance, None]:
related_objects: list[ifcopenshell.entity_instance],
) -> None:
"""Unassigns a planning control or constraint to an object
:param relating_control: The IfcControl entity that is creating the
control or constraint
:param related_object: The IfcObjectDefinition that is being controlled
:return: If the control still is related to other objects, the
IfcRelAssignsToControl is returned, otherwise None.
:param related_objects: The list IfcObjectDefinitions that is being controlled
:return: None
Example:
@@ -45,23 +43,23 @@ def unassign_control(
cost_item = ifcopenshell.api.cost.add_cost_item(model,
cost_schedule=schedule)
ifcopenshell.api.control.assign_control(model,
relating_control=cost_item, related_object=wall)
relating_control=cost_item, related_objects=[wall])
# And now let's change our mind
ifcopenshell.api.control.unassign_control(model,
relating_control=cost_item, related_object=wall)
relating_control=cost_item, related_objects=[wall])
"""
for rel in related_object.HasAssignments or []:
if not rel.is_a("IfcRelAssignsToControl") or rel.RelatingControl != relating_control:
continue
if len(rel.RelatedObjects) == 1:
related_objects_set = set(related_objects)
control_assignments = set(relating_control.Controls)
rels = set(rel for obj in related_objects_set for rel in obj.HasAssignments if rel in control_assignments)
for rel in rels:
related_objects_new = set(rel.RelatedObjects) - related_objects_set
if related_objects_new:
rel.RelatedObjects = list(related_objects_new)
ifcopenshell.api.owner.update_owner_history(file, element=rel)
else:
history = rel.OwnerHistory
file.remove(rel)
if history:
ifcopenshell.util.element.remove_deep2(file, history)
return
related_objects = list(rel.RelatedObjects)
related_objects.remove(related_object)
rel.RelatedObjects = related_objects
ifcopenshell.api.owner.update_owner_history(file, element=rel)
return rel
@@ -59,7 +59,7 @@ def add_cost_item(
cost_item_ = ifcopenshell.api.root.create_entity(file, ifc_class="IfcCostItem")
if cost_schedule:
ifcopenshell.api.control.assign_control(file, cost_schedule, cost_item_)
ifcopenshell.api.control.assign_control(file, cost_schedule, [cost_item_])
elif cost_item:
ifcopenshell.api.nest.assign_object(file, related_objects=[cost_item_], relating_object=cost_item)
return cost_item_
@@ -66,7 +66,7 @@ def add_cost_item_quantity(
schedule = ifcopenshell.api.cost.add_cost_schedule(model)
item = ifcopenshell.api.cost.add_cost_item(model, cost_schedule=schedule)
ifcopenshell.api.control.assign_control(model,
relating_control=item, related_object=chair)
relating_control=item, related_objects=[chair])
# Let's assume we want to count the amount of chairs to calculate our cost item
# Because this is an IfcQuantityCount the count will be automatically set to "1" chair
@@ -122,7 +122,7 @@ class Usecase:
) -> ifcopenshell.entity_instance:
return ifcopenshell.api.control.assign_control(
self.file,
related_object=related_object,
related_objects=[related_object],
relating_control=cost_item,
)
@@ -57,7 +57,7 @@ def calculate_cost_item_resource_value(file: ifcopenshell.file, cost_item: ifcop
concrete = ifcopenshell.api.resource.add_resource(model,
ifc_class="IfcConstructionMaterialResource", parent_resource=crew)
ifcopenshell.api.control.assign_control(model,
relating_control=item, related_object=concrete)
relating_control=item, related_objects=[concrete])
# ... which has a unit price of 42.0 per m3
value = ifcopenshell.api.cost.add_cost_value(model, parent=concrete)
ifcopenshell.api.cost.edit_cost_value(model, cost_value=value,
@@ -72,7 +72,7 @@ def calculate_cost_item_resource_value(file: ifcopenshell.file, cost_item: ifcop
equipment = ifcopenshell.api.resource.add_resource(model,
ifc_class="IfcConstructionEquipmentResource", parent_resource=crew)
ifcopenshell.api.control.assign_control(model,
relating_control=item, related_object=equipment)
relating_control=item, related_objects=[equipment])
# ... with a fixed price of 50,000
value = ifcopenshell.api.cost.add_cost_value(model, parent=concrete)
ifcopenshell.api.cost.edit_cost_value(model, cost_value=value,
@@ -45,5 +45,5 @@ def copy_cost_schedule(
if isinstance(duplicated_cost_item, list):
# All other nested items are not connected to the cost schedule explicitly.
duplicated_cost_item = duplicated_cost_item[0]
ifcopenshell.api.control.assign_control(file, new_schedule, duplicated_cost_item)
ifcopenshell.api.control.assign_control(file, new_schedule, [duplicated_cost_item])
return new_schedule
@@ -86,7 +86,7 @@ class Usecase:
for product in products:
ifcopenshell.api.control.unassign_control(
self.file,
related_object=product,
related_objects=[product],
relating_control=cost_item,
)
self.update_cost_item_count(cost_item)
@@ -22,6 +22,7 @@ import ifcopenshell.api.owner
import ifcopenshell.util.unit
import ifcopenshell.util.element
import ifcopenshell.util.placement
from ifcopenshell.util.shape_builder import ShapeBuilder
from typing import Optional, Union, Any
NPArrayOfFloats = npt.NDArray[np.float64]
@@ -74,6 +75,7 @@ class Usecase:
if not hasattr(self.settings["product"], "ObjectPlacement"):
return
self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(self.file)
self.builder = ShapeBuilder(self.file)
if not self.settings["is_si"]:
self.convert_matrix_to_si(self.settings["matrix"])
@@ -183,25 +185,12 @@ class Usecase:
o = np.array((m[0][3], m[1][3], m[2][3]))
object_matrix = ifcopenshell.util.placement.a2p(o, z, x)
relative_placement_matrix = np.linalg.inv(relating_object_matrix) @ object_matrix
return self.create_ifc_axis_2_placement_3d(
relative_placement_matrix[:, 3][0:3],
return self.builder.create_axis2_placement_3d(
self.convert_si_to_unit(relative_placement_matrix[:, 3][0:3]),
relative_placement_matrix[:, 2][0:3],
relative_placement_matrix[:, 0][0:3],
)
def create_ifc_axis_2_placement_3d(
self, point: NPArrayOfFloats, up: NPArrayOfFloats, forward: NPArrayOfFloats
) -> ifcopenshell.entity_instance:
return self.file.createIfcAxis2Placement3D(
self.create_cartesian_point(point),
self.file.createIfcDirection(up.tolist()),
self.file.createIfcDirection(forward.tolist()),
)
def create_cartesian_point(self, co: NPArrayOfFloats) -> ifcopenshell.entity_instance:
co = self.convert_si_to_unit(co)
return self.file.createIfcCartesianPoint(co.tolist())
def convert_si_to_unit(self, co: NPArrayOfFloats) -> NPArrayOfFloats:
return co / self.unit_scale
@@ -18,9 +18,9 @@
import ifcopenshell
import ifcopenshell.util.element
import ifcopenshell.util.geolocation
import ifcopenshell.util.unit
import numpy as np
from ifcopenshell.util.shape_builder import ShapeBuilder
from math import sin, cos, radians
@@ -62,6 +62,7 @@ def edit_wcs(
ifcopenshell.api.georeference.edit_wcs(model)
"""
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file)
builder = ShapeBuilder(file)
if np.isclose(rotation, 0):
xaxis_x = 1.0
xaxis_y = 0.0
@@ -74,14 +75,10 @@ def edit_wcs(
old_wcs = context.WorldCoordinateSystem
if context.CoordinateSpaceDimension == 3:
if is_si:
point = file.createIfcCartesianPoint((x / unit_scale, y / unit_scale, z / unit_scale))
xyz = (x / unit_scale, y / unit_scale, z / unit_scale)
else:
point = file.createIfcCartesianPoint((x, y, z))
placement = file.createIfcAxis2Placement3D(
point,
file.createIfcDirection((0.0, 0.0, 1.0)),
file.createIfcDirection((xaxis_x, xaxis_y, 0.0)),
)
xyz = (x, y, z)
placement = builder.create_axis2_placement_3d(xyz, (0.0, 0.0, 1.0), (xaxis_x, xaxis_y, 0.0))
elif context.CoordinateSpaceDimension == 2:
if is_si:
point = file.createIfcCartesianPoint((x / unit_scale, y / unit_scale))
@@ -138,7 +138,7 @@ def add_task(
task.Identification = identification
task.IsMilestone = False
if work_schedule:
ifcopenshell.api.control.assign_control(file, work_schedule, task)
ifcopenshell.api.control.assign_control(file, work_schedule, [task])
elif parent_task:
rel = ifcopenshell.api.nest.assign_object(
file,
@@ -75,7 +75,7 @@ def add_work_calendar(
# We associate the calendar with the construction root task. All
# subtasks underneath the construction work task will also inherit
# this calendar by default (though you can override them).
ifcopenshell.api.control.assign_control(model, relating_control=calendar, related_object=task)
ifcopenshell.api.control.assign_control(model, relating_control=calendar, related_objects=[task])
"""
work_calendar = ifcopenshell.api.root.create_entity(
file,
@@ -47,5 +47,5 @@ def copy_work_schedule(
duplicated_tasks = ifcopenshell.api.sequence.duplicate_task(file, task)[1]
# All other nested items are not connected to the work schedule explicitly.
duplicated_task = duplicated_tasks[0]
ifcopenshell.api.control.assign_control(file, new_schedule, duplicated_task)
ifcopenshell.api.control.assign_control(file, new_schedule, [duplicated_task])
return new_schedule
@@ -79,7 +79,7 @@ class Usecase:
assert isinstance(res, list)
current, duplicate = res
ifcopenshell.api.control.assign_control(
self.file, relating_control=baseline_work_schedule, related_object=duplicate[0]
self.file, relating_control=baseline_work_schedule, related_objects=[duplicate[0]]
)
for i, task in enumerate(current):
self.create_baseline_reference(task, duplicate[i])
@@ -54,7 +54,7 @@ def remove_work_calendar(file: ifcopenshell.file, work_calendar: ifcopenshell.en
ifcopenshell.api.control.unassign_control(
file,
relating_control=work_calendar,
related_object=related_object,
related_objects=[related_object],
)
# Currently in API work times are created already attached
+59 -12
View File
@@ -18,11 +18,13 @@
from __future__ import annotations
import re
import json
import numpy as np
import numpy.typing as npt
import ifcopenshell
import ifcopenshell.util.attribute
import ifcopenshell.util.schema
from pathlib import Path
from typing import Any, NoReturn, Union, Optional, TYPE_CHECKING
from typing import Any, NoReturn, Union, Optional, TYPE_CHECKING, TypedDict
from . import ifcopenshell_wrapper
from .file import file
from .entity_instance import entity_instance
@@ -36,8 +38,33 @@ except ImportError as e:
print(f"No SQL support: {e}")
class GeometryCache(TypedDict):
shapes: dict[int, GeometryCacheShape]
geometry: dict[str, GeometryCacheGeometry]
class GeometryCacheShape(TypedDict):
co: list[float]
"""Object location."""
matrix: npt.NDArray[np.float64]
geometry: Union[str, None]
"""Element's geometry id (same value as in ``Representation.id``).
Is set to ``None` when no geometry is available for the element.
"""
class GeometryCacheGeometry(TypedDict):
verts: npt.NDArray[np.float64]
edges: npt.NDArray[np.int32]
faces: npt.NDArray[np.int32]
material_ids: npt.NDArray[np.int32]
materials: list[int]
class sqlite(file):
schema: ifcopenshell.util.schema.IFC_SCHEMA = "IFC4"
mvd_str: str
"""As in `header.file_description.description`."""
def __init__(self, filepath: str):
"""
@@ -53,7 +80,6 @@ class sqlite(file):
if not Path(filepath).exists():
raise FileNotFoundError(f"File doesn't exist: {filepath}")
self.wrapped_data = None
self.history_size = 64
self.history = []
self.future = []
@@ -81,7 +107,8 @@ class sqlite(file):
except:
assert False, "SQLite schema not supported."
self.schema = row[1]
self._schema = row[1]
self.mvd_str = row[2]
self.ifc_schema = ifcopenshell.schema_by_name(self.schema)
self.cursor.execute("SELECT ifc_id, ifc_class FROM id_map")
@@ -234,29 +261,30 @@ class sqlite(file):
return True
return False
def get_geometry(self, ids: list[int]) -> dict[str, dict]:
def get_geometry(self, ids: list[int]) -> GeometryCache:
import numpy as np
ids_csv = ",".join(map(str, ids))
query = f"SELECT ifc_id, x, y, z, matrix, geometry, verts, edges, faces, material_ids, materials FROM shape LEFT JOIN geometry ON shape.geometry = geometry.id WHERE `ifc_id` IN ({ids_csv})"
self.cursor.execute(query)
rows = self.cursor.fetchall()
shapes = {}
geometry = {}
shapes: dict[int, GeometryCacheShape] = {}
geometry: dict[str, GeometryCacheGeometry] = {}
for row in rows:
if row["geometry"] and row["geometry"] not in geometry:
# Same data types as in ifcopenshell.util.shape.
geometry[row["geometry"]] = {
"verts": np.frombuffer(row["verts"]).tolist() if row["verts"] else [],
"edges": np.frombuffer(row["edges"], dtype=np.int64).tolist() if row["edges"] else [],
"faces": np.frombuffer(row["faces"], dtype=np.int64).tolist() if row["faces"] else [],
"verts": np.frombuffer(row["verts"], dtype="d") if row["verts"] else np.empty(0, dtype="d"),
"edges": np.frombuffer(row["edges"], dtype="i") if row["edges"] else np.empty(0, dtype="i"),
"faces": np.frombuffer(row["faces"], dtype="i") if row["faces"] else np.empty(0, dtype="i"),
"material_ids": (
np.frombuffer(row["material_ids"], dtype=np.int64).tolist() if row["material_ids"] else []
np.frombuffer(row["material_ids"], dtype="i") if row["material_ids"] else np.empty(0, dtype="i")
),
"materials": json.loads(row["materials"]) if row["materials"] else [],
}
shapes[row["ifc_id"]] = {
"co": [row["x"], row["y"], row["z"]],
"matrix": np.copy(np.frombuffer(row["matrix"]).reshape((4, 4))),
"matrix": np.copy(np.frombuffer(row["matrix"], dtype="d").reshape((4, 4))),
"geometry": row["geometry"],
}
ids_without_geometry = set(ids) - set(shapes.keys())
@@ -272,6 +300,21 @@ class sqlite(file):
# Override to avoid clean up data unrelated to sqlite file.
pass
def wrapped_data(self) -> NoReturn:
class_name = str(type(self))
raise Exception(
f"No `wrapped_data` for {class_name}. `ifcopenshell.{class_name}` is probably confused with `ifcopenshell.file`."
)
@property
def schema(self) -> ifcopenshell.util.schema.IFC_SCHEMA:
return self._schema
@property
def schema_identifier(self) -> str:
# The best option we've got for mimicing `file.schema_identifier`.
return self._schema
class sqlite_entity(entity_instance):
sqlite_wrapper: sqlite_wrapper
@@ -449,6 +492,10 @@ class sqlite_entity(entity_instance):
info.update(self.sqlite_wrapper.attribute_cache)
return info
@property
def file(self) -> sqlite:
return self.sqlite_wrapper.file
class sqlite_wrapper:
def __init__(self, id: int, ifc_class: str, file: sqlite):
+22 -10
View File
@@ -87,10 +87,7 @@ try:
return (int(items[0]), str(items[1]), items[2])
class stream(file):
schema: ifcopenshell.util.schema.IFC_SCHEMA = "IFC4"
def __init__(self, filepath: str):
self.wrapped_data = None
self.history_size = 64
self.history = []
self.future = []
@@ -109,9 +106,9 @@ try:
# common.INT doesn't support negative integers.
grammar = r"""
start: "#" NUMBER "=" TYPE "(" args ")" ";"
args: arg ("," arg)*
arg: STRING -> string
| FLOAT -> float
| IFCINT -> ifcint
@@ -121,21 +118,21 @@ try:
| REFERENCE -> reference
| list -> list
| inline_type -> inline_type
list: "(" arg? ("," arg)* ")"
inline_type: TYPE "(" arg ")"
REFERENCE: "#" /[0-9]+/
TYPE: CNAME
NUMBER: INT
STRING: "'" /([^']|'')*/ "'"
IFCINT: /-?[0-9]+/
FLOAT: /-?[0-9]+\.[0-9]*([Ee]-?[0-9]+)?/
NULL: "$"
DERIVED: "*"
ENUM: "." CNAME "."
%import common.INT
%import common.CNAME
"""
@@ -176,7 +173,7 @@ try:
self.class_map.setdefault(ifc_class, []).append(step_id)
self.id_offset[step_id] = offset
elif line.startswith("FILE_SCHEMA"):
self.schema = line.split("'")[1]
self._schema = line.split("'")[1]
self.ifc_schema = ifcopenshell.schema_by_name(self.schema)
for ifc_class in exclude_classes:
declaration = self.ifc_schema.declaration_by_name(ifc_class)
@@ -290,6 +287,21 @@ try:
# Override to avoid clean up unrelated to stream file.
pass
def wrapped_data(self) -> NoReturn:
class_name = str(type(self))
raise Exception(
f"No `wrapped_data` for {class_name}. `ifcopenshell.{class_name}` is probably confused with `ifcopenshell.file`."
)
@property
def schema(self) -> ifcopenshell.util.schema.IFC_SCHEMA:
return self._schema
@property
def schema_identifier(self) -> str:
# The best option we've got for mimicing `file.schema_identifier`.
return self._schema
class stream_entity(entity_instance):
stream_wrapper: stream_wrapper
@@ -21,6 +21,7 @@ import numpy as np
import ifcopenshell
from typing import Any, Union
from dataclasses import dataclass
from ifcopenshell.util.shape_builder import ShapeBuilder
@dataclass
@@ -78,9 +79,7 @@ class Clipping:
if not ifc_file:
ifc_file = first_operand.file
location = ifc_file.createIfcCartesianPoint([i / unit_scale for i in self.location])
direction = ifc_file.createIfcDirection(self.normal)
builder = ShapeBuilder(ifc_file)
normal = np.array(self.normal)
if np.allclose(normal, np.array([0.0, 0.0, 1.0]), atol=1e-2) or np.allclose(
@@ -92,9 +91,9 @@ class Clipping:
x_axis = np.cross(normal, arbitrary_vector)
x_axis /= np.linalg.norm(x_axis)
x_axis = ifc_file.createIfcDirection(x_axis.tolist())
plane = ifc_file.createIfcPlane(ifc_file.createIfcAxis2Placement3D(location, direction, x_axis))
placement = builder.create_axis2_placement_3d([i / unit_scale for i in self.location], self.normal, x_axis)
plane = ifc_file.create_entity("IfcPlane", placement)
second_operand = ifc_file.createIfcHalfSpaceSolid(plane, False)
return ifc_file.createIfcBooleanClippingResult("DIFFERENCE", first_operand, second_operand)
@@ -1169,6 +1169,25 @@ def get_groups(element: ifcopenshell.entity_instance) -> list[ifcopenshell.entit
return groups
def get_controls(element: ifcopenshell.entity_instance) -> Generator[ifcopenshell.entity_instance]:
"""
Retrieves the controls of an element.
:param element: The IFC element
:return: Generator of IfcControl elements assigned to the element.
Example:
.. code:: python
task = file.by_type("IfcTask")[0]
control = ifcopenshell.util.element.get_controls(task)[0]
"""
for rel in element.HasAssignments:
if rel.is_a("IfcRelAssignsToControl"):
yield rel.RelatingControl
def get_parent(element: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]:
"""Get the parent in the spatial heirarchy
@@ -23,6 +23,7 @@ import ifcopenshell.util.unit
import ifcopenshell.util.element
import ifcopenshell.util.placement
from typing import NamedTuple, Optional, Union
from decimal import Decimal, ROUND_HALF_UP
MatrixType = ifcopenshell.util.placement.MatrixType
@@ -39,39 +40,54 @@ class HelmertTransformation(NamedTuple):
factor_z: float
def dms2dd(degrees: int, minutes: int, seconds: int, ms: int = 0) -> float:
"""Convert degrees, minutes, and (milli)seconds to decimal degrees
def dms2dd(degrees: int, minutes: int, seconds: int, us: int = 0) -> float:
"""Convert degrees, minutes, and (micro)seconds to decimal degrees
All components must be either positive or negative.
:param degrees: The degrees component
:param minutes: The minutes component
:param seconds: The seconds component
:param ms: The milliseconds component
:param us: The microseconds component
:return: The angle in decimal degrees.
"""
dd = float(degrees) + float(minutes) / 60.0 + float(seconds) / (3600.0) + float(ms / 3600000000.0)
return dd
all_positive_or_zero = degrees >= 0 and minutes >= 0 and seconds >= 0 and us >= 0
all_negative_or_zero = degrees <= 0 and minutes <= 0 and seconds <= 0 and us <= 0
assert all_positive_or_zero or all_negative_or_zero
return degrees + minutes / 60.0 + seconds / 3600.0 + us / 3600000000.0
def dd2dms(dd: float, use_ms: bool = False) -> Union[tuple[float, float, float, float], tuple[float, float, float]]:
"""Convert decimal degrees to degrees, minutes, and (milli)seconds format
def dd2dms(dd: float, use_us: bool = False) -> Union[tuple[int, int, int, int], tuple[int, int, float]]:
"""Convert decimal degrees to degrees, minutes, and (micro)seconds format
:param dd: The decimal degrees
:param use_ms: True if to include milliseconds and false otherwise. Defaults to false.
:return: The angle in a tuple of either 3 or 4 values, being degrees,
minutes, seconds, and optionally milliseconds.
:param use_us: True if to include microseconds and false otherwise. Defaults to false.
:return: The angle in a tuple of either 3 or 4 values,
4 values: integer number of degrees, integer number of minutes, integer number of seconds and integer number of microseconds
3 values: integer number of degrees, integer number of minutes, and a float number for seconds
:note: the tuple follows the format of IfcCompoundPlaneAngleMeasure. Namely all of its components are either positive or negative.
"""
dd = float(dd)
sign = 1 if dd >= 0 else -1
dd = abs(dd)
if use_ms:
seconds, ms = divmod(dd * 60 * 60 * 1000000, 1000000)
minutes, seconds = divmod(dd * 60 * 60, 60)
degrees, minutes = divmod(minutes, 60)
if dd < 0:
degrees = -degrees
if use_ms:
return (int(degrees) * sign, int(minutes) * sign, int(seconds) * sign, int(ms) * sign)
return (int(degrees) * sign, int(minutes) * sign, int(seconds) * sign)
dd_decimal = Decimal(str(dd))
degrees = int(dd_decimal)
degrees_decimal = Decimal(degrees)
fractional_part = dd_decimal - degrees_decimal
minutes_decimal = fractional_part * Decimal(60)
minutes = int(minutes_decimal)
minutes_decimal_int = Decimal(minutes)
seconds_decimal = (minutes_decimal - minutes_decimal_int) * Decimal(60)
if use_us:
seconds = int(seconds_decimal)
seconds_decimal_int = Decimal(seconds)
microseconds_decimal = (seconds_decimal - seconds_decimal_int) * Decimal(1000000)
microseconds = int(microseconds_decimal.quantize(Decimal(1), rounding=ROUND_HALF_UP))
return (degrees, minutes, seconds, microseconds)
else:
seconds_float = float(seconds_decimal)
return (degrees, minutes, seconds_float)
def xyz2enh(
@@ -692,7 +692,8 @@ def calculate_unit_scale(ifc_file: ifcopenshell.file, unit_type: str = "LENGTHUN
:returns: The scale factor
"""
if (
unit_type
type(ifc_file) is ifcopenshell.file
and unit_type
not in ifcopenshell.ifcopenshell_wrapper.schema_by_name(ifc_file.schema_identifier)
.declaration_by_name("IfcUnitEnum")
.enumeration_items()
@@ -27,23 +27,33 @@ class TestAssignControl(test.bootstrap.IFC4):
control = ifcopenshell.api.cost.add_cost_schedule(self.file)
# simple assignment
relation = ifcopenshell.api.control.assign_control(self.file, relating_control=control, related_object=wall)
relation = ifcopenshell.api.control.assign_control(self.file, relating_control=control, related_objects=[wall])
assert relation
assert len(self.file.by_type("IfcRelAssignsToControl")) == 1
assert relation.RelatingControl == control
assert relation.RelatedObjects == (wall,)
# trying to establish existing relationship
relation = ifcopenshell.api.control.assign_control(self.file, relating_control=control, related_object=wall)
relation = ifcopenshell.api.control.assign_control(self.file, relating_control=control, related_objects=[wall])
assert relation is None
# assigning same control to another object
wall1 = self.file.createIfcWall()
relation = ifcopenshell.api.control.assign_control(self.file, relating_control=control, related_object=wall1)
relation = ifcopenshell.api.control.assign_control(self.file, relating_control=control, related_objects=[wall1])
assert relation is not None
assert len(self.file.by_type("IfcRelAssignsToControl")) == 1
assert relation.RelatingControl == control
assert set(relation.RelatedObjects) == set((wall, wall1))
def test_batch_assignment(self):
walls = [self.file.createIfcWall() for _ in range(5)]
control = ifcopenshell.api.cost.add_cost_schedule(self.file)
relation = ifcopenshell.api.control.assign_control(self.file, relating_control=control, related_objects=walls)
assert relation
assert len(self.file.by_type("IfcRelAssignsToControl")) == 1
assert relation.RelatingControl == control
assert set(relation.RelatedObjects) == set(walls)
class TestAssignControlIFC2X3(test.bootstrap.IFC2X3, TestAssignControl):
pass
@@ -27,15 +27,17 @@ class TestUnassignControl(test.bootstrap.IFC4):
control = ifcopenshell.api.cost.add_cost_schedule(self.file)
# assign and unassign
relation = ifcopenshell.api.control.assign_control(self.file, relating_control=control, related_object=wall)
ifcopenshell.api.control.unassign_control(self.file, relating_control=control, related_object=wall)
relation = ifcopenshell.api.control.assign_control(self.file, relating_control=control, related_objects=[wall])
assert relation
ifcopenshell.api.control.unassign_control(self.file, relating_control=control, related_objects=[wall])
assert len(self.file.by_type("IfcRelAssignsToControl")) == 0
# 1 control 2 related objects
wall1 = self.file.createIfcWall()
relation = ifcopenshell.api.control.assign_control(self.file, relating_control=control, related_object=wall)
ifcopenshell.api.control.assign_control(self.file, relating_control=control, related_object=wall1)
ifcopenshell.api.control.unassign_control(self.file, relating_control=control, related_object=wall1)
relation = ifcopenshell.api.control.assign_control(self.file, relating_control=control, related_objects=[wall])
assert relation
ifcopenshell.api.control.assign_control(self.file, relating_control=control, related_objects=[wall1])
ifcopenshell.api.control.unassign_control(self.file, relating_control=control, related_objects=[wall1])
assert len(self.file.by_type("IfcRelAssignsToControl")) == 1
assert relation.RelatedObjects == (wall,)
@@ -30,7 +30,7 @@ class TestAddCostItemQuantity(test.bootstrap.IFC4):
schedule = ifcopenshell.api.cost.add_cost_schedule(self.file)
item = ifcopenshell.api.cost.add_cost_item(self.file, cost_schedule=schedule)
wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
ifcopenshell.api.control.assign_control(self.file, relating_control=item, related_object=wall)
ifcopenshell.api.control.assign_control(self.file, relating_control=item, related_objects=[wall])
quantities = []
for quantity_type in quantity_types:
@@ -95,7 +95,7 @@ class TestEditTaskTime(test.bootstrap.IFC4):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
calendar = ifcopenshell.api.sequence.add_work_calendar(self.file)
task = self.file.createIfcTask()
ifcopenshell.api.control.assign_control(self.file, relating_control=calendar, related_object=task)
ifcopenshell.api.control.assign_control(self.file, relating_control=calendar, related_objects=[task])
task_time = ifcopenshell.api.sequence.add_task_time(self.file, task=task)
ifcopenshell.api.sequence.edit_task_time(
self.file,
@@ -195,7 +195,7 @@ class TestEditTaskTime(test.bootstrap.IFC4):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
calendar = ifcopenshell.api.sequence.add_work_calendar(self.file)
task = self.file.createIfcTask()
ifcopenshell.api.control.assign_control(self.file, relating_control=calendar, related_object=task)
ifcopenshell.api.control.assign_control(self.file, relating_control=calendar, related_objects=[task])
task_time = ifcopenshell.api.sequence.add_task_time(self.file, task=task)
ifcopenshell.api.sequence.edit_task_time(
self.file,
@@ -38,7 +38,7 @@ class TestRemoveWorkCalendar(test.bootstrap.IFC4):
# Assign tasks.
task = ifcopenshell.api.sequence.add_task(self.file)
ifcopenshell.api.control.assign_control(self.file, work_calendar, task)
ifcopenshell.api.control.assign_control(self.file, work_calendar, [task])
ifcopenshell.api.sequence.remove_work_calendar(self.file, work_calendar)
+22 -2
View File
@@ -16,19 +16,39 @@
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import ifcopenshell.api.cost
import ifcopenshell.api.root
import test.bootstrap
import ifcopenshell.api
import ifcopenshell.api.control
import ifcopenshell.api.sequence
import ifcopenshell.util.element
from datetime import datetime
from typing import Union
def deprecation_check(test):
def new_test(self):
assert datetime.now().date() < datetime(2024, 8, 1).date(), "API arguments are completely deprecated"
assert datetime.now().date() < datetime(2026, 1, 9).date(), "API arguments are completely deprecated"
test(self)
return new_test
class TestTemporarySupportForDeprecatedAPIArguments(test.bootstrap.IFC4):
pass
@deprecation_check
def test_assigning_control(self):
model = self.file
element = ifcopenshell.api.root.create_entity(model, ifc_class="IfcWall")
control = ifcopenshell.api.cost.add_cost_schedule(model)
ifcopenshell.api.control.assign_control(model, relating_control=control, related_objects=[element])
assert list(ifcopenshell.util.element.get_controls(element)) == [control]
@deprecation_check
def test_unassigning_control(self):
TestTemporarySupportForDeprecatedAPIArguments.test_assigning_control(self)
model = self.file
element = model.by_type("IfcWall")[0]
control = model.by_type("IfcCostSchedule")[0]
ifcopenshell.api.control.unassign_control(model, relating_control=control, related_objects=[element])
assert list(ifcopenshell.util.element.get_controls(element)) == []
@@ -145,14 +145,20 @@ class TestAssignType(test.bootstrap.IFC4):
This is because the type will have its own PredefinedType, and the element's PredefinedType
will conflict with it. (See #7006)
"""
is_ifc2x3 = self.file.schema == "IFC2X3"
element_type = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWallType")
element_type.PredefinedType = "MOVABLE"
element_type.PredefinedType = "POLYGONAL"
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
element.PredefinedType = "USERDEFINED"
if not is_ifc2x3:
# In IFC2X3, there seems to be no example when both type and occurence have PredefinedType.
# So we just ignore it.
element.PredefinedType = "USERDEFINED"
element.ObjectType = "Test"
ifcopenshell.api.type.assign_type(self.file, related_objects=[element], relating_type=element_type)
assert element.PredefinedType is None
if not is_ifc2x3:
assert element.PredefinedType is None
assert element.ObjectType is None
def test_keep_predefined_type_if_type_assignment_is_notdefined(self):
@@ -160,16 +166,26 @@ class TestAssignType(test.bootstrap.IFC4):
if an element has a PredefinedType, it will be removed when assigning a type.(See #7006)
This behavior needs to be blocked if the PredefinedType of the typing Entity is set to "NOTDEFINED". (See #7011)
"""
is_ifc2x3 = self.file.schema == "IFC2X3"
element_type = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWallType")
element_type.PredefinedType = "NOTDEFINED"
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
element.PredefinedType = "USERDEFINED"
if not is_ifc2x3:
# In IFC2X3, there seems to be no example when both type and occurence have PredefinedType.
# So we just ignore it.
element.PredefinedType = "USERDEFINED"
element.ObjectType = "Test"
ifcopenshell.api.type.assign_type(self.file, related_objects=[element], relating_type=element_type)
assert element.PredefinedType == "USERDEFINED"
if not is_ifc2x3:
assert element.PredefinedType == "USERDEFINED"
assert element.ObjectType == "Test"
class TestAssignTypeIFC2X3(test.bootstrap.IFC2X3, TestAssignType):
pass
class TestAssignTypeIFC4X3(test.bootstrap.IFC4X3, TestAssignType):
pass
+8 -3
View File
@@ -47,15 +47,20 @@ class TestPackageSupportedPlatforms:
response = conn.getresponse()
build_html = response.read().decode("utf-8")
def find_make_var(var_name: str) -> str:
line = next(l for l in text.splitlines() if l.startswith(f"{var_name}:="))
return line.partition(":=")[2]
BINARY_VERSION = find_make_var("BINARY_VERSION")
URL_TYPES = ("IOS_URL", "IFCCONVERT_URL")
missing_urls: set[str] = set()
for url_type in URL_TYPES:
line = next(l for l in text.splitlines() if l.startswith(f"{url_type}:="))
_, _, url_template = line.partition(":=")
url_template = find_make_var(url_type)
url_template = url_template.replace("$(", "{").replace(")", "}")
for platform in SUPPORTED_PLATFORMS:
for pyver in SUPPORTED_PY_VERSIONS:
url = url_template.format(PYNUMBER=pyver, PLATFORM=platform)
url = url_template.format(PYNUMBER=pyver, PLATFORM=platform, BINARY_VERSION=BINARY_VERSION)
if url not in build_html:
missing_urls.add(url)
@@ -16,6 +16,8 @@
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import ifcopenshell.api.control
import ifcopenshell.api.cost
import ifcopenshell.api.profile
import pytest
import test.bootstrap
@@ -926,6 +928,23 @@ class TestGetGroupsIFC2X3(test.bootstrap.IFC2X3, TestGetGroupsIFC4):
pass
class TestGetControls(test.bootstrap.IFC2X3):
def test_run(self):
model = self.file
element = ifcopenshell.api.root.create_entity(model, ifc_class="IfcWall")
control = ifcopenshell.api.cost.add_cost_schedule(model)
ifcopenshell.api.control.assign_control(model, related_objects=[element], relating_control=control)
assert list(subject.get_controls(element)) == [control]
class TestGetControlsIFC4(test.bootstrap.IFC4, TestGetControls):
pass
class TestGetControlsIFC4X3(test.bootstrap.IFC4X3, TestGetControls):
pass
class TestGetAggregateIFC4(test.bootstrap.IFC4):
def test_getting_the_containing_aggregate_of_a_subelement(self):
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
@@ -468,3 +468,29 @@ class TestAngle2YAxis(test.bootstrap.IFC4):
assert np.allclose(subject.angle2yaxis(45), (-a, a))
assert np.allclose(subject.angle2yaxis(-135), (a, -a))
assert np.allclose(subject.angle2yaxis(135), (-a, -a))
class TestDMS2DDandDD2DMS(test.bootstrap.IFC4):
def test_dms2dd_and_dd2dms(self):
test_cases_3tuple = [
(35.41, (35, 24, 36.0)),
(-116.89, (-116, -53, -24.0)),
]
test_cases_4tuple = [
(40.431389, (40, 25, 53, 400)),
(-4.248056, (-4, -14, -53, -1600)),
(-35.401389, (-35, -24, -5, -400)),
(148.981667, (148, 58, 54, 1200)),
]
for dd, dms in test_cases_3tuple:
d, m, s = subject.dd2dms(dd)
assert (d, m, s) == dms
dd_converted = subject.dms2dd(dms[0], dms[1], dms[2])
assert dd_converted == dd
for dd, dms in test_cases_4tuple:
d, m, s, us = subject.dd2dms(dd, use_us=True)
assert (d, m, s, us) == dms
dd_converted = subject.dms2dd(dms[0], dms[1], dms[2], dms[3])
assert dd_converted == dd
+41 -14
View File
@@ -33,6 +33,7 @@ import ifcopenshell.ifcopenshell_wrapper as W
import ifcopenshell.util.attribute
import ifcopenshell.util.element
import ifcopenshell.util.placement
import ifcopenshell.util.representation
import ifcopenshell.util.schema
import ifcopenshell.util.shape
import ifcopenshell.util.unit
@@ -140,7 +141,7 @@ class Patcher(ifcpatch.BasePatcher):
self.should_skip_geometry_data = should_skip_geometry_data
geometry_rows: dict[str, tuple[str, bytes, bytes, bytes, bytes, str]]
shape_rows: dict[int, tuple[int, list[float], list[float], list[float], bytes, str]]
shape_rows: dict[int, tuple[int, float, float, float, bytes, Union[str, None]]]
def get_output(self) -> Union[str, None]:
"""Return resulting database filepath for sqlite and ``None`` for mysql."""
@@ -249,29 +250,29 @@ class Patcher(ifcpatch.BasePatcher):
self.geometry_rows = {}
if self.file.schema in ("IFC2X3", "IFC4"):
self.elements = self.file.by_type("IfcElement") + self.file.by_type("IfcProxy")
elements = self.file.by_type("IfcElement") + self.file.by_type("IfcProxy")
else:
self.elements = self.file.by_type("IfcElement")
elements = self.file.by_type("IfcElement")
self.settings = ifcopenshell.geom.settings()
self.settings.set("apply-default-materials", False)
self.body_contexts = [
body_contexts = [
c.id()
for c in self.file.by_type("IfcGeometricRepresentationSubContext")
if c.ContextIdentifier in ["Body", "Facetation"]
]
# Ideally, all representations should be in a subcontext, but some BIM programs don't do this correctly
self.body_contexts.extend(
body_contexts.extend(
[
c.id()
for c in self.file.by_type("IfcGeometricRepresentationContext", include_subtypes=False)
if c.ContextType == "Model"
]
)
self.settings.set("context-ids", self.body_contexts)
self.settings.set("context-ids", body_contexts)
products = self.elements
products = elements
iterator = ifcopenshell.geom.iterator(self.settings, self.file, multiprocessing.cpu_count(), include=products)
valid_file = iterator.initialize()
if not valid_file:
@@ -301,12 +302,7 @@ class Patcher(ifcpatch.BasePatcher):
geometry = shape.geometry
geometry_id = geometry.id
if geometry_id not in self.geometry_rows:
v = geometry.verts_buffer
e = geometry.edges_buffer
f = geometry.faces_buffer
mids = geometry.material_ids_buffer
m = json.dumps([m.instance_id() for m in geometry.materials])
self.geometry_rows[geometry_id] = (geometry_id, v, e, f, mids, m)
self.add_geometry_row(geometry_id, geometry)
# Copy required since otherwise it is read-only
m = ifcopenshell.util.shape.get_shape_matrix(shape).copy()
m[:3, 3] /= self.unit_scale
@@ -315,6 +311,37 @@ class Patcher(ifcpatch.BasePatcher):
if not iterator.next():
break
# Process element types geometry.
element_types = self.file.by_type("IfcElementType")
body_contexts = [self.file.by_id(i) for i in body_contexts]
m_bytes = np.eye(4, dtype=np.float64).tobytes()
for element_type in element_types:
representation = None
for context in body_contexts:
representation = ifcopenshell.util.representation.get_representation(element_type, context)
if representation:
break
geometry_id = None
if representation:
geometry_id_ = str(representation.id())
if geometry_id_ in self.geometry_rows:
geometry_id = geometry_id_
elif geometry := ifcopenshell.geom.create_shape(self.settings, representation):
geometry_id = geometry_id_
assert isinstance(geometry, W.Triangulation)
self.add_geometry_row(geometry_id, geometry)
shape_id = element_type.id()
self.shape_rows[shape_id] = (shape_id, *(0.0, 0.0, 0.0), m_bytes, geometry_id)
def add_geometry_row(self, geometry_id: str, geometry: W.Triangulation) -> None:
v = geometry.verts_buffer
e = geometry.edges_buffer
f = geometry.faces_buffer
mids = geometry.material_ids_buffer
m = json.dumps([m.instance_id() for m in geometry.materials])
self.geometry_rows[geometry_id] = (geometry_id, v, e, f, mids, m)
def check_existing_ifc_database(self) -> None:
if self.sql_type == "sqlite":
cursor = self.c.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='id_map'")
@@ -576,7 +603,7 @@ class Patcher(ifcpatch.BasePatcher):
if element.id() not in self.shape_rows and (placement := getattr(element, "ObjectPlacement", None)):
m = ifcopenshell.util.placement.get_local_placement(placement)
x, y, z = m[:, 3][0:3].tolist()
self.shape_rows[element.id()] = [element.id(), x, y, z, m.tobytes(), None]
self.shape_rows[element.id()] = (element.id(), x, y, z, m.tobytes(), None)
if self.sql_type == "sqlite":
if rows:
@@ -21,6 +21,7 @@ import numpy as np
import ifcopenshell
import ifcopenshell.util.placement
import typing
from ifcopenshell.util.shape_builder import ShapeBuilder
class Patcher:
@@ -97,6 +98,7 @@ class Patcher:
self.ax = None
self.ay = None
self.az = None
self.builder = ShapeBuilder(file)
try:
self.ax = float(ax)
@@ -183,15 +185,4 @@ class Patcher:
z = np.array((m[0][2], m[1][2], m[2][2]))
o = np.array((m[0][3], m[1][3], m[2][3]))
object_matrix = ifcopenshell.util.placement.a2p(o, z, x)
return self.create_ifc_axis_2_placement_3d(
object_matrix[:, 3][0:3],
object_matrix[:, 2][0:3],
object_matrix[:, 0][0:3],
)
def create_ifc_axis_2_placement_3d(self, point, up, forward):
return self.file.createIfcAxis2Placement3D(
self.file.createIfcCartesianPoint(point.tolist()),
self.file.createIfcDirection(up.tolist()),
self.file.createIfcDirection(forward.tolist()),
)
return self.builder.create_axis2_placement_3d_from_matrix(object_matrix)
@@ -0,0 +1,54 @@
import ifcopenshell
import ifcopenshell.api.alignment
import ifcopenshell.util.element
import ifcpatch
from logging import Logger
import typing
from typing import Union
class Patcher(ifcpatch.BasePatcher):
def __init__(self, file: ifcopenshell.file, logger: Union[Logger, None] = None):
"""Adds a zero length segments to alignment layouts
Example:
.. code:: python
model = ifcpatch.execute({"input": "input.ifc", "file": model, "recipe": "PatchStationReferentPosition"})
"""
super().__init__(file, logger)
self.file_patched: ifcopenshell.file
def patch(self):
patched_file = ifcopenshell.file.from_string(self.file.wrapped_data.to_string())
alignments = patched_file.by_type("IfcAlignment")
for alignment in alignments:
basis_curve = ifcopenshell.api.alignment.get_basis_curve(alignment)
nests = alignment.IsNestedBy
first_referent = nests[1].RelatedObjects[0]
start_station = ifcopenshell.util.element.get_pset(first_referent,"Pset_Stationing","Station")
#start_station = first_referent.IsDefinedBy[0].RelatingPropertyDefinition.HasProperties[0].NominalValue.wrapped_data # get station from first_referent
for referent in nests[1].RelatedObjects:
if referent.ObjectPlacement == None:
# Need to get Station property from Pset_Stationing + Station property from the first referent... the DistanceAlong is the different in these values
station = ifcopenshell.util.element.get_pset(referent,"Pset_Stationing","Station")
#station = referent.IsDefinedBy[0].RelatingPropertyDefinition.HasProperties[0].NominalValue.wrapped_data # get station from current referent
object_placement = patched_file.createIfcLinearPlacement(
RelativePlacement=patched_file.createIfcAxis2PlacementLinear(
Location=patched_file.createIfcPointByDistanceExpression(
DistanceAlong=patched_file.createIfcLengthMeasure(station - start_station),
OffsetLateral=None,
OffsetVertical=None,
OffsetLongitudinal=None,
BasisCurve=basis_curve,
)
),
)
referent.ObjectPlacement = object_placement
self.file_patched = patched_file
+71
View File
@@ -19,6 +19,77 @@
PACKAGE_NAME:=ifctester
include ../common.mk
NODE_ENV ?= production
WEBAPP_DIR := webapp
WEBAPP_BUILD_DIR := $(WEBAPP_DIR)/dist
PYODIDE_DIR := $(WEBAPP_DIR)/public/pyodide
PYODIDE_VERSION := 0.28.0
PYODIDE_URL := https://github.com/pyodide/pyodide/releases/download/$(PYODIDE_VERSION)/pyodide-$(PYODIDE_VERSION).tar.bz2
.PHONY: webapp-dev
webapp-dev:
cd $(WEBAPP_DIR) && npm run dev
.PHONY: pyodide-download
pyodide-download:
@if [ -d "$(PYODIDE_DIR)" ]; then \
echo "Pyodide directory already exists at $(PYODIDE_DIR), skipping download"; \
else \
echo "Downloading and preparing Pyodide $(PYODIDE_VERSION)..."; \
mkdir -p $(PYODIDE_DIR)/tmp; \
curl -L -o $(PYODIDE_DIR)/tmp/pyodide-$(PYODIDE_VERSION).tar.bz2 $(PYODIDE_URL); \
cd $(PYODIDE_DIR)/tmp && tar -xjf pyodide-$(PYODIDE_VERSION).tar.bz2 && cd -; \
cp $(PYODIDE_DIR)/tmp/pyodide/pyodide.asm.js $(PYODIDE_DIR)/; \
cp $(PYODIDE_DIR)/tmp/pyodide/pyodide.asm.wasm $(PYODIDE_DIR)/; \
cp $(PYODIDE_DIR)/tmp/pyodide/pyodide.mjs $(PYODIDE_DIR)/; \
cp $(PYODIDE_DIR)/tmp/pyodide/pyodide.mjs.map $(PYODIDE_DIR)/; \
cp $(PYODIDE_DIR)/tmp/pyodide/python_stdlib.zip $(PYODIDE_DIR)/; \
cp $(PYODIDE_DIR)/tmp/pyodide/pyodide-lock.json $(PYODIDE_DIR)/; \
cp $(PYODIDE_DIR)/tmp/pyodide/certifi-*-py3-none-any.whl $(PYODIDE_DIR)/ 2>/dev/null || true; \
cp $(PYODIDE_DIR)/tmp/pyodide/charset_normalizer-*-py3-none-any.whl $(PYODIDE_DIR)/ 2>/dev/null || true; \
cp $(PYODIDE_DIR)/tmp/pyodide/idna-*-py3-none-any.whl $(PYODIDE_DIR)/ 2>/dev/null || true; \
cp $(PYODIDE_DIR)/tmp/pyodide/micropip-*-py3-none-any.whl $(PYODIDE_DIR)/ 2>/dev/null || true; \
cp $(PYODIDE_DIR)/tmp/pyodide/numpy-*-cp313-cp313-pyodide_2025_0_wasm32.whl $(PYODIDE_DIR)/ 2>/dev/null || true; \
cp $(PYODIDE_DIR)/tmp/pyodide/python_dateutil-*-py2.py3-none-any.whl $(PYODIDE_DIR)/ 2>/dev/null || true; \
cp $(PYODIDE_DIR)/tmp/pyodide/requests-*-py3-none-any.whl $(PYODIDE_DIR)/ 2>/dev/null || true; \
cp $(PYODIDE_DIR)/tmp/pyodide/shapely-*-cp313-cp313-pyodide_2025_0_wasm32.whl $(PYODIDE_DIR)/ 2>/dev/null || true; \
cp $(PYODIDE_DIR)/tmp/pyodide/six-*-py2.py3-none-any.whl $(PYODIDE_DIR)/ 2>/dev/null || true; \
cp $(PYODIDE_DIR)/tmp/pyodide/typing_extensions-*-py3-none-any.whl $(PYODIDE_DIR)/ 2>/dev/null || true; \
cp $(PYODIDE_DIR)/tmp/pyodide/urllib3-*-py3-none-any.whl $(PYODIDE_DIR)/ 2>/dev/null || true; \
rm -rf /tmp/pyodide; \
rm -f /tmp/pyodide-$(PYODIDE_VERSION).tar.bz2; \
echo "Pyodide $(PYODIDE_VERSION) prepared in $(PYODIDE_DIR)"; \
fi
.PHONY: webapp-build
webapp-build: pyodide-download
cd $(WEBAPP_DIR) && npm install
cd $(WEBAPP_DIR) && npm run build
.PHONY: webapp-serve
webapp-serve: webapp-build
cd $(WEBAPP_DIR) && $(PYTHON) serve.py
.PHONY: clean
clean:
rm -rf $(WEBAPP_BUILD_DIR)
rm -rf $(WEBAPP_DIR)/node_modules
rm -rf $(PYODIDE_DIR)
rm -rf $(PACKAGE_NAME)/webapp
rm -rf dist
.PHONY: dist
dist: webapp-prepare
$(MAKE) -f ../common.mk dist PACKAGE_NAME=$(PACKAGE_NAME)
.PHONY: webapp-prepare
webapp-prepare: webapp-build
rm -rf $(PACKAGE_NAME)/webapp/www/*
mkdir -p $(PACKAGE_NAME)/webapp/www
cp -r $(WEBAPP_BUILD_DIR)/* $(PACKAGE_NAME)/webapp/www/
cp $(WEBAPP_DIR)/__init__.py $(PACKAGE_NAME)/webapp/__init__.py
cp $(WEBAPP_DIR)/serve.py $(PACKAGE_NAME)/webapp/serve.py
.PHONY: test
test:
pytest -p no:pytest-blender test
+1 -2
View File
@@ -15,11 +15,10 @@ classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)",
]
dependencies = ["ifcopenshell", "python-dateutil", "xmlschema", "numpy", "odfpy", "pystache", "bcf-client"]
dependencies = ["ifcopenshell", "python-dateutil", "xmlschema", "numpy", "odfpy", "pystache", "bcf-client", "flask"]
[project.optional-dependencies]
advanced = [
"flask",
]
[project.urls]
+27
View File
@@ -0,0 +1,27 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.claude
experiment/*
+2
View File
@@ -0,0 +1,2 @@
# IfcTester (Next)
The "next" version of IDS authoring and auditing on the web.
+3
View File
@@ -0,0 +1,3 @@
from .serve import app
__all__ = ["app"]
-83
View File
@@ -1,83 +0,0 @@
# IfcTester - IDS based model auditing
# Copyright (C) 2022 Dion Moult <dion@thinkmoult.com>
#
# This file is part of IfcTester.
#
# IfcTester is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcTester is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcTester. If not, see <http://www.gnu.org/licenses/>.
import os
import time
import ifctester
import ifctester.reporter
import ifcopenshell
import ifcopenshell.guid
from flask import Flask, request, send_from_directory
app = Flask(__name__)
class Ifc:
ifc = None
filepath = None
@classmethod
def get(cls, filepath=None):
if filepath is None or filepath == cls.filepath:
return cls.ifc
cls.filepath = filepath
cls.ifc = ifcopenshell.open(filepath)
return cls.ifc
@app.route("/")
def index():
with open("www/index.html") as template:
return template.read()
@app.route("/<path:asset>.<string:ext>")
def get_asset(asset, ext):
if ext in ("js", "css"):
return send_from_directory("www", asset + "." + ext)
@app.route("/audit", methods=["POST"])
def audit():
filename = ifcopenshell.guid.new()
ids_filepath = os.path.join("uploads", filename + ".ids")
ifc_filepath = os.path.join("uploads", filename + ".ifc")
os.makedirs("uploads", exist_ok=True)
request.files.get("ids").save(ids_filepath)
request.files.get("ifc").save(ifc_filepath)
start = time.time()
specs = ifctester.open(ids_filepath)
ifc = Ifc.get(ifc_filepath)
print("Finished loading:", time.time() - start)
start = time.time()
specs.validate(ifc)
print("Finished validating:", time.time() - start)
start = time.time()
os.remove(ids_filepath)
os.remove(ifc_filepath)
engine = ifctester.reporter.Json(specs)
engine.report()
return engine.to_string()
if __name__ == "__main__":
app.run(debug=False)
+16
View File
@@ -0,0 +1,16 @@
{
"$schema": "https://shadcn-svelte.com/schema.json",
"tailwind": {
"css": "src/css/app.scss",
"baseColor": "gray"
},
"aliases": {
"components": "$lib/components",
"utils": "$lib/utils",
"ui": "$lib/components/ui",
"hooks": "$lib/hooks",
"lib": "$lib"
},
"typescript": false,
"registry": "https://shadcn-svelte.com/registry"
}
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<title>IFC Tester</title>
<meta charset="UTF-8" />
<meta name="color-scheme" content="dark" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+39
View File
@@ -0,0 +1,39 @@
{
"compilerOptions": {
"moduleResolution": "bundler",
"target": "ESNext",
"module": "ESNext",
/**
* svelte-preprocess cannot figure out whether you have
* a value or a type, so tell TypeScript to enforce using
* `import type` instead of `import` for Types.
*/
"verbatimModuleSyntax": true,
"isolatedModules": true,
"resolveJsonModule": true,
/**
* To have warnings / errors of the Svelte compiler at the
* correct position, enable source maps by default.
*/
"sourceMap": true,
"esModuleInterop": true,
"skipLibCheck": true,
/**
* Typecheck JS in `.svelte` and `.js` files by default.
* Disable this if you'd like to use dynamic types.
*/
"checkJs": false,
"baseUrl": ".",
"paths": {
"$lib": ["./src/lib"],
"$lib/*": ["./src/lib/*"],
"$src": ["./src"],
"$src/*": ["./src/*"]
}
},
/**
* Use global.d.ts instead of compilerOptions.types
* to avoid limiting type declarations.
*/
"include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.svelte"]
}
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
{
"name": "ifctester-next",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"deploy": "npm run build && npx wrangler pages deploy dist"
},
"devDependencies": {
"@internationalized/date": "^3.8.1",
"@lucide/svelte": "^0.515.0",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tailwindcss/vite": "^4.0.0",
"bits-ui": "^2.8.10",
"clsx": "^2.1.1",
"mode-watcher": "^1.1.0",
"sass-embedded": "^1.89.0",
"svelte": "^5.28.1",
"svelte-sonner": "^1.0.5",
"tailwind-merge": "^3.3.0",
"tailwind-variants": "^1.0.0",
"tailwindcss": "^4.0.0",
"tw-animate-css": "^1.3.2",
"vite": "^6.3.5"
},
"dependencies": {
"eventemitter3": "^5.0.1",
"hyperid": "^3.3.0",
"lucide-svelte": "^0.542.0",
"socket.io-client": "^4.8.1",
"svelecte": "^5.2.0",
"svelte-spa-router": "^4.0.1"
}
}
@@ -0,0 +1,63 @@
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="783.031" viewBox="0 0 800 783.031" xmlns:xlink="http://www.w3.org/1999/xlink" role="img" artist="Katerina Limpitsouni" source="https://undraw.co/">
<g id="Group_178" data-name="Group 178" transform="translate(-656 -242)">
<g id="Group_177" data-name="Group 177" transform="translate(656 242)">
<path id="Path_2918-215" data-name="Path 2918" d="M914.2,382.533q-.65-3.406-1.369-6.789c-1.077-5.056-2.259-10.1-3.535-15.1-.632-2.528-1.311-5.056-2-7.573v-.012A392.052,392.052,0,0,0,545.043,66.735h-.035q-7.532-.3-15.1-.293c-1.381,0-2.762.012-4.131.035A391.992,391.992,0,0,0,152.944,351.586q-1.089,3.827-2.072,7.678c-1.311,5.045-2.5,10.113-3.617,15.216v.012c-.492,2.282-.96,4.553-1.416,6.835a395.6,395.6,0,0,0-7.444,76.629c0,9.82.363,19.652,1.089,29.3.105,1.381.21,2.762.327,4.132.281,3.453.632,7.046,1.042,10.7a391.609,391.609,0,0,0,756.588,91.084q4.056-11.008,7.421-22.239a385.285,385.285,0,0,0,12.407-55.76q.965-6.531,1.7-13.086c.41-3.652.761-7.245,1.042-10.686.936-10.967,1.416-22.215,1.416-33.44a395.4,395.4,0,0,0-7.221-75.424Z" transform="translate(-121.423 -66.442)" fill="#fff"/>
<path id="Path_2919-216" data-name="Path 2919" d="M506.048,137.05a1.028,1.028,0,1,1,0-2.057h68.076A32.535,32.535,0,0,1,605.6,110.127a45.245,45.245,0,0,1,82.18-9.652c1.012-.079,2-.119,2.958-.119a38.41,38.41,0,0,1,38.2,35.592,1.028,1.028,0,0,1-.954,1.1l-.073,0a1.028,1.028,0,0,1-1.025-.956Z" transform="translate(-5.811 -62.72)" fill="#e6e6e6"/>
<path id="Path_2920-217" data-name="Path 2920" d="M705.856,133.47H537.193a1.028,1.028,0,1,1,0-2.057H705.856a1.028,1.028,0,1,1,0,2.057Z" transform="translate(4.01 -45.954)" fill="#e6e6e6"/>
<rect id="Rectangle_663" data-name="Rectangle 663" width="46.814" height="108.524" transform="translate(88.233 423.738)" fill="#090814"/>
<rect id="Rectangle_664" data-name="Rectangle 664" width="46.814" height="108.524" transform="translate(681.923 423.738)" fill="#090814"/>
<path id="Path_2921-218" data-name="Path 2921" d="M889.646,466.426C834.857,615.354,691.711,721.583,523.764,721.583S212.671,615.354,157.882,466.426Z" transform="translate(-115.278 59.69)" fill="#1ca595"/>
<circle id="Ellipse_470" data-name="Ellipse 470" cx="134.601" cy="134.601" r="134.601" transform="translate(0 62.619)" fill="#e6e6e6"/>
<path id="Path_2922-219" data-name="Path 2922" d="M914.2,382.533q-.65-3.406-1.369-6.789c-1.077-5.056-2.259-10.1-3.535-15.1-.632-2.528-1.311-5.056-2-7.573v-.012A392.052,392.052,0,0,0,545.043,66.735h-.035q-7.532-.3-15.1-.293c-1.381,0-2.762.012-4.131.035A391.992,391.992,0,0,0,152.944,351.586q-1.089,3.827-2.072,7.678c-1.311,5.045-2.5,10.113-3.617,15.216v.012c-.492,2.282-.96,4.553-1.416,6.835a395.6,395.6,0,0,0-7.444,76.629c0,9.82.363,19.652,1.089,29.3.105,1.381.21,2.762.327,4.132.281,3.453.632,7.046,1.042,10.7a391.609,391.609,0,0,0,756.588,91.084q4.056-11.008,7.421-22.239a385.285,385.285,0,0,0,12.407-55.76q.965-6.531,1.7-13.086c.41-3.652.761-7.245,1.042-10.686.936-10.967,1.416-22.215,1.416-33.44a395.4,395.4,0,0,0-7.221-75.424ZM916.507,491.1c-.269,3.406-.62,6.976-1.03,10.581q-.737,6.514-1.673,12.992a386.684,386.684,0,0,1-12.3,55.268q-3.354,11.113-7.362,22C838.252,743.885,691.875,845.96,529.909,845.96S221.567,743.885,165.678,591.949a385.547,385.547,0,0,1-21.337-90.265c-.41-3.6-.761-7.175-1.042-10.593-.117-1.358-.222-2.715-.327-4.085-.7-9.562-1.065-19.324-1.065-29.05a387.49,387.49,0,0,1,8.4-80.5c.492-2.353,1.018-4.693,1.545-7.034v-.012q1.861-7.936,4.015-15.754c.2-.726.4-1.451.609-2.165q.28-1.036.6-2.072c.351-1.264.726-2.516,1.1-3.757C205.754,188.028,352.317,71.779,525.8,69.977c1.369-.012,2.739-.023,4.108-.023q7.515,0,14.982.281c169.27,6.461,311.117,121.89,357.221,277.981q1.159,3.933,2.247,7.912c1.4,5.15,2.7,10.358,3.9,15.59q.79,3.476,1.51,6.987a387.08,387.08,0,0,1,8.146,79.251C917.912,469.076,917.444,480.23,916.507,491.1Z" transform="translate(-121.423 -66.442)" fill="#090814"/>
<rect id="Rectangle_665" data-name="Rectangle 665" width="36.175" height="68.093" transform="translate(93.554 360.965)" fill="#090814"/>
<rect id="Rectangle_666" data-name="Rectangle 666" width="8.512" height="153.21" transform="translate(98.874 214.138)" fill="#090814"/>
<rect id="Rectangle_667" data-name="Rectangle 667" width="8.512" height="153.21" transform="translate(115.897 214.138)" fill="#090814"/>
<rect id="Rectangle_668" data-name="Rectangle 668" width="19.151" height="10.64" transform="translate(103.131 220.522)" fill="#090814"/>
<rect id="Rectangle_669" data-name="Rectangle 669" width="19.151" height="10.64" transform="translate(103.131 263.08)" fill="#090814"/>
<rect id="Rectangle_670" data-name="Rectangle 670" width="19.151" height="10.64" transform="translate(103.131 309.894)" fill="#090814"/>
<rect id="Rectangle_671" data-name="Rectangle 671" width="4.256" height="10.64" transform="translate(101.001 207.754)" fill="#090814"/>
<rect id="Rectangle_672" data-name="Rectangle 672" width="4.256" height="10.64" transform="translate(118.026 207.754)" fill="#090814"/>
<rect id="Rectangle_673" data-name="Rectangle 673" width="36.175" height="68.093" transform="translate(687.243 360.965)" fill="#090814"/>
<rect id="Rectangle_674" data-name="Rectangle 674" width="8.512" height="153.21" transform="translate(692.564 214.138)" fill="#090814"/>
<rect id="Rectangle_675" data-name="Rectangle 675" width="8.512" height="153.21" transform="translate(709.586 214.138)" fill="#090814"/>
<rect id="Rectangle_676" data-name="Rectangle 676" width="19.151" height="10.64" transform="translate(696.818 220.522)" fill="#090814"/>
<rect id="Rectangle_677" data-name="Rectangle 677" width="19.151" height="10.64" transform="translate(696.818 263.08)" fill="#090814"/>
<rect id="Rectangle_678" data-name="Rectangle 678" width="19.151" height="10.64" transform="translate(696.818 309.894)" fill="#090814"/>
<rect id="Rectangle_679" data-name="Rectangle 679" width="4.256" height="10.64" transform="translate(694.691 207.754)" fill="#090814"/>
<rect id="Rectangle_680" data-name="Rectangle 680" width="4.256" height="10.64" transform="translate(711.713 207.754)" fill="#090814"/>
<path id="Path_2923-220" data-name="Path 2923" d="M917.505,389.4q-.439,5.355-1.041,10.639H141.839q-.6-5.285-1.041-10.639Z" transform="translate(-120.666 35.401)" fill="#090814"/>
<path id="Path_2924-221" data-name="Path 2924" d="M515.482,424.693c-102.026,0-207.255-66.265-312.844-197.029l3.311-2.675C312.155,356.519,417.736,422.315,519.7,420.4c99.111-1.811,197.892-67.535,293.6-195.348l3.407,2.552C720.175,356.521,620.274,422.818,519.782,424.655Q517.633,424.694,515.482,424.693Z" transform="translate(-101.165 -16.445)" fill="#090814"/>
<path id="Path_2925-222" data-name="Path 2925" d="M511.075,410.395c-96.371,0-195.758-61.448-295.485-182.712l3.287-2.7C319.169,346.93,418.815,407.834,515.151,406.1c93.523-1.716,186.738-62.634,277.056-181.061l3.384,2.581C704.445,347.134,610.117,408.616,515.229,410.357,513.846,410.382,512.459,410.395,511.075,410.395Z" transform="translate(-97.081 -16.448)" fill="#090814"/>
<path id="Path_2926-223" data-name="Path 2926" d="M752.237,332.535c-1.475-1.264-2.961-2.54-4.436-3.827q-47.772-41.592-95.789-101.045l3.312-2.669q45.594,56.48,90.967,96.726,2.282,2.037,4.577,4.026Q751.587,329.135,752.237,332.535Z" transform="translate(40.542 -16.444)" fill="#090814"/>
<path id="Path_2927-224" data-name="Path 2927" d="M743.25,310.653q-2.476-2.212-4.939-4.518a785.148,785.148,0,0,1-73.352-78.455l3.289-2.7a793.373,793.373,0,0,0,67.816,73.247c1.732,1.65,3.453,3.265,5.185,4.846v.012C741.939,305.6,742.618,308.125,743.25,310.653Z" transform="translate(44.625 -16.449)" fill="#090814"/>
<rect id="Rectangle_681" data-name="Rectangle 681" width="4.256" height="184.538" transform="translate(146.992 243.456)" fill="#090814"/>
<rect id="Rectangle_682" data-name="Rectangle 682" width="4.256" height="144.025" transform="translate(186.583 283.969)" fill="#090814"/>
<rect id="Rectangle_683" data-name="Rectangle 683" width="4.256" height="100.05" transform="translate(240.515 329.008)" fill="#090814"/>
<rect id="Rectangle_684" data-name="Rectangle 684" width="4.256" height="67.029" transform="translate(292.142 360.965)" fill="#090814"/>
<rect id="Rectangle_685" data-name="Rectangle 685" width="4.256" height="51.089" transform="translate(334.981 379.033)" fill="#090814"/>
<rect id="Rectangle_686" data-name="Rectangle 686" width="4.256" height="184.538" transform="translate(736.898 243.456)" fill="#090814"/>
<rect id="Rectangle_687" data-name="Rectangle 687" width="4.256" height="144.025" transform="translate(776.489 283.969)" fill="#090814"/>
<path id="Path_2928-225" data-name="Path 2928" d="M242.886,227.663q-22.191,27.476-44.348,51.172-2.142,2.265-4.249,4.506-17.7,18.592-35.336,34.762-2.141,1.966-4.26,3.874c-2.06,1.849-4.109,3.675-6.168,5.478-.48.421-.948.843-1.428,1.252-1.006.878-2.025,1.756-3.043,2.622.457-2.282.925-4.553,1.416-6.835v-.012c1.054-.9,2.1-1.826,3.137-2.762.491-.433.972-.866,1.463-1.3v-.012q2.317-2.054,4.623-4.167,2.141-1.931,4.26-3.921,17.645-16.345,35.336-35.2c1.416-1.51,2.832-3.02,4.249-4.553q20.506-22.139,41.036-47.579Z" transform="translate(-119.639 -16.444)" fill="#090814"/>
<path id="Path_2929-226" data-name="Path 2929" d="M224.65,227.68Q210.762,244.57,196.9,259.9c-1.276,1.4-2.54,2.8-3.815,4.178q-17.856,19.47-35.687,36.342-1.967,1.861-3.909,3.675c-.2.2-.41.386-.609.562-.527.5-1.053.983-1.58,1.475-1.136,1.065-2.271,2.107-3.418,3.137q.983-3.845,2.072-7.678,1.808-1.65,3.593-3.371c.55-.515,1.089-1.03,1.639-1.557q18.4-17.469,36.834-37.735,14.66-16.117,29.343-33.955Z" transform="translate(-118.432 -16.449)" fill="#090814"/>
<rect id="Rectangle_688" data-name="Rectangle 688" width="4.256" height="184.538" transform="translate(74.648 243.456)" fill="#090814"/>
<rect id="Rectangle_689" data-name="Rectangle 689" width="4.256" height="144.025" transform="translate(35.057 283.969)" fill="#090814"/>
<rect id="Rectangle_690" data-name="Rectangle 690" width="4.256" height="188.793" transform="translate(672.109 239.2)" fill="#090814"/>
<rect id="Rectangle_691" data-name="Rectangle 691" width="4.256" height="144.025" transform="translate(632.515 283.969)" fill="#090814"/>
<rect id="Rectangle_692" data-name="Rectangle 692" width="4.256" height="100.05" transform="translate(578.586 329.008)" fill="#090814"/>
<rect id="Rectangle_693" data-name="Rectangle 693" width="4.256" height="67.029" transform="translate(526.959 360.965)" fill="#090814"/>
<rect id="Rectangle_694" data-name="Rectangle 694" width="4.256" height="51.089" transform="translate(484.118 379.033)" fill="#090814"/>
<rect id="Rectangle_695" data-name="Rectangle 695" width="4.256" height="38.53" transform="translate(401.616 391.592)" fill="#090814"/>
<path id="Path_2930-227" data-name="Path 2930" d="M438.4,547.825H340.018a5.725,5.725,0,1,1,0-11.449H438.4a5.725,5.725,0,0,1,0,11.449Z" transform="translate(-59.648 81.748)" fill="#fff"/>
<path id="Path_2931-228" data-name="Path 2931" d="M558.407,521.032H460.025a5.725,5.725,0,1,1,0-11.449h98.381a5.724,5.724,0,1,1,0,11.449Z" transform="translate(-21.805 73.299)" fill="#fff"/>
<path id="Path_2932-229" data-name="Path 2932" d="M509.8,585.3H411.415a5.725,5.725,0,0,1,0-11.449H509.8a5.725,5.725,0,0,1,0,11.449Z" transform="translate(-37.134 93.564)" fill="#fff"/>
<path id="Path_2933-230" data-name="Path 2933" d="M362.048,212.05a1.028,1.028,0,1,1,0-2.057h68.076A32.535,32.535,0,0,1,461.6,185.127a45.245,45.245,0,0,1,82.18-9.652c1.012-.079,2-.119,2.958-.119a38.41,38.41,0,0,1,38.2,35.592,1.028,1.028,0,0,1-.954,1.1l-.073,0a1.028,1.028,0,0,1-1.025-.956Z" transform="translate(-51.22 -39.069)" fill="#e6e6e6"/>
<path id="Path_2934-231" data-name="Path 2934" d="M438.029,219.807H381.465a1.028,1.028,0,1,1,0-2.057h56.564a1.028,1.028,0,1,1,0,2.057Z" transform="translate(-45.097 -18.728)" fill="#e6e6e6"/>
<path id="Path_2935-232" data-name="Path 2935" d="M451.665,113.7l8.553-6.841c-6.644-.733-9.374,2.891-10.492,5.759-5.191-2.155-10.842.669-10.842.669L456,119.5A12.949,12.949,0,0,0,451.665,113.7Z" transform="translate(-26.666 -53.727)" fill="#090814"/>
<path id="Path_2936-233" data-name="Path 2936" d="M474.8,248.956l8.553-6.841c-6.644-.733-9.374,2.891-10.492,5.759-5.191-2.155-10.842.669-10.842.669l17.113,6.213a12.949,12.949,0,0,0-4.332-5.8Z" transform="translate(-19.371 -11.075)" fill="#090814"/>
<path id="Path_2937-234" data-name="Path 2937" d="M626.963,198.235l8.553-6.841c-6.644-.733-9.374,2.891-10.492,5.759-5.191-2.155-10.842.669-10.842.669l17.113,6.213A12.95,12.95,0,0,0,626.963,198.235Z" transform="translate(28.613 -27.069)" fill="#090814"/>
<rect id="Rectangle_696" data-name="Rectangle 696" width="83.102" height="14.045" transform="translate(67.887 512.07)" fill="#090814"/>
<rect id="Rectangle_697" data-name="Rectangle 697" width="83.102" height="14.045" transform="translate(663.644 512.07)" fill="#090814"/>
<rect id="Rectangle_698" data-name="Rectangle 698" width="778.085" height="3.511" transform="translate(19.376 414.524)" fill="#090814"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 13 KiB

+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="32mm" height="32mm" version="1.1" viewBox="0 0 32 32" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:serif="http://www.serif.com/" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><linearGradient id="d" x2="1" gradientTransform="matrix(388.04 -111.5 84.017 279.39 391.79 1018.9)" gradientUnits="userSpaceOnUse"><stop stop-color="#3bb549" offset="0"/><stop stop-color="#f5ee30" offset="1"/></linearGradient><linearGradient id="c" x2=".95791" y2=".0001803" gradientTransform="matrix(122.94 -266.68 378.61 181.95 228.98 392.44)" gradientUnits="userSpaceOnUse"><stop stop-color="#f5ee30" offset="0"/><stop stop-color="#3bb549" offset="1"/></linearGradient><linearGradient id="b" x2="1" gradientTransform="matrix(208.13 0 0 420.73 148.9 534.09)" gradientUnits="userSpaceOnUse"><stop stop-color="#f1592a" offset="0"/><stop stop-color="#f5e732" offset="1"/></linearGradient><linearGradient id="a" x2="1" gradientTransform="matrix(401.83 285.46 -279.32 410.66 391.79 578.6)" gradientUnits="userSpaceOnUse"><stop stop-color="#f5e732" offset="0"/><stop stop-color="#f1592a" offset="1"/></linearGradient></defs><g transform="translate(-1082.3 -67.993)"><g transform="matrix(.02877 0 0 .027964 1085.1 67.175)" clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" stroke-width="8.4072" serif:id="Logo IfcOpenShell"><path d="m744.02 832.33c18.051 11.403 55.982 31.518 63.628 94.935l-0.12525 23.628c-8.342 104.33-183.41 185.58-415.74 186.93v-215.59c141.61-0.7966 269.74-30.595 352.23-89.901z" fill="url(#d)" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" stroke-width="8.1335"/><path d="m181.03 442.16c-12.017-10.232-30.084-43.15-32.067-93.421l-0.0617-16.621c1.4066-109.27 91.01-193.98 208.13-195.34v214.31c-47.868 0.55282-122.9 15.206-176 91.069z" fill="url(#c)" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" stroke-width="8.3203"/><path d="m148.9 334.48c1.4117 109.69 91.548 194.06 208.13 195.34v214.63c-116.74-1.3026-206.72-85.776-208.13-195.34z" fill="url(#b)" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" stroke-width="8.3203"/><path d="m391.79 744.46v-214.64c235.46 1.3846 413.03 88.962 415.86 200.34v214.64c-2.8649-112.42-183.57-199-415.86-200.34z" fill="url(#a)" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" stroke-width="8.3164"/><rect transform="matrix(4.3448 0 0 4.47 -94.827 29.251)" x="-8.2421e-7" y="-1.0518e-16" width="256" height="256" fill="none"/></g></g></svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

+194
View File
@@ -0,0 +1,194 @@
import ifcopenshell
import ifcopenshell.util
import ifcopenshell.util.pset
import ifcopenshell.util.schema
import xml.etree.ElementTree as ET
from xmlschema.validators.exceptions import XMLSchemaValidationError
from ifctester.ids import Ids, IdsXmlValidationError, get_schema
# https://github.com/buildingSMART/IDS/blob/9914d568c7ac037acd97e58a0d16e9f93c3e3416/Schema/ids.xsd#L232
ifc_schemas = ["IFC2X3", "IFC4", "IFC4X3_ADD2"]
def get_predefined_types_for_entity(schema_name, entity_name):
"""Get a list of predefined types for a given entity."""
schema = ifcopenshell.schema_by_name(schema_name)
try:
entity = schema.declaration_by_name(entity_name)
except:
return []
if not entity or not entity.as_entity():
print(f"Entity {entity_name} not found")
return []
entity = entity.as_entity()
predefined_type_attr = None
# Check all attributes for "PredefinedType"
for attr in entity.all_attributes():
if attr.name() == "PredefinedType":
predefined_type_attr = attr
break
if not predefined_type_attr:
return []
param_type = predefined_type_attr.type_of_attribute()
if param_type.as_named_type():
enum_decl = param_type.as_named_type().declared_type()
if enum_decl.as_enumeration_type():
return enum_decl.as_enumeration_type().enumeration_items()
return []
def get_all_entity_classes(schema_name):
"""Get all IFC entity classes in the given schema."""
schema = ifcopenshell.schema_by_name(schema_name)
entities = []
for entity in schema.entities():
entities.append(entity.name())
# Sort alphabetically
entities.sort()
return entities
def get_all_data_types(schema_name):
"""Get all data types in the given schema."""
schema = ifcopenshell.schema_by_name(schema_name)
return {
d.name(): ifcopenshell.util.attribute.get_primitive_type(d)
for d in schema.declarations()
if d.as_type_declaration()
}
def get_entity_attributes(schema_name, entity_name):
"""Get all attributes for a given entity."""
schema = ifcopenshell.schema_by_name(schema_name)
try:
entity = schema.declaration_by_name(entity_name)
except:
return []
if not entity or not entity.as_entity():
print(f"Entity {entity_name} not found")
return []
entity = entity.as_entity()
attributes = []
for attr in entity.all_attributes():
attributes.append(
{
"name": attr.name(),
# "type": attr.type_of_attribute() # TODO Types of attribute
}
)
return attributes
def get_applicable_psets(schema_name, entity_name, predefined_type=""):
"""Get all applicable property and quantity sets for a given entity."""
pset_qto = ifcopenshell.util.pset.PsetQto(schema_name)
pset_names = pset_qto.get_applicable_names(entity_name, predefined_type)
return pset_names
def get_all_psets(schema_name):
"""Get all property sets and quantity sets defined in an IFC schema"""
pset_qto = ifcopenshell.util.pset.PsetQto(schema_name)
result = {}
for template_file in pset_qto.templates:
for pset_template in template_file.by_type("IfcPropertySetTemplate"):
pset_name = pset_template.Name
properties = []
# Get property templates for this pset
if pset_template.HasPropertyTemplates:
for prop_template in pset_template.HasPropertyTemplates:
prop_info = {
"name": prop_template.Name,
# "description": prop_template.Description
}
# Extract type information
if prop_template.is_a("IfcSimplePropertyTemplate"):
if prop_template.PrimaryMeasureType:
prop_info["type"] = prop_template.PrimaryMeasureType
else:
prop_info["type"] = str(prop_template.TemplateType)
elif prop_template.is_a("IfcComplexPropertyTemplate"):
prop_info["type"] = None # Complex properties are not supported
else:
prop_info["type"] = None
properties.append(prop_info)
result[pset_name] = properties
return result
def get_material_categories():
return ["concrete", "steel", "aluminium", "block", "brick", "stone", "wood", "glass", "gypsum", "plastic", "earth"]
def get_standard_classification_systems():
return {
"BB/SfB (3/4 cijfers)": {"source": "Regie der Gebouwen", "tokens": ["."]},
"BIMTypeCode": {"source": "BIMStockholm", "tokens": None},
"Common Arrangement of Work Sections (CAWS)": {"source": "NBS", "tokens": ["/"]},
"CBI Classification - Level 2": {"source": "Masterspec", "tokens": None},
"CBI Classification - Level 4": {"source": "Masterspec", "tokens": None},
"Rumsfunktionskoder CC001 - 001": {"source": "BIMAlliance", "tokens": ["-"]},
"CCS": {"source": "Molio", "tokens": None},
"CCTB": {"source": "CCT-Bâtiments", "tokens": ["."]},
"Funktionskoder Regionservice CD001 - 001": {"source": "BIMAlliance", "tokens": None},
"Rumsfunktion Blekinge CD002 - 001": {"source": "BIMAlliance", "tokens": None},
"EcoQuaestor Codetabel": {"source": "EcoQuaestor", "tokens": [".", "-"]},
"GuBIMclass CA": {"source": "GuBIMClass", "tokens": ["."]},
"GuBIMclass ES": {"source": "GuBIMClass", "tokens": ["."]},
"MasterFormat": {"source": "CSI", "tokens": [" ", "."]},
"NATSPEC Worksections": {"source": "NATSPEC", "tokens": None},
"NBS Create": {"source": "NBS", "tokens": ["_", "/"]},
"NL/SfB (4 cijfers)": {"source": "BIMLoket", "tokens": ["."]},
"NS 3451 - Bygningsdelstabell": {"source": "Standard Norge", "tokens": None},
"OmniClass": {"source": "OmniClass", "tokens": ["-", " "]},
"ÖNORM 6241-2": {"source": "freeBIM 2", "tokens": None},
"RICS NRM1": {"source": "RICS", "tokens": ["."]},
"RICS NRM3": {"source": "RICS", "tokens": ["."]},
"SFG20": {"source": "SFG20", "tokens": ["-"]},
"SINAPI": {"source": "Caixa", "tokens": ["/"]},
"STABU-Element": {"source": "STABU", "tokens": ["."]},
"TALO 2000 Building Component Classification": {"source": "Rakennustieto", "tokens": ["."]},
"TALO 2000 Hankenimikkeistö": {"source": "Rakennustieto", "tokens": ["."]},
"Uniclass": {"source": "RIBA Enterprises Ltd", "tokens": ["_"]},
"Uniclass 2015": {"source": "RIBA Enterprises Ltd", "tokens": ["_"]},
"UniFormat": {"source": "UniFormat", "tokens": ["."]},
"Uniformat": {"source": "UniFormat", "tokens": ["."]},
"VMSW": {"source": "VMSW", "tokens": ["."]},
}
def ids_from_xml_string(xml: str, validate: bool = False) -> Ids:
try:
decode = get_schema().decode(
xml, strip_namespaces=True, namespaces={"": "http://standards.buildingsmart.org/IDS"}
)
except XMLSchemaValidationError as e:
raise IdsXmlValidationError(e, "Provided XML appears to be invalid. See details above.")
return Ids().parse(decode)
+76
View File
@@ -0,0 +1,76 @@
# IfcTester - IDS based model auditing
# Copyright (C) 2022 Dion Moult <dion@thinkmoult.com>
#
# This file is part of IfcTester.
#
# IfcTester is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcTester is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcTester. If not, see <http://www.gnu.org/licenses/>.
import os
import argparse
from flask import Flask, send_from_directory, send_file
app = Flask(__name__)
def get_static_folder():
base_dir = os.path.dirname(__file__)
dist_dir = os.path.join(base_dir, "dist")
www_dir = os.path.join(base_dir, "www")
if os.path.exists(dist_dir) and os.path.isdir(dist_dir):
return dist_dir
elif os.path.exists(www_dir) and os.path.isdir(www_dir):
return www_dir
else:
return dist_dir
STATIC_FOLDER = get_static_folder()
@app.route("/")
def index():
return send_file(os.path.join(STATIC_FOLDER, "index.html"))
@app.route("/<path:filename>")
def static_files(filename):
return send_from_directory(STATIC_FOLDER, filename)
@app.route("/assets/<path:filename>")
def assets(filename):
return send_from_directory(os.path.join(STATIC_FOLDER, "assets"), filename)
@app.errorhandler(404)
def not_found(error):
return send_file(os.path.join(STATIC_FOLDER, "index.html"))
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Start IfcTester webapp")
parser.add_argument("--host", default="127.0.0.1", help="Host to bind to (default: 127.0.0.1)")
parser.add_argument("--port", type=int, default=5000, help="Port to bind to (default: 5000)")
parser.add_argument("--debug", action="store_true", help="Enable debug mode")
parser.add_argument("--dist-dir", default=STATIC_FOLDER, help="Directory containing built files")
args = parser.parse_args()
STATIC_FOLDER = args.dist_dir
print(f"Serving IfcTester webapp from: {STATIC_FOLDER}")
print(f"Server running at: http://{args.host}:{args.port}")
app.run(host=args.host, port=args.port, debug=args.debug)
+6
View File
@@ -0,0 +1,6 @@
<script>
import Router from 'svelte-spa-router';
import routes from './routes';
</script>
<Router {routes} />

Some files were not shown because too many files have changed in this diff Show More