ifcquery: render types with material profile sets

Generated with the assistance of an AI coding tool.
This commit is contained in:
Bruno Postle
2026-03-11 20:39:24 +00:00
parent cd8199d3c4
commit 75cfbd40b1
2 changed files with 166 additions and 3 deletions
+98 -2
View File
@@ -231,6 +231,102 @@ def _make_type_occurrence(model: ifcopenshell.file, type_entity) -> object | Non
return occurrence
def _make_profile_occurrence(model: ifcopenshell.file, type_entity) -> object | None:
"""Create a temporary occurrence for a type that has a material profile set.
Finds the first profile in the type's IfcMaterialProfileSet and creates a
1-metre IfcExtrudedAreaSolid body representation from it. Returns the
occurrence, or ``None`` when no usable profile is found.
.. note::
Intended for use on a temporary model copy; caller discards it after
rendering.
"""
# Locate the first profile from the type's material profile set.
profile = None
for rel in getattr(type_entity, "HasAssociations", []):
if not rel.is_a("IfcRelAssociatesMaterial"):
continue
mat = rel.RelatingMaterial
if mat.is_a("IfcMaterialProfileSetUsage"):
mat = mat.ForProfileSet
if mat.is_a("IfcMaterialProfileSet"):
mat_profiles = list(getattr(mat, "MaterialProfiles", None) or [])
if mat_profiles:
profile = getattr(mat_profiles[0], "Profile", None)
if profile is not None:
break
if profile is None:
return None
# Find a Body subcontext, or fall back to any Model context.
body_ctx = None
for ctx in model.by_type("IfcGeometricRepresentationSubContext"):
if ctx.ContextIdentifier == "Body":
body_ctx = ctx
break
if body_ctx is None:
for ctx in model.by_type("IfcGeometricRepresentationContext"):
if ctx.ContextType == "Model":
body_ctx = ctx
break
if body_ctx is None:
return None
# Extrude 1 metre along Z (profile lies in XY plane).
origin = model.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0))
z_axis = model.create_entity("IfcDirection", DirectionRatios=(0.0, 0.0, 1.0))
x_axis = model.create_entity("IfcDirection", DirectionRatios=(1.0, 0.0, 0.0))
position = model.create_entity(
"IfcAxis2Placement3D", Location=origin, Axis=z_axis, RefDirection=x_axis
)
extrude_dir = model.create_entity("IfcDirection", DirectionRatios=(0.0, 0.0, 1.0))
extrusion = model.create_entity(
"IfcExtrudedAreaSolid",
SweptArea=profile,
Position=position,
ExtrudedDirection=extrude_dir,
Depth=1.0,
)
shape_rep = model.create_entity(
"IfcShapeRepresentation",
ContextOfItems=body_ctx,
RepresentationIdentifier="Body",
RepresentationType="SweptSolid",
Items=[extrusion],
)
prod_def_shape = model.create_entity(
"IfcProductDefinitionShape",
Representations=[shape_rep],
)
# Identity placement.
pt = model.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0))
z_dir = model.create_entity("IfcDirection", DirectionRatios=(0.0, 0.0, 1.0))
x_dir = model.create_entity("IfcDirection", DirectionRatios=(1.0, 0.0, 0.0))
axis2 = model.create_entity("IfcAxis2Placement3D", Location=pt, Axis=z_dir, RefDirection=x_dir)
placement = model.create_entity("IfcLocalPlacement", RelativePlacement=axis2)
occ_class = _get_occurrence_class(type_entity)
try:
occurrence = model.create_entity(
occ_class,
GlobalId=ifcopenshell.guid.new(),
Name=f"_profile_preview_{type_entity.id()}",
ObjectPlacement=placement,
Representation=prod_def_shape,
)
except Exception:
occurrence = model.create_entity(
"IfcBuildingElementProxy",
GlobalId=ifcopenshell.guid.new(),
Name=f"_profile_preview_{type_entity.id()}",
ObjectPlacement=placement,
Representation=prod_def_shape,
)
return occurrence
def _render_with_types(
model: ifcopenshell.file,
types: list,
@@ -256,12 +352,12 @@ def _render_with_types(
type_id_to_occ_id: dict[int, int] = {}
for t in types:
tmp_type = tmp.by_id(t.id())
occ = _make_type_occurrence(tmp, tmp_type)
occ = _make_type_occurrence(tmp, tmp_type) or _make_profile_occurrence(tmp, tmp_type)
if occ:
type_id_to_occ_id[t.id()] = occ.id()
if not type_id_to_occ_id:
raise ValueError("Type entities have no RepresentationMaps to render")
raise ValueError("Type entities have no RepresentationMaps or material profile sets to render")
include = [tmp.by_id(occ_id) for occ_id in type_id_to_occ_id.values()]
if selector_elements:
+68 -1
View File
@@ -6,6 +6,7 @@ import tempfile
import ifcopenshell
import ifcopenshell.api.aggregate
import ifcopenshell.guid
import ifcopenshell.api.context
import ifcopenshell.api.geometry
import ifcopenshell.api.owner.settings
@@ -16,7 +17,7 @@ import ifcopenshell.api.unit
import numpy as np
import pytest
from ifcquery.render import render, _make_type_occurrence
from ifcquery.render import render, _make_type_occurrence, _make_profile_occurrence
try:
import pyvista # noqa: F401
@@ -98,6 +99,44 @@ def library_with_type():
return f, wall_type
@pytest.fixture
def library_with_profile_type():
"""IFC4 library: a BeamType with an IfcMaterialProfileSet but no RepresentationMaps."""
f = ifcopenshell.api.project.create_file()
ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0]
ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0]
project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="ProfileLibProject")
ifcopenshell.api.unit.assign_unit(f)
model_ctx = ifcopenshell.api.context.add_context(f, context_type="Model")
ifcopenshell.api.context.add_context(
f, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=model_ctx
)
# Rectangular profile 0.2m x 0.3m
profile = f.create_entity(
"IfcRectangleProfileDef",
ProfileType="AREA",
ProfileName="200x300",
XDim=0.2,
YDim=0.3,
)
material = f.create_entity("IfcMaterial", Name="Steel")
mat_profile = f.create_entity("IfcMaterialProfile", Material=material, Profile=profile)
profile_set = f.create_entity("IfcMaterialProfileSet", MaterialProfiles=[mat_profile])
beam_type = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBeamType", name="200x300 Steel Beam")
rel = f.create_entity(
"IfcRelAssociatesMaterial",
GlobalId=ifcopenshell.guid.new(),
RelatedObjects=[beam_type],
RelatingMaterial=profile_set,
)
return f, beam_type
class TestRenderBasic:
def test_returns_png_bytes(self, model_with_geometry):
result = render(model_with_geometry)
@@ -182,6 +221,34 @@ class TestRenderTypes:
render(f, selector="IfcWallType")
class TestRenderProfileTypes:
def test_render_profile_type_by_element_id(self, library_with_profile_type):
"""A type with only a material profile set renders via temporary extrusion."""
model, beam_type = library_with_profile_type
result = render(model, element_ids=[beam_type.id()])
assert result[:4] == PNG_MAGIC
def test_make_profile_occurrence_creates_occurrence(self, library_with_profile_type):
"""_make_profile_occurrence returns an occurrence entity for a profile-set type."""
model, beam_type = library_with_profile_type
occ = _make_profile_occurrence(model, beam_type)
assert occ is not None
def test_make_profile_occurrence_no_profile_returns_none(self, library_with_type):
"""_make_profile_occurrence returns None when type has no material profile set."""
model, wall_type = library_with_type
# wall_type has RepresentationMaps but no material profile set
occ = _make_profile_occurrence(model, wall_type)
assert occ is None
def test_original_model_unmodified_for_profile_type(self, library_with_profile_type):
"""Rendering a profile-based type does not modify the original model."""
model, beam_type = library_with_profile_type
entity_count_before = len(list(model))
render(model, element_ids=[beam_type.id()])
assert len(list(model)) == entity_count_before
class TestRenderNoGeometry:
def test_no_geometry_raises(self):
"""A model without geometry representations raises ValueError."""