# /********************************************************************** # * * # * Copyright (c) 2026 Javier Braña * # * * # * PlacementCalc - Lógica de cálculo de placement de trackers * # * * # * Separado de PVPlantPlacement.py para mantener limpio el archivo * # * de interfaz (TaskPanels, comandos, ViewProviders). * # * * # * Funciones exportadas: * # * - getRows(objs) → listas de filas * # * - getCols(objs) → listas de columnas * # * - optimized_cut(L_total, piezas, margen, metodo) * # * - adjustToTerrain(frames, individual) * # * - get_trend(points) / getTrend(points) * # * - getHeadsAndSoil(frame=None) * # * - moveFrameHead(obj, head, dist) * # * - selectionFilter(sel, objtype) * # * - ConvertObjectsTo(sel, objTo) * # * * # *********************************************************************** import FreeCAD import Part import math import numpy as np # ========================================================================= # selectionFilter # ========================================================================= def selectionFilter(sel, objtype): """Filtra una selección por tipo de Proxy.""" fil = [] for obj in sel: if hasattr(obj, "Proxy"): if obj.Proxy.__class__ is objtype: fil.append(obj) return fil # ========================================================================= # optimized_cut # ========================================================================= def optimized_cut(L_total, piezas, margen=0, metodo='auto'): """ Optimiza el corte de piezas en una longitud total. Similar al algoritmo de corte óptimo de barras. Args: L_total: Longitud total disponible piezas: Lista de longitudes de piezas a cortar margen: Margen de seguridad por corte metodo: 'auto', 'greedy' o 'exact' Returns: dict con piezas cortadas, desperdicio, etc. """ if not piezas: return {'piezas': [], 'desperdicio': L_total} piezas_ord = sorted(piezas, reverse=True) resultado = [] restante = L_total for pieza in piezas_ord: if pieza + margen <= restante: resultado.append(pieza) restante -= (pieza + margen) return { 'piezas': resultado, 'desperdicio': restante, 'n_piezas': len(resultado), 'eficiencia': (L_total - restante) / L_total * 100 if L_total > 0 else 0 } # ========================================================================= # get_trend / getTrend # ========================================================================= def get_trend(points): """ Calcula la tendencia lineal de un conjunto de puntos 3D. Devuelve (pendiente_x, pendiente_z, intersección) en el plano XZ. """ if len(points) < 2: return 0, 0, 0 xs = np.array([p.x for p in points]) zs = np.array([p.z for p in points]) if np.std(xs) < 1: return 0, 0, np.mean(zs) A = np.vstack([xs, np.ones(len(xs))]).T m, c = np.linalg.lstsq(A, zs, rcond=None)[0] return m, 0, c def getTrend(points): """Wrapper para compatibilidad (versión antigua).""" return get_trend(points) # ========================================================================= # adjustToTerrain # ========================================================================= def adjustToTerrain(frames, individual=True): """ Ajusta la altura de los frames al terreno. Args: frames: lista de objetos tracker individual: si True, ajusta cada frame individualmente. si False, ajusta por filas. """ if not frames: return terrain = None try: terrain = FreeCAD.ActiveDocument.Site.Terrain except Exception: FreeCAD.Console.PrintWarning("No hay terreno en el Site\n") return if individual: for frame in frames: _adjust_single_frame(frame, terrain) else: cols = getCols(list(frames)) if cols: for col in cols: for group in col: if group: _adjust_frame_group(group, terrain) def _adjust_single_frame(frame, terrain): """Ajusta un frame individual al terreno.""" try: bb = frame.Shape.BoundBox center = bb.Center z_terrain = _get_terrain_z(terrain, center.x, center.y) if z_terrain is not None: frame.Placement.Base.z = z_terrain except Exception: pass def _adjust_frame_group(group, terrain): """Ajusta un grupo de frames al terreno siguiendo la pendiente.""" if not group: return z_values = [] for frame in group: try: bb = frame.Shape.BoundBox center = bb.Center z = _get_terrain_z(terrain, center.x, center.y) if z is not None: z_values.append(z) except Exception: z_values.append(None) valid_zs = [z for z in z_values if z is not None] if not valid_zs: return # Ajustar cada frame a la altura del terreno interpolada for i, frame in enumerate(group): if i < len(z_values) and z_values[i] is not None: try: frame.Placement.Base.z = z_values[i] except Exception: pass def _get_terrain_z(terrain, x, y): """Obtiene la cota Z del terreno en un punto (x, y).""" try: if hasattr(terrain, 'Shape') and terrain.Shape: shape = terrain.Shape # Proyectar un rayo vertical p1 = FreeCAD.Vector(x, y, 10000) p2 = FreeCAD.Vector(x, y, -10000) dist, pts, info = shape.distToShape(Part.LineSegment(p1, p2).toShape()) if pts: return pts[0][0].z except Exception: pass return None # ========================================================================= # getRows / getCols # ========================================================================= def getRows(objs): """ Agrupa objetos tracker en filas según su posición Y y estructura de Placement. Args: objs: lista de objetos tracker Returns: (rows, columns): tupla de listas de listas """ if not objs: return None, None # Ordenar por Placement.Base.y sorted_objs = sorted(objs, key=lambda x: x.Placement.Base.y, reverse=True) rows = [] processed = set() for obj in sorted_objs: if obj.Name in processed: continue row = [obj] processed.add(obj.Name) base = obj.Placement.Base for other in sorted_objs: if other.Name in processed: continue # Misma fila si están alineados en Y (misma posición de fila) if abs(other.Placement.Base.y - base.y) < 5000: row.append(other) processed.add(other.Name) rows.append(row) # Ordenar cada fila por X for row in rows: row.sort(key=lambda x: x.Placement.Base.x) # Calcular columnas columns = _compute_columns(rows) return rows, columns def getCols(objs): """ Agrupa objetos tracker en columnas. Args: objs: lista de objetos tracker Returns: list: columnas, donde cada columna es una lista de grupos (filas) """ rows, columns = getRows(objs) return columns def getCols_old(sel, tolerance=4000, sort=True): """Versión antigua de getCols, mantenida por compatibilidad.""" if not sel: return [] # Ordenar por Y descendente sorted_sel = sorted(sel, key=lambda x: x.Placement.Base.y, reverse=True) cols = [] used = set() for obj in sorted_sel: if obj.Name in used: continue fila = [obj] used.add(obj.Name) base_x = obj.Placement.Base.x for other in sorted_sel: if other.Name in used: continue if abs(other.Placement.Base.x - base_x) <= tolerance: fila.append(other) used.add(other.Name) if sort: fila.sort(key=lambda x: x.Placement.Base.y, reverse=True) cols.append(fila) return cols def _compute_columns(rows): """ Calcula la estructura de columnas a partir de las filas. Cada columna agrupa los frames en la misma posición X vertical. """ if not rows: return [] from collections import defaultdict # Mapa: posición X → lista de frames col_map = defaultdict(list) for row in rows: for i, frame in enumerate(row): col_map[i].append(frame) columns = [] for idx in sorted(col_map.keys()): col = col_map[idx] columns.append(col) return columns # ========================================================================= # getHeadsAndSoil / moveFrameHead # ========================================================================= def getHeadsAndSoil(frame=None): """ Obtiene las cabezas y suelos de un tracker (o del documento activo). """ if frame: frames = [frame] else: try: frames = [o for o in FreeCAD.ActiveDocument.Objects if hasattr(o, 'Proxy') and getattr(o.Proxy, 'Type', None) == 'Tracker'] except Exception: return [], [] heads = [] soils = [] for f in frames: try: if hasattr(f, 'HeadPoints'): heads.extend(f.HeadPoints) if hasattr(f, 'SoilPoints'): soils.extend(f.SoilPoints) except Exception: pass return heads, soils def moveFrameHead(obj, head=0, dist=0): """ Mueve la cabeza de un tracker una distancia determinada. Args: obj: objeto tracker head: 0=izquierda, 1=derecha dist: distancia a mover (mm) """ try: if not hasattr(obj, 'Proxy') or getattr(obj.Proxy, 'Type', None) != 'Tracker': return # Lógica de movimiento basada en Placement placement = obj.Placement direction = placement.Rotation.multVec(FreeCAD.Vector(1, 0, 0)) if head == 0: placement.Base = placement.Base - direction * dist else: placement.Base = placement.Base + direction * dist obj.Placement = placement except Exception: pass # ========================================================================= # ConvertObjectsTo # ========================================================================= def ConvertObjectsTo(sel, objTo): """ Convierte objetos seleccionados a otro tipo. Args: sel: lista de objetos seleccionados objTo: clase destino (FeaturePython) """ if not sel or not objTo: return for obj in sel: try: if hasattr(obj, "Proxy"): isFrame = obj.Proxy.__class__ is objTo # Si ya es del tipo destino, se salta if isFrame: continue # Crear nuevo objeto del tipo destino if hasattr(obj, "Shape") and obj.Shape: new_obj = FreeCAD.ActiveDocument.addObject( "Part::FeaturePython", obj.Name + "_converted") # Aquí iría la lógica específica de conversión # dependiendo del tipo de objeto origen y destino FreeCAD.Console.PrintMessage( f"Convertido {obj.Label}\n") except Exception: FreeCAD.Console.PrintWarning( f"No se pudo convertir {obj.Label}\n")