diff --git a/Civil/PVPlantEarthWorks.py b/Civil/PVPlantEarthWorks.py index 78d76a6..ef44231 100644 --- a/Civil/PVPlantEarthWorks.py +++ b/Civil/PVPlantEarthWorks.py @@ -5,8 +5,14 @@ # * EarthWorks - Cálculo de movimiento de tierras * # * * # * Calcula volúmenes de desmonte (cut) y terraplén (fill) entre una * -# * superficie diseñada y el terreno natural, usando operaciones * -# * booleanas OCC + mallas para visualización. * +# * plataforma diseñada (generada por PVPlantPlatform) y el terreno * +# * natural representado por un mesh. * +# * * +# * Flujo: * +# * 1. build_platform(frames) → Part.Solid (superficie diseñada) * +# * 2. cut_mesh = mesh_above(platform_mesh, terrain_mesh) * +# * 3. fill_mesh = mesh_below(platform_mesh, terrain_mesh) * +# * 4. Volumen = mesh.Volume * # * * # *********************************************************************** @@ -14,8 +20,6 @@ import FreeCAD import Part import Mesh import math -import numpy as np -import ArchComponent if FreeCAD.GuiUp: import FreeCADGui, os @@ -27,66 +31,48 @@ else: import PVPlantResources from PVPlantResources import DirIcons as DirIcons +from .PVPlantPlatform import build_platform VOLUME_TYPES = ["Fill", "Cut"] -def makeEarthWorksVolume(vtype=0): - """Crea un objeto de volumen (Fill=0, Cut=1) en el documento activo.""" - obj = FreeCAD.ActiveDocument.addObject("Part::FeaturePython", VOLUME_TYPES[vtype]) - EarthWorksVolume(obj) - ViewProviderEarthWorksVolume(obj.ViewObject) - return obj - - -# ========================================================================= -# Funciones de cálculo principales -# ========================================================================= - -def get_tracker_rows(frames): - """ - Agrupa trackers en filas y columnas usando la lógica de Placement. - - Returns: - (rows, columns): listas de listas agrupadas - """ - try: - import PVPlantPlacement - return PVPlantPlacement.getRows(frames) - except Exception: - return None, None - - def compute_earthworks(frames, terrain_mesh, slope_tolerance=10.0): """ Calcula el movimiento de tierras para una lista de frames/trackers. Args: frames: lista de objetos tracker - terrain_mesh: Mesh del terreno + terrain_mesh: Mesh del terreno natural slope_tolerance: pendiente máxima E-W (grados) Returns: - (mesh_cut, mesh_fill, volume_cut, volume_fill) + 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 - rows, columns = get_tracker_rows(frames) - if rows is None or not rows: + + # 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 - # Generar superficie diseñada (loft de bordes de filas) - design_shape = _build_design_surface(rows, columns, slope_tolerance) - if design_shape is None: + # 2. Convertir plataforma a mesh para booleanos + try: + platform_mesh = MeshPart.meshFromShape( + Shape=platform, + LinearDeflection=500, + AngularDeflection=0.5 + ) + except Exception as e: + FreeCAD.Console.PrintError(f"Error al meshificar la plataforma: {e}\n") return None, None, 0, 0 - # Convertir a mesh para booleanos - design_mesh = MeshPart.meshFromShape(Shape=design_shape, LinearDeflection=500, AngularDeflection=0.5) + if platform_mesh is None or platform_mesh.countPoints() == 0: + return None, None, 0, 0 - # Corte: diseño por encima del terreno → material a remover - cut_mesh = _mesh_above(design_mesh, terrain_mesh) - - # Relleno: diseño por debajo del terreno → material a aportar - fill_mesh = _mesh_below(design_mesh, terrain_mesh) + # 3. Calcular corte y relleno + cut_mesh = _mesh_above(platform_mesh, terrain_mesh) + fill_mesh = _mesh_below(platform_mesh, terrain_mesh) volume_cut = _mesh_volume(cut_mesh) volume_fill = _mesh_volume(fill_mesh) @@ -94,181 +80,28 @@ def compute_earthworks(frames, terrain_mesh, slope_tolerance=10.0): return cut_mesh, fill_mesh, volume_cut, volume_fill -def _build_design_surface(rows, columns, slope_tolerance): +def _mesh_above(reference, terrain): """ - Construye la superficie diseñada entre filas de trackers. - - Para cada fila, genera líneas de borde (izquierda/derecha) teniendo - en cuenta la pendiente transversal (E-W). Luego lofting para crear - la superficie continua entre filas. - """ - from DraftGeomUtils import isPlanar - - all_lines = [] - tools = [] # (frame, line_left, line_right) - - for group in rows: - lines = [] - for i, frame in enumerate(group): - aw = _angle_to_prev(group, i) - ae = _angle_to_next(group, i) - anf = (aw + ae) / 2 - if anf > slope_tolerance: - anf = slope_tolerance - - wdt = int(frame.Setup.Width / 2) if hasattr(frame.Setup, 'Width') else 0 - zz = wdt * math.sin(math.radians(anf)) - - # Línea base a lo largo del tracker - line = _get_tracker_line(frame) - - # Borde izquierdo (sur) - li = line.copy() - li.Placement = frame.Placement - li.Placement.Rotation = frame.Placement.Rotation - li.Placement.Base.x -= wdt - li.Placement.Base.z -= zz - lines.append(li) - - # Borde derecho (norte) - ld = line.copy() - ld.Placement = frame.Placement - ld.Placement.Rotation = frame.Placement.Rotation - ld.Placement.Base.x += wdt - ld.Placement.Base.z += zz - lines.append(ld) - - tools.append([frame, li, ld]) - - if len(lines) >= 2: - try: - loft = Part.makeLoft(lines, False, True, False) - if loft and not loft.isNull(): - all_lines.append(loft) - except Exception: - pass - - # Rellenar huecos entre columnas - for group in rows: - for frame in group: - col, idx = _find_in_columns(frame, columns) - tool = _find_tool(frame, tools) - if tool is None: - continue - - if idx < len(col) - 1: - next_frame = col[idx + 1] - next_tool = _find_tool(next_frame, tools) - if next_tool: - 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 len([l1, l2]) >= 2: - loft = Part.makeLoft([l1, l2], False, True, False) - if loft and not loft.isNull(): - all_lines.append(loft) - except Exception: - pass - - if not all_lines: - return None - - # Unir todas las caras en un solo sólido - try: - faces = [] - for item in all_lines: - faces.extend(item.Faces) - if faces: - shell = Part.makeShell(faces) - solid = Part.makeSolid(shell) - return solid - except Exception: - return None - - -def _get_tracker_line(frame): - """Obtiene la línea base longitudinal de un tracker.""" - try: - lng = int(frame.Setup.Length / 2) - return Part.LineSegment(FreeCAD.Vector(-lng, 0, 0), - FreeCAD.Vector(lng, 0, 0)).toShape() - except Exception: - # Fallback: usar el shape del setup - try: - setup_shape = frame.Setup.Shape - bb = 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): - """Ángulo con el tracker anterior en la fila.""" - if i <= 0: - return 0 - p0 = FreeCAD.Vector(group[i - 1].Placement.Base) - p1 = FreeCAD.Vector(group[i].Placement.Base) - return _angle_between(p0, p1) - - -def _angle_to_next(group, i): - """Ángulo con el tracker siguiente en la fila.""" - if i >= len(group) - 1: - return 0 - p1 = FreeCAD.Vector(group[i].Placement.Base) - p2 = FreeCAD.Vector(group[i + 1].Placement.Base) - return _angle_between(p1, p2) - - -def _angle_between(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): - """Busca un frame en la estructura de columnas.""" - for col in columns: - for group in col: - if frame in group: - return group, group.index(frame) - return [], -1 - - -def _find_tool(frame, tools): - """Busca el tool [frame, line_left, line_right] asociado a un frame.""" - for t in tools: - if t[0] == frame: - return t - return None - - -def _mesh_above(design_mesh, terrain_mesh): - """ - Devuelve el mesh de diseño que está POR ENCIMA del terreno (Cut). + Porción del mesh de referencia que está por encima del terreno. + Representa material a excavar (cut). """ try: - common = design_mesh.common(terrain_mesh) - if common and common.countPoints() > 0: + common = reference.common(terrain) + if common and common.countPoints() > 3: return common except Exception: pass return None -def _mesh_below(design_mesh, terrain_mesh): +def _mesh_below(reference, terrain): """ - Devuelve el mesh de diseño que está POR DEBAJO del terreno (Fill). + Porción del mesh de referencia que está por debajo del terreno. + Representa material a rellenar (fill). """ try: - # Mesh booleano: diseño - terreno = parte del diseño fuera del terreno - diff = design_mesh.cut(terrain_mesh) - if diff and diff.countPoints() > 0: + diff = reference.cut(terrain) + if diff and diff.countPoints() > 3: return diff except Exception: pass @@ -276,8 +109,8 @@ def _mesh_below(design_mesh, terrain_mesh): def _mesh_volume(mesh): - """Calcula el volumen aproximado de un mesh (mm³).""" - if mesh is None or mesh.countPoints() == 0: + """Volumen de un mesh en mm³. Retorna 0 si no hay mesh válido.""" + if mesh is None or mesh.countPoints() < 4: return 0 try: return mesh.Volume @@ -285,14 +118,23 @@ def _mesh_volume(mesh): return 0 +def makeEarthWorksVolume(vtype=0): + """Crea un objeto FeaturePython con el mesh de volumen.""" + obj = FreeCAD.ActiveDocument.addObject( + "Part::FeaturePython", VOLUME_TYPES[vtype]) + EarthWorksVolume(obj) + ViewProviderEarthWorksVolume(obj.ViewObject) + return obj + + # ========================================================================= -# Objeto EarthWorksVolume (FeaturePython) +# FeaturePython: EarthWorksVolume # ========================================================================= -class EarthWorksVolume(ArchComponent.Component): +class EarthWorksVolume: + """Objeto que almacena un mesh de volumen (cut o fill).""" + def __init__(self, obj): - ArchComponent.Component.__init__(self, obj) - self.obj = obj self.setProperties(obj) obj.Proxy = self @@ -300,32 +142,31 @@ class EarthWorksVolume(ArchComponent.Component): pl = obj.PropertiesList if "VolumeType" not in pl: - obj.addProperty("App::PropertyEnumeration", - "VolumeType", "Volume", - "Fill o Cut").VolumeType = VOLUME_TYPES + obj.addProperty( + "App::PropertyEnumeration", "VolumeType", "Volume", + "Fill o Cut").VolumeType = VOLUME_TYPES if "VolumeMesh" not in pl: - obj.addProperty("Mesh::PropertyMeshKernel", - "VolumeMesh", "Volume", "Mesh del volumen") + obj.addProperty( + "Mesh::PropertyMeshKernel", "VolumeMesh", "Volume", + "Mesh del volumen") obj.setEditorMode("VolumeMesh", 2) if "Volume" not in pl: - obj.addProperty("App::PropertyVolume", - "Volume", "Volume", - "Volumen calculado (mm³)") + obj.addProperty( + "App::PropertyVolume", "Volume", "Volume", + "Volumen calculado (mm³)") obj.setEditorMode("Volume", 1) obj.IfcType = "Civil Element" obj.setEditorMode("IfcType", 1) def onDocumentRestored(self, obj): - ArchComponent.Component.onDocumentRestored(self, obj) self.setProperties(obj) def onChange(self, obj, prop): - if prop == "VolumeMesh": - if obj.VolumeMesh: - obj.Volume = obj.VolumeMesh.Volume + if prop == "VolumeMesh" and obj.VolumeMesh: + obj.Volume = obj.VolumeMesh.Volume def execute(self, obj): pass @@ -338,7 +179,7 @@ class EarthWorksVolume(ArchComponent.Component): # ========================================================================= -# ViewProvider (Coin3D visualization) +# ViewProvider (Coin3D) # ========================================================================= class ViewProviderEarthWorksVolume: @@ -348,39 +189,38 @@ class ViewProviderEarthWorksVolume: r, g, b = (1.0, 0.0, 0.0) if is_cut else (0.0, 0.0, 1.0) if "Transparency" not in pl: - vobj.addProperty("App::PropertyIntegerConstraint", - "Transparency", "Surface Style", - "Transparencia de la superficie") + vobj.addProperty( + "App::PropertyIntegerConstraint", "Transparency", + "Surface Style", "Transparencia (0=opaco, 100=invisible)") vobj.Transparency = (50, 0, 100, 1) if "ShapeColor" not in pl: - vobj.addProperty("App::PropertyColor", - "ShapeColor", "Surface Style", - "Color de superficie") + vobj.addProperty( + "App::PropertyColor", "ShapeColor", "Surface Style", + "Color de superficie") vobj.ShapeColor = (r, g, b, vobj.Transparency / 100) if "ShapeMaterial" not in pl: - vobj.addProperty("App::PropertyMaterial", - "ShapeMaterial", "Surface Style", - "Material de superficie") + vobj.addProperty( + "App::PropertyMaterial", "ShapeMaterial", "Surface Style", + "Material de superficie") vobj.ShapeMaterial = FreeCAD.Material() vobj.Proxy = self vobj.ShapeMaterial.DiffuseColor = vobj.ShapeColor def onChanged(self, vobj, prop): - if prop == "ShapeColor" or prop == "Transparency": + if prop in ("ShapeColor", "Transparency"): if hasattr(vobj, "ShapeColor") and hasattr(vobj, "Transparency"): - color = vobj.getPropertyByName("ShapeColor") - t = vobj.getPropertyByName("Transparency") - vobj.ShapeMaterial.DiffuseColor = (color[0], color[1], color[2], t / 100) + c = vobj.ShapeColor + t = vobj.Transparency + vobj.ShapeMaterial.DiffuseColor = (c[0], c[1], c[2], t / 100) if prop == "ShapeMaterial": - if hasattr(vobj, "ShapeMaterial"): - mat = vobj.getPropertyByName("ShapeMaterial") - if hasattr(self, 'face_material'): - self.face_material.diffuseColor.setValue(mat.DiffuseColor[:3]) - self.face_material.transparency = mat.DiffuseColor[3] + if hasattr(self, "face_material"): + mat = vobj.ShapeMaterial + self.face_material.diffuseColor.setValue(mat.DiffuseColor[:3]) + self.face_material.transparency = mat.DiffuseColor[3] def attach(self, vobj): from pivy import coin @@ -400,8 +240,8 @@ class ViewProviderEarthWorksVolume: offset.styles = coin.SoPolygonOffset.LINES offset.factor = -2.0 - highlight = coin.SoType.fromName('SoFCSelection').createInstance() - highlight.style = 'EMISSIVE_DIFFUSE' + highlight = coin.SoType.fromName("SoFCSelection").createInstance() + highlight.style = "EMISSIVE_DIFFUSE" highlight.addChild(shape_hints) highlight.addChild(mat_binding) highlight.addChild(self.geo_coords) @@ -416,15 +256,16 @@ class ViewProviderEarthWorksVolume: edge.addChild(self.edge_style) edge.addChild(highlight) - surface_root = coin.SoSeparator() - surface_root.addChild(face) - surface_root.addChild(offset) - surface_root.addChild(edge) - vobj.addDisplayMode(surface_root, "Surface") + surface = coin.SoSeparator() + surface.addChild(face) + surface.addChild(offset) + surface.addChild(edge) - wireframe_root = coin.SoSeparator() - wireframe_root.addChild(edge) - vobj.addDisplayMode(wireframe_root, "Wireframe") + wireframe = coin.SoSeparator() + wireframe.addChild(edge) + + vobj.addDisplayMode(surface, "Surface") + vobj.addDisplayMode(wireframe, "Wireframe") self.onChanged(vobj, "ShapeColor") @@ -434,18 +275,17 @@ class ViewProviderEarthWorksVolume: if mesh is None or mesh.countPoints() == 0: return try: - geo_system = ["UTM", FreeCAD.ActiveDocument.Site.UtmZone, "FLAT"] + geo = ["UTM", FreeCAD.ActiveDocument.Site.UtmZone, "FLAT"] except Exception: - geo_system = ["UTM", "30N", "FLAT"] - self.geo_coords.geoSystem.setValues(geo_system) + geo = ["UTM", "30N", "FLAT"] + self.geo_coords.geoSystem.setValues(geo) - copy_mesh = mesh.copy() + cm = mesh.copy() triangles = [] - for i in copy_mesh.Topology[1]: + for i in cm.Topology[1]: triangles.extend(list(i)) triangles.append(-1) - - self.geo_coords.point.setValues(copy_mesh.Topology[0]) + self.geo_coords.point.setValues(cm.Topology[0]) self.triangles.coordIndex.setValues(triangles) def getIcon(self): @@ -479,46 +319,47 @@ class EarthWorksTaskPanel: QtGui.QIcon(os.path.join(PVPlantResources.DirIcons, "convert.svg"))) def accept(self): - import MeshPart land = FreeCAD.ActiveDocument.Terrain.Mesh.copy() frames = [] for obj in FreeCADGui.Selection.getSelection(): if hasattr(obj, "Proxy"): - proxy_type = getattr(obj.Proxy, "Type", None) - if proxy_type == "Tracker": - if obj not in frames: - frames.append(obj) - elif proxy_type == "FrameArea": + t = getattr(obj.Proxy, "Type", None) + if t == "Tracker" and obj not in frames: + frames.append(obj) + elif t == "FrameArea": for fr in obj.Frames: if fr not in frames: frames.append(fr) if not frames: - FreeCAD.Console.PrintWarning("Selecciona trackers o un FrameArea\n") + FreeCAD.Console.PrintWarning( + "Selecciona trackers o un FrameArea\n") return False - FreeCAD.ActiveDocument.openTransaction("Movimiento de tierras") + slope = getattr(FreeCAD.ActiveDocument, + "MaximumWestEastSlope", 10.0) + FreeCAD.ActiveDocument.openTransaction("Movimiento de tierras") try: cut_mesh, fill_mesh, vol_cut, vol_fill = compute_earthworks( - frames, land, - slope_tolerance=getattr(FreeCAD.ActiveDocument, - 'MaximumWestEastSlope', 10.0) - ) + frames, land, slope) - if cut_mesh and cut_mesh.countPoints() > 0: - vol_obj = makeEarthWorksVolume(1) # Cut - vol_obj.VolumeMesh = cut_mesh - FreeCAD.Console.PrintMessage(f"Volumen corte: {vol_cut:.0f} mm³\n") + if cut_mesh and cut_mesh.countPoints() > 3: + v = makeEarthWorksVolume(1) # Cut + v.VolumeMesh = cut_mesh + FreeCAD.Console.PrintMessage( + f"Volumen de corte: {vol_cut:,.0f} mm³\n") - if fill_mesh and fill_mesh.countPoints() > 0: - vol_obj = makeEarthWorksVolume(0) # Fill - vol_obj.VolumeMesh = fill_mesh - FreeCAD.Console.PrintMessage(f"Volumen relleno: {vol_fill:.0f} mm³\n") + if fill_mesh and fill_mesh.countPoints() > 3: + v = makeEarthWorksVolume(0) # Fill + v.VolumeMesh = fill_mesh + FreeCAD.Console.PrintMessage( + f"Volumen de relleno: {vol_fill:,.0f} mm³\n") except Exception as e: - FreeCAD.Console.PrintError(f"Error en movimiento de tierras: {e}\n") + FreeCAD.Console.PrintError( + f"Error en movimiento de tierras: {e}\n") finally: FreeCAD.ActiveDocument.commitTransaction() @@ -527,7 +368,4 @@ class EarthWorksTaskPanel: def reject(self): FreeCADGui.Control.closeDialog() - return True - - -# Comando registrado desde Init.py o InitGui.py \ No newline at end of file + return True \ No newline at end of file diff --git a/Civil/PVPlantPlatform.py b/Civil/PVPlantPlatform.py new file mode 100644 index 0000000..d6d8730 --- /dev/null +++ b/Civil/PVPlantPlatform.py @@ -0,0 +1,284 @@ +# /********************************************************************** +# * * +# * 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 \ No newline at end of file