Add BBIM_DimensionTarget: parametric dimensions anchored to element geometry

New modal operator (bim.set_dimension_anchor) anchors dimension vertices
to IFC element faces. Anchors are stored as JSON in a BBIM_DimensionTarget
pset on the IfcAnnotation and resolved via tessellation at regeneration time.

- resolve_anchor.py / regenerate_dimension.py: new ifcopenshell API modules
- bim.set_dimension_anchor: 2-phase Object Mode modal (pick vertex → pick face)
- bim.regenerate_dimensions: recomputes all parametric dimensions
- Auto-regeneration via depsgraph_update_post when referenced elements move
- placement_override reads Blender matrix_world for G-moved elements
- Plan-view annotations flattened to annotation plane (Z=0 in local space)
- IfcIndexedPolyCurve.Segments rebuilt to handle n-point chains correctly
This commit is contained in:
Ryan Schultz
2026-05-15 20:49:10 -05:00
parent d2f18709cb
commit 0874c4e59c
8 changed files with 1620 additions and 1 deletions
@@ -37,9 +37,12 @@ DATA;
#30=IFCSIMPLEPROPERTYTEMPLATE('2TJn72t_v2cvBUG916Dpev',$,'CustomUnit','Dimension''s custom unit',.P_ENUMERATEDVALUE.,'IfcText',$,#31,$,$,$,.READWRITE.); #30=IFCSIMPLEPROPERTYTEMPLATE('2TJn72t_v2cvBUG916Dpev',$,'CustomUnit','Dimension''s custom unit',.P_ENUMERATEDVALUE.,'IfcText',$,#31,$,$,$,.READWRITE.);
#31=IFCPROPERTYENUMERATION('CustomUnit',(IFCTEXT('Feet and Inches - Fractional'),IFCTEXT('Feet - Decimal'),IFCTEXT('Inches - Fractional'),IFCTEXT('Inches - Decimal'),IFCTEXT('Meters'),IFCTEXT('Decimeters'),IFCTEXT('Centimeters'),IFCTEXT('Millimeters')),$); #31=IFCPROPERTYENUMERATION('CustomUnit',(IFCTEXT('Feet and Inches - Fractional'),IFCTEXT('Feet - Decimal'),IFCTEXT('Inches - Fractional'),IFCTEXT('Inches - Decimal'),IFCTEXT('Meters'),IFCTEXT('Decimeters'),IFCTEXT('Centimeters'),IFCTEXT('Millimeters')),$);
#32=IFCSIMPLEPROPERTYTEMPLATE('0gjJzDYBX8P85qn1xcAOOo',$,'Reverse_List','',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.); #32=IFCSIMPLEPROPERTYTEMPLATE('0gjJzDYBX8P85qn1xcAOOo',$,'Reverse_List','',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
#33=IFCSIMPLEPROPERTYTEMPLATE('22TrcxF8jFNB4buSmzjGEF',$,'List_Separator','',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.);
#34=IFCSIMPLEPROPERTYTEMPLATE('1Kx4Pm9nR8vBwZqTs2uYeL',$,'Separator','Characters placed between multiple dimension values when CustomUnit has more than one unit selected (default: '' / '')',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.); #34=IFCSIMPLEPROPERTYTEMPLATE('1Kx4Pm9nR8vBwZqTs2uYeL',$,'Separator','Characters placed between multiple dimension values when CustomUnit has more than one unit selected (default: '' / '')',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
#35=IFCSIMPLEPROPERTYTEMPLATE('3Nf6Qs1mT0pWxBuCvDyEzA',$,'SuppressZeroFeet','Suppress 0 feet in dimension annotation text (for example: 0'' - 3 1/2" -> 3 1/2")',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.); #35=IFCSIMPLEPROPERTYTEMPLATE('3Nf6Qs1mT0pWxBuCvDyEzA',$,'SuppressZeroFeet','Suppress 0 feet in dimension annotation text (for example: 0'' - 3 1/2" -> 3 1/2")',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
#36=IFCSIMPLEPROPERTYTEMPLATE('2Rg7Hn5jK4mLpNqOsVwXtY',$,'IsOrdinate','Show accumulated distance from the first vertex instead of individual segment lengths',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.); #36=IFCSIMPLEPROPERTYTEMPLATE('2Rg7Hn5jK4mLpNqOsVwXtY',$,'IsOrdinate','Show accumulated distance from the first vertex instead of individual segment lengths',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
#33=IFCSIMPLEPROPERTYTEMPLATE('22TrcxF8jFNB4buSmzjGEF',$,'List_Separator','',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.); #37=IFCPROPERTYSETTEMPLATE('3Qk8mPzT1rFoV9wXDyBnLe',$,'BBIM_DimensionTarget','Parametric anchor references that connect a dimension annotation to IFC geometry. Anchors is a JSON array (one entry per polyline vertex) encoding element GUID, geometry address, fingerprint, and fallback world point.',.PSET_TYPEDRIVENOVERRIDE.,'IfcAnnotation/DIMENSION,IfcAnnotation/RADIUS,IfcAnnotation/DIAMETER,IfcAnnotation/ANGLE,IfcAnnotation/PLAN_LEVEL,IfcAnnotation/SECTION_LEVEL',(#38,#39));
#38=IFCSIMPLEPROPERTYTEMPLATE('1XpRnKoT2sGuW7vYcZaMqb',$,'Anchors','JSON array of anchor descriptors — one per polyline vertex. Each entry: {"guid": str|null, "type": "FACE"|"CIRCLE_CENTER"|"WORLD", "addr": {...}, "hint": [x,y,z]|null, "pt": [x,y,z]}',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.);
#39=IFCSIMPLEPROPERTYTEMPLATE('2YqSmLoU3tHvX8wZdaNrjc',$,'MeasureAxis','Axis along which distances are projected: X | Y | Z | TRUE | PERPENDICULAR',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
ENDSEC; ENDSEC;
END-ISO-10303-21; END-ISO-10303-21;
@@ -111,6 +111,8 @@ classes = (
operator.ToggleDrawingCategorySelection, operator.ToggleDrawingCategorySelection,
operator.OpenDocumentationWebUi, operator.OpenDocumentationWebUi,
operator.FilterSelectedObjectsIfIntersectedByCamera, operator.FilterSelectedObjectsIfIntersectedByCamera,
operator.SetDimensionAnchor,
operator.RegenerateDimensions,
prop.Variable, prop.Variable,
prop.Drawing, prop.Drawing,
prop.Document, prop.Document,
@@ -196,6 +198,7 @@ def register():
bpy.types.TextCurve.BIMTextProperties = bpy.props.PointerProperty(type=prop.BIMTextProperties) bpy.types.TextCurve.BIMTextProperties = bpy.props.PointerProperty(type=prop.BIMTextProperties)
bpy.app.handlers.load_post.append(handler.load_post) bpy.app.handlers.load_post.append(handler.load_post)
bpy.app.handlers.depsgraph_update_pre.append(handler.depsgraph_update_pre_handler) bpy.app.handlers.depsgraph_update_pre.append(handler.depsgraph_update_pre_handler)
bpy.app.handlers.depsgraph_update_post.append(handler.depsgraph_update_post_handler)
bpy.types.VIEW3D_MT_image_add.append(ui.add_object_button) bpy.types.VIEW3D_MT_image_add.append(ui.add_object_button)
bpy.types.VIEW3D_MT_object_context_menu.append(menu_func) bpy.types.VIEW3D_MT_object_context_menu.append(menu_func)
@@ -211,5 +214,6 @@ def unregister():
del bpy.types.TextCurve.BIMTextProperties del bpy.types.TextCurve.BIMTextProperties
bpy.app.handlers.load_post.remove(handler.load_post) bpy.app.handlers.load_post.remove(handler.load_post)
bpy.app.handlers.depsgraph_update_pre.remove(handler.depsgraph_update_pre_handler) bpy.app.handlers.depsgraph_update_pre.remove(handler.depsgraph_update_pre_handler)
bpy.app.handlers.depsgraph_update_post.remove(handler.depsgraph_update_post_handler)
bpy.types.VIEW3D_MT_image_add.remove(ui.add_object_button) bpy.types.VIEW3D_MT_image_add.remove(ui.add_object_button)
bpy.types.VIEW3D_MT_object_context_menu.remove(menu_func) bpy.types.VIEW3D_MT_object_context_menu.remove(menu_func)
@@ -16,15 +16,63 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>. # along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import json
import bpy import bpy
import numpy as np
from bpy.app.handlers import persistent from bpy.app.handlers import persistent
import bonsai.bim.module.drawing.decoration as decoration import bonsai.bim.module.drawing.decoration as decoration
import bonsai.tool as tool import bonsai.tool as tool
# ---------------------------------------------------------------------------
# Parametric dimension auto-regeneration state
# ---------------------------------------------------------------------------
# Maps element GUID → list of annotation STEP IDs that reference it.
_dim_guid_index: dict = {}
# Persistent tessellation cache for the depsgraph handler (element id → shape).
_dim_shape_cache: dict = {}
# Set True whenever BBIM_DimensionTarget anchors change or a new file loads.
_dim_index_dirty: bool = True
# Re-entry guard so curve updates don't trigger a second handler call.
_dim_handler_running: bool = False
def invalidate_dim_index() -> None:
"""Mark the GUID index as stale so it is rebuilt on the next handler call."""
global _dim_index_dirty, _dim_shape_cache
_dim_index_dirty = True
_dim_shape_cache.clear()
def _rebuild_dim_guid_index(file) -> None:
global _dim_guid_index, _dim_index_dirty
import ifcopenshell.util.element
_dim_guid_index = {}
for annotation in file.by_type("IfcAnnotation"):
pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_DimensionTarget")
if not pset_data or not pset_data.get("Anchors"):
continue
try:
anchors = json.loads(pset_data["Anchors"])
except Exception:
continue
ann_id = annotation.id()
for anchor in anchors:
guid = anchor.get("guid")
if not guid:
continue
ids = _dim_guid_index.setdefault(guid, [])
if ann_id not in ids:
ids.append(ann_id)
_dim_index_dirty = False
@persistent @persistent
def load_post(*args): def load_post(*args):
invalidate_dim_index()
props = tool.Drawing.get_document_props() props = tool.Drawing.get_document_props()
if props.should_draw_decorations: if props.should_draw_decorations:
decoration.DecorationsHandler.install(bpy.context) decoration.DecorationsHandler.install(bpy.context)
@@ -61,3 +109,95 @@ def set_active_camera_resolution(scene: bpy.types.Scene) -> None:
raster_x, raster_y = props.update_camera_resolution() raster_x, raster_y = props.update_camera_resolution()
scene_render.resolution_x = raster_x scene_render.resolution_x = raster_x
scene_render.resolution_y = raster_y scene_render.resolution_y = raster_y
@persistent
def depsgraph_update_post_handler(scene, depsgraph):
"""Auto-regenerate parametric dimensions when referenced elements are moved."""
global _dim_handler_running, _dim_index_dirty, _dim_guid_index, _dim_shape_cache
if _dim_handler_running:
return
file = tool.Ifc.get()
if not file:
return
# Collect GUIDs of IFC objects whose transform changed this update.
moved_guids: set = set()
for update in depsgraph.updates:
obj = update.id
if not isinstance(obj, bpy.types.Object):
continue
if not update.is_updated_transform:
continue
element = tool.Ifc.get_entity(obj)
if element is None or not hasattr(element, "GlobalId"):
continue
moved_guids.add(element.GlobalId)
if not moved_guids:
return
if _dim_index_dirty:
_rebuild_dim_guid_index(file)
annotation_ids: set = set()
for guid in moved_guids:
for ann_id in _dim_guid_index.get(guid, []):
annotation_ids.add(ann_id)
if not annotation_ids:
return
import ifcopenshell.api.drawing as drawing_api
import ifcopenshell.geom
import ifcopenshell.util.element
from bonsai.bim.module.drawing.operator import _update_blender_curve
geom_settings = ifcopenshell.geom.settings()
geom_settings.set("APPLY_DEFAULT_MATERIALS", False)
_dim_handler_running = True
try:
for ann_id in annotation_ids:
try:
annotation = file.by_id(ann_id)
except Exception:
continue
pset = ifcopenshell.util.element.get_pset(annotation, "BBIM_DimensionTarget")
if not pset:
continue
placement_override: dict = {}
try:
anchors_raw = json.loads(pset.get("Anchors") or "[]")
for anchor in anchors_raw:
guid = anchor.get("guid")
if not guid:
continue
try:
elem = file.by_guid(guid)
elem_id = elem.id()
if elem_id in placement_override:
continue
elem_obj = tool.Ifc.get_object(elem)
if elem_obj:
placement_override[elem_id] = np.array(elem_obj.matrix_world)
except Exception:
pass
except Exception:
pass
resolved_pts = drawing_api.regenerate_dimension(
file,
annotation,
settings=geom_settings,
shape_cache=_dim_shape_cache,
placement_override=placement_override,
)
if resolved_pts:
_update_blender_curve(annotation, resolved_pts)
finally:
_dim_handler_running = False
@@ -5560,3 +5560,521 @@ class ShowElementValuesInstructions(bpy.types.Operator):
def execute(self, context): def execute(self, context):
return {"FINISHED"} return {"FINISHED"}
# ---------------------------------------------------------------------------
# Parametric dimension operators
# ---------------------------------------------------------------------------
class SetDimensionAnchor(bpy.types.Operator):
"""Interactively anchor dimension vertices to IFC element faces.
Two-phase modal workflow (all in Object Mode, no Tab required):
1. Run the operator with a dimension annotation selected.
2. Click a vertex ON the dimension line to select it.
3. Click an IFC element face to anchor that vertex to it.
ALT+click sets a free world-point anchor instead.
4. Repeat steps 2-3 for more vertices.
5. RMB or ESC to finish.
"""
bl_idname = "bim.set_dimension_anchor"
bl_label = "Set Dimension Anchor"
bl_options = {"REGISTER", "UNDO"}
if TYPE_CHECKING:
pass
_annotation: Optional[ifcopenshell.entity_instance] = None
_annotation_obj: Optional[bpy.types.Object] = None
_phase: str = "PICK_VERTEX" # "PICK_VERTEX" | "PICK_FACE"
_active_vertex_idx: int = -1
_shape_cache: dict
_VERTEX_PICK_RADIUS_PX = 20 # pixels — how close the click must be to a vertex
@classmethod
def poll(cls, context):
if not tool.Ifc.get():
cls.poll_message_set("No IFC file loaded.")
return False
obj = context.active_object
if not obj:
cls.poll_message_set("No active object.")
return False
if context.mode != "OBJECT":
cls.poll_message_set("Must be in Object Mode.")
return False
element = tool.Ifc.get_entity(obj)
if not element or not element.is_a("IfcAnnotation"):
cls.poll_message_set("Active object must be an IfcAnnotation.")
return False
ptype = ifcopenshell.util.element.get_predefined_type(element)
if ptype not in ("DIMENSION", "RADIUS", "DIAMETER", "ANGLE", "PLAN_LEVEL", "SECTION_LEVEL"):
cls.poll_message_set("Annotation must be a dimension type.")
return False
return True
def invoke(self, context, event):
obj = context.active_object
self._annotation = tool.Ifc.get_entity(obj)
self._annotation_obj = obj
self._phase = "PICK_VERTEX"
self._active_vertex_idx = -1
self._shape_cache = {}
self._set_status(context)
context.window_manager.modal_handler_add(self)
return {"RUNNING_MODAL"}
def modal(self, context, event):
if event.type == "ESC" or (event.type == "RIGHTMOUSE" and event.value == "PRESS"):
context.workspace.status_text_set(None)
return {"FINISHED"} # keep any anchors already written
if event.type == "LEFTMOUSE" and event.value == "PRESS":
if self._phase == "PICK_VERTEX":
self._handle_vertex_pick(context, event)
else:
self._handle_face_pick(context, event)
self._set_status(context)
return {"RUNNING_MODAL"}
return {"PASS_THROUGH"}
# ------------------------------------------------------------------
# Status bar
def _set_status(self, context):
if self._phase == "PICK_VERTEX":
context.workspace.status_text_set(
"Click a dimension vertex | RMB / ESC: Finish"
)
else:
context.workspace.status_text_set(
f"Vertex {self._active_vertex_idx} selected — "
"Click element face to anchor | ALT+Click: free world point | RMB / ESC: Finish"
)
# ------------------------------------------------------------------
# Phase 1: pick a vertex on the dimension curve
def _handle_vertex_pick(self, context, event):
from bpy_extras import view3d_utils
region = context.region
rv3d = context.region_data
if not region or not rv3d:
return
coord = (event.mouse_region_x, event.mouse_region_y)
obj = self._annotation_obj
best_idx = None
best_dist_sq = self._VERTEX_PICK_RADIUS_PX ** 2
if obj.data and hasattr(obj.data, "splines"):
for spline in obj.data.splines:
for i, pt in enumerate(spline.points):
world_co = obj.matrix_world @ pt.co.xyz
screen_co = view3d_utils.location_3d_to_region_2d(region, rv3d, world_co)
if screen_co is None:
continue
dist_sq = (screen_co.x - coord[0]) ** 2 + (screen_co.y - coord[1]) ** 2
if dist_sq < best_dist_sq:
best_dist_sq = dist_sq
best_idx = i
if best_idx is None:
self.report({"WARNING"}, f"Click closer to a dimension vertex (within {self._VERTEX_PICK_RADIUS_PX}px)")
return
self._active_vertex_idx = best_idx
self._phase = "PICK_FACE"
# ------------------------------------------------------------------
# Phase 2: pick a face on an IFC element
def _handle_face_pick(self, context, event):
from bpy_extras import view3d_utils
region = context.region
rv3d = context.region_data
if not region or not rv3d:
return
coord = (event.mouse_region_x, event.mouse_region_y)
# ALT+click → free world-point anchor at the cursor 3D location
if event.alt:
origin = view3d_utils.region_2d_to_origin_3d(region, rv3d, coord)
direction = view3d_utils.region_2d_to_vector_3d(region, rv3d, coord)
hit, location, *_ = context.scene.ray_cast(context.view_layer.depsgraph, origin, direction)
pt_m = tuple(location) if hit else tuple(origin + direction * 5.0)
import ifcopenshell.api.drawing as drawing_api
anchor = drawing_api.make_world_anchor(list(pt_m))
self._write_anchor(anchor, self._active_vertex_idx)
self.report({"INFO"}, f"Vertex {self._active_vertex_idx} → free world point")
self._phase = "PICK_VERTEX"
return
# Normal click → raycast for IFC element face
origin = view3d_utils.region_2d_to_origin_3d(region, rv3d, coord)
direction = view3d_utils.region_2d_to_vector_3d(region, rv3d, coord)
hit, location, normal, face_index, hit_obj, _ = context.scene.ray_cast(
context.view_layer.depsgraph, origin, direction
)
if not hit or hit_obj is None:
self.report({"WARNING"}, "Nothing under cursor — click on a model element")
return
if hit_obj == self._annotation_obj:
self.report({"WARNING"}, "Click on an element, not the dimension line itself")
return
element = tool.Ifc.get_entity(hit_obj)
if not element:
self.report({"WARNING"}, f"'{hit_obj.name}' is not an IFC element")
return
file = tool.Ifc.get()
hit_m = (float(location.x), float(location.y), float(location.z))
normal_m = (float(normal.x), float(normal.y), float(normal.z))
# Pass the Blender matrix_world so face-group matching uses current position.
placement_override = {element.id(): np.array(hit_obj.matrix_world)}
import ifcopenshell.api.drawing as drawing_api
anchor = drawing_api.build_anchor_from_hit(
file, element, hit_m, normal_m,
shape_cache=self._shape_cache,
placement_override=placement_override,
)
self._write_anchor(anchor, self._active_vertex_idx)
self.report(
{"INFO"},
f"Vertex {self._active_vertex_idx}{element.is_a()}/{element.Name or element.GlobalId}",
)
self._phase = "PICK_VERTEX"
# ------------------------------------------------------------------
# Pset write (shared by both face and free-point paths)
def _write_anchor(self, new_anchor: dict, vertex_index: int) -> None:
file = tool.Ifc.get()
annotation = self._annotation
pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_DimensionTarget")
if pset_data and pset_data.get("Anchors"):
try:
anchors: list = json.loads(pset_data["Anchors"])
except Exception:
anchors = []
else:
anchors = _anchors_from_spline(self._annotation_obj, file)
while len(anchors) <= vertex_index:
obj = self._annotation_obj
idx = len(anchors)
if obj and obj.data and hasattr(obj.data, "splines") and obj.data.splines:
pts = obj.data.splines[0].points
if idx < len(pts):
co = obj.matrix_world @ pts[idx].co.xyz
import ifcopenshell.api.drawing as drawing_api
anchors.append(drawing_api.make_world_anchor([float(co.x), float(co.y), float(co.z)]))
continue
import ifcopenshell.api.drawing as drawing_api
anchors.append(drawing_api.make_world_anchor([0.0, 0.0, 0.0]))
anchors[vertex_index] = new_anchor
anchors_json = json.dumps(anchors)
if pset_data:
pset_entity = file.by_id(pset_data["id"])
ifcopenshell.api.run("pset.edit_pset", file, pset=pset_entity, properties={"Anchors": anchors_json})
else:
ifcopenshell.api.run("pset.add_pset", file, product=annotation, name="BBIM_DimensionTarget")
pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_DimensionTarget")
pset_entity = file.by_id(pset_data["id"])
ifcopenshell.api.run("pset.edit_pset", file, pset=pset_entity, properties={"Anchors": anchors_json})
from bonsai.bim.module.drawing import handler as _drawing_handler
_drawing_handler.invalidate_dim_index()
class RegenerateDimensions(bpy.types.Operator, tool.Ifc.Operator):
"""Regenerate all parametric dimension annotations in the project.
For every IfcAnnotation that has a BBIM_DimensionTarget pset, resolve all
anchor references from live element geometry and update the annotation's
curve vertices and linked IfcMetric values.
"""
bl_idname = "bim.regenerate_dimensions"
bl_label = "Regenerate Dimensions"
bl_description = (
"Recompute all parametric dimension annotations from current element geometry.\n"
"Updates curve vertex positions and IfcMetric segment values."
)
bl_options = {"REGISTER", "UNDO"}
active_only: bpy.props.BoolProperty(
name="Active Only",
description="Only regenerate the currently selected dimension annotation",
default=False,
)
if TYPE_CHECKING:
active_only: bool
@classmethod
def poll(cls, context):
return bool(tool.Ifc.get())
def _execute(self, context):
import ifcopenshell.api.drawing as drawing_api
import ifcopenshell.geom
file = tool.Ifc.get()
geom_settings = ifcopenshell.geom.settings()
geom_settings.set("APPLY_DEFAULT_MATERIALS", False)
shape_cache: dict = {}
if self.active_only:
obj = context.active_object
if not obj:
self.report({"WARNING"}, "No active object.")
return
element = tool.Ifc.get_entity(obj)
if not element or not element.is_a("IfcAnnotation"):
self.report({"WARNING"}, "Active object is not an IfcAnnotation.")
return
candidates = [element]
else:
candidates = [
a for a in file.by_type("IfcAnnotation")
if ifcopenshell.util.element.get_pset(a, "BBIM_DimensionTarget")
]
updated = 0
for annotation in candidates:
pset = ifcopenshell.util.element.get_pset(annotation, "BBIM_DimensionTarget")
if not pset:
continue
# Build a placement override from each referenced element's current
# Blender matrix_world. Bonsai only syncs ObjectPlacement to the IFC
# file when the user explicitly clicks "Edit Object Placement" — so the
# IFC entity may be stale after a viewport G-move. Using matrix_world
# ensures we always see the current element position.
placement_override: dict[int, "np.ndarray"] = {}
try:
anchors_raw = json.loads(pset.get("Anchors") or "[]")
for anchor in anchors_raw:
guid = anchor.get("guid")
if not guid:
continue
try:
elem = file.by_guid(guid)
elem_id = elem.id()
if elem_id in placement_override:
continue
elem_obj = tool.Ifc.get_object(elem)
if elem_obj:
placement_override[elem_id] = np.array(elem_obj.matrix_world)
except Exception:
pass
except Exception:
pass
resolved_pts = drawing_api.regenerate_dimension(
file, annotation,
settings=geom_settings,
shape_cache=shape_cache,
placement_override=placement_override,
)
if not resolved_pts:
continue
_update_blender_curve(annotation, resolved_pts)
updated += 1
self.report({"INFO"}, f"Regenerated {updated} parametric dimension(s).")
# ---------------------------------------------------------------------------
# Helpers for dimension operators
# ---------------------------------------------------------------------------
def _anchors_from_spline(obj: bpy.types.Object, file: ifcopenshell.file) -> list:
"""Build a list of WORLD anchors from the current spline points of obj.
Coordinates are stored in metres (Blender world space), which matches the
output of ifcopenshell.geom.create_shape regardless of IFC project unit.
"""
import ifcopenshell.api.drawing as drawing_api
anchors = []
if not obj or not obj.data or not hasattr(obj.data, "splines") or not obj.data.splines:
return anchors
for pt in obj.data.splines[0].points:
world_co = obj.matrix_world @ pt.co.xyz
# Store in metres (Blender world space)
pt_m = [float(world_co.x), float(world_co.y), float(world_co.z)]
anchors.append(drawing_api.make_world_anchor(pt_m))
return anchors
def _update_blender_curve(
annotation: ifcopenshell.entity_instance,
resolved_pts_m: list,
) -> None:
"""Update a Blender curve object's spline points AND the backing IFC IfcPolyline.
:param resolved_pts_m: Points in metres (Blender world space).
Both the Blender curve data and the IFC representation are updated so that
entering Edit Mode (which reloads geometry from IFC via import_representation_items)
does not reset the curve back to pre-regeneration positions.
"""
obj = tool.Ifc.get_object(annotation)
if not obj or not obj.data or not hasattr(obj.data, "splines"):
return
curve_data: bpy.types.Curve = obj.data
inv_world = obj.matrix_world.inverted()
n = len(resolved_pts_m)
if not curve_data.splines:
spline = curve_data.splines.new("POLY")
spline.points.add(n - 1)
else:
spline = curve_data.splines[0]
if len(spline.points) != n:
curve_data.splines.remove(spline)
spline = curve_data.splines.new("POLY")
spline.points.add(n - 1)
is_2d = _annotation_is_2d(annotation)
for i, pt_m in enumerate(resolved_pts_m):
blender_world = Vector((float(pt_m[0]), float(pt_m[1]), float(pt_m[2])))
local_pt = inv_world @ blender_world
# For 2D (plan-view) annotations, project onto the annotation plane by
# zeroing local Z — matching the Annotator.add_line_to_annotation pattern.
if is_2d:
spline.points[i].co = (local_pt.x, local_pt.y, 0.0, 1.0)
else:
spline.points[i].co = (*local_pt, 1.0)
# Also update the IFC IfcPolyline so Edit Mode reloads reflect the new positions.
_update_ifc_polyline(tool.Ifc.get(), annotation, obj, resolved_pts_m)
def _annotation_is_2d(annotation: ifcopenshell.entity_instance) -> bool:
"""Return True if the annotation's representation uses 2D coordinates (plan view)."""
if not getattr(annotation, "Representation", None):
return False
for rep in annotation.Representation.Representations:
curve = _find_curve_item(rep)
if curve is None:
continue
if curve.is_a("IfcIndexedPolyCurve"):
return curve.Points.is_a("IfcCartesianPointList2D")
if curve.is_a("IfcPolyline") and curve.Points:
return len(curve.Points[0].Coordinates) == 2
return False
def _update_ifc_polyline(
file: ifcopenshell.file,
annotation: ifcopenshell.entity_instance,
obj: bpy.types.Object,
resolved_pts_m: list,
) -> None:
"""Update the curve coordinates in the annotation's IFC representation.
Converts world-space metres points annotation-local IFC project units and
writes them into the existing IfcIndexedPolyCurve or IfcPolyline entities.
Handles both 2D (IfcCartesianPointList2D) and 3D representations.
"""
if not resolved_pts_m or not getattr(annotation, "Representation", None):
return
import ifcopenshell.util.unit as ifc_unit
unit_scale = ifc_unit.calculate_unit_scale(file)
inv_world = obj.matrix_world.inverted()
def _to_ifc_local(pt_m: tuple) -> tuple:
blender_local = inv_world @ Vector((float(pt_m[0]), float(pt_m[1]), float(pt_m[2])))
return (
float(blender_local.x) / unit_scale,
float(blender_local.y) / unit_scale,
float(blender_local.z) / unit_scale,
)
new_coords = [_to_ifc_local(pt) for pt in resolved_pts_m]
for rep in annotation.Representation.Representations:
curve = _find_curve_item(rep)
if curve is None:
continue
if curve.is_a("IfcIndexedPolyCurve"):
pts_list = curve.Points # IfcCartesianPointList2D or 3D
n_dims = 2 if pts_list.is_a("IfcCartesianPointList2D") else 3
pts_list.CoordList = tuple(coords[:n_dims] for coords in new_coords)
# Rebuild Segments to cover all consecutive pairs. Bonsai creates
# explicit IfcLineIndex entries per segment; leaving a stale Segments
# list (e.g. [IfcLineIndex([1,2])]) after adding a 3rd point means
# the extra point is silently ignored on geometry reload.
n_pts = len(new_coords)
if n_pts >= 2:
curve.Segments = [file.createIfcLineIndex([i + 1, i + 2]) for i in range(n_pts - 1)]
else:
curve.Segments = None
return
if curve.is_a("IfcPolyline"):
existing = list(curve.Points)
if len(existing) == len(new_coords):
for ifc_pt, coords in zip(existing, new_coords):
n_dims = len(ifc_pt.Coordinates)
ifc_pt.Coordinates = coords[:n_dims]
else:
dim = len(existing[0].Coordinates) if existing else 3
curve.Points = [
file.create_entity("IfcCartesianPoint", Coordinates=coords[:dim])
for coords in new_coords
]
return
def _find_curve_item(rep: ifcopenshell.entity_instance) -> Optional[ifcopenshell.entity_instance]:
"""Return the first IfcPolyline or IfcIndexedPolyCurve in a shape representation."""
for item in rep.Items:
result = _find_curve_in_item(item)
if result is not None:
return result
return None
def _find_curve_in_item(item: ifcopenshell.entity_instance) -> Optional[ifcopenshell.entity_instance]:
if item.is_a("IfcPolyline") or item.is_a("IfcIndexedPolyCurve"):
return item
if item.is_a("IfcGeometricCurveSet"):
for element in item.Elements:
result = _find_curve_in_item(element)
if result is not None:
return result
return None
@@ -571,6 +571,19 @@ class BIM_PT_product_assignments(Panel):
col.operator("bim.select_assigned_product", icon="RESTRICT_SELECT_OFF", text="") col.operator("bim.select_assigned_product", icon="RESTRICT_SELECT_OFF", text="")
col.enabled = bool(ProductAssignmentsData.data["relating_product"]) col.enabled = bool(ProductAssignmentsData.data["relating_product"])
# Parametric dimension controls
element = tool.Ifc.get_entity(obj)
if element:
import ifcopenshell.util.element
ptype = ifcopenshell.util.element.get_predefined_type(element)
if ptype in ("DIMENSION", "RADIUS", "DIAMETER", "ANGLE", "PLAN_LEVEL", "SECTION_LEVEL"):
self.layout.separator()
self.layout.label(text="Parametric Dimension", icon="CONSTRAINT")
row = self.layout.row(align=True)
row.operator("bim.set_dimension_anchor", icon="PIVOT_CURSOR")
op = row.operator("bim.regenerate_dimensions", icon="FILE_REFRESH", text="Regenerate")
op.active_only = True
def get_category_icon(category_name): def get_category_icon(category_name):
"""Get appropriate icon for each category""" """Get appropriate icon for each category"""
@@ -25,12 +25,19 @@ annotations may have relationships which indicate smart data being populated.
from .. import wrap_usecases from .. import wrap_usecases
from .assign_product import assign_product from .assign_product import assign_product
from .edit_text_literal import edit_text_literal from .edit_text_literal import edit_text_literal
from .regenerate_dimension import regenerate_dimension, get_dimension_segment_lengths
from .resolve_anchor import build_anchor_from_hit, make_world_anchor, resolve_anchor
from .unassign_product import unassign_product from .unassign_product import unassign_product
wrap_usecases(__path__, __name__) wrap_usecases(__path__, __name__)
__all__ = [ __all__ = [
"assign_product", "assign_product",
"build_anchor_from_hit",
"edit_text_literal", "edit_text_literal",
"get_dimension_segment_lengths",
"make_world_anchor",
"regenerate_dimension",
"resolve_anchor",
"unassign_product", "unassign_product",
] ]
@@ -0,0 +1,238 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2021 Dion Moult <dion@thinkmoult.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
"""Regenerate a parametric dimension annotation from its BBIM_DimensionTarget anchors.
This module operates purely on IFC data. It:
1. Reads the ``Anchors`` JSON array from the ``BBIM_DimensionTarget`` pset on an
``IfcAnnotation``.
2. Resolves each anchor to a world-space point (IFC project units) using
``resolve_anchor``.
3. Computes per-segment distances and updates (or creates) the linked
``IfcMetric`` + ``IfcRelAssociatesConstraint`` entities.
4. Returns the ordered list of resolved world-space points so that the
Bonsai operator layer can update the Blender curve object.
Updating the Blender curve (converting IFC world coords annotation local
coords) is the *caller's* responsibility and does **not** happen here.
"""
from __future__ import annotations
import json
import math
from typing import Optional
import ifcopenshell
import ifcopenshell.api.owner
import ifcopenshell.api.pset
import ifcopenshell.geom
import ifcopenshell.guid
import ifcopenshell.util.element
from .resolve_anchor import resolve_anchor
_PSET_NAME = "BBIM_DimensionTarget"
_METRIC_INTENT_PREFIX = "PARAMETRIC_DIMENSION_SEG_"
def regenerate_dimension(
file: ifcopenshell.file,
annotation: ifcopenshell.entity_instance,
settings: Optional[ifcopenshell.geom.settings] = None,
shape_cache: Optional[dict] = None,
placement_override: Optional[dict] = None,
) -> list[tuple[float, float, float]]:
"""Regenerate a parametric dimension from its stored anchor references.
Resolves every anchor in ``BBIM_DimensionTarget.Anchors``, updates the
per-segment ``IfcMetric`` values (creating them when absent), and returns
the resolved world-space points in metres.
:param file: The open IFC file.
:param annotation: An ``IfcAnnotation`` with a ``BBIM_DimensionTarget`` pset.
:param settings: Geometry settings for tessellation (shared across calls).
:param shape_cache: Shape cache dict (shared across calls for performance).
:param placement_override: Optional dict mapping element STEP id 4×4 numpy
matrix (metres, row-major). Pass ``{elem.id(): np.array(obj.matrix_world)}``
for each referenced element so that viewport moves not yet synced to the
IFC ``ObjectPlacement`` are reflected. See ``resolve_anchor`` for details.
:return: Ordered list of ``(x, y, z)`` tuples, one per anchor.
Empty list if the pset is missing or malformed.
"""
pset_data = ifcopenshell.util.element.get_pset(annotation, _PSET_NAME)
if not pset_data or "Anchors" not in pset_data:
return []
try:
anchors: list[dict] = json.loads(pset_data["Anchors"])
except (json.JSONDecodeError, TypeError):
return []
if not anchors:
return []
if shape_cache is None:
shape_cache = {}
resolved: list[Optional[tuple]] = []
for anchor in anchors:
pt = resolve_anchor(file, anchor, settings, shape_cache, placement_override)
if pt is None:
pt = tuple(anchor["pt"]) if anchor.get("pt") else (0.0, 0.0, 0.0)
resolved.append(pt)
anchor["pt"] = list(pt)
pset_entity_id = pset_data.get("id")
if pset_entity_id:
pset_entity = file.by_id(pset_entity_id)
ifcopenshell.api.pset.edit_pset(
file,
pset=pset_entity,
properties={"Anchors": json.dumps(anchors)},
)
n_segments = len(resolved) - 1
if n_segments >= 1:
existing_metrics = _get_segment_metrics(file, annotation)
_sync_segment_metrics(file, annotation, resolved, existing_metrics)
return [pt for pt in resolved if pt is not None]
def get_dimension_segment_lengths(
file: ifcopenshell.file,
annotation: ifcopenshell.entity_instance,
) -> list[float]:
"""Return the segment lengths for a parametric dimension from stored anchor pts.
Distances are computed from the cached ``pt`` fields in ``BBIM_DimensionTarget.Anchors``
(in metres, matching ifcopenshell.geom output). Returns an empty list if the pset
is absent or malformed.
"""
pset_data = ifcopenshell.util.element.get_pset(annotation, _PSET_NAME)
if not pset_data or not pset_data.get("Anchors"):
return []
try:
anchors: list[dict] = json.loads(pset_data["Anchors"])
except Exception:
return []
lengths: list[float] = []
for i in range(len(anchors) - 1):
pt_a = anchors[i].get("pt")
pt_b = anchors[i + 1].get("pt")
if pt_a and pt_b:
lengths.append(_dist(tuple(pt_a), tuple(pt_b)))
else:
lengths.append(0.0)
return lengths
# ---------------------------------------------------------------------------
# IfcMetric / IfcRelAssociatesConstraint management
# ---------------------------------------------------------------------------
def _get_segment_metrics(
file: ifcopenshell.file,
annotation: ifcopenshell.entity_instance,
) -> dict[int, ifcopenshell.entity_instance]:
"""Return {segment_index: IfcMetric} for all constraint rels on the annotation."""
metrics: dict[int, ifcopenshell.entity_instance] = {}
for rel in annotation.HasAssociations:
if not rel.is_a("IfcRelAssociatesConstraint"):
continue
intent: str = rel.Intent or ""
if not intent.startswith(_METRIC_INTENT_PREFIX):
continue
try:
seg_idx = int(intent[len(_METRIC_INTENT_PREFIX):])
except ValueError:
continue
constraint = rel.RelatingConstraint
if constraint.is_a("IfcMetric"):
metrics[seg_idx] = constraint
return metrics
def _sync_segment_metrics(
file: ifcopenshell.file,
annotation: ifcopenshell.entity_instance,
resolved_pts: list[tuple],
existing: dict[int, ifcopenshell.entity_instance],
) -> None:
"""Create missing and update existing IfcMetric entities for each segment."""
n_segments = len(resolved_pts) - 1
seen_guids: set[str] = set()
# Build a lookup of which elements are at each anchor endpoint
pset_data = ifcopenshell.util.element.get_pset(annotation, _PSET_NAME)
anchors: list[dict] = []
if pset_data and pset_data.get("Anchors"):
try:
anchors = json.loads(pset_data["Anchors"])
except Exception:
pass
for seg_idx in range(n_segments):
if seg_idx in existing:
pass # metric already exists; association is still valid
else:
# Create new IfcMetric + IfcRelAssociatesConstraint
# DataValue is IfcMetricValueSelect (entity-only SELECT in IFC4) — omit it;
# the measured distance is derivable from the anchor pt fields.
metric = file.create_entity(
"IfcMetric",
Name=f"seg_{seg_idx}",
ConstraintGrade="ADVISORY",
Benchmark="EQUALTO",
)
# Gather related products for this segment (the two anchor elements)
related: list[ifcopenshell.entity_instance] = [annotation]
for anchor_idx in (seg_idx, seg_idx + 1):
if anchor_idx < len(anchors):
guid = anchors[anchor_idx].get("guid")
if guid and guid not in seen_guids:
try:
elem = file.by_guid(guid)
related.append(elem)
seen_guids.add(guid)
except Exception:
pass
file.create_entity(
"IfcRelAssociatesConstraint",
GlobalId=ifcopenshell.guid.new(),
OwnerHistory=ifcopenshell.api.owner.create_owner_history(file),
Intent=f"{_METRIC_INTENT_PREFIX}{seg_idx}",
RelatingConstraint=metric,
RelatedObjects=related,
)
# Remove orphaned metrics for segments that no longer exist
for seg_idx, metric in existing.items():
if seg_idx >= n_segments:
for rel in file.get_inverse(metric):
if rel.is_a("IfcRelAssociatesConstraint"):
file.remove(rel)
file.remove(metric)
def _dist(a: tuple, b: tuple) -> float:
return math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2)
@@ -0,0 +1,696 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2021 Dion Moult <dion@thinkmoult.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
"""Resolve a parametric dimension anchor to a world-space coordinate in metres.
NOTE ON COORDINATE SPACE
ifcopenshell.geom.create_shape() always outputs geometry in **metres** (its
internal unit), regardless of the IFC project's declared length unit (feet, mm,
etc.). All anchor coordinates (``pt``, ``hint``, fingerprint ``centroid``) are
therefore stored in metres, which is also Blender world space. The IFC
project's unit_scale is NOT applied here. Callers that need IFC project units
must divide by ``ifcopenshell.util.unit.calculate_unit_scale(file)`` themselves.
Anchor schema (JSON-serialisable dict stored in BBIM_DimensionTarget.Anchors):
{
"guid": str | None, # element GlobalId; None → WORLD type (free point)
"type": str, # "FACE" | "CIRCLE_CENTER" | "WORLD"
"addr": {
"method": str, # "ANALYTIC" | "TESS_INDEX" | "TESS_FINGERPRINT"
"repr_id": int, # STEP id of representation item (ANALYTIC / TESS_INDEX)
"repr_type": str, # IFC class of representation item
"face_role": str, # "TOP" | "BOTTOM" | "SIDE_<n>" (IfcExtrudedAreaSolid only)
"tess_index": int, # coplanar face-group index (-1 = skip)
"fingerprint": {
"normal": [x, y, z], # world-space unit normal (IFC project units)
"area": float, # total face area
"centroid": [x, y, z] # area-weighted centroid
}
} | None,
"hint": [x, y, z] | None, # original click position for disambiguation
"pt": [x, y, z] # last resolved position — used as fallback
}
"""
from __future__ import annotations
import math
from typing import Optional
import ifcopenshell
import ifcopenshell.geom
import ifcopenshell.util.placement
import ifcopenshell.util.unit
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def resolve_anchor(
file: ifcopenshell.file,
anchor: dict,
settings: Optional[ifcopenshell.geom.settings] = None,
shape_cache: Optional[dict] = None,
placement_override: Optional[dict] = None,
) -> Optional[tuple[float, float, float]]:
"""Resolve an anchor dict to a world-space point in metres.
Resolution order:
1. WORLD / null guid return stored ``pt`` directly.
2. ANALYTIC for IfcExtrudedAreaSolid analytical TOP/BOTTOM face centre.
3. TESS_INDEX centroid of a pre-recorded face group by index.
4. TESS_FINGERPRINT best face group matched by normal + centroid proximity.
5. Fallback stored ``pt``.
:param file: The open IFC file.
:param anchor: Anchor descriptor dict.
:param settings: ifcopenshell.geom settings; created automatically when None.
:param shape_cache: Mutable dict keyed by element STEP id to cache shapes.
:param placement_override: Optional dict mapping element STEP id 4×4 numpy
matrix (row-major, metres). When provided, this matrix is used instead of
``element.ObjectPlacement`` for the localworld transform. Pass the
Blender object's ``matrix_world`` here so that elements moved in the
viewport but not yet explicitly synced to IFC are handled correctly.
:return: ``(x, y, z)`` in metres, or ``None``.
"""
anchor_type = anchor.get("type", "WORLD")
guid = anchor.get("guid")
if anchor_type == "WORLD" or not guid:
return _pt_or_none(anchor.get("pt"))
try:
element = file.by_guid(guid)
except Exception:
return _pt_or_none(anchor.get("pt"))
addr = anchor.get("addr") or {}
method = addr.get("method", "TESS_FINGERPRINT")
# --- 1. Analytical path (fast, exact) ---
if method == "ANALYTIC" and addr.get("repr_type") == "IfcExtrudedAreaSolid":
pt = _resolve_extruded_area_solid_analytic(file, element, addr, placement_override)
if pt is not None:
return pt
# --- 2 & 3. Tessellation path (universal) ---
shape = _get_shape(file, element, settings, shape_cache)
if shape is None:
return _pt_or_none(anchor.get("pt"))
verts, tris = _extract_mesh(shape)
if not tris:
return _pt_or_none(anchor.get("pt"))
groups = _group_coplanar_tris(verts, tris)
group_props = [_face_group_props(g, verts, tris) for g in groups]
# group_props centroids/normals are in LOCAL metres (no USE_WORLD_COORDS).
# Build world-space equivalents using placement_override (Blender matrix_world)
# when available, otherwise fall back to element.ObjectPlacement from IFC.
world_group_props = [
{
"centroid": _local_to_world_m(file, element, gp["centroid"], placement_override),
"normal": _rotate_local_to_world(element, gp["normal"], placement_override),
"area": gp["area"],
}
for gp in group_props
]
# TESS_INDEX (fast, index into the cached face-group list)
tess_index = addr.get("tess_index", -1)
if 0 <= tess_index < len(groups):
return world_group_props[tess_index]["centroid"]
# TESS_FINGERPRINT (robust across topology changes)
fingerprint = addr.get("fingerprint")
hint = anchor.get("hint")
if fingerprint:
pt = _find_by_fingerprint(world_group_props, fingerprint, hint)
if pt is not None:
return pt
return _pt_or_none(anchor.get("pt"))
def build_anchor_from_hit(
file: ifcopenshell.file,
element: ifcopenshell.entity_instance,
hit_location_ifc: tuple[float, float, float],
hit_normal_ifc: tuple[float, float, float],
settings: Optional[ifcopenshell.geom.settings] = None,
shape_cache: Optional[dict] = None,
placement_override: Optional[dict] = None,
) -> dict:
"""Build an anchor dict from a viewport ray-cast hit.
Tessellates the element, finds the best-matching face group for the hit
normal/location, computes the fingerprint, and optionally detects an
IfcExtrudedAreaSolid face role (TOP/BOTTOM) for the analytical path.
:param file: The open IFC file.
:param element: The IFC element that was hit.
:param hit_location_ifc: Hit point in metres (world space).
:param hit_normal_ifc: Face normal at the hit point (world space, unit vec).
:param settings: Geometry settings for tessellation.
:param shape_cache: Mutable shape-cache dict.
:param placement_override: Optional dict mapping element STEP id 4×4 numpy
matrix (metres). See ``resolve_anchor`` for details.
:return: Anchor dict ready for JSON serialisation into BBIM_DimensionTarget.
"""
shape = _get_shape(file, element, settings, shape_cache)
tess_index = -1
fingerprint: dict = {
"normal": list(hit_normal_ifc),
"area": 0.0,
"centroid": list(hit_location_ifc),
}
if shape is not None:
verts, tris = _extract_mesh(shape)
groups = _group_coplanar_tris(verts, tris)
local_group_props = [_face_group_props(g, verts, tris) for g in groups]
world_group_props = [
{
"centroid": _local_to_world_m(file, element, gp["centroid"], placement_override),
"normal": _rotate_local_to_world(element, gp["normal"], placement_override),
"area": gp["area"],
}
for gp in local_group_props
]
best = _best_group(world_group_props, hit_normal_ifc, hit_location_ifc)
if best is not None:
tess_index, props = best
fingerprint = {
"normal": list(props["normal"]),
"area": props["area"],
"centroid": list(props["centroid"]),
}
repr_type, repr_id, face_role = _detect_extruded_face(file, element, hit_location_ifc, hit_normal_ifc)
method = "ANALYTIC" if repr_type == "IfcExtrudedAreaSolid" else "TESS_FINGERPRINT"
return {
"guid": element.GlobalId,
"type": "FACE",
"addr": {
"method": method,
"repr_id": repr_id,
"repr_type": repr_type,
"face_role": face_role,
"tess_index": tess_index,
"fingerprint": fingerprint,
},
"hint": list(hit_location_ifc),
"pt": list(hit_location_ifc),
}
def make_world_anchor(pt_ifc: tuple[float, float, float]) -> dict:
"""Build a free-floating (WORLD) anchor — not connected to any element."""
return {
"guid": None,
"type": "WORLD",
"addr": None,
"hint": None,
"pt": list(pt_ifc),
}
# ---------------------------------------------------------------------------
# Mesh extraction helpers
# ---------------------------------------------------------------------------
def _get_shape(file, element, settings, shape_cache):
if shape_cache is None:
shape_cache = {}
elem_id = element.id()
if elem_id in shape_cache:
return shape_cache[elem_id]
if settings is None:
settings = ifcopenshell.geom.settings()
# Do NOT set USE_WORLD_COORDS — tessellate in local (element-origin) space.
# The geom kernel caches by representation ID; with USE_WORLD_COORDS=True,
# moving an element would return stale world-space coords from the cache.
# We apply the current placement manually via placement_override.
settings.set("APPLY_DEFAULT_MATERIALS", False)
try:
shape = ifcopenshell.geom.create_shape(settings, element)
except Exception:
shape = None
shape_cache[elem_id] = shape
return shape
def _local_to_world_m(
file: ifcopenshell.file,
element: ifcopenshell.entity_instance,
local_pt_m: tuple,
placement_override: Optional[dict] = None,
) -> tuple[float, float, float]:
"""Convert a local-space point (metres, from create_shape without USE_WORLD_COORDS)
to a world-space point in metres.
When *placement_override* contains the element's STEP id, that 4×4 matrix
(row-major, already in metres typically ``np.array(obj.matrix_world)``) is
used instead of reading ``element.ObjectPlacement`` from the IFC file. This
ensures that elements moved in the Blender viewport but not yet explicitly
synced to IFC (via "Edit Object Placement") are handled correctly.
Without an override, falls back to ``get_local_placement`` which reads the IFC
placement and scales IFC-unit translation to metres via ``unit_scale``.
"""
x, y, z = float(local_pt_m[0]), float(local_pt_m[1]), float(local_pt_m[2])
if placement_override is not None and element.id() in placement_override:
m = placement_override[element.id()] # 4×4, metres, row-major
return (
float(m[0][0] * x + m[0][1] * y + m[0][2] * z + m[0][3]),
float(m[1][0] * x + m[1][1] * y + m[1][2] * z + m[1][3]),
float(m[2][0] * x + m[2][1] * y + m[2][2] * z + m[2][3]),
)
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file)
m = ifcopenshell.util.placement.get_local_placement(element.ObjectPlacement)
return (
float(m[0][0] * x + m[0][1] * y + m[0][2] * z + m[0][3] * unit_scale),
float(m[1][0] * x + m[1][1] * y + m[1][2] * z + m[1][3] * unit_scale),
float(m[2][0] * x + m[2][1] * y + m[2][2] * z + m[2][3] * unit_scale),
)
def _rotate_local_to_world(
element: ifcopenshell.entity_instance,
local_vec: tuple,
placement_override: Optional[dict] = None,
) -> tuple[float, float, float]:
"""Rotate a direction vector from local to world space (no translation)."""
x, y, z = float(local_vec[0]), float(local_vec[1]), float(local_vec[2])
if placement_override is not None and element.id() in placement_override:
m = placement_override[element.id()]
return (
float(m[0][0] * x + m[0][1] * y + m[0][2] * z),
float(m[1][0] * x + m[1][1] * y + m[1][2] * z),
float(m[2][0] * x + m[2][1] * y + m[2][2] * z),
)
m = ifcopenshell.util.placement.get_local_placement(element.ObjectPlacement)
return (
float(m[0][0] * x + m[0][1] * y + m[0][2] * z),
float(m[1][0] * x + m[1][1] * y + m[1][2] * z),
float(m[2][0] * x + m[2][1] * y + m[2][2] * z),
)
def _extract_mesh(shape) -> tuple[list[tuple], list[tuple]]:
"""Return (verts, tris) from a tessellated shape."""
vf = shape.geometry.verts
ff = shape.geometry.faces
verts = [(vf[i * 3], vf[i * 3 + 1], vf[i * 3 + 2]) for i in range(len(vf) // 3)]
tris = [(ff[i * 3], ff[i * 3 + 1], ff[i * 3 + 2]) for i in range(len(ff) // 3)]
return verts, tris
# ---------------------------------------------------------------------------
# Coplanar face grouping
# ---------------------------------------------------------------------------
_NORMAL_THRESHOLD = 0.005 # max angle deviation between coplanar normals (~0.3°)
_PLANE_THRESHOLD = 1e-4 # max distance from origin along normal (metres — matches geom output)
def _tri_normal(v0, v1, v2) -> tuple[float, float, float]:
ax, ay, az = v1[0] - v0[0], v1[1] - v0[1], v1[2] - v0[2]
bx, by, bz = v2[0] - v0[0], v2[1] - v0[1], v2[2] - v0[2]
nx = ay * bz - az * by
ny = az * bx - ax * bz
nz = ax * by - ay * bx
mag = math.sqrt(nx * nx + ny * ny + nz * nz)
if mag < 1e-12:
return (0.0, 0.0, 0.0)
return (nx / mag, ny / mag, nz / mag)
def _dot(a, b) -> float:
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
def _group_coplanar_tris(verts: list, tris: list) -> list[list[int]]:
"""Group triangle indices whose faces are coplanar (same normal + plane)."""
n_tris = len(tris)
normals: list[tuple] = []
plane_d: list[float] = []
for a, b, c in tris:
n = _tri_normal(verts[a], verts[b], verts[c])
normals.append(n)
# plane distance: n · centroid
cx = (verts[a][0] + verts[b][0] + verts[c][0]) / 3
cy = (verts[a][1] + verts[b][1] + verts[c][1]) / 3
cz = (verts[a][2] + verts[b][2] + verts[c][2]) / 3
plane_d.append(n[0] * cx + n[1] * cy + n[2] * cz)
assigned = [False] * n_tris
groups: list[list[int]] = []
for i in range(n_tris):
if assigned[i]:
continue
group = [i]
assigned[i] = True
ni, di = normals[i], plane_d[i]
if ni == (0.0, 0.0, 0.0):
groups.append(group)
continue
for j in range(i + 1, n_tris):
if assigned[j]:
continue
nj, dj = normals[j], plane_d[j]
if nj == (0.0, 0.0, 0.0):
continue
dot_val = _dot(ni, nj) # signed — opposite normals (dot≈-1) must NOT merge
if dot_val > 1.0 - _NORMAL_THRESHOLD and abs(di - dj) < _PLANE_THRESHOLD:
group.append(j)
assigned[j] = True
groups.append(group)
return groups
def _tri_area(v0, v1, v2) -> float:
ax, ay, az = v1[0] - v0[0], v1[1] - v0[1], v1[2] - v0[2]
bx, by, bz = v2[0] - v0[0], v2[1] - v0[1], v2[2] - v0[2]
cx = ay * bz - az * by
cy = az * bx - ax * bz
cz = ax * by - ay * bx
return 0.5 * math.sqrt(cx * cx + cy * cy + cz * cz)
def _face_group_props(group: list[int], verts: list, tris: list) -> dict:
"""Compute normal, total area, and area-weighted centroid for a face group."""
total_area = 0.0
wx = wy = wz = 0.0
nx = ny = nz = 0.0
for idx in group:
a, b, c = tris[idx]
va, vb, vc = verts[a], verts[b], verts[c]
area = _tri_area(va, vb, vc)
total_area += area
cx = (va[0] + vb[0] + vc[0]) / 3
cy = (va[1] + vb[1] + vc[1]) / 3
cz = (va[2] + vb[2] + vc[2]) / 3
wx += cx * area
wy += cy * area
wz += cz * area
n = _tri_normal(va, vb, vc)
nx += n[0] * area
ny += n[1] * area
nz += n[2] * area
if total_area < 1e-12:
return {"normal": (0.0, 0.0, 1.0), "area": 0.0, "centroid": (wx, wy, wz)}
centroid = (wx / total_area, wy / total_area, wz / total_area)
mag = math.sqrt(nx * nx + ny * ny + nz * nz)
if mag > 1e-12:
normal: tuple[float, ...] = (nx / mag, ny / mag, nz / mag)
else:
normal = (0.0, 0.0, 1.0)
return {"normal": normal, "area": total_area, "centroid": centroid}
# ---------------------------------------------------------------------------
# Fingerprint matching
# ---------------------------------------------------------------------------
_NORMAL_MATCH_THRESHOLD = 0.02 # max dot-product deviation for normal match
_CENTROID_MAX_DIST = 10.0 # max IFC-unit distance for centroid proximity
def _dist(a, b) -> float:
return math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2)
def _find_by_fingerprint(
group_props: list[dict],
fingerprint: dict,
hint: Optional[list],
) -> Optional[tuple[float, float, float]]:
"""Return the centroid of the best-matching face group."""
fp_normal = fingerprint["normal"]
fp_centroid = fingerprint["centroid"]
best_score = -1.0
best_centroid = None
for props in group_props:
dot_val = _dot(props["normal"], fp_normal)
if dot_val < 1.0 - _NORMAL_MATCH_THRESHOLD:
continue # wrong-facing face
# Score: prefer face whose centroid is closest to stored fingerprint centroid,
# then to the original click hint.
centroid_dist = _dist(props["centroid"], fp_centroid)
if centroid_dist > _CENTROID_MAX_DIST:
continue
score = dot_val - centroid_dist / _CENTROID_MAX_DIST * 0.3
if hint:
hint_dist = _dist(props["centroid"], hint)
score -= hint_dist / _CENTROID_MAX_DIST * 0.1
if score > best_score:
best_score = score
best_centroid = props["centroid"]
return best_centroid
def _best_group(
group_props: list[dict],
hit_normal: tuple,
hit_location: tuple,
) -> Optional[tuple[int, dict]]:
"""Return (index, props) for the best face group matching a ray-cast hit."""
best_score = -1.0
best = None
for i, props in enumerate(group_props):
dot_val = _dot(props["normal"], hit_normal)
if dot_val < 1.0 - _NORMAL_MATCH_THRESHOLD:
continue
dist = _dist(props["centroid"], hit_location)
score = dot_val - dist / max(_CENTROID_MAX_DIST, 0.001) * 0.2
if score > best_score:
best_score = score
best = (i, props)
return best
# ---------------------------------------------------------------------------
# Analytical resolution — IfcExtrudedAreaSolid TOP / BOTTOM
# ---------------------------------------------------------------------------
def _resolve_extruded_area_solid_analytic(
file: ifcopenshell.file,
element: ifcopenshell.entity_instance,
addr: dict,
placement_override: Optional[dict] = None,
) -> Optional[tuple[float, float, float]]:
"""Analytically resolve TOP or BOTTOM face centre of an IfcExtrudedAreaSolid."""
face_role = addr.get("face_role", "")
if face_role not in ("TOP", "BOTTOM"):
return None
repr_id = addr.get("repr_id")
if not repr_id:
return None
try:
solid = file.by_id(repr_id)
except Exception:
return None
if not solid.is_a("IfcExtrudedAreaSolid"):
return None
try:
profile_centroid_local = _profile_centroid(solid.SweptArea)
dir_ratios = solid.ExtrudedDirection.DirectionRatios
depth = solid.Depth
mag = math.sqrt(sum(d * d for d in dir_ratios))
if mag < 1e-12:
return None
dir_vec = tuple(d / mag for d in dir_ratios)
px = profile_centroid_local[0] + dir_vec[0] * (depth if face_role == "TOP" else 0.0)
py = profile_centroid_local[1] + dir_vec[1] * (depth if face_role == "TOP" else 0.0)
pz = dir_vec[2] * (depth if face_role == "TOP" else 0.0)
if solid.Position:
local_pt = _apply_axis2placement3d(solid.Position, (px, py, pz))
else:
local_pt = (px, py, pz)
# Apply element placement — use placement_override (Blender matrix_world, metres)
# when available so that unsync'd viewport moves are reflected.
return _local_to_world_m(file, element, local_pt, placement_override)
except Exception:
return None
def _profile_centroid(profile) -> tuple[float, float]:
"""Return (x, y) centroid of a profile def in its local 2D space."""
if profile.is_a("IfcRectangleProfileDef"):
pos = profile.Position
if pos:
loc = pos.Location
return (loc.Coordinates[0], loc.Coordinates[1])
return (0.0, 0.0)
if profile.is_a("IfcCircleProfileDef"):
pos = profile.Position
if pos:
loc = pos.Location
return (loc.Coordinates[0], loc.Coordinates[1])
return (0.0, 0.0)
# Fallback for arbitrary profiles — use position location if available
if hasattr(profile, "Position") and profile.Position:
loc = profile.Position.Location
return (loc.Coordinates[0], loc.Coordinates[1])
return (0.0, 0.0)
def _apply_axis2placement3d(placement, pt: tuple) -> tuple[float, float, float]:
"""Apply an IfcAxis2Placement3D to a local point."""
loc = placement.Location.Coordinates
ox, oy, oz = float(loc[0]), float(loc[1]), float(loc[2])
# Z axis (extrusion direction in placement space)
if placement.Axis:
zr = placement.Axis.DirectionRatios
zx, zy, zz = float(zr[0]), float(zr[1]), float(zr[2])
else:
zx, zy, zz = 0.0, 0.0, 1.0
# X axis (ref direction)
if placement.RefDirection:
xr = placement.RefDirection.DirectionRatios
xx, xy, xz = float(xr[0]), float(xr[1]), float(xr[2])
else:
xx, xy, xz = 1.0, 0.0, 0.0
# Y axis = Z × X
yx = zy * xz - zz * xy
yy = zz * xx - zx * xz
yz = zx * xy - zy * xx
px, py, pz = pt
return (
ox + px * xx + py * yx + pz * zx,
oy + px * xy + py * yy + pz * zy,
oz + px * xz + py * yz + pz * zz,
)
def _mat_apply(m, pt: tuple) -> tuple[float, float, float]:
"""Apply a 4×4 numpy placement matrix to a point."""
x, y, z = float(pt[0]), float(pt[1]), float(pt[2])
return (
float(m[0][0] * x + m[0][1] * y + m[0][2] * z + m[0][3]),
float(m[1][0] * x + m[1][1] * y + m[1][2] * z + m[1][3]),
float(m[2][0] * x + m[2][1] * y + m[2][2] * z + m[2][3]),
)
# ---------------------------------------------------------------------------
# IfcExtrudedAreaSolid face role detection
# ---------------------------------------------------------------------------
def _detect_extruded_face(
file: ifcopenshell.file,
element: ifcopenshell.entity_instance,
hit_location: tuple,
hit_normal: tuple,
) -> tuple[str, int, str]:
"""Try to identify if the hit face is a TOP or BOTTOM of an IfcExtrudedAreaSolid.
Returns (repr_type, repr_id, face_role).
repr_type is empty string if not detected as extruded solid.
"""
if not hasattr(element, "Representation") or not element.Representation:
return ("", -1, "")
for rep in element.Representation.Representations:
for item in rep.Items:
solid = _unwrap_mapped(item)
if not solid or not solid.is_a("IfcExtrudedAreaSolid"):
continue
role = _extruded_face_role(solid, hit_normal)
if role:
return ("IfcExtrudedAreaSolid", solid.id(), role)
return ("", -1, "")
def _unwrap_mapped(item):
"""Unwrap IfcMappedItem to its underlying representation item (first item)."""
if item.is_a("IfcMappedItem"):
items = item.MappingSource.MappedRepresentation.Items
return items[0] if items else None
return item
def _extruded_face_role(solid, hit_normal: tuple) -> str:
"""Return 'TOP', 'BOTTOM', or '' based on whether hit_normal aligns with extrusion."""
try:
dr = solid.ExtrudedDirection.DirectionRatios
mag = math.sqrt(sum(d * d for d in dr))
if mag < 1e-12:
return ""
extrude_dir = tuple(d / mag for d in dr)
dot_val = _dot(extrude_dir, hit_normal)
if dot_val > 0.99:
return "TOP"
if dot_val < -0.99:
return "BOTTOM"
except Exception:
pass
return ""
# ---------------------------------------------------------------------------
# Misc helpers
# ---------------------------------------------------------------------------
def _pt_or_none(pt) -> Optional[tuple[float, float, float]]:
if pt:
return (float(pt[0]), float(pt[1]), float(pt[2]))
return None