# /********************************************************************** # * * # * Copyright (c) 2021 Javier Braña * # * * # * 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 print(poles_data[0]) 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