diff --git a/src/bonsai/bonsai/bim/module/drawing/__init__.py b/src/bonsai/bonsai/bim/module/drawing/__init__.py index bf1b903e5c..d499a7fa88 100644 --- a/src/bonsai/bonsai/bim/module/drawing/__init__.py +++ b/src/bonsai/bonsai/bim/module/drawing/__init__.py @@ -215,9 +215,7 @@ def register(): kc = wm.keyconfigs.addon if kc: km = kc.keymaps.new(name="3D View", space_type="VIEW_3D") - kmi = km.keymap_items.new( - "bim.click_nearest_dimension_anchor", "LEFTMOUSE", "PRESS" - ) + kmi = km.keymap_items.new("bim.click_nearest_dimension_anchor", "LEFTMOUSE", "PRESS") _keymaps.append((km, kmi)) diff --git a/src/bonsai/bonsai/bim/module/drawing/decoration.py b/src/bonsai/bonsai/bim/module/drawing/decoration.py index 2e69fb8023..43db298ece 100644 --- a/src/bonsai/bonsai/bim/module/drawing/decoration.py +++ b/src/bonsai/bonsai/bim/module/drawing/decoration.py @@ -2146,4 +2146,8 @@ class DecorationsHandler: object_decorators = DecoratorData.data.get("object_decorators", []) for obj, decorator in object_decorators: - decorator.decorate(context, obj) + try: + decorator.decorate(context, obj) + except ReferenceError: + DecoratorData.is_loaded = False + break diff --git a/src/bonsai/bonsai/bim/module/drawing/gizmos.py b/src/bonsai/bonsai/bim/module/drawing/gizmos.py index 39cd1cd542..c17c032324 100644 --- a/src/bonsai/bonsai/bim/module/drawing/gizmos.py +++ b/src/bonsai/bonsai/bim/module/drawing/gizmos.py @@ -2636,44 +2636,25 @@ class ExtrusionWidget(types.GizmoGroup): class GizmoAnchorHandle(bpy.types.Gizmo): - """Dot gizmo positioned at one vertex of a parametric dimension curve. + """Visual-only dot at a parametric dimension vertex. - Clicking it invokes ``bim.set_dimension_anchor`` pre-scoped to that vertex - index, skipping the manual vertex-pick phase of the operator. + No draw_select/invoke — any draw_select entry puts the gizmo in Blender's + select buffer, which causes the gizmo system to consume the click even + without an explicit invoke. All click handling is done by the + bim.click_nearest_dimension_anchor keymap operator. """ bl_idname = "BIM_GT_anchor_handle" - __slots__ = ("anchor_index", "custom_shape", "custom_shape_select") + __slots__ = ("anchor_index", "custom_shape") def setup(self): self.anchor_index = 0 self.custom_shape = self.new_custom_shape(type="TRIS", verts=X3DISC) - # 4× scaled version used only for hit-detection — bigger target, same visual size. - _sel = tuple((x * 4.0, y * 4.0, z) for x, y, z in X3DISC) - self.custom_shape_select = self.new_custom_shape(type="TRIS", verts=_sel) def draw(self, context): self.draw_custom_shape(self.custom_shape) - def draw_select(self, context, select_id): - self.draw_custom_shape(self.custom_shape_select, select_id=select_id) - - def invoke(self, context, event): - return {"RUNNING_MODAL"} - - def modal(self, context, event, tweak): - anchor_index = self.anchor_index - - def _launch(): - try: - bpy.ops.bim.set_dimension_anchor("INVOKE_DEFAULT", anchor_index=anchor_index) - except Exception as e: - print(f"[DimensionAnchorWidget] {e}") - return None - - bpy.app.timers.register(_launch, first_interval=0.0) - return {"FINISHED"} class DimensionAnchorWidget(types.GizmoGroup): @@ -2697,12 +2678,22 @@ class DimensionAnchorWidget(types.GizmoGroup): def poll(cls, context: bpy.types.Context) -> bool: if not tool.Ifc.get(): return False - # Stay visible while SetDimensionAnchor is running (active obj may be a temp element). + # Stay visible while SetDimensionAnchor is running (active obj may temporarily + # be an IFC element in the face-picking phase rather than the annotation). if _active_anchor_idx >= 0 and _editing_annotation_obj is not None: - return True + active = context.active_object + if active is _editing_annotation_obj: + return True # annotation still active + if active is not None and tool.Ifc.get_entity(active) is not None: + return True # face-picking phase: active obj is a target element + # Active object is None or a non-IFC object — the modal ended without + # calling set_active_anchor(-1). Reset stale state and fall through. + set_active_anchor(-1) obj = context.active_object if not obj or obj.type != "CURVE": return False + if not obj.select_get(): + return False element = tool.Ifc.get_entity(obj) if not element or not element.is_a("IfcAnnotation"): return False @@ -2716,8 +2707,7 @@ class DimensionAnchorWidget(types.GizmoGroup): self._handles: list = [] for _ in range(self._MAX_ANCHORS): gz = self.gizmos.new("BIM_GT_anchor_handle") - gz.scale_basis = 0.18 - gz.select_bias = -32.0 + gz.scale_basis = 0.2 gz.use_draw_modal = True gz.hide = True self._handles.append(gz) @@ -2756,11 +2746,11 @@ class DimensionAnchorWidget(types.GizmoGroup): for i in range(n): gz = self._handles[i] - world_co = obj.matrix_world @ spline.points[i].co.to_3d() + raw_co = spline.points[i].co + world_co = obj.matrix_world @ raw_co.to_3d() gz.matrix_basis = Matrix.Translation(world_co) gz.anchor_index = i if i == _active_anchor_idx and obj is _editing_annotation_obj: - print(f"[refresh] setting anchor[{i}] BLUE (obj={obj.name} editing={_editing_annotation_obj.name if _editing_annotation_obj else None})") gz.color = (0.2, 0.7, 1.0) gz.color_highlight = (0.4, 0.85, 1.0) elif anchors[i].get("guid"): @@ -2777,7 +2767,6 @@ class DimensionAnchorWidget(types.GizmoGroup): self._handles[i].hide = True def draw_prepare(self, context: bpy.types.Context) -> None: - print(f"[draw_prepare] DimensionAnchorWidget _active_anchor_idx={_active_anchor_idx}") self.refresh(context) diff --git a/src/bonsai/bonsai/bim/module/drawing/handler.py b/src/bonsai/bonsai/bim/module/drawing/handler.py index 61dad633fb..bc47e169d9 100644 --- a/src/bonsai/bonsai/bim/module/drawing/handler.py +++ b/src/bonsai/bonsai/bim/module/drawing/handler.py @@ -70,6 +70,84 @@ def _rebuild_dim_guid_index(file) -> None: _dim_index_dirty = False +def regenerate_dims_for_layer(file, layer) -> None: + """Regenerate all parametric dimensions anchored to elements that use *layer*.""" + global _dim_shape_cache, _dim_index_dirty, _dim_guid_index + + if _dim_index_dirty: + _rebuild_dim_guid_index(file) + + affected_guids: set = set() + for layer_set in file.get_inverse(layer): + if not layer_set.is_a("IfcMaterialLayerSet"): + continue + for inv in file.get_inverse(layer_set): + if inv.is_a("IfcRelAssociatesMaterial"): + rels = [inv] + elif inv.is_a("IfcMaterialLayerSetUsage"): + rels = [r for r in file.get_inverse(inv) if r.is_a("IfcRelAssociatesMaterial")] + else: + continue + for rel in rels: + for element in rel.RelatedObjects: + if hasattr(element, "GlobalId"): + affected_guids.add(element.GlobalId) + _dim_shape_cache.pop(element.id(), None) + + if not affected_guids: + return + + annotation_ids: set = set() + for guid in affected_guids: + for ann_id in _dim_guid_index.get(guid, []): + annotation_ids.add(ann_id) + + if not annotation_ids: + return + + import ifcopenshell.util.element + import ifcopenshell.api.drawing as drawing_api + import ifcopenshell.geom + from bonsai.bim.module.drawing.operator import _update_blender_curve + + geom_settings = ifcopenshell.geom.settings() + geom_settings.set("APPLY_DEFAULT_MATERIALS", False) + + for ann_id in annotation_ids: + try: + annotation = file.by_id(ann_id) + except Exception: + continue + pset = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension") + if not pset: + continue + placement_override: dict = {} + try: + anchors_raw = json.loads(pset.get("Anchors") or "[]") + for anchor in anchors_raw: + guid = anchor.get("guid") + if not guid: + continue + try: + elem = file.by_guid(guid) + elem_obj = tool.Ifc.get_object(elem) + if elem_obj: + placement_override[elem.id()] = np.array(elem_obj.matrix_world) + except Exception: + pass + except Exception: + pass + resolved_pts = drawing_api.regenerate_dimension( + file, + annotation, + settings=geom_settings, + shape_cache=_dim_shape_cache, + placement_override=placement_override, + ) + if resolved_pts: + _update_blender_curve(annotation, resolved_pts) + + @persistent def load_post(*args): invalidate_dim_index() @@ -140,10 +218,7 @@ def _sync_dimension_anchors_to_curve(file, annotation, obj) -> bool: n_pts = len(spline_world) n_anchors = len(anchors) - print(f"[sync_anchors] obj={obj.name} spline_pts={n_pts} anchors={n_anchors}") - if n_pts == n_anchors: - print("[sync_anchors] counts match — no change needed") return False # Match each spline point to the nearest unused anchor by proximity. @@ -177,7 +252,6 @@ def _sync_dimension_anchors_to_curve(file, annotation, obj) -> bool: "pt": [pt.x, pt.y, pt.z], }) - print(f"[sync_anchors] rebuilt {len(new_anchors)} anchors (was {n_anchors})") pset_entity = file.by_id(pset_data["id"]) ifcopenshell.api.pset.edit_pset(file, pset=pset_entity, properties={"Anchors": json.dumps(new_anchors)}) @@ -197,10 +271,11 @@ def depsgraph_update_post_handler(scene, depsgraph): if not file: return - # Collect GUIDs of IFC objects whose transform or geometry changed. - # is_updated_geometry fires on Edit Mode exit after Bonsai has already - # serialised the new mesh back to IFC via update_representation, so the - # tessellation will reflect the edited shape. + if _dim_index_dirty: + _rebuild_dim_guid_index(file) + + import ifcopenshell.util.element + moved_guids: set = set() edited_annotation_ids: set = set() @@ -214,26 +289,20 @@ def depsgraph_update_post_handler(scene, depsgraph): if element is None or not hasattr(element, "GlobalId"): continue - # If the dimension annotation curve itself was edited (vertex added/removed), - # sync the anchor list before regenerating. + if update.is_updated_geometry and obj.type == "CURVE" and element.is_a("IfcAnnotation"): import ifcopenshell.util.element as _ue ptype = _ue.get_predefined_type(element) - print(f"[handler] dimension curve geometry updated: {obj.name} ptype={ptype} is_updated_geometry={update.is_updated_geometry}") if ptype in ("DIMENSION", "RADIUS", "DIAMETER", "ANGLE", "PLAN_LEVEL", "SECTION_LEVEL"): - _sync_dimension_anchors_to_curve(file, element, obj) - edited_annotation_ids.add(element.id()) + changed = _sync_dimension_anchors_to_curve(file, element, obj) + if changed: + edited_annotation_ids.add(element.id()) continue moved_guids.add(element.GlobalId) - # Geometry edits invalidate the cached tessellation for this element. if update.is_updated_geometry: _dim_shape_cache.pop(element.id(), None) - if _dim_index_dirty: - _rebuild_dim_guid_index(file) - - print(f"[handler] edited_annotation_ids={edited_annotation_ids} moved_guids={moved_guids}") annotation_ids: set = set(edited_annotation_ids) for guid in moved_guids: for ann_id in _dim_guid_index.get(guid, []): @@ -244,7 +313,6 @@ def depsgraph_update_post_handler(scene, depsgraph): import ifcopenshell.api.drawing as drawing_api import ifcopenshell.geom - import ifcopenshell.util.element from bonsai.bim.module.drawing.operator import _update_blender_curve geom_settings = ifcopenshell.geom.settings() @@ -282,7 +350,6 @@ def depsgraph_update_post_handler(scene, depsgraph): except Exception: pass - print(f"[handler] regenerating ann_id={ann_id} anchors_in_pset={len(json.loads(pset.get('Anchors') or '[]'))}") resolved_pts = drawing_api.regenerate_dimension( file, annotation, @@ -290,13 +357,7 @@ def depsgraph_update_post_handler(scene, depsgraph): shape_cache=_dim_shape_cache, placement_override=placement_override, ) - print(f"[handler] resolved_pts count={len(resolved_pts)} pts={[(round(p[0],3),round(p[1],3),round(p[2],3)) for p in resolved_pts]}") if resolved_pts: - obj = tool.Ifc.get_object(annotation) - if obj: - print(f"[handler] curve spline pts before update={len(obj.data.splines[0].points) if obj.data.splines else 0}") _update_blender_curve(annotation, resolved_pts) - if obj: - print(f"[handler] curve spline pts after update={len(obj.data.splines[0].points) if obj.data.splines else 0}") finally: _dim_handler_running = False diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index 7833afa779..e99ff2d965 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -5701,19 +5701,42 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope def _update_perp_constraint(self) -> None: """Extract the face normal from anchor[0] and store it as the constraint axis.""" import math + from mathutils import Vector a = self._anchors[0] if self._anchors else None if not a or a.get("type") != "FACE": + print(f"[perp] _update_perp_constraint: anchor type={a.get('type') if a else None} — need FACE, skipping") return - fp = (a.get("addr") or {}).get("fingerprint") or {} - n = fp.get("normal") # world-space at build time + addr = a.get("addr") or {} + normal_local = addr.get("normal_local") pt = a.get("pt") - if not n or not pt: + if not normal_local or not pt: + print(f"[perp] _update_perp_constraint: missing normal_local={normal_local} or pt={pt}") return + + # Rotate element-local normal to world space via the Blender object's matrix. + n = normal_local + guid = a.get("guid") + if guid: + try: + file = tool.Ifc.get() + element = file.by_guid(guid) + obj = tool.Ifc.get_object(element) + if obj: + nw = obj.matrix_world.to_3x3() @ Vector(normal_local) + nw.normalize() + n = (nw.x, nw.y, nw.z) + else: + print(f"[perp] _update_perp_constraint: no Blender obj for guid={guid}, using normal_local as-is") + except Exception as exc: + print(f"[perp] _update_perp_constraint: exception rotating normal: {exc}") + mag = math.sqrt(n[0] ** 2 + n[1] ** 2 + n[2] ** 2) if mag < 1e-12: + print(f"[perp] _update_perp_constraint: zero-length normal after rotation") return self._anchor0_normal = (n[0] / mag, n[1] / mag, n[2] / mag) self._anchor0_pt = tuple(pt) + print(f"[perp] _update_perp_constraint: OK normal={[round(v,3) for v in self._anchor0_normal]} pt={[round(v,3) for v in self._anchor0_pt]}") def _apply_perp_constraint(self) -> None: """Project the current snap point onto the constraint line when active.""" @@ -5730,7 +5753,9 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope base = self._anchor0_pt 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])) + constrained = Vector((base[0] + t * n[0], base[1] + t * n[1], base[2] + t * n[2])) + print(f"[perp] _apply_perp_constraint: raw=({p.x:.3f},{p.y:.3f},{p.z:.3f}) t={t:.4f} constrained=({constrained.x:.3f},{constrained.y:.3f},{constrained.z:.3f})") + snap["point"] = constrained # ------------------------------------------------------------------ # Finalize: create IfcAnnotation + BBIM_Dimension pset @@ -5917,7 +5942,85 @@ def _prefer_perp_face_index( return best_idx -class SetDimensionAnchor(bpy.types.Operator): +# Module-level draw data so the GPU callback never touches the operator RNA struct. +_snap_draw_data: dict = {} + + + +def _draw_snap_indicator_global(): + """GPU draw callback (POST_VIEW) — draws face outline, edge, or vertex dot.""" + data = _snap_draw_data + if not data or not data.get("type"): + return + import gpu + from gpu_extras.batch import batch_for_shader + try: + shader = gpu.shader.from_builtin("UNIFORM_COLOR") + gpu.state.blend_set("ALPHA") + gpu.state.depth_test_set("ALWAYS") + snap_type = data["type"] + + if snap_type == "FACE": + verts = data.get("face_verts", []) + if len(verts) >= 3: + lines = [] + for i in range(len(verts)): + lines.append(verts[i]) + lines.append(verts[(i + 1) % len(verts)]) + shader.bind() + shader.uniform_float("color", (0.2, 0.55, 1.0, 0.9)) + gpu.state.line_width_set(4.0) + batch_for_shader(shader, "LINES", {"pos": lines}).draw(shader) + + elif snap_type == "EDGE": + v0, v1 = data.get("v0"), data.get("v1") + if v0 and v1: + shader.bind() + shader.uniform_float("color", (1.0, 0.65, 0.0, 1.0)) + gpu.state.line_width_set(6.0) + batch_for_shader(shader, "LINES", {"pos": [v0, v1]}).draw(shader) + gpu.state.point_size_set(12.0) + batch_for_shader(shader, "POINTS", {"pos": [v0, v1]}).draw(shader) + + elif snap_type == "VERTEX": + pt = data.get("snap_world") + if pt: + shader.bind() + shader.uniform_float("color", (1.0, 0.2, 0.4, 1.0)) + gpu.state.point_size_set(20.0) + batch_for_shader(shader, "POINTS", {"pos": [pt]}).draw(shader) + + elif snap_type == "LAYER": + corners = data.get("seam_corners", []) + pt = data.get("snap_world") + shader.bind() + shader.uniform_float("color", (0.2, 0.9, 0.5, 1.0)) + n = len(corners) + if n >= 2: + lines = [] + for i in range(n): + lines.append(corners[i]) + lines.append(corners[(i + 1) % n]) + gpu.state.line_width_set(5.0) + batch_for_shader(shader, "LINES", {"pos": lines}).draw(shader) + gpu.state.point_size_set(10.0) + batch_for_shader(shader, "POINTS", {"pos": corners}).draw(shader) + if pt: + gpu.state.point_size_set(20.0) + batch_for_shader(shader, "POINTS", {"pos": [pt]}).draw(shader) + + except Exception: + pass + finally: + try: + gpu.state.depth_test_set("LESS_EQUAL") + gpu.state.blend_set("NONE") + gpu.state.line_width_set(1.0) + except Exception: + pass + + +class SetDimensionAnchor(bpy.types.Operator, tool.Ifc.Operator): """Interactively anchor dimension vertices to IFC element faces. Two-phase modal workflow (all in Object Mode): @@ -5950,12 +6053,17 @@ class SetDimensionAnchor(bpy.types.Operator): # Hover-cycle state (active during PICK_FACE phase) _hover_candidates: list # [(ifc_obj, hit_mesh, hit_mesh_mx, location, normal, face_index), ...] - _hover_index: int # which candidate is currently highlighted + _hover_index: int # which element candidate is currently highlighted _hover_last_px: tuple # last cursor pixel position where candidates were computed _hover_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 + # Snap-mode cycle state (FACE → EDGE → VERTEX, TAB) + _snap_mode: str # "FACE" | "EDGE" | "VERTEX" + _draw_handler: object # SpaceView3D draw handler handle + + _VERTEX_PICK_RADIUS_PX = 20 + _HOVER_THROTTLE_PX_SQ = 25 + _SNAP_MODES = ("FACE", "LAYER", "EDGE", "VERTEX") @classmethod def poll(cls, context): @@ -5980,6 +6088,12 @@ class SetDimensionAnchor(bpy.types.Operator): return True def invoke(self, context, event): + return IfcStore.execute_ifc_operator(self, context, event, method="INVOKE") + + def modal(self, context, event): + return IfcStore.execute_ifc_operator(self, context, event, method="MODAL") + + def _invoke(self, context, event): obj = context.active_object self._annotation = tool.Ifc.get_entity(obj) self._annotation_obj = obj @@ -5996,6 +6110,11 @@ class SetDimensionAnchor(bpy.types.Operator): self._hover_index = 0 self._hover_last_px = (-9999, -9999) self._hover_highlighted_obj = None + self._snap_mode = "FACE" + _snap_draw_data.clear() + self._draw_handler = bpy.types.SpaceView3D.draw_handler_add( + _draw_snap_indicator_global, (), "WINDOW", "POST_VIEW" + ) # When invoked from a panel, context.region_data is None. # Walk the screen areas to find the actual 3D viewport region. @@ -6014,13 +6133,28 @@ class SetDimensionAnchor(bpy.types.Operator): context.window_manager.modal_handler_add(self) return {"RUNNING_MODAL"} - def modal(self, context, event): + def _modal(self, context, event): + # Undo while the modal is running can free the annotation object. + try: + _ = self._annotation_obj.name + except ReferenceError: + self._cleanup(context) + return {"FINISHED"} + if event.type == "ESC" or (event.type == "RIGHTMOUSE" and event.value == "PRESS"): self._clear_hover_highlight(context) context.workspace.status_text_set(None) + _snap_draw_data.clear() + if self._draw_handler: + bpy.types.SpaceView3D.draw_handler_remove(self._draw_handler, "WINDOW") + self._draw_handler = None from bonsai.bim.module.drawing.gizmos import set_active_anchor set_active_anchor(-1) - return {"FINISHED"} # keep any anchors already written + obj = context.active_object + if obj: + obj.select_set(False) + context.view_layer.objects.active = None + return {"FINISHED"} # Hover — recompute candidates as cursor moves (PICK_FACE phase only) if event.type == "MOUSEMOVE" and self._phase == "PICK_FACE": @@ -6036,12 +6170,32 @@ class SetDimensionAnchor(bpy.types.Operator): if self._phase == "PICK_VERTEX": self._handle_vertex_pick(context, event) else: - self._handle_face_pick(context, event) + wrote = self._handle_face_pick(context, event) + if wrote: + # Finish here so this anchor write is its own undo step. + # The dimension stays selected so the user can click + # another dot immediately. + self._cleanup(context) + return {"FINISHED"} self._set_status(context) return {"RUNNING_MODAL"} return {"PASS_THROUGH"} + def _cleanup(self, context): + self._clear_hover_highlight(context) + context.workspace.status_text_set(None) + _snap_draw_data.clear() + if self._draw_handler: + bpy.types.SpaceView3D.draw_handler_remove(self._draw_handler, "WINDOW") + self._draw_handler = None + from bonsai.bim.module.drawing.gizmos import set_active_anchor + set_active_anchor(-1) + for area in context.screen.areas: + if area.type == "VIEW_3D": + area.tag_redraw() + break + # ------------------------------------------------------------------ # Status bar @@ -6136,12 +6290,9 @@ class SetDimensionAnchor(bpy.types.Operator): 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(pt_m) - self._write_anchor(anchor, self._active_vertex_idx) + _do_write_anchor(self._annotation, self._annotation_obj, anchor, self._active_vertex_idx, self._shape_cache) self.report({"INFO"}, f"Vertex {self._active_vertex_idx} → free world point") - self._phase = "PICK_VERTEX" - from bonsai.bim.module.drawing.gizmos import set_active_anchor - set_active_anchor(-1) - return + return True # Normal click — use whichever candidate is currently highlighted. self._clear_hover_highlight(context) @@ -6166,33 +6317,47 @@ class SetDimensionAnchor(bpy.types.Operator): return file = tool.Ifc.get() - hit_m = (float(location.x), float(location.y), float(location.z)) - normal_m = (float(normal.x), float(normal.y), float(normal.z)) placement_override = {element.id(): np.array(hit_obj.matrix_world)} + # Recompute snap geometry at the exact click position for accuracy. + snap = self._compute_snap_geom(hit_obj, face_index, coord) + snap_type = snap.get("type", "FACE") + import ifcopenshell.api.drawing as drawing_api try: - anchor = drawing_api.build_anchor_from_hit( - file, element, hit_m, normal_m, - shape_cache=self._shape_cache, - placement_override=placement_override, - ) + if snap_type == "LAYER" and snap.get("method") == "LAYER_BOUNDARY": + anchor = drawing_api.build_anchor_from_layer_boundary(file, element, snap) + elif snap_type == "VERTEX" and snap.get("profile_x_m") is not None: + anchor = drawing_api.build_anchor_from_profile_vert(file, element, snap) + elif snap_type == "EDGE" and snap.get("profile_x_m") is not None: + anchor = drawing_api.build_anchor_from_profile_edge(file, element, snap) + elif snap_type in ("VERTEX", "EDGE") and snap.get("snap_world") is not None: + # Tessellation fallback — no IFC profile data available. + # Use the snap position as a static WORLD anchor rather than a + # FACE fingerprint, so the endpoint stays at the correct vertex/ + # edge position instead of drifting to the face centre. + sw = snap["snap_world"] + anchor = drawing_api.make_world_anchor([float(sw[0]), float(sw[1]), float(sw[2])]) + else: + hit_m = (float(location.x), float(location.y), float(location.z)) + normal_m = (float(normal.x), float(normal.y), float(normal.z)) + anchor = drawing_api.build_anchor_from_hit( + file, element, hit_m, normal_m, + shape_cache=self._shape_cache, + placement_override=placement_override, + ) except Exception as exc: import traceback traceback.print_exc() - self.report({"ERROR"}, f"build_anchor_from_hit failed: {exc}") + self.report({"ERROR"}, f"build_anchor failed: {exc}") return - self._write_anchor(anchor, self._active_vertex_idx) + _do_write_anchor(self._annotation, self._annotation_obj, anchor, self._active_vertex_idx, self._shape_cache) self.report( {"INFO"}, - f"Vertex {self._active_vertex_idx} → {element.is_a()}/{element.Name or element.GlobalId}", + f"Vertex {self._active_vertex_idx} → {element.is_a()}/{element.Name or element.GlobalId} [{anchor.get('type')}]", ) - self._phase = "PICK_VERTEX" - self._hover_candidates = [] - self._hover_index = 0 - from bonsai.bim.module.drawing.gizmos import set_active_anchor - set_active_anchor(-1) + return True # ------------------------------------------------------------------ # Hover / cycle helpers @@ -6285,7 +6450,10 @@ class SetDimensionAnchor(bpy.types.Operator): 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) + try: + found, loc_l, nrm_l, fi = ifc_obj.closest_point_on_mesh(mx_inv @ query_w, distance=100.0) + except RuntimeError: + continue if not found: continue loc_w = mx @ loc_l @@ -6311,19 +6479,25 @@ class SetDimensionAnchor(bpy.types.Operator): self._apply_hover_highlight(context) def _cycle_hover(self, context): - """Advance to the next candidate and update the highlight.""" + """Cycle snap mode (FACE → EDGE → VERTEX); advance element on wrap-around.""" if not self._hover_candidates: return - self._hover_index = (self._hover_index + 1) % len(self._hover_candidates) + modes = self._SNAP_MODES + cur = modes.index(self._snap_mode) + nxt = (cur + 1) % len(modes) + self._snap_mode = modes[nxt] + if nxt == 0 and len(self._hover_candidates) > 1: + self._hover_index = (self._hover_index + 1) % len(self._hover_candidates) self._apply_hover_highlight(context) def _apply_hover_highlight(self, context): - """Select the current candidate object for visual feedback.""" + """Select the current candidate object; compute snap geometry; update status.""" if not self._hover_candidates: self._clear_hover_highlight(context) + _snap_draw_data.clear() return - ifc_obj = self._hover_candidates[self._hover_index][0] + ifc_obj, _, _, _, _, face_index = self._hover_candidates[self._hover_index] # Only update selection when the highlighted object changes. if ifc_obj != self._hover_highlighted_obj: @@ -6339,14 +6513,22 @@ class SetDimensionAnchor(bpy.types.Operator): except Exception: pass + _snap_draw_data.clear() + _snap_draw_data.update(self._compute_snap_geom(ifc_obj, face_index, self._hover_last_px)) + entity = tool.Ifc.get_entity(ifc_obj) 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 "" + mode_label = self._snap_mode.capitalize() + elem_hint = f" ({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" + f"Dim vertex {self._active_vertex_idx} — {label} [{mode_label}{elem_hint}]" + " | TAB: cycle snap | Click: anchor | ALT+Click: free | RMB/ESC: Finish" ) + for area in context.screen.areas: + if area.type == "VIEW_3D": + area.tag_redraw() + break def _clear_hover_highlight(self, context): """Deselect the highlighted object and restore the annotation as active.""" @@ -6362,77 +6544,199 @@ class SetDimensionAnchor(bpy.types.Operator): except Exception: pass + def cancel(self, context): + """Called when the operator is cancelled externally — clean up GPU handler.""" + _snap_draw_data.clear() + if self._draw_handler: + bpy.types.SpaceView3D.draw_handler_remove(self._draw_handler, "WINDOW") + self._draw_handler = None + from bonsai.bim.module.drawing.gizmos import set_active_anchor + set_active_anchor(-1) + obj = context.active_object + if obj: + obj.select_set(False) + context.view_layer.objects.active = None + # ------------------------------------------------------------------ - # Pset write (shared by both face and free-point paths) + # Snap geometry helpers - def _write_anchor(self, new_anchor: dict, vertex_index: int) -> None: - file = tool.Ifc.get() - annotation = self._annotation + def _compute_snap_geom(self, hit_obj, face_index, coord) -> dict: + """Return snap draw-data dict for the current snap mode and hit face. - pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension") + When the hit element has an IfcExtrudedAreaSolid, VERTEX and EDGE snaps + are resolved from the IFC profile geometry (stable across mesh reloads) + rather than from Blender tessellation vertex indices. - if pset_data and pset_data.get("Anchors"): - try: - anchors: list = json.loads(pset_data["Anchors"]) - except Exception: - anchors = [] - else: - anchors = _anchors_from_spline(self._annotation_obj, file) + ``coord`` is a (x, y) tuple in region-local pixels. Returns an empty + dict when the face index is invalid or the region is unavailable. + """ + from bpy_extras.view3d_utils import location_3d_to_region_2d - while len(anchors) <= vertex_index: - obj = self._annotation_obj - idx = len(anchors) - if obj and obj.data and hasattr(obj.data, "splines") and obj.data.splines: - pts = obj.data.splines[0].points - if idx < len(pts): - co = obj.matrix_world @ pts[idx].co.xyz - import ifcopenshell.api.drawing as drawing_api - anchors.append(drawing_api.make_world_anchor([float(co.x), float(co.y), float(co.z)])) - continue - import ifcopenshell.api.drawing as drawing_api - anchors.append(drawing_api.make_world_anchor([0.0, 0.0, 0.0])) + region = self._region + rv3d = self._rv3d + if not region or not rv3d or face_index is None: + return {} + try: + face = hit_obj.data.polygons[face_index] + except (IndexError, AttributeError): + return {} - anchors[vertex_index] = new_anchor - anchors_json = json.dumps(anchors) + mx = hit_obj.matrix_world + face_verts_world = [tuple(mx @ hit_obj.data.vertices[vi].co) for vi in face.vertices] - if pset_data: - 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_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}) + if self._snap_mode == "FACE": + return {"type": "FACE", "face_verts": face_verts_world} - from bonsai.bim.module.drawing import handler as _drawing_handler - _drawing_handler.invalidate_dim_index() + # Try profile-based snap candidates first (IFC-native, index-stable). + if self._snap_mode in ("VERTEX", "EDGE"): + element = tool.Ifc.get_entity(hit_obj) + if element: + import ifcopenshell.api.drawing as drawing_api + placement_override = {element.id(): np.array(mx)} + candidates = drawing_api.get_profile_snap_candidates( + tool.Ifc.get(), element, placement_override=placement_override + ) + want_type = self._snap_mode + best_cand = None + best_d2 = float("inf") + for cand in candidates: + if cand["type"] != want_type: + continue + sp = location_3d_to_region_2d(region, rv3d, cand["snap_world"]) + if sp is None: + continue + dx, dy = sp.x - coord[0], sp.y - coord[1] + d2 = dx * dx + dy * dy + if d2 < best_d2: + best_d2, best_cand = d2, cand + if best_cand is not None: + return best_cand - # Move the Blender curve vertex to the newly resolved anchor position. - # 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: + # Fallback: Blender tessellation snap for elements without an + # IfcExtrudedAreaSolid profile. Returns snap_world for the visual + # indicator; no pt_idx so the click handler creates a face anchor. + screen_pts = [location_3d_to_region_2d(region, rv3d, wv) for wv in face_verts_world] + n = len(face_verts_world) + + if self._snap_mode == "VERTEX": + best_i, best_d2 = 0, float("inf") + for i, sp in enumerate(screen_pts): + if sp is not None: + dx, dy = sp.x - coord[0], sp.y - coord[1] + d2 = dx * dx + dy * dy + if d2 < best_d2: + best_d2, best_i = d2, i + return {"type": "VERTEX", "snap_world": face_verts_world[best_i]} + + if self._snap_mode == "EDGE": + best_e, best_d2 = 0, float("inf") + for i in range(n): + j = (i + 1) % n + sp0, sp1 = screen_pts[i], screen_pts[j] + if sp0 is not None and sp1 is not None: + mid_x = (sp0.x + sp1.x) * 0.5 + mid_y = (sp0.y + sp1.y) * 0.5 + dx, dy = mid_x - coord[0], mid_y - coord[1] + d2 = dx * dx + dy * dy + if d2 < best_d2: + best_d2, best_e = d2, i + i0, i1 = best_e, (best_e + 1) % n + v0_w, v1_w = face_verts_world[i0], face_verts_world[i1] + mid_w = ((v0_w[0] + v1_w[0]) * 0.5, (v0_w[1] + v1_w[1]) * 0.5, (v0_w[2] + v1_w[2]) * 0.5) + return {"type": "EDGE", "v0": v0_w, "v1": v1_w, "snap_world": mid_w} + + if self._snap_mode == "LAYER": + element = tool.Ifc.get_entity(hit_obj) + if element: + import ifcopenshell.api.drawing as drawing_api + placement_override = {element.id(): np.array(mx)} + candidates = drawing_api.get_layer_snap_candidates( + tool.Ifc.get(), element, placement_override=placement_override + ) + best_cand = None + best_d2 = float("inf") + for cand in candidates: + sp = location_3d_to_region_2d(region, rv3d, cand["snap_world"]) + if sp is None: + continue + dx, dy = sp.x - coord[0], sp.y - coord[1] + d2 = dx * dx + dy * dy + if d2 < best_d2: + best_d2, best_cand = d2, cand + if best_cand is not None: + result = dict(best_cand) + result["type"] = "LAYER" + result["method"] = "LAYER_BOUNDARY" + return result + return {"type": "FACE", "face_verts": face_verts_world} + + return {} + + +def _do_write_anchor(annotation, annotation_obj, new_anchor: dict, vertex_index: int, shape_cache=None) -> None: + """Write one anchor into the BBIM_Dimension pset and regenerate the curve.""" + file = tool.Ifc.get() + + pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension") + + if pset_data and pset_data.get("Anchors"): + try: + anchors: list = json.loads(pset_data["Anchors"]) + except Exception: + anchors = [] + else: + anchors = _anchors_from_spline(annotation_obj, file) + + while len(anchors) <= vertex_index: + idx = len(anchors) + if annotation_obj and annotation_obj.data and hasattr(annotation_obj.data, "splines") and annotation_obj.data.splines: + pts = annotation_obj.data.splines[0].points + if idx < len(pts): + co = annotation_obj.matrix_world @ pts[idx].co.xyz + import ifcopenshell.api.drawing as drawing_api + anchors.append(drawing_api.make_world_anchor([float(co.x), float(co.y), float(co.z)])) continue - 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]}") + anchors.append(drawing_api.make_world_anchor([0.0, 0.0, 0.0])) + + anchors[vertex_index] = new_anchor + anchors_json = json.dumps(anchors) + + if pset_data: + 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_Dimension") + pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension") + pset_entity = file.by_id(pset_data["id"]) + ifcopenshell.api.run("pset.edit_pset", file, pset=pset_entity, properties={"Anchors": anchors_json}) + + from bonsai.bim.module.drawing import handler as _drawing_handler + _drawing_handler.invalidate_dim_index() + + placement_override: dict = {} + for a in anchors: + guid = a.get("guid") + if not guid: + continue + try: + elem = file.by_guid(guid) + elem_obj = tool.Ifc.get_object(elem) + if elem_obj: + placement_override[elem.id()] = np.array(elem_obj.matrix_world) + except Exception: + pass + + import ifcopenshell.api.drawing as drawing_api + resolved_pts = drawing_api.regenerate_dimension( + file, + annotation, + shape_cache=shape_cache, + placement_override=placement_override, + ) + if resolved_pts: + _update_blender_curve(annotation, resolved_pts) + class RegenerateDimensions(bpy.types.Operator, tool.Ifc.Operator): @@ -6601,7 +6905,6 @@ def _update_blender_curve( spline.points.add(n - 1) is_2d = _annotation_is_2d(annotation) - for i, pt_m in enumerate(resolved_pts_m): blender_world = Vector((float(pt_m[0]), float(pt_m[1]), float(pt_m[2]))) local_pt = inv_world @ blender_world @@ -6613,7 +6916,10 @@ def _update_blender_curve( spline.points[i].co = (*local_pt, 1.0) # Also update the IFC IfcPolyline so Edit Mode reloads reflect the new positions. - _update_ifc_polyline(tool.Ifc.get(), annotation, obj, resolved_pts_m) + try: + _update_ifc_polyline(tool.Ifc.get(), annotation, obj, resolved_pts_m) + except Exception as e: + pass def _annotation_is_2d(annotation: ifcopenshell.entity_instance) -> bool: @@ -6716,46 +7022,24 @@ def _find_curve_in_item(item: ifcopenshell.entity_instance) -> Optional[ifcopens return None -class ClickNearestDimensionAnchor(bpy.types.Operator): - """LMB handler: fire SetDimensionAnchor when cursor is within RADIUS pixels of an anchor dot. - Registered as a keymap item so it runs before Blender's object-selection handler. - Returns PASS_THROUGH when the cursor is not near any anchor, so normal viewport - clicks are unaffected. + +class ClickNearestDimensionAnchor(bpy.types.Operator): + """LMB fallback: fire SetDimensionAnchor when cursor is within RADIUS pixels of an anchor dot. + + The gizmo handles exact hits; this catches near-misses where the cursor + is close to a dot but didn't land inside the gizmo hit shape. """ bl_idname = "bim.click_nearest_dimension_anchor" bl_label = "Click Nearest Dimension Anchor" - RADIUS_PX = 120 + RADIUS_PX = 60 def invoke(self, context, event): from bpy_extras.view3d_utils import location_3d_to_region_2d - print(f"[AnchorClick] invoke called") - if not tool.Ifc.get(): - print(f"[AnchorClick] PASS_THROUGH — no IFC file") - return {"PASS_THROUGH"} - - obj = context.active_object - if not obj or obj.type != "CURVE": - print(f"[AnchorClick] PASS_THROUGH — active obj is {obj} type={getattr(obj,'type',None)}") - return {"PASS_THROUGH"} - - element = tool.Ifc.get_entity(obj) - if not element or not element.is_a("IfcAnnotation"): - print(f"[AnchorClick] PASS_THROUGH — not an IfcAnnotation: {element}") - return {"PASS_THROUGH"} - - import ifcopenshell.util.element as _ue - pset = _ue.get_pset(element, "BBIM_Dimension") - if not pset or not pset.get("Anchors"): - print(f"[AnchorClick] PASS_THROUGH — no BBIM_Dimension pset or Anchors") - return {"PASS_THROUGH"} - - if not obj.data.splines: - print(f"[AnchorClick] PASS_THROUGH — no splines") return {"PASS_THROUGH"} # Always use the 3D viewport WINDOW region — context.region may be a header, @@ -6777,35 +7061,54 @@ class ClickNearestDimensionAnchor(bpy.types.Operator): break if not region or not rv3d: - print(f"[AnchorClick] PASS_THROUGH — no 3D region") return {"PASS_THROUGH"} # Convert absolute mouse position to WINDOW region-local coordinates. cx = event.mouse_x - region.x cy = event.mouse_y - region.y + import ifcopenshell.util.element as _ue + r2 = self.RADIUS_PX ** 2 + best_obj = None best_idx = -1 best_dist_sq = float("inf") - for i, pt in enumerate(obj.data.splines[0].points): - world_pos = obj.matrix_world @ pt.co.to_3d() - sp = location_3d_to_region_2d(region, rv3d, world_pos) - if not sp: - continue - dx, dy = cx - sp.x, cy - sp.y - d2 = dx * dx + dy * dy - print(f"[AnchorClick] click=({cx},{cy}) anchor[{i}]=({sp.x:.0f},{sp.y:.0f}) dist={d2**0.5:.1f}px radius={self.RADIUS_PX}px") - if d2 < r2 and d2 < best_dist_sq: - best_dist_sq = d2 - best_idx = i - if best_idx < 0: - print(f"[AnchorClick] MISS — no anchor within {self.RADIUS_PX}px") + # Scan ALL selected objects — context.active_object may have changed to an + # underlying IFC element due to Blender's hover pre-selection, so we can't + # rely on it being the dimension we want to click. + for obj in context.scene.objects: + if not obj.select_get(): + continue + if obj.type != "CURVE": + continue + element = tool.Ifc.get_entity(obj) + if not element or not element.is_a("IfcAnnotation"): + continue + pset = _ue.get_pset(element, "BBIM_Dimension") + if not pset or not pset.get("Anchors"): + continue + if not obj.data.splines: + continue + + for i, pt in enumerate(obj.data.splines[0].points): + world_pos = obj.matrix_world @ pt.co.to_3d() + sp = location_3d_to_region_2d(region, rv3d, world_pos) + if not sp: + continue + dx, dy = cx - sp.x, cy - sp.y + d2 = dx * dx + dy * dy + if d2 < r2 and d2 < best_dist_sq: + best_dist_sq = d2 + best_idx = i + best_obj = obj + + if best_obj is None: return {"PASS_THROUGH"} - print(f"[AnchorClick] HIT anchor[{best_idx}] dist={best_dist_sq**0.5:.1f}px") + context.view_layer.objects.active = best_obj from bonsai.bim.module.drawing.gizmos import set_active_anchor - set_active_anchor(best_idx, obj) + set_active_anchor(best_idx, best_obj) # Force viewport redraw so gizmo colors update before the modal starts. for area in context.screen.areas: if area.type == "VIEW_3D": diff --git a/src/bonsai/bonsai/bim/module/material/operator.py b/src/bonsai/bonsai/bim/module/material/operator.py index b2ab1cfa06..11ac3857b1 100644 --- a/src/bonsai/bonsai/bim/module/material/operator.py +++ b/src/bonsai/bonsai/bim/module/material/operator.py @@ -834,6 +834,8 @@ class EditMaterialSetItem(bpy.types.Operator, tool.Ifc.Operator): ) slab.DumbSlabPlaner().regenerate_from_layer(layer) wall.DumbWallPlaner().regenerate_from_layer(layer) + from bonsai.bim.module.drawing.handler import regenerate_dims_for_layer + regenerate_dims_for_layer(self.file, layer) elif material.is_a("IfcMaterialProfileSet"): profile_def = None if mprops.profiles: diff --git a/src/bonsai/bonsai/bim/module/pset/operator.py b/src/bonsai/bonsai/bim/module/pset/operator.py index 0b431c81e7..4380f9658a 100644 --- a/src/bonsai/bonsai/bim/module/pset/operator.py +++ b/src/bonsai/bonsai/bim/module/pset/operator.py @@ -90,7 +90,6 @@ class DisablePsetEditing(bpy.types.Operator, tool.Ifc.Operator): 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 @@ -100,13 +99,10 @@ def _regenerate_parametric_dimension(file, annotation): 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") @@ -117,17 +113,14 @@ def _regenerate_parametric_dimension(file, annotation): 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}") + except Exception: + pass 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() @@ -197,12 +190,9 @@ 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) diff --git a/src/ifcopenshell-python/ifcopenshell/api/drawing/__init__.py b/src/ifcopenshell-python/ifcopenshell/api/drawing/__init__.py index 4b8bbb0177..d24e45cbea 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/drawing/__init__.py +++ b/src/ifcopenshell-python/ifcopenshell/api/drawing/__init__.py @@ -26,7 +26,7 @@ from .. import wrap_usecases from .assign_product import assign_product from .edit_text_literal import edit_text_literal from .regenerate_dimension import regenerate_dimension, get_dimension_segment_lengths -from .resolve_anchor import build_anchor_from_hit, make_world_anchor, resolve_anchor +from .resolve_anchor import build_anchor_from_hit, build_anchor_from_layer_boundary, build_anchor_from_profile_vert, build_anchor_from_profile_edge, get_layer_snap_candidates, get_profile_snap_candidates, make_world_anchor, resolve_anchor from .unassign_product import unassign_product wrap_usecases(__path__, __name__) @@ -34,8 +34,13 @@ wrap_usecases(__path__, __name__) __all__ = [ "assign_product", "build_anchor_from_hit", + "build_anchor_from_layer_boundary", + "build_anchor_from_profile_edge", + "build_anchor_from_profile_vert", "edit_text_literal", "get_dimension_segment_lengths", + "get_layer_snap_candidates", + "get_profile_snap_candidates", "make_world_anchor", "regenerate_dimension", "resolve_anchor", diff --git a/src/ifcopenshell-python/ifcopenshell/api/drawing/regenerate_dimension.py b/src/ifcopenshell-python/ifcopenshell/api/drawing/regenerate_dimension.py index 62abe52ba3..313e430a5c 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/drawing/regenerate_dimension.py +++ b/src/ifcopenshell-python/ifcopenshell/api/drawing/regenerate_dimension.py @@ -296,33 +296,44 @@ def _get_anchor_face_normal_world( ) -> 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. + Reads ``normal_local`` (element-local, rotation-invariant) from the anchor + addr and rotates it to world space via the current element placement. + Also accepts the legacy ``addr.fingerprint.normal_local`` format. """ if anchor.get("type") != "FACE": return None guid = anchor.get("guid") if not guid: return None - fp = (anchor.get("addr") or {}).get("fingerprint") or {} + try: + element = file.by_guid(guid) + except Exception: + return None - normal_local = fp.get("normal_local") - if normal_local: - try: - element = file.by_guid(guid) - except Exception: + addr = anchor.get("addr") or {} + from .resolve_anchor import _rotate_local_to_world + + if addr.get("method") == "LAYER_BOUNDARY": + import ifcopenshell.util.element as _ifc_elem + usage = _ifc_elem.get_material(element, should_inherit=True) + if not usage or not usage.is_a("IfcMaterialLayerSetUsage"): + return None + axis = (getattr(usage, "LayerSetDirection", None) or "AXIS2") + if axis == "AXIS1": + normal_local: tuple = (1.0, 0.0, 0.0) + elif axis == "AXIS3": + normal_local = (0.0, 0.0, 1.0) + else: + normal_local = (0.0, 1.0, 0.0) + else: + # FACE_NORMAL: normal_local stored in addr (new) or addr.fingerprint (legacy). + normal_local = addr.get("normal_local") or (addr.get("fingerprint") or {}).get("normal_local") + if not normal_local: return None - 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 + 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 def _get_line_offset_direction( diff --git a/src/ifcopenshell-python/ifcopenshell/api/drawing/resolve_anchor.py b/src/ifcopenshell-python/ifcopenshell/api/drawing/resolve_anchor.py index 36b3565df0..25bcec5b65 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/drawing/resolve_anchor.py +++ b/src/ifcopenshell-python/ifcopenshell/api/drawing/resolve_anchor.py @@ -19,32 +19,51 @@ """Resolve a parametric dimension anchor to a world-space coordinate in metres. NOTE ON COORDINATE SPACE -ifcopenshell.geom.create_shape() always outputs geometry in **metres** (its -internal unit), regardless of the IFC project's declared length unit (feet, mm, -etc.). All anchor coordinates (``pt``, ``hint``, fingerprint ``centroid``) are -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. +All anchor coordinates (``pt``, ``hint``) are stored in metres (= Blender world +space). Profile vertex positions computed from IFC attributes are converted to +metres via ``ifcopenshell.util.unit.calculate_unit_scale``. Anchor schema (JSON-serialisable dict stored in BBIM_Dimension.Anchors): { "guid": str | None, # element GlobalId; None → WORLD type (free point) - "type": str, # "FACE" | "CIRCLE_CENTER" | "WORLD" + "type": str, # "FACE" | "EDGE" | "VERTEX" | "WORLD" "addr": { - "method": str, # "ANALYTIC" | "TESS_INDEX" | "TESS_FINGERPRINT" - "repr_id": int, # STEP id of representation item (ANALYTIC / TESS_INDEX) - "repr_type": str, # IFC class of representation item - "face_role": str, # "TOP" | "BOTTOM" | "SIDE_" (IfcExtrudedAreaSolid only) - "tess_index": int, # coplanar face-group index (-1 = skip) - "fingerprint": { - "normal": [x, y, z], # world-space unit normal (IFC project units) - "area": float, # total face area - "centroid": [x, y, z] # area-weighted centroid - } + # FACE_NORMAL — face identified by element-local unit normal (platform-agnostic). + # Rotation-invariant: moving/rotating the element does not invalidate the anchor. + "method": "FACE_NORMAL", + "normal_local": [float, float, float], # element-local unit normal + + # PROFILE_LOCAL — point in IfcExtrudedAreaSolid profile-local space. + # profile_x_m / profile_y_m are in the profile's 2-D local frame, metres. + # extrusion_z_m is distance along the normalized ExtrudedDirection, metres. + # snap: "VERTEX" → re-snap to nearest profile vertex on resolution, + # "EDGE" → use coords directly (midpoint between two vertices). + "method": "PROFILE_LOCAL", + "profile_x_m": float, + "profile_y_m": float, + "extrusion_z_m": float, + "snap": "VERTEX" | "EDGE", + + # LAYER_BOUNDARY — face/boundary of a material layer (platform-agnostic). + # Resolution tiers (first match wins): + # layer_id → IfcMaterialLayer STEP id (stable in NativeBIM) + # layer_material_id → IfcMaterial STEP id + # layer_material_name → IfcMaterial.Name + # layer_category → IfcMaterialLayer.Category (IFC4) + # layer_index → 0-based position in layer set + # offset_from_ref_m → proximity to nearest boundary (geometric fallback) + "method": "LAYER_BOUNDARY", + "layer_id": int, + "layer_material_id": int, + "layer_material_name": str, + "layer_category": str, + "layer_index": int, + "face": "start" | "end", + "offset_from_ref_m": float, } | None, - "hint": [x, y, z] | None, # original click position for disambiguation - "pt": [x, y, z] # last resolved position — used as fallback + "hint": [x, y, z] | None, + "pt": [x, y, z] # cached world position; universal static fallback } """ @@ -75,9 +94,9 @@ def resolve_anchor( Resolution order: 1. WORLD / null guid → return stored ``pt`` directly. - 2. ANALYTIC for IfcExtrudedAreaSolid → analytical TOP/BOTTOM face centre. - 3. TESS_INDEX → centroid of a pre-recorded face group by index. - 4. TESS_FINGERPRINT → best face group matched by normal + centroid proximity. + 2. PROFILE_LOCAL → analytically resolve via IfcExtrudedAreaSolid profile coords. + 3. PROFILE_VERT / PROFILE_EDGE → legacy index-based resolution (backwards compat). + 4. FACE_NORMAL → match face group by element-local normal (rotation-invariant). 5. Fallback → stored ``pt``. :param file: The open IFC file. @@ -103,15 +122,26 @@ def resolve_anchor( return _pt_or_none(anchor.get("pt")) addr = anchor.get("addr") or {} - method = addr.get("method", "TESS_FINGERPRINT") - # --- 1. Analytical path (fast, exact) --- - if method == "ANALYTIC" and addr.get("repr_type") == "IfcExtrudedAreaSolid": - pt = _resolve_extruded_area_solid_analytic(file, element, addr, placement_override) - if pt is not None: - return pt + if anchor_type in ("VERTEX", "EDGE"): + method = addr.get("method", "") + if method == "PROFILE_LOCAL": + pt = _resolve_profile_local_anchor(file, element, addr, placement_override) + elif method == "PROFILE_VERT": + pt = _resolve_profile_vert_anchor(file, element, addr, placement_override) + elif method == "PROFILE_EDGE": + pt = _resolve_profile_edge_anchor(file, element, addr, placement_override) + else: + pt = None + return pt if pt is not None else _pt_or_none(anchor.get("pt")) - # --- 2 & 3. Tessellation path (universal) --- + # FACE anchor — check method first; LAYER_BOUNDARY does not need tessellation. + method = addr.get("method", "FACE_NORMAL") + if method == "LAYER_BOUNDARY": + pt = _resolve_layer_boundary_anchor(file, element, addr, placement_override) + return pt if pt is not None else _pt_or_none(anchor.get("pt")) + + # FACE_NORMAL — match by element-local normal (rotation-invariant). shape = _get_shape(file, element, settings, shape_cache) if shape is None: return _pt_or_none(anchor.get("pt")) @@ -122,10 +152,6 @@ def resolve_anchor( groups = _group_coplanar_tris(verts, tris) group_props = [_face_group_props(g, verts, tris) for g in groups] - - # group_props centroids/normals are in LOCAL metres (no USE_WORLD_COORDS). - # Build world-space equivalents using placement_override (Blender matrix_world) - # when available, otherwise fall back to element.ObjectPlacement from IFC. world_group_props = [ { "centroid": _local_to_world_m(file, element, gp["centroid"], placement_override), @@ -135,33 +161,11 @@ def resolve_anchor( for gp in group_props ] - fingerprint = addr.get("fingerprint") + # Support both new FACE_NORMAL (addr.normal_local) and legacy fingerprint field. + normal_local = addr.get("normal_local") or (addr.get("fingerprint") or {}).get("normal_local") hint = anchor.get("hint") - 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 - - return _pt_or_none(anchor.get("pt")) + pt = _find_by_local_normal(group_props, world_group_props, normal_local, hint) if normal_local else None + return pt if pt is not None else _pt_or_none(anchor.get("pt")) def build_anchor_from_hit( @@ -173,11 +177,11 @@ def build_anchor_from_hit( shape_cache: Optional[dict] = None, placement_override: Optional[dict] = None, ) -> dict: - """Build an anchor dict from a viewport ray-cast hit. + """Build a FACE/FACE_NORMAL anchor dict from a viewport ray-cast hit. - Tessellates the element, finds the best-matching face group for the hit - normal/location, computes the fingerprint, and optionally detects an - IfcExtrudedAreaSolid face role (TOP/BOTTOM) for the analytical path. + Tessellates the element, finds the best-matching face group, and stores the + element-local unit normal. The local normal is rotation-invariant: moving or + rotating the element does not invalidate the anchor. :param file: The open IFC file. :param element: The IFC element that was hit. @@ -190,13 +194,7 @@ def build_anchor_from_hit( :return: Anchor dict ready for JSON serialisation into BBIM_Dimension. """ shape = _get_shape(file, element, settings, shape_cache) - - tess_index = -1 - fingerprint: dict = { - "normal": list(hit_normal_ifc), - "area": 0.0, - "centroid": list(hit_location_ifc), - } + normal_local: list = list(hit_normal_ifc) # fallback: world normal as approximation if shape is not None: verts, tris = _extract_mesh(shape) @@ -212,31 +210,15 @@ def build_anchor_from_hit( ] best = _best_group(world_group_props, hit_normal_ifc, hit_location_ifc) 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, placement_override - ) - method = "ANALYTIC" if repr_type == "IfcExtrudedAreaSolid" else "TESS_FINGERPRINT" + best_idx, _ = best + normal_local = list(local_group_props[best_idx]["normal"]) return { "guid": element.GlobalId, "type": "FACE", "addr": { - "method": method, - "repr_id": repr_id, - "repr_type": repr_type, - "face_role": face_role, - "tess_index": tess_index, - "fingerprint": fingerprint, + "method": "FACE_NORMAL", + "normal_local": normal_local, }, "hint": list(hit_location_ifc), "pt": list(hit_location_ifc), @@ -254,6 +236,673 @@ def make_world_anchor(pt_ifc: tuple[float, float, float]) -> dict: } +def build_anchor_from_profile_vert( + file: ifcopenshell.file, + element: ifcopenshell.entity_instance, + snap: dict, +) -> dict: + """Build a VERTEX/PROFILE_LOCAL anchor from a profile snap candidate. + + :param snap: Dict from ``get_profile_snap_candidates`` with keys + ``snap_world``, ``profile_x_m``, ``profile_y_m``, ``extrusion_z_m``. + :return: Anchor dict ready for JSON serialisation. + """ + world_pos = snap["snap_world"] + return { + "guid": element.GlobalId, + "type": "VERTEX", + "addr": { + "method": "PROFILE_LOCAL", + "profile_x_m": snap["profile_x_m"], + "profile_y_m": snap["profile_y_m"], + "extrusion_z_m": snap["extrusion_z_m"], + "snap": "VERTEX", + }, + "hint": list(world_pos), + "pt": list(world_pos), + } + + +def build_anchor_from_profile_edge( + file: ifcopenshell.file, + element: ifcopenshell.entity_instance, + snap: dict, +) -> dict: + """Build an EDGE/PROFILE_LOCAL anchor from a profile snap candidate. + + :param snap: Dict from ``get_profile_snap_candidates`` with keys + ``snap_world``, ``profile_x_m``, ``profile_y_m``, ``extrusion_z_m``. + :return: Anchor dict ready for JSON serialisation. + """ + world_pos = snap["snap_world"] + return { + "guid": element.GlobalId, + "type": "EDGE", + "addr": { + "method": "PROFILE_LOCAL", + "profile_x_m": snap["profile_x_m"], + "profile_y_m": snap["profile_y_m"], + "extrusion_z_m": snap["extrusion_z_m"], + "snap": "EDGE", + }, + "hint": list(world_pos), + "pt": list(world_pos), + } + + +def get_layer_snap_candidates( + file: ifcopenshell.file, + element: ifcopenshell.entity_instance, + placement_override: Optional[dict] = None, +) -> list[dict]: + """Return one snap candidate per IfcMaterialLayer boundary in the element. + + Each candidate dict has: + - ``type``: ``"VERTEX"`` + - ``snap_world``: mid-height world position on the boundary, metres + - ``v0``, ``v1``: base and top endpoints of the boundary line (for GPU draw) + - ``layer``: the IfcMaterialLayer entity + - ``layer_index``: 0-based index in the layer set + - ``face``: ``"start"`` or ``"end"`` + - ``offset_from_ref_m``: signed offset from element origin along thickness axis + + Returns [] when the element has no IfcMaterialLayerSetUsage or IfcExtrudedAreaSolid. + """ + import ifcopenshell.util.element as ifc_elem + + material = ifc_elem.get_material(element, should_inherit=True) + if not material or not material.is_a("IfcMaterialLayerSetUsage"): + return [] + + usage = material + layers = usage.ForLayerSet.MaterialLayers + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file) + + solid = _get_extrusion_solid(file, element) + if solid is None: + return [] + + depth_m = float(solid.Depth) * unit_scale + profile_pts_ifc = _get_profile_points_ifc(solid.SweptArea) + centroid_x_m = 0.0 + centroid_y_m = 0.0 + x_min_m = x_max_m = 0.0 + y_min_m = y_max_m = 0.0 + if profile_pts_ifc: + xs = [float(p[0]) * unit_scale for p in profile_pts_ifc] + ys = [float(p[1]) * unit_scale for p in profile_pts_ifc] + centroid_x_m = sum(xs) / len(xs) + centroid_y_m = sum(ys) / len(ys) + x_min_m, x_max_m = min(xs), max(xs) + y_min_m, y_max_m = min(ys), max(ys) + + ref_m = float(usage.OffsetFromReferenceLine) * unit_scale + direction_sense = (getattr(usage, "DirectionSense", None) or "POSITIVE") + thickness_axis = (getattr(usage, "LayerSetDirection", None) or "AXIS2") + sense = 1.0 if direction_sense == "POSITIVE" else -1.0 + + # Collect unique boundary offsets (adjacent layers share a boundary) + boundaries: list[tuple] = [] # (layer_index, layer, face, offset_m) + seen_offsets: set = set() + cumulative_m = ref_m + + for i, layer in enumerate(layers): + thickness_m = float(layer.LayerThickness) * unit_scale + start_m = cumulative_m + end_m = cumulative_m + sense * thickness_m + for face_label, offset_m in (("start", start_m), ("end", end_m)): + key = round(offset_m, 6) + if key not in seen_offsets: + seen_offsets.add(key) + boundaries.append((i, layer, face_label, offset_m)) + cumulative_m = end_m + + def _w(px, py, pz): + return _profile_coords_to_world_m(file, element, solid, px, py, pz, placement_override) + + candidates: list[dict] = [] + for layer_idx, layer, face, offset_m in boundaries: + if thickness_axis == "AXIS3": + # Boundary is a horizontal plane at z = offset_m; span full profile XY extent. + c0 = _w(x_min_m, y_min_m, offset_m) + c1 = _w(x_max_m, y_min_m, offset_m) + c2 = _w(x_max_m, y_max_m, offset_m) + c3 = _w(x_min_m, y_max_m, offset_m) + wp_mid = _w(centroid_x_m, centroid_y_m, offset_m) + elif thickness_axis == "AXIS1": + # Boundary is a plane at profile_x = offset_m; span full Y extent and Z depth. + c0 = _w(offset_m, y_min_m, 0.0) + c1 = _w(offset_m, y_max_m, 0.0) + c2 = _w(offset_m, y_max_m, depth_m) + c3 = _w(offset_m, y_min_m, depth_m) + wp_mid = _w(offset_m, centroid_y_m, depth_m * 0.5) + else: # AXIS2 + # Boundary is a plane at profile_y = offset_m; span full X extent and Z depth. + c0 = _w(x_min_m, offset_m, 0.0) + c1 = _w(x_max_m, offset_m, 0.0) + c2 = _w(x_max_m, offset_m, depth_m) + c3 = _w(x_min_m, offset_m, depth_m) + wp_mid = _w(centroid_x_m, offset_m, depth_m * 0.5) + + if wp_mid is None: + continue + seam_corners = [c for c in (c0, c1, c2, c3) if c is not None] + candidates.append({ + "type": "VERTEX", + "snap_world": wp_mid, + "seam_corners": seam_corners, + "layer": layer, + "layer_index": layer_idx, + "face": face, + "offset_from_ref_m": offset_m, + }) + + return candidates + + +def build_anchor_from_layer_boundary( + file: ifcopenshell.file, + element: ifcopenshell.entity_instance, + snap: dict, +) -> dict: + """Build a FACE/LAYER_BOUNDARY anchor from a layer snap candidate. + + :param snap: Dict from ``get_layer_snap_candidates`` with keys + ``snap_world``, ``layer``, ``layer_index``, ``face``, ``offset_from_ref_m``. + :return: Anchor dict ready for JSON serialisation into BBIM_Dimension. + """ + world_pos = snap["snap_world"] + layer = snap["layer"] + mat = getattr(layer, "Material", None) + return { + "guid": element.GlobalId, + "type": "FACE", + "addr": { + "method": "LAYER_BOUNDARY", + "layer_id": layer.id(), + "layer_material_id": mat.id() if mat else None, + "layer_material_name": mat.Name if mat else None, + "layer_category": getattr(layer, "Category", None), + "layer_index": snap["layer_index"], + "face": snap["face"], + "offset_from_ref_m": snap["offset_from_ref_m"], + }, + "hint": list(world_pos), + "pt": list(world_pos), + } + + +def get_profile_snap_candidates( + file: ifcopenshell.file, + element: ifcopenshell.entity_instance, + placement_override: Optional[dict] = None, +) -> list[dict]: + """Return snap candidates derived from an element's IfcExtrudedAreaSolid profile. + + Each candidate dict has: + - ``type``: ``"VERTEX"`` or ``"EDGE"`` + - ``snap_world``: world-space snap position in metres + - ``profile_x_m``, ``profile_y_m``: position in profile-local 2-D space, metres + - ``extrusion_z_m``: distance along ExtrudedDirection, metres + - ``snap``: ``"VERTEX"`` or ``"EDGE"`` (resolution hint) + - For EDGE candidates: ``v0``, ``v1`` world-space endpoints (for GPU indicator) + + Returns ``[]`` when the element has no IfcExtrudedAreaSolid representation. + + Candidates generated: + - Base vertices — each profile vertex at extrusion_z_m = 0 + - Top vertices — each profile vertex at extrusion_z_m = depth_m + - Base horiz edges — adjacent profile vertex pairs at extrusion_z_m = 0 + - Top horiz edges — adjacent profile vertex pairs at extrusion_z_m = depth_m + - Vertical edges — same profile vertex at z=0 and z=depth_m (mid z) + """ + solid = _get_extrusion_solid(file, element) + if solid is None: + return [] + + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file) + profile_pts_ifc = _get_profile_points_ifc(solid.SweptArea) + if not profile_pts_ifc: + return [] + + depth_m = float(solid.Depth) * unit_scale + n = len(profile_pts_ifc) + pts_m = [(float(p[0]) * unit_scale, float(p[1]) * unit_scale) for p in profile_pts_ifc] + candidates: list[dict] = [] + + def world_pos(pt_idx: int, z_m: float): + return _profile_vert_to_world_m(file, element, solid, pt_idx, z_m, placement_override) + + def midpoint(pa, pb): + return ((pa[0] + pb[0]) * 0.5, (pa[1] + pb[1]) * 0.5, (pa[2] + pb[2]) * 0.5) + + for z_m in (0.0, depth_m): + for i, (px_m, py_m) in enumerate(pts_m): + wp = world_pos(i, z_m) + if wp is None: + continue + candidates.append({ + "type": "VERTEX", "snap_world": wp, + "profile_x_m": px_m, "profile_y_m": py_m, + "extrusion_z_m": z_m, "snap": "VERTEX", + }) + + j = (i + 1) % n + wpb = world_pos(j, z_m) + if wpb is not None: + jpx_m, jpy_m = pts_m[j] + candidates.append({ + "type": "EDGE", + "snap_world": midpoint(wp, wpb), + "v0": wp, "v1": wpb, + "profile_x_m": (px_m + jpx_m) * 0.5, + "profile_y_m": (py_m + jpy_m) * 0.5, + "extrusion_z_m": z_m, "snap": "EDGE", + }) + + for i, (px_m, py_m) in enumerate(pts_m): + wpa = world_pos(i, 0.0) + wpb = world_pos(i, depth_m) + if wpa is not None and wpb is not None: + candidates.append({ + "type": "EDGE", + "snap_world": midpoint(wpa, wpb), + "v0": wpa, "v1": wpb, + "profile_x_m": px_m, "profile_y_m": py_m, + "extrusion_z_m": depth_m * 0.5, "snap": "EDGE", + }) + + return candidates + + +# --------------------------------------------------------------------------- +# Profile anchor resolution +# --------------------------------------------------------------------------- + + +def _get_extrusion_solid(file: ifcopenshell.file, element: ifcopenshell.entity_instance): + """Return the first IfcExtrudedAreaSolid found in the element's representations.""" + if not hasattr(element, "Representation") or not element.Representation: + return None + for rep in element.Representation.Representations: + for item in rep.Items: + solid = _unwrap_to_solid(item) + if solid is not None and solid.is_a("IfcExtrudedAreaSolid"): + return solid + return None + + +def _unwrap_to_solid(item): + """Recursively unwrap IfcMappedItem / IfcBooleanResult to find the underlying solid.""" + if item.is_a("IfcMappedItem"): + items = item.MappingSource.MappedRepresentation.Items + return _unwrap_to_solid(items[0]) if items else None + if item.is_a("IfcBooleanResult"): + return _unwrap_to_solid(item.FirstOperand) + return item + + +def _get_profile_points_ifc(profile) -> list[tuple[float, float]]: + """Return list of (x, y) profile vertices in IFC project units. + + Supports IfcRectangleProfileDef (derives 4 corners) and + IfcArbitraryClosedProfileDef with IfcIndexedPolyCurve or IfcPolyline outer curves. + """ + if profile.is_a("IfcRectangleProfileDef"): + xd = float(profile.XDim) + yd = float(profile.YDim) + hx, hy = xd / 2.0, yd / 2.0 + cx, cy = 0.0, 0.0 + if hasattr(profile, "Position") and profile.Position and profile.Position.Location: + loc = profile.Position.Location.Coordinates + cx, cy = float(loc[0]), float(loc[1]) + return [(cx - hx, cy - hy), (cx + hx, cy - hy), (cx + hx, cy + hy), (cx - hx, cy + hy)] + + if profile.is_a("IfcArbitraryClosedProfileDef"): + outer = profile.OuterCurve + if outer.is_a("IfcIndexedPolyCurve"): + coord_list = outer.Points.CoordList + return [(float(c[0]), float(c[1])) for c in coord_list] + if outer.is_a("IfcPolyline"): + pts = [(float(p.Coordinates[0]), float(p.Coordinates[1])) for p in outer.Points] + if len(pts) > 1 and pts[0] == pts[-1]: + pts = pts[:-1] + return pts + + return [] + + +def _apply_axis2placement3d_m( + file: ifcopenshell.file, + placement, + pt_m: tuple[float, float, float], +) -> tuple[float, float, float]: + """Apply an IfcAxis2Placement3D to a point that is already in metres. + + Location.Coordinates are in IFC project units and are scaled by unit_scale. + Rotation basis vectors (Axis, RefDirection) are dimensionless. + """ + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file) + loc = placement.Location.Coordinates + ox = float(loc[0]) * unit_scale + oy = float(loc[1]) * unit_scale + oz = float(loc[2]) * unit_scale + + 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 + + yx = zy * xz - zz * xy + yy = zz * xx - zx * xz + yz = zx * xy - zy * xx + + px, py, pz = pt_m + return ( + ox + px * xx + py * yx + pz * zx, + oy + px * xy + py * yy + pz * zy, + oz + px * xz + py * yz + pz * zz, + ) + + +def _profile_vert_to_world_m( + file: ifcopenshell.file, + element: ifcopenshell.entity_instance, + solid, + pt_idx: int, + extrusion_z_m: float, + placement_override: Optional[dict] = None, +) -> Optional[tuple[float, float, float]]: + """Convert a profile vertex index + extrusion distance to world-space metres. + + Coordinate flow (all in metres after unit_scale): + 1. Profile 2D point → scale IFC coords by unit_scale + 2. Add extrusion offset along normalized ExtrudedDirection + 3. Apply solid.Position (IfcAxis2Placement3D) → element-local metres + 4. Apply element ObjectPlacement → world metres + """ + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file) + + profile_pts_ifc = _get_profile_points_ifc(solid.SweptArea) + if not profile_pts_ifc or pt_idx < 0 or pt_idx >= len(profile_pts_ifc): + return None + + px_m = float(profile_pts_ifc[pt_idx][0]) * unit_scale + py_m = float(profile_pts_ifc[pt_idx][1]) * unit_scale + + dr = solid.ExtrudedDirection.DirectionRatios + mag = math.sqrt(sum(d * d for d in dr)) + if mag < 1e-12: + return None + ex, ey, ez = dr[0] / mag, dr[1] / mag, dr[2] / mag + + pt_solid_m = ( + px_m + ex * float(extrusion_z_m), + py_m + ey * float(extrusion_z_m), + ez * float(extrusion_z_m), + ) + + if solid.Position: + pt_elem_m = _apply_axis2placement3d_m(file, solid.Position, pt_solid_m) + else: + pt_elem_m = pt_solid_m + + return _local_to_world_m(file, element, pt_elem_m, placement_override) + + +def _profile_coords_to_world_m( + file: ifcopenshell.file, + element: ifcopenshell.entity_instance, + solid, + px_m: float, + py_m: float, + extrusion_z_m: float, + placement_override: Optional[dict] = None, +) -> Optional[tuple[float, float, float]]: + """Convert profile-local coordinates (metres) + extrusion distance to world-space metres. + + Identical coordinate flow to ``_profile_vert_to_world_m`` but takes the + profile 2-D position directly instead of a CoordList index. + """ + dr = solid.ExtrudedDirection.DirectionRatios + mag = math.sqrt(sum(d * d for d in dr)) + if mag < 1e-12: + return None + ex, ey, ez = dr[0] / mag, dr[1] / mag, dr[2] / mag + + pt_solid_m = ( + px_m + ex * float(extrusion_z_m), + py_m + ey * float(extrusion_z_m), + ez * float(extrusion_z_m), + ) + + if solid.Position: + pt_elem_m = _apply_axis2placement3d_m(file, solid.Position, pt_solid_m) + else: + pt_elem_m = pt_solid_m + + return _local_to_world_m(file, element, pt_elem_m, placement_override) + + +def _resolve_profile_local_anchor( + file: ifcopenshell.file, + element: ifcopenshell.entity_instance, + addr: dict, + placement_override: Optional[dict] = None, +) -> Optional[tuple[float, float, float]]: + """Resolve a PROFILE_LOCAL anchor to world-space metres. + + For VERTEX snap type, re-snaps to the nearest current profile vertex so that + the anchor tracks correctly even when CoordList ordering changes after an + ``update_representation`` call. For EDGE snap type the stored midpoint + coordinates are used directly (the midpoint is already between two vertices + and is unambiguous after reordering). + """ + profile_x_m = addr.get("profile_x_m") + profile_y_m = addr.get("profile_y_m") + extrusion_z_m = addr.get("extrusion_z_m") + snap_type = addr.get("snap", "VERTEX") + + if profile_x_m is None or profile_y_m is None or extrusion_z_m is None: + return None + + solid = _get_extrusion_solid(file, element) + if solid is None: + return None + + if snap_type == "VERTEX": + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file) + profile_pts = _get_profile_points_ifc(solid.SweptArea) + if profile_pts: + best_px, best_py, best_d2 = profile_x_m, profile_y_m, float("inf") + for pt in profile_pts: + px = float(pt[0]) * unit_scale + py = float(pt[1]) * unit_scale + d2 = (px - profile_x_m) ** 2 + (py - profile_y_m) ** 2 + if d2 < best_d2: + best_d2, best_px, best_py = d2, px, py + profile_x_m, profile_y_m = best_px, best_py + + return _profile_coords_to_world_m( + file, element, solid, profile_x_m, profile_y_m, extrusion_z_m, placement_override + ) + + +def _resolve_profile_vert_anchor( + file: ifcopenshell.file, + element: ifcopenshell.entity_instance, + addr: dict, + placement_override: Optional[dict] = None, +) -> Optional[tuple[float, float, float]]: + pt_idx = addr.get("pt_idx") + extrusion_z_m = addr.get("extrusion_z_m") + if pt_idx is None or extrusion_z_m is None: + return None + solid = _get_extrusion_solid(file, element) + if solid is None: + return None + return _profile_vert_to_world_m(file, element, solid, pt_idx, extrusion_z_m, placement_override) + + +def _resolve_profile_edge_anchor( + file: ifcopenshell.file, + element: ifcopenshell.entity_instance, + addr: dict, + placement_override: Optional[dict] = None, +) -> Optional[tuple[float, float, float]]: + """Resolve a PROFILE_EDGE anchor to the world-space midpoint of the stored edge.""" + pt_idx_a = addr.get("pt_idx_a") + extrusion_z_m_a = addr.get("extrusion_z_m_a") + pt_idx_b = addr.get("pt_idx_b") + extrusion_z_m_b = addr.get("extrusion_z_m_b") + if any(v is None for v in (pt_idx_a, extrusion_z_m_a, pt_idx_b, extrusion_z_m_b)): + return None + solid = _get_extrusion_solid(file, element) + if solid is None: + return None + pa = _profile_vert_to_world_m(file, element, solid, pt_idx_a, extrusion_z_m_a, placement_override) + pb = _profile_vert_to_world_m(file, element, solid, pt_idx_b, extrusion_z_m_b, placement_override) + if pa is None or pb is None: + return None + return ((pa[0] + pb[0]) * 0.5, (pa[1] + pb[1]) * 0.5, (pa[2] + pb[2]) * 0.5) + + +# --------------------------------------------------------------------------- +# Layer boundary anchor resolution +# --------------------------------------------------------------------------- + + +def _get_material_layer_usage(element: ifcopenshell.entity_instance): + """Return the IfcMaterialLayerSetUsage for an element, or None.""" + import ifcopenshell.util.element as ifc_elem + mat = ifc_elem.get_material(element, should_inherit=True) + return mat if (mat and mat.is_a("IfcMaterialLayerSetUsage")) else None + + +def _resolve_layer_boundary_anchor( + file: ifcopenshell.file, + element: ifcopenshell.entity_instance, + addr: dict, + placement_override: Optional[dict] = None, +) -> Optional[tuple[float, float, float]]: + """Resolve a LAYER_BOUNDARY anchor to world-space metres via a 6-tier fallback stack. + + Resolution tiers (first match wins): + 1. layer_id → IfcMaterialLayer STEP id (most stable) + 2. layer_material_id → IfcMaterial STEP id + 3. layer_material_name → IfcMaterial.Name + 4. layer_category → IfcMaterialLayer.Category (IFC4) + 5. layer_index → 0-based position in layer set + 6. Geometric fallback → caller uses stored ``pt`` + """ + usage = _get_material_layer_usage(element) + if usage is None: + return None + + layers = list(usage.ForLayerSet.MaterialLayers) + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file) + + # --- Identify target layer via tier stack --- + target_idx: Optional[int] = None + + layer_id = addr.get("layer_id") + if layer_id is not None and target_idx is None: + for i, layer in enumerate(layers): + if layer.id() == layer_id: + target_idx = i + break + + if target_idx is None: + layer_material_id = addr.get("layer_material_id") + if layer_material_id is not None: + for i, layer in enumerate(layers): + mat = getattr(layer, "Material", None) + if mat and mat.id() == layer_material_id: + target_idx = i + break + + if target_idx is None: + layer_material_name = addr.get("layer_material_name") + if layer_material_name: + for i, layer in enumerate(layers): + mat = getattr(layer, "Material", None) + if mat and mat.Name == layer_material_name: + target_idx = i + break + + if target_idx is None: + layer_category = addr.get("layer_category") + if layer_category: + for i, layer in enumerate(layers): + if getattr(layer, "Category", None) == layer_category: + target_idx = i + break + + if target_idx is None: + stored_idx = addr.get("layer_index") + if stored_idx is not None and 0 <= stored_idx < len(layers): + target_idx = stored_idx + + if target_idx is None: + return None # tier 6: caller will use stored pt + + # --- Compute boundary offset along thickness axis --- + ref_m = float(usage.OffsetFromReferenceLine) * unit_scale + direction_sense = (getattr(usage, "DirectionSense", None) or "POSITIVE") + thickness_axis = (getattr(usage, "LayerSetDirection", None) or "AXIS2") + sense = 1.0 if direction_sense == "POSITIVE" else -1.0 + + cumulative_m = ref_m + target_offset_m: Optional[float] = None + for i, layer in enumerate(layers): + thickness_m = float(layer.LayerThickness) * unit_scale + if i == target_idx: + face = addr.get("face", "start") + target_offset_m = cumulative_m if face == "start" else cumulative_m + sense * thickness_m + break + cumulative_m += sense * thickness_m + + if target_offset_m is None: + return None + + # --- Convert to world position at profile centroid, mid-extrusion --- + solid = _get_extrusion_solid(file, element) + if solid is None: + return None + + depth_m = float(solid.Depth) * unit_scale + profile_pts_ifc = _get_profile_points_ifc(solid.SweptArea) + centroid_x_m = 0.0 + centroid_y_m = 0.0 + if profile_pts_ifc: + xs = [float(p[0]) * unit_scale for p in profile_pts_ifc] + ys = [float(p[1]) * unit_scale for p in profile_pts_ifc] + centroid_x_m = sum(xs) / len(xs) + centroid_y_m = sum(ys) / len(ys) + + if thickness_axis == "AXIS3": + return _profile_coords_to_world_m( + file, element, solid, centroid_x_m, centroid_y_m, target_offset_m, placement_override + ) + elif thickness_axis == "AXIS1": + return _profile_coords_to_world_m( + file, element, solid, target_offset_m, centroid_y_m, depth_m * 0.5, placement_override + ) + else: # AXIS2 + return _profile_coords_to_world_m( + file, element, solid, centroid_x_m, target_offset_m, depth_m * 0.5, placement_override + ) + + # --------------------------------------------------------------------------- # Mesh extraction helpers # --------------------------------------------------------------------------- @@ -340,35 +989,6 @@ 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 @@ -490,7 +1110,7 @@ def _face_group_props(group: list[int], verts: list, tris: list) -> dict: # --------------------------------------------------------------------------- -# Fingerprint matching +# Face group matching # --------------------------------------------------------------------------- _NORMAL_MATCH_THRESHOLD = 0.02 # max dot-product deviation for normal match @@ -501,41 +1121,6 @@ def _dist(a, b) -> float: return math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2) -def _find_by_fingerprint( - group_props: list[dict], - fingerprint: dict, - hint: Optional[list], -) -> Optional[tuple[float, float, float]]: - """Return the centroid of the best-matching face group.""" - fp_normal = fingerprint["normal"] - fp_centroid = fingerprint["centroid"] - - best_score = -1.0 - best_centroid = None - - for props in group_props: - dot_val = _dot(props["normal"], fp_normal) - if dot_val < 1.0 - _NORMAL_MATCH_THRESHOLD: - continue # wrong-facing face - - # Score: prefer face whose centroid is closest to stored fingerprint centroid, - # then to the original click hint. - centroid_dist = _dist(props["centroid"], fp_centroid) - if centroid_dist > _CENTROID_MAX_DIST: - continue - - score = dot_val - centroid_dist / _CENTROID_MAX_DIST * 0.3 - if hint: - hint_dist = _dist(props["centroid"], hint) - score -= hint_dist / _CENTROID_MAX_DIST * 0.1 - - if score > best_score: - best_score = score - best_centroid = props["centroid"] - - return best_centroid - - def _best_group( group_props: list[dict], hit_normal: tuple, @@ -586,319 +1171,6 @@ def _find_by_local_normal( return best_centroid -# --------------------------------------------------------------------------- -# Analytical resolution — IfcExtrudedAreaSolid TOP / BOTTOM / SIDE_* -# --------------------------------------------------------------------------- - - -def _resolve_extruded_area_solid_analytic( - file: ifcopenshell.file, - element: ifcopenshell.entity_instance, - addr: dict, - placement_override: Optional[dict] = None, -) -> Optional[tuple[float, float, float]]: - """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", "") - _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") - if not repr_id: - return None - - try: - solid = file.by_id(repr_id) - except Exception: - return None - - if not solid.is_a("IfcExtrudedAreaSolid"): - return None - - try: - profile = solid.SweptArea - dir_ratios = solid.ExtrudedDirection.DirectionRatios - 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) - - 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)) - else: - local_pt = (px, py, pz) - - # Apply element placement — use placement_override (Blender matrix_world, metres) - # when available so that unsync'd viewport moves are reflected. - return _local_to_world_m(file, element, local_pt, placement_override) - except Exception: - return None - - -def _profile_centroid(profile) -> tuple[float, float]: - """Return (x, y) centroid of a profile def in its local 2D space.""" - if profile.is_a("IfcRectangleProfileDef"): - pos = profile.Position - if pos: - loc = pos.Location - return (loc.Coordinates[0], loc.Coordinates[1]) - return (0.0, 0.0) - if profile.is_a("IfcCircleProfileDef"): - pos = profile.Position - if pos: - loc = pos.Location - return (loc.Coordinates[0], loc.Coordinates[1]) - return (0.0, 0.0) - # Fallback for arbitrary profiles — use position location if available - if hasattr(profile, "Position") and profile.Position: - loc = profile.Position.Location - return (loc.Coordinates[0], loc.Coordinates[1]) - return (0.0, 0.0) - - -def _apply_axis2placement3d(placement, pt: tuple) -> tuple[float, float, float]: - """Apply an IfcAxis2Placement3D to a local point.""" - loc = placement.Location.Coordinates - ox, oy, oz = float(loc[0]), float(loc[1]), float(loc[2]) - - # Z axis (extrusion direction in placement space) - if placement.Axis: - zr = placement.Axis.DirectionRatios - zx, zy, zz = float(zr[0]), float(zr[1]), float(zr[2]) - else: - zx, zy, zz = 0.0, 0.0, 1.0 - - # X axis (ref direction) - if placement.RefDirection: - xr = placement.RefDirection.DirectionRatios - xx, xy, xz = float(xr[0]), float(xr[1]), float(xr[2]) - else: - xx, xy, xz = 1.0, 0.0, 0.0 - - # Y axis = Z × X - yx = zy * xz - zz * xy - yy = zz * xx - zx * xz - yz = zx * xy - zy * xx - - px, py, pz = pt - return ( - ox + px * xx + py * yx + pz * zx, - oy + px * xy + py * yy + pz * zy, - oz + px * xz + py * yz + pz * zz, - ) - - -def _mat_apply(m, pt: tuple) -> tuple[float, float, float]: - """Apply a 4×4 numpy placement matrix to a point.""" - x, y, z = float(pt[0]), float(pt[1]), float(pt[2]) - return ( - float(m[0][0] * x + m[0][1] * y + m[0][2] * z + m[0][3]), - float(m[1][0] * x + m[1][1] * y + m[1][2] * z + m[1][3]), - float(m[2][0] * x + m[2][1] * y + m[2][2] * z + m[2][3]), - ) - - -# --------------------------------------------------------------------------- -# IfcExtrudedAreaSolid face role detection -# --------------------------------------------------------------------------- - - -def _detect_extruded_face( - file: ifcopenshell.file, - element: ifcopenshell.entity_instance, - hit_location: tuple, - hit_normal: tuple, - placement_override: Optional[dict] = None, -) -> tuple[str, int, str]: - """Identify if the hit face is a face of an IfcExtrudedAreaSolid. - - Returns (repr_type, repr_id, face_role). - 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_elem) - if role: - return ("IfcExtrudedAreaSolid", solid.id(), role) - - return ("", -1, "") - - -def _unwrap_mapped(item): - """Unwrap IfcMappedItem to its underlying representation item (first item).""" - if item.is_a("IfcMappedItem"): - items = item.MappingSource.MappedRepresentation.Items - return items[0] if items else None - return item - - -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_extrude = _dot(extrude_dir, hit_normal_solid) - if dot_extrude > 0.99: - return "TOP" - 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 "" - - # --------------------------------------------------------------------------- # Misc helpers # ---------------------------------------------------------------------------