387 lines
13 KiB
Python
387 lines
13 KiB
Python
# /**********************************************************************
|
|
# * *
|
|
# * Copyright (c) 2026 Javier Braña <javier.branagutierrez@gmail.com> *
|
|
# * *
|
|
# * 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, get_platform_shape, make_platform
|
|
|
|
VOLUME_TYPES = ["Fill", "Cut"]
|
|
|
|
|
|
def compute_earthworks(platform_or_frames, terrain_mesh, slope_tolerance=10.0):
|
|
"""
|
|
Calcula el movimiento de tierras.
|
|
|
|
Args:
|
|
platform_or_frames: Objeto Platform o lista de frames/trackers
|
|
Si es Platform, usa su Shape directamente.
|
|
Si es lista de frames, genera la plataforma primero.
|
|
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)
|
|
"""
|
|
import MeshPart
|
|
|
|
# 1. Obtener la plataforma
|
|
if hasattr(platform_or_frames, 'Proxy') and hasattr(platform_or_frames.Proxy, '__class__'):
|
|
cls_name = platform_or_frames.Proxy.__class__.__name__
|
|
if cls_name == 'Platform':
|
|
# Ya es un objeto Platform → usar su Shape
|
|
platform = get_platform_shape(platform_or_frames)
|
|
else:
|
|
platform = build_platform([platform_or_frames], slope_tolerance)
|
|
elif hasattr(platform_or_frames, '__iter__'):
|
|
# Es una lista de frames
|
|
platform = build_platform(platform_or_frames, slope_tolerance)
|
|
else:
|
|
platform = 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
|
|
|
|
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()
|
|
|
|
# Detectar si hay un Platform seleccionado o trackers sueltos
|
|
platform_obj = None
|
|
frames = []
|
|
|
|
for obj in FreeCADGui.Selection.getSelection():
|
|
if hasattr(obj, "Proxy"):
|
|
if obj.Proxy.__class__.__name__ == 'Platform':
|
|
platform_obj = obj
|
|
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 and not platform_obj:
|
|
FreeCAD.Console.PrintWarning(
|
|
"Selecciona trackers, un FrameArea o un Platform\n")
|
|
return False
|
|
|
|
slope = getattr(FreeCAD.ActiveDocument,
|
|
"MaximumWestEastSlope", 10.0)
|
|
|
|
FreeCAD.ActiveDocument.openTransaction("Movimiento de tierras")
|
|
try:
|
|
input_data = platform_obj if platform_obj else frames
|
|
cut_mesh, fill_mesh, vol_cut, vol_fill = compute_earthworks(
|
|
input_data, 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 |