bimdelta-core 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_core/__init__.py +44 -0
- bimdelta_core/comparator.py +274 -0
- bimdelta_core/config.py +63 -0
- bimdelta_core/engine.py +160 -0
- bimdelta_core/geometry.py +160 -0
- bimdelta_core/matcher.py +148 -0
- bimdelta_core/noise.py +67 -0
- bimdelta_core/parser.py +289 -0
- bimdelta_core/quantities.py +136 -0
- bimdelta_core/schemas.py +147 -0
- bimdelta_core/testing.py +230 -0
- bimdelta_core-0.4.0.dist-info/METADATA +41 -0
- bimdelta_core-0.4.0.dist-info/RECORD +14 -0
- bimdelta_core-0.4.0.dist-info/WHEEL +4 -0
|
@@ -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
|
+
}
|
bimdelta_core/config.py
ADDED
|
@@ -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
|
bimdelta_core/engine.py
ADDED
|
@@ -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
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import logging
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import ifcopenshell
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
_BODY_IDENTIFIERS = ("Body", "Facetation")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class GeometrySignature:
|
|
17
|
+
bbox_dims: tuple[float, float, float] # width, height, depth in metres
|
|
18
|
+
volume: float # cubic metres
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_cached_geom_settings = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _get_geom_settings():
|
|
25
|
+
global _cached_geom_settings
|
|
26
|
+
if _cached_geom_settings is not None:
|
|
27
|
+
return _cached_geom_settings
|
|
28
|
+
import ifcopenshell.geom
|
|
29
|
+
|
|
30
|
+
s = ifcopenshell.geom.settings()
|
|
31
|
+
s.set(s.USE_WORLD_COORDS, True)
|
|
32
|
+
try:
|
|
33
|
+
s.set(s.MESHER_LINEAR_DEFLECTION, 0.01)
|
|
34
|
+
except Exception:
|
|
35
|
+
pass
|
|
36
|
+
_cached_geom_settings = s
|
|
37
|
+
return s
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def body_representation(element):
|
|
41
|
+
"""The 3D body representation of a product.
|
|
42
|
+
|
|
43
|
+
Elements commonly carry an 'Axis' or 'FootPrint' representation next to 'Body'; left to
|
|
44
|
+
choose, IfcOpenShell may pick the 2D one and fail.
|
|
45
|
+
"""
|
|
46
|
+
shape = element.Representation
|
|
47
|
+
if shape is None:
|
|
48
|
+
return None
|
|
49
|
+
reps = shape.Representations or ()
|
|
50
|
+
for identifier in _BODY_IDENTIFIERS:
|
|
51
|
+
for rep in reps:
|
|
52
|
+
if rep.RepresentationIdentifier == identifier:
|
|
53
|
+
return rep
|
|
54
|
+
return reps[0] if len(reps) == 1 else None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def compute_geometry_signature(
|
|
58
|
+
element, unit_scale: float, qto_volume: float | None = None
|
|
59
|
+
) -> GeometrySignature | None:
|
|
60
|
+
"""Compute bbox dims + volume. ifcopenshell.geom already normalises vertices
|
|
61
|
+
to metres, so we do NOT re-apply unit_scale to geometry output."""
|
|
62
|
+
try:
|
|
63
|
+
import ifcopenshell.geom
|
|
64
|
+
|
|
65
|
+
shape = ifcopenshell.geom.create_shape(
|
|
66
|
+
_get_geom_settings(), element, body_representation(element)
|
|
67
|
+
)
|
|
68
|
+
verts = np.array(shape.geometry.verts, dtype=np.float64).reshape(-1, 3)
|
|
69
|
+
|
|
70
|
+
bbox_min = verts.min(axis=0)
|
|
71
|
+
bbox_max = verts.max(axis=0)
|
|
72
|
+
dims = bbox_max - bbox_min
|
|
73
|
+
bbox_dims = (float(dims[0]), float(dims[1]), float(dims[2]))
|
|
74
|
+
|
|
75
|
+
if qto_volume is not None:
|
|
76
|
+
volume = qto_volume
|
|
77
|
+
else:
|
|
78
|
+
faces = shape.geometry.faces
|
|
79
|
+
volume = _compute_mesh_volume(verts, faces)
|
|
80
|
+
|
|
81
|
+
return GeometrySignature(bbox_dims=bbox_dims, volume=volume)
|
|
82
|
+
except Exception:
|
|
83
|
+
logger.debug("Geometry computation failed for %s", element.GlobalId, exc_info=True)
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def representation_fingerprint(element, memo: dict[int, str]) -> str:
|
|
88
|
+
"""Content hash of everything that determines an element's body shape in its own
|
|
89
|
+
coordinate system: the body representation and its openings and projections.
|
|
90
|
+
|
|
91
|
+
Equal fingerprints (with equal file units) mean equal geometry, so tessellation can be
|
|
92
|
+
skipped. STEP ids are not part of the hash; ``memo`` caches digests per entity id and
|
|
93
|
+
must not be shared between files.
|
|
94
|
+
"""
|
|
95
|
+
parts = [_digest(body_representation(element), memo)]
|
|
96
|
+
features = [
|
|
97
|
+
"void:" + _feature_digest(rel.RelatedOpeningElement, memo)
|
|
98
|
+
for rel in getattr(element, "HasOpenings", None) or ()
|
|
99
|
+
]
|
|
100
|
+
features += [
|
|
101
|
+
"projection:" + _feature_digest(rel.RelatedFeatureElement, memo)
|
|
102
|
+
for rel in getattr(element, "HasProjections", None) or ()
|
|
103
|
+
]
|
|
104
|
+
parts.extend(sorted(features))
|
|
105
|
+
return _hash("|".join(parts))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _feature_digest(feature, memo: dict[int, str]) -> str:
|
|
109
|
+
placement = feature.ObjectPlacement
|
|
110
|
+
if placement is not None and placement.is_a("IfcLocalPlacement"):
|
|
111
|
+
placement = placement.RelativePlacement
|
|
112
|
+
return _digest(body_representation(feature), memo) + "@" + _digest(placement, memo)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _digest(value, memo: dict[int, str]) -> str:
|
|
116
|
+
if isinstance(value, ifcopenshell.entity_instance):
|
|
117
|
+
eid = value.id()
|
|
118
|
+
if eid and eid in memo:
|
|
119
|
+
return memo[eid]
|
|
120
|
+
attrs = ",".join(_digest(value[i], memo) for i in range(len(value)))
|
|
121
|
+
digest = _hash(f"{value.is_a()}({attrs})")
|
|
122
|
+
if eid:
|
|
123
|
+
memo[eid] = digest
|
|
124
|
+
return digest
|
|
125
|
+
if isinstance(value, (tuple, list)):
|
|
126
|
+
return "(" + ",".join(_digest(v, memo) for v in value) + ")"
|
|
127
|
+
return repr(value)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _hash(text: str) -> str:
|
|
131
|
+
return hashlib.blake2b(text.encode("utf-8"), digest_size=16).hexdigest()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _compute_mesh_volume(verts: np.ndarray, faces: list[int] | tuple[int, ...]) -> float:
|
|
135
|
+
"""Signed volume via divergence theorem on a triangulated closed mesh."""
|
|
136
|
+
faces_arr = np.array(faces, dtype=np.int32).reshape(-1, 3)
|
|
137
|
+
v0 = verts[faces_arr[:, 0]]
|
|
138
|
+
v1 = verts[faces_arr[:, 1]]
|
|
139
|
+
v2 = verts[faces_arr[:, 2]]
|
|
140
|
+
cross = np.cross(v1, v2)
|
|
141
|
+
signed = np.sum(v0 * cross, axis=1)
|
|
142
|
+
return float(abs(signed.sum()) / 6.0)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def signatures_equal(
|
|
146
|
+
a: GeometrySignature | None,
|
|
147
|
+
b: GeometrySignature | None,
|
|
148
|
+
bbox_tol: float,
|
|
149
|
+
volume_tol: float,
|
|
150
|
+
) -> bool:
|
|
151
|
+
if a is None and b is None:
|
|
152
|
+
return True
|
|
153
|
+
if a is None or b is None:
|
|
154
|
+
return False
|
|
155
|
+
for da, db in zip(a.bbox_dims, b.bbox_dims):
|
|
156
|
+
if abs(da - db) > bbox_tol:
|
|
157
|
+
return False
|
|
158
|
+
if abs(a.volume - b.volume) > volume_tol:
|
|
159
|
+
return False
|
|
160
|
+
return True
|