Basic infrastructure for IfcCostSchedule PDF export with typst (#6860)

This commit is contained in:
carlopav
2025-07-03 15:18:03 +02:00
committed by GitHub
parent 3055dfa1a6
commit 5f5c0b41d5
9 changed files with 546 additions and 1 deletions
@@ -59,6 +59,7 @@ classes = (
operator.ExpandCostItemRate,
operator.ExpandCostItems,
operator.ExportCostSchedules,
operator.ExportCostSchedulesToPDF,
operator.HighlightProductCostItem,
operator.ImportCostScheduleCsv,
operator.RefreshCostScheduleCsv,
@@ -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"
+1
View File
@@ -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:
+7
View File
@@ -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],
+1
View File
@@ -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
+7
View File
@@ -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 = {}
+74 -1
View File
@@ -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")
@@ -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], [],[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)
}
}
+5
View File
@@ -19,6 +19,11 @@ dependencies = [
"ifcopenshell",
]
[project.optional-dependencies]
advanced = [
"typst",
]
[project.urls]
Homepage = "http://ifcopenshell.org"
Documentation = "https://docs.ifcopenshell.org"