diff --git a/src/ifcquery/ifcquery/__main__.py b/src/ifcquery/ifcquery/__main__.py index ef593022c8..351edbfc36 100644 --- a/src/ifcquery/ifcquery/__main__.py +++ b/src/ifcquery/ifcquery/__main__.py @@ -28,7 +28,7 @@ import ifcopenshell from ifcquery import clash as clash_mod from ifcquery import cost as cost_mod -from ifcquery import info, relations, render as render_mod, schedule, schema, select, summary, tree +from ifcquery import info, relations, render as render_mod, schedule, schema, select, summary, tree, plot from ifcquery import validate as validate_mod @@ -142,6 +142,52 @@ def main(): help="Camera angle (default: iso)", ) + plot_parser = subparsers.add_parser("plot", help="Plot model drawing (SVG via ifcopenshell.draw; optional PNG via CairoSVG)") + plot_parser.add_argument( + "-o", "--output", default="", metavar="FILE", + help="Output file path. Default depends on --out-format: .svg/.png" + ) + plot_parser.add_argument( + "--out-format", + choices=["svg", "png", "base64"], + default="png", + help="Output format: svg (write SVG), png (write PNG), base64 (print base64 in JSON/text). Default: png", + ) + plot_parser.add_argument( + "--selector", default="", metavar="QUERY", + help="ifcopenshell selector to restrict plotted elements" + ) + plot_parser.add_argument( + "--element", default="", metavar="ID[,ID...]", + help="Comma-separated step IDs of elements to highlight" + ) + plot_parser.add_argument( + "--view", + choices=getattr(plot, "VIEWS", ("floorplan", "elevation", "section", "auto")), + default="floorplan", + help="Drawing view (default: floorplan)", + ) + plot_parser.add_argument( + "--width-mm", type=float, default=297.0, metavar="MM", + help="Paper width in mm (default: 297)", + ) + plot_parser.add_argument( + "--height-mm", type=float, default=420.0, metavar="MM", + help="Paper height in mm (default: 420)", + ) + plot_parser.add_argument( + "--scale", type=float, default=1.0 / 100.0, metavar="S", + help="Model-to-paper scale (default: 0.01 = 1:100)", + ) + plot_parser.add_argument( + "--png-width", type=int, default=1024, metavar="PX", + help="PNG width in pixels (default: 1024)", + ) + plot_parser.add_argument( + "--png-height", type=int, default=1024, metavar="PX", + help="PNG height in pixels (default: 1024)", + ) + args = parser.parse_args() try: @@ -232,6 +278,40 @@ def main(): f.write(png_bytes) print(f"Saved render to {out_path}", file=sys.stderr) return + elif args.command == "plot": + 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) + + # Choose default output extension based on out-format + 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 + + 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) + + print(f"Saved render to {out_path}", file=sys.stderr) + return + print(format_output(result, args.output_format)) diff --git a/src/ifcquery/ifcquery/plot.py b/src/ifcquery/ifcquery/plot.py new file mode 100644 index 0000000000..6fdff2664f --- /dev/null +++ b/src/ifcquery/ifcquery/plot.py @@ -0,0 +1,238 @@ +# IfcQuery - IFC model interrogation CLI +# Copyright (C) 2026 Bruno Postle +# +# 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 . + +from __future__ import annotations + +from io import BytesIO +import os +from typing import Any + +import ifcopenshell +import ifcopenshell.geom +import ifcopenshell.util.selector +import ifcopenshell.draw + +from xml.etree.ElementTree import ElementTree, Element, SubElement, register_namespace + +try: + import cairosvg # type: ignore + + _HAS_CAIROSVG = True +except Exception: + _HAS_CAIROSVG = False + + +try: + from PIL import Image # type: ignore + + _HAS_PIL = True +except Exception: + _HAS_PIL = False + +VIEWS = ("floorplan", "elevation", "section", "auto") +OUTPUT_FORMATS = ("svg", "png", "base64") + + +def _escape_css_attr(name: str) -> str: + # CSS attribute selectors must escape ':' (e.g. ifc:guid -> ifc\:guid) + return name.replace(":", "\\:") + + +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)) + if e is None: + continue + gid = getattr(e, "GlobalId", None) + if isinstance(gid, str) and gid: + guids.append(gid) + + if not guids: + return "" + + attr = _escape_css_attr("ifc:guid") + + css = [ + "/* Auto-highlight injected by ifcquery.plot */", + f'[{attr}] path {{ opacity: 0.10; }}', + f'[{attr}] text {{ opacity: 0.25; }}', + ] + for gid in guids: + css.append(f'[{attr}="{gid}"] path {{ opacity: 1.0; stroke: #d00; stroke-width: 0.25; }}') + css.append(f'[{attr}="{gid}"] text {{ opacity: 1.0; fill: #d00; }}') + return "\n".join(css) + "\n" + + +def _make_filtered_iterator(model: ifcopenshell.file, include_elements: list[Any]) -> ifcopenshell.geom.iterator: + # Avoid multiprocessing in WASM; os.cpu_count is good enough. + n_threads = os.cpu_count() or 1 + + # These flags mirror the defaults used by ifcopenshell.draw in v0.8.x. + geom_settings = ifcopenshell.geom.settings( + REORIENT_SHELLS=False, + ELEMENT_HIERARCHY=True, + ) + + # IfcOpenShell wrapper constants may live in different places across builds. + wrapper = getattr(ifcopenshell, "ifcopenshell_wrapper", None) + if wrapper is not None: + try: + geom_settings.set("iterator-output", wrapper.NATIVE) + except Exception: + pass + try: + geom_settings.set("apply-default-materials", True) + except Exception: + pass + try: + geom_settings.set("dimensionality", wrapper.SURFACES_AND_SOLIDS) + except Exception: + pass + + return ifcopenshell.geom.iterator(geom_settings, model, n_threads, include=include_elements) + + +def plot( + model: ifcopenshell.file, + *, + output_format: str = "png", + selector: str | None = None, + element_ids: list[int] | None = None, + view: str = "floorplan", + # SVG / page sizing (draw works in mm coordinates) + width_mm: float = 297.0, + height_mm: float = 420.0, + scale: float = 1.0 / 100.0, + merge_projection: bool = True, + # PNG sizing (only for output_format png/base64) + png_width: int = 1024, + png_height: int = 1024, +) -> bytes | dict[str, Any]: + """ + Plot IFC model as SVG (via ifcopenshell.draw) or PNG/base64 (via CairoSVG). + + Args: + model: In-memory IFC model. + output_format: 'svg' | 'png' | 'base64' + - 'svg' -> returns SVG bytes + - 'png' -> returns PNG bytes + - 'base64'-> returns dict: {mime, png_b64, width, height, view} + selector: ifcopenshell selector query to restrict plotted elements. + element_ids: STEP ids to highlight; non-highlighted geometry is faded. + view: One of VIEWS ('floorplan', 'elevation', 'section', 'auto'). + width_mm, height_mm: Page size in mm. + scale: Model-to-paper scale (0.01 means 1:100). + merge_projection: Passed through to ifcopenshell.draw.main. + png_width, png_height: Raster size in pixels for png/base64 outputs. + + Raises: + ImportError: if ifcopenshell.draw or CairoSVG is not available (as required). + ValueError: invalid args or selector matches nothing. + """ + if output_format not in OUTPUT_FORMATS: + raise ValueError(f"output_format must be one of {OUTPUT_FORMATS}, got {output_format!r}") + if view not in VIEWS: + raise ValueError(f"view must be one of {VIEWS}, got {view!r}") + if not _HAS_DRAW: + raise ImportError("ifcopenshell.draw is not available in this environment.") + + # Configure draw settings + settings = ifcopenshell.draw.draw_settings( + auto_floorplan=(view in ("floorplan", "auto")), + auto_elevation=(view in ("elevation", "auto")), + auto_section=(view in ("section", "auto")), + width=width_mm, + height=height_mm, + scale=scale, + css="", + ) + + # Optional highlight CSS overlay + if element_ids: + settings.css = _highlight_css_from_ids(model, element_ids) + + # Optional element restriction via selector -> custom iterator + iterators: tuple[Any, ...] = () + 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") + it = _make_filtered_iterator(model, include_elements) + iterators = (it,) + # If we explicitly include elements, don't rely on exclude_entities (best-effort). + settings.exclude_entities = "" + + # Generate SVG + svg_bytes = ifcopenshell.draw.main( + settings, + files=[model], + iterators=iterators, + merge_projection=merge_projection, + ) + + register_namespace('',"http://www.w3.org/2000/svg") + + def svg_split(f): + x = ElementTree(file=f) + svg = x.getroot() + resources = [] + for child in svg: + if child.tag == "{http://www.w3.org/2000/svg}g": + root = Element(svg.tag, svg.attrib) + n = ElementTree(root) + for r in (resources + [child]): + root.append(r) + b = BytesIO() + n.write(b, + xml_declaration = True, + encoding = 'utf-8', + method = 'xml') + yield b.getvalue() + else: + resources.append(child) + + if output_format == "svg": + return svg_bytes + + # Need CairoSVG for png/base64 + if not _HAS_CAIROSVG: + raise ImportError("CairoSVG is not installed. Install with: pip install cairosvg") + + 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: + break + + # Need Pillow for concatenating images + if not _HAS_PIL: + raise ImportError("Pillow is not installed. Install with: pip install Pillow") + + if composite is None: + composite = Image.new('RGBA', (png_width, png_height * len(svgs))) + img = Image.open(BytesIO(png_bytes)) + composite.paste(img, (0, png_height * i)) + if composite is not None: + b = BytesIO() + composite.save(b, 'png') + png_bytes = b.getvalue() + + return png_bytes