From 75cfbd40b1be34976556d5bf1c1b5f7bcd148275 Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Wed, 11 Mar 2026 20:39:24 +0000 Subject: [PATCH] ifcquery: render types with material profile sets Generated with the assistance of an AI coding tool. --- src/ifcquery/ifcquery/render.py | 100 +++++++++++++++++++++++++++++- src/ifcquery/tests/test_render.py | 69 ++++++++++++++++++++- 2 files changed, 166 insertions(+), 3 deletions(-) diff --git a/src/ifcquery/ifcquery/render.py b/src/ifcquery/ifcquery/render.py index 90e65bd5f5..431b0b1d8c 100644 --- a/src/ifcquery/ifcquery/render.py +++ b/src/ifcquery/ifcquery/render.py @@ -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: diff --git a/src/ifcquery/tests/test_render.py b/src/ifcquery/tests/test_render.py index 314a44b9a7..4f453c4ddc 100644 --- a/src/ifcquery/tests/test_render.py +++ b/src/ifcquery/tests/test_render.py @@ -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."""