mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-16 21:42:19 +00:00
ifcquery plot subcommand
This commit is contained in:
@@ -28,7 +28,7 @@ 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, 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
|
from ifcquery import validate as validate_mod
|
||||||
|
|
||||||
|
|
||||||
@@ -142,6 +142,52 @@ def main():
|
|||||||
help="Camera angle (default: iso)",
|
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: <ifc_file>.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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -232,6 +278,40 @@ def main():
|
|||||||
f.write(png_bytes)
|
f.write(png_bytes)
|
||||||
print(f"Saved render to {out_path}", file=sys.stderr)
|
print(f"Saved render to {out_path}", file=sys.stderr)
|
||||||
return
|
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))
|
print(format_output(result, args.output_format))
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
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
|
||||||
Reference in New Issue
Block a user