Compare commits

...

48 Commits

Author SHA1 Message Date
Bruno Postle b709f355ae README and pyproject.toml fixes for ifcquery, ifcedit, ifcmcp 2026-03-23 23:41:17 +00:00
Bruno Postle de2d2c4e3b Remove ifcquery build artifacts and add to .gitignore 2026-03-23 23:17:21 +00:00
Bruno Postle 901306a3fd black and ruff 2026-03-23 23:15:08 +00:00
Bruno Postle b52a23cb93 Add geometry.copy_representation API
Deep-copies the named representation from a source element to a target
element.

Generated with the assistance of an AI coding tool.
2026-03-21 07:50:32 +00:00
Bruno Postle b45cbefdd9 ifcquery info: add geometry_summary for Body representations
When an element has a Body representation, ifc_info now includes a
geometry_summary key with representation type and geometry details.

Generated with the assistance of an AI coding tool.
2026-03-20 22:57:54 +00:00
Bruno Postle a95c9fcaab ifcedit discover: fix param/return doc truncation on RST inline roles
Generated with the assistance of an AI coding tool.
2026-03-20 22:14:52 +00:00
Bruno Postle c078b5f255 geometry_creation.rst: add sections for assemblies, clipping normals, openings 2026-03-20 22:07:11 +00:00
Bruno Postle 7197d79dd9 clip_solid/clip_solid_bounded: add element param to register BBIM_Boolean
Add an optional element parameter to geometry.clip_solid and
geometry.clip_solid_bounded. When provided, the resulting
IfcBooleanClippingResult is registered in the element's BBIM_Boolean
property set so that regenerate_wall_representation preserves it during
regeneration.

Generated with the assistance of an AI coding tool.
2026-03-20 21:30:00 +00:00
Bruno Postle c3ca969e32 Doc clarification for api.geometry.add_wall_representation clippings normal
Generated with the assistance of an AI coding tool.
2026-03-20 21:28:47 +00:00
Bruno Postle 49bda7d8ba Doc clarification for api.feature.remove_feature
Generated with the assistance of an AI coding tool.
2026-03-20 20:51:19 +00:00
Bruno Postle 4c1f79f6ab Doc clarification for api.geometry.edit_object_placement
Generated with the assistance of an AI coding tool.
2026-03-20 20:45:45 +00:00
Bruno Postle 5de76cf5f9 Doc clarification for api.sequence.assign_process
Generated with the assistance of an AI coding tool.
2026-03-20 20:36:37 +00:00
Bruno Postle 76f073d9e7 ifcmcp can now save drawings and renders to files
Generated with the assistance of an AI coding tool.
2026-03-20 00:20:39 +00:00
Bruno Postle c23dfcf82e Add geometry.clip_solid_bounded API
Generated with the assistance of an AI coding tool.
2026-03-18 23:01:57 +00:00
Bruno Postle 80922fcd9a Add geometry.clip_solid API and fix half_space_solid docstring
clip_solid(file, item, location, normal) wraps Clipping.apply() to
clip any solid with a half-space plane. Normal points toward the
removed material — same convention as add_wall_representation clippings.

Clarifies that agreement_flag=False means the normal direction is the
void (removed) side.
2026-03-12 21:54:00 +00:00
Bruno Postle 75cfbd40b1 ifcquery: render types with material profile sets
Generated with the assistance of an AI coding tool.
2026-03-11 20:39:24 +00:00
Bruno Postle cd8199d3c4 ifcquery: diagnose empty drawings and fix test fixture
Generated with the assistance of an AI coding tool.
2026-03-10 07:30:16 +00:00
Bruno Postle ad2dc8c38e ifcquery/ifcmcp: fix plot bugs and add ifc_plot MCP tool
Generated with the assistance of an AI coding tool.
2026-03-10 07:14:09 +00:00
Bruno Postle 4d564f00aa Set _HAS_DRAW correctly 2026-03-09 23:10:07 +00:00
Bruno Postle 5b3960c9e5 Merge branch 'tfk-cli-plot' of github.com:IfcOpenShell/IfcOpenShell into ifc_cli 2026-03-09 23:08:00 +00:00
Bruno Postle 01d7c4dea8 ifcquery: render element types when asked
Generated with the assistance of an AI coding tool.
2026-03-09 21:09:09 +00:00
Bruno Postle 1d56736d75 shape_builder: warn about mixed item types in get_representation
Generated with the assistance of an AI coding tool.
2026-03-06 15:18:55 +00:00
Bruno Postle 06548c5db2 ifcmcp: add ifc_shape_list, ifc_shape_docs, ifc_shape tools
Exposes ShapeBuilder geometry methods via the MCP server: discovery
(list), documentation (docs), and execution (shape) with entity ID
coercion and numpy serialisation.

Generated with the assistance of an AI coding tool.
2026-03-05 00:11:52 +00:00
Bruno Postle b02321e3ed ifcmcp: fix tests to use IfcSession directly
Generated with the assistance of an AI coding tool.
2026-03-04 23:38:52 +00:00
Bruno Postle 3d2104d689 shape_builder: complete docstrings and return type annotations
Generated with the assistance of an AI coding tool.
2026-03-04 23:33:58 +00:00
Bruno Postle 9ecc587cb9 coerce: floatify mixed int/float lists in attribute dicts
JSON parses whole numbers as int, but ifcopenshell's C++ binding
requires Python floats for AGGREGATE OF DOUBLE attributes such as
DirectionRatios and Coordinates. Convert list elements to float when
the list already contains at least one float, leaving pure-integer
lists (face indices etc.) unchanged.

Generated with the assistance of an AI coding tool.
2026-03-01 20:27:38 +00:00
Thomas Krijnen e42acdfa86 ifcquery plot subcommand 2026-03-01 21:01:09 +01:00
Bruno Postle bf5b48d037 Add ifc_contexts and ifc_materials tools
New ifcquery modules list geometric representation contexts (with step
IDs, context type, identifier, target view, parent) and material sets
(IfcMaterial, layer sets, constituent sets, profile sets). Exposed as
CLI subcommands and MCP tools.

Generated with the assistance of an AI coding tool.
2026-03-01 19:49:53 +00:00
Bruno Postle 1cb298fa9c render: skip degenerate geometry instead of crashing
Guard the face array reshape so elements whose triangulation is not
divisible by 3 are silently dropped. Also wrap each _add_shape() call
in a try/except so a broken shape never aborts the whole render.

Generated with the assistance of an AI coding tool.
2026-03-01 19:49:13 +00:00
Bruno Postle 1801a768c1 Fix clipping when generating a wall 2026-03-01 17:07:50 +00:00
Bruno Postle 66f0b71c65 Add ifcmcp ifc_render tool and ifcquery render subcommand
Renders IFC model geometry to a PNG image using pyvista (off_screen).

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

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

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

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

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

Generated with the assistance of an AI coding tool.
2026-02-23 23:19:06 +00:00
Bruno Postle fd69c0534b Add validate, schedule, cost, schema, quantify to ifcquery/ifcedit/ifcmcp
ifcquery: validate [--rules], schedule [--depth N], cost [--depth N],
schema <EntityType>. ifcedit: quantify list/run subcommands using ifc5d
QTO rules. schedule and cost support max_depth to limit tree expansion,
replacing truncated levels with {truncated, count}. ifcmcp gains matching
session methods, @server.tool() decorators, and OpenAI tool schemas.

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

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

Optional --traverse up flag walks the hierarchy from the element
up to IfcProject, returning the chain as a list.
2026-02-09 07:20:37 +00:00
Bruno Postle 78f26c2432 Add ifcapi CLI wrapper for ifcopenshell.api editor functions
Runtime introspection auto-discovers all API functions. Subcommands:
list (modules/functions), docs (parameter documentation), run (execute
with type-coerced CLI arguments). Output is JSON to stdout, supports
--dry-run validation and -o alternate output path.
2026-02-09 06:27:00 +00:00
Bruno Postle 39161e7256 Add ifcquery CLI tool for IFC model interrogation
Provides four subcommands for querying IFC models with JSON output:
- summary: schema, entity counts, project info
- tree: spatial hierarchy (Project > Site > Building > Storey > elements)
- info: deep element inspection (attributes, psets, type, material, container)
- select: filter elements using selector syntax
2026-02-08 23:24:48 +00:00
84 changed files with 10290 additions and 49 deletions
+1
View File
@@ -113,3 +113,4 @@ dev_environment.bat
src/ifcopenshell-python/ifcopenshell/express/*.exp
src/ifcopenshell-python/ifcopenshell/express/*.exp.cache.dat
src/ifcquery/build/
+153
View File
@@ -0,0 +1,153 @@
<!-- This file was generated with the assistance of an AI coding tool. -->
# AGENTS.md
Guidelines for AI coding agents contributing to IfcOpenShell. This file is
intended to be read by all AI agents regardless of platform (Claude Code,
Copilot, Cursor, etc.) in addition to any tool-specific configuration files.
Human contributors using AI tools should also read this document carefully,
as they are responsible for ensuring their contributions comply with these
guidelines.
## Project Overview
IfcOpenShell is an open source library for working with Industry Foundation
Classes (IFC). It provides C++ and Python APIs, geometry processing, and an
ecosystem of tools including IfcConvert and the Bonsai Blender add-on.
## Licensing
All contributions must be compatible with the project's licensing:
- **Library code** (everything except Bonsai): **LGPL-3.0-or-later**
- **Bonsai** (`src/bonsai/`): **GPL-3.0-or-later**
There is no Contributor License Agreement (CLA). By submitting a pull request,
you agree that your contribution is licensed under the applicable license above.
## Indicating AI-Generated Code
Contributors must clearly indicate when code has been generated or
substantially written by an AI tool.
### Commits
Commits that modify existing code must include a note in the **body** of the
commit message (not the subject line) indicating that the change was
AI-generated. For example:
```
Fix off-by-one error in element iteration
The loop termination condition was incorrect when processing
IfcRelAggregates relationships.
Generated with the assistance of an AI coding tool.
```
### New Files
New files that are AI-generated must include a comment near the top of the
file indicating this. Use the appropriate comment syntax for the language:
```python
# This file was generated with the assistance of an AI coding tool.
```
```cpp
// This file was generated with the assistance of an AI coding tool.
```
### Pull Requests
Pull requests containing AI-generated code must indicate in the PR description
which parts of the contribution are AI-generated. If the entire PR is
AI-generated, state that clearly. If only specific commits or files are
AI-generated, identify them.
## Pull Request Guidelines
### Scope and Size
- Each pull request should address a **single issue or feature**.
- Do not mix unrelated changes (e.g., bug fixes with refactoring or style
changes) in the same PR.
- Large pull requests should be broken down into **multiple small, standalone
commits** that are each easy to review independently. Rewrite commit history
for this purpose if necessary.
- PRs that are minimal, focused solutions to a specific problem are much more
likely to be accepted.
### What to Avoid
- **Over-engineering**: Do not add features, abstractions, or configurability
beyond what is needed to solve the immediate problem.
- **Scope creep**: Do not make changes to files or code that are not directly
related to the task at hand.
- **Unnecessary additions**: Do not add docstrings, comments, type annotations,
or error handling to code you did not otherwise need to change.
- **Cosmetic changes**: Do not reformat, rename, or reorganize code that is
unrelated to your change.
## Commit Messages
- The **subject line** must be **50 characters or less**.
- Use the **imperative mood** (e.g., "Fix crash in geometry kernel", not
"Fixed crash" or "Fixes crash").
- A commit message can be a single line if the purpose is obvious from the
subject alone.
- Otherwise, add a blank line after the subject followed by a short explanation
of a few lines in the body.
## Code Style
### Python
- **Line length**: 120 characters
- **Formatter**: black
- **Linter**: ruff
- Configuration is in `pyproject.toml`
### C++
- **Standard**: C++17 minimum
- **Formatter**: clang-format (configuration in `.clang-format`)
- **Linter**: clang-tidy (configuration in `.clang-tidy`)
Run linters and formatters **before submitting** your pull request. Do not rely
on CI to catch formatting issues.
## Testing
- Pull requests with test coverage are **much more likely to be merged**.
- If tests are appropriate and feasible for your change, they should be
included.
- Tests are not required for every change (e.g., documentation-only changes),
but the expectation is that testable code changes come with tests.
- Python tests use **pytest** and are located in `test/` or `tests/` directories
within each package under `src/`.
- Run the existing test suite for the package you modified before submitting.
## Architecture Quick Reference
### Directory Structure
- `src/ifcparse/` — C++ IFC file parsing
- `src/ifcgeom/` — C++ geometry processing (OpenCASCADE and CGAL kernels)
- `src/serializers/` — Output format serializers (glTF, Collada, SVG, etc.)
- `src/ifcwrap/` — SWIG Python bindings
- `src/ifcconvert/` — CLI conversion tool
- `src/ifcopenshell-python/` — Python API (`ifcopenshell` package)
- `src/bonsai/` — Blender add-on (GPL-3.0-or-later)
- `src/ifctester/` — IDS model auditing
- `src/ifcpatch/` — IFC file manipulation scripts
- `src/ifcdiff/` — IFC model comparison
- `src/ifcclash/` — Clash detection
- `src/ifccsv/` — Schedule import/export
### IFC Schema Versions
The library supports IFC2x3 TC1, IFC4 Add2 TC1, IFC4x1, IFC4x2, and
IFC4x3 Add2. Schema-specific code is compiled conditionally. Be aware of
which schema versions your change affects.
+21
View File
@@ -0,0 +1,21 @@
IfcOpenShell AI Assistant
=========================
A web-based client-side (pyodide + OpenAI API) model interrogation and generation API based on: ifcedit, ifcquery and ifcmcp packaged in a HTML+JS application.
### Setup instructions
```
mkdir ./src/chat/dist
cd ./src/ifcquery/
python -m build
cp ./dist/ifcquery-0.0.0-py3-none-any.whl ../chat/dist/
cd ../../src/ifcedit
python -m build
cp ./dist/ifcedit-0.0.0-py3-none-any.whl ../chat/dist/
cd ../../src/ifcmcp
python -m build
cp ./dist/ifcmcp-0.0.0-py3-none-any.whl ../chat/dist/
cd ../chat/dist/
wget https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl
```
+320
View File
@@ -0,0 +1,320 @@
// app.js
const $ = (id) => document.getElementById(id);
const statusEl = $("status");
const msgsEl = $("msgs");
const sendBtn = $("send");
const inputEl = $("input");
const apiKeyEl = $("apiKey");
const modelEl = $("model");
const ifcFileEl = $("ifcFile");
const newBtn = $("newModel");
const downloadBtn = $("downloadIfc");
function setBusy(isBusy, reason = "") {
const controls = [
$("send"),
$("newModel"),
$("downloadIfc"),
$("ifcFile"),
];
for (const el of controls) el.disabled = isBusy;
$("input").disabled = isBusy;
const browseBtn = $("browseBtn");
if (browseBtn) {
browseBtn.classList.toggle("disabled", isBusy);
browseBtn.setAttribute("aria-disabled", isBusy ? "true" : "false");
browseBtn.tabIndex = isBusy ? -1 : 0;
}
setStatus(isBusy ? (reason || "Working…") : "Ready");
}
function addMessage(role, text) {
if (text.ok) {
text = text.data;
}
const wrap = document.createElement("div");
wrap.className = `msg ${role}`;
wrap.innerHTML = `
<div class="role">${role}</div>
<div class="bubble"></div>`;
const bubble = wrap.querySelector(".bubble");
bubble.textContent = text;
bubble.onclick = function () {
if (bubble.scrollHeight > 100 && role === "tool") {
bubble.style.maxHeight = bubble.style.maxHeight == 'none' ? '' : 'none';
bubble.style.borderBottom = bubble.style.borderBottom == '' ? 'dotted 2px gray' : '';
}
}
msgsEl.appendChild(wrap);
msgsEl.scrollTop = msgsEl.scrollHeight;
}
function setStatus(text) {
statusEl.textContent = text;
}
const worker = new Worker("./ifc_worker.js", { type: "module" });
function callWorker(type, payload = {}) {
return new Promise((resolve, reject) => {
const id = crypto.randomUUID();
const onMsg = (ev) => {
const msg = ev.data;
if (!msg || msg.id !== id) return;
worker.removeEventListener("message", onMsg);
if (msg.ok) resolve(msg);
else reject(new Error(msg.error || "Worker error"));
};
worker.addEventListener("message", onMsg);
worker.postMessage({ id, type, payload });
});
}
// ---- OpenAI Responses API tool schemas (should match ifcmcp.core openai_tools()) ----
// Docs show Responses API function_call items + function_call_output loop. :contentReference[oaicite:4]{index=4}
const tools = [
{
type: "function", name: "ifc_new", description: "Create a new empty IFC model in memory.",
parameters: { type: "object", properties: { schema: { type: "string" } }, required: [], additionalProperties: false }
},
{
type: "function", name: "ifc_summary", description: "Get a concise overview of the loaded IFC model.",
parameters: { type: "object", properties: {}, required: [], additionalProperties: false }
},
{
type: "function", name: "ifc_tree", description: "Get the full spatial hierarchy tree.",
parameters: { type: "object", properties: {}, required: [], additionalProperties: false }
},
{
type: "function", name: "ifc_select", description: "Select elements using ifcopenshell selector syntax (e.g. 'IfcWall').",
parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"], additionalProperties: false }
},
{
type: "function", name: "ifc_info", description: "Inspect an entity by STEP id.",
parameters: { type: "object", properties: { element_id: { type: "integer" } }, required: ["element_id"], additionalProperties: false }
},
{
type: "function", name: "ifc_relations", description: "Get relationships for an element. traverse='up' walks to IfcProject.",
parameters: {
type: "object", properties: { element_id: { type: "integer" }, traverse: { type: "string" } },
required: ["element_id"], additionalProperties: false
}
},
{
type: "function", name: "ifc_clash", description: "Run clash/clearance checks for an element.",
parameters: {
type: "object", properties: { element_id: { type: "integer" }, clearance: { type: "number" }, tolerance: { type: "number" }, scope: { type: "string" } },
required: ["element_id"], additionalProperties: false
}
},
{
type: "function", name: "ifc_list", description: "List ifcopenshell.api modules or functions within a module.",
parameters: { type: "object", properties: { module: { type: "string" } }, required: [], additionalProperties: false }
},
{
type: "function", name: "ifc_docs", description: "Get documentation for an ifcopenshell.api function, 'module.function'.",
parameters: { type: "object", properties: { function_path: { type: "string" } }, required: ["function_path"], additionalProperties: false }
},
{
type: "function", name: "ifc_edit", description: "Execute an ifcopenshell.api mutation; params is a JSON string of stringly-typed kwargs.",
parameters: { type: "object", properties: { function_path: { type: "string" }, params: { type: "string" } }, required: ["function_path"], additionalProperties: false }
},
{
type: "function", name: "ifc_validate", description: "Validate the loaded model. Returns valid bool and list of issues.",
parameters: { type: "object", properties: { express_rules: { type: "boolean" } }, required: [], additionalProperties: false }
},
{
type: "function", name: "ifc_schedule", description: "List work schedules and nested tasks. Use max_depth=1 for top-level phases only on large projects.",
parameters: { type: "object", properties: { max_depth: { type: "integer" } }, required: [], additionalProperties: false }
},
{
type: "function", name: "ifc_cost", description: "List cost schedules and nested cost items. Use max_depth=1 for top-level sections only on large BoQs.",
parameters: { type: "object", properties: { max_depth: { type: "integer" } }, required: [], additionalProperties: false }
},
{
type: "function", name: "ifc_schema", description: "Return IFC class documentation for an entity type.",
parameters: { type: "object", properties: { entity_type: { type: "string" } }, required: ["entity_type"], additionalProperties: false }
},
{
type: "function", name: "ifc_quantify", description: "Run quantity take-off (QTO) on the model. Modifies model in-place; call ifc_save() after.",
parameters: { type: "object", properties: { rule: { type: "string" }, selector: { type: "string" } }, required: ["rule"], additionalProperties: false }
},
];
const SYSTEM_INSTRUCTIONS = `
You are an IFC copilot running in a browser. You can call tools to inspect or modify the currently loaded IFC model.
Rules:
- If the user asks about model contents (counts, lists, properties, hierarchy), use tools like ifc_summary/ifc_select/ifc_info/ifc_tree.
- If the user asks to change the model, prefer: (1) ifc_list to find candidate API modules, (2) ifc_docs for the exact function signature, then (3) ifc_edit.
- If there is no model and the user wants to create one, call ifc_new.
- After edits, explain what changed and suggest downloading the IFC.
Be concise. Avoid dumping huge trees unless asked.
`;
let inputItems = []; // running conversation state (Responses API style)
async function openAIResponsesCreate({ apiKey, model, input, tools }) {
const res = await fetch("https://api.openai.com/v1/responses", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${apiKey}`,
},
body: JSON.stringify({
model,
instructions: SYSTEM_INSTRUCTIONS,
tools,
input,
}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`OpenAI error ${res.status}: ${text}`);
}
return await res.json();
}
function extractAssistantText(response) {
const out = [];
for (const item of response.output ?? []) {
if (item.type === "message" && item.role === "assistant") {
for (const c of item.content ?? []) {
if (c.type === "output_text") out.push(c.text);
}
}
}
return out.join("\n").trim();
}
async function runAgentTurn(userText) {
const apiKey = apiKeyEl.value.trim();
if (!apiKey) throw new Error("Missing API key");
// Add user message
inputItems.push({ role: "user", content: userText });
// Tool-calling loop (Responses API): append response.output, execute function_call items, append function_call_output.
for (let i = 0; i < 64; i++) {
const response = await openAIResponsesCreate({
apiKey,
model: modelEl.value,
input: inputItems,
tools,
});
// Keep ALL output items (incl reasoning/tool calls) in the running state.
inputItems.push(...(response.output ?? []));
// Show any assistant text immediately
const text = extractAssistantText(response);
if (text) addMessage("assistant", text);
const calls = (response.output ?? []).filter((x) => x.type === "function_call");
if (calls.length === 0) return;
for (const call of calls) {
let args = {};
try { args = call.arguments ? JSON.parse(call.arguments) : {}; }
catch { args = {}; }
addMessage("tool", `${call.name}(${JSON.stringify(args)})`);
const toolRes = await callWorker("toolCall", { name: call.name, args });
// Feed tool result back to the model
inputItems.push({
type: "function_call_output",
call_id: call.call_id,
output: JSON.stringify(toolRes.result),
});
addMessage("tool", `${call.name}: ${JSON.stringify(toolRes.result, null, 2)}`);
}
}
addMessage("assistant", "I hit the tool-call loop limit. Try narrowing your request.");
}
sendBtn.onclick = async () => {
const text = inputEl.value.trim();
if (!text) return;
inputEl.value = "";
addMessage("user", text);
try {
setBusy(true, "Thinking…");
await runAgentTurn(text);
setBusy(false, "Ready");
} catch (e) {
setBusy(true, "Error");
addMessage("assistant", `Error: ${e.message}`);
}
};
inputEl.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendBtn.click();
}
});
ifcFileEl.onchange = async () => {
const f = ifcFileEl.files?.[0];
if (!f) return;
setBusy(true, "Loading IFC into Pyodide…");
const buf = await f.arrayBuffer();
try {
const r = await callWorker("loadIfc", { filename: f.name, bytes: buf }, [buf]);
addMessage("assistant", r.result);
setBusy(false, "Ready");
} catch (e) {
setStatus(true, "Error");
addMessage("assistant", `Load error: ${e.message}`);
}
};
newBtn.onclick = async () => {
try {
setBusy(true, "Creating new model…");
const r = await callWorker("toolCall", { name: "ifc_new", args: { schema: "IFC4" } });
addMessage("assistant", `New model: ${JSON.stringify(r.result)}`);
setBusy(false, "Ready");
} catch (e) {
setBusy(true, "Error");
addMessage("assistant", `Error: ${e.message}`);
}
};
downloadBtn.onclick = async () => {
try {
setBusy(true, "Exporting IFC…");
const r = await callWorker("exportIfc", {});
const blob = new Blob([r.bytes], { type: "application/octet-stream" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = r.filename || "model.ifc";
a.click();
URL.revokeObjectURL(url);
setBusy(false, "Ready");
} catch (e) {
setBusy(true, "Error");
addMessage("assistant", `Export error: ${e.message}`);
}
};
(async () => {
try {
setBusy(true, "Initializing Pyodide and IfcOpenShell for in-memory IFC access…");
await callWorker("init", {});
setBusy(false, "Ready");
} catch (e) {
setBusy(true, "Error");
addMessage("assistant", `Worker init failed: ${e.message}`);
}
})();
+108
View File
@@ -0,0 +1,108 @@
// ifc_worker.js (MODULE WORKER)
import { loadPyodide } from "https://cdn.jsdelivr.net/pyodide/v0.29.3/full/pyodide.mjs";
let pyodide = null;
let callToolPy = null;
let initPromise = null;
function ok(id, extra = {}, transfer = []) {
self.postMessage({ id, ok: true, ...extra }, transfer);
}
function fail(id, error) {
self.postMessage({ id, ok: false, error: String(error?.message || error) });
}
async function ensurePyodide() {
if (initPromise) return initPromise;
initPromise = (async () => {
// Passing indexURL avoids some environments failing to infer it from the module URL. :contentReference[oaicite:3]{index=3}
pyodide = await loadPyodide({
indexURL: "https://cdn.jsdelivr.net/pyodide/v0.29.3/full/",
});
await pyodide.loadPackage("micropip");
await pyodide.loadPackage("numpy");
await pyodide.loadPackage("shapely");
await pyodide.loadPackage("typing-extensions");
const micropip = pyodide.pyimport("micropip");
// Detect python minor version (3.12 vs 3.13) and pick a matching wheel.
const pyVer = pyodide.runPython(`
import sys
f"{sys.version_info.major}.{sys.version_info.minor}"
`);
const wheelUrl =
pyVer === "3.13"
? "https://ifcopenshell.github.io/wasm-wheels/ifcopenshell-0.8.3+34a1bc6-cp313-cp313-emscripten_4_0_9_wasm32.whl"
: "https://ifcopenshell.github.io/wasm-wheels/ifcopenshell-0.8.2+d50e806-cp312-cp312-emscripten_3_1_58_wasm32.whl";
await micropip.install(wheelUrl);
await micropip.install([
"./dist/ifcquery-0.0.0-py3-none-any.whl",
"./dist/ifcedit-0.0.0-py3-none-any.whl",
"./dist/ifcmcp-0.0.0-py3-none-any.whl",
"./dist/lark-1.3.1-py3-none-any.whl",
])
await pyodide.runPythonAsync(`
from ifcmcp.embedded import call_tool as _call_tool
`);
callToolPy = pyodide.globals.get("_call_tool");
})();
return initPromise;
}
function callTool(name, args) {
const pyArgs = pyodide.toPy(args);
const res = callToolPy(name, pyArgs);
pyArgs.destroy();
const resJs = res.toJs({ dict_converter: Object.fromEntries });
res.destroy();
return resJs;
}
self.onmessage = async (ev) => {
const { id, type, payload } = ev.data || {};
try {
if (type === "init") {
await ensurePyodide();
ok(id, { result: "ok" });
return;
}
await ensurePyodide();
if (type === "loadIfc") {
const { filename, bytes } = payload;
const path = `/tmp/${filename || "model.ifc"}`;
pyodide.FS.mkdirTree("/tmp");
pyodide.FS.writeFile(path, new Uint8Array(bytes));
const result = callTool("ifc_load", { path });
ok(id, { result });
return;
}
if (type === "exportIfc") {
const path = "/tmp/export.ifc";
const result = callTool("ifc_save", { path });
const data = pyodide.FS.readFile(path);
ok(id, { result, filename: "export.ifc", bytes: data }, [data.buffer]);
return;
}
if (type === "toolCall") {
const { name, args } = payload;
const result = callTool(name, args || {});
ok(id, { result });
return;
}
throw new Error(`Unknown message type: ${type}`);
} catch (e) {
fail(id, e);
}
};
+294
View File
@@ -0,0 +1,294 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>IfcOpenShell AI Assistant</title>
<style>
* {
box-sizing: border-box;
}
body {
font-family: system-ui, sans-serif;
margin: 0;
}
button,
input,
select,
textarea {
font: inherit;
}
header {
padding: 12px 16px;
border-bottom: 1px solid #ddd;
}
header input,
header select {
padding: 8px;
}
main {
display: grid;
grid-template-columns: 320px 1fr;
height: calc(100vh - 84px);
}
.side {
border-right: 1px solid #ddd;
padding: 12px;
overflow: auto;
}
.chat {
display: flex;
flex-direction: column;
height: 100%;
}
.msgs {
flex: 1;
overflow: auto;
padding: 16px;
}
.msg {
margin: 10px 0;
}
.msg .role {
font-size: 12px;
opacity: 0.7;
margin-bottom: 4px;
}
.msg .bubble {
padding: 10px 12px;
border-radius: 10px;
white-space: pre-wrap;
}
.msg.user .bubble {
background: #e8f0ff;
align-self: flex-end;
}
.msg.assistant .bubble {
background: #f4f4f4;
}
.msg.tool .bubble {
background: #fff6db;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
}
.composer {
display: flex;
gap: 8px;
padding: 12px;
justify-content: center;
}
.composer textarea {
flex: 1;
resize: none;
height: 88px;
border: none;
}
.composer button {
align-self: center;
}
.composer .inner {
border: solid 1px #ddd;
border-radius: 20px;
padding: 10px;
display: flex;
width: 50%;
}
.status {
font-size: 12px;
opacity: 0.7;
}
section>.row>label {
display: block;
font-size: 12px;
opacity: 0.75;
margin-bottom: 6px;
}
.side .row {
margin-bottom: 10px;
}
.row button {
padding: 8px 10px;
}
#model {
background: white;
color: gray;
border: solid 1px #eee;
border-radius: 6px;
}
hr {
border: dashed 1px #ddd;
}
.btn-row {
display: flex;
gap: 10px;
}
.btn-row>.btn,
.btn-row>button.btn {
flex: 1 1 0;
min-width: 0;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 14px;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid #d0d0d0;
background: #eee;
cursor: pointer;
user-select: none;
text-decoration: none;
}
.btn:hover {
background: #ddd;
}
.btn:active {
transform: translateY(1px);
}
.btn-wide {
width: 100%;
}
.material-icons {
font-size: 18px;
line-height: 1;
}
/* Disabled state for label-button + normal buttons */
.btn.disabled,
.btn:disabled {
opacity: 0.55;
cursor: not-allowed;
pointer-events: none;
}
.msg.tool .bubble {
max-height: 100px;
overflow: hidden;
}
#input {
border: none;
outline: none;
}
#input:focus,
#input:focus-visible {
outline: none;
box-shadow: none;
}
</style>
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
</head>
<body>
<header>
<div class="row">
<strong>IfcOpenShell AI Assistant</strong>
<select id="model">
<option value="gpt-5">gpt-5</option>
<option value="gpt-4.1">gpt-4.1</option>
</select>
</div>
<div class="row">
<span class="status" id="status">Booting…</span>
</div>
</header>
<main>
<section class="side">
<div class="row">
<label>OpenAI API key (stored only in memory)</label>
<input id="apiKey" type="password" placeholder="sk-..." autocomplete="off" style="width: 100%;" />
</div>
<hr />
<div class="row">
<label>IFC model (stored only in memory)</label>
<div class="btn-row">
<label class="btn" id="browseBtn" for="ifcFile" role="button" tabindex="0">
<span class="material-icons">folder_open</span>
Browse
</label>
<button class="btn" id="newModel" type="button">
<span class="material-icons">add_box</span>
New IFC
</button>
</div>
<input id="ifcFile" type="file" accept=".ifc,.ifczip,.ifcxml,.zip" hidden />
</div>
<div class="row">
<button class="btn btn-wide" id="downloadIfc" type="button">
<span class="material-icons">download</span>
Download IFC
</button>
</div>
<hr />
<div class="row">
<label>Tips</label>
<div class="status">
• Upload an IFC, then ask “Summarize the model” or “List all IfcWalls”.<br />
• Try “Add a new site and building named X” (will use ifc_edit).
</div>
</div>
</section>
<section class="chat">
<div class="msgs" id="msgs"></div>
<div class="composer">
<div class="inner">
<textarea id="input" placeholder="Ask or instruct about the IFC model…"></textarea>
<button id="send" class="btn">Send <span class="material-icons">send</span></button>
</div>
</div>
</section>
</main>
<script type="module" src="./app.js"></script>
</body>
</html>
+248
View File
@@ -0,0 +1,248 @@
<!-- This file was generated with the assistance of an AI coding tool. -->
# ifcedit
A CLI wrapper that exposes all 350+ `ifcopenshell.api` mutation functions as
shell commands. Functions are auto-discovered at runtime via introspection --
no hardcoded list to maintain.
## Installation
```bash
pip install ifcedit
```
Requires `ifcopenshell`.
## Usage
```
ifcedit <command> [options] [--format json|text]
```
Three subcommands: `list` to discover functions, `docs` to read their
documentation, and `run` to execute them.
## Subcommands
### list
Discover available API modules and their functions.
**List all modules:**
```bash
ifcedit list
```
```json
[
{
"module": "root",
"description": "Functions for creating project-level entities",
"functions": ["create_entity", "remove_product", "copy_class"],
"count": 3
},
{
"module": "spatial",
"description": "Functions for managing spatial relationships",
"functions": ["assign_container", "unassign_container"],
"count": 2
}
]
```
**List functions in a module:**
```bash
ifcedit list root
```
```json
[
{
"name": "create_entity",
"description": "Create an IFC entity with optional initial attributes",
"params": [
{"name": "ifc_class", "type": "str", "required": true},
{"name": "name", "type": "Optional[str]"}
]
}
]
```
### docs
Show full documentation for a specific function, including parameter
descriptions from docstrings and return type.
```bash
ifcedit docs root.create_entity
```
```json
{
"module": "root",
"function": "create_entity",
"description": "Create an IFC entity with optional initial attributes",
"long_description": "This function creates a new entity instance...",
"params": [
{
"name": "ifc_class",
"type": "str",
"required": true,
"description": "The IFC class name (e.g. 'IfcWall', 'IfcProject')"
},
{
"name": "name",
"type": "Optional[str]",
"description": "Optional name attribute"
}
],
"return_type": "ifcopenshell.entity_instance",
"return_description": "The newly created entity instance"
}
```
### run
Execute an API function against an IFC file. Parameters are passed as
`--key value` pairs after the function name.
```bash
ifcedit run model.ifc root.create_entity --ifc_class IfcWall --name "My Wall"
```
```json
{
"ok": true,
"result": {"id": 42, "type": "IfcWall", "name": "My Wall"}
}
```
**Options:**
- `-o, --output <path>` -- write to a different file instead of overwriting the input
- `--dry-run` -- validate parameters without executing or saving
```bash
# Save to a new file
ifcedit run model.ifc root.create_entity -o out.ifc --ifc_class IfcWall
# Validate without executing
ifcedit run model.ifc root.create_entity --dry-run --ifc_class IfcWall
```
Dry-run output shows the resolved parameters:
```json
{
"ok": true,
"dry_run": true,
"module": "root",
"function": "create_entity",
"args": {"ifc_class": "IfcWall", "name": "My Wall"}
}
```
## Parameter type coercion
CLI strings are automatically converted to the types expected by each API
function, using the function's type annotations:
| Type | CLI input | Python value |
|------|-----------|--------------|
| `str` | `"hello"` | `"hello"` |
| `int` | `"42"` or `"#42"` | `42` |
| `float` | `"3.14"` | `3.14` |
| `bool` | `"true"`, `"1"`, `"yes"` | `True` |
| `Optional[X]` | `"none"` | `None` |
| `entity_instance` | `"42"` or `"#42"` | resolved from model by step ID |
| `list[entity_instance]` | `"5,6,7"` or `"[5, 6, 7]"` | list of resolved entities |
| `dict` | `'{"key": "val"}'` | parsed JSON object |
| `Literal["A", "B"]` | `"A"` | validated against allowed values |
## Examples
```bash
# Create a project
ifcedit run model.ifc root.create_entity --ifc_class IfcProject --name "My Project"
# Assign an element to a storey
ifcedit run model.ifc spatial.assign_container --products 10 --relating_structure 4
# Assign multiple elements at once
ifcedit run model.ifc aggregate.assign_object --products "5,6,7" --relating_object 1
# Add a property set
ifcedit run model.ifc pset.add_pset --product 10 --name "Pset_WallCommon"
# Edit properties
ifcedit run model.ifc pset.edit_pset --pset 15 \
--properties '{"IsExternal": true, "FireRating": "2HR"}'
```
### quantify
Run quantity take-off (QTO) on an IFC file, computing physical measurements
(volume, area, length, count, weight) and writing them back as
`IfcElementQuantity` property sets. Uses `ifc5d` rules.
**List available rules:**
```bash
ifcedit quantify list
```
```json
[
{"name": "IFC4QtoBaseQuantities"},
{"name": "IFC4X3QtoBaseQuantities"}
]
```
**Run QTO on a file:**
```bash
ifcedit quantify run model.ifc IFC4QtoBaseQuantities
ifcedit quantify run model.ifc IFC4QtoBaseQuantities --selector IfcWall
ifcedit quantify run model.ifc IFC4QtoBaseQuantities -o model_qto.ifc
```
```json
{"ok": true, "rule": "IFC4QtoBaseQuantities", "elements_quantified": 42}
```
Options:
- `--selector <query>` -- ifcopenshell selector to restrict elements (default: all `IfcElement`)
- `-o, --output <path>` -- write to a different file instead of overwriting the input
Note: `quantify run` writes geometry-based measurements and requires the
IfcOpenShell C++ geometry bindings for elements with computed quantities.
## Error handling
Errors are reported in the JSON response:
```json
{
"ok": false,
"error": "Entity #999 not found in model"
}
```
Exit code is 0 on success, 1 on error.
## Relationship to ifcquery
`ifcedit` and `ifcquery` are complementary tools:
- **ifcquery** reads and inspects IFC models (summary, tree, info, select, relations, clash, validate, schedule, cost, schema, contexts, materials, plot, render)
- **ifcedit** modifies IFC models by wrapping `ifcopenshell.api` functions, and runs QTO via `quantify`
A typical workflow: inspect with `ifcquery`, look up the right API function
with `ifcedit docs`, then apply changes with `ifcedit run`.
## License
LGPLv3+ -- see the IfcOpenShell project license.
+20
View File
@@ -0,0 +1,20 @@
# This file was generated with the assistance of an AI coding tool.
# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
#
# This file is part of IfcEdit.
#
# IfcEdit is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcEdit is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcEdit. If not, see <http://www.gnu.org/licenses/>.
__version__ = version = "0.0.0"
+217
View File
@@ -0,0 +1,217 @@
# This file was generated with the assistance of an AI coding tool.
# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
#
# This file is part of IfcEdit.
#
# IfcEdit is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcEdit is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcEdit. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
import argparse
import json
import sys
import ifcopenshell
from ifcedit.discover import function_docs, list_functions, list_modules
from ifcedit.quantify import list_rules, run_quantify
from ifcedit.run import run_api
def format_output(data, fmt: str) -> str:
if fmt == "json":
return json.dumps(data, indent=2, ensure_ascii=False)
elif fmt == "text":
return _format_text(data)
return json.dumps(data, indent=2, ensure_ascii=False)
def _format_text(data, indent: int = 0) -> str:
prefix = " " * indent
lines = []
if isinstance(data, dict):
for key, value in data.items():
if isinstance(value, (dict, list)):
lines.append(f"{prefix}{key}:")
lines.append(_format_text(value, indent + 1))
else:
lines.append(f"{prefix}{key}: {value}")
elif isinstance(data, list):
for item in data:
if isinstance(item, dict):
lines.append(_format_text(item, indent))
lines.append("")
else:
lines.append(f"{prefix}- {item}")
else:
lines.append(f"{prefix}{data}")
return "\n".join(lines)
def cmd_list(args):
if args.module:
try:
functions = list_functions(args.module)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
print(format_output(functions, args.output_format))
else:
modules = list_modules()
print(format_output(modules, args.output_format))
def cmd_docs(args):
parts = args.function_path.split(".")
if len(parts) != 2:
print("Error: function path must be 'module.function' (e.g. root.create_entity)", file=sys.stderr)
sys.exit(1)
module, function = parts
try:
docs = function_docs(module, function)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
print(format_output(docs, args.output_format))
def cmd_run(args, extra_args):
try:
model = ifcopenshell.open(args.ifc_file)
except Exception as e:
print(f"Error: Could not open IFC file: {e}", file=sys.stderr)
sys.exit(1)
parts = args.function_path.split(".")
if len(parts) != 2:
print("Error: function path must be 'module.function' (e.g. root.create_entity)", file=sys.stderr)
sys.exit(1)
module, function = parts
# Parse extra --key value arguments into a dict
raw_kwargs = _parse_extra_args(extra_args)
if args.dry_run:
result = {"ok": True, "dry_run": True, "module": module, "function": function, "args": raw_kwargs}
else:
result = run_api(model, module, function, raw_kwargs)
if result["ok"]:
output_path = args.output or args.ifc_file
model.write(output_path)
print(format_output(result, args.output_format))
if not result["ok"]:
sys.exit(1)
def _parse_extra_args(extra: list[str]) -> dict[str, str]:
"""Parse a list of ['--key', 'value', ...] into a dict."""
kwargs = {}
i = 0
while i < len(extra):
arg = extra[i]
if arg.startswith("--"):
key = arg[2:]
if i + 1 < len(extra) and not extra[i + 1].startswith("--"):
kwargs[key] = extra[i + 1]
i += 2
else:
# Flag without value — treat as "true"
kwargs[key] = "true"
i += 1
else:
print(f"Error: Unexpected argument: {arg}", file=sys.stderr)
sys.exit(1)
return kwargs
def cmd_quantify(args, extra_args):
if args.quantify_command == "list":
result = list_rules()
print(format_output(result, args.output_format))
elif args.quantify_command == "run":
try:
model = ifcopenshell.open(args.ifc_file)
except Exception as e:
print(f"Error: Could not open IFC file: {e}", file=sys.stderr)
sys.exit(1)
selector = args.selector or None
result = run_quantify(model, args.rule_name, selector=selector)
if result["ok"]:
output_path = args.output or args.ifc_file
model.write(output_path)
print(format_output(result, args.output_format))
if not result["ok"]:
sys.exit(1)
else:
print("Error: quantify requires a subcommand: list or run", file=sys.stderr)
sys.exit(1)
def main():
parser = argparse.ArgumentParser(
prog="ifcedit",
description="CLI wrapper for ifcopenshell.api IFC model mutation functions",
)
parser.add_argument(
"--format",
choices=["json", "text"],
default="json",
dest="output_format",
help="Output format (default: json)",
)
subparsers = parser.add_subparsers(dest="command", required=True)
# list
list_parser = subparsers.add_parser("list", help="List API modules or functions in a module")
list_parser.add_argument("module", nargs="?", help="Module name (omit to list all modules)")
# docs
docs_parser = subparsers.add_parser("docs", help="Show full documentation for an API function")
docs_parser.add_argument("function_path", help="module.function (e.g. root.create_entity)")
# run
run_parser = subparsers.add_parser("run", help="Execute an API function on an IFC file")
run_parser.add_argument("ifc_file", help="Path to the IFC file")
run_parser.add_argument("function_path", help="module.function (e.g. root.create_entity)")
run_parser.add_argument("-o", "--output", help="Output file path (default: overwrite input)")
run_parser.add_argument("--dry-run", action="store_true", help="Validate without executing or saving")
# quantify
quantify_parser = subparsers.add_parser("quantify", help="Quantity take-off (QTO) using ifc5d rules")
quantify_sub = quantify_parser.add_subparsers(dest="quantify_command")
quantify_sub.add_parser("list", help="List available QTO rule names")
qrun_parser = quantify_sub.add_parser("run", help="Run QTO on an IFC file")
qrun_parser.add_argument("ifc_file", help="Path to the IFC file")
qrun_parser.add_argument("rule_name", help="QTO rule name (e.g. IFC4QtoBaseQuantities)")
qrun_parser.add_argument("--selector", help="ifcopenshell selector to restrict elements (default: all IfcElement)")
qrun_parser.add_argument("-o", "--output", help="Output file path (default: overwrite input)")
args, extra = parser.parse_known_args()
if args.command == "list":
cmd_list(args)
elif args.command == "docs":
cmd_docs(args)
elif args.command == "run":
cmd_run(args, extra)
elif args.command == "quantify":
cmd_quantify(args, extra)
if __name__ == "__main__":
main()
+180
View File
@@ -0,0 +1,180 @@
# This file was generated with the assistance of an AI coding tool.
# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
#
# This file is part of IfcEdit.
#
# IfcEdit is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcEdit is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcEdit. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
import json
import typing
import ifcopenshell
def coerce_value(
value_str: str,
type_hint,
model: ifcopenshell.file | None = None,
lookup_file: ifcopenshell.file | None = None,
):
"""Convert a CLI string argument to the proper Python type based on a type hint.
Args:
value_str: The raw string from the CLI.
type_hint: The type annotation from the function signature.
model: The main open IFC model, needed to resolve entity instance references by ID.
lookup_file: Override file for entity resolution (e.g. a library file for
project.append_asset). When provided, entity IDs are looked up here instead
of in model.
Returns:
The converted Python value.
Raises:
ValueError: If the value cannot be converted.
TypeError: If the type hint is not supported.
"""
# When a library file has been opened, entity IDs are resolved from it, not the main model.
effective_lookup = lookup_file if lookup_file is not None else model
if type_hint is None:
return value_str
origin = typing.get_origin(type_hint)
args = typing.get_args(type_hint)
# Union / Optional
if origin is typing.Union:
non_none_types = [a for a in args if a is not type(None)]
if value_str.lower() == "none":
if type(None) in args:
return None
# Try each non-None type in order
for t in non_none_types:
try:
return coerce_value(value_str, t, model, lookup_file)
except (ValueError, TypeError):
continue
raise ValueError(f"Cannot convert '{value_str}' to any of {non_none_types}")
# Literal
if origin is typing.Literal:
allowed = args
if value_str in [str(a) for a in allowed]:
# return the actual literal value with proper type
for a in allowed:
if str(a) == value_str:
return a
raise ValueError(f"'{value_str}' is not one of: {', '.join(repr(a) for a in allowed)}")
# list types
if origin is list:
if args and _is_entity_type(args[0]):
return _coerce_entity_list(value_str, effective_lookup)
if args:
items = _split_list(value_str)
return [coerce_value(item.strip(), args[0], model, lookup_file) for item in items]
return _split_list(value_str)
# dict types
if origin is dict:
return _floatify_numeric_lists(json.loads(value_str))
# Simple types
if type_hint is str:
return value_str
if type_hint is int:
return int(value_str.lstrip("#"))
if type_hint is float:
return float(value_str)
if type_hint is bool:
return value_str.lower() in ("true", "1", "yes")
# ifcopenshell.file — open from path string
if type_hint is ifcopenshell.file:
return ifcopenshell.open(value_str)
# entity_instance
if _is_entity_type(type_hint):
return _coerce_entity(value_str, effective_lookup)
# Fallback: try json.loads for complex types, then plain string
try:
return json.loads(value_str)
except (json.JSONDecodeError, TypeError):
return value_str
def _is_entity_type(hint) -> bool:
"""Check if a type hint refers to ifcopenshell.entity_instance."""
if hint is ifcopenshell.entity_instance:
return True
if isinstance(hint, type) and issubclass(hint, ifcopenshell.entity_instance):
return True
return False
def _coerce_entity(value_str: str | int, lookup_file: ifcopenshell.file | None) -> ifcopenshell.entity_instance:
"""Resolve a step ID string like '123' or '#123' to an entity instance."""
if lookup_file is None:
raise ValueError("Cannot resolve entity reference without an IFC model")
if isinstance(value_str, int):
entity_id = value_str
else:
entity_id = int(value_str.strip().lstrip("#"))
try:
return lookup_file.by_id(entity_id)
except RuntimeError:
raise ValueError(f"Entity #{entity_id} not found in model")
def _coerce_entity_list(value_str: str, lookup_file: ifcopenshell.file | None) -> list[ifcopenshell.entity_instance]:
"""Resolve a comma-separated list of step IDs to entity instances."""
items = _split_list(value_str)
return [_coerce_entity(item.strip(), lookup_file) for item in items]
def _floatify_numeric_lists(obj):
"""Recursively convert lists of numbers to lists of floats.
IFC C++ bindings require Python floats (not ints) for AGGREGATE OF DOUBLE
attributes (e.g. DirectionRatios, Coordinates). JSON parsing produces ints
for whole numbers like 0, which causes a TypeError at the binding level.
"""
if isinstance(obj, dict):
return {k: _floatify_numeric_lists(v) for k, v in obj.items()}
if (
isinstance(obj, list)
and obj
and all(isinstance(v, (int, float)) for v in obj)
and any(isinstance(v, float) for v in obj)
):
return [float(v) for v in obj]
return obj
def _split_list(value_str: str) -> list[str]:
"""Split a comma-separated string, handling JSON arrays too."""
value_str = value_str.strip()
if value_str.startswith("["):
try:
parsed = json.loads(value_str)
if isinstance(parsed, list):
return [json.dumps(item) if isinstance(item, (dict, list)) else str(item) for item in parsed]
except json.JSONDecodeError:
pass
return [item.strip() for item in value_str.split(",") if item.strip()]
+282
View File
@@ -0,0 +1,282 @@
# This file was generated with the assistance of an AI coding tool.
# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
#
# This file is part of IfcEdit.
#
# IfcEdit is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcEdit is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcEdit. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
import importlib
import inspect
import re
import typing
from pathlib import Path
def _api_package_path() -> Path:
"""Return the filesystem path to the ifcopenshell.api package."""
import ifcopenshell.api
return Path(ifcopenshell.api.__file__).parent
def list_modules() -> list[dict]:
"""List all API modules with their function counts and descriptions.
Returns a list of dicts: [{"module": "root", "description": "...", "functions": [...], "count": 4}, ...]
"""
api_path = _api_package_path()
modules = []
for child in sorted(api_path.iterdir()):
if not child.is_dir() or child.name.startswith("_"):
continue
init_file = child / "__init__.py"
if not init_file.exists():
continue
try:
mod = importlib.import_module(f"ifcopenshell.api.{child.name}")
except Exception:
continue
all_names = getattr(mod, "__all__", [])
if not all_names:
continue
description = ""
if mod.__doc__:
description = mod.__doc__.strip().split("\n")[0]
modules.append(
{
"module": child.name,
"description": description,
"functions": list(all_names),
"count": len(all_names),
}
)
return modules
def list_functions(module: str) -> list[dict]:
"""List functions in an API module with one-line descriptions and parameter info.
Returns a list of dicts: [{"name": "create_entity", "description": "...", "params": [...]}]
"""
mod = importlib.import_module(f"ifcopenshell.api.{module}")
all_names = getattr(mod, "__all__", [])
functions = []
for name in all_names:
fn = _get_underlying_function(module, name)
if fn is None:
continue
description = ""
if fn.__doc__:
description = fn.__doc__.strip().split("\n")[0]
params = _extract_params(fn)
functions.append(
{
"name": name,
"description": description,
"params": params,
}
)
return functions
def function_docs(module: str, function: str) -> dict:
"""Full documentation for a single API function.
Returns a dict with: module, function, description, params (with types/defaults/descriptions), return_type
"""
fn = _get_underlying_function(module, function)
if fn is None:
raise ValueError(f"Function '{module}.{function}' not found")
description = ""
long_description = ""
if fn.__doc__:
description, long_description = _parse_docstring_body(fn.__doc__)
params = _extract_params(fn)
param_descriptions = _parse_param_docs(fn.__doc__ or "")
for param in params:
if param["name"] in param_descriptions:
param["description"] = param_descriptions[param["name"]]
return_type = _format_type_hint(typing.get_type_hints(fn).get("return"))
return_description = _parse_return_doc(fn.__doc__ or "")
result = {
"module": module,
"function": function,
"description": description,
"long_description": long_description,
"params": params,
}
if return_type:
result["return_type"] = return_type
if return_description:
result["return_description"] = return_description
return result
def _get_underlying_function(module: str, function: str):
"""Get the actual function object (unwrapping the listener wrapper if needed)."""
try:
fn_module = importlib.import_module(f"ifcopenshell.api.{module}.{function}")
fn = getattr(fn_module, function, None)
return fn
except (ImportError, AttributeError):
return None
def _extract_params(fn) -> list[dict]:
"""Extract parameter info from a function's signature and type hints."""
sig = inspect.signature(fn)
try:
hints = typing.get_type_hints(fn)
except Exception:
hints = {}
params = []
for name, param in sig.parameters.items():
if name == "file" or name == "self":
continue
info = {"name": name}
if name in hints:
info["type"] = _format_type_hint(hints[name])
if param.default is not inspect.Parameter.empty:
info["default"] = _serialize_default(param.default)
else:
info["required"] = True
params.append(info)
return params
def _format_type_hint(hint) -> str | None:
"""Format a type hint to a readable string."""
import ifcopenshell
if hint is None:
return None
if hint is type(None):
return "None"
# ifcopenshell.file params are passed as a file path string
if hint is ifcopenshell.file:
return "file_path"
origin = typing.get_origin(hint)
args = typing.get_args(hint)
# Union (including Optional)
if origin is typing.Union:
formatted = [_format_type_hint(a) for a in args]
# Optional[X] is Union[X, None] — render as "Optional[X]"
if len(formatted) == 2 and "None" in formatted:
inner = next(f for f in formatted if f != "None")
return f"Optional[{inner}]"
return " | ".join(formatted)
# Literal
if origin is typing.Literal:
values = ", ".join(repr(a) for a in args)
return f"Literal[{values}]"
# Generic types (list, dict, etc.)
if origin is not None:
origin_name = getattr(origin, "__name__", str(origin))
if args:
inner = ", ".join(_format_type_hint(a) for a in args)
return f"{origin_name}[{inner}]"
return origin_name
# Simple types
return getattr(hint, "__name__", str(hint))
def _serialize_default(value):
"""Serialize a default value to something JSON-friendly."""
if value is None:
return None
if isinstance(value, (str, int, float, bool)):
return value
return repr(value)
def _parse_docstring_body(docstring: str) -> tuple[str, str]:
"""Parse the summary and long description from a docstring."""
lines = docstring.strip().split("\n")
summary = lines[0].strip() if lines else ""
body_lines = []
in_body = False
for line in lines[1:]:
stripped = line.strip()
if stripped.startswith(":param") or stripped.startswith(":return"):
break
if stripped.startswith("Example"):
break
if not in_body and not stripped:
in_body = True
continue
if in_body:
body_lines.append(stripped)
long_description = " ".join(body_lines).strip()
# collapse multiple spaces
long_description = re.sub(r"\s+", " ", long_description)
return summary, long_description
_FIELD_MARKER = re.compile(r":(?:param|returns?|rtype|type|raises?)\b")
def _parse_param_docs(docstring: str) -> dict[str, str]:
"""Extract :param name: description lines from a docstring."""
params = {}
current_param = None
current_lines = []
for line in docstring.split("\n"):
stripped = line.strip()
match = re.match(r":param\s+(\w+):\s*(.*)", stripped)
if match:
if current_param:
params[current_param] = " ".join(current_lines).strip()
current_param = match.group(1)
current_lines = [match.group(2)]
elif current_param and stripped and not _FIELD_MARKER.match(stripped):
current_lines.append(stripped)
elif _FIELD_MARKER.match(stripped) or (stripped == "" and current_param):
if current_param:
params[current_param] = " ".join(current_lines).strip()
current_param = None
current_lines = []
if current_param:
params[current_param] = " ".join(current_lines).strip()
# collapse whitespace
return {k: re.sub(r"\s+", " ", v) for k, v in params.items()}
def _parse_return_doc(docstring: str) -> str:
"""Extract :return: description from a docstring."""
lines = []
in_return = False
for line in docstring.split("\n"):
stripped = line.strip()
match = re.match(r":return:\s*(.*)", stripped)
if match:
in_return = True
lines = [match.group(1)]
elif in_return:
if _FIELD_MARKER.match(stripped) or stripped == "":
break
lines.append(stripped)
return re.sub(r"\s+", " ", " ".join(lines).strip())
+37
View File
@@ -0,0 +1,37 @@
# This file was generated with the assistance of an AI coding tool.
from __future__ import annotations
from typing import Any
import ifcopenshell
AVAILABLE_RULES = ["IFC4QtoBaseQuantities", "IFC4X3QtoBaseQuantities"]
def list_rules() -> list[dict[str, str]]:
"""Return a list of available quantification rule names."""
return [{"name": name} for name in AVAILABLE_RULES]
def run_quantify(model: ifcopenshell.file, rule: str, selector: str | None = None) -> dict[str, Any]:
"""Run quantity take-off on the model using the named rule.
Modifies the model in-place by adding/updating IfcElementQuantity psets.
Returns a summary dict with ok, rule, and elements_quantified.
"""
from ifc5d.qto import edit_qtos, quantify
from ifc5d.qto import rules as rule_sets
if rule not in rule_sets:
return {"ok": False, "error": f"Unknown rule: {rule}. Available: {list(rule_sets.keys())}"}
import ifcopenshell.util.selector
if selector:
elements = set(ifcopenshell.util.selector.filter_elements(model, selector))
else:
elements = set(model.by_type("IfcElement"))
results = quantify(model, elements, rule_sets[rule])
edit_qtos(model, results)
return {"ok": True, "rule": rule, "elements_quantified": len(results)}
+148
View File
@@ -0,0 +1,148 @@
# This file was generated with the assistance of an AI coding tool.
# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
#
# This file is part of IfcEdit.
#
# IfcEdit is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcEdit is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcEdit. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
import importlib
import inspect
import typing
import ifcopenshell
from ifcedit.coerce import coerce_value
def _is_file_type(hint) -> bool:
"""Check if a type hint refers to ifcopenshell.file (or Optional[ifcopenshell.file])."""
if hint is ifcopenshell.file:
return True
origin = typing.get_origin(hint)
args = typing.get_args(hint)
if origin is typing.Union and ifcopenshell.file in args:
return True
return False
def run_api(
model: ifcopenshell.file,
module: str,
function: str,
raw_kwargs: dict[str, str],
) -> dict:
"""Execute an ifcopenshell.api function with CLI-provided string arguments.
Args:
model: The open IFC model.
module: API module name (e.g. "root").
function: Function name (e.g. "create_entity").
raw_kwargs: String keyword arguments from the CLI.
Returns:
A dict with {"ok": True, "result": ...} on success,
or {"ok": False, "error": "..."} on failure.
"""
try:
fn = _import_function(module, function)
except (ImportError, AttributeError) as e:
return {"ok": False, "error": f"Cannot find function '{module}.{function}': {e}"}
try:
hints = typing.get_type_hints(fn)
except Exception:
hints = {}
sig = inspect.signature(fn)
coerced_kwargs = {}
# Pass 1: coerce ifcopenshell.file-typed params first (e.g. library= in append_asset).
# The opened file is then used as the lookup file for entity resolution in pass 2.
opened_files: list[ifcopenshell.file] = []
for name, value_str in raw_kwargs.items():
if name not in sig.parameters:
return {"ok": False, "error": f"Unknown parameter '{name}' for {module}.{function}"}
hint = hints.get(name)
if not _is_file_type(hint):
continue
try:
coerced = coerce_value(value_str, hint, model)
coerced_kwargs[name] = coerced
if isinstance(coerced, ifcopenshell.file):
opened_files.append(coerced)
except (ValueError, TypeError) as e:
return {"ok": False, "error": f"Cannot convert parameter '{name}': {e}"}
# Pass 2: coerce remaining params. Entity instance IDs are resolved from the opened
# library file (if any), since you are always appending from another file, never
# from the current model.
lookup_file = opened_files[0] if opened_files else None
for name, value_str in raw_kwargs.items():
if name in coerced_kwargs:
continue
if name not in sig.parameters:
return {"ok": False, "error": f"Unknown parameter '{name}' for {module}.{function}"}
hint = hints.get(name)
try:
coerced_kwargs[name] = coerce_value(value_str, hint, model, lookup_file=lookup_file)
except (ValueError, TypeError) as e:
return {"ok": False, "error": f"Cannot convert parameter '{name}': {e}"}
# Determine if the function takes 'file' as its first parameter
first_param = next(iter(sig.parameters), None)
try:
if first_param == "file":
result = fn(model, **coerced_kwargs)
else:
result = fn(**coerced_kwargs)
except Exception as e:
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
return {"ok": True, "result": serialize_result(result)}
def _import_function(module: str, function: str):
"""Import and return the underlying function from ifcopenshell.api."""
fn_module = importlib.import_module(f"ifcopenshell.api.{module}.{function}")
fn = getattr(fn_module, function)
return fn
def serialize_result(value) -> object:
"""Serialize an API result to a JSON-friendly structure."""
if value is None:
return None
if isinstance(value, ifcopenshell.entity_instance):
return _serialize_entity(value)
if isinstance(value, (list, tuple, set, frozenset)):
return [serialize_result(item) for item in value]
if isinstance(value, dict):
return {str(k): serialize_result(v) for k, v in value.items()}
if isinstance(value, (str, int, float, bool)):
return value
return str(value)
def _serialize_entity(entity: ifcopenshell.entity_instance) -> dict:
"""Serialize an entity instance to a summary dict."""
result = {
"id": entity.id(),
"type": entity.is_a(),
}
if hasattr(entity, "Name") and entity.Name:
result["name"] = entity.Name
return result
+33
View File
@@ -0,0 +1,33 @@
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "ifcedit"
version = "0.0.0"
authors = [
{ name="Bruno Postle", email="bruno@postle.net" },
]
description = "CLI wrapper for ifcopenshell.api IFC model mutation functions"
readme = "README.md"
keywords = ["IFC", "BIM", "API"]
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)",
]
dependencies = ["ifcopenshell", "ifc5d"]
[project.scripts]
ifcedit = "ifcedit.__main__:main"
[project.urls]
Homepage = "http://ifcopenshell.org"
Documentation = "https://docs.ifcopenshell.org"
Issues = "https://github.com/IfcOpenShell/IfcOpenShell/issues"
[tool.setuptools.packages.find]
include = ["ifcedit*"]
exclude = ["test*"]
[tool.ruff]
extend = "../../pyproject.toml"
+1
View File
@@ -0,0 +1 @@
# This file was generated with the assistance of an AI coding tool.
+63
View File
@@ -0,0 +1,63 @@
# This file was generated with the assistance of an AI coding tool.
import ifcopenshell
import ifcopenshell.api.aggregate
import ifcopenshell.api.material
import ifcopenshell.api.owner.settings
import ifcopenshell.api.project
import ifcopenshell.api.pset
import ifcopenshell.api.root
import ifcopenshell.api.spatial
import ifcopenshell.api.unit
import pytest
@pytest.fixture
def model():
"""Create an IFC4 model with a spatial hierarchy and a wall."""
f = ifcopenshell.api.project.create_file()
ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0]
ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0]
project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="TestProject")
ifcopenshell.api.unit.assign_unit(f)
site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="TestSite")
building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="TestBuilding")
storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="Ground Floor")
ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project)
ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site)
ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=building)
wall = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall001")
ifcopenshell.api.spatial.assign_container(f, products=[wall], relating_structure=storey)
return f
@pytest.fixture
def model_file(model, tmp_path):
"""Write the model fixture to a temp file and return the path."""
path = tmp_path / "test.ifc"
model.write(str(path))
return str(path)
@pytest.fixture
def library():
"""Create an IFC4 library with a single IfcWallType asset."""
lib = ifcopenshell.api.project.create_file()
ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0]
ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0]
ifcopenshell.api.root.create_entity(lib, ifc_class="IfcProject", name="TestLibrary")
ifcopenshell.api.unit.assign_unit(lib)
ifcopenshell.api.root.create_entity(lib, ifc_class="IfcWallType", name="WAL01")
return lib
@pytest.fixture
def library_file(library, tmp_path):
"""Write the library fixture to a temp file and return the path."""
path = tmp_path / "library.ifc"
library.write(str(path))
return str(path)
+158
View File
@@ -0,0 +1,158 @@
# This file was generated with the assistance of an AI coding tool.
import json
from typing import Literal, Optional, Union
import ifcopenshell
import ifcopenshell.api.project
import pytest
from ifcedit.coerce import coerce_value
class TestStringCoercion:
def test_plain_string(self):
assert coerce_value("hello", str) == "hello"
def test_empty_string(self):
assert coerce_value("", str) == ""
class TestIntCoercion:
def test_plain_int(self):
assert coerce_value("42", int) == 42
def test_hash_prefix(self):
assert coerce_value("#42", int) == 42
def test_negative(self):
assert coerce_value("-5", int) == -5
class TestFloatCoercion:
def test_plain_float(self):
assert coerce_value("3.14", float) == pytest.approx(3.14)
def test_integer_as_float(self):
assert coerce_value("5", float) == 5.0
class TestBoolCoercion:
def test_true_values(self):
for val in ("true", "True", "TRUE", "1", "yes"):
assert coerce_value(val, bool) is True
def test_false_values(self):
for val in ("false", "False", "0", "no"):
assert coerce_value(val, bool) is False
class TestOptionalCoercion:
def test_optional_string(self):
assert coerce_value("hello", Optional[str]) == "hello"
def test_optional_none(self):
assert coerce_value("none", Optional[str]) is None
assert coerce_value("None", Optional[str]) is None
def test_optional_int(self):
assert coerce_value("42", Optional[int]) == 42
class TestUnionCoercion:
def test_union_str_int(self):
# Tries str first (or int first depending on order), both work
result = coerce_value("hello", Union[str, int])
assert result == "hello"
def test_union_int_none(self):
result = coerce_value("42", Union[int, None])
assert result == 42
class TestLiteralCoercion:
def test_valid_literal(self):
assert coerce_value("IFC4", Literal["IFC2X3", "IFC4", "IFC4X3"]) == "IFC4"
def test_invalid_literal(self):
with pytest.raises(ValueError, match="not one of"):
coerce_value("IFC5", Literal["IFC2X3", "IFC4", "IFC4X3"])
class TestDictCoercion:
def test_json_dict(self):
result = coerce_value('{"IsExternal": true, "FireRating": "2HR"}', dict[str, object])
assert result == {"IsExternal": True, "FireRating": "2HR"}
def test_mixed_float_int_list_coerced_to_float(self):
# [0.419, 0, 0.908] — JSON integer 0 mixed with floats must become float
# so ifcopenshell AGGREGATE OF DOUBLE attributes (e.g. DirectionRatios) don't reject the list
result = coerce_value('{"DirectionRatios": [0.419, 0, 0.908]}', dict[str, object])
assert result["DirectionRatios"] == pytest.approx([0.419, 0.0, 0.908])
assert all(isinstance(v, float) for v in result["DirectionRatios"])
def test_pure_int_list_not_coerced(self):
# All-integer lists (e.g. face indices) must stay as ints
result = coerce_value('{"CoordIndex": [0, 1, 2]}', dict[str, object])
assert result["CoordIndex"] == [0, 1, 2]
assert all(isinstance(v, int) for v in result["CoordIndex"])
class TestListCoercion:
def test_comma_separated(self):
result = coerce_value("a,b,c", list[str])
assert result == ["a", "b", "c"]
def test_json_array(self):
result = coerce_value("[1, 2, 3]", list[int])
assert result == [1, 2, 3]
class TestEntityCoercion:
def test_entity_by_id(self, model):
wall = model.by_type("IfcWall")[0]
result = coerce_value(str(wall.id()), ifcopenshell.entity_instance, model)
assert result == wall
def test_entity_with_hash(self, model):
wall = model.by_type("IfcWall")[0]
result = coerce_value(f"#{wall.id()}", ifcopenshell.entity_instance, model)
assert result == wall
def test_entity_not_found(self, model):
with pytest.raises(ValueError, match="not found"):
coerce_value("999999", ifcopenshell.entity_instance, model)
def test_entity_list(self, model):
wall = model.by_type("IfcWall")[0]
result = coerce_value(str(wall.id()), list[ifcopenshell.entity_instance], model)
assert len(result) == 1
assert result[0] == wall
def test_entity_list_multiple(self, model):
wall = model.by_type("IfcWall")[0]
storey = model.by_type("IfcBuildingStorey")[0]
result = coerce_value(f"{wall.id()},{storey.id()}", list[ifcopenshell.entity_instance], model)
assert len(result) == 2
def test_entity_no_model(self):
with pytest.raises(ValueError, match="without an IFC model"):
coerce_value("42", ifcopenshell.entity_instance, None)
class TestFileCoercion:
def test_opens_file_from_path(self, model_file):
result = coerce_value(model_file, ifcopenshell.file)
assert isinstance(result, ifcopenshell.file)
def test_entity_from_lookup_file(self, model_file):
lib = ifcopenshell.open(model_file)
wall = lib.by_type("IfcWall")[0]
empty_model = ifcopenshell.api.project.create_file()
result = coerce_value(str(wall.id()), ifcopenshell.entity_instance, empty_model, lookup_file=lib)
assert result.id() == wall.id()
assert result.is_a("IfcWall")
class TestFallback:
def test_no_type_hint(self):
assert coerce_value("hello", None) == "hello"
+104
View File
@@ -0,0 +1,104 @@
# This file was generated with the assistance of an AI coding tool.
from ifcedit.discover import function_docs, list_functions, list_modules
class TestListModules:
def test_returns_list(self):
result = list_modules()
assert isinstance(result, list)
assert len(result) > 0
def test_module_structure(self):
result = list_modules()
for entry in result:
assert "module" in entry
assert "description" in entry
assert "functions" in entry
assert "count" in entry
assert isinstance(entry["functions"], list)
assert entry["count"] == len(entry["functions"])
def test_known_modules_present(self):
result = list_modules()
module_names = [m["module"] for m in result]
for expected in ("root", "spatial", "pset", "aggregate", "unit"):
assert expected in module_names
def test_root_module_has_functions(self):
result = list_modules()
root = next(m for m in result if m["module"] == "root")
assert "create_entity" in root["functions"]
assert root["count"] >= 3
class TestListFunctions:
def test_root_functions(self):
result = list_functions("root")
assert isinstance(result, list)
names = [f["name"] for f in result]
assert "create_entity" in names
def test_function_structure(self):
result = list_functions("root")
for fn in result:
assert "name" in fn
assert "description" in fn
assert "params" in fn
def test_create_entity_params(self):
result = list_functions("root")
create = next(f for f in result if f["name"] == "create_entity")
param_names = [p["name"] for p in create["params"]]
assert "ifc_class" in param_names
assert "name" in param_names
def test_pset_functions(self):
result = list_functions("pset")
names = [f["name"] for f in result]
assert "add_pset" in names
assert "edit_pset" in names
class TestFunctionDocs:
def test_create_entity_docs(self):
result = function_docs("root", "create_entity")
assert result["module"] == "root"
assert result["function"] == "create_entity"
assert result["description"]
assert isinstance(result["params"], list)
assert len(result["params"]) > 0
def test_params_have_types(self):
result = function_docs("root", "create_entity")
for param in result["params"]:
assert "name" in param
assert "type" in param
def test_params_have_descriptions(self):
result = function_docs("root", "create_entity")
ifc_class = next(p for p in result["params"] if p["name"] == "ifc_class")
assert "description" in ifc_class
assert len(ifc_class["description"]) > 0
def test_return_type(self):
result = function_docs("root", "create_entity")
assert "return_type" in result
def test_assign_container_docs(self):
result = function_docs("spatial", "assign_container")
assert result["module"] == "spatial"
param_names = [p["name"] for p in result["params"]]
assert "products" in param_names
assert "relating_structure" in param_names
def test_unknown_function_raises(self):
import pytest
with pytest.raises(ValueError, match="not found"):
function_docs("root", "nonexistent_function")
def test_edit_pset_docs(self):
result = function_docs("pset", "edit_pset")
param_names = [p["name"] for p in result["params"]]
assert "pset" in param_names
assert "properties" in param_names
+100
View File
@@ -0,0 +1,100 @@
# This file was generated with the assistance of an AI coding tool.
import json
import subprocess
import sys
import pytest
def run_ifcedit(*args):
"""Run ifcedit as a subprocess and return (stdout, stderr, returncode)."""
result = subprocess.run(
[sys.executable, "-m", "ifcedit", *args],
capture_output=True,
text=True,
)
return result.stdout, result.stderr, result.returncode
class TestListCommand:
def test_list_all_modules(self):
stdout, stderr, rc = run_ifcedit("list")
assert rc == 0
data = json.loads(stdout)
assert isinstance(data, list)
module_names = [m["module"] for m in data]
assert "root" in module_names
assert "spatial" in module_names
def test_list_module_functions(self):
stdout, stderr, rc = run_ifcedit("list", "root")
assert rc == 0
data = json.loads(stdout)
assert isinstance(data, list)
names = [f["name"] for f in data]
assert "create_entity" in names
def test_list_text_format(self):
stdout, stderr, rc = run_ifcedit("--format", "text", "list")
assert rc == 0
assert "root" in stdout
class TestDocsCommand:
def test_docs_create_entity(self):
stdout, stderr, rc = run_ifcedit("docs", "root.create_entity")
assert rc == 0
data = json.loads(stdout)
assert data["module"] == "root"
assert data["function"] == "create_entity"
assert "params" in data
def test_docs_invalid_path(self):
stdout, stderr, rc = run_ifcedit("docs", "invalid_path")
assert rc != 0
assert "module.function" in stderr
def test_docs_unknown_function(self):
stdout, stderr, rc = run_ifcedit("docs", "root.nonexistent")
assert rc != 0
class TestRunCommand:
def test_create_entity(self, model_file):
stdout, stderr, rc = run_ifcedit(
"run", model_file, "root.create_entity", "--ifc_class", "IfcWall", "--name", "CLIWall"
)
assert rc == 0, f"stderr: {stderr}"
data = json.loads(stdout)
assert data["ok"] is True
assert data["result"]["type"] == "IfcWall"
assert data["result"]["name"] == "CLIWall"
def test_dry_run(self, model_file):
stdout, stderr, rc = run_ifcedit("run", model_file, "root.create_entity", "--dry-run", "--ifc_class", "IfcWall")
assert rc == 0
data = json.loads(stdout)
assert data["ok"] is True
assert data["dry_run"] is True
def test_output_to_different_file(self, model_file, tmp_path):
output = str(tmp_path / "output.ifc")
stdout, stderr, rc = run_ifcedit(
"run", model_file, "root.create_entity", "-o", output, "--ifc_class", "IfcSlab"
)
assert rc == 0, f"stderr: {stderr}"
data = json.loads(stdout)
assert data["ok"] is True
import os
assert os.path.exists(output)
def test_run_error_bad_function(self, model_file):
stdout, stderr, rc = run_ifcedit("run", model_file, "root.nonexistent")
assert rc != 0
def test_run_invalid_function_path(self, model_file):
stdout, stderr, rc = run_ifcedit("run", model_file, "invalid_path")
assert rc != 0
assert "module.function" in stderr
+87
View File
@@ -0,0 +1,87 @@
# This file was generated with the assistance of an AI coding tool.
from __future__ import annotations
import ifcopenshell
import ifcopenshell.api.aggregate
import ifcopenshell.api.owner.settings
import ifcopenshell.api.project
import ifcopenshell.api.root
import ifcopenshell.api.spatial
import ifcopenshell.api.unit
import pytest
from ifcedit.quantify import AVAILABLE_RULES, list_rules, run_quantify
class TestListRules:
def test_returns_list(self):
result = list_rules()
assert isinstance(result, list)
def test_each_entry_has_name(self):
result = list_rules()
for entry in result:
assert "name" in entry
def test_ifc4_rule_present(self):
result = list_rules()
names = [r["name"] for r in result]
assert "IFC4QtoBaseQuantities" in names
def test_ifc4x3_rule_present(self):
result = list_rules()
names = [r["name"] for r in result]
assert "IFC4X3QtoBaseQuantities" in names
@pytest.fixture
def quantify_model():
"""Create an IFC4 model with a wall element."""
f = ifcopenshell.api.project.create_file()
ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0]
ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0]
project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="TestProject")
ifcopenshell.api.unit.assign_unit(f)
site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="TestSite")
building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="TestBuilding")
storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="Ground Floor")
ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project)
ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site)
ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=building)
wall = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall001")
ifcopenshell.api.spatial.assign_container(f, products=[wall], relating_structure=storey)
return f
class TestRunQuantify:
def test_returns_ok_true(self, quantify_model):
result = run_quantify(quantify_model, "IFC4QtoBaseQuantities")
assert result["ok"] is True
def test_returns_rule_name(self, quantify_model):
result = run_quantify(quantify_model, "IFC4QtoBaseQuantities")
assert result["rule"] == "IFC4QtoBaseQuantities"
def test_returns_elements_quantified(self, quantify_model):
result = run_quantify(quantify_model, "IFC4QtoBaseQuantities")
assert "elements_quantified" in result
assert isinstance(result["elements_quantified"], int)
def test_unknown_rule_returns_error(self, quantify_model):
result = run_quantify(quantify_model, "NonExistentRule")
assert result["ok"] is False
assert "error" in result
def test_selector_restricts_elements(self, quantify_model):
result = run_quantify(quantify_model, "IFC4QtoBaseQuantities", selector="IfcWall")
assert result["ok"] is True
assert result["rule"] == "IFC4QtoBaseQuantities"
def test_empty_selector_runs_on_all(self, quantify_model):
result = run_quantify(quantify_model, "IFC4QtoBaseQuantities", selector=None)
assert result["ok"] is True
+97
View File
@@ -0,0 +1,97 @@
# This file was generated with the assistance of an AI coding tool.
import ifcopenshell
import ifcopenshell.api.project
import ifcopenshell.api.pset
import ifcopenshell.api.root
from ifcedit.run import run_api, serialize_result
class TestRunApi:
def test_create_entity(self, model):
result = run_api(model, "root", "create_entity", {"ifc_class": "IfcWall", "name": "NewWall"})
assert result["ok"] is True
assert result["result"]["type"] == "IfcWall"
assert result["result"]["name"] == "NewWall"
assert isinstance(result["result"]["id"], int)
def test_create_entity_default_class(self, model):
result = run_api(model, "root", "create_entity", {})
assert result["ok"] is True
assert result["result"]["type"] == "IfcBuildingElementProxy"
def test_assign_container(self, model):
wall = ifcopenshell.api.root.create_entity(model, ifc_class="IfcWall", name="TestWall2")
storey = model.by_type("IfcBuildingStorey")[0]
result = run_api(
model,
"spatial",
"assign_container",
{"products": str(wall.id()), "relating_structure": str(storey.id())},
)
assert result["ok"] is True
assert result["result"]["type"] == "IfcRelContainedInSpatialStructure"
def test_add_pset(self, model):
wall = model.by_type("IfcWall")[0]
result = run_api(model, "pset", "add_pset", {"product": str(wall.id()), "name": "Pset_WallCommon"})
assert result["ok"] is True
assert result["result"]["type"] == "IfcPropertySet"
def test_unknown_function(self, model):
result = run_api(model, "root", "nonexistent", {})
assert result["ok"] is False
assert "Cannot find" in result["error"]
def test_unknown_parameter(self, model):
result = run_api(model, "root", "create_entity", {"bogus_param": "value"})
assert result["ok"] is False
assert "Unknown parameter" in result["error"]
def test_bad_entity_reference(self, model):
result = run_api(model, "pset", "add_pset", {"product": "999999", "name": "Pset_WallCommon"})
assert result["ok"] is False
assert "not found" in result["error"]
class TestAppendAsset:
def test_append_asset_from_library(self, model, library_file):
lib = ifcopenshell.open(library_file)
wall_type = lib.by_type("IfcWallType")[0]
result = run_api(
model,
"project",
"append_asset",
{"library": library_file, "element": str(wall_type.id())},
)
assert result["ok"] is True
assert result["result"]["type"] == "IfcWallType"
assert model.by_type("IfcWallType"), "wall type should have been appended to the model"
class TestSerializeResult:
def test_none(self):
assert serialize_result(None) is None
def test_string(self):
assert serialize_result("hello") == "hello"
def test_int(self):
assert serialize_result(42) == 42
def test_entity(self, model):
wall = model.by_type("IfcWall")[0]
result = serialize_result(wall)
assert result["id"] == wall.id()
assert result["type"] == "IfcWall"
assert result["name"] == "Wall001"
def test_list(self, model):
walls = model.by_type("IfcWall")
result = serialize_result(walls)
assert isinstance(result, list)
assert all(isinstance(r, dict) for r in result)
def test_dict(self):
result = serialize_result({"key": "value"})
assert result == {"key": "value"}
+401
View File
@@ -0,0 +1,401 @@
<!-- 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`, and `ifcedit`. The `mcp` package is an optional dependency needed to run the server; install it with `pip install ifcmcp[mcp]` or add `mcp` separately.
## 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_new
Create a new empty IFC model in memory, replacing any currently loaded model.
```
ifc_new()
ifc_new(schema="IFC4X3")
```
Default schema is `IFC4`.
#### 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_reset
Unload the current model from memory, freeing all session state.
```
ifc_reset()
```
#### 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_contexts
List all geometric representation contexts and subcontexts in the loaded model.
```
ifc_contexts()
```
#### ifc_materials
List all materials and material sets in the loaded model, with their assigned elements.
```
ifc_materials()
```
#### ifc_clash
Check an element for geometric intersections and clearance violations.
```
ifc_clash(element_id=10)
ifc_clash(element_id=10, clearance=0.5, scope="all")
```
Parameters:
- `clearance` -- minimum clearance distance in meters (0.0 = no clearance check)
- `tolerance` -- intersection tolerance in meters (default: 0.002)
- `scope` -- `"storey"` or `"all"` (default: `"storey"`)
#### ifc_validate
Check the model for schema and constraint violations.
```
ifc_validate()
ifc_validate(express_rules=True)
```
Returns `{"valid": true, "issues": []}` or `{"valid": false, "issues": [{"level": "ERROR", "message": "..."}]}`.
#### ifc_schedule
List all work schedules and their nested task trees.
```
ifc_schedule()
ifc_schedule(max_depth=1) # top-level phases only
```
`max_depth` limits subtask expansion. At the cutoff, `subtasks` is replaced
with `{"truncated": true, "count": N}` so you know children exist without
fetching them all. Omit for unlimited depth.
#### ifc_cost
List all cost schedules and their nested cost item trees.
```
ifc_cost()
ifc_cost(max_depth=2) # top two levels of the BoQ
```
`max_depth` limits cost item expansion, same truncation convention as
`ifc_schedule`.
#### ifc_schema
Return IFC class documentation for any entity type, using the loaded model's
schema version.
```
ifc_schema(entity_type="IfcWall")
ifc_schema(entity_type="IfcBuildingStorey")
```
Returns description, predefined types, spec URL, and attribute descriptions.
Returns `{"error": "Unknown entity: Foo"}` for unrecognised types.
#### ifc_quantify
Run quantity take-off (QTO) on the loaded model using an `ifc5d` rule.
Computes physical measurements (volume, area, length, count, weight) and
writes them back as `IfcElementQuantity` property sets. Modifies the model
in-place -- call `ifc_save()` when done.
```
ifc_quantify(rule="IFC4QtoBaseQuantities")
ifc_quantify(rule="IFC4QtoBaseQuantities", selector="IfcWall")
```
Available rules: `IFC4QtoBaseQuantities`, `IFC4X3QtoBaseQuantities`.
`selector` is an optional ifcopenshell selector to restrict which elements
are quantified (default: all `IfcElement`).
Returns `{"ok": true, "rule": "...", "elements_quantified": 42}`.
### Drawing and rendering tools
#### ifc_plot
Generate a 2D technical drawing of the loaded model and return it as an inline image.
```
ifc_plot()
ifc_plot(selector="IfcWall", view="floorplan", scale=0.01, output_path="/tmp/plan.svg")
ifc_plot(element_ids=[10, 11], view="floorplan")
```
Parameters:
- `selector` -- ifcopenshell selector to restrict plotted elements
- `element_ids` -- step IDs of elements to highlight; others are faded
- `view` -- `"floorplan"` (default), `"elevation"`, `"section"`, or `"auto"`
- `width_mm`, `height_mm` -- paper size in mm (default: 297 x 420)
- `scale` -- model-to-paper ratio (default: 0.01 = 1:100)
- `png_width`, `png_height` -- raster output size in pixels (default: 1024 x 1024)
- `output_path` -- optional path to also save to disk (`.svg` for vector, otherwise PNG)
Returns an inline PNG the LLM can inspect. Requires `ifcopenshell.draw`.
#### ifc_render
Render the loaded model to a 3D PNG image.
```
ifc_render()
ifc_render(selector="IfcWall", view="iso", output_path="/tmp/model.png")
ifc_render(element_ids=[10, 11], view="south")
```
Parameters:
- `selector` -- ifcopenshell selector to restrict rendered elements
- `element_ids` -- step IDs of elements to highlight; others are shown translucent
- `view` -- `"iso"` (default), `"top"`, `"south"`, `"north"`, `"east"`, or `"west"`
- `output_path` -- optional path to save the PNG to disk
Returns an inline PNG. Requires `pyvista` and the IfcOpenShell C++ geometry bindings.
### Shape builder tools
#### ifc_shape_list
List all available `ShapeBuilder` methods with brief descriptions.
```
ifc_shape_list()
```
#### ifc_shape_docs
Show full documentation for a specific `ShapeBuilder` method.
```
ifc_shape_docs(method="extrude")
ifc_shape_docs(method="create_ellipse")
```
#### ifc_shape
Execute a `ShapeBuilder` method on the loaded model.
```
ifc_shape(method="extrude", params='{"profile": "42", "magnitude": 3.0}')
```
`params` is a JSON string; entity references are resolved by step ID (same coercion as `ifc_edit`).
### Edit discovery tools
#### ifc_list
List all API modules, or functions within a specific module.
```
ifc_list() # all modules
ifc_list(module="root") # functions in the root module
```
#### ifc_docs
Show full documentation for an API function including parameters, types,
defaults, and descriptions.
```
ifc_docs(function_path="root.create_entity")
```
### Edit execution
#### ifc_edit
Execute an `ifcopenshell.api` mutation function. Parameters are passed as a
JSON string with string values that get coerced by ifcedit's type system.
```
ifc_edit(
function_path="root.create_entity",
params='{"ifc_class": "IfcWall", "name": "My Wall"}'
)
```
Returns `{"ok": true, "result": ...}` or `{"ok": false, "error": "..."}`.
Does NOT auto-save -- call `ifc_save()` when ready to write changes to disk.
**Parameter coercion:**
| Type | JSON value | Python value |
|------|------------|--------------|
| `entity_instance` | `"42"` | resolved from model by step ID |
| `list[entity_instance]` | `"5,6,7"` | list of resolved entities |
| `dict` | `'{"key": "val"}'` | parsed JSON object |
| `bool` | `"true"` | `True` |
| `Optional[X]` | `"none"` | `None` |
## Typical workflow
1. **Load** a model: `ifc_load`
2. **Inspect** with query tools: `ifc_summary`, `ifc_tree`, `ifc_select`, `ifc_info`, `ifc_relations`
3. **Validate** if needed: `ifc_validate`
4. **Browse schedules / costs**: `ifc_schedule`, `ifc_cost` (use `max_depth=1` first on large projects)
5. **Look up IFC classes**: `ifc_schema`
6. **Find** the right API function: `ifc_list`, `ifc_docs`
7. **Edit** the model: `ifc_edit`
8. **Quantify** elements: `ifc_quantify` (writes QTO psets in-place)
9. **Verify** changes with query tools
10. **Save** when satisfied: `ifc_save`
The model stays in memory across all calls, so multi-step editing sessions
are fast -- no file I/O between operations.
## License
LGPLv3+ -- see the IfcOpenShell project license.
+20
View File
@@ -0,0 +1,20 @@
# This file was generated with the assistance of an AI coding tool.
# IfcMCP - MCP server for IFC building models
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
#
# This file is part of IfcMCP.
#
# IfcMCP is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcMCP is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcMCP. If not, see <http://www.gnu.org/licenses/>.
__version__ = version = "0.0.0"
+11
View File
@@ -0,0 +1,11 @@
# This file was generated with the assistance of an AI coding tool.
from ifcmcp.server import build_server
def main():
server = build_server()
server.run(transport="stdio")
if __name__ == "__main__":
main()
+731
View File
@@ -0,0 +1,731 @@
# This file was generated with the assistance of an AI coding tool.
from __future__ import annotations
# inside ifcmcp/core.py
import json
from collections.abc import Callable # noqa: F401 — Callable used in helpers below
from dataclasses import dataclass
from typing import Any
import ifcopenshell
from ifcedit.discover import function_docs, list_functions, list_modules
from ifcedit.quantify import run_quantify
from ifcedit.run import run_api
from ifcquery import clash as clash_mod
from ifcquery import contexts as contexts_mod
from ifcquery import cost as cost_mod
from ifcquery import (
info,
relations,
schedule,
schema,
select,
summary,
tree,
)
from ifcquery import (
materials as materials_mod,
)
from ifcquery import (
plot as plot_mod,
)
from ifcquery import (
render as render_mod,
)
from ifcquery import validate as validate_mod
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
# numpy arrays (and any array-like with tolist)
if hasattr(x, "tolist"):
return x.tolist()
# 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)
# ---------------------------------------------------------------------------
# Shape builder helpers
# ---------------------------------------------------------------------------
def _list_shape_methods() -> list[dict]:
"""Introspect ShapeBuilder and return a summary of all public methods."""
import inspect
from ifcedit.discover import _extract_params
from ifcopenshell.util.shape_builder import ShapeBuilder
results = []
for name, fn in inspect.getmembers(ShapeBuilder, predicate=inspect.isfunction):
if name.startswith("_"):
continue
doc = fn.__doc__ or ""
description = doc.strip().split("\n")[0] if doc.strip() else ""
results.append({"method": name, "description": description, "params": _extract_params(fn)})
return results
def _shape_method_docs(method_name: str) -> dict:
"""Return full documentation for a single ShapeBuilder method."""
import typing
from ifcedit.discover import (
_extract_params,
_format_type_hint,
_parse_docstring_body,
_parse_param_docs,
_parse_return_doc,
)
from ifcopenshell.util.shape_builder import ShapeBuilder
if method_name.startswith("_"):
raise ValueError(f"ShapeBuilder has no method '{method_name}'")
fn = getattr(ShapeBuilder, method_name, None)
if fn is None:
raise ValueError(f"ShapeBuilder has no method '{method_name}'")
doc = fn.__doc__ or ""
description, long_description = _parse_docstring_body(doc)
params = _extract_params(fn)
for param in params:
param_desc = _parse_param_docs(doc)
if param["name"] in param_desc:
param["description"] = param_desc[param["name"]]
try:
hints = typing.get_type_hints(fn)
except Exception:
hints = {}
result: dict[str, Any] = {
"method": method_name,
"description": description,
"long_description": long_description,
"params": params,
}
return_type = _format_type_hint(hints.get("return"))
if return_type:
result["return_type"] = return_type
return_description = _parse_return_doc(doc)
if return_description:
result["return_description"] = return_description
return result
def _coerce_shape_params(fn: Callable, raw_kwargs: dict, model: ifcopenshell.file) -> dict:
"""Coerce JSON-parsed kwargs to proper Python types for a ShapeBuilder method."""
import inspect
import typing
sig = inspect.signature(fn)
try:
hints = typing.get_type_hints(fn)
except Exception:
hints = {}
return {
key: _coerce_shape_value(value, hints.get(key), model)
for key, value in raw_kwargs.items()
if key in sig.parameters and key != "self"
}
def _coerce_shape_value(value: Any, hint: Any, model: ifcopenshell.file) -> Any:
"""Convert a single JSON-parsed value to the correct Python type."""
import typing
if hint is None or value is None:
return value
origin = typing.get_origin(hint)
args = typing.get_args(hint)
# Optional[X] / Union — try each non-None branch in order
if origin is typing.Union:
if value is None:
return None
for t in (a for a in args if a is not type(None)):
try:
return _coerce_shape_value(value, t, model)
except (ValueError, TypeError):
continue
return value
# entity_instance: resolve integer or "#N" string step ID
if hint is ifcopenshell.entity_instance or (
isinstance(hint, type) and issubclass(hint, ifcopenshell.entity_instance)
):
entity_id = int(str(value).lstrip("#"))
entity = model.by_id(entity_id)
if entity is None:
raise ValueError(f"Entity #{entity_id} not found in model")
return entity
# Sequence[entity_instance]: resolve each element in the list
import collections.abc
if origin is not None and issubclass(origin, collections.abc.Sequence) and not isinstance(value, str):
if args and (
args[0] is ifcopenshell.entity_instance
or (isinstance(args[0], type) and issubclass(args[0], ifcopenshell.entity_instance))
):
if isinstance(value, (list, tuple)):
return [_coerce_shape_value(v, args[0], model) for v in value]
# bool: JSON gives actual bools; also accept string representations
if hint is bool:
if isinstance(value, bool):
return value
return str(value).lower() in ("true", "1", "yes")
# Everything else (float, int, VectorType lists, dicts, Literals) passes through
return value
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,
)
def ifc_contexts(self) -> list[dict[str, Any]]:
"""List all geometric representation contexts and subcontexts with their step IDs."""
return contexts_mod.contexts(self._require_model())
def ifc_materials(self) -> list[dict[str, Any]]:
"""List all materials and material sets (layers, constituents, profiles)."""
return materials_mod.materials(self._require_model())
# ------------------------
# Edit discovery + execute
# ------------------------
def ifc_list(self, module: str = "") -> list[dict]:
"""List all API modules, or functions within a module. Empty module = all modules."""
return list_functions(module) if module else list_modules()
def ifc_docs(self, function_path: str) -> dict:
"""Show full documentation for an API function. Input format: 'module.function'."""
module, function = function_path.split(".", 1)
return function_docs(module, function)
def ifc_edit(self, function_path: str, params: Any = "{}") -> dict:
"""Execute an ifcopenshell.api mutation.
params may be:
- JSON string
- dict (from tool calling / JS)
- JsProxy (handled upstream in embedded.py)
"""
model = self._require_model()
module, function = function_path.split(".", 1)
if isinstance(params, str):
raw_kwargs = json.loads(params) if params.strip() else {}
elif isinstance(params, dict):
raw_kwargs = params
else:
# e.g. list/None/etc
raw_kwargs = dict(params) if params is not None else {}
res = run_api(model, module, function, raw_kwargs)
return _jsonify(res)
# ------------------------
# Extended query + edit tools
# ------------------------
def ifc_validate(self, express_rules: bool = False) -> dict[str, Any]:
"""Validate the loaded model. Returns {'valid': bool, 'issues': [...]}."""
return validate_mod.validate(self._require_model(), express_rules=express_rules)
def ifc_schedule(self, max_depth: int | None = None) -> list[dict[str, Any]]:
"""List work schedules and nested tasks from the model.
max_depth limits subtask expansion (None = unlimited). At the cutoff,
subtasks is replaced with {"truncated": True, "count": N}.
"""
return schedule.schedule(self._require_model(), max_depth=max_depth)
def ifc_cost(self, max_depth: int | None = None) -> list[dict[str, Any]]:
"""List cost schedules and nested cost items from the model.
max_depth limits cost item expansion (None = unlimited). At the cutoff,
subitems is replaced with {"truncated": True, "count": N}.
"""
return cost_mod.cost(self._require_model(), max_depth=max_depth)
def ifc_schema(self, entity_type: str) -> dict[str, Any]:
"""Return IFC class documentation for entity_type using the model's schema version."""
return schema.schema(self._require_model(), entity_type)
def ifc_plot(
self,
selector: str = "",
element_ids: list[int] | None = None,
view: str = "floorplan",
width_mm: float = 297.0,
height_mm: float = 420.0,
scale: float = 1.0 / 100.0,
png_width: int = 1024,
png_height: int = 1024,
output_format: str = "png",
) -> bytes:
"""Generate a 2D technical drawing (floor plan, elevation, or section) and return image bytes.
Uses ifcopenshell.draw to produce SVG output which is rasterised to PNG via CairoSVG
when output_format is 'png'.
:param selector: ifcopenshell selector to restrict plotted elements
(e.g. ``'IfcWall'``). Omit to plot the whole model.
:param element_ids: Step IDs of elements to highlight. Other elements
are faded to 10% opacity so the subject stands out.
:param view: Drawing view — ``floorplan`` (default), ``elevation``,
``section``, or ``auto``.
:param width_mm: Paper width in mm (default 297 = A4).
:param height_mm: Paper height in mm (default 420 = A4).
:param scale: Model-to-paper scale ratio (default 0.01 = 1:100).
:param png_width: Raster output width in pixels (default 1024).
:param png_height: Raster output height in pixels (default 1024).
:param output_format: ``'svg'`` or ``'png'`` (default ``'png'``).
:return: SVG or PNG bytes depending on output_format.
"""
model = self._require_model()
return plot_mod.plot(
model,
output_format=output_format,
selector=selector if selector else None,
element_ids=element_ids,
view=view,
width_mm=width_mm,
height_mm=height_mm,
scale=scale,
png_width=png_width,
png_height=png_height,
)
def ifc_render(
self,
selector: str = "",
element_ids: list[int] | None = None,
view: str = "iso",
) -> bytes:
"""Render the loaded model to a PNG image and return raw bytes.
:param selector: ifcopenshell selector to restrict rendered elements
(e.g. ``'IfcWall'``). Omit to render the whole model.
:param element_ids: Step IDs of elements to highlight. Other elements
are rendered in translucent grey.
:param view: Camera angle: ``iso``, ``top``, ``south``, ``north``,
``east``, or ``west``.
:return: PNG image as raw bytes.
"""
model = self._require_model()
return render_mod.render(
model,
selector=selector if selector else None,
element_ids=element_ids,
view=view,
)
# ------------------------
# Shape builder tools
# ------------------------
def ifc_shape_list(self) -> list[dict]:
"""List all ShapeBuilder geometry methods with one-line descriptions and parameter names."""
return _list_shape_methods()
def ifc_shape_docs(self, method: str) -> dict:
"""Full documentation for a ShapeBuilder method: params, types, return value."""
return _shape_method_docs(method)
def ifc_shape(self, method: str, params: Any = "{}") -> dict:
"""Call a ShapeBuilder method by name. Returns the created entity's step ID.
params is a JSON string of keyword arguments. Pass entity references as integer
step IDs; vectors as JSON arrays (e.g. [1.0, 0.0, 0.0]).
"""
model = self._require_model()
from ifcopenshell.util.shape_builder import ShapeBuilder
if method.startswith("_"):
raise IfcSessionError(f"Private method '{method}' is not accessible")
fn = getattr(ShapeBuilder, method, None)
if fn is None:
return {"ok": False, "error": f"ShapeBuilder has no method '{method}'"}
if isinstance(params, str):
raw_kwargs = json.loads(params) if params.strip() else {}
elif isinstance(params, dict):
raw_kwargs = params
else:
raw_kwargs = {}
try:
coerced = _coerce_shape_params(fn, raw_kwargs, model)
result = fn(ShapeBuilder(model), **coerced)
return {"ok": True, "result": _jsonify(result)}
except Exception as e:
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
def ifc_quantify(self, rule: str, selector: str = "") -> dict[str, Any]:
"""Run quantity take-off on the model using the named rule.
Modifies the model in-place; call ifc_save() after.
"""
model = self._require_model()
return run_quantify(model, rule, selector=selector if selector else None)
# ------------------------
# Generic dispatcher + tool specs for LLMs
# ------------------------
def dispatch(self, name: str, args: dict[str, Any] | None = None) -> Any:
args = args or {}
fn = getattr(self, name, None)
if not callable(fn):
raise IfcSessionError(f"Unknown tool: {name}")
return _jsonify(fn(**args))
def openai_tools(self) -> list[dict[str, Any]]:
"""Tool schemas in the OpenAI 'Responses API' format (type=function)."""
# Keep schemas tight so the model calls tools correctly.
return [
{
"type": "function",
"name": "ifc_new",
"description": "Create a new empty IFC model in memory.",
"parameters": {
"type": "object",
"properties": {"schema": {"type": "string", "description": "IFC schema, e.g. IFC4"}},
"required": [],
"additionalProperties": False,
},
},
{
"type": "function",
"name": "ifc_summary",
"description": "Get a concise overview of the loaded IFC model.",
"parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": False},
},
{
"type": "function",
"name": "ifc_tree",
"description": "Get the full spatial hierarchy tree.",
"parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": False},
},
{
"type": "function",
"name": "ifc_select",
"description": "Select elements using ifcopenshell selector syntax (e.g. 'IfcWall').",
"parameters": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
"additionalProperties": False,
},
},
{
"type": "function",
"name": "ifc_info",
"description": "Inspect an entity by STEP id.",
"parameters": {
"type": "object",
"properties": {"element_id": {"type": "integer"}},
"required": ["element_id"],
"additionalProperties": False,
},
},
{
"type": "function",
"name": "ifc_relations",
"description": "Get relationships for an element. traverse='up' walks to IfcProject.",
"parameters": {
"type": "object",
"properties": {"element_id": {"type": "integer"}, "traverse": {"type": "string"}},
"required": ["element_id"],
"additionalProperties": False,
},
},
{
"type": "function",
"name": "ifc_clash",
"description": "Run clash/clearance checks for an element.",
"parameters": {
"type": "object",
"properties": {
"element_id": {"type": "integer"},
"clearance": {"type": "number"},
"tolerance": {"type": "number"},
"scope": {"type": "string", "description": "storey or all"},
},
"required": ["element_id"],
"additionalProperties": False,
},
},
{
"type": "function",
"name": "ifc_contexts",
"description": "List all geometric representation contexts and subcontexts with their step IDs, context type, identifier, and target view. Use this to find the context ID required for geometry-creation API calls.",
"parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": False},
},
{
"type": "function",
"name": "ifc_materials",
"description": "List all materials and material sets (IfcMaterial, IfcMaterialLayerSet, IfcMaterialConstituentSet, IfcMaterialProfileSet) with their layers, constituents, or profiles.",
"parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": False},
},
{
"type": "function",
"name": "ifc_list",
"description": "List ifcopenshell.api modules or functions within a module.",
"parameters": {
"type": "object",
"properties": {"module": {"type": "string"}},
"required": [],
"additionalProperties": False,
},
},
{
"type": "function",
"name": "ifc_docs",
"description": "Get documentation for an ifcopenshell.api function, 'module.function'.",
"parameters": {
"type": "object",
"properties": {"function_path": {"type": "string"}},
"required": ["function_path"],
"additionalProperties": False,
},
},
{
"type": "function",
"name": "ifc_edit",
"description": "Execute an ifcopenshell.api mutation; params is a JSON string of stringly-typed kwargs.",
"parameters": {
"type": "object",
"properties": {"function_path": {"type": "string"}, "params": {"type": "string"}},
"required": ["function_path"],
"additionalProperties": False,
},
},
{
"type": "function",
"name": "ifc_validate",
"description": "Validate the loaded model. Returns valid bool and list of issues.",
"parameters": {
"type": "object",
"properties": {
"express_rules": {"type": "boolean", "description": "Also check EXPRESS rules (slower)"}
},
"required": [],
"additionalProperties": False,
},
},
{
"type": "function",
"name": "ifc_schedule",
"description": "List work schedules and nested tasks. Use max_depth=1 for top-level phases only on large projects.",
"parameters": {
"type": "object",
"properties": {
"max_depth": {
"type": "integer",
"description": "Max levels of subtask expansion (omit for unlimited)",
}
},
"required": [],
"additionalProperties": False,
},
},
{
"type": "function",
"name": "ifc_cost",
"description": "List cost schedules and nested cost items. Use max_depth=1 for top-level sections only on large BoQs.",
"parameters": {
"type": "object",
"properties": {
"max_depth": {
"type": "integer",
"description": "Max levels of cost item expansion (omit for unlimited)",
}
},
"required": [],
"additionalProperties": False,
},
},
{
"type": "function",
"name": "ifc_schema",
"description": "Return IFC class documentation for an entity type.",
"parameters": {
"type": "object",
"properties": {"entity_type": {"type": "string", "description": "IFC entity type, e.g. IfcWall"}},
"required": ["entity_type"],
"additionalProperties": False,
},
},
{
"type": "function",
"name": "ifc_quantify",
"description": "Run quantity take-off (QTO) on the model. Modifies model in-place; call ifc_save() after.",
"parameters": {
"type": "object",
"properties": {
"rule": {"type": "string", "description": "QTO rule name, e.g. IFC4QtoBaseQuantities"},
"selector": {
"type": "string",
"description": "ifcopenshell selector to restrict elements (default: all IfcElement)",
},
},
"required": ["rule"],
"additionalProperties": False,
},
},
{
"type": "function",
"name": "ifc_render",
"description": (
"Render the loaded IFC model to a PNG image for visual inspection. "
"Use selector to restrict which elements are rendered (e.g. a single storey). "
"Use element_ids to highlight elements against a greyed-out background. "
"Returns base64-encoded PNG bytes."
),
"parameters": {
"type": "object",
"properties": {
"selector": {"type": "string", "description": "ifcopenshell selector (default: whole model)"},
"element_ids": {
"type": "array",
"items": {"type": "integer"},
"description": "Step IDs of elements to highlight",
},
"view": {
"type": "string",
"enum": ["iso", "top", "south", "north", "east", "west"],
"description": "Camera angle (default: iso)",
},
},
"required": [],
"additionalProperties": False,
},
},
]
+60
View File
@@ -0,0 +1,60 @@
# This file was generated with the assistance of an AI coding tool.
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
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)}
+231
View File
@@ -0,0 +1,231 @@
# This file was generated with the assistance of an AI coding tool.
from __future__ import annotations
import base64
from typing import Any
from ifcmcp.core import IfcSession
try:
from mcp.server.fastmcp import FastMCP # type: ignore
from mcp.types import ImageContent # type: ignore
except Exception: # pragma: no cover
FastMCP = None # type: ignore
ImageContent = None # type: ignore
def build_server() -> Any:
"""Create the FastMCP server if the dependency is available."""
if FastMCP is None:
raise ImportError(
"FastMCP is not installed. Install with: pip install ifcmcp[mcp] " "(or add 'mcp' to your environment)."
)
session = IfcSession()
server = FastMCP(
name="ifc-mcp",
instructions=(
"MCP server for querying and editing IFC building models. "
"Load a file first with ifc_load, then use query/edit tools. "
"Save changes with ifc_save."
),
)
# ---- Lifecycle ----
@server.tool()
def ifc_new(schema: str = "IFC4") -> dict[str, Any]:
return session.ifc_new(schema=schema)
@server.tool()
def ifc_load(path: str) -> str:
return session.ifc_load(path)
@server.tool()
def ifc_save(path: str = "") -> str:
return session.ifc_save(path)
@server.tool()
def ifc_reset() -> dict[str, Any]:
return session.ifc_reset()
# ---- Query ----
@server.tool()
def ifc_summary() -> dict[str, Any]:
return session.ifc_summary()
@server.tool()
def ifc_tree() -> dict[str, Any] | list[dict[str, Any]]:
return session.ifc_tree()
@server.tool()
def ifc_info(element_id: int) -> dict[str, Any]:
return session.ifc_info(element_id)
@server.tool()
def ifc_select(query: str) -> list[dict[str, Any]]:
return session.ifc_select(query)
@server.tool()
def ifc_relations(element_id: int, traverse: str = "") -> dict[str, Any] | list[dict[str, Any]]:
return session.ifc_relations(element_id, traverse=traverse)
@server.tool()
def ifc_clash(
element_id: int,
clearance: float = 0.0,
tolerance: float = 0.002,
scope: str = "storey",
) -> dict[str, Any]:
return session.ifc_clash(
element_id=element_id,
clearance=clearance,
tolerance=tolerance,
scope=scope,
)
@server.tool()
def ifc_contexts() -> list[dict[str, Any]]:
return session.ifc_contexts()
@server.tool()
def ifc_materials() -> list[dict[str, Any]]:
return session.ifc_materials()
# ---- Edit ----
@server.tool()
def ifc_list(module: str = "") -> list[dict]:
return session.ifc_list(module=module)
@server.tool()
def ifc_docs(function_path: str) -> dict:
return session.ifc_docs(function_path=function_path)
@server.tool()
def ifc_edit(function_path: str, params: str = "{}") -> dict:
return session.ifc_edit(function_path=function_path, params=params)
# ---- Extended query + edit ----
@server.tool()
def ifc_validate(express_rules: bool = False) -> dict[str, Any]:
return session.ifc_validate(express_rules=express_rules)
@server.tool()
def ifc_schedule(max_depth: int | None = None) -> list[dict[str, Any]]:
return session.ifc_schedule(max_depth=max_depth)
@server.tool()
def ifc_cost(max_depth: int | None = None) -> list[dict[str, Any]]:
return session.ifc_cost(max_depth=max_depth)
@server.tool()
def ifc_schema(entity_type: str) -> dict[str, Any]:
return session.ifc_schema(entity_type=entity_type)
@server.tool()
def ifc_quantify(rule: str, selector: str = "") -> dict[str, Any]:
return session.ifc_quantify(rule=rule, selector=selector)
# ---- Shape builder ----
@server.tool()
def ifc_shape_list() -> list[dict]:
return session.ifc_shape_list()
@server.tool()
def ifc_shape_docs(method: str) -> dict:
return session.ifc_shape_docs(method=method)
@server.tool()
def ifc_shape(method: str, params: str = "{}") -> dict:
return session.ifc_shape(method=method, params=params)
@server.tool(structured_output=False)
def ifc_plot(
selector: str = "",
element_ids: list[int] | None = None,
view: str = "floorplan",
width_mm: float = 297.0,
height_mm: float = 420.0,
scale: float = 1.0 / 100.0,
png_width: int = 1024,
png_height: int = 1024,
output_path: str = "",
) -> list[ImageContent]:
"""Generate a 2D technical drawing of the loaded IFC model.
Returns an inline PNG image (floor plan, elevation, or section) that the
LLM can inspect to understand the 2D layout of the model. If
``output_path`` is provided the drawing is also saved to disk — as SVG
when the path ends in ``.svg``, otherwise as PNG.
:param selector: ifcopenshell selector to restrict plotted elements
(e.g. ``'IfcWall'``). Omit to plot the whole model.
:param element_ids: Step IDs of elements to highlight. Other elements
are faded so the subject stands out.
:param view: Drawing view — ``floorplan`` (default), ``elevation``,
``section``, or ``auto``.
:param width_mm: Paper width in mm (default 297 = A4 landscape width).
:param height_mm: Paper height in mm (default 420 = A4 landscape height).
:param scale: Model-to-paper scale ratio (default 0.01 = 1:100).
:param png_width: Raster output width in pixels (default 1024).
:param png_height: Raster output height in pixels (default 1024).
:param output_path: Optional file path to save the drawing to disk.
"""
png_bytes = session.ifc_plot(
selector=selector,
element_ids=element_ids,
view=view,
width_mm=width_mm,
height_mm=height_mm,
scale=scale,
png_width=png_width,
png_height=png_height,
output_format="png",
)
if output_path:
if output_path.endswith(".svg"):
svg_bytes = session.ifc_plot(
selector=selector,
element_ids=element_ids,
view=view,
width_mm=width_mm,
height_mm=height_mm,
scale=scale,
output_format="svg",
)
with open(output_path, "wb") as f:
f.write(svg_bytes)
else:
with open(output_path, "wb") as f:
f.write(png_bytes)
return [ImageContent(type="image", data=base64.b64encode(png_bytes).decode(), mimeType="image/png")]
@server.tool(structured_output=False)
def ifc_render(
selector: str = "",
element_ids: list[int] | None = None,
view: str = "iso",
output_path: str = "",
) -> list[ImageContent]:
"""Render the loaded IFC model to a PNG image.
Returns an inline image the LLM can inspect to understand the spatial
layout of the model or a specific element in context. If
``output_path`` is provided the PNG is also saved to that file path.
:param selector: ifcopenshell selector to restrict rendered elements
(e.g. ``'IfcWall'``, ``'IfcBuildingStorey[Name="0"]'``).
Omit to render the whole model.
:param element_ids: Step IDs of elements to highlight. Other elements
are rendered in translucent grey so the subject stands out.
:param view: Camera angle — ``iso`` (default), ``top``, ``south``,
``north``, ``east``, or ``west``.
:param output_path: Optional file path to save the PNG to disk.
"""
png_bytes = session.ifc_render(selector=selector, element_ids=element_ids, view=view)
if output_path:
with open(output_path, "wb") as f:
f.write(png_bytes)
return [ImageContent(type="image", data=base64.b64encode(png_bytes).decode(), mimeType="image/png")]
return server
+36
View File
@@ -0,0 +1,36 @@
[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"
readme = "README.md"
keywords = ["IFC", "BIM", "MCP"]
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)",
]
dependencies = ["ifcopenshell", "ifcquery", "ifcedit"]
[project.optional-dependencies]
mcp = ["mcp"]
[project.scripts]
ifcmcp = "ifcmcp.__main__:main"
[project.urls]
Homepage = "http://ifcopenshell.org"
Documentation = "https://docs.ifcopenshell.org"
Issues = "https://github.com/IfcOpenShell/IfcOpenShell/issues"
[tool.setuptools.packages.find]
include = ["ifcmcp*"]
exclude = ["test*"]
[tool.ruff]
extend = "../../pyproject.toml"
+1
View File
@@ -0,0 +1 @@
# This file was generated with the assistance of an AI coding tool.
+59
View File
@@ -0,0 +1,59 @@
# 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
from ifcmcp.core import IfcSession
@pytest.fixture
def session():
return IfcSession()
@pytest.fixture
def model():
"""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
def loaded_session(model):
"""An IfcSession with an in-memory model already loaded (no file path)."""
s = IfcSession()
s.model = model
return s
+96
View File
@@ -0,0 +1,96 @@
# This file was generated with the assistance of an AI coding tool.
import json
import ifcopenshell
import pytest
from ifcmcp.core import IfcSession, IfcSessionError
class TestNoModel:
def test_edit_no_model(self, session):
with pytest.raises(IfcSessionError, match="No model loaded"):
session.ifc_edit("root.create_entity")
class TestList:
def test_list_all_modules(self, loaded_session):
result = loaded_session.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_session):
result = loaded_session.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_session):
result = loaded_session.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_session):
result = loaded_session.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_session):
with pytest.raises(ValueError):
loaded_session.ifc_docs("no_dot_here")
class TestEdit:
def test_create_entity(self, loaded_session):
result = loaded_session.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_session):
result = loaded_session.ifc_edit("root.create_entity", "{}")
assert result["ok"] is True
def test_unknown_function(self, loaded_session):
result = loaded_session.ifc_edit("root.nonexistent", "{}")
assert result["ok"] is False
assert "Cannot find" in result["error"]
def test_unknown_parameter(self, loaded_session):
result = loaded_session.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_session):
with pytest.raises(json.JSONDecodeError):
loaded_session.ifc_edit("root.create_entity", "not json")
def test_edit_does_not_save(self, loaded_session, tmp_path):
"""Verify that ifc_edit mutates the in-memory model but does not write to disk."""
path = str(tmp_path / "test.ifc")
loaded_session.model.write(path)
loaded_session.model_path = path
before_count = sum(1 for _ in loaded_session.model)
loaded_session.ifc_edit("root.create_entity", json.dumps({"ifc_class": "IfcWall", "name": "Unsaved"}))
after_count = sum(1 for _ in loaded_session.model)
assert after_count == before_count + 1
on_disk = ifcopenshell.open(path)
disk_count = sum(1 for _ in on_disk)
assert disk_count == before_count
def test_assign_container(self, loaded_session):
wall = loaded_session.model.by_type("IfcWall")[0]
storey = loaded_session.model.by_type("IfcBuildingStorey")[0]
result = loaded_session.ifc_edit(
"spatial.assign_container",
json.dumps({"products": str(wall.id()), "relating_structure": str(storey.id())}),
)
assert result["ok"] is True
+113
View File
@@ -0,0 +1,113 @@
# This file was generated with the assistance of an AI coding tool.
import pytest
from ifcmcp.core import IfcSessionError
class TestNoModel:
"""All query tools should fail when no model is loaded."""
def test_summary_no_model(self, session):
with pytest.raises(IfcSessionError, match="No model loaded"):
session.ifc_summary()
def test_tree_no_model(self, session):
with pytest.raises(IfcSessionError, match="No model loaded"):
session.ifc_tree()
def test_info_no_model(self, session):
with pytest.raises(IfcSessionError, match="No model loaded"):
session.ifc_info(1)
def test_select_no_model(self, session):
with pytest.raises(IfcSessionError, match="No model loaded"):
session.ifc_select("IfcWall")
def test_relations_no_model(self, session):
with pytest.raises(IfcSessionError, match="No model loaded"):
session.ifc_relations(1)
class TestSummary:
def test_schema(self, loaded_session):
result = loaded_session.ifc_summary()
assert result["schema"] == "IFC4"
def test_total_entities(self, loaded_session):
result = loaded_session.ifc_summary()
assert result["total_entities"] > 0
def test_project_name(self, loaded_session):
result = loaded_session.ifc_summary()
assert result["project"]["name"] == "TestProject"
def test_type_counts(self, loaded_session):
result = loaded_session.ifc_summary()
assert result["types"]["IfcWall"] == 1
assert result["types"]["IfcSlab"] == 1
class TestTree:
def test_root_is_project(self, loaded_session):
result = loaded_session.ifc_tree()
assert result["type"] == "IfcProject"
assert result["name"] == "TestProject"
def test_hierarchy_depth(self, loaded_session):
result = loaded_session.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_session):
wall = loaded_session.model.by_type("IfcWall")[0]
result = loaded_session.ifc_info(wall.id())
assert result["id"] == wall.id()
assert result["type"] == "IfcWall"
def test_invalid_id(self, loaded_session):
with pytest.raises(Exception):
loaded_session.ifc_info(999999)
class TestSelect:
def test_select_walls(self, loaded_session):
result = loaded_session.ifc_select("IfcWall")
assert len(result) == 1
assert result[0]["type"] == "IfcWall"
assert result[0]["name"] == "Wall001"
def test_select_slabs(self, loaded_session):
result = loaded_session.ifc_select("IfcSlab")
assert len(result) == 1
assert result[0]["name"] == "Slab001"
def test_select_no_match(self, loaded_session):
result = loaded_session.ifc_select("IfcWindow")
assert result == []
class TestRelations:
def test_wall_relations(self, loaded_session):
wall = loaded_session.model.by_type("IfcWall")[0]
result = loaded_session.ifc_relations(wall.id())
assert result["id"] == wall.id()
assert result["type"] == "IfcWall"
assert "hierarchy" in result
def test_traverse_up(self, loaded_session):
wall = loaded_session.model.by_type("IfcWall")[0]
result = loaded_session.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_session):
wall = loaded_session.model.by_type("IfcWall")[0]
result = loaded_session.ifc_relations(wall.id(), traverse="")
assert isinstance(result, dict)
+106
View File
@@ -0,0 +1,106 @@
# This file was generated with the assistance of an AI coding tool.
from unittest.mock import patch
import pytest
from ifcmcp.server import build_server
class TestServerRegistration:
def test_server_name(self):
server = build_server()
assert server.name == "ifc-mcp"
def test_all_tools_registered(self):
server = build_server()
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"
@pytest.fixture
def tool_fns():
"""Return a dict of tool name → raw function from a freshly built server."""
server = build_server()
return {t.name: t.fn for t in server._tool_manager.list_tools()}
PNG_FAKE = b"\x89PNG\r\n\x1a\nFAKE"
SVG_FAKE = b"<svg>FAKE</svg>"
class TestRenderOutputPath:
def test_no_output_path_no_file_written(self, tool_fns, tmp_path):
with patch("ifcmcp.core.IfcSession.ifc_render", return_value=PNG_FAKE):
tool_fns["ifc_render"](selector="", element_ids=None, view="iso", output_path="")
assert list(tmp_path.iterdir()) == []
def test_png_output_path_writes_file(self, tool_fns, tmp_path):
out = str(tmp_path / "render.png")
with patch("ifcmcp.core.IfcSession.ifc_render", return_value=PNG_FAKE):
tool_fns["ifc_render"](selector="", element_ids=None, view="iso", output_path=out)
assert open(out, "rb").read() == PNG_FAKE
class TestPlotOutputPath:
def test_no_output_path_no_file_written(self, tool_fns, tmp_path):
with patch("ifcmcp.core.IfcSession.ifc_plot", return_value=PNG_FAKE):
tool_fns["ifc_plot"](
selector="",
element_ids=None,
view="floorplan",
width_mm=297.0,
height_mm=420.0,
scale=0.01,
png_width=1024,
png_height=1024,
output_path="",
)
assert list(tmp_path.iterdir()) == []
def test_png_output_path_writes_png(self, tool_fns, tmp_path):
out = str(tmp_path / "plot.png")
with patch("ifcmcp.core.IfcSession.ifc_plot", return_value=PNG_FAKE):
tool_fns["ifc_plot"](
selector="",
element_ids=None,
view="floorplan",
width_mm=297.0,
height_mm=420.0,
scale=0.01,
png_width=1024,
png_height=1024,
output_path=out,
)
assert open(out, "rb").read() == PNG_FAKE
def test_svg_output_path_writes_svg(self, tool_fns, tmp_path):
out = str(tmp_path / "plot.svg")
# ifc_plot is called twice: once with "png" for the inline image,
# once with "svg" for the file.
with patch("ifcmcp.core.IfcSession.ifc_plot", side_effect=[PNG_FAKE, SVG_FAKE]):
tool_fns["ifc_plot"](
selector="",
element_ids=None,
view="floorplan",
width_mm=297.0,
height_mm=420.0,
scale=0.01,
png_width=1024,
png_height=1024,
output_path=out,
)
assert open(out, "rb").read() == SVG_FAKE
+62
View File
@@ -0,0 +1,62 @@
# This file was generated with the assistance of an AI coding tool.
from unittest.mock import patch
import ifcopenshell
import pytest
from ifcmcp.core import IfcSession, IfcSessionError
class TestLoad:
def test_load_file(self, session, model_file):
result = session.ifc_load(model_file)
assert "IFC4" in result
assert session.model is not None
assert session.model_path == model_file
def test_load_sets_entity_count(self, session, model_file):
result = session.ifc_load(model_file)
assert "entities" in result
def test_load_nonexistent_file(self, session):
with pytest.raises(Exception):
session.ifc_load("/nonexistent/path/model.ifc")
class TestSave:
def test_save_no_model(self, session):
with pytest.raises(IfcSessionError, match="No model loaded"):
session.ifc_save()
def test_save_overwrites_original(self, session, model_file):
session.ifc_load(model_file)
result = session.ifc_save()
assert model_file in result
def test_save_to_new_path(self, session, model_file, tmp_path):
session.ifc_load(model_file)
new_path = str(tmp_path / "output.ifc")
result = session.ifc_save(new_path)
assert new_path in result
reloaded = ifcopenshell.open(new_path)
assert reloaded.schema == "IFC4"
def test_save_no_path_no_original(self, loaded_session):
with pytest.raises(IfcSessionError, match="No path specified"):
loaded_session.ifc_save()
class TestIfcPlotOutputFormat:
"""ifc_plot should pass output_format through to the underlying plot function."""
def test_default_output_format_is_png(self, loaded_session):
with patch("ifcmcp.core.plot_mod.plot", return_value=b"PNG_FAKE") as mock_plot:
loaded_session.ifc_plot()
mock_plot.assert_called_once()
assert mock_plot.call_args.kwargs["output_format"] == "png"
def test_svg_output_format(self, loaded_session):
with patch("ifcmcp.core.plot_mod.plot", return_value=b"SVG_FAKE") as mock_plot:
result = loaded_session.ifc_plot(output_format="svg")
assert result == b"SVG_FAKE"
assert mock_plot.call_args.kwargs["output_format"] == "svg"
+141
View File
@@ -0,0 +1,141 @@
# This file was generated with the assistance of an AI coding tool.
import json
import pytest
from ifcmcp.core import IfcSessionError
class TestShapeList:
def test_returns_list(self, loaded_session):
result = loaded_session.ifc_shape_list()
assert isinstance(result, list)
assert len(result) > 0
def test_has_expected_methods(self, loaded_session):
result = loaded_session.ifc_shape_list()
names = [m["method"] for m in result]
assert "polyline" in names
assert "rectangle" in names
assert "extrude" in names
assert "profile" in names
assert "get_representation" in names
def test_well_documented_methods_have_descriptions(self, loaded_session):
result = loaded_session.ifc_shape_list()
by_name = {m["method"]: m for m in result}
# These methods have detailed docstrings
for name in ("polyline", "extrude", "rectangle", "profile", "get_representation"):
assert by_name[name]["description"], f"'{name}' has no description"
def test_no_private_methods(self, loaded_session):
result = loaded_session.ifc_shape_list()
assert not any(m["method"].startswith("_") for m in result)
def test_does_not_require_model(self, session):
# ifc_shape_list is pure introspection — no model needed
result = session.ifc_shape_list()
assert isinstance(result, list)
class TestShapeDocs:
def test_extrude_docs(self, loaded_session):
result = loaded_session.ifc_shape_docs("extrude")
assert result["method"] == "extrude"
assert result["description"]
assert "params" in result
param_names = [p["name"] for p in result["params"]]
assert "profile_or_curve" in param_names
assert "magnitude" in param_names
def test_has_return_type(self, loaded_session):
result = loaded_session.ifc_shape_docs("rectangle")
assert "return_type" in result
def test_has_param_descriptions(self, loaded_session):
result = loaded_session.ifc_shape_docs("polyline")
params_with_desc = [p for p in result["params"] if "description" in p]
assert len(params_with_desc) > 0
def test_unknown_method(self, loaded_session):
with pytest.raises(ValueError, match="no method"):
loaded_session.ifc_shape_docs("nonexistent_method")
def test_private_method_rejected(self, loaded_session):
with pytest.raises(ValueError):
loaded_session.ifc_shape_docs("__init__")
def test_does_not_require_model(self, session):
result = session.ifc_shape_docs("circle")
assert result["method"] == "circle"
class TestShapeExecute:
def test_rectangle(self, loaded_session):
result = loaded_session.ifc_shape("rectangle", json.dumps({"size": [4.0, 0.2]}))
assert result["ok"] is True
assert result["result"]["type"] == "IfcIndexedPolyCurve"
def test_circle(self, loaded_session):
result = loaded_session.ifc_shape("circle", json.dumps({"center": [0.0, 0.0], "radius": 0.5}))
assert result["ok"] is True
assert result["result"]["type"] == "IfcCircle"
def test_extrude_chained_from_rectangle(self, loaded_session):
rect = loaded_session.ifc_shape("rectangle", json.dumps({"size": [4.0, 0.2]}))
rect_id = rect["result"]["id"]
result = loaded_session.ifc_shape("extrude", json.dumps({"profile_or_curve": rect_id, "magnitude": 3.0}))
assert result["ok"] is True
assert result["result"]["type"] == "IfcExtrudedAreaSolid"
def test_entity_id_as_integer(self, loaded_session):
"""Entity IDs should be accepted as plain integers (from JSON)."""
rect = loaded_session.ifc_shape("rectangle", json.dumps({"size": [1.0, 1.0]}))
rect_id = rect["result"]["id"]
# Pass as int, not string
result = loaded_session.ifc_shape("extrude", json.dumps({"profile_or_curve": rect_id, "magnitude": 1.0}))
assert result["ok"] is True
def test_rotate_2d_point_returns_list(self, loaded_session):
"""Methods returning numpy arrays should give back plain lists."""
result = loaded_session.ifc_shape(
"rotate_2d_point", json.dumps({"point_2d": [1.0, 0.0], "angle": 90.0, "counter_clockwise": True})
)
assert result["ok"] is True
assert isinstance(result["result"], list)
assert len(result["result"]) == 2
def test_set_polyline_coords_returns_none(self, loaded_session):
"""In-place methods that return None should give ok=True, result=None."""
rect = loaded_session.ifc_shape("rectangle", json.dumps({"size": [2.0, 2.0]}))
rect_id = rect["result"]["id"]
result = loaded_session.ifc_shape(
"set_polyline_coords",
json.dumps({"polyline": rect_id, "coords": [[0.0, 0.0], [3.0, 0.0], [3.0, 3.0], [0.0, 3.0]]}),
)
assert result["ok"] is True
assert result["result"] is None
def test_unknown_method(self, loaded_session):
result = loaded_session.ifc_shape("nonexistent_method", "{}")
assert result["ok"] is False
assert "error" in result
def test_private_method_rejected(self, loaded_session):
with pytest.raises(IfcSessionError):
loaded_session.ifc_shape("__init__", "{}")
def test_no_model_raises(self, session):
with pytest.raises(IfcSessionError, match="No model loaded"):
session.ifc_shape("rectangle", "{}")
def test_params_as_dict(self, loaded_session):
"""params can be passed as a dict (not just a JSON string)."""
result = loaded_session.ifc_shape("rectangle", {"size": [2.0, 1.0]})
assert result["ok"] is True
def test_error_on_bad_params(self, loaded_session):
"""Bad parameters should give ok=False with an error message."""
result = loaded_session.ifc_shape("extrude", json.dumps({"profile_or_curve": 999999, "magnitude": 1.0}))
assert result["ok"] is False
assert "error" in result
@@ -775,3 +775,52 @@ responsibility to make sure the geometry is correct.
# Assign our new body geometry back to our beam
ifcopenshell.api.geometry.assign_representation(model, product=beam, representation=representation)
Moving assemblies
-----------------
When moving an assembly and you want all children to follow, pass
``should_transform_children=True``. The default (``False``) rewrites each
child's local placement to preserve its world position, so the parent moves
but the children stay where they are.
.. code-block:: python
matrix = numpy.eye(4)
matrix[:,3][0:3] = (0, 0, 6)
# Move the assembly; children travel with it.
ifcopenshell.api.geometry.edit_object_placement(model,
product=assembly, matrix=matrix, is_si=True,
should_transform_children=True)
Clipping normals convention
---------------------------
The ``normal`` passed to :func:`geometry.clip_solid`,
:func:`geometry.clip_solid_bounded`, and the ``clippings`` parameter of
:func:`geometry.add_wall_representation` points toward the **removed**
material (the discarded side), not toward the kept material.
.. code-block:: python
# Clip the top of a wall to a lean-to slope.
# normal points upward into the wedge that will be removed.
bcr = ifcopenshell.api.geometry.clip_solid(model,
item=extrusion,
location=[0.0, 0.0, 3.26],
normal=[0.419, 0.0, 0.908])
shape_representation.RepresentationType = "Clipping"
Opening lifecycle
-----------------
``feature.remove_feature`` permanently deletes the feature entity from the
model. Any fillings (windows, doors) that occupied the opening become
orphaned and must be separately removed via ``root.remove_product``.
.. code-block:: python
# Remove a window and its opening from a wall.
ifcopenshell.api.root.remove_product(model, product=window)
ifcopenshell.api.feature.remove_feature(model, feature=opening)
@@ -22,11 +22,13 @@ import ifcopenshell.util.element
def remove_feature(file: ifcopenshell.file, feature: ifcopenshell.entity_instance) -> None:
"""Remove a feature
"""Permanently delete a feature element and its void or projection relationship.
Fillings are retained as orphans. Featured elements remain. Features
cannot exist by themselves, so not only is the relationship removed, the
feature is also removed.
The feature entity (e.g. IfcOpeningElement) is removed from the model
along with its IfcRelVoidsElement or IfcRelProjectsElement relationship.
The host element (wall, slab, etc.) is unaffected. Any fillings (windows,
doors) that occupied the opening become orphaned and must be separately
deleted via root.remove_product.
:param feature: The IfcFeatureElement to remove.
@@ -26,6 +26,8 @@ geometry extrusions).
from .. import wrap_usecases
from .add_axis_representation import add_axis_representation
from .add_boolean import add_boolean
from .clip_solid import clip_solid
from .clip_solid_bounded import clip_solid_bounded
from .add_door_representation import add_door_representation
from .add_footprint_representation import add_footprint_representation
from .add_mesh_representation import add_mesh_representation
@@ -50,6 +52,7 @@ from .disconnect_element import disconnect_element
from .disconnect_path import disconnect_path
from .edit_object_placement import edit_object_placement
from .map_representation import map_representation
from .copy_representation import copy_representation
from .regenerate_wall_representation import regenerate_wall_representation
from .remove_boolean import remove_boolean
from .remove_representation import remove_representation
@@ -61,6 +64,9 @@ wrap_usecases(__path__, __name__)
__all__ = [
"add_axis_representation",
"add_boolean",
"clip_solid",
"clip_solid_bounded",
"copy_representation",
"add_door_representation",
"add_footprint_representation",
"add_mesh_representation",
@@ -47,7 +47,9 @@ def add_wall_representation(
:param thickness: The thickness of the wall in meters.
:param x_angle: The slope angle along the wall's X-axis, in radians.
:param clippings: List of clipping definitions. Clippings can be `Clipping` objects
or dictionaries of arguments for `Clipping.parse`.
or dictionaries of arguments for `Clipping.parse`. Each clipping has a
``normal`` that points toward the removed material (the discarded side),
not toward the kept material; see :func:`clip_solid` for details.
:param booleans: List of any existing IfcBooleanResults.
:return: IfcShapeRepresentation.
"""
@@ -0,0 +1,86 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell 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.
#
# IfcOpenShell 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 IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
import json
from typing import Optional, Sequence
import ifcopenshell.api.pset
import ifcopenshell.util.element
import ifcopenshell.util.unit
from ifcopenshell.util.data import Clipping
def clip_solid(
file: ifcopenshell.file,
item: ifcopenshell.entity_instance,
location: Sequence[float],
normal: Sequence[float],
element: Optional[ifcopenshell.entity_instance] = None,
) -> ifcopenshell.entity_instance:
"""Clip a solid with a half-space plane, returning an IfcBooleanClippingResult.
Convenience wrapper around :class:`ifcopenshell.util.data.Clipping` for
use with any solid. This is the same convention used by the ``clippings``
parameter of :func:`add_wall_representation`.
.. warning::
The ``normal`` points toward the **removed** material (the discarded
side), not toward the kept material. For a slope clip the normal
points upward into the removed wedge above the slope line. For a
side mitre the normal points outward away from the wall body.
After clipping, set the parent ``IfcShapeRepresentation``
``RepresentationType`` to ``"Clipping"``.
Example — trim an extruded solid to a lean-to slope (removed material is
above the slope)::
bcr = ifcopenshell.api.run(
"geometry.clip_solid", model,
item=extrusion,
location=[0.0, 0.0, 3.26],
normal=[0.419, 0.0, 0.908], # points UP toward removed material
)
:param item: The solid to clip (``IfcSweptAreaSolid``, ``IfcSweptDiskSolid``,
or ``IfcBooleanClippingResult``).
:param location: A point on the clipping plane in the representation's
local coordinate system.
:param normal: Plane normal pointing toward the material to be removed
(see warning above).
:param element: If provided, the resulting ``IfcBooleanClippingResult`` is
registered in the element's ``BBIM_Boolean`` property set so that
:func:`regenerate_wall_representation` preserves it during regeneration.
:return: The resulting ``IfcBooleanClippingResult``.
"""
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file)
clipping = Clipping(location=tuple(location), normal=tuple(normal))
result = clipping.apply(file, item, unit_scale)
if element is not None:
pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Boolean")
if pset_data:
pset = file.by_id(pset_data["id"])
data = list(set(json.loads(pset_data["Data"]) + [result.id()]))
else:
pset = ifcopenshell.api.pset.add_pset(file, product=element, name="BBIM_Boolean")
data = [result.id()]
ifcopenshell.api.pset.edit_pset(file, pset=pset, properties={"Data": json.dumps(data)})
return result
@@ -0,0 +1,116 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell 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.
#
# IfcOpenShell 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 IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
import json
from typing import Optional, Sequence
import numpy as np
import ifcopenshell.api.pset
import ifcopenshell.util.element
import ifcopenshell.util.unit
from ifcopenshell.util.shape_builder import ShapeBuilder
def clip_solid_bounded(
file: ifcopenshell.file,
item: ifcopenshell.entity_instance,
location: Sequence[float],
normal: Sequence[float],
boundary_points: Sequence[Sequence[float]],
boundary_position: Sequence[float] = (0.0, 0.0, 0.0),
element: Optional[ifcopenshell.entity_instance] = None,
) -> ifcopenshell.entity_instance:
"""Clip a solid with a polygonally bounded half-space, returning an IfcBooleanClippingResult.
Like :func:`clip_solid`, but the boolean subtraction is restricted to the
region enclosed by ``boundary_points`` rather than extending across the
entire half-space. The clipping plane is still infinite, but material is
only removed within the extruded footprint of the polygon.
The ``normal`` convention is the same as :func:`clip_solid`: it points
toward the **removed** material.
After clipping, set the parent ``IfcShapeRepresentation``
``RepresentationType`` to ``"Clipping"``.
Example::
bcr = ifcopenshell.api.run(
"geometry.clip_solid_bounded", model,
item=extrusion,
location=[2.5, 0.0, 2.0],
normal=[0.6, 0.0, 0.8],
boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]],
)
:param item: The solid to clip (``IfcSweptAreaSolid``, ``IfcSweptDiskSolid``,
or ``IfcBooleanClippingResult``).
:param location: A point on the clipping plane in the representation's
local coordinate system.
:param normal: Plane normal pointing toward the material to be removed.
:param boundary_points: 2D ``[x, y]`` points defining the closed polygonal
boundary in the coordinate system of ``boundary_position``. The polygon
is automatically closed — do not repeat the first point.
:param boundary_position: 3D origin of the boundary coordinate system
(axes default to the global X/Y/Z directions). Defaults to the origin.
:param element: If provided, the resulting ``IfcBooleanClippingResult`` is
registered in the element's ``BBIM_Boolean`` property set so that
:func:`regenerate_wall_representation` preserves it during regeneration.
:return: The resulting ``IfcBooleanClippingResult``.
"""
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file)
builder = ShapeBuilder(file)
normal_arr = np.array(normal)
if np.allclose(normal_arr, [0.0, 0.0, 1.0], atol=1e-2) or np.allclose(normal_arr, [0.0, 0.0, -1.0], atol=1e-2):
arbitrary_vector = np.array([0.0, 1.0, 0.0])
else:
arbitrary_vector = np.array([0.0, 0.0, 1.0])
x_axis = np.cross(normal_arr, arbitrary_vector)
x_axis /= np.linalg.norm(x_axis)
scaled_location = [i / unit_scale for i in location]
plane_placement = builder.create_axis2_placement_3d(scaled_location, normal, x_axis)
plane = file.create_entity("IfcPlane", plane_placement)
scaled_boundary_position = [i / unit_scale for i in boundary_position]
boundary_pos_entity = file.create_entity(
"IfcAxis2Placement3D",
file.create_entity("IfcCartesianPoint", scaled_boundary_position),
)
scaled_pts = [[p[0] / unit_scale, p[1] / unit_scale] for p in boundary_points]
scaled_pts.append(scaled_pts[0]) # close the polygon
ifc_pts = [file.create_entity("IfcCartesianPoint", p) for p in scaled_pts]
boundary = file.createIfcPolyline(ifc_pts)
half_space = file.create_entity("IfcPolygonalBoundedHalfSpace", plane, False, boundary_pos_entity, boundary)
result = file.create_entity("IfcBooleanClippingResult", "DIFFERENCE", item, half_space)
if element is not None:
pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Boolean")
if pset_data:
pset = file.by_id(pset_data["id"])
data = list(set(json.loads(pset_data["Data"]) + [result.id()]))
else:
pset = ifcopenshell.api.pset.add_pset(file, product=element, name="BBIM_Boolean")
data = [result.id()]
ifcopenshell.api.pset.edit_pset(file, pset=pset, properties={"Data": json.dumps(data)})
return result
@@ -0,0 +1,82 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell 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.
#
# IfcOpenShell 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 IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
from typing import Optional
import ifcopenshell.api.geometry
import ifcopenshell.util.element
import ifcopenshell.util.representation
def copy_representation(
file: ifcopenshell.file,
source: ifcopenshell.entity_instance,
target: ifcopenshell.entity_instance,
context_identifier: str = "Body",
) -> Optional[ifcopenshell.entity_instance]:
"""Copy a geometric representation from one element to another.
Finds the named representation on ``source``, deep-copies its entity
graph (geometry items, profiles, placements, etc.), and assigns the copy
to ``target``. Representation contexts are shared rather than copied.
If ``target`` already has a matching representation it is removed and
replaced.
If no matching representation is found on ``source``, returns ``None``
and leaves ``target`` unchanged.
:param source: The element to copy the representation from.
:param target: The element to assign the copied representation to.
:param context_identifier: The RepresentationIdentifier to look up on
``source`` (e.g. ``"Body"``, ``"Axis"``, ``"Box"``).
Defaults to ``"Body"``.
:return: The newly created IfcShapeRepresentation, or None if no
matching representation was found on ``source``.
Example:
.. code:: python
wall_a = model.by_id(1)
wall_b = model.by_id(2)
# Give wall_b the same body geometry as wall_a.
ifcopenshell.api.geometry.copy_representation(model,
source=wall_a, target=wall_b)
"""
source_rep = ifcopenshell.util.representation.get_representation(
source, "Model", context_identifier
)
if source_rep is None:
return None
new_rep = ifcopenshell.util.element.copy_deep(
file, source_rep, exclude=["IfcGeometricRepresentationContext"]
)
existing_rep = ifcopenshell.util.representation.get_representation(
target, "Model", context_identifier
)
if existing_rep:
ifcopenshell.api.geometry.unassign_representation(file, product=target, representation=existing_rep)
ifcopenshell.api.geometry.remove_representation(file, representation=existing_rep)
ifcopenshell.api.geometry.assign_representation(file, product=target, representation=new_rep)
return new_rep
@@ -52,10 +52,11 @@ def edit_object_placement(
:param is_si: If True, the matrix is given in SI units. If false, in
project units.
:param should_transform_children: A child element is a nested element,
opening, filling, etc. If true, child elements will move along with the
parent. If false, child elements will stay where they are. Because most
placements in IFC are relative, this means that if a child moves, we
actually don't change their placement.
opening, filling, etc. If True, child elements move along with the
parent; pass True when moving an assembly (roof, furniture group, etc.)
and you want all children to follow. If False (default), child elements
keep their current world positions; their local placements are rewritten
to compensate for the parent move.
:return: The new or updated IfcLocalPlacement entity
"""
usecase = Usecase()
@@ -69,6 +69,12 @@ def regenerate_wall_representation(
additional extrusions are generated for each connection that boolean
difference the base extrusion.
Clippings applied via :func:`geometry.clip_solid` or
:func:`geometry.clip_solid_bounded` are preserved only if the ``element``
parameter was passed when creating them, which registers the result in the
``BBIM_Boolean`` property set. Clippings created without that parameter
are silently discarded during regeneration.
This will also update the axis line representation (e.g. trim the axis line
to any connections).
@@ -26,7 +26,7 @@ def assign_process(
relating_process: ifcopenshell.entity_instance,
related_object: ifcopenshell.entity_instance,
) -> ifcopenshell.entity_instance:
"""Assigns an object to be related to a process, typically a construction task
"""Assigns an object as an input, control, or resource of a process
Processes work using the ICOM (Input, Controls, Outputs, Mechanisms)
paradigm in IFC. This process model is commonly used in modeling
@@ -63,6 +63,17 @@ def assign_process(
For resources, any construction resource may be assigned to a task.
.. warning::
This function creates an **Input** relationship
(``IfcRelAssignsToProcess``), meaning the product is *consumed* or
*operated on* by the task — the typical case is demolition or
maintenance.
If the task *constructs or installs* a product (e.g. erecting a wall
or fitting a window), use :func:`assign_product` instead, which
creates an **Output** relationship (``IfcRelAssignsToProduct``).
:param relating_process: The IfcProcess (typically IfcTask) that the
input, control, or resource is related to.
:param related_object: The IfcProduct (for input), IfcCostItem (for
@@ -77,7 +88,7 @@ def assign_process(
# need to be part of a work schedule.
schedule = ifcopenshell.api.sequence.add_work_schedule(model, name="Construction Schedule A")
# Let's create a construction task. Note that the predefined type is
# Let's create a demolition task. Note that the predefined type is
# important to distinguish types of tasks.
task = ifcopenshell.api.sequence.add_task(model,
work_schedule=schedule, name="Demolish existing", identification="A", predefined_type="DEMOLITION")
@@ -85,8 +96,12 @@ def assign_process(
# Let's say we have a wall somewhere.
wall = ifcopenshell.api.root.create_entity(model, ifc_class="IfcWall")
# Let's demolish that wall!
# The wall is an INPUT to the demolition task (it will be consumed).
ifcopenshell.api.sequence.assign_process(model, relating_process=task, related_object=wall)
# For a construction task that BUILDS a wall, use assign_product instead:
# build_task = ifcopenshell.api.sequence.add_task(model, ..., predefined_type="CONSTRUCTION")
# ifcopenshell.api.sequence.assign_product(model, relating_product=wall, related_object=build_task)
"""
if related_object.HasAssignments:
for assignment in related_object.HasAssignments:
@@ -517,11 +517,18 @@ class ShapeBuilder:
trim_points_mask: Sequence[int],
position_offset: Optional[VectorType] = None,
) -> np.ndarray:
"""Handy way to get edge points of the ellipse like shape of a given radiuses.
"""Get cardinal-point coordinates of an ellipse by index mask.
Mask points are numerated from 0 to 3 ccw starting from (x_axis_radius/2; 0).
The four cardinal points are numbered 03 counter-clockwise starting from the
positive X axis: 0 → ``(x, 0)``, 1 → ``(0, y)``, 2 → ``(-x, 0)``, 3 → ``(0, -y)``.
Example: mask (0, 1, 2, 3) will return points (x, 0), (0, y), (-x, 0), (0, -y)
Example: mask ``(0, 1, 2, 3)`` returns all four points in order.
:param x_axis_radius: Radius (semi-axis length) along the X axis.
:param y_axis_radius: Radius (semi-axis length) along the Y axis.
:param trim_points_mask: Sequence of cardinal-point indices (03) to select.
:param position_offset: Optional 2D offset added to all returned points.
:return: Numpy array of the selected 2D points.
"""
points = np.array(
(
@@ -546,15 +553,23 @@ class ShapeBuilder:
ref_x_direction: VectorType = (1.0, 0.0),
trim_points_mask: Sequence[int] = (),
) -> ifcopenshell.entity_instance:
"""
Ellipse trimming points should be specified in counter clockwise order.
"""Create an IfcEllipse, optionally trimmed to an arc.
For example, if you need to get the part of the ellipse ABOVE y-axis, you need to use mask (0,2). Below y-axis - (2,0)
If neither ``trim_points`` nor ``trim_points_mask`` is provided, a full IfcEllipse is returned.
Trimming points must be given in counter-clockwise order. For example, to get the arc
above the Y-axis use mask ``(0, 2)``; below the Y-axis use ``(2, 0)``.
For more information about trim_points_mask check builder.get_trim_points_from_mask
A trimmed result (IfcTrimmedCurve) includes a closing segment between the trim points,
making it suitable for use as a profile in :meth:`extrude`.
Notion: trimmed ellipse also contains polyline between trim points, meaning IfcTrimmedCurve could be used
for further extrusion.
:param x_axis_radius: Semi-axis length along the local X axis.
:param y_axis_radius: Semi-axis length along the local Y axis.
:param position: 2D centre of the ellipse.
:param trim_points: Explicit pair of 2D trim points. Takes precedence over ``trim_points_mask``.
:param ref_x_direction: Direction of the local X axis.
:param trim_points_mask: Pair of cardinal-point indices (03) used when ``trim_points`` is empty.
See :meth:`get_trim_points_from_mask` for index definitions.
:return: IfcEllipse (untrimmed) or IfcTrimmedCurve (trimmed).
"""
ifc_position = self.create_axis2_placement_2d(position, ref_x_direction)
ifc_ellipse = self.file.createIfcEllipse(
@@ -685,6 +700,14 @@ class ShapeBuilder:
pivot_point: VectorType = (0.0, 0.0),
counter_clockwise: bool = False,
) -> np.ndarray:
"""Rotate a single 2D point around a pivot.
:param point_2d: The 2D point to rotate.
:param angle: Rotation angle, in degrees. Defaults to 90.
:param pivot_point: The point to rotate around.
:param counter_clockwise: If True, rotate counter-clockwise. Defaults to clockwise.
:return: Rotated 2D point as a numpy array.
"""
angle_rad = radians(angle) * (1 if counter_clockwise else -1)
relative_point = np.array(point_2d) - pivot_point
relative_point = np_rotation_matrix(angle_rad, 2) @ relative_point
@@ -752,7 +775,16 @@ class ShapeBuilder:
mirror_axes: VectorType = (1.0, 1.0),
mirror_point: VectorType = (0.0, 0.0),
) -> np.ndarray:
"""mirror_axes - along which axes mirror will be applied"""
"""Mirror a single 2D point across the specified axes.
:param point_2d: The 2D point to mirror.
:param mirror_axes: Indicates which axes to mirror across. A positive value in a
component means that axis is mirrored (negated relative to ``mirror_point``).
Example: ``(1, 0)`` mirrors across the Y-axis (negates X only),
``(1, 1)`` mirrors across both axes.
:param mirror_point: Origin of the mirror operation.
:return: Mirrored 2D point as a numpy array.
"""
mirror_axes: np.ndarray = np.where(np.array(mirror_axes) > 0, -1, 1)
mirror_point: np.ndarray = np.array(mirror_point)
relative_point = point_2d - mirror_point
@@ -798,7 +830,13 @@ class ShapeBuilder:
def create_axis2_placement_2d(
self, position: VectorType = (0.0, 0.0), x_direction: Optional[VectorType] = None
) -> ifcopenshell.entity_instance:
"""Create IfcAxis2Placement2D."""
"""Create IfcAxis2Placement2D.
:param position: 2D origin of the placement.
:param x_direction: Direction of the local X axis. If not provided, defaults to
the global X axis ``(1, 0)``.
:return: IfcAxis2Placement2D
"""
ref_direction = (
self.file.create_entity("IfcDirection", ifc_safe_vector_type(x_direction)) if x_direction else None
)
@@ -1000,7 +1038,7 @@ class ShapeBuilder:
) -> ifcopenshell.entity_instance:
"""
:param plane: The IfcPlane representing the half space.
:param agreement_flag: False if +Z represents the void
:param agreement_flag: If False (default), the plane normal points toward the **removed** material (the void). The kept region is on the opposite side from the normal.
:return: IfcHalfSpaceSolid
"""
return self.file.createIfcHalfSpaceSolid(plane, AgreementFlag=agreement_flag)
@@ -1053,7 +1091,14 @@ class ShapeBuilder:
def create_swept_disk_solid(
self, path_curve: ifcopenshell.entity_instance, radius: float
) -> ifcopenshell.entity_instance:
"""Create IfcSweptDiskSolid from `path_curve` (must be 3D) and `radius`"""
"""Create an IfcSweptDiskSolid — a circular cross-section swept along a 3D path.
Useful for modelling round pipes, conduits, and cables.
:param path_curve: A 3D curve entity defining the centreline path. Must have ``Dim == 3``.
:param radius: Radius of the circular disk cross-section.
:return: IfcSweptDiskSolid
"""
if path_curve.Dim != 3:
raise Exception(
f"Path curve for IfcSweptDiskSolid should be 3D to be valid, currently it has {path_curve.Dim} dimensions.\n"
@@ -1071,10 +1116,22 @@ class ShapeBuilder:
) -> ifcopenshell.entity_instance:
"""Create IFC representation for the specified context and items.
**All items must belong to the same geometry category.** IFC prohibits
mixing incompatible item types in one representation (e.g.
``IfcExtrudedAreaSolid`` with ``IfcBlock``, or solids with curves).
When ``representation_type`` is omitted the type is inferred via
:func:`ifcopenshell.util.representation.guess_type`; if the items are
heterogeneous ``guess_type`` returns ``None`` and the representation is
written with no ``RepresentationType``, which fails IFC validation.
Avoid mixing swept-solid primitives (``IfcExtrudedAreaSolid``,
``IfcRevolvedAreaSolid``) with CSG primitives (``IfcBlock``,
``IfcSphere``, etc.) or any other category in a single call.
:param context: IfcGeometricRepresentationSubContext
:param items: could be a list or single curve/IfcExtrudedAreaSolid
:param items: A single item or list of items, all of the same geometry
category (e.g. all ``IfcExtrudedAreaSolid``, all ``IfcIndexedPolyCurve``)
:param representation_type: Explicitly specified RepresentationType.
If not provided it will be guessed from the items types
If not provided it will be guessed from the items types.
:return: IfcShapeRepresentation
"""
if not isinstance(items, collections.abc.Iterable):
@@ -1096,18 +1153,26 @@ class ShapeBuilder:
)
def deep_copy(self, element: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance:
"""Create a deep copy of an IFC element and all its referenced entities.
:param element: The IFC entity to copy.
:return: A new independent copy of the element.
"""
return ifcopenshell.util.element.copy_deep(self.file, element)
# UTILITIES
def extrude_kwargs(self, axis: Literal["Y", "X", "Z"]) -> dict[str, tuple[float, float, float]]:
"""Shortcut to get kwargs for `ShapeBuilder.extrude` to extrude by some axis.
"""Shortcut to get kwargs for :meth:`extrude` to extrude along a principal axis.
It assumes you have 2D profile in:
XZ plane for Y axis extrusion, \n
YZ plane for X axis extrusion, \n
XY plane for Z axis extrusion, \n
Assumes the 2D profile lies in the plane perpendicular to the extrusion axis:
XZ plane for Y-axis extrusion, YZ plane for X-axis extrusion, XY plane for Z-axis extrusion.
Extruding by X/Y using other kwargs might break ValidExtrusionDirection."""
Extruding along X or Y with other kwargs may violate the IFC ValidExtrusionDirection constraint.
:param axis: The extrusion axis: ``'X'``, ``'Y'``, or ``'Z'``.
:return: A dict with keys ``position_x_axis``, ``position_z_axis``, and ``extrusion_vector``
suitable for passing as ``**kwargs`` to :meth:`extrude`.
"""
if axis == "Y":
return {
@@ -1131,13 +1196,16 @@ class ShapeBuilder:
def rotate_extrusion_kwargs_by_z(
self, kwargs: dict[str, Any], angle: float, counter_clockwise: bool = False
) -> dict[str, VectorType]:
"""shortcut to rotate extrusion kwargs by z axis
"""Rotate extrusion kwargs around the Z axis.
`kwargs` expected to have `position_x_axis` and `position_z_axis` keys
A shortcut to rotate the ``position_x_axis`` and ``position_z_axis`` values returned by
:meth:`extrude_kwargs` around the Z axis before passing them to :meth:`extrude`.
`angle` is a rotation value in radians
by default rotation is clockwise, to make it counter clockwise use `counter_clockwise` flag
:param kwargs: A dict with ``position_x_axis`` and ``position_z_axis`` keys,
as returned by :meth:`extrude_kwargs`. The original dict is not mutated.
:param angle: Rotation angle, in radians.
:param counter_clockwise: If True, rotate counter-clockwise. Defaults to clockwise.
:return: A new dict with ``position_x_axis`` and ``position_z_axis`` rotated around Z.
"""
rot = np_rotation_matrix(-angle, 3, "Z")
kwargs = kwargs.copy() # prevent mutation of original kwargs
@@ -1146,7 +1214,11 @@ class ShapeBuilder:
return kwargs
def get_polyline_coords(self, polyline: ifcopenshell.entity_instance) -> np.ndarray:
"""polyline should be either `IfcIndexedPolyCurve` or `IfcPolyline`"""
"""Extract the coordinate array from a polyline entity.
:param polyline: An ``IfcIndexedPolyCurve`` or ``IfcPolyline`` entity.
:return: Numpy array of the polyline's point coordinates.
"""
coords = None
if polyline.is_a("IfcIndexedPolyCurve"):
coords = np.array(polyline.Points.CoordList)
@@ -1157,7 +1229,12 @@ class ShapeBuilder:
return coords
def set_polyline_coords(self, polyline: ifcopenshell.entity_instance, coords: SequenceOfVectors) -> None:
"""polyline should be either `IfcIndexedPolyCurve` or `IfcPolyline`"""
"""Update the coordinates of a polyline entity in-place.
:param polyline: An ``IfcIndexedPolyCurve`` or ``IfcPolyline`` entity.
:param coords: New sequence of point coordinates. Must contain the same number of
points as the original polyline.
"""
if polyline.is_a("IfcIndexedPolyCurve"):
polyline.Points.CoordList = ifc_safe_vector_type(coords)
elif polyline.is_a("IfcPolyline"):
@@ -1296,6 +1373,18 @@ class ShapeBuilder:
WallThickness: float,
FilletRadius: float,
) -> ifcopenshell.entity_instance:
"""Create a Z-profile (cold-formed steel section) outline curve with lips and fillets.
All dimensions are in the IFC project's length units.
:param FirstFlangeWidth: Width of the first (top) flange, measured from the web centreline.
:param SecondFlangeWidth: Width of the second (bottom) flange, measured from the web centreline.
:param Depth: Total depth of the section (web height).
:param Girth: Length of the return lips on each flange.
:param WallThickness: Uniform material thickness.
:param FilletRadius: Inner bend radius at each corner.
:return: IfcIndexedPolyCurve representing the closed Z-profile outline.
"""
x1 = FirstFlangeWidth
x2 = SecondFlangeWidth
y = Depth / 2
@@ -1337,10 +1426,17 @@ class ShapeBuilder:
def create_transition_arc_ifc(
self, width: float, height: float, create_ifc_curve: bool = False
) -> tuple[SequenceOfVectors, list[list[int]], Union[ifcopenshell.entity_instance, None]]:
"""Create an arc in the rectangle with specified width and height.
"""Create an arc fitting inside a rectangle of the given width and height.
If it's not possible to make a complete arc, create an arc with longest radius possible
and straight segment in the middle.
If a single arc cannot span the full width, the longest possible radius is used and
a straight segment is inserted in the middle.
:param width: Width of the bounding rectangle.
:param height: Height of the bounding rectangle (also the maximum arc radius).
:param create_ifc_curve: If True, also create and return an ``IfcIndexedPolyCurve``.
If False, only return the raw point and segment data.
:return: A tuple ``(points, segments, ifc_curve)`` where ``ifc_curve`` is an
``IfcIndexedPolyCurve`` when ``create_ifc_curve=True``, otherwise ``None``.
"""
fillet_size = (width / 2) / height
if fillet_size <= 1:
@@ -1370,6 +1466,14 @@ class ShapeBuilder:
return points, segments, transition_arc
def mesh(self, points: SequenceOfVectors, faces: Sequence[Sequence[int]]) -> ifcopenshell.entity_instance:
"""Create a tessellated mesh from points and face indices.
Delegates to :meth:`faceted_brep` for IFC2X3, or :meth:`polygonal_face_set` for IFC4 and later.
:param points: List of 3D coordinates.
:param faces: List of faces, each face a sequence of zero-based point indices.
:return: IfcFacetedBrep (IFC2X3) or IfcPolygonalFaceSet (IFC4+).
"""
if self.file.schema == "IFC2X3":
return self.faceted_brep(points, faces)
return self.polygonal_face_set(points, faces)
@@ -1723,11 +1827,20 @@ class ShapeBuilder:
angle: float,
profile_offset: VectorType = (0.0, 0.0),
verbose: bool = True,
):
"""get the final transition length for two profiles dimensions, angle and XY offset between them,
) -> Optional[float]:
"""Get the transition length for two profile half-dimensions, an angle, and an XY offset.
the difference from `calculate_transition` - `get_transition_length` is making sure
that length will fit both sides of the transition
Unlike :meth:`mep_transition_calculate`, this method checks that the resulting length
satisfies the angle constraint from both the start and end profile perspectives.
:param start_half_dim: Half-dimensions of the start profile as a 3-element array
``[half_x, half_y, depth]``. For circular profiles ``half_x == half_y == radius``.
:param end_half_dim: Half-dimensions of the end profile in the same format.
:param angle: Maximum allowed transition angle, in degrees.
:param profile_offset: 2D XY offset between the centrelines of the start and end profiles.
:param verbose: If True, print diagnostic values during calculation.
:return: Transition length in project length units, or ``None`` if no valid length exists
for the given angle and offset.
"""
print = lambda *args, **kwargs: __builtins__["print"](*args, **kwargs) if verbose else None
np_X, np_Y = 0, 1
@@ -1788,9 +1901,23 @@ class ShapeBuilder:
angle: Optional[float] = None,
verbose: bool = True,
) -> Union[float, None]:
"""will return transition length based on the profile dimension differences and offset.
"""Calculate MEP transition length from angle, or transition angle from length.
If `length` is provided will return transition angle"""
Low-level calculation kernel used by :meth:`mep_transition_length`. Provide either
``angle`` or ``length`` (not both); the other value is computed and returned.
:param start_half_dim: Half-dimensions of the start profile ``[half_x, half_y, depth]``.
:param end_half_dim: Half-dimensions of the end profile ``[half_x, half_y, depth]``.
:param offset: 2D XY offset between profile centrelines.
:param diff: Pre-computed absolute difference of start and end half-dimensions (XY only).
Computed from ``start_half_dim`` and ``end_half_dim`` if not provided.
:param end_profile: If True, swap X and Y axes to compute from the end-profile perspective.
:param length: Known transition length. If provided, the corresponding angle is returned.
:param angle: Known transition angle, in degrees. If provided, the corresponding length is returned.
:param verbose: If True, print diagnostic values during calculation.
:return: Transition length (if ``angle`` was given) or transition angle in degrees
(if ``length`` was given), or ``None`` if the geometry is not feasible.
"""
print = lambda *args, **kwargs: __builtins__["print"](*args, **kwargs) if verbose else None
@@ -0,0 +1,155 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell 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.
#
# IfcOpenShell 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 IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import json
import ifcopenshell.api.geometry
import ifcopenshell.util.element
import ifcopenshell.util.shape_builder
import test.bootstrap
class TestClipSolid(test.bootstrap.IFC4):
def make_extrusion(self):
builder = ifcopenshell.util.shape_builder.ShapeBuilder(self.file)
rect = builder.rectangle(size=(1.0, 1.0))
return builder.extrude(rect, magnitude=4.0)
def test_returns_boolean_clipping_result(self):
extrusion = self.make_extrusion()
result = ifcopenshell.api.geometry.clip_solid(
self.file,
item=extrusion,
location=[0.0, 0.0, 3.0],
normal=[0.0, 0.0, 1.0],
)
assert result.is_a("IfcBooleanClippingResult")
assert result.Operator == "DIFFERENCE"
def test_first_operand_is_the_item(self):
extrusion = self.make_extrusion()
result = ifcopenshell.api.geometry.clip_solid(
self.file,
item=extrusion,
location=[0.0, 0.0, 3.0],
normal=[0.0, 0.0, 1.0],
)
assert result.FirstOperand == extrusion
def test_second_operand_is_half_space_solid(self):
extrusion = self.make_extrusion()
result = ifcopenshell.api.geometry.clip_solid(
self.file,
item=extrusion,
location=[0.0, 0.0, 3.0],
normal=[0.0, 0.0, 1.0],
)
assert result.SecondOperand.is_a("IfcHalfSpaceSolid")
def test_clip_plane_location_matches(self):
extrusion = self.make_extrusion()
result = ifcopenshell.api.geometry.clip_solid(
self.file,
item=extrusion,
location=[0.0, 0.0, 3.0],
normal=[0.0, 0.0, 1.0],
)
plane = result.SecondOperand.BaseSurface
coords = plane.Position.Location.Coordinates
assert list(coords) == [0.0, 0.0, 3.0]
def test_chaining_two_clips(self):
extrusion = self.make_extrusion()
first_clip = ifcopenshell.api.geometry.clip_solid(
self.file,
item=extrusion,
location=[0.0, 0.0, 3.0],
normal=[0.0, 0.0, 1.0],
)
second_clip = ifcopenshell.api.geometry.clip_solid(
self.file,
item=first_clip,
location=[0.0, 0.0, 1.0],
normal=[0.0, 0.0, -1.0],
)
assert second_clip.is_a("IfcBooleanClippingResult")
assert second_clip.FirstOperand == first_clip
assert first_clip.FirstOperand == extrusion
def test_angled_clip_plane(self):
extrusion = self.make_extrusion()
result = ifcopenshell.api.geometry.clip_solid(
self.file,
item=extrusion,
location=[0.0, 0.0, 3.26],
normal=[0.419, 0.0, 0.908],
)
assert result.is_a("IfcBooleanClippingResult")
assert result.SecondOperand.is_a("IfcHalfSpaceSolid")
def test_element_registers_result_in_bbim_boolean(self):
extrusion = self.make_extrusion()
wall = self.file.createIfcWall()
result = ifcopenshell.api.geometry.clip_solid(
self.file,
item=extrusion,
location=[0.0, 0.0, 3.0],
normal=[0.0, 0.0, 1.0],
element=wall,
)
pset = ifcopenshell.util.element.get_pset(wall, "BBIM_Boolean")
assert pset is not None
assert result.id() in json.loads(pset["Data"])
def test_element_appends_to_existing_bbim_boolean(self):
extrusion = self.make_extrusion()
wall = self.file.createIfcWall()
first = ifcopenshell.api.geometry.clip_solid(
self.file,
item=extrusion,
location=[0.0, 0.0, 3.0],
normal=[0.0, 0.0, 1.0],
element=wall,
)
second = ifcopenshell.api.geometry.clip_solid(
self.file,
item=first,
location=[0.0, 0.0, 1.0],
normal=[0.0, 0.0, -1.0],
element=wall,
)
pset = ifcopenshell.util.element.get_pset(wall, "BBIM_Boolean")
ids = json.loads(pset["Data"])
assert first.id() in ids
assert second.id() in ids
def test_no_element_does_not_create_pset(self):
extrusion = self.make_extrusion()
wall = self.file.createIfcWall()
ifcopenshell.api.geometry.clip_solid(
self.file,
item=extrusion,
location=[0.0, 0.0, 3.0],
normal=[0.0, 0.0, 1.0],
)
assert ifcopenshell.util.element.get_pset(wall, "BBIM_Boolean") is None
class TestClipSolidIFC2X3(test.bootstrap.IFC2X3, TestClipSolid):
pass
@@ -0,0 +1,180 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell 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.
#
# IfcOpenShell 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 IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import json
import ifcopenshell.api.geometry
import ifcopenshell.util.element
import ifcopenshell.util.shape_builder
import test.bootstrap
class TestClipSolidBounded(test.bootstrap.IFC4):
def make_extrusion(self):
builder = ifcopenshell.util.shape_builder.ShapeBuilder(self.file)
rect = builder.rectangle(size=(4.0, 1.0))
return builder.extrude(rect, magnitude=3.0)
def test_returns_boolean_clipping_result(self):
extrusion = self.make_extrusion()
result = ifcopenshell.api.geometry.clip_solid_bounded(
self.file,
item=extrusion,
location=[2.5, 0.0, 2.0],
normal=[0.6, 0.0, 0.8],
boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]],
)
assert result.is_a("IfcBooleanClippingResult")
assert result.Operator == "DIFFERENCE"
def test_first_operand_is_the_item(self):
extrusion = self.make_extrusion()
result = ifcopenshell.api.geometry.clip_solid_bounded(
self.file,
item=extrusion,
location=[2.5, 0.0, 2.0],
normal=[0.6, 0.0, 0.8],
boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]],
)
assert result.FirstOperand == extrusion
def test_second_operand_is_polygonal_bounded_half_space(self):
extrusion = self.make_extrusion()
result = ifcopenshell.api.geometry.clip_solid_bounded(
self.file,
item=extrusion,
location=[2.5, 0.0, 2.0],
normal=[0.6, 0.0, 0.8],
boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]],
)
assert result.SecondOperand.is_a("IfcPolygonalBoundedHalfSpace")
def test_agreement_flag_is_false(self):
extrusion = self.make_extrusion()
result = ifcopenshell.api.geometry.clip_solid_bounded(
self.file,
item=extrusion,
location=[2.5, 0.0, 2.0],
normal=[0.6, 0.0, 0.8],
boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]],
)
assert result.SecondOperand.AgreementFlag is False
def test_clip_plane_location_matches(self):
extrusion = self.make_extrusion()
result = ifcopenshell.api.geometry.clip_solid_bounded(
self.file,
item=extrusion,
location=[2.5, 0.0, 2.0],
normal=[0.6, 0.0, 0.8],
boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]],
)
plane = result.SecondOperand.BaseSurface
coords = plane.Position.Location.Coordinates
assert list(coords) == [2.5, 0.0, 2.0]
def test_boundary_is_closed_polyline(self):
extrusion = self.make_extrusion()
result = ifcopenshell.api.geometry.clip_solid_bounded(
self.file,
item=extrusion,
location=[2.5, 0.0, 2.0],
normal=[0.6, 0.0, 0.8],
boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]],
)
boundary = result.SecondOperand.PolygonalBoundary
assert boundary.is_a("IfcPolyline")
pts = [list(p.Coordinates) for p in boundary.Points]
assert pts[0] == pts[-1], "polygon should be closed"
assert len(pts) == 5 # 4 unique + closing repeat
def test_boundary_position_defaults_to_origin(self):
extrusion = self.make_extrusion()
result = ifcopenshell.api.geometry.clip_solid_bounded(
self.file,
item=extrusion,
location=[2.5, 0.0, 2.0],
normal=[0.6, 0.0, 0.8],
boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]],
)
pos = result.SecondOperand.Position
assert list(pos.Location.Coordinates) == [0.0, 0.0, 0.0]
def test_custom_boundary_position(self):
extrusion = self.make_extrusion()
result = ifcopenshell.api.geometry.clip_solid_bounded(
self.file,
item=extrusion,
location=[2.5, 0.0, 2.0],
normal=[0.6, 0.0, 0.8],
boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]],
boundary_position=[1.0, 2.0, 3.0],
)
pos = result.SecondOperand.Position
assert list(pos.Location.Coordinates) == [1.0, 2.0, 3.0]
def test_chaining_with_clip_solid(self):
extrusion = self.make_extrusion()
first_clip = ifcopenshell.api.geometry.clip_solid(
self.file,
item=extrusion,
location=[0.0, 0.0, 3.0],
normal=[0.0, 0.0, 1.0],
)
result = ifcopenshell.api.geometry.clip_solid_bounded(
self.file,
item=first_clip,
location=[2.5, 0.0, 2.0],
normal=[0.6, 0.0, 0.8],
boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]],
)
assert result.is_a("IfcBooleanClippingResult")
assert result.FirstOperand == first_clip
assert first_clip.FirstOperand == extrusion
def test_element_registers_result_in_bbim_boolean(self):
extrusion = self.make_extrusion()
wall = self.file.createIfcWall()
result = ifcopenshell.api.geometry.clip_solid_bounded(
self.file,
item=extrusion,
location=[2.5, 0.0, 2.0],
normal=[0.6, 0.0, 0.8],
boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]],
element=wall,
)
pset = ifcopenshell.util.element.get_pset(wall, "BBIM_Boolean")
assert pset is not None
assert result.id() in json.loads(pset["Data"])
def test_no_element_does_not_create_pset(self):
extrusion = self.make_extrusion()
wall = self.file.createIfcWall()
ifcopenshell.api.geometry.clip_solid_bounded(
self.file,
item=extrusion,
location=[2.5, 0.0, 2.0],
normal=[0.6, 0.0, 0.8],
boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]],
)
assert ifcopenshell.util.element.get_pset(wall, "BBIM_Boolean") is None
class TestClipSolidBoundedIFC2X3(test.bootstrap.IFC2X3, TestClipSolidBounded):
pass
@@ -0,0 +1,142 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell 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.
#
# IfcOpenShell 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 IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import ifcopenshell.api.geometry
import ifcopenshell.api.root
import ifcopenshell.util.representation
import ifcopenshell.util.shape_builder
import test.bootstrap
class TestCopyRepresentation(test.bootstrap.IFC4):
def _body_context(self):
body = ifcopenshell.util.representation.get_context(self.file, "Model", "Body", "MODEL_VIEW")
if body is None:
model = self.file.createIfcGeometricRepresentationContext(
ContextType="Model", CoordinateSpaceDimension=3, Precision=1e-5,
WorldCoordinateSystem=self.file.createIfcAxis2Placement3D(
self.file.createIfcCartesianPoint((0.0, 0.0, 0.0))
),
)
body = self.file.createIfcGeometricRepresentationSubContext(
ContextIdentifier="Body", ContextType="Model",
TargetView="MODEL_VIEW", ParentContext=model,
)
return body
def _add_body_rep(self, element):
body = self._body_context()
rep = ifcopenshell.api.geometry.add_wall_representation(
self.file, context=body, length=5.0, height=3.0, thickness=0.2
)
ifcopenshell.api.geometry.assign_representation(self.file, product=element, representation=rep)
return rep
def test_copy_to_empty_target(self):
wall_a = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
wall_b = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
self._add_body_rep(wall_a)
result = ifcopenshell.api.geometry.copy_representation(
self.file, source=wall_a, target=wall_b
)
assert result is not None
assert result.is_a("IfcShapeRepresentation")
target_rep = ifcopenshell.util.representation.get_representation(wall_b, "Model", "Body")
assert target_rep is not None
assert target_rep == result
def test_source_rep_entities_are_distinct(self):
wall_a = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
wall_b = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
source_rep = self._add_body_rep(wall_a)
new_rep = ifcopenshell.api.geometry.copy_representation(
self.file, source=wall_a, target=wall_b
)
assert new_rep.id() != source_rep.id()
assert new_rep.Items[0].id() != source_rep.Items[0].id()
def test_context_is_shared_not_copied(self):
wall_a = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
wall_b = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
source_rep = self._add_body_rep(wall_a)
new_rep = ifcopenshell.api.geometry.copy_representation(
self.file, source=wall_a, target=wall_b
)
assert new_rep.ContextOfItems.id() == source_rep.ContextOfItems.id()
def test_replaces_existing_target_rep(self):
wall_a = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
wall_b = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
self._add_body_rep(wall_a)
old_rep = self._add_body_rep(wall_b)
old_rep_id = old_rep.id()
ifcopenshell.api.geometry.copy_representation(
self.file, source=wall_a, target=wall_b
)
try:
self.file.by_id(old_rep_id)
assert False, "old representation still exists"
except RuntimeError:
pass # entity was removed, as expected
def test_source_unchanged_after_copy(self):
wall_a = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
wall_b = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
source_rep = self._add_body_rep(wall_a)
source_rep_id = source_rep.id()
ifcopenshell.api.geometry.copy_representation(
self.file, source=wall_a, target=wall_b
)
assert self.file.by_id(source_rep_id) is not None # source must still exist
assert ifcopenshell.util.representation.get_representation(wall_a, "Model", "Body") is not None
def test_returns_none_when_no_matching_rep(self):
wall_a = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
wall_b = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
result = ifcopenshell.api.geometry.copy_representation(
self.file, source=wall_a, target=wall_b
)
assert result is None
def test_custom_context_identifier(self):
wall_a = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
wall_b = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
self._add_body_rep(wall_a)
# "Axis" doesn't exist on wall_a, so should return None
result = ifcopenshell.api.geometry.copy_representation(
self.file, source=wall_a, target=wall_b, context_identifier="Axis"
)
assert result is None
class TestCopyRepresentationIFC2X3(test.bootstrap.IFC2X3, TestCopyRepresentation):
pass
+458
View File
@@ -0,0 +1,458 @@
<!-- This file was generated with the assistance of an AI coding tool. -->
# ifcquery
A CLI tool for querying and inspecting IFC building models. All output is
structured JSON (or human-readable text), making it easy to pipe into other
tools or scripts.
## Installation
```bash
pip install ifcquery
```
Requires `ifcopenshell`. The `clash` subcommand additionally requires the
IfcOpenShell C++ geometry bindings (`ifcopenshell.geom`).
## Usage
```
ifcquery <ifc_file> <command> [options] [--format json|text]
```
The `--format` flag controls output. Default is `json`; use `text` for
indented human-readable output.
## Subcommands
### summary
Get a model overview: schema version, entity counts, and project info.
```bash
ifcquery model.ifc summary
```
```json
{
"schema": "IFC4",
"total_entities": 1847,
"project": {
"id": 1,
"name": "Office Building",
"description": null
},
"types": {
"IfcWall": 42,
"IfcSlab": 12,
"IfcWindow": 36
}
}
```
### tree
Display the spatial hierarchy from IfcProject down through sites, buildings,
storeys, and their contained elements.
```bash
ifcquery model.ifc tree
```
```json
{
"id": 1,
"type": "IfcProject",
"name": "Office Building",
"children": [
{
"id": 2,
"type": "IfcSite",
"name": "Default Site",
"children": [
{
"id": 3,
"type": "IfcBuilding",
"name": "Main Building",
"children": [
{
"id": 4,
"type": "IfcBuildingStorey",
"name": "Ground Floor",
"elements": [
{"id": 10, "type": "IfcWall", "name": "Wall001"},
{"id": 11, "type": "IfcSlab", "name": "Floor001"}
]
}
]
}
]
}
]
}
```
### info
Get detailed information about a specific element by step ID.
```bash
ifcquery model.ifc info 10
ifcquery model.ifc info '#10'
```
Returns attributes, property sets, type relationship, material assignment,
spatial container, and placement matrix.
```json
{
"id": 10,
"type": "IfcWall",
"attributes": {
"Name": "Wall001",
"Description": null,
"ObjectType": "LOADBEARING"
},
"property_sets": {
"Pset_WallCommon": {
"IsExternal": true,
"FireRating": "2HR"
}
},
"element_type": {"id": 50, "type": "IfcWallType", "name": "Standard"},
"material": {"id": 60, "type": "IfcMaterial", "name": "Concrete"},
"container": {"id": 4, "type": "IfcBuildingStorey", "name": "Ground Floor"},
"placement": [
[1.0, 0.0, 0.0, 5.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[0.0, 0.0, 0.0, 1.0]
]
}
```
### select
Filter elements using the ifcopenshell selector syntax.
```bash
ifcquery model.ifc select 'IfcWall'
ifcquery model.ifc select 'IfcWall, IfcSlab'
```
```json
[
{"id": 10, "type": "IfcWall", "name": "Wall001"},
{"id": 11, "type": "IfcWall", "name": "Wall002"},
{"id": 20, "type": "IfcSlab", "name": "Floor001"}
]
```
Results are sorted by ID.
### relations
Show all relationships for an element, organized by category: hierarchy,
children, type relationships, groups, systems, material, and connections.
```bash
ifcquery model.ifc relations 10
```
```json
{
"id": 10,
"type": "IfcWall",
"name": "Wall001",
"hierarchy": {
"parent": {"id": 4, "type": "IfcBuildingStorey", "name": "Ground Floor"},
"container": {"id": 4, "type": "IfcBuildingStorey", "name": "Ground Floor"}
},
"children": {
"openings": [{"id": 30, "type": "IfcOpeningElement", "name": "Opening01"}]
},
"type_relationship": {
"type_of": {"id": 50, "type": "IfcWallType", "name": "Standard"}
},
"material": {"id": 60, "type": "IfcMaterial", "name": "Concrete"}
}
```
Empty categories are omitted from output.
Use `--traverse up` to walk the spatial hierarchy from the element up to
IfcProject:
```bash
ifcquery model.ifc relations 10 --traverse up
```
```json
[
{"id": 10, "type": "IfcWall", "name": "Wall001"},
{"id": 4, "type": "IfcBuildingStorey", "name": "Ground Floor"},
{"id": 3, "type": "IfcBuilding", "name": "Main Building"},
{"id": 2, "type": "IfcSite", "name": "Default Site"},
{"id": 1, "type": "IfcProject", "name": "Office Building"}
]
```
### validate
Check the model for schema and constraint violations.
```bash
ifcquery model.ifc validate
ifcquery model.ifc validate --rules
```
Options:
- `--rules` -- also run the slower EXPRESS rules check (default: off)
```json
{
"valid": true,
"issues": []
}
```
On an invalid model:
```json
{
"valid": false,
"issues": [
{"level": "ERROR", "message": "Entity #42 IfcWall.GlobalId is not a valid IfcGloballyUniqueId"}
]
}
```
### schedule
List all work schedules and their task trees from the model.
```bash
ifcquery model.ifc schedule
ifcquery model.ifc schedule --depth 1
```
Options:
- `--depth N` -- expand at most N levels of subtasks (default: unlimited). At the
cutoff, `subtasks` is replaced with `{"truncated": true, "count": N}`.
```json
[
{
"id": 42,
"name": "Construction Schedule",
"predefined_type": "BASELINE",
"tasks": [
{
"id": 55,
"name": "Phase 1",
"start": "2024-01-01T09:00:00",
"finish": "2024-06-30T17:00:00",
"is_milestone": false,
"outputs": [{"id": 10, "type": "IfcWall", "name": "Wall A"}],
"subtasks": [
{"id": 56, "name": "Foundations", "start": null, "finish": null,
"is_milestone": false, "outputs": [], "subtasks": []}
]
}
]
}
]
```
### cost
List all cost schedules and their cost item trees from the model.
```bash
ifcquery model.ifc cost
ifcquery model.ifc cost --depth 2
```
Options:
- `--depth N` -- expand at most N levels of subitems (default: unlimited). At the
cutoff, `subitems` is replaced with `{"truncated": true, "count": N}`.
```json
[
{
"id": 100,
"name": "Bill of Quantities",
"predefined_type": "COSTPLAN",
"items": [
{
"id": 110,
"name": "Concrete Works",
"values": [{"formula": "1200.00 = material(1200.0)", "category": "material"}],
"subitems": [
{"id": 111, "name": "Formwork", "values": [], "subitems": []}
]
}
]
}
]
```
### schema
Show IFC class documentation for any entity type, using the schema version of
the loaded model.
```bash
ifcquery model.ifc schema IfcWall
ifcquery model.ifc schema IfcBuildingStorey
```
```json
{
"description": "The wall represents a vertical construction ...",
"predefined_types": {"STANDARD": "A standard wall, extruded vertically ..."},
"spec_url": "https://standards.buildingsmart.org/...",
"attributes": {
"Name": "Optional name for use by the participating software systems",
"ObjectPlacement": "Placement of the product in space ..."
}
}
```
Returns `{"error": "Unknown entity: Foo"}` for unrecognised types.
### contexts
List all geometric representation contexts and subcontexts in the model.
```bash
ifcquery model.ifc contexts
```
```json
[
{
"id": 5,
"type": "IfcGeometricRepresentationContext",
"context_type": "Model",
"subcontexts": [
{"id": 6, "type": "IfcGeometricRepresentationSubContext", "context_identifier": "Body", "target_view": "MODEL_VIEW"},
{"id": 7, "type": "IfcGeometricRepresentationSubContext", "context_identifier": "Axis", "target_view": "GRAPH_VIEW"}
]
}
]
```
### materials
List all materials and material sets used in the model, with their assigned elements.
```bash
ifcquery model.ifc materials
```
```json
[
{
"id": 60,
"type": "IfcMaterial",
"name": "Concrete",
"elements": [{"id": 10, "type": "IfcWall", "name": "Wall001"}]
}
]
```
### plot
Generate a 2D technical drawing (floor plan, elevation, or section) of the model and write it to a file.
```bash
ifcquery model.ifc plot output.svg
ifcquery model.ifc plot output.png --view floorplan --scale 0.01
```
Options:
- `--view {floorplan,elevation,section,auto}` -- drawing view (default: `floorplan`)
- `--scale <ratio>` -- model-to-paper scale ratio (default: 0.01 = 1:100)
- `--width-mm <mm>` -- paper width in mm (default: 297)
- `--height-mm <mm>` -- paper height in mm (default: 420)
- `--png-width <px>` -- raster output width in pixels (default: 1024)
- `--png-height <px>` -- raster output height in pixels (default: 1024)
Writes SVG when the output path ends in `.svg`, otherwise PNG.
Requires the IfcOpenShell drawing module (`ifcopenshell.draw`).
### render
Render a 3D view of the model geometry to a PNG file.
```bash
ifcquery model.ifc render output.png
ifcquery model.ifc render output.png --view iso --selector IfcWall
```
Options:
- `--view {iso,top,south,north,east,west}` -- camera angle (default: `iso`)
- `--selector <query>` -- ifcopenshell selector to restrict rendered elements
Requires `pyvista` and the IfcOpenShell C++ geometry bindings.
### clash
Check a single element for geometric intersections and clearance violations
against other elements.
```bash
ifcquery model.ifc clash 10
ifcquery model.ifc clash 10 --clearance 0.5
ifcquery model.ifc clash 10 --scope all --tolerance 0.001
```
Options:
- `--clearance <meters>` -- minimum clearance distance to check
- `--tolerance <meters>` -- intersection tolerance (default: 0.002)
- `--scope {storey,all}` -- check against same-storey elements or all elements (default: storey)
```json
{
"element": {"id": 10, "type": "IfcWall", "name": "Wall001"},
"scope": "storey",
"pass": false,
"checks": {
"intersection": {
"pass": false,
"tolerance": 0.002,
"clashes": [
{
"element": {"id": 11, "type": "IfcWall", "name": "Wall002"},
"type": "intersection",
"distance": 0.0,
"p1": [2.5, 2.5, 1.5],
"p2": [2.5, 2.5, 1.5]
}
]
},
"clearance": {
"pass": true,
"clearance": 0.5,
"clashes": []
}
}
}
```
Requires the IfcOpenShell C++ geometry bindings.
## Error handling
Errors are written to stderr. Exit code is 0 on success, 1 on error.
## License
LGPLv3+ -- see the IfcOpenShell project license.
+20
View File
@@ -0,0 +1,20 @@
# This file was generated with the assistance of an AI coding tool.
# IfcQuery - IFC model interrogation CLI
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
#
# This file is part of IfcQuery.
#
# IfcQuery is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcQuery is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcQuery. If not, see <http://www.gnu.org/licenses/>.
__version__ = version = "0.0.0"
+378
View File
@@ -0,0 +1,378 @@
# This file was generated with the assistance of an AI coding tool.
# IfcQuery - IFC model interrogation CLI
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
#
# This file is part of IfcQuery.
#
# IfcQuery is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcQuery is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcQuery. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
import argparse
import json
import os
import sys
import ifcopenshell
from ifcquery import clash as clash_mod
from ifcquery import contexts as contexts_mod
from ifcquery import cost as cost_mod
from ifcquery import (
info,
plot,
relations,
schedule,
schema,
select,
summary,
tree,
)
from ifcquery import (
materials as materials_mod,
)
from ifcquery import (
render as render_mod,
)
from ifcquery import validate as validate_mod
def parse_element_id(raw: str) -> int:
"""Parse an element ID from '#123' or '123' format."""
raw = raw.strip().lstrip("#")
return int(raw)
def format_output(data, fmt: str) -> str:
if fmt == "json":
return json.dumps(data, indent=2, ensure_ascii=False)
elif fmt == "text":
return _format_text(data)
return json.dumps(data, indent=2, ensure_ascii=False)
def _format_text(data, indent: int = 0) -> str:
prefix = " " * indent
lines = []
if isinstance(data, dict):
for key, value in data.items():
if isinstance(value, (dict, list)):
lines.append(f"{prefix}{key}:")
lines.append(_format_text(value, indent + 1))
else:
lines.append(f"{prefix}{key}: {value}")
elif isinstance(data, list):
for item in data:
if isinstance(item, dict):
lines.append(_format_text(item, indent))
lines.append("")
else:
lines.append(f"{prefix}- {item}")
else:
lines.append(f"{prefix}{data}")
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(
prog="ifcquery",
description="Query and inspect IFC building models",
)
parser.add_argument("ifc_file", help="Path to the IFC file")
parser.add_argument(
"--format",
choices=["json", "text"],
default="json",
dest="output_format",
help="Output format (default: json)",
)
subparsers = parser.add_subparsers(dest="command", required=True)
subparsers.add_parser("summary", help="Model overview: schema, element counts, project info")
subparsers.add_parser("tree", help="Spatial hierarchy tree")
info_parser = subparsers.add_parser("info", help="Deep inspection of a specific element")
info_parser.add_argument("element_id", help="Element step ID (e.g. 123 or #123)")
select_parser = subparsers.add_parser("select", help="Filter elements using selector syntax")
select_parser.add_argument("query", help="Selector query string")
relations_parser = subparsers.add_parser("relations", help="Show relationships for an element")
relations_parser.add_argument("element_id", help="Element step ID (e.g. 123 or #123)")
relations_parser.add_argument("--traverse", choices=["up"], help="Traverse hierarchy (up: walk to IfcProject)")
clash_parser = subparsers.add_parser("clash", help="Check element placement for clashes")
clash_parser.add_argument("element_id", help="Element step ID (e.g. 123 or #123)")
clash_parser.add_argument("--clearance", type=float, help="Minimum clearance distance")
clash_parser.add_argument("--tolerance", type=float, default=0.002, help="Intersection tolerance (default: 0.002)")
clash_parser.add_argument(
"--scope", choices=["storey", "all"], default="storey", help="Scope of elements to check (default: storey)"
)
validate_parser = subparsers.add_parser("validate", help="Schema/constraint validation")
validate_parser.add_argument(
"--rules", action="store_true", help="Also check EXPRESS rules (slower, default: false)"
)
schedule_parser = subparsers.add_parser("schedule", help="List work plans and tasks from the model")
schedule_parser.add_argument(
"--depth", type=int, default=None, metavar="N", help="Limit subtask expansion to N levels (default: unlimited)"
)
cost_parser = subparsers.add_parser("cost", help="List cost schedules and cost items from the model")
cost_parser.add_argument(
"--depth",
type=int,
default=None,
metavar="N",
help="Limit cost item expansion to N levels (default: unlimited)",
)
subparsers.add_parser("contexts", help="List geometric representation contexts and subcontexts")
subparsers.add_parser("materials", help="List materials and material sets")
schema_parser = subparsers.add_parser("schema", help="IFC class documentation")
schema_parser.add_argument("entity_type", help="IFC entity type (e.g. IfcWall)")
render_parser = subparsers.add_parser("render", help="Render model geometry to a PNG image")
render_parser.add_argument(
"-o", "--output", default="", metavar="FILE", help="Output PNG path (default: <ifc_file>.png)"
)
render_parser.add_argument(
"--selector", default="", metavar="QUERY", help="ifcopenshell selector to restrict rendered elements"
)
render_parser.add_argument(
"--element",
default="",
metavar="ID[,ID...]",
help="Comma-separated step IDs of elements to highlight (rest rendered in grey)",
)
render_parser.add_argument(
"--view",
choices=render_mod.VIEWS,
default="iso",
help="Camera angle (default: iso)",
)
plot_parser = subparsers.add_parser(
"plot", help="Plot model drawing (SVG via ifcopenshell.draw; optional PNG via CairoSVG)"
)
plot_parser.add_argument(
"-o",
"--output",
default="",
metavar="FILE",
help="Output file path. Default depends on --out-format: <ifc_file>.svg/.png",
)
plot_parser.add_argument(
"--out-format",
choices=["svg", "png", "base64"],
default="png",
help="Output format: svg (write SVG), png (write PNG), base64 (print base64 in JSON/text). Default: png",
)
plot_parser.add_argument(
"--selector", default="", metavar="QUERY", help="ifcopenshell selector to restrict plotted elements"
)
plot_parser.add_argument(
"--element", default="", metavar="ID[,ID...]", help="Comma-separated step IDs of elements to highlight"
)
plot_parser.add_argument(
"--view",
choices=getattr(plot, "VIEWS", ("floorplan", "elevation", "section", "auto")),
default="floorplan",
help="Drawing view (default: floorplan)",
)
plot_parser.add_argument(
"--width-mm",
type=float,
default=297.0,
metavar="MM",
help="Paper width in mm (default: 297)",
)
plot_parser.add_argument(
"--height-mm",
type=float,
default=420.0,
metavar="MM",
help="Paper height in mm (default: 420)",
)
plot_parser.add_argument(
"--scale",
type=float,
default=1.0 / 100.0,
metavar="S",
help="Model-to-paper scale (default: 0.01 = 1:100)",
)
plot_parser.add_argument(
"--png-width",
type=int,
default=1024,
metavar="PX",
help="PNG width in pixels (default: 1024)",
)
plot_parser.add_argument(
"--png-height",
type=int,
default=1024,
metavar="PX",
help="PNG height in pixels (default: 1024)",
)
args = parser.parse_args()
try:
model = ifcopenshell.open(args.ifc_file)
except Exception as e:
print(f"Error: Could not open IFC file: {e}", file=sys.stderr)
sys.exit(1)
if args.command == "summary":
result = summary.summary(model)
elif args.command == "tree":
result = tree.tree(model)
elif args.command == "info":
try:
element_id = parse_element_id(args.element_id)
except ValueError:
print(f"Error: Invalid element ID: {args.element_id}", file=sys.stderr)
sys.exit(1)
try:
element = model.by_id(element_id)
except RuntimeError:
print(f"Error: Element #{element_id} not found", file=sys.stderr)
sys.exit(1)
result = info.info(model, element)
elif args.command == "select":
result = select.select(model, args.query)
elif args.command == "relations":
try:
element_id = parse_element_id(args.element_id)
except ValueError:
print(f"Error: Invalid element ID: {args.element_id}", file=sys.stderr)
sys.exit(1)
try:
element = model.by_id(element_id)
except RuntimeError:
print(f"Error: Element #{element_id} not found", file=sys.stderr)
sys.exit(1)
result = relations.relations(model, element, traverse=args.traverse)
elif args.command == "clash":
try:
element_id = parse_element_id(args.element_id)
except ValueError:
print(f"Error: Invalid element ID: {args.element_id}", file=sys.stderr)
sys.exit(1)
try:
element = model.by_id(element_id)
except RuntimeError:
print(f"Error: Element #{element_id} not found", file=sys.stderr)
sys.exit(1)
try:
result = clash_mod.clash(
model, element, clearance=args.clearance, tolerance=args.tolerance, scope=args.scope
)
except ImportError:
print("Error: ifcopenshell geometry engine not available (C++ bindings required)", file=sys.stderr)
sys.exit(1)
elif args.command == "validate":
result = validate_mod.validate(model, express_rules=args.rules)
elif args.command == "schedule":
result = schedule.schedule(model, max_depth=args.depth)
elif args.command == "cost":
result = cost_mod.cost(model, max_depth=args.depth)
elif args.command == "contexts":
result = contexts_mod.contexts(model)
elif args.command == "materials":
result = materials_mod.materials(model)
elif args.command == "schema":
result = schema.schema(model, args.entity_type)
elif args.command == "render":
element_ids = None
if args.element:
try:
element_ids = [parse_element_id(part) for part in args.element.split(",")]
except ValueError:
print(f"Error: Invalid element ID(s): {args.element}", file=sys.stderr)
sys.exit(1)
out_path = args.output or (os.path.splitext(args.ifc_file)[0] + ".png")
try:
png_bytes = render_mod.render(
model,
selector=args.selector or None,
element_ids=element_ids,
view=args.view,
)
except ImportError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
except ValueError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
with open(out_path, "wb") as f:
f.write(png_bytes)
print(f"Saved render to {out_path}", file=sys.stderr)
return
elif args.command == "plot":
element_ids = None
if args.element:
try:
element_ids = [parse_element_id(part) for part in args.element.split(",")]
except ValueError:
print(f"Error: Invalid element ID(s): {args.element}", file=sys.stderr)
sys.exit(1)
try:
result = plot.plot(
model,
selector=args.selector or None,
element_ids=element_ids,
view=args.view,
width_mm=args.width_mm,
height_mm=args.height_mm,
scale=args.scale,
output_format=args.out_format,
)
except ImportError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
except ValueError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if args.out_format == "base64":
# result is a dict; serialise to stdout so callers can consume it
print(format_output(result, args.output_format))
return
# svg or png: write to a file
base = os.path.splitext(args.ifc_file)[0]
if args.out_format == "svg":
out_path = args.output or (base + ".svg")
else:
out_path = args.output or (base + ".png")
with open(out_path, "wb") as f:
f.write(result)
print(f"Saved drawing to {out_path}", file=sys.stderr)
return
print(format_output(result, args.output_format))
if __name__ == "__main__":
main()
+167
View File
@@ -0,0 +1,167 @@
# This file was generated with the assistance of an AI coding tool.
# IfcQuery - IFC model interrogation CLI
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
#
# This file is part of IfcQuery.
#
# IfcQuery is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcQuery is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcQuery. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
import multiprocessing
import sys
from typing import Any
import ifcopenshell
import ifcopenshell.geom
import ifcopenshell.util.element
def _ref(element: ifcopenshell.entity_instance) -> dict[str, Any]:
"""Serialize an element to a compact reference dict."""
result: dict[str, Any] = {"id": element.id(), "type": element.is_a()}
if hasattr(element, "Name") and element.Name:
result["name"] = element.Name
return result
def _get_scope_elements(
model: ifcopenshell.file, element: ifcopenshell.entity_instance, scope: str
) -> tuple[set[ifcopenshell.entity_instance], str]:
"""Return set of elements to check against and the effective scope used.
Returns (elements, effective_scope) where effective_scope may differ from
the requested scope if fallback was needed.
"""
if scope == "storey":
container = ifcopenshell.util.element.get_container(element)
if container is not None:
siblings = set(ifcopenshell.util.element.get_contained(container))
siblings.discard(element)
return siblings, "storey"
else:
print(
f"Warning: Element #{element.id()} has no spatial container, falling back to --scope all",
file=sys.stderr,
)
# scope == "all" or fallback
elements = set(model.by_type("IfcElement"))
elements -= set(model.by_type("IfcFeatureElement"))
elements.discard(element)
return elements, "all"
def _build_tree(model: ifcopenshell.file, elements: set[ifcopenshell.entity_instance]) -> ifcopenshell.geom.tree | None:
"""Build geometry tree for given elements using iterator.
Returns None if iterator fails to initialize (no geometry available).
"""
geom_settings = ifcopenshell.geom.settings()
geom_settings.set("use-world-coords", True)
geom_tree = ifcopenshell.geom.tree()
iterator = ifcopenshell.geom.iterator(geom_settings, model, multiprocessing.cpu_count(), include=list(elements))
if not iterator.initialize():
return None
while True:
geom_tree.add_element(iterator.get())
if not iterator.next():
break
return geom_tree
def _format_clash(clash_result, geom_tree: ifcopenshell.geom.tree, model: ifcopenshell.file) -> dict[str, Any]:
"""Format a single clash result to dict."""
# clash result .a/.b are C++ wrapper entity_instances without .Name;
# look up the Python entity from the model by id for proper serialization
other = model.by_id(clash_result.b.id())
return {
"element": _ref(other),
"type": geom_tree.get_clash_type(clash_result.clash_type),
"distance": clash_result.distance,
"p1": list(clash_result.p1),
"p2": list(clash_result.p2),
}
def clash(
model: ifcopenshell.file,
element: ifcopenshell.entity_instance,
clearance: float | None = None,
tolerance: float = 0.002,
scope: str = "storey",
) -> dict[str, Any]:
"""Check element for geometric clashes against other elements.
:param model: The IFC model.
:param element: The element to check.
:param clearance: Minimum clearance distance; if provided, runs clearance check.
:param tolerance: Intersection tolerance in meters (default 0.002).
:param scope: Which elements to check against: "storey" or "all".
:return: Dict with clash results suitable for JSON serialization.
"""
result: dict[str, Any] = {"element": _ref(element)}
# Get scope elements
scope_elements, effective_scope = _get_scope_elements(model, element, scope)
result["scope"] = effective_scope
if not scope_elements:
result["pass"] = True
result["checks"] = {"intersection": {"pass": True, "tolerance": tolerance, "clashes": []}}
if clearance is not None:
result["checks"]["clearance"] = {"pass": True, "clearance": clearance, "clashes": []}
return result
# Build geometry tree for target element + scope elements
all_elements = scope_elements | {element}
geom_tree = _build_tree(model, all_elements)
if geom_tree is None:
result["pass"] = None
result["error"] = f"No geometry for element #{element.id()}"
return result
# Run intersection check
intersection_clashes = geom_tree.clash_intersection_many(
[element], list(scope_elements), tolerance=tolerance, check_all=True
)
intersection_results = [_format_clash(c, geom_tree, model) for c in intersection_clashes]
checks: dict[str, Any] = {
"intersection": {
"pass": len(intersection_results) == 0,
"tolerance": tolerance,
"clashes": intersection_results,
}
}
all_pass = len(intersection_results) == 0
# Run clearance check if requested
if clearance is not None:
clearance_clashes = geom_tree.clash_clearance_many(
[element], list(scope_elements), clearance=clearance, check_all=True
)
clearance_results = [_format_clash(c, geom_tree, model) for c in clearance_clashes]
checks["clearance"] = {
"pass": len(clearance_results) == 0,
"clearance": clearance,
"clashes": clearance_results,
}
if clearance_results:
all_pass = False
result["pass"] = all_pass
result["checks"] = checks
return result
+44
View File
@@ -0,0 +1,44 @@
# 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 ifcopenshell
def contexts(model: ifcopenshell.file) -> list[dict]:
"""Return all geometric representation contexts and subcontexts.
:param model: The in-memory IFC model.
:return: List of dicts with id, type, context_type, context_identifier,
and (for subcontexts) target_view and parent_context_id.
"""
results = []
for ctx in model.by_type("IfcGeometricRepresentationContext"):
entry = {
"id": ctx.id(),
"type": ctx.is_a(),
"context_type": getattr(ctx, "ContextType", None),
"context_identifier": getattr(ctx, "ContextIdentifier", None),
}
if ctx.is_a("IfcGeometricRepresentationSubContext"):
entry["target_view"] = ctx.TargetView
parent = ctx.ParentContext
entry["parent_context_id"] = parent.id() if parent else None
results.append(entry)
return results
+45
View File
@@ -0,0 +1,45 @@
# This file was generated with the assistance of an AI coding tool.
from __future__ import annotations
from typing import Any
import ifcopenshell
import ifcopenshell.util.cost as cost_util
def _cost_item_to_dict(item: ifcopenshell.entity_instance, max_depth: int | None, depth: int) -> dict[str, Any]:
raw_values = cost_util.get_cost_values(item)
values = [{"formula": v.get("label", ""), "category": v.get("category")} for v in raw_values]
if max_depth is not None and depth >= max_depth:
child_count = len(cost_util.get_nested_cost_items(item))
subitems = {"truncated": True, "count": child_count} if child_count else []
else:
subitems = [_cost_item_to_dict(sub, max_depth, depth + 1) for sub in cost_util.get_nested_cost_items(item)]
return {
"id": item.id(),
"name": getattr(item, "Name", None),
"values": values,
"subitems": subitems,
}
def cost(model: ifcopenshell.file, max_depth: int | None = None) -> list[dict[str, Any]]:
"""Return a list of IfcCostSchedule entries with nested cost item trees.
max_depth limits how many levels of subitems are expanded (None = unlimited).
At the cutoff level, subitems is replaced with {"truncated": True, "count": N}.
"""
result = []
for cost_schedule in model.by_type("IfcCostSchedule"):
items = [_cost_item_to_dict(i, max_depth, depth=1) for i in cost_util.get_root_cost_items(cost_schedule)]
result.append(
{
"id": cost_schedule.id(),
"name": getattr(cost_schedule, "Name", None),
"predefined_type": getattr(cost_schedule, "PredefinedType", None),
"items": items,
}
)
return result
+262
View File
@@ -0,0 +1,262 @@
# This file was generated with the assistance of an AI coding tool.
# IfcQuery - IFC model interrogation CLI
# Copyright (C) 2026 Bruno Postle <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
# ---------------------------------------------------------------------------
# Geometry summary helpers
# ---------------------------------------------------------------------------
_MAX_PROFILE_POINTS = 20
def _rc(coords) -> list[float]:
"""Round a coordinate sequence to 6 decimal places."""
return [round(float(c), 6) for c in coords]
def _curve_points(curve) -> list | None:
if curve.is_a("IfcPolyline"):
return [_rc(p.Coordinates) for p in curve.Points]
if curve.is_a("IfcIndexedPolyCurve"):
return [_rc(c) for c in curve.Points.CoordList]
return None
def _profile_summary(profile) -> dict:
t = profile.is_a()
result: dict[str, Any] = {"type": t}
if t == "IfcRectangleProfileDef":
result["x_dim"] = profile.XDim
result["y_dim"] = profile.YDim
elif t in ("IfcCircleProfileDef", "IfcCircleHollowProfileDef"):
result["radius"] = profile.Radius
if t == "IfcCircleHollowProfileDef":
result["wall_thickness"] = profile.WallThickness
elif t in ("IfcArbitraryClosedProfileDef", "IfcArbitraryProfileDefWithVoids"):
pts = _curve_points(profile.OuterCurve)
if pts is not None:
if len(pts) <= _MAX_PROFILE_POINTS:
result["points"] = pts
else:
result["point_count"] = len(pts)
elif t == "IfcCompositeProfileDef":
result["profiles"] = [_profile_summary(p) for p in profile.Profiles]
return result
def _half_space_plane(half_space) -> dict | None:
if not half_space.is_a("IfcHalfSpaceSolid"):
return None
surface = half_space.BaseSurface
if not surface or not surface.is_a("IfcPlane"):
return None
pos = surface.Position
loc = _rc(pos.Location.Coordinates)
normal = _rc(pos.Axis.DirectionRatios) if pos.Axis else [0.0, 0.0, 1.0]
return {"location": loc, "normal": normal}
def _walk_clipping(item) -> tuple:
"""Return (base_solid, [clipping_plane_dicts]) from a BooleanClippingResult chain."""
planes = []
current = item
while current.is_a("IfcBooleanClippingResult"):
plane = _half_space_plane(current.SecondOperand)
if plane:
planes.append(plane)
current = current.FirstOperand
return current, planes
def _swept_solid_dict(item) -> dict:
result: dict[str, Any] = {"solid_type": item.is_a()}
if item.is_a("IfcExtrudedAreaSolid"):
result["depth"] = item.Depth
if item.ExtrudedDirection:
result["direction"] = _rc(item.ExtrudedDirection.DirectionRatios)
if item.SweptArea:
result["profile"] = _profile_summary(item.SweptArea)
return result
def _summarize_rep(rep) -> dict:
rep_type = rep.RepresentationType or ""
result: dict[str, Any] = {"representation_type": rep_type}
items = list(rep.Items)
if rep_type == "MappedRepresentation":
for item in items:
if item.is_a("IfcMappedItem"):
return _summarize_rep(item.MappingSource.MappedRepresentation)
elif rep_type == "SweptSolid":
result["solids"] = [_swept_solid_dict(item) for item in items]
elif rep_type == "Clipping":
solids = []
for item in items:
base, planes = _walk_clipping(item)
solid = _swept_solid_dict(base)
if planes:
solid["clipping_planes"] = planes
solids.append(solid)
result["solids"] = solids
elif rep_type == "CSG":
ops = []
for item in items:
if hasattr(item, "Operator"):
ops.append({"operator": str(item.Operator), "type": item.is_a()})
if ops:
result["operations"] = ops
elif rep_type in ("Brep", "Tessellation", "SolidModel"):
face_count = 0
vertex_count = 0
for item in items:
if item.is_a("IfcPolygonalFaceSet"):
face_count += len(item.Faces)
vertex_count += len(item.Coordinates.CoordList)
elif item.is_a("IfcFacetedBrep"):
face_count += len(item.Outer.CfsFaces)
if face_count:
result["face_count"] = face_count
if vertex_count:
result["vertex_count"] = vertex_count
return result
def _geometry_summary(element) -> dict | None:
if not hasattr(element, "Representation") or not element.Representation:
return None
body_rep = next(
(r for r in element.Representation.Representations if r.RepresentationIdentifier == "Body"),
None,
)
if body_rep is None:
return None
try:
return _summarize_rep(body_rep)
except Exception:
return None
def _serialize_attribute(value: Any) -> Any:
"""Convert an IFC attribute value to a JSON-serializable form."""
if isinstance(value, ifcopenshell.entity_instance):
return {"id": value.id(), "type": value.is_a()}
if isinstance(value, tuple):
return [_serialize_attribute(v) for v in value]
return value
def _material_to_dict(material: ifcopenshell.entity_instance | None) -> dict[str, Any] | None:
"""Convert a material entity to a summary dict."""
if material is None:
return None
result: dict[str, Any] = {
"id": material.id(),
"type": material.is_a(),
}
if hasattr(material, "Name"):
result["name"] = material.Name
return result
def info(model: ifcopenshell.file, element: ifcopenshell.entity_instance) -> dict[str, Any]:
"""Return deep inspection data for an element."""
result: dict[str, Any] = {
"id": element.id(),
"type": element.is_a(),
}
# Direct attributes via get_info() which returns a dict of all attributes
element_info = element.get_info()
attrs = {}
for key, value in element_info.items():
if key in ("id", "type"):
continue
attrs[key] = _serialize_attribute(value)
result["attributes"] = attrs
# Property sets and quantity sets
try:
psets = ifcopenshell.util.element.get_psets(element)
if psets:
result["property_sets"] = psets
except Exception:
pass
# Element type
try:
element_type = ifcopenshell.util.element.get_type(element)
if element_type:
type_info: dict[str, Any] = {
"id": element_type.id(),
"type": element_type.is_a(),
}
if hasattr(element_type, "Name"):
type_info["name"] = element_type.Name
result["element_type"] = type_info
except Exception:
pass
# Material
try:
material = ifcopenshell.util.element.get_material(element)
mat_dict = _material_to_dict(material)
if mat_dict:
result["material"] = mat_dict
except Exception:
pass
# Spatial container
try:
container = ifcopenshell.util.element.get_container(element)
if container:
result["container"] = {
"id": container.id(),
"type": container.is_a(),
"name": container.Name if hasattr(container, "Name") else None,
}
except Exception:
pass
# Placement (as 4x4 matrix)
try:
if hasattr(element, "ObjectPlacement") and element.ObjectPlacement:
matrix = ifcopenshell.util.placement.get_local_placement(element.ObjectPlacement)
result["placement"] = matrix.tolist()
except Exception:
pass
# Geometry summary
geom = _geometry_summary(element)
if geom:
result["geometry_summary"] = geom
return result
+100
View File
@@ -0,0 +1,100 @@
# 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 ifcopenshell
def materials(model: ifcopenshell.file) -> list[dict]:
"""Return all materials and material sets from the model.
:param model: The in-memory IFC model.
:return: List of dicts covering IfcMaterial, IfcMaterialLayerSet,
IfcMaterialConstituentSet, and IfcMaterialProfileSet entities.
"""
results = []
for m in model.by_type("IfcMaterial"):
results.append(
{
"id": m.id(),
"type": "IfcMaterial",
"name": m.Name,
"category": getattr(m, "Category", None),
}
)
for ls in model.by_type("IfcMaterialLayerSet"):
layers = []
for layer in ls.MaterialLayers or []:
layers.append(
{
"name": layer.Name,
"thickness": layer.LayerThickness,
"material": layer.Material.Name if layer.Material else None,
"is_ventilated": layer.IsVentilated,
}
)
results.append(
{
"id": ls.id(),
"type": "IfcMaterialLayerSet",
"name": ls.LayerSetName,
"layers": layers,
}
)
for cs in model.by_type("IfcMaterialConstituentSet"):
constituents = []
for c in cs.MaterialConstituents or []:
constituents.append(
{
"name": c.Name,
"material": c.Material.Name if c.Material else None,
"fraction": c.Fraction,
}
)
results.append(
{
"id": cs.id(),
"type": "IfcMaterialConstituentSet",
"name": cs.Name,
"constituents": constituents,
}
)
for ps in model.by_type("IfcMaterialProfileSet"):
profiles = []
for p in ps.MaterialProfiles or []:
profiles.append(
{
"name": p.Name,
"material": p.Material.Name if p.Material else None,
}
)
results.append(
{
"id": ps.id(),
"type": "IfcMaterialProfileSet",
"name": ps.Name,
"profiles": profiles,
}
)
return results
+284
View File
@@ -0,0 +1,284 @@
# 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 base64
import os
from io import BytesIO
from typing import Any
import ifcopenshell
import ifcopenshell.geom
import ifcopenshell.util.selector
try:
import ifcopenshell.draw
_HAS_DRAW = True
except ImportError:
_HAS_DRAW = False
from xml.etree.ElementTree import Element, ElementTree, SubElement, register_namespace
try:
import cairosvg # type: ignore
_HAS_CAIROSVG = True
except Exception:
_HAS_CAIROSVG = False
try:
from PIL import Image # type: ignore
_HAS_PIL = True
except Exception:
_HAS_PIL = False
VIEWS = ("floorplan", "elevation", "section", "auto")
OUTPUT_FORMATS = ("svg", "png", "base64")
def _escape_css_attr(name: str) -> str:
# CSS attribute selectors must escape ':' (e.g. ifc:guid -> ifc\:guid)
return name.replace(":", "\\:")
def _highlight_css_from_ids(model: ifcopenshell.file, element_ids: list[int]) -> str:
guids: list[str] = []
for sid in element_ids:
try:
e = model.by_id(int(sid))
except RuntimeError:
continue
if e is None:
continue
gid = getattr(e, "GlobalId", None)
if isinstance(gid, str) and gid:
guids.append(gid)
if not guids:
return ""
attr = _escape_css_attr("ifc:guid")
css = [
"/* Auto-highlight injected by ifcquery.plot */",
f"[{attr}] path {{ opacity: 0.10; }}",
f"[{attr}] text {{ opacity: 0.25; }}",
]
for gid in guids:
css.append(f'[{attr}="{gid}"] path {{ opacity: 1.0; stroke: #d00; stroke-width: 0.25; }}')
css.append(f'[{attr}="{gid}"] text {{ opacity: 1.0; fill: #d00; }}')
return "\n".join(css) + "\n"
def _make_filtered_iterator(model: ifcopenshell.file, include_elements: list[Any]) -> ifcopenshell.geom.iterator:
# Avoid multiprocessing in WASM; os.cpu_count is good enough.
n_threads = os.cpu_count() or 1
# These flags mirror the defaults used by ifcopenshell.draw in v0.8.x.
geom_settings = ifcopenshell.geom.settings(
REORIENT_SHELLS=False,
ELEMENT_HIERARCHY=True,
)
# IfcOpenShell wrapper constants may live in different places across builds.
wrapper = getattr(ifcopenshell, "ifcopenshell_wrapper", None)
if wrapper is not None:
try:
geom_settings.set("iterator-output", wrapper.NATIVE)
except Exception:
pass
try:
geom_settings.set("apply-default-materials", True)
except Exception:
pass
try:
geom_settings.set("dimensionality", wrapper.SURFACES_AND_SOLIDS)
except Exception:
pass
return ifcopenshell.geom.iterator(geom_settings, model, n_threads, include=include_elements)
def _diagnose_empty_drawing(model: ifcopenshell.file, view: str) -> str:
"""Return a helpful error message when ifcopenshell.draw produces no geometry groups."""
hints = []
if view in ("floorplan", "auto"):
storeys = model.by_type("IfcBuildingStorey")
if not storeys:
hints.append("the model has no IfcBuildingStorey entities (required for auto_floorplan)")
else:
null_elevation = [s for s in storeys if getattr(s, "Elevation", None) is None]
if null_elevation:
names = ", ".join(f'"{s.Name or s.GlobalId}"' for s in null_elevation)
hints.append(
f"storey Elevation is None for: {names}"
"set IfcBuildingStorey.Elevation (e.g. 0.0) so the section cut height can be determined"
)
has_geom = any(getattr(e, "Representation", None) is not None for e in model.by_type("IfcProduct"))
if not has_geom:
hints.append("no IfcProduct entities have geometric representations")
base = f"No plan geometry found for view={view!r}."
if hints:
return base + " Possible causes: " + "; ".join(hints) + "."
return base + " The model may lack geometry visible in this view."
def plot(
model: ifcopenshell.file,
*,
output_format: str = "png",
selector: str | None = None,
element_ids: list[int] | None = None,
view: str = "floorplan",
# SVG / page sizing (draw works in mm coordinates)
width_mm: float = 297.0,
height_mm: float = 420.0,
scale: float = 1.0 / 100.0,
merge_projection: bool = True,
# PNG sizing (only for output_format png/base64)
png_width: int = 1024,
png_height: int = 1024,
) -> bytes | dict[str, Any]:
"""
Plot IFC model as SVG (via ifcopenshell.draw) or PNG/base64 (via CairoSVG).
Args:
model: In-memory IFC model.
output_format: 'svg' | 'png' | 'base64'
- 'svg' -> returns SVG bytes
- 'png' -> returns PNG bytes
- 'base64'-> returns dict: {mime, png_b64, width, height, view}
selector: ifcopenshell selector query to restrict plotted elements.
element_ids: STEP ids to highlight; non-highlighted geometry is faded.
view: One of VIEWS ('floorplan', 'elevation', 'section', 'auto').
width_mm, height_mm: Page size in mm.
scale: Model-to-paper scale (0.01 means 1:100).
merge_projection: Passed through to ifcopenshell.draw.main.
png_width, png_height: Raster size in pixels for png/base64 outputs.
Raises:
ImportError: if ifcopenshell.draw or CairoSVG is not available (as required).
ValueError: invalid args or selector matches nothing.
"""
if output_format not in OUTPUT_FORMATS:
raise ValueError(f"output_format must be one of {OUTPUT_FORMATS}, got {output_format!r}")
if view not in VIEWS:
raise ValueError(f"view must be one of {VIEWS}, got {view!r}")
if not _HAS_DRAW:
raise ImportError("ifcopenshell.draw is not available in this environment.")
# Configure draw settings
settings = ifcopenshell.draw.draw_settings(
auto_floorplan=(view in ("floorplan", "auto")),
auto_elevation=(view in ("elevation", "auto")),
auto_section=(view in ("section", "auto")),
width=width_mm,
height=height_mm,
scale=scale,
css="",
)
# Optional highlight CSS overlay
if element_ids:
settings.css = _highlight_css_from_ids(model, element_ids)
# Optional element restriction via selector -> custom iterator
iterators: tuple[Any, ...] = ()
if selector:
include_elements = list(ifcopenshell.util.selector.filter_elements(model, selector))
if not include_elements:
raise ValueError(f"Selector {selector!r} matched no elements")
it = _make_filtered_iterator(model, include_elements)
iterators = (it,)
# If we explicitly include elements, don't rely on exclude_entities (best-effort).
settings.exclude_entities = ""
# Generate SVG
svg_bytes = ifcopenshell.draw.main(
settings,
files=[model],
iterators=iterators,
merge_projection=merge_projection,
)
register_namespace("", "http://www.w3.org/2000/svg")
def svg_split(f):
x = ElementTree(file=f)
svg = x.getroot()
resources = []
for child in svg:
if child.tag == "{http://www.w3.org/2000/svg}g":
root = Element(svg.tag, svg.attrib)
n = ElementTree(root)
for r in resources + [child]:
root.append(r)
b = BytesIO()
n.write(b, xml_declaration=True, encoding="utf-8", method="xml")
yield b.getvalue()
else:
resources.append(child)
if output_format == "svg":
return svg_bytes
# Need CairoSVG for png/base64
if not _HAS_CAIROSVG:
raise ImportError("CairoSVG is not installed. Install with: pip install cairosvg")
svgs = list(svg_split(BytesIO(svg_bytes)))
if not svgs:
raise ValueError(_diagnose_empty_drawing(model, view))
composite = None
png_bytes = None
for i, svgb in enumerate(svgs):
png_bytes = cairosvg.svg2png(bytestring=svgb, output_width=png_width, output_height=png_height)
if len(svgs) == 1:
break
# Need Pillow for concatenating images
if not _HAS_PIL:
raise ImportError("Pillow is not installed. Install with: pip install Pillow")
if composite is None:
composite = Image.new("RGBA", (png_width, png_height * len(svgs)))
img = Image.open(BytesIO(png_bytes))
composite.paste(img, (0, png_height * i))
if composite is not None:
b = BytesIO()
composite.save(b, "png")
png_bytes = b.getvalue()
if output_format == "base64":
return {
"mime": "image/png",
"png_b64": base64.b64encode(png_bytes).decode(),
"width": png_width,
"height": png_height,
"view": view,
}
return png_bytes
+169
View File
@@ -0,0 +1,169 @@
# This file was generated with the assistance of an AI coding tool.
# IfcQuery - IFC model interrogation CLI
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
#
# This file is part of IfcQuery.
#
# IfcQuery is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcQuery is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcQuery. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
from typing import Any
import ifcopenshell
import ifcopenshell.util.element
import ifcopenshell.util.system
def _ref(element: ifcopenshell.entity_instance) -> dict[str, Any]:
"""Serialize an element to a compact reference dict."""
result: dict[str, Any] = {"id": element.id(), "type": element.is_a()}
if hasattr(element, "Name") and element.Name:
result["name"] = element.Name
return result
def _ref_or_none(element: ifcopenshell.entity_instance | None) -> dict[str, Any] | None:
return _ref(element) if element is not None else None
def _ref_list(elements) -> list[dict[str, Any]]:
return [_ref(e) for e in elements]
def _traverse_up(element: ifcopenshell.entity_instance) -> list[dict[str, Any]]:
"""Walk the hierarchy from element up to IfcProject."""
chain = [_ref(element)]
current = element
while True:
parent = ifcopenshell.util.element.get_parent(current)
if parent is None:
break
chain.append(_ref(parent))
current = parent
return chain
def _all_relations(model: ifcopenshell.file, element: ifcopenshell.entity_instance) -> dict[str, Any]:
"""Collect all relationships for an element."""
result: dict[str, Any] = {
"id": element.id(),
"type": element.is_a(),
}
if hasattr(element, "Name") and element.Name:
result["name"] = element.Name
# Hierarchy (upward)
hierarchy: dict[str, Any] = {}
parent = ifcopenshell.util.element.get_parent(element)
if parent is not None:
hierarchy["parent"] = _ref(parent)
container = ifcopenshell.util.element.get_container(element)
if container is not None:
hierarchy["container"] = _ref(container)
aggregate = ifcopenshell.util.element.get_aggregate(element)
if aggregate is not None:
hierarchy["aggregate"] = _ref(aggregate)
nest = ifcopenshell.util.element.get_nest(element)
if nest is not None:
hierarchy["nest"] = _ref(nest)
filled_void = ifcopenshell.util.element.get_filled_void(element)
if filled_void is not None:
hierarchy["filled_void"] = _ref(filled_void)
voided_element = ifcopenshell.util.element.get_voided_element(element)
if voided_element is not None:
hierarchy["voided_element"] = _ref(voided_element)
if hierarchy:
result["hierarchy"] = hierarchy
# Children (downward)
children: dict[str, Any] = {}
contained = ifcopenshell.util.element.get_contained(element)
if contained:
children["contained"] = _ref_list(contained)
parts = ifcopenshell.util.element.get_parts(element)
if parts:
children["parts"] = _ref_list(parts)
components = ifcopenshell.util.element.get_components(element)
if components:
children["components"] = _ref_list(components)
openings = list(ifcopenshell.util.element.get_openings(element))
if openings:
children["openings"] = _ref_list(openings)
if children:
result["children"] = children
# Type relationship
type_relationship: dict[str, Any] = {}
element_type = ifcopenshell.util.element.get_type(element)
if element_type is not None:
type_relationship["type_of"] = _ref(element_type)
try:
occurrences = ifcopenshell.util.element.get_types(element)
if occurrences:
type_relationship["occurrences"] = _ref_list(occurrences)
except Exception:
pass
if type_relationship:
result["type_relationship"] = type_relationship
# Groups
groups = ifcopenshell.util.element.get_groups(element)
if groups:
result["groups"] = _ref_list(groups)
# Systems
systems = ifcopenshell.util.system.get_element_systems(element)
if systems:
result["systems"] = _ref_list(systems)
# Zones
zones = ifcopenshell.util.system.get_element_zones(element)
if zones:
result["zones"] = _ref_list(zones)
# Material
material = ifcopenshell.util.element.get_material(element)
if material is not None:
result["material"] = _ref(material)
# Referenced structures
referenced = ifcopenshell.util.element.get_referenced_structures(element)
if referenced:
result["referenced_structures"] = _ref_list(referenced)
# Connections
connections: dict[str, Any] = {}
connected_to = ifcopenshell.util.system.get_connected_to(element)
if connected_to:
connections["connected_to"] = _ref_list(connected_to)
connected_from = ifcopenshell.util.system.get_connected_from(element)
if connected_from:
connections["connected_from"] = _ref_list(connected_from)
ports = ifcopenshell.util.system.get_ports(element)
if ports:
connections["ports"] = _ref_list(ports)
if connections:
result["connections"] = connections
return result
def relations(
model: ifcopenshell.file, element: ifcopenshell.entity_instance, traverse: str | None = None
) -> dict[str, Any] | list[dict[str, Any]]:
"""Return relationships for an element, or hierarchy chain if traverse='up'."""
if traverse == "up":
return _traverse_up(element)
return _all_relations(model, element)
+465
View File
@@ -0,0 +1,465 @@
# IfcQuery - IFC model interrogation CLI
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
#
# This file is part of IfcQuery.
#
# IfcQuery is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcQuery is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcQuery. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
import multiprocessing
import os
import tempfile
import ifcopenshell
import ifcopenshell.geom
import ifcopenshell.guid
import ifcopenshell.util.selector
try:
import numpy as np
import pyvista as pv
_HAS_PYVISTA = True
except ImportError:
_HAS_PYVISTA = False
VIEWS = ("iso", "top", "south", "north", "east", "west")
def _apply_view(plotter: pv.Plotter, view: str) -> None:
"""Set the camera to the requested named view. Z is up (IFC convention)."""
if view == "top":
plotter.view_xy()
elif view == "south":
# Camera at -Y looking toward +Y (south face of building)
plotter.view_xz(negative=True)
elif view == "north":
plotter.view_xz(negative=False)
elif view == "east":
plotter.view_yz(negative=False)
elif view == "west":
plotter.view_yz(negative=True)
else:
plotter.view_isometric()
# Ensure Z is world up for elevation views
if view not in ("top",):
plotter.camera.up = (0, 0, 1)
def _add_shape(
shape: object,
plotter: pv.Plotter,
highlight_ids: frozenset[int] | None,
) -> None:
"""Triangulate and add a geometry shape to the plotter."""
geom = shape.geometry
verts = np.array(geom.verts, dtype=float).reshape(-1, 3)
if verts.size == 0:
return
raw_faces = np.array(geom.faces, dtype=int)
if raw_faces.size == 0 or raw_faces.size % 3 != 0:
return # degenerate geometry from kernel — skip silently
faces = raw_faces.reshape(-1, 3)
material_ids = np.array(geom.material_ids, dtype=int)
is_subject = highlight_ids is not None and shape.product.id() in highlight_ids
for midx, mat in enumerate(geom.materials):
tri_mask = material_ids == midx
if not np.any(tri_mask):
continue
sub_faces = faces[tri_mask]
faces_pv = np.hstack([np.full((sub_faces.shape[0], 1), 3, dtype=int), sub_faces]).ravel()
mesh = pv.PolyData(verts, faces_pv)
if highlight_ids is not None and not is_subject:
color = (180, 180, 180)
opacity = 0.10
else:
diffuse = np.clip(np.array(mat.diffuse.components), 0.0, 1.0)
color = tuple((diffuse * 255).astype(np.uint8))
transparency = mat.transparency if mat.transparency == mat.transparency else 0.0
opacity = float(np.clip(1.0 - transparency, 0.0, 1.0))
plotter.add_mesh(mesh, color=color, opacity=opacity, show_edges=False)
def _render_iterator(
iterator: object,
highlight_ids: list[int] | None,
view: str,
) -> bytes:
"""Drive a geometry iterator into a pyvista plotter and return PNG bytes."""
plotter = pv.Plotter(off_screen=True, window_size=(1280, 960))
plotter.background_color = "white"
while True:
try:
_add_shape(iterator.get(), plotter, highlight_ids=frozenset(highlight_ids) if highlight_ids else None)
except Exception:
pass # skip broken shapes, keep rendering the rest
if not iterator.next():
break
plotter.reset_camera()
_apply_view(plotter, view)
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".png")
os.close(tmp_fd)
try:
plotter.show(screenshot=tmp_path, auto_close=True)
with open(tmp_path, "rb") as f:
return f.read()
finally:
try:
os.unlink(tmp_path)
except OSError:
pass
def _build_geom_settings(model: ifcopenshell.file) -> ifcopenshell.geom.settings:
"""Build geometry settings, excluding Clearance subcontexts."""
settings = ifcopenshell.geom.settings()
settings.set("use-world-coords", True)
clearance_ids = {
c.id() for c in model.by_type("IfcGeometricRepresentationSubContext") if c.ContextIdentifier == "Clearance"
}
if clearance_ids:
ctx_ids = [c.id() for c in model.by_type("IfcGeometricRepresentationContext") if c.id() not in clearance_ids]
if ctx_ids:
settings.set("context-ids", ctx_ids)
return settings
def _get_occurrence_class(type_entity) -> str:
"""Derive the occurrence IFC class from a type entity class name."""
type_class = type_entity.is_a()
if type_class.endswith("Type"):
return type_class[:-4]
return "IfcBuildingElementProxy"
def _make_type_occurrence(model: ifcopenshell.file, type_entity) -> object | None:
"""Create a temporary occurrence for *type_entity* using its RepresentationMaps.
The occurrence is added to *model* and references the type's existing
RepresentationMap entities via IfcMappedItem. Returns the occurrence entity,
or ``None`` when the type has no usable RepresentationMaps.
.. note::
This function is intended for use on a temporary model copy. The
caller is responsible for discarding that copy after rendering.
"""
rep_maps = getattr(type_entity, "RepresentationMaps", None) or []
if not rep_maps:
return None
# One IfcMappedItem per RepresentationMap.
mapped_items = []
for rep_map in rep_maps:
origin = model.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0))
transform = model.create_entity(
"IfcCartesianTransformationOperator3D",
LocalOrigin=origin,
)
mapped_item = model.create_entity(
"IfcMappedItem",
MappingSource=rep_map,
MappingTarget=transform,
)
mapped_items.append(mapped_item)
context = rep_maps[0].MappedRepresentation.ContextOfItems
shape_rep = model.create_entity(
"IfcShapeRepresentation",
ContextOfItems=context,
RepresentationIdentifier="Body",
RepresentationType="MappedRepresentation",
Items=mapped_items,
)
prod_def_shape = model.create_entity(
"IfcProductDefinitionShape",
Representations=[shape_rep],
)
# Identity placement.
pt = model.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0))
z_dir = model.create_entity("IfcDirection", DirectionRatios=(0.0, 0.0, 1.0))
x_dir = model.create_entity("IfcDirection", DirectionRatios=(1.0, 0.0, 0.0))
axis2 = model.create_entity("IfcAxis2Placement3D", Location=pt, Axis=z_dir, RefDirection=x_dir)
placement = model.create_entity("IfcLocalPlacement", RelativePlacement=axis2)
occ_class = _get_occurrence_class(type_entity)
try:
occurrence = model.create_entity(
occ_class,
GlobalId=ifcopenshell.guid.new(),
Name=f"_type_preview_{type_entity.id()}",
ObjectPlacement=placement,
Representation=prod_def_shape,
)
except Exception:
occurrence = model.create_entity(
"IfcBuildingElementProxy",
GlobalId=ifcopenshell.guid.new(),
Name=f"_type_preview_{type_entity.id()}",
ObjectPlacement=placement,
Representation=prod_def_shape,
)
return occurrence
def _make_profile_occurrence(model: ifcopenshell.file, type_entity) -> object | None:
"""Create a temporary occurrence for a type that has a material profile set.
Finds the first profile in the type's IfcMaterialProfileSet and creates a
1-metre IfcExtrudedAreaSolid body representation from it. Returns the
occurrence, or ``None`` when no usable profile is found.
.. note::
Intended for use on a temporary model copy; caller discards it after
rendering.
"""
# Locate the first profile from the type's material profile set.
profile = None
for rel in getattr(type_entity, "HasAssociations", []):
if not rel.is_a("IfcRelAssociatesMaterial"):
continue
mat = rel.RelatingMaterial
if mat.is_a("IfcMaterialProfileSetUsage"):
mat = mat.ForProfileSet
if mat.is_a("IfcMaterialProfileSet"):
mat_profiles = list(getattr(mat, "MaterialProfiles", None) or [])
if mat_profiles:
profile = getattr(mat_profiles[0], "Profile", None)
if profile is not None:
break
if profile is None:
return None
# Find a Body subcontext, or fall back to any Model context.
body_ctx = None
for ctx in model.by_type("IfcGeometricRepresentationSubContext"):
if ctx.ContextIdentifier == "Body":
body_ctx = ctx
break
if body_ctx is None:
for ctx in model.by_type("IfcGeometricRepresentationContext"):
if ctx.ContextType == "Model":
body_ctx = ctx
break
if body_ctx is None:
return None
# Extrude 1 metre along Z (profile lies in XY plane).
origin = model.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0))
z_axis = model.create_entity("IfcDirection", DirectionRatios=(0.0, 0.0, 1.0))
x_axis = model.create_entity("IfcDirection", DirectionRatios=(1.0, 0.0, 0.0))
position = model.create_entity("IfcAxis2Placement3D", Location=origin, Axis=z_axis, RefDirection=x_axis)
extrude_dir = model.create_entity("IfcDirection", DirectionRatios=(0.0, 0.0, 1.0))
extrusion = model.create_entity(
"IfcExtrudedAreaSolid",
SweptArea=profile,
Position=position,
ExtrudedDirection=extrude_dir,
Depth=1.0,
)
shape_rep = model.create_entity(
"IfcShapeRepresentation",
ContextOfItems=body_ctx,
RepresentationIdentifier="Body",
RepresentationType="SweptSolid",
Items=[extrusion],
)
prod_def_shape = model.create_entity(
"IfcProductDefinitionShape",
Representations=[shape_rep],
)
# Identity placement.
pt = model.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0))
z_dir = model.create_entity("IfcDirection", DirectionRatios=(0.0, 0.0, 1.0))
x_dir = model.create_entity("IfcDirection", DirectionRatios=(1.0, 0.0, 0.0))
axis2 = model.create_entity("IfcAxis2Placement3D", Location=pt, Axis=z_dir, RefDirection=x_dir)
placement = model.create_entity("IfcLocalPlacement", RelativePlacement=axis2)
occ_class = _get_occurrence_class(type_entity)
try:
occurrence = model.create_entity(
occ_class,
GlobalId=ifcopenshell.guid.new(),
Name=f"_profile_preview_{type_entity.id()}",
ObjectPlacement=placement,
Representation=prod_def_shape,
)
except Exception:
occurrence = model.create_entity(
"IfcBuildingElementProxy",
GlobalId=ifcopenshell.guid.new(),
Name=f"_profile_preview_{type_entity.id()}",
ObjectPlacement=placement,
Representation=prod_def_shape,
)
return occurrence
def _render_with_types(
model: ifcopenshell.file,
types: list,
selector_elements: list | None,
element_ids: list[int] | None,
type_highlight_ids: set[int],
view: str,
) -> bytes:
"""Render type entities by creating occurrences in a temporary model copy.
*types* list of IfcTypeProduct entities to render.
*selector_elements* non-type elements from the selector (or ``None``).
*element_ids* original highlight IDs (may contain type IDs).
*type_highlight_ids* subset of *element_ids* that are type IDs.
"""
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".ifc")
os.close(tmp_fd)
try:
model.write(tmp_path)
tmp = ifcopenshell.open(tmp_path)
# Map original type step-ID → new occurrence step-ID in the tmp model.
type_id_to_occ_id: dict[int, int] = {}
for t in types:
tmp_type = tmp.by_id(t.id())
occ = _make_type_occurrence(tmp, tmp_type) or _make_profile_occurrence(tmp, tmp_type)
if occ:
type_id_to_occ_id[t.id()] = occ.id()
if not type_id_to_occ_id:
raise ValueError("Type entities have no RepresentationMaps or material profile sets to render")
include = [tmp.by_id(occ_id) for occ_id in type_id_to_occ_id.values()]
if selector_elements:
include.extend(tmp.by_id(e.id()) for e in selector_elements)
settings = _build_geom_settings(tmp)
iterator = ifcopenshell.geom.iterator(settings, tmp, multiprocessing.cpu_count(), include=include)
if not iterator.initialize():
raise ValueError("Type entities have no renderable geometry")
# Remap type IDs → occurrence IDs in the highlight list.
new_highlight = None
if element_ids:
new_highlight = []
for hid in element_ids:
if hid in type_highlight_ids:
mapped = type_id_to_occ_id.get(hid)
if mapped:
new_highlight.append(mapped)
else:
new_highlight.append(hid)
return _render_iterator(iterator, new_highlight, view)
finally:
try:
os.unlink(tmp_path)
except OSError:
pass
def render(
model: ifcopenshell.file,
selector: str | None = None,
element_ids: list[int] | None = None,
view: str = "iso",
) -> bytes:
"""Render IFC model geometry to a PNG image.
Supports both element instances and element types (e.g. ``IfcWallType``).
When type entities are targeted via *selector* or *element_ids* a
temporary copy of the model is used to create proxy occurrences that
reference the type's RepresentationMaps; the original model is not
modified.
:param model: The in-memory IFC model.
:param selector: ifcopenshell selector to restrict rendered elements
(e.g. ``'IfcWall'``, ``'IfcWallType'``, or
``'IfcBuildingStorey[Name="Ground Floor"]'``).
When omitted the whole model is rendered.
:param element_ids: Step IDs of elements (or types) to highlight. The
rest of the model is rendered in translucent grey so the highlighted
items stand out.
:param view: Camera angle: ``iso``, ``top``, ``south``, ``north``,
``east``, or ``west``. Defaults to ``iso``.
:return: PNG image as raw bytes.
:raises ImportError: If pyvista is not installed.
:raises ValueError: If the selector matches nothing or the model has no
renderable geometry.
"""
if not _HAS_PYVISTA:
raise ImportError("pyvista is not installed. Install with: pip install pyvista")
# --- Partition selector results into types and elements ---
if selector:
matched = list(ifcopenshell.util.selector.filter_elements(model, selector))
if not matched:
raise ValueError(f"Selector {selector!r} matched no elements")
types = [e for e in matched if e.is_a("IfcTypeProduct")]
selector_elements: list | None = [e for e in matched if not e.is_a("IfcTypeProduct")]
else:
types = []
selector_elements = None # no restriction — render all elements
# --- Collect any type entities from element_ids ---
type_highlight_ids: set[int] = set()
if element_ids:
for eid in element_ids:
entity = model.by_id(eid)
if entity.is_a("IfcTypeProduct"):
type_highlight_ids.add(eid)
seen = {t.id() for t in types}
if eid not in seen:
types.append(entity)
# --- Delegate to temp-copy path when any type entities are involved ---
if types:
return _render_with_types(model, types, selector_elements, element_ids, type_highlight_ids, view)
# --- Regular element rendering ---
settings = _build_geom_settings(model)
if selector_elements is not None:
if not selector_elements:
raise ValueError(f"Selector {selector!r} matched only type entities (use a type selector or IfcElement)")
iterator = ifcopenshell.geom.iterator(
settings,
model,
multiprocessing.cpu_count(),
include=selector_elements,
)
else:
exclude = list(model.by_type("IfcOpeningElement"))
iterator = ifcopenshell.geom.iterator(
settings,
model,
multiprocessing.cpu_count(),
exclude=exclude if exclude else None,
)
if not iterator.initialize():
raise ValueError("No renderable geometry found in model (or selector matched nothing)")
return _render_iterator(iterator, element_ids, view)
+56
View File
@@ -0,0 +1,56 @@
# This file was generated with the assistance of an AI coding tool.
from __future__ import annotations
from typing import Any
import ifcopenshell
import ifcopenshell.util.sequence as seq
def _task_to_dict(task: ifcopenshell.entity_instance, max_depth: int | None, depth: int) -> dict[str, Any]:
task_time = task.TaskTime
start = None
finish = None
if task_time:
start = task_time.ScheduleStart
finish = task_time.ScheduleFinish
outputs = []
for product in seq.get_task_outputs(task):
outputs.append({"id": product.id(), "type": product.is_a(), "name": getattr(product, "Name", None)})
if max_depth is not None and depth >= max_depth:
child_count = len(seq.get_nested_tasks(task))
subtasks = {"truncated": True, "count": child_count} if child_count else []
else:
subtasks = [_task_to_dict(sub, max_depth, depth + 1) for sub in seq.get_nested_tasks(task)]
return {
"id": task.id(),
"name": getattr(task, "Name", None),
"start": start,
"finish": finish,
"is_milestone": bool(task.IsMilestone) if hasattr(task, "IsMilestone") else False,
"outputs": outputs,
"subtasks": subtasks,
}
def schedule(model: ifcopenshell.file, max_depth: int | None = None) -> list[dict[str, Any]]:
"""Return a list of IfcWorkSchedule entries with nested task trees.
max_depth limits how many levels of subtasks are expanded (None = unlimited).
At the cutoff level, subtasks is replaced with {"truncated": True, "count": N}.
"""
result = []
for work_schedule in model.by_type("IfcWorkSchedule"):
tasks = [_task_to_dict(t, max_depth, depth=1) for t in seq.get_root_tasks(work_schedule)]
result.append(
{
"id": work_schedule.id(),
"name": getattr(work_schedule, "Name", None),
"predefined_type": getattr(work_schedule, "PredefinedType", None),
"tasks": tasks,
}
)
return result
+19
View File
@@ -0,0 +1,19 @@
# This file was generated with the assistance of an AI coding tool.
from __future__ import annotations
from typing import Any
import ifcopenshell
import ifcopenshell.util.doc
def schema(model: ifcopenshell.file, entity_type: str) -> dict[str, Any]:
"""Return IFC class documentation for entity_type from model's schema version."""
schema_name = model.schema
try:
doc = ifcopenshell.util.doc.get_entity_doc(schema_name, entity_type)
except Exception:
return {"error": f"Unknown entity: {entity_type}"}
if not doc:
return {"error": f"Unknown entity: {entity_type}"}
return dict(doc)
+41
View File
@@ -0,0 +1,41 @@
# This file was generated with the assistance of an AI coding tool.
# IfcQuery - IFC model interrogation CLI
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
#
# This file is part of IfcQuery.
#
# IfcQuery is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcQuery is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcQuery. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
from typing import Any
import ifcopenshell
import ifcopenshell.util.selector
def select(model: ifcopenshell.file, query: str) -> list[dict[str, Any]]:
"""Filter elements using selector syntax and return matching element summaries."""
elements = ifcopenshell.util.selector.filter_elements(model, query)
results = []
for element in sorted(elements, key=lambda e: e.id()):
entry: dict[str, Any] = {
"id": element.id(),
"type": element.is_a(),
"repr": str(element),
}
if hasattr(element, "Name"):
entry["name"] = element.Name
results.append(entry)
return results
+52
View File
@@ -0,0 +1,52 @@
# This file was generated with the assistance of an AI coding tool.
# IfcQuery - IFC model interrogation CLI
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
#
# This file is part of IfcQuery.
#
# IfcQuery is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcQuery is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcQuery. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
from collections import Counter
from typing import Any
import ifcopenshell
def summary(model: ifcopenshell.file) -> dict[str, Any]:
"""Return a model overview with schema, element counts, and project info."""
# Count elements by IFC type, sorted by count descending
type_counter: Counter[str] = Counter()
total = 0
for entity in model:
type_counter[entity.is_a()] += 1
total += 1
result: dict[str, Any] = {
"schema": model.schema,
"total_entities": total,
}
projects = model.by_type("IfcProject")
if projects:
project = projects[0]
result["project"] = {
"id": project.id(),
"name": project.Name,
"description": project.Description,
}
result["types"] = dict(type_counter.most_common())
return result
+68
View File
@@ -0,0 +1,68 @@
# This file was generated with the assistance of an AI coding tool.
# IfcQuery - IFC model interrogation CLI
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
#
# This file is part of IfcQuery.
#
# IfcQuery is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcQuery is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcQuery. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
from typing import Any
import ifcopenshell
import ifcopenshell.util.element
def _element_summary(element: ifcopenshell.entity_instance) -> dict[str, Any]:
"""Return a minimal summary dict for an element."""
return {
"id": element.id(),
"type": element.is_a(),
"name": element.Name if hasattr(element, "Name") else None,
}
def _build_spatial_node(element: ifcopenshell.entity_instance) -> dict[str, Any]:
"""Recursively build a spatial tree node."""
node = _element_summary(element)
# Get aggregated children (Site in Project, Building in Site, Storey in Building, etc.)
aggregates = []
for rel in getattr(element, "IsDecomposedBy", []):
for child in rel.RelatedObjects:
aggregates.append(_build_spatial_node(child))
# Get contained elements (walls, slabs, etc. in a storey/space)
contained = []
for rel in getattr(element, "ContainsElements", []):
for child in rel.RelatedElements:
contained.append(_element_summary(child))
if aggregates:
node["children"] = aggregates
if contained:
node["elements"] = contained
return node
def tree(model: ifcopenshell.file) -> dict[str, Any] | list[dict[str, Any]]:
"""Return the spatial hierarchy tree starting from IfcProject."""
projects = model.by_type("IfcProject")
if not projects:
return {"error": "No IfcProject found in model"}
if len(projects) == 1:
return _build_spatial_node(projects[0])
return [_build_spatial_node(p) for p in projects]
+15
View File
@@ -0,0 +1,15 @@
# This file was generated with the assistance of an AI coding tool.
from __future__ import annotations
from typing import Any
import ifcopenshell
import ifcopenshell.validate
def validate(model: ifcopenshell.file, express_rules: bool = False) -> dict[str, Any]:
"""Validate the model and return a dict with 'valid' bool and 'issues' list."""
logger = ifcopenshell.validate.json_logger()
ifcopenshell.validate.validate(model, logger, express_rules=express_rules)
issues = [{"level": s["level"], "message": s["message"]} for s in logger.statements]
return {"valid": len(issues) == 0, "issues": issues}
+33
View File
@@ -0,0 +1,33 @@
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "ifcquery"
version = "0.0.0"
authors = [
{ name="Bruno Postle", email="bruno@postle.net" },
]
description = "CLI tool for querying and inspecting IFC building models"
readme = "README.md"
keywords = ["IFC", "BIM", "Query"]
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)",
]
dependencies = ["ifcopenshell"]
[project.scripts]
ifcquery = "ifcquery.__main__:main"
[project.urls]
Homepage = "http://ifcopenshell.org"
Documentation = "https://docs.ifcopenshell.org"
Issues = "https://github.com/IfcOpenShell/IfcOpenShell/issues"
[tool.setuptools.packages.find]
include = ["ifcquery*"]
exclude = ["test*"]
[tool.ruff]
extend = "../../pyproject.toml"
+1
View File
@@ -0,0 +1 @@
# This file was generated with the assistance of an AI coding tool.
+36
View File
@@ -0,0 +1,36 @@
# This file was generated with the assistance of an AI coding tool.
import ifcopenshell
import ifcopenshell.api.aggregate
import ifcopenshell.api.owner.settings
import ifcopenshell.api.project
import ifcopenshell.api.root
import ifcopenshell.api.spatial
import ifcopenshell.api.unit
import pytest
@pytest.fixture
def model():
"""Create an IFC4 model with a spatial hierarchy and a wall."""
f = ifcopenshell.api.project.create_file()
ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0]
ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0]
project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="TestProject")
ifcopenshell.api.unit.assign_unit(f)
site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="TestSite")
building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="TestBuilding")
storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="Ground Floor")
ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project)
ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site)
ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=building)
wall = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall001")
ifcopenshell.api.spatial.assign_container(f, products=[wall], relating_structure=storey)
slab = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSlab", name="Slab001")
ifcopenshell.api.spatial.assign_container(f, products=[slab], relating_structure=storey)
return f
+330
View File
@@ -0,0 +1,330 @@
# This file was generated with the assistance of an AI coding tool.
import json
import os
import subprocess
import sys
import tempfile
import ifcopenshell
import ifcopenshell.api.aggregate
import ifcopenshell.api.context
import ifcopenshell.api.geometry
import ifcopenshell.api.owner.settings
import ifcopenshell.api.project
import ifcopenshell.api.root
import ifcopenshell.api.spatial
import ifcopenshell.api.unit
import numpy as np
import pytest
from ifcquery.clash import clash
try:
import ifcopenshell.geom
HAS_GEOM = True
except ImportError:
HAS_GEOM = False
pytestmark = pytest.mark.skipif(not HAS_GEOM, reason="ifcopenshell geometry engine not available")
@pytest.fixture
def model_with_geometry():
"""Create an IFC4 model with walls that have geometric representations."""
f = ifcopenshell.api.project.create_file()
ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0]
ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0]
project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="TestProject")
ifcopenshell.api.unit.assign_unit(f)
site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="TestSite")
building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="TestBuilding")
storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="Ground Floor")
ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project)
ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site)
ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=building)
# Create geometry context
model_ctx = ifcopenshell.api.context.add_context(f, context_type="Model")
body = ifcopenshell.api.context.add_context(
f, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=model_ctx
)
# Wall 1 at origin
wall1 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall001")
rep1 = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=5, height=3, thickness=0.2)
ifcopenshell.api.geometry.assign_representation(f, product=wall1, representation=rep1)
ifcopenshell.api.spatial.assign_container(f, products=[wall1], relating_structure=storey)
# Wall 2 perpendicular, crossing through wall 1
wall2 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall002")
rep2 = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=5, height=3, thickness=0.2)
ifcopenshell.api.geometry.assign_representation(f, product=wall2, representation=rep2)
ifcopenshell.api.spatial.assign_container(f, products=[wall2], relating_structure=storey)
matrix2 = np.array([[0, -1, 0, 2.5], [1, 0, 0, -2.0], [0, 0, 1, 0], [0, 0, 0, 1]], dtype=float)
ifcopenshell.api.geometry.edit_object_placement(f, product=wall2, matrix=matrix2)
# Wall 3 far away (10m offset in Y)
wall3 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall003")
rep3 = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=5, height=3, thickness=0.2)
ifcopenshell.api.geometry.assign_representation(f, product=wall3, representation=rep3)
ifcopenshell.api.spatial.assign_container(f, products=[wall3], relating_structure=storey)
matrix3 = np.eye(4)
matrix3[1, 3] = 10.0 # 10m in Y direction
ifcopenshell.api.geometry.edit_object_placement(f, product=wall3, matrix=matrix3)
# Wall 4 close but not overlapping (0.3m offset in Y, wall thickness is 0.2m)
wall4 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall004")
rep4 = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=5, height=3, thickness=0.2)
ifcopenshell.api.geometry.assign_representation(f, product=wall4, representation=rep4)
ifcopenshell.api.spatial.assign_container(f, products=[wall4], relating_structure=storey)
matrix4 = np.eye(4)
matrix4[1, 3] = 0.3 # 0.3m in Y (gap of 0.1m from wall1)
ifcopenshell.api.geometry.edit_object_placement(f, product=wall4, matrix=matrix4)
return f
@pytest.fixture
def model_two_storeys():
"""Create a model with walls in different storeys."""
f = ifcopenshell.api.project.create_file()
ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0]
ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0]
project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="TestProject")
ifcopenshell.api.unit.assign_unit(f)
site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="TestSite")
building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="TestBuilding")
storey1 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="Ground Floor")
storey2 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="First Floor")
ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project)
ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site)
ifcopenshell.api.aggregate.assign_object(f, products=[storey1, storey2], relating_object=building)
model_ctx = ifcopenshell.api.context.add_context(f, context_type="Model")
body = ifcopenshell.api.context.add_context(
f, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=model_ctx
)
# Wall in storey 1
wall1 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="GroundWall")
rep1 = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=5, height=3, thickness=0.2)
ifcopenshell.api.geometry.assign_representation(f, product=wall1, representation=rep1)
ifcopenshell.api.spatial.assign_container(f, products=[wall1], relating_structure=storey1)
# Wall in storey 2, perpendicular and crossing wall1
wall2 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="FirstFloorWall")
rep2 = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=5, height=3, thickness=0.2)
ifcopenshell.api.geometry.assign_representation(f, product=wall2, representation=rep2)
ifcopenshell.api.spatial.assign_container(f, products=[wall2], relating_structure=storey2)
matrix2 = np.array([[0, -1, 0, 2.5], [1, 0, 0, -2.0], [0, 0, 1, 0], [0, 0, 0, 1]], dtype=float)
ifcopenshell.api.geometry.edit_object_placement(f, product=wall2, matrix=matrix2)
return f
class TestNoClashes:
def test_no_clashes_far_apart(self, model_with_geometry):
wall3 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall003")
result = clash(model_with_geometry, wall3)
assert result["pass"] is True
assert result["checks"]["intersection"]["pass"] is True
assert result["checks"]["intersection"]["clashes"] == []
def test_no_clashes_empty_scope(self, model_with_geometry):
"""A model where the element is the only one in scope should pass."""
# Create a model with a single wall
f = ifcopenshell.api.project.create_file()
ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0]
ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0]
project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="P")
ifcopenshell.api.unit.assign_unit(f)
site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="S")
building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="B")
storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="GF")
ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project)
ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site)
ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=building)
model_ctx = ifcopenshell.api.context.add_context(f, context_type="Model")
body = ifcopenshell.api.context.add_context(
f, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=model_ctx
)
wall = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="OnlyWall")
rep = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=5, height=3, thickness=0.2)
ifcopenshell.api.geometry.assign_representation(f, product=wall, representation=rep)
ifcopenshell.api.spatial.assign_container(f, products=[wall], relating_structure=storey)
result = clash(f, wall)
assert result["pass"] is True
class TestIntersectionDetected:
def test_overlapping_walls(self, model_with_geometry):
wall1 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001")
result = clash(model_with_geometry, wall1)
assert result["pass"] is False
assert result["checks"]["intersection"]["pass"] is False
clashes = result["checks"]["intersection"]["clashes"]
assert len(clashes) > 0
# Wall002 should be in the clashes (it overlaps wall1)
clash_ids = {c["element"]["id"] for c in clashes}
wall2 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall002")
assert wall2.id() in clash_ids
def test_clash_has_points(self, model_with_geometry):
wall1 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001")
result = clash(model_with_geometry, wall1)
clashes = result["checks"]["intersection"]["clashes"]
for c in clashes:
assert "p1" in c
assert "p2" in c
assert len(c["p1"]) == 3
assert len(c["p2"]) == 3
assert "type" in c
assert "distance" in c
class TestClearance:
def test_clearance_violation(self, model_with_geometry):
"""Wall004 is 0.1m from wall1; clearance of 0.5m should fail."""
wall1 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001")
result = clash(model_with_geometry, wall1, clearance=0.5)
assert "clearance" in result["checks"]
# Wall004 should violate clearance
clearance_clashes = result["checks"]["clearance"]["clashes"]
clash_ids = {c["element"]["id"] for c in clearance_clashes}
wall4 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall004")
assert wall4.id() in clash_ids
assert result["checks"]["clearance"]["pass"] is False
def test_clearance_pass(self, model_with_geometry):
"""Wall003 is 10m away; clearance of 0.5m should pass for wall003."""
wall3 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall003")
result = clash(model_with_geometry, wall3, clearance=0.5)
assert result["checks"]["clearance"]["pass"] is True
assert result["checks"]["clearance"]["clashes"] == []
def test_clearance_not_included_by_default(self, model_with_geometry):
wall1 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001")
result = clash(model_with_geometry, wall1)
assert "clearance" not in result["checks"]
class TestScope:
def test_scope_storey_excludes_other_storeys(self, model_two_storeys):
wall1 = next(w for w in model_two_storeys.by_type("IfcWall") if w.Name == "GroundWall")
result = clash(model_two_storeys, wall1, scope="storey")
assert result["scope"] == "storey"
# No clashes because the overlapping wall is in a different storey
assert result["pass"] is True
def test_scope_all_includes_other_storeys(self, model_two_storeys):
wall1 = next(w for w in model_two_storeys.by_type("IfcWall") if w.Name == "GroundWall")
result = clash(model_two_storeys, wall1, scope="all")
assert result["scope"] == "all"
# Should detect clash with the other-storey wall
assert result["pass"] is False
clash_ids = {c["element"]["id"] for c in result["checks"]["intersection"]["clashes"]}
wall2 = next(w for w in model_two_storeys.by_type("IfcWall") if w.Name == "FirstFloorWall")
assert wall2.id() in clash_ids
class TestNoGeometry:
def test_no_geometry_error(self, model):
"""Element without geometry reports error."""
wall = model.by_type("IfcWall")[0]
result = clash(model, wall)
assert result["pass"] is None
assert "error" in result
assert "No geometry" in result["error"]
class TestJsonSerializable:
def test_result_serializable(self, model_with_geometry):
wall1 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001")
result = clash(model_with_geometry, wall1)
serialized = json.dumps(result)
parsed = json.loads(serialized)
assert parsed["element"]["type"] == "IfcWall"
def test_clearance_result_serializable(self, model_with_geometry):
wall1 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001")
result = clash(model_with_geometry, wall1, clearance=0.5)
serialized = json.dumps(result)
parsed = json.loads(serialized)
assert "clearance" in parsed["checks"]
class TestCLI:
@staticmethod
def _ifc_path(model):
f = tempfile.NamedTemporaryFile(suffix=".ifc", delete=False)
model.write(f.name)
f.close()
return f.name
def test_clash_json(self, model_with_geometry):
path = self._ifc_path(model_with_geometry)
try:
wall1 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001")
result = subprocess.run(
[sys.executable, "-m", "ifcquery", path, "clash", str(wall1.id())],
capture_output=True,
text=True,
)
assert result.returncode == 0
data = json.loads(result.stdout)
assert data["element"]["type"] == "IfcWall"
assert "checks" in data
assert "intersection" in data["checks"]
finally:
os.unlink(path)
def test_clash_with_clearance(self, model_with_geometry):
path = self._ifc_path(model_with_geometry)
try:
wall1 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001")
result = subprocess.run(
[sys.executable, "-m", "ifcquery", path, "clash", str(wall1.id()), "--clearance", "0.5"],
capture_output=True,
text=True,
)
assert result.returncode == 0
data = json.loads(result.stdout)
assert "clearance" in data["checks"]
finally:
os.unlink(path)
def test_clash_scope_all(self, model_with_geometry):
path = self._ifc_path(model_with_geometry)
try:
wall1 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001")
result = subprocess.run(
[sys.executable, "-m", "ifcquery", path, "clash", str(wall1.id()), "--scope", "all"],
capture_output=True,
text=True,
)
assert result.returncode == 0
data = json.loads(result.stdout)
assert data["scope"] == "all"
finally:
os.unlink(path)
def test_clash_bad_id(self, model_with_geometry):
path = self._ifc_path(model_with_geometry)
try:
result = subprocess.run(
[sys.executable, "-m", "ifcquery", path, "clash", "999999"],
capture_output=True,
text=True,
)
assert result.returncode != 0
assert "Error" in result.stderr
finally:
os.unlink(path)
+52
View File
@@ -0,0 +1,52 @@
import ifcopenshell.api.context
import ifcopenshell.api.project
import ifcopenshell.api.root
import ifcopenshell.api.unit
from ifcquery.contexts import contexts
class TestContexts:
def test_empty_model(self):
f = ifcopenshell.api.project.create_file()
result = contexts(f)
assert isinstance(result, list)
assert len(result) == 0
def test_model_context(self, model):
import ifcopenshell.api.context
ifcopenshell.api.context.add_context(model, context_type="Model")
result = contexts(model)
assert len(result) == 1
entry = result[0]
assert entry["type"] == "IfcGeometricRepresentationContext"
assert entry["context_type"] == "Model"
assert "id" in entry
assert "context_identifier" in entry
def test_subcontext(self, model):
import ifcopenshell.api.context
model_ctx = ifcopenshell.api.context.add_context(model, context_type="Model")
ifcopenshell.api.context.add_context(
model,
context_type="Model",
context_identifier="Body",
target_view="MODEL_VIEW",
parent=model_ctx,
)
result = contexts(model)
assert len(result) == 2
subctx = next(e for e in result if e["type"] == "IfcGeometricRepresentationSubContext")
assert subctx["context_identifier"] == "Body"
assert subctx["target_view"] == "MODEL_VIEW"
assert subctx["parent_context_id"] == model_ctx.id()
def test_ids_are_integers(self, model):
import ifcopenshell.api.context
ifcopenshell.api.context.add_context(model, context_type="Model")
result = contexts(model)
for entry in result:
assert isinstance(entry["id"], int)
+108
View File
@@ -0,0 +1,108 @@
# This file was generated with the assistance of an AI coding tool.
from __future__ import annotations
import ifcopenshell
import ifcopenshell.api.cost
import ifcopenshell.api.owner.settings
import ifcopenshell.api.project
import ifcopenshell.api.root
import ifcopenshell.api.unit
import pytest
from ifcquery.cost import cost
@pytest.fixture
def cost_model():
"""Create an IFC4 model with a cost schedule, a top-level item, and one nested subitem."""
f = ifcopenshell.api.project.create_file()
ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0]
ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0]
project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="TestProject")
ifcopenshell.api.unit.assign_unit(f)
cs = ifcopenshell.api.cost.add_cost_schedule(f, name="Bill of Quantities")
item = ifcopenshell.api.cost.add_cost_item(f, cost_schedule=cs)
ifcopenshell.api.cost.edit_cost_item(f, cost_item=item, attributes={"Name": "Concrete Works"})
cv = ifcopenshell.api.cost.add_cost_value(f, parent=item)
ifcopenshell.api.cost.edit_cost_value(f, cost_value=cv, attributes={"AppliedValue": 1200.0, "Category": "material"})
# Add a nested subitem
subitem = ifcopenshell.api.cost.add_cost_item(f, cost_item=item)
ifcopenshell.api.cost.edit_cost_item(f, cost_item=subitem, attributes={"Name": "Formwork"})
return f
class TestCost:
def test_returns_list(self, cost_model):
result = cost(cost_model)
assert isinstance(result, list)
def test_finds_cost_schedule(self, cost_model):
result = cost(cost_model)
assert len(result) == 1
def test_schedule_has_name(self, cost_model):
result = cost(cost_model)
assert result[0]["name"] == "Bill of Quantities"
def test_schedule_has_id(self, cost_model):
result = cost(cost_model)
assert isinstance(result[0]["id"], int)
assert result[0]["id"] > 0
def test_schedule_has_items(self, cost_model):
result = cost(cost_model)
assert len(result[0]["items"]) == 1
def test_item_has_required_fields(self, cost_model):
result = cost(cost_model)
item = result[0]["items"][0]
assert "id" in item
assert "name" in item
assert "values" in item
assert "subitems" in item
def test_item_name(self, cost_model):
result = cost(cost_model)
assert result[0]["items"][0]["name"] == "Concrete Works"
def test_item_has_values(self, cost_model):
result = cost(cost_model)
values = result[0]["items"][0]["values"]
assert len(values) == 1
assert "formula" in values[0]
assert "category" in values[0]
def test_item_value_category(self, cost_model):
result = cost(cost_model)
values = result[0]["items"][0]["values"]
assert values[0]["category"] == "material"
def test_empty_model_returns_empty_list(self, model):
result = cost(model)
assert result == []
def test_max_depth_none_returns_full_tree(self, cost_model):
result = cost(cost_model, max_depth=None)
item = result[0]["items"][0]
assert isinstance(item["subitems"], list)
assert len(item["subitems"]) == 1
assert item["subitems"][0]["name"] == "Formwork"
def test_max_depth_1_truncates_subitems(self, cost_model):
result = cost(cost_model, max_depth=1)
item = result[0]["items"][0]
assert isinstance(item["subitems"], dict)
assert item["subitems"]["truncated"] is True
assert item["subitems"]["count"] == 1
def test_max_depth_2_expands_to_depth_2(self, cost_model):
result = cost(cost_model, max_depth=2)
item = result[0]["items"][0]
assert isinstance(item["subitems"], list)
assert item["subitems"][0]["name"] == "Formwork"
# subitem has no children, so subitems should be empty list
assert item["subitems"][0]["subitems"] == []
+112
View File
@@ -0,0 +1,112 @@
# This file was generated with the assistance of an AI coding tool.
import ifcopenshell
import ifcopenshell.api.context
import ifcopenshell.api.geometry
import ifcopenshell.api.project
import ifcopenshell.api.root
import ifcopenshell.api.unit
import ifcopenshell.util.representation
import ifcopenshell.util.shape_builder
from ifcquery.info import info
class TestInfo:
def test_basic_attributes(self, model):
wall = model.by_type("IfcWall")[0]
result = info(model, wall)
assert result["id"] == wall.id()
assert result["type"] == "IfcWall"
assert result["attributes"]["Name"] == "Wall001"
def test_container(self, model):
wall = model.by_type("IfcWall")[0]
result = info(model, wall)
assert result["container"]["type"] == "IfcBuildingStorey"
assert result["container"]["name"] == "Ground Floor"
def test_project_info(self, model):
project = model.by_type("IfcProject")[0]
result = info(model, project)
assert result["type"] == "IfcProject"
assert result["attributes"]["Name"] == "TestProject"
def test_all_attributes_serializable(self, model):
"""All attribute values should be JSON-serializable (no entity instances)."""
import json
wall = model.by_type("IfcWall")[0]
result = info(model, wall)
# Should not raise
json.dumps(result)
def test_no_geometry_summary_without_representation(self, model):
wall = model.by_type("IfcWall")[0]
result = info(model, wall)
assert "geometry_summary" not in result
class TestGeometrySummary:
def _make_model_with_wall(self):
f = ifcopenshell.api.project.create_file()
ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject")
ifcopenshell.api.unit.assign_unit(f)
model_ctx = ifcopenshell.api.context.add_context(f, context_type="Model")
ifcopenshell.api.context.add_context(
f,
context_type="Model",
context_identifier="Body",
target_view="MODEL_VIEW",
parent=model_ctx,
)
wall = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="W1")
ifcopenshell.api.geometry.edit_object_placement(f, product=wall)
return f, wall
def _body_context(self, f):
return ifcopenshell.util.representation.get_context(f, "Model", "Body", "MODEL_VIEW")
def test_swept_solid_summary(self):
f, wall = self._make_model_with_wall()
body = self._body_context(f)
rep = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=5.0, height=3.0, thickness=0.2)
ifcopenshell.api.geometry.assign_representation(f, product=wall, representation=rep)
result = info(f, wall)
gs = result["geometry_summary"]
assert gs["representation_type"] == "SweptSolid"
assert len(gs["solids"]) == 1
solid = gs["solids"][0]
assert solid["depth"] == 3000.0 # stored in project units (mm)
assert solid["profile"]["type"] == "IfcArbitraryClosedProfileDef"
assert len(solid["profile"]["points"]) == 5 # closed polyline
def test_clipping_summary(self):
f, wall = self._make_model_with_wall()
body = self._body_context(f)
rep = ifcopenshell.api.geometry.add_wall_representation(
f,
context=body,
length=5.0,
height=4.0,
thickness=0.2,
clippings=[{"location": (0.0, 0.0, 3.0), "normal": (0.0, 0.0, 1.0)}],
)
ifcopenshell.api.geometry.assign_representation(f, product=wall, representation=rep)
result = info(f, wall)
gs = result["geometry_summary"]
assert gs["representation_type"] == "Clipping"
solid = gs["solids"][0]
assert len(solid["clipping_planes"]) == 1
plane = solid["clipping_planes"][0]
assert plane["location"][2] == 3000.0 # stored in project units (mm)
assert plane["normal"] == [0.0, 0.0, 1.0]
def test_geometry_summary_json_serializable(self):
import json
f, wall = self._make_model_with_wall()
body = self._body_context(f)
rep = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=5.0, height=3.0, thickness=0.2)
ifcopenshell.api.geometry.assign_representation(f, product=wall, representation=rep)
result = info(f, wall)
json.dumps(result)
+84
View File
@@ -0,0 +1,84 @@
# This file was generated with the assistance of an AI coding tool.
import json
import os
import subprocess
import sys
import tempfile
import ifcopenshell
import ifcopenshell.api.project
import pytest
@pytest.fixture
def ifc_path(model):
"""Write the model fixture to a temp file and return its path."""
with tempfile.NamedTemporaryFile(suffix=".ifc", delete=False) as f:
model.write(f.name)
yield f.name
os.unlink(f.name)
def run_ifcquery(*args):
"""Run ifcquery as a subprocess and return (returncode, stdout, stderr)."""
result = subprocess.run(
[sys.executable, "-m", "ifcquery", *args],
capture_output=True,
text=True,
)
return result.returncode, result.stdout, result.stderr
class TestCLI:
def test_summary_json(self, ifc_path):
rc, stdout, stderr = run_ifcquery(ifc_path, "summary")
assert rc == 0
data = json.loads(stdout)
assert data["schema"] == "IFC4"
assert "types" in data
def test_tree_json(self, ifc_path):
rc, stdout, stderr = run_ifcquery(ifc_path, "tree")
assert rc == 0
data = json.loads(stdout)
assert data["type"] == "IfcProject"
def test_info_json(self, ifc_path, model):
wall = model.by_type("IfcWall")[0]
rc, stdout, stderr = run_ifcquery(ifc_path, "info", str(wall.id()))
assert rc == 0
data = json.loads(stdout)
assert data["type"] == "IfcWall"
def test_info_hash_id(self, ifc_path, model):
wall = model.by_type("IfcWall")[0]
rc, stdout, stderr = run_ifcquery(ifc_path, "info", f"#{wall.id()}")
assert rc == 0
data = json.loads(stdout)
assert data["type"] == "IfcWall"
def test_select_json(self, ifc_path):
rc, stdout, stderr = run_ifcquery(ifc_path, "select", "IfcWall")
assert rc == 0
data = json.loads(stdout)
assert len(data) == 1
assert data[0]["type"] == "IfcWall"
def test_text_format(self, ifc_path):
rc, stdout, stderr = run_ifcquery(ifc_path, "--format", "text", "summary")
assert rc == 0
assert "schema:" in stdout
def test_bad_file(self):
rc, stdout, stderr = run_ifcquery("/nonexistent.ifc", "summary")
assert rc != 0
assert "Error" in stderr
def test_bad_element_id(self, ifc_path):
rc, stdout, stderr = run_ifcquery(ifc_path, "info", "999999")
assert rc != 0
assert "Error" in stderr
def test_no_command(self, ifc_path):
rc, stdout, stderr = run_ifcquery(ifc_path)
assert rc != 0
+52
View File
@@ -0,0 +1,52 @@
import ifcopenshell.api.material
import ifcopenshell.api.project
from ifcquery.materials import materials
class TestMaterials:
def test_empty_model(self, model):
result = materials(model)
assert isinstance(result, list)
assert len(result) == 0
def test_single_material(self, model):
ifcopenshell.api.material.add_material(model, name="Concrete", category="concrete")
result = materials(model)
assert len(result) == 1
m = result[0]
assert m["type"] == "IfcMaterial"
assert m["name"] == "Concrete"
assert m["category"] == "concrete"
assert isinstance(m["id"], int)
def test_material_layer_set(self, model):
mat = ifcopenshell.api.material.add_material(model, name="Brick")
layer_set = ifcopenshell.api.material.add_material_set(model, name="BrickSet", set_type="IfcMaterialLayerSet")
ifcopenshell.api.material.add_layer(model, layer_set=layer_set, material=mat)
result = materials(model)
layer_sets = [e for e in result if e["type"] == "IfcMaterialLayerSet"]
assert len(layer_sets) == 1
ls = layer_sets[0]
assert ls["name"] == "BrickSet"
assert isinstance(ls["layers"], list)
assert len(ls["layers"]) == 1
layer = ls["layers"][0]
assert layer["material"] == "Brick"
def test_material_constituent_set(self, model):
mat = ifcopenshell.api.material.add_material(model, name="Steel")
cs = ifcopenshell.api.material.add_material_set(model, name="CompSet", set_type="IfcMaterialConstituentSet")
ifcopenshell.api.material.add_constituent(model, constituent_set=cs, material=mat)
result = materials(model)
constituent_sets = [e for e in result if e["type"] == "IfcMaterialConstituentSet"]
assert len(constituent_sets) == 1
entry = constituent_sets[0]
assert entry["name"] == "CompSet"
assert isinstance(entry["constituents"], list)
def test_ids_are_integers(self, model):
ifcopenshell.api.material.add_material(model, name="Wood")
result = materials(model)
for entry in result:
assert isinstance(entry["id"], int)
+265
View File
@@ -0,0 +1,265 @@
from __future__ import annotations
import base64
import os
import subprocess
import sys
import tempfile
import ifcopenshell
import ifcopenshell.api.aggregate
import ifcopenshell.api.context
import ifcopenshell.api.geometry
import ifcopenshell.api.owner.settings
import ifcopenshell.api.project
import ifcopenshell.api.root
import ifcopenshell.api.spatial
import ifcopenshell.api.unit
import pytest
from ifcquery.plot import _highlight_css_from_ids, plot
try:
import ifcopenshell.draw # noqa: F401
HAS_DRAW = True
except ImportError:
HAS_DRAW = False
try:
import cairosvg # noqa: F401
HAS_CAIROSVG = True
except ImportError:
HAS_CAIROSVG = False
pytestmark = pytest.mark.skipif(not HAS_DRAW, reason="ifcopenshell.draw not available")
SVG_MAGIC = b"<?xml"
PNG_MAGIC = b"\x89PNG"
@pytest.fixture
def model_with_annotations():
"""IFC4 model with walls and explicit 2D annotation geometry (Plan/PLAN_VIEW context)."""
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")
storey.Elevation = 0.0 # required for setSectionHeightsFromStoreys() to create a cut plane
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="Wall001")
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)
return f, wall
@pytest.fixture
def model_no_plan(model_with_annotations):
"""Model whose SVG output will be empty (wall geometry only, no plan annotation group)."""
return model_with_annotations
class TestHighlightCSS:
def test_css_for_valid_element(self, model_with_annotations):
model, wall = model_with_annotations
css = _highlight_css_from_ids(model, [wall.id()])
assert wall.GlobalId in css
assert "opacity: 0.10" in css
assert "opacity: 1.0" in css
assert "#d00" in css
def test_css_empty_for_no_ids(self, model_with_annotations):
model, _ = model_with_annotations
css = _highlight_css_from_ids(model, [])
assert css == ""
def test_css_skips_unknown_ids(self, model_with_annotations):
model, _ = model_with_annotations
css = _highlight_css_from_ids(model, [999999])
assert css == ""
class TestPlotSVG:
def test_returns_svg_bytes(self, model_with_annotations):
model, _ = model_with_annotations
result = plot(model, output_format="svg")
assert isinstance(result, bytes)
assert result[:5] == SVG_MAGIC
def test_svg_contains_xml(self, model_with_annotations):
model, _ = model_with_annotations
result = plot(model, output_format="svg")
assert b"<svg" in result
def test_invalid_format_raises(self, model_with_annotations):
model, _ = model_with_annotations
with pytest.raises(ValueError, match="output_format"):
plot(model, output_format="xyz")
def test_invalid_view_raises(self, model_with_annotations):
model, _ = model_with_annotations
with pytest.raises(ValueError, match="view"):
plot(model, output_format="svg", view="bogus")
def test_selector_no_match_raises(self, model_with_annotations):
model, _ = model_with_annotations
with pytest.raises(ValueError, match="matched no elements"):
plot(model, output_format="svg", selector="IfcDoor")
def test_selector_filters_elements(self, model_with_annotations):
model, _ = model_with_annotations
result = plot(model, output_format="svg", selector="IfcWall")
assert isinstance(result, bytes)
assert b"<svg" in result
class TestPlotEmptySVG:
"""When draw produces no <g> elements, PNG/base64 should raise a clear error."""
def test_empty_drawing_png_raises(self, model_no_plan):
"""PNG format raises ValueError (not silently returns None) for empty drawings."""
model, _ = model_no_plan
svg = plot(model, output_format="svg")
has_groups = b"<g " in svg or b"<g>" in svg
if not has_groups:
pytest.raises(ValueError, plot, model, output_format="png")
else:
pytest.skip("Model produced non-empty SVG — empty path not triggered")
def test_empty_drawing_base64_raises(self, model_no_plan):
"""base64 format raises ValueError (not silently returns None) for empty drawings."""
model, _ = model_no_plan
svg = plot(model, output_format="svg")
has_groups = b"<g " in svg or b"<g>" in svg
if not has_groups:
pytest.raises(ValueError, plot, model, output_format="base64")
else:
pytest.skip("Model produced non-empty SVG — empty path not triggered")
@pytest.mark.skipif(not HAS_CAIROSVG, reason="cairosvg not installed")
class TestPlotPNG:
"""PNG and base64 require cairosvg."""
def test_png_returns_bytes_or_raises_on_empty(self, model_with_annotations):
model, _ = model_with_annotations
svg = plot(model, output_format="svg")
has_groups = b"<g " in svg or b"<g>" in svg
if has_groups:
result = plot(model, output_format="png")
assert isinstance(result, bytes)
assert result[:4] == PNG_MAGIC
else:
with pytest.raises(ValueError, match="No plan geometry"):
plot(model, output_format="png")
def test_base64_returns_dict(self, model_with_annotations):
model, _ = model_with_annotations
svg = plot(model, output_format="svg")
has_groups = b"<g " in svg or b"<g>" in svg
if has_groups:
result = plot(model, output_format="base64")
assert isinstance(result, dict)
assert result["mime"] == "image/png"
assert "png_b64" in result
assert "width" in result
assert "height" in result
assert "view" in result
# Verify the base64 is valid PNG
decoded = base64.b64decode(result["png_b64"])
assert decoded[:4] == PNG_MAGIC
else:
with pytest.raises(ValueError, match="No plan geometry"):
plot(model, output_format="base64")
def test_base64_view_field_matches_requested(self, model_with_annotations):
model, _ = model_with_annotations
svg = plot(model, output_format="svg")
has_groups = b"<g " in svg or b"<g>" in svg
if not has_groups:
pytest.skip("Model produces empty SVG")
result = plot(model, output_format="base64", view="floorplan")
assert result["view"] == "floorplan"
def test_png_custom_size(self, model_with_annotations):
model, _ = model_with_annotations
svg = plot(model, output_format="svg")
has_groups = b"<g " in svg or b"<g>" in svg
if not has_groups:
pytest.skip("Model produces empty SVG")
result = plot(model, output_format="png", png_width=512, png_height=512)
assert isinstance(result, bytes)
assert result[:4] == PNG_MAGIC
class TestCLI:
@staticmethod
def _ifc_path(model):
f = tempfile.NamedTemporaryFile(suffix=".ifc", delete=False)
model.write(f.name)
f.close()
return f.name
def test_plot_svg_writes_file(self, model_with_annotations):
model, _ = model_with_annotations
ifc_path = self._ifc_path(model)
out_path = ifc_path.replace(".ifc", "_out.svg")
try:
result = subprocess.run(
[sys.executable, "-m", "ifcquery", ifc_path, "plot", "--out-format", "svg", "-o", out_path],
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr
assert os.path.exists(out_path)
with open(out_path, "rb") as f:
assert f.read(5) == SVG_MAGIC
finally:
for path in (ifc_path, out_path):
try:
os.unlink(path)
except OSError:
pass
@pytest.mark.skipif(not HAS_CAIROSVG, reason="cairosvg not installed")
def test_plot_base64_prints_json(self, model_with_annotations):
"""base64 format prints JSON to stdout instead of writing a file."""
model, _ = model_with_annotations
ifc_path = self._ifc_path(model)
try:
# First check if the model would produce geometry
svg = plot(model, output_format="svg")
has_groups = b"<g " in svg or b"<g>" in svg
if not has_groups:
pytest.skip("Model produces empty SVG — base64 would raise ValueError")
result = subprocess.run(
[sys.executable, "-m", "ifcquery", ifc_path, "plot", "--out-format", "base64"],
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr
# Output should be JSON (not an error) and contain base64 key
assert "png_b64" in result.stdout
finally:
try:
os.unlink(ifc_path)
except OSError:
pass
+158
View File
@@ -0,0 +1,158 @@
# This file was generated with the assistance of an AI coding tool.
import json
import os
import subprocess
import sys
import tempfile
from ifcquery.relations import relations
class TestWallRelations:
def test_wall_has_container(self, model):
wall = model.by_type("IfcWall")[0]
result = relations(model, wall)
assert result["id"] == wall.id()
assert result["type"] == "IfcWall"
assert result["hierarchy"]["container"]["type"] == "IfcBuildingStorey"
assert result["hierarchy"]["container"]["name"] == "Ground Floor"
def test_wall_has_parent(self, model):
wall = model.by_type("IfcWall")[0]
result = relations(model, wall)
assert result["hierarchy"]["parent"]["type"] == "IfcBuildingStorey"
def test_wall_no_children(self, model):
wall = model.by_type("IfcWall")[0]
result = relations(model, wall)
assert "children" not in result
def test_wall_empty_categories_omitted(self, model):
wall = model.by_type("IfcWall")[0]
result = relations(model, wall)
assert "groups" not in result
assert "systems" not in result
assert "zones" not in result
assert "connections" not in result
assert "referenced_structures" not in result
class TestStoreyRelations:
def test_storey_has_contained(self, model):
storey = model.by_type("IfcBuildingStorey")[0]
result = relations(model, storey)
contained_types = {e["type"] for e in result["children"]["contained"]}
assert "IfcWall" in contained_types
assert "IfcSlab" in contained_types
def test_storey_has_aggregate_parent(self, model):
storey = model.by_type("IfcBuildingStorey")[0]
result = relations(model, storey)
assert result["hierarchy"]["aggregate"]["type"] == "IfcBuilding"
assert result["hierarchy"]["aggregate"]["name"] == "TestBuilding"
class TestProjectRelations:
def test_project_has_parts(self, model):
project = model.by_type("IfcProject")[0]
result = relations(model, project)
parts = result["children"]["parts"]
assert any(p["type"] == "IfcSite" for p in parts)
def test_project_no_hierarchy(self, model):
project = model.by_type("IfcProject")[0]
result = relations(model, project)
assert "hierarchy" not in result
class TestTraverseUp:
def test_wall_to_project(self, model):
wall = model.by_type("IfcWall")[0]
chain = relations(model, wall, traverse="up")
assert isinstance(chain, list)
assert chain[0]["type"] == "IfcWall"
assert chain[-1]["type"] == "IfcProject"
types = [e["type"] for e in chain]
assert "IfcBuildingStorey" in types
assert "IfcBuilding" in types
assert "IfcSite" in types
def test_project_traverse(self, model):
project = model.by_type("IfcProject")[0]
chain = relations(model, project, traverse="up")
assert len(chain) == 1
assert chain[0]["type"] == "IfcProject"
def test_storey_to_project(self, model):
storey = model.by_type("IfcBuildingStorey")[0]
chain = relations(model, storey, traverse="up")
assert chain[0]["type"] == "IfcBuildingStorey"
assert chain[-1]["type"] == "IfcProject"
assert len(chain) == 4 # storey -> building -> site -> project
class TestJsonSerializable:
def test_relations_serializable(self, model):
wall = model.by_type("IfcWall")[0]
result = relations(model, wall)
json.dumps(result)
def test_traverse_serializable(self, model):
wall = model.by_type("IfcWall")[0]
result = relations(model, wall, traverse="up")
json.dumps(result)
class TestCLI:
@staticmethod
def _ifc_path(model):
f = tempfile.NamedTemporaryFile(suffix=".ifc", delete=False)
model.write(f.name)
f.close()
return f.name
def test_relations_json(self, model):
path = self._ifc_path(model)
try:
wall = model.by_type("IfcWall")[0]
result = subprocess.run(
[sys.executable, "-m", "ifcquery", path, "relations", str(wall.id())],
capture_output=True,
text=True,
)
assert result.returncode == 0
data = json.loads(result.stdout)
assert data["type"] == "IfcWall"
assert "hierarchy" in data
finally:
os.unlink(path)
def test_relations_traverse_up(self, model):
path = self._ifc_path(model)
try:
wall = model.by_type("IfcWall")[0]
result = subprocess.run(
[sys.executable, "-m", "ifcquery", path, "relations", str(wall.id()), "--traverse", "up"],
capture_output=True,
text=True,
)
assert result.returncode == 0
data = json.loads(result.stdout)
assert isinstance(data, list)
assert data[0]["type"] == "IfcWall"
assert data[-1]["type"] == "IfcProject"
finally:
os.unlink(path)
def test_relations_bad_id(self, model):
path = self._ifc_path(model)
try:
result = subprocess.run(
[sys.executable, "-m", "ifcquery", path, "relations", "999999"],
capture_output=True,
text=True,
)
assert result.returncode != 0
assert "Error" in result.stderr
finally:
os.unlink(path)
+355
View File
@@ -0,0 +1,355 @@
# This file was generated with the assistance of an AI coding tool.
import os
import subprocess
import sys
import tempfile
import ifcopenshell
import ifcopenshell.api.aggregate
import ifcopenshell.api.context
import ifcopenshell.api.geometry
import ifcopenshell.api.owner.settings
import ifcopenshell.api.project
import ifcopenshell.api.root
import ifcopenshell.api.spatial
import ifcopenshell.api.unit
import ifcopenshell.guid
import numpy as np
import pytest
from ifcquery.render import _make_profile_occurrence, _make_type_occurrence, render
try:
import pyvista # noqa: F401
HAS_PYVISTA = True
except ImportError:
HAS_PYVISTA = False
pytestmark = pytest.mark.skipif(not HAS_PYVISTA, reason="pyvista not installed")
PNG_MAGIC = b"\x89PNG"
@pytest.fixture
def model_with_geometry():
"""Create an IFC4 model with walls that have geometric representations."""
f = ifcopenshell.api.project.create_file()
ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0]
ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0]
project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="TestProject")
ifcopenshell.api.unit.assign_unit(f)
site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="TestSite")
building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="TestBuilding")
storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="Ground Floor")
ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project)
ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site)
ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=building)
model_ctx = ifcopenshell.api.context.add_context(f, context_type="Model")
body = ifcopenshell.api.context.add_context(
f, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=model_ctx
)
wall1 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall001")
rep1 = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=5, height=3, thickness=0.2)
ifcopenshell.api.geometry.assign_representation(f, product=wall1, representation=rep1)
ifcopenshell.api.spatial.assign_container(f, products=[wall1], relating_structure=storey)
wall2 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall002")
rep2 = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=4, height=3, thickness=0.2)
ifcopenshell.api.geometry.assign_representation(f, product=wall2, representation=rep2)
ifcopenshell.api.spatial.assign_container(f, products=[wall2], relating_structure=storey)
matrix2 = np.eye(4)
matrix2[1, 3] = 3.0
ifcopenshell.api.geometry.edit_object_placement(f, product=wall2, matrix=matrix2)
return f
@pytest.fixture
def library_with_type():
"""IFC4 library file: a WallType with a RepresentationMap but no instances."""
f = ifcopenshell.api.project.create_file()
ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0]
ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0]
project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="LibProject")
ifcopenshell.api.unit.assign_unit(f)
model_ctx = ifcopenshell.api.context.add_context(f, context_type="Model")
body = ifcopenshell.api.context.add_context(
f, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=model_ctx
)
# Build the shape representation and wrap it in an IfcRepresentationMap.
shape_rep = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=3, height=2.5, thickness=0.2)
origin = f.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0))
z_dir = f.create_entity("IfcDirection", DirectionRatios=(0.0, 0.0, 1.0))
x_dir = f.create_entity("IfcDirection", DirectionRatios=(1.0, 0.0, 0.0))
map_origin = f.create_entity("IfcAxis2Placement3D", Location=origin, Axis=z_dir, RefDirection=x_dir)
rep_map = f.create_entity("IfcRepresentationMap", MappingOrigin=map_origin, MappedRepresentation=shape_rep)
wall_type = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWallType", name="LibWallType")
wall_type.RepresentationMaps = [rep_map]
return f, wall_type
@pytest.fixture
def library_with_profile_type():
"""IFC4 library: a BeamType with an IfcMaterialProfileSet but no RepresentationMaps."""
f = ifcopenshell.api.project.create_file()
ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0]
ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0]
project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="ProfileLibProject")
ifcopenshell.api.unit.assign_unit(f)
model_ctx = ifcopenshell.api.context.add_context(f, context_type="Model")
ifcopenshell.api.context.add_context(
f, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=model_ctx
)
# Rectangular profile 0.2m x 0.3m
profile = f.create_entity(
"IfcRectangleProfileDef",
ProfileType="AREA",
ProfileName="200x300",
XDim=0.2,
YDim=0.3,
)
material = f.create_entity("IfcMaterial", Name="Steel")
mat_profile = f.create_entity("IfcMaterialProfile", Material=material, Profile=profile)
profile_set = f.create_entity("IfcMaterialProfileSet", MaterialProfiles=[mat_profile])
beam_type = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBeamType", name="200x300 Steel Beam")
rel = f.create_entity(
"IfcRelAssociatesMaterial",
GlobalId=ifcopenshell.guid.new(),
RelatedObjects=[beam_type],
RelatingMaterial=profile_set,
)
return f, beam_type
class TestRenderBasic:
def test_returns_png_bytes(self, model_with_geometry):
result = render(model_with_geometry)
assert isinstance(result, bytes)
assert result[:4] == PNG_MAGIC
def test_iso_view(self, model_with_geometry):
result = render(model_with_geometry, view="iso")
assert result[:4] == PNG_MAGIC
def test_top_view(self, model_with_geometry):
result = render(model_with_geometry, view="top")
assert result[:4] == PNG_MAGIC
def test_south_view(self, model_with_geometry):
result = render(model_with_geometry, view="south")
assert result[:4] == PNG_MAGIC
def test_unknown_view_falls_back_to_iso(self, model_with_geometry):
# Unknown view strings fall through to isometric
result = render(model_with_geometry, view="diagonal")
assert result[:4] == PNG_MAGIC
class TestRenderSelector:
def test_selector_restricts_elements(self, model_with_geometry):
result = render(model_with_geometry, selector="IfcWall")
assert result[:4] == PNG_MAGIC
def test_selector_no_match_raises(self, model_with_geometry):
with pytest.raises(ValueError, match="matched no elements"):
render(model_with_geometry, selector="IfcDoor")
class TestRenderHighlight:
def test_highlight_single_element(self, model_with_geometry):
wall = model_with_geometry.by_type("IfcWall")[0]
result = render(model_with_geometry, element_ids=[wall.id()])
assert result[:4] == PNG_MAGIC
def test_highlight_multiple_elements(self, model_with_geometry):
walls = model_with_geometry.by_type("IfcWall")
result = render(model_with_geometry, element_ids=[w.id() for w in walls])
assert result[:4] == PNG_MAGIC
class TestRenderTypes:
def test_render_type_by_selector(self, library_with_type):
"""Selecting a type class renders its RepresentationMap geometry."""
model, wall_type = library_with_type
result = render(model, selector="IfcWallType")
assert result[:4] == PNG_MAGIC
def test_render_type_by_element_id(self, library_with_type):
"""Passing a type step-ID via element_ids renders it highlighted."""
model, wall_type = library_with_type
result = render(model, element_ids=[wall_type.id()])
assert result[:4] == PNG_MAGIC
def test_original_model_unmodified(self, library_with_type):
"""Rendering a type must not add entities to the original model."""
model, wall_type = library_with_type
entity_count_before = len(list(model))
render(model, selector="IfcWallType")
assert len(list(model)) == entity_count_before
def test_make_type_occurrence_no_rep_maps(self, library_with_type):
"""_make_type_occurrence returns None for a type with no RepresentationMaps."""
model, _ = library_with_type
bare_type = ifcopenshell.api.root.create_entity(model, ifc_class="IfcWallType", name="Bare")
assert _make_type_occurrence(model, bare_type) is None
def test_type_without_rep_maps_raises(self):
"""Selecting a type that has no RepresentationMaps raises ValueError."""
f = ifcopenshell.api.project.create_file()
ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0]
ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0]
ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="P")
ifcopenshell.api.unit.assign_unit(f)
ifcopenshell.api.root.create_entity(f, ifc_class="IfcWallType", name="Bare")
with pytest.raises(ValueError):
render(f, selector="IfcWallType")
class TestRenderProfileTypes:
def test_render_profile_type_by_element_id(self, library_with_profile_type):
"""A type with only a material profile set renders via temporary extrusion."""
model, beam_type = library_with_profile_type
result = render(model, element_ids=[beam_type.id()])
assert result[:4] == PNG_MAGIC
def test_make_profile_occurrence_creates_occurrence(self, library_with_profile_type):
"""_make_profile_occurrence returns an occurrence entity for a profile-set type."""
model, beam_type = library_with_profile_type
occ = _make_profile_occurrence(model, beam_type)
assert occ is not None
def test_make_profile_occurrence_no_profile_returns_none(self, library_with_type):
"""_make_profile_occurrence returns None when type has no material profile set."""
model, wall_type = library_with_type
# wall_type has RepresentationMaps but no material profile set
occ = _make_profile_occurrence(model, wall_type)
assert occ is None
def test_original_model_unmodified_for_profile_type(self, library_with_profile_type):
"""Rendering a profile-based type does not modify the original model."""
model, beam_type = library_with_profile_type
entity_count_before = len(list(model))
render(model, element_ids=[beam_type.id()])
assert len(list(model)) == entity_count_before
class TestRenderNoGeometry:
def test_no_geometry_raises(self):
"""A model without geometry representations raises ValueError."""
f = ifcopenshell.api.project.create_file()
ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0]
ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0]
project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="P")
ifcopenshell.api.unit.assign_unit(f)
site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="S")
building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="B")
storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="GF")
ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project)
ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site)
ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=building)
wall = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wallless")
ifcopenshell.api.spatial.assign_container(f, products=[wall], relating_structure=storey)
with pytest.raises(ValueError, match="No renderable geometry"):
render(f)
class TestCLI:
@staticmethod
def _ifc_path(model):
f = tempfile.NamedTemporaryFile(suffix=".ifc", delete=False)
model.write(f.name)
f.close()
return f.name
def test_render_writes_png(self, model_with_geometry):
ifc_path = self._ifc_path(model_with_geometry)
out_path = ifc_path.replace(".ifc", "_out.png")
try:
result = subprocess.run(
[sys.executable, "-m", "ifcquery", ifc_path, "render", "-o", out_path],
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr
assert os.path.exists(out_path)
with open(out_path, "rb") as f:
assert f.read(4) == PNG_MAGIC
finally:
for path in (ifc_path, out_path):
try:
os.unlink(path)
except OSError:
pass
def test_render_default_output_path(self, model_with_geometry):
ifc_path = self._ifc_path(model_with_geometry)
expected_png = ifc_path.replace(".ifc", ".png")
try:
result = subprocess.run(
[sys.executable, "-m", "ifcquery", ifc_path, "render"],
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr
assert os.path.exists(expected_png)
finally:
for path in (ifc_path, expected_png):
try:
os.unlink(path)
except OSError:
pass
def test_render_with_selector(self, model_with_geometry):
ifc_path = self._ifc_path(model_with_geometry)
out_path = ifc_path.replace(".ifc", "_sel.png")
try:
result = subprocess.run(
[sys.executable, "-m", "ifcquery", ifc_path, "render", "-o", out_path, "--selector", "IfcWall"],
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr
with open(out_path, "rb") as f:
assert f.read(4) == PNG_MAGIC
finally:
for path in (ifc_path, out_path):
try:
os.unlink(path)
except OSError:
pass
def test_render_with_view(self, model_with_geometry):
ifc_path = self._ifc_path(model_with_geometry)
out_path = ifc_path.replace(".ifc", "_top.png")
try:
result = subprocess.run(
[sys.executable, "-m", "ifcquery", ifc_path, "render", "-o", out_path, "--view", "top"],
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr
with open(out_path, "rb") as f:
assert f.read(4) == PNG_MAGIC
finally:
for path in (ifc_path, out_path):
try:
os.unlink(path)
except OSError:
pass
+121
View File
@@ -0,0 +1,121 @@
# This file was generated with the assistance of an AI coding tool.
from __future__ import annotations
import ifcopenshell
import ifcopenshell.api.aggregate
import ifcopenshell.api.owner.settings
import ifcopenshell.api.project
import ifcopenshell.api.root
import ifcopenshell.api.sequence
import ifcopenshell.api.unit
import pytest
from ifcquery.schedule import schedule
@pytest.fixture
def schedule_model():
"""Create an IFC4 model with a work schedule and nested tasks."""
f = ifcopenshell.api.project.create_file()
ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0]
ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0]
project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="TestProject")
ifcopenshell.api.unit.assign_unit(f)
ws = ifcopenshell.api.sequence.add_work_schedule(f, name="Construction Schedule")
task1 = ifcopenshell.api.sequence.add_task(f, work_schedule=ws, name="Phase 1", identification="P1")
tt1 = ifcopenshell.api.sequence.add_task_time(f, task=task1)
ifcopenshell.api.sequence.edit_task_time(
f, task_time=tt1, attributes={"ScheduleStart": "2024-01-01", "ScheduleFinish": "2024-06-30"}
)
task2 = ifcopenshell.api.sequence.add_task(f, work_schedule=ws, name="Phase 2", identification="P2")
subtask = ifcopenshell.api.sequence.add_task(f, parent_task=task1, name="Sub Task", identification="S1")
return f
class TestSchedule:
def test_returns_list(self, schedule_model):
result = schedule(schedule_model)
assert isinstance(result, list)
def test_finds_work_schedule(self, schedule_model):
result = schedule(schedule_model)
assert len(result) == 1
def test_work_schedule_has_name(self, schedule_model):
result = schedule(schedule_model)
assert result[0]["name"] == "Construction Schedule"
def test_work_schedule_has_id(self, schedule_model):
result = schedule(schedule_model)
assert isinstance(result[0]["id"], int)
assert result[0]["id"] > 0
def test_work_schedule_has_tasks(self, schedule_model):
result = schedule(schedule_model)
tasks = result[0]["tasks"]
assert isinstance(tasks, list)
assert len(tasks) >= 1
def test_task_has_required_fields(self, schedule_model):
result = schedule(schedule_model)
task = result[0]["tasks"][0]
assert "id" in task
assert "name" in task
assert "start" in task
assert "finish" in task
assert "is_milestone" in task
assert "outputs" in task
assert "subtasks" in task
def test_task_name(self, schedule_model):
result = schedule(schedule_model)
task_names = [t["name"] for t in result[0]["tasks"]]
assert "Phase 1" in task_names
def test_task_start_finish(self, schedule_model):
result = schedule(schedule_model)
phase1 = next(t for t in result[0]["tasks"] if t["name"] == "Phase 1")
assert phase1["start"] is not None
assert phase1["finish"] is not None
def test_subtasks(self, schedule_model):
result = schedule(schedule_model)
phase1 = next(t for t in result[0]["tasks"] if t["name"] == "Phase 1")
assert len(phase1["subtasks"]) == 1
assert phase1["subtasks"][0]["name"] == "Sub Task"
def test_empty_model_returns_empty_list(self, model):
result = schedule(model)
assert result == []
def test_max_depth_none_returns_full_tree(self, schedule_model):
result = schedule(schedule_model, max_depth=None)
phase1 = next(t for t in result[0]["tasks"] if t["name"] == "Phase 1")
assert isinstance(phase1["subtasks"], list)
assert len(phase1["subtasks"]) == 1
def test_max_depth_1_truncates_subtasks(self, schedule_model):
result = schedule(schedule_model, max_depth=1)
phase1 = next(t for t in result[0]["tasks"] if t["name"] == "Phase 1")
assert isinstance(phase1["subtasks"], dict)
assert phase1["subtasks"]["truncated"] is True
assert phase1["subtasks"]["count"] == 1
def test_max_depth_truncation_shows_count(self, schedule_model):
result = schedule(schedule_model, max_depth=1)
# Phase 2 has no subtasks — should return empty list, not truncation dict
phase2 = next(t for t in result[0]["tasks"] if t["name"] == "Phase 2")
assert phase2["subtasks"] == []
def test_max_depth_2_expands_to_depth_2(self, schedule_model):
result = schedule(schedule_model, max_depth=2)
phase1 = next(t for t in result[0]["tasks"] if t["name"] == "Phase 1")
# subtask at depth 2 should be fully expanded (it has no children)
assert isinstance(phase1["subtasks"], list)
assert phase1["subtasks"][0]["name"] == "Sub Task"
assert phase1["subtasks"][0]["subtasks"] == []
+32
View File
@@ -0,0 +1,32 @@
# This file was generated with the assistance of an AI coding tool.
from __future__ import annotations
import pytest
from ifcquery.schema import schema
class TestSchema:
def test_ifc_wall_has_description(self, model):
result = schema(model, "IfcWall")
assert "description" in result
assert isinstance(result["description"], str)
assert len(result["description"]) > 0
def test_ifc_wall_has_attributes(self, model):
result = schema(model, "IfcWall")
assert "attributes" in result
def test_ifc_wall_has_spec_url(self, model):
result = schema(model, "IfcWall")
assert "spec_url" in result
def test_unknown_entity_returns_error(self, model):
result = schema(model, "IfcNonExistentFooBar")
assert "error" in result
assert "IfcNonExistentFooBar" in result["error"]
def test_ifc_window_has_description(self, model):
result = schema(model, "IfcWindow")
assert "description" in result
assert len(result["description"]) > 0
+32
View File
@@ -0,0 +1,32 @@
# This file was generated with the assistance of an AI coding tool.
from ifcquery.select import select
class TestSelect:
def test_select_by_type(self, model):
result = select(model, "IfcWall")
assert len(result) == 1
assert result[0]["type"] == "IfcWall"
assert result[0]["name"] == "Wall001"
def test_select_multiple_types(self, model):
result = select(model, "IfcWall, IfcSlab")
assert len(result) == 2
types = {r["type"] for r in result}
assert types == {"IfcWall", "IfcSlab"}
def test_select_no_match(self, model):
result = select(model, "IfcDoor")
assert result == []
def test_results_sorted_by_id(self, model):
result = select(model, "IfcWall, IfcSlab")
ids = [r["id"] for r in result]
assert ids == sorted(ids)
def test_result_has_id_type_name(self, model):
result = select(model, "IfcWall")
entry = result[0]
assert "id" in entry
assert "type" in entry
assert "name" in entry
+34
View File
@@ -0,0 +1,34 @@
# This file was generated with the assistance of an AI coding tool.
import ifcopenshell
import ifcopenshell.api.project
from ifcquery.summary import summary
class TestSummary:
def test_schema(self, model):
result = summary(model)
assert result["schema"] == "IFC4"
def test_total_entities(self, model):
result = summary(model)
assert result["total_entities"] == len(list(model))
assert result["total_entities"] > 0
def test_project_info(self, model):
result = summary(model)
assert result["project"]["name"] == "TestProject"
def test_type_counts(self, model):
result = summary(model)
types = result["types"]
assert "IfcWall" in types
assert types["IfcWall"] == 1
assert "IfcSlab" in types
assert types["IfcSlab"] == 1
def test_empty_model(self):
f = ifcopenshell.api.project.create_file()
result = summary(f)
assert result["schema"] == "IFC4"
assert "project" not in result
+37
View File
@@ -0,0 +1,37 @@
# This file was generated with the assistance of an AI coding tool.
from ifcquery.tree import tree
class TestTree:
def test_root_is_project(self, model):
result = tree(model)
assert result["type"] == "IfcProject"
assert result["name"] == "TestProject"
def test_spatial_hierarchy(self, model):
result = tree(model)
# Project > Site > Building > Storey
site = result["children"][0]
assert site["type"] == "IfcSite"
assert site["name"] == "TestSite"
building = site["children"][0]
assert building["type"] == "IfcBuilding"
assert building["name"] == "TestBuilding"
storey = building["children"][0]
assert storey["type"] == "IfcBuildingStorey"
assert storey["name"] == "Ground Floor"
def test_contained_elements(self, model):
result = tree(model)
storey = result["children"][0]["children"][0]["children"][0]
elements = storey["elements"]
element_types = {e["type"] for e in elements}
assert "IfcWall" in element_types
assert "IfcSlab" in element_types
def test_element_ids_present(self, model):
result = tree(model)
assert "id" in result
assert isinstance(result["id"], int)
+47
View File
@@ -0,0 +1,47 @@
# This file was generated with the assistance of an AI coding tool.
from __future__ import annotations
import ifcopenshell
import ifcopenshell.api.project
import pytest
from ifcquery.validate import validate
class TestValidate:
def test_valid_model_returns_valid_true(self, model):
result = validate(model)
assert result["valid"] is True
assert isinstance(result["issues"], list)
def test_valid_model_has_no_issues(self, model):
result = validate(model)
assert result["issues"] == []
def test_empty_model_is_valid(self):
f = ifcopenshell.api.project.create_file()
result = validate(f)
assert result["valid"] is True
assert result["issues"] == []
def test_result_has_expected_keys(self, model):
result = validate(model)
assert "valid" in result
assert "issues" in result
def test_express_rules_flag_accepted(self, model):
# Just verify it runs without error; express rules may add/not add issues
result = validate(model, express_rules=True)
assert "valid" in result
assert isinstance(result["issues"], list)
def test_issue_has_level_and_message(self, model):
# Force an issue by manually breaking the model (invalid IfcWall attribute)
f = ifcopenshell.file()
# Create a raw IfcWall with deliberately wrong type for GlobalId (use int)
# We just check structure if any issues appear; on well-formed models there are none.
result = validate(model)
# Even if no issues, the structure contract must hold for any issues present
for issue in result["issues"]:
assert "level" in issue
assert "message" in issue