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:
Ryan Schultz
2026-08-15 11:55:32 -05:00
parent 6cae194610
commit 99f95bee6a
4 changed files with 239 additions and 51 deletions
@@ -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"
+146 -46
View File
@@ -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)