368 lines
14 KiB
Python
368 lines
14 KiB
Python
# /**********************************************************************
|
|
# * *
|
|
# * Copyright (c) 2021-2026 Javier Braña <javier.branagutierrez@gmail.com>*
|
|
# * *
|
|
# * PVPlant Road - Sistema de carreteras con alineamiento profesional *
|
|
# * Basado en ejes (Alignment) con estaciones, perfiles y cubicación. *
|
|
# * *
|
|
# ***********************************************************************
|
|
|
|
import FreeCAD
|
|
import ArchComponent
|
|
import Part
|
|
import math
|
|
import numpy as np
|
|
|
|
if FreeCAD.GuiUp:
|
|
import FreeCADGui
|
|
from PySide import QtCore
|
|
from DraftTools import translate
|
|
from PySide.QtCore import QT_TRANSLATE_NOOP
|
|
import os
|
|
else:
|
|
def translate(ctxt, txt): return txt
|
|
def QT_TRANSLATE_NOOP(ctxt, txt): return txt
|
|
|
|
import PVPlantResources
|
|
from PVPlantResources import DirIcons as DirIcons
|
|
from Civil.Alignment import make_alignment_from_wire
|
|
|
|
|
|
def makeRoad(base=None, alignment=None):
|
|
"""Crea un objeto Road con o sin alignment."""
|
|
doc = FreeCAD.ActiveDocument
|
|
obj = doc.addObject("Part::FeaturePython", "Road")
|
|
_Road(obj)
|
|
_ViewProviderRoad(obj.ViewObject)
|
|
obj.Base = base
|
|
obj.Alignment = alignment
|
|
doc.recompute()
|
|
return obj
|
|
|
|
|
|
class _Road(ArchComponent.Component):
|
|
"""Carretera con alineamiento horizontal+vertical y secciones multicapa."""
|
|
|
|
def __init__(self, obj):
|
|
ArchComponent.Component.__init__(self, obj)
|
|
self.obj = obj
|
|
self.setProperties(obj)
|
|
self.Type = "Road"
|
|
obj.Proxy = self
|
|
obj.IfcType = "Civil Element"
|
|
obj.setEditorMode("IfcType", 1)
|
|
|
|
def setProperties(self, obj):
|
|
pl = obj.PropertiesList
|
|
|
|
# --- Alineamiento ---
|
|
if "Alignment" not in pl:
|
|
obj.addProperty("App::PropertyLink",
|
|
"Alignment", "Road",
|
|
"Objeto Alignment que define el eje").Alignment = None
|
|
|
|
if "Base" not in pl:
|
|
obj.addProperty("App::PropertyLink",
|
|
"Base", "Road",
|
|
"Wire base (alternativo si no hay Alignment)").Base = None
|
|
|
|
# --- Geometría transversal ---
|
|
if "Width" not in pl:
|
|
obj.addProperty("App::PropertyLength",
|
|
"Width", "Road",
|
|
"Ancho total de la carretera").Width = 4000
|
|
|
|
if "PavementThickness" not in pl:
|
|
obj.addProperty("App::PropertyLength",
|
|
"PavementThickness", "Road",
|
|
"Espesor del pavimento").PavementThickness = 250
|
|
|
|
if "BaseThickness" not in pl:
|
|
obj.addProperty("App::PropertyLength",
|
|
"BaseThickness", "Road",
|
|
"Espesor de la base").BaseThickness = 200
|
|
|
|
if "SubbaseThickness" not in pl:
|
|
obj.addProperty("App::PropertyLength",
|
|
"SubbaseThickness", "Road",
|
|
"Espesor de la subbase").SubbaseThickness = 300
|
|
|
|
if "ShoulderWidth" not in pl:
|
|
obj.addProperty("App::PropertyLength",
|
|
"ShoulderWidth", "Road",
|
|
"Ancho del arcén cada lado").ShoulderWidth = 500
|
|
|
|
if "CrossSlope" not in pl:
|
|
obj.addProperty("App::PropertyPercent",
|
|
"CrossSlope", "Road",
|
|
"Pendiente transversal del pavimento (%)").CrossSlope = 2
|
|
|
|
if "DitchSlope" not in pl:
|
|
obj.addProperty("App::PropertyPercent",
|
|
"DitchSlope", "Road",
|
|
"Pendiente del drenaje (%)").DitchSlope = 3
|
|
|
|
# --- Estaciones y cubicación ---
|
|
if "StationInterval" not in pl:
|
|
obj.addProperty("App::PropertyLength",
|
|
"StationInterval", "Road",
|
|
"Intervalo entre estaciones de cálculo").StationInterval = 20000
|
|
|
|
if "NumberOfStations" not in pl:
|
|
obj.addProperty("App::PropertyInteger",
|
|
"NumberOfStations", "Road",
|
|
"Número de estaciones calculadas").NumberOfStations = 0
|
|
obj.setEditorMode("NumberOfStations", 1)
|
|
|
|
if "CutVolume" not in pl:
|
|
obj.addProperty("App::PropertyVolume",
|
|
"CutVolume", "Road",
|
|
"Volumen de desmonte (corte)").CutVolume = 0
|
|
obj.setEditorMode("CutVolume", 1)
|
|
|
|
if "FillVolume" not in pl:
|
|
obj.addProperty("App::PropertyVolume",
|
|
"FillVolume", "Road",
|
|
"Volumen de terraplén (relleno)").FillVolume = 0
|
|
obj.setEditorMode("FillVolume", 1)
|
|
|
|
if "TotalLength" not in pl:
|
|
obj.addProperty("App::PropertyLength",
|
|
"TotalLength", "Road",
|
|
"Longitud total del eje").TotalLength = 0
|
|
obj.setEditorMode("TotalLength", 1)
|
|
|
|
def onDocumentRestored(self, obj):
|
|
ArchComponent.Component.onDocumentRestored(self, obj)
|
|
self.obj = obj
|
|
self.Type = "Road"
|
|
obj.Proxy = self
|
|
|
|
def _get_alignment_wire(self, obj):
|
|
"""Devuelve el wire base (desde Alignment o Base)."""
|
|
if obj.Alignment and obj.Alignment.SourceWire:
|
|
return obj.Alignment.SourceWire.Shape
|
|
if obj.Base:
|
|
return obj.Base.Shape
|
|
return None
|
|
|
|
def _generate_cross_section(self, obj, station_point, tangent):
|
|
"""
|
|
Genera el perfil transversal en un punto del eje.
|
|
|
|
Returns:
|
|
list of Part.Wire: [pavimento, base, subbase, arcén_izq, arcén_der]
|
|
"""
|
|
# Ancho medio carril
|
|
hw = obj.Width.Value / 2
|
|
sw = obj.ShoulderWidth.Value
|
|
cs = obj.CrossSlope / 100 # pendiente transversal (decimal)
|
|
ds = obj.DitchSlope / 100
|
|
pt = obj.PavementThickness.Value
|
|
bt = obj.BaseThickness.Value
|
|
sbt = obj.SubbaseThickness.Value
|
|
|
|
# Vector perpendicular (horizontal) al eje
|
|
perp = FreeCAD.Vector(-tangent.y, tangent.x, 0)
|
|
perp.normalize()
|
|
|
|
# Puntos del pavimento (sección transversal con bombeo)
|
|
# Centro del eje
|
|
center = station_point
|
|
|
|
# Borde izquierdo pavimento
|
|
left_edge = center + perp * (-hw)
|
|
right_edge = center + perp * hw
|
|
|
|
# Con pendiente transversal: el centro más alto
|
|
left_top = FreeCAD.Vector(left_edge.x, left_edge.y, center.z - hw * cs)
|
|
right_top = FreeCAD.Vector(right_edge.x, right_edge.y, center.z - hw * cs)
|
|
center_top = center
|
|
|
|
# Borde inferior pavimento
|
|
left_bot = FreeCAD.Vector(left_top.x, left_top.y, left_top.z - pt)
|
|
right_bot = FreeCAD.Vector(right_top.x, right_top.y, right_top.z - pt)
|
|
center_bot = FreeCAD.Vector(center_top.x, center_top.y, center_top.z - pt)
|
|
|
|
# Arcén (más ancho, misma pendiente o ligeramente mayor)
|
|
shoulder_left = FreeCAD.Vector(left_edge.x - sw, left_edge.y - sw * 0, left_top.z - sw * cs * 0.5)
|
|
shoulder_right = FreeCAD.Vector(right_edge.x + sw, right_edge.y + sw * 0, right_top.z - sw * cs * 0.5)
|
|
|
|
# Base (ligeiramente más ancha)
|
|
base_extra = 200 # mm extra cada lado
|
|
bl = FreeCAD.Vector(left_bot.x - base_extra, left_bot.y, left_bot.z)
|
|
br = FreeCAD.Vector(right_bot.x + base_extra, right_bot.y, right_bot.z)
|
|
bc = FreeCAD.Vector(center_bot.x, center_bot.y, center_bot.z)
|
|
bl_bot = FreeCAD.Vector(bl.x, bl.y, bl.z - bt)
|
|
br_bot = FreeCAD.Vector(br.x, br.y, br.z - bt)
|
|
|
|
# Subbase (aún más ancha)
|
|
sbl = FreeCAD.Vector(bl.x - base_extra, bl.y, bl.z)
|
|
sbr = FreeCAD.Vector(br.x + base_extra, br.y, br.z)
|
|
sbl_bot = FreeCAD.Vector(sbl.x, sbl.y, sbl.z - sbt)
|
|
sbr_bot = FreeCAD.Vector(sbr.x, sbr.y, sbr.z - sbt)
|
|
|
|
# Construir wires de cada capa
|
|
# Pavimento
|
|
pave = Part.makePolygon([left_top, center_top, right_top, right_bot, center_bot, left_bot, left_top])
|
|
# Base
|
|
base = Part.makePolygon([bl, bc, br, br_bot, bc - FreeCAD.Vector(0, 0, bt), bl_bot, bl])
|
|
# Subbase
|
|
subbase = Part.makePolygon([sbl, sbl + FreeCAD.Vector(0, 0, -sbt),
|
|
sbr + FreeCAD.Vector(0, 0, -sbt), sbr,
|
|
sbl])
|
|
|
|
return [pave, base, subbase]
|
|
|
|
def execute(self, obj):
|
|
"""Genera el sólido 3D de la carretera por extrusión de secciones."""
|
|
wire = self._get_alignment_wire(obj)
|
|
if not wire:
|
|
return
|
|
|
|
total_len = wire.Length
|
|
obj.TotalLength = total_len
|
|
interval = obj.StationInterval.Value
|
|
if interval <= 0:
|
|
interval = 20000
|
|
|
|
n_stations = max(2, int(total_len / interval) + 1)
|
|
obj.NumberOfStations = n_stations
|
|
|
|
# Generar el sólido mediante barrido de secciones
|
|
shapes = []
|
|
cut_volume = 0
|
|
fill_volume = 0
|
|
|
|
for i in range(n_stations):
|
|
param = i / (n_stations - 1)
|
|
try:
|
|
pt = wire.valueAt(wire.getParameterByLength(param * total_len))
|
|
tangent = wire.tangentAt(wire.getParameterByLength(param * total_len))
|
|
except Exception:
|
|
continue
|
|
|
|
sections = self._generate_cross_section(obj, pt, tangent)
|
|
|
|
# Barrer cada sección a lo largo del eje (versión simplificada)
|
|
for sec in sections:
|
|
try:
|
|
# Extrusión simple a lo largo del eje
|
|
# En una implementación completa: makePipeShell
|
|
shape = sec.extrude(FreeCAD.Vector(0, 0, 1))
|
|
if shape and not shape.isNull():
|
|
shapes.append(shape)
|
|
except Exception:
|
|
pass
|
|
|
|
if shapes:
|
|
try:
|
|
compound = Part.makeCompound(shapes)
|
|
obj.Shape = compound
|
|
|
|
# Cubicación contra el terreno
|
|
terrain = self._get_terrain(obj)
|
|
if terrain:
|
|
try:
|
|
common = compound.common(terrain.Shape)
|
|
if common and not common.isNull():
|
|
cut_volume = common.Volume
|
|
# Terraplén: volumen del sólido fuera del terreno
|
|
fill = compound.cut(terrain.Shape)
|
|
if fill and not fill.isNull():
|
|
fill_volume = fill.Volume
|
|
except Exception:
|
|
pass
|
|
|
|
obj.CutVolume = cut_volume
|
|
obj.FillVolume = fill_volume
|
|
except Exception:
|
|
pass
|
|
|
|
def _get_terrain(self, obj):
|
|
"""Obtiene el terreno desde el Site."""
|
|
try:
|
|
return FreeCAD.ActiveDocument.Site.Terrain
|
|
except Exception:
|
|
return None
|
|
|
|
def __getstate__(self):
|
|
return self.Type
|
|
|
|
def __setstate__(self, state):
|
|
if state:
|
|
self.Type = state
|
|
|
|
|
|
class _ViewProviderRoad(ArchComponent.ViewProviderComponent):
|
|
def __init__(self, vobj):
|
|
ArchComponent.ViewProviderComponent.__init__(self, vobj)
|
|
|
|
def getIcon(self):
|
|
return str(os.path.join(PVPlantResources.DirIcons, "road.svg"))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TaskPanel para crear carretera interactivamente
|
|
# ---------------------------------------------------------------------------
|
|
class _RoadTaskPanel:
|
|
def __init__(self, obj=None):
|
|
if obj is None:
|
|
self.new = True
|
|
self.obj = makeRoad()
|
|
else:
|
|
self.new = False
|
|
self.obj = obj
|
|
|
|
self.form = FreeCADGui.PySideUic.loadUi(
|
|
os.path.join(PVPlantResources.__dir__, "PVPlantRoad.ui"))
|
|
|
|
def accept(self):
|
|
FreeCADGui.Control.closeDialog()
|
|
return True
|
|
|
|
def reject(self):
|
|
FreeCAD.ActiveDocument.removeObject(self.obj.Name)
|
|
if self.new:
|
|
FreeCADGui.Control.closeDialog()
|
|
return True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Comando para dibujar carretera sobre un wire seleccionado
|
|
# ---------------------------------------------------------------------------
|
|
class _CommandRoad:
|
|
"""Comando para crear carretera seleccionando un wire + generando alignment."""
|
|
|
|
def GetResources(self):
|
|
return {'Pixmap': str(os.path.join(DirIcons, "road.svg")),
|
|
'MenuText': QT_TRANSLATE_NOOP("PVPlantRoad", "Road"),
|
|
'Accel': "C, R",
|
|
'ToolTip': QT_TRANSLATE_NOOP("PVPlantRoad",
|
|
"Crea una carretera con alineamiento profesional.")}
|
|
|
|
def IsActive(self):
|
|
return FreeCAD.ActiveDocument is not None
|
|
|
|
def Activated(self):
|
|
sel = FreeCADGui.Selection.getSelection()
|
|
wire = None
|
|
if sel:
|
|
import Draft
|
|
if Draft.getType(sel[0]) == "Wire":
|
|
wire = sel[0]
|
|
|
|
if wire:
|
|
# Crear alignment desde el wire seleccionado
|
|
alignment = make_alignment_from_wire(wire)
|
|
road = makeRoad(alignment=alignment)
|
|
FreeCAD.Console.PrintMessage(
|
|
f"Carretera creada desde '{wire.Label}'. "
|
|
f"Alineamiento: {alignment.Label}\n")
|
|
else:
|
|
FreeCAD.Console.PrintWarning(
|
|
"Selecciona un Wire (polilínea) para usarlo como eje de carretera.\n")
|
|
|
|
|
|
if FreeCAD.GuiUp:
|
|
FreeCADGui.addCommand('PVPlantRoad', _CommandRoad()) |