# /********************************************************************** # * * # * Copyright (c) 2026 Javier Braña * # * * # * Platform - Generación de plataforma desde trackers * # * * # * Construye la superficie diseñada (plataforma) a partir de la * # * disposición de trackers solares, incluyendo pendientes E-W, * # * conexiones entre filas y columnas. * # * * # * Esta plataforma se usa luego en EarthWorks para calcular * # * volúmenes de corte y relleno contra el terreno natural. * # * * # *********************************************************************** import FreeCAD import Part import math def get_tracker_rows(frames): """ Agrupa trackers en filas y columnas usando la lógica de Placement. Returns: (rows, columns): listas de listas, o (None, None) si falla """ try: import PVPlantPlacement return PVPlantPlacement.getRows(frames) except Exception: return None, None def build_platform(frames, slope_tolerance=10.0): """ Construye la plataforma (superficie diseñada) a partir de una lista de frames/trackers. Args: frames: lista de objetos tracker (con Placement, Setup, etc.) slope_tolerance: pendiente máxima E-W en grados Returns: Part.Solid con la plataforma, o None si no se puede generar """ rows, columns = get_tracker_rows(frames) if rows is None or not rows: FreeCAD.Console.PrintWarning( "No se pudieron agrupar los trackers en filas/columnas\n") return None all_faces = [] tools = [] # --- Fase 1: Lofts longitudinales (a lo largo de cada fila) --- for group in rows: lines = _generate_row_lines(group, slope_tolerance) tools.extend(lines["tools"]) if len(lines["edges"]) >= 2: try: loft = Part.makeLoft(lines["edges"], False, True, False) if loft and not loft.isNull(): all_faces.extend(loft.Faces) except Exception: pass # --- Fase 2: Lofts transversales (entre columnas) --- if columns: for group in rows: for frame in group: col, idx = _find_in_columns(frame, columns) tool = _find_tool(frame, tools) if tool is None or idx >= len(col) - 1: continue next_frame = col[idx + 1] next_tool = _find_tool(next_frame, tools) if next_tool is None: continue try: # Conectar borde izquierdo de frame con borde izquierdo del siguiente l1 = Part.LineSegment( tool[1].Vertexes[-1].Point, next_tool[1].Vertexes[0].Point ).toShape() # Conectar borde derecho l2 = Part.LineSegment( tool[2].Vertexes[-1].Point, next_tool[2].Vertexes[0].Point ).toShape() if l1 and l2: loft = Part.makeLoft([l1, l2], False, True, False) if loft and not loft.isNull(): all_faces.extend(loft.Faces) except Exception: pass if not all_faces: FreeCAD.Console.PrintWarning( "No se generaron caras para la plataforma\n") return None # --- Fase 3: Unir caras en un sólido --- try: import Utils.PVPlantUtils as utils # Intentar fuse progresivo platform = None for face in all_faces: if platform is None: platform = face else: try: platform = platform.fuse(face) except Exception: pass if platform is None: return None # Si es una shell, convertir a sólido if platform.ShapeType == "Shell": try: platform = Part.makeSolid(platform) except Exception: pass elif platform.ShapeType == "Compound": # Extraer caras y hacer shell faces_in_compound = [] for sub in platform.SubShapes: if sub.ShapeType == "Face": faces_in_compound.append(sub) if faces_in_compound: try: shell = Part.makeShell(faces_in_compound) platform = Part.makeSolid(shell) except Exception: pass return platform if not platform.isNull() else None except Exception as e: FreeCAD.Console.PrintError( f"Error al unir la plataforma: {e}\n") return None def add_platform_to_doc(platform, name="Platform"): """ Añade la plataforma como objeto visible en el documento activo. Args: platform: Part.Shape (Solid o Compound) name: nombre del objeto en el documento """ if platform is None: return None doc = FreeCAD.ActiveDocument if doc is None: return None obj = doc.addObject("Part::Feature", name) obj.Shape = platform obj.Label = name doc.recompute() return obj def _generate_row_lines(group, slope_tolerance): """ Genera las líneas de borde (izquierda/derecha) para una fila de trackers. Returns: dict con: - edges: lista de Part.Shape (líneas) - tools: lista de [frame, izq, der] para reconexión """ lines = {"edges": [], "tools": []} for i, frame in enumerate(group): if not hasattr(frame, "Setup"): continue aw = _angle_to_prev(group, i) ae = _angle_to_next(group, i) anf = (aw + ae) / 2 if anf > slope_tolerance: anf = slope_tolerance wdt = _get_tracker_width(frame) zz = wdt * math.sin(math.radians(anf)) base_line = _get_tracker_base_line(frame) # Borde izquierdo (sur) li = base_line.copy() li.Placement = frame.Placement li.Placement.Rotation = frame.Placement.Rotation li.Placement.Base.x -= wdt li.Placement.Base.z -= zz lines["edges"].append(li) # Borde derecho (norte) ld = base_line.copy() ld.Placement = frame.Placement ld.Placement.Rotation = frame.Placement.Rotation ld.Placement.Base.x += wdt ld.Placement.Base.z += zz lines["edges"].append(ld) lines["tools"].append([frame, li, ld]) return lines def _get_tracker_width(frame): """Ancho medio del tracker (mm).""" try: return int(frame.Setup.Width / 2) except Exception: return 0 def _get_tracker_base_line(frame): """ Línea base longitudinal del tracker, centrada en su origen local. """ try: lng = int(frame.Setup.Length / 2) return Part.LineSegment( FreeCAD.Vector(-lng, 0, 0), FreeCAD.Vector(lng, 0, 0) ).toShape() except Exception: # Fallback: BoundBox try: bb = frame.Setup.Shape.BoundBox return Part.LineSegment( FreeCAD.Vector(bb.XMin, 0, 0), FreeCAD.Vector(bb.XMax, 0, 0) ).toShape() except Exception: return Part.LineSegment( FreeCAD.Vector(-2000, 0, 0), FreeCAD.Vector(2000, 0, 0) ).toShape() def _angle_to_prev(group, i): if i <= 0: return 0 p0 = FreeCAD.Vector(group[i - 1].Placement.Base) p1 = FreeCAD.Vector(group[i].Placement.Base) return _angle_xz(p0, p1) def _angle_to_next(group, i): if i >= len(group) - 1: return 0 p1 = FreeCAD.Vector(group[i].Placement.Base) p2 = FreeCAD.Vector(group[i + 1].Placement.Base) return _angle_xz(p1, p2) def _angle_xz(v1, v2): """Ángulo en el plano XZ entre dos vectores (grados).""" dx = v2.x - v1.x dz = v2.z - v1.z return math.degrees(math.atan2(dz, dx)) def _find_in_columns(frame, columns): for col in columns: for group in col: if frame in group: return group, group.index(frame) return [], -1 def _find_tool(frame, tools): for t in tools: if t[0] == frame: return t return None