mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-05 23:41:44 +00:00
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user