From 5f5c0b41d516dc503cd5bb71b4df1b3354620bc5 Mon Sep 17 00:00:00 2001 From: carlopav <47068848+carlopav@users.noreply.github.com> Date: Thu, 3 Jul 2025 15:18:03 +0200 Subject: [PATCH] Basic infrastructure for IfcCostSchedule PDF export with typst (#6860) --- src/bonsai/bonsai/bim/module/cost/__init__.py | 1 + src/bonsai/bonsai/bim/module/cost/operator.py | 75 ++++ src/bonsai/bonsai/bim/module/cost/ui.py | 1 + src/bonsai/bonsai/core/cost.py | 7 + src/bonsai/bonsai/core/tool.py | 1 + src/bonsai/bonsai/tool/cost.py | 7 + src/ifc5d/ifc5d/ifc5Dspreadsheet.py | 75 +++- .../typst_template_ifc_cost_schedule.typ | 375 ++++++++++++++++++ src/ifc5d/pyproject.toml | 5 + 9 files changed, 546 insertions(+), 1 deletion(-) create mode 100644 src/ifc5d/ifc5d/typst_template_ifc_cost_schedule.typ diff --git a/src/bonsai/bonsai/bim/module/cost/__init__.py b/src/bonsai/bonsai/bim/module/cost/__init__.py index 5152cbdf04..e2fa66f24a 100644 --- a/src/bonsai/bonsai/bim/module/cost/__init__.py +++ b/src/bonsai/bonsai/bim/module/cost/__init__.py @@ -59,6 +59,7 @@ classes = ( operator.ExpandCostItemRate, operator.ExpandCostItems, operator.ExportCostSchedules, + operator.ExportCostSchedulesToPDF, operator.HighlightProductCostItem, operator.ImportCostScheduleCsv, operator.RefreshCostScheduleCsv, diff --git a/src/bonsai/bonsai/bim/module/cost/operator.py b/src/bonsai/bonsai/bim/module/cost/operator.py index 169b712246..cfba6ade07 100644 --- a/src/bonsai/bonsai/bim/module/cost/operator.py +++ b/src/bonsai/bonsai/bim/module/cost/operator.py @@ -824,6 +824,81 @@ class ExportCostSchedules(bpy.types.Operator, ExportHelper): self.layout.label(text="Select a directory.") +class ExportCostSchedulesToPDF(bpy.types.Operator, ExportHelper): + bl_idname = "bim.export_cost_schedules_to_pdf" + bl_label = "Export Cost Schedule to PDF" + bl_options = {"REGISTER", "UNDO"} + bl_description = "Print chosen cost schedule to pdf." + filename_ext = ".pdf" + filter_glob: bpy.props.StringProperty(default="*.pdf", options={"HIDDEN"}, maxlen=255) + + cost_schedules_items = [] + + def get_cost_schedules_enum_items(self, context): + return ExportCostSchedulesToPDF.cost_schedules_items + + cost_schedules_enum: bpy.props.EnumProperty( + name="", + description="Choose IfcCostSchedule to print", + items=get_cost_schedules_enum_items, + ) + + should_print_summary: bpy.props.BoolProperty( + name="Should print summary", + description="Print summary at the end of the document", + default=True, + ) + + def draw(self, context): + layout = self.layout + box = layout.box() + box.label(text="Select Ifc Cost Schedule:") + box.prop(self, "cost_schedules_enum", text="") + layout.separator() + box = layout.box() + box.label(text="Export properties:") + box.prop(self, "should_print_summary") + + @classmethod + def poll(cls, context): + try: + import typst + + return True + except: + cls.poll_message_set( + "Typst not available.\nIt can be installed from Quality and\nControl -> Debug and using 'typst' with Pip Install.\n(Run Blender as Administrator)" + ) + return False + + def invoke(self, context, event): + ExportCostSchedulesToPDF.cost_schedules_items.clear() + file = tool.Ifc.get() + schedules = file.by_type("IfcCostSchedule") + for schedule in schedules: + ExportCostSchedulesToPDF.cost_schedules_items.append( + ( + str(schedule.id()), + "{} ({})".format( + schedule.Name if schedule.Name is not None else "Unnamed", + schedule.PredefinedType if schedule.PredefinedType is not None else "UNTYPED", + ), + "", + ) + ) + return ExportHelper.invoke(self, context, event) + + def execute(self, context): + file = tool.Ifc.get() + self.props = tool.Cost.get_cost_props() + cost_schedule = file.by_id(int(self.cost_schedules_enum)) + options = {"should_print_summary": self.should_print_summary} + core.export_cost_schedules_to_pdf( + tool.Cost, filepath=self.filepath, cost_schedule=cost_schedule, options=options + ) + return {"FINISHED"} + + class ClearCostItemAssignments(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.clear_cost_item_assignments" bl_label = "Clear Cost Item Product Assignments" diff --git a/src/bonsai/bonsai/bim/module/cost/ui.py b/src/bonsai/bonsai/bim/module/cost/ui.py index f02ae3eabd..bea25aef8a 100644 --- a/src/bonsai/bonsai/bim/module/cost/ui.py +++ b/src/bonsai/bonsai/bim/module/cost/ui.py @@ -53,6 +53,7 @@ class BIM_PT_cost_schedules(Panel): if CostSchedulesData.data["total_cost_schedules"]: row.alignment = "RIGHT" row.operator("bim.export_cost_schedules", icon="EXPORT", text="Export All Schedules") + row.operator("bim.export_cost_schedules_to_pdf", icon="OUTPUT", text="") row = self.layout.row(align=True) row.label(text=f"{CostSchedulesData.data['total_cost_schedules']} Cost Schedules Found", icon="TEXT") else: diff --git a/src/bonsai/bonsai/core/cost.py b/src/bonsai/bonsai/core/cost.py index 62a72a0056..bbb9747380 100644 --- a/src/bonsai/bonsai/core/cost.py +++ b/src/bonsai/bonsai/core/cost.py @@ -397,6 +397,13 @@ def export_cost_schedules( return cost.export_cost_schedules(dirpath, format, cost_schedule) +def export_cost_schedules_to_pdf( + cost: type[tool.Cost], filepath: str, cost_schedule: ifcopenshell.entity_instance, options: dict +): + cost.play_sound() + return cost.export_cost_schedules_to_pdf(filepath, cost_schedule, options) + + def clear_cost_item_assignments( ifc: type[tool.Ifc], cost: type[tool.Cost], diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index 0edc1559ca..a58a7cd8ad 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -223,6 +223,7 @@ class Cost: def expand_cost_item(cls, cost_item_id): pass def expand_cost_items(cls): pass def export_cost_schedules(cls, filepath, format, cost_schedule): pass + def export_cost_schedules_to_pdf(cls, filepath, cost_schedule, options): pass def format_unit(cls, unit): pass def get_active_cost_item(cls): pass def get_active_cost_schedule(cls): pass diff --git a/src/bonsai/bonsai/tool/cost.py b/src/bonsai/bonsai/tool/cost.py index e1c5ab1db4..7708b72146 100644 --- a/src/bonsai/bonsai/tool/cost.py +++ b/src/bonsai/bonsai/tool/cost.py @@ -843,6 +843,13 @@ class Cost(bonsai.core.tool.Cost): except: return "Could not open file location" + @classmethod + def export_cost_schedules_to_pdf(cls, filepath: str, cost_schedule: ifcopenshell.entity_instance, options: dict): + from ifc5d.ifc5Dspreadsheet import Ifc5DPdfWriter + + writer = Ifc5DPdfWriter(file=tool.Ifc.get(), output=filepath, cost_schedule=cost_schedule, options=options) + writer.write() + @classmethod def get_units(cls) -> dict[int, str]: units = {} diff --git a/src/ifc5d/ifc5d/ifc5Dspreadsheet.py b/src/ifc5d/ifc5d/ifc5Dspreadsheet.py index 42580e66ed..4cc3b3aa34 100644 --- a/src/ifc5d/ifc5d/ifc5Dspreadsheet.py +++ b/src/ifc5d/ifc5d/ifc5Dspreadsheet.py @@ -142,6 +142,7 @@ class IfcDataGetter: "Id": cost_item.id(), "Identification": cost_item.Identification, "Name": cost_item.Name, + "Description": cost_item.Description, "Unit": unit, "Quantity": quantity_data["quantity"], "RateSubtotal": rate_subtotal, @@ -160,8 +161,12 @@ class IfcDataGetter: @staticmethod def get_schedule_cost_items_data(file: ifcopenshell.file, schedule: ifcopenshell.entity_instance) -> list[CostItem]: cost_items_data: list[CostItem] = [] + index = 1 for cost_item in IfcDataGetter.get_root_costs(schedule): - cost_items_data.extend(IfcDataGetter.get_cost_items_data(file, cost_item)) + cost_items_data.extend( + IfcDataGetter.get_cost_items_data(file=file, cost_item=cost_item, hierarchy=str(index)) + ) + index += 1 return cost_items_data @staticmethod @@ -300,6 +305,7 @@ class Ifc5Dwriter: "Index", "Identification", "Name", + "Description", "Unit", ] if cost_schedule.PredefinedType != "SCHEDULEOFRATES": @@ -517,6 +523,73 @@ class Ifc5DXlsxWriter(Ifc5Dwriter): row += 1 +class Ifc5DPdfWriter(Ifc5Dwriter): + def __init__( + self, + file: Union[str, ifcopenshell.file], + output: str, + options: dict, + cost_schedule: Optional[ifcopenshell.entity_instance] = None, + ): + """ + PDF Writer is based on typst library, be sure it is available. + :param file: IFC file to exprot cost schedules from. + :param output: Output file path including name and .pdf extension. + :param cost_schedule: exported cost schedule. If not provided, will export all available schedules. Output will be different accoding to Cost Schedule PredefinedType. + """ + self.output = output + if isinstance(file, str): + self.file = ifcopenshell.open(file) + else: + self.file = file + self.cost_schedule = cost_schedule + self.options = options + + def write(self) -> None: + import os + import ifc5d + import shutil + import typst + import tempfile + + with tempfile.TemporaryDirectory() as temp_dir: + project_name = self.file.by_type("IfcProject")[0].Name + schedule_name = getattr(self.cost_schedule, "Name", None) or "Unnamed" + schedule_type = getattr(self.cost_schedule, "PredefinedType", None) or "UNTYPED" + + # export csv file + csv_file_writer = Ifc5DCsvWriter(file=self.file, output=temp_dir, cost_schedule=self.cost_schedule) + csv_file_writer.write() + csv_file_name = schedule_name + ".csv" + + # locate typst template file + typst_template_file_path = os.path.join( + os.path.dirname(ifc5d.__file__), "typst_template_ifc_cost_schedule.typ" + ) + shutil.copy(typst_template_file_path, temp_dir) + + # generate typst main file content and write it + typst_main_content = "" + typst_main_content += '#import "{}": *\n'.format("typst_template_ifc_cost_schedule.typ") + typst_main_content += "#show: project.with(\n" + typst_main_content += 'schedule_path: "{}",\n'.format(csv_file_name) + typst_main_content += 'title: "{}",\n'.format(project_name) + typst_main_content += 'schedule_name: "{}",\n'.format(schedule_name) + typst_main_content += 'schedule_type: "{}",\n'.format(schedule_type) + typst_main_content += "cover_page: {},\n".format("false") + typst_main_content += "root_items_to_new_page: {},\n".format("false") + typst_main_content += "summary: {},\n".format(str(self.options.get("should_print_summary", False)).lower()) + typst_main_content += ")" + typst_main_path = os.path.join(temp_dir, "main.typ") + with open(typst_main_path, "w") as typ_file: + typ_file.write(typst_main_content) + + # compile pdf file and write it + pdf_bytes = typst.compile(typst_main_path) + with open(self.output, "wb") as f: + f.write(pdf_bytes) + + if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("input", type=str, help="Specify an IFC file to process") diff --git a/src/ifc5d/ifc5d/typst_template_ifc_cost_schedule.typ b/src/ifc5d/ifc5d/typst_template_ifc_cost_schedule.typ new file mode 100644 index 0000000000..6bd35a1b1f --- /dev/null +++ b/src/ifc5d/ifc5d/typst_template_ifc_cost_schedule.typ @@ -0,0 +1,375 @@ +// PRICED BILL OF QUANTITIES TEMPLATE +// author: carlo pavan +// year: 2025 + + +// custom cell styles +#let total-cell-style = (stroke: (top: 0.25pt + gray)) + + + +#let root-cost-cell-style = ( + stroke: (bottom: (thickness: 0.4pt, dash: "dotted")), + fill: gray.transparentize(90%), + align: bottom +) + + + +#let template_fonts = ("Liberation Sans", "Roboto", "Arial", "Calibri") + + + +#let euro(num) = { + str(calc.round(float(num), digits: 2)) + " €" +} + + + +#let bill_of_quantities_table = table( + columns: (18mm,54mm, 12mm,12mm,12mm,12mm, 20mm, 20mm, 25mm), + rows: (6mm, 248mm), + align: (center, left, center, center, center, center, center, center, center), + stroke: (x, y) => ( + left: if x == 0 { 1pt } else { 0.25pt }, + right: 1pt, + top: 1pt, + bottom: 1pt + ), + [Hierarchy], [Description], [n°],[l],[w],[h/w], [Quantity], [Rate], [Total] + ) + + + +#let schedule_of_rates_table = table( + columns: (30mm,130mm, 25mm), + rows: (6mm, 248mm), + align: (center, left, center), + stroke: (x, y) => ( + left: if x == 0 { 1pt } else { 0.25pt }, + right: 1pt, + top: 1pt, + bottom: 1pt + ), + [Identification], [Description], [Rate] + ) + + + +#let summary_table = table( + columns: (18mm,107mm, 30mm, 30mm), + rows: (6mm, 248mm), + align: (center, left, center, center, center, center, center, center, center), + stroke: (x, y) => ( + left: if x == 0 { 1pt } else { 0.25pt }, + right: 1pt, + top: 1pt, + bottom: 1pt + ), + text(size: 8pt)[Hierarchy], + text(size: 8pt)[Description], + text(size: 8pt)[Sub Total], + text(size: 8pt)[Total] +) + + + + +#let format_table = ( + "SCHEDULEOFRATES": schedule_of_rates_table, + "PRICEDBILLOFQUANTITIES" : bill_of_quantities_table, + "UNPRICEDBILLOFQUANTITIES" : bill_of_quantities_table, + "SUMMARY" : summary_table +) + + + +#let unit_map = ( + "METRE": "m", + "SQUARE_METRE": "m²", + "m2": "m²", + "CUBIC_METRE": "m³", + "m3": "m³", + "VOLUMEUNIT / CUBIC_METRE": "m³", + "KILOGRAM": "kg", + // add more mappings as needed +) + + + +#let format-decimal(num, places: 2) = { + let rounded = calc.round(num, digits: places) + let str-num = str(rounded) + + // Split into integer and decimal parts + let parts = str-num.split(".") + let integer-part = parts.at(0) + let decimal-part = parts.at(1, default: "") + + // Add thousand separators to integer part + let formatted-integer = "" + let chars = integer-part.clusters().rev() + for (i, char) in chars.enumerate() { + if i > 0 and calc.rem(i, 3) == 0 { + formatted-integer = "'" + formatted-integer + } + formatted-integer = char + formatted-integer + } + + // Ensure decimal part has correct number of places + decimal-part = decimal-part + "0" * (places - decimal-part.len()) + + formatted-integer + "." + decimal-part +} + + + +#let arrange_summary_row(row) = { + let name = strong(upper(row.at("Name"))) + let description = [#par(justify: true, text(8pt, row.at("Description", default: "")))] + let total = if row.at("RateSubtotal") == "" {0.0} else {float(row.at("RateSubtotal"))} + if row.at("TotalPrice") != "0.0" { + if row.at("Index") == "1" { + // ROOT COST + ( + row.at("Hierarchy"), + name, + [], + strong[#format-decimal(float(row.at("TotalPrice")), places: 2)] + ) + } else { + // SUB CATEGORY + ( + row.at("Hierarchy"), + table.cell(inset: (left: int(row.at("Index"))*2.5mm))[#upper(row.at("Name"))], + format-decimal(float(row.at("TotalPrice")), places: 2), + [], + ) + } + } +} + + + +#let arrange_bill_of_quantity_row(row) = { + if row.at("TotalPrice") != "0.0" { + // CATEGORY + let name = strong(upper(row.at("Name"))) + let description = [#par(justify: true, text(8pt, row.at("Description", default: "")))] + let total_price = format-decimal(float(row.at("TotalPrice", default: "0.0")), places: 2) + + ( + [], [], [], [], [], [], [], [], [], + ) + ( + table.cell(..root-cost-cell-style)[#row.at("Hierarchy")], + table.cell(..root-cost-cell-style)[#strong(upper(row.at("Name"))) #linebreak() #row.at("Description", default:"")], + table.cell(..root-cost-cell-style)[], + table.cell(..root-cost-cell-style)[], + table.cell(..root-cost-cell-style)[], + table.cell(..root-cost-cell-style)[], + table.cell(..root-cost-cell-style)[], + table.cell(..root-cost-cell-style)[], + table.cell(..root-cost-cell-style)[#strong(total_price)], + ) + + } else { + // COST ITEM + let name = strong(upper(row.at("Name"))) + let description = [#par(justify: true, text(8pt, row.at("Description", default: "")))] + let unit = table.cell(align: right)[Sum #unit_map.at(row.at("Unit"), default: "")] + let quant = if row.at("Quantity") == "" {0.0} else { + format-decimal(float(row.at("Quantity")))} + let rate = if row.at("RateSubtotal") == "" {0.0} else { + format-decimal(float(row.at("RateSubtotal")))} + let total = if row.at("Quantity") == "" {0.0} else { + format-decimal(float(row.at("Quantity")) * float(row.at("RateSubtotal")), places: 2)} + + ( + row.at("Hierarchy"), + if row.at("Identification") == "" {name + linebreak() + description} else {name + linebreak() + row.at("Identification") + linebreak() + description}, + [], + [], + [], + [], + [], + [], + [], + ) + ( + [], + unit, + [], + [], + [], + [], + table.cell(..total-cell-style, align: right + bottom)[#quant], + table.cell(..total-cell-style, align: right + bottom)[#rate], + table.cell(..total-cell-style, align: right + bottom)[#total], + ) + + } +} + + + + +#let arrange_schedule_of_rates_row(row) = { + let name = strong(upper(row.at("Name"))) + let description = [#par(justify: true, text(8pt, row.at("Description", default: "")))] + let unit = table.cell(align: right)[#unit_map.at(row.at("Unit"), default: "")] + let rate = if row.at("RateSubtotal") == "" {0.0} else { + format-decimal(float(row.at("RateSubtotal")))} + ( + row.at("Identification"), + if row.at("Identification") == "" {name + linebreak() + description} else {name + linebreak() + description}, + [] + ) + ( + [], + table.cell(align: right+bottom)[#unit], + table.cell(align: right+bottom)[#rate], + ) + ( + [],[],[], + ) +} + + + +#let create-schedule( + path, + delimiter: ",", + type: "PRICEDBILLOFQUANTITIES", + should_hide_rates: false + ) = { + if type == "PRICEDBILLOFQUANTITIES" or type == "UNPRICEDBILLOFQUANTITIES"{ + let data = csv(path, delimiter: delimiter, row-type: dictionary) + let new_rows = data.map(arrange_bill_of_quantity_row) + + table( + columns: (18mm,1fr, 12mm,12mm,12mm,12mm, 20mm, 20mm, 25mm), + align: (center, left, center, center, center, center, right, right, right), + stroke: none, + ..new_rows.flatten() + ) + } else if type == "SCHEDULEOFRATES" { + // REMEMBER TO CHECK IF THE ROW IS UNIQUE + let data = csv(path, delimiter: delimiter, row-type: dictionary) + let new_rows = data.map(arrange_schedule_of_rates_row) + + table( + columns: (30mm,130mm, 25mm), + align: (center, left, right), + stroke: none, + ..new_rows.flatten() + ) + } +} + + + +#let create-summary( + path, + delimiter: "," + ) = { + let data = csv(path, delimiter: delimiter, row-type: dictionary) + let new_rows = data.map(arrange_summary_row) + let general_total = data.filter(row => row.at("Index") == "1") + .map(row => float(row.at("TotalPrice"))) + .sum(default: 0.00) + + set text(size: 10pt) + pad(left: 2cm)[SUMMARY:] + + set text(size: 8pt) + table( + columns: (18mm,107mm, 30mm, 30mm), + align: (center, left, right, right), + stroke: (x, y) => ( + left: none, + right: none, + top: (thickness: 0.4pt, dash: "dotted"), + bottom: (thickness: 0.4pt, dash: "dotted") + ), + ..new_rows.flatten() + ) + + set text(size: 10pt) + grid( + columns: (18mm,107mm, 30mm, 30mm), + align: (center, right, center, right), + inset: 1mm, + fill: gray.transparentize(70%), + [], strong[GENERAL TOTAL:], [],[#strong(format-decimal(general_total, places: 2))] +) +} + + + +#let project( + schedule_path: "", + title: "", + schedule_name: "", + schedule_type: "", + cover_page: bool, + root_items_to_new_page: bool, + should_hide_rates: false, + summary: bool, + body) = { + // Set the document's basic properties. + //set document(schedule: schedule_name, title: title) + + set page( + margin: (left: 15mm, right: 10mm, top: 35mm, bottom: 20mm), + numbering: "1/1", + number-align: end, + header:[ + #set text(font: template_fonts, size: 9pt, lang: "en"); + #table( + columns: (1fr, 2fr), + rows: 10mm, + stroke: none, + inset: 0mm, + align:(top+left, top+right), + [#title], [#schedule_name] + ) + ], + footer: context [ + #grid( + columns: (1fr, 1fr), + align: (left, right), + [#datetime.today().display("[day]/[month]/[year]")], + [#counter(page).display("1/1", both: true)] + ) + ], + background: + place( top + left, dx: 15mm, dy: 25mm, + format_table.at(schedule_type, default: bill_of_quantities_table) + ) + ) + + set text(font: template_fonts, size: 8pt, lang: "en"); + + if schedule_type == "PRICEDBILLOFQUANTITIES" { + create-schedule(schedule_path, type: "PRICEDBILLOFQUANTITIES", should_hide_rates: should_hide_rates) + } else if schedule_type == "UNPRICEDBILLOFQUANTITIES" { + create-schedule(schedule_path, type: "PRICEDBILLOFQUANTITIES", should_hide_rates: true) + } else if schedule_type == "SCHEDULEOFRATES" { + create-schedule(schedule_path, type: "SCHEDULEOFRATES", should_hide_rates: true) + } else { + create-schedule(schedule_path, type: "PRICEDBILLOFQUANTITIES", should_hide_rates: should_hide_rates) + } + + if summary == true { + pagebreak() + set text(font: template_fonts, size: 8pt, lang: "en"); + set page( + background: + place( top + left, dx: 15mm, dy: 25mm, + format_table.at("SUMMARY") + ) + ) + create-summary(schedule_path) + } +} \ No newline at end of file diff --git a/src/ifc5d/pyproject.toml b/src/ifc5d/pyproject.toml index 93ee167207..4d89b8a108 100644 --- a/src/ifc5d/pyproject.toml +++ b/src/ifc5d/pyproject.toml @@ -19,6 +19,11 @@ dependencies = [ "ifcopenshell", ] + [project.optional-dependencies] + advanced = [ + "typst", + ] + [project.urls] Homepage = "http://ifcopenshell.org" Documentation = "https://docs.ifcopenshell.org"