From da84cc5925b783c6e1fb0046cf9e6fb49bc4fce5 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 10 Jan 2023 10:18:13 +1100 Subject: [PATCH] Add documentation for IfcPatch and all recipes --- src/ifcopenshell-python/docs/conf.py | 2 +- src/ifcopenshell-python/docs/ifcpatch.rst | 5 ++ src/ifcpatch/ifcpatch/__init__.py | 53 ++++++++++++- .../ifcpatch/recipes/ConvertLengthUnit.py | 24 +++++- .../recipes/ConvertPropertiesToQuantities.py | 42 ++++++++-- .../recipes/DowngradeIndexedPolyCurve.py | 20 ++++- .../ifcpatch/recipes/ExtractElements.py | 20 ++++- .../ifcpatch/recipes/Fix12DToRevitTINs.py | 46 ++++++----- .../recipes/FixArchiCADToRevitDoorSwings.py | 50 +++++++----- .../recipes/FixArchiCADToRevitSpaces.py | 60 ++++++++------- .../ifcpatch/recipes/MergeDuplicateTypes.py | 45 +++++++++-- src/ifcpatch/ifcpatch/recipes/MergeProject.py | 23 +++++- src/ifcpatch/ifcpatch/recipes/Migrate.py | 21 ++++- .../recipes/OffsetObjectPlacements.py | 77 ++++++++++++++++--- .../recipes/OffsetStoreyElevations.py | 20 ++++- src/ifcpatch/ifcpatch/recipes/Optimise.py | 27 ++++++- .../recipes/RecycleNonRootedElements.py | 26 ++++++- .../ifcpatch/recipes/RegenerateGlobalIds.py | 31 +++++++- .../recipes/RemoveSiteRepresentation.py | 13 +++- .../recipes/ResetAbsoluteCoordinates.py | 69 ++++++++++++++++- .../recipes/ResetSpatialElementLocations.py | 22 +++++- .../ifcpatch/recipes/SetRefElevation.py | 26 ++++++- .../ifcpatch/recipes/SplitByBuildingStorey.py | 15 +++- 23 files changed, 611 insertions(+), 126 deletions(-) diff --git a/src/ifcopenshell-python/docs/conf.py b/src/ifcopenshell-python/docs/conf.py index 6e0eeb3116..5fb3475e87 100644 --- a/src/ifcopenshell-python/docs/conf.py +++ b/src/ifcopenshell-python/docs/conf.py @@ -63,7 +63,7 @@ autoapi_add_toctree_entry = True autoapi_type = 'python' # autoapi works by reading source code instead of importing modules -autoapi_dirs = ['../ifcopenshell', '../../ifcdiff'] +autoapi_dirs = ['../ifcopenshell', '../../ifcdiff', '../../ifcpatch/ifcpatch'] # These are auto-generated based on the IFC schema, so exclude them autoapi_ignore = ['*ifcopenshell/express/rules*'] diff --git a/src/ifcopenshell-python/docs/ifcpatch.rst b/src/ifcopenshell-python/docs/ifcpatch.rst index bbf9043311..aa9b99a851 100644 --- a/src/ifcopenshell-python/docs/ifcpatch.rst +++ b/src/ifcopenshell-python/docs/ifcpatch.rst @@ -75,3 +75,8 @@ Alternatively, you can package it as an executable. $ python make.py $ ./dist/ifcpatch + +Patch recipes +------------- + +You can view all built-in patches in IfcPatch here: :doc:`List of IfcPatch recipes `. diff --git a/src/ifcpatch/ifcpatch/__init__.py b/src/ifcpatch/ifcpatch/__init__.py index a5918e82f1..f336e9c2ba 100644 --- a/src/ifcpatch/ifcpatch/__init__.py +++ b/src/ifcpatch/ifcpatch/__init__.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # IfcPatch - IFC patching utiliy -# Copyright (C) 2020, 2021 Dion Moult +# Copyright (C) 2020, 2021, 2023 Dion Moult # # This file is part of IfcPatch. # @@ -28,6 +28,41 @@ import importlib def execute(args): + """Execute a patch recipe + + The details of how the patch recipe is executed depends on the definition of + the recipe, as well as the arguments passed to the recipe. See the + documentation for each patch recipe separately to understand more. + + :param args: A dictionary of arguments, corresponding to the parameters + listed subsequent to this in this docstring. + :type args: dict + :param input: An IFC model to apply the patch recipe to. + :type input: ifcopenshell.file.file + :param recipe: The name of the recipe. This is the same as the filename of + the recipe. E.g. "ExtractElements". + :type recipe: str + :param log: A filepath to a logfile. + :type log: str,optional + :param arguments: A list of zero or more positional arguments, depending on + the patch recipe. Some patch recipes will require you to specify + arguments, some won't. + :type arguments: list + :return: The result of the patch. This is typically a patched model, either + as an object or as a string. + :rtype: ifcopenshell.file.file,str + + Example: + + .. code:: python + + output = ifcpatch.execute({ + "input": ifcopenshell.open("input.ifc"), + "recipe": "ExtractElements", + "arguments": [".IfcWall"], + }) + ifcpatch.write(output, "output.ifc") + """ if "log" in args: logging.basicConfig(filename=args["log"], filemode="a", level=logging.DEBUG) logger = logging.getLogger("IFCPatch") @@ -40,13 +75,23 @@ def execute(args): patcher = recipe.Patcher(args["input"], ifc_file, logger, args["arguments"]) patcher.patch() output = getattr(patcher, "file_patched", patcher.file) - if isinstance(output, str): - with open(args["output"], "w") as text_file: - text_file.write(output) return output def write(output, filepath): + """Write the output of an IFC patch to a file + + Typically a patch output would be a patched IFC model file object, or as a + string. This function lets you agnostically write that output to a filepath. + + :param output: The results from ifcpatch.execute() + :type output: ifcopenshell.file.file,str + :param filepath: A filepath to where the results of the patched model should + be written to. + :type filepath: str + :return: None + :rtype: None + """ if isinstance(output, str): with open(filepath, "w") as text_file: text_file.write(output) diff --git a/src/ifcpatch/ifcpatch/recipes/ConvertLengthUnit.py b/src/ifcpatch/ifcpatch/recipes/ConvertLengthUnit.py index 9192e977c7..6f5fa48921 100644 --- a/src/ifcpatch/ifcpatch/recipes/ConvertLengthUnit.py +++ b/src/ifcpatch/ifcpatch/recipes/ConvertLengthUnit.py @@ -24,14 +24,32 @@ import ifcopenshell.util.element class Patcher: - def __init__(self, src, file, logger, args=None): + def __init__(self, src, file, logger, unit="METERS"): + """Converts the length unit of a model to the specified unit + + Allowed metric units include METERS, MILLIMETERS, CENTIMETERS, etc. + Allowed imperial units include INCHES, FEET, MILES. + + :param unit: The name of the desired unit. + :type unit: str + + Example: + + .. code:: python + + # Convert to millimeters + ifcpatch.execute({"input": model, "recipe": "ExtractElements", "arguments": ["MILLIMETERS"]}) + + # Convert to feet + ifcpatch.execute({"input": model, "recipe": "ExtractElements", "arguments": ["FEET"]}) + """ self.src = src self.file = file self.logger = logger - self.args = args + self.unit = unit def patch(self): - unit = {"is_metric": "METERS" in self.args[0], "raw": self.args[0]} + unit = {"is_metric": "METERS" in self.unit, "raw": self.unit} self.file_patched = ifcopenshell.api.run("project.create_file", version=self.file.schema) if self.file.schema == "IFC2X3": user = self.file_patched.add(self.file.by_type("IfcProject")[0].OwnerHistory.OwningUser) diff --git a/src/ifcpatch/ifcpatch/recipes/ConvertPropertiesToQuantities.py b/src/ifcpatch/ifcpatch/recipes/ConvertPropertiesToQuantities.py index 36db19f82b..cc0939e67d 100644 --- a/src/ifcpatch/ifcpatch/recipes/ConvertPropertiesToQuantities.py +++ b/src/ifcpatch/ifcpatch/recipes/ConvertPropertiesToQuantities.py @@ -22,19 +22,51 @@ import ifcopenshell.util.element class Patcher: - def __init__(self, src, file, logger, args=None): + def __init__(self, src, file, logger, property_name=None, quantity_name=None): + """Converts a property to a standardised quantity + + IFC can store arbitrary key value metadata associated with a elements + known as properties and quantities. The difference between the two is + that quantities specifically measure physical dimensions, and can be + used parametrically. A collection of standardised quantities have also + been published by buildingSMART. Using standardised quantities can help + automate BIM workflows, instead of arbitrary properties. + + Sometimes, proprietary BIM software incorrectly stores quantities and + properties. This patch lets you convert these incorrect properties to + standardised quantities instead. + + The existing property will not be removed. + + :param property_name: The name of the property to convert into a + quantity. The name of the property set is not considered. + :type property_name: str + :param quantity_name: The name of the quantity that this property should + be stored in. This should be a standard name that is one of the + quantity names of a buildingSMART quantity template. For example, it + may be "NetSideArea" for walls, which exists in + Qto_WallBaseQuantities. The quantity set name will be based on the + standard buildingSMART quantity template. + :type quantity_name: str + + Example: + + .. code:: python + + # Any property named "Area" is converted to a quantity named + # "NetSideArea", if that standardised quantity exists. + ifcpatch.execute({"input": model, "recipe": "ExtractElements", "arguments": ["Area", "NetSideArea"]}) + """ self.src = src self.file = file self.logger = logger - self.args = args + self.source_property_name = property_name + self.destination_quantity_name = quantity_name def patch(self): self.qto_template_cache = {} self.psetqto = ifcopenshell.util.pset.get_template("IFC4") - self.source_property_name = self.args[0] - self.destination_quantity_name = self.args[1] - for product in self.file.by_type("IfcTypeProduct"): self.process_product(product, product.HasPropertySets or []) diff --git a/src/ifcpatch/ifcpatch/recipes/DowngradeIndexedPolyCurve.py b/src/ifcpatch/ifcpatch/recipes/DowngradeIndexedPolyCurve.py index ea10b67ce2..4d8051b3fe 100644 --- a/src/ifcpatch/ifcpatch/recipes/DowngradeIndexedPolyCurve.py +++ b/src/ifcpatch/ifcpatch/recipes/DowngradeIndexedPolyCurve.py @@ -21,11 +21,27 @@ import ifcopenshell.util.element class Patcher: - def __init__(self, src, file, logger, args=None): + def __init__(self, src, file, logger): + """Downgrade indexed polycurves to simple polylines + + Low quality IFC viewers like Navisworks do not support various IFC4 + geometry, such as indexed polycurves. These can result in missing + geometry or geometric glitches (such as arcs being displayed as full + circles). This is pretty common when viewing IFCs from ArchiCAD that + include site boundaries (incorrectly drawn using the ArchiCAD grid tool, + as ArchiCAD has no site boundary tool). + + This will downgrade specifically the indexed polycurve geometry types in + an IFC4 model (IFC2X3 does not have this geometry type) to help + compatibility in viewers like Navisworks. + + Example: + + ifcpatch.execute({"input": model, "recipe": "DowngradeIndexedPolyCurve", "arguments": []}) + """ self.src = src self.file = file self.logger = logger - self.args = args def patch(self): if self.file.schema == "IFC2X3": diff --git a/src/ifcpatch/ifcpatch/recipes/ExtractElements.py b/src/ifcpatch/ifcpatch/recipes/ExtractElements.py index b229073417..f21d2fd31d 100644 --- a/src/ifcpatch/ifcpatch/recipes/ExtractElements.py +++ b/src/ifcpatch/ifcpatch/recipes/ExtractElements.py @@ -23,11 +23,27 @@ import ifcopenshell.util.selector class Patcher: def __init__(self, src, file, logger, query: str = ".IfcWall"): - """Extract Elements + """Extract certain elements into a new model - Extract a subset of elements from an existing IFC data set and save it to a new IFC file. + Extract a subset of elements from an existing IFC data set and save it + to a new IFC file. For example, you might want to extract only the walls + in a model and save it as a new model. :param query: A query to select the subset of IFC elements. + :type query: str + + Example: + + .. code:: python + + # Extract all walls + ifcpatch.execute({"input": model, "recipe": "ExtractElements", "arguments": [".IfcWall"]}) + + # Extract all slabs + ifcpatch.execute({"input": model, "recipe": "ExtractElements", "arguments": [".IfcSlab"]}) + + # Extract all walls and slabs + ifcpatch.execute({"input": model, "recipe": "ExtractElements", "arguments": [".IfcWall|.IfcSlab"]}) """ self.src = src self.file = file diff --git a/src/ifcpatch/ifcpatch/recipes/Fix12DToRevitTINs.py b/src/ifcpatch/ifcpatch/recipes/Fix12DToRevitTINs.py index 0126ece36a..00e7d22b13 100644 --- a/src/ifcpatch/ifcpatch/recipes/Fix12DToRevitTINs.py +++ b/src/ifcpatch/ifcpatch/recipes/Fix12DToRevitTINs.py @@ -1,28 +1,36 @@ class Patcher: - def __init__(self, src, file, logger, args=None): + def __init__(self, src, file, logger): + """Fix missing or spot-coordinate bugged TINs loading in Revit + + TINs exported from 12D may contain dense or highly obtuse triangles. + Although these will load in Revit, you will not be able to use Revit's + Spot Coordinate or Spot Elevation tool. + + See bug: https://github.com/Autodesk/revit-ifc/issues/511 + + The solution will merge vertices closer than 10mm to prevent dense + portions of the TIN at a minor sacrifice of surveying accuracy. It will + also triangulate all meshes to prevent non-coplanar surfaces, and delete + any obtuse triangles where one of their XY angles is less than 0.3 + degrees. Therefore the result will contain some minor "holes" in the + TIN, but these holes will only be in dense triangles that Revit can't + handle anyway and won't affect most coordination tasks. + + This patch is designed to only work on 12D IFC exports. It also requires + you to run it using Blender, as the geometric modification uses the + Blender geometry engine. + + Example: + + .. code:: python + + ifcpatch.execute({"input": model, "recipe": "Fix12DToRevitTINs", "arguments": []}) + """ self.src = src self.file = file self.logger = logger - self.args = args def patch(self): - # TINs exported from 12D may contain dense or highly obtuse triangles. - # Although these will load in Revit, you will not be able to use Revit's - # Spot Coordinate or Spot Elevation tool. - # - # See bug: https://github.com/Autodesk/revit-ifc/issues/511 - # - # The solution will merge vertices closer than 10mm to prevent dense - # portions of the TIN at a minor sacrifice of surveying accuracy. It - # will also triangulate all meshes to prevent non-coplanar surfaces, and - # delete any obtuse triangles where one of their XY angles is less than - # 0.3 degrees. Therefore the result will contain some minor "holes" in - # the TIN, but these holes will only be in dense triangles that Revit - # can't handle anyway and won't affect most coordination tasks. - # - # This patch is designed to only work on 12D IFC exports. It also - # requires you to run it using Blender, as the geometric modification - # uses the Blender geometry engine. import bpy import bmesh import blenderbim.tool as tool diff --git a/src/ifcpatch/ifcpatch/recipes/FixArchiCADToRevitDoorSwings.py b/src/ifcpatch/ifcpatch/recipes/FixArchiCADToRevitDoorSwings.py index 7a509a2862..ffd86b1e14 100644 --- a/src/ifcpatch/ifcpatch/recipes/FixArchiCADToRevitDoorSwings.py +++ b/src/ifcpatch/ifcpatch/recipes/FixArchiCADToRevitDoorSwings.py @@ -6,32 +6,40 @@ import ifcopenshell.util.element class Patcher: - def __init__(self, src, file, logger, args=None): + def __init__(self, src, file, logger): + """Fix missing door swings in Revit when viewing ArchiCAD IFCs + + ArchiCAD has the ability to store 2D data with objects like doors for + door swings. ArchiCAD's implementation is not 100% correct (using + footprint instead of annotation contexts), but otherwise not too + shabby. + + Revit, however, is incapable of understanding this 2D representation. + Revit users linking in IFCs produced by ArchiCAD may experience the + following symptoms: + + A. Invisible doors, and difficulty selecting doors + B. Invisible door swings, or only visible at particular view ranges + C. Weird arc shapes around doors + D. Extra lines around doors and walls + E. Cannot easily change visibility graphics of 2D vs 3D elements + F. Cannot view 2D data in a 3D view + + This is caused by the perfect storm of Revit IFC bugs, which we will + work through methodically. For programmers interested in the details of + how we fix this, read the comments of the patch function. + + Example: + + .. code:: python + + ifcpatch.execute({"input": model, "recipe": "FixArchiCADToRevitDoorSwings", "arguments": []}) + """ self.src = src self.file = file self.logger = logger - self.args = args def patch(self): - # ArchiCAD has the ability to store 2D data with objects like doors for - # door swings. ArchiCAD's implementation is not 100% correct (using - # footprint instead of annotation contexts), but otherwise not too - # shabby. - # - # Revit, however, is incapable of understanding this 2D representation. - # Revit users linking in IFCs produced by ArchiCAD may experience the - # following symptoms: - # - # A. Invisible doors, and difficulty selecting doors - # B. Invisible door swings, or only visible at particular view ranges - # C. Weird arc shapes around doors - # D. Extra lines around doors and walls - # E. Cannot easily change visibility graphics of 2D vs 3D elements - # F. Cannot view 2D data in a 3D view - # - # This is caused by the perfect storm of Revit IFC bugs, which we will - # work through methodically. - # Revit has the ability to switch between 3D representations and 2D # representations (e.g. in plan view). It does this by detecting IFC # representations that belong to either the Model Body representation diff --git a/src/ifcpatch/ifcpatch/recipes/FixArchiCADToRevitSpaces.py b/src/ifcpatch/ifcpatch/recipes/FixArchiCADToRevitSpaces.py index d83feb0895..ef9cbea64c 100644 --- a/src/ifcpatch/ifcpatch/recipes/FixArchiCADToRevitSpaces.py +++ b/src/ifcpatch/ifcpatch/recipes/FixArchiCADToRevitSpaces.py @@ -1,35 +1,43 @@ class Patcher: - def __init__(self, src, file, logger, args=None): + def __init__(self, src, file, logger): + """Allow ArchiCAD IFC spaces to open as Revit rooms + + The underlying problem is that Revit does not bring in IFC spaces as + spaces / rooms in Revit when you link an IFC in Revit. This has been + broken for at least 3 years and counting. This is a problem typically + for ArchiCAD architects who want to send rooms to MEP folks using + Revit. + + See bug: https://github.com/Autodesk/revit-ifc/issues/15 + + The solution is to open an IFC in Revit instead of linking it, which + will convert IFC spaces into Revit rooms. However, there are very + specific scenarios where Revit will convert these rooms, which have + been painstakingly reverse engineered through trial and error. + Firstly, the rooms should have a lower bound with a Z value matching + the Z value of the storey it is on. Secondly, although faceted breps + do work in some scenarios (I assume Revit has an internal topological + analysis tool), conversion to an extruded area solid yield much more + robust results. Finally, changing the Precision value to an obscene + number very strangely seems to cause a lot more rooms to be converted + successfully. + + This patch is designed to only work on ArchiCAD IFC exports where the + only contents of the IFC is IFC space and `nothing else`. It also + requires you to run it using Blender, as the geometric modification + uses the Blender geometry engine. + + Example: + + .. code:: python + + ifcpatch.execute({"input": model, "recipe": "FixArchiCADToRevitSpaces", "arguments": []}) + """ self.src = src self.file = file self.logger = logger - self.args = args def patch(self): - # The underlying problem is that Revit does not bring in IFC spaces as - # spaces / rooms in Revit when you link an IFC in Revit. This has been - # broken for at least 3 years and counting. This is a problem typically - # for ArchiCAD architects who want to send rooms to MEP folks using - # Revit. - # - # See bug: https://github.com/Autodesk/revit-ifc/issues/15 - # - # The solution is to open an IFC in Revit instead of linking it, which - # will convert IFC spaces into Revit rooms. However, there are very - # specific scenarios where Revit will convert these rooms, which have - # been painstakingly reverse engineered through trial and error. - # Firstly, the rooms should have a lower bound with a Z value matching - # the Z value of the storey it is on. Secondly, although faceted breps - # do work in some scenarios (I assume Revit has an internal topological - # analysis tool), conversion to an extruded area solid yield much more - # robust results. Finally, changing the Precision value to an obscene - # number very strangely seems to cause a lot more rooms to be converted - # successfully. - # - # This patch is designed to only work on ArchiCAD IFC exports where the - # only contents of the IFC is IFC space and _nothing else_. It also - # requires you to run it using Blender, as the geometric modification - # uses the Blender geometry engine. import bpy from blenderbim.bim.ifc import IfcStore from mathutils import Vector, Matrix diff --git a/src/ifcpatch/ifcpatch/recipes/MergeDuplicateTypes.py b/src/ifcpatch/ifcpatch/recipes/MergeDuplicateTypes.py index ab29957464..302fa4062d 100644 --- a/src/ifcpatch/ifcpatch/recipes/MergeDuplicateTypes.py +++ b/src/ifcpatch/ifcpatch/recipes/MergeDuplicateTypes.py @@ -21,17 +21,50 @@ import ifcopenshell.util.element class Patcher: - def __init__(self, src, file, logger, args=None): + def __init__(self, src, file, logger, attribute="Tag"): + """Merge duplicate element types via the Tag or another attribute + + Revit is notorious for creating many duplicate element types. Element + types may be duplicated by being mirrored, such as doors, columns, etc, + or being certain MEP equipment. This means that even though you think + you might have only 3 door families and 3 door types in your door + schedule, your IFC might actually incorrectly store 6 or more door types. + + Revit stores the Revit Element ID in the "Tag" attribute of all IFC + elements, so we can deduce that multiple IFC elements with the same Tag + attribute have been duplicated in IFC. This patch will merge them into a + single element type. + + You may optionally specify your own attribute if you want to merge using + different criteria, such as "Name". For example, may Revit users + incorrectly have multiple types with the same name as workarounds to + overcome various Revit limitations. This is incorrect and this patch + will merge the types into a single type. + + Occurrences of the type will be remapped to the merged type. + + :param attribute: The name of the attribute to merge element types based + on. Typically this will be "Tag" as it stores the unique ID from the + proprietary BIM software. + :type attribute: str + + Example: + + .. code:: python + + # Default behaviour of merging by Tag attribute + ifcpatch.execute({"input": model, "recipe": "MergeDuplicateTypes", "arguments": []}) + + # Explicitly say we want to merge based on the Name attribute + ifcpatch.execute({"input": model, "recipe": "MergeDuplicateTypes", "arguments": ["Name"]}) + """ self.src = src self.file = file self.logger = logger - self.args = args + self.attribute = attribute def patch(self): - if self.args: - key = self.args[0] - else: - key = "Tag" + key = self.attribute keys = {} for element_type in self.file.by_type("IfcTypeObject"): original_type = keys.get(getattr(element_type, key), None) diff --git a/src/ifcpatch/ifcpatch/recipes/MergeProject.py b/src/ifcpatch/ifcpatch/recipes/MergeProject.py index 2ac075f2b0..8a4a36f388 100644 --- a/src/ifcpatch/ifcpatch/recipes/MergeProject.py +++ b/src/ifcpatch/ifcpatch/recipes/MergeProject.py @@ -21,14 +21,31 @@ import ifcopenshell.util.element class Patcher: - def __init__(self, src, file, logger, args=None): + def __init__(self, src, file, logger, filepath=None): + """Merge two IFC models into one + + Note that other than combining the two IfcProject elements into one, no + further processing will be done. This means that you may end up with + duplicate spatial hierarchies (i.e. 2 sites, 2 buildings, etc). + + :param filepath: The filepath of the second IFC model to merge into the + first. The first model is already specified as the input to + IfcPatch. + :type filepath: str + + Example: + + .. code:: python + + ifcpatch.execute({"input": model, "recipe": "MergeProject", "arguments": ["/path/to/model2.ifc"]}) + """ self.src = src self.file = file self.logger = logger - self.args = args + self.filepath = filepath def patch(self): - source = ifcopenshell.open(self.args[0]) + source = ifcopenshell.open(self.filepath) original_project = self.file.by_type("IfcProject")[0] merged_project = self.file.add(source.by_type("IfcProject")[0]) for element in source: diff --git a/src/ifcpatch/ifcpatch/recipes/Migrate.py b/src/ifcpatch/ifcpatch/recipes/Migrate.py index e94652b567..be664315f8 100644 --- a/src/ifcpatch/ifcpatch/recipes/Migrate.py +++ b/src/ifcpatch/ifcpatch/recipes/Migrate.py @@ -21,14 +21,29 @@ import ifcopenshell.util.schema class Patcher: - def __init__(self, src, file, logger, args=None): + def __init__(self, src, file, logger, schema="IFC4"): + """Migrate from one IFC version to another + + Note that this is experimental and will try to preserve as much data as + possible. Upgrading to IFC4 is more stable than downgrading to IFC2X3. + + :param schema: The schema identifier of the IFC version to migrate to. + :type schema: str + + Example: + + .. code:: python + + # Upgrade an IFC2X3 model to IFC4 + ifcpatch.execute({"input": model, "recipe": "Migrate", "arguments": ["IFC4"]}) + """ self.src = src self.file = file self.logger = logger - self.args = args + self.schema = schema def patch(self): - self.file_patched = ifcopenshell.file(schema=self.args[0]) + self.file_patched = ifcopenshell.file(schema=self.schema) migrator = ifcopenshell.util.schema.Migrator() for element in self.file: migrator.migrate(element, self.file_patched) diff --git a/src/ifcpatch/ifcpatch/recipes/OffsetObjectPlacements.py b/src/ifcpatch/ifcpatch/recipes/OffsetObjectPlacements.py index 8da535093c..401d5afa2d 100644 --- a/src/ifcpatch/ifcpatch/recipes/OffsetObjectPlacements.py +++ b/src/ifcpatch/ifcpatch/recipes/OffsetObjectPlacements.py @@ -23,11 +23,70 @@ import ifcopenshell.util.placement class Patcher: - def __init__(self, src, file, logger, args=None): + def __init__(self, src, file, logger, x=None, y=None, z=None, ax=None, ay=None, az=None): + """Offset and rotate all object placements in a model + + Every physical object in an IFC model has an object placement, a + matrix dictating where it is in XYZ space and its rotation. + + Sometimes, models will have their models offset incorrectly into map + coordinates (i.e. very large coordinates) when they should be using + local coordinates, or vice versa, or simply be using wrong coordinates. + + In some cases, models will even be rotated, especially with mixups where + Y is up instead of Z, coming from low quality BIM software. + + This patch lets you translate, and optionally rotate (either rotate 2D + in plan view along the Z axis, or rotate in 3D across any axis) the + entire IFC model. + + :param x: The X coordinate to offset by in project length units. + :type x: float + :param y: The Y coordinate to offset by in project length units. + :type y: float + :param z: The Z coordinate to offset by in project length units. + :type z: float + :param ax: An optional angle to rotate by. If only this angle is + specified, it is treated as the angle to rotate in plan view (i.e. + around the Z axis). If all angle parameters are specified, then it + is treated as the angle to rotate around the X axis. Angles are in + decimal degrees. + :type ax: float,optional + :param ay: An optional angle to rotate by for 3D rotations along the Y + axis. Angles are in decimal degrees. + :type ay: float,optional + :param az: An optional angle to rotate by for 3D rotations along the Z + axis. Angles are in decimal degrees. + :type az: float,optional + + Example: + + .. code:: python + + # Offset a model by 100 units in both the X and Y axis. + ifcpatch.execute({"input": model, "recipe": "OffsetObjectPlacements", "arguments": [100,100,0]}) + + # Rotate by 90 degrees, but don't do any offset + ifcpatch.execute({"input": model, "recipe": "OffsetObjectPlacements", "arguments": [0,0,0,90]}) + + # Some crazy 3D rotation and offset + ifcpatch.execute({"input": model, "recipe": "OffsetObjectPlacements", "arguments": [12.5,5,2,90,90,45]}) + """ self.src = src self.file = file self.logger = logger - self.args = args + self.x = x + self.y = y + self.z = z + self.ax = ax + self.ay = ay + self.az = az + if self.ay is not None: + self.angle_type = "3D" + elif self.ay is not None: + self.angle_type = "2D" + else: + self.angle_type = None def patch(self): absolute_placements = [] @@ -41,17 +100,17 @@ class Patcher: absolute_placements = set(absolute_placements) transformation = self.identity_matrix() - if len(self.args) == 4: - angle = float(self.args[3]) + if self.angle_type == "2D": + angle = float(self.ax) if angle: transformation = self.z_rotation_matrix(math.radians(angle), transformation) - elif len(self.args) == 6: - for arg in (("x", float(self.args[3])), ("y", float(self.args[4])), ("z", float(self.args[5]))): + elif self.angle_type == "3D": + for arg in (("x", float(self.ax)), ("y", float(self.ay)), ("z", float(self.az))): if arg[1]: transformation = getattr(self, f"{arg[0]}_rotation_matrix")(math.radians(arg[1]), transformation) - transformation[0][3] += float(self.args[0]) - transformation[1][3] += float(self.args[1]) - transformation[2][3] += float(self.args[2]) + transformation[0][3] += float(self.x) + transformation[1][3] += float(self.y) + transformation[2][3] += float(self.z) for placement in absolute_placements: placement.RelativePlacement = self.get_relative_placement( diff --git a/src/ifcpatch/ifcpatch/recipes/OffsetStoreyElevations.py b/src/ifcpatch/ifcpatch/recipes/OffsetStoreyElevations.py index a92389732e..c93cbaaaef 100644 --- a/src/ifcpatch/ifcpatch/recipes/OffsetStoreyElevations.py +++ b/src/ifcpatch/ifcpatch/recipes/OffsetStoreyElevations.py @@ -18,18 +18,32 @@ class Patcher: - def __init__(self, src, file, logger, args=None): + def __init__(self, src, file, logger, z=None): + """Offset building storeys by a particular Z value + + All objects placed relative to the storeys will also be shifted. + + :param z: The Z value in project length units to offset storeys by. + :type z: float + + Example: + + .. code:: python + + # Shift all storeys up by 42 units + ifcpatch.execute({"input": model, "recipe": "OffsetStoreyElevations", "arguments": [42]}) + """ self.src = src self.file = file self.logger = logger - self.args = args + self.z = z def patch(self): project = self.file.by_type("IfcProject")[0] storeys = self.find_decomposed_ifc_class(project, "IfcBuildingStorey") for storey in storeys: co = storey.ObjectPlacement.RelativePlacement.Location.Coordinates - storey.ObjectPlacement.RelativePlacement.Location.Coordinates = (co[0], co[1], co[2] + float(self.args[0])) + storey.ObjectPlacement.RelativePlacement.Location.Coordinates = (co[0], co[1], co[2] + float(self.z)) co = storey.ObjectPlacement.RelativePlacement.Location.Coordinates # NOTE If the geometric data is provided (ObjectPlacement is # specified), the Elevation value shall either not be included, or diff --git a/src/ifcpatch/ifcpatch/recipes/Optimise.py b/src/ifcpatch/ifcpatch/recipes/Optimise.py index f85f585c12..706754bf05 100644 --- a/src/ifcpatch/ifcpatch/recipes/Optimise.py +++ b/src/ifcpatch/ifcpatch/recipes/Optimise.py @@ -22,11 +22,34 @@ from toposort import toposort_flatten as toposort class Patcher: - def __init__(self, src, file, logger, args=None): + def __init__(self, src, file, logger): + """Optimise the filesize of an IFC model + + It is possible to non-losslessly optimise the filesize of an IFC model. + + Note that this is usually not recommended. Optimising runs a risk of + losing some indirect semantic data critical for native IFC authoring. + Most parties who recommend optimisation are not aware of these risks. + Optimising is only safe in the context of read-only IFCs. + + If filesize is an issue, another approach would be to use IFCZIP + instead to compress the model. Optimising the model only typically + affects filesize and has minimal impact on load times. Large filesizes + can usually be solved through other means. Consult the BlenderBIM Add-on + documentation on dealing with large models for more details. + + Warning: this optimise recipe is very, very slow. Please consider using + RecycleNonRootedElements instead. + + Example: + + .. code:: python + + ifcpatch.execute({"input": model, "recipe": "Optimise", "arguments": []}) + """ self.src = src self.file = file self.logger = logger - self.args = args self.optimized_file = ifcopenshell.file(schema=self.file.schema) def patch(self): diff --git a/src/ifcpatch/ifcpatch/recipes/RecycleNonRootedElements.py b/src/ifcpatch/ifcpatch/recipes/RecycleNonRootedElements.py index cba3482c5d..f73ccf0200 100644 --- a/src/ifcpatch/ifcpatch/recipes/RecycleNonRootedElements.py +++ b/src/ifcpatch/ifcpatch/recipes/RecycleNonRootedElements.py @@ -21,11 +21,33 @@ import ifcopenshell.util.element class Patcher: - def __init__(self, src, file, logger, args=None): + def __init__(self, src, file, logger): + """Optimise the filesize of an IFC model by reusing non-rooted elements + + It is possible to non-losslessly optimise the filesize of an IFC model. + + Note that this is usually not recommended. Optimising runs a risk of + losing some indirect semantic data critical for native IFC authoring. + Most parties who recommend optimisation are not aware of these risks. + Optimising is only safe in the context of read-only IFCs. + + If filesize is an issue, another approach would be to use IFCZIP + instead to compress the model. Optimising the model only typically + affects filesize and has minimal impact on load times. Large filesizes + can usually be solved through other means. Consult the BlenderBIM Add-on + documentation on dealing with large models for more details. + + This patch may be run multiple times with diminishing returns. + + Example: + + .. code:: python + + ifcpatch.execute({"input": model, "recipe": "RecycleNonRootedElements", "arguments": []}) + """ self.src = src self.file = file self.logger = logger - self.args = args def patch(self): deleted = [] diff --git a/src/ifcpatch/ifcpatch/recipes/RegenerateGlobalIds.py b/src/ifcpatch/ifcpatch/recipes/RegenerateGlobalIds.py index 85f09bb557..b3989d7b75 100644 --- a/src/ifcpatch/ifcpatch/recipes/RegenerateGlobalIds.py +++ b/src/ifcpatch/ifcpatch/recipes/RegenerateGlobalIds.py @@ -20,14 +20,39 @@ import ifcopenshell class Patcher: - def __init__(self, src, file, logger, args=None): + def __init__(self, src, file, logger, only_duplicates=False): + """Regenerate GlobalIds in an IFC model + + All root elements in an IFC model must be identified by a unique Global + ID (also known as a GUID or UUID). Some proprietary BIM software do this + incorrect (I know right), either by generating an invalid ID, creating + duplicate IDs, generating IDs in a way that is not universally unique or + as random as you might prefer (e.g. non-compliant with UUID v4). + + This will regenerate new GlobalIds for the entire model. + + :param only_duplicates: If set to True, new GlobalIds will only be + generated for duplicate IDs. This is a safe thing to run to ensure + IFCs are valid. If False, all GlobalIds will be regenerated. + :type only_duplicates: bool + + Example: + + .. code:: python + + # Regenerate all GlobalIds + ifcpatch.execute({"input": model, "recipe": "RegenerateGlobalIds", "arguments": []}) + + # Regenerate only duplicate GlobalIds + ifcpatch.execute({"input": model, "recipe": "RegenerateGlobalIds", "arguments": [True]}) + """ self.src = src self.file = file self.logger = logger - self.args = args + self.only_duplicates = only_duplicates def patch(self): - if self.args and self.args[0] == "DUPLICATE": + if self.only_duplicates: guids = set() for element in self.file.by_type("IfcRoot"): if element.GlobalId in guids: diff --git a/src/ifcpatch/ifcpatch/recipes/RemoveSiteRepresentation.py b/src/ifcpatch/ifcpatch/recipes/RemoveSiteRepresentation.py index 356673e904..c9703967d6 100644 --- a/src/ifcpatch/ifcpatch/recipes/RemoveSiteRepresentation.py +++ b/src/ifcpatch/ifcpatch/recipes/RemoveSiteRepresentation.py @@ -18,11 +18,20 @@ class Patcher: - def __init__(self, src, file, logger, args=None): + def __init__(self, src, file, logger): + """Removes any 3D geometry associated with a site or multiple sites + + If no sites or no site geometry is present, nothing happens. + + Example: + + .. code:: python + + ifcpatch.execute({"input": model, "recipe": "RemoveSiteRepresentation", "arguments": []}) + """ self.src = src self.file = file self.logger = logger - self.args = args def patch(self): project = self.file.by_type("IfcProject")[0] diff --git a/src/ifcpatch/ifcpatch/recipes/ResetAbsoluteCoordinates.py b/src/ifcpatch/ifcpatch/recipes/ResetAbsoluteCoordinates.py index 58c5462438..a44b41211f 100644 --- a/src/ifcpatch/ifcpatch/recipes/ResetAbsoluteCoordinates.py +++ b/src/ifcpatch/ifcpatch/recipes/ResetAbsoluteCoordinates.py @@ -18,11 +18,76 @@ class Patcher: - def __init__(self, src, file, logger, args=None): + def __init__(self, src, file, logger, a=None, b=None, c=None, d=None): + """Reset any large coordinates to smaller coordinates based on a threshold + + If you find large coordinates in your model, the large coordinates may + either by due to large coordinates in the object placement matrix of + each object, or large coordinates in the geometry of each object. + + If it is the former (preferred), consult the OffsetObjectPlacements + recipe. If it is the latter, this indicates seriously incorrect + coordinates in your IFC model. + + This recipe finds any large coordinates (if either the X, Y, or Z + ordinate is larger than a threshold) and offsets it back down to a small + number. + + You may either manually specify the offset to apply to any large + coordinate, or an offset will be automatically determined arbitrarily + based on the first large number we encounter. + + Note that if your model inconsistently mixes coordinates between large + and small (such as if your model mixes both local and map coordinates) + then the results of this function may be poor. Provide a bug report back + to your BIM application to get it fixed. + + You may specify up to 4 arguments, a, b, c, and d. + + If you specify no arguments, then the threshold is set to 1000000. The + offset is auto detected. + + If you only specify 1 parameter (i.e. a), then this is treated as the + threshold beyond which an ordinate is considered to be large. The offset + is auto detected. + + If you specify 3 parameters, (i.e. a, b, c) then your three numbers are + treated as the X, Y, Z offset to apply. Typically your numbers will be + negative to bring the numbers smaller. The threshold is set to 1000000. + + If you specify 4 parameters (i.e. a, b, c, d), then the first three + numbers are treated as the X, Y, Z offset to apply (a, b, c). The fourth + (d) will be treated as the threshold. + + :param a: The first parameter + :type a: float,optional + :param b: The second parameter + :type b: float,optional + :param c: The third parameter + :type c: float,optional + :param d: The fourth parameter + :type d: float,optional + + Example: + + .. code:: python + + # Reset all coordinates with an ordinate larger than 1000000 arbitrarily + ifcpatch.execute({"input": model, "recipe": "ResetAbsoluteCoordinates", "arguments": []}) + + # Reset all coordinates with an ordinate larger than 1000 arbitrarily + ifcpatch.execute({"input": model, "recipe": "ResetAbsoluteCoordinates", "arguments": [1000]}) + + # Reset all coordinates with an ordinate larger than 1000000 by -50000,-20000,0 + ifcpatch.execute({"input": model, "recipe": "ResetAbsoluteCoordinates", "arguments": [-50000,-20000,0]}) + + # Reset all coordinates with an ordinate larger than 1000 by -500,-200,0 + ifcpatch.execute({"input": model, "recipe": "ResetAbsoluteCoordinates", "arguments": [-500,-200,0,1000]}) + """ self.src = src self.file = file self.logger = logger - self.args = args + self.args = [x for x in [a, b, c, d] if x is not None] def patch(self): placement_coord_ids = set() diff --git a/src/ifcpatch/ifcpatch/recipes/ResetSpatialElementLocations.py b/src/ifcpatch/ifcpatch/recipes/ResetSpatialElementLocations.py index 680a9fa3f6..a1e19d1488 100644 --- a/src/ifcpatch/ifcpatch/recipes/ResetSpatialElementLocations.py +++ b/src/ifcpatch/ifcpatch/recipes/ResetSpatialElementLocations.py @@ -18,15 +18,31 @@ class Patcher: - def __init__(self, src, file, logger, args=None): + def __init__(self, src, file, logger, ifc_class="IfcSite"): + """Resets the location of a spatial element to 0,0,0 + + Another more specialised patch to fix incorrect coordinate usage is to + reset the location of spatial elements (sites, buildings, storeys) back + to 0,0,0. + + :param ifc_class: The class of spatial element to reset coordinates for. + :type ifc_class: str + + Example: + + .. code:: python + + # All IfcSites will shift back to 0,0,0. + ifcpatch.execute({"input": model, "recipe": "ResetSpatialElementLocations", "arguments": ["IfcSite"]}) + """ self.src = src self.file = file self.logger = logger - self.args = args + self.ifc_class = ifc_class def patch(self): project = self.file.by_type("IfcProject")[0] - spatial_elements = self.find_decomposed_ifc_class(project, self.args[0]) + spatial_elements = self.find_decomposed_ifc_class(project, self.ifc_class) for spatial_element in spatial_elements: self.patch_placement_to_origin(spatial_element) diff --git a/src/ifcpatch/ifcpatch/recipes/SetRefElevation.py b/src/ifcpatch/ifcpatch/recipes/SetRefElevation.py index a7b555279e..188644f230 100644 --- a/src/ifcpatch/ifcpatch/recipes/SetRefElevation.py +++ b/src/ifcpatch/ifcpatch/recipes/SetRefElevation.py @@ -18,17 +18,37 @@ class Patcher: - def __init__(self, src, file, logger, args=None): + def __init__(self, src, file, logger, elevation=0): + """Sets the reference elevation of all IfcSites + + To completely reference model coordinates, a reference elevation should + be specified on the IfcSite. This is often omitted or not possible by + proprietary BIM applications. This patch lets you set the reference + elevation attribute explicitly. + + Note that this does not physically shift the Z coordinates of anything. + The reference elevation is simply a numerical attribute. + + :param elevation: The elevation to set. + :type elevation: float + + Example: + + .. code:: python + + # All IfcSites will have their reference elevation set to 42. + ifcpatch.execute({"input": model, "recipe": "SetRefElevation", "arguments": [42]}) + """ self.src = src self.file = file self.logger = logger - self.args = args + self.elevation = elevation def patch(self): project = self.file.by_type("IfcProject")[0] sites = self.find_decomposed_ifc_class(project, "IfcSite") for site in sites: - site.RefElevation = float(self.args[0]) + site.RefElevation = float(self.elevation) def find_decomposed_ifc_class(self, element, ifc_class): results = [] diff --git a/src/ifcpatch/ifcpatch/recipes/SplitByBuildingStorey.py b/src/ifcpatch/ifcpatch/recipes/SplitByBuildingStorey.py index 5b6af064be..8ce7f30656 100644 --- a/src/ifcpatch/ifcpatch/recipes/SplitByBuildingStorey.py +++ b/src/ifcpatch/ifcpatch/recipes/SplitByBuildingStorey.py @@ -18,11 +18,22 @@ class Patcher: - def __init__(self, src, file, logger, args=None): + def __init__(self, src, file, logger): + """Split an IFC model into multiple models based on building storey + + The new IFC model names will be named after the storey name in the + format of {i}-{name}.ifc, where {i} is an ascending number starting from + 0 and {name} is the name of the storey. + + Example: + + .. code:: python + + ifcpatch.execute({"input": model, "recipe": "SplitByBuildingStorey", "arguments": []}) + """ self.src = src self.file = file self.logger = logger - self.args = args def patch(self): import ifcopenshell