# /********************************************************************** # * * # * Copyright (c) 2021-2026 Javier Braña * # * * # * PVPlantPlacement - TaskPanels y comandos de placement de trackers * # * * # * La lógica de cálculo está en Civil/PVPlantPlacementCalc.py * # * * # *********************************************************************** import FreeCAD import Part if FreeCAD.GuiUp: import FreeCADGui, os from PySide import QtCore, QtGui from PySide.QtGui import QListWidgetItem from PySide.QtCore import QT_TRANSLATE_NOOP else: def translate(ctxt, txt): return txt def QT_TRANSLATE_NOOP(ctxt, txt): return txt import PVPlantResources import PVPlantSite from Civil.PVPlantPlacementCalc import ( selectionFilter, getRows, getCols, get_trend, getTrend, adjustToTerrain, optimized_cut, getHeadsAndSoil, moveFrameHead, ConvertObjectsTo ) version = "0.2.0" class _PVPlantPlacementTaskPanel: '''The editmode TaskPanel for Schedules''' def __init__(self, obj=None): self.site = PVPlantSite.get() self.Terrain = self.site.Terrain self.FrameSetups = None self.PVArea = None self.Area = None self.gap_col = .0 self.gap_row = .0 self.offsetX = .0 self.offsetY = .0 self.Dir = FreeCAD.Vector(0, -1, 0) self._terrain_interpolator = None self._frame_footprints_cache = {} self._isinside_cache = {} # LRU: (frame_name, x, y) -> bool self._area_polygon = None # Caché shapely del área # UI setup self.form = FreeCADGui.PySideUic.loadUi(os.path.join(PVPlantResources.__dir__, "PVPlantPlacement.ui")) self.form.setWindowIcon(QtGui.QIcon(os.path.join(PVPlantResources.DirIcons, "way.svg"))) self.addFrames() self.maxWidth = max([frame.Width.Value for frame in self.site.Frames]) self.form.buttonPVArea.clicked.connect(self.addPVArea) self.form.editGapCols.valueChanged.connect(self.update_inner_spacing) self.update_inner_spacing() def addPVArea(self): sel = FreeCADGui.Selection.getSelection() if sel: self.PVArea = sel[0] self.form.editPVArea.setText(self.PVArea.Label) def addFrames(self): for frame_setup in self.site.Frames: list_item = QListWidgetItem(frame_setup.Name, self.form.listFrameSetups) list_item.setCheckState(QtCore.Qt.Checked) def update_inner_spacing(self): self.form.editInnerSpacing.setText(f"{self.form.editGapCols.value() - self.maxWidth / 1000} m") def _get_or_create_frame_group(self): """Gestión optimizada de grupos de frames""" doc = FreeCAD.ActiveDocument main_group = doc.getObject("Frames") if not main_group: main_group = doc.addObject("App::DocumentObjectGroup", "Frames") main_group.Label = "Frames" mg = doc.getObject('MechanicalGroup') if mg and main_group not in mg.Group: mg.addObject(main_group) if self.form.cbSubfolders.isChecked() and self.PVArea: sn = f"Frames-{self.PVArea.Label}" sg = next((o for o in main_group.Group if o.Name == sn), None) if not sg: sg = doc.addObject("App::DocumentObjectGroup", sn) sg.Label = sn main_group.addObject(sg) return sg return main_group def createFrameFromPoints(self, dataframe): from Mechanical.Frame import PVPlantFrame doc = FreeCAD.ActiveDocument group = self._get_or_create_frame_group() frames = [] placements_key = "placement" if "placement" in dataframe.columns else 0 if placements_key == "placement": placements = dataframe["placement"].tolist() types = dataframe["type"].tolist() for idx, (placement, frame_type) in enumerate(zip(placements, types)): newrack = PVPlantFrame.makeTracker(setup=frame_type) newrack.Label = "Tracker" newrack.Visibility = False newrack.Placement = placement group.addObject(newrack) frames.append(newrack) if self.PVArea and self.PVArea.Name.startswith("FrameArea"): self.PVArea.Frames = frames def getProjected(self, shape): """Optimized projection calculation""" if shape.BoundBox.ZLength == 0: return Part.Face(Part.Wire(shape.Edges)) from Utils import PVPlantUtils as utils wire = utils.simplifyWire(utils.getProjected(shape)) return Part.Face(wire.removeSplitter()) if wire and wire.isClosed() else Part.Face(wire) def calculateWorkingArea(self): """Optimized working area calculation""" self.Area = self.getProjected(self.PVArea.Shape) exclusion_areas = FreeCAD.ActiveDocument.findObjects(Name="ExclusionArea") if exclusion_areas: prohibited_faces = [] for obj in exclusion_areas: face = self.getProjected(obj.Shape.SubShapes[1]) if face and face.isValid(): prohibited_faces.append(face) if prohibited_faces: self.Area = self.Area.cut(prohibited_faces) # Clear caches when area changes self._terrain_interpolator = None self._area_polygon = None self._isinside_cache.clear() def _setup_terrain_interpolator(self): """Cached terrain interpolator""" if self._terrain_interpolator is not None: return self._terrain_interpolator mesh = self.Terrain.Mesh points = np.array([v.Vector for v in mesh.Points]) bbox = self.Area.BoundBox # Filter points within working area efficiently mask = ((points[:, 0] >= bbox.XMin) & (points[:, 0] <= bbox.XMax) & (points[:, 1] >= bbox.YMin) & (points[:, 1] <= bbox.YMax)) filtered_points = points[mask] if len(filtered_points) == 0: self._terrain_interpolator = None return None try: self._terrain_interpolator = LinearNDInterpolator( filtered_points[:, :2], filtered_points[:, 2] ) except: self._terrain_interpolator = None return self._terrain_interpolator def _get_frame_footprint(self, frame): """Cached footprint calculation""" frame_key = (frame.Length.Value, frame.Width.Value) if frame_key not in self._frame_footprints_cache: l, w = frame.Length.Value, frame.Width.Value l_med, w_med = l / 2, w / 2 footprint = Part.makePolygon([ FreeCAD.Vector(-l_med, -w_med, 0), FreeCAD.Vector(l_med, -w_med, 0), FreeCAD.Vector(l_med, w_med, 0), FreeCAD.Vector(-l_med, w_med, 0), FreeCAD.Vector(-l_med, -w_med, 0) ]) footprint.Placement.Rotation = FreeCAD.Rotation(FreeCAD.Vector(1, 0, 0), FreeCAD.Vector(0, 1, 0)) self._frame_footprints_cache[frame_key] = footprint return self._frame_footprints_cache[frame_key] def _calculate_terrain_adjustment_batch(self, points_data): """Process terrain adjustments in batches for better performance""" terrain_interp = self._setup_terrain_interpolator() results = [] for frame_type, base_point in points_data: yl = frame_type.Length.Value / 2 top_point = FreeCAD.Vector(base_point.x, base_point.y + yl, 0) bot_point = FreeCAD.Vector(base_point.x, base_point.y - yl, 0) if terrain_interp: # Use interpolator for faster elevation calculation yy = np.linspace(bot_point.y, top_point.y, 6) # Reduced points for speed xx = np.full_like(yy, base_point.x) try: zz = terrain_interp(xx, yy) if not np.isnan(zz).all(): slope, intercept, *_ = stats.linregress(yy, zz) z_top = slope * top_point.y + intercept z_bot = slope * bot_point.y + intercept else: z_top = z_bot = 0 except: z_top = z_bot = 0 else: # Fallback to direct projection (slower) line = Part.LineSegment(bot_point, top_point).toShape() try: import MeshPart projected = MeshPart.projectShapeOnMesh(line, self.Terrain.Mesh, FreeCAD.Vector(0, 0, 1))[0] if len(projected) >= 2: yy = [p.y for p in projected] zz = [p.z for p in projected] slope, intercept, *_ = stats.linregress(yy, zz) z_top = slope * top_point.y + intercept z_bot = slope * bot_point.y + intercept else: z_top = z_bot = 0 except: z_top = z_bot = 0 new_top = FreeCAD.Vector(top_point.x, top_point.y, z_top) new_bot = FreeCAD.Vector(bot_point.x, bot_point.y, z_bot) new_pl = FreeCAD.Placement() new_pl.Base = (new_top + new_bot) / 2 new_pl.Rotation = FreeCAD.Rotation( FreeCAD.Vector(-1, 0, 0), new_top - new_bot ) results.append((frame_type, new_pl)) return results def adjustToTerrain(self, coordinates): """Unified terrain adjustment function for both aligned and non-aligned arrays""" # Create binary array efficiently arr = np.array([[int(obj != 0) for obj in col] for col in coordinates], dtype=np.uint8) labeled_array, num_features = sclabel(arr) # Build DataFrame efficiently data = [] for label in range(1, num_features + 1): cols, rows = np.where(labeled_array == label) for col, row in zip(cols, rows): frame_type, placement = coordinates[col][row] data.append({ 'ID': len(data) + 1, 'region': label, 'type': frame_type, 'column': col, 'row': row, 'placement': placement }) if not data: return pd.DataFrame(columns=['ID', 'region', 'type', 'column', 'row', 'placement']) df = pd.DataFrame(data) # Process terrain adjustments in batches points_data = [(row['type'], row['placement']) for _, row in df.iterrows()] adjusted_results = self._calculate_terrain_adjustment_batch(points_data) # Update placements in DataFrame for idx, (frame_type, new_placement) in enumerate(adjusted_results): df.at[idx, 'placement'] = new_placement return df def _get_area_polygon(self): """Convierte self.Area a shapely Polygon para comprobaciones rápidas""" if self._area_polygon is None and self.Area: from shapely.geometry import Polygon verts = self.Area.Vertexes if len(verts) >= 3: self._area_polygon = Polygon([(v.x, v.y) for v in verts]) return self._area_polygon def isInside(self, frame, point): """ Comprueba si un frame cabe en el área en un punto dado. Usa shapely para la comprobación 2D (mucho más rápido que Part.cut). """ # Caché LRU: mismo frame + misma posición key = (frame.Name, round(point.x, 0), round(point.y, 0)) if key in self._isinside_cache: return self._isinside_cache[key] # Prefiltro rápido por BoundBox fw, fl = frame.Width.Value / 2, frame.Length.Value / 2 if (point.x - fw < self.Area.BoundBox.XMin or point.x + fw > self.Area.BoundBox.XMax or point.y - fl < self.Area.BoundBox.YMin or point.y + fl > self.Area.BoundBox.YMax): self._isinside_cache[key] = False return False # Comprobación precisa con shapely ap = self._get_area_polygon() if ap is not None: from shapely.geometry import box fp = box(point.x - fw, point.y - fl, point.x + fw, point.y + fl) result = ap.contains(fp) self._isinside_cache[key] = result return result # Fallback OCC (si shapely falla) try: frame_footprint = self._get_frame_footprint(frame) frame_footprint.Placement.Base = point cut = frame_footprint.cut([self.Area]) result = len(cut.Vertexes) == 0 self._isinside_cache[key] = result return result except Part.OCCError: self._isinside_cache[key] = False return False def getAligments(self): """ Calcula las alineaciones X (columnas) y opcionalmente Y (filas) en función de las referencias seleccionadas. Retorna (x_range, y_range). y_range vacío si no hay referencia vertical. """ sel = FreeCADGui.Selection.getSelectionEx() if not sel or not sel[0].SubObjects: return np.array([], dtype=np.float64), np.array([], dtype=np.float64) sub_objects = sel[0].SubObjects if len(sub_objects) == 1: # Una sola referencia: usar BoundBox completo bb = sub_objects[0].BoundBox area_bb = self.Area.BoundBox n_cols = max(1, int((area_bb.XMax - area_bb.XMin) / self.gap_col)) n_rows = max(1, int((area_bb.YMax - area_bb.YMin) / self.gap_row)) x_range = np.linspace(area_bb.XMin + self.offsetX, area_bb.XMax, n_cols, dtype=np.float64) y_range = np.linspace(area_bb.YMax - self.offsetY - self.gap_row, area_bb.YMin, n_rows, dtype=np.float64) else: refh = max(sub_objects[:2], key=lambda x: x.BoundBox.XLength) refv = max(sub_objects[:2], key=lambda x: x.BoundBox.YLength) # Alinear grid con referencias area_xmin, area_xmax = self.Area.BoundBox.XMin, self.Area.BoundBox.XMax area_ymin, area_ymax = self.Area.BoundBox.YMin, self.Area.BoundBox.YMax n_cols = max(1, int((area_xmax - area_xmin) / self.gap_col)) n_rows = max(1, int((area_ymax - area_ymin) / self.gap_row)) x_range = np.linspace( refv.BoundBox.XMin + self.offsetX, min(refv.BoundBox.XMax + self.offsetX, area_xmax), n_cols, dtype=np.float64 ) y_range = np.linspace( refh.BoundBox.YMax - self.offsetY, max(refh.BoundBox.YMin - self.offsetY, area_ymin), n_rows, dtype=np.float64 ) if n_rows > 1 else np.array([refh.BoundBox.YMin], dtype=np.float64) # Pre-filtrar: eliminar puntos claramente fuera del BoundBox del área x_range = x_range[(x_range >= self.Area.BoundBox.XMin) & (x_range <= self.Area.BoundBox.XMax)] if len(y_range) > 0: y_range = y_range[(y_range >= self.Area.BoundBox.YMin) & (y_range <= self.Area.BoundBox.YMax)] return x_range, y_range def calculateAlignedArray(self): """ Coloca frames en grid alineado (filas y columnas). Llama al motor unificado _calculate_placement. """ return self._calculate_placement(mode='aligned') def calculateNonAlignedArray(self): """ Coloca frames adaptados al contorno del área (solo columnas). Llama al motor unificado _calculate_placement. """ return self._calculate_placement(mode='non_aligned') def _calculate_placement(self, mode='non_aligned'): """ Motor de posicionamiento unificado para aligned y non_aligned. aligned: grid Y fijo + isInside (rápido en áreas rectangulares, usa caché) non_aligned: intersección área-línea (preciso en bordes irregulares) """ pointsx, pointsy = self.getAligments() if len(pointsx) == 0: FreeCAD.Console.PrintWarning("No X alignments found.\n") return pd.DataFrame() # Pre-calcular footprints una sola vez footprints = [] for frame in self.FrameSetups: footprint = self._get_frame_footprint(frame) footprints.append((frame, footprint)) if not footprints: return pd.DataFrame() min_h = min(ftp[0].Width.Value for ftp in footprints) corridor_enabled = self.form.groupCorridor.isChecked() corridor_count = 0 corridor_offset = 0 ref_width = footprints[0][0].Width.Value corridor_val = FreeCAD.Units.Quantity( self.form.editColGap.text()).Value - (self.gap_col - ref_width) area_ymax = self.Area.BoundBox.YMax area_ymin = self.Area.BoundBox.YMin ref_frame = footprints[0][0] ref_len = ref_frame.Length.Value n_cols = len(pointsx) cols = [None] * n_cols # Procesar por lotes para permitir interrupción con barra de progreso from PySide.QtCore import QCoreApplication for idx, x in enumerate(pointsx): col = [] cx = x + corridor_offset # Actualizar barra de progreso cada 20 columnas if idx % 20 == 0 and hasattr(self.form, 'progressBar'): self.form.progressBar.setValue(int(100 * idx / n_cols)) QCoreApplication.processEvents() if mode == 'aligned' and len(pointsy) > 0: half_len = ref_len / 2 for y in pointsy: py = y - half_len # Vector creado solo si pasa el BoundBox check (ya lo hace isInside internamente) tp = FreeCAD.Vector(cx, py, 0.0) placed = False if self.isInside(ref_frame, tp): col.append([ref_frame, tp]) placed = True else: # frames alternativos: probar con offsets for fi in range(1, len(footprints)): fr = footprints[fi][0] ld = (ref_len - fr.Length.Value) / 2 for yoff in (ld, -ld): if self.isInside(fr, FreeCAD.Vector(tp.x, tp.y + yoff, 0.0)): col.append([fr, FreeCAD.Vector(tp.x, tp.y + yoff, 0.0)]) placed = True break if placed: break if not placed: col.append(0) else: # Non-aligned: intersección de línea vertical con el área line = Part.LineSegment( FreeCAD.Vector(cx, area_ymax, 0.0), FreeCAD.Vector(cx, area_ymin, 0.0) ).toShape() try: inter = self.Area.section(line) pts = sorted([v.Point for v in inter.Vertexes], key=lambda p: p.y, reverse=True) for i in range(0, len(pts) - 1, 1 + (len(pts) > 2)): top, bot = pts[i], pts[i + 1] if top.y - bot.y > min_h: self._place_frames_in_segment(col, footprints, cx, top, bot) except Exception as e: FreeCAD.Console.PrintWarning(f"Segment error: {e}\n") # Corredores if corridor_enabled and col: corridor_count += 1 if corridor_count >= self.form.editColCount.value(): corridor_offset += corridor_val corridor_count = 0 cols[idx] = col return self.adjustToTerrain(cols) def accept(self): from datetime import datetime starttime = datetime.now() params = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Document") auto_save_enabled = params.GetBool("AutoSaveEnabled") params.SetBool("AutoSaveEnabled", False) FreeCAD.ActiveDocument.RecomputesFrozen = True try: items = [ FreeCAD.ActiveDocument.getObject(self.form.listFrameSetups.item(i).text()) for i in range(self.form.listFrameSetups.count()) if self.form.listFrameSetups.item(i).checkState() == QtCore.Qt.Checked ] self.FrameSetups = list({f.Length.Value: f for f in items}.values()) self.FrameSetups.sort(key=lambda x: x.Length.Value, reverse=True) self.gap_col = FreeCAD.Units.Quantity(self.form.editGapCols.text()).Value self.gap_row = FreeCAD.Units.Quantity(self.form.editGapRows.text()).Value + self.FrameSetups[0].Length.Value self.offsetX = FreeCAD.Units.Quantity(self.form.editOffsetHorizontal.text()).Value self.offsetY = FreeCAD.Units.Quantity(self.form.editOffsetVertical.text()).Value FreeCAD.ActiveDocument.openTransaction("Create Placement") self.calculateWorkingArea() if self.form.cbAlignFrames.isChecked(): dataframe = self.calculateAlignedArray() else: dataframe = self.calculateNonAlignedArray() if not dataframe.empty: self.createFrameFromPoints(dataframe) import Electrical.group as egroup import importlib importlib.reload(egroup) egroup.groupTrackersToTransformers(5000000, self.gap_row) FreeCAD.ActiveDocument.commitTransaction() finally: FreeCAD.ActiveDocument.RecomputesFrozen = False params.SetBool("AutoSaveEnabled", auto_save_enabled) elapsed = datetime.now() - starttime FreeCAD.Console.PrintMessage(f"Placement: {elapsed}\n") FreeCADGui.Control.closeDialog() FreeCAD.ActiveDocument.recompute() class adjustToTerrainTaskPanel: def __init__(self, obj=None): self.obj = obj self.form = FreeCADGui.PySideUic.loadUi(PVPlantResources.__dir__ + "/PVPlantPlacementAdjust.ui") def accept(self): frames = [] for obj in FreeCADGui.Selection.getSelection(): if obj.Name.startswith("Tracker"): frames.append(obj) elif obj.Name.startswith("FrameArea"): frames.extend(obj.Frames) adjustToTerrain(frames, self.form.comboMethod.currentIndex() == 0) self.close() return True def reject(self): self.close() return False def close(self): FreeCADGui.Control.closeDialog() import numpy as np from scipy import stats class _PVPlantConvertTaskPanel: '''The editmode TaskPanel for Conversions''' def __init__(self): self.To = None # self.form: self.form = FreeCADGui.PySideUic.loadUi(os.path.join(PVPlantResources.__dir__, "PVPlantPlacementConvert.ui")) self.form.setWindowIcon(QtGui.QIcon(os.path.join(PVPlantResources.DirIcons, "Trace.svg"))) self.form.buttonTo.clicked.connect(self.addTo) def addTo(self): sel = FreeCADGui.Selection.getSelection() if len(sel) > 0: self.To = sel[0] self.form.editTo.setText(self.To.Label) def accept(self): sel = FreeCADGui.Selection.getSelection() if sel == self.To: return False if len(sel) > 0 and self.To is not None: FreeCAD.ActiveDocument.openTransaction("Convert to") ConvertObjectsTo(sel, self.To) return True return False class CommandPVPlantPlacement: def GetResources(self): return {'Pixmap': str(os.path.join(PVPlantResources.DirIcons, "way.svg")), 'Accel': "P,P", 'MenuText': QT_TRANSLATE_NOOP("Placement", "Placement"), 'ToolTip': QT_TRANSLATE_NOOP("Placement", "Crear un campo fotovoltaico")} def Activated(self): taskd = _PVPlantPlacementTaskPanel(None) FreeCADGui.Control.showDialog(taskd) def IsActive(self): if FreeCAD.ActiveDocument: return True else: return False class CommandAdjustToTerrain: def GetResources(self): return {'Pixmap': str(os.path.join(PVPlantResources.DirIcons, "adjust.svg")), 'Accel': "P, A", 'MenuText': QT_TRANSLATE_NOOP("Placement", "Adjust"), 'ToolTip': QT_TRANSLATE_NOOP("Placement", "Adjust object to terrain")} def Activated(self): sel = FreeCADGui.Selection.getSelection() if len(sel) > 0: # adjustToTerrain(sel) FreeCADGui.Control.showDialog(adjustToTerrainTaskPanel()) else: print("No selected object") def IsActive(self): if FreeCAD.ActiveDocument: return True else: return False class CommandConvert: def GetResources(self): return {'Pixmap': str(os.path.join(PVPlantResources.DirIcons, "convert.svg")), 'Accel': "P, C", 'MenuText': QT_TRANSLATE_NOOP("Placement", "Convert"), 'ToolTip': QT_TRANSLATE_NOOP("Placement", "Convertir un objeto en otro")} def IsActive(self): return (not FreeCAD.ActiveDocument is None and not FreeCAD.ActiveDocument.getObject("Site") is None and not FreeCAD.ActiveDocument.getObject("Terrain") is None and not FreeCAD.ActiveDocument.getObject("TrackerSetup") is None) def Activated(self): taskd = _PVPlantConvertTaskPanel() FreeCADGui.Control.showDialog(taskd) '''if FreeCAD.GuiUp: FreeCADGui.addCommand('PVPlantPlacement', _CommandPVPlantPlacement()) FreeCADGui.addCommand('PVPlantAdjustToTerrain', _CommandAdjustToTerrain()) FreeCADGui.addCommand('PVPlantConvertTo', _CommandConvert())'''