mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-09 09:21:46 +00:00
Add validate, schedule, cost, schema, quantify to ifcquery/ifcedit/ifcmcp
ifcquery: validate [--rules], schedule [--depth N], cost [--depth N],
schema <EntityType>. ifcedit: quantify list/run subcommands using ifc5d
QTO rules. schedule and cost support max_depth to limit tree expansion,
replacing truncated levels with {truncated, count}. ifcmcp gains matching
session methods, @server.tool() decorators, and OpenAI tool schemas.
Generated with the assistance of an AI coding tool.
This commit is contained in:
@@ -26,6 +26,7 @@ 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
|
||||
|
||||
|
||||
@@ -137,6 +138,29 @@ def _parse_extra_args(extra: list[str]) -> dict[str, str]:
|
||||
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",
|
||||
@@ -167,6 +191,16 @@ def main():
|
||||
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":
|
||||
@@ -175,6 +209,8 @@ def main():
|
||||
cmd_docs(args)
|
||||
elif args.command == "run":
|
||||
cmd_run(args, extra)
|
||||
elif args.command == "quantify":
|
||||
cmd_quantify(args, extra)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -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)}
|
||||
@@ -15,7 +15,7 @@ classifiers = [
|
||||
"Programming Language :: Python :: 3",
|
||||
"License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)",
|
||||
]
|
||||
dependencies = ["ifcopenshell"]
|
||||
dependencies = ["ifcopenshell", "ifc5d"]
|
||||
|
||||
[project.scripts]
|
||||
ifcedit = "ifcedit.__main__:main"
|
||||
|
||||
@@ -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
|
||||
@@ -8,9 +8,12 @@ from typing import Any, Callable
|
||||
import ifcopenshell
|
||||
|
||||
from ifcedit.discover import function_docs, list_functions, list_modules
|
||||
from ifcedit.quantify import run_quantify
|
||||
from ifcedit.run import run_api
|
||||
from ifcquery import clash as clash_mod
|
||||
from ifcquery import info, relations, select, summary, tree
|
||||
from ifcquery import cost as cost_mod
|
||||
from ifcquery import info, relations, schedule, schema, select, summary, tree
|
||||
from ifcquery import validate as validate_mod
|
||||
|
||||
|
||||
# inside ifcmcp/core.py
|
||||
@@ -181,6 +184,41 @@ class IfcSession:
|
||||
res = run_api(model, module, function, raw_kwargs)
|
||||
return _jsonify(res)
|
||||
|
||||
# ------------------------
|
||||
# Extended query + edit tools
|
||||
# ------------------------
|
||||
def ifc_validate(self, express_rules: bool = False) -> dict[str, Any]:
|
||||
"""Validate the loaded model. Returns {'valid': bool, 'issues': [...]}."""
|
||||
return validate_mod.validate(self._require_model(), express_rules=express_rules)
|
||||
|
||||
def ifc_schedule(self, max_depth: int | None = None) -> list[dict[str, Any]]:
|
||||
"""List work schedules and nested tasks from the model.
|
||||
|
||||
max_depth limits subtask expansion (None = unlimited). At the cutoff,
|
||||
subtasks is replaced with {"truncated": True, "count": N}.
|
||||
"""
|
||||
return schedule.schedule(self._require_model(), max_depth=max_depth)
|
||||
|
||||
def ifc_cost(self, max_depth: int | None = None) -> list[dict[str, Any]]:
|
||||
"""List cost schedules and nested cost items from the model.
|
||||
|
||||
max_depth limits cost item expansion (None = unlimited). At the cutoff,
|
||||
subitems is replaced with {"truncated": True, "count": N}.
|
||||
"""
|
||||
return cost_mod.cost(self._require_model(), max_depth=max_depth)
|
||||
|
||||
def ifc_schema(self, entity_type: str) -> dict[str, Any]:
|
||||
"""Return IFC class documentation for entity_type using the model's schema version."""
|
||||
return schema.schema(self._require_model(), entity_type)
|
||||
|
||||
def ifc_quantify(self, rule: str, selector: str = "") -> dict[str, Any]:
|
||||
"""Run quantity take-off on the model using the named rule.
|
||||
|
||||
Modifies the model in-place; call ifc_save() after.
|
||||
"""
|
||||
model = self._require_model()
|
||||
return run_quantify(model, rule, selector=selector if selector else None)
|
||||
|
||||
# ------------------------
|
||||
# Generic dispatcher + tool specs for LLMs
|
||||
# ------------------------
|
||||
@@ -300,4 +338,62 @@ class IfcSession:
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "ifc_validate",
|
||||
"description": "Validate the loaded model. Returns valid bool and list of issues.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"express_rules": {"type": "boolean", "description": "Also check EXPRESS rules (slower)"}},
|
||||
"required": [],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "ifc_schedule",
|
||||
"description": "List work schedules and nested tasks. Use max_depth=1 for top-level phases only on large projects.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"max_depth": {"type": "integer", "description": "Max levels of subtask expansion (omit for unlimited)"}},
|
||||
"required": [],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "ifc_cost",
|
||||
"description": "List cost schedules and nested cost items. Use max_depth=1 for top-level sections only on large BoQs.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"max_depth": {"type": "integer", "description": "Max levels of cost item expansion (omit for unlimited)"}},
|
||||
"required": [],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "ifc_schema",
|
||||
"description": "Return IFC class documentation for an entity type.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"entity_type": {"type": "string", "description": "IFC entity type, e.g. IfcWall"}},
|
||||
"required": ["entity_type"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "ifc_quantify",
|
||||
"description": "Run quantity take-off (QTO) on the model. Modifies model in-place; call ifc_save() after.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"rule": {"type": "string", "description": "QTO rule name, e.g. IFC4QtoBaseQuantities"},
|
||||
"selector": {"type": "string", "description": "ifcopenshell selector to restrict elements (default: all IfcElement)"},
|
||||
},
|
||||
"required": ["rule"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -95,4 +95,25 @@ def build_server() -> Any:
|
||||
def ifc_edit(function_path: str, params: str = "{}") -> dict:
|
||||
return session.ifc_edit(function_path=function_path, params=params)
|
||||
|
||||
# ---- Extended query + edit ----
|
||||
@server.tool()
|
||||
def ifc_validate(express_rules: bool = False) -> dict[str, Any]:
|
||||
return session.ifc_validate(express_rules=express_rules)
|
||||
|
||||
@server.tool()
|
||||
def ifc_schedule(max_depth: int | None = None) -> list[dict[str, Any]]:
|
||||
return session.ifc_schedule(max_depth=max_depth)
|
||||
|
||||
@server.tool()
|
||||
def ifc_cost(max_depth: int | None = None) -> list[dict[str, Any]]:
|
||||
return session.ifc_cost(max_depth=max_depth)
|
||||
|
||||
@server.tool()
|
||||
def ifc_schema(entity_type: str) -> dict[str, Any]:
|
||||
return session.ifc_schema(entity_type=entity_type)
|
||||
|
||||
@server.tool()
|
||||
def ifc_quantify(rule: str, selector: str = "") -> dict[str, Any]:
|
||||
return session.ifc_quantify(rule=rule, selector=selector)
|
||||
|
||||
return server
|
||||
@@ -26,7 +26,9 @@ import sys
|
||||
import ifcopenshell
|
||||
|
||||
from ifcquery import clash as clash_mod
|
||||
from ifcquery import info, relations, select, summary, tree
|
||||
from ifcquery import cost as cost_mod
|
||||
from ifcquery import info, relations, schedule, schema, select, summary, tree
|
||||
from ifcquery import validate as validate_mod
|
||||
|
||||
|
||||
def parse_element_id(raw: str) -> int:
|
||||
@@ -103,6 +105,24 @@ def main():
|
||||
"--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)"
|
||||
)
|
||||
|
||||
schema_parser = subparsers.add_parser("schema", help="IFC class documentation")
|
||||
schema_parser.add_argument("entity_type", help="IFC entity type (e.g. IfcWall)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
@@ -159,6 +179,14 @@ def main():
|
||||
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 == "schema":
|
||||
result = schema.schema(model, args.entity_type)
|
||||
|
||||
print(format_output(result, args.output_format))
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
@@ -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"] == []
|
||||
@@ -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"] == []
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user