# /********************************************************************** # * * # * Copyright (c) 2026 Javier Braña * # * * # * PVPlantPlatform - Plataforma de diseño solar * # * * # * Es el elemento central del movimiento de tierras. Representa la * # * superficie diseñada generada a partir de la disposición de trackers.* # * * # * De ella dependen: * # * - EarthWorks: cut/fill entre plataforma y terreno natural * # * - Road: trazado de viales sobre la plataforma * # * - Drainage: drenaje superficial * # * - Trench: zanjas sobre la plataforma * # * * # *********************************************************************** import FreeCAD import Part import math if FreeCAD.GuiUp: import FreeCADGui, os from PySide.QtCore import QT_TRANSLATE_NOOP else: def QT_TRANSLATE_NOOP(ctxt, txt): return txt import PVPlantResources from PVPlantResources import DirIcons as DirIcons # ========================================================================= # Constructor # ========================================================================= def make_platform(frames=None, name="Platform"): """ Crea un objeto Platform en el documento activo. Args: frames: lista opcional de objetos tracker para inicializar name: nombre del objeto Returns: Objeto FeaturePython Platform, o None si no hay documento """ doc = FreeCAD.ActiveDocument if doc is None: return None obj = doc.addObject("Part::FeaturePython", name) Platform(obj) _ViewProviderPlatform(obj.ViewObject) obj.Label = name if frames: # Asignar SourceFrames como lista de enlaces obj.SourceFrames = frames doc.recompute() return obj # ========================================================================= # FeaturePython: Platform # ========================================================================= class Platform: """ Plataforma de diseño generada desde trackers solares. Propiedades principales: SourceFrames : Lista de trackers que definen la plataforma SlopeTolerance : Pendiente máxima E-W (grados) PlatformArea : Área de la plataforma (solo lectura) PlatformVolume : Volumen bajo la plataforma (solo lectura) Status : Estado del último cálculo """ def __init__(self, obj): self.setProperties(obj) obj.Proxy = self def setProperties(self, obj): pl = obj.PropertiesList if "SourceFrames" not in pl: obj.addProperty( "App::PropertyLinkList", "SourceFrames", "Platform", "Trackers que definen la plataforma") if "SlopeTolerance" not in pl: obj.addProperty( "App::PropertyFloat", "SlopeTolerance", "Platform", "Pendiente transversal máxima (grados)").SlopeTolerance = 10.0 if "PlatformArea" not in pl: obj.addProperty( "App::PropertyArea", "PlatformArea", "Platform", "Área total de la plataforma (solo lectura)") obj.setEditorMode("PlatformArea", 1) if "PlatformVolume" not in pl: obj.addProperty( "App::PropertyVolume", "PlatformVolume", "Platform", "Volumen bajo la plataforma (solo lectura)") obj.setEditorMode("PlatformVolume", 1) if "NumberOfFrames" not in pl: obj.addProperty( "App::PropertyInteger", "NumberOfFrames", "Platform", "Número de trackers en la plataforma") obj.setEditorMode("NumberOfFrames", 1) if "Status" not in pl: obj.addProperty( "App::PropertyString", "Status", "Platform", "Estado del último cálculo") def onDocumentRestored(self, obj): self.setProperties(obj) def execute(self, obj): """Calcula la plataforma a partir de los SourceFrames.""" frames = obj.SourceFrames if not frames: obj.Shape = Part.Shape() obj.Status = "Sin frames" return obj.NumberOfFrames = len(frames) slope = obj.SlopeTolerance try: shape = _build_platform_shape(frames, slope) except Exception as e: obj.Status = f"Error: {e}" FreeCAD.Console.PrintError( f"Error al generar plataforma: {e}\n") return if shape is None: obj.Status = "No se pudo generar" return obj.Shape = shape # Calcular área y volumen try: area = shape.Area if area > 0: obj.PlatformArea = area # Volumen aproximado: proyectar al plano XY try: obj.PlatformVolume = shape.Volume except Exception: pass except Exception: pass obj.Status = f"OK - {len(frames)} frames" FreeCAD.Console.PrintMessage( f"Plataforma generada: {len(frames)} frames, " f"área={area:,.0f} mm²\n") def __getstate__(self): return None def __setstate__(self, state): return None # ========================================================================= # Cálculo de la plataforma (lógica principal) # ========================================================================= def _build_platform_shape(frames, slope_tolerance): """ Construye la geometría de la plataforma desde los frames. Returns: Part.Solid o None si falla """ rows, columns = _get_tracker_rows(frames) if rows is None or not rows: 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: l1 = Part.LineSegment( tool[1].Vertexes[-1].Point, next_tool[1].Vertexes[0].Point ).toShape() 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: return None # Fase 3: Unir caras en un sólido try: 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 if platform.ShapeType == "Shell": try: platform = Part.makeSolid(platform) except Exception: pass elif platform.ShapeType == "Compound": faces_in = [s for s in platform.SubShapes if s.ShapeType == "Face"] if faces_in: try: shell = Part.makeShell(faces_in) platform = Part.makeSolid(shell) except Exception: pass return platform if not platform.isNull() else None except Exception: return None def _get_tracker_rows(frames): """Agrupa trackers usando la lógica de PVPlantPlacement.""" try: import PVPlantPlacement return PVPlantPlacement.getRows(frames) except Exception: return None, None def _generate_row_lines(group, slope_tolerance): """ Genera líneas de borde (izquierda/derecha) para una fila de trackers. Returns: dict con edges (lista de Part.Shape) y tools (lista de [frame, izq, der]) """ result = {"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_half_width(frame) zz = wdt * math.sin(math.radians(anf)) base = _get_base_line(frame) li = base.copy() li.Placement = frame.Placement li.Placement.Rotation = frame.Placement.Rotation li.Placement.Base.x -= wdt li.Placement.Base.z -= zz result["edges"].append(li) ld = base.copy() ld.Placement = frame.Placement ld.Placement.Rotation = frame.Placement.Rotation ld.Placement.Base.x += wdt ld.Placement.Base.z += zz result["edges"].append(ld) result["tools"].append([frame, li, ld]) return result def _get_half_width(frame): try: return int(frame.Setup.Width / 2) except Exception: return 0 def _get_base_line(frame): try: lng = int(frame.Setup.Length / 2) return Part.LineSegment( FreeCAD.Vector(-lng, 0, 0), FreeCAD.Vector(lng, 0, 0) ).toShape() except Exception: 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 return _angle_xz( group[i - 1].Placement.Base, group[i].Placement.Base ) def _angle_to_next(group, i): if i >= len(group) - 1: return 0 return _angle_xz( group[i].Placement.Base, group[i + 1].Placement.Base ) def _angle_xz(v1, v2): 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 g in col: if frame in g: return g, g.index(frame) return [], -1 def _find_tool(frame, tools): for t in tools: if t[0] == frame: return t return None # ========================================================================= # ViewProvider # ========================================================================= class _ViewProviderPlatform: def __init__(self, vobj): vobj.Proxy = self pl = vobj.PropertiesList if "Transparency" not in pl: vobj.addProperty( "App::PropertyIntegerConstraint", "Transparency", "Platform Style", "Transparencia de la plataforma") vobj.Transparency = (40, 0, 100, 1) if "ShapeColor" not in pl: vobj.addProperty( "App::PropertyColor", "ShapeColor", "Platform Style", "Color de la plataforma") vobj.ShapeColor = (0.3, 0.8, 0.3, 0.6) # verde semitransparente if "ShapeMaterial" not in pl: vobj.addProperty( "App::PropertyMaterial", "ShapeMaterial", "Platform Style", "Material") vobj.ShapeMaterial = FreeCAD.Material() vobj.ShapeMaterial.DiffuseColor = vobj.ShapeColor def onChanged(self, vobj, prop): if prop in ("ShapeColor", "Transparency"): if hasattr(vobj, "ShapeColor") and hasattr(vobj, "Transparency"): c = vobj.ShapeColor t = vobj.Transparency vobj.ShapeMaterial.DiffuseColor = (c[0], c[1], c[2], t / 100) def getIcon(self): return str(os.path.join(DirIcons, "solar-fixed.svg")) def __getstate__(self): return None def __setstate__(self, state): return None # ========================================================================= # Functions de conveniencia (API pública) # ========================================================================= def build_platform(frames, slope_tolerance=10.0): """ API pública: construye la geometría de plataforma desde frames. Útil para EarthWorks, Road, etc. que quieran la Shape sin crear objeto. Returns: Part.Solid o None """ return _build_platform_shape(frames, slope_tolerance) def get_platform_shape(platform_obj): """ Obtiene la Shape de un objeto Platform de forma segura. """ if platform_obj is None: return None try: shape = platform_obj.Shape if shape and not shape.isNull(): return shape except Exception: pass return None