Bonsai, drawing sheets show git tag revision history in titleblocks

Titleblock SVGs are given a revisions table populated from git tags on
the IFC file's repository. Tags are sorted oldest-first so they stack
upward from a fixed anchor point. Each row shows the tag name, the
tagged commit date, the first line of the tag message, and the author's
initials. A1/A2/A3 titleblock templates are updated accordingly.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Bruno Postle
2026-04-07 23:50:43 +01:00
parent 26a1955cba
commit f105c6f1fa
5 changed files with 411 additions and 1 deletions
@@ -323,5 +323,14 @@
x="160.29811" x="160.29811"
y="250.54721" 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</tspan></text> 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</tspan></text>
<g transform="translate(0,285.54)">
{{#revisions}}
<text x="11" y="{{y}}" style="font-size:3.175px;font-family:sans-serif;fill:#000000">{{rev}}</text>
<text x="31" y="{{y}}" style="font-size:3.175px;font-family:sans-serif;fill:#000000">{{description}}</text>
<text x="111" y="{{y}}" style="font-size:3.175px;font-family:sans-serif;fill:#000000">{{author}}</text>
<text x="131" y="{{y}}" style="font-size:3.175px;font-family:sans-serif;fill:#000000">{{issued}}</text>
<text x="151" y="{{y}}" style="font-size:3.175px;font-family:sans-serif;fill:#000000">{{date}}</text>
{{/revisions}}
</g>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 21 KiB

@@ -318,5 +318,14 @@
x="160.29811" x="160.29811"
y="250.54721" 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</tspan></text> 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</tspan></text>
<g transform="translate(0,285.54)">
{{#revisions}}
<text x="11" y="{{y}}" style="font-size:3.175px;font-family:sans-serif;fill:#000000">{{rev}}</text>
<text x="31" y="{{y}}" style="font-size:3.175px;font-family:sans-serif;fill:#000000">{{description}}</text>
<text x="111" y="{{y}}" style="font-size:3.175px;font-family:sans-serif;fill:#000000">{{author}}</text>
<text x="131" y="{{y}}" style="font-size:3.175px;font-family:sans-serif;fill:#000000">{{issued}}</text>
<text x="151" y="{{y}}" style="font-size:3.175px;font-family:sans-serif;fill:#000000">{{date}}</text>
{{/revisions}}
</g>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

@@ -195,6 +195,15 @@
x="160.29811" x="160.29811"
y="250.54721" 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</tspan></text> 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</tspan></text>
<g transform="translate(0,285.54)">
{{#revisions}}
<text x="11" y="{{y}}" style="font-size:3.175px;font-family:sans-serif;fill:#000000">{{rev}}</text>
<text x="31" y="{{y}}" style="font-size:3.175px;font-family:sans-serif;fill:#000000">{{description}}</text>
<text x="111" y="{{y}}" style="font-size:3.175px;font-family:sans-serif;fill:#000000">{{author}}</text>
<text x="131" y="{{y}}" style="font-size:3.175px;font-family:sans-serif;fill:#000000">{{issued}}</text>
<text x="151" y="{{y}}" style="font-size:3.175px;font-family:sans-serif;fill:#000000">{{date}}</text>
{{/revisions}}
</g>
</g> </g>
<g <g
id="g38171" id="g38171"

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 21 KiB

@@ -23,6 +23,7 @@ import shutil
import urllib.parse import urllib.parse
import uuid import uuid
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from datetime import datetime
from pathlib import Path from pathlib import Path
from xml.dom import minidom from xml.dom import minidom
@@ -32,6 +33,8 @@ from mathutils import Vector
import bonsai.tool as tool import bonsai.tool as tool
os.environ.setdefault("GIT_PYTHON_REFRESH", "quiet")
VIEW_TITLE_OFFSET_Y = 5 VIEW_TITLE_OFFSET_Y = 5
DRAWING_PADDING = 10 DRAWING_PADDING = 10
DEFAULT_POSITION = Vector((30, 30)) DEFAULT_POSITION = Vector((30, 30))
@@ -316,10 +319,65 @@ class SheetBuilder:
return {"SHEET": sheet_path} 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: def build_titleblock(self, root: ET.Element, sheet: ifcopenshell.entity_instance) -> None:
titleblock = root.findall(f'{SVG}g[@data-type="titleblock"]')[0] titleblock = root.findall(f'{SVG}g[@data-type="titleblock"]')[0]
image = titleblock.findall(f"{SVG}image")[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 grid_north = ifcopenshell.util.geolocation.get_grid_north(tool.Ifc.get()) * -1
true_north = ifcopenshell.util.geolocation.get_true_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"]'): for north in g.iterfind(f'.//{SVG}g[@data-type="grid-north"]'):
+325
View File
@@ -0,0 +1,325 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2025 Bruno Postle <bruno@postle.net>
#
# 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 <http://www.gnu.org/licenses/>.
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 = '<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg"><text>{{Name}}</text></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 = (
'<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg"><g>'
'{{#revisions}}<text x="11" y="{{y}}">{{rev}}</text>{{/revisions}}'
"</g></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 = (
'<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg">'
"{{#has_revisions}}<text>HEADERS</text>{{/has_revisions}}"
"</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 = (
'<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg">'
"{{#has_revisions}}<text>HEADERS</text>{{/has_revisions}}"
"</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"