bimdelta 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,47 @@
1
+ Metadata-Version: 2.5
2
+ Name: bimdelta
3
+ Version: 0.4.0
4
+ Summary: Code review for building models: element-level IFC diffs in the terminal and in Git
5
+ Project-URL: Repository, https://github.com/kaan36875/BIMDelta
6
+ License-Expression: MIT
7
+ Keywords: bim,diff,git,ifc,ifcopenshell,openbim
8
+ Classifier: Environment :: Console
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Topic :: Scientific/Engineering
12
+ Classifier: Topic :: Software Development :: Version Control :: Git
13
+ Requires-Python: >=3.10
14
+ Requires-Dist: bimdelta-core==0.4.0
15
+ Requires-Dist: rich>=13.0.0
16
+ Requires-Dist: typer>=0.12.0
17
+ Provides-Extra: dev
18
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
19
+ Requires-Dist: ruff>=0.6.0; extra == 'dev'
20
+ Description-Content-Type: text/markdown
21
+
22
+ # bimdelta
23
+
24
+ Code review for building models. `bimdelta` compares two versions of an IFC model element by
25
+ element and plugs into Git, so `git diff model.ifc` shows which walls, doors and columns changed
26
+ instead of thousands of lines of STEP text.
27
+
28
+ ```sh
29
+ pip install bimdelta
30
+
31
+ bimdelta diff rev-a.ifc rev-b.ifc # table in the terminal
32
+ bimdelta diff rev-a.ifc rev-b.ifc -f markdown # for a pull request or a report
33
+ bimdelta diff rev-a.ifc rev-b.ifc -f json # the full DiffResult
34
+ bimdelta diff rev-a.ifc rev-b.ifc --filter "class=IfcWall*,storey=Level 1"
35
+
36
+ bimdelta install-git-driver # once per clone
37
+ git diff HEAD~1 -- model.ifc
38
+ bimdelta blame 2O2Fr$t4X7Zf8NOew3FLOH model.ifc # commits that touched one element
39
+ bimdelta quantities rev-a.ifc rev-b.ifc --by material
40
+ ```
41
+
42
+ The diff engine is [`bimdelta-core`](https://pypi.org/project/bimdelta-core/), built on
43
+ [IfcOpenShell](https://ifcopenshell.org). Documentation:
44
+ [CLI reference](https://github.com/kaan36875/BIMDelta/blob/main/docs/cli.md) and
45
+ [Git integration](https://github.com/kaan36875/BIMDelta/blob/main/docs/git-integration.md).
46
+
47
+ Part of [BIMDelta](https://github.com/kaan36875/BIMDelta). MIT licensed.
@@ -0,0 +1,13 @@
1
+ bimdelta_cli/__init__.py,sha256=VBHsWNkVLfLoC9EUlwuKA2LiV9dSHNsENB53PnkteDM,224
2
+ bimdelta_cli/__main__.py,sha256=kNzCcCqnolrhVed4G-dka0u_bNu88EPIXO7rVV9NV1c,49
3
+ bimdelta_cli/blame.py,sha256=v1P6W2TjlRMhJ_xeQfzDZ3Nn_1AOJWnmAVVx3b_RHhY,9707
4
+ bimdelta_cli/filters.py,sha256=hk2CYqEr-avjAY64OtCY9i7aS3H6Kgb9-0GHlCYUa8M,1629
5
+ bimdelta_cli/gitdriver.py,sha256=7He5KJOmBtJUOXXVcZEpgiuGkeW1b6uf7Y8us7N6X5M,4115
6
+ bimdelta_cli/gitutil.py,sha256=3Pj3ki6TlXiTC2NoQWsComNHrW1NbY66nQY3x82UXaY,2633
7
+ bimdelta_cli/main.py,sha256=GgxRIoqRg8I0Gg4Mdg4FNVRIA1wAilsE_gcZQ62oVOc,13798
8
+ bimdelta_cli/render.py,sha256=fGqet-Xzunn9G_Wd3VtgJOCV4A1WsTwfzNeCrX4rJdA,8206
9
+ bimdelta_cli/snapshot.py,sha256=smhDR3SsyuXbPM10tzpmPnJyWgNQ69TgY4Df5AxZwuU,3502
10
+ bimdelta-0.4.0.dist-info/METADATA,sha256=rCOxm5NjJZ4SU2uz3ex8kcS9aUx1BDZE-V405h3lNIc,2000
11
+ bimdelta-0.4.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
12
+ bimdelta-0.4.0.dist-info/entry_points.txt,sha256=t1l8Yk6jNSPWviknUpvz-OSbIrY3Ns50TwTxBTet0es,51
13
+ bimdelta-0.4.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ bimdelta = bimdelta_cli.main:app
@@ -0,0 +1,8 @@
1
+ """Command-line interface and Git integration for BIMDelta."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("bimdelta")
7
+ except PackageNotFoundError:
8
+ __version__ = "0.0.0"
@@ -0,0 +1,3 @@
1
+ from .main import app
2
+
3
+ app(prog_name="bimdelta")
bimdelta_cli/blame.py ADDED
@@ -0,0 +1,275 @@
1
+ """History of one element through the commits that touched an IFC file.
2
+
3
+ Consecutive versions of the file are compared with the diff engine, so an element is
4
+ followed the same way ``bimdelta diff`` matches it: by GlobalId, then Tag, then placement.
5
+ A re-export that changes an element's GlobalId therefore does not break its history.
6
+
7
+ Each comparison is cached in ``<git dir>/bimdelta-cache`` keyed by the two blob ids and the
8
+ diff settings; a later blame of any element in the same file reuses it.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import json
15
+ import tempfile
16
+ from dataclasses import asdict, dataclass, field
17
+ from pathlib import Path
18
+
19
+ from bimdelta_core import ChangeType, Settings, __version__, run_diff
20
+ from bimdelta_core.engine import IFC_LOCK
21
+ from bimdelta_core.parser import parse_ifc
22
+
23
+ from .gitutil import GitError, git, git_dir, git_text, repo_root, resolve_lfs
24
+ from .render import describe_changes
25
+
26
+ CACHE_DIRNAME = "bimdelta-cache"
27
+ CACHE_FORMAT = 1
28
+ _SEP = "\x1f"
29
+
30
+
31
+ @dataclass
32
+ class Commit:
33
+ sha: str
34
+ author: str
35
+ date: str
36
+ subject: str
37
+ blob: str | None # None where the file does not exist in this commit
38
+
39
+
40
+ @dataclass
41
+ class BlameEvent:
42
+ change: str # added | modified | removed | reidentified
43
+ sha: str
44
+ author: str
45
+ date: str
46
+ subject: str
47
+ global_id: str
48
+ ifc_class: str | None = None
49
+ name: str | None = None
50
+ details: list[str] = field(default_factory=list)
51
+
52
+
53
+ @dataclass
54
+ class BlameResult:
55
+ global_id: str
56
+ path: str
57
+ commits_scanned: int
58
+ truncated: bool
59
+ events: list[BlameEvent] # newest first
60
+ # The element already existed before the oldest commit scanned (--max-commits).
61
+ present_before: bool = False
62
+
63
+ def to_dict(self) -> dict:
64
+ return asdict(self)
65
+
66
+
67
+ class BlameError(RuntimeError):
68
+ pass
69
+
70
+
71
+ def blame(
72
+ global_id: str,
73
+ path: str | None,
74
+ cfg: Settings,
75
+ repo: Path | str = ".",
76
+ rev: str = "HEAD",
77
+ max_commits: int | None = None,
78
+ ) -> BlameResult:
79
+ root = repo_root(repo)
80
+ rel_path = _resolve_path(root, path, rev)
81
+ # One commit more than asked for: the version just before the window is what the
82
+ # oldest commit in it is compared with.
83
+ commits = _history(root, rel_path, rev, None if max_commits is None else max_commits + 1)
84
+ if not commits:
85
+ raise BlameError(f"{rel_path} has no history at {rev}")
86
+ truncated = max_commits is not None and len(commits) > max_commits
87
+ window = commits[:max_commits] if truncated else commits
88
+
89
+ cache = _Cache(git_dir(root) / CACHE_DIRNAME, cfg)
90
+
91
+ events: list[BlameEvent] = []
92
+ tracked = global_id
93
+ newest = commits[0].blob
94
+ found = newest is not None and tracked in cache.element_ids(root, newest)
95
+ present_before = False
96
+
97
+ # Walk from newest to oldest; each step compares a commit with its predecessor.
98
+ for i, newer in enumerate(window):
99
+ older = commits[i + 1] if i + 1 < len(commits) else None
100
+ if newer.blob is None:
101
+ continue
102
+ older_blob = older.blob if older is not None else None
103
+ if older_blob is None:
104
+ # File created (or re-created) here: everything in it was added.
105
+ info = cache.element_ids(root, newer.blob).get(tracked)
106
+ if info is not None:
107
+ events.append(_event("added", newer, tracked, info))
108
+ found = True
109
+ break
110
+ continue
111
+ if older_blob == newer.blob:
112
+ continue
113
+
114
+ rec = cache.pair(root, older_blob, newer.blob).get(tracked)
115
+ if rec is None:
116
+ continue
117
+ found = True
118
+ change = rec["change"]
119
+ if change == ChangeType.ADDED.value:
120
+ events.append(_event("added", newer, tracked, rec))
121
+ break
122
+ if change == ChangeType.REMOVED.value:
123
+ events.append(_event("removed", newer, tracked, rec))
124
+ continue
125
+ previous_id = rec.get("matched_global_id") or tracked
126
+ if change == ChangeType.MODIFIED.value:
127
+ events.append(_event("modified", newer, tracked, rec))
128
+ elif previous_id != tracked:
129
+ events.append(_event("reidentified", newer, tracked, rec))
130
+ tracked = previous_id
131
+ else:
132
+ if truncated and commits[-1].blob is not None:
133
+ present_before = tracked in cache.element_ids(root, commits[-1].blob)
134
+ found = found or present_before
135
+
136
+ if not found:
137
+ raise BlameError(f"no element with GlobalId {global_id} in the history of {rel_path}")
138
+
139
+ return BlameResult(
140
+ global_id=global_id,
141
+ path=rel_path,
142
+ commits_scanned=len(window),
143
+ truncated=truncated,
144
+ events=events,
145
+ present_before=present_before,
146
+ )
147
+
148
+
149
+ def _event(change: str, commit: Commit, global_id: str, rec: dict | None) -> BlameEvent:
150
+ rec = rec or {}
151
+ return BlameEvent(
152
+ change=change,
153
+ sha=commit.sha,
154
+ author=commit.author,
155
+ date=commit.date,
156
+ subject=commit.subject,
157
+ global_id=global_id,
158
+ ifc_class=rec.get("ifc_class"),
159
+ name=rec.get("name"),
160
+ details=list(rec.get("details", [])),
161
+ )
162
+
163
+
164
+ def _resolve_path(root: Path, path: str | None, rev: str) -> str:
165
+ if path is not None:
166
+ full = Path(path)
167
+ if not full.is_absolute():
168
+ full = (Path.cwd() / full).resolve()
169
+ try:
170
+ return full.relative_to(root.resolve()).as_posix()
171
+ except ValueError:
172
+ raise BlameError(f"{path} is not inside the repository at {root}") from None
173
+ tracked = [
174
+ p
175
+ for p in git_text("ls-tree", "-r", "--name-only", rev, cwd=root).splitlines()
176
+ if p.lower().endswith(".ifc")
177
+ ]
178
+ if len(tracked) == 1:
179
+ return tracked[0]
180
+ if not tracked:
181
+ raise BlameError(f"no .ifc file is tracked at {rev}")
182
+ raise BlameError("several .ifc files are tracked; name one: " + ", ".join(tracked))
183
+
184
+
185
+ def _history(root: Path, rel_path: str, rev: str, max_commits: int | None) -> list[Commit]:
186
+ args = ["log", f"--format=%H{_SEP}%an{_SEP}%aI{_SEP}%s"]
187
+ if max_commits is not None:
188
+ args.append(f"--max-count={max_commits}")
189
+ out = git_text(*args, rev, "--", rel_path, cwd=root)
190
+ commits = []
191
+ for line in out.splitlines():
192
+ sha, author, date, subject = line.split(_SEP, 3)
193
+ commits.append(Commit(sha, author, date, subject, _blob(root, sha, rel_path)))
194
+ return commits
195
+
196
+
197
+ def _blob(root: Path, sha: str, rel_path: str) -> str | None:
198
+ try:
199
+ return git_text("rev-parse", "--verify", "--quiet", f"{sha}:{rel_path}", cwd=root)
200
+ except GitError:
201
+ return None
202
+
203
+
204
+ class _Cache:
205
+ def __init__(self, directory: Path, cfg: Settings):
206
+ self.dir = directory
207
+ self.cfg = cfg
208
+ key = f"{CACHE_FORMAT}|{__version__}|{cfg.model_dump_json()}"
209
+ self.settings_key = hashlib.blake2b(key.encode(), digest_size=8).hexdigest()
210
+
211
+ def pair(self, root: Path, old_blob: str, new_blob: str) -> dict[str, dict]:
212
+ """Every element of the newer version that differs from the older one, keyed by
213
+ its GlobalId; removed elements are keyed by their GlobalId in the older version."""
214
+ path = self.dir / f"pair-{old_blob}-{new_blob}-{self.settings_key}.json"
215
+ cached = self._read(path)
216
+ if cached is not None:
217
+ return cached
218
+
219
+ with tempfile.TemporaryDirectory(prefix="bimdelta-") as tmp:
220
+ old = _materialise(root, old_blob, Path(tmp), "old")
221
+ new = _materialise(root, new_blob, Path(tmp), "new")
222
+ result = run_diff(str(old), "old.ifc", str(new), "new.ifc", self.cfg)
223
+
224
+ records: dict[str, dict] = {}
225
+ for el in result.elements:
226
+ if el.change_type == ChangeType.UNCHANGED and not el.matched_global_id:
227
+ continue
228
+ records[el.global_id] = {
229
+ "change": el.change_type.value,
230
+ "ifc_class": el.ifc_class,
231
+ "name": el.name,
232
+ "matched_global_id": el.matched_global_id,
233
+ "details": describe_changes(el) if el.change_type == ChangeType.MODIFIED else [],
234
+ }
235
+ self._write(path, records)
236
+ return records
237
+
238
+ def element_ids(self, root: Path, blob: str) -> dict[str, dict]:
239
+ """Class and name of every element in one version, keyed by GlobalId."""
240
+ path = self.dir / f"elements-{blob}.json"
241
+ cached = self._read(path)
242
+ if cached is not None:
243
+ return cached
244
+
245
+ with tempfile.TemporaryDirectory(prefix="bimdelta-") as tmp:
246
+ file = _materialise(root, blob, Path(tmp), "model")
247
+ with IFC_LOCK:
248
+ model = parse_ifc(str(file), "model.ifc", self.cfg.include_openings)
249
+ elements = {
250
+ gid: {"ifc_class": el.ifc_class, "name": el.name}
251
+ for gid, el in model.elements.items()
252
+ }
253
+ self._write(path, elements)
254
+ return elements
255
+
256
+ def _read(self, path: Path):
257
+ try:
258
+ return json.loads(path.read_text(encoding="utf-8"))
259
+ except (OSError, ValueError):
260
+ return None
261
+
262
+ def _write(self, path: Path, data) -> None:
263
+ try:
264
+ self.dir.mkdir(parents=True, exist_ok=True)
265
+ tmp = path.with_suffix(".tmp")
266
+ tmp.write_text(json.dumps(data), encoding="utf-8")
267
+ tmp.replace(path)
268
+ except OSError:
269
+ pass # The cache is an optimisation only.
270
+
271
+
272
+ def _materialise(root: Path, blob: str, directory: Path, stem: str) -> Path:
273
+ path = directory / f"{stem}.ifc"
274
+ path.write_bytes(git("cat-file", "blob", blob, cwd=root))
275
+ return resolve_lfs(path, directory, cwd=root)
@@ -0,0 +1,49 @@
1
+ """The ``--filter`` expression: ``class=IfcWall*,storey=Level 1,change=modified``.
2
+
3
+ Terms are separated by commas. Terms with the same key are alternatives (OR), different
4
+ keys must all match (AND). Values are case-insensitive and may use ``*`` and ``?``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Callable
10
+ from fnmatch import fnmatchcase
11
+
12
+ from bimdelta_core import ElementDiff
13
+
14
+ KEYS = ("class", "storey", "change", "name", "id")
15
+
16
+
17
+ class FilterError(ValueError):
18
+ pass
19
+
20
+
21
+ def parse_filter(expression: str) -> Callable[[ElementDiff], bool]:
22
+ terms: dict[str, list[str]] = {}
23
+ for raw in expression.split(","):
24
+ if not raw.strip():
25
+ continue
26
+ key, sep, value = raw.partition("=")
27
+ key = key.strip().lower()
28
+ if not sep or not value.strip():
29
+ raise FilterError(f"expected key=value, got {raw.strip()!r}")
30
+ if key not in KEYS:
31
+ raise FilterError(f"unknown filter key {key!r}; valid keys: {', '.join(KEYS)}")
32
+ terms.setdefault(key, []).append(value.strip().lower())
33
+
34
+ def keep(el: ElementDiff) -> bool:
35
+ fields = {
36
+ "class": [el.ifc_class],
37
+ "storey": [el.storey or "", el.old_storey or ""],
38
+ "change": [el.change_type.value],
39
+ "name": [el.name or "", el.old_name or ""],
40
+ "id": [el.global_id, el.matched_global_id or ""],
41
+ }
42
+ return all(
43
+ any(
44
+ fnmatchcase(field.lower(), pattern) for field in fields[key] for pattern in patterns
45
+ )
46
+ for key, patterns in terms.items()
47
+ )
48
+
49
+ return keep
@@ -0,0 +1,120 @@
1
+ """Registering BIMDelta as Git's diff driver for ``*.ifc`` files.
2
+
3
+ Two hooks are installed under ``[diff "ifc"]``:
4
+
5
+ * ``command``: an external diff driver. ``git diff`` hands it both versions of the file,
6
+ so it shows the real matched, noise-filtered element diff as Markdown.
7
+ * ``textconv``: turns one version into stable text (see ``snapshot``). Git uses it where no
8
+ external driver runs: ``git log -p``, ``git show`` and ``git diff --no-ext-diff``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import shutil
15
+ import sys
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+
19
+ from .gitutil import git, git_text, repo_root
20
+ from .snapshot import XFUNCNAME
21
+
22
+ DRIVER = "ifc"
23
+ PATTERN = "*.ifc"
24
+
25
+
26
+ @dataclass
27
+ class InstallReport:
28
+ scope: str
29
+ command: str
30
+ attributes_file: Path
31
+ attributes_changed: bool
32
+
33
+
34
+ def default_command() -> str:
35
+ """How Git should invoke this CLI.
36
+
37
+ The ``bimdelta`` script is used when it is on PATH. Otherwise (e.g. installed in a
38
+ virtual environment that is not activated) the current interpreter is called directly,
39
+ so the driver keeps working from any shell.
40
+ """
41
+ if shutil.which("bimdelta"):
42
+ return "bimdelta"
43
+ python = Path(sys.executable).as_posix()
44
+ return f'"{python}" -m bimdelta_cli'
45
+
46
+
47
+ def install(
48
+ global_: bool = False, command: str | None = None, cwd: Path | str = "."
49
+ ) -> InstallReport:
50
+ command = command or default_command()
51
+ scope = ["--global"] if global_ else []
52
+ if not global_:
53
+ cwd = repo_root(cwd)
54
+
55
+ git("config", *scope, f"diff.{DRIVER}.command", f"{command} git-diff", cwd=cwd)
56
+ git("config", *scope, f"diff.{DRIVER}.textconv", f"{command} textconv", cwd=cwd)
57
+ git("config", *scope, f"diff.{DRIVER}.xfuncname", XFUNCNAME, cwd=cwd)
58
+
59
+ attributes = global_attributes_file(cwd) if global_ else Path(cwd) / ".gitattributes"
60
+ changed = ensure_attribute(attributes)
61
+ return InstallReport(
62
+ scope="global" if global_ else "repository",
63
+ command=command,
64
+ attributes_file=attributes,
65
+ attributes_changed=changed,
66
+ )
67
+
68
+
69
+ def uninstall(global_: bool = False, cwd: Path | str = ".") -> None:
70
+ """Remove the ``[diff "ifc"]`` section. The ``.gitattributes`` line is left in place: it
71
+ is committed and shared, and does nothing without the driver."""
72
+ scope = ["--global"] if global_ else []
73
+ if not global_:
74
+ cwd = repo_root(cwd)
75
+ git("config", *scope, "--remove-section", f"diff.{DRIVER}", cwd=cwd)
76
+
77
+
78
+ def global_attributes_file(cwd: Path | str = ".") -> Path:
79
+ try:
80
+ configured = git_text("config", "--global", "--path", "core.attributesFile", cwd=cwd)
81
+ except Exception:
82
+ configured = ""
83
+ if configured:
84
+ return Path(configured).expanduser()
85
+ xdg = os.environ.get("XDG_CONFIG_HOME")
86
+ base = Path(xdg) if xdg else Path.home() / ".config"
87
+ return base / "git" / "attributes"
88
+
89
+
90
+ def ensure_attribute(path: Path) -> bool:
91
+ """Make ``*.ifc`` use ``diff=ifc`` in an attributes file. Returns True if it changed.
92
+
93
+ An existing ``*.ifc`` line keeps its other attributes; Git LFS writes ``diff=lfs`` there,
94
+ which is replaced (LFS still works: content is resolved through ``git lfs smudge``).
95
+ """
96
+ text = path.read_text(encoding="utf-8") if path.exists() else ""
97
+ lines = text.splitlines()
98
+ wanted = f"diff={DRIVER}"
99
+ for i, line in enumerate(lines):
100
+ fields = line.split()
101
+ if not fields or fields[0] != PATTERN:
102
+ continue
103
+ attrs = [a for a in fields[1:] if not _is_diff_attr(a)]
104
+ new_line = " ".join([PATTERN, *attrs, wanted])
105
+ if fields[1:].count(wanted) == 1 and not any(
106
+ _is_diff_attr(a) and a != wanted for a in fields[1:]
107
+ ):
108
+ return False
109
+ lines[i] = new_line
110
+ break
111
+ else:
112
+ lines.append(f"{PATTERN} {wanted}")
113
+
114
+ path.parent.mkdir(parents=True, exist_ok=True)
115
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n")
116
+ return True
117
+
118
+
119
+ def _is_diff_attr(attr: str) -> bool:
120
+ return attr in ("diff", "-diff", "!diff") or attr.startswith("diff=")
@@ -0,0 +1,70 @@
1
+ """Small helpers around the ``git`` executable."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ LFS_POINTER_HEADER = b"version https://git-lfs.github.com/spec/v1"
9
+ # LFS pointer files are tiny; anything bigger is real content.
10
+ LFS_POINTER_MAX_BYTES = 1024
11
+
12
+
13
+ class GitError(RuntimeError):
14
+ pass
15
+
16
+
17
+ def git(*args: str, cwd: Path | str | None = None, input: bytes | None = None) -> bytes:
18
+ try:
19
+ proc = subprocess.run(
20
+ ["git", *args], cwd=cwd, input=input, capture_output=True, check=False
21
+ )
22
+ except FileNotFoundError as exc:
23
+ raise GitError("git was not found on PATH") from exc
24
+ if proc.returncode != 0:
25
+ msg = proc.stderr.decode("utf-8", "replace").strip()
26
+ raise GitError(msg or f"git {' '.join(args)} failed with exit code {proc.returncode}")
27
+ return proc.stdout
28
+
29
+
30
+ def git_text(*args: str, cwd: Path | str | None = None) -> str:
31
+ return git(*args, cwd=cwd).decode("utf-8", "replace").strip()
32
+
33
+
34
+ def repo_root(start: Path | str = ".") -> Path:
35
+ return Path(git_text("rev-parse", "--show-toplevel", cwd=start))
36
+
37
+
38
+ def git_dir(start: Path | str = ".") -> Path:
39
+ """The directory shared by all worktrees of the repository (normally ``.git``)."""
40
+ path = Path(git_text("rev-parse", "--git-common-dir", cwd=start))
41
+ return path if path.is_absolute() else (Path(start) / path).resolve()
42
+
43
+
44
+ def is_lfs_pointer(data: bytes) -> bool:
45
+ return len(data) <= LFS_POINTER_MAX_BYTES and data.startswith(LFS_POINTER_HEADER)
46
+
47
+
48
+ def resolve_lfs(path: Path, scratch: Path, cwd: Path | str | None = None) -> Path:
49
+ """If ``path`` holds a Git LFS pointer, write the real content into the ``scratch``
50
+ directory and return that file; otherwise return ``path`` unchanged.
51
+
52
+ Git hands diff drivers the stored blob, which for LFS-tracked files is the pointer, not
53
+ the model. The object must already be in the local LFS store (``git lfs fetch``).
54
+ """
55
+ with open(path, "rb") as f:
56
+ head = f.read(LFS_POINTER_MAX_BYTES + 1)
57
+ if not is_lfs_pointer(head):
58
+ return path
59
+ try:
60
+ content = git("lfs", "smudge", cwd=cwd, input=path.read_bytes())
61
+ except GitError as exc:
62
+ raise GitError(
63
+ f"{path.name} is a Git LFS pointer and its content is not available locally "
64
+ f"(run `git lfs fetch`): {exc}"
65
+ ) from exc
66
+ if is_lfs_pointer(content[: LFS_POINTER_MAX_BYTES + 1]):
67
+ raise GitError(f"{path.name}: Git LFS object is not downloaded (run `git lfs fetch`)")
68
+ resolved = scratch / f"lfs-{path.stem}.ifc"
69
+ resolved.write_bytes(content)
70
+ return resolved
bimdelta_cli/main.py ADDED
@@ -0,0 +1,398 @@
1
+ """The ``bimdelta`` command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ import tempfile
8
+ from enum import Enum
9
+ from pathlib import Path
10
+ from typing import Annotated, NoReturn
11
+
12
+ import typer
13
+ from rich.console import Console
14
+ from rich.markup import escape
15
+ from rich.table import Table
16
+ from rich.text import Text
17
+
18
+ import bimdelta_core
19
+ from bimdelta_core import (
20
+ CONFIG_FILENAME,
21
+ Settings,
22
+ filter_result,
23
+ load_settings,
24
+ quantity_delta,
25
+ run_diff,
26
+ )
27
+ from bimdelta_core.engine import IFC_LOCK
28
+ from bimdelta_core.parser import parse_ifc
29
+
30
+ from . import __version__, gitdriver
31
+ from .blame import BlameError
32
+ from .blame import blame as run_blame
33
+ from .filters import FilterError, parse_filter
34
+ from .gitutil import GitError, resolve_lfs
35
+ from .render import has_changes, md_escape, print_table, to_markdown
36
+ from .snapshot import snapshot
37
+
38
+ app = typer.Typer(
39
+ name="bimdelta",
40
+ help="Code review for building models: element-level diffs of IFC files.",
41
+ no_args_is_help=True,
42
+ add_completion=False,
43
+ rich_markup_mode=None,
44
+ )
45
+
46
+ console = Console()
47
+ # For messages: long paths and commit subjects should not be broken across lines.
48
+ say = Console(soft_wrap=True)
49
+ err = Console(stderr=True, soft_wrap=True)
50
+
51
+ EXIT_CHANGES = 1
52
+ EXIT_ERROR = 2
53
+
54
+
55
+ class Format(str, Enum):
56
+ table = "table"
57
+ json = "json"
58
+ markdown = "markdown"
59
+
60
+
61
+ class GroupBy(str, Enum):
62
+ by_class = "class"
63
+ material = "material"
64
+
65
+
66
+ def _fail(message: str) -> NoReturn:
67
+ err.print(Text(f"error: {message}", style="red"))
68
+ raise typer.Exit(EXIT_ERROR)
69
+
70
+
71
+ def _utf8_stdout() -> None:
72
+ # Windows consoles and pipes default to a legacy code page; output contains → and ∅.
73
+ reconfigure = getattr(sys.stdout, "reconfigure", None)
74
+ if reconfigure is not None:
75
+ reconfigure(encoding="utf-8", errors="replace")
76
+
77
+
78
+ def find_config(start: Path) -> Path | None:
79
+ """The nearest ``.bimdelta.yml`` in ``start`` or one of its parents."""
80
+ for directory in (start.resolve(), *start.resolve().parents):
81
+ candidate = directory / CONFIG_FILENAME
82
+ if candidate.is_file():
83
+ return candidate
84
+ return None
85
+
86
+
87
+ def _settings(config: Path | None, strict: bool) -> Settings:
88
+ path = config if config is not None else find_config(Path.cwd())
89
+ overrides = {"strict": True} if strict else {}
90
+ try:
91
+ return load_settings(path, **overrides)
92
+ except (OSError, ValueError) as exc:
93
+ _fail(str(exc))
94
+
95
+
96
+ def _version_callback(value: bool) -> None:
97
+ if value:
98
+ console.print(f"bimdelta {__version__} (bimdelta-core {bimdelta_core.__version__})")
99
+ raise typer.Exit()
100
+
101
+
102
+ @app.callback()
103
+ def main(
104
+ version: Annotated[
105
+ bool,
106
+ typer.Option("--version", callback=_version_callback, is_eager=True, help="Show version."),
107
+ ] = False,
108
+ ) -> None:
109
+ _utf8_stdout()
110
+
111
+
112
+ ConfigOption = Annotated[
113
+ Path | None,
114
+ typer.Option(
115
+ "--config",
116
+ help=f"Settings file. Default: the nearest {CONFIG_FILENAME} here or in a parent.",
117
+ dir_okay=False,
118
+ ),
119
+ ]
120
+ StrictOption = Annotated[
121
+ bool, typer.Option("--strict", help="Turn the noise filter off and report every difference.")
122
+ ]
123
+ ExistingFile = Annotated[Path, typer.Argument(exists=True, dir_okay=False, readable=True)]
124
+
125
+
126
+ @app.command()
127
+ def diff(
128
+ v1: ExistingFile,
129
+ v2: ExistingFile,
130
+ format: Annotated[Format, typer.Option("--format", "-f", help="Output format.")] = Format.table,
131
+ summary_only: Annotated[
132
+ bool, typer.Option("--summary-only", help="Counts only, no element list.")
133
+ ] = False,
134
+ filter: Annotated[
135
+ str | None,
136
+ typer.Option(
137
+ "--filter",
138
+ help="Keep matching elements only, e.g. 'class=IfcWall*,storey=Level 1'. "
139
+ "Keys: class, storey, change, name, id. Same key = OR, different keys = AND.",
140
+ ),
141
+ ] = None,
142
+ strict: StrictOption = False,
143
+ config: ConfigOption = None,
144
+ limit: Annotated[
145
+ int, typer.Option(help="Most elements to list in table/markdown output; 0 = all.")
146
+ ] = 100,
147
+ exit_code: Annotated[
148
+ bool, typer.Option("--exit-code", help="Exit with 1 if there are changes, like git diff.")
149
+ ] = False,
150
+ ) -> None:
151
+ """Compare two versions of an IFC model."""
152
+ cfg = _settings(config, strict)
153
+ keep = None
154
+ if filter:
155
+ try:
156
+ keep = parse_filter(filter)
157
+ except FilterError as exc:
158
+ _fail(f"--filter: {exc}")
159
+
160
+ try:
161
+ result = run_diff(str(v1), v1.name, str(v2), v2.name, cfg)
162
+ except Exception as exc:
163
+ _fail(f"could not compare the models: {exc}")
164
+ if keep is not None:
165
+ result = filter_result(result, keep)
166
+
167
+ if format == Format.json:
168
+ payload = result.summary if summary_only else result
169
+ sys.stdout.write(payload.model_dump_json(indent=2) + "\n")
170
+ elif format == Format.markdown:
171
+ sys.stdout.write(to_markdown(result, summary_only=summary_only, limit=limit))
172
+ else:
173
+ print_table(console, result, summary_only=summary_only, limit=limit)
174
+
175
+ if exit_code and has_changes(result):
176
+ raise typer.Exit(EXIT_CHANGES)
177
+
178
+
179
+ @app.command()
180
+ def quantities(
181
+ v1: ExistingFile,
182
+ v2: ExistingFile,
183
+ by: Annotated[GroupBy, typer.Option("--by", help="Group by IFC class or material.")] = (
184
+ GroupBy.by_class
185
+ ),
186
+ format: Annotated[Format, typer.Option("--format", "-f")] = Format.table,
187
+ ) -> None:
188
+ """Quantity take-off of two versions and the change, from the exported quantity sets."""
189
+ try:
190
+ result = quantity_delta(str(v1), v1.name, str(v2), v2.name, by.value)
191
+ except Exception as exc:
192
+ _fail(f"could not read quantities: {exc}")
193
+
194
+ if format == Format.json:
195
+ sys.stdout.write(result.model_dump_json(indent=2) + "\n")
196
+ return
197
+
198
+ rows = [*result.rows, result.total]
199
+ head = [
200
+ "Class" if by == GroupBy.by_class else "Material",
201
+ "Count",
202
+ "Length m",
203
+ "Area m²",
204
+ "Volume m³",
205
+ ]
206
+
207
+ def cells(row) -> list[str]:
208
+ out = [f"{row.v2.count} ({row.delta.count:+d})"]
209
+ for attr in ("length", "area", "volume"):
210
+ new, delta = getattr(row.v2, attr), getattr(row.delta, attr)
211
+ out.append(f"{new:,.2f} ({delta:+,.2f})")
212
+ return out
213
+
214
+ missing = [
215
+ f"{n} element(s) in {name} have no quantities (export base quantities to include them)."
216
+ for n, name in (
217
+ (result.v1_without_quantities, result.v1_filename),
218
+ (result.v2_without_quantities, result.v2_filename),
219
+ )
220
+ if n
221
+ ]
222
+
223
+ if format == Format.markdown:
224
+ lines = [
225
+ f"### Quantities: {md_escape(result.v1_filename)} → {md_escape(result.v2_filename)}",
226
+ "",
227
+ "| " + " | ".join(head) + " |",
228
+ "|---|" + "---:|" * (len(head) - 1),
229
+ ]
230
+ for row in rows:
231
+ group = f"**{row.group}**" if row is result.total else md_escape(row.group)
232
+ lines.append(f"| {group} | " + " | ".join(cells(row)) + " |")
233
+ lines += [f"\n> {m}" for m in missing]
234
+ sys.stdout.write("\n".join(lines) + "\n")
235
+ return
236
+
237
+ table = Table(title=f"{result.v1_filename} → {result.v2_filename} (v2 value, change)")
238
+ for i, h in enumerate(head):
239
+ table.add_column(h, justify="left" if i == 0 else "right")
240
+ for row in result.rows:
241
+ table.add_row(Text(row.group), *cells(row))
242
+ table.add_section()
243
+ table.add_row(Text("Total", "bold"), *cells(result.total))
244
+ console.print(table)
245
+ for m in missing:
246
+ console.print(Text(m, "dim"))
247
+
248
+
249
+ @app.command()
250
+ def textconv(
251
+ path: ExistingFile,
252
+ strict: StrictOption = False,
253
+ config: ConfigOption = None,
254
+ ) -> None:
255
+ """Print one model as stable text (Git textconv protocol)."""
256
+ cfg = _settings(config, strict)
257
+ with tempfile.TemporaryDirectory(prefix="bimdelta-") as tmp:
258
+ try:
259
+ model = resolve_lfs(path, Path(tmp))
260
+ sys.stdout.write(snapshot(str(model), path.name, cfg))
261
+ except Exception as exc:
262
+ # A failure here would abort the whole `git log -p`; print a line instead.
263
+ sys.stdout.write(f"# bimdelta: could not read {path.name}: {exc}\n")
264
+
265
+
266
+ @app.command("git-diff", hidden=True)
267
+ def git_diff(
268
+ args: Annotated[list[str], typer.Argument(help="Arguments passed by Git.")],
269
+ strict: StrictOption = False,
270
+ config: ConfigOption = None,
271
+ ) -> None:
272
+ """External diff driver: path old-file old-hex old-mode new-file new-hex new-mode."""
273
+ if len(args) < 7:
274
+ _fail("git-diff expects the 7 arguments of GIT_EXTERNAL_DIFF")
275
+ name, old_file, _old_hex, old_mode, new_file, _new_hex, new_mode = args[:7]
276
+ cfg = _settings(config, strict)
277
+
278
+ sys.stdout.write(f"diff --bimdelta a/{name} b/{name}\n")
279
+ with tempfile.TemporaryDirectory(prefix="bimdelta-") as tmp:
280
+ try:
281
+ old = None if old_mode == "." else resolve_lfs(Path(old_file), Path(tmp))
282
+ new = None if new_mode == "." else resolve_lfs(Path(new_file), Path(tmp))
283
+ if old is None or new is None:
284
+ present = old or new
285
+ with IFC_LOCK:
286
+ count = len(parse_ifc(str(present), name).elements)
287
+ state = "new file" if old is None else "deleted file"
288
+ sys.stdout.write(f"### BIMDelta: {md_escape(name)} ({state}, {count} elements)\n")
289
+ return
290
+ result = run_diff(str(old), name, str(new), name, cfg)
291
+ sys.stdout.write(to_markdown(result, title=name))
292
+ except Exception as exc:
293
+ sys.stdout.write(f"bimdelta: could not compare {name}: {exc}\n")
294
+ finally:
295
+ sys.stdout.flush()
296
+
297
+
298
+ @app.command("install-git-driver")
299
+ def install_git_driver(
300
+ global_: Annotated[
301
+ bool, typer.Option("--global", help="Configure for all repositories of this user.")
302
+ ] = False,
303
+ command: Annotated[
304
+ str | None,
305
+ typer.Option(help="How Git should run bimdelta. Default: detected from this install."),
306
+ ] = None,
307
+ uninstall: Annotated[
308
+ bool, typer.Option("--uninstall", help="Remove the driver configuration.")
309
+ ] = False,
310
+ ) -> None:
311
+ """Make `git diff`, `git log -p` and `git show` understand .ifc files."""
312
+ try:
313
+ if uninstall:
314
+ gitdriver.uninstall(global_)
315
+ say.print("Removed the [bold]diff.ifc[/] driver configuration.")
316
+ return
317
+ report = gitdriver.install(global_, command)
318
+ except GitError as exc:
319
+ _fail(str(exc))
320
+
321
+ say.print(f"Configured the [bold]diff.ifc[/] driver ({report.scope}): {escape(report.command)}")
322
+ if report.attributes_changed:
323
+ say.print(f"Added '*.ifc diff=ifc' to {escape(str(report.attributes_file))}")
324
+ else:
325
+ say.print(f"{escape(str(report.attributes_file))} already maps *.ifc to the driver")
326
+ if not global_:
327
+ say.print(
328
+ Text(
329
+ "Commit .gitattributes so others get the mapping; each clone still needs "
330
+ "`bimdelta install-git-driver` once.",
331
+ "dim",
332
+ )
333
+ )
334
+
335
+
336
+ @app.command()
337
+ def blame(
338
+ global_id: Annotated[str, typer.Argument(metavar="GLOBALID", help="Element GlobalId.")],
339
+ path: Annotated[
340
+ str | None,
341
+ typer.Argument(help="IFC file in the repository. Optional if only one is tracked."),
342
+ ] = None,
343
+ repo: Annotated[Path, typer.Option(help="Repository directory.", file_okay=False)] = Path("."),
344
+ rev: Annotated[str, typer.Option(help="Revision to start from.")] = "HEAD",
345
+ max_commits: Annotated[
346
+ int | None, typer.Option("--max-commits", min=1, help="Only look at the N latest commits.")
347
+ ] = None,
348
+ format: Annotated[Format, typer.Option("--format", "-f")] = Format.table,
349
+ strict: StrictOption = False,
350
+ config: ConfigOption = None,
351
+ ) -> None:
352
+ """Show the commits that added, changed or removed an element."""
353
+ cfg = _settings(config if config is not None else find_config(repo), strict)
354
+ try:
355
+ result = run_blame(global_id, path, cfg, repo=repo, rev=rev, max_commits=max_commits)
356
+ except (BlameError, GitError) as exc:
357
+ _fail(str(exc))
358
+
359
+ if format == Format.json:
360
+ sys.stdout.write(json.dumps(result.to_dict(), indent=2, ensure_ascii=False) + "\n")
361
+ return
362
+
363
+ md = format == Format.markdown
364
+ if md:
365
+ sys.stdout.write(f"### History of `{result.global_id}` in {md_escape(result.path)}\n\n")
366
+ for ev in result.events:
367
+ what = f"{ev.ifc_class or ''} {ev.name or ''}".strip()
368
+ line = f'{ev.change} @ {ev.sha[:10]} ({ev.author}, {ev.date[:10]}): "{ev.subject}"'
369
+ if md:
370
+ sys.stdout.write(f"- {md_escape(line)}\n")
371
+ for d in ev.details:
372
+ sys.stdout.write(f" - {md_escape(d)}\n")
373
+ continue
374
+ style = {"added": "green", "removed": "red", "modified": "yellow"}.get(ev.change, "cyan")
375
+ say.print(
376
+ Text.assemble(
377
+ (f"{ev.change:<12}", style),
378
+ (ev.sha[:10], "bold"),
379
+ " ",
380
+ f"{ev.date[:10]} {ev.author} ",
381
+ (f'"{ev.subject}"', "italic"),
382
+ (f" {what}" if what else "", "dim"),
383
+ )
384
+ )
385
+ for d in ev.details:
386
+ say.print(Text(f"{'':12}{d}", "dim"))
387
+ tail = []
388
+ if result.present_before:
389
+ tail.append(f"already present before the {result.commits_scanned} commit(s) scanned")
390
+ elif result.truncated:
391
+ tail.append(f"history limited to {result.commits_scanned} commits")
392
+ if not result.events and not tail:
393
+ tail.append("no changes found")
394
+ for t in tail:
395
+ if md:
396
+ sys.stdout.write(f"\n_{md_escape(t)}_\n")
397
+ else:
398
+ say.print(Text(t, "dim"))
bimdelta_cli/render.py ADDED
@@ -0,0 +1,232 @@
1
+ """Text renderings of a DiffResult: a Rich terminal table and Markdown."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from rich import box
8
+ from rich.console import Console, Group
9
+ from rich.markup import escape
10
+ from rich.table import Table
11
+ from rich.text import Text
12
+
13
+ from bimdelta_core import ChangeType, DiffResult, ElementDiff
14
+
15
+ CHANGE_ORDER = (ChangeType.MODIFIED, ChangeType.ADDED, ChangeType.REMOVED)
16
+ CHANGE_STYLE = {
17
+ ChangeType.ADDED: "green",
18
+ ChangeType.REMOVED: "red",
19
+ ChangeType.MODIFIED: "yellow",
20
+ ChangeType.UNCHANGED: "dim",
21
+ }
22
+ COLUMNS = ("added", "removed", "modified", "unchanged")
23
+
24
+
25
+ def format_value(value) -> str:
26
+ if value is None:
27
+ return "∅"
28
+ if isinstance(value, float):
29
+ return f"{value:.6g}"
30
+ return str(value)
31
+
32
+
33
+ def describe_changes(el: ElementDiff) -> list[str]:
34
+ """What changed on a modified element, one short phrase per change."""
35
+ parts = [
36
+ f"{p.pset_name}.{p.property_name}: {format_value(p.old_value)} → "
37
+ f"{format_value(p.new_value)}"
38
+ for p in el.property_changes
39
+ ]
40
+ if el.type_changed:
41
+ parts.append(f"type: {el.old_type_name} → {el.new_type_name}")
42
+ if el.name_changed:
43
+ parts.append(f"name: {el.old_name} → {el.name}")
44
+ if el.storey_changed:
45
+ parts.append(f"storey: {el.old_storey} → {el.storey}")
46
+ if el.placement_changed:
47
+ parts.append("moved")
48
+ if el.geometry_changed:
49
+ parts.append("geometry changed")
50
+ if el.geometry_unknown:
51
+ parts.append("geometry not evaluated")
52
+ return parts
53
+
54
+
55
+ def identity_note(el: ElementDiff) -> str | None:
56
+ if el.matched_global_id and el.match_method is not None:
57
+ return f"matched by {el.match_method.value.replace('_', ' ')}, was {el.matched_global_id}"
58
+ return None
59
+
60
+
61
+ def changed_elements(result: DiffResult, change: ChangeType) -> list[ElementDiff]:
62
+ els = [el for el in result.elements if el.change_type == change]
63
+ return sorted(els, key=lambda el: (el.ifc_class, el.storey or "", el.name or "", el.global_id))
64
+
65
+
66
+ def has_changes(result: DiffResult) -> bool:
67
+ s = result.summary
68
+ return bool(s.added or s.removed or s.modified)
69
+
70
+
71
+ # --- Markdown -------------------------------------------------------------------------
72
+
73
+ _MD_SPECIAL = re.compile(r"([\\`*_{}\[\]<>|#~])")
74
+
75
+
76
+ def md_escape(text: str) -> str:
77
+ """Escape text from a model (names, values) so it cannot inject Markdown or HTML."""
78
+ return _MD_SPECIAL.sub(r"\\\1", text.replace("\r", " ").replace("\n", " "))
79
+
80
+
81
+ def to_markdown(
82
+ result: DiffResult,
83
+ *,
84
+ summary_only: bool = False,
85
+ limit: int = 100,
86
+ title: str | None = None,
87
+ ) -> str:
88
+ s = result.summary
89
+ title = title or f"{result.v1_filename} → {result.v2_filename}"
90
+ lines = [f"### BIMDelta: {md_escape(title)}", ""]
91
+
92
+ if not has_changes(result):
93
+ lines.append(f"No element changes ({s.unchanged} unchanged).")
94
+ else:
95
+ lines += [
96
+ "| Class | Added | Removed | Modified | Unchanged |",
97
+ "|---|---:|---:|---:|---:|",
98
+ f"| **Total** | {s.added} | {s.removed} | {s.modified} | {s.unchanged} |",
99
+ ]
100
+ for cls, counts in sorted(s.by_class.items()):
101
+ if not any(counts.get(c, 0) for c in COLUMNS[:3]):
102
+ continue
103
+ cells = " | ".join(str(counts.get(c, 0)) for c in COLUMNS)
104
+ lines.append(f"| {md_escape(cls)} | {cells} |")
105
+
106
+ lines += _markdown_notes(result)
107
+
108
+ if not summary_only:
109
+ for change in CHANGE_ORDER:
110
+ els = changed_elements(result, change)
111
+ if not els:
112
+ continue
113
+ lines += ["", f"#### {change.value.capitalize()} ({len(els)})", ""]
114
+ shown = els if limit <= 0 else els[:limit]
115
+ lines += [_markdown_element(el) for el in shown]
116
+ if len(els) > len(shown):
117
+ lines.append(f"- … and {len(els) - len(shown)} more")
118
+
119
+ return "\n".join(lines) + "\n"
120
+
121
+
122
+ def _markdown_element(el: ElementDiff) -> str:
123
+ head = f"- `{el.ifc_class}` **{md_escape(el.name or '(unnamed)')}**"
124
+ if el.storey:
125
+ head += f" ({md_escape(el.storey)})"
126
+ head += f" `{el.global_id}`"
127
+ details = describe_changes(el) if el.change_type == ChangeType.MODIFIED else []
128
+ note = identity_note(el)
129
+ if note:
130
+ details.append(note)
131
+ if details:
132
+ head += " — " + "; ".join(md_escape(d) for d in details)
133
+ return head
134
+
135
+
136
+ def notes(result: DiffResult, esc=str, strong=str) -> list[str]:
137
+ """Model-level remarks: storey moves, unevaluated geometry, what the noise filter hid.
138
+
139
+ ``esc`` escapes text taken from the models; ``strong`` emphasises it.
140
+ """
141
+ s = result.summary
142
+ out: list[str] = []
143
+ for ch in s.storey_elevation_changes:
144
+ out.append(
145
+ f"Storey {strong(esc(ch.storey_name))} elevation: "
146
+ f"{ch.old_elevation:.3f} → {ch.new_elevation:.3f} m"
147
+ )
148
+ if s.geometry_unknown:
149
+ out.append(f"Geometry could not be evaluated for {s.geometry_unknown} element(s).")
150
+ if s.noise is not None and not s.noise.strict:
151
+ hidden = []
152
+ if s.noise.ignored_property_changes:
153
+ hidden.append(f"{s.noise.ignored_property_changes} property difference(s) ignored")
154
+ one_sided = s.noise.one_sided_psets
155
+ if one_sided:
156
+ names = ", ".join(esc(p.pset_name) for p in one_sided[:5])
157
+ if len(one_sided) > 5:
158
+ names += f" and {len(one_sided) - 5} more"
159
+ hidden.append(f"property sets in one version only: {names}")
160
+ if hidden:
161
+ out.append("Noise filter: " + "; ".join(hidden) + " (--strict shows them).")
162
+ return out
163
+
164
+
165
+ def _markdown_notes(result: DiffResult) -> list[str]:
166
+ found = notes(result, esc=md_escape, strong=lambda t: f"**{t}**")
167
+ return ["", *(f"> {n}" for n in found)] if found else []
168
+
169
+
170
+ # --- Terminal -------------------------------------------------------------------------
171
+
172
+
173
+ def print_table(
174
+ console: Console, result: DiffResult, *, summary_only: bool = False, limit: int = 100
175
+ ) -> None:
176
+ s = result.summary
177
+ console.print(
178
+ Text.assemble(
179
+ ("BIMDelta ", "bold"),
180
+ (result.v1_filename, "cyan"),
181
+ " → ",
182
+ (result.v2_filename, "cyan"),
183
+ (f" ({result.v1_ifc_schema} → {result.v2_ifc_schema})", "dim"),
184
+ )
185
+ )
186
+
187
+ summary = Table(box=box.SIMPLE_HEAD, show_edge=False)
188
+ summary.add_column("Class")
189
+ for col in COLUMNS:
190
+ summary.add_column(col.capitalize(), justify="right", style=CHANGE_STYLE[ChangeType(col)])
191
+ summary.add_row(
192
+ "[bold]Total[/]", str(s.added), str(s.removed), str(s.modified), str(s.unchanged)
193
+ )
194
+ summary.add_section()
195
+ for cls, counts in sorted(s.by_class.items()):
196
+ summary.add_row(escape(cls), *(str(counts.get(c, 0)) for c in COLUMNS))
197
+ console.print(summary)
198
+
199
+ for note in notes(result):
200
+ console.print(Text(note, "dim"))
201
+
202
+ if summary_only or not has_changes(result):
203
+ return
204
+
205
+ elements = Table(box=box.SIMPLE_HEAD, show_edge=False, expand=False)
206
+ elements.add_column("Change")
207
+ elements.add_column("Class")
208
+ elements.add_column("Name", overflow="fold")
209
+ elements.add_column("Storey")
210
+ elements.add_column("GlobalId", style="dim", no_wrap=True)
211
+ elements.add_column("What changed", overflow="fold")
212
+
213
+ rows = [el for change in CHANGE_ORDER for el in changed_elements(result, change)]
214
+ shown = rows if limit <= 0 else rows[:limit]
215
+ for el in shown:
216
+ details = describe_changes(el) if el.change_type == ChangeType.MODIFIED else []
217
+ note = identity_note(el)
218
+ if note:
219
+ details.append(note)
220
+ elements.add_row(
221
+ Text(el.change_type.value, CHANGE_STYLE[el.change_type]),
222
+ Text(el.ifc_class),
223
+ Text(el.name or ""),
224
+ Text(el.storey or ""),
225
+ Text(el.global_id),
226
+ Group(*(Text(d) for d in details)) if details else Text(""),
227
+ )
228
+ console.print(elements)
229
+ if len(rows) > len(shown):
230
+ console.print(
231
+ Text(f"… and {len(rows) - len(shown)} more (use --limit 0 to list all)", "dim")
232
+ )
@@ -0,0 +1,92 @@
1
+ """A line-oriented text form of one IFC model, for Git's textconv protocol.
2
+
3
+ Git runs textconv on each version separately and line-diffs the outputs, so the text has
4
+ to be stable: one header line per element, sorted, followed by indented lines for its
5
+ type, placement, geometry and properties. A changed property then shows up as one
6
+ ``-``/``+`` pair under the element's header (which ``diff.ifc.xfuncname`` puts in the
7
+ hunk header). For a matched, noise-filtered comparison use ``bimdelta diff``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections import Counter
13
+ from collections.abc import Iterator
14
+
15
+ from bimdelta_core import Settings
16
+ from bimdelta_core.engine import IFC_LOCK
17
+ from bimdelta_core.geometry import representation_fingerprint
18
+ from bimdelta_core.noise import PropertyFilter
19
+ from bimdelta_core.parser import ParsedElement, parse_ifc
20
+
21
+ from .render import format_value
22
+
23
+ # Matches the element header lines below; used as the Git hunk header pattern.
24
+ XFUNCNAME = "^Ifc[A-Za-z]+ .*$"
25
+
26
+
27
+ def snapshot(path: str, filename: str, cfg: Settings) -> str:
28
+ return "\n".join(snapshot_lines(path, filename, cfg)) + "\n"
29
+
30
+
31
+ def snapshot_lines(path: str, filename: str, cfg: Settings) -> Iterator[str]:
32
+ with IFC_LOCK:
33
+ model = parse_ifc(path, filename, cfg.include_openings)
34
+ memo: dict[int, str] = {}
35
+ ifc = model._ifc_file
36
+ fingerprints = {
37
+ gid: representation_fingerprint(ifc.by_id(el._ifc_entity_id), memo)
38
+ for gid, el in model.elements.items()
39
+ if el.has_representation and el._ifc_entity_id is not None
40
+ }
41
+
42
+ prop_filter = (
43
+ PropertyFilter()
44
+ if cfg.strict
45
+ else PropertyFilter(ignore_patterns=tuple(cfg.ignore_properties))
46
+ )
47
+
48
+ classes = Counter(el.ifc_class for el in model.elements.values())
49
+ yield f"# {filename} {model.ifc_schema} {len(model.elements)} elements"
50
+ yield ""
51
+ yield "## Storeys"
52
+ for name, elevation in sorted(model.storeys.items(), key=lambda kv: (kv[1], kv[0])):
53
+ yield f" {name} {elevation:+.3f} m"
54
+ yield ""
55
+ yield "## Classes"
56
+ for cls, n in sorted(classes.items()):
57
+ yield f" {cls} {n}"
58
+ yield ""
59
+ yield "## Elements"
60
+
61
+ for el in sorted(model.elements.values(), key=_sort_key):
62
+ yield _header(el)
63
+ if el.tag:
64
+ yield f" tag: {el.tag}"
65
+ if el.type_name:
66
+ yield f" type: {el.type_name}"
67
+ x, y, z = (float(v) for v in el.world_placement[:3, 3])
68
+ yield f" placement: {x:.3f}, {y:.3f}, {z:.3f}"
69
+ fp = fingerprints.get(el.global_id)
70
+ yield f" geometry: {fp[:12] if fp else 'none'}"
71
+ for prop in sorted(el.properties, key=lambda p: (p.source, p.pset_name, p.property_name)):
72
+ if prop_filter.is_ignored(prop.pset_name, prop.property_name):
73
+ continue
74
+ prefix = "type " if prop.source == "type" else ""
75
+ yield (
76
+ f" {prefix}{prop.pset_name}.{prop.property_name} = "
77
+ f"{_one_line(format_value(prop.value))}"
78
+ )
79
+
80
+
81
+ def _sort_key(el: ParsedElement) -> tuple:
82
+ return (el.ifc_class, el.storey or "", el.name or "", el.tag or "", el.global_id)
83
+
84
+
85
+ def _header(el: ParsedElement) -> str:
86
+ name = _one_line(el.name or "(unnamed)")
87
+ storey = f" @ {_one_line(el.storey)}" if el.storey else ""
88
+ return f"{el.ifc_class} {el.global_id} {name}{storey}"
89
+
90
+
91
+ def _one_line(text: str) -> str:
92
+ return text.replace("\r", " ").replace("\n", " ")