Platform: FeaturePython completo. Objeto Platform con SourceFrames, SlopeTolerance, Shape. EarthWorks acepta Platform o frames. ViewProvider verde semitransparente.

This commit is contained in:
Javier Braña
2026-05-04 00:09:09 +02:00
parent 1a22121f87
commit e461ab2e80
2 changed files with 311 additions and 111 deletions
+28 -12
View File
@@ -31,30 +31,40 @@ else:
import PVPlantResources import PVPlantResources
from PVPlantResources import DirIcons as DirIcons from PVPlantResources import DirIcons as DirIcons
from .PVPlantPlatform import build_platform from .PVPlantPlatform import build_platform, get_platform_shape, make_platform
VOLUME_TYPES = ["Fill", "Cut"] VOLUME_TYPES = ["Fill", "Cut"]
def compute_earthworks(frames, terrain_mesh, slope_tolerance=10.0): def compute_earthworks(platform_or_frames, terrain_mesh, slope_tolerance=10.0):
""" """
Calcula el movimiento de tierras para una lista de frames/trackers. Calcula el movimiento de tierras.
Args: Args:
frames: lista de objetos tracker 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 terrain_mesh: Mesh del terreno natural
slope_tolerance: pendiente máxima E-W (grados) slope_tolerance: pendiente máxima E-W (grados)
Returns: Returns:
tuple: (mesh_cut, mesh_fill, volume_cut_mm3, volume_fill_mm3) 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 import MeshPart
# 1. Construir la plataforma (superficie diseñada) desde los trackers # 1. Obtener la plataforma
platform = build_platform(frames, slope_tolerance) if hasattr(platform_or_frames, 'Proxy') and hasattr(platform_or_frames.Proxy, '__class__'):
if platform is None: cls_name = platform_or_frames.Proxy.__class__.__name__
return None, None, 0, 0 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 # 2. Convertir plataforma a mesh para booleanos
try: try:
@@ -321,9 +331,14 @@ class EarthWorksTaskPanel:
def accept(self): def accept(self):
land = FreeCAD.ActiveDocument.Terrain.Mesh.copy() land = FreeCAD.ActiveDocument.Terrain.Mesh.copy()
# Detectar si hay un Platform seleccionado o trackers sueltos
platform_obj = None
frames = [] frames = []
for obj in FreeCADGui.Selection.getSelection(): for obj in FreeCADGui.Selection.getSelection():
if hasattr(obj, "Proxy"): if hasattr(obj, "Proxy"):
if obj.Proxy.__class__.__name__ == 'Platform':
platform_obj = obj
t = getattr(obj.Proxy, "Type", None) t = getattr(obj.Proxy, "Type", None)
if t == "Tracker" and obj not in frames: if t == "Tracker" and obj not in frames:
frames.append(obj) frames.append(obj)
@@ -332,9 +347,9 @@ class EarthWorksTaskPanel:
if fr not in frames: if fr not in frames:
frames.append(fr) frames.append(fr)
if not frames: if not frames and not platform_obj:
FreeCAD.Console.PrintWarning( FreeCAD.Console.PrintWarning(
"Selecciona trackers o un FrameArea\n") "Selecciona trackers, un FrameArea o un Platform\n")
return False return False
slope = getattr(FreeCAD.ActiveDocument, slope = getattr(FreeCAD.ActiveDocument,
@@ -342,8 +357,9 @@ class EarthWorksTaskPanel:
FreeCAD.ActiveDocument.openTransaction("Movimiento de tierras") FreeCAD.ActiveDocument.openTransaction("Movimiento de tierras")
try: try:
input_data = platform_obj if platform_obj else frames
cut_mesh, fill_mesh, vol_cut, vol_fill = compute_earthworks( cut_mesh, fill_mesh, vol_cut, vol_fill = compute_earthworks(
frames, land, slope) input_data, land, slope)
if cut_mesh and cut_mesh.countPoints() > 3: if cut_mesh and cut_mesh.countPoints() > 3:
v = makeEarthWorksVolume(1) # Cut v = makeEarthWorksVolume(1) # Cut
+283 -99
View File
@@ -2,14 +2,16 @@
# * * # * *
# * Copyright (c) 2026 Javier Braña <javier.branagutierrez@gmail.com> * # * Copyright (c) 2026 Javier Braña <javier.branagutierrez@gmail.com> *
# * * # * *
# * Platform - Generación de plataforma desde trackers * # * PVPlantPlatform - Plataforma de diseño solar *
# * * # * *
# * Construye la superficie diseñada (plataforma) a partir de la * # * Es el elemento central del movimiento de tierras. Representa la *
# * disposición de trackers solares, incluyendo pendientes E-W, * # * superficie diseñada generada a partir de la disposición de trackers.*
# * conexiones entre filas y columnas. *
# * * # * *
# * Esta plataforma se usa luego en EarthWorks para calcular * # * De ella dependen: *
# * volúmenes de corte y relleno contra el terreno natural. * # * - EarthWorks: cut/fill entre plataforma y terreno natural *
# * - Road: trazado de viales sobre la plataforma *
# * - Drainage: drenaje superficial *
# * - Trench: zanjas sobre la plataforma *
# * * # * *
# *********************************************************************** # ***********************************************************************
@@ -17,43 +19,182 @@ import FreeCAD
import Part import Part
import math import math
if FreeCAD.GuiUp:
import FreeCADGui, os
from PySide.QtCore import QT_TRANSLATE_NOOP
else:
def QT_TRANSLATE_NOOP(ctxt, txt): return txt
def get_tracker_rows(frames): import PVPlantResources
from PVPlantResources import DirIcons as DirIcons
# =========================================================================
# Constructor
# =========================================================================
def make_platform(frames=None, name="Platform"):
""" """
Agrupa trackers en filas y columnas usando la lógica de Placement. Crea un objeto Platform en el documento activo.
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: Args:
frames: lista de objetos tracker (con Placement, Setup, etc.) frames: lista opcional de objetos tracker para inicializar
slope_tolerance: pendiente máxima E-W en grados name: nombre del objeto
Returns: Returns:
Part.Solid con la plataforma, o None si no se puede generar Objeto FeaturePython Platform, o None si no hay documento
""" """
rows, columns = get_tracker_rows(frames) doc = FreeCAD.ActiveDocument
if doc is None:
return None
obj = doc.addObject("Part::FeaturePython", name)
Platform(obj)
_ViewProviderPlatform(obj.ViewObject)
obj.Label = name
if frames:
# Asignar SourceFrames como lista de enlaces
obj.SourceFrames = frames
doc.recompute()
return obj
# =========================================================================
# FeaturePython: Platform
# =========================================================================
class Platform:
"""
Plataforma de diseño generada desde trackers solares.
Propiedades principales:
SourceFrames : Lista de trackers que definen la plataforma
SlopeTolerance : Pendiente máxima E-W (grados)
PlatformArea : Área de la plataforma (solo lectura)
PlatformVolume : Volumen bajo la plataforma (solo lectura)
Status : Estado del último cálculo
"""
def __init__(self, obj):
self.setProperties(obj)
obj.Proxy = self
def setProperties(self, obj):
pl = obj.PropertiesList
if "SourceFrames" not in pl:
obj.addProperty(
"App::PropertyLinkList", "SourceFrames",
"Platform",
"Trackers que definen la plataforma")
if "SlopeTolerance" not in pl:
obj.addProperty(
"App::PropertyFloat", "SlopeTolerance",
"Platform",
"Pendiente transversal máxima (grados)").SlopeTolerance = 10.0
if "PlatformArea" not in pl:
obj.addProperty(
"App::PropertyArea", "PlatformArea",
"Platform",
"Área total de la plataforma (solo lectura)")
obj.setEditorMode("PlatformArea", 1)
if "PlatformVolume" not in pl:
obj.addProperty(
"App::PropertyVolume", "PlatformVolume",
"Platform",
"Volumen bajo la plataforma (solo lectura)")
obj.setEditorMode("PlatformVolume", 1)
if "NumberOfFrames" not in pl:
obj.addProperty(
"App::PropertyInteger", "NumberOfFrames",
"Platform",
"Número de trackers en la plataforma")
obj.setEditorMode("NumberOfFrames", 1)
if "Status" not in pl:
obj.addProperty(
"App::PropertyString", "Status",
"Platform",
"Estado del último cálculo")
def onDocumentRestored(self, obj):
self.setProperties(obj)
def execute(self, obj):
"""Calcula la plataforma a partir de los SourceFrames."""
frames = obj.SourceFrames
if not frames:
obj.Shape = Part.Shape()
obj.Status = "Sin frames"
return
obj.NumberOfFrames = len(frames)
slope = obj.SlopeTolerance
try:
shape = _build_platform_shape(frames, slope)
except Exception as e:
obj.Status = f"Error: {e}"
FreeCAD.Console.PrintError(
f"Error al generar plataforma: {e}\n")
return
if shape is None:
obj.Status = "No se pudo generar"
return
obj.Shape = shape
# Calcular área y volumen
try:
area = shape.Area
if area > 0:
obj.PlatformArea = area
# Volumen aproximado: proyectar al plano XY
try:
obj.PlatformVolume = shape.Volume
except Exception:
pass
except Exception:
pass
obj.Status = f"OK - {len(frames)} frames"
FreeCAD.Console.PrintMessage(
f"Plataforma generada: {len(frames)} frames, "
f"área={area:,.0f} mm²\n")
def __getstate__(self):
return None
def __setstate__(self, state):
return None
# =========================================================================
# Cálculo de la plataforma (lógica principal)
# =========================================================================
def _build_platform_shape(frames, slope_tolerance):
"""
Construye la geometría de la plataforma desde los frames.
Returns:
Part.Solid o None si falla
"""
rows, columns = _get_tracker_rows(frames)
if rows is None or not rows: if rows is None or not rows:
FreeCAD.Console.PrintWarning(
"No se pudieron agrupar los trackers en filas/columnas\n")
return None return None
all_faces = [] all_faces = []
tools = [] tools = []
# --- Fase 1: Lofts longitudinales (a lo largo de cada fila) --- # Fase 1: Lofts longitudinales (a lo largo de cada fila)
for group in rows: for group in rows:
lines = _generate_row_lines(group, slope_tolerance) lines = _generate_row_lines(group, slope_tolerance)
tools.extend(lines["tools"]) tools.extend(lines["tools"])
@@ -65,7 +206,7 @@ def build_platform(frames, slope_tolerance=10.0):
except Exception: except Exception:
pass pass
# --- Fase 2: Lofts transversales (entre columnas) --- # Fase 2: Lofts transversales (entre columnas)
if columns: if columns:
for group in rows: for group in rows:
for frame in group: for frame in group:
@@ -80,12 +221,10 @@ def build_platform(frames, slope_tolerance=10.0):
continue continue
try: try:
# Conectar borde izquierdo de frame con borde izquierdo del siguiente
l1 = Part.LineSegment( l1 = Part.LineSegment(
tool[1].Vertexes[-1].Point, tool[1].Vertexes[-1].Point,
next_tool[1].Vertexes[0].Point next_tool[1].Vertexes[0].Point
).toShape() ).toShape()
# Conectar borde derecho
l2 = Part.LineSegment( l2 = Part.LineSegment(
tool[2].Vertexes[-1].Point, tool[2].Vertexes[-1].Point,
next_tool[2].Vertexes[0].Point next_tool[2].Vertexes[0].Point
@@ -98,14 +237,10 @@ def build_platform(frames, slope_tolerance=10.0):
pass pass
if not all_faces: if not all_faces:
FreeCAD.Console.PrintWarning(
"No se generaron caras para la plataforma\n")
return None return None
# --- Fase 3: Unir caras en un sólido --- # Fase 3: Unir caras en un sólido
try: try:
import Utils.PVPlantUtils as utils
# Intentar fuse progresivo
platform = None platform = None
for face in all_faces: for face in all_faces:
if platform is None: if platform is None:
@@ -119,63 +254,43 @@ def build_platform(frames, slope_tolerance=10.0):
if platform is None: if platform is None:
return None return None
# Si es una shell, convertir a sólido
if platform.ShapeType == "Shell": if platform.ShapeType == "Shell":
try: try:
platform = Part.makeSolid(platform) platform = Part.makeSolid(platform)
except Exception: except Exception:
pass pass
elif platform.ShapeType == "Compound": elif platform.ShapeType == "Compound":
# Extraer caras y hacer shell faces_in = [s for s in platform.SubShapes if s.ShapeType == "Face"]
faces_in_compound = [] if faces_in:
for sub in platform.SubShapes:
if sub.ShapeType == "Face":
faces_in_compound.append(sub)
if faces_in_compound:
try: try:
shell = Part.makeShell(faces_in_compound) shell = Part.makeShell(faces_in)
platform = Part.makeSolid(shell) platform = Part.makeSolid(shell)
except Exception: except Exception:
pass pass
return platform if not platform.isNull() else None return platform if not platform.isNull() else None
except Exception as e: except Exception:
FreeCAD.Console.PrintError(
f"Error al unir la plataforma: {e}\n")
return None return None
def add_platform_to_doc(platform, name="Platform"): def _get_tracker_rows(frames):
""" """Agrupa trackers usando la lógica de PVPlantPlacement."""
Añade la plataforma como objeto visible en el documento activo. try:
import PVPlantPlacement
Args: return PVPlantPlacement.getRows(frames)
platform: Part.Shape (Solid o Compound) except Exception:
name: nombre del objeto en el documento return None, None
"""
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): def _generate_row_lines(group, slope_tolerance):
""" """
Genera las líneas de borde (izquierda/derecha) para una fila de trackers. Genera líneas de borde (izquierda/derecha) para una fila de trackers.
Returns: Returns:
dict con: dict con edges (lista de Part.Shape) y tools (lista de [frame, izq, der])
- edges: lista de Part.Shape (líneas)
- tools: lista de [frame, izq, der] para reconexión
""" """
lines = {"edges": [], "tools": []} result = {"edges": [], "tools": []}
for i, frame in enumerate(group): for i, frame in enumerate(group):
if not hasattr(frame, "Setup"): if not hasattr(frame, "Setup"):
@@ -187,44 +302,38 @@ def _generate_row_lines(group, slope_tolerance):
if anf > slope_tolerance: if anf > slope_tolerance:
anf = slope_tolerance anf = slope_tolerance
wdt = _get_tracker_width(frame) wdt = _get_half_width(frame)
zz = wdt * math.sin(math.radians(anf)) zz = wdt * math.sin(math.radians(anf))
base_line = _get_tracker_base_line(frame) base = _get_base_line(frame)
# Borde izquierdo (sur) li = base.copy()
li = base_line.copy()
li.Placement = frame.Placement li.Placement = frame.Placement
li.Placement.Rotation = frame.Placement.Rotation li.Placement.Rotation = frame.Placement.Rotation
li.Placement.Base.x -= wdt li.Placement.Base.x -= wdt
li.Placement.Base.z -= zz li.Placement.Base.z -= zz
lines["edges"].append(li) result["edges"].append(li)
# Borde derecho (norte) ld = base.copy()
ld = base_line.copy()
ld.Placement = frame.Placement ld.Placement = frame.Placement
ld.Placement.Rotation = frame.Placement.Rotation ld.Placement.Rotation = frame.Placement.Rotation
ld.Placement.Base.x += wdt ld.Placement.Base.x += wdt
ld.Placement.Base.z += zz ld.Placement.Base.z += zz
lines["edges"].append(ld) result["edges"].append(ld)
lines["tools"].append([frame, li, ld]) result["tools"].append([frame, li, ld])
return lines return result
def _get_tracker_width(frame): def _get_half_width(frame):
"""Ancho medio del tracker (mm)."""
try: try:
return int(frame.Setup.Width / 2) return int(frame.Setup.Width / 2)
except Exception: except Exception:
return 0 return 0
def _get_tracker_base_line(frame): def _get_base_line(frame):
"""
Línea base longitudinal del tracker, centrada en su origen local.
"""
try: try:
lng = int(frame.Setup.Length / 2) lng = int(frame.Setup.Length / 2)
return Part.LineSegment( return Part.LineSegment(
@@ -232,7 +341,6 @@ def _get_tracker_base_line(frame):
FreeCAD.Vector(lng, 0, 0) FreeCAD.Vector(lng, 0, 0)
).toShape() ).toShape()
except Exception: except Exception:
# Fallback: BoundBox
try: try:
bb = frame.Setup.Shape.BoundBox bb = frame.Setup.Shape.BoundBox
return Part.LineSegment( return Part.LineSegment(
@@ -249,21 +357,22 @@ def _get_tracker_base_line(frame):
def _angle_to_prev(group, i): def _angle_to_prev(group, i):
if i <= 0: if i <= 0:
return 0 return 0
p0 = FreeCAD.Vector(group[i - 1].Placement.Base) return _angle_xz(
p1 = FreeCAD.Vector(group[i].Placement.Base) group[i - 1].Placement.Base,
return _angle_xz(p0, p1) group[i].Placement.Base
)
def _angle_to_next(group, i): def _angle_to_next(group, i):
if i >= len(group) - 1: if i >= len(group) - 1:
return 0 return 0
p1 = FreeCAD.Vector(group[i].Placement.Base) return _angle_xz(
p2 = FreeCAD.Vector(group[i + 1].Placement.Base) group[i].Placement.Base,
return _angle_xz(p1, p2) group[i + 1].Placement.Base
)
def _angle_xz(v1, v2): def _angle_xz(v1, v2):
"""Ángulo en el plano XZ entre dos vectores (grados)."""
dx = v2.x - v1.x dx = v2.x - v1.x
dz = v2.z - v1.z dz = v2.z - v1.z
return math.degrees(math.atan2(dz, dx)) return math.degrees(math.atan2(dz, dx))
@@ -271,9 +380,9 @@ def _angle_xz(v1, v2):
def _find_in_columns(frame, columns): def _find_in_columns(frame, columns):
for col in columns: for col in columns:
for group in col: for g in col:
if frame in group: if frame in g:
return group, group.index(frame) return g, g.index(frame)
return [], -1 return [], -1
@@ -281,4 +390,79 @@ def _find_tool(frame, tools):
for t in tools: for t in tools:
if t[0] == frame: if t[0] == frame:
return t return t
return None
# =========================================================================
# ViewProvider
# =========================================================================
class _ViewProviderPlatform:
def __init__(self, vobj):
vobj.Proxy = self
pl = vobj.PropertiesList
if "Transparency" not in pl:
vobj.addProperty(
"App::PropertyIntegerConstraint", "Transparency",
"Platform Style", "Transparencia de la plataforma")
vobj.Transparency = (40, 0, 100, 1)
if "ShapeColor" not in pl:
vobj.addProperty(
"App::PropertyColor", "ShapeColor",
"Platform Style", "Color de la plataforma")
vobj.ShapeColor = (0.3, 0.8, 0.3, 0.6) # verde semitransparente
if "ShapeMaterial" not in pl:
vobj.addProperty(
"App::PropertyMaterial", "ShapeMaterial",
"Platform Style", "Material")
vobj.ShapeMaterial = FreeCAD.Material()
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)
def getIcon(self):
return str(os.path.join(DirIcons, "solar-fixed.svg"))
def __getstate__(self):
return None
def __setstate__(self, state):
return None
# =========================================================================
# Functions de conveniencia (API pública)
# =========================================================================
def build_platform(frames, slope_tolerance=10.0):
"""
API pública: construye la geometría de plataforma desde frames.
Útil para EarthWorks, Road, etc. que quieran la Shape sin crear objeto.
Returns:
Part.Solid o None
"""
return _build_platform_shape(frames, slope_tolerance)
def get_platform_shape(platform_obj):
"""
Obtiene la Shape de un objeto Platform de forma segura.
"""
if platform_obj is None:
return None
try:
shape = platform_obj.Shape
if shape and not shape.isNull():
return shape
except Exception:
pass
return None return None