mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-07 08:21:42 +00:00
Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b709f355ae | |||
| de2d2c4e3b | |||
| 901306a3fd | |||
| b52a23cb93 | |||
| b45cbefdd9 | |||
| a95c9fcaab | |||
| c078b5f255 | |||
| 7197d79dd9 | |||
| c3ca969e32 | |||
| 49bda7d8ba | |||
| 4c1f79f6ab | |||
| 5de76cf5f9 | |||
| 76f073d9e7 | |||
| c23dfcf82e | |||
| 80922fcd9a | |||
| 75cfbd40b1 | |||
| cd8199d3c4 | |||
| ad2dc8c38e | |||
| 4d564f00aa | |||
| 5b3960c9e5 | |||
| 01d7c4dea8 | |||
| 1d56736d75 | |||
| 06548c5db2 | |||
| b02321e3ed | |||
| 3d2104d689 | |||
| 9ecc587cb9 | |||
| e42acdfa86 | |||
| bf5b48d037 | |||
| 1cb298fa9c | |||
| 1801a768c1 | |||
| 66f0b71c65 | |||
| f4d7fda255 | |||
| c112c115ce | |||
| 2a2ba45676 | |||
| fd69c0534b | |||
| 1226370b9e | |||
| 6e3eaa9b39 | |||
| 3ed2da23be | |||
| 249c498112 | |||
| 2dde62803d | |||
| d4ac3c36ce | |||
| fecd4a810a | |||
| 060afb94b5 | |||
| b1f1de954d | |||
| 12ed2d1ec3 | |||
| c77983ffe5 | |||
| 78f26c2432 | |||
| 39161e7256 |
+1
-2
@@ -82,8 +82,6 @@ src/bonsai/bonsai/translations.py
|
||||
|
||||
# bonsai test temp files
|
||||
src/bonsai/test/files/temp
|
||||
src/bonsai/test/files/basic.ifc.cache.blend
|
||||
src/bonsai/test/files/basic.ifc.cache.sqlite
|
||||
|
||||
# bonsai data
|
||||
src/bonsai/bonsai/bim/data/build/
|
||||
@@ -115,3 +113,4 @@ dev_environment.bat
|
||||
|
||||
src/ifcopenshell-python/ifcopenshell/express/*.exp
|
||||
src/ifcopenshell-python/ifcopenshell/express/*.exp.cache.dat
|
||||
src/ifcquery/build/
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ name = "IfcOpenShell"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"black==26.1.0",
|
||||
"ruff==0.15.1",
|
||||
"ruff==0.15.0",
|
||||
"poethepoet",
|
||||
"gersemi==0.25.4",
|
||||
]
|
||||
|
||||
+1
-1
@@ -371,7 +371,7 @@ test-tool:
|
||||
ifndef MODULE
|
||||
pytest test/tool
|
||||
else
|
||||
pytest test/tool/test_$(MODULE).py --maxfail=1
|
||||
pytest test/tool/test_$(MODULE).py
|
||||
endif
|
||||
|
||||
# Reregistering test is not added to the standard test suite because during unregister
|
||||
|
||||
@@ -52,6 +52,7 @@ class IfcExporter:
|
||||
self.set_header()
|
||||
IfcStore.update_cache()
|
||||
self.sync_all_objects()
|
||||
tool.Project.save_linked_models_to_ifc()
|
||||
extension = self.ifc_export_settings.output_file.split(".")[-1].lower()
|
||||
if extension == "ifczip":
|
||||
with tempfile.TemporaryDirectory() as unzipped_path:
|
||||
|
||||
@@ -85,7 +85,7 @@ class MaterialCreator:
|
||||
if element.is_a("IfcTypeProduct"):
|
||||
self.parse_element_type_material_styles(element)
|
||||
self.parsed_meshes.add(self.mesh.name)
|
||||
if self.ifc_import_settings.load_indexed_maps:
|
||||
if not self.ifc_import_settings.load_indexed_maps:
|
||||
self.load_texture_maps(shape_has_openings)
|
||||
self.assign_material_slots_to_faces()
|
||||
tool.Geometry.record_object_materials(obj)
|
||||
@@ -117,6 +117,7 @@ class MaterialCreator:
|
||||
for texture in texture_style.Textures or []:
|
||||
if coords := getattr(texture, "IsMappedBy", None):
|
||||
coords = coords[0]
|
||||
# IfcTextureCoordinateGenerator handled in the style shader graph
|
||||
if coords.is_a("IfcIndexedTextureMap"):
|
||||
return coords
|
||||
# TODO: support IfcTextureMap
|
||||
@@ -134,10 +135,6 @@ class MaterialCreator:
|
||||
if shape_has_openings and coords.is_a("IfcIndexedTextureMap"):
|
||||
continue
|
||||
tool.Loader.load_indexed_map(coords, self.mesh)
|
||||
elif tool.Style.get_texture_style(material):
|
||||
# No explicit coordinate mapping (e.g. IFC2X3 has no IsMappedBy,
|
||||
# and IFC4 COORD uses generated UVs). Bake XY→UV as fallback.
|
||||
tool.Loader.load_generated_uv_map(self.mesh)
|
||||
|
||||
def assign_material_slots_to_faces(self) -> None:
|
||||
if not self.mesh["ios_materials"]:
|
||||
@@ -895,6 +892,48 @@ class IfcImporter:
|
||||
obj, tool.Loader.apply_blender_offset_to_matrix_world(obj, self.get_element_matrix(element))
|
||||
)
|
||||
|
||||
if element.is_a("IfcAnnotation") and getattr(element, "ObjectType", None) == "IMAGE":
|
||||
image = None
|
||||
if obj.data and obj.data.materials and obj.data.materials[0]:
|
||||
material = obj.data.materials[0]
|
||||
if material.use_nodes and material.node_tree:
|
||||
for node in material.node_tree.nodes:
|
||||
if node.type == "TEX_IMAGE" and node.image:
|
||||
image = node.image
|
||||
break
|
||||
if image:
|
||||
import bmesh
|
||||
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(obj.data)
|
||||
if not bm.loops.layers.uv:
|
||||
uv_layer = bm.loops.layers.uv.new()
|
||||
else:
|
||||
uv_layer = bm.loops.layers.uv.active
|
||||
|
||||
if bm.verts:
|
||||
min_x = min(v.co.x for v in bm.verts)
|
||||
max_x = max(v.co.x for v in bm.verts)
|
||||
min_y = min(v.co.y for v in bm.verts)
|
||||
max_y = max(v.co.y for v in bm.verts)
|
||||
|
||||
width = max_x - min_x
|
||||
height = max_y - min_y
|
||||
|
||||
for face in bm.faces:
|
||||
for loop in face.loops:
|
||||
vert = loop.vert
|
||||
u = (vert.co.x - min_x) / width if width > 0 else 0.5
|
||||
v = (vert.co.y - min_y) / height if height > 0 else 0.5
|
||||
|
||||
u = max(0.0, min(1.0, u))
|
||||
v = max(0.0, min(1.0, v))
|
||||
|
||||
loop[uv_layer].uv = (u, v)
|
||||
|
||||
bm.to_mesh(obj.data)
|
||||
bm.free()
|
||||
obj.data.update()
|
||||
return obj
|
||||
|
||||
def load_existing_meshes(self) -> None:
|
||||
|
||||
@@ -163,7 +163,7 @@ class BIMBSDDProperties(PropertyGroup):
|
||||
default=False,
|
||||
)
|
||||
classification_psets: CollectionProperty(name="Classification Psets", type=BSDDPset)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
active_dictionary: str
|
||||
active_dictionary: str
|
||||
@@ -182,7 +182,7 @@ class BIMBSDDProperties(PropertyGroup):
|
||||
should_filter_ifc_class: bool
|
||||
use_only_ifc_properties: bool
|
||||
classification_psets: bpy.types.bpy_prop_collection_idprop[BSDDPset]
|
||||
|
||||
|
||||
@property
|
||||
def active_class(self) -> Union[BSDDClassification, None]:
|
||||
return tool.Blender.get_active_uilist_element(self.classes, self.active_class_index)
|
||||
|
||||
@@ -24,7 +24,6 @@ import bpy
|
||||
from bpy.types import Panel, UIList
|
||||
|
||||
import bonsai.tool as tool
|
||||
import bsdd
|
||||
from bonsai.bim.module.bsdd.data import BSDDData
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -84,6 +83,7 @@ class BIM_PT_bsdd(Panel):
|
||||
row = self.layout.row()
|
||||
row.operator("bim.load_bsdd_dictionaries")
|
||||
|
||||
|
||||
class BIM_UL_bsdd_dictionaries(UIList):
|
||||
def draw_item(
|
||||
self,
|
||||
|
||||
@@ -1788,7 +1788,7 @@ class CutDecorator:
|
||||
|
||||
# Handle both old float64 and new float32 checksums for version compatibility
|
||||
rot_checksum_bytes: bytes = eval(DecoratorData.camera_rotation_checksum)
|
||||
rot_check = tool.Blender.np_frombuffer_legacy(rot_checksum_bytes, 9).reshape(3, 3)
|
||||
rot_check = tool.Blender.np_frombuffer_legacy(rot_checksum_bytes, 9)
|
||||
rot_real = tool.Blender.np_array_legacy(obj.matrix_world.to_3x3())
|
||||
rot_dot = np.dot(rot_check, rot_real.T)
|
||||
angle_rad = np.arccos(np.clip((np.trace(rot_dot) - 1) / 2, -1, 1))
|
||||
|
||||
@@ -40,7 +40,6 @@ from typing import (
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
import logging
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api
|
||||
import ifcopenshell.api.document
|
||||
@@ -51,7 +50,6 @@ import ifcopenshell.ifcopenshell_wrapper
|
||||
import ifcopenshell.util.element
|
||||
import ifcopenshell.util.representation
|
||||
import ifcopenshell.util.selector
|
||||
import ifcopenshell.util.shape_builder
|
||||
import ifcopenshell.util.unit
|
||||
import numpy as np
|
||||
import shapely
|
||||
@@ -61,7 +59,6 @@ from bpy_extras.io_utils import ImportHelper
|
||||
from lxml import etree
|
||||
from mathutils import Color, Matrix, Vector
|
||||
|
||||
import bonsai.bim.import_ifc
|
||||
import bonsai.bim.export_ifc
|
||||
import bonsai.bim.handler
|
||||
import bonsai.bim.helper
|
||||
@@ -141,7 +138,7 @@ class AddAnnotationType(bpy.types.Operator, tool.Ifc.Operator):
|
||||
element.ApplicableOccurrence = f"IfcAnnotation/{object_type}"
|
||||
|
||||
if props.create_representation_for_type and object_type == "IMAGE":
|
||||
bpy.ops.bim.add_reference_image("INVOKE_DEFAULT", existing_object_by_name=obj.name)
|
||||
bpy.ops.bim.add_reference_image("INVOKE_DEFAULT", use_existing_object_by_name=obj.name)
|
||||
|
||||
|
||||
class EnableAddAnnotationType(bpy.types.Operator):
|
||||
@@ -931,7 +928,7 @@ class CreateDrawing(bpy.types.Operator):
|
||||
|
||||
props = tool.Project.get_project_props()
|
||||
for link in props.get_loaded_links_for_drawings():
|
||||
files[link.filepath] = self.get_linked_file(link)
|
||||
files[link.name] = self.get_linked_file(link)
|
||||
|
||||
target_view = ifcopenshell.util.element.get_psets(self.camera_element)["EPset_Drawing"]["TargetView"]
|
||||
self.setup_serialiser(target_view)
|
||||
@@ -1377,7 +1374,7 @@ class CreateDrawing(bpy.types.Operator):
|
||||
return True
|
||||
|
||||
def get_linked_file(self, link: "Link") -> ifcopenshell.file:
|
||||
link_path = link.filepath
|
||||
link_path = link.name
|
||||
ifc_file = IfcStore.session_files.get(link_path, None)
|
||||
if ifc_file is not None:
|
||||
return ifc_file
|
||||
@@ -1762,7 +1759,7 @@ class AddAnnotation(bpy.types.Operator, tool.Ifc.Operator):
|
||||
enable_editing=True,
|
||||
)
|
||||
if props.object_type == "IMAGE":
|
||||
bpy.ops.bim.add_reference_image("INVOKE_DEFAULT", existing_object_by_name=obj.name)
|
||||
bpy.ops.bim.add_reference_image("INVOKE_DEFAULT", use_existing_object_by_name=obj.name)
|
||||
|
||||
|
||||
class AddSheet(bpy.types.Operator, tool.Ifc.Operator):
|
||||
@@ -2899,16 +2896,12 @@ class AddSchedule(bpy.types.Operator, tool.Ifc.Operator, ImportHelper):
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
bl_description = "Add an .ods, .xls or .xlsx file as a schedule"
|
||||
|
||||
files: bpy.props.CollectionProperty(name="Files", type=bpy.types.OperatorFileListElement)
|
||||
directory: bpy.props.StringProperty(subtype="DIR_PATH")
|
||||
filter_glob: bpy.props.StringProperty(default="*.ods;*.xls;*.xlsx", options={"HIDDEN"})
|
||||
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=True)
|
||||
|
||||
def _execute(self, context):
|
||||
for filepath in tool.Blender.get_selected_files(
|
||||
self.directory, self.files, use_relative_path=self.use_relative_path
|
||||
):
|
||||
core.add_document(tool.Ifc, tool.Drawing, "SCHEDULE", uri=filepath)
|
||||
filepath = tool.Ifc.get_uri(self.filepath, use_relative_path=self.use_relative_path)
|
||||
core.add_document(tool.Ifc, tool.Drawing, "SCHEDULE", uri=filepath)
|
||||
|
||||
|
||||
class RemoveSchedule(bpy.types.Operator, tool.Ifc.Operator):
|
||||
@@ -3097,16 +3090,23 @@ class AddReference(bpy.types.Operator, tool.Ifc.Operator, ImportHelper):
|
||||
bl_description = "Import a .svg file to the project as a reference"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
files: bpy.props.CollectionProperty(name="Files", type=bpy.types.OperatorFileListElement)
|
||||
directory: bpy.props.StringProperty(subtype="DIR_PATH")
|
||||
filter_glob: bpy.props.StringProperty(default="*.svg", options={"HIDDEN"})
|
||||
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=True)
|
||||
filename_ext = ".svg"
|
||||
|
||||
files: bpy.props.CollectionProperty(type=bpy.types.OperatorFileListElement)
|
||||
directory: bpy.props.StringProperty(subtype="DIR_PATH")
|
||||
|
||||
def _execute(self, context):
|
||||
for filepath in tool.Blender.get_selected_files(
|
||||
self.directory, self.files, use_relative_path=self.use_relative_path
|
||||
):
|
||||
# Handle both single and multiple file selection
|
||||
if self.files:
|
||||
for file_elem in self.files:
|
||||
filepath = os.path.join(self.directory, file_elem.name)
|
||||
uri = tool.Ifc.get_uri(filepath, use_relative_path=self.use_relative_path)
|
||||
core.add_document(tool.Ifc, tool.Drawing, "REFERENCE", uri=uri)
|
||||
else:
|
||||
# Fallback for single file (backward compatibility)
|
||||
filepath = tool.Ifc.get_uri(self.filepath, use_relative_path=self.use_relative_path)
|
||||
core.add_document(tool.Ifc, tool.Drawing, "REFERENCE", uri=filepath)
|
||||
|
||||
|
||||
@@ -3165,7 +3165,6 @@ class EditTextPopup(bpy.types.Operator):
|
||||
bpy.ops.bim.disable_editing_text()
|
||||
|
||||
def execute(self, context):
|
||||
# TODO: check for possible subtle undo bug here
|
||||
# can't use invoke() because this operator
|
||||
# will be run indirectly by hotkey
|
||||
# so we use execute() and track whether it's the first run of the operator
|
||||
@@ -3185,10 +3184,7 @@ class EditText(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def _execute(self, context):
|
||||
obj = tool.Blender.get_active_object()
|
||||
if not tool.Drawing.get_text_props(obj).is_editing:
|
||||
return
|
||||
core.edit_text(tool.Drawing, obj=obj)
|
||||
core.edit_text(tool.Drawing, obj=tool.Blender.get_active_object())
|
||||
tool.Blender.update_viewport()
|
||||
|
||||
|
||||
@@ -3808,51 +3804,83 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper):
|
||||
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=True)
|
||||
filter_image: bpy.props.BoolProperty(default=True, options={"HIDDEN", "SKIP_SAVE"})
|
||||
filter_folder: bpy.props.BoolProperty(default=True, options={"HIDDEN", "SKIP_SAVE"})
|
||||
|
||||
override_existing_image: bpy.props.BoolProperty(
|
||||
name="Override Existing Image",
|
||||
default=True,
|
||||
description=(
|
||||
"Override image if it was previously loaded to Blender. If disabled, will always create a new image"
|
||||
),
|
||||
)
|
||||
use_existing_object_by_name: bpy.props.StringProperty(
|
||||
name="Use Existing Object By Name",
|
||||
description="Existing object name to add a style with reference image to. If not provided will create a new object.",
|
||||
options={"SKIP_SAVE"},
|
||||
)
|
||||
|
||||
x_length: bpy.props.FloatProperty(
|
||||
name="X Length",
|
||||
description="Width of the reference image",
|
||||
description="Width of the reference image in project units",
|
||||
default=1.0,
|
||||
min=0.001,
|
||||
soft_min=0.01,
|
||||
precision=3,
|
||||
unit="LENGTH",
|
||||
)
|
||||
y_length: bpy.props.FloatProperty(
|
||||
name="Y Length",
|
||||
description="Height of the reference image",
|
||||
description="Height of the reference image in project units",
|
||||
default=1.0,
|
||||
min=0.001,
|
||||
soft_min=0.01,
|
||||
precision=3,
|
||||
unit="LENGTH",
|
||||
)
|
||||
show_texture_solid_mode: bpy.props.BoolProperty(
|
||||
name="Show Texture in Solid mode (slow)",
|
||||
description="Show Texture in Solid mode (slow)",
|
||||
default=False,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
if not tool.Ifc.get():
|
||||
cls.poll_message_set("No IFC project is loaded.")
|
||||
return False
|
||||
return True
|
||||
show_dimensions_dialog: bpy.props.BoolProperty(default=False, options={"HIDDEN", "SKIP_SAVE"})
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
|
||||
if getattr(self, "show_dimensions_dialog", False):
|
||||
if tool.Ifc.get():
|
||||
length_unit = ifcopenshell.util.unit.get_project_unit(tool.Ifc.get(), "LENGTHUNIT")
|
||||
if length_unit:
|
||||
unit_name = ifcopenshell.util.unit.get_full_unit_name(length_unit).lower()
|
||||
else:
|
||||
unit_name = "project units"
|
||||
layout.label(text=f"Set Reference Image Dimensions (in {unit_name}):")
|
||||
else:
|
||||
layout.label(text="Set Reference Image Dimensions (in project units):")
|
||||
layout.separator()
|
||||
layout.prop(self, "x_length")
|
||||
layout.prop(self, "y_length")
|
||||
else:
|
||||
if Path(tool.Ifc.get_path()).is_file():
|
||||
layout.prop(self, "use_relative_path")
|
||||
else:
|
||||
self.use_relative_path = False
|
||||
layout.label(text="Save the .ifc file first ")
|
||||
layout.label(text="to use relative paths.")
|
||||
layout.prop(self, "override_existing_image")
|
||||
layout.prop(self, "use_existing_object_by_name")
|
||||
|
||||
def invoke(self, context, event):
|
||||
self._last_filepath = ""
|
||||
return super().invoke(context, event)
|
||||
|
||||
def check(self, context):
|
||||
if not hasattr(self, "_last_filepath"):
|
||||
self._last_filepath = ""
|
||||
|
||||
if self.filepath and self.filepath != self._last_filepath:
|
||||
self._last_filepath = self.filepath
|
||||
if not getattr(self, "show_dimensions_dialog", False):
|
||||
context.window_manager.fileselect_add(self)
|
||||
return {"RUNNING_MODAL"}
|
||||
else:
|
||||
return context.window_manager.invoke_props_dialog(self)
|
||||
|
||||
def execute(self, context):
|
||||
if not getattr(self, "show_dimensions_dialog", False):
|
||||
abs_path = Path(self.filepath).absolute().resolve()
|
||||
if abs_path.exists() and abs_path.is_file():
|
||||
image = load_image(abs_path.name, str(abs_path.parent), check_existing=False)
|
||||
if self.override_existing_image:
|
||||
params = {"check_existing": True, "force_reload": True}
|
||||
else:
|
||||
params = {"check_existing": False}
|
||||
|
||||
try:
|
||||
image = load_image(abs_path.name, str(abs_path.parent), **params)
|
||||
|
||||
image_width_px = image.size[0]
|
||||
image_height_px = image.size[1]
|
||||
aspect_ratio = image_width_px / image_height_px
|
||||
@@ -3865,24 +3893,17 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper):
|
||||
self.y_length = 1.0
|
||||
|
||||
bpy.data.images.remove(image)
|
||||
return True
|
||||
|
||||
return False
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, f"Failed to load image: {str(e)}")
|
||||
return {"CANCELLED"}
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
if Path(tool.Ifc.get_path()).is_file():
|
||||
layout.prop(self, "use_relative_path")
|
||||
else:
|
||||
self.use_relative_path = False
|
||||
layout.prop(self, "show_texture_solid_mode")
|
||||
layout.prop(self, "x_length")
|
||||
layout.prop(self, "y_length")
|
||||
self.show_dimensions_dialog = True
|
||||
return context.window_manager.invoke_props_dialog(self)
|
||||
|
||||
return self._execute(context)
|
||||
|
||||
def _execute(self, context):
|
||||
project_props = tool.Project.get_project_props()
|
||||
project_props.load_indexed_maps = self.show_texture_solid_mode
|
||||
space = tool.Blender.get_view3d_space()
|
||||
if space.shading.color_type != "TEXTURE":
|
||||
space.shading.color_type = "TEXTURE"
|
||||
@@ -3895,66 +3916,121 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper):
|
||||
image_filepath = Path(tool.Ifc.get_uri(self.filepath, use_relative_path=self.use_relative_path))
|
||||
ifc_file = tool.Ifc.get()
|
||||
|
||||
params = {"check_existing": False}
|
||||
if self.override_existing_image:
|
||||
params = {"check_existing": True, "force_reload": True}
|
||||
else:
|
||||
params = {"check_existing": False}
|
||||
image = load_image(abs_path.name, str(abs_path.parent), **params)
|
||||
|
||||
mesh = bpy.data.meshes.new(image_filepath.stem)
|
||||
obj = bpy.data.objects.new(image_filepath.stem, mesh)
|
||||
element = tool.Drawing.run_root_assign_class(
|
||||
obj=obj, ifc_class="IfcAnnotation", predefined_type="IMAGE", should_add_representation=False
|
||||
)
|
||||
def bm_add_image_plane(mesh):
|
||||
bm = tool.Blender.get_bmesh_for_mesh(mesh, clean=True)
|
||||
|
||||
builder = ifcopenshell.util.shape_builder.ShapeBuilder(ifc_file)
|
||||
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file)
|
||||
hx = self.x_length * 0.5 / unit_scale
|
||||
hy = self.y_length * 0.5 / unit_scale
|
||||
verts = [(-hx, -hy, 0.0), ( hx, -hy, 0.0), ( hx, hy, 0.0), (-hx, hy, 0.0)]
|
||||
item = builder.mesh(verts, [[0, 1, 2, 3]])
|
||||
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file)
|
||||
plane_scale = Vector((self.x_length * unit_scale / 2.0, self.y_length * unit_scale / 2.0, 1.0))
|
||||
matrix = Matrix.LocRotScale(None, None, plane_scale)
|
||||
bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=1, matrix=matrix, calc_uvs=False)
|
||||
|
||||
ifc_context = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW")
|
||||
representation = builder.get_representation(ifc_context, [item])
|
||||
ifcopenshell.api.geometry.assign_representation(ifc_file, element, representation)
|
||||
if not bm.loops.layers.uv:
|
||||
uv_layer = bm.loops.layers.uv.new()
|
||||
else:
|
||||
uv_layer = bm.loops.layers.uv.active
|
||||
|
||||
style = ifcopenshell.api.style.add_style(tool.Ifc.get(), name=image_filepath.stem)
|
||||
ifcopenshell.api.style.assign_representation_styles(
|
||||
ifc_file, shape_representation=representation, styles=[style]
|
||||
)
|
||||
min_x = min(v.co.x for v in bm.verts)
|
||||
max_x = max(v.co.x for v in bm.verts)
|
||||
min_y = min(v.co.y for v in bm.verts)
|
||||
max_y = max(v.co.y for v in bm.verts)
|
||||
|
||||
width = max_x - min_x
|
||||
height = max_y - min_y
|
||||
|
||||
for face in bm.faces:
|
||||
for loop in face.loops:
|
||||
vert = loop.vert
|
||||
u = (vert.co.x - min_x) / width if width > 0 else 0.5
|
||||
v = (vert.co.y - min_y) / height if height > 0 else 0.5
|
||||
|
||||
u = max(0.0, min(1.0, u))
|
||||
v = max(0.0, min(1.0, v))
|
||||
loop[uv_layer].uv = (u, v)
|
||||
|
||||
tool.Blender.apply_bmesh(mesh, bm)
|
||||
|
||||
if self.use_existing_object_by_name:
|
||||
obj = bpy.data.objects[self.use_existing_object_by_name]
|
||||
bm_add_image_plane(obj.data)
|
||||
bpy.ops.bim.update_representation(obj=obj.name, ifc_representation_class="")
|
||||
else:
|
||||
temp_mesh = bpy.data.meshes.new("temp_mesh")
|
||||
bm_add_image_plane(temp_mesh)
|
||||
obj = bpy.data.objects.new(image_filepath.stem, temp_mesh)
|
||||
tool.Drawing.run_root_assign_class(
|
||||
obj=obj,
|
||||
ifc_class="IfcAnnotation",
|
||||
predefined_type="IMAGE",
|
||||
should_add_representation=True,
|
||||
context=ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW"),
|
||||
ifc_representation_class=None,
|
||||
)
|
||||
tool.Blender.remove_data_block(temp_mesh)
|
||||
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if element and isinstance(obj.data, bpy.types.Mesh):
|
||||
representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
|
||||
if representation and representation.Items:
|
||||
item_id = representation.Items[0].id()
|
||||
num_faces = len(obj.data.polygons)
|
||||
obj.data["ios_item_ids"] = [item_id] * num_faces
|
||||
tool.Blender.Attribute.fill_attribute(obj.data, "ios_item_ids", "FACE", "INT", [item_id] * num_faces)
|
||||
|
||||
for item in representation.Items:
|
||||
if item.is_a("IfcPolygonalFaceSet") and item.Coordinates:
|
||||
new_coords = []
|
||||
for vertex in obj.data.vertices:
|
||||
co = obj.matrix_world @ vertex.co
|
||||
new_coords.append([co.x, co.y, co.z])
|
||||
item.Coordinates.CoordList = new_coords
|
||||
|
||||
tool.Blender.set_active_object(obj)
|
||||
|
||||
material = bpy.data.materials.new(name=image_filepath.stem)
|
||||
obj.data.materials.append(None) # new slot
|
||||
obj.material_slots[0].material = material
|
||||
bpy.ops.bim.add_style()
|
||||
|
||||
style = tool.Ifc.get_entity(material)
|
||||
assert style
|
||||
tool.Style.assign_style_to_object(style, obj)
|
||||
|
||||
# TODO: IfcSurfaceStyleRendering is unnecessary here, added it only because
|
||||
# we don't support IfcSurfaceStyleWithTextures without Rendering yet
|
||||
shading_attributes = {
|
||||
"SurfaceColour": {"Red": 1.0, "Green": 1.0, "Blue": 1.0},
|
||||
"SurfaceColour": {
|
||||
"Red": 1.0,
|
||||
"Green": 1.0,
|
||||
"Blue": 1.0,
|
||||
},
|
||||
"Transparency": 0.0,
|
||||
"ReflectanceMethod": "NOTDEFINED",
|
||||
}
|
||||
ifcopenshell.api.style.add_surface_style(
|
||||
tool.Ifc.get(), style=style, ifc_class="IfcSurfaceStyleRendering", attributes=shading_attributes
|
||||
tool.Ifc.get(),
|
||||
style=style,
|
||||
ifc_class="IfcSurfaceStyleRendering",
|
||||
attributes=shading_attributes,
|
||||
)
|
||||
|
||||
if tool.Ifc.get_schema() == "IFC2X3":
|
||||
texture = ifc_file.create_entity(
|
||||
"IfcImageTexture",
|
||||
RepeatS=True,
|
||||
RepeatT=True,
|
||||
TextureType="TEXTURE",
|
||||
UrlReference=image_filepath.as_posix(),
|
||||
)
|
||||
else:
|
||||
texture = ifc_file.create_entity("IfcImageTexture", Mode="DIFFUSE", URLReference=image_filepath.as_posix())
|
||||
ifc_file.create_entity("IfcTextureCoordinateGenerator", Maps=[texture], Mode="COORD")
|
||||
|
||||
texture = ifc_file.create_entity("IfcImageTexture", Mode="DIFFUSE", URLReference=image_filepath.as_posix())
|
||||
textures = [texture]
|
||||
ifc_file.create_entity("IfcTextureCoordinateGenerator", Maps=textures, Mode="COORD") # UV map
|
||||
ifcopenshell.api.style.add_surface_style(
|
||||
ifc_file, style=style, ifc_class="IfcSurfaceStyleWithTextures", attributes={"Textures": textures}
|
||||
ifc_file,
|
||||
style=style,
|
||||
ifc_class="IfcSurfaceStyleWithTextures",
|
||||
attributes={"Textures": textures},
|
||||
)
|
||||
tool.Style.reload_material_from_ifc(material)
|
||||
tool.Geometry.record_object_materials(obj)
|
||||
|
||||
logger = logging.getLogger("ImportIFC")
|
||||
ifc_import_settings = bonsai.bim.import_ifc.IfcImportSettings.factory(bpy.context, None, logger)
|
||||
ifc_importer = bonsai.bim.import_ifc.IfcImporter(ifc_import_settings)
|
||||
ifc_importer.file = tool.Ifc.get()
|
||||
ifc_importer.create_style(style)
|
||||
|
||||
bonsai.core.geometry.switch_representation(tool.Ifc, tool.Geometry, obj=obj, representation=representation)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class ConvertSVGToDXF(bpy.types.Operator):
|
||||
|
||||
@@ -89,7 +89,7 @@ class BIM_PT_camera(Panel):
|
||||
for link in links:
|
||||
row = panel.row(align=True)
|
||||
split = row.split(factor=0.9)
|
||||
split.label(text=link.filepath, icon="FILE")
|
||||
split.label(text=link.name, icon="FILE")
|
||||
split.prop(link, "include_in_drawings", text="")
|
||||
else:
|
||||
panel.label(text="No IFC projects linked and loaded.")
|
||||
|
||||
@@ -902,7 +902,6 @@ class OverrideDelete(bpy.types.Operator):
|
||||
|
||||
if not is_valid_data_block:
|
||||
continue
|
||||
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if element:
|
||||
if tool.Geometry.is_locked(element):
|
||||
@@ -913,9 +912,6 @@ class OverrideDelete(bpy.types.Operator):
|
||||
if ifcopenshell.util.element.get_pset(element, "BBIM_Array"):
|
||||
self.report({"INFO"}, "Elements that are part of an array cannot be deleted.")
|
||||
continue
|
||||
if element.is_a("IfcDocumentReference"):
|
||||
self.report({"INFO"}, "Linked models cannot be deleted.")
|
||||
continue
|
||||
if element.is_a("IfcGridAxis"):
|
||||
# Deleting the last W axis is OK
|
||||
if ((grid := element.PartOfU) and len(grid[0].UAxes) == 1) or (
|
||||
@@ -1212,11 +1208,6 @@ class OverrideDuplicateMove(bpy.types.Operator):
|
||||
operator.report({"ERROR"}, f"Drawing '{obj.name}' not duplicated.")
|
||||
continue
|
||||
|
||||
if element.is_a("IfcDocumentReference"):
|
||||
objects_to_remove.add(obj)
|
||||
operator.report({"ERROR"}, f"Linked model '{obj.name}' not duplicated.")
|
||||
continue
|
||||
|
||||
if tool.Geometry.is_locked(element):
|
||||
objects_to_remove.add(obj)
|
||||
operator.report({"ERROR"}, lock_error_message(obj.name))
|
||||
|
||||
@@ -51,7 +51,7 @@ class RemoveGeoreferencing(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_description = "Remove the georeferencing"
|
||||
|
||||
def _execute(self, context):
|
||||
core.remove_georeferencing(tool.Ifc, tool.Georeference)
|
||||
core.remove_georeferencing(tool.Ifc)
|
||||
|
||||
|
||||
class EditGeoreferencing(bpy.types.Operator, tool.Ifc.Operator):
|
||||
|
||||
@@ -224,12 +224,16 @@ class BIMGeoreferenceProperties(PropertyGroup):
|
||||
x_axis_ordinate: StringProperty(name="X Axis Ordinate", update=update_grid_north_vector)
|
||||
x_axis_is_null: BoolProperty(name="X Axis Is Null")
|
||||
|
||||
# These are only for reference to capture data about a host model from a linked model
|
||||
# If you relink a model from a new host origin, we can autodetect it in theory with this
|
||||
host_model_origin: StringProperty(name="Host Model Origin")
|
||||
host_model_origin_si: StringProperty(name="Host Model Origin SI")
|
||||
host_model_project_north: StringProperty(name="Host Model Angle to Grid North")
|
||||
|
||||
# This is the ENH in project units and SI units of the Blender session's 0,0,0.
|
||||
# These are only for reference, using tool.Georeference.set_model_origin on
|
||||
# project load, project create, and when linking for the first time from an
|
||||
# empty Blender session.
|
||||
model_is_georeferenced: BoolProperty(name="Model Is Georeferenced")
|
||||
model_crs: StringProperty(name="Model CRS")
|
||||
model_origin: StringProperty(name="Model Origin")
|
||||
model_origin_si: StringProperty(name="Model Origin SI")
|
||||
model_project_north: StringProperty(name="Model Angle to Grid North")
|
||||
@@ -271,6 +275,10 @@ class BIMGeoreferenceProperties(PropertyGroup):
|
||||
x_axis_ordinate: str
|
||||
x_axis_is_null: bool
|
||||
|
||||
host_model_origin: str
|
||||
host_model_origin_si: str
|
||||
host_model_project_north: str
|
||||
|
||||
model_origin: str
|
||||
model_origin_si: str
|
||||
model_project_north: str
|
||||
|
||||
@@ -211,21 +211,22 @@ class PolylineOperator:
|
||||
context.workspace.status_text_set(draw_instructions)
|
||||
|
||||
def handle_lock_axis(self, context: bpy.types.Context, event: bpy.types.Event) -> None:
|
||||
angle_snap = tool.Snap.get_angle_snap_value(context)
|
||||
if event.value == "PRESS" and event.type == "A":
|
||||
self.tool_state.lock_axis = False if self.tool_state.lock_axis else True
|
||||
if self.tool_state.lock_axis:
|
||||
self.tool_state.snap_angle = self.input_ui.get_number_value("WORLD_ANGLE")
|
||||
self.tool_state.snap_angle = round(self.tool_state.snap_angle / angle_snap) * angle_snap
|
||||
# Round to the closest 5
|
||||
self.tool_state.snap_angle = round(self.tool_state.snap_angle / 5) * 5
|
||||
|
||||
if event.shift and event.type in {"WHEELUPMOUSE", "WHEELDOWNMOUSE"}:
|
||||
self.tool_state.lock_axis = True
|
||||
self.tool_state.snap_angle = self.input_ui.get_number_value("WORLD_ANGLE")
|
||||
self.tool_state.snap_angle = round(self.tool_state.snap_angle / angle_snap) * angle_snap
|
||||
# Round to the closest 5
|
||||
self.tool_state.snap_angle = round(self.tool_state.snap_angle / 5) * 5
|
||||
if event.type in {"WHEELUPMOUSE"}:
|
||||
self.tool_state.snap_angle += angle_snap
|
||||
self.tool_state.snap_angle += 5
|
||||
else:
|
||||
self.tool_state.snap_angle -= angle_snap
|
||||
self.tool_state.snap_angle -= 5
|
||||
self.handle_mouse_move(context, event)
|
||||
detected_snaps = tool.Snap.detect_snapping_points(context, event, self.objs_2d_bbox, self.tool_state)
|
||||
self.snapping_points = tool.Snap.select_snapping_points(context, event, self.tool_state, detected_snaps)
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import copy
|
||||
from math import atan2, degrees, pi, radians
|
||||
from math import atan2, degrees, pi
|
||||
from typing import Any, Literal, Optional, Union
|
||||
|
||||
import bmesh
|
||||
@@ -195,8 +195,8 @@ class DumbProfileGenerator:
|
||||
if should_round:
|
||||
# Round to nearest 50mm (yes, metric for now)
|
||||
self.length = 0.05 * round(length / 0.05)
|
||||
angle_snap = tool.Snap.get_angle_snap_value(bpy.context)
|
||||
nearest_degree = radians(angle_snap)
|
||||
# Round to nearest 5 degrees
|
||||
nearest_degree = (pi / 180) * 5
|
||||
self.rotation = nearest_degree * round(self.rotation / nearest_degree)
|
||||
self.location = coords[0]
|
||||
data["obj"] = self.create_profile()
|
||||
|
||||
@@ -916,8 +916,8 @@ class DumbWallGenerator:
|
||||
if should_round:
|
||||
# Round to nearest 50mm (yes, metric for now)
|
||||
self.length = 0.05 * round(length / 0.05)
|
||||
angle_snap = tool.Snap.get_angle_snap_value(bpy.context)
|
||||
nearest_degree = math.radians(angle_snap)
|
||||
# Round to nearest 5 degrees
|
||||
nearest_degree = (math.pi / 180) * 5
|
||||
self.rotation = nearest_degree * round(self.rotation / nearest_degree)
|
||||
self.location = coords[0]
|
||||
data["obj"] = self.create_wall()
|
||||
|
||||
@@ -31,31 +31,27 @@ classes = (
|
||||
operator.BIM_OT_load_clipping_planes,
|
||||
operator.BIM_OT_save_clipping_planes,
|
||||
operator.ChangeLibraryElement,
|
||||
operator.ClearMeasurement,
|
||||
operator.ClearRecentIFCProjects,
|
||||
operator.CreateClippingPlane,
|
||||
operator.CreateProject,
|
||||
operator.DisableCulling,
|
||||
operator.DisableEditingHeader,
|
||||
operator.DisableEditingLink,
|
||||
operator.EditHeader,
|
||||
operator.EditLink,
|
||||
operator.EditProjectLibrary,
|
||||
operator.EnableCulling,
|
||||
operator.EnableEditingHeader,
|
||||
operator.EnableEditingLink,
|
||||
operator.ExportIFC,
|
||||
operator.FlipClippingPlane,
|
||||
operator.IFCFileHandlerOperator,
|
||||
operator.ImageScalingTool,
|
||||
operator.LinkIfc,
|
||||
operator.LoadBlendMetadataAndIFC,
|
||||
operator.LoadLink,
|
||||
operator.LoadLinkedProject,
|
||||
operator.LoadProject,
|
||||
operator.LoadProjectElements,
|
||||
operator.MeasureFaceAreaTool,
|
||||
operator.MeasureTool,
|
||||
operator.MeasureFaceAreaTool,
|
||||
operator.ClearMeasurement,
|
||||
operator.NewProject,
|
||||
operator.QueryLinkedElement,
|
||||
operator.RefreshClippingPlanes,
|
||||
@@ -73,6 +69,7 @@ classes = (
|
||||
operator.UnassignLibraryDeclaration,
|
||||
operator.UnlinkIfc,
|
||||
operator.UnloadLink,
|
||||
operator.LoadBlendMetadataAndIFC,
|
||||
workspace.ExploreHotkey,
|
||||
prop.LibraryBreadcrumb,
|
||||
prop.LibraryElement,
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import math
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
@@ -60,15 +59,6 @@ import bonsai.core.project as core
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim import export_ifc, import_ifc
|
||||
from bonsai.bim.ifc import IfcStore
|
||||
from bonsai.bim.ui import IFCFileSelector
|
||||
from bonsai.bim import import_ifc
|
||||
from bonsai.bim import export_ifc
|
||||
from math import radians, degrees
|
||||
from pathlib import Path
|
||||
from collections import defaultdict
|
||||
from mathutils import Vector, Matrix
|
||||
from bpy.app.handlers import persistent
|
||||
from ifcopenshell.geom import ShapeElementType
|
||||
from bonsai.bim.module.model.decorator import FaceAreaDecorator, PolylineDecorator
|
||||
from bonsai.bim.module.model.polyline import PolylineOperator
|
||||
from bonsai.bim.module.project.data import LinksData, ProjectLibraryData
|
||||
@@ -1331,7 +1321,7 @@ class ToggleFilterCategories(bpy.types.Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator):
|
||||
class LinkIfc(bpy.types.Operator, ImportHelper):
|
||||
bl_idname = "bim.link_ifc"
|
||||
bl_label = "Link IFC"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
@@ -1370,125 +1360,134 @@ class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator):
|
||||
row = self.layout.row()
|
||||
row.prop(pprops, "project_north")
|
||||
|
||||
def _execute(self, context):
|
||||
def execute(self, context):
|
||||
start = time.time()
|
||||
files = [f.name for f in self.files] if self.files else [self.filepath]
|
||||
|
||||
if not files or all(not f or not f.strip() for f in files):
|
||||
self.report({"ERROR"}, "No file selected")
|
||||
return {"CANCELLED"}
|
||||
|
||||
existing_links = tool.Project.get_linked_models_documents() if tool.Ifc.get() else {}
|
||||
for filename in files:
|
||||
if not filename or not filename.strip():
|
||||
continue
|
||||
filepath = Path(self.directory) / filename
|
||||
if bpy.data.filepath and filepath.samefile(bpy.data.filepath):
|
||||
self.report({"INFO"}, "Can't link the current .blend file")
|
||||
continue
|
||||
props = tool.Project.get_project_props()
|
||||
filepath = tool.Ifc.get_uri(filepath, use_relative_path=self.use_relative_path)
|
||||
|
||||
new = props.links.add()
|
||||
if tool.Ifc.get():
|
||||
if not (document := existing_links.get(filepath)):
|
||||
document = ifcopenshell.api.document.add_information(tool.Ifc.get())
|
||||
document.Name = Path(filepath).name
|
||||
document.Scope = "LINKED_MODEL"
|
||||
reference = ifcopenshell.api.document.add_reference(tool.Ifc.get(), information=document)
|
||||
reference[1] = ",".join([str(o) for o in np.eye(4).flatten().tolist()])
|
||||
reference.Location = filepath.replace("\\", "/")
|
||||
new.ifc_definition_id = reference.id()
|
||||
filepath = tool.Ifc.get_uri(filepath, use_relative_path=self.use_relative_path)
|
||||
new.name = filepath
|
||||
new.filepath = filepath
|
||||
bpy.ops.bim.load_link(link_index=-1, use_cache=self.use_cache)
|
||||
status = bpy.ops.bim.load_link(filepath=filepath, use_cache=self.use_cache)
|
||||
if status == {"CANCELLED"}:
|
||||
error_msg = (
|
||||
f'Error processing IFC file "{filepath}" '
|
||||
"was critical and blend file either wasn't saved or wasn't updated. "
|
||||
"See logs above in system console for details."
|
||||
)
|
||||
print(error_msg)
|
||||
self.report({"ERROR"}, error_msg)
|
||||
return {"FINISHED"}
|
||||
print(f"Finished linking {len(files)} IFCs", time.time() - start)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class UnlinkIfc(bpy.types.Operator, tool.Ifc.Operator):
|
||||
class UnlinkIfc(bpy.types.Operator):
|
||||
bl_idname = "bim.unlink_ifc"
|
||||
bl_label = "Unlink IFC"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
bl_description = "Remove the selected file from the link list"
|
||||
link_index: bpy.props.IntProperty(name="Link Index")
|
||||
filepath: bpy.props.StringProperty()
|
||||
|
||||
def _execute(self, context):
|
||||
def execute(self, context):
|
||||
filepath = Path(self.filepath).as_posix()
|
||||
bpy.ops.bim.unload_link(filepath=filepath)
|
||||
props = tool.Project.get_project_props()
|
||||
link = props.links[self.link_index]
|
||||
bpy.ops.bim.unload_link(link_index=self.link_index)
|
||||
if tool.Ifc.get():
|
||||
reference = tool.Ifc.get().by_id(link.ifc_definition_id)
|
||||
document = tool.Document.get_reference_document(reference)
|
||||
ifcopenshell.api.document.remove_reference(tool.Ifc.get(), reference)
|
||||
if document and not tool.Document.get_document_references(document):
|
||||
ifcopenshell.api.document.remove_information(tool.Ifc.get(), document)
|
||||
props.links.remove(self.link_index)
|
||||
index = props.links.find(filepath)
|
||||
if index != -1:
|
||||
props.links.remove(index)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class UnloadLink(bpy.types.Operator, tool.Ifc.Operator):
|
||||
class UnloadLink(bpy.types.Operator):
|
||||
bl_idname = "bim.unload_link"
|
||||
bl_label = "Unload Link"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
bl_description = "Unload the selected linked file"
|
||||
link_index: bpy.props.IntProperty(name="Link Index")
|
||||
filepath: bpy.props.StringProperty()
|
||||
|
||||
def _execute(self, context):
|
||||
link = tool.Project.get_project_props().links[self.link_index]
|
||||
if obj := tool.Project.get_link_empty_handle(link):
|
||||
collection = obj.instance_collection
|
||||
library = collection.library
|
||||
tool.Ifc.unlink(obj=obj)
|
||||
bpy.data.objects.remove(obj)
|
||||
if collection.users == 0:
|
||||
bpy.data.collections.remove(collection)
|
||||
if not len([c for c in bpy.data.collections if c.library == library]):
|
||||
def execute(self, context):
|
||||
filepath = tool.Blender.ensure_blender_path_is_abs(Path(self.filepath))
|
||||
if filepath.suffix.lower() == ".ifc":
|
||||
filepath = filepath.with_suffix(".ifc.cache.blend")
|
||||
|
||||
for library in list(bpy.data.libraries):
|
||||
if tool.Blender.ensure_blender_path_is_abs(Path(library.filepath)) == filepath:
|
||||
bpy.data.libraries.remove(library)
|
||||
|
||||
props = tool.Project.get_project_props()
|
||||
links = props.links
|
||||
link = links[self.filepath]
|
||||
# Let's assume that user might delete it.
|
||||
if empty_handle := link.empty_handle:
|
||||
bpy.data.objects.remove(empty_handle)
|
||||
|
||||
# following lines removes the library also when use_relative_path=True, otherwise it doesn't
|
||||
libraries = bpy.data.libraries
|
||||
for library in libraries:
|
||||
if library.name == self.filepath + ".cache.blend":
|
||||
bpy.data.libraries.remove(library)
|
||||
|
||||
link.is_loaded = False
|
||||
ProjectDecorator.uninstall()
|
||||
|
||||
if not any([l.is_loaded for l in links]):
|
||||
ProjectDecorator.uninstall()
|
||||
# we make sure we don't draw queried object from the file that was just unlinked
|
||||
elif queried_obj := props.queried_obj:
|
||||
queried_filepath = Path(queried_obj["ifc_filepath"])
|
||||
if queried_filepath == filepath:
|
||||
ProjectDecorator.uninstall()
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class LoadLink(bpy.types.Operator, tool.Ifc.Operator):
|
||||
class LoadLink(bpy.types.Operator):
|
||||
bl_idname = "bim.load_link"
|
||||
bl_label = "Load Link"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
bl_description = "Load the selected file"
|
||||
link_index: bpy.props.IntProperty(name="Link Index")
|
||||
filepath: bpy.props.StringProperty(name="Link Filepath")
|
||||
use_cache: bpy.props.BoolProperty(name="Use Cache", default=True)
|
||||
|
||||
def _execute(self, context):
|
||||
self.link = tool.Project.get_project_props().links[self.link_index]
|
||||
filepath = Path(tool.Ifc.resolve_uri(self.link.filepath))
|
||||
filepath_: Path
|
||||
|
||||
def execute(self, context):
|
||||
filepath = Path(tool.Ifc.resolve_uri(self.filepath))
|
||||
if not filepath.exists():
|
||||
self.report({"ERROR"}, f"File does not exist: '{filepath}'")
|
||||
return {"CANCELLED"}
|
||||
self.filepath_ = filepath
|
||||
if filepath.suffix.lower().endswith(".ifc"):
|
||||
return self.link_ifc()
|
||||
if filepath.suffix.lower().endswith(".blend"):
|
||||
self.link_blend(filepath)
|
||||
elif filepath.suffix.lower().endswith(".ifc"):
|
||||
status = self.link_ifc()
|
||||
if status:
|
||||
return status
|
||||
return {"FINISHED"}
|
||||
|
||||
def link_blend(self, filepath: Path) -> None:
|
||||
with bpy.data.libraries.load(str(filepath), link=True) as (data_from, data_to):
|
||||
data_to.collections = [c for c in data_from.collections if "IfcProject" in c]
|
||||
|
||||
# Find the linked collection
|
||||
for collection in bpy.data.collections:
|
||||
if not collection.library or Path(collection.library.filepath) != filepath:
|
||||
data_to.scenes = data_from.scenes
|
||||
link = tool.Project.get_project_props().links[self.filepath]
|
||||
for scene in bpy.data.scenes:
|
||||
if not scene.library or Path(scene.library.filepath) != filepath:
|
||||
continue
|
||||
# Create unique empty instance for this link
|
||||
empty_name = collection.name
|
||||
empty = bpy.data.objects.new(empty_name, None)
|
||||
empty.instance_type = "COLLECTION"
|
||||
empty.instance_collection = collection
|
||||
empty.matrix_world = Matrix(tool.Project.calculate_link_matrix(self.link))
|
||||
|
||||
tool.Project.set_link_empty_handle(self.link, empty)
|
||||
bpy.context.scene.collection.objects.link(empty)
|
||||
self.link.is_loaded = True
|
||||
if tool.Ifc.get(): # For non-IFC projects, locking has no meaning
|
||||
tool.Geometry.lock_object(empty)
|
||||
tool.Blender.select_and_activate_single_object(bpy.context, empty)
|
||||
for child in scene.collection.children:
|
||||
if "IfcProject" not in child.name:
|
||||
continue
|
||||
empty = bpy.data.objects.new(child.name, None)
|
||||
empty.instance_type = "COLLECTION"
|
||||
empty.instance_collection = child
|
||||
link.empty_handle = empty
|
||||
bpy.context.scene.collection.objects.link(empty)
|
||||
break
|
||||
break
|
||||
else:
|
||||
print(f"WARNING: No IfcProject collection found in {filepath}")
|
||||
self.link.is_loaded = False
|
||||
link.is_loaded = True
|
||||
tool.Blender.select_and_activate_single_object(bpy.context, empty)
|
||||
|
||||
def link_ifc(self) -> Union[set[str], None]:
|
||||
blend_filepath = self.filepath_.with_suffix(".ifc.cache.blend")
|
||||
@@ -1503,12 +1502,14 @@ class LoadLink(bpy.types.Operator, tool.Ifc.Operator):
|
||||
|
||||
code = f"""
|
||||
import bpy
|
||||
import sys
|
||||
|
||||
def run():
|
||||
import bonsai.tool as tool
|
||||
gprops = tool.Georeference.get_georeference_props()
|
||||
# Our model origin becomes their host model origin
|
||||
gprops.host_model_origin = "{gprops.model_origin}"
|
||||
gprops.host_model_origin_si = "{gprops.model_origin_si}"
|
||||
gprops.host_model_project_north = "{gprops.model_project_north}"
|
||||
gprops.has_blender_offset = {gprops.has_blender_offset}
|
||||
gprops.blender_offset_x = "{gprops.blender_offset_x}"
|
||||
gprops.blender_offset_y = "{gprops.blender_offset_y}"
|
||||
@@ -1521,12 +1522,7 @@ def run():
|
||||
pprops.false_origin = "{pprops.false_origin}"
|
||||
pprops.project_north = "{pprops.project_north}"
|
||||
# Use absolute path to be safe from cwd changes.
|
||||
try:
|
||||
bpy.ops.bim.load_linked_project(filepath=r"{str(self.filepath_)}")
|
||||
except RuntimeError as e:
|
||||
# Operator failed (returned CANCELLED with error report)
|
||||
print(f"Failed to load linked project: {{e}}")
|
||||
sys.exit(1)
|
||||
bpy.ops.bim.load_linked_project(filepath=r"{str(self.filepath_)}")
|
||||
# Use str instead of as_posix to avoid issues with Windows shared paths.
|
||||
bpy.ops.wm.save_as_mainfile(filepath=r"{str(blend_filepath)}")
|
||||
|
||||
@@ -1560,17 +1556,14 @@ except Exception as e:
|
||||
if not blend_filepath.exists() or blend_filepath.stat().st_mtime < t:
|
||||
return {"CANCELLED"}
|
||||
|
||||
self.set_model_origin_from_link()
|
||||
self.set_georeferencing_indicator()
|
||||
self.set_model_origin_from_link()
|
||||
|
||||
self.link_blend(blend_filepath)
|
||||
|
||||
def set_model_origin_from_link(self) -> None:
|
||||
if tool.Ifc.get():
|
||||
return # The current model's coordinates always take priority.
|
||||
|
||||
if len(tool.Project.get_project_props().links) > 1:
|
||||
return # Only the first link sets the origin
|
||||
|
||||
json_filepath = self.filepath_.with_suffix(".ifc.cache.json")
|
||||
if not json_filepath.exists():
|
||||
return
|
||||
@@ -1583,36 +1576,21 @@ except Exception as e:
|
||||
if (value := data.get(prop, None)) is not None:
|
||||
setattr(gprops, prop, value)
|
||||
|
||||
def set_georeferencing_indicator(self) -> None:
|
||||
if not tool.Ifc.get():
|
||||
self.link.georeferenced = "NONE"
|
||||
return
|
||||
if not (crs_name := (ifcopenshell.util.geolocation.get_crs(tool.Ifc.get()) or {}).get("Name", "")):
|
||||
self.link.georeferenced = "NONE"
|
||||
return
|
||||
reference = tool.Ifc.get().by_id(self.link.ifc_definition_id)
|
||||
json_filepath = Path(reference.Location).with_suffix(".ifc.cache.json")
|
||||
if not json_filepath.exists():
|
||||
self.link.georeferenced = "NONE"
|
||||
return
|
||||
with open(json_filepath, "r") as f:
|
||||
data = json.load(f)
|
||||
if not data["model_is_georeferenced"]:
|
||||
self.link.georeferenced = "NONE"
|
||||
else:
|
||||
self.link.georeferenced = "FULL_COMPATIBLE" if crs_name == data["model_crs"] else "NOT_COMPATIBLE"
|
||||
|
||||
|
||||
class ReloadLink(bpy.types.Operator):
|
||||
bl_idname = "bim.reload_link"
|
||||
bl_label = "Reload Link"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
bl_description = "Reload the selected file"
|
||||
link_index: bpy.props.IntProperty(name="Link Index")
|
||||
filepath: bpy.props.StringProperty()
|
||||
|
||||
def execute(self, context):
|
||||
bpy.ops.bim.unload_link(link_index=self.link_index)
|
||||
return bpy.ops.bim.load_link(link_index=self.link_index, use_cache=False) or {"FINISHED"}
|
||||
is_abs = os.path.isabs(Path(self.filepath))
|
||||
use_relative_path = not is_abs
|
||||
bpy.ops.bim.unlink_ifc(filepath=self.filepath)
|
||||
filepath = tool.Ifc.resolve_uri(self.filepath)
|
||||
status = bpy.ops.bim.link_ifc(filepath=filepath, use_cache=False, use_relative_path=use_relative_path)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class ToggleLinkSelectability(bpy.types.Operator):
|
||||
@@ -1620,18 +1598,16 @@ class ToggleLinkSelectability(bpy.types.Operator):
|
||||
bl_label = "Toggle Link Selectability"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
bl_description = "Toggle selectability"
|
||||
link_index: bpy.props.IntProperty(name="Link Index")
|
||||
link: bpy.props.StringProperty(name="Linked IFC Filepath")
|
||||
|
||||
def execute(self, context):
|
||||
props = tool.Project.get_project_props()
|
||||
link = props.links[self.link_index]
|
||||
self.library_filepath = tool.Blender.ensure_blender_path_is_abs(
|
||||
Path(link.filepath).with_suffix(".ifc.cache.blend")
|
||||
)
|
||||
link = props.links[self.link]
|
||||
self.library_filepath = tool.Blender.ensure_blender_path_is_abs(Path(self.link).with_suffix(".ifc.cache.blend"))
|
||||
link.is_selectable = (is_selectable := not link.is_selectable)
|
||||
for collection in self.get_linked_collections():
|
||||
collection.hide_select = not is_selectable
|
||||
if handle := tool.Project.get_link_empty_handle(link):
|
||||
if handle := link.empty_handle:
|
||||
handle.hide_select = not is_selectable
|
||||
return {"FINISHED"}
|
||||
|
||||
@@ -1648,15 +1624,13 @@ class ToggleLinkVisibility(bpy.types.Operator):
|
||||
bl_label = "Toggle Link Visibility"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
bl_description = "Toggle visibility between SOLID and WIREFRAME"
|
||||
link_index: bpy.props.IntProperty(name="Link Index")
|
||||
link: bpy.props.StringProperty(name="Linked IFC Filepath")
|
||||
mode: bpy.props.EnumProperty(name="Visibility Mode", items=((i, i, "") for i in ("WIREFRAME", "VISIBLE")))
|
||||
|
||||
def execute(self, context):
|
||||
props = tool.Project.get_project_props()
|
||||
link = props.links[self.link_index]
|
||||
self.library_filepath = tool.Blender.ensure_blender_path_is_abs(
|
||||
Path(link.filepath).with_suffix(".ifc.cache.blend")
|
||||
)
|
||||
link = props.links[self.link]
|
||||
self.library_filepath = tool.Blender.ensure_blender_path_is_abs(Path(self.link).with_suffix(".ifc.cache.blend"))
|
||||
if self.mode == "WIREFRAME":
|
||||
self.toggle_wireframe(link)
|
||||
elif self.mode == "VISIBLE":
|
||||
@@ -1664,16 +1638,12 @@ class ToggleLinkVisibility(bpy.types.Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
def toggle_wireframe(self, link: "Link") -> None:
|
||||
linked_collections = self.get_linked_collections()
|
||||
|
||||
link.is_wireframe = not link.is_wireframe
|
||||
display_type = "WIRE" if link.is_wireframe else "TEXTURED"
|
||||
for collection in linked_collections:
|
||||
for collection in self.get_linked_collections():
|
||||
objs = filter(lambda obj: "IfcOpeningElement" not in obj.name, collection.all_objects)
|
||||
for obj in objs:
|
||||
obj.display_type = display_type
|
||||
if handle := tool.Project.get_link_empty_handle(link):
|
||||
handle.display_type = display_type
|
||||
|
||||
def toggle_visibility(self, link: "Link") -> None:
|
||||
linked_collections = self.get_linked_collections()
|
||||
@@ -1682,7 +1652,7 @@ class ToggleLinkVisibility(bpy.types.Operator):
|
||||
layer_collections = tool.Blender.get_layer_collections_mapping(linked_collections)
|
||||
for layer_collection in layer_collections.values():
|
||||
layer_collection.exclude = is_hidden
|
||||
if handle := tool.Project.get_link_empty_handle(link):
|
||||
if handle := link.empty_handle:
|
||||
handle.hide_set(is_hidden)
|
||||
|
||||
def get_linked_collections(self) -> list[bpy.types.Collection]:
|
||||
@@ -1693,94 +1663,17 @@ class ToggleLinkVisibility(bpy.types.Operator):
|
||||
]
|
||||
|
||||
|
||||
class EnableEditingLink(bpy.types.Operator):
|
||||
bl_idname = "bim.enable_editing_link"
|
||||
bl_label = "Enable Editing Link"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
bl_description = "Enable editing link location"
|
||||
|
||||
def execute(self, context):
|
||||
link = tool.Project.get_project_props().active_link
|
||||
link.is_editing = True
|
||||
tool.Geometry.unlock_object(tool.Project.get_link_empty_handle(link))
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class DisableEditingLink(bpy.types.Operator):
|
||||
bl_idname = "bim.disable_editing_link"
|
||||
bl_label = "Disable Editing Link"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
bl_description = "Disable editing link and restore to previously saved location"
|
||||
|
||||
def execute(self, context):
|
||||
link = tool.Project.get_project_props().active_link
|
||||
link.is_editing = False
|
||||
obj = tool.Project.get_link_empty_handle(link)
|
||||
obj.matrix_world = Matrix(tool.Project.calculate_link_matrix(link))
|
||||
tool.Geometry.lock_object(obj)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class EditLink(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.edit_link"
|
||||
bl_label = "Edit Link"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
bl_description = "Disable editing link and restore to previously saved location"
|
||||
|
||||
def _execute(self, context):
|
||||
link = tool.Project.get_project_props().active_link
|
||||
link.is_editing = False
|
||||
obj = tool.Project.get_link_empty_handle(link)
|
||||
new_obj_matrix = obj.matrix_world
|
||||
|
||||
filepath = Path(tool.Ifc.resolve_uri(link.filepath))
|
||||
with open(filepath.with_suffix(".ifc.cache.json"), "r") as f:
|
||||
metadata = json.load(f)
|
||||
|
||||
rot = ifcopenshell.util.shape_builder.np_rotation_matrix(
|
||||
radians(-float(metadata["model_project_north"])), 4, "Z"
|
||||
)
|
||||
global_matrix = rot @ np.eye(4)
|
||||
global_matrix[:, 3][:3] = [float(o) for o in metadata["model_origin_si"].split(",")]
|
||||
|
||||
gprops = tool.Georeference.get_georeference_props()
|
||||
rot = ifcopenshell.util.shape_builder.np_rotation_matrix(radians(-float(gprops.model_project_north)), 4, "Z")
|
||||
local_matrix = rot @ np.eye(4)
|
||||
local_matrix[:, 3][:3] = [float(o) for o in gprops.model_origin_si.split(",")]
|
||||
|
||||
# obj_matrix is typically calculated as:
|
||||
# obj_matrix = np.linalg.inv(local_matrix) @ transformation @ global_matrix
|
||||
identity_blender_matrix = np.linalg.inv(local_matrix) @ global_matrix
|
||||
if np.allclose(np.array(new_obj_matrix), identity_blender_matrix, atol=1e-5):
|
||||
link.has_transformation = False
|
||||
transformation = ",".join(map(str, np.eye(4).reshape(-1)))
|
||||
else:
|
||||
transformed_global_matrix = local_matrix @ np.array(new_obj_matrix)
|
||||
transformation = transformed_global_matrix @ np.linalg.inv(global_matrix)
|
||||
link.has_transformation = True
|
||||
transformation = ",".join(map(str, transformation.reshape(-1)))
|
||||
|
||||
if tool.Ifc.get():
|
||||
reference = tool.Ifc.get().by_id(link.ifc_definition_id)
|
||||
reference[1] = transformation
|
||||
else:
|
||||
link.transformation = transformation
|
||||
|
||||
obj.matrix_world = Matrix(tool.Project.calculate_link_matrix(link))
|
||||
tool.Geometry.lock_object(obj)
|
||||
|
||||
|
||||
class SelectLinkHandle(bpy.types.Operator):
|
||||
bl_idname = "bim.select_link_handle"
|
||||
bl_label = "Select Link Handle"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
bl_description = "Select link empty object handle"
|
||||
link_index: bpy.props.IntProperty(name="Link Index")
|
||||
index: bpy.props.IntProperty(name="Link Index")
|
||||
|
||||
def execute(self, context):
|
||||
props = tool.Project.get_project_props()
|
||||
link = props.links[self.link_index]
|
||||
handle = tool.Project.get_link_empty_handle(link)
|
||||
link = props.links[self.index]
|
||||
handle = link.empty_handle
|
||||
if not handle:
|
||||
self.report({"ERROR"}, "Link has no empty handle (probably it was deleted).")
|
||||
return {"CANCELLED"}
|
||||
@@ -1973,14 +1866,7 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper):
|
||||
print("Processing", self.filepath)
|
||||
|
||||
self.collection = bpy.data.collections.new("IfcProject/" + os.path.basename(self.filepath))
|
||||
|
||||
try:
|
||||
self.file = ifcopenshell.open(self.filepath)
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, f"Failed to open IFC file: {str(e)}")
|
||||
bpy.data.collections.remove(self.collection)
|
||||
return {"CANCELLED"}
|
||||
|
||||
self.file = ifcopenshell.open(self.filepath)
|
||||
tool.Ifc.set(self.file)
|
||||
print("Finished opening")
|
||||
|
||||
@@ -2011,13 +1897,20 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper):
|
||||
if tool.Loader.settings.false_origin_mode == "MANUAL" and tool.Loader.settings.false_origin:
|
||||
tool.Loader.set_manual_blender_offset(self.file)
|
||||
elif tool.Loader.settings.false_origin_mode == "AUTOMATIC":
|
||||
tool.Loader.guess_false_origin(self.file)
|
||||
if host_model_origin_si := gprops.host_model_origin_si:
|
||||
host_model_origin_si = [float(o) / self.unit_scale for o in host_model_origin_si.split(",")]
|
||||
tool.Loader.settings.false_origin = host_model_origin_si
|
||||
tool.Loader.settings.project_north = float(gprops.host_model_project_north)
|
||||
tool.Loader.set_manual_blender_offset(self.file)
|
||||
else:
|
||||
tool.Loader.guess_false_origin(self.file)
|
||||
|
||||
tool.Georeference.set_model_origin()
|
||||
self.json_filepath = self.filepath + ".cache.json"
|
||||
data = {
|
||||
"model_is_georeferenced": gprops.model_is_georeferenced,
|
||||
"model_crs": gprops.model_crs,
|
||||
"host_model_origin": gprops.host_model_origin,
|
||||
"host_model_origin_si": gprops.host_model_origin_si,
|
||||
"host_model_project_north": gprops.host_model_project_north,
|
||||
"model_origin": gprops.model_origin,
|
||||
"model_origin_si": gprops.model_origin_si,
|
||||
"model_project_north": gprops.model_project_north,
|
||||
@@ -3248,9 +3141,29 @@ class ImageScalingTool(bpy.types.Operator, PolylineOperator):
|
||||
|
||||
bmesh.ops.scale(bm, vec=(scale_factor, scale_factor, 1.0), verts=bm.verts)
|
||||
|
||||
if bm.loops.layers.uv:
|
||||
uv_layer = bm.loops.layers.uv.active
|
||||
|
||||
min_x = min(v.co.x for v in bm.verts)
|
||||
max_x = max(v.co.x for v in bm.verts)
|
||||
min_y = min(v.co.y for v in bm.verts)
|
||||
max_y = max(v.co.y for v in bm.verts)
|
||||
|
||||
width = max_x - min_x
|
||||
height = max_y - min_y
|
||||
|
||||
for face in bm.faces:
|
||||
for loop in face.loops:
|
||||
vert = loop.vert
|
||||
u = (vert.co.x - min_x) / width if width > 0 else 0.5
|
||||
v = (vert.co.y - min_y) / height if height > 0 else 0.5
|
||||
|
||||
u = max(0.0, min(1.0, u))
|
||||
v = max(0.0, min(1.0, v))
|
||||
loop[uv_layer].uv = (u, v)
|
||||
|
||||
bm.to_mesh(mesh)
|
||||
bm.free()
|
||||
tool.Loader.load_generated_uv_map(mesh)
|
||||
mesh.update()
|
||||
|
||||
element = tool.Ifc.get_entity(self.target_object)
|
||||
|
||||
@@ -16,9 +16,8 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import math
|
||||
import os
|
||||
from collections.abc import Generator
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Literal, Union, assert_never, get_args
|
||||
|
||||
import bpy
|
||||
@@ -220,62 +219,29 @@ class FilterCategory(PropertyGroup):
|
||||
|
||||
|
||||
class Link(PropertyGroup):
|
||||
name: StringProperty(name="Name")
|
||||
filepath: StringProperty(
|
||||
name="Filepath",
|
||||
name: StringProperty(
|
||||
name="Name",
|
||||
description="Filepath to linked .ifc file, stored in posix format (could be relative to .ifc file, not to .blend)",
|
||||
)
|
||||
transformation: StringProperty(
|
||||
name="Transformation",
|
||||
description="4x4 matrix transformation as a flattened comma separated list for the linked model",
|
||||
default="",
|
||||
)
|
||||
georeferenced: EnumProperty(
|
||||
name="Georeferenced",
|
||||
description="Georeferencing status: compatibility between host and linked model",
|
||||
items=[
|
||||
("NONE", "No Georef", "Linked model has no georeferencing"),
|
||||
("NOT_COMPATIBLE", "Not Compatible", "Has geo data but CRS differ from host"),
|
||||
("FULL_COMPATIBLE", "Full Compatible", "Both CRS name and vertical datum match host"),
|
||||
],
|
||||
default="NONE",
|
||||
)
|
||||
has_transformation: BoolProperty(
|
||||
name="Has Transformation",
|
||||
description="Whether there is a transformation from its global coordinates",
|
||||
default=False,
|
||||
)
|
||||
is_loaded: BoolProperty(name="Is Loaded", default=False)
|
||||
is_editing: BoolProperty(name="Is Editing", description="Whether the link is being transformed", default=False)
|
||||
is_selectable: BoolProperty(name="Is Selectable", default=True)
|
||||
is_wireframe: BoolProperty(name="Is Wireframe", default=False)
|
||||
is_hidden: BoolProperty(name="Is Hidden", default=False)
|
||||
include_in_drawings: BoolProperty(name="Include in Drawings", default=True, options=set())
|
||||
empty_handle: PointerProperty(
|
||||
name="Empty Object Handle",
|
||||
description="Storage for empty handle. Used in non-IFC scenarios or temporarily during link creation",
|
||||
description="We use empty object handle to allow simple manipulations with a linked model (moving, scaling, rotating)",
|
||||
type=bpy.types.Object,
|
||||
)
|
||||
ifc_definition_id: IntProperty(
|
||||
name="IFC Definition ID",
|
||||
description="STEP ID of the IfcDocumentReference when linked to a parent IFC project. Zero when no parent IFC exists",
|
||||
default=0,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
name: str
|
||||
filepath: str
|
||||
transformation: str
|
||||
georeferenced: Literal["NONE", "NOT_COMPATIBLE", "FULL_COMPATIBLE"]
|
||||
has_transformation: bool
|
||||
is_loaded: bool
|
||||
is_editing: bool
|
||||
is_selectable: bool
|
||||
is_wireframe: bool
|
||||
is_hidden: bool
|
||||
include_in_drawings: bool
|
||||
empty_handle: Union[bpy.types.Object, None]
|
||||
ifc_definition_id: int
|
||||
|
||||
|
||||
class EditedObj(PropertyGroup):
|
||||
@@ -458,10 +424,6 @@ class BIMProjectProperties(PropertyGroup):
|
||||
clipping_planes_active_index: bpy.props.IntProperty(min=0, default=0, max=5)
|
||||
edited_objs: bpy.props.CollectionProperty(type=EditedObj)
|
||||
|
||||
@property
|
||||
def active_link(self) -> Union[Link, None]:
|
||||
return tool.Blender.get_active_uilist_element(self.links, self.active_link_index)
|
||||
|
||||
@property
|
||||
def active_clipping_plane(self) -> ObjProperty | None:
|
||||
return tool.Blender.get_active_uilist_element(self.clipping_planes, self.clipping_planes_active_index)
|
||||
|
||||
@@ -22,7 +22,6 @@ import os
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import bpy
|
||||
import math
|
||||
import ifcopenshell
|
||||
from bpy.types import Menu, Panel, UIList
|
||||
|
||||
@@ -31,7 +30,6 @@ import bonsai.tool as tool
|
||||
from bonsai.bim.helper import draw_attributes, prop_with_search
|
||||
from bonsai.bim.ifc import IfcStore
|
||||
from bonsai.bim.module.project.data import LinksData, ProjectData
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bonsai.bim.module.project.prop import (
|
||||
@@ -479,27 +477,17 @@ class BIM_PT_links(Panel):
|
||||
|
||||
def draw(self, context):
|
||||
self.props = tool.Project.get_project_props()
|
||||
|
||||
row = self.layout.row(align=True)
|
||||
row.operator("bim.link_ifc")
|
||||
if self.props.links:
|
||||
if self.props.active_link:
|
||||
row = self.layout.row(align=True)
|
||||
row.alignment = "RIGHT"
|
||||
index = self.props.active_link_index
|
||||
if self.props.active_link.is_loaded:
|
||||
if self.props.active_link.is_editing:
|
||||
row.operator("bim.edit_link", text="", icon="CHECKMARK")
|
||||
row.operator("bim.disable_editing_link", text="", icon="CANCEL")
|
||||
else:
|
||||
row.operator("bim.enable_editing_link", text="", icon="GREASEPENCIL")
|
||||
row.operator("bim.select_link_handle", text="", icon="OBJECT_DATA").link_index = index
|
||||
row.operator("bim.unload_link", text="", icon="UNLINKED").link_index = index
|
||||
row.operator("bim.reload_link", text="", icon="FILE_REFRESH").link_index = index
|
||||
else:
|
||||
row.operator("bim.load_link", text="", icon="LINKED").link_index = index
|
||||
row.operator("bim.unlink_ifc", text="", icon="X").link_index = index
|
||||
self.layout.template_list("BIM_UL_links", "", self.props, "links", self.props, "active_link_index")
|
||||
self.layout.template_list(
|
||||
"BIM_UL_links",
|
||||
"",
|
||||
self.props,
|
||||
"links",
|
||||
self.props,
|
||||
"active_link_index",
|
||||
)
|
||||
|
||||
if LinksData.enable_culling:
|
||||
row = self.layout.row(align=True)
|
||||
@@ -619,30 +607,47 @@ class BIM_UL_links(UIList):
|
||||
active_propname,
|
||||
index,
|
||||
):
|
||||
row = layout.row(align=True)
|
||||
if item.is_loaded:
|
||||
if item.georeferenced == "NONE":
|
||||
row.label(text="", icon="QUESTION")
|
||||
elif item.georeferenced == "NOT_COMPATIBLE":
|
||||
row.label(text="", icon="ERROR")
|
||||
elif item.georeferenced == "FULL_COMPATIBLE":
|
||||
row.label(text="", icon="WORLD")
|
||||
if item.has_transformation:
|
||||
row.label(text="", icon="OBJECT_ORIGIN")
|
||||
|
||||
row.label(text=item.filepath)
|
||||
icon = "RESTRICT_SELECT_OFF" if item.is_selectable else "RESTRICT_SELECT_ON"
|
||||
row.operator("bim.toggle_link_selectability", text="", icon=icon, emboss=False).link_index = index
|
||||
icon = "CUBE" if item.is_wireframe else "MESH_CUBE"
|
||||
op = row.operator("bim.toggle_link_visibility", text="", icon=icon, emboss=False)
|
||||
op.link_index = index
|
||||
op.mode = "WIREFRAME"
|
||||
icon = "HIDE_ON" if item.is_hidden else "HIDE_OFF"
|
||||
op = row.operator("bim.toggle_link_visibility", text="", icon=icon, emboss=False)
|
||||
op.link_index = index
|
||||
op.mode = "VISIBLE"
|
||||
else:
|
||||
row.label(text=item.filepath)
|
||||
if item:
|
||||
row = layout.row(align=True)
|
||||
if item.is_loaded:
|
||||
row.label(text=item.name)
|
||||
op = row.operator(
|
||||
"bim.toggle_link_selectability",
|
||||
text="",
|
||||
icon="RESTRICT_SELECT_OFF" if item.is_selectable else "RESTRICT_SELECT_ON",
|
||||
emboss=False,
|
||||
)
|
||||
op.link = item.name
|
||||
op = row.operator(
|
||||
"bim.toggle_link_visibility",
|
||||
text="",
|
||||
icon="CUBE" if item.is_wireframe else "MESH_CUBE",
|
||||
emboss=False,
|
||||
)
|
||||
op.link = item.name
|
||||
op.mode = "WIREFRAME"
|
||||
op = row.operator(
|
||||
"bim.toggle_link_visibility",
|
||||
text="",
|
||||
icon="HIDE_ON" if item.is_hidden else "HIDE_OFF",
|
||||
emboss=False,
|
||||
)
|
||||
op.link = item.name
|
||||
op.mode = "VISIBLE"
|
||||
op = row.operator("bim.select_link_handle", text="", icon="OBJECT_DATA")
|
||||
op.index = index
|
||||
op = row.operator("bim.unload_link", text="", icon="UNLINKED")
|
||||
op.filepath = item.name
|
||||
op = row.operator("bim.reload_link", text="", icon="FILE_REFRESH")
|
||||
op.filepath = item.name
|
||||
else:
|
||||
row.prop(item, "name", text="")
|
||||
op = row.operator("bim.select_uri_attribute", text="", icon="FILE_FOLDER")
|
||||
op.attribute_data_path = tool.Blender.get_full_data_path(item, "name")
|
||||
op = row.operator("bim.load_link", text="", icon="LINKED")
|
||||
op.filepath = item.name
|
||||
op = row.operator("bim.unlink_ifc", text="", icon="X")
|
||||
op.filepath = item.name
|
||||
|
||||
|
||||
class BIM_PT_purge(Panel):
|
||||
|
||||
@@ -87,7 +87,6 @@ class RemoveStyle(bpy.types.Operator, tool.Ifc.Operator):
|
||||
core.remove_style(tool.Ifc, tool.Style, style=tool.Ifc.get().by_id(self.style), reload_styles_ui=True)
|
||||
|
||||
|
||||
# TODO: remove completely
|
||||
class AddStyle(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.add_style"
|
||||
bl_label = "Add Style"
|
||||
|
||||
@@ -268,45 +268,63 @@ class SaveBlendMetadataFile(bpy.types.Operator):
|
||||
import bpy
|
||||
|
||||
# Ensure all styles are loaded before attempting to remove them
|
||||
bpy.ops.bim.load_styles()
|
||||
try:
|
||||
bpy.ops.bim.load_styles()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 1. Collect all IfcStyle material names
|
||||
ifcstyle_material_names = []
|
||||
styles_props = getattr(bpy.context.scene, "BIMStylesProperties", None)
|
||||
if styles_props is None and bpy.data.scenes:
|
||||
styles_props = getattr(bpy.data.scenes[0], "BIMStylesProperties", None)
|
||||
if styles_props:
|
||||
for style in list(styles_props.styles):
|
||||
material = getattr(style, "blender_material", None)
|
||||
if material and material.name:
|
||||
ifcstyle_material_names.append(material.name)
|
||||
try:
|
||||
styles_props = getattr(bpy.context.scene, "BIMStylesProperties", None)
|
||||
if styles_props is None and bpy.data.scenes:
|
||||
styles_props = getattr(bpy.data.scenes[0], "BIMStylesProperties", None)
|
||||
if styles_props:
|
||||
for style in list(styles_props.styles):
|
||||
material = getattr(style, "blender_material", None)
|
||||
if material and material.name:
|
||||
ifcstyle_material_names.append(material.name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2. Purge IfcStore
|
||||
from bonsai.bim.ifc import IfcStore
|
||||
IfcStore.purge()
|
||||
try:
|
||||
from bonsai.bim.ifc import IfcStore
|
||||
except ImportError:
|
||||
IfcStore = None
|
||||
|
||||
if IfcStore:
|
||||
try:
|
||||
IfcStore.purge()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3. Remove all collections named IfcProject*
|
||||
for collection in list(bpy.data.collections):
|
||||
if collection.name.startswith('IfcProject'):
|
||||
bpy.data.collections.remove(collection, do_unlink=True)
|
||||
try:
|
||||
bpy.data.collections.remove(collection, do_unlink=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 4.1 Remove all collections from linked libraries (they will be recreated by bonsai)
|
||||
for collection in list(bpy.data.collections):
|
||||
if collection.library:
|
||||
bpy.data.collections.remove(collection, do_unlink=True)
|
||||
# 4. Purge orphaned data blocks after removing IfcProject collections
|
||||
try:
|
||||
bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 4.2. Remove all empty objects that are collection instances for linked models
|
||||
for obj in list(bpy.data.objects):
|
||||
if obj.type == 'EMPTY' and obj.instance_type == 'COLLECTION' and obj.name.startswith('IfcProject/'):
|
||||
bpy.data.objects.remove(obj, do_unlink=True)
|
||||
|
||||
# 5. Purge orphaned data blocks after removing IfcProject collections
|
||||
bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True)
|
||||
|
||||
# 6. Remove all materials corresponding to the IfcStyles we collected
|
||||
for mat_name in ifcstyle_material_names:
|
||||
if mat_name in bpy.data.materials:
|
||||
bpy.data.materials.remove(bpy.data.materials[mat_name], do_unlink=True)
|
||||
# 5. Remove all materials corresponding to the IfcStyles we collected
|
||||
materials_removed = 0
|
||||
try:
|
||||
for mat_name in ifcstyle_material_names:
|
||||
if mat_name in bpy.data.materials:
|
||||
try:
|
||||
bpy.data.materials.remove(bpy.data.materials[mat_name], do_unlink=True)
|
||||
materials_removed += 1
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
bpy.ops.wm.save_as_mainfile(filepath=r'{blendmetadata_path}')
|
||||
"""
|
||||
|
||||
@@ -467,12 +467,12 @@ class DocPreferences(bpy.types.PropertyGroup):
|
||||
classes_to_wireframe: StringProperty(
|
||||
default="IfcVirtualElement",
|
||||
name="Classes to Wireframe",
|
||||
description="Upon import, these classes will display as wireframe.\nEx: IfcVirtualElement, IfcSpace",
|
||||
description="Upon import, these classes will display as wireframe.\nEx: IfcVirtualelement, IfcSpace",
|
||||
)
|
||||
classes_no_cut: StringProperty(
|
||||
default="IfcVirtualElement, IfcSpace",
|
||||
name="Classes that are not cut",
|
||||
description="The cut decorator will be turned off for these classes\nEx: IfcVirtualElement, IfcSpace",
|
||||
description="The cut decoractor will be turned off for these classes\nEx: IfcVirtualelement, IfcSpace",
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -660,10 +660,6 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
|
||||
bsdd_load_test_dictionaries: BoolProperty(
|
||||
name="Load Test Dictionaries", description="Load dictionaries that are for testing only", default=False
|
||||
)
|
||||
bsdd_baseurl: StringProperty(
|
||||
name="bSDD API Base URL", description="Base URL for data dictionary API requests, e.g. https://api.bsdd.buildingsmart.org/api/",
|
||||
default="https://api.bsdd.buildingsmart.org/api/",
|
||||
)
|
||||
should_disable_undo_on_save: BoolProperty(
|
||||
name="Disable Undo When Saving (Faster saves, no undo for you!)", default=False
|
||||
)
|
||||
@@ -976,7 +972,6 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
|
||||
layout.prop(self, "bsdd_load_preview_dictionaries")
|
||||
layout.prop(self, "bsdd_load_inactive_dictionaries")
|
||||
layout.prop(self, "bsdd_load_test_dictionaries")
|
||||
layout.prop(self, "bsdd_baseurl")
|
||||
|
||||
def draw_extras_settings(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
|
||||
layout.prop(self, "container_hide_show_isolate")
|
||||
|
||||
@@ -44,7 +44,6 @@ def edit_text(drawing: type[tool.Drawing], obj: bpy.types.Object) -> None:
|
||||
drawing.edit_text_wrap_length(obj, drawing.export_wrap_length(obj))
|
||||
drawing.edit_text_symbol(obj, drawing.export_symbol(obj))
|
||||
drawing.edit_text_literals(obj, literal_attributes)
|
||||
drawing.edit_text_alignment(obj, drawing.export_alignment(obj))
|
||||
drawing.disable_editing_text(obj)
|
||||
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ if TYPE_CHECKING:
|
||||
|
||||
def add_georeferencing(georeference: type[tool.Georeference]) -> None:
|
||||
georeference.add_georeferencing()
|
||||
georeference.set_model_origin()
|
||||
|
||||
|
||||
def enable_editing_georeferencing(georeference: type[tool.Georeference]) -> None:
|
||||
@@ -38,9 +37,8 @@ def enable_editing_georeferencing(georeference: type[tool.Georeference]) -> None
|
||||
georeference.enable_editing()
|
||||
|
||||
|
||||
def remove_georeferencing(ifc: type[tool.Ifc], georeference: type[tool.Georeference]) -> None:
|
||||
def remove_georeferencing(ifc: type[tool.Ifc]) -> None:
|
||||
ifc.run("georeference.remove_georeferencing")
|
||||
georeference.set_model_origin()
|
||||
|
||||
|
||||
def disable_editing_georeferencing(georeference: type[tool.Georeference]) -> None:
|
||||
|
||||
@@ -2165,13 +2165,3 @@ class Blender(bonsai.core.tool.Blender):
|
||||
if cls.BLENDER_5:
|
||||
return np.array(mathutils_type)
|
||||
return np.array(mathutils_type, dtype=np.float32)
|
||||
|
||||
@classmethod
|
||||
def get_selected_files(
|
||||
cls, directory: str, files: bpy.types.OperatorFileListElement, use_relative_path=False
|
||||
) -> list[Path]:
|
||||
return [
|
||||
tool.Ifc.get_uri(Path(directory) / f.name, use_relative_path=use_relative_path)
|
||||
for f in files
|
||||
if (Path(directory) / f.name).is_file()
|
||||
]
|
||||
|
||||
@@ -123,10 +123,6 @@ class Bsdd(bonsai.core.tool.Bsdd):
|
||||
@classmethod
|
||||
def get_dictionaries(cls) -> list[bsdd.DictionaryContractV1]:
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
baseurl = getattr(prefs, "bsdd_baseurl", "https://api.bsdd.buildingsmart.org/api/")
|
||||
cls.client = bsdd.Client()
|
||||
if hasattr(cls.client, "baseurl"):
|
||||
cls.client.baseurl = baseurl
|
||||
response = cls.client.get_dictionary(include_test_dictionaries=prefs.bsdd_load_test_dictionaries)
|
||||
dicts = response.get("dictionaries") or []
|
||||
statuses = ["Active"]
|
||||
|
||||
@@ -251,19 +251,11 @@ class Document(bonsai.core.tool.Document):
|
||||
def get_document_references(
|
||||
cls, document: ifcopenshell.entity_instance
|
||||
) -> tuple[ifcopenshell.entity_instance, ...]:
|
||||
# TODO: migrate to util.document and replace all instances
|
||||
"""Get IfcDocumentReference.ReferencedDocuments, compatible with IFC2X3."""
|
||||
if document.file.schema == "IFC2X3":
|
||||
return document.DocumentReferences or ()
|
||||
return document.HasDocumentReferences
|
||||
|
||||
@classmethod
|
||||
def get_reference_document(cls, reference: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance | None:
|
||||
# TODO: migrate to util.document and replace all instances
|
||||
if reference.file.schema == "IFC2X3":
|
||||
return (reference.ReferenceToDocument or (None))[0]
|
||||
return reference.ReferencedDocument
|
||||
|
||||
@classmethod
|
||||
def clear_active_document(cls) -> None:
|
||||
props = cls.get_document_props()
|
||||
|
||||
@@ -857,11 +857,9 @@ class Drawing(bonsai.core.tool.Drawing):
|
||||
def edit_text_literals(cls, obj: bpy.types.Object, literal_attributes: dict) -> None:
|
||||
assert (element := tool.Ifc.get_entity(obj))
|
||||
assert (rep := cls.get_annotation_representation(element))
|
||||
to_remove = [i for i in rep.Items if i.is_a("IfcTextLiteral")]
|
||||
new_literals = [cls.add_literal(**a) for a in literal_attributes]
|
||||
rep.Items = [i for i in rep.Items if not i.is_a("IfcTextLiteral")] + new_literals
|
||||
for literal in to_remove:
|
||||
for literal in cls.get_text_literal(obj, return_list=True):
|
||||
ifcopenshell.util.element.remove_deep2(tool.Ifc.get(), literal)
|
||||
rep.Items = [cls.add_literal(**a) for a in literal_attributes]
|
||||
|
||||
@classmethod
|
||||
def add_literal(cls, **attributes: str) -> ifcopenshell.entity_instance:
|
||||
@@ -1185,7 +1183,7 @@ class Drawing(bonsai.core.tool.Drawing):
|
||||
|
||||
ifc_literals = cls.get_text_literal(obj, return_list=True)
|
||||
assert isinstance(ifc_literals, list)
|
||||
for i, ifc_literal in enumerate(ifc_literals):
|
||||
for ifc_literal in ifc_literals:
|
||||
literal_props = props.literals.add()
|
||||
bonsai.bim.helper.import_attributes(ifc_literal, literal_props.attributes)
|
||||
|
||||
@@ -1196,16 +1194,6 @@ class Drawing(bonsai.core.tool.Drawing):
|
||||
literal_props.box_alignment = box_alignment_mask # pyright: ignore[reportAttributeAccessIssue]
|
||||
literal_props.ifc_definition_id = ifc_literal.id()
|
||||
|
||||
if i == 0:
|
||||
if position_string == "center":
|
||||
props.align_vertical = "middle"
|
||||
props.align_horizontal = "middle"
|
||||
else:
|
||||
parts = position_string.split("-")
|
||||
if len(parts) == 2:
|
||||
props.align_vertical = parts[0]
|
||||
props.align_horizontal = parts[1]
|
||||
|
||||
from bonsai.bim.module.drawing.data import DecoratorData
|
||||
|
||||
text_data = DecoratorData.get_text_data(obj)
|
||||
|
||||
@@ -315,21 +315,6 @@ class Georeference(bonsai.core.tool.Georeference):
|
||||
)
|
||||
return coordinates
|
||||
|
||||
@classmethod
|
||||
def global2local(cls, matrix, is_specified_in_map_units: bool) -> tuple[float, float, float]:
|
||||
matrix = ifcopenshell.util.geolocation.auto_global2local(tool.Ifc.get(), matrix, is_specified_in_map_units=is_specified_in_map_units)
|
||||
props = cls.get_georeference_props()
|
||||
if props.has_blender_offset:
|
||||
matrix = ifcopenshell.util.geolocation.global2local(
|
||||
matrix,
|
||||
float(props.blender_offset_x),
|
||||
float(props.blender_offset_y),
|
||||
float(props.blender_offset_z),
|
||||
float(props.blender_x_axis_abscissa),
|
||||
float(props.blender_x_axis_ordinate),
|
||||
)
|
||||
return matrix
|
||||
|
||||
@classmethod
|
||||
def import_plot(cls, filepath: str) -> None:
|
||||
import bmesh
|
||||
@@ -400,9 +385,6 @@ class Georeference(bonsai.core.tool.Georeference):
|
||||
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
||||
gprops = tool.Georeference.get_georeference_props()
|
||||
e, n, h = cls.xyz2enh((0, 0, 0), should_return_in_map_units=False)
|
||||
crs = ifcopenshell.util.geolocation.get_crs(tool.Ifc.get()) or {}
|
||||
gprops.model_is_georeferenced = bool(crs)
|
||||
gprops.model_crs = crs.get("Name", "") or ""
|
||||
gprops.model_origin = f"{e},{n},{h}"
|
||||
gprops.model_origin_si = f"{e * unit_scale},{n * unit_scale},{h * unit_scale}"
|
||||
angle = ifcopenshell.util.geolocation.get_grid_north(tool.Ifc.get())
|
||||
|
||||
@@ -179,8 +179,7 @@ class Loader(bonsai.core.tool.Loader):
|
||||
def surface_texture_to_dict(cls, surface_texture):
|
||||
if isinstance(surface_texture, dict):
|
||||
return surface_texture
|
||||
# IsMappedBy is an IFC4+ inverse attribute, not available in IFC2X3.
|
||||
mappings = getattr(surface_texture, "IsMappedBy", None) or []
|
||||
mappings = surface_texture.IsMappedBy or []
|
||||
surface_texture = surface_texture.get_info()
|
||||
uv_mode = None
|
||||
if mappings:
|
||||
@@ -189,7 +188,7 @@ class Loader(bonsai.core.tool.Loader):
|
||||
uv_mode = "Generated"
|
||||
elif coordinates.is_a("IfcTextureCoordinateGenerator") and coordinates.Mode == "COORD-EYE":
|
||||
uv_mode = "Camera"
|
||||
surface_texture["uv_mode"] = uv_mode or "Generated"
|
||||
surface_texture["uv_mode"] = uv_mode or "UV"
|
||||
return surface_texture
|
||||
|
||||
@classmethod
|
||||
@@ -287,9 +286,6 @@ class Loader(bonsai.core.tool.Loader):
|
||||
|
||||
for texture in textures:
|
||||
mode = texture.get("Mode", None)
|
||||
# IFC2X3 IfcImageTexture has no Mode attribute; default to DIFFUSE.
|
||||
if mode is None and texture["type"] == "IfcImageTexture":
|
||||
mode = "DIFFUSE"
|
||||
node = None
|
||||
|
||||
image_url = None
|
||||
@@ -297,8 +293,7 @@ class Loader(bonsai.core.tool.Loader):
|
||||
def get_image() -> Union[bpy.types.Image, None]:
|
||||
# TODO: orphaned textures after shader recreated?
|
||||
if texture["type"] == "IfcImageTexture":
|
||||
# IFC2X3 uses UrlReference, IFC4+ uses URLReference.
|
||||
original_image_url = texture.get("URLReference") or texture.get("UrlReference", "")
|
||||
original_image_url = texture["URLReference"]
|
||||
is_relative = not os.path.isabs(original_image_url)
|
||||
nonlocal image_url
|
||||
image_url = Path(original_image_url)
|
||||
@@ -544,33 +539,6 @@ class Loader(bonsai.core.tool.Loader):
|
||||
for colour in colours:
|
||||
cls.load_indexed_map(colour, mesh)
|
||||
|
||||
@classmethod
|
||||
def load_generated_uv_map(cls, mesh: bpy.types.Mesh) -> None:
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(mesh)
|
||||
uv_layer = bm.loops.layers.uv.active or bm.loops.layers.uv.new("UVMap")
|
||||
|
||||
all_verts = [v.co for v in bm.verts]
|
||||
if not all_verts:
|
||||
bm.free()
|
||||
return
|
||||
|
||||
min_x = min(v.x for v in all_verts)
|
||||
max_x = max(v.x for v in all_verts)
|
||||
min_y = min(v.y for v in all_verts)
|
||||
max_y = max(v.y for v in all_verts)
|
||||
width = max_x - min_x
|
||||
height = max_y - min_y
|
||||
|
||||
for face in bm.faces:
|
||||
for loop in face.loops:
|
||||
u = (loop.vert.co.x - min_x) / width if width > 0 else 0.5
|
||||
v = (loop.vert.co.y - min_y) / height if height > 0 else 0.5
|
||||
loop[uv_layer].uv = (max(0.0, min(1.0, u)), max(0.0, min(1.0, v)))
|
||||
|
||||
bm.to_mesh(mesh)
|
||||
bm.free()
|
||||
|
||||
@classmethod
|
||||
def load_indexed_map(cls, index_map: ifcopenshell.entity_instance, mesh: bpy.types.Mesh) -> None:
|
||||
"""Add data from index map as blender mesh attribute.
|
||||
|
||||
@@ -1244,7 +1244,18 @@ class Model(bonsai.core.tool.Model):
|
||||
|
||||
height = 100
|
||||
|
||||
is_horizontal = cls.get_usage_type(element) == "LAYER3"
|
||||
is_horizontal = False
|
||||
if element.is_a("IfcSlabType"):
|
||||
is_horizontal = True
|
||||
|
||||
parametric = ifcopenshell.util.element.get_psets(element).get("EPset_Parametric")
|
||||
if parametric:
|
||||
layer_set_direction = parametric.get("LayerSetDirection", None)
|
||||
if layer_set_direction == "AXIS2":
|
||||
is_horizontal = False
|
||||
elif layer_set_direction == "AXIS3":
|
||||
is_horizontal = True
|
||||
|
||||
if is_horizontal:
|
||||
width, height = height, width
|
||||
|
||||
@@ -1255,7 +1266,7 @@ class Model(bonsai.core.tool.Model):
|
||||
del thicknesses[-1]
|
||||
for thickness in thicknesses:
|
||||
current_thickness += thickness
|
||||
if is_horizontal:
|
||||
if element.is_a("IfcSlabType"):
|
||||
y = (current_thickness / total_thickness) * height
|
||||
line = [x_offset, y_offset + y, x_offset + width, y_offset + y]
|
||||
else:
|
||||
|
||||
@@ -191,8 +191,7 @@ class Polyline(bonsai.core.tool.Polyline):
|
||||
orientation_angle = 0
|
||||
if input_ui:
|
||||
if should_round:
|
||||
angle_snap = tool.Snap.get_angle_snap_value(context)
|
||||
angle = angle_snap * round(angle / angle_snap) if distance < angle_round_threshold else angle
|
||||
angle = 5 * round(angle / 5) if distance < angle_round_threshold else angle
|
||||
factor = tool.Snap.get_increment_snap_value(context)
|
||||
distance = factor * round(distance / factor)
|
||||
input_ui.set_value("X", mouse_vector.x)
|
||||
|
||||
@@ -19,14 +19,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import json
|
||||
import math
|
||||
import shutil
|
||||
import numpy as np
|
||||
from collections import defaultdict
|
||||
from math import radians
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, NamedTuple, Optional
|
||||
from typing import TYPE_CHECKING, NamedTuple, Optional, Union
|
||||
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
@@ -62,47 +58,6 @@ class Project(bonsai.core.tool.Project):
|
||||
assert (scene := bpy.context.scene)
|
||||
return scene.MeasureToolSettings # pyright: ignore[reportAttributeAccessIssue]
|
||||
|
||||
@classmethod
|
||||
def get_link_empty_handle(cls, link) -> bpy.types.Object | None:
|
||||
if tool.Ifc.get():
|
||||
return tool.Ifc.get_object(tool.Ifc.get().by_id(link.ifc_definition_id))
|
||||
return link.empty_handle
|
||||
|
||||
@classmethod
|
||||
def set_link_empty_handle(cls, link, empty: bpy.types.Object) -> None:
|
||||
if tool.Ifc.get():
|
||||
tool.Ifc.link(tool.Ifc.get().by_id(link.ifc_definition_id), empty)
|
||||
else:
|
||||
link.empty_handle = empty
|
||||
|
||||
@classmethod
|
||||
def calculate_link_matrix(cls, link) -> None:
|
||||
filepath = Path(tool.Ifc.resolve_uri(link.filepath))
|
||||
with open(filepath.with_suffix(".ifc.cache.json"), "r") as f:
|
||||
metadata = json.load(f)
|
||||
|
||||
rot = ifcopenshell.util.shape_builder.np_rotation_matrix(
|
||||
radians(-float(metadata["model_project_north"])), 4, "Z"
|
||||
)
|
||||
global_matrix = rot @ np.eye(4)
|
||||
global_matrix[:, 3][:3] = [float(o) for o in metadata["model_origin_si"].split(",")]
|
||||
|
||||
if tool.Ifc.get():
|
||||
transformation = tool.Ifc.get().by_id(link.ifc_definition_id)[1] # Identification
|
||||
else:
|
||||
transformation = link.transformation
|
||||
|
||||
if transformation:
|
||||
transformation = np.fromstring(transformation, sep=",", dtype=np.float64).reshape(4, 4)
|
||||
if not np.allclose(transformation, np.eye(4)):
|
||||
global_matrix = transformation @ global_matrix
|
||||
|
||||
gprops = tool.Georeference.get_georeference_props()
|
||||
rot = ifcopenshell.util.shape_builder.np_rotation_matrix(radians(-float(gprops.model_project_north)), 4, "Z")
|
||||
local_matrix = rot @ np.eye(4)
|
||||
local_matrix[:, 3][:3] = [float(o) for o in gprops.model_origin_si.split(",")]
|
||||
return np.linalg.inv(local_matrix) @ global_matrix
|
||||
|
||||
@classmethod
|
||||
def append_all_types_from_template(cls, template: str) -> None:
|
||||
# TODO refactor
|
||||
@@ -294,32 +249,68 @@ class Project(bonsai.core.tool.Project):
|
||||
tool.Root.reload_grid_decorator()
|
||||
|
||||
@classmethod
|
||||
def get_linked_models_documents(cls) -> dict[str, ifcopenshell.entity_instance]:
|
||||
linked_docs = {}
|
||||
for doc in tool.Ifc.get().by_type("IfcDocumentInformation"):
|
||||
if doc.Scope == "LINKED_MODEL":
|
||||
for reference in tool.Drawing.get_document_references(doc):
|
||||
linked_docs[Path(reference.Location).as_posix()] = doc
|
||||
break
|
||||
return linked_docs
|
||||
def get_linked_models_document(cls) -> Union[ifcopenshell.entity_instance, None]:
|
||||
for document in tool.Ifc.get().by_type("IfcDocumentInformation"):
|
||||
if document.Name == "BBIM_Linked_Models":
|
||||
return document
|
||||
|
||||
@classmethod
|
||||
def load_linked_models_from_ifc(cls) -> None:
|
||||
links = tool.Project.get_project_props().links
|
||||
links.clear()
|
||||
for doc in tool.Ifc.get().by_type("IfcDocumentInformation"):
|
||||
if doc.Scope != "LINKED_MODEL":
|
||||
continue
|
||||
for reference in tool.Drawing.get_document_references(doc):
|
||||
filepath = reference.Location
|
||||
link = links.add()
|
||||
link.name = filepath
|
||||
link.filepath = filepath
|
||||
link.ifc_definition_id = reference.id()
|
||||
link.has_transformation = False
|
||||
if reference[1]:
|
||||
m = np.fromstring(reference[1], sep=",", dtype=np.float64).reshape(4, 4)
|
||||
link.has_transformation = not np.allclose(m, np.eye(4))
|
||||
links_document = cls.get_linked_models_document()
|
||||
if not links_document:
|
||||
return
|
||||
|
||||
references = tool.Document.get_document_references(links_document)
|
||||
if not references:
|
||||
return
|
||||
|
||||
for reference in references:
|
||||
link = links.add()
|
||||
link.name = reference.Location
|
||||
|
||||
@classmethod
|
||||
def save_linked_models_to_ifc(cls) -> None:
|
||||
ifc_file = tool.Ifc.get()
|
||||
links = tool.Project.get_project_props().links
|
||||
filepaths: set[Path] = set()
|
||||
for link in links:
|
||||
filepaths.add(Path(link.name))
|
||||
|
||||
links_document = cls.get_linked_models_document()
|
||||
|
||||
if not filepaths and links_document is None:
|
||||
return
|
||||
|
||||
paths_to_add = filepaths.copy()
|
||||
references_to_remove: list[ifcopenshell.entity_instance] = []
|
||||
if links_document:
|
||||
references = tool.Document.get_document_references(links_document)
|
||||
for reference in references:
|
||||
# I guess got corrupted by the user.
|
||||
if not (location := reference.Location):
|
||||
references_to_remove.remove(reference)
|
||||
continue
|
||||
path = Path(location)
|
||||
if path in paths_to_add:
|
||||
paths_to_add.remove(path)
|
||||
else:
|
||||
references_to_remove.append(reference)
|
||||
|
||||
if paths_to_add:
|
||||
if links_document is None:
|
||||
links_document = ifcopenshell.api.document.add_information(ifc_file)
|
||||
links_document.Name = "BBIM_Linked_Models"
|
||||
links_document.Description = "Bonsai internal document containing references to currently linked models"
|
||||
|
||||
for path in paths_to_add:
|
||||
reference = ifcopenshell.api.document.add_reference(ifc_file, links_document)
|
||||
reference.Location = path.as_posix()
|
||||
|
||||
if references_to_remove:
|
||||
for reference in references_to_remove:
|
||||
ifcopenshell.api.document.remove_reference(ifc_file, reference)
|
||||
|
||||
@classmethod
|
||||
def get_project_library_elements(
|
||||
|
||||
@@ -114,15 +114,6 @@ class Snap(bonsai.core.tool.Snap):
|
||||
|
||||
return increment
|
||||
|
||||
@classmethod
|
||||
def get_angle_snap_value(cls, context: bpy.types.Context) -> float:
|
||||
"""Get the angle snap increment from Blender's tool settings.
|
||||
|
||||
:param context: Blender context
|
||||
:return: Angle snap increment in degrees
|
||||
"""
|
||||
return math.degrees(context.scene.tool_settings.snap_angle_increment_3d)
|
||||
|
||||
@classmethod
|
||||
def get_snap_points_on_raycasted_face(cls, context, event, obj, face_index):
|
||||
matrix = obj.matrix_world.copy()
|
||||
|
||||
@@ -34,7 +34,6 @@ from bonsai.bim.ifc import IfcStore
|
||||
|
||||
# Monkey-patch webbrowser opening since we want to test headlessly
|
||||
webbrowser.open = lambda x: True
|
||||
tool.Drawing.open_with_user_command = lambda x, y: True
|
||||
|
||||
|
||||
variables = {"cwd": os.getcwd(), "ifc": "IfcStore.get_file()"}
|
||||
|
||||
@@ -3,6 +3,12 @@ Feature: Drawing
|
||||
|
||||
Scenario: Duplicate drawing
|
||||
Given an empty IFC project
|
||||
And I add a cube
|
||||
And the object "Cube" is selected
|
||||
And I look at the "Class" panel
|
||||
And I set the "Products" property to "IfcElement"
|
||||
And I set the "Class" property to "IfcWall"
|
||||
And I click "Assign IFC Class"
|
||||
And I save IFC project
|
||||
And I look at the "Drawings" panel
|
||||
And I click "IMPORT"
|
||||
@@ -309,10 +315,3 @@ Scenario: Create sheet - with a drawing added to it
|
||||
And I click "IMAGE_PLANE"
|
||||
When I click "OUTPUT"
|
||||
Then the file "{ifc_dir}/sheets/A01 - UNTITLED.svg" should contain "IfcWall"
|
||||
|
||||
Scenario: Add reference image
|
||||
Given an empty IFC project
|
||||
And I save IFC project
|
||||
When I press "bim.add_reference_image(filepath='{cwd}/test/files/image.jpg')"
|
||||
Then the object "IfcAnnotation/image" exists
|
||||
And the object "IfcAnnotation/image" dimensions are "1.0,0.565,0."
|
||||
|
||||
@@ -677,43 +677,31 @@ Scenario: Load project elements - all georeferencing coordinate situations with
|
||||
And the object "IfcActuator/J" has a vertex at "10.366,3.813,-1"
|
||||
And the object "IfcActuator/J" has a vertex at "12.298,4.331,-1"
|
||||
|
||||
Scenario: Link IFC - from an empty IFC project
|
||||
Scenario: Link IFC
|
||||
Given an empty IFC project
|
||||
When I press "bim.link_ifc(filepath='{cwd}/test/files/basic.ifc')"
|
||||
When I link IFC project from "{cwd}/test/files/basic.ifc"
|
||||
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_loaded" is "True"
|
||||
And the collection "IfcProject/basic.ifc" exists
|
||||
And the object "Chunk" exists
|
||||
And the object "Chunk" is placed in the collection "IfcProject/basic.ifc"
|
||||
|
||||
Scenario: Link IFC - disabled false origin mode
|
||||
Given an empty IFC project
|
||||
# Not currently possible via UI
|
||||
And I set "scene.BIMProjectProperties.distance_limit" to "5"
|
||||
And I set "scene.BIMProjectProperties.false_origin_mode" to "DISABLED"
|
||||
When I link IFC project from "{cwd}/test/files/geolocation.ifc"
|
||||
Then the object "Chunk" exists
|
||||
And the object "Chunk" has a vertex at "2,2,-1"
|
||||
And the object "Chunk" has a vertex at "9,-1,-1"
|
||||
And the object "Chunk" has a vertex at "17,4,-1"
|
||||
|
||||
Scenario: Link IFC - from an empty IFC project - automatic false origin mode (0,0,0 will be the false origin)
|
||||
Given an empty IFC project
|
||||
# Not currently possible via UI
|
||||
And I set "scene.BIMProjectProperties.distance_limit" to "5"
|
||||
And I set "scene.BIMProjectProperties.false_origin_mode" to "AUTOMATIC"
|
||||
When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation.ifc', use_cache=False)"
|
||||
Then the object "Chunk" exists
|
||||
And the object "Chunk" has a vertex at "2,2,-1"
|
||||
And the object "Chunk" has a vertex at "9,-1,-1"
|
||||
And the object "Chunk" has a vertex at "17,4,-1"
|
||||
|
||||
Scenario: Link IFC - from an empty IFC project - manual false origin mode
|
||||
Given an empty IFC project
|
||||
# Not currently possible via UI
|
||||
And I set "scene.BIMProjectProperties.distance_limit" to "5"
|
||||
And I set "scene.BIMProjectProperties.false_origin_mode" to "MANUAL"
|
||||
And I set "scene.BIMProjectProperties.false_origin" to "10000,0,0"
|
||||
When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation.ifc', use_cache=False)"
|
||||
Then the object "Chunk" exists
|
||||
And the object "Chunk" has a vertex at "2,2,-1"
|
||||
And the object "Chunk" has a vertex at "9,-1,-1"
|
||||
And the object "Chunk" has a vertex at "17,4,-1"
|
||||
|
||||
Scenario: Link IFC - from an empty IFC project - disabled false origin mode
|
||||
Given an empty IFC project
|
||||
# Not currently possible via UI
|
||||
And I set "scene.BIMProjectProperties.distance_limit" to "5"
|
||||
And I set "scene.BIMProjectProperties.false_origin_mode" to "DISABLED"
|
||||
When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation.ifc', use_cache=False)"
|
||||
When I link IFC project from "{cwd}/test/files/geolocation.ifc"
|
||||
Then the object "Chunk" exists
|
||||
And the object "Chunk" has a vertex at "2,2,-1"
|
||||
And the object "Chunk" has a vertex at "9,-1,-1"
|
||||
@@ -724,42 +712,31 @@ Scenario: Link IFC - from an empty Blender session - automatic false origin mode
|
||||
# Not currently possible via UI
|
||||
And I set "scene.BIMProjectProperties.distance_limit" to "5"
|
||||
And I set "scene.BIMProjectProperties.false_origin_mode" to "AUTOMATIC"
|
||||
When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation.ifc', use_cache=False)"
|
||||
When I link IFC project from "{cwd}/test/files/geolocation.ifc"
|
||||
Then the object "Chunk" exists
|
||||
And the object "Chunk" has a vertex at "-11,-2,0"
|
||||
And the object "Chunk" has a vertex at "-4,-5,0"
|
||||
And the object "Chunk" has a vertex at "4,0,0"
|
||||
|
||||
Scenario: Link IFC - from an empty Blender session - manual false origin mode
|
||||
Scenario: Link IFC - manual false origin mode
|
||||
Given an empty Blender session
|
||||
# Not currently possible via UI
|
||||
And I set "scene.BIMProjectProperties.distance_limit" to "5"
|
||||
And I set "scene.BIMProjectProperties.false_origin_mode" to "MANUAL"
|
||||
And I set "scene.BIMProjectProperties.false_origin" to "10000,0,0"
|
||||
When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation.ifc', use_cache=False)"
|
||||
When I link IFC project from "{cwd}/test/files/geolocation.ifc"
|
||||
Then the object "Chunk" exists
|
||||
And the object "Chunk" has a vertex at "-8,2,-1"
|
||||
And the object "Chunk" has a vertex at "-1,-1,-1"
|
||||
And the object "Chunk" has a vertex at "7,4,-1"
|
||||
|
||||
Scenario: Link IFC - from an empty Blender session - disabled false origin mode
|
||||
Given an empty Blender session
|
||||
# Not currently possible via UI
|
||||
And I set "scene.BIMProjectProperties.distance_limit" to "5"
|
||||
And I set "scene.BIMProjectProperties.false_origin_mode" to "DISABLED"
|
||||
When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation.ifc', use_cache=False)"
|
||||
Then the object "Chunk" exists
|
||||
And the object "Chunk" has a vertex at "2,2,-1"
|
||||
And the object "Chunk" has a vertex at "9,-1,-1"
|
||||
And the object "Chunk" has a vertex at "17,4,-1"
|
||||
|
||||
Scenario: Link IFC - from an empty Blender session - automatic false origin mode - two different false origins and project norths - grid north is up because we start with geolocation.ifc
|
||||
Scenario: Link IFC - automatic false origin mode - two different false origins and project norths - grid north is up because we start with geolocation.ifc
|
||||
Given an empty Blender session
|
||||
# Not currently possible via UI
|
||||
And I set "scene.BIMProjectProperties.distance_limit" to "5"
|
||||
And I set "scene.BIMProjectProperties.false_origin_mode" to "AUTOMATIC"
|
||||
When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation.ifc', use_cache=False)"
|
||||
And I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation-mapconversion-angle.ifc', use_cache=False)"
|
||||
When I link IFC project from "{cwd}/test/files/geolocation.ifc"
|
||||
And I link IFC project from "{cwd}/test/files/geolocation-mapconversion-angle.ifc"
|
||||
Then the object "Col:IfcProject/geolocation.ifc:Chunk" exists
|
||||
And the object "Col:IfcProject/geolocation-mapconversion-angle.ifc:Chunk" exists
|
||||
And the object "Col:IfcProject/geolocation.ifc:Chunk" has a vertex at "-11,-2,0"
|
||||
@@ -769,13 +746,13 @@ Scenario: Link IFC - from an empty Blender session - automatic false origin mode
|
||||
And the object "Col:IfcProject/geolocation-mapconversion-angle.ifc:Chunk" has a vertex at "9.294,-9.366,0"
|
||||
And the object "Col:IfcProject/geolocation-mapconversion-angle.ifc:Chunk" has a vertex at "18.722,-9.036,0"
|
||||
|
||||
Scenario: Link IFC - from an empty Blender session - automatic false origin mode - two different false origins and project norths - project north is up because we start with geolocation-mapconversion-angle.ifc
|
||||
Scenario: Link IFC - automatic false origin mode - two different false origins and project norths - project north is up because we start with geolocation-mapconversion-angle.ifc
|
||||
Given an empty Blender session
|
||||
# Not currently possible via UI
|
||||
And I set "scene.BIMProjectProperties.distance_limit" to "5"
|
||||
And I set "scene.BIMProjectProperties.false_origin_mode" to "AUTOMATIC"
|
||||
When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation-mapconversion-angle.ifc', use_cache=False)"
|
||||
And I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation.ifc', use_cache=False)"
|
||||
When I link IFC project from "{cwd}/test/files/geolocation-mapconversion-angle.ifc"
|
||||
And I link IFC project from "{cwd}/test/files/geolocation.ifc"
|
||||
Then the object "Col:IfcProject/geolocation.ifc:Chunk" exists
|
||||
And the object "Col:IfcProject/geolocation-mapconversion-angle.ifc:Chunk" exists
|
||||
And the object "Col:IfcProject/geolocation-mapconversion-angle.ifc:Chunk" has a vertex at "-11,-2,0"
|
||||
@@ -785,14 +762,14 @@ Scenario: Link IFC - from an empty Blender session - automatic false origin mode
|
||||
And the object "Col:IfcProject/geolocation.ifc:Chunk" has a vertex at "-17.696,-7.866,0"
|
||||
And the object "Col:IfcProject/geolocation.ifc:Chunk" has a vertex at "-13.268,0.464,0"
|
||||
|
||||
Scenario: Link IFC - from an empty Blender session - automatic false origin mode - three identical false origins but different project and map units
|
||||
Scenario: Link IFC - automatic false origin mode - three identical false origins but different project and map units
|
||||
Given an empty Blender session
|
||||
# Not currently possible via UI
|
||||
And I set "scene.BIMProjectProperties.distance_limit" to "5"
|
||||
And I set "scene.BIMProjectProperties.false_origin_mode" to "AUTOMATIC"
|
||||
When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation-unit1.ifc', use_cache=False)"
|
||||
And I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation-unit2.ifc', use_cache=False)"
|
||||
And I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation-unit3.ifc', use_cache=False)"
|
||||
When I link IFC project from "{cwd}/test/files/geolocation-unit1.ifc"
|
||||
And I link IFC project from "{cwd}/test/files/geolocation-unit2.ifc"
|
||||
And I link IFC project from "{cwd}/test/files/geolocation-unit3.ifc"
|
||||
Then the object "Col:IfcProject/geolocation-unit1.ifc:Chunk" exists
|
||||
And the object "Col:IfcProject/geolocation-unit2.ifc:Chunk" exists
|
||||
And the object "Col:IfcProject/geolocation-unit3.ifc:Chunk" exists
|
||||
@@ -802,54 +779,54 @@ Scenario: Link IFC - from an empty Blender session - automatic false origin mode
|
||||
|
||||
Scenario: Toggle link visibility - wireframe mode
|
||||
Given an empty IFC project
|
||||
And I press "bim.link_ifc(filepath='{cwd}/test/files/basic.ifc')"
|
||||
When I press "bim.toggle_link_visibility(link_index=0, mode='WIREFRAME')"
|
||||
And I link IFC project from "{cwd}/test/files/basic.ifc"
|
||||
When I press "bim.toggle_link_visibility(link='{cwd}/test/files/basic.ifc', mode='WIREFRAME')"
|
||||
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_wireframe" is "True"
|
||||
And the object "Chunk" should display as "WIRE"
|
||||
When I press "bim.toggle_link_visibility(link_index=0, mode='WIREFRAME')"
|
||||
When I press "bim.toggle_link_visibility(link='{cwd}/test/files/basic.ifc', mode='WIREFRAME')"
|
||||
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_wireframe" is "False"
|
||||
And the object "Chunk" should display as "TEXTURED"
|
||||
|
||||
Scenario: Toggle link selectability
|
||||
Given an empty IFC project
|
||||
And I press "bim.link_ifc(filepath='{cwd}/test/files/basic.ifc')"
|
||||
When I press "bim.toggle_link_selectability(link_index=0)"
|
||||
And I link IFC project from "{cwd}/test/files/basic.ifc"
|
||||
When I press "bim.toggle_link_selectability(link='{cwd}/test/files/basic.ifc')"
|
||||
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_selectable" is "False"
|
||||
And the collection "IfcProject/basic.ifc" is unselectable
|
||||
When I press "bim.toggle_link_selectability(link_index=0)"
|
||||
When I press "bim.toggle_link_selectability(link='{cwd}/test/files/basic.ifc')"
|
||||
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_selectable" is "True"
|
||||
And the collection "IfcProject/basic.ifc" is selectable
|
||||
|
||||
Scenario: Toggle link visibility - visible mode
|
||||
Given an empty IFC project
|
||||
And I press "bim.link_ifc(filepath='{cwd}/test/files/basic.ifc')"
|
||||
When I press "bim.toggle_link_visibility(link_index=0, mode='VISIBLE')"
|
||||
And I link IFC project from "{cwd}/test/files/basic.ifc"
|
||||
When I press "bim.toggle_link_visibility(link='{cwd}/test/files/basic.ifc', mode='VISIBLE')"
|
||||
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_hidden" is "True"
|
||||
And the object "IfcProject/basic.ifc" is not visible
|
||||
When I press "bim.toggle_link_visibility(link_index=0, mode='VISIBLE')"
|
||||
When I press "bim.toggle_link_visibility(link='{cwd}/test/files/basic.ifc', mode='VISIBLE')"
|
||||
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_hidden" is "False"
|
||||
And the object "IfcProject/basic.ifc" is visible
|
||||
|
||||
Scenario: Unload link
|
||||
Given an empty Blender session
|
||||
And I press "bim.link_ifc(filepath='{cwd}/test/files/basic.ifc')"
|
||||
When I press "bim.unload_link(link_index=0)"
|
||||
And I link IFC project from "{cwd}/test/files/basic.ifc"
|
||||
When I press "bim.unload_link(filepath='{cwd}/test/files/basic.ifc')"
|
||||
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_loaded" is "False"
|
||||
And the collection "IfcProject/basic.ifc" does not exist
|
||||
|
||||
Scenario: Load link
|
||||
Given an empty Blender session
|
||||
And I press "bim.link_ifc(filepath='{cwd}/test/files/basic.ifc')"
|
||||
And I press "bim.unload_link(link_index=0)"
|
||||
When I press "bim.load_link(link_index=0)"
|
||||
And I link IFC project from "{cwd}/test/files/basic.ifc"
|
||||
And I press "bim.unload_link(filepath='{cwd}/test/files/basic.ifc')"
|
||||
When I press "bim.load_link(filepath='{cwd}/test/files/basic.ifc')"
|
||||
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_loaded" is "True"
|
||||
And the object "IfcProject/basic.ifc" exists
|
||||
|
||||
Scenario: Unlink IFC
|
||||
Given an empty Blender session
|
||||
And I press "bim.link_ifc(filepath='{cwd}/test/files/basic.ifc')"
|
||||
And I press "bim.unload_link(link_index=0)"
|
||||
When I press "bim.unlink_ifc(link_index=0)"
|
||||
And I link IFC project from "{cwd}/test/files/basic.ifc"
|
||||
And I press "bim.unload_link(filepath='{cwd}/test/files/basic.ifc')"
|
||||
When I press "bim.unlink_ifc(filepath='{cwd}/test/files/basic.ifc')"
|
||||
Then "scene.BIMProjectProperties.links.get('{cwd}/test/files/basic.ifc')" is "None"
|
||||
And "scene.collection.children.get('IfcProject/basic.ifc')" is "None"
|
||||
And the object "Chunk" does not exist
|
||||
|
||||
@@ -70,7 +70,10 @@ Can be useful for debugging, but has caveats - can't use ``wm.read_homefile``
|
||||
as resets the ``bpy.context`` and some it's members become `None`.
|
||||
"""
|
||||
|
||||
TMP = Path(f"{variables['cwd']}/test/files/temp")
|
||||
TMP = Path.cwd() / "test/files/temp"
|
||||
TEST_FILES_DIR = Path.cwd() / "test/files"
|
||||
|
||||
CLEAN_LINKED_FILES_CACHE = False
|
||||
|
||||
EPSET_DRAWING = Path.cwd() / "bonsai/bim/data/pset/EPset_Drawing.ifc"
|
||||
EPSET_DRAWING_BYTES = EPSET_DRAWING.read_bytes()
|
||||
@@ -280,8 +283,6 @@ def create_ui_name_cache():
|
||||
try:
|
||||
panel_type = getattr(bpy.types, bl_idname)
|
||||
if panel_type.bl_rna.base.name == "Panel":
|
||||
if "_tab_" in panel_type.bl_idname:
|
||||
continue # Tab panels are just groups and not relevant in testing
|
||||
ui_name_cache[panel_type.bl_label] = panel_type.bl_idname
|
||||
elif panel_type.bl_rna.base.name == "Operator":
|
||||
ui_name_cache[panel_type.bl_label] = bl_idname
|
||||
@@ -310,6 +311,25 @@ def vectors_are_equal(v1, v2):
|
||||
return all(is_x(v1[i], v2[i]) for i in range(len(v1)))
|
||||
|
||||
|
||||
@pytest.fixture(scope="function", autouse=True)
|
||||
def run_for_each_test() -> Generator[None]:
|
||||
# Code before this runs before each test
|
||||
yield
|
||||
# Code after this runs after each test
|
||||
|
||||
global CLEAN_LINKED_FILES_CACHE
|
||||
if CLEAN_LINKED_FILES_CACHE:
|
||||
for filepath in TEST_FILES_DIR.glob("*.ifc.cache.*"):
|
||||
filepath.unlink()
|
||||
CLEAN_LINKED_FILES_CACHE = False
|
||||
|
||||
# pset_template tests are editing EPset_Drawing.ifc, so we need to restore it.
|
||||
global RELOAD_EPSET_DRAWING
|
||||
if RELOAD_EPSET_DRAWING:
|
||||
EPSET_DRAWING.write_bytes(EPSET_DRAWING_BYTES)
|
||||
RELOAD_EPSET_DRAWING = False
|
||||
|
||||
|
||||
@given("an untestable scenario")
|
||||
def an_untestable_scenario():
|
||||
pass
|
||||
@@ -364,6 +384,16 @@ def saving_ifc_project() -> None:
|
||||
tool.Project.save_test_project()
|
||||
|
||||
|
||||
@given(parsers.parse('I link IFC project from "{filepath}"'))
|
||||
@when(parsers.parse('I link IFC project from "{filepath}"'))
|
||||
@then(parsers.parse('I link IFC project from "{filepath}"'))
|
||||
def i_link_ifc_project_from_filepath(filepath: str) -> None:
|
||||
global CLEAN_LINKED_FILES_CACHE
|
||||
filepath = replace_variables(filepath)
|
||||
CLEAN_LINKED_FILES_CACHE = True
|
||||
bpy.ops.bim.link_ifc(filepath=filepath, use_cache=False)
|
||||
|
||||
|
||||
@given("the Brickschema is stubbed")
|
||||
def the_brickschema_is_stubbed():
|
||||
# This makes things run faster since we don't need to load the entire brick schema
|
||||
@@ -1557,19 +1587,10 @@ def the_object_name_has_a_vertex_at_location(name, location):
|
||||
is_pass = False
|
||||
target = Vector([float(co) for co in location.split(",")])
|
||||
verts = []
|
||||
depsgraph = bpy.context.evaluated_depsgraph_get()
|
||||
obj_eval = obj.evaluated_get(depsgraph)
|
||||
mesh = obj_eval.to_mesh(preserve_all_data_layers=False, depsgraph=depsgraph)
|
||||
try:
|
||||
for inst in depsgraph.object_instances:
|
||||
if inst.object.original is obj:
|
||||
mw = inst.matrix_world
|
||||
for i, v in enumerate(mesh.vertices):
|
||||
verts.append(mw @ v.co)
|
||||
if (verts[-1] - target).length < 0.001:
|
||||
is_pass = True
|
||||
finally:
|
||||
obj_eval.to_mesh_clear()
|
||||
for v in obj.data.vertices:
|
||||
verts.append(obj.matrix_world @ v.co)
|
||||
if (verts[-1] - target).length < 0.001:
|
||||
is_pass = True
|
||||
assert is_pass, f"No verts found at {location}: {verts}"
|
||||
|
||||
|
||||
|
||||
@@ -24,10 +24,8 @@ import pytest
|
||||
|
||||
import bonsai.core.tool
|
||||
import bonsai.tool as tool
|
||||
import tempfile
|
||||
from bonsai.tool.blender import Blender as subject
|
||||
from test.bim.bootstrap import NewFile
|
||||
from pathlib import Path
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import bpy.stub_internal.rna_enums as rna_enums
|
||||
@@ -112,35 +110,3 @@ class TestBlenderErrorMessageExtraction(NewFile):
|
||||
assert error_reports == []
|
||||
|
||||
bpy.utils.unregister_class(OBJECT_OT_test_fail_operator)
|
||||
|
||||
|
||||
class TestGetSelectedFiles(NewFile):
|
||||
def test_get_a_single_file(self) -> None:
|
||||
with tempfile.NamedTemporaryFile() as f:
|
||||
file = type("", (object,), {"name": f.name})()
|
||||
assert subject.get_selected_files(Path(f.name).parent, [file]) == [f.name]
|
||||
|
||||
def test_get_multiple_files(self) -> None:
|
||||
with tempfile.NamedTemporaryFile() as f:
|
||||
with tempfile.NamedTemporaryFile() as g:
|
||||
file = type("", (object,), {"name": f.name})()
|
||||
file2 = type("", (object,), {"name": g.name})()
|
||||
assert subject.get_selected_files(Path(f.name).parent, [file, file2]) == [f.name, g.name]
|
||||
|
||||
def test_exclude_directories(self) -> None:
|
||||
with tempfile.NamedTemporaryFile() as f:
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
file = type("", (object,), {"name": f.name})()
|
||||
directory = type("", (object,), {"name": d})()
|
||||
assert subject.get_selected_files(Path(f.name).parent, [file, directory]) == [f.name]
|
||||
|
||||
def test_get_relative_paths(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
base_path = Path(tmp_dir)
|
||||
with tempfile.NamedTemporaryFile(dir=tmp_dir, suffix=".ifc") as f:
|
||||
tool.Ifc.set_path(str(f.name))
|
||||
with tempfile.NamedTemporaryFile(dir=tmp_dir) as g:
|
||||
file = type("", (object,), {"name": g.name})
|
||||
assert subject.get_selected_files(Path(g.name).parent, [file], use_relative_path=True) == [
|
||||
Path(g.name).name
|
||||
]
|
||||
|
||||
@@ -21,7 +21,6 @@ import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.drawing
|
||||
import ifcopenshell.api.group
|
||||
@@ -32,7 +31,6 @@ import ifcopenshell.util.element
|
||||
import mathutils
|
||||
import numpy as np
|
||||
from mathutils import Vector
|
||||
from ifcopenshell.util.shape_builder import ShapeBuilder
|
||||
|
||||
import bonsai.core.tool
|
||||
import bonsai.tool as tool
|
||||
@@ -152,34 +150,6 @@ class TestDisableEditingSheets(NewFile):
|
||||
assert props.is_editing_sheets == False
|
||||
|
||||
|
||||
class TestEditTextLiterals(NewFile):
|
||||
def test_run(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
obj = bpy.data.objects.new("Object", None)
|
||||
element = ifc.createIfcAnnotation()
|
||||
element.Representation = ifc.createIfcProductDefinitionShape()
|
||||
context = ifc.createIfcGeometricRepresentationSubContext(ContextType="Plan", ContextIdentifier="Annotation")
|
||||
item = ifc.createIfcTextLiteralWithExtent(Literal="Literal", Path="RIGHT", BoxAlignment="bottom-left")
|
||||
builder = ShapeBuilder(tool.Ifc.get())
|
||||
polyline = builder.polyline([(0.,0.,0.), (1.,0.,0.)])
|
||||
representation = ifc.createIfcShapeRepresentation(ContextOfItems=context, Items=[item, polyline])
|
||||
element.Representation.Representations = [representation]
|
||||
tool.Ifc.link(element, obj)
|
||||
literal_attributes = [
|
||||
{
|
||||
"Literal": "Foo",
|
||||
"Path": "RIGHT",
|
||||
"BoxAlignment": "bottom-left",
|
||||
}
|
||||
]
|
||||
subject.edit_text_literals(obj, literal_attributes)
|
||||
assert len(ifc.by_type("IfcTextLiteralWithExtent")) == 1
|
||||
literal = ifc.by_type("IfcTextLiteralWithExtent")[0]
|
||||
assert literal in representation.Items
|
||||
assert literal.Literal == "Foo"
|
||||
|
||||
|
||||
class TestDisableEditingText(NewFile):
|
||||
def test_run(self):
|
||||
obj = bpy.data.objects.new("Object", None)
|
||||
@@ -934,7 +904,7 @@ class TestAddReferenceImage(NewFile):
|
||||
bpy.ops.bim.save_project(filepath=str(ifc_path), should_save_as=True)
|
||||
|
||||
filepath = Path("test/files/image.jpg").absolute()
|
||||
bpy.ops.bim.add_reference_image(filepath=str(filepath), x_length=3.53982, y_length=2.0)
|
||||
bpy.ops.bim.add_reference_image(filepath=str(filepath))
|
||||
|
||||
obj = bpy.data.objects["IfcAnnotation/image"]
|
||||
assert obj is not None
|
||||
@@ -957,4 +927,72 @@ class TestAddReferenceImage(NewFile):
|
||||
assert texture_filepath == filepath
|
||||
|
||||
uv_node = material_nodes["Texture Coordinate"]
|
||||
assert len(uv_node.outputs["UV"].links[:]) == 1
|
||||
assert len(uv_node.outputs["Generated"].links[:]) == 1
|
||||
|
||||
|
||||
class TestAddReference(NewFile):
|
||||
def test_add_single_reference(self):
|
||||
"""Test adding a single reference file (backward compatibility)"""
|
||||
bpy.ops.bim.create_project()
|
||||
ifc_path = Path("test/files/temp/test.ifc").absolute()
|
||||
bpy.ops.bim.save_project(filepath=str(ifc_path), should_save_as=True)
|
||||
|
||||
# Create a temporary SVG file
|
||||
svg_path = Path("test/files/temp/reference.svg").absolute()
|
||||
svg_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(svg_path, "w") as f:
|
||||
f.write('<svg xmlns="http://www.w3.org/2000/svg"></svg>')
|
||||
|
||||
try:
|
||||
# Add single reference
|
||||
bpy.ops.bim.add_reference(filepath=str(svg_path))
|
||||
|
||||
# Verify reference was added
|
||||
ifc = tool.Ifc.get()
|
||||
references = [doc for doc in ifc.by_type("IfcDocumentInformation") if doc.Scope == "REFERENCE"]
|
||||
assert len(references) == 1
|
||||
assert references[0].Name == "reference"
|
||||
finally:
|
||||
# Cleanup
|
||||
if svg_path.exists():
|
||||
svg_path.unlink()
|
||||
|
||||
def test_add_multiple_references(self):
|
||||
"""Test adding multiple reference files at once"""
|
||||
bpy.ops.bim.create_project()
|
||||
ifc_path = Path("test/files/temp/test.ifc").absolute()
|
||||
bpy.ops.bim.save_project(filepath=str(ifc_path), should_save_as=True)
|
||||
|
||||
# Create temporary SVG files
|
||||
temp_dir = Path("test/files/temp").absolute()
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
svg_files = []
|
||||
for i in range(3):
|
||||
svg_path = temp_dir / f"reference_{i}.svg"
|
||||
with open(svg_path, "w") as f:
|
||||
f.write('<svg xmlns="http://www.w3.org/2000/svg"></svg>')
|
||||
svg_files.append(svg_path)
|
||||
|
||||
try:
|
||||
# Test by directly calling core.add_document multiple times
|
||||
# (simulating what the operator does with multiple files)
|
||||
ifc = tool.Ifc.get()
|
||||
for svg_file in svg_files:
|
||||
uri = tool.Ifc.get_uri(str(svg_file), use_relative_path=True)
|
||||
from bonsai.bim import core
|
||||
|
||||
core.drawing.add_document(tool.Ifc, tool.Drawing, "REFERENCE", uri=uri)
|
||||
|
||||
# Verify all references were added
|
||||
references = [doc for doc in ifc.by_type("IfcDocumentInformation") if doc.Scope == "REFERENCE"]
|
||||
assert len(references) == 3
|
||||
|
||||
reference_names = {ref.Name for ref in references}
|
||||
expected_names = {f"reference_{i}" for i in range(3)}
|
||||
assert reference_names == expected_names
|
||||
finally:
|
||||
# Cleanup
|
||||
for svg_file in svg_files:
|
||||
if svg_file.exists():
|
||||
svg_file.unlink()
|
||||
|
||||
@@ -16,12 +16,9 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import json
|
||||
import contextlib
|
||||
import tempfile
|
||||
import numpy as np
|
||||
from pathlib import Path
|
||||
from tempfile import NamedTemporaryFile
|
||||
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
@@ -266,7 +263,7 @@ class TestLoadLinkedModels(NewFile):
|
||||
props = tool.Project.get_project_props()
|
||||
ifcopenshell.api.root.create_entity(ifc, "IfcProject")
|
||||
document = ifcopenshell.api.document.add_information(ifc)
|
||||
document.Name = "X"
|
||||
document.Name = "BBIM_Linked_Models"
|
||||
tool.Ifc.set(ifc)
|
||||
subject.load_linked_models_from_ifc()
|
||||
assert len(props.links) == 0
|
||||
@@ -276,81 +273,86 @@ class TestLoadLinkedModels(NewFile):
|
||||
props = tool.Project.get_project_props()
|
||||
ifcopenshell.api.root.create_entity(ifc, "IfcProject")
|
||||
document = ifcopenshell.api.document.add_information(ifc)
|
||||
document.Scope = "LINKED_MODEL"
|
||||
document.Name = "BBIM_Linked_Models"
|
||||
reference = ifcopenshell.api.document.add_reference(ifc, document)
|
||||
reference.Location = "test.ifc"
|
||||
reference.Identification = ""
|
||||
reference2 = ifcopenshell.api.document.add_reference(ifc, document)
|
||||
reference2.Location = "test2.ifc"
|
||||
reference2.Identification = "1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16"
|
||||
linked_model_path = "test.ifc"
|
||||
reference.Location = linked_model_path
|
||||
tool.Ifc.set(ifc)
|
||||
subject.load_linked_models_from_ifc()
|
||||
assert len(props.links) == 2
|
||||
assert props.links[0].name == "test.ifc"
|
||||
assert props.links[0].ifc_definition_id == reference.id()
|
||||
assert props.links[0].has_transformation is False
|
||||
assert props.links[1].name == "test2.ifc"
|
||||
assert props.links[1].ifc_definition_id == reference2.id()
|
||||
assert props.links[1].has_transformation is True
|
||||
assert len(props.links) == 1
|
||||
assert props.links[0].name == linked_model_path
|
||||
|
||||
|
||||
class TestCalculateLinkMatrix(NewFile):
|
||||
def test_linking_a_model_without_an_offset_to_our_session_with_no_offset(self):
|
||||
class TestSaveLinkedModelsToIfc(NewFile):
|
||||
def test_save_linked_models_to_ifc_no_links(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
subject.save_linked_models_to_ifc()
|
||||
assert len(ifc.by_type("IfcDocumentInformation")) == 0
|
||||
assert len(ifc.by_type("IfcDocumentReference")) == 0
|
||||
|
||||
def test_save_linked_models_to_ifc_paths_to_add(self):
|
||||
ifc = ifcopenshell.file()
|
||||
ifcopenshell.api.root.create_entity(ifc, "IfcProject")
|
||||
props = tool.Project.get_project_props()
|
||||
gprops = tool.Georeference.get_georeference_props()
|
||||
with NamedTemporaryFile(suffix=".ifc.cache.json", mode="w", delete=True) as tmp:
|
||||
link = props.links.add()
|
||||
link.filepath = tmp.name.replace(".ifc.cache.json", ".ifc")
|
||||
json.dump({"model_project_north": "0", "model_origin_si": "0,0,0"}, tmp)
|
||||
tmp.flush()
|
||||
gprops.model_project_north = "0"
|
||||
gprops.model_origin_si = "0,0,0"
|
||||
assert np.allclose(subject.calculate_link_matrix(link), np.eye(4))
|
||||
link = props.links.add()
|
||||
linked_model_path = "test.ifc"
|
||||
link.name = linked_model_path
|
||||
tool.Ifc.set(ifc)
|
||||
subject.save_linked_models_to_ifc()
|
||||
assert len(documents := ifc.by_type("IfcDocumentInformation")) == 1
|
||||
assert documents[0].Name == "BBIM_Linked_Models"
|
||||
assert len(references := ifc.by_type("IfcDocumentReference")) == 1
|
||||
assert references[0].Location == linked_model_path
|
||||
|
||||
def test_linking_an_offset_model_to_our_session_with_no_offset(self):
|
||||
props = tool.Project.get_project_props()
|
||||
gprops = tool.Georeference.get_georeference_props()
|
||||
with NamedTemporaryFile(suffix=".ifc.cache.json", mode="w", delete=True) as tmp:
|
||||
link = props.links.add()
|
||||
link.filepath = tmp.name.replace(".ifc.cache.json", ".ifc")
|
||||
json.dump({"model_project_north": "0", "model_origin_si": "5,0,0"}, tmp)
|
||||
tmp.flush()
|
||||
gprops.model_project_north = "0"
|
||||
gprops.model_origin_si = "0,0,0"
|
||||
m = np.eye(4)
|
||||
m[0][3] = 5
|
||||
assert np.allclose(subject.calculate_link_matrix(link), m)
|
||||
def test_save_linked_models_to_ifc_already_created_references(self):
|
||||
ifc = ifcopenshell.file()
|
||||
links = tool.Project.get_project_props().links
|
||||
ifcopenshell.api.root.create_entity(ifc, "IfcProject")
|
||||
|
||||
def test_linking_an_offset_model_to_our_session_with_offset(self):
|
||||
props = tool.Project.get_project_props()
|
||||
gprops = tool.Georeference.get_georeference_props()
|
||||
with NamedTemporaryFile(suffix=".ifc.cache.json", mode="w", delete=True) as tmp:
|
||||
link = props.links.add()
|
||||
link.filepath = tmp.name.replace(".ifc.cache.json", ".ifc")
|
||||
json.dump({"model_project_north": "0", "model_origin_si": "5,0,0"}, tmp)
|
||||
tmp.flush()
|
||||
gprops.model_project_north = "0"
|
||||
gprops.model_origin_si = "2,0,0"
|
||||
m = np.eye(4)
|
||||
m[0][3] = 3
|
||||
assert np.allclose(subject.calculate_link_matrix(link), m)
|
||||
document = ifcopenshell.api.document.add_information(ifc)
|
||||
document.Name = "BBIM_Linked_Models"
|
||||
document_id = document.id()
|
||||
reference = ifcopenshell.api.document.add_reference(ifc, document)
|
||||
linked_model_path = "test.ifc"
|
||||
reference.Location = linked_model_path
|
||||
reference_id = reference.id()
|
||||
|
||||
def test_linking_an_offset_model_to_our_session_with_offset_and_transformation(self):
|
||||
props = tool.Project.get_project_props()
|
||||
gprops = tool.Georeference.get_georeference_props()
|
||||
with NamedTemporaryFile(suffix=".ifc.cache.json", mode="w", delete=True) as tmp:
|
||||
link = props.links.add()
|
||||
link.filepath = tmp.name.replace(".ifc.cache.json", ".ifc")
|
||||
transformation = np.eye(4)
|
||||
transformation[0][3] = 4
|
||||
link.transformation = ",".join(map(str, transformation.reshape(-1)))
|
||||
json.dump({"model_project_north": "0", "model_origin_si": "5,0,0"}, tmp)
|
||||
tmp.flush()
|
||||
gprops.model_project_north = "0"
|
||||
gprops.model_origin_si = "2,0,0"
|
||||
m = np.eye(4)
|
||||
m[0][3] = 7
|
||||
assert np.allclose(subject.calculate_link_matrix(link), m)
|
||||
link = links.add()
|
||||
linked_model_path = "test.ifc"
|
||||
link.name = linked_model_path
|
||||
tool.Ifc.set(ifc)
|
||||
subject.save_linked_models_to_ifc()
|
||||
|
||||
# Information and references to stay intact.
|
||||
assert len(documents := ifc.by_type("IfcDocumentInformation")) == 1
|
||||
assert documents[0].id() == document_id
|
||||
assert documents[0].Name == "BBIM_Linked_Models"
|
||||
assert len(references := ifc.by_type("IfcDocumentReference")) == 1
|
||||
assert references[0].id() == reference_id
|
||||
assert references[0].Location == linked_model_path
|
||||
|
||||
def test_save_linked_models_to_ifc_references_to_remove(self):
|
||||
ifc = ifcopenshell.file()
|
||||
links = tool.Project.get_project_props().links
|
||||
ifcopenshell.api.root.create_entity(ifc, "IfcProject")
|
||||
|
||||
document = ifcopenshell.api.document.add_information(ifc)
|
||||
document.Name = "BBIM_Linked_Models"
|
||||
document_id = document.id()
|
||||
reference = ifcopenshell.api.document.add_reference(ifc, document)
|
||||
linked_model_path = "test.ifc"
|
||||
reference.Location = linked_model_path
|
||||
|
||||
tool.Ifc.set(ifc)
|
||||
subject.save_linked_models_to_ifc()
|
||||
links.clear()
|
||||
|
||||
# Remove reference for removed link.
|
||||
assert len(documents := ifc.by_type("IfcDocumentInformation")) == 1
|
||||
assert documents[0].id() == document_id
|
||||
assert documents[0].Name == "BBIM_Linked_Models"
|
||||
assert len(ifc.by_type("IfcDocumentReference")) == 0
|
||||
|
||||
|
||||
class TestLoadingIfcSqlite(NewFile):
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
IfcOpenShell AI Assistant
|
||||
=========================
|
||||
|
||||
A web-based client-side (pyodide + OpenAI API) model interrogation and generation API based on: ifcedit, ifcquery and ifcmcp packaged in a HTML+JS application.
|
||||
|
||||
### Setup instructions
|
||||
|
||||
```
|
||||
mkdir ./src/chat/dist
|
||||
cd ./src/ifcquery/
|
||||
python -m build
|
||||
cp ./dist/ifcquery-0.0.0-py3-none-any.whl ../chat/dist/
|
||||
cd ../../src/ifcedit
|
||||
python -m build
|
||||
cp ./dist/ifcedit-0.0.0-py3-none-any.whl ../chat/dist/
|
||||
cd ../../src/ifcmcp
|
||||
python -m build
|
||||
cp ./dist/ifcmcp-0.0.0-py3-none-any.whl ../chat/dist/
|
||||
cd ../chat/dist/
|
||||
wget https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl
|
||||
```
|
||||
@@ -0,0 +1,320 @@
|
||||
// app.js
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
const statusEl = $("status");
|
||||
const msgsEl = $("msgs");
|
||||
const sendBtn = $("send");
|
||||
const inputEl = $("input");
|
||||
const apiKeyEl = $("apiKey");
|
||||
const modelEl = $("model");
|
||||
const ifcFileEl = $("ifcFile");
|
||||
const newBtn = $("newModel");
|
||||
const downloadBtn = $("downloadIfc");
|
||||
|
||||
function setBusy(isBusy, reason = "") {
|
||||
const controls = [
|
||||
$("send"),
|
||||
$("newModel"),
|
||||
$("downloadIfc"),
|
||||
$("ifcFile"),
|
||||
];
|
||||
|
||||
for (const el of controls) el.disabled = isBusy;
|
||||
|
||||
$("input").disabled = isBusy;
|
||||
|
||||
const browseBtn = $("browseBtn");
|
||||
if (browseBtn) {
|
||||
browseBtn.classList.toggle("disabled", isBusy);
|
||||
browseBtn.setAttribute("aria-disabled", isBusy ? "true" : "false");
|
||||
browseBtn.tabIndex = isBusy ? -1 : 0;
|
||||
}
|
||||
|
||||
setStatus(isBusy ? (reason || "Working…") : "Ready");
|
||||
}
|
||||
|
||||
function addMessage(role, text) {
|
||||
if (text.ok) {
|
||||
text = text.data;
|
||||
}
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = `msg ${role}`;
|
||||
wrap.innerHTML = `
|
||||
<div class="role">${role}</div>
|
||||
<div class="bubble"></div>`;
|
||||
const bubble = wrap.querySelector(".bubble");
|
||||
bubble.textContent = text;
|
||||
bubble.onclick = function () {
|
||||
if (bubble.scrollHeight > 100 && role === "tool") {
|
||||
bubble.style.maxHeight = bubble.style.maxHeight == 'none' ? '' : 'none';
|
||||
bubble.style.borderBottom = bubble.style.borderBottom == '' ? 'dotted 2px gray' : '';
|
||||
}
|
||||
}
|
||||
msgsEl.appendChild(wrap);
|
||||
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||
}
|
||||
|
||||
function setStatus(text) {
|
||||
statusEl.textContent = text;
|
||||
}
|
||||
|
||||
const worker = new Worker("./ifc_worker.js", { type: "module" });
|
||||
|
||||
function callWorker(type, payload = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = crypto.randomUUID();
|
||||
const onMsg = (ev) => {
|
||||
const msg = ev.data;
|
||||
if (!msg || msg.id !== id) return;
|
||||
worker.removeEventListener("message", onMsg);
|
||||
if (msg.ok) resolve(msg);
|
||||
else reject(new Error(msg.error || "Worker error"));
|
||||
};
|
||||
worker.addEventListener("message", onMsg);
|
||||
worker.postMessage({ id, type, payload });
|
||||
});
|
||||
}
|
||||
|
||||
// ---- OpenAI Responses API tool schemas (should match ifcmcp.core openai_tools()) ----
|
||||
// Docs show Responses API function_call items + function_call_output loop. :contentReference[oaicite:4]{index=4}
|
||||
const tools = [
|
||||
{
|
||||
type: "function", name: "ifc_new", description: "Create a new empty IFC model in memory.",
|
||||
parameters: { type: "object", properties: { schema: { type: "string" } }, 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" } },
|
||||
required: ["element_id"], 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" } }, 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" } }, 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" } }, 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" } }, 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" }, selector: { type: "string" } }, required: ["rule"], additionalProperties: false }
|
||||
},
|
||||
];
|
||||
|
||||
const SYSTEM_INSTRUCTIONS = `
|
||||
You are an IFC copilot running in a browser. You can call tools to inspect or modify the currently loaded IFC model.
|
||||
Rules:
|
||||
- If the user asks about model contents (counts, lists, properties, hierarchy), use tools like ifc_summary/ifc_select/ifc_info/ifc_tree.
|
||||
- If the user asks to change the model, prefer: (1) ifc_list to find candidate API modules, (2) ifc_docs for the exact function signature, then (3) ifc_edit.
|
||||
- If there is no model and the user wants to create one, call ifc_new.
|
||||
- After edits, explain what changed and suggest downloading the IFC.
|
||||
Be concise. Avoid dumping huge trees unless asked.
|
||||
`;
|
||||
|
||||
let inputItems = []; // running conversation state (Responses API style)
|
||||
|
||||
async function openAIResponsesCreate({ apiKey, model, input, tools }) {
|
||||
const res = await fetch("https://api.openai.com/v1/responses", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
instructions: SYSTEM_INSTRUCTIONS,
|
||||
tools,
|
||||
input,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`OpenAI error ${res.status}: ${text}`);
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
function extractAssistantText(response) {
|
||||
const out = [];
|
||||
for (const item of response.output ?? []) {
|
||||
if (item.type === "message" && item.role === "assistant") {
|
||||
for (const c of item.content ?? []) {
|
||||
if (c.type === "output_text") out.push(c.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out.join("\n").trim();
|
||||
}
|
||||
|
||||
async function runAgentTurn(userText) {
|
||||
const apiKey = apiKeyEl.value.trim();
|
||||
if (!apiKey) throw new Error("Missing API key");
|
||||
|
||||
// Add user message
|
||||
inputItems.push({ role: "user", content: userText });
|
||||
|
||||
// Tool-calling loop (Responses API): append response.output, execute function_call items, append function_call_output.
|
||||
for (let i = 0; i < 64; i++) {
|
||||
const response = await openAIResponsesCreate({
|
||||
apiKey,
|
||||
model: modelEl.value,
|
||||
input: inputItems,
|
||||
tools,
|
||||
});
|
||||
|
||||
// Keep ALL output items (incl reasoning/tool calls) in the running state.
|
||||
inputItems.push(...(response.output ?? []));
|
||||
|
||||
// Show any assistant text immediately
|
||||
const text = extractAssistantText(response);
|
||||
if (text) addMessage("assistant", text);
|
||||
|
||||
const calls = (response.output ?? []).filter((x) => x.type === "function_call");
|
||||
if (calls.length === 0) return;
|
||||
|
||||
for (const call of calls) {
|
||||
let args = {};
|
||||
try { args = call.arguments ? JSON.parse(call.arguments) : {}; }
|
||||
catch { args = {}; }
|
||||
|
||||
addMessage("tool", `→ ${call.name}(${JSON.stringify(args)})`);
|
||||
|
||||
const toolRes = await callWorker("toolCall", { name: call.name, args });
|
||||
|
||||
// Feed tool result back to the model
|
||||
inputItems.push({
|
||||
type: "function_call_output",
|
||||
call_id: call.call_id,
|
||||
output: JSON.stringify(toolRes.result),
|
||||
});
|
||||
|
||||
addMessage("tool", `← ${call.name}: ${JSON.stringify(toolRes.result, null, 2)}`);
|
||||
}
|
||||
}
|
||||
|
||||
addMessage("assistant", "I hit the tool-call loop limit. Try narrowing your request.");
|
||||
}
|
||||
|
||||
sendBtn.onclick = async () => {
|
||||
const text = inputEl.value.trim();
|
||||
if (!text) return;
|
||||
inputEl.value = "";
|
||||
addMessage("user", text);
|
||||
try {
|
||||
setBusy(true, "Thinking…");
|
||||
await runAgentTurn(text);
|
||||
setBusy(false, "Ready");
|
||||
} catch (e) {
|
||||
setBusy(true, "Error");
|
||||
addMessage("assistant", `Error: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
inputEl.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendBtn.click();
|
||||
}
|
||||
});
|
||||
|
||||
ifcFileEl.onchange = async () => {
|
||||
const f = ifcFileEl.files?.[0];
|
||||
if (!f) return;
|
||||
setBusy(true, "Loading IFC into Pyodide…");
|
||||
const buf = await f.arrayBuffer();
|
||||
try {
|
||||
const r = await callWorker("loadIfc", { filename: f.name, bytes: buf }, [buf]);
|
||||
addMessage("assistant", r.result);
|
||||
setBusy(false, "Ready");
|
||||
} catch (e) {
|
||||
setStatus(true, "Error");
|
||||
addMessage("assistant", `Load error: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
newBtn.onclick = async () => {
|
||||
try {
|
||||
setBusy(true, "Creating new model…");
|
||||
const r = await callWorker("toolCall", { name: "ifc_new", args: { schema: "IFC4" } });
|
||||
addMessage("assistant", `New model: ${JSON.stringify(r.result)}`);
|
||||
setBusy(false, "Ready");
|
||||
} catch (e) {
|
||||
setBusy(true, "Error");
|
||||
addMessage("assistant", `Error: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
downloadBtn.onclick = async () => {
|
||||
try {
|
||||
setBusy(true, "Exporting IFC…");
|
||||
const r = await callWorker("exportIfc", {});
|
||||
const blob = new Blob([r.bytes], { type: "application/octet-stream" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = r.filename || "model.ifc";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
setBusy(false, "Ready");
|
||||
} catch (e) {
|
||||
setBusy(true, "Error");
|
||||
addMessage("assistant", `Export error: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
setBusy(true, "Initializing Pyodide and IfcOpenShell for in-memory IFC access…");
|
||||
await callWorker("init", {});
|
||||
setBusy(false, "Ready");
|
||||
} catch (e) {
|
||||
setBusy(true, "Error");
|
||||
addMessage("assistant", `Worker init failed: ${e.message}`);
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,108 @@
|
||||
// ifc_worker.js (MODULE WORKER)
|
||||
import { loadPyodide } from "https://cdn.jsdelivr.net/pyodide/v0.29.3/full/pyodide.mjs";
|
||||
|
||||
let pyodide = null;
|
||||
let callToolPy = null;
|
||||
let initPromise = null;
|
||||
|
||||
function ok(id, extra = {}, transfer = []) {
|
||||
self.postMessage({ id, ok: true, ...extra }, transfer);
|
||||
}
|
||||
function fail(id, error) {
|
||||
self.postMessage({ id, ok: false, error: String(error?.message || error) });
|
||||
}
|
||||
|
||||
async function ensurePyodide() {
|
||||
if (initPromise) return initPromise;
|
||||
|
||||
initPromise = (async () => {
|
||||
// Passing indexURL avoids some environments failing to infer it from the module URL. :contentReference[oaicite:3]{index=3}
|
||||
pyodide = await loadPyodide({
|
||||
indexURL: "https://cdn.jsdelivr.net/pyodide/v0.29.3/full/",
|
||||
});
|
||||
|
||||
await pyodide.loadPackage("micropip");
|
||||
await pyodide.loadPackage("numpy");
|
||||
await pyodide.loadPackage("shapely");
|
||||
await pyodide.loadPackage("typing-extensions");
|
||||
|
||||
const micropip = pyodide.pyimport("micropip");
|
||||
|
||||
// Detect python minor version (3.12 vs 3.13) and pick a matching wheel.
|
||||
const pyVer = pyodide.runPython(`
|
||||
import sys
|
||||
f"{sys.version_info.major}.{sys.version_info.minor}"
|
||||
`);
|
||||
|
||||
const wheelUrl =
|
||||
pyVer === "3.13"
|
||||
? "https://ifcopenshell.github.io/wasm-wheels/ifcopenshell-0.8.3+34a1bc6-cp313-cp313-emscripten_4_0_9_wasm32.whl"
|
||||
: "https://ifcopenshell.github.io/wasm-wheels/ifcopenshell-0.8.2+d50e806-cp312-cp312-emscripten_3_1_58_wasm32.whl";
|
||||
|
||||
await micropip.install(wheelUrl);
|
||||
|
||||
await micropip.install([
|
||||
"./dist/ifcquery-0.0.0-py3-none-any.whl",
|
||||
"./dist/ifcedit-0.0.0-py3-none-any.whl",
|
||||
"./dist/ifcmcp-0.0.0-py3-none-any.whl",
|
||||
"./dist/lark-1.3.1-py3-none-any.whl",
|
||||
])
|
||||
await pyodide.runPythonAsync(`
|
||||
from ifcmcp.embedded import call_tool as _call_tool
|
||||
`);
|
||||
callToolPy = pyodide.globals.get("_call_tool");
|
||||
})();
|
||||
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
function callTool(name, args) {
|
||||
const pyArgs = pyodide.toPy(args);
|
||||
const res = callToolPy(name, pyArgs);
|
||||
pyArgs.destroy();
|
||||
const resJs = res.toJs({ dict_converter: Object.fromEntries });
|
||||
res.destroy();
|
||||
return resJs;
|
||||
}
|
||||
|
||||
self.onmessage = async (ev) => {
|
||||
const { id, type, payload } = ev.data || {};
|
||||
try {
|
||||
if (type === "init") {
|
||||
await ensurePyodide();
|
||||
ok(id, { result: "ok" });
|
||||
return;
|
||||
}
|
||||
|
||||
await ensurePyodide();
|
||||
|
||||
if (type === "loadIfc") {
|
||||
const { filename, bytes } = payload;
|
||||
const path = `/tmp/${filename || "model.ifc"}`;
|
||||
pyodide.FS.mkdirTree("/tmp");
|
||||
pyodide.FS.writeFile(path, new Uint8Array(bytes));
|
||||
const result = callTool("ifc_load", { path });
|
||||
ok(id, { result });
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "exportIfc") {
|
||||
const path = "/tmp/export.ifc";
|
||||
const result = callTool("ifc_save", { path });
|
||||
const data = pyodide.FS.readFile(path);
|
||||
ok(id, { result, filename: "export.ifc", bytes: data }, [data.buffer]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "toolCall") {
|
||||
const { name, args } = payload;
|
||||
const result = callTool(name, args || {});
|
||||
ok(id, { result });
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Unknown message type: ${type}`);
|
||||
} catch (e) {
|
||||
fail(id, e);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,294 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>IfcOpenShell AI Assistant</title>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
header {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
header input,
|
||||
header select {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
main {
|
||||
display: grid;
|
||||
grid-template-columns: 320px 1fr;
|
||||
height: calc(100vh - 84px);
|
||||
}
|
||||
|
||||
.side {
|
||||
border-right: 1px solid #ddd;
|
||||
padding: 12px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.msgs {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.msg {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.msg .role {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.msg .bubble {
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.msg.user .bubble {
|
||||
background: #e8f0ff;
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.msg.assistant .bubble {
|
||||
background: #f4f4f4;
|
||||
}
|
||||
|
||||
.msg.tool .bubble {
|
||||
background: #fff6db;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.composer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.composer textarea {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
height: 88px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.composer button {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.composer .inner {
|
||||
border: solid 1px #ddd;
|
||||
border-radius: 20px;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
section>.row>label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
opacity: 0.75;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.side .row {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.row button {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
#model {
|
||||
background: white;
|
||||
color: gray;
|
||||
border: solid 1px #eee;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: dashed 1px #ddd;
|
||||
}
|
||||
|
||||
.btn-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn-row>.btn,
|
||||
.btn-row>button.btn {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #d0d0d0;
|
||||
background: #eee;
|
||||
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #ddd;
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.btn-wide {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.material-icons {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Disabled state for label-button + normal buttons */
|
||||
.btn.disabled,
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.msg.tool .bubble {
|
||||
max-height: 100px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#input {
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#input:focus,
|
||||
#input:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
</style>
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<header>
|
||||
<div class="row">
|
||||
|
||||
<strong>IfcOpenShell AI Assistant</strong>
|
||||
<select id="model">
|
||||
<option value="gpt-5">gpt-5</option>
|
||||
<option value="gpt-4.1">gpt-4.1</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="row">
|
||||
|
||||
<span class="status" id="status">Booting…</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="side">
|
||||
<div class="row">
|
||||
<label>OpenAI API key (stored only in memory)</label>
|
||||
<input id="apiKey" type="password" placeholder="sk-..." autocomplete="off" style="width: 100%;" />
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="row">
|
||||
<label>IFC model (stored only in memory)</label>
|
||||
|
||||
<div class="btn-row">
|
||||
<label class="btn" id="browseBtn" for="ifcFile" role="button" tabindex="0">
|
||||
<span class="material-icons">folder_open</span>
|
||||
Browse
|
||||
</label>
|
||||
|
||||
<button class="btn" id="newModel" type="button">
|
||||
<span class="material-icons">add_box</span>
|
||||
New IFC
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input id="ifcFile" type="file" accept=".ifc,.ifczip,.ifcxml,.zip" hidden />
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<button class="btn btn-wide" id="downloadIfc" type="button">
|
||||
<span class="material-icons">download</span>
|
||||
Download IFC
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="row">
|
||||
<label>Tips</label>
|
||||
<div class="status">
|
||||
• Upload an IFC, then ask “Summarize the model” or “List all IfcWalls”.<br />
|
||||
• Try “Add a new site and building named X” (will use ifc_edit).
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="chat">
|
||||
<div class="msgs" id="msgs"></div>
|
||||
<div class="composer">
|
||||
<div class="inner">
|
||||
<textarea id="input" placeholder="Ask or instruct about the IFC model…"></textarea>
|
||||
<button id="send" class="btn">Send <span class="material-icons">send</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script type="module" src="./app.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,248 @@
|
||||
<!-- This file was generated with the assistance of an AI coding tool. -->
|
||||
# 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 <command> [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 <path>` -- 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"}'
|
||||
```
|
||||
|
||||
### 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 <query>` -- ifcopenshell selector to restrict elements (default: all `IfcElement`)
|
||||
- `-o, --output <path>` -- 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`.
|
||||
|
||||
## License
|
||||
|
||||
LGPLv3+ -- see the IfcOpenShell project license.
|
||||
@@ -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 <bruno@postle.net>
|
||||
#
|
||||
# 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
__version__ = version = "0.0.0"
|
||||
@@ -0,0 +1,217 @@
|
||||
# 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 <bruno@postle.net>
|
||||
#
|
||||
# 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
|
||||
|
||||
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_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")
|
||||
|
||||
# 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 == "quantify":
|
||||
cmd_quantify(args, extra)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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 <bruno@postle.net>
|
||||
#
|
||||
# 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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()]
|
||||
@@ -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 <bruno@postle.net>
|
||||
#
|
||||
# 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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())
|
||||
@@ -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)}
|
||||
@@ -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 <bruno@postle.net>
|
||||
#
|
||||
# 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
@@ -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"
|
||||
@@ -0,0 +1 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -0,0 +1,100 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def run_ifcedit(*args):
|
||||
"""Run ifcedit as a subprocess and return (stdout, stderr, returncode)."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "ifcedit", *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
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
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -12,8 +12,11 @@ ifcopenshell::geometry::Converter::Converter(std::unique_ptr<ifcopenshell::geome
|
||||
settings_ = mapping_->settings();
|
||||
}
|
||||
|
||||
ifcopenshell::geometry::Converter::~Converter() {
|
||||
delete mapping_;
|
||||
ifcopenshell::geometry::Converter::~Converter()
|
||||
{
|
||||
if (mapping_ != nullptr) {
|
||||
delete mapping_;
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -82,79 +82,49 @@ bool OpenCascadeKernel::convert(const taxonomy::loft::ptr loft, TopoDS_Shape& re
|
||||
}
|
||||
|
||||
if (non_polygonal) {
|
||||
if (loft->children.size() < 2) {
|
||||
Logger::Error("Not enough sections to loft");
|
||||
return false;
|
||||
}
|
||||
if (loft->children.size() == 2) {
|
||||
BRep_Builder BB;
|
||||
TopoDS_Shell comp;
|
||||
BB.MakeShell(comp);
|
||||
|
||||
std::vector<std::vector<TopoDS_Wire>> sections;
|
||||
sections.reserve(loft->children.size());
|
||||
|
||||
TopoDS_Shape f0, f1;
|
||||
|
||||
// Convert all children to vectors of wires
|
||||
for (const auto& child : loft->children) {
|
||||
TopoDS_Shape shape;
|
||||
if (!convert(std::static_pointer_cast<taxonomy::face>(child), shape)) {
|
||||
return false;
|
||||
}
|
||||
if (shape.ShapeType() != TopAbs_FACE) {
|
||||
return false;
|
||||
}
|
||||
// At least make sure to have outer wire consistent, but in reality
|
||||
// this is probably not a concern given how to build up these faces
|
||||
auto f = TopoDS::Face(shape);
|
||||
|
||||
if (child == loft->children.front()) {
|
||||
f0 = f;
|
||||
} else if (child == loft->children.back()) {
|
||||
f1 = f;
|
||||
}
|
||||
|
||||
auto outer = BRepTools::OuterWire(f);
|
||||
sections.emplace_back();
|
||||
sections.back().push_back(outer);
|
||||
for (TopoDS_Iterator it(f); it.More(); it.Next()) {
|
||||
if (outer != it.Value()) {
|
||||
sections.back().push_back(TopoDS::Wire(it.Value()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
auto first_wire_count = sections.front().size();
|
||||
for (auto& section : sections) {
|
||||
if (section.size() != first_wire_count) {
|
||||
Logger::Error("Inconsistent number of wires in sections");
|
||||
TopoDS_Shape f0, f1;
|
||||
if (!convert(std::static_pointer_cast<taxonomy::face>(loft->children.front()), f0) ||
|
||||
!convert(std::static_pointer_cast<taxonomy::face>(loft->children.back()), f1))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
BRep_Builder BB;
|
||||
TopoDS_Shell comp;
|
||||
BB.MakeShell(comp);
|
||||
|
||||
for (size_t i = 0; i < first_wire_count; ++i) {
|
||||
// Rule=True uses linear interpolation.
|
||||
// This is critical for preventing twists in roads/railings.
|
||||
BRepOffsetAPI_ThruSections builder(false, true);
|
||||
for (auto& ws : sections) {
|
||||
builder.AddWire(ws[i]);
|
||||
}
|
||||
builder.Build();
|
||||
if (!builder.IsDone()) {
|
||||
if (f0.ShapeType() != TopAbs_FACE || f1.ShapeType() != TopAbs_FACE) {
|
||||
return false;
|
||||
}
|
||||
for (TopExp_Explorer exp(builder.Shape(), TopAbs_FACE); exp.More(); exp.Next()) {
|
||||
BB.Add(comp, exp.Current());
|
||||
|
||||
TopExp_Explorer exp1(f0, TopAbs_WIRE);
|
||||
TopExp_Explorer exp2(f1, TopAbs_WIRE);
|
||||
for (; exp1.More() && exp2.More(); exp1.Next(), exp2.Next()) {
|
||||
const auto& w1 = TopoDS::Wire(exp1.Current());
|
||||
const auto& w2 = TopoDS::Wire(exp2.Current());
|
||||
BRepOffsetAPI_ThruSections builder;
|
||||
builder.AddWire(w1);
|
||||
builder.AddWire(w2);
|
||||
builder.Build();
|
||||
if (!builder.IsDone()) {
|
||||
return false;
|
||||
}
|
||||
for (TopExp_Explorer exp(builder.Shape(), TopAbs_FACE); exp.More(); exp.Next()) {
|
||||
BB.Add(comp, exp.Current());
|
||||
}
|
||||
}
|
||||
|
||||
BB.Add(comp, f0.Reversed());
|
||||
BB.Add(comp, f1);
|
||||
|
||||
result = BRepBuilderAPI_MakeSolid(comp).Solid();
|
||||
|
||||
return true;
|
||||
} else {
|
||||
Logger::Error("Lofting more than two sections is not supported");
|
||||
return false;
|
||||
}
|
||||
|
||||
BB.Add(comp, f0.Reversed());
|
||||
BB.Add(comp, f1);
|
||||
|
||||
result = BRepBuilderAPI_MakeSolid(comp).Solid();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
TopTools_ListOfShape faces;
|
||||
|
||||
@@ -21,44 +21,7 @@
|
||||
#define mapping POSTFIX_SCHEMA(mapping)
|
||||
using namespace ifcopenshell::geometry;
|
||||
|
||||
#include <deque>
|
||||
|
||||
taxonomy::ptr mapping::map_impl(const IfcSchema::IfcObjectPlacement* inst) {
|
||||
if (placement_rel_to_type_ || placement_rel_to_instance_) {
|
||||
using QueueItem = std::pair<const IfcUtil::IfcBaseEntity*, int>;
|
||||
std::deque<QueueItem> q = {{inst, 0}};
|
||||
while (!q.empty()) {
|
||||
auto [placement_entity, depth] = q.front();
|
||||
q.pop_front();
|
||||
|
||||
auto placement = placement_entity->as<typename IfcSchema::IfcObjectPlacement>();
|
||||
if (!placement) {
|
||||
continue;
|
||||
}
|
||||
|
||||
auto self_places = placement->PlacesObject();
|
||||
for (auto iter = self_places->begin(); iter != self_places->end(); ++iter) {
|
||||
if ((placement_rel_to_type_ && (*iter)->declaration().is(*placement_rel_to_type_)) ||
|
||||
(placement_rel_to_instance_ && (*iter)->as<IfcUtil::IfcBaseEntity>() == placement_rel_to_instance_)) {
|
||||
return taxonomy::make<taxonomy::matrix4>();
|
||||
}
|
||||
}
|
||||
|
||||
// Look for two levels deep, we want to know if we're at or *above* the
|
||||
// element we're ignoring, but we don't want to traverse the entire model.
|
||||
#ifdef SCHEMA_IfcObjectPlacement_HAS_ReferencedByPlacements
|
||||
if (depth < 2) {
|
||||
auto refs = placement->ReferencedByPlacements();
|
||||
for (auto& ref : *refs) {
|
||||
q.emplace_back(ref, depth + 1);
|
||||
}
|
||||
}
|
||||
#else
|
||||
Logger::Warning("Using --site-local-placement or --building-local-placement on IFC4.2 might have issues");
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
const IfcSchema::IfcObjectPlacement* relative_to = nullptr;
|
||||
const IfcUtil::IfcBaseInterface* transform;
|
||||
|
||||
|
||||
@@ -61,9 +61,33 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcSectionedSolidHorizontal* in
|
||||
|
||||
longitudes.push_back(*pbde->DistanceAlong()->as<IfcSchema::IfcLengthMeasure>(true) * length_unit_);
|
||||
|
||||
auto linear_placement = taxonomy::cast<taxonomy::matrix4>(map(csp));
|
||||
profile_offsets.push_back(linear_placement->ccomponents().block<3, 1>(0, 3));
|
||||
boost::optional<Eigen::Matrix3d> rot(linear_placement->ccomponents().block<3,3>(0,0));
|
||||
// Corresponds to the profile X, Y directions (hopefully).
|
||||
Eigen::Vector3d po(
|
||||
pbde->OffsetLateral().get_value_or(0.),
|
||||
// @todo I don't understand whether vertical is an offset relative to the tangent plane or to the global XY plane
|
||||
pbde->OffsetVertical().get_value_or(0.),
|
||||
0.
|
||||
);
|
||||
|
||||
profile_offsets.push_back(po);
|
||||
|
||||
boost::optional<Eigen::Matrix3d> rot;
|
||||
if (csp->Axis() && csp->RefDirection()) {
|
||||
rot = taxonomy::matrix4(
|
||||
Eigen::Vector3d(0, 0, 0),
|
||||
taxonomy::cast<taxonomy::direction3>(map(csp->Axis()))->ccomponents(),
|
||||
taxonomy::cast<taxonomy::direction3>(map(csp->RefDirection()))->ccomponents()).ccomponents().block<3,3>(0,0);
|
||||
} else if (csp->Axis()) {
|
||||
rot = taxonomy::matrix4(
|
||||
Eigen::Vector3d(0, 0, 0),
|
||||
taxonomy::cast<taxonomy::direction3>(map(csp->Axis()))->ccomponents()).ccomponents().block<3, 3>(0, 0);
|
||||
} else if (csp->RefDirection()) {
|
||||
rot = taxonomy::matrix4(
|
||||
Eigen::Vector3d(0, 0, 0),
|
||||
Eigen::Vector3d(0, 0, 1),
|
||||
taxonomy::cast<taxonomy::direction3>(map(csp->RefDirection()))->ccomponents()
|
||||
).ccomponents().block<3, 3>(0, 0);
|
||||
}
|
||||
profile_rotations.push_back(rot);
|
||||
}
|
||||
if (faces.size() != profile_offsets.size()) {
|
||||
|
||||
@@ -63,10 +63,35 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcSectionedSurface* inst) {
|
||||
|
||||
longitudes.push_back(*pbde->DistanceAlong()->as<IfcSchema::IfcLengthMeasure>(true) * length_unit_);
|
||||
|
||||
auto linear_placement = taxonomy::cast<taxonomy::matrix4>(map(csp));
|
||||
profile_offsets.push_back(linear_placement->ccomponents().block<3, 1>(0, 3));
|
||||
boost::optional<Eigen::Matrix3d> rot(linear_placement->ccomponents().block<3, 3>(0, 0));
|
||||
profile_rotations.push_back(rot);
|
||||
// Corresponds to the profile X, Y directions (hopefully).
|
||||
Eigen::Vector3d po(
|
||||
pbde->OffsetLateral().get_value_or(0.),
|
||||
// @todo I don't understand whether vertical is an offset relative to the tangent plane or to the global XY plane
|
||||
pbde->OffsetVertical().get_value_or(0.),
|
||||
0.
|
||||
);
|
||||
|
||||
profile_offsets.push_back(po);
|
||||
|
||||
boost::optional<Eigen::Matrix3d> rot;
|
||||
if (csp->Axis() && csp->RefDirection()) {
|
||||
rot = taxonomy::matrix4(
|
||||
Eigen::Vector3d(0, 0, 0),
|
||||
taxonomy::cast<taxonomy::direction3>(map(csp->Axis()))->ccomponents(),
|
||||
taxonomy::cast<taxonomy::direction3>(map(csp->RefDirection()))->ccomponents()).ccomponents().block<3, 3>(0, 0);
|
||||
} else if (csp->Axis()) {
|
||||
rot = taxonomy::matrix4(
|
||||
Eigen::Vector3d(0, 0, 0),
|
||||
taxonomy::cast<taxonomy::direction3>(map(csp->Axis()))->ccomponents()).ccomponents().block<3, 3>(0, 0);
|
||||
} else if (csp->RefDirection()) {
|
||||
rot = taxonomy::matrix4(
|
||||
Eigen::Vector3d(0, 0, 0),
|
||||
Eigen::Vector3d(0, 0, 1),
|
||||
taxonomy::cast<taxonomy::direction3>(map(csp->RefDirection()))->ccomponents())
|
||||
.ccomponents()
|
||||
.block<3, 3>(0, 0);
|
||||
}
|
||||
profile_rotations.push_back(rot);
|
||||
}
|
||||
#else
|
||||
return nullptr;
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
<!-- This file was generated with the assistance of an AI coding tool. -->
|
||||
# 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.
|
||||
@@ -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 <bruno@postle.net>
|
||||
#
|
||||
# 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
__version__ = version = "0.0.0"
|
||||
@@ -0,0 +1,11 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
from ifcmcp.server import build_server
|
||||
|
||||
|
||||
def main():
|
||||
server = build_server()
|
||||
server.run(transport="stdio")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -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)}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -0,0 +1 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"<svg>FAKE</svg>"
|
||||
|
||||
|
||||
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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -254,7 +254,7 @@ nest formulas, for example ``concat(title("foo"), lower("Bar"))`` will produce
|
||||
"``imperial_length({{value}}, {{precision}}, {{input_unit}}, {{output_unit}}, {{suppress_zero_inches}})``", "``imperial_length(3.0, 4, ""foot"", ""foot"", true)`` OR ``imperial_length(3.0, 4, ""foot"", ""foot"", false)``", "``3'`` OR ``3' - 0""``", "The ``{{value}}`` may be specified either as ``foot`` or ``inch`` depending on ``{{input_unit}}``. The ``{{value}}`` is then rounded to the nearest ``1/{{precision}}`` inch, then formatted using fractional feet and inches if ``{{output_unit}}`` is set to ``foot``, or just inches if ``{{output_unit}}`` is set to ``inch``. When ``{{suppress_zero_inches}}`` is ``true`` (default), measurements with zero inches will omit the inch portion (e.g., ``3'`` instead of ``3' - 0""``)."
|
||||
"``sort({{values}})``", "``sort({{mats.Name}})``", "``Name1, Name2``", "Sorts a list of items."
|
||||
"``reverse({{values}})``", "``reverse({{mats.Name}})``", "``Name2, Name1``", "Reverses a list of items."
|
||||
"``join({{separator}}, {{values}})``", "``join(""-"", {{mats.Name}})``", "``Name1-Name2``", "Joins a list of items with a custom separator. By default, all lists a rendered as comma separated."
|
||||
"``join({{separator}}, {{values}})``", "``join("-", {{mats.Name}})``", "``Name1-Name2``", "Joins a list of items with a custom separator. By default, all lists a rendered as comma separated."
|
||||
"``{{value1}}[+-*/]{{value2}}``", "``{{z}}+3``", "``5``", "Does arithmetic. Typical operators such as +, -, \*, and / are allowed and can be mixed with other variables and formatting functions."
|
||||
|
||||
When using queries in an IfcAnnotation tag surround with backticks.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ geometry extrusions).
|
||||
from .. import wrap_usecases
|
||||
from .add_axis_representation import add_axis_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 +52,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
|
||||
@@ -61,6 +64,9 @@ wrap_usecases(__path__, __name__)
|
||||
__all__ = [
|
||||
"add_axis_representation",
|
||||
"add_boolean",
|
||||
"clip_solid",
|
||||
"clip_solid_bounded",
|
||||
"copy_representation",
|
||||
"add_door_representation",
|
||||
"add_footprint_representation",
|
||||
"add_mesh_representation",
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# IfcOpenShell - IFC toolkit and geometry engine
|
||||
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
@@ -0,0 +1,116 @@
|
||||
# IfcOpenShell - IFC toolkit and geometry engine
|
||||
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
@@ -0,0 +1,82 @@
|
||||
# IfcOpenShell - IFC toolkit and geometry engine
|
||||
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
@@ -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()
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
import math
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
from typing import NamedTuple, Optional, Union, Any
|
||||
from typing import NamedTuple, Optional, Union
|
||||
|
||||
import numpy as np
|
||||
|
||||
@@ -268,15 +268,6 @@ def get_helmert_transformation_parameters(ifc_file: ifcopenshell.file) -> Option
|
||||
return HelmertTransformation(e, n, h, xaa, xao, scale, factor_x, factor_y, factor_z)
|
||||
|
||||
|
||||
def get_crs(ifc_file: ifcopenshell.file) -> dict[str, Any]:
|
||||
"""Get CRS information from an IFC file."""
|
||||
if ifc_file.schema == "IFC2X3":
|
||||
return ifcopenshell.util.element.get_pset(ifc_file.by_type("IfcProject")[0], "ePSet_ProjectedCRS")
|
||||
for context in ifc_file.by_type("IfcGeometricRepresentationContext", include_subtypes=False):
|
||||
if operation := context.HasCoordinateOperation:
|
||||
return operation[0].TargetCRS.get_info()
|
||||
|
||||
|
||||
def auto_z2e(ifc_file: ifcopenshell.file, z: float, should_return_in_map_units: bool = True) -> float:
|
||||
"""Convert a Z coordinate to an elevation using model georeferencing data
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
# IfcOpenShell - IFC toolkit and geometry engine
|
||||
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
@@ -0,0 +1,180 @@
|
||||
# IfcOpenShell - IFC toolkit and geometry engine
|
||||
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
@@ -0,0 +1,142 @@
|
||||
# IfcOpenShell - IFC toolkit and geometry engine
|
||||
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
@@ -746,7 +746,7 @@ namespace {
|
||||
static std::string format_double(const double& d) {
|
||||
std::ostringstream oss;
|
||||
oss.imbue(std::locale::classic());
|
||||
oss << std::setprecision(std::numeric_limits<double>::max_digits10) << d;
|
||||
oss << std::setprecision(std::numeric_limits<double>::digits10) << d;
|
||||
const std::string str = oss.str();
|
||||
oss.str("");
|
||||
std::string::size_type e = str.find('e');
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
<!-- This file was generated with the assistance of an AI coding tool. -->
|
||||
# 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 <ifc_file> <command> [options] [--format json|text]
|
||||
```
|
||||
|
||||
The `--format` flag controls output. Default is `json`; use `text` for
|
||||
indented human-readable output.
|
||||
|
||||
## 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.
|
||||
|
||||
### 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 <ratio>` -- model-to-paper scale ratio (default: 0.01 = 1:100)
|
||||
- `--width-mm <mm>` -- paper width in mm (default: 297)
|
||||
- `--height-mm <mm>` -- paper height in mm (default: 420)
|
||||
- `--png-width <px>` -- raster output width in pixels (default: 1024)
|
||||
- `--png-height <px>` -- 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 <query>` -- 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 <meters>` -- minimum clearance distance to check
|
||||
- `--tolerance <meters>` -- 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.
|
||||
|
||||
## Error handling
|
||||
|
||||
Errors are written to stderr. Exit code is 0 on success, 1 on error.
|
||||
|
||||
## License
|
||||
|
||||
LGPLv3+ -- see the IfcOpenShell project license.
|
||||
@@ -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 <bruno@postle.net>
|
||||
#
|
||||
# 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
__version__ = version = "0.0.0"
|
||||
@@ -0,0 +1,378 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
# IfcQuery - IFC model interrogation CLI
|
||||
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
|
||||
#
|
||||
# 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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)
|
||||
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 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"],
|
||||
default="json",
|
||||
dest="output_format",
|
||||
help="Output format (default: json)",
|
||||
)
|
||||
|
||||
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: <ifc_file>.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: <ifc_file>.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()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user