mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-19 22:50:21 +00:00
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:
@@ -12,7 +12,7 @@ from ifcedit.quantify import run_quantify
|
|||||||
from ifcedit.run import run_api
|
from ifcedit.run import run_api
|
||||||
from ifcquery import clash as clash_mod
|
from ifcquery import clash as clash_mod
|
||||||
from ifcquery import cost as cost_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
|
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 IFC class documentation for entity_type using the model's schema version."""
|
||||||
return schema.schema(self._require_model(), entity_type)
|
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]:
|
def ifc_quantify(self, rule: str, selector: str = "") -> dict[str, Any]:
|
||||||
"""Run quantity take-off on the model using the named rule.
|
"""Run quantity take-off on the model using the named rule.
|
||||||
|
|
||||||
@@ -396,4 +420,28 @@ class IfcSession:
|
|||||||
"additionalProperties": False,
|
"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,
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
@@ -1,14 +1,17 @@
|
|||||||
# This file was generated with the assistance of an AI coding tool.
|
# This file was generated with the assistance of an AI coding tool.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from ifcmcp.core import IfcSession
|
from ifcmcp.core import IfcSession
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from mcp.server.fastmcp import FastMCP # type: ignore
|
from mcp.server.fastmcp import FastMCP # type: ignore
|
||||||
|
from mcp.types import ImageContent # type: ignore
|
||||||
except Exception: # pragma: no cover
|
except Exception: # pragma: no cover
|
||||||
FastMCP = None # type: ignore
|
FastMCP = None # type: ignore
|
||||||
|
ImageContent = None # type: ignore
|
||||||
|
|
||||||
|
|
||||||
def build_server() -> Any:
|
def build_server() -> Any:
|
||||||
@@ -116,4 +119,26 @@ def build_server() -> Any:
|
|||||||
def ifc_quantify(rule: str, selector: str = "") -> dict[str, Any]:
|
def ifc_quantify(rule: str, selector: str = "") -> dict[str, Any]:
|
||||||
return session.ifc_quantify(rule=rule, selector=selector)
|
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
|
return server
|
||||||
@@ -21,13 +21,14 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
import ifcopenshell
|
import ifcopenshell
|
||||||
|
|
||||||
from ifcquery import clash as clash_mod
|
from ifcquery import clash as clash_mod
|
||||||
from ifcquery import cost as cost_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
|
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 = subparsers.add_parser("schema", help="IFC class documentation")
|
||||||
schema_parser.add_argument("entity_type", help="IFC entity type (e.g. IfcWall)")
|
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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -187,6 +206,32 @@ def main():
|
|||||||
result = cost_mod.cost(model, max_depth=args.depth)
|
result = cost_mod.cost(model, max_depth=args.depth)
|
||||||
elif args.command == "schema":
|
elif args.command == "schema":
|
||||||
result = schema.schema(model, args.entity_type)
|
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))
|
print(format_output(result, args.output_format))
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user