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.);
|
#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.);
|
#22=IFCSIMPLEPROPERTYTEMPLATE('0d53LEtgLDQxnv__NfgH7i',$,'PatternsPath','Default patterns SVG',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
|
||||||
#23=IFCSIMPLEPROPERTYTEMPLATE('26qFNMv7nCHgU6Jd7Anga5',$,'ShadingStylesPath','Default shading styles',.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.);
|
#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.);
|
#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.);
|
#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.);
|
#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.);
|
#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.);
|
#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));
|
#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('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.);
|
#38=IFCSIMPLEPROPERTYTEMPLATE('2YqSmLoU3tHvX8wZdaNrjc',$,'MeasureAxis','Axis along which distances are projected: X | Y | Z | TRUE | PERPENDICULAR',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
|
||||||
#39=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;
|
ENDSEC;
|
||||||
END-ISO-10303-21;
|
END-ISO-10303-21;
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import bonsai.tool as tool
|
|||||||
_dim_guid_index: dict = {}
|
_dim_guid_index: dict = {}
|
||||||
# Persistent tessellation cache for the depsgraph handler (element id → shape).
|
# Persistent tessellation cache for the depsgraph handler (element id → shape).
|
||||||
_dim_shape_cache: dict = {}
|
_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
|
_dim_index_dirty: bool = True
|
||||||
# Re-entry guard so curve updates don't trigger a second handler call.
|
# Re-entry guard so curve updates don't trigger a second handler call.
|
||||||
_dim_handler_running: bool = False
|
_dim_handler_running: bool = False
|
||||||
@@ -52,7 +52,7 @@ def _rebuild_dim_guid_index(file) -> None:
|
|||||||
|
|
||||||
_dim_guid_index = {}
|
_dim_guid_index = {}
|
||||||
for annotation in file.by_type("IfcAnnotation"):
|
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"):
|
if not pset_data or not pset_data.get("Anchors"):
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
@@ -172,7 +172,7 @@ def depsgraph_update_post_handler(scene, depsgraph):
|
|||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
pset = ifcopenshell.util.element.get_pset(annotation, "BBIM_DimensionTarget")
|
pset = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
|
||||||
if not pset:
|
if not pset:
|
||||||
continue
|
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
|
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
|
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.
|
recomputes automatically when the referenced elements move.
|
||||||
|
|
||||||
RMB or ENTER to finish; ESC to cancel without creating an annotation.
|
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)
|
PolylineOperator.__init__(self)
|
||||||
self._anchors = []
|
self._anchors = []
|
||||||
self._shape_cache = {}
|
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
|
# Snap → anchor bridge
|
||||||
|
|
||||||
def _snap_to_anchor(self, snap: dict) -> dict:
|
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
|
import ifcopenshell.api.drawing as drawing_api
|
||||||
|
|
||||||
obj = snap.get("object")
|
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))
|
hit_m = (float(pt_world.x), float(pt_world.y), float(pt_world.z))
|
||||||
|
|
||||||
face_index = snap.get("face_index")
|
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):
|
if face_index is not None and face_index < len(obj.data.polygons):
|
||||||
normal_local = obj.data.polygons[face_index].normal
|
normal_local = obj.data.polygons[face_index].normal
|
||||||
else:
|
else:
|
||||||
# Vertex / edge snap: find the closest face for a proper normal.
|
normal_local = Vector((0.0, 0.0, 1.0))
|
||||||
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_world = (obj.matrix_world.to_3x3() @ normal_local).normalized()
|
normal_world = (obj.matrix_world.to_3x3() @ normal_local).normalized()
|
||||||
normal_m = (float(normal_world.x), float(normal_world.y), float(normal_world.z))
|
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
|
import ifcopenshell.api.drawing as drawing_api
|
||||||
pt = polyline_data[0].polyline_points[-1]
|
pt = polyline_data[0].polyline_points[-1]
|
||||||
self._anchors.append(drawing_api.make_world_anchor([float(pt.x), float(pt.y), float(pt.z)]))
|
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:
|
elif count_after < count_before and self._anchors:
|
||||||
# BACKSPACE removed a point.
|
# BACKSPACE removed a point.
|
||||||
self._anchors.pop()
|
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:
|
def _create_dimension_from_polyline(self, context) -> None:
|
||||||
import ifcopenshell.api.drawing as drawing_api
|
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)])))
|
anchors.append(drawing_api.make_world_anchor(list(resolved_pts_m[len(anchors)])))
|
||||||
|
|
||||||
file = tool.Ifc.get()
|
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_Dimension")
|
||||||
pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_DimensionTarget")
|
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"])
|
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
|
from bonsai.bim.module.drawing import handler as _drawing_handler
|
||||||
_drawing_handler.invalidate_dim_index()
|
_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.handle_mouse_move(context, event)
|
||||||
self.choose_axis(event)
|
self.choose_axis(event)
|
||||||
self.handle_snap_selection(context, event)
|
self.handle_snap_selection(context, event)
|
||||||
|
self._apply_perp_constraint()
|
||||||
|
|
||||||
if (
|
if (
|
||||||
not self.tool_state.is_input_on
|
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):
|
def _invoke(self, context, event):
|
||||||
super().invoke(context, event)
|
super().invoke(context, event)
|
||||||
|
self._force_perpendicular = tool.Drawing.get_annotation_props().force_perpendicular_to_face
|
||||||
return {"RUNNING_MODAL"}
|
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):
|
class SetDimensionAnchor(bpy.types.Operator):
|
||||||
"""Interactively anchor dimension vertices to IFC element faces.
|
"""Interactively anchor dimension vertices to IFC element faces.
|
||||||
|
|
||||||
Two-phase modal workflow (all in Object Mode, no Tab required):
|
Two-phase modal workflow (all in Object Mode):
|
||||||
1. Run the operator with a dimension annotation selected.
|
1. Run the operator with a dimension annotation selected.
|
||||||
2. Click a vertex ON the dimension line to select it.
|
2. Click a vertex ON the dimension line to select it.
|
||||||
3. Click an IFC element face to anchor that vertex to it.
|
3. Hover over IFC elements — the nearest candidate is highlighted.
|
||||||
ALT+click sets a free world-point anchor instead.
|
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.
|
4. Repeat steps 2-3 for more vertices.
|
||||||
5. RMB or ESC to finish.
|
5. RMB or ESC to finish.
|
||||||
"""
|
"""
|
||||||
@@ -5816,8 +5943,17 @@ class SetDimensionAnchor(bpy.types.Operator):
|
|||||||
_phase: str = "PICK_VERTEX" # "PICK_VERTEX" | "PICK_FACE"
|
_phase: str = "PICK_VERTEX" # "PICK_VERTEX" | "PICK_FACE"
|
||||||
_active_vertex_idx: int = -1
|
_active_vertex_idx: int = -1
|
||||||
_shape_cache: dict
|
_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
|
@classmethod
|
||||||
def poll(cls, context):
|
def poll(cls, context):
|
||||||
@@ -5848,15 +5984,44 @@ class SetDimensionAnchor(bpy.types.Operator):
|
|||||||
self._phase = "PICK_VERTEX"
|
self._phase = "PICK_VERTEX"
|
||||||
self._active_vertex_idx = -1
|
self._active_vertex_idx = -1
|
||||||
self._shape_cache = {}
|
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)
|
self._set_status(context)
|
||||||
context.window_manager.modal_handler_add(self)
|
context.window_manager.modal_handler_add(self)
|
||||||
return {"RUNNING_MODAL"}
|
return {"RUNNING_MODAL"}
|
||||||
|
|
||||||
def modal(self, context, event):
|
def modal(self, context, event):
|
||||||
if event.type == "ESC" or (event.type == "RIGHTMOUSE" and event.value == "PRESS"):
|
if event.type == "ESC" or (event.type == "RIGHTMOUSE" and event.value == "PRESS"):
|
||||||
|
self._clear_hover_highlight(context)
|
||||||
context.workspace.status_text_set(None)
|
context.workspace.status_text_set(None)
|
||||||
return {"FINISHED"} # keep any anchors already written
|
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 event.type == "LEFTMOUSE" and event.value == "PRESS":
|
||||||
if self._phase == "PICK_VERTEX":
|
if self._phase == "PICK_VERTEX":
|
||||||
self._handle_vertex_pick(context, event)
|
self._handle_vertex_pick(context, event)
|
||||||
@@ -5876,9 +6041,11 @@ class SetDimensionAnchor(bpy.types.Operator):
|
|||||||
"Click a dimension vertex | RMB / ESC: Finish"
|
"Click a dimension vertex | RMB / ESC: Finish"
|
||||||
)
|
)
|
||||||
else:
|
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(
|
context.workspace.status_text_set(
|
||||||
f"Vertex {self._active_vertex_idx} selected — "
|
f"Vertex {self._active_vertex_idx} — hover over element | "
|
||||||
"Click element face to anchor | ALT+Click: free world point | RMB / ESC: Finish"
|
"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):
|
def _handle_vertex_pick(self, context, event):
|
||||||
from bpy_extras import view3d_utils
|
from bpy_extras import view3d_utils
|
||||||
|
|
||||||
region = context.region
|
region = self._region
|
||||||
rv3d = context.region_data
|
rv3d = self._rv3d
|
||||||
if not region or not rv3d:
|
if not region or not rv3d:
|
||||||
return
|
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
|
obj = self._annotation_obj
|
||||||
|
|
||||||
best_idx = None
|
best_idx = None
|
||||||
@@ -5921,44 +6090,61 @@ class SetDimensionAnchor(bpy.types.Operator):
|
|||||||
# Phase 2: pick a face on an IFC element
|
# Phase 2: pick a face on an IFC element
|
||||||
|
|
||||||
def _handle_face_pick(self, context, event):
|
def _handle_face_pick(self, context, event):
|
||||||
from bpy_extras import view3d_utils
|
from mathutils import Vector
|
||||||
|
|
||||||
region = context.region
|
region = self._region
|
||||||
rv3d = context.region_data
|
rv3d = self._rv3d
|
||||||
if not region or not rv3d:
|
if not region or not rv3d:
|
||||||
return
|
return
|
||||||
|
|
||||||
coord = (event.mouse_region_x, event.mouse_region_y)
|
# ALT+click → free world-point anchor at any mesh surface.
|
||||||
|
|
||||||
# ALT+click → free world-point anchor at the cursor 3D location
|
|
||||||
if event.alt:
|
if event.alt:
|
||||||
origin = view3d_utils.region_2d_to_origin_3d(region, rv3d, coord)
|
self._clear_hover_highlight(context)
|
||||||
direction = view3d_utils.region_2d_to_vector_3d(region, rv3d, coord)
|
origin, direction = self._unproject_coord(
|
||||||
hit, location, *_ = context.scene.ray_cast(context.view_layer.depsgraph, origin, direction)
|
(event.mouse_x - region.x, event.mouse_y - region.y)
|
||||||
pt_m = tuple(location) if hit else tuple(origin + direction * 5.0)
|
)
|
||||||
|
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
|
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._write_anchor(anchor, self._active_vertex_idx)
|
||||||
self.report({"INFO"}, f"Vertex {self._active_vertex_idx} → free world point")
|
self.report({"INFO"}, f"Vertex {self._active_vertex_idx} → free world point")
|
||||||
self._phase = "PICK_VERTEX"
|
self._phase = "PICK_VERTEX"
|
||||||
return
|
return
|
||||||
|
|
||||||
# Normal click → raycast for IFC element face
|
# Normal click — use whichever candidate is currently highlighted.
|
||||||
origin = view3d_utils.region_2d_to_origin_3d(region, rv3d, coord)
|
self._clear_hover_highlight(context)
|
||||||
direction = view3d_utils.region_2d_to_vector_3d(region, rv3d, coord)
|
|
||||||
|
|
||||||
hit, location, normal, face_index, hit_obj, _ = context.scene.ray_cast(
|
coord = (event.mouse_x - region.x, event.mouse_y - region.y)
|
||||||
context.view_layer.depsgraph, origin, direction
|
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")
|
self.report({"WARNING"}, "Nothing under cursor — click on a model element")
|
||||||
return
|
return
|
||||||
|
|
||||||
if hit_obj == self._annotation_obj:
|
idx = min(self._hover_index, len(self._hover_candidates) - 1)
|
||||||
self.report({"WARNING"}, "Click on an element, not the dimension line itself")
|
hit_obj, hit_mesh, hit_mesh_mx, location, normal, face_index = self._hover_candidates[idx]
|
||||||
return
|
|
||||||
|
|
||||||
element = tool.Ifc.get_entity(hit_obj)
|
element = tool.Ifc.get_entity(hit_obj)
|
||||||
if not element:
|
if not element:
|
||||||
@@ -5968,16 +6154,20 @@ class SetDimensionAnchor(bpy.types.Operator):
|
|||||||
file = tool.Ifc.get()
|
file = tool.Ifc.get()
|
||||||
hit_m = (float(location.x), float(location.y), float(location.z))
|
hit_m = (float(location.x), float(location.y), float(location.z))
|
||||||
normal_m = (float(normal.x), float(normal.y), float(normal.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)}
|
placement_override = {element.id(): np.array(hit_obj.matrix_world)}
|
||||||
|
|
||||||
import ifcopenshell.api.drawing as drawing_api
|
import ifcopenshell.api.drawing as drawing_api
|
||||||
anchor = drawing_api.build_anchor_from_hit(
|
try:
|
||||||
file, element, hit_m, normal_m,
|
anchor = drawing_api.build_anchor_from_hit(
|
||||||
shape_cache=self._shape_cache,
|
file, element, hit_m, normal_m,
|
||||||
placement_override=placement_override,
|
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._write_anchor(anchor, self._active_vertex_idx)
|
||||||
self.report(
|
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}",
|
f"Vertex {self._active_vertex_idx} → {element.is_a()}/{element.Name or element.GlobalId}",
|
||||||
)
|
)
|
||||||
self._phase = "PICK_VERTEX"
|
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)
|
# Pset write (shared by both face and free-point paths)
|
||||||
@@ -5993,7 +6353,7 @@ class SetDimensionAnchor(bpy.types.Operator):
|
|||||||
file = tool.Ifc.get()
|
file = tool.Ifc.get()
|
||||||
annotation = self._annotation
|
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"):
|
if pset_data and pset_data.get("Anchors"):
|
||||||
try:
|
try:
|
||||||
@@ -6023,19 +6383,46 @@ class SetDimensionAnchor(bpy.types.Operator):
|
|||||||
pset_entity = file.by_id(pset_data["id"])
|
pset_entity = file.by_id(pset_data["id"])
|
||||||
ifcopenshell.api.run("pset.edit_pset", file, pset=pset_entity, properties={"Anchors": anchors_json})
|
ifcopenshell.api.run("pset.edit_pset", file, pset=pset_entity, properties={"Anchors": anchors_json})
|
||||||
else:
|
else:
|
||||||
ifcopenshell.api.run("pset.add_pset", file, product=annotation, name="BBIM_DimensionTarget")
|
ifcopenshell.api.run("pset.add_pset", file, product=annotation, name="BBIM_Dimension")
|
||||||
pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_DimensionTarget")
|
pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
|
||||||
pset_entity = file.by_id(pset_data["id"])
|
pset_entity = file.by_id(pset_data["id"])
|
||||||
ifcopenshell.api.run("pset.edit_pset", file, pset=pset_entity, properties={"Anchors": anchors_json})
|
ifcopenshell.api.run("pset.edit_pset", file, pset=pset_entity, properties={"Anchors": anchors_json})
|
||||||
|
|
||||||
from bonsai.bim.module.drawing import handler as _drawing_handler
|
from bonsai.bim.module.drawing import handler as _drawing_handler
|
||||||
_drawing_handler.invalidate_dim_index()
|
_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):
|
class RegenerateDimensions(bpy.types.Operator, tool.Ifc.Operator):
|
||||||
"""Regenerate all parametric dimension annotations in the project.
|
"""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
|
anchor references from live element geometry and update the annotation's
|
||||||
curve vertices and linked IfcMetric values.
|
curve vertices and linked IfcMetric values.
|
||||||
"""
|
"""
|
||||||
@@ -6084,12 +6471,12 @@ class RegenerateDimensions(bpy.types.Operator, tool.Ifc.Operator):
|
|||||||
else:
|
else:
|
||||||
candidates = [
|
candidates = [
|
||||||
a for a in file.by_type("IfcAnnotation")
|
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
|
updated = 0
|
||||||
for annotation in candidates:
|
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:
|
if not pset:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
@@ -1051,6 +1051,11 @@ class BIMAnnotationProperties(PropertyGroup):
|
|||||||
)
|
)
|
||||||
is_adding_type: bpy.props.BoolProperty(default=False)
|
is_adding_type: bpy.props.BoolProperty(default=False)
|
||||||
type_name: bpy.props.StringProperty(name="Name", default="TYPEX")
|
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(
|
tag_rotation_mode: bpy.props.EnumProperty(
|
||||||
name="Tag Rotation Mode",
|
name="Tag Rotation Mode",
|
||||||
description="How to orient the tag relative to the tagged object",
|
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")
|
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:
|
if object_type in tool.Drawing.ANNOTATION_TYPES_SUPPORT_SETUP:
|
||||||
row = cls.layout.row(align=True)
|
row = cls.layout.row(align=True)
|
||||||
row.label(text="", icon="DRIVER_ROTATIONAL_DIFFERENCE")
|
row.label(text="", icon="DRIVER_ROTATIONAL_DIFFERENCE")
|
||||||
|
|||||||
@@ -88,6 +88,51 @@ class DisablePsetEditing(bpy.types.Operator, tool.Ifc.Operator):
|
|||||||
props.active_pset_type = "-"
|
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):
|
class EditPset(bpy.types.Operator, tool.Ifc.Operator):
|
||||||
bl_idname = "bim.edit_pset"
|
bl_idname = "bim.edit_pset"
|
||||||
bl_label = "Edit Pset"
|
bl_label = "Edit Pset"
|
||||||
@@ -152,7 +197,15 @@ class EditPset(bpy.types.Operator, tool.Ifc.Operator):
|
|||||||
)
|
)
|
||||||
if tool.Cost.has_schedules():
|
if tool.Cost.has_schedules():
|
||||||
tool.Cost.update_cost_items(pset=pset)
|
tool.Cost.update_cost_items(pset=pset)
|
||||||
|
print(f"[edit_pset] pset_name='{props.active_pset_name}' element={element.is_a()} before disable_pset_editing")
|
||||||
|
is_bbim_dimension = props.active_pset_name == "BBIM_Dimension" and element.is_a("IfcAnnotation")
|
||||||
|
|
||||||
bpy.ops.bim.disable_pset_editing(obj=self.obj, obj_type=self.obj_type)
|
bpy.ops.bim.disable_pset_editing(obj=self.obj, obj_type=self.obj_type)
|
||||||
|
|
||||||
|
print(f"[edit_pset] pset_name after disable='{props.active_pset_name}' is_bbim_dimension={is_bbim_dimension}")
|
||||||
|
if is_bbim_dimension:
|
||||||
|
_regenerate_parametric_dimension(self.file, element)
|
||||||
|
|
||||||
tool.Blender.update_viewport()
|
tool.Blender.update_viewport()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,10 @@
|
|||||||
# You should have received a copy of the GNU Lesser General Public License
|
# You should have received a copy of the GNU Lesser General Public License
|
||||||
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
|
# 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:
|
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``.
|
``IfcAnnotation``.
|
||||||
2. Resolves each anchor to a world-space point (IFC project units) using
|
2. Resolves each anchor to a world-space point (IFC project units) using
|
||||||
``resolve_anchor``.
|
``resolve_anchor``.
|
||||||
@@ -48,7 +48,7 @@ import ifcopenshell.util.element
|
|||||||
from .resolve_anchor import resolve_anchor
|
from .resolve_anchor import resolve_anchor
|
||||||
|
|
||||||
|
|
||||||
_PSET_NAME = "BBIM_DimensionTarget"
|
_PSET_NAME = "BBIM_Dimension"
|
||||||
_METRIC_INTENT_PREFIX = "PARAMETRIC_DIMENSION_SEG_"
|
_METRIC_INTENT_PREFIX = "PARAMETRIC_DIMENSION_SEG_"
|
||||||
|
|
||||||
|
|
||||||
@@ -61,12 +61,12 @@ def regenerate_dimension(
|
|||||||
) -> list[tuple[float, float, float]]:
|
) -> list[tuple[float, float, float]]:
|
||||||
"""Regenerate a parametric dimension from its stored anchor references.
|
"""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
|
per-segment ``IfcMetric`` values (creating them when absent), and returns
|
||||||
the resolved world-space points in metres.
|
the resolved world-space points in metres.
|
||||||
|
|
||||||
:param file: The open IFC file.
|
: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 settings: Geometry settings for tessellation (shared across calls).
|
||||||
:param shape_cache: Shape cache dict (shared across calls for performance).
|
:param shape_cache: Shape cache dict (shared across calls for performance).
|
||||||
:param placement_override: Optional dict mapping element STEP id → 4×4 numpy
|
:param placement_override: Optional dict mapping element STEP id → 4×4 numpy
|
||||||
@@ -99,6 +99,25 @@ def regenerate_dimension(
|
|||||||
resolved.append(pt)
|
resolved.append(pt)
|
||||||
anchor["pt"] = list(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")
|
pset_entity_id = pset_data.get("id")
|
||||||
if pset_entity_id:
|
if pset_entity_id:
|
||||||
pset_entity = file.by_id(pset_entity_id)
|
pset_entity = file.by_id(pset_entity_id)
|
||||||
@@ -122,7 +141,7 @@ def get_dimension_segment_lengths(
|
|||||||
) -> list[float]:
|
) -> list[float]:
|
||||||
"""Return the segment lengths for a parametric dimension from stored anchor pts.
|
"""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
|
(in metres, matching ifcopenshell.geom output). Returns an empty list if the pset
|
||||||
is absent or malformed.
|
is absent or malformed.
|
||||||
"""
|
"""
|
||||||
@@ -236,3 +255,39 @@ def _sync_segment_metrics(
|
|||||||
|
|
||||||
def _dist(a: tuple, b: tuple) -> float:
|
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)
|
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
|
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.
|
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)
|
"guid": str | None, # element GlobalId; None → WORLD type (free point)
|
||||||
@@ -135,15 +135,28 @@ def resolve_anchor(
|
|||||||
for gp in group_props
|
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")
|
fingerprint = addr.get("fingerprint")
|
||||||
hint = anchor.get("hint")
|
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)
|
pt = _find_by_fingerprint(world_group_props, fingerprint, hint)
|
||||||
if pt is not None:
|
if pt is not None:
|
||||||
return pt
|
return pt
|
||||||
@@ -174,7 +187,7 @@ def build_anchor_from_hit(
|
|||||||
:param shape_cache: Mutable shape-cache dict.
|
:param shape_cache: Mutable shape-cache dict.
|
||||||
:param placement_override: Optional dict mapping element STEP id → 4×4 numpy
|
:param placement_override: Optional dict mapping element STEP id → 4×4 numpy
|
||||||
matrix (metres). See ``resolve_anchor`` for details.
|
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)
|
shape = _get_shape(file, element, settings, shape_cache)
|
||||||
|
|
||||||
@@ -201,12 +214,17 @@ def build_anchor_from_hit(
|
|||||||
if best is not None:
|
if best is not None:
|
||||||
tess_index, props = best
|
tess_index, props = best
|
||||||
fingerprint = {
|
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"]),
|
"normal": list(props["normal"]),
|
||||||
"area": props["area"],
|
"area": props["area"],
|
||||||
"centroid": list(props["centroid"]),
|
"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"
|
method = "ANALYTIC" if repr_type == "IfcExtrudedAreaSolid" else "TESS_FINGERPRINT"
|
||||||
|
|
||||||
return {
|
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]]:
|
def _extract_mesh(shape) -> tuple[list[tuple], list[tuple]]:
|
||||||
"""Return (verts, tris) from a tessellated shape."""
|
"""Return (verts, tris) from a tessellated shape."""
|
||||||
vf = shape.geometry.verts
|
vf = shape.geometry.verts
|
||||||
@@ -511,8 +558,36 @@ def _best_group(
|
|||||||
return best
|
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,
|
addr: dict,
|
||||||
placement_override: Optional[dict] = None,
|
placement_override: Optional[dict] = None,
|
||||||
) -> Optional[tuple[float, float, float]]:
|
) -> 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", "")
|
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
|
return None
|
||||||
|
|
||||||
repr_id = addr.get("repr_id")
|
repr_id = addr.get("repr_id")
|
||||||
@@ -540,18 +622,64 @@ def _resolve_extruded_area_solid_analytic(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
profile_centroid_local = _profile_centroid(solid.SweptArea)
|
profile = solid.SweptArea
|
||||||
dir_ratios = solid.ExtrudedDirection.DirectionRatios
|
dir_ratios = solid.ExtrudedDirection.DirectionRatios
|
||||||
depth = solid.Depth
|
depth = float(solid.Depth)
|
||||||
|
|
||||||
mag = math.sqrt(sum(d * d for d in dir_ratios))
|
mag = math.sqrt(sum(d * d for d in dir_ratios))
|
||||||
if mag < 1e-12:
|
if mag < 1e-12:
|
||||||
return None
|
return None
|
||||||
dir_vec = tuple(d / mag for d in dir_ratios)
|
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)
|
if face_role in _top_bottom:
|
||||||
py = profile_centroid_local[1] + dir_vec[1] * (depth if face_role == "TOP" else 0.0)
|
profile_centroid_local = _profile_centroid(profile)
|
||||||
pz = dir_vec[2] * (depth if face_role == "TOP" else 0.0)
|
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:
|
if solid.Position:
|
||||||
local_pt = _apply_axis2placement3d(solid.Position, (px, py, pz))
|
local_pt = _apply_axis2placement3d(solid.Position, (px, py, pz))
|
||||||
@@ -638,21 +766,27 @@ def _detect_extruded_face(
|
|||||||
element: ifcopenshell.entity_instance,
|
element: ifcopenshell.entity_instance,
|
||||||
hit_location: tuple,
|
hit_location: tuple,
|
||||||
hit_normal: tuple,
|
hit_normal: tuple,
|
||||||
|
placement_override: Optional[dict] = None,
|
||||||
) -> tuple[str, int, str]:
|
) -> 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).
|
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:
|
if not hasattr(element, "Representation") or not element.Representation:
|
||||||
return ("", -1, "")
|
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 rep in element.Representation.Representations:
|
||||||
for item in rep.Items:
|
for item in rep.Items:
|
||||||
solid = _unwrap_mapped(item)
|
solid = _unwrap_mapped(item)
|
||||||
if not solid or not solid.is_a("IfcExtrudedAreaSolid"):
|
if not solid or not solid.is_a("IfcExtrudedAreaSolid"):
|
||||||
continue
|
continue
|
||||||
role = _extruded_face_role(solid, hit_normal)
|
role = _extruded_face_role(solid, hit_normal_elem)
|
||||||
if role:
|
if role:
|
||||||
return ("IfcExtrudedAreaSolid", solid.id(), role)
|
return ("IfcExtrudedAreaSolid", solid.id(), role)
|
||||||
|
|
||||||
@@ -667,19 +801,99 @@ def _unwrap_mapped(item):
|
|||||||
return item
|
return item
|
||||||
|
|
||||||
|
|
||||||
def _extruded_face_role(solid, hit_normal: tuple) -> str:
|
def _apply_axis2placement3d_rotation_inv(placement, vec: tuple) -> tuple[float, float, float]:
|
||||||
"""Return 'TOP', 'BOTTOM', or '' based on whether hit_normal aligns with extrusion."""
|
"""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:
|
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
|
dr = solid.ExtrudedDirection.DirectionRatios
|
||||||
mag = math.sqrt(sum(d * d for d in dr))
|
mag = math.sqrt(sum(d * d for d in dr))
|
||||||
if mag < 1e-12:
|
if mag < 1e-12:
|
||||||
return ""
|
return ""
|
||||||
extrude_dir = tuple(d / mag for d in dr)
|
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"
|
return "TOP"
|
||||||
if dot_val < -0.99:
|
if dot_extrude < -0.99:
|
||||||
return "BOTTOM"
|
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:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
Reference in New Issue
Block a user