bimdelta-core 0.4.0__tar.gz

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,48 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .venv/
8
+ venv/
9
+ *.egg
10
+
11
+ # IDE
12
+ .vscode/
13
+ .idea/
14
+ *.swp
15
+ *.swo
16
+
17
+ # Environment
18
+ .env
19
+ .env.local
20
+ .env.production
21
+
22
+ # OS
23
+ .DS_Store
24
+ Thumbs.db
25
+
26
+ # Test / coverage
27
+ .pytest_cache/
28
+ htmlcov/
29
+ .coverage
30
+
31
+ # Node
32
+ node_modules/
33
+
34
+ # IFC temp files
35
+ *.ifc.tmp
36
+
37
+ # Docker
38
+ docker-compose.override.yml
39
+
40
+ # Claude
41
+ .claude/
42
+
43
+ # Revit project and family files, including backups (*.0001.rvt)
44
+ *.rvt
45
+ *.rfa
46
+
47
+ # Local session file
48
+ \!sessionid.txt
@@ -0,0 +1,41 @@
1
+ Metadata-Version: 2.5
2
+ Name: bimdelta-core
3
+ Version: 0.4.0
4
+ Summary: Element-level diff engine for IFC building models
5
+ Project-URL: Repository, https://github.com/kaan36875/BIMDelta
6
+ License-Expression: MIT
7
+ Keywords: bim,diff,ifc,ifcopenshell,openbim
8
+ Classifier: Operating System :: OS Independent
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Topic :: Scientific/Engineering
11
+ Requires-Python: >=3.10
12
+ Requires-Dist: ifcopenshell>=0.8.0
13
+ Requires-Dist: numpy>=1.26.0
14
+ Requires-Dist: pydantic-settings>=2.0.0
15
+ Requires-Dist: pydantic>=2.0.0
16
+ Requires-Dist: pyyaml>=6.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-core
23
+
24
+ Element-level diff engine for IFC building models, built on [IfcOpenShell](https://ifcopenshell.org).
25
+
26
+ ```python
27
+ from bimdelta_core import load_settings, run_diff
28
+
29
+ result = run_diff("rev-a.ifc", "rev-a.ifc", "rev-b.ifc", "rev-b.ifc", load_settings())
30
+ print(result.summary.added, result.summary.removed, result.summary.modified)
31
+ print(result.model_dump_json(indent=2))
32
+ ```
33
+
34
+ Elements are matched by `GlobalId`, then by the authoring tool's element id in `IfcElement.Tag`,
35
+ then by class, name, storey and placement. Matched pairs are compared on properties and
36
+ quantities, placement, type, storey and a geometry signature (bounding box and volume).
37
+
38
+ A noise filter keeps export artefacts out of the result; see
39
+ [docs/noise-filter.md](https://github.com/kaan36875/BIMDelta/blob/main/docs/noise-filter.md).
40
+
41
+ Part of [BIMDelta](https://github.com/kaan36875/BIMDelta). MIT licensed.
@@ -0,0 +1,20 @@
1
+ # bimdelta-core
2
+
3
+ Element-level diff engine for IFC building models, built on [IfcOpenShell](https://ifcopenshell.org).
4
+
5
+ ```python
6
+ from bimdelta_core import load_settings, run_diff
7
+
8
+ result = run_diff("rev-a.ifc", "rev-a.ifc", "rev-b.ifc", "rev-b.ifc", load_settings())
9
+ print(result.summary.added, result.summary.removed, result.summary.modified)
10
+ print(result.model_dump_json(indent=2))
11
+ ```
12
+
13
+ Elements are matched by `GlobalId`, then by the authoring tool's element id in `IfcElement.Tag`,
14
+ then by class, name, storey and placement. Matched pairs are compared on properties and
15
+ quantities, placement, type, storey and a geometry signature (bounding box and volume).
16
+
17
+ A noise filter keeps export artefacts out of the result; see
18
+ [docs/noise-filter.md](https://github.com/kaan36875/BIMDelta/blob/main/docs/noise-filter.md).
19
+
20
+ Part of [BIMDelta](https://github.com/kaan36875/BIMDelta). MIT licensed.
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "bimdelta-core"
7
+ version = "0.4.0"
8
+ description = "Element-level diff engine for IFC building models"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ keywords = ["ifc", "bim", "diff", "openbim", "ifcopenshell"]
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ "Operating System :: OS Independent",
16
+ "Topic :: Scientific/Engineering",
17
+ ]
18
+ dependencies = [
19
+ "ifcopenshell>=0.8.0",
20
+ "numpy>=1.26.0",
21
+ "pydantic>=2.0.0",
22
+ "pydantic-settings>=2.0.0",
23
+ "pyyaml>=6.0",
24
+ ]
25
+
26
+ [project.optional-dependencies]
27
+ dev = [
28
+ "pytest>=8.0.0",
29
+ "ruff>=0.6.0",
30
+ ]
31
+
32
+ [project.urls]
33
+ Repository = "https://github.com/kaan36875/BIMDelta"
34
+
35
+ [tool.hatch.build.targets.wheel]
36
+ packages = ["src/bimdelta_core"]
37
+
38
+ [tool.pytest.ini_options]
39
+ testpaths = ["tests"]
@@ -0,0 +1,44 @@
1
+ """Element-level diff engine for IFC building models."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ from .config import CONFIG_FILENAME, Settings, load_settings
6
+ from .engine import filter_result, run_diff
7
+ from .quantities import quantity_delta
8
+ from .schemas import (
9
+ SCHEMA_VERSION,
10
+ ChangeType,
11
+ DiffResult,
12
+ DiffSummary,
13
+ ElementDiff,
14
+ MatchMethod,
15
+ PropertyChange,
16
+ QuantityDelta,
17
+ QuantityDeltaRow,
18
+ QuantityTotals,
19
+ )
20
+
21
+ try:
22
+ __version__ = version("bimdelta-core")
23
+ except PackageNotFoundError:
24
+ __version__ = "0.0.0"
25
+
26
+ __all__ = [
27
+ "CONFIG_FILENAME",
28
+ "SCHEMA_VERSION",
29
+ "ChangeType",
30
+ "DiffResult",
31
+ "DiffSummary",
32
+ "ElementDiff",
33
+ "MatchMethod",
34
+ "PropertyChange",
35
+ "QuantityDelta",
36
+ "QuantityDeltaRow",
37
+ "QuantityTotals",
38
+ "Settings",
39
+ "__version__",
40
+ "filter_result",
41
+ "load_settings",
42
+ "quantity_delta",
43
+ "run_diff",
44
+ ]
@@ -0,0 +1,274 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+
5
+ import numpy as np
6
+
7
+ from .config import Settings
8
+ from .geometry import compute_geometry_signature, representation_fingerprint, signatures_equal
9
+ from .noise import NO_FILTER, PropertyFilter
10
+ from .parser import ParsedElement, ParsedModel, PropertyValue
11
+ from .schemas import (
12
+ ChangeType,
13
+ ElementDiff,
14
+ MatchMethod,
15
+ PropertyChange,
16
+ PropertySource,
17
+ )
18
+
19
+
20
+ def compare_matched(
21
+ v1_el: ParsedElement,
22
+ v2_el: ParsedElement,
23
+ method: str,
24
+ cfg: Settings,
25
+ v1_model: ParsedModel | None = None,
26
+ v2_model: ParsedModel | None = None,
27
+ prop_filter: PropertyFilter = NO_FILTER,
28
+ ) -> ElementDiff:
29
+ prop_changes, ignored = _compare_properties(
30
+ v1_el.properties, v2_el.properties, cfg.float_tolerance, prop_filter
31
+ )
32
+
33
+ placement_changed = _placement_changed(v1_el, v2_el, cfg)
34
+ type_changed = v1_el.type_name != v2_el.type_name
35
+ name_changed = v1_el.name != v2_el.name
36
+ storey_changed = v1_el.storey != v2_el.storey
37
+
38
+ already_modified = (
39
+ bool(prop_changes) or placement_changed or type_changed or name_changed or storey_changed
40
+ )
41
+
42
+ geom_changed = False
43
+ geom_unknown = False
44
+ if not already_modified:
45
+ geom_changed, geom_unknown = pair_geometry_state(v1_el, v2_el, v1_model, v2_model, cfg)
46
+
47
+ is_modified = already_modified or geom_changed
48
+
49
+ return ElementDiff(
50
+ global_id=v2_el.global_id,
51
+ ifc_class=v2_el.ifc_class,
52
+ name=v2_el.name,
53
+ storey=v2_el.storey,
54
+ change_type=ChangeType.MODIFIED if is_modified else ChangeType.UNCHANGED,
55
+ match_method=MatchMethod(method),
56
+ matched_global_id=v1_el.global_id if v1_el.global_id != v2_el.global_id else None,
57
+ property_changes=prop_changes,
58
+ geometry_changed=geom_changed,
59
+ geometry_unknown=geom_unknown,
60
+ placement_changed=placement_changed,
61
+ type_changed=type_changed,
62
+ name_changed=name_changed,
63
+ storey_changed=storey_changed,
64
+ ignored_property_changes=ignored,
65
+ old_name=v1_el.name if name_changed else None,
66
+ old_storey=v1_el.storey if storey_changed else None,
67
+ old_type_name=v1_el.type_name if type_changed else None,
68
+ new_type_name=v2_el.type_name if type_changed else None,
69
+ )
70
+
71
+
72
+ def build_added(el: ParsedElement) -> ElementDiff:
73
+ return ElementDiff(
74
+ global_id=el.global_id,
75
+ ifc_class=el.ifc_class,
76
+ name=el.name,
77
+ storey=el.storey,
78
+ change_type=ChangeType.ADDED,
79
+ )
80
+
81
+
82
+ def build_removed(el: ParsedElement) -> ElementDiff:
83
+ return ElementDiff(
84
+ global_id=el.global_id,
85
+ ifc_class=el.ifc_class,
86
+ name=el.name,
87
+ storey=el.storey,
88
+ change_type=ChangeType.REMOVED,
89
+ )
90
+
91
+
92
+ def pair_geometry_state(
93
+ v1_el: ParsedElement,
94
+ v2_el: ParsedElement,
95
+ v1_model: ParsedModel | None,
96
+ v2_model: ParsedModel | None,
97
+ cfg: Settings,
98
+ ) -> tuple[bool, bool]:
99
+ if v1_model is not None and v2_model is not None:
100
+ if same_geometry_source(v1_el, v2_el, v1_model, v2_model):
101
+ return False, False
102
+ ensure_pair_geometry(v1_el, v2_el, v1_model, v2_model)
103
+ return geometry_state(v1_el, v2_el, cfg)
104
+
105
+
106
+ def same_geometry_source(
107
+ v1_el: ParsedElement, v2_el: ParsedElement, v1_model: ParsedModel, v2_model: ParsedModel
108
+ ) -> bool:
109
+ """True if both elements are defined by identical IFC geometry, without tessellating."""
110
+ if not math.isclose(v1_model.unit_scale, v2_model.unit_scale):
111
+ return False
112
+ return _fingerprint(v1_el, v1_model) == _fingerprint(v2_el, v2_model)
113
+
114
+
115
+ def _fingerprint(el: ParsedElement, model: ParsedModel) -> str | None:
116
+ if el.geometry_fingerprint is None and model._ifc_file is not None:
117
+ if el._ifc_entity_id is not None:
118
+ entity = model._ifc_file.by_id(el._ifc_entity_id)
119
+ el.geometry_fingerprint = representation_fingerprint(entity, model._fingerprint_memo)
120
+ return el.geometry_fingerprint
121
+
122
+
123
+ def geometry_state(v1_el: ParsedElement, v2_el: ParsedElement, cfg: Settings) -> tuple[bool, bool]:
124
+ """Return (changed, unknown). A missing signature without a failure means no representation."""
125
+ if v1_el.geometry_failed or v2_el.geometry_failed:
126
+ return False, True
127
+ changed = not signatures_equal(
128
+ v1_el.geometry_signature,
129
+ v2_el.geometry_signature,
130
+ cfg.geometry_bbox_tolerance,
131
+ cfg.geometry_volume_tolerance,
132
+ )
133
+ return changed, False
134
+
135
+
136
+ def _compare_properties(
137
+ v1_props: list[PropertyValue],
138
+ v2_props: list[PropertyValue],
139
+ float_tol: float,
140
+ prop_filter: PropertyFilter,
141
+ ) -> tuple[list[PropertyChange], int]:
142
+ v1_map = {(p.pset_name, p.property_name, p.source): p.value for p in v1_props}
143
+ v2_map = {(p.pset_name, p.property_name, p.source): p.value for p in v2_props}
144
+
145
+ changes: list[PropertyChange] = []
146
+ ignored = 0
147
+
148
+ for key in sorted(set(v1_map) | set(v2_map)):
149
+ pset, prop, source = key
150
+ old = v1_map.get(key)
151
+ new = v2_map.get(key)
152
+
153
+ if _values_equal(old, new, float_tol):
154
+ continue
155
+ if prop_filter.is_ignored(pset, prop):
156
+ ignored += 1
157
+ continue
158
+
159
+ changes.append(
160
+ PropertyChange(
161
+ pset_name=pset,
162
+ property_name=prop,
163
+ source=PropertySource(source),
164
+ old_value=old,
165
+ new_value=new,
166
+ )
167
+ )
168
+
169
+ return changes, ignored
170
+
171
+
172
+ def _values_equal(
173
+ a: str | float | int | bool | None,
174
+ b: str | float | int | bool | None,
175
+ float_tol: float,
176
+ ) -> bool:
177
+ if a is None and b is None:
178
+ return True
179
+ if a is None or b is None:
180
+ return False
181
+ if type(a) is bool or type(b) is bool:
182
+ return a == b
183
+ if isinstance(a, (int, float)) and isinstance(b, (int, float)):
184
+ return math.isclose(float(a), float(b), abs_tol=float_tol)
185
+ return a == b
186
+
187
+
188
+ def _placement_changed(v1_el: ParsedElement, v2_el: ParsedElement, cfg: Settings) -> bool:
189
+ m1 = v1_el.storey_relative_placement
190
+ m2 = v2_el.storey_relative_placement
191
+
192
+ # Fall back to world placement if no storey context
193
+ if m1 is None or m2 is None:
194
+ m1 = v1_el.world_placement
195
+ m2 = v2_el.world_placement
196
+
197
+ return not _matrices_close(
198
+ m1, m2, cfg.placement_translation_tolerance, cfg.placement_rotation_tolerance_deg
199
+ )
200
+
201
+
202
+ def _matrices_close(
203
+ m1: np.ndarray,
204
+ m2: np.ndarray,
205
+ trans_tol: float,
206
+ rot_tol_deg: float,
207
+ ) -> bool:
208
+ t1 = m1[:3, 3]
209
+ t2 = m2[:3, 3]
210
+ if float(np.linalg.norm(t1 - t2)) > trans_tol:
211
+ return False
212
+
213
+ r1 = m1[:3, :3]
214
+ r2 = m2[:3, :3]
215
+ r_diff = r1.T @ r2
216
+ trace = float(np.trace(r_diff))
217
+ trace_clamped = max(-1.0, min(3.0, trace))
218
+ angle_rad = math.acos(max(-1.0, min(1.0, (trace_clamped - 1.0) / 2.0)))
219
+ angle_deg = math.degrees(angle_rad)
220
+ return angle_deg <= rot_tol_deg
221
+
222
+
223
+ def ensure_pair_geometry(
224
+ v1_el: ParsedElement,
225
+ v2_el: ParsedElement,
226
+ v1_model: ParsedModel | None,
227
+ v2_model: ParsedModel | None,
228
+ ) -> None:
229
+ v1_qto, v2_qto = pair_qto_volumes(v1_el, v2_el)
230
+ ensure_geometry(v1_el, v1_model, v1_qto)
231
+ ensure_geometry(v2_el, v2_model, v2_qto)
232
+
233
+
234
+ def ensure_geometry(
235
+ el: ParsedElement, model: ParsedModel | None, qto_volume: float | None = None
236
+ ) -> None:
237
+ """Lazily compute the geometry signature; record a failure instead of hiding it."""
238
+ if el.geometry_signature is not None or el.geometry_failed or not el.has_representation:
239
+ return
240
+ if model is None or model._ifc_file is None or el._ifc_entity_id is None:
241
+ return
242
+ ifc_entity = model._ifc_file.by_id(el._ifc_entity_id)
243
+ signature = compute_geometry_signature(ifc_entity, model.unit_scale, qto_volume)
244
+ if signature is None:
245
+ el.geometry_failed = True
246
+ else:
247
+ el.geometry_signature = signature
248
+
249
+
250
+ _VOLUME_QUANTITIES = ("NetVolume", "GrossVolume")
251
+
252
+
253
+ def pair_qto_volumes(
254
+ v1_el: ParsedElement, v2_el: ParsedElement
255
+ ) -> tuple[float | None, float | None]:
256
+ """Exported volume quantities, used only when both sides carry the same one.
257
+
258
+ Mixing an exported quantity on one side with a mesh-derived volume on the other would
259
+ report a geometry change whenever "export base quantities" differs between exports.
260
+ """
261
+ v1_vols = _volume_quantities(v1_el.properties)
262
+ v2_vols = _volume_quantities(v2_el.properties)
263
+ for name in _VOLUME_QUANTITIES:
264
+ if name in v1_vols and name in v2_vols:
265
+ return v1_vols[name], v2_vols[name]
266
+ return None, None
267
+
268
+
269
+ def _volume_quantities(props: list[PropertyValue]) -> dict[str, float]:
270
+ return {
271
+ p.property_name: p.value
272
+ for p in props
273
+ if p.property_name in _VOLUME_QUANTITIES and isinstance(p.value, float)
274
+ }
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ import yaml
7
+ from pydantic import Field
8
+ from pydantic_settings import BaseSettings, SettingsConfigDict
9
+
10
+ CONFIG_FILENAME = ".bimdelta.yml"
11
+
12
+ # Matched against "<property set>.<property>" with fnmatch-style wildcards.
13
+ DEFAULT_IGNORE_PROPERTIES: tuple[str, ...] = (
14
+ # Revit worksharing: the user currently holding the element, not a model property.
15
+ "*.Edited by",
16
+ )
17
+
18
+
19
+ class Settings(BaseSettings):
20
+ # All lengths in metres
21
+ float_tolerance: float = 1e-5
22
+ placement_translation_tolerance: float = 0.001
23
+ placement_rotation_tolerance_deg: float = 0.01
24
+ fallback_placement_tolerance: float = 0.050
25
+ geometry_bbox_tolerance: float = 0.001
26
+ geometry_volume_tolerance: float = 1e-6 # m³
27
+
28
+ include_openings: bool = False
29
+
30
+ # Disables every noise rule: ignore patterns and one-sided property set handling.
31
+ strict: bool = False
32
+ ignore_properties: list[str] = Field(default_factory=lambda: list(DEFAULT_IGNORE_PROPERTIES))
33
+
34
+ model_config = SettingsConfigDict(env_prefix="BIMDELTA_", env_file=".env", extra="ignore")
35
+
36
+
37
+ def load_settings(config_path: str | Path | None = None, **overrides: Any) -> Settings:
38
+ """Build settings from defaults, BIMDELTA_* env vars, an optional YAML file and overrides.
39
+
40
+ Precedence (highest first): overrides, YAML file, environment, defaults.
41
+ """
42
+ data: dict[str, Any] = {}
43
+ if config_path is not None:
44
+ data.update(_read_config_file(Path(config_path)))
45
+ data.update(overrides)
46
+ return Settings(**data)
47
+
48
+
49
+ def _read_config_file(path: Path) -> dict[str, Any]:
50
+ raw = yaml.safe_load(path.read_text(encoding="utf-8"))
51
+ if raw is None:
52
+ return {}
53
+ if not isinstance(raw, dict):
54
+ raise ValueError(f"{path}: expected a mapping at the top level")
55
+
56
+ known = set(Settings.model_fields)
57
+ unknown = sorted(set(raw) - known)
58
+ if unknown:
59
+ raise ValueError(
60
+ f"{path}: unknown setting(s) {', '.join(unknown)}. "
61
+ f"Valid settings: {', '.join(sorted(known))}"
62
+ )
63
+ return raw
@@ -0,0 +1,160 @@
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+ from collections import Counter, defaultdict
5
+ from collections.abc import Callable
6
+ from datetime import datetime, timezone
7
+
8
+ from .comparator import build_added, build_removed, compare_matched
9
+ from .config import Settings
10
+ from .matcher import match_elements
11
+ from .noise import build_property_filter, find_one_sided_psets
12
+ from .parser import ParsedModel, parse_ifc
13
+ from .schemas import (
14
+ DiffResult,
15
+ DiffSummary,
16
+ ElementDiff,
17
+ NoiseReport,
18
+ OneSidedPset,
19
+ PropertyChangeCount,
20
+ StoreyElevationChange,
21
+ )
22
+
23
+ TOP_CHANGED_PROPERTIES = 20
24
+
25
+
26
+ # IfcOpenShell is not safe to use from several threads at once: parallel ifcopenshell.open()
27
+ # calls intermittently fail to parse the header or crash the process. Diffs run one at a time.
28
+ IFC_LOCK = threading.Lock()
29
+
30
+
31
+ def run_diff(
32
+ v1_path: str,
33
+ v1_filename: str,
34
+ v2_path: str,
35
+ v2_filename: str,
36
+ cfg: Settings | None = None,
37
+ ) -> DiffResult:
38
+ cfg = cfg if cfg is not None else Settings()
39
+
40
+ with IFC_LOCK:
41
+ v1 = parse_ifc(v1_path, v1_filename, cfg.include_openings)
42
+ v2 = parse_ifc(v2_path, v2_filename, cfg.include_openings)
43
+
44
+ one_sided = [] if cfg.strict else find_one_sided_psets(v1, v2)
45
+ prop_filter = build_property_filter(cfg, one_sided)
46
+
47
+ match_result = match_elements(v1, v2, cfg.fallback_placement_tolerance)
48
+
49
+ elements: list[ElementDiff] = [
50
+ compare_matched(v1_el, v2_el, method, cfg, v1, v2, prop_filter=prop_filter)
51
+ for v1_el, v2_el, method in match_result.matched
52
+ ]
53
+
54
+ for el in match_result.added:
55
+ elements.append(build_added(el))
56
+
57
+ for el in match_result.removed:
58
+ elements.append(build_removed(el))
59
+
60
+ storey_changes = _compute_storey_elevation_changes(v1, v2)
61
+ summary = _build_summary(v1, v2, elements, storey_changes, cfg, one_sided)
62
+
63
+ return DiffResult(
64
+ v1_filename=v1_filename,
65
+ v2_filename=v2_filename,
66
+ v1_ifc_schema=v1.ifc_schema,
67
+ v2_ifc_schema=v2.ifc_schema,
68
+ timestamp=datetime.now(timezone.utc),
69
+ summary=summary,
70
+ elements=elements,
71
+ )
72
+
73
+
74
+ def _build_summary(
75
+ v1: ParsedModel,
76
+ v2: ParsedModel,
77
+ elements: list[ElementDiff],
78
+ storey_changes: list[StoreyElevationChange],
79
+ cfg: Settings,
80
+ one_sided: list[OneSidedPset],
81
+ ) -> DiffSummary:
82
+ noise = NoiseReport(
83
+ strict=cfg.strict,
84
+ ignore_properties=[] if cfg.strict else list(cfg.ignore_properties),
85
+ ignored_property_changes=0,
86
+ one_sided_psets=one_sided,
87
+ )
88
+ summary = DiffSummary(
89
+ total_v1=len(v1.elements),
90
+ total_v2=len(v2.elements),
91
+ added=0,
92
+ removed=0,
93
+ modified=0,
94
+ unchanged=0,
95
+ by_class={},
96
+ by_storey={},
97
+ storey_elevation_changes=storey_changes,
98
+ noise=noise,
99
+ )
100
+ return _tally(summary, elements)
101
+
102
+
103
+ def filter_result(result: DiffResult, keep: Callable[[ElementDiff], bool]) -> DiffResult:
104
+ """A copy of ``result`` with only the elements ``keep`` accepts, and the summary counts
105
+ recomputed for them. Model totals, storey elevation changes and the noise settings are
106
+ properties of the two models and stay as they are."""
107
+ elements = [el for el in result.elements if keep(el)]
108
+ summary = _tally(result.summary.model_copy(deep=True), elements)
109
+ return result.model_copy(update={"summary": summary, "elements": elements})
110
+
111
+
112
+ def _tally(summary: DiffSummary, elements: list[ElementDiff]) -> DiffSummary:
113
+ """Fill the per-element counts of ``summary`` from ``elements``."""
114
+ counts: Counter[str] = Counter()
115
+ by_class: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
116
+ by_storey: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
117
+ changed_props: Counter[tuple[str, str]] = Counter()
118
+
119
+ for el in elements:
120
+ ct = el.change_type.value
121
+ counts[ct] += 1
122
+ by_class[el.ifc_class][ct] += 1
123
+ by_storey[el.storey or "(no storey)"][ct] += 1
124
+ changed_props.update({(p.pset_name, p.property_name) for p in el.property_changes})
125
+
126
+ summary.added = counts["added"]
127
+ summary.removed = counts["removed"]
128
+ summary.modified = counts["modified"]
129
+ summary.unchanged = counts["unchanged"]
130
+ summary.by_class = {k: dict(v) for k, v in by_class.items()}
131
+ summary.by_storey = {k: dict(v) for k, v in by_storey.items()}
132
+ summary.geometry_unknown = sum(1 for el in elements if el.geometry_unknown)
133
+ summary.top_changed_properties = [
134
+ PropertyChangeCount(pset_name=pset, property_name=prop, element_count=n)
135
+ for (pset, prop), n in sorted(changed_props.items(), key=lambda kv: (-kv[1], kv[0]))[
136
+ :TOP_CHANGED_PROPERTIES
137
+ ]
138
+ ]
139
+ if summary.noise is not None:
140
+ summary.noise.ignored_property_changes = sum(el.ignored_property_changes for el in elements)
141
+ return summary
142
+
143
+
144
+ def _compute_storey_elevation_changes(
145
+ v1: ParsedModel, v2: ParsedModel
146
+ ) -> list[StoreyElevationChange]:
147
+ changes: list[StoreyElevationChange] = []
148
+ common_storeys = set(v1.storeys.keys()) & set(v2.storeys.keys())
149
+ for name in sorted(common_storeys):
150
+ old_elev = v1.storeys[name]
151
+ new_elev = v2.storeys[name]
152
+ if abs(old_elev - new_elev) > 1e-6:
153
+ changes.append(
154
+ StoreyElevationChange(
155
+ storey_name=name,
156
+ old_elevation=old_elev,
157
+ new_elevation=new_elev,
158
+ )
159
+ )
160
+ return changes