361 lines
14 KiB
Python
361 lines
14 KiB
Python
# /**********************************************************************
|
|
# * *
|
|
# * Copyright (c) 2021 Javier Braña <javier.branagutierrez@gmail.com> *
|
|
# * *
|
|
# * This program is free software; you can redistribute it and/or modify*
|
|
# * it under the terms of the GNU Lesser General Public License (LGPL) *
|
|
# * as published by the Free Software Foundation; either version 2 of *
|
|
# * the License, or (at your option) any later version. *
|
|
# * for detail see the LICENCE text file. *
|
|
# * *
|
|
# * This program is distributed in the hope that it will be useful, *
|
|
# * but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
|
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
|
# * GNU Library General Public License for more details. *
|
|
# * *
|
|
# * You should have received a copy of the GNU Library General Public *
|
|
# * License along with this program; if not, write to the Free Software *
|
|
# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307*
|
|
# * USA *
|
|
# * *
|
|
# ***********************************************************************
|
|
|
|
import os
|
|
import platform
|
|
import subprocess
|
|
from typing import Dict, List
|
|
|
|
import FreeCAD
|
|
import openpyxl
|
|
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
|
|
from openpyxl.workbook import Workbook
|
|
from openpyxl.worksheet.worksheet import Worksheet
|
|
|
|
import PVPlantResources
|
|
import PVPlantSite
|
|
|
|
if FreeCAD.GuiUp:
|
|
from PySide.QtCore import QT_TRANSLATE_NOOP
|
|
|
|
|
|
__title__ = "PVPlant Mechanical BOQ"
|
|
__author__ = "Javier Braña"
|
|
__url__ = "http://www.sogos-solar.com"
|
|
|
|
# Constants
|
|
SCALE = 0.001 # Millimeters to meters
|
|
THIN_BORDER = Border(
|
|
top=Side(border_style="thin", color="7DA4B8"),
|
|
left=Side(border_style="thin", color="7DA4B8"),
|
|
right=Side(border_style="thin", color="7DA4B8"),
|
|
bottom=Side(border_style="thin", color="7DA4B8")
|
|
)
|
|
HEADER_FILL = PatternFill("solid", fgColor="7DA4B8")
|
|
HEADER_FONT = Font(name='Arial', size=10, bold=True, color="FFFFFF")
|
|
DATA_FONT = Font(name='Arial', size=10)
|
|
CENTER_ALIGN = Alignment(horizontal="center", vertical="center")
|
|
|
|
|
|
|
|
# Estilos:
|
|
thin = Side(border_style="thin", color="7DA4B8")
|
|
double = Side(border_style="double", color="ff0000")
|
|
border_thin = Border(top=thin, left=thin, right=thin, bottom=thin)
|
|
border_fat = Border(top=thin, left=thin, right=thin, bottom=thin)
|
|
# fill = PatternFill("solid", fgColor="DDDDDD")
|
|
# fill = GradientFill(stop=("000000", "FFFFFF"))
|
|
|
|
scale = 0.001 # milimeters to meter
|
|
|
|
|
|
def open_file(path: str) -> None:
|
|
"""Open a file or directory using the system's default handler"""
|
|
if platform.system() == "Windows":
|
|
os.startfile(path)
|
|
elif platform.system() == "Darwin":
|
|
subprocess.Popen(["open", path])
|
|
else:
|
|
subprocess.Popen(["xdg-open", path])
|
|
|
|
def style_range(ws, cell_range, border=Border(), fill=None, font=None, alignment=None):
|
|
"""
|
|
Apply styles to a range of cells as if they were a single cell.
|
|
|
|
:param ws: Excel worksheet instance
|
|
:param range: An excel range to style (e.g. A1:F20)
|
|
:param border: An openpyxl Border
|
|
:param fill: An openpyxl PatternFill or GradientFill
|
|
:param font: An openpyxl Font object
|
|
"""
|
|
|
|
top = Border(top=border.top)
|
|
left = Border(left=border.left)
|
|
right = Border(right=border.right)
|
|
bottom = Border(bottom=border.bottom)
|
|
|
|
first_cell = ws[cell_range.split(":")[0]]
|
|
if alignment:
|
|
first_cell.alignment = alignment
|
|
|
|
rows = ws[cell_range]
|
|
if font:
|
|
first_cell.font = font
|
|
|
|
for cell in rows[0]:
|
|
cell.border = cell.border + top
|
|
for cell in rows[-1]:
|
|
cell.border = cell.border + bottom
|
|
|
|
for row in rows:
|
|
l = row[0]
|
|
r = row[-1]
|
|
l.border = l.border + left
|
|
r.border = r.border + right
|
|
if fill:
|
|
for c in row:
|
|
c.fill = fill
|
|
|
|
|
|
def create_sheet_headers(sheet: Worksheet, headers: List[str], widths: Dict[str, float]) -> None:
|
|
"""Create standardized sheet headers."""
|
|
for col, header in enumerate(headers, start=1):
|
|
cell = sheet.cell(row=1, column=col, value=header)
|
|
cell.fill = HEADER_FILL
|
|
cell.font = HEADER_FONT
|
|
cell.alignment = CENTER_ALIGN
|
|
|
|
for col, width in widths.items():
|
|
sheet.column_dimensions[col].width = width
|
|
|
|
sheet.row_dimensions[1].height = 30
|
|
style_range(sheet, f'A1:{chr(64 + len(headers))}1',
|
|
border=THIN_BORDER,
|
|
fill=HEADER_FILL,
|
|
font=HEADER_FONT,
|
|
alignment=CENTER_ALIGN)
|
|
|
|
|
|
def spreadsheetBOQFrames(sheet: Worksheet, selection: List[FreeCAD.DocumentObject]) -> None:
|
|
"""Generate frames information sheet."""
|
|
headers = [
|
|
'Index', 'Frame', 'Frame Type', 'X (m)', 'Y (m)', 'Z (m)',
|
|
'Angle N-S', 'Angle L-W', 'Nº Poles'
|
|
]
|
|
widths = {'A': 8, 'B': 30, 'C': 20, 'D': 15, 'E': 15,
|
|
'F': 15, 'G': 15, 'H': 15, 'I': 10}
|
|
|
|
create_sheet_headers(sheet, headers, widths)
|
|
|
|
for idx, obj in enumerate(selection, start=2):
|
|
placement = obj.Placement
|
|
sheet[f'A{idx}'] = idx - 1
|
|
sheet[f'B{idx}'] = obj.Label
|
|
sheet[f'C{idx}'] = obj.Setup.Label
|
|
sheet[f'D{idx}'] = placement.Base.x * SCALE
|
|
sheet[f'E{idx}'] = placement.Base.y * SCALE
|
|
sheet[f'F{idx}'] = placement.Base.z * SCALE
|
|
sheet[f'G{idx}'] = placement.Rotation.toEuler()[0]
|
|
sheet[f'H{idx}'] = placement.Rotation.toEuler()[1]
|
|
sheet[f'I{idx}'] = obj.Setup.NumberPole.Value
|
|
|
|
style_range(sheet, f'A{idx}:I{idx}',
|
|
border=THIN_BORDER,
|
|
font=DATA_FONT,
|
|
alignment=CENTER_ALIGN)
|
|
|
|
def spreadsheetBOQPoles(sheet: Worksheet, selection: List[FreeCAD.DocumentObject]) -> None:
|
|
"""Generate poles information sheet."""
|
|
headers = [
|
|
'Frame', 'Pole', 'Pole Type', 'X (m)', 'Y (m)', 'Z Frame Attach (m)',
|
|
'Z Aerial Head (m)', 'Pole Length (m)', 'Aerial Length (m)',
|
|
'Terrain Enter Length (m)'
|
|
]
|
|
widths = {chr(65 + i): 20 for i in range(10)}
|
|
widths['A'] = 30
|
|
widths['B'] = 10
|
|
|
|
create_sheet_headers(sheet, headers, widths)
|
|
sheet.row_dimensions[2].height = 5
|
|
|
|
|
|
import MeshPart as mp
|
|
from Mechanical.Frame import PVPlantFrame
|
|
# Data:
|
|
terrain = PVPlantSite.get().Terrain.Mesh # Shape
|
|
poles_data = []
|
|
|
|
for frame in selection:
|
|
try:
|
|
poles = frame.Shape.SubShapes[1].SubShapes[0].SubShapes
|
|
num_poles = int(frame.Setup.NumberPole.Value)
|
|
sequence = frame.Setup.PoleSequence
|
|
|
|
if len(sequence) < num_poles:
|
|
sequence = PVPlantFrame.getarray(frame.Setup.PoleSequence, num_poles)
|
|
|
|
for pole_idx in range(num_poles):
|
|
pole = poles[pole_idx]
|
|
pole_type = frame.Setup.PoleType[sequence[pole_idx]]
|
|
center = pole.BoundBox.Center
|
|
|
|
poles_data.append({
|
|
'frame': frame.Label,
|
|
'number': pole_idx + 1,
|
|
'type': pole_type.Label,
|
|
'center': center,
|
|
'head_z': pole.BoundBox.ZMax,
|
|
'length': pole_type.Height.Value
|
|
})
|
|
except Exception as e:
|
|
FreeCAD.Console.PrintError(f"Error processing frame {frame.Label}: {str(e)}\n")
|
|
|
|
# Project points on terrain
|
|
try:
|
|
import MeshPart
|
|
points = [data['center'] for data in poles_data]
|
|
terrain_z = MeshPart.projectPointsOnMesh(points, terrain, FreeCAD.Vector(0, 0, 1))
|
|
for data, z in zip(poles_data, terrain_z):
|
|
data['terrain_z'] = z.z
|
|
except Exception as e:
|
|
FreeCAD.Console.PrintError(f"Terrain projection failed: {str(e)}\n")
|
|
for data in poles_data:
|
|
data['terrain_z'] = 0
|
|
|
|
# Write data to sheet
|
|
row = 3
|
|
group_from = row
|
|
f = poles_data[0]['frame']
|
|
for i, data in enumerate(poles_data):
|
|
if f != data["frame"]:
|
|
style_range(sheet, 'A' + str(group_from) + ':F' + str(row - 1),
|
|
border=Border(top=thin, left=thin, right=thin, bottom=thin),
|
|
font=Font(name='Quicksand', size=11, ),
|
|
alignment=Alignment(horizontal="center", vertical="center"))
|
|
sheet.merge_cells('A' + str(group_from) + ':A' + str(row - 1))
|
|
style_range(sheet, 'A' + str(group_from) + ':A' + str(row - 1),
|
|
border=Border(top=thin, left=thin, right=thin, bottom=thin),
|
|
font=Font(name='Quicksand', size=11, ),
|
|
alignment=Alignment(horizontal="center", vertical="center"))
|
|
# sheet['A{0}'.format(row)] = ""
|
|
sheet.row_dimensions[row].height = 5
|
|
row += 1
|
|
f = data["frame"]
|
|
group_from = row
|
|
|
|
sheet['A{0}'.format(row)] = data['frame']
|
|
sheet['B{0}'.format(row)] = data['number']
|
|
sheet['C{0}'.format(row)] = data['type']
|
|
sheet['D{0}'.format(row)] = round(data['center'].x, 0) * scale
|
|
sheet['D{0}'.format(row)].number_format = "0.000"
|
|
sheet['E{0}'.format(row)] = round(data['center'].y, 0) * scale
|
|
sheet['E{0}'.format(row)].number_format = "0.000"
|
|
try:
|
|
sheet['F{0}'.format(row)] = round(data['terrain_z'].z, 0) * scale
|
|
sheet['F{0}'.format(row)].number_format = "0.000"
|
|
except:
|
|
pass
|
|
|
|
sheet['G{0}'.format(row)] = round(data['head_z']) * scale
|
|
sheet['G{0}'.format(row)].number_format = "0.000"
|
|
sheet['H{0}'.format(row)] = data["length"] * scale
|
|
sheet['H{0}'.format(row)].number_format = "0.000"
|
|
sheet['I{0}'.format(row)] = '=G{0}-F{0}'.format(row)
|
|
sheet['I{0}'.format(row)].number_format = "0.000"
|
|
sheet['J{0}'.format(row)] = '=H{0}-I{0}'.format(row)
|
|
sheet['J{0}'.format(row)].number_format = "0.000"
|
|
|
|
style_range(sheet, 'A' + str(row) + ':J' + str(row),
|
|
border=Border(top=thin, left=thin, right=thin, bottom=thin),
|
|
font=Font(name='Quicksand', size=11, ),
|
|
alignment=Alignment(horizontal="center", vertical="center"))
|
|
row += 1
|
|
|
|
def spreadsheetBOQPanelCollision(sheet, sel):
|
|
# Headers:
|
|
sheet['A1'] = 'Frame'
|
|
sheet['B1'] = 'Nombre'
|
|
sheet['C1'] = 'X'
|
|
sheet['D1'] = 'Y'
|
|
sheet['E1'] = 'Z'
|
|
sheet['G1'] = 'Ángulo E-O'
|
|
sheet['H1'] = 'Nº Hincas'
|
|
|
|
sheet.column_dimensions['A'].width = 30
|
|
sheet.column_dimensions['B'].width = 8
|
|
sheet.column_dimensions['C'].width = 20
|
|
sheet.column_dimensions['D'].width = 20
|
|
sheet.column_dimensions['E'].width = 20
|
|
sheet.column_dimensions['F'].width = 20
|
|
sheet.column_dimensions['G'].width = 20
|
|
sheet.column_dimensions['H'].width = 20
|
|
sheet.column_dimensions['I'].width = 20
|
|
sheet.row_dimensions[1].height = 40
|
|
style_range(sheet, 'A1:I1',
|
|
border=Border(top=thin, left=thin, right=thin, bottom=thin),
|
|
fill=PatternFill("solid", fgColor="7DA4B8"),
|
|
font=Font(name='Quicksand', size=11, b=True, color="FFFFFF"),
|
|
alignment=Alignment(horizontal="center", vertical="center"))
|
|
sheet['A2'] = ""
|
|
sheet.row_dimensions[2].height = 5
|
|
|
|
# Data:
|
|
for frame_ind in range(0, len(sel)):
|
|
frame = sel[frame_ind]
|
|
sheet['A{0}'.format(ind + 2)] = ind
|
|
sheet['B{0}'.format(ind + 2)] = sel[ind].Label
|
|
sheet['C{0}'.format(ind + 2)] = sel[ind].Placement.Base.x * scale
|
|
sheet['D{0}'.format(ind + 2)] = sel[ind].Placement.Base.y * scale
|
|
sheet['E{0}'.format(ind + 2)] = sel[ind].Placement.Base.z * scale
|
|
sheet['F{0}'.format(ind + 2)] = sel[ind].Placement.Rotation.toEuler()[0]
|
|
sheet['G{0}'.format(ind + 2)] = sel[ind].Placement.Rotation.toEuler()[1]
|
|
sheet['H{0}'.format(ind + 2)] = sel[ind].NumberPole.Value
|
|
|
|
class CommandBOQMechanical:
|
|
def GetResources(self):
|
|
return {'Pixmap': str(os.path.join(PVPlantResources.DirIcons, "boqm.svg")),
|
|
'Accel': "R, M",
|
|
'MenuText': QT_TRANSLATE_NOOP("Placement", "BOQ Mecánico"),
|
|
'ToolTip': QT_TRANSLATE_NOOP("Placement", "Calcular el BOQ de la")}
|
|
|
|
def Activated(self):
|
|
doc = FreeCAD.ActiveDocument
|
|
|
|
if not doc or not doc.FileName:
|
|
FreeCAD.Console.PrintError("Document must be saved first\n")
|
|
return
|
|
|
|
try:
|
|
sel = [obj for obj in FreeCAD.ActiveDocument.Objects if (hasattr(obj, "Proxy") and (obj.Proxy.Type == "Tracker"))]
|
|
|
|
if not sel:
|
|
FreeCAD.Console.PrintWarning("No valid trackers found\n")
|
|
return
|
|
|
|
path = os.path.dirname(doc.FileName)
|
|
filename = os.path.join(path, "BOQ_Mechanical.xlsx")
|
|
|
|
sel = sorted(sel, key=lambda x: x.Label)
|
|
|
|
wb = openpyxl.Workbook()
|
|
wb.remove(wb.active) # Remove default sheet
|
|
|
|
frames_sheet = wb.create_sheet("Frames Information")
|
|
spreadsheetBOQFrames(frames_sheet, sel)
|
|
|
|
poles_sheet = wb.create_sheet("Poles information")
|
|
spreadsheetBOQPoles(poles_sheet , sel)
|
|
|
|
wb.save(filename)
|
|
FreeCAD.Console.PrintMessage(f"Report generated: {filename}\n")
|
|
open_file(path)
|
|
|
|
except Exception as e:
|
|
FreeCAD.Console.PrintError(f"Error generating BOQ: {str(e)}\n")
|
|
|
|
def IsActive(self):
|
|
if FreeCAD.ActiveDocument:
|
|
return True
|
|
else:
|
|
return False
|
|
|