""" ================================================================ Script: Hidráulica — Panel Unificado v1.11 Blender 5.0 + Bonsai 0.8.5 ================================================================ N-Panel "Hidráulica" con 4 operaciones: • TEE v1.59 — 2 tubos → inserta TEE (principal + ramal) • CODO v8.86 — 2 tubos → une con codo 90° • TERMINAL v1.3 — 1 tubo → click extremo → accesorio terminal • REGISTRO v1.1 — 1 tubo → click punto → corta e inserta CAMBIOS v1.11 (sobre v1.10): PROTECCIÓN CTRL+Z: - Al cargar el script, Ctrl+Z se bloquea automáticamente - Muestra mensaje "Ctrl+Z bloqueado" en vez de crashear Bonsai - Toggle en el panel para activar/desactivar (🛡/🔓) - Al descargar el script (unregister), se restaura Ctrl+Z PANEL: - Removido botón "Deshacer" (backup .blend) - Nuevo toggle "Ctrl+Z Bloqueado/Libre" con icono candado MESH IDs (de v1.10): - sincronizar_mesh_ids() después de cada mep_connect - Previene el error #XXXXX not found al duplicar con Shift+D - Previene crash de gizmos por mesh data corrupto NOTA: Guardar el .blend ANTES de operar. ================================================================ """ import bpy import math import os import shutil import tempfile import numpy as np from mathutils import Vector, Matrix try: import ifcopenshell import ifcopenshell.api import ifcopenshell.util.placement import bonsai.tool as tool except ImportError: raise RuntimeError("ifcopenshell / bonsai no disponible") try: from bpy_extras import view3d_utils except ImportError: pass try: import ifcopenshell.util.system except ImportError: pass # ════════════════════════════════════════════════════════════ # TABLAS DE ACCESORIOS # ════════════════════════════════════════════════════════════ TEE_POR_DIAM = { '0.5': 'TePVC_0.5_SCH40', '0.75': 'TePVC_0.75_SCH40', '1.0': 'TePVC_1.0_SCH40', '0.5C': 'TeCPVC_0.5_CTS', '0.75C': 'TeCPVC_0.75_CTS', '1.0C': 'TeCPVC_1.0_CTS', } CODO_POR_DIAM = { '0.5': 'CodoPVC_0.5_SCH40', '0.75': 'CodoPVC_0.75_SCH40', '1.0': 'CodoPVC_1.0_SCH40', } CLASES_TERMINAL = [ ('IfcPipeFittingType', 'IfcPipeFittingType', 'Accesorios de tubería'), ('IfcFlowTerminalType', 'IfcFlowTerminalType', 'Terminales de flujo'), ('IfcSanitaryTerminalType','IfcSanitaryTerminalType','Terminales sanitarios'), ('IfcFlowControllerType', 'IfcFlowControllerType', 'Controladores (válvulas)'), ] # ════════════════════════════════════════════════════════════ # SISTEMA DE DESHACER (backup IFC) # ════════════════════════════════════════════════════════════ _UNDO_BACKUP_PATH = None def guardar_backup_ifc(): """Guarda una copia del archivo .blend actual como punto de restauración.""" global _UNDO_BACKUP_PATH try: filepath = bpy.data.filepath if not filepath: print(" ⚠ No se puede hacer backup: archivo no guardado") return False backup_dir = tempfile.gettempdir() backup_name = "hidro_undo_backup.blend" _UNDO_BACKUP_PATH = os.path.join(backup_dir, backup_name) # Guardar el archivo actual bpy.ops.wm.save_as_mainfile(filepath=_UNDO_BACKUP_PATH, copy=True) print(f" ✓ Backup guardado: {_UNDO_BACKUP_PATH}") return True except Exception as e: print(f" ⚠ Error al guardar backup: {e}") _UNDO_BACKUP_PATH = None return False def restaurar_backup_ifc(): """Restaura el archivo .blend desde el backup.""" global _UNDO_BACKUP_PATH if not _UNDO_BACKUP_PATH or not os.path.exists(_UNDO_BACKUP_PATH): return False, "No hay backup disponible" try: bpy.ops.wm.open_mainfile(filepath=_UNDO_BACKUP_PATH) print(f" ✓ Restaurado desde: {_UNDO_BACKUP_PATH}") _UNDO_BACKUP_PATH = None return True, "Restaurado correctamente" except Exception as e: return False, str(e) # ════════════════════════════════════════════════════════════ # UTILIDADES COMUNES # ════════════════════════════════════════════════════════════ def get_ifc(): ifc = tool.Ifc.get() if not ifc: raise RuntimeError("No hay archivo IFC abierto") return ifc def get_mesh_objects_selected(context): result = [] for o in context.selected_objects: try: if o.type == 'MESH': result.append(o) except Exception: pass return result def buscar_tipo_ifc(ifc, nombre): for t in ifc.by_type('IfcTypeObject'): if t.Name == nombre: return t return None def regenerar_tubo(nombre, context): obj = bpy.data.objects.get(nombre) if not obj: return bpy.ops.object.select_all(action='DESELECT') obj.select_set(True) context.view_layer.objects.active = obj try: bpy.ops.bim.regenerate_distribution_element() print(f" ✓ {nombre} regenerado") except Exception as e: print(f" ⚠ regenerate {nombre}: {e}") # ════════════════════════════════════════════════════════════ # AISLAMIENTO DE CONEXIONES # ════════════════════════════════════════════════════════════ def desconectar_puertos_tubo(ifc, elem_tubo): """ Desconecta TODOS los puertos del tubo de otros elementos. Retorna lista de (puerto_tubo, puerto_otro) para reconectar. CRÍTICO: esto evita que mep_connect/regenerate propague cambios. """ conexiones_guardadas = [] puertos = puertos_de_elem(ifc, elem_tubo) rels_removidas = set() for p_tubo in puertos: for rel in list(ifc.by_type('IfcRelConnectsPorts')): if rel.id() in rels_removidas: continue if rel.RelatingPort == p_tubo: p_otro = rel.RelatedPort conexiones_guardadas.append((p_tubo, p_otro)) print(f" ✂ Desconectando puerto #{p_tubo.id()} ↔ #{p_otro.id()}") rels_removidas.add(rel.id()) ifc.remove(rel) elif rel.RelatedPort == p_tubo: p_otro = rel.RelatingPort conexiones_guardadas.append((p_tubo, p_otro)) print(f" ✂ Desconectando puerto #{p_tubo.id()} ↔ #{p_otro.id()}") rels_removidas.add(rel.id()) ifc.remove(rel) if conexiones_guardadas: print(f" Desconectadas {len(conexiones_guardadas)} conexiones") return conexiones_guardadas def reconectar_puertos(ifc, conexiones, excluir_puertos=None, max_dist=0.15): """ Reconecta puertos previamente desconectados. Solo reconecta si los puertos están geométricamente cerca (< max_dist). Esto es CRÍTICO para la TEE: cuando se recorta seg1 y se crea seg2, las conexiones que antes iban al extremo que ahora pertenece a seg2 quedan lejos del seg1 recortado → no se reconectan (correcto). El seg2 ya fue conectado a la nueva TEE por conectar_aislado. excluir_puertos: set de IDs de puertos que NO deben reconectarse. max_dist: distancia máxima entre puertos para reconectar (metros). """ if excluir_puertos is None: excluir_puertos = set() for p_tubo, p_otro in conexiones: if p_tubo.id() in excluir_puertos or p_otro.id() in excluir_puertos: print(f" ⊘ Puerto #{p_tubo.id()} excluido (nuevo accesorio)") continue # Verificar que ambos puertos aún existen try: _ = p_tubo.id() _ = p_otro.id() except Exception: print(f" ⚠ Puerto ya no existe, no se reconecta") continue # Verificar proximidad geométrica pos_tubo, _ = info_puerto(p_tubo) pos_otro, _ = info_puerto(p_otro) dist = (pos_tubo - pos_otro).length if dist > max_dist: print(f" ⊘ Puerto #{p_tubo.id()} ↔ #{p_otro.id()} demasiado lejos " f"({dist:.3f}m > {max_dist}m) — no se reconecta") continue # Verificar que no estén ya conectados ya_conectado = False for rel in ifc.by_type('IfcRelConnectsPorts'): if (rel.RelatingPort == p_tubo and rel.RelatedPort == p_otro) or \ (rel.RelatingPort == p_otro and rel.RelatedPort == p_tubo): ya_conectado = True break if ya_conectado: print(f" ─ Puerto #{p_tubo.id()} ↔ #{p_otro.id()} ya conectados") continue try: ifcopenshell.api.run("system.connect_port", ifc, port1=p_tubo, port2=p_otro, direction="NOTDEFINED") print(f" ✓ Reconectado #{p_tubo.id()} ↔ #{p_otro.id()} (dist={dist:.3f}m)") except Exception as e: print(f" ⚠ Error reconectando #{p_tubo.id()}: {e}") # ════════════════════════════════════════════════════════════ # GEOMETRÍA COMÚN # ════════════════════════════════════════════════════════════ def extremos_objeto(obj): mw = obj.matrix_world verts = [mw @ v.co for v in obj.data.vertices] if not verts: loc = mw.translation.copy() return loc, loc eje_z = (mw.to_3x3() @ Vector((0, 0, 1))).normalized() centro = mw.translation proy = sorted([(eje_z.dot(v - centro), v) for v in verts], key=lambda x: x[0]) pmin, pmax = proy[0][0], proy[-1][0] tol = 0.001 def centroid(lst): s = Vector((0, 0, 0)) for v in lst: s += v return s / len(lst) vmin = [v for p, v in proy if abs(p - pmin) < tol] vmax = [v for p, v in proy if abs(p - pmax) < tol] return centroid(vmin), centroid(vmax) def interseccion_ejes_3d(ext_a, ext_b): p0a, p1a = ext_a p0b, p1b = ext_b d_a = (p1a - p0a).normalized() d_b = (p1b - p0b).normalized() r = p0a - p0b b_ = d_a.dot(d_b) c_ = d_a.dot(r) f_ = d_b.dot(r) den = 1.0 - b_ * b_ if abs(den) < 1e-6: p_int = (p0a + p0b) * 0.5 t, s = 0.0, 0.0 else: t = (b_ * f_ - c_) / den s = (f_ - b_ * c_) / den p_a = p0a + d_a * t p_b = p0b + d_b * s p_int = (p_a + p_b) / 2.0 dist = (p_a - p_b).length pa = p0a if (p0a - p_int).length < (p1a - p_int).length else p1a pb = p0b if (p0b - p_int).length < (p1b - p_int).length else p1b va = p_int - pa vb = p_int - pb dir_a = va.normalized() if va.length > 1e-6 else d_a dir_b = vb.normalized() if vb.length > 1e-6 else d_b angulo = math.degrees(dir_a.angle(dir_b)) print(f" t={t:.4f} s={s:.4f} dist_ejes={dist:.6f}") print(f" dir_a=({dir_a.x:.3f},{dir_a.y:.3f},{dir_a.z:.3f}) " f"dir_b=({dir_b.x:.3f},{dir_b.y:.3f},{dir_b.z:.3f})") print(f" ángulo: {angulo:.1f}°") return p_int, pa, pb, dir_a, dir_b def validar_interseccion_ejes(ext_a, ext_b, tolerancia=0.02): """ Verifica que los ejes de los 2 tubos se intersecten (o casi). Esto reemplaza la validación de coplanaridad, que era demasiado estricta: rechazaba tubos en planos XZ, YZ, o cualquier plano que no fuera XY. Lo que realmente importa es que la distancia mínima entre los ejes infinitos sea ~0, lo cual garantiza que los tubos se cruzan y se puede insertar un accesorio en el punto de cruce. Retorna (es_valido, dist_ejes). """ p0a, p1a = ext_a p0b, p1b = ext_b d_a = (p1a - p0a).normalized() d_b = (p1b - p0b).normalized() r = p0a - p0b b_ = d_a.dot(d_b) c_ = d_a.dot(r) f_ = d_b.dot(r) den = 1.0 - b_ * b_ if abs(den) < 1e-6: # Ejes paralelos — no se intersectan return False, float('inf') t = (b_ * f_ - c_) / den s = (f_ - b_ * c_) / den p_a = p0a + d_a * t p_b = p0b + d_b * s dist = (p_a - p_b).length return dist <= tolerancia, dist def construir_matriz_placement(col_z_nuevo, col_x_hint, posicion): cz = col_z_nuevo.normalized() cx_raw = col_x_hint - cz * cz.dot(col_x_hint) if cx_raw.length < 1e-6: ref = Vector((0, 1, 0)) if abs(cz.dot(Vector((0, 1, 0)))) < 0.99 \ else Vector((1, 0, 0)) cx_raw = ref - cz * cz.dot(ref) cx = cx_raw.normalized() cy = cz.cross(cx).normalized() mat = np.eye(4, dtype=np.float64) mat[:3, 0] = [cx.x, cx.y, cx.z] mat[:3, 1] = [cy.x, cy.y, cy.z] mat[:3, 2] = [cz.x, cz.y, cz.z] mat[:3, 3] = [posicion.x, posicion.y, posicion.z] return mat # ════════════════════════════════════════════════════════════ # PUERTOS # ════════════════════════════════════════════════════════════ def puertos_de_elem(ifc, elem): result = [] for rel in ifc.by_type('IfcRelNests'): if rel.RelatingObject == elem: for p in rel.RelatedObjects: if p.is_a('IfcDistributionPort'): result.append(p) return result def info_puerto(puerto): m = ifcopenshell.util.placement.get_local_placement(puerto.ObjectPlacement) pos = Vector((m[0][3], m[1][3], m[2][3])) ez = Vector((m[0][2], m[1][2], m[2][2])).normalized() return pos, ez def puerto_esta_conectado(ifc, puerto): for rel in ifc.by_type('IfcRelConnectsPorts'): if rel.RelatingPort == puerto or rel.RelatedPort == puerto: return True return False def clasificar_puertos_tee(puertos, dir_principal, dir_ramal, p_int): dp = dir_principal.normalized() dr = dir_ramal.normalized() mejor_ramal = -2.0 puerto_ramal = None for p in puertos: pos, ez = info_puerto(p) dot = abs(ez.dot(dr)) print(f" Puerto #{p.id()} pos=({pos.x:.3f},{pos.y:.3f},{pos.z:.3f}) " f"ez=({ez.x:.3f},{ez.y:.3f},{ez.z:.3f}) dot_ramal={dot:.3f}") if dot > mejor_ramal: mejor_ramal = dot puerto_ramal = p puertos_paso = [p for p in puertos if p != puerto_ramal] if len(puertos_paso) == 2: _, ez0 = info_puerto(puertos_paso[0]) _, ez1 = info_puerto(puertos_paso[1]) dot0 = ez0.dot(-dp) dot1 = ez1.dot(-dp) print(f" dot paso0 con -dp: {dot0:.3f}") print(f" dot paso1 con -dp: {dot1:.3f}") if dot0 > dot1: puerto_paso_a = puertos_paso[0] puerto_paso_b = puertos_paso[1] else: puerto_paso_a = puertos_paso[1] puerto_paso_b = puertos_paso[0] elif len(puertos_paso) == 1: puerto_paso_a = puertos_paso[0] puerto_paso_b = None else: puerto_paso_a = None puerto_paso_b = None return puerto_ramal, puerto_paso_a, puerto_paso_b # ════════════════════════════════════════════════════════════ # MATRICES DE ACCESORIOS # ════════════════════════════════════════════════════════════ def calcular_matriz_tee(p_int, dir_principal, dir_ramal): col_z = dir_principal.normalized() col_y_raw = dir_ramal - col_z * col_z.dot(dir_ramal) if col_y_raw.length > 1e-6: col_y = col_y_raw.normalized() else: ref = Vector((0, 1, 0)) if abs(col_z.dot(Vector((0, 1, 0)))) < 0.99 \ else Vector((1, 0, 0)) col_y = (ref - col_z * col_z.dot(ref)).normalized() col_x = col_z.cross(col_y).normalized() return Matrix([ [col_x.x, col_y.x, col_z.x, p_int.x], [col_x.y, col_y.y, col_z.y, p_int.y], [col_x.z, col_y.z, col_z.z, p_int.z], [0.0, 0.0, 0.0, 1.0 ] ]) def calcular_matriz_codo(p_int, dir_a, dir_b): col_y = dir_a.normalized() col_x_raw = dir_b - col_y * col_y.dot(dir_b) if col_x_raw.length > 1e-6: col_x = col_x_raw.normalized() else: ref = Vector((1, 0, 0)) if abs(col_y.dot(Vector((1, 0, 0)))) < 0.99 \ else Vector((0, 1, 0)) col_x = (ref - col_y * col_y.dot(ref)).normalized() col_z = col_x.cross(col_y).normalized() print(f" Codo col_X=({col_x.x:.3f},{col_x.y:.3f},{col_x.z:.3f})") print(f" Codo col_Y=({col_y.x:.3f},{col_y.y:.3f},{col_y.z:.3f})") print(f" Codo col_Z=({col_z.x:.3f},{col_z.y:.3f},{col_z.z:.3f})") return Matrix([ [col_x.x, col_y.x, col_z.x, p_int.x], [col_x.y, col_y.y, col_z.y, p_int.y], [col_x.z, col_y.z, col_z.z, p_int.z], [0.0, 0.0, 0.0, 1.0 ] ]) def calcular_matriz_terminal(p_extremo, dir_tubo): col_z = dir_tubo.normalized() ref = Vector((0, 0, 1)) if abs(col_z.dot(Vector((0, 0, 1)))) < 0.99 \ else Vector((1, 0, 0)) col_x = col_z.cross(ref).normalized() col_y = col_z.cross(col_x).normalized() return Matrix([ [col_x.x, col_y.x, col_z.x, p_extremo.x], [col_x.y, col_y.y, col_z.y, p_extremo.y], [col_x.z, col_y.z, col_z.z, p_extremo.z], [0.0, 0.0, 0.0, 1.0 ] ]) # ════════════════════════════════════════════════════════════ # DUPLICAR TUBO # ════════════════════════════════════════════════════════════ def duplicar_tubo(context, obj_principal): nombre_orig = obj_principal.name objetos_antes = set(o.name for o in bpy.data.objects) bpy.ops.object.select_all(action='DESELECT') obj_principal.select_set(True) context.view_layer.objects.active = obj_principal context.view_layer.update() try: bpy.ops.bim.override_object_duplicate_move() except Exception as e: print(f" ⚠ Error al duplicar: {e}") return None context.view_layer.update() nuevos = set(o.name for o in bpy.data.objects) - objetos_antes if nuevos: nombre_nuevo = list(nuevos)[0] print(f" ✓ Duplicado: '{nombre_nuevo}'") return nombre_nuevo candidatos = [o.name for o in bpy.data.objects if nombre_orig in o.name and o.name != nombre_orig] if candidatos: return candidatos[-1] print(f" ⚠ No se detectó nuevo objeto") return None # ════════════════════════════════════════════════════════════ # TERMINAL — utilidades # ════════════════════════════════════════════════════════════ def punto_3d_desde_cursor(context, event): region = context.region rv3d = context.region_data coord = (event.mouse_region_x, event.mouse_region_y) ray_origin = view3d_utils.region_2d_to_origin_3d(region, rv3d, coord) ray_dir = view3d_utils.region_2d_to_vector_3d(region, rv3d, coord) z_plane = context.scene.hidro_props.z_plano if abs(ray_dir.z) > 1e-6: t = (z_plane - ray_origin.z) / ray_dir.z return ray_origin + ray_dir * t return ray_origin + ray_dir * 10.0 def get_clases_terminal(scene, context): try: ifc = get_ifc() except Exception: return [('NONE', 'No hay IFC abierto', '')] items = [] for ident, nombre, desc in CLASES_TERMINAL: try: tipos = ifc.by_type(ident) if tipos and any(t.Name for t in tipos): items.append((ident, nombre, desc)) except Exception: pass if not items: return [('NONE', 'No se encontraron clases', '')] return items # (get_tipos_por_clase removida — reemplazada por get_tipos_terminal/get_tipos_registro) # ════════════════════════════════════════════════════════════ # FUNCIÓN COMÚN: preparar_tubo (recortar) # ════════════════════════════════════════════════════════════ def preparar_tubo(ifc, elem_tubo, obj_tubo, pos_puerto_tee_mundo, p_int): """ Recorta un tubo: placement + depth. IMPORTANTE: el tubo debe estar DESCONECTADO antes de llamar esto. """ ext = extremos_objeto(obj_tubo) p0, p1 = ext dir_exterior = (pos_puerto_tee_mundo - p_int).normalized() dot0 = (p0 - pos_puerto_tee_mundo).dot(dir_exterior) dot1 = (p1 - pos_puerto_tee_mundo).dot(dir_exterior) p_fijo = p0 if dot0 > dot1 else p1 nueva_lng = (p_fijo - pos_puerto_tee_mundo).length print(f" pos_tee=({pos_puerto_tee_mundo.x:.4f},{pos_puerto_tee_mundo.y:.4f},{pos_puerto_tee_mundo.z:.4f})") print(f" p_fijo =({p_fijo.x:.4f},{p_fijo.y:.4f},{p_fijo.z:.4f}) lng={nueva_lng:.4f}") dir_hacia_pfijo = (p_fijo - pos_puerto_tee_mundo).normalized() m_orig = ifcopenshell.util.placement.get_local_placement(elem_tubo.ObjectPlacement) col_x_orig = Vector((m_orig[0][0], m_orig[1][0], m_orig[2][0])) mat_np = construir_matriz_placement( col_z_nuevo=dir_hacia_pfijo, col_x_hint=col_x_orig, posicion=pos_puerto_tee_mundo ) col_z_final = Vector((mat_np[0, 2], mat_np[1, 2], mat_np[2, 2])) print(f" col_Z nuevo → ({col_z_final.x:.3f},{col_z_final.y:.3f},{col_z_final.z:.3f})") ifcopenshell.api.run("geometry.edit_object_placement", ifc, product=elem_tubo, matrix=mat_np, is_si=True) depth_modified = False for rep in elem_tubo.Representation.Representations: if rep.RepresentationIdentifier == 'Body': for item in rep.Items: if item.is_a('IfcExtrudedAreaSolid'): old_depth = item.Depth item.Depth = nueva_lng depth_modified = True print(f" ✓ Depth: {old_depth:.4f} → {nueva_lng:.4f}") break break if not depth_modified: print(f" ⚠ No se encontró IfcExtrudedAreaSolid") return obj_blender = tool.Ifc.get_object(elem_tubo) if obj_blender: obj_blender.matrix_world = Matrix(mat_np.tolist()) bpy.context.view_layer.update() import bonsai.core.geometry as cg tool.Geometry.clear_cache(elem_tubo) rep_body = None for rep in elem_tubo.Representation.Representations: if rep.RepresentationIdentifier == 'Body': rep_body = rep break if rep_body: try: cg.switch_representation( tool.Ifc, tool.Geometry, obj=obj_blender, representation=rep_body, ) except Exception as e: print(f" ⚠ switch_representation: {e}") m_ver = ifcopenshell.util.placement.get_local_placement(elem_tubo.ObjectPlacement) col_z_ver = Vector((m_ver[0][2], m_ver[1][2], m_ver[2][2])) pos_ver = Vector((m_ver[0][3], m_ver[1][3], m_ver[2][3])) p1_esp = pos_ver + col_z_ver * nueva_lng print(f" ✓ placement=({pos_ver.x:.4f},{pos_ver.y:.4f},{pos_ver.z:.4f}) " f"col_Z=({col_z_ver.x:.3f},{col_z_ver.y:.3f},{col_z_ver.z:.3f}) " f"depth={nueva_lng:.4f}") print(f" ✓ esperado: p0→p1=({p1_esp.x:.4f},{p1_esp.y:.4f},{p1_esp.z:.4f})") # ════════════════════════════════════════════════════════════ # FUNCIÓN COMÚN: sincronizar mesh IDs después de mep_connect # ════════════════════════════════════════════════════════════ def sincronizar_mesh_ids(ifc, *objetos): """ Después de mep_connect_elements, Bonsai puede destruir y recrear representaciones IFC. El mesh de Blender queda apuntando al ID viejo. Esta función sincroniza BIMMeshProperties.ifc_definition_id con la representación actual del elemento IFC. Si el fitting no tiene representación (Bonsai la eliminó), intenta recrearla desde el RepresentationMap del tipo. """ for obj in objetos: if obj is None or not obj.data: continue bim_props = obj.BIMObjectProperties ifc_def_id = bim_props.ifc_definition_id if not ifc_def_id or ifc_def_id <= 0: continue try: elem = ifc.by_id(ifc_def_id) except Exception: continue mesh_props = obj.data.BIMMeshProperties mesh_id = mesh_props.ifc_definition_id # Verificar si el mesh ID actual es válido mesh_ok = False if mesh_id and mesh_id > 0: try: ifc.by_id(mesh_id) mesh_ok = True except Exception: mesh_ok = False if mesh_ok: continue # ── Mesh ID roto — intentar sincronizar ── # Caso 1: el elemento tiene representación → actualizar mesh ID if hasattr(elem, 'Representation') and elem.Representation: reps = elem.Representation.Representations if reps: body_rep = None for rep in reps: if rep.RepresentationIdentifier == 'Body': body_rep = rep break if not body_rep: body_rep = reps[0] old_id = mesh_props.ifc_definition_id mesh_props.ifc_definition_id = body_rep.id() print(f" sync {obj.name}: mesh #{old_id} → #{body_rep.id()}") # Refrescar geometría try: import bonsai.core.geometry as cg tool.Geometry.clear_cache(elem) cg.switch_representation( tool.Ifc, tool.Geometry, obj=obj, representation=body_rep, ) except Exception as e: print(f" ⚠ switch_representation {obj.name}: {e}") continue # Caso 2: sin representación — recrear desde tipo tipo = None for rel in ifc.by_type('IfcRelDefinesByType'): if hasattr(rel, 'RelatedObjects') and elem in (rel.RelatedObjects or []): tipo = rel.RelatingType break if tipo and hasattr(tipo, 'RepresentationMaps') and tipo.RepresentationMaps: try: import ifcopenshell.util.representation as rep_util body_ctx = rep_util.get_context(ifc, "Model", "Body", "MODEL_VIEW") if body_ctx and tipo.RepresentationMaps: map_source = tipo.RepresentationMaps[0] origin = ifc.createIfcCartesianPoint((0.0, 0.0, 0.0)) d1 = ifc.createIfcDirection((1.0, 0.0, 0.0)) d2 = ifc.createIfcDirection((0.0, 1.0, 0.0)) d3 = ifc.createIfcDirection((0.0, 0.0, 1.0)) transform = ifc.createIfcCartesianTransformationOperator3D( d1, d2, origin, 1.0, d3) mapped_item = ifc.createIfcMappedItem(map_source, transform) shape_rep = ifc.createIfcShapeRepresentation( body_ctx, 'Body', 'MappedRepresentation', (mapped_item,)) if elem.Representation: old_reps = tuple(elem.Representation.Representations or ()) elem.Representation.Representations = old_reps + (shape_rep,) else: prod_def = ifc.createIfcProductDefinitionShape( None, None, (shape_rep,)) elem.Representation = prod_def mesh_props.ifc_definition_id = shape_rep.id() print(f" sync {obj.name}: representación recreada → #{shape_rep.id()}") try: import bonsai.core.geometry as cg tool.Geometry.clear_cache(elem) cg.switch_representation( tool.Ifc, tool.Geometry, obj=obj, representation=shape_rep, ) except Exception as e: print(f" ⚠ switch_representation {obj.name}: {e}") continue except Exception as e: print(f" ⚠ Error recreando rep {obj.name}: {e}") print(f" ⚠ {obj.name}: no se pudo sincronizar mesh ID (sin rep ni tipo)") # ════════════════════════════════════════════════════════════ # FUNCIÓN COMÚN: conectar_aislado # ════════════════════════════════════════════════════════════ def conectar_aislado(context, nombre_tubo, fitting_obj): """ Conecta tubo+fitting ocultando TODO lo demás. Después de mep_connect, sincroniza los mesh IDs de ambos objetos. """ obj_t = bpy.data.objects.get(nombre_tubo) if not obj_t: return False # Guardar y ocultar todo vis = {o.name: o.hide_viewport for o in bpy.data.objects} for o in bpy.data.objects: o.hide_viewport = True obj_t.hide_viewport = False fitting_obj.hide_viewport = False bpy.ops.object.select_all(action='DESELECT') obj_t.select_set(True) fitting_obj.select_set(True) context.view_layer.objects.active = fitting_obj ok = False try: r = bpy.ops.bim.mep_connect_elements() print(f" ✓ {nombre_tubo}: {r}") ok = True except Exception as e: print(f" ⚠ {nombre_tubo}: {e}") # Restaurar visibilidad for o in bpy.data.objects: if o.name in vis: o.hide_viewport = vis[o.name] # ── Sincronizar mesh IDs después de mep_connect ── if ok: try: ifc = get_ifc() sincronizar_mesh_ids(ifc, obj_t, fitting_obj) except Exception as e: print(f" ⚠ sync mesh IDs: {e}") return ok def verificar_y_reparar_conexiones(ifc, elem_fitting, tubos_info): """ Después de conectar_aislado, verifica que TODOS los puertos del fitting estén conectados. Si alguno quedó libre (lib), busca el puerto más cercano del tubo correspondiente y lo conecta manualmente. tubos_info: lista de (elem_tubo, nombre_tubo, pos_esperada) pos_esperada = posición del puerto de la TEE que debería conectar al tubo """ puertos_fitting = puertos_de_elem(ifc, elem_fitting) for p_fit in puertos_fitting: if puerto_esta_conectado(ifc, p_fit): continue pos_fit, ez_fit = info_puerto(p_fit) print(f" ⚠ Puerto fitting #{p_fit.id()} libre " f"pos=({pos_fit.x:.3f},{pos_fit.y:.3f},{pos_fit.z:.3f})") # Buscar el puerto de tubo más cercano que también esté libre mejor_dist = float('inf') mejor_p_tubo = None mejor_nombre = "" for elem_tubo, nombre_tubo, _ in tubos_info: if elem_tubo is None: continue puertos_tubo = puertos_de_elem(ifc, elem_tubo) for p_tubo in puertos_tubo: if puerto_esta_conectado(ifc, p_tubo): continue pos_tubo, _ = info_puerto(p_tubo) d = (pos_tubo - pos_fit).length if d < mejor_dist: mejor_dist = d mejor_p_tubo = p_tubo mejor_nombre = nombre_tubo if mejor_p_tubo and mejor_dist < 0.15: try: ifcopenshell.api.run("system.connect_port", ifc, port1=mejor_p_tubo, port2=p_fit, direction="NOTDEFINED") print(f" ✓ Reparado: #{mejor_p_tubo.id()} ({mejor_nombre}) " f"↔ #{p_fit.id()} (dist={mejor_dist:.3f}m)") except Exception as e: print(f" ⚠ Error reparando: {e}") elif mejor_p_tubo: print(f" Tubo más cercano: {mejor_nombre} dist={mejor_dist:.3f}m (muy lejos)") else: print(f" No se encontró puerto de tubo libre cercano") # ════════════════════════════════════════════════════════════ # OPERADOR: TEE v1.59 # ════════════════════════════════════════════════════════════ class HIDRO_OT_tee(bpy.types.Operator): bl_idname = "hidro.insertar_tee" bl_label = "Insertar TEE" bl_options = {'REGISTER'} bl_description = "Click tubo principal → Shift+Click ramal. Inserta TEE." def execute(self, context): sel = get_mesh_objects_selected(context) if len(sel) != 2: self.report({'ERROR'}, f"Selecciona 2 tubos ({len(sel)} sel.)") return {'CANCELLED'} # Backup antes de operar guardar_backup_ifc() obj_ramal = context.active_object if obj_ramal not in sel: obj_ramal = sel[1] obj_principal = [o for o in sel if o != obj_ramal][0] props = context.scene.hidro_props diam = props.diametro nombre_tee = TEE_POR_DIAM[diam] try: ifc = get_ifc() except Exception as e: self.report({'ERROR'}, str(e)) return {'CANCELLED'} tipo_ifc = buscar_tipo_ifc(ifc, nombre_tee) if not tipo_ifc: self.report({'ERROR'}, f"Tipo '{nombre_tee}' no encontrado") return {'CANCELLED'} for obj in sel: ent = tool.Ifc.get_entity(obj) if not ent or not ent.is_a("IfcPipeSegment"): self.report({'ERROR'}, f"'{obj.name}' no es IfcPipeSegment") return {'CANCELLED'} ext_r = extremos_objeto(obj_ramal) ext_p = extremos_objeto(obj_principal) p_int, _, _, dir_ramal, _ = interseccion_ejes_3d(ext_r, ext_p) d_princ = (ext_p[1] - ext_p[0]).normalized() angulo = math.degrees(math.acos( max(-1.0, min(1.0, abs(d_princ.dot(dir_ramal)))))) desv = abs(90.0 - angulo) print(f"\n=== TEE v1.59 ===") print(f" Principal : {obj_principal.name}") print(f" Ramal : {obj_ramal.name}") print(f" p_int : ({p_int.x:.4f},{p_int.y:.4f},{p_int.z:.4f})") print(f" d_princ : ({d_princ.x:.3f},{d_princ.y:.3f},{d_princ.z:.3f})") print(f" dir_ramal : ({dir_ramal.x:.3f},{dir_ramal.y:.3f},{dir_ramal.z:.3f})") print(f" ángulo : {angulo:.2f}° (desv={desv:.2f}°)") if desv > 10.0: self.report({'ERROR'}, f"Tubos no perpendiculares ({angulo:.1f}°).") return {'CANCELLED'} # Validar que los ejes se intersecten ejes_ok, dist_ejes = validar_interseccion_ejes(ext_p, ext_r) print(f" ejes_ok : {ejes_ok} (dist_ejes={dist_ejes:.4f}m)") if not ejes_ok: self.report({'ERROR'}, f"Los ejes de los tubos no se intersectan " f"(distancia={dist_ejes:.3f}m). Deben cruzarse.") return {'CANCELLED'} nombre_ramal = obj_ramal.name nombre_seg1 = obj_principal.name # ── PASO 1: Desconectar tubos de accesorios existentes ── elem_seg1 = tool.Ifc.get_entity(obj_principal) elem_ramal = tool.Ifc.get_entity(obj_ramal) print(f" ── Aislando conexiones existentes ──") conex_seg1 = desconectar_puertos_tubo(ifc, elem_seg1) conex_ramal = desconectar_puertos_tubo(ifc, elem_ramal) # ── PASO 2: Duplicar principal ── nombre_seg2 = duplicar_tubo(context, obj_principal) if nombre_seg2 is None: # Reconectar antes de abortar reconectar_puertos(ifc, conex_seg1) reconectar_puertos(ifc, conex_ramal) self.report({'ERROR'}, "No se pudo duplicar el tubo principal") return {'CANCELLED'} obj_s2 = bpy.data.objects.get(nombre_seg2) elem_s2 = tool.Ifc.get_entity(obj_s2) if obj_s2 else None # El duplicado hereda conexiones — desconectarlo también if elem_s2: conex_seg2 = desconectar_puertos_tubo(ifc, elem_s2) else: conex_seg2 = [] # ── PASO 3: Crear TEE ── cursor_backup = context.scene.cursor.location.copy() context.scene.cursor.location = (0.0, 0.0, 0.0) bpy.ops.bim.add_occurrence(relating_type_id=tipo_ifc.id()) obj_tee = context.active_object context.scene.cursor.location = cursor_backup if obj_tee is None: reconectar_puertos(ifc, conex_seg1) reconectar_puertos(ifc, conex_ramal) self.report({'ERROR'}, "add_occurrence no creó el objeto") return {'CANCELLED'} mat = calcular_matriz_tee(p_int, d_princ, dir_ramal) obj_tee.matrix_world = mat context.view_layer.update() bpy.context.view_layer.update() import bonsai.core.geometry as core_geom core_geom.edit_object_placement( tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj_tee) print(f" ✓ TEE '{obj_tee.name}' posicionada") # ── PASO 4: Clasificar puertos TEE ── ifc = get_ifc() elem_tee = tool.Ifc.get_entity(obj_tee) puertos = puertos_de_elem(ifc, elem_tee) print(f" Clasificando {len(puertos)} puertos:") puerto_ramal, puerto_paso_a, puerto_paso_b = clasificar_puertos_tee( puertos, d_princ, dir_ramal, p_int) print(f" → Ramal : #{puerto_ramal.id() if puerto_ramal else 'None'}") print(f" → Paso A : #{puerto_paso_a.id() if puerto_paso_a else 'None'} (seg1={nombre_seg1})") print(f" → Paso B : #{puerto_paso_b.id() if puerto_paso_b else 'None'} (seg2={nombre_seg2})") def pos_p_tee(puerto): m = ifcopenshell.util.placement.get_local_placement(puerto.ObjectPlacement) return Vector((m[0][3], m[1][3], m[2][3])) pos_paso_a = pos_p_tee(puerto_paso_a) if puerto_paso_a else None pos_paso_b = pos_p_tee(puerto_paso_b) if puerto_paso_b else None pos_ramal = pos_p_tee(puerto_ramal) if puerto_ramal else None # ── PASO 5: Recortar tubos (ya están desconectados) ── obj_s1 = bpy.data.objects.get(nombre_seg1) elem_s1 = tool.Ifc.get_entity(obj_s1) if obj_s1 else None obj_r = bpy.data.objects.get(nombre_ramal) elem_r = tool.Ifc.get_entity(obj_r) if obj_r else None GAP = 0.01 if elem_s1 and pos_paso_a: dir_s1 = (pos_paso_a - p_int).normalized() print(f" Preparando seg1 (gap={GAP}m)...") preparar_tubo(ifc, elem_s1, obj_s1, pos_paso_a + dir_s1 * GAP, p_int) if elem_s2 and pos_paso_b: dir_s2 = (pos_paso_b - p_int).normalized() print(f" Preparando seg2 (gap={GAP}m)...") preparar_tubo(ifc, elem_s2, obj_s2, pos_paso_b + dir_s2 * GAP, p_int) if elem_r and pos_ramal: dir_r = (pos_ramal - p_int).normalized() print(f" Preparando ramal (gap={GAP}m)...") preparar_tubo(ifc, elem_r, obj_r, pos_ramal + dir_r * GAP, p_int) # ── PASO 6: Conectar tubos a la TEE (aislado) ── conectar_aislado(context, nombre_seg1, obj_tee) conectar_aislado(context, nombre_seg2, obj_tee) conectar_aislado(context, nombre_ramal, obj_tee) # Verificar y reparar puertos que quedaron sin conectar print(f" ── Verificando conexiones TEE ──") ifc = get_ifc() elem_tee = tool.Ifc.get_entity(obj_tee) obj_s1 = bpy.data.objects.get(nombre_seg1) elem_s1 = tool.Ifc.get_entity(obj_s1) if obj_s1 else None obj_s2 = bpy.data.objects.get(nombre_seg2) elem_s2 = tool.Ifc.get_entity(obj_s2) if obj_s2 else None obj_r = bpy.data.objects.get(nombre_ramal) elem_r = tool.Ifc.get_entity(obj_r) if obj_r else None verificar_y_reparar_conexiones(ifc, elem_tee, [ (elem_s1, nombre_seg1, pos_paso_a), (elem_s2, nombre_seg2, pos_paso_b), (elem_r, nombre_ramal, pos_ramal), ]) # ── PASO 7: Restaurar TEE ── import bonsai.core.geometry as core_geom_final obj_tee.matrix_world = mat bpy.context.view_layer.update() core_geom_final.edit_object_placement( tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj_tee) print(f" ✓ TEE reposicionada: {np.array(obj_tee.matrix_world)[0:3,3].round(4)}") # ── PASO 8: Reconectar puertos previos ── # Los puertos que ahora están conectados a la TEE NO se reconectan print(f" ── Reconectando puertos previos ──") reconectar_puertos(ifc, conex_seg1) reconectar_puertos(ifc, conex_ramal) # seg2 es duplicado, sus conexiones heredadas no deben reconectarse # ── Verificación final ── print(f" ── Verificación final ──") for nombre in [nombre_seg1, nombre_seg2, nombre_ramal]: obj_check = bpy.data.objects.get(nombre) if obj_check: p0c, p1c = extremos_objeto(obj_check) print(f" {nombre}: p0=({p0c.x:.4f},{p0c.y:.4f},{p0c.z:.4f}) " f"→ p1=({p1c.x:.4f},{p1c.y:.4f},{p1c.z:.4f})") print(f" ✓ Proceso completo") bpy.ops.object.select_all(action='DESELECT') obj_tee.select_set(True) context.view_layer.objects.active = obj_tee self.report({'INFO'}, f"✓ {nombre_tee} [v1.59] insertada") return {'FINISHED'} # ════════════════════════════════════════════════════════════ # OPERADOR: CODO v8.84 # ════════════════════════════════════════════════════════════ class HIDRO_OT_codo(bpy.types.Operator): bl_idname = "hidro.unir_con_codo" bl_label = "Unir con Codo" bl_options = {'REGISTER'} bl_description = "Selecciona 2 tubos → une con codo 90°" def execute(self, context): sel = get_mesh_objects_selected(context) if len(sel) != 2: self.report({'ERROR'}, f"Selecciona 2 tubos ({len(sel)} sel.)") return {'CANCELLED'} guardar_backup_ifc() obj_a, obj_b = sel nombre_a = obj_a.name nombre_b = obj_b.name props = context.scene.hidro_props diam = props.diametro nombre_codo = CODO_POR_DIAM[diam] try: ifc = get_ifc() except Exception as e: self.report({'ERROR'}, str(e)) return {'CANCELLED'} tipo_ifc = buscar_tipo_ifc(ifc, nombre_codo) if not tipo_ifc: self.report({'ERROR'}, f"Tipo '{nombre_codo}' no encontrado") return {'CANCELLED'} ent_a = tool.Ifc.get_entity(obj_a) ent_b = tool.Ifc.get_entity(obj_b) if not ent_a or not ent_a.is_a("IfcPipeSegment"): self.report({'ERROR'}, f"'{nombre_a}' no es IfcPipeSegment") return {'CANCELLED'} if not ent_b or not ent_b.is_a("IfcPipeSegment"): self.report({'ERROR'}, f"'{nombre_b}' no es IfcPipeSegment") return {'CANCELLED'} print(f"\n=== UNIR CON CODO v8.86 ===") print(f" Tubo A: {nombre_a}") print(f" Tubo B: {nombre_b}") # Desconectar tubos print(f" ── Aislando conexiones ──") conex_a = desconectar_puertos_tubo(ifc, ent_a) conex_b = desconectar_puertos_tubo(ifc, ent_b) ext_a = extremos_objeto(obj_a) ext_b = extremos_objeto(obj_b) p_int, pa, pb, dir_a, dir_b = interseccion_ejes_3d(ext_a, ext_b) print(f" Intersec: ({p_int.x:.4f}, {p_int.y:.4f}, {p_int.z:.4f})") bpy.ops.bim.add_occurrence(relating_type_id=tipo_ifc.id()) obj_codo = context.active_object if obj_codo is None: reconectar_puertos(ifc, conex_a) reconectar_puertos(ifc, conex_b) self.report({'ERROR'}, "add_occurrence no creó el objeto") return {'CANCELLED'} print(f" ✓ Codo creado: '{obj_codo.name}'") mat = calcular_matriz_codo(p_int, dir_a, dir_b) obj_codo.matrix_world = mat context.view_layer.update() bpy.context.view_layer.update() import bonsai.core.geometry as core_geom core_geom.edit_object_placement( tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj_codo) print(f" ✓ Codo posicionado") # ── Obtener puertos del codo para recortar tubos ── elem_codo = tool.Ifc.get_entity(obj_codo) puertos_codo = puertos_de_elem(ifc, elem_codo) print(f" Puertos del codo:") for p in puertos_codo: pos, ez = info_puerto(p) print(f" #{p.id()} pos=({pos.x:.3f},{pos.y:.3f},{pos.z:.3f}) " f"ez=({ez.x:.3f},{ez.y:.3f},{ez.z:.3f})") # Identificar cuál puerto del codo va con cuál tubo # El puerto cuyo ez apunta más hacia dir_a → conecta a tubo A # (porque el ez del puerto apunta HACIA AFUERA, es decir hacia el tubo) def mejor_puerto_para_dir(puertos, direction): mejor = None mejor_dot = -2.0 for p in puertos: _, ez = info_puerto(p) # El puerto apunta en -dir (hacia afuera del codo, hacia el tubo) # El tubo viene en +dir (hacia p_int) # Entonces buscamos ez ≈ -dir d = ez.dot(-direction) if d > mejor_dot: mejor_dot = d mejor = p return mejor puerto_a = mejor_puerto_para_dir(puertos_codo, dir_a) puerto_b = mejor_puerto_para_dir(puertos_codo, dir_b) if puerto_a: pos_pa, _ = info_puerto(puerto_a) print(f" Puerto codo → tubo A: #{puerto_a.id()} pos=({pos_pa.x:.3f},{pos_pa.y:.3f},{pos_pa.z:.3f})") if puerto_b: pos_pb, _ = info_puerto(puerto_b) print(f" Puerto codo → tubo B: #{puerto_b.id()} pos=({pos_pb.x:.3f},{pos_pb.y:.3f},{pos_pb.z:.3f})") # ── Recortar tubos (como la TEE: Depth directo, sin regenerar) ── GAP = 0.01 MIN_TUBO = 0.05 # Longitud mínima de tubo después de recortar if puerto_a: dir_ext_a = (pos_pa - p_int).normalized() pos_gap_a = pos_pa + dir_ext_a * GAP # Verificar que el tubo tendrá longitud suficiente ext_a_check = extremos_objeto(obj_a) p_fijo_a = ext_a_check[0] if (ext_a_check[0] - pos_gap_a).dot(dir_ext_a) > \ (ext_a_check[1] - pos_gap_a).dot(dir_ext_a) else ext_a_check[1] lng_a = (p_fijo_a - pos_gap_a).length if lng_a < MIN_TUBO: print(f" ⚠ Tubo A demasiado corto después del corte ({lng_a:.3f}m)") self.report({'WARNING'}, f"Tubo A quedará muy corto ({lng_a:.3f}m). " f"Considere usar un tubo más largo.") print(f" Preparando tubo A (gap={GAP}m, lng={lng_a:.3f}m)...") preparar_tubo(ifc, ent_a, obj_a, pos_gap_a, p_int) if puerto_b: dir_ext_b = (pos_pb - p_int).normalized() pos_gap_b = pos_pb + dir_ext_b * GAP ext_b_check = extremos_objeto(obj_b) p_fijo_b = ext_b_check[0] if (ext_b_check[0] - pos_gap_b).dot(dir_ext_b) > \ (ext_b_check[1] - pos_gap_b).dot(dir_ext_b) else ext_b_check[1] lng_b = (p_fijo_b - pos_gap_b).length if lng_b < MIN_TUBO: print(f" ⚠ Tubo B demasiado corto después del corte ({lng_b:.3f}m)") self.report({'WARNING'}, f"Tubo B quedará muy corto ({lng_b:.3f}m). " f"Considere usar un tubo más largo.") print(f" Preparando tubo B (gap={GAP}m, lng={lng_b:.3f}m)...") preparar_tubo(ifc, ent_b, obj_b, pos_gap_b, p_int) # ── Conectar tubos al codo (aislado, sin regenerar) ── conectar_aislado(context, nombre_a, obj_codo) conectar_aislado(context, nombre_b, obj_codo) # Verificar y reparar puertos del codo print(f" ── Verificando conexiones codo ──") verificar_y_reparar_conexiones(ifc, elem_codo, [ (ent_a, nombre_a, None), (ent_b, nombre_b, None), ]) # Restaurar codo obj_codo.matrix_world = mat bpy.context.view_layer.update() core_geom.edit_object_placement( tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj_codo) # Reconectar previas print(f" ── Reconectando puertos previos ──") reconectar_puertos(ifc, conex_a) reconectar_puertos(ifc, conex_b) print(f" ── Verificación final ──") for nombre in [nombre_a, nombre_b]: obj_check = bpy.data.objects.get(nombre) if obj_check: p0c, p1c = extremos_objeto(obj_check) print(f" {nombre}: p0=({p0c.x:.4f},{p0c.y:.4f},{p0c.z:.4f}) " f"→ p1=({p1c.x:.4f},{p1c.y:.4f},{p1c.z:.4f})") bpy.ops.object.select_all(action='DESELECT') obj_codo.select_set(True) context.view_layer.objects.active = obj_codo self.report({'INFO'}, f"✓ {nombre_codo} conectado [v8.86]") return {'FINISHED'} # ════════════════════════════════════════════════════════════ # OPERADOR: TERMINAL v1.2 # ════════════════════════════════════════════════════════════ class HIDRO_OT_terminal(bpy.types.Operator): bl_idname = "hidro.insertar_terminal" bl_label = "Insertar Terminal" bl_options = {'REGISTER'} bl_description = "Selecciona 1 tubo → presiona → click cerca del extremo" _nombre_tubo = "" def invoke(self, context, event): sel = get_mesh_objects_selected(context) if len(sel) != 1: self.report({'ERROR'}, f"Selecciona 1 tubo ({len(sel)} sel.)") return {'CANCELLED'} obj = sel[0] try: ifc = get_ifc() ent = tool.Ifc.get_entity(obj) if not ent or not ent.is_a("IfcPipeSegment"): self.report({'ERROR'}, f"'{obj.name}' no es IfcPipeSegment") return {'CANCELLED'} except Exception as e: self.report({'ERROR'}, str(e)) return {'CANCELLED'} props = context.scene.hidro_props if props.term_tipo == 'NONE': self.report({'ERROR'}, "No hay tipos IFC disponibles") return {'CANCELLED'} self._nombre_tubo = obj.name ext = extremos_objeto(obj) props.z_plano = (ext[0].z + ext[1].z) / 2.0 context.window_manager.modal_handler_add(self) context.area.header_text_set( "Click cerca del extremo | Clic derecho: cancelar") return {'RUNNING_MODAL'} def modal(self, context, event): if event.type == 'MOUSEMOVE': return {'PASS_THROUGH'} if event.type == 'LEFTMOUSE' and event.value == 'PRESS': context.area.header_text_set(None) return self._ejecutar(context, event) if event.type in {'RIGHTMOUSE', 'ESC'}: context.area.header_text_set(None) self.report({'INFO'}, "Cancelado") return {'CANCELLED'} return {'RUNNING_MODAL'} def _ejecutar(self, context, event): guardar_backup_ifc() props = context.scene.hidro_props nombre_tipo = props.term_tipo obj_tubo = bpy.data.objects.get(self._nombre_tubo) if obj_tubo is None: self.report({'ERROR'}, f"Tubo '{self._nombre_tubo}' ya no existe") return {'CANCELLED'} try: ifc = get_ifc() except Exception as e: self.report({'ERROR'}, str(e)) return {'CANCELLED'} tipo_ifc = buscar_tipo_ifc(ifc, nombre_tipo) if not tipo_ifc: self.report({'ERROR'}, f"Tipo '{nombre_tipo}' no encontrado") return {'CANCELLED'} p_click = punto_3d_desde_cursor(context, event) ext = extremos_objeto(obj_tubo) p0, p1 = ext if (p0 - p_click).length <= (p1 - p_click).length: p_extremo, p_otro = p0, p1 else: p_extremo, p_otro = p1, p0 dir_tubo = (p_extremo - p_otro).normalized() print(f"\n=== INSERTAR TERMINAL v1.2 ===") print(f" Tubo : {self._nombre_tubo}") print(f" Tipo : {nombre_tipo}") print(f" Extremo : ({p_extremo.x:.4f}, {p_extremo.y:.4f}, {p_extremo.z:.4f})") print(f" Dir : ({dir_tubo.x:.3f}, {dir_tubo.y:.3f}, {dir_tubo.z:.3f})") # Desconectar tubo antes de operar elem_tubo = tool.Ifc.get_entity(obj_tubo) print(f" ── Aislando conexiones ──") conex_tubo = desconectar_puertos_tubo(ifc, elem_tubo) # Crear accesorio bpy.ops.bim.add_occurrence(relating_type_id=tipo_ifc.id()) obj_acc = context.active_object if obj_acc is None: reconectar_puertos(ifc, conex_tubo) self.report({'ERROR'}, "add_occurrence no creó el objeto") return {'CANCELLED'} print(f" ✓ Accesorio creado: '{obj_acc.name}'") mat = calcular_matriz_terminal(p_extremo, dir_tubo) obj_acc.matrix_world = mat context.view_layer.update() bpy.context.view_layer.update() import bonsai.core.geometry as core_geom core_geom.edit_object_placement( tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj_acc) print(f" ✓ Accesorio posicionado") # Conectar (aislado) ok = conectar_aislado(context, self._nombre_tubo, obj_acc) if not ok: # Intentar conexión manual print(f" Intentando conexión manual...") puertos_tubo = puertos_de_elem(ifc, elem_tubo) elem_acc = tool.Ifc.get_entity(obj_acc) puertos_acc = puertos_de_elem(ifc, elem_acc) # Puerto del tubo más cercano al extremo y libre mejor_pt = None mejor_d = float('inf') for p in puertos_tubo: if not puerto_esta_conectado(ifc, p): pos, _ = info_puerto(p) d = (pos - p_extremo).length if d < mejor_d: mejor_d = d mejor_pt = p mejor_pa = None mejor_d = float('inf') for p in puertos_acc: if not puerto_esta_conectado(ifc, p): pos, _ = info_puerto(p) d = (pos - p_extremo).length if d < mejor_d: mejor_d = d mejor_pa = p if mejor_pt and mejor_pa: try: ifcopenshell.api.run("system.connect_port", ifc, port1=mejor_pt, port2=mejor_pa, direction="NOTDEFINED") print(f" ✓ Conexión manual: #{mejor_pt.id()} ↔ #{mejor_pa.id()}") except Exception as e: print(f" ⚠ Conexión manual falló: {e}") regenerar_tubo(self._nombre_tubo, context) # Reconectar puertos previos print(f" ── Reconectando puertos previos ──") reconectar_puertos(ifc, conex_tubo) bpy.ops.object.select_all(action='DESELECT') obj_acc.select_set(True) context.view_layer.objects.active = obj_acc self.report({'INFO'}, f"✓ {nombre_tipo} insertado [v1.2]") return {'FINISHED'} # ════════════════════════════════════════════════════════════ # OPERADOR: REGISTRO v1.0 (modal — click punto de inserción) # ════════════════════════════════════════════════════════════ class HIDRO_OT_registro(bpy.types.Operator): bl_idname = "hidro.insertar_registro" bl_label = "Insertar Registro" bl_options = {'REGISTER'} bl_description = ( "Selecciona 1 tubo → presiona → click en el punto de inserción. " "Corta el tubo y coloca el registro." ) _nombre_tubo = "" def invoke(self, context, event): sel = get_mesh_objects_selected(context) if len(sel) != 1: self.report({'ERROR'}, f"Selecciona 1 tubo ({len(sel)} sel.)") return {'CANCELLED'} obj = sel[0] try: ifc = get_ifc() ent = tool.Ifc.get_entity(obj) if not ent or not ent.is_a("IfcPipeSegment"): self.report({'ERROR'}, f"'{obj.name}' no es IfcPipeSegment") return {'CANCELLED'} except Exception as e: self.report({'ERROR'}, str(e)) return {'CANCELLED'} props = context.scene.hidro_props if props.reg_tipo == 'NONE': self.report({'ERROR'}, "No hay tipos IFC disponibles") return {'CANCELLED'} self._nombre_tubo = obj.name ext = extremos_objeto(obj) props.z_plano = (ext[0].z + ext[1].z) / 2.0 context.window_manager.modal_handler_add(self) context.area.header_text_set( "Click en el punto del tubo donde insertar el registro | Clic derecho: cancelar") return {'RUNNING_MODAL'} def modal(self, context, event): if event.type == 'MOUSEMOVE': return {'PASS_THROUGH'} if event.type == 'LEFTMOUSE' and event.value == 'PRESS': context.area.header_text_set(None) return self._ejecutar(context, event) if event.type in {'RIGHTMOUSE', 'ESC'}: context.area.header_text_set(None) self.report({'INFO'}, "Cancelado") return {'CANCELLED'} return {'RUNNING_MODAL'} def _ejecutar(self, context, event): guardar_backup_ifc() props = context.scene.hidro_props nombre_tipo = props.reg_tipo obj_tubo = bpy.data.objects.get(self._nombre_tubo) if obj_tubo is None: self.report({'ERROR'}, f"Tubo '{self._nombre_tubo}' ya no existe") return {'CANCELLED'} try: ifc = get_ifc() except Exception as e: self.report({'ERROR'}, str(e)) return {'CANCELLED'} tipo_ifc = buscar_tipo_ifc(ifc, nombre_tipo) if not tipo_ifc: self.report({'ERROR'}, f"Tipo '{nombre_tipo}' no encontrado") return {'CANCELLED'} # ── Punto 3D del click proyectado al eje del tubo ── p_click = punto_3d_desde_cursor(context, event) ext = extremos_objeto(obj_tubo) p0, p1 = ext dir_tubo = (p1 - p0).normalized() # Proyectar click al eje del tubo t_proj = dir_tubo.dot(p_click - p0) t_proj = max(0.05, min(t_proj, (p1 - p0).length - 0.05)) # clamp p_insert = p0 + dir_tubo * t_proj print(f"\n=== INSERTAR REGISTRO v1.0 ===") print(f" Tubo : {self._nombre_tubo}") print(f" Tipo : {nombre_tipo}") print(f" p0 : ({p0.x:.4f}, {p0.y:.4f}, {p0.z:.4f})") print(f" p1 : ({p1.x:.4f}, {p1.y:.4f}, {p1.z:.4f})") print(f" Punto : ({p_insert.x:.4f}, {p_insert.y:.4f}, {p_insert.z:.4f})") print(f" Dir : ({dir_tubo.x:.3f}, {dir_tubo.y:.3f}, {dir_tubo.z:.3f})") # ── Desconectar tubo ── elem_tubo = tool.Ifc.get_entity(obj_tubo) print(f" ── Aislando conexiones ──") conex_tubo = desconectar_puertos_tubo(ifc, elem_tubo) # ── Duplicar tubo (seg2 = lado p1) ── nombre_seg1 = self._nombre_tubo nombre_seg2 = duplicar_tubo(context, obj_tubo) if nombre_seg2 is None: reconectar_puertos(ifc, conex_tubo) self.report({'ERROR'}, "No se pudo duplicar el tubo") return {'CANCELLED'} obj_s2 = bpy.data.objects.get(nombre_seg2) elem_s2 = tool.Ifc.get_entity(obj_s2) if obj_s2 else None if elem_s2: conex_s2 = desconectar_puertos_tubo(ifc, elem_s2) # ── Crear accesorio ── cursor_backup = context.scene.cursor.location.copy() context.scene.cursor.location = (0.0, 0.0, 0.0) bpy.ops.bim.add_occurrence(relating_type_id=tipo_ifc.id()) obj_reg = context.active_object context.scene.cursor.location = cursor_backup if obj_reg is None: reconectar_puertos(ifc, conex_tubo) self.report({'ERROR'}, "add_occurrence no creó el objeto") return {'CANCELLED'} print(f" ✓ Registro creado: '{obj_reg.name}'") # ── Detectar eje de flujo del accesorio desde sus puertos ── ifc = get_ifc() elem_reg = tool.Ifc.get_entity(obj_reg) puertos_reg = puertos_de_elem(ifc, elem_reg) eje_flujo_local = Vector((0, 0, 1)) # default: Z if len(puertos_reg) >= 2: pos_pa, _ = info_puerto(puertos_reg[0]) pos_pb, _ = info_puerto(puertos_reg[1]) delta = (pos_pb - pos_pa) if delta.length > 1e-6: eje_flujo_local = delta.normalized() print(f" Eje flujo local: ({eje_flujo_local.x:.3f},{eje_flujo_local.y:.3f},{eje_flujo_local.z:.3f})") # Construir matriz: mapear eje_flujo_local → dir_tubo abs_x = abs(eje_flujo_local.x) abs_y = abs(eje_flujo_local.y) abs_z = abs(eje_flujo_local.z) if abs_y >= abs_x and abs_y >= abs_z: # Flujo en Y local → col_Y = dir_tubo print(f" Orientación: flujo en Y → col_Y = dir_tubo") col_y = dir_tubo.normalized() ref = Vector((0, 0, 1)) if abs(col_y.dot(Vector((0, 0, 1)))) < 0.99 \ else Vector((1, 0, 0)) col_x = col_y.cross(ref).normalized() col_z = col_x.cross(col_y).normalized() mat = Matrix([ [col_x.x, col_y.x, col_z.x, p_insert.x], [col_x.y, col_y.y, col_z.y, p_insert.y], [col_x.z, col_y.z, col_z.z, p_insert.z], [0.0, 0.0, 0.0, 1.0 ] ]) elif abs_x >= abs_y and abs_x >= abs_z: # Flujo en X local → col_X = dir_tubo print(f" Orientación: flujo en X → col_X = dir_tubo") col_x = dir_tubo.normalized() ref = Vector((0, 0, 1)) if abs(col_x.dot(Vector((0, 0, 1)))) < 0.99 \ else Vector((0, 1, 0)) col_z = col_x.cross(ref).normalized() col_y = col_z.cross(col_x).normalized() mat = Matrix([ [col_x.x, col_y.x, col_z.x, p_insert.x], [col_x.y, col_y.y, col_z.y, p_insert.y], [col_x.z, col_y.z, col_z.z, p_insert.z], [0.0, 0.0, 0.0, 1.0 ] ]) else: # Flujo en Z local → col_Z = dir_tubo (estándar) print(f" Orientación: flujo en Z → col_Z = dir_tubo") mat = calcular_matriz_terminal(p_insert, dir_tubo) obj_reg.matrix_world = mat context.view_layer.update() bpy.context.view_layer.update() import bonsai.core.geometry as core_geom core_geom.edit_object_placement( tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj_reg) print(f" ✓ Registro posicionado") # ── Re-leer puertos después de posicionar ── ifc = get_ifc() elem_reg = tool.Ifc.get_entity(obj_reg) puertos_reg = puertos_de_elem(ifc, elem_reg) print(f" Puertos del registro ({len(puertos_reg)}):") for p in puertos_reg: pos, ez = info_puerto(p) print(f" #{p.id()} pos=({pos.x:.3f},{pos.y:.3f},{pos.z:.3f}) " f"ez=({ez.x:.3f},{ez.y:.3f},{ez.z:.3f})") # Identificar puertos: el más cercano a p0 → puerto_hacia_p0 if len(puertos_reg) >= 2: pos0_r, _ = info_puerto(puertos_reg[0]) pos1_r, _ = info_puerto(puertos_reg[1]) if (pos0_r - p0).length < (pos1_r - p0).length: puerto_hacia_p0 = puertos_reg[0] puerto_hacia_p1 = puertos_reg[1] else: puerto_hacia_p0 = puertos_reg[1] puerto_hacia_p1 = puertos_reg[0] else: puerto_hacia_p0 = puertos_reg[0] if puertos_reg else None puerto_hacia_p1 = puertos_reg[1] if len(puertos_reg) > 1 else None # ── Recortar seg1 (lado p0) y seg2 (lado p1) ── # CRÍTICO: seg1 debe ir de puerto_p0 hacia p0 (extremo original) # seg2 debe ir de puerto_p1 hacia p1 (extremo original) GAP = 0.01 elem_s1 = tool.Ifc.get_entity(bpy.data.objects.get(nombre_seg1)) obj_s1 = bpy.data.objects.get(nombre_seg1) if puerto_hacia_p0 and elem_s1: pos_pp0, _ = info_puerto(puerto_hacia_p0) dir_ext_0 = (pos_pp0 - p_insert).normalized() pos_gap_0 = pos_pp0 + dir_ext_0 * GAP print(f" Preparando seg1 (lado p0, gap={GAP}m)...") print(f" puerto_p0=({pos_pp0.x:.4f},{pos_pp0.y:.4f},{pos_pp0.z:.4f})") print(f" p0_orig =({p0.x:.4f},{p0.y:.4f},{p0.z:.4f})") preparar_tubo(ifc, elem_s1, obj_s1, pos_gap_0, p_insert) if puerto_hacia_p1 and elem_s2: pos_pp1, _ = info_puerto(puerto_hacia_p1) dir_ext_1 = (pos_pp1 - p_insert).normalized() pos_gap_1 = pos_pp1 + dir_ext_1 * GAP print(f" Preparando seg2 (lado p1, gap={GAP}m)...") print(f" puerto_p1=({pos_pp1.x:.4f},{pos_pp1.y:.4f},{pos_pp1.z:.4f})") print(f" p1_orig =({p1.x:.4f},{p1.y:.4f},{p1.z:.4f})") preparar_tubo(ifc, elem_s2, obj_s2, pos_gap_1, p_insert) # ── Conectar ── conectar_aislado(context, nombre_seg1, obj_reg) conectar_aislado(context, nombre_seg2, obj_reg) # Verificar conexiones print(f" ── Verificando conexiones registro ──") verificar_y_reparar_conexiones(ifc, elem_reg, [ (elem_s1, nombre_seg1, None), (elem_s2, nombre_seg2, None), ]) # Restaurar registro obj_reg.matrix_world = mat bpy.context.view_layer.update() core_geom.edit_object_placement( tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj_reg) # Reconectar puertos previos print(f" ── Reconectando puertos previos ──") reconectar_puertos(ifc, conex_tubo) # Verificación final print(f" ── Verificación final ──") for nombre in [nombre_seg1, nombre_seg2]: obj_check = bpy.data.objects.get(nombre) if obj_check: p0c, p1c = extremos_objeto(obj_check) print(f" {nombre}: p0=({p0c.x:.4f},{p0c.y:.4f},{p0c.z:.4f}) " f"→ p1=({p1c.x:.4f},{p1c.y:.4f},{p1c.z:.4f})") print(f" ✓ Proceso completo") bpy.ops.object.select_all(action='DESELECT') obj_reg.select_set(True) context.view_layer.objects.active = obj_reg self.report({'INFO'}, f"✓ {nombre_tipo} insertado como registro [v1.0]") return {'FINISHED'} # ════════════════════════════════════════════════════════════ # OPERADOR: DESHACER # ════════════════════════════════════════════════════════════ class HIDRO_OT_deshacer(bpy.types.Operator): """Intercepta Ctrl+Z — muestra advertencia en vez de crashear""" bl_idname = "hidro.interceptar_undo" bl_label = "Undo Bloqueado (Hidráulica)" bl_options = set() def execute(self, context): self.report({'WARNING'}, "⚠ Ctrl+Z bloqueado — Guarde el .blend antes de operar con Hidráulica") return {'CANCELLED'} # ── Estado global de protección ── _UNDO_PROTEGIDO = False _UNDO_KEYMAP = None _UNDO_KMI = None def activar_proteccion_undo(): """Registra un keymap que intercepta Ctrl+Z con nuestro operador.""" global _UNDO_PROTEGIDO, _UNDO_KEYMAP, _UNDO_KMI if _UNDO_PROTEGIDO: return # Ya está activo wm = bpy.context.window_manager kc = wm.keyconfigs.addon if kc is None: return km = kc.keymaps.new(name='Screen', space_type='EMPTY') # Ctrl+Z → nuestro interceptor (alta prioridad por ser addon keymap) kmi = km.keymap_items.new('hidro.interceptar_undo', 'Z', 'PRESS', ctrl=True) _UNDO_KEYMAP = km _UNDO_KMI = kmi _UNDO_PROTEGIDO = True print(" 🛡 Protección Ctrl+Z activada") def desactivar_proteccion_undo(): """Remueve el keymap interceptor y restaura Ctrl+Z normal.""" global _UNDO_PROTEGIDO, _UNDO_KEYMAP, _UNDO_KMI if not _UNDO_PROTEGIDO: return if _UNDO_KEYMAP and _UNDO_KMI: try: _UNDO_KEYMAP.keymap_items.remove(_UNDO_KMI) except Exception: pass _UNDO_KEYMAP = None _UNDO_KMI = None _UNDO_PROTEGIDO = False print(" 🔓 Protección Ctrl+Z desactivada") class HIDRO_OT_toggle_undo(bpy.types.Operator): bl_idname = "hidro.toggle_undo" bl_label = "Activar/Desactivar protección Ctrl+Z" bl_options = set() bl_description = ( "Activa: Ctrl+Z bloqueado (previene crash). " "Desactiva: Ctrl+Z normal de Blender" ) def execute(self, context): global _UNDO_PROTEGIDO if _UNDO_PROTEGIDO: desactivar_proteccion_undo() self.report({'INFO'}, "🔓 Ctrl+Z restaurado — cuidado con el undo") else: activar_proteccion_undo() self.report({'INFO'}, "🛡 Ctrl+Z bloqueado — protegido contra crash") return {'FINISHED'} # ════════════════════════════════════════════════════════════ # LISTAS DINÁMICAS SEPARADAS # ════════════════════════════════════════════════════════════ def get_clases_terminal(scene, context): try: ifc = get_ifc() except Exception: return [('NONE', 'No hay IFC abierto', '')] items = [] for ident, nombre, desc in CLASES_TERMINAL: try: tipos = ifc.by_type(ident) if tipos and any(t.Name for t in tipos): items.append((ident, nombre, desc)) except Exception: pass if not items: return [('NONE', 'No se encontraron clases', '')] return items def get_tipos_terminal(scene, context): try: ifc = get_ifc() except Exception: return [('NONE', 'No hay IFC abierto', '')] props = context.scene.hidro_props clase = props.term_clase if clase == 'NONE': return [('NONE', 'Elige una clase', '')] items = [] try: for t in ifc.by_type(clase): if t.Name: items.append((t.Name, t.Name, f"{clase}: {t.Name}")) except Exception: pass if not items: return [('NONE', f'No hay tipos en {clase}', '')] items.sort(key=lambda x: x[0]) return items def get_clases_registro(scene, context): try: ifc = get_ifc() except Exception: return [('NONE', 'No hay IFC abierto', '')] items = [] for ident, nombre, desc in CLASES_TERMINAL: try: tipos = ifc.by_type(ident) if tipos and any(t.Name for t in tipos): items.append((ident, nombre, desc)) except Exception: pass if not items: return [('NONE', 'No se encontraron clases', '')] return items def get_tipos_registro(scene, context): try: ifc = get_ifc() except Exception: return [('NONE', 'No hay IFC abierto', '')] props = context.scene.hidro_props clase = props.reg_clase if clase == 'NONE': return [('NONE', 'Elige una clase', '')] items = [] try: for t in ifc.by_type(clase): if t.Name: items.append((t.Name, t.Name, f"{clase}: {t.Name}")) except Exception: pass if not items: return [('NONE', f'No hay tipos en {clase}', '')] items.sort(key=lambda x: x[0]) return items # ════════════════════════════════════════════════════════════ # PROPIEDADES # ════════════════════════════════════════════════════════════ DIAM_ITEMS = [ ('0.5', '1/2" — SCH40', ''), ('0.75', '3/4" — SCH40', ''), ('1.0', '1" — SCH40', ''), ('0.5C', 'CPVC 1/2" — CTS', ''), ('0.75C', 'CPVC 3/4" — CTS', ''), ('1.0C', 'CPVC 1" — CTS', ''), ] class HIDRO_Props(bpy.types.PropertyGroup): # TEE / CODO diametro: bpy.props.EnumProperty( name="Diámetro", description="Diámetro del accesorio (TEE / Codo)", items=DIAM_ITEMS, default='0.5') # TERMINAL term_clase: bpy.props.EnumProperty( name="Clase Terminal", items=get_clases_terminal) term_tipo: bpy.props.EnumProperty( name="Tipo Terminal", items=get_tipos_terminal) # REGISTRO reg_clase: bpy.props.EnumProperty( name="Clase Registro", items=get_clases_registro) reg_tipo: bpy.props.EnumProperty( name="Tipo Registro", items=get_tipos_registro) # Z plano (para proyección del click) z_plano: bpy.props.FloatProperty( name="Altura plano (m)", default=0.0, precision=3, step=5) # ════════════════════════════════════════════════════════════ # PANEL UNIFICADO # ════════════════════════════════════════════════════════════ class HIDRO_PT_main(bpy.types.Panel): bl_label = "Hidráulica" bl_idname = "HIDRO_PT_main" bl_space_type = 'VIEW_3D' bl_region_type = 'UI' bl_category = 'Hidráulica' def draw(self, context): layout = self.layout props = context.scene.hidro_props sel = get_mesh_objects_selected(context) n = len(sel) # ── Estado de selección ── box = layout.box() if n == 0: box.label(text="Ningún tubo seleccionado", icon='ERROR') elif n == 1: box.label(text=f"✓ 1 tubo: {sel[0].name}", icon='CHECKMARK') elif n == 2: activo = context.active_object principal = [o for o in sel if o != activo] ramal = activo if activo in sel else sel[1] box.label(text="✓ 2 tubos seleccionados:", icon='CHECKMARK') if principal: box.label(text=f" Principal: {principal[0].name}") box.label(text=f" Ramal/B : {ramal.name if ramal else '?'}") else: box.label(text=f"{n} objetos (max 2)", icon='ERROR') layout.separator() # ── TEE + CODO (diámetro compartido) ── box_tc = layout.box() box_tc.label(text="TEE / Codo:", icon='PREFERENCES') row = box_tc.row(align=True) row.label(text="Diámetro") row.prop(props, "diametro", text="") row = box_tc.row(align=True) row.scale_y = 1.4 row.enabled = (n == 2) row.operator("hidro.insertar_tee", text="TEE", icon='OUTLINER_OB_EMPTY') row.operator("hidro.unir_con_codo", text="Codo", icon='CURVE_BEZCURVE') layout.separator() # ── TERMINAL (clase + tipo propios) ── box_term = layout.box() box_term.label(text="Terminal:", icon='IMPORT') row = box_term.row(align=True) row.label(text="Clase") row.prop(props, "term_clase", text="") row = box_term.row(align=True) row.label(text="Tipo") row.prop(props, "term_tipo", text="") row = box_term.row(align=True) row.scale_y = 1.4 row.enabled = (n == 1) row.operator("hidro.insertar_terminal", text="Insertar Terminal", icon='IMPORT') layout.separator() # ── REGISTRO (clase + tipo propios) ── box_reg = layout.box() box_reg.label(text="Registro:", icon='CONSTRAINT') row = box_reg.row(align=True) row.label(text="Clase") row.prop(props, "reg_clase", text="") row = box_reg.row(align=True) row.label(text="Tipo") row.prop(props, "reg_tipo", text="") row = box_reg.row(align=True) row.scale_y = 1.4 row.enabled = (n == 1) row.operator("hidro.insertar_registro", text="Insertar Registro", icon='CONSTRAINT') layout.separator() # ── Protección Ctrl+Z ── box_undo = layout.box() row = box_undo.row(align=True) if _UNDO_PROTEGIDO: row.operator("hidro.toggle_undo", text="🛡 Ctrl+Z Bloqueado", icon='LOCKED', depress=True) else: row.alert = True row.operator("hidro.toggle_undo", text="🔓 Ctrl+Z Libre (riesgo)", icon='UNLOCKED') layout.separator() # ── Instrucciones contextuales ── box_help = layout.box() col = box_help.column(align=True) col.scale_y = 0.8 if n == 2: col.label(text="TEE: Click → principal, Shift → ramal") col.label(text="Codo: selecciona 2 tubos → unir") elif n == 1: col.label(text="Terminal: click cerca del extremo") col.label(text="Registro: click en punto de inserción") else: col.label(text="Selecciona 1 o 2 tubos") col.separator() col.label(text="⚠ Guardar .blend antes de operar", icon='ERROR') # ════════════════════════════════════════════════════════════ # REGISTRO # ════════════════════════════════════════════════════════════ classes = [ HIDRO_Props, HIDRO_OT_tee, HIDRO_OT_codo, HIDRO_OT_terminal, HIDRO_OT_registro, HIDRO_OT_deshacer, HIDRO_OT_toggle_undo, HIDRO_PT_main, ] _OLD_PROPS = ['hidro_tee_props', 'hidro_codo_props', 'hidro_term_props'] def register(): for prop_name in _OLD_PROPS: if hasattr(bpy.types.Scene, prop_name): try: delattr(bpy.types.Scene, prop_name) except Exception: pass for cls in classes: bpy.utils.register_class(cls) bpy.types.Scene.hidro_props = bpy.props.PointerProperty(type=HIDRO_Props) # Auto-activar protección Ctrl+Z activar_proteccion_undo() print("✓ Hidráulica Panel Unificado v1.11 registrado") print(" • TEE v1.59 • CODO v8.86 • TERMINAL v1.3 • REGISTRO v1.1") print(" 🛡 Ctrl+Z bloqueado (toggle en panel)") def unregister(): # Desactivar protección antes de limpiar desactivar_proteccion_undo() for cls in reversed(classes): bpy.utils.unregister_class(cls) if hasattr(bpy.types.Scene, 'hidro_props'): del bpy.types.Scene.hidro_props if __name__ == "__main__": try: unregister() except Exception: pass register()