black and ruff

This commit is contained in:
Bruno Postle
2026-03-23 23:15:08 +00:00
parent b52a23cb93
commit 901306a3fd
34 changed files with 1794 additions and 150 deletions
+5 -2
View File
@@ -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
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+2 -1
View File
@@ -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()
main()
+53 -16
View File
@@ -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,
},
},
]
]
+2 -1
View File
@@ -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
+2 -3
View File
@@ -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
return server
+1 -1
View File
@@ -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
+33 -9
View File
@@ -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
@@ -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 <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/>.
__version__ = version = "0.0.0"
+251
View File
@@ -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 <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
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: <ifc_file>.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()
+167
View File
@@ -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 <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
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
@@ -0,0 +1,44 @@
# 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
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
+45
View File
@@ -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
+118
View File
@@ -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 <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 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
@@ -0,0 +1,86 @@
# 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
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
@@ -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 <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 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)
+377
View File
@@ -0,0 +1,377 @@
# 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
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)
@@ -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
+19
View File
@@ -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)
+41
View File
@@ -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 <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 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
@@ -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 <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 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
+68
View File
@@ -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 <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 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]
@@ -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}
+55 -17
View File
@@ -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: <ifc_file>.svg/.png"
"-o",
"--output",
default="",
metavar="FILE",
help="Output file path. Default depends on --out-format: <ifc_file>.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))
+56 -42
View File
@@ -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
+14 -19
View File
@@ -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":
+6 -14
View File
@@ -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",
+16 -16
View File
@@ -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,
+3
View File
@@ -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:
+12 -3
View File
@@ -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)
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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