mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-06 07:51:47 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bab00dffc3 | |||
| d4ac3c36ce | |||
| fecd4a810a | |||
| 060afb94b5 | |||
| b1f1de954d | |||
| 12ed2d1ec3 | |||
| c77983ffe5 | |||
| 78f26c2432 | |||
| 39161e7256 |
@@ -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
|
||||
```
|
||||
+300
@@ -0,0 +1,300 @@
|
||||
// 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 }
|
||||
},
|
||||
];
|
||||
|
||||
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}`);
|
||||
}
|
||||
})();
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
@@ -0,0 +1,209 @@
|
||||
<!-- 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"}'
|
||||
```
|
||||
|
||||
## 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)
|
||||
- **ifcedit** modifies IFC models by wrapping `ifcopenshell.api` functions
|
||||
|
||||
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.
|
||||
@@ -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"
|
||||
@@ -0,0 +1,181 @@
|
||||
# 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.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 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")
|
||||
|
||||
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)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,146 @@
|
||||
# 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):
|
||||
"""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: An open IFC model, needed to resolve entity instance references by ID.
|
||||
|
||||
Returns:
|
||||
The converted Python value.
|
||||
|
||||
Raises:
|
||||
ValueError: If the value cannot be converted.
|
||||
TypeError: If the type hint is not supported.
|
||||
"""
|
||||
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)
|
||||
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, model)
|
||||
if args:
|
||||
items = _split_list(value_str)
|
||||
return [coerce_value(item.strip(), args[0], model) 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")
|
||||
|
||||
# entity_instance
|
||||
if _is_entity_type(type_hint):
|
||||
return _coerce_entity(value_str, model)
|
||||
|
||||
# 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, model: ifcopenshell.file | None) -> ifcopenshell.entity_instance:
|
||||
"""Resolve a step ID string like '123' or '#123' to an entity instance."""
|
||||
if model 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 model.by_id(entity_id)
|
||||
except RuntimeError:
|
||||
raise ValueError(f"Entity #{entity_id} not found in model")
|
||||
|
||||
|
||||
def _coerce_entity_list(value_str: str, model: 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(), model) 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 [str(item) for item in parsed]
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return [item.strip() for item in value_str.split(",") if item.strip()]
|
||||
@@ -0,0 +1,274 @@
|
||||
# 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."""
|
||||
if hint is None:
|
||||
return None
|
||||
if hint is type(None):
|
||||
return "None"
|
||||
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())
|
||||
@@ -0,0 +1,113 @@
|
||||
# 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 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 = {}
|
||||
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)
|
||||
try:
|
||||
coerced_kwargs[name] = coerce_value(value_str, hint, model)
|
||||
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
|
||||
@@ -0,0 +1,30 @@
|
||||
[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"]
|
||||
|
||||
[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"
|
||||
@@ -0,0 +1 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
@@ -0,0 +1,42 @@
|
||||
# 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
|
||||
|
||||
|
||||
@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)
|
||||
@@ -0,0 +1,130 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
import json
|
||||
from typing import Literal, Optional, Union
|
||||
|
||||
import ifcopenshell
|
||||
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 TestFallback:
|
||||
def test_no_type_hint(self):
|
||||
assert coerce_value("hello", None) == "hello"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,80 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
import ifcopenshell.api.pset
|
||||
import ifcopenshell.api.root
|
||||
|
||||
from ifcedit.run import run_api, serialize_result
|
||||
|
||||
|
||||
class TestRunApi:
|
||||
def test_create_entity(self, model):
|
||||
result = run_api(model, "root", "create_entity", {"ifc_class": "IfcWall", "name": "NewWall"})
|
||||
assert result["ok"] is True
|
||||
assert result["result"]["type"] == "IfcWall"
|
||||
assert result["result"]["name"] == "NewWall"
|
||||
assert isinstance(result["result"]["id"], int)
|
||||
|
||||
def test_create_entity_default_class(self, model):
|
||||
result = run_api(model, "root", "create_entity", {})
|
||||
assert result["ok"] is True
|
||||
assert result["result"]["type"] == "IfcBuildingElementProxy"
|
||||
|
||||
def test_assign_container(self, model):
|
||||
wall = ifcopenshell.api.root.create_entity(model, ifc_class="IfcWall", name="TestWall2")
|
||||
storey = model.by_type("IfcBuildingStorey")[0]
|
||||
result = run_api(
|
||||
model,
|
||||
"spatial",
|
||||
"assign_container",
|
||||
{"products": str(wall.id()), "relating_structure": str(storey.id())},
|
||||
)
|
||||
assert result["ok"] is True
|
||||
assert result["result"]["type"] == "IfcRelContainedInSpatialStructure"
|
||||
|
||||
def test_add_pset(self, model):
|
||||
wall = model.by_type("IfcWall")[0]
|
||||
result = run_api(model, "pset", "add_pset", {"product": str(wall.id()), "name": "Pset_WallCommon"})
|
||||
assert result["ok"] is True
|
||||
assert result["result"]["type"] == "IfcPropertySet"
|
||||
|
||||
def test_unknown_function(self, model):
|
||||
result = run_api(model, "root", "nonexistent", {})
|
||||
assert result["ok"] is False
|
||||
assert "Cannot find" in result["error"]
|
||||
|
||||
def test_unknown_parameter(self, model):
|
||||
result = run_api(model, "root", "create_entity", {"bogus_param": "value"})
|
||||
assert result["ok"] is False
|
||||
assert "Unknown parameter" in result["error"]
|
||||
|
||||
def test_bad_entity_reference(self, model):
|
||||
result = run_api(model, "pset", "add_pset", {"product": "999999", "name": "Pset_WallCommon"})
|
||||
assert result["ok"] is False
|
||||
assert "not found" in result["error"]
|
||||
|
||||
|
||||
class 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"}
|
||||
@@ -0,0 +1,222 @@
|
||||
<!-- 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"`)
|
||||
|
||||
### 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. **Find** the right API function: `ifc_list`, `ifc_docs`
|
||||
4. **Edit** the model: `ifc_edit`
|
||||
5. **Verify** changes with query tools
|
||||
6. **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.
|
||||
@@ -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"
|
||||
@@ -0,0 +1,5 @@
|
||||
# ifc_cli/src/ifcmcp/ifcmcp/__main__.py
|
||||
from ifcmcp.server import build_server
|
||||
|
||||
server = build_server()
|
||||
server.run(transport="stdio")
|
||||
@@ -0,0 +1,303 @@
|
||||
# ifc_cli/src/ifcmcp/ifcmcp/core.py
|
||||
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.run import run_api
|
||||
from ifcquery import clash as clash_mod
|
||||
from ifcquery import info, relations, select, summary, tree
|
||||
|
||||
|
||||
# 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)
|
||||
|
||||
# ------------------------
|
||||
# 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,
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,59 @@
|
||||
# ifc_cli/src/ifcmcp/ifcmcp/embedded.py
|
||||
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)}
|
||||
@@ -0,0 +1,98 @@
|
||||
# ifc_cli/src/ifcmcp/ifcmcp/server.py
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from ifcmcp.core import IfcSession
|
||||
|
||||
try:
|
||||
from mcp.server.fastmcp import FastMCP # type: ignore
|
||||
except Exception: # pragma: no cover
|
||||
FastMCP = 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)
|
||||
|
||||
return server
|
||||
@@ -0,0 +1,32 @@
|
||||
[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.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"
|
||||
@@ -0,0 +1 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -0,0 +1,253 @@
|
||||
<!-- 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"}
|
||||
]
|
||||
```
|
||||
|
||||
### 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.
|
||||
@@ -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"
|
||||
@@ -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 argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
import ifcopenshell
|
||||
|
||||
from ifcquery import clash as clash_mod
|
||||
from ifcquery import info, relations, select, summary, tree
|
||||
|
||||
|
||||
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)"
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
print(format_output(result, args.output_format))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,167 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
# IfcQuery - IFC model interrogation CLI
|
||||
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
|
||||
#
|
||||
# This file is part of IfcQuery.
|
||||
#
|
||||
# IfcQuery is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# IfcQuery is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcQuery. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import multiprocessing
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import ifcopenshell
|
||||
import ifcopenshell.geom
|
||||
import ifcopenshell.util.element
|
||||
|
||||
|
||||
def _ref(element: ifcopenshell.entity_instance) -> dict[str, Any]:
|
||||
"""Serialize an element to a compact reference dict."""
|
||||
result: dict[str, Any] = {"id": element.id(), "type": element.is_a()}
|
||||
if hasattr(element, "Name") and element.Name:
|
||||
result["name"] = element.Name
|
||||
return result
|
||||
|
||||
|
||||
def _get_scope_elements(
|
||||
model: ifcopenshell.file, element: ifcopenshell.entity_instance, scope: str
|
||||
) -> tuple[set[ifcopenshell.entity_instance], str]:
|
||||
"""Return set of elements to check against and the effective scope used.
|
||||
|
||||
Returns (elements, effective_scope) where effective_scope may differ from
|
||||
the requested scope if fallback was needed.
|
||||
"""
|
||||
if scope == "storey":
|
||||
container = ifcopenshell.util.element.get_container(element)
|
||||
if container is not None:
|
||||
siblings = set(ifcopenshell.util.element.get_contained(container))
|
||||
siblings.discard(element)
|
||||
return siblings, "storey"
|
||||
else:
|
||||
print(
|
||||
f"Warning: Element #{element.id()} has no spatial container, falling back to --scope all",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
# scope == "all" or fallback
|
||||
elements = set(model.by_type("IfcElement"))
|
||||
elements -= set(model.by_type("IfcFeatureElement"))
|
||||
elements.discard(element)
|
||||
return elements, "all"
|
||||
|
||||
|
||||
def _build_tree(model: ifcopenshell.file, elements: set[ifcopenshell.entity_instance]) -> ifcopenshell.geom.tree | None:
|
||||
"""Build geometry tree for given elements using iterator.
|
||||
|
||||
Returns None if iterator fails to initialize (no geometry available).
|
||||
"""
|
||||
geom_settings = ifcopenshell.geom.settings()
|
||||
geom_settings.set("use-world-coords", True)
|
||||
geom_tree = ifcopenshell.geom.tree()
|
||||
iterator = ifcopenshell.geom.iterator(geom_settings, model, multiprocessing.cpu_count(), include=list(elements))
|
||||
if not iterator.initialize():
|
||||
return None
|
||||
while True:
|
||||
geom_tree.add_element(iterator.get())
|
||||
if not iterator.next():
|
||||
break
|
||||
return geom_tree
|
||||
|
||||
|
||||
def _format_clash(clash_result, geom_tree: ifcopenshell.geom.tree, model: ifcopenshell.file) -> dict[str, Any]:
|
||||
"""Format a single clash result to dict."""
|
||||
# clash result .a/.b are C++ wrapper entity_instances without .Name;
|
||||
# look up the Python entity from the model by id for proper serialization
|
||||
other = model.by_id(clash_result.b.id())
|
||||
return {
|
||||
"element": _ref(other),
|
||||
"type": geom_tree.get_clash_type(clash_result.clash_type),
|
||||
"distance": clash_result.distance,
|
||||
"p1": list(clash_result.p1),
|
||||
"p2": list(clash_result.p2),
|
||||
}
|
||||
|
||||
|
||||
def clash(
|
||||
model: ifcopenshell.file,
|
||||
element: ifcopenshell.entity_instance,
|
||||
clearance: float | None = None,
|
||||
tolerance: float = 0.002,
|
||||
scope: str = "storey",
|
||||
) -> dict[str, Any]:
|
||||
"""Check element for geometric clashes against other elements.
|
||||
|
||||
:param model: The IFC model.
|
||||
:param element: The element to check.
|
||||
:param clearance: Minimum clearance distance; if provided, runs clearance check.
|
||||
:param tolerance: Intersection tolerance in meters (default 0.002).
|
||||
:param scope: Which elements to check against: "storey" or "all".
|
||||
:return: Dict with clash results suitable for JSON serialization.
|
||||
"""
|
||||
result: dict[str, Any] = {"element": _ref(element)}
|
||||
|
||||
# Get scope elements
|
||||
scope_elements, effective_scope = _get_scope_elements(model, element, scope)
|
||||
result["scope"] = effective_scope
|
||||
|
||||
if not scope_elements:
|
||||
result["pass"] = True
|
||||
result["checks"] = {"intersection": {"pass": True, "tolerance": tolerance, "clashes": []}}
|
||||
if clearance is not None:
|
||||
result["checks"]["clearance"] = {"pass": True, "clearance": clearance, "clashes": []}
|
||||
return result
|
||||
|
||||
# Build geometry tree for target element + scope elements
|
||||
all_elements = scope_elements | {element}
|
||||
geom_tree = _build_tree(model, all_elements)
|
||||
|
||||
if geom_tree is None:
|
||||
result["pass"] = None
|
||||
result["error"] = f"No geometry for element #{element.id()}"
|
||||
return result
|
||||
|
||||
# Run intersection check
|
||||
intersection_clashes = geom_tree.clash_intersection_many(
|
||||
[element], list(scope_elements), tolerance=tolerance, check_all=True
|
||||
)
|
||||
intersection_results = [_format_clash(c, geom_tree, model) for c in intersection_clashes]
|
||||
checks: dict[str, Any] = {
|
||||
"intersection": {
|
||||
"pass": len(intersection_results) == 0,
|
||||
"tolerance": tolerance,
|
||||
"clashes": intersection_results,
|
||||
}
|
||||
}
|
||||
|
||||
all_pass = len(intersection_results) == 0
|
||||
|
||||
# Run clearance check if requested
|
||||
if clearance is not None:
|
||||
clearance_clashes = geom_tree.clash_clearance_many(
|
||||
[element], list(scope_elements), clearance=clearance, check_all=True
|
||||
)
|
||||
clearance_results = [_format_clash(c, geom_tree, model) for c in clearance_clashes]
|
||||
checks["clearance"] = {
|
||||
"pass": len(clearance_results) == 0,
|
||||
"clearance": clearance,
|
||||
"clashes": clearance_results,
|
||||
}
|
||||
if clearance_results:
|
||||
all_pass = False
|
||||
|
||||
result["pass"] = all_pass
|
||||
result["checks"] = checks
|
||||
return result
|
||||
@@ -0,0 +1,118 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
# IfcQuery - IFC model interrogation CLI
|
||||
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
|
||||
#
|
||||
# This file is part of IfcQuery.
|
||||
#
|
||||
# IfcQuery is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# IfcQuery is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcQuery. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import ifcopenshell
|
||||
import ifcopenshell.util.element
|
||||
import ifcopenshell.util.placement
|
||||
|
||||
|
||||
def _serialize_attribute(value: Any) -> Any:
|
||||
"""Convert an IFC attribute value to a JSON-serializable form."""
|
||||
if isinstance(value, ifcopenshell.entity_instance):
|
||||
return {"id": value.id(), "type": value.is_a()}
|
||||
if isinstance(value, tuple):
|
||||
return [_serialize_attribute(v) for v in value]
|
||||
return value
|
||||
|
||||
|
||||
def _material_to_dict(material: ifcopenshell.entity_instance | None) -> dict[str, Any] | None:
|
||||
"""Convert a material entity to a summary dict."""
|
||||
if material is None:
|
||||
return None
|
||||
result: dict[str, Any] = {
|
||||
"id": material.id(),
|
||||
"type": material.is_a(),
|
||||
}
|
||||
if hasattr(material, "Name"):
|
||||
result["name"] = material.Name
|
||||
return result
|
||||
|
||||
|
||||
def info(model: ifcopenshell.file, element: ifcopenshell.entity_instance) -> dict[str, Any]:
|
||||
"""Return deep inspection data for an element."""
|
||||
result: dict[str, Any] = {
|
||||
"id": element.id(),
|
||||
"type": element.is_a(),
|
||||
}
|
||||
|
||||
# Direct attributes via get_info() which returns a dict of all attributes
|
||||
element_info = element.get_info()
|
||||
attrs = {}
|
||||
for key, value in element_info.items():
|
||||
if key in ("id", "type"):
|
||||
continue
|
||||
attrs[key] = _serialize_attribute(value)
|
||||
result["attributes"] = attrs
|
||||
|
||||
# Property sets and quantity sets
|
||||
try:
|
||||
psets = ifcopenshell.util.element.get_psets(element)
|
||||
if psets:
|
||||
result["property_sets"] = psets
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Element type
|
||||
try:
|
||||
element_type = ifcopenshell.util.element.get_type(element)
|
||||
if element_type:
|
||||
type_info: dict[str, Any] = {
|
||||
"id": element_type.id(),
|
||||
"type": element_type.is_a(),
|
||||
}
|
||||
if hasattr(element_type, "Name"):
|
||||
type_info["name"] = element_type.Name
|
||||
result["element_type"] = type_info
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Material
|
||||
try:
|
||||
material = ifcopenshell.util.element.get_material(element)
|
||||
mat_dict = _material_to_dict(material)
|
||||
if mat_dict:
|
||||
result["material"] = mat_dict
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Spatial container
|
||||
try:
|
||||
container = ifcopenshell.util.element.get_container(element)
|
||||
if container:
|
||||
result["container"] = {
|
||||
"id": container.id(),
|
||||
"type": container.is_a(),
|
||||
"name": container.Name if hasattr(container, "Name") else None,
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Placement (as 4x4 matrix)
|
||||
try:
|
||||
if hasattr(element, "ObjectPlacement") and element.ObjectPlacement:
|
||||
matrix = ifcopenshell.util.placement.get_local_placement(element.ObjectPlacement)
|
||||
result["placement"] = matrix.tolist()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,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)
|
||||
@@ -0,0 +1,41 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
# IfcQuery - IFC model interrogation CLI
|
||||
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
|
||||
#
|
||||
# This file is part of IfcQuery.
|
||||
#
|
||||
# IfcQuery is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# IfcQuery is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcQuery. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import ifcopenshell
|
||||
import ifcopenshell.util.selector
|
||||
|
||||
|
||||
def select(model: ifcopenshell.file, query: str) -> list[dict[str, Any]]:
|
||||
"""Filter elements using selector syntax and return matching element summaries."""
|
||||
elements = ifcopenshell.util.selector.filter_elements(model, query)
|
||||
results = []
|
||||
for element in sorted(elements, key=lambda e: e.id()):
|
||||
entry: dict[str, Any] = {
|
||||
"id": element.id(),
|
||||
"type": element.is_a(),
|
||||
"repr": str(element),
|
||||
}
|
||||
if hasattr(element, "Name"):
|
||||
entry["name"] = element.Name
|
||||
results.append(entry)
|
||||
return results
|
||||
@@ -0,0 +1,52 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
# IfcQuery - IFC model interrogation CLI
|
||||
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
|
||||
#
|
||||
# This file is part of IfcQuery.
|
||||
#
|
||||
# IfcQuery is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# IfcQuery is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcQuery. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from typing import Any
|
||||
|
||||
import ifcopenshell
|
||||
|
||||
|
||||
def summary(model: ifcopenshell.file) -> dict[str, Any]:
|
||||
"""Return a model overview with schema, element counts, and project info."""
|
||||
# Count elements by IFC type, sorted by count descending
|
||||
type_counter: Counter[str] = Counter()
|
||||
total = 0
|
||||
for entity in model:
|
||||
type_counter[entity.is_a()] += 1
|
||||
total += 1
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"schema": model.schema,
|
||||
"total_entities": total,
|
||||
}
|
||||
|
||||
projects = model.by_type("IfcProject")
|
||||
if projects:
|
||||
project = projects[0]
|
||||
result["project"] = {
|
||||
"id": project.id(),
|
||||
"name": project.Name,
|
||||
"description": project.Description,
|
||||
}
|
||||
|
||||
result["types"] = dict(type_counter.most_common())
|
||||
return result
|
||||
@@ -0,0 +1,68 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
# IfcQuery - IFC model interrogation CLI
|
||||
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
|
||||
#
|
||||
# This file is part of IfcQuery.
|
||||
#
|
||||
# IfcQuery is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# IfcQuery is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcQuery. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import ifcopenshell
|
||||
import ifcopenshell.util.element
|
||||
|
||||
|
||||
def _element_summary(element: ifcopenshell.entity_instance) -> dict[str, Any]:
|
||||
"""Return a minimal summary dict for an element."""
|
||||
return {
|
||||
"id": element.id(),
|
||||
"type": element.is_a(),
|
||||
"name": element.Name if hasattr(element, "Name") else None,
|
||||
}
|
||||
|
||||
|
||||
def _build_spatial_node(element: ifcopenshell.entity_instance) -> dict[str, Any]:
|
||||
"""Recursively build a spatial tree node."""
|
||||
node = _element_summary(element)
|
||||
|
||||
# Get aggregated children (Site in Project, Building in Site, Storey in Building, etc.)
|
||||
aggregates = []
|
||||
for rel in getattr(element, "IsDecomposedBy", []):
|
||||
for child in rel.RelatedObjects:
|
||||
aggregates.append(_build_spatial_node(child))
|
||||
|
||||
# Get contained elements (walls, slabs, etc. in a storey/space)
|
||||
contained = []
|
||||
for rel in getattr(element, "ContainsElements", []):
|
||||
for child in rel.RelatedElements:
|
||||
contained.append(_element_summary(child))
|
||||
|
||||
if aggregates:
|
||||
node["children"] = aggregates
|
||||
if contained:
|
||||
node["elements"] = contained
|
||||
|
||||
return node
|
||||
|
||||
|
||||
def tree(model: ifcopenshell.file) -> dict[str, Any] | list[dict[str, Any]]:
|
||||
"""Return the spatial hierarchy tree starting from IfcProject."""
|
||||
projects = model.by_type("IfcProject")
|
||||
if not projects:
|
||||
return {"error": "No IfcProject found in model"}
|
||||
if len(projects) == 1:
|
||||
return _build_spatial_node(projects[0])
|
||||
return [_build_spatial_node(p) for p in projects]
|
||||
@@ -0,0 +1,30 @@
|
||||
[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.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"
|
||||
@@ -0,0 +1 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user