Add ForcePerpendicularToFace + hover-cycle UX for parametric dimensions

SetDimensionAnchor — hover-select-then-confirm:
- Cursor highlights candidate IFC elements (orange Blender selection outline)
  before committing; Tab cycles through overlapping/coplanar candidates
- _compute_candidates: ray-cast all IFC mesh objects; falls back to 2D
  bounding-box proximity (5 cm tolerance) for plan-view picks where the
  ray misses the mesh by sub-mm amounts
- _write_anchor: after anchoring a face, immediately calls
  regenerate_dimension with placement_override (Blender matrix_world)
  and _update_blender_curve so the curve vertex moves to the resolved point

DrawParametricDimension — ForcePerpendicularToFace live snap constraint:
- Reads force_perpendicular_to_face toggle from annotation props on invoke
- After anchor[0] is placed on a FACE, _update_perp_constraint extracts
  the face normal and stores it as the constraint axis
- _apply_perp_constraint runs every modal tick after handle_snap_selection,
  projecting the current snap point onto pt[0] + t*normal
- On finalize, _create_dimension_from_polyline writes ForcePerpendicularToFace
  to the BBIM_Dimension pset and calls regenerate_dimension to snap the
  stored curve to the constraint before the operator exits

regenerate_dimension.py:
- ForcePerpendicularToFace block: after resolving all anchors, projects
  vertices 1…n onto the line through pt[0] along anchor[0]'s face normal
- _get_anchor_face_normal_world: reads normal_local from anchor fingerprint,
  calls _rotate_local_to_world with placement_override; falls back to stored
  world-space normal

resolve_anchor.py:
- _rotate_local_to_world: transforms an element-local direction vector to
  world space using the element's placement or placement_override matrix

pset/operator.py:
- EditPset._execute: after editing a BBIM_Dimension pset on an IfcAnnotation,
  auto-calls regenerate_dimension + _update_blender_curve so changes to
  anchors/ForcePerpendicularToFace are reflected immediately in the viewport

prop.py / workspace.py:
- Added force_perpendicular_to_face BoolProperty to BIMAnnotationProperties
- UI toggle shown in annotation tool header for DIMENSION/RADIUS/DIAMETER/
  ANGLE/PLAN_LEVEL/SECTION_LEVEL types

Psets_BBIM_Annotation.ifc:
- Added ForcePerpendicularToFace property template (#39) to BBIM_Dimension
- Extended BBIM_Dimension applicability to ANGLE, PLAN_LEVEL, SECTION_LEVEL

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ryan Schultz
2026-05-16 15:11:05 -05:00
parent 11543f19ca
commit 9b39dd629b
8 changed files with 813 additions and 94 deletions
@@ -28,7 +28,7 @@ DATA;
#21=IFCSIMPLEPROPERTYTEMPLATE('1UDakJ5_f7kBhggNSW4$h5',$,'SymbolsPath','Default symbols SVG',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.); #21=IFCSIMPLEPROPERTYTEMPLATE('1UDakJ5_f7kBhggNSW4$h5',$,'SymbolsPath','Default symbols SVG',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
#22=IFCSIMPLEPROPERTYTEMPLATE('0d53LEtgLDQxnv__NfgH7i',$,'PatternsPath','Default patterns SVG',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.); #22=IFCSIMPLEPROPERTYTEMPLATE('0d53LEtgLDQxnv__NfgH7i',$,'PatternsPath','Default patterns SVG',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
#23=IFCSIMPLEPROPERTYTEMPLATE('26qFNMv7nCHgU6Jd7Anga5',$,'ShadingStylesPath','Default shading styles',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.); #23=IFCSIMPLEPROPERTYTEMPLATE('26qFNMv7nCHgU6Jd7Anga5',$,'ShadingStylesPath','Default shading styles',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
#24=IFCPROPERTYSETTEMPLATE('0I9merLinF5Ap$aZwaclgm',$,'BBIM_Dimension','',.PSET_TYPEDRIVENOVERRIDE.,'IfcAnnotation/DIMENSION,IfcAnnotation/RADIUS,IfcAnnotation/DIAMETER,IfcTypeProduct',(#25,#26,#35,#36,#27,#28,#30,#34)); #24=IFCPROPERTYSETTEMPLATE('0I9merLinF5Ap$aZwaclgm',$,'BBIM_Dimension','',.PSET_TYPEDRIVENOVERRIDE.,'IfcAnnotation/DIMENSION,IfcAnnotation/RADIUS,IfcAnnotation/DIAMETER,IfcAnnotation/ANGLE,IfcAnnotation/PLAN_LEVEL,IfcAnnotation/SECTION_LEVEL,IfcTypeProduct',(#25,#26,#35,#36,#27,#28,#30,#34,#37,#38,#39));
#25=IFCSIMPLEPROPERTYTEMPLATE('1rL2AbQsXD8RbpoWH5pYOV',$,'ShowDescriptionOnly','Hide the measurement values and show only annotation description',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.); #25=IFCSIMPLEPROPERTYTEMPLATE('1rL2AbQsXD8RbpoWH5pYOV',$,'ShowDescriptionOnly','Hide the measurement values and show only annotation description',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
#26=IFCSIMPLEPROPERTYTEMPLATE('0SVyOfB0rC2xNfdRYf3XvY',$,'SuppressZeroInches','Suppress 0 inch values in dimension annotation text (for example: 12'' - 0" -> 12'')',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.); #26=IFCSIMPLEPROPERTYTEMPLATE('0SVyOfB0rC2xNfdRYf3XvY',$,'SuppressZeroInches','Suppress 0 inch values in dimension annotation text (for example: 12'' - 0" -> 12'')',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
#27=IFCSIMPLEPROPERTYTEMPLATE('2bUmj458PBqPAtUoI3MXsb',$,'TextPrefix','Text to add before annotation measurement value',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.); #27=IFCSIMPLEPROPERTYTEMPLATE('2bUmj458PBqPAtUoI3MXsb',$,'TextPrefix','Text to add before annotation measurement value',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
@@ -41,8 +41,8 @@ DATA;
#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.);
#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)); #37=IFCSIMPLEPROPERTYTEMPLATE('1XpRnKoT2sGuW7vYcZaMqb',$,'Anchors','JSON array of parametric 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.);
#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.); #38=IFCSIMPLEPROPERTYTEMPLATE('2YqSmLoU3tHvX8wZdaNrjc',$,'MeasureAxis','Axis along which distances are projected: X | Y | Z | TRUE | PERPENDICULAR',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
#39=IFCSIMPLEPROPERTYTEMPLATE('2YqSmLoU3tHvX8wZdaNrjc',$,'MeasureAxis','Axis along which distances are projected: X | Y | Z | TRUE | PERPENDICULAR',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.); #39=IFCSIMPLEPROPERTYTEMPLATE('3Ny31Go6T5Z9fh8j4yQC0p',$,'ForcePerpendicularToFace','When enabled the polyline is constrained to follow the face normal of the first anchor vertex so the dimension measures straight-line distance perpendicular to that face',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
ENDSEC; ENDSEC;
END-ISO-10303-21; END-ISO-10303-21;
@@ -33,7 +33,7 @@ import bonsai.tool as tool
_dim_guid_index: dict = {} _dim_guid_index: dict = {}
# Persistent tessellation cache for the depsgraph handler (element id → shape). # Persistent tessellation cache for the depsgraph handler (element id → shape).
_dim_shape_cache: dict = {} _dim_shape_cache: dict = {}
# Set True whenever BBIM_DimensionTarget anchors change or a new file loads. # Set True whenever BBIM_Dimension anchors change or a new file loads.
_dim_index_dirty: bool = True _dim_index_dirty: bool = True
# Re-entry guard so curve updates don't trigger a second handler call. # Re-entry guard so curve updates don't trigger a second handler call.
_dim_handler_running: bool = False _dim_handler_running: bool = False
@@ -52,7 +52,7 @@ def _rebuild_dim_guid_index(file) -> None:
_dim_guid_index = {} _dim_guid_index = {}
for annotation in file.by_type("IfcAnnotation"): for annotation in file.by_type("IfcAnnotation"):
pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_DimensionTarget") pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
if not pset_data or not pset_data.get("Anchors"): if not pset_data or not pset_data.get("Anchors"):
continue continue
try: try:
@@ -172,7 +172,7 @@ def depsgraph_update_post_handler(scene, depsgraph):
except Exception: except Exception:
continue continue
pset = ifcopenshell.util.element.get_pset(annotation, "BBIM_DimensionTarget") pset = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
if not pset: if not pset:
continue continue
+442 -55
View File
@@ -5574,7 +5574,7 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope
Click on IFC element faces to place dimension vertices one by one using the Click on IFC element faces to place dimension vertices one by one using the
same snap system as wall and slab drawing. Each confirmed point is stored same snap system as wall and slab drawing. Each confirmed point is stored
as a parametric anchor in ``BBIM_DimensionTarget`` so the dimension as a parametric anchor in ``BBIM_Dimension`` so the dimension
recomputes automatically when the referenced elements move. recomputes automatically when the referenced elements move.
RMB or ENTER to finish; ESC to cancel without creating an annotation. RMB or ENTER to finish; ESC to cancel without creating an annotation.
@@ -5603,12 +5603,15 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope
PolylineOperator.__init__(self) PolylineOperator.__init__(self)
self._anchors = [] self._anchors = []
self._shape_cache = {} self._shape_cache = {}
self._force_perpendicular = False
self._anchor0_normal = None # (nx, ny, nz) world-space face normal of anchor[0]
self._anchor0_pt = None # (x, y, z) world-space position of anchor[0]
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Snap → anchor bridge # Snap → anchor bridge
def _snap_to_anchor(self, snap: dict) -> dict: def _snap_to_anchor(self, snap: dict) -> dict:
"""Convert a PolylineOperator snap candidate to a BBIM_DimensionTarget anchor dict.""" """Convert a PolylineOperator snap candidate to a BBIM_Dimension anchor dict."""
import ifcopenshell.api.drawing as drawing_api import ifcopenshell.api.drawing as drawing_api
obj = snap.get("object") obj = snap.get("object")
@@ -5619,14 +5622,21 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope
hit_m = (float(pt_world.x), float(pt_world.y), float(pt_world.z)) hit_m = (float(pt_world.x), float(pt_world.y), float(pt_world.z))
face_index = snap.get("face_index") face_index = snap.get("face_index")
if face_index is None or face_index >= len(obj.data.polygons):
# Vertex / edge snap: seed from closest face.
local_pt = obj.matrix_world.inverted() @ pt_world
ok, _loc, _n, face_index = obj.closest_point_on_mesh(local_pt)
if not ok:
face_index = None
# Prefer faces perpendicular to the camera rather than faces that
# directly face the camera (e.g. top of a wall in plan view).
face_index = _prefer_perp_face_index(obj, pt_world, face_index)
if face_index is not None and face_index < len(obj.data.polygons): if face_index is not None and face_index < len(obj.data.polygons):
normal_local = obj.data.polygons[face_index].normal normal_local = obj.data.polygons[face_index].normal
else: else:
# Vertex / edge snap: find the closest face for a proper normal. normal_local = Vector((0.0, 0.0, 1.0))
local_pt = obj.matrix_world.inverted() @ pt_world
ok, _loc, normal_local, face_index = obj.closest_point_on_mesh(local_pt)
if not ok:
normal_local = Vector((0.0, 0.0, 1.0))
normal_world = (obj.matrix_world.to_3x3() @ normal_local).normalized() normal_world = (obj.matrix_world.to_3x3() @ normal_local).normalized()
normal_m = (float(normal_world.x), float(normal_world.y), float(normal_world.z)) normal_m = (float(normal_world.x), float(normal_world.y), float(normal_world.z))
@@ -5673,12 +5683,57 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope
import ifcopenshell.api.drawing as drawing_api import ifcopenshell.api.drawing as drawing_api
pt = polyline_data[0].polyline_points[-1] pt = polyline_data[0].polyline_points[-1]
self._anchors.append(drawing_api.make_world_anchor([float(pt.x), float(pt.y), float(pt.z)])) self._anchors.append(drawing_api.make_world_anchor([float(pt.x), float(pt.y), float(pt.z)]))
# After anchor[0] is set, extract its face normal for the perp constraint.
if self._force_perpendicular and len(self._anchors) == 1:
self._update_perp_constraint()
elif count_after < count_before and self._anchors: elif count_after < count_before and self._anchors:
# BACKSPACE removed a point. # BACKSPACE removed a point.
self._anchors.pop() self._anchors.pop()
# Reset constraint if we backspaced past anchor[0].
if len(self._anchors) == 0:
self._anchor0_normal = None
self._anchor0_pt = None
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Finalize: create IfcAnnotation + BBIM_DimensionTarget pset # Perpendicular-to-face constraint helpers
def _update_perp_constraint(self) -> None:
"""Extract the face normal from anchor[0] and store it as the constraint axis."""
import math
a = self._anchors[0] if self._anchors else None
if not a or a.get("type") != "FACE":
return
fp = (a.get("addr") or {}).get("fingerprint") or {}
n = fp.get("normal") # world-space at build time
pt = a.get("pt")
if not n or not pt:
return
mag = math.sqrt(n[0] ** 2 + n[1] ** 2 + n[2] ** 2)
if mag < 1e-12:
return
self._anchor0_normal = (n[0] / mag, n[1] / mag, n[2] / mag)
self._anchor0_pt = tuple(pt)
def _apply_perp_constraint(self) -> None:
"""Project the current snap point onto the constraint line when active."""
if not self._force_perpendicular or not self._anchor0_normal or not self._anchor0_pt:
return
if not self._anchors: # constraint not yet active (no anchor[0] yet)
return
if not self.snapping_points:
return
snap = self.snapping_points[0]
if not snap or not snap.get("point"):
return
p = snap["point"]
base = self._anchor0_pt
n = self._anchor0_normal
t = (p.x - base[0]) * n[0] + (p.y - base[1]) * n[1] + (p.z - base[2]) * n[2]
snap["point"] = Vector((base[0] + t * n[0], base[1] + t * n[1], base[2] + t * n[2]))
# ------------------------------------------------------------------
# Finalize: create IfcAnnotation + BBIM_Dimension pset
def _create_dimension_from_polyline(self, context) -> None: def _create_dimension_from_polyline(self, context) -> None:
import ifcopenshell.api.drawing as drawing_api import ifcopenshell.api.drawing as drawing_api
@@ -5731,10 +5786,36 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope
anchors.append(drawing_api.make_world_anchor(list(resolved_pts_m[len(anchors)]))) anchors.append(drawing_api.make_world_anchor(list(resolved_pts_m[len(anchors)])))
file = tool.Ifc.get() file = tool.Ifc.get()
ifcopenshell.api.run("pset.add_pset", file, product=annotation, name="BBIM_DimensionTarget") pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_DimensionTarget") if not pset_data:
ifcopenshell.api.run("pset.add_pset", file, product=annotation, name="BBIM_Dimension")
pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
pset_entity = file.by_id(pset_data["id"]) pset_entity = file.by_id(pset_data["id"])
ifcopenshell.api.run("pset.edit_pset", file, pset=pset_entity, properties={"Anchors": json.dumps(anchors)}) pset_props = {"Anchors": json.dumps(anchors)}
if self._force_perpendicular:
pset_props["ForcePerpendicularToFace"] = True
ifcopenshell.api.run("pset.edit_pset", file, pset=pset_entity, properties=pset_props)
if self._force_perpendicular:
placement_override: dict = {}
for a in anchors:
guid = a.get("guid")
if not guid:
continue
try:
elem = file.by_guid(guid)
elem_obj = tool.Ifc.get_object(elem)
if elem_obj:
placement_override[elem.id()] = np.array(elem_obj.matrix_world)
except Exception:
pass
resolved_pts = drawing_api.regenerate_dimension(
file, annotation,
shape_cache=getattr(self, "_shape_cache", None),
placement_override=placement_override,
)
if resolved_pts:
_update_blender_curve(annotation, resolved_pts)
from bonsai.bim.module.drawing import handler as _drawing_handler from bonsai.bim.module.drawing import handler as _drawing_handler
_drawing_handler.invalidate_dim_index() _drawing_handler.invalidate_dim_index()
@@ -5762,6 +5843,7 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope
self.handle_mouse_move(context, event) self.handle_mouse_move(context, event)
self.choose_axis(event) self.choose_axis(event)
self.handle_snap_selection(context, event) self.handle_snap_selection(context, event)
self._apply_perp_constraint()
if ( if (
not self.tool_state.is_input_on not self.tool_state.is_input_on
@@ -5789,17 +5871,62 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope
def _invoke(self, context, event): def _invoke(self, context, event):
super().invoke(context, event) super().invoke(context, event)
self._force_perpendicular = tool.Drawing.get_annotation_props().force_perpendicular_to_face
return {"RUNNING_MODAL"} return {"RUNNING_MODAL"}
def _prefer_perp_face_index(
obj: "bpy.types.Object",
hit_world: "Vector",
current_index: "Optional[int]",
world_matrix=None,
) -> "Optional[int]":
"""Return the polygon index most perpendicular to the camera near *hit_world*.
If the camera is unavailable or the current face is already sufficiently
perpendicular (|dot| < 0.5), returns *current_index* unchanged.
*world_matrix* overrides ``obj.matrix_world``; useful when *obj* is a mesh
inside a collection instance whose effective transform differs from its own
``matrix_world``.
"""
camera = bpy.context.scene.camera
if not camera or not obj.data or not hasattr(obj.data, "polygons"):
return current_index
cam_view = (camera.matrix_world.to_3x3() @ Vector((0.0, 0.0, -1.0))).normalized()
mx = world_matrix if world_matrix is not None else obj.matrix_world
mx3 = mx.to_3x3()
if current_index is not None and current_index < len(obj.data.polygons):
current_n = (mx3 @ obj.data.polygons[current_index].normal).normalized()
if abs(current_n.dot(cam_view)) < 0.5:
return current_index
best_idx = current_index
best_score = -1.0
for i, poly in enumerate(obj.data.polygons):
n_world = (mx3 @ poly.normal).normalized()
perp = 1.0 - abs(n_world.dot(cam_view))
if perp < 0.5:
continue
dist = (mx @ poly.center - hit_world).length
score = perp - dist / 4.0
if score > best_score:
best_score = score
best_idx = i
return best_idx
class SetDimensionAnchor(bpy.types.Operator): class SetDimensionAnchor(bpy.types.Operator):
"""Interactively anchor dimension vertices to IFC element faces. """Interactively anchor dimension vertices to IFC element faces.
Two-phase modal workflow (all in Object Mode, no Tab required): Two-phase modal workflow (all in Object Mode):
1. Run the operator with a dimension annotation selected. 1. Run the operator with a dimension annotation selected.
2. Click a vertex ON the dimension line to select it. 2. Click a vertex ON the dimension line to select it.
3. Click an IFC element face to anchor that vertex to it. 3. Hover over IFC elements the nearest candidate is highlighted.
ALT+click sets a free world-point anchor instead. TAB cycles through overlapping candidates under the cursor.
Click to anchor the highlighted element face to that vertex.
ALT+Click sets a free world-point anchor instead.
4. Repeat steps 2-3 for more vertices. 4. Repeat steps 2-3 for more vertices.
5. RMB or ESC to finish. 5. RMB or ESC to finish.
""" """
@@ -5816,8 +5943,17 @@ class SetDimensionAnchor(bpy.types.Operator):
_phase: str = "PICK_VERTEX" # "PICK_VERTEX" | "PICK_FACE" _phase: str = "PICK_VERTEX" # "PICK_VERTEX" | "PICK_FACE"
_active_vertex_idx: int = -1 _active_vertex_idx: int = -1
_shape_cache: dict _shape_cache: dict
_region: Optional[bpy.types.Region] = None
_rv3d: Optional[bpy.types.RegionView3D] = None
_VERTEX_PICK_RADIUS_PX = 20 # pixels — how close the click must be to a vertex # Hover-cycle state (active during PICK_FACE phase)
_hover_candidates: list # [(ifc_obj, hit_mesh, hit_mesh_mx, location, normal, face_index), ...]
_hover_index: int # which candidate is currently highlighted
_hover_last_px: tuple # last cursor pixel position where candidates were computed
_hover_highlighted_obj: Optional[bpy.types.Object] # object currently selected for highlight
_VERTEX_PICK_RADIUS_PX = 20 # pixels — how close the click must be to a vertex
_HOVER_THROTTLE_PX_SQ = 25 # only recompute candidates if cursor moves >5px
@classmethod @classmethod
def poll(cls, context): def poll(cls, context):
@@ -5848,15 +5984,44 @@ class SetDimensionAnchor(bpy.types.Operator):
self._phase = "PICK_VERTEX" self._phase = "PICK_VERTEX"
self._active_vertex_idx = -1 self._active_vertex_idx = -1
self._shape_cache = {} self._shape_cache = {}
self._hover_candidates = []
self._hover_index = 0
self._hover_last_px = (-9999, -9999)
self._hover_highlighted_obj = None
# When invoked from a panel, context.region_data is None.
# Walk the screen areas to find the actual 3D viewport region.
self._region, self._rv3d = None, None
for area in context.screen.areas:
if area.type == "VIEW_3D":
for region in area.regions:
if region.type == "WINDOW":
self._region = region
break
if area.spaces and area.spaces[0].type == "VIEW_3D":
self._rv3d = area.spaces[0].region_3d
break
self._set_status(context) self._set_status(context)
context.window_manager.modal_handler_add(self) context.window_manager.modal_handler_add(self)
return {"RUNNING_MODAL"} return {"RUNNING_MODAL"}
def modal(self, context, event): def modal(self, context, event):
if event.type == "ESC" or (event.type == "RIGHTMOUSE" and event.value == "PRESS"): if event.type == "ESC" or (event.type == "RIGHTMOUSE" and event.value == "PRESS"):
self._clear_hover_highlight(context)
context.workspace.status_text_set(None) context.workspace.status_text_set(None)
return {"FINISHED"} # keep any anchors already written return {"FINISHED"} # keep any anchors already written
# Hover — recompute candidates as cursor moves (PICK_FACE phase only)
if event.type == "MOUSEMOVE" and self._phase == "PICK_FACE":
self._handle_hover(context, event)
return {"RUNNING_MODAL"}
# Tab — cycle through candidates under cursor
if event.type == "TAB" and event.value == "PRESS" and self._phase == "PICK_FACE":
self._cycle_hover(context)
return {"RUNNING_MODAL"}
if event.type == "LEFTMOUSE" and event.value == "PRESS": if event.type == "LEFTMOUSE" and event.value == "PRESS":
if self._phase == "PICK_VERTEX": if self._phase == "PICK_VERTEX":
self._handle_vertex_pick(context, event) self._handle_vertex_pick(context, event)
@@ -5876,9 +6041,11 @@ class SetDimensionAnchor(bpy.types.Operator):
"Click a dimension vertex | RMB / ESC: Finish" "Click a dimension vertex | RMB / ESC: Finish"
) )
else: else:
# In PICK_FACE the hover handler writes a richer status; this is the
# fallback shown when no candidates have been computed yet.
context.workspace.status_text_set( context.workspace.status_text_set(
f"Vertex {self._active_vertex_idx} selected " f"Vertex {self._active_vertex_idx} — hover over element | "
"Click element face to anchor | ALT+Click: free world point | RMB / ESC: Finish" "TAB: cycle candidates | Click: anchor | ALT+Click: free point | RMB/ESC: Finish"
) )
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -5887,12 +6054,14 @@ class SetDimensionAnchor(bpy.types.Operator):
def _handle_vertex_pick(self, context, event): def _handle_vertex_pick(self, context, event):
from bpy_extras import view3d_utils from bpy_extras import view3d_utils
region = context.region region = self._region
rv3d = context.region_data rv3d = self._rv3d
if not region or not rv3d: if not region or not rv3d:
return return
coord = (event.mouse_region_x, event.mouse_region_y) # event.mouse_region_x/y is relative to the event's region (e.g. N-panel),
# not our stored 3D viewport region. Use absolute coords minus region offset.
coord = (event.mouse_x - region.x, event.mouse_y - region.y)
obj = self._annotation_obj obj = self._annotation_obj
best_idx = None best_idx = None
@@ -5921,44 +6090,61 @@ class SetDimensionAnchor(bpy.types.Operator):
# Phase 2: pick a face on an IFC element # Phase 2: pick a face on an IFC element
def _handle_face_pick(self, context, event): def _handle_face_pick(self, context, event):
from bpy_extras import view3d_utils from mathutils import Vector
region = context.region region = self._region
rv3d = context.region_data rv3d = self._rv3d
if not region or not rv3d: if not region or not rv3d:
return return
coord = (event.mouse_region_x, event.mouse_region_y) # ALT+click → free world-point anchor at any mesh surface.
# ALT+click → free world-point anchor at the cursor 3D location
if event.alt: if event.alt:
origin = view3d_utils.region_2d_to_origin_3d(region, rv3d, coord) self._clear_hover_highlight(context)
direction = view3d_utils.region_2d_to_vector_3d(region, rv3d, coord) origin, direction = self._unproject_coord(
hit, location, *_ = context.scene.ray_cast(context.view_layer.depsgraph, origin, direction) (event.mouse_x - region.x, event.mouse_y - region.y)
pt_m = tuple(location) if hit else tuple(origin + direction * 5.0) )
best_dist = float("inf")
alt_loc = None
for obj in context.scene.objects:
if obj.type != "MESH":
continue
try:
mx_inv = obj.matrix_world.inverted()
except Exception:
continue
ok, loc_l, _, _ = obj.ray_cast(
mx_inv @ origin, (mx_inv.to_3x3() @ direction).normalized()
)
if ok:
loc_w = obj.matrix_world @ loc_l
d = (loc_w - origin).length
if d < best_dist:
best_dist = d
alt_loc = loc_w
pt_m = list(alt_loc) if alt_loc else list(origin + direction * 5.0)
import ifcopenshell.api.drawing as drawing_api import ifcopenshell.api.drawing as drawing_api
anchor = drawing_api.make_world_anchor(list(pt_m)) anchor = drawing_api.make_world_anchor(pt_m)
self._write_anchor(anchor, self._active_vertex_idx) self._write_anchor(anchor, self._active_vertex_idx)
self.report({"INFO"}, f"Vertex {self._active_vertex_idx} → free world point") self.report({"INFO"}, f"Vertex {self._active_vertex_idx} → free world point")
self._phase = "PICK_VERTEX" self._phase = "PICK_VERTEX"
return return
# Normal click → raycast for IFC element face # Normal click — use whichever candidate is currently highlighted.
origin = view3d_utils.region_2d_to_origin_3d(region, rv3d, coord) self._clear_hover_highlight(context)
direction = view3d_utils.region_2d_to_vector_3d(region, rv3d, coord)
hit, location, normal, face_index, hit_obj, _ = context.scene.ray_cast( coord = (event.mouse_x - region.x, event.mouse_y - region.y)
context.view_layer.depsgraph, origin, direction dx = coord[0] - self._hover_last_px[0]
) dy = coord[1] - self._hover_last_px[1]
if dx * dx + dy * dy > self._HOVER_THROTTLE_PX_SQ or not self._hover_candidates:
self._hover_candidates = self._compute_candidates(context, coord)
self._hover_index = 0
if not hit or hit_obj is None: if not self._hover_candidates:
self.report({"WARNING"}, "Nothing under cursor — click on a model element") self.report({"WARNING"}, "Nothing under cursor — click on a model element")
return return
if hit_obj == self._annotation_obj: idx = min(self._hover_index, len(self._hover_candidates) - 1)
self.report({"WARNING"}, "Click on an element, not the dimension line itself") hit_obj, hit_mesh, hit_mesh_mx, location, normal, face_index = self._hover_candidates[idx]
return
element = tool.Ifc.get_entity(hit_obj) element = tool.Ifc.get_entity(hit_obj)
if not element: if not element:
@@ -5968,16 +6154,20 @@ class SetDimensionAnchor(bpy.types.Operator):
file = tool.Ifc.get() file = tool.Ifc.get()
hit_m = (float(location.x), float(location.y), float(location.z)) hit_m = (float(location.x), float(location.y), float(location.z))
normal_m = (float(normal.x), float(normal.y), float(normal.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)} placement_override = {element.id(): np.array(hit_obj.matrix_world)}
import ifcopenshell.api.drawing as drawing_api import ifcopenshell.api.drawing as drawing_api
anchor = drawing_api.build_anchor_from_hit( try:
file, element, hit_m, normal_m, anchor = drawing_api.build_anchor_from_hit(
shape_cache=self._shape_cache, file, element, hit_m, normal_m,
placement_override=placement_override, shape_cache=self._shape_cache,
) placement_override=placement_override,
)
except Exception as exc:
import traceback
traceback.print_exc()
self.report({"ERROR"}, f"build_anchor_from_hit failed: {exc}")
return
self._write_anchor(anchor, self._active_vertex_idx) self._write_anchor(anchor, self._active_vertex_idx)
self.report( self.report(
@@ -5985,6 +6175,176 @@ class SetDimensionAnchor(bpy.types.Operator):
f"Vertex {self._active_vertex_idx}{element.is_a()}/{element.Name or element.GlobalId}", f"Vertex {self._active_vertex_idx}{element.is_a()}/{element.Name or element.GlobalId}",
) )
self._phase = "PICK_VERTEX" self._phase = "PICK_VERTEX"
self._hover_candidates = []
self._hover_index = 0
# ------------------------------------------------------------------
# Hover / cycle helpers
def _unproject_coord(self, coord):
"""Return (origin, direction) world-space ray for a region pixel coord."""
from mathutils import Vector
rv3d = self._rv3d
region = self._region
persinv = rv3d.perspective_matrix.inverted()
dx = (2.0 * coord[0] / region.width) - 1.0
dy = (2.0 * coord[1] / region.height) - 1.0
near_h = persinv @ Vector((dx, dy, -1.0, 1.0))
far_h = persinv @ Vector((dx, dy, 1.0, 1.0))
origin = near_h.xyz / near_h.w
far_pt = far_h.xyz / far_h.w
direction = (far_pt - origin).normalized()
if not rv3d.is_perspective:
origin = origin - direction * 1e4
return origin, direction
def _compute_candidates(self, context, coord):
"""Cast a ray from *coord* and return a ranked list of hit candidates.
Each entry: (ifc_obj, hit_mesh, hit_mesh_mx, location, normal, face_index)
Sorted closest-first for direct hits; by proximity distance for near-misses.
"""
import math as _math
from mathutils import Vector
origin, direction = self._unproject_coord(coord)
direct: list = [] # (dist, ifc_obj, mesh_obj, mx, loc_w, normal, face_index)
for ifc_obj in context.scene.objects:
if ifc_obj == self._annotation_obj:
continue
if not tool.Ifc.get_entity(ifc_obj):
continue
if ifc_obj.type != "MESH":
continue
mx = ifc_obj.matrix_world
try:
mx_inv = mx.inverted()
except Exception:
continue
ok, loc_l, nrm_l, fi = ifc_obj.ray_cast(
mx_inv @ origin, (mx_inv.to_3x3() @ direction).normalized()
)
if not ok:
continue
loc_w = mx @ loc_l
fi = _prefer_perp_face_index(ifc_obj, loc_w, fi, world_matrix=mx)
normal = (mx.to_3x3() @ ifc_obj.data.polygons[fi].normal).normalized() if fi is not None else (mx.to_3x3() @ nrm_l).normalized()
dist = (loc_w - origin).length
direct.append((dist, ifc_obj, ifc_obj, mx, loc_w, normal, fi))
if direct:
direct.sort(key=lambda c: c[0])
return [(o, m, mmx, l, n, f) for _, o, m, mmx, l, n, f in direct]
# Proximity fallback — collect ALL candidates within TOL, sorted by perp distance.
TOL = 0.05
def _perp(v):
return v - v.dot(direction) * direction
prox: list = []
for ifc_obj in context.scene.objects:
if ifc_obj == self._annotation_obj:
continue
if not tool.Ifc.get_entity(ifc_obj):
continue
if ifc_obj.type != "MESH":
continue
mx = ifc_obj.matrix_world
try:
mx_inv = mx.inverted()
except Exception:
continue
bb_world = [mx @ Vector(c) for c in ifc_obj.bound_box]
bb_proj = [_perp(v) for v in bb_world]
op = _perp(origin)
sx = max(min(v.x for v in bb_proj) - op.x, 0.0, op.x - max(v.x for v in bb_proj))
sy = max(min(v.y for v in bb_proj) - op.y, 0.0, op.y - max(v.y for v in bb_proj))
sz = max(min(v.z for v in bb_proj) - op.z, 0.0, op.z - max(v.z for v in bb_proj))
perp_dist = _math.sqrt(sx * sx + sy * sy + sz * sz)
if perp_dist > TOL:
continue
bb_ctr = sum((v for v in bb_world), Vector()) / 8
t = (bb_ctr - origin).dot(direction)
query_w = origin + t * direction
found, loc_l, nrm_l, fi = ifc_obj.closest_point_on_mesh(mx_inv @ query_w, distance=100.0)
if not found:
continue
loc_w = mx @ loc_l
fi = _prefer_perp_face_index(ifc_obj, loc_w, fi, world_matrix=mx)
normal = (mx.to_3x3() @ ifc_obj.data.polygons[fi].normal).normalized() if fi is not None else (mx.to_3x3() @ nrm_l).normalized()
prox.append((perp_dist, ifc_obj, ifc_obj, mx, loc_w, normal, fi))
prox.sort(key=lambda c: c[0])
return [(o, m, mmx, l, n, f) for _, o, m, mmx, l, n, f in prox]
def _handle_hover(self, context, event):
"""Recompute candidates when cursor moves; highlight the current one."""
if not self._region:
return
coord = (event.mouse_x - self._region.x, event.mouse_y - self._region.y)
dx = coord[0] - self._hover_last_px[0]
dy = coord[1] - self._hover_last_px[1]
if dx * dx + dy * dy < self._HOVER_THROTTLE_PX_SQ:
return
self._hover_last_px = coord
self._hover_candidates = self._compute_candidates(context, coord)
self._hover_index = 0
self._apply_hover_highlight(context)
def _cycle_hover(self, context):
"""Advance to the next candidate and update the highlight."""
if not self._hover_candidates:
return
self._hover_index = (self._hover_index + 1) % len(self._hover_candidates)
self._apply_hover_highlight(context)
def _apply_hover_highlight(self, context):
"""Select the current candidate object for visual feedback."""
if not self._hover_candidates:
self._clear_hover_highlight(context)
return
ifc_obj = self._hover_candidates[self._hover_index][0]
# Only update selection when the highlighted object changes.
if ifc_obj != self._hover_highlighted_obj:
if self._hover_highlighted_obj:
try:
self._hover_highlighted_obj.select_set(False)
except Exception:
pass
self._hover_highlighted_obj = ifc_obj
try:
ifc_obj.select_set(True)
context.view_layer.objects.active = ifc_obj
except Exception:
pass
entity = tool.Ifc.get_entity(ifc_obj)
label = (entity.Name or entity.GlobalId) if entity else ifc_obj.name
n = len(self._hover_candidates)
cycle_hint = f" | TAB: cycle ({self._hover_index + 1}/{n})" if n > 1 else ""
context.workspace.status_text_set(
f"Vertex {self._active_vertex_idx}{ifc_obj.name}{cycle_hint}"
" | Click: anchor | ALT+Click: free point | RMB/ESC: Finish"
)
def _clear_hover_highlight(self, context):
"""Deselect the highlighted object and restore the annotation as active."""
if self._hover_highlighted_obj:
try:
self._hover_highlighted_obj.select_set(False)
except Exception:
pass
self._hover_highlighted_obj = None
try:
self._annotation_obj.select_set(True)
context.view_layer.objects.active = self._annotation_obj
except Exception:
pass
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Pset write (shared by both face and free-point paths) # Pset write (shared by both face and free-point paths)
@@ -5993,7 +6353,7 @@ class SetDimensionAnchor(bpy.types.Operator):
file = tool.Ifc.get() file = tool.Ifc.get()
annotation = self._annotation annotation = self._annotation
pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_DimensionTarget") pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
if pset_data and pset_data.get("Anchors"): if pset_data and pset_data.get("Anchors"):
try: try:
@@ -6023,19 +6383,46 @@ class SetDimensionAnchor(bpy.types.Operator):
pset_entity = file.by_id(pset_data["id"]) pset_entity = file.by_id(pset_data["id"])
ifcopenshell.api.run("pset.edit_pset", file, pset=pset_entity, properties={"Anchors": anchors_json}) ifcopenshell.api.run("pset.edit_pset", file, pset=pset_entity, properties={"Anchors": anchors_json})
else: else:
ifcopenshell.api.run("pset.add_pset", file, product=annotation, name="BBIM_DimensionTarget") ifcopenshell.api.run("pset.add_pset", file, product=annotation, name="BBIM_Dimension")
pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_DimensionTarget") pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
pset_entity = file.by_id(pset_data["id"]) pset_entity = file.by_id(pset_data["id"])
ifcopenshell.api.run("pset.edit_pset", file, pset=pset_entity, properties={"Anchors": anchors_json}) ifcopenshell.api.run("pset.edit_pset", file, pset=pset_entity, properties={"Anchors": anchors_json})
from bonsai.bim.module.drawing import handler as _drawing_handler from bonsai.bim.module.drawing import handler as _drawing_handler
_drawing_handler.invalidate_dim_index() _drawing_handler.invalidate_dim_index()
# Move the Blender curve vertex to the newly resolved anchor position.
# Build placement_override from current Blender matrix_world so that
# elements whose IFC ObjectPlacement hasn't been synced yet resolve correctly.
placement_override: dict = {}
for a in anchors:
guid = a.get("guid")
if not guid:
continue
try:
elem = file.by_guid(guid)
elem_obj = tool.Ifc.get_object(elem)
if elem_obj:
placement_override[elem.id()] = np.array(elem_obj.matrix_world)
except Exception:
pass
import ifcopenshell.api.drawing as drawing_api
resolved_pts = drawing_api.regenerate_dimension(
file,
annotation,
shape_cache=getattr(self, "_shape_cache", None),
placement_override=placement_override,
)
if resolved_pts:
_update_blender_curve(annotation, resolved_pts)
print(f"[write_anchor] resolved_pts={[(round(p[0],4),round(p[1],4),round(p[2],4)) for p in resolved_pts]}")
class RegenerateDimensions(bpy.types.Operator, tool.Ifc.Operator): class RegenerateDimensions(bpy.types.Operator, tool.Ifc.Operator):
"""Regenerate all parametric dimension annotations in the project. """Regenerate all parametric dimension annotations in the project.
For every IfcAnnotation that has a BBIM_DimensionTarget pset, resolve all For every IfcAnnotation that has a BBIM_Dimension pset, resolve all
anchor references from live element geometry and update the annotation's anchor references from live element geometry and update the annotation's
curve vertices and linked IfcMetric values. curve vertices and linked IfcMetric values.
""" """
@@ -6084,12 +6471,12 @@ class RegenerateDimensions(bpy.types.Operator, tool.Ifc.Operator):
else: else:
candidates = [ candidates = [
a for a in file.by_type("IfcAnnotation") a for a in file.by_type("IfcAnnotation")
if ifcopenshell.util.element.get_pset(a, "BBIM_DimensionTarget") if ifcopenshell.util.element.get_pset(a, "BBIM_Dimension")
] ]
updated = 0 updated = 0
for annotation in candidates: for annotation in candidates:
pset = ifcopenshell.util.element.get_pset(annotation, "BBIM_DimensionTarget") pset = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
if not pset: if not pset:
continue continue
@@ -1051,6 +1051,11 @@ class BIMAnnotationProperties(PropertyGroup):
) )
is_adding_type: bpy.props.BoolProperty(default=False) is_adding_type: bpy.props.BoolProperty(default=False)
type_name: bpy.props.StringProperty(name="Name", default="TYPEX") type_name: bpy.props.StringProperty(name="Name", default="TYPEX")
force_perpendicular_to_face: bpy.props.BoolProperty(
name="Force ⊥ to Face",
description="Constrain subsequent dimension vertices to lie on the line through the first vertex along its face normal",
default=False,
)
tag_rotation_mode: bpy.props.EnumProperty( tag_rotation_mode: bpy.props.EnumProperty(
name="Tag Rotation Mode", name="Tag Rotation Mode",
description="How to orient the tag relative to the tagged object", description="How to orient the tag relative to the tagged object",
@@ -251,6 +251,11 @@ class AnnotationToolUI:
add_layout_hotkey_operator(cls.layout, "Add", "S_A", "Create a new annotation") add_layout_hotkey_operator(cls.layout, "Add", "S_A", "Create a new annotation")
_DIMENSION_TYPES = {"DIMENSION", "RADIUS", "DIAMETER", "ANGLE", "PLAN_LEVEL", "SECTION_LEVEL"}
if object_type in _DIMENSION_TYPES:
row = cls.layout.row(align=True)
row.prop(cls.props, "force_perpendicular_to_face", toggle=True)
if object_type in tool.Drawing.ANNOTATION_TYPES_SUPPORT_SETUP: if object_type in tool.Drawing.ANNOTATION_TYPES_SUPPORT_SETUP:
row = cls.layout.row(align=True) row = cls.layout.row(align=True)
row.label(text="", icon="DRIVER_ROTATIONAL_DIFFERENCE") row.label(text="", icon="DRIVER_ROTATIONAL_DIFFERENCE")
@@ -88,6 +88,51 @@ class DisablePsetEditing(bpy.types.Operator, tool.Ifc.Operator):
props.active_pset_type = "-" props.active_pset_type = "-"
def _regenerate_parametric_dimension(file, annotation):
"""Regenerate a single parametric dimension annotation after a pset edit."""
print(f"[regen_dim] called for annotation={annotation.id()} {annotation.is_a()}")
try:
import json
import numpy as np
import ifcopenshell.util.element
import ifcopenshell.api.drawing as drawing_api
import bonsai.tool as _tool
from bonsai.bim.module.drawing.operator import _update_blender_curve
pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
print(f"[regen_dim] pset_data keys={list(pset_data.keys()) if pset_data else None}")
if not pset_data or not pset_data.get("Anchors"):
print("[regen_dim] no Anchors — skipping")
return
anchors = json.loads(pset_data["Anchors"])
print(f"[regen_dim] {len(anchors)} anchors")
placement_override = {}
for a in anchors:
guid = a.get("guid")
if not guid:
continue
try:
elem = file.by_guid(guid)
elem_obj = _tool.Ifc.get_object(elem)
if elem_obj:
placement_override[elem.id()] = np.array(elem_obj.matrix_world)
print(f"[regen_dim] placement_override added for {elem.is_a()} id={elem.id()}")
except Exception as e:
print(f"[regen_dim] placement_override error: {e}")
resolved_pts = drawing_api.regenerate_dimension(
file, annotation, placement_override=placement_override
)
print(f"[regen_dim] resolved_pts={resolved_pts}")
if resolved_pts:
_update_blender_curve(annotation, resolved_pts)
print("[regen_dim] _update_blender_curve done")
except Exception:
import traceback
traceback.print_exc()
class EditPset(bpy.types.Operator, tool.Ifc.Operator): class EditPset(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.edit_pset" bl_idname = "bim.edit_pset"
bl_label = "Edit Pset" bl_label = "Edit Pset"
@@ -152,7 +197,15 @@ class EditPset(bpy.types.Operator, tool.Ifc.Operator):
) )
if tool.Cost.has_schedules(): if tool.Cost.has_schedules():
tool.Cost.update_cost_items(pset=pset) tool.Cost.update_cost_items(pset=pset)
print(f"[edit_pset] pset_name='{props.active_pset_name}' element={element.is_a()} before disable_pset_editing")
is_bbim_dimension = props.active_pset_name == "BBIM_Dimension" and element.is_a("IfcAnnotation")
bpy.ops.bim.disable_pset_editing(obj=self.obj, obj_type=self.obj_type) bpy.ops.bim.disable_pset_editing(obj=self.obj, obj_type=self.obj_type)
print(f"[edit_pset] pset_name after disable='{props.active_pset_name}' is_bbim_dimension={is_bbim_dimension}")
if is_bbim_dimension:
_regenerate_parametric_dimension(self.file, element)
tool.Blender.update_viewport() tool.Blender.update_viewport()
@@ -16,10 +16,10 @@
# You should have received a copy of the GNU Lesser General Public License # You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>. # along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
"""Regenerate a parametric dimension annotation from its BBIM_DimensionTarget anchors. """Regenerate a parametric dimension annotation from its BBIM_Dimension anchors.
This module operates purely on IFC data. It: This module operates purely on IFC data. It:
1. Reads the ``Anchors`` JSON array from the ``BBIM_DimensionTarget`` pset on an 1. Reads the ``Anchors`` JSON array from the ``BBIM_Dimension`` pset on an
``IfcAnnotation``. ``IfcAnnotation``.
2. Resolves each anchor to a world-space point (IFC project units) using 2. Resolves each anchor to a world-space point (IFC project units) using
``resolve_anchor``. ``resolve_anchor``.
@@ -48,7 +48,7 @@ import ifcopenshell.util.element
from .resolve_anchor import resolve_anchor from .resolve_anchor import resolve_anchor
_PSET_NAME = "BBIM_DimensionTarget" _PSET_NAME = "BBIM_Dimension"
_METRIC_INTENT_PREFIX = "PARAMETRIC_DIMENSION_SEG_" _METRIC_INTENT_PREFIX = "PARAMETRIC_DIMENSION_SEG_"
@@ -61,12 +61,12 @@ def regenerate_dimension(
) -> list[tuple[float, float, float]]: ) -> list[tuple[float, float, float]]:
"""Regenerate a parametric dimension from its stored anchor references. """Regenerate a parametric dimension from its stored anchor references.
Resolves every anchor in ``BBIM_DimensionTarget.Anchors``, updates the Resolves every anchor in ``BBIM_Dimension.Anchors``, updates the
per-segment ``IfcMetric`` values (creating them when absent), and returns per-segment ``IfcMetric`` values (creating them when absent), and returns
the resolved world-space points in metres. the resolved world-space points in metres.
:param file: The open IFC file. :param file: The open IFC file.
:param annotation: An ``IfcAnnotation`` with a ``BBIM_DimensionTarget`` pset. :param annotation: An ``IfcAnnotation`` with a ``BBIM_Dimension`` pset.
:param settings: Geometry settings for tessellation (shared across calls). :param settings: Geometry settings for tessellation (shared across calls).
:param shape_cache: Shape cache dict (shared across calls for performance). :param shape_cache: Shape cache dict (shared across calls for performance).
:param placement_override: Optional dict mapping element STEP id → 4×4 numpy :param placement_override: Optional dict mapping element STEP id → 4×4 numpy
@@ -99,6 +99,25 @@ def regenerate_dimension(
resolved.append(pt) resolved.append(pt)
anchor["pt"] = list(pt) anchor["pt"] = list(pt)
# ForcePerpendicularToFace: project vertices 1…n onto the line through
# pt[0] in the direction of anchor[0]'s face normal, so the polyline is
# constrained perpendicular to the face the first vertex is anchored to.
if pset_data.get("ForcePerpendicularToFace") and len(resolved) >= 2 and resolved[0] is not None:
normal = _get_anchor_face_normal_world(file, anchors[0], placement_override)
if normal:
base = resolved[0]
for i in range(1, len(resolved)):
if resolved[i] is None:
continue
pt = resolved[i]
t = ((pt[0] - base[0]) * normal[0]
+ (pt[1] - base[1]) * normal[1]
+ (pt[2] - base[2]) * normal[2])
resolved[i] = (base[0] + t * normal[0],
base[1] + t * normal[1],
base[2] + t * normal[2])
anchors[i]["pt"] = list(resolved[i])
pset_entity_id = pset_data.get("id") pset_entity_id = pset_data.get("id")
if pset_entity_id: if pset_entity_id:
pset_entity = file.by_id(pset_entity_id) pset_entity = file.by_id(pset_entity_id)
@@ -122,7 +141,7 @@ def get_dimension_segment_lengths(
) -> list[float]: ) -> list[float]:
"""Return the segment lengths for a parametric dimension from stored anchor pts. """Return the segment lengths for a parametric dimension from stored anchor pts.
Distances are computed from the cached ``pt`` fields in ``BBIM_DimensionTarget.Anchors`` Distances are computed from the cached ``pt`` fields in ``BBIM_Dimension.Anchors``
(in metres, matching ifcopenshell.geom output). Returns an empty list if the pset (in metres, matching ifcopenshell.geom output). Returns an empty list if the pset
is absent or malformed. is absent or malformed.
""" """
@@ -236,3 +255,39 @@ def _sync_segment_metrics(
def _dist(a: tuple, b: tuple) -> float: 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) return math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2)
def _get_anchor_face_normal_world(
file: ifcopenshell.file,
anchor: dict,
placement_override: Optional[dict] = None,
) -> Optional[tuple[float, float, float]]:
"""Return the world-space unit face normal stored in a FACE anchor, or None.
Prefers ``normal_local`` (element-local, rotation-invariant) transformed by
the current element placement. Falls back to the stored world-space normal.
"""
if anchor.get("type") != "FACE":
return None
guid = anchor.get("guid")
if not guid:
return None
fp = (anchor.get("addr") or {}).get("fingerprint") or {}
normal_local = fp.get("normal_local")
if normal_local:
try:
element = file.by_guid(guid)
except Exception:
return None
from .resolve_anchor import _rotate_local_to_world
n = _rotate_local_to_world(element, normal_local, placement_override)
mag = math.sqrt(n[0] ** 2 + n[1] ** 2 + n[2] ** 2)
return (n[0] / mag, n[1] / mag, n[2] / mag) if mag > 1e-12 else None
normal_world = fp.get("normal")
if normal_world:
mag = math.sqrt(sum(x * x for x in normal_world))
return tuple(x / mag for x in normal_world) if mag > 1e-12 else None # type: ignore[return-value]
return None
@@ -26,7 +26,7 @@ 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 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. must divide by ``ifcopenshell.util.unit.calculate_unit_scale(file)`` themselves.
Anchor schema (JSON-serialisable dict stored in BBIM_DimensionTarget.Anchors): Anchor schema (JSON-serialisable dict stored in BBIM_Dimension.Anchors):
{ {
"guid": str | None, # element GlobalId; None → WORLD type (free point) "guid": str | None, # element GlobalId; None → WORLD type (free point)
@@ -135,15 +135,28 @@ def resolve_anchor(
for gp in group_props 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") fingerprint = addr.get("fingerprint")
hint = anchor.get("hint") hint = anchor.get("hint")
if fingerprint: fp_normal_local = fingerprint.get("normal_local") if fingerprint else None
# TESS_INDEX fast path — only accept when the local fingerprint normal still
# matches at that index, guarding against face-group reordering after any
# geometry edit or profile change.
tess_index = addr.get("tess_index", -1)
if 0 <= tess_index < len(groups):
candidate_local = group_props[tess_index]
if fp_normal_local is None or _dot(candidate_local["normal"], fp_normal_local) >= 1.0 - _NORMAL_MATCH_THRESHOLD:
return world_group_props[tess_index]["centroid"]
# Local-normal mismatch — face groups reordered; fall through to fingerprint.
# TESS_FINGERPRINT — match by element-local normal (rotation-invariant).
if fp_normal_local:
pt = _find_by_local_normal(group_props, world_group_props, fp_normal_local, hint)
if pt is not None:
return pt
elif fingerprint:
# Legacy anchors built before normal_local was stored: fall back to
# world-space normal matching (not rotation-invariant, but best we can do).
pt = _find_by_fingerprint(world_group_props, fingerprint, hint) pt = _find_by_fingerprint(world_group_props, fingerprint, hint)
if pt is not None: if pt is not None:
return pt return pt
@@ -174,7 +187,7 @@ def build_anchor_from_hit(
:param shape_cache: Mutable shape-cache dict. :param shape_cache: Mutable shape-cache dict.
:param placement_override: Optional dict mapping element STEP id → 4×4 numpy :param placement_override: Optional dict mapping element STEP id → 4×4 numpy
matrix (metres). See ``resolve_anchor`` for details. matrix (metres). See ``resolve_anchor`` for details.
:return: Anchor dict ready for JSON serialisation into BBIM_DimensionTarget. :return: Anchor dict ready for JSON serialisation into BBIM_Dimension.
""" """
shape = _get_shape(file, element, settings, shape_cache) shape = _get_shape(file, element, settings, shape_cache)
@@ -201,12 +214,17 @@ def build_anchor_from_hit(
if best is not None: if best is not None:
tess_index, props = best tess_index, props = best
fingerprint = { fingerprint = {
# normal_local: element-local normal — rotation-invariant primary key.
"normal_local": list(local_group_props[tess_index]["normal"]),
# world-space fields kept for legacy / disambiguation.
"normal": list(props["normal"]), "normal": list(props["normal"]),
"area": props["area"], "area": props["area"],
"centroid": list(props["centroid"]), "centroid": list(props["centroid"]),
} }
repr_type, repr_id, face_role = _detect_extruded_face(file, element, hit_location_ifc, hit_normal_ifc) repr_type, repr_id, face_role = _detect_extruded_face(
file, element, hit_location_ifc, hit_normal_ifc, placement_override
)
method = "ANALYTIC" if repr_type == "IfcExtrudedAreaSolid" else "TESS_FINGERPRINT" method = "ANALYTIC" if repr_type == "IfcExtrudedAreaSolid" else "TESS_FINGERPRINT"
return { return {
@@ -322,6 +340,35 @@ def _rotate_local_to_world(
) )
def _world_normal_to_elem_local(
file: ifcopenshell.file,
element: ifcopenshell.entity_instance,
world_normal: tuple,
placement_override: Optional[dict] = None,
) -> tuple[float, float, float]:
"""Rotate a world-space direction into element-local space (rotation only, no translation).
Uses placement_override (Blender matrix_world) when available so that
elements moved/rotated in the viewport are handled correctly.
"""
x, y, z = float(world_normal[0]), float(world_normal[1]), float(world_normal[2])
if placement_override is not None and element.id() in placement_override:
m = placement_override[element.id()]
# Inverse rotation = transpose of the 3×3 rotation block.
lx = float(m[0][0]) * x + float(m[1][0]) * y + float(m[2][0]) * z
ly = float(m[0][1]) * x + float(m[1][1]) * y + float(m[2][1]) * z
lz = float(m[0][2]) * x + float(m[1][2]) * y + float(m[2][2]) * z
else:
m = ifcopenshell.util.placement.get_local_placement(element.ObjectPlacement)
lx = float(m[0][0]) * x + float(m[1][0]) * y + float(m[2][0]) * z
ly = float(m[0][1]) * x + float(m[1][1]) * y + float(m[2][1]) * z
lz = float(m[0][2]) * x + float(m[1][2]) * y + float(m[2][2]) * z
mag = math.sqrt(lx * lx + ly * ly + lz * lz)
if mag > 1e-12:
return (lx / mag, ly / mag, lz / mag)
return (x, y, z)
def _extract_mesh(shape) -> tuple[list[tuple], list[tuple]]: def _extract_mesh(shape) -> tuple[list[tuple], list[tuple]]:
"""Return (verts, tris) from a tessellated shape.""" """Return (verts, tris) from a tessellated shape."""
vf = shape.geometry.verts vf = shape.geometry.verts
@@ -511,8 +558,36 @@ def _best_group(
return best return best
def _find_by_local_normal(
local_group_props: list[dict],
world_group_props: list[dict],
fp_normal_local: list,
hint: Optional[list],
) -> Optional[tuple[float, float, float]]:
"""Return the world-space centroid of the face group whose element-local normal
best matches *fp_normal_local*. Matching in local space is rotation-invariant —
moving or rotating the element does not change local normals, so the anchor
correctly tracks the same face through placement changes and profile edits."""
best_score = -1.0
best_centroid = None
for i, lp in enumerate(local_group_props):
dot_val = _dot(lp["normal"], fp_normal_local)
if dot_val < 1.0 - _NORMAL_MATCH_THRESHOLD:
continue
score = dot_val
if hint:
hint_dist = _dist(world_group_props[i]["centroid"], hint)
score -= hint_dist / max(_CENTROID_MAX_DIST, 0.001) * 0.1
if score > best_score:
best_score = score
best_centroid = world_group_props[i]["centroid"]
return best_centroid
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Analytical resolution — IfcExtrudedAreaSolid TOP / BOTTOM # Analytical resolution — IfcExtrudedAreaSolid TOP / BOTTOM / SIDE_*
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -522,9 +597,16 @@ def _resolve_extruded_area_solid_analytic(
addr: dict, addr: dict,
placement_override: Optional[dict] = None, placement_override: Optional[dict] = None,
) -> Optional[tuple[float, float, float]]: ) -> Optional[tuple[float, float, float]]:
"""Analytically resolve TOP or BOTTOM face centre of an IfcExtrudedAreaSolid.""" """Analytically resolve a face centre of an IfcExtrudedAreaSolid.
Handles TOP, BOTTOM, and SIDE_PLUS_X / SIDE_MINUS_X / SIDE_PLUS_Y / SIDE_MINUS_Y
roles. Side-face roles are only supported for IfcRectangleProfileDef; other
profile types fall back to tessellation fingerprint matching.
"""
face_role = addr.get("face_role", "") face_role = addr.get("face_role", "")
if face_role not in ("TOP", "BOTTOM"): _top_bottom = ("TOP", "BOTTOM")
_sides = ("SIDE_PLUS_X", "SIDE_MINUS_X", "SIDE_PLUS_Y", "SIDE_MINUS_Y")
if face_role not in _top_bottom + _sides:
return None return None
repr_id = addr.get("repr_id") repr_id = addr.get("repr_id")
@@ -540,18 +622,64 @@ def _resolve_extruded_area_solid_analytic(
return None return None
try: try:
profile_centroid_local = _profile_centroid(solid.SweptArea) profile = solid.SweptArea
dir_ratios = solid.ExtrudedDirection.DirectionRatios dir_ratios = solid.ExtrudedDirection.DirectionRatios
depth = solid.Depth depth = float(solid.Depth)
mag = math.sqrt(sum(d * d for d in dir_ratios)) mag = math.sqrt(sum(d * d for d in dir_ratios))
if mag < 1e-12: if mag < 1e-12:
return None return None
dir_vec = tuple(d / mag for d in dir_ratios) 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) if face_role in _top_bottom:
py = profile_centroid_local[1] + dir_vec[1] * (depth if face_role == "TOP" else 0.0) profile_centroid_local = _profile_centroid(profile)
pz = dir_vec[2] * (depth if face_role == "TOP" else 0.0) scale = depth if face_role == "TOP" else 0.0
px = profile_centroid_local[0] + dir_vec[0] * scale
py = profile_centroid_local[1] + dir_vec[1] * scale
pz = dir_vec[2] * scale
else: # SIDE_* — only for IfcRectangleProfileDef
if not profile.is_a("IfcRectangleProfileDef"):
return None
x_dim = float(profile.XDim)
y_dim = float(profile.YDim)
half_depth = depth / 2.0
# Profile centre and local axes (from profile.Position 2D placement).
cx, cy = 0.0, 0.0
px_axis = (1.0, 0.0) # profile X in profile 2D
if hasattr(profile, "Position") and profile.Position:
loc = profile.Position.Location
cx = float(loc.Coordinates[0])
cy = float(loc.Coordinates[1])
if profile.Position.RefDirection:
pr = profile.Position.RefDirection.DirectionRatios
pm = math.sqrt(pr[0] ** 2 + pr[1] ** 2)
if pm > 1e-12:
px_axis = (pr[0] / pm, pr[1] / pm)
py_axis = (-px_axis[1], px_axis[0]) # 90° rotation
half_x = x_dim / 2.0
half_y = y_dim / 2.0
if face_role == "SIDE_PLUS_X":
fx = cx + half_x * px_axis[0]
fy = cy + half_x * px_axis[1]
elif face_role == "SIDE_MINUS_X":
fx = cx - half_x * px_axis[0]
fy = cy - half_x * px_axis[1]
elif face_role == "SIDE_PLUS_Y":
fx = cx + half_y * py_axis[0]
fy = cy + half_y * py_axis[1]
else: # SIDE_MINUS_Y
fx = cx - half_y * py_axis[0]
fy = cy - half_y * py_axis[1]
# Lift from profile 2D to solid-local 3D at mid-extrusion depth.
px = fx + dir_vec[0] * half_depth
py = fy + dir_vec[1] * half_depth
pz = dir_vec[2] * half_depth
if solid.Position: if solid.Position:
local_pt = _apply_axis2placement3d(solid.Position, (px, py, pz)) local_pt = _apply_axis2placement3d(solid.Position, (px, py, pz))
@@ -638,21 +766,27 @@ def _detect_extruded_face(
element: ifcopenshell.entity_instance, element: ifcopenshell.entity_instance,
hit_location: tuple, hit_location: tuple,
hit_normal: tuple, hit_normal: tuple,
placement_override: Optional[dict] = None,
) -> tuple[str, int, str]: ) -> tuple[str, int, str]:
"""Try to identify if the hit face is a TOP or BOTTOM of an IfcExtrudedAreaSolid. """Identify if the hit face is a face of an IfcExtrudedAreaSolid.
Returns (repr_type, repr_id, face_role). Returns (repr_type, repr_id, face_role).
repr_type is empty string if not detected as extruded solid. face_role is one of: 'TOP', 'BOTTOM', 'SIDE_PLUS_X', 'SIDE_MINUS_X',
'SIDE_PLUS_Y', 'SIDE_MINUS_Y', or '' (not recognized).
Side roles are only returned for IfcRectangleProfileDef.
""" """
if not hasattr(element, "Representation") or not element.Representation: if not hasattr(element, "Representation") or not element.Representation:
return ("", -1, "") return ("", -1, "")
# Transform hit_normal from world → element-local for accurate role classification.
hit_normal_elem = _world_normal_to_elem_local(file, element, hit_normal, placement_override)
for rep in element.Representation.Representations: for rep in element.Representation.Representations:
for item in rep.Items: for item in rep.Items:
solid = _unwrap_mapped(item) solid = _unwrap_mapped(item)
if not solid or not solid.is_a("IfcExtrudedAreaSolid"): if not solid or not solid.is_a("IfcExtrudedAreaSolid"):
continue continue
role = _extruded_face_role(solid, hit_normal) role = _extruded_face_role(solid, hit_normal_elem)
if role: if role:
return ("IfcExtrudedAreaSolid", solid.id(), role) return ("IfcExtrudedAreaSolid", solid.id(), role)
@@ -667,19 +801,99 @@ def _unwrap_mapped(item):
return item return item
def _extruded_face_role(solid, hit_normal: tuple) -> str: def _apply_axis2placement3d_rotation_inv(placement, vec: tuple) -> tuple[float, float, float]:
"""Return 'TOP', 'BOTTOM', or '' based on whether hit_normal aligns with extrusion.""" """Apply the inverse rotation of an IfcAxis2Placement3D to a direction.
Transforms a direction from element-local space into solid-local space.
The rotation matrix R = [x_axis | y_axis | z_axis]; its inverse for an
orthogonal matrix is R^T, computed here by dotting with each basis vector.
"""
if placement is None:
return vec
x, y, z = float(vec[0]), float(vec[1]), float(vec[2])
if placement.Axis:
zr = placement.Axis.DirectionRatios
zm = math.sqrt(zr[0] ** 2 + zr[1] ** 2 + zr[2] ** 2)
zx, zy, zz = (zr[0] / zm, zr[1] / zm, zr[2] / zm) if zm > 1e-12 else (0.0, 0.0, 1.0)
else:
zx, zy, zz = 0.0, 0.0, 1.0
if placement.RefDirection:
xr = placement.RefDirection.DirectionRatios
xm = math.sqrt(xr[0] ** 2 + xr[1] ** 2 + xr[2] ** 2)
xx, xy, xz = (xr[0] / xm, xr[1] / xm, xr[2] / xm) if xm > 1e-12 else (1.0, 0.0, 0.0)
else:
xx, xy, xz = 1.0, 0.0, 0.0
# Y = Z × X
yx = zy * xz - zz * xy
yy = zz * xx - zx * xz
yz = zx * xy - zy * xx
# R^T: dot input with each column of R (= each basis axis of the placement).
inv_x = xx * x + xy * y + xz * z
inv_y = yx * x + yy * y + yz * z
inv_z = zx * x + zy * y + zz * z
mag = math.sqrt(inv_x ** 2 + inv_y ** 2 + inv_z ** 2)
if mag > 1e-12:
return (inv_x / mag, inv_y / mag, inv_z / mag)
return vec
def _extruded_face_role(solid, hit_normal_elem_local: tuple) -> str:
"""Classify the hit face role on an IfcExtrudedAreaSolid.
Returns 'TOP', 'BOTTOM', 'SIDE_PLUS_X', 'SIDE_MINUS_X', 'SIDE_PLUS_Y',
'SIDE_MINUS_Y', or ''. Side roles require IfcRectangleProfileDef.
:param hit_normal_elem_local: Face normal in element-local space.
"""
try: try:
# Map from element-local to solid-local via solid.Position inverse rotation.
hit_normal_solid = _apply_axis2placement3d_rotation_inv(solid.Position, hit_normal_elem_local)
dr = solid.ExtrudedDirection.DirectionRatios dr = solid.ExtrudedDirection.DirectionRatios
mag = math.sqrt(sum(d * d for d in dr)) mag = math.sqrt(sum(d * d for d in dr))
if mag < 1e-12: if mag < 1e-12:
return "" return ""
extrude_dir = tuple(d / mag for d in dr) extrude_dir = tuple(d / mag for d in dr)
dot_val = _dot(extrude_dir, hit_normal)
if dot_val > 0.99: dot_extrude = _dot(extrude_dir, hit_normal_solid)
if dot_extrude > 0.99:
return "TOP" return "TOP"
if dot_val < -0.99: if dot_extrude < -0.99:
return "BOTTOM" return "BOTTOM"
# Side face detection — only supported for IfcRectangleProfileDef.
if not solid.SweptArea.is_a("IfcRectangleProfileDef"):
return ""
profile = solid.SweptArea
# Profile X axis in solid-local 2D (from profile.Position.RefDirection).
px_axis = (1.0, 0.0)
if hasattr(profile, "Position") and profile.Position and profile.Position.RefDirection:
pr = profile.Position.RefDirection.DirectionRatios
pm = math.sqrt(pr[0] ** 2 + pr[1] ** 2)
if pm > 1e-12:
px_axis = (pr[0] / pm, pr[1] / pm)
py_axis = (-px_axis[1], px_axis[0]) # 90° CCW
# Lift 2D profile axes to solid-local 3D (profile is in the solid XY plane).
px_3d = (px_axis[0], px_axis[1], 0.0)
py_3d = (py_axis[0], py_axis[1], 0.0)
dot_x = _dot(hit_normal_solid, px_3d)
dot_y = _dot(hit_normal_solid, py_3d)
if abs(dot_x) > 0.99:
return "SIDE_PLUS_X" if dot_x > 0 else "SIDE_MINUS_X"
if abs(dot_y) > 0.99:
return "SIDE_PLUS_Y" if dot_y > 0 else "SIDE_MINUS_Y"
except Exception: except Exception:
pass pass
return "" return ""