mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-05 23:41:44 +00:00
Fix anchor click, undo, layer regen, and ForcePerpendicularToFace for layer anchors
- ClickNearestDimensionAnchor: scan all selected objects instead of active object so clicking a green dot doesn't lose to the underlying IFC geometry - GizmoAnchorHandle: remove draw_select entirely (any entry in the select buffer causes Blender's gizmo system to consume clicks); keep purely visual - Scale anchor dots to scale_basis = 0.2 - Fix ReferenceError in decoration.py draw loop after undo by catching ReferenceError and resetting DecoratorData.is_loaded - SetDimensionAnchor: inherit tool.Ifc.Operator so IFC pset writes are tracked for undo; finish the modal after each face write so each anchor gets its own undo step - Fix ReferenceError in _modal after undo when annotation RNA is freed - handler.py: add regenerate_dims_for_layer; call it from EditMaterialSetItem._execute so dimensions update when layer thickness changes - regenerate_dimension.py: fix ForcePerpendicularToFace for LAYER_BOUNDARY anchors by deriving the thickness-axis normal from LayerSetDirection (AXIS2→Y, AXIS1→X, AXIS3→Z) instead of requiring a stored normal_local Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -215,9 +215,7 @@ def register():
|
|||||||
kc = wm.keyconfigs.addon
|
kc = wm.keyconfigs.addon
|
||||||
if kc:
|
if kc:
|
||||||
km = kc.keymaps.new(name="3D View", space_type="VIEW_3D")
|
km = kc.keymaps.new(name="3D View", space_type="VIEW_3D")
|
||||||
kmi = km.keymap_items.new(
|
kmi = km.keymap_items.new("bim.click_nearest_dimension_anchor", "LEFTMOUSE", "PRESS")
|
||||||
"bim.click_nearest_dimension_anchor", "LEFTMOUSE", "PRESS"
|
|
||||||
)
|
|
||||||
_keymaps.append((km, kmi))
|
_keymaps.append((km, kmi))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2146,4 +2146,8 @@ class DecorationsHandler:
|
|||||||
|
|
||||||
object_decorators = DecoratorData.data.get("object_decorators", [])
|
object_decorators = DecoratorData.data.get("object_decorators", [])
|
||||||
for obj, decorator in object_decorators:
|
for obj, decorator in object_decorators:
|
||||||
decorator.decorate(context, obj)
|
try:
|
||||||
|
decorator.decorate(context, obj)
|
||||||
|
except ReferenceError:
|
||||||
|
DecoratorData.is_loaded = False
|
||||||
|
break
|
||||||
|
|||||||
@@ -2636,44 +2636,25 @@ class ExtrusionWidget(types.GizmoGroup):
|
|||||||
|
|
||||||
|
|
||||||
class GizmoAnchorHandle(bpy.types.Gizmo):
|
class GizmoAnchorHandle(bpy.types.Gizmo):
|
||||||
"""Dot gizmo positioned at one vertex of a parametric dimension curve.
|
"""Visual-only dot at a parametric dimension vertex.
|
||||||
|
|
||||||
Clicking it invokes ``bim.set_dimension_anchor`` pre-scoped to that vertex
|
No draw_select/invoke — any draw_select entry puts the gizmo in Blender's
|
||||||
index, skipping the manual vertex-pick phase of the operator.
|
select buffer, which causes the gizmo system to consume the click even
|
||||||
|
without an explicit invoke. All click handling is done by the
|
||||||
|
bim.click_nearest_dimension_anchor keymap operator.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
bl_idname = "BIM_GT_anchor_handle"
|
bl_idname = "BIM_GT_anchor_handle"
|
||||||
|
|
||||||
__slots__ = ("anchor_index", "custom_shape", "custom_shape_select")
|
__slots__ = ("anchor_index", "custom_shape")
|
||||||
|
|
||||||
def setup(self):
|
def setup(self):
|
||||||
self.anchor_index = 0
|
self.anchor_index = 0
|
||||||
self.custom_shape = self.new_custom_shape(type="TRIS", verts=X3DISC)
|
self.custom_shape = self.new_custom_shape(type="TRIS", verts=X3DISC)
|
||||||
# 4× scaled version used only for hit-detection — bigger target, same visual size.
|
|
||||||
_sel = tuple((x * 4.0, y * 4.0, z) for x, y, z in X3DISC)
|
|
||||||
self.custom_shape_select = self.new_custom_shape(type="TRIS", verts=_sel)
|
|
||||||
|
|
||||||
def draw(self, context):
|
def draw(self, context):
|
||||||
self.draw_custom_shape(self.custom_shape)
|
self.draw_custom_shape(self.custom_shape)
|
||||||
|
|
||||||
def draw_select(self, context, select_id):
|
|
||||||
self.draw_custom_shape(self.custom_shape_select, select_id=select_id)
|
|
||||||
|
|
||||||
def invoke(self, context, event):
|
|
||||||
return {"RUNNING_MODAL"}
|
|
||||||
|
|
||||||
def modal(self, context, event, tweak):
|
|
||||||
anchor_index = self.anchor_index
|
|
||||||
|
|
||||||
def _launch():
|
|
||||||
try:
|
|
||||||
bpy.ops.bim.set_dimension_anchor("INVOKE_DEFAULT", anchor_index=anchor_index)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[DimensionAnchorWidget] {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
bpy.app.timers.register(_launch, first_interval=0.0)
|
|
||||||
return {"FINISHED"}
|
|
||||||
|
|
||||||
|
|
||||||
class DimensionAnchorWidget(types.GizmoGroup):
|
class DimensionAnchorWidget(types.GizmoGroup):
|
||||||
@@ -2697,12 +2678,22 @@ class DimensionAnchorWidget(types.GizmoGroup):
|
|||||||
def poll(cls, context: bpy.types.Context) -> bool:
|
def poll(cls, context: bpy.types.Context) -> bool:
|
||||||
if not tool.Ifc.get():
|
if not tool.Ifc.get():
|
||||||
return False
|
return False
|
||||||
# Stay visible while SetDimensionAnchor is running (active obj may be a temp element).
|
# Stay visible while SetDimensionAnchor is running (active obj may temporarily
|
||||||
|
# be an IFC element in the face-picking phase rather than the annotation).
|
||||||
if _active_anchor_idx >= 0 and _editing_annotation_obj is not None:
|
if _active_anchor_idx >= 0 and _editing_annotation_obj is not None:
|
||||||
return True
|
active = context.active_object
|
||||||
|
if active is _editing_annotation_obj:
|
||||||
|
return True # annotation still active
|
||||||
|
if active is not None and tool.Ifc.get_entity(active) is not None:
|
||||||
|
return True # face-picking phase: active obj is a target element
|
||||||
|
# Active object is None or a non-IFC object — the modal ended without
|
||||||
|
# calling set_active_anchor(-1). Reset stale state and fall through.
|
||||||
|
set_active_anchor(-1)
|
||||||
obj = context.active_object
|
obj = context.active_object
|
||||||
if not obj or obj.type != "CURVE":
|
if not obj or obj.type != "CURVE":
|
||||||
return False
|
return False
|
||||||
|
if not obj.select_get():
|
||||||
|
return False
|
||||||
element = tool.Ifc.get_entity(obj)
|
element = tool.Ifc.get_entity(obj)
|
||||||
if not element or not element.is_a("IfcAnnotation"):
|
if not element or not element.is_a("IfcAnnotation"):
|
||||||
return False
|
return False
|
||||||
@@ -2716,8 +2707,7 @@ class DimensionAnchorWidget(types.GizmoGroup):
|
|||||||
self._handles: list = []
|
self._handles: list = []
|
||||||
for _ in range(self._MAX_ANCHORS):
|
for _ in range(self._MAX_ANCHORS):
|
||||||
gz = self.gizmos.new("BIM_GT_anchor_handle")
|
gz = self.gizmos.new("BIM_GT_anchor_handle")
|
||||||
gz.scale_basis = 0.18
|
gz.scale_basis = 0.2
|
||||||
gz.select_bias = -32.0
|
|
||||||
gz.use_draw_modal = True
|
gz.use_draw_modal = True
|
||||||
gz.hide = True
|
gz.hide = True
|
||||||
self._handles.append(gz)
|
self._handles.append(gz)
|
||||||
@@ -2756,11 +2746,11 @@ class DimensionAnchorWidget(types.GizmoGroup):
|
|||||||
|
|
||||||
for i in range(n):
|
for i in range(n):
|
||||||
gz = self._handles[i]
|
gz = self._handles[i]
|
||||||
world_co = obj.matrix_world @ spline.points[i].co.to_3d()
|
raw_co = spline.points[i].co
|
||||||
|
world_co = obj.matrix_world @ raw_co.to_3d()
|
||||||
gz.matrix_basis = Matrix.Translation(world_co)
|
gz.matrix_basis = Matrix.Translation(world_co)
|
||||||
gz.anchor_index = i
|
gz.anchor_index = i
|
||||||
if i == _active_anchor_idx and obj is _editing_annotation_obj:
|
if i == _active_anchor_idx and obj is _editing_annotation_obj:
|
||||||
print(f"[refresh] setting anchor[{i}] BLUE (obj={obj.name} editing={_editing_annotation_obj.name if _editing_annotation_obj else None})")
|
|
||||||
gz.color = (0.2, 0.7, 1.0)
|
gz.color = (0.2, 0.7, 1.0)
|
||||||
gz.color_highlight = (0.4, 0.85, 1.0)
|
gz.color_highlight = (0.4, 0.85, 1.0)
|
||||||
elif anchors[i].get("guid"):
|
elif anchors[i].get("guid"):
|
||||||
@@ -2777,7 +2767,6 @@ class DimensionAnchorWidget(types.GizmoGroup):
|
|||||||
self._handles[i].hide = True
|
self._handles[i].hide = True
|
||||||
|
|
||||||
def draw_prepare(self, context: bpy.types.Context) -> None:
|
def draw_prepare(self, context: bpy.types.Context) -> None:
|
||||||
print(f"[draw_prepare] DimensionAnchorWidget _active_anchor_idx={_active_anchor_idx}")
|
|
||||||
self.refresh(context)
|
self.refresh(context)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,84 @@ def _rebuild_dim_guid_index(file) -> None:
|
|||||||
_dim_index_dirty = False
|
_dim_index_dirty = False
|
||||||
|
|
||||||
|
|
||||||
|
def regenerate_dims_for_layer(file, layer) -> None:
|
||||||
|
"""Regenerate all parametric dimensions anchored to elements that use *layer*."""
|
||||||
|
global _dim_shape_cache, _dim_index_dirty, _dim_guid_index
|
||||||
|
|
||||||
|
if _dim_index_dirty:
|
||||||
|
_rebuild_dim_guid_index(file)
|
||||||
|
|
||||||
|
affected_guids: set = set()
|
||||||
|
for layer_set in file.get_inverse(layer):
|
||||||
|
if not layer_set.is_a("IfcMaterialLayerSet"):
|
||||||
|
continue
|
||||||
|
for inv in file.get_inverse(layer_set):
|
||||||
|
if inv.is_a("IfcRelAssociatesMaterial"):
|
||||||
|
rels = [inv]
|
||||||
|
elif inv.is_a("IfcMaterialLayerSetUsage"):
|
||||||
|
rels = [r for r in file.get_inverse(inv) if r.is_a("IfcRelAssociatesMaterial")]
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
for rel in rels:
|
||||||
|
for element in rel.RelatedObjects:
|
||||||
|
if hasattr(element, "GlobalId"):
|
||||||
|
affected_guids.add(element.GlobalId)
|
||||||
|
_dim_shape_cache.pop(element.id(), None)
|
||||||
|
|
||||||
|
if not affected_guids:
|
||||||
|
return
|
||||||
|
|
||||||
|
annotation_ids: set = set()
|
||||||
|
for guid in affected_guids:
|
||||||
|
for ann_id in _dim_guid_index.get(guid, []):
|
||||||
|
annotation_ids.add(ann_id)
|
||||||
|
|
||||||
|
if not annotation_ids:
|
||||||
|
return
|
||||||
|
|
||||||
|
import ifcopenshell.util.element
|
||||||
|
import ifcopenshell.api.drawing as drawing_api
|
||||||
|
import ifcopenshell.geom
|
||||||
|
from bonsai.bim.module.drawing.operator import _update_blender_curve
|
||||||
|
|
||||||
|
geom_settings = ifcopenshell.geom.settings()
|
||||||
|
geom_settings.set("APPLY_DEFAULT_MATERIALS", False)
|
||||||
|
|
||||||
|
for ann_id in annotation_ids:
|
||||||
|
try:
|
||||||
|
annotation = file.by_id(ann_id)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
pset = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
|
||||||
|
if not pset:
|
||||||
|
continue
|
||||||
|
placement_override: dict = {}
|
||||||
|
try:
|
||||||
|
anchors_raw = json.loads(pset.get("Anchors") or "[]")
|
||||||
|
for anchor in anchors_raw:
|
||||||
|
guid = anchor.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
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
resolved_pts = drawing_api.regenerate_dimension(
|
||||||
|
file,
|
||||||
|
annotation,
|
||||||
|
settings=geom_settings,
|
||||||
|
shape_cache=_dim_shape_cache,
|
||||||
|
placement_override=placement_override,
|
||||||
|
)
|
||||||
|
if resolved_pts:
|
||||||
|
_update_blender_curve(annotation, resolved_pts)
|
||||||
|
|
||||||
|
|
||||||
@persistent
|
@persistent
|
||||||
def load_post(*args):
|
def load_post(*args):
|
||||||
invalidate_dim_index()
|
invalidate_dim_index()
|
||||||
@@ -140,10 +218,7 @@ def _sync_dimension_anchors_to_curve(file, annotation, obj) -> bool:
|
|||||||
n_pts = len(spline_world)
|
n_pts = len(spline_world)
|
||||||
n_anchors = len(anchors)
|
n_anchors = len(anchors)
|
||||||
|
|
||||||
print(f"[sync_anchors] obj={obj.name} spline_pts={n_pts} anchors={n_anchors}")
|
|
||||||
|
|
||||||
if n_pts == n_anchors:
|
if n_pts == n_anchors:
|
||||||
print("[sync_anchors] counts match — no change needed")
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Match each spline point to the nearest unused anchor by proximity.
|
# Match each spline point to the nearest unused anchor by proximity.
|
||||||
@@ -177,7 +252,6 @@ def _sync_dimension_anchors_to_curve(file, annotation, obj) -> bool:
|
|||||||
"pt": [pt.x, pt.y, pt.z],
|
"pt": [pt.x, pt.y, pt.z],
|
||||||
})
|
})
|
||||||
|
|
||||||
print(f"[sync_anchors] rebuilt {len(new_anchors)} anchors (was {n_anchors})")
|
|
||||||
|
|
||||||
pset_entity = file.by_id(pset_data["id"])
|
pset_entity = file.by_id(pset_data["id"])
|
||||||
ifcopenshell.api.pset.edit_pset(file, pset=pset_entity, properties={"Anchors": json.dumps(new_anchors)})
|
ifcopenshell.api.pset.edit_pset(file, pset=pset_entity, properties={"Anchors": json.dumps(new_anchors)})
|
||||||
@@ -197,10 +271,11 @@ def depsgraph_update_post_handler(scene, depsgraph):
|
|||||||
if not file:
|
if not file:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Collect GUIDs of IFC objects whose transform or geometry changed.
|
if _dim_index_dirty:
|
||||||
# is_updated_geometry fires on Edit Mode exit after Bonsai has already
|
_rebuild_dim_guid_index(file)
|
||||||
# serialised the new mesh back to IFC via update_representation, so the
|
|
||||||
# tessellation will reflect the edited shape.
|
import ifcopenshell.util.element
|
||||||
|
|
||||||
moved_guids: set = set()
|
moved_guids: set = set()
|
||||||
edited_annotation_ids: set = set()
|
edited_annotation_ids: set = set()
|
||||||
|
|
||||||
@@ -214,26 +289,20 @@ def depsgraph_update_post_handler(scene, depsgraph):
|
|||||||
if element is None or not hasattr(element, "GlobalId"):
|
if element is None or not hasattr(element, "GlobalId"):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# If the dimension annotation curve itself was edited (vertex added/removed),
|
|
||||||
# sync the anchor list before regenerating.
|
|
||||||
if update.is_updated_geometry and obj.type == "CURVE" and element.is_a("IfcAnnotation"):
|
if update.is_updated_geometry and obj.type == "CURVE" and element.is_a("IfcAnnotation"):
|
||||||
import ifcopenshell.util.element as _ue
|
import ifcopenshell.util.element as _ue
|
||||||
ptype = _ue.get_predefined_type(element)
|
ptype = _ue.get_predefined_type(element)
|
||||||
print(f"[handler] dimension curve geometry updated: {obj.name} ptype={ptype} is_updated_geometry={update.is_updated_geometry}")
|
|
||||||
if ptype in ("DIMENSION", "RADIUS", "DIAMETER", "ANGLE", "PLAN_LEVEL", "SECTION_LEVEL"):
|
if ptype in ("DIMENSION", "RADIUS", "DIAMETER", "ANGLE", "PLAN_LEVEL", "SECTION_LEVEL"):
|
||||||
_sync_dimension_anchors_to_curve(file, element, obj)
|
changed = _sync_dimension_anchors_to_curve(file, element, obj)
|
||||||
edited_annotation_ids.add(element.id())
|
if changed:
|
||||||
|
edited_annotation_ids.add(element.id())
|
||||||
continue
|
continue
|
||||||
|
|
||||||
moved_guids.add(element.GlobalId)
|
moved_guids.add(element.GlobalId)
|
||||||
# Geometry edits invalidate the cached tessellation for this element.
|
|
||||||
if update.is_updated_geometry:
|
if update.is_updated_geometry:
|
||||||
_dim_shape_cache.pop(element.id(), None)
|
_dim_shape_cache.pop(element.id(), None)
|
||||||
|
|
||||||
if _dim_index_dirty:
|
|
||||||
_rebuild_dim_guid_index(file)
|
|
||||||
|
|
||||||
print(f"[handler] edited_annotation_ids={edited_annotation_ids} moved_guids={moved_guids}")
|
|
||||||
annotation_ids: set = set(edited_annotation_ids)
|
annotation_ids: set = set(edited_annotation_ids)
|
||||||
for guid in moved_guids:
|
for guid in moved_guids:
|
||||||
for ann_id in _dim_guid_index.get(guid, []):
|
for ann_id in _dim_guid_index.get(guid, []):
|
||||||
@@ -244,7 +313,6 @@ def depsgraph_update_post_handler(scene, depsgraph):
|
|||||||
|
|
||||||
import ifcopenshell.api.drawing as drawing_api
|
import ifcopenshell.api.drawing as drawing_api
|
||||||
import ifcopenshell.geom
|
import ifcopenshell.geom
|
||||||
import ifcopenshell.util.element
|
|
||||||
from bonsai.bim.module.drawing.operator import _update_blender_curve
|
from bonsai.bim.module.drawing.operator import _update_blender_curve
|
||||||
|
|
||||||
geom_settings = ifcopenshell.geom.settings()
|
geom_settings = ifcopenshell.geom.settings()
|
||||||
@@ -282,7 +350,6 @@ def depsgraph_update_post_handler(scene, depsgraph):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
print(f"[handler] regenerating ann_id={ann_id} anchors_in_pset={len(json.loads(pset.get('Anchors') or '[]'))}")
|
|
||||||
resolved_pts = drawing_api.regenerate_dimension(
|
resolved_pts = drawing_api.regenerate_dimension(
|
||||||
file,
|
file,
|
||||||
annotation,
|
annotation,
|
||||||
@@ -290,13 +357,7 @@ def depsgraph_update_post_handler(scene, depsgraph):
|
|||||||
shape_cache=_dim_shape_cache,
|
shape_cache=_dim_shape_cache,
|
||||||
placement_override=placement_override,
|
placement_override=placement_override,
|
||||||
)
|
)
|
||||||
print(f"[handler] resolved_pts count={len(resolved_pts)} pts={[(round(p[0],3),round(p[1],3),round(p[2],3)) for p in resolved_pts]}")
|
|
||||||
if resolved_pts:
|
if resolved_pts:
|
||||||
obj = tool.Ifc.get_object(annotation)
|
|
||||||
if obj:
|
|
||||||
print(f"[handler] curve spline pts before update={len(obj.data.splines[0].points) if obj.data.splines else 0}")
|
|
||||||
_update_blender_curve(annotation, resolved_pts)
|
_update_blender_curve(annotation, resolved_pts)
|
||||||
if obj:
|
|
||||||
print(f"[handler] curve spline pts after update={len(obj.data.splines[0].points) if obj.data.splines else 0}")
|
|
||||||
finally:
|
finally:
|
||||||
_dim_handler_running = False
|
_dim_handler_running = False
|
||||||
|
|||||||
@@ -5701,19 +5701,42 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope
|
|||||||
def _update_perp_constraint(self) -> None:
|
def _update_perp_constraint(self) -> None:
|
||||||
"""Extract the face normal from anchor[0] and store it as the constraint axis."""
|
"""Extract the face normal from anchor[0] and store it as the constraint axis."""
|
||||||
import math
|
import math
|
||||||
|
from mathutils import Vector
|
||||||
a = self._anchors[0] if self._anchors else None
|
a = self._anchors[0] if self._anchors else None
|
||||||
if not a or a.get("type") != "FACE":
|
if not a or a.get("type") != "FACE":
|
||||||
|
print(f"[perp] _update_perp_constraint: anchor type={a.get('type') if a else None} — need FACE, skipping")
|
||||||
return
|
return
|
||||||
fp = (a.get("addr") or {}).get("fingerprint") or {}
|
addr = a.get("addr") or {}
|
||||||
n = fp.get("normal") # world-space at build time
|
normal_local = addr.get("normal_local")
|
||||||
pt = a.get("pt")
|
pt = a.get("pt")
|
||||||
if not n or not pt:
|
if not normal_local or not pt:
|
||||||
|
print(f"[perp] _update_perp_constraint: missing normal_local={normal_local} or pt={pt}")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Rotate element-local normal to world space via the Blender object's matrix.
|
||||||
|
n = normal_local
|
||||||
|
guid = a.get("guid")
|
||||||
|
if guid:
|
||||||
|
try:
|
||||||
|
file = tool.Ifc.get()
|
||||||
|
element = file.by_guid(guid)
|
||||||
|
obj = tool.Ifc.get_object(element)
|
||||||
|
if obj:
|
||||||
|
nw = obj.matrix_world.to_3x3() @ Vector(normal_local)
|
||||||
|
nw.normalize()
|
||||||
|
n = (nw.x, nw.y, nw.z)
|
||||||
|
else:
|
||||||
|
print(f"[perp] _update_perp_constraint: no Blender obj for guid={guid}, using normal_local as-is")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[perp] _update_perp_constraint: exception rotating normal: {exc}")
|
||||||
|
|
||||||
mag = math.sqrt(n[0] ** 2 + n[1] ** 2 + n[2] ** 2)
|
mag = math.sqrt(n[0] ** 2 + n[1] ** 2 + n[2] ** 2)
|
||||||
if mag < 1e-12:
|
if mag < 1e-12:
|
||||||
|
print(f"[perp] _update_perp_constraint: zero-length normal after rotation")
|
||||||
return
|
return
|
||||||
self._anchor0_normal = (n[0] / mag, n[1] / mag, n[2] / mag)
|
self._anchor0_normal = (n[0] / mag, n[1] / mag, n[2] / mag)
|
||||||
self._anchor0_pt = tuple(pt)
|
self._anchor0_pt = tuple(pt)
|
||||||
|
print(f"[perp] _update_perp_constraint: OK normal={[round(v,3) for v in self._anchor0_normal]} pt={[round(v,3) for v in self._anchor0_pt]}")
|
||||||
|
|
||||||
def _apply_perp_constraint(self) -> None:
|
def _apply_perp_constraint(self) -> None:
|
||||||
"""Project the current snap point onto the constraint line when active."""
|
"""Project the current snap point onto the constraint line when active."""
|
||||||
@@ -5730,7 +5753,9 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope
|
|||||||
base = self._anchor0_pt
|
base = self._anchor0_pt
|
||||||
n = self._anchor0_normal
|
n = self._anchor0_normal
|
||||||
t = (p.x - base[0]) * n[0] + (p.y - base[1]) * n[1] + (p.z - base[2]) * n[2]
|
t = (p.x - base[0]) * n[0] + (p.y - base[1]) * n[1] + (p.z - base[2]) * n[2]
|
||||||
snap["point"] = 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]))
|
||||||
|
print(f"[perp] _apply_perp_constraint: raw=({p.x:.3f},{p.y:.3f},{p.z:.3f}) t={t:.4f} constrained=({constrained.x:.3f},{constrained.y:.3f},{constrained.z:.3f})")
|
||||||
|
snap["point"] = constrained
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Finalize: create IfcAnnotation + BBIM_Dimension pset
|
# Finalize: create IfcAnnotation + BBIM_Dimension pset
|
||||||
@@ -5917,7 +5942,85 @@ def _prefer_perp_face_index(
|
|||||||
return best_idx
|
return best_idx
|
||||||
|
|
||||||
|
|
||||||
class SetDimensionAnchor(bpy.types.Operator):
|
# Module-level draw data so the GPU callback never touches the operator RNA struct.
|
||||||
|
_snap_draw_data: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_snap_indicator_global():
|
||||||
|
"""GPU draw callback (POST_VIEW) — draws face outline, edge, or vertex dot."""
|
||||||
|
data = _snap_draw_data
|
||||||
|
if not data or not data.get("type"):
|
||||||
|
return
|
||||||
|
import gpu
|
||||||
|
from gpu_extras.batch import batch_for_shader
|
||||||
|
try:
|
||||||
|
shader = gpu.shader.from_builtin("UNIFORM_COLOR")
|
||||||
|
gpu.state.blend_set("ALPHA")
|
||||||
|
gpu.state.depth_test_set("ALWAYS")
|
||||||
|
snap_type = data["type"]
|
||||||
|
|
||||||
|
if snap_type == "FACE":
|
||||||
|
verts = data.get("face_verts", [])
|
||||||
|
if len(verts) >= 3:
|
||||||
|
lines = []
|
||||||
|
for i in range(len(verts)):
|
||||||
|
lines.append(verts[i])
|
||||||
|
lines.append(verts[(i + 1) % len(verts)])
|
||||||
|
shader.bind()
|
||||||
|
shader.uniform_float("color", (0.2, 0.55, 1.0, 0.9))
|
||||||
|
gpu.state.line_width_set(4.0)
|
||||||
|
batch_for_shader(shader, "LINES", {"pos": lines}).draw(shader)
|
||||||
|
|
||||||
|
elif snap_type == "EDGE":
|
||||||
|
v0, v1 = data.get("v0"), data.get("v1")
|
||||||
|
if v0 and v1:
|
||||||
|
shader.bind()
|
||||||
|
shader.uniform_float("color", (1.0, 0.65, 0.0, 1.0))
|
||||||
|
gpu.state.line_width_set(6.0)
|
||||||
|
batch_for_shader(shader, "LINES", {"pos": [v0, v1]}).draw(shader)
|
||||||
|
gpu.state.point_size_set(12.0)
|
||||||
|
batch_for_shader(shader, "POINTS", {"pos": [v0, v1]}).draw(shader)
|
||||||
|
|
||||||
|
elif snap_type == "VERTEX":
|
||||||
|
pt = data.get("snap_world")
|
||||||
|
if pt:
|
||||||
|
shader.bind()
|
||||||
|
shader.uniform_float("color", (1.0, 0.2, 0.4, 1.0))
|
||||||
|
gpu.state.point_size_set(20.0)
|
||||||
|
batch_for_shader(shader, "POINTS", {"pos": [pt]}).draw(shader)
|
||||||
|
|
||||||
|
elif snap_type == "LAYER":
|
||||||
|
corners = data.get("seam_corners", [])
|
||||||
|
pt = data.get("snap_world")
|
||||||
|
shader.bind()
|
||||||
|
shader.uniform_float("color", (0.2, 0.9, 0.5, 1.0))
|
||||||
|
n = len(corners)
|
||||||
|
if n >= 2:
|
||||||
|
lines = []
|
||||||
|
for i in range(n):
|
||||||
|
lines.append(corners[i])
|
||||||
|
lines.append(corners[(i + 1) % n])
|
||||||
|
gpu.state.line_width_set(5.0)
|
||||||
|
batch_for_shader(shader, "LINES", {"pos": lines}).draw(shader)
|
||||||
|
gpu.state.point_size_set(10.0)
|
||||||
|
batch_for_shader(shader, "POINTS", {"pos": corners}).draw(shader)
|
||||||
|
if pt:
|
||||||
|
gpu.state.point_size_set(20.0)
|
||||||
|
batch_for_shader(shader, "POINTS", {"pos": [pt]}).draw(shader)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
gpu.state.depth_test_set("LESS_EQUAL")
|
||||||
|
gpu.state.blend_set("NONE")
|
||||||
|
gpu.state.line_width_set(1.0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SetDimensionAnchor(bpy.types.Operator, tool.Ifc.Operator):
|
||||||
"""Interactively anchor dimension vertices to IFC element faces.
|
"""Interactively anchor dimension vertices to IFC element faces.
|
||||||
|
|
||||||
Two-phase modal workflow (all in Object Mode):
|
Two-phase modal workflow (all in Object Mode):
|
||||||
@@ -5950,12 +6053,17 @@ class SetDimensionAnchor(bpy.types.Operator):
|
|||||||
|
|
||||||
# Hover-cycle state (active during PICK_FACE phase)
|
# Hover-cycle state (active during PICK_FACE phase)
|
||||||
_hover_candidates: list # [(ifc_obj, hit_mesh, hit_mesh_mx, location, normal, face_index), ...]
|
_hover_candidates: list # [(ifc_obj, hit_mesh, hit_mesh_mx, location, normal, face_index), ...]
|
||||||
_hover_index: int # which candidate is currently highlighted
|
_hover_index: int # which element candidate is currently highlighted
|
||||||
_hover_last_px: tuple # last cursor pixel position where candidates were computed
|
_hover_last_px: tuple # last cursor pixel position where candidates were computed
|
||||||
_hover_highlighted_obj: Optional[bpy.types.Object] # object currently selected for highlight
|
_hover_highlighted_obj: Optional[bpy.types.Object] # object currently selected for highlight
|
||||||
|
|
||||||
_VERTEX_PICK_RADIUS_PX = 20 # pixels — how close the click must be to a vertex
|
# Snap-mode cycle state (FACE → EDGE → VERTEX, TAB)
|
||||||
_HOVER_THROTTLE_PX_SQ = 25 # only recompute candidates if cursor moves >5px
|
_snap_mode: str # "FACE" | "EDGE" | "VERTEX"
|
||||||
|
_draw_handler: object # SpaceView3D draw handler handle
|
||||||
|
|
||||||
|
_VERTEX_PICK_RADIUS_PX = 20
|
||||||
|
_HOVER_THROTTLE_PX_SQ = 25
|
||||||
|
_SNAP_MODES = ("FACE", "LAYER", "EDGE", "VERTEX")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def poll(cls, context):
|
def poll(cls, context):
|
||||||
@@ -5980,6 +6088,12 @@ class SetDimensionAnchor(bpy.types.Operator):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def invoke(self, context, event):
|
def invoke(self, context, event):
|
||||||
|
return IfcStore.execute_ifc_operator(self, context, event, method="INVOKE")
|
||||||
|
|
||||||
|
def modal(self, context, event):
|
||||||
|
return IfcStore.execute_ifc_operator(self, context, event, method="MODAL")
|
||||||
|
|
||||||
|
def _invoke(self, context, event):
|
||||||
obj = context.active_object
|
obj = context.active_object
|
||||||
self._annotation = tool.Ifc.get_entity(obj)
|
self._annotation = tool.Ifc.get_entity(obj)
|
||||||
self._annotation_obj = obj
|
self._annotation_obj = obj
|
||||||
@@ -5996,6 +6110,11 @@ class SetDimensionAnchor(bpy.types.Operator):
|
|||||||
self._hover_index = 0
|
self._hover_index = 0
|
||||||
self._hover_last_px = (-9999, -9999)
|
self._hover_last_px = (-9999, -9999)
|
||||||
self._hover_highlighted_obj = None
|
self._hover_highlighted_obj = None
|
||||||
|
self._snap_mode = "FACE"
|
||||||
|
_snap_draw_data.clear()
|
||||||
|
self._draw_handler = bpy.types.SpaceView3D.draw_handler_add(
|
||||||
|
_draw_snap_indicator_global, (), "WINDOW", "POST_VIEW"
|
||||||
|
)
|
||||||
|
|
||||||
# When invoked from a panel, context.region_data is None.
|
# When invoked from a panel, context.region_data is None.
|
||||||
# Walk the screen areas to find the actual 3D viewport region.
|
# Walk the screen areas to find the actual 3D viewport region.
|
||||||
@@ -6014,13 +6133,28 @@ class SetDimensionAnchor(bpy.types.Operator):
|
|||||||
context.window_manager.modal_handler_add(self)
|
context.window_manager.modal_handler_add(self)
|
||||||
return {"RUNNING_MODAL"}
|
return {"RUNNING_MODAL"}
|
||||||
|
|
||||||
def modal(self, context, event):
|
def _modal(self, context, event):
|
||||||
|
# Undo while the modal is running can free the annotation object.
|
||||||
|
try:
|
||||||
|
_ = self._annotation_obj.name
|
||||||
|
except ReferenceError:
|
||||||
|
self._cleanup(context)
|
||||||
|
return {"FINISHED"}
|
||||||
|
|
||||||
if event.type == "ESC" or (event.type == "RIGHTMOUSE" and event.value == "PRESS"):
|
if event.type == "ESC" or (event.type == "RIGHTMOUSE" and event.value == "PRESS"):
|
||||||
self._clear_hover_highlight(context)
|
self._clear_hover_highlight(context)
|
||||||
context.workspace.status_text_set(None)
|
context.workspace.status_text_set(None)
|
||||||
|
_snap_draw_data.clear()
|
||||||
|
if self._draw_handler:
|
||||||
|
bpy.types.SpaceView3D.draw_handler_remove(self._draw_handler, "WINDOW")
|
||||||
|
self._draw_handler = None
|
||||||
from bonsai.bim.module.drawing.gizmos import set_active_anchor
|
from bonsai.bim.module.drawing.gizmos import set_active_anchor
|
||||||
set_active_anchor(-1)
|
set_active_anchor(-1)
|
||||||
return {"FINISHED"} # keep any anchors already written
|
obj = context.active_object
|
||||||
|
if obj:
|
||||||
|
obj.select_set(False)
|
||||||
|
context.view_layer.objects.active = None
|
||||||
|
return {"FINISHED"}
|
||||||
|
|
||||||
# Hover — recompute candidates as cursor moves (PICK_FACE phase only)
|
# Hover — recompute candidates as cursor moves (PICK_FACE phase only)
|
||||||
if event.type == "MOUSEMOVE" and self._phase == "PICK_FACE":
|
if event.type == "MOUSEMOVE" and self._phase == "PICK_FACE":
|
||||||
@@ -6036,12 +6170,32 @@ class SetDimensionAnchor(bpy.types.Operator):
|
|||||||
if self._phase == "PICK_VERTEX":
|
if self._phase == "PICK_VERTEX":
|
||||||
self._handle_vertex_pick(context, event)
|
self._handle_vertex_pick(context, event)
|
||||||
else:
|
else:
|
||||||
self._handle_face_pick(context, event)
|
wrote = self._handle_face_pick(context, event)
|
||||||
|
if wrote:
|
||||||
|
# Finish here so this anchor write is its own undo step.
|
||||||
|
# The dimension stays selected so the user can click
|
||||||
|
# another dot immediately.
|
||||||
|
self._cleanup(context)
|
||||||
|
return {"FINISHED"}
|
||||||
self._set_status(context)
|
self._set_status(context)
|
||||||
return {"RUNNING_MODAL"}
|
return {"RUNNING_MODAL"}
|
||||||
|
|
||||||
return {"PASS_THROUGH"}
|
return {"PASS_THROUGH"}
|
||||||
|
|
||||||
|
def _cleanup(self, context):
|
||||||
|
self._clear_hover_highlight(context)
|
||||||
|
context.workspace.status_text_set(None)
|
||||||
|
_snap_draw_data.clear()
|
||||||
|
if self._draw_handler:
|
||||||
|
bpy.types.SpaceView3D.draw_handler_remove(self._draw_handler, "WINDOW")
|
||||||
|
self._draw_handler = None
|
||||||
|
from bonsai.bim.module.drawing.gizmos import set_active_anchor
|
||||||
|
set_active_anchor(-1)
|
||||||
|
for area in context.screen.areas:
|
||||||
|
if area.type == "VIEW_3D":
|
||||||
|
area.tag_redraw()
|
||||||
|
break
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Status bar
|
# Status bar
|
||||||
|
|
||||||
@@ -6136,12 +6290,9 @@ class SetDimensionAnchor(bpy.types.Operator):
|
|||||||
pt_m = list(alt_loc) if alt_loc else list(origin + direction * 5.0)
|
pt_m = list(alt_loc) if alt_loc else list(origin + direction * 5.0)
|
||||||
import ifcopenshell.api.drawing as drawing_api
|
import ifcopenshell.api.drawing as drawing_api
|
||||||
anchor = drawing_api.make_world_anchor(pt_m)
|
anchor = drawing_api.make_world_anchor(pt_m)
|
||||||
self._write_anchor(anchor, self._active_vertex_idx)
|
_do_write_anchor(self._annotation, self._annotation_obj, anchor, self._active_vertex_idx, self._shape_cache)
|
||||||
self.report({"INFO"}, f"Vertex {self._active_vertex_idx} → free world point")
|
self.report({"INFO"}, f"Vertex {self._active_vertex_idx} → free world point")
|
||||||
self._phase = "PICK_VERTEX"
|
return True
|
||||||
from bonsai.bim.module.drawing.gizmos import set_active_anchor
|
|
||||||
set_active_anchor(-1)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Normal click — use whichever candidate is currently highlighted.
|
# Normal click — use whichever candidate is currently highlighted.
|
||||||
self._clear_hover_highlight(context)
|
self._clear_hover_highlight(context)
|
||||||
@@ -6166,33 +6317,47 @@ class SetDimensionAnchor(bpy.types.Operator):
|
|||||||
return
|
return
|
||||||
|
|
||||||
file = tool.Ifc.get()
|
file = tool.Ifc.get()
|
||||||
hit_m = (float(location.x), float(location.y), float(location.z))
|
|
||||||
normal_m = (float(normal.x), float(normal.y), float(normal.z))
|
|
||||||
placement_override = {element.id(): np.array(hit_obj.matrix_world)}
|
placement_override = {element.id(): np.array(hit_obj.matrix_world)}
|
||||||
|
|
||||||
|
# Recompute snap geometry at the exact click position for accuracy.
|
||||||
|
snap = self._compute_snap_geom(hit_obj, face_index, coord)
|
||||||
|
snap_type = snap.get("type", "FACE")
|
||||||
|
|
||||||
import ifcopenshell.api.drawing as drawing_api
|
import ifcopenshell.api.drawing as drawing_api
|
||||||
try:
|
try:
|
||||||
anchor = drawing_api.build_anchor_from_hit(
|
if snap_type == "LAYER" and snap.get("method") == "LAYER_BOUNDARY":
|
||||||
file, element, hit_m, normal_m,
|
anchor = drawing_api.build_anchor_from_layer_boundary(file, element, snap)
|
||||||
shape_cache=self._shape_cache,
|
elif snap_type == "VERTEX" and snap.get("profile_x_m") is not None:
|
||||||
placement_override=placement_override,
|
anchor = drawing_api.build_anchor_from_profile_vert(file, element, snap)
|
||||||
)
|
elif snap_type == "EDGE" and snap.get("profile_x_m") is not None:
|
||||||
|
anchor = drawing_api.build_anchor_from_profile_edge(file, element, snap)
|
||||||
|
elif snap_type in ("VERTEX", "EDGE") and snap.get("snap_world") is not None:
|
||||||
|
# Tessellation fallback — no IFC profile data available.
|
||||||
|
# Use the snap position as a static WORLD anchor rather than a
|
||||||
|
# FACE fingerprint, so the endpoint stays at the correct vertex/
|
||||||
|
# edge position instead of drifting to the face centre.
|
||||||
|
sw = snap["snap_world"]
|
||||||
|
anchor = drawing_api.make_world_anchor([float(sw[0]), float(sw[1]), float(sw[2])])
|
||||||
|
else:
|
||||||
|
hit_m = (float(location.x), float(location.y), float(location.z))
|
||||||
|
normal_m = (float(normal.x), float(normal.y), float(normal.z))
|
||||||
|
anchor = drawing_api.build_anchor_from_hit(
|
||||||
|
file, element, hit_m, normal_m,
|
||||||
|
shape_cache=self._shape_cache,
|
||||||
|
placement_override=placement_override,
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
import traceback
|
import traceback
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
self.report({"ERROR"}, f"build_anchor_from_hit failed: {exc}")
|
self.report({"ERROR"}, f"build_anchor failed: {exc}")
|
||||||
return
|
return
|
||||||
|
|
||||||
self._write_anchor(anchor, self._active_vertex_idx)
|
_do_write_anchor(self._annotation, self._annotation_obj, anchor, self._active_vertex_idx, self._shape_cache)
|
||||||
self.report(
|
self.report(
|
||||||
{"INFO"},
|
{"INFO"},
|
||||||
f"Vertex {self._active_vertex_idx} → {element.is_a()}/{element.Name or element.GlobalId}",
|
f"Vertex {self._active_vertex_idx} → {element.is_a()}/{element.Name or element.GlobalId} [{anchor.get('type')}]",
|
||||||
)
|
)
|
||||||
self._phase = "PICK_VERTEX"
|
return True
|
||||||
self._hover_candidates = []
|
|
||||||
self._hover_index = 0
|
|
||||||
from bonsai.bim.module.drawing.gizmos import set_active_anchor
|
|
||||||
set_active_anchor(-1)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Hover / cycle helpers
|
# Hover / cycle helpers
|
||||||
@@ -6285,7 +6450,10 @@ class SetDimensionAnchor(bpy.types.Operator):
|
|||||||
bb_ctr = sum((v for v in bb_world), Vector()) / 8
|
bb_ctr = sum((v for v in bb_world), Vector()) / 8
|
||||||
t = (bb_ctr - origin).dot(direction)
|
t = (bb_ctr - origin).dot(direction)
|
||||||
query_w = origin + t * direction
|
query_w = origin + t * direction
|
||||||
found, loc_l, nrm_l, fi = ifc_obj.closest_point_on_mesh(mx_inv @ query_w, distance=100.0)
|
try:
|
||||||
|
found, loc_l, nrm_l, fi = ifc_obj.closest_point_on_mesh(mx_inv @ query_w, distance=100.0)
|
||||||
|
except RuntimeError:
|
||||||
|
continue
|
||||||
if not found:
|
if not found:
|
||||||
continue
|
continue
|
||||||
loc_w = mx @ loc_l
|
loc_w = mx @ loc_l
|
||||||
@@ -6311,19 +6479,25 @@ class SetDimensionAnchor(bpy.types.Operator):
|
|||||||
self._apply_hover_highlight(context)
|
self._apply_hover_highlight(context)
|
||||||
|
|
||||||
def _cycle_hover(self, context):
|
def _cycle_hover(self, context):
|
||||||
"""Advance to the next candidate and update the highlight."""
|
"""Cycle snap mode (FACE → EDGE → VERTEX); advance element on wrap-around."""
|
||||||
if not self._hover_candidates:
|
if not self._hover_candidates:
|
||||||
return
|
return
|
||||||
self._hover_index = (self._hover_index + 1) % len(self._hover_candidates)
|
modes = self._SNAP_MODES
|
||||||
|
cur = modes.index(self._snap_mode)
|
||||||
|
nxt = (cur + 1) % len(modes)
|
||||||
|
self._snap_mode = modes[nxt]
|
||||||
|
if nxt == 0 and len(self._hover_candidates) > 1:
|
||||||
|
self._hover_index = (self._hover_index + 1) % len(self._hover_candidates)
|
||||||
self._apply_hover_highlight(context)
|
self._apply_hover_highlight(context)
|
||||||
|
|
||||||
def _apply_hover_highlight(self, context):
|
def _apply_hover_highlight(self, context):
|
||||||
"""Select the current candidate object for visual feedback."""
|
"""Select the current candidate object; compute snap geometry; update status."""
|
||||||
if not self._hover_candidates:
|
if not self._hover_candidates:
|
||||||
self._clear_hover_highlight(context)
|
self._clear_hover_highlight(context)
|
||||||
|
_snap_draw_data.clear()
|
||||||
return
|
return
|
||||||
|
|
||||||
ifc_obj = self._hover_candidates[self._hover_index][0]
|
ifc_obj, _, _, _, _, face_index = self._hover_candidates[self._hover_index]
|
||||||
|
|
||||||
# Only update selection when the highlighted object changes.
|
# Only update selection when the highlighted object changes.
|
||||||
if ifc_obj != self._hover_highlighted_obj:
|
if ifc_obj != self._hover_highlighted_obj:
|
||||||
@@ -6339,14 +6513,22 @@ class SetDimensionAnchor(bpy.types.Operator):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
_snap_draw_data.clear()
|
||||||
|
_snap_draw_data.update(self._compute_snap_geom(ifc_obj, face_index, self._hover_last_px))
|
||||||
|
|
||||||
entity = tool.Ifc.get_entity(ifc_obj)
|
entity = tool.Ifc.get_entity(ifc_obj)
|
||||||
label = (entity.Name or entity.GlobalId) if entity else ifc_obj.name
|
label = (entity.Name or entity.GlobalId) if entity else ifc_obj.name
|
||||||
n = len(self._hover_candidates)
|
n = len(self._hover_candidates)
|
||||||
cycle_hint = f" | TAB: cycle ({self._hover_index + 1}/{n})" if n > 1 else ""
|
mode_label = self._snap_mode.capitalize()
|
||||||
|
elem_hint = f" ({self._hover_index + 1}/{n})" if n > 1 else ""
|
||||||
context.workspace.status_text_set(
|
context.workspace.status_text_set(
|
||||||
f"Vertex {self._active_vertex_idx} — {ifc_obj.name}{cycle_hint}"
|
f"Dim vertex {self._active_vertex_idx} — {label} [{mode_label}{elem_hint}]"
|
||||||
" | Click: anchor | ALT+Click: free point | RMB/ESC: Finish"
|
" | TAB: cycle snap | Click: anchor | ALT+Click: free | RMB/ESC: Finish"
|
||||||
)
|
)
|
||||||
|
for area in context.screen.areas:
|
||||||
|
if area.type == "VIEW_3D":
|
||||||
|
area.tag_redraw()
|
||||||
|
break
|
||||||
|
|
||||||
def _clear_hover_highlight(self, context):
|
def _clear_hover_highlight(self, context):
|
||||||
"""Deselect the highlighted object and restore the annotation as active."""
|
"""Deselect the highlighted object and restore the annotation as active."""
|
||||||
@@ -6362,77 +6544,199 @@ class SetDimensionAnchor(bpy.types.Operator):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def cancel(self, context):
|
||||||
|
"""Called when the operator is cancelled externally — clean up GPU handler."""
|
||||||
|
_snap_draw_data.clear()
|
||||||
|
if self._draw_handler:
|
||||||
|
bpy.types.SpaceView3D.draw_handler_remove(self._draw_handler, "WINDOW")
|
||||||
|
self._draw_handler = None
|
||||||
|
from bonsai.bim.module.drawing.gizmos import set_active_anchor
|
||||||
|
set_active_anchor(-1)
|
||||||
|
obj = context.active_object
|
||||||
|
if obj:
|
||||||
|
obj.select_set(False)
|
||||||
|
context.view_layer.objects.active = None
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Pset write (shared by both face and free-point paths)
|
# Snap geometry helpers
|
||||||
|
|
||||||
def _write_anchor(self, new_anchor: dict, vertex_index: int) -> None:
|
def _compute_snap_geom(self, hit_obj, face_index, coord) -> dict:
|
||||||
file = tool.Ifc.get()
|
"""Return snap draw-data dict for the current snap mode and hit face.
|
||||||
annotation = self._annotation
|
|
||||||
|
|
||||||
pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
|
When the hit element has an IfcExtrudedAreaSolid, VERTEX and EDGE snaps
|
||||||
|
are resolved from the IFC profile geometry (stable across mesh reloads)
|
||||||
|
rather than from Blender tessellation vertex indices.
|
||||||
|
|
||||||
if pset_data and pset_data.get("Anchors"):
|
``coord`` is a (x, y) tuple in region-local pixels. Returns an empty
|
||||||
try:
|
dict when the face index is invalid or the region is unavailable.
|
||||||
anchors: list = json.loads(pset_data["Anchors"])
|
"""
|
||||||
except Exception:
|
from bpy_extras.view3d_utils import location_3d_to_region_2d
|
||||||
anchors = []
|
|
||||||
else:
|
|
||||||
anchors = _anchors_from_spline(self._annotation_obj, file)
|
|
||||||
|
|
||||||
while len(anchors) <= vertex_index:
|
region = self._region
|
||||||
obj = self._annotation_obj
|
rv3d = self._rv3d
|
||||||
idx = len(anchors)
|
if not region or not rv3d or face_index is None:
|
||||||
if obj and obj.data and hasattr(obj.data, "splines") and obj.data.splines:
|
return {}
|
||||||
pts = obj.data.splines[0].points
|
try:
|
||||||
if idx < len(pts):
|
face = hit_obj.data.polygons[face_index]
|
||||||
co = obj.matrix_world @ pts[idx].co.xyz
|
except (IndexError, AttributeError):
|
||||||
import ifcopenshell.api.drawing as drawing_api
|
return {}
|
||||||
anchors.append(drawing_api.make_world_anchor([float(co.x), float(co.y), float(co.z)]))
|
|
||||||
continue
|
|
||||||
import ifcopenshell.api.drawing as drawing_api
|
|
||||||
anchors.append(drawing_api.make_world_anchor([0.0, 0.0, 0.0]))
|
|
||||||
|
|
||||||
anchors[vertex_index] = new_anchor
|
mx = hit_obj.matrix_world
|
||||||
anchors_json = json.dumps(anchors)
|
face_verts_world = [tuple(mx @ hit_obj.data.vertices[vi].co) for vi in face.vertices]
|
||||||
|
|
||||||
if pset_data:
|
if self._snap_mode == "FACE":
|
||||||
pset_entity = file.by_id(pset_data["id"])
|
return {"type": "FACE", "face_verts": face_verts_world}
|
||||||
ifcopenshell.api.run("pset.edit_pset", file, pset=pset_entity, properties={"Anchors": anchors_json})
|
|
||||||
else:
|
|
||||||
ifcopenshell.api.run("pset.add_pset", file, product=annotation, name="BBIM_Dimension")
|
|
||||||
pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
|
|
||||||
pset_entity = file.by_id(pset_data["id"])
|
|
||||||
ifcopenshell.api.run("pset.edit_pset", file, pset=pset_entity, properties={"Anchors": anchors_json})
|
|
||||||
|
|
||||||
from bonsai.bim.module.drawing import handler as _drawing_handler
|
# Try profile-based snap candidates first (IFC-native, index-stable).
|
||||||
_drawing_handler.invalidate_dim_index()
|
if self._snap_mode in ("VERTEX", "EDGE"):
|
||||||
|
element = tool.Ifc.get_entity(hit_obj)
|
||||||
|
if element:
|
||||||
|
import ifcopenshell.api.drawing as drawing_api
|
||||||
|
placement_override = {element.id(): np.array(mx)}
|
||||||
|
candidates = drawing_api.get_profile_snap_candidates(
|
||||||
|
tool.Ifc.get(), element, placement_override=placement_override
|
||||||
|
)
|
||||||
|
want_type = self._snap_mode
|
||||||
|
best_cand = None
|
||||||
|
best_d2 = float("inf")
|
||||||
|
for cand in candidates:
|
||||||
|
if cand["type"] != want_type:
|
||||||
|
continue
|
||||||
|
sp = location_3d_to_region_2d(region, rv3d, cand["snap_world"])
|
||||||
|
if sp is None:
|
||||||
|
continue
|
||||||
|
dx, dy = sp.x - coord[0], sp.y - coord[1]
|
||||||
|
d2 = dx * dx + dy * dy
|
||||||
|
if d2 < best_d2:
|
||||||
|
best_d2, best_cand = d2, cand
|
||||||
|
if best_cand is not None:
|
||||||
|
return best_cand
|
||||||
|
|
||||||
# Move the Blender curve vertex to the newly resolved anchor position.
|
# Fallback: Blender tessellation snap for elements without an
|
||||||
# Build placement_override from current Blender matrix_world so that
|
# IfcExtrudedAreaSolid profile. Returns snap_world for the visual
|
||||||
# elements whose IFC ObjectPlacement hasn't been synced yet resolve correctly.
|
# indicator; no pt_idx so the click handler creates a face anchor.
|
||||||
placement_override: dict = {}
|
screen_pts = [location_3d_to_region_2d(region, rv3d, wv) for wv in face_verts_world]
|
||||||
for a in anchors:
|
n = len(face_verts_world)
|
||||||
guid = a.get("guid")
|
|
||||||
if not guid:
|
if self._snap_mode == "VERTEX":
|
||||||
|
best_i, best_d2 = 0, float("inf")
|
||||||
|
for i, sp in enumerate(screen_pts):
|
||||||
|
if sp is not None:
|
||||||
|
dx, dy = sp.x - coord[0], sp.y - coord[1]
|
||||||
|
d2 = dx * dx + dy * dy
|
||||||
|
if d2 < best_d2:
|
||||||
|
best_d2, best_i = d2, i
|
||||||
|
return {"type": "VERTEX", "snap_world": face_verts_world[best_i]}
|
||||||
|
|
||||||
|
if self._snap_mode == "EDGE":
|
||||||
|
best_e, best_d2 = 0, float("inf")
|
||||||
|
for i in range(n):
|
||||||
|
j = (i + 1) % n
|
||||||
|
sp0, sp1 = screen_pts[i], screen_pts[j]
|
||||||
|
if sp0 is not None and sp1 is not None:
|
||||||
|
mid_x = (sp0.x + sp1.x) * 0.5
|
||||||
|
mid_y = (sp0.y + sp1.y) * 0.5
|
||||||
|
dx, dy = mid_x - coord[0], mid_y - coord[1]
|
||||||
|
d2 = dx * dx + dy * dy
|
||||||
|
if d2 < best_d2:
|
||||||
|
best_d2, best_e = d2, i
|
||||||
|
i0, i1 = best_e, (best_e + 1) % n
|
||||||
|
v0_w, v1_w = face_verts_world[i0], face_verts_world[i1]
|
||||||
|
mid_w = ((v0_w[0] + v1_w[0]) * 0.5, (v0_w[1] + v1_w[1]) * 0.5, (v0_w[2] + v1_w[2]) * 0.5)
|
||||||
|
return {"type": "EDGE", "v0": v0_w, "v1": v1_w, "snap_world": mid_w}
|
||||||
|
|
||||||
|
if self._snap_mode == "LAYER":
|
||||||
|
element = tool.Ifc.get_entity(hit_obj)
|
||||||
|
if element:
|
||||||
|
import ifcopenshell.api.drawing as drawing_api
|
||||||
|
placement_override = {element.id(): np.array(mx)}
|
||||||
|
candidates = drawing_api.get_layer_snap_candidates(
|
||||||
|
tool.Ifc.get(), element, placement_override=placement_override
|
||||||
|
)
|
||||||
|
best_cand = None
|
||||||
|
best_d2 = float("inf")
|
||||||
|
for cand in candidates:
|
||||||
|
sp = location_3d_to_region_2d(region, rv3d, cand["snap_world"])
|
||||||
|
if sp is None:
|
||||||
|
continue
|
||||||
|
dx, dy = sp.x - coord[0], sp.y - coord[1]
|
||||||
|
d2 = dx * dx + dy * dy
|
||||||
|
if d2 < best_d2:
|
||||||
|
best_d2, best_cand = d2, cand
|
||||||
|
if best_cand is not None:
|
||||||
|
result = dict(best_cand)
|
||||||
|
result["type"] = "LAYER"
|
||||||
|
result["method"] = "LAYER_BOUNDARY"
|
||||||
|
return result
|
||||||
|
return {"type": "FACE", "face_verts": face_verts_world}
|
||||||
|
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _do_write_anchor(annotation, annotation_obj, new_anchor: dict, vertex_index: int, shape_cache=None) -> None:
|
||||||
|
"""Write one anchor into the BBIM_Dimension pset and regenerate the curve."""
|
||||||
|
file = tool.Ifc.get()
|
||||||
|
|
||||||
|
pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
|
||||||
|
|
||||||
|
if pset_data and pset_data.get("Anchors"):
|
||||||
|
try:
|
||||||
|
anchors: list = json.loads(pset_data["Anchors"])
|
||||||
|
except Exception:
|
||||||
|
anchors = []
|
||||||
|
else:
|
||||||
|
anchors = _anchors_from_spline(annotation_obj, file)
|
||||||
|
|
||||||
|
while len(anchors) <= vertex_index:
|
||||||
|
idx = len(anchors)
|
||||||
|
if annotation_obj and annotation_obj.data and hasattr(annotation_obj.data, "splines") and annotation_obj.data.splines:
|
||||||
|
pts = annotation_obj.data.splines[0].points
|
||||||
|
if idx < len(pts):
|
||||||
|
co = annotation_obj.matrix_world @ pts[idx].co.xyz
|
||||||
|
import ifcopenshell.api.drawing as drawing_api
|
||||||
|
anchors.append(drawing_api.make_world_anchor([float(co.x), float(co.y), float(co.z)]))
|
||||||
continue
|
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
|
|
||||||
|
|
||||||
import ifcopenshell.api.drawing as drawing_api
|
import ifcopenshell.api.drawing as drawing_api
|
||||||
resolved_pts = drawing_api.regenerate_dimension(
|
anchors.append(drawing_api.make_world_anchor([0.0, 0.0, 0.0]))
|
||||||
file,
|
|
||||||
annotation,
|
anchors[vertex_index] = new_anchor
|
||||||
shape_cache=getattr(self, "_shape_cache", None),
|
anchors_json = json.dumps(anchors)
|
||||||
placement_override=placement_override,
|
|
||||||
)
|
if pset_data:
|
||||||
if resolved_pts:
|
pset_entity = file.by_id(pset_data["id"])
|
||||||
_update_blender_curve(annotation, resolved_pts)
|
ifcopenshell.api.run("pset.edit_pset", file, pset=pset_entity, properties={"Anchors": anchors_json})
|
||||||
print(f"[write_anchor] resolved_pts={[(round(p[0],4),round(p[1],4),round(p[2],4)) for p in resolved_pts]}")
|
else:
|
||||||
|
ifcopenshell.api.run("pset.add_pset", file, product=annotation, name="BBIM_Dimension")
|
||||||
|
pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
|
||||||
|
pset_entity = file.by_id(pset_data["id"])
|
||||||
|
ifcopenshell.api.run("pset.edit_pset", file, pset=pset_entity, properties={"Anchors": anchors_json})
|
||||||
|
|
||||||
|
from bonsai.bim.module.drawing import handler as _drawing_handler
|
||||||
|
_drawing_handler.invalidate_dim_index()
|
||||||
|
|
||||||
|
placement_override: dict = {}
|
||||||
|
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
|
||||||
|
|
||||||
|
import ifcopenshell.api.drawing as drawing_api
|
||||||
|
resolved_pts = drawing_api.regenerate_dimension(
|
||||||
|
file,
|
||||||
|
annotation,
|
||||||
|
shape_cache=shape_cache,
|
||||||
|
placement_override=placement_override,
|
||||||
|
)
|
||||||
|
if resolved_pts:
|
||||||
|
_update_blender_curve(annotation, resolved_pts)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class RegenerateDimensions(bpy.types.Operator, tool.Ifc.Operator):
|
class RegenerateDimensions(bpy.types.Operator, tool.Ifc.Operator):
|
||||||
@@ -6601,7 +6905,6 @@ def _update_blender_curve(
|
|||||||
spline.points.add(n - 1)
|
spline.points.add(n - 1)
|
||||||
|
|
||||||
is_2d = _annotation_is_2d(annotation)
|
is_2d = _annotation_is_2d(annotation)
|
||||||
|
|
||||||
for i, pt_m in enumerate(resolved_pts_m):
|
for i, pt_m in enumerate(resolved_pts_m):
|
||||||
blender_world = Vector((float(pt_m[0]), float(pt_m[1]), float(pt_m[2])))
|
blender_world = Vector((float(pt_m[0]), float(pt_m[1]), float(pt_m[2])))
|
||||||
local_pt = inv_world @ blender_world
|
local_pt = inv_world @ blender_world
|
||||||
@@ -6613,7 +6916,10 @@ def _update_blender_curve(
|
|||||||
spline.points[i].co = (*local_pt, 1.0)
|
spline.points[i].co = (*local_pt, 1.0)
|
||||||
|
|
||||||
# Also update the IFC IfcPolyline so Edit Mode reloads reflect the new positions.
|
# Also update the IFC IfcPolyline so Edit Mode reloads reflect the new positions.
|
||||||
_update_ifc_polyline(tool.Ifc.get(), annotation, obj, resolved_pts_m)
|
try:
|
||||||
|
_update_ifc_polyline(tool.Ifc.get(), annotation, obj, resolved_pts_m)
|
||||||
|
except Exception as e:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _annotation_is_2d(annotation: ifcopenshell.entity_instance) -> bool:
|
def _annotation_is_2d(annotation: ifcopenshell.entity_instance) -> bool:
|
||||||
@@ -6716,46 +7022,24 @@ def _find_curve_in_item(item: ifcopenshell.entity_instance) -> Optional[ifcopens
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
class ClickNearestDimensionAnchor(bpy.types.Operator):
|
|
||||||
"""LMB handler: fire SetDimensionAnchor when cursor is within RADIUS pixels of an anchor dot.
|
|
||||||
|
|
||||||
Registered as a keymap item so it runs before Blender's object-selection handler.
|
|
||||||
Returns PASS_THROUGH when the cursor is not near any anchor, so normal viewport
|
class ClickNearestDimensionAnchor(bpy.types.Operator):
|
||||||
clicks are unaffected.
|
"""LMB fallback: fire SetDimensionAnchor when cursor is within RADIUS pixels of an anchor dot.
|
||||||
|
|
||||||
|
The gizmo handles exact hits; this catches near-misses where the cursor
|
||||||
|
is close to a dot but didn't land inside the gizmo hit shape.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
bl_idname = "bim.click_nearest_dimension_anchor"
|
bl_idname = "bim.click_nearest_dimension_anchor"
|
||||||
bl_label = "Click Nearest Dimension Anchor"
|
bl_label = "Click Nearest Dimension Anchor"
|
||||||
|
|
||||||
RADIUS_PX = 120
|
RADIUS_PX = 60
|
||||||
|
|
||||||
def invoke(self, context, event):
|
def invoke(self, context, event):
|
||||||
from bpy_extras.view3d_utils import location_3d_to_region_2d
|
from bpy_extras.view3d_utils import location_3d_to_region_2d
|
||||||
|
|
||||||
print(f"[AnchorClick] invoke called")
|
|
||||||
|
|
||||||
if not tool.Ifc.get():
|
if not tool.Ifc.get():
|
||||||
print(f"[AnchorClick] PASS_THROUGH — no IFC file")
|
|
||||||
return {"PASS_THROUGH"}
|
|
||||||
|
|
||||||
obj = context.active_object
|
|
||||||
if not obj or obj.type != "CURVE":
|
|
||||||
print(f"[AnchorClick] PASS_THROUGH — active obj is {obj} type={getattr(obj,'type',None)}")
|
|
||||||
return {"PASS_THROUGH"}
|
|
||||||
|
|
||||||
element = tool.Ifc.get_entity(obj)
|
|
||||||
if not element or not element.is_a("IfcAnnotation"):
|
|
||||||
print(f"[AnchorClick] PASS_THROUGH — not an IfcAnnotation: {element}")
|
|
||||||
return {"PASS_THROUGH"}
|
|
||||||
|
|
||||||
import ifcopenshell.util.element as _ue
|
|
||||||
pset = _ue.get_pset(element, "BBIM_Dimension")
|
|
||||||
if not pset or not pset.get("Anchors"):
|
|
||||||
print(f"[AnchorClick] PASS_THROUGH — no BBIM_Dimension pset or Anchors")
|
|
||||||
return {"PASS_THROUGH"}
|
|
||||||
|
|
||||||
if not obj.data.splines:
|
|
||||||
print(f"[AnchorClick] PASS_THROUGH — no splines")
|
|
||||||
return {"PASS_THROUGH"}
|
return {"PASS_THROUGH"}
|
||||||
|
|
||||||
# Always use the 3D viewport WINDOW region — context.region may be a header,
|
# Always use the 3D viewport WINDOW region — context.region may be a header,
|
||||||
@@ -6777,35 +7061,54 @@ class ClickNearestDimensionAnchor(bpy.types.Operator):
|
|||||||
break
|
break
|
||||||
|
|
||||||
if not region or not rv3d:
|
if not region or not rv3d:
|
||||||
print(f"[AnchorClick] PASS_THROUGH — no 3D region")
|
|
||||||
return {"PASS_THROUGH"}
|
return {"PASS_THROUGH"}
|
||||||
|
|
||||||
# Convert absolute mouse position to WINDOW region-local coordinates.
|
# Convert absolute mouse position to WINDOW region-local coordinates.
|
||||||
cx = event.mouse_x - region.x
|
cx = event.mouse_x - region.x
|
||||||
cy = event.mouse_y - region.y
|
cy = event.mouse_y - region.y
|
||||||
|
|
||||||
|
import ifcopenshell.util.element as _ue
|
||||||
|
|
||||||
r2 = self.RADIUS_PX ** 2
|
r2 = self.RADIUS_PX ** 2
|
||||||
|
best_obj = None
|
||||||
best_idx = -1
|
best_idx = -1
|
||||||
best_dist_sq = float("inf")
|
best_dist_sq = float("inf")
|
||||||
for i, pt in enumerate(obj.data.splines[0].points):
|
|
||||||
world_pos = obj.matrix_world @ pt.co.to_3d()
|
|
||||||
sp = location_3d_to_region_2d(region, rv3d, world_pos)
|
|
||||||
if not sp:
|
|
||||||
continue
|
|
||||||
dx, dy = cx - sp.x, cy - sp.y
|
|
||||||
d2 = dx * dx + dy * dy
|
|
||||||
print(f"[AnchorClick] click=({cx},{cy}) anchor[{i}]=({sp.x:.0f},{sp.y:.0f}) dist={d2**0.5:.1f}px radius={self.RADIUS_PX}px")
|
|
||||||
if d2 < r2 and d2 < best_dist_sq:
|
|
||||||
best_dist_sq = d2
|
|
||||||
best_idx = i
|
|
||||||
|
|
||||||
if best_idx < 0:
|
# Scan ALL selected objects — context.active_object may have changed to an
|
||||||
print(f"[AnchorClick] MISS — no anchor within {self.RADIUS_PX}px")
|
# underlying IFC element due to Blender's hover pre-selection, so we can't
|
||||||
|
# rely on it being the dimension we want to click.
|
||||||
|
for obj in context.scene.objects:
|
||||||
|
if not obj.select_get():
|
||||||
|
continue
|
||||||
|
if obj.type != "CURVE":
|
||||||
|
continue
|
||||||
|
element = tool.Ifc.get_entity(obj)
|
||||||
|
if not element or not element.is_a("IfcAnnotation"):
|
||||||
|
continue
|
||||||
|
pset = _ue.get_pset(element, "BBIM_Dimension")
|
||||||
|
if not pset or not pset.get("Anchors"):
|
||||||
|
continue
|
||||||
|
if not obj.data.splines:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for i, pt in enumerate(obj.data.splines[0].points):
|
||||||
|
world_pos = obj.matrix_world @ pt.co.to_3d()
|
||||||
|
sp = location_3d_to_region_2d(region, rv3d, world_pos)
|
||||||
|
if not sp:
|
||||||
|
continue
|
||||||
|
dx, dy = cx - sp.x, cy - sp.y
|
||||||
|
d2 = dx * dx + dy * dy
|
||||||
|
if d2 < r2 and d2 < best_dist_sq:
|
||||||
|
best_dist_sq = d2
|
||||||
|
best_idx = i
|
||||||
|
best_obj = obj
|
||||||
|
|
||||||
|
if best_obj is None:
|
||||||
return {"PASS_THROUGH"}
|
return {"PASS_THROUGH"}
|
||||||
|
|
||||||
print(f"[AnchorClick] HIT anchor[{best_idx}] dist={best_dist_sq**0.5:.1f}px")
|
context.view_layer.objects.active = best_obj
|
||||||
from bonsai.bim.module.drawing.gizmos import set_active_anchor
|
from bonsai.bim.module.drawing.gizmos import set_active_anchor
|
||||||
set_active_anchor(best_idx, obj)
|
set_active_anchor(best_idx, best_obj)
|
||||||
# Force viewport redraw so gizmo colors update before the modal starts.
|
# Force viewport redraw so gizmo colors update before the modal starts.
|
||||||
for area in context.screen.areas:
|
for area in context.screen.areas:
|
||||||
if area.type == "VIEW_3D":
|
if area.type == "VIEW_3D":
|
||||||
|
|||||||
@@ -834,6 +834,8 @@ class EditMaterialSetItem(bpy.types.Operator, tool.Ifc.Operator):
|
|||||||
)
|
)
|
||||||
slab.DumbSlabPlaner().regenerate_from_layer(layer)
|
slab.DumbSlabPlaner().regenerate_from_layer(layer)
|
||||||
wall.DumbWallPlaner().regenerate_from_layer(layer)
|
wall.DumbWallPlaner().regenerate_from_layer(layer)
|
||||||
|
from bonsai.bim.module.drawing.handler import regenerate_dims_for_layer
|
||||||
|
regenerate_dims_for_layer(self.file, layer)
|
||||||
elif material.is_a("IfcMaterialProfileSet"):
|
elif material.is_a("IfcMaterialProfileSet"):
|
||||||
profile_def = None
|
profile_def = None
|
||||||
if mprops.profiles:
|
if mprops.profiles:
|
||||||
|
|||||||
@@ -90,7 +90,6 @@ class DisablePsetEditing(bpy.types.Operator, tool.Ifc.Operator):
|
|||||||
|
|
||||||
def _regenerate_parametric_dimension(file, annotation):
|
def _regenerate_parametric_dimension(file, annotation):
|
||||||
"""Regenerate a single parametric dimension annotation after a pset edit."""
|
"""Regenerate a single parametric dimension annotation after a pset edit."""
|
||||||
print(f"[regen_dim] called for annotation={annotation.id()} {annotation.is_a()}")
|
|
||||||
try:
|
try:
|
||||||
import json
|
import json
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -100,13 +99,10 @@ def _regenerate_parametric_dimension(file, annotation):
|
|||||||
from bonsai.bim.module.drawing.operator import _update_blender_curve
|
from bonsai.bim.module.drawing.operator import _update_blender_curve
|
||||||
|
|
||||||
pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
|
pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
|
||||||
print(f"[regen_dim] pset_data keys={list(pset_data.keys()) if pset_data else None}")
|
|
||||||
if not pset_data or not pset_data.get("Anchors"):
|
if not pset_data or not pset_data.get("Anchors"):
|
||||||
print("[regen_dim] no Anchors — skipping")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
anchors = json.loads(pset_data["Anchors"])
|
anchors = json.loads(pset_data["Anchors"])
|
||||||
print(f"[regen_dim] {len(anchors)} anchors")
|
|
||||||
placement_override = {}
|
placement_override = {}
|
||||||
for a in anchors:
|
for a in anchors:
|
||||||
guid = a.get("guid")
|
guid = a.get("guid")
|
||||||
@@ -117,17 +113,14 @@ def _regenerate_parametric_dimension(file, annotation):
|
|||||||
elem_obj = _tool.Ifc.get_object(elem)
|
elem_obj = _tool.Ifc.get_object(elem)
|
||||||
if elem_obj:
|
if elem_obj:
|
||||||
placement_override[elem.id()] = np.array(elem_obj.matrix_world)
|
placement_override[elem.id()] = np.array(elem_obj.matrix_world)
|
||||||
print(f"[regen_dim] placement_override added for {elem.is_a()} id={elem.id()}")
|
except Exception:
|
||||||
except Exception as e:
|
pass
|
||||||
print(f"[regen_dim] placement_override error: {e}")
|
|
||||||
|
|
||||||
resolved_pts = drawing_api.regenerate_dimension(
|
resolved_pts = drawing_api.regenerate_dimension(
|
||||||
file, annotation, placement_override=placement_override
|
file, annotation, placement_override=placement_override
|
||||||
)
|
)
|
||||||
print(f"[regen_dim] resolved_pts={resolved_pts}")
|
|
||||||
if resolved_pts:
|
if resolved_pts:
|
||||||
_update_blender_curve(annotation, resolved_pts)
|
_update_blender_curve(annotation, resolved_pts)
|
||||||
print("[regen_dim] _update_blender_curve done")
|
|
||||||
except Exception:
|
except Exception:
|
||||||
import traceback
|
import traceback
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
@@ -197,12 +190,9 @@ class EditPset(bpy.types.Operator, tool.Ifc.Operator):
|
|||||||
)
|
)
|
||||||
if tool.Cost.has_schedules():
|
if tool.Cost.has_schedules():
|
||||||
tool.Cost.update_cost_items(pset=pset)
|
tool.Cost.update_cost_items(pset=pset)
|
||||||
print(f"[edit_pset] pset_name='{props.active_pset_name}' element={element.is_a()} before disable_pset_editing")
|
|
||||||
is_bbim_dimension = props.active_pset_name == "BBIM_Dimension" and element.is_a("IfcAnnotation")
|
is_bbim_dimension = props.active_pset_name == "BBIM_Dimension" and element.is_a("IfcAnnotation")
|
||||||
|
|
||||||
bpy.ops.bim.disable_pset_editing(obj=self.obj, obj_type=self.obj_type)
|
bpy.ops.bim.disable_pset_editing(obj=self.obj, obj_type=self.obj_type)
|
||||||
|
|
||||||
print(f"[edit_pset] pset_name after disable='{props.active_pset_name}' is_bbim_dimension={is_bbim_dimension}")
|
|
||||||
if is_bbim_dimension:
|
if is_bbim_dimension:
|
||||||
_regenerate_parametric_dimension(self.file, element)
|
_regenerate_parametric_dimension(self.file, element)
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ from .. import wrap_usecases
|
|||||||
from .assign_product import assign_product
|
from .assign_product import assign_product
|
||||||
from .edit_text_literal import edit_text_literal
|
from .edit_text_literal import edit_text_literal
|
||||||
from .regenerate_dimension import regenerate_dimension, get_dimension_segment_lengths
|
from .regenerate_dimension import regenerate_dimension, get_dimension_segment_lengths
|
||||||
from .resolve_anchor import build_anchor_from_hit, make_world_anchor, resolve_anchor
|
from .resolve_anchor import build_anchor_from_hit, build_anchor_from_layer_boundary, build_anchor_from_profile_vert, build_anchor_from_profile_edge, get_layer_snap_candidates, get_profile_snap_candidates, make_world_anchor, resolve_anchor
|
||||||
from .unassign_product import unassign_product
|
from .unassign_product import unassign_product
|
||||||
|
|
||||||
wrap_usecases(__path__, __name__)
|
wrap_usecases(__path__, __name__)
|
||||||
@@ -34,8 +34,13 @@ wrap_usecases(__path__, __name__)
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"assign_product",
|
"assign_product",
|
||||||
"build_anchor_from_hit",
|
"build_anchor_from_hit",
|
||||||
|
"build_anchor_from_layer_boundary",
|
||||||
|
"build_anchor_from_profile_edge",
|
||||||
|
"build_anchor_from_profile_vert",
|
||||||
"edit_text_literal",
|
"edit_text_literal",
|
||||||
"get_dimension_segment_lengths",
|
"get_dimension_segment_lengths",
|
||||||
|
"get_layer_snap_candidates",
|
||||||
|
"get_profile_snap_candidates",
|
||||||
"make_world_anchor",
|
"make_world_anchor",
|
||||||
"regenerate_dimension",
|
"regenerate_dimension",
|
||||||
"resolve_anchor",
|
"resolve_anchor",
|
||||||
|
|||||||
@@ -296,33 +296,44 @@ def _get_anchor_face_normal_world(
|
|||||||
) -> Optional[tuple[float, float, float]]:
|
) -> Optional[tuple[float, float, float]]:
|
||||||
"""Return the world-space unit face normal stored in a FACE anchor, or None.
|
"""Return the world-space unit face normal stored in a FACE anchor, or None.
|
||||||
|
|
||||||
Prefers ``normal_local`` (element-local, rotation-invariant) transformed by
|
Reads ``normal_local`` (element-local, rotation-invariant) from the anchor
|
||||||
the current element placement. Falls back to the stored world-space normal.
|
addr and rotates it to world space via the current element placement.
|
||||||
|
Also accepts the legacy ``addr.fingerprint.normal_local`` format.
|
||||||
"""
|
"""
|
||||||
if anchor.get("type") != "FACE":
|
if anchor.get("type") != "FACE":
|
||||||
return None
|
return None
|
||||||
guid = anchor.get("guid")
|
guid = anchor.get("guid")
|
||||||
if not guid:
|
if not guid:
|
||||||
return None
|
return None
|
||||||
fp = (anchor.get("addr") or {}).get("fingerprint") or {}
|
try:
|
||||||
|
element = file.by_guid(guid)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
normal_local = fp.get("normal_local")
|
addr = anchor.get("addr") or {}
|
||||||
if normal_local:
|
from .resolve_anchor import _rotate_local_to_world
|
||||||
try:
|
|
||||||
element = file.by_guid(guid)
|
if addr.get("method") == "LAYER_BOUNDARY":
|
||||||
except Exception:
|
import ifcopenshell.util.element as _ifc_elem
|
||||||
|
usage = _ifc_elem.get_material(element, should_inherit=True)
|
||||||
|
if not usage or not usage.is_a("IfcMaterialLayerSetUsage"):
|
||||||
|
return None
|
||||||
|
axis = (getattr(usage, "LayerSetDirection", None) or "AXIS2")
|
||||||
|
if axis == "AXIS1":
|
||||||
|
normal_local: tuple = (1.0, 0.0, 0.0)
|
||||||
|
elif axis == "AXIS3":
|
||||||
|
normal_local = (0.0, 0.0, 1.0)
|
||||||
|
else:
|
||||||
|
normal_local = (0.0, 1.0, 0.0)
|
||||||
|
else:
|
||||||
|
# FACE_NORMAL: normal_local stored in addr (new) or addr.fingerprint (legacy).
|
||||||
|
normal_local = addr.get("normal_local") or (addr.get("fingerprint") or {}).get("normal_local")
|
||||||
|
if not normal_local:
|
||||||
return None
|
return None
|
||||||
from .resolve_anchor import _rotate_local_to_world
|
|
||||||
n = _rotate_local_to_world(element, normal_local, placement_override)
|
|
||||||
mag = math.sqrt(n[0] ** 2 + n[1] ** 2 + n[2] ** 2)
|
|
||||||
return (n[0] / mag, n[1] / mag, n[2] / mag) if mag > 1e-12 else None
|
|
||||||
|
|
||||||
normal_world = fp.get("normal")
|
n = _rotate_local_to_world(element, normal_local, placement_override)
|
||||||
if normal_world:
|
mag = math.sqrt(n[0] ** 2 + n[1] ** 2 + n[2] ** 2)
|
||||||
mag = math.sqrt(sum(x * x for x in normal_world))
|
return (n[0] / mag, n[1] / mag, n[2] / mag) if mag > 1e-12 else None
|
||||||
return tuple(x / mag for x in normal_world) if mag > 1e-12 else None # type: ignore[return-value]
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _get_line_offset_direction(
|
def _get_line_offset_direction(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user