diff --git a/.github/workflows/build_osx.yml b/.github/workflows/build_osx.yml index 0f6bb702e9..4c6cb9312c 100644 --- a/.github/workflows/build_osx.yml +++ b/.github/workflows/build_osx.yml @@ -53,7 +53,7 @@ jobs: python ../nix/cache_dependencies.py unpack - name: ccache - uses: hendrikmuhs/ccache-action@v1.2.20 + uses: hendrikmuhs/ccache-action@v1.2.22 with: key: mac-${{ matrix.arch }} diff --git a/.github/workflows/build_pyodide.yml b/.github/workflows/build_pyodide.yml index 7da3bb408c..a5e80a32df 100644 --- a/.github/workflows/build_pyodide.yml +++ b/.github/workflows/build_pyodide.yml @@ -29,7 +29,7 @@ jobs: python ../IfcOpenShell/nix/cache_dependencies.py unpack - name: ccache - uses: hendrikmuhs/ccache-action@v1.2.20 + uses: hendrikmuhs/ccache-action@v1.2.22 with: key: ubuntu-22.04-${{ runner.arch }} diff --git a/.github/workflows/build_rocky.yml b/.github/workflows/build_rocky.yml index 710b918564..9ac489378b 100644 --- a/.github/workflows/build_rocky.yml +++ b/.github/workflows/build_rocky.yml @@ -48,7 +48,7 @@ jobs: python3 ../nix/cache_dependencies.py unpack - name: ccache - uses: hendrikmuhs/ccache-action@v1.2.20 + uses: hendrikmuhs/ccache-action@v1.2.22 with: key: ubuntu-22.04-${{ runner.arch }}-rockylinux9 diff --git a/.github/workflows/build_rocky_arm.yml b/.github/workflows/build_rocky_arm.yml index b54e62ccef..208e1da9da 100644 --- a/.github/workflows/build_rocky_arm.yml +++ b/.github/workflows/build_rocky_arm.yml @@ -48,7 +48,7 @@ jobs: python3 ../nix/cache_dependencies.py unpack - name: ccache - uses: hendrikmuhs/ccache-action@v1.2.20 + uses: hendrikmuhs/ccache-action@v1.2.22 with: key: ubuntu-22.04-${{ runner.arch }}-rockylinux9 diff --git a/.github/workflows/build_win.yml b/.github/workflows/build_win.yml index c2ae75143d..7880296a2a 100644 --- a/.github/workflows/build_win.yml +++ b/.github/workflows/build_win.yml @@ -52,7 +52,7 @@ jobs: } - name: ccache - uses: hendrikmuhs/ccache-action@v1.2.20 + uses: hendrikmuhs/ccache-action@v1.2.22 with: key: win-${{ matrix.arch }} # Windows ccache needs ~1GB diff --git a/.github/workflows/ci-ifcopenshell-conda-cleaner.yml b/.github/workflows/ci-ifcopenshell-conda-cleaner.yml index 5c1b80bcc6..c34cf50e0f 100644 --- a/.github/workflows/ci-ifcopenshell-conda-cleaner.yml +++ b/.github/workflows/ci-ifcopenshell-conda-cleaner.yml @@ -24,7 +24,7 @@ jobs: if: | github.repository == 'IfcOpenShell/IfcOpenShell' steps: - - uses: mamba-org/setup-micromamba@v2 # https://github.com/mamba-org/setup-micromamba + - uses: mamba-org/setup-micromamba@v3 # https://github.com/mamba-org/setup-micromamba with: environment-name: test-env create-args: >- diff --git a/.github/workflows/ci-ifcopenshell-conda-daily.yml b/.github/workflows/ci-ifcopenshell-conda-daily.yml index b648538ed7..3d18316963 100644 --- a/.github/workflows/ci-ifcopenshell-conda-daily.yml +++ b/.github/workflows/ci-ifcopenshell-conda-daily.yml @@ -84,7 +84,7 @@ jobs: run: | curl -L https://github.com/phracker/MacOSX-SDKs/releases/download/11.3/MacOSX10.13.sdk.tar.xz | tar -xvJf - -C /Users/runner/work/ - - uses: mamba-org/setup-micromamba@v2 # https://github.com/mamba-org/setup-micromamba + - uses: mamba-org/setup-micromamba@v3 # https://github.com/mamba-org/setup-micromamba with: environment-name: test-env create-args: >- diff --git a/.github/workflows/ci-ifcopenshell-docker.yml b/.github/workflows/ci-ifcopenshell-docker.yml index 2eee5eff1a..df672139b8 100644 --- a/.github/workflows/ci-ifcopenshell-docker.yml +++ b/.github/workflows/ci-ifcopenshell-docker.yml @@ -35,7 +35,7 @@ jobs: - name: ccache - uses: hendrikmuhs/ccache-action@v1.2.20 + uses: hendrikmuhs/ccache-action@v1.2.22 - name: Build ifcopenshell diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb044eeab3..a49c92fcd7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,7 +79,7 @@ jobs: libhdf5-dev libcgal-dev libeigen3-dev - name: ccache - uses: hendrikmuhs/ccache-action@v1.2.20 + uses: hendrikmuhs/ccache-action@v1.2.22 with: key: ubuntu-22.04-${{ runner.arch }} diff --git a/.github/workflows/publish-pyodide-demo-app.yml b/.github/workflows/publish-pyodide-demo-app.yml index 4aebe4266e..d7107c410d 100644 --- a/.github/workflows/publish-pyodide-demo-app.yml +++ b/.github/workflows/publish-pyodide-demo-app.yml @@ -31,7 +31,7 @@ jobs: submodules: recursive fetch-depth: 0 - name: Setup Pages - uses: actions/configure-pages@v5 + uses: actions/configure-pages@v6 - name: Upload static files as artifact id: deployment uses: actions/upload-pages-artifact@v4 @@ -47,4 +47,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 diff --git a/.gitignore b/.gitignore index 395086c420..80fbb01a00 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ /src/ifcmax/out/ /src/ifcwrap/out/ /src/qtviewer/out/ +/src/ifctester/webapp/public/pyodide/ /win/BuildDepsCache*.txt @@ -85,10 +86,11 @@ src/bonsai/bonsai/translations.py # bonsai external dependencies (cloned for just ty checks) src/bonsai/external_dependencies/ -# bonsai test temp files +# bonsai test temp/cache files src/bonsai/test/files/temp -src/bonsai/test/files/basic.ifc.cache.blend -src/bonsai/test/files/basic.ifc.cache.sqlite +src/bonsai/test/files/*.cache.blend +src/bonsai/test/files/*.cache.json +src/bonsai/test/files/*.cache.sqlite # bonsai data src/bonsai/bonsai/bim/data/build/ diff --git a/pyproject.toml b/pyproject.toml index a306ffa166..330f6150d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "IfcOpenShell" version = "0.0.0" dependencies = [ "black==26.3.1", - "ruff==0.15.6", + "ruff==0.15.8", "poethepoet", "gersemi==0.26.1", ] diff --git a/src/bonsai/bonsai/bim/module/boundary/operator.py b/src/bonsai/bonsai/bim/module/boundary/operator.py index 2c6a6de2cf..45ee8c60e2 100644 --- a/src/bonsai/bonsai/bim/module/boundary/operator.py +++ b/src/bonsai/bonsai/bim/module/boundary/operator.py @@ -377,6 +377,8 @@ class EnableEditingBoundary(bpy.types.Operator): obj = tool.Ifc.get_object(entity) if entity and obj: setattr(bprops, blender_property, obj) + bprops.physical_or_virtual = boundary.PhysicalOrVirtualBoundary or "NOTDEFINED" + bprops.internal_or_external = boundary.InternalOrExternalBoundary or "NOTDEFINED" return {"FINISHED"} @@ -392,6 +394,8 @@ class DisableEditingBoundary(bpy.types.Operator): bprops.is_editing = False for ifc_attribute, blender_property in EDITABLE_ATTRIBUTES.items(): setattr(bprops, blender_property, None) + bprops.physical_or_virtual = "NOTDEFINED" + bprops.internal_or_external = "NOTDEFINED" return {"FINISHED"} @@ -411,6 +415,8 @@ class EditBoundaryAttributes(bpy.types.Operator, tool.Ifc.Operator): obj = getattr(bprops, blender_property, None) entity = tool.Ifc.get_entity(obj) attributes[blender_property] = entity + attributes["physical_or_virtual"] = bprops.physical_or_virtual + attributes["internal_or_external"] = bprops.internal_or_external ifcopenshell.api.boundary.edit_attributes(tool.Ifc.get(), entity=boundary, **attributes) bpy.ops.bim.disable_editing_boundary() return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/boundary/prop.py b/src/bonsai/bonsai/bim/module/boundary/prop.py index 2e9ab8c975..7b4f5069ad 100644 --- a/src/bonsai/bonsai/bim/module/boundary/prop.py +++ b/src/bonsai/bonsai/bim/module/boundary/prop.py @@ -21,6 +21,7 @@ from typing import TYPE_CHECKING, Union import bpy from bpy.props import ( BoolProperty, + EnumProperty, PointerProperty, ) from bpy.types import PropertyGroup @@ -50,12 +51,43 @@ def element_filter(self: "BIMObjectBoundaryProperties", object: bpy.types.Object return False +def get_internal_or_external_items( + self: "BIMObjectBoundaryProperties", context: bpy.types.Context | None +) -> list[tuple[str, str, str]]: + items = [ + ("INTERNAL", "Internal", ""), + ("EXTERNAL", "External", ""), + ] + ifc = tool.Ifc.get() + if not ifc or ifc.schema != "IFC2X3": + items += [ + ("EXTERNAL_EARTH", "External Earth", ""), + ("EXTERNAL_WATER", "External Water", ""), + ("EXTERNAL_FIRE", "External Fire", ""), + ] + items.append(("NOTDEFINED", "Not Defined", "")) + return items + + class BIMObjectBoundaryProperties(PropertyGroup): is_editing: BoolProperty(name="Is Editing") relating_space: PointerProperty(name="RelatingSpace", type=bpy.types.Object, poll=space_filter) related_building_element: PointerProperty(name="RelatedBuildingElement", type=bpy.types.Object, poll=element_filter) parent_boundary: PointerProperty(name="ParentBoundary", type=bpy.types.Object, poll=boundary_filter) corresponding_boundary: PointerProperty(name="CorrespondingBoundary", type=bpy.types.Object, poll=boundary_filter) + physical_or_virtual: EnumProperty( + name="PhysicalOrVirtualBoundary", + items=[ + ("PHYSICAL", "Physical", ""), + ("VIRTUAL", "Virtual", ""), + ("NOTDEFINED", "Not Defined", ""), + ], + default="NOTDEFINED", + ) + internal_or_external: EnumProperty( + name="InternalOrExternalBoundary", + items=get_internal_or_external_items, + ) if TYPE_CHECKING: is_editing: bool @@ -63,6 +95,8 @@ class BIMObjectBoundaryProperties(PropertyGroup): related_building_element: Union[bpy.types.Object, None] parent_boundary: Union[bpy.types.Object, None] corresponding_boundary: Union[bpy.types.Object, None] + physical_or_virtual: str + internal_or_external: str # values depend on schema: IFC2X3 omits EXTERNAL_EARTH/WATER/FIRE class BIMBoundaryProperties(PropertyGroup): diff --git a/src/bonsai/bonsai/bim/module/boundary/ui.py b/src/bonsai/bonsai/bim/module/boundary/ui.py index 0ca21b9ce5..91990a5eb0 100644 --- a/src/bonsai/bonsai/bim/module/boundary/ui.py +++ b/src/bonsai/bonsai/bim/module/boundary/ui.py @@ -77,6 +77,10 @@ class BIM_PT_Boundary(Panel): self.draw_relation_editor(boundary, "RelatedBuildingElement", "related_building_element") self.draw_relation_editor(boundary, "ParentBoundary", "parent_boundary") self.draw_relation_editor(boundary, "CorrespondingBoundary", "corresponding_boundary") + row = self.layout.row() + row.prop(self.bprops, "physical_or_virtual") + row = self.layout.row() + row.prop(self.bprops, "internal_or_external") else: row = self.layout.row() row.operator("bim.enable_editing_boundary", icon="GREASEPENCIL", text="Edit") @@ -84,6 +88,8 @@ class BIM_PT_Boundary(Panel): self.draw_relation_data(boundary, "RelatedBuildingElement") self.draw_relation_data(boundary, "ParentBoundary") self.draw_relation_data(boundary, "CorrespondingBoundary") + self.draw_enum_data(boundary, "PhysicalOrVirtualBoundary") + self.draw_enum_data(boundary, "InternalOrExternalBoundary") if hasattr(boundary, "InnerBoundaries"): for i, inner_boundary in enumerate(getattr(boundary, "InnerBoundaries", ())): row = self.layout.row(align=True) @@ -110,6 +116,11 @@ class BIM_PT_Boundary(Panel): else: row.label(text="") + def draw_enum_data(self, boundary, ifc_attribute: str): + row = self.layout.row(align=True) + row.label(text=ifc_attribute) + row.label(text=getattr(boundary, ifc_attribute, "") or "") + def draw_relation_editor(self, boundary, ifc_attribute: str, blender_property: str): if hasattr(boundary, ifc_attribute): row = self.layout.row(align=True) diff --git a/src/bonsai/bonsai/bim/module/model/polyline.py b/src/bonsai/bonsai/bim/module/model/polyline.py index e33fe2cf5d..be491e756b 100644 --- a/src/bonsai/bonsai/bim/module/model/polyline.py +++ b/src/bonsai/bonsai/bim/module/model/polyline.py @@ -421,7 +421,7 @@ class PolylineOperator: tool.Polyline.calculate_x_y_and_z(context, self.input_ui, self.tool_state) tool.Blender.update_viewport() - return {"RUNNING_MODAL"} + return {"RUNNING_MODAL"} def set_offset(self, context: bpy.types.Context, relating_type: ifcopenshell.entity_instance) -> None: props = tool.Model.get_model_props() @@ -461,6 +461,7 @@ class PolylineOperator: self.tool_state.axis_method = None self.tool_state.plane_method = None self.tool_state.mode = "Mouse" + tool.Raycast.clear_snap_objs() self.visible_objs = tool.Raycast.get_visible_objects(context) for obj in self.visible_objs: if bbox_2d := tool.Raycast.get_on_screen_2d_bounding_boxes(context, obj): diff --git a/src/bonsai/bonsai/bim/module/model/workspace.py b/src/bonsai/bonsai/bim/module/model/workspace.py index 5f7cf7699d..828fc2c21f 100644 --- a/src/bonsai/bonsai/bim/module/model/workspace.py +++ b/src/bonsai/bonsai/bim/module/model/workspace.py @@ -943,7 +943,7 @@ class EditObjectUI: if "LAYER2" in AuthoringData.data["selected_material_usages"]: row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row add_layout_hotkey_operator( - cls.layout, "Extend To Underside", "S_E", bpy.ops.bim.extend_to_underside.__doc__, ui_context + cls.layout, "Extend To Underside", "S_E", bpy.ops.bim.extend_walls_to_underside.__doc__, ui_context ) if AuthoringData.data["is_flippable_element"]: diff --git a/src/bonsai/bonsai/core/covering.py b/src/bonsai/bonsai/core/covering.py index a8c7a73f25..2ab0d39cc4 100644 --- a/src/bonsai/bonsai/core/covering.py +++ b/src/bonsai/bonsai/core/covering.py @@ -51,21 +51,11 @@ def add_instance_flooring_covering_from_cursor( if isinstance(space_polygon, str): return - bm = spatial.get_bmesh_from_polygon(space_polygon, h=0, polygon_is_si=True) - name = "Covering" - mesh = spatial.get_named_mesh_from_bmesh(name=name, bmesh=bm) - - obj = spatial.get_named_obj_from_mesh(name, mesh) - + obj = spatial.create_object("Covering") spatial.set_obj_origin_to_cursor_position_and_zero_elevation(obj) spatial.translate_obj_to_z_location(obj, z) - points = spatial.get_2d_vertices_from_obj(obj) - points = spatial.get_scaled_2d_vertices(points) spatial.assign_type_to_obj(obj) - - spatial.assign_swept_area_outer_curve_from_2d_vertices(obj, vertices=points) - body = spatial.get_body_representation(obj) - spatial.regen_obj_representation(obj, body) + spatial.set_covering_representation_from_polygon(obj, space_polygon, polygon_is_si=True) def add_instance_ceiling_covering_from_cursor( @@ -95,21 +85,11 @@ def add_instance_ceiling_covering_from_cursor( if isinstance(space_polygon, str): return - bm = spatial.get_bmesh_from_polygon(space_polygon, h=0, polygon_is_si=True) - name = "Covering" - mesh = spatial.get_named_mesh_from_bmesh(name=name, bmesh=bm) - - obj = spatial.get_named_obj_from_mesh(name, mesh) - + obj = spatial.create_object("Covering") spatial.set_obj_origin_to_cursor_position_and_zero_elevation(obj) spatial.translate_obj_to_z_location(obj, z + ceiling_height) - points = spatial.get_2d_vertices_from_obj(obj) - points = spatial.get_scaled_2d_vertices(points) spatial.assign_type_to_obj(obj) - - spatial.assign_swept_area_outer_curve_from_2d_vertices(obj, vertices=points) - body = spatial.get_body_representation(obj) - spatial.regen_obj_representation(obj, body) + spatial.set_covering_representation_from_polygon(obj, space_polygon, polygon_is_si=True) def regen_selected_covering_object(root: type[tool.Root], spatial: type[tool.Spatial]) -> None: @@ -127,19 +107,7 @@ def regen_selected_covering_object(root: type[tool.Root], spatial: type[tool.Spa if isinstance(space_polygon, str): return - bm = spatial.get_bmesh_from_polygon(space_polygon, h=0, polygon_is_si=True) - - name = "Aux" - mesh = spatial.get_named_mesh_from_bmesh(name=name, bmesh=bm) - mesh = spatial.get_transformed_mesh_from_local_to_global(mesh) - obj = spatial.get_named_obj_from_mesh(name, mesh) - - points = spatial.get_2d_vertices_from_obj(obj) - points = spatial.get_scaled_2d_vertices(points) - - spatial.assign_swept_area_outer_curve_from_2d_vertices(active_obj, vertices=points) - body = spatial.get_body_representation(active_obj) - spatial.regen_obj_representation(active_obj, body) + spatial.set_covering_representation_from_polygon(active_obj, space_polygon, polygon_is_si=True) # TODO CHECK IF IT IS POSSIBLE TO CREATE ONLY ONE CORE FUNCTION FOR _FROM_WALLS @@ -151,22 +119,13 @@ def add_instance_flooring_coverings_from_walls(root: type[tool.Root], spatial: t union = spatial.get_union_shape_from_selected_objects() for i, linear_ring in enumerate(union.interiors): poly = spatial.get_buffered_poly_from_linear_ring(linear_ring) - bm = spatial.get_bmesh_from_polygon(poly, h=0, polygon_is_si=False) name = "Covering" + str(i) - obj = spatial.get_named_obj_from_bmesh(name, bmesh=bm) - - spatial.set_obj_origin_to_bboxcenter(obj) + obj = spatial.create_object(name) + spatial.set_obj_origin_to_polygon_center(obj, poly, polygon_is_si=False) spatial.translate_obj_to_z_location(obj, z) - - points = spatial.get_2d_vertices_from_obj(obj) - points = spatial.get_scaled_2d_vertices(points) - spatial.assign_type_to_obj(obj) - - spatial.assign_swept_area_outer_curve_from_2d_vertices(obj, vertices=points) - body = spatial.get_body_representation(obj) - spatial.regen_obj_representation(obj, body) + spatial.set_covering_representation_from_polygon(obj, poly, polygon_is_si=False) def add_instance_ceiling_coverings_from_walls( @@ -179,22 +138,13 @@ def add_instance_ceiling_coverings_from_walls( union = spatial.get_union_shape_from_selected_objects() for i, linear_ring in enumerate(union.interiors): poly = spatial.get_buffered_poly_from_linear_ring(linear_ring) - bm = spatial.get_bmesh_from_polygon(poly, h=0, polygon_is_si=False) name = "Covering" + str(i) - obj = spatial.get_named_obj_from_bmesh(name, bmesh=bm) - - spatial.set_obj_origin_to_bboxcenter(obj) + obj = spatial.create_object(name) + spatial.set_obj_origin_to_polygon_center(obj, poly, polygon_is_si=False) spatial.translate_obj_to_z_location(obj, z) - - points = spatial.get_2d_vertices_from_obj(obj) - points = spatial.get_scaled_2d_vertices(points) - spatial.assign_type_to_obj(obj) - - spatial.assign_swept_area_outer_curve_from_2d_vertices(obj, vertices=points) - body = spatial.get_body_representation(obj) - spatial.regen_obj_representation(obj, body) + spatial.set_covering_representation_from_polygon(obj, poly, polygon_is_si=False) class NoDefaultContainer(Exception): diff --git a/src/bonsai/bonsai/core/spatial.py b/src/bonsai/bonsai/core/spatial.py index 5086a8a872..5ce6d7c253 100644 --- a/src/bonsai/bonsai/core/spatial.py +++ b/src/bonsai/bonsai/core/spatial.py @@ -219,13 +219,8 @@ def generate_space( else: assert space_polygon - bm = spatial.get_bmesh_from_polygon(space_polygon, h=h, polygon_is_si=True) - - mesh = spatial.get_named_mesh_from_bmesh(name="Space", bmesh=bm) - if element and element.is_a("IfcSpace"): - mesh = spatial.get_transformed_mesh_from_local_to_global(mesh) - spatial.edit_active_space_obj_from_mesh(mesh) + spatial.set_space_representation_from_polygon(active_obj, element, space_polygon, h, polygon_is_si=True) spatial.translate_obj_to_z_location(active_obj, z) else: if relating_type: @@ -233,12 +228,13 @@ def generate_space( else: name = "Space" - obj = spatial.get_named_obj_from_mesh(name, mesh) + obj = spatial.create_object(name) spatial.set_obj_origin_to_cursor_position_and_zero_elevation(obj) spatial.translate_obj_to_z_location(obj, z) spatial.assign_ifcspace_class_to_obj(obj) element = ifc.get_entity(obj) + spatial.set_space_representation_from_polygon(obj, element, space_polygon, h, polygon_is_si=True) if relating_type: spatial.assign_relating_type_to_element(ifc, type, element, relating_type) @@ -257,16 +253,16 @@ def generate_spaces_from_walls( for i, linear_ring in enumerate(union.interiors): poly = spatial.get_buffered_poly_from_linear_ring(linear_ring) - bm = spatial.get_bmesh_from_polygon(poly, h, polygon_is_si=False) - name = "Space" + str(i) - obj = spatial.get_named_obj_from_bmesh(name, bmesh=bm) - - spatial.set_obj_origin_to_bboxcenter_and_zero_elevation(obj) + obj = spatial.create_object(name) + spatial.set_obj_origin_to_polygon_center(obj, poly, polygon_is_si=False) spatial.translate_obj_to_z_location(obj, z) spatial.assign_ifcspace_class_to_obj(obj) + element = ifc.get_entity(obj) + spatial.set_space_representation_from_polygon(obj, element, poly, h, polygon_is_si=False) + def toggle_space_visibility(ifc: type[tool.Ifc], spatial: type[tool.Spatial]) -> None: model = ifc.get() diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index 09a4fe90f0..f815bf0963 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -999,14 +999,12 @@ class Spatial: def get_purged_inner_holes_poly(cls, union_geom, min_area): pass def get_poly_valid_interior_list(cls, poly, min_area, interiors_list): pass def get_buffered_poly_from_linear_ring(cls, linear_ring): pass - def get_bmesh_from_polygon(cls, poly, h, polygon_is_si=False): pass - def get_named_obj_from_bmesh(cls, name, bmesh): pass - def get_named_obj_from_mesh(cls, name, mesh): pass - def get_named_mesh_from_bmesh(cls, name, bmesh): pass - def get_transformed_mesh_from_local_to_global(cls, mesh): pass - def edit_active_space_obj_from_mesh(cls, mesh): pass - def set_obj_origin_to_bboxcenter(cls, obj): pass - def set_obj_origin_to_bboxcenter_and_zero_elevation(cls, obj): pass + def get_2d_vertices_from_polygon(cls, poly, obj, polygon_is_si=True): pass + def set_extrusion_representation_from_polygon(cls, obj, element, poly, depth_ifc, polygon_is_si=True): pass + def set_space_representation_from_polygon(cls, obj, element, poly, h, polygon_is_si=True): pass + def set_covering_representation_from_polygon(cls, obj, poly, polygon_is_si=True): pass + def create_object(cls, name): pass + def set_obj_origin_to_polygon_center(cls, obj, poly, polygon_is_si=True): pass def set_obj_origin_to_cursor_position_and_zero_elevation(cls, obj): pass def get_selected_objects(cls): pass def get_active_obj(cls): pass @@ -1014,14 +1012,9 @@ class Spatial: def get_active_obj_height(cls): pass def get_relating_type_id(cls): pass def translate_obj_to_z_location(cls, obj, z): pass - def get_2d_vertices_from_obj(cls, obj): pass - def get_scaled_2d_vertices(cls, points): pass - def assign_swept_area_outer_curve_from_2d_vertices(cls, obj, vertices): pass - def get_body_representation(cls, obj): pass def assign_ifcspace_class_to_obj(cls, obj): pass def assign_type_to_obj(cls, obj): pass def assign_relating_type_to_element(cls, ifc, type, element, relating_type): pass - def regen_obj_representation(cls, obj, body): pass def toggle_spaces_visibility_wired_and_textured(cls, spaces): pass def toggle_hide_spaces(cls, spaces): pass def set_default_container(cls, container): pass diff --git a/src/bonsai/bonsai/tool/raycast.py b/src/bonsai/bonsai/tool/raycast.py index d98aaf26ab..70dae00438 100644 --- a/src/bonsai/bonsai/tool/raycast.py +++ b/src/bonsai/bonsai/tool/raycast.py @@ -16,6 +16,9 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +from __future__ import annotations + +import math from typing import Union import bmesh @@ -41,6 +44,7 @@ class Raycast(bonsai.core.tool.Raycast): (0, -offset), (offset, -offset), ) + snap_objs = [] @classmethod def get_visible_objects(cls, context: bpy.types.Context): @@ -69,8 +73,14 @@ class Raycast(bonsai.core.tool.Raycast): rv3d = context.region_data assert rv3d view_location = rv3d.view_matrix.inverted().translation + view_normal = rv3d.view_rotation @ mathutils.Vector((0.0, 0.0, -1.0)) obj_matrix = obj.matrix_world.copy() bbox = [obj_matrix @ Vector(v) for v in obj.bound_box] + bbox_edges = [ + (0,1),(1,2),(2,3),(3,0), + (4,5),(5,6),(6,7),(7,4), + (0,4),(1,5),(2,6),(3,7) + ] transposed_bbox: list[Vector] = [] bbox_2d: list[float] = [] @@ -94,8 +104,23 @@ class Raycast(bonsai.core.tool.Raycast): for v in bbox: coord_2d = tool.Cad.location_3d_to_region_2d_np(context.region, context.space_data.region_3d, v) - if coord_2d is not None: - transposed_bbox.append(coord_2d) + transposed_bbox.append(coord_2d) + + if not any(transposed_bbox): + transposed_bbox = [] + # If there are None values in transposed_bbox it means that there are vertices behind the camera + # so we get the intersection of the edge with the region border + # new_bbox = [] + if any(transposed_bbox) and not all(transposed_bbox): + new_bbox = transposed_bbox.copy() + new_bbox = [x for x in new_bbox if x is not None] + for edge in bbox_edges: + if (transposed_bbox[edge[0]] is None) ^ (transposed_bbox[edge[1]] is None): + point, _ = cls.intersect_edge_region_border(context.region, context.space_data, rv3d, bbox[edge[0]], bbox[edge[1]]) + if point: + new_bbox.append(point) + if new_bbox: + transposed_bbox = new_bbox region = context.region borders = (0, region.width, 0, region.height) @@ -117,6 +142,99 @@ class Raycast(bonsai.core.tool.Raycast): return (obj, bbox_2d) return None + def intersect_edge_region_border(region, space, rv3d, v1, v2): + def segment_intersect_near_plane(view_matrix, clip_start, p_world_a, p_world_b): + a_view = view_matrix @ p_world_a + b_view = view_matrix @ p_world_b + z_near = -clip_start + za = a_view.z + zb = b_view.z + denom = (zb - za) + if denom == 0.0: + return None, None + t = (z_near - za) / denom + if t < 0.0 or t > 1.0: + return None, None + p_view = a_view.lerp(b_view, t) + cam_world = view_matrix.inverted() + p_world = cam_world @ p_view + return p_world, t + + def is_inside_region(pt2d, region): + return 0.0 <= pt2d.x <= region.width and 0.0 <= pt2d.y <= region.height + + def clamp_to_region_border(point2d, region): + x, y = point2d + x_clamped = max(0.0, min(region.width, x)) + y_clamped = max(0.0, min(region.height, y)) + return Vector((x_clamped, y_clamped)) + + def find_nearby_onscreen_point(region, rv3d, p1, p2, initial_t_on_segment, max_iters=40, step=0.05): + """ + Use iterative approach: move t toward 0. Returns the first point that is inside region border + """ + t = initial_t_on_segment + for i in range(max_iters): + test_3d = p1.lerp(p2, t) + test_2d = view3d_utils.location_3d_to_region_2d(region, rv3d, test_3d) + if test_2d is not None and is_inside_region(test_2d, region): + return test_3d, test_2d, t + # move t toward 0 by reducing it by a fraction of its current value + t -= step + # if t is already very small, break + if t <= 1e-6: + break + + return None, None, None + + # Ensures that all the calculation uses the same direction based on which point is on the screen + if view3d_utils.location_3d_to_region_2d(region, rv3d, v1): + onscreen_vert = v1 + offscreen_vert = v2 + else: + onscreen_vert = v2 + offscreen_vert = v1 + # v2, v1 = v1, v2 + + clip_start = space.clip_start + view_mat = rv3d.view_matrix + inter_world, t_on_ab = segment_intersect_near_plane(view_mat, clip_start, onscreen_vert, offscreen_vert) + + if inter_world is None: + print("No intersection with viewport near plane found for the segment.") + return + + init_2d = view3d_utils.location_3d_to_region_2d(region, rv3d, inter_world) + + if init_2d is not None and is_inside_region(init_2d, region): + final_world = inter_world + final_2d = init_2d + final_t = initial_t + else: + found_world, found_2d, found_t = find_nearby_onscreen_point( + region, rv3d, + onscreen_vert, offscreen_vert, + t_on_ab, + max_iters=600, step=0.01 + ) + if found_world is None: + if init_2d is None: + print("Initial projection invalid and iterative search failed.") + return + # fallback: clamp projected point to border via manual mapping + final_2d = clamp_to_region_border(init_2d, region) + final_world = None + final_t = None + # print("Iterative search failed; using clamped 2D:", final_2d) + else: + final_world = found_world + final_2d = found_2d + final_t = found_t + # print(f"Found onscreen point at t={final_t:.4f}") + + # print("Final 2D:", final_2d) + return final_2d, v2 + @classmethod def intersect_mouse_2d_bounding_box(cls, mouse_pos: tuple[int, int], bbox: list[float, float, float, float]): x, y = mouse_pos @@ -232,6 +350,161 @@ class Raycast(bonsai.core.tool.Raycast): else: return None, None, None + @classmethod + def ray_cast_by_proximity_2d( + cls, + context: bpy.types.Context, + event: bpy.types.Event, + snap_obj: SnapObj, + ): + + def divide_vector(start, end, n): + points = [] + delta = (end - start) / n + for i in range(1, n): + point = start + i * delta + points.append(point) + return points + + region = context.region + rv3d = context.region_data + mouse_pos = event.mouse_region_x, event.mouse_region_y + ray_origin, ray_target, ray_direction = cls.get_viewport_ray_data(context, event) + points = [] + + try: + loc = tool.Cad.region_2d_to_location_3d_np(region, rv3d, mouse_pos, ray_direction) + except: + loc = Vector((0, 0, 0)) + + verts_2d = [ + view3d_utils.location_3d_to_region_2d(region, rv3d, v) for v in snap_obj.verts_3d + ] # Numpy version is worst in performance + + intersected = snap_obj.raycast_boxes( + context, event, snap_obj.root, intersected=[], rays=(ray_origin, ray_direction) + ) + edges = [] + for it in intersected: + edges.extend(it.edges) + edges = set(edges) + + edge_verts = {} + for e in edges: + verts_idx = tuple(snap_obj.obj.data.edges[e].vertices) + verts = snap_obj.obj.data.vertices + v1 = snap_obj.obj.matrix_world @ verts[verts_idx[0]].co + v1_2d = verts_2d[verts_idx[0]] + v2 = snap_obj.obj.matrix_world @ verts[verts_idx[1]].co + v2_2d = verts_2d[verts_idx[1]] + if (v1_2d is None) ^ (v2_2d is None): + point, _ = cls.intersect_edge_region_border(region, context.space_data, rv3d, v1, v2) + if v1_2d is None: + edge_verts[e] = (point, v2_2d) + else: + edge_verts[e] = (v1_2d, point) + else: + edge_verts[e] = (v1_2d, v2_2d) + + snap_threshold = 10.0 + + for i, point in enumerate(verts_2d): + if not point: + continue + distance = (Vector(mouse_pos) - point).length + if distance <= snap_threshold: + snap_point = { + "object": snap_obj.obj, + "type": "Vertex", + "point": snap_obj.verts_3d[i], + "distance": distance / 10, + } + points.append(snap_point) + + count = 0 + selected_edges = {} + for e in edges: + p0, p1 = edge_verts[e] + p0x, p0y = p0 + p1x, p1y = p1 + px, py = mouse_pos + + # segment vector = p1 - p0 + sx = p1x - p0x + sy = p1y - p0y + + # seg length squared + seg_len_sq = sx * sx + sy * sy + + if seg_len_sq == 0.0: + # degenerate segment: return distance to p0 + dx = px - p0x + dy = py - p0y + dist = math.hypot(dx, dy) + return dist, (p0x, p0y), 0.0 + + # project (p - p0) onto seg: t = dot(p-p0, seg) / |seg|^2 + apx = px - p0x + apy = py - p0y + t = (apx * sx + apy * sy) / seg_len_sq + + # clamp to segment + if t <= 0.0: + t_clamped = 0.0 + cx, cy = p0x, p0y + elif t >= 1.0: + t_clamped = 1.0 + cx, cy = p1x, p1y + else: + t_clamped = t + cx = p0x + sx * t_clamped + cy = p0y + sy * t_clamped + + dx = px - cx + dy = py - cy + dist = math.hypot(dx, dy) + if dist <= snap_threshold: + selected_edges[dist] = e + + if selected_edges: + min_dist = float("inf") + for key in selected_edges: + if key < min_dist: + min_dist = key + + idx = snap_obj.obj.data.edges[selected_edges[min_dist]].vertices + edge_verts = (snap_obj.verts_3d[idx[0]], snap_obj.verts_3d[idx[1]]) + division_points = divide_vector( + edge_verts[0], edge_verts[1], 2 + ) # TODO Make it work for different divisions + for division_point in division_points: + intersection = tool.Cad.point_on_edge(division_point, (ray_target, loc)) + distance = (division_point - intersection).length + if distance < snap_threshold: + snap_point = { + "object": snap_obj.obj, + "type": "Edge Center", + "point": division_point.copy(), + "distance": distance, + } + points.append(snap_point) + + intersection = tool.Cad.intersect_edges_v2((ray_target, loc), edge_verts) + if intersection[0]: + if tool.Cad.is_point_on_edge(intersection[1], edge_verts): + distance = (intersection[1] - intersection[0]).length + if distance < snap_threshold: + snap_point = { + "object": snap_obj.obj, + "type": "Edge", + "point": intersection[1].copy(), + "edge_verts": edge_verts, + "distance": distance, + } + points.append(snap_point) + + return points + @classmethod def ray_cast_by_proximity( cls, @@ -457,7 +730,8 @@ class Raycast(bonsai.core.tool.Raycast): if bbox_2d: if tool.Raycast.intersect_mouse_2d_bounding_box(mouse_pos, bbox_2d): if tool.Raycast.object_is_visible_in_clipping_plane(obj): - objs_to_raycast.append(obj) + snap_obj = cls.create_snap_obj(obj) + objs_to_raycast.append(snap_obj) return objs_to_raycast @@ -474,12 +748,6 @@ class Raycast(bonsai.core.tool.Raycast): face_index = None # Wireframes if obj.type in {"EMPTY", "CURVE"} or (hasattr(obj.data, "polygons") and len(obj.data.polygons) == 0): - snap_points = tool.Raycast.ray_cast_by_proximity(context, event, obj) - if snap_points: - hit = sorted(snap_points, key=lambda x: x["distance"])[0]["point"] - if hit: - hit_world = obj.original.matrix_world @ hit - return obj, hit_world, face_index return None, None, None # Meshes else: @@ -514,19 +782,20 @@ class Raycast(bonsai.core.tool.Raycast): ray_origin, ray_target, ray_direction = cls.get_viewport_ray_data(context, event) - for obj in objs_to_raycast: + for snap_obj in objs_to_raycast: if not include_wireframes and ( - obj.type in {"EMPTY", "CURVE"} or (hasattr(obj.data, "polygons") and len(obj.data.polygons) == 0) + snap_obj.obj.type in {"EMPTY", "CURVE"} + or (hasattr(snap_obj.obj.data, "polygons") and len(snap_obj.obj.data.polygons) == 0) ): continue - snap_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, obj) + hit_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, snap_obj.obj) if hit is not None: length_squared = (hit - ray_origin).length_squared if best_obj is None or length_squared < best_length_squared: best_length_squared = length_squared - best_obj = snap_obj + best_obj = hit_obj best_hit = hit best_face_index = face_index @@ -536,6 +805,81 @@ class Raycast(bonsai.core.tool.Raycast): else: return None, None, None + @classmethod + def ray_cast_and_get_closest_to_camera_snaps( + cls, + context: bpy.types.Context, + event: bpy.types.Event, + objs_to_raycast: list[bpy.types.Object], + ) -> Union[tuple[bpy.types.Object, Vector, int], tuple[None, None, None]]: + closest_length_squared = 1.0 + closest_obj = None + closest_hit = None + closest_face_index = None + + ray_origin, ray_target, ray_direction = cls.get_viewport_ray_data(context, event) + + closest_snaps = [] + hit = None + + for snap_obj in objs_to_raycast: + if (snap_obj.obj.type in {"EMPTY", "CURVE"} + or (hasattr(snap_obj.obj.data, "polygons") and len(snap_obj.obj.data.polygons) == 0) + ): + # For wireframe objects we have to test all the snaps to see which is closer + snap_points = tool.Raycast.ray_cast_by_proximity_2d(context, event, snap_obj) + closest_wf_hit = None + closest_wf_length_squared = 1.0 + closest_wf_point = None + if snap_points: + for point in snap_points: + point["group"] = "Wireframe" + closest_snaps.append(point) + length = (point["point"] - ray_origin).length_squared + if closest_wf_hit is None or length < closest_wf_length_squared: + closest_wf_length_squared = length + closest_wf_hit = point["point"] + closest_wf_point = point + + if closest_wf_point: + hit_obj = closest_wf_point["object"] + hit = closest_wf_point["point"] + face_index = None + + + else: + # Solid objects + hit_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, snap_obj.obj) + + if hit: + snap_point = { + "point": hit, + "type": "Face", + "group": "Object", + "object": hit_obj, + "face_index": face_index, + "distance": 9, # High value so it has low priority + } + closest_snaps.append(snap_point) + + + # Here we test which is closer, including wireframe and solid objects + if hit is not None: + length_squared = (hit - ray_origin).length_squared + if closest_obj is None or length_squared < closest_length_squared: + closest_length_squared = length_squared + closest_obj = hit_obj + closest_hit = hit + closest_face_index = face_index + + # Label snaps from the closest object + if closest_obj is not None: + for snap in closest_snaps: + if snap["object"] == closest_obj: + snap["is_closest_to_camera"] = True + + return closest_snaps + @classmethod def calculate_snap_threshold(cls, view_distance): snap_threshold = view_distance / 100 @@ -547,3 +891,260 @@ class Raycast(bonsai.core.tool.Raycast): if lens < 50: snap_threshold *= value return snap_threshold + + @classmethod + def create_snap_obj(cls, obj): + for snap_obj in cls.snap_objs: + if obj.name == snap_obj.obj.name: + return snap_obj + snap_obj = SnapObj(obj) + cls.snap_objs.append(snap_obj) + return snap_obj + + @classmethod + def clear_snap_objs(cls): + TreeNode.__clear_all__() + SnapObj.__clear_all__() + cls.snap_objs.clear() + + +class TreeNode: + all = [] + + def __init__(self, box: tuple): + self.__class__.all.append(self) + self.box = box + self.child_a = None + self.child_b = None + self.edges = [] + + def __clear_all__(): + for instance in TreeNode.all: + del instance + TreeNode.all.clear() + + +class SnapObj: + max_depth = 9 + all = [] + + def __init__(self, obj: bpy.types.Object): + self.__class__.all.append(self) + self.obj = obj + self.root = self._create_root_node() + self.root.edges = [e.index for e in obj.data.edges] + self.split_box(self.root, 0) + self.verts_3d = [obj.matrix_world @ v.co for v in obj.data.vertices] + self.snap_points = [] + + def __clear_all__(): + for instance in SnapObj.all: + del instance + SnapObj.all.clear() + + def _create_root_node(self) -> TreeNode: + bbox = tool.Blender.get_object_bounding_box(self.obj) + min_point = self.obj.matrix_world @ bbox["min_point"] + max_point = self.obj.matrix_world @ bbox["max_point"] + new_bbox = self.expand_bounding_box((min_point, max_point)) + return TreeNode(new_bbox) + + def divide_bounding_box_along_longest_axis( + self, min_pt: Vector, max_pt: Vector + ) -> Union[tuple[Vector, Vector], tuple[Vector, Vector]]: + """ + Divide a bounding box into two equal parts along the axis with the longest dimension. + + Args: + min_pt: The minimum point of the bounding box. + max_pt: The maximum point of the bounding box. + + Returns: + list: A list of two tuples, each containing the minimum and maximum points of the divided boxes. + """ + + # Calculate the dimensions of the box + dx = max_pt.x - min_pt.x + dy = max_pt.y - min_pt.y + dz = max_pt.z - min_pt.z + + # Determine the axis with the longest dimension + if dx >= dy and dx >= dz: + # Divide along the x-axis + mid_x = min_pt.x + dx / 2 + box1 = (min_pt, Vector((mid_x, max_pt.y, max_pt.z))) + box2 = (Vector((mid_x, min_pt.y, min_pt.z)), max_pt) + elif dy >= dx and dy >= dz: + # Divide along the y-axis + mid_y = min_pt.y + dy / 2 + box1 = (min_pt, Vector((max_pt.x, mid_y, max_pt.z))) + box2 = (Vector((min_pt.x, mid_y, min_pt.z)), max_pt) + else: + # Divide along the z-axis + mid_z = min_pt.z + dz / 2 + box1 = (min_pt, Vector((max_pt.x, max_pt.y, mid_z))) + box2 = (Vector((min_pt.x, min_pt.y, mid_z)), max_pt) + + return [box1, box2] + + def expand_bounding_box(self, box: tuple[Vector, Vector], offset: float = 0.1) -> tuple[Vector, Vector]: + """ + Expand a 3D bounding box by a given offset. + + Args: + min_pt: The minimum point of the bounding box. + max_pt: The maximum point of the bounding box. + offset: The offset to expand the bounding box by. + + Returns: + tuple: A tuple containing the new minimum and maximum points of the expanded bounding box. + """ + + min_pt, max_pt = box + # Calculate the new minimum and maximum points + new_min_pt = Vector((min_pt.x - offset, min_pt.y - offset, min_pt.z - offset)) + new_max_pt = Vector((max_pt.x + offset, max_pt.y + offset, max_pt.z + offset)) + + return new_min_pt, new_max_pt + + def split_box(self, parent: TreeNode, depth: int): + """ + Splits the bounding box creating two child nodes to compose a BVH Tree recursively. + + Args: + parent: the TreeNode instance that represents the parent node of a BVH Tree. + depth: the depth of the BVH Tree no be used in recursion. + """ + if depth > self.max_depth: + return + box_a, box_b = self.divide_bounding_box_along_longest_axis(parent.box[0], parent.box[1]) + parent.child_a = TreeNode(box_a) + parent.child_b = TreeNode(box_b) + edges_a = [] + edges_b = [] + for e in parent.edges: + verts_idx = [v for v in self.obj.data.edges[e].vertices] + verts_coords = [] + for idx in verts_idx: + if idx < len(self.obj.data.vertices): + verts_coords.append(self.obj.matrix_world @ self.obj.data.vertices[idx].co) + if self.line_intersects_box(verts_coords[0], verts_coords[1], parent.child_a.box): + edges_a.append(e) + if self.line_intersects_box(verts_coords[0], verts_coords[1], parent.child_b.box): + edges_b.append(e) + parent.child_a.edges = edges_a + parent.child_b.edges = edges_b + self.split_box(parent.child_a, depth + 1) + self.split_box(parent.child_b, depth + 1) + + def raycast_box( + self, context: bpy.types.Context, event: bpy.types.Event, node: TreeNode, rays: tuple[Vector, Vector] + ) -> bool: + """ + Raycast bounding box. + + Args: + context: Blender context. + event: Blender event. + node: a TreeNode instance. + rays: tuple containing ray origin and ray direction + + Returns: + True if hits the box or False otherwise. + """ + box = node.box + min_v = box[0] + max_v = box[1] + t_min = 0.0 + t_max = float("inf") + ray_origin, ray_dir = rays + inv_dir = Vector((1.0 / r if r != 0.0 else 1e32) for r in (ray_dir.x, ray_dir.y, ray_dir.z)) + # X + tx1 = (min_v.x - ray_origin.x) * inv_dir[0] + tx2 = (max_v.x - ray_origin.x) * inv_dir[0] + tmin = min(tx1, tx2) + tmax = max(tx1, tx2) + # Y + ty1 = (min_v.y - ray_origin.y) * inv_dir[1] + ty2 = (max_v.y - ray_origin.y) * inv_dir[1] + tmin = max(tmin, min(ty1, ty2)) + tmax = min(tmax, max(ty1, ty2)) + # Z + tz1 = (min_v.z - ray_origin.z) * inv_dir[2] + tz2 = (max_v.z - ray_origin.z) * inv_dir[2] + tmin = max(tmin, min(tz1, tz2)) + tmax = min(tmax, max(tz1, tz2)) + return (tmax >= max(tmin, t_min)) and (tmin <= t_max) + + def line_intersects_box(self, v1: mathutils.Vector, v2: mathutils.Vector, box: tuple) -> bool: + """ + Check if a line segment intersects an axis-aligned bounding box (AABB). + + Args: + v1: The first endpoint of the line segment as a mathutils.Vector. + v2: The second endpoint of the line segment as a mathutils.Vector. + box: A tuple containing the minimum and maximum points of the AABB, where each point is a mathutils.Vector. + + Returns: + bool: True if the segment [v1, v2] intersects the AABB; otherwise, False. + """ + bmin, bmax = box + dir = v2 - v1 + tmin = 0.0 + tmax = 1.0 + + for i in range(3): + if abs(dir[i]) < 1e-12: + # Line is parallel to slab. If origin not within slab -> no hit. + if v1[i] < bmin[i] or v1[i] > bmax[i]: + return False + else: + ood = 1.0 / dir[i] + t1 = (bmin[i] - v1[i]) * ood + t2 = (bmax[i] - v1[i]) * ood + if t1 > t2: + t1, t2 = t2, t1 + if t1 > tmin: + tmin = t1 + if t2 < tmax: + tmax = t2 + if tmin > tmax: + return False + + # If any overlap in [0,1] exists, there's intersection + return (tmax >= 0.0) and (tmin <= 1.0) + + def raycast_boxes( + self, + context: bpy.types.Context, + event: bpy.Types.Event, + node: TreeNode, + intersected: Union[TreeNode] = [], + rays: tuple[Vector, Vector] = (), + ) -> Union[TreeNode]: + """ + Raycast bounding box subdivisions recursively. + + Args: + context: Blender context. + event: Blender event. + node: a TreeNode instance. + intersected: list of intersected boxes to use in recursion. + rays: tuple containing ray origin and ray direction + + Returns: + tuple: a list of TreeNode instances that represent the subdivided boxes hit by the ray cast. + """ + if not node.child_a: + intersected.append(node) + return intersected + + intersects_a = self.raycast_box(context, event, node.child_a, rays) + intersects_b = self.raycast_box(context, event, node.child_b, rays) + if intersects_a: + intersected = self.raycast_boxes(context, event, node.child_a, intersected, rays) + + if intersects_b: + intersected = self.raycast_boxes(context, event, node.child_b, intersected, rays) + + return intersected diff --git a/src/bonsai/bonsai/tool/snap.py b/src/bonsai/bonsai/tool/snap.py index 02f484d388..f464779ea3 100644 --- a/src/bonsai/bonsai/tool/snap.py +++ b/src/bonsai/bonsai/tool/snap.py @@ -360,6 +360,7 @@ class Snap(bonsai.core.tool.Snap): plane_normal = tool.Polyline.use_transform_orientations(plane_normal) return plane_origin, plane_normal + # Polyline polyline_props = tool.Model.get_polyline_props() try: @@ -388,58 +389,31 @@ class Snap(bonsai.core.tool.Snap): # Objects objs_to_raycast = tool.Raycast.filter_objects_to_raycast(context, event, objs_2d_bbox) - # Wireframes - # For wireframe we have to get all the objects so we can further calculate edge intersection + closest_snaps = tool.Raycast.ray_cast_and_get_closest_to_camera_snaps(context, event, objs_to_raycast) + detected_snaps.extend(closest_snaps) + + xray_mode = (space.shading.type == "SOLID" and space.shading.show_xray) or (space.shading.type == "WIREFRAME" and space.shading.show_xray_wireframe) + for snap_obj in objs_to_raycast: - if snap_obj.type in {"EMPTY", "CURVE"} or (snap_obj.type == "MESH" and len(snap_obj.data.polygons) == 0): - snap_points = tool.Raycast.ray_cast_by_proximity(context, event, snap_obj) - if snap_points: - for point in snap_points: - point["group"] = "Wireframe" - detected_snaps.append(point) - - if (space.shading.type == "SOLID" and space.shading.show_xray) or ( - space.shading.type == "WIREFRAME" and space.shading.show_xray_wireframe - ): - results = [] - for obj in objs_to_raycast: - results.append(tool.Raycast.cast_rays_to_single_object(context, event, obj)) - else: - results = [] - results.append(tool.Raycast.cast_rays_and_get_best_object(context, event, objs_to_raycast)) - - for result in results: - snap_obj = result[0] - hit = result[1] - face_index = result[2] - if hit is not None: - # Wireframes - if snap_obj.type in {"EMPTY", "CURVE"} or ( - snap_obj.type == "MESH" and len(snap_obj.data.polygons) == 0 - ): - continue - # Meshes - else: - # Add face snap - snap_point = { - "point": hit, - "type": "Face", - "group": "Object", - "object": snap_obj, - "face_index": face_index, - "distance": 9, # High value so it has low priority - } - detected_snaps.append(snap_point) - - # Add vertex and edge snap - snap_points = tool.Raycast.ray_cast_by_proximity( - context, event, snap_obj, snap_obj.data.polygons[face_index] - ) - if snap_points: - for point in snap_points: - point["group"] = "Object" - detected_snaps.append(point) - + for snap in closest_snaps: + if snap_obj.obj == snap["object"]: + if xray_mode: + if "face_index" in snap and snap["face_index"] is not None: + snap_points = tool.Raycast.ray_cast_by_proximity_2d(context, event, snap_obj) + for point in snap_points: + point["group"] = "Object" + detected_snaps.append(point) + else: + # If it is a solid object that is closest to camera it ignores all the rest + if "is_closest_to_camera" in snap and snap["is_closest_to_camera"] and snap["group"] == "Object": + closest_snap = [snap] # discards objects that aren't the closest + if "face_index" in snap and snap["face_index"] is not None: + snap_points = tool.Raycast.ray_cast_by_proximity_2d(context, event, snap_obj) + for point in snap_points: + point["group"] = "Object" + closest_snap.append(point) + detected_snaps = closest_snap + # snap to cut geometry (e.g. in plan view) if CutDecorator.installed: cut_snaps = [] diff --git a/src/bonsai/bonsai/tool/spatial.py b/src/bonsai/bonsai/tool/spatial.py index b185b9ab59..7bbdb28904 100644 --- a/src/bonsai/bonsai/tool/spatial.py +++ b/src/bonsai/bonsai/tool/spatial.py @@ -21,13 +21,13 @@ from __future__ import annotations import json from collections import defaultdict from collections.abc import Generator, Iterable -from math import pi from typing import TYPE_CHECKING, Any, Literal, Optional, Union import bmesh import bpy import ifcopenshell import ifcopenshell.api.attribute +import ifcopenshell.api.geometry import ifcopenshell.api.type import ifcopenshell.geom import ifcopenshell.util.classification @@ -991,114 +991,107 @@ class Spatial(bonsai.core.tool.Spatial): return poly @classmethod - def get_bmesh_from_polygon(cls, poly: Polygon, h: float, polygon_is_si: bool = False) -> bmesh.types.BMesh: - """ - :param h: Height, in meters. - :param polygon_is_si: Should be True if `poly` is defined in meters. - """ - mat = Matrix() - bm = bmesh.new() - bm.verts.index_update() - bm.edges.index_update() - - mat_invert = mat.inverted() - si_conversion = 1.0 if polygon_is_si else ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) - new_verts = [ - # bm.verts.new(mat_invert @ (Vector([v[0], v[1], 0]) * si_conversion)) for v in poly.exterior.coords[0:-1] - bm.verts.new(mat_invert @ (Vector([v[0], v[1], 0]) * si_conversion)) - for v in shapely.get_exterior_ring(poly).coords[0:-1] - ] - [bm.edges.new((new_verts[i], new_verts[i + 1])) for i in range(len(new_verts) - 1)] - bm.edges.new((new_verts[len(new_verts) - 1], new_verts[0])) - - bm.verts.index_update() - bm.edges.index_update() - - bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-5) - bmesh.ops.triangle_fill(bm, edges=bm.edges) - bmesh.ops.dissolve_limit(bm, angle_limit=pi / 180 * 5, verts=bm.verts, edges=bm.edges) - - if h != 0: - extrusion = bmesh.ops.extrude_face_region(bm, geom=bm.faces) - extruded_verts = [g for g in extrusion["geom"] if isinstance(g, bmesh.types.BMVert)] - bmesh.ops.translate(bm, vec=[0.0, 0.0, h], verts=extruded_verts) - - bmesh.ops.recalc_face_normals(bm, faces=bm.faces) - - return bm - - @classmethod - def get_named_obj_from_bmesh(cls, name: str, bmesh: bmesh.types.BMesh) -> bpy.types.Object: - mesh = cls.get_named_mesh_from_bmesh(name, bmesh) - obj = cls.get_named_obj_from_mesh(name, mesh) - return obj - - @classmethod - def get_named_obj_from_mesh(cls, name: str, mesh: bpy.types.Mesh) -> bpy.types.Object: + def create_object(cls, name: str) -> bpy.types.Object: + mesh = bpy.data.meshes.new(name=name) obj = bpy.data.objects.new(name, mesh) return obj @classmethod - def get_named_mesh_from_bmesh(cls, name: str, bmesh: bmesh.types.BMesh) -> bpy.types.Mesh: - mesh = bpy.data.meshes.new(name=name) - bmesh.to_mesh(mesh) - bmesh.free() - return mesh + def set_obj_origin_to_polygon_center( + cls, obj: bpy.types.Object, poly: Polygon, polygon_is_si: bool = True + ) -> None: + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + centroid = poly.centroid + if polygon_is_si: + obj.location = Vector((centroid.x, centroid.y, 0)) + else: + obj.location = Vector((centroid.x * unit_scale, centroid.y * unit_scale, 0)) + @classmethod - def get_transformed_mesh_from_local_to_global(cls, mesh: bpy.types.Mesh) -> bpy.types.Mesh: - active_obj = cls.get_active_obj() - mat = active_obj.matrix_world - mesh.transform(mat.inverted()) - mesh.update() - return mesh + def get_2d_vertices_from_polygon( + cls, + poly: Polygon, + obj: bpy.types.Object, + polygon_is_si: bool = True, + ) -> list[list[float]]: + """Convert a world-space shapely polygon to 2D vertices in obj's local space, in IFC file units. + + :param poly: The polygon in world space. + :param obj: The Blender object whose local space is used. + :param polygon_is_si: True if polygon coords are in SI, False if in IFC file units. + :return: List of [x, y] coordinates (not closed). + """ + ifc_file = tool.Ifc.get() + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file) + bpy.context.view_layer.update() + mat_inv = obj.matrix_world.inverted() + coords_2d = [] + for v in shapely.get_exterior_ring(poly).coords[:-1]: + world_si = Vector((v[0], v[1], 0)) + if not polygon_is_si: + world_si = world_si * unit_scale + local_si = mat_inv @ world_si + coords_2d.append([local_si.x / unit_scale, local_si.y / unit_scale]) + return coords_2d @classmethod - def edit_active_space_obj_from_mesh(cls, mesh: bpy.types.Mesh) -> None: - active_obj = bpy.context.active_object - old_mesh = active_obj.data - old_mesh_name = old_mesh.name - assert active_obj and isinstance(old_mesh, bpy.types.Mesh) - tool.Geometry.get_mesh_props(mesh).ifc_definition_id = tool.Geometry.get_mesh_props(old_mesh).ifc_definition_id - tool.Geometry.change_object_data(active_obj, mesh, is_global=True) - tool.Ifc.edit(active_obj) - tool.Blender.remove_data_block(old_mesh) - # Rename after old mesh is removed to avoid .001 suffix. - mesh.name = old_mesh_name + def set_extrusion_representation_from_polygon( + cls, + obj: bpy.types.Object, + element: ifcopenshell.entity_instance, + poly: Polygon, + depth_ifc: float, + polygon_is_si: bool = True, + ) -> None: + """Create or replace the IFC body representation from a polygon extrusion. + + :param obj: The Blender object. + :param element: The IFC product entity. + :param poly: The polygon in world space. + :param depth_ifc: The extrusion depth in IFC file units. + :param polygon_is_si: True if polygon coords are in SI, False if in IFC file units. + """ + ifc_file = tool.Ifc.get() + builder = ifcopenshell.util.shape_builder.ShapeBuilder(ifc_file) + + coords_2d = cls.get_2d_vertices_from_polygon(poly, obj, polygon_is_si) + + curve = builder.polyline(coords_2d, closed=True) + item = builder.extrude(curve, magnitude=depth_ifc) + + old_body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") + if old_body: + context = old_body.ContextOfItems + ifcopenshell.api.geometry.unassign_representation(ifc_file, product=element, representation=old_body) + ifcopenshell.api.geometry.remove_representation(ifc_file, representation=old_body) + else: + context = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW") + + new_body = builder.get_representation(context, item) + ifcopenshell.api.geometry.assign_representation(ifc_file, product=element, representation=new_body) + bonsai.core.geometry.switch_representation( + tool.Ifc, + tool.Geometry, + obj=obj, + representation=new_body, + ) @classmethod - def set_obj_origin_to_bboxcenter(cls, obj: bpy.types.Object) -> None: - mat = obj.matrix_world - inverted = mat.inverted() - local_bbox_center = 0.125 * sum((Vector(b) for b in obj.bound_box), Vector()) - global_bbox_center = mat @ local_bbox_center + def set_space_representation_from_polygon( + cls, + obj: bpy.types.Object, + element: ifcopenshell.entity_instance, + poly: Polygon, + h: float, + polygon_is_si: bool = True, + ) -> None: + """Create or replace the IFC body representation of a space from a polygon. - oldLoc = obj.location - newLoc = global_bbox_center - diff = newLoc - oldLoc - for vert in obj.data.vertices: - aux_vector = mat @ vert.co - aux_vector = aux_vector - diff - vert.co = inverted @ aux_vector - obj.location = newLoc - - @classmethod - def set_obj_origin_to_bboxcenter_and_zero_elevation(cls, obj: bpy.types.Object) -> None: - mat = obj.matrix_world - inverted = mat.inverted() - local_bbox_center = 0.125 * sum((Vector(b) for b in obj.bound_box), Vector()) - global_bbox_center = mat @ local_bbox_center - global_obj_origin = global_bbox_center - global_obj_origin.z = 0 - - oldLoc = obj.location - newLoc = global_obj_origin - diff = newLoc - oldLoc - for vert in obj.data.vertices: - aux_vector = mat @ vert.co - aux_vector = aux_vector - diff - vert.co = inverted @ aux_vector - obj.location = newLoc + :param h: The height in SI (meters). + """ + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + cls.set_extrusion_representation_from_polygon(obj, element, poly, h / unit_scale, polygon_is_si) @classmethod def set_obj_origin_to_cursor_position_and_zero_elevation(cls, obj: bpy.types.Object) -> None: @@ -1145,65 +1138,53 @@ class Spatial(bonsai.core.tool.Spatial): if z != 0: obj.location = obj.location + Vector((0, 0, z)) - @classmethod - def get_2d_vertices_from_obj(cls, obj: bpy.types.Object) -> list[tuple]: - points = [] - vectors = [v.co for v in obj.data.vertices.values()] - for vector in vectors: - points.append(vector.xy) - - points.append(vectors[0].xy) - return points - - @classmethod - def get_scaled_2d_vertices(cls, points: list[Vector]) -> list[tuple[float, float]]: - model = tool.Ifc.get() - unit_scale = ifcopenshell.util.unit.calculate_unit_scale(model) - _points = [] - for p in points: - _p = list(p) - _p[0] /= unit_scale - _p[1] /= unit_scale - _points.append(_p) - return _points - - @classmethod - def assign_swept_area_outer_curve_from_2d_vertices(cls, obj: bpy.types.Object, vertices: list[Vector]) -> None: - body = cls.get_body_representation(obj) - model = tool.Ifc.get() - extrusion = tool.Model.get_extrusion(body) - area = extrusion.SweptArea - old_area = area.OuterCurve - - builder = ifcopenshell.util.shape_builder.ShapeBuilder(model) - outer_curve = builder.polyline(vertices, closed=True) - - area.OuterCurve = outer_curve - ifcopenshell.util.element.remove_deep2(tool.Ifc.get(), old_area) - - @classmethod - def get_body_representation(cls, obj: bpy.types.Object) -> Union[ifcopenshell.entity_instance, None]: - element = tool.Ifc.get_entity(obj) - return ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") - @classmethod def assign_ifcspace_class_to_obj(cls, obj: bpy.types.Object) -> None: - bpy.ops.bim.assign_class(obj=obj.name, ifc_class="IfcSpace") + bonsai.core.root.assign_class( + tool.Ifc, + tool.Collector, + tool.Root, + obj=obj, + ifc_class="IfcSpace", + should_add_representation=False, + ) @classmethod def assign_type_to_obj(cls, obj: bpy.types.Object) -> None: - # TODO this code looks in the wrong spot and suspicious props = tool.Model.get_model_props() ifc_file = tool.Ifc.get() relating_type_id = props.relating_type_id - relating_type = tool.Ifc.get().by_id(int(relating_type_id)) + relating_type = ifc_file.by_id(int(relating_type_id)) ifc_class = relating_type.is_a() instance_class = ifcopenshell.util.type.get_applicable_entities(ifc_class, ifc_file.schema)[0] - bpy.ops.bim.assign_class(obj=obj.name, ifc_class=instance_class) + bonsai.core.root.assign_class( + tool.Ifc, + tool.Collector, + tool.Root, + obj=obj, + ifc_class=instance_class, + should_add_representation=False, + ) element = tool.Ifc.get_entity(obj) assert element ifcopenshell.api.type.assign_type(ifc_file, related_objects=[element], relating_type=relating_type) + @classmethod + def set_covering_representation_from_polygon( + cls, + obj: bpy.types.Object, + poly: Polygon, + polygon_is_si: bool = True, + ) -> None: + """Create the covering body representation from a polygon, extruded by the type's material layer thickness.""" + element = tool.Ifc.get_entity(obj) + relating_type = ifcopenshell.util.element.get_type(element) + material = ifcopenshell.util.element.get_material(relating_type, should_skip_usage=True) + depth = 0.0 + if material and material.is_a("IfcMaterialLayerSet"): + depth = sum(layer.LayerThickness for layer in material.MaterialLayers) + cls.set_extrusion_representation_from_polygon(obj, element, poly, depth, polygon_is_si) + @classmethod def assign_relating_type_to_element( cls, @@ -1214,14 +1195,6 @@ class Spatial(bonsai.core.tool.Spatial): ) -> None: bonsai.core.type.assign_type(ifc, tool.Model, type, element=element, type=relating_type) - @classmethod - def regen_obj_representation(cls, obj: bpy.types.Object, body: ifcopenshell.entity_instance) -> None: - bonsai.core.geometry.switch_representation( - tool.Ifc, - tool.Geometry, - obj=obj, - representation=body, - ) @classmethod def set_space_visibility(cls, is_visible: bool) -> None: diff --git a/src/bonsai/test/bim/feature/covering.feature b/src/bonsai/test/bim/feature/covering.feature index 4eb97649da..cefa554d41 100644 --- a/src/bonsai/test/bim/feature/covering.feature +++ b/src/bonsai/test/bim/feature/covering.feature @@ -2,7 +2,7 @@ Feature: Covering Covers covering tool. -Scenario: Execute generate flooring coverings from walls +Scenario: Add flooring from walls Given an empty IFC project And I load the demo construction library And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" @@ -24,9 +24,9 @@ Scenario: Execute generate flooring coverings from walls And the cursor is at "0,2.0,0" And I set "scene.BIMModelProperties.length" to "1.9" And I press "bim.add_occurrence" - # add_instance_flooring_coverings_from_walls is expecting FLOORING predefined type. + # Set COV30 predefined type to FLOORING. And the object "IfcCoveringType/COV30" is selected - And I look at the "Attributes" panel + And I look at the "Object Attributes" panel And I click "Edit" And I set the "PredefinedType" property to "FLOORING" And I click "Save Attributes" @@ -42,3 +42,108 @@ Scenario: Execute generate flooring coverings from walls Then the object "IfcCovering/Covering0" exists And the object "IfcCovering/Covering0" is at "1.8,1.05,0.0" And the object "IfcCovering/Covering0" dimensions are "3.4,1.9,0.03" + +Scenario: Add ceiling from walls + Given an empty IFC project + And I load the demo construction library + And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" + And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()" + And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" + And I press "bim.add_occurrence" + And the object "IfcWall/Wall" is selected + And I press "bim.change_layer_length(length=3.6)" + And the cursor is at "3.6,0.1,3" + And I set "scene.BIMModelProperties.length" to "2.0" + And I press "bim.add_occurrence" + And the cursor is at "3.5,2.1,3" + And I set "scene.BIMModelProperties.length" to "3.5" + And I press "bim.add_occurrence" + And the cursor is at "0,2.0,0" + And I set "scene.BIMModelProperties.length" to "1.9" + And I press "bim.add_occurrence" + # Set COV30 predefined type to CEILING. + And the object "IfcCoveringType/COV30" is selected + And I look at the "Object Attributes" panel + And I click "Edit" + And I set the "PredefinedType" property to "CEILING" + And I click "Save Attributes" + # Run the operator with ceiling height = 2.7 (default). + When the object "IfcWall/Wall" is selected + And additionally the object "IfcWall/Wall.001" is selected + And additionally the object "IfcWall/Wall.002" is selected + And additionally the object "IfcWall/Wall.003" is selected + And I set "scene.BIMModelProperties.ifc_class" to "IfcCoveringType" + And the variable "element_type" is "[e for e in {ifc}.by_type('IfcCoveringType') if e.Name == 'COV30'][0].id()" + And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" + And I press "bim.add_instance_ceiling_coverings_from_walls" + Then the object "IfcCovering/Covering0" exists + And the object "IfcCovering/Covering0" is at "1.8,1.05,2.7" + And the object "IfcCovering/Covering0" dimensions are "3.4,1.9,0.03" + +Scenario: Add flooring from cursor + Given an empty IFC project + And I load the demo construction library + And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" + And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()" + And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" + And I press "bim.add_occurrence" + And the cursor is at "1.1,0,0" + And I press "bim.add_occurrence" + And the object "IfcWall/Wall.001" is selected + And I press "bim.hotkey(hotkey='S_R')" + And the cursor is at "0,.9,0" + And I press "bim.add_occurrence" + And the cursor is at "-1,0,0" + And I press "bim.add_occurrence" + And the object "IfcWall/Wall.003" is selected + And I press "bim.hotkey(hotkey='S_R')" + And the object "IfcWall/Wall.003" is moved to "0,0,0" + # Set COV30 predefined type to FLOORING. + And the object "IfcCoveringType/COV30" is selected + And I look at the "Object Attributes" panel + And I click "Edit" + And I set the "PredefinedType" property to "FLOORING" + And I click "Save Attributes" + # Generate covering from cursor inside the room. + When the cursor is at "0.5,0.5,0" + And I deselect all objects + And I set "scene.BIMModelProperties.ifc_class" to "IfcCoveringType" + And the variable "element_type" is "[e for e in {ifc}.by_type('IfcCoveringType') if e.Name == 'COV30'][0].id()" + And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" + And I press "bim.add_instance_flooring_covering_from_cursor" + Then the object "IfcCovering/Covering" exists + And the object "IfcCovering/Covering" dimensions are "1,0.8,0.03" + +Scenario: Add ceiling from cursor + Given an empty IFC project + And I load the demo construction library + And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" + And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()" + And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" + And I press "bim.add_occurrence" + And the cursor is at "1.1,0,0" + And I press "bim.add_occurrence" + And the object "IfcWall/Wall.001" is selected + And I press "bim.hotkey(hotkey='S_R')" + And the cursor is at "0,.9,0" + And I press "bim.add_occurrence" + And the cursor is at "-1,0,0" + And I press "bim.add_occurrence" + And the object "IfcWall/Wall.003" is selected + And I press "bim.hotkey(hotkey='S_R')" + And the object "IfcWall/Wall.003" is moved to "0,0,0" + # Set COV30 predefined type to CEILING. + And the object "IfcCoveringType/COV30" is selected + And I look at the "Object Attributes" panel + And I click "Edit" + And I set the "PredefinedType" property to "CEILING" + And I click "Save Attributes" + # Generate covering from cursor inside the room. + When the cursor is at "0.5,0.5,0" + And I deselect all objects + And I set "scene.BIMModelProperties.ifc_class" to "IfcCoveringType" + And the variable "element_type" is "[e for e in {ifc}.by_type('IfcCoveringType') if e.Name == 'COV30'][0].id()" + And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" + And I press "bim.add_instance_ceiling_covering_from_cursor" + Then the object "IfcCovering/Covering" exists + And the object "IfcCovering/Covering" dimensions are "1,0.8,0.03" diff --git a/src/bonsai/test/bim/feature/model.feature b/src/bonsai/test/bim/feature/model.feature index ad5b25bd07..064620a574 100644 --- a/src/bonsai/test/bim/feature/model.feature +++ b/src/bonsai/test/bim/feature/model.feature @@ -396,6 +396,44 @@ Scenario: Add a slab And the object "IfcSlab/Slab" bottom left corner is at "0,0,0" And the object "IfcSlab/Slab" top right corner is at "1,1,0.2" +Scenario: Extend walls to underside + Given an empty IFC project + And I load the demo construction library + And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" + And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()" + And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" + And I press "bim.add_occurrence" + And I set "scene.BIMModelProperties.ifc_class" to "IfcSlabType" + And the variable "element_type" is "[e for e in {ifc}.by_type('IfcSlabType') if e.Name == 'FLR200'][0].id()" + And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" + And I press "bim.add_occurrence" + And the object "IfcSlab/Slab" is moved to "0,0,2.5" + When the object "IfcWall/Wall" is selected + And additionally the object "IfcSlab/Slab" is selected + And I look at the tool header + And I click "Extend To Underside" + Then the object "IfcWall/Wall" dimensions are "1,0.1,2.5" + +Scenario: Extend walls to underside - extending to a tessellated gable roof + Given an empty IFC project + And I load the demo construction library + And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" + And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()" + And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" + And I press "bim.add_occurrence" + # Create gable roof: a cube turned into a prism with a ridge. + And I add a cube of size "1" at "0.5,0.05,3" + And the object "Cube" is selected + And I evaluate expression "obj = bpy.context.active_object; [setattr(v.co, 'y', 0) for v in obj.data.vertices if v.co.z > 0]" + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" + And I set "scene.BIMRootProperties.ifc_class" to "IfcRoof" + And I press "bim.assign_class" + When the object "IfcWall/Wall" is selected + And additionally the object "IfcRoof/Cube" is selected + And I look at the tool header + And I click "Extend To Underside" + Then the object "IfcWall/Wall" dimensions are "1,0.1,2.5" + Scenario: Enable editing a slab profile Given an empty IFC project And I load the demo construction library diff --git a/src/bonsai/test/bim/test_feature.py b/src/bonsai/test/bim/test_feature.py index b7dc932024..79e8494441 100644 --- a/src/bonsai/test/bim/test_feature.py +++ b/src/bonsai/test/bim/test_feature.py @@ -133,7 +133,11 @@ class PanelSpy: self.spied_labels.append(kwargs["text"]) return self elif self.spied_attr == "prop": - props, name = args + if args: + props, name = args + else: + props = kwargs.get("data") + name = kwargs.get("property") props: bpy.types.bpy_struct text = kwargs.get("text", props.bl_rna.properties[name].name) icon = kwargs.get("icon", None) @@ -390,6 +394,32 @@ def i_look_at_the_panel_panel(panel: str) -> None: panel_spy.refresh_spy() +@given(parsers.parse("I look at the tool header")) +@when(parsers.parse("I look at the tool header")) +@then(parsers.parse("I look at the tool header")) +def i_look_at_the_tool_header() -> None: + from bonsai.bim.module.model.workspace import EditObjectUI + + class MockRegion: + type = "UI" + + class MockContext: + def __getattr__(self, name): + if name == "region": + return MockRegion() + return getattr(bpy.context, name) + + global panel_spy + panel_spy = PanelSpy(EditObjectUI) + panel_spy.is_spy_dirty = False + panel_spy.spied_attr = None + panel_spy.spied_labels = [] + panel_spy.spied_props = [] + panel_spy.spied_operators = [] + panel_spy.spied_lists = [] + EditObjectUI.draw(MockContext(), panel_spy) + + @given(parsers.parse('I open the "{name}" menu')) @when(parsers.parse('I open the "{name}" menu')) @then(parsers.parse('I open the "{name}" menu')) diff --git a/src/common.mk b/src/common.mk index 24537d06de..765655339b 100644 --- a/src/common.mk +++ b/src/common.mk @@ -28,6 +28,7 @@ dist: mkdir -p dist cp -r $(PACKAGE_NAME) build/ cp pyproject.toml build/ + if [ -f README.md ]; then cp README.md build/; fi ifeq ($(IS_STABLE), TRUE) $(SED) 's/version = "0.0.0"/version = "$(VERSION)"/' build/pyproject.toml ifdef IS_MODULE diff --git a/src/ifcedit/README.md b/src/ifcedit/README.md new file mode 100644 index 0000000000..19b1ec8e7a --- /dev/null +++ b/src/ifcedit/README.md @@ -0,0 +1,305 @@ + +# ifcedit + +A CLI wrapper that exposes all 350+ `ifcopenshell.api` mutation functions as +shell commands. Functions are auto-discovered at runtime via introspection -- +no hardcoded list to maintain. + +## Installation + +```bash +pip install ifcedit +``` + +Requires `ifcopenshell`. + +## Usage + +``` +ifcedit [options] [--format json|text] +``` + +Three subcommands: `list` to discover functions, `docs` to read their +documentation, and `run` to execute them. + +## Subcommands + +### list + +Discover available API modules and their functions. + +**List all modules:** + +```bash +ifcedit list +``` + +```json +[ + { + "module": "root", + "description": "Functions for creating project-level entities", + "functions": ["create_entity", "remove_product", "copy_class"], + "count": 3 + }, + { + "module": "spatial", + "description": "Functions for managing spatial relationships", + "functions": ["assign_container", "unassign_container"], + "count": 2 + } +] +``` + +**List functions in a module:** + +```bash +ifcedit list root +``` + +```json +[ + { + "name": "create_entity", + "description": "Create an IFC entity with optional initial attributes", + "params": [ + {"name": "ifc_class", "type": "str", "required": true}, + {"name": "name", "type": "Optional[str]"} + ] + } +] +``` + +### docs + +Show full documentation for a specific function, including parameter +descriptions from docstrings and return type. + +```bash +ifcedit docs root.create_entity +``` + +```json +{ + "module": "root", + "function": "create_entity", + "description": "Create an IFC entity with optional initial attributes", + "long_description": "This function creates a new entity instance...", + "params": [ + { + "name": "ifc_class", + "type": "str", + "required": true, + "description": "The IFC class name (e.g. 'IfcWall', 'IfcProject')" + }, + { + "name": "name", + "type": "Optional[str]", + "description": "Optional name attribute" + } + ], + "return_type": "ifcopenshell.entity_instance", + "return_description": "The newly created entity instance" +} +``` + +### run + +Execute an API function against an IFC file. Parameters are passed as +`--key value` pairs after the function name. + +```bash +ifcedit run model.ifc root.create_entity --ifc_class IfcWall --name "My Wall" +``` + +```json +{ + "ok": true, + "result": {"id": 42, "type": "IfcWall", "name": "My Wall"} +} +``` + +**Options:** + +- `-o, --output ` -- write to a different file instead of overwriting the input +- `--dry-run` -- validate parameters without executing or saving + +```bash +# Save to a new file +ifcedit run model.ifc root.create_entity -o out.ifc --ifc_class IfcWall + +# Validate without executing +ifcedit run model.ifc root.create_entity --dry-run --ifc_class IfcWall +``` + +Dry-run output shows the resolved parameters: + +```json +{ + "ok": true, + "dry_run": true, + "module": "root", + "function": "create_entity", + "args": {"ifc_class": "IfcWall", "name": "My Wall"} +} +``` + +## Parameter type coercion + +CLI strings are automatically converted to the types expected by each API +function, using the function's type annotations: + +| Type | CLI input | Python value | +|------|-----------|--------------| +| `str` | `"hello"` | `"hello"` | +| `int` | `"42"` or `"#42"` | `42` | +| `float` | `"3.14"` | `3.14` | +| `bool` | `"true"`, `"1"`, `"yes"` | `True` | +| `Optional[X]` | `"none"` | `None` | +| `entity_instance` | `"42"` or `"#42"` | resolved from model by step ID | +| `list[entity_instance]` | `"5,6,7"` or `"[5, 6, 7]"` | list of resolved entities | +| `dict` | `'{"key": "val"}'` | parsed JSON object | +| `Literal["A", "B"]` | `"A"` | validated against allowed values | + +## Examples + +```bash +# Create a project +ifcedit run model.ifc root.create_entity --ifc_class IfcProject --name "My Project" + +# Assign an element to a storey +ifcedit run model.ifc spatial.assign_container --products 10 --relating_structure 4 + +# Assign multiple elements at once +ifcedit run model.ifc aggregate.assign_object --products "5,6,7" --relating_object 1 + +# Add a property set +ifcedit run model.ifc pset.add_pset --product 10 --name "Pset_WallCommon" + +# Edit properties +ifcedit run model.ifc pset.edit_pset --pset 15 \ + --properties '{"IsExternal": true, "FireRating": "2HR"}' +``` + +### foreach + +Apply an API function to each element in a JSON array read from stdin. +`{field}` placeholders in argument values are substituted with fields from +each JSON object. The model is opened once and saved once regardless of how +many elements are processed. + +```bash +ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product {id} +``` + +```json +{"ok": true, "count": 36, "errors": []} +``` + +Placeholder tokens match the fields emitted by `ifcquery` — typically `{id}`, +`{type}`, and `{name}`: + +```bash +ifcquery model.ifc select 'IfcDoor' | ifcedit foreach model.ifc attribute.edit_attributes \ + --product {id} --attributes '{"Name": "Door"}' +``` + +**Options:** + +- `-o, --output ` -- write to a different file instead of overwriting the input + +**Output:** + +- `count` -- number of elements successfully processed +- `errors` -- list of per-element failures, each with `index`, `item`, and `error`; processing continues past errors + +```json +{ + "ok": false, + "count": 34, + "errors": [ + {"index": 2, "item": {"id": 55, "type": "IfcWindow", "name": "W03"}, "error": "Entity #55 not found in model"} + ] +} +``` + +Exit code is 1 if any element failed. + +### quantify + +Run quantity take-off (QTO) on an IFC file, computing physical measurements +(volume, area, length, count, weight) and writing them back as +`IfcElementQuantity` property sets. Uses `ifc5d` rules. + +**List available rules:** + +```bash +ifcedit quantify list +``` + +```json +[ + {"name": "IFC4QtoBaseQuantities"}, + {"name": "IFC4X3QtoBaseQuantities"} +] +``` + +**Run QTO on a file:** + +```bash +ifcedit quantify run model.ifc IFC4QtoBaseQuantities +ifcedit quantify run model.ifc IFC4QtoBaseQuantities --selector IfcWall +ifcedit quantify run model.ifc IFC4QtoBaseQuantities -o model_qto.ifc +``` + +```json +{"ok": true, "rule": "IFC4QtoBaseQuantities", "elements_quantified": 42} +``` + +Options: + +- `--selector ` -- ifcopenshell selector to restrict elements (default: all `IfcElement`) +- `-o, --output ` -- write to a different file instead of overwriting the input + +Note: `quantify run` writes geometry-based measurements and requires the +IfcOpenShell C++ geometry bindings for elements with computed quantities. + +## Error handling + +Errors are reported in the JSON response: + +```json +{ + "ok": false, + "error": "Entity #999 not found in model" +} +``` + +Exit code is 0 on success, 1 on error. + +## Relationship to ifcquery + +`ifcedit` and `ifcquery` are complementary tools: + +- **ifcquery** reads and inspects IFC models (summary, tree, info, select, relations, clash, validate, schedule, cost, schema, contexts, materials, plot, render) +- **ifcedit** modifies IFC models by wrapping `ifcopenshell.api` functions, and runs QTO via `quantify` + +A typical workflow: inspect with `ifcquery`, look up the right API function +with `ifcedit docs`, then apply changes with `ifcedit run`. + +The two tools also compose directly in shell scripts. Use `ifcquery --format ids` +to feed a list of IDs into a `run` parameter, or pipe `ifcquery select` JSON +into `ifcedit foreach` to apply an operation to every matching element: + +```bash +# Aggregate — pass all IDs as a list parameter +ifcedit run model.ifc spatial.unassign_container \ + --products "$(ifcquery model.ifc --format ids select 'IfcWall')" + +# Fan-out — one operation per element, model opened and saved once +ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product {id} +``` + +## License + +LGPLv3+ -- see the IfcOpenShell project license. diff --git a/src/ifcedit/ifcedit/__init__.py b/src/ifcedit/ifcedit/__init__.py new file mode 100644 index 0000000000..eac2eac799 --- /dev/null +++ b/src/ifcedit/ifcedit/__init__.py @@ -0,0 +1,20 @@ +# This file was generated with the assistance of an AI coding tool. +# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions +# Copyright (C) 2026 Bruno Postle +# +# This file is part of IfcEdit. +# +# IfcEdit 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. +# +# IfcEdit 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 IfcEdit. If not, see . + +__version__ = version = "0.0.0" diff --git a/src/ifcedit/ifcedit/__main__.py b/src/ifcedit/ifcedit/__main__.py new file mode 100644 index 0000000000..1b14863e5b --- /dev/null +++ b/src/ifcedit/ifcedit/__main__.py @@ -0,0 +1,265 @@ +# This file was generated with the assistance of an AI coding tool. +# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions +# Copyright (C) 2026 Bruno Postle +# +# This file is part of IfcEdit. +# +# IfcEdit 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. +# +# IfcEdit 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 IfcEdit. If not, see . + +from __future__ import annotations + +import argparse +import json +import sys + +import ifcopenshell + +from ifcedit.discover import function_docs, list_functions, list_modules +from ifcedit.quantify import list_rules, run_quantify +from ifcedit.run import run_api +from ifcedit.foreach import run_foreach + + +def format_output(data, fmt: str) -> str: + if fmt == "json": + return json.dumps(data, indent=2, ensure_ascii=False) + elif fmt == "text": + return _format_text(data) + return json.dumps(data, indent=2, ensure_ascii=False) + + +def _format_text(data, indent: int = 0) -> str: + prefix = " " * indent + lines = [] + if isinstance(data, dict): + for key, value in data.items(): + if isinstance(value, (dict, list)): + lines.append(f"{prefix}{key}:") + lines.append(_format_text(value, indent + 1)) + else: + lines.append(f"{prefix}{key}: {value}") + elif isinstance(data, list): + for item in data: + if isinstance(item, dict): + lines.append(_format_text(item, indent)) + lines.append("") + else: + lines.append(f"{prefix}- {item}") + else: + lines.append(f"{prefix}{data}") + return "\n".join(lines) + + +def cmd_list(args): + if args.module: + try: + functions = list_functions(args.module) + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + print(format_output(functions, args.output_format)) + else: + modules = list_modules() + print(format_output(modules, args.output_format)) + + +def cmd_docs(args): + parts = args.function_path.split(".") + if len(parts) != 2: + print("Error: function path must be 'module.function' (e.g. root.create_entity)", file=sys.stderr) + sys.exit(1) + module, function = parts + try: + docs = function_docs(module, function) + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + print(format_output(docs, args.output_format)) + + +def cmd_run(args, extra_args): + try: + model = ifcopenshell.open(args.ifc_file) + except Exception as e: + print(f"Error: Could not open IFC file: {e}", file=sys.stderr) + sys.exit(1) + + parts = args.function_path.split(".") + if len(parts) != 2: + print("Error: function path must be 'module.function' (e.g. root.create_entity)", file=sys.stderr) + sys.exit(1) + module, function = parts + + # Parse extra --key value arguments into a dict + raw_kwargs = _parse_extra_args(extra_args) + + if args.dry_run: + result = {"ok": True, "dry_run": True, "module": module, "function": function, "args": raw_kwargs} + else: + result = run_api(model, module, function, raw_kwargs) + + if result["ok"]: + output_path = args.output or args.ifc_file + model.write(output_path) + + print(format_output(result, args.output_format)) + if not result["ok"]: + sys.exit(1) + + +def _parse_extra_args(extra: list[str]) -> dict[str, str]: + """Parse a list of ['--key', 'value', ...] into a dict.""" + kwargs = {} + i = 0 + while i < len(extra): + arg = extra[i] + if arg.startswith("--"): + key = arg[2:] + if i + 1 < len(extra) and not extra[i + 1].startswith("--"): + kwargs[key] = extra[i + 1] + i += 2 + else: + # Flag without value — treat as "true" + kwargs[key] = "true" + i += 1 + else: + print(f"Error: Unexpected argument: {arg}", file=sys.stderr) + sys.exit(1) + return kwargs + + +def cmd_foreach(args, extra_args): + try: + model = ifcopenshell.open(args.ifc_file) + except Exception as e: + print(f"Error: Could not open IFC file: {e}", file=sys.stderr) + sys.exit(1) + + parts = args.function_path.split(".") + if len(parts) != 2: + print("Error: function path must be 'module.function' (e.g. root.create_entity)", file=sys.stderr) + sys.exit(1) + module, function = parts + + raw_kwargs_template = _parse_extra_args(extra_args) + + try: + stdin_data = json.load(sys.stdin) + except json.JSONDecodeError as e: + print(f"Error: Could not parse JSON from stdin: {e}", file=sys.stderr) + sys.exit(1) + + if not isinstance(stdin_data, list): + print("Error: stdin must be a JSON array", file=sys.stderr) + sys.exit(1) + + result = run_foreach(model, module, function, raw_kwargs_template, stdin_data) + + if result["ok"]: + output_path = args.output or args.ifc_file + model.write(output_path) + + print(format_output(result, args.output_format)) + if not result["ok"]: + sys.exit(1) + + +def cmd_quantify(args, extra_args): + if args.quantify_command == "list": + result = list_rules() + print(format_output(result, args.output_format)) + elif args.quantify_command == "run": + try: + model = ifcopenshell.open(args.ifc_file) + except Exception as e: + print(f"Error: Could not open IFC file: {e}", file=sys.stderr) + sys.exit(1) + selector = args.selector or None + result = run_quantify(model, args.rule_name, selector=selector) + if result["ok"]: + output_path = args.output or args.ifc_file + model.write(output_path) + print(format_output(result, args.output_format)) + if not result["ok"]: + sys.exit(1) + else: + print("Error: quantify requires a subcommand: list or run", file=sys.stderr) + sys.exit(1) + + +def main(): + parser = argparse.ArgumentParser( + prog="ifcedit", + description="CLI wrapper for ifcopenshell.api IFC model mutation functions", + ) + parser.add_argument( + "--format", + choices=["json", "text"], + default="json", + dest="output_format", + help="Output format (default: json)", + ) + + subparsers = parser.add_subparsers(dest="command", required=True) + + # list + list_parser = subparsers.add_parser("list", help="List API modules or functions in a module") + list_parser.add_argument("module", nargs="?", help="Module name (omit to list all modules)") + + # docs + docs_parser = subparsers.add_parser("docs", help="Show full documentation for an API function") + docs_parser.add_argument("function_path", help="module.function (e.g. root.create_entity)") + + # run + run_parser = subparsers.add_parser("run", help="Execute an API function on an IFC file") + run_parser.add_argument("ifc_file", help="Path to the IFC file") + run_parser.add_argument("function_path", help="module.function (e.g. root.create_entity)") + run_parser.add_argument("-o", "--output", help="Output file path (default: overwrite input)") + run_parser.add_argument("--dry-run", action="store_true", help="Validate without executing or saving") + + # foreach + foreach_parser = subparsers.add_parser( + "foreach", + help="Apply an API function to each element in a JSON array read from stdin", + ) + foreach_parser.add_argument("ifc_file", help="Path to the IFC file") + foreach_parser.add_argument("function_path", help="module.function (e.g. attribute.edit_attributes)") + foreach_parser.add_argument("-o", "--output", help="Output file path (default: overwrite input)") + + # quantify + quantify_parser = subparsers.add_parser("quantify", help="Quantity take-off (QTO) using ifc5d rules") + quantify_sub = quantify_parser.add_subparsers(dest="quantify_command") + quantify_sub.add_parser("list", help="List available QTO rule names") + qrun_parser = quantify_sub.add_parser("run", help="Run QTO on an IFC file") + qrun_parser.add_argument("ifc_file", help="Path to the IFC file") + qrun_parser.add_argument("rule_name", help="QTO rule name (e.g. IFC4QtoBaseQuantities)") + qrun_parser.add_argument("--selector", help="ifcopenshell selector to restrict elements (default: all IfcElement)") + qrun_parser.add_argument("-o", "--output", help="Output file path (default: overwrite input)") + + args, extra = parser.parse_known_args() + + if args.command == "list": + cmd_list(args) + elif args.command == "docs": + cmd_docs(args) + elif args.command == "run": + cmd_run(args, extra) + elif args.command == "foreach": + cmd_foreach(args, extra) + elif args.command == "quantify": + cmd_quantify(args, extra) + + +if __name__ == "__main__": + main() diff --git a/src/ifcedit/ifcedit/coerce.py b/src/ifcedit/ifcedit/coerce.py new file mode 100644 index 0000000000..5a410ec186 --- /dev/null +++ b/src/ifcedit/ifcedit/coerce.py @@ -0,0 +1,180 @@ +# This file was generated with the assistance of an AI coding tool. +# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions +# Copyright (C) 2026 Bruno Postle +# +# This file is part of IfcEdit. +# +# IfcEdit 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. +# +# IfcEdit 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 IfcEdit. If not, see . + +from __future__ import annotations + +import json +import typing + +import ifcopenshell + + +def coerce_value( + value_str: str, + type_hint, + model: ifcopenshell.file | None = None, + lookup_file: ifcopenshell.file | None = None, +): + """Convert a CLI string argument to the proper Python type based on a type hint. + + Args: + value_str: The raw string from the CLI. + type_hint: The type annotation from the function signature. + model: The main open IFC model, needed to resolve entity instance references by ID. + lookup_file: Override file for entity resolution (e.g. a library file for + project.append_asset). When provided, entity IDs are looked up here instead + of in model. + + Returns: + The converted Python value. + + Raises: + ValueError: If the value cannot be converted. + TypeError: If the type hint is not supported. + """ + # When a library file has been opened, entity IDs are resolved from it, not the main model. + effective_lookup = lookup_file if lookup_file is not None else model + + if type_hint is None: + return value_str + + origin = typing.get_origin(type_hint) + args = typing.get_args(type_hint) + + # Union / Optional + if origin is typing.Union: + non_none_types = [a for a in args if a is not type(None)] + if value_str.lower() == "none": + if type(None) in args: + return None + # Try each non-None type in order + for t in non_none_types: + try: + return coerce_value(value_str, t, model, lookup_file) + except (ValueError, TypeError): + continue + raise ValueError(f"Cannot convert '{value_str}' to any of {non_none_types}") + + # Literal + if origin is typing.Literal: + allowed = args + if value_str in [str(a) for a in allowed]: + # return the actual literal value with proper type + for a in allowed: + if str(a) == value_str: + return a + raise ValueError(f"'{value_str}' is not one of: {', '.join(repr(a) for a in allowed)}") + + # list types + if origin is list: + if args and _is_entity_type(args[0]): + return _coerce_entity_list(value_str, effective_lookup) + if args: + items = _split_list(value_str) + return [coerce_value(item.strip(), args[0], model, lookup_file) for item in items] + return _split_list(value_str) + + # dict types + if origin is dict: + return _floatify_numeric_lists(json.loads(value_str)) + + # Simple types + if type_hint is str: + return value_str + if type_hint is int: + return int(value_str.lstrip("#")) + if type_hint is float: + return float(value_str) + if type_hint is bool: + return value_str.lower() in ("true", "1", "yes") + + # ifcopenshell.file — open from path string + if type_hint is ifcopenshell.file: + return ifcopenshell.open(value_str) + + # entity_instance + if _is_entity_type(type_hint): + return _coerce_entity(value_str, effective_lookup) + + # Fallback: try json.loads for complex types, then plain string + try: + return json.loads(value_str) + except (json.JSONDecodeError, TypeError): + return value_str + + +def _is_entity_type(hint) -> bool: + """Check if a type hint refers to ifcopenshell.entity_instance.""" + if hint is ifcopenshell.entity_instance: + return True + if isinstance(hint, type) and issubclass(hint, ifcopenshell.entity_instance): + return True + return False + + +def _coerce_entity(value_str: str | int, lookup_file: ifcopenshell.file | None) -> ifcopenshell.entity_instance: + """Resolve a step ID string like '123' or '#123' to an entity instance.""" + if lookup_file is None: + raise ValueError("Cannot resolve entity reference without an IFC model") + if isinstance(value_str, int): + entity_id = value_str + else: + entity_id = int(value_str.strip().lstrip("#")) + try: + return lookup_file.by_id(entity_id) + except RuntimeError: + raise ValueError(f"Entity #{entity_id} not found in model") + + +def _coerce_entity_list(value_str: str, lookup_file: ifcopenshell.file | None) -> list[ifcopenshell.entity_instance]: + """Resolve a comma-separated list of step IDs to entity instances.""" + items = _split_list(value_str) + return [_coerce_entity(item.strip(), lookup_file) for item in items] + + +def _floatify_numeric_lists(obj): + """Recursively convert lists of numbers to lists of floats. + + IFC C++ bindings require Python floats (not ints) for AGGREGATE OF DOUBLE + attributes (e.g. DirectionRatios, Coordinates). JSON parsing produces ints + for whole numbers like 0, which causes a TypeError at the binding level. + """ + if isinstance(obj, dict): + return {k: _floatify_numeric_lists(v) for k, v in obj.items()} + if ( + isinstance(obj, list) + and obj + and all(isinstance(v, (int, float)) for v in obj) + and any(isinstance(v, float) for v in obj) + ): + return [float(v) for v in obj] + return obj + + +def _split_list(value_str: str) -> list[str]: + """Split a comma-separated string, handling JSON arrays too.""" + value_str = value_str.strip() + if value_str.startswith("["): + try: + parsed = json.loads(value_str) + if isinstance(parsed, list): + return [json.dumps(item) if isinstance(item, (dict, list)) else str(item) for item in parsed] + except json.JSONDecodeError: + pass + return [item.strip() for item in value_str.split(",") if item.strip()] diff --git a/src/ifcedit/ifcedit/discover.py b/src/ifcedit/ifcedit/discover.py new file mode 100644 index 0000000000..b26a93f6c6 --- /dev/null +++ b/src/ifcedit/ifcedit/discover.py @@ -0,0 +1,282 @@ +# This file was generated with the assistance of an AI coding tool. +# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions +# Copyright (C) 2026 Bruno Postle +# +# This file is part of IfcEdit. +# +# IfcEdit 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. +# +# IfcEdit 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 IfcEdit. If not, see . + +from __future__ import annotations + +import importlib +import inspect +import re +import typing +from pathlib import Path + + +def _api_package_path() -> Path: + """Return the filesystem path to the ifcopenshell.api package.""" + import ifcopenshell.api + + return Path(ifcopenshell.api.__file__).parent + + +def list_modules() -> list[dict]: + """List all API modules with their function counts and descriptions. + + Returns a list of dicts: [{"module": "root", "description": "...", "functions": [...], "count": 4}, ...] + """ + api_path = _api_package_path() + modules = [] + for child in sorted(api_path.iterdir()): + if not child.is_dir() or child.name.startswith("_"): + continue + init_file = child / "__init__.py" + if not init_file.exists(): + continue + try: + mod = importlib.import_module(f"ifcopenshell.api.{child.name}") + except Exception: + continue + all_names = getattr(mod, "__all__", []) + if not all_names: + continue + description = "" + if mod.__doc__: + description = mod.__doc__.strip().split("\n")[0] + modules.append( + { + "module": child.name, + "description": description, + "functions": list(all_names), + "count": len(all_names), + } + ) + return modules + + +def list_functions(module: str) -> list[dict]: + """List functions in an API module with one-line descriptions and parameter info. + + Returns a list of dicts: [{"name": "create_entity", "description": "...", "params": [...]}] + """ + mod = importlib.import_module(f"ifcopenshell.api.{module}") + all_names = getattr(mod, "__all__", []) + functions = [] + for name in all_names: + fn = _get_underlying_function(module, name) + if fn is None: + continue + description = "" + if fn.__doc__: + description = fn.__doc__.strip().split("\n")[0] + params = _extract_params(fn) + functions.append( + { + "name": name, + "description": description, + "params": params, + } + ) + return functions + + +def function_docs(module: str, function: str) -> dict: + """Full documentation for a single API function. + + Returns a dict with: module, function, description, params (with types/defaults/descriptions), return_type + """ + fn = _get_underlying_function(module, function) + if fn is None: + raise ValueError(f"Function '{module}.{function}' not found") + + description = "" + long_description = "" + if fn.__doc__: + description, long_description = _parse_docstring_body(fn.__doc__) + + params = _extract_params(fn) + param_descriptions = _parse_param_docs(fn.__doc__ or "") + for param in params: + if param["name"] in param_descriptions: + param["description"] = param_descriptions[param["name"]] + + return_type = _format_type_hint(typing.get_type_hints(fn).get("return")) + return_description = _parse_return_doc(fn.__doc__ or "") + + result = { + "module": module, + "function": function, + "description": description, + "long_description": long_description, + "params": params, + } + if return_type: + result["return_type"] = return_type + if return_description: + result["return_description"] = return_description + return result + + +def _get_underlying_function(module: str, function: str): + """Get the actual function object (unwrapping the listener wrapper if needed).""" + try: + fn_module = importlib.import_module(f"ifcopenshell.api.{module}.{function}") + fn = getattr(fn_module, function, None) + return fn + except (ImportError, AttributeError): + return None + + +def _extract_params(fn) -> list[dict]: + """Extract parameter info from a function's signature and type hints.""" + sig = inspect.signature(fn) + try: + hints = typing.get_type_hints(fn) + except Exception: + hints = {} + + params = [] + for name, param in sig.parameters.items(): + if name == "file" or name == "self": + continue + info = {"name": name} + if name in hints: + info["type"] = _format_type_hint(hints[name]) + if param.default is not inspect.Parameter.empty: + info["default"] = _serialize_default(param.default) + else: + info["required"] = True + params.append(info) + return params + + +def _format_type_hint(hint) -> str | None: + """Format a type hint to a readable string.""" + import ifcopenshell + + if hint is None: + return None + if hint is type(None): + return "None" + # ifcopenshell.file params are passed as a file path string + if hint is ifcopenshell.file: + return "file_path" + origin = typing.get_origin(hint) + args = typing.get_args(hint) + + # Union (including Optional) + if origin is typing.Union: + formatted = [_format_type_hint(a) for a in args] + # Optional[X] is Union[X, None] — render as "Optional[X]" + if len(formatted) == 2 and "None" in formatted: + inner = next(f for f in formatted if f != "None") + return f"Optional[{inner}]" + return " | ".join(formatted) + + # Literal + if origin is typing.Literal: + values = ", ".join(repr(a) for a in args) + return f"Literal[{values}]" + + # Generic types (list, dict, etc.) + if origin is not None: + origin_name = getattr(origin, "__name__", str(origin)) + if args: + inner = ", ".join(_format_type_hint(a) for a in args) + return f"{origin_name}[{inner}]" + return origin_name + + # Simple types + return getattr(hint, "__name__", str(hint)) + + +def _serialize_default(value): + """Serialize a default value to something JSON-friendly.""" + if value is None: + return None + if isinstance(value, (str, int, float, bool)): + return value + return repr(value) + + +def _parse_docstring_body(docstring: str) -> tuple[str, str]: + """Parse the summary and long description from a docstring.""" + lines = docstring.strip().split("\n") + summary = lines[0].strip() if lines else "" + body_lines = [] + in_body = False + for line in lines[1:]: + stripped = line.strip() + if stripped.startswith(":param") or stripped.startswith(":return"): + break + if stripped.startswith("Example"): + break + if not in_body and not stripped: + in_body = True + continue + if in_body: + body_lines.append(stripped) + + long_description = " ".join(body_lines).strip() + # collapse multiple spaces + long_description = re.sub(r"\s+", " ", long_description) + return summary, long_description + + +_FIELD_MARKER = re.compile(r":(?:param|returns?|rtype|type|raises?)\b") + + +def _parse_param_docs(docstring: str) -> dict[str, str]: + """Extract :param name: description lines from a docstring.""" + params = {} + current_param = None + current_lines = [] + for line in docstring.split("\n"): + stripped = line.strip() + match = re.match(r":param\s+(\w+):\s*(.*)", stripped) + if match: + if current_param: + params[current_param] = " ".join(current_lines).strip() + current_param = match.group(1) + current_lines = [match.group(2)] + elif current_param and stripped and not _FIELD_MARKER.match(stripped): + current_lines.append(stripped) + elif _FIELD_MARKER.match(stripped) or (stripped == "" and current_param): + if current_param: + params[current_param] = " ".join(current_lines).strip() + current_param = None + current_lines = [] + if current_param: + params[current_param] = " ".join(current_lines).strip() + # collapse whitespace + return {k: re.sub(r"\s+", " ", v) for k, v in params.items()} + + +def _parse_return_doc(docstring: str) -> str: + """Extract :return: description from a docstring.""" + lines = [] + in_return = False + for line in docstring.split("\n"): + stripped = line.strip() + match = re.match(r":return:\s*(.*)", stripped) + if match: + in_return = True + lines = [match.group(1)] + elif in_return: + if _FIELD_MARKER.match(stripped) or stripped == "": + break + lines.append(stripped) + return re.sub(r"\s+", " ", " ".join(lines).strip()) diff --git a/src/ifcedit/ifcedit/foreach.py b/src/ifcedit/ifcedit/foreach.py new file mode 100644 index 0000000000..587d611c3f --- /dev/null +++ b/src/ifcedit/ifcedit/foreach.py @@ -0,0 +1,76 @@ +# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions +# Copyright (C) 2026 Bruno Postle +# +# This file is part of IfcEdit. +# +# IfcEdit 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. +# +# IfcEdit 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 IfcEdit. If not, see . + +from __future__ import annotations + +import ifcopenshell + +from ifcedit.run import run_api + + +def _substitute(template: str, item: dict) -> str: + """Replace {key} placeholders in template with values from item.""" + for key, value in item.items(): + template = template.replace(f"{{{key}}}", str(value)) + return template + + +def run_foreach( + model: ifcopenshell.file, + module: str, + function: str, + raw_kwargs_template: dict[str, str], + items: list[dict], +) -> dict: + """Apply an API function to each item in a list, substituting {field} placeholders. + + Opens the model once, applies the mutation for every item, and returns a summary. + The caller is responsible for saving the model. + + Args: + model: The open IFC model (mutated in place). + module: API module name (e.g. "root"). + function: Function name (e.g. "remove_product"). + raw_kwargs_template: Arg templates with {field} placeholders, e.g. {"product": "{id}"}. + items: List of dicts (e.g. from ifcquery select output). + + Returns: + {"ok": True, "count": N, "errors": []} on full success, + {"ok": False, "count": N, "errors": [{...}]} if any item failed. + """ + errors = [] + count = 0 + + for i, item in enumerate(items): + if not isinstance(item, dict): + errors.append({"index": i, "item": item, "error": "item is not a dict"}) + continue + + substituted = {k: _substitute(v, item) for k, v in raw_kwargs_template.items()} + result = run_api(model, module, function, substituted) + + if result["ok"]: + count += 1 + else: + errors.append({"index": i, "item": item, "error": result["error"]}) + + return { + "ok": len(errors) == 0, + "count": count, + "errors": errors, + } diff --git a/src/ifcedit/ifcedit/quantify.py b/src/ifcedit/ifcedit/quantify.py new file mode 100644 index 0000000000..f85475e22c --- /dev/null +++ b/src/ifcedit/ifcedit/quantify.py @@ -0,0 +1,37 @@ +# This file was generated with the assistance of an AI coding tool. +from __future__ import annotations + +from typing import Any + +import ifcopenshell + +AVAILABLE_RULES = ["IFC4QtoBaseQuantities", "IFC4X3QtoBaseQuantities"] + + +def list_rules() -> list[dict[str, str]]: + """Return a list of available quantification rule names.""" + return [{"name": name} for name in AVAILABLE_RULES] + + +def run_quantify(model: ifcopenshell.file, rule: str, selector: str | None = None) -> dict[str, Any]: + """Run quantity take-off on the model using the named rule. + + Modifies the model in-place by adding/updating IfcElementQuantity psets. + Returns a summary dict with ok, rule, and elements_quantified. + """ + from ifc5d.qto import edit_qtos, quantify + from ifc5d.qto import rules as rule_sets + + if rule not in rule_sets: + return {"ok": False, "error": f"Unknown rule: {rule}. Available: {list(rule_sets.keys())}"} + + import ifcopenshell.util.selector + + if selector: + elements = set(ifcopenshell.util.selector.filter_elements(model, selector)) + else: + elements = set(model.by_type("IfcElement")) + + results = quantify(model, elements, rule_sets[rule]) + edit_qtos(model, results) + return {"ok": True, "rule": rule, "elements_quantified": len(results)} diff --git a/src/ifcedit/ifcedit/run.py b/src/ifcedit/ifcedit/run.py new file mode 100644 index 0000000000..91d8be6774 --- /dev/null +++ b/src/ifcedit/ifcedit/run.py @@ -0,0 +1,148 @@ +# This file was generated with the assistance of an AI coding tool. +# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions +# Copyright (C) 2026 Bruno Postle +# +# This file is part of IfcEdit. +# +# IfcEdit 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. +# +# IfcEdit 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 IfcEdit. If not, see . + +from __future__ import annotations + +import importlib +import inspect +import typing + +import ifcopenshell + +from ifcedit.coerce import coerce_value + + +def _is_file_type(hint) -> bool: + """Check if a type hint refers to ifcopenshell.file (or Optional[ifcopenshell.file]).""" + if hint is ifcopenshell.file: + return True + origin = typing.get_origin(hint) + args = typing.get_args(hint) + if origin is typing.Union and ifcopenshell.file in args: + return True + return False + + +def run_api( + model: ifcopenshell.file, + module: str, + function: str, + raw_kwargs: dict[str, str], +) -> dict: + """Execute an ifcopenshell.api function with CLI-provided string arguments. + + Args: + model: The open IFC model. + module: API module name (e.g. "root"). + function: Function name (e.g. "create_entity"). + raw_kwargs: String keyword arguments from the CLI. + + Returns: + A dict with {"ok": True, "result": ...} on success, + or {"ok": False, "error": "..."} on failure. + """ + try: + fn = _import_function(module, function) + except (ImportError, AttributeError) as e: + return {"ok": False, "error": f"Cannot find function '{module}.{function}': {e}"} + + try: + hints = typing.get_type_hints(fn) + except Exception: + hints = {} + + sig = inspect.signature(fn) + coerced_kwargs = {} + + # Pass 1: coerce ifcopenshell.file-typed params first (e.g. library= in append_asset). + # The opened file is then used as the lookup file for entity resolution in pass 2. + opened_files: list[ifcopenshell.file] = [] + for name, value_str in raw_kwargs.items(): + if name not in sig.parameters: + return {"ok": False, "error": f"Unknown parameter '{name}' for {module}.{function}"} + hint = hints.get(name) + if not _is_file_type(hint): + continue + try: + coerced = coerce_value(value_str, hint, model) + coerced_kwargs[name] = coerced + if isinstance(coerced, ifcopenshell.file): + opened_files.append(coerced) + except (ValueError, TypeError) as e: + return {"ok": False, "error": f"Cannot convert parameter '{name}': {e}"} + + # Pass 2: coerce remaining params. Entity instance IDs are resolved from the opened + # library file (if any), since you are always appending from another file, never + # from the current model. + lookup_file = opened_files[0] if opened_files else None + for name, value_str in raw_kwargs.items(): + if name in coerced_kwargs: + continue + if name not in sig.parameters: + return {"ok": False, "error": f"Unknown parameter '{name}' for {module}.{function}"} + hint = hints.get(name) + try: + coerced_kwargs[name] = coerce_value(value_str, hint, model, lookup_file=lookup_file) + except (ValueError, TypeError) as e: + return {"ok": False, "error": f"Cannot convert parameter '{name}': {e}"} + + # Determine if the function takes 'file' as its first parameter + first_param = next(iter(sig.parameters), None) + try: + if first_param == "file": + result = fn(model, **coerced_kwargs) + else: + result = fn(**coerced_kwargs) + except Exception as e: + return {"ok": False, "error": f"{type(e).__name__}: {e}"} + + return {"ok": True, "result": serialize_result(result)} + + +def _import_function(module: str, function: str): + """Import and return the underlying function from ifcopenshell.api.""" + fn_module = importlib.import_module(f"ifcopenshell.api.{module}.{function}") + fn = getattr(fn_module, function) + return fn + + +def serialize_result(value) -> object: + """Serialize an API result to a JSON-friendly structure.""" + if value is None: + return None + if isinstance(value, ifcopenshell.entity_instance): + return _serialize_entity(value) + if isinstance(value, (list, tuple, set, frozenset)): + return [serialize_result(item) for item in value] + if isinstance(value, dict): + return {str(k): serialize_result(v) for k, v in value.items()} + if isinstance(value, (str, int, float, bool)): + return value + return str(value) + + +def _serialize_entity(entity: ifcopenshell.entity_instance) -> dict: + """Serialize an entity instance to a summary dict.""" + result = { + "id": entity.id(), + "type": entity.is_a(), + } + if hasattr(entity, "Name") and entity.Name: + result["name"] = entity.Name + return result diff --git a/src/ifcedit/pyproject.toml b/src/ifcedit/pyproject.toml new file mode 100644 index 0000000000..f50c0b3cc4 --- /dev/null +++ b/src/ifcedit/pyproject.toml @@ -0,0 +1,33 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "ifcedit" +version = "0.0.0" +authors = [ + { name="Bruno Postle", email="bruno@postle.net" }, +] +description = "CLI wrapper for ifcopenshell.api IFC model mutation functions" +readme = "README.md" +keywords = ["IFC", "BIM", "API"] +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)", +] +dependencies = ["ifcopenshell", "ifc5d"] + +[project.scripts] +ifcedit = "ifcedit.__main__:main" + +[project.urls] +Homepage = "http://ifcopenshell.org" +Documentation = "https://docs.ifcopenshell.org" +Issues = "https://github.com/IfcOpenShell/IfcOpenShell/issues" + +[tool.setuptools.packages.find] +include = ["ifcedit*"] +exclude = ["test*"] + +[tool.ruff] +extend = "../../pyproject.toml" diff --git a/src/ifcedit/tests/__init__.py b/src/ifcedit/tests/__init__.py new file mode 100644 index 0000000000..0a3bc271a0 --- /dev/null +++ b/src/ifcedit/tests/__init__.py @@ -0,0 +1 @@ +# This file was generated with the assistance of an AI coding tool. diff --git a/src/ifcedit/tests/conftest.py b/src/ifcedit/tests/conftest.py new file mode 100644 index 0000000000..241220b145 --- /dev/null +++ b/src/ifcedit/tests/conftest.py @@ -0,0 +1,63 @@ +# This file was generated with the assistance of an AI coding tool. +import ifcopenshell +import ifcopenshell.api.aggregate +import ifcopenshell.api.material +import ifcopenshell.api.owner.settings +import ifcopenshell.api.project +import ifcopenshell.api.pset +import ifcopenshell.api.root +import ifcopenshell.api.spatial +import ifcopenshell.api.unit +import pytest + + +@pytest.fixture +def model(): + """Create an IFC4 model with a spatial hierarchy and a wall.""" + f = ifcopenshell.api.project.create_file() + ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0] + ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0] + + project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="TestProject") + ifcopenshell.api.unit.assign_unit(f) + + site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="TestSite") + building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="TestBuilding") + storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="Ground Floor") + + ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project) + ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site) + ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=building) + + wall = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall001") + ifcopenshell.api.spatial.assign_container(f, products=[wall], relating_structure=storey) + + return f + + +@pytest.fixture +def model_file(model, tmp_path): + """Write the model fixture to a temp file and return the path.""" + path = tmp_path / "test.ifc" + model.write(str(path)) + return str(path) + + +@pytest.fixture +def library(): + """Create an IFC4 library with a single IfcWallType asset.""" + lib = ifcopenshell.api.project.create_file() + ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0] + ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0] + ifcopenshell.api.root.create_entity(lib, ifc_class="IfcProject", name="TestLibrary") + ifcopenshell.api.unit.assign_unit(lib) + ifcopenshell.api.root.create_entity(lib, ifc_class="IfcWallType", name="WAL01") + return lib + + +@pytest.fixture +def library_file(library, tmp_path): + """Write the library fixture to a temp file and return the path.""" + path = tmp_path / "library.ifc" + library.write(str(path)) + return str(path) diff --git a/src/ifcedit/tests/test_coerce.py b/src/ifcedit/tests/test_coerce.py new file mode 100644 index 0000000000..4ee6895717 --- /dev/null +++ b/src/ifcedit/tests/test_coerce.py @@ -0,0 +1,158 @@ +# This file was generated with the assistance of an AI coding tool. +import json +from typing import Literal, Optional, Union + +import ifcopenshell +import ifcopenshell.api.project +import pytest + +from ifcedit.coerce import coerce_value + + +class TestStringCoercion: + def test_plain_string(self): + assert coerce_value("hello", str) == "hello" + + def test_empty_string(self): + assert coerce_value("", str) == "" + + +class TestIntCoercion: + def test_plain_int(self): + assert coerce_value("42", int) == 42 + + def test_hash_prefix(self): + assert coerce_value("#42", int) == 42 + + def test_negative(self): + assert coerce_value("-5", int) == -5 + + +class TestFloatCoercion: + def test_plain_float(self): + assert coerce_value("3.14", float) == pytest.approx(3.14) + + def test_integer_as_float(self): + assert coerce_value("5", float) == 5.0 + + +class TestBoolCoercion: + def test_true_values(self): + for val in ("true", "True", "TRUE", "1", "yes"): + assert coerce_value(val, bool) is True + + def test_false_values(self): + for val in ("false", "False", "0", "no"): + assert coerce_value(val, bool) is False + + +class TestOptionalCoercion: + def test_optional_string(self): + assert coerce_value("hello", Optional[str]) == "hello" + + def test_optional_none(self): + assert coerce_value("none", Optional[str]) is None + assert coerce_value("None", Optional[str]) is None + + def test_optional_int(self): + assert coerce_value("42", Optional[int]) == 42 + + +class TestUnionCoercion: + def test_union_str_int(self): + # Tries str first (or int first depending on order), both work + result = coerce_value("hello", Union[str, int]) + assert result == "hello" + + def test_union_int_none(self): + result = coerce_value("42", Union[int, None]) + assert result == 42 + + +class TestLiteralCoercion: + def test_valid_literal(self): + assert coerce_value("IFC4", Literal["IFC2X3", "IFC4", "IFC4X3"]) == "IFC4" + + def test_invalid_literal(self): + with pytest.raises(ValueError, match="not one of"): + coerce_value("IFC5", Literal["IFC2X3", "IFC4", "IFC4X3"]) + + +class TestDictCoercion: + def test_json_dict(self): + result = coerce_value('{"IsExternal": true, "FireRating": "2HR"}', dict[str, object]) + assert result == {"IsExternal": True, "FireRating": "2HR"} + + def test_mixed_float_int_list_coerced_to_float(self): + # [0.419, 0, 0.908] — JSON integer 0 mixed with floats must become float + # so ifcopenshell AGGREGATE OF DOUBLE attributes (e.g. DirectionRatios) don't reject the list + result = coerce_value('{"DirectionRatios": [0.419, 0, 0.908]}', dict[str, object]) + assert result["DirectionRatios"] == pytest.approx([0.419, 0.0, 0.908]) + assert all(isinstance(v, float) for v in result["DirectionRatios"]) + + def test_pure_int_list_not_coerced(self): + # All-integer lists (e.g. face indices) must stay as ints + result = coerce_value('{"CoordIndex": [0, 1, 2]}', dict[str, object]) + assert result["CoordIndex"] == [0, 1, 2] + assert all(isinstance(v, int) for v in result["CoordIndex"]) + + +class TestListCoercion: + def test_comma_separated(self): + result = coerce_value("a,b,c", list[str]) + assert result == ["a", "b", "c"] + + def test_json_array(self): + result = coerce_value("[1, 2, 3]", list[int]) + assert result == [1, 2, 3] + + +class TestEntityCoercion: + def test_entity_by_id(self, model): + wall = model.by_type("IfcWall")[0] + result = coerce_value(str(wall.id()), ifcopenshell.entity_instance, model) + assert result == wall + + def test_entity_with_hash(self, model): + wall = model.by_type("IfcWall")[0] + result = coerce_value(f"#{wall.id()}", ifcopenshell.entity_instance, model) + assert result == wall + + def test_entity_not_found(self, model): + with pytest.raises(ValueError, match="not found"): + coerce_value("999999", ifcopenshell.entity_instance, model) + + def test_entity_list(self, model): + wall = model.by_type("IfcWall")[0] + result = coerce_value(str(wall.id()), list[ifcopenshell.entity_instance], model) + assert len(result) == 1 + assert result[0] == wall + + def test_entity_list_multiple(self, model): + wall = model.by_type("IfcWall")[0] + storey = model.by_type("IfcBuildingStorey")[0] + result = coerce_value(f"{wall.id()},{storey.id()}", list[ifcopenshell.entity_instance], model) + assert len(result) == 2 + + def test_entity_no_model(self): + with pytest.raises(ValueError, match="without an IFC model"): + coerce_value("42", ifcopenshell.entity_instance, None) + + +class TestFileCoercion: + def test_opens_file_from_path(self, model_file): + result = coerce_value(model_file, ifcopenshell.file) + assert isinstance(result, ifcopenshell.file) + + def test_entity_from_lookup_file(self, model_file): + lib = ifcopenshell.open(model_file) + wall = lib.by_type("IfcWall")[0] + empty_model = ifcopenshell.api.project.create_file() + result = coerce_value(str(wall.id()), ifcopenshell.entity_instance, empty_model, lookup_file=lib) + assert result.id() == wall.id() + assert result.is_a("IfcWall") + + +class TestFallback: + def test_no_type_hint(self): + assert coerce_value("hello", None) == "hello" diff --git a/src/ifcedit/tests/test_discover.py b/src/ifcedit/tests/test_discover.py new file mode 100644 index 0000000000..7b77cb5b7e --- /dev/null +++ b/src/ifcedit/tests/test_discover.py @@ -0,0 +1,104 @@ +# This file was generated with the assistance of an AI coding tool. +from ifcedit.discover import function_docs, list_functions, list_modules + + +class TestListModules: + def test_returns_list(self): + result = list_modules() + assert isinstance(result, list) + assert len(result) > 0 + + def test_module_structure(self): + result = list_modules() + for entry in result: + assert "module" in entry + assert "description" in entry + assert "functions" in entry + assert "count" in entry + assert isinstance(entry["functions"], list) + assert entry["count"] == len(entry["functions"]) + + def test_known_modules_present(self): + result = list_modules() + module_names = [m["module"] for m in result] + for expected in ("root", "spatial", "pset", "aggregate", "unit"): + assert expected in module_names + + def test_root_module_has_functions(self): + result = list_modules() + root = next(m for m in result if m["module"] == "root") + assert "create_entity" in root["functions"] + assert root["count"] >= 3 + + +class TestListFunctions: + def test_root_functions(self): + result = list_functions("root") + assert isinstance(result, list) + names = [f["name"] for f in result] + assert "create_entity" in names + + def test_function_structure(self): + result = list_functions("root") + for fn in result: + assert "name" in fn + assert "description" in fn + assert "params" in fn + + def test_create_entity_params(self): + result = list_functions("root") + create = next(f for f in result if f["name"] == "create_entity") + param_names = [p["name"] for p in create["params"]] + assert "ifc_class" in param_names + assert "name" in param_names + + def test_pset_functions(self): + result = list_functions("pset") + names = [f["name"] for f in result] + assert "add_pset" in names + assert "edit_pset" in names + + +class TestFunctionDocs: + def test_create_entity_docs(self): + result = function_docs("root", "create_entity") + assert result["module"] == "root" + assert result["function"] == "create_entity" + assert result["description"] + assert isinstance(result["params"], list) + assert len(result["params"]) > 0 + + def test_params_have_types(self): + result = function_docs("root", "create_entity") + for param in result["params"]: + assert "name" in param + assert "type" in param + + def test_params_have_descriptions(self): + result = function_docs("root", "create_entity") + ifc_class = next(p for p in result["params"] if p["name"] == "ifc_class") + assert "description" in ifc_class + assert len(ifc_class["description"]) > 0 + + def test_return_type(self): + result = function_docs("root", "create_entity") + assert "return_type" in result + + def test_assign_container_docs(self): + result = function_docs("spatial", "assign_container") + assert result["module"] == "spatial" + param_names = [p["name"] for p in result["params"]] + assert "products" in param_names + assert "relating_structure" in param_names + + def test_unknown_function_raises(self): + import pytest + + with pytest.raises(ValueError, match="not found"): + function_docs("root", "nonexistent_function") + + def test_edit_pset_docs(self): + result = function_docs("pset", "edit_pset") + param_names = [p["name"] for p in result["params"]] + assert "pset" in param_names + assert "properties" in param_names diff --git a/src/ifcedit/tests/test_foreach.py b/src/ifcedit/tests/test_foreach.py new file mode 100644 index 0000000000..e51c3c03be --- /dev/null +++ b/src/ifcedit/tests/test_foreach.py @@ -0,0 +1,79 @@ +# Tests for ifcedit.foreach +import ifcopenshell +import ifcopenshell.api.owner.settings +import ifcopenshell.api.project +import ifcopenshell.api.root +import ifcopenshell.api.spatial +import ifcopenshell.api.unit +import pytest + +from ifcedit.foreach import _substitute, run_foreach + + +@pytest.fixture +def model(model): + return model + + +class TestSubstitute: + def test_single_field(self): + assert _substitute("--product {id}", {"id": 42}) == "--product 42" + + def test_multiple_fields(self): + result = _substitute("{type} #{id} ({name})", {"id": 5, "type": "IfcWall", "name": "W1"}) + assert result == "IfcWall #5 (W1)" + + def test_no_placeholder(self): + assert _substitute("hello", {"id": 1}) == "hello" + + def test_unknown_placeholder_unchanged(self): + assert _substitute("{unknown}", {"id": 1}) == "{unknown}" + + +class TestRunForeach: + def _items(self, model, ifc_class): + return [{"id": e.id(), "type": e.is_a(), "name": e.Name} for e in model.by_type(ifc_class)] + + def test_rename_single(self, model): + items = self._items(model, "IfcWall") + result = run_foreach(model, "attribute", "edit_attributes", {"product": "{id}", "attributes": '{"Name": "R"}'}, items) + assert result["ok"] is True + assert result["count"] == 1 + assert result["errors"] == [] + assert model.by_type("IfcWall")[0].Name == "R" + + def test_rename_multiple(self, model): + items = self._items(model, "IfcElement") + result = run_foreach(model, "attribute", "edit_attributes", {"product": "{id}", "attributes": '{"Name": "X"}'}, items) + assert result["ok"] is True + assert result["count"] == len(items) + + def test_empty_list(self, model): + result = run_foreach(model, "root", "remove_product", {"product": "{id}"}, []) + assert result["ok"] is True + assert result["count"] == 0 + assert result["errors"] == [] + + def test_bad_id_collects_error(self, model): + items = [{"id": 999999, "type": "IfcWall", "name": "X"}] + result = run_foreach(model, "root", "remove_product", {"product": "{id}"}, items) + assert result["ok"] is False + assert result["count"] == 0 + assert len(result["errors"]) == 1 + assert result["errors"][0]["index"] == 0 + + def test_non_dict_item_collects_error(self, model): + result = run_foreach(model, "root", "remove_product", {"product": "{id}"}, ["not_a_dict"]) + assert result["ok"] is False + assert len(result["errors"]) == 1 + + def test_partial_failure_counts_successes(self, model): + wall_id = model.by_type("IfcWall")[0].id() + items = [ + {"id": wall_id, "type": "IfcWall", "name": "W"}, + {"id": 999999, "type": "IfcWall", "name": "Bad"}, + ] + result = run_foreach(model, "attribute", "edit_attributes", {"product": "{id}", "attributes": '{"Name": "Ok"}'}, items) + assert result["ok"] is False + assert result["count"] == 1 + assert len(result["errors"]) == 1 diff --git a/src/ifcedit/tests/test_main.py b/src/ifcedit/tests/test_main.py new file mode 100644 index 0000000000..63bf75a217 --- /dev/null +++ b/src/ifcedit/tests/test_main.py @@ -0,0 +1,181 @@ +# This file was generated with the assistance of an AI coding tool. +import json +import subprocess +import sys + +import ifcopenshell +import pytest + + +def run_ifcedit(*args, stdin=None): + """Run ifcedit as a subprocess and return (stdout, stderr, returncode).""" + result = subprocess.run( + [sys.executable, "-m", "ifcedit", *args], + capture_output=True, + text=True, + input=stdin, + ) + return result.stdout, result.stderr, result.returncode + + +class TestListCommand: + def test_list_all_modules(self): + stdout, stderr, rc = run_ifcedit("list") + assert rc == 0 + data = json.loads(stdout) + assert isinstance(data, list) + module_names = [m["module"] for m in data] + assert "root" in module_names + assert "spatial" in module_names + + def test_list_module_functions(self): + stdout, stderr, rc = run_ifcedit("list", "root") + assert rc == 0 + data = json.loads(stdout) + assert isinstance(data, list) + names = [f["name"] for f in data] + assert "create_entity" in names + + def test_list_text_format(self): + stdout, stderr, rc = run_ifcedit("--format", "text", "list") + assert rc == 0 + assert "root" in stdout + + +class TestDocsCommand: + def test_docs_create_entity(self): + stdout, stderr, rc = run_ifcedit("docs", "root.create_entity") + assert rc == 0 + data = json.loads(stdout) + assert data["module"] == "root" + assert data["function"] == "create_entity" + assert "params" in data + + def test_docs_invalid_path(self): + stdout, stderr, rc = run_ifcedit("docs", "invalid_path") + assert rc != 0 + assert "module.function" in stderr + + def test_docs_unknown_function(self): + stdout, stderr, rc = run_ifcedit("docs", "root.nonexistent") + assert rc != 0 + + +class TestRunCommand: + def test_create_entity(self, model_file): + stdout, stderr, rc = run_ifcedit( + "run", model_file, "root.create_entity", "--ifc_class", "IfcWall", "--name", "CLIWall" + ) + assert rc == 0, f"stderr: {stderr}" + data = json.loads(stdout) + assert data["ok"] is True + assert data["result"]["type"] == "IfcWall" + assert data["result"]["name"] == "CLIWall" + + def test_dry_run(self, model_file): + stdout, stderr, rc = run_ifcedit("run", model_file, "root.create_entity", "--dry-run", "--ifc_class", "IfcWall") + assert rc == 0 + data = json.loads(stdout) + assert data["ok"] is True + assert data["dry_run"] is True + + def test_output_to_different_file(self, model_file, tmp_path): + output = str(tmp_path / "output.ifc") + stdout, stderr, rc = run_ifcedit( + "run", model_file, "root.create_entity", "-o", output, "--ifc_class", "IfcSlab" + ) + assert rc == 0, f"stderr: {stderr}" + data = json.loads(stdout) + assert data["ok"] is True + + import os + + assert os.path.exists(output) + + def test_run_error_bad_function(self, model_file): + stdout, stderr, rc = run_ifcedit("run", model_file, "root.nonexistent") + assert rc != 0 + + def test_run_invalid_function_path(self, model_file): + stdout, stderr, rc = run_ifcedit("run", model_file, "invalid_path") + assert rc != 0 + assert "module.function" in stderr + + +class TestForeachCommand: + def _select_json(self, model, ifc_class): + """Build a JSON array like ifcquery select would produce.""" + elements = model.by_type(ifc_class) + return json.dumps([{"id": e.id(), "type": e.is_a(), "name": getattr(e, "Name", None)} for e in elements]) + + def test_foreach_rename(self, model, model_file): + walls_json = self._select_json(model, "IfcWall") + stdout, stderr, rc = run_ifcedit( + "foreach", model_file, "attribute.edit_attributes", + "--product", "{id}", "--attributes", '{"Name": "Renamed"}', + stdin=walls_json, + ) + assert rc == 0, f"stderr: {stderr}" + data = json.loads(stdout) + assert data["ok"] is True + assert data["count"] == 1 + assert data["errors"] == [] + updated = ifcopenshell.open(model_file) + assert updated.by_type("IfcWall")[0].Name == "Renamed" + + def test_foreach_multiple_elements(self, model, model_file): + # Build a two-item list by selecting all IfcObject (includes spatial structure + elements) + elements_json = self._select_json(model, "IfcObject") + items = json.loads(elements_json) + assert len(items) >= 2 + stdout, stderr, rc = run_ifcedit( + "foreach", model_file, "attribute.edit_attributes", + "--product", "{id}", "--attributes", '{"Name": "Bulk"}', + stdin=elements_json, + ) + assert rc == 0, f"stderr: {stderr}" + data = json.loads(stdout) + assert data["ok"] is True + assert data["count"] == len(items) + + def test_foreach_empty_list(self, model_file): + stdout, stderr, rc = run_ifcedit( + "foreach", model_file, "attribute.edit_attributes", + "--product", "{id}", "--attributes", '{"Name": "X"}', + stdin="[]", + ) + assert rc == 0 + data = json.loads(stdout) + assert data["ok"] is True + assert data["count"] == 0 + + def test_foreach_invalid_json_stdin(self, model_file): + stdout, stderr, rc = run_ifcedit( + "foreach", model_file, "root.remove_product", "--product", "{id}", + stdin="not json", + ) + assert rc != 0 + assert "Error" in stderr + + def test_foreach_not_array_stdin(self, model_file): + stdout, stderr, rc = run_ifcedit( + "foreach", model_file, "root.remove_product", "--product", "{id}", + stdin='{"id": 1}', + ) + assert rc != 0 + assert "Error" in stderr + + def test_foreach_output_to_different_file(self, model, model_file, tmp_path): + import os + output = str(tmp_path / "out.ifc") + walls_json = self._select_json(model, "IfcWall") + stdout, stderr, rc = run_ifcedit( + "foreach", model_file, "attribute.edit_attributes", + "-o", output, + "--product", "{id}", "--attributes", '{"Name": "OutFile"}', + stdin=walls_json, + ) + assert rc == 0, f"stderr: {stderr}" + assert os.path.exists(output) + updated = ifcopenshell.open(output) + assert updated.by_type("IfcWall")[0].Name == "OutFile" diff --git a/src/ifcedit/tests/test_quantify.py b/src/ifcedit/tests/test_quantify.py new file mode 100644 index 0000000000..f4335fb2a7 --- /dev/null +++ b/src/ifcedit/tests/test_quantify.py @@ -0,0 +1,87 @@ +# This file was generated with the assistance of an AI coding tool. +from __future__ import annotations + +import ifcopenshell +import ifcopenshell.api.aggregate +import ifcopenshell.api.owner.settings +import ifcopenshell.api.project +import ifcopenshell.api.root +import ifcopenshell.api.spatial +import ifcopenshell.api.unit +import pytest + +from ifcedit.quantify import AVAILABLE_RULES, list_rules, run_quantify + + +class TestListRules: + def test_returns_list(self): + result = list_rules() + assert isinstance(result, list) + + def test_each_entry_has_name(self): + result = list_rules() + for entry in result: + assert "name" in entry + + def test_ifc4_rule_present(self): + result = list_rules() + names = [r["name"] for r in result] + assert "IFC4QtoBaseQuantities" in names + + def test_ifc4x3_rule_present(self): + result = list_rules() + names = [r["name"] for r in result] + assert "IFC4X3QtoBaseQuantities" in names + + +@pytest.fixture +def quantify_model(): + """Create an IFC4 model with a wall element.""" + f = ifcopenshell.api.project.create_file() + ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0] + ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0] + + project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="TestProject") + ifcopenshell.api.unit.assign_unit(f) + + site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="TestSite") + building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="TestBuilding") + storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="Ground Floor") + + ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project) + ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site) + ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=building) + + wall = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall001") + ifcopenshell.api.spatial.assign_container(f, products=[wall], relating_structure=storey) + + return f + + +class TestRunQuantify: + def test_returns_ok_true(self, quantify_model): + result = run_quantify(quantify_model, "IFC4QtoBaseQuantities") + assert result["ok"] is True + + def test_returns_rule_name(self, quantify_model): + result = run_quantify(quantify_model, "IFC4QtoBaseQuantities") + assert result["rule"] == "IFC4QtoBaseQuantities" + + def test_returns_elements_quantified(self, quantify_model): + result = run_quantify(quantify_model, "IFC4QtoBaseQuantities") + assert "elements_quantified" in result + assert isinstance(result["elements_quantified"], int) + + def test_unknown_rule_returns_error(self, quantify_model): + result = run_quantify(quantify_model, "NonExistentRule") + assert result["ok"] is False + assert "error" in result + + def test_selector_restricts_elements(self, quantify_model): + result = run_quantify(quantify_model, "IFC4QtoBaseQuantities", selector="IfcWall") + assert result["ok"] is True + assert result["rule"] == "IFC4QtoBaseQuantities" + + def test_empty_selector_runs_on_all(self, quantify_model): + result = run_quantify(quantify_model, "IFC4QtoBaseQuantities", selector=None) + assert result["ok"] is True diff --git a/src/ifcedit/tests/test_run.py b/src/ifcedit/tests/test_run.py new file mode 100644 index 0000000000..80881e879f --- /dev/null +++ b/src/ifcedit/tests/test_run.py @@ -0,0 +1,97 @@ +# This file was generated with the assistance of an AI coding tool. +import ifcopenshell +import ifcopenshell.api.project +import ifcopenshell.api.pset +import ifcopenshell.api.root + +from ifcedit.run import run_api, serialize_result + + +class TestRunApi: + def test_create_entity(self, model): + result = run_api(model, "root", "create_entity", {"ifc_class": "IfcWall", "name": "NewWall"}) + assert result["ok"] is True + assert result["result"]["type"] == "IfcWall" + assert result["result"]["name"] == "NewWall" + assert isinstance(result["result"]["id"], int) + + def test_create_entity_default_class(self, model): + result = run_api(model, "root", "create_entity", {}) + assert result["ok"] is True + assert result["result"]["type"] == "IfcBuildingElementProxy" + + def test_assign_container(self, model): + wall = ifcopenshell.api.root.create_entity(model, ifc_class="IfcWall", name="TestWall2") + storey = model.by_type("IfcBuildingStorey")[0] + result = run_api( + model, + "spatial", + "assign_container", + {"products": str(wall.id()), "relating_structure": str(storey.id())}, + ) + assert result["ok"] is True + assert result["result"]["type"] == "IfcRelContainedInSpatialStructure" + + def test_add_pset(self, model): + wall = model.by_type("IfcWall")[0] + result = run_api(model, "pset", "add_pset", {"product": str(wall.id()), "name": "Pset_WallCommon"}) + assert result["ok"] is True + assert result["result"]["type"] == "IfcPropertySet" + + def test_unknown_function(self, model): + result = run_api(model, "root", "nonexistent", {}) + assert result["ok"] is False + assert "Cannot find" in result["error"] + + def test_unknown_parameter(self, model): + result = run_api(model, "root", "create_entity", {"bogus_param": "value"}) + assert result["ok"] is False + assert "Unknown parameter" in result["error"] + + def test_bad_entity_reference(self, model): + result = run_api(model, "pset", "add_pset", {"product": "999999", "name": "Pset_WallCommon"}) + assert result["ok"] is False + assert "not found" in result["error"] + + +class TestAppendAsset: + def test_append_asset_from_library(self, model, library_file): + lib = ifcopenshell.open(library_file) + wall_type = lib.by_type("IfcWallType")[0] + result = run_api( + model, + "project", + "append_asset", + {"library": library_file, "element": str(wall_type.id())}, + ) + assert result["ok"] is True + assert result["result"]["type"] == "IfcWallType" + assert model.by_type("IfcWallType"), "wall type should have been appended to the model" + + +class TestSerializeResult: + def test_none(self): + assert serialize_result(None) is None + + def test_string(self): + assert serialize_result("hello") == "hello" + + def test_int(self): + assert serialize_result(42) == 42 + + def test_entity(self, model): + wall = model.by_type("IfcWall")[0] + result = serialize_result(wall) + assert result["id"] == wall.id() + assert result["type"] == "IfcWall" + assert result["name"] == "Wall001" + + def test_list(self, model): + walls = model.by_type("IfcWall") + result = serialize_result(walls) + assert isinstance(result, list) + assert all(isinstance(r, dict) for r in result) + + def test_dict(self): + result = serialize_result({"key": "value"}) + assert result == {"key": "value"} diff --git a/src/ifcmcp/README.md b/src/ifcmcp/README.md new file mode 100644 index 0000000000..3867f7b79c --- /dev/null +++ b/src/ifcmcp/README.md @@ -0,0 +1,401 @@ + +# ifcmcp + +An MCP (Model Context Protocol) server that wraps `ifcquery` and `ifcedit`, +holding the IFC model in memory across tool calls for fast interactive editing +sessions. + +## Installation + +```bash +pip install ifcmcp +``` + +Requires `ifcopenshell`, `ifcquery`, and `ifcedit`. The `mcp` package is an optional dependency needed to run the server; install it with `pip install ifcmcp[mcp]` or add `mcp` separately. + +## Running the server + +```bash +python3 -m ifcmcp +``` + +This starts the server on stdio transport, suitable for use with Claude Code +or any MCP client. + +### Claude Code configuration + +Use the `claude mcp add` command: + +```bash +claude mcp add --transport stdio ifc -- python3 -m ifcmcp +``` + +Or create a `.mcp.json` file in your project root: + +```json +{ + "mcpServers": { + "ifc": { + "type": "stdio", + "command": "python3", + "args": ["-m", "ifcmcp"] + } + } +} +``` + +After adding the server, restart Claude Code for the tools to become available. +Then load a model by asking Claude to use `ifc_load`: + +``` +load model.ifc using ifc_load +``` + +## Tools + +### Session + +#### ifc_new + +Create a new empty IFC model in memory, replacing any currently loaded model. + +``` +ifc_new() +ifc_new(schema="IFC4X3") +``` + +Default schema is `IFC4`. + +#### ifc_load + +Open an IFC file into memory. + +``` +ifc_load(path="/path/to/model.ifc") +-> "Loaded /path/to/model.ifc: schema IFC4, 1847 entities" +``` + +#### ifc_reset + +Unload the current model from memory, freeing all session state. + +``` +ifc_reset() +``` + +#### ifc_save + +Write the in-memory model to disk. Empty path overwrites the original file. + +``` +ifc_save() +ifc_save(path="/path/to/output.ifc") +``` + +### Query tools + +All query tools require a model to be loaded first via `ifc_load`. + +#### ifc_summary + +Model overview: schema, entity counts, project info. + +```json +{ + "schema": "IFC4", + "total_entities": 1847, + "project": {"id": 1, "name": "Office Building"}, + "types": {"IfcWall": 42, "IfcSlab": 12, "IfcWindow": 36} +} +``` + +#### ifc_tree + +Full spatial hierarchy from IfcProject down through sites, buildings, storeys, +and contained elements. + +```json +{ + "id": 1, + "type": "IfcProject", + "name": "Office Building", + "children": [ + { + "id": 2, + "type": "IfcSite", + "children": [{"id": 3, "type": "IfcBuilding", "children": ["..."]}] + } + ] +} +``` + +#### ifc_info + +Deep inspection of an entity by step ID: attributes, property sets, type, +material, container, and 4x4 placement matrix. + +``` +ifc_info(element_id=10) +``` + +#### ifc_select + +Filter elements using ifcopenshell selector syntax. + +``` +ifc_select(query="IfcWall") +ifc_select(query="IfcWindow") +``` + +Returns a sorted list of `{"id", "type", "name"}` references. + +#### ifc_relations + +Show all relationships for an element: hierarchy, children, type, groups, +systems, material, connections. + +``` +ifc_relations(element_id=10) +ifc_relations(element_id=10, traverse="up") +``` + +With `traverse="up"`, walks the hierarchy from element up to IfcProject. + +#### ifc_contexts + +List all geometric representation contexts and subcontexts in the loaded model. + +``` +ifc_contexts() +``` + +#### ifc_materials + +List all materials and material sets in the loaded model, with their assigned elements. + +``` +ifc_materials() +``` + +#### ifc_clash + +Check an element for geometric intersections and clearance violations. + +``` +ifc_clash(element_id=10) +ifc_clash(element_id=10, clearance=0.5, scope="all") +``` + +Parameters: + +- `clearance` -- minimum clearance distance in meters (0.0 = no clearance check) +- `tolerance` -- intersection tolerance in meters (default: 0.002) +- `scope` -- `"storey"` or `"all"` (default: `"storey"`) + +#### ifc_validate + +Check the model for schema and constraint violations. + +``` +ifc_validate() +ifc_validate(express_rules=True) +``` + +Returns `{"valid": true, "issues": []}` or `{"valid": false, "issues": [{"level": "ERROR", "message": "..."}]}`. + +#### ifc_schedule + +List all work schedules and their nested task trees. + +``` +ifc_schedule() +ifc_schedule(max_depth=1) # top-level phases only +``` + +`max_depth` limits subtask expansion. At the cutoff, `subtasks` is replaced +with `{"truncated": true, "count": N}` so you know children exist without +fetching them all. Omit for unlimited depth. + +#### ifc_cost + +List all cost schedules and their nested cost item trees. + +``` +ifc_cost() +ifc_cost(max_depth=2) # top two levels of the BoQ +``` + +`max_depth` limits cost item expansion, same truncation convention as +`ifc_schedule`. + +#### ifc_schema + +Return IFC class documentation for any entity type, using the loaded model's +schema version. + +``` +ifc_schema(entity_type="IfcWall") +ifc_schema(entity_type="IfcBuildingStorey") +``` + +Returns description, predefined types, spec URL, and attribute descriptions. +Returns `{"error": "Unknown entity: Foo"}` for unrecognised types. + +#### ifc_quantify + +Run quantity take-off (QTO) on the loaded model using an `ifc5d` rule. +Computes physical measurements (volume, area, length, count, weight) and +writes them back as `IfcElementQuantity` property sets. Modifies the model +in-place -- call `ifc_save()` when done. + +``` +ifc_quantify(rule="IFC4QtoBaseQuantities") +ifc_quantify(rule="IFC4QtoBaseQuantities", selector="IfcWall") +``` + +Available rules: `IFC4QtoBaseQuantities`, `IFC4X3QtoBaseQuantities`. + +`selector` is an optional ifcopenshell selector to restrict which elements +are quantified (default: all `IfcElement`). + +Returns `{"ok": true, "rule": "...", "elements_quantified": 42}`. + +### Drawing and rendering tools + +#### ifc_plot + +Generate a 2D technical drawing of the loaded model and return it as an inline image. + +``` +ifc_plot() +ifc_plot(selector="IfcWall", view="floorplan", scale=0.01, output_path="/tmp/plan.svg") +ifc_plot(element_ids=[10, 11], view="floorplan") +``` + +Parameters: + +- `selector` -- ifcopenshell selector to restrict plotted elements +- `element_ids` -- step IDs of elements to highlight; others are faded +- `view` -- `"floorplan"` (default), `"elevation"`, `"section"`, or `"auto"` +- `width_mm`, `height_mm` -- paper size in mm (default: 297 x 420) +- `scale` -- model-to-paper ratio (default: 0.01 = 1:100) +- `png_width`, `png_height` -- raster output size in pixels (default: 1024 x 1024) +- `output_path` -- optional path to also save to disk (`.svg` for vector, otherwise PNG) + +Returns an inline PNG the LLM can inspect. Requires `ifcopenshell.draw`. + +#### ifc_render + +Render the loaded model to a 3D PNG image. + +``` +ifc_render() +ifc_render(selector="IfcWall", view="iso", output_path="/tmp/model.png") +ifc_render(element_ids=[10, 11], view="south") +``` + +Parameters: + +- `selector` -- ifcopenshell selector to restrict rendered elements +- `element_ids` -- step IDs of elements to highlight; others are shown translucent +- `view` -- `"iso"` (default), `"top"`, `"south"`, `"north"`, `"east"`, or `"west"` +- `output_path` -- optional path to save the PNG to disk + +Returns an inline PNG. Requires `pyvista` and the IfcOpenShell C++ geometry bindings. + +### Shape builder tools + +#### ifc_shape_list + +List all available `ShapeBuilder` methods with brief descriptions. + +``` +ifc_shape_list() +``` + +#### ifc_shape_docs + +Show full documentation for a specific `ShapeBuilder` method. + +``` +ifc_shape_docs(method="extrude") +ifc_shape_docs(method="create_ellipse") +``` + +#### ifc_shape + +Execute a `ShapeBuilder` method on the loaded model. + +``` +ifc_shape(method="extrude", params='{"profile": "42", "magnitude": 3.0}') +``` + +`params` is a JSON string; entity references are resolved by step ID (same coercion as `ifc_edit`). + +### Edit discovery tools + +#### ifc_list + +List all API modules, or functions within a specific module. + +``` +ifc_list() # all modules +ifc_list(module="root") # functions in the root module +``` + +#### ifc_docs + +Show full documentation for an API function including parameters, types, +defaults, and descriptions. + +``` +ifc_docs(function_path="root.create_entity") +``` + +### Edit execution + +#### ifc_edit + +Execute an `ifcopenshell.api` mutation function. Parameters are passed as a +JSON string with string values that get coerced by ifcedit's type system. + +``` +ifc_edit( + function_path="root.create_entity", + params='{"ifc_class": "IfcWall", "name": "My Wall"}' +) +``` + +Returns `{"ok": true, "result": ...}` or `{"ok": false, "error": "..."}`. + +Does NOT auto-save -- call `ifc_save()` when ready to write changes to disk. + +**Parameter coercion:** + +| Type | JSON value | Python value | +|------|------------|--------------| +| `entity_instance` | `"42"` | resolved from model by step ID | +| `list[entity_instance]` | `"5,6,7"` | list of resolved entities | +| `dict` | `'{"key": "val"}'` | parsed JSON object | +| `bool` | `"true"` | `True` | +| `Optional[X]` | `"none"` | `None` | + +## Typical workflow + +1. **Load** a model: `ifc_load` +2. **Inspect** with query tools: `ifc_summary`, `ifc_tree`, `ifc_select`, `ifc_info`, `ifc_relations` +3. **Validate** if needed: `ifc_validate` +4. **Browse schedules / costs**: `ifc_schedule`, `ifc_cost` (use `max_depth=1` first on large projects) +5. **Look up IFC classes**: `ifc_schema` +6. **Find** the right API function: `ifc_list`, `ifc_docs` +7. **Edit** the model: `ifc_edit` +8. **Quantify** elements: `ifc_quantify` (writes QTO psets in-place) +9. **Verify** changes with query tools +10. **Save** when satisfied: `ifc_save` + +The model stays in memory across all calls, so multi-step editing sessions +are fast -- no file I/O between operations. + +## License + +LGPLv3+ -- see the IfcOpenShell project license. diff --git a/src/ifcmcp/ifcmcp/__init__.py b/src/ifcmcp/ifcmcp/__init__.py new file mode 100644 index 0000000000..92e1c230b6 --- /dev/null +++ b/src/ifcmcp/ifcmcp/__init__.py @@ -0,0 +1,20 @@ +# This file was generated with the assistance of an AI coding tool. +# IfcMCP - MCP server for IFC building models +# Copyright (C) 2026 Bruno Postle +# +# This file is part of IfcMCP. +# +# IfcMCP 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. +# +# IfcMCP 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 IfcMCP. If not, see . + +__version__ = version = "0.0.0" diff --git a/src/ifcmcp/ifcmcp/__main__.py b/src/ifcmcp/ifcmcp/__main__.py new file mode 100644 index 0000000000..47419dc209 --- /dev/null +++ b/src/ifcmcp/ifcmcp/__main__.py @@ -0,0 +1,48 @@ +# This file was generated with the assistance of an AI coding tool. +import argparse + +from ifcmcp import __version__ + + +def main(): + parser = argparse.ArgumentParser( + prog="python3 -m ifcmcp", + description=( + "ifcmcp — MCP server for IFC building models.\n\n" + "Runs a Model Context Protocol server over stdio so that MCP clients\n" + "can query and edit IFC files without writing them to disk between\n" + "operations.\n\n" + "Add to .mcp.json to configure:\n" + ' {"mcpServers": {"ifc": {"type": "stdio", "command": "python3", "args": ["-m", "ifcmcp"]}}}' + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("--version", action="version", version=f"ifcmcp {__version__}") + parser.add_argument( + "--transport", + choices=["stdio", "sse", "streamable-http"], + default="stdio", + help="MCP transport to use (default: stdio)", + ) + + args = parser.parse_args() + + try: + from mcp.server.fastmcp import FastMCP # noqa: F401 + except ImportError: + import sys + print( + "error: the 'mcp' package is required to run the server.\n" + "Install it with: pip install mcp", + file=sys.stderr, + ) + sys.exit(1) + + from ifcmcp.server import build_server + + server = build_server() + server.run(transport=args.transport) + + +if __name__ == "__main__": + main() diff --git a/src/ifcmcp/ifcmcp/core.py b/src/ifcmcp/ifcmcp/core.py new file mode 100644 index 0000000000..ed9f91c3c9 --- /dev/null +++ b/src/ifcmcp/ifcmcp/core.py @@ -0,0 +1,731 @@ +# This file was generated with the assistance of an AI coding tool. +from __future__ import annotations + +# inside ifcmcp/core.py +import json +from collections.abc import Callable # noqa: F401 — Callable used in helpers below +from dataclasses import dataclass +from typing import Any + +import ifcopenshell +from ifcedit.discover import function_docs, list_functions, list_modules +from ifcedit.quantify import run_quantify +from ifcedit.run import run_api +from ifcquery import clash as clash_mod +from ifcquery import contexts as contexts_mod +from ifcquery import cost as cost_mod +from ifcquery import ( + info, + relations, + schedule, + schema, + select, + summary, + tree, +) +from ifcquery import ( + materials as materials_mod, +) +from ifcquery import ( + plot as plot_mod, +) +from ifcquery import ( + render as render_mod, +) +from ifcquery import validate as validate_mod + + +def _jsonify(x: Any) -> Any: + """Convert IfcOpenShell objects / iterables into JSON-safe primitives.""" + if x is None or isinstance(x, (str, int, float, bool)): + return x + + # numpy arrays (and any array-like with tolist) + if hasattr(x, "tolist"): + return x.tolist() + + # IfcOpenShell entity instances: normalize + if isinstance(x, ifcopenshell.entity_instance): + return { + "id": int(x.id()), + "type": x.is_a(), + "repr": str(x), + "name": getattr(x, "Name", None), + } + + if isinstance(x, dict): + return {str(k): _jsonify(v) for k, v in x.items()} + + if isinstance(x, (list, tuple, set)): + return [_jsonify(v) for v in x] + + # Try JSON as-is, else fallback to string + try: + json.dumps(x) + return x + except Exception: + return str(x) + + +# --------------------------------------------------------------------------- +# Shape builder helpers +# --------------------------------------------------------------------------- + + +def _list_shape_methods() -> list[dict]: + """Introspect ShapeBuilder and return a summary of all public methods.""" + import inspect + + from ifcedit.discover import _extract_params + from ifcopenshell.util.shape_builder import ShapeBuilder + + results = [] + for name, fn in inspect.getmembers(ShapeBuilder, predicate=inspect.isfunction): + if name.startswith("_"): + continue + doc = fn.__doc__ or "" + description = doc.strip().split("\n")[0] if doc.strip() else "" + results.append({"method": name, "description": description, "params": _extract_params(fn)}) + return results + + +def _shape_method_docs(method_name: str) -> dict: + """Return full documentation for a single ShapeBuilder method.""" + import typing + + from ifcedit.discover import ( + _extract_params, + _format_type_hint, + _parse_docstring_body, + _parse_param_docs, + _parse_return_doc, + ) + from ifcopenshell.util.shape_builder import ShapeBuilder + + if method_name.startswith("_"): + raise ValueError(f"ShapeBuilder has no method '{method_name}'") + fn = getattr(ShapeBuilder, method_name, None) + if fn is None: + raise ValueError(f"ShapeBuilder has no method '{method_name}'") + + doc = fn.__doc__ or "" + description, long_description = _parse_docstring_body(doc) + params = _extract_params(fn) + for param in params: + param_desc = _parse_param_docs(doc) + if param["name"] in param_desc: + param["description"] = param_desc[param["name"]] + + try: + hints = typing.get_type_hints(fn) + except Exception: + hints = {} + + result: dict[str, Any] = { + "method": method_name, + "description": description, + "long_description": long_description, + "params": params, + } + return_type = _format_type_hint(hints.get("return")) + if return_type: + result["return_type"] = return_type + return_description = _parse_return_doc(doc) + if return_description: + result["return_description"] = return_description + return result + + +def _coerce_shape_params(fn: Callable, raw_kwargs: dict, model: ifcopenshell.file) -> dict: + """Coerce JSON-parsed kwargs to proper Python types for a ShapeBuilder method.""" + import inspect + import typing + + sig = inspect.signature(fn) + try: + hints = typing.get_type_hints(fn) + except Exception: + hints = {} + + return { + key: _coerce_shape_value(value, hints.get(key), model) + for key, value in raw_kwargs.items() + if key in sig.parameters and key != "self" + } + + +def _coerce_shape_value(value: Any, hint: Any, model: ifcopenshell.file) -> Any: + """Convert a single JSON-parsed value to the correct Python type.""" + import typing + + if hint is None or value is None: + return value + + origin = typing.get_origin(hint) + args = typing.get_args(hint) + + # Optional[X] / Union — try each non-None branch in order + if origin is typing.Union: + if value is None: + return None + for t in (a for a in args if a is not type(None)): + try: + return _coerce_shape_value(value, t, model) + except (ValueError, TypeError): + continue + return value + + # entity_instance: resolve integer or "#N" string step ID + if hint is ifcopenshell.entity_instance or ( + isinstance(hint, type) and issubclass(hint, ifcopenshell.entity_instance) + ): + entity_id = int(str(value).lstrip("#")) + entity = model.by_id(entity_id) + if entity is None: + raise ValueError(f"Entity #{entity_id} not found in model") + return entity + + # Sequence[entity_instance]: resolve each element in the list + import collections.abc + + if origin is not None and issubclass(origin, collections.abc.Sequence) and not isinstance(value, str): + if args and ( + args[0] is ifcopenshell.entity_instance + or (isinstance(args[0], type) and issubclass(args[0], ifcopenshell.entity_instance)) + ): + if isinstance(value, (list, tuple)): + return [_coerce_shape_value(v, args[0], model) for v in value] + + # bool: JSON gives actual bools; also accept string representations + if hint is bool: + if isinstance(value, bool): + return value + return str(value).lower() in ("true", "1", "yes") + + # Everything else (float, int, VectorType lists, dicts, Literals) passes through + return value + + +class IfcSessionError(RuntimeError): + pass + + +@dataclass +class IfcSession: + """In-memory IFC session (no FastMCP dependency). + + Designed to work in: + - FastMCP server (single global session) + - Embedded runtimes like Pyodide (one session per browser tab/worker) + """ + + model: ifcopenshell.file | None = None + model_path: str | None = None + + # ----------------- + # Session lifecycle + # ----------------- + def _require_model(self) -> ifcopenshell.file: + if self.model is None: + raise IfcSessionError("No model loaded. Call ifc_load() or ifc_new() first.") + return self.model + + def ifc_new(self, schema: str = "IFC4") -> dict[str, Any]: + """Create a new empty IFC model in memory.""" + self.model = ifcopenshell.file(schema=schema) + self.model_path = None + return {"ok": True, "schema": self.model.schema, "entities": sum(1 for _ in self.model)} + + def ifc_load(self, path: str) -> str: + """Open an IFC file into memory. Returns confirmation string.""" + self.model = ifcopenshell.open(path) + self.model_path = path + count = sum(1 for _ in self.model) + return f"Loaded {path}: schema {self.model.schema}, {count} entities" + + def ifc_save(self, path: str = "") -> str: + """Write the in-memory model to disk. Empty path overwrites the original file.""" + model = self._require_model() + target = path if path else self.model_path + if not target: + raise IfcSessionError("No path specified and no original path available.") + model.write(target) + return f"Saved to {target}" + + def ifc_reset(self) -> dict[str, Any]: + """Drop the in-memory model.""" + self.model = None + self.model_path = None + return {"ok": True} + + # ------------- + # Query tools + # ------------- + def ifc_summary(self) -> dict[str, Any]: + """Model overview: schema, entity counts, project info.""" + return summary.summary(self._require_model()) + + def ifc_tree(self) -> dict[str, Any] | list[dict[str, Any]]: + """Full spatial hierarchy tree (Project -> Site -> Building -> Storeys -> Elements).""" + return tree.tree(self._require_model()) + + def ifc_info(self, element_id: int) -> dict[str, Any]: + """Deep inspection of an entity by step ID (attributes, psets, placement, type, material).""" + model = self._require_model() + element = model.by_id(element_id) + if element is None: + raise IfcSessionError(f"Element #{element_id} not found.") + return info.info(model, element) + + def ifc_select(self, query: str) -> list[dict[str, Any]]: + """Filter elements using ifcopenshell selector syntax (e.g. 'IfcWall', 'IfcWindow').""" + return select.select(self._require_model(), query) + + def ifc_relations(self, element_id: int, traverse: str = "") -> dict[str, Any] | list[dict[str, Any]]: + """Show relationships for an element. Set traverse='up' to walk hierarchy to IfcProject.""" + model = self._require_model() + element = model.by_id(element_id) + if element is None: + raise IfcSessionError(f"Element #{element_id} not found.") + return relations.relations(model, element, traverse=traverse if traverse else None) + + def ifc_clash( + self, + element_id: int, + clearance: float = 0.0, + tolerance: float = 0.002, + scope: str = "storey", + ) -> dict[str, Any]: + """Check element for geometric clashes. clearance=0.0 means no clearance check.""" + model = self._require_model() + element = model.by_id(element_id) + if element is None: + raise IfcSessionError(f"Element #{element_id} not found.") + return clash_mod.clash( + model, + element, + clearance=clearance if clearance and clearance > 0.0 else None, + tolerance=tolerance, + scope=scope, + ) + + def ifc_contexts(self) -> list[dict[str, Any]]: + """List all geometric representation contexts and subcontexts with their step IDs.""" + return contexts_mod.contexts(self._require_model()) + + def ifc_materials(self) -> list[dict[str, Any]]: + """List all materials and material sets (layers, constituents, profiles).""" + return materials_mod.materials(self._require_model()) + + # ------------------------ + # Edit discovery + execute + # ------------------------ + def ifc_list(self, module: str = "") -> list[dict]: + """List all API modules, or functions within a module. Empty module = all modules.""" + return list_functions(module) if module else list_modules() + + def ifc_docs(self, function_path: str) -> dict: + """Show full documentation for an API function. Input format: 'module.function'.""" + module, function = function_path.split(".", 1) + return function_docs(module, function) + + def ifc_edit(self, function_path: str, params: Any = "{}") -> dict: + """Execute an ifcopenshell.api mutation. + + params may be: + - JSON string + - dict (from tool calling / JS) + - JsProxy (handled upstream in embedded.py) + """ + model = self._require_model() + module, function = function_path.split(".", 1) + + if isinstance(params, str): + raw_kwargs = json.loads(params) if params.strip() else {} + elif isinstance(params, dict): + raw_kwargs = params + else: + # e.g. list/None/etc + raw_kwargs = dict(params) if params is not None else {} + + res = run_api(model, module, function, raw_kwargs) + return _jsonify(res) + + # ------------------------ + # Extended query + edit tools + # ------------------------ + def ifc_validate(self, express_rules: bool = False) -> dict[str, Any]: + """Validate the loaded model. Returns {'valid': bool, 'issues': [...]}.""" + return validate_mod.validate(self._require_model(), express_rules=express_rules) + + def ifc_schedule(self, max_depth: int | None = None) -> list[dict[str, Any]]: + """List work schedules and nested tasks from the model. + + max_depth limits subtask expansion (None = unlimited). At the cutoff, + subtasks is replaced with {"truncated": True, "count": N}. + """ + return schedule.schedule(self._require_model(), max_depth=max_depth) + + def ifc_cost(self, max_depth: int | None = None) -> list[dict[str, Any]]: + """List cost schedules and nested cost items from the model. + + max_depth limits cost item expansion (None = unlimited). At the cutoff, + subitems is replaced with {"truncated": True, "count": N}. + """ + return cost_mod.cost(self._require_model(), max_depth=max_depth) + + def ifc_schema(self, entity_type: str) -> dict[str, Any]: + """Return IFC class documentation for entity_type using the model's schema version.""" + return schema.schema(self._require_model(), entity_type) + + def ifc_plot( + self, + selector: str = "", + element_ids: list[int] | None = None, + view: str = "floorplan", + width_mm: float = 297.0, + height_mm: float = 420.0, + scale: float = 1.0 / 100.0, + png_width: int = 1024, + png_height: int = 1024, + output_format: str = "png", + ) -> bytes: + """Generate a 2D technical drawing (floor plan, elevation, or section) and return image bytes. + + Uses ifcopenshell.draw to produce SVG output which is rasterised to PNG via CairoSVG + when output_format is 'png'. + + :param selector: ifcopenshell selector to restrict plotted elements + (e.g. ``'IfcWall'``). Omit to plot the whole model. + :param element_ids: Step IDs of elements to highlight. Other elements + are faded to 10% opacity so the subject stands out. + :param view: Drawing view — ``floorplan`` (default), ``elevation``, + ``section``, or ``auto``. + :param width_mm: Paper width in mm (default 297 = A4). + :param height_mm: Paper height in mm (default 420 = A4). + :param scale: Model-to-paper scale ratio (default 0.01 = 1:100). + :param png_width: Raster output width in pixels (default 1024). + :param png_height: Raster output height in pixels (default 1024). + :param output_format: ``'svg'`` or ``'png'`` (default ``'png'``). + :return: SVG or PNG bytes depending on output_format. + """ + model = self._require_model() + return plot_mod.plot( + model, + output_format=output_format, + selector=selector if selector else None, + element_ids=element_ids, + view=view, + width_mm=width_mm, + height_mm=height_mm, + scale=scale, + png_width=png_width, + png_height=png_height, + ) + + def ifc_render( + self, + selector: str = "", + element_ids: list[int] | None = None, + view: str = "iso", + ) -> bytes: + """Render the loaded model to a PNG image and return raw bytes. + + :param selector: ifcopenshell selector to restrict rendered elements + (e.g. ``'IfcWall'``). Omit to render the whole model. + :param element_ids: Step IDs of elements to highlight. Other elements + are rendered in translucent grey. + :param view: Camera angle: ``iso``, ``top``, ``south``, ``north``, + ``east``, or ``west``. + :return: PNG image as raw bytes. + """ + model = self._require_model() + return render_mod.render( + model, + selector=selector if selector else None, + element_ids=element_ids, + view=view, + ) + + # ------------------------ + # Shape builder tools + # ------------------------ + def ifc_shape_list(self) -> list[dict]: + """List all ShapeBuilder geometry methods with one-line descriptions and parameter names.""" + return _list_shape_methods() + + def ifc_shape_docs(self, method: str) -> dict: + """Full documentation for a ShapeBuilder method: params, types, return value.""" + return _shape_method_docs(method) + + def ifc_shape(self, method: str, params: Any = "{}") -> dict: + """Call a ShapeBuilder method by name. Returns the created entity's step ID. + + params is a JSON string of keyword arguments. Pass entity references as integer + step IDs; vectors as JSON arrays (e.g. [1.0, 0.0, 0.0]). + """ + model = self._require_model() + + from ifcopenshell.util.shape_builder import ShapeBuilder + + if method.startswith("_"): + raise IfcSessionError(f"Private method '{method}' is not accessible") + fn = getattr(ShapeBuilder, method, None) + if fn is None: + return {"ok": False, "error": f"ShapeBuilder has no method '{method}'"} + + if isinstance(params, str): + raw_kwargs = json.loads(params) if params.strip() else {} + elif isinstance(params, dict): + raw_kwargs = params + else: + raw_kwargs = {} + + try: + coerced = _coerce_shape_params(fn, raw_kwargs, model) + result = fn(ShapeBuilder(model), **coerced) + return {"ok": True, "result": _jsonify(result)} + except Exception as e: + return {"ok": False, "error": f"{type(e).__name__}: {e}"} + + def ifc_quantify(self, rule: str, selector: str = "") -> dict[str, Any]: + """Run quantity take-off on the model using the named rule. + + Modifies the model in-place; call ifc_save() after. + """ + model = self._require_model() + return run_quantify(model, rule, selector=selector if selector else None) + + # ------------------------ + # Generic dispatcher + tool specs for LLMs + # ------------------------ + def dispatch(self, name: str, args: dict[str, Any] | None = None) -> Any: + args = args or {} + fn = getattr(self, name, None) + if not callable(fn): + raise IfcSessionError(f"Unknown tool: {name}") + return _jsonify(fn(**args)) + + def openai_tools(self) -> list[dict[str, Any]]: + """Tool schemas in the OpenAI 'Responses API' format (type=function).""" + # Keep schemas tight so the model calls tools correctly. + return [ + { + "type": "function", + "name": "ifc_new", + "description": "Create a new empty IFC model in memory.", + "parameters": { + "type": "object", + "properties": {"schema": {"type": "string", "description": "IFC schema, e.g. IFC4"}}, + "required": [], + "additionalProperties": False, + }, + }, + { + "type": "function", + "name": "ifc_summary", + "description": "Get a concise overview of the loaded IFC model.", + "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": False}, + }, + { + "type": "function", + "name": "ifc_tree", + "description": "Get the full spatial hierarchy tree.", + "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": False}, + }, + { + "type": "function", + "name": "ifc_select", + "description": "Select elements using ifcopenshell selector syntax (e.g. 'IfcWall').", + "parameters": { + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + "additionalProperties": False, + }, + }, + { + "type": "function", + "name": "ifc_info", + "description": "Inspect an entity by STEP id.", + "parameters": { + "type": "object", + "properties": {"element_id": {"type": "integer"}}, + "required": ["element_id"], + "additionalProperties": False, + }, + }, + { + "type": "function", + "name": "ifc_relations", + "description": "Get relationships for an element. traverse='up' walks to IfcProject.", + "parameters": { + "type": "object", + "properties": {"element_id": {"type": "integer"}, "traverse": {"type": "string"}}, + "required": ["element_id"], + "additionalProperties": False, + }, + }, + { + "type": "function", + "name": "ifc_clash", + "description": "Run clash/clearance checks for an element.", + "parameters": { + "type": "object", + "properties": { + "element_id": {"type": "integer"}, + "clearance": {"type": "number"}, + "tolerance": {"type": "number"}, + "scope": {"type": "string", "description": "storey or all"}, + }, + "required": ["element_id"], + "additionalProperties": False, + }, + }, + { + "type": "function", + "name": "ifc_contexts", + "description": "List all geometric representation contexts and subcontexts with their step IDs, context type, identifier, and target view. Use this to find the context ID required for geometry-creation API calls.", + "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": False}, + }, + { + "type": "function", + "name": "ifc_materials", + "description": "List all materials and material sets (IfcMaterial, IfcMaterialLayerSet, IfcMaterialConstituentSet, IfcMaterialProfileSet) with their layers, constituents, or profiles.", + "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": False}, + }, + { + "type": "function", + "name": "ifc_list", + "description": "List ifcopenshell.api modules or functions within a module.", + "parameters": { + "type": "object", + "properties": {"module": {"type": "string"}}, + "required": [], + "additionalProperties": False, + }, + }, + { + "type": "function", + "name": "ifc_docs", + "description": "Get documentation for an ifcopenshell.api function, 'module.function'.", + "parameters": { + "type": "object", + "properties": {"function_path": {"type": "string"}}, + "required": ["function_path"], + "additionalProperties": False, + }, + }, + { + "type": "function", + "name": "ifc_edit", + "description": "Execute an ifcopenshell.api mutation; params is a JSON string of stringly-typed kwargs.", + "parameters": { + "type": "object", + "properties": {"function_path": {"type": "string"}, "params": {"type": "string"}}, + "required": ["function_path"], + "additionalProperties": False, + }, + }, + { + "type": "function", + "name": "ifc_validate", + "description": "Validate the loaded model. Returns valid bool and list of issues.", + "parameters": { + "type": "object", + "properties": { + "express_rules": {"type": "boolean", "description": "Also check EXPRESS rules (slower)"} + }, + "required": [], + "additionalProperties": False, + }, + }, + { + "type": "function", + "name": "ifc_schedule", + "description": "List work schedules and nested tasks. Use max_depth=1 for top-level phases only on large projects.", + "parameters": { + "type": "object", + "properties": { + "max_depth": { + "type": "integer", + "description": "Max levels of subtask expansion (omit for unlimited)", + } + }, + "required": [], + "additionalProperties": False, + }, + }, + { + "type": "function", + "name": "ifc_cost", + "description": "List cost schedules and nested cost items. Use max_depth=1 for top-level sections only on large BoQs.", + "parameters": { + "type": "object", + "properties": { + "max_depth": { + "type": "integer", + "description": "Max levels of cost item expansion (omit for unlimited)", + } + }, + "required": [], + "additionalProperties": False, + }, + }, + { + "type": "function", + "name": "ifc_schema", + "description": "Return IFC class documentation for an entity type.", + "parameters": { + "type": "object", + "properties": {"entity_type": {"type": "string", "description": "IFC entity type, e.g. IfcWall"}}, + "required": ["entity_type"], + "additionalProperties": False, + }, + }, + { + "type": "function", + "name": "ifc_quantify", + "description": "Run quantity take-off (QTO) on the model. Modifies model in-place; call ifc_save() after.", + "parameters": { + "type": "object", + "properties": { + "rule": {"type": "string", "description": "QTO rule name, e.g. IFC4QtoBaseQuantities"}, + "selector": { + "type": "string", + "description": "ifcopenshell selector to restrict elements (default: all IfcElement)", + }, + }, + "required": ["rule"], + "additionalProperties": False, + }, + }, + { + "type": "function", + "name": "ifc_render", + "description": ( + "Render the loaded IFC model to a PNG image for visual inspection. " + "Use selector to restrict which elements are rendered (e.g. a single storey). " + "Use element_ids to highlight elements against a greyed-out background. " + "Returns base64-encoded PNG bytes." + ), + "parameters": { + "type": "object", + "properties": { + "selector": {"type": "string", "description": "ifcopenshell selector (default: whole model)"}, + "element_ids": { + "type": "array", + "items": {"type": "integer"}, + "description": "Step IDs of elements to highlight", + }, + "view": { + "type": "string", + "enum": ["iso", "top", "south", "north", "east", "west"], + "description": "Camera angle (default: iso)", + }, + }, + "required": [], + "additionalProperties": False, + }, + }, + ] diff --git a/src/ifcmcp/ifcmcp/embedded.py b/src/ifcmcp/ifcmcp/embedded.py new file mode 100644 index 0000000000..4ad99ba1fa --- /dev/null +++ b/src/ifcmcp/ifcmcp/embedded.py @@ -0,0 +1,60 @@ +# This file was generated with the assistance of an AI coding tool. +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from ifcmcp.core import IfcSession + +session = IfcSession() + +# Optional imports only available under Pyodide +try: + from pyodide.ffi import JsProxy, to_py # type: ignore +except Exception: # pragma: no cover + JsProxy = None # type: ignore + to_py = None # type: ignore + + +def _coerce_args(args: Any) -> dict[str, Any]: + """Convert JS objects / JsProxy / mappings into a real Python dict.""" + if args is None: + return {} + + # Pyodide: JS object arrives as JsProxy; convert recursively to Python. + if JsProxy is not None and isinstance(args, JsProxy): + # dict_converter=dict ensures JS object -> Python dict (not Map) + return to_py(args, dict_converter=dict) + + # Already a Python dict + if isinstance(args, dict): + return args + + # Any Mapping-like object + if isinstance(args, Mapping): + return dict(args) + + # Last resort: try dict() coercion + try: + return dict(args) + except Exception as e: + raise TypeError(f"Tool args must be a mapping/dict; got {type(args)}") from e + + +def tools_openai() -> list[dict[str, Any]]: + return session.openai_tools() + + +def call_tool(name: str, args: Any = None) -> dict[str, Any]: + """ + Non-throwing tool dispatcher. + Always returns: {"ok": bool, "data": ...} or {"ok": false, "error": "...", "error_type": "...", ...} + """ + try: + py_args = _coerce_args(args) + data = session.dispatch(name, py_args) + return {"ok": True, "data": data} + + except Exception as e: + # Keep it short; avoid full tracebacks in tool output unless debugging. + return {"ok": False, "error_type": type(e).__name__, "error": str(e)} diff --git a/src/ifcmcp/ifcmcp/server.py b/src/ifcmcp/ifcmcp/server.py new file mode 100644 index 0000000000..08ba86a2cb --- /dev/null +++ b/src/ifcmcp/ifcmcp/server.py @@ -0,0 +1,231 @@ +# This file was generated with the assistance of an AI coding tool. +from __future__ import annotations + +import base64 +from typing import Any + +from ifcmcp.core import IfcSession + +try: + from mcp.server.fastmcp import FastMCP # type: ignore + from mcp.types import ImageContent # type: ignore +except Exception: # pragma: no cover + FastMCP = None # type: ignore + ImageContent = None # type: ignore + + +def build_server() -> Any: + """Create the FastMCP server if the dependency is available.""" + if FastMCP is None: + raise ImportError( + "FastMCP is not installed. Install with: pip install ifcmcp[mcp] " "(or add 'mcp' to your environment)." + ) + + session = IfcSession() + + server = FastMCP( + name="ifc-mcp", + instructions=( + "MCP server for querying and editing IFC building models. " + "Load a file first with ifc_load, then use query/edit tools. " + "Save changes with ifc_save." + ), + ) + + # ---- Lifecycle ---- + @server.tool() + def ifc_new(schema: str = "IFC4") -> dict[str, Any]: + return session.ifc_new(schema=schema) + + @server.tool() + def ifc_load(path: str) -> str: + return session.ifc_load(path) + + @server.tool() + def ifc_save(path: str = "") -> str: + return session.ifc_save(path) + + @server.tool() + def ifc_reset() -> dict[str, Any]: + return session.ifc_reset() + + # ---- Query ---- + @server.tool() + def ifc_summary() -> dict[str, Any]: + return session.ifc_summary() + + @server.tool() + def ifc_tree() -> dict[str, Any] | list[dict[str, Any]]: + return session.ifc_tree() + + @server.tool() + def ifc_info(element_id: int) -> dict[str, Any]: + return session.ifc_info(element_id) + + @server.tool() + def ifc_select(query: str) -> list[dict[str, Any]]: + return session.ifc_select(query) + + @server.tool() + def ifc_relations(element_id: int, traverse: str = "") -> dict[str, Any] | list[dict[str, Any]]: + return session.ifc_relations(element_id, traverse=traverse) + + @server.tool() + def ifc_clash( + element_id: int, + clearance: float = 0.0, + tolerance: float = 0.002, + scope: str = "storey", + ) -> dict[str, Any]: + return session.ifc_clash( + element_id=element_id, + clearance=clearance, + tolerance=tolerance, + scope=scope, + ) + + @server.tool() + def ifc_contexts() -> list[dict[str, Any]]: + return session.ifc_contexts() + + @server.tool() + def ifc_materials() -> list[dict[str, Any]]: + return session.ifc_materials() + + # ---- Edit ---- + @server.tool() + def ifc_list(module: str = "") -> list[dict]: + return session.ifc_list(module=module) + + @server.tool() + def ifc_docs(function_path: str) -> dict: + return session.ifc_docs(function_path=function_path) + + @server.tool() + def ifc_edit(function_path: str, params: str = "{}") -> dict: + return session.ifc_edit(function_path=function_path, params=params) + + # ---- Extended query + edit ---- + @server.tool() + def ifc_validate(express_rules: bool = False) -> dict[str, Any]: + return session.ifc_validate(express_rules=express_rules) + + @server.tool() + def ifc_schedule(max_depth: int | None = None) -> list[dict[str, Any]]: + return session.ifc_schedule(max_depth=max_depth) + + @server.tool() + def ifc_cost(max_depth: int | None = None) -> list[dict[str, Any]]: + return session.ifc_cost(max_depth=max_depth) + + @server.tool() + def ifc_schema(entity_type: str) -> dict[str, Any]: + return session.ifc_schema(entity_type=entity_type) + + @server.tool() + def ifc_quantify(rule: str, selector: str = "") -> dict[str, Any]: + return session.ifc_quantify(rule=rule, selector=selector) + + # ---- Shape builder ---- + @server.tool() + def ifc_shape_list() -> list[dict]: + return session.ifc_shape_list() + + @server.tool() + def ifc_shape_docs(method: str) -> dict: + return session.ifc_shape_docs(method=method) + + @server.tool() + def ifc_shape(method: str, params: str = "{}") -> dict: + return session.ifc_shape(method=method, params=params) + + @server.tool(structured_output=False) + def ifc_plot( + selector: str = "", + element_ids: list[int] | None = None, + view: str = "floorplan", + width_mm: float = 297.0, + height_mm: float = 420.0, + scale: float = 1.0 / 100.0, + png_width: int = 1024, + png_height: int = 1024, + output_path: str = "", + ) -> list[ImageContent]: + """Generate a 2D technical drawing of the loaded IFC model. + + Returns an inline PNG image (floor plan, elevation, or section) that the + LLM can inspect to understand the 2D layout of the model. If + ``output_path`` is provided the drawing is also saved to disk — as SVG + when the path ends in ``.svg``, otherwise as PNG. + + :param selector: ifcopenshell selector to restrict plotted elements + (e.g. ``'IfcWall'``). Omit to plot the whole model. + :param element_ids: Step IDs of elements to highlight. Other elements + are faded so the subject stands out. + :param view: Drawing view — ``floorplan`` (default), ``elevation``, + ``section``, or ``auto``. + :param width_mm: Paper width in mm (default 297 = A4 landscape width). + :param height_mm: Paper height in mm (default 420 = A4 landscape height). + :param scale: Model-to-paper scale ratio (default 0.01 = 1:100). + :param png_width: Raster output width in pixels (default 1024). + :param png_height: Raster output height in pixels (default 1024). + :param output_path: Optional file path to save the drawing to disk. + """ + png_bytes = session.ifc_plot( + selector=selector, + element_ids=element_ids, + view=view, + width_mm=width_mm, + height_mm=height_mm, + scale=scale, + png_width=png_width, + png_height=png_height, + output_format="png", + ) + if output_path: + if output_path.endswith(".svg"): + svg_bytes = session.ifc_plot( + selector=selector, + element_ids=element_ids, + view=view, + width_mm=width_mm, + height_mm=height_mm, + scale=scale, + output_format="svg", + ) + with open(output_path, "wb") as f: + f.write(svg_bytes) + else: + with open(output_path, "wb") as f: + f.write(png_bytes) + return [ImageContent(type="image", data=base64.b64encode(png_bytes).decode(), mimeType="image/png")] + + @server.tool(structured_output=False) + def ifc_render( + selector: str = "", + element_ids: list[int] | None = None, + view: str = "iso", + output_path: str = "", + ) -> list[ImageContent]: + """Render the loaded IFC model to a PNG image. + + Returns an inline image the LLM can inspect to understand the spatial + layout of the model or a specific element in context. If + ``output_path`` is provided the PNG is also saved to that file path. + + :param selector: ifcopenshell selector to restrict rendered elements + (e.g. ``'IfcWall'``, ``'IfcBuildingStorey[Name="0"]'``). + Omit to render the whole model. + :param element_ids: Step IDs of elements to highlight. Other elements + are rendered in translucent grey so the subject stands out. + :param view: Camera angle — ``iso`` (default), ``top``, ``south``, + ``north``, ``east``, or ``west``. + :param output_path: Optional file path to save the PNG to disk. + """ + png_bytes = session.ifc_render(selector=selector, element_ids=element_ids, view=view) + if output_path: + with open(output_path, "wb") as f: + f.write(png_bytes) + return [ImageContent(type="image", data=base64.b64encode(png_bytes).decode(), mimeType="image/png")] + + return server diff --git a/src/ifcmcp/pyproject.toml b/src/ifcmcp/pyproject.toml new file mode 100644 index 0000000000..1cfe734e4b --- /dev/null +++ b/src/ifcmcp/pyproject.toml @@ -0,0 +1,36 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "ifcmcp" +version = "0.0.0" +authors = [ + { name="Bruno Postle", email="bruno@postle.net" }, +] +description = "MCP server for querying and editing IFC building models" +readme = "README.md" +keywords = ["IFC", "BIM", "MCP"] +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)", +] +dependencies = ["ifcopenshell", "ifcquery", "ifcedit"] + +[project.optional-dependencies] +mcp = ["mcp"] + +[project.scripts] +ifcmcp = "ifcmcp.__main__:main" + +[project.urls] +Homepage = "http://ifcopenshell.org" +Documentation = "https://docs.ifcopenshell.org" +Issues = "https://github.com/IfcOpenShell/IfcOpenShell/issues" + +[tool.setuptools.packages.find] +include = ["ifcmcp*"] +exclude = ["test*"] + +[tool.ruff] +extend = "../../pyproject.toml" diff --git a/src/ifcmcp/tests/__init__.py b/src/ifcmcp/tests/__init__.py new file mode 100644 index 0000000000..0a3bc271a0 --- /dev/null +++ b/src/ifcmcp/tests/__init__.py @@ -0,0 +1 @@ +# This file was generated with the assistance of an AI coding tool. diff --git a/src/ifcmcp/tests/conftest.py b/src/ifcmcp/tests/conftest.py new file mode 100644 index 0000000000..9fae4aef92 --- /dev/null +++ b/src/ifcmcp/tests/conftest.py @@ -0,0 +1,59 @@ +# This file was generated with the assistance of an AI coding tool. +import ifcopenshell +import ifcopenshell.api.aggregate +import ifcopenshell.api.owner.settings +import ifcopenshell.api.project +import ifcopenshell.api.root +import ifcopenshell.api.spatial +import ifcopenshell.api.unit +import pytest + +from ifcmcp.core import IfcSession + + +@pytest.fixture +def session(): + return IfcSession() + + +@pytest.fixture +def model(): + """IFC4 model with a spatial hierarchy, a wall, and a slab.""" + f = ifcopenshell.api.project.create_file() + ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0] + ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0] + + project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="TestProject") + ifcopenshell.api.unit.assign_unit(f) + + site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="TestSite") + building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="TestBuilding") + storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="Ground Floor") + + ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project) + ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site) + ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=building) + + wall = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall001") + ifcopenshell.api.spatial.assign_container(f, products=[wall], relating_structure=storey) + + slab = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSlab", name="Slab001") + ifcopenshell.api.spatial.assign_container(f, products=[slab], relating_structure=storey) + + return f + + +@pytest.fixture +def model_file(model, tmp_path): + """Write the model fixture to a temp file and return the path.""" + path = tmp_path / "test.ifc" + model.write(str(path)) + return str(path) + + +@pytest.fixture +def loaded_session(model): + """An IfcSession with an in-memory model already loaded (no file path).""" + s = IfcSession() + s.model = model + return s diff --git a/src/ifcmcp/tests/test_edit.py b/src/ifcmcp/tests/test_edit.py new file mode 100644 index 0000000000..778b232249 --- /dev/null +++ b/src/ifcmcp/tests/test_edit.py @@ -0,0 +1,96 @@ +# This file was generated with the assistance of an AI coding tool. +import json + +import ifcopenshell +import pytest + +from ifcmcp.core import IfcSession, IfcSessionError + + +class TestNoModel: + def test_edit_no_model(self, session): + with pytest.raises(IfcSessionError, match="No model loaded"): + session.ifc_edit("root.create_entity") + + +class TestList: + def test_list_all_modules(self, loaded_session): + result = loaded_session.ifc_list() + assert isinstance(result, list) + assert len(result) > 0 + modules = [m["module"] for m in result] + assert "root" in modules + assert "spatial" in modules + + def test_list_module_functions(self, loaded_session): + result = loaded_session.ifc_list(module="root") + assert isinstance(result, list) + names = [f["name"] for f in result] + assert "create_entity" in names + + def test_list_empty_string_returns_modules(self, loaded_session): + result = loaded_session.ifc_list(module="") + assert isinstance(result, list) + assert any(m["module"] == "root" for m in result) + + +class TestDocs: + def test_docs_create_entity(self, loaded_session): + result = loaded_session.ifc_docs("root.create_entity") + assert result["module"] == "root" + assert result["function"] == "create_entity" + assert "params" in result + + def test_docs_bad_format(self, loaded_session): + with pytest.raises(ValueError): + loaded_session.ifc_docs("no_dot_here") + + +class TestEdit: + def test_create_entity(self, loaded_session): + result = loaded_session.ifc_edit("root.create_entity", json.dumps({"ifc_class": "IfcWall", "name": "NewWall"})) + assert result["ok"] is True + assert result["result"]["type"] == "IfcWall" + assert result["result"]["name"] == "NewWall" + + def test_create_entity_default_params(self, loaded_session): + result = loaded_session.ifc_edit("root.create_entity", "{}") + assert result["ok"] is True + + def test_unknown_function(self, loaded_session): + result = loaded_session.ifc_edit("root.nonexistent", "{}") + assert result["ok"] is False + assert "Cannot find" in result["error"] + + def test_unknown_parameter(self, loaded_session): + result = loaded_session.ifc_edit("root.create_entity", json.dumps({"bogus": "value"})) + assert result["ok"] is False + assert "Unknown parameter" in result["error"] + + def test_bad_json(self, loaded_session): + with pytest.raises(json.JSONDecodeError): + loaded_session.ifc_edit("root.create_entity", "not json") + + def test_edit_does_not_save(self, loaded_session, tmp_path): + """Verify that ifc_edit mutates the in-memory model but does not write to disk.""" + path = str(tmp_path / "test.ifc") + loaded_session.model.write(path) + loaded_session.model_path = path + + before_count = sum(1 for _ in loaded_session.model) + loaded_session.ifc_edit("root.create_entity", json.dumps({"ifc_class": "IfcWall", "name": "Unsaved"})) + after_count = sum(1 for _ in loaded_session.model) + assert after_count == before_count + 1 + + on_disk = ifcopenshell.open(path) + disk_count = sum(1 for _ in on_disk) + assert disk_count == before_count + + def test_assign_container(self, loaded_session): + wall = loaded_session.model.by_type("IfcWall")[0] + storey = loaded_session.model.by_type("IfcBuildingStorey")[0] + result = loaded_session.ifc_edit( + "spatial.assign_container", + json.dumps({"products": str(wall.id()), "relating_structure": str(storey.id())}), + ) + assert result["ok"] is True diff --git a/src/ifcmcp/tests/test_query.py b/src/ifcmcp/tests/test_query.py new file mode 100644 index 0000000000..e0a28ae1c0 --- /dev/null +++ b/src/ifcmcp/tests/test_query.py @@ -0,0 +1,113 @@ +# This file was generated with the assistance of an AI coding tool. +import pytest + +from ifcmcp.core import IfcSessionError + + +class TestNoModel: + """All query tools should fail when no model is loaded.""" + + def test_summary_no_model(self, session): + with pytest.raises(IfcSessionError, match="No model loaded"): + session.ifc_summary() + + def test_tree_no_model(self, session): + with pytest.raises(IfcSessionError, match="No model loaded"): + session.ifc_tree() + + def test_info_no_model(self, session): + with pytest.raises(IfcSessionError, match="No model loaded"): + session.ifc_info(1) + + def test_select_no_model(self, session): + with pytest.raises(IfcSessionError, match="No model loaded"): + session.ifc_select("IfcWall") + + def test_relations_no_model(self, session): + with pytest.raises(IfcSessionError, match="No model loaded"): + session.ifc_relations(1) + + +class TestSummary: + def test_schema(self, loaded_session): + result = loaded_session.ifc_summary() + assert result["schema"] == "IFC4" + + def test_total_entities(self, loaded_session): + result = loaded_session.ifc_summary() + assert result["total_entities"] > 0 + + def test_project_name(self, loaded_session): + result = loaded_session.ifc_summary() + assert result["project"]["name"] == "TestProject" + + def test_type_counts(self, loaded_session): + result = loaded_session.ifc_summary() + assert result["types"]["IfcWall"] == 1 + assert result["types"]["IfcSlab"] == 1 + + +class TestTree: + def test_root_is_project(self, loaded_session): + result = loaded_session.ifc_tree() + assert result["type"] == "IfcProject" + assert result["name"] == "TestProject" + + def test_hierarchy_depth(self, loaded_session): + result = loaded_session.ifc_tree() + site = result["children"][0] + assert site["type"] == "IfcSite" + building = site["children"][0] + assert building["type"] == "IfcBuilding" + storey = building["children"][0] + assert storey["type"] == "IfcBuildingStorey" + + +class TestInfo: + def test_wall_info(self, loaded_session): + wall = loaded_session.model.by_type("IfcWall")[0] + result = loaded_session.ifc_info(wall.id()) + assert result["id"] == wall.id() + assert result["type"] == "IfcWall" + + def test_invalid_id(self, loaded_session): + with pytest.raises(Exception): + loaded_session.ifc_info(999999) + + +class TestSelect: + def test_select_walls(self, loaded_session): + result = loaded_session.ifc_select("IfcWall") + assert len(result) == 1 + assert result[0]["type"] == "IfcWall" + assert result[0]["name"] == "Wall001" + + def test_select_slabs(self, loaded_session): + result = loaded_session.ifc_select("IfcSlab") + assert len(result) == 1 + assert result[0]["name"] == "Slab001" + + def test_select_no_match(self, loaded_session): + result = loaded_session.ifc_select("IfcWindow") + assert result == [] + + +class TestRelations: + def test_wall_relations(self, loaded_session): + wall = loaded_session.model.by_type("IfcWall")[0] + result = loaded_session.ifc_relations(wall.id()) + assert result["id"] == wall.id() + assert result["type"] == "IfcWall" + assert "hierarchy" in result + + def test_traverse_up(self, loaded_session): + wall = loaded_session.model.by_type("IfcWall")[0] + result = loaded_session.ifc_relations(wall.id(), traverse="up") + assert isinstance(result, list) + assert result[0]["type"] == "IfcWall" + assert result[-1]["type"] == "IfcProject" + + def test_traverse_empty_string_means_no_traverse(self, loaded_session): + wall = loaded_session.model.by_type("IfcWall")[0] + result = loaded_session.ifc_relations(wall.id(), traverse="") + assert isinstance(result, dict) diff --git a/src/ifcmcp/tests/test_server.py b/src/ifcmcp/tests/test_server.py new file mode 100644 index 0000000000..ed41434df7 --- /dev/null +++ b/src/ifcmcp/tests/test_server.py @@ -0,0 +1,106 @@ +# This file was generated with the assistance of an AI coding tool. +from unittest.mock import patch + +import pytest + +from ifcmcp.server import build_server + + +class TestServerRegistration: + def test_server_name(self): + server = build_server() + assert server.name == "ifc-mcp" + + def test_all_tools_registered(self): + server = build_server() + tools = [t.name for t in server._tool_manager.list_tools()] + expected = [ + "ifc_load", + "ifc_save", + "ifc_summary", + "ifc_tree", + "ifc_info", + "ifc_select", + "ifc_relations", + "ifc_clash", + "ifc_list", + "ifc_docs", + "ifc_edit", + ] + for name in expected: + assert name in tools, f"Tool {name} not registered" + + +@pytest.fixture +def tool_fns(): + """Return a dict of tool name → raw function from a freshly built server.""" + server = build_server() + return {t.name: t.fn for t in server._tool_manager.list_tools()} + + +PNG_FAKE = b"\x89PNG\r\n\x1a\nFAKE" +SVG_FAKE = b"FAKE" + + +class TestRenderOutputPath: + def test_no_output_path_no_file_written(self, tool_fns, tmp_path): + with patch("ifcmcp.core.IfcSession.ifc_render", return_value=PNG_FAKE): + tool_fns["ifc_render"](selector="", element_ids=None, view="iso", output_path="") + assert list(tmp_path.iterdir()) == [] + + def test_png_output_path_writes_file(self, tool_fns, tmp_path): + out = str(tmp_path / "render.png") + with patch("ifcmcp.core.IfcSession.ifc_render", return_value=PNG_FAKE): + tool_fns["ifc_render"](selector="", element_ids=None, view="iso", output_path=out) + assert open(out, "rb").read() == PNG_FAKE + + +class TestPlotOutputPath: + def test_no_output_path_no_file_written(self, tool_fns, tmp_path): + with patch("ifcmcp.core.IfcSession.ifc_plot", return_value=PNG_FAKE): + tool_fns["ifc_plot"]( + selector="", + element_ids=None, + view="floorplan", + width_mm=297.0, + height_mm=420.0, + scale=0.01, + png_width=1024, + png_height=1024, + output_path="", + ) + assert list(tmp_path.iterdir()) == [] + + def test_png_output_path_writes_png(self, tool_fns, tmp_path): + out = str(tmp_path / "plot.png") + with patch("ifcmcp.core.IfcSession.ifc_plot", return_value=PNG_FAKE): + tool_fns["ifc_plot"]( + selector="", + element_ids=None, + view="floorplan", + width_mm=297.0, + height_mm=420.0, + scale=0.01, + png_width=1024, + png_height=1024, + output_path=out, + ) + assert open(out, "rb").read() == PNG_FAKE + + def test_svg_output_path_writes_svg(self, tool_fns, tmp_path): + out = str(tmp_path / "plot.svg") + # ifc_plot is called twice: once with "png" for the inline image, + # once with "svg" for the file. + with patch("ifcmcp.core.IfcSession.ifc_plot", side_effect=[PNG_FAKE, SVG_FAKE]): + tool_fns["ifc_plot"]( + selector="", + element_ids=None, + view="floorplan", + width_mm=297.0, + height_mm=420.0, + scale=0.01, + png_width=1024, + png_height=1024, + output_path=out, + ) + assert open(out, "rb").read() == SVG_FAKE diff --git a/src/ifcmcp/tests/test_session.py b/src/ifcmcp/tests/test_session.py new file mode 100644 index 0000000000..4fa59120c0 --- /dev/null +++ b/src/ifcmcp/tests/test_session.py @@ -0,0 +1,62 @@ +# This file was generated with the assistance of an AI coding tool. +from unittest.mock import patch + +import ifcopenshell +import pytest + +from ifcmcp.core import IfcSession, IfcSessionError + + +class TestLoad: + def test_load_file(self, session, model_file): + result = session.ifc_load(model_file) + assert "IFC4" in result + assert session.model is not None + assert session.model_path == model_file + + def test_load_sets_entity_count(self, session, model_file): + result = session.ifc_load(model_file) + assert "entities" in result + + def test_load_nonexistent_file(self, session): + with pytest.raises(Exception): + session.ifc_load("/nonexistent/path/model.ifc") + + +class TestSave: + def test_save_no_model(self, session): + with pytest.raises(IfcSessionError, match="No model loaded"): + session.ifc_save() + + def test_save_overwrites_original(self, session, model_file): + session.ifc_load(model_file) + result = session.ifc_save() + assert model_file in result + + def test_save_to_new_path(self, session, model_file, tmp_path): + session.ifc_load(model_file) + new_path = str(tmp_path / "output.ifc") + result = session.ifc_save(new_path) + assert new_path in result + reloaded = ifcopenshell.open(new_path) + assert reloaded.schema == "IFC4" + + def test_save_no_path_no_original(self, loaded_session): + with pytest.raises(IfcSessionError, match="No path specified"): + loaded_session.ifc_save() + + +class TestIfcPlotOutputFormat: + """ifc_plot should pass output_format through to the underlying plot function.""" + + def test_default_output_format_is_png(self, loaded_session): + with patch("ifcmcp.core.plot_mod.plot", return_value=b"PNG_FAKE") as mock_plot: + loaded_session.ifc_plot() + mock_plot.assert_called_once() + assert mock_plot.call_args.kwargs["output_format"] == "png" + + def test_svg_output_format(self, loaded_session): + with patch("ifcmcp.core.plot_mod.plot", return_value=b"SVG_FAKE") as mock_plot: + result = loaded_session.ifc_plot(output_format="svg") + assert result == b"SVG_FAKE" + assert mock_plot.call_args.kwargs["output_format"] == "svg" diff --git a/src/ifcmcp/tests/test_shape.py b/src/ifcmcp/tests/test_shape.py new file mode 100644 index 0000000000..1b657d5730 --- /dev/null +++ b/src/ifcmcp/tests/test_shape.py @@ -0,0 +1,141 @@ +# This file was generated with the assistance of an AI coding tool. +import json + +import pytest + +from ifcmcp.core import IfcSessionError + + +class TestShapeList: + def test_returns_list(self, loaded_session): + result = loaded_session.ifc_shape_list() + assert isinstance(result, list) + assert len(result) > 0 + + def test_has_expected_methods(self, loaded_session): + result = loaded_session.ifc_shape_list() + names = [m["method"] for m in result] + assert "polyline" in names + assert "rectangle" in names + assert "extrude" in names + assert "profile" in names + assert "get_representation" in names + + def test_well_documented_methods_have_descriptions(self, loaded_session): + result = loaded_session.ifc_shape_list() + by_name = {m["method"]: m for m in result} + # These methods have detailed docstrings + for name in ("polyline", "extrude", "rectangle", "profile", "get_representation"): + assert by_name[name]["description"], f"'{name}' has no description" + + def test_no_private_methods(self, loaded_session): + result = loaded_session.ifc_shape_list() + assert not any(m["method"].startswith("_") for m in result) + + def test_does_not_require_model(self, session): + # ifc_shape_list is pure introspection — no model needed + result = session.ifc_shape_list() + assert isinstance(result, list) + + +class TestShapeDocs: + def test_extrude_docs(self, loaded_session): + result = loaded_session.ifc_shape_docs("extrude") + assert result["method"] == "extrude" + assert result["description"] + assert "params" in result + param_names = [p["name"] for p in result["params"]] + assert "profile_or_curve" in param_names + assert "magnitude" in param_names + + def test_has_return_type(self, loaded_session): + result = loaded_session.ifc_shape_docs("rectangle") + assert "return_type" in result + + def test_has_param_descriptions(self, loaded_session): + result = loaded_session.ifc_shape_docs("polyline") + params_with_desc = [p for p in result["params"] if "description" in p] + assert len(params_with_desc) > 0 + + def test_unknown_method(self, loaded_session): + with pytest.raises(ValueError, match="no method"): + loaded_session.ifc_shape_docs("nonexistent_method") + + def test_private_method_rejected(self, loaded_session): + with pytest.raises(ValueError): + loaded_session.ifc_shape_docs("__init__") + + def test_does_not_require_model(self, session): + result = session.ifc_shape_docs("circle") + assert result["method"] == "circle" + + +class TestShapeExecute: + def test_rectangle(self, loaded_session): + result = loaded_session.ifc_shape("rectangle", json.dumps({"size": [4.0, 0.2]})) + assert result["ok"] is True + assert result["result"]["type"] == "IfcIndexedPolyCurve" + + def test_circle(self, loaded_session): + result = loaded_session.ifc_shape("circle", json.dumps({"center": [0.0, 0.0], "radius": 0.5})) + assert result["ok"] is True + assert result["result"]["type"] == "IfcCircle" + + def test_extrude_chained_from_rectangle(self, loaded_session): + rect = loaded_session.ifc_shape("rectangle", json.dumps({"size": [4.0, 0.2]})) + rect_id = rect["result"]["id"] + result = loaded_session.ifc_shape("extrude", json.dumps({"profile_or_curve": rect_id, "magnitude": 3.0})) + assert result["ok"] is True + assert result["result"]["type"] == "IfcExtrudedAreaSolid" + + def test_entity_id_as_integer(self, loaded_session): + """Entity IDs should be accepted as plain integers (from JSON).""" + rect = loaded_session.ifc_shape("rectangle", json.dumps({"size": [1.0, 1.0]})) + rect_id = rect["result"]["id"] + # Pass as int, not string + result = loaded_session.ifc_shape("extrude", json.dumps({"profile_or_curve": rect_id, "magnitude": 1.0})) + assert result["ok"] is True + + def test_rotate_2d_point_returns_list(self, loaded_session): + """Methods returning numpy arrays should give back plain lists.""" + result = loaded_session.ifc_shape( + "rotate_2d_point", json.dumps({"point_2d": [1.0, 0.0], "angle": 90.0, "counter_clockwise": True}) + ) + assert result["ok"] is True + assert isinstance(result["result"], list) + assert len(result["result"]) == 2 + + def test_set_polyline_coords_returns_none(self, loaded_session): + """In-place methods that return None should give ok=True, result=None.""" + rect = loaded_session.ifc_shape("rectangle", json.dumps({"size": [2.0, 2.0]})) + rect_id = rect["result"]["id"] + result = loaded_session.ifc_shape( + "set_polyline_coords", + json.dumps({"polyline": rect_id, "coords": [[0.0, 0.0], [3.0, 0.0], [3.0, 3.0], [0.0, 3.0]]}), + ) + assert result["ok"] is True + assert result["result"] is None + + def test_unknown_method(self, loaded_session): + result = loaded_session.ifc_shape("nonexistent_method", "{}") + assert result["ok"] is False + assert "error" in result + + def test_private_method_rejected(self, loaded_session): + with pytest.raises(IfcSessionError): + loaded_session.ifc_shape("__init__", "{}") + + def test_no_model_raises(self, session): + with pytest.raises(IfcSessionError, match="No model loaded"): + session.ifc_shape("rectangle", "{}") + + def test_params_as_dict(self, loaded_session): + """params can be passed as a dict (not just a JSON string).""" + result = loaded_session.ifc_shape("rectangle", {"size": [2.0, 1.0]}) + assert result["ok"] is True + + def test_error_on_bad_params(self, loaded_session): + """Bad parameters should give ok=False with an error message.""" + result = loaded_session.ifc_shape("extrude", json.dumps({"profile_or_curve": 999999, "magnitude": 1.0})) + assert result["ok"] is False + assert "error" in result diff --git a/src/ifcopenshell-python/docs/ifcopenshell-python/geometry_creation.rst b/src/ifcopenshell-python/docs/ifcopenshell-python/geometry_creation.rst index 817630ad7d..3aa34a3aa8 100644 --- a/src/ifcopenshell-python/docs/ifcopenshell-python/geometry_creation.rst +++ b/src/ifcopenshell-python/docs/ifcopenshell-python/geometry_creation.rst @@ -775,3 +775,52 @@ responsibility to make sure the geometry is correct. # Assign our new body geometry back to our beam ifcopenshell.api.geometry.assign_representation(model, product=beam, representation=representation) + +Moving assemblies +----------------- + +When moving an assembly and you want all children to follow, pass +``should_transform_children=True``. The default (``False``) rewrites each +child's local placement to preserve its world position, so the parent moves +but the children stay where they are. + +.. code-block:: python + + matrix = numpy.eye(4) + matrix[:,3][0:3] = (0, 0, 6) + + # Move the assembly; children travel with it. + ifcopenshell.api.geometry.edit_object_placement(model, + product=assembly, matrix=matrix, is_si=True, + should_transform_children=True) + +Clipping normals convention +--------------------------- + +The ``normal`` passed to :func:`geometry.clip_solid`, +:func:`geometry.clip_solid_bounded`, and the ``clippings`` parameter of +:func:`geometry.add_wall_representation` points toward the **removed** +material (the discarded side), not toward the kept material. + +.. code-block:: python + + # Clip the top of a wall to a lean-to slope. + # normal points upward into the wedge that will be removed. + bcr = ifcopenshell.api.geometry.clip_solid(model, + item=extrusion, + location=[0.0, 0.0, 3.26], + normal=[0.419, 0.0, 0.908]) + shape_representation.RepresentationType = "Clipping" + +Opening lifecycle +----------------- + +``feature.remove_feature`` permanently deletes the feature entity from the +model. Any fillings (windows, doors) that occupied the opening become +orphaned and must be separately removed via ``root.remove_product``. + +.. code-block:: python + + # Remove a window and its opening from a wall. + ifcopenshell.api.root.remove_product(model, product=window) + ifcopenshell.api.feature.remove_feature(model, feature=opening) diff --git a/src/ifcopenshell-python/ifcopenshell/api/boundary/assign_connection_geometry.py b/src/ifcopenshell-python/ifcopenshell/api/boundary/assign_connection_geometry.py index f974ea2cd2..9d988a4e82 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/boundary/assign_connection_geometry.py +++ b/src/ifcopenshell-python/ifcopenshell/api/boundary/assign_connection_geometry.py @@ -108,7 +108,7 @@ class Usecase: self.rel_space_boundary.ConnectionGeometry = connection_geometry def create_point(self, point: npt.NDArray) -> ifcopenshell.entity_instance: - return self.file.create_enitty("IfcCartesianPoint", ifc_safe_vector_type(point / self.unit_scale)) + return self.file.create_entity("IfcCartesianPoint", ifc_safe_vector_type(point / self.unit_scale)) def close_polyline( self, points: tuple[ifcopenshell.entity_instance, ...] @@ -127,7 +127,7 @@ class Usecase: return self.file.createIfcPlane( self.file.createIfcAxis2Placement3D( self.create_point(location), - self.file.createIfcDirection(axis), - self.file.createIfcDirection(ref_direction), + self.file.createIfcDirection(ifc_safe_vector_type(axis)), + self.file.createIfcDirection(ifc_safe_vector_type(ref_direction)), ) ) diff --git a/src/ifcopenshell-python/ifcopenshell/api/boundary/edit_attributes.py b/src/ifcopenshell-python/ifcopenshell/api/boundary/edit_attributes.py index ee11094d13..4fa5516bc6 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/boundary/edit_attributes.py +++ b/src/ifcopenshell-python/ifcopenshell/api/boundary/edit_attributes.py @@ -27,12 +27,11 @@ def edit_attributes( related_building_element: ifcopenshell.entity_instance, parent_boundary: Optional[ifcopenshell.entity_instance] = None, corresponding_boundary: Optional[ifcopenshell.entity_instance] = None, + physical_or_virtual: str = "NOTDEFINED", + internal_or_external: str = "NOTDEFINED", ) -> None: """Modify the relationships of a space boundary relationship - Currently this function is quite minimal and offers no advantage to - manual assignment of the space boundary attributes. - :param entity: The IfcRelSpaceBoundary to modify :param relating_space: The IfcSpace or IfcExternalSpatialElement that the space boundary is related to. @@ -44,17 +43,18 @@ def edit_attributes( :param corresponding_boundary: The other IfcRelSpaceBoundary on the other side of the related element. The pair together represents a thermal boundary. This only applies to 2nd level boundaries. + :param physical_or_virtual: IfcPhysicalOrVirtualEnum value: "PHYSICAL", + "VIRTUAL", or "NOTDEFINED". + :param internal_or_external: IfcInternalOrExternalEnum value: + "INTERNAL", "EXTERNAL", "EXTERNAL_EARTH", "EXTERNAL_WATER", + "EXTERNAL_FIRE", or "NOTDEFINED". :return: None """ - entity = entity - relating_space = relating_space - related_building_element = related_building_element - parent_boundary = parent_boundary - corresponding_boundary = corresponding_boundary - entity.RelatingSpace = relating_space entity.RelatedBuildingElement = related_building_element if hasattr(entity, "ParentBoundary"): entity.ParentBoundary = parent_boundary if hasattr(entity, "CorrespondingBoundary"): entity.CorrespondingBoundary = corresponding_boundary + entity.PhysicalOrVirtualBoundary = physical_or_virtual + entity.InternalOrExternalBoundary = internal_or_external diff --git a/src/ifcopenshell-python/ifcopenshell/api/feature/remove_feature.py b/src/ifcopenshell-python/ifcopenshell/api/feature/remove_feature.py index ff847b39eb..100e886214 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/feature/remove_feature.py +++ b/src/ifcopenshell-python/ifcopenshell/api/feature/remove_feature.py @@ -22,11 +22,13 @@ import ifcopenshell.util.element def remove_feature(file: ifcopenshell.file, feature: ifcopenshell.entity_instance) -> None: - """Remove a feature + """Permanently delete a feature element and its void or projection relationship. - Fillings are retained as orphans. Featured elements remain. Features - cannot exist by themselves, so not only is the relationship removed, the - feature is also removed. + The feature entity (e.g. IfcOpeningElement) is removed from the model + along with its IfcRelVoidsElement or IfcRelProjectsElement relationship. + The host element (wall, slab, etc.) is unaffected. Any fillings (windows, + doors) that occupied the opening become orphaned and must be separately + deleted via root.remove_product. :param feature: The IfcFeatureElement to remove. diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/__init__.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/__init__.py index 44728c2902..d845f4dc83 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/__init__.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/__init__.py @@ -25,7 +25,10 @@ geometry extrusions). from .. import wrap_usecases from .add_axis_representation import add_axis_representation +from .add_topology_representation import add_topology_representation from .add_boolean import add_boolean +from .clip_solid import clip_solid +from .clip_solid_bounded import clip_solid_bounded from .add_door_representation import add_door_representation from .add_footprint_representation import add_footprint_representation from .add_mesh_representation import add_mesh_representation @@ -50,6 +53,7 @@ from .disconnect_element import disconnect_element from .disconnect_path import disconnect_path from .edit_object_placement import edit_object_placement from .map_representation import map_representation +from .copy_representation import copy_representation from .regenerate_wall_representation import regenerate_wall_representation from .remove_boolean import remove_boolean from .remove_representation import remove_representation @@ -60,7 +64,11 @@ wrap_usecases(__path__, __name__) __all__ = [ "add_axis_representation", + "add_topology_representation", "add_boolean", + "clip_solid", + "clip_solid_bounded", + "copy_representation", "add_door_representation", "add_footprint_representation", "add_mesh_representation", diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_topology_representation.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_topology_representation.py new file mode 100644 index 0000000000..14c4fa9dcc --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_topology_representation.py @@ -0,0 +1,98 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2026 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell 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. +# +# IfcOpenShell 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 IfcOpenShell. If not, see . +# This file was generated with the assistance of an AI coding tool. + +from typing import Optional + +import ifcopenshell + + +_ITEM_TYPE_TO_REP_TYPE = { + "IfcVertex": "Vertex", + "IfcVertexPoint": "Vertex", + "IfcEdge": "Edge", + "IfcOrientedEdge": "Edge", + "IfcEdgeCurve": "Edge", + "IfcEdgeLoop": "Edge", + "IfcPath": "Edge", + "IfcFace": "Face", + "IfcFaceSurface": "Face", + "IfcAdvancedFace": "Face", + "IfcClosedShell": "Face", + "IfcOpenShell": "Face", + "IfcConnectedFaceSet": "Face", +} + + +def add_topology_representation( + file: ifcopenshell.file, + context: ifcopenshell.entity_instance, + item: ifcopenshell.entity_instance, + representation_identifier: Optional[str] = None, + representation_type: Optional[str] = None, +) -> ifcopenshell.entity_instance: + """Adds an IfcTopologyRepresentation for a structural element + + Structural analysis elements (IfcStructuralSurfaceMember, + IfcStructuralCurveMember) use topology representations rather than solid + geometry. This is analogous to :func:`add_axis_representation` and + :func:`add_profile_representation` but produces an + IfcTopologyRepresentation instead of an IfcShapeRepresentation. + + The representation type ("Face", "Edge", "Vertex") is inferred from the + item's IFC class if not provided explicitly. + + :param context: The IfcGeometricRepresentationContext for the + representation, typically a Reference context. + :param item: The IfcTopologicalRepresentationItem (e.g. IfcFaceSurface, + IfcEdge) to include in the representation. + :param representation_identifier: The RepresentationIdentifier string. + Defaults to the context's ContextIdentifier. + :param representation_type: The RepresentationType string ("Face", + "Edge", "Vertex"). Inferred from item class if not given. + :return: The newly created IfcTopologyRepresentation entity. + + Example: + + .. code:: python + + context = ifcopenshell.util.representation.get_context( + model, "Model", "Reference", "GRAPH_VIEW") + face = model.createIfcFaceSurface(bounds, surface, True) + rep = ifcopenshell.api.geometry.add_topology_representation( + model, context=context, item=face) + ifcopenshell.api.geometry.assign_representation( + model, product=member, representation=rep) + """ + if representation_identifier is None: + representation_identifier = context.ContextIdentifier + + if representation_type is None: + for ifc_class, rep_type in _ITEM_TYPE_TO_REP_TYPE.items(): + if item.is_a(ifc_class): + representation_type = rep_type + break + else: + representation_type = "Undefined" + + return file.createIfcTopologyRepresentation( + context, + representation_identifier, + representation_type, + [item], + ) diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_wall_representation.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_wall_representation.py index ff62bae474..c542471896 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_wall_representation.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_wall_representation.py @@ -47,7 +47,9 @@ def add_wall_representation( :param thickness: The thickness of the wall in meters. :param x_angle: The slope angle along the wall's X-axis, in radians. :param clippings: List of clipping definitions. Clippings can be `Clipping` objects - or dictionaries of arguments for `Clipping.parse`. + or dictionaries of arguments for `Clipping.parse`. Each clipping has a + ``normal`` that points toward the removed material (the discarded side), + not toward the kept material; see :func:`clip_solid` for details. :param booleans: List of any existing IfcBooleanResults. :return: IfcShapeRepresentation. """ diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/clip_solid.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/clip_solid.py new file mode 100644 index 0000000000..a83fd4385f --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/clip_solid.py @@ -0,0 +1,86 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2026 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell 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. +# +# IfcOpenShell 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 IfcOpenShell. If not, see . + +from __future__ import annotations + +import json +from typing import Optional, Sequence + +import ifcopenshell.api.pset +import ifcopenshell.util.element +import ifcopenshell.util.unit +from ifcopenshell.util.data import Clipping + + +def clip_solid( + file: ifcopenshell.file, + item: ifcopenshell.entity_instance, + location: Sequence[float], + normal: Sequence[float], + element: Optional[ifcopenshell.entity_instance] = None, +) -> ifcopenshell.entity_instance: + """Clip a solid with a half-space plane, returning an IfcBooleanClippingResult. + + Convenience wrapper around :class:`ifcopenshell.util.data.Clipping` for + use with any solid. This is the same convention used by the ``clippings`` + parameter of :func:`add_wall_representation`. + + .. warning:: + + The ``normal`` points toward the **removed** material (the discarded + side), not toward the kept material. For a slope clip the normal + points upward into the removed wedge above the slope line. For a + side mitre the normal points outward away from the wall body. + + After clipping, set the parent ``IfcShapeRepresentation`` + ``RepresentationType`` to ``"Clipping"``. + + Example — trim an extruded solid to a lean-to slope (removed material is + above the slope):: + + bcr = ifcopenshell.api.run( + "geometry.clip_solid", model, + item=extrusion, + location=[0.0, 0.0, 3.26], + normal=[0.419, 0.0, 0.908], # points UP toward removed material + ) + + :param item: The solid to clip (``IfcSweptAreaSolid``, ``IfcSweptDiskSolid``, + or ``IfcBooleanClippingResult``). + :param location: A point on the clipping plane in the representation's + local coordinate system. + :param normal: Plane normal pointing toward the material to be removed + (see warning above). + :param element: If provided, the resulting ``IfcBooleanClippingResult`` is + registered in the element's ``BBIM_Boolean`` property set so that + :func:`regenerate_wall_representation` preserves it during regeneration. + :return: The resulting ``IfcBooleanClippingResult``. + """ + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file) + clipping = Clipping(location=tuple(location), normal=tuple(normal)) + result = clipping.apply(file, item, unit_scale) + if element is not None: + pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Boolean") + if pset_data: + pset = file.by_id(pset_data["id"]) + data = list(set(json.loads(pset_data["Data"]) + [result.id()])) + else: + pset = ifcopenshell.api.pset.add_pset(file, product=element, name="BBIM_Boolean") + data = [result.id()] + ifcopenshell.api.pset.edit_pset(file, pset=pset, properties={"Data": json.dumps(data)}) + return result diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/clip_solid_bounded.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/clip_solid_bounded.py new file mode 100644 index 0000000000..ac2e39c741 --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/clip_solid_bounded.py @@ -0,0 +1,116 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2026 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell 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. +# +# IfcOpenShell 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 IfcOpenShell. If not, see . + +from __future__ import annotations + +import json +from typing import Optional, Sequence + +import numpy as np + +import ifcopenshell.api.pset +import ifcopenshell.util.element +import ifcopenshell.util.unit +from ifcopenshell.util.shape_builder import ShapeBuilder + + +def clip_solid_bounded( + file: ifcopenshell.file, + item: ifcopenshell.entity_instance, + location: Sequence[float], + normal: Sequence[float], + boundary_points: Sequence[Sequence[float]], + boundary_position: Sequence[float] = (0.0, 0.0, 0.0), + element: Optional[ifcopenshell.entity_instance] = None, +) -> ifcopenshell.entity_instance: + """Clip a solid with a polygonally bounded half-space, returning an IfcBooleanClippingResult. + + Like :func:`clip_solid`, but the boolean subtraction is restricted to the + region enclosed by ``boundary_points`` rather than extending across the + entire half-space. The clipping plane is still infinite, but material is + only removed within the extruded footprint of the polygon. + + The ``normal`` convention is the same as :func:`clip_solid`: it points + toward the **removed** material. + + After clipping, set the parent ``IfcShapeRepresentation`` + ``RepresentationType`` to ``"Clipping"``. + + Example:: + + bcr = ifcopenshell.api.run( + "geometry.clip_solid_bounded", model, + item=extrusion, + location=[2.5, 0.0, 2.0], + normal=[0.6, 0.0, 0.8], + boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]], + ) + + :param item: The solid to clip (``IfcSweptAreaSolid``, ``IfcSweptDiskSolid``, + or ``IfcBooleanClippingResult``). + :param location: A point on the clipping plane in the representation's + local coordinate system. + :param normal: Plane normal pointing toward the material to be removed. + :param boundary_points: 2D ``[x, y]`` points defining the closed polygonal + boundary in the coordinate system of ``boundary_position``. The polygon + is automatically closed — do not repeat the first point. + :param boundary_position: 3D origin of the boundary coordinate system + (axes default to the global X/Y/Z directions). Defaults to the origin. + :param element: If provided, the resulting ``IfcBooleanClippingResult`` is + registered in the element's ``BBIM_Boolean`` property set so that + :func:`regenerate_wall_representation` preserves it during regeneration. + :return: The resulting ``IfcBooleanClippingResult``. + """ + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file) + builder = ShapeBuilder(file) + + normal_arr = np.array(normal) + if np.allclose(normal_arr, [0.0, 0.0, 1.0], atol=1e-2) or np.allclose(normal_arr, [0.0, 0.0, -1.0], atol=1e-2): + arbitrary_vector = np.array([0.0, 1.0, 0.0]) + else: + arbitrary_vector = np.array([0.0, 0.0, 1.0]) + x_axis = np.cross(normal_arr, arbitrary_vector) + x_axis /= np.linalg.norm(x_axis) + + scaled_location = [i / unit_scale for i in location] + plane_placement = builder.create_axis2_placement_3d(scaled_location, normal, x_axis) + plane = file.create_entity("IfcPlane", plane_placement) + + scaled_boundary_position = [i / unit_scale for i in boundary_position] + boundary_pos_entity = file.create_entity( + "IfcAxis2Placement3D", + file.create_entity("IfcCartesianPoint", scaled_boundary_position), + ) + + scaled_pts = [[p[0] / unit_scale, p[1] / unit_scale] for p in boundary_points] + scaled_pts.append(scaled_pts[0]) # close the polygon + ifc_pts = [file.create_entity("IfcCartesianPoint", p) for p in scaled_pts] + boundary = file.createIfcPolyline(ifc_pts) + + half_space = file.create_entity("IfcPolygonalBoundedHalfSpace", plane, False, boundary_pos_entity, boundary) + result = file.create_entity("IfcBooleanClippingResult", "DIFFERENCE", item, half_space) + if element is not None: + pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Boolean") + if pset_data: + pset = file.by_id(pset_data["id"]) + data = list(set(json.loads(pset_data["Data"]) + [result.id()])) + else: + pset = ifcopenshell.api.pset.add_pset(file, product=element, name="BBIM_Boolean") + data = [result.id()] + ifcopenshell.api.pset.edit_pset(file, pset=pset, properties={"Data": json.dumps(data)}) + return result diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/connect_path.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/connect_path.py index 3135bef116..64c9fddb62 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/connect_path.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/connect_path.py @@ -31,6 +31,7 @@ def connect_path( relating_connection: str = "NOTDEFINED", related_connection: str = "NOTDEFINED", description: Optional[str] = None, + connection_geometry: Optional[ifcopenshell.entity_instance] = None, ) -> ifcopenshell.entity_instance: incompatible_connections: list[ifcopenshell.entity_instance] = [] for rel in relating_element.ConnectedTo: @@ -73,6 +74,7 @@ def connect_path( ifcopenshell.guid.new(), OwnerHistory=ifcopenshell.api.owner.create_owner_history(file), Description=description, + ConnectionGeometry=connection_geometry, RelatingElement=relating_element, RelatedElement=related_element, RelatingConnectionType=relating_connection, diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/copy_representation.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/copy_representation.py new file mode 100644 index 0000000000..c2b4c485a9 --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/copy_representation.py @@ -0,0 +1,76 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2026 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell 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. +# +# IfcOpenShell 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 IfcOpenShell. If not, see . + +from __future__ import annotations + +from typing import Optional + +import ifcopenshell.api.geometry +import ifcopenshell.util.element +import ifcopenshell.util.representation + + +def copy_representation( + file: ifcopenshell.file, + source: ifcopenshell.entity_instance, + target: ifcopenshell.entity_instance, + context_identifier: str = "Body", +) -> Optional[ifcopenshell.entity_instance]: + """Copy a geometric representation from one element to another. + + Finds the named representation on ``source``, deep-copies its entity + graph (geometry items, profiles, placements, etc.), and assigns the copy + to ``target``. Representation contexts are shared rather than copied. + If ``target`` already has a matching representation it is removed and + replaced. + + If no matching representation is found on ``source``, returns ``None`` + and leaves ``target`` unchanged. + + :param source: The element to copy the representation from. + :param target: The element to assign the copied representation to. + :param context_identifier: The RepresentationIdentifier to look up on + ``source`` (e.g. ``"Body"``, ``"Axis"``, ``"Box"``). + Defaults to ``"Body"``. + :return: The newly created IfcShapeRepresentation, or None if no + matching representation was found on ``source``. + + Example: + + .. code:: python + + wall_a = model.by_id(1) + wall_b = model.by_id(2) + + # Give wall_b the same body geometry as wall_a. + ifcopenshell.api.geometry.copy_representation(model, + source=wall_a, target=wall_b) + """ + source_rep = ifcopenshell.util.representation.get_representation(source, "Model", context_identifier) + if source_rep is None: + return None + + new_rep = ifcopenshell.util.element.copy_deep(file, source_rep, exclude=["IfcGeometricRepresentationContext"]) + + existing_rep = ifcopenshell.util.representation.get_representation(target, "Model", context_identifier) + if existing_rep: + ifcopenshell.api.geometry.unassign_representation(file, product=target, representation=existing_rep) + ifcopenshell.api.geometry.remove_representation(file, representation=existing_rep) + + ifcopenshell.api.geometry.assign_representation(file, product=target, representation=new_rep) + return new_rep diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/edit_object_placement.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/edit_object_placement.py index 7fbe44ce12..0d86b997f5 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/edit_object_placement.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/edit_object_placement.py @@ -52,10 +52,11 @@ def edit_object_placement( :param is_si: If True, the matrix is given in SI units. If false, in project units. :param should_transform_children: A child element is a nested element, - opening, filling, etc. If true, child elements will move along with the - parent. If false, child elements will stay where they are. Because most - placements in IFC are relative, this means that if a child moves, we - actually don't change their placement. + opening, filling, etc. If True, child elements move along with the + parent; pass True when moving an assembly (roof, furniture group, etc.) + and you want all children to follow. If False (default), child elements + keep their current world positions; their local placements are rewritten + to compensate for the parent move. :return: The new or updated IfcLocalPlacement entity """ usecase = Usecase() diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/regenerate_wall_representation.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/regenerate_wall_representation.py index 9143577438..e59c6e1efa 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/regenerate_wall_representation.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/regenerate_wall_representation.py @@ -69,6 +69,12 @@ def regenerate_wall_representation( additional extrusions are generated for each connection that boolean difference the base extrusion. + Clippings applied via :func:`geometry.clip_solid` or + :func:`geometry.clip_solid_bounded` are preserved only if the ``element`` + parameter was passed when creating them, which registers the result in the + ``BBIM_Boolean`` property set. Clippings created without that parameter + are silently discarded during regeneration. + This will also update the axis line representation (e.g. trim the axis line to any connections). diff --git a/src/ifcopenshell-python/ifcopenshell/api/georeference/add_georeferencing.py b/src/ifcopenshell-python/ifcopenshell/api/georeference/add_georeferencing.py index 43ccaea2bc..c7e6dfba80 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/georeference/add_georeferencing.py +++ b/src/ifcopenshell-python/ifcopenshell/api/georeference/add_georeferencing.py @@ -17,6 +17,7 @@ # along with IfcOpenShell. If not, see . import ifcopenshell +import ifcopenshell.api.georeference import ifcopenshell.api.pset import ifcopenshell.util.element @@ -63,8 +64,13 @@ def add_georeferencing(file: ifcopenshell.file, ifc_class: str = "IfcMapConversi }, ) return - if file.by_type("IfcProjectedCRS"): + has_crs = bool(file.by_type("IfcProjectedCRS")) + has_conversion = bool(file.by_type("IfcCoordinateOperation")) + if has_crs and has_conversion: return + if has_crs or has_conversion: + # This is technically invalid, but we shall forgive the industry here if they are wrong ... + ifcopenshell.api.georeference.remove_georeferencing(file) source_crs = None for context in file.by_type("IfcGeometricRepresentationContext", include_subtypes=False): if context.ContextType == "Model": diff --git a/src/ifcopenshell-python/ifcopenshell/api/sequence/assign_process.py b/src/ifcopenshell-python/ifcopenshell/api/sequence/assign_process.py index 302445483b..e7cca1dafa 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/sequence/assign_process.py +++ b/src/ifcopenshell-python/ifcopenshell/api/sequence/assign_process.py @@ -26,7 +26,7 @@ def assign_process( relating_process: ifcopenshell.entity_instance, related_object: ifcopenshell.entity_instance, ) -> ifcopenshell.entity_instance: - """Assigns an object to be related to a process, typically a construction task + """Assigns an object as an input, control, or resource of a process Processes work using the ICOM (Input, Controls, Outputs, Mechanisms) paradigm in IFC. This process model is commonly used in modeling @@ -63,6 +63,17 @@ def assign_process( For resources, any construction resource may be assigned to a task. + .. warning:: + + This function creates an **Input** relationship + (``IfcRelAssignsToProcess``), meaning the product is *consumed* or + *operated on* by the task — the typical case is demolition or + maintenance. + + If the task *constructs or installs* a product (e.g. erecting a wall + or fitting a window), use :func:`assign_product` instead, which + creates an **Output** relationship (``IfcRelAssignsToProduct``). + :param relating_process: The IfcProcess (typically IfcTask) that the input, control, or resource is related to. :param related_object: The IfcProduct (for input), IfcCostItem (for @@ -77,7 +88,7 @@ def assign_process( # need to be part of a work schedule. schedule = ifcopenshell.api.sequence.add_work_schedule(model, name="Construction Schedule A") - # Let's create a construction task. Note that the predefined type is + # Let's create a demolition task. Note that the predefined type is # important to distinguish types of tasks. task = ifcopenshell.api.sequence.add_task(model, work_schedule=schedule, name="Demolish existing", identification="A", predefined_type="DEMOLITION") @@ -85,8 +96,12 @@ def assign_process( # Let's say we have a wall somewhere. wall = ifcopenshell.api.root.create_entity(model, ifc_class="IfcWall") - # Let's demolish that wall! + # The wall is an INPUT to the demolition task (it will be consumed). ifcopenshell.api.sequence.assign_process(model, relating_process=task, related_object=wall) + + # For a construction task that BUILDS a wall, use assign_product instead: + # build_task = ifcopenshell.api.sequence.add_task(model, ..., predefined_type="CONSTRUCTION") + # ifcopenshell.api.sequence.assign_product(model, relating_product=wall, related_object=build_task) """ if related_object.HasAssignments: for assignment in related_object.HasAssignments: diff --git a/src/ifcopenshell-python/ifcopenshell/api/structural/__init__.py b/src/ifcopenshell-python/ifcopenshell/api/structural/__init__.py index 2baa262432..8a21e73602 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/structural/__init__.py +++ b/src/ifcopenshell-python/ifcopenshell/api/structural/__init__.py @@ -30,7 +30,9 @@ from .add_structural_load import add_structural_load from .add_structural_load_case import add_structural_load_case from .add_structural_load_group import add_structural_load_group from .add_structural_member_connection import add_structural_member_connection +from .assign_product import assign_product from .assign_structural_analysis_model import assign_structural_analysis_model +from .assign_to_building import assign_to_building from .edit_structural_analysis_model import edit_structural_analysis_model from .edit_structural_boundary_condition import edit_structural_boundary_condition from .edit_structural_connection_cs import edit_structural_connection_cs @@ -57,7 +59,9 @@ __all__ = [ "add_structural_load_case", "add_structural_load_group", "add_structural_member_connection", + "assign_product", "assign_structural_analysis_model", + "assign_to_building", "edit_structural_analysis_model", "edit_structural_boundary_condition", "edit_structural_connection_cs", diff --git a/src/ifcopenshell-python/ifcopenshell/api/structural/assign_product.py b/src/ifcopenshell-python/ifcopenshell/api/structural/assign_product.py new file mode 100644 index 0000000000..a30672facd --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/structural/assign_product.py @@ -0,0 +1,64 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2026 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell 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. +# +# IfcOpenShell 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 IfcOpenShell. If not, see . +# This file was generated with the assistance of an AI coding tool. + +import ifcopenshell +import ifcopenshell.api.root + + +def assign_product( + file: ifcopenshell.file, + relating_product: ifcopenshell.entity_instance, + related_object: ifcopenshell.entity_instance, +) -> ifcopenshell.entity_instance: + """Links an object to a product via IfcRelAssignsToProduct + + Typically used to associate a physical building element with a structural + analysis member (IfcStructuralSurfaceMember, IfcStructuralCurveMember) so + that analysis results can be traced back to the physical model. + + :param relating_product: The IfcProduct that the object is assigned to, + typically an IfcStructuralMember. + :param related_object: The IfcObjectDefinition being assigned, typically + a physical building element such as an IfcWall or IfcSlab. + :return: The IfcRelAssignsToProduct relationship. + + Example: + + .. code:: python + + wall = ifcopenshell.api.root.create_entity(model, ifc_class="IfcWall") + member = ifcopenshell.api.root.create_entity( + model, ifc_class="IfcStructuralSurfaceMember") + ifcopenshell.api.structural.assign_product(model, + relating_product=member, related_object=wall) + """ + for rel in relating_product.ReferencedBy or []: + if not rel.is_a("IfcRelAssignsToProduct"): + continue + if related_object in rel.RelatedObjects: + return rel + related_objects = list(rel.RelatedObjects) + related_objects.append(related_object) + rel.RelatedObjects = related_objects + return rel + + rel = ifcopenshell.api.root.create_entity(file, ifc_class="IfcRelAssignsToProduct") + rel.RelatingProduct = relating_product + rel.RelatedObjects = [related_object] + return rel diff --git a/src/ifcopenshell-python/ifcopenshell/api/structural/assign_to_building.py b/src/ifcopenshell-python/ifcopenshell/api/structural/assign_to_building.py new file mode 100644 index 0000000000..7e70f20272 --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/structural/assign_to_building.py @@ -0,0 +1,64 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2026 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell 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. +# +# IfcOpenShell 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 IfcOpenShell. If not, see . +# This file was generated with the assistance of an AI coding tool. + +import ifcopenshell +import ifcopenshell.api.owner +import ifcopenshell.guid + + +def assign_to_building( + file: ifcopenshell.file, + structural_analysis_model: ifcopenshell.entity_instance, + building: ifcopenshell.entity_instance, +) -> ifcopenshell.entity_instance: + """Associates a structural analysis model with a building via IfcRelServicesBuildings + + The existing :func:`assign_structural_analysis_model` handles + IfcRelAssignsToGroup (linking structural members to the analysis model). + This function handles the separate model-to-building relationship, which + records which building the structural analysis model serves. + + :param structural_analysis_model: The IfcStructuralAnalysisModel to + associate with the building. + :param building: The IfcBuilding (or other IfcSpatialStructureElement) + that the structural analysis model serves. + :return: The IfcRelServicesBuildings relationship. + + Example: + + .. code:: python + + building = ifcopenshell.util.selector.filter_elements(model, "IfcBuilding")[0] + model_ = ifcopenshell.api.structural.add_structural_analysis_model(model) + ifcopenshell.api.structural.assign_to_building(model, + structural_analysis_model=model_, building=building) + """ + for rel in structural_analysis_model.ServicesBuildings or []: + if building in rel.RelatedBuildings: + return rel + rel.RelatedBuildings = list(rel.RelatedBuildings) + [building] + return rel + + return file.create_entity( + "IfcRelServicesBuildings", + ifcopenshell.guid.new(), + OwnerHistory=ifcopenshell.api.owner.create_owner_history(file), + RelatingSystem=structural_analysis_model, + RelatedBuildings=[building], + ) diff --git a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py index 774c0c898e..e53d069a76 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py +++ b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py @@ -517,11 +517,18 @@ class ShapeBuilder: trim_points_mask: Sequence[int], position_offset: Optional[VectorType] = None, ) -> np.ndarray: - """Handy way to get edge points of the ellipse like shape of a given radiuses. + """Get cardinal-point coordinates of an ellipse by index mask. - Mask points are numerated from 0 to 3 ccw starting from (x_axis_radius/2; 0). + The four cardinal points are numbered 0–3 counter-clockwise starting from the + positive X axis: 0 → ``(x, 0)``, 1 → ``(0, y)``, 2 → ``(-x, 0)``, 3 → ``(0, -y)``. - Example: mask (0, 1, 2, 3) will return points (x, 0), (0, y), (-x, 0), (0, -y) + Example: mask ``(0, 1, 2, 3)`` returns all four points in order. + + :param x_axis_radius: Radius (semi-axis length) along the X axis. + :param y_axis_radius: Radius (semi-axis length) along the Y axis. + :param trim_points_mask: Sequence of cardinal-point indices (0–3) to select. + :param position_offset: Optional 2D offset added to all returned points. + :return: Numpy array of the selected 2D points. """ points = np.array( ( @@ -546,15 +553,23 @@ class ShapeBuilder: ref_x_direction: VectorType = (1.0, 0.0), trim_points_mask: Sequence[int] = (), ) -> ifcopenshell.entity_instance: - """ - Ellipse trimming points should be specified in counter clockwise order. + """Create an IfcEllipse, optionally trimmed to an arc. - For example, if you need to get the part of the ellipse ABOVE y-axis, you need to use mask (0,2). Below y-axis - (2,0) + If neither ``trim_points`` nor ``trim_points_mask`` is provided, a full IfcEllipse is returned. + Trimming points must be given in counter-clockwise order. For example, to get the arc + above the Y-axis use mask ``(0, 2)``; below the Y-axis use ``(2, 0)``. - For more information about trim_points_mask check builder.get_trim_points_from_mask + A trimmed result (IfcTrimmedCurve) includes a closing segment between the trim points, + making it suitable for use as a profile in :meth:`extrude`. - Notion: trimmed ellipse also contains polyline between trim points, meaning IfcTrimmedCurve could be used - for further extrusion. + :param x_axis_radius: Semi-axis length along the local X axis. + :param y_axis_radius: Semi-axis length along the local Y axis. + :param position: 2D centre of the ellipse. + :param trim_points: Explicit pair of 2D trim points. Takes precedence over ``trim_points_mask``. + :param ref_x_direction: Direction of the local X axis. + :param trim_points_mask: Pair of cardinal-point indices (0–3) used when ``trim_points`` is empty. + See :meth:`get_trim_points_from_mask` for index definitions. + :return: IfcEllipse (untrimmed) or IfcTrimmedCurve (trimmed). """ ifc_position = self.create_axis2_placement_2d(position, ref_x_direction) ifc_ellipse = self.file.createIfcEllipse( @@ -685,6 +700,14 @@ class ShapeBuilder: pivot_point: VectorType = (0.0, 0.0), counter_clockwise: bool = False, ) -> np.ndarray: + """Rotate a single 2D point around a pivot. + + :param point_2d: The 2D point to rotate. + :param angle: Rotation angle, in degrees. Defaults to 90. + :param pivot_point: The point to rotate around. + :param counter_clockwise: If True, rotate counter-clockwise. Defaults to clockwise. + :return: Rotated 2D point as a numpy array. + """ angle_rad = radians(angle) * (1 if counter_clockwise else -1) relative_point = np.array(point_2d) - pivot_point relative_point = np_rotation_matrix(angle_rad, 2) @ relative_point @@ -752,7 +775,16 @@ class ShapeBuilder: mirror_axes: VectorType = (1.0, 1.0), mirror_point: VectorType = (0.0, 0.0), ) -> np.ndarray: - """mirror_axes - along which axes mirror will be applied""" + """Mirror a single 2D point across the specified axes. + + :param point_2d: The 2D point to mirror. + :param mirror_axes: Indicates which axes to mirror across. A positive value in a + component means that axis is mirrored (negated relative to ``mirror_point``). + Example: ``(1, 0)`` mirrors across the Y-axis (negates X only), + ``(1, 1)`` mirrors across both axes. + :param mirror_point: Origin of the mirror operation. + :return: Mirrored 2D point as a numpy array. + """ mirror_axes: np.ndarray = np.where(np.array(mirror_axes) > 0, -1, 1) mirror_point: np.ndarray = np.array(mirror_point) relative_point = point_2d - mirror_point @@ -798,7 +830,13 @@ class ShapeBuilder: def create_axis2_placement_2d( self, position: VectorType = (0.0, 0.0), x_direction: Optional[VectorType] = None ) -> ifcopenshell.entity_instance: - """Create IfcAxis2Placement2D.""" + """Create IfcAxis2Placement2D. + + :param position: 2D origin of the placement. + :param x_direction: Direction of the local X axis. If not provided, defaults to + the global X axis ``(1, 0)``. + :return: IfcAxis2Placement2D + """ ref_direction = ( self.file.create_entity("IfcDirection", ifc_safe_vector_type(x_direction)) if x_direction else None ) @@ -1000,7 +1038,7 @@ class ShapeBuilder: ) -> ifcopenshell.entity_instance: """ :param plane: The IfcPlane representing the half space. - :param agreement_flag: False if +Z represents the void + :param agreement_flag: If False (default), the plane normal points toward the **removed** material (the void). The kept region is on the opposite side from the normal. :return: IfcHalfSpaceSolid """ return self.file.createIfcHalfSpaceSolid(plane, AgreementFlag=agreement_flag) @@ -1053,7 +1091,14 @@ class ShapeBuilder: def create_swept_disk_solid( self, path_curve: ifcopenshell.entity_instance, radius: float ) -> ifcopenshell.entity_instance: - """Create IfcSweptDiskSolid from `path_curve` (must be 3D) and `radius`""" + """Create an IfcSweptDiskSolid — a circular cross-section swept along a 3D path. + + Useful for modelling round pipes, conduits, and cables. + + :param path_curve: A 3D curve entity defining the centreline path. Must have ``Dim == 3``. + :param radius: Radius of the circular disk cross-section. + :return: IfcSweptDiskSolid + """ if path_curve.Dim != 3: raise Exception( f"Path curve for IfcSweptDiskSolid should be 3D to be valid, currently it has {path_curve.Dim} dimensions.\n" @@ -1071,10 +1116,22 @@ class ShapeBuilder: ) -> ifcopenshell.entity_instance: """Create IFC representation for the specified context and items. + **All items must belong to the same geometry category.** IFC prohibits + mixing incompatible item types in one representation (e.g. + ``IfcExtrudedAreaSolid`` with ``IfcBlock``, or solids with curves). + When ``representation_type`` is omitted the type is inferred via + :func:`ifcopenshell.util.representation.guess_type`; if the items are + heterogeneous ``guess_type`` returns ``None`` and the representation is + written with no ``RepresentationType``, which fails IFC validation. + Avoid mixing swept-solid primitives (``IfcExtrudedAreaSolid``, + ``IfcRevolvedAreaSolid``) with CSG primitives (``IfcBlock``, + ``IfcSphere``, etc.) or any other category in a single call. + :param context: IfcGeometricRepresentationSubContext - :param items: could be a list or single curve/IfcExtrudedAreaSolid + :param items: A single item or list of items, all of the same geometry + category (e.g. all ``IfcExtrudedAreaSolid``, all ``IfcIndexedPolyCurve``) :param representation_type: Explicitly specified RepresentationType. - If not provided it will be guessed from the items types + If not provided it will be guessed from the items types. :return: IfcShapeRepresentation """ if not isinstance(items, collections.abc.Iterable): @@ -1096,18 +1153,26 @@ class ShapeBuilder: ) def deep_copy(self, element: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance: + """Create a deep copy of an IFC element and all its referenced entities. + + :param element: The IFC entity to copy. + :return: A new independent copy of the element. + """ return ifcopenshell.util.element.copy_deep(self.file, element) # UTILITIES def extrude_kwargs(self, axis: Literal["Y", "X", "Z"]) -> dict[str, tuple[float, float, float]]: - """Shortcut to get kwargs for `ShapeBuilder.extrude` to extrude by some axis. + """Shortcut to get kwargs for :meth:`extrude` to extrude along a principal axis. - It assumes you have 2D profile in: - XZ plane for Y axis extrusion, \n - YZ plane for X axis extrusion, \n - XY plane for Z axis extrusion, \n + Assumes the 2D profile lies in the plane perpendicular to the extrusion axis: + XZ plane for Y-axis extrusion, YZ plane for X-axis extrusion, XY plane for Z-axis extrusion. - Extruding by X/Y using other kwargs might break ValidExtrusionDirection.""" + Extruding along X or Y with other kwargs may violate the IFC ValidExtrusionDirection constraint. + + :param axis: The extrusion axis: ``'X'``, ``'Y'``, or ``'Z'``. + :return: A dict with keys ``position_x_axis``, ``position_z_axis``, and ``extrusion_vector`` + suitable for passing as ``**kwargs`` to :meth:`extrude`. + """ if axis == "Y": return { @@ -1131,13 +1196,16 @@ class ShapeBuilder: def rotate_extrusion_kwargs_by_z( self, kwargs: dict[str, Any], angle: float, counter_clockwise: bool = False ) -> dict[str, VectorType]: - """shortcut to rotate extrusion kwargs by z axis + """Rotate extrusion kwargs around the Z axis. - `kwargs` expected to have `position_x_axis` and `position_z_axis` keys + A shortcut to rotate the ``position_x_axis`` and ``position_z_axis`` values returned by + :meth:`extrude_kwargs` around the Z axis before passing them to :meth:`extrude`. - `angle` is a rotation value in radians - - by default rotation is clockwise, to make it counter clockwise use `counter_clockwise` flag + :param kwargs: A dict with ``position_x_axis`` and ``position_z_axis`` keys, + as returned by :meth:`extrude_kwargs`. The original dict is not mutated. + :param angle: Rotation angle, in radians. + :param counter_clockwise: If True, rotate counter-clockwise. Defaults to clockwise. + :return: A new dict with ``position_x_axis`` and ``position_z_axis`` rotated around Z. """ rot = np_rotation_matrix(-angle, 3, "Z") kwargs = kwargs.copy() # prevent mutation of original kwargs @@ -1146,7 +1214,11 @@ class ShapeBuilder: return kwargs def get_polyline_coords(self, polyline: ifcopenshell.entity_instance) -> np.ndarray: - """polyline should be either `IfcIndexedPolyCurve` or `IfcPolyline`""" + """Extract the coordinate array from a polyline entity. + + :param polyline: An ``IfcIndexedPolyCurve`` or ``IfcPolyline`` entity. + :return: Numpy array of the polyline's point coordinates. + """ coords = None if polyline.is_a("IfcIndexedPolyCurve"): coords = np.array(polyline.Points.CoordList) @@ -1157,7 +1229,12 @@ class ShapeBuilder: return coords def set_polyline_coords(self, polyline: ifcopenshell.entity_instance, coords: SequenceOfVectors) -> None: - """polyline should be either `IfcIndexedPolyCurve` or `IfcPolyline`""" + """Update the coordinates of a polyline entity in-place. + + :param polyline: An ``IfcIndexedPolyCurve`` or ``IfcPolyline`` entity. + :param coords: New sequence of point coordinates. Must contain the same number of + points as the original polyline. + """ if polyline.is_a("IfcIndexedPolyCurve"): polyline.Points.CoordList = ifc_safe_vector_type(coords) elif polyline.is_a("IfcPolyline"): @@ -1296,6 +1373,18 @@ class ShapeBuilder: WallThickness: float, FilletRadius: float, ) -> ifcopenshell.entity_instance: + """Create a Z-profile (cold-formed steel section) outline curve with lips and fillets. + + All dimensions are in the IFC project's length units. + + :param FirstFlangeWidth: Width of the first (top) flange, measured from the web centreline. + :param SecondFlangeWidth: Width of the second (bottom) flange, measured from the web centreline. + :param Depth: Total depth of the section (web height). + :param Girth: Length of the return lips on each flange. + :param WallThickness: Uniform material thickness. + :param FilletRadius: Inner bend radius at each corner. + :return: IfcIndexedPolyCurve representing the closed Z-profile outline. + """ x1 = FirstFlangeWidth x2 = SecondFlangeWidth y = Depth / 2 @@ -1337,10 +1426,17 @@ class ShapeBuilder: def create_transition_arc_ifc( self, width: float, height: float, create_ifc_curve: bool = False ) -> tuple[SequenceOfVectors, list[list[int]], Union[ifcopenshell.entity_instance, None]]: - """Create an arc in the rectangle with specified width and height. + """Create an arc fitting inside a rectangle of the given width and height. - If it's not possible to make a complete arc, create an arc with longest radius possible - and straight segment in the middle. + If a single arc cannot span the full width, the longest possible radius is used and + a straight segment is inserted in the middle. + + :param width: Width of the bounding rectangle. + :param height: Height of the bounding rectangle (also the maximum arc radius). + :param create_ifc_curve: If True, also create and return an ``IfcIndexedPolyCurve``. + If False, only return the raw point and segment data. + :return: A tuple ``(points, segments, ifc_curve)`` where ``ifc_curve`` is an + ``IfcIndexedPolyCurve`` when ``create_ifc_curve=True``, otherwise ``None``. """ fillet_size = (width / 2) / height if fillet_size <= 1: @@ -1370,6 +1466,14 @@ class ShapeBuilder: return points, segments, transition_arc def mesh(self, points: SequenceOfVectors, faces: Sequence[Sequence[int]]) -> ifcopenshell.entity_instance: + """Create a tessellated mesh from points and face indices. + + Delegates to :meth:`faceted_brep` for IFC2X3, or :meth:`polygonal_face_set` for IFC4 and later. + + :param points: List of 3D coordinates. + :param faces: List of faces, each face a sequence of zero-based point indices. + :return: IfcFacetedBrep (IFC2X3) or IfcPolygonalFaceSet (IFC4+). + """ if self.file.schema == "IFC2X3": return self.faceted_brep(points, faces) return self.polygonal_face_set(points, faces) @@ -1723,11 +1827,20 @@ class ShapeBuilder: angle: float, profile_offset: VectorType = (0.0, 0.0), verbose: bool = True, - ): - """get the final transition length for two profiles dimensions, angle and XY offset between them, + ) -> Optional[float]: + """Get the transition length for two profile half-dimensions, an angle, and an XY offset. - the difference from `calculate_transition` - `get_transition_length` is making sure - that length will fit both sides of the transition + Unlike :meth:`mep_transition_calculate`, this method checks that the resulting length + satisfies the angle constraint from both the start and end profile perspectives. + + :param start_half_dim: Half-dimensions of the start profile as a 3-element array + ``[half_x, half_y, depth]``. For circular profiles ``half_x == half_y == radius``. + :param end_half_dim: Half-dimensions of the end profile in the same format. + :param angle: Maximum allowed transition angle, in degrees. + :param profile_offset: 2D XY offset between the centrelines of the start and end profiles. + :param verbose: If True, print diagnostic values during calculation. + :return: Transition length in project length units, or ``None`` if no valid length exists + for the given angle and offset. """ print = lambda *args, **kwargs: __builtins__["print"](*args, **kwargs) if verbose else None np_X, np_Y = 0, 1 @@ -1788,9 +1901,23 @@ class ShapeBuilder: angle: Optional[float] = None, verbose: bool = True, ) -> Union[float, None]: - """will return transition length based on the profile dimension differences and offset. + """Calculate MEP transition length from angle, or transition angle from length. - If `length` is provided will return transition angle""" + Low-level calculation kernel used by :meth:`mep_transition_length`. Provide either + ``angle`` or ``length`` (not both); the other value is computed and returned. + + :param start_half_dim: Half-dimensions of the start profile ``[half_x, half_y, depth]``. + :param end_half_dim: Half-dimensions of the end profile ``[half_x, half_y, depth]``. + :param offset: 2D XY offset between profile centrelines. + :param diff: Pre-computed absolute difference of start and end half-dimensions (XY only). + Computed from ``start_half_dim`` and ``end_half_dim`` if not provided. + :param end_profile: If True, swap X and Y axes to compute from the end-profile perspective. + :param length: Known transition length. If provided, the corresponding angle is returned. + :param angle: Known transition angle, in degrees. If provided, the corresponding length is returned. + :param verbose: If True, print diagnostic values during calculation. + :return: Transition length (if ``angle`` was given) or transition angle in degrees + (if ``length`` was given), or ``None`` if the geometry is not feasible. + """ print = lambda *args, **kwargs: __builtins__["print"](*args, **kwargs) if verbose else None diff --git a/src/ifcopenshell-python/test/api/boundary/test_edit_attributes.py b/src/ifcopenshell-python/test/api/boundary/test_edit_attributes.py new file mode 100644 index 0000000000..ed79d7e8bf --- /dev/null +++ b/src/ifcopenshell-python/test/api/boundary/test_edit_attributes.py @@ -0,0 +1,115 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2026 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell 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. +# +# IfcOpenShell 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 IfcOpenShell. If not, see . +# This file was generated with the assistance of an AI coding tool. + +import ifcopenshell.api.boundary +import ifcopenshell.api.root +import test.bootstrap + + +class TestEditAttributes(test.bootstrap.IFC4): + def setup_boundary(self): + space = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcSpace") + wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + boundary = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcRelSpaceBoundary") + return boundary, space, wall + + def test_sets_relating_space_and_building_element(self): + boundary, space, wall = self.setup_boundary() + ifcopenshell.api.boundary.edit_attributes( + self.file, entity=boundary, relating_space=space, related_building_element=wall + ) + assert boundary.RelatingSpace == space + assert boundary.RelatedBuildingElement == wall + + def test_defaults_enums_to_notdefined(self): + boundary, space, wall = self.setup_boundary() + ifcopenshell.api.boundary.edit_attributes( + self.file, entity=boundary, relating_space=space, related_building_element=wall + ) + assert boundary.PhysicalOrVirtualBoundary == "NOTDEFINED" + assert boundary.InternalOrExternalBoundary == "NOTDEFINED" + + def test_sets_physical_or_virtual(self): + boundary, space, wall = self.setup_boundary() + ifcopenshell.api.boundary.edit_attributes( + self.file, + entity=boundary, + relating_space=space, + related_building_element=wall, + physical_or_virtual="PHYSICAL", + ) + assert boundary.PhysicalOrVirtualBoundary == "PHYSICAL" + + def test_sets_internal_or_external(self): + boundary, space, wall = self.setup_boundary() + ifcopenshell.api.boundary.edit_attributes( + self.file, + entity=boundary, + relating_space=space, + related_building_element=wall, + internal_or_external="EXTERNAL", + ) + assert boundary.InternalOrExternalBoundary == "EXTERNAL" + + def test_sets_all_enum_variants(self): + boundary, space, wall = self.setup_boundary() + for value in ("PHYSICAL", "VIRTUAL", "NOTDEFINED"): + ifcopenshell.api.boundary.edit_attributes( + self.file, + entity=boundary, + relating_space=space, + related_building_element=wall, + physical_or_virtual=value, + ) + assert boundary.PhysicalOrVirtualBoundary == value + + for value in ("INTERNAL", "EXTERNAL", "EXTERNAL_EARTH", "EXTERNAL_WATER", "EXTERNAL_FIRE", "NOTDEFINED"): + ifcopenshell.api.boundary.edit_attributes( + self.file, + entity=boundary, + relating_space=space, + related_building_element=wall, + internal_or_external=value, + ) + assert boundary.InternalOrExternalBoundary == value + + +class TestEditAttributesIFC2X3(test.bootstrap.IFC2X3, TestEditAttributes): + def test_sets_all_enum_variants(self): + boundary, space, wall = self.setup_boundary() + for value in ("PHYSICAL", "VIRTUAL", "NOTDEFINED"): + ifcopenshell.api.boundary.edit_attributes( + self.file, + entity=boundary, + relating_space=space, + related_building_element=wall, + physical_or_virtual=value, + ) + assert boundary.PhysicalOrVirtualBoundary == value + + # IFC2X3 only has INTERNAL, EXTERNAL, NOTDEFINED + for value in ("INTERNAL", "EXTERNAL", "NOTDEFINED"): + ifcopenshell.api.boundary.edit_attributes( + self.file, + entity=boundary, + relating_space=space, + related_building_element=wall, + internal_or_external=value, + ) + assert boundary.InternalOrExternalBoundary == value diff --git a/src/ifcopenshell-python/test/api/geometry/test_add_topology_representation.py b/src/ifcopenshell-python/test/api/geometry/test_add_topology_representation.py new file mode 100644 index 0000000000..5b89f50338 --- /dev/null +++ b/src/ifcopenshell-python/test/api/geometry/test_add_topology_representation.py @@ -0,0 +1,82 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2026 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell 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. +# +# IfcOpenShell 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 IfcOpenShell. If not, see . +# This file was generated with the assistance of an AI coding tool. + +import ifcopenshell.api.context +import ifcopenshell.api.geometry +import ifcopenshell.api.root +import test.bootstrap + + +class TestAddTopologyRepresentation(test.bootstrap.IFC4): + def setup_context(self): + ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") + model = ifcopenshell.api.context.add_context(self.file, context_type="Model") + return ifcopenshell.api.context.add_context( + self.file, + context_type="Model", + context_identifier="Reference", + target_view="GRAPH_VIEW", + parent=model, + ) + + def test_creates_topology_representation(self): + context = self.setup_context() + face = self.file.create_entity("IfcFaceSurface") + rep = ifcopenshell.api.geometry.add_topology_representation(self.file, context=context, item=face) + assert rep.is_a("IfcTopologyRepresentation") + assert rep.ContextOfItems == context + assert face in rep.Items + + def test_infers_face_representation_type(self): + context = self.setup_context() + face = self.file.create_entity("IfcFaceSurface") + rep = ifcopenshell.api.geometry.add_topology_representation(self.file, context=context, item=face) + assert rep.RepresentationType == "Face" + + def test_infers_edge_representation_type(self): + context = self.setup_context() + edge = self.file.create_entity("IfcEdge") + rep = ifcopenshell.api.geometry.add_topology_representation(self.file, context=context, item=edge) + assert rep.RepresentationType == "Edge" + + def test_defaults_representation_identifier_to_context_identifier(self): + context = self.setup_context() + face = self.file.create_entity("IfcFaceSurface") + rep = ifcopenshell.api.geometry.add_topology_representation(self.file, context=context, item=face) + assert rep.RepresentationIdentifier == context.ContextIdentifier + + def test_custom_representation_identifier(self): + context = self.setup_context() + face = self.file.create_entity("IfcFaceSurface") + rep = ifcopenshell.api.geometry.add_topology_representation( + self.file, context=context, item=face, representation_identifier="Body" + ) + assert rep.RepresentationIdentifier == "Body" + + def test_custom_representation_type_overrides_inferred(self): + context = self.setup_context() + face = self.file.create_entity("IfcFaceSurface") + rep = ifcopenshell.api.geometry.add_topology_representation( + self.file, context=context, item=face, representation_type="Undefined" + ) + assert rep.RepresentationType == "Undefined" + + +class TestAddTopologyRepresentationIFC2X3(test.bootstrap.IFC2X3, TestAddTopologyRepresentation): + pass diff --git a/src/ifcopenshell-python/test/api/geometry/test_clip_solid.py b/src/ifcopenshell-python/test/api/geometry/test_clip_solid.py new file mode 100644 index 0000000000..512bfcc92e --- /dev/null +++ b/src/ifcopenshell-python/test/api/geometry/test_clip_solid.py @@ -0,0 +1,154 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2026 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell 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. +# +# IfcOpenShell 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 IfcOpenShell. If not, see . + +import json + +import ifcopenshell.api.geometry +import ifcopenshell.util.element +import ifcopenshell.util.shape_builder +import test.bootstrap + + +class TestClipSolid(test.bootstrap.IFC4): + def make_extrusion(self): + builder = ifcopenshell.util.shape_builder.ShapeBuilder(self.file) + rect = builder.rectangle(size=(1.0, 1.0)) + return builder.extrude(rect, magnitude=4.0) + + def test_returns_boolean_clipping_result(self): + extrusion = self.make_extrusion() + result = ifcopenshell.api.geometry.clip_solid( + self.file, + item=extrusion, + location=[0.0, 0.0, 3.0], + normal=[0.0, 0.0, 1.0], + ) + assert result.is_a("IfcBooleanClippingResult") + assert result.Operator == "DIFFERENCE" + + def test_first_operand_is_the_item(self): + extrusion = self.make_extrusion() + result = ifcopenshell.api.geometry.clip_solid( + self.file, + item=extrusion, + location=[0.0, 0.0, 3.0], + normal=[0.0, 0.0, 1.0], + ) + assert result.FirstOperand == extrusion + + def test_second_operand_is_half_space_solid(self): + extrusion = self.make_extrusion() + result = ifcopenshell.api.geometry.clip_solid( + self.file, + item=extrusion, + location=[0.0, 0.0, 3.0], + normal=[0.0, 0.0, 1.0], + ) + assert result.SecondOperand.is_a("IfcHalfSpaceSolid") + + def test_clip_plane_location_matches(self): + extrusion = self.make_extrusion() + result = ifcopenshell.api.geometry.clip_solid( + self.file, + item=extrusion, + location=[0.0, 0.0, 3.0], + normal=[0.0, 0.0, 1.0], + ) + plane = result.SecondOperand.BaseSurface + coords = plane.Position.Location.Coordinates + assert list(coords) == [0.0, 0.0, 3.0] + + def test_chaining_two_clips(self): + extrusion = self.make_extrusion() + first_clip = ifcopenshell.api.geometry.clip_solid( + self.file, + item=extrusion, + location=[0.0, 0.0, 3.0], + normal=[0.0, 0.0, 1.0], + ) + second_clip = ifcopenshell.api.geometry.clip_solid( + self.file, + item=first_clip, + location=[0.0, 0.0, 1.0], + normal=[0.0, 0.0, -1.0], + ) + assert second_clip.is_a("IfcBooleanClippingResult") + assert second_clip.FirstOperand == first_clip + assert first_clip.FirstOperand == extrusion + + def test_angled_clip_plane(self): + extrusion = self.make_extrusion() + result = ifcopenshell.api.geometry.clip_solid( + self.file, + item=extrusion, + location=[0.0, 0.0, 3.26], + normal=[0.419, 0.0, 0.908], + ) + assert result.is_a("IfcBooleanClippingResult") + assert result.SecondOperand.is_a("IfcHalfSpaceSolid") + + def test_element_registers_result_in_bbim_boolean(self): + extrusion = self.make_extrusion() + wall = self.file.createIfcWall() + result = ifcopenshell.api.geometry.clip_solid( + self.file, + item=extrusion, + location=[0.0, 0.0, 3.0], + normal=[0.0, 0.0, 1.0], + element=wall, + ) + pset = ifcopenshell.util.element.get_pset(wall, "BBIM_Boolean") + assert pset is not None + assert result.id() in json.loads(pset["Data"]) + + def test_element_appends_to_existing_bbim_boolean(self): + extrusion = self.make_extrusion() + wall = self.file.createIfcWall() + first = ifcopenshell.api.geometry.clip_solid( + self.file, + item=extrusion, + location=[0.0, 0.0, 3.0], + normal=[0.0, 0.0, 1.0], + element=wall, + ) + second = ifcopenshell.api.geometry.clip_solid( + self.file, + item=first, + location=[0.0, 0.0, 1.0], + normal=[0.0, 0.0, -1.0], + element=wall, + ) + pset = ifcopenshell.util.element.get_pset(wall, "BBIM_Boolean") + ids = json.loads(pset["Data"]) + assert first.id() in ids + assert second.id() in ids + + def test_no_element_does_not_create_pset(self): + extrusion = self.make_extrusion() + wall = self.file.createIfcWall() + ifcopenshell.api.geometry.clip_solid( + self.file, + item=extrusion, + location=[0.0, 0.0, 3.0], + normal=[0.0, 0.0, 1.0], + ) + assert ifcopenshell.util.element.get_pset(wall, "BBIM_Boolean") is None + + +class TestClipSolidIFC2X3(test.bootstrap.IFC2X3, TestClipSolid): + pass diff --git a/src/ifcopenshell-python/test/api/geometry/test_clip_solid_bounded.py b/src/ifcopenshell-python/test/api/geometry/test_clip_solid_bounded.py new file mode 100644 index 0000000000..3dae335cad --- /dev/null +++ b/src/ifcopenshell-python/test/api/geometry/test_clip_solid_bounded.py @@ -0,0 +1,179 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2026 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell 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. +# +# IfcOpenShell 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 IfcOpenShell. If not, see . + +import json + +import ifcopenshell.api.geometry +import ifcopenshell.util.element +import ifcopenshell.util.shape_builder +import test.bootstrap + + +class TestClipSolidBounded(test.bootstrap.IFC4): + def make_extrusion(self): + builder = ifcopenshell.util.shape_builder.ShapeBuilder(self.file) + rect = builder.rectangle(size=(4.0, 1.0)) + return builder.extrude(rect, magnitude=3.0) + + def test_returns_boolean_clipping_result(self): + extrusion = self.make_extrusion() + result = ifcopenshell.api.geometry.clip_solid_bounded( + self.file, + item=extrusion, + location=[2.5, 0.0, 2.0], + normal=[0.6, 0.0, 0.8], + boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]], + ) + assert result.is_a("IfcBooleanClippingResult") + assert result.Operator == "DIFFERENCE" + + def test_first_operand_is_the_item(self): + extrusion = self.make_extrusion() + result = ifcopenshell.api.geometry.clip_solid_bounded( + self.file, + item=extrusion, + location=[2.5, 0.0, 2.0], + normal=[0.6, 0.0, 0.8], + boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]], + ) + assert result.FirstOperand == extrusion + + def test_second_operand_is_polygonal_bounded_half_space(self): + extrusion = self.make_extrusion() + result = ifcopenshell.api.geometry.clip_solid_bounded( + self.file, + item=extrusion, + location=[2.5, 0.0, 2.0], + normal=[0.6, 0.0, 0.8], + boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]], + ) + assert result.SecondOperand.is_a("IfcPolygonalBoundedHalfSpace") + + def test_agreement_flag_is_false(self): + extrusion = self.make_extrusion() + result = ifcopenshell.api.geometry.clip_solid_bounded( + self.file, + item=extrusion, + location=[2.5, 0.0, 2.0], + normal=[0.6, 0.0, 0.8], + boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]], + ) + assert result.SecondOperand.AgreementFlag is False + + def test_clip_plane_location_matches(self): + extrusion = self.make_extrusion() + result = ifcopenshell.api.geometry.clip_solid_bounded( + self.file, + item=extrusion, + location=[2.5, 0.0, 2.0], + normal=[0.6, 0.0, 0.8], + boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]], + ) + plane = result.SecondOperand.BaseSurface + coords = plane.Position.Location.Coordinates + assert list(coords) == [2.5, 0.0, 2.0] + + def test_boundary_is_closed_polyline(self): + extrusion = self.make_extrusion() + result = ifcopenshell.api.geometry.clip_solid_bounded( + self.file, + item=extrusion, + location=[2.5, 0.0, 2.0], + normal=[0.6, 0.0, 0.8], + boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]], + ) + boundary = result.SecondOperand.PolygonalBoundary + assert boundary.is_a("IfcPolyline") + pts = [list(p.Coordinates) for p in boundary.Points] + assert pts[0] == pts[-1], "polygon should be closed" + assert len(pts) == 5 # 4 unique + closing repeat + + def test_boundary_position_defaults_to_origin(self): + extrusion = self.make_extrusion() + result = ifcopenshell.api.geometry.clip_solid_bounded( + self.file, + item=extrusion, + location=[2.5, 0.0, 2.0], + normal=[0.6, 0.0, 0.8], + boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]], + ) + pos = result.SecondOperand.Position + assert list(pos.Location.Coordinates) == [0.0, 0.0, 0.0] + + def test_custom_boundary_position(self): + extrusion = self.make_extrusion() + result = ifcopenshell.api.geometry.clip_solid_bounded( + self.file, + item=extrusion, + location=[2.5, 0.0, 2.0], + normal=[0.6, 0.0, 0.8], + boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]], + boundary_position=[1.0, 2.0, 3.0], + ) + pos = result.SecondOperand.Position + assert list(pos.Location.Coordinates) == [1.0, 2.0, 3.0] + + def test_chaining_with_clip_solid(self): + extrusion = self.make_extrusion() + first_clip = ifcopenshell.api.geometry.clip_solid( + self.file, + item=extrusion, + location=[0.0, 0.0, 3.0], + normal=[0.0, 0.0, 1.0], + ) + result = ifcopenshell.api.geometry.clip_solid_bounded( + self.file, + item=first_clip, + location=[2.5, 0.0, 2.0], + normal=[0.6, 0.0, 0.8], + boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]], + ) + assert result.is_a("IfcBooleanClippingResult") + assert result.FirstOperand == first_clip + assert first_clip.FirstOperand == extrusion + + def test_element_registers_result_in_bbim_boolean(self): + extrusion = self.make_extrusion() + wall = self.file.createIfcWall() + result = ifcopenshell.api.geometry.clip_solid_bounded( + self.file, + item=extrusion, + location=[2.5, 0.0, 2.0], + normal=[0.6, 0.0, 0.8], + boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]], + element=wall, + ) + pset = ifcopenshell.util.element.get_pset(wall, "BBIM_Boolean") + assert pset is not None + assert result.id() in json.loads(pset["Data"]) + + def test_no_element_does_not_create_pset(self): + extrusion = self.make_extrusion() + wall = self.file.createIfcWall() + ifcopenshell.api.geometry.clip_solid_bounded( + self.file, + item=extrusion, + location=[2.5, 0.0, 2.0], + normal=[0.6, 0.0, 0.8], + boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]], + ) + assert ifcopenshell.util.element.get_pset(wall, "BBIM_Boolean") is None + + +class TestClipSolidBoundedIFC2X3(test.bootstrap.IFC2X3, TestClipSolidBounded): + pass diff --git a/src/ifcopenshell-python/test/api/geometry/test_connect_path.py b/src/ifcopenshell-python/test/api/geometry/test_connect_path.py index bda341d2c6..0927e474ae 100644 --- a/src/ifcopenshell-python/test/api/geometry/test_connect_path.py +++ b/src/ifcopenshell-python/test/api/geometry/test_connect_path.py @@ -33,6 +33,18 @@ class TestConnectPath(test.bootstrap.IFC4): assert rel.RelatedConnectionType == "ATEND" assert rel.Description == "MITRE" + def test_storing_connection_geometry(self): + wall1 = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + wall2 = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + geometry = self.file.create_entity("IfcConnectionPointGeometry") + rel = ifcopenshell.api.geometry.connect_path( + self.file, + relating_element=wall1, + related_element=wall2, + connection_geometry=geometry, + ) + assert rel.ConnectionGeometry == geometry + def test_doing_nothing_if_the_element_is_already_connected(self): wall1 = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") wall2 = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") diff --git a/src/ifcopenshell-python/test/api/geometry/test_copy_representation.py b/src/ifcopenshell-python/test/api/geometry/test_copy_representation.py new file mode 100644 index 0000000000..8faf07baa9 --- /dev/null +++ b/src/ifcopenshell-python/test/api/geometry/test_copy_representation.py @@ -0,0 +1,134 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2026 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell 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. +# +# IfcOpenShell 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 IfcOpenShell. If not, see . + +import ifcopenshell.api.geometry +import ifcopenshell.api.root +import ifcopenshell.util.representation +import ifcopenshell.util.shape_builder +import test.bootstrap + + +class TestCopyRepresentation(test.bootstrap.IFC4): + def _body_context(self): + body = ifcopenshell.util.representation.get_context(self.file, "Model", "Body", "MODEL_VIEW") + if body is None: + model = self.file.createIfcGeometricRepresentationContext( + ContextType="Model", + CoordinateSpaceDimension=3, + Precision=1e-5, + WorldCoordinateSystem=self.file.createIfcAxis2Placement3D( + self.file.createIfcCartesianPoint((0.0, 0.0, 0.0)) + ), + ) + body = self.file.createIfcGeometricRepresentationSubContext( + ContextIdentifier="Body", + ContextType="Model", + TargetView="MODEL_VIEW", + ParentContext=model, + ) + return body + + def _add_body_rep(self, element): + body = self._body_context() + rep = ifcopenshell.api.geometry.add_wall_representation( + self.file, context=body, length=5.0, height=3.0, thickness=0.2 + ) + ifcopenshell.api.geometry.assign_representation(self.file, product=element, representation=rep) + return rep + + def test_copy_to_empty_target(self): + wall_a = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + wall_b = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + self._add_body_rep(wall_a) + + result = ifcopenshell.api.geometry.copy_representation(self.file, source=wall_a, target=wall_b) + + assert result is not None + assert result.is_a("IfcShapeRepresentation") + target_rep = ifcopenshell.util.representation.get_representation(wall_b, "Model", "Body") + assert target_rep is not None + assert target_rep == result + + def test_source_rep_entities_are_distinct(self): + wall_a = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + wall_b = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + source_rep = self._add_body_rep(wall_a) + + new_rep = ifcopenshell.api.geometry.copy_representation(self.file, source=wall_a, target=wall_b) + + assert new_rep.id() != source_rep.id() + assert new_rep.Items[0].id() != source_rep.Items[0].id() + + def test_context_is_shared_not_copied(self): + wall_a = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + wall_b = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + source_rep = self._add_body_rep(wall_a) + + new_rep = ifcopenshell.api.geometry.copy_representation(self.file, source=wall_a, target=wall_b) + + assert new_rep.ContextOfItems.id() == source_rep.ContextOfItems.id() + + def test_replaces_existing_target_rep(self): + wall_a = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + wall_b = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + self._add_body_rep(wall_a) + old_rep = self._add_body_rep(wall_b) + old_rep_id = old_rep.id() + + ifcopenshell.api.geometry.copy_representation(self.file, source=wall_a, target=wall_b) + + try: + self.file.by_id(old_rep_id) + assert False, "old representation still exists" + except RuntimeError: + pass # entity was removed, as expected + + def test_source_unchanged_after_copy(self): + wall_a = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + wall_b = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + source_rep = self._add_body_rep(wall_a) + source_rep_id = source_rep.id() + + ifcopenshell.api.geometry.copy_representation(self.file, source=wall_a, target=wall_b) + + assert self.file.by_id(source_rep_id) is not None # source must still exist + assert ifcopenshell.util.representation.get_representation(wall_a, "Model", "Body") is not None + + def test_returns_none_when_no_matching_rep(self): + wall_a = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + wall_b = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + + result = ifcopenshell.api.geometry.copy_representation(self.file, source=wall_a, target=wall_b) + + assert result is None + + def test_custom_context_identifier(self): + wall_a = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + wall_b = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + self._add_body_rep(wall_a) + + # "Axis" doesn't exist on wall_a, so should return None + result = ifcopenshell.api.geometry.copy_representation( + self.file, source=wall_a, target=wall_b, context_identifier="Axis" + ) + + assert result is None + + +class TestCopyRepresentationIFC2X3(test.bootstrap.IFC2X3, TestCopyRepresentation): + pass diff --git a/src/ifcopenshell-python/test/api/georeference/test_add_georeferencing.py b/src/ifcopenshell-python/test/api/georeference/test_add_georeferencing.py index 68f1ac2597..44e45dec28 100644 --- a/src/ifcopenshell-python/test/api/georeference/test_add_georeferencing.py +++ b/src/ifcopenshell-python/test/api/georeference/test_add_georeferencing.py @@ -51,6 +51,30 @@ class TestAddGeoreferencing(test.bootstrap.IFC4): assert len(self.file.by_type("IfcMapConversion")) == 1 assert len(self.file.by_type("IfcProjectedCRS")) == 1 + def test_recovering_from_orphan_projected_crs(self): + ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") + ifcopenshell.api.context.add_context(self.file, "Model") + self.file.create_entity("IfcProjectedCRS", Name="EPSG:1234") + assert len(self.file.by_type("IfcProjectedCRS")) == 1 + assert len(self.file.by_type("IfcCoordinateOperation")) == 0 + ifcopenshell.api.georeference.add_georeferencing(self.file) + assert len(self.file.by_type("IfcMapConversion")) == 1 + assert len(self.file.by_type("IfcProjectedCRS")) == 1 + + def test_recovering_from_orphan_coordinate_operation(self): + ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") + context = ifcopenshell.api.context.add_context(self.file, "Model") + self.file.create_entity("IfcMapConversion", SourceCRS=context, TargetCRS=self.file.create_entity("IfcProjectedCRS", Name="EPSG:1234")) + ifcopenshell.api.georeference.remove_georeferencing(self.file) + # Simulate orphan by re-adding just a conversion without CRS + self.file.create_entity("IfcMapConversion", SourceCRS=context, TargetCRS=self.file.create_entity("IfcProjectedCRS", Name="EPSG:1234")) + self.file.remove(self.file.by_type("IfcProjectedCRS")[0]) + assert len(self.file.by_type("IfcProjectedCRS")) == 0 + assert len(self.file.by_type("IfcCoordinateOperation")) == 1 + ifcopenshell.api.georeference.add_georeferencing(self.file) + assert len(self.file.by_type("IfcMapConversion")) == 1 + assert len(self.file.by_type("IfcProjectedCRS")) == 1 + class TestAddGeoreferencingIFC2X3(test.bootstrap.IFC2X3): def test_adding_georeferencing(self): diff --git a/src/ifcopenshell-python/test/api/structural/test_assign_product.py b/src/ifcopenshell-python/test/api/structural/test_assign_product.py new file mode 100644 index 0000000000..3e2f1c2b4a --- /dev/null +++ b/src/ifcopenshell-python/test/api/structural/test_assign_product.py @@ -0,0 +1,56 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2026 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell 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. +# +# IfcOpenShell 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 IfcOpenShell. If not, see . +# This file was generated with the assistance of an AI coding tool. + +import ifcopenshell.api.root +import ifcopenshell.api.structural +import test.bootstrap + + +class TestAssignProduct(test.bootstrap.IFC4): + def test_creating_a_new_relationship(self): + member = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcStructuralSurfaceMember") + wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + rel = ifcopenshell.api.structural.assign_product(self.file, relating_product=member, related_object=wall) + assert rel.is_a("IfcRelAssignsToProduct") + assert rel.RelatingProduct == member + assert wall in rel.RelatedObjects + + def test_adding_a_second_object_to_an_existing_relationship(self): + member = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcStructuralSurfaceMember") + wall1 = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + wall2 = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + rel1 = ifcopenshell.api.structural.assign_product(self.file, relating_product=member, related_object=wall1) + rel2 = ifcopenshell.api.structural.assign_product(self.file, relating_product=member, related_object=wall2) + assert rel1 == rel2 + assert len(self.file.by_type("IfcRelAssignsToProduct")) == 1 + assert wall1 in rel1.RelatedObjects + assert wall2 in rel1.RelatedObjects + + def test_does_not_duplicate_an_existing_assignment(self): + member = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcStructuralSurfaceMember") + wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + ifcopenshell.api.structural.assign_product(self.file, relating_product=member, related_object=wall) + ifcopenshell.api.structural.assign_product(self.file, relating_product=member, related_object=wall) + assert len(self.file.by_type("IfcRelAssignsToProduct")) == 1 + rels = self.file.by_type("IfcRelAssignsToProduct") + assert len(rels[0].RelatedObjects) == 1 + + +class TestAssignProductIFC2X3(test.bootstrap.IFC2X3, TestAssignProduct): + pass diff --git a/src/ifcopenshell-python/test/api/structural/test_assign_to_building.py b/src/ifcopenshell-python/test/api/structural/test_assign_to_building.py new file mode 100644 index 0000000000..671ca1b476 --- /dev/null +++ b/src/ifcopenshell-python/test/api/structural/test_assign_to_building.py @@ -0,0 +1,62 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2026 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell 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. +# +# IfcOpenShell 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 IfcOpenShell. If not, see . +# This file was generated with the assistance of an AI coding tool. + +import ifcopenshell.api.root +import ifcopenshell.api.structural +import test.bootstrap + + +class TestAssignToBuilding(test.bootstrap.IFC4): + def test_creating_a_new_relationship(self): + model = ifcopenshell.api.structural.add_structural_analysis_model(self.file) + building = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcBuilding") + rel = ifcopenshell.api.structural.assign_to_building( + self.file, structural_analysis_model=model, building=building + ) + assert rel.is_a("IfcRelServicesBuildings") + assert rel.RelatingSystem == model + assert building in rel.RelatedBuildings + + def test_adding_a_second_building_to_an_existing_relationship(self): + model = ifcopenshell.api.structural.add_structural_analysis_model(self.file) + building1 = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcBuilding") + building2 = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcBuilding") + rel1 = ifcopenshell.api.structural.assign_to_building( + self.file, structural_analysis_model=model, building=building1 + ) + rel2 = ifcopenshell.api.structural.assign_to_building( + self.file, structural_analysis_model=model, building=building2 + ) + assert rel1 == rel2 + assert len(self.file.by_type("IfcRelServicesBuildings")) == 1 + assert building1 in rel1.RelatedBuildings + assert building2 in rel1.RelatedBuildings + + def test_does_not_duplicate_an_existing_assignment(self): + model = ifcopenshell.api.structural.add_structural_analysis_model(self.file) + building = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcBuilding") + ifcopenshell.api.structural.assign_to_building(self.file, structural_analysis_model=model, building=building) + ifcopenshell.api.structural.assign_to_building(self.file, structural_analysis_model=model, building=building) + assert len(self.file.by_type("IfcRelServicesBuildings")) == 1 + rels = self.file.by_type("IfcRelServicesBuildings") + assert len(rels[0].RelatedBuildings) == 1 + + +class TestAssignToBuildingIFC2X3(test.bootstrap.IFC2X3, TestAssignToBuilding): + pass diff --git a/src/ifcquery/README.md b/src/ifcquery/README.md new file mode 100644 index 0000000000..0c90ecf596 --- /dev/null +++ b/src/ifcquery/README.md @@ -0,0 +1,499 @@ + +# ifcquery + +A CLI tool for querying and inspecting IFC building models. All output is +structured JSON (or human-readable text), making it easy to pipe into other +tools or scripts. + +## Installation + +```bash +pip install ifcquery +``` + +Requires `ifcopenshell`. The `clash` subcommand additionally requires the +IfcOpenShell C++ geometry bindings (`ifcopenshell.geom`). + +## Usage + +``` +ifcquery [options] [--format json|text|ids] +``` + +The `--format` flag controls output: + +- `json` (default) -- structured JSON, suitable for piping to `jq` or `ifcedit foreach` +- `text` -- indented human-readable output +- `ids` -- comma-separated step IDs extracted from list results, suitable for piping directly into `ifcedit run` parameters + +## Subcommands + +### summary + +Get a model overview: schema version, entity counts, and project info. + +```bash +ifcquery model.ifc summary +``` + +```json +{ + "schema": "IFC4", + "total_entities": 1847, + "project": { + "id": 1, + "name": "Office Building", + "description": null + }, + "types": { + "IfcWall": 42, + "IfcSlab": 12, + "IfcWindow": 36 + } +} +``` + +### tree + +Display the spatial hierarchy from IfcProject down through sites, buildings, +storeys, and their contained elements. + +```bash +ifcquery model.ifc tree +``` + +```json +{ + "id": 1, + "type": "IfcProject", + "name": "Office Building", + "children": [ + { + "id": 2, + "type": "IfcSite", + "name": "Default Site", + "children": [ + { + "id": 3, + "type": "IfcBuilding", + "name": "Main Building", + "children": [ + { + "id": 4, + "type": "IfcBuildingStorey", + "name": "Ground Floor", + "elements": [ + {"id": 10, "type": "IfcWall", "name": "Wall001"}, + {"id": 11, "type": "IfcSlab", "name": "Floor001"} + ] + } + ] + } + ] + } + ] +} +``` + +### info + +Get detailed information about a specific element by step ID. + +```bash +ifcquery model.ifc info 10 +ifcquery model.ifc info '#10' +``` + +Returns attributes, property sets, type relationship, material assignment, +spatial container, and placement matrix. + +```json +{ + "id": 10, + "type": "IfcWall", + "attributes": { + "Name": "Wall001", + "Description": null, + "ObjectType": "LOADBEARING" + }, + "property_sets": { + "Pset_WallCommon": { + "IsExternal": true, + "FireRating": "2HR" + } + }, + "element_type": {"id": 50, "type": "IfcWallType", "name": "Standard"}, + "material": {"id": 60, "type": "IfcMaterial", "name": "Concrete"}, + "container": {"id": 4, "type": "IfcBuildingStorey", "name": "Ground Floor"}, + "placement": [ + [1.0, 0.0, 0.0, 5.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0] + ] +} +``` + +### select + +Filter elements using the ifcopenshell selector syntax. + +```bash +ifcquery model.ifc select 'IfcWall' +ifcquery model.ifc select 'IfcWall, IfcSlab' +``` + +```json +[ + {"id": 10, "type": "IfcWall", "name": "Wall001"}, + {"id": 11, "type": "IfcWall", "name": "Wall002"}, + {"id": 20, "type": "IfcSlab", "name": "Floor001"} +] +``` + +Results are sorted by ID. + +Use `--format ids` to get a comma-separated list of step IDs for direct use +in `ifcedit run` parameters: + +```bash +ifcedit run model.ifc type.assign_type \ + --related_objects "$(ifcquery model.ifc --format ids select 'IfcWall')" \ + --relating_type 456 +``` + +### relations + +Show all relationships for an element, organized by category: hierarchy, +children, type relationships, groups, systems, material, and connections. + +```bash +ifcquery model.ifc relations 10 +``` + +```json +{ + "id": 10, + "type": "IfcWall", + "name": "Wall001", + "hierarchy": { + "parent": {"id": 4, "type": "IfcBuildingStorey", "name": "Ground Floor"}, + "container": {"id": 4, "type": "IfcBuildingStorey", "name": "Ground Floor"} + }, + "children": { + "openings": [{"id": 30, "type": "IfcOpeningElement", "name": "Opening01"}] + }, + "type_relationship": { + "type_of": {"id": 50, "type": "IfcWallType", "name": "Standard"} + }, + "material": {"id": 60, "type": "IfcMaterial", "name": "Concrete"} +} +``` + +Empty categories are omitted from output. + +Use `--traverse up` to walk the spatial hierarchy from the element up to +IfcProject: + +```bash +ifcquery model.ifc relations 10 --traverse up +``` + +```json +[ + {"id": 10, "type": "IfcWall", "name": "Wall001"}, + {"id": 4, "type": "IfcBuildingStorey", "name": "Ground Floor"}, + {"id": 3, "type": "IfcBuilding", "name": "Main Building"}, + {"id": 2, "type": "IfcSite", "name": "Default Site"}, + {"id": 1, "type": "IfcProject", "name": "Office Building"} +] +``` + +### validate + +Check the model for schema and constraint violations. + +```bash +ifcquery model.ifc validate +ifcquery model.ifc validate --rules +``` + +Options: + +- `--rules` -- also run the slower EXPRESS rules check (default: off) + +```json +{ + "valid": true, + "issues": [] +} +``` + +On an invalid model: + +```json +{ + "valid": false, + "issues": [ + {"level": "ERROR", "message": "Entity #42 IfcWall.GlobalId is not a valid IfcGloballyUniqueId"} + ] +} +``` + +### schedule + +List all work schedules and their task trees from the model. + +```bash +ifcquery model.ifc schedule +ifcquery model.ifc schedule --depth 1 +``` + +Options: + +- `--depth N` -- expand at most N levels of subtasks (default: unlimited). At the + cutoff, `subtasks` is replaced with `{"truncated": true, "count": N}`. + +```json +[ + { + "id": 42, + "name": "Construction Schedule", + "predefined_type": "BASELINE", + "tasks": [ + { + "id": 55, + "name": "Phase 1", + "start": "2024-01-01T09:00:00", + "finish": "2024-06-30T17:00:00", + "is_milestone": false, + "outputs": [{"id": 10, "type": "IfcWall", "name": "Wall A"}], + "subtasks": [ + {"id": 56, "name": "Foundations", "start": null, "finish": null, + "is_milestone": false, "outputs": [], "subtasks": []} + ] + } + ] + } +] +``` + +### cost + +List all cost schedules and their cost item trees from the model. + +```bash +ifcquery model.ifc cost +ifcquery model.ifc cost --depth 2 +``` + +Options: + +- `--depth N` -- expand at most N levels of subitems (default: unlimited). At the + cutoff, `subitems` is replaced with `{"truncated": true, "count": N}`. + +```json +[ + { + "id": 100, + "name": "Bill of Quantities", + "predefined_type": "COSTPLAN", + "items": [ + { + "id": 110, + "name": "Concrete Works", + "values": [{"formula": "1200.00 = material(1200.0)", "category": "material"}], + "subitems": [ + {"id": 111, "name": "Formwork", "values": [], "subitems": []} + ] + } + ] + } +] +``` + +### schema + +Show IFC class documentation for any entity type, using the schema version of +the loaded model. + +```bash +ifcquery model.ifc schema IfcWall +ifcquery model.ifc schema IfcBuildingStorey +``` + +```json +{ + "description": "The wall represents a vertical construction ...", + "predefined_types": {"STANDARD": "A standard wall, extruded vertically ..."}, + "spec_url": "https://standards.buildingsmart.org/...", + "attributes": { + "Name": "Optional name for use by the participating software systems", + "ObjectPlacement": "Placement of the product in space ..." + } +} +``` + +Returns `{"error": "Unknown entity: Foo"}` for unrecognised types. + +### contexts + +List all geometric representation contexts and subcontexts in the model. + +```bash +ifcquery model.ifc contexts +``` + +```json +[ + { + "id": 5, + "type": "IfcGeometricRepresentationContext", + "context_type": "Model", + "subcontexts": [ + {"id": 6, "type": "IfcGeometricRepresentationSubContext", "context_identifier": "Body", "target_view": "MODEL_VIEW"}, + {"id": 7, "type": "IfcGeometricRepresentationSubContext", "context_identifier": "Axis", "target_view": "GRAPH_VIEW"} + ] + } +] +``` + +### materials + +List all materials and material sets used in the model, with their assigned elements. + +```bash +ifcquery model.ifc materials +``` + +```json +[ + { + "id": 60, + "type": "IfcMaterial", + "name": "Concrete", + "elements": [{"id": 10, "type": "IfcWall", "name": "Wall001"}] + } +] +``` + +### plot + +Generate a 2D technical drawing (floor plan, elevation, or section) of the model and write it to a file. + +```bash +ifcquery model.ifc plot output.svg +ifcquery model.ifc plot output.png --view floorplan --scale 0.01 +``` + +Options: + +- `--view {floorplan,elevation,section,auto}` -- drawing view (default: `floorplan`) +- `--scale ` -- model-to-paper scale ratio (default: 0.01 = 1:100) +- `--width-mm ` -- paper width in mm (default: 297) +- `--height-mm ` -- paper height in mm (default: 420) +- `--png-width ` -- raster output width in pixels (default: 1024) +- `--png-height ` -- raster output height in pixels (default: 1024) + +Writes SVG when the output path ends in `.svg`, otherwise PNG. +Requires the IfcOpenShell drawing module (`ifcopenshell.draw`). + +### render + +Render a 3D view of the model geometry to a PNG file. + +```bash +ifcquery model.ifc render output.png +ifcquery model.ifc render output.png --view iso --selector IfcWall +``` + +Options: + +- `--view {iso,top,south,north,east,west}` -- camera angle (default: `iso`) +- `--selector ` -- ifcopenshell selector to restrict rendered elements + +Requires `pyvista` and the IfcOpenShell C++ geometry bindings. + +### clash + +Check a single element for geometric intersections and clearance violations +against other elements. + +```bash +ifcquery model.ifc clash 10 +ifcquery model.ifc clash 10 --clearance 0.5 +ifcquery model.ifc clash 10 --scope all --tolerance 0.001 +``` + +Options: + +- `--clearance ` -- minimum clearance distance to check +- `--tolerance ` -- intersection tolerance (default: 0.002) +- `--scope {storey,all}` -- check against same-storey elements or all elements (default: storey) + +```json +{ + "element": {"id": 10, "type": "IfcWall", "name": "Wall001"}, + "scope": "storey", + "pass": false, + "checks": { + "intersection": { + "pass": false, + "tolerance": 0.002, + "clashes": [ + { + "element": {"id": 11, "type": "IfcWall", "name": "Wall002"}, + "type": "intersection", + "distance": 0.0, + "p1": [2.5, 2.5, 1.5], + "p2": [2.5, 2.5, 1.5] + } + ] + }, + "clearance": { + "pass": true, + "clearance": 0.5, + "clashes": [] + } + } +} +``` + +Requires the IfcOpenShell C++ geometry bindings. + +## Scripting with ifcedit + +`ifcquery` and `ifcedit` are designed to compose. Use `--format ids` to pass +query results directly into `ifcedit run` parameters, or pipe JSON into +`ifcedit foreach` to apply an operation to every matching element. + +```bash +# Remove all walls from their spatial container +ifcedit run model.ifc spatial.unassign_container \ + --products "$(ifcquery model.ifc --format ids select 'IfcWall')" + +# Delete every window (model opened and saved once) +ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product {id} + +# Bulk rename all doors +ifcquery model.ifc select 'IfcDoor' | ifcedit foreach model.ifc attribute.edit_attributes \ + --product {id} --attributes '{"Name": "Door"}' + +# Render an element highlighted against everything related to it +ifcquery model.ifc render relations.png \ + --element "$(ifcquery model.ifc --format ids relations 42)" + +# Render a clash — subject and clashing elements highlighted together +ifcquery model.ifc render clash.png \ + --element "$(ifcquery model.ifc --format ids clash 42)" +``` + +See the `ifcedit` documentation for the full `foreach` reference. + +## Error handling + +Errors are written to stderr. Exit code is 0 on success, 1 on error. + +## License + +LGPLv3+ -- see the IfcOpenShell project license. diff --git a/src/ifcquery/ifcquery/__init__.py b/src/ifcquery/ifcquery/__init__.py new file mode 100644 index 0000000000..be5327b20d --- /dev/null +++ b/src/ifcquery/ifcquery/__init__.py @@ -0,0 +1,20 @@ +# This file was generated with the assistance of an AI coding tool. +# IfcQuery - IFC model interrogation CLI +# Copyright (C) 2026 Bruno Postle +# +# This file is part of IfcQuery. +# +# IfcQuery 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. +# +# IfcQuery 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 IfcQuery. If not, see . + +__version__ = version = "0.0.0" diff --git a/src/ifcquery/ifcquery/__main__.py b/src/ifcquery/ifcquery/__main__.py new file mode 100644 index 0000000000..d4b6141fd9 --- /dev/null +++ b/src/ifcquery/ifcquery/__main__.py @@ -0,0 +1,397 @@ +# This file was generated with the assistance of an AI coding tool. +# IfcQuery - IFC model interrogation CLI +# Copyright (C) 2026 Bruno Postle +# +# This file is part of IfcQuery. +# +# IfcQuery 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. +# +# IfcQuery 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 IfcQuery. If not, see . + +from __future__ import annotations + +import argparse +import json +import os +import sys + +import ifcopenshell + +from ifcquery import clash as clash_mod +from ifcquery import contexts as contexts_mod +from ifcquery import cost as cost_mod +from ifcquery import ( + info, + plot, + relations, + schedule, + schema, + select, + summary, + tree, +) +from ifcquery import ( + materials as materials_mod, +) +from ifcquery import ( + render as render_mod, +) +from ifcquery import validate as validate_mod + + +def parse_element_id(raw: str) -> int: + """Parse an element ID from '#123' or '123' format.""" + raw = raw.strip().lstrip("#") + return int(raw) + + +def format_output(data, fmt: str) -> str: + if fmt == "json": + return json.dumps(data, indent=2, ensure_ascii=False) + elif fmt == "text": + return _format_text(data) + elif fmt == "ids": + return _format_ids(data) + return json.dumps(data, indent=2, ensure_ascii=False) + + +def _format_ids(data) -> str: + """Extract 'id' fields from a list of dicts and return as comma-separated string. + + For dicts with a top-level 'elements' key (e.g. clash, relations output), + extracts from that flat summary list rather than the nested structure. + """ + if isinstance(data, list): + ids = [str(item["id"]) for item in data if isinstance(item, dict) and "id" in item] + return ",".join(ids) + if isinstance(data, dict): + if "elements" in data and isinstance(data["elements"], list): + return _format_ids(data["elements"]) + if "id" in data: + return str(data["id"]) + return "" + + +def _format_text(data, indent: int = 0) -> str: + prefix = " " * indent + lines = [] + if isinstance(data, dict): + for key, value in data.items(): + if isinstance(value, (dict, list)): + lines.append(f"{prefix}{key}:") + lines.append(_format_text(value, indent + 1)) + else: + lines.append(f"{prefix}{key}: {value}") + elif isinstance(data, list): + for item in data: + if isinstance(item, dict): + lines.append(_format_text(item, indent)) + lines.append("") + else: + lines.append(f"{prefix}- {item}") + else: + lines.append(f"{prefix}{data}") + return "\n".join(lines) + + +def main(): + parser = argparse.ArgumentParser( + prog="ifcquery", + description="Query and inspect IFC building models", + ) + parser.add_argument("ifc_file", help="Path to the IFC file") + parser.add_argument( + "--format", + choices=["json", "text", "ids"], + default="json", + dest="output_format", + help="Output format: json (default), text (human-readable), ids (comma-separated step IDs)", + ) + + subparsers = parser.add_subparsers(dest="command", required=True) + + subparsers.add_parser("summary", help="Model overview: schema, element counts, project info") + + subparsers.add_parser("tree", help="Spatial hierarchy tree") + + info_parser = subparsers.add_parser("info", help="Deep inspection of a specific element") + info_parser.add_argument("element_id", help="Element step ID (e.g. 123 or #123)") + + select_parser = subparsers.add_parser("select", help="Filter elements using selector syntax") + select_parser.add_argument("query", help="Selector query string") + + relations_parser = subparsers.add_parser("relations", help="Show relationships for an element") + relations_parser.add_argument("element_id", help="Element step ID (e.g. 123 or #123)") + relations_parser.add_argument("--traverse", choices=["up"], help="Traverse hierarchy (up: walk to IfcProject)") + + clash_parser = subparsers.add_parser("clash", help="Check element placement for clashes") + clash_parser.add_argument("element_id", help="Element step ID (e.g. 123 or #123)") + clash_parser.add_argument("--clearance", type=float, help="Minimum clearance distance") + clash_parser.add_argument("--tolerance", type=float, default=0.002, help="Intersection tolerance (default: 0.002)") + clash_parser.add_argument( + "--scope", choices=["storey", "all"], default="storey", help="Scope of elements to check (default: storey)" + ) + + validate_parser = subparsers.add_parser("validate", help="Schema/constraint validation") + validate_parser.add_argument( + "--rules", action="store_true", help="Also check EXPRESS rules (slower, default: false)" + ) + + schedule_parser = subparsers.add_parser("schedule", help="List work plans and tasks from the model") + schedule_parser.add_argument( + "--depth", type=int, default=None, metavar="N", help="Limit subtask expansion to N levels (default: unlimited)" + ) + + cost_parser = subparsers.add_parser("cost", help="List cost schedules and cost items from the model") + cost_parser.add_argument( + "--depth", + type=int, + default=None, + metavar="N", + help="Limit cost item expansion to N levels (default: unlimited)", + ) + + subparsers.add_parser("contexts", help="List geometric representation contexts and subcontexts") + + subparsers.add_parser("materials", help="List materials and material sets") + + schema_parser = subparsers.add_parser("schema", help="IFC class documentation") + schema_parser.add_argument("entity_type", help="IFC entity type (e.g. IfcWall)") + + render_parser = subparsers.add_parser("render", help="Render model geometry to a PNG image") + render_parser.add_argument( + "-o", "--output", default="", metavar="FILE", help="Output PNG path (default: .png)" + ) + render_parser.add_argument( + "--selector", default="", metavar="QUERY", help="ifcopenshell selector to restrict rendered elements" + ) + render_parser.add_argument( + "--element", + default="", + metavar="ID[,ID...]", + help="Comma-separated step IDs of elements to highlight (rest rendered in grey)", + ) + render_parser.add_argument( + "--view", + choices=render_mod.VIEWS, + default="iso", + help="Camera angle (default: iso)", + ) + + plot_parser = subparsers.add_parser( + "plot", help="Plot model drawing (SVG via ifcopenshell.draw; optional PNG via CairoSVG)" + ) + plot_parser.add_argument( + "-o", + "--output", + default="", + metavar="FILE", + help="Output file path. Default depends on --out-format: .svg/.png", + ) + plot_parser.add_argument( + "--out-format", + choices=["svg", "png", "base64"], + default="png", + help="Output format: svg (write SVG), png (write PNG), base64 (print base64 in JSON/text). Default: png", + ) + plot_parser.add_argument( + "--selector", default="", metavar="QUERY", help="ifcopenshell selector to restrict plotted elements" + ) + plot_parser.add_argument( + "--element", default="", metavar="ID[,ID...]", help="Comma-separated step IDs of elements to highlight" + ) + plot_parser.add_argument( + "--view", + choices=getattr(plot, "VIEWS", ("floorplan", "elevation", "section", "auto")), + default="floorplan", + help="Drawing view (default: floorplan)", + ) + plot_parser.add_argument( + "--width-mm", + type=float, + default=297.0, + metavar="MM", + help="Paper width in mm (default: 297)", + ) + plot_parser.add_argument( + "--height-mm", + type=float, + default=420.0, + metavar="MM", + help="Paper height in mm (default: 420)", + ) + plot_parser.add_argument( + "--scale", + type=float, + default=1.0 / 100.0, + metavar="S", + help="Model-to-paper scale (default: 0.01 = 1:100)", + ) + plot_parser.add_argument( + "--png-width", + type=int, + default=1024, + metavar="PX", + help="PNG width in pixels (default: 1024)", + ) + plot_parser.add_argument( + "--png-height", + type=int, + default=1024, + metavar="PX", + help="PNG height in pixels (default: 1024)", + ) + + args = parser.parse_args() + + try: + model = ifcopenshell.open(args.ifc_file) + except Exception as e: + print(f"Error: Could not open IFC file: {e}", file=sys.stderr) + sys.exit(1) + + if args.command == "summary": + result = summary.summary(model) + elif args.command == "tree": + result = tree.tree(model) + elif args.command == "info": + try: + element_id = parse_element_id(args.element_id) + except ValueError: + print(f"Error: Invalid element ID: {args.element_id}", file=sys.stderr) + sys.exit(1) + try: + element = model.by_id(element_id) + except RuntimeError: + print(f"Error: Element #{element_id} not found", file=sys.stderr) + sys.exit(1) + result = info.info(model, element) + elif args.command == "select": + result = select.select(model, args.query) + elif args.command == "relations": + try: + element_id = parse_element_id(args.element_id) + except ValueError: + print(f"Error: Invalid element ID: {args.element_id}", file=sys.stderr) + sys.exit(1) + try: + element = model.by_id(element_id) + except RuntimeError: + print(f"Error: Element #{element_id} not found", file=sys.stderr) + sys.exit(1) + result = relations.relations(model, element, traverse=args.traverse) + elif args.command == "clash": + try: + element_id = parse_element_id(args.element_id) + except ValueError: + print(f"Error: Invalid element ID: {args.element_id}", file=sys.stderr) + sys.exit(1) + try: + element = model.by_id(element_id) + except RuntimeError: + print(f"Error: Element #{element_id} not found", file=sys.stderr) + sys.exit(1) + try: + result = clash_mod.clash( + model, element, clearance=args.clearance, tolerance=args.tolerance, scope=args.scope + ) + except ImportError: + print("Error: ifcopenshell geometry engine not available (C++ bindings required)", file=sys.stderr) + sys.exit(1) + elif args.command == "validate": + result = validate_mod.validate(model, express_rules=args.rules) + elif args.command == "schedule": + result = schedule.schedule(model, max_depth=args.depth) + elif args.command == "cost": + result = cost_mod.cost(model, max_depth=args.depth) + elif args.command == "contexts": + result = contexts_mod.contexts(model) + elif args.command == "materials": + result = materials_mod.materials(model) + elif args.command == "schema": + result = schema.schema(model, args.entity_type) + elif args.command == "render": + element_ids = None + if args.element: + try: + element_ids = [parse_element_id(part) for part in args.element.split(",")] + except ValueError: + print(f"Error: Invalid element ID(s): {args.element}", file=sys.stderr) + sys.exit(1) + out_path = args.output or (os.path.splitext(args.ifc_file)[0] + ".png") + try: + png_bytes = render_mod.render( + model, + selector=args.selector or None, + element_ids=element_ids, + view=args.view, + ) + except ImportError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + except ValueError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + with open(out_path, "wb") as f: + f.write(png_bytes) + print(f"Saved render to {out_path}", file=sys.stderr) + return + elif args.command == "plot": + element_ids = None + if args.element: + try: + element_ids = [parse_element_id(part) for part in args.element.split(",")] + except ValueError: + print(f"Error: Invalid element ID(s): {args.element}", file=sys.stderr) + sys.exit(1) + + try: + result = plot.plot( + model, + selector=args.selector or None, + element_ids=element_ids, + view=args.view, + width_mm=args.width_mm, + height_mm=args.height_mm, + scale=args.scale, + output_format=args.out_format, + ) + except ImportError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + except ValueError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + if args.out_format == "base64": + # result is a dict; serialise to stdout so callers can consume it + print(format_output(result, args.output_format)) + return + + # svg or png: write to a file + base = os.path.splitext(args.ifc_file)[0] + if args.out_format == "svg": + out_path = args.output or (base + ".svg") + else: + out_path = args.output or (base + ".png") + + with open(out_path, "wb") as f: + f.write(result) + + print(f"Saved drawing to {out_path}", file=sys.stderr) + return + + print(format_output(result, args.output_format)) + + +if __name__ == "__main__": + main() diff --git a/src/ifcquery/ifcquery/clash.py b/src/ifcquery/ifcquery/clash.py new file mode 100644 index 0000000000..52c8d53e3b --- /dev/null +++ b/src/ifcquery/ifcquery/clash.py @@ -0,0 +1,180 @@ +# This file was generated with the assistance of an AI coding tool. +# IfcQuery - IFC model interrogation CLI +# Copyright (C) 2026 Bruno Postle +# +# This file is part of IfcQuery. +# +# IfcQuery 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. +# +# IfcQuery 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 IfcQuery. If not, see . + +from __future__ import annotations + +import multiprocessing +import sys +from typing import Any + +import ifcopenshell +import ifcopenshell.geom +import ifcopenshell.util.element + + +def _ref(element: ifcopenshell.entity_instance) -> dict[str, Any]: + """Serialize an element to a compact reference dict.""" + result: dict[str, Any] = {"id": element.id(), "type": element.is_a()} + if hasattr(element, "Name") and element.Name: + result["name"] = element.Name + return result + + +def _get_scope_elements( + model: ifcopenshell.file, element: ifcopenshell.entity_instance, scope: str +) -> tuple[set[ifcopenshell.entity_instance], str]: + """Return set of elements to check against and the effective scope used. + + Returns (elements, effective_scope) where effective_scope may differ from + the requested scope if fallback was needed. + """ + if scope == "storey": + container = ifcopenshell.util.element.get_container(element) + if container is not None: + siblings = set(ifcopenshell.util.element.get_contained(container)) + siblings.discard(element) + return siblings, "storey" + else: + print( + f"Warning: Element #{element.id()} has no spatial container, falling back to --scope all", + file=sys.stderr, + ) + + # scope == "all" or fallback + elements = set(model.by_type("IfcElement")) + elements -= set(model.by_type("IfcFeatureElement")) + elements.discard(element) + return elements, "all" + + +def _build_tree(model: ifcopenshell.file, elements: set[ifcopenshell.entity_instance]) -> ifcopenshell.geom.tree | None: + """Build geometry tree for given elements using iterator. + + Returns None if iterator fails to initialize (no geometry available). + """ + geom_settings = ifcopenshell.geom.settings() + geom_settings.set("use-world-coords", True) + geom_tree = ifcopenshell.geom.tree() + iterator = ifcopenshell.geom.iterator(geom_settings, model, multiprocessing.cpu_count(), include=list(elements)) + if not iterator.initialize(): + return None + while True: + geom_tree.add_element(iterator.get()) + if not iterator.next(): + break + return geom_tree + + +def _format_clash(clash_result, geom_tree: ifcopenshell.geom.tree, model: ifcopenshell.file) -> dict[str, Any]: + """Format a single clash result to dict.""" + # clash result .a/.b are C++ wrapper entity_instances without .Name; + # look up the Python entity from the model by id for proper serialization + other = model.by_id(clash_result.b.id()) + return { + "element": _ref(other), + "type": geom_tree.get_clash_type(clash_result.clash_type), + "distance": clash_result.distance, + "p1": list(clash_result.p1), + "p2": list(clash_result.p2), + } + + +def clash( + model: ifcopenshell.file, + element: ifcopenshell.entity_instance, + clearance: float | None = None, + tolerance: float = 0.002, + scope: str = "storey", +) -> dict[str, Any]: + """Check element for geometric clashes against other elements. + + :param model: The IFC model. + :param element: The element to check. + :param clearance: Minimum clearance distance; if provided, runs clearance check. + :param tolerance: Intersection tolerance in meters (default 0.002). + :param scope: Which elements to check against: "storey" or "all". + :return: Dict with clash results suitable for JSON serialization. + """ + result: dict[str, Any] = {"element": _ref(element)} + + # Get scope elements + scope_elements, effective_scope = _get_scope_elements(model, element, scope) + result["scope"] = effective_scope + + if not scope_elements: + result["pass"] = True + result["checks"] = {"intersection": {"pass": True, "tolerance": tolerance, "clashes": []}} + if clearance is not None: + result["checks"]["clearance"] = {"pass": True, "clearance": clearance, "clashes": []} + return result + + # Build geometry tree for target element + scope elements + all_elements = scope_elements | {element} + geom_tree = _build_tree(model, all_elements) + + if geom_tree is None: + result["pass"] = None + result["error"] = f"No geometry for element #{element.id()}" + return result + + # Run intersection check + intersection_clashes = geom_tree.clash_intersection_many( + [element], list(scope_elements), tolerance=tolerance, check_all=True + ) + intersection_results = [_format_clash(c, geom_tree, model) for c in intersection_clashes] + checks: dict[str, Any] = { + "intersection": { + "pass": len(intersection_results) == 0, + "tolerance": tolerance, + "clashes": intersection_results, + } + } + + all_pass = len(intersection_results) == 0 + + # Run clearance check if requested + if clearance is not None: + clearance_clashes = geom_tree.clash_clearance_many( + [element], list(scope_elements), clearance=clearance, check_all=True + ) + clearance_results = [_format_clash(c, geom_tree, model) for c in clearance_clashes] + checks["clearance"] = { + "pass": len(clearance_results) == 0, + "clearance": clearance, + "clashes": clearance_results, + } + if clearance_results: + all_pass = False + + result["pass"] = all_pass + result["checks"] = checks + + # Flat list of subject + all clashing elements across all checks, deduplicated. + # Allows --format ids to extract all involved IDs without jq. + seen: set[int] = {element.id()} + involved = [_ref(element)] + for check in checks.values(): + for clash_item in check.get("clashes", []): + eid = clash_item["element"]["id"] + if eid not in seen: + seen.add(eid) + involved.append(clash_item["element"]) + result["elements"] = involved + + return result diff --git a/src/ifcquery/ifcquery/contexts.py b/src/ifcquery/ifcquery/contexts.py new file mode 100644 index 0000000000..912df173d7 --- /dev/null +++ b/src/ifcquery/ifcquery/contexts.py @@ -0,0 +1,44 @@ +# IfcQuery - IFC model interrogation CLI +# Copyright (C) 2026 Bruno Postle +# +# This file is part of IfcQuery. +# +# IfcQuery 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. +# +# IfcQuery 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 IfcQuery. If not, see . + +from __future__ import annotations + +import ifcopenshell + + +def contexts(model: ifcopenshell.file) -> list[dict]: + """Return all geometric representation contexts and subcontexts. + + :param model: The in-memory IFC model. + :return: List of dicts with id, type, context_type, context_identifier, + and (for subcontexts) target_view and parent_context_id. + """ + results = [] + for ctx in model.by_type("IfcGeometricRepresentationContext"): + entry = { + "id": ctx.id(), + "type": ctx.is_a(), + "context_type": getattr(ctx, "ContextType", None), + "context_identifier": getattr(ctx, "ContextIdentifier", None), + } + if ctx.is_a("IfcGeometricRepresentationSubContext"): + entry["target_view"] = ctx.TargetView + parent = ctx.ParentContext + entry["parent_context_id"] = parent.id() if parent else None + results.append(entry) + return results diff --git a/src/ifcquery/ifcquery/cost.py b/src/ifcquery/ifcquery/cost.py new file mode 100644 index 0000000000..ef9559d597 --- /dev/null +++ b/src/ifcquery/ifcquery/cost.py @@ -0,0 +1,45 @@ +# This file was generated with the assistance of an AI coding tool. +from __future__ import annotations + +from typing import Any + +import ifcopenshell +import ifcopenshell.util.cost as cost_util + + +def _cost_item_to_dict(item: ifcopenshell.entity_instance, max_depth: int | None, depth: int) -> dict[str, Any]: + raw_values = cost_util.get_cost_values(item) + values = [{"formula": v.get("label", ""), "category": v.get("category")} for v in raw_values] + + if max_depth is not None and depth >= max_depth: + child_count = len(cost_util.get_nested_cost_items(item)) + subitems = {"truncated": True, "count": child_count} if child_count else [] + else: + subitems = [_cost_item_to_dict(sub, max_depth, depth + 1) for sub in cost_util.get_nested_cost_items(item)] + + return { + "id": item.id(), + "name": getattr(item, "Name", None), + "values": values, + "subitems": subitems, + } + + +def cost(model: ifcopenshell.file, max_depth: int | None = None) -> list[dict[str, Any]]: + """Return a list of IfcCostSchedule entries with nested cost item trees. + + max_depth limits how many levels of subitems are expanded (None = unlimited). + At the cutoff level, subitems is replaced with {"truncated": True, "count": N}. + """ + result = [] + for cost_schedule in model.by_type("IfcCostSchedule"): + items = [_cost_item_to_dict(i, max_depth, depth=1) for i in cost_util.get_root_cost_items(cost_schedule)] + result.append( + { + "id": cost_schedule.id(), + "name": getattr(cost_schedule, "Name", None), + "predefined_type": getattr(cost_schedule, "PredefinedType", None), + "items": items, + } + ) + return result diff --git a/src/ifcquery/ifcquery/info.py b/src/ifcquery/ifcquery/info.py new file mode 100644 index 0000000000..ad9a9daa4f --- /dev/null +++ b/src/ifcquery/ifcquery/info.py @@ -0,0 +1,262 @@ +# This file was generated with the assistance of an AI coding tool. +# IfcQuery - IFC model interrogation CLI +# Copyright (C) 2026 Bruno Postle +# +# This file is part of IfcQuery. +# +# IfcQuery 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. +# +# IfcQuery 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 IfcQuery. If not, see . + +from __future__ import annotations + +from typing import Any + +import ifcopenshell +import ifcopenshell.util.element +import ifcopenshell.util.placement + +# --------------------------------------------------------------------------- +# Geometry summary helpers +# --------------------------------------------------------------------------- + +_MAX_PROFILE_POINTS = 20 + + +def _rc(coords) -> list[float]: + """Round a coordinate sequence to 6 decimal places.""" + return [round(float(c), 6) for c in coords] + + +def _curve_points(curve) -> list | None: + if curve.is_a("IfcPolyline"): + return [_rc(p.Coordinates) for p in curve.Points] + if curve.is_a("IfcIndexedPolyCurve"): + return [_rc(c) for c in curve.Points.CoordList] + return None + + +def _profile_summary(profile) -> dict: + t = profile.is_a() + result: dict[str, Any] = {"type": t} + if t == "IfcRectangleProfileDef": + result["x_dim"] = profile.XDim + result["y_dim"] = profile.YDim + elif t in ("IfcCircleProfileDef", "IfcCircleHollowProfileDef"): + result["radius"] = profile.Radius + if t == "IfcCircleHollowProfileDef": + result["wall_thickness"] = profile.WallThickness + elif t in ("IfcArbitraryClosedProfileDef", "IfcArbitraryProfileDefWithVoids"): + pts = _curve_points(profile.OuterCurve) + if pts is not None: + if len(pts) <= _MAX_PROFILE_POINTS: + result["points"] = pts + else: + result["point_count"] = len(pts) + elif t == "IfcCompositeProfileDef": + result["profiles"] = [_profile_summary(p) for p in profile.Profiles] + return result + + +def _half_space_plane(half_space) -> dict | None: + if not half_space.is_a("IfcHalfSpaceSolid"): + return None + surface = half_space.BaseSurface + if not surface or not surface.is_a("IfcPlane"): + return None + pos = surface.Position + loc = _rc(pos.Location.Coordinates) + normal = _rc(pos.Axis.DirectionRatios) if pos.Axis else [0.0, 0.0, 1.0] + return {"location": loc, "normal": normal} + + +def _walk_clipping(item) -> tuple: + """Return (base_solid, [clipping_plane_dicts]) from a BooleanClippingResult chain.""" + planes = [] + current = item + while current.is_a("IfcBooleanClippingResult"): + plane = _half_space_plane(current.SecondOperand) + if plane: + planes.append(plane) + current = current.FirstOperand + return current, planes + + +def _swept_solid_dict(item) -> dict: + result: dict[str, Any] = {"solid_type": item.is_a()} + if item.is_a("IfcExtrudedAreaSolid"): + result["depth"] = item.Depth + if item.ExtrudedDirection: + result["direction"] = _rc(item.ExtrudedDirection.DirectionRatios) + if item.SweptArea: + result["profile"] = _profile_summary(item.SweptArea) + return result + + +def _summarize_rep(rep) -> dict: + rep_type = rep.RepresentationType or "" + result: dict[str, Any] = {"representation_type": rep_type} + items = list(rep.Items) + + if rep_type == "MappedRepresentation": + for item in items: + if item.is_a("IfcMappedItem"): + return _summarize_rep(item.MappingSource.MappedRepresentation) + + elif rep_type == "SweptSolid": + result["solids"] = [_swept_solid_dict(item) for item in items] + + elif rep_type == "Clipping": + solids = [] + for item in items: + base, planes = _walk_clipping(item) + solid = _swept_solid_dict(base) + if planes: + solid["clipping_planes"] = planes + solids.append(solid) + result["solids"] = solids + + elif rep_type == "CSG": + ops = [] + for item in items: + if hasattr(item, "Operator"): + ops.append({"operator": str(item.Operator), "type": item.is_a()}) + if ops: + result["operations"] = ops + + elif rep_type in ("Brep", "Tessellation", "SolidModel"): + face_count = 0 + vertex_count = 0 + for item in items: + if item.is_a("IfcPolygonalFaceSet"): + face_count += len(item.Faces) + vertex_count += len(item.Coordinates.CoordList) + elif item.is_a("IfcFacetedBrep"): + face_count += len(item.Outer.CfsFaces) + if face_count: + result["face_count"] = face_count + if vertex_count: + result["vertex_count"] = vertex_count + + return result + + +def _geometry_summary(element) -> dict | None: + if not hasattr(element, "Representation") or not element.Representation: + return None + body_rep = next( + (r for r in element.Representation.Representations if r.RepresentationIdentifier == "Body"), + None, + ) + if body_rep is None: + return None + try: + return _summarize_rep(body_rep) + except Exception: + return None + + +def _serialize_attribute(value: Any) -> Any: + """Convert an IFC attribute value to a JSON-serializable form.""" + if isinstance(value, ifcopenshell.entity_instance): + return {"id": value.id(), "type": value.is_a()} + if isinstance(value, tuple): + return [_serialize_attribute(v) for v in value] + return value + + +def _material_to_dict(material: ifcopenshell.entity_instance | None) -> dict[str, Any] | None: + """Convert a material entity to a summary dict.""" + if material is None: + return None + result: dict[str, Any] = { + "id": material.id(), + "type": material.is_a(), + } + if hasattr(material, "Name"): + result["name"] = material.Name + return result + + +def info(model: ifcopenshell.file, element: ifcopenshell.entity_instance) -> dict[str, Any]: + """Return deep inspection data for an element.""" + result: dict[str, Any] = { + "id": element.id(), + "type": element.is_a(), + } + + # Direct attributes via get_info() which returns a dict of all attributes + element_info = element.get_info() + attrs = {} + for key, value in element_info.items(): + if key in ("id", "type"): + continue + attrs[key] = _serialize_attribute(value) + result["attributes"] = attrs + + # Property sets and quantity sets + try: + psets = ifcopenshell.util.element.get_psets(element) + if psets: + result["property_sets"] = psets + except Exception: + pass + + # Element type + try: + element_type = ifcopenshell.util.element.get_type(element) + if element_type: + type_info: dict[str, Any] = { + "id": element_type.id(), + "type": element_type.is_a(), + } + if hasattr(element_type, "Name"): + type_info["name"] = element_type.Name + result["element_type"] = type_info + except Exception: + pass + + # Material + try: + material = ifcopenshell.util.element.get_material(element) + mat_dict = _material_to_dict(material) + if mat_dict: + result["material"] = mat_dict + except Exception: + pass + + # Spatial container + try: + container = ifcopenshell.util.element.get_container(element) + if container: + result["container"] = { + "id": container.id(), + "type": container.is_a(), + "name": container.Name if hasattr(container, "Name") else None, + } + except Exception: + pass + + # Placement (as 4x4 matrix) + try: + if hasattr(element, "ObjectPlacement") and element.ObjectPlacement: + matrix = ifcopenshell.util.placement.get_local_placement(element.ObjectPlacement) + result["placement"] = matrix.tolist() + except Exception: + pass + + # Geometry summary + geom = _geometry_summary(element) + if geom: + result["geometry_summary"] = geom + + return result diff --git a/src/ifcquery/ifcquery/materials.py b/src/ifcquery/ifcquery/materials.py new file mode 100644 index 0000000000..c7402d51e5 --- /dev/null +++ b/src/ifcquery/ifcquery/materials.py @@ -0,0 +1,100 @@ +# IfcQuery - IFC model interrogation CLI +# Copyright (C) 2026 Bruno Postle +# +# This file is part of IfcQuery. +# +# IfcQuery 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. +# +# IfcQuery 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 IfcQuery. If not, see . + +from __future__ import annotations + +import ifcopenshell + + +def materials(model: ifcopenshell.file) -> list[dict]: + """Return all materials and material sets from the model. + + :param model: The in-memory IFC model. + :return: List of dicts covering IfcMaterial, IfcMaterialLayerSet, + IfcMaterialConstituentSet, and IfcMaterialProfileSet entities. + """ + results = [] + + for m in model.by_type("IfcMaterial"): + results.append( + { + "id": m.id(), + "type": "IfcMaterial", + "name": m.Name, + "category": getattr(m, "Category", None), + } + ) + + for ls in model.by_type("IfcMaterialLayerSet"): + layers = [] + for layer in ls.MaterialLayers or []: + layers.append( + { + "name": layer.Name, + "thickness": layer.LayerThickness, + "material": layer.Material.Name if layer.Material else None, + "is_ventilated": layer.IsVentilated, + } + ) + results.append( + { + "id": ls.id(), + "type": "IfcMaterialLayerSet", + "name": ls.LayerSetName, + "layers": layers, + } + ) + + for cs in model.by_type("IfcMaterialConstituentSet"): + constituents = [] + for c in cs.MaterialConstituents or []: + constituents.append( + { + "name": c.Name, + "material": c.Material.Name if c.Material else None, + "fraction": c.Fraction, + } + ) + results.append( + { + "id": cs.id(), + "type": "IfcMaterialConstituentSet", + "name": cs.Name, + "constituents": constituents, + } + ) + + for ps in model.by_type("IfcMaterialProfileSet"): + profiles = [] + for p in ps.MaterialProfiles or []: + profiles.append( + { + "name": p.Name, + "material": p.Material.Name if p.Material else None, + } + ) + results.append( + { + "id": ps.id(), + "type": "IfcMaterialProfileSet", + "name": ps.Name, + "profiles": profiles, + } + ) + + return results diff --git a/src/ifcquery/ifcquery/plot.py b/src/ifcquery/ifcquery/plot.py new file mode 100644 index 0000000000..3393a87fc8 --- /dev/null +++ b/src/ifcquery/ifcquery/plot.py @@ -0,0 +1,284 @@ +# IfcQuery - IFC model interrogation CLI +# Copyright (C) 2026 Bruno Postle +# +# This file is part of IfcQuery. +# +# IfcQuery 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. +# +# IfcQuery 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 IfcQuery. If not, see . + +from __future__ import annotations + +import base64 +import os +from io import BytesIO +from typing import Any + +import ifcopenshell +import ifcopenshell.geom +import ifcopenshell.util.selector + +try: + import ifcopenshell.draw + + _HAS_DRAW = True +except ImportError: + _HAS_DRAW = False + +from xml.etree.ElementTree import Element, ElementTree, SubElement, register_namespace + +try: + import cairosvg # type: ignore + + _HAS_CAIROSVG = True +except Exception: + _HAS_CAIROSVG = False + + +try: + from PIL import Image # type: ignore + + _HAS_PIL = True +except Exception: + _HAS_PIL = False + +VIEWS = ("floorplan", "elevation", "section", "auto") +OUTPUT_FORMATS = ("svg", "png", "base64") + + +def _escape_css_attr(name: str) -> str: + # CSS attribute selectors must escape ':' (e.g. ifc:guid -> ifc\:guid) + return name.replace(":", "\\:") + + +def _highlight_css_from_ids(model: ifcopenshell.file, element_ids: list[int]) -> str: + guids: list[str] = [] + for sid in element_ids: + try: + e = model.by_id(int(sid)) + except RuntimeError: + continue + if e is None: + continue + gid = getattr(e, "GlobalId", None) + if isinstance(gid, str) and gid: + guids.append(gid) + + if not guids: + return "" + + attr = _escape_css_attr("ifc:guid") + + css = [ + "/* Auto-highlight injected by ifcquery.plot */", + f"[{attr}] path {{ opacity: 0.10; }}", + f"[{attr}] text {{ opacity: 0.25; }}", + ] + for gid in guids: + css.append(f'[{attr}="{gid}"] path {{ opacity: 1.0; stroke: #d00; stroke-width: 0.25; }}') + css.append(f'[{attr}="{gid}"] text {{ opacity: 1.0; fill: #d00; }}') + return "\n".join(css) + "\n" + + +def _make_filtered_iterator(model: ifcopenshell.file, include_elements: list[Any]) -> ifcopenshell.geom.iterator: + # Avoid multiprocessing in WASM; os.cpu_count is good enough. + n_threads = os.cpu_count() or 1 + + # These flags mirror the defaults used by ifcopenshell.draw in v0.8.x. + geom_settings = ifcopenshell.geom.settings( + REORIENT_SHELLS=False, + ELEMENT_HIERARCHY=True, + ) + + # IfcOpenShell wrapper constants may live in different places across builds. + wrapper = getattr(ifcopenshell, "ifcopenshell_wrapper", None) + if wrapper is not None: + try: + geom_settings.set("iterator-output", wrapper.NATIVE) + except Exception: + pass + try: + geom_settings.set("apply-default-materials", True) + except Exception: + pass + try: + geom_settings.set("dimensionality", wrapper.SURFACES_AND_SOLIDS) + except Exception: + pass + + return ifcopenshell.geom.iterator(geom_settings, model, n_threads, include=include_elements) + + +def _diagnose_empty_drawing(model: ifcopenshell.file, view: str) -> str: + """Return a helpful error message when ifcopenshell.draw produces no geometry groups.""" + hints = [] + + if view in ("floorplan", "auto"): + storeys = model.by_type("IfcBuildingStorey") + if not storeys: + hints.append("the model has no IfcBuildingStorey entities (required for auto_floorplan)") + else: + null_elevation = [s for s in storeys if getattr(s, "Elevation", None) is None] + if null_elevation: + names = ", ".join(f'"{s.Name or s.GlobalId}"' for s in null_elevation) + hints.append( + f"storey Elevation is None for: {names} — " + "set IfcBuildingStorey.Elevation (e.g. 0.0) so the section cut height can be determined" + ) + + has_geom = any(getattr(e, "Representation", None) is not None for e in model.by_type("IfcProduct")) + if not has_geom: + hints.append("no IfcProduct entities have geometric representations") + + base = f"No plan geometry found for view={view!r}." + if hints: + return base + " Possible causes: " + "; ".join(hints) + "." + return base + " The model may lack geometry visible in this view." + + +def plot( + model: ifcopenshell.file, + *, + output_format: str = "png", + selector: str | None = None, + element_ids: list[int] | None = None, + view: str = "floorplan", + # SVG / page sizing (draw works in mm coordinates) + width_mm: float = 297.0, + height_mm: float = 420.0, + scale: float = 1.0 / 100.0, + merge_projection: bool = True, + # PNG sizing (only for output_format png/base64) + png_width: int = 1024, + png_height: int = 1024, +) -> bytes | dict[str, Any]: + """ + Plot IFC model as SVG (via ifcopenshell.draw) or PNG/base64 (via CairoSVG). + + Args: + model: In-memory IFC model. + output_format: 'svg' | 'png' | 'base64' + - 'svg' -> returns SVG bytes + - 'png' -> returns PNG bytes + - 'base64'-> returns dict: {mime, png_b64, width, height, view} + selector: ifcopenshell selector query to restrict plotted elements. + element_ids: STEP ids to highlight; non-highlighted geometry is faded. + view: One of VIEWS ('floorplan', 'elevation', 'section', 'auto'). + width_mm, height_mm: Page size in mm. + scale: Model-to-paper scale (0.01 means 1:100). + merge_projection: Passed through to ifcopenshell.draw.main. + png_width, png_height: Raster size in pixels for png/base64 outputs. + + Raises: + ImportError: if ifcopenshell.draw or CairoSVG is not available (as required). + ValueError: invalid args or selector matches nothing. + """ + if output_format not in OUTPUT_FORMATS: + raise ValueError(f"output_format must be one of {OUTPUT_FORMATS}, got {output_format!r}") + if view not in VIEWS: + raise ValueError(f"view must be one of {VIEWS}, got {view!r}") + if not _HAS_DRAW: + raise ImportError("ifcopenshell.draw is not available in this environment.") + + # Configure draw settings + settings = ifcopenshell.draw.draw_settings( + auto_floorplan=(view in ("floorplan", "auto")), + auto_elevation=(view in ("elevation", "auto")), + auto_section=(view in ("section", "auto")), + width=width_mm, + height=height_mm, + scale=scale, + css="", + ) + + # Optional highlight CSS overlay + if element_ids: + settings.css = _highlight_css_from_ids(model, element_ids) + + # Optional element restriction via selector -> custom iterator + iterators: tuple[Any, ...] = () + if selector: + include_elements = list(ifcopenshell.util.selector.filter_elements(model, selector)) + if not include_elements: + raise ValueError(f"Selector {selector!r} matched no elements") + it = _make_filtered_iterator(model, include_elements) + iterators = (it,) + # If we explicitly include elements, don't rely on exclude_entities (best-effort). + settings.exclude_entities = "" + + # Generate SVG + svg_bytes = ifcopenshell.draw.main( + settings, + files=[model], + iterators=iterators, + merge_projection=merge_projection, + ) + + register_namespace("", "http://www.w3.org/2000/svg") + + def svg_split(f): + x = ElementTree(file=f) + svg = x.getroot() + resources = [] + for child in svg: + if child.tag == "{http://www.w3.org/2000/svg}g": + root = Element(svg.tag, svg.attrib) + n = ElementTree(root) + for r in resources + [child]: + root.append(r) + b = BytesIO() + n.write(b, xml_declaration=True, encoding="utf-8", method="xml") + yield b.getvalue() + else: + resources.append(child) + + if output_format == "svg": + return svg_bytes + + # Need CairoSVG for png/base64 + if not _HAS_CAIROSVG: + raise ImportError("CairoSVG is not installed. Install with: pip install cairosvg") + + svgs = list(svg_split(BytesIO(svg_bytes))) + if not svgs: + raise ValueError(_diagnose_empty_drawing(model, view)) + + composite = None + png_bytes = None + for i, svgb in enumerate(svgs): + png_bytes = cairosvg.svg2png(bytestring=svgb, output_width=png_width, output_height=png_height) + if len(svgs) == 1: + break + + # Need Pillow for concatenating images + if not _HAS_PIL: + raise ImportError("Pillow is not installed. Install with: pip install Pillow") + + if composite is None: + composite = Image.new("RGBA", (png_width, png_height * len(svgs))) + img = Image.open(BytesIO(png_bytes)) + composite.paste(img, (0, png_height * i)) + if composite is not None: + b = BytesIO() + composite.save(b, "png") + png_bytes = b.getvalue() + + if output_format == "base64": + return { + "mime": "image/png", + "png_b64": base64.b64encode(png_bytes).decode(), + "width": png_width, + "height": png_height, + "view": view, + } + + return png_bytes diff --git a/src/ifcquery/ifcquery/relations.py b/src/ifcquery/ifcquery/relations.py new file mode 100644 index 0000000000..334a3241a3 --- /dev/null +++ b/src/ifcquery/ifcquery/relations.py @@ -0,0 +1,193 @@ +# This file was generated with the assistance of an AI coding tool. +# IfcQuery - IFC model interrogation CLI +# Copyright (C) 2026 Bruno Postle +# +# This file is part of IfcQuery. +# +# IfcQuery 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. +# +# IfcQuery 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 IfcQuery. If not, see . + +from __future__ import annotations + +from typing import Any + +import ifcopenshell +import ifcopenshell.util.element +import ifcopenshell.util.system + + +def _ref(element: ifcopenshell.entity_instance) -> dict[str, Any]: + """Serialize an element to a compact reference dict.""" + result: dict[str, Any] = {"id": element.id(), "type": element.is_a()} + if hasattr(element, "Name") and element.Name: + result["name"] = element.Name + return result + + +def _ref_or_none(element: ifcopenshell.entity_instance | None) -> dict[str, Any] | None: + return _ref(element) if element is not None else None + + +def _ref_list(elements) -> list[dict[str, Any]]: + return [_ref(e) for e in elements] + + +def _traverse_up(element: ifcopenshell.entity_instance) -> list[dict[str, Any]]: + """Walk the hierarchy from element up to IfcProject.""" + chain = [_ref(element)] + current = element + while True: + parent = ifcopenshell.util.element.get_parent(current) + if parent is None: + break + chain.append(_ref(parent)) + current = parent + return chain + + +def _all_relations(model: ifcopenshell.file, element: ifcopenshell.entity_instance) -> dict[str, Any]: + """Collect all relationships for an element.""" + result: dict[str, Any] = { + "id": element.id(), + "type": element.is_a(), + } + if hasattr(element, "Name") and element.Name: + result["name"] = element.Name + + # Hierarchy (upward) + hierarchy: dict[str, Any] = {} + parent = ifcopenshell.util.element.get_parent(element) + if parent is not None: + hierarchy["parent"] = _ref(parent) + container = ifcopenshell.util.element.get_container(element) + if container is not None: + hierarchy["container"] = _ref(container) + aggregate = ifcopenshell.util.element.get_aggregate(element) + if aggregate is not None: + hierarchy["aggregate"] = _ref(aggregate) + nest = ifcopenshell.util.element.get_nest(element) + if nest is not None: + hierarchy["nest"] = _ref(nest) + filled_void = ifcopenshell.util.element.get_filled_void(element) + if filled_void is not None: + hierarchy["filled_void"] = _ref(filled_void) + voided_element = ifcopenshell.util.element.get_voided_element(element) + if voided_element is not None: + hierarchy["voided_element"] = _ref(voided_element) + if hierarchy: + result["hierarchy"] = hierarchy + + # Children (downward) + children: dict[str, Any] = {} + contained = ifcopenshell.util.element.get_contained(element) + if contained: + children["contained"] = _ref_list(contained) + parts = ifcopenshell.util.element.get_parts(element) + if parts: + children["parts"] = _ref_list(parts) + components = ifcopenshell.util.element.get_components(element) + if components: + children["components"] = _ref_list(components) + openings = list(ifcopenshell.util.element.get_openings(element)) + if openings: + children["openings"] = _ref_list(openings) + if children: + result["children"] = children + + # Type relationship + type_relationship: dict[str, Any] = {} + element_type = ifcopenshell.util.element.get_type(element) + if element_type is not None: + type_relationship["type_of"] = _ref(element_type) + try: + occurrences = ifcopenshell.util.element.get_types(element) + if occurrences: + type_relationship["occurrences"] = _ref_list(occurrences) + except Exception: + pass + if type_relationship: + result["type_relationship"] = type_relationship + + # Groups + groups = ifcopenshell.util.element.get_groups(element) + if groups: + result["groups"] = _ref_list(groups) + + # Systems + systems = ifcopenshell.util.system.get_element_systems(element) + if systems: + result["systems"] = _ref_list(systems) + + # Zones + zones = ifcopenshell.util.system.get_element_zones(element) + if zones: + result["zones"] = _ref_list(zones) + + # Material + material = ifcopenshell.util.element.get_material(element) + if material is not None: + result["material"] = _ref(material) + + # Referenced structures + referenced = ifcopenshell.util.element.get_referenced_structures(element) + if referenced: + result["referenced_structures"] = _ref_list(referenced) + + # Connections + connections: dict[str, Any] = {} + connected_to = ifcopenshell.util.system.get_connected_to(element) + if connected_to: + connections["connected_to"] = _ref_list(connected_to) + connected_from = ifcopenshell.util.system.get_connected_from(element) + if connected_from: + connections["connected_from"] = _ref_list(connected_from) + ports = ifcopenshell.util.system.get_ports(element) + if ports: + connections["ports"] = _ref_list(ports) + if connections: + result["connections"] = connections + + return result + + +def _collect_elements(data: Any, seen: set[int], result: list[dict[str, Any]]) -> None: + """Recursively collect all element refs (dicts with 'id') from a nested structure.""" + if isinstance(data, dict): + if "id" in data and isinstance(data["id"], int): + eid = data["id"] + if eid not in seen: + seen.add(eid) + result.append({"id": data["id"], "type": data.get("type"), "name": data.get("name")} if "name" in data else {"id": data["id"], "type": data.get("type")}) + for v in data.values(): + _collect_elements(v, seen, result) + elif isinstance(data, list): + for item in data: + _collect_elements(item, seen, result) + + +def relations( + model: ifcopenshell.file, element: ifcopenshell.entity_instance, traverse: str | None = None +) -> dict[str, Any] | list[dict[str, Any]]: + """Return relationships for an element, or hierarchy chain if traverse='up'.""" + if traverse == "up": + return _traverse_up(element) + result = _all_relations(model, element) + + # Flat list of subject + all referenced elements, deduplicated. + # Allows --format ids to extract all involved IDs without jq. + seen: set[int] = set() + elements: list[dict[str, Any]] = [] + _collect_elements(result, seen, elements) + result["elements"] = elements + + return result diff --git a/src/ifcquery/ifcquery/render.py b/src/ifcquery/ifcquery/render.py new file mode 100644 index 0000000000..44105e0ee2 --- /dev/null +++ b/src/ifcquery/ifcquery/render.py @@ -0,0 +1,465 @@ +# IfcQuery - IFC model interrogation CLI +# Copyright (C) 2026 Bruno Postle +# +# This file is part of IfcQuery. +# +# IfcQuery 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. +# +# IfcQuery 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 IfcQuery. If not, see . + +from __future__ import annotations + +import multiprocessing +import os +import tempfile + +import ifcopenshell +import ifcopenshell.geom +import ifcopenshell.guid +import ifcopenshell.util.selector + +try: + import numpy as np + import pyvista as pv + + _HAS_PYVISTA = True +except ImportError: + _HAS_PYVISTA = False + +VIEWS = ("iso", "top", "south", "north", "east", "west") + + +def _apply_view(plotter: pv.Plotter, view: str) -> None: + """Set the camera to the requested named view. Z is up (IFC convention).""" + if view == "top": + plotter.view_xy() + elif view == "south": + # Camera at -Y looking toward +Y (south face of building) + plotter.view_xz(negative=True) + elif view == "north": + plotter.view_xz(negative=False) + elif view == "east": + plotter.view_yz(negative=False) + elif view == "west": + plotter.view_yz(negative=True) + else: + plotter.view_isometric() + # Ensure Z is world up for elevation views + if view not in ("top",): + plotter.camera.up = (0, 0, 1) + + +def _add_shape( + shape: object, + plotter: pv.Plotter, + highlight_ids: frozenset[int] | None, +) -> None: + """Triangulate and add a geometry shape to the plotter.""" + geom = shape.geometry + verts = np.array(geom.verts, dtype=float).reshape(-1, 3) + if verts.size == 0: + return + + raw_faces = np.array(geom.faces, dtype=int) + if raw_faces.size == 0 or raw_faces.size % 3 != 0: + return # degenerate geometry from kernel — skip silently + faces = raw_faces.reshape(-1, 3) + material_ids = np.array(geom.material_ids, dtype=int) + + is_subject = highlight_ids is not None and shape.product.id() in highlight_ids + + for midx, mat in enumerate(geom.materials): + tri_mask = material_ids == midx + if not np.any(tri_mask): + continue + + sub_faces = faces[tri_mask] + faces_pv = np.hstack([np.full((sub_faces.shape[0], 1), 3, dtype=int), sub_faces]).ravel() + mesh = pv.PolyData(verts, faces_pv) + + if highlight_ids is not None and not is_subject: + color = (180, 180, 180) + opacity = 0.10 + else: + diffuse = np.clip(np.array(mat.diffuse.components), 0.0, 1.0) + color = tuple((diffuse * 255).astype(np.uint8)) + transparency = mat.transparency if mat.transparency == mat.transparency else 0.0 + opacity = float(np.clip(1.0 - transparency, 0.0, 1.0)) + + plotter.add_mesh(mesh, color=color, opacity=opacity, show_edges=False) + + +def _render_iterator( + iterator: object, + highlight_ids: list[int] | None, + view: str, +) -> bytes: + """Drive a geometry iterator into a pyvista plotter and return PNG bytes.""" + plotter = pv.Plotter(off_screen=True, window_size=(1280, 960)) + plotter.background_color = "white" + + while True: + try: + _add_shape(iterator.get(), plotter, highlight_ids=frozenset(highlight_ids) if highlight_ids else None) + except Exception: + pass # skip broken shapes, keep rendering the rest + if not iterator.next(): + break + + plotter.reset_camera() + _apply_view(plotter, view) + + tmp_fd, tmp_path = tempfile.mkstemp(suffix=".png") + os.close(tmp_fd) + try: + plotter.show(screenshot=tmp_path, auto_close=True) + with open(tmp_path, "rb") as f: + return f.read() + finally: + try: + os.unlink(tmp_path) + except OSError: + pass + + +def _build_geom_settings(model: ifcopenshell.file) -> ifcopenshell.geom.settings: + """Build geometry settings, excluding Clearance subcontexts.""" + settings = ifcopenshell.geom.settings() + settings.set("use-world-coords", True) + + clearance_ids = { + c.id() for c in model.by_type("IfcGeometricRepresentationSubContext") if c.ContextIdentifier == "Clearance" + } + if clearance_ids: + ctx_ids = [c.id() for c in model.by_type("IfcGeometricRepresentationContext") if c.id() not in clearance_ids] + if ctx_ids: + settings.set("context-ids", ctx_ids) + + return settings + + +def _get_occurrence_class(type_entity) -> str: + """Derive the occurrence IFC class from a type entity class name.""" + type_class = type_entity.is_a() + if type_class.endswith("Type"): + return type_class[:-4] + return "IfcBuildingElementProxy" + + +def _make_type_occurrence(model: ifcopenshell.file, type_entity) -> object | None: + """Create a temporary occurrence for *type_entity* using its RepresentationMaps. + + The occurrence is added to *model* and references the type's existing + RepresentationMap entities via IfcMappedItem. Returns the occurrence entity, + or ``None`` when the type has no usable RepresentationMaps. + + .. note:: + This function is intended for use on a temporary model copy. The + caller is responsible for discarding that copy after rendering. + """ + rep_maps = getattr(type_entity, "RepresentationMaps", None) or [] + if not rep_maps: + return None + + # One IfcMappedItem per RepresentationMap. + mapped_items = [] + for rep_map in rep_maps: + origin = model.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0)) + transform = model.create_entity( + "IfcCartesianTransformationOperator3D", + LocalOrigin=origin, + ) + mapped_item = model.create_entity( + "IfcMappedItem", + MappingSource=rep_map, + MappingTarget=transform, + ) + mapped_items.append(mapped_item) + + context = rep_maps[0].MappedRepresentation.ContextOfItems + shape_rep = model.create_entity( + "IfcShapeRepresentation", + ContextOfItems=context, + RepresentationIdentifier="Body", + RepresentationType="MappedRepresentation", + Items=mapped_items, + ) + prod_def_shape = model.create_entity( + "IfcProductDefinitionShape", + Representations=[shape_rep], + ) + + # Identity placement. + pt = model.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0)) + z_dir = model.create_entity("IfcDirection", DirectionRatios=(0.0, 0.0, 1.0)) + x_dir = model.create_entity("IfcDirection", DirectionRatios=(1.0, 0.0, 0.0)) + axis2 = model.create_entity("IfcAxis2Placement3D", Location=pt, Axis=z_dir, RefDirection=x_dir) + placement = model.create_entity("IfcLocalPlacement", RelativePlacement=axis2) + + occ_class = _get_occurrence_class(type_entity) + try: + occurrence = model.create_entity( + occ_class, + GlobalId=ifcopenshell.guid.new(), + Name=f"_type_preview_{type_entity.id()}", + ObjectPlacement=placement, + Representation=prod_def_shape, + ) + except Exception: + occurrence = model.create_entity( + "IfcBuildingElementProxy", + GlobalId=ifcopenshell.guid.new(), + Name=f"_type_preview_{type_entity.id()}", + ObjectPlacement=placement, + Representation=prod_def_shape, + ) + return occurrence + + +def _make_profile_occurrence(model: ifcopenshell.file, type_entity) -> object | None: + """Create a temporary occurrence for a type that has a material profile set. + + Finds the first profile in the type's IfcMaterialProfileSet and creates a + 1-metre IfcExtrudedAreaSolid body representation from it. Returns the + occurrence, or ``None`` when no usable profile is found. + + .. note:: + Intended for use on a temporary model copy; caller discards it after + rendering. + """ + # Locate the first profile from the type's material profile set. + profile = None + for rel in getattr(type_entity, "HasAssociations", []): + if not rel.is_a("IfcRelAssociatesMaterial"): + continue + mat = rel.RelatingMaterial + if mat.is_a("IfcMaterialProfileSetUsage"): + mat = mat.ForProfileSet + if mat.is_a("IfcMaterialProfileSet"): + mat_profiles = list(getattr(mat, "MaterialProfiles", None) or []) + if mat_profiles: + profile = getattr(mat_profiles[0], "Profile", None) + if profile is not None: + break + if profile is None: + return None + + # Find a Body subcontext, or fall back to any Model context. + body_ctx = None + for ctx in model.by_type("IfcGeometricRepresentationSubContext"): + if ctx.ContextIdentifier == "Body": + body_ctx = ctx + break + if body_ctx is None: + for ctx in model.by_type("IfcGeometricRepresentationContext"): + if ctx.ContextType == "Model": + body_ctx = ctx + break + if body_ctx is None: + return None + + # Extrude 1 metre along Z (profile lies in XY plane). + origin = model.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0)) + z_axis = model.create_entity("IfcDirection", DirectionRatios=(0.0, 0.0, 1.0)) + x_axis = model.create_entity("IfcDirection", DirectionRatios=(1.0, 0.0, 0.0)) + position = model.create_entity("IfcAxis2Placement3D", Location=origin, Axis=z_axis, RefDirection=x_axis) + extrude_dir = model.create_entity("IfcDirection", DirectionRatios=(0.0, 0.0, 1.0)) + extrusion = model.create_entity( + "IfcExtrudedAreaSolid", + SweptArea=profile, + Position=position, + ExtrudedDirection=extrude_dir, + Depth=1.0, + ) + shape_rep = model.create_entity( + "IfcShapeRepresentation", + ContextOfItems=body_ctx, + RepresentationIdentifier="Body", + RepresentationType="SweptSolid", + Items=[extrusion], + ) + prod_def_shape = model.create_entity( + "IfcProductDefinitionShape", + Representations=[shape_rep], + ) + + # Identity placement. + pt = model.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0)) + z_dir = model.create_entity("IfcDirection", DirectionRatios=(0.0, 0.0, 1.0)) + x_dir = model.create_entity("IfcDirection", DirectionRatios=(1.0, 0.0, 0.0)) + axis2 = model.create_entity("IfcAxis2Placement3D", Location=pt, Axis=z_dir, RefDirection=x_dir) + placement = model.create_entity("IfcLocalPlacement", RelativePlacement=axis2) + + occ_class = _get_occurrence_class(type_entity) + try: + occurrence = model.create_entity( + occ_class, + GlobalId=ifcopenshell.guid.new(), + Name=f"_profile_preview_{type_entity.id()}", + ObjectPlacement=placement, + Representation=prod_def_shape, + ) + except Exception: + occurrence = model.create_entity( + "IfcBuildingElementProxy", + GlobalId=ifcopenshell.guid.new(), + Name=f"_profile_preview_{type_entity.id()}", + ObjectPlacement=placement, + Representation=prod_def_shape, + ) + return occurrence + + +def _render_with_types( + model: ifcopenshell.file, + types: list, + selector_elements: list | None, + element_ids: list[int] | None, + type_highlight_ids: set[int], + view: str, +) -> bytes: + """Render type entities by creating occurrences in a temporary model copy. + + *types* — list of IfcTypeProduct entities to render. + *selector_elements* — non-type elements from the selector (or ``None``). + *element_ids* — original highlight IDs (may contain type IDs). + *type_highlight_ids* — subset of *element_ids* that are type IDs. + """ + tmp_fd, tmp_path = tempfile.mkstemp(suffix=".ifc") + os.close(tmp_fd) + try: + model.write(tmp_path) + tmp = ifcopenshell.open(tmp_path) + + # Map original type step-ID → new occurrence step-ID in the tmp model. + type_id_to_occ_id: dict[int, int] = {} + for t in types: + tmp_type = tmp.by_id(t.id()) + occ = _make_type_occurrence(tmp, tmp_type) or _make_profile_occurrence(tmp, tmp_type) + if occ: + type_id_to_occ_id[t.id()] = occ.id() + + if not type_id_to_occ_id: + raise ValueError("Type entities have no RepresentationMaps or material profile sets to render") + + include = [tmp.by_id(occ_id) for occ_id in type_id_to_occ_id.values()] + if selector_elements: + include.extend(tmp.by_id(e.id()) for e in selector_elements) + + settings = _build_geom_settings(tmp) + iterator = ifcopenshell.geom.iterator(settings, tmp, multiprocessing.cpu_count(), include=include) + if not iterator.initialize(): + raise ValueError("Type entities have no renderable geometry") + + # Remap type IDs → occurrence IDs in the highlight list. + new_highlight = None + if element_ids: + new_highlight = [] + for hid in element_ids: + if hid in type_highlight_ids: + mapped = type_id_to_occ_id.get(hid) + if mapped: + new_highlight.append(mapped) + else: + new_highlight.append(hid) + + return _render_iterator(iterator, new_highlight, view) + finally: + try: + os.unlink(tmp_path) + except OSError: + pass + + +def render( + model: ifcopenshell.file, + selector: str | None = None, + element_ids: list[int] | None = None, + view: str = "iso", +) -> bytes: + """Render IFC model geometry to a PNG image. + + Supports both element instances and element types (e.g. ``IfcWallType``). + When type entities are targeted — via *selector* or *element_ids* — a + temporary copy of the model is used to create proxy occurrences that + reference the type's RepresentationMaps; the original model is not + modified. + + :param model: The in-memory IFC model. + :param selector: ifcopenshell selector to restrict rendered elements + (e.g. ``'IfcWall'``, ``'IfcWallType'``, or + ``'IfcBuildingStorey[Name="Ground Floor"]'``). + When omitted the whole model is rendered. + :param element_ids: Step IDs of elements (or types) to highlight. The + rest of the model is rendered in translucent grey so the highlighted + items stand out. + :param view: Camera angle: ``iso``, ``top``, ``south``, ``north``, + ``east``, or ``west``. Defaults to ``iso``. + :return: PNG image as raw bytes. + :raises ImportError: If pyvista is not installed. + :raises ValueError: If the selector matches nothing or the model has no + renderable geometry. + """ + if not _HAS_PYVISTA: + raise ImportError("pyvista is not installed. Install with: pip install pyvista") + + # --- Partition selector results into types and elements --- + if selector: + matched = list(ifcopenshell.util.selector.filter_elements(model, selector)) + if not matched: + raise ValueError(f"Selector {selector!r} matched no elements") + types = [e for e in matched if e.is_a("IfcTypeProduct")] + selector_elements: list | None = [e for e in matched if not e.is_a("IfcTypeProduct")] + else: + types = [] + selector_elements = None # no restriction — render all elements + + # --- Collect any type entities from element_ids --- + type_highlight_ids: set[int] = set() + if element_ids: + for eid in element_ids: + entity = model.by_id(eid) + if entity.is_a("IfcTypeProduct"): + type_highlight_ids.add(eid) + seen = {t.id() for t in types} + if eid not in seen: + types.append(entity) + + # --- Delegate to temp-copy path when any type entities are involved --- + if types: + return _render_with_types(model, types, selector_elements, element_ids, type_highlight_ids, view) + + # --- Regular element rendering --- + settings = _build_geom_settings(model) + + if selector_elements is not None: + if not selector_elements: + raise ValueError(f"Selector {selector!r} matched only type entities (use a type selector or IfcElement)") + iterator = ifcopenshell.geom.iterator( + settings, + model, + multiprocessing.cpu_count(), + include=selector_elements, + ) + else: + exclude = list(model.by_type("IfcOpeningElement")) + iterator = ifcopenshell.geom.iterator( + settings, + model, + multiprocessing.cpu_count(), + exclude=exclude if exclude else None, + ) + + if not iterator.initialize(): + raise ValueError("No renderable geometry found in model (or selector matched nothing)") + + return _render_iterator(iterator, element_ids, view) diff --git a/src/ifcquery/ifcquery/schedule.py b/src/ifcquery/ifcquery/schedule.py new file mode 100644 index 0000000000..b3e3ae45d6 --- /dev/null +++ b/src/ifcquery/ifcquery/schedule.py @@ -0,0 +1,56 @@ +# This file was generated with the assistance of an AI coding tool. +from __future__ import annotations + +from typing import Any + +import ifcopenshell +import ifcopenshell.util.sequence as seq + + +def _task_to_dict(task: ifcopenshell.entity_instance, max_depth: int | None, depth: int) -> dict[str, Any]: + task_time = task.TaskTime + start = None + finish = None + if task_time: + start = task_time.ScheduleStart + finish = task_time.ScheduleFinish + + outputs = [] + for product in seq.get_task_outputs(task): + outputs.append({"id": product.id(), "type": product.is_a(), "name": getattr(product, "Name", None)}) + + if max_depth is not None and depth >= max_depth: + child_count = len(seq.get_nested_tasks(task)) + subtasks = {"truncated": True, "count": child_count} if child_count else [] + else: + subtasks = [_task_to_dict(sub, max_depth, depth + 1) for sub in seq.get_nested_tasks(task)] + + return { + "id": task.id(), + "name": getattr(task, "Name", None), + "start": start, + "finish": finish, + "is_milestone": bool(task.IsMilestone) if hasattr(task, "IsMilestone") else False, + "outputs": outputs, + "subtasks": subtasks, + } + + +def schedule(model: ifcopenshell.file, max_depth: int | None = None) -> list[dict[str, Any]]: + """Return a list of IfcWorkSchedule entries with nested task trees. + + max_depth limits how many levels of subtasks are expanded (None = unlimited). + At the cutoff level, subtasks is replaced with {"truncated": True, "count": N}. + """ + result = [] + for work_schedule in model.by_type("IfcWorkSchedule"): + tasks = [_task_to_dict(t, max_depth, depth=1) for t in seq.get_root_tasks(work_schedule)] + result.append( + { + "id": work_schedule.id(), + "name": getattr(work_schedule, "Name", None), + "predefined_type": getattr(work_schedule, "PredefinedType", None), + "tasks": tasks, + } + ) + return result diff --git a/src/ifcquery/ifcquery/schema.py b/src/ifcquery/ifcquery/schema.py new file mode 100644 index 0000000000..ad47486db2 --- /dev/null +++ b/src/ifcquery/ifcquery/schema.py @@ -0,0 +1,19 @@ +# This file was generated with the assistance of an AI coding tool. +from __future__ import annotations + +from typing import Any + +import ifcopenshell +import ifcopenshell.util.doc + + +def schema(model: ifcopenshell.file, entity_type: str) -> dict[str, Any]: + """Return IFC class documentation for entity_type from model's schema version.""" + schema_name = model.schema + try: + doc = ifcopenshell.util.doc.get_entity_doc(schema_name, entity_type) + except Exception: + return {"error": f"Unknown entity: {entity_type}"} + if not doc: + return {"error": f"Unknown entity: {entity_type}"} + return dict(doc) diff --git a/src/ifcquery/ifcquery/select.py b/src/ifcquery/ifcquery/select.py new file mode 100644 index 0000000000..c28a159b90 --- /dev/null +++ b/src/ifcquery/ifcquery/select.py @@ -0,0 +1,41 @@ +# This file was generated with the assistance of an AI coding tool. +# IfcQuery - IFC model interrogation CLI +# Copyright (C) 2026 Bruno Postle +# +# This file is part of IfcQuery. +# +# IfcQuery 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. +# +# IfcQuery 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 IfcQuery. If not, see . + +from __future__ import annotations + +from typing import Any + +import ifcopenshell +import ifcopenshell.util.selector + + +def select(model: ifcopenshell.file, query: str) -> list[dict[str, Any]]: + """Filter elements using selector syntax and return matching element summaries.""" + elements = ifcopenshell.util.selector.filter_elements(model, query) + results = [] + for element in sorted(elements, key=lambda e: e.id()): + entry: dict[str, Any] = { + "id": element.id(), + "type": element.is_a(), + "repr": str(element), + } + if hasattr(element, "Name"): + entry["name"] = element.Name + results.append(entry) + return results diff --git a/src/ifcquery/ifcquery/summary.py b/src/ifcquery/ifcquery/summary.py new file mode 100644 index 0000000000..d13f070472 --- /dev/null +++ b/src/ifcquery/ifcquery/summary.py @@ -0,0 +1,52 @@ +# This file was generated with the assistance of an AI coding tool. +# IfcQuery - IFC model interrogation CLI +# Copyright (C) 2026 Bruno Postle +# +# This file is part of IfcQuery. +# +# IfcQuery 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. +# +# IfcQuery 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 IfcQuery. If not, see . + +from __future__ import annotations + +from collections import Counter +from typing import Any + +import ifcopenshell + + +def summary(model: ifcopenshell.file) -> dict[str, Any]: + """Return a model overview with schema, element counts, and project info.""" + # Count elements by IFC type, sorted by count descending + type_counter: Counter[str] = Counter() + total = 0 + for entity in model: + type_counter[entity.is_a()] += 1 + total += 1 + + result: dict[str, Any] = { + "schema": model.schema, + "total_entities": total, + } + + projects = model.by_type("IfcProject") + if projects: + project = projects[0] + result["project"] = { + "id": project.id(), + "name": project.Name, + "description": project.Description, + } + + result["types"] = dict(type_counter.most_common()) + return result diff --git a/src/ifcquery/ifcquery/tree.py b/src/ifcquery/ifcquery/tree.py new file mode 100644 index 0000000000..1cbbaeb986 --- /dev/null +++ b/src/ifcquery/ifcquery/tree.py @@ -0,0 +1,68 @@ +# This file was generated with the assistance of an AI coding tool. +# IfcQuery - IFC model interrogation CLI +# Copyright (C) 2026 Bruno Postle +# +# This file is part of IfcQuery. +# +# IfcQuery 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. +# +# IfcQuery 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 IfcQuery. If not, see . + +from __future__ import annotations + +from typing import Any + +import ifcopenshell +import ifcopenshell.util.element + + +def _element_summary(element: ifcopenshell.entity_instance) -> dict[str, Any]: + """Return a minimal summary dict for an element.""" + return { + "id": element.id(), + "type": element.is_a(), + "name": element.Name if hasattr(element, "Name") else None, + } + + +def _build_spatial_node(element: ifcopenshell.entity_instance) -> dict[str, Any]: + """Recursively build a spatial tree node.""" + node = _element_summary(element) + + # Get aggregated children (Site in Project, Building in Site, Storey in Building, etc.) + aggregates = [] + for rel in getattr(element, "IsDecomposedBy", []): + for child in rel.RelatedObjects: + aggregates.append(_build_spatial_node(child)) + + # Get contained elements (walls, slabs, etc. in a storey/space) + contained = [] + for rel in getattr(element, "ContainsElements", []): + for child in rel.RelatedElements: + contained.append(_element_summary(child)) + + if aggregates: + node["children"] = aggregates + if contained: + node["elements"] = contained + + return node + + +def tree(model: ifcopenshell.file) -> dict[str, Any] | list[dict[str, Any]]: + """Return the spatial hierarchy tree starting from IfcProject.""" + projects = model.by_type("IfcProject") + if not projects: + return {"error": "No IfcProject found in model"} + if len(projects) == 1: + return _build_spatial_node(projects[0]) + return [_build_spatial_node(p) for p in projects] diff --git a/src/ifcquery/ifcquery/validate.py b/src/ifcquery/ifcquery/validate.py new file mode 100644 index 0000000000..d35d9150af --- /dev/null +++ b/src/ifcquery/ifcquery/validate.py @@ -0,0 +1,15 @@ +# This file was generated with the assistance of an AI coding tool. +from __future__ import annotations + +from typing import Any + +import ifcopenshell +import ifcopenshell.validate + + +def validate(model: ifcopenshell.file, express_rules: bool = False) -> dict[str, Any]: + """Validate the model and return a dict with 'valid' bool and 'issues' list.""" + logger = ifcopenshell.validate.json_logger() + ifcopenshell.validate.validate(model, logger, express_rules=express_rules) + issues = [{"level": s["level"], "message": s["message"]} for s in logger.statements] + return {"valid": len(issues) == 0, "issues": issues} diff --git a/src/ifcquery/pyproject.toml b/src/ifcquery/pyproject.toml new file mode 100644 index 0000000000..3c8e734474 --- /dev/null +++ b/src/ifcquery/pyproject.toml @@ -0,0 +1,33 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "ifcquery" +version = "0.0.0" +authors = [ + { name="Bruno Postle", email="bruno@postle.net" }, +] +description = "CLI tool for querying and inspecting IFC building models" +readme = "README.md" +keywords = ["IFC", "BIM", "Query"] +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)", +] +dependencies = ["ifcopenshell"] + +[project.scripts] +ifcquery = "ifcquery.__main__:main" + +[project.urls] +Homepage = "http://ifcopenshell.org" +Documentation = "https://docs.ifcopenshell.org" +Issues = "https://github.com/IfcOpenShell/IfcOpenShell/issues" + +[tool.setuptools.packages.find] +include = ["ifcquery*"] +exclude = ["test*"] + +[tool.ruff] +extend = "../../pyproject.toml" diff --git a/src/ifcquery/tests/__init__.py b/src/ifcquery/tests/__init__.py new file mode 100644 index 0000000000..0a3bc271a0 --- /dev/null +++ b/src/ifcquery/tests/__init__.py @@ -0,0 +1 @@ +# This file was generated with the assistance of an AI coding tool. diff --git a/src/ifcquery/tests/conftest.py b/src/ifcquery/tests/conftest.py new file mode 100644 index 0000000000..496e74cd6e --- /dev/null +++ b/src/ifcquery/tests/conftest.py @@ -0,0 +1,36 @@ +# This file was generated with the assistance of an AI coding tool. +import ifcopenshell +import ifcopenshell.api.aggregate +import ifcopenshell.api.owner.settings +import ifcopenshell.api.project +import ifcopenshell.api.root +import ifcopenshell.api.spatial +import ifcopenshell.api.unit +import pytest + + +@pytest.fixture +def model(): + """Create an IFC4 model with a spatial hierarchy and a wall.""" + f = ifcopenshell.api.project.create_file() + ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0] + ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0] + + project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="TestProject") + ifcopenshell.api.unit.assign_unit(f) + + site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="TestSite") + building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="TestBuilding") + storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="Ground Floor") + + ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project) + ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site) + ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=building) + + wall = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall001") + ifcopenshell.api.spatial.assign_container(f, products=[wall], relating_structure=storey) + + slab = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSlab", name="Slab001") + ifcopenshell.api.spatial.assign_container(f, products=[slab], relating_structure=storey) + + return f diff --git a/src/ifcquery/tests/test_clash.py b/src/ifcquery/tests/test_clash.py new file mode 100644 index 0000000000..150200f95c --- /dev/null +++ b/src/ifcquery/tests/test_clash.py @@ -0,0 +1,330 @@ +# This file was generated with the assistance of an AI coding tool. +import json +import os +import subprocess +import sys +import tempfile + +import ifcopenshell +import ifcopenshell.api.aggregate +import ifcopenshell.api.context +import ifcopenshell.api.geometry +import ifcopenshell.api.owner.settings +import ifcopenshell.api.project +import ifcopenshell.api.root +import ifcopenshell.api.spatial +import ifcopenshell.api.unit +import numpy as np +import pytest + +from ifcquery.clash import clash + +try: + import ifcopenshell.geom + + HAS_GEOM = True +except ImportError: + HAS_GEOM = False + +pytestmark = pytest.mark.skipif(not HAS_GEOM, reason="ifcopenshell geometry engine not available") + + +@pytest.fixture +def model_with_geometry(): + """Create an IFC4 model with walls that have geometric representations.""" + f = ifcopenshell.api.project.create_file() + ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0] + ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0] + + project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="TestProject") + ifcopenshell.api.unit.assign_unit(f) + + site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="TestSite") + building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="TestBuilding") + storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="Ground Floor") + + ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project) + ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site) + ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=building) + + # Create geometry context + model_ctx = ifcopenshell.api.context.add_context(f, context_type="Model") + body = ifcopenshell.api.context.add_context( + f, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=model_ctx + ) + + # Wall 1 at origin + wall1 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall001") + rep1 = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=5, height=3, thickness=0.2) + ifcopenshell.api.geometry.assign_representation(f, product=wall1, representation=rep1) + ifcopenshell.api.spatial.assign_container(f, products=[wall1], relating_structure=storey) + + # Wall 2 perpendicular, crossing through wall 1 + wall2 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall002") + rep2 = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=5, height=3, thickness=0.2) + ifcopenshell.api.geometry.assign_representation(f, product=wall2, representation=rep2) + ifcopenshell.api.spatial.assign_container(f, products=[wall2], relating_structure=storey) + matrix2 = np.array([[0, -1, 0, 2.5], [1, 0, 0, -2.0], [0, 0, 1, 0], [0, 0, 0, 1]], dtype=float) + ifcopenshell.api.geometry.edit_object_placement(f, product=wall2, matrix=matrix2) + + # Wall 3 far away (10m offset in Y) + wall3 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall003") + rep3 = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=5, height=3, thickness=0.2) + ifcopenshell.api.geometry.assign_representation(f, product=wall3, representation=rep3) + ifcopenshell.api.spatial.assign_container(f, products=[wall3], relating_structure=storey) + matrix3 = np.eye(4) + matrix3[1, 3] = 10.0 # 10m in Y direction + ifcopenshell.api.geometry.edit_object_placement(f, product=wall3, matrix=matrix3) + + # Wall 4 close but not overlapping (0.3m offset in Y, wall thickness is 0.2m) + wall4 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall004") + rep4 = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=5, height=3, thickness=0.2) + ifcopenshell.api.geometry.assign_representation(f, product=wall4, representation=rep4) + ifcopenshell.api.spatial.assign_container(f, products=[wall4], relating_structure=storey) + matrix4 = np.eye(4) + matrix4[1, 3] = 0.3 # 0.3m in Y (gap of 0.1m from wall1) + ifcopenshell.api.geometry.edit_object_placement(f, product=wall4, matrix=matrix4) + + return f + + +@pytest.fixture +def model_two_storeys(): + """Create a model with walls in different storeys.""" + f = ifcopenshell.api.project.create_file() + ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0] + ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0] + + project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="TestProject") + ifcopenshell.api.unit.assign_unit(f) + + site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="TestSite") + building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="TestBuilding") + storey1 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="Ground Floor") + storey2 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="First Floor") + + ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project) + ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site) + ifcopenshell.api.aggregate.assign_object(f, products=[storey1, storey2], relating_object=building) + + model_ctx = ifcopenshell.api.context.add_context(f, context_type="Model") + body = ifcopenshell.api.context.add_context( + f, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=model_ctx + ) + + # Wall in storey 1 + wall1 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="GroundWall") + rep1 = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=5, height=3, thickness=0.2) + ifcopenshell.api.geometry.assign_representation(f, product=wall1, representation=rep1) + ifcopenshell.api.spatial.assign_container(f, products=[wall1], relating_structure=storey1) + + # Wall in storey 2, perpendicular and crossing wall1 + wall2 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="FirstFloorWall") + rep2 = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=5, height=3, thickness=0.2) + ifcopenshell.api.geometry.assign_representation(f, product=wall2, representation=rep2) + ifcopenshell.api.spatial.assign_container(f, products=[wall2], relating_structure=storey2) + matrix2 = np.array([[0, -1, 0, 2.5], [1, 0, 0, -2.0], [0, 0, 1, 0], [0, 0, 0, 1]], dtype=float) + ifcopenshell.api.geometry.edit_object_placement(f, product=wall2, matrix=matrix2) + + return f + + +class TestNoClashes: + def test_no_clashes_far_apart(self, model_with_geometry): + wall3 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall003") + result = clash(model_with_geometry, wall3) + assert result["pass"] is True + assert result["checks"]["intersection"]["pass"] is True + assert result["checks"]["intersection"]["clashes"] == [] + + def test_no_clashes_empty_scope(self, model_with_geometry): + """A model where the element is the only one in scope should pass.""" + # Create a model with a single wall + f = ifcopenshell.api.project.create_file() + ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0] + ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0] + project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="P") + ifcopenshell.api.unit.assign_unit(f) + site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="S") + building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="B") + storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="GF") + ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project) + ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site) + ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=building) + model_ctx = ifcopenshell.api.context.add_context(f, context_type="Model") + body = ifcopenshell.api.context.add_context( + f, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=model_ctx + ) + wall = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="OnlyWall") + rep = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=5, height=3, thickness=0.2) + ifcopenshell.api.geometry.assign_representation(f, product=wall, representation=rep) + ifcopenshell.api.spatial.assign_container(f, products=[wall], relating_structure=storey) + + result = clash(f, wall) + assert result["pass"] is True + + +class TestIntersectionDetected: + def test_overlapping_walls(self, model_with_geometry): + wall1 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001") + result = clash(model_with_geometry, wall1) + assert result["pass"] is False + assert result["checks"]["intersection"]["pass"] is False + clashes = result["checks"]["intersection"]["clashes"] + assert len(clashes) > 0 + # Wall002 should be in the clashes (it overlaps wall1) + clash_ids = {c["element"]["id"] for c in clashes} + wall2 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall002") + assert wall2.id() in clash_ids + + def test_clash_has_points(self, model_with_geometry): + wall1 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001") + result = clash(model_with_geometry, wall1) + clashes = result["checks"]["intersection"]["clashes"] + for c in clashes: + assert "p1" in c + assert "p2" in c + assert len(c["p1"]) == 3 + assert len(c["p2"]) == 3 + assert "type" in c + assert "distance" in c + + +class TestClearance: + def test_clearance_violation(self, model_with_geometry): + """Wall004 is 0.1m from wall1; clearance of 0.5m should fail.""" + wall1 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001") + result = clash(model_with_geometry, wall1, clearance=0.5) + assert "clearance" in result["checks"] + # Wall004 should violate clearance + clearance_clashes = result["checks"]["clearance"]["clashes"] + clash_ids = {c["element"]["id"] for c in clearance_clashes} + wall4 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall004") + assert wall4.id() in clash_ids + assert result["checks"]["clearance"]["pass"] is False + + def test_clearance_pass(self, model_with_geometry): + """Wall003 is 10m away; clearance of 0.5m should pass for wall003.""" + wall3 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall003") + result = clash(model_with_geometry, wall3, clearance=0.5) + assert result["checks"]["clearance"]["pass"] is True + assert result["checks"]["clearance"]["clashes"] == [] + + def test_clearance_not_included_by_default(self, model_with_geometry): + wall1 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001") + result = clash(model_with_geometry, wall1) + assert "clearance" not in result["checks"] + + +class TestScope: + def test_scope_storey_excludes_other_storeys(self, model_two_storeys): + wall1 = next(w for w in model_two_storeys.by_type("IfcWall") if w.Name == "GroundWall") + result = clash(model_two_storeys, wall1, scope="storey") + assert result["scope"] == "storey" + # No clashes because the overlapping wall is in a different storey + assert result["pass"] is True + + def test_scope_all_includes_other_storeys(self, model_two_storeys): + wall1 = next(w for w in model_two_storeys.by_type("IfcWall") if w.Name == "GroundWall") + result = clash(model_two_storeys, wall1, scope="all") + assert result["scope"] == "all" + # Should detect clash with the other-storey wall + assert result["pass"] is False + clash_ids = {c["element"]["id"] for c in result["checks"]["intersection"]["clashes"]} + wall2 = next(w for w in model_two_storeys.by_type("IfcWall") if w.Name == "FirstFloorWall") + assert wall2.id() in clash_ids + + +class TestNoGeometry: + def test_no_geometry_error(self, model): + """Element without geometry reports error.""" + wall = model.by_type("IfcWall")[0] + result = clash(model, wall) + assert result["pass"] is None + assert "error" in result + assert "No geometry" in result["error"] + + +class TestJsonSerializable: + def test_result_serializable(self, model_with_geometry): + wall1 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001") + result = clash(model_with_geometry, wall1) + serialized = json.dumps(result) + parsed = json.loads(serialized) + assert parsed["element"]["type"] == "IfcWall" + + def test_clearance_result_serializable(self, model_with_geometry): + wall1 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001") + result = clash(model_with_geometry, wall1, clearance=0.5) + serialized = json.dumps(result) + parsed = json.loads(serialized) + assert "clearance" in parsed["checks"] + + +class TestCLI: + @staticmethod + def _ifc_path(model): + f = tempfile.NamedTemporaryFile(suffix=".ifc", delete=False) + model.write(f.name) + f.close() + return f.name + + def test_clash_json(self, model_with_geometry): + path = self._ifc_path(model_with_geometry) + try: + wall1 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001") + result = subprocess.run( + [sys.executable, "-m", "ifcquery", path, "clash", str(wall1.id())], + capture_output=True, + text=True, + ) + assert result.returncode == 0 + data = json.loads(result.stdout) + assert data["element"]["type"] == "IfcWall" + assert "checks" in data + assert "intersection" in data["checks"] + finally: + os.unlink(path) + + def test_clash_with_clearance(self, model_with_geometry): + path = self._ifc_path(model_with_geometry) + try: + wall1 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001") + result = subprocess.run( + [sys.executable, "-m", "ifcquery", path, "clash", str(wall1.id()), "--clearance", "0.5"], + capture_output=True, + text=True, + ) + assert result.returncode == 0 + data = json.loads(result.stdout) + assert "clearance" in data["checks"] + finally: + os.unlink(path) + + def test_clash_scope_all(self, model_with_geometry): + path = self._ifc_path(model_with_geometry) + try: + wall1 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001") + result = subprocess.run( + [sys.executable, "-m", "ifcquery", path, "clash", str(wall1.id()), "--scope", "all"], + capture_output=True, + text=True, + ) + assert result.returncode == 0 + data = json.loads(result.stdout) + assert data["scope"] == "all" + finally: + os.unlink(path) + + def test_clash_bad_id(self, model_with_geometry): + path = self._ifc_path(model_with_geometry) + try: + result = subprocess.run( + [sys.executable, "-m", "ifcquery", path, "clash", "999999"], + capture_output=True, + text=True, + ) + assert result.returncode != 0 + assert "Error" in result.stderr + finally: + os.unlink(path) diff --git a/src/ifcquery/tests/test_contexts.py b/src/ifcquery/tests/test_contexts.py new file mode 100644 index 0000000000..c7a21acb96 --- /dev/null +++ b/src/ifcquery/tests/test_contexts.py @@ -0,0 +1,52 @@ +import ifcopenshell.api.context +import ifcopenshell.api.project +import ifcopenshell.api.root +import ifcopenshell.api.unit + +from ifcquery.contexts import contexts + + +class TestContexts: + def test_empty_model(self): + f = ifcopenshell.api.project.create_file() + result = contexts(f) + assert isinstance(result, list) + assert len(result) == 0 + + def test_model_context(self, model): + import ifcopenshell.api.context + + ifcopenshell.api.context.add_context(model, context_type="Model") + result = contexts(model) + assert len(result) == 1 + entry = result[0] + assert entry["type"] == "IfcGeometricRepresentationContext" + assert entry["context_type"] == "Model" + assert "id" in entry + assert "context_identifier" in entry + + def test_subcontext(self, model): + import ifcopenshell.api.context + + model_ctx = ifcopenshell.api.context.add_context(model, context_type="Model") + ifcopenshell.api.context.add_context( + model, + context_type="Model", + context_identifier="Body", + target_view="MODEL_VIEW", + parent=model_ctx, + ) + result = contexts(model) + assert len(result) == 2 + subctx = next(e for e in result if e["type"] == "IfcGeometricRepresentationSubContext") + assert subctx["context_identifier"] == "Body" + assert subctx["target_view"] == "MODEL_VIEW" + assert subctx["parent_context_id"] == model_ctx.id() + + def test_ids_are_integers(self, model): + import ifcopenshell.api.context + + ifcopenshell.api.context.add_context(model, context_type="Model") + result = contexts(model) + for entry in result: + assert isinstance(entry["id"], int) diff --git a/src/ifcquery/tests/test_cost.py b/src/ifcquery/tests/test_cost.py new file mode 100644 index 0000000000..1011beca0a --- /dev/null +++ b/src/ifcquery/tests/test_cost.py @@ -0,0 +1,108 @@ +# This file was generated with the assistance of an AI coding tool. +from __future__ import annotations + +import ifcopenshell +import ifcopenshell.api.cost +import ifcopenshell.api.owner.settings +import ifcopenshell.api.project +import ifcopenshell.api.root +import ifcopenshell.api.unit +import pytest + +from ifcquery.cost import cost + + +@pytest.fixture +def cost_model(): + """Create an IFC4 model with a cost schedule, a top-level item, and one nested subitem.""" + f = ifcopenshell.api.project.create_file() + ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0] + ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0] + + project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="TestProject") + ifcopenshell.api.unit.assign_unit(f) + + cs = ifcopenshell.api.cost.add_cost_schedule(f, name="Bill of Quantities") + item = ifcopenshell.api.cost.add_cost_item(f, cost_schedule=cs) + ifcopenshell.api.cost.edit_cost_item(f, cost_item=item, attributes={"Name": "Concrete Works"}) + cv = ifcopenshell.api.cost.add_cost_value(f, parent=item) + ifcopenshell.api.cost.edit_cost_value(f, cost_value=cv, attributes={"AppliedValue": 1200.0, "Category": "material"}) + + # Add a nested subitem + subitem = ifcopenshell.api.cost.add_cost_item(f, cost_item=item) + ifcopenshell.api.cost.edit_cost_item(f, cost_item=subitem, attributes={"Name": "Formwork"}) + + return f + + +class TestCost: + def test_returns_list(self, cost_model): + result = cost(cost_model) + assert isinstance(result, list) + + def test_finds_cost_schedule(self, cost_model): + result = cost(cost_model) + assert len(result) == 1 + + def test_schedule_has_name(self, cost_model): + result = cost(cost_model) + assert result[0]["name"] == "Bill of Quantities" + + def test_schedule_has_id(self, cost_model): + result = cost(cost_model) + assert isinstance(result[0]["id"], int) + assert result[0]["id"] > 0 + + def test_schedule_has_items(self, cost_model): + result = cost(cost_model) + assert len(result[0]["items"]) == 1 + + def test_item_has_required_fields(self, cost_model): + result = cost(cost_model) + item = result[0]["items"][0] + assert "id" in item + assert "name" in item + assert "values" in item + assert "subitems" in item + + def test_item_name(self, cost_model): + result = cost(cost_model) + assert result[0]["items"][0]["name"] == "Concrete Works" + + def test_item_has_values(self, cost_model): + result = cost(cost_model) + values = result[0]["items"][0]["values"] + assert len(values) == 1 + assert "formula" in values[0] + assert "category" in values[0] + + def test_item_value_category(self, cost_model): + result = cost(cost_model) + values = result[0]["items"][0]["values"] + assert values[0]["category"] == "material" + + def test_empty_model_returns_empty_list(self, model): + result = cost(model) + assert result == [] + + def test_max_depth_none_returns_full_tree(self, cost_model): + result = cost(cost_model, max_depth=None) + item = result[0]["items"][0] + assert isinstance(item["subitems"], list) + assert len(item["subitems"]) == 1 + assert item["subitems"][0]["name"] == "Formwork" + + def test_max_depth_1_truncates_subitems(self, cost_model): + result = cost(cost_model, max_depth=1) + item = result[0]["items"][0] + assert isinstance(item["subitems"], dict) + assert item["subitems"]["truncated"] is True + assert item["subitems"]["count"] == 1 + + def test_max_depth_2_expands_to_depth_2(self, cost_model): + result = cost(cost_model, max_depth=2) + item = result[0]["items"][0] + assert isinstance(item["subitems"], list) + assert item["subitems"][0]["name"] == "Formwork" + # subitem has no children, so subitems should be empty list + assert item["subitems"][0]["subitems"] == [] diff --git a/src/ifcquery/tests/test_info.py b/src/ifcquery/tests/test_info.py new file mode 100644 index 0000000000..c17331a24d --- /dev/null +++ b/src/ifcquery/tests/test_info.py @@ -0,0 +1,112 @@ +# This file was generated with the assistance of an AI coding tool. +import ifcopenshell +import ifcopenshell.api.context +import ifcopenshell.api.geometry +import ifcopenshell.api.project +import ifcopenshell.api.root +import ifcopenshell.api.unit +import ifcopenshell.util.representation +import ifcopenshell.util.shape_builder + +from ifcquery.info import info + + +class TestInfo: + def test_basic_attributes(self, model): + wall = model.by_type("IfcWall")[0] + result = info(model, wall) + assert result["id"] == wall.id() + assert result["type"] == "IfcWall" + assert result["attributes"]["Name"] == "Wall001" + + def test_container(self, model): + wall = model.by_type("IfcWall")[0] + result = info(model, wall) + assert result["container"]["type"] == "IfcBuildingStorey" + assert result["container"]["name"] == "Ground Floor" + + def test_project_info(self, model): + project = model.by_type("IfcProject")[0] + result = info(model, project) + assert result["type"] == "IfcProject" + assert result["attributes"]["Name"] == "TestProject" + + def test_all_attributes_serializable(self, model): + """All attribute values should be JSON-serializable (no entity instances).""" + import json + + wall = model.by_type("IfcWall")[0] + result = info(model, wall) + # Should not raise + json.dumps(result) + + def test_no_geometry_summary_without_representation(self, model): + wall = model.by_type("IfcWall")[0] + result = info(model, wall) + assert "geometry_summary" not in result + + +class TestGeometrySummary: + def _make_model_with_wall(self): + f = ifcopenshell.api.project.create_file() + ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject") + ifcopenshell.api.unit.assign_unit(f) + model_ctx = ifcopenshell.api.context.add_context(f, context_type="Model") + ifcopenshell.api.context.add_context( + f, + context_type="Model", + context_identifier="Body", + target_view="MODEL_VIEW", + parent=model_ctx, + ) + wall = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="W1") + ifcopenshell.api.geometry.edit_object_placement(f, product=wall) + return f, wall + + def _body_context(self, f): + return ifcopenshell.util.representation.get_context(f, "Model", "Body", "MODEL_VIEW") + + def test_swept_solid_summary(self): + f, wall = self._make_model_with_wall() + body = self._body_context(f) + rep = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=5.0, height=3.0, thickness=0.2) + ifcopenshell.api.geometry.assign_representation(f, product=wall, representation=rep) + result = info(f, wall) + gs = result["geometry_summary"] + assert gs["representation_type"] == "SweptSolid" + assert len(gs["solids"]) == 1 + solid = gs["solids"][0] + assert solid["depth"] == 3000.0 # stored in project units (mm) + assert solid["profile"]["type"] == "IfcArbitraryClosedProfileDef" + assert len(solid["profile"]["points"]) == 5 # closed polyline + + def test_clipping_summary(self): + f, wall = self._make_model_with_wall() + body = self._body_context(f) + rep = ifcopenshell.api.geometry.add_wall_representation( + f, + context=body, + length=5.0, + height=4.0, + thickness=0.2, + clippings=[{"location": (0.0, 0.0, 3.0), "normal": (0.0, 0.0, 1.0)}], + ) + ifcopenshell.api.geometry.assign_representation(f, product=wall, representation=rep) + result = info(f, wall) + gs = result["geometry_summary"] + assert gs["representation_type"] == "Clipping" + solid = gs["solids"][0] + assert len(solid["clipping_planes"]) == 1 + plane = solid["clipping_planes"][0] + assert plane["location"][2] == 3000.0 # stored in project units (mm) + assert plane["normal"] == [0.0, 0.0, 1.0] + + def test_geometry_summary_json_serializable(self): + import json + + f, wall = self._make_model_with_wall() + body = self._body_context(f) + rep = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=5.0, height=3.0, thickness=0.2) + ifcopenshell.api.geometry.assign_representation(f, product=wall, representation=rep) + result = info(f, wall) + json.dumps(result) diff --git a/src/ifcquery/tests/test_main.py b/src/ifcquery/tests/test_main.py new file mode 100644 index 0000000000..164b93152d --- /dev/null +++ b/src/ifcquery/tests/test_main.py @@ -0,0 +1,121 @@ +# This file was generated with the assistance of an AI coding tool. +import json +import os +import subprocess +import sys +import tempfile + +import ifcopenshell +import ifcopenshell.api.project +import pytest + + +@pytest.fixture +def ifc_path(model): + """Write the model fixture to a temp file and return its path.""" + with tempfile.NamedTemporaryFile(suffix=".ifc", delete=False) as f: + model.write(f.name) + yield f.name + os.unlink(f.name) + + +def run_ifcquery(*args): + """Run ifcquery as a subprocess and return (returncode, stdout, stderr).""" + result = subprocess.run( + [sys.executable, "-m", "ifcquery", *args], + capture_output=True, + text=True, + ) + return result.returncode, result.stdout, result.stderr + + +class TestCLI: + def test_summary_json(self, ifc_path): + rc, stdout, stderr = run_ifcquery(ifc_path, "summary") + assert rc == 0 + data = json.loads(stdout) + assert data["schema"] == "IFC4" + assert "types" in data + + def test_tree_json(self, ifc_path): + rc, stdout, stderr = run_ifcquery(ifc_path, "tree") + assert rc == 0 + data = json.loads(stdout) + assert data["type"] == "IfcProject" + + def test_info_json(self, ifc_path, model): + wall = model.by_type("IfcWall")[0] + rc, stdout, stderr = run_ifcquery(ifc_path, "info", str(wall.id())) + assert rc == 0 + data = json.loads(stdout) + assert data["type"] == "IfcWall" + + def test_info_hash_id(self, ifc_path, model): + wall = model.by_type("IfcWall")[0] + rc, stdout, stderr = run_ifcquery(ifc_path, "info", f"#{wall.id()}") + assert rc == 0 + data = json.loads(stdout) + assert data["type"] == "IfcWall" + + def test_select_json(self, ifc_path): + rc, stdout, stderr = run_ifcquery(ifc_path, "select", "IfcWall") + assert rc == 0 + data = json.loads(stdout) + assert len(data) == 1 + assert data[0]["type"] == "IfcWall" + + def test_text_format(self, ifc_path): + rc, stdout, stderr = run_ifcquery(ifc_path, "--format", "text", "summary") + assert rc == 0 + assert "schema:" in stdout + + def test_bad_file(self): + rc, stdout, stderr = run_ifcquery("/nonexistent.ifc", "summary") + assert rc != 0 + assert "Error" in stderr + + def test_bad_element_id(self, ifc_path): + rc, stdout, stderr = run_ifcquery(ifc_path, "info", "999999") + assert rc != 0 + assert "Error" in stderr + + def test_no_command(self, ifc_path): + rc, stdout, stderr = run_ifcquery(ifc_path) + assert rc != 0 + + def test_select_ids_format(self, ifc_path): + rc, stdout, stderr = run_ifcquery(ifc_path, "--format", "ids", "select", "IfcWall") + assert rc == 0 + # Should be a comma-separated string of integers with no surrounding whitespace + ids = stdout.strip() + assert ids != "" + for part in ids.split(","): + assert part.isdigit() + + def test_select_ids_format_multiple(self, ifc_path, model): + rc, stdout, stderr = run_ifcquery(ifc_path, "--format", "ids", "select", "IfcElement") + assert rc == 0 + ids = stdout.strip().split(",") + assert len(ids) >= 2 + + def test_ids_format_empty_result(self, ifc_path): + rc, stdout, stderr = run_ifcquery(ifc_path, "--format", "ids", "select", "IfcDoor") + assert rc == 0 + assert stdout.strip() == "" + + def test_relations_ids_format(self, ifc_path, model): + storey = model.by_type("IfcBuildingStorey")[0] + rc, stdout, stderr = run_ifcquery(ifc_path, "--format", "ids", "relations", str(storey.id())) + assert rc == 0 + ids = stdout.strip().split(",") + assert all(i.isdigit() for i in ids) + # should include the storey itself and its contained elements + assert str(storey.id()) in ids + wall_id = str(model.by_type("IfcWall")[0].id()) + assert wall_id in ids + + def test_info_ids_format(self, ifc_path, model): + wall = model.by_type("IfcWall")[0] + rc, stdout, stderr = run_ifcquery(ifc_path, "--format", "ids", "info", str(wall.id())) + assert rc == 0 + assert stdout.strip() == str(wall.id()) diff --git a/src/ifcquery/tests/test_materials.py b/src/ifcquery/tests/test_materials.py new file mode 100644 index 0000000000..aa6886156f --- /dev/null +++ b/src/ifcquery/tests/test_materials.py @@ -0,0 +1,52 @@ +import ifcopenshell.api.material +import ifcopenshell.api.project + +from ifcquery.materials import materials + + +class TestMaterials: + def test_empty_model(self, model): + result = materials(model) + assert isinstance(result, list) + assert len(result) == 0 + + def test_single_material(self, model): + ifcopenshell.api.material.add_material(model, name="Concrete", category="concrete") + result = materials(model) + assert len(result) == 1 + m = result[0] + assert m["type"] == "IfcMaterial" + assert m["name"] == "Concrete" + assert m["category"] == "concrete" + assert isinstance(m["id"], int) + + def test_material_layer_set(self, model): + mat = ifcopenshell.api.material.add_material(model, name="Brick") + layer_set = ifcopenshell.api.material.add_material_set(model, name="BrickSet", set_type="IfcMaterialLayerSet") + ifcopenshell.api.material.add_layer(model, layer_set=layer_set, material=mat) + result = materials(model) + layer_sets = [e for e in result if e["type"] == "IfcMaterialLayerSet"] + assert len(layer_sets) == 1 + ls = layer_sets[0] + assert ls["name"] == "BrickSet" + assert isinstance(ls["layers"], list) + assert len(ls["layers"]) == 1 + layer = ls["layers"][0] + assert layer["material"] == "Brick" + + def test_material_constituent_set(self, model): + mat = ifcopenshell.api.material.add_material(model, name="Steel") + cs = ifcopenshell.api.material.add_material_set(model, name="CompSet", set_type="IfcMaterialConstituentSet") + ifcopenshell.api.material.add_constituent(model, constituent_set=cs, material=mat) + result = materials(model) + constituent_sets = [e for e in result if e["type"] == "IfcMaterialConstituentSet"] + assert len(constituent_sets) == 1 + entry = constituent_sets[0] + assert entry["name"] == "CompSet" + assert isinstance(entry["constituents"], list) + + def test_ids_are_integers(self, model): + ifcopenshell.api.material.add_material(model, name="Wood") + result = materials(model) + for entry in result: + assert isinstance(entry["id"], int) diff --git a/src/ifcquery/tests/test_plot.py b/src/ifcquery/tests/test_plot.py new file mode 100644 index 0000000000..4c003ab946 --- /dev/null +++ b/src/ifcquery/tests/test_plot.py @@ -0,0 +1,265 @@ +from __future__ import annotations + +import base64 +import os +import subprocess +import sys +import tempfile + +import ifcopenshell +import ifcopenshell.api.aggregate +import ifcopenshell.api.context +import ifcopenshell.api.geometry +import ifcopenshell.api.owner.settings +import ifcopenshell.api.project +import ifcopenshell.api.root +import ifcopenshell.api.spatial +import ifcopenshell.api.unit +import pytest + +from ifcquery.plot import _highlight_css_from_ids, plot + +try: + import ifcopenshell.draw # noqa: F401 + + HAS_DRAW = True +except ImportError: + HAS_DRAW = False + +try: + import cairosvg # noqa: F401 + + HAS_CAIROSVG = True +except ImportError: + HAS_CAIROSVG = False + +pytestmark = pytest.mark.skipif(not HAS_DRAW, reason="ifcopenshell.draw not available") + +SVG_MAGIC = b" elements, PNG/base64 should raise a clear error.""" + + def test_empty_drawing_png_raises(self, model_no_plan): + """PNG format raises ValueError (not silently returns None) for empty drawings.""" + model, _ = model_no_plan + svg = plot(model, output_format="svg") + has_groups = b"" in svg + if not has_groups: + pytest.raises(ValueError, plot, model, output_format="png") + else: + pytest.skip("Model produced non-empty SVG — empty path not triggered") + + def test_empty_drawing_base64_raises(self, model_no_plan): + """base64 format raises ValueError (not silently returns None) for empty drawings.""" + model, _ = model_no_plan + svg = plot(model, output_format="svg") + has_groups = b"" in svg + if not has_groups: + pytest.raises(ValueError, plot, model, output_format="base64") + else: + pytest.skip("Model produced non-empty SVG — empty path not triggered") + + +@pytest.mark.skipif(not HAS_CAIROSVG, reason="cairosvg not installed") +class TestPlotPNG: + """PNG and base64 require cairosvg.""" + + def test_png_returns_bytes_or_raises_on_empty(self, model_with_annotations): + model, _ = model_with_annotations + svg = plot(model, output_format="svg") + has_groups = b"" in svg + if has_groups: + result = plot(model, output_format="png") + assert isinstance(result, bytes) + assert result[:4] == PNG_MAGIC + else: + with pytest.raises(ValueError, match="No plan geometry"): + plot(model, output_format="png") + + def test_base64_returns_dict(self, model_with_annotations): + model, _ = model_with_annotations + svg = plot(model, output_format="svg") + has_groups = b"" in svg + if has_groups: + result = plot(model, output_format="base64") + assert isinstance(result, dict) + assert result["mime"] == "image/png" + assert "png_b64" in result + assert "width" in result + assert "height" in result + assert "view" in result + # Verify the base64 is valid PNG + decoded = base64.b64decode(result["png_b64"]) + assert decoded[:4] == PNG_MAGIC + else: + with pytest.raises(ValueError, match="No plan geometry"): + plot(model, output_format="base64") + + def test_base64_view_field_matches_requested(self, model_with_annotations): + model, _ = model_with_annotations + svg = plot(model, output_format="svg") + has_groups = b"" in svg + if not has_groups: + pytest.skip("Model produces empty SVG") + result = plot(model, output_format="base64", view="floorplan") + assert result["view"] == "floorplan" + + def test_png_custom_size(self, model_with_annotations): + model, _ = model_with_annotations + svg = plot(model, output_format="svg") + has_groups = b"" in svg + if not has_groups: + pytest.skip("Model produces empty SVG") + result = plot(model, output_format="png", png_width=512, png_height=512) + assert isinstance(result, bytes) + assert result[:4] == PNG_MAGIC + + +class TestCLI: + @staticmethod + def _ifc_path(model): + f = tempfile.NamedTemporaryFile(suffix=".ifc", delete=False) + model.write(f.name) + f.close() + return f.name + + def test_plot_svg_writes_file(self, model_with_annotations): + model, _ = model_with_annotations + ifc_path = self._ifc_path(model) + out_path = ifc_path.replace(".ifc", "_out.svg") + try: + result = subprocess.run( + [sys.executable, "-m", "ifcquery", ifc_path, "plot", "--out-format", "svg", "-o", out_path], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + assert os.path.exists(out_path) + with open(out_path, "rb") as f: + assert f.read(5) == SVG_MAGIC + finally: + for path in (ifc_path, out_path): + try: + os.unlink(path) + except OSError: + pass + + @pytest.mark.skipif(not HAS_CAIROSVG, reason="cairosvg not installed") + def test_plot_base64_prints_json(self, model_with_annotations): + """base64 format prints JSON to stdout instead of writing a file.""" + model, _ = model_with_annotations + ifc_path = self._ifc_path(model) + try: + # First check if the model would produce geometry + svg = plot(model, output_format="svg") + has_groups = b"" in svg + if not has_groups: + pytest.skip("Model produces empty SVG — base64 would raise ValueError") + + result = subprocess.run( + [sys.executable, "-m", "ifcquery", ifc_path, "plot", "--out-format", "base64"], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + # Output should be JSON (not an error) and contain base64 key + assert "png_b64" in result.stdout + finally: + try: + os.unlink(ifc_path) + except OSError: + pass diff --git a/src/ifcquery/tests/test_relations.py b/src/ifcquery/tests/test_relations.py new file mode 100644 index 0000000000..32bc8bf3cb --- /dev/null +++ b/src/ifcquery/tests/test_relations.py @@ -0,0 +1,199 @@ +# This file was generated with the assistance of an AI coding tool. +import json +import os +import subprocess +import sys +import tempfile + +from ifcquery.relations import relations + + +class TestWallRelations: + def test_wall_has_container(self, model): + wall = model.by_type("IfcWall")[0] + result = relations(model, wall) + assert result["id"] == wall.id() + assert result["type"] == "IfcWall" + assert result["hierarchy"]["container"]["type"] == "IfcBuildingStorey" + assert result["hierarchy"]["container"]["name"] == "Ground Floor" + + def test_wall_has_parent(self, model): + wall = model.by_type("IfcWall")[0] + result = relations(model, wall) + assert result["hierarchy"]["parent"]["type"] == "IfcBuildingStorey" + + def test_wall_no_children(self, model): + wall = model.by_type("IfcWall")[0] + result = relations(model, wall) + assert "children" not in result + + def test_wall_empty_categories_omitted(self, model): + wall = model.by_type("IfcWall")[0] + result = relations(model, wall) + assert "groups" not in result + assert "systems" not in result + assert "zones" not in result + assert "connections" not in result + assert "referenced_structures" not in result + + +class TestStoreyRelations: + def test_storey_has_contained(self, model): + storey = model.by_type("IfcBuildingStorey")[0] + result = relations(model, storey) + contained_types = {e["type"] for e in result["children"]["contained"]} + assert "IfcWall" in contained_types + assert "IfcSlab" in contained_types + + def test_storey_has_aggregate_parent(self, model): + storey = model.by_type("IfcBuildingStorey")[0] + result = relations(model, storey) + assert result["hierarchy"]["aggregate"]["type"] == "IfcBuilding" + assert result["hierarchy"]["aggregate"]["name"] == "TestBuilding" + + +class TestProjectRelations: + def test_project_has_parts(self, model): + project = model.by_type("IfcProject")[0] + result = relations(model, project) + parts = result["children"]["parts"] + assert any(p["type"] == "IfcSite" for p in parts) + + def test_project_no_hierarchy(self, model): + project = model.by_type("IfcProject")[0] + result = relations(model, project) + assert "hierarchy" not in result + + +class TestTraverseUp: + def test_wall_to_project(self, model): + wall = model.by_type("IfcWall")[0] + chain = relations(model, wall, traverse="up") + assert isinstance(chain, list) + assert chain[0]["type"] == "IfcWall" + assert chain[-1]["type"] == "IfcProject" + types = [e["type"] for e in chain] + assert "IfcBuildingStorey" in types + assert "IfcBuilding" in types + assert "IfcSite" in types + + def test_project_traverse(self, model): + project = model.by_type("IfcProject")[0] + chain = relations(model, project, traverse="up") + assert len(chain) == 1 + assert chain[0]["type"] == "IfcProject" + + def test_storey_to_project(self, model): + storey = model.by_type("IfcBuildingStorey")[0] + chain = relations(model, storey, traverse="up") + assert chain[0]["type"] == "IfcBuildingStorey" + assert chain[-1]["type"] == "IfcProject" + assert len(chain) == 4 # storey -> building -> site -> project + + +class TestElementsSummary: + def test_wall_elements_includes_self(self, model): + wall = model.by_type("IfcWall")[0] + result = relations(model, wall) + ids = [e["id"] for e in result["elements"]] + assert wall.id() in ids + + def test_wall_elements_includes_container(self, model): + wall = model.by_type("IfcWall")[0] + result = relations(model, wall) + ids = [e["id"] for e in result["elements"]] + storey = model.by_type("IfcBuildingStorey")[0] + assert storey.id() in ids + + def test_storey_elements_includes_contained(self, model): + storey = model.by_type("IfcBuildingStorey")[0] + result = relations(model, storey) + ids = [e["id"] for e in result["elements"]] + wall = model.by_type("IfcWall")[0] + assert wall.id() in ids + + def test_elements_no_duplicates(self, model): + storey = model.by_type("IfcBuildingStorey")[0] + result = relations(model, storey) + ids = [e["id"] for e in result["elements"]] + assert len(ids) == len(set(ids)) + + def test_elements_all_have_id_and_type(self, model): + wall = model.by_type("IfcWall")[0] + result = relations(model, wall) + for e in result["elements"]: + assert "id" in e + assert "type" in e + + def test_traverse_up_has_no_elements_field(self, model): + wall = model.by_type("IfcWall")[0] + result = relations(model, wall, traverse="up") + assert isinstance(result, list) + assert not any("elements" in item for item in result) + + +class TestJsonSerializable: + def test_relations_serializable(self, model): + wall = model.by_type("IfcWall")[0] + result = relations(model, wall) + json.dumps(result) + + def test_traverse_serializable(self, model): + wall = model.by_type("IfcWall")[0] + result = relations(model, wall, traverse="up") + json.dumps(result) + + +class TestCLI: + @staticmethod + def _ifc_path(model): + f = tempfile.NamedTemporaryFile(suffix=".ifc", delete=False) + model.write(f.name) + f.close() + return f.name + + def test_relations_json(self, model): + path = self._ifc_path(model) + try: + wall = model.by_type("IfcWall")[0] + result = subprocess.run( + [sys.executable, "-m", "ifcquery", path, "relations", str(wall.id())], + capture_output=True, + text=True, + ) + assert result.returncode == 0 + data = json.loads(result.stdout) + assert data["type"] == "IfcWall" + assert "hierarchy" in data + finally: + os.unlink(path) + + def test_relations_traverse_up(self, model): + path = self._ifc_path(model) + try: + wall = model.by_type("IfcWall")[0] + result = subprocess.run( + [sys.executable, "-m", "ifcquery", path, "relations", str(wall.id()), "--traverse", "up"], + capture_output=True, + text=True, + ) + assert result.returncode == 0 + data = json.loads(result.stdout) + assert isinstance(data, list) + assert data[0]["type"] == "IfcWall" + assert data[-1]["type"] == "IfcProject" + finally: + os.unlink(path) + + def test_relations_bad_id(self, model): + path = self._ifc_path(model) + try: + result = subprocess.run( + [sys.executable, "-m", "ifcquery", path, "relations", "999999"], + capture_output=True, + text=True, + ) + assert result.returncode != 0 + assert "Error" in result.stderr + finally: + os.unlink(path) diff --git a/src/ifcquery/tests/test_render.py b/src/ifcquery/tests/test_render.py new file mode 100644 index 0000000000..28d83a6b68 --- /dev/null +++ b/src/ifcquery/tests/test_render.py @@ -0,0 +1,355 @@ +# This file was generated with the assistance of an AI coding tool. +import os +import subprocess +import sys +import tempfile + +import ifcopenshell +import ifcopenshell.api.aggregate +import ifcopenshell.api.context +import ifcopenshell.api.geometry +import ifcopenshell.api.owner.settings +import ifcopenshell.api.project +import ifcopenshell.api.root +import ifcopenshell.api.spatial +import ifcopenshell.api.unit +import ifcopenshell.guid +import numpy as np +import pytest + +from ifcquery.render import _make_profile_occurrence, _make_type_occurrence, render + +try: + import pyvista # noqa: F401 + + HAS_PYVISTA = True +except ImportError: + HAS_PYVISTA = False + +pytestmark = pytest.mark.skipif(not HAS_PYVISTA, reason="pyvista not installed") + +PNG_MAGIC = b"\x89PNG" + + +@pytest.fixture +def model_with_geometry(): + """Create an IFC4 model with walls that have geometric representations.""" + f = ifcopenshell.api.project.create_file() + ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0] + ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0] + + project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="TestProject") + ifcopenshell.api.unit.assign_unit(f) + + site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="TestSite") + building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="TestBuilding") + storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="Ground Floor") + + ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project) + ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site) + ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=building) + + model_ctx = ifcopenshell.api.context.add_context(f, context_type="Model") + body = ifcopenshell.api.context.add_context( + f, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=model_ctx + ) + + wall1 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall001") + rep1 = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=5, height=3, thickness=0.2) + ifcopenshell.api.geometry.assign_representation(f, product=wall1, representation=rep1) + ifcopenshell.api.spatial.assign_container(f, products=[wall1], relating_structure=storey) + + wall2 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall002") + rep2 = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=4, height=3, thickness=0.2) + ifcopenshell.api.geometry.assign_representation(f, product=wall2, representation=rep2) + ifcopenshell.api.spatial.assign_container(f, products=[wall2], relating_structure=storey) + matrix2 = np.eye(4) + matrix2[1, 3] = 3.0 + ifcopenshell.api.geometry.edit_object_placement(f, product=wall2, matrix=matrix2) + + return f + + +@pytest.fixture +def library_with_type(): + """IFC4 library file: a WallType with a RepresentationMap but no instances.""" + f = ifcopenshell.api.project.create_file() + ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0] + ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0] + + project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="LibProject") + ifcopenshell.api.unit.assign_unit(f) + + model_ctx = ifcopenshell.api.context.add_context(f, context_type="Model") + body = ifcopenshell.api.context.add_context( + f, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=model_ctx + ) + + # Build the shape representation and wrap it in an IfcRepresentationMap. + shape_rep = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=3, height=2.5, thickness=0.2) + origin = f.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0)) + z_dir = f.create_entity("IfcDirection", DirectionRatios=(0.0, 0.0, 1.0)) + x_dir = f.create_entity("IfcDirection", DirectionRatios=(1.0, 0.0, 0.0)) + map_origin = f.create_entity("IfcAxis2Placement3D", Location=origin, Axis=z_dir, RefDirection=x_dir) + rep_map = f.create_entity("IfcRepresentationMap", MappingOrigin=map_origin, MappedRepresentation=shape_rep) + + wall_type = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWallType", name="LibWallType") + wall_type.RepresentationMaps = [rep_map] + + return f, wall_type + + +@pytest.fixture +def library_with_profile_type(): + """IFC4 library: a BeamType with an IfcMaterialProfileSet but no RepresentationMaps.""" + f = ifcopenshell.api.project.create_file() + ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0] + ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0] + + project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="ProfileLibProject") + ifcopenshell.api.unit.assign_unit(f) + + model_ctx = ifcopenshell.api.context.add_context(f, context_type="Model") + ifcopenshell.api.context.add_context( + f, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=model_ctx + ) + + # Rectangular profile 0.2m x 0.3m + profile = f.create_entity( + "IfcRectangleProfileDef", + ProfileType="AREA", + ProfileName="200x300", + XDim=0.2, + YDim=0.3, + ) + material = f.create_entity("IfcMaterial", Name="Steel") + mat_profile = f.create_entity("IfcMaterialProfile", Material=material, Profile=profile) + profile_set = f.create_entity("IfcMaterialProfileSet", MaterialProfiles=[mat_profile]) + + beam_type = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBeamType", name="200x300 Steel Beam") + rel = f.create_entity( + "IfcRelAssociatesMaterial", + GlobalId=ifcopenshell.guid.new(), + RelatedObjects=[beam_type], + RelatingMaterial=profile_set, + ) + + return f, beam_type + + +class TestRenderBasic: + def test_returns_png_bytes(self, model_with_geometry): + result = render(model_with_geometry) + assert isinstance(result, bytes) + assert result[:4] == PNG_MAGIC + + def test_iso_view(self, model_with_geometry): + result = render(model_with_geometry, view="iso") + assert result[:4] == PNG_MAGIC + + def test_top_view(self, model_with_geometry): + result = render(model_with_geometry, view="top") + assert result[:4] == PNG_MAGIC + + def test_south_view(self, model_with_geometry): + result = render(model_with_geometry, view="south") + assert result[:4] == PNG_MAGIC + + def test_unknown_view_falls_back_to_iso(self, model_with_geometry): + # Unknown view strings fall through to isometric + result = render(model_with_geometry, view="diagonal") + assert result[:4] == PNG_MAGIC + + +class TestRenderSelector: + def test_selector_restricts_elements(self, model_with_geometry): + result = render(model_with_geometry, selector="IfcWall") + assert result[:4] == PNG_MAGIC + + def test_selector_no_match_raises(self, model_with_geometry): + with pytest.raises(ValueError, match="matched no elements"): + render(model_with_geometry, selector="IfcDoor") + + +class TestRenderHighlight: + def test_highlight_single_element(self, model_with_geometry): + wall = model_with_geometry.by_type("IfcWall")[0] + result = render(model_with_geometry, element_ids=[wall.id()]) + assert result[:4] == PNG_MAGIC + + def test_highlight_multiple_elements(self, model_with_geometry): + walls = model_with_geometry.by_type("IfcWall") + result = render(model_with_geometry, element_ids=[w.id() for w in walls]) + assert result[:4] == PNG_MAGIC + + +class TestRenderTypes: + def test_render_type_by_selector(self, library_with_type): + """Selecting a type class renders its RepresentationMap geometry.""" + model, wall_type = library_with_type + result = render(model, selector="IfcWallType") + assert result[:4] == PNG_MAGIC + + def test_render_type_by_element_id(self, library_with_type): + """Passing a type step-ID via element_ids renders it highlighted.""" + model, wall_type = library_with_type + result = render(model, element_ids=[wall_type.id()]) + assert result[:4] == PNG_MAGIC + + def test_original_model_unmodified(self, library_with_type): + """Rendering a type must not add entities to the original model.""" + model, wall_type = library_with_type + entity_count_before = len(list(model)) + render(model, selector="IfcWallType") + assert len(list(model)) == entity_count_before + + def test_make_type_occurrence_no_rep_maps(self, library_with_type): + """_make_type_occurrence returns None for a type with no RepresentationMaps.""" + model, _ = library_with_type + bare_type = ifcopenshell.api.root.create_entity(model, ifc_class="IfcWallType", name="Bare") + assert _make_type_occurrence(model, bare_type) is None + + def test_type_without_rep_maps_raises(self): + """Selecting a type that has no RepresentationMaps raises ValueError.""" + f = ifcopenshell.api.project.create_file() + ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0] + ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0] + ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="P") + ifcopenshell.api.unit.assign_unit(f) + ifcopenshell.api.root.create_entity(f, ifc_class="IfcWallType", name="Bare") + with pytest.raises(ValueError): + render(f, selector="IfcWallType") + + +class TestRenderProfileTypes: + def test_render_profile_type_by_element_id(self, library_with_profile_type): + """A type with only a material profile set renders via temporary extrusion.""" + model, beam_type = library_with_profile_type + result = render(model, element_ids=[beam_type.id()]) + assert result[:4] == PNG_MAGIC + + def test_make_profile_occurrence_creates_occurrence(self, library_with_profile_type): + """_make_profile_occurrence returns an occurrence entity for a profile-set type.""" + model, beam_type = library_with_profile_type + occ = _make_profile_occurrence(model, beam_type) + assert occ is not None + + def test_make_profile_occurrence_no_profile_returns_none(self, library_with_type): + """_make_profile_occurrence returns None when type has no material profile set.""" + model, wall_type = library_with_type + # wall_type has RepresentationMaps but no material profile set + occ = _make_profile_occurrence(model, wall_type) + assert occ is None + + def test_original_model_unmodified_for_profile_type(self, library_with_profile_type): + """Rendering a profile-based type does not modify the original model.""" + model, beam_type = library_with_profile_type + entity_count_before = len(list(model)) + render(model, element_ids=[beam_type.id()]) + assert len(list(model)) == entity_count_before + + +class TestRenderNoGeometry: + def test_no_geometry_raises(self): + """A model without geometry representations raises ValueError.""" + f = ifcopenshell.api.project.create_file() + ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0] + ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0] + project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="P") + ifcopenshell.api.unit.assign_unit(f) + site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="S") + building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="B") + storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="GF") + ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project) + ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site) + ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=building) + wall = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wallless") + ifcopenshell.api.spatial.assign_container(f, products=[wall], relating_structure=storey) + + with pytest.raises(ValueError, match="No renderable geometry"): + render(f) + + +class TestCLI: + @staticmethod + def _ifc_path(model): + f = tempfile.NamedTemporaryFile(suffix=".ifc", delete=False) + model.write(f.name) + f.close() + return f.name + + def test_render_writes_png(self, model_with_geometry): + ifc_path = self._ifc_path(model_with_geometry) + out_path = ifc_path.replace(".ifc", "_out.png") + try: + result = subprocess.run( + [sys.executable, "-m", "ifcquery", ifc_path, "render", "-o", out_path], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + assert os.path.exists(out_path) + with open(out_path, "rb") as f: + assert f.read(4) == PNG_MAGIC + finally: + for path in (ifc_path, out_path): + try: + os.unlink(path) + except OSError: + pass + + def test_render_default_output_path(self, model_with_geometry): + ifc_path = self._ifc_path(model_with_geometry) + expected_png = ifc_path.replace(".ifc", ".png") + try: + result = subprocess.run( + [sys.executable, "-m", "ifcquery", ifc_path, "render"], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + assert os.path.exists(expected_png) + finally: + for path in (ifc_path, expected_png): + try: + os.unlink(path) + except OSError: + pass + + def test_render_with_selector(self, model_with_geometry): + ifc_path = self._ifc_path(model_with_geometry) + out_path = ifc_path.replace(".ifc", "_sel.png") + try: + result = subprocess.run( + [sys.executable, "-m", "ifcquery", ifc_path, "render", "-o", out_path, "--selector", "IfcWall"], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + with open(out_path, "rb") as f: + assert f.read(4) == PNG_MAGIC + finally: + for path in (ifc_path, out_path): + try: + os.unlink(path) + except OSError: + pass + + def test_render_with_view(self, model_with_geometry): + ifc_path = self._ifc_path(model_with_geometry) + out_path = ifc_path.replace(".ifc", "_top.png") + try: + result = subprocess.run( + [sys.executable, "-m", "ifcquery", ifc_path, "render", "-o", out_path, "--view", "top"], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + with open(out_path, "rb") as f: + assert f.read(4) == PNG_MAGIC + finally: + for path in (ifc_path, out_path): + try: + os.unlink(path) + except OSError: + pass diff --git a/src/ifcquery/tests/test_schedule.py b/src/ifcquery/tests/test_schedule.py new file mode 100644 index 0000000000..0d3a3e934f --- /dev/null +++ b/src/ifcquery/tests/test_schedule.py @@ -0,0 +1,121 @@ +# This file was generated with the assistance of an AI coding tool. +from __future__ import annotations + +import ifcopenshell +import ifcopenshell.api.aggregate +import ifcopenshell.api.owner.settings +import ifcopenshell.api.project +import ifcopenshell.api.root +import ifcopenshell.api.sequence +import ifcopenshell.api.unit +import pytest + +from ifcquery.schedule import schedule + + +@pytest.fixture +def schedule_model(): + """Create an IFC4 model with a work schedule and nested tasks.""" + f = ifcopenshell.api.project.create_file() + ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0] + ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0] + + project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="TestProject") + ifcopenshell.api.unit.assign_unit(f) + + ws = ifcopenshell.api.sequence.add_work_schedule(f, name="Construction Schedule") + + task1 = ifcopenshell.api.sequence.add_task(f, work_schedule=ws, name="Phase 1", identification="P1") + tt1 = ifcopenshell.api.sequence.add_task_time(f, task=task1) + ifcopenshell.api.sequence.edit_task_time( + f, task_time=tt1, attributes={"ScheduleStart": "2024-01-01", "ScheduleFinish": "2024-06-30"} + ) + + task2 = ifcopenshell.api.sequence.add_task(f, work_schedule=ws, name="Phase 2", identification="P2") + subtask = ifcopenshell.api.sequence.add_task(f, parent_task=task1, name="Sub Task", identification="S1") + + return f + + +class TestSchedule: + def test_returns_list(self, schedule_model): + result = schedule(schedule_model) + assert isinstance(result, list) + + def test_finds_work_schedule(self, schedule_model): + result = schedule(schedule_model) + assert len(result) == 1 + + def test_work_schedule_has_name(self, schedule_model): + result = schedule(schedule_model) + assert result[0]["name"] == "Construction Schedule" + + def test_work_schedule_has_id(self, schedule_model): + result = schedule(schedule_model) + assert isinstance(result[0]["id"], int) + assert result[0]["id"] > 0 + + def test_work_schedule_has_tasks(self, schedule_model): + result = schedule(schedule_model) + tasks = result[0]["tasks"] + assert isinstance(tasks, list) + assert len(tasks) >= 1 + + def test_task_has_required_fields(self, schedule_model): + result = schedule(schedule_model) + task = result[0]["tasks"][0] + assert "id" in task + assert "name" in task + assert "start" in task + assert "finish" in task + assert "is_milestone" in task + assert "outputs" in task + assert "subtasks" in task + + def test_task_name(self, schedule_model): + result = schedule(schedule_model) + task_names = [t["name"] for t in result[0]["tasks"]] + assert "Phase 1" in task_names + + def test_task_start_finish(self, schedule_model): + result = schedule(schedule_model) + phase1 = next(t for t in result[0]["tasks"] if t["name"] == "Phase 1") + assert phase1["start"] is not None + assert phase1["finish"] is not None + + def test_subtasks(self, schedule_model): + result = schedule(schedule_model) + phase1 = next(t for t in result[0]["tasks"] if t["name"] == "Phase 1") + assert len(phase1["subtasks"]) == 1 + assert phase1["subtasks"][0]["name"] == "Sub Task" + + def test_empty_model_returns_empty_list(self, model): + result = schedule(model) + assert result == [] + + def test_max_depth_none_returns_full_tree(self, schedule_model): + result = schedule(schedule_model, max_depth=None) + phase1 = next(t for t in result[0]["tasks"] if t["name"] == "Phase 1") + assert isinstance(phase1["subtasks"], list) + assert len(phase1["subtasks"]) == 1 + + def test_max_depth_1_truncates_subtasks(self, schedule_model): + result = schedule(schedule_model, max_depth=1) + phase1 = next(t for t in result[0]["tasks"] if t["name"] == "Phase 1") + assert isinstance(phase1["subtasks"], dict) + assert phase1["subtasks"]["truncated"] is True + assert phase1["subtasks"]["count"] == 1 + + def test_max_depth_truncation_shows_count(self, schedule_model): + result = schedule(schedule_model, max_depth=1) + # Phase 2 has no subtasks — should return empty list, not truncation dict + phase2 = next(t for t in result[0]["tasks"] if t["name"] == "Phase 2") + assert phase2["subtasks"] == [] + + def test_max_depth_2_expands_to_depth_2(self, schedule_model): + result = schedule(schedule_model, max_depth=2) + phase1 = next(t for t in result[0]["tasks"] if t["name"] == "Phase 1") + # subtask at depth 2 should be fully expanded (it has no children) + assert isinstance(phase1["subtasks"], list) + assert phase1["subtasks"][0]["name"] == "Sub Task" + assert phase1["subtasks"][0]["subtasks"] == [] diff --git a/src/ifcquery/tests/test_schema.py b/src/ifcquery/tests/test_schema.py new file mode 100644 index 0000000000..c4f63344ee --- /dev/null +++ b/src/ifcquery/tests/test_schema.py @@ -0,0 +1,32 @@ +# This file was generated with the assistance of an AI coding tool. +from __future__ import annotations + +import pytest + +from ifcquery.schema import schema + + +class TestSchema: + def test_ifc_wall_has_description(self, model): + result = schema(model, "IfcWall") + assert "description" in result + assert isinstance(result["description"], str) + assert len(result["description"]) > 0 + + def test_ifc_wall_has_attributes(self, model): + result = schema(model, "IfcWall") + assert "attributes" in result + + def test_ifc_wall_has_spec_url(self, model): + result = schema(model, "IfcWall") + assert "spec_url" in result + + def test_unknown_entity_returns_error(self, model): + result = schema(model, "IfcNonExistentFooBar") + assert "error" in result + assert "IfcNonExistentFooBar" in result["error"] + + def test_ifc_window_has_description(self, model): + result = schema(model, "IfcWindow") + assert "description" in result + assert len(result["description"]) > 0 diff --git a/src/ifcquery/tests/test_select.py b/src/ifcquery/tests/test_select.py new file mode 100644 index 0000000000..d5700a38e6 --- /dev/null +++ b/src/ifcquery/tests/test_select.py @@ -0,0 +1,32 @@ +# This file was generated with the assistance of an AI coding tool. +from ifcquery.select import select + + +class TestSelect: + def test_select_by_type(self, model): + result = select(model, "IfcWall") + assert len(result) == 1 + assert result[0]["type"] == "IfcWall" + assert result[0]["name"] == "Wall001" + + def test_select_multiple_types(self, model): + result = select(model, "IfcWall, IfcSlab") + assert len(result) == 2 + types = {r["type"] for r in result} + assert types == {"IfcWall", "IfcSlab"} + + def test_select_no_match(self, model): + result = select(model, "IfcDoor") + assert result == [] + + def test_results_sorted_by_id(self, model): + result = select(model, "IfcWall, IfcSlab") + ids = [r["id"] for r in result] + assert ids == sorted(ids) + + def test_result_has_id_type_name(self, model): + result = select(model, "IfcWall") + entry = result[0] + assert "id" in entry + assert "type" in entry + assert "name" in entry diff --git a/src/ifcquery/tests/test_summary.py b/src/ifcquery/tests/test_summary.py new file mode 100644 index 0000000000..c230b48ab7 --- /dev/null +++ b/src/ifcquery/tests/test_summary.py @@ -0,0 +1,34 @@ +# This file was generated with the assistance of an AI coding tool. +import ifcopenshell +import ifcopenshell.api.project + +from ifcquery.summary import summary + + +class TestSummary: + def test_schema(self, model): + result = summary(model) + assert result["schema"] == "IFC4" + + def test_total_entities(self, model): + result = summary(model) + assert result["total_entities"] == len(list(model)) + assert result["total_entities"] > 0 + + def test_project_info(self, model): + result = summary(model) + assert result["project"]["name"] == "TestProject" + + def test_type_counts(self, model): + result = summary(model) + types = result["types"] + assert "IfcWall" in types + assert types["IfcWall"] == 1 + assert "IfcSlab" in types + assert types["IfcSlab"] == 1 + + def test_empty_model(self): + f = ifcopenshell.api.project.create_file() + result = summary(f) + assert result["schema"] == "IFC4" + assert "project" not in result diff --git a/src/ifcquery/tests/test_tree.py b/src/ifcquery/tests/test_tree.py new file mode 100644 index 0000000000..2110be3188 --- /dev/null +++ b/src/ifcquery/tests/test_tree.py @@ -0,0 +1,37 @@ +# This file was generated with the assistance of an AI coding tool. +from ifcquery.tree import tree + + +class TestTree: + def test_root_is_project(self, model): + result = tree(model) + assert result["type"] == "IfcProject" + assert result["name"] == "TestProject" + + def test_spatial_hierarchy(self, model): + result = tree(model) + # Project > Site > Building > Storey + site = result["children"][0] + assert site["type"] == "IfcSite" + assert site["name"] == "TestSite" + + building = site["children"][0] + assert building["type"] == "IfcBuilding" + assert building["name"] == "TestBuilding" + + storey = building["children"][0] + assert storey["type"] == "IfcBuildingStorey" + assert storey["name"] == "Ground Floor" + + def test_contained_elements(self, model): + result = tree(model) + storey = result["children"][0]["children"][0]["children"][0] + elements = storey["elements"] + element_types = {e["type"] for e in elements} + assert "IfcWall" in element_types + assert "IfcSlab" in element_types + + def test_element_ids_present(self, model): + result = tree(model) + assert "id" in result + assert isinstance(result["id"], int) diff --git a/src/ifcquery/tests/test_validate.py b/src/ifcquery/tests/test_validate.py new file mode 100644 index 0000000000..44734ce4b0 --- /dev/null +++ b/src/ifcquery/tests/test_validate.py @@ -0,0 +1,47 @@ +# This file was generated with the assistance of an AI coding tool. +from __future__ import annotations + +import ifcopenshell +import ifcopenshell.api.project +import pytest + +from ifcquery.validate import validate + + +class TestValidate: + def test_valid_model_returns_valid_true(self, model): + result = validate(model) + assert result["valid"] is True + assert isinstance(result["issues"], list) + + def test_valid_model_has_no_issues(self, model): + result = validate(model) + assert result["issues"] == [] + + def test_empty_model_is_valid(self): + f = ifcopenshell.api.project.create_file() + result = validate(f) + assert result["valid"] is True + assert result["issues"] == [] + + def test_result_has_expected_keys(self, model): + result = validate(model) + assert "valid" in result + assert "issues" in result + + def test_express_rules_flag_accepted(self, model): + # Just verify it runs without error; express rules may add/not add issues + result = validate(model, express_rules=True) + assert "valid" in result + assert isinstance(result["issues"], list) + + def test_issue_has_level_and_message(self, model): + # Force an issue by manually breaking the model (invalid IfcWall attribute) + f = ifcopenshell.file() + # Create a raw IfcWall with deliberately wrong type for GlobalId (use int) + # We just check structure if any issues appear; on well-formed models there are none. + result = validate(model) + # Even if no issues, the structure contract must hold for any issues present + for issue in result["issues"]: + assert "level" in issue + assert "message" in issue diff --git a/src/ifctester/Makefile b/src/ifctester/Makefile index 63ce7bc311..71ecbd48d7 100644 --- a/src/ifctester/Makefile +++ b/src/ifctester/Makefile @@ -25,9 +25,19 @@ 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 +WORKER_BIN_DIR := $(WEBAPP_DIR)/public/worker/bin +IFCOPENSHELL_WASM_WHEEL := ifcopenshell-0.8.5+a51b2c5-cp313-cp313-pyodide_2025_0_wasm32.whl +IFCOPENSHELL_WASM_WHEEL_URL := https://s3.amazonaws.com/ifcopenshell-builds/ifcopenshell-0.8.5%2Ba51b2c5-cp313-cp313-pyodide_2025_0_wasm32.whl +IFCOPENSHELL_WASM_WHEEL_PATH := $(WORKER_BIN_DIR)/$(IFCOPENSHELL_WASM_WHEEL) +WEBAPP_GENERATED_DIR := $(WEBAPP_DIR)/public/worker/generated +WEBAPP_IFCTESTER_MANIFEST := $(WEBAPP_GENERATED_DIR)/ifctester.json +WEBAPP_IFCTESTER_BUILD_DIR := build-webapp-wheel +PACKAGE_WEBAPP_DIR := $(PACKAGE_NAME)/webapp +PACKAGE_WEBAPP_WWW_DIR := $(PACKAGE_WEBAPP_DIR)/www .PHONY: webapp-dev -webapp-dev: +webapp-dev: pyodide-download ifcopenshell-wasm-download webapp-stage-ifctester-wheel + cd $(WEBAPP_DIR) && npm install cd $(WEBAPP_DIR) && npm run dev .PHONY: pyodide-download @@ -62,8 +72,43 @@ pyodide-download: echo "Pyodide $(PYODIDE_VERSION) prepared in $(PYODIDE_DIR)"; \ fi +.PHONY: ifcopenshell-wasm-download +ifcopenshell-wasm-download: + @if [ -f "$(IFCOPENSHELL_WASM_WHEEL_PATH)" ]; then \ + echo "IfcOpenShell wasm wheel already exists at $(IFCOPENSHELL_WASM_WHEEL_PATH), skipping download"; \ + else \ + echo "Downloading IfcOpenShell wasm wheel..."; \ + mkdir -p $(WORKER_BIN_DIR); \ + rm -f $(WORKER_BIN_DIR)/ifcopenshell-*.whl; \ + curl -fL -o "$(IFCOPENSHELL_WASM_WHEEL_PATH)" "$(IFCOPENSHELL_WASM_WHEEL_URL)"; \ + echo "IfcOpenShell wasm wheel prepared at $(IFCOPENSHELL_WASM_WHEEL_PATH)"; \ + fi + +.PHONY: webapp-stage-ifctester-wheel +webapp-stage-ifctester-wheel: + rm -rf $(WEBAPP_GENERATED_DIR) + rm -rf $(WEBAPP_IFCTESTER_BUILD_DIR) + mkdir -p $(WEBAPP_IFCTESTER_BUILD_DIR) + cp -r $(PACKAGE_NAME) $(WEBAPP_IFCTESTER_BUILD_DIR)/ + rm -rf $(WEBAPP_IFCTESTER_BUILD_DIR)/$(PACKAGE_NAME)/webapp + cp pyproject.toml $(WEBAPP_IFCTESTER_BUILD_DIR)/ + cp README.md $(WEBAPP_IFCTESTER_BUILD_DIR)/ +ifeq ($(IS_STABLE), TRUE) + $(SED) 's/version = "0.0.0"/version = "$(VERSION)"/' $(WEBAPP_IFCTESTER_BUILD_DIR)/pyproject.toml + $(SED) 's/version = "0.0.0"/version = "$(VERSION)"/' $(WEBAPP_IFCTESTER_BUILD_DIR)/$(PACKAGE_NAME)/__init__.py +else + $(SED) 's/version = "0.0.0"/version = "$(VERSION)a$(VERSION_DATE)"/' $(WEBAPP_IFCTESTER_BUILD_DIR)/pyproject.toml + $(SED) 's/version = "0.0.0"/version = "$(VERSION)-alpha$(VERSION_DATE)"/' $(WEBAPP_IFCTESTER_BUILD_DIR)/$(PACKAGE_NAME)/__init__.py +endif + cd $(WEBAPP_IFCTESTER_BUILD_DIR) && $(PYTHON) -m venv env --system-site-packages && . env/$(VENV_ACTIVATE) && python -m pip install build && python -m build --wheel --no-isolation + mkdir -p $(WEBAPP_GENERATED_DIR) + wheel=$$(basename $(WEBAPP_IFCTESTER_BUILD_DIR)/dist/$(PACKAGE_NAME)-*.whl); \ + cp "$(WEBAPP_IFCTESTER_BUILD_DIR)/dist/$$wheel" "$(WEBAPP_GENERATED_DIR)/$$wheel"; \ + printf '{\n "wheel_url": "/worker/generated/%s"\n}\n' "$$wheel" > "$(WEBAPP_IFCTESTER_MANIFEST)" + rm -rf $(WEBAPP_IFCTESTER_BUILD_DIR) + .PHONY: webapp-build -webapp-build: pyodide-download +webapp-build: pyodide-download ifcopenshell-wasm-download webapp-stage-ifctester-wheel cd $(WEBAPP_DIR) && npm install cd $(WEBAPP_DIR) && npm run build @@ -76,22 +121,30 @@ clean: rm -rf $(WEBAPP_BUILD_DIR) rm -rf $(WEBAPP_DIR)/node_modules rm -rf $(PYODIDE_DIR) + rm -f $(WORKER_BIN_DIR)/ifcopenshell-*.whl + rm -rf $(WEBAPP_GENERATED_DIR) + rm -rf $(WEBAPP_IFCTESTER_BUILD_DIR) rm -rf $(PACKAGE_NAME)/webapp rm -rf dist -.PHONY: dist -dist: webapp-prepare +.PHONY: python-dist +python-dist: + rm -rf dist # For some reason OS is not initalized when we call common.mk dist, which matters on Windows. # So we pass it explicitly. - $(MAKE) -f ../common.mk dist PACKAGE_NAME=$(PACKAGE_NAME) OS=$(OS) + $(MAKE) -f ../common.mk dist PACKAGE_NAME=$(PACKAGE_NAME) OS=$(OS) IS_STABLE=$(IS_STABLE) + +.PHONY: dist +dist: webapp-prepare + $(MAKE) python-dist OS=$(OS) IS_STABLE=$(IS_STABLE) .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 + rm -rf $(PACKAGE_WEBAPP_WWW_DIR) + mkdir -p $(PACKAGE_WEBAPP_WWW_DIR) + cp -r $(WEBAPP_BUILD_DIR)/* $(PACKAGE_WEBAPP_WWW_DIR)/ + cp $(WEBAPP_DIR)/__init__.py $(PACKAGE_WEBAPP_DIR)/__init__.py + cp $(WEBAPP_DIR)/serve.py $(PACKAGE_WEBAPP_DIR)/serve.py .PHONY: test test: diff --git a/src/ifctester/webapp/.gitignore b/src/ifctester/webapp/.gitignore index 4205867691..f7daf321ef 100644 --- a/src/ifctester/webapp/.gitignore +++ b/src/ifctester/webapp/.gitignore @@ -11,6 +11,8 @@ node_modules dist dist-ssr *.local +public/worker/bin/ifcopenshell-*.whl +public/worker/generated # Editor directories and files .vscode/* @@ -24,4 +26,4 @@ dist-ssr *.sw? .claude -experiment/* \ No newline at end of file +experiment/* diff --git a/src/ifctester/webapp/biome.json b/src/ifctester/webapp/biome.json new file mode 100644 index 0000000000..ce01c80c3f --- /dev/null +++ b/src/ifctester/webapp/biome.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "files": { + "ignore": [ + "dist", + "node_modules", + "public/pyodide", + "**/*.svelte" + ] + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "overrides": [ + { + "include": [ + "src/modules/wasm/worker/**" + ], + "linter": { + "rules": { + "suspicious": { + "noExplicitAny": "off" + } + } + } + } + ] +} diff --git a/src/ifctester/webapp/index.html b/src/ifctester/webapp/index.html index 27c32440dc..48ac4cc1c8 100644 --- a/src/ifctester/webapp/index.html +++ b/src/ifctester/webapp/index.html @@ -9,6 +9,6 @@
- + diff --git a/src/ifctester/webapp/jsconfig.json b/src/ifctester/webapp/jsconfig.json deleted file mode 100644 index 2aef2313e4..0000000000 --- a/src/ifctester/webapp/jsconfig.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "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"] -} diff --git a/src/ifctester/webapp/package-lock.json b/src/ifctester/webapp/package-lock.json index c167ebcecc..1244317fd2 100644 --- a/src/ifctester/webapp/package-lock.json +++ b/src/ifctester/webapp/package-lock.json @@ -16,6 +16,7 @@ "svelte-spa-router": "^4.0.1" }, "devDependencies": { + "@biomejs/biome": "^1.9.4", "@internationalized/date": "^3.8.1", "@lucide/svelte": "^0.515.0", "@sveltejs/vite-plugin-svelte": "^5.0.3", @@ -25,11 +26,13 @@ "mode-watcher": "^1.1.0", "sass-embedded": "^1.89.0", "svelte": "^5.53.6", + "svelte-check": "^4.0.0", "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", + "typescript": "^5.8.3", "vite": "^6.4.1" } }, @@ -47,6 +50,170 @@ "node": ">=6.0.0" } }, + "node_modules/@biomejs/biome": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-1.9.4.tgz", + "integrity": "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==", + "dev": true, + "hasInstallScript": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "1.9.4", + "@biomejs/cli-darwin-x64": "1.9.4", + "@biomejs/cli-linux-arm64": "1.9.4", + "@biomejs/cli-linux-arm64-musl": "1.9.4", + "@biomejs/cli-linux-x64": "1.9.4", + "@biomejs/cli-linux-x64-musl": "1.9.4", + "@biomejs/cli-win32-arm64": "1.9.4", + "@biomejs/cli-win32-x64": "1.9.4" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-1.9.4.tgz", + "integrity": "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-1.9.4.tgz", + "integrity": "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-1.9.4.tgz", + "integrity": "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.9.4.tgz", + "integrity": "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-1.9.4.tgz", + "integrity": "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-1.9.4.tgz", + "integrity": "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-1.9.4.tgz", + "integrity": "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-1.9.4.tgz", + "integrity": "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, "node_modules/@bufbuild/protobuf": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.5.1.tgz", @@ -1468,6 +1635,22 @@ "dev": true, "license": "MIT/X11" }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -2126,6 +2309,16 @@ "svelte": "^5.7.0" } }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2159,9 +2352,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -2200,6 +2393,20 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/regexparam": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/regexparam/-/regexparam-2.0.2.tgz", @@ -2281,6 +2488,19 @@ "tslib": "^2.1.0" } }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/sass-embedded": { "version": "1.89.0", "resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.89.0.tgz", @@ -2783,6 +3003,30 @@ "node": ">=18" } }, + "node_modules/svelte-check": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.3.5.tgz", + "integrity": "sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, "node_modules/svelte-sonner": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/svelte-sonner/-/svelte-sonner-1.0.5.tgz", @@ -2983,6 +3227,20 @@ "url": "https://github.com/sponsors/Wombosvideo" } }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", diff --git a/src/ifctester/webapp/package.json b/src/ifctester/webapp/package.json index bb7d58567e..14b54ad8ed 100644 --- a/src/ifctester/webapp/package.json +++ b/src/ifctester/webapp/package.json @@ -6,11 +6,15 @@ "scripts": { "dev": "vite", "build": "vite build", + "check": "tsc -p tsconfig.json --noEmit && svelte-check && biome lint .", + "lint": "biome lint .", + "lint:fix": "biome lint --write .", "preview": "vite preview", "deploy": "npm run build && npx wrangler pages deploy dist" }, "devDependencies": { "@internationalized/date": "^3.8.1", + "@biomejs/biome": "^1.9.4", "@lucide/svelte": "^0.515.0", "@sveltejs/vite-plugin-svelte": "^5.0.3", "@tailwindcss/vite": "^4.0.0", @@ -19,10 +23,12 @@ "mode-watcher": "^1.1.0", "sass-embedded": "^1.89.0", "svelte": "^5.53.6", + "svelte-check": "^4.0.0", "svelte-sonner": "^1.0.5", "tailwind-merge": "^3.3.0", "tailwind-variants": "^1.0.0", "tailwindcss": "^4.0.0", + "typescript": "^5.8.3", "tw-animate-css": "^1.3.2", "vite": "^6.4.1" }, diff --git a/src/ifctester/webapp/public/worker/bin/ifcopenshell-0.8.3+bb329af-cp313-cp313-emscripten_4_0_9_wasm32.whl b/src/ifctester/webapp/public/worker/bin/ifcopenshell-0.8.3+bb329af-cp313-cp313-emscripten_4_0_9_wasm32.whl deleted file mode 100644 index 703ae3b877..0000000000 Binary files a/src/ifctester/webapp/public/worker/bin/ifcopenshell-0.8.3+bb329af-cp313-cp313-emscripten_4_0_9_wasm32.whl and /dev/null differ diff --git a/src/ifctester/webapp/serve.py b/src/ifctester/webapp/serve.py index 5b18f15545..c114b47542 100644 --- a/src/ifctester/webapp/serve.py +++ b/src/ifctester/webapp/serve.py @@ -22,9 +22,6 @@ import os import sys bonsai_lib_path = os.environ.get("BONSAI_LIB_PATH") -print(os.environ) -print(bonsai_lib_path) -bonsai_version = os.environ.get("BONSAI_VERSION") if bonsai_lib_path: sys.path.insert(0, bonsai_lib_path) diff --git a/src/ifctester/webapp/src/App.svelte b/src/ifctester/webapp/src/App.svelte index 19222ad8d1..27b5c8001b 100644 --- a/src/ifctester/webapp/src/App.svelte +++ b/src/ifctester/webapp/src/App.svelte @@ -1,4 +1,4 @@ - diff --git a/src/ifctester/webapp/src/app.d.ts b/src/ifctester/webapp/src/app.d.ts new file mode 100644 index 0000000000..ff86489626 --- /dev/null +++ b/src/ifctester/webapp/src/app.d.ts @@ -0,0 +1,4 @@ +declare module "*.svelte" { + import type { SvelteComponent } from "svelte"; + export default class Component extends SvelteComponent {} +} diff --git a/src/ifctester/webapp/src/components/AppHeader.svelte b/src/ifctester/webapp/src/components/AppHeader.svelte index 61f8a5b3b2..8fb71b6180 100644 --- a/src/ifctester/webapp/src/components/AppHeader.svelte +++ b/src/ifctester/webapp/src/components/AppHeader.svelte @@ -1,11 +1,11 @@ - @@ -142,4 +143,4 @@ - \ No newline at end of file + diff --git a/src/ifctester/webapp/src/components/AppRibbon.svelte b/src/ifctester/webapp/src/components/AppRibbon.svelte index 4f9643637e..662a93b500 100644 --- a/src/ifctester/webapp/src/components/AppRibbon.svelte +++ b/src/ifctester/webapp/src/components/AppRibbon.svelte @@ -1,6 +1,5 @@ -
@@ -20,4 +19,4 @@ Error
{/if} - \ No newline at end of file + diff --git a/src/ifctester/webapp/src/components/AppToolbar.svelte b/src/ifctester/webapp/src/components/AppToolbar.svelte index 3552b13969..c9b3dd2968 100644 --- a/src/ifctester/webapp/src/components/AppToolbar.svelte +++ b/src/ifctester/webapp/src/components/AppToolbar.svelte @@ -1,14 +1,16 @@ - @@ -63,4 +65,4 @@ Part Of - \ No newline at end of file + diff --git a/src/ifctester/webapp/src/components/IdsTabs.svelte b/src/ifctester/webapp/src/components/IdsTabs.svelte index 0f6f38a569..25d6891ab7 100644 --- a/src/ifctester/webapp/src/components/IdsTabs.svelte +++ b/src/ifctester/webapp/src/components/IdsTabs.svelte @@ -1,13 +1,22 @@ -
@@ -15,10 +24,13 @@
switchDocument(docId)} - aria-label={doc.info.title || "Untitled"} + onkeydown={(event) => handleActivation(event, () => switchDocument(docId))} + aria-label={(doc as IdsDocument).info.title || "Untitled"} > - {doc.info.title || "Untitled"} + {(doc as IdsDocument).info.title || "Untitled"}
{/each}
-
\ No newline at end of file + diff --git a/src/ifctester/webapp/src/config.json b/src/ifctester/webapp/src/config.json index d5c450f460..f4f469178f 100644 --- a/src/ifctester/webapp/src/config.json +++ b/src/ifctester/webapp/src/config.json @@ -1,8 +1,7 @@ { "wasm": { - "wheel_url": "/worker/bin/ifcopenshell-0.8.3+bb329af-cp313-cp313-emscripten_4_0_9_wasm32.whl", + "wheel_url": "/worker/bin/ifcopenshell-0.8.5+a51b2c5-cp313-cp313-pyodide_2025_0_wasm32.whl", "odfpy_url": "/worker/bin/odfpy-1.4.2-py2.py3-none-any.whl", - "api_py_url": "/worker/api.py", - "pyodide_url": "https://cdn.jsdelivr.net/pyodide/v0.28.0/full/pyodide.js" + "api_py_url": "/worker/api.py" } -} \ No newline at end of file +} diff --git a/src/ifctester/webapp/src/css/app.css b/src/ifctester/webapp/src/css/app.css index a8210b43e8..09f12f3b9f 100644 --- a/src/ifctester/webapp/src/css/app.css +++ b/src/ifctester/webapp/src/css/app.css @@ -576,7 +576,8 @@ html, body { display: flex; flex-direction: column; - label { + label, + .form-label { margin-bottom: 4px; font-size: 14px; font-weight: 500; diff --git a/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-close.svelte b/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-close.svelte index 94266a6315..a6ac3f979a 100644 --- a/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-close.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-close.svelte @@ -1,7 +1,11 @@ - - \ No newline at end of file + diff --git a/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-content.svelte b/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-content.svelte index 9ceaa7faf7..17d98fe392 100644 --- a/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-content.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-content.svelte @@ -1,8 +1,17 @@ - @@ -35,4 +44,4 @@ {/if} - \ No newline at end of file + diff --git a/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-description.svelte b/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-description.svelte index 419a27e449..0f91cf0670 100644 --- a/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-description.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-description.svelte @@ -1,12 +1,17 @@ - \ No newline at end of file +/> diff --git a/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-footer.svelte b/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-footer.svelte index b2d4035e6e..7071ae2a0c 100644 --- a/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-footer.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-footer.svelte @@ -1,11 +1,19 @@ -
{@render children?.()} -
\ No newline at end of file + diff --git a/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-header.svelte b/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-header.svelte index 791dcde5d7..9bb73aa601 100644 --- a/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-header.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-header.svelte @@ -1,12 +1,19 @@ -
{@render children?.()} -
\ No newline at end of file + diff --git a/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-overlay.svelte b/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-overlay.svelte index adb8a38e94..90e3104148 100644 --- a/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-overlay.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-overlay.svelte @@ -1,12 +1,17 @@ - \ No newline at end of file +/> diff --git a/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-title.svelte b/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-title.svelte index 2d36371d01..866d5ef826 100644 --- a/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-title.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-title.svelte @@ -1,12 +1,17 @@ - \ No newline at end of file +/> diff --git a/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-trigger.svelte b/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-trigger.svelte index 7a38ff9e6a..5636ea2df1 100644 --- a/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-trigger.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-trigger.svelte @@ -1,7 +1,11 @@ - - \ No newline at end of file + diff --git a/src/ifctester/webapp/src/lib/components/ui/dialog/index.js b/src/ifctester/webapp/src/lib/components/ui/dialog/index.ts similarity index 100% rename from src/ifctester/webapp/src/lib/components/ui/dialog/index.js rename to src/ifctester/webapp/src/lib/components/ui/dialog/index.ts diff --git a/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte b/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte index fc034efd31..242f3088ef 100644 --- a/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte @@ -1,8 +1,16 @@ - {@render childrenProp?.()} {/snippet} - \ No newline at end of file + diff --git a/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte b/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte index d13c1ad08d..b793c23e85 100644 --- a/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte @@ -1,14 +1,21 @@ - @@ -22,4 +29,4 @@ )} {...restProps} /> - \ No newline at end of file + diff --git a/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte b/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte index 89454b26df..83032fee1e 100644 --- a/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte @@ -1,12 +1,17 @@ - \ No newline at end of file +/> diff --git a/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte b/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte index 4f3421fbe8..55220a4783 100644 --- a/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte @@ -1,7 +1,11 @@ - - \ No newline at end of file + diff --git a/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte b/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte index a06f2099dd..20744df288 100644 --- a/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte @@ -1,14 +1,21 @@ - \ No newline at end of file +/> diff --git a/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte b/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte index e0aceee7d3..2fc90b933b 100644 --- a/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte @@ -1,12 +1,20 @@ -
{@render children?.()} -
\ No newline at end of file + diff --git a/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte b/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte index 009a4dd02d..0be2895631 100644 --- a/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte @@ -1,11 +1,16 @@ - \ No newline at end of file +/> diff --git a/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte b/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte index 218db7f78a..629cc37563 100644 --- a/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte @@ -1,14 +1,22 @@ - {@render childrenProp?.({ checked })} {/snippet} - \ No newline at end of file + diff --git a/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte b/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte index 4d02884d17..533e47c574 100644 --- a/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte @@ -1,12 +1,17 @@ - \ No newline at end of file +/> diff --git a/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte b/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte index 20f2210c5a..93b6175647 100644 --- a/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte @@ -1,12 +1,19 @@ - {@render children?.()} - \ No newline at end of file + diff --git a/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte b/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte index 0f3c698fea..7a33ac199c 100644 --- a/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte @@ -1,12 +1,17 @@ - \ No newline at end of file +/> diff --git a/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte b/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte index e4e236e111..a2be23a136 100644 --- a/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte @@ -1,7 +1,15 @@ - {@render children?.()} - \ No newline at end of file + diff --git a/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte b/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte index 720d196b77..1aa23bc512 100644 --- a/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte @@ -1,7 +1,11 @@ - - \ No newline at end of file + diff --git a/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/index.js b/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/index.ts similarity index 100% rename from src/ifctester/webapp/src/lib/components/ui/dropdown-menu/index.js rename to src/ifctester/webapp/src/lib/components/ui/dropdown-menu/index.ts diff --git a/src/ifctester/webapp/src/lib/components/ui/menubar/index.js b/src/ifctester/webapp/src/lib/components/ui/menubar/index.ts similarity index 100% rename from src/ifctester/webapp/src/lib/components/ui/menubar/index.js rename to src/ifctester/webapp/src/lib/components/ui/menubar/index.ts diff --git a/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-checkbox-item.svelte b/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-checkbox-item.svelte index 0d6e298b0f..bb020eec7b 100644 --- a/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-checkbox-item.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-checkbox-item.svelte @@ -1,8 +1,16 @@ - {@render childrenProp?.()} {/snippet} - \ No newline at end of file + diff --git a/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-content.svelte b/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-content.svelte index 2706fc76e2..4c7696d218 100644 --- a/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-content.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-content.svelte @@ -1,6 +1,19 @@ - @@ -28,4 +41,4 @@ )} {...restProps} /> - \ No newline at end of file + diff --git a/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-group-heading.svelte b/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-group-heading.svelte index c64dd91534..13417d54ec 100644 --- a/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-group-heading.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-group-heading.svelte @@ -1,12 +1,17 @@ - \ No newline at end of file +/> diff --git a/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-group.svelte b/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-group.svelte index 8acc8b9549..2c77293f76 100644 --- a/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-group.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-group.svelte @@ -1,10 +1,14 @@ - - \ No newline at end of file + diff --git a/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-item.svelte b/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-item.svelte index f4fee4711e..1ff76231a5 100644 --- a/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-item.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-item.svelte @@ -1,6 +1,13 @@ - \ No newline at end of file +/> diff --git a/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-label.svelte b/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-label.svelte index 129be16dd5..efbbb9b017 100644 --- a/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-label.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-label.svelte @@ -1,12 +1,19 @@ -
{@render children?.()} -
\ No newline at end of file + diff --git a/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-radio-item.svelte b/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-radio-item.svelte index c7c122881f..1947458beb 100644 --- a/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-radio-item.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-radio-item.svelte @@ -1,14 +1,22 @@ - {@render childrenProp?.({ checked })} {/snippet} - \ No newline at end of file + diff --git a/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-separator.svelte b/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-separator.svelte index d32bbab673..7674b560bc 100644 --- a/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-separator.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-separator.svelte @@ -1,12 +1,17 @@ - \ No newline at end of file +/> diff --git a/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-shortcut.svelte b/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-shortcut.svelte index 7df7a0d118..05cf1bc13b 100644 --- a/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-shortcut.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-shortcut.svelte @@ -1,12 +1,19 @@ - {@render children?.()} - \ No newline at end of file + diff --git a/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-sub-content.svelte b/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-sub-content.svelte index 5a79de2c58..67b68ca633 100644 --- a/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-sub-content.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-sub-content.svelte @@ -1,12 +1,17 @@ - \ No newline at end of file +/> diff --git a/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-sub-trigger.svelte b/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-sub-trigger.svelte index f9fa0461ee..c14976bd12 100644 --- a/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-sub-trigger.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-sub-trigger.svelte @@ -1,7 +1,15 @@ - {@render children?.()} - \ No newline at end of file + diff --git a/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-trigger.svelte b/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-trigger.svelte index c39921a2a1..33119d913c 100644 --- a/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-trigger.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-trigger.svelte @@ -1,12 +1,17 @@ - \ No newline at end of file +/> diff --git a/src/ifctester/webapp/src/lib/components/ui/menubar/menubar.svelte b/src/ifctester/webapp/src/lib/components/ui/menubar/menubar.svelte index cddff1b908..ebc3534d09 100644 --- a/src/ifctester/webapp/src/lib/components/ui/menubar/menubar.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/menubar/menubar.svelte @@ -1,12 +1,17 @@ - \ No newline at end of file +/> diff --git a/src/ifctester/webapp/src/lib/components/ui/sonner/index.js b/src/ifctester/webapp/src/lib/components/ui/sonner/index.ts similarity index 100% rename from src/ifctester/webapp/src/lib/components/ui/sonner/index.js rename to src/ifctester/webapp/src/lib/components/ui/sonner/index.ts diff --git a/src/ifctester/webapp/src/lib/components/ui/sonner/sonner.svelte b/src/ifctester/webapp/src/lib/components/ui/sonner/sonner.svelte index 91981de7e6..28a794ca0f 100644 --- a/src/ifctester/webapp/src/lib/components/ui/sonner/sonner.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/sonner/sonner.svelte @@ -1,8 +1,8 @@ - \ No newline at end of file +/> diff --git a/src/ifctester/webapp/src/lib/components/ui/tooltip/index.js b/src/ifctester/webapp/src/lib/components/ui/tooltip/index.ts similarity index 100% rename from src/ifctester/webapp/src/lib/components/ui/tooltip/index.js rename to src/ifctester/webapp/src/lib/components/ui/tooltip/index.ts diff --git a/src/ifctester/webapp/src/lib/components/ui/tooltip/tooltip-content.svelte b/src/ifctester/webapp/src/lib/components/ui/tooltip/tooltip-content.svelte index 3c25c65b9a..6db2203857 100644 --- a/src/ifctester/webapp/src/lib/components/ui/tooltip/tooltip-content.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/tooltip/tooltip-content.svelte @@ -1,6 +1,18 @@ - @@ -42,4 +54,4 @@ {/snippet} - \ No newline at end of file + diff --git a/src/ifctester/webapp/src/lib/components/ui/tooltip/tooltip-trigger.svelte b/src/ifctester/webapp/src/lib/components/ui/tooltip/tooltip-trigger.svelte index a2885f2cca..b1524c3a3c 100644 --- a/src/ifctester/webapp/src/lib/components/ui/tooltip/tooltip-trigger.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/tooltip/tooltip-trigger.svelte @@ -1,7 +1,11 @@ - - \ No newline at end of file + diff --git a/src/ifctester/webapp/src/lib/utils.js b/src/ifctester/webapp/src/lib/utils.js deleted file mode 100644 index f79e2db4c9..0000000000 --- a/src/ifctester/webapp/src/lib/utils.js +++ /dev/null @@ -1,8 +0,0 @@ -import { clsx, } from "clsx"; -import { twMerge } from "tailwind-merge"; - -export function cn(...inputs) { - return twMerge(clsx(inputs)); -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any \ No newline at end of file diff --git a/src/ifctester/webapp/src/lib/utils.ts b/src/ifctester/webapp/src/lib/utils.ts new file mode 100644 index 0000000000..be010d3ced --- /dev/null +++ b/src/ifctester/webapp/src/lib/utils.ts @@ -0,0 +1,7 @@ +import { clsx } from "clsx"; +import type { ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/src/ifctester/webapp/src/main.js b/src/ifctester/webapp/src/main.js deleted file mode 100644 index 14fb0c3be8..0000000000 --- a/src/ifctester/webapp/src/main.js +++ /dev/null @@ -1,9 +0,0 @@ -import { mount } from 'svelte'; -import './css/app.css'; -import App from './App.svelte'; - -const app = mount(App, { - target: document.getElementById('root'), -}); - -export default app; \ No newline at end of file diff --git a/src/ifctester/webapp/src/main.ts b/src/ifctester/webapp/src/main.ts new file mode 100644 index 0000000000..54ddb22cf9 --- /dev/null +++ b/src/ifctester/webapp/src/main.ts @@ -0,0 +1,14 @@ +import { mount } from 'svelte'; +import './css/app.css'; +import App from './App.svelte'; + +const root = document.getElementById('root'); +if (!root) { + throw new Error('Missing root element'); +} + +const app = mount(App, { + target: root, +}); + +export default app; diff --git a/src/ifctester/webapp/src/modules/api/api.svelte.js b/src/ifctester/webapp/src/modules/api/api.svelte.ts similarity index 72% rename from src/ifctester/webapp/src/modules/api/api.svelte.js rename to src/ifctester/webapp/src/modules/api/api.svelte.ts index ad2a7b8c3f..b4ebe01fdc 100644 --- a/src/ifctester/webapp/src/modules/api/api.svelte.js +++ b/src/ifctester/webapp/src/modules/api/api.svelte.ts @@ -1,8 +1,31 @@ import wasm from "$src/modules/wasm"; -import * as IDS from "$src/modules/api/ids.svelte.js"; +import * as IDS from "$src/modules/api/ids.svelte"; import hyperid from "hyperid"; +import type { AuditReport, AuditReportData } from "$src/types/report"; +import type { IdsDocument } from "$src/types/ids"; -export let Autocompletions = $state({ +type AutocompletionState = { + entityClasses: string[]; + materialCategories: string[]; + classificationSystems: Record; + dataTypes: string[]; + isLoaded: boolean; +}; + +type IfcModel = { + id: string; + fileName: string; + fileSize: number; + loadedAt: Date; +}; + +type IfcModelState = { + models: IfcModel[]; + isLoading: boolean; + audits: AuditReport[]; +}; + +export const Autocompletions: AutocompletionState = $state({ entityClasses: [], materialCategories: [], classificationSystems: {}, @@ -10,13 +33,13 @@ export let Autocompletions = $state({ isLoaded: false }); -export let IFCModels = $state({ +export const IFCModels: IfcModelState = $state({ models: [], isLoading: false, audits: [] }); -const id = hyperid(); +const id: () => string = hyperid(); // Preload autocompletions on initialization wasm.init().then(async () => { @@ -30,26 +53,30 @@ export async function preloadAutocompletions() { // Entity classes const entitySets = await Promise.all( schemas.map(schema => wasm.getAllEntityClasses(schema)) - ); - const allEntities = new Set(); - entitySets.forEach(entities => { - entities.forEach(entity => allEntities.add(entity.toUpperCase())); - }); + ) as string[][]; + const allEntities = new Set(); + for (const entities of entitySets) { + for (const entity of entities) { + allEntities.add(entity.toUpperCase()); + } + } // Data types const dataTypeSets = await Promise.all( schemas.map(schema => wasm.getAllDataTypes(schema)) - ); - const allDataTypes = new Set(); - dataTypeSets.forEach(dataTypes => { - Object.keys(dataTypes).forEach(dataType => allDataTypes.add(dataType)); - }); + ) as Record[]; + const allDataTypes = new Set(); + for (const dataTypes of dataTypeSets) { + for (const dataType of Object.keys(dataTypes as Record)) { + allDataTypes.add(dataType); + } + } // Material categories and Classification systems const [materialCategories, classificationSystems] = await Promise.all([ wasm.getMaterialCategories(), wasm.getStandardClassificationSystems() - ]); + ]) as [string[], AutocompletionState["classificationSystems"]]; // Cache autocompletions Autocompletions.entityClasses = Array.from(allEntities).sort(); @@ -64,15 +91,15 @@ export async function preloadAutocompletions() { } } -export async function getPredefinedTypes(schema, entity) { +export async function getPredefinedTypes(schema: string, entity: string) { return await wasm.getPredefinedTypes(schema, entity); } -export async function getEntityAttributes(schema, entity) { +export async function getEntityAttributes(schema: string, entity: string) { return await wasm.getEntityAttributes(schema, entity); } -export async function getApplicablePsets(schema, entity, predefinedType = '') { +export async function getApplicablePsets(schema: string, entity: string, predefinedType = '') { return await wasm.getApplicablePsets(schema, entity, predefinedType); } @@ -92,7 +119,7 @@ export function getDataTypes() { return Autocompletions.dataTypes; } -export async function loadIfc(file) { +export async function loadIfc(file: File): Promise { try { IFCModels.isLoading = true; @@ -100,10 +127,10 @@ export async function loadIfc(file) { const uint8Array = new Uint8Array(arrayBuffer); // Load IFC model - const ifcId = await wasm.loadIfc(Array.from(uint8Array)); + const ifcId = await wasm.loadIfc(Array.from(uint8Array)) as string; // Add to models list - const model = { + const model: IfcModel = { id: ifcId, fileName: file.name, fileSize: file.size, @@ -121,7 +148,7 @@ export async function loadIfc(file) { } } -export async function unloadIfc(modelId) { +export async function unloadIfc(modelId: string) { try { // Unload model await wasm.unloadIfc(modelId); @@ -136,9 +163,9 @@ export async function unloadIfc(modelId) { } } -export async function auditIfc(modelId, idsData) { +export async function auditIfc(modelId: string, idsData: string | Uint8Array | ArrayBuffer) { try { - let idsBytes; + let idsBytes: Uint8Array; if (typeof idsData === 'string') { idsBytes = new TextEncoder().encode(idsData); } else if (idsData instanceof ArrayBuffer) { @@ -148,7 +175,7 @@ export async function auditIfc(modelId, idsData) { } // Run audit - const auditResult = await wasm.auditIfc(modelId, idsBytes); + const auditResult = await wasm.auditIfc(modelId, idsBytes) as { json: AuditReportData; html: string }; console.log(`Audit completed for model ${modelId}`); return auditResult; @@ -163,13 +190,14 @@ export function getLoadedModels() { } export async function openIfc() { - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const fileInput = document.createElement('input'); fileInput.type = 'file'; fileInput.accept = '.ifc'; fileInput.onchange = async (event) => { - const file = event.target.files[0]; + const target = event.target as HTMLInputElement | null; + const file = target?.files?.[0]; if (!file) { reject(new Error('No file selected')); return; @@ -194,15 +222,20 @@ export async function openIfc() { }); } -export function getIfcById(modelId) { +export function getIfcById(modelId: string) { return IFCModels.models.find(model => model.id === modelId); } -export function createAuditReport(modelId, document, auditData, htmlReport = null) { +export function createAuditReport( + modelId: string, + document: string, + auditData: AuditReportData, + htmlReport: string | null = null +): AuditReport | undefined { const model = getIfcById(modelId); if (!model) return; - const auditReport = { + const auditReport: AuditReport = { id: id(), modelId: modelId, modelName: model.fileName, @@ -216,19 +249,19 @@ export function createAuditReport(modelId, document, auditData, htmlReport = nul return auditReport; } -export function getAuditReportsForIfc(modelId) { +export function getAuditReportsForIfc(modelId: string) { return IFCModels.audits.filter(audit => audit.modelId === modelId); } -export function getAuditReportById(auditId) { +export function getAuditReportById(auditId: string) { return IFCModels.audits.find(audit => audit.id === auditId); } -export function clearIdsAuditReports(document) { +export function clearIdsAuditReports(document: string) { IFCModels.audits = IFCModels.audits.filter(audit => audit.document !== document); } -export async function downloadAuditReport(auditId) { +export async function downloadAuditReport(auditId: string) { const audit = getAuditReportById(auditId); if (!audit || !audit.htmlReport) { throw new Error('HTML report not available for this audit'); @@ -237,7 +270,7 @@ export async function downloadAuditReport(auditId) { // Get IDS document title for filename let filename = 'report.html'; if (audit.document && IDS.Module.documents[audit.document]) { - const doc = IDS.Module.documents[audit.document]; + const doc = IDS.Module.documents[audit.document] as IdsDocument; const title = doc.info?.title || 'untitled'; filename = `report_${title.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.html`; } @@ -270,9 +303,12 @@ export async function runAudit() { // Get the active IDS document XML const idsXml = await IDS.exportActiveDocument(); + if (!idsXml) { + throw new Error('Failed to export IDS document'); + } // Run audit on all loaded models - let firstAuditReport = null; + let firstAuditReport: AuditReport | undefined; for (const model of IFCModels.models) { const result = await auditIfc(model.id, idsXml); @@ -280,7 +316,10 @@ export async function runAudit() { const jsonData = result.json || null; const htmlReport = result.html || null; - const auditReport = createAuditReport(model.id, IDS.Module.activeDocument, jsonData, htmlReport); + if (!jsonData) { + continue; + } + const auditReport = createAuditReport(model.id, IDS.Module.activeDocument as string, jsonData, htmlReport); // Store the first audit report to open in viewer if (!firstAuditReport) { diff --git a/src/ifctester/webapp/src/modules/api/bonsai.svelte.js b/src/ifctester/webapp/src/modules/api/bonsai.svelte.ts similarity index 64% rename from src/ifctester/webapp/src/modules/api/bonsai.svelte.js rename to src/ifctester/webapp/src/modules/api/bonsai.svelte.ts index bcc4b8965d..5ed9b562f4 100644 --- a/src/ifctester/webapp/src/modules/api/bonsai.svelte.js +++ b/src/ifctester/webapp/src/modules/api/bonsai.svelte.ts @@ -1,12 +1,37 @@ import { io } from 'socket.io-client'; -import { IFCModels } from './api.svelte.js'; -import * as IDS from './ids.svelte.js'; -import { error, success } from '../utils/toast.svelte.js'; +import type { Socket } from 'socket.io-client'; +import { IFCModels } from './api.svelte'; +import * as IDS from './ids.svelte'; +import { error, success } from '../utils/toast.svelte'; import hyperid from 'hyperid'; -import { onMount } from 'svelte'; +import type { AuditReport, AuditReportData } from "$src/types/report"; // Bonsai connection state -export let Bonsai = $state({ +type BonsaiState = { + enabled: boolean; + port: string | null; + socket: Socket | null; + connected: boolean; + auditing: boolean; +}; + +type PendingAudit = { + resolve: (value: string | null) => void; + reject: (reason?: unknown) => void; +}; + +type AuditResultPayload = { + id?: string; + json_report?: string; + html_report?: string; +}; + +type AuditErrorPayload = { + id?: string; + error?: string; +}; + +export const Bonsai: BonsaiState = $state({ enabled: false, port: null, socket: null, @@ -14,8 +39,8 @@ export let Bonsai = $state({ auditing: false }); -const id = hyperid(); -const pendingAudits = new Map(); +const id: () => string = hyperid(); +const pendingAudits = new Map(); // Check for Bonsai server port in URL parameters const urlParams = new URLSearchParams(window.location.search); @@ -29,8 +54,11 @@ if (serverPort) { /** * Connect to Bonsai server */ -export const connect = () => new Promise((resolve, reject) => { - if (!Bonsai.port) return; +export const connect = () => new Promise((resolve, reject) => { + if (!Bonsai.port) { + resolve(); + return; + } try { Bonsai.socket = io(`ws://127.0.0.1:${Bonsai.port}/ifctester`, { @@ -49,7 +77,7 @@ export const connect = () => new Promise((resolve, reject) => { Bonsai.connected = false; }); - Bonsai.socket.on('connect_error', (err) => { + Bonsai.socket.on('connect_error', (err: Error) => { Bonsai.connected = false; error(`Failed to connect to Bonsai: ${err.message}`); reject(err); @@ -59,7 +87,8 @@ export const connect = () => new Promise((resolve, reject) => { Bonsai.socket.on('error', handleAuditError); } catch (err) { - error(`Failed to connect to Bonsai: ${err.message}`); + const message = err instanceof Error ? err.message : String(err); + error(`Failed to connect to Bonsai: ${message}`); reject(err); } }); @@ -93,14 +122,21 @@ export const runAudit = async () => { // Convert IDS document to XML string const idsXml = await IDS.exportActiveDocument(); + if (!idsXml) { + throw new Error('Failed to export IDS document'); + } const requestId = id(); + const socket = Bonsai.socket; + if (!socket) { + throw new Error('Bonsai socket not connected'); + } - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { // Store request with resolve/reject functions pendingAudits.set(requestId, { resolve, reject }); - Bonsai.socket.emit('audit_ids', { + socket.emit('audit_ids', { id: requestId, ids: idsXml }); @@ -108,7 +144,8 @@ export const runAudit = async () => { } catch (err) { Bonsai.auditing = false; - error(`Failed to run Bonsai audit: ${err.message}`); + const message = err instanceof Error ? err.message : String(err); + error(`Failed to run Bonsai audit: ${message}`); return null; } }; @@ -117,7 +154,7 @@ export const runAudit = async () => { * Handles audit results from Bonsai server * @param {Object} data - Audit result data */ -const handleAuditResult = (data) => { +const handleAuditResult = (data: AuditResultPayload) => { if (!data.id || !data.json_report) return; const pendingAudit = pendingAudits.get(data.id); @@ -130,13 +167,14 @@ const handleAuditResult = (data) => { const { resolve } = pendingAudit; try { - const reportData = JSON.parse(data.json_report); + const reportData = JSON.parse(data.json_report) as AuditReportData; - const auditReport = { + const auditReport: AuditReport = { id: data.id, + modelId: `bonsai:${data.id}`, date: new Date().toISOString(), modelName: 'Bonsai IFC Model', - document: IDS.Module.activeDocument, + document: IDS.Module.activeDocument ?? "", data: reportData, htmlReport: data.html_report }; @@ -152,7 +190,8 @@ const handleAuditResult = (data) => { } catch (err) { Bonsai.auditing = false; - error(`Failed to process audit result: ${err.message}`); + const message = err instanceof Error ? err.message : String(err); + error(`Failed to process audit result: ${message}`); resolve(null); } }; @@ -161,7 +200,7 @@ const handleAuditResult = (data) => { * Handles audit errors from Bonsai server * @param {Object} data - Error data */ -const handleAuditError = (data) => { +const handleAuditError = (data: AuditErrorPayload) => { if (!data.id) return; const pendingAudit = pendingAudits.get(data.id); @@ -174,7 +213,6 @@ const handleAuditError = (data) => { const { resolve } = pendingAudit; Bonsai.auditing = false; - error(`Audit failed (Bonsai): ${data.error}`); + error(`Audit failed (Bonsai): ${data.error ?? "Unknown error"}`); resolve(null); }; - diff --git a/src/ifctester/webapp/src/modules/api/ids.svelte.js b/src/ifctester/webapp/src/modules/api/ids.svelte.ts similarity index 63% rename from src/ifctester/webapp/src/modules/api/ids.svelte.js rename to src/ifctester/webapp/src/modules/api/ids.svelte.ts index 3d30948d3d..3035113654 100644 --- a/src/ifctester/webapp/src/modules/api/ids.svelte.js +++ b/src/ifctester/webapp/src/modules/api/ids.svelte.ts @@ -1,10 +1,18 @@ import wasm from "$src/modules/wasm"; -import { clearIdsAuditReports } from "./api.svelte.js"; +import { clearIdsAuditReports } from "./api.svelte"; import hyperid from "hyperid"; import {tick} from "svelte"; +import type { DocumentState, Facet, FacetValue, IdsDocument, IdsCardinality, Restriction, Specification } from "$src/types/ids"; -export let Module = $state({ - documents: [], +type ModuleState = { + documents: Record; + activeDocument: string | null; + status: "loading" | "ready" | "error"; + states: Record; +}; + +export const Module: ModuleState = $state({ + documents: {}, activeDocument: null, status: "loading", states: {} @@ -17,14 +25,15 @@ wasm.init().then(() => { Module.status = "error"; }); -const id = hyperid() +const id: () => string = hyperid(); -export function setDocumentState(docId, updates) { +export function setDocumentState(docId: string, updates: Partial) { if (!Module.states[docId]) { Module.states[docId] = { activeTab: 'info', viewMode: 'editor', - activeSpecification: null + activeSpecification: null, + auditReport: null }; } Object.assign(Module.states[docId], updates); @@ -32,7 +41,7 @@ export function setDocumentState(docId, updates) { export async function createDocument() { const docId = id(); - const doc = await wasm.createIDS(); + const doc = await wasm.createIDS() as IdsDocument; Module.documents[docId] = doc; @@ -43,14 +52,14 @@ export async function createDocument() { Module.activeDocument = docId; } -export async function deleteDocument(id) { +export async function deleteDocument(id: string) { // Clear any audit reports generated using this IDS document clearIdsAuditReports(id); delete Module.documents[id]; delete Module.states[id]; - if (Module.activeDocument == id) { + if (Module.activeDocument === id) { // If there are other documents, set the first one as active if (Object.keys(Module.documents).length > 0) { Module.activeDocument = Object.keys(Module.documents)[0]; @@ -62,19 +71,19 @@ export async function deleteDocument(id) { // Normalize (remove xs: prefix) from JSON dict returned from Python // We need this because the backend exports with xs: prefix, yet expects a dict without prefixes. -function normalizeIdsDict(obj) { +function normalizeIdsDict(obj: unknown): unknown { if (typeof obj !== 'object' || obj === null) return obj; if (Array.isArray(obj)) { return obj.map(normalizeIdsDict); } - const result = {}; + const result: Record = {}; for (const [key, value] of Object.entries(obj)) { if (key === 'xs:restriction' && Array.isArray(value) && value.length > 0) { // Convert xs:restriction array to restriction object - const restriction = value[0]; - const newRestriction = {}; + const restriction = value[0] as Record; + const newRestriction: Record = {}; for (const [restrictionKey, restrictionValue] of Object.entries(restriction)) { if (restrictionKey.startsWith('xs:')) { @@ -86,7 +95,7 @@ function normalizeIdsDict(obj) { } } - result['restriction'] = newRestriction; + result.restriction = newRestriction; } else { result[key] = normalizeIdsDict(value); } @@ -96,13 +105,16 @@ function normalizeIdsDict(obj) { } export async function openDocument() { - return new Promise((resolve, reject) => { - const fileInput = document.createElement('input'); + return new Promise((resolve, reject) => { + const fileInput = document.createElement('input') as HTMLInputElement & { + oncancel?: ((this: HTMLInputElement, ev: Event) => void) | null; + }; fileInput.type = 'file'; fileInput.accept = '.ids,.xml'; fileInput.onchange = async (event) => { - const file = event.target.files[0]; + const target = event.target as HTMLInputElement | null; + const file = target?.files?.[0]; if (!file) { reject(new Error('No file selected')); return; @@ -112,8 +124,8 @@ export async function openDocument() { const reader = new FileReader(); reader.onload = async (e) => { try { - const fileContent = e.target.result; - const doc = normalizeIdsDict(await wasm.openIDS(fileContent, false)); + const fileContent = (e.target as FileReader).result; + const doc = normalizeIdsDict(await wasm.openIDS(String(fileContent), false)) as IdsDocument; const docId = id(); // Add document to list and set as active @@ -145,16 +157,16 @@ export async function openDocument() { }); } -export async function exportActiveDocument() { +export async function exportActiveDocument(): Promise { if (!Module.activeDocument) return null; const doc = $state.snapshot(Module.documents[Module.activeDocument]); - const xmlString = await wasm.exportIDS(doc); + const xmlString = await wasm.exportIDS(doc as Record) as string; return xmlString; } -export async function exportDocument(docId) { +export async function exportDocument(docId: string) { const doc = $state.snapshot(Module.documents[docId]); // Validate @@ -162,37 +174,38 @@ export async function exportDocument(docId) { throw new Error("Please create at least one specification before exporting the document."); } - const xmlString = await wasm.exportIDS(doc); + const xmlString = await wasm.exportIDS(doc as Record) as string; // Create and download file const blob = new Blob([xmlString], { type: 'application/xml' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - a.download = `${Module.documents[docId].info.title.replace(/[^a-zA-Z0-9]/g, '_')}.ids`; + const title = Module.documents[docId].info.title || "untitled"; + a.download = `${title.replace(/[^a-zA-Z0-9]/g, '_')}.ids`; a.click(); URL.revokeObjectURL(url); } -export async function createSpecification(docId) { - const spec = await wasm.createSpecification(); +export async function createSpecification(docId: string) { + const spec = await wasm.createSpecification() as Specification; // Add specification to document Module.documents[docId].specifications.specification.push(spec); // Set as active specification - if (Module.activeDocument == docId) { + if (Module.activeDocument === docId) { const state = Module.states[docId]; state.activeSpecification = Module.documents[docId].specifications.specification.length - 1; } } -export async function deleteSpecification(docId, specId) { +export async function deleteSpecification(docId: string, specId: number) { Module.documents[docId].specifications.specification.splice(specId, 1); - if (Module.activeDocument == docId) { + if (Module.activeDocument === docId) { const state = Module.states[docId]; - if (state.activeSpecification == specId) { + if (state.activeSpecification === specId) { // We need to wait for the next tick here because of Svelte's internal shenanigans await tick(); setDocumentState(docId, { activeSpecification: null }); @@ -209,124 +222,148 @@ export async function deleteSpecification(docId, specId) { * clause: "applicability", "requirements" * facet: "entity", "attribute", "classification", "partOf", "property", "material" */ -export async function createFacet(docId, specId, clause, facet) { - let facetObj; - if (facet == "entity") { - facetObj = await wasm.createEntityFacet(clause, {}); - } else if (facet == "attribute") { - facetObj = await wasm.createAttributeFacet(clause, {}); - } else if (facet == "classification") { - facetObj = await wasm.createClassificationFacet(clause, {}); - } else if (facet == "partOf") { - facetObj = await wasm.createPartOfFacet(clause, {}); - } else if (facet == "property") { - facetObj = await wasm.createPropertyFacet(clause, {}); - } else if (facet == "material") { - facetObj = await wasm.createMaterialFacet(clause, {}); +export async function createFacet( + docId: string, + specId: number, + clause: "applicability" | "requirements", + facet: "entity" | "attribute" | "classification" | "partOf" | "property" | "material" +) { + let facetObj: Facet | undefined; + if (facet === "entity") { + facetObj = await wasm.createEntityFacet(clause, {}) as Facet; + } else if (facet === "attribute") { + facetObj = await wasm.createAttributeFacet(clause, {}) as Facet; + } else if (facet === "classification") { + facetObj = await wasm.createClassificationFacet(clause, {}) as Facet; + } else if (facet === "partOf") { + facetObj = await wasm.createPartOfFacet(clause, {}) as Facet; + } else if (facet === "property") { + facetObj = await wasm.createPropertyFacet(clause, {}) as Facet; + } else if (facet === "material") { + facetObj = await wasm.createMaterialFacet(clause, {}) as Facet; } - if (!(facet in Module.documents[docId].specifications.specification[specId][clause])) { - Module.documents[docId].specifications.specification[specId][clause][facet] = []; + if (!facetObj) return; + + const spec = Module.documents[docId].specifications.specification[specId]; + const clauseKey = clause as "applicability" | "requirements"; + if (!spec[clauseKey]) spec[clauseKey] = {}; + if (!(facet in (spec[clauseKey] as Record))) { + (spec[clauseKey] as Record)[facet] = []; } - Module.documents[docId].specifications.specification[specId][clause][facet].push(facetObj); + ((spec[clauseKey] as Record)[facet] as Facet[]).push(facetObj); } -export async function deleteFacet(docId, specId, clause, facet, facetId) { - delete Module.documents[docId].specifications.specification[specId][clause][facet][facetId]; +export async function deleteFacet( + docId: string, + specId: number, + clause: "applicability" | "requirements", + facet: "entity" | "attribute" | "classification" | "partOf" | "property" | "material", + facetId: number +) { + const spec = Module.documents[docId].specifications.specification[specId]; + const list = (spec[clause] as Record | undefined)?.[facet] as Facet[] | undefined; + if (!list) return; + list.splice(facetId, 1); } -export function getSpecUsage(spec) { +export function getSpecUsage(spec?: Specification | null): IdsCardinality { if (!spec?.applicability) return 'required'; - const minOccurs = spec.applicability["@minOccurs"]; - const maxOccurs = spec.applicability["@maxOccurs"]; - - if (minOccurs === 1 && maxOccurs === "unbounded") return 'required'; - if (minOccurs === 0 && maxOccurs === "unbounded") return 'optional'; - if (minOccurs === 0 && maxOccurs === 0) return 'prohibited'; + const minOccurs = spec.applicability["@minOccurs"] as number | undefined; + const maxOccurs = spec.applicability["@maxOccurs"] as number | "unbounded" | undefined; + + if (minOccurs !== 0) return 'required'; + if (minOccurs === 0 && maxOccurs !== 0) return 'optional'; + if (maxOccurs === 0) return 'prohibited'; return 'required'; }; // Converts facet to human-readable description -export function stringifyFacet(clauseType, facet, facetType, spec) { +export function stringifyFacet( + clauseType: "applicability" | "requirements", + facet: Facet, + facetType: string, + spec?: Specification | null +) { if (!facet) return ""; const usage = getSpecUsage(spec); - const descriptions = []; + const descriptions: string[] = []; // Entity facet if (facetType === "entity") { if (clauseType === "applicability") { - descriptions.push(`All data where IFC class ${stringifyValue(facet.name)}`); + descriptions.push(`All data where IFC class ${stringifyValue(facet.name as FacetValue)}`); } else { - descriptions.push(`Shall be data where IFC class ${stringifyValue(facet.name)}`); + descriptions.push(`Shall be data where IFC class ${stringifyValue(facet.name as FacetValue)}`); } if (facet.predefinedType) { - descriptions.push(`and type ${stringifyValue(facet.predefinedType)}`); + descriptions.push(`and type ${stringifyValue(facet.predefinedType as FacetValue)}`); } } // Attribute facet else if (facetType === "attribute") { if (clauseType === "applicability") { - descriptions.push(`All data where attribute ${stringifyValue(facet.name)}`); + descriptions.push(`All data where attribute ${stringifyValue(facet.name as FacetValue)}`); } else { - descriptions.push(`Shall be data where attribute ${stringifyValue(facet.name)}`); + descriptions.push(`Shall be data where attribute ${stringifyValue(facet.name as FacetValue)}`); } - descriptions.push(`and value ${stringifyValue(facet.value)}`); + descriptions.push(`and value ${stringifyValue(facet.value as FacetValue)}`); } // Property facet else if (facetType === "property") { if (clauseType === "applicability") { - descriptions.push(`Elements where property ${stringifyValue(facet.baseName)}`); + descriptions.push(`Elements where property ${stringifyValue(facet.baseName as FacetValue)}`); } else { - descriptions.push(`Shall be elements where property ${stringifyValue(facet.baseName)}`); + descriptions.push(`Shall be elements where property ${stringifyValue(facet.baseName as FacetValue)}`); } if (facet.value) { - descriptions.push(`and value ${stringifyValue(facet.value)}`); + descriptions.push(`and value ${stringifyValue(facet.value as FacetValue)}`); } - descriptions.push(`and dataset ${stringifyValue(facet.propertySet)}`); + descriptions.push(`and dataset ${stringifyValue(facet.propertySet as FacetValue)}`); } // Classification facet else if (facetType === "classification") { if (clauseType === "applicability") { - descriptions.push(`All data where classification system ${stringifyValue(facet.system)}`); + descriptions.push(`All data where classification system ${stringifyValue(facet.system as FacetValue)}`); } else { - descriptions.push(`Shall be data where classification system ${stringifyValue(facet.system)}`); + descriptions.push(`Shall be data where classification system ${stringifyValue(facet.system as FacetValue)}`); } if (facet.value) { - descriptions.push(`and classification ${stringifyValue(facet.value)}`); + descriptions.push(`and classification ${stringifyValue(facet.value as FacetValue)}`); } } // Material facet else if (facetType === "material") { if (clauseType === "applicability") { - descriptions.push(`All data where material ${stringifyValue(facet.value)}`); + descriptions.push(`All data where material ${stringifyValue(facet.value as FacetValue)}`); } else { - descriptions.push(`Shall be data where material ${stringifyValue(facet.value)}`); + descriptions.push(`Shall be data where material ${stringifyValue(facet.value as FacetValue)}`); } } // PartOf facet else if (facetType === "partOf") { if (clauseType === "applicability") { - descriptions.push(`An element with an **${facet['@relation']}** relationship`); + descriptions.push(`An element with an **${String(facet['@relation'] ?? "")}** relationship`); if (facet.name) { - descriptions.push(`with an entity where IFC class ${stringifyValue(facet.name)}`); + descriptions.push(`with an entity where IFC class ${stringifyValue(facet.name as FacetValue)}`); } } else { - descriptions.push(`An element shall have an **${facet['@relation']}** relationship`); + descriptions.push(`An element shall have an **${String(facet['@relation'] ?? "")}** relationship`); if (facet.name) { - descriptions.push(`with an entity where IFC class ${stringifyValue(facet.name)}`); + descriptions.push(`with an entity where IFC class ${stringifyValue(facet.name as FacetValue)}`); } if (facet.predefinedType) { - descriptions.push(`and predefined type ${stringifyValue(facet.predefinedType)}`); + descriptions.push(`and predefined type ${stringifyValue(facet.predefinedType as FacetValue)}`); } } } @@ -336,20 +373,22 @@ export function stringifyFacet(clauseType, facet, facetType, spec) { // Post-process for prohibited and optional requirements let isProhibited = false; - if (usage == "prohibited") isProhibited = !isProhibited; - if (clauseType == "requirements" && "@cardinality" in facet && facet["@cardinality"] == "prohibited") isProhibited = !isProhibited; + if (usage === "prohibited") isProhibited = !isProhibited; + if (clauseType === "requirements" && "@cardinality" in facet && facet["@cardinality"] === "prohibited") { + isProhibited = !isProhibited; + } if (isProhibited) combined = combined.replace("Shall", "Shall not").replace("shall", "shall not"); - if (clauseType == "requirements" && "@cardinality" in facet && facet["@cardinality"] == "optional") + if (clauseType === "requirements" && "@cardinality" in facet && facet["@cardinality"] === "optional") combined = combined.replace("Shall", "May").replace("shall", "may"); return renderFacetString(combined); } // Converts value objects to human-readable strings -function stringifyValue(value) { +function stringifyValue(value?: FacetValue) { if (!value) return "is provided"; if (value.simpleValue) return `is **${value.simpleValue}**`; if (value.restriction) return stringifyRestriction(value.restriction); @@ -357,7 +396,7 @@ function stringifyValue(value) { } // Converts restriction objects to human-readable strings -function stringifyRestriction(restriction) { +function stringifyRestriction(restriction: Restriction) { if (!restriction) return ""; // Handle enumeration @@ -394,7 +433,7 @@ function stringifyRestriction(restriction) { if (restriction.maxExclusive && restriction.maxExclusive.length > 0) { parts.push(`**< ${restriction.maxExclusive[0]['@value'] || ''}**`); } - return parts.length > 0 ? "is in range " + parts.join(", ") : "has range restriction"; + return parts.length > 0 ? `is in range ${parts.join(", ")}` : "has range restriction"; } // Handle length range restrictions @@ -406,18 +445,18 @@ function stringifyRestriction(restriction) { if (restriction.maxLength && restriction.maxLength.length > 0) { parts.push(`**max length ${restriction.maxLength[0]['@value'] || ''}**`); } - return parts.length > 0 ? "has " + parts.join(", ") : "has length range restriction"; + return parts.length > 0 ? `has ${parts.join(", ")}` : "has length range restriction"; } return "has complex restriction"; } -function renderFacetString(text) { +function renderFacetString(text: string): string { // Convert **text** to text - text = text.replace(/\*\*([^*]+)\*\*/g, '$1'); + const withStrong = text.replace(/\*\*([^*]+)\*\*/g, '$1'); // Convert `text` to text - text = text.replace(/`([^`]+)`/g, '$1'); + const withCode = withStrong.replace(/`([^`]+)`/g, '$1'); - return text; -} \ No newline at end of file + return withCode; +} diff --git a/src/ifctester/webapp/src/modules/utils/toast.svelte.js b/src/ifctester/webapp/src/modules/utils/toast.svelte.ts similarity index 73% rename from src/ifctester/webapp/src/modules/utils/toast.svelte.js rename to src/ifctester/webapp/src/modules/utils/toast.svelte.ts index eaf9b373f0..335be48645 100644 --- a/src/ifctester/webapp/src/modules/utils/toast.svelte.js +++ b/src/ifctester/webapp/src/modules/utils/toast.svelte.ts @@ -4,7 +4,7 @@ import { toast } from "svelte-sonner"; * Show an error toast notification * @param {string} message - The error message to display */ -export function error(message) { +export function error(message: string): void { toast.error(message); } @@ -12,7 +12,7 @@ export function error(message) { * Show a success toast notification * @param {string} message - The success message to display */ -export function success(message) { +export function success(message: string): void { toast.success(message); } @@ -20,7 +20,7 @@ export function success(message) { * Show an info toast notification * @param {string} message - The info message to display */ -export function info(message) { +export function info(message: string): void { toast.info(message); } @@ -28,7 +28,7 @@ export function info(message) { * Show a warning toast notification * @param {string} message - The warning message to display */ -export function warning(message) { +export function warning(message: string): void { toast.warning(message); } @@ -37,7 +37,7 @@ export function warning(message) { * @param {string} message - The loading message to display * @returns {string} - Toast ID for dismissing later */ -export function loading(message) { +export function loading(message: string): string | number { return toast.loading(message); } @@ -45,7 +45,7 @@ export function loading(message) { * Dismiss a specific toast * @param {string} toastId - The toast ID to dismiss */ -export function dismiss(toastId) { +export function dismiss(toastId: string | number): void { toast.dismiss(toastId); } @@ -57,10 +57,16 @@ export function dismiss(toastId) { * @param {string} messages.success - Success message * @param {string} messages.error - Error message */ -export function promise(promiseToTrack, messages) { +type PromiseToastMessages = { + loading: string; + success: string; + error: string; +}; + +export function promise(promiseToTrack: Promise, messages: PromiseToastMessages) { return toast.promise(promiseToTrack, { loading: messages.loading, success: messages.success, error: messages.error, }); -} \ No newline at end of file +} diff --git a/src/ifctester/webapp/src/modules/wasm/index.js b/src/ifctester/webapp/src/modules/wasm/index.ts similarity index 69% rename from src/ifctester/webapp/src/modules/wasm/index.js rename to src/ifctester/webapp/src/modules/wasm/index.ts index cecb655f73..d7c4f1e40a 100644 --- a/src/ifctester/webapp/src/modules/wasm/index.js +++ b/src/ifctester/webapp/src/modules/wasm/index.ts @@ -5,6 +5,7 @@ import hyperid from "hyperid"; import EventEmitter from "eventemitter3"; +import type { WorkerResponse } from "$src/types/wasm"; // Message types export const MessageType = { @@ -25,52 +26,57 @@ export const MessageType = { // WASM module disposed DISPOSED: 'disposed' +} as const; + +type PendingMessage = { + resolve: (value?: unknown) => void; + reject: (reason?: unknown) => void; }; +type WasmReadyState = boolean | Promise; + class WASMModule extends EventEmitter { id = hyperid(); - ready = false; - worker = null; - pendingMessages = new Map(); + ready: WasmReadyState = false; + worker: Worker | null = null; + pendingMessages = new Map(); async init() { if (this.ready === true) return; - else if (this.ready instanceof Promise) return this.ready; + if (this.ready instanceof Promise) return this.ready; - this.worker = new Worker(new URL('./worker/worker.js', import.meta.url), {type: 'module'}); + this.worker = new Worker(new URL('./worker/worker.ts', import.meta.url), {type: 'module'}); - this.worker.onmessage = (event) => { + this.worker.onmessage = (event: MessageEvent) => { this._handleWorkerMessage(event.data); }; - this.worker.onerror = (error) => { + this.worker.onerror = (error: ErrorEvent) => { console.error('[WASM] Web worker error:', error); this._rejectPendingMessages(error); }; - this.ready = new Promise(async (resolve, reject) => { - try { - await this._sendMessage(MessageType.INIT); - resolve(true); - } catch (error) { + this.ready = this._sendMessage(MessageType.INIT) + .then(() => true) + .catch((error) => { console.error('[WASM] Failed to initialize:', error); this.ready = false; - reject(error); - } - }); + throw error; + }); return this.ready; } - async _sendMessage(type, payload = {}) { - if (!this.worker) throw new Error('Worker not initialized'); + async _sendMessage(type: string, payload: Record = {}): Promise { + const worker = this.worker; + if (!worker) throw new Error('Worker not initialized'); const id = this.id(); return new Promise((resolve, reject) => { this.pendingMessages.set(id, { resolve, reject }); - this.worker.postMessage({ + worker.postMessage({ type, payload, id @@ -78,7 +84,7 @@ class WASMModule extends EventEmitter { }); } - _handleWorkerMessage({ type, payload, id }) { + _handleWorkerMessage({ type, payload, id }: WorkerResponse) { const pendingMessage = this.pendingMessages.get(id); if (!pendingMessage) { @@ -97,23 +103,28 @@ class WASMModule extends EventEmitter { case MessageType.API_RESPONSE: resolve(payload); break; - case MessageType.ERROR: - reject(new Error(payload.message)); + case MessageType.ERROR: { + const message = + payload && typeof payload === "object" && "message" in payload + ? String(payload.message) + : "Unknown worker error"; + reject(new Error(message)); break; + } default: console.warn('[WASM] Unknown message type:', type); reject(new Error(`Unknown message type: ${type}`)); } } - _rejectPendingMessages(error) { + _rejectPendingMessages(error: unknown) { for (const { reject } of this.pendingMessages.values()) { reject(error); } this.pendingMessages.clear(); } - async _apiCall(method, ...args) { + async _apiCall(method: string, ...args: unknown[]) { if (!this.ready) await this.init(); const result = await this._sendMessage(MessageType.API_CALL, { method, args }); @@ -123,35 +134,35 @@ class WASMModule extends EventEmitter { /** * Get all entity classes in a given IFC schema */ - async getAllEntityClasses(schema) { + async getAllEntityClasses(schema: string) { return this._apiCall('getAllEntityClasses', schema); } /** * Get all data types in a given IFC schema */ - async getAllDataTypes(schema) { + async getAllDataTypes(schema: string) { return this._apiCall('getAllDataTypes', schema); } /** * Get predefined types for a given IFC entity */ - async getPredefinedTypes(schema, entity) { + async getPredefinedTypes(schema: string, entity: string) { return this._apiCall('getPredefinedTypes', schema, entity); } /** * Get all attributes for a given IFC entity */ - async getEntityAttributes(schema, entity) { + async getEntityAttributes(schema: string, entity: string) { return this._apiCall('getEntityAttributes', schema, entity); } /** * Get applicable property sets for a given IFC entity */ - async getApplicablePsets(schema, entity, predefinedType = '') { + async getApplicablePsets(schema: string, entity: string, predefinedType = '') { return this._apiCall('getApplicablePsets', schema, entity, predefinedType); } @@ -172,21 +183,21 @@ class WASMModule extends EventEmitter { /** * Load an IFC file. Returns a unique ID for the loaded file. */ - async loadIfc(ifcData) { + async loadIfc(ifcData: number[] | Uint8Array | ArrayBuffer) { return this._apiCall('loadIfc', ifcData); } /** * Unload an IFC file */ - async unloadIfc(ifcId) { + async unloadIfc(ifcId: string) { return this._apiCall('unloadIfc', ifcId); } /** * Audit a loaded IFC file against IDS specifications */ - async auditIfc(ifcId, idsData) { + async auditIfc(ifcId: string, idsData: ArrayBuffer | Uint8Array | number[]) { const idsBytes = idsData instanceof ArrayBuffer ? new Uint8Array(idsData) : idsData; return this._apiCall('auditIfc', ifcId, Array.from(idsBytes)); @@ -204,70 +215,70 @@ class WASMModule extends EventEmitter { /** * Open an existing IDS from XML string */ - async openIDS(idsXml, validate = false) { + async openIDS(idsXml: string, validate = false) { return this._apiCall('openIDS', idsXml, validate); } /** * Create a specification */ - async createSpecification(options = {}) { + async createSpecification(options: Record = {}) { return this._apiCall('createSpecification', options); } /** * Create an entity facet */ - async createEntityFacet(clause, options = {}) { + async createEntityFacet(clause: string, options: Record = {}) { return this._apiCall('createEntityFacet', clause, options); } /** * Create an attribute facet */ - async createAttributeFacet(clause, options = {}) { + async createAttributeFacet(clause: string, options: Record = {}) { return this._apiCall('createAttributeFacet', clause, options); } /** * Create a property facet */ - async createPropertyFacet(clause, options = {}) { + async createPropertyFacet(clause: string, options: Record = {}) { return this._apiCall('createPropertyFacet', clause, options); } /** * Create a material facet */ - async createMaterialFacet(clause, options = {}) { + async createMaterialFacet(clause: string, options: Record = {}) { return this._apiCall('createMaterialFacet', clause, options); } /** * Create a classification facet */ - async createClassificationFacet(clause, options = {}) { + async createClassificationFacet(clause: string, options: Record = {}) { return this._apiCall('createClassificationFacet', clause, options); } /** * Create a part-of facet */ - async createPartOfFacet(clause, options = {}) { + async createPartOfFacet(clause: string, options: Record = {}) { return this._apiCall('createPartOfFacet', clause, options); } /** * Validate an IDS object */ - async validateIDS(idsObj) { + async validateIDS(idsObj: Record) { return await this._apiCall('validateIDS', idsObj); } /** * Export IDS instance to XML string */ - async exportIDS(idsObj) { + async exportIDS(idsObj: Record) { return this._apiCall('exportIDS', idsObj); } @@ -319,4 +330,4 @@ export const { dispose } = wasm; -export default wasm; \ No newline at end of file +export default wasm; diff --git a/src/ifctester/webapp/src/modules/wasm/worker/api.js b/src/ifctester/webapp/src/modules/wasm/worker/api.ts similarity index 80% rename from src/ifctester/webapp/src/modules/wasm/worker/api.js rename to src/ifctester/webapp/src/modules/wasm/worker/api.ts index d08c17efe2..283971bc27 100644 --- a/src/ifctester/webapp/src/modules/wasm/worker/api.js +++ b/src/ifctester/webapp/src/modules/wasm/worker/api.ts @@ -1,12 +1,13 @@ -import config from '../../../config.json'; import hyperid from 'hyperid'; +import config from '../../../config.json'; +import type { AuditReportData } from "$src/types/report"; -let pyodide = null; -let id = hyperid(); +let pyodide: any = null; +const id = hyperid(); -let LoadedIFC = new Map(); +const LoadedIFC = new Map(); -export async function init(pdide) { +export async function init(pdide: any) { pyodide = pdide; // Load Python API bindings @@ -18,7 +19,7 @@ export async function init(pdide) { `); } -export async function getPredefinedTypes(schema, entity) { +export async function getPredefinedTypes(schema: string, entity: string) { const result = await pyodide.runPythonAsync(` from api import get_predefined_types_for_entity predef_types = get_predefined_types_for_entity("${schema}", "${entity}") @@ -27,7 +28,7 @@ export async function getPredefinedTypes(schema, entity) { return result.toJs({ dict_converter: Object.fromEntries }); } -export async function getAllEntityClasses(schema) { +export async function getAllEntityClasses(schema: string) { const result = await pyodide.runPythonAsync(` from api import get_all_entity_classes entities = get_all_entity_classes("${schema}") @@ -36,7 +37,7 @@ export async function getAllEntityClasses(schema) { return result.toJs({ dict_converter: Object.fromEntries }); } -export async function getAllDataTypes(schema) { +export async function getAllDataTypes(schema: string) { const result = await pyodide.runPythonAsync(` from api import get_all_data_types data_types = get_all_data_types("${schema}") @@ -45,7 +46,7 @@ export async function getAllDataTypes(schema) { return result.toJs({ dict_converter: Object.fromEntries }); } -export async function getEntityAttributes(schema, entity) { +export async function getEntityAttributes(schema: string, entity: string) { const result = await pyodide.runPythonAsync(` from api import get_entity_attributes attrs = get_entity_attributes("${schema}", "${entity}") @@ -54,7 +55,7 @@ export async function getEntityAttributes(schema, entity) { return result.toJs({ dict_converter: Object.fromEntries }); } -export async function getApplicablePsets(schema, entity, predefinedType = '') { +export async function getApplicablePsets(schema: string, entity: string, predefinedType = '') { const result = await pyodide.runPythonAsync(` from api import get_applicable_psets psets = get_applicable_psets("${schema}", "${entity}", "${predefinedType}") @@ -81,7 +82,7 @@ export async function getStandardClassificationSystems() { return result.toJs({ dict_converter: Object.fromEntries }); } -export async function loadIfc(ifcData) { +export async function loadIfc(ifcData: number[] | Uint8Array | ArrayBuffer) { const ifc_id = id(); const path = `/tmp/${encodeURIComponent(ifc_id)}.ifc`; @@ -97,14 +98,14 @@ export async function loadIfc(ifcData) { return ifc_id; } -export async function unloadIfc(ifcId) { +export async function unloadIfc(ifcId: string) { const path = `/tmp/${encodeURIComponent(ifcId)}.ifc`; pyodide.FS.unlink(path); LoadedIFC.delete(ifcId); } -export async function auditIfc(ifcId, idsData) { +export async function auditIfc(ifcId: string, idsData: number[] | Uint8Array | ArrayBuffer) { const reporter = pyodide.pyimport("ifctester.reporter"); const api = pyodide.pyimport("api"); @@ -116,16 +117,16 @@ export async function auditIfc(ifcId, idsData) { specs.validate(ifc); // Create report in both HTML and JSON formats - let jsonReporter = reporter.Json(specs); + const jsonReporter = reporter.Json(specs); jsonReporter.report(); const jsonReport = jsonReporter.to_string(); - let htmlReporter = reporter.Html(specs); + const htmlReporter = reporter.Html(specs); htmlReporter.report(); const htmlReport = htmlReporter.to_string(); return { - json: JSON.parse(jsonReport), + json: JSON.parse(jsonReport) as AuditReportData, html: htmlReport }; } @@ -142,4 +143,4 @@ export const API = { "loadIfc": loadIfc, "unloadIfc": unloadIfc, "auditIfc": auditIfc -}; \ No newline at end of file +}; diff --git a/src/ifctester/webapp/src/modules/wasm/worker/ids.js b/src/ifctester/webapp/src/modules/wasm/worker/ids.ts similarity index 57% rename from src/ifctester/webapp/src/modules/wasm/worker/ids.js rename to src/ifctester/webapp/src/modules/wasm/worker/ids.ts index 8afc023cec..ca34b21d75 100644 --- a/src/ifctester/webapp/src/modules/wasm/worker/ids.js +++ b/src/ifctester/webapp/src/modules/wasm/worker/ids.ts @@ -2,13 +2,19 @@ * IDS module */ -let pyodide = null; +let pyodide: any = null; // IDS Python classes -let Ids, Specification; -let Entity, Attribute, Property, Material, Classification, PartOf; +let Ids: any; +let Specification: any; +let Entity: any; +let Attribute: any; +let Property: any; +let Material: any; +let Classification: any; +let PartOf: any; -export async function init(pdide) { +export async function init(pdide: any) { pyodide = pdide; await pyodide.loadPackagesFromImports(` @@ -29,24 +35,24 @@ export async function init(pdide) { PartOf = pyodide.pyimport("ifctester.facet").PartOf; } -function _idsToInstance(idsObj) { +function _idsToInstance(idsObj: Record) { const ids_raw = Ids(); return ids_raw.parse(pyodide.toPy(idsObj)) } -export function createIDS() { +export function createIDS(): Record { const ids_raw = Ids() return ids_raw.asdict().toJs({dict_converter: Object.fromEntries}); } -export function openIDS(ids_xml, validate = false) { +export function openIDS(ids_xml: string, validate = false): Record { const ids_from_xml_string = pyodide.pyimport("api").ids_from_xml_string; const ids_raw = ids_from_xml_string(ids_xml, validate); return ids_raw.asdict().toJs({dict_converter: Object.fromEntries}); } -export function validateIDS(idsObj) { +export function validateIDS(idsObj: Record): boolean { const ids_raw = _idsToInstance(idsObj) const tempFilename = `temp_${Date.now()}.xml`; const isValid = ids_raw.to_xml(tempFilename); // to_xml validates the XML as well, as far as I understand @@ -60,12 +66,26 @@ export function validateIDS(idsObj) { return isValid; } -export function exportIDS(idsObj) { +export function exportIDS(idsObj: Record): string { const ids_raw = _idsToInstance(idsObj) return ids_raw.to_string(); } -export function createSpecification({name = "Unnamed", ifcVersion = ["IFC2X3", "IFC4"], identifier = null, description = null, instructions = null, usage = "required"}) { +export function createSpecification({ + name = "Unnamed", + ifcVersion = ["IFC2X3", "IFC4"], + identifier = null, + description = null, + instructions = null, + usage = "required" +}: { + name?: string; + ifcVersion?: string[]; + identifier?: string | null; + description?: string | null; + instructions?: string | null; + usage?: string; +} = {}): Record { const spec = Specification.callKwargs({ name: name, ifcVersion: ifcVersion, @@ -79,7 +99,14 @@ export function createSpecification({name = "Unnamed", ifcVersion = ["IFC2X3", " } // @instructions -export function createEntityFacet(clause, {name = "IFCWALL", predefinedType = null, instructions = null}) { +export function createEntityFacet( + clause: string, + {name = "IFCWALL", predefinedType = null, instructions = null}: { + name?: string; + predefinedType?: string | null; + instructions?: string | null; + } = {} +): Record { const entity = Entity.callKwargs({ name: name, predefinedType: predefinedType, @@ -89,7 +116,15 @@ export function createEntityFacet(clause, {name = "IFCWALL", predefinedType = nu } // @cardinality, @instructions -export function createAttributeFacet(clause, {name = "Name", value = null, cardinality = "required", instructions = null}) { +export function createAttributeFacet( + clause: string, + {name = "Name", value = null, cardinality = "required", instructions = null}: { + name?: string; + value?: string | null; + cardinality?: string; + instructions?: string | null; + } = {} +): Record { const attribute = Attribute.callKwargs({ name: name, value: value, @@ -100,7 +135,16 @@ export function createAttributeFacet(clause, {name = "Name", value = null, cardi } // @uri, @cardinality, @instructions -export function createClassificationFacet(clause, {value = null, system = null, uri = null, cardinality = "required", instructions = null}) { +export function createClassificationFacet( + clause: string, + {value = null, system = null, uri = null, cardinality = "required", instructions = null}: { + value?: string | null; + system?: string | null; + uri?: string | null; + cardinality?: string; + instructions?: string | null; + } = {} +): Record { const classification = Classification.callKwargs({ value: value, system: system, @@ -112,7 +156,16 @@ export function createClassificationFacet(clause, {value = null, system = null, } // @relation, @cardinality, @instructions -export function createPartOfFacet(clause, {name = "IFCWALL", predefinedType = null, relation = null, cardinality = "required", instructions = null}) { +export function createPartOfFacet( + clause: string, + {name = "IFCWALL", predefinedType = null, relation = null, cardinality = "required", instructions = null}: { + name?: string; + predefinedType?: string | null; + relation?: string | null; + cardinality?: string; + instructions?: string | null; + } = {} +): Record { const part_of = PartOf.callKwargs({ name: name, predefinedType: predefinedType, @@ -124,7 +177,26 @@ export function createPartOfFacet(clause, {name = "IFCWALL", predefinedType = nu } // @dataType, @uri, @cardinality, @instructions -export function createPropertyFacet(clause, {propertySet = "Property_Set", baseName = "propertyName", value = null, dataType = null, uri = null, cardinality = "required", instructions = null}) { +export function createPropertyFacet( + clause: string, + { + propertySet = "Property_Set", + baseName = "propertyName", + value = null, + dataType = null, + uri = null, + cardinality = "required", + instructions = null + }: { + propertySet?: string; + baseName?: string; + value?: string | null; + dataType?: string | null; + uri?: string | null; + cardinality?: string; + instructions?: string | null; + } = {} +): Record { const property = Property.callKwargs({ propertySet: propertySet, baseName: baseName, @@ -138,7 +210,15 @@ export function createPropertyFacet(clause, {propertySet = "Property_Set", baseN } // @uri, @cardinality, @instructions -export function createMaterialFacet(clause, {value = null, uri = null, cardinality = "required", instructions = null}) { +export function createMaterialFacet( + clause: string, + {value = null, uri = null, cardinality = "required", instructions = null}: { + value?: string | null; + uri?: string | null; + cardinality?: string; + instructions?: string | null; + } = {} +): Record { const material = Material.callKwargs({ value: value, uri: uri, @@ -149,7 +229,7 @@ export function createMaterialFacet(clause, {value = null, uri = null, cardinali } // Helper function to convert date to ISO format string -export function formatDate(date) { +export function formatDate(date?: string | number | Date | null): string | null { if (!date) return null; const d = new Date(date); return d.toISOString().split('T')[0]; @@ -168,4 +248,4 @@ export const API = { "createPartOfFacet": createPartOfFacet, "createPropertyFacet": createPropertyFacet, "createMaterialFacet": createMaterialFacet, -}; \ No newline at end of file +}; diff --git a/src/ifctester/webapp/src/modules/wasm/worker/worker.js b/src/ifctester/webapp/src/modules/wasm/worker/worker.ts similarity index 61% rename from src/ifctester/webapp/src/modules/wasm/worker/worker.js rename to src/ifctester/webapp/src/modules/wasm/worker/worker.ts index 6bf47e46fe..fe4b188644 100644 --- a/src/ifctester/webapp/src/modules/wasm/worker/worker.js +++ b/src/ifctester/webapp/src/modules/wasm/worker/worker.ts @@ -4,13 +4,14 @@ import { MessageType } from '../index'; import config from '../../../config.json'; -import * as IDS from './ids.js'; +import * as IDS from './ids'; import * as API from './api'; +import type { ApiCallPayload, WorkerRequest } from "$src/types/wasm"; -let pyodide = null; +let pyodide: any = null; let ready = false; -self.addEventListener('message', async (event) => { +self.addEventListener('message', async (event: MessageEvent) => { console.log("[worker] Received message:", event.data); const { type, payload, id } = event.data; @@ -25,27 +26,33 @@ self.addEventListener('message', async (event) => { }); break; - case MessageType.API_CALL: + case MessageType.API_CALL: { if (!ready) { throw new Error('[worker] Pyodide not initialized'); } - const result = await handleApiCall(payload); + if (!payload) { + throw new Error('[worker] Missing payload for API call'); + } + const result = await handleApiCall(payload as ApiCallPayload); self.postMessage({ type: MessageType.API_RESPONSE, payload: result, id }); break; + } default: throw new Error(`[worker] Unknown message type: ${type}`); } } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const stack = error instanceof Error ? error.stack : undefined; self.postMessage({ type: MessageType.ERROR, payload: { - message: error.message, - stack: error.stack + message, + stack }, id }); @@ -76,7 +83,13 @@ async function initEnvironment() { await pyodide.loadPackage("shapely"); // Install IfcTester - await micropip.install('ifctester'); + const ifctesterManifest = await fetch('/worker/generated/ifctester.json').then((response) => { + if (!response.ok) { + throw new Error(`[worker] Failed to load IfcTester wheel manifest: ${response.status} ${response.statusText}`); + } + return response.json() as Promise<{ wheel_url: string }>; + }); + await micropip.install(ifctesterManifest.wheel_url); // Initialize IDS and API await API.init(pyodide); @@ -93,17 +106,17 @@ async function cleanupEnvironment() { console.log("[worker] Closed environment"); } -async function handleApiCall({ method, args = [] }) { +async function handleApiCall({ method, args = [] }: ApiCallPayload) { if (method === 'internal.cleanup') { await cleanupEnvironment(); return true; } if (method in API.API) { - return await API.API[method](...args); - } else if (method in IDS.API) { - return await IDS.API[method](...args); - } else { - throw new Error(`[worker] Unknown API method: ${method}`); + return await (API.API as Record unknown>)[method](...args); } -} \ No newline at end of file + if (method in IDS.API) { + return await (IDS.API as Record unknown>)[method](...args); + } + throw new Error(`[worker] Unknown API method: ${method}`); +} diff --git a/src/ifctester/webapp/src/pages/Home/ApplicabilityPanel.svelte b/src/ifctester/webapp/src/pages/Home/ApplicabilityPanel.svelte index 2bf687ecc0..df2cfbe9b0 100644 --- a/src/ifctester/webapp/src/pages/Home/ApplicabilityPanel.svelte +++ b/src/ifctester/webapp/src/pages/Home/ApplicabilityPanel.svelte @@ -1,37 +1,56 @@ -
@@ -40,22 +59,19 @@
- {#if activeSpecification?.applicability} - {#each Object.entries(activeSpecification.applicability) as [facetType, facets]} - {#if facetType !== "@minOccurs" && facetType !== "@maxOccurs"} - {#each facets as facet, index} - - {/each} - {/if} + {#if activeSpecification && applicabilityEntries.length > 0} + {#each applicabilityEntries as [facetType, facets]} + {#each facets as facet, index} + + {/each} {/each} {/if}
- \ No newline at end of file + diff --git a/src/ifctester/webapp/src/pages/Home/FacetEditor.svelte b/src/ifctester/webapp/src/pages/Home/FacetEditor.svelte index 4d807625b3..dbc6091f95 100644 --- a/src/ifctester/webapp/src/pages/Home/FacetEditor.svelte +++ b/src/ifctester/webapp/src/pages/Home/FacetEditor.svelte @@ -1,20 +1,40 @@ -
@@ -48,8 +68,8 @@
- - getSpecialProp("@relation"), (v) => setSpecialProp("@relation", v)}> @@ -62,8 +82,8 @@ {#if activeTab === 'requirements'} {#if facetType !== 'entity'}
- - getSpecialProp("@cardinality"), (v) => setSpecialProp("@cardinality", v)}> @@ -71,9 +91,9 @@
{/if}
- - + +
{/if}
-
\ No newline at end of file + diff --git a/src/ifctester/webapp/src/pages/Home/IdsMetadataEditor.svelte b/src/ifctester/webapp/src/pages/Home/IdsMetadataEditor.svelte index 59cd95c0a5..63b762fc5e 100644 --- a/src/ifctester/webapp/src/pages/Home/IdsMetadataEditor.svelte +++ b/src/ifctester/webapp/src/pages/Home/IdsMetadataEditor.svelte @@ -1,13 +1,17 @@ - @@ -18,36 +22,36 @@
- - getProp("title"), (v) => setProp("title", v)} placeholder="Enter IDS title"> + + getProp("title"), (v) => setProp("title", v)} placeholder="Enter IDS title">
- - getProp("author"), (v) => setProp("author", v)} placeholder="Enter author"> + + getProp("author"), (v) => setProp("author", v)} placeholder="Enter author">
- - getProp("version"), (v) => setProp("version", v)} placeholder="Enter version"> + + getProp("version"), (v) => setProp("version", v)} placeholder="Enter version">
- - getProp("date"), (v) => setProp("date", v)}> + + getProp("date"), (v) => setProp("date", v)}>
- - + +
- - getProp("purpose"), (v) => setProp("purpose", v)} placeholder="Enter purpose"> + + getProp("purpose"), (v) => setProp("purpose", v)} placeholder="Enter purpose">
- - getProp("milestone"), (v) => setProp("milestone", v)} placeholder="Enter milestone"> + + getProp("milestone"), (v) => setProp("milestone", v)} placeholder="Enter milestone">
- - getProp("copyright"), (v) => setProp("copyright", v)} placeholder="Enter copyright"> + + getProp("copyright"), (v) => setProp("copyright", v)} placeholder="Enter copyright">
diff --git a/src/ifctester/webapp/src/pages/Home/IdsViewer.svelte b/src/ifctester/webapp/src/pages/Home/IdsViewer.svelte index 2fda5f0c2c..beadd151b8 100644 --- a/src/ifctester/webapp/src/pages/Home/IdsViewer.svelte +++ b/src/ifctester/webapp/src/pages/Home/IdsViewer.svelte @@ -1,18 +1,28 @@ -
@@ -207,8 +254,17 @@
{#each activeDocument.specifications.specification as spec, index} + {@const usage = getDocumentSpecificationUsage(spec)} + {@const requirementGroups = getRequirementGroups(spec)}
-
toggleSpecification(index)}> +
toggleSpecification(index)} + onkeydown={(event) => handleActivation(event, () => toggleSpecification(index))} + >

{spec["@name"] || `Specification ${index + 1}`}

@@ -237,19 +293,19 @@

{spec["@description"]}

{/if}
- {#if spec.applicability["@minOccurs"] === 1 && spec.applicability["@maxOccurs"] === 'unbounded'} + {#if usage === 'required'} Required {/if} - {#if spec.applicability["@minOccurs"] === 0 && spec.applicability["@maxOccurs"] === 'unbounded'} + {#if usage === 'optional'} Optional {/if} - {#if spec.applicability["@minOccurs"] === 0 && spec.applicability["@maxOccurs"] === 0} + {#if usage === 'prohibited'} Prohibited {/if} {#if auditReport} {@const stats = getSpecificationStats(index, auditReport.data)} {@const status = getSpecificationStatus(index, auditReport.data)} - {#if stats && spec.applicability["@maxOccurs"] !== 0 && status !== 'skipped'} + {#if stats && usage !== 'prohibited' && status !== 'skipped'} Checks: {stats.checksPassed}/{stats.checksTotal} Requirements: {stats.requirementsPassed}/{stats.requirements} {/if} @@ -302,12 +358,13 @@ {#if auditReport} {@const status = getSpecificationStatus(index, auditReport.data)} - {#if ! status && spec.applicability["@maxOccurs"] == 0} + {#if status === false && usage === 'prohibited'} {@const specReport = auditReport.data.specifications[index]} + {@const applicableEntities = specReport.applicable_entities ?? []}
- {#if specReport.applicable_entities && specReport.applicable_entities.length > 0} + {#if applicableEntities.length > 0}
-

Failed Elements ({specReport.applicable_entities.length})

+

Failed Elements ({applicableEntities.length})

@@ -323,7 +380,7 @@ - {#each specReport.applicable_entities.slice(0, 10) as entity} + {#each applicableEntities.slice(0, 10) as entity} @@ -379,9 +436,9 @@ {/each} - {#if specReport.applicable_entities.length > 10} + {#if applicableEntities.length > 10} - + {/if} @@ -397,37 +454,36 @@ - {#if Array.isArray(spec.requirements) && spec.requirements.length > 0} + {#if requirementGroups.length > 0}

Requirements

- {#each Object.entries(spec.requirements || {}) as [facetType, facets]} - {#if Array.isArray(facets) && facets.length > 0} -
- {#each facets as facet, facetIndex} - {@const reqAuditData = auditReport ? getRequirementStatus(index, facetIndex, auditReport.data) : null} - {@const specStatus = auditReport ? getSpecificationStatus(index, auditReport.data) : null} -
- - {#if isRequirementDetailsExpanded(index, facetIndex)} + {/if} + + {#if reqAuditData && isRequirementDetailsExpanded(index, item.reqIndex)}
{#if reqAuditData.passed_entities && reqAuditData.passed_entities.length > 0} @@ -592,11 +648,10 @@ {/if}
- {/if} -
- {/each} -
- {/if} + {/if} +
+ {/each} +
{/each} diff --git a/src/ifctester/webapp/src/pages/Home/RequirementsPanel.svelte b/src/ifctester/webapp/src/pages/Home/RequirementsPanel.svelte index 189d9c7066..b034778a67 100644 --- a/src/ifctester/webapp/src/pages/Home/RequirementsPanel.svelte +++ b/src/ifctester/webapp/src/pages/Home/RequirementsPanel.svelte @@ -1,37 +1,53 @@ -
@@ -40,8 +56,8 @@
- {#if activeSpecification?.requirements} - {#each Object.entries(activeSpecification.requirements) as [facetType, facets]} + {#if activeSpecification && requirementEntries.length > 0} + {#each requirementEntries as [facetType, facets]} {#each facets as facet, index} {/each} {/each} {/if}
- \ No newline at end of file + diff --git a/src/ifctester/webapp/src/pages/Home/RestrictionEditor.svelte b/src/ifctester/webapp/src/pages/Home/RestrictionEditor.svelte index 3bc5042065..c8942b2a52 100644 --- a/src/ifctester/webapp/src/pages/Home/RestrictionEditor.svelte +++ b/src/ifctester/webapp/src/pages/Home/RestrictionEditor.svelte @@ -1,34 +1,86 @@ -
- -
+ {label} +
{#if !isSpecialProp}
- handleTypeChange((e.target as HTMLSelectElement).value)} + > @@ -477,7 +537,7 @@ placeholder={placeholder} /> {:else} - getSimpleValue(), (v) => setSimpleValue(v)} {placeholder}> + getSimpleValue(), (v) => setSimpleValue(v)} {placeholder} aria-label={label}> {/if} {:else if restrictionType === 'Enumeration'} @@ -497,7 +557,14 @@ placeholder={placeholder} /> {:else} - updateEnumerationValue(index, e.target.value)} {placeholder}> + updateEnumerationValue(index, (e.target as HTMLInputElement).value)} + {placeholder} + aria-label={`${label} option ${index + 1}`} + > {/if}
{:else if restrictionType === 'Pattern'} - getPatternValue(), (v) => setPatternValue(v)} placeholder="Enter regex pattern (e.g., DT[0-9]{2})"> + getPatternValue(), (v) => setPatternValue(v)} placeholder="Enter regex pattern (e.g., DT[0-9]{2})" aria-label={`${label} pattern`}> {:else if restrictionType === 'Range'} + {@const range = getRangeValues()}
- - getRangeValues().min, (v) => { const range = getRangeValues(); setRangeValues(v, range.max, range.minType, range.maxType); }} placeholder="0"> - range.min, (v) => setRangeValues(v, range.max, range.minType, range.maxType)} + placeholder="0" + > +
- - getRangeValues().max, (v) => { const range = getRangeValues(); setRangeValues(range.min, v, range.minType, range.maxType); }} placeholder="0"> - range.max, (v) => setRangeValues(range.min, v, range.minType, range.maxType)} + placeholder="0" + > + @@ -538,17 +626,18 @@
{:else if restrictionType === 'Length'} - getLengthValue(), (v) => setLengthValue(v)} placeholder="Enter exact length"> + getLengthValue(), (v) => setLengthValue(v)} placeholder="Enter exact length" aria-label={`${label} length`}> {:else if restrictionType === 'Length Range'} + {@const lengthRange = getLengthRangeValues()}
- - getLengthRangeValues().min, (v) => { const range = getLengthRangeValues(); setLengthRangeValues(v, range.max); }} placeholder="0"> + + lengthRange.min, (v) => setLengthRangeValues(v, lengthRange.max)} placeholder="0">
- - getLengthRangeValues().max, (v) => { const range = getLengthRangeValues(); setLengthRangeValues(range.min, v); }} placeholder="0"> + + lengthRange.max, (v) => setLengthRangeValues(lengthRange.min, v)} placeholder="0">
{/if} @@ -656,4 +745,4 @@ color: #666; margin: 0; } - \ No newline at end of file + diff --git a/src/ifctester/webapp/src/pages/Home/SpecificationEditor.svelte b/src/ifctester/webapp/src/pages/Home/SpecificationEditor.svelte index be524ea411..cc340f6cbe 100644 --- a/src/ifctester/webapp/src/pages/Home/SpecificationEditor.svelte +++ b/src/ifctester/webapp/src/pages/Home/SpecificationEditor.svelte @@ -1,33 +1,44 @@ - {#if IDS.Module.status != "ready"} @@ -130,8 +158,7 @@ Import from IDS - {#each Object.entries(IDS.Module.documents) as [docId, doc]} - {#if docId !== IDS.Module.activeDocument && doc.specifications?.specification?.length > 0} + {#each importableDocuments as [docId, doc], docIndex} {doc.info?.title || 'Untitled Document'} @@ -146,12 +173,11 @@ {/each} - {#if Object.entries(IDS.Module.documents).filter(([id, d]) => id !== IDS.Module.activeDocument && d.specifications?.specification?.length > 0).indexOf([docId, doc]) < Object.entries(IDS.Module.documents).filter(([id, d]) => id !== IDS.Module.activeDocument && d.specifications?.specification?.length > 0).length - 1} + {#if docIndex < importableDocuments.length - 1} {/if} - {/if} {/each} - {#if Object.entries(IDS.Module.documents).filter(([docId, doc]) => docId !== IDS.Module.activeDocument && doc.specifications?.specification?.length > 0).length === 0} + {#if importableDocuments.length === 0} No specifications available to import @@ -162,16 +188,30 @@
-
{ if (IDS.Module.activeDocument) IDS.setDocumentState(IDS.Module.activeDocument, { activeSpecification: null }); }}> +
{ if (IDS.Module.activeDocument) IDS.setDocumentState(IDS.Module.activeDocument, { activeSpecification: null }); }} + onkeydown={(event) => handleActivation(event, () => { if (IDS.Module.activeDocument) IDS.setDocumentState(IDS.Module.activeDocument, { activeSpecification: null }); })} + > ℹ️ IDS Information
{#if activeDocument?.specifications?.specification} {#each activeDocument.specifications.specification as spec, index} -
selectSpecification(index)}> +
selectSpecification(index)} + onkeydown={(event) => handleActivation(event, () => selectSpecification(index))} + > 📄 {spec["@name"] || "Specification " + (index + 1)} - -
@@ -209,18 +249,18 @@

{activeSpecification ? activeSpecification["@name"] || "Specification" : "Specification"}

- - - + + +
{#if documentState?.activeTab === 'info'} {:else if documentState?.activeTab === 'applicability'} - + {:else if documentState?.activeTab === 'requirements'} - + {/if}
{/if} diff --git a/src/ifctester/webapp/src/pages/index.js b/src/ifctester/webapp/src/pages/index.ts similarity index 100% rename from src/ifctester/webapp/src/pages/index.js rename to src/ifctester/webapp/src/pages/index.ts diff --git a/src/ifctester/webapp/src/routes.js b/src/ifctester/webapp/src/routes.ts similarity index 100% rename from src/ifctester/webapp/src/routes.js rename to src/ifctester/webapp/src/routes.ts diff --git a/src/ifctester/webapp/src/types/ids.ts b/src/ifctester/webapp/src/types/ids.ts new file mode 100644 index 0000000000..a8213e6baa --- /dev/null +++ b/src/ifctester/webapp/src/types/ids.ts @@ -0,0 +1,70 @@ +export type IdsCardinality = "required" | "optional" | "prohibited"; + +export type RestrictionValue = { + "@value": string; +}; + +export type Restriction = { + "@base"?: string; + enumeration?: RestrictionValue[]; + pattern?: RestrictionValue[]; + length?: RestrictionValue[]; + minLength?: RestrictionValue[]; + maxLength?: RestrictionValue[]; + minInclusive?: RestrictionValue[]; + maxInclusive?: RestrictionValue[]; + minExclusive?: RestrictionValue[]; + maxExclusive?: RestrictionValue[]; +}; + +export type SimpleValue = { + simpleValue: string; +}; + +export type FacetValue = { + simpleValue?: string; + restriction?: Restriction; +}; + +export type Facet = Record; + +export type FacetClause = Record; + +export type Specification = { + "@name"?: string; + "@identifier"?: string; + "@description"?: string; + "@instructions"?: string; + "@ifcVersion"?: string[]; + applicability?: FacetClause; + requirements?: FacetClause; +}; + +export type IdsInfo = { + title?: string; + copyright?: string; + version?: string; + description?: string; + author?: string; + date?: string; + purpose?: string; + milestone?: string; +}; + +export type IdsDocument = { + "@xmlns"?: string; + "@xmlns:xs"?: string; + "@xmlns:xsi"?: string; + "@xsi:schemaLocation"?: string; + info: IdsInfo; + specifications: { + specification: Specification[]; + }; +}; + +export type DocumentState = { + activeTab: "info" | "applicability" | "requirements"; + viewMode: "editor" | "viewer"; + activeSpecification: number | null; + auditReport?: string | null; +}; diff --git a/src/ifctester/webapp/src/types/report.ts b/src/ifctester/webapp/src/types/report.ts new file mode 100644 index 0000000000..ee683e0808 --- /dev/null +++ b/src/ifctester/webapp/src/types/report.ts @@ -0,0 +1,95 @@ +export type ResultsPercent = number | "N/A"; + +export type AuditReportEntity = { + reason?: string; + element?: unknown; + element_type?: unknown; + class?: string; + predefined_type?: string; + name?: string | null; + description?: string | null; + id?: number; + global_id?: string | null; + tag?: string | null; + type_name?: string; + type_tag?: string | null; + type_global_id?: string | null; + extra_of_type?: number; +}; + +export type AuditRequirement = { + facet_type: string; + metadata: Record; + label: string; + value: string; + description: string; + status: boolean; + passed_entities: AuditReportEntity[]; + failed_entities: AuditReportEntity[]; + total_applicable: number; + total_pass: number; + total_fail: number; + percent_pass: ResultsPercent; + instructions?: string; + total_failed_entities?: number; + total_omitted_failures?: number; + has_omitted_failures?: boolean; + total_passed_entities?: number; + total_omitted_passes?: number; + has_omitted_passes?: boolean; +}; + +export type AuditSpecification = { + name: string; + description: string; + instructions: string; + status: boolean; + is_skipped?: boolean; + is_ifc_version: boolean; + total_applicable: number; + total_applicable_pass: number; + total_applicable_fail: number; + percent_applicable_pass: ResultsPercent; + total_checks: number; + total_checks_pass: number; + total_checks_fail: number; + percent_checks_pass: ResultsPercent; + cardinality: string; + applicability: string[]; + applicable_entities?: AuditReportEntity[]; + requirements: AuditRequirement[]; + total_requirements?: number; + total_requirements_pass?: number; +}; + +export type AuditReportData = { + title: string; + date: string; + filepath: string | null; + filename: string | null; + hide_skipped: boolean; + specifications: AuditSpecification[]; + status: boolean; + total_specifications: number; + total_specifications_pass: number; + total_specifications_fail: number; + percent_specifications_pass: ResultsPercent; + total_requirements: number; + total_requirements_pass: number; + total_requirements_fail: number; + percent_requirements_pass: ResultsPercent; + total_checks: number; + total_checks_pass: number; + total_checks_fail: number; + percent_checks_pass: ResultsPercent; +}; + +export type AuditReport = { + id: string; + modelId: string; + modelName: string; + document: string; + date: string; + data: AuditReportData; + htmlReport?: string | null; +}; diff --git a/src/ifctester/webapp/src/types/wasm.ts b/src/ifctester/webapp/src/types/wasm.ts new file mode 100644 index 0000000000..101796bd11 --- /dev/null +++ b/src/ifctester/webapp/src/types/wasm.ts @@ -0,0 +1,24 @@ +export type WorkerMessageType = + | "init" + | "api_call" + | "ready" + | "api_response" + | "error" + | "disposed"; + +export type WorkerRequest = { + type: WorkerMessageType; + payload?: Record; + id: string; +}; + +export type WorkerResponse = { + type: WorkerMessageType; + payload?: Record; + id: string; +}; + +export type ApiCallPayload = { + method: string; + args?: unknown[]; +}; diff --git a/src/ifctester/webapp/tsconfig.json b/src/ifctester/webapp/tsconfig.json new file mode 100644 index 0000000000..39e15a0e10 --- /dev/null +++ b/src/ifctester/webapp/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "moduleResolution": "bundler", + "target": "ESNext", + "module": "ESNext", + "verbatimModuleSyntax": true, + "isolatedModules": true, + "resolveJsonModule": true, + "sourceMap": true, + "esModuleInterop": true, + "skipLibCheck": true, + "strict": true, + "baseUrl": ".", + "paths": { + "$lib": ["./src/lib"], + "$lib/*": ["./src/lib/*"], + "$src": ["./src"], + "$src/*": ["./src/*"] + } + }, + "include": ["src/**/*.d.ts", "src/**/*.ts", "src/**/*.svelte"] +} diff --git a/src/ifctester/webapp/vite.config.js b/src/ifctester/webapp/vite.config.js index cfc33f9356..46cecde534 100644 --- a/src/ifctester/webapp/vite.config.js +++ b/src/ifctester/webapp/vite.config.js @@ -1,7 +1,7 @@ import tailwindcss from '@tailwindcss/vite'; import { defineConfig } from 'vite'; import { svelte } from '@sveltejs/vite-plugin-svelte'; -import path from "path"; +import path from "node:path"; export default defineConfig({ plugins: [tailwindcss(), svelte()], @@ -11,4 +11,4 @@ export default defineConfig({ $src: path.resolve("./src"), }, }, -}); \ No newline at end of file +}); diff --git a/src/serializers/WavefrontObjSerializer.cpp b/src/serializers/WavefrontObjSerializer.cpp index f29612a65e..848dd9beb7 100644 --- a/src/serializers/WavefrontObjSerializer.cpp +++ b/src/serializers/WavefrontObjSerializer.cpp @@ -32,6 +32,7 @@ WaveFrontOBJSerializer::WaveFrontOBJSerializer(const stream_or_filename& obj_fil , obj_stream(obj_filename) , mtl_stream(mtl_filename) , vcount_total(1) + , ncount_total(1) { obj_stream.stream << std::setprecision(settings.get().get()); mtl_stream.stream << std::setprecision(settings.get().get());
{entity.class} {entity.predefined_type || '-'}
... {specReport.applicable_entities.length - 10} more failing elements not shown ...... {applicableEntities.length - 10} more failing elements not shown ...