Add interactive PLAN_LEVEL / SECTION_LEVEL elevation annotation placement

- AddElevationAnnotation: new modal operator (inherits SetDimensionAnchor) that
  creates the annotation only after the user clicks a face/layer/edge/vertex,
  placing it at the picked world position with elevation tracked via BBIM_Dimension
- hotkey_S_A routes PLAN_LEVEL/SECTION_LEVEL to AddElevationAnnotation instead of
  the static AddAnnotation
- _annotation_is_2d: check predefined type first so PLAN_LEVEL/SECTION_LEVEL always
  use object-placement Z (not spline-point Z)
- _update_elevation_marker_z: fixed early-exit guard that skipped 2D placement update
  when splines were absent; now moves the object via geometry.edit_object_placement
- SetDimensionAnchor._handle_face_pick: for elevation types, relocate the whole
  annotation (XY and Z) to the new face hit, not just Z
- _zero_elevation_annotation_spline_z: flatten local spline-point Z to 0 so the
  visible line and the gizmo sit at the same world elevation
- DimensionAnchorWidget: position gizmo at object origin for elevation annotations
  (anchor reference point) instead of spline.points[0]
- ClickNearestDimensionAnchor: hit-test at object origin for elevation annotations
  so clicking the dot correctly turns it blue
- SECTION_LEVEL default curve is now horizontal (camera X) instead of vertical
- Remove 'Edit Elevation Anchor' button; replaced by viewport green-dot workflow
- Add 'Bake to Static' and 'Make Parametric' buttons for elevation annotation types
- MakeDimensionParametric: extended to handle elevation types (single world-point
  anchor at object origin rather than one anchor per spline vertex)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ryan Schultz
2026-06-27 11:47:10 -05:00
parent 5d5be1ea65
commit 3faa192db8
7 changed files with 533 additions and 85 deletions
@@ -32,6 +32,7 @@ classes = (
operator.ActivateModel,
operator.AddAnnotation,
operator.AddAnnotationType,
operator.AddElevationAnnotation,
operator.AddDrawing,
operator.AddDrawingStyle,
operator.AddDrawingToSheet,
+12 -3
View File
@@ -2744,10 +2744,19 @@ class DimensionAnchorWidget(types.GizmoGroup):
spline = obj.data.splines[0]
n = min(len(spline.points), len(anchors), self._MAX_ANCHORS)
import ifcopenshell.util.element as _ue_gz
_ptype_gz = _ue_gz.get_predefined_type(element)
_is_elevation_gz = _ptype_gz in ("SECTION_LEVEL", "PLAN_LEVEL")
for i in range(n):
gz = self._handles[i]
raw_co = spline.points[i].co
world_co = obj.matrix_world @ raw_co.to_3d()
if _is_elevation_gz:
# The object origin IS the anchor reference point (placed at face hit).
# Spline vertices are offset from the origin and should not be used.
world_co = obj.matrix_world.translation.copy()
else:
raw_co = spline.points[i].co
world_co = obj.matrix_world @ raw_co.to_3d()
gz.matrix_basis = Matrix.Translation(world_co)
gz.anchor_index = i
if i == _active_anchor_idx and obj is _editing_annotation_obj:
@@ -2787,7 +2796,7 @@ class DimensionLinePositionWidget(types.GizmoGroup):
bl_region_type = "WINDOW"
bl_options = {"3D", "PERSISTENT", "SHOW_MODAL_ALL"}
_DIM_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE", "PLAN_LEVEL", "SECTION_LEVEL"))
_DIM_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE"))
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
+20 -11
View File
@@ -293,7 +293,7 @@ def depsgraph_update_post_handler(scene, depsgraph):
if update.is_updated_geometry and obj.type == "CURVE" and element.is_a("IfcAnnotation"):
import ifcopenshell.util.element as _ue
ptype = _ue.get_predefined_type(element)
if ptype in ("DIMENSION", "RADIUS", "DIAMETER", "ANGLE", "PLAN_LEVEL", "SECTION_LEVEL"):
if ptype in ("DIMENSION", "RADIUS", "DIAMETER", "ANGLE"):
changed = _sync_dimension_anchors_to_curve(file, element, obj)
if changed:
edited_annotation_ids.add(element.id())
@@ -313,7 +313,7 @@ def depsgraph_update_post_handler(scene, depsgraph):
import ifcopenshell.api.drawing as drawing_api
import ifcopenshell.geom
from bonsai.bim.module.drawing.operator import _update_blender_curve
from bonsai.bim.module.drawing.operator import _update_blender_curve, _update_elevation_marker_z
geom_settings = ifcopenshell.geom.settings()
geom_settings.set("APPLY_DEFAULT_MATERIALS", False)
@@ -350,14 +350,23 @@ def depsgraph_update_post_handler(scene, depsgraph):
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)
ptype = ifcopenshell.util.element.get_predefined_type(annotation)
if ptype in ("SECTION_LEVEL", "PLAN_LEVEL"):
_update_elevation_marker_z(
file, annotation,
settings=geom_settings,
shape_cache=_dim_shape_cache,
placement_override=placement_override,
)
else:
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)
finally:
_dim_handler_running = False
+467 -66
View File
@@ -1904,6 +1904,10 @@ class AddAnnotation(bpy.types.Operator, tool.Ifc.Operator):
bpy.ops.bim.add_reference_image("INVOKE_DEFAULT", existing_object_by_name=obj.name)
# AddElevationAnnotation is defined after SetDimensionAnchor (below) because it
# inherits from it. This placeholder keeps the module namespace tidy.
class AddSheet(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.add_sheet"
bl_label = "Add Sheet"
@@ -6628,7 +6632,7 @@ class SetDimensionAnchor(bpy.types.Operator, tool.Ifc.Operator):
return False
ptype = ifcopenshell.util.element.get_predefined_type(element)
if ptype not in ("DIMENSION", "RADIUS", "DIAMETER", "ANGLE", "PLAN_LEVEL", "SECTION_LEVEL"):
cls.poll_message_set("Annotation must be a dimension type.")
cls.poll_message_set("Annotation must be a dimension or elevation type.")
return False
return True
@@ -6908,6 +6912,25 @@ class SetDimensionAnchor(bpy.types.Operator, tool.Ifc.Operator):
return
_do_write_anchor(self._annotation, self._annotation_obj, anchor, self._active_vertex_idx, self._shape_cache)
# For elevation annotations the whole object should jump to the face hit
# (XY and Z), not just update Z via the object-placement path in
# _update_elevation_marker_z.
ptype = ifcopenshell.util.element.get_predefined_type(self._annotation)
if ptype in ("SECTION_LEVEL", "PLAN_LEVEL"):
from mathutils import Vector as _mVector
hit_pt = anchor.get("pt")
target_z = float(hit_pt[2]) if hit_pt else float(location.z)
hit_world = _mVector((float(location.x), float(location.y), target_z))
m = self._annotation_obj.matrix_world.copy()
m.translation = hit_world
ifcopenshell.api.run("geometry.edit_object_placement", file, product=self._annotation, matrix=np.array(m))
if self._annotation_obj.parent:
self._annotation_obj.location = self._annotation_obj.parent.matrix_world.inverted() @ hit_world
else:
self._annotation_obj.location = hit_world
_zero_elevation_annotation_spline_z(self._annotation, self._annotation_obj)
self.report(
{"INFO"},
f"Vertex {self._active_vertex_idx}{element.is_a()}/{element.Name or element.GlobalId} [{anchor.get('type')}]",
@@ -7415,30 +7438,37 @@ class SetDimensionAnchor(bpy.types.Operator, tool.Ifc.Operator):
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()
ptype = ifcopenshell.util.element.get_predefined_type(annotation)
_is_elevation = ptype in ("SECTION_LEVEL", "PLAN_LEVEL")
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 = []
if _is_elevation:
# Elevation markers use exactly one anchor (the Z reference point).
anchors: list = [new_anchor]
else:
anchors = _anchors_from_spline(annotation_obj, file)
if pset_data and pset_data.get("Anchors"):
try:
anchors = 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
import ifcopenshell.api.drawing as drawing_api
anchors.append(drawing_api.make_world_anchor([0.0, 0.0, 0.0]))
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
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
anchors[vertex_index] = new_anchor
anchors_json = json.dumps(anchors)
if pset_data:
@@ -7466,18 +7496,227 @@ def _do_write_anchor(annotation, annotation_obj, new_anchor: dict, vertex_index:
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)
if _is_elevation:
_update_elevation_marker_z(file, annotation, shape_cache=shape_cache, placement_override=placement_override)
else:
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 AddElevationAnnotation(SetDimensionAnchor):
"""Interactively place a SECTION_LEVEL or PLAN_LEVEL annotation.
Runs the same face / layer / edge / vertex snap UI as SetDimensionAnchor but
creates the annotation only AFTER the user clicks, placing it directly at the
picked elevation. The anchor is stored in BBIM_Dimension so the annotation
tracks the element if it moves.
"""
bl_idname = "bim.add_elevation_annotation"
bl_label = "Add Elevation Annotation"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
if not tool.Ifc.get():
return False
if not context.scene.camera or not tool.Ifc.get_entity(context.scene.camera):
cls.poll_message_set("No active drawing.")
return False
if context.mode != "OBJECT":
cls.poll_message_set("Must be in Object Mode.")
return False
if not getattr(context, "space_data", None) or context.space_data.type != "VIEW_3D":
return False
ann_props = tool.Drawing.get_annotation_props()
return ann_props.object_type in ("SECTION_LEVEL", "PLAN_LEVEL")
def _invoke(self, context, event):
props = tool.Drawing.get_annotation_props()
dprops = tool.Drawing.get_document_props()
self._create_drawing = dprops.get_active_drawing()
if not self._create_drawing:
self.report({"WARNING"}, "No active drawing.")
return {"CANCELLED"}
self._create_object_type = props.object_type
self._create_relating_type = (
tool.Ifc.get().by_id(int(props.relating_type_id))
if props.relating_type_id and props.relating_type_id != "0"
else None
)
# No existing annotation — we create it on the click.
self._annotation = None
self._annotation_obj = None
self._shape_cache = {}
self._phase = "PICK_FACE"
self._active_vertex_idx = 0
self._hover_candidates = []
self._hover_index = 0
self._hover_last_px = (-9999, -9999)
self._hover_highlighted_obj = None
self._snap_mode = "FACE"
_anchor_hover_draw_data.clear()
self._draw_handler = bpy.types.SpaceView3D.draw_handler_add(
_draw_anchor_hover_global, (), "WINDOW", "POST_PIXEL"
)
self._region, self._rv3d = None, None
for area in context.screen.areas:
if area.type == "VIEW_3D":
for region in area.regions:
if region.type == "WINDOW":
self._region = region
break
if area.spaces and area.spaces[0].type == "VIEW_3D":
self._rv3d = area.spaces[0].region_3d
break
context.workspace.status_text_set(
f"Hover over an element | Click: place {self._create_object_type}"
" | TAB: cycle snap | RMB/ESC: Cancel"
)
context.window_manager.modal_handler_add(self)
return {"RUNNING_MODAL"}
def _modal(self, context, event):
if event.type == "ESC" or (event.type == "RIGHTMOUSE" and event.value == "PRESS"):
self._cleanup(context)
return {"FINISHED"}
if event.type == "MOUSEMOVE" and self._phase == "PICK_FACE":
self._handle_hover(context, event)
return {"RUNNING_MODAL"}
if event.type == "TAB" and event.value == "PRESS" and self._phase == "PICK_FACE":
self._cycle_hover(context)
return {"RUNNING_MODAL"}
if event.type == "LEFTMOUSE" and event.value == "RELEASE":
return {"RUNNING_MODAL"}
if event.type == "LEFTMOUSE" and event.value == "PRESS":
wrote = self._handle_face_pick_create(context, event)
if wrote:
self._cleanup(context)
return {"FINISHED"}
return {"RUNNING_MODAL"}
return {"PASS_THROUGH"}
def _handle_face_pick_create(self, context, event):
"""Pick a face, create the elevation annotation there, write the anchor."""
region = self._region
rv3d = self._rv3d
if not region or not rv3d:
return False
self._clear_hover_highlight(context)
coord = (event.mouse_x - region.x, event.mouse_y - region.y)
dx = coord[0] - self._hover_last_px[0]
dy = coord[1] - self._hover_last_px[1]
if dx * dx + dy * dy > self._HOVER_THROTTLE_PX_SQ or not self._hover_candidates:
self._hover_candidates = self._compute_candidates(context, coord)
self._hover_index = 0
if not self._hover_candidates:
self.report({"WARNING"}, "Nothing under cursor — click on a model element")
return False
idx = min(self._hover_index, len(self._hover_candidates) - 1)
hit_obj, hit_mesh, hit_mesh_mx, location, normal, face_index = self._hover_candidates[idx]
element = tool.Ifc.get_entity(hit_obj)
if not element:
self.report({"WARNING"}, f"'{hit_obj.name}' is not an IFC element")
return False
file = tool.Ifc.get()
placement_override = {element.id(): np.array(hit_obj.matrix_world)}
snap = self._compute_snap_geom(hit_obj, face_index, coord)
snap_type = snap.get("type", "FACE")
import ifcopenshell.api.drawing as drawing_api
try:
if snap_type == "LAYER" and snap.get("method") == "LAYER_BOUNDARY":
anchor = drawing_api.build_anchor_from_layer_boundary(file, element, snap)
elif snap_type == "VERTEX" and snap.get("profile_x_m") is not None:
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:
sw = snap["snap_world"]
local_m = snap.get("local_m")
if local_m is not None:
anchor = drawing_api.build_anchor_from_local_point(element, snap_type, sw, local_m)
else:
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:
import traceback as _tb
_tb.print_exc()
self.report({"ERROR"}, f"build_anchor failed: {exc}")
return False
obj = core.add_annotation(
tool.Ifc, tool.Collector, tool.Drawing,
drawing=self._create_drawing,
object_type=self._create_object_type,
relating_type=self._create_relating_type,
enable_editing=False,
)
if not obj:
return False
annotation = tool.Ifc.get_entity(obj)
self._annotation = annotation
self._annotation_obj = obj
_do_write_anchor(annotation, obj, anchor, 0, self._shape_cache)
# Move annotation to the face hit position (XY and Z).
# obj.matrix_world may be stale (depsgraph not yet evaluated), so we
# override the translation column directly from the known hit point.
from mathutils import Vector as _mVector
hit_pt = anchor.get("pt")
target_z = float(hit_pt[2]) if hit_pt else float(location.z)
hit_world = _mVector((float(location.x), float(location.y), target_z))
m = obj.matrix_world.copy()
m.translation = hit_world
ifcopenshell.api.run("geometry.edit_object_placement", file, product=annotation, matrix=np.array(m))
if obj.parent:
obj.location = obj.parent.matrix_world.inverted() @ hit_world
else:
obj.location = hit_world
# Spline points were built from camera-space coords and may have a large
# local Z offset. Zero it so the line sits at the same world elevation
# as the object origin (where the gizmo is drawn).
_zero_elevation_annotation_spline_z(annotation, obj)
tool.Blender.select_and_activate_single_object(context, obj)
self.report(
{"INFO"},
f"Vertex 0 → {element.is_a()}/{element.Name or element.GlobalId} [{anchor.get('type')}]",
)
return True
def _execute(self, context):
pass # This operator is modal-only; always invoke with INVOKE_DEFAULT.
class RegenerateDimensions(bpy.types.Operator, tool.Ifc.Operator):
"""Regenerate all parametric dimension annotations in the project.
@@ -7541,10 +7780,13 @@ class RegenerateDimensions(bpy.types.Operator, tool.Ifc.Operator):
if not pset:
continue
# Sync anchor count to curve vertex count in case the user added or
# removed vertices in Edit Mode since the last regeneration.
ptype = ifcopenshell.util.element.get_predefined_type(annotation)
_is_elevation = ptype in ("SECTION_LEVEL", "PLAN_LEVEL")
# Sync anchor count to curve vertex count for true dimensions only.
# Elevation markers always have exactly one anchor regardless of vertex count.
ann_obj = tool.Ifc.get_object(annotation)
if ann_obj and ann_obj.type == "CURVE":
if not _is_elevation and ann_obj and ann_obj.type == "CURVE":
_sync_dimension_anchors_to_curve(file, annotation, ann_obj)
# Build a placement override from each referenced element's current
@@ -7572,19 +7814,27 @@ class RegenerateDimensions(bpy.types.Operator, tool.Ifc.Operator):
except Exception:
pass
resolved_pts = drawing_api.regenerate_dimension(
file, annotation,
settings=geom_settings,
shape_cache=shape_cache,
placement_override=placement_override,
)
if not resolved_pts:
continue
if _is_elevation:
if _update_elevation_marker_z(
file, annotation,
settings=geom_settings,
shape_cache=shape_cache,
placement_override=placement_override,
):
updated += 1
else:
resolved_pts = drawing_api.regenerate_dimension(
file, annotation,
settings=geom_settings,
shape_cache=shape_cache,
placement_override=placement_override,
)
if not resolved_pts:
continue
_update_blender_curve(annotation, resolved_pts)
updated += 1
_update_blender_curve(annotation, resolved_pts)
updated += 1
self.report({"INFO"}, f"Regenerated {updated} parametric dimension(s).")
self.report({"INFO"}, f"Regenerated {updated} parametric annotation(s).")
# ---------------------------------------------------------------------------
@@ -7661,8 +7911,107 @@ def _update_blender_curve(
pass
def _update_elevation_marker_z(
file: "ifcopenshell.file",
annotation: "ifcopenshell.entity_instance",
settings=None,
shape_cache: Optional[dict] = None,
placement_override: Optional[dict] = None,
) -> bool:
"""For SECTION_LEVEL / PLAN_LEVEL: resolve anchor 0 elevation and slide all spline vertices to that Z.
Unlike regenerate_dimension (which rebuilds the full polyline from N anchors),
this preserves every vertex's X/Y and only updates Z, keeping the annotation's
drawn shape intact while tracking the anchored element's elevation.
Returns True if the spline was updated.
"""
from ifcopenshell.api.drawing.resolve_anchor import resolve_anchor as _resolve_anchor
import ifcopenshell.api.pset as _pset_api
import json as _json
pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
if not pset_data or not pset_data.get("Anchors"):
return False
try:
anchors: list = _json.loads(pset_data["Anchors"])
except Exception:
return False
if not anchors:
return False
pt = _resolve_anchor(file, anchors[0], settings, shape_cache or {}, placement_override)
if pt is None:
cached = anchors[0].get("pt")
pt = tuple(cached) if cached else None
if pt is None:
return False
target_z = pt[2]
# Persist the resolved pt so the index stays warm
anchors[0]["pt"] = list(pt)
pset_entity = file.by_id(pset_data["id"])
_pset_api.edit_pset(file, pset=pset_entity, properties={"Anchors": _json.dumps(anchors)})
obj = tool.Ifc.get_object(annotation)
if not obj:
return False
is_2d = _annotation_is_2d(annotation)
if is_2d:
# 2D annotations encode elevation in the object placement (world Z), not curve points.
from mathutils import Vector as _Vector
current_world = obj.matrix_world.to_translation()
new_world = _Vector((current_world.x, current_world.y, target_z))
# Build updated world matrix (replace Z translation only, preserve rotation/scale)
m = obj.matrix_world.copy()
m[2][3] = target_z
ifcopenshell.api.run("geometry.edit_object_placement", file, product=annotation, matrix=np.array(m))
# Update Blender visual position
if obj.parent:
obj.location = obj.parent.matrix_world.inverted() @ new_world
else:
obj.location = new_world
return True
if not obj.data or not hasattr(obj.data, "splines") or not obj.data.splines:
return True
from mathutils import Vector as _Vector
mx = obj.matrix_world
inv_mx = mx.inverted()
for spline in obj.data.splines:
for spt in spline.points:
world_pos = mx @ spt.co.to_3d()
new_local = inv_mx @ _Vector((world_pos.x, world_pos.y, target_z))
spt.co = (*new_local, 1.0)
try:
world_pts = []
for spline in obj.data.splines:
for spt in spline.points:
wp = mx @ spt.co.to_3d()
world_pts.append((wp.x, wp.y, wp.z))
_update_ifc_polyline(file, annotation, obj, world_pts)
except Exception:
pass
return True
def _annotation_is_2d(annotation: ifcopenshell.entity_instance) -> bool:
"""Return True if the annotation's representation uses 2D coordinates (plan view)."""
"""Return True when elevation is encoded in the object placement (not spline Z values).
PLAN_LEVEL and SECTION_LEVEL always encode elevation in object placement regardless
of whether the curve representation happens to use 2D or 3D coordinates.
For other annotation types we fall back to inspecting the geometry.
"""
ptype = ifcopenshell.util.element.get_predefined_type(annotation)
if ptype in ("SECTION_LEVEL", "PLAN_LEVEL"):
return True
if not getattr(annotation, "Representation", None):
return False
for rep in annotation.Representation.Representations:
@@ -7741,6 +8090,37 @@ def _update_ifc_polyline(
return
def _zero_elevation_annotation_spline_z(annotation: ifcopenshell.entity_instance, obj: bpy.types.Object) -> None:
"""Flatten all spline-point local Z to 0 for PLAN_LEVEL / SECTION_LEVEL annotations.
For elevation annotations the elevation is encoded in the object placement (world Z).
Spline points are created from camera-space coords and may carry a large local Z
offset. Zeroing it ensures the visible line and the gizmo sit at the same elevation.
"""
if not obj.data or not hasattr(obj.data, "splines"):
return
changed = False
for spline in obj.data.splines:
for pt in spline.points:
if pt.co.z != 0.0:
pt.co = (pt.co.x, pt.co.y, 0.0, pt.co.w)
changed = True
if not changed or not getattr(annotation, "Representation", None):
return
for rep in annotation.Representation.Representations:
curve = _find_curve_item(rep)
if curve is None:
continue
if curve.is_a("IfcIndexedPolyCurve"):
pts_list = curve.Points
if not pts_list.is_a("IfcCartesianPointList2D"):
pts_list.CoordList = tuple((c[0], c[1], 0.0) for c in pts_list.CoordList)
elif curve.is_a("IfcPolyline") and curve.Points:
for ifc_pt in curve.Points:
if len(ifc_pt.Coordinates) >= 3:
ifc_pt.Coordinates = (ifc_pt.Coordinates[0], ifc_pt.Coordinates[1], 0.0)
def _find_curve_item(rep: ifcopenshell.entity_instance) -> Optional[ifcopenshell.entity_instance]:
"""Return the first IfcPolyline or IfcIndexedPolyCurve in a shape representation."""
for item in rep.Items:
@@ -7828,17 +8208,30 @@ class ClickNearestDimensionAnchor(bpy.types.Operator):
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()
ptype = _ue.get_predefined_type(element)
if ptype in ("SECTION_LEVEL", "PLAN_LEVEL"):
# Gizmo is drawn at the object origin for elevation annotations.
world_pos = obj.matrix_world.translation.copy()
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 sp:
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 = 0
best_obj = obj
else:
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"}
@@ -7890,7 +8283,8 @@ class MakeDimensionParametric(bpy.types.Operator, tool.Ifc.Operator):
bl_label = "Make Dimension Parametric"
bl_options = {"REGISTER", "UNDO"}
_DIM_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE", "PLAN_LEVEL", "SECTION_LEVEL"))
_DIM_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE"))
_ELEVATION_TYPES = frozenset(("SECTION_LEVEL", "PLAN_LEVEL"))
@classmethod
def poll(cls, context):
@@ -7899,20 +8293,21 @@ class MakeDimensionParametric(bpy.types.Operator, tool.Ifc.Operator):
return False
obj = context.active_object
if not obj or obj.type != "CURVE":
cls.poll_message_set("Active object must be a dimension curve.")
cls.poll_message_set("Active object must be an annotation curve.")
return False
element = tool.Ifc.get_entity(obj)
if not element or not element.is_a("IfcAnnotation"):
cls.poll_message_set("Active object must be an IfcAnnotation.")
return False
if ifcopenshell.util.element.get_predefined_type(element) not in cls._DIM_TYPES:
cls.poll_message_set("Annotation must be a dimension type.")
ptype = ifcopenshell.util.element.get_predefined_type(element)
if ptype not in cls._DIM_TYPES and ptype not in cls._ELEVATION_TYPES:
cls.poll_message_set("Annotation must be a dimension or elevation type.")
return False
pset = ifcopenshell.util.element.get_pset(element, "BBIM_Dimension")
if pset and pset.get("Anchors"):
cls.poll_message_set("Dimension is already parametric.")
cls.poll_message_set("Annotation is already parametric.")
return False
if not obj.data.splines or len(obj.data.splines[0].points) < 2:
if ptype in cls._DIM_TYPES and (not obj.data.splines or len(obj.data.splines[0].points) < 2):
cls.poll_message_set("Curve has fewer than 2 points.")
return False
return True
@@ -7923,12 +8318,18 @@ class MakeDimensionParametric(bpy.types.Operator, tool.Ifc.Operator):
obj = context.active_object
element = tool.Ifc.get_entity(obj)
file = tool.Ifc.get()
ptype = ifcopenshell.util.element.get_predefined_type(element)
spline = obj.data.splines[0]
anchors = [
drawing_api.make_world_anchor(list(obj.matrix_world @ pt.co.to_3d()))
for pt in spline.points
]
if ptype in self._ELEVATION_TYPES:
# One free-world anchor at the object origin (the current elevation).
world_pos = list(obj.matrix_world.translation)
anchors = [drawing_api.make_world_anchor(world_pos)]
else:
spline = obj.data.splines[0]
anchors = [
drawing_api.make_world_anchor(list(obj.matrix_world @ pt.co.to_3d()))
for pt in spline.points
]
pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Dimension")
if not pset_data:
@@ -7945,7 +8346,7 @@ class MakeDimensionParametric(bpy.types.Operator, tool.Ifc.Operator):
area.tag_redraw()
break
self.report({"INFO"}, f"'{obj.name}' is now parametric with {len(anchors)} free-end anchors.")
self.report({"INFO"}, f"'{obj.name}' is now parametric with {len(anchors)} free-end anchor(s).")
class BakeParametricDimension(bpy.types.Operator, tool.Ifc.Operator):
+2 -2
View File
@@ -1052,7 +1052,7 @@ def _update_force_perpendicular(self, context):
return
new_value = self.force_perpendicular_to_face
_DIM_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE", "PLAN_LEVEL", "SECTION_LEVEL"))
_DIM_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE"))
targets = []
for obj in context.selected_objects:
@@ -1149,7 +1149,7 @@ def _set_line_position(self, value: float) -> None:
if not file:
return
_DIM_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE", "PLAN_LEVEL", "SECTION_LEVEL"))
_DIM_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE"))
targets = []
import bpy as _bpy
@@ -225,7 +225,8 @@ class AnnotationToolUI:
props = tool.Drawing.get_document_props()
row.prop(props, "should_draw_decorations", text="Viewport Annotations")
_DIMENSION_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE", "PLAN_LEVEL", "SECTION_LEVEL"))
_DIMENSION_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE"))
_ELEVATION_TYPES = frozenset(("SECTION_LEVEL", "PLAN_LEVEL"))
@classmethod
def draw_edit_object_interface(cls, context):
@@ -272,6 +273,18 @@ class AnnotationToolUI:
else:
row = cls.layout.row(align=True)
row.operator("bim.make_dimension_parametric", text="Make Parametric", icon="LINKED")
elif ptype in cls._ELEVATION_TYPES:
cls.layout.separator()
pset = ifcopenshell.util.element.get_pset(element, "BBIM_Dimension")
if pset and pset.get("Anchors"):
row = cls.layout.row(align=True)
op = row.operator("bim.regenerate_dimensions", icon="FILE_REFRESH", text="Regenerate")
op.active_only = True
row = cls.layout.row(align=True)
row.operator("bim.bake_parametric_dimension", text="Bake to Static", icon="UNLINKED")
else:
row = cls.layout.row(align=True)
row.operator("bim.make_dimension_parametric", text="Make Parametric", icon="LINKED")
@classmethod
def draw_type_selection_interface(cls):
@@ -295,7 +308,7 @@ class AnnotationToolUI:
add_layout_hotkey_operator(cls.layout, "Add", "S_A", "Create a new annotation")
_DIMENSION_TYPES = {"DIMENSION", "RADIUS", "DIAMETER", "ANGLE", "PLAN_LEVEL", "SECTION_LEVEL"}
_DIMENSION_TYPES = {"DIMENSION", "RADIUS", "DIAMETER", "ANGLE"}
if object_type in _DIMENSION_TYPES:
row = cls.layout.row(align=True)
row.prop(cls.props, "force_perpendicular_to_face")
@@ -383,14 +396,18 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator):
bpy.context.view_layer.objects.active = created_objects[-1]
_PARAMETRIC_DIMENSION_TYPES = frozenset(
("DIMENSION", "RADIUS", "DIAMETER", "ANGLE", "PLAN_LEVEL", "SECTION_LEVEL")
("DIMENSION", "RADIUS", "DIAMETER", "ANGLE")
)
_ELEVATION_TYPES = frozenset(("SECTION_LEVEL", "PLAN_LEVEL"))
def hotkey_S_A(self):
props = tool.Drawing.get_annotation_props()
if props.object_type in self._PARAMETRIC_DIMENSION_TYPES:
if bpy.ops.bim.draw_parametric_dimension.poll():
bpy.ops.bim.draw_parametric_dimension("INVOKE_DEFAULT")
elif props.object_type in self._ELEVATION_TYPES:
if bpy.ops.bim.add_elevation_annotation.poll():
bpy.ops.bim.add_elevation_annotation("INVOKE_DEFAULT")
elif bpy.ops.bim.add_annotation.poll():
bpy.ops.bim.add_annotation()
+11
View File
@@ -209,6 +209,17 @@ class Drawing(bonsai.core.tool.Drawing):
co_end = co1 + vec * scaled_length
obj = annotation.Annotator.add_line_to_annotation(obj, co_end, co1)
obj.matrix_world = obj.matrix_world @ Matrix.Rotation(math.radians(-90), 4, "Z")
elif object_type == "SECTION_LEVEL":
co1, _, co3, _ = annotation.Annotator.get_placeholder_coords()
# co3 - co1 is the camera X direction (horizontal in a section view).
vec = co3 - co1
if vec.length == 0:
vec = Vector((1, 0, 0))
else:
vec = vec.normalized()
scaled_length = 0.023 * scale
co_end = co1 + vec * scaled_length
obj = annotation.Annotator.add_line_to_annotation(obj, co_end, co1)
elif object_type != "TEXT":
obj = annotation.Annotator.add_line_to_annotation(obj)