Skip to content

Commit d734e66

Browse files
committed
test: add IFC→Modelica tests against public sample corpora
Adds tests/test_external_ifc.py covering six public IFC samples downloaded from youshengCode/IfcSampleFiles and andrewisen/bim-whale- ifc-samples (cached under tests/models/external/, gitignored): three "happy path" Modelica conversions and three IfcSpace-less files that must surface NoIfcSpaceFoundError. While curating the corpus, Ifc4_WallElementedCase.ifc revealed a crash in Layers.from_ifc_material_layers when an IfcMaterialLayer has no associated material — a configuration the IFC schema explicitly allows. construction.py now logs the missing-material case and falls back to a default material; tests/test_construction.py adds a unit test pinning the new behaviour. A second sample, AdvancedProject.ifc, fails downstream with InvalidBuildingStructureError ("window without matching wall"); it is intentionally omitted from the suite and tracked in #14.
1 parent fbef81c commit d734e66

4 files changed

Lines changed: 208 additions & 1 deletion

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,3 +165,6 @@ cython_debug/
165165
# and can be added to the global gitignore or merged into this file. For a more nuclear
166166
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
167167
.idea/
168+
169+
# Externally-downloaded IFC samples (populated by tests/test_external_ifc.py)
170+
tests/models/external/

ifctrano/construction.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,12 +150,22 @@ def from_ifc_material_layers(
150150
) -> "Layers":
151151
layers = []
152152
for layer in ifc_material_layers:
153+
if layer.Material is None:
154+
logger.warning(
155+
f"IfcMaterialLayer {layer.id()} has no associated material; "
156+
f"falling back to default material."
157+
)
158+
material = Material.model_validate(
159+
{"name": f"default_material_{layer.id()}", **DEFAULT_MATERIAL}
160+
)
161+
else:
162+
material = materials.get_material(layer.Material.id())
153163
thickness = layer.LayerThickness * unit_factor
154164
layers.append(
155165
LayerId(
156166
id=layer.id(),
157167
thickness=thickness,
158-
material=materials.get_material(layer.Material.id()),
168+
material=material,
159169
)
160170
)
161171
return cls(layers=layers)

tests/test_construction.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import ifcopenshell
12
from ifcopenshell import file
23

34
from ifctrano.construction import Materials, Layers, Constructions
@@ -56,3 +57,25 @@ def test_construction_example_hom(example_hom: file) -> None:
5657
for wall in get_building_elements(example_hom):
5758
construction = constructions.get_construction(wall)
5859
assert construction.layers
60+
61+
62+
def test_layers_from_ifc_handles_optional_material() -> None:
63+
"""``IfcMaterialLayer.Material`` is OPTIONAL per the IFC schema. The
64+
parser must fall back to a default material instead of crashing."""
65+
ifc_file = ifcopenshell.file(schema="IFC4")
66+
real_material = ifc_file.create_entity("IfcMaterial", Name="Concrete")
67+
layer_with_material = ifc_file.create_entity(
68+
"IfcMaterialLayer", Material=real_material, LayerThickness=0.2
69+
)
70+
layer_without_material = ifc_file.create_entity(
71+
"IfcMaterialLayer", Material=None, LayerThickness=0.1
72+
)
73+
74+
materials = Materials.from_ifc_materials([real_material])
75+
layers = Layers.from_ifc_material_layers(
76+
[layer_with_material, layer_without_material], materials
77+
)
78+
79+
assert len(layers.layers) == 2
80+
assert layers.layers[0].material.name.lower() == "concrete"
81+
assert layers.layers[1].material.name.startswith("default_material_")

tests/test_external_ifc.py

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
"""Integration tests against publicly available IFC sample files.
2+
3+
Each test downloads a small IFC sample from a well-known open-source IFC
4+
sample collection (cached locally in ``tests/models/external``) and verifies
5+
that ``ifctrano`` can convert it into a Modelica model via ``trano``.
6+
7+
The samples are sourced from:
8+
9+
* ``youshengCode/IfcSampleFiles`` - vendor-neutral and Autodesk Revit exports
10+
routinely used as IFC test corpora.
11+
* ``andrewisen/bim-whale-ifc-samples`` - synthetic IFC fixtures designed to
12+
be small, easy to parse, and freely redistributable (MIT licence).
13+
14+
All sample files are plain ISO 10303-21 STEP text (``ISO-10303-21;`` magic
15+
header), so there is no executable content; they are safe to download into
16+
the workspace.
17+
18+
Samples that surface a known unfixed upstream issue are intentionally
19+
omitted from this suite and tracked separately - see ifctrano#14
20+
(AdvancedProject.ifc: ``InvalidBuildingStructureError`` on windows without a
21+
matching host wall).
22+
"""
23+
24+
from __future__ import annotations
25+
26+
import urllib.error
27+
import urllib.request
28+
from dataclasses import dataclass
29+
from pathlib import Path
30+
31+
import pytest
32+
33+
from ifctrano.building import Building
34+
from ifctrano.exceptions import NoIfcSpaceFoundError
35+
36+
EXTERNAL_DIR = Path(__file__).parent / "models" / "external"
37+
DOWNLOAD_TIMEOUT_SECS = 60
38+
39+
40+
@dataclass(frozen=True)
41+
class IfcSample:
42+
"""A downloadable IFC sample used as a test fixture."""
43+
44+
name: str
45+
url: str
46+
description: str
47+
48+
49+
# Samples that should successfully convert into a Modelica model.
50+
PASSING_SAMPLES: list[IfcSample] = [
51+
IfcSample(
52+
name="Ifc2x3_Duplex_Architecture.ifc",
53+
url=(
54+
"https://raw.githubusercontent.com/youshengCode/IfcSampleFiles/"
55+
"main/Ifc2x3_Duplex_Architecture.ifc"
56+
),
57+
description=(
58+
"Autodesk Revit Architecture 2011 IFC2x3 duplex apartment, "
59+
"21 IfcSpace zones."
60+
),
61+
),
62+
IfcSample(
63+
name="Ifc4_SampleHouse.ifc",
64+
url=(
65+
"https://raw.githubusercontent.com/youshengCode/IfcSampleFiles/"
66+
"main/Ifc4_SampleHouse.ifc"
67+
),
68+
description="Vendor-neutral IFC4 sample house with 4 IfcSpace zones.",
69+
),
70+
IfcSample(
71+
name="TallBuilding.ifc",
72+
url=(
73+
"https://raw.githubusercontent.com/andrewisen/bim-whale-ifc-samples/"
74+
"main/TallBuilding/IFC/TallBuilding.ifc"
75+
),
76+
description=(
77+
"Synthetic IFC2x3 tall building with 3 stacked IfcSpace zones, "
78+
"from the BIM Whale sample collection."
79+
),
80+
),
81+
]
82+
83+
84+
# Samples that legitimately have no IfcSpace and must raise
85+
# ``NoIfcSpaceFoundError``. They guard against silent regressions in
86+
# input validation.
87+
NO_SPACE_SAMPLES: list[IfcSample] = [
88+
IfcSample(
89+
name="Ifc4_BasinFacetedBrep.ifc",
90+
url=(
91+
"https://raw.githubusercontent.com/youshengCode/IfcSampleFiles/"
92+
"main/Ifc4_BasinFacetedBrep.ifc"
93+
),
94+
description="Single basin geometry, no IfcSpace.",
95+
),
96+
IfcSample(
97+
name="Ifc4_CubeAdvancedBrep.ifc",
98+
url=(
99+
"https://raw.githubusercontent.com/youshengCode/IfcSampleFiles/"
100+
"main/Ifc4_CubeAdvancedBrep.ifc"
101+
),
102+
description="Single cube advanced BRep, no IfcSpace.",
103+
),
104+
# Regression test for a bug discovered while curating these tests:
105+
# IfcMaterialLayer.Material is OPTIONAL per the IFC schema, but
106+
# ``Constructions.from_ifc`` previously crashed with
107+
# ``AttributeError: 'NoneType' object has no attribute 'id'`` whenever a
108+
# layer had no associated material. ``ifctrano/construction.py`` now
109+
# falls back to a default material in that case, so loading this file
110+
# gets past construction parsing and fails (as it should) on the
111+
# missing IfcSpace.
112+
IfcSample(
113+
name="Ifc4_WallElementedCase.ifc",
114+
url=(
115+
"https://raw.githubusercontent.com/youshengCode/IfcSampleFiles/"
116+
"main/Ifc4_WallElementedCase.ifc"
117+
),
118+
description=(
119+
"Elemented wall with IfcMaterialLayer entries whose Material "
120+
"attribute is NULL (IFC schema permits this). Regression test "
121+
"for the construction-parsing crash on optional materials."
122+
),
123+
),
124+
]
125+
126+
127+
def _download_sample(sample: IfcSample) -> Path:
128+
EXTERNAL_DIR.mkdir(parents=True, exist_ok=True)
129+
dest = EXTERNAL_DIR / sample.name
130+
if dest.exists() and dest.stat().st_size > 0:
131+
return dest
132+
try:
133+
with urllib.request.urlopen( # noqa: S310 - https URL from sample list
134+
sample.url, timeout=DOWNLOAD_TIMEOUT_SECS
135+
) as response:
136+
payload = response.read()
137+
except (urllib.error.URLError, TimeoutError, OSError) as exc:
138+
pytest.skip(f"Could not download {sample.url}: {exc}")
139+
if not payload.startswith(b"ISO-10303-21"):
140+
pytest.skip(
141+
f"Downloaded {sample.url} is not a valid IFC STEP file "
142+
f"(unexpected header)."
143+
)
144+
dest.write_bytes(payload)
145+
return dest
146+
147+
148+
@pytest.fixture(scope="session")
149+
def external_ifc_dir() -> Path:
150+
EXTERNAL_DIR.mkdir(parents=True, exist_ok=True)
151+
return EXTERNAL_DIR
152+
153+
154+
@pytest.mark.parametrize("sample", PASSING_SAMPLES, ids=lambda s: s.name)
155+
def test_external_ifc_to_modelica(sample: IfcSample) -> None:
156+
"""Each sample with at least one IfcSpace must produce a Modelica model."""
157+
ifc_path = _download_sample(sample)
158+
building = Building.from_ifc(ifc_path)
159+
model = building.get_model()
160+
assert model, f"Empty Modelica model for {sample.name}"
161+
assert building.space_boundaries, (
162+
f"No space boundaries detected in {sample.name}"
163+
)
164+
165+
166+
@pytest.mark.parametrize("sample", NO_SPACE_SAMPLES, ids=lambda s: s.name)
167+
def test_external_ifc_without_spaces_raises(sample: IfcSample) -> None:
168+
"""Files without any IfcSpace must surface ``NoIfcSpaceFoundError``."""
169+
ifc_path = _download_sample(sample)
170+
with pytest.raises(NoIfcSpaceFoundError):
171+
Building.from_ifc(ifc_path)

0 commit comments

Comments
 (0)