Compare commits

..

20 Commits

Author SHA1 Message Date
Thomas Krijnen e42acdfa86 ifcquery plot subcommand 2026-03-01 21:01:09 +01:00
Bruno Postle 1801a768c1 Fix clipping when generating a wall 2026-03-01 17:07:50 +00:00
Bruno Postle 66f0b71c65 Add ifcmcp ifc_render tool and ifcquery render subcommand
Renders IFC model geometry to a PNG image using pyvista (off_screen).

ifcquery CLI:
  ifcquery <file> render [-o out.png] [--selector QUERY]
                         [--element ID[,ID...]] [--view iso|top|south|north|east|west]

ifcmcp MCP tool:
  ifc_render(selector, element_ids, view) -> list[ImageContent]
  Returns base64-encoded PNG as an MCP ImageContent block so the agent
  can inspect the model geometry inline.

Generated with the assistance of an AI coding tool.
2026-03-01 13:23:00 +00:00
Bruno Postle f4d7fda255 Support file paths in ifcedit project.append_asset
This now works:

  ifcedit run model.ifc project.append_asset --library library.ifc --element 5711

Generated with the assistance of an AI coding tool.
2026-02-28 07:19:51 +00:00
Bruno Postle c112c115ce Update README files
Generated with the assistance of an AI coding tool.
2026-02-23 23:30:07 +00:00
Bruno Postle 2a2ba45676 Add new ifcmcp tools to ifcchat tool schema
Adds ifc_validate, ifc_schedule, ifc_cost, ifc_schema, and ifc_quantify
to the hardcoded tool list in app.js to match the extended ifcmcp session.

Generated with the assistance of an AI coding tool.
2026-02-23 23:19:06 +00:00
Bruno Postle fd69c0534b Add validate, schedule, cost, schema, quantify to ifcquery/ifcedit/ifcmcp
ifcquery: validate [--rules], schedule [--depth N], cost [--depth N],
schema <EntityType>. ifcedit: quantify list/run subcommands using ifc5d
QTO rules. schedule and cost support max_depth to limit tree expansion,
replacing truncated levels with {truncated, count}. ifcmcp gains matching
session methods, @server.tool() decorators, and OpenAI tool schemas.

Generated with the assistance of an AI coding tool.
2026-02-23 23:11:35 +00:00
Thomas Krijnen 1226370b9e re-add AI generation disclosure 2026-02-21 09:37:29 +01:00
Thomas Krijnen 6e3eaa9b39 mv chat/ ifcchat 2026-02-21 09:34:42 +01:00
Bruno Postle 3ed2da23be Create missing CLI tools
Actually create the ifcquery, ifcedit and ifcmcp executables on pip
install instead of relying on `python3 -m ifcquery` style usage
2026-02-20 22:22:29 +00:00
Bruno Postle 249c498112 Add AGENTS.md contributor guide
Guidelines for external contributors using AI coding tools,
covering licensing, AI disclosure requirements, PR scope,
commit style, code formatting, and testing expectations.

Generated with the assistance of an AI coding tool.
2026-02-20 21:43:22 +00:00
Thomas Krijnen 2dde62803d Package ifcmcp as a HTML client-side chat app (#7697) 2026-02-20 20:54:16 +00:00
Bruno Postle d4ac3c36ce Add AI generation disclosure 2026-02-14 08:10:55 +00:00
Bruno Postle fecd4a810a black and ruff 2026-02-11 18:22:45 +00:00
Bruno Postle 060afb94b5 MCP wrapper for ifcquery and ifcedit
Model Context Protocol server implementation for AI agent access to IFC
projects
2026-02-10 22:32:43 +00:00
Bruno Postle b1f1de954d Rename ifcapi to ifcedit, README and black 2026-02-09 22:59:11 +00:00
Bruno Postle 12ed2d1ec3 Add ifcquery clash subcommand for geometric clash detection
Uses ifcopenshell.geom.tree API directly to check an element for
intersections and clearance violations against sibling elements
(--scope storey) or the entire model (--scope all).
2026-02-09 18:20:41 +00:00
Bruno Postle c77983ffe5 Add ifcquery relations subcommand for IFC relationship traversal
New subcommand: ifcquery <file> relations <element_id>
Returns all relationships for an element organised by category:
hierarchy (parent, container, aggregate, nest), children (contained,
parts, components, openings), type relationships, groups, systems,
zones, material, referenced structures, and connections/ports.
Empty categories are omitted from output.

Optional --traverse up flag walks the hierarchy from the element
up to IfcProject, returning the chain as a list.
2026-02-09 07:20:37 +00:00
Bruno Postle 78f26c2432 Add ifcapi CLI wrapper for ifcopenshell.api editor functions
Runtime introspection auto-discovers all API functions. Subcommands:
list (modules/functions), docs (parameter documentation), run (execute
with type-coerced CLI arguments). Output is JSON to stdout, supports
--dry-run validation and -o alternate output path.
2026-02-09 06:27:00 +00:00
Bruno Postle 39161e7256 Add ifcquery CLI tool for IFC model interrogation
Provides four subcommands for querying IFC models with JSON output:
- summary: schema, entity counts, project info
- tree: spatial hierarchy (Project > Site > Building > Storey > elements)
- info: deep element inspection (attributes, psets, type, material, container)
- select: filter elements using selector syntax
2026-02-08 23:24:48 +00:00
115 changed files with 8907 additions and 1839 deletions
-2
View File
@@ -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/
+1 -1
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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:
+44 -5
View File
@@ -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:
+2 -2
View File
@@ -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)
+1 -1
View File
@@ -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))
+180 -104
View File
@@ -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):
+1 -1
View File
@@ -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()
+2 -2
View File
@@ -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,
+142 -229
View File
@@ -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)
+4 -42
View File
@@ -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)
+49 -44
View File
@@ -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"
+46 -28
View File
@@ -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}')
"""
+2 -7
View File
@@ -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")
-1
View File
@@ -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)
+1 -3
View File
@@ -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:
-10
View File
@@ -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()
]
-4
View 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"]
-8
View File
@@ -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()
+3 -15
View File
@@ -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)
-18
View File
@@ -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())
+3 -35
View File
@@ -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.
+13 -2
View File
@@ -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:
+1 -2
View File
@@ -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)
+58 -67
View File
@@ -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(
-9
View File
@@ -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()
-1
View File
@@ -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()"}
+6 -7
View 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."
+44 -67
View File
@@ -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
+37 -16
View File
@@ -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}"
-34
View File
@@ -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
]
+70 -32
View File
@@ -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()
+72 -70
View File
@@ -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):
+21
View File
@@ -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
```
+320
View File
@@ -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}`);
}
})();
+108
View File
@@ -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);
}
};
+294
View File
@@ -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>
+248
View File
@@ -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)
- **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.
+20
View File
@@ -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"
+217
View File
@@ -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()
+161
View File
@@ -0,0 +1,161 @@
# 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 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 _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()]
+279
View File
@@ -0,0 +1,279 @@
# 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 = [f for f in formatted if f != "None"][0]
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
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 stripped.startswith(":"):
current_lines.append(stripped)
elif stripped.startswith(":") 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 stripped.startswith(":") or stripped == "":
break
lines.append(stripped)
return re.sub(r"\s+", " ", " ".join(lines).strip())
+37
View File
@@ -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)}
+148
View File
@@ -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
+33
View File
@@ -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"
+1
View File
@@ -0,0 +1 @@
# This file was generated with the assistance of an AI coding tool.
+63
View File
@@ -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.owner.settings
import ifcopenshell.api.project
import ifcopenshell.api.pset
import ifcopenshell.api.root
import ifcopenshell.api.spatial
import ifcopenshell.api.unit
import pytest
import ifcopenshell.api.material
@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)
+145
View File
@@ -0,0 +1,145 @@
# 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"}
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"
+104
View File
@@ -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
+100
View File
@@ -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
+87
View File
@@ -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
+97
View File
@@ -0,0 +1,97 @@
# This file was generated with the assistance of an AI coding tool.
import ifcopenshell
import ifcopenshell.api.pset
import ifcopenshell.api.project
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"}
+5 -2
View File
@@ -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 {
+35 -65
View File
@@ -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()) {
+29 -4
View File
@@ -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;
+294
View File
@@ -0,0 +1,294 @@
<!-- 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`, `ifcedit`, and `mcp`.
## 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_load
Open an IFC file into memory.
```
ifc_load(path="/path/to/model.ifc")
-> "Loaded /path/to/model.ifc: schema IFC4, 1847 entities"
```
#### 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_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}`.
### 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.
+20
View File
@@ -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"
+10
View File
@@ -0,0 +1,10 @@
# 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()
+447
View File
@@ -0,0 +1,447 @@
# This file was generated with the assistance of an AI coding tool.
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any, Callable
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 cost as cost_mod
from ifcquery import info, relations, render as render_mod, schedule, schema, select, summary, tree
from ifcquery import validate as validate_mod
# inside ifcmcp/core.py
import json
from typing import Any
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
# 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)
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,
)
# ------------------------
# 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_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,
)
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_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,
},
},
]
+59
View File
@@ -0,0 +1,59 @@
# This file was generated with the assistance of an AI coding tool.
from __future__ import annotations
from typing import Any, Mapping
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)}
+144
View File
@@ -0,0 +1,144 @@
# 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,
)
# ---- 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)
@server.tool(structured_output=False)
def ifc_render(
selector: str = "",
element_ids: list[int] | None = None,
view: str = "iso",
) -> 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.
: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``.
"""
png_bytes = session.ifc_render(selector=selector, element_ids=element_ids, view=view)
return [ImageContent(type="image", data=base64.b64encode(png_bytes).decode(), mimeType="image/png")]
return server
+35
View File
@@ -0,0 +1,35 @@
[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"
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"
+1
View File
@@ -0,0 +1 @@
# This file was generated with the assistance of an AI coding tool.
+64
View File
@@ -0,0 +1,64 @@
# 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
import ifcmcp.server as server_mod
@pytest.fixture
def model():
"""Create an 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(autouse=True)
def reset_server_state():
"""Reset module-level state before each test."""
server_mod._model = None
server_mod._model_path = None
yield
server_mod._model = None
server_mod._model_path = None
@pytest.fixture
def loaded_model(model):
"""Set the server module state to an in-memory model (no file path)."""
server_mod._model = model
server_mod._model_path = None
return model
+100
View File
@@ -0,0 +1,100 @@
# This file was generated with the assistance of an AI coding tool.
import json
import pytest
from ifcmcp.server import ifc_docs, ifc_edit, ifc_list
class TestNoModel:
def test_edit_no_model(self):
with pytest.raises(ValueError, match="No model loaded"):
ifc_edit("root.create_entity")
class TestList:
def test_list_all_modules(self, loaded_model):
result = 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_model):
result = 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_model):
result = 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_model):
result = 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_model):
with pytest.raises(ValueError):
ifc_docs("no_dot_here")
class TestEdit:
def test_create_entity(self, loaded_model):
result = 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_model):
result = ifc_edit("root.create_entity", "{}")
assert result["ok"] is True
def test_unknown_function(self, loaded_model):
result = ifc_edit("root.nonexistent", "{}")
assert result["ok"] is False
assert "Cannot find" in result["error"]
def test_unknown_parameter(self, loaded_model):
result = 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_model):
with pytest.raises(json.JSONDecodeError):
ifc_edit("root.create_entity", "not json")
def test_edit_does_not_save(self, loaded_model, tmp_path):
"""Verify that ifc_edit mutates the in-memory model but does not write to disk."""
import ifcmcp.server as server_mod
path = str(tmp_path / "test.ifc")
loaded_model.write(path)
server_mod._model_path = path
before_count = sum(1 for _ in loaded_model)
ifc_edit("root.create_entity", json.dumps({"ifc_class": "IfcWall", "name": "Unsaved"}))
after_count = sum(1 for _ in loaded_model)
assert after_count == before_count + 1
# Re-read the file — it should not have the new entity
import ifcopenshell
on_disk = ifcopenshell.open(path)
disk_count = sum(1 for _ in on_disk)
assert disk_count == before_count
def test_assign_container(self, loaded_model):
wall = loaded_model.by_type("IfcWall")[0]
storey = loaded_model.by_type("IfcBuildingStorey")[0]
result = ifc_edit(
"spatial.assign_container",
json.dumps({"products": str(wall.id()), "relating_structure": str(storey.id())}),
)
assert result["ok"] is True
+113
View File
@@ -0,0 +1,113 @@
# This file was generated with the assistance of an AI coding tool.
import pytest
from ifcmcp.server import ifc_info, ifc_relations, ifc_select, ifc_summary, ifc_tree
class TestNoModel:
"""All query tools should fail when no model is loaded."""
def test_summary_no_model(self):
with pytest.raises(ValueError, match="No model loaded"):
ifc_summary()
def test_tree_no_model(self):
with pytest.raises(ValueError, match="No model loaded"):
ifc_tree()
def test_info_no_model(self):
with pytest.raises(ValueError, match="No model loaded"):
ifc_info(1)
def test_select_no_model(self):
with pytest.raises(ValueError, match="No model loaded"):
ifc_select("IfcWall")
def test_relations_no_model(self):
with pytest.raises(ValueError, match="No model loaded"):
ifc_relations(1)
class TestSummary:
def test_schema(self, loaded_model):
result = ifc_summary()
assert result["schema"] == "IFC4"
def test_total_entities(self, loaded_model):
result = ifc_summary()
assert result["total_entities"] > 0
def test_project_name(self, loaded_model):
result = ifc_summary()
assert result["project"]["name"] == "TestProject"
def test_type_counts(self, loaded_model):
result = ifc_summary()
assert result["types"]["IfcWall"] == 1
assert result["types"]["IfcSlab"] == 1
class TestTree:
def test_root_is_project(self, loaded_model):
result = ifc_tree()
assert result["type"] == "IfcProject"
assert result["name"] == "TestProject"
def test_hierarchy_depth(self, loaded_model):
result = 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_model):
wall = loaded_model.by_type("IfcWall")[0]
result = ifc_info(wall.id())
assert result["id"] == wall.id()
assert result["type"] == "IfcWall"
def test_invalid_id(self, loaded_model):
with pytest.raises(Exception):
ifc_info(999999)
class TestSelect:
def test_select_walls(self, loaded_model):
result = ifc_select("IfcWall")
assert len(result) == 1
assert result[0]["type"] == "IfcWall"
assert result[0]["name"] == "Wall001"
def test_select_slabs(self, loaded_model):
result = ifc_select("IfcSlab")
assert len(result) == 1
assert result[0]["name"] == "Slab001"
def test_select_no_match(self, loaded_model):
result = ifc_select("IfcWindow")
assert result == []
class TestRelations:
def test_wall_relations(self, loaded_model):
wall = loaded_model.by_type("IfcWall")[0]
result = ifc_relations(wall.id())
assert result["id"] == wall.id()
assert result["type"] == "IfcWall"
assert "hierarchy" in result
def test_traverse_up(self, loaded_model):
wall = loaded_model.by_type("IfcWall")[0]
result = 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_model):
wall = loaded_model.by_type("IfcWall")[0]
result = ifc_relations(wall.id(), traverse="")
assert isinstance(result, dict)
+29
View File
@@ -0,0 +1,29 @@
# This file was generated with the assistance of an AI coding tool.
from ifcmcp.server import server
class TestServerRegistration:
def test_server_name(self):
assert server.name == "ifc-mcp"
def test_all_tools_registered(self):
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"
def test_tool_count(self):
tools = server._tool_manager.list_tools()
assert len(tools) == 11
+46
View File
@@ -0,0 +1,46 @@
# This file was generated with the assistance of an AI coding tool.
import pytest
import ifcmcp.server as server_mod
from ifcmcp.server import ifc_load, ifc_save
class TestLoad:
def test_load_file(self, model_file):
result = ifc_load(model_file)
assert "IFC4" in result
assert server_mod._model is not None
assert server_mod._model_path == model_file
def test_load_sets_entity_count(self, model_file):
result = ifc_load(model_file)
assert "entities" in result
def test_load_nonexistent_file(self):
with pytest.raises(Exception):
ifc_load("/nonexistent/path/model.ifc")
class TestSave:
def test_save_no_model(self):
with pytest.raises(ValueError, match="No model loaded"):
ifc_save()
def test_save_overwrites_original(self, model_file):
ifc_load(model_file)
result = ifc_save()
assert model_file in result
def test_save_to_new_path(self, model_file, tmp_path):
ifc_load(model_file)
new_path = str(tmp_path / "output.ifc")
result = ifc_save(new_path)
assert new_path in result
import ifcopenshell
reloaded = ifcopenshell.open(new_path)
assert reloaded.schema == "IFC4"
def test_save_no_path_no_original(self, loaded_model):
with pytest.raises(ValueError, match="No path specified"):
ifc_save()
@@ -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.
@@ -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
+1 -1
View File
@@ -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');
+380
View File
@@ -0,0 +1,380 @@
<!-- 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.
### 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.
+20
View File
@@ -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"
+320
View File
@@ -0,0 +1,320 @@
# 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 cost as cost_mod
from ifcquery import info, relations, render as render_mod, schedule, schema, select, summary, tree, plot
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)"
)
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 == "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)
# Choose default output extension based on out-format
base = os.path.splitext(args.ifc_file)[0]
if args.out_format == "svg":
out_path = args.output or (base + ".svg")
elif args.out_format == "png":
out_path = args.output or (base + ".png")
else:
out_path = args.output # unused; base64 prints to stdout via format_output
png_bytes = 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
)
with open(out_path, "wb") as f:
f.write(png_bytes)
print(f"Saved render to {out_path}", file=sys.stderr)
return
print(format_output(result, args.output_format))
if __name__ == "__main__":
main()
+167
View File
@@ -0,0 +1,167 @@
# 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 multiprocessing
import sys
from typing import Any
import ifcopenshell
import ifcopenshell.geom
import ifcopenshell.util.element
def _ref(element: ifcopenshell.entity_instance) -> dict[str, Any]:
"""Serialize an element to a compact reference dict."""
result: dict[str, Any] = {"id": element.id(), "type": element.is_a()}
if hasattr(element, "Name") and element.Name:
result["name"] = element.Name
return result
def _get_scope_elements(
model: ifcopenshell.file, element: ifcopenshell.entity_instance, scope: str
) -> tuple[set[ifcopenshell.entity_instance], str]:
"""Return set of elements to check against and the effective scope used.
Returns (elements, effective_scope) where effective_scope may differ from
the requested scope if fallback was needed.
"""
if scope == "storey":
container = ifcopenshell.util.element.get_container(element)
if container is not None:
siblings = set(ifcopenshell.util.element.get_contained(container))
siblings.discard(element)
return siblings, "storey"
else:
print(
f"Warning: Element #{element.id()} has no spatial container, falling back to --scope all",
file=sys.stderr,
)
# scope == "all" or fallback
elements = set(model.by_type("IfcElement"))
elements -= set(model.by_type("IfcFeatureElement"))
elements.discard(element)
return elements, "all"
def _build_tree(model: ifcopenshell.file, elements: set[ifcopenshell.entity_instance]) -> ifcopenshell.geom.tree | None:
"""Build geometry tree for given elements using iterator.
Returns None if iterator fails to initialize (no geometry available).
"""
geom_settings = ifcopenshell.geom.settings()
geom_settings.set("use-world-coords", True)
geom_tree = ifcopenshell.geom.tree()
iterator = ifcopenshell.geom.iterator(geom_settings, model, multiprocessing.cpu_count(), include=list(elements))
if not iterator.initialize():
return None
while True:
geom_tree.add_element(iterator.get())
if not iterator.next():
break
return geom_tree
def _format_clash(clash_result, geom_tree: ifcopenshell.geom.tree, model: ifcopenshell.file) -> dict[str, Any]:
"""Format a single clash result to dict."""
# clash result .a/.b are C++ wrapper entity_instances without .Name;
# look up the Python entity from the model by id for proper serialization
other = model.by_id(clash_result.b.id())
return {
"element": _ref(other),
"type": geom_tree.get_clash_type(clash_result.clash_type),
"distance": clash_result.distance,
"p1": list(clash_result.p1),
"p2": list(clash_result.p2),
}
def clash(
model: ifcopenshell.file,
element: ifcopenshell.entity_instance,
clearance: float | None = None,
tolerance: float = 0.002,
scope: str = "storey",
) -> dict[str, Any]:
"""Check element for geometric clashes against other elements.
:param model: The IFC model.
:param element: The element to check.
:param clearance: Minimum clearance distance; if provided, runs clearance check.
:param tolerance: Intersection tolerance in meters (default 0.002).
:param scope: Which elements to check against: "storey" or "all".
:return: Dict with clash results suitable for JSON serialization.
"""
result: dict[str, Any] = {"element": _ref(element)}
# Get scope elements
scope_elements, effective_scope = _get_scope_elements(model, element, scope)
result["scope"] = effective_scope
if not scope_elements:
result["pass"] = True
result["checks"] = {"intersection": {"pass": True, "tolerance": tolerance, "clashes": []}}
if clearance is not None:
result["checks"]["clearance"] = {"pass": True, "clearance": clearance, "clashes": []}
return result
# Build geometry tree for target element + scope elements
all_elements = scope_elements | {element}
geom_tree = _build_tree(model, all_elements)
if geom_tree is None:
result["pass"] = None
result["error"] = f"No geometry for element #{element.id()}"
return result
# Run intersection check
intersection_clashes = geom_tree.clash_intersection_many(
[element], list(scope_elements), tolerance=tolerance, check_all=True
)
intersection_results = [_format_clash(c, geom_tree, model) for c in intersection_clashes]
checks: dict[str, Any] = {
"intersection": {
"pass": len(intersection_results) == 0,
"tolerance": tolerance,
"clashes": intersection_results,
}
}
all_pass = len(intersection_results) == 0
# Run clearance check if requested
if clearance is not None:
clearance_clashes = geom_tree.clash_clearance_many(
[element], list(scope_elements), clearance=clearance, check_all=True
)
clearance_results = [_format_clash(c, geom_tree, model) for c in clearance_clashes]
checks["clearance"] = {
"pass": len(clearance_results) == 0,
"clearance": clearance,
"clashes": clearance_results,
}
if clearance_results:
all_pass = False
result["pass"] = all_pass
result["checks"] = checks
return result
+45
View File
@@ -0,0 +1,45 @@
# This file was generated with the assistance of an AI coding tool.
from __future__ import annotations
from typing import Any
import ifcopenshell
import ifcopenshell.util.cost as cost_util
def _cost_item_to_dict(item: ifcopenshell.entity_instance, max_depth: int | None, depth: int) -> dict[str, Any]:
raw_values = cost_util.get_cost_values(item)
values = [{"formula": v.get("label", ""), "category": v.get("category")} for v in raw_values]
if max_depth is not None and depth >= max_depth:
child_count = len(cost_util.get_nested_cost_items(item))
subitems = {"truncated": True, "count": child_count} if child_count else []
else:
subitems = [_cost_item_to_dict(sub, max_depth, depth + 1) for sub in cost_util.get_nested_cost_items(item)]
return {
"id": item.id(),
"name": getattr(item, "Name", None),
"values": values,
"subitems": subitems,
}
def cost(model: ifcopenshell.file, max_depth: int | None = None) -> list[dict[str, Any]]:
"""Return a list of IfcCostSchedule entries with nested cost item trees.
max_depth limits how many levels of subitems are expanded (None = unlimited).
At the cutoff level, subitems is replaced with {"truncated": True, "count": N}.
"""
result = []
for cost_schedule in model.by_type("IfcCostSchedule"):
items = [_cost_item_to_dict(i, max_depth, depth=1) for i in cost_util.get_root_cost_items(cost_schedule)]
result.append(
{
"id": cost_schedule.id(),
"name": getattr(cost_schedule, "Name", None),
"predefined_type": getattr(cost_schedule, "PredefinedType", None),
"items": items,
}
)
return result
+118
View File
@@ -0,0 +1,118 @@
# 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
from typing import Any
import ifcopenshell
import ifcopenshell.util.element
import ifcopenshell.util.placement
def _serialize_attribute(value: Any) -> Any:
"""Convert an IFC attribute value to a JSON-serializable form."""
if isinstance(value, ifcopenshell.entity_instance):
return {"id": value.id(), "type": value.is_a()}
if isinstance(value, tuple):
return [_serialize_attribute(v) for v in value]
return value
def _material_to_dict(material: ifcopenshell.entity_instance | None) -> dict[str, Any] | None:
"""Convert a material entity to a summary dict."""
if material is None:
return None
result: dict[str, Any] = {
"id": material.id(),
"type": material.is_a(),
}
if hasattr(material, "Name"):
result["name"] = material.Name
return result
def info(model: ifcopenshell.file, element: ifcopenshell.entity_instance) -> dict[str, Any]:
"""Return deep inspection data for an element."""
result: dict[str, Any] = {
"id": element.id(),
"type": element.is_a(),
}
# Direct attributes via get_info() which returns a dict of all attributes
element_info = element.get_info()
attrs = {}
for key, value in element_info.items():
if key in ("id", "type"):
continue
attrs[key] = _serialize_attribute(value)
result["attributes"] = attrs
# Property sets and quantity sets
try:
psets = ifcopenshell.util.element.get_psets(element)
if psets:
result["property_sets"] = psets
except Exception:
pass
# Element type
try:
element_type = ifcopenshell.util.element.get_type(element)
if element_type:
type_info: dict[str, Any] = {
"id": element_type.id(),
"type": element_type.is_a(),
}
if hasattr(element_type, "Name"):
type_info["name"] = element_type.Name
result["element_type"] = type_info
except Exception:
pass
# Material
try:
material = ifcopenshell.util.element.get_material(element)
mat_dict = _material_to_dict(material)
if mat_dict:
result["material"] = mat_dict
except Exception:
pass
# Spatial container
try:
container = ifcopenshell.util.element.get_container(element)
if container:
result["container"] = {
"id": container.id(),
"type": container.is_a(),
"name": container.Name if hasattr(container, "Name") else None,
}
except Exception:
pass
# Placement (as 4x4 matrix)
try:
if hasattr(element, "ObjectPlacement") and element.ObjectPlacement:
matrix = ifcopenshell.util.placement.get_local_placement(element.ObjectPlacement)
result["placement"] = matrix.tolist()
except Exception:
pass
return result
+238
View File
@@ -0,0 +1,238 @@
# 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
from io import BytesIO
import os
from typing import Any
import ifcopenshell
import ifcopenshell.geom
import ifcopenshell.util.selector
import ifcopenshell.draw
from xml.etree.ElementTree import ElementTree, Element, SubElement, register_namespace
try:
import cairosvg # type: ignore
_HAS_CAIROSVG = True
except Exception:
_HAS_CAIROSVG = False
try:
from PIL import Image # type: ignore
_HAS_PIL = True
except Exception:
_HAS_PIL = False
VIEWS = ("floorplan", "elevation", "section", "auto")
OUTPUT_FORMATS = ("svg", "png", "base64")
def _escape_css_attr(name: str) -> str:
# CSS attribute selectors must escape ':' (e.g. ifc:guid -> ifc\:guid)
return name.replace(":", "\\:")
def _highlight_css_from_ids(model: ifcopenshell.file, element_ids: list[int]) -> str:
guids: list[str] = []
for sid in element_ids:
e = model.by_id(int(sid))
if e is None:
continue
gid = getattr(e, "GlobalId", None)
if isinstance(gid, str) and gid:
guids.append(gid)
if not guids:
return ""
attr = _escape_css_attr("ifc:guid")
css = [
"/* Auto-highlight injected by ifcquery.plot */",
f'[{attr}] path {{ opacity: 0.10; }}',
f'[{attr}] text {{ opacity: 0.25; }}',
]
for gid in guids:
css.append(f'[{attr}="{gid}"] path {{ opacity: 1.0; stroke: #d00; stroke-width: 0.25; }}')
css.append(f'[{attr}="{gid}"] text {{ opacity: 1.0; fill: #d00; }}')
return "\n".join(css) + "\n"
def _make_filtered_iterator(model: ifcopenshell.file, include_elements: list[Any]) -> ifcopenshell.geom.iterator:
# Avoid multiprocessing in WASM; os.cpu_count is good enough.
n_threads = os.cpu_count() or 1
# These flags mirror the defaults used by ifcopenshell.draw in v0.8.x.
geom_settings = ifcopenshell.geom.settings(
REORIENT_SHELLS=False,
ELEMENT_HIERARCHY=True,
)
# IfcOpenShell wrapper constants may live in different places across builds.
wrapper = getattr(ifcopenshell, "ifcopenshell_wrapper", None)
if wrapper is not None:
try:
geom_settings.set("iterator-output", wrapper.NATIVE)
except Exception:
pass
try:
geom_settings.set("apply-default-materials", True)
except Exception:
pass
try:
geom_settings.set("dimensionality", wrapper.SURFACES_AND_SOLIDS)
except Exception:
pass
return ifcopenshell.geom.iterator(geom_settings, model, n_threads, include=include_elements)
def plot(
model: ifcopenshell.file,
*,
output_format: str = "png",
selector: str | None = None,
element_ids: list[int] | None = None,
view: str = "floorplan",
# SVG / page sizing (draw works in mm coordinates)
width_mm: float = 297.0,
height_mm: float = 420.0,
scale: float = 1.0 / 100.0,
merge_projection: bool = True,
# PNG sizing (only for output_format png/base64)
png_width: int = 1024,
png_height: int = 1024,
) -> bytes | dict[str, Any]:
"""
Plot IFC model as SVG (via ifcopenshell.draw) or PNG/base64 (via CairoSVG).
Args:
model: In-memory IFC model.
output_format: 'svg' | 'png' | 'base64'
- 'svg' -> returns SVG bytes
- 'png' -> returns PNG bytes
- 'base64'-> returns dict: {mime, png_b64, width, height, view}
selector: ifcopenshell selector query to restrict plotted elements.
element_ids: STEP ids to highlight; non-highlighted geometry is faded.
view: One of VIEWS ('floorplan', 'elevation', 'section', 'auto').
width_mm, height_mm: Page size in mm.
scale: Model-to-paper scale (0.01 means 1:100).
merge_projection: Passed through to ifcopenshell.draw.main.
png_width, png_height: Raster size in pixels for png/base64 outputs.
Raises:
ImportError: if ifcopenshell.draw or CairoSVG is not available (as required).
ValueError: invalid args or selector matches nothing.
"""
if output_format not in OUTPUT_FORMATS:
raise ValueError(f"output_format must be one of {OUTPUT_FORMATS}, got {output_format!r}")
if view not in VIEWS:
raise ValueError(f"view must be one of {VIEWS}, got {view!r}")
if not _HAS_DRAW:
raise ImportError("ifcopenshell.draw is not available in this environment.")
# Configure draw settings
settings = ifcopenshell.draw.draw_settings(
auto_floorplan=(view in ("floorplan", "auto")),
auto_elevation=(view in ("elevation", "auto")),
auto_section=(view in ("section", "auto")),
width=width_mm,
height=height_mm,
scale=scale,
css="",
)
# Optional highlight CSS overlay
if element_ids:
settings.css = _highlight_css_from_ids(model, element_ids)
# Optional element restriction via selector -> custom iterator
iterators: tuple[Any, ...] = ()
if selector:
include_elements = list(ifcopenshell.util.selector.filter_elements(model, selector))
if not include_elements:
raise ValueError(f"Selector {selector!r} matched no elements")
it = _make_filtered_iterator(model, include_elements)
iterators = (it,)
# If we explicitly include elements, don't rely on exclude_entities (best-effort).
settings.exclude_entities = ""
# Generate SVG
svg_bytes = ifcopenshell.draw.main(
settings,
files=[model],
iterators=iterators,
merge_projection=merge_projection,
)
register_namespace('',"http://www.w3.org/2000/svg")
def svg_split(f):
x = ElementTree(file=f)
svg = x.getroot()
resources = []
for child in svg:
if child.tag == "{http://www.w3.org/2000/svg}g":
root = Element(svg.tag, svg.attrib)
n = ElementTree(root)
for r in (resources + [child]):
root.append(r)
b = BytesIO()
n.write(b,
xml_declaration = True,
encoding = 'utf-8',
method = 'xml')
yield b.getvalue()
else:
resources.append(child)
if output_format == "svg":
return svg_bytes
# Need CairoSVG for png/base64
if not _HAS_CAIROSVG:
raise ImportError("CairoSVG is not installed. Install with: pip install cairosvg")
composite = None
png_bytes = None
svgs = list(svg_split(BytesIO(svg_bytes)))
for i, svgb in enumerate(svgs):
png_bytes = cairosvg.svg2png(bytestring=svgb, output_width=png_width, output_height=png_height)
if len(svgs) == 1:
break
# Need Pillow for concatenating images
if not _HAS_PIL:
raise ImportError("Pillow is not installed. Install with: pip install Pillow")
if composite is None:
composite = Image.new('RGBA', (png_width, png_height * len(svgs)))
img = Image.open(BytesIO(png_bytes))
composite.paste(img, (0, png_height * i))
if composite is not None:
b = BytesIO()
composite.save(b, 'png')
png_bytes = b.getvalue()
return png_bytes
+169
View File
@@ -0,0 +1,169 @@
# 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
from typing import Any
import ifcopenshell
import ifcopenshell.util.element
import ifcopenshell.util.system
def _ref(element: ifcopenshell.entity_instance) -> dict[str, Any]:
"""Serialize an element to a compact reference dict."""
result: dict[str, Any] = {"id": element.id(), "type": element.is_a()}
if hasattr(element, "Name") and element.Name:
result["name"] = element.Name
return result
def _ref_or_none(element: ifcopenshell.entity_instance | None) -> dict[str, Any] | None:
return _ref(element) if element is not None else None
def _ref_list(elements) -> list[dict[str, Any]]:
return [_ref(e) for e in elements]
def _traverse_up(element: ifcopenshell.entity_instance) -> list[dict[str, Any]]:
"""Walk the hierarchy from element up to IfcProject."""
chain = [_ref(element)]
current = element
while True:
parent = ifcopenshell.util.element.get_parent(current)
if parent is None:
break
chain.append(_ref(parent))
current = parent
return chain
def _all_relations(model: ifcopenshell.file, element: ifcopenshell.entity_instance) -> dict[str, Any]:
"""Collect all relationships for an element."""
result: dict[str, Any] = {
"id": element.id(),
"type": element.is_a(),
}
if hasattr(element, "Name") and element.Name:
result["name"] = element.Name
# Hierarchy (upward)
hierarchy: dict[str, Any] = {}
parent = ifcopenshell.util.element.get_parent(element)
if parent is not None:
hierarchy["parent"] = _ref(parent)
container = ifcopenshell.util.element.get_container(element)
if container is not None:
hierarchy["container"] = _ref(container)
aggregate = ifcopenshell.util.element.get_aggregate(element)
if aggregate is not None:
hierarchy["aggregate"] = _ref(aggregate)
nest = ifcopenshell.util.element.get_nest(element)
if nest is not None:
hierarchy["nest"] = _ref(nest)
filled_void = ifcopenshell.util.element.get_filled_void(element)
if filled_void is not None:
hierarchy["filled_void"] = _ref(filled_void)
voided_element = ifcopenshell.util.element.get_voided_element(element)
if voided_element is not None:
hierarchy["voided_element"] = _ref(voided_element)
if hierarchy:
result["hierarchy"] = hierarchy
# Children (downward)
children: dict[str, Any] = {}
contained = ifcopenshell.util.element.get_contained(element)
if contained:
children["contained"] = _ref_list(contained)
parts = ifcopenshell.util.element.get_parts(element)
if parts:
children["parts"] = _ref_list(parts)
components = ifcopenshell.util.element.get_components(element)
if components:
children["components"] = _ref_list(components)
openings = list(ifcopenshell.util.element.get_openings(element))
if openings:
children["openings"] = _ref_list(openings)
if children:
result["children"] = children
# Type relationship
type_relationship: dict[str, Any] = {}
element_type = ifcopenshell.util.element.get_type(element)
if element_type is not None:
type_relationship["type_of"] = _ref(element_type)
try:
occurrences = ifcopenshell.util.element.get_types(element)
if occurrences:
type_relationship["occurrences"] = _ref_list(occurrences)
except Exception:
pass
if type_relationship:
result["type_relationship"] = type_relationship
# Groups
groups = ifcopenshell.util.element.get_groups(element)
if groups:
result["groups"] = _ref_list(groups)
# Systems
systems = ifcopenshell.util.system.get_element_systems(element)
if systems:
result["systems"] = _ref_list(systems)
# Zones
zones = ifcopenshell.util.system.get_element_zones(element)
if zones:
result["zones"] = _ref_list(zones)
# Material
material = ifcopenshell.util.element.get_material(element)
if material is not None:
result["material"] = _ref(material)
# Referenced structures
referenced = ifcopenshell.util.element.get_referenced_structures(element)
if referenced:
result["referenced_structures"] = _ref_list(referenced)
# Connections
connections: dict[str, Any] = {}
connected_to = ifcopenshell.util.system.get_connected_to(element)
if connected_to:
connections["connected_to"] = _ref_list(connected_to)
connected_from = ifcopenshell.util.system.get_connected_from(element)
if connected_from:
connections["connected_from"] = _ref_list(connected_from)
ports = ifcopenshell.util.system.get_ports(element)
if ports:
connections["ports"] = _ref_list(ports)
if connections:
result["connections"] = connections
return result
def relations(
model: ifcopenshell.file, element: ifcopenshell.entity_instance, traverse: str | None = None
) -> dict[str, Any] | list[dict[str, Any]]:
"""Return relationships for an element, or hierarchy chain if traverse='up'."""
if traverse == "up":
return _traverse_up(element)
return _all_relations(model, element)
+183
View File
@@ -0,0 +1,183 @@
# 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 multiprocessing
import os
import tempfile
import ifcopenshell
import ifcopenshell.geom
import ifcopenshell.util.selector
try:
import numpy as np
import pyvista as pv
_HAS_PYVISTA = True
except ImportError:
_HAS_PYVISTA = False
VIEWS = ("iso", "top", "south", "north", "east", "west")
def _apply_view(plotter: "pv.Plotter", view: str) -> None:
"""Set the camera to the requested named view. Z is up (IFC convention)."""
if view == "top":
plotter.view_xy()
elif view == "south":
# Camera at -Y looking toward +Y (south face of building)
plotter.view_xz(negative=True)
elif view == "north":
plotter.view_xz(negative=False)
elif view == "east":
plotter.view_yz(negative=False)
elif view == "west":
plotter.view_yz(negative=True)
else:
plotter.view_isometric()
# Ensure Z is world up for elevation views
if view not in ("top",):
plotter.camera.up = (0, 0, 1)
def _add_shape(
shape: object,
plotter: "pv.Plotter",
highlight_ids: frozenset[int] | None,
) -> None:
"""Triangulate and add a geometry shape to the plotter."""
geom = shape.geometry
verts = np.array(geom.verts, dtype=float).reshape(-1, 3)
if verts.size == 0:
return
faces = np.array(geom.faces, dtype=int).reshape(-1, 3)
material_ids = np.array(geom.material_ids, dtype=int)
is_subject = highlight_ids is not None and shape.product.id() in highlight_ids
for midx, mat in enumerate(geom.materials):
tri_mask = material_ids == midx
if not np.any(tri_mask):
continue
sub_faces = faces[tri_mask]
faces_pv = np.hstack([np.full((sub_faces.shape[0], 1), 3, dtype=int), sub_faces]).ravel()
mesh = pv.PolyData(verts, faces_pv)
if highlight_ids is not None and not is_subject:
color = (180, 180, 180)
opacity = 0.10
else:
diffuse = np.clip(np.array(mat.diffuse.components), 0.0, 1.0)
color = tuple((diffuse * 255).astype(np.uint8))
transparency = mat.transparency if mat.transparency == mat.transparency else 0.0
opacity = float(np.clip(1.0 - transparency, 0.0, 1.0))
plotter.add_mesh(mesh, color=color, opacity=opacity, show_edges=False)
def render(
model: ifcopenshell.file,
selector: str | None = None,
element_ids: list[int] | None = None,
view: str = "iso",
) -> bytes:
"""Render IFC model geometry to a PNG image.
:param model: The in-memory IFC model.
:param selector: ifcopenshell selector to restrict rendered elements
(e.g. ``'IfcWall'`` or ``'IfcBuildingStorey[Name="Ground Floor"]'``).
When omitted the whole model is rendered.
:param element_ids: Step IDs of elements to highlight. The rest of the
model is rendered in translucent grey so the highlighted elements
stand out.
:param view: Camera angle: ``iso``, ``top``, ``south``, ``north``,
``east``, or ``west``. Defaults to ``iso``.
:return: PNG image as raw bytes.
:raises ImportError: If pyvista is not installed.
:raises ValueError: If the selector matches nothing or the model has no
renderable geometry.
"""
if not _HAS_PYVISTA:
raise ImportError("pyvista is not installed. Install with: pip install pyvista")
settings = ifcopenshell.geom.settings()
settings.set("use-world-coords", True)
# Exclude 'Clearance' subcontexts (door/window operation zones) from rendering.
clearance_ids = {
c.id()
for c in model.by_type("IfcGeometricRepresentationSubContext")
if c.ContextIdentifier == "Clearance"
}
if clearance_ids:
ctx_ids = [
c.id()
for c in model.by_type("IfcGeometricRepresentationContext")
if c.id() not in clearance_ids
]
if ctx_ids:
settings.set("context-ids", ctx_ids)
if selector:
include_elements = list(ifcopenshell.util.selector.filter_elements(model, selector))
if not include_elements:
raise ValueError(f"Selector {selector!r} matched no elements")
iterator = ifcopenshell.geom.iterator(
settings,
model,
multiprocessing.cpu_count(),
include=include_elements,
)
else:
exclude = list(model.by_type("IfcOpeningElement"))
iterator = ifcopenshell.geom.iterator(
settings,
model,
multiprocessing.cpu_count(),
exclude=exclude if exclude else None,
)
if not iterator.initialize():
raise ValueError("No renderable geometry found in model (or selector matched nothing)")
plotter = pv.Plotter(off_screen=True, window_size=(1280, 960))
plotter.background_color = "white"
while True:
_add_shape(iterator.get(), plotter, highlight_ids=frozenset(element_ids) if element_ids else None)
if not iterator.next():
break
plotter.reset_camera()
_apply_view(plotter, view)
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".png")
os.close(tmp_fd)
try:
plotter.show(screenshot=tmp_path, auto_close=True)
with open(tmp_path, "rb") as f:
return f.read()
finally:
try:
os.unlink(tmp_path)
except OSError:
pass
+56
View File
@@ -0,0 +1,56 @@
# This file was generated with the assistance of an AI coding tool.
from __future__ import annotations
from typing import Any
import ifcopenshell
import ifcopenshell.util.sequence as seq
def _task_to_dict(task: ifcopenshell.entity_instance, max_depth: int | None, depth: int) -> dict[str, Any]:
task_time = task.TaskTime
start = None
finish = None
if task_time:
start = task_time.ScheduleStart
finish = task_time.ScheduleFinish
outputs = []
for product in seq.get_task_outputs(task):
outputs.append({"id": product.id(), "type": product.is_a(), "name": getattr(product, "Name", None)})
if max_depth is not None and depth >= max_depth:
child_count = len(seq.get_nested_tasks(task))
subtasks = {"truncated": True, "count": child_count} if child_count else []
else:
subtasks = [_task_to_dict(sub, max_depth, depth + 1) for sub in seq.get_nested_tasks(task)]
return {
"id": task.id(),
"name": getattr(task, "Name", None),
"start": start,
"finish": finish,
"is_milestone": bool(task.IsMilestone) if hasattr(task, "IsMilestone") else False,
"outputs": outputs,
"subtasks": subtasks,
}
def schedule(model: ifcopenshell.file, max_depth: int | None = None) -> list[dict[str, Any]]:
"""Return a list of IfcWorkSchedule entries with nested task trees.
max_depth limits how many levels of subtasks are expanded (None = unlimited).
At the cutoff level, subtasks is replaced with {"truncated": True, "count": N}.
"""
result = []
for work_schedule in model.by_type("IfcWorkSchedule"):
tasks = [_task_to_dict(t, max_depth, depth=1) for t in seq.get_root_tasks(work_schedule)]
result.append(
{
"id": work_schedule.id(),
"name": getattr(work_schedule, "Name", None),
"predefined_type": getattr(work_schedule, "PredefinedType", None),
"tasks": tasks,
}
)
return result
+19
View File
@@ -0,0 +1,19 @@
# This file was generated with the assistance of an AI coding tool.
from __future__ import annotations
from typing import Any
import ifcopenshell
import ifcopenshell.util.doc
def schema(model: ifcopenshell.file, entity_type: str) -> dict[str, Any]:
"""Return IFC class documentation for entity_type from model's schema version."""
schema_name = model.schema
try:
doc = ifcopenshell.util.doc.get_entity_doc(schema_name, entity_type)
except Exception:
return {"error": f"Unknown entity: {entity_type}"}
if not doc:
return {"error": f"Unknown entity: {entity_type}"}
return dict(doc)
+41
View File
@@ -0,0 +1,41 @@
# This file was generated with the assistance of an AI coding tool.
# IfcQuery - IFC model interrogation CLI
# Copyright (C) 2026 Bruno Postle <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
from typing import Any
import ifcopenshell
import ifcopenshell.util.selector
def select(model: ifcopenshell.file, query: str) -> list[dict[str, Any]]:
"""Filter elements using selector syntax and return matching element summaries."""
elements = ifcopenshell.util.selector.filter_elements(model, query)
results = []
for element in sorted(elements, key=lambda e: e.id()):
entry: dict[str, Any] = {
"id": element.id(),
"type": element.is_a(),
"repr": str(element),
}
if hasattr(element, "Name"):
entry["name"] = element.Name
results.append(entry)
return results
+52
View File
@@ -0,0 +1,52 @@
# This file was generated with the assistance of an AI coding tool.
# IfcQuery - IFC model interrogation CLI
# Copyright (C) 2026 Bruno Postle <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
from collections import Counter
from typing import Any
import ifcopenshell
def summary(model: ifcopenshell.file) -> dict[str, Any]:
"""Return a model overview with schema, element counts, and project info."""
# Count elements by IFC type, sorted by count descending
type_counter: Counter[str] = Counter()
total = 0
for entity in model:
type_counter[entity.is_a()] += 1
total += 1
result: dict[str, Any] = {
"schema": model.schema,
"total_entities": total,
}
projects = model.by_type("IfcProject")
if projects:
project = projects[0]
result["project"] = {
"id": project.id(),
"name": project.Name,
"description": project.Description,
}
result["types"] = dict(type_counter.most_common())
return result
+68
View File
@@ -0,0 +1,68 @@
# This file was generated with the assistance of an AI coding tool.
# IfcQuery - IFC model interrogation CLI
# Copyright (C) 2026 Bruno Postle <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
from typing import Any
import ifcopenshell
import ifcopenshell.util.element
def _element_summary(element: ifcopenshell.entity_instance) -> dict[str, Any]:
"""Return a minimal summary dict for an element."""
return {
"id": element.id(),
"type": element.is_a(),
"name": element.Name if hasattr(element, "Name") else None,
}
def _build_spatial_node(element: ifcopenshell.entity_instance) -> dict[str, Any]:
"""Recursively build a spatial tree node."""
node = _element_summary(element)
# Get aggregated children (Site in Project, Building in Site, Storey in Building, etc.)
aggregates = []
for rel in getattr(element, "IsDecomposedBy", []):
for child in rel.RelatedObjects:
aggregates.append(_build_spatial_node(child))
# Get contained elements (walls, slabs, etc. in a storey/space)
contained = []
for rel in getattr(element, "ContainsElements", []):
for child in rel.RelatedElements:
contained.append(_element_summary(child))
if aggregates:
node["children"] = aggregates
if contained:
node["elements"] = contained
return node
def tree(model: ifcopenshell.file) -> dict[str, Any] | list[dict[str, Any]]:
"""Return the spatial hierarchy tree starting from IfcProject."""
projects = model.by_type("IfcProject")
if not projects:
return {"error": "No IfcProject found in model"}
if len(projects) == 1:
return _build_spatial_node(projects[0])
return [_build_spatial_node(p) for p in projects]
+15
View File
@@ -0,0 +1,15 @@
# This file was generated with the assistance of an AI coding tool.
from __future__ import annotations
from typing import Any
import ifcopenshell
import ifcopenshell.validate
def validate(model: ifcopenshell.file, express_rules: bool = False) -> dict[str, Any]:
"""Validate the model and return a dict with 'valid' bool and 'issues' list."""
logger = ifcopenshell.validate.json_logger()
ifcopenshell.validate.validate(model, logger, express_rules=express_rules)
issues = [{"level": s["level"], "message": s["message"]} for s in logger.statements]
return {"valid": len(issues) == 0, "issues": issues}
+33
View File
@@ -0,0 +1,33 @@
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "ifcquery"
version = "0.0.0"
authors = [
{ name="Bruno Postle", email="bruno@postle.net" },
]
description = "CLI tool for querying and inspecting IFC building models"
readme = "README.md"
keywords = ["IFC", "BIM", "Query"]
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)",
]
dependencies = ["ifcopenshell"]
[project.scripts]
ifcquery = "ifcquery.__main__:main"
[project.urls]
Homepage = "http://ifcopenshell.org"
Documentation = "https://docs.ifcopenshell.org"
Issues = "https://github.com/IfcOpenShell/IfcOpenShell/issues"
[tool.setuptools.packages.find]
include = ["ifcquery*"]
exclude = ["test*"]
[tool.ruff]
extend = "../../pyproject.toml"
+1
View File
@@ -0,0 +1 @@
# This file was generated with the assistance of an AI coding tool.
+36
View File
@@ -0,0 +1,36 @@
# This file was generated with the assistance of an AI coding tool.
import ifcopenshell
import ifcopenshell.api.aggregate
import ifcopenshell.api.owner.settings
import ifcopenshell.api.project
import ifcopenshell.api.root
import ifcopenshell.api.spatial
import ifcopenshell.api.unit
import pytest
@pytest.fixture
def model():
"""Create an IFC4 model with a spatial hierarchy and a wall."""
f = ifcopenshell.api.project.create_file()
ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0]
ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0]
project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="TestProject")
ifcopenshell.api.unit.assign_unit(f)
site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="TestSite")
building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="TestBuilding")
storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="Ground Floor")
ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project)
ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site)
ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=building)
wall = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall001")
ifcopenshell.api.spatial.assign_container(f, products=[wall], relating_structure=storey)
slab = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSlab", name="Slab001")
ifcopenshell.api.spatial.assign_container(f, products=[slab], relating_structure=storey)
return f

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