diff --git a/src/ifcmcp/ifcmcp/core.py b/src/ifcmcp/ifcmcp/core.py index 9442090cb9..2d9a52b57f 100644 --- a/src/ifcmcp/ifcmcp/core.py +++ b/src/ifcmcp/ifcmcp/core.py @@ -13,7 +13,7 @@ from ifcedit.run import run_api from ifcquery import clash as clash_mod from ifcquery import contexts as contexts_mod from ifcquery import cost as cost_mod -from ifcquery import info, materials as materials_mod, relations, render as render_mod, schedule, schema, select, summary, tree +from ifcquery import info, materials as materials_mod, plot as plot_mod, relations, render as render_mod, schedule, schema, select, summary, tree from ifcquery import validate as validate_mod @@ -360,6 +360,48 @@ 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_plot( + self, + selector: str = "", + element_ids: list[int] | None = None, + view: str = "floorplan", + width_mm: float = 297.0, + height_mm: float = 420.0, + scale: float = 1.0 / 100.0, + png_width: int = 1024, + png_height: int = 1024, + ) -> bytes: + """Generate a 2D technical drawing (floor plan, elevation, or section) and return PNG bytes. + + Uses ifcopenshell.draw to produce SVG output which is rasterised to PNG via CairoSVG. + + :param selector: ifcopenshell selector to restrict plotted elements + (e.g. ``'IfcWall'``). Omit to plot the whole model. + :param element_ids: Step IDs of elements to highlight. Other elements + are faded to 10% opacity so the subject stands out. + :param view: Drawing view — ``floorplan`` (default), ``elevation``, + ``section``, or ``auto``. + :param width_mm: Paper width in mm (default 297 = A4). + :param height_mm: Paper height in mm (default 420 = A4). + :param scale: Model-to-paper scale ratio (default 0.01 = 1:100). + :param png_width: Raster output width in pixels (default 1024). + :param png_height: Raster output height in pixels (default 1024). + :return: PNG image as raw bytes. + """ + model = self._require_model() + return plot_mod.plot( + model, + output_format="png", + selector=selector if selector else None, + element_ids=element_ids, + view=view, + width_mm=width_mm, + height_mm=height_mm, + scale=scale, + png_width=png_width, + png_height=png_height, + ) + def ifc_render( self, selector: str = "", diff --git a/src/ifcmcp/ifcmcp/server.py b/src/ifcmcp/ifcmcp/server.py index 84764ec1e9..13c43d55a6 100644 --- a/src/ifcmcp/ifcmcp/server.py +++ b/src/ifcmcp/ifcmcp/server.py @@ -140,6 +140,46 @@ def build_server() -> Any: def ifc_shape(method: str, params: str = "{}") -> dict: return session.ifc_shape(method=method, params=params) + @server.tool(structured_output=False) + def ifc_plot( + selector: str = "", + element_ids: list[int] | None = None, + view: str = "floorplan", + width_mm: float = 297.0, + height_mm: float = 420.0, + scale: float = 1.0 / 100.0, + png_width: int = 1024, + png_height: int = 1024, + ) -> list[ImageContent]: + """Generate a 2D technical drawing of the loaded IFC model. + + Returns an inline image (floor plan, elevation, or section) that the + LLM can inspect to understand the 2D layout of the model. + + :param selector: ifcopenshell selector to restrict plotted elements + (e.g. ``'IfcWall'``). Omit to plot the whole model. + :param element_ids: Step IDs of elements to highlight. Other elements + are faded so the subject stands out. + :param view: Drawing view — ``floorplan`` (default), ``elevation``, + ``section``, or ``auto``. + :param width_mm: Paper width in mm (default 297 = A4 landscape width). + :param height_mm: Paper height in mm (default 420 = A4 landscape height). + :param scale: Model-to-paper scale ratio (default 0.01 = 1:100). + :param png_width: Raster output width in pixels (default 1024). + :param png_height: Raster output height in pixels (default 1024). + """ + png_bytes = session.ifc_plot( + selector=selector, + element_ids=element_ids, + view=view, + width_mm=width_mm, + height_mm=height_mm, + scale=scale, + png_width=png_width, + png_height=png_height, + ) + return [ImageContent(type="image", data=base64.b64encode(png_bytes).decode(), mimeType="image/png")] + @server.tool(structured_output=False) def ifc_render( selector: str = "", diff --git a/src/ifcquery/ifcquery/__main__.py b/src/ifcquery/ifcquery/__main__.py index e998e38546..2bc7dfefd3 100644 --- a/src/ifcquery/ifcquery/__main__.py +++ b/src/ifcquery/ifcquery/__main__.py @@ -296,29 +296,40 @@ def main(): print(f"Error: Invalid element ID(s): {args.element}", file=sys.stderr) sys.exit(1) - # Choose default output extension based on out-format + try: + result = plot.plot( + model, + selector=args.selector or None, + element_ids=element_ids, + view=args.view, + width_mm=args.width_mm, + height_mm=args.height_mm, + scale=args.scale, + output_format=args.out_format, + ) + 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) + + if args.out_format == "base64": + # result is a dict; serialise to stdout so callers can consume it + print(format_output(result, args.output_format)) + return + + # svg or png: write to a file base = os.path.splitext(args.ifc_file)[0] if args.out_format == "svg": out_path = args.output or (base + ".svg") - elif args.out_format == "png": - out_path = args.output or (base + ".png") else: - out_path = args.output # unused; base64 prints to stdout via format_output + out_path = args.output or (base + ".png") - png_bytes = plot.plot( - model, - selector=args.selector or None, - element_ids=element_ids, - view=args.view, - width_mm=args.width_mm, - height_mm=args.height_mm, - scale=args.scale, - output_format=args.out_format - ) with open(out_path, "wb") as f: - f.write(png_bytes) + f.write(result) - print(f"Saved render to {out_path}", file=sys.stderr) + print(f"Saved drawing to {out_path}", file=sys.stderr) return diff --git a/src/ifcquery/ifcquery/plot.py b/src/ifcquery/ifcquery/plot.py index fd2f23c73d..0deee7baca 100644 --- a/src/ifcquery/ifcquery/plot.py +++ b/src/ifcquery/ifcquery/plot.py @@ -18,6 +18,7 @@ from __future__ import annotations +import base64 from io import BytesIO import os from typing import Any @@ -61,7 +62,10 @@ def _escape_css_attr(name: str) -> str: def _highlight_css_from_ids(model: ifcopenshell.file, element_ids: list[int]) -> str: guids: list[str] = [] for sid in element_ids: - e = model.by_id(int(sid)) + try: + e = model.by_id(int(sid)) + except RuntimeError: + continue if e is None: continue gid = getattr(e, "GlobalId", None) @@ -219,9 +223,12 @@ def plot( if not _HAS_CAIROSVG: raise ImportError("CairoSVG is not installed. Install with: pip install cairosvg") + svgs = list(svg_split(BytesIO(svg_bytes))) + if not svgs: + raise ValueError("No plan geometry found; the model may lack 2D annotation representations for the requested view.") + composite = None png_bytes = None - svgs = list(svg_split(BytesIO(svg_bytes))) for i, svgb in enumerate(svgs): png_bytes = cairosvg.svg2png(bytestring=svgb, output_width=png_width, output_height=png_height) if len(svgs) == 1: @@ -240,4 +247,13 @@ def plot( composite.save(b, 'png') png_bytes = b.getvalue() + if output_format == "base64": + return { + "mime": "image/png", + "png_b64": base64.b64encode(png_bytes).decode(), + "width": png_width, + "height": png_height, + "view": view, + } + return png_bytes diff --git a/src/ifcquery/tests/test_plot.py b/src/ifcquery/tests/test_plot.py new file mode 100644 index 0000000000..1794546fab --- /dev/null +++ b/src/ifcquery/tests/test_plot.py @@ -0,0 +1,264 @@ +from __future__ import annotations + +import base64 +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 pytest + +from ifcquery.plot import plot, _highlight_css_from_ids + +try: + import ifcopenshell.draw # noqa: F401 + + HAS_DRAW = True +except ImportError: + HAS_DRAW = False + +try: + import cairosvg # noqa: F401 + + HAS_CAIROSVG = True +except ImportError: + HAS_CAIROSVG = False + +pytestmark = pytest.mark.skipif(not HAS_DRAW, reason="ifcopenshell.draw not available") + +SVG_MAGIC = b" elements, PNG/base64 should raise a clear error.""" + + def test_empty_drawing_png_raises(self, model_no_plan): + """PNG format raises ValueError (not silently returns None) for empty drawings.""" + model, _ = model_no_plan + svg = plot(model, output_format="svg") + has_groups = b"" in svg + if not has_groups: + pytest.raises(ValueError, plot, model, output_format="png") + else: + pytest.skip("Model produced non-empty SVG — empty path not triggered") + + def test_empty_drawing_base64_raises(self, model_no_plan): + """base64 format raises ValueError (not silently returns None) for empty drawings.""" + model, _ = model_no_plan + svg = plot(model, output_format="svg") + has_groups = b"" in svg + if not has_groups: + pytest.raises(ValueError, plot, model, output_format="base64") + else: + pytest.skip("Model produced non-empty SVG — empty path not triggered") + + +@pytest.mark.skipif(not HAS_CAIROSVG, reason="cairosvg not installed") +class TestPlotPNG: + """PNG and base64 require cairosvg.""" + + def test_png_returns_bytes_or_raises_on_empty(self, model_with_annotations): + model, _ = model_with_annotations + svg = plot(model, output_format="svg") + has_groups = b"" in svg + if has_groups: + result = plot(model, output_format="png") + assert isinstance(result, bytes) + assert result[:4] == PNG_MAGIC + else: + with pytest.raises(ValueError, match="No plan geometry"): + plot(model, output_format="png") + + def test_base64_returns_dict(self, model_with_annotations): + model, _ = model_with_annotations + svg = plot(model, output_format="svg") + has_groups = b"" in svg + if has_groups: + result = plot(model, output_format="base64") + assert isinstance(result, dict) + assert result["mime"] == "image/png" + assert "png_b64" in result + assert "width" in result + assert "height" in result + assert "view" in result + # Verify the base64 is valid PNG + decoded = base64.b64decode(result["png_b64"]) + assert decoded[:4] == PNG_MAGIC + else: + with pytest.raises(ValueError, match="No plan geometry"): + plot(model, output_format="base64") + + def test_base64_view_field_matches_requested(self, model_with_annotations): + model, _ = model_with_annotations + svg = plot(model, output_format="svg") + has_groups = b"" in svg + if not has_groups: + pytest.skip("Model produces empty SVG") + result = plot(model, output_format="base64", view="floorplan") + assert result["view"] == "floorplan" + + def test_png_custom_size(self, model_with_annotations): + model, _ = model_with_annotations + svg = plot(model, output_format="svg") + has_groups = b"" in svg + if not has_groups: + pytest.skip("Model produces empty SVG") + result = plot(model, output_format="png", png_width=512, png_height=512) + assert isinstance(result, bytes) + assert result[:4] == PNG_MAGIC + + +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_plot_svg_writes_file(self, model_with_annotations): + model, _ = model_with_annotations + ifc_path = self._ifc_path(model) + out_path = ifc_path.replace(".ifc", "_out.svg") + try: + result = subprocess.run( + [sys.executable, "-m", "ifcquery", ifc_path, "plot", "--out-format", "svg", "-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(5) == SVG_MAGIC + finally: + for path in (ifc_path, out_path): + try: + os.unlink(path) + except OSError: + pass + + @pytest.mark.skipif(not HAS_CAIROSVG, reason="cairosvg not installed") + def test_plot_base64_prints_json(self, model_with_annotations): + """base64 format prints JSON to stdout instead of writing a file.""" + model, _ = model_with_annotations + ifc_path = self._ifc_path(model) + try: + # First check if the model would produce geometry + svg = plot(model, output_format="svg") + has_groups = b"" in svg + if not has_groups: + pytest.skip("Model produces empty SVG — base64 would raise ValueError") + + result = subprocess.run( + [sys.executable, "-m", "ifcquery", ifc_path, "plot", "--out-format", "base64"], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + # Output should be JSON (not an error) and contain base64 key + assert "png_b64" in result.stdout + finally: + try: + os.unlink(ifc_path) + except OSError: + pass