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
@@ -16,10 +16,10 @@
# 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.
"""Regenerate a parametric dimension annotation from its BBIM_Dimension anchors.
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``.
2. Resolves each anchor to a world-space point (IFC project units) using
``resolve_anchor``.
@@ -48,7 +48,7 @@ import ifcopenshell.util.element
from .resolve_anchor import resolve_anchor
_PSET_NAME = "BBIM_DimensionTarget"
_PSET_NAME = "BBIM_Dimension"
_METRIC_INTENT_PREFIX = "PARAMETRIC_DIMENSION_SEG_"
@@ -61,12 +61,12 @@ def regenerate_dimension(
) -> list[tuple[float, float, float]]:
"""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
the resolved world-space points in metres.
: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 shape_cache: Shape cache dict (shared across calls for performance).
:param placement_override: Optional dict mapping element STEP id → 4×4 numpy
@@ -99,6 +99,25 @@ def regenerate_dimension(
resolved.append(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")
if pset_entity_id:
pset_entity = file.by_id(pset_entity_id)
@@ -122,7 +141,7 @@ def get_dimension_segment_lengths(
) -> 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``
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
is absent or malformed.
"""
@@ -236,3 +255,39 @@ def _sync_segment_metrics(
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)
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
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)
@@ -135,15 +135,28 @@ def resolve_anchor(
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:
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)
if pt is not None:
return pt
@@ -174,7 +187,7 @@ def build_anchor_from_hit(
: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.
:return: Anchor dict ready for JSON serialisation into BBIM_Dimension.
"""
shape = _get_shape(file, element, settings, shape_cache)
@@ -201,12 +214,17 @@ def build_anchor_from_hit(
if best is not None:
tess_index, props = best
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"]),
"area": props["area"],
"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"
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]]:
"""Return (verts, tris) from a tessellated shape."""
vf = shape.geometry.verts
@@ -511,8 +558,36 @@ def _best_group(
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,
placement_override: Optional[dict] = None,
) -> 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", "")
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
repr_id = addr.get("repr_id")
@@ -540,18 +622,64 @@ def _resolve_extruded_area_solid_analytic(
return None
try:
profile_centroid_local = _profile_centroid(solid.SweptArea)
profile = solid.SweptArea
dir_ratios = solid.ExtrudedDirection.DirectionRatios
depth = solid.Depth
depth = float(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 face_role in _top_bottom:
profile_centroid_local = _profile_centroid(profile)
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:
local_pt = _apply_axis2placement3d(solid.Position, (px, py, pz))
@@ -638,21 +766,27 @@ def _detect_extruded_face(
element: ifcopenshell.entity_instance,
hit_location: tuple,
hit_normal: tuple,
placement_override: Optional[dict] = None,
) -> 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).
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:
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 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)
role = _extruded_face_role(solid, hit_normal_elem)
if role:
return ("IfcExtrudedAreaSolid", solid.id(), role)
@@ -667,19 +801,99 @@ def _unwrap_mapped(item):
return item
def _extruded_face_role(solid, hit_normal: tuple) -> str:
"""Return 'TOP', 'BOTTOM', or '' based on whether hit_normal aligns with extrusion."""
def _apply_axis2placement3d_rotation_inv(placement, vec: tuple) -> tuple[float, float, float]:
"""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:
# 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
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:
dot_extrude = _dot(extrude_dir, hit_normal_solid)
if dot_extrude > 0.99:
return "TOP"
if dot_val < -0.99:
if dot_extrude < -0.99:
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:
pass
return ""