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 = '{{Name}}' + (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 = ( + '' + '{{#revisions}}{{rev}}{{/revisions}}' + "" + ) + (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 = ( + '' + "{{#has_revisions}}HEADERS{{/has_revisions}}" + "" + ) + (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 = ( + '' + "{{#has_revisions}}HEADERS{{/has_revisions}}" + "" + ) + (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"