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.
@@ -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
@@ -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