Compare commits

..

20 Commits

Author SHA1 Message Date
Thomas Krijnen e42acdfa86 ifcquery plot subcommand 2026-03-01 21:01:09 +01:00
Bruno Postle 1801a768c1 Fix clipping when generating a wall 2026-03-01 17:07:50 +00:00
Bruno Postle 66f0b71c65 Add ifcmcp ifc_render tool and ifcquery render subcommand
Renders IFC model geometry to a PNG image using pyvista (off_screen).

ifcquery CLI:
  ifcquery <file> render [-o out.png] [--selector QUERY]
                         [--element ID[,ID...]] [--view iso|top|south|north|east|west]

ifcmcp MCP tool:
  ifc_render(selector, element_ids, view) -> list[ImageContent]
  Returns base64-encoded PNG as an MCP ImageContent block so the agent
  can inspect the model geometry inline.

Generated with the assistance of an AI coding tool.
2026-03-01 13:23:00 +00:00
Bruno Postle f4d7fda255 Support file paths in ifcedit project.append_asset
This now works:

  ifcedit run model.ifc project.append_asset --library library.ifc --element 5711

Generated with the assistance of an AI coding tool.
2026-02-28 07:19:51 +00:00
Bruno Postle c112c115ce Update README files
Generated with the assistance of an AI coding tool.
2026-02-23 23:30:07 +00:00
Bruno Postle 2a2ba45676 Add new ifcmcp tools to ifcchat tool schema
Adds ifc_validate, ifc_schedule, ifc_cost, ifc_schema, and ifc_quantify
to the hardcoded tool list in app.js to match the extended ifcmcp session.

Generated with the assistance of an AI coding tool.
2026-02-23 23:19:06 +00:00
Bruno Postle fd69c0534b 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.
2026-02-23 23:11:35 +00:00
Thomas Krijnen 1226370b9e re-add AI generation disclosure 2026-02-21 09:37:29 +01:00
Thomas Krijnen 6e3eaa9b39 mv chat/ ifcchat 2026-02-21 09:34:42 +01:00
Bruno Postle 3ed2da23be Create missing CLI tools
Actually create the ifcquery, ifcedit and ifcmcp executables on pip
install instead of relying on `python3 -m ifcquery` style usage
2026-02-20 22:22:29 +00:00
Bruno Postle 249c498112 Add AGENTS.md contributor guide
Guidelines for external contributors using AI coding tools,
covering licensing, AI disclosure requirements, PR scope,
commit style, code formatting, and testing expectations.

Generated with the assistance of an AI coding tool.
2026-02-20 21:43:22 +00:00
Thomas Krijnen 2dde62803d Package ifcmcp as a HTML client-side chat app (#7697) 2026-02-20 20:54:16 +00:00
Bruno Postle d4ac3c36ce Add AI generation disclosure 2026-02-14 08:10:55 +00:00
Bruno Postle fecd4a810a black and ruff 2026-02-11 18:22:45 +00:00
Bruno Postle 060afb94b5 MCP wrapper for ifcquery and ifcedit
Model Context Protocol server implementation for AI agent access to IFC
projects
2026-02-10 22:32:43 +00:00
Bruno Postle b1f1de954d Rename ifcapi to ifcedit, README and black 2026-02-09 22:59:11 +00:00
Bruno Postle 12ed2d1ec3 Add ifcquery clash subcommand for geometric clash detection
Uses ifcopenshell.geom.tree API directly to check an element for
intersections and clearance violations against sibling elements
(--scope storey) or the entire model (--scope all).
2026-02-09 18:20:41 +00:00
Bruno Postle c77983ffe5 Add ifcquery relations subcommand for IFC relationship traversal
New subcommand: ifcquery <file> relations <element_id>
Returns all relationships for an element organised by category:
hierarchy (parent, container, aggregate, nest), children (contained,
parts, components, openings), type relationships, groups, systems,
zones, material, referenced structures, and connections/ports.
Empty categories are omitted from output.

Optional --traverse up flag walks the hierarchy from the element
up to IfcProject, returning the chain as a list.
2026-02-09 07:20:37 +00:00
Bruno Postle 78f26c2432 Add ifcapi CLI wrapper for ifcopenshell.api editor functions
Runtime introspection auto-discovers all API functions. Subcommands:
list (modules/functions), docs (parameter documentation), run (execute
with type-coerced CLI arguments). Output is JSON to stdout, supports
--dry-run validation and -o alternate output path.
2026-02-09 06:27:00 +00:00
Bruno Postle 39161e7256 Add ifcquery CLI tool for IFC model interrogation
Provides four subcommands for querying IFC models with JSON output:
- summary: schema, entity counts, project info
- tree: spatial hierarchy (Project > Site > Building > Storey > elements)
- info: deep element inspection (attributes, psets, type, material, container)
- select: filter elements using selector syntax
2026-02-08 23:24:48 +00:00
73 changed files with 8050 additions and 945 deletions
@@ -211,21 +211,22 @@ class PolylineOperator:
context.workspace.status_text_set(draw_instructions)
def handle_lock_axis(self, context: bpy.types.Context, event: bpy.types.Event) -> None:
angle_snap = tool.Snap.get_angle_snap_value(context)
if event.value == "PRESS" and event.type == "A":
self.tool_state.lock_axis = False if self.tool_state.lock_axis else True
if self.tool_state.lock_axis:
self.tool_state.snap_angle = self.input_ui.get_number_value("WORLD_ANGLE")
self.tool_state.snap_angle = round(self.tool_state.snap_angle / angle_snap) * angle_snap
# Round to the closest 5
self.tool_state.snap_angle = round(self.tool_state.snap_angle / 5) * 5
if event.shift and event.type in {"WHEELUPMOUSE", "WHEELDOWNMOUSE"}:
self.tool_state.lock_axis = True
self.tool_state.snap_angle = self.input_ui.get_number_value("WORLD_ANGLE")
self.tool_state.snap_angle = round(self.tool_state.snap_angle / angle_snap) * angle_snap
# Round to the closest 5
self.tool_state.snap_angle = round(self.tool_state.snap_angle / 5) * 5
if event.type in {"WHEELUPMOUSE"}:
self.tool_state.snap_angle += angle_snap
self.tool_state.snap_angle += 5
else:
self.tool_state.snap_angle -= angle_snap
self.tool_state.snap_angle -= 5
self.handle_mouse_move(context, event)
detected_snaps = tool.Snap.detect_snapping_points(context, event, self.objs_2d_bbox, self.tool_state)
self.snapping_points = tool.Snap.select_snapping_points(context, event, self.tool_state, detected_snaps)
@@ -17,7 +17,7 @@
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import copy
from math import atan2, degrees, pi, radians
from math import atan2, degrees, pi
from typing import Any, Literal, Optional, Union
import bmesh
@@ -195,8 +195,8 @@ class DumbProfileGenerator:
if should_round:
# Round to nearest 50mm (yes, metric for now)
self.length = 0.05 * round(length / 0.05)
angle_snap = tool.Snap.get_angle_snap_value(bpy.context)
nearest_degree = radians(angle_snap)
# Round to nearest 5 degrees
nearest_degree = (pi / 180) * 5
self.rotation = nearest_degree * round(self.rotation / nearest_degree)
self.location = coords[0]
data["obj"] = self.create_profile()
+2 -2
View File
@@ -916,8 +916,8 @@ class DumbWallGenerator:
if should_round:
# Round to nearest 50mm (yes, metric for now)
self.length = 0.05 * round(length / 0.05)
angle_snap = tool.Snap.get_angle_snap_value(bpy.context)
nearest_degree = math.radians(angle_snap)
# Round to nearest 5 degrees
nearest_degree = (math.pi / 180) * 5
self.rotation = nearest_degree * round(self.rotation / nearest_degree)
self.location = coords[0]
data["obj"] = self.create_wall()
+1 -2
View File
@@ -191,8 +191,7 @@ class Polyline(bonsai.core.tool.Polyline):
orientation_angle = 0
if input_ui:
if should_round:
angle_snap = tool.Snap.get_angle_snap_value(context)
angle = angle_snap * round(angle / angle_snap) if distance < angle_round_threshold else angle
angle = 5 * round(angle / 5) if distance < angle_round_threshold else angle
factor = tool.Snap.get_increment_snap_value(context)
distance = factor * round(distance / factor)
input_ui.set_value("X", mouse_vector.x)
-13
View File
@@ -114,19 +114,6 @@ class Snap(bonsai.core.tool.Snap):
return increment
@classmethod
def get_angle_snap_value(cls, context: bpy.types.Context) -> float:
"""Get the angle snap increment from Blender's tool settings.
Uses snap_angle_increment_3d (Blender 5.0+) or snap_angle_increment (Blender 4.x).
:param context: Blender context
:return: Angle snap increment in degrees
"""
if bpy.app.version >= (5, 0, 0):
return math.degrees(context.scene.tool_settings.snap_angle_increment_3d)
return math.degrees(context.scene.tool_settings.snap_angle_increment)
@classmethod
def get_snap_points_on_raycasted_face(cls, context, event, obj, face_index):
matrix = obj.matrix_world.copy()
+21
View File
@@ -0,0 +1,21 @@
IfcOpenShell AI Assistant
=========================
A web-based client-side (pyodide + OpenAI API) model interrogation and generation API based on: ifcedit, ifcquery and ifcmcp packaged in a HTML+JS application.
### Setup instructions
```
mkdir ./src/chat/dist
cd ./src/ifcquery/
python -m build
cp ./dist/ifcquery-0.0.0-py3-none-any.whl ../chat/dist/
cd ../../src/ifcedit
python -m build
cp ./dist/ifcedit-0.0.0-py3-none-any.whl ../chat/dist/
cd ../../src/ifcmcp
python -m build
cp ./dist/ifcmcp-0.0.0-py3-none-any.whl ../chat/dist/
cd ../chat/dist/
wget https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl
```
+320
View File
@@ -0,0 +1,320 @@
// app.js
const $ = (id) => document.getElementById(id);
const statusEl = $("status");
const msgsEl = $("msgs");
const sendBtn = $("send");
const inputEl = $("input");
const apiKeyEl = $("apiKey");
const modelEl = $("model");
const ifcFileEl = $("ifcFile");
const newBtn = $("newModel");
const downloadBtn = $("downloadIfc");
function setBusy(isBusy, reason = "") {
const controls = [
$("send"),
$("newModel"),
$("downloadIfc"),
$("ifcFile"),
];
for (const el of controls) el.disabled = isBusy;
$("input").disabled = isBusy;
const browseBtn = $("browseBtn");
if (browseBtn) {
browseBtn.classList.toggle("disabled", isBusy);
browseBtn.setAttribute("aria-disabled", isBusy ? "true" : "false");
browseBtn.tabIndex = isBusy ? -1 : 0;
}
setStatus(isBusy ? (reason || "Working…") : "Ready");
}
function addMessage(role, text) {
if (text.ok) {
text = text.data;
}
const wrap = document.createElement("div");
wrap.className = `msg ${role}`;
wrap.innerHTML = `
<div class="role">${role}</div>
<div class="bubble"></div>`;
const bubble = wrap.querySelector(".bubble");
bubble.textContent = text;
bubble.onclick = function () {
if (bubble.scrollHeight > 100 && role === "tool") {
bubble.style.maxHeight = bubble.style.maxHeight == 'none' ? '' : 'none';
bubble.style.borderBottom = bubble.style.borderBottom == '' ? 'dotted 2px gray' : '';
}
}
msgsEl.appendChild(wrap);
msgsEl.scrollTop = msgsEl.scrollHeight;
}
function setStatus(text) {
statusEl.textContent = text;
}
const worker = new Worker("./ifc_worker.js", { type: "module" });
function callWorker(type, payload = {}) {
return new Promise((resolve, reject) => {
const id = crypto.randomUUID();
const onMsg = (ev) => {
const msg = ev.data;
if (!msg || msg.id !== id) return;
worker.removeEventListener("message", onMsg);
if (msg.ok) resolve(msg);
else reject(new Error(msg.error || "Worker error"));
};
worker.addEventListener("message", onMsg);
worker.postMessage({ id, type, payload });
});
}
// ---- OpenAI Responses API tool schemas (should match ifcmcp.core openai_tools()) ----
// Docs show Responses API function_call items + function_call_output loop. :contentReference[oaicite:4]{index=4}
const tools = [
{
type: "function", name: "ifc_new", description: "Create a new empty IFC model in memory.",
parameters: { type: "object", properties: { schema: { type: "string" } }, required: [], additionalProperties: false }
},
{
type: "function", name: "ifc_summary", description: "Get a concise overview of the loaded IFC model.",
parameters: { type: "object", properties: {}, required: [], additionalProperties: false }
},
{
type: "function", name: "ifc_tree", description: "Get the full spatial hierarchy tree.",
parameters: { type: "object", properties: {}, required: [], additionalProperties: false }
},
{
type: "function", name: "ifc_select", description: "Select elements using ifcopenshell selector syntax (e.g. 'IfcWall').",
parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"], additionalProperties: false }
},
{
type: "function", name: "ifc_info", description: "Inspect an entity by STEP id.",
parameters: { type: "object", properties: { element_id: { type: "integer" } }, required: ["element_id"], additionalProperties: false }
},
{
type: "function", name: "ifc_relations", description: "Get relationships for an element. traverse='up' walks to IfcProject.",
parameters: {
type: "object", properties: { element_id: { type: "integer" }, traverse: { type: "string" } },
required: ["element_id"], additionalProperties: false
}
},
{
type: "function", name: "ifc_clash", description: "Run clash/clearance checks for an element.",
parameters: {
type: "object", properties: { element_id: { type: "integer" }, clearance: { type: "number" }, tolerance: { type: "number" }, scope: { type: "string" } },
required: ["element_id"], additionalProperties: false
}
},
{
type: "function", name: "ifc_list", description: "List ifcopenshell.api modules or functions within a module.",
parameters: { type: "object", properties: { module: { type: "string" } }, required: [], additionalProperties: false }
},
{
type: "function", name: "ifc_docs", description: "Get documentation for an ifcopenshell.api function, 'module.function'.",
parameters: { type: "object", properties: { function_path: { type: "string" } }, required: ["function_path"], additionalProperties: false }
},
{
type: "function", name: "ifc_edit", description: "Execute an ifcopenshell.api mutation; params is a JSON string of stringly-typed kwargs.",
parameters: { type: "object", properties: { function_path: { type: "string" }, params: { type: "string" } }, required: ["function_path"], 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" } }, 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" } }, 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" } }, 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" } }, 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" }, selector: { type: "string" } }, required: ["rule"], additionalProperties: false }
},
];
const SYSTEM_INSTRUCTIONS = `
You are an IFC copilot running in a browser. You can call tools to inspect or modify the currently loaded IFC model.
Rules:
- If the user asks about model contents (counts, lists, properties, hierarchy), use tools like ifc_summary/ifc_select/ifc_info/ifc_tree.
- If the user asks to change the model, prefer: (1) ifc_list to find candidate API modules, (2) ifc_docs for the exact function signature, then (3) ifc_edit.
- If there is no model and the user wants to create one, call ifc_new.
- After edits, explain what changed and suggest downloading the IFC.
Be concise. Avoid dumping huge trees unless asked.
`;
let inputItems = []; // running conversation state (Responses API style)
async function openAIResponsesCreate({ apiKey, model, input, tools }) {
const res = await fetch("https://api.openai.com/v1/responses", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${apiKey}`,
},
body: JSON.stringify({
model,
instructions: SYSTEM_INSTRUCTIONS,
tools,
input,
}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`OpenAI error ${res.status}: ${text}`);
}
return await res.json();
}
function extractAssistantText(response) {
const out = [];
for (const item of response.output ?? []) {
if (item.type === "message" && item.role === "assistant") {
for (const c of item.content ?? []) {
if (c.type === "output_text") out.push(c.text);
}
}
}
return out.join("\n").trim();
}
async function runAgentTurn(userText) {
const apiKey = apiKeyEl.value.trim();
if (!apiKey) throw new Error("Missing API key");
// Add user message
inputItems.push({ role: "user", content: userText });
// Tool-calling loop (Responses API): append response.output, execute function_call items, append function_call_output.
for (let i = 0; i < 64; i++) {
const response = await openAIResponsesCreate({
apiKey,
model: modelEl.value,
input: inputItems,
tools,
});
// Keep ALL output items (incl reasoning/tool calls) in the running state.
inputItems.push(...(response.output ?? []));
// Show any assistant text immediately
const text = extractAssistantText(response);
if (text) addMessage("assistant", text);
const calls = (response.output ?? []).filter((x) => x.type === "function_call");
if (calls.length === 0) return;
for (const call of calls) {
let args = {};
try { args = call.arguments ? JSON.parse(call.arguments) : {}; }
catch { args = {}; }
addMessage("tool", `${call.name}(${JSON.stringify(args)})`);
const toolRes = await callWorker("toolCall", { name: call.name, args });
// Feed tool result back to the model
inputItems.push({
type: "function_call_output",
call_id: call.call_id,
output: JSON.stringify(toolRes.result),
});
addMessage("tool", `${call.name}: ${JSON.stringify(toolRes.result, null, 2)}`);
}
}
addMessage("assistant", "I hit the tool-call loop limit. Try narrowing your request.");
}
sendBtn.onclick = async () => {
const text = inputEl.value.trim();
if (!text) return;
inputEl.value = "";
addMessage("user", text);
try {
setBusy(true, "Thinking…");
await runAgentTurn(text);
setBusy(false, "Ready");
} catch (e) {
setBusy(true, "Error");
addMessage("assistant", `Error: ${e.message}`);
}
};
inputEl.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendBtn.click();
}
});
ifcFileEl.onchange = async () => {
const f = ifcFileEl.files?.[0];
if (!f) return;
setBusy(true, "Loading IFC into Pyodide…");
const buf = await f.arrayBuffer();
try {
const r = await callWorker("loadIfc", { filename: f.name, bytes: buf }, [buf]);
addMessage("assistant", r.result);
setBusy(false, "Ready");
} catch (e) {
setStatus(true, "Error");
addMessage("assistant", `Load error: ${e.message}`);
}
};
newBtn.onclick = async () => {
try {
setBusy(true, "Creating new model…");
const r = await callWorker("toolCall", { name: "ifc_new", args: { schema: "IFC4" } });
addMessage("assistant", `New model: ${JSON.stringify(r.result)}`);
setBusy(false, "Ready");
} catch (e) {
setBusy(true, "Error");
addMessage("assistant", `Error: ${e.message}`);
}
};
downloadBtn.onclick = async () => {
try {
setBusy(true, "Exporting IFC…");
const r = await callWorker("exportIfc", {});
const blob = new Blob([r.bytes], { type: "application/octet-stream" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = r.filename || "model.ifc";
a.click();
URL.revokeObjectURL(url);
setBusy(false, "Ready");
} catch (e) {
setBusy(true, "Error");
addMessage("assistant", `Export error: ${e.message}`);
}
};
(async () => {
try {
setBusy(true, "Initializing Pyodide and IfcOpenShell for in-memory IFC access…");
await callWorker("init", {});
setBusy(false, "Ready");
} catch (e) {
setBusy(true, "Error");
addMessage("assistant", `Worker init failed: ${e.message}`);
}
})();
+108
View File
@@ -0,0 +1,108 @@
// ifc_worker.js (MODULE WORKER)
import { loadPyodide } from "https://cdn.jsdelivr.net/pyodide/v0.29.3/full/pyodide.mjs";
let pyodide = null;
let callToolPy = null;
let initPromise = null;
function ok(id, extra = {}, transfer = []) {
self.postMessage({ id, ok: true, ...extra }, transfer);
}
function fail(id, error) {
self.postMessage({ id, ok: false, error: String(error?.message || error) });
}
async function ensurePyodide() {
if (initPromise) return initPromise;
initPromise = (async () => {
// Passing indexURL avoids some environments failing to infer it from the module URL. :contentReference[oaicite:3]{index=3}
pyodide = await loadPyodide({
indexURL: "https://cdn.jsdelivr.net/pyodide/v0.29.3/full/",
});
await pyodide.loadPackage("micropip");
await pyodide.loadPackage("numpy");
await pyodide.loadPackage("shapely");
await pyodide.loadPackage("typing-extensions");
const micropip = pyodide.pyimport("micropip");
// Detect python minor version (3.12 vs 3.13) and pick a matching wheel.
const pyVer = pyodide.runPython(`
import sys
f"{sys.version_info.major}.{sys.version_info.minor}"
`);
const wheelUrl =
pyVer === "3.13"
? "https://ifcopenshell.github.io/wasm-wheels/ifcopenshell-0.8.3+34a1bc6-cp313-cp313-emscripten_4_0_9_wasm32.whl"
: "https://ifcopenshell.github.io/wasm-wheels/ifcopenshell-0.8.2+d50e806-cp312-cp312-emscripten_3_1_58_wasm32.whl";
await micropip.install(wheelUrl);
await micropip.install([
"./dist/ifcquery-0.0.0-py3-none-any.whl",
"./dist/ifcedit-0.0.0-py3-none-any.whl",
"./dist/ifcmcp-0.0.0-py3-none-any.whl",
"./dist/lark-1.3.1-py3-none-any.whl",
])
await pyodide.runPythonAsync(`
from ifcmcp.embedded import call_tool as _call_tool
`);
callToolPy = pyodide.globals.get("_call_tool");
})();
return initPromise;
}
function callTool(name, args) {
const pyArgs = pyodide.toPy(args);
const res = callToolPy(name, pyArgs);
pyArgs.destroy();
const resJs = res.toJs({ dict_converter: Object.fromEntries });
res.destroy();
return resJs;
}
self.onmessage = async (ev) => {
const { id, type, payload } = ev.data || {};
try {
if (type === "init") {
await ensurePyodide();
ok(id, { result: "ok" });
return;
}
await ensurePyodide();
if (type === "loadIfc") {
const { filename, bytes } = payload;
const path = `/tmp/${filename || "model.ifc"}`;
pyodide.FS.mkdirTree("/tmp");
pyodide.FS.writeFile(path, new Uint8Array(bytes));
const result = callTool("ifc_load", { path });
ok(id, { result });
return;
}
if (type === "exportIfc") {
const path = "/tmp/export.ifc";
const result = callTool("ifc_save", { path });
const data = pyodide.FS.readFile(path);
ok(id, { result, filename: "export.ifc", bytes: data }, [data.buffer]);
return;
}
if (type === "toolCall") {
const { name, args } = payload;
const result = callTool(name, args || {});
ok(id, { result });
return;
}
throw new Error(`Unknown message type: ${type}`);
} catch (e) {
fail(id, e);
}
};
+294
View File
@@ -0,0 +1,294 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>IfcOpenShell AI Assistant</title>
<style>
* {
box-sizing: border-box;
}
body {
font-family: system-ui, sans-serif;
margin: 0;
}
button,
input,
select,
textarea {
font: inherit;
}
header {
padding: 12px 16px;
border-bottom: 1px solid #ddd;
}
header input,
header select {
padding: 8px;
}
main {
display: grid;
grid-template-columns: 320px 1fr;
height: calc(100vh - 84px);
}
.side {
border-right: 1px solid #ddd;
padding: 12px;
overflow: auto;
}
.chat {
display: flex;
flex-direction: column;
height: 100%;
}
.msgs {
flex: 1;
overflow: auto;
padding: 16px;
}
.msg {
margin: 10px 0;
}
.msg .role {
font-size: 12px;
opacity: 0.7;
margin-bottom: 4px;
}
.msg .bubble {
padding: 10px 12px;
border-radius: 10px;
white-space: pre-wrap;
}
.msg.user .bubble {
background: #e8f0ff;
align-self: flex-end;
}
.msg.assistant .bubble {
background: #f4f4f4;
}
.msg.tool .bubble {
background: #fff6db;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
}
.composer {
display: flex;
gap: 8px;
padding: 12px;
justify-content: center;
}
.composer textarea {
flex: 1;
resize: none;
height: 88px;
border: none;
}
.composer button {
align-self: center;
}
.composer .inner {
border: solid 1px #ddd;
border-radius: 20px;
padding: 10px;
display: flex;
width: 50%;
}
.status {
font-size: 12px;
opacity: 0.7;
}
section>.row>label {
display: block;
font-size: 12px;
opacity: 0.75;
margin-bottom: 6px;
}
.side .row {
margin-bottom: 10px;
}
.row button {
padding: 8px 10px;
}
#model {
background: white;
color: gray;
border: solid 1px #eee;
border-radius: 6px;
}
hr {
border: dashed 1px #ddd;
}
.btn-row {
display: flex;
gap: 10px;
}
.btn-row>.btn,
.btn-row>button.btn {
flex: 1 1 0;
min-width: 0;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 14px;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid #d0d0d0;
background: #eee;
cursor: pointer;
user-select: none;
text-decoration: none;
}
.btn:hover {
background: #ddd;
}
.btn:active {
transform: translateY(1px);
}
.btn-wide {
width: 100%;
}
.material-icons {
font-size: 18px;
line-height: 1;
}
/* Disabled state for label-button + normal buttons */
.btn.disabled,
.btn:disabled {
opacity: 0.55;
cursor: not-allowed;
pointer-events: none;
}
.msg.tool .bubble {
max-height: 100px;
overflow: hidden;
}
#input {
border: none;
outline: none;
}
#input:focus,
#input:focus-visible {
outline: none;
box-shadow: none;
}
</style>
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
</head>
<body>
<header>
<div class="row">
<strong>IfcOpenShell AI Assistant</strong>
<select id="model">
<option value="gpt-5">gpt-5</option>
<option value="gpt-4.1">gpt-4.1</option>
</select>
</div>
<div class="row">
<span class="status" id="status">Booting…</span>
</div>
</header>
<main>
<section class="side">
<div class="row">
<label>OpenAI API key (stored only in memory)</label>
<input id="apiKey" type="password" placeholder="sk-..." autocomplete="off" style="width: 100%;" />
</div>
<hr />
<div class="row">
<label>IFC model (stored only in memory)</label>
<div class="btn-row">
<label class="btn" id="browseBtn" for="ifcFile" role="button" tabindex="0">
<span class="material-icons">folder_open</span>
Browse
</label>
<button class="btn" id="newModel" type="button">
<span class="material-icons">add_box</span>
New IFC
</button>
</div>
<input id="ifcFile" type="file" accept=".ifc,.ifczip,.ifcxml,.zip" hidden />
</div>
<div class="row">
<button class="btn btn-wide" id="downloadIfc" type="button">
<span class="material-icons">download</span>
Download IFC
</button>
</div>
<hr />
<div class="row">
<label>Tips</label>
<div class="status">
• Upload an IFC, then ask “Summarize the model” or “List all IfcWalls”.<br />
• Try “Add a new site and building named X” (will use ifc_edit).
</div>
</div>
</section>
<section class="chat">
<div class="msgs" id="msgs"></div>
<div class="composer">
<div class="inner">
<textarea id="input" placeholder="Ask or instruct about the IFC model…"></textarea>
<button id="send" class="btn">Send <span class="material-icons">send</span></button>
</div>
</div>
</section>
</main>
<script type="module" src="./app.js"></script>
</body>
</html>
+248
View File
@@ -0,0 +1,248 @@
<!-- This file was generated with the assistance of an AI coding tool. -->
# 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 <command> [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 <path>` -- 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 <query>` -- ifcopenshell selector to restrict elements (default: all `IfcElement`)
- `-o, --output <path>` -- 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)
- **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.
+20
View File
@@ -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 <bruno@postle.net>
#
# 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 <http://www.gnu.org/licenses/>.
__version__ = version = "0.0.0"
+217
View File
@@ -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 <bruno@postle.net>
#
# 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 <http://www.gnu.org/licenses/>.
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()
+161
View File
@@ -0,0 +1,161 @@
# 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 <bruno@postle.net>
#
# 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 <http://www.gnu.org/licenses/>.
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 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 _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()]
+279
View File
@@ -0,0 +1,279 @@
# 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 <bruno@postle.net>
#
# 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 <http://www.gnu.org/licenses/>.
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 = [f for f in formatted if f != "None"][0]
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
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 stripped.startswith(":"):
current_lines.append(stripped)
elif stripped.startswith(":") 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 stripped.startswith(":") or stripped == "":
break
lines.append(stripped)
return re.sub(r"\s+", " ", " ".join(lines).strip())
+37
View File
@@ -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)}
+148
View File
@@ -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 <bruno@postle.net>
#
# 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 <http://www.gnu.org/licenses/>.
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
+33
View File
@@ -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"
+1
View File
@@ -0,0 +1 @@
# This file was generated with the assistance of an AI coding tool.
+63
View File
@@ -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.owner.settings
import ifcopenshell.api.project
import ifcopenshell.api.pset
import ifcopenshell.api.root
import ifcopenshell.api.spatial
import ifcopenshell.api.unit
import pytest
import ifcopenshell.api.material
@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)
+145
View File
@@ -0,0 +1,145 @@
# 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"}
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"
+104
View File
@@ -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
+100
View File
@@ -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
+87
View File
@@ -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
+97
View File
@@ -0,0 +1,97 @@
# This file was generated with the assistance of an AI coding tool.
import ifcopenshell
import ifcopenshell.api.pset
import ifcopenshell.api.project
import ifcopenshell.api.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"}
+5 -2
View File
@@ -12,8 +12,11 @@ ifcopenshell::geometry::Converter::Converter(std::unique_ptr<ifcopenshell::geome
settings_ = mapping_->settings();
}
ifcopenshell::geometry::Converter::~Converter() {
delete mapping_;
ifcopenshell::geometry::Converter::~Converter()
{
if (mapping_ != nullptr) {
delete mapping_;
}
}
namespace {
+35 -65
View File
@@ -82,79 +82,49 @@ bool OpenCascadeKernel::convert(const taxonomy::loft::ptr loft, TopoDS_Shape& re
}
if (non_polygonal) {
if (loft->children.size() < 2) {
Logger::Error("Not enough sections to loft");
return false;
}
if (loft->children.size() == 2) {
BRep_Builder BB;
TopoDS_Shell comp;
BB.MakeShell(comp);
std::vector<std::vector<TopoDS_Wire>> sections;
sections.reserve(loft->children.size());
TopoDS_Shape f0, f1;
// Convert all children to vectors of wires
for (const auto& child : loft->children) {
TopoDS_Shape shape;
if (!convert(std::static_pointer_cast<taxonomy::face>(child), shape)) {
return false;
}
if (shape.ShapeType() != TopAbs_FACE) {
return false;
}
// At least make sure to have outer wire consistent, but in reality
// this is probably not a concern given how to build up these faces
auto f = TopoDS::Face(shape);
if (child == loft->children.front()) {
f0 = f;
} else if (child == loft->children.back()) {
f1 = f;
}
auto outer = BRepTools::OuterWire(f);
sections.emplace_back();
sections.back().push_back(outer);
for (TopoDS_Iterator it(f); it.More(); it.Next()) {
if (outer != it.Value()) {
sections.back().push_back(TopoDS::Wire(it.Value()));
}
}
}
auto first_wire_count = sections.front().size();
for (auto& section : sections) {
if (section.size() != first_wire_count) {
Logger::Error("Inconsistent number of wires in sections");
TopoDS_Shape f0, f1;
if (!convert(std::static_pointer_cast<taxonomy::face>(loft->children.front()), f0) ||
!convert(std::static_pointer_cast<taxonomy::face>(loft->children.back()), f1))
{
return false;
}
}
BRep_Builder BB;
TopoDS_Shell comp;
BB.MakeShell(comp);
for (size_t i = 0; i < first_wire_count; ++i) {
// Rule=True uses linear interpolation.
// This is critical for preventing twists in roads/railings.
BRepOffsetAPI_ThruSections builder(false, true);
for (auto& ws : sections) {
builder.AddWire(ws[i]);
}
builder.Build();
if (!builder.IsDone()) {
if (f0.ShapeType() != TopAbs_FACE || f1.ShapeType() != TopAbs_FACE) {
return false;
}
for (TopExp_Explorer exp(builder.Shape(), TopAbs_FACE); exp.More(); exp.Next()) {
BB.Add(comp, exp.Current());
TopExp_Explorer exp1(f0, TopAbs_WIRE);
TopExp_Explorer exp2(f1, TopAbs_WIRE);
for (; exp1.More() && exp2.More(); exp1.Next(), exp2.Next()) {
const auto& w1 = TopoDS::Wire(exp1.Current());
const auto& w2 = TopoDS::Wire(exp2.Current());
BRepOffsetAPI_ThruSections builder;
builder.AddWire(w1);
builder.AddWire(w2);
builder.Build();
if (!builder.IsDone()) {
return false;
}
for (TopExp_Explorer exp(builder.Shape(), TopAbs_FACE); exp.More(); exp.Next()) {
BB.Add(comp, exp.Current());
}
}
BB.Add(comp, f0.Reversed());
BB.Add(comp, f1);
result = BRepBuilderAPI_MakeSolid(comp).Solid();
return true;
} else {
Logger::Error("Lofting more than two sections is not supported");
return false;
}
BB.Add(comp, f0.Reversed());
BB.Add(comp, f1);
result = BRepBuilderAPI_MakeSolid(comp).Solid();
return true;
}
TopTools_ListOfShape faces;
@@ -21,44 +21,7 @@
#define mapping POSTFIX_SCHEMA(mapping)
using namespace ifcopenshell::geometry;
#include <deque>
taxonomy::ptr mapping::map_impl(const IfcSchema::IfcObjectPlacement* inst) {
if (placement_rel_to_type_ || placement_rel_to_instance_) {
using QueueItem = std::pair<const IfcUtil::IfcBaseEntity*, int>;
std::deque<QueueItem> q = {{inst, 0}};
while (!q.empty()) {
auto [placement_entity, depth] = q.front();
q.pop_front();
auto placement = placement_entity->as<typename IfcSchema::IfcObjectPlacement>();
if (!placement) {
continue;
}
auto self_places = placement->PlacesObject();
for (auto iter = self_places->begin(); iter != self_places->end(); ++iter) {
if ((placement_rel_to_type_ && (*iter)->declaration().is(*placement_rel_to_type_)) ||
(placement_rel_to_instance_ && (*iter)->as<IfcUtil::IfcBaseEntity>() == placement_rel_to_instance_)) {
return taxonomy::make<taxonomy::matrix4>();
}
}
// Look for two levels deep, we want to know if we're at or *above* the
// element we're ignoring, but we don't want to traverse the entire model.
#ifdef SCHEMA_IfcObjectPlacement_HAS_ReferencedByPlacements
if (depth < 2) {
auto refs = placement->ReferencedByPlacements();
for (auto& ref : *refs) {
q.emplace_back(ref, depth + 1);
}
}
#else
Logger::Warning("Using --site-local-placement or --building-local-placement on IFC4.2 might have issues");
#endif
}
}
const IfcSchema::IfcObjectPlacement* relative_to = nullptr;
const IfcUtil::IfcBaseInterface* transform;
+294
View File
@@ -0,0 +1,294 @@
<!-- This file was generated with the assistance of an AI coding tool. -->
# ifcmcp
An MCP (Model Context Protocol) server that wraps `ifcquery` and `ifcedit`,
holding the IFC model in memory across tool calls for fast interactive editing
sessions.
## Installation
```bash
pip install ifcmcp
```
Requires `ifcopenshell`, `ifcquery`, `ifcedit`, and `mcp`.
## Running the server
```bash
python3 -m ifcmcp
```
This starts the server on stdio transport, suitable for use with Claude Code
or any MCP client.
### Claude Code configuration
Use the `claude mcp add` command:
```bash
claude mcp add --transport stdio ifc -- python3 -m ifcmcp
```
Or create a `.mcp.json` file in your project root:
```json
{
"mcpServers": {
"ifc": {
"type": "stdio",
"command": "python3",
"args": ["-m", "ifcmcp"]
}
}
}
```
After adding the server, restart Claude Code for the tools to become available.
Then load a model by asking Claude to use `ifc_load`:
```
load model.ifc using ifc_load
```
## Tools
### Session
#### ifc_load
Open an IFC file into memory.
```
ifc_load(path="/path/to/model.ifc")
-> "Loaded /path/to/model.ifc: schema IFC4, 1847 entities"
```
#### ifc_save
Write the in-memory model to disk. Empty path overwrites the original file.
```
ifc_save()
ifc_save(path="/path/to/output.ifc")
```
### Query tools
All query tools require a model to be loaded first via `ifc_load`.
#### ifc_summary
Model overview: schema, entity counts, project info.
```json
{
"schema": "IFC4",
"total_entities": 1847,
"project": {"id": 1, "name": "Office Building"},
"types": {"IfcWall": 42, "IfcSlab": 12, "IfcWindow": 36}
}
```
#### ifc_tree
Full spatial hierarchy from IfcProject down through sites, buildings, storeys,
and contained elements.
```json
{
"id": 1,
"type": "IfcProject",
"name": "Office Building",
"children": [
{
"id": 2,
"type": "IfcSite",
"children": [{"id": 3, "type": "IfcBuilding", "children": ["..."]}]
}
]
}
```
#### ifc_info
Deep inspection of an entity by step ID: attributes, property sets, type,
material, container, and 4x4 placement matrix.
```
ifc_info(element_id=10)
```
#### ifc_select
Filter elements using ifcopenshell selector syntax.
```
ifc_select(query="IfcWall")
ifc_select(query="IfcWindow")
```
Returns a sorted list of `{"id", "type", "name"}` references.
#### ifc_relations
Show all relationships for an element: hierarchy, children, type, groups,
systems, material, connections.
```
ifc_relations(element_id=10)
ifc_relations(element_id=10, traverse="up")
```
With `traverse="up"`, walks the hierarchy from element up to IfcProject.
#### ifc_clash
Check an element for geometric intersections and clearance violations.
```
ifc_clash(element_id=10)
ifc_clash(element_id=10, clearance=0.5, scope="all")
```
Parameters:
- `clearance` -- minimum clearance distance in meters (0.0 = no clearance check)
- `tolerance` -- intersection tolerance in meters (default: 0.002)
- `scope` -- `"storey"` or `"all"` (default: `"storey"`)
#### ifc_validate
Check the model for schema and constraint violations.
```
ifc_validate()
ifc_validate(express_rules=True)
```
Returns `{"valid": true, "issues": []}` or `{"valid": false, "issues": [{"level": "ERROR", "message": "..."}]}`.
#### ifc_schedule
List all work schedules and their nested task trees.
```
ifc_schedule()
ifc_schedule(max_depth=1) # top-level phases only
```
`max_depth` limits subtask expansion. At the cutoff, `subtasks` is replaced
with `{"truncated": true, "count": N}` so you know children exist without
fetching them all. Omit for unlimited depth.
#### ifc_cost
List all cost schedules and their nested cost item trees.
```
ifc_cost()
ifc_cost(max_depth=2) # top two levels of the BoQ
```
`max_depth` limits cost item expansion, same truncation convention as
`ifc_schedule`.
#### ifc_schema
Return IFC class documentation for any entity type, using the loaded model's
schema version.
```
ifc_schema(entity_type="IfcWall")
ifc_schema(entity_type="IfcBuildingStorey")
```
Returns description, predefined types, spec URL, and attribute descriptions.
Returns `{"error": "Unknown entity: Foo"}` for unrecognised types.
#### ifc_quantify
Run quantity take-off (QTO) on the loaded model using an `ifc5d` rule.
Computes physical measurements (volume, area, length, count, weight) and
writes them back as `IfcElementQuantity` property sets. Modifies the model
in-place -- call `ifc_save()` when done.
```
ifc_quantify(rule="IFC4QtoBaseQuantities")
ifc_quantify(rule="IFC4QtoBaseQuantities", selector="IfcWall")
```
Available rules: `IFC4QtoBaseQuantities`, `IFC4X3QtoBaseQuantities`.
`selector` is an optional ifcopenshell selector to restrict which elements
are quantified (default: all `IfcElement`).
Returns `{"ok": true, "rule": "...", "elements_quantified": 42}`.
### Edit discovery tools
#### ifc_list
List all API modules, or functions within a specific module.
```
ifc_list() # all modules
ifc_list(module="root") # functions in the root module
```
#### ifc_docs
Show full documentation for an API function including parameters, types,
defaults, and descriptions.
```
ifc_docs(function_path="root.create_entity")
```
### Edit execution
#### ifc_edit
Execute an `ifcopenshell.api` mutation function. Parameters are passed as a
JSON string with string values that get coerced by ifcedit's type system.
```
ifc_edit(
function_path="root.create_entity",
params='{"ifc_class": "IfcWall", "name": "My Wall"}'
)
```
Returns `{"ok": true, "result": ...}` or `{"ok": false, "error": "..."}`.
Does NOT auto-save -- call `ifc_save()` when ready to write changes to disk.
**Parameter coercion:**
| Type | JSON value | Python value |
|------|------------|--------------|
| `entity_instance` | `"42"` | resolved from model by step ID |
| `list[entity_instance]` | `"5,6,7"` | list of resolved entities |
| `dict` | `'{"key": "val"}'` | parsed JSON object |
| `bool` | `"true"` | `True` |
| `Optional[X]` | `"none"` | `None` |
## Typical workflow
1. **Load** a model: `ifc_load`
2. **Inspect** with query tools: `ifc_summary`, `ifc_tree`, `ifc_select`, `ifc_info`, `ifc_relations`
3. **Validate** if needed: `ifc_validate`
4. **Browse schedules / costs**: `ifc_schedule`, `ifc_cost` (use `max_depth=1` first on large projects)
5. **Look up IFC classes**: `ifc_schema`
6. **Find** the right API function: `ifc_list`, `ifc_docs`
7. **Edit** the model: `ifc_edit`
8. **Quantify** elements: `ifc_quantify` (writes QTO psets in-place)
9. **Verify** changes with query tools
10. **Save** when satisfied: `ifc_save`
The model stays in memory across all calls, so multi-step editing sessions
are fast -- no file I/O between operations.
## License
LGPLv3+ -- see the IfcOpenShell project license.
+20
View File
@@ -0,0 +1,20 @@
# This file was generated with the assistance of an AI coding tool.
# IfcMCP - MCP server for IFC building models
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
#
# This file is part of IfcMCP.
#
# IfcMCP 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.
#
# IfcMCP 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 IfcMCP. If not, see <http://www.gnu.org/licenses/>.
__version__ = version = "0.0.0"
+10
View File
@@ -0,0 +1,10 @@
# This file was generated with the assistance of an AI coding tool.
from ifcmcp.server import build_server
def main():
server = build_server()
server.run(transport="stdio")
if __name__ == "__main__":
main()
+447
View File
@@ -0,0 +1,447 @@
# This file was generated with the assistance of an AI coding tool.
from __future__ import annotations
import json
from dataclasses import dataclass
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 cost as cost_mod
from ifcquery import info, relations, render as render_mod, schedule, schema, select, summary, tree
from ifcquery import validate as validate_mod
# inside ifcmcp/core.py
import json
from typing import Any
def _jsonify(x: Any) -> Any:
"""Convert IfcOpenShell objects / iterables into JSON-safe primitives."""
if x is None or isinstance(x, (str, int, float, bool)):
return x
# IfcOpenShell entity instances: normalize
if isinstance(x, ifcopenshell.entity_instance):
return {
"id": int(x.id()),
"type": x.is_a(),
"repr": str(x),
"name": getattr(x, "Name", None),
}
if isinstance(x, dict):
return {str(k): _jsonify(v) for k, v in x.items()}
if isinstance(x, (list, tuple, set)):
return [_jsonify(v) for v in x]
# Try JSON as-is, else fallback to string
try:
json.dumps(x)
return x
except Exception:
return str(x)
class IfcSessionError(RuntimeError):
pass
@dataclass
class IfcSession:
"""In-memory IFC session (no FastMCP dependency).
Designed to work in:
- FastMCP server (single global session)
- Embedded runtimes like Pyodide (one session per browser tab/worker)
"""
model: ifcopenshell.file | None = None
model_path: str | None = None
# -----------------
# Session lifecycle
# -----------------
def _require_model(self) -> ifcopenshell.file:
if self.model is None:
raise IfcSessionError("No model loaded. Call ifc_load() or ifc_new() first.")
return self.model
def ifc_new(self, schema: str = "IFC4") -> dict[str, Any]:
"""Create a new empty IFC model in memory."""
self.model = ifcopenshell.file(schema=schema)
self.model_path = None
return {"ok": True, "schema": self.model.schema, "entities": sum(1 for _ in self.model)}
def ifc_load(self, path: str) -> str:
"""Open an IFC file into memory. Returns confirmation string."""
self.model = ifcopenshell.open(path)
self.model_path = path
count = sum(1 for _ in self.model)
return f"Loaded {path}: schema {self.model.schema}, {count} entities"
def ifc_save(self, path: str = "") -> str:
"""Write the in-memory model to disk. Empty path overwrites the original file."""
model = self._require_model()
target = path if path else self.model_path
if not target:
raise IfcSessionError("No path specified and no original path available.")
model.write(target)
return f"Saved to {target}"
def ifc_reset(self) -> dict[str, Any]:
"""Drop the in-memory model."""
self.model = None
self.model_path = None
return {"ok": True}
# -------------
# Query tools
# -------------
def ifc_summary(self) -> dict[str, Any]:
"""Model overview: schema, entity counts, project info."""
return summary.summary(self._require_model())
def ifc_tree(self) -> dict[str, Any] | list[dict[str, Any]]:
"""Full spatial hierarchy tree (Project -> Site -> Building -> Storeys -> Elements)."""
return tree.tree(self._require_model())
def ifc_info(self, element_id: int) -> dict[str, Any]:
"""Deep inspection of an entity by step ID (attributes, psets, placement, type, material)."""
model = self._require_model()
element = model.by_id(element_id)
if element is None:
raise IfcSessionError(f"Element #{element_id} not found.")
return info.info(model, element)
def ifc_select(self, query: str) -> list[dict[str, Any]]:
"""Filter elements using ifcopenshell selector syntax (e.g. 'IfcWall', 'IfcWindow')."""
return select.select(self._require_model(), query)
def ifc_relations(self, element_id: int, traverse: str = "") -> dict[str, Any] | list[dict[str, Any]]:
"""Show relationships for an element. Set traverse='up' to walk hierarchy to IfcProject."""
model = self._require_model()
element = model.by_id(element_id)
if element is None:
raise IfcSessionError(f"Element #{element_id} not found.")
return relations.relations(model, element, traverse=traverse if traverse else None)
def ifc_clash(
self,
element_id: int,
clearance: float = 0.0,
tolerance: float = 0.002,
scope: str = "storey",
) -> dict[str, Any]:
"""Check element for geometric clashes. clearance=0.0 means no clearance check."""
model = self._require_model()
element = model.by_id(element_id)
if element is None:
raise IfcSessionError(f"Element #{element_id} not found.")
return clash_mod.clash(
model,
element,
clearance=clearance if clearance and clearance > 0.0 else None,
tolerance=tolerance,
scope=scope,
)
# ------------------------
# Edit discovery + execute
# ------------------------
def ifc_list(self, module: str = "") -> list[dict]:
"""List all API modules, or functions within a module. Empty module = all modules."""
return list_functions(module) if module else list_modules()
def ifc_docs(self, function_path: str) -> dict:
"""Show full documentation for an API function. Input format: 'module.function'."""
module, function = function_path.split(".", 1)
return function_docs(module, function)
def ifc_edit(self, function_path: str, params: Any = "{}") -> dict:
"""Execute an ifcopenshell.api mutation.
params may be:
- JSON string
- dict (from tool calling / JS)
- JsProxy (handled upstream in embedded.py)
"""
model = self._require_model()
module, function = function_path.split(".", 1)
if isinstance(params, str):
raw_kwargs = json.loads(params) if params.strip() else {}
elif isinstance(params, dict):
raw_kwargs = params
else:
# e.g. list/None/etc
raw_kwargs = dict(params) if params is not None else {}
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_render(
self,
selector: str = "",
element_ids: list[int] | None = None,
view: str = "iso",
) -> bytes:
"""Render the loaded model to a PNG image and return raw bytes.
:param selector: ifcopenshell selector to restrict rendered elements
(e.g. ``'IfcWall'``). Omit to render the whole model.
:param element_ids: Step IDs of elements to highlight. Other elements
are rendered in translucent grey.
:param view: Camera angle: ``iso``, ``top``, ``south``, ``north``,
``east``, or ``west``.
:return: PNG image as raw bytes.
"""
model = self._require_model()
return render_mod.render(
model,
selector=selector if selector else None,
element_ids=element_ids,
view=view,
)
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
# ------------------------
def dispatch(self, name: str, args: dict[str, Any] | None = None) -> Any:
args = args or {}
fn = getattr(self, name, None)
if not callable(fn):
raise IfcSessionError(f"Unknown tool: {name}")
return _jsonify(fn(**args))
def openai_tools(self) -> list[dict[str, Any]]:
"""Tool schemas in the OpenAI 'Responses API' format (type=function)."""
# Keep schemas tight so the model calls tools correctly.
return [
{
"type": "function",
"name": "ifc_new",
"description": "Create a new empty IFC model in memory.",
"parameters": {
"type": "object",
"properties": {"schema": {"type": "string", "description": "IFC schema, e.g. IFC4"}},
"required": [],
"additionalProperties": False,
},
},
{
"type": "function",
"name": "ifc_summary",
"description": "Get a concise overview of the loaded IFC model.",
"parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": False},
},
{
"type": "function",
"name": "ifc_tree",
"description": "Get the full spatial hierarchy tree.",
"parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": False},
},
{
"type": "function",
"name": "ifc_select",
"description": "Select elements using ifcopenshell selector syntax (e.g. 'IfcWall').",
"parameters": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
"additionalProperties": False,
},
},
{
"type": "function",
"name": "ifc_info",
"description": "Inspect an entity by STEP id.",
"parameters": {
"type": "object",
"properties": {"element_id": {"type": "integer"}},
"required": ["element_id"],
"additionalProperties": False,
},
},
{
"type": "function",
"name": "ifc_relations",
"description": "Get relationships for an element. traverse='up' walks to IfcProject.",
"parameters": {
"type": "object",
"properties": {"element_id": {"type": "integer"}, "traverse": {"type": "string"}},
"required": ["element_id"],
"additionalProperties": False,
},
},
{
"type": "function",
"name": "ifc_clash",
"description": "Run clash/clearance checks for an element.",
"parameters": {
"type": "object",
"properties": {
"element_id": {"type": "integer"},
"clearance": {"type": "number"},
"tolerance": {"type": "number"},
"scope": {"type": "string", "description": "storey or all"},
},
"required": ["element_id"],
"additionalProperties": False,
},
},
{
"type": "function",
"name": "ifc_list",
"description": "List ifcopenshell.api modules or functions within a module.",
"parameters": {
"type": "object",
"properties": {"module": {"type": "string"}},
"required": [],
"additionalProperties": False,
},
},
{
"type": "function",
"name": "ifc_docs",
"description": "Get documentation for an ifcopenshell.api function, 'module.function'.",
"parameters": {
"type": "object",
"properties": {"function_path": {"type": "string"}},
"required": ["function_path"],
"additionalProperties": False,
},
},
{
"type": "function",
"name": "ifc_edit",
"description": "Execute an ifcopenshell.api mutation; params is a JSON string of stringly-typed kwargs.",
"parameters": {
"type": "object",
"properties": {"function_path": {"type": "string"}, "params": {"type": "string"}},
"required": ["function_path"],
"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,
},
},
{
"type": "function",
"name": "ifc_render",
"description": (
"Render the loaded IFC model to a PNG image for visual inspection. "
"Use selector to restrict which elements are rendered (e.g. a single storey). "
"Use element_ids to highlight elements against a greyed-out background. "
"Returns base64-encoded PNG bytes."
),
"parameters": {
"type": "object",
"properties": {
"selector": {"type": "string", "description": "ifcopenshell selector (default: whole model)"},
"element_ids": {"type": "array", "items": {"type": "integer"}, "description": "Step IDs of elements to highlight"},
"view": {
"type": "string",
"enum": ["iso", "top", "south", "north", "east", "west"],
"description": "Camera angle (default: iso)",
},
},
"required": [],
"additionalProperties": False,
},
},
]
+59
View File
@@ -0,0 +1,59 @@
# This file was generated with the assistance of an AI coding tool.
from __future__ import annotations
from typing import Any, Mapping
from ifcmcp.core import IfcSession
session = IfcSession()
# Optional imports only available under Pyodide
try:
from pyodide.ffi import JsProxy, to_py # type: ignore
except Exception: # pragma: no cover
JsProxy = None # type: ignore
to_py = None # type: ignore
def _coerce_args(args: Any) -> dict[str, Any]:
"""Convert JS objects / JsProxy / mappings into a real Python dict."""
if args is None:
return {}
# Pyodide: JS object arrives as JsProxy; convert recursively to Python.
if JsProxy is not None and isinstance(args, JsProxy):
# dict_converter=dict ensures JS object -> Python dict (not Map)
return to_py(args, dict_converter=dict)
# Already a Python dict
if isinstance(args, dict):
return args
# Any Mapping-like object
if isinstance(args, Mapping):
return dict(args)
# Last resort: try dict() coercion
try:
return dict(args)
except Exception as e:
raise TypeError(f"Tool args must be a mapping/dict; got {type(args)}") from e
def tools_openai() -> list[dict[str, Any]]:
return session.openai_tools()
def call_tool(name: str, args: Any = None) -> dict[str, Any]:
"""
Non-throwing tool dispatcher.
Always returns: {"ok": bool, "data": ...} or {"ok": false, "error": "...", "error_type": "...", ...}
"""
try:
py_args = _coerce_args(args)
data = session.dispatch(name, py_args)
return {"ok": True, "data": data}
except Exception as e:
# Keep it short; avoid full tracebacks in tool output unless debugging.
return {"ok": False, "error_type": type(e).__name__, "error": str(e)}
+144
View File
@@ -0,0 +1,144 @@
# This file was generated with the assistance of an AI coding tool.
from __future__ import annotations
import base64
from typing import Any
from ifcmcp.core import IfcSession
try:
from mcp.server.fastmcp import FastMCP # type: ignore
from mcp.types import ImageContent # type: ignore
except Exception: # pragma: no cover
FastMCP = None # type: ignore
ImageContent = None # type: ignore
def build_server() -> Any:
"""Create the FastMCP server if the dependency is available."""
if FastMCP is None:
raise ImportError(
"FastMCP is not installed. Install with: pip install ifcmcp[mcp] "
"(or add 'mcp' to your environment)."
)
session = IfcSession()
server = FastMCP(
name="ifc-mcp",
instructions=(
"MCP server for querying and editing IFC building models. "
"Load a file first with ifc_load, then use query/edit tools. "
"Save changes with ifc_save."
),
)
# ---- Lifecycle ----
@server.tool()
def ifc_new(schema: str = "IFC4") -> dict[str, Any]:
return session.ifc_new(schema=schema)
@server.tool()
def ifc_load(path: str) -> str:
return session.ifc_load(path)
@server.tool()
def ifc_save(path: str = "") -> str:
return session.ifc_save(path)
@server.tool()
def ifc_reset() -> dict[str, Any]:
return session.ifc_reset()
# ---- Query ----
@server.tool()
def ifc_summary() -> dict[str, Any]:
return session.ifc_summary()
@server.tool()
def ifc_tree() -> dict[str, Any] | list[dict[str, Any]]:
return session.ifc_tree()
@server.tool()
def ifc_info(element_id: int) -> dict[str, Any]:
return session.ifc_info(element_id)
@server.tool()
def ifc_select(query: str) -> list[dict[str, Any]]:
return session.ifc_select(query)
@server.tool()
def ifc_relations(element_id: int, traverse: str = "") -> dict[str, Any] | list[dict[str, Any]]:
return session.ifc_relations(element_id, traverse=traverse)
@server.tool()
def ifc_clash(
element_id: int,
clearance: float = 0.0,
tolerance: float = 0.002,
scope: str = "storey",
) -> dict[str, Any]:
return session.ifc_clash(
element_id=element_id,
clearance=clearance,
tolerance=tolerance,
scope=scope,
)
# ---- Edit ----
@server.tool()
def ifc_list(module: str = "") -> list[dict]:
return session.ifc_list(module=module)
@server.tool()
def ifc_docs(function_path: str) -> dict:
return session.ifc_docs(function_path=function_path)
@server.tool()
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)
@server.tool(structured_output=False)
def ifc_render(
selector: str = "",
element_ids: list[int] | None = None,
view: str = "iso",
) -> list[ImageContent]:
"""Render the loaded IFC model to a PNG image.
Returns an inline image the LLM can inspect to understand the spatial
layout of the model or a specific element in context.
:param selector: ifcopenshell selector to restrict rendered elements
(e.g. ``'IfcWall'``, ``'IfcBuildingStorey[Name="0"]'``).
Omit to render the whole model.
:param element_ids: Step IDs of elements to highlight. Other elements
are rendered in translucent grey so the subject stands out.
:param view: Camera angle — ``iso`` (default), ``top``, ``south``,
``north``, ``east``, or ``west``.
"""
png_bytes = session.ifc_render(selector=selector, element_ids=element_ids, view=view)
return [ImageContent(type="image", data=base64.b64encode(png_bytes).decode(), mimeType="image/png")]
return server
+35
View File
@@ -0,0 +1,35 @@
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "ifcmcp"
version = "0.0.0"
authors = [
{ name="Bruno Postle", email="bruno@postle.net" },
]
description = "MCP server for querying and editing IFC building models"
keywords = ["IFC", "BIM", "MCP"]
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)",
]
dependencies = ["ifcopenshell", "ifcquery", "ifcedit"]
[project.optional-dependencies]
mcp = ["mcp"]
[project.scripts]
ifcmcp = "ifcmcp.__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 = ["ifcmcp*"]
exclude = ["test*"]
[tool.ruff]
extend = "../../pyproject.toml"
+1
View File
@@ -0,0 +1 @@
# This file was generated with the assistance of an AI coding tool.
+64
View File
@@ -0,0 +1,64 @@
# 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
import ifcmcp.server as server_mod
@pytest.fixture
def model():
"""Create an IFC4 model with a spatial hierarchy, a wall, and a slab."""
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
@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(autouse=True)
def reset_server_state():
"""Reset module-level state before each test."""
server_mod._model = None
server_mod._model_path = None
yield
server_mod._model = None
server_mod._model_path = None
@pytest.fixture
def loaded_model(model):
"""Set the server module state to an in-memory model (no file path)."""
server_mod._model = model
server_mod._model_path = None
return model
+100
View File
@@ -0,0 +1,100 @@
# This file was generated with the assistance of an AI coding tool.
import json
import pytest
from ifcmcp.server import ifc_docs, ifc_edit, ifc_list
class TestNoModel:
def test_edit_no_model(self):
with pytest.raises(ValueError, match="No model loaded"):
ifc_edit("root.create_entity")
class TestList:
def test_list_all_modules(self, loaded_model):
result = ifc_list()
assert isinstance(result, list)
assert len(result) > 0
modules = [m["module"] for m in result]
assert "root" in modules
assert "spatial" in modules
def test_list_module_functions(self, loaded_model):
result = ifc_list(module="root")
assert isinstance(result, list)
names = [f["name"] for f in result]
assert "create_entity" in names
def test_list_empty_string_returns_modules(self, loaded_model):
result = ifc_list(module="")
assert isinstance(result, list)
assert any(m["module"] == "root" for m in result)
class TestDocs:
def test_docs_create_entity(self, loaded_model):
result = ifc_docs("root.create_entity")
assert result["module"] == "root"
assert result["function"] == "create_entity"
assert "params" in result
def test_docs_bad_format(self, loaded_model):
with pytest.raises(ValueError):
ifc_docs("no_dot_here")
class TestEdit:
def test_create_entity(self, loaded_model):
result = ifc_edit("root.create_entity", json.dumps({"ifc_class": "IfcWall", "name": "NewWall"}))
assert result["ok"] is True
assert result["result"]["type"] == "IfcWall"
assert result["result"]["name"] == "NewWall"
def test_create_entity_default_params(self, loaded_model):
result = ifc_edit("root.create_entity", "{}")
assert result["ok"] is True
def test_unknown_function(self, loaded_model):
result = ifc_edit("root.nonexistent", "{}")
assert result["ok"] is False
assert "Cannot find" in result["error"]
def test_unknown_parameter(self, loaded_model):
result = ifc_edit("root.create_entity", json.dumps({"bogus": "value"}))
assert result["ok"] is False
assert "Unknown parameter" in result["error"]
def test_bad_json(self, loaded_model):
with pytest.raises(json.JSONDecodeError):
ifc_edit("root.create_entity", "not json")
def test_edit_does_not_save(self, loaded_model, tmp_path):
"""Verify that ifc_edit mutates the in-memory model but does not write to disk."""
import ifcmcp.server as server_mod
path = str(tmp_path / "test.ifc")
loaded_model.write(path)
server_mod._model_path = path
before_count = sum(1 for _ in loaded_model)
ifc_edit("root.create_entity", json.dumps({"ifc_class": "IfcWall", "name": "Unsaved"}))
after_count = sum(1 for _ in loaded_model)
assert after_count == before_count + 1
# Re-read the file — it should not have the new entity
import ifcopenshell
on_disk = ifcopenshell.open(path)
disk_count = sum(1 for _ in on_disk)
assert disk_count == before_count
def test_assign_container(self, loaded_model):
wall = loaded_model.by_type("IfcWall")[0]
storey = loaded_model.by_type("IfcBuildingStorey")[0]
result = ifc_edit(
"spatial.assign_container",
json.dumps({"products": str(wall.id()), "relating_structure": str(storey.id())}),
)
assert result["ok"] is True
+113
View File
@@ -0,0 +1,113 @@
# This file was generated with the assistance of an AI coding tool.
import pytest
from ifcmcp.server import ifc_info, ifc_relations, ifc_select, ifc_summary, ifc_tree
class TestNoModel:
"""All query tools should fail when no model is loaded."""
def test_summary_no_model(self):
with pytest.raises(ValueError, match="No model loaded"):
ifc_summary()
def test_tree_no_model(self):
with pytest.raises(ValueError, match="No model loaded"):
ifc_tree()
def test_info_no_model(self):
with pytest.raises(ValueError, match="No model loaded"):
ifc_info(1)
def test_select_no_model(self):
with pytest.raises(ValueError, match="No model loaded"):
ifc_select("IfcWall")
def test_relations_no_model(self):
with pytest.raises(ValueError, match="No model loaded"):
ifc_relations(1)
class TestSummary:
def test_schema(self, loaded_model):
result = ifc_summary()
assert result["schema"] == "IFC4"
def test_total_entities(self, loaded_model):
result = ifc_summary()
assert result["total_entities"] > 0
def test_project_name(self, loaded_model):
result = ifc_summary()
assert result["project"]["name"] == "TestProject"
def test_type_counts(self, loaded_model):
result = ifc_summary()
assert result["types"]["IfcWall"] == 1
assert result["types"]["IfcSlab"] == 1
class TestTree:
def test_root_is_project(self, loaded_model):
result = ifc_tree()
assert result["type"] == "IfcProject"
assert result["name"] == "TestProject"
def test_hierarchy_depth(self, loaded_model):
result = ifc_tree()
site = result["children"][0]
assert site["type"] == "IfcSite"
building = site["children"][0]
assert building["type"] == "IfcBuilding"
storey = building["children"][0]
assert storey["type"] == "IfcBuildingStorey"
class TestInfo:
def test_wall_info(self, loaded_model):
wall = loaded_model.by_type("IfcWall")[0]
result = ifc_info(wall.id())
assert result["id"] == wall.id()
assert result["type"] == "IfcWall"
def test_invalid_id(self, loaded_model):
with pytest.raises(Exception):
ifc_info(999999)
class TestSelect:
def test_select_walls(self, loaded_model):
result = ifc_select("IfcWall")
assert len(result) == 1
assert result[0]["type"] == "IfcWall"
assert result[0]["name"] == "Wall001"
def test_select_slabs(self, loaded_model):
result = ifc_select("IfcSlab")
assert len(result) == 1
assert result[0]["name"] == "Slab001"
def test_select_no_match(self, loaded_model):
result = ifc_select("IfcWindow")
assert result == []
class TestRelations:
def test_wall_relations(self, loaded_model):
wall = loaded_model.by_type("IfcWall")[0]
result = ifc_relations(wall.id())
assert result["id"] == wall.id()
assert result["type"] == "IfcWall"
assert "hierarchy" in result
def test_traverse_up(self, loaded_model):
wall = loaded_model.by_type("IfcWall")[0]
result = ifc_relations(wall.id(), traverse="up")
assert isinstance(result, list)
assert result[0]["type"] == "IfcWall"
assert result[-1]["type"] == "IfcProject"
def test_traverse_empty_string_means_no_traverse(self, loaded_model):
wall = loaded_model.by_type("IfcWall")[0]
result = ifc_relations(wall.id(), traverse="")
assert isinstance(result, dict)
+29
View File
@@ -0,0 +1,29 @@
# This file was generated with the assistance of an AI coding tool.
from ifcmcp.server import server
class TestServerRegistration:
def test_server_name(self):
assert server.name == "ifc-mcp"
def test_all_tools_registered(self):
tools = [t.name for t in server._tool_manager.list_tools()]
expected = [
"ifc_load",
"ifc_save",
"ifc_summary",
"ifc_tree",
"ifc_info",
"ifc_select",
"ifc_relations",
"ifc_clash",
"ifc_list",
"ifc_docs",
"ifc_edit",
]
for name in expected:
assert name in tools, f"Tool {name} not registered"
def test_tool_count(self):
tools = server._tool_manager.list_tools()
assert len(tools) == 11
+46
View File
@@ -0,0 +1,46 @@
# This file was generated with the assistance of an AI coding tool.
import pytest
import ifcmcp.server as server_mod
from ifcmcp.server import ifc_load, ifc_save
class TestLoad:
def test_load_file(self, model_file):
result = ifc_load(model_file)
assert "IFC4" in result
assert server_mod._model is not None
assert server_mod._model_path == model_file
def test_load_sets_entity_count(self, model_file):
result = ifc_load(model_file)
assert "entities" in result
def test_load_nonexistent_file(self):
with pytest.raises(Exception):
ifc_load("/nonexistent/path/model.ifc")
class TestSave:
def test_save_no_model(self):
with pytest.raises(ValueError, match="No model loaded"):
ifc_save()
def test_save_overwrites_original(self, model_file):
ifc_load(model_file)
result = ifc_save()
assert model_file in result
def test_save_to_new_path(self, model_file, tmp_path):
ifc_load(model_file)
new_path = str(tmp_path / "output.ifc")
result = ifc_save(new_path)
assert new_path in result
import ifcopenshell
reloaded = ifcopenshell.open(new_path)
assert reloaded.schema == "IFC4"
def test_save_no_path_no_original(self, loaded_model):
with pytest.raises(ValueError, match="No path specified"):
ifc_save()
+1 -1
View File
@@ -746,7 +746,7 @@ namespace {
static std::string format_double(const double& d) {
std::ostringstream oss;
oss.imbue(std::locale::classic());
oss << std::setprecision(std::numeric_limits<double>::max_digits10) << d;
oss << std::setprecision(std::numeric_limits<double>::digits10) << d;
const std::string str = oss.str();
oss.str("");
std::string::size_type e = str.find('e');
+380
View File
@@ -0,0 +1,380 @@
<!-- This file was generated with the assistance of an AI coding tool. -->
# 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 <ifc_file> <command> [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 <meters>` -- minimum clearance distance to check
- `--tolerance <meters>` -- 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.
+20
View File
@@ -0,0 +1,20 @@
# This file was generated with the assistance of an AI coding tool.
# IfcQuery - IFC model interrogation CLI
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
#
# This file is part of IfcQuery.
#
# IfcQuery is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcQuery is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcQuery. If not, see <http://www.gnu.org/licenses/>.
__version__ = version = "0.0.0"
+320
View File
@@ -0,0 +1,320 @@
# This file was generated with the assistance of an AI coding tool.
# IfcQuery - IFC model interrogation CLI
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
#
# This file is part of IfcQuery.
#
# IfcQuery is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcQuery is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcQuery. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
import argparse
import json
import os
import sys
import ifcopenshell
from ifcquery import clash as clash_mod
from ifcquery import cost as cost_mod
from ifcquery import info, relations, render as render_mod, schedule, schema, select, summary, tree, plot
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)"
)
schema_parser = subparsers.add_parser("schema", help="IFC class documentation")
schema_parser.add_argument("entity_type", help="IFC entity type (e.g. IfcWall)")
render_parser = subparsers.add_parser("render", help="Render model geometry to a PNG image")
render_parser.add_argument(
"-o", "--output", default="", metavar="FILE", help="Output PNG path (default: <ifc_file>.png)"
)
render_parser.add_argument(
"--selector", default="", metavar="QUERY", help="ifcopenshell selector to restrict rendered elements"
)
render_parser.add_argument(
"--element", default="", metavar="ID[,ID...]",
help="Comma-separated step IDs of elements to highlight (rest rendered in grey)"
)
render_parser.add_argument(
"--view",
choices=render_mod.VIEWS,
default="iso",
help="Camera angle (default: iso)",
)
plot_parser = subparsers.add_parser("plot", help="Plot model drawing (SVG via ifcopenshell.draw; optional PNG via CairoSVG)")
plot_parser.add_argument(
"-o", "--output", default="", metavar="FILE",
help="Output file path. Default depends on --out-format: <ifc_file>.svg/.png"
)
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 == "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)
# Choose default output extension based on out-format
base = os.path.splitext(args.ifc_file)[0]
if args.out_format == "svg":
out_path = args.output or (base + ".svg")
elif args.out_format == "png":
out_path = args.output or (base + ".png")
else:
out_path = args.output # unused; base64 prints to stdout via format_output
png_bytes = 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
)
with open(out_path, "wb") as f:
f.write(png_bytes)
print(f"Saved render to {out_path}", file=sys.stderr)
return
print(format_output(result, args.output_format))
if __name__ == "__main__":
main()
+167
View File
@@ -0,0 +1,167 @@
# This file was generated with the assistance of an AI coding tool.
# IfcQuery - IFC model interrogation CLI
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
#
# This file is part of IfcQuery.
#
# IfcQuery is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcQuery is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcQuery. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
import multiprocessing
import sys
from typing import Any
import ifcopenshell
import ifcopenshell.geom
import ifcopenshell.util.element
def _ref(element: ifcopenshell.entity_instance) -> dict[str, Any]:
"""Serialize an element to a compact reference dict."""
result: dict[str, Any] = {"id": element.id(), "type": element.is_a()}
if hasattr(element, "Name") and element.Name:
result["name"] = element.Name
return result
def _get_scope_elements(
model: ifcopenshell.file, element: ifcopenshell.entity_instance, scope: str
) -> tuple[set[ifcopenshell.entity_instance], str]:
"""Return set of elements to check against and the effective scope used.
Returns (elements, effective_scope) where effective_scope may differ from
the requested scope if fallback was needed.
"""
if scope == "storey":
container = ifcopenshell.util.element.get_container(element)
if container is not None:
siblings = set(ifcopenshell.util.element.get_contained(container))
siblings.discard(element)
return siblings, "storey"
else:
print(
f"Warning: Element #{element.id()} has no spatial container, falling back to --scope all",
file=sys.stderr,
)
# scope == "all" or fallback
elements = set(model.by_type("IfcElement"))
elements -= set(model.by_type("IfcFeatureElement"))
elements.discard(element)
return elements, "all"
def _build_tree(model: ifcopenshell.file, elements: set[ifcopenshell.entity_instance]) -> ifcopenshell.geom.tree | None:
"""Build geometry tree for given elements using iterator.
Returns None if iterator fails to initialize (no geometry available).
"""
geom_settings = ifcopenshell.geom.settings()
geom_settings.set("use-world-coords", True)
geom_tree = ifcopenshell.geom.tree()
iterator = ifcopenshell.geom.iterator(geom_settings, model, multiprocessing.cpu_count(), include=list(elements))
if not iterator.initialize():
return None
while True:
geom_tree.add_element(iterator.get())
if not iterator.next():
break
return geom_tree
def _format_clash(clash_result, geom_tree: ifcopenshell.geom.tree, model: ifcopenshell.file) -> dict[str, Any]:
"""Format a single clash result to dict."""
# clash result .a/.b are C++ wrapper entity_instances without .Name;
# look up the Python entity from the model by id for proper serialization
other = model.by_id(clash_result.b.id())
return {
"element": _ref(other),
"type": geom_tree.get_clash_type(clash_result.clash_type),
"distance": clash_result.distance,
"p1": list(clash_result.p1),
"p2": list(clash_result.p2),
}
def clash(
model: ifcopenshell.file,
element: ifcopenshell.entity_instance,
clearance: float | None = None,
tolerance: float = 0.002,
scope: str = "storey",
) -> dict[str, Any]:
"""Check element for geometric clashes against other elements.
:param model: The IFC model.
:param element: The element to check.
:param clearance: Minimum clearance distance; if provided, runs clearance check.
:param tolerance: Intersection tolerance in meters (default 0.002).
:param scope: Which elements to check against: "storey" or "all".
:return: Dict with clash results suitable for JSON serialization.
"""
result: dict[str, Any] = {"element": _ref(element)}
# Get scope elements
scope_elements, effective_scope = _get_scope_elements(model, element, scope)
result["scope"] = effective_scope
if not scope_elements:
result["pass"] = True
result["checks"] = {"intersection": {"pass": True, "tolerance": tolerance, "clashes": []}}
if clearance is not None:
result["checks"]["clearance"] = {"pass": True, "clearance": clearance, "clashes": []}
return result
# Build geometry tree for target element + scope elements
all_elements = scope_elements | {element}
geom_tree = _build_tree(model, all_elements)
if geom_tree is None:
result["pass"] = None
result["error"] = f"No geometry for element #{element.id()}"
return result
# Run intersection check
intersection_clashes = geom_tree.clash_intersection_many(
[element], list(scope_elements), tolerance=tolerance, check_all=True
)
intersection_results = [_format_clash(c, geom_tree, model) for c in intersection_clashes]
checks: dict[str, Any] = {
"intersection": {
"pass": len(intersection_results) == 0,
"tolerance": tolerance,
"clashes": intersection_results,
}
}
all_pass = len(intersection_results) == 0
# Run clearance check if requested
if clearance is not None:
clearance_clashes = geom_tree.clash_clearance_many(
[element], list(scope_elements), clearance=clearance, check_all=True
)
clearance_results = [_format_clash(c, geom_tree, model) for c in clearance_clashes]
checks["clearance"] = {
"pass": len(clearance_results) == 0,
"clearance": clearance,
"clashes": clearance_results,
}
if clearance_results:
all_pass = False
result["pass"] = all_pass
result["checks"] = checks
return result
+45
View File
@@ -0,0 +1,45 @@
# This file was generated with the assistance of an AI coding tool.
from __future__ import annotations
from typing import Any
import ifcopenshell
import ifcopenshell.util.cost as cost_util
def _cost_item_to_dict(item: ifcopenshell.entity_instance, max_depth: int | None, depth: int) -> dict[str, Any]:
raw_values = cost_util.get_cost_values(item)
values = [{"formula": v.get("label", ""), "category": v.get("category")} for v in raw_values]
if max_depth is not None and depth >= max_depth:
child_count = len(cost_util.get_nested_cost_items(item))
subitems = {"truncated": True, "count": child_count} if child_count else []
else:
subitems = [_cost_item_to_dict(sub, max_depth, depth + 1) for sub in cost_util.get_nested_cost_items(item)]
return {
"id": item.id(),
"name": getattr(item, "Name", None),
"values": values,
"subitems": subitems,
}
def cost(model: ifcopenshell.file, max_depth: int | None = None) -> list[dict[str, Any]]:
"""Return a list of IfcCostSchedule entries with nested cost item trees.
max_depth limits how many levels of subitems are expanded (None = unlimited).
At the cutoff level, subitems is replaced with {"truncated": True, "count": N}.
"""
result = []
for cost_schedule in model.by_type("IfcCostSchedule"):
items = [_cost_item_to_dict(i, max_depth, depth=1) for i in cost_util.get_root_cost_items(cost_schedule)]
result.append(
{
"id": cost_schedule.id(),
"name": getattr(cost_schedule, "Name", None),
"predefined_type": getattr(cost_schedule, "PredefinedType", None),
"items": items,
}
)
return result
+118
View File
@@ -0,0 +1,118 @@
# This file was generated with the assistance of an AI coding tool.
# IfcQuery - IFC model interrogation CLI
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
#
# This file is part of IfcQuery.
#
# IfcQuery is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcQuery is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcQuery. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
from typing import Any
import ifcopenshell
import ifcopenshell.util.element
import ifcopenshell.util.placement
def _serialize_attribute(value: Any) -> Any:
"""Convert an IFC attribute value to a JSON-serializable form."""
if isinstance(value, ifcopenshell.entity_instance):
return {"id": value.id(), "type": value.is_a()}
if isinstance(value, tuple):
return [_serialize_attribute(v) for v in value]
return value
def _material_to_dict(material: ifcopenshell.entity_instance | None) -> dict[str, Any] | None:
"""Convert a material entity to a summary dict."""
if material is None:
return None
result: dict[str, Any] = {
"id": material.id(),
"type": material.is_a(),
}
if hasattr(material, "Name"):
result["name"] = material.Name
return result
def info(model: ifcopenshell.file, element: ifcopenshell.entity_instance) -> dict[str, Any]:
"""Return deep inspection data for an element."""
result: dict[str, Any] = {
"id": element.id(),
"type": element.is_a(),
}
# Direct attributes via get_info() which returns a dict of all attributes
element_info = element.get_info()
attrs = {}
for key, value in element_info.items():
if key in ("id", "type"):
continue
attrs[key] = _serialize_attribute(value)
result["attributes"] = attrs
# Property sets and quantity sets
try:
psets = ifcopenshell.util.element.get_psets(element)
if psets:
result["property_sets"] = psets
except Exception:
pass
# Element type
try:
element_type = ifcopenshell.util.element.get_type(element)
if element_type:
type_info: dict[str, Any] = {
"id": element_type.id(),
"type": element_type.is_a(),
}
if hasattr(element_type, "Name"):
type_info["name"] = element_type.Name
result["element_type"] = type_info
except Exception:
pass
# Material
try:
material = ifcopenshell.util.element.get_material(element)
mat_dict = _material_to_dict(material)
if mat_dict:
result["material"] = mat_dict
except Exception:
pass
# Spatial container
try:
container = ifcopenshell.util.element.get_container(element)
if container:
result["container"] = {
"id": container.id(),
"type": container.is_a(),
"name": container.Name if hasattr(container, "Name") else None,
}
except Exception:
pass
# Placement (as 4x4 matrix)
try:
if hasattr(element, "ObjectPlacement") and element.ObjectPlacement:
matrix = ifcopenshell.util.placement.get_local_placement(element.ObjectPlacement)
result["placement"] = matrix.tolist()
except Exception:
pass
return result
+238
View File
@@ -0,0 +1,238 @@
# IfcQuery - IFC model interrogation CLI
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
#
# This file is part of IfcQuery.
#
# IfcQuery is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcQuery is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcQuery. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
from io import BytesIO
import os
from typing import Any
import ifcopenshell
import ifcopenshell.geom
import ifcopenshell.util.selector
import ifcopenshell.draw
from xml.etree.ElementTree import ElementTree, Element, 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:
e = model.by_id(int(sid))
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 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")
composite = None
png_bytes = None
svgs = list(svg_split(BytesIO(svg_bytes)))
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()
return png_bytes
+169
View File
@@ -0,0 +1,169 @@
# This file was generated with the assistance of an AI coding tool.
# IfcQuery - IFC model interrogation CLI
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
#
# This file is part of IfcQuery.
#
# IfcQuery is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcQuery is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcQuery. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
from typing import Any
import ifcopenshell
import ifcopenshell.util.element
import ifcopenshell.util.system
def _ref(element: ifcopenshell.entity_instance) -> dict[str, Any]:
"""Serialize an element to a compact reference dict."""
result: dict[str, Any] = {"id": element.id(), "type": element.is_a()}
if hasattr(element, "Name") and element.Name:
result["name"] = element.Name
return result
def _ref_or_none(element: ifcopenshell.entity_instance | None) -> dict[str, Any] | None:
return _ref(element) if element is not None else None
def _ref_list(elements) -> list[dict[str, Any]]:
return [_ref(e) for e in elements]
def _traverse_up(element: ifcopenshell.entity_instance) -> list[dict[str, Any]]:
"""Walk the hierarchy from element up to IfcProject."""
chain = [_ref(element)]
current = element
while True:
parent = ifcopenshell.util.element.get_parent(current)
if parent is None:
break
chain.append(_ref(parent))
current = parent
return chain
def _all_relations(model: ifcopenshell.file, element: ifcopenshell.entity_instance) -> dict[str, Any]:
"""Collect all relationships for an element."""
result: dict[str, Any] = {
"id": element.id(),
"type": element.is_a(),
}
if hasattr(element, "Name") and element.Name:
result["name"] = element.Name
# Hierarchy (upward)
hierarchy: dict[str, Any] = {}
parent = ifcopenshell.util.element.get_parent(element)
if parent is not None:
hierarchy["parent"] = _ref(parent)
container = ifcopenshell.util.element.get_container(element)
if container is not None:
hierarchy["container"] = _ref(container)
aggregate = ifcopenshell.util.element.get_aggregate(element)
if aggregate is not None:
hierarchy["aggregate"] = _ref(aggregate)
nest = ifcopenshell.util.element.get_nest(element)
if nest is not None:
hierarchy["nest"] = _ref(nest)
filled_void = ifcopenshell.util.element.get_filled_void(element)
if filled_void is not None:
hierarchy["filled_void"] = _ref(filled_void)
voided_element = ifcopenshell.util.element.get_voided_element(element)
if voided_element is not None:
hierarchy["voided_element"] = _ref(voided_element)
if hierarchy:
result["hierarchy"] = hierarchy
# Children (downward)
children: dict[str, Any] = {}
contained = ifcopenshell.util.element.get_contained(element)
if contained:
children["contained"] = _ref_list(contained)
parts = ifcopenshell.util.element.get_parts(element)
if parts:
children["parts"] = _ref_list(parts)
components = ifcopenshell.util.element.get_components(element)
if components:
children["components"] = _ref_list(components)
openings = list(ifcopenshell.util.element.get_openings(element))
if openings:
children["openings"] = _ref_list(openings)
if children:
result["children"] = children
# Type relationship
type_relationship: dict[str, Any] = {}
element_type = ifcopenshell.util.element.get_type(element)
if element_type is not None:
type_relationship["type_of"] = _ref(element_type)
try:
occurrences = ifcopenshell.util.element.get_types(element)
if occurrences:
type_relationship["occurrences"] = _ref_list(occurrences)
except Exception:
pass
if type_relationship:
result["type_relationship"] = type_relationship
# Groups
groups = ifcopenshell.util.element.get_groups(element)
if groups:
result["groups"] = _ref_list(groups)
# Systems
systems = ifcopenshell.util.system.get_element_systems(element)
if systems:
result["systems"] = _ref_list(systems)
# Zones
zones = ifcopenshell.util.system.get_element_zones(element)
if zones:
result["zones"] = _ref_list(zones)
# Material
material = ifcopenshell.util.element.get_material(element)
if material is not None:
result["material"] = _ref(material)
# Referenced structures
referenced = ifcopenshell.util.element.get_referenced_structures(element)
if referenced:
result["referenced_structures"] = _ref_list(referenced)
# Connections
connections: dict[str, Any] = {}
connected_to = ifcopenshell.util.system.get_connected_to(element)
if connected_to:
connections["connected_to"] = _ref_list(connected_to)
connected_from = ifcopenshell.util.system.get_connected_from(element)
if connected_from:
connections["connected_from"] = _ref_list(connected_from)
ports = ifcopenshell.util.system.get_ports(element)
if ports:
connections["ports"] = _ref_list(ports)
if connections:
result["connections"] = connections
return result
def relations(
model: ifcopenshell.file, element: ifcopenshell.entity_instance, traverse: str | None = None
) -> dict[str, Any] | list[dict[str, Any]]:
"""Return relationships for an element, or hierarchy chain if traverse='up'."""
if traverse == "up":
return _traverse_up(element)
return _all_relations(model, element)
+183
View File
@@ -0,0 +1,183 @@
# IfcQuery - IFC model interrogation CLI
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
#
# This file is part of IfcQuery.
#
# IfcQuery is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcQuery is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcQuery. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
import multiprocessing
import os
import tempfile
import ifcopenshell
import ifcopenshell.geom
import ifcopenshell.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
faces = np.array(geom.faces, dtype=int).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(
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.
:param model: The in-memory IFC model.
:param selector: ifcopenshell selector to restrict rendered elements
(e.g. ``'IfcWall'`` or ``'IfcBuildingStorey[Name="Ground Floor"]'``).
When omitted the whole model is rendered.
:param element_ids: Step IDs of elements to highlight. The rest of the
model is rendered in translucent grey so the highlighted elements
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")
settings = ifcopenshell.geom.settings()
settings.set("use-world-coords", True)
# Exclude 'Clearance' subcontexts (door/window operation zones) from rendering.
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)
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")
iterator = ifcopenshell.geom.iterator(
settings,
model,
multiprocessing.cpu_count(),
include=include_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)")
plotter = pv.Plotter(off_screen=True, window_size=(1280, 960))
plotter.background_color = "white"
while True:
_add_shape(iterator.get(), plotter, highlight_ids=frozenset(element_ids) if element_ids else None)
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
+56
View File
@@ -0,0 +1,56 @@
# This file was generated with the assistance of an AI coding tool.
from __future__ import annotations
from typing import Any
import ifcopenshell
import ifcopenshell.util.sequence as seq
def _task_to_dict(task: ifcopenshell.entity_instance, max_depth: int | None, depth: int) -> dict[str, Any]:
task_time = task.TaskTime
start = None
finish = None
if task_time:
start = task_time.ScheduleStart
finish = task_time.ScheduleFinish
outputs = []
for product in seq.get_task_outputs(task):
outputs.append({"id": product.id(), "type": product.is_a(), "name": getattr(product, "Name", None)})
if max_depth is not None and depth >= max_depth:
child_count = len(seq.get_nested_tasks(task))
subtasks = {"truncated": True, "count": child_count} if child_count else []
else:
subtasks = [_task_to_dict(sub, max_depth, depth + 1) for sub in seq.get_nested_tasks(task)]
return {
"id": task.id(),
"name": getattr(task, "Name", None),
"start": start,
"finish": finish,
"is_milestone": bool(task.IsMilestone) if hasattr(task, "IsMilestone") else False,
"outputs": outputs,
"subtasks": subtasks,
}
def schedule(model: ifcopenshell.file, max_depth: int | None = None) -> list[dict[str, Any]]:
"""Return a list of IfcWorkSchedule entries with nested task trees.
max_depth limits how many levels of subtasks are expanded (None = unlimited).
At the cutoff level, subtasks is replaced with {"truncated": True, "count": N}.
"""
result = []
for work_schedule in model.by_type("IfcWorkSchedule"):
tasks = [_task_to_dict(t, max_depth, depth=1) for t in seq.get_root_tasks(work_schedule)]
result.append(
{
"id": work_schedule.id(),
"name": getattr(work_schedule, "Name", None),
"predefined_type": getattr(work_schedule, "PredefinedType", None),
"tasks": tasks,
}
)
return result
+19
View File
@@ -0,0 +1,19 @@
# This file was generated with the assistance of an AI coding tool.
from __future__ import annotations
from typing import Any
import ifcopenshell
import ifcopenshell.util.doc
def schema(model: ifcopenshell.file, entity_type: str) -> dict[str, Any]:
"""Return IFC class documentation for entity_type from model's schema version."""
schema_name = model.schema
try:
doc = ifcopenshell.util.doc.get_entity_doc(schema_name, entity_type)
except Exception:
return {"error": f"Unknown entity: {entity_type}"}
if not doc:
return {"error": f"Unknown entity: {entity_type}"}
return dict(doc)
+41
View File
@@ -0,0 +1,41 @@
# This file was generated with the assistance of an AI coding tool.
# IfcQuery - IFC model interrogation CLI
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
#
# This file is part of IfcQuery.
#
# IfcQuery is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcQuery is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcQuery. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
from typing import Any
import ifcopenshell
import ifcopenshell.util.selector
def select(model: ifcopenshell.file, query: str) -> list[dict[str, Any]]:
"""Filter elements using selector syntax and return matching element summaries."""
elements = ifcopenshell.util.selector.filter_elements(model, query)
results = []
for element in sorted(elements, key=lambda e: e.id()):
entry: dict[str, Any] = {
"id": element.id(),
"type": element.is_a(),
"repr": str(element),
}
if hasattr(element, "Name"):
entry["name"] = element.Name
results.append(entry)
return results
+52
View File
@@ -0,0 +1,52 @@
# This file was generated with the assistance of an AI coding tool.
# IfcQuery - IFC model interrogation CLI
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
#
# This file is part of IfcQuery.
#
# IfcQuery is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcQuery is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcQuery. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
from collections import Counter
from typing import Any
import ifcopenshell
def summary(model: ifcopenshell.file) -> dict[str, Any]:
"""Return a model overview with schema, element counts, and project info."""
# Count elements by IFC type, sorted by count descending
type_counter: Counter[str] = Counter()
total = 0
for entity in model:
type_counter[entity.is_a()] += 1
total += 1
result: dict[str, Any] = {
"schema": model.schema,
"total_entities": total,
}
projects = model.by_type("IfcProject")
if projects:
project = projects[0]
result["project"] = {
"id": project.id(),
"name": project.Name,
"description": project.Description,
}
result["types"] = dict(type_counter.most_common())
return result
+68
View File
@@ -0,0 +1,68 @@
# This file was generated with the assistance of an AI coding tool.
# IfcQuery - IFC model interrogation CLI
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
#
# This file is part of IfcQuery.
#
# IfcQuery is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcQuery is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcQuery. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
from typing import Any
import ifcopenshell
import ifcopenshell.util.element
def _element_summary(element: ifcopenshell.entity_instance) -> dict[str, Any]:
"""Return a minimal summary dict for an element."""
return {
"id": element.id(),
"type": element.is_a(),
"name": element.Name if hasattr(element, "Name") else None,
}
def _build_spatial_node(element: ifcopenshell.entity_instance) -> dict[str, Any]:
"""Recursively build a spatial tree node."""
node = _element_summary(element)
# Get aggregated children (Site in Project, Building in Site, Storey in Building, etc.)
aggregates = []
for rel in getattr(element, "IsDecomposedBy", []):
for child in rel.RelatedObjects:
aggregates.append(_build_spatial_node(child))
# Get contained elements (walls, slabs, etc. in a storey/space)
contained = []
for rel in getattr(element, "ContainsElements", []):
for child in rel.RelatedElements:
contained.append(_element_summary(child))
if aggregates:
node["children"] = aggregates
if contained:
node["elements"] = contained
return node
def tree(model: ifcopenshell.file) -> dict[str, Any] | list[dict[str, Any]]:
"""Return the spatial hierarchy tree starting from IfcProject."""
projects = model.by_type("IfcProject")
if not projects:
return {"error": "No IfcProject found in model"}
if len(projects) == 1:
return _build_spatial_node(projects[0])
return [_build_spatial_node(p) for p in projects]
+15
View File
@@ -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}
+33
View File
@@ -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"
+1
View File
@@ -0,0 +1 @@
# This file was generated with the assistance of an AI coding tool.
+36
View File
@@ -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
+330
View File
@@ -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 = [w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall003"][0]
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 = [w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001"][0]
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 = [w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall002"][0]
assert wall2.id() in clash_ids
def test_clash_has_points(self, model_with_geometry):
wall1 = [w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001"][0]
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 = [w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001"][0]
result = clash(model_with_geometry, wall1, clearance=0.5)
assert "clearance" in result["checks"]
# Wall004 should violate clearance
clearance_clashes = result["checks"]["clearance"]["clashes"]
clash_ids = {c["element"]["id"] for c in clearance_clashes}
wall4 = [w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall004"][0]
assert wall4.id() in clash_ids
assert result["checks"]["clearance"]["pass"] is False
def test_clearance_pass(self, model_with_geometry):
"""Wall003 is 10m away; clearance of 0.5m should pass for wall003."""
wall3 = [w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall003"][0]
result = clash(model_with_geometry, wall3, clearance=0.5)
assert result["checks"]["clearance"]["pass"] is True
assert result["checks"]["clearance"]["clashes"] == []
def test_clearance_not_included_by_default(self, model_with_geometry):
wall1 = [w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001"][0]
result = clash(model_with_geometry, wall1)
assert "clearance" not in result["checks"]
class TestScope:
def test_scope_storey_excludes_other_storeys(self, model_two_storeys):
wall1 = [w for w in model_two_storeys.by_type("IfcWall") if w.Name == "GroundWall"][0]
result = clash(model_two_storeys, wall1, scope="storey")
assert result["scope"] == "storey"
# No clashes because the overlapping wall is in a different storey
assert result["pass"] is True
def test_scope_all_includes_other_storeys(self, model_two_storeys):
wall1 = [w for w in model_two_storeys.by_type("IfcWall") if w.Name == "GroundWall"][0]
result = clash(model_two_storeys, wall1, scope="all")
assert result["scope"] == "all"
# Should detect clash with the other-storey wall
assert result["pass"] is False
clash_ids = {c["element"]["id"] for c in result["checks"]["intersection"]["clashes"]}
wall2 = [w for w in model_two_storeys.by_type("IfcWall") if w.Name == "FirstFloorWall"][0]
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 = [w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001"][0]
result = clash(model_with_geometry, wall1)
serialized = json.dumps(result)
parsed = json.loads(serialized)
assert parsed["element"]["type"] == "IfcWall"
def test_clearance_result_serializable(self, model_with_geometry):
wall1 = [w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001"][0]
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 = [w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001"][0]
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 = [w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001"][0]
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 = [w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001"][0]
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)
+108
View File
@@ -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"] == []
+32
View File
@@ -0,0 +1,32 @@
# This file was generated with the assistance of an AI coding tool.
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)
+84
View File
@@ -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
+158
View File
@@ -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)
+221
View File
@@ -0,0 +1,221 @@
# 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 numpy as np
import pytest
from ifcquery.render import 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
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 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
+121
View File
@@ -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"] == []
+32
View File
@@ -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
+32
View File
@@ -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
+34
View File
@@ -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
+37
View File
@@ -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)
+47
View File
@@ -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
File diff suppressed because it is too large Load Diff
-12
View File
@@ -2,10 +2,8 @@
#define GRAPH_2D_H
#ifdef SVGFILL_DEBUG
#if 0
#include <nlohmann/json.hpp>
#endif
#endif
template <typename Kernel>
class Graph2D {
@@ -336,16 +334,6 @@ public:
return Graph2D(input_adjacency_list);
}
template <typename T>
void to_arrangement(T& arr) {
for (auto it = edges_begin(); it != edges_end(); ++it) {
if (it->first == it->second) {
continue;
}
CGAL::insert(arr, CGAL::Segment_2<Kernel>(it->first, it->second));
}
}
void assert_symmetric() {
#ifdef SVGFILL_DEBUG
#if 0