diff --git a/src/bonsai/bonsai/bim/data/templates/titleblocks/A1.svg b/src/bonsai/bonsai/bim/data/templates/titleblocks/A1.svg
index 4c1f7dad4dc..a6abcbc656c 100644
--- a/src/bonsai/bonsai/bim/data/templates/titleblocks/A1.svg
+++ b/src/bonsai/bonsai/bim/data/templates/titleblocks/A1.svg
@@ -323,5 +323,14 @@
x="160.29811"
y="250.54721"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:4.24897px;line-height:100%;font-family:'OpenGost Type B TT';-inkscape-font-specification:'OpenGost Type B TT';text-align:center;text-anchor:middle;stroke-width:0.25;stroke-miterlimit:4;stroke-dasharray:none">DATE
+
+ {{#revisions}}
+ {{rev}}
+ {{description}}
+ {{author}}
+ {{issued}}
+ {{date}}
+ {{/revisions}}
+
diff --git a/src/bonsai/bonsai/bim/data/templates/titleblocks/A2.svg b/src/bonsai/bonsai/bim/data/templates/titleblocks/A2.svg
index a86b7619b72..9baa02b36bf 100644
--- a/src/bonsai/bonsai/bim/data/templates/titleblocks/A2.svg
+++ b/src/bonsai/bonsai/bim/data/templates/titleblocks/A2.svg
@@ -318,5 +318,14 @@
x="160.29811"
y="250.54721"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:4.24897px;line-height:100%;font-family:'OpenGost Type B TT';-inkscape-font-specification:'OpenGost Type B TT';text-align:center;text-anchor:middle;stroke-width:0.25;stroke-miterlimit:4;stroke-dasharray:none">DATE
+
+ {{#revisions}}
+ {{rev}}
+ {{description}}
+ {{author}}
+ {{issued}}
+ {{date}}
+ {{/revisions}}
+
diff --git a/src/bonsai/bonsai/bim/data/templates/titleblocks/A3.svg b/src/bonsai/bonsai/bim/data/templates/titleblocks/A3.svg
index cea1d2af23d..cc636c7ea32 100644
--- a/src/bonsai/bonsai/bim/data/templates/titleblocks/A3.svg
+++ b/src/bonsai/bonsai/bim/data/templates/titleblocks/A3.svg
@@ -195,6 +195,15 @@
x="160.29811"
y="250.54721"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:4.24897px;line-height:100%;font-family:'OpenGost Type B TT';-inkscape-font-specification:'OpenGost Type B TT';text-align:center;text-anchor:middle;stroke-width:0.25;stroke-miterlimit:4;stroke-dasharray:none">DATE
+
+ {{#revisions}}
+ {{rev}}
+ {{description}}
+ {{author}}
+ {{issued}}
+ {{date}}
+ {{/revisions}}
+
dict[str, str]:
return {"SHEET": sheet_path}
+ def get_titleblock_data(self, sheet: ifcopenshell.entity_instance) -> dict:
+ data = sheet.get_info()
+ revisions = self._get_git_revisions()
+ data["revisions"] = revisions
+ data["has_revisions"] = bool(revisions)
+ return data
+
+ def _get_git_revisions(self) -> list[dict]:
+ try:
+ import git
+ except ImportError:
+ return []
+
+ ifc_path = tool.Ifc.get_path()
+ if not ifc_path:
+ return []
+ try:
+ repo = git.Repo(ifc_path, search_parent_directories=True)
+ except Exception:
+ return []
+
+ # Oldest-first so the SVG template can anchor at the bottom: oldest
+ # tag sits at y=0 (the anchor point) and newer tags stack upward.
+ # Always sort and date by the tagged commit, not when the tag was applied.
+ tags = sorted(repo.tags, key=lambda t: t.commit.committed_date)
+
+ def initials(actor) -> str:
+ if not actor or not actor.name:
+ return ""
+ return "".join(w[0].upper() for w in actor.name.split() if w)
+
+ rows = []
+ for i, tag_ref in enumerate(tags):
+ date = datetime.fromtimestamp(tag_ref.commit.committed_date).date().isoformat()
+ if tag_ref.tag:
+ description = (tag_ref.tag.message or "").strip().splitlines()[0]
+ author = initials(tag_ref.tag.tagger)
+ else:
+ description = ""
+ author = initials(tag_ref.commit.author)
+ # y is a negative offset from the group anchor (5 mm row height to
+ # match the A3 titleblock grid). Oldest tag sits at y=0 (the anchor);
+ # newer tags stack upward so the oldest stays at a fixed position.
+ rows.append(
+ {
+ "rev": tag_ref.name,
+ "date": date,
+ "description": description,
+ "author": author,
+ "issued": "",
+ "y": -i * 5,
+ }
+ )
+ return rows
+
def build_titleblock(self, root: ET.Element, sheet: ifcopenshell.entity_instance) -> None:
titleblock = root.findall(f'{SVG}g[@data-type="titleblock"]')[0]
image = titleblock.findall(f"{SVG}image")[0]
- g = self.parse_embedded_svg(image, sheet.get_info())
+ g = self.parse_embedded_svg(image, self.get_titleblock_data(sheet))
grid_north = ifcopenshell.util.geolocation.get_grid_north(tool.Ifc.get()) * -1
true_north = ifcopenshell.util.geolocation.get_true_north(tool.Ifc.get()) * -1
for north in g.iterfind(f'.//{SVG}g[@data-type="grid-north"]'):
diff --git a/src/bonsai/test/tool/test_sheeter.py b/src/bonsai/test/tool/test_sheeter.py
new file mode 100644
index 00000000000..e152f2ef965
--- /dev/null
+++ b/src/bonsai/test/tool/test_sheeter.py
@@ -0,0 +1,325 @@
+# Bonsai - OpenBIM Blender Add-on
+# Copyright (C) 2025 Bruno Postle
+#
+# This file is part of Bonsai.
+#
+# Bonsai is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Bonsai 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Bonsai. If not, see .
+
+import os
+import re
+import time
+import xml.etree.ElementTree as ET
+
+import ifcopenshell
+import pytest
+
+try:
+ import git
+ import git.exc
+
+ HAS_GIT = True
+except ImportError:
+ HAS_GIT = False
+
+requires_git = pytest.mark.skipif(not HAS_GIT, reason="GitPython not available")
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def _make_repo(tmpdir: str) -> "git.Repo":
+ repo = git.Repo.init(tmpdir)
+ with repo.config_writer() as cfg:
+ cfg.set_value("user", "name", "Test User")
+ cfg.set_value("user", "email", "test@example.com")
+ return repo
+
+
+def _commit(repo: "git.Repo", tmpdir: str, content: str = "data") -> "git.Commit":
+ path = os.path.join(tmpdir, "model.ifc")
+ with open(path, "w") as f:
+ f.write(content)
+ repo.index.add([os.path.normpath(path)])
+ return repo.index.commit(content)
+
+
+@pytest.fixture
+def builder():
+ """Return a SheetBuilder with bonsai.bim loaded lazily."""
+ from bonsai.bim.module.drawing import sheeter
+
+ return sheeter.SheetBuilder()
+
+
+@pytest.fixture
+def builder_at(monkeypatch):
+ """Factory: builder_at(ifc_path) patches tool.Ifc.get_path and returns a SheetBuilder."""
+ import bonsai.tool as tool
+ from bonsai.bim.module.drawing import sheeter
+
+ def factory(ifc_path):
+ monkeypatch.setattr(tool.Ifc, "get_path", classmethod(lambda cls: ifc_path))
+ return sheeter.SheetBuilder()
+
+ return factory
+
+
+# ---------------------------------------------------------------------------
+# Unit conversions (pure, no add-on registration required)
+# ---------------------------------------------------------------------------
+
+
+class TestConvertToMm:
+ def test_mm(self, builder):
+ assert builder.convert_to_mm("297mm") == pytest.approx(297.0)
+
+ def test_cm(self, builder):
+ assert builder.convert_to_mm("29.7cm") == pytest.approx(297.0)
+
+ def test_in(self, builder):
+ assert builder.convert_to_mm("1in") == pytest.approx(25.4)
+
+ def test_px(self, builder):
+ # 96 dpi: 96 px = 25.4 mm
+ assert builder.convert_to_mm("96px") == pytest.approx(25.4, rel=1e-3)
+
+ def test_pt(self, builder):
+ # 72 pt = 25.4 mm
+ assert builder.convert_to_mm("72pt") == pytest.approx(25.4, rel=1e-3)
+
+
+class TestMmToPx:
+ def test_round_trip(self, builder):
+ assert builder.mm_to_px(builder.convert_to_mm("96px")) == pytest.approx(96.0, rel=1e-3)
+
+ def test_zero(self, builder):
+ assert builder.mm_to_px(0.0) == pytest.approx(0.0)
+
+
+# ---------------------------------------------------------------------------
+# parse_embedded_svg — Pystache template rendering
+# ---------------------------------------------------------------------------
+
+
+class TestParseEmbeddedSvg:
+ def _image_element(self, filename="titleblock.svg"):
+ image = ET.Element("image")
+ image.attrib["x"] = "0mm"
+ image.attrib["y"] = "0mm"
+ image.attrib["width"] = "420mm"
+ image.attrib["height"] = "297mm"
+ image.attrib["{http://www.w3.org/1999/xlink}href"] = filename
+ return image
+
+ def _make_builder(self, tmp_path):
+ from bonsai.bim.module.drawing import sheeter
+
+ b = sheeter.SheetBuilder()
+ b.layout_dir = str(tmp_path)
+ b.sheets_dir = str(tmp_path)
+ b.defs = ET.Element("defs")
+ return b
+
+ def test_variable_substitution(self, tmp_path):
+ svg = ''
+ (tmp_path / "titleblock.svg").write_text(svg)
+
+ group = self._make_builder(tmp_path).parse_embedded_svg(self._image_element(), {"Name": "My Sheet"})
+ texts = group.findall(".//{http://www.w3.org/2000/svg}text")
+ assert any(t.text == "My Sheet" for t in texts)
+
+ def test_revision_rows_rendered(self, tmp_path):
+ svg = (
+ '"
+ )
+ (tmp_path / "titleblock.svg").write_text(svg)
+
+ revisions = [
+ {"rev": "v1.0", "date": "2024-01-01", "description": "First", "author": "TU", "issued": "", "y": 0},
+ {"rev": "v2.0", "date": "2025-01-01", "description": "Second", "author": "TU", "issued": "", "y": -5},
+ ]
+ group = self._make_builder(tmp_path).parse_embedded_svg(self._image_element(), {"revisions": revisions})
+ labels = [t.text for t in group.findall(".//{http://www.w3.org/2000/svg}text")]
+ assert "v1.0" in labels
+ assert "v2.0" in labels
+
+ def test_has_revisions_section_hidden_when_false(self, tmp_path):
+ svg = (
+ '"
+ )
+ (tmp_path / "titleblock.svg").write_text(svg)
+
+ group = self._make_builder(tmp_path).parse_embedded_svg(
+ self._image_element(), {"has_revisions": False, "revisions": []}
+ )
+ texts = group.findall(".//{http://www.w3.org/2000/svg}text")
+ assert not any(t.text == "HEADERS" for t in texts)
+
+ def test_has_revisions_section_shown_when_true(self, tmp_path):
+ svg = (
+ '"
+ )
+ (tmp_path / "titleblock.svg").write_text(svg)
+
+ group = self._make_builder(tmp_path).parse_embedded_svg(
+ self._image_element(), {"has_revisions": True, "revisions": []}
+ )
+ texts = group.findall(".//{http://www.w3.org/2000/svg}text")
+ assert any(t.text == "HEADERS" for t in texts)
+
+
+# ---------------------------------------------------------------------------
+# _get_git_revisions
+# ---------------------------------------------------------------------------
+
+
+class TestGetGitRevisions:
+ def test_no_ifc_path_returns_empty(self, builder_at):
+ assert builder_at(None)._get_git_revisions() == []
+
+ @requires_git
+ def test_not_a_git_repo_returns_empty(self, builder_at, tmp_path):
+ assert builder_at(str(tmp_path / "model.ifc"))._get_git_revisions() == []
+
+ @requires_git
+ def test_no_tags_returns_empty(self, builder_at, tmp_path):
+ repo = _make_repo(str(tmp_path))
+ _commit(repo, str(tmp_path))
+ assert builder_at(str(tmp_path / "model.ifc"))._get_git_revisions() == []
+
+ @requires_git
+ def test_lightweight_tag_fields(self, builder_at, tmp_path):
+ repo = _make_repo(str(tmp_path))
+ _commit(repo, str(tmp_path))
+ repo.create_tag("v1.0")
+
+ rows = builder_at(str(tmp_path / "model.ifc"))._get_git_revisions()
+
+ assert len(rows) == 1
+ assert rows[0]["rev"] == "v1.0"
+ assert rows[0]["description"] == ""
+ assert rows[0]["author"] == "TU" # initials of "Test User"
+ assert rows[0]["issued"] == ""
+ assert rows[0]["y"] == 0
+
+ @requires_git
+ def test_annotated_tag_uses_message_first_line(self, builder_at, tmp_path):
+ repo = _make_repo(str(tmp_path))
+ _commit(repo, str(tmp_path))
+ repo.create_tag("v1.0", message="First release\nExtra detail ignored")
+
+ rows = builder_at(str(tmp_path / "model.ifc"))._get_git_revisions()
+
+ assert rows[0]["description"] == "First release"
+
+ @requires_git
+ def test_annotated_tag_author_is_tagger_initials(self, builder_at, tmp_path):
+ repo = _make_repo(str(tmp_path))
+ _commit(repo, str(tmp_path))
+ repo.create_tag("v1.0", message="Release")
+
+ rows = builder_at(str(tmp_path / "model.ifc"))._get_git_revisions()
+
+ assert rows[0]["author"] == "TU"
+
+ @requires_git
+ def test_date_is_iso_format(self, builder_at, tmp_path):
+ repo = _make_repo(str(tmp_path))
+ _commit(repo, str(tmp_path))
+ repo.create_tag("v1.0", message="Release")
+
+ rows = builder_at(str(tmp_path / "model.ifc"))._get_git_revisions()
+
+ assert re.match(r"\d{4}-\d{2}-\d{2}$", rows[0]["date"])
+
+ @requires_git
+ def test_multiple_tags_sorted_oldest_first(self, builder_at, tmp_path):
+ repo = _make_repo(str(tmp_path))
+ _commit(repo, str(tmp_path), "first")
+ repo.create_tag("v1.0", message="First")
+ time.sleep(1.1) # ensure distinct second-level timestamps
+ _commit(repo, str(tmp_path), "second")
+ repo.create_tag("v2.0", message="Second")
+
+ rows = builder_at(str(tmp_path / "model.ifc"))._get_git_revisions()
+
+ assert len(rows) == 2
+ assert rows[0]["rev"] == "v1.0" # oldest at index 0 (bottom of table)
+ assert rows[1]["rev"] == "v2.0" # newest at index 1 (stacks upward)
+
+ @requires_git
+ def test_y_offsets_are_negative_multiples_of_5(self, builder_at, tmp_path):
+ repo = _make_repo(str(tmp_path))
+ for i in range(3):
+ _commit(repo, str(tmp_path), f"rev{i}")
+ repo.create_tag(f"v{i}.0", message=f"Release {i}")
+ time.sleep(1.1)
+
+ rows = builder_at(str(tmp_path / "model.ifc"))._get_git_revisions()
+
+ assert rows[0]["y"] == 0
+ assert rows[1]["y"] == -5
+ assert rows[2]["y"] == -10
+
+
+# ---------------------------------------------------------------------------
+# get_titleblock_data
+# ---------------------------------------------------------------------------
+
+
+class TestGetTitleblockData:
+ @requires_git
+ def test_has_revisions_false_when_no_tags(self, builder_at, tmp_path):
+ repo = _make_repo(str(tmp_path))
+ _commit(repo, str(tmp_path))
+
+ ifc = ifcopenshell.file()
+ sheet = ifc.createIfcDocumentInformation(Identification="DR-01", Name="Site Plan")
+ data = builder_at(str(tmp_path / "model.ifc")).get_titleblock_data(sheet)
+
+ assert data["has_revisions"] is False
+ assert data["revisions"] == []
+
+ @requires_git
+ def test_has_revisions_true_when_tags_present(self, builder_at, tmp_path):
+ repo = _make_repo(str(tmp_path))
+ _commit(repo, str(tmp_path))
+ repo.create_tag("v1.0", message="Issued for review")
+
+ ifc = ifcopenshell.file()
+ sheet = ifc.createIfcDocumentInformation(Identification="DR-01", Name="Site Plan")
+ data = builder_at(str(tmp_path / "model.ifc")).get_titleblock_data(sheet)
+
+ assert data["has_revisions"] is True
+ assert len(data["revisions"]) == 1
+
+ @requires_git
+ def test_sheet_ifc_attributes_are_included(self, builder_at, tmp_path):
+ repo = _make_repo(str(tmp_path))
+ _commit(repo, str(tmp_path))
+
+ ifc = ifcopenshell.file()
+ sheet = ifc.createIfcDocumentInformation(Identification="DR-01", Name="Site Plan")
+ data = builder_at(str(tmp_path / "model.ifc")).get_titleblock_data(sheet)
+
+ assert data["Identification"] == "DR-01"
+ assert data["Name"] == "Site Plan"