From cf3602bee1ce64ba12215cb0dcc3a8e07a0e6f71 Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Mon, 23 Mar 2026 23:22:47 +0000 Subject: [PATCH] Add ifcquery CLI tool for IFC model interrogation --- src/ifcquery/README.md | 380 ++++++++++++++++++++++ src/ifcquery/ifcquery/__init__.py | 20 ++ src/ifcquery/ifcquery/__main__.py | 378 ++++++++++++++++++++++ src/ifcquery/ifcquery/clash.py | 167 ++++++++++ src/ifcquery/ifcquery/contexts.py | 44 +++ src/ifcquery/ifcquery/cost.py | 45 +++ src/ifcquery/ifcquery/info.py | 262 +++++++++++++++ src/ifcquery/ifcquery/materials.py | 100 ++++++ src/ifcquery/ifcquery/plot.py | 284 ++++++++++++++++ src/ifcquery/ifcquery/relations.py | 169 ++++++++++ src/ifcquery/ifcquery/render.py | 465 +++++++++++++++++++++++++++ src/ifcquery/ifcquery/schedule.py | 56 ++++ src/ifcquery/ifcquery/schema.py | 19 ++ src/ifcquery/ifcquery/select.py | 41 +++ src/ifcquery/ifcquery/summary.py | 52 +++ src/ifcquery/ifcquery/tree.py | 68 ++++ src/ifcquery/ifcquery/validate.py | 15 + src/ifcquery/pyproject.toml | 33 ++ src/ifcquery/tests/__init__.py | 1 + src/ifcquery/tests/conftest.py | 36 +++ src/ifcquery/tests/test_clash.py | 330 +++++++++++++++++++ src/ifcquery/tests/test_contexts.py | 52 +++ src/ifcquery/tests/test_cost.py | 108 +++++++ src/ifcquery/tests/test_info.py | 112 +++++++ src/ifcquery/tests/test_main.py | 84 +++++ src/ifcquery/tests/test_materials.py | 52 +++ src/ifcquery/tests/test_plot.py | 265 +++++++++++++++ src/ifcquery/tests/test_relations.py | 158 +++++++++ src/ifcquery/tests/test_render.py | 355 ++++++++++++++++++++ src/ifcquery/tests/test_schedule.py | 121 +++++++ src/ifcquery/tests/test_schema.py | 32 ++ src/ifcquery/tests/test_select.py | 32 ++ src/ifcquery/tests/test_summary.py | 34 ++ src/ifcquery/tests/test_tree.py | 37 +++ src/ifcquery/tests/test_validate.py | 47 +++ 35 files changed, 4454 insertions(+) create mode 100644 src/ifcquery/README.md create mode 100644 src/ifcquery/ifcquery/__init__.py create mode 100644 src/ifcquery/ifcquery/__main__.py create mode 100644 src/ifcquery/ifcquery/clash.py create mode 100644 src/ifcquery/ifcquery/contexts.py create mode 100644 src/ifcquery/ifcquery/cost.py create mode 100644 src/ifcquery/ifcquery/info.py create mode 100644 src/ifcquery/ifcquery/materials.py create mode 100644 src/ifcquery/ifcquery/plot.py create mode 100644 src/ifcquery/ifcquery/relations.py create mode 100644 src/ifcquery/ifcquery/render.py create mode 100644 src/ifcquery/ifcquery/schedule.py create mode 100644 src/ifcquery/ifcquery/schema.py create mode 100644 src/ifcquery/ifcquery/select.py create mode 100644 src/ifcquery/ifcquery/summary.py create mode 100644 src/ifcquery/ifcquery/tree.py create mode 100644 src/ifcquery/ifcquery/validate.py create mode 100644 src/ifcquery/pyproject.toml create mode 100644 src/ifcquery/tests/__init__.py create mode 100644 src/ifcquery/tests/conftest.py create mode 100644 src/ifcquery/tests/test_clash.py create mode 100644 src/ifcquery/tests/test_contexts.py create mode 100644 src/ifcquery/tests/test_cost.py create mode 100644 src/ifcquery/tests/test_info.py create mode 100644 src/ifcquery/tests/test_main.py create mode 100644 src/ifcquery/tests/test_materials.py create mode 100644 src/ifcquery/tests/test_plot.py create mode 100644 src/ifcquery/tests/test_relations.py create mode 100644 src/ifcquery/tests/test_render.py create mode 100644 src/ifcquery/tests/test_schedule.py create mode 100644 src/ifcquery/tests/test_schema.py create mode 100644 src/ifcquery/tests/test_select.py create mode 100644 src/ifcquery/tests/test_summary.py create mode 100644 src/ifcquery/tests/test_tree.py create mode 100644 src/ifcquery/tests/test_validate.py diff --git a/src/ifcquery/README.md b/src/ifcquery/README.md new file mode 100644 index 0000000000..6f5062c1ab --- /dev/null +++ b/src/ifcquery/README.md @@ -0,0 +1,380 @@ + +# ifcquery + +A CLI tool for querying and inspecting IFC building models. All output is +structured JSON (or human-readable text), making it easy to pipe into other +tools or scripts. + +## Installation + +```bash +pip install ifcquery +``` + +Requires `ifcopenshell`. The `clash` subcommand additionally requires the +IfcOpenShell C++ geometry bindings (`ifcopenshell.geom`). + +## Usage + +``` +ifcquery [options] [--format json|text] +``` + +The `--format` flag controls output. Default is `json`; use `text` for +indented human-readable output. + +## Subcommands + +### summary + +Get a model overview: schema version, entity counts, and project info. + +```bash +ifcquery model.ifc summary +``` + +```json +{ + "schema": "IFC4", + "total_entities": 1847, + "project": { + "id": 1, + "name": "Office Building", + "description": null + }, + "types": { + "IfcWall": 42, + "IfcSlab": 12, + "IfcWindow": 36 + } +} +``` + +### tree + +Display the spatial hierarchy from IfcProject down through sites, buildings, +storeys, and their contained elements. + +```bash +ifcquery model.ifc tree +``` + +```json +{ + "id": 1, + "type": "IfcProject", + "name": "Office Building", + "children": [ + { + "id": 2, + "type": "IfcSite", + "name": "Default Site", + "children": [ + { + "id": 3, + "type": "IfcBuilding", + "name": "Main Building", + "children": [ + { + "id": 4, + "type": "IfcBuildingStorey", + "name": "Ground Floor", + "elements": [ + {"id": 10, "type": "IfcWall", "name": "Wall001"}, + {"id": 11, "type": "IfcSlab", "name": "Floor001"} + ] + } + ] + } + ] + } + ] +} +``` + +### info + +Get detailed information about a specific element by step ID. + +```bash +ifcquery model.ifc info 10 +ifcquery model.ifc info '#10' +``` + +Returns attributes, property sets, type relationship, material assignment, +spatial container, and placement matrix. + +```json +{ + "id": 10, + "type": "IfcWall", + "attributes": { + "Name": "Wall001", + "Description": null, + "ObjectType": "LOADBEARING" + }, + "property_sets": { + "Pset_WallCommon": { + "IsExternal": true, + "FireRating": "2HR" + } + }, + "element_type": {"id": 50, "type": "IfcWallType", "name": "Standard"}, + "material": {"id": 60, "type": "IfcMaterial", "name": "Concrete"}, + "container": {"id": 4, "type": "IfcBuildingStorey", "name": "Ground Floor"}, + "placement": [ + [1.0, 0.0, 0.0, 5.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0] + ] +} +``` + +### select + +Filter elements using the ifcopenshell selector syntax. + +```bash +ifcquery model.ifc select 'IfcWall' +ifcquery model.ifc select 'IfcWall, IfcSlab' +``` + +```json +[ + {"id": 10, "type": "IfcWall", "name": "Wall001"}, + {"id": 11, "type": "IfcWall", "name": "Wall002"}, + {"id": 20, "type": "IfcSlab", "name": "Floor001"} +] +``` + +Results are sorted by ID. + +### relations + +Show all relationships for an element, organized by category: hierarchy, +children, type relationships, groups, systems, material, and connections. + +```bash +ifcquery model.ifc relations 10 +``` + +```json +{ + "id": 10, + "type": "IfcWall", + "name": "Wall001", + "hierarchy": { + "parent": {"id": 4, "type": "IfcBuildingStorey", "name": "Ground Floor"}, + "container": {"id": 4, "type": "IfcBuildingStorey", "name": "Ground Floor"} + }, + "children": { + "openings": [{"id": 30, "type": "IfcOpeningElement", "name": "Opening01"}] + }, + "type_relationship": { + "type_of": {"id": 50, "type": "IfcWallType", "name": "Standard"} + }, + "material": {"id": 60, "type": "IfcMaterial", "name": "Concrete"} +} +``` + +Empty categories are omitted from output. + +Use `--traverse up` to walk the spatial hierarchy from the element up to +IfcProject: + +```bash +ifcquery model.ifc relations 10 --traverse up +``` + +```json +[ + {"id": 10, "type": "IfcWall", "name": "Wall001"}, + {"id": 4, "type": "IfcBuildingStorey", "name": "Ground Floor"}, + {"id": 3, "type": "IfcBuilding", "name": "Main Building"}, + {"id": 2, "type": "IfcSite", "name": "Default Site"}, + {"id": 1, "type": "IfcProject", "name": "Office Building"} +] +``` + +### validate + +Check the model for schema and constraint violations. + +```bash +ifcquery model.ifc validate +ifcquery model.ifc validate --rules +``` + +Options: + +- `--rules` -- also run the slower EXPRESS rules check (default: off) + +```json +{ + "valid": true, + "issues": [] +} +``` + +On an invalid model: + +```json +{ + "valid": false, + "issues": [ + {"level": "ERROR", "message": "Entity #42 IfcWall.GlobalId is not a valid IfcGloballyUniqueId"} + ] +} +``` + +### schedule + +List all work schedules and their task trees from the model. + +```bash +ifcquery model.ifc schedule +ifcquery model.ifc schedule --depth 1 +``` + +Options: + +- `--depth N` -- expand at most N levels of subtasks (default: unlimited). At the + cutoff, `subtasks` is replaced with `{"truncated": true, "count": N}`. + +```json +[ + { + "id": 42, + "name": "Construction Schedule", + "predefined_type": "BASELINE", + "tasks": [ + { + "id": 55, + "name": "Phase 1", + "start": "2024-01-01T09:00:00", + "finish": "2024-06-30T17:00:00", + "is_milestone": false, + "outputs": [{"id": 10, "type": "IfcWall", "name": "Wall A"}], + "subtasks": [ + {"id": 56, "name": "Foundations", "start": null, "finish": null, + "is_milestone": false, "outputs": [], "subtasks": []} + ] + } + ] + } +] +``` + +### cost + +List all cost schedules and their cost item trees from the model. + +```bash +ifcquery model.ifc cost +ifcquery model.ifc cost --depth 2 +``` + +Options: + +- `--depth N` -- expand at most N levels of subitems (default: unlimited). At the + cutoff, `subitems` is replaced with `{"truncated": true, "count": N}`. + +```json +[ + { + "id": 100, + "name": "Bill of Quantities", + "predefined_type": "COSTPLAN", + "items": [ + { + "id": 110, + "name": "Concrete Works", + "values": [{"formula": "1200.00 = material(1200.0)", "category": "material"}], + "subitems": [ + {"id": 111, "name": "Formwork", "values": [], "subitems": []} + ] + } + ] + } +] +``` + +### schema + +Show IFC class documentation for any entity type, using the schema version of +the loaded model. + +```bash +ifcquery model.ifc schema IfcWall +ifcquery model.ifc schema IfcBuildingStorey +``` + +```json +{ + "description": "The wall represents a vertical construction ...", + "predefined_types": {"STANDARD": "A standard wall, extruded vertically ..."}, + "spec_url": "https://standards.buildingsmart.org/...", + "attributes": { + "Name": "Optional name for use by the participating software systems", + "ObjectPlacement": "Placement of the product in space ..." + } +} +``` + +Returns `{"error": "Unknown entity: Foo"}` for unrecognised types. + +### clash + +Check a single element for geometric intersections and clearance violations +against other elements. + +```bash +ifcquery model.ifc clash 10 +ifcquery model.ifc clash 10 --clearance 0.5 +ifcquery model.ifc clash 10 --scope all --tolerance 0.001 +``` + +Options: + +- `--clearance ` -- minimum clearance distance to check +- `--tolerance ` -- intersection tolerance (default: 0.002) +- `--scope {storey,all}` -- check against same-storey elements or all elements (default: storey) + +```json +{ + "element": {"id": 10, "type": "IfcWall", "name": "Wall001"}, + "scope": "storey", + "pass": false, + "checks": { + "intersection": { + "pass": false, + "tolerance": 0.002, + "clashes": [ + { + "element": {"id": 11, "type": "IfcWall", "name": "Wall002"}, + "type": "intersection", + "distance": 0.0, + "p1": [2.5, 2.5, 1.5], + "p2": [2.5, 2.5, 1.5] + } + ] + }, + "clearance": { + "pass": true, + "clearance": 0.5, + "clashes": [] + } + } +} +``` + +Requires the IfcOpenShell C++ geometry bindings. + +## Error handling + +Errors are written to stderr. Exit code is 0 on success, 1 on error. + +## License + +LGPLv3+ -- see the IfcOpenShell project license. diff --git a/src/ifcquery/ifcquery/__init__.py b/src/ifcquery/ifcquery/__init__.py new file mode 100644 index 0000000000..be5327b20d --- /dev/null +++ b/src/ifcquery/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/ifcquery/__main__.py b/src/ifcquery/ifcquery/__main__.py new file mode 100644 index 0000000000..a7a59f3ae5 --- /dev/null +++ b/src/ifcquery/ifcquery/__main__.py @@ -0,0 +1,378 @@ +# 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, + 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 + + +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)", + ) + + plot_parser = subparsers.add_parser( + "plot", help="Plot model drawing (SVG via ifcopenshell.draw; optional PNG via CairoSVG)" + ) + plot_parser.add_argument( + "-o", + "--output", + default="", + metavar="FILE", + help="Output file path. Default depends on --out-format: .svg/.png", + ) + plot_parser.add_argument( + "--out-format", + choices=["svg", "png", "base64"], + default="png", + help="Output format: svg (write SVG), png (write PNG), base64 (print base64 in JSON/text). Default: png", + ) + plot_parser.add_argument( + "--selector", default="", metavar="QUERY", help="ifcopenshell selector to restrict plotted elements" + ) + plot_parser.add_argument( + "--element", default="", metavar="ID[,ID...]", help="Comma-separated step IDs of elements to highlight" + ) + plot_parser.add_argument( + "--view", + choices=getattr(plot, "VIEWS", ("floorplan", "elevation", "section", "auto")), + default="floorplan", + help="Drawing view (default: floorplan)", + ) + plot_parser.add_argument( + "--width-mm", + type=float, + default=297.0, + metavar="MM", + help="Paper width in mm (default: 297)", + ) + plot_parser.add_argument( + "--height-mm", + type=float, + default=420.0, + metavar="MM", + help="Paper height in mm (default: 420)", + ) + plot_parser.add_argument( + "--scale", + type=float, + default=1.0 / 100.0, + metavar="S", + help="Model-to-paper scale (default: 0.01 = 1:100)", + ) + plot_parser.add_argument( + "--png-width", + type=int, + default=1024, + metavar="PX", + help="PNG width in pixels (default: 1024)", + ) + plot_parser.add_argument( + "--png-height", + type=int, + default=1024, + metavar="PX", + help="PNG height in pixels (default: 1024)", + ) + + args = parser.parse_args() + + try: + 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 + elif args.command == "plot": + element_ids = None + if args.element: + try: + element_ids = [parse_element_id(part) for part in args.element.split(",")] + except ValueError: + print(f"Error: Invalid element ID(s): {args.element}", file=sys.stderr) + sys.exit(1) + + try: + result = plot.plot( + model, + selector=args.selector or None, + element_ids=element_ids, + view=args.view, + width_mm=args.width_mm, + height_mm=args.height_mm, + scale=args.scale, + output_format=args.out_format, + ) + 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) + + if args.out_format == "base64": + # result is a dict; serialise to stdout so callers can consume it + print(format_output(result, args.output_format)) + return + + # svg or png: write to a file + base = os.path.splitext(args.ifc_file)[0] + if args.out_format == "svg": + out_path = args.output or (base + ".svg") + else: + out_path = args.output or (base + ".png") + + with open(out_path, "wb") as f: + f.write(result) + + print(f"Saved drawing to {out_path}", file=sys.stderr) + return + + print(format_output(result, args.output_format)) + + +if __name__ == "__main__": + main() diff --git a/src/ifcquery/ifcquery/clash.py b/src/ifcquery/ifcquery/clash.py new file mode 100644 index 0000000000..7e3583e0aa --- /dev/null +++ b/src/ifcquery/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/ifcquery/contexts.py b/src/ifcquery/ifcquery/contexts.py new file mode 100644 index 0000000000..912df173d7 --- /dev/null +++ b/src/ifcquery/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/ifcquery/cost.py b/src/ifcquery/ifcquery/cost.py new file mode 100644 index 0000000000..ef9559d597 --- /dev/null +++ b/src/ifcquery/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/ifcquery/info.py b/src/ifcquery/ifcquery/info.py new file mode 100644 index 0000000000..ad9a9daa4f --- /dev/null +++ b/src/ifcquery/ifcquery/info.py @@ -0,0 +1,262 @@ +# 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 + +# --------------------------------------------------------------------------- +# Geometry summary helpers +# --------------------------------------------------------------------------- + +_MAX_PROFILE_POINTS = 20 + + +def _rc(coords) -> list[float]: + """Round a coordinate sequence to 6 decimal places.""" + return [round(float(c), 6) for c in coords] + + +def _curve_points(curve) -> list | None: + if curve.is_a("IfcPolyline"): + return [_rc(p.Coordinates) for p in curve.Points] + if curve.is_a("IfcIndexedPolyCurve"): + return [_rc(c) for c in curve.Points.CoordList] + return None + + +def _profile_summary(profile) -> dict: + t = profile.is_a() + result: dict[str, Any] = {"type": t} + if t == "IfcRectangleProfileDef": + result["x_dim"] = profile.XDim + result["y_dim"] = profile.YDim + elif t in ("IfcCircleProfileDef", "IfcCircleHollowProfileDef"): + result["radius"] = profile.Radius + if t == "IfcCircleHollowProfileDef": + result["wall_thickness"] = profile.WallThickness + elif t in ("IfcArbitraryClosedProfileDef", "IfcArbitraryProfileDefWithVoids"): + pts = _curve_points(profile.OuterCurve) + if pts is not None: + if len(pts) <= _MAX_PROFILE_POINTS: + result["points"] = pts + else: + result["point_count"] = len(pts) + elif t == "IfcCompositeProfileDef": + result["profiles"] = [_profile_summary(p) for p in profile.Profiles] + return result + + +def _half_space_plane(half_space) -> dict | None: + if not half_space.is_a("IfcHalfSpaceSolid"): + return None + surface = half_space.BaseSurface + if not surface or not surface.is_a("IfcPlane"): + return None + pos = surface.Position + loc = _rc(pos.Location.Coordinates) + normal = _rc(pos.Axis.DirectionRatios) if pos.Axis else [0.0, 0.0, 1.0] + return {"location": loc, "normal": normal} + + +def _walk_clipping(item) -> tuple: + """Return (base_solid, [clipping_plane_dicts]) from a BooleanClippingResult chain.""" + planes = [] + current = item + while current.is_a("IfcBooleanClippingResult"): + plane = _half_space_plane(current.SecondOperand) + if plane: + planes.append(plane) + current = current.FirstOperand + return current, planes + + +def _swept_solid_dict(item) -> dict: + result: dict[str, Any] = {"solid_type": item.is_a()} + if item.is_a("IfcExtrudedAreaSolid"): + result["depth"] = item.Depth + if item.ExtrudedDirection: + result["direction"] = _rc(item.ExtrudedDirection.DirectionRatios) + if item.SweptArea: + result["profile"] = _profile_summary(item.SweptArea) + return result + + +def _summarize_rep(rep) -> dict: + rep_type = rep.RepresentationType or "" + result: dict[str, Any] = {"representation_type": rep_type} + items = list(rep.Items) + + if rep_type == "MappedRepresentation": + for item in items: + if item.is_a("IfcMappedItem"): + return _summarize_rep(item.MappingSource.MappedRepresentation) + + elif rep_type == "SweptSolid": + result["solids"] = [_swept_solid_dict(item) for item in items] + + elif rep_type == "Clipping": + solids = [] + for item in items: + base, planes = _walk_clipping(item) + solid = _swept_solid_dict(base) + if planes: + solid["clipping_planes"] = planes + solids.append(solid) + result["solids"] = solids + + elif rep_type == "CSG": + ops = [] + for item in items: + if hasattr(item, "Operator"): + ops.append({"operator": str(item.Operator), "type": item.is_a()}) + if ops: + result["operations"] = ops + + elif rep_type in ("Brep", "Tessellation", "SolidModel"): + face_count = 0 + vertex_count = 0 + for item in items: + if item.is_a("IfcPolygonalFaceSet"): + face_count += len(item.Faces) + vertex_count += len(item.Coordinates.CoordList) + elif item.is_a("IfcFacetedBrep"): + face_count += len(item.Outer.CfsFaces) + if face_count: + result["face_count"] = face_count + if vertex_count: + result["vertex_count"] = vertex_count + + return result + + +def _geometry_summary(element) -> dict | None: + if not hasattr(element, "Representation") or not element.Representation: + return None + body_rep = next( + (r for r in element.Representation.Representations if r.RepresentationIdentifier == "Body"), + None, + ) + if body_rep is None: + return None + try: + return _summarize_rep(body_rep) + except Exception: + return None + + +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 + + # Geometry summary + geom = _geometry_summary(element) + if geom: + result["geometry_summary"] = geom + + return result diff --git a/src/ifcquery/ifcquery/materials.py b/src/ifcquery/ifcquery/materials.py new file mode 100644 index 0000000000..c7402d51e5 --- /dev/null +++ b/src/ifcquery/ifcquery/materials.py @@ -0,0 +1,100 @@ +# 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/ifcquery/plot.py b/src/ifcquery/ifcquery/plot.py new file mode 100644 index 0000000000..3393a87fc8 --- /dev/null +++ b/src/ifcquery/ifcquery/plot.py @@ -0,0 +1,284 @@ +# 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 base64 +import os +from io import BytesIO +from typing import Any + +import ifcopenshell +import ifcopenshell.geom +import ifcopenshell.util.selector + +try: + import ifcopenshell.draw + + _HAS_DRAW = True +except ImportError: + _HAS_DRAW = False + +from xml.etree.ElementTree import Element, ElementTree, SubElement, register_namespace + +try: + import cairosvg # type: ignore + + _HAS_CAIROSVG = True +except Exception: + _HAS_CAIROSVG = False + + +try: + from PIL import Image # type: ignore + + _HAS_PIL = True +except Exception: + _HAS_PIL = False + +VIEWS = ("floorplan", "elevation", "section", "auto") +OUTPUT_FORMATS = ("svg", "png", "base64") + + +def _escape_css_attr(name: str) -> str: + # CSS attribute selectors must escape ':' (e.g. ifc:guid -> ifc\:guid) + return name.replace(":", "\\:") + + +def _highlight_css_from_ids(model: ifcopenshell.file, element_ids: list[int]) -> str: + guids: list[str] = [] + for sid in element_ids: + try: + e = model.by_id(int(sid)) + except RuntimeError: + continue + if e is None: + continue + gid = getattr(e, "GlobalId", None) + if isinstance(gid, str) and gid: + guids.append(gid) + + if not guids: + return "" + + attr = _escape_css_attr("ifc:guid") + + css = [ + "/* Auto-highlight injected by ifcquery.plot */", + f"[{attr}] path {{ opacity: 0.10; }}", + f"[{attr}] text {{ opacity: 0.25; }}", + ] + for gid in guids: + css.append(f'[{attr}="{gid}"] path {{ opacity: 1.0; stroke: #d00; stroke-width: 0.25; }}') + css.append(f'[{attr}="{gid}"] text {{ opacity: 1.0; fill: #d00; }}') + return "\n".join(css) + "\n" + + +def _make_filtered_iterator(model: ifcopenshell.file, include_elements: list[Any]) -> ifcopenshell.geom.iterator: + # Avoid multiprocessing in WASM; os.cpu_count is good enough. + n_threads = os.cpu_count() or 1 + + # These flags mirror the defaults used by ifcopenshell.draw in v0.8.x. + geom_settings = ifcopenshell.geom.settings( + REORIENT_SHELLS=False, + ELEMENT_HIERARCHY=True, + ) + + # IfcOpenShell wrapper constants may live in different places across builds. + wrapper = getattr(ifcopenshell, "ifcopenshell_wrapper", None) + if wrapper is not None: + try: + geom_settings.set("iterator-output", wrapper.NATIVE) + except Exception: + pass + try: + geom_settings.set("apply-default-materials", True) + except Exception: + pass + try: + geom_settings.set("dimensionality", wrapper.SURFACES_AND_SOLIDS) + except Exception: + pass + + return ifcopenshell.geom.iterator(geom_settings, model, n_threads, include=include_elements) + + +def _diagnose_empty_drawing(model: ifcopenshell.file, view: str) -> str: + """Return a helpful error message when ifcopenshell.draw produces no geometry groups.""" + hints = [] + + if view in ("floorplan", "auto"): + storeys = model.by_type("IfcBuildingStorey") + if not storeys: + hints.append("the model has no IfcBuildingStorey entities (required for auto_floorplan)") + else: + null_elevation = [s for s in storeys if getattr(s, "Elevation", None) is None] + if null_elevation: + names = ", ".join(f'"{s.Name or s.GlobalId}"' for s in null_elevation) + hints.append( + f"storey Elevation is None for: {names} — " + "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")) + if not has_geom: + hints.append("no IfcProduct entities have geometric representations") + + base = f"No plan geometry found for view={view!r}." + if hints: + return base + " Possible causes: " + "; ".join(hints) + "." + return base + " The model may lack geometry visible in this view." + + +def plot( + model: ifcopenshell.file, + *, + output_format: str = "png", + selector: str | None = None, + element_ids: list[int] | None = None, + view: str = "floorplan", + # SVG / page sizing (draw works in mm coordinates) + width_mm: float = 297.0, + height_mm: float = 420.0, + scale: float = 1.0 / 100.0, + merge_projection: bool = True, + # PNG sizing (only for output_format png/base64) + png_width: int = 1024, + png_height: int = 1024, +) -> bytes | dict[str, Any]: + """ + Plot IFC model as SVG (via ifcopenshell.draw) or PNG/base64 (via CairoSVG). + + Args: + model: In-memory IFC model. + output_format: 'svg' | 'png' | 'base64' + - 'svg' -> returns SVG bytes + - 'png' -> returns PNG bytes + - 'base64'-> returns dict: {mime, png_b64, width, height, view} + selector: ifcopenshell selector query to restrict plotted elements. + element_ids: STEP ids to highlight; non-highlighted geometry is faded. + view: One of VIEWS ('floorplan', 'elevation', 'section', 'auto'). + width_mm, height_mm: Page size in mm. + scale: Model-to-paper scale (0.01 means 1:100). + merge_projection: Passed through to ifcopenshell.draw.main. + png_width, png_height: Raster size in pixels for png/base64 outputs. + + Raises: + ImportError: if ifcopenshell.draw or CairoSVG is not available (as required). + ValueError: invalid args or selector matches nothing. + """ + if output_format not in OUTPUT_FORMATS: + raise ValueError(f"output_format must be one of {OUTPUT_FORMATS}, got {output_format!r}") + if view not in VIEWS: + raise ValueError(f"view must be one of {VIEWS}, got {view!r}") + if not _HAS_DRAW: + raise ImportError("ifcopenshell.draw is not available in this environment.") + + # Configure draw settings + settings = ifcopenshell.draw.draw_settings( + auto_floorplan=(view in ("floorplan", "auto")), + auto_elevation=(view in ("elevation", "auto")), + auto_section=(view in ("section", "auto")), + width=width_mm, + height=height_mm, + scale=scale, + css="", + ) + + # Optional highlight CSS overlay + if element_ids: + settings.css = _highlight_css_from_ids(model, element_ids) + + # Optional element restriction via selector -> custom iterator + iterators: tuple[Any, ...] = () + if selector: + include_elements = list(ifcopenshell.util.selector.filter_elements(model, selector)) + if not include_elements: + raise ValueError(f"Selector {selector!r} matched no elements") + it = _make_filtered_iterator(model, include_elements) + iterators = (it,) + # If we explicitly include elements, don't rely on exclude_entities (best-effort). + settings.exclude_entities = "" + + # Generate SVG + svg_bytes = ifcopenshell.draw.main( + settings, + files=[model], + iterators=iterators, + merge_projection=merge_projection, + ) + + register_namespace("", "http://www.w3.org/2000/svg") + + def svg_split(f): + x = ElementTree(file=f) + svg = x.getroot() + resources = [] + for child in svg: + if child.tag == "{http://www.w3.org/2000/svg}g": + root = Element(svg.tag, svg.attrib) + n = ElementTree(root) + for r in resources + [child]: + root.append(r) + b = BytesIO() + n.write(b, xml_declaration=True, encoding="utf-8", method="xml") + yield b.getvalue() + else: + resources.append(child) + + if output_format == "svg": + return svg_bytes + + # Need CairoSVG for png/base64 + if not _HAS_CAIROSVG: + raise ImportError("CairoSVG is not installed. Install with: pip install cairosvg") + + svgs = list(svg_split(BytesIO(svg_bytes))) + if not svgs: + raise ValueError(_diagnose_empty_drawing(model, view)) + + composite = None + png_bytes = None + for i, svgb in enumerate(svgs): + png_bytes = cairosvg.svg2png(bytestring=svgb, output_width=png_width, output_height=png_height) + if len(svgs) == 1: + break + + # Need Pillow for concatenating images + if not _HAS_PIL: + raise ImportError("Pillow is not installed. Install with: pip install Pillow") + + if composite is None: + composite = Image.new("RGBA", (png_width, png_height * len(svgs))) + img = Image.open(BytesIO(png_bytes)) + composite.paste(img, (0, png_height * i)) + if composite is not None: + b = BytesIO() + composite.save(b, "png") + png_bytes = b.getvalue() + + if output_format == "base64": + return { + "mime": "image/png", + "png_b64": base64.b64encode(png_bytes).decode(), + "width": png_width, + "height": png_height, + "view": view, + } + + return png_bytes diff --git a/src/ifcquery/ifcquery/relations.py b/src/ifcquery/ifcquery/relations.py new file mode 100644 index 0000000000..f8f12e9989 --- /dev/null +++ b/src/ifcquery/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/ifcquery/render.py b/src/ifcquery/ifcquery/render.py new file mode 100644 index 0000000000..44105e0ee2 --- /dev/null +++ b/src/ifcquery/ifcquery/render.py @@ -0,0 +1,465 @@ +# 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 _make_profile_occurrence(model: ifcopenshell.file, type_entity) -> object | None: + """Create a temporary occurrence for a type that has a material profile set. + + Finds the first profile in the type's IfcMaterialProfileSet and creates a + 1-metre IfcExtrudedAreaSolid body representation from it. Returns the + occurrence, or ``None`` when no usable profile is found. + + .. note:: + Intended for use on a temporary model copy; caller discards it after + rendering. + """ + # Locate the first profile from the type's material profile set. + profile = None + for rel in getattr(type_entity, "HasAssociations", []): + if not rel.is_a("IfcRelAssociatesMaterial"): + continue + mat = rel.RelatingMaterial + if mat.is_a("IfcMaterialProfileSetUsage"): + mat = mat.ForProfileSet + if mat.is_a("IfcMaterialProfileSet"): + mat_profiles = list(getattr(mat, "MaterialProfiles", None) or []) + if mat_profiles: + profile = getattr(mat_profiles[0], "Profile", None) + if profile is not None: + break + if profile is None: + return None + + # Find a Body subcontext, or fall back to any Model context. + body_ctx = None + for ctx in model.by_type("IfcGeometricRepresentationSubContext"): + if ctx.ContextIdentifier == "Body": + body_ctx = ctx + break + if body_ctx is None: + for ctx in model.by_type("IfcGeometricRepresentationContext"): + if ctx.ContextType == "Model": + body_ctx = ctx + break + if body_ctx is None: + return None + + # Extrude 1 metre along Z (profile lies in XY plane). + 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) + extrude_dir = model.create_entity("IfcDirection", DirectionRatios=(0.0, 0.0, 1.0)) + extrusion = model.create_entity( + "IfcExtrudedAreaSolid", + SweptArea=profile, + Position=position, + ExtrudedDirection=extrude_dir, + Depth=1.0, + ) + shape_rep = model.create_entity( + "IfcShapeRepresentation", + ContextOfItems=body_ctx, + RepresentationIdentifier="Body", + RepresentationType="SweptSolid", + Items=[extrusion], + ) + 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"_profile_preview_{type_entity.id()}", + ObjectPlacement=placement, + Representation=prod_def_shape, + ) + except Exception: + occurrence = model.create_entity( + "IfcBuildingElementProxy", + GlobalId=ifcopenshell.guid.new(), + Name=f"_profile_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) or _make_profile_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 or material profile sets 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/ifcquery/schedule.py b/src/ifcquery/ifcquery/schedule.py new file mode 100644 index 0000000000..b3e3ae45d6 --- /dev/null +++ b/src/ifcquery/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/ifcquery/schema.py b/src/ifcquery/ifcquery/schema.py new file mode 100644 index 0000000000..ad47486db2 --- /dev/null +++ b/src/ifcquery/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/ifcquery/select.py b/src/ifcquery/ifcquery/select.py new file mode 100644 index 0000000000..c28a159b90 --- /dev/null +++ b/src/ifcquery/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/ifcquery/summary.py b/src/ifcquery/ifcquery/summary.py new file mode 100644 index 0000000000..d13f070472 --- /dev/null +++ b/src/ifcquery/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/ifcquery/tree.py b/src/ifcquery/ifcquery/tree.py new file mode 100644 index 0000000000..1cbbaeb986 --- /dev/null +++ b/src/ifcquery/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/ifcquery/validate.py b/src/ifcquery/ifcquery/validate.py new file mode 100644 index 0000000000..d35d9150af --- /dev/null +++ b/src/ifcquery/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/pyproject.toml b/src/ifcquery/pyproject.toml new file mode 100644 index 0000000000..3c8e734474 --- /dev/null +++ b/src/ifcquery/pyproject.toml @@ -0,0 +1,33 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "ifcquery" +version = "0.0.0" +authors = [ + { name="Bruno Postle", email="bruno@postle.net" }, +] +description = "CLI tool for querying and inspecting IFC building models" +readme = "README.md" +keywords = ["IFC", "BIM", "Query"] +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)", +] +dependencies = ["ifcopenshell"] + +[project.scripts] +ifcquery = "ifcquery.__main__:main" + +[project.urls] +Homepage = "http://ifcopenshell.org" +Documentation = "https://docs.ifcopenshell.org" +Issues = "https://github.com/IfcOpenShell/IfcOpenShell/issues" + +[tool.setuptools.packages.find] +include = ["ifcquery*"] +exclude = ["test*"] + +[tool.ruff] +extend = "../../pyproject.toml" diff --git a/src/ifcquery/tests/__init__.py b/src/ifcquery/tests/__init__.py new file mode 100644 index 0000000000..0a3bc271a0 --- /dev/null +++ b/src/ifcquery/tests/__init__.py @@ -0,0 +1 @@ +# This file was generated with the assistance of an AI coding tool. diff --git a/src/ifcquery/tests/conftest.py b/src/ifcquery/tests/conftest.py new file mode 100644 index 0000000000..496e74cd6e --- /dev/null +++ b/src/ifcquery/tests/conftest.py @@ -0,0 +1,36 @@ +# This file was generated with the assistance of an AI coding tool. +import ifcopenshell +import ifcopenshell.api.aggregate +import ifcopenshell.api.owner.settings +import ifcopenshell.api.project +import ifcopenshell.api.root +import ifcopenshell.api.spatial +import ifcopenshell.api.unit +import pytest + + +@pytest.fixture +def model(): + """Create an IFC4 model with a spatial hierarchy and a wall.""" + f = ifcopenshell.api.project.create_file() + ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0] + ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0] + + project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="TestProject") + ifcopenshell.api.unit.assign_unit(f) + + site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="TestSite") + building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="TestBuilding") + storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="Ground Floor") + + ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project) + ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site) + ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=building) + + wall = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall001") + ifcopenshell.api.spatial.assign_container(f, products=[wall], relating_structure=storey) + + slab = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSlab", name="Slab001") + ifcopenshell.api.spatial.assign_container(f, products=[slab], relating_structure=storey) + + return f diff --git a/src/ifcquery/tests/test_clash.py b/src/ifcquery/tests/test_clash.py new file mode 100644 index 0000000000..150200f95c --- /dev/null +++ b/src/ifcquery/tests/test_clash.py @@ -0,0 +1,330 @@ +# This file was generated with the assistance of an AI coding tool. +import json +import os +import subprocess +import sys +import tempfile + +import ifcopenshell +import ifcopenshell.api.aggregate +import ifcopenshell.api.context +import ifcopenshell.api.geometry +import ifcopenshell.api.owner.settings +import ifcopenshell.api.project +import ifcopenshell.api.root +import ifcopenshell.api.spatial +import ifcopenshell.api.unit +import numpy as np +import pytest + +from ifcquery.clash import clash + +try: + import ifcopenshell.geom + + HAS_GEOM = True +except ImportError: + HAS_GEOM = False + +pytestmark = pytest.mark.skipif(not HAS_GEOM, reason="ifcopenshell geometry engine not available") + + +@pytest.fixture +def model_with_geometry(): + """Create an IFC4 model with walls that have geometric representations.""" + f = ifcopenshell.api.project.create_file() + ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0] + ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0] + + project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="TestProject") + ifcopenshell.api.unit.assign_unit(f) + + site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="TestSite") + building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="TestBuilding") + storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="Ground Floor") + + ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project) + ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site) + ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=building) + + # Create geometry context + model_ctx = ifcopenshell.api.context.add_context(f, context_type="Model") + body = ifcopenshell.api.context.add_context( + f, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=model_ctx + ) + + # Wall 1 at origin + wall1 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall001") + rep1 = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=5, height=3, thickness=0.2) + ifcopenshell.api.geometry.assign_representation(f, product=wall1, representation=rep1) + ifcopenshell.api.spatial.assign_container(f, products=[wall1], relating_structure=storey) + + # Wall 2 perpendicular, crossing through wall 1 + wall2 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall002") + rep2 = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=5, height=3, thickness=0.2) + ifcopenshell.api.geometry.assign_representation(f, product=wall2, representation=rep2) + ifcopenshell.api.spatial.assign_container(f, products=[wall2], relating_structure=storey) + matrix2 = np.array([[0, -1, 0, 2.5], [1, 0, 0, -2.0], [0, 0, 1, 0], [0, 0, 0, 1]], dtype=float) + ifcopenshell.api.geometry.edit_object_placement(f, product=wall2, matrix=matrix2) + + # Wall 3 far away (10m offset in Y) + wall3 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall003") + rep3 = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=5, height=3, thickness=0.2) + ifcopenshell.api.geometry.assign_representation(f, product=wall3, representation=rep3) + ifcopenshell.api.spatial.assign_container(f, products=[wall3], relating_structure=storey) + matrix3 = np.eye(4) + matrix3[1, 3] = 10.0 # 10m in Y direction + ifcopenshell.api.geometry.edit_object_placement(f, product=wall3, matrix=matrix3) + + # Wall 4 close but not overlapping (0.3m offset in Y, wall thickness is 0.2m) + wall4 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall004") + rep4 = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=5, height=3, thickness=0.2) + ifcopenshell.api.geometry.assign_representation(f, product=wall4, representation=rep4) + ifcopenshell.api.spatial.assign_container(f, products=[wall4], relating_structure=storey) + matrix4 = np.eye(4) + matrix4[1, 3] = 0.3 # 0.3m in Y (gap of 0.1m from wall1) + ifcopenshell.api.geometry.edit_object_placement(f, product=wall4, matrix=matrix4) + + return f + + +@pytest.fixture +def model_two_storeys(): + """Create a model with walls in different storeys.""" + f = ifcopenshell.api.project.create_file() + ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0] + ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0] + + project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="TestProject") + ifcopenshell.api.unit.assign_unit(f) + + site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="TestSite") + building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="TestBuilding") + storey1 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="Ground Floor") + storey2 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="First Floor") + + ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project) + ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site) + ifcopenshell.api.aggregate.assign_object(f, products=[storey1, storey2], relating_object=building) + + model_ctx = ifcopenshell.api.context.add_context(f, context_type="Model") + body = ifcopenshell.api.context.add_context( + f, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=model_ctx + ) + + # Wall in storey 1 + wall1 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="GroundWall") + rep1 = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=5, height=3, thickness=0.2) + ifcopenshell.api.geometry.assign_representation(f, product=wall1, representation=rep1) + ifcopenshell.api.spatial.assign_container(f, products=[wall1], relating_structure=storey1) + + # Wall in storey 2, perpendicular and crossing wall1 + wall2 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="FirstFloorWall") + rep2 = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=5, height=3, thickness=0.2) + ifcopenshell.api.geometry.assign_representation(f, product=wall2, representation=rep2) + ifcopenshell.api.spatial.assign_container(f, products=[wall2], relating_structure=storey2) + matrix2 = np.array([[0, -1, 0, 2.5], [1, 0, 0, -2.0], [0, 0, 1, 0], [0, 0, 0, 1]], dtype=float) + ifcopenshell.api.geometry.edit_object_placement(f, product=wall2, matrix=matrix2) + + return f + + +class TestNoClashes: + def test_no_clashes_far_apart(self, model_with_geometry): + 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 + assert result["checks"]["intersection"]["clashes"] == [] + + def test_no_clashes_empty_scope(self, model_with_geometry): + """A model where the element is the only one in scope should pass.""" + # Create a model with a single wall + f = ifcopenshell.api.project.create_file() + ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0] + ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0] + project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="P") + ifcopenshell.api.unit.assign_unit(f) + site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="S") + building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="B") + storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="GF") + ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project) + ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site) + ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=building) + model_ctx = ifcopenshell.api.context.add_context(f, context_type="Model") + body = ifcopenshell.api.context.add_context( + 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="OnlyWall") + rep = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=5, height=3, thickness=0.2) + ifcopenshell.api.geometry.assign_representation(f, product=wall, representation=rep) + ifcopenshell.api.spatial.assign_container(f, products=[wall], relating_structure=storey) + + result = clash(f, wall) + assert result["pass"] is True + + +class TestIntersectionDetected: + def test_overlapping_walls(self, model_with_geometry): + 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 + clashes = result["checks"]["intersection"]["clashes"] + assert len(clashes) > 0 + # Wall002 should be in the clashes (it overlaps wall1) + clash_ids = {c["element"]["id"] for c in clashes} + 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 = 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: + assert "p1" in c + assert "p2" in c + assert len(c["p1"]) == 3 + assert len(c["p2"]) == 3 + assert "type" in c + assert "distance" in c + + +class TestClearance: + def test_clearance_violation(self, model_with_geometry): + """Wall004 is 0.1m from wall1; clearance of 0.5m should fail.""" + 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 = 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 = 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 = 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 = 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 = 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 = next(w for w in model_two_storeys.by_type("IfcWall") if w.Name == "FirstFloorWall") + assert wall2.id() in clash_ids + + +class TestNoGeometry: + def test_no_geometry_error(self, model): + """Element without geometry reports error.""" + wall = model.by_type("IfcWall")[0] + result = clash(model, wall) + assert result["pass"] is None + assert "error" in result + assert "No geometry" in result["error"] + + +class TestJsonSerializable: + def test_result_serializable(self, model_with_geometry): + 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 = 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) + assert "clearance" in parsed["checks"] + + +class TestCLI: + @staticmethod + def _ifc_path(model): + f = tempfile.NamedTemporaryFile(suffix=".ifc", delete=False) + model.write(f.name) + f.close() + return f.name + + def test_clash_json(self, model_with_geometry): + path = self._ifc_path(model_with_geometry) + try: + 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, + text=True, + ) + assert result.returncode == 0 + data = json.loads(result.stdout) + assert data["element"]["type"] == "IfcWall" + assert "checks" in data + assert "intersection" in data["checks"] + finally: + os.unlink(path) + + def test_clash_with_clearance(self, model_with_geometry): + path = self._ifc_path(model_with_geometry) + try: + 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, + text=True, + ) + assert result.returncode == 0 + data = json.loads(result.stdout) + assert "clearance" in data["checks"] + finally: + os.unlink(path) + + def test_clash_scope_all(self, model_with_geometry): + path = self._ifc_path(model_with_geometry) + try: + 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, + text=True, + ) + assert result.returncode == 0 + data = json.loads(result.stdout) + assert data["scope"] == "all" + finally: + os.unlink(path) + + def test_clash_bad_id(self, model_with_geometry): + path = self._ifc_path(model_with_geometry) + try: + result = subprocess.run( + [sys.executable, "-m", "ifcquery", path, "clash", "999999"], + capture_output=True, + text=True, + ) + assert result.returncode != 0 + assert "Error" in result.stderr + finally: + os.unlink(path) diff --git a/src/ifcquery/tests/test_contexts.py b/src/ifcquery/tests/test_contexts.py new file mode 100644 index 0000000000..c7a21acb96 --- /dev/null +++ b/src/ifcquery/tests/test_contexts.py @@ -0,0 +1,52 @@ +import ifcopenshell.api.context +import ifcopenshell.api.project +import ifcopenshell.api.root +import ifcopenshell.api.unit + +from ifcquery.contexts import contexts + + +class TestContexts: + def test_empty_model(self): + f = ifcopenshell.api.project.create_file() + result = contexts(f) + assert isinstance(result, list) + assert len(result) == 0 + + 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 + entry = result[0] + assert entry["type"] == "IfcGeometricRepresentationContext" + assert entry["context_type"] == "Model" + assert "id" in entry + assert "context_identifier" in entry + + 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, + context_type="Model", + context_identifier="Body", + target_view="MODEL_VIEW", + parent=model_ctx, + ) + result = contexts(model) + assert len(result) == 2 + subctx = next(e for e in result if e["type"] == "IfcGeometricRepresentationSubContext") + assert subctx["context_identifier"] == "Body" + assert subctx["target_view"] == "MODEL_VIEW" + assert subctx["parent_context_id"] == model_ctx.id() + + 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: + assert isinstance(entry["id"], int) diff --git a/src/ifcquery/tests/test_cost.py b/src/ifcquery/tests/test_cost.py new file mode 100644 index 0000000000..1011beca0a --- /dev/null +++ b/src/ifcquery/tests/test_cost.py @@ -0,0 +1,108 @@ +# This file was generated with the assistance of an AI coding tool. +from __future__ import annotations + +import ifcopenshell +import ifcopenshell.api.cost +import ifcopenshell.api.owner.settings +import ifcopenshell.api.project +import ifcopenshell.api.root +import ifcopenshell.api.unit +import pytest + +from ifcquery.cost import cost + + +@pytest.fixture +def cost_model(): + """Create an IFC4 model with a cost schedule, a top-level item, and one nested subitem.""" + f = ifcopenshell.api.project.create_file() + ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0] + ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0] + + project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="TestProject") + ifcopenshell.api.unit.assign_unit(f) + + cs = ifcopenshell.api.cost.add_cost_schedule(f, name="Bill of Quantities") + item = ifcopenshell.api.cost.add_cost_item(f, cost_schedule=cs) + ifcopenshell.api.cost.edit_cost_item(f, cost_item=item, attributes={"Name": "Concrete Works"}) + cv = ifcopenshell.api.cost.add_cost_value(f, parent=item) + ifcopenshell.api.cost.edit_cost_value(f, cost_value=cv, attributes={"AppliedValue": 1200.0, "Category": "material"}) + + # Add a nested subitem + subitem = ifcopenshell.api.cost.add_cost_item(f, cost_item=item) + ifcopenshell.api.cost.edit_cost_item(f, cost_item=subitem, attributes={"Name": "Formwork"}) + + return f + + +class TestCost: + def test_returns_list(self, cost_model): + result = cost(cost_model) + assert isinstance(result, list) + + def test_finds_cost_schedule(self, cost_model): + result = cost(cost_model) + assert len(result) == 1 + + def test_schedule_has_name(self, cost_model): + result = cost(cost_model) + assert result[0]["name"] == "Bill of Quantities" + + def test_schedule_has_id(self, cost_model): + result = cost(cost_model) + assert isinstance(result[0]["id"], int) + assert result[0]["id"] > 0 + + def test_schedule_has_items(self, cost_model): + result = cost(cost_model) + assert len(result[0]["items"]) == 1 + + def test_item_has_required_fields(self, cost_model): + result = cost(cost_model) + item = result[0]["items"][0] + assert "id" in item + assert "name" in item + assert "values" in item + assert "subitems" in item + + def test_item_name(self, cost_model): + result = cost(cost_model) + assert result[0]["items"][0]["name"] == "Concrete Works" + + def test_item_has_values(self, cost_model): + result = cost(cost_model) + values = result[0]["items"][0]["values"] + assert len(values) == 1 + assert "formula" in values[0] + assert "category" in values[0] + + def test_item_value_category(self, cost_model): + result = cost(cost_model) + values = result[0]["items"][0]["values"] + assert values[0]["category"] == "material" + + def test_empty_model_returns_empty_list(self, model): + result = cost(model) + assert result == [] + + def test_max_depth_none_returns_full_tree(self, cost_model): + result = cost(cost_model, max_depth=None) + item = result[0]["items"][0] + assert isinstance(item["subitems"], list) + assert len(item["subitems"]) == 1 + assert item["subitems"][0]["name"] == "Formwork" + + def test_max_depth_1_truncates_subitems(self, cost_model): + result = cost(cost_model, max_depth=1) + item = result[0]["items"][0] + assert isinstance(item["subitems"], dict) + assert item["subitems"]["truncated"] is True + assert item["subitems"]["count"] == 1 + + def test_max_depth_2_expands_to_depth_2(self, cost_model): + result = cost(cost_model, max_depth=2) + item = result[0]["items"][0] + assert isinstance(item["subitems"], list) + assert item["subitems"][0]["name"] == "Formwork" + # subitem has no children, so subitems should be empty list + assert item["subitems"][0]["subitems"] == [] diff --git a/src/ifcquery/tests/test_info.py b/src/ifcquery/tests/test_info.py new file mode 100644 index 0000000000..c17331a24d --- /dev/null +++ b/src/ifcquery/tests/test_info.py @@ -0,0 +1,112 @@ +# This file was generated with the assistance of an AI coding tool. +import ifcopenshell +import ifcopenshell.api.context +import ifcopenshell.api.geometry +import ifcopenshell.api.project +import ifcopenshell.api.root +import ifcopenshell.api.unit +import ifcopenshell.util.representation +import ifcopenshell.util.shape_builder + +from ifcquery.info import info + + +class TestInfo: + def test_basic_attributes(self, model): + wall = model.by_type("IfcWall")[0] + result = info(model, wall) + assert result["id"] == wall.id() + assert result["type"] == "IfcWall" + assert result["attributes"]["Name"] == "Wall001" + + def test_container(self, model): + wall = model.by_type("IfcWall")[0] + result = info(model, wall) + assert result["container"]["type"] == "IfcBuildingStorey" + assert result["container"]["name"] == "Ground Floor" + + def test_project_info(self, model): + project = model.by_type("IfcProject")[0] + result = info(model, project) + assert result["type"] == "IfcProject" + assert result["attributes"]["Name"] == "TestProject" + + def test_all_attributes_serializable(self, model): + """All attribute values should be JSON-serializable (no entity instances).""" + import json + + wall = model.by_type("IfcWall")[0] + result = info(model, wall) + # Should not raise + json.dumps(result) + + def test_no_geometry_summary_without_representation(self, model): + wall = model.by_type("IfcWall")[0] + result = info(model, wall) + assert "geometry_summary" not in result + + +class TestGeometrySummary: + def _make_model_with_wall(self): + f = ifcopenshell.api.project.create_file() + ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject") + 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, + ) + wall = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="W1") + ifcopenshell.api.geometry.edit_object_placement(f, product=wall) + return f, wall + + def _body_context(self, f): + return ifcopenshell.util.representation.get_context(f, "Model", "Body", "MODEL_VIEW") + + def test_swept_solid_summary(self): + 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) + ifcopenshell.api.geometry.assign_representation(f, product=wall, representation=rep) + result = info(f, wall) + gs = result["geometry_summary"] + assert gs["representation_type"] == "SweptSolid" + assert len(gs["solids"]) == 1 + solid = gs["solids"][0] + assert solid["depth"] == 3000.0 # stored in project units (mm) + assert solid["profile"]["type"] == "IfcArbitraryClosedProfileDef" + assert len(solid["profile"]["points"]) == 5 # closed polyline + + def test_clipping_summary(self): + 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, + 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) + result = info(f, wall) + gs = result["geometry_summary"] + assert gs["representation_type"] == "Clipping" + solid = gs["solids"][0] + assert len(solid["clipping_planes"]) == 1 + plane = solid["clipping_planes"][0] + assert plane["location"][2] == 3000.0 # stored in project units (mm) + assert plane["normal"] == [0.0, 0.0, 1.0] + + 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) + ifcopenshell.api.geometry.assign_representation(f, product=wall, representation=rep) + result = info(f, wall) + json.dumps(result) diff --git a/src/ifcquery/tests/test_main.py b/src/ifcquery/tests/test_main.py new file mode 100644 index 0000000000..cc17c1b04c --- /dev/null +++ b/src/ifcquery/tests/test_main.py @@ -0,0 +1,84 @@ +# This file was generated with the assistance of an AI coding tool. +import json +import os +import subprocess +import sys +import tempfile + +import ifcopenshell +import ifcopenshell.api.project +import pytest + + +@pytest.fixture +def ifc_path(model): + """Write the model fixture to a temp file and return its path.""" + with tempfile.NamedTemporaryFile(suffix=".ifc", delete=False) as f: + model.write(f.name) + yield f.name + os.unlink(f.name) + + +def run_ifcquery(*args): + """Run ifcquery as a subprocess and return (returncode, stdout, stderr).""" + result = subprocess.run( + [sys.executable, "-m", "ifcquery", *args], + capture_output=True, + text=True, + ) + return result.returncode, result.stdout, result.stderr + + +class TestCLI: + def test_summary_json(self, ifc_path): + rc, stdout, stderr = run_ifcquery(ifc_path, "summary") + assert rc == 0 + data = json.loads(stdout) + assert data["schema"] == "IFC4" + assert "types" in data + + def test_tree_json(self, ifc_path): + rc, stdout, stderr = run_ifcquery(ifc_path, "tree") + assert rc == 0 + data = json.loads(stdout) + assert data["type"] == "IfcProject" + + def test_info_json(self, ifc_path, model): + wall = model.by_type("IfcWall")[0] + rc, stdout, stderr = run_ifcquery(ifc_path, "info", str(wall.id())) + assert rc == 0 + data = json.loads(stdout) + assert data["type"] == "IfcWall" + + def test_info_hash_id(self, ifc_path, model): + wall = model.by_type("IfcWall")[0] + rc, stdout, stderr = run_ifcquery(ifc_path, "info", f"#{wall.id()}") + assert rc == 0 + data = json.loads(stdout) + assert data["type"] == "IfcWall" + + def test_select_json(self, ifc_path): + rc, stdout, stderr = run_ifcquery(ifc_path, "select", "IfcWall") + assert rc == 0 + data = json.loads(stdout) + assert len(data) == 1 + assert data[0]["type"] == "IfcWall" + + def test_text_format(self, ifc_path): + rc, stdout, stderr = run_ifcquery(ifc_path, "--format", "text", "summary") + assert rc == 0 + assert "schema:" in stdout + + def test_bad_file(self): + rc, stdout, stderr = run_ifcquery("/nonexistent.ifc", "summary") + assert rc != 0 + assert "Error" in stderr + + def test_bad_element_id(self, ifc_path): + rc, stdout, stderr = run_ifcquery(ifc_path, "info", "999999") + assert rc != 0 + assert "Error" in stderr + + def test_no_command(self, ifc_path): + rc, stdout, stderr = run_ifcquery(ifc_path) + assert rc != 0 diff --git a/src/ifcquery/tests/test_materials.py b/src/ifcquery/tests/test_materials.py new file mode 100644 index 0000000000..aa6886156f --- /dev/null +++ b/src/ifcquery/tests/test_materials.py @@ -0,0 +1,52 @@ +import ifcopenshell.api.material +import ifcopenshell.api.project + +from ifcquery.materials import materials + + +class TestMaterials: + def test_empty_model(self, model): + result = materials(model) + assert isinstance(result, list) + assert len(result) == 0 + + def test_single_material(self, model): + ifcopenshell.api.material.add_material(model, name="Concrete", category="concrete") + result = materials(model) + assert len(result) == 1 + m = result[0] + assert m["type"] == "IfcMaterial" + assert m["name"] == "Concrete" + assert m["category"] == "concrete" + assert isinstance(m["id"], int) + + def test_material_layer_set(self, model): + mat = ifcopenshell.api.material.add_material(model, name="Brick") + layer_set = ifcopenshell.api.material.add_material_set(model, name="BrickSet", set_type="IfcMaterialLayerSet") + ifcopenshell.api.material.add_layer(model, layer_set=layer_set, material=mat) + result = materials(model) + layer_sets = [e for e in result if e["type"] == "IfcMaterialLayerSet"] + assert len(layer_sets) == 1 + ls = layer_sets[0] + assert ls["name"] == "BrickSet" + assert isinstance(ls["layers"], list) + assert len(ls["layers"]) == 1 + layer = ls["layers"][0] + assert layer["material"] == "Brick" + + def test_material_constituent_set(self, model): + mat = ifcopenshell.api.material.add_material(model, name="Steel") + cs = ifcopenshell.api.material.add_material_set(model, name="CompSet", set_type="IfcMaterialConstituentSet") + ifcopenshell.api.material.add_constituent(model, constituent_set=cs, material=mat) + result = materials(model) + constituent_sets = [e for e in result if e["type"] == "IfcMaterialConstituentSet"] + assert len(constituent_sets) == 1 + entry = constituent_sets[0] + assert entry["name"] == "CompSet" + assert isinstance(entry["constituents"], list) + + def test_ids_are_integers(self, model): + ifcopenshell.api.material.add_material(model, name="Wood") + result = materials(model) + for entry in result: + assert isinstance(entry["id"], int) diff --git a/src/ifcquery/tests/test_plot.py b/src/ifcquery/tests/test_plot.py new file mode 100644 index 0000000000..4c003ab946 --- /dev/null +++ b/src/ifcquery/tests/test_plot.py @@ -0,0 +1,265 @@ +from __future__ import annotations + +import base64 +import os +import subprocess +import sys +import tempfile + +import ifcopenshell +import ifcopenshell.api.aggregate +import ifcopenshell.api.context +import ifcopenshell.api.geometry +import ifcopenshell.api.owner.settings +import ifcopenshell.api.project +import ifcopenshell.api.root +import ifcopenshell.api.spatial +import ifcopenshell.api.unit +import pytest + +from ifcquery.plot import _highlight_css_from_ids, plot + +try: + import ifcopenshell.draw # noqa: F401 + + HAS_DRAW = True +except ImportError: + HAS_DRAW = False + +try: + import cairosvg # noqa: F401 + + HAS_CAIROSVG = True +except ImportError: + HAS_CAIROSVG = False + +pytestmark = pytest.mark.skipif(not HAS_DRAW, reason="ifcopenshell.draw not available") + +SVG_MAGIC = b" elements, PNG/base64 should raise a clear error.""" + + def test_empty_drawing_png_raises(self, model_no_plan): + """PNG format raises ValueError (not silently returns None) for empty drawings.""" + model, _ = model_no_plan + svg = plot(model, output_format="svg") + has_groups = b"" in svg + if not has_groups: + pytest.raises(ValueError, plot, model, output_format="png") + else: + pytest.skip("Model produced non-empty SVG — empty path not triggered") + + def test_empty_drawing_base64_raises(self, model_no_plan): + """base64 format raises ValueError (not silently returns None) for empty drawings.""" + model, _ = model_no_plan + svg = plot(model, output_format="svg") + has_groups = b"" in svg + if not has_groups: + pytest.raises(ValueError, plot, model, output_format="base64") + else: + pytest.skip("Model produced non-empty SVG — empty path not triggered") + + +@pytest.mark.skipif(not HAS_CAIROSVG, reason="cairosvg not installed") +class TestPlotPNG: + """PNG and base64 require cairosvg.""" + + def test_png_returns_bytes_or_raises_on_empty(self, model_with_annotations): + model, _ = model_with_annotations + svg = plot(model, output_format="svg") + has_groups = b"" in svg + if has_groups: + result = plot(model, output_format="png") + assert isinstance(result, bytes) + assert result[:4] == PNG_MAGIC + else: + with pytest.raises(ValueError, match="No plan geometry"): + plot(model, output_format="png") + + def test_base64_returns_dict(self, model_with_annotations): + model, _ = model_with_annotations + svg = plot(model, output_format="svg") + has_groups = b"" in svg + if has_groups: + result = plot(model, output_format="base64") + assert isinstance(result, dict) + assert result["mime"] == "image/png" + assert "png_b64" in result + assert "width" in result + assert "height" in result + assert "view" in result + # Verify the base64 is valid PNG + decoded = base64.b64decode(result["png_b64"]) + assert decoded[:4] == PNG_MAGIC + else: + with pytest.raises(ValueError, match="No plan geometry"): + plot(model, output_format="base64") + + def test_base64_view_field_matches_requested(self, model_with_annotations): + model, _ = model_with_annotations + svg = plot(model, output_format="svg") + has_groups = b"" in svg + if not has_groups: + pytest.skip("Model produces empty SVG") + result = plot(model, output_format="base64", view="floorplan") + assert result["view"] == "floorplan" + + def test_png_custom_size(self, model_with_annotations): + model, _ = model_with_annotations + svg = plot(model, output_format="svg") + has_groups = b"" in svg + if not has_groups: + pytest.skip("Model produces empty SVG") + result = plot(model, output_format="png", png_width=512, png_height=512) + assert isinstance(result, bytes) + assert result[:4] == PNG_MAGIC + + +class TestCLI: + @staticmethod + def _ifc_path(model): + f = tempfile.NamedTemporaryFile(suffix=".ifc", delete=False) + model.write(f.name) + f.close() + return f.name + + def test_plot_svg_writes_file(self, model_with_annotations): + model, _ = model_with_annotations + ifc_path = self._ifc_path(model) + out_path = ifc_path.replace(".ifc", "_out.svg") + try: + result = subprocess.run( + [sys.executable, "-m", "ifcquery", ifc_path, "plot", "--out-format", "svg", "-o", out_path], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + assert os.path.exists(out_path) + with open(out_path, "rb") as f: + assert f.read(5) == SVG_MAGIC + finally: + for path in (ifc_path, out_path): + try: + os.unlink(path) + except OSError: + pass + + @pytest.mark.skipif(not HAS_CAIROSVG, reason="cairosvg not installed") + def test_plot_base64_prints_json(self, model_with_annotations): + """base64 format prints JSON to stdout instead of writing a file.""" + model, _ = model_with_annotations + ifc_path = self._ifc_path(model) + try: + # First check if the model would produce geometry + svg = plot(model, output_format="svg") + has_groups = b"" in svg + if not has_groups: + pytest.skip("Model produces empty SVG — base64 would raise ValueError") + + result = subprocess.run( + [sys.executable, "-m", "ifcquery", ifc_path, "plot", "--out-format", "base64"], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + # Output should be JSON (not an error) and contain base64 key + assert "png_b64" in result.stdout + finally: + try: + os.unlink(ifc_path) + except OSError: + pass diff --git a/src/ifcquery/tests/test_relations.py b/src/ifcquery/tests/test_relations.py new file mode 100644 index 0000000000..4e4df48f21 --- /dev/null +++ b/src/ifcquery/tests/test_relations.py @@ -0,0 +1,158 @@ +# This file was generated with the assistance of an AI coding tool. +import json +import os +import subprocess +import sys +import tempfile + +from ifcquery.relations import relations + + +class TestWallRelations: + def test_wall_has_container(self, model): + wall = model.by_type("IfcWall")[0] + result = relations(model, wall) + assert result["id"] == wall.id() + assert result["type"] == "IfcWall" + assert result["hierarchy"]["container"]["type"] == "IfcBuildingStorey" + assert result["hierarchy"]["container"]["name"] == "Ground Floor" + + def test_wall_has_parent(self, model): + wall = model.by_type("IfcWall")[0] + result = relations(model, wall) + assert result["hierarchy"]["parent"]["type"] == "IfcBuildingStorey" + + def test_wall_no_children(self, model): + wall = model.by_type("IfcWall")[0] + result = relations(model, wall) + assert "children" not in result + + def test_wall_empty_categories_omitted(self, model): + wall = model.by_type("IfcWall")[0] + result = relations(model, wall) + assert "groups" not in result + assert "systems" not in result + assert "zones" not in result + assert "connections" not in result + assert "referenced_structures" not in result + + +class TestStoreyRelations: + def test_storey_has_contained(self, model): + storey = model.by_type("IfcBuildingStorey")[0] + result = relations(model, storey) + contained_types = {e["type"] for e in result["children"]["contained"]} + assert "IfcWall" in contained_types + assert "IfcSlab" in contained_types + + def test_storey_has_aggregate_parent(self, model): + storey = model.by_type("IfcBuildingStorey")[0] + result = relations(model, storey) + assert result["hierarchy"]["aggregate"]["type"] == "IfcBuilding" + assert result["hierarchy"]["aggregate"]["name"] == "TestBuilding" + + +class TestProjectRelations: + def test_project_has_parts(self, model): + project = model.by_type("IfcProject")[0] + result = relations(model, project) + parts = result["children"]["parts"] + assert any(p["type"] == "IfcSite" for p in parts) + + def test_project_no_hierarchy(self, model): + project = model.by_type("IfcProject")[0] + result = relations(model, project) + assert "hierarchy" not in result + + +class TestTraverseUp: + def test_wall_to_project(self, model): + wall = model.by_type("IfcWall")[0] + chain = relations(model, wall, traverse="up") + assert isinstance(chain, list) + assert chain[0]["type"] == "IfcWall" + assert chain[-1]["type"] == "IfcProject" + types = [e["type"] for e in chain] + assert "IfcBuildingStorey" in types + assert "IfcBuilding" in types + assert "IfcSite" in types + + def test_project_traverse(self, model): + project = model.by_type("IfcProject")[0] + chain = relations(model, project, traverse="up") + assert len(chain) == 1 + assert chain[0]["type"] == "IfcProject" + + def test_storey_to_project(self, model): + storey = model.by_type("IfcBuildingStorey")[0] + chain = relations(model, storey, traverse="up") + assert chain[0]["type"] == "IfcBuildingStorey" + assert chain[-1]["type"] == "IfcProject" + assert len(chain) == 4 # storey -> building -> site -> project + + +class TestJsonSerializable: + def test_relations_serializable(self, model): + wall = model.by_type("IfcWall")[0] + result = relations(model, wall) + json.dumps(result) + + def test_traverse_serializable(self, model): + wall = model.by_type("IfcWall")[0] + result = relations(model, wall, traverse="up") + json.dumps(result) + + +class TestCLI: + @staticmethod + def _ifc_path(model): + f = tempfile.NamedTemporaryFile(suffix=".ifc", delete=False) + model.write(f.name) + f.close() + return f.name + + def test_relations_json(self, model): + path = self._ifc_path(model) + try: + wall = model.by_type("IfcWall")[0] + result = subprocess.run( + [sys.executable, "-m", "ifcquery", path, "relations", str(wall.id())], + capture_output=True, + text=True, + ) + assert result.returncode == 0 + data = json.loads(result.stdout) + assert data["type"] == "IfcWall" + assert "hierarchy" in data + finally: + os.unlink(path) + + def test_relations_traverse_up(self, model): + path = self._ifc_path(model) + try: + wall = model.by_type("IfcWall")[0] + result = subprocess.run( + [sys.executable, "-m", "ifcquery", path, "relations", str(wall.id()), "--traverse", "up"], + capture_output=True, + text=True, + ) + assert result.returncode == 0 + data = json.loads(result.stdout) + assert isinstance(data, list) + assert data[0]["type"] == "IfcWall" + assert data[-1]["type"] == "IfcProject" + finally: + os.unlink(path) + + def test_relations_bad_id(self, model): + path = self._ifc_path(model) + try: + result = subprocess.run( + [sys.executable, "-m", "ifcquery", path, "relations", "999999"], + capture_output=True, + text=True, + ) + assert result.returncode != 0 + assert "Error" in result.stderr + finally: + os.unlink(path) diff --git a/src/ifcquery/tests/test_render.py b/src/ifcquery/tests/test_render.py new file mode 100644 index 0000000000..28d83a6b68 --- /dev/null +++ b/src/ifcquery/tests/test_render.py @@ -0,0 +1,355 @@ +# This file was generated with the assistance of an AI coding tool. +import os +import subprocess +import sys +import tempfile + +import ifcopenshell +import ifcopenshell.api.aggregate +import ifcopenshell.api.context +import ifcopenshell.api.geometry +import ifcopenshell.api.owner.settings +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 _make_profile_occurrence, _make_type_occurrence, render + +try: + import pyvista # noqa: F401 + + HAS_PYVISTA = True +except ImportError: + HAS_PYVISTA = False + +pytestmark = pytest.mark.skipif(not HAS_PYVISTA, reason="pyvista not installed") + +PNG_MAGIC = b"\x89PNG" + + +@pytest.fixture +def model_with_geometry(): + """Create an IFC4 model with walls that have geometric representations.""" + f = ifcopenshell.api.project.create_file() + ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0] + ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0] + + project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="TestProject") + ifcopenshell.api.unit.assign_unit(f) + + site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="TestSite") + building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="TestBuilding") + storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="Ground Floor") + + ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project) + ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site) + ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=building) + + model_ctx = ifcopenshell.api.context.add_context(f, context_type="Model") + body = ifcopenshell.api.context.add_context( + f, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=model_ctx + ) + + wall1 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall001") + rep1 = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=5, height=3, thickness=0.2) + ifcopenshell.api.geometry.assign_representation(f, product=wall1, representation=rep1) + ifcopenshell.api.spatial.assign_container(f, products=[wall1], relating_structure=storey) + + wall2 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall002") + rep2 = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=4, height=3, thickness=0.2) + ifcopenshell.api.geometry.assign_representation(f, product=wall2, representation=rep2) + ifcopenshell.api.spatial.assign_container(f, products=[wall2], relating_structure=storey) + matrix2 = np.eye(4) + matrix2[1, 3] = 3.0 + ifcopenshell.api.geometry.edit_object_placement(f, product=wall2, matrix=matrix2) + + return f + + +@pytest.fixture +def library_with_type(): + """IFC4 library file: a WallType with a RepresentationMap but no instances.""" + f = ifcopenshell.api.project.create_file() + ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0] + ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0] + + project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="LibProject") + ifcopenshell.api.unit.assign_unit(f) + + model_ctx = ifcopenshell.api.context.add_context(f, context_type="Model") + body = ifcopenshell.api.context.add_context( + f, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=model_ctx + ) + + # Build the shape representation and wrap it in an IfcRepresentationMap. + shape_rep = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=3, height=2.5, thickness=0.2) + origin = f.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0)) + z_dir = f.create_entity("IfcDirection", DirectionRatios=(0.0, 0.0, 1.0)) + x_dir = f.create_entity("IfcDirection", DirectionRatios=(1.0, 0.0, 0.0)) + map_origin = f.create_entity("IfcAxis2Placement3D", Location=origin, Axis=z_dir, RefDirection=x_dir) + rep_map = f.create_entity("IfcRepresentationMap", MappingOrigin=map_origin, MappedRepresentation=shape_rep) + + wall_type = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWallType", name="LibWallType") + wall_type.RepresentationMaps = [rep_map] + + return f, wall_type + + +@pytest.fixture +def library_with_profile_type(): + """IFC4 library: a BeamType with an IfcMaterialProfileSet but no RepresentationMaps.""" + f = ifcopenshell.api.project.create_file() + ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0] + ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0] + + project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="ProfileLibProject") + 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 + ) + + # Rectangular profile 0.2m x 0.3m + profile = f.create_entity( + "IfcRectangleProfileDef", + ProfileType="AREA", + ProfileName="200x300", + XDim=0.2, + YDim=0.3, + ) + material = f.create_entity("IfcMaterial", Name="Steel") + mat_profile = f.create_entity("IfcMaterialProfile", Material=material, Profile=profile) + profile_set = f.create_entity("IfcMaterialProfileSet", MaterialProfiles=[mat_profile]) + + beam_type = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBeamType", name="200x300 Steel Beam") + rel = f.create_entity( + "IfcRelAssociatesMaterial", + GlobalId=ifcopenshell.guid.new(), + RelatedObjects=[beam_type], + RelatingMaterial=profile_set, + ) + + return f, beam_type + + +class TestRenderBasic: + def test_returns_png_bytes(self, model_with_geometry): + result = render(model_with_geometry) + assert isinstance(result, bytes) + assert result[:4] == PNG_MAGIC + + def test_iso_view(self, model_with_geometry): + result = render(model_with_geometry, view="iso") + assert result[:4] == PNG_MAGIC + + def test_top_view(self, model_with_geometry): + result = render(model_with_geometry, view="top") + assert result[:4] == PNG_MAGIC + + def test_south_view(self, model_with_geometry): + result = render(model_with_geometry, view="south") + assert result[:4] == PNG_MAGIC + + def test_unknown_view_falls_back_to_iso(self, model_with_geometry): + # Unknown view strings fall through to isometric + result = render(model_with_geometry, view="diagonal") + assert result[:4] == PNG_MAGIC + + +class TestRenderSelector: + def test_selector_restricts_elements(self, model_with_geometry): + result = render(model_with_geometry, selector="IfcWall") + assert result[:4] == PNG_MAGIC + + def test_selector_no_match_raises(self, model_with_geometry): + with pytest.raises(ValueError, match="matched no elements"): + render(model_with_geometry, selector="IfcDoor") + + +class TestRenderHighlight: + def test_highlight_single_element(self, model_with_geometry): + wall = model_with_geometry.by_type("IfcWall")[0] + result = render(model_with_geometry, element_ids=[wall.id()]) + assert result[:4] == PNG_MAGIC + + def test_highlight_multiple_elements(self, model_with_geometry): + walls = model_with_geometry.by_type("IfcWall") + result = render(model_with_geometry, element_ids=[w.id() for w in walls]) + assert result[:4] == PNG_MAGIC + + +class TestRenderTypes: + def test_render_type_by_selector(self, library_with_type): + """Selecting a type class renders its RepresentationMap geometry.""" + model, wall_type = library_with_type + result = render(model, selector="IfcWallType") + assert result[:4] == PNG_MAGIC + + def test_render_type_by_element_id(self, library_with_type): + """Passing a type step-ID via element_ids renders it highlighted.""" + model, wall_type = library_with_type + result = render(model, element_ids=[wall_type.id()]) + assert result[:4] == PNG_MAGIC + + def test_original_model_unmodified(self, library_with_type): + """Rendering a type must not add entities to the original model.""" + model, wall_type = library_with_type + entity_count_before = len(list(model)) + render(model, selector="IfcWallType") + assert len(list(model)) == entity_count_before + + def test_make_type_occurrence_no_rep_maps(self, library_with_type): + """_make_type_occurrence returns None for a type with no RepresentationMaps.""" + model, _ = library_with_type + bare_type = ifcopenshell.api.root.create_entity(model, ifc_class="IfcWallType", name="Bare") + assert _make_type_occurrence(model, bare_type) is None + + def test_type_without_rep_maps_raises(self): + """Selecting a type that has no RepresentationMaps raises ValueError.""" + f = ifcopenshell.api.project.create_file() + ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0] + ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0] + ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="P") + ifcopenshell.api.unit.assign_unit(f) + ifcopenshell.api.root.create_entity(f, ifc_class="IfcWallType", name="Bare") + with pytest.raises(ValueError): + render(f, selector="IfcWallType") + + +class TestRenderProfileTypes: + def test_render_profile_type_by_element_id(self, library_with_profile_type): + """A type with only a material profile set renders via temporary extrusion.""" + model, beam_type = library_with_profile_type + result = render(model, element_ids=[beam_type.id()]) + assert result[:4] == PNG_MAGIC + + def test_make_profile_occurrence_creates_occurrence(self, library_with_profile_type): + """_make_profile_occurrence returns an occurrence entity for a profile-set type.""" + model, beam_type = library_with_profile_type + occ = _make_profile_occurrence(model, beam_type) + assert occ is not None + + def test_make_profile_occurrence_no_profile_returns_none(self, library_with_type): + """_make_profile_occurrence returns None when type has no material profile set.""" + model, wall_type = library_with_type + # wall_type has RepresentationMaps but no material profile set + occ = _make_profile_occurrence(model, wall_type) + assert occ is None + + def test_original_model_unmodified_for_profile_type(self, library_with_profile_type): + """Rendering a profile-based type does not modify the original model.""" + model, beam_type = library_with_profile_type + entity_count_before = len(list(model)) + render(model, element_ids=[beam_type.id()]) + assert len(list(model)) == entity_count_before + + +class TestRenderNoGeometry: + def test_no_geometry_raises(self): + """A model without geometry representations raises ValueError.""" + f = ifcopenshell.api.project.create_file() + ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0] + ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0] + project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="P") + ifcopenshell.api.unit.assign_unit(f) + site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="S") + building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="B") + storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="GF") + ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project) + ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site) + ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=building) + wall = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wallless") + ifcopenshell.api.spatial.assign_container(f, products=[wall], relating_structure=storey) + + with pytest.raises(ValueError, match="No renderable geometry"): + render(f) + + +class TestCLI: + @staticmethod + def _ifc_path(model): + f = tempfile.NamedTemporaryFile(suffix=".ifc", delete=False) + model.write(f.name) + f.close() + return f.name + + def test_render_writes_png(self, model_with_geometry): + ifc_path = self._ifc_path(model_with_geometry) + out_path = ifc_path.replace(".ifc", "_out.png") + try: + result = subprocess.run( + [sys.executable, "-m", "ifcquery", ifc_path, "render", "-o", out_path], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + assert os.path.exists(out_path) + with open(out_path, "rb") as f: + assert f.read(4) == PNG_MAGIC + finally: + for path in (ifc_path, out_path): + try: + os.unlink(path) + except OSError: + pass + + def test_render_default_output_path(self, model_with_geometry): + ifc_path = self._ifc_path(model_with_geometry) + expected_png = ifc_path.replace(".ifc", ".png") + try: + result = subprocess.run( + [sys.executable, "-m", "ifcquery", ifc_path, "render"], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + assert os.path.exists(expected_png) + finally: + for path in (ifc_path, expected_png): + try: + os.unlink(path) + except OSError: + pass + + def test_render_with_selector(self, model_with_geometry): + ifc_path = self._ifc_path(model_with_geometry) + out_path = ifc_path.replace(".ifc", "_sel.png") + try: + result = subprocess.run( + [sys.executable, "-m", "ifcquery", ifc_path, "render", "-o", out_path, "--selector", "IfcWall"], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + with open(out_path, "rb") as f: + assert f.read(4) == PNG_MAGIC + finally: + for path in (ifc_path, out_path): + try: + os.unlink(path) + except OSError: + pass + + def test_render_with_view(self, model_with_geometry): + ifc_path = self._ifc_path(model_with_geometry) + out_path = ifc_path.replace(".ifc", "_top.png") + try: + result = subprocess.run( + [sys.executable, "-m", "ifcquery", ifc_path, "render", "-o", out_path, "--view", "top"], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + with open(out_path, "rb") as f: + assert f.read(4) == PNG_MAGIC + finally: + for path in (ifc_path, out_path): + try: + os.unlink(path) + except OSError: + pass diff --git a/src/ifcquery/tests/test_schedule.py b/src/ifcquery/tests/test_schedule.py new file mode 100644 index 0000000000..0d3a3e934f --- /dev/null +++ b/src/ifcquery/tests/test_schedule.py @@ -0,0 +1,121 @@ +# This file was generated with the assistance of an AI coding tool. +from __future__ import annotations + +import ifcopenshell +import ifcopenshell.api.aggregate +import ifcopenshell.api.owner.settings +import ifcopenshell.api.project +import ifcopenshell.api.root +import ifcopenshell.api.sequence +import ifcopenshell.api.unit +import pytest + +from ifcquery.schedule import schedule + + +@pytest.fixture +def schedule_model(): + """Create an IFC4 model with a work schedule and nested tasks.""" + f = ifcopenshell.api.project.create_file() + ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0] + ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0] + + project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="TestProject") + ifcopenshell.api.unit.assign_unit(f) + + ws = ifcopenshell.api.sequence.add_work_schedule(f, name="Construction Schedule") + + task1 = ifcopenshell.api.sequence.add_task(f, work_schedule=ws, name="Phase 1", identification="P1") + tt1 = ifcopenshell.api.sequence.add_task_time(f, task=task1) + ifcopenshell.api.sequence.edit_task_time( + f, task_time=tt1, attributes={"ScheduleStart": "2024-01-01", "ScheduleFinish": "2024-06-30"} + ) + + task2 = ifcopenshell.api.sequence.add_task(f, work_schedule=ws, name="Phase 2", identification="P2") + subtask = ifcopenshell.api.sequence.add_task(f, parent_task=task1, name="Sub Task", identification="S1") + + return f + + +class TestSchedule: + def test_returns_list(self, schedule_model): + result = schedule(schedule_model) + assert isinstance(result, list) + + def test_finds_work_schedule(self, schedule_model): + result = schedule(schedule_model) + assert len(result) == 1 + + def test_work_schedule_has_name(self, schedule_model): + result = schedule(schedule_model) + assert result[0]["name"] == "Construction Schedule" + + def test_work_schedule_has_id(self, schedule_model): + result = schedule(schedule_model) + assert isinstance(result[0]["id"], int) + assert result[0]["id"] > 0 + + def test_work_schedule_has_tasks(self, schedule_model): + result = schedule(schedule_model) + tasks = result[0]["tasks"] + assert isinstance(tasks, list) + assert len(tasks) >= 1 + + def test_task_has_required_fields(self, schedule_model): + result = schedule(schedule_model) + task = result[0]["tasks"][0] + assert "id" in task + assert "name" in task + assert "start" in task + assert "finish" in task + assert "is_milestone" in task + assert "outputs" in task + assert "subtasks" in task + + def test_task_name(self, schedule_model): + result = schedule(schedule_model) + task_names = [t["name"] for t in result[0]["tasks"]] + assert "Phase 1" in task_names + + def test_task_start_finish(self, schedule_model): + result = schedule(schedule_model) + phase1 = next(t for t in result[0]["tasks"] if t["name"] == "Phase 1") + assert phase1["start"] is not None + assert phase1["finish"] is not None + + def test_subtasks(self, schedule_model): + result = schedule(schedule_model) + phase1 = next(t for t in result[0]["tasks"] if t["name"] == "Phase 1") + assert len(phase1["subtasks"]) == 1 + assert phase1["subtasks"][0]["name"] == "Sub Task" + + def test_empty_model_returns_empty_list(self, model): + result = schedule(model) + assert result == [] + + def test_max_depth_none_returns_full_tree(self, schedule_model): + result = schedule(schedule_model, max_depth=None) + phase1 = next(t for t in result[0]["tasks"] if t["name"] == "Phase 1") + assert isinstance(phase1["subtasks"], list) + assert len(phase1["subtasks"]) == 1 + + def test_max_depth_1_truncates_subtasks(self, schedule_model): + result = schedule(schedule_model, max_depth=1) + phase1 = next(t for t in result[0]["tasks"] if t["name"] == "Phase 1") + assert isinstance(phase1["subtasks"], dict) + assert phase1["subtasks"]["truncated"] is True + assert phase1["subtasks"]["count"] == 1 + + def test_max_depth_truncation_shows_count(self, schedule_model): + result = schedule(schedule_model, max_depth=1) + # Phase 2 has no subtasks — should return empty list, not truncation dict + phase2 = next(t for t in result[0]["tasks"] if t["name"] == "Phase 2") + assert phase2["subtasks"] == [] + + def test_max_depth_2_expands_to_depth_2(self, schedule_model): + result = schedule(schedule_model, max_depth=2) + phase1 = next(t for t in result[0]["tasks"] if t["name"] == "Phase 1") + # subtask at depth 2 should be fully expanded (it has no children) + assert isinstance(phase1["subtasks"], list) + assert phase1["subtasks"][0]["name"] == "Sub Task" + assert phase1["subtasks"][0]["subtasks"] == [] diff --git a/src/ifcquery/tests/test_schema.py b/src/ifcquery/tests/test_schema.py new file mode 100644 index 0000000000..c4f63344ee --- /dev/null +++ b/src/ifcquery/tests/test_schema.py @@ -0,0 +1,32 @@ +# This file was generated with the assistance of an AI coding tool. +from __future__ import annotations + +import pytest + +from ifcquery.schema import schema + + +class TestSchema: + def test_ifc_wall_has_description(self, model): + result = schema(model, "IfcWall") + assert "description" in result + assert isinstance(result["description"], str) + assert len(result["description"]) > 0 + + def test_ifc_wall_has_attributes(self, model): + result = schema(model, "IfcWall") + assert "attributes" in result + + def test_ifc_wall_has_spec_url(self, model): + result = schema(model, "IfcWall") + assert "spec_url" in result + + def test_unknown_entity_returns_error(self, model): + result = schema(model, "IfcNonExistentFooBar") + assert "error" in result + assert "IfcNonExistentFooBar" in result["error"] + + def test_ifc_window_has_description(self, model): + result = schema(model, "IfcWindow") + assert "description" in result + assert len(result["description"]) > 0 diff --git a/src/ifcquery/tests/test_select.py b/src/ifcquery/tests/test_select.py new file mode 100644 index 0000000000..d5700a38e6 --- /dev/null +++ b/src/ifcquery/tests/test_select.py @@ -0,0 +1,32 @@ +# This file was generated with the assistance of an AI coding tool. +from ifcquery.select import select + + +class TestSelect: + def test_select_by_type(self, model): + result = select(model, "IfcWall") + assert len(result) == 1 + assert result[0]["type"] == "IfcWall" + assert result[0]["name"] == "Wall001" + + def test_select_multiple_types(self, model): + result = select(model, "IfcWall, IfcSlab") + assert len(result) == 2 + types = {r["type"] for r in result} + assert types == {"IfcWall", "IfcSlab"} + + def test_select_no_match(self, model): + result = select(model, "IfcDoor") + assert result == [] + + def test_results_sorted_by_id(self, model): + result = select(model, "IfcWall, IfcSlab") + ids = [r["id"] for r in result] + assert ids == sorted(ids) + + def test_result_has_id_type_name(self, model): + result = select(model, "IfcWall") + entry = result[0] + assert "id" in entry + assert "type" in entry + assert "name" in entry diff --git a/src/ifcquery/tests/test_summary.py b/src/ifcquery/tests/test_summary.py new file mode 100644 index 0000000000..c230b48ab7 --- /dev/null +++ b/src/ifcquery/tests/test_summary.py @@ -0,0 +1,34 @@ +# This file was generated with the assistance of an AI coding tool. +import ifcopenshell +import ifcopenshell.api.project + +from ifcquery.summary import summary + + +class TestSummary: + def test_schema(self, model): + result = summary(model) + assert result["schema"] == "IFC4" + + def test_total_entities(self, model): + result = summary(model) + assert result["total_entities"] == len(list(model)) + assert result["total_entities"] > 0 + + def test_project_info(self, model): + result = summary(model) + assert result["project"]["name"] == "TestProject" + + def test_type_counts(self, model): + result = summary(model) + types = result["types"] + assert "IfcWall" in types + assert types["IfcWall"] == 1 + assert "IfcSlab" in types + assert types["IfcSlab"] == 1 + + def test_empty_model(self): + f = ifcopenshell.api.project.create_file() + result = summary(f) + assert result["schema"] == "IFC4" + assert "project" not in result diff --git a/src/ifcquery/tests/test_tree.py b/src/ifcquery/tests/test_tree.py new file mode 100644 index 0000000000..2110be3188 --- /dev/null +++ b/src/ifcquery/tests/test_tree.py @@ -0,0 +1,37 @@ +# This file was generated with the assistance of an AI coding tool. +from ifcquery.tree import tree + + +class TestTree: + def test_root_is_project(self, model): + result = tree(model) + assert result["type"] == "IfcProject" + assert result["name"] == "TestProject" + + def test_spatial_hierarchy(self, model): + result = tree(model) + # Project > Site > Building > Storey + site = result["children"][0] + assert site["type"] == "IfcSite" + assert site["name"] == "TestSite" + + building = site["children"][0] + assert building["type"] == "IfcBuilding" + assert building["name"] == "TestBuilding" + + storey = building["children"][0] + assert storey["type"] == "IfcBuildingStorey" + assert storey["name"] == "Ground Floor" + + def test_contained_elements(self, model): + result = tree(model) + storey = result["children"][0]["children"][0]["children"][0] + elements = storey["elements"] + element_types = {e["type"] for e in elements} + assert "IfcWall" in element_types + assert "IfcSlab" in element_types + + def test_element_ids_present(self, model): + result = tree(model) + assert "id" in result + assert isinstance(result["id"], int) diff --git a/src/ifcquery/tests/test_validate.py b/src/ifcquery/tests/test_validate.py new file mode 100644 index 0000000000..44734ce4b0 --- /dev/null +++ b/src/ifcquery/tests/test_validate.py @@ -0,0 +1,47 @@ +# This file was generated with the assistance of an AI coding tool. +from __future__ import annotations + +import ifcopenshell +import ifcopenshell.api.project +import pytest + +from ifcquery.validate import validate + + +class TestValidate: + def test_valid_model_returns_valid_true(self, model): + result = validate(model) + assert result["valid"] is True + assert isinstance(result["issues"], list) + + def test_valid_model_has_no_issues(self, model): + result = validate(model) + assert result["issues"] == [] + + def test_empty_model_is_valid(self): + f = ifcopenshell.api.project.create_file() + result = validate(f) + assert result["valid"] is True + assert result["issues"] == [] + + def test_result_has_expected_keys(self, model): + result = validate(model) + assert "valid" in result + assert "issues" in result + + def test_express_rules_flag_accepted(self, model): + # Just verify it runs without error; express rules may add/not add issues + result = validate(model, express_rules=True) + assert "valid" in result + assert isinstance(result["issues"], list) + + def test_issue_has_level_and_message(self, model): + # Force an issue by manually breaking the model (invalid IfcWall attribute) + f = ifcopenshell.file() + # Create a raw IfcWall with deliberately wrong type for GlobalId (use int) + # We just check structure if any issues appear; on well-formed models there are none. + result = validate(model) + # Even if no issues, the structure contract must hold for any issues present + for issue in result["issues"]: + assert "level" in issue + assert "message" in issue