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.
- bimdelta-0.4.0.dist-info/METADATA +47 -0
- bimdelta-0.4.0.dist-info/RECORD +13 -0
- bimdelta-0.4.0.dist-info/WHEEL +4 -0
- bimdelta-0.4.0.dist-info/entry_points.txt +2 -0
- bimdelta_cli/__init__.py +8 -0
- bimdelta_cli/__main__.py +3 -0
- bimdelta_cli/blame.py +275 -0
- bimdelta_cli/filters.py +49 -0
- bimdelta_cli/gitdriver.py +120 -0
- bimdelta_cli/gitutil.py +70 -0
- bimdelta_cli/main.py +398 -0
- bimdelta_cli/render.py +232 -0
- bimdelta_cli/snapshot.py +92 -0
|
@@ -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,,
|
bimdelta_cli/__init__.py
ADDED
bimdelta_cli/__main__.py
ADDED
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)
|
bimdelta_cli/filters.py
ADDED
|
@@ -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=")
|
bimdelta_cli/gitutil.py
ADDED
|
@@ -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
|
+
)
|
bimdelta_cli/snapshot.py
ADDED
|
@@ -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", " ")
|