Compare commits

..

1 Commits

Author SHA1 Message Date
Thomas Krijnen 8c6d59789d Package ifcmcp as a HTML client-side chat app (#7697) 2026-03-24 00:07:21 +00:00
27 changed files with 828 additions and 881 deletions
+1 -1
View File
@@ -53,7 +53,7 @@ jobs:
python ../nix/cache_dependencies.py unpack
- name: ccache
uses: hendrikmuhs/ccache-action@v1.2.22
uses: hendrikmuhs/ccache-action@v1.2.21
with:
key: mac-${{ matrix.arch }}
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
python ../IfcOpenShell/nix/cache_dependencies.py unpack
- name: ccache
uses: hendrikmuhs/ccache-action@v1.2.22
uses: hendrikmuhs/ccache-action@v1.2.21
with:
key: ubuntu-22.04-${{ runner.arch }}
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
python3 ../nix/cache_dependencies.py unpack
- name: ccache
uses: hendrikmuhs/ccache-action@v1.2.22
uses: hendrikmuhs/ccache-action@v1.2.21
with:
key: ubuntu-22.04-${{ runner.arch }}-rockylinux9
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
python3 ../nix/cache_dependencies.py unpack
- name: ccache
uses: hendrikmuhs/ccache-action@v1.2.22
uses: hendrikmuhs/ccache-action@v1.2.21
with:
key: ubuntu-22.04-${{ runner.arch }}-rockylinux9
+1 -1
View File
@@ -52,7 +52,7 @@ jobs:
}
- name: ccache
uses: hendrikmuhs/ccache-action@v1.2.22
uses: hendrikmuhs/ccache-action@v1.2.21
with:
key: win-${{ matrix.arch }}
# Windows ccache needs ~1GB
@@ -24,7 +24,7 @@ jobs:
if: |
github.repository == 'IfcOpenShell/IfcOpenShell'
steps:
- uses: mamba-org/setup-micromamba@v3 # https://github.com/mamba-org/setup-micromamba
- uses: mamba-org/setup-micromamba@v2 # https://github.com/mamba-org/setup-micromamba
with:
environment-name: test-env
create-args: >-
@@ -84,7 +84,7 @@ jobs:
run: |
curl -L https://github.com/phracker/MacOSX-SDKs/releases/download/11.3/MacOSX10.13.sdk.tar.xz | tar -xvJf - -C /Users/runner/work/
- uses: mamba-org/setup-micromamba@v3 # https://github.com/mamba-org/setup-micromamba
- uses: mamba-org/setup-micromamba@v2 # https://github.com/mamba-org/setup-micromamba
with:
environment-name: test-env
create-args: >-
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
-
name: ccache
uses: hendrikmuhs/ccache-action@v1.2.22
uses: hendrikmuhs/ccache-action@v1.2.21
-
name: Build ifcopenshell
+1 -1
View File
@@ -79,7 +79,7 @@ jobs:
libhdf5-dev libcgal-dev libeigen3-dev
- name: ccache
uses: hendrikmuhs/ccache-action@v1.2.22
uses: hendrikmuhs/ccache-action@v1.2.21
with:
key: ubuntu-22.04-${{ runner.arch }}
@@ -31,7 +31,7 @@ jobs:
submodules: recursive
fetch-depth: 0
- name: Setup Pages
uses: actions/configure-pages@v6
uses: actions/configure-pages@v5
- name: Upload static files as artifact
id: deployment
uses: actions/upload-pages-artifact@v4
@@ -47,4 +47,4 @@ jobs:
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v5
uses: actions/deploy-pages@v4
+1 -1
View File
@@ -3,7 +3,7 @@ name = "IfcOpenShell"
version = "0.0.0"
dependencies = [
"black==26.3.1",
"ruff==0.15.8",
"ruff==0.15.7",
"poethepoet",
"gersemi==0.26.1",
]
@@ -104,7 +104,6 @@ classes = (
profile.ExtendProfile,
profile.RecalculateProfile,
profile.Rotate90,
profile.SplitProfile,
profile.PatchNonParametricMepSegment,
roof.GenerateHippedRoof,
slab.DisableEditingExtrusionProfile,
@@ -421,7 +421,7 @@ class PolylineOperator:
tool.Polyline.calculate_x_y_and_z(context, self.input_ui, self.tool_state)
tool.Blender.update_viewport()
return {"RUNNING_MODAL"}
return {"RUNNING_MODAL"}
def set_offset(self, context: bpy.types.Context, relating_type: ifcopenshell.entity_instance) -> None:
props = tool.Model.get_model_props()
@@ -461,7 +461,6 @@ class PolylineOperator:
self.tool_state.axis_method = None
self.tool_state.plane_method = None
self.tool_state.mode = "Mouse"
tool.Raycast.clear_snap_objs()
self.visible_objs = tool.Raycast.get_visible_objects(context)
for obj in self.visible_objs:
if bbox_2d := tool.Raycast.get_on_screen_2d_bounding_boxes(context, obj):
@@ -849,57 +849,6 @@ class DumbProfileJoiner:
def create_matrix(self, p: Vector, x: Vector, y: Vector, z: Vector) -> Matrix:
return Matrix([x, y, z, p]).to_4x4().transposed()
def split(self, profile1: bpy.types.Object, target: Vector) -> None:
element1 = tool.Ifc.get_entity(profile1)
if not element1:
return
if tool.Ifc.is_moved(profile1):
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=profile1)
axis1 = self.get_profile_axis(profile1)
intersect, cut_percentage = mathutils.geometry.intersect_point_line(target, *axis1)
if cut_percentage < 0 or cut_percentage > 1 or tool.Cad.is_x(cut_percentage, (0, 1)):
return
# Duplicate the profile element
profile2 = profile1.copy()
profile2.data = profile2.data.copy()
for collection in profile1.users_collection:
collection.objects.link(profile2)
bonsai.core.root.copy_class(tool.Ifc, tool.Collector, tool.Geometry, tool.Root, obj=profile2)
element2 = tool.Ifc.get_entity(profile2)
# Transfer ATEND connection from element1 to element2
relating_element = None
relating_connection = None
description = None
for conn in list(element1.ConnectedTo):
if conn.is_a("IfcRelConnectsPathElements") and conn.RelatingConnectionType == "ATEND":
relating_element = conn.RelatedElement
relating_connection = conn.RelatedConnectionType
description = conn.Description
bonsai.core.geometry.remove_connection(tool.Geometry, connection=conn)
for conn in list(element1.ConnectedFrom):
if conn.is_a("IfcRelConnectsPathElements") and conn.RelatedConnectionType == "ATEND":
relating_element = conn.RelatingElement
relating_connection = conn.RelatingConnectionType
description = conn.Description
bonsai.core.geometry.remove_connection(tool.Geometry, connection=conn)
if relating_element:
ifcopenshell.api.geometry.connect_path(
tool.Ifc.get(),
relating_element=relating_element,
related_element=element2,
relating_connection=relating_connection,
related_connection="ATEND",
description=description,
)
# Recreate both profiles with split axes
self.recreate_profile(element1, profile1, [axis1[0], intersect], [axis1[0], intersect])
self.recreate_profile(element2, profile2, [intersect, axis1[1]], [intersect, axis1[1]])
def get_profile_axis(self, obj: bpy.types.Object) -> list[Vector]:
z_values = [v[2] for v in obj.bound_box]
return [
@@ -908,29 +857,6 @@ class DumbProfileJoiner:
]
class SplitProfile(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.split_profile"
bl_label = "Split Profile"
bl_options = {"REGISTER", "UNDO"}
bl_description = (
"Split selected profile element into two elements at the Blender cursor location. "
"The cursor must be positioned on the element's axis."
)
@classmethod
def poll(cls, context):
if not tool.Model.has_selected_ifc_objects():
cls.poll_message_set("No IFC objects selected.")
return False
return True
def _execute(self, context):
selected_objs = tool.Model.get_selected_mesh_objects()
for obj in selected_objs:
DumbProfileJoiner().split(obj, context.scene.cursor.location)
return {"FINISHED"}
class RecalculateProfile(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.recalculate_profile"
bl_label = "Recalculate Profile"
@@ -937,21 +937,13 @@ class EditObjectUI:
row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row
add_layout_hotkey_operator(row, "Mitre", "S_Y", "", ui_context)
row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row
add_layout_hotkey_operator(
row,
"Split",
"S_K",
"Split selected Element into two Elements at the cursor location\n\nHotkey: ⇧ K",
ui_context,
)
row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row
add_layout_hotkey_operator(row, "Rotate 90", "S_R", bpy.ops.bim.rotate_90.__doc__, ui_context)
else:
if "LAYER2" in AuthoringData.data["selected_material_usages"]:
row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row
add_layout_hotkey_operator(
cls.layout, "Extend To Underside", "S_E", bpy.ops.bim.extend_walls_to_underside.__doc__, ui_context
cls.layout, "Extend To Underside", "S_E", bpy.ops.bim.extend_to_underside.__doc__, ui_context
)
if AuthoringData.data["is_flippable_element"]:
@@ -1369,8 +1361,6 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator):
return
if self.active_material_usage == "LAYER2":
bpy.ops.bim.split_wall()
elif self.active_material_usage == "PROFILE":
bpy.ops.bim.split_profile()
def hotkey_S_T(self):
if not bpy.context.selected_objects:
+13 -614
View File
@@ -16,9 +16,6 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
import math
from typing import Union
import bmesh
@@ -44,7 +41,6 @@ class Raycast(bonsai.core.tool.Raycast):
(0, -offset),
(offset, -offset),
)
snap_objs = []
@classmethod
def get_visible_objects(cls, context: bpy.types.Context):
@@ -73,14 +69,8 @@ class Raycast(bonsai.core.tool.Raycast):
rv3d = context.region_data
assert rv3d
view_location = rv3d.view_matrix.inverted().translation
view_normal = rv3d.view_rotation @ mathutils.Vector((0.0, 0.0, -1.0))
obj_matrix = obj.matrix_world.copy()
bbox = [obj_matrix @ Vector(v) for v in obj.bound_box]
bbox_edges = [
(0,1),(1,2),(2,3),(3,0),
(4,5),(5,6),(6,7),(7,4),
(0,4),(1,5),(2,6),(3,7)
]
transposed_bbox: list[Vector] = []
bbox_2d: list[float] = []
@@ -104,23 +94,8 @@ class Raycast(bonsai.core.tool.Raycast):
for v in bbox:
coord_2d = tool.Cad.location_3d_to_region_2d_np(context.region, context.space_data.region_3d, v)
transposed_bbox.append(coord_2d)
if not any(transposed_bbox):
transposed_bbox = []
# If there are None values in transposed_bbox it means that there are vertices behind the camera
# so we get the intersection of the edge with the region border
# new_bbox = []
if any(transposed_bbox) and not all(transposed_bbox):
new_bbox = transposed_bbox.copy()
new_bbox = [x for x in new_bbox if x is not None]
for edge in bbox_edges:
if (transposed_bbox[edge[0]] is None) ^ (transposed_bbox[edge[1]] is None):
point, _ = cls.intersect_edge_region_border(context.region, context.space_data, rv3d, bbox[edge[0]], bbox[edge[1]])
if point:
new_bbox.append(point)
if new_bbox:
transposed_bbox = new_bbox
if coord_2d is not None:
transposed_bbox.append(coord_2d)
region = context.region
borders = (0, region.width, 0, region.height)
@@ -142,99 +117,6 @@ class Raycast(bonsai.core.tool.Raycast):
return (obj, bbox_2d)
return None
def intersect_edge_region_border(region, space, rv3d, v1, v2):
def segment_intersect_near_plane(view_matrix, clip_start, p_world_a, p_world_b):
a_view = view_matrix @ p_world_a
b_view = view_matrix @ p_world_b
z_near = -clip_start
za = a_view.z
zb = b_view.z
denom = (zb - za)
if denom == 0.0:
return None, None
t = (z_near - za) / denom
if t < 0.0 or t > 1.0:
return None, None
p_view = a_view.lerp(b_view, t)
cam_world = view_matrix.inverted()
p_world = cam_world @ p_view
return p_world, t
def is_inside_region(pt2d, region):
return 0.0 <= pt2d.x <= region.width and 0.0 <= pt2d.y <= region.height
def clamp_to_region_border(point2d, region):
x, y = point2d
x_clamped = max(0.0, min(region.width, x))
y_clamped = max(0.0, min(region.height, y))
return Vector((x_clamped, y_clamped))
def find_nearby_onscreen_point(region, rv3d, p1, p2, initial_t_on_segment, max_iters=40, step=0.05):
"""
Use iterative approach: move t toward 0. Returns the first point that is inside region border
"""
t = initial_t_on_segment
for i in range(max_iters):
test_3d = p1.lerp(p2, t)
test_2d = view3d_utils.location_3d_to_region_2d(region, rv3d, test_3d)
if test_2d is not None and is_inside_region(test_2d, region):
return test_3d, test_2d, t
# move t toward 0 by reducing it by a fraction of its current value
t -= step
# if t is already very small, break
if t <= 1e-6:
break
return None, None, None
# Ensures that all the calculation uses the same direction based on which point is on the screen
if view3d_utils.location_3d_to_region_2d(region, rv3d, v1):
onscreen_vert = v1
offscreen_vert = v2
else:
onscreen_vert = v2
offscreen_vert = v1
# v2, v1 = v1, v2
clip_start = space.clip_start
view_mat = rv3d.view_matrix
inter_world, t_on_ab = segment_intersect_near_plane(view_mat, clip_start, onscreen_vert, offscreen_vert)
if inter_world is None:
print("No intersection with viewport near plane found for the segment.")
return
init_2d = view3d_utils.location_3d_to_region_2d(region, rv3d, inter_world)
if init_2d is not None and is_inside_region(init_2d, region):
final_world = inter_world
final_2d = init_2d
final_t = initial_t
else:
found_world, found_2d, found_t = find_nearby_onscreen_point(
region, rv3d,
onscreen_vert, offscreen_vert,
t_on_ab,
max_iters=600, step=0.01
)
if found_world is None:
if init_2d is None:
print("Initial projection invalid and iterative search failed.")
return
# fallback: clamp projected point to border via manual mapping
final_2d = clamp_to_region_border(init_2d, region)
final_world = None
final_t = None
# print("Iterative search failed; using clamped 2D:", final_2d)
else:
final_world = found_world
final_2d = found_2d
final_t = found_t
# print(f"Found onscreen point at t={final_t:.4f}")
# print("Final 2D:", final_2d)
return final_2d, v2
@classmethod
def intersect_mouse_2d_bounding_box(cls, mouse_pos: tuple[int, int], bbox: list[float, float, float, float]):
x, y = mouse_pos
@@ -350,161 +232,6 @@ class Raycast(bonsai.core.tool.Raycast):
else:
return None, None, None
@classmethod
def ray_cast_by_proximity_2d(
cls,
context: bpy.types.Context,
event: bpy.types.Event,
snap_obj: SnapObj,
):
def divide_vector(start, end, n):
points = []
delta = (end - start) / n
for i in range(1, n):
point = start + i * delta
points.append(point)
return points
region = context.region
rv3d = context.region_data
mouse_pos = event.mouse_region_x, event.mouse_region_y
ray_origin, ray_target, ray_direction = cls.get_viewport_ray_data(context, event)
points = []
try:
loc = tool.Cad.region_2d_to_location_3d_np(region, rv3d, mouse_pos, ray_direction)
except:
loc = Vector((0, 0, 0))
verts_2d = [
view3d_utils.location_3d_to_region_2d(region, rv3d, v) for v in snap_obj.verts_3d
] # Numpy version is worst in performance
intersected = snap_obj.raycast_boxes(
context, event, snap_obj.root, intersected=[], rays=(ray_origin, ray_direction)
)
edges = []
for it in intersected:
edges.extend(it.edges)
edges = set(edges)
edge_verts = {}
for e in edges:
verts_idx = tuple(snap_obj.obj.data.edges[e].vertices)
verts = snap_obj.obj.data.vertices
v1 = snap_obj.obj.matrix_world @ verts[verts_idx[0]].co
v1_2d = verts_2d[verts_idx[0]]
v2 = snap_obj.obj.matrix_world @ verts[verts_idx[1]].co
v2_2d = verts_2d[verts_idx[1]]
if (v1_2d is None) ^ (v2_2d is None):
point, _ = cls.intersect_edge_region_border(region, context.space_data, rv3d, v1, v2)
if v1_2d is None:
edge_verts[e] = (point, v2_2d)
else:
edge_verts[e] = (v1_2d, point)
else:
edge_verts[e] = (v1_2d, v2_2d)
snap_threshold = 10.0
for i, point in enumerate(verts_2d):
if not point:
continue
distance = (Vector(mouse_pos) - point).length
if distance <= snap_threshold:
snap_point = {
"object": snap_obj.obj,
"type": "Vertex",
"point": snap_obj.verts_3d[i],
"distance": distance / 10,
}
points.append(snap_point)
count = 0
selected_edges = {}
for e in edges:
p0, p1 = edge_verts[e]
p0x, p0y = p0
p1x, p1y = p1
px, py = mouse_pos
# segment vector = p1 - p0
sx = p1x - p0x
sy = p1y - p0y
# seg length squared
seg_len_sq = sx * sx + sy * sy
if seg_len_sq == 0.0:
# degenerate segment: return distance to p0
dx = px - p0x
dy = py - p0y
dist = math.hypot(dx, dy)
return dist, (p0x, p0y), 0.0
# project (p - p0) onto seg: t = dot(p-p0, seg) / |seg|^2
apx = px - p0x
apy = py - p0y
t = (apx * sx + apy * sy) / seg_len_sq
# clamp to segment
if t <= 0.0:
t_clamped = 0.0
cx, cy = p0x, p0y
elif t >= 1.0:
t_clamped = 1.0
cx, cy = p1x, p1y
else:
t_clamped = t
cx = p0x + sx * t_clamped
cy = p0y + sy * t_clamped
dx = px - cx
dy = py - cy
dist = math.hypot(dx, dy)
if dist <= snap_threshold:
selected_edges[dist] = e
if selected_edges:
min_dist = float("inf")
for key in selected_edges:
if key < min_dist:
min_dist = key
idx = snap_obj.obj.data.edges[selected_edges[min_dist]].vertices
edge_verts = (snap_obj.verts_3d[idx[0]], snap_obj.verts_3d[idx[1]])
division_points = divide_vector(
edge_verts[0], edge_verts[1], 2
) # TODO Make it work for different divisions
for division_point in division_points:
intersection = tool.Cad.point_on_edge(division_point, (ray_target, loc))
distance = (division_point - intersection).length
if distance < snap_threshold:
snap_point = {
"object": snap_obj.obj,
"type": "Edge Center",
"point": division_point.copy(),
"distance": distance,
}
points.append(snap_point)
intersection = tool.Cad.intersect_edges_v2((ray_target, loc), edge_verts)
if intersection[0]:
if tool.Cad.is_point_on_edge(intersection[1], edge_verts):
distance = (intersection[1] - intersection[0]).length
if distance < snap_threshold:
snap_point = {
"object": snap_obj.obj,
"type": "Edge",
"point": intersection[1].copy(),
"edge_verts": edge_verts,
"distance": distance,
}
points.append(snap_point)
return points
@classmethod
def ray_cast_by_proximity(
cls,
@@ -730,8 +457,7 @@ class Raycast(bonsai.core.tool.Raycast):
if bbox_2d:
if tool.Raycast.intersect_mouse_2d_bounding_box(mouse_pos, bbox_2d):
if tool.Raycast.object_is_visible_in_clipping_plane(obj):
snap_obj = cls.create_snap_obj(obj)
objs_to_raycast.append(snap_obj)
objs_to_raycast.append(obj)
return objs_to_raycast
@@ -748,6 +474,12 @@ class Raycast(bonsai.core.tool.Raycast):
face_index = None
# Wireframes
if obj.type in {"EMPTY", "CURVE"} or (hasattr(obj.data, "polygons") and len(obj.data.polygons) == 0):
snap_points = tool.Raycast.ray_cast_by_proximity(context, event, obj)
if snap_points:
hit = sorted(snap_points, key=lambda x: x["distance"])[0]["point"]
if hit:
hit_world = obj.original.matrix_world @ hit
return obj, hit_world, face_index
return None, None, None
# Meshes
else:
@@ -782,20 +514,19 @@ class Raycast(bonsai.core.tool.Raycast):
ray_origin, ray_target, ray_direction = cls.get_viewport_ray_data(context, event)
for snap_obj in objs_to_raycast:
for obj in objs_to_raycast:
if not include_wireframes and (
snap_obj.obj.type in {"EMPTY", "CURVE"}
or (hasattr(snap_obj.obj.data, "polygons") and len(snap_obj.obj.data.polygons) == 0)
obj.type in {"EMPTY", "CURVE"} or (hasattr(obj.data, "polygons") and len(obj.data.polygons) == 0)
):
continue
hit_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, snap_obj.obj)
snap_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, obj)
if hit is not None:
length_squared = (hit - ray_origin).length_squared
if best_obj is None or length_squared < best_length_squared:
best_length_squared = length_squared
best_obj = hit_obj
best_obj = snap_obj
best_hit = hit
best_face_index = face_index
@@ -805,81 +536,6 @@ class Raycast(bonsai.core.tool.Raycast):
else:
return None, None, None
@classmethod
def ray_cast_and_get_closest_to_camera_snaps(
cls,
context: bpy.types.Context,
event: bpy.types.Event,
objs_to_raycast: list[bpy.types.Object],
) -> Union[tuple[bpy.types.Object, Vector, int], tuple[None, None, None]]:
closest_length_squared = 1.0
closest_obj = None
closest_hit = None
closest_face_index = None
ray_origin, ray_target, ray_direction = cls.get_viewport_ray_data(context, event)
closest_snaps = []
hit = None
for snap_obj in objs_to_raycast:
if (snap_obj.obj.type in {"EMPTY", "CURVE"}
or (hasattr(snap_obj.obj.data, "polygons") and len(snap_obj.obj.data.polygons) == 0)
):
# For wireframe objects we have to test all the snaps to see which is closer
snap_points = tool.Raycast.ray_cast_by_proximity_2d(context, event, snap_obj)
closest_wf_hit = None
closest_wf_length_squared = 1.0
closest_wf_point = None
if snap_points:
for point in snap_points:
point["group"] = "Wireframe"
closest_snaps.append(point)
length = (point["point"] - ray_origin).length_squared
if closest_wf_hit is None or length < closest_wf_length_squared:
closest_wf_length_squared = length
closest_wf_hit = point["point"]
closest_wf_point = point
if closest_wf_point:
hit_obj = closest_wf_point["object"]
hit = closest_wf_point["point"]
face_index = None
else:
# Solid objects
hit_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, snap_obj.obj)
if hit:
snap_point = {
"point": hit,
"type": "Face",
"group": "Object",
"object": hit_obj,
"face_index": face_index,
"distance": 9, # High value so it has low priority
}
closest_snaps.append(snap_point)
# Here we test which is closer, including wireframe and solid objects
if hit is not None:
length_squared = (hit - ray_origin).length_squared
if closest_obj is None or length_squared < closest_length_squared:
closest_length_squared = length_squared
closest_obj = hit_obj
closest_hit = hit
closest_face_index = face_index
# Label snaps from the closest object
if closest_obj is not None:
for snap in closest_snaps:
if snap["object"] == closest_obj:
snap["is_closest_to_camera"] = True
return closest_snaps
@classmethod
def calculate_snap_threshold(cls, view_distance):
snap_threshold = view_distance / 100
@@ -891,260 +547,3 @@ class Raycast(bonsai.core.tool.Raycast):
if lens < 50:
snap_threshold *= value
return snap_threshold
@classmethod
def create_snap_obj(cls, obj):
for snap_obj in cls.snap_objs:
if obj.name == snap_obj.obj.name:
return snap_obj
snap_obj = SnapObj(obj)
cls.snap_objs.append(snap_obj)
return snap_obj
@classmethod
def clear_snap_objs(cls):
TreeNode.__clear_all__()
SnapObj.__clear_all__()
cls.snap_objs.clear()
class TreeNode:
all = []
def __init__(self, box: tuple):
self.__class__.all.append(self)
self.box = box
self.child_a = None
self.child_b = None
self.edges = []
def __clear_all__():
for instance in TreeNode.all:
del instance
TreeNode.all.clear()
class SnapObj:
max_depth = 9
all = []
def __init__(self, obj: bpy.types.Object):
self.__class__.all.append(self)
self.obj = obj
self.root = self._create_root_node()
self.root.edges = [e.index for e in obj.data.edges]
self.split_box(self.root, 0)
self.verts_3d = [obj.matrix_world @ v.co for v in obj.data.vertices]
self.snap_points = []
def __clear_all__():
for instance in SnapObj.all:
del instance
SnapObj.all.clear()
def _create_root_node(self) -> TreeNode:
bbox = tool.Blender.get_object_bounding_box(self.obj)
min_point = self.obj.matrix_world @ bbox["min_point"]
max_point = self.obj.matrix_world @ bbox["max_point"]
new_bbox = self.expand_bounding_box((min_point, max_point))
return TreeNode(new_bbox)
def divide_bounding_box_along_longest_axis(
self, min_pt: Vector, max_pt: Vector
) -> Union[tuple[Vector, Vector], tuple[Vector, Vector]]:
"""
Divide a bounding box into two equal parts along the axis with the longest dimension.
Args:
min_pt: The minimum point of the bounding box.
max_pt: The maximum point of the bounding box.
Returns:
list: A list of two tuples, each containing the minimum and maximum points of the divided boxes.
"""
# Calculate the dimensions of the box
dx = max_pt.x - min_pt.x
dy = max_pt.y - min_pt.y
dz = max_pt.z - min_pt.z
# Determine the axis with the longest dimension
if dx >= dy and dx >= dz:
# Divide along the x-axis
mid_x = min_pt.x + dx / 2
box1 = (min_pt, Vector((mid_x, max_pt.y, max_pt.z)))
box2 = (Vector((mid_x, min_pt.y, min_pt.z)), max_pt)
elif dy >= dx and dy >= dz:
# Divide along the y-axis
mid_y = min_pt.y + dy / 2
box1 = (min_pt, Vector((max_pt.x, mid_y, max_pt.z)))
box2 = (Vector((min_pt.x, mid_y, min_pt.z)), max_pt)
else:
# Divide along the z-axis
mid_z = min_pt.z + dz / 2
box1 = (min_pt, Vector((max_pt.x, max_pt.y, mid_z)))
box2 = (Vector((min_pt.x, min_pt.y, mid_z)), max_pt)
return [box1, box2]
def expand_bounding_box(self, box: tuple[Vector, Vector], offset: float = 0.1) -> tuple[Vector, Vector]:
"""
Expand a 3D bounding box by a given offset.
Args:
min_pt: The minimum point of the bounding box.
max_pt: The maximum point of the bounding box.
offset: The offset to expand the bounding box by.
Returns:
tuple: A tuple containing the new minimum and maximum points of the expanded bounding box.
"""
min_pt, max_pt = box
# Calculate the new minimum and maximum points
new_min_pt = Vector((min_pt.x - offset, min_pt.y - offset, min_pt.z - offset))
new_max_pt = Vector((max_pt.x + offset, max_pt.y + offset, max_pt.z + offset))
return new_min_pt, new_max_pt
def split_box(self, parent: TreeNode, depth: int):
"""
Splits the bounding box creating two child nodes to compose a BVH Tree recursively.
Args:
parent: the TreeNode instance that represents the parent node of a BVH Tree.
depth: the depth of the BVH Tree no be used in recursion.
"""
if depth > self.max_depth:
return
box_a, box_b = self.divide_bounding_box_along_longest_axis(parent.box[0], parent.box[1])
parent.child_a = TreeNode(box_a)
parent.child_b = TreeNode(box_b)
edges_a = []
edges_b = []
for e in parent.edges:
verts_idx = [v for v in self.obj.data.edges[e].vertices]
verts_coords = []
for idx in verts_idx:
if idx < len(self.obj.data.vertices):
verts_coords.append(self.obj.matrix_world @ self.obj.data.vertices[idx].co)
if self.line_intersects_box(verts_coords[0], verts_coords[1], parent.child_a.box):
edges_a.append(e)
if self.line_intersects_box(verts_coords[0], verts_coords[1], parent.child_b.box):
edges_b.append(e)
parent.child_a.edges = edges_a
parent.child_b.edges = edges_b
self.split_box(parent.child_a, depth + 1)
self.split_box(parent.child_b, depth + 1)
def raycast_box(
self, context: bpy.types.Context, event: bpy.types.Event, node: TreeNode, rays: tuple[Vector, Vector]
) -> bool:
"""
Raycast bounding box.
Args:
context: Blender context.
event: Blender event.
node: a TreeNode instance.
rays: tuple containing ray origin and ray direction
Returns:
True if hits the box or False otherwise.
"""
box = node.box
min_v = box[0]
max_v = box[1]
t_min = 0.0
t_max = float("inf")
ray_origin, ray_dir = rays
inv_dir = Vector((1.0 / r if r != 0.0 else 1e32) for r in (ray_dir.x, ray_dir.y, ray_dir.z))
# X
tx1 = (min_v.x - ray_origin.x) * inv_dir[0]
tx2 = (max_v.x - ray_origin.x) * inv_dir[0]
tmin = min(tx1, tx2)
tmax = max(tx1, tx2)
# Y
ty1 = (min_v.y - ray_origin.y) * inv_dir[1]
ty2 = (max_v.y - ray_origin.y) * inv_dir[1]
tmin = max(tmin, min(ty1, ty2))
tmax = min(tmax, max(ty1, ty2))
# Z
tz1 = (min_v.z - ray_origin.z) * inv_dir[2]
tz2 = (max_v.z - ray_origin.z) * inv_dir[2]
tmin = max(tmin, min(tz1, tz2))
tmax = min(tmax, max(tz1, tz2))
return (tmax >= max(tmin, t_min)) and (tmin <= t_max)
def line_intersects_box(self, v1: mathutils.Vector, v2: mathutils.Vector, box: tuple) -> bool:
"""
Check if a line segment intersects an axis-aligned bounding box (AABB).
Args:
v1: The first endpoint of the line segment as a mathutils.Vector.
v2: The second endpoint of the line segment as a mathutils.Vector.
box: A tuple containing the minimum and maximum points of the AABB, where each point is a mathutils.Vector.
Returns:
bool: True if the segment [v1, v2] intersects the AABB; otherwise, False.
"""
bmin, bmax = box
dir = v2 - v1
tmin = 0.0
tmax = 1.0
for i in range(3):
if abs(dir[i]) < 1e-12:
# Line is parallel to slab. If origin not within slab -> no hit.
if v1[i] < bmin[i] or v1[i] > bmax[i]:
return False
else:
ood = 1.0 / dir[i]
t1 = (bmin[i] - v1[i]) * ood
t2 = (bmax[i] - v1[i]) * ood
if t1 > t2:
t1, t2 = t2, t1
if t1 > tmin:
tmin = t1
if t2 < tmax:
tmax = t2
if tmin > tmax:
return False
# If any overlap in [0,1] exists, there's intersection
return (tmax >= 0.0) and (tmin <= 1.0)
def raycast_boxes(
self,
context: bpy.types.Context,
event: bpy.Types.Event,
node: TreeNode,
intersected: Union[TreeNode] = [],
rays: tuple[Vector, Vector] = (),
) -> Union[TreeNode]:
"""
Raycast bounding box subdivisions recursively.
Args:
context: Blender context.
event: Blender event.
node: a TreeNode instance.
intersected: list of intersected boxes to use in recursion.
rays: tuple containing ray origin and ray direction
Returns:
tuple: a list of TreeNode instances that represent the subdivided boxes hit by the ray cast.
"""
if not node.child_a:
intersected.append(node)
return intersected
intersects_a = self.raycast_box(context, event, node.child_a, rays)
intersects_b = self.raycast_box(context, event, node.child_b, rays)
if intersects_a:
intersected = self.raycast_boxes(context, event, node.child_a, intersected, rays)
if intersects_b:
intersected = self.raycast_boxes(context, event, node.child_b, intersected, rays)
return intersected
+51 -25
View File
@@ -360,7 +360,6 @@ class Snap(bonsai.core.tool.Snap):
plane_normal = tool.Polyline.use_transform_orientations(plane_normal)
return plane_origin, plane_normal
# Polyline
polyline_props = tool.Model.get_polyline_props()
try:
@@ -389,31 +388,58 @@ class Snap(bonsai.core.tool.Snap):
# Objects
objs_to_raycast = tool.Raycast.filter_objects_to_raycast(context, event, objs_2d_bbox)
closest_snaps = tool.Raycast.ray_cast_and_get_closest_to_camera_snaps(context, event, objs_to_raycast)
detected_snaps.extend(closest_snaps)
xray_mode = (space.shading.type == "SOLID" and space.shading.show_xray) or (space.shading.type == "WIREFRAME" and space.shading.show_xray_wireframe)
# Wireframes
# For wireframe we have to get all the objects so we can further calculate edge intersection
for snap_obj in objs_to_raycast:
for snap in closest_snaps:
if snap_obj.obj == snap["object"]:
if xray_mode:
if "face_index" in snap and snap["face_index"] is not None:
snap_points = tool.Raycast.ray_cast_by_proximity_2d(context, event, snap_obj)
for point in snap_points:
point["group"] = "Object"
detected_snaps.append(point)
else:
# If it is a solid object that is closest to camera it ignores all the rest
if "is_closest_to_camera" in snap and snap["is_closest_to_camera"] and snap["group"] == "Object":
closest_snap = [snap] # discards objects that aren't the closest
if "face_index" in snap and snap["face_index"] is not None:
snap_points = tool.Raycast.ray_cast_by_proximity_2d(context, event, snap_obj)
for point in snap_points:
point["group"] = "Object"
closest_snap.append(point)
detected_snaps = closest_snap
if snap_obj.type in {"EMPTY", "CURVE"} or (snap_obj.type == "MESH" and len(snap_obj.data.polygons) == 0):
snap_points = tool.Raycast.ray_cast_by_proximity(context, event, snap_obj)
if snap_points:
for point in snap_points:
point["group"] = "Wireframe"
detected_snaps.append(point)
if (space.shading.type == "SOLID" and space.shading.show_xray) or (
space.shading.type == "WIREFRAME" and space.shading.show_xray_wireframe
):
results = []
for obj in objs_to_raycast:
results.append(tool.Raycast.cast_rays_to_single_object(context, event, obj))
else:
results = []
results.append(tool.Raycast.cast_rays_and_get_best_object(context, event, objs_to_raycast))
for result in results:
snap_obj = result[0]
hit = result[1]
face_index = result[2]
if hit is not None:
# Wireframes
if snap_obj.type in {"EMPTY", "CURVE"} or (
snap_obj.type == "MESH" and len(snap_obj.data.polygons) == 0
):
continue
# Meshes
else:
# Add face snap
snap_point = {
"point": hit,
"type": "Face",
"group": "Object",
"object": snap_obj,
"face_index": face_index,
"distance": 9, # High value so it has low priority
}
detected_snaps.append(snap_point)
# Add vertex and edge snap
snap_points = tool.Raycast.ray_cast_by_proximity(
context, event, snap_obj, snap_obj.data.polygons[face_index]
)
if snap_points:
for point in snap_points:
point["group"] = "Object"
detected_snaps.append(point)
# snap to cut geometry (e.g. in plan view)
if CutDecorator.installed:
cut_snaps = []
-38
View File
@@ -396,44 +396,6 @@ Scenario: Add a slab
And the object "IfcSlab/Slab" bottom left corner is at "0,0,0"
And the object "IfcSlab/Slab" top right corner is at "1,1,0.2"
Scenario: Extend walls to underside
Given an empty IFC project
And I load the demo construction library
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
And I press "bim.add_occurrence"
And I set "scene.BIMModelProperties.ifc_class" to "IfcSlabType"
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcSlabType') if e.Name == 'FLR200'][0].id()"
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
And I press "bim.add_occurrence"
And the object "IfcSlab/Slab" is moved to "0,0,2.5"
When the object "IfcWall/Wall" is selected
And additionally the object "IfcSlab/Slab" is selected
And I look at the tool header
And I click "Extend To Underside"
Then the object "IfcWall/Wall" dimensions are "1,0.1,2.5"
Scenario: Extend walls to underside - extending to a tessellated gable roof
Given an empty IFC project
And I load the demo construction library
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
And I press "bim.add_occurrence"
# Create gable roof: a cube turned into a prism with a ridge.
And I add a cube of size "1" at "0.5,0.05,3"
And the object "Cube" is selected
And I evaluate expression "obj = bpy.context.active_object; [setattr(v.co, 'y', 0) for v in obj.data.vertices if v.co.z > 0]"
And I set "scene.BIMRootProperties.ifc_product" to "IfcElement"
And I set "scene.BIMRootProperties.ifc_class" to "IfcRoof"
And I press "bim.assign_class"
When the object "IfcWall/Wall" is selected
And additionally the object "IfcRoof/Cube" is selected
And I look at the tool header
And I click "Extend To Underside"
Then the object "IfcWall/Wall" dimensions are "1,0.1,2.5"
Scenario: Enable editing a slab profile
Given an empty IFC project
And I load the demo construction library
+1 -31
View File
@@ -133,11 +133,7 @@ class PanelSpy:
self.spied_labels.append(kwargs["text"])
return self
elif self.spied_attr == "prop":
if args:
props, name = args
else:
props = kwargs.get("data")
name = kwargs.get("property")
props, name = args
props: bpy.types.bpy_struct
text = kwargs.get("text", props.bl_rna.properties[name].name)
icon = kwargs.get("icon", None)
@@ -394,32 +390,6 @@ def i_look_at_the_panel_panel(panel: str) -> None:
panel_spy.refresh_spy()
@given(parsers.parse("I look at the tool header"))
@when(parsers.parse("I look at the tool header"))
@then(parsers.parse("I look at the tool header"))
def i_look_at_the_tool_header() -> None:
from bonsai.bim.module.model.workspace import EditObjectUI
class MockRegion:
type = "UI"
class MockContext:
def __getattr__(self, name):
if name == "region":
return MockRegion()
return getattr(bpy.context, name)
global panel_spy
panel_spy = PanelSpy(EditObjectUI)
panel_spy.is_spy_dirty = False
panel_spy.spied_attr = None
panel_spy.spied_labels = []
panel_spy.spied_props = []
panel_spy.spied_operators = []
panel_spy.spied_lists = []
EditObjectUI.draw(MockContext(), panel_spy)
@given(parsers.parse('I open the "{name}" menu'))
@when(parsers.parse('I open the "{name}" menu'))
@then(parsers.parse('I open the "{name}" menu'))
+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>
+2 -39
View File
@@ -1,47 +1,10 @@
# This file was generated with the assistance of an AI coding tool.
import argparse
from ifcmcp import __version__
from ifcmcp.server import build_server
def main():
parser = argparse.ArgumentParser(
prog="python3 -m ifcmcp",
description=(
"ifcmcp — MCP server for IFC building models.\n\n"
"Runs a Model Context Protocol server over stdio so that MCP clients\n"
"can query and edit IFC files without writing them to disk between\n"
"operations.\n\n"
"Add to .mcp.json to configure:\n"
' {"mcpServers": {"ifc": {"type": "stdio", "command": "python3", "args": ["-m", "ifcmcp"]}}}'
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("--version", action="version", version=f"ifcmcp {__version__}")
parser.add_argument(
"--transport",
choices=["stdio", "sse", "streamable-http"],
default="stdio",
help="MCP transport to use (default: stdio)",
)
args = parser.parse_args()
try:
from mcp.server.fastmcp import FastMCP # noqa: F401
except ImportError:
import sys
print(
"error: the 'mcp' package is required to run the server.\n"
"Install it with: pip install mcp",
file=sys.stderr,
)
sys.exit(1)
from ifcmcp.server import build_server
server = build_server()
server.run(transport=args.transport)
server.run(transport="stdio")
if __name__ == "__main__":
@@ -17,7 +17,6 @@
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import ifcopenshell
import ifcopenshell.api.georeference
import ifcopenshell.api.pset
import ifcopenshell.util.element
@@ -64,13 +63,8 @@ def add_georeferencing(file: ifcopenshell.file, ifc_class: str = "IfcMapConversi
},
)
return
has_crs = bool(file.by_type("IfcProjectedCRS"))
has_conversion = bool(file.by_type("IfcCoordinateOperation"))
if has_crs and has_conversion:
if file.by_type("IfcProjectedCRS"):
return
if has_crs or has_conversion:
# This is technically invalid, but we shall forgive the industry here if they are wrong ...
ifcopenshell.api.georeference.remove_georeferencing(file)
source_crs = None
for context in file.by_type("IfcGeometricRepresentationContext", include_subtypes=False):
if context.ContextType == "Model":
@@ -51,30 +51,6 @@ class TestAddGeoreferencing(test.bootstrap.IFC4):
assert len(self.file.by_type("IfcMapConversion")) == 1
assert len(self.file.by_type("IfcProjectedCRS")) == 1
def test_recovering_from_orphan_projected_crs(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
ifcopenshell.api.context.add_context(self.file, "Model")
self.file.create_entity("IfcProjectedCRS", Name="EPSG:1234")
assert len(self.file.by_type("IfcProjectedCRS")) == 1
assert len(self.file.by_type("IfcCoordinateOperation")) == 0
ifcopenshell.api.georeference.add_georeferencing(self.file)
assert len(self.file.by_type("IfcMapConversion")) == 1
assert len(self.file.by_type("IfcProjectedCRS")) == 1
def test_recovering_from_orphan_coordinate_operation(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
context = ifcopenshell.api.context.add_context(self.file, "Model")
self.file.create_entity("IfcMapConversion", SourceCRS=context, TargetCRS=self.file.create_entity("IfcProjectedCRS", Name="EPSG:1234"))
ifcopenshell.api.georeference.remove_georeferencing(self.file)
# Simulate orphan by re-adding just a conversion without CRS
self.file.create_entity("IfcMapConversion", SourceCRS=context, TargetCRS=self.file.create_entity("IfcProjectedCRS", Name="EPSG:1234"))
self.file.remove(self.file.by_type("IfcProjectedCRS")[0])
assert len(self.file.by_type("IfcProjectedCRS")) == 0
assert len(self.file.by_type("IfcCoordinateOperation")) == 1
ifcopenshell.api.georeference.add_georeferencing(self.file)
assert len(self.file.by_type("IfcMapConversion")) == 1
assert len(self.file.by_type("IfcProjectedCRS")) == 1
class TestAddGeoreferencingIFC2X3(test.bootstrap.IFC2X3):
def test_adding_georeferencing(self):
+3 -3
View File
@@ -2352,9 +2352,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"dev": true,
"license": "MIT",
"engines": {