Compare commits

...

1 Commits

Author SHA1 Message Date
Bruno Postle f105c6f1fa 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.
2026-04-07 23:50:43 +01:00
5 changed files with 411 additions and 1 deletions
@@ -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</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>
</svg>

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 21 KiB

@@ -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</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>
</svg>

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

@@ -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</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
id="g38171"

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 21 KiB

@@ -23,6 +23,7 @@ import shutil
import urllib.parse
import uuid
import xml.etree.ElementTree as ET
from datetime import datetime
from pathlib import Path
from xml.dom import minidom
@@ -32,6 +33,8 @@ from mathutils import Vector
import bonsai.tool as tool
os.environ.setdefault("GIT_PYTHON_REFRESH", "quiet")
VIEW_TITLE_OFFSET_Y = 5
DRAWING_PADDING = 10
DEFAULT_POSITION = Vector((30, 30))
@@ -316,10 +319,65 @@ class SheetBuilder:
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"]'):
+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"