Add DriveDimensionLength operator to move anchored objects to target dimension

Clicking the near/far half of a parametric dimension segment opens a dialog
pre-filled with the current segment length; entering a target value moves
the corresponding anchored IFC element and regenerates the dimension curve.
First click selects the dimension; second click triggers the dialog.
Clears the cut-decorator cache after the move so section hatch updates.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ryan Schultz
2026-08-05 21:08:46 -05:00
parent a6f2396476
commit 053d8e9901
3 changed files with 299 additions and 17 deletions
@@ -115,6 +115,7 @@ classes = (
operator.DrawParametricDimension,
operator.SetDimensionAnchor,
operator.RegenerateDimensions,
operator.DriveDimensionLength,
operator.RemoveDimensionAnchor,
operator.InsertDimensionAnchor,
operator.ClickNearestDimensionAnchor,
@@ -183,8 +184,10 @@ classes = (
gizmos.ExtrusionGuidesGizmo,
gizmos.ExtrusionWidget,
gizmos.GizmoAnchorHandle,
gizmos.GizmoDriveDimLabel,
gizmos.DimensionAnchorWidget,
gizmos.DimensionLinePositionWidget,
gizmos.DimensionDriveLabelWidget,
workspace.LaunchAnnotationTypeManager,
workspace.Hotkey,
)
@@ -3022,6 +3022,76 @@ class DimensionLinePositionWidget(types.GizmoGroup):
return scale_value
class DimensionDriveLabelWidget(types.GizmoGroup):
"""Pen-icon gizmos at each segment midpoint of the active parametric dimension.
Clicking a pen invokes ``bim.drive_dimension_length`` for that segment,
opening a dialog pre-filled with the current length.
"""
bl_idname = "BIM_GGT_dimension_drive_label"
bl_label = "Dimension Drive Label"
bl_space_type = "VIEW_3D"
bl_region_type = "WINDOW"
bl_options = {"3D", "PERSISTENT", "SHOW_MODAL_ALL"}
_DIM_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE"))
_MAX_SEGMENTS = 15
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
if not tool.Ifc.get():
return False
obj = context.active_object
if not obj or obj.type != "CURVE":
return False
element = tool.Ifc.get_entity(obj)
if not element or not element.is_a("IfcAnnotation"):
return False
import ifcopenshell.util.element as _ue
if _ue.get_predefined_type(element) not in cls._DIM_TYPES:
return False
pset = _ue.get_pset(element, "BBIM_Dimension")
return bool(pset and pset.get("Anchors"))
def setup(self, context: bpy.types.Context) -> None:
self._labels: list = []
for _ in range(self._MAX_SEGMENTS):
gz = self.gizmos.new("BIM_GT_drive_dim_label")
gz.color = (0.9, 0.75, 0.1)
gz.color_highlight = (1.0, 0.95, 0.3)
gz.alpha = 0.85
gz.alpha_highlight = 1.0
gz.scale_basis = 0.18
gz.use_draw_modal = True
gz.hide = True
self._labels.append(gz)
def refresh(self, context: bpy.types.Context) -> None:
obj = context.active_object
if not obj or not obj.data or not getattr(obj.data, "splines", None) or not obj.data.splines:
for gz in self._labels:
gz.hide = True
return
spline = obj.data.splines[0]
pts = [obj.matrix_world @ p.co.to_3d() for p in spline.points]
n_segs = min(len(pts) - 1, self._MAX_SEGMENTS)
for i in range(n_segs):
gz = self._labels[i]
mid = (pts[i] + pts[i + 1]) * 0.5
gz.matrix_basis = Matrix.Translation(mid)
gz.segment_index = i
gz.hide = False
for i in range(n_segs, self._MAX_SEGMENTS):
self._labels[i].hide = True
def draw_prepare(self, context: bpy.types.Context) -> None:
self.refresh(context)
# ============================================================================
# Core Gizmo Classes
# ============================================================================
@@ -3988,6 +4058,24 @@ class GizmoPen(StaticTrisGizmoMixin, bpy.types.Gizmo):
)
class GizmoDriveDimLabel(bpy.types.Gizmo):
"""Visual-only pen icon at a parametric dimension segment midpoint.
No draw_select/invoke click handling is done by ClickNearestDimensionAnchor,
which dispatches bim.drive_dimension_length on a plain LMB at a midpoint.
"""
bl_idname = "BIM_GT_drive_dim_label"
__slots__ = ("segment_index", "custom_shape")
def setup(self):
self.segment_index = 0
self.custom_shape = self.new_custom_shape("TRIS", GizmoPen.tris)
def draw(self, context):
self.draw_custom_shape(self.custom_shape)
class GizmoValidate(StaticTrisGizmoMixin, bpy.types.Gizmo):
"""Validate/checkmark icon gizmo for confirming edits."""
+208 -17
View File
@@ -8416,6 +8416,144 @@ def _find_curve_in_item(item: ifcopenshell.entity_instance) -> Optional[ifcopens
class DriveDimensionLength(bpy.types.Operator, tool.Ifc.Operator):
"""Set the length of one segment of a parametric dimension by moving one end's element.
Click the near half of a segment to move the near-end element; click the far half to
move the far-end element. A dialog opens pre-filled with the current length enter
the target value and confirm.
"""
bl_idname = "bim.drive_dimension_length"
bl_label = "Drive Dimension Length"
bl_options = {"REGISTER", "UNDO"}
segment_index: bpy.props.IntProperty(default=0)
# 0 = move anchor[segment_index] (near end); 1 = move anchor[segment_index+1] (far end)
move_end: bpy.props.IntProperty(default=1)
target_length: bpy.props.FloatProperty(
name="Length",
description="Target length for this dimension segment",
subtype="DISTANCE",
unit="LENGTH",
min=0.001,
)
def invoke(self, context, event):
obj = context.active_object
if not obj:
return {"CANCELLED"}
element = tool.Ifc.get_entity(obj)
if not element:
return {"CANCELLED"}
pset = ifcopenshell.util.element.get_pset(element, "BBIM_Dimension")
if not pset or not pset.get("Anchors"):
return {"CANCELLED"}
try:
anchors = json.loads(pset["Anchors"])
except Exception:
return {"CANCELLED"}
if self.segment_index + 1 >= len(anchors):
return {"CANCELLED"}
pt_a = anchors[self.segment_index].get("pt")
pt_b = anchors[self.segment_index + 1].get("pt")
if pt_a and pt_b:
import math as _math
dx, dy, dz = pt_b[0] - pt_a[0], pt_b[1] - pt_a[1], pt_b[2] - pt_a[2]
self.target_length = _math.sqrt(dx * dx + dy * dy + dz * dz)
return context.window_manager.invoke_props_dialog(self)
def _execute(self, context):
from mathutils import Vector
obj = context.active_object
if not obj:
return {"CANCELLED"}
file = tool.Ifc.get()
annotation = tool.Ifc.get_entity(obj)
if not annotation:
return {"CANCELLED"}
pset = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
if not pset or not pset.get("Anchors"):
return {"CANCELLED"}
try:
anchors = json.loads(pset["Anchors"])
except Exception:
return {"CANCELLED"}
idx_a, idx_b = self.segment_index, self.segment_index + 1
if idx_b >= len(anchors):
return {"CANCELLED"}
pt_a = anchors[idx_a].get("pt")
pt_b = anchors[idx_b].get("pt")
if not pt_a or not pt_b:
return {"CANCELLED"}
seg = Vector(pt_b) - Vector(pt_a)
current_length = seg.length
if current_length < 1e-10:
return {"CANCELLED"}
# move_end=1 → move far anchor (anchor[idx_b]) away from near anchor (fixed).
# move_end=0 → move near anchor (anchor[idx_a]) away from far anchor (fixed).
# The direction seg points from a→b; negating it gives the b→a direction.
if self.move_end == 0:
move_idx = idx_a
delta = -(self.target_length - current_length) * seg.normalized()
else:
move_idx = idx_b
delta = (self.target_length - current_length) * seg.normalized()
guid_b = anchors[move_idx].get("guid")
if not guid_b:
self.report({"WARNING"}, "That anchor has no IFC element — cannot move")
return {"CANCELLED"}
try:
elem_b = file.by_guid(guid_b)
except Exception:
return {"CANCELLED"}
elem_obj = tool.Ifc.get_object(elem_b)
if not elem_obj:
return {"CANCELLED"}
elem_obj.matrix_world.translation += delta
ifcopenshell.api.run(
"geometry.edit_object_placement",
file,
product=elem_b,
matrix=np.array(elem_obj.matrix_world),
)
placement_override: dict = {}
for a in anchors:
guid = a.get("guid")
if not guid:
continue
try:
elem = file.by_guid(guid)
o = tool.Ifc.get_object(elem)
if o:
placement_override[elem.id()] = np.array(o.matrix_world)
except Exception:
pass
import ifcopenshell.api.drawing as drawing_api
_cam = bpy.context.scene.camera
_cam_dir = None
if _cam:
_cam_dir = tuple((_cam.matrix_world.to_3x3() @ Vector((0, 0, -1))).normalized())
resolved_pts = drawing_api.regenerate_dimension(
file, annotation,
placement_override=placement_override,
camera_dir=_cam_dir,
)
if resolved_pts:
_update_blender_curve(annotation, resolved_pts)
if CutDecorator.installed:
CutDecorator.install(context)
tool.Blender.update_viewport()
class RemoveDimensionAnchor(bpy.types.Operator, tool.Ifc.Operator):
"""Remove one anchor vertex from a parametric dimension.
@@ -8570,6 +8708,7 @@ class ClickNearestDimensionAnchor(bpy.types.Operator):
"""Click handler for dimension anchor dots and segment midpoints.
Plain click near a dot SetDimensionAnchor (re-anchor that vertex).
Plain click near a segment midpoint DriveDimensionLength (drive that segment to a typed length).
Alt+click near a dot RemoveDimensionAnchor (delete that vertex).
Ctrl+click near a dot or segment midpoint InsertDimensionAnchor
(insert a new vertex after that position and enter face-pick mode).
@@ -8584,6 +8723,12 @@ class ClickNearestDimensionAnchor(bpy.types.Operator):
RADIUS_PX = 15
# Tracks which dimension objects have already been "first-clicked" (selected).
# A plain click on a segment line is only dispatched to DriveDimensionLength once
# the object's name appears here. Cleared whenever the user clicks away from all
# dimensions so the next click re-enters the "first click = select" state.
_activated: set = set()
def invoke(self, context, event):
from bpy_extras.view3d_utils import location_3d_to_region_2d
@@ -8620,6 +8765,7 @@ class ClickNearestDimensionAnchor(bpy.types.Operator):
best_dist_sq = float("inf")
# "DOT" = clicked on an anchor vertex; "MIDPOINT" = clicked between two anchors.
best_hit_type = "DOT"
best_move_end = 1 # only meaningful for MIDPOINT hits
for obj in context.scene.objects:
if obj.type != "CURVE":
@@ -8665,27 +8811,63 @@ class ClickNearestDimensionAnchor(bpy.types.Operator):
best_obj = obj
best_hit_type = "DOT"
# When Ctrl is held, also check segment midpoints so the user
# can insert between two anchors without clicking on a dot.
if event.ctrl:
for i in range(len(screen_pts) - 1):
sp_a = screen_pts[i]
sp_b = screen_pts[i + 1]
if not sp_a or not sp_b:
continue
mid_x = (sp_a.x + sp_b.x) / 2.0
mid_y = (sp_a.y + sp_b.y) / 2.0
dx, dy = cx - mid_x, cy - mid_y
d2 = dx * dx + dy * dy
if d2 < r2 and d2 < best_dist_sq:
best_dist_sq = d2
best_idx = i # insert_after = segment start
best_obj = obj
best_hit_type = "MIDPOINT"
# Check proximity to each segment line (full length, excluding dot zones).
# Projects the cursor onto the segment; accepts clicks within RADIUS_PX
# perpendicularly, but rejects the RADIUS_PX region around each endpoint
# so dot and segment hits never compete.
for i in range(len(screen_pts) - 1):
sp_a = screen_pts[i]
sp_b = screen_pts[i + 1]
if not sp_a or not sp_b:
continue
seg_dx = sp_b.x - sp_a.x
seg_dy = sp_b.y - sp_a.y
seg_len_sq = seg_dx * seg_dx + seg_dy * seg_dy
if seg_len_sq < 1e-6:
continue
seg_len = seg_len_sq ** 0.5
# Parametric projection of cursor onto segment (0=sp_a, 1=sp_b).
t = ((cx - sp_a.x) * seg_dx + (cy - sp_a.y) * seg_dy) / seg_len_sq
# Exclude the dot zones at each end.
dot_frac = self.RADIUS_PX / seg_len
if t < dot_frac or t > 1.0 - dot_frac:
continue
# Perpendicular distance from cursor to segment.
foot_x = sp_a.x + t * seg_dx
foot_y = sp_a.y + t * seg_dy
perp_d2 = (cx - foot_x) ** 2 + (cy - foot_y) ** 2
if perp_d2 < r2 and perp_d2 < best_dist_sq:
best_dist_sq = perp_d2
best_idx = i
best_obj = obj
best_hit_type = "MIDPOINT"
# t < 0.5 → cursor is in the near half → move near-end object.
best_move_end = 0 if t < 0.5 else 1
if best_obj is None:
print(f"[ClickDim] no hit → clear _activated={ClickNearestDimensionAnchor._activated}")
ClickNearestDimensionAnchor._activated.clear()
return {"PASS_THROUGH"}
print(f"[ClickDim] hit={best_hit_type} obj={best_obj.name!r} ctrl={event.ctrl} alt={event.alt} _activated={ClickNearestDimensionAnchor._activated}")
# For midpoint hits (drive-dimension): require a prior interaction with this
# dimension before opening the dialog. First click explicitly selects it
# (anchor dots appear) so the user has clear feedback before the second click.
if best_hit_type == "MIDPOINT" and not event.ctrl:
if best_obj.name not in ClickNearestDimensionAnchor._activated:
ClickNearestDimensionAnchor._activated.add(best_obj.name)
print(f"[ClickDim] first click → select {best_obj.name!r}")
for o in list(context.selected_objects):
o.select_set(False)
best_obj.select_set(True)
context.view_layer.objects.active = best_obj
return {"FINISHED"}
print(f"[ClickDim] MIDPOINT dispatch → drive_dimension_length")
# Any successful non-first-click interaction marks this dimension as activated.
ClickNearestDimensionAnchor._activated.add(best_obj.name)
for o in list(context.selected_objects):
o.select_set(False)
best_obj.select_set(True)
@@ -8698,6 +8880,7 @@ class ClickNearestDimensionAnchor(bpy.types.Operator):
self._alt = event.alt
self._ctrl = event.ctrl
self._hit_type = best_hit_type
self._move_end = best_move_end
context.window_manager.modal_handler_add(self)
return {"RUNNING_MODAL"}
@@ -8710,6 +8893,14 @@ class ClickNearestDimensionAnchor(bpy.types.Operator):
elif self._ctrl:
# Ctrl+click on a dot or midpoint → insert after that position.
bpy.ops.bim.insert_dimension_anchor("INVOKE_DEFAULT", insert_after=self._best_idx)
elif self._hit_type == "MIDPOINT":
# Plain click on a segment midpoint → drive that segment's length.
# Which end moves depends on which half of the segment was clicked.
bpy.ops.bim.drive_dimension_length(
"INVOKE_DEFAULT",
segment_index=self._best_idx,
move_end=self._move_end,
)
else:
# Plain click on a dot → re-anchor (existing behaviour).
from bonsai.bim.module.drawing.gizmos import set_active_anchor