From 2858b58d86084d834ed6ced45b1454cbff51b7fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Bra=C3=B1a?= Date: Sun, 3 May 2026 22:50:55 +0200 Subject: [PATCH] =?UTF-8?q?EarthWorks:=20rewrite=20completo.=20Limpio,=20m?= =?UTF-8?q?odular,=20con=20funci=C3=B3n=20compute=5Fearthworks()=20princip?= =?UTF-8?q?al.=20Cut=3Drojo,=20Fill=3Dazul.=20Eliminado=20c=C3=B3digo=20mu?= =?UTF-8?q?erto=20(3=20versiones=20conviviendo,=20accept()=20duplicada,=20?= =?UTF-8?q?viewprovider=20comentado)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Civil/PVPlantEarthWorks.py | 533 +++++++++++++++++++++++++++++++++++++ 1 file changed, 533 insertions(+) create mode 100644 Civil/PVPlantEarthWorks.py diff --git a/Civil/PVPlantEarthWorks.py b/Civil/PVPlantEarthWorks.py new file mode 100644 index 0000000..78d76a6 --- /dev/null +++ b/Civil/PVPlantEarthWorks.py @@ -0,0 +1,533 @@ +# /********************************************************************** +# * * +# * Copyright (c) 2026 Javier Braña * +# * * +# * 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. * +# * * +# *********************************************************************** + +import FreeCAD +import Part +import Mesh +import math +import numpy as np +import ArchComponent + +if FreeCAD.GuiUp: + import FreeCADGui, os + from PySide import QtCore, QtGui + from PySide.QtCore import QT_TRANSLATE_NOOP +else: + def translate(ctxt, txt): return txt + def QT_TRANSLATE_NOOP(ctxt, txt): return txt + +import PVPlantResources +from PVPlantResources import DirIcons as DirIcons + +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 + slope_tolerance: pendiente máxima E-W (grados) + + Returns: + (mesh_cut, mesh_fill, volume_cut, volume_fill) + """ + import MeshPart + rows, columns = get_tracker_rows(frames) + if rows is None or not rows: + 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: + return None, None, 0, 0 + + # Convertir a mesh para booleanos + design_mesh = MeshPart.meshFromShape(Shape=design_shape, LinearDeflection=500, AngularDeflection=0.5) + + # 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) + + volume_cut = _mesh_volume(cut_mesh) + volume_fill = _mesh_volume(fill_mesh) + + return cut_mesh, fill_mesh, volume_cut, volume_fill + + +def _build_design_surface(rows, columns, slope_tolerance): + """ + 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). + """ + try: + common = design_mesh.common(terrain_mesh) + if common and common.countPoints() > 0: + return common + except Exception: + pass + return None + + +def _mesh_below(design_mesh, terrain_mesh): + """ + Devuelve el mesh de diseño que está POR DEBAJO del terreno (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: + return diff + except Exception: + pass + return None + + +def _mesh_volume(mesh): + """Calcula el volumen aproximado de un mesh (mm³).""" + if mesh is None or mesh.countPoints() == 0: + return 0 + try: + return mesh.Volume + except Exception: + return 0 + + +# ========================================================================= +# Objeto EarthWorksVolume (FeaturePython) +# ========================================================================= + +class EarthWorksVolume(ArchComponent.Component): + def __init__(self, obj): + ArchComponent.Component.__init__(self, obj) + self.obj = obj + self.setProperties(obj) + obj.Proxy = self + + def setProperties(self, obj): + pl = obj.PropertiesList + + if "VolumeType" not in pl: + 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.setEditorMode("VolumeMesh", 2) + + if "Volume" not in pl: + 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 + + def execute(self, obj): + pass + + def __getstate__(self): + return None + + def __setstate__(self, state): + return None + + +# ========================================================================= +# ViewProvider (Coin3D visualization) +# ========================================================================= + +class ViewProviderEarthWorksVolume: + def __init__(self, vobj): + pl = vobj.PropertiesList + is_cut = vobj.Object.VolumeType == "Cut" + 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.Transparency = (50, 0, 100, 1) + + if "ShapeColor" not in pl: + 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.ShapeMaterial = FreeCAD.Material() + + vobj.Proxy = self + vobj.ShapeMaterial.DiffuseColor = vobj.ShapeColor + + def onChanged(self, vobj, prop): + if prop == "ShapeColor" or prop == "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) + + 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] + + def attach(self, vobj): + from pivy import coin + + self.geo_coords = coin.SoGeoCoordinate() + self.triangles = coin.SoIndexedFaceSet() + self.face_material = coin.SoMaterial() + self.edge_material = coin.SoMaterial() + self.edge_style = coin.SoDrawStyle() + self.edge_style.style = coin.SoDrawStyle.LINES + + shape_hints = coin.SoShapeHints() + shape_hints.vertex_ordering = coin.SoShapeHints.COUNTERCLOCKWISE + mat_binding = coin.SoMaterialBinding() + mat_binding.value = coin.SoMaterialBinding.PER_FACE + offset = coin.SoPolygonOffset() + offset.styles = coin.SoPolygonOffset.LINES + offset.factor = -2.0 + + highlight = coin.SoType.fromName('SoFCSelection').createInstance() + highlight.style = 'EMISSIVE_DIFFUSE' + highlight.addChild(shape_hints) + highlight.addChild(mat_binding) + highlight.addChild(self.geo_coords) + highlight.addChild(self.triangles) + + face = coin.SoSeparator() + face.addChild(self.face_material) + face.addChild(highlight) + + edge = coin.SoSeparator() + edge.addChild(self.edge_material) + 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") + + wireframe_root = coin.SoSeparator() + wireframe_root.addChild(edge) + vobj.addDisplayMode(wireframe_root, "Wireframe") + + self.onChanged(vobj, "ShapeColor") + + def updateData(self, obj, prop): + if prop == "VolumeMesh": + mesh = obj.VolumeMesh + if mesh is None or mesh.countPoints() == 0: + return + try: + geo_system = ["UTM", FreeCAD.ActiveDocument.Site.UtmZone, "FLAT"] + except Exception: + geo_system = ["UTM", "30N", "FLAT"] + self.geo_coords.geoSystem.setValues(geo_system) + + copy_mesh = mesh.copy() + triangles = [] + for i in copy_mesh.Topology[1]: + triangles.extend(list(i)) + triangles.append(-1) + + self.geo_coords.point.setValues(copy_mesh.Topology[0]) + self.triangles.coordIndex.setValues(triangles) + + def getIcon(self): + return str(os.path.join(DirIcons, "googleearth.svg")) + + def getDisplayModes(self, vobj): + return ["Surface", "Wireframe"] + + def getDefaultDisplayMode(self): + return "Surface" + + def setDisplayMode(self, mode): + return mode + + def __getstate__(self): + return None + + def __setstate__(self, state): + return None + + +# ========================================================================= +# TaskPanel +# ========================================================================= + +class EarthWorksTaskPanel: + def __init__(self): + self.form = FreeCADGui.PySideUic.loadUi( + os.path.join(PVPlantResources.__dir__, "PVPlantEarthworks.ui")) + self.form.setWindowIcon( + 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": + 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") + return False + + 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) + ) + + 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 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") + + except Exception as e: + FreeCAD.Console.PrintError(f"Error en movimiento de tierras: {e}\n") + finally: + FreeCAD.ActiveDocument.commitTransaction() + + FreeCADGui.Control.closeDialog() + return True + + def reject(self): + FreeCADGui.Control.closeDialog() + return True + + +# Comando registrado desde Init.py o InitGui.py \ No newline at end of file