mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-19 22:50:21 +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._force_perpendicular = False
|
||||||
self._anchor0_normal = None # (nx, ny, nz) world-space face normal of anchor[0]
|
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._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._snap_mode = "FACE"
|
||||||
self._ifc_snap_candidate = None
|
self._ifc_snap_candidate = None
|
||||||
self._draw_handler = 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]
|
pt = polyline_data[0].polyline_points[-1]
|
||||||
self._anchors.append(drawing_api.make_world_anchor([float(pt.x), float(pt.y), float(pt.z)]))
|
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.
|
# After anchor[0] is set, extract its face normal for the perp/parallel constraints.
|
||||||
if self._force_perpendicular and len(self._anchors) == 1:
|
if len(self._anchors) == 1:
|
||||||
self._update_perp_constraint()
|
if self._force_perpendicular:
|
||||||
|
self._update_perp_constraint()
|
||||||
|
if self._force_parallel:
|
||||||
|
self._update_parallel_constraint()
|
||||||
elif count_after < count_before and self._anchors:
|
elif count_after < count_before and self._anchors:
|
||||||
# BACKSPACE removed a point.
|
# BACKSPACE removed a point.
|
||||||
self._anchors.pop()
|
self._anchors.pop()
|
||||||
# Reset constraint if we backspaced past anchor[0].
|
# Reset constraints if we backspaced past anchor[0].
|
||||||
if len(self._anchors) == 0:
|
if len(self._anchors) == 0:
|
||||||
self._anchor0_normal = None
|
self._anchor0_normal = None
|
||||||
self._anchor0_pt = None
|
self._anchor0_pt = None
|
||||||
|
self._anchor0_parallel_dir = None
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Perpendicular-to-face constraint helpers
|
# 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]))
|
constrained = Vector((base[0] + t * n[0], base[1] + t * n[1], base[2] + t * n[2]))
|
||||||
snap["point"] = constrained
|
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
|
# 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)}
|
pset_props = {"Anchors": json.dumps(anchors)}
|
||||||
if self._force_perpendicular:
|
if self._force_perpendicular:
|
||||||
pset_props["ForcePerpendicularToFace"] = True
|
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)
|
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
|
# 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.choose_axis(event)
|
||||||
self.handle_snap_selection(context, event)
|
self.handle_snap_selection(context, event)
|
||||||
self._apply_perp_constraint()
|
self._apply_perp_constraint()
|
||||||
|
self._apply_parallel_constraint()
|
||||||
|
|
||||||
# TAB: cycle snap mode when not in keyboard-input mode.
|
# TAB: cycle snap mode when not in keyboard-input mode.
|
||||||
# Consume both PRESS and RELEASE so the RELEASE never reaches
|
# 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):
|
def _invoke(self, context, event):
|
||||||
super().invoke(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()
|
_snap_draw_data.clear()
|
||||||
self._draw_handler = bpy.types.SpaceView3D.draw_handler_add(
|
self._draw_handler = bpy.types.SpaceView3D.draw_handler_add(
|
||||||
_draw_snap_indicator_global, (), "WINDOW", "POST_VIEW"
|
_draw_snap_indicator_global, (), "WINDOW", "POST_VIEW"
|
||||||
|
|||||||
@@ -1046,60 +1046,154 @@ def update_sheet_data(self, context):
|
|||||||
SheetsData.is_loaded = False
|
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):
|
def _update_force_perpendicular(self, context):
|
||||||
"""Apply ForcePerpendicularToFace to all selected dimension annotations and regenerate them."""
|
"""Apply ForcePerpendicularToFace to all selected dimension annotations and regenerate them."""
|
||||||
import json
|
global _face_constraint_updating
|
||||||
import numpy as np
|
if _face_constraint_updating:
|
||||||
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:
|
|
||||||
return
|
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
|
file = tool.Ifc.get()
|
||||||
_DIM_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE"))
|
if not file:
|
||||||
|
return
|
||||||
|
|
||||||
targets = []
|
new_value = self.force_perpendicular_to_face
|
||||||
for obj in context.selected_objects:
|
if new_value and self.force_parallel_to_face:
|
||||||
element = tool.Ifc.get_entity(obj)
|
self.force_parallel_to_face = False
|
||||||
if not element or not element.is_a("IfcAnnotation"):
|
_DIM_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE"))
|
||||||
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:
|
targets = []
|
||||||
return
|
for obj in context.selected_objects:
|
||||||
|
element = tool.Ifc.get_entity(obj)
|
||||||
from bonsai.bim.module.drawing.operator import _update_blender_curve
|
if not element or not element.is_a("IfcAnnotation"):
|
||||||
|
|
||||||
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:
|
|
||||||
continue
|
continue
|
||||||
try:
|
if ifcopenshell.util.element.get_predefined_type(element) not in _DIM_TYPES:
|
||||||
elem = file.by_guid(guid)
|
continue
|
||||||
elem_obj = tool.Ifc.get_object(elem)
|
pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Dimension")
|
||||||
if elem_obj:
|
if not pset_data:
|
||||||
placement_override[elem.id()] = np.array(elem_obj.matrix_world)
|
continue
|
||||||
except Exception:
|
targets.append((obj, element, pset_data))
|
||||||
pass
|
|
||||||
|
|
||||||
resolved_pts = drawing_api.regenerate_dimension(file, element, placement_override=placement_override)
|
if not targets:
|
||||||
if resolved_pts:
|
return
|
||||||
_update_blender_curve(element, resolved_pts)
|
|
||||||
|
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:
|
def _get_line_position(self) -> float:
|
||||||
@@ -1241,6 +1335,12 @@ class BIMAnnotationProperties(PropertyGroup):
|
|||||||
default=False,
|
default=False,
|
||||||
update=_update_force_perpendicular,
|
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(
|
line_position: bpy.props.FloatProperty(
|
||||||
name="Line Position",
|
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.",
|
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:
|
if object_type in _DIMENSION_TYPES:
|
||||||
row = cls.layout.row(align=True)
|
row = cls.layout.row(align=True)
|
||||||
row.prop(cls.props, "force_perpendicular_to_face")
|
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:
|
if object_type in tool.Drawing.ANNOTATION_TYPES_SUPPORT_SETUP:
|
||||||
row = cls.layout.row(align=True)
|
row = cls.layout.row(align=True)
|
||||||
|
|||||||
@@ -119,6 +119,34 @@ def regenerate_dimension(
|
|||||||
base[2] + t * normal[2])
|
base[2] + t * normal[2])
|
||||||
anchors[i]["pt"] = list(resolved[i])
|
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")
|
pset_entity_id = pset_data.get("id")
|
||||||
if pset_entity_id:
|
if pset_entity_id:
|
||||||
pset_entity = file.by_id(pset_entity_id)
|
pset_entity = file.by_id(pset_entity_id)
|
||||||
|
|||||||
Reference in New Issue
Block a user