From 901306a3fd94e1e5068757ca6308090da51db747 Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Mon, 23 Mar 2026 23:15:08 +0000 Subject: [PATCH] black and ruff --- src/ifcedit/ifcedit/coerce.py | 7 +- src/ifcedit/ifcedit/discover.py | 2 +- src/ifcedit/tests/conftest.py | 2 +- src/ifcedit/tests/test_run.py | 2 +- src/ifcmcp/ifcmcp/__main__.py | 3 +- src/ifcmcp/ifcmcp/core.py | 69 +++- src/ifcmcp/ifcmcp/embedded.py | 3 +- src/ifcmcp/ifcmcp/server.py | 5 +- src/ifcmcp/tests/conftest.py | 2 +- src/ifcmcp/tests/test_server.py | 42 ++- src/ifcquery/build/lib/ifcquery/__init__.py | 20 + src/ifcquery/build/lib/ifcquery/__main__.py | 251 ++++++++++++ src/ifcquery/build/lib/ifcquery/clash.py | 167 ++++++++ src/ifcquery/build/lib/ifcquery/contexts.py | 44 +++ src/ifcquery/build/lib/ifcquery/cost.py | 45 +++ src/ifcquery/build/lib/ifcquery/info.py | 118 ++++++ src/ifcquery/build/lib/ifcquery/materials.py | 86 +++++ src/ifcquery/build/lib/ifcquery/relations.py | 169 +++++++++ src/ifcquery/build/lib/ifcquery/render.py | 377 +++++++++++++++++++ src/ifcquery/build/lib/ifcquery/schedule.py | 56 +++ src/ifcquery/build/lib/ifcquery/schema.py | 19 + src/ifcquery/build/lib/ifcquery/select.py | 41 ++ src/ifcquery/build/lib/ifcquery/summary.py | 52 +++ src/ifcquery/build/lib/ifcquery/tree.py | 68 ++++ src/ifcquery/build/lib/ifcquery/validate.py | 15 + src/ifcquery/ifcquery/__main__.py | 72 +++- src/ifcquery/ifcquery/materials.py | 98 ++--- src/ifcquery/ifcquery/plot.py | 33 +- src/ifcquery/ifcquery/render.py | 20 +- src/ifcquery/tests/test_clash.py | 32 +- src/ifcquery/tests/test_contexts.py | 3 + src/ifcquery/tests/test_info.py | 15 +- src/ifcquery/tests/test_plot.py | 2 +- src/ifcquery/tests/test_render.py | 4 +- 34 files changed, 1794 insertions(+), 150 deletions(-) create mode 100644 src/ifcquery/build/lib/ifcquery/__init__.py create mode 100644 src/ifcquery/build/lib/ifcquery/__main__.py create mode 100644 src/ifcquery/build/lib/ifcquery/clash.py create mode 100644 src/ifcquery/build/lib/ifcquery/contexts.py create mode 100644 src/ifcquery/build/lib/ifcquery/cost.py create mode 100644 src/ifcquery/build/lib/ifcquery/info.py create mode 100644 src/ifcquery/build/lib/ifcquery/materials.py create mode 100644 src/ifcquery/build/lib/ifcquery/relations.py create mode 100644 src/ifcquery/build/lib/ifcquery/render.py create mode 100644 src/ifcquery/build/lib/ifcquery/schedule.py create mode 100644 src/ifcquery/build/lib/ifcquery/schema.py create mode 100644 src/ifcquery/build/lib/ifcquery/select.py create mode 100644 src/ifcquery/build/lib/ifcquery/summary.py create mode 100644 src/ifcquery/build/lib/ifcquery/tree.py create mode 100644 src/ifcquery/build/lib/ifcquery/validate.py diff --git a/src/ifcedit/ifcedit/coerce.py b/src/ifcedit/ifcedit/coerce.py index a192cd721c..5a410ec186 100644 --- a/src/ifcedit/ifcedit/coerce.py +++ b/src/ifcedit/ifcedit/coerce.py @@ -157,8 +157,11 @@ def _floatify_numeric_lists(obj): """ if isinstance(obj, dict): return {k: _floatify_numeric_lists(v) for k, v in obj.items()} - if isinstance(obj, list) and obj and all(isinstance(v, (int, float)) for v in obj) and any( - isinstance(v, float) for v in obj + if ( + isinstance(obj, list) + and obj + and all(isinstance(v, (int, float)) for v in obj) + and any(isinstance(v, float) for v in obj) ): return [float(v) for v in obj] return obj diff --git a/src/ifcedit/ifcedit/discover.py b/src/ifcedit/ifcedit/discover.py index 9ebab10534..b26a93f6c6 100644 --- a/src/ifcedit/ifcedit/discover.py +++ b/src/ifcedit/ifcedit/discover.py @@ -182,7 +182,7 @@ def _format_type_hint(hint) -> str | None: formatted = [_format_type_hint(a) for a in args] # Optional[X] is Union[X, None] — render as "Optional[X]" if len(formatted) == 2 and "None" in formatted: - inner = [f for f in formatted if f != "None"][0] + inner = next(f for f in formatted if f != "None") return f"Optional[{inner}]" return " | ".join(formatted) diff --git a/src/ifcedit/tests/conftest.py b/src/ifcedit/tests/conftest.py index 017578a343..241220b145 100644 --- a/src/ifcedit/tests/conftest.py +++ b/src/ifcedit/tests/conftest.py @@ -1,6 +1,7 @@ # This file was generated with the assistance of an AI coding tool. import ifcopenshell import ifcopenshell.api.aggregate +import ifcopenshell.api.material import ifcopenshell.api.owner.settings import ifcopenshell.api.project import ifcopenshell.api.pset @@ -8,7 +9,6 @@ import ifcopenshell.api.root import ifcopenshell.api.spatial import ifcopenshell.api.unit import pytest -import ifcopenshell.api.material @pytest.fixture diff --git a/src/ifcedit/tests/test_run.py b/src/ifcedit/tests/test_run.py index 620614ff08..80881e879f 100644 --- a/src/ifcedit/tests/test_run.py +++ b/src/ifcedit/tests/test_run.py @@ -1,7 +1,7 @@ # This file was generated with the assistance of an AI coding tool. import ifcopenshell -import ifcopenshell.api.pset import ifcopenshell.api.project +import ifcopenshell.api.pset import ifcopenshell.api.root from ifcedit.run import run_api, serialize_result diff --git a/src/ifcmcp/ifcmcp/__main__.py b/src/ifcmcp/ifcmcp/__main__.py index d5d7e71cfa..0e94699ea0 100644 --- a/src/ifcmcp/ifcmcp/__main__.py +++ b/src/ifcmcp/ifcmcp/__main__.py @@ -1,10 +1,11 @@ # This file was generated with the assistance of an AI coding tool. from ifcmcp.server import build_server + def main(): server = build_server() server.run(transport="stdio") if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/src/ifcmcp/ifcmcp/core.py b/src/ifcmcp/ifcmcp/core.py index 4df97cd241..ed9f91c3c9 100644 --- a/src/ifcmcp/ifcmcp/core.py +++ b/src/ifcmcp/ifcmcp/core.py @@ -1,26 +1,40 @@ # This file was generated with the assistance of an AI coding tool. from __future__ import annotations +# inside ifcmcp/core.py import json +from collections.abc import Callable # noqa: F401 — Callable used in helpers below from dataclasses import dataclass -from typing import Any, Callable # noqa: F401 — Callable used in helpers below +from typing import Any import ifcopenshell - from ifcedit.discover import function_docs, list_functions, list_modules from ifcedit.quantify import run_quantify 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, plot as plot_mod, relations, render as render_mod, schedule, schema, select, summary, tree +from ifcquery import ( + info, + relations, + schedule, + schema, + select, + summary, + tree, +) +from ifcquery import ( + materials as materials_mod, +) +from ifcquery import ( + plot as plot_mod, +) +from ifcquery import ( + render as render_mod, +) from ifcquery import validate as validate_mod -# inside ifcmcp/core.py -import json -from typing import Any - def _jsonify(x: Any) -> Any: """Convert IfcOpenShell objects / iterables into JSON-safe primitives.""" if x is None or isinstance(x, (str, int, float, bool)): @@ -57,6 +71,7 @@ def _jsonify(x: Any) -> Any: # Shape builder helpers # --------------------------------------------------------------------------- + def _list_shape_methods() -> list[dict]: """Introspect ShapeBuilder and return a summary of all public methods.""" import inspect @@ -172,10 +187,12 @@ def _coerce_shape_value(value: Any, hint: Any, model: ifcopenshell.file) -> Any: # Sequence[entity_instance]: resolve each element in the list import collections.abc + if origin is not None and issubclass(origin, collections.abc.Sequence) and not isinstance(value, str): - if args and (args[0] is ifcopenshell.entity_instance or ( - isinstance(args[0], type) and issubclass(args[0], ifcopenshell.entity_instance) - )): + if args and ( + args[0] is ifcopenshell.entity_instance + or (isinstance(args[0], type) and issubclass(args[0], ifcopenshell.entity_instance)) + ): if isinstance(value, (list, tuple)): return [_coerce_shape_value(v, args[0], model) for v in value] @@ -188,6 +205,7 @@ def _coerce_shape_value(value: Any, hint: Any, model: ifcopenshell.file) -> Any: # Everything else (float, int, VectorType lists, dicts, Literals) passes through return value + class IfcSessionError(RuntimeError): pass @@ -615,7 +633,9 @@ class IfcSession: "description": "Validate the loaded model. Returns valid bool and list of issues.", "parameters": { "type": "object", - "properties": {"express_rules": {"type": "boolean", "description": "Also check EXPRESS rules (slower)"}}, + "properties": { + "express_rules": {"type": "boolean", "description": "Also check EXPRESS rules (slower)"} + }, "required": [], "additionalProperties": False, }, @@ -626,7 +646,12 @@ class IfcSession: "description": "List work schedules and nested tasks. Use max_depth=1 for top-level phases only on large projects.", "parameters": { "type": "object", - "properties": {"max_depth": {"type": "integer", "description": "Max levels of subtask expansion (omit for unlimited)"}}, + "properties": { + "max_depth": { + "type": "integer", + "description": "Max levels of subtask expansion (omit for unlimited)", + } + }, "required": [], "additionalProperties": False, }, @@ -637,7 +662,12 @@ class IfcSession: "description": "List cost schedules and nested cost items. Use max_depth=1 for top-level sections only on large BoQs.", "parameters": { "type": "object", - "properties": {"max_depth": {"type": "integer", "description": "Max levels of cost item expansion (omit for unlimited)"}}, + "properties": { + "max_depth": { + "type": "integer", + "description": "Max levels of cost item expansion (omit for unlimited)", + } + }, "required": [], "additionalProperties": False, }, @@ -661,7 +691,10 @@ class IfcSession: "type": "object", "properties": { "rule": {"type": "string", "description": "QTO rule name, e.g. IFC4QtoBaseQuantities"}, - "selector": {"type": "string", "description": "ifcopenshell selector to restrict elements (default: all IfcElement)"}, + "selector": { + "type": "string", + "description": "ifcopenshell selector to restrict elements (default: all IfcElement)", + }, }, "required": ["rule"], "additionalProperties": False, @@ -680,7 +713,11 @@ class IfcSession: "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"}, + "element_ids": { + "type": "array", + "items": {"type": "integer"}, + "description": "Step IDs of elements to highlight", + }, "view": { "type": "string", "enum": ["iso", "top", "south", "north", "east", "west"], @@ -691,4 +728,4 @@ class IfcSession: "additionalProperties": False, }, }, - ] \ No newline at end of file + ] diff --git a/src/ifcmcp/ifcmcp/embedded.py b/src/ifcmcp/ifcmcp/embedded.py index a51e98798a..4ad99ba1fa 100644 --- a/src/ifcmcp/ifcmcp/embedded.py +++ b/src/ifcmcp/ifcmcp/embedded.py @@ -1,7 +1,8 @@ # This file was generated with the assistance of an AI coding tool. from __future__ import annotations -from typing import Any, Mapping +from collections.abc import Mapping +from typing import Any from ifcmcp.core import IfcSession diff --git a/src/ifcmcp/ifcmcp/server.py b/src/ifcmcp/ifcmcp/server.py index 95a51f6036..08ba86a2cb 100644 --- a/src/ifcmcp/ifcmcp/server.py +++ b/src/ifcmcp/ifcmcp/server.py @@ -18,8 +18,7 @@ def build_server() -> Any: """Create the FastMCP server if the dependency is available.""" if FastMCP is None: raise ImportError( - "FastMCP is not installed. Install with: pip install ifcmcp[mcp] " - "(or add 'mcp' to your environment)." + "FastMCP is not installed. Install with: pip install ifcmcp[mcp] " "(or add 'mcp' to your environment)." ) session = IfcSession() @@ -229,4 +228,4 @@ def build_server() -> Any: f.write(png_bytes) return [ImageContent(type="image", data=base64.b64encode(png_bytes).decode(), mimeType="image/png")] - return server \ No newline at end of file + return server diff --git a/src/ifcmcp/tests/conftest.py b/src/ifcmcp/tests/conftest.py index bef5469acf..9fae4aef92 100644 --- a/src/ifcmcp/tests/conftest.py +++ b/src/ifcmcp/tests/conftest.py @@ -1,5 +1,4 @@ # This file was generated with the assistance of an AI coding tool. -import pytest import ifcopenshell import ifcopenshell.api.aggregate import ifcopenshell.api.owner.settings @@ -7,6 +6,7 @@ import ifcopenshell.api.project import ifcopenshell.api.root import ifcopenshell.api.spatial import ifcopenshell.api.unit +import pytest from ifcmcp.core import IfcSession diff --git a/src/ifcmcp/tests/test_server.py b/src/ifcmcp/tests/test_server.py index 996be3b024..ed41434df7 100644 --- a/src/ifcmcp/tests/test_server.py +++ b/src/ifcmcp/tests/test_server.py @@ -58,17 +58,33 @@ class TestRenderOutputPath: class TestPlotOutputPath: def test_no_output_path_no_file_written(self, tool_fns, tmp_path): with patch("ifcmcp.core.IfcSession.ifc_plot", return_value=PNG_FAKE): - tool_fns["ifc_plot"](selector="", element_ids=None, view="floorplan", - width_mm=297.0, height_mm=420.0, scale=0.01, - png_width=1024, png_height=1024, output_path="") + tool_fns["ifc_plot"]( + selector="", + element_ids=None, + view="floorplan", + width_mm=297.0, + height_mm=420.0, + scale=0.01, + png_width=1024, + png_height=1024, + output_path="", + ) assert list(tmp_path.iterdir()) == [] def test_png_output_path_writes_png(self, tool_fns, tmp_path): out = str(tmp_path / "plot.png") with patch("ifcmcp.core.IfcSession.ifc_plot", return_value=PNG_FAKE): - tool_fns["ifc_plot"](selector="", element_ids=None, view="floorplan", - width_mm=297.0, height_mm=420.0, scale=0.01, - png_width=1024, png_height=1024, output_path=out) + tool_fns["ifc_plot"]( + selector="", + element_ids=None, + view="floorplan", + width_mm=297.0, + height_mm=420.0, + scale=0.01, + png_width=1024, + png_height=1024, + output_path=out, + ) assert open(out, "rb").read() == PNG_FAKE def test_svg_output_path_writes_svg(self, tool_fns, tmp_path): @@ -76,7 +92,15 @@ class TestPlotOutputPath: # ifc_plot is called twice: once with "png" for the inline image, # once with "svg" for the file. with patch("ifcmcp.core.IfcSession.ifc_plot", side_effect=[PNG_FAKE, SVG_FAKE]): - tool_fns["ifc_plot"](selector="", element_ids=None, view="floorplan", - width_mm=297.0, height_mm=420.0, scale=0.01, - png_width=1024, png_height=1024, output_path=out) + tool_fns["ifc_plot"]( + selector="", + element_ids=None, + view="floorplan", + width_mm=297.0, + height_mm=420.0, + scale=0.01, + png_width=1024, + png_height=1024, + output_path=out, + ) assert open(out, "rb").read() == SVG_FAKE diff --git a/src/ifcquery/build/lib/ifcquery/__init__.py b/src/ifcquery/build/lib/ifcquery/__init__.py new file mode 100644 index 0000000000..be5327b20d --- /dev/null +++ b/src/ifcquery/build/lib/ifcquery/__init__.py @@ -0,0 +1,20 @@ +# This file was generated with the assistance of an AI coding tool. +# 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 . + +__version__ = version = "0.0.0" diff --git a/src/ifcquery/build/lib/ifcquery/__main__.py b/src/ifcquery/build/lib/ifcquery/__main__.py new file mode 100644 index 0000000000..5c403b7842 --- /dev/null +++ b/src/ifcquery/build/lib/ifcquery/__main__.py @@ -0,0 +1,251 @@ +# This file was generated with the assistance of an AI coding tool. +# 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 + +import argparse +import json +import os +import sys + +import ifcopenshell + +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, relations, schedule, schema, select, summary, tree +from ifcquery import materials as materials_mod +from ifcquery import render as render_mod +from ifcquery import validate as validate_mod + + +def parse_element_id(raw: str) -> int: + """Parse an element ID from '#123' or '123' format.""" + raw = raw.strip().lstrip("#") + return int(raw) + + +def format_output(data, fmt: str) -> str: + if fmt == "json": + return json.dumps(data, indent=2, ensure_ascii=False) + elif fmt == "text": + return _format_text(data) + return json.dumps(data, indent=2, ensure_ascii=False) + + +def _format_text(data, indent: int = 0) -> str: + prefix = " " * indent + lines = [] + if isinstance(data, dict): + for key, value in data.items(): + if isinstance(value, (dict, list)): + lines.append(f"{prefix}{key}:") + lines.append(_format_text(value, indent + 1)) + else: + lines.append(f"{prefix}{key}: {value}") + elif isinstance(data, list): + for item in data: + if isinstance(item, dict): + lines.append(_format_text(item, indent)) + lines.append("") + else: + lines.append(f"{prefix}- {item}") + else: + lines.append(f"{prefix}{data}") + return "\n".join(lines) + + +def main(): + parser = argparse.ArgumentParser( + prog="ifcquery", + description="Query and inspect IFC building models", + ) + parser.add_argument("ifc_file", help="Path to the IFC file") + parser.add_argument( + "--format", + choices=["json", "text"], + default="json", + dest="output_format", + help="Output format (default: json)", + ) + + subparsers = parser.add_subparsers(dest="command", required=True) + + subparsers.add_parser("summary", help="Model overview: schema, element counts, project info") + + subparsers.add_parser("tree", help="Spatial hierarchy tree") + + info_parser = subparsers.add_parser("info", help="Deep inspection of a specific element") + info_parser.add_argument("element_id", help="Element step ID (e.g. 123 or #123)") + + select_parser = subparsers.add_parser("select", help="Filter elements using selector syntax") + select_parser.add_argument("query", help="Selector query string") + + relations_parser = subparsers.add_parser("relations", help="Show relationships for an element") + relations_parser.add_argument("element_id", help="Element step ID (e.g. 123 or #123)") + relations_parser.add_argument("--traverse", choices=["up"], help="Traverse hierarchy (up: walk to IfcProject)") + + clash_parser = subparsers.add_parser("clash", help="Check element placement for clashes") + clash_parser.add_argument("element_id", help="Element step ID (e.g. 123 or #123)") + clash_parser.add_argument("--clearance", type=float, help="Minimum clearance distance") + clash_parser.add_argument("--tolerance", type=float, default=0.002, help="Intersection tolerance (default: 0.002)") + clash_parser.add_argument( + "--scope", choices=["storey", "all"], default="storey", help="Scope of elements to check (default: storey)" + ) + + validate_parser = subparsers.add_parser("validate", help="Schema/constraint validation") + validate_parser.add_argument( + "--rules", action="store_true", help="Also check EXPRESS rules (slower, default: false)" + ) + + schedule_parser = subparsers.add_parser("schedule", help="List work plans and tasks from the model") + schedule_parser.add_argument( + "--depth", type=int, default=None, metavar="N", help="Limit subtask expansion to N levels (default: unlimited)" + ) + + cost_parser = subparsers.add_parser("cost", help="List cost schedules and cost items from the model") + cost_parser.add_argument( + "--depth", type=int, default=None, metavar="N", help="Limit cost item expansion to N levels (default: unlimited)" + ) + + subparsers.add_parser("contexts", help="List geometric representation contexts and subcontexts") + + subparsers.add_parser("materials", help="List materials and material sets") + + schema_parser = subparsers.add_parser("schema", help="IFC class documentation") + 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: .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() + + try: + model = ifcopenshell.open(args.ifc_file) + except Exception as e: + print(f"Error: Could not open IFC file: {e}", file=sys.stderr) + sys.exit(1) + + if args.command == "summary": + result = summary.summary(model) + elif args.command == "tree": + result = tree.tree(model) + elif args.command == "info": + try: + element_id = parse_element_id(args.element_id) + except ValueError: + print(f"Error: Invalid element ID: {args.element_id}", file=sys.stderr) + sys.exit(1) + try: + element = model.by_id(element_id) + except RuntimeError: + print(f"Error: Element #{element_id} not found", file=sys.stderr) + sys.exit(1) + result = info.info(model, element) + elif args.command == "select": + result = select.select(model, args.query) + elif args.command == "relations": + try: + element_id = parse_element_id(args.element_id) + except ValueError: + print(f"Error: Invalid element ID: {args.element_id}", file=sys.stderr) + sys.exit(1) + try: + element = model.by_id(element_id) + except RuntimeError: + print(f"Error: Element #{element_id} not found", file=sys.stderr) + sys.exit(1) + result = relations.relations(model, element, traverse=args.traverse) + elif args.command == "clash": + try: + element_id = parse_element_id(args.element_id) + except ValueError: + print(f"Error: Invalid element ID: {args.element_id}", file=sys.stderr) + sys.exit(1) + try: + element = model.by_id(element_id) + except RuntimeError: + print(f"Error: Element #{element_id} not found", file=sys.stderr) + sys.exit(1) + try: + result = clash_mod.clash( + model, element, clearance=args.clearance, tolerance=args.tolerance, scope=args.scope + ) + except ImportError: + print("Error: ifcopenshell geometry engine not available (C++ bindings required)", file=sys.stderr) + sys.exit(1) + elif args.command == "validate": + result = validate_mod.validate(model, express_rules=args.rules) + elif args.command == "schedule": + result = schedule.schedule(model, max_depth=args.depth) + elif args.command == "cost": + result = cost_mod.cost(model, max_depth=args.depth) + elif args.command == "contexts": + result = contexts_mod.contexts(model) + elif args.command == "materials": + result = materials_mod.materials(model) + elif args.command == "schema": + 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)) + + +if __name__ == "__main__": + main() diff --git a/src/ifcquery/build/lib/ifcquery/clash.py b/src/ifcquery/build/lib/ifcquery/clash.py new file mode 100644 index 0000000000..7e3583e0aa --- /dev/null +++ b/src/ifcquery/build/lib/ifcquery/clash.py @@ -0,0 +1,167 @@ +# This file was generated with the assistance of an AI coding tool. +# 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 + +import multiprocessing +import sys +from typing import Any + +import ifcopenshell +import ifcopenshell.geom +import ifcopenshell.util.element + + +def _ref(element: ifcopenshell.entity_instance) -> dict[str, Any]: + """Serialize an element to a compact reference dict.""" + result: dict[str, Any] = {"id": element.id(), "type": element.is_a()} + if hasattr(element, "Name") and element.Name: + result["name"] = element.Name + return result + + +def _get_scope_elements( + model: ifcopenshell.file, element: ifcopenshell.entity_instance, scope: str +) -> tuple[set[ifcopenshell.entity_instance], str]: + """Return set of elements to check against and the effective scope used. + + Returns (elements, effective_scope) where effective_scope may differ from + the requested scope if fallback was needed. + """ + if scope == "storey": + container = ifcopenshell.util.element.get_container(element) + if container is not None: + siblings = set(ifcopenshell.util.element.get_contained(container)) + siblings.discard(element) + return siblings, "storey" + else: + print( + f"Warning: Element #{element.id()} has no spatial container, falling back to --scope all", + file=sys.stderr, + ) + + # scope == "all" or fallback + elements = set(model.by_type("IfcElement")) + elements -= set(model.by_type("IfcFeatureElement")) + elements.discard(element) + return elements, "all" + + +def _build_tree(model: ifcopenshell.file, elements: set[ifcopenshell.entity_instance]) -> ifcopenshell.geom.tree | None: + """Build geometry tree for given elements using iterator. + + Returns None if iterator fails to initialize (no geometry available). + """ + geom_settings = ifcopenshell.geom.settings() + geom_settings.set("use-world-coords", True) + geom_tree = ifcopenshell.geom.tree() + iterator = ifcopenshell.geom.iterator(geom_settings, model, multiprocessing.cpu_count(), include=list(elements)) + if not iterator.initialize(): + return None + while True: + geom_tree.add_element(iterator.get()) + if not iterator.next(): + break + return geom_tree + + +def _format_clash(clash_result, geom_tree: ifcopenshell.geom.tree, model: ifcopenshell.file) -> dict[str, Any]: + """Format a single clash result to dict.""" + # clash result .a/.b are C++ wrapper entity_instances without .Name; + # look up the Python entity from the model by id for proper serialization + other = model.by_id(clash_result.b.id()) + return { + "element": _ref(other), + "type": geom_tree.get_clash_type(clash_result.clash_type), + "distance": clash_result.distance, + "p1": list(clash_result.p1), + "p2": list(clash_result.p2), + } + + +def clash( + model: ifcopenshell.file, + element: ifcopenshell.entity_instance, + clearance: float | None = None, + tolerance: float = 0.002, + scope: str = "storey", +) -> dict[str, Any]: + """Check element for geometric clashes against other elements. + + :param model: The IFC model. + :param element: The element to check. + :param clearance: Minimum clearance distance; if provided, runs clearance check. + :param tolerance: Intersection tolerance in meters (default 0.002). + :param scope: Which elements to check against: "storey" or "all". + :return: Dict with clash results suitable for JSON serialization. + """ + result: dict[str, Any] = {"element": _ref(element)} + + # Get scope elements + scope_elements, effective_scope = _get_scope_elements(model, element, scope) + result["scope"] = effective_scope + + if not scope_elements: + result["pass"] = True + result["checks"] = {"intersection": {"pass": True, "tolerance": tolerance, "clashes": []}} + if clearance is not None: + result["checks"]["clearance"] = {"pass": True, "clearance": clearance, "clashes": []} + return result + + # Build geometry tree for target element + scope elements + all_elements = scope_elements | {element} + geom_tree = _build_tree(model, all_elements) + + if geom_tree is None: + result["pass"] = None + result["error"] = f"No geometry for element #{element.id()}" + return result + + # Run intersection check + intersection_clashes = geom_tree.clash_intersection_many( + [element], list(scope_elements), tolerance=tolerance, check_all=True + ) + intersection_results = [_format_clash(c, geom_tree, model) for c in intersection_clashes] + checks: dict[str, Any] = { + "intersection": { + "pass": len(intersection_results) == 0, + "tolerance": tolerance, + "clashes": intersection_results, + } + } + + all_pass = len(intersection_results) == 0 + + # Run clearance check if requested + if clearance is not None: + clearance_clashes = geom_tree.clash_clearance_many( + [element], list(scope_elements), clearance=clearance, check_all=True + ) + clearance_results = [_format_clash(c, geom_tree, model) for c in clearance_clashes] + checks["clearance"] = { + "pass": len(clearance_results) == 0, + "clearance": clearance, + "clashes": clearance_results, + } + if clearance_results: + all_pass = False + + result["pass"] = all_pass + result["checks"] = checks + return result diff --git a/src/ifcquery/build/lib/ifcquery/contexts.py b/src/ifcquery/build/lib/ifcquery/contexts.py new file mode 100644 index 0000000000..912df173d7 --- /dev/null +++ b/src/ifcquery/build/lib/ifcquery/contexts.py @@ -0,0 +1,44 @@ +# 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 + +import ifcopenshell + + +def contexts(model: ifcopenshell.file) -> list[dict]: + """Return all geometric representation contexts and subcontexts. + + :param model: The in-memory IFC model. + :return: List of dicts with id, type, context_type, context_identifier, + and (for subcontexts) target_view and parent_context_id. + """ + results = [] + for ctx in model.by_type("IfcGeometricRepresentationContext"): + entry = { + "id": ctx.id(), + "type": ctx.is_a(), + "context_type": getattr(ctx, "ContextType", None), + "context_identifier": getattr(ctx, "ContextIdentifier", None), + } + if ctx.is_a("IfcGeometricRepresentationSubContext"): + entry["target_view"] = ctx.TargetView + parent = ctx.ParentContext + entry["parent_context_id"] = parent.id() if parent else None + results.append(entry) + return results diff --git a/src/ifcquery/build/lib/ifcquery/cost.py b/src/ifcquery/build/lib/ifcquery/cost.py new file mode 100644 index 0000000000..ef9559d597 --- /dev/null +++ b/src/ifcquery/build/lib/ifcquery/cost.py @@ -0,0 +1,45 @@ +# This file was generated with the assistance of an AI coding tool. +from __future__ import annotations + +from typing import Any + +import ifcopenshell +import ifcopenshell.util.cost as cost_util + + +def _cost_item_to_dict(item: ifcopenshell.entity_instance, max_depth: int | None, depth: int) -> dict[str, Any]: + raw_values = cost_util.get_cost_values(item) + values = [{"formula": v.get("label", ""), "category": v.get("category")} for v in raw_values] + + if max_depth is not None and depth >= max_depth: + child_count = len(cost_util.get_nested_cost_items(item)) + subitems = {"truncated": True, "count": child_count} if child_count else [] + else: + subitems = [_cost_item_to_dict(sub, max_depth, depth + 1) for sub in cost_util.get_nested_cost_items(item)] + + return { + "id": item.id(), + "name": getattr(item, "Name", None), + "values": values, + "subitems": subitems, + } + + +def cost(model: ifcopenshell.file, max_depth: int | None = None) -> list[dict[str, Any]]: + """Return a list of IfcCostSchedule entries with nested cost item trees. + + max_depth limits how many levels of subitems are expanded (None = unlimited). + At the cutoff level, subitems is replaced with {"truncated": True, "count": N}. + """ + result = [] + for cost_schedule in model.by_type("IfcCostSchedule"): + items = [_cost_item_to_dict(i, max_depth, depth=1) for i in cost_util.get_root_cost_items(cost_schedule)] + result.append( + { + "id": cost_schedule.id(), + "name": getattr(cost_schedule, "Name", None), + "predefined_type": getattr(cost_schedule, "PredefinedType", None), + "items": items, + } + ) + return result diff --git a/src/ifcquery/build/lib/ifcquery/info.py b/src/ifcquery/build/lib/ifcquery/info.py new file mode 100644 index 0000000000..74823bd826 --- /dev/null +++ b/src/ifcquery/build/lib/ifcquery/info.py @@ -0,0 +1,118 @@ +# This file was generated with the assistance of an AI coding tool. +# 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 typing import Any + +import ifcopenshell +import ifcopenshell.util.element +import ifcopenshell.util.placement + + +def _serialize_attribute(value: Any) -> Any: + """Convert an IFC attribute value to a JSON-serializable form.""" + if isinstance(value, ifcopenshell.entity_instance): + return {"id": value.id(), "type": value.is_a()} + if isinstance(value, tuple): + return [_serialize_attribute(v) for v in value] + return value + + +def _material_to_dict(material: ifcopenshell.entity_instance | None) -> dict[str, Any] | None: + """Convert a material entity to a summary dict.""" + if material is None: + return None + result: dict[str, Any] = { + "id": material.id(), + "type": material.is_a(), + } + if hasattr(material, "Name"): + result["name"] = material.Name + return result + + +def info(model: ifcopenshell.file, element: ifcopenshell.entity_instance) -> dict[str, Any]: + """Return deep inspection data for an element.""" + result: dict[str, Any] = { + "id": element.id(), + "type": element.is_a(), + } + + # Direct attributes via get_info() which returns a dict of all attributes + element_info = element.get_info() + attrs = {} + for key, value in element_info.items(): + if key in ("id", "type"): + continue + attrs[key] = _serialize_attribute(value) + result["attributes"] = attrs + + # Property sets and quantity sets + try: + psets = ifcopenshell.util.element.get_psets(element) + if psets: + result["property_sets"] = psets + except Exception: + pass + + # Element type + try: + element_type = ifcopenshell.util.element.get_type(element) + if element_type: + type_info: dict[str, Any] = { + "id": element_type.id(), + "type": element_type.is_a(), + } + if hasattr(element_type, "Name"): + type_info["name"] = element_type.Name + result["element_type"] = type_info + except Exception: + pass + + # Material + try: + material = ifcopenshell.util.element.get_material(element) + mat_dict = _material_to_dict(material) + if mat_dict: + result["material"] = mat_dict + except Exception: + pass + + # Spatial container + try: + container = ifcopenshell.util.element.get_container(element) + if container: + result["container"] = { + "id": container.id(), + "type": container.is_a(), + "name": container.Name if hasattr(container, "Name") else None, + } + except Exception: + pass + + # Placement (as 4x4 matrix) + try: + if hasattr(element, "ObjectPlacement") and element.ObjectPlacement: + matrix = ifcopenshell.util.placement.get_local_placement(element.ObjectPlacement) + result["placement"] = matrix.tolist() + except Exception: + pass + + return result diff --git a/src/ifcquery/build/lib/ifcquery/materials.py b/src/ifcquery/build/lib/ifcquery/materials.py new file mode 100644 index 0000000000..f1b5ac7b2c --- /dev/null +++ b/src/ifcquery/build/lib/ifcquery/materials.py @@ -0,0 +1,86 @@ +# 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 + +import ifcopenshell + + +def materials(model: ifcopenshell.file) -> list[dict]: + """Return all materials and material sets from the model. + + :param model: The in-memory IFC model. + :return: List of dicts covering IfcMaterial, IfcMaterialLayerSet, + IfcMaterialConstituentSet, and IfcMaterialProfileSet entities. + """ + results = [] + + for m in model.by_type("IfcMaterial"): + results.append({ + "id": m.id(), + "type": "IfcMaterial", + "name": m.Name, + "category": getattr(m, "Category", None), + }) + + for ls in model.by_type("IfcMaterialLayerSet"): + layers = [] + for layer in (ls.MaterialLayers or []): + layers.append({ + "name": layer.Name, + "thickness": layer.LayerThickness, + "material": layer.Material.Name if layer.Material else None, + "is_ventilated": layer.IsVentilated, + }) + results.append({ + "id": ls.id(), + "type": "IfcMaterialLayerSet", + "name": ls.LayerSetName, + "layers": layers, + }) + + for cs in model.by_type("IfcMaterialConstituentSet"): + constituents = [] + for c in (cs.MaterialConstituents or []): + constituents.append({ + "name": c.Name, + "material": c.Material.Name if c.Material else None, + "fraction": c.Fraction, + }) + results.append({ + "id": cs.id(), + "type": "IfcMaterialConstituentSet", + "name": cs.Name, + "constituents": constituents, + }) + + for ps in model.by_type("IfcMaterialProfileSet"): + profiles = [] + for p in (ps.MaterialProfiles or []): + profiles.append({ + "name": p.Name, + "material": p.Material.Name if p.Material else None, + }) + results.append({ + "id": ps.id(), + "type": "IfcMaterialProfileSet", + "name": ps.Name, + "profiles": profiles, + }) + + return results diff --git a/src/ifcquery/build/lib/ifcquery/relations.py b/src/ifcquery/build/lib/ifcquery/relations.py new file mode 100644 index 0000000000..f8f12e9989 --- /dev/null +++ b/src/ifcquery/build/lib/ifcquery/relations.py @@ -0,0 +1,169 @@ +# This file was generated with the assistance of an AI coding tool. +# 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 typing import Any + +import ifcopenshell +import ifcopenshell.util.element +import ifcopenshell.util.system + + +def _ref(element: ifcopenshell.entity_instance) -> dict[str, Any]: + """Serialize an element to a compact reference dict.""" + result: dict[str, Any] = {"id": element.id(), "type": element.is_a()} + if hasattr(element, "Name") and element.Name: + result["name"] = element.Name + return result + + +def _ref_or_none(element: ifcopenshell.entity_instance | None) -> dict[str, Any] | None: + return _ref(element) if element is not None else None + + +def _ref_list(elements) -> list[dict[str, Any]]: + return [_ref(e) for e in elements] + + +def _traverse_up(element: ifcopenshell.entity_instance) -> list[dict[str, Any]]: + """Walk the hierarchy from element up to IfcProject.""" + chain = [_ref(element)] + current = element + while True: + parent = ifcopenshell.util.element.get_parent(current) + if parent is None: + break + chain.append(_ref(parent)) + current = parent + return chain + + +def _all_relations(model: ifcopenshell.file, element: ifcopenshell.entity_instance) -> dict[str, Any]: + """Collect all relationships for an element.""" + result: dict[str, Any] = { + "id": element.id(), + "type": element.is_a(), + } + if hasattr(element, "Name") and element.Name: + result["name"] = element.Name + + # Hierarchy (upward) + hierarchy: dict[str, Any] = {} + parent = ifcopenshell.util.element.get_parent(element) + if parent is not None: + hierarchy["parent"] = _ref(parent) + container = ifcopenshell.util.element.get_container(element) + if container is not None: + hierarchy["container"] = _ref(container) + aggregate = ifcopenshell.util.element.get_aggregate(element) + if aggregate is not None: + hierarchy["aggregate"] = _ref(aggregate) + nest = ifcopenshell.util.element.get_nest(element) + if nest is not None: + hierarchy["nest"] = _ref(nest) + filled_void = ifcopenshell.util.element.get_filled_void(element) + if filled_void is not None: + hierarchy["filled_void"] = _ref(filled_void) + voided_element = ifcopenshell.util.element.get_voided_element(element) + if voided_element is not None: + hierarchy["voided_element"] = _ref(voided_element) + if hierarchy: + result["hierarchy"] = hierarchy + + # Children (downward) + children: dict[str, Any] = {} + contained = ifcopenshell.util.element.get_contained(element) + if contained: + children["contained"] = _ref_list(contained) + parts = ifcopenshell.util.element.get_parts(element) + if parts: + children["parts"] = _ref_list(parts) + components = ifcopenshell.util.element.get_components(element) + if components: + children["components"] = _ref_list(components) + openings = list(ifcopenshell.util.element.get_openings(element)) + if openings: + children["openings"] = _ref_list(openings) + if children: + result["children"] = children + + # Type relationship + type_relationship: dict[str, Any] = {} + element_type = ifcopenshell.util.element.get_type(element) + if element_type is not None: + type_relationship["type_of"] = _ref(element_type) + try: + occurrences = ifcopenshell.util.element.get_types(element) + if occurrences: + type_relationship["occurrences"] = _ref_list(occurrences) + except Exception: + pass + if type_relationship: + result["type_relationship"] = type_relationship + + # Groups + groups = ifcopenshell.util.element.get_groups(element) + if groups: + result["groups"] = _ref_list(groups) + + # Systems + systems = ifcopenshell.util.system.get_element_systems(element) + if systems: + result["systems"] = _ref_list(systems) + + # Zones + zones = ifcopenshell.util.system.get_element_zones(element) + if zones: + result["zones"] = _ref_list(zones) + + # Material + material = ifcopenshell.util.element.get_material(element) + if material is not None: + result["material"] = _ref(material) + + # Referenced structures + referenced = ifcopenshell.util.element.get_referenced_structures(element) + if referenced: + result["referenced_structures"] = _ref_list(referenced) + + # Connections + connections: dict[str, Any] = {} + connected_to = ifcopenshell.util.system.get_connected_to(element) + if connected_to: + connections["connected_to"] = _ref_list(connected_to) + connected_from = ifcopenshell.util.system.get_connected_from(element) + if connected_from: + connections["connected_from"] = _ref_list(connected_from) + ports = ifcopenshell.util.system.get_ports(element) + if ports: + connections["ports"] = _ref_list(ports) + if connections: + result["connections"] = connections + + return result + + +def relations( + model: ifcopenshell.file, element: ifcopenshell.entity_instance, traverse: str | None = None +) -> dict[str, Any] | list[dict[str, Any]]: + """Return relationships for an element, or hierarchy chain if traverse='up'.""" + if traverse == "up": + return _traverse_up(element) + return _all_relations(model, element) diff --git a/src/ifcquery/build/lib/ifcquery/render.py b/src/ifcquery/build/lib/ifcquery/render.py new file mode 100644 index 0000000000..709e829e2f --- /dev/null +++ b/src/ifcquery/build/lib/ifcquery/render.py @@ -0,0 +1,377 @@ +# 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 + +import multiprocessing +import os +import tempfile + +import ifcopenshell +import ifcopenshell.geom +import ifcopenshell.guid +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 + + raw_faces = np.array(geom.faces, dtype=int) + if raw_faces.size == 0 or raw_faces.size % 3 != 0: + return # degenerate geometry from kernel — skip silently + faces = raw_faces.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_iterator( + iterator: object, + highlight_ids: list[int] | None, + view: str, +) -> bytes: + """Drive a geometry iterator into a pyvista plotter and return PNG bytes.""" + plotter = pv.Plotter(off_screen=True, window_size=(1280, 960)) + plotter.background_color = "white" + + while True: + try: + _add_shape(iterator.get(), plotter, highlight_ids=frozenset(highlight_ids) if highlight_ids else None) + except Exception: + pass # skip broken shapes, keep rendering the rest + 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 + + +def _build_geom_settings(model: ifcopenshell.file) -> ifcopenshell.geom.settings: + """Build geometry settings, excluding Clearance subcontexts.""" + settings = ifcopenshell.geom.settings() + settings.set("use-world-coords", True) + + 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) + + return settings + + +def _get_occurrence_class(type_entity) -> str: + """Derive the occurrence IFC class from a type entity class name.""" + type_class = type_entity.is_a() + if type_class.endswith("Type"): + return type_class[:-4] + return "IfcBuildingElementProxy" + + +def _make_type_occurrence(model: ifcopenshell.file, type_entity) -> object | None: + """Create a temporary occurrence for *type_entity* using its RepresentationMaps. + + The occurrence is added to *model* and references the type's existing + RepresentationMap entities via IfcMappedItem. Returns the occurrence entity, + or ``None`` when the type has no usable RepresentationMaps. + + .. note:: + This function is intended for use on a temporary model copy. The + caller is responsible for discarding that copy after rendering. + """ + rep_maps = getattr(type_entity, "RepresentationMaps", None) or [] + if not rep_maps: + return None + + # One IfcMappedItem per RepresentationMap. + mapped_items = [] + for rep_map in rep_maps: + origin = model.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0)) + transform = model.create_entity( + "IfcCartesianTransformationOperator3D", + LocalOrigin=origin, + ) + mapped_item = model.create_entity( + "IfcMappedItem", + MappingSource=rep_map, + MappingTarget=transform, + ) + mapped_items.append(mapped_item) + + context = rep_maps[0].MappedRepresentation.ContextOfItems + shape_rep = model.create_entity( + "IfcShapeRepresentation", + ContextOfItems=context, + RepresentationIdentifier="Body", + RepresentationType="MappedRepresentation", + Items=mapped_items, + ) + prod_def_shape = model.create_entity( + "IfcProductDefinitionShape", + Representations=[shape_rep], + ) + + # Identity placement. + pt = model.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0)) + z_dir = model.create_entity("IfcDirection", DirectionRatios=(0.0, 0.0, 1.0)) + x_dir = model.create_entity("IfcDirection", DirectionRatios=(1.0, 0.0, 0.0)) + axis2 = model.create_entity("IfcAxis2Placement3D", Location=pt, Axis=z_dir, RefDirection=x_dir) + placement = model.create_entity("IfcLocalPlacement", RelativePlacement=axis2) + + occ_class = _get_occurrence_class(type_entity) + try: + occurrence = model.create_entity( + occ_class, + GlobalId=ifcopenshell.guid.new(), + Name=f"_type_preview_{type_entity.id()}", + ObjectPlacement=placement, + Representation=prod_def_shape, + ) + except Exception: + occurrence = model.create_entity( + "IfcBuildingElementProxy", + GlobalId=ifcopenshell.guid.new(), + Name=f"_type_preview_{type_entity.id()}", + ObjectPlacement=placement, + Representation=prod_def_shape, + ) + return occurrence + + +def _render_with_types( + model: ifcopenshell.file, + types: list, + selector_elements: list | None, + element_ids: list[int] | None, + type_highlight_ids: set[int], + view: str, +) -> bytes: + """Render type entities by creating occurrences in a temporary model copy. + + *types* — list of IfcTypeProduct entities to render. + *selector_elements* — non-type elements from the selector (or ``None``). + *element_ids* — original highlight IDs (may contain type IDs). + *type_highlight_ids* — subset of *element_ids* that are type IDs. + """ + tmp_fd, tmp_path = tempfile.mkstemp(suffix=".ifc") + os.close(tmp_fd) + try: + model.write(tmp_path) + tmp = ifcopenshell.open(tmp_path) + + # Map original type step-ID → new occurrence step-ID in the tmp model. + type_id_to_occ_id: dict[int, int] = {} + for t in types: + tmp_type = tmp.by_id(t.id()) + occ = _make_type_occurrence(tmp, tmp_type) + if occ: + type_id_to_occ_id[t.id()] = occ.id() + + if not type_id_to_occ_id: + raise ValueError("Type entities have no RepresentationMaps to render") + + include = [tmp.by_id(occ_id) for occ_id in type_id_to_occ_id.values()] + if selector_elements: + include.extend(tmp.by_id(e.id()) for e in selector_elements) + + settings = _build_geom_settings(tmp) + iterator = ifcopenshell.geom.iterator(settings, tmp, multiprocessing.cpu_count(), include=include) + if not iterator.initialize(): + raise ValueError("Type entities have no renderable geometry") + + # Remap type IDs → occurrence IDs in the highlight list. + new_highlight = None + if element_ids: + new_highlight = [] + for hid in element_ids: + if hid in type_highlight_ids: + mapped = type_id_to_occ_id.get(hid) + if mapped: + new_highlight.append(mapped) + else: + new_highlight.append(hid) + + return _render_iterator(iterator, new_highlight, view) + finally: + try: + os.unlink(tmp_path) + except OSError: + pass + + +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. + + Supports both element instances and element types (e.g. ``IfcWallType``). + When type entities are targeted — via *selector* or *element_ids* — a + temporary copy of the model is used to create proxy occurrences that + reference the type's RepresentationMaps; the original model is not + modified. + + :param model: The in-memory IFC model. + :param selector: ifcopenshell selector to restrict rendered elements + (e.g. ``'IfcWall'``, ``'IfcWallType'``, or + ``'IfcBuildingStorey[Name="Ground Floor"]'``). + When omitted the whole model is rendered. + :param element_ids: Step IDs of elements (or types) to highlight. The + rest of the model is rendered in translucent grey so the highlighted + items 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") + + # --- Partition selector results into types and elements --- + if selector: + matched = list(ifcopenshell.util.selector.filter_elements(model, selector)) + if not matched: + raise ValueError(f"Selector {selector!r} matched no elements") + types = [e for e in matched if e.is_a("IfcTypeProduct")] + selector_elements: list | None = [e for e in matched if not e.is_a("IfcTypeProduct")] + else: + types = [] + selector_elements = None # no restriction — render all elements + + # --- Collect any type entities from element_ids --- + type_highlight_ids: set[int] = set() + if element_ids: + for eid in element_ids: + entity = model.by_id(eid) + if entity.is_a("IfcTypeProduct"): + type_highlight_ids.add(eid) + seen = {t.id() for t in types} + if eid not in seen: + types.append(entity) + + # --- Delegate to temp-copy path when any type entities are involved --- + if types: + return _render_with_types(model, types, selector_elements, element_ids, type_highlight_ids, view) + + # --- Regular element rendering --- + settings = _build_geom_settings(model) + + if selector_elements is not None: + if not selector_elements: + raise ValueError(f"Selector {selector!r} matched only type entities (use a type selector or IfcElement)") + iterator = ifcopenshell.geom.iterator( + settings, + model, + multiprocessing.cpu_count(), + include=selector_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)") + + return _render_iterator(iterator, element_ids, view) diff --git a/src/ifcquery/build/lib/ifcquery/schedule.py b/src/ifcquery/build/lib/ifcquery/schedule.py new file mode 100644 index 0000000000..b3e3ae45d6 --- /dev/null +++ b/src/ifcquery/build/lib/ifcquery/schedule.py @@ -0,0 +1,56 @@ +# This file was generated with the assistance of an AI coding tool. +from __future__ import annotations + +from typing import Any + +import ifcopenshell +import ifcopenshell.util.sequence as seq + + +def _task_to_dict(task: ifcopenshell.entity_instance, max_depth: int | None, depth: int) -> dict[str, Any]: + task_time = task.TaskTime + start = None + finish = None + if task_time: + start = task_time.ScheduleStart + finish = task_time.ScheduleFinish + + outputs = [] + for product in seq.get_task_outputs(task): + outputs.append({"id": product.id(), "type": product.is_a(), "name": getattr(product, "Name", None)}) + + if max_depth is not None and depth >= max_depth: + child_count = len(seq.get_nested_tasks(task)) + subtasks = {"truncated": True, "count": child_count} if child_count else [] + else: + subtasks = [_task_to_dict(sub, max_depth, depth + 1) for sub in seq.get_nested_tasks(task)] + + return { + "id": task.id(), + "name": getattr(task, "Name", None), + "start": start, + "finish": finish, + "is_milestone": bool(task.IsMilestone) if hasattr(task, "IsMilestone") else False, + "outputs": outputs, + "subtasks": subtasks, + } + + +def schedule(model: ifcopenshell.file, max_depth: int | None = None) -> list[dict[str, Any]]: + """Return a list of IfcWorkSchedule entries with nested task trees. + + max_depth limits how many levels of subtasks are expanded (None = unlimited). + At the cutoff level, subtasks is replaced with {"truncated": True, "count": N}. + """ + result = [] + for work_schedule in model.by_type("IfcWorkSchedule"): + tasks = [_task_to_dict(t, max_depth, depth=1) for t in seq.get_root_tasks(work_schedule)] + result.append( + { + "id": work_schedule.id(), + "name": getattr(work_schedule, "Name", None), + "predefined_type": getattr(work_schedule, "PredefinedType", None), + "tasks": tasks, + } + ) + return result diff --git a/src/ifcquery/build/lib/ifcquery/schema.py b/src/ifcquery/build/lib/ifcquery/schema.py new file mode 100644 index 0000000000..ad47486db2 --- /dev/null +++ b/src/ifcquery/build/lib/ifcquery/schema.py @@ -0,0 +1,19 @@ +# This file was generated with the assistance of an AI coding tool. +from __future__ import annotations + +from typing import Any + +import ifcopenshell +import ifcopenshell.util.doc + + +def schema(model: ifcopenshell.file, entity_type: str) -> dict[str, Any]: + """Return IFC class documentation for entity_type from model's schema version.""" + schema_name = model.schema + try: + doc = ifcopenshell.util.doc.get_entity_doc(schema_name, entity_type) + except Exception: + return {"error": f"Unknown entity: {entity_type}"} + if not doc: + return {"error": f"Unknown entity: {entity_type}"} + return dict(doc) diff --git a/src/ifcquery/build/lib/ifcquery/select.py b/src/ifcquery/build/lib/ifcquery/select.py new file mode 100644 index 0000000000..c28a159b90 --- /dev/null +++ b/src/ifcquery/build/lib/ifcquery/select.py @@ -0,0 +1,41 @@ +# This file was generated with the assistance of an AI coding tool. +# 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 typing import Any + +import ifcopenshell +import ifcopenshell.util.selector + + +def select(model: ifcopenshell.file, query: str) -> list[dict[str, Any]]: + """Filter elements using selector syntax and return matching element summaries.""" + elements = ifcopenshell.util.selector.filter_elements(model, query) + results = [] + for element in sorted(elements, key=lambda e: e.id()): + entry: dict[str, Any] = { + "id": element.id(), + "type": element.is_a(), + "repr": str(element), + } + if hasattr(element, "Name"): + entry["name"] = element.Name + results.append(entry) + return results diff --git a/src/ifcquery/build/lib/ifcquery/summary.py b/src/ifcquery/build/lib/ifcquery/summary.py new file mode 100644 index 0000000000..d13f070472 --- /dev/null +++ b/src/ifcquery/build/lib/ifcquery/summary.py @@ -0,0 +1,52 @@ +# This file was generated with the assistance of an AI coding tool. +# 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 collections import Counter +from typing import Any + +import ifcopenshell + + +def summary(model: ifcopenshell.file) -> dict[str, Any]: + """Return a model overview with schema, element counts, and project info.""" + # Count elements by IFC type, sorted by count descending + type_counter: Counter[str] = Counter() + total = 0 + for entity in model: + type_counter[entity.is_a()] += 1 + total += 1 + + result: dict[str, Any] = { + "schema": model.schema, + "total_entities": total, + } + + projects = model.by_type("IfcProject") + if projects: + project = projects[0] + result["project"] = { + "id": project.id(), + "name": project.Name, + "description": project.Description, + } + + result["types"] = dict(type_counter.most_common()) + return result diff --git a/src/ifcquery/build/lib/ifcquery/tree.py b/src/ifcquery/build/lib/ifcquery/tree.py new file mode 100644 index 0000000000..1cbbaeb986 --- /dev/null +++ b/src/ifcquery/build/lib/ifcquery/tree.py @@ -0,0 +1,68 @@ +# This file was generated with the assistance of an AI coding tool. +# 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 typing import Any + +import ifcopenshell +import ifcopenshell.util.element + + +def _element_summary(element: ifcopenshell.entity_instance) -> dict[str, Any]: + """Return a minimal summary dict for an element.""" + return { + "id": element.id(), + "type": element.is_a(), + "name": element.Name if hasattr(element, "Name") else None, + } + + +def _build_spatial_node(element: ifcopenshell.entity_instance) -> dict[str, Any]: + """Recursively build a spatial tree node.""" + node = _element_summary(element) + + # Get aggregated children (Site in Project, Building in Site, Storey in Building, etc.) + aggregates = [] + for rel in getattr(element, "IsDecomposedBy", []): + for child in rel.RelatedObjects: + aggregates.append(_build_spatial_node(child)) + + # Get contained elements (walls, slabs, etc. in a storey/space) + contained = [] + for rel in getattr(element, "ContainsElements", []): + for child in rel.RelatedElements: + contained.append(_element_summary(child)) + + if aggregates: + node["children"] = aggregates + if contained: + node["elements"] = contained + + return node + + +def tree(model: ifcopenshell.file) -> dict[str, Any] | list[dict[str, Any]]: + """Return the spatial hierarchy tree starting from IfcProject.""" + projects = model.by_type("IfcProject") + if not projects: + return {"error": "No IfcProject found in model"} + if len(projects) == 1: + return _build_spatial_node(projects[0]) + return [_build_spatial_node(p) for p in projects] diff --git a/src/ifcquery/build/lib/ifcquery/validate.py b/src/ifcquery/build/lib/ifcquery/validate.py new file mode 100644 index 0000000000..d35d9150af --- /dev/null +++ b/src/ifcquery/build/lib/ifcquery/validate.py @@ -0,0 +1,15 @@ +# This file was generated with the assistance of an AI coding tool. +from __future__ import annotations + +from typing import Any + +import ifcopenshell +import ifcopenshell.validate + + +def validate(model: ifcopenshell.file, express_rules: bool = False) -> dict[str, Any]: + """Validate the model and return a dict with 'valid' bool and 'issues' list.""" + logger = ifcopenshell.validate.json_logger() + ifcopenshell.validate.validate(model, logger, express_rules=express_rules) + issues = [{"level": s["level"], "message": s["message"]} for s in logger.statements] + return {"valid": len(issues) == 0, "issues": issues} diff --git a/src/ifcquery/ifcquery/__main__.py b/src/ifcquery/ifcquery/__main__.py index 2bc7dfefd3..a7a59f3ae5 100644 --- a/src/ifcquery/ifcquery/__main__.py +++ b/src/ifcquery/ifcquery/__main__.py @@ -29,7 +29,22 @@ import ifcopenshell 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, plot, relations, render as render_mod, schedule, schema, select, summary, tree +from ifcquery import ( + info, + plot, + relations, + schedule, + schema, + select, + summary, + tree, +) +from ifcquery import ( + materials as materials_mod, +) +from ifcquery import ( + render as render_mod, +) from ifcquery import validate as validate_mod @@ -119,7 +134,11 @@ def main(): cost_parser = subparsers.add_parser("cost", help="List cost schedules and cost items from the model") cost_parser.add_argument( - "--depth", type=int, default=None, metavar="N", help="Limit cost item expansion to N levels (default: unlimited)" + "--depth", + type=int, + default=None, + metavar="N", + help="Limit cost item expansion to N levels (default: unlimited)", ) subparsers.add_parser("contexts", help="List geometric representation contexts and subcontexts") @@ -137,8 +156,10 @@ def main(): "--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)" + "--element", + default="", + metavar="ID[,ID...]", + help="Comma-separated step IDs of elements to highlight (rest rendered in grey)", ) render_parser.add_argument( "--view", @@ -147,10 +168,15 @@ 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 = 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" + "-o", + "--output", + default="", + metavar="FILE", + help="Output file path. Default depends on --out-format: .svg/.png", ) plot_parser.add_argument( "--out-format", @@ -159,12 +185,10 @@ def main(): 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" + "--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" + "--element", default="", metavar="ID[,ID...]", help="Comma-separated step IDs of elements to highlight" ) plot_parser.add_argument( "--view", @@ -173,23 +197,38 @@ def main(): help="Drawing view (default: floorplan)", ) plot_parser.add_argument( - "--width-mm", type=float, default=297.0, metavar="MM", + "--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", + "--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", + "--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", + "--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", + "--png-height", + type=int, + default=1024, + metavar="PX", help="PNG height in pixels (default: 1024)", ) @@ -332,7 +371,6 @@ def main(): print(f"Saved drawing to {out_path}", file=sys.stderr) return - print(format_output(result, args.output_format)) diff --git a/src/ifcquery/ifcquery/materials.py b/src/ifcquery/ifcquery/materials.py index f1b5ac7b2c..c7402d51e5 100644 --- a/src/ifcquery/ifcquery/materials.py +++ b/src/ifcquery/ifcquery/materials.py @@ -31,56 +31,70 @@ def materials(model: ifcopenshell.file) -> list[dict]: results = [] for m in model.by_type("IfcMaterial"): - results.append({ - "id": m.id(), - "type": "IfcMaterial", - "name": m.Name, - "category": getattr(m, "Category", None), - }) + results.append( + { + "id": m.id(), + "type": "IfcMaterial", + "name": m.Name, + "category": getattr(m, "Category", None), + } + ) for ls in model.by_type("IfcMaterialLayerSet"): layers = [] - for layer in (ls.MaterialLayers or []): - layers.append({ - "name": layer.Name, - "thickness": layer.LayerThickness, - "material": layer.Material.Name if layer.Material else None, - "is_ventilated": layer.IsVentilated, - }) - results.append({ - "id": ls.id(), - "type": "IfcMaterialLayerSet", - "name": ls.LayerSetName, - "layers": layers, - }) + for layer in ls.MaterialLayers or []: + layers.append( + { + "name": layer.Name, + "thickness": layer.LayerThickness, + "material": layer.Material.Name if layer.Material else None, + "is_ventilated": layer.IsVentilated, + } + ) + results.append( + { + "id": ls.id(), + "type": "IfcMaterialLayerSet", + "name": ls.LayerSetName, + "layers": layers, + } + ) for cs in model.by_type("IfcMaterialConstituentSet"): constituents = [] - for c in (cs.MaterialConstituents or []): - constituents.append({ - "name": c.Name, - "material": c.Material.Name if c.Material else None, - "fraction": c.Fraction, - }) - results.append({ - "id": cs.id(), - "type": "IfcMaterialConstituentSet", - "name": cs.Name, - "constituents": constituents, - }) + for c in cs.MaterialConstituents or []: + constituents.append( + { + "name": c.Name, + "material": c.Material.Name if c.Material else None, + "fraction": c.Fraction, + } + ) + results.append( + { + "id": cs.id(), + "type": "IfcMaterialConstituentSet", + "name": cs.Name, + "constituents": constituents, + } + ) for ps in model.by_type("IfcMaterialProfileSet"): profiles = [] - for p in (ps.MaterialProfiles or []): - profiles.append({ - "name": p.Name, - "material": p.Material.Name if p.Material else None, - }) - results.append({ - "id": ps.id(), - "type": "IfcMaterialProfileSet", - "name": ps.Name, - "profiles": profiles, - }) + for p in ps.MaterialProfiles or []: + profiles.append( + { + "name": p.Name, + "material": p.Material.Name if p.Material else None, + } + ) + results.append( + { + "id": ps.id(), + "type": "IfcMaterialProfileSet", + "name": ps.Name, + "profiles": profiles, + } + ) return results diff --git a/src/ifcquery/ifcquery/plot.py b/src/ifcquery/ifcquery/plot.py index 4327384b63..3393a87fc8 100644 --- a/src/ifcquery/ifcquery/plot.py +++ b/src/ifcquery/ifcquery/plot.py @@ -19,13 +19,14 @@ from __future__ import annotations import base64 -from io import BytesIO import os +from io import BytesIO from typing import Any import ifcopenshell import ifcopenshell.geom import ifcopenshell.util.selector + try: import ifcopenshell.draw @@ -33,7 +34,7 @@ try: except ImportError: _HAS_DRAW = False -from xml.etree.ElementTree import ElementTree, Element, SubElement, register_namespace +from xml.etree.ElementTree import Element, ElementTree, SubElement, register_namespace try: import cairosvg # type: ignore @@ -44,7 +45,7 @@ except Exception: try: - from PIL import Image # type: ignore + from PIL import Image # type: ignore _HAS_PIL = True except Exception: @@ -79,8 +80,8 @@ def _highlight_css_from_ids(model: ifcopenshell.file, element_ids: list[int]) -> css = [ "/* Auto-highlight injected by ifcquery.plot */", - f'[{attr}] path {{ opacity: 0.10; }}', - f'[{attr}] text {{ opacity: 0.25; }}', + 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; }}') @@ -134,10 +135,7 @@ def _diagnose_empty_drawing(model: ifcopenshell.file, view: str) -> str: "set IfcBuildingStorey.Elevation (e.g. 0.0) so the section cut height can be determined" ) - has_geom = any( - getattr(e, "Representation", None) is not None - for e in model.by_type("IfcProduct") - ) + has_geom = any(getattr(e, "Representation", None) is not None for e in model.by_type("IfcProduct")) if not has_geom: hints.append("no IfcProduct entities have geometric representations") @@ -224,10 +222,10 @@ def plot( iterators=iterators, merge_projection=merge_projection, ) - - register_namespace('',"http://www.w3.org/2000/svg") - def svg_split(f): + register_namespace("", "http://www.w3.org/2000/svg") + + def svg_split(f): x = ElementTree(file=f) svg = x.getroot() resources = [] @@ -235,13 +233,10 @@ def plot( if child.tag == "{http://www.w3.org/2000/svg}g": root = Element(svg.tag, svg.attrib) n = ElementTree(root) - for r in (resources + [child]): + for r in resources + [child]: root.append(r) b = BytesIO() - n.write(b, - xml_declaration = True, - encoding = 'utf-8', - method = 'xml') + n.write(b, xml_declaration=True, encoding="utf-8", method="xml") yield b.getvalue() else: resources.append(child) @@ -269,12 +264,12 @@ def plot( 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))) + 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') + composite.save(b, "png") png_bytes = b.getvalue() if output_format == "base64": diff --git a/src/ifcquery/ifcquery/render.py b/src/ifcquery/ifcquery/render.py index 431b0b1d8c..44105e0ee2 100644 --- a/src/ifcquery/ifcquery/render.py +++ b/src/ifcquery/ifcquery/render.py @@ -38,7 +38,7 @@ except ImportError: VIEWS = ("iso", "top", "south", "north", "east", "west") -def _apply_view(plotter: "pv.Plotter", view: str) -> None: +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() @@ -60,7 +60,7 @@ def _apply_view(plotter: "pv.Plotter", view: str) -> None: def _add_shape( shape: object, - plotter: "pv.Plotter", + plotter: pv.Plotter, highlight_ids: frozenset[int] | None, ) -> None: """Triangulate and add a geometry shape to the plotter.""" @@ -131,22 +131,16 @@ def _render_iterator( pass -def _build_geom_settings(model: ifcopenshell.file) -> "ifcopenshell.geom.settings": +def _build_geom_settings(model: ifcopenshell.file) -> ifcopenshell.geom.settings: """Build geometry settings, excluding Clearance subcontexts.""" settings = ifcopenshell.geom.settings() settings.set("use-world-coords", True) clearance_ids = { - c.id() - for c in model.by_type("IfcGeometricRepresentationSubContext") - if c.ContextIdentifier == "Clearance" + 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 - ] + 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) @@ -277,9 +271,7 @@ def _make_profile_occurrence(model: ifcopenshell.file, type_entity) -> object | origin = model.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0)) z_axis = model.create_entity("IfcDirection", DirectionRatios=(0.0, 0.0, 1.0)) x_axis = model.create_entity("IfcDirection", DirectionRatios=(1.0, 0.0, 0.0)) - position = model.create_entity( - "IfcAxis2Placement3D", Location=origin, Axis=z_axis, RefDirection=x_axis - ) + position = model.create_entity("IfcAxis2Placement3D", Location=origin, Axis=z_axis, RefDirection=x_axis) extrude_dir = model.create_entity("IfcDirection", DirectionRatios=(0.0, 0.0, 1.0)) extrusion = model.create_entity( "IfcExtrudedAreaSolid", diff --git a/src/ifcquery/tests/test_clash.py b/src/ifcquery/tests/test_clash.py index 1c4b251dc1..150200f95c 100644 --- a/src/ifcquery/tests/test_clash.py +++ b/src/ifcquery/tests/test_clash.py @@ -131,7 +131,7 @@ def model_two_storeys(): class TestNoClashes: def test_no_clashes_far_apart(self, model_with_geometry): - wall3 = [w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall003"][0] + wall3 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall003") result = clash(model_with_geometry, wall3) assert result["pass"] is True assert result["checks"]["intersection"]["pass"] is True @@ -166,7 +166,7 @@ class TestNoClashes: class TestIntersectionDetected: def test_overlapping_walls(self, model_with_geometry): - wall1 = [w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001"][0] + wall1 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001") result = clash(model_with_geometry, wall1) assert result["pass"] is False assert result["checks"]["intersection"]["pass"] is False @@ -174,11 +174,11 @@ class TestIntersectionDetected: assert len(clashes) > 0 # Wall002 should be in the clashes (it overlaps wall1) clash_ids = {c["element"]["id"] for c in clashes} - wall2 = [w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall002"][0] + wall2 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall002") assert wall2.id() in clash_ids def test_clash_has_points(self, model_with_geometry): - wall1 = [w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001"][0] + wall1 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001") result = clash(model_with_geometry, wall1) clashes = result["checks"]["intersection"]["clashes"] for c in clashes: @@ -193,45 +193,45 @@ class TestIntersectionDetected: class TestClearance: def test_clearance_violation(self, model_with_geometry): """Wall004 is 0.1m from wall1; clearance of 0.5m should fail.""" - wall1 = [w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001"][0] + wall1 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001") result = clash(model_with_geometry, wall1, clearance=0.5) assert "clearance" in result["checks"] # Wall004 should violate clearance clearance_clashes = result["checks"]["clearance"]["clashes"] clash_ids = {c["element"]["id"] for c in clearance_clashes} - wall4 = [w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall004"][0] + wall4 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall004") assert wall4.id() in clash_ids assert result["checks"]["clearance"]["pass"] is False def test_clearance_pass(self, model_with_geometry): """Wall003 is 10m away; clearance of 0.5m should pass for wall003.""" - wall3 = [w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall003"][0] + wall3 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall003") result = clash(model_with_geometry, wall3, clearance=0.5) assert result["checks"]["clearance"]["pass"] is True assert result["checks"]["clearance"]["clashes"] == [] def test_clearance_not_included_by_default(self, model_with_geometry): - wall1 = [w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001"][0] + wall1 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001") result = clash(model_with_geometry, wall1) assert "clearance" not in result["checks"] class TestScope: def test_scope_storey_excludes_other_storeys(self, model_two_storeys): - wall1 = [w for w in model_two_storeys.by_type("IfcWall") if w.Name == "GroundWall"][0] + wall1 = next(w for w in model_two_storeys.by_type("IfcWall") if w.Name == "GroundWall") result = clash(model_two_storeys, wall1, scope="storey") assert result["scope"] == "storey" # No clashes because the overlapping wall is in a different storey assert result["pass"] is True def test_scope_all_includes_other_storeys(self, model_two_storeys): - wall1 = [w for w in model_two_storeys.by_type("IfcWall") if w.Name == "GroundWall"][0] + wall1 = next(w for w in model_two_storeys.by_type("IfcWall") if w.Name == "GroundWall") result = clash(model_two_storeys, wall1, scope="all") assert result["scope"] == "all" # Should detect clash with the other-storey wall assert result["pass"] is False clash_ids = {c["element"]["id"] for c in result["checks"]["intersection"]["clashes"]} - wall2 = [w for w in model_two_storeys.by_type("IfcWall") if w.Name == "FirstFloorWall"][0] + wall2 = next(w for w in model_two_storeys.by_type("IfcWall") if w.Name == "FirstFloorWall") assert wall2.id() in clash_ids @@ -247,14 +247,14 @@ class TestNoGeometry: class TestJsonSerializable: def test_result_serializable(self, model_with_geometry): - wall1 = [w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001"][0] + wall1 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001") result = clash(model_with_geometry, wall1) serialized = json.dumps(result) parsed = json.loads(serialized) assert parsed["element"]["type"] == "IfcWall" def test_clearance_result_serializable(self, model_with_geometry): - wall1 = [w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001"][0] + wall1 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001") result = clash(model_with_geometry, wall1, clearance=0.5) serialized = json.dumps(result) parsed = json.loads(serialized) @@ -272,7 +272,7 @@ class TestCLI: def test_clash_json(self, model_with_geometry): path = self._ifc_path(model_with_geometry) try: - wall1 = [w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001"][0] + wall1 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001") result = subprocess.run( [sys.executable, "-m", "ifcquery", path, "clash", str(wall1.id())], capture_output=True, @@ -289,7 +289,7 @@ class TestCLI: def test_clash_with_clearance(self, model_with_geometry): path = self._ifc_path(model_with_geometry) try: - wall1 = [w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001"][0] + wall1 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001") result = subprocess.run( [sys.executable, "-m", "ifcquery", path, "clash", str(wall1.id()), "--clearance", "0.5"], capture_output=True, @@ -304,7 +304,7 @@ class TestCLI: def test_clash_scope_all(self, model_with_geometry): path = self._ifc_path(model_with_geometry) try: - wall1 = [w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001"][0] + wall1 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001") result = subprocess.run( [sys.executable, "-m", "ifcquery", path, "clash", str(wall1.id()), "--scope", "all"], capture_output=True, diff --git a/src/ifcquery/tests/test_contexts.py b/src/ifcquery/tests/test_contexts.py index f629373cea..c7a21acb96 100644 --- a/src/ifcquery/tests/test_contexts.py +++ b/src/ifcquery/tests/test_contexts.py @@ -15,6 +15,7 @@ class TestContexts: def test_model_context(self, model): import ifcopenshell.api.context + ifcopenshell.api.context.add_context(model, context_type="Model") result = contexts(model) assert len(result) == 1 @@ -26,6 +27,7 @@ class TestContexts: def test_subcontext(self, model): import ifcopenshell.api.context + model_ctx = ifcopenshell.api.context.add_context(model, context_type="Model") ifcopenshell.api.context.add_context( model, @@ -43,6 +45,7 @@ class TestContexts: def test_ids_are_integers(self, model): import ifcopenshell.api.context + ifcopenshell.api.context.add_context(model, context_type="Model") result = contexts(model) for entry in result: diff --git a/src/ifcquery/tests/test_info.py b/src/ifcquery/tests/test_info.py index 8e8735e3f4..c17331a24d 100644 --- a/src/ifcquery/tests/test_info.py +++ b/src/ifcquery/tests/test_info.py @@ -7,6 +7,7 @@ import ifcopenshell.api.root import ifcopenshell.api.unit import ifcopenshell.util.representation import ifcopenshell.util.shape_builder + from ifcquery.info import info @@ -52,8 +53,11 @@ class TestGeometrySummary: ifcopenshell.api.unit.assign_unit(f) model_ctx = ifcopenshell.api.context.add_context(f, context_type="Model") ifcopenshell.api.context.add_context( - f, context_type="Model", context_identifier="Body", - target_view="MODEL_VIEW", parent=model_ctx, + f, + context_type="Model", + context_identifier="Body", + target_view="MODEL_VIEW", + parent=model_ctx, ) wall = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="W1") ifcopenshell.api.geometry.edit_object_placement(f, product=wall) @@ -80,7 +84,11 @@ class TestGeometrySummary: f, wall = self._make_model_with_wall() body = self._body_context(f) rep = ifcopenshell.api.geometry.add_wall_representation( - f, context=body, length=5.0, height=4.0, thickness=0.2, + f, + context=body, + length=5.0, + height=4.0, + thickness=0.2, clippings=[{"location": (0.0, 0.0, 3.0), "normal": (0.0, 0.0, 1.0)}], ) ifcopenshell.api.geometry.assign_representation(f, product=wall, representation=rep) @@ -95,6 +103,7 @@ class TestGeometrySummary: def test_geometry_summary_json_serializable(self): import json + f, wall = self._make_model_with_wall() body = self._body_context(f) rep = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=5.0, height=3.0, thickness=0.2) diff --git a/src/ifcquery/tests/test_plot.py b/src/ifcquery/tests/test_plot.py index 142be30bdc..4c003ab946 100644 --- a/src/ifcquery/tests/test_plot.py +++ b/src/ifcquery/tests/test_plot.py @@ -17,7 +17,7 @@ import ifcopenshell.api.spatial import ifcopenshell.api.unit import pytest -from ifcquery.plot import plot, _highlight_css_from_ids +from ifcquery.plot import _highlight_css_from_ids, plot try: import ifcopenshell.draw # noqa: F401 diff --git a/src/ifcquery/tests/test_render.py b/src/ifcquery/tests/test_render.py index 4f453c4ddc..28d83a6b68 100644 --- a/src/ifcquery/tests/test_render.py +++ b/src/ifcquery/tests/test_render.py @@ -6,7 +6,6 @@ import tempfile import ifcopenshell import ifcopenshell.api.aggregate -import ifcopenshell.guid import ifcopenshell.api.context import ifcopenshell.api.geometry import ifcopenshell.api.owner.settings @@ -14,10 +13,11 @@ import ifcopenshell.api.project import ifcopenshell.api.root import ifcopenshell.api.spatial import ifcopenshell.api.unit +import ifcopenshell.guid import numpy as np import pytest -from ifcquery.render import render, _make_type_occurrence, _make_profile_occurrence +from ifcquery.render import _make_profile_occurrence, _make_type_occurrence, render try: import pyvista # noqa: F401