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.operator("bim.select_assigned_product", icon="RESTRICT_SELECT_OFF", text="")
col.enabled = bool(ProductAssignmentsData.data["relating_product"]) col.enabled = bool(ProductAssignmentsData.data["relating_product"])
# Parametric dimension controls
element = tool.Ifc.get_entity(obj)
if element:
import ifcopenshell.util.element
ptype = ifcopenshell.util.element.get_predefined_type(element)
if ptype in ("DIMENSION", "RADIUS", "DIAMETER", "ANGLE", "PLAN_LEVEL", "SECTION_LEVEL"):
self.layout.separator()
self.layout.label(text="Parametric Dimension", icon="CONSTRAINT")
row = self.layout.row(align=True)
row.operator("bim.set_dimension_anchor", icon="PIVOT_CURSOR")
op = row.operator("bim.regenerate_dimensions", icon="FILE_REFRESH", text="Regenerate")
op.active_only = True
def get_category_icon(category_name): def get_category_icon(category_name):
@@ -26,7 +26,7 @@ from .. import wrap_usecases
from .assign_product import assign_product from .assign_product import assign_product
from .edit_text_literal import edit_text_literal from .edit_text_literal import edit_text_literal
from .regenerate_dimension import regenerate_dimension, get_dimension_segment_lengths from .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 from .unassign_product import unassign_product
wrap_usecases(__path__, __name__) wrap_usecases(__path__, __name__)
@@ -35,6 +35,7 @@ __all__ = [
"assign_product", "assign_product",
"build_anchor_from_hit", "build_anchor_from_hit",
"build_anchor_from_layer_boundary", "build_anchor_from_layer_boundary",
"build_anchor_from_local_point",
"build_anchor_from_profile_edge", "build_anchor_from_profile_edge",
"build_anchor_from_profile_vert", "build_anchor_from_profile_vert",
"edit_text_literal", "edit_text_literal",
@@ -58,6 +58,7 @@ def regenerate_dimension(
settings: Optional[ifcopenshell.geom.settings] = None, settings: Optional[ifcopenshell.geom.settings] = None,
shape_cache: Optional[dict] = None, shape_cache: Optional[dict] = None,
placement_override: Optional[dict] = None, placement_override: Optional[dict] = None,
camera_dir: Optional[tuple[float, float, float]] = None,
) -> 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.
@@ -136,11 +137,10 @@ def regenerate_dimension(
# horizontal offset axis (perpendicular to the dimension direction). Applied after # horizontal offset axis (perpendicular to the dimension direction). Applied after
# the pset write so anchor["pt"] always stores the true geometry surface hit. # 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. # 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") 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) 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: if offset_dir:
resolved = [ resolved = [
_project_to_line_position(pt, offset_dir, float(line_position)) if pt is not None else None _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( def _get_line_offset_direction(
face_normal: Optional[tuple[float, float, float]], face_normal: Optional[tuple[float, float, float]],
resolved_pts: list[tuple], resolved_pts: list[tuple],
camera_dir: Optional[tuple[float, float, float]] = None,
) -> Optional[tuple[float, float, float]]: ) -> 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 For plan views (camera mostly vertical) uses cross(world_Z, dim_dir) —
perpendicular to the dimension line, which slides the line sideways unchanged from the original behaviour, so existing stored LinePosition
(parallel to the face) rather than into/out of it. 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 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) 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: if len(resolved_pts) >= 2:
a, b = resolved_pts[0], resolved_pts[1] a, b = resolved_pts[0], resolved_pts[1]
dx, dy, dz = b[0] - a[0], b[1] - a[1], b[2] - a[2] 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) dim_mag = math.sqrt(dx * dx + dy * dy + dz * dz)
if dim_mag > 1e-10: if dim_mag > 1e-10:
dim_dir = (dx / dim_mag, dy / dim_mag, dz / dim_mag) dim_dir = (dx / dim_mag, dy / dim_mag, dz / dim_mag)
# cross(world_Z, dim_dir) — horizontal direction perp to dimension
d = ( d = (
world_z[1] * dim_dir[2] - world_z[2] * dim_dir[1], ref[1] * dim_dir[2] - ref[2] * dim_dir[1],
world_z[2] * dim_dir[0] - world_z[0] * dim_dir[2], ref[2] * dim_dir[0] - ref[0] * dim_dir[2],
world_z[0] * dim_dir[1] - world_z[1] * dim_dir[0], ref[0] * dim_dir[1] - ref[1] * dim_dir[0],
) )
mag = math.sqrt(d[0] ** 2 + d[1] ** 2 + d[2] ** 2) mag = math.sqrt(d[0] ** 2 + d[1] ** 2 + d[2] ** 2)
if mag > 1e-6: if mag > 1e-6:
return (d[0] / mag, d[1] / mag, d[2] / mag) 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: if face_normal:
n = face_normal n = face_normal
d = ( d = (
@@ -131,6 +131,11 @@ def resolve_anchor(
pt = _resolve_profile_vert_anchor(file, element, addr, placement_override) pt = _resolve_profile_vert_anchor(file, element, addr, placement_override)
elif method == "PROFILE_EDGE": elif method == "PROFILE_EDGE":
pt = _resolve_profile_edge_anchor(file, element, addr, placement_override) 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: else:
pt = None pt = None
return pt if pt is not None else _pt_or_none(anchor.get("pt")) 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( def build_anchor_from_profile_vert(
file: ifcopenshell.file, file: ifcopenshell.file,
element: ifcopenshell.entity_instance, element: ifcopenshell.entity_instance,