mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-20 06:58:56 +00:00
ifcquery: render element types when asked
Generated with the assistance of an AI coding tool.
This commit is contained in:
+253
-65
@@ -24,6 +24,7 @@ import tempfile
|
|||||||
|
|
||||||
import ifcopenshell
|
import ifcopenshell
|
||||||
import ifcopenshell.geom
|
import ifcopenshell.geom
|
||||||
|
import ifcopenshell.guid
|
||||||
import ifcopenshell.util.selector
|
import ifcopenshell.util.selector
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -97,77 +98,18 @@ def _add_shape(
|
|||||||
plotter.add_mesh(mesh, color=color, opacity=opacity, show_edges=False)
|
plotter.add_mesh(mesh, color=color, opacity=opacity, show_edges=False)
|
||||||
|
|
||||||
|
|
||||||
def render(
|
def _render_iterator(
|
||||||
model: ifcopenshell.file,
|
iterator: object,
|
||||||
selector: str | None = None,
|
highlight_ids: list[int] | None,
|
||||||
element_ids: list[int] | None = None,
|
view: str,
|
||||||
view: str = "iso",
|
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
"""Render IFC model geometry to a PNG image.
|
"""Drive a geometry iterator into a pyvista plotter and return PNG bytes."""
|
||||||
|
|
||||||
:param model: The in-memory IFC model.
|
|
||||||
:param selector: ifcopenshell selector to restrict rendered elements
|
|
||||||
(e.g. ``'IfcWall'`` or ``'IfcBuildingStorey[Name="Ground Floor"]'``).
|
|
||||||
When omitted the whole model is rendered.
|
|
||||||
:param element_ids: Step IDs of elements to highlight. The rest of the
|
|
||||||
model is rendered in translucent grey so the highlighted elements
|
|
||||||
stand out.
|
|
||||||
:param view: Camera angle: ``iso``, ``top``, ``south``, ``north``,
|
|
||||||
``east``, or ``west``. Defaults to ``iso``.
|
|
||||||
:return: PNG image as raw bytes.
|
|
||||||
:raises ImportError: If pyvista is not installed.
|
|
||||||
:raises ValueError: If the selector matches nothing or the model has no
|
|
||||||
renderable geometry.
|
|
||||||
"""
|
|
||||||
if not _HAS_PYVISTA:
|
|
||||||
raise ImportError("pyvista is not installed. Install with: pip install pyvista")
|
|
||||||
|
|
||||||
settings = ifcopenshell.geom.settings()
|
|
||||||
settings.set("use-world-coords", True)
|
|
||||||
|
|
||||||
# Exclude 'Clearance' subcontexts (door/window operation zones) from rendering.
|
|
||||||
clearance_ids = {
|
|
||||||
c.id()
|
|
||||||
for c in model.by_type("IfcGeometricRepresentationSubContext")
|
|
||||||
if c.ContextIdentifier == "Clearance"
|
|
||||||
}
|
|
||||||
if clearance_ids:
|
|
||||||
ctx_ids = [
|
|
||||||
c.id()
|
|
||||||
for c in model.by_type("IfcGeometricRepresentationContext")
|
|
||||||
if c.id() not in clearance_ids
|
|
||||||
]
|
|
||||||
if ctx_ids:
|
|
||||||
settings.set("context-ids", ctx_ids)
|
|
||||||
|
|
||||||
if selector:
|
|
||||||
include_elements = list(ifcopenshell.util.selector.filter_elements(model, selector))
|
|
||||||
if not include_elements:
|
|
||||||
raise ValueError(f"Selector {selector!r} matched no elements")
|
|
||||||
iterator = ifcopenshell.geom.iterator(
|
|
||||||
settings,
|
|
||||||
model,
|
|
||||||
multiprocessing.cpu_count(),
|
|
||||||
include=include_elements,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
exclude = list(model.by_type("IfcOpeningElement"))
|
|
||||||
iterator = ifcopenshell.geom.iterator(
|
|
||||||
settings,
|
|
||||||
model,
|
|
||||||
multiprocessing.cpu_count(),
|
|
||||||
exclude=exclude if exclude else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not iterator.initialize():
|
|
||||||
raise ValueError("No renderable geometry found in model (or selector matched nothing)")
|
|
||||||
|
|
||||||
plotter = pv.Plotter(off_screen=True, window_size=(1280, 960))
|
plotter = pv.Plotter(off_screen=True, window_size=(1280, 960))
|
||||||
plotter.background_color = "white"
|
plotter.background_color = "white"
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
_add_shape(iterator.get(), plotter, highlight_ids=frozenset(element_ids) if element_ids else None)
|
_add_shape(iterator.get(), plotter, highlight_ids=frozenset(highlight_ids) if highlight_ids else None)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # skip broken shapes, keep rendering the rest
|
pass # skip broken shapes, keep rendering the rest
|
||||||
if not iterator.next():
|
if not iterator.next():
|
||||||
@@ -187,3 +129,249 @@ def render(
|
|||||||
os.unlink(tmp_path)
|
os.unlink(tmp_path)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _build_geom_settings(model: ifcopenshell.file) -> "ifcopenshell.geom.settings":
|
||||||
|
"""Build geometry settings, excluding Clearance subcontexts."""
|
||||||
|
settings = ifcopenshell.geom.settings()
|
||||||
|
settings.set("use-world-coords", True)
|
||||||
|
|
||||||
|
clearance_ids = {
|
||||||
|
c.id()
|
||||||
|
for c in model.by_type("IfcGeometricRepresentationSubContext")
|
||||||
|
if c.ContextIdentifier == "Clearance"
|
||||||
|
}
|
||||||
|
if clearance_ids:
|
||||||
|
ctx_ids = [
|
||||||
|
c.id()
|
||||||
|
for c in model.by_type("IfcGeometricRepresentationContext")
|
||||||
|
if c.id() not in clearance_ids
|
||||||
|
]
|
||||||
|
if ctx_ids:
|
||||||
|
settings.set("context-ids", ctx_ids)
|
||||||
|
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
def _get_occurrence_class(type_entity) -> str:
|
||||||
|
"""Derive the occurrence IFC class from a type entity class name."""
|
||||||
|
type_class = type_entity.is_a()
|
||||||
|
if type_class.endswith("Type"):
|
||||||
|
return type_class[:-4]
|
||||||
|
return "IfcBuildingElementProxy"
|
||||||
|
|
||||||
|
|
||||||
|
def _make_type_occurrence(model: ifcopenshell.file, type_entity) -> object | None:
|
||||||
|
"""Create a temporary occurrence for *type_entity* using its RepresentationMaps.
|
||||||
|
|
||||||
|
The occurrence is added to *model* and references the type's existing
|
||||||
|
RepresentationMap entities via IfcMappedItem. Returns the occurrence entity,
|
||||||
|
or ``None`` when the type has no usable RepresentationMaps.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
This function is intended for use on a temporary model copy. The
|
||||||
|
caller is responsible for discarding that copy after rendering.
|
||||||
|
"""
|
||||||
|
rep_maps = getattr(type_entity, "RepresentationMaps", None) or []
|
||||||
|
if not rep_maps:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# One IfcMappedItem per RepresentationMap.
|
||||||
|
mapped_items = []
|
||||||
|
for rep_map in rep_maps:
|
||||||
|
origin = model.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0))
|
||||||
|
transform = model.create_entity(
|
||||||
|
"IfcCartesianTransformationOperator3D",
|
||||||
|
LocalOrigin=origin,
|
||||||
|
)
|
||||||
|
mapped_item = model.create_entity(
|
||||||
|
"IfcMappedItem",
|
||||||
|
MappingSource=rep_map,
|
||||||
|
MappingTarget=transform,
|
||||||
|
)
|
||||||
|
mapped_items.append(mapped_item)
|
||||||
|
|
||||||
|
context = rep_maps[0].MappedRepresentation.ContextOfItems
|
||||||
|
shape_rep = model.create_entity(
|
||||||
|
"IfcShapeRepresentation",
|
||||||
|
ContextOfItems=context,
|
||||||
|
RepresentationIdentifier="Body",
|
||||||
|
RepresentationType="MappedRepresentation",
|
||||||
|
Items=mapped_items,
|
||||||
|
)
|
||||||
|
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"_type_preview_{type_entity.id()}",
|
||||||
|
ObjectPlacement=placement,
|
||||||
|
Representation=prod_def_shape,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
occurrence = model.create_entity(
|
||||||
|
"IfcBuildingElementProxy",
|
||||||
|
GlobalId=ifcopenshell.guid.new(),
|
||||||
|
Name=f"_type_preview_{type_entity.id()}",
|
||||||
|
ObjectPlacement=placement,
|
||||||
|
Representation=prod_def_shape,
|
||||||
|
)
|
||||||
|
return occurrence
|
||||||
|
|
||||||
|
|
||||||
|
def _render_with_types(
|
||||||
|
model: ifcopenshell.file,
|
||||||
|
types: list,
|
||||||
|
selector_elements: list | None,
|
||||||
|
element_ids: list[int] | None,
|
||||||
|
type_highlight_ids: set[int],
|
||||||
|
view: str,
|
||||||
|
) -> bytes:
|
||||||
|
"""Render type entities by creating occurrences in a temporary model copy.
|
||||||
|
|
||||||
|
*types* — list of IfcTypeProduct entities to render.
|
||||||
|
*selector_elements* — non-type elements from the selector (or ``None``).
|
||||||
|
*element_ids* — original highlight IDs (may contain type IDs).
|
||||||
|
*type_highlight_ids* — subset of *element_ids* that are type IDs.
|
||||||
|
"""
|
||||||
|
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".ifc")
|
||||||
|
os.close(tmp_fd)
|
||||||
|
try:
|
||||||
|
model.write(tmp_path)
|
||||||
|
tmp = ifcopenshell.open(tmp_path)
|
||||||
|
|
||||||
|
# Map original type step-ID → new occurrence step-ID in the tmp model.
|
||||||
|
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)
|
||||||
|
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")
|
||||||
|
|
||||||
|
include = [tmp.by_id(occ_id) for occ_id in type_id_to_occ_id.values()]
|
||||||
|
if selector_elements:
|
||||||
|
include.extend(tmp.by_id(e.id()) for e in selector_elements)
|
||||||
|
|
||||||
|
settings = _build_geom_settings(tmp)
|
||||||
|
iterator = ifcopenshell.geom.iterator(settings, tmp, multiprocessing.cpu_count(), include=include)
|
||||||
|
if not iterator.initialize():
|
||||||
|
raise ValueError("Type entities have no renderable geometry")
|
||||||
|
|
||||||
|
# Remap type IDs → occurrence IDs in the highlight list.
|
||||||
|
new_highlight = None
|
||||||
|
if element_ids:
|
||||||
|
new_highlight = []
|
||||||
|
for hid in element_ids:
|
||||||
|
if hid in type_highlight_ids:
|
||||||
|
mapped = type_id_to_occ_id.get(hid)
|
||||||
|
if mapped:
|
||||||
|
new_highlight.append(mapped)
|
||||||
|
else:
|
||||||
|
new_highlight.append(hid)
|
||||||
|
|
||||||
|
return _render_iterator(iterator, new_highlight, view)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def render(
|
||||||
|
model: ifcopenshell.file,
|
||||||
|
selector: str | None = None,
|
||||||
|
element_ids: list[int] | None = None,
|
||||||
|
view: str = "iso",
|
||||||
|
) -> bytes:
|
||||||
|
"""Render IFC model geometry to a PNG image.
|
||||||
|
|
||||||
|
Supports both element instances and element types (e.g. ``IfcWallType``).
|
||||||
|
When type entities are targeted — via *selector* or *element_ids* — a
|
||||||
|
temporary copy of the model is used to create proxy occurrences that
|
||||||
|
reference the type's RepresentationMaps; the original model is not
|
||||||
|
modified.
|
||||||
|
|
||||||
|
:param model: The in-memory IFC model.
|
||||||
|
:param selector: ifcopenshell selector to restrict rendered elements
|
||||||
|
(e.g. ``'IfcWall'``, ``'IfcWallType'``, or
|
||||||
|
``'IfcBuildingStorey[Name="Ground Floor"]'``).
|
||||||
|
When omitted the whole model is rendered.
|
||||||
|
:param element_ids: Step IDs of elements (or types) to highlight. The
|
||||||
|
rest of the model is rendered in translucent grey so the highlighted
|
||||||
|
items stand out.
|
||||||
|
:param view: Camera angle: ``iso``, ``top``, ``south``, ``north``,
|
||||||
|
``east``, or ``west``. Defaults to ``iso``.
|
||||||
|
:return: PNG image as raw bytes.
|
||||||
|
:raises ImportError: If pyvista is not installed.
|
||||||
|
:raises ValueError: If the selector matches nothing or the model has no
|
||||||
|
renderable geometry.
|
||||||
|
"""
|
||||||
|
if not _HAS_PYVISTA:
|
||||||
|
raise ImportError("pyvista is not installed. Install with: pip install pyvista")
|
||||||
|
|
||||||
|
# --- Partition selector results into types and elements ---
|
||||||
|
if selector:
|
||||||
|
matched = list(ifcopenshell.util.selector.filter_elements(model, selector))
|
||||||
|
if not matched:
|
||||||
|
raise ValueError(f"Selector {selector!r} matched no elements")
|
||||||
|
types = [e for e in matched if e.is_a("IfcTypeProduct")]
|
||||||
|
selector_elements: list | None = [e for e in matched if not e.is_a("IfcTypeProduct")]
|
||||||
|
else:
|
||||||
|
types = []
|
||||||
|
selector_elements = None # no restriction — render all elements
|
||||||
|
|
||||||
|
# --- Collect any type entities from element_ids ---
|
||||||
|
type_highlight_ids: set[int] = set()
|
||||||
|
if element_ids:
|
||||||
|
for eid in element_ids:
|
||||||
|
entity = model.by_id(eid)
|
||||||
|
if entity.is_a("IfcTypeProduct"):
|
||||||
|
type_highlight_ids.add(eid)
|
||||||
|
seen = {t.id() for t in types}
|
||||||
|
if eid not in seen:
|
||||||
|
types.append(entity)
|
||||||
|
|
||||||
|
# --- Delegate to temp-copy path when any type entities are involved ---
|
||||||
|
if types:
|
||||||
|
return _render_with_types(model, types, selector_elements, element_ids, type_highlight_ids, view)
|
||||||
|
|
||||||
|
# --- Regular element rendering ---
|
||||||
|
settings = _build_geom_settings(model)
|
||||||
|
|
||||||
|
if selector_elements is not None:
|
||||||
|
if not selector_elements:
|
||||||
|
raise ValueError(f"Selector {selector!r} matched only type entities (use a type selector or IfcElement)")
|
||||||
|
iterator = ifcopenshell.geom.iterator(
|
||||||
|
settings,
|
||||||
|
model,
|
||||||
|
multiprocessing.cpu_count(),
|
||||||
|
include=selector_elements,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
exclude = list(model.by_type("IfcOpeningElement"))
|
||||||
|
iterator = ifcopenshell.geom.iterator(
|
||||||
|
settings,
|
||||||
|
model,
|
||||||
|
multiprocessing.cpu_count(),
|
||||||
|
exclude=exclude if exclude else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not iterator.initialize():
|
||||||
|
raise ValueError("No renderable geometry found in model (or selector matched nothing)")
|
||||||
|
|
||||||
|
return _render_iterator(iterator, element_ids, view)
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import ifcopenshell.api.unit
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from ifcquery.render import render
|
from ifcquery.render import render, _make_type_occurrence
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import pyvista # noqa: F401
|
import pyvista # noqa: F401
|
||||||
@@ -69,6 +69,35 @@ def model_with_geometry():
|
|||||||
return f
|
return f
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def library_with_type():
|
||||||
|
"""IFC4 library file: a WallType with a RepresentationMap but no instances."""
|
||||||
|
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="LibProject")
|
||||||
|
ifcopenshell.api.unit.assign_unit(f)
|
||||||
|
|
||||||
|
model_ctx = ifcopenshell.api.context.add_context(f, context_type="Model")
|
||||||
|
body = ifcopenshell.api.context.add_context(
|
||||||
|
f, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=model_ctx
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build the shape representation and wrap it in an IfcRepresentationMap.
|
||||||
|
shape_rep = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=3, height=2.5, thickness=0.2)
|
||||||
|
origin = f.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0))
|
||||||
|
z_dir = f.create_entity("IfcDirection", DirectionRatios=(0.0, 0.0, 1.0))
|
||||||
|
x_dir = f.create_entity("IfcDirection", DirectionRatios=(1.0, 0.0, 0.0))
|
||||||
|
map_origin = f.create_entity("IfcAxis2Placement3D", Location=origin, Axis=z_dir, RefDirection=x_dir)
|
||||||
|
rep_map = f.create_entity("IfcRepresentationMap", MappingOrigin=map_origin, MappedRepresentation=shape_rep)
|
||||||
|
|
||||||
|
wall_type = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWallType", name="LibWallType")
|
||||||
|
wall_type.RepresentationMaps = [rep_map]
|
||||||
|
|
||||||
|
return f, wall_type
|
||||||
|
|
||||||
|
|
||||||
class TestRenderBasic:
|
class TestRenderBasic:
|
||||||
def test_returns_png_bytes(self, model_with_geometry):
|
def test_returns_png_bytes(self, model_with_geometry):
|
||||||
result = render(model_with_geometry)
|
result = render(model_with_geometry)
|
||||||
@@ -115,6 +144,44 @@ class TestRenderHighlight:
|
|||||||
assert result[:4] == PNG_MAGIC
|
assert result[:4] == PNG_MAGIC
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenderTypes:
|
||||||
|
def test_render_type_by_selector(self, library_with_type):
|
||||||
|
"""Selecting a type class renders its RepresentationMap geometry."""
|
||||||
|
model, wall_type = library_with_type
|
||||||
|
result = render(model, selector="IfcWallType")
|
||||||
|
assert result[:4] == PNG_MAGIC
|
||||||
|
|
||||||
|
def test_render_type_by_element_id(self, library_with_type):
|
||||||
|
"""Passing a type step-ID via element_ids renders it highlighted."""
|
||||||
|
model, wall_type = library_with_type
|
||||||
|
result = render(model, element_ids=[wall_type.id()])
|
||||||
|
assert result[:4] == PNG_MAGIC
|
||||||
|
|
||||||
|
def test_original_model_unmodified(self, library_with_type):
|
||||||
|
"""Rendering a type must not add entities to the original model."""
|
||||||
|
model, wall_type = library_with_type
|
||||||
|
entity_count_before = len(list(model))
|
||||||
|
render(model, selector="IfcWallType")
|
||||||
|
assert len(list(model)) == entity_count_before
|
||||||
|
|
||||||
|
def test_make_type_occurrence_no_rep_maps(self, library_with_type):
|
||||||
|
"""_make_type_occurrence returns None for a type with no RepresentationMaps."""
|
||||||
|
model, _ = library_with_type
|
||||||
|
bare_type = ifcopenshell.api.root.create_entity(model, ifc_class="IfcWallType", name="Bare")
|
||||||
|
assert _make_type_occurrence(model, bare_type) is None
|
||||||
|
|
||||||
|
def test_type_without_rep_maps_raises(self):
|
||||||
|
"""Selecting a type that has no RepresentationMaps raises ValueError."""
|
||||||
|
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]
|
||||||
|
ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="P")
|
||||||
|
ifcopenshell.api.unit.assign_unit(f)
|
||||||
|
ifcopenshell.api.root.create_entity(f, ifc_class="IfcWallType", name="Bare")
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
render(f, selector="IfcWallType")
|
||||||
|
|
||||||
|
|
||||||
class TestRenderNoGeometry:
|
class TestRenderNoGeometry:
|
||||||
def test_no_geometry_raises(self):
|
def test_no_geometry_raises(self):
|
||||||
"""A model without geometry representations raises ValueError."""
|
"""A model without geometry representations raises ValueError."""
|
||||||
|
|||||||
Reference in New Issue
Block a user