mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-13 19:07:57 +00:00
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:
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user