# /********************************************************************** # * * # * 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 * # * 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 * # * * # *********************************************************************** import FreeCAD import Part import Mesh import math 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 from .PVPlantPlatform import build_platform VOLUME_TYPES = ["Fill", "Cut"] 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 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 # 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 if platform_mesh is None or platform_mesh.countPoints() == 0: return None, None, 0, 0 # 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) return cut_mesh, fill_mesh, volume_cut, volume_fill def _mesh_above(reference, terrain): """ Porción del mesh de referencia que está por encima del terreno. Representa material a excavar (cut). """ try: common = reference.common(terrain) if common and common.countPoints() > 3: return common except Exception: pass return None def _mesh_below(reference, terrain): """ Porción del mesh de referencia que está por debajo del terreno. Representa material a rellenar (fill). """ try: diff = reference.cut(terrain) if diff and diff.countPoints() > 3: return diff except Exception: pass return None def _mesh_volume(mesh): """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 except Exception: 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 # ========================================================================= # FeaturePython: EarthWorksVolume # ========================================================================= class EarthWorksVolume: """Objeto que almacena un mesh de volumen (cut o fill).""" def __init__(self, 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): self.setProperties(obj) def onChange(self, obj, prop): if prop == "VolumeMesh" and 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) # ========================================================================= 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 (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.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 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) if prop == "ShapeMaterial": 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 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 = coin.SoSeparator() surface.addChild(face) surface.addChild(offset) surface.addChild(edge) wireframe = coin.SoSeparator() wireframe.addChild(edge) vobj.addDisplayMode(surface, "Surface") vobj.addDisplayMode(wireframe, "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 = ["UTM", FreeCAD.ActiveDocument.Site.UtmZone, "FLAT"] except Exception: geo = ["UTM", "30N", "FLAT"] self.geo_coords.geoSystem.setValues(geo) cm = mesh.copy() triangles = [] for i in cm.Topology[1]: triangles.extend(list(i)) triangles.append(-1) self.geo_coords.point.setValues(cm.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): land = FreeCAD.ActiveDocument.Terrain.Mesh.copy() frames = [] for obj in FreeCADGui.Selection.getSelection(): if hasattr(obj, "Proxy"): 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") return False 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) 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() > 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") finally: FreeCAD.ActiveDocument.commitTransaction() FreeCADGui.Control.closeDialog() return True def reject(self): FreeCADGui.Control.closeDialog() return True