mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-09 17:31:45 +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 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: <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()
|
||||
|
||||
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))
|
||||
|
||||
|
||||
@@ -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