diff --git a/Civil/PVPlantEarthWorks.py b/Civil/PVPlantEarthWorks.py index ef44231..aa6cbe6 100644 --- a/Civil/PVPlantEarthWorks.py +++ b/Civil/PVPlantEarthWorks.py @@ -31,30 +31,40 @@ else: import PVPlantResources from PVPlantResources import DirIcons as DirIcons -from .PVPlantPlatform import build_platform +from .PVPlantPlatform import build_platform, get_platform_shape, make_platform VOLUME_TYPES = ["Fill", "Cut"] -def compute_earthworks(frames, terrain_mesh, slope_tolerance=10.0): +def compute_earthworks(platform_or_frames, terrain_mesh, slope_tolerance=10.0): """ - Calcula el movimiento de tierras para una lista de frames/trackers. + Calcula el movimiento de tierras. Args: - frames: lista de objetos tracker + platform_or_frames: Objeto Platform o lista de frames/trackers + Si es Platform, usa su Shape directamente. + Si es lista de frames, genera la plataforma primero. terrain_mesh: Mesh del terreno natural slope_tolerance: pendiente máxima E-W (grados) Returns: tuple: (mesh_cut, mesh_fill, volume_cut_mm3, volume_fill_mm3) - mesh_cut/mesh_fill pueden ser None si no hay volumen en esa categoría """ import MeshPart - # 1. Construir la plataforma (superficie diseñada) desde los trackers - platform = build_platform(frames, slope_tolerance) - if platform is None: - return None, None, 0, 0 + # 1. Obtener la plataforma + if hasattr(platform_or_frames, 'Proxy') and hasattr(platform_or_frames.Proxy, '__class__'): + cls_name = platform_or_frames.Proxy.__class__.__name__ + if cls_name == 'Platform': + # Ya es un objeto Platform → usar su Shape + platform = get_platform_shape(platform_or_frames) + else: + platform = build_platform([platform_or_frames], slope_tolerance) + elif hasattr(platform_or_frames, '__iter__'): + # Es una lista de frames + platform = build_platform(platform_or_frames, slope_tolerance) + else: + platform = None # 2. Convertir plataforma a mesh para booleanos try: @@ -321,9 +331,14 @@ class EarthWorksTaskPanel: def accept(self): land = FreeCAD.ActiveDocument.Terrain.Mesh.copy() + # Detectar si hay un Platform seleccionado o trackers sueltos + platform_obj = None frames = [] + for obj in FreeCADGui.Selection.getSelection(): if hasattr(obj, "Proxy"): + if obj.Proxy.__class__.__name__ == 'Platform': + platform_obj = obj t = getattr(obj.Proxy, "Type", None) if t == "Tracker" and obj not in frames: frames.append(obj) @@ -332,9 +347,9 @@ class EarthWorksTaskPanel: if fr not in frames: frames.append(fr) - if not frames: + if not frames and not platform_obj: FreeCAD.Console.PrintWarning( - "Selecciona trackers o un FrameArea\n") + "Selecciona trackers, un FrameArea o un Platform\n") return False slope = getattr(FreeCAD.ActiveDocument, @@ -342,8 +357,9 @@ class EarthWorksTaskPanel: FreeCAD.ActiveDocument.openTransaction("Movimiento de tierras") try: + input_data = platform_obj if platform_obj else frames cut_mesh, fill_mesh, vol_cut, vol_fill = compute_earthworks( - frames, land, slope) + input_data, land, slope) if cut_mesh and cut_mesh.countPoints() > 3: v = makeEarthWorksVolume(1) # Cut diff --git a/Civil/PVPlantPlatform.py b/Civil/PVPlantPlatform.py index d6d8730..af42e87 100644 --- a/Civil/PVPlantPlatform.py +++ b/Civil/PVPlantPlatform.py @@ -2,14 +2,16 @@ # * * # * Copyright (c) 2026 Javier Braña * # * * -# * Platform - Generación de plataforma desde trackers * +# * PVPlantPlatform - Plataforma de diseño solar * # * * -# * Construye la superficie diseñada (plataforma) a partir de la * -# * disposición de trackers solares, incluyendo pendientes E-W, * -# * conexiones entre filas y columnas. * +# * Es el elemento central del movimiento de tierras. Representa la * +# * superficie diseñada generada a partir de la disposición de trackers.* # * * -# * Esta plataforma se usa luego en EarthWorks para calcular * -# * volúmenes de corte y relleno contra el terreno natural. * +# * 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 * # * * # *********************************************************************** @@ -17,43 +19,182 @@ 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 -def get_tracker_rows(frames): +import PVPlantResources +from PVPlantResources import DirIcons as DirIcons + + +# ========================================================================= +# Constructor +# ========================================================================= + +def make_platform(frames=None, name="Platform"): """ - 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. + Crea un objeto Platform en el documento activo. Args: - frames: lista de objetos tracker (con Placement, Setup, etc.) - slope_tolerance: pendiente máxima E-W en grados + frames: lista opcional de objetos tracker para inicializar + name: nombre del objeto Returns: - Part.Solid con la plataforma, o None si no se puede generar + Objeto FeaturePython Platform, o None si no hay documento """ - rows, columns = get_tracker_rows(frames) + 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: - 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) --- + # 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"]) @@ -65,7 +206,7 @@ def build_platform(frames, slope_tolerance=10.0): except Exception: pass - # --- Fase 2: Lofts transversales (entre columnas) --- + # Fase 2: Lofts transversales (entre columnas) if columns: for group in rows: for frame in group: @@ -80,12 +221,10 @@ def build_platform(frames, slope_tolerance=10.0): 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 @@ -98,14 +237,10 @@ def build_platform(frames, slope_tolerance=10.0): 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 --- + # 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: @@ -119,63 +254,43 @@ def build_platform(frames, slope_tolerance=10.0): 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: + faces_in = [s for s in platform.SubShapes if s.ShapeType == "Face"] + if faces_in: try: - shell = Part.makeShell(faces_in_compound) + shell = Part.makeShell(faces_in) 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") + except Exception: 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 _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 las líneas de borde (izquierda/derecha) para una fila de trackers. + Genera 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 + dict con edges (lista de Part.Shape) y tools (lista de [frame, izq, der]) """ - lines = {"edges": [], "tools": []} + result = {"edges": [], "tools": []} for i, frame in enumerate(group): if not hasattr(frame, "Setup"): @@ -187,44 +302,38 @@ def _generate_row_lines(group, slope_tolerance): if anf > slope_tolerance: anf = slope_tolerance - wdt = _get_tracker_width(frame) + wdt = _get_half_width(frame) zz = wdt * math.sin(math.radians(anf)) - base_line = _get_tracker_base_line(frame) + base = _get_base_line(frame) - # Borde izquierdo (sur) - li = base_line.copy() + li = base.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) + result["edges"].append(li) - # Borde derecho (norte) - ld = base_line.copy() + ld = base.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) + result["edges"].append(ld) - lines["tools"].append([frame, li, ld]) + result["tools"].append([frame, li, ld]) - return lines + return result -def _get_tracker_width(frame): - """Ancho medio del tracker (mm).""" +def _get_half_width(frame): 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. - """ +def _get_base_line(frame): try: lng = int(frame.Setup.Length / 2) return Part.LineSegment( @@ -232,7 +341,6 @@ def _get_tracker_base_line(frame): FreeCAD.Vector(lng, 0, 0) ).toShape() except Exception: - # Fallback: BoundBox try: bb = frame.Setup.Shape.BoundBox return Part.LineSegment( @@ -249,21 +357,22 @@ def _get_tracker_base_line(frame): 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) + 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 - p1 = FreeCAD.Vector(group[i].Placement.Base) - p2 = FreeCAD.Vector(group[i + 1].Placement.Base) - return _angle_xz(p1, p2) + return _angle_xz( + group[i].Placement.Base, + group[i + 1].Placement.Base + ) 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)) @@ -271,9 +380,9 @@ def _angle_xz(v1, v2): def _find_in_columns(frame, columns): for col in columns: - for group in col: - if frame in group: - return group, group.index(frame) + for g in col: + if frame in g: + return g, g.index(frame) return [], -1 @@ -281,4 +390,79 @@ 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 \ No newline at end of file