Add ifcmcp ifc_render tool and ifcquery render subcommand

Renders IFC model geometry to a PNG image using pyvista (off_screen).

ifcquery CLI:
  ifcquery <file> render [-o out.png] [--selector QUERY]
                         [--element ID[,ID...]] [--view iso|top|south|north|east|west]

ifcmcp MCP tool:
  ifc_render(selector, element_ids, view) -> list[ImageContent]
  Returns base64-encoded PNG as an MCP ImageContent block so the agent
  can inspect the model geometry inline.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Bruno Postle
2026-03-01 13:21:13 +00:00
parent f4d7fda255
commit 66f0b71c65
5 changed files with 524 additions and 2 deletions
+49 -1
View File
@@ -12,7 +12,7 @@ from ifcedit.quantify import run_quantify
from ifcedit.run import run_api
from ifcquery import clash as clash_mod
from ifcquery import cost as cost_mod
from ifcquery import info, relations, schedule, schema, select, summary, tree
from ifcquery import info, relations, render as render_mod, schedule, schema, select, summary, tree
from ifcquery import validate as validate_mod
@@ -211,6 +211,30 @@ class IfcSession:
"""Return IFC class documentation for entity_type using the model's schema version."""
return schema.schema(self._require_model(), entity_type)
def ifc_render(
self,
selector: str = "",
element_ids: list[int] | None = None,
view: str = "iso",
) -> bytes:
"""Render the loaded model to a PNG image and return raw bytes.
:param selector: ifcopenshell selector to restrict rendered elements
(e.g. ``'IfcWall'``). Omit to render the whole model.
:param element_ids: Step IDs of elements to highlight. Other elements
are rendered in translucent grey.
:param view: Camera angle: ``iso``, ``top``, ``south``, ``north``,
``east``, or ``west``.
:return: PNG image as raw bytes.
"""
model = self._require_model()
return render_mod.render(
model,
selector=selector if selector else None,
element_ids=element_ids,
view=view,
)
def ifc_quantify(self, rule: str, selector: str = "") -> dict[str, Any]:
"""Run quantity take-off on the model using the named rule.
@@ -396,4 +420,28 @@ class IfcSession:
"additionalProperties": False,
},
},
{
"type": "function",
"name": "ifc_render",
"description": (
"Render the loaded IFC model to a PNG image for visual inspection. "
"Use selector to restrict which elements are rendered (e.g. a single storey). "
"Use element_ids to highlight elements against a greyed-out background. "
"Returns base64-encoded PNG bytes."
),
"parameters": {
"type": "object",
"properties": {
"selector": {"type": "string", "description": "ifcopenshell selector (default: whole model)"},
"element_ids": {"type": "array", "items": {"type": "integer"}, "description": "Step IDs of elements to highlight"},
"view": {
"type": "string",
"enum": ["iso", "top", "south", "north", "east", "west"],
"description": "Camera angle (default: iso)",
},
},
"required": [],
"additionalProperties": False,
},
},
]
+25
View File
@@ -1,14 +1,17 @@
# This file was generated with the assistance of an AI coding tool.
from __future__ import annotations
import base64
from typing import Any
from ifcmcp.core import IfcSession
try:
from mcp.server.fastmcp import FastMCP # type: ignore
from mcp.types import ImageContent # type: ignore
except Exception: # pragma: no cover
FastMCP = None # type: ignore
ImageContent = None # type: ignore
def build_server() -> Any:
@@ -116,4 +119,26 @@ def build_server() -> Any:
def ifc_quantify(rule: str, selector: str = "") -> dict[str, Any]:
return session.ifc_quantify(rule=rule, selector=selector)
@server.tool(structured_output=False)
def ifc_render(
selector: str = "",
element_ids: list[int] | None = None,
view: str = "iso",
) -> list[ImageContent]:
"""Render the loaded IFC model to a PNG image.
Returns an inline image the LLM can inspect to understand the spatial
layout of the model or a specific element in context.
:param selector: ifcopenshell selector to restrict rendered elements
(e.g. ``'IfcWall'``, ``'IfcBuildingStorey[Name="0"]'``).
Omit to render the whole model.
:param element_ids: Step IDs of elements to highlight. Other elements
are rendered in translucent grey so the subject stands out.
:param view: Camera angle — ``iso`` (default), ``top``, ``south``,
``north``, ``east``, or ``west``.
"""
png_bytes = session.ifc_render(selector=selector, element_ids=element_ids, view=view)
return [ImageContent(type="image", data=base64.b64encode(png_bytes).decode(), mimeType="image/png")]
return server
+46 -1
View File
@@ -21,13 +21,14 @@ from __future__ import annotations
import argparse
import json
import os
import sys
import ifcopenshell
from ifcquery import clash as clash_mod
from ifcquery import cost as cost_mod
from ifcquery import info, relations, schedule, schema, select, summary, tree
from ifcquery import info, relations, render as render_mod, schedule, schema, select, summary, tree
from ifcquery import validate as validate_mod
@@ -123,6 +124,24 @@ def main():
schema_parser = subparsers.add_parser("schema", help="IFC class documentation")
schema_parser.add_argument("entity_type", help="IFC entity type (e.g. IfcWall)")
render_parser = subparsers.add_parser("render", help="Render model geometry to a PNG image")
render_parser.add_argument(
"-o", "--output", default="", metavar="FILE", help="Output PNG path (default: <ifc_file>.png)"
)
render_parser.add_argument(
"--selector", default="", metavar="QUERY", help="ifcopenshell selector to restrict rendered elements"
)
render_parser.add_argument(
"--element", default="", metavar="ID[,ID...]",
help="Comma-separated step IDs of elements to highlight (rest rendered in grey)"
)
render_parser.add_argument(
"--view",
choices=render_mod.VIEWS,
default="iso",
help="Camera angle (default: iso)",
)
args = parser.parse_args()
try:
@@ -187,6 +206,32 @@ def main():
result = cost_mod.cost(model, max_depth=args.depth)
elif args.command == "schema":
result = schema.schema(model, args.entity_type)
elif args.command == "render":
element_ids = None
if args.element:
try:
element_ids = [parse_element_id(part) for part in args.element.split(",")]
except ValueError:
print(f"Error: Invalid element ID(s): {args.element}", file=sys.stderr)
sys.exit(1)
out_path = args.output or (os.path.splitext(args.ifc_file)[0] + ".png")
try:
png_bytes = render_mod.render(
model,
selector=args.selector or None,
element_ids=element_ids,
view=args.view,
)
except ImportError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
except ValueError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
with open(out_path, "wb") as f:
f.write(png_bytes)
print(f"Saved render to {out_path}", file=sys.stderr)
return
print(format_output(result, args.output_format))
+183
View File
@@ -0,0 +1,183 @@
# IfcQuery - IFC model interrogation CLI
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
#
# This file is part of IfcQuery.
#
# IfcQuery is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcQuery is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcQuery. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
import multiprocessing
import os
import tempfile
import ifcopenshell
import ifcopenshell.geom
import ifcopenshell.util.selector
try:
import numpy as np
import pyvista as pv
_HAS_PYVISTA = True
except ImportError:
_HAS_PYVISTA = False
VIEWS = ("iso", "top", "south", "north", "east", "west")
def _apply_view(plotter: "pv.Plotter", view: str) -> None:
"""Set the camera to the requested named view. Z is up (IFC convention)."""
if view == "top":
plotter.view_xy()
elif view == "south":
# Camera at -Y looking toward +Y (south face of building)
plotter.view_xz(negative=True)
elif view == "north":
plotter.view_xz(negative=False)
elif view == "east":
plotter.view_yz(negative=False)
elif view == "west":
plotter.view_yz(negative=True)
else:
plotter.view_isometric()
# Ensure Z is world up for elevation views
if view not in ("top",):
plotter.camera.up = (0, 0, 1)
def _add_shape(
shape: object,
plotter: "pv.Plotter",
highlight_ids: frozenset[int] | None,
) -> None:
"""Triangulate and add a geometry shape to the plotter."""
geom = shape.geometry
verts = np.array(geom.verts, dtype=float).reshape(-1, 3)
if verts.size == 0:
return
faces = np.array(geom.faces, dtype=int).reshape(-1, 3)
material_ids = np.array(geom.material_ids, dtype=int)
is_subject = highlight_ids is not None and shape.product.id() in highlight_ids
for midx, mat in enumerate(geom.materials):
tri_mask = material_ids == midx
if not np.any(tri_mask):
continue
sub_faces = faces[tri_mask]
faces_pv = np.hstack([np.full((sub_faces.shape[0], 1), 3, dtype=int), sub_faces]).ravel()
mesh = pv.PolyData(verts, faces_pv)
if highlight_ids is not None and not is_subject:
color = (180, 180, 180)
opacity = 0.10
else:
diffuse = np.clip(np.array(mat.diffuse.components), 0.0, 1.0)
color = tuple((diffuse * 255).astype(np.uint8))
transparency = mat.transparency if mat.transparency == mat.transparency else 0.0
opacity = float(np.clip(1.0 - transparency, 0.0, 1.0))
plotter.add_mesh(mesh, color=color, opacity=opacity, show_edges=False)
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.
: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.background_color = "white"
while True:
_add_shape(iterator.get(), plotter, highlight_ids=frozenset(element_ids) if element_ids else None)
if not iterator.next():
break
plotter.reset_camera()
_apply_view(plotter, view)
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".png")
os.close(tmp_fd)
try:
plotter.show(screenshot=tmp_path, auto_close=True)
with open(tmp_path, "rb") as f:
return f.read()
finally:
try:
os.unlink(tmp_path)
except OSError:
pass
+221
View File
@@ -0,0 +1,221 @@
# This file was generated with the assistance of an AI coding tool.
import os
import subprocess
import sys
import tempfile
import ifcopenshell
import ifcopenshell.api.aggregate
import ifcopenshell.api.context
import ifcopenshell.api.geometry
import ifcopenshell.api.owner.settings
import ifcopenshell.api.project
import ifcopenshell.api.root
import ifcopenshell.api.spatial
import ifcopenshell.api.unit
import numpy as np
import pytest
from ifcquery.render import render
try:
import pyvista # noqa: F401
HAS_PYVISTA = True
except ImportError:
HAS_PYVISTA = False
pytestmark = pytest.mark.skipif(not HAS_PYVISTA, reason="pyvista not installed")
PNG_MAGIC = b"\x89PNG"
@pytest.fixture
def model_with_geometry():
"""Create an IFC4 model with walls that have geometric representations."""
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="TestProject")
ifcopenshell.api.unit.assign_unit(f)
site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="TestSite")
building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="TestBuilding")
storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="Ground Floor")
ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project)
ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site)
ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=building)
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
)
wall1 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall001")
rep1 = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=5, height=3, thickness=0.2)
ifcopenshell.api.geometry.assign_representation(f, product=wall1, representation=rep1)
ifcopenshell.api.spatial.assign_container(f, products=[wall1], relating_structure=storey)
wall2 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall002")
rep2 = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=4, height=3, thickness=0.2)
ifcopenshell.api.geometry.assign_representation(f, product=wall2, representation=rep2)
ifcopenshell.api.spatial.assign_container(f, products=[wall2], relating_structure=storey)
matrix2 = np.eye(4)
matrix2[1, 3] = 3.0
ifcopenshell.api.geometry.edit_object_placement(f, product=wall2, matrix=matrix2)
return f
class TestRenderBasic:
def test_returns_png_bytes(self, model_with_geometry):
result = render(model_with_geometry)
assert isinstance(result, bytes)
assert result[:4] == PNG_MAGIC
def test_iso_view(self, model_with_geometry):
result = render(model_with_geometry, view="iso")
assert result[:4] == PNG_MAGIC
def test_top_view(self, model_with_geometry):
result = render(model_with_geometry, view="top")
assert result[:4] == PNG_MAGIC
def test_south_view(self, model_with_geometry):
result = render(model_with_geometry, view="south")
assert result[:4] == PNG_MAGIC
def test_unknown_view_falls_back_to_iso(self, model_with_geometry):
# Unknown view strings fall through to isometric
result = render(model_with_geometry, view="diagonal")
assert result[:4] == PNG_MAGIC
class TestRenderSelector:
def test_selector_restricts_elements(self, model_with_geometry):
result = render(model_with_geometry, selector="IfcWall")
assert result[:4] == PNG_MAGIC
def test_selector_no_match_raises(self, model_with_geometry):
with pytest.raises(ValueError, match="matched no elements"):
render(model_with_geometry, selector="IfcDoor")
class TestRenderHighlight:
def test_highlight_single_element(self, model_with_geometry):
wall = model_with_geometry.by_type("IfcWall")[0]
result = render(model_with_geometry, element_ids=[wall.id()])
assert result[:4] == PNG_MAGIC
def test_highlight_multiple_elements(self, model_with_geometry):
walls = model_with_geometry.by_type("IfcWall")
result = render(model_with_geometry, element_ids=[w.id() for w in walls])
assert result[:4] == PNG_MAGIC
class TestRenderNoGeometry:
def test_no_geometry_raises(self):
"""A model without geometry representations 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]
project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="P")
ifcopenshell.api.unit.assign_unit(f)
site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="S")
building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="B")
storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="GF")
ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project)
ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site)
ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=building)
wall = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wallless")
ifcopenshell.api.spatial.assign_container(f, products=[wall], relating_structure=storey)
with pytest.raises(ValueError, match="No renderable geometry"):
render(f)
class TestCLI:
@staticmethod
def _ifc_path(model):
f = tempfile.NamedTemporaryFile(suffix=".ifc", delete=False)
model.write(f.name)
f.close()
return f.name
def test_render_writes_png(self, model_with_geometry):
ifc_path = self._ifc_path(model_with_geometry)
out_path = ifc_path.replace(".ifc", "_out.png")
try:
result = subprocess.run(
[sys.executable, "-m", "ifcquery", ifc_path, "render", "-o", out_path],
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr
assert os.path.exists(out_path)
with open(out_path, "rb") as f:
assert f.read(4) == PNG_MAGIC
finally:
for path in (ifc_path, out_path):
try:
os.unlink(path)
except OSError:
pass
def test_render_default_output_path(self, model_with_geometry):
ifc_path = self._ifc_path(model_with_geometry)
expected_png = ifc_path.replace(".ifc", ".png")
try:
result = subprocess.run(
[sys.executable, "-m", "ifcquery", ifc_path, "render"],
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr
assert os.path.exists(expected_png)
finally:
for path in (ifc_path, expected_png):
try:
os.unlink(path)
except OSError:
pass
def test_render_with_selector(self, model_with_geometry):
ifc_path = self._ifc_path(model_with_geometry)
out_path = ifc_path.replace(".ifc", "_sel.png")
try:
result = subprocess.run(
[sys.executable, "-m", "ifcquery", ifc_path, "render", "-o", out_path, "--selector", "IfcWall"],
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr
with open(out_path, "rb") as f:
assert f.read(4) == PNG_MAGIC
finally:
for path in (ifc_path, out_path):
try:
os.unlink(path)
except OSError:
pass
def test_render_with_view(self, model_with_geometry):
ifc_path = self._ifc_path(model_with_geometry)
out_path = ifc_path.replace(".ifc", "_top.png")
try:
result = subprocess.run(
[sys.executable, "-m", "ifcquery", ifc_path, "render", "-o", out_path, "--view", "top"],
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr
with open(out_path, "rb") as f:
assert f.read(4) == PNG_MAGIC
finally:
for path in (ifc_path, out_path):
try:
os.unlink(path)
except OSError:
pass