mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-05 23:41:44 +00:00
Add ForcePerpendicularToFace + hover-cycle UX for parametric dimensions
SetDimensionAnchor — hover-select-then-confirm: - Cursor highlights candidate IFC elements (orange Blender selection outline) before committing; Tab cycles through overlapping/coplanar candidates - _compute_candidates: ray-cast all IFC mesh objects; falls back to 2D bounding-box proximity (5 cm tolerance) for plan-view picks where the ray misses the mesh by sub-mm amounts - _write_anchor: after anchoring a face, immediately calls regenerate_dimension with placement_override (Blender matrix_world) and _update_blender_curve so the curve vertex moves to the resolved point DrawParametricDimension — ForcePerpendicularToFace live snap constraint: - Reads force_perpendicular_to_face toggle from annotation props on invoke - After anchor[0] is placed on a FACE, _update_perp_constraint extracts the face normal and stores it as the constraint axis - _apply_perp_constraint runs every modal tick after handle_snap_selection, projecting the current snap point onto pt[0] + t*normal - On finalize, _create_dimension_from_polyline writes ForcePerpendicularToFace to the BBIM_Dimension pset and calls regenerate_dimension to snap the stored curve to the constraint before the operator exits regenerate_dimension.py: - ForcePerpendicularToFace block: after resolving all anchors, projects vertices 1…n onto the line through pt[0] along anchor[0]'s face normal - _get_anchor_face_normal_world: reads normal_local from anchor fingerprint, calls _rotate_local_to_world with placement_override; falls back to stored world-space normal resolve_anchor.py: - _rotate_local_to_world: transforms an element-local direction vector to world space using the element's placement or placement_override matrix pset/operator.py: - EditPset._execute: after editing a BBIM_Dimension pset on an IfcAnnotation, auto-calls regenerate_dimension + _update_blender_curve so changes to anchors/ForcePerpendicularToFace are reflected immediately in the viewport prop.py / workspace.py: - Added force_perpendicular_to_face BoolProperty to BIMAnnotationProperties - UI toggle shown in annotation tool header for DIMENSION/RADIUS/DIAMETER/ ANGLE/PLAN_LEVEL/SECTION_LEVEL types Psets_BBIM_Annotation.ifc: - Added ForcePerpendicularToFace property template (#39) to BBIM_Dimension - Extended BBIM_Dimension applicability to ANGLE, PLAN_LEVEL, SECTION_LEVEL Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -28,7 +28,7 @@ DATA;
|
||||
#21=IFCSIMPLEPROPERTYTEMPLATE('1UDakJ5_f7kBhggNSW4$h5',$,'SymbolsPath','Default symbols SVG',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
|
||||
#22=IFCSIMPLEPROPERTYTEMPLATE('0d53LEtgLDQxnv__NfgH7i',$,'PatternsPath','Default patterns SVG',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
|
||||
#23=IFCSIMPLEPROPERTYTEMPLATE('26qFNMv7nCHgU6Jd7Anga5',$,'ShadingStylesPath','Default shading styles',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
|
||||
#24=IFCPROPERTYSETTEMPLATE('0I9merLinF5Ap$aZwaclgm',$,'BBIM_Dimension','',.PSET_TYPEDRIVENOVERRIDE.,'IfcAnnotation/DIMENSION,IfcAnnotation/RADIUS,IfcAnnotation/DIAMETER,IfcTypeProduct',(#25,#26,#35,#36,#27,#28,#30,#34));
|
||||
#24=IFCPROPERTYSETTEMPLATE('0I9merLinF5Ap$aZwaclgm',$,'BBIM_Dimension','',.PSET_TYPEDRIVENOVERRIDE.,'IfcAnnotation/DIMENSION,IfcAnnotation/RADIUS,IfcAnnotation/DIAMETER,IfcAnnotation/ANGLE,IfcAnnotation/PLAN_LEVEL,IfcAnnotation/SECTION_LEVEL,IfcTypeProduct',(#25,#26,#35,#36,#27,#28,#30,#34,#37,#38,#39));
|
||||
#25=IFCSIMPLEPROPERTYTEMPLATE('1rL2AbQsXD8RbpoWH5pYOV',$,'ShowDescriptionOnly','Hide the measurement values and show only annotation description',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
|
||||
#26=IFCSIMPLEPROPERTYTEMPLATE('0SVyOfB0rC2xNfdRYf3XvY',$,'SuppressZeroInches','Suppress 0 inch values in dimension annotation text (for example: 12'' - 0" -> 12'')',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
|
||||
#27=IFCSIMPLEPROPERTYTEMPLATE('2bUmj458PBqPAtUoI3MXsb',$,'TextPrefix','Text to add before annotation measurement value',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
|
||||
@@ -41,8 +41,8 @@ DATA;
|
||||
#34=IFCSIMPLEPROPERTYTEMPLATE('1Kx4Pm9nR8vBwZqTs2uYeL',$,'Separator','Characters placed between multiple dimension values when CustomUnit has more than one unit selected (default: '' / '')',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
|
||||
#35=IFCSIMPLEPROPERTYTEMPLATE('3Nf6Qs1mT0pWxBuCvDyEzA',$,'SuppressZeroFeet','Suppress 0 feet in dimension annotation text (for example: 0'' - 3 1/2" -> 3 1/2")',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
|
||||
#36=IFCSIMPLEPROPERTYTEMPLATE('2Rg7Hn5jK4mLpNqOsVwXtY',$,'IsOrdinate','Show accumulated distance from the first vertex instead of individual segment lengths',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
|
||||
#37=IFCPROPERTYSETTEMPLATE('3Qk8mPzT1rFoV9wXDyBnLe',$,'BBIM_DimensionTarget','Parametric anchor references that connect a dimension annotation to IFC geometry. Anchors is a JSON array (one entry per polyline vertex) encoding element GUID, geometry address, fingerprint, and fallback world point.',.PSET_TYPEDRIVENOVERRIDE.,'IfcAnnotation/DIMENSION,IfcAnnotation/RADIUS,IfcAnnotation/DIAMETER,IfcAnnotation/ANGLE,IfcAnnotation/PLAN_LEVEL,IfcAnnotation/SECTION_LEVEL',(#38,#39));
|
||||
#38=IFCSIMPLEPROPERTYTEMPLATE('1XpRnKoT2sGuW7vYcZaMqb',$,'Anchors','JSON array of anchor descriptors — one per polyline vertex. Each entry: {"guid": str|null, "type": "FACE"|"CIRCLE_CENTER"|"WORLD", "addr": {...}, "hint": [x,y,z]|null, "pt": [x,y,z]}',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.);
|
||||
#39=IFCSIMPLEPROPERTYTEMPLATE('2YqSmLoU3tHvX8wZdaNrjc',$,'MeasureAxis','Axis along which distances are projected: X | Y | Z | TRUE | PERPENDICULAR',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
|
||||
#37=IFCSIMPLEPROPERTYTEMPLATE('1XpRnKoT2sGuW7vYcZaMqb',$,'Anchors','JSON array of parametric anchor descriptors — one per polyline vertex. Each entry: {"guid": str|null, "type": "FACE"|"CIRCLE_CENTER"|"WORLD", "addr": {...}, "hint": [x,y,z]|null, "pt": [x,y,z]}',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.);
|
||||
#38=IFCSIMPLEPROPERTYTEMPLATE('2YqSmLoU3tHvX8wZdaNrjc',$,'MeasureAxis','Axis along which distances are projected: X | Y | Z | TRUE | PERPENDICULAR',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
|
||||
#39=IFCSIMPLEPROPERTYTEMPLATE('3Ny31Go6T5Z9fh8j4yQC0p',$,'ForcePerpendicularToFace','When enabled the polyline is constrained to follow the face normal of the first anchor vertex so the dimension measures straight-line distance perpendicular to that face',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
|
||||
ENDSEC;
|
||||
END-ISO-10303-21;
|
||||
|
||||
@@ -33,7 +33,7 @@ import bonsai.tool as tool
|
||||
_dim_guid_index: dict = {}
|
||||
# Persistent tessellation cache for the depsgraph handler (element id → shape).
|
||||
_dim_shape_cache: dict = {}
|
||||
# Set True whenever BBIM_DimensionTarget anchors change or a new file loads.
|
||||
# Set True whenever BBIM_Dimension anchors change or a new file loads.
|
||||
_dim_index_dirty: bool = True
|
||||
# Re-entry guard so curve updates don't trigger a second handler call.
|
||||
_dim_handler_running: bool = False
|
||||
@@ -52,7 +52,7 @@ def _rebuild_dim_guid_index(file) -> None:
|
||||
|
||||
_dim_guid_index = {}
|
||||
for annotation in file.by_type("IfcAnnotation"):
|
||||
pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_DimensionTarget")
|
||||
pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
|
||||
if not pset_data or not pset_data.get("Anchors"):
|
||||
continue
|
||||
try:
|
||||
@@ -172,7 +172,7 @@ def depsgraph_update_post_handler(scene, depsgraph):
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
pset = ifcopenshell.util.element.get_pset(annotation, "BBIM_DimensionTarget")
|
||||
pset = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
|
||||
if not pset:
|
||||
continue
|
||||
|
||||
|
||||
@@ -5574,7 +5574,7 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope
|
||||
|
||||
Click on IFC element faces to place dimension vertices one by one using the
|
||||
same snap system as wall and slab drawing. Each confirmed point is stored
|
||||
as a parametric anchor in ``BBIM_DimensionTarget`` so the dimension
|
||||
as a parametric anchor in ``BBIM_Dimension`` so the dimension
|
||||
recomputes automatically when the referenced elements move.
|
||||
|
||||
RMB or ENTER to finish; ESC to cancel without creating an annotation.
|
||||
@@ -5603,12 +5603,15 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope
|
||||
PolylineOperator.__init__(self)
|
||||
self._anchors = []
|
||||
self._shape_cache = {}
|
||||
self._force_perpendicular = False
|
||||
self._anchor0_normal = None # (nx, ny, nz) world-space face normal of anchor[0]
|
||||
self._anchor0_pt = None # (x, y, z) world-space position of anchor[0]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Snap → anchor bridge
|
||||
|
||||
def _snap_to_anchor(self, snap: dict) -> dict:
|
||||
"""Convert a PolylineOperator snap candidate to a BBIM_DimensionTarget anchor dict."""
|
||||
"""Convert a PolylineOperator snap candidate to a BBIM_Dimension anchor dict."""
|
||||
import ifcopenshell.api.drawing as drawing_api
|
||||
|
||||
obj = snap.get("object")
|
||||
@@ -5619,14 +5622,21 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope
|
||||
hit_m = (float(pt_world.x), float(pt_world.y), float(pt_world.z))
|
||||
|
||||
face_index = snap.get("face_index")
|
||||
if face_index is None or face_index >= len(obj.data.polygons):
|
||||
# Vertex / edge snap: seed from closest face.
|
||||
local_pt = obj.matrix_world.inverted() @ pt_world
|
||||
ok, _loc, _n, face_index = obj.closest_point_on_mesh(local_pt)
|
||||
if not ok:
|
||||
face_index = None
|
||||
|
||||
# Prefer faces perpendicular to the camera rather than faces that
|
||||
# directly face the camera (e.g. top of a wall in plan view).
|
||||
face_index = _prefer_perp_face_index(obj, pt_world, face_index)
|
||||
|
||||
if face_index is not None and face_index < len(obj.data.polygons):
|
||||
normal_local = obj.data.polygons[face_index].normal
|
||||
else:
|
||||
# Vertex / edge snap: find the closest face for a proper normal.
|
||||
local_pt = obj.matrix_world.inverted() @ pt_world
|
||||
ok, _loc, normal_local, face_index = obj.closest_point_on_mesh(local_pt)
|
||||
if not ok:
|
||||
normal_local = Vector((0.0, 0.0, 1.0))
|
||||
normal_local = Vector((0.0, 0.0, 1.0))
|
||||
|
||||
normal_world = (obj.matrix_world.to_3x3() @ normal_local).normalized()
|
||||
normal_m = (float(normal_world.x), float(normal_world.y), float(normal_world.z))
|
||||
@@ -5673,12 +5683,57 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope
|
||||
import ifcopenshell.api.drawing as drawing_api
|
||||
pt = polyline_data[0].polyline_points[-1]
|
||||
self._anchors.append(drawing_api.make_world_anchor([float(pt.x), float(pt.y), float(pt.z)]))
|
||||
|
||||
# After anchor[0] is set, extract its face normal for the perp constraint.
|
||||
if self._force_perpendicular and len(self._anchors) == 1:
|
||||
self._update_perp_constraint()
|
||||
elif count_after < count_before and self._anchors:
|
||||
# BACKSPACE removed a point.
|
||||
self._anchors.pop()
|
||||
# Reset constraint if we backspaced past anchor[0].
|
||||
if len(self._anchors) == 0:
|
||||
self._anchor0_normal = None
|
||||
self._anchor0_pt = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Finalize: create IfcAnnotation + BBIM_DimensionTarget pset
|
||||
# Perpendicular-to-face constraint helpers
|
||||
|
||||
def _update_perp_constraint(self) -> None:
|
||||
"""Extract the face normal from anchor[0] and store it as the constraint axis."""
|
||||
import math
|
||||
a = self._anchors[0] if self._anchors else None
|
||||
if not a or a.get("type") != "FACE":
|
||||
return
|
||||
fp = (a.get("addr") or {}).get("fingerprint") or {}
|
||||
n = fp.get("normal") # world-space at build time
|
||||
pt = a.get("pt")
|
||||
if not n or not pt:
|
||||
return
|
||||
mag = math.sqrt(n[0] ** 2 + n[1] ** 2 + n[2] ** 2)
|
||||
if mag < 1e-12:
|
||||
return
|
||||
self._anchor0_normal = (n[0] / mag, n[1] / mag, n[2] / mag)
|
||||
self._anchor0_pt = tuple(pt)
|
||||
|
||||
def _apply_perp_constraint(self) -> None:
|
||||
"""Project the current snap point onto the constraint line when active."""
|
||||
if not self._force_perpendicular or not self._anchor0_normal or not self._anchor0_pt:
|
||||
return
|
||||
if not self._anchors: # constraint not yet active (no anchor[0] yet)
|
||||
return
|
||||
if not self.snapping_points:
|
||||
return
|
||||
snap = self.snapping_points[0]
|
||||
if not snap or not snap.get("point"):
|
||||
return
|
||||
p = snap["point"]
|
||||
base = self._anchor0_pt
|
||||
n = self._anchor0_normal
|
||||
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]))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Finalize: create IfcAnnotation + BBIM_Dimension pset
|
||||
|
||||
def _create_dimension_from_polyline(self, context) -> None:
|
||||
import ifcopenshell.api.drawing as drawing_api
|
||||
@@ -5731,10 +5786,36 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope
|
||||
anchors.append(drawing_api.make_world_anchor(list(resolved_pts_m[len(anchors)])))
|
||||
|
||||
file = tool.Ifc.get()
|
||||
ifcopenshell.api.run("pset.add_pset", file, product=annotation, name="BBIM_DimensionTarget")
|
||||
pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_DimensionTarget")
|
||||
pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
|
||||
if not pset_data:
|
||||
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": json.dumps(anchors)})
|
||||
pset_props = {"Anchors": json.dumps(anchors)}
|
||||
if self._force_perpendicular:
|
||||
pset_props["ForcePerpendicularToFace"] = True
|
||||
ifcopenshell.api.run("pset.edit_pset", file, pset=pset_entity, properties=pset_props)
|
||||
|
||||
if self._force_perpendicular:
|
||||
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
|
||||
resolved_pts = drawing_api.regenerate_dimension(
|
||||
file, annotation,
|
||||
shape_cache=getattr(self, "_shape_cache", None),
|
||||
placement_override=placement_override,
|
||||
)
|
||||
if resolved_pts:
|
||||
_update_blender_curve(annotation, resolved_pts)
|
||||
|
||||
from bonsai.bim.module.drawing import handler as _drawing_handler
|
||||
_drawing_handler.invalidate_dim_index()
|
||||
@@ -5762,6 +5843,7 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope
|
||||
self.handle_mouse_move(context, event)
|
||||
self.choose_axis(event)
|
||||
self.handle_snap_selection(context, event)
|
||||
self._apply_perp_constraint()
|
||||
|
||||
if (
|
||||
not self.tool_state.is_input_on
|
||||
@@ -5789,17 +5871,62 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope
|
||||
|
||||
def _invoke(self, context, event):
|
||||
super().invoke(context, event)
|
||||
self._force_perpendicular = tool.Drawing.get_annotation_props().force_perpendicular_to_face
|
||||
return {"RUNNING_MODAL"}
|
||||
|
||||
|
||||
def _prefer_perp_face_index(
|
||||
obj: "bpy.types.Object",
|
||||
hit_world: "Vector",
|
||||
current_index: "Optional[int]",
|
||||
world_matrix=None,
|
||||
) -> "Optional[int]":
|
||||
"""Return the polygon index most perpendicular to the camera near *hit_world*.
|
||||
|
||||
If the camera is unavailable or the current face is already sufficiently
|
||||
perpendicular (|dot| < 0.5), returns *current_index* unchanged.
|
||||
*world_matrix* overrides ``obj.matrix_world``; useful when *obj* is a mesh
|
||||
inside a collection instance whose effective transform differs from its own
|
||||
``matrix_world``.
|
||||
"""
|
||||
camera = bpy.context.scene.camera
|
||||
if not camera or not obj.data or not hasattr(obj.data, "polygons"):
|
||||
return current_index
|
||||
|
||||
cam_view = (camera.matrix_world.to_3x3() @ Vector((0.0, 0.0, -1.0))).normalized()
|
||||
mx = world_matrix if world_matrix is not None else obj.matrix_world
|
||||
mx3 = mx.to_3x3()
|
||||
|
||||
if current_index is not None and current_index < len(obj.data.polygons):
|
||||
current_n = (mx3 @ obj.data.polygons[current_index].normal).normalized()
|
||||
if abs(current_n.dot(cam_view)) < 0.5:
|
||||
return current_index
|
||||
|
||||
best_idx = current_index
|
||||
best_score = -1.0
|
||||
for i, poly in enumerate(obj.data.polygons):
|
||||
n_world = (mx3 @ poly.normal).normalized()
|
||||
perp = 1.0 - abs(n_world.dot(cam_view))
|
||||
if perp < 0.5:
|
||||
continue
|
||||
dist = (mx @ poly.center - hit_world).length
|
||||
score = perp - dist / 4.0
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_idx = i
|
||||
return best_idx
|
||||
|
||||
|
||||
class SetDimensionAnchor(bpy.types.Operator):
|
||||
"""Interactively anchor dimension vertices to IFC element faces.
|
||||
|
||||
Two-phase modal workflow (all in Object Mode, no Tab required):
|
||||
Two-phase modal workflow (all in Object Mode):
|
||||
1. Run the operator with a dimension annotation selected.
|
||||
2. Click a vertex ON the dimension line to select it.
|
||||
3. Click an IFC element face to anchor that vertex to it.
|
||||
ALT+click sets a free world-point anchor instead.
|
||||
3. Hover over IFC elements — the nearest candidate is highlighted.
|
||||
TAB cycles through overlapping candidates under the cursor.
|
||||
Click to anchor the highlighted element face to that vertex.
|
||||
ALT+Click sets a free world-point anchor instead.
|
||||
4. Repeat steps 2-3 for more vertices.
|
||||
5. RMB or ESC to finish.
|
||||
"""
|
||||
@@ -5816,8 +5943,17 @@ class SetDimensionAnchor(bpy.types.Operator):
|
||||
_phase: str = "PICK_VERTEX" # "PICK_VERTEX" | "PICK_FACE"
|
||||
_active_vertex_idx: int = -1
|
||||
_shape_cache: dict
|
||||
_region: Optional[bpy.types.Region] = None
|
||||
_rv3d: Optional[bpy.types.RegionView3D] = None
|
||||
|
||||
_VERTEX_PICK_RADIUS_PX = 20 # pixels — how close the click must be to a vertex
|
||||
# Hover-cycle state (active during PICK_FACE phase)
|
||||
_hover_candidates: list # [(ifc_obj, hit_mesh, hit_mesh_mx, location, normal, face_index), ...]
|
||||
_hover_index: int # which candidate is currently highlighted
|
||||
_hover_last_px: tuple # last cursor pixel position where candidates were computed
|
||||
_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
|
||||
_HOVER_THROTTLE_PX_SQ = 25 # only recompute candidates if cursor moves >5px
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
@@ -5848,15 +5984,44 @@ class SetDimensionAnchor(bpy.types.Operator):
|
||||
self._phase = "PICK_VERTEX"
|
||||
self._active_vertex_idx = -1
|
||||
self._shape_cache = {}
|
||||
self._hover_candidates = []
|
||||
self._hover_index = 0
|
||||
self._hover_last_px = (-9999, -9999)
|
||||
self._hover_highlighted_obj = None
|
||||
|
||||
# When invoked from a panel, context.region_data is None.
|
||||
# Walk the screen areas to find the actual 3D viewport region.
|
||||
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
|
||||
|
||||
self._set_status(context)
|
||||
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._clear_hover_highlight(context)
|
||||
context.workspace.status_text_set(None)
|
||||
return {"FINISHED"} # keep any anchors already written
|
||||
|
||||
# Hover — recompute candidates as cursor moves (PICK_FACE phase only)
|
||||
if event.type == "MOUSEMOVE" and self._phase == "PICK_FACE":
|
||||
self._handle_hover(context, event)
|
||||
return {"RUNNING_MODAL"}
|
||||
|
||||
# Tab — cycle through candidates under cursor
|
||||
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 == "PRESS":
|
||||
if self._phase == "PICK_VERTEX":
|
||||
self._handle_vertex_pick(context, event)
|
||||
@@ -5876,9 +6041,11 @@ class SetDimensionAnchor(bpy.types.Operator):
|
||||
"Click a dimension vertex | RMB / ESC: Finish"
|
||||
)
|
||||
else:
|
||||
# In PICK_FACE the hover handler writes a richer status; this is the
|
||||
# fallback shown when no candidates have been computed yet.
|
||||
context.workspace.status_text_set(
|
||||
f"Vertex {self._active_vertex_idx} selected — "
|
||||
"Click element face to anchor | ALT+Click: free world point | RMB / ESC: Finish"
|
||||
f"Vertex {self._active_vertex_idx} — hover over element | "
|
||||
"TAB: cycle candidates | Click: anchor | ALT+Click: free point | RMB/ESC: Finish"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -5887,12 +6054,14 @@ class SetDimensionAnchor(bpy.types.Operator):
|
||||
def _handle_vertex_pick(self, context, event):
|
||||
from bpy_extras import view3d_utils
|
||||
|
||||
region = context.region
|
||||
rv3d = context.region_data
|
||||
region = self._region
|
||||
rv3d = self._rv3d
|
||||
if not region or not rv3d:
|
||||
return
|
||||
|
||||
coord = (event.mouse_region_x, event.mouse_region_y)
|
||||
# event.mouse_region_x/y is relative to the event's region (e.g. N-panel),
|
||||
# not our stored 3D viewport region. Use absolute coords minus region offset.
|
||||
coord = (event.mouse_x - region.x, event.mouse_y - region.y)
|
||||
obj = self._annotation_obj
|
||||
|
||||
best_idx = None
|
||||
@@ -5921,44 +6090,61 @@ class SetDimensionAnchor(bpy.types.Operator):
|
||||
# Phase 2: pick a face on an IFC element
|
||||
|
||||
def _handle_face_pick(self, context, event):
|
||||
from bpy_extras import view3d_utils
|
||||
from mathutils import Vector
|
||||
|
||||
region = context.region
|
||||
rv3d = context.region_data
|
||||
region = self._region
|
||||
rv3d = self._rv3d
|
||||
if not region or not rv3d:
|
||||
return
|
||||
|
||||
coord = (event.mouse_region_x, event.mouse_region_y)
|
||||
|
||||
# ALT+click → free world-point anchor at the cursor 3D location
|
||||
# ALT+click → free world-point anchor at any mesh surface.
|
||||
if event.alt:
|
||||
origin = view3d_utils.region_2d_to_origin_3d(region, rv3d, coord)
|
||||
direction = view3d_utils.region_2d_to_vector_3d(region, rv3d, coord)
|
||||
hit, location, *_ = context.scene.ray_cast(context.view_layer.depsgraph, origin, direction)
|
||||
pt_m = tuple(location) if hit else tuple(origin + direction * 5.0)
|
||||
|
||||
self._clear_hover_highlight(context)
|
||||
origin, direction = self._unproject_coord(
|
||||
(event.mouse_x - region.x, event.mouse_y - region.y)
|
||||
)
|
||||
best_dist = float("inf")
|
||||
alt_loc = None
|
||||
for obj in context.scene.objects:
|
||||
if obj.type != "MESH":
|
||||
continue
|
||||
try:
|
||||
mx_inv = obj.matrix_world.inverted()
|
||||
except Exception:
|
||||
continue
|
||||
ok, loc_l, _, _ = obj.ray_cast(
|
||||
mx_inv @ origin, (mx_inv.to_3x3() @ direction).normalized()
|
||||
)
|
||||
if ok:
|
||||
loc_w = obj.matrix_world @ loc_l
|
||||
d = (loc_w - origin).length
|
||||
if d < best_dist:
|
||||
best_dist = d
|
||||
alt_loc = loc_w
|
||||
pt_m = list(alt_loc) if alt_loc else list(origin + direction * 5.0)
|
||||
import ifcopenshell.api.drawing as drawing_api
|
||||
anchor = drawing_api.make_world_anchor(list(pt_m))
|
||||
anchor = drawing_api.make_world_anchor(pt_m)
|
||||
self._write_anchor(anchor, self._active_vertex_idx)
|
||||
self.report({"INFO"}, f"Vertex {self._active_vertex_idx} → free world point")
|
||||
self._phase = "PICK_VERTEX"
|
||||
return
|
||||
|
||||
# Normal click → raycast for IFC element face
|
||||
origin = view3d_utils.region_2d_to_origin_3d(region, rv3d, coord)
|
||||
direction = view3d_utils.region_2d_to_vector_3d(region, rv3d, coord)
|
||||
# Normal click — use whichever candidate is currently highlighted.
|
||||
self._clear_hover_highlight(context)
|
||||
|
||||
hit, location, normal, face_index, hit_obj, _ = context.scene.ray_cast(
|
||||
context.view_layer.depsgraph, origin, direction
|
||||
)
|
||||
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 hit or hit_obj is None:
|
||||
if not self._hover_candidates:
|
||||
self.report({"WARNING"}, "Nothing under cursor — click on a model element")
|
||||
return
|
||||
|
||||
if hit_obj == self._annotation_obj:
|
||||
self.report({"WARNING"}, "Click on an element, not the dimension line itself")
|
||||
return
|
||||
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:
|
||||
@@ -5968,16 +6154,20 @@ class SetDimensionAnchor(bpy.types.Operator):
|
||||
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))
|
||||
|
||||
# Pass the Blender matrix_world so face-group matching uses current position.
|
||||
placement_override = {element.id(): np.array(hit_obj.matrix_world)}
|
||||
|
||||
import ifcopenshell.api.drawing as drawing_api
|
||||
anchor = drawing_api.build_anchor_from_hit(
|
||||
file, element, hit_m, normal_m,
|
||||
shape_cache=self._shape_cache,
|
||||
placement_override=placement_override,
|
||||
)
|
||||
try:
|
||||
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
|
||||
traceback.print_exc()
|
||||
self.report({"ERROR"}, f"build_anchor_from_hit failed: {exc}")
|
||||
return
|
||||
|
||||
self._write_anchor(anchor, self._active_vertex_idx)
|
||||
self.report(
|
||||
@@ -5985,6 +6175,176 @@ class SetDimensionAnchor(bpy.types.Operator):
|
||||
f"Vertex {self._active_vertex_idx} → {element.is_a()}/{element.Name or element.GlobalId}",
|
||||
)
|
||||
self._phase = "PICK_VERTEX"
|
||||
self._hover_candidates = []
|
||||
self._hover_index = 0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Hover / cycle helpers
|
||||
|
||||
def _unproject_coord(self, coord):
|
||||
"""Return (origin, direction) world-space ray for a region pixel coord."""
|
||||
from mathutils import Vector
|
||||
|
||||
rv3d = self._rv3d
|
||||
region = self._region
|
||||
persinv = rv3d.perspective_matrix.inverted()
|
||||
dx = (2.0 * coord[0] / region.width) - 1.0
|
||||
dy = (2.0 * coord[1] / region.height) - 1.0
|
||||
near_h = persinv @ Vector((dx, dy, -1.0, 1.0))
|
||||
far_h = persinv @ Vector((dx, dy, 1.0, 1.0))
|
||||
origin = near_h.xyz / near_h.w
|
||||
far_pt = far_h.xyz / far_h.w
|
||||
direction = (far_pt - origin).normalized()
|
||||
if not rv3d.is_perspective:
|
||||
origin = origin - direction * 1e4
|
||||
return origin, direction
|
||||
|
||||
def _compute_candidates(self, context, coord):
|
||||
"""Cast a ray from *coord* and return a ranked list of hit candidates.
|
||||
|
||||
Each entry: (ifc_obj, hit_mesh, hit_mesh_mx, location, normal, face_index)
|
||||
Sorted closest-first for direct hits; by proximity distance for near-misses.
|
||||
"""
|
||||
import math as _math
|
||||
from mathutils import Vector
|
||||
|
||||
origin, direction = self._unproject_coord(coord)
|
||||
|
||||
direct: list = [] # (dist, ifc_obj, mesh_obj, mx, loc_w, normal, face_index)
|
||||
for ifc_obj in context.scene.objects:
|
||||
if ifc_obj == self._annotation_obj:
|
||||
continue
|
||||
if not tool.Ifc.get_entity(ifc_obj):
|
||||
continue
|
||||
if ifc_obj.type != "MESH":
|
||||
continue
|
||||
mx = ifc_obj.matrix_world
|
||||
try:
|
||||
mx_inv = mx.inverted()
|
||||
except Exception:
|
||||
continue
|
||||
ok, loc_l, nrm_l, fi = ifc_obj.ray_cast(
|
||||
mx_inv @ origin, (mx_inv.to_3x3() @ direction).normalized()
|
||||
)
|
||||
if not ok:
|
||||
continue
|
||||
loc_w = mx @ loc_l
|
||||
fi = _prefer_perp_face_index(ifc_obj, loc_w, fi, world_matrix=mx)
|
||||
normal = (mx.to_3x3() @ ifc_obj.data.polygons[fi].normal).normalized() if fi is not None else (mx.to_3x3() @ nrm_l).normalized()
|
||||
dist = (loc_w - origin).length
|
||||
direct.append((dist, ifc_obj, ifc_obj, mx, loc_w, normal, fi))
|
||||
|
||||
if direct:
|
||||
direct.sort(key=lambda c: c[0])
|
||||
return [(o, m, mmx, l, n, f) for _, o, m, mmx, l, n, f in direct]
|
||||
|
||||
# Proximity fallback — collect ALL candidates within TOL, sorted by perp distance.
|
||||
TOL = 0.05
|
||||
|
||||
def _perp(v):
|
||||
return v - v.dot(direction) * direction
|
||||
|
||||
prox: list = []
|
||||
for ifc_obj in context.scene.objects:
|
||||
if ifc_obj == self._annotation_obj:
|
||||
continue
|
||||
if not tool.Ifc.get_entity(ifc_obj):
|
||||
continue
|
||||
if ifc_obj.type != "MESH":
|
||||
continue
|
||||
mx = ifc_obj.matrix_world
|
||||
try:
|
||||
mx_inv = mx.inverted()
|
||||
except Exception:
|
||||
continue
|
||||
bb_world = [mx @ Vector(c) for c in ifc_obj.bound_box]
|
||||
bb_proj = [_perp(v) for v in bb_world]
|
||||
op = _perp(origin)
|
||||
sx = max(min(v.x for v in bb_proj) - op.x, 0.0, op.x - max(v.x for v in bb_proj))
|
||||
sy = max(min(v.y for v in bb_proj) - op.y, 0.0, op.y - max(v.y for v in bb_proj))
|
||||
sz = max(min(v.z for v in bb_proj) - op.z, 0.0, op.z - max(v.z for v in bb_proj))
|
||||
perp_dist = _math.sqrt(sx * sx + sy * sy + sz * sz)
|
||||
if perp_dist > TOL:
|
||||
continue
|
||||
bb_ctr = sum((v for v in bb_world), Vector()) / 8
|
||||
t = (bb_ctr - origin).dot(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)
|
||||
if not found:
|
||||
continue
|
||||
loc_w = mx @ loc_l
|
||||
fi = _prefer_perp_face_index(ifc_obj, loc_w, fi, world_matrix=mx)
|
||||
normal = (mx.to_3x3() @ ifc_obj.data.polygons[fi].normal).normalized() if fi is not None else (mx.to_3x3() @ nrm_l).normalized()
|
||||
prox.append((perp_dist, ifc_obj, ifc_obj, mx, loc_w, normal, fi))
|
||||
|
||||
prox.sort(key=lambda c: c[0])
|
||||
return [(o, m, mmx, l, n, f) for _, o, m, mmx, l, n, f in prox]
|
||||
|
||||
def _handle_hover(self, context, event):
|
||||
"""Recompute candidates when cursor moves; highlight the current one."""
|
||||
if not self._region:
|
||||
return
|
||||
coord = (event.mouse_x - self._region.x, event.mouse_y - self._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:
|
||||
return
|
||||
self._hover_last_px = coord
|
||||
self._hover_candidates = self._compute_candidates(context, coord)
|
||||
self._hover_index = 0
|
||||
self._apply_hover_highlight(context)
|
||||
|
||||
def _cycle_hover(self, context):
|
||||
"""Advance to the next candidate and update the highlight."""
|
||||
if not self._hover_candidates:
|
||||
return
|
||||
self._hover_index = (self._hover_index + 1) % len(self._hover_candidates)
|
||||
self._apply_hover_highlight(context)
|
||||
|
||||
def _apply_hover_highlight(self, context):
|
||||
"""Select the current candidate object for visual feedback."""
|
||||
if not self._hover_candidates:
|
||||
self._clear_hover_highlight(context)
|
||||
return
|
||||
|
||||
ifc_obj = self._hover_candidates[self._hover_index][0]
|
||||
|
||||
# Only update selection when the highlighted object changes.
|
||||
if ifc_obj != self._hover_highlighted_obj:
|
||||
if self._hover_highlighted_obj:
|
||||
try:
|
||||
self._hover_highlighted_obj.select_set(False)
|
||||
except Exception:
|
||||
pass
|
||||
self._hover_highlighted_obj = ifc_obj
|
||||
try:
|
||||
ifc_obj.select_set(True)
|
||||
context.view_layer.objects.active = ifc_obj
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
entity = tool.Ifc.get_entity(ifc_obj)
|
||||
label = (entity.Name or entity.GlobalId) if entity else ifc_obj.name
|
||||
n = len(self._hover_candidates)
|
||||
cycle_hint = f" | TAB: cycle ({self._hover_index + 1}/{n})" if n > 1 else ""
|
||||
context.workspace.status_text_set(
|
||||
f"Vertex {self._active_vertex_idx} — {ifc_obj.name}{cycle_hint}"
|
||||
" | Click: anchor | ALT+Click: free point | RMB/ESC: Finish"
|
||||
)
|
||||
|
||||
def _clear_hover_highlight(self, context):
|
||||
"""Deselect the highlighted object and restore the annotation as active."""
|
||||
if self._hover_highlighted_obj:
|
||||
try:
|
||||
self._hover_highlighted_obj.select_set(False)
|
||||
except Exception:
|
||||
pass
|
||||
self._hover_highlighted_obj = None
|
||||
try:
|
||||
self._annotation_obj.select_set(True)
|
||||
context.view_layer.objects.active = self._annotation_obj
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Pset write (shared by both face and free-point paths)
|
||||
@@ -5993,7 +6353,7 @@ class SetDimensionAnchor(bpy.types.Operator):
|
||||
file = tool.Ifc.get()
|
||||
annotation = self._annotation
|
||||
|
||||
pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_DimensionTarget")
|
||||
pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
|
||||
|
||||
if pset_data and pset_data.get("Anchors"):
|
||||
try:
|
||||
@@ -6023,19 +6383,46 @@ class SetDimensionAnchor(bpy.types.Operator):
|
||||
pset_entity = file.by_id(pset_data["id"])
|
||||
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_DimensionTarget")
|
||||
pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_DimensionTarget")
|
||||
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()
|
||||
|
||||
# Move the Blender curve vertex to the newly resolved anchor position.
|
||||
# Build placement_override from current Blender matrix_world so that
|
||||
# elements whose IFC ObjectPlacement hasn't been synced yet resolve correctly.
|
||||
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=getattr(self, "_shape_cache", None),
|
||||
placement_override=placement_override,
|
||||
)
|
||||
if resolved_pts:
|
||||
_update_blender_curve(annotation, resolved_pts)
|
||||
print(f"[write_anchor] resolved_pts={[(round(p[0],4),round(p[1],4),round(p[2],4)) for p in resolved_pts]}")
|
||||
|
||||
|
||||
class RegenerateDimensions(bpy.types.Operator, tool.Ifc.Operator):
|
||||
"""Regenerate all parametric dimension annotations in the project.
|
||||
|
||||
For every IfcAnnotation that has a BBIM_DimensionTarget pset, resolve all
|
||||
For every IfcAnnotation that has a BBIM_Dimension pset, resolve all
|
||||
anchor references from live element geometry and update the annotation's
|
||||
curve vertices and linked IfcMetric values.
|
||||
"""
|
||||
@@ -6084,12 +6471,12 @@ class RegenerateDimensions(bpy.types.Operator, tool.Ifc.Operator):
|
||||
else:
|
||||
candidates = [
|
||||
a for a in file.by_type("IfcAnnotation")
|
||||
if ifcopenshell.util.element.get_pset(a, "BBIM_DimensionTarget")
|
||||
if ifcopenshell.util.element.get_pset(a, "BBIM_Dimension")
|
||||
]
|
||||
|
||||
updated = 0
|
||||
for annotation in candidates:
|
||||
pset = ifcopenshell.util.element.get_pset(annotation, "BBIM_DimensionTarget")
|
||||
pset = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
|
||||
if not pset:
|
||||
continue
|
||||
|
||||
|
||||
@@ -1051,6 +1051,11 @@ class BIMAnnotationProperties(PropertyGroup):
|
||||
)
|
||||
is_adding_type: bpy.props.BoolProperty(default=False)
|
||||
type_name: bpy.props.StringProperty(name="Name", default="TYPEX")
|
||||
force_perpendicular_to_face: bpy.props.BoolProperty(
|
||||
name="Force ⊥ to Face",
|
||||
description="Constrain subsequent dimension vertices to lie on the line through the first vertex along its face normal",
|
||||
default=False,
|
||||
)
|
||||
tag_rotation_mode: bpy.props.EnumProperty(
|
||||
name="Tag Rotation Mode",
|
||||
description="How to orient the tag relative to the tagged object",
|
||||
|
||||
@@ -251,6 +251,11 @@ 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"}
|
||||
if object_type in _DIMENSION_TYPES:
|
||||
row = cls.layout.row(align=True)
|
||||
row.prop(cls.props, "force_perpendicular_to_face", toggle=True)
|
||||
|
||||
if object_type in tool.Drawing.ANNOTATION_TYPES_SUPPORT_SETUP:
|
||||
row = cls.layout.row(align=True)
|
||||
row.label(text="", icon="DRIVER_ROTATIONAL_DIFFERENCE")
|
||||
|
||||
@@ -88,6 +88,51 @@ class DisablePsetEditing(bpy.types.Operator, tool.Ifc.Operator):
|
||||
props.active_pset_type = "-"
|
||||
|
||||
|
||||
def _regenerate_parametric_dimension(file, annotation):
|
||||
"""Regenerate a single parametric dimension annotation after a pset edit."""
|
||||
print(f"[regen_dim] called for annotation={annotation.id()} {annotation.is_a()}")
|
||||
try:
|
||||
import json
|
||||
import numpy as np
|
||||
import ifcopenshell.util.element
|
||||
import ifcopenshell.api.drawing as drawing_api
|
||||
import bonsai.tool as _tool
|
||||
from bonsai.bim.module.drawing.operator import _update_blender_curve
|
||||
|
||||
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"):
|
||||
print("[regen_dim] no Anchors — skipping")
|
||||
return
|
||||
|
||||
anchors = json.loads(pset_data["Anchors"])
|
||||
print(f"[regen_dim] {len(anchors)} anchors")
|
||||
placement_override = {}
|
||||
for a in anchors:
|
||||
guid = a.get("guid")
|
||||
if not guid:
|
||||
continue
|
||||
try:
|
||||
elem = file.by_guid(guid)
|
||||
elem_obj = _tool.Ifc.get_object(elem)
|
||||
if elem_obj:
|
||||
placement_override[elem.id()] = np.array(elem_obj.matrix_world)
|
||||
print(f"[regen_dim] placement_override added for {elem.is_a()} id={elem.id()}")
|
||||
except Exception as e:
|
||||
print(f"[regen_dim] placement_override error: {e}")
|
||||
|
||||
resolved_pts = drawing_api.regenerate_dimension(
|
||||
file, annotation, placement_override=placement_override
|
||||
)
|
||||
print(f"[regen_dim] resolved_pts={resolved_pts}")
|
||||
if resolved_pts:
|
||||
_update_blender_curve(annotation, resolved_pts)
|
||||
print("[regen_dim] _update_blender_curve done")
|
||||
except Exception:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
class EditPset(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.edit_pset"
|
||||
bl_label = "Edit Pset"
|
||||
@@ -152,7 +197,15 @@ class EditPset(bpy.types.Operator, tool.Ifc.Operator):
|
||||
)
|
||||
if tool.Cost.has_schedules():
|
||||
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")
|
||||
|
||||
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:
|
||||
_regenerate_parametric_dimension(self.file, element)
|
||||
|
||||
tool.Blender.update_viewport()
|
||||
|
||||
|
||||
|
||||
@@ -16,10 +16,10 @@
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""Regenerate a parametric dimension annotation from its BBIM_DimensionTarget anchors.
|
||||
"""Regenerate a parametric dimension annotation from its BBIM_Dimension anchors.
|
||||
|
||||
This module operates purely on IFC data. It:
|
||||
1. Reads the ``Anchors`` JSON array from the ``BBIM_DimensionTarget`` pset on an
|
||||
1. Reads the ``Anchors`` JSON array from the ``BBIM_Dimension`` pset on an
|
||||
``IfcAnnotation``.
|
||||
2. Resolves each anchor to a world-space point (IFC project units) using
|
||||
``resolve_anchor``.
|
||||
@@ -48,7 +48,7 @@ import ifcopenshell.util.element
|
||||
from .resolve_anchor import resolve_anchor
|
||||
|
||||
|
||||
_PSET_NAME = "BBIM_DimensionTarget"
|
||||
_PSET_NAME = "BBIM_Dimension"
|
||||
_METRIC_INTENT_PREFIX = "PARAMETRIC_DIMENSION_SEG_"
|
||||
|
||||
|
||||
@@ -61,12 +61,12 @@ def regenerate_dimension(
|
||||
) -> list[tuple[float, float, float]]:
|
||||
"""Regenerate a parametric dimension from its stored anchor references.
|
||||
|
||||
Resolves every anchor in ``BBIM_DimensionTarget.Anchors``, updates the
|
||||
Resolves every anchor in ``BBIM_Dimension.Anchors``, updates the
|
||||
per-segment ``IfcMetric`` values (creating them when absent), and returns
|
||||
the resolved world-space points in metres.
|
||||
|
||||
:param file: The open IFC file.
|
||||
:param annotation: An ``IfcAnnotation`` with a ``BBIM_DimensionTarget`` pset.
|
||||
:param annotation: An ``IfcAnnotation`` with a ``BBIM_Dimension`` pset.
|
||||
:param settings: Geometry settings for tessellation (shared across calls).
|
||||
:param shape_cache: Shape cache dict (shared across calls for performance).
|
||||
:param placement_override: Optional dict mapping element STEP id → 4×4 numpy
|
||||
@@ -99,6 +99,25 @@ def regenerate_dimension(
|
||||
resolved.append(pt)
|
||||
anchor["pt"] = list(pt)
|
||||
|
||||
# ForcePerpendicularToFace: project vertices 1…n onto the line through
|
||||
# pt[0] in the direction of anchor[0]'s face normal, so the polyline is
|
||||
# constrained perpendicular to the face the first vertex is anchored to.
|
||||
if pset_data.get("ForcePerpendicularToFace") and len(resolved) >= 2 and resolved[0] is not None:
|
||||
normal = _get_anchor_face_normal_world(file, anchors[0], placement_override)
|
||||
if normal:
|
||||
base = resolved[0]
|
||||
for i in range(1, len(resolved)):
|
||||
if resolved[i] is None:
|
||||
continue
|
||||
pt = resolved[i]
|
||||
t = ((pt[0] - base[0]) * normal[0]
|
||||
+ (pt[1] - base[1]) * normal[1]
|
||||
+ (pt[2] - base[2]) * normal[2])
|
||||
resolved[i] = (base[0] + t * normal[0],
|
||||
base[1] + t * normal[1],
|
||||
base[2] + t * normal[2])
|
||||
anchors[i]["pt"] = list(resolved[i])
|
||||
|
||||
pset_entity_id = pset_data.get("id")
|
||||
if pset_entity_id:
|
||||
pset_entity = file.by_id(pset_entity_id)
|
||||
@@ -122,7 +141,7 @@ def get_dimension_segment_lengths(
|
||||
) -> list[float]:
|
||||
"""Return the segment lengths for a parametric dimension from stored anchor pts.
|
||||
|
||||
Distances are computed from the cached ``pt`` fields in ``BBIM_DimensionTarget.Anchors``
|
||||
Distances are computed from the cached ``pt`` fields in ``BBIM_Dimension.Anchors``
|
||||
(in metres, matching ifcopenshell.geom output). Returns an empty list if the pset
|
||||
is absent or malformed.
|
||||
"""
|
||||
@@ -236,3 +255,39 @@ def _sync_segment_metrics(
|
||||
|
||||
def _dist(a: tuple, b: tuple) -> float:
|
||||
return math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2)
|
||||
|
||||
|
||||
def _get_anchor_face_normal_world(
|
||||
file: ifcopenshell.file,
|
||||
anchor: dict,
|
||||
placement_override: Optional[dict] = None,
|
||||
) -> Optional[tuple[float, float, float]]:
|
||||
"""Return the world-space unit face normal stored in a FACE anchor, or None.
|
||||
|
||||
Prefers ``normal_local`` (element-local, rotation-invariant) transformed by
|
||||
the current element placement. Falls back to the stored world-space normal.
|
||||
"""
|
||||
if anchor.get("type") != "FACE":
|
||||
return None
|
||||
guid = anchor.get("guid")
|
||||
if not guid:
|
||||
return None
|
||||
fp = (anchor.get("addr") or {}).get("fingerprint") or {}
|
||||
|
||||
normal_local = fp.get("normal_local")
|
||||
if normal_local:
|
||||
try:
|
||||
element = file.by_guid(guid)
|
||||
except Exception:
|
||||
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")
|
||||
if normal_world:
|
||||
mag = math.sqrt(sum(x * x for x in normal_world))
|
||||
return tuple(x / mag for x in normal_world) if mag > 1e-12 else None # type: ignore[return-value]
|
||||
|
||||
return None
|
||||
|
||||
@@ -26,7 +26,7 @@ therefore stored in metres, which is also Blender world space. The IFC
|
||||
project's unit_scale is NOT applied here. Callers that need IFC project units
|
||||
must divide by ``ifcopenshell.util.unit.calculate_unit_scale(file)`` themselves.
|
||||
|
||||
Anchor schema (JSON-serialisable dict stored in BBIM_DimensionTarget.Anchors):
|
||||
Anchor schema (JSON-serialisable dict stored in BBIM_Dimension.Anchors):
|
||||
|
||||
{
|
||||
"guid": str | None, # element GlobalId; None → WORLD type (free point)
|
||||
@@ -135,15 +135,28 @@ def resolve_anchor(
|
||||
for gp in group_props
|
||||
]
|
||||
|
||||
# TESS_INDEX (fast, index into the cached face-group list)
|
||||
tess_index = addr.get("tess_index", -1)
|
||||
if 0 <= tess_index < len(groups):
|
||||
return world_group_props[tess_index]["centroid"]
|
||||
|
||||
# TESS_FINGERPRINT (robust across topology changes)
|
||||
fingerprint = addr.get("fingerprint")
|
||||
hint = anchor.get("hint")
|
||||
if fingerprint:
|
||||
fp_normal_local = fingerprint.get("normal_local") if fingerprint else None
|
||||
|
||||
# TESS_INDEX fast path — only accept when the local fingerprint normal still
|
||||
# matches at that index, guarding against face-group reordering after any
|
||||
# geometry edit or profile change.
|
||||
tess_index = addr.get("tess_index", -1)
|
||||
if 0 <= tess_index < len(groups):
|
||||
candidate_local = group_props[tess_index]
|
||||
if fp_normal_local is None or _dot(candidate_local["normal"], fp_normal_local) >= 1.0 - _NORMAL_MATCH_THRESHOLD:
|
||||
return world_group_props[tess_index]["centroid"]
|
||||
# Local-normal mismatch — face groups reordered; fall through to fingerprint.
|
||||
|
||||
# TESS_FINGERPRINT — match by element-local normal (rotation-invariant).
|
||||
if fp_normal_local:
|
||||
pt = _find_by_local_normal(group_props, world_group_props, fp_normal_local, hint)
|
||||
if pt is not None:
|
||||
return pt
|
||||
elif fingerprint:
|
||||
# Legacy anchors built before normal_local was stored: fall back to
|
||||
# world-space normal matching (not rotation-invariant, but best we can do).
|
||||
pt = _find_by_fingerprint(world_group_props, fingerprint, hint)
|
||||
if pt is not None:
|
||||
return pt
|
||||
@@ -174,7 +187,7 @@ def build_anchor_from_hit(
|
||||
:param shape_cache: Mutable shape-cache dict.
|
||||
:param placement_override: Optional dict mapping element STEP id → 4×4 numpy
|
||||
matrix (metres). See ``resolve_anchor`` for details.
|
||||
:return: Anchor dict ready for JSON serialisation into BBIM_DimensionTarget.
|
||||
:return: Anchor dict ready for JSON serialisation into BBIM_Dimension.
|
||||
"""
|
||||
shape = _get_shape(file, element, settings, shape_cache)
|
||||
|
||||
@@ -201,12 +214,17 @@ def build_anchor_from_hit(
|
||||
if best is not None:
|
||||
tess_index, props = best
|
||||
fingerprint = {
|
||||
# normal_local: element-local normal — rotation-invariant primary key.
|
||||
"normal_local": list(local_group_props[tess_index]["normal"]),
|
||||
# world-space fields kept for legacy / disambiguation.
|
||||
"normal": list(props["normal"]),
|
||||
"area": props["area"],
|
||||
"centroid": list(props["centroid"]),
|
||||
}
|
||||
|
||||
repr_type, repr_id, face_role = _detect_extruded_face(file, element, hit_location_ifc, hit_normal_ifc)
|
||||
repr_type, repr_id, face_role = _detect_extruded_face(
|
||||
file, element, hit_location_ifc, hit_normal_ifc, placement_override
|
||||
)
|
||||
method = "ANALYTIC" if repr_type == "IfcExtrudedAreaSolid" else "TESS_FINGERPRINT"
|
||||
|
||||
return {
|
||||
@@ -322,6 +340,35 @@ def _rotate_local_to_world(
|
||||
)
|
||||
|
||||
|
||||
def _world_normal_to_elem_local(
|
||||
file: ifcopenshell.file,
|
||||
element: ifcopenshell.entity_instance,
|
||||
world_normal: tuple,
|
||||
placement_override: Optional[dict] = None,
|
||||
) -> tuple[float, float, float]:
|
||||
"""Rotate a world-space direction into element-local space (rotation only, no translation).
|
||||
|
||||
Uses placement_override (Blender matrix_world) when available so that
|
||||
elements moved/rotated in the viewport are handled correctly.
|
||||
"""
|
||||
x, y, z = float(world_normal[0]), float(world_normal[1]), float(world_normal[2])
|
||||
if placement_override is not None and element.id() in placement_override:
|
||||
m = placement_override[element.id()]
|
||||
# Inverse rotation = transpose of the 3×3 rotation block.
|
||||
lx = float(m[0][0]) * x + float(m[1][0]) * y + float(m[2][0]) * z
|
||||
ly = float(m[0][1]) * x + float(m[1][1]) * y + float(m[2][1]) * z
|
||||
lz = float(m[0][2]) * x + float(m[1][2]) * y + float(m[2][2]) * z
|
||||
else:
|
||||
m = ifcopenshell.util.placement.get_local_placement(element.ObjectPlacement)
|
||||
lx = float(m[0][0]) * x + float(m[1][0]) * y + float(m[2][0]) * z
|
||||
ly = float(m[0][1]) * x + float(m[1][1]) * y + float(m[2][1]) * z
|
||||
lz = float(m[0][2]) * x + float(m[1][2]) * y + float(m[2][2]) * z
|
||||
mag = math.sqrt(lx * lx + ly * ly + lz * lz)
|
||||
if mag > 1e-12:
|
||||
return (lx / mag, ly / mag, lz / mag)
|
||||
return (x, y, z)
|
||||
|
||||
|
||||
def _extract_mesh(shape) -> tuple[list[tuple], list[tuple]]:
|
||||
"""Return (verts, tris) from a tessellated shape."""
|
||||
vf = shape.geometry.verts
|
||||
@@ -511,8 +558,36 @@ def _best_group(
|
||||
return best
|
||||
|
||||
|
||||
def _find_by_local_normal(
|
||||
local_group_props: list[dict],
|
||||
world_group_props: list[dict],
|
||||
fp_normal_local: list,
|
||||
hint: Optional[list],
|
||||
) -> Optional[tuple[float, float, float]]:
|
||||
"""Return the world-space centroid of the face group whose element-local normal
|
||||
best matches *fp_normal_local*. Matching in local space is rotation-invariant —
|
||||
moving or rotating the element does not change local normals, so the anchor
|
||||
correctly tracks the same face through placement changes and profile edits."""
|
||||
best_score = -1.0
|
||||
best_centroid = None
|
||||
|
||||
for i, lp in enumerate(local_group_props):
|
||||
dot_val = _dot(lp["normal"], fp_normal_local)
|
||||
if dot_val < 1.0 - _NORMAL_MATCH_THRESHOLD:
|
||||
continue
|
||||
score = dot_val
|
||||
if hint:
|
||||
hint_dist = _dist(world_group_props[i]["centroid"], hint)
|
||||
score -= hint_dist / max(_CENTROID_MAX_DIST, 0.001) * 0.1
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_centroid = world_group_props[i]["centroid"]
|
||||
|
||||
return best_centroid
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Analytical resolution — IfcExtrudedAreaSolid TOP / BOTTOM
|
||||
# Analytical resolution — IfcExtrudedAreaSolid TOP / BOTTOM / SIDE_*
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -522,9 +597,16 @@ def _resolve_extruded_area_solid_analytic(
|
||||
addr: dict,
|
||||
placement_override: Optional[dict] = None,
|
||||
) -> Optional[tuple[float, float, float]]:
|
||||
"""Analytically resolve TOP or BOTTOM face centre of an IfcExtrudedAreaSolid."""
|
||||
"""Analytically resolve a face centre of an IfcExtrudedAreaSolid.
|
||||
|
||||
Handles TOP, BOTTOM, and SIDE_PLUS_X / SIDE_MINUS_X / SIDE_PLUS_Y / SIDE_MINUS_Y
|
||||
roles. Side-face roles are only supported for IfcRectangleProfileDef; other
|
||||
profile types fall back to tessellation fingerprint matching.
|
||||
"""
|
||||
face_role = addr.get("face_role", "")
|
||||
if face_role not in ("TOP", "BOTTOM"):
|
||||
_top_bottom = ("TOP", "BOTTOM")
|
||||
_sides = ("SIDE_PLUS_X", "SIDE_MINUS_X", "SIDE_PLUS_Y", "SIDE_MINUS_Y")
|
||||
if face_role not in _top_bottom + _sides:
|
||||
return None
|
||||
|
||||
repr_id = addr.get("repr_id")
|
||||
@@ -540,18 +622,64 @@ def _resolve_extruded_area_solid_analytic(
|
||||
return None
|
||||
|
||||
try:
|
||||
profile_centroid_local = _profile_centroid(solid.SweptArea)
|
||||
profile = solid.SweptArea
|
||||
dir_ratios = solid.ExtrudedDirection.DirectionRatios
|
||||
depth = solid.Depth
|
||||
depth = float(solid.Depth)
|
||||
|
||||
mag = math.sqrt(sum(d * d for d in dir_ratios))
|
||||
if mag < 1e-12:
|
||||
return None
|
||||
dir_vec = tuple(d / mag for d in dir_ratios)
|
||||
|
||||
px = profile_centroid_local[0] + dir_vec[0] * (depth if face_role == "TOP" else 0.0)
|
||||
py = profile_centroid_local[1] + dir_vec[1] * (depth if face_role == "TOP" else 0.0)
|
||||
pz = dir_vec[2] * (depth if face_role == "TOP" else 0.0)
|
||||
if face_role in _top_bottom:
|
||||
profile_centroid_local = _profile_centroid(profile)
|
||||
scale = depth if face_role == "TOP" else 0.0
|
||||
px = profile_centroid_local[0] + dir_vec[0] * scale
|
||||
py = profile_centroid_local[1] + dir_vec[1] * scale
|
||||
pz = dir_vec[2] * scale
|
||||
|
||||
else: # SIDE_* — only for IfcRectangleProfileDef
|
||||
if not profile.is_a("IfcRectangleProfileDef"):
|
||||
return None
|
||||
|
||||
x_dim = float(profile.XDim)
|
||||
y_dim = float(profile.YDim)
|
||||
half_depth = depth / 2.0
|
||||
|
||||
# Profile centre and local axes (from profile.Position 2D placement).
|
||||
cx, cy = 0.0, 0.0
|
||||
px_axis = (1.0, 0.0) # profile X in profile 2D
|
||||
if hasattr(profile, "Position") and profile.Position:
|
||||
loc = profile.Position.Location
|
||||
cx = float(loc.Coordinates[0])
|
||||
cy = float(loc.Coordinates[1])
|
||||
if profile.Position.RefDirection:
|
||||
pr = profile.Position.RefDirection.DirectionRatios
|
||||
pm = math.sqrt(pr[0] ** 2 + pr[1] ** 2)
|
||||
if pm > 1e-12:
|
||||
px_axis = (pr[0] / pm, pr[1] / pm)
|
||||
py_axis = (-px_axis[1], px_axis[0]) # 90° rotation
|
||||
|
||||
half_x = x_dim / 2.0
|
||||
half_y = y_dim / 2.0
|
||||
|
||||
if face_role == "SIDE_PLUS_X":
|
||||
fx = cx + half_x * px_axis[0]
|
||||
fy = cy + half_x * px_axis[1]
|
||||
elif face_role == "SIDE_MINUS_X":
|
||||
fx = cx - half_x * px_axis[0]
|
||||
fy = cy - half_x * px_axis[1]
|
||||
elif face_role == "SIDE_PLUS_Y":
|
||||
fx = cx + half_y * py_axis[0]
|
||||
fy = cy + half_y * py_axis[1]
|
||||
else: # SIDE_MINUS_Y
|
||||
fx = cx - half_y * py_axis[0]
|
||||
fy = cy - half_y * py_axis[1]
|
||||
|
||||
# Lift from profile 2D to solid-local 3D at mid-extrusion depth.
|
||||
px = fx + dir_vec[0] * half_depth
|
||||
py = fy + dir_vec[1] * half_depth
|
||||
pz = dir_vec[2] * half_depth
|
||||
|
||||
if solid.Position:
|
||||
local_pt = _apply_axis2placement3d(solid.Position, (px, py, pz))
|
||||
@@ -638,21 +766,27 @@ def _detect_extruded_face(
|
||||
element: ifcopenshell.entity_instance,
|
||||
hit_location: tuple,
|
||||
hit_normal: tuple,
|
||||
placement_override: Optional[dict] = None,
|
||||
) -> tuple[str, int, str]:
|
||||
"""Try to identify if the hit face is a TOP or BOTTOM of an IfcExtrudedAreaSolid.
|
||||
"""Identify if the hit face is a face of an IfcExtrudedAreaSolid.
|
||||
|
||||
Returns (repr_type, repr_id, face_role).
|
||||
repr_type is empty string if not detected as extruded solid.
|
||||
face_role is one of: 'TOP', 'BOTTOM', 'SIDE_PLUS_X', 'SIDE_MINUS_X',
|
||||
'SIDE_PLUS_Y', 'SIDE_MINUS_Y', or '' (not recognized).
|
||||
Side roles are only returned for IfcRectangleProfileDef.
|
||||
"""
|
||||
if not hasattr(element, "Representation") or not element.Representation:
|
||||
return ("", -1, "")
|
||||
|
||||
# Transform hit_normal from world → element-local for accurate role classification.
|
||||
hit_normal_elem = _world_normal_to_elem_local(file, element, hit_normal, placement_override)
|
||||
|
||||
for rep in element.Representation.Representations:
|
||||
for item in rep.Items:
|
||||
solid = _unwrap_mapped(item)
|
||||
if not solid or not solid.is_a("IfcExtrudedAreaSolid"):
|
||||
continue
|
||||
role = _extruded_face_role(solid, hit_normal)
|
||||
role = _extruded_face_role(solid, hit_normal_elem)
|
||||
if role:
|
||||
return ("IfcExtrudedAreaSolid", solid.id(), role)
|
||||
|
||||
@@ -667,19 +801,99 @@ def _unwrap_mapped(item):
|
||||
return item
|
||||
|
||||
|
||||
def _extruded_face_role(solid, hit_normal: tuple) -> str:
|
||||
"""Return 'TOP', 'BOTTOM', or '' based on whether hit_normal aligns with extrusion."""
|
||||
def _apply_axis2placement3d_rotation_inv(placement, vec: tuple) -> tuple[float, float, float]:
|
||||
"""Apply the inverse rotation of an IfcAxis2Placement3D to a direction.
|
||||
|
||||
Transforms a direction from element-local space into solid-local space.
|
||||
The rotation matrix R = [x_axis | y_axis | z_axis]; its inverse for an
|
||||
orthogonal matrix is R^T, computed here by dotting with each basis vector.
|
||||
"""
|
||||
if placement is None:
|
||||
return vec
|
||||
|
||||
x, y, z = float(vec[0]), float(vec[1]), float(vec[2])
|
||||
|
||||
if placement.Axis:
|
||||
zr = placement.Axis.DirectionRatios
|
||||
zm = math.sqrt(zr[0] ** 2 + zr[1] ** 2 + zr[2] ** 2)
|
||||
zx, zy, zz = (zr[0] / zm, zr[1] / zm, zr[2] / zm) if zm > 1e-12 else (0.0, 0.0, 1.0)
|
||||
else:
|
||||
zx, zy, zz = 0.0, 0.0, 1.0
|
||||
|
||||
if placement.RefDirection:
|
||||
xr = placement.RefDirection.DirectionRatios
|
||||
xm = math.sqrt(xr[0] ** 2 + xr[1] ** 2 + xr[2] ** 2)
|
||||
xx, xy, xz = (xr[0] / xm, xr[1] / xm, xr[2] / xm) if xm > 1e-12 else (1.0, 0.0, 0.0)
|
||||
else:
|
||||
xx, xy, xz = 1.0, 0.0, 0.0
|
||||
|
||||
# Y = Z × X
|
||||
yx = zy * xz - zz * xy
|
||||
yy = zz * xx - zx * xz
|
||||
yz = zx * xy - zy * xx
|
||||
|
||||
# R^T: dot input with each column of R (= each basis axis of the placement).
|
||||
inv_x = xx * x + xy * y + xz * z
|
||||
inv_y = yx * x + yy * y + yz * z
|
||||
inv_z = zx * x + zy * y + zz * z
|
||||
|
||||
mag = math.sqrt(inv_x ** 2 + inv_y ** 2 + inv_z ** 2)
|
||||
if mag > 1e-12:
|
||||
return (inv_x / mag, inv_y / mag, inv_z / mag)
|
||||
return vec
|
||||
|
||||
|
||||
def _extruded_face_role(solid, hit_normal_elem_local: tuple) -> str:
|
||||
"""Classify the hit face role on an IfcExtrudedAreaSolid.
|
||||
|
||||
Returns 'TOP', 'BOTTOM', 'SIDE_PLUS_X', 'SIDE_MINUS_X', 'SIDE_PLUS_Y',
|
||||
'SIDE_MINUS_Y', or ''. Side roles require IfcRectangleProfileDef.
|
||||
|
||||
:param hit_normal_elem_local: Face normal in element-local space.
|
||||
"""
|
||||
try:
|
||||
# Map from element-local to solid-local via solid.Position inverse rotation.
|
||||
hit_normal_solid = _apply_axis2placement3d_rotation_inv(solid.Position, hit_normal_elem_local)
|
||||
|
||||
dr = solid.ExtrudedDirection.DirectionRatios
|
||||
mag = math.sqrt(sum(d * d for d in dr))
|
||||
if mag < 1e-12:
|
||||
return ""
|
||||
extrude_dir = tuple(d / mag for d in dr)
|
||||
dot_val = _dot(extrude_dir, hit_normal)
|
||||
if dot_val > 0.99:
|
||||
|
||||
dot_extrude = _dot(extrude_dir, hit_normal_solid)
|
||||
if dot_extrude > 0.99:
|
||||
return "TOP"
|
||||
if dot_val < -0.99:
|
||||
if dot_extrude < -0.99:
|
||||
return "BOTTOM"
|
||||
|
||||
# Side face detection — only supported for IfcRectangleProfileDef.
|
||||
if not solid.SweptArea.is_a("IfcRectangleProfileDef"):
|
||||
return ""
|
||||
|
||||
profile = solid.SweptArea
|
||||
|
||||
# Profile X axis in solid-local 2D (from profile.Position.RefDirection).
|
||||
px_axis = (1.0, 0.0)
|
||||
if hasattr(profile, "Position") and profile.Position and profile.Position.RefDirection:
|
||||
pr = profile.Position.RefDirection.DirectionRatios
|
||||
pm = math.sqrt(pr[0] ** 2 + pr[1] ** 2)
|
||||
if pm > 1e-12:
|
||||
px_axis = (pr[0] / pm, pr[1] / pm)
|
||||
py_axis = (-px_axis[1], px_axis[0]) # 90° CCW
|
||||
|
||||
# Lift 2D profile axes to solid-local 3D (profile is in the solid XY plane).
|
||||
px_3d = (px_axis[0], px_axis[1], 0.0)
|
||||
py_3d = (py_axis[0], py_axis[1], 0.0)
|
||||
|
||||
dot_x = _dot(hit_normal_solid, px_3d)
|
||||
dot_y = _dot(hit_normal_solid, py_3d)
|
||||
|
||||
if abs(dot_x) > 0.99:
|
||||
return "SIDE_PLUS_X" if dot_x > 0 else "SIDE_MINUS_X"
|
||||
if abs(dot_y) > 0.99:
|
||||
return "SIDE_PLUS_Y" if dot_y > 0 else "SIDE_MINUS_Y"
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
Reference in New Issue
Block a user