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 95025ad4b1
commit 288b716574
4 changed files with 79 additions and 15 deletions
@@ -571,6 +571,19 @@ class BIM_PT_product_assignments(Panel):
col.operator("bim.select_assigned_product", icon="RESTRICT_SELECT_OFF", text="")
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):
@@ -26,7 +26,7 @@ from .. import wrap_usecases
from .assign_product import assign_product
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, build_anchor_from_layer_boundary, build_anchor_from_profile_vert, build_anchor_from_profile_edge, get_layer_snap_candidates, get_profile_snap_candidates, make_world_anchor, resolve_anchor
from .resolve_anchor import build_anchor_from_hit, build_anchor_from_layer_boundary, build_anchor_from_local_point, build_anchor_from_profile_vert, build_anchor_from_profile_edge, get_layer_snap_candidates, get_profile_snap_candidates, make_world_anchor, resolve_anchor
from .unassign_product import unassign_product
wrap_usecases(__path__, __name__)
@@ -35,6 +35,7 @@ __all__ = [
"assign_product",
"build_anchor_from_hit",
"build_anchor_from_layer_boundary",
"build_anchor_from_local_point",
"build_anchor_from_profile_edge",
"build_anchor_from_profile_vert",
"edit_text_literal",
@@ -58,6 +58,7 @@ def regenerate_dimension(
settings: Optional[ifcopenshell.geom.settings] = None,
shape_cache: Optional[dict] = None,
placement_override: Optional[dict] = None,
camera_dir: Optional[tuple[float, float, float]] = None,
) -> list[tuple[float, float, float]]:
"""Regenerate a parametric dimension from its stored anchor references.
@@ -136,11 +137,10 @@ def regenerate_dimension(
# horizontal offset axis (perpendicular to the dimension direction). Applied after
# the pset write so anchor["pt"] always stores the true geometry surface hit.
# Because it is absolute, the dimension line stays put even if the geometry moves.
# Only active when ForcePerpendicularToFace is also set — the two are semantically coupled.
line_position = pset_data.get("LinePosition")
if line_position is not None and pset_data.get("ForcePerpendicularToFace") and resolved:
if line_position is not None and resolved:
face_normal = _get_anchor_face_normal_world(file, anchors[0], placement_override)
offset_dir = _get_line_offset_direction(face_normal, [pt for pt in resolved if pt is not None])
offset_dir = _get_line_offset_direction(face_normal, [pt for pt in resolved if pt is not None], camera_dir)
if offset_dir:
resolved = [
_project_to_line_position(pt, offset_dir, float(line_position)) if pt is not None else None
@@ -339,36 +339,47 @@ def _get_anchor_face_normal_world(
def _get_line_offset_direction(
face_normal: Optional[tuple[float, float, float]],
resolved_pts: list[tuple],
camera_dir: Optional[tuple[float, float, float]] = None,
) -> Optional[tuple[float, float, float]]:
"""Return the direction to apply LineOffset — parallel to the first face.
"""Return the direction to slide the dimension line (perpendicular to it, in-view).
Uses cross(world_Z, dim_direction) to get the horizontal direction
perpendicular to the dimension line, which slides the line sideways
(parallel to the face) rather than into/out of it.
For plan views (camera mostly vertical) uses cross(world_Z, dim_dir)
unchanged from the original behaviour, so existing stored LinePosition
values continue to work.
For section/elevation views (camera mostly horizontal) uses
cross(camera_dir, dim_dir) so the offset lies in the camera's view plane.
This makes dragging the gizmo move the line visually up/down (or
left/right) rather than in/out of the screen.
Falls back to cross(face_normal, world_Z) when the dimension line is
nearly vertical (e.g. elevation dimensions).
nearly parallel to the reference vector (e.g. vertical elevation dims).
"""
world_z = (0.0, 0.0, 1.0)
# Primary: use the dimension line direction (anchor[0] → anchor[1])
# In section/elevation (camera mostly horizontal) use camera_dir as the
# reference so the offset axis lies in the view plane.
cam_is_plan = camera_dir is None or abs(camera_dir[2]) > 0.7
ref = world_z if cam_is_plan else camera_dir
# Primary: cross(ref, dim_dir)
if len(resolved_pts) >= 2:
a, b = resolved_pts[0], resolved_pts[1]
dx, dy, dz = b[0] - a[0], b[1] - a[1], b[2] - a[2]
dim_mag = math.sqrt(dx * dx + dy * dy + dz * dz)
if dim_mag > 1e-10:
dim_dir = (dx / dim_mag, dy / dim_mag, dz / dim_mag)
# cross(world_Z, dim_dir) — horizontal direction perp to dimension
d = (
world_z[1] * dim_dir[2] - world_z[2] * dim_dir[1],
world_z[2] * dim_dir[0] - world_z[0] * dim_dir[2],
world_z[0] * dim_dir[1] - world_z[1] * dim_dir[0],
ref[1] * dim_dir[2] - ref[2] * dim_dir[1],
ref[2] * dim_dir[0] - ref[0] * dim_dir[2],
ref[0] * dim_dir[1] - ref[1] * dim_dir[0],
)
mag = math.sqrt(d[0] ** 2 + d[1] ** 2 + d[2] ** 2)
if mag > 1e-6:
return (d[0] / mag, d[1] / mag, d[2] / mag)
# Fallback for vertical dims: cross(face_normal, world_Z)
# Fallback for dims parallel to ref (e.g. vertical dims in plan):
# cross(face_normal, world_Z)
if face_normal:
n = face_normal
d = (
@@ -131,6 +131,11 @@ def resolve_anchor(
pt = _resolve_profile_vert_anchor(file, element, addr, placement_override)
elif method == "PROFILE_EDGE":
pt = _resolve_profile_edge_anchor(file, element, addr, placement_override)
elif method == "LOCAL_POINT":
lx = addr.get("local_x_m")
ly = addr.get("local_y_m")
lz = addr.get("local_z_m")
pt = _local_to_world_m(file, element, (lx, ly, lz), placement_override) if None not in (lx, ly, lz) else None
else:
pt = None
return pt if pt is not None else _pt_or_none(anchor.get("pt"))
@@ -236,6 +241,40 @@ def make_world_anchor(pt_ifc: tuple[float, float, float]) -> dict:
}
def build_anchor_from_local_point(
element: ifcopenshell.entity_instance,
snap_kind: str,
world_pos: tuple,
local_pos_m: tuple,
) -> dict:
"""Build a VERTEX/EDGE anchor using element-local coordinates (metres).
Used as a fallback for tessellated elements (IfcFacetedBrep, etc.) that have
no IfcExtrudedAreaSolid. The stored ``local_m`` is resolved back to world
space via ``_local_to_world_m`` so the anchor follows the element through
moves and rotations.
:param element: The IFC element the snap landed on.
:param snap_kind: ``"VERTEX"`` or ``"EDGE"``.
:param world_pos: Current world-space snap position in metres (stored as hint/pt).
:param local_pos_m: Element-local position in metres (rotation-invariant).
:return: Anchor dict ready for JSON serialisation.
"""
return {
"guid": element.GlobalId,
"type": snap_kind,
"addr": {
"method": "LOCAL_POINT",
"local_x_m": float(local_pos_m[0]),
"local_y_m": float(local_pos_m[1]),
"local_z_m": float(local_pos_m[2]),
"snap": snap_kind,
},
"hint": list(world_pos),
"pt": list(world_pos),
}
def build_anchor_from_profile_vert(
file: ifcopenshell.file,
element: ifcopenshell.entity_instance,