From 7cd40bf8cbd2c48bef579a172aa50e6f7e97fea7 Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Mon, 23 Mar 2026 23:29:32 +0000 Subject: [PATCH 1/2] Add ifcquery CLI tool for IFC model interrogation (#7845) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ifcquery is a new command-line tool for querying and inspecting IFC models. All output is JSON. Subcommands: summary — schema version, entity counts, project metadata tree — full spatial hierarchy (Project → Site → Building → Storeys → Spaces → Elements) info — deep inspection of any entity by step ID (attributes, psets, placement matrix, type, material) select — filter elements using ifcopenshell selector syntax relations — relationships for an element; --traverse up walks to IfcProject clash — geometric intersection and clearance detection validate — schema/constraint validation; --rules adds EXPRESS checks schedule — work schedules with nested task trees cost — cost schedules with nested cost item trees schema — IFC class documentation from the model's schema version plot — SVG plan drawing render — 3D geometry rendering contexts — geometric representation contexts materials — material assignments Usage: python3 -m ifcquery [args] Generated with the assistance of an AI coding tool. --- 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 From 6bf42592982d500d38930712ea8d45fc220ef949 Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Mon, 23 Mar 2026 23:45:42 +0000 Subject: [PATCH 2/2] Add ifcedit: CLI wrapper for ifcopenshell.api mutation functions (#7846) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ifcedit is a new command-line tool for executing ifcopenshell.api mutations from the shell. It wraps the entire API surface — any function callable via ifcopenshell.api can be invoked without writing Python. Subcommands: list [module] — list all API modules, or functions within a module docs — full documentation (params, types, descriptions) run [--param value ...] — execute a mutation; overwrites input file by default, or use -o to write elsewhere; --dry-run validates without executing quantify list — list available QTO rules quantify run — run quantity take-off, writing IfcElementQuantity psets back to the file Parameter coercion: entity references can be passed as step IDs (strings); lists, dicts, booleans, and None are handled automatically. Usage: python3 -m ifcedit run model.ifc root.remove_product --product 42 python3 -m ifcedit docs geometry.edit_object_placement Generated with the assistance of an AI coding tool. --- src/ifcedit/README.md | 248 +++++++++++++++++++++++++ src/ifcedit/ifcedit/__init__.py | 20 ++ src/ifcedit/ifcedit/__main__.py | 217 ++++++++++++++++++++++ src/ifcedit/ifcedit/coerce.py | 180 ++++++++++++++++++ src/ifcedit/ifcedit/discover.py | 282 +++++++++++++++++++++++++++++ src/ifcedit/ifcedit/quantify.py | 37 ++++ src/ifcedit/ifcedit/run.py | 148 +++++++++++++++ src/ifcedit/pyproject.toml | 33 ++++ src/ifcedit/tests/__init__.py | 1 + src/ifcedit/tests/conftest.py | 63 +++++++ src/ifcedit/tests/test_coerce.py | 158 ++++++++++++++++ src/ifcedit/tests/test_discover.py | 104 +++++++++++ src/ifcedit/tests/test_main.py | 100 ++++++++++ src/ifcedit/tests/test_quantify.py | 87 +++++++++ src/ifcedit/tests/test_run.py | 97 ++++++++++ 15 files changed, 1775 insertions(+) create mode 100644 src/ifcedit/README.md create mode 100644 src/ifcedit/ifcedit/__init__.py create mode 100644 src/ifcedit/ifcedit/__main__.py create mode 100644 src/ifcedit/ifcedit/coerce.py create mode 100644 src/ifcedit/ifcedit/discover.py create mode 100644 src/ifcedit/ifcedit/quantify.py create mode 100644 src/ifcedit/ifcedit/run.py create mode 100644 src/ifcedit/pyproject.toml create mode 100644 src/ifcedit/tests/__init__.py create mode 100644 src/ifcedit/tests/conftest.py create mode 100644 src/ifcedit/tests/test_coerce.py create mode 100644 src/ifcedit/tests/test_discover.py create mode 100644 src/ifcedit/tests/test_main.py create mode 100644 src/ifcedit/tests/test_quantify.py create mode 100644 src/ifcedit/tests/test_run.py diff --git a/src/ifcedit/README.md b/src/ifcedit/README.md new file mode 100644 index 0000000000..21b4935cfb --- /dev/null +++ b/src/ifcedit/README.md @@ -0,0 +1,248 @@ + +# ifcedit + +A CLI wrapper that exposes all 350+ `ifcopenshell.api` mutation functions as +shell commands. Functions are auto-discovered at runtime via introspection -- +no hardcoded list to maintain. + +## Installation + +```bash +pip install ifcedit +``` + +Requires `ifcopenshell`. + +## Usage + +``` +ifcedit [options] [--format json|text] +``` + +Three subcommands: `list` to discover functions, `docs` to read their +documentation, and `run` to execute them. + +## Subcommands + +### list + +Discover available API modules and their functions. + +**List all modules:** + +```bash +ifcedit list +``` + +```json +[ + { + "module": "root", + "description": "Functions for creating project-level entities", + "functions": ["create_entity", "remove_product", "copy_class"], + "count": 3 + }, + { + "module": "spatial", + "description": "Functions for managing spatial relationships", + "functions": ["assign_container", "unassign_container"], + "count": 2 + } +] +``` + +**List functions in a module:** + +```bash +ifcedit list root +``` + +```json +[ + { + "name": "create_entity", + "description": "Create an IFC entity with optional initial attributes", + "params": [ + {"name": "ifc_class", "type": "str", "required": true}, + {"name": "name", "type": "Optional[str]"} + ] + } +] +``` + +### docs + +Show full documentation for a specific function, including parameter +descriptions from docstrings and return type. + +```bash +ifcedit docs root.create_entity +``` + +```json +{ + "module": "root", + "function": "create_entity", + "description": "Create an IFC entity with optional initial attributes", + "long_description": "This function creates a new entity instance...", + "params": [ + { + "name": "ifc_class", + "type": "str", + "required": true, + "description": "The IFC class name (e.g. 'IfcWall', 'IfcProject')" + }, + { + "name": "name", + "type": "Optional[str]", + "description": "Optional name attribute" + } + ], + "return_type": "ifcopenshell.entity_instance", + "return_description": "The newly created entity instance" +} +``` + +### run + +Execute an API function against an IFC file. Parameters are passed as +`--key value` pairs after the function name. + +```bash +ifcedit run model.ifc root.create_entity --ifc_class IfcWall --name "My Wall" +``` + +```json +{ + "ok": true, + "result": {"id": 42, "type": "IfcWall", "name": "My Wall"} +} +``` + +**Options:** + +- `-o, --output ` -- write to a different file instead of overwriting the input +- `--dry-run` -- validate parameters without executing or saving + +```bash +# Save to a new file +ifcedit run model.ifc root.create_entity -o out.ifc --ifc_class IfcWall + +# Validate without executing +ifcedit run model.ifc root.create_entity --dry-run --ifc_class IfcWall +``` + +Dry-run output shows the resolved parameters: + +```json +{ + "ok": true, + "dry_run": true, + "module": "root", + "function": "create_entity", + "args": {"ifc_class": "IfcWall", "name": "My Wall"} +} +``` + +## Parameter type coercion + +CLI strings are automatically converted to the types expected by each API +function, using the function's type annotations: + +| Type | CLI input | Python value | +|------|-----------|--------------| +| `str` | `"hello"` | `"hello"` | +| `int` | `"42"` or `"#42"` | `42` | +| `float` | `"3.14"` | `3.14` | +| `bool` | `"true"`, `"1"`, `"yes"` | `True` | +| `Optional[X]` | `"none"` | `None` | +| `entity_instance` | `"42"` or `"#42"` | resolved from model by step ID | +| `list[entity_instance]` | `"5,6,7"` or `"[5, 6, 7]"` | list of resolved entities | +| `dict` | `'{"key": "val"}'` | parsed JSON object | +| `Literal["A", "B"]` | `"A"` | validated against allowed values | + +## Examples + +```bash +# Create a project +ifcedit run model.ifc root.create_entity --ifc_class IfcProject --name "My Project" + +# Assign an element to a storey +ifcedit run model.ifc spatial.assign_container --products 10 --relating_structure 4 + +# Assign multiple elements at once +ifcedit run model.ifc aggregate.assign_object --products "5,6,7" --relating_object 1 + +# Add a property set +ifcedit run model.ifc pset.add_pset --product 10 --name "Pset_WallCommon" + +# Edit properties +ifcedit run model.ifc pset.edit_pset --pset 15 \ + --properties '{"IsExternal": true, "FireRating": "2HR"}' +``` + +### quantify + +Run quantity take-off (QTO) on an IFC file, computing physical measurements +(volume, area, length, count, weight) and writing them back as +`IfcElementQuantity` property sets. Uses `ifc5d` rules. + +**List available rules:** + +```bash +ifcedit quantify list +``` + +```json +[ + {"name": "IFC4QtoBaseQuantities"}, + {"name": "IFC4X3QtoBaseQuantities"} +] +``` + +**Run QTO on a file:** + +```bash +ifcedit quantify run model.ifc IFC4QtoBaseQuantities +ifcedit quantify run model.ifc IFC4QtoBaseQuantities --selector IfcWall +ifcedit quantify run model.ifc IFC4QtoBaseQuantities -o model_qto.ifc +``` + +```json +{"ok": true, "rule": "IFC4QtoBaseQuantities", "elements_quantified": 42} +``` + +Options: + +- `--selector ` -- ifcopenshell selector to restrict elements (default: all `IfcElement`) +- `-o, --output ` -- write to a different file instead of overwriting the input + +Note: `quantify run` writes geometry-based measurements and requires the +IfcOpenShell C++ geometry bindings for elements with computed quantities. + +## Error handling + +Errors are reported in the JSON response: + +```json +{ + "ok": false, + "error": "Entity #999 not found in model" +} +``` + +Exit code is 0 on success, 1 on error. + +## Relationship to ifcquery + +`ifcedit` and `ifcquery` are complementary tools: + +- **ifcquery** reads and inspects IFC models (summary, tree, info, select, relations, clash, validate, schedule, cost, schema, contexts, materials, plot, render) +- **ifcedit** modifies IFC models by wrapping `ifcopenshell.api` functions, and runs QTO via `quantify` + +A typical workflow: inspect with `ifcquery`, look up the right API function +with `ifcedit docs`, then apply changes with `ifcedit run`. + +## License + +LGPLv3+ -- see the IfcOpenShell project license. diff --git a/src/ifcedit/ifcedit/__init__.py b/src/ifcedit/ifcedit/__init__.py new file mode 100644 index 0000000000..eac2eac799 --- /dev/null +++ b/src/ifcedit/ifcedit/__init__.py @@ -0,0 +1,20 @@ +# This file was generated with the assistance of an AI coding tool. +# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions +# Copyright (C) 2026 Bruno Postle +# +# This file is part of IfcEdit. +# +# IfcEdit 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. +# +# IfcEdit 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 IfcEdit. If not, see . + +__version__ = version = "0.0.0" diff --git a/src/ifcedit/ifcedit/__main__.py b/src/ifcedit/ifcedit/__main__.py new file mode 100644 index 0000000000..7c4e4c642e --- /dev/null +++ b/src/ifcedit/ifcedit/__main__.py @@ -0,0 +1,217 @@ +# This file was generated with the assistance of an AI coding tool. +# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions +# Copyright (C) 2026 Bruno Postle +# +# This file is part of IfcEdit. +# +# IfcEdit 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. +# +# IfcEdit 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 IfcEdit. If not, see . + +from __future__ import annotations + +import argparse +import json +import sys + +import ifcopenshell + +from ifcedit.discover import function_docs, list_functions, list_modules +from ifcedit.quantify import list_rules, run_quantify +from ifcedit.run import run_api + + +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 cmd_list(args): + if args.module: + try: + functions = list_functions(args.module) + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + print(format_output(functions, args.output_format)) + else: + modules = list_modules() + print(format_output(modules, args.output_format)) + + +def cmd_docs(args): + parts = args.function_path.split(".") + if len(parts) != 2: + print("Error: function path must be 'module.function' (e.g. root.create_entity)", file=sys.stderr) + sys.exit(1) + module, function = parts + try: + docs = function_docs(module, function) + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + print(format_output(docs, args.output_format)) + + +def cmd_run(args, extra_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) + + parts = args.function_path.split(".") + if len(parts) != 2: + print("Error: function path must be 'module.function' (e.g. root.create_entity)", file=sys.stderr) + sys.exit(1) + module, function = parts + + # Parse extra --key value arguments into a dict + raw_kwargs = _parse_extra_args(extra_args) + + if args.dry_run: + result = {"ok": True, "dry_run": True, "module": module, "function": function, "args": raw_kwargs} + else: + result = run_api(model, module, function, raw_kwargs) + + if result["ok"]: + output_path = args.output or args.ifc_file + model.write(output_path) + + print(format_output(result, args.output_format)) + if not result["ok"]: + sys.exit(1) + + +def _parse_extra_args(extra: list[str]) -> dict[str, str]: + """Parse a list of ['--key', 'value', ...] into a dict.""" + kwargs = {} + i = 0 + while i < len(extra): + arg = extra[i] + if arg.startswith("--"): + key = arg[2:] + if i + 1 < len(extra) and not extra[i + 1].startswith("--"): + kwargs[key] = extra[i + 1] + i += 2 + else: + # Flag without value — treat as "true" + kwargs[key] = "true" + i += 1 + else: + print(f"Error: Unexpected argument: {arg}", file=sys.stderr) + sys.exit(1) + return kwargs + + +def cmd_quantify(args, extra_args): + if args.quantify_command == "list": + result = list_rules() + print(format_output(result, args.output_format)) + elif args.quantify_command == "run": + 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) + selector = args.selector or None + result = run_quantify(model, args.rule_name, selector=selector) + if result["ok"]: + output_path = args.output or args.ifc_file + model.write(output_path) + print(format_output(result, args.output_format)) + if not result["ok"]: + sys.exit(1) + else: + print("Error: quantify requires a subcommand: list or run", file=sys.stderr) + sys.exit(1) + + +def main(): + parser = argparse.ArgumentParser( + prog="ifcedit", + description="CLI wrapper for ifcopenshell.api IFC model mutation functions", + ) + 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) + + # list + list_parser = subparsers.add_parser("list", help="List API modules or functions in a module") + list_parser.add_argument("module", nargs="?", help="Module name (omit to list all modules)") + + # docs + docs_parser = subparsers.add_parser("docs", help="Show full documentation for an API function") + docs_parser.add_argument("function_path", help="module.function (e.g. root.create_entity)") + + # run + run_parser = subparsers.add_parser("run", help="Execute an API function on an IFC file") + run_parser.add_argument("ifc_file", help="Path to the IFC file") + run_parser.add_argument("function_path", help="module.function (e.g. root.create_entity)") + run_parser.add_argument("-o", "--output", help="Output file path (default: overwrite input)") + run_parser.add_argument("--dry-run", action="store_true", help="Validate without executing or saving") + + # quantify + quantify_parser = subparsers.add_parser("quantify", help="Quantity take-off (QTO) using ifc5d rules") + quantify_sub = quantify_parser.add_subparsers(dest="quantify_command") + quantify_sub.add_parser("list", help="List available QTO rule names") + qrun_parser = quantify_sub.add_parser("run", help="Run QTO on an IFC file") + qrun_parser.add_argument("ifc_file", help="Path to the IFC file") + qrun_parser.add_argument("rule_name", help="QTO rule name (e.g. IFC4QtoBaseQuantities)") + qrun_parser.add_argument("--selector", help="ifcopenshell selector to restrict elements (default: all IfcElement)") + qrun_parser.add_argument("-o", "--output", help="Output file path (default: overwrite input)") + + args, extra = parser.parse_known_args() + + if args.command == "list": + cmd_list(args) + elif args.command == "docs": + cmd_docs(args) + elif args.command == "run": + cmd_run(args, extra) + elif args.command == "quantify": + cmd_quantify(args, extra) + + +if __name__ == "__main__": + main() diff --git a/src/ifcedit/ifcedit/coerce.py b/src/ifcedit/ifcedit/coerce.py new file mode 100644 index 0000000000..5a410ec186 --- /dev/null +++ b/src/ifcedit/ifcedit/coerce.py @@ -0,0 +1,180 @@ +# This file was generated with the assistance of an AI coding tool. +# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions +# Copyright (C) 2026 Bruno Postle +# +# This file is part of IfcEdit. +# +# IfcEdit 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. +# +# IfcEdit 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 IfcEdit. If not, see . + +from __future__ import annotations + +import json +import typing + +import ifcopenshell + + +def coerce_value( + value_str: str, + type_hint, + model: ifcopenshell.file | None = None, + lookup_file: ifcopenshell.file | None = None, +): + """Convert a CLI string argument to the proper Python type based on a type hint. + + Args: + value_str: The raw string from the CLI. + type_hint: The type annotation from the function signature. + model: The main open IFC model, needed to resolve entity instance references by ID. + lookup_file: Override file for entity resolution (e.g. a library file for + project.append_asset). When provided, entity IDs are looked up here instead + of in model. + + Returns: + The converted Python value. + + Raises: + ValueError: If the value cannot be converted. + TypeError: If the type hint is not supported. + """ + # When a library file has been opened, entity IDs are resolved from it, not the main model. + effective_lookup = lookup_file if lookup_file is not None else model + + if type_hint is None: + return value_str + + origin = typing.get_origin(type_hint) + args = typing.get_args(type_hint) + + # Union / Optional + if origin is typing.Union: + non_none_types = [a for a in args if a is not type(None)] + if value_str.lower() == "none": + if type(None) in args: + return None + # Try each non-None type in order + for t in non_none_types: + try: + return coerce_value(value_str, t, model, lookup_file) + except (ValueError, TypeError): + continue + raise ValueError(f"Cannot convert '{value_str}' to any of {non_none_types}") + + # Literal + if origin is typing.Literal: + allowed = args + if value_str in [str(a) for a in allowed]: + # return the actual literal value with proper type + for a in allowed: + if str(a) == value_str: + return a + raise ValueError(f"'{value_str}' is not one of: {', '.join(repr(a) for a in allowed)}") + + # list types + if origin is list: + if args and _is_entity_type(args[0]): + return _coerce_entity_list(value_str, effective_lookup) + if args: + items = _split_list(value_str) + return [coerce_value(item.strip(), args[0], model, lookup_file) for item in items] + return _split_list(value_str) + + # dict types + if origin is dict: + return _floatify_numeric_lists(json.loads(value_str)) + + # Simple types + if type_hint is str: + return value_str + if type_hint is int: + return int(value_str.lstrip("#")) + if type_hint is float: + return float(value_str) + if type_hint is bool: + return value_str.lower() in ("true", "1", "yes") + + # ifcopenshell.file — open from path string + if type_hint is ifcopenshell.file: + return ifcopenshell.open(value_str) + + # entity_instance + if _is_entity_type(type_hint): + return _coerce_entity(value_str, effective_lookup) + + # Fallback: try json.loads for complex types, then plain string + try: + return json.loads(value_str) + except (json.JSONDecodeError, TypeError): + return value_str + + +def _is_entity_type(hint) -> bool: + """Check if a type hint refers to ifcopenshell.entity_instance.""" + if hint is ifcopenshell.entity_instance: + return True + if isinstance(hint, type) and issubclass(hint, ifcopenshell.entity_instance): + return True + return False + + +def _coerce_entity(value_str: str | int, lookup_file: ifcopenshell.file | None) -> ifcopenshell.entity_instance: + """Resolve a step ID string like '123' or '#123' to an entity instance.""" + if lookup_file is None: + raise ValueError("Cannot resolve entity reference without an IFC model") + if isinstance(value_str, int): + entity_id = value_str + else: + entity_id = int(value_str.strip().lstrip("#")) + try: + return lookup_file.by_id(entity_id) + except RuntimeError: + raise ValueError(f"Entity #{entity_id} not found in model") + + +def _coerce_entity_list(value_str: str, lookup_file: ifcopenshell.file | None) -> list[ifcopenshell.entity_instance]: + """Resolve a comma-separated list of step IDs to entity instances.""" + items = _split_list(value_str) + return [_coerce_entity(item.strip(), lookup_file) for item in items] + + +def _floatify_numeric_lists(obj): + """Recursively convert lists of numbers to lists of floats. + + IFC C++ bindings require Python floats (not ints) for AGGREGATE OF DOUBLE + attributes (e.g. DirectionRatios, Coordinates). JSON parsing produces ints + for whole numbers like 0, which causes a TypeError at the binding level. + """ + if isinstance(obj, dict): + return {k: _floatify_numeric_lists(v) for k, v in obj.items()} + if ( + isinstance(obj, list) + and obj + and all(isinstance(v, (int, float)) for v in obj) + and any(isinstance(v, float) for v in obj) + ): + return [float(v) for v in obj] + return obj + + +def _split_list(value_str: str) -> list[str]: + """Split a comma-separated string, handling JSON arrays too.""" + value_str = value_str.strip() + if value_str.startswith("["): + try: + parsed = json.loads(value_str) + if isinstance(parsed, list): + return [json.dumps(item) if isinstance(item, (dict, list)) else str(item) for item in parsed] + except json.JSONDecodeError: + pass + return [item.strip() for item in value_str.split(",") if item.strip()] diff --git a/src/ifcedit/ifcedit/discover.py b/src/ifcedit/ifcedit/discover.py new file mode 100644 index 0000000000..b26a93f6c6 --- /dev/null +++ b/src/ifcedit/ifcedit/discover.py @@ -0,0 +1,282 @@ +# This file was generated with the assistance of an AI coding tool. +# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions +# Copyright (C) 2026 Bruno Postle +# +# This file is part of IfcEdit. +# +# IfcEdit 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. +# +# IfcEdit 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 IfcEdit. If not, see . + +from __future__ import annotations + +import importlib +import inspect +import re +import typing +from pathlib import Path + + +def _api_package_path() -> Path: + """Return the filesystem path to the ifcopenshell.api package.""" + import ifcopenshell.api + + return Path(ifcopenshell.api.__file__).parent + + +def list_modules() -> list[dict]: + """List all API modules with their function counts and descriptions. + + Returns a list of dicts: [{"module": "root", "description": "...", "functions": [...], "count": 4}, ...] + """ + api_path = _api_package_path() + modules = [] + for child in sorted(api_path.iterdir()): + if not child.is_dir() or child.name.startswith("_"): + continue + init_file = child / "__init__.py" + if not init_file.exists(): + continue + try: + mod = importlib.import_module(f"ifcopenshell.api.{child.name}") + except Exception: + continue + all_names = getattr(mod, "__all__", []) + if not all_names: + continue + description = "" + if mod.__doc__: + description = mod.__doc__.strip().split("\n")[0] + modules.append( + { + "module": child.name, + "description": description, + "functions": list(all_names), + "count": len(all_names), + } + ) + return modules + + +def list_functions(module: str) -> list[dict]: + """List functions in an API module with one-line descriptions and parameter info. + + Returns a list of dicts: [{"name": "create_entity", "description": "...", "params": [...]}] + """ + mod = importlib.import_module(f"ifcopenshell.api.{module}") + all_names = getattr(mod, "__all__", []) + functions = [] + for name in all_names: + fn = _get_underlying_function(module, name) + if fn is None: + continue + description = "" + if fn.__doc__: + description = fn.__doc__.strip().split("\n")[0] + params = _extract_params(fn) + functions.append( + { + "name": name, + "description": description, + "params": params, + } + ) + return functions + + +def function_docs(module: str, function: str) -> dict: + """Full documentation for a single API function. + + Returns a dict with: module, function, description, params (with types/defaults/descriptions), return_type + """ + fn = _get_underlying_function(module, function) + if fn is None: + raise ValueError(f"Function '{module}.{function}' not found") + + description = "" + long_description = "" + if fn.__doc__: + description, long_description = _parse_docstring_body(fn.__doc__) + + params = _extract_params(fn) + param_descriptions = _parse_param_docs(fn.__doc__ or "") + for param in params: + if param["name"] in param_descriptions: + param["description"] = param_descriptions[param["name"]] + + return_type = _format_type_hint(typing.get_type_hints(fn).get("return")) + return_description = _parse_return_doc(fn.__doc__ or "") + + result = { + "module": module, + "function": function, + "description": description, + "long_description": long_description, + "params": params, + } + if return_type: + result["return_type"] = return_type + if return_description: + result["return_description"] = return_description + return result + + +def _get_underlying_function(module: str, function: str): + """Get the actual function object (unwrapping the listener wrapper if needed).""" + try: + fn_module = importlib.import_module(f"ifcopenshell.api.{module}.{function}") + fn = getattr(fn_module, function, None) + return fn + except (ImportError, AttributeError): + return None + + +def _extract_params(fn) -> list[dict]: + """Extract parameter info from a function's signature and type hints.""" + sig = inspect.signature(fn) + try: + hints = typing.get_type_hints(fn) + except Exception: + hints = {} + + params = [] + for name, param in sig.parameters.items(): + if name == "file" or name == "self": + continue + info = {"name": name} + if name in hints: + info["type"] = _format_type_hint(hints[name]) + if param.default is not inspect.Parameter.empty: + info["default"] = _serialize_default(param.default) + else: + info["required"] = True + params.append(info) + return params + + +def _format_type_hint(hint) -> str | None: + """Format a type hint to a readable string.""" + import ifcopenshell + + if hint is None: + return None + if hint is type(None): + return "None" + # ifcopenshell.file params are passed as a file path string + if hint is ifcopenshell.file: + return "file_path" + origin = typing.get_origin(hint) + args = typing.get_args(hint) + + # Union (including Optional) + if origin is typing.Union: + formatted = [_format_type_hint(a) for a in args] + # Optional[X] is Union[X, None] — render as "Optional[X]" + if len(formatted) == 2 and "None" in formatted: + inner = next(f for f in formatted if f != "None") + return f"Optional[{inner}]" + return " | ".join(formatted) + + # Literal + if origin is typing.Literal: + values = ", ".join(repr(a) for a in args) + return f"Literal[{values}]" + + # Generic types (list, dict, etc.) + if origin is not None: + origin_name = getattr(origin, "__name__", str(origin)) + if args: + inner = ", ".join(_format_type_hint(a) for a in args) + return f"{origin_name}[{inner}]" + return origin_name + + # Simple types + return getattr(hint, "__name__", str(hint)) + + +def _serialize_default(value): + """Serialize a default value to something JSON-friendly.""" + if value is None: + return None + if isinstance(value, (str, int, float, bool)): + return value + return repr(value) + + +def _parse_docstring_body(docstring: str) -> tuple[str, str]: + """Parse the summary and long description from a docstring.""" + lines = docstring.strip().split("\n") + summary = lines[0].strip() if lines else "" + body_lines = [] + in_body = False + for line in lines[1:]: + stripped = line.strip() + if stripped.startswith(":param") or stripped.startswith(":return"): + break + if stripped.startswith("Example"): + break + if not in_body and not stripped: + in_body = True + continue + if in_body: + body_lines.append(stripped) + + long_description = " ".join(body_lines).strip() + # collapse multiple spaces + long_description = re.sub(r"\s+", " ", long_description) + return summary, long_description + + +_FIELD_MARKER = re.compile(r":(?:param|returns?|rtype|type|raises?)\b") + + +def _parse_param_docs(docstring: str) -> dict[str, str]: + """Extract :param name: description lines from a docstring.""" + params = {} + current_param = None + current_lines = [] + for line in docstring.split("\n"): + stripped = line.strip() + match = re.match(r":param\s+(\w+):\s*(.*)", stripped) + if match: + if current_param: + params[current_param] = " ".join(current_lines).strip() + current_param = match.group(1) + current_lines = [match.group(2)] + elif current_param and stripped and not _FIELD_MARKER.match(stripped): + current_lines.append(stripped) + elif _FIELD_MARKER.match(stripped) or (stripped == "" and current_param): + if current_param: + params[current_param] = " ".join(current_lines).strip() + current_param = None + current_lines = [] + if current_param: + params[current_param] = " ".join(current_lines).strip() + # collapse whitespace + return {k: re.sub(r"\s+", " ", v) for k, v in params.items()} + + +def _parse_return_doc(docstring: str) -> str: + """Extract :return: description from a docstring.""" + lines = [] + in_return = False + for line in docstring.split("\n"): + stripped = line.strip() + match = re.match(r":return:\s*(.*)", stripped) + if match: + in_return = True + lines = [match.group(1)] + elif in_return: + if _FIELD_MARKER.match(stripped) or stripped == "": + break + lines.append(stripped) + return re.sub(r"\s+", " ", " ".join(lines).strip()) diff --git a/src/ifcedit/ifcedit/quantify.py b/src/ifcedit/ifcedit/quantify.py new file mode 100644 index 0000000000..f85475e22c --- /dev/null +++ b/src/ifcedit/ifcedit/quantify.py @@ -0,0 +1,37 @@ +# This file was generated with the assistance of an AI coding tool. +from __future__ import annotations + +from typing import Any + +import ifcopenshell + +AVAILABLE_RULES = ["IFC4QtoBaseQuantities", "IFC4X3QtoBaseQuantities"] + + +def list_rules() -> list[dict[str, str]]: + """Return a list of available quantification rule names.""" + return [{"name": name} for name in AVAILABLE_RULES] + + +def run_quantify(model: ifcopenshell.file, rule: str, selector: str | None = None) -> dict[str, Any]: + """Run quantity take-off on the model using the named rule. + + Modifies the model in-place by adding/updating IfcElementQuantity psets. + Returns a summary dict with ok, rule, and elements_quantified. + """ + from ifc5d.qto import edit_qtos, quantify + from ifc5d.qto import rules as rule_sets + + if rule not in rule_sets: + return {"ok": False, "error": f"Unknown rule: {rule}. Available: {list(rule_sets.keys())}"} + + import ifcopenshell.util.selector + + if selector: + elements = set(ifcopenshell.util.selector.filter_elements(model, selector)) + else: + elements = set(model.by_type("IfcElement")) + + results = quantify(model, elements, rule_sets[rule]) + edit_qtos(model, results) + return {"ok": True, "rule": rule, "elements_quantified": len(results)} diff --git a/src/ifcedit/ifcedit/run.py b/src/ifcedit/ifcedit/run.py new file mode 100644 index 0000000000..91d8be6774 --- /dev/null +++ b/src/ifcedit/ifcedit/run.py @@ -0,0 +1,148 @@ +# This file was generated with the assistance of an AI coding tool. +# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions +# Copyright (C) 2026 Bruno Postle +# +# This file is part of IfcEdit. +# +# IfcEdit 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. +# +# IfcEdit 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 IfcEdit. If not, see . + +from __future__ import annotations + +import importlib +import inspect +import typing + +import ifcopenshell + +from ifcedit.coerce import coerce_value + + +def _is_file_type(hint) -> bool: + """Check if a type hint refers to ifcopenshell.file (or Optional[ifcopenshell.file]).""" + if hint is ifcopenshell.file: + return True + origin = typing.get_origin(hint) + args = typing.get_args(hint) + if origin is typing.Union and ifcopenshell.file in args: + return True + return False + + +def run_api( + model: ifcopenshell.file, + module: str, + function: str, + raw_kwargs: dict[str, str], +) -> dict: + """Execute an ifcopenshell.api function with CLI-provided string arguments. + + Args: + model: The open IFC model. + module: API module name (e.g. "root"). + function: Function name (e.g. "create_entity"). + raw_kwargs: String keyword arguments from the CLI. + + Returns: + A dict with {"ok": True, "result": ...} on success, + or {"ok": False, "error": "..."} on failure. + """ + try: + fn = _import_function(module, function) + except (ImportError, AttributeError) as e: + return {"ok": False, "error": f"Cannot find function '{module}.{function}': {e}"} + + try: + hints = typing.get_type_hints(fn) + except Exception: + hints = {} + + sig = inspect.signature(fn) + coerced_kwargs = {} + + # Pass 1: coerce ifcopenshell.file-typed params first (e.g. library= in append_asset). + # The opened file is then used as the lookup file for entity resolution in pass 2. + opened_files: list[ifcopenshell.file] = [] + for name, value_str in raw_kwargs.items(): + if name not in sig.parameters: + return {"ok": False, "error": f"Unknown parameter '{name}' for {module}.{function}"} + hint = hints.get(name) + if not _is_file_type(hint): + continue + try: + coerced = coerce_value(value_str, hint, model) + coerced_kwargs[name] = coerced + if isinstance(coerced, ifcopenshell.file): + opened_files.append(coerced) + except (ValueError, TypeError) as e: + return {"ok": False, "error": f"Cannot convert parameter '{name}': {e}"} + + # Pass 2: coerce remaining params. Entity instance IDs are resolved from the opened + # library file (if any), since you are always appending from another file, never + # from the current model. + lookup_file = opened_files[0] if opened_files else None + for name, value_str in raw_kwargs.items(): + if name in coerced_kwargs: + continue + if name not in sig.parameters: + return {"ok": False, "error": f"Unknown parameter '{name}' for {module}.{function}"} + hint = hints.get(name) + try: + coerced_kwargs[name] = coerce_value(value_str, hint, model, lookup_file=lookup_file) + except (ValueError, TypeError) as e: + return {"ok": False, "error": f"Cannot convert parameter '{name}': {e}"} + + # Determine if the function takes 'file' as its first parameter + first_param = next(iter(sig.parameters), None) + try: + if first_param == "file": + result = fn(model, **coerced_kwargs) + else: + result = fn(**coerced_kwargs) + except Exception as e: + return {"ok": False, "error": f"{type(e).__name__}: {e}"} + + return {"ok": True, "result": serialize_result(result)} + + +def _import_function(module: str, function: str): + """Import and return the underlying function from ifcopenshell.api.""" + fn_module = importlib.import_module(f"ifcopenshell.api.{module}.{function}") + fn = getattr(fn_module, function) + return fn + + +def serialize_result(value) -> object: + """Serialize an API result to a JSON-friendly structure.""" + if value is None: + return None + if isinstance(value, ifcopenshell.entity_instance): + return _serialize_entity(value) + if isinstance(value, (list, tuple, set, frozenset)): + return [serialize_result(item) for item in value] + if isinstance(value, dict): + return {str(k): serialize_result(v) for k, v in value.items()} + if isinstance(value, (str, int, float, bool)): + return value + return str(value) + + +def _serialize_entity(entity: ifcopenshell.entity_instance) -> dict: + """Serialize an entity instance to a summary dict.""" + result = { + "id": entity.id(), + "type": entity.is_a(), + } + if hasattr(entity, "Name") and entity.Name: + result["name"] = entity.Name + return result diff --git a/src/ifcedit/pyproject.toml b/src/ifcedit/pyproject.toml new file mode 100644 index 0000000000..f50c0b3cc4 --- /dev/null +++ b/src/ifcedit/pyproject.toml @@ -0,0 +1,33 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "ifcedit" +version = "0.0.0" +authors = [ + { name="Bruno Postle", email="bruno@postle.net" }, +] +description = "CLI wrapper for ifcopenshell.api IFC model mutation functions" +readme = "README.md" +keywords = ["IFC", "BIM", "API"] +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)", +] +dependencies = ["ifcopenshell", "ifc5d"] + +[project.scripts] +ifcedit = "ifcedit.__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 = ["ifcedit*"] +exclude = ["test*"] + +[tool.ruff] +extend = "../../pyproject.toml" diff --git a/src/ifcedit/tests/__init__.py b/src/ifcedit/tests/__init__.py new file mode 100644 index 0000000000..0a3bc271a0 --- /dev/null +++ b/src/ifcedit/tests/__init__.py @@ -0,0 +1 @@ +# This file was generated with the assistance of an AI coding tool. diff --git a/src/ifcedit/tests/conftest.py b/src/ifcedit/tests/conftest.py new file mode 100644 index 0000000000..241220b145 --- /dev/null +++ b/src/ifcedit/tests/conftest.py @@ -0,0 +1,63 @@ +# This file was generated with the assistance of an AI coding tool. +import ifcopenshell +import ifcopenshell.api.aggregate +import ifcopenshell.api.material +import ifcopenshell.api.owner.settings +import ifcopenshell.api.project +import ifcopenshell.api.pset +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) + + return f + + +@pytest.fixture +def model_file(model, tmp_path): + """Write the model fixture to a temp file and return the path.""" + path = tmp_path / "test.ifc" + model.write(str(path)) + return str(path) + + +@pytest.fixture +def library(): + """Create an IFC4 library with a single IfcWallType asset.""" + lib = 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(lib, ifc_class="IfcProject", name="TestLibrary") + ifcopenshell.api.unit.assign_unit(lib) + ifcopenshell.api.root.create_entity(lib, ifc_class="IfcWallType", name="WAL01") + return lib + + +@pytest.fixture +def library_file(library, tmp_path): + """Write the library fixture to a temp file and return the path.""" + path = tmp_path / "library.ifc" + library.write(str(path)) + return str(path) diff --git a/src/ifcedit/tests/test_coerce.py b/src/ifcedit/tests/test_coerce.py new file mode 100644 index 0000000000..4ee6895717 --- /dev/null +++ b/src/ifcedit/tests/test_coerce.py @@ -0,0 +1,158 @@ +# This file was generated with the assistance of an AI coding tool. +import json +from typing import Literal, Optional, Union + +import ifcopenshell +import ifcopenshell.api.project +import pytest + +from ifcedit.coerce import coerce_value + + +class TestStringCoercion: + def test_plain_string(self): + assert coerce_value("hello", str) == "hello" + + def test_empty_string(self): + assert coerce_value("", str) == "" + + +class TestIntCoercion: + def test_plain_int(self): + assert coerce_value("42", int) == 42 + + def test_hash_prefix(self): + assert coerce_value("#42", int) == 42 + + def test_negative(self): + assert coerce_value("-5", int) == -5 + + +class TestFloatCoercion: + def test_plain_float(self): + assert coerce_value("3.14", float) == pytest.approx(3.14) + + def test_integer_as_float(self): + assert coerce_value("5", float) == 5.0 + + +class TestBoolCoercion: + def test_true_values(self): + for val in ("true", "True", "TRUE", "1", "yes"): + assert coerce_value(val, bool) is True + + def test_false_values(self): + for val in ("false", "False", "0", "no"): + assert coerce_value(val, bool) is False + + +class TestOptionalCoercion: + def test_optional_string(self): + assert coerce_value("hello", Optional[str]) == "hello" + + def test_optional_none(self): + assert coerce_value("none", Optional[str]) is None + assert coerce_value("None", Optional[str]) is None + + def test_optional_int(self): + assert coerce_value("42", Optional[int]) == 42 + + +class TestUnionCoercion: + def test_union_str_int(self): + # Tries str first (or int first depending on order), both work + result = coerce_value("hello", Union[str, int]) + assert result == "hello" + + def test_union_int_none(self): + result = coerce_value("42", Union[int, None]) + assert result == 42 + + +class TestLiteralCoercion: + def test_valid_literal(self): + assert coerce_value("IFC4", Literal["IFC2X3", "IFC4", "IFC4X3"]) == "IFC4" + + def test_invalid_literal(self): + with pytest.raises(ValueError, match="not one of"): + coerce_value("IFC5", Literal["IFC2X3", "IFC4", "IFC4X3"]) + + +class TestDictCoercion: + def test_json_dict(self): + result = coerce_value('{"IsExternal": true, "FireRating": "2HR"}', dict[str, object]) + assert result == {"IsExternal": True, "FireRating": "2HR"} + + def test_mixed_float_int_list_coerced_to_float(self): + # [0.419, 0, 0.908] — JSON integer 0 mixed with floats must become float + # so ifcopenshell AGGREGATE OF DOUBLE attributes (e.g. DirectionRatios) don't reject the list + result = coerce_value('{"DirectionRatios": [0.419, 0, 0.908]}', dict[str, object]) + assert result["DirectionRatios"] == pytest.approx([0.419, 0.0, 0.908]) + assert all(isinstance(v, float) for v in result["DirectionRatios"]) + + def test_pure_int_list_not_coerced(self): + # All-integer lists (e.g. face indices) must stay as ints + result = coerce_value('{"CoordIndex": [0, 1, 2]}', dict[str, object]) + assert result["CoordIndex"] == [0, 1, 2] + assert all(isinstance(v, int) for v in result["CoordIndex"]) + + +class TestListCoercion: + def test_comma_separated(self): + result = coerce_value("a,b,c", list[str]) + assert result == ["a", "b", "c"] + + def test_json_array(self): + result = coerce_value("[1, 2, 3]", list[int]) + assert result == [1, 2, 3] + + +class TestEntityCoercion: + def test_entity_by_id(self, model): + wall = model.by_type("IfcWall")[0] + result = coerce_value(str(wall.id()), ifcopenshell.entity_instance, model) + assert result == wall + + def test_entity_with_hash(self, model): + wall = model.by_type("IfcWall")[0] + result = coerce_value(f"#{wall.id()}", ifcopenshell.entity_instance, model) + assert result == wall + + def test_entity_not_found(self, model): + with pytest.raises(ValueError, match="not found"): + coerce_value("999999", ifcopenshell.entity_instance, model) + + def test_entity_list(self, model): + wall = model.by_type("IfcWall")[0] + result = coerce_value(str(wall.id()), list[ifcopenshell.entity_instance], model) + assert len(result) == 1 + assert result[0] == wall + + def test_entity_list_multiple(self, model): + wall = model.by_type("IfcWall")[0] + storey = model.by_type("IfcBuildingStorey")[0] + result = coerce_value(f"{wall.id()},{storey.id()}", list[ifcopenshell.entity_instance], model) + assert len(result) == 2 + + def test_entity_no_model(self): + with pytest.raises(ValueError, match="without an IFC model"): + coerce_value("42", ifcopenshell.entity_instance, None) + + +class TestFileCoercion: + def test_opens_file_from_path(self, model_file): + result = coerce_value(model_file, ifcopenshell.file) + assert isinstance(result, ifcopenshell.file) + + def test_entity_from_lookup_file(self, model_file): + lib = ifcopenshell.open(model_file) + wall = lib.by_type("IfcWall")[0] + empty_model = ifcopenshell.api.project.create_file() + result = coerce_value(str(wall.id()), ifcopenshell.entity_instance, empty_model, lookup_file=lib) + assert result.id() == wall.id() + assert result.is_a("IfcWall") + + +class TestFallback: + def test_no_type_hint(self): + assert coerce_value("hello", None) == "hello" diff --git a/src/ifcedit/tests/test_discover.py b/src/ifcedit/tests/test_discover.py new file mode 100644 index 0000000000..7b77cb5b7e --- /dev/null +++ b/src/ifcedit/tests/test_discover.py @@ -0,0 +1,104 @@ +# This file was generated with the assistance of an AI coding tool. +from ifcedit.discover import function_docs, list_functions, list_modules + + +class TestListModules: + def test_returns_list(self): + result = list_modules() + assert isinstance(result, list) + assert len(result) > 0 + + def test_module_structure(self): + result = list_modules() + for entry in result: + assert "module" in entry + assert "description" in entry + assert "functions" in entry + assert "count" in entry + assert isinstance(entry["functions"], list) + assert entry["count"] == len(entry["functions"]) + + def test_known_modules_present(self): + result = list_modules() + module_names = [m["module"] for m in result] + for expected in ("root", "spatial", "pset", "aggregate", "unit"): + assert expected in module_names + + def test_root_module_has_functions(self): + result = list_modules() + root = next(m for m in result if m["module"] == "root") + assert "create_entity" in root["functions"] + assert root["count"] >= 3 + + +class TestListFunctions: + def test_root_functions(self): + result = list_functions("root") + assert isinstance(result, list) + names = [f["name"] for f in result] + assert "create_entity" in names + + def test_function_structure(self): + result = list_functions("root") + for fn in result: + assert "name" in fn + assert "description" in fn + assert "params" in fn + + def test_create_entity_params(self): + result = list_functions("root") + create = next(f for f in result if f["name"] == "create_entity") + param_names = [p["name"] for p in create["params"]] + assert "ifc_class" in param_names + assert "name" in param_names + + def test_pset_functions(self): + result = list_functions("pset") + names = [f["name"] for f in result] + assert "add_pset" in names + assert "edit_pset" in names + + +class TestFunctionDocs: + def test_create_entity_docs(self): + result = function_docs("root", "create_entity") + assert result["module"] == "root" + assert result["function"] == "create_entity" + assert result["description"] + assert isinstance(result["params"], list) + assert len(result["params"]) > 0 + + def test_params_have_types(self): + result = function_docs("root", "create_entity") + for param in result["params"]: + assert "name" in param + assert "type" in param + + def test_params_have_descriptions(self): + result = function_docs("root", "create_entity") + ifc_class = next(p for p in result["params"] if p["name"] == "ifc_class") + assert "description" in ifc_class + assert len(ifc_class["description"]) > 0 + + def test_return_type(self): + result = function_docs("root", "create_entity") + assert "return_type" in result + + def test_assign_container_docs(self): + result = function_docs("spatial", "assign_container") + assert result["module"] == "spatial" + param_names = [p["name"] for p in result["params"]] + assert "products" in param_names + assert "relating_structure" in param_names + + def test_unknown_function_raises(self): + import pytest + + with pytest.raises(ValueError, match="not found"): + function_docs("root", "nonexistent_function") + + def test_edit_pset_docs(self): + result = function_docs("pset", "edit_pset") + param_names = [p["name"] for p in result["params"]] + assert "pset" in param_names + assert "properties" in param_names diff --git a/src/ifcedit/tests/test_main.py b/src/ifcedit/tests/test_main.py new file mode 100644 index 0000000000..626351a5a3 --- /dev/null +++ b/src/ifcedit/tests/test_main.py @@ -0,0 +1,100 @@ +# This file was generated with the assistance of an AI coding tool. +import json +import subprocess +import sys + +import pytest + + +def run_ifcedit(*args): + """Run ifcedit as a subprocess and return (stdout, stderr, returncode).""" + result = subprocess.run( + [sys.executable, "-m", "ifcedit", *args], + capture_output=True, + text=True, + ) + return result.stdout, result.stderr, result.returncode + + +class TestListCommand: + def test_list_all_modules(self): + stdout, stderr, rc = run_ifcedit("list") + assert rc == 0 + data = json.loads(stdout) + assert isinstance(data, list) + module_names = [m["module"] for m in data] + assert "root" in module_names + assert "spatial" in module_names + + def test_list_module_functions(self): + stdout, stderr, rc = run_ifcedit("list", "root") + assert rc == 0 + data = json.loads(stdout) + assert isinstance(data, list) + names = [f["name"] for f in data] + assert "create_entity" in names + + def test_list_text_format(self): + stdout, stderr, rc = run_ifcedit("--format", "text", "list") + assert rc == 0 + assert "root" in stdout + + +class TestDocsCommand: + def test_docs_create_entity(self): + stdout, stderr, rc = run_ifcedit("docs", "root.create_entity") + assert rc == 0 + data = json.loads(stdout) + assert data["module"] == "root" + assert data["function"] == "create_entity" + assert "params" in data + + def test_docs_invalid_path(self): + stdout, stderr, rc = run_ifcedit("docs", "invalid_path") + assert rc != 0 + assert "module.function" in stderr + + def test_docs_unknown_function(self): + stdout, stderr, rc = run_ifcedit("docs", "root.nonexistent") + assert rc != 0 + + +class TestRunCommand: + def test_create_entity(self, model_file): + stdout, stderr, rc = run_ifcedit( + "run", model_file, "root.create_entity", "--ifc_class", "IfcWall", "--name", "CLIWall" + ) + assert rc == 0, f"stderr: {stderr}" + data = json.loads(stdout) + assert data["ok"] is True + assert data["result"]["type"] == "IfcWall" + assert data["result"]["name"] == "CLIWall" + + def test_dry_run(self, model_file): + stdout, stderr, rc = run_ifcedit("run", model_file, "root.create_entity", "--dry-run", "--ifc_class", "IfcWall") + assert rc == 0 + data = json.loads(stdout) + assert data["ok"] is True + assert data["dry_run"] is True + + def test_output_to_different_file(self, model_file, tmp_path): + output = str(tmp_path / "output.ifc") + stdout, stderr, rc = run_ifcedit( + "run", model_file, "root.create_entity", "-o", output, "--ifc_class", "IfcSlab" + ) + assert rc == 0, f"stderr: {stderr}" + data = json.loads(stdout) + assert data["ok"] is True + + import os + + assert os.path.exists(output) + + def test_run_error_bad_function(self, model_file): + stdout, stderr, rc = run_ifcedit("run", model_file, "root.nonexistent") + assert rc != 0 + + def test_run_invalid_function_path(self, model_file): + stdout, stderr, rc = run_ifcedit("run", model_file, "invalid_path") + assert rc != 0 + assert "module.function" in stderr diff --git a/src/ifcedit/tests/test_quantify.py b/src/ifcedit/tests/test_quantify.py new file mode 100644 index 0000000000..f4335fb2a7 --- /dev/null +++ b/src/ifcedit/tests/test_quantify.py @@ -0,0 +1,87 @@ +# 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.spatial +import ifcopenshell.api.unit +import pytest + +from ifcedit.quantify import AVAILABLE_RULES, list_rules, run_quantify + + +class TestListRules: + def test_returns_list(self): + result = list_rules() + assert isinstance(result, list) + + def test_each_entry_has_name(self): + result = list_rules() + for entry in result: + assert "name" in entry + + def test_ifc4_rule_present(self): + result = list_rules() + names = [r["name"] for r in result] + assert "IFC4QtoBaseQuantities" in names + + def test_ifc4x3_rule_present(self): + result = list_rules() + names = [r["name"] for r in result] + assert "IFC4X3QtoBaseQuantities" in names + + +@pytest.fixture +def quantify_model(): + """Create an IFC4 model with a wall element.""" + 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) + + return f + + +class TestRunQuantify: + def test_returns_ok_true(self, quantify_model): + result = run_quantify(quantify_model, "IFC4QtoBaseQuantities") + assert result["ok"] is True + + def test_returns_rule_name(self, quantify_model): + result = run_quantify(quantify_model, "IFC4QtoBaseQuantities") + assert result["rule"] == "IFC4QtoBaseQuantities" + + def test_returns_elements_quantified(self, quantify_model): + result = run_quantify(quantify_model, "IFC4QtoBaseQuantities") + assert "elements_quantified" in result + assert isinstance(result["elements_quantified"], int) + + def test_unknown_rule_returns_error(self, quantify_model): + result = run_quantify(quantify_model, "NonExistentRule") + assert result["ok"] is False + assert "error" in result + + def test_selector_restricts_elements(self, quantify_model): + result = run_quantify(quantify_model, "IFC4QtoBaseQuantities", selector="IfcWall") + assert result["ok"] is True + assert result["rule"] == "IFC4QtoBaseQuantities" + + def test_empty_selector_runs_on_all(self, quantify_model): + result = run_quantify(quantify_model, "IFC4QtoBaseQuantities", selector=None) + assert result["ok"] is True diff --git a/src/ifcedit/tests/test_run.py b/src/ifcedit/tests/test_run.py new file mode 100644 index 0000000000..80881e879f --- /dev/null +++ b/src/ifcedit/tests/test_run.py @@ -0,0 +1,97 @@ +# This file was generated with the assistance of an AI coding tool. +import ifcopenshell +import ifcopenshell.api.project +import ifcopenshell.api.pset +import ifcopenshell.api.root + +from ifcedit.run import run_api, serialize_result + + +class TestRunApi: + def test_create_entity(self, model): + result = run_api(model, "root", "create_entity", {"ifc_class": "IfcWall", "name": "NewWall"}) + assert result["ok"] is True + assert result["result"]["type"] == "IfcWall" + assert result["result"]["name"] == "NewWall" + assert isinstance(result["result"]["id"], int) + + def test_create_entity_default_class(self, model): + result = run_api(model, "root", "create_entity", {}) + assert result["ok"] is True + assert result["result"]["type"] == "IfcBuildingElementProxy" + + def test_assign_container(self, model): + wall = ifcopenshell.api.root.create_entity(model, ifc_class="IfcWall", name="TestWall2") + storey = model.by_type("IfcBuildingStorey")[0] + result = run_api( + model, + "spatial", + "assign_container", + {"products": str(wall.id()), "relating_structure": str(storey.id())}, + ) + assert result["ok"] is True + assert result["result"]["type"] == "IfcRelContainedInSpatialStructure" + + def test_add_pset(self, model): + wall = model.by_type("IfcWall")[0] + result = run_api(model, "pset", "add_pset", {"product": str(wall.id()), "name": "Pset_WallCommon"}) + assert result["ok"] is True + assert result["result"]["type"] == "IfcPropertySet" + + def test_unknown_function(self, model): + result = run_api(model, "root", "nonexistent", {}) + assert result["ok"] is False + assert "Cannot find" in result["error"] + + def test_unknown_parameter(self, model): + result = run_api(model, "root", "create_entity", {"bogus_param": "value"}) + assert result["ok"] is False + assert "Unknown parameter" in result["error"] + + def test_bad_entity_reference(self, model): + result = run_api(model, "pset", "add_pset", {"product": "999999", "name": "Pset_WallCommon"}) + assert result["ok"] is False + assert "not found" in result["error"] + + +class TestAppendAsset: + def test_append_asset_from_library(self, model, library_file): + lib = ifcopenshell.open(library_file) + wall_type = lib.by_type("IfcWallType")[0] + result = run_api( + model, + "project", + "append_asset", + {"library": library_file, "element": str(wall_type.id())}, + ) + assert result["ok"] is True + assert result["result"]["type"] == "IfcWallType" + assert model.by_type("IfcWallType"), "wall type should have been appended to the model" + + +class TestSerializeResult: + def test_none(self): + assert serialize_result(None) is None + + def test_string(self): + assert serialize_result("hello") == "hello" + + def test_int(self): + assert serialize_result(42) == 42 + + def test_entity(self, model): + wall = model.by_type("IfcWall")[0] + result = serialize_result(wall) + assert result["id"] == wall.id() + assert result["type"] == "IfcWall" + assert result["name"] == "Wall001" + + def test_list(self, model): + walls = model.by_type("IfcWall") + result = serialize_result(walls) + assert isinstance(result, list) + assert all(isinstance(r, dict) for r in result) + + def test_dict(self): + result = serialize_result({"key": "value"}) + assert result == {"key": "value"}