mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-18 03:19:53 +00:00
Add Force Parallel to Face constraint for parametric dimensions
Adds a new 'Force ∥ to Face' toggle alongside the existing 'Force ⊥ to Face'. The constraint direction is cross(face_normal, camera_dir), keeping dimension vertices running along the face surface rather than into it. Enabling one constraint automatically disables the other (mutual exclusion via a re-entrant guard in the prop callbacks). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5714,6 +5714,8 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope
|
||||
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]
|
||||
self._force_parallel = False
|
||||
self._anchor0_parallel_dir = None # (tx, ty, tz) cross(face_normal, camera_dir)
|
||||
self._snap_mode = "FACE"
|
||||
self._ifc_snap_candidate = None
|
||||
self._draw_handler = None
|
||||
@@ -5800,16 +5802,20 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope
|
||||
pt = polyline_data[0].polyline_points[-1]
|
||||
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()
|
||||
# After anchor[0] is set, extract its face normal for the perp/parallel constraints.
|
||||
if len(self._anchors) == 1:
|
||||
if self._force_perpendicular:
|
||||
self._update_perp_constraint()
|
||||
if self._force_parallel:
|
||||
self._update_parallel_constraint()
|
||||
elif count_after < count_before and self._anchors:
|
||||
# BACKSPACE removed a point.
|
||||
self._anchors.pop()
|
||||
# Reset constraint if we backspaced past anchor[0].
|
||||
# Reset constraints if we backspaced past anchor[0].
|
||||
if len(self._anchors) == 0:
|
||||
self._anchor0_normal = None
|
||||
self._anchor0_pt = None
|
||||
self._anchor0_parallel_dir = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Perpendicular-to-face constraint helpers
|
||||
@@ -5897,6 +5903,53 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope
|
||||
constrained = Vector((base[0] + t * n[0], base[1] + t * n[1], base[2] + t * n[2]))
|
||||
snap["point"] = constrained
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Parallel-to-face constraint helpers
|
||||
|
||||
def _update_parallel_constraint(self) -> None:
|
||||
"""Compute the tangent direction (cross of face normal × camera forward) for anchor[0]."""
|
||||
import math
|
||||
from mathutils import Vector
|
||||
# Reuse _update_perp_constraint to obtain the face normal first.
|
||||
if not self._anchor0_normal:
|
||||
self._update_perp_constraint()
|
||||
if not self._anchor0_normal:
|
||||
return
|
||||
cam = bpy.context.scene.camera
|
||||
if not cam:
|
||||
return
|
||||
cam_fwd = cam.matrix_world.to_3x3() @ Vector((0.0, 0.0, -1.0))
|
||||
cam_fwd.normalize()
|
||||
n = self._anchor0_normal
|
||||
cd = (cam_fwd.x, cam_fwd.y, cam_fwd.z)
|
||||
tang = (
|
||||
n[1] * cd[2] - n[2] * cd[1],
|
||||
n[2] * cd[0] - n[0] * cd[2],
|
||||
n[0] * cd[1] - n[1] * cd[0],
|
||||
)
|
||||
mag = math.sqrt(tang[0] ** 2 + tang[1] ** 2 + tang[2] ** 2)
|
||||
if mag < 1e-12:
|
||||
return
|
||||
self._anchor0_parallel_dir = (tang[0] / mag, tang[1] / mag, tang[2] / mag)
|
||||
|
||||
def _apply_parallel_constraint(self) -> None:
|
||||
"""Project the current snap point onto the tangent line (parallel to face) when active."""
|
||||
if not self._force_parallel or not self._anchor0_parallel_dir or not self._anchor0_pt:
|
||||
return
|
||||
if not self._anchors:
|
||||
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
|
||||
t = self._anchor0_parallel_dir
|
||||
proj = (p.x - base[0]) * t[0] + (p.y - base[1]) * t[1] + (p.z - base[2]) * t[2]
|
||||
constrained = Vector((base[0] + proj * t[0], base[1] + proj * t[1], base[2] + proj * t[2]))
|
||||
snap["point"] = constrained
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# IFC-native snap for LAYER / VERTEX / EDGE modes
|
||||
|
||||
@@ -6375,6 +6428,8 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope
|
||||
pset_props = {"Anchors": json.dumps(anchors)}
|
||||
if self._force_perpendicular:
|
||||
pset_props["ForcePerpendicularToFace"] = True
|
||||
if self._force_parallel:
|
||||
pset_props["ForceParallelToFace"] = True
|
||||
ifcopenshell.api.run("pset.edit_pset", file, pset=pset_entity, properties=pset_props)
|
||||
|
||||
# Always regenerate from anchor data so the curve reflects the true IFC
|
||||
@@ -6431,6 +6486,7 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope
|
||||
self.choose_axis(event)
|
||||
self.handle_snap_selection(context, event)
|
||||
self._apply_perp_constraint()
|
||||
self._apply_parallel_constraint()
|
||||
|
||||
# TAB: cycle snap mode when not in keyboard-input mode.
|
||||
# Consume both PRESS and RELEASE so the RELEASE never reaches
|
||||
@@ -6513,7 +6569,9 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope
|
||||
|
||||
def _invoke(self, context, event):
|
||||
super().invoke(context, event)
|
||||
self._force_perpendicular = tool.Drawing.get_annotation_props().force_perpendicular_to_face
|
||||
props = tool.Drawing.get_annotation_props()
|
||||
self._force_perpendicular = props.force_perpendicular_to_face
|
||||
self._force_parallel = props.force_parallel_to_face and not self._force_perpendicular
|
||||
_snap_draw_data.clear()
|
||||
self._draw_handler = bpy.types.SpaceView3D.draw_handler_add(
|
||||
_draw_snap_indicator_global, (), "WINDOW", "POST_VIEW"
|
||||
|
||||
@@ -1046,60 +1046,154 @@ def update_sheet_data(self, context):
|
||||
SheetsData.is_loaded = False
|
||||
|
||||
|
||||
# Guard against re-entrant calls when one constraint callback clears the other property.
|
||||
_face_constraint_updating = False
|
||||
|
||||
|
||||
def _update_force_perpendicular(self, context):
|
||||
"""Apply ForcePerpendicularToFace to all selected dimension annotations and regenerate them."""
|
||||
import json
|
||||
import numpy as np
|
||||
import ifcopenshell.util.element
|
||||
import ifcopenshell.api.pset
|
||||
import ifcopenshell.api.drawing as drawing_api
|
||||
import bonsai.tool as tool
|
||||
|
||||
file = tool.Ifc.get()
|
||||
if not file:
|
||||
global _face_constraint_updating
|
||||
if _face_constraint_updating:
|
||||
return
|
||||
_face_constraint_updating = True
|
||||
try:
|
||||
import json
|
||||
import numpy as np
|
||||
import ifcopenshell.util.element
|
||||
import ifcopenshell.api.pset
|
||||
import ifcopenshell.api.drawing as drawing_api
|
||||
import bonsai.tool as tool
|
||||
|
||||
new_value = self.force_perpendicular_to_face
|
||||
_DIM_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE"))
|
||||
file = tool.Ifc.get()
|
||||
if not file:
|
||||
return
|
||||
|
||||
targets = []
|
||||
for obj in context.selected_objects:
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element or not element.is_a("IfcAnnotation"):
|
||||
continue
|
||||
if ifcopenshell.util.element.get_predefined_type(element) not in _DIM_TYPES:
|
||||
continue
|
||||
pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Dimension")
|
||||
if not pset_data:
|
||||
continue
|
||||
targets.append((obj, element, pset_data))
|
||||
new_value = self.force_perpendicular_to_face
|
||||
if new_value and self.force_parallel_to_face:
|
||||
self.force_parallel_to_face = False
|
||||
_DIM_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE"))
|
||||
|
||||
if not targets:
|
||||
return
|
||||
|
||||
from bonsai.bim.module.drawing.operator import _update_blender_curve
|
||||
|
||||
for obj, element, pset_data in targets:
|
||||
pset_entity = file.by_id(pset_data["id"])
|
||||
ifcopenshell.api.pset.edit_pset(file, pset=pset_entity, properties={"ForcePerpendicularToFace": new_value})
|
||||
|
||||
anchors = json.loads(pset_data.get("Anchors") or "[]")
|
||||
placement_override = {}
|
||||
for a in anchors:
|
||||
guid = a.get("guid")
|
||||
if not guid:
|
||||
targets = []
|
||||
for obj in context.selected_objects:
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element or not element.is_a("IfcAnnotation"):
|
||||
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
|
||||
if ifcopenshell.util.element.get_predefined_type(element) not in _DIM_TYPES:
|
||||
continue
|
||||
pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Dimension")
|
||||
if not pset_data:
|
||||
continue
|
||||
targets.append((obj, element, pset_data))
|
||||
|
||||
resolved_pts = drawing_api.regenerate_dimension(file, element, placement_override=placement_override)
|
||||
if resolved_pts:
|
||||
_update_blender_curve(element, resolved_pts)
|
||||
if not targets:
|
||||
return
|
||||
|
||||
from bonsai.bim.module.drawing.operator import _update_blender_curve
|
||||
|
||||
for obj, element, pset_data in targets:
|
||||
pset_entity = file.by_id(pset_data["id"])
|
||||
pset_props = {"ForcePerpendicularToFace": new_value}
|
||||
if new_value:
|
||||
pset_props["ForceParallelToFace"] = False
|
||||
ifcopenshell.api.pset.edit_pset(file, pset=pset_entity, properties=pset_props)
|
||||
|
||||
anchors = json.loads(pset_data.get("Anchors") or "[]")
|
||||
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)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
resolved_pts = drawing_api.regenerate_dimension(file, element, placement_override=placement_override)
|
||||
if resolved_pts:
|
||||
_update_blender_curve(element, resolved_pts)
|
||||
finally:
|
||||
_face_constraint_updating = False
|
||||
|
||||
|
||||
def _update_force_parallel(self, context):
|
||||
"""Apply ForceParallelToFace to all selected dimension annotations and regenerate them."""
|
||||
global _face_constraint_updating
|
||||
if _face_constraint_updating:
|
||||
return
|
||||
_face_constraint_updating = True
|
||||
try:
|
||||
import json
|
||||
import numpy as np
|
||||
import ifcopenshell.util.element
|
||||
import ifcopenshell.api.pset
|
||||
import ifcopenshell.api.drawing as drawing_api
|
||||
import bonsai.tool as tool
|
||||
from mathutils import Vector
|
||||
|
||||
file = tool.Ifc.get()
|
||||
if not file:
|
||||
return
|
||||
|
||||
new_value = self.force_parallel_to_face
|
||||
if new_value and self.force_perpendicular_to_face:
|
||||
self.force_perpendicular_to_face = False
|
||||
_DIM_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE"))
|
||||
|
||||
targets = []
|
||||
for obj in context.selected_objects:
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element or not element.is_a("IfcAnnotation"):
|
||||
continue
|
||||
if ifcopenshell.util.element.get_predefined_type(element) not in _DIM_TYPES:
|
||||
continue
|
||||
pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Dimension")
|
||||
if not pset_data:
|
||||
continue
|
||||
targets.append((obj, element, pset_data))
|
||||
|
||||
if not targets:
|
||||
return
|
||||
|
||||
cam = context.scene.camera
|
||||
cam_dir_tuple = None
|
||||
if cam:
|
||||
cd = cam.matrix_world.to_3x3() @ Vector((0, 0, -1))
|
||||
cd.normalize()
|
||||
cam_dir_tuple = (cd.x, cd.y, cd.z)
|
||||
|
||||
from bonsai.bim.module.drawing.operator import _update_blender_curve
|
||||
|
||||
for obj, element, pset_data in targets:
|
||||
pset_entity = file.by_id(pset_data["id"])
|
||||
pset_props = {"ForceParallelToFace": new_value}
|
||||
if new_value:
|
||||
pset_props["ForcePerpendicularToFace"] = False
|
||||
ifcopenshell.api.pset.edit_pset(file, pset=pset_entity, properties=pset_props)
|
||||
|
||||
anchors = json.loads(pset_data.get("Anchors") or "[]")
|
||||
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)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
resolved_pts = drawing_api.regenerate_dimension(
|
||||
file, element, placement_override=placement_override, camera_dir=cam_dir_tuple
|
||||
)
|
||||
if resolved_pts:
|
||||
_update_blender_curve(element, resolved_pts)
|
||||
finally:
|
||||
_face_constraint_updating = False
|
||||
|
||||
|
||||
def _get_line_position(self) -> float:
|
||||
@@ -1241,6 +1335,12 @@ class BIMAnnotationProperties(PropertyGroup):
|
||||
default=False,
|
||||
update=_update_force_perpendicular,
|
||||
)
|
||||
force_parallel_to_face: bpy.props.BoolProperty(
|
||||
name="Force ∥ to Face",
|
||||
description="Constrain dimension vertices to run parallel to the face of the first anchor (along the face, perpendicular to its normal). When dimensions are selected, toggling this updates them all.",
|
||||
default=False,
|
||||
update=_update_force_parallel,
|
||||
)
|
||||
line_position: bpy.props.FloatProperty(
|
||||
name="Line Position",
|
||||
description="Absolute world position of the dimension line along the horizontal axis perpendicular to the dimension. The line is held at this fixed global coordinate even when the measured geometry moves. Updates all selected dimensions.",
|
||||
|
||||
@@ -313,6 +313,8 @@ class AnnotationToolUI:
|
||||
if object_type in _DIMENSION_TYPES:
|
||||
row = cls.layout.row(align=True)
|
||||
row.prop(cls.props, "force_perpendicular_to_face")
|
||||
row = cls.layout.row(align=True)
|
||||
row.prop(cls.props, "force_parallel_to_face")
|
||||
|
||||
if object_type in tool.Drawing.ANNOTATION_TYPES_SUPPORT_SETUP:
|
||||
row = cls.layout.row(align=True)
|
||||
|
||||
@@ -119,6 +119,34 @@ def regenerate_dimension(
|
||||
base[2] + t * normal[2])
|
||||
anchors[i]["pt"] = list(resolved[i])
|
||||
|
||||
# ForceParallelToFace: project vertices 1…n onto the line through
|
||||
# pt[0] in the direction cross(face_normal, camera_dir), so the polyline
|
||||
# runs parallel to the face (perpendicular to the face normal).
|
||||
if pset_data.get("ForceParallelToFace") and len(resolved) >= 2 and resolved[0] is not None:
|
||||
face_normal = _get_anchor_face_normal_world(file, anchors[0], placement_override)
|
||||
if face_normal and camera_dir:
|
||||
fn, cd = face_normal, camera_dir
|
||||
tang = (
|
||||
fn[1] * cd[2] - fn[2] * cd[1],
|
||||
fn[2] * cd[0] - fn[0] * cd[2],
|
||||
fn[0] * cd[1] - fn[1] * cd[0],
|
||||
)
|
||||
mag = math.sqrt(tang[0] ** 2 + tang[1] ** 2 + tang[2] ** 2)
|
||||
if mag > 1e-12:
|
||||
tang = (tang[0] / mag, tang[1] / mag, tang[2] / mag)
|
||||
base = resolved[0]
|
||||
for i in range(1, len(resolved)):
|
||||
if resolved[i] is None:
|
||||
continue
|
||||
pt = resolved[i]
|
||||
t = ((pt[0] - base[0]) * tang[0]
|
||||
+ (pt[1] - base[1]) * tang[1]
|
||||
+ (pt[2] - base[2]) * tang[2])
|
||||
resolved[i] = (base[0] + t * tang[0],
|
||||
base[1] + t * tang[1],
|
||||
base[2] + t * tang[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)
|
||||
|
||||
Reference in New Issue
Block a user