Compare commits

...

51 Commits

Author SHA1 Message Date
Richard Brice 048242783e Updates update_key_point_referents to confirm to CT 4.1.4.4.3 2026-08-05 07:26:27 -07:00
Bruno Postle 6f3acc84ee ifcmcp: source tool descriptions from ifcquery/ifcedit instead of duplicating them
Alternative to #8955, for #8951 (23 of 25 ifcmcp tools reach MCP clients
with an empty description because FastMCP reads each wrapper's own
__doc__, and the server.py wrappers had none).

#8955 fixes this by hand-writing a new docstring directly onto each
server.py wrapper. Most of those wrappers are thin passthroughs to
IfcSession methods in core.py, which already had short docstrings, which
themselves mostly delegate to already-documented ifcquery/ifcedit
functions -- so that fix tripled up content across three layers that can
drift out of sync.

This instead enriches the true source (the ifcquery/ifcedit library
functions, useful independently of MCP) and has core.py's IfcSession
methods copy __doc__ from their delegate via a small _use_doc()
decorator, and server.py's tool registration pull description= from the
matching IfcSession method. Methods that aren't pure passthroughs
(session lifecycle, generic API/shape dispatch) keep their own
hand-written docs. Keeps #8955's regression test.

Generated with the assistance of an AI coding tool.
2026-08-03 12:52:55 +02:00
Richard Brice e077390e3d add update_key_point_referents to label key alignment points 2026-08-01 15:24:03 -07:00
Richard Brice 80cc603932 alignment: rename get_referent_nest to get_stationing_nest 2026-08-01 15:21:50 -07:00
dependabot[bot] a11ebdf8c4 build(deps): bump actions/setup-python from 6 to 7
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 6 to 7.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-31 13:57:34 +02:00
Bartok b997726564 docs: add Eigen to Linux install deps
Linux "basic dependencies" omitted libeigen3-dev even though
ifcgeom requires Eigen3 (find_package Eigen3 REQUIRED) and the
cmake snippet already passes -DEIGEN_DIR=/usr/include/eigen3.
macOS Homebrew line already installs eigen.

Closes #6903

Generated with the assistance of an AI coding tool.
2026-07-31 12:49:37 +02:00
Petru Conduraru 25713a486a Fix test_rules.py filtering by sys.argv, which empties the corpus under pytest
test_file's parametrize list was filtered with `sys.argv[1] in
os.path.basename(fn)`, reading the raw process argv instead of a
pytest-native option. Under a bare `pytest` invocation sys.argv[1] is
pytest's own first CLI token, never a match, so the 138-fixture EXPRESS
rule corpus in test/fixtures/rules collapses to an empty parametrize and
pytest reports it as a single skipped test rather than an error. Under
CI's actual invocation (pytest -p no:pytest-blender -n $NPROCS test ...)
sys.argv[1] is "-p", which happens to substring-match 47 of the 138
fixtures, so CI has been silently running a coincidental 34% slice of
the corpus with no signal anything was wrong.

Replaced the module-level list comprehension with a pytest_generate_tests
hook plus a --rule CLI option (added via a new test/conftest.py). This
runs the full corpus by default under any pytest invocation, still
allows filtering to one rule for local debugging via --rule, and no
longer collides with pytest's own argv.

Verified all 138 fixtures collect and pass under the fixed harness
(63 fail- fixtures each raise a violation, 75 pass- fixtures raise none).

Generated with the assistance of an AI coding tool.
2026-07-31 10:38:58 +02:00
Petru Conduraru d3b6b82151 ifcmcp: pin mcp below 2.0 to fix broken FastMCP import
mcp 2.0.0 (unpinned in CI and in the ifcmcp[mcp] extra) renamed
mcp.server.fastmcp.FastMCP to mcp.server.mcpserver.MCPServer, which
ifcmcp does not support yet. server.py caught the resulting
ModuleNotFoundError with a bare except Exception and silently
reported it as FastMCP not installed, masking the real breakage
until the ifcmcp test suite failed in CI.

Pinned mcp to >=1.0,<2 in both ci.yml and ifcmcp's pyproject.toml
mcp extra, confirmed the full ifcmcp test suite (70 tests) passes
against mcp 1.29.0, and confirmed the genuinely-not-installed path
still raises the expected ImportError. Also narrowed the except
clause to ImportError only so an unrelated future bug in that
import block surfaces instead of being swallowed as "not installed".

Generated with the assistance of an AI coding tool.
2026-07-31 10:36:39 +02:00
yekose 9e6797e172 ifcparse: check the result of fopen before using the FILE*
FullBufferImpl and PagedFileImpl both open the file and then use the handle
without ever testing it:

    auto stream = _wfopen(fn_wide, L"rb");   // null when the file is missing
    fseek(stream, 0, SEEK_END);              // null goes straight to the CRT
    buf_.resize((size_t)ftell(stream));

Opening a path that does not exist therefore hands a null FILE* to the CRT. On
MSVC that does not return an error: the runtime terminates the process
immediately (fastfail, exit code 0xC0000409). No exception is thrown, no stack
unwinding starts, so a caller cannot defend with try/catch — the host
application simply dies. On glibc it is undefined behaviour as well.

This is reachable through the ordinary entry point, because guess_file_type()
answers FT_IFCSPF for a path that does not exist (its own comment calls this
"just weird, but for consistency with earlier behaviour"), so a missing path
flows into the reader rather than being reported.

The fix is to leave the reader empty when the open fails. Both implementations
then behave like a zero-length file: size() is 0 and get() throws out_of_range
for any position, so the parse fails and IfcFile::good() reports it, which is
what a caller can actually handle. PagedFileImpl's destructor already tested
fp_ for null, so the possibility was known — only the constructor did not check.

Verified by reading a non-existent path through IfcParse::IfcFile: the
constructor returns and good() reports the failure, where before the process
died with 0xC0000409 and no output.
2026-07-31 10:33:04 +02:00
Petru Conduraru 1f9a0a53bb ifcopenshell.template: fix timestring ignoring an explicit timestamp of 0
create(timestamp=0) computed the FILE_NAME timestring with
`d.get("timestamp") or time.time()`, which treats 0 (a legitimate
epoch timestamp) as unset because 0 is falsy. The header ended up
with the current wall-clock time in FILE_NAME while IFCOWNERHISTORY
correctly stored CreationDate=0, an inconsistent pair of dates in
the same file. Switched to an explicit None check so an explicit
timestamp of 0 is honoured the same way any other explicit
timestamp is.

Generated with the assistance of an AI coding tool.
2026-07-31 10:19:33 +02:00
yekose b82c4c53fe ifcgeom: add a profile_point overload taking a plain double
The profile mapping builds its points as

    profile_helper(m4, {
        {{-x, -y}, {f2}},
        ...

where `f2` is a `double` and profile_point's second member is a
`boost::optional<double>`. In recent Boost (somewhere between 1.85 and 1.91)
optional's converting constructor became explicit, and an explicit constructor
cannot be used in copy-initialization — which is what a braced element is. So
every one of these call sites stops compiling:

  MSVC 19.4x:  error C2664: cannot convert argument 2 from
               'initializer list' to 'const std::vector<profile_point>&'
  clang-cl 22: error: chosen constructor is explicit in copy-initialization

Twelve translation units are affected (IfcCShapeProfileDef,
IfcIShapeProfileDef, IfcLShapeProfileDef, IfcTShapeProfileDef,
IfcUShapeProfileDef, IfcZShapeProfileDef, IfcAsymmetricIShapeProfileDef,
IfcCraneRailAShapeProfileDef, IfcRectangleProfileDef,
IfcRectangleHollowProfileDef, IfcRoundedRectangleProfileDef,
IfcTrapeziumProfileDef), roughly 100 call sites in total.

Adding one overload that takes the double directly fixes all of them without
touching a single call site, and changes nothing for existing code: the
optional overload still wins wherever an optional is passed.

Verified by building schemas 2x3;4;4x3_add2 with MSVC 2022 against Boost
1.91 and OCCT 7.9.3 — IfcParse, IfcGeom, the schema mappings and
geometry_kernel_opencascade all archive cleanly. Without this, the same build
against Boost 1.85 succeeds, which is what identified Boost as the variable.
2026-07-31 10:14:16 +02:00
CyrilWaechter 8deefe497c Fix space regen doubling Z location
Removing translate_obj_to_z_location from the existing-IfcSpace
regeneration branch. The ShapeBuilder rewrite (d8de62308) builds
geometry in local space preserving obj.matrix_world, making the
translate call redundant — it adds z on top of the already-correct
location.z, producing 2*z.

Add test_regenerate_space_preserves_z_location to cover the
regeneration path with a non-zero Z elevation.

Generated with the assistance of an AI coding tool.
2026-07-26 16:19:51 +02:00
Petru Conduraru 9621388953 ifcpatch: correct the AGS2IFC docstring example
The example block was copy pasted verbatim from ExtractPropertiesToSQLite,
so it named the wrong recipe and wrote a .sqlite file. These docstrings are
what ifcpatch surfaces as CLI and UI help, so anyone following the example
for AGS2IFC got a recipe name that does not match the one they selected.

Also state that the input file is not read and that a new IFC4X3 model is
built, since that is not obvious from the signature and the recipe creates
its own project rather than patching the one passed in.

Generated with the assistance of an AI coding tool.
2026-07-25 19:46:32 +10:00
Petru Conduraru 89523999b3 Bonsai: fix UnboundLocalError crash in polyline angle calculation
angle_round_threshold was only assigned inside the `distance > 0`
branch of calculate_distance_and_angle, but read unconditionally
whenever should_round is True. When the mouse sample coincides with
the last placed point (distance == 0), such as the first mouse move
after placing a wall's start point on a YZ plane view, this crashed
the modal wall tool.

angle_round_threshold is a fixed cutoff unrelated to whether distance
is currently zero, so it is now assigned once before the branch.

Fixes #8597.

Generated with the assistance of an AI coding tool.
2026-07-25 13:57:51 +10:00
Petru Conduraru 51ab38de27 Fix ci-bonsai-daily: configure unmerged_blobs mock in git_mergetool tests (#8574)
test_returns_none_when_report_file_absent/empty build a MagicMock repo
without configuring index.unmerged_blobs(), so it returned a truthy
MagicMock and git_mergetool's load-bearing "unresolved conflicts remain"
fallback (tool/ifcgit.py:646-647) returned that list instead of None -
failing "assert [] is None". The production fallback is correct and
intentionally left untouched; the tests just misrepresented the
"mergetool resolved cleanly" scenario they are named for. Set
mock_repo.index.unmerged_blobs.return_value = {} in both.

Verified in headless Blender: test/tool/test_ifcgit.py::TestGitMergetool
2 failed / 1 passed -> 3 passed.

This change was made with the assistance of an AI tool.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 23:21:18 +01:00
Petru Conduraru fbe36532a0 ifcdiff: fix crash when exporting property diffs to JSON
DeepDiff's dictionary_item_added/set_item_added results are a
deepdiff.helper.SetOrdered instance, which subclasses orderly_set's
StableSetEq rather than the OrderedSet class json_dump_default checked
for, so the property relationship check always crashed export() with
"Object of type SetOrdered is not JSON serializable". Check against
StableSet, the common base class shared by every orderly_set set
flavour, instead.

Fixes #8905

Generated with the assistance of an AI coding tool.
2026-07-25 07:47:13 +10:00
Andrej730 2f1b2f9638 ifcwrap: use swig shadowing for keeping reference to Element 2026-07-24 21:51:21 +05:00
Andrej730 9001cca078 ifcwrap: exclude internal geometry pointers
Still available as `int(xxx.this)`.
2026-07-24 21:51:21 +05:00
Andrej730 ec558dc57e stub: add logger_or_root arg type 2026-07-24 18:37:25 +05:00
Andrej730 2db5658386 ifcwrap: hide guess_file_type from Python as unused 2026-07-24 18:22:59 +05:00
Andrej730 88c8bd032f ifcwrap: fix breaking validate_stub (824c1fc)
It's ignoring underscore prefixed functions as not actually used.
Removing underscore to keep it happy without adding new exceptions.
2026-07-24 18:00:54 +05:00
Andrej730 3d8654acfd ifcwrap: ignore newly added conversion settings structs (183e4c4) 2026-07-24 17:59:44 +05:00
Andrej730 4a20b67038 IfcSchema: provide arg names for register_schema, schema_by_name 2026-07-24 16:48:51 +05:00
Andrej730 b14df627d7 ci: fix failing test for ifc5d 2026-07-24 16:15:54 +05:00
dependabot[bot] 0828c6ba92 build(deps): bump ty from 0.0.61 to 0.0.63
Bumps [ty](https://github.com/astral-sh/ty) from 0.0.61 to 0.0.63.
- [Release notes](https://github.com/astral-sh/ty/releases)
- [Changelog](https://github.com/astral-sh/ty/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ty/compare/0.0.61...0.0.63)

---
updated-dependencies:
- dependency-name: ty
  dependency-version: 0.0.63
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-24 15:46:39 +05:00
Andrej730 a586c7f695 black . 2026-07-24 15:43:59 +05:00
Andrej730 0bad5a9389 ty: add ignores 2026-07-24 15:43:59 +05:00
dependabot[bot] 0ce400cace build(deps): bump gersemi from 0.26.1 to 0.28.0
Bumps [gersemi](https://github.com/BlankSpruce/gersemi) from 0.26.1 to 0.28.0.
- [Release notes](https://github.com/BlankSpruce/gersemi/releases)
- [Changelog](https://github.com/BlankSpruce/gersemi/blob/master/CHANGELOG.md)
- [Commits](https://github.com/BlankSpruce/gersemi/compare/0.26.1...0.28.0)

---
updated-dependencies:
- dependency-name: gersemi
  dependency-version: 0.28.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-24 15:29:41 +05:00
dependabot[bot] 91ed59311b build(deps): bump ruff from 0.15.22 to 0.16.0
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.22 to 0.16.0.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.15.22...0.16.0)

---
updated-dependencies:
- dependency-name: ruff
  dependency-version: 0.16.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-24 15:29:22 +05:00
dependabot[bot] 1906481a01 Bump svelte from 5.53.6 to 5.55.8 in /src/ifctester/webapp
Bumps [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte) from 5.53.6 to 5.55.8.
- [Release notes](https://github.com/sveltejs/svelte/releases)
- [Changelog](https://github.com/sveltejs/svelte/blob/main/packages/svelte/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte/commits/svelte@5.55.8/packages/svelte)

---
updated-dependencies:
- dependency-name: svelte
  dependency-version: 5.55.8
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-24 15:06:42 +05:00
dependabot[bot] 73bf238232 Bump uuid and hyperid in /src/ifctester/webapp
Removes [uuid](https://github.com/uuidjs/uuid). It's no longer used after updating ancestor dependency [hyperid](https://github.com/mcollina/hyperid). These dependencies need to be updated together.


Removes `uuid`

Updates `hyperid` from 3.3.0 to 4.0.0
- [Release notes](https://github.com/mcollina/hyperid/releases)
- [Commits](https://github.com/mcollina/hyperid/compare/v3.3.0...v4.0.0)

---
updated-dependencies:
- dependency-name: uuid
  dependency-version: 
  dependency-type: indirect
- dependency-name: hyperid
  dependency-version: 4.0.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-24 15:06:21 +05:00
dependabot[bot] a85d5cc990 Bump lxml from 4.9.1 to 6.1.0 in /src/ifcopenshell-python
Bumps [lxml](https://github.com/lxml/lxml) from 4.9.1 to 6.1.0.
- [Release notes](https://github.com/lxml/lxml/releases)
- [Changelog](https://github.com/lxml/lxml/blob/master/CHANGES.txt)
- [Commits](https://github.com/lxml/lxml/compare/lxml-4.9.1...lxml-6.1.0)

---
updated-dependencies:
- dependency-name: lxml
  dependency-version: 6.1.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-24 15:04:15 +05:00
dependabot[bot] c16aec2cb0 Bump ws and engine.io-client in /src/ifctester/webapp
Bumps [ws](https://github.com/websockets/ws) and [engine.io-client](https://github.com/socketio/socket.io). These dependencies needed to be updated together.

Updates `ws` from 8.17.1 to 8.21.0
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.17.1...8.21.0)

Updates `engine.io-client` from 6.6.3 to 6.6.6
- [Release notes](https://github.com/socketio/socket.io/releases)
- [Changelog](https://github.com/socketio/socket.io/blob/main/CHANGELOG.md)
- [Commits](https://github.com/socketio/socket.io/compare/engine.io-client@6.6.3...engine.io-client@6.6.6)

---
updated-dependencies:
- dependency-name: ws
  dependency-version: 8.21.0
  dependency-type: indirect
- dependency-name: engine.io-client
  dependency-version: 6.6.6
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-24 15:03:56 +05:00
dependabot[bot] 76be31561e build(deps-dev): bump immutable in /src/ifctester/webapp
Bumps [immutable](https://github.com/immutable-js/immutable-js) from 5.1.5 to 5.1.9.
- [Release notes](https://github.com/immutable-js/immutable-js/releases)
- [Changelog](https://github.com/immutable-js/immutable-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/immutable-js/immutable-js/compare/v5.1.5...v5.1.9)

---
updated-dependencies:
- dependency-name: immutable
  dependency-version: 5.1.9
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-24 15:01:00 +05:00
dependabot[bot] a111c68d44 Bump devalue from 5.6.4 to 5.8.1 in /src/ifctester/webapp
Bumps [devalue](https://github.com/sveltejs/devalue) from 5.6.4 to 5.8.1.
- [Release notes](https://github.com/sveltejs/devalue/releases)
- [Changelog](https://github.com/sveltejs/devalue/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/devalue/compare/v5.6.4...v5.8.1)

---
updated-dependencies:
- dependency-name: devalue
  dependency-version: 5.8.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-24 14:59:19 +05:00
dependabot[bot] 62f627ecc6 Bump actions/checkout from 6 to 7
Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-24 14:59:09 +05:00
dependabot[bot] 279e16f2ab build(deps-dev): bump tar from 7.5.16 to 7.5.21 in /src/ifctester/webapp
Bumps [tar](https://github.com/isaacs/node-tar) from 7.5.16 to 7.5.21.
- [Release notes](https://github.com/isaacs/node-tar/releases)
- [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/isaacs/node-tar/compare/v7.5.16...v7.5.21)

---
updated-dependencies:
- dependency-name: tar
  dependency-version: 7.5.21
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-24 14:58:54 +05:00
dependabot[bot] 3d68d0f0e5 build(deps-dev): bump postcss in /src/ifctester/webapp
Bumps [postcss](https://github.com/postcss/postcss) from 8.5.4 to 8.5.22.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.4...8.5.22)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.22
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-24 14:58:50 +05:00
Petru Conduraru e759135608 Bonsai: cache-bust webui static assets so shipped JS/CSS changes reach users
Browsers were caching /static/js and /static/css for the standalone
webui (costing, gantt, drawings, index, demo pages) indefinitely, so a
shipped JS fix (e.g. the Download CSV button) would only reach a user
after a manual hard refresh.

Two changes, applied consistently across all five webui pages.

1. Every locally served link/script tag in the pystache templates now
carries a ?v=<bonsai version> query string, falling back to a static
asset mtime hash when BONSAI_VERSION isn't set (e.g. running
sioserver.py standalone). Since get_bonsai_version() includes the
build's commit hash, the token changes on every shipped update.

2. Responses under /static/ and /jsgantt/ now carry
Cache-Control: no-cache, must-revalidate. This covers what query
stamping alone can't reach: cost.js and gantt.js statically import
utilities/costui.js by a fixed relative path with no query string, so
that nested module still needed server side revalidation to pick up
changes.

Verified against a live aiohttp instance of sioserver.py: rendered
HTML for all five routes shows the stamped URLs, and the token
changes when BONSAI_VERSION changes between two server runs. A
conditional GET against a static file with a stale If-Modified-Since
header confirms the cheap 304 revalidation path still works.

Also used this instance plus a real headless Chromium (Playwright) to
click test the previously untested Download CSV button on the costing
page. The ribbon renders it correctly, and clicking it (with a
synthetic cost-items table injected into the DOM to stand in for a
connected Blender's data) triggers a real Blob download with the
correct filename and CSV content. No bug found, the button works as
intended.

AI-generated with Claude Code.
2026-07-24 11:32:34 +02:00
Petru Conduraru 1df738d968 ifc5d: match cost schedule export columns to the Bonsai cost panel (#6251)
Stefano's final ask on #6251 was specific: the ODS/XLSX export should
show exactly what the cost panel shows, ID (Identification), Name,
Quantity, Value, Total Cost, no more, no less. The previous fix in
this PR removed the internal bookkeeping columns but still exported
Description, Unit and a per-category cost breakdown (Labor Cost,
Material Cost, etc), none of which appear in the panel.

Presentation formats (.ods/.xlsx) now use an explicit allow-list of
columns instead of a block-list of internal ones, and relabel headers
to match the panel's own wording (ID / Value / Total Cost). The .csv
format is unchanged: csv2ifc still reads back the extra bookkeeping
columns for the import round trip, which is why it keeps them.

Also add a "Download CSV" button to the browser costing view
(Generate spreadsheet browser), which previously only offered a
clipboard-based Copy Selected. It reuses the already-rendered table
(respecting the user's column visibility settings) and triggers a
real file download, dropping only the UI-only Actions column.

AI-generated with Claude Code; reviewed and tested by Petru Conduraru.
2026-07-24 11:32:34 +02:00
Petru Conduraru 98c28a1f30 ifc5d: professional grade ODS/XLSX cost schedule export #6251
Three defects reported against the Costing tab export:

1. XLSX export crashed with ModuleNotFoundError: xlsxwriter was never
   bundled with Bonsai. Port the writer to openpyxl, which ifccsv
   already uses and Bonsai already ships, so it works out of the box.
2. Every ODS cell was written as a string (numbers as text), and the
   formula branch was dead code: it compared against 'Total Price' /
   'Rate Subtotal' while the headers are 'TotalPrice' / 'RateSubtotal'.
   Numeric columns are now typed float cells and TotalPrice becomes a
   real formula: Quantity*RateSubtotal on leaf items, SUM over the
   direct children's TotalPrice cells on sum items.
3. Internal bookkeeping columns (Id, ItemIsASum, Hierarchy, Index,
   Quantities) leaked into the presentation formats. ODS/XLSX now hide
   them; CSV keeps them since csv2ifc consumes them for the round trip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 11:32:34 +02:00
Petru Conduraru 21122c0d28 Resolve nested complex quantity paths in the selector (#2041)
get_element_value could not reach the members of an IfcPhysicalComplexQuantity
(or IfcComplexProperty) by their natural path. util.element expands a complex
quantity into a dict whose nested members live under a "properties" sub-dict,
but the selector's dict navigation only looked at the top level, so
"Qto_Custom.Layer1.Width" returned None and IfcCsv exported nothing for it.
Only the internal "Qto_Custom.Layer1.properties.Width" path worked.

When a key is not a direct member of the value dict, descend into its
"properties" sub-dict so nested quantities/properties resolve with the
natural "Set.Complex.Nested" path. Direct keys still take priority, so the
explicit ".properties." path stays backward compatible and the regex branch
is untouched.

Verified: Qto_Custom.Layer1.Width -> 0.1 and Layer1.Height -> 2.5 (were
None), the sibling simple NetArea still resolves, the legacy .properties.
path still works, and IfcCsv now exports the nested value. test_selector.py:
38 passed (adds test_selecting_a_nested_complex_quantity).

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 10:31:38 +02:00
falken10vdl d63a99b70c Merge pull request #8342 from falken10vdl/style-flat-pretty-toggle
Style flat pretty toggle
2026-07-24 09:56:24 +02:00
falken10vdl f2d8f17f88 Remove unused has_any_textures return from restore_material_style_types 2026-07-24 09:47:16 +02:00
falken10vdl e9b619e3fb Remove unused _get_shader_label helper method 2026-07-24 09:41:23 +02:00
Petru Conduraru d1f9e5243e Fix ci-bonsai-daily: ProjectLibraryData duplicate parent-library enum entry (#8573)
* Fix ci-bonsai-daily: ProjectLibraryData duplicate parent-library enum

parent_libraries_enum() adds an explicit entry for get_root_context(),
then loops over cls.data["project_libraries"] (all IfcProjectLibrary
entities) and appends each. For a library-only file (no IfcProject),
get_root_context falls back to the top-level IfcProjectLibrary itself,
so the root is appended twice with the same enum key (its STEP id),
which Blender EnumProperty requires to be unique -> the data load
asserts. Normal project files are unaffected (root is an IfcProject
whose id never collides with a library id).

Skip library_id == root.id() in the loop (dedup by id, the colliding
key). Verified in headless Blender:
test_project_library_data.py::TestLibraryOnlyFile goes from 1 failed /
5 passed to 6 passed.

This change was made with the assistance of an AI tool.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Bonsai: repair library files missing the required IfcProject, not just the symptom

Per the IFC Project Context concept template, every project data set (library
files included) shall contain exactly one IfcProject, and IfcProjectLibrary
instances are assigned to it via IfcRelDeclares. There is no such thing as a
spec-valid file rooted on IfcProjectLibrary alone.

get_root_context() (added in 260a387069, #8184) treated a missing IfcProject
as license to use the top-level IfcProjectLibrary as the file's root context
instead. That invalid premise is why project_libraries() (which walks every
IfcProjectLibrary, root included) then re-added that same entity, producing
the duplicate, colliding enum key this PR originally papered over with a
dedup guard.

Add tool.Project.ensure_project_context(), which repairs a file missing
IfcProject by creating one and declaring the file's root-level
IfcProjectLibrary instances to it, and tool.Project.open_library_file(),
which opens a library file through that repair. Route all three
IfcStore.library_file load sites in SelectLibraryFile through it. Downstream
code (get_root_context, ProjectLibraryData, RefreshLibrary,
AddProjectLibrary) now always operates on a spec-valid model, so the
duplicate enum entry cannot occur; the previous one-line dedup guard in
parent_libraries_enum() is kept only as cheap defense in depth for callers
that bypass the load-time repair, not as the fix.

Rework test_project_library_data.py: the previous _make_library_only_file()
fixture built an invalid library-only model and asserted that as correct
behaviour. Replace it with a spec-valid fixture (IfcProject + IfcProjectLibrary
declared to it) for the downstream tests, and a malformed fixture used only to
exercise the new repair path.

Verified live in headless Blender (isolated profile): reproduced the original
duplicate-enum-key failure mode, then confirmed ensure_project_context/
open_library_file repair a malformed file and ProjectLibraryData,
refresh_library and add_project_library all operate correctly on the result,
with no duplicate keys and no regression on already-valid files or IFC2X3.

This change was made with the assistance of an AI tool.

* Bonsai: stop supporting library-only files, do not repair them

Per Moult's feedback: if the IFC is invalid, our default position is to not
support it, not to patch around it. A library file with no IfcProject is
invalid IFC (Project Context concept template requires exactly one
IfcProject), and it is not ubiquitous: every library file bonsai ships under
bim/data/libraries has an IfcProject with the IfcProjectLibrary declared to
it via IfcRelDeclares. The single #8183 report is an outlier, not a common
authoring pattern worth accommodating.

Remove tool.Project.ensure_project_context() and open_library_file() (the
load-time repair added in the previous commit here) and revert
SelectLibraryFile's three load sites to plain ifcopenshell.open. Simplify
get_root_context() back to returning ifc_file.by_type("IfcProject")[0]
directly, no IfcProjectLibrary fallback: a file without IfcProject now raises
IndexError instead of being silently treated as valid. AddProjectLibrary's
nest-under-library branch is now dead code (root_context is always an
IfcProject) and is removed. The one-line enum dedup guard from the original
commit here is also removed: since get_root_context can only return an
IfcProject or raise, an IfcProject id can never collide with a library id, so
the guard has nothing left to guard against.

Rework test_project_library_data.py: drop the invalid _make_library_only_file
fixture and its tests, which asserted an unsupported model as correct
behaviour. Replace with a single spec-valid fixture matching bonsai's own
shipped library files (IfcProject + IfcProjectLibrary declared to it), used
for the ci-bonsai-daily regression test and the refresh/add-library
operators, plus one explicit test that get_root_context raises for a file
without IfcProject, documenting that this input is intentionally
unsupported rather than silently tolerated.

Verified live in headless Blender (isolated profile, source-loaded, never
the real profile): confirmed the removed methods are gone, that a
library-only file now raises instead of being handled, that
ProjectLibraryData/refresh_library/add_project_library all work correctly
on a spec-valid model with unique enum keys, and spot-checked that every
library file under bim/data/libraries already has an IfcProject.

This change was made with the assistance of an AI tool.

* Bonsai: inline get_root_context, trim docstrings, confirm get_parent_library unchanged

Per Moult's round 3 review. get_root_context added nothing over
ifc_file.by_type("IfcProject")[0], which is guaranteed by the IFC Project
Context concept template; remove it and inline the call at its three sites
(operator.py's RefreshLibrary and AddProjectLibrary, data.py's
parent_libraries_enum). Trim the get_parent_library docstring to one line;
its logic is untouched by this PR, byte for byte identical to origin/v0.8.0,
and still returns None only when project_library has neither Nests nor
HasContext, never for a library declared directly to IfcProject.

Rework test_project_library_data.py to match: replace the two
get_root_context-specific tests with one that exercises the real call site
(ProjectLibraryData.parent_libraries_enum raising IndexError for a file
without IfcProject), and add an explicit test that get_parent_library
returns None for a genuinely orphaned library. Also drop a long inline
comment that restated what the test body already shows.

Verified live in headless Blender (isolated profile, source-loaded, never
the real profile): all 17 test/bim/module/project tests pass, including the
new get_parent_library None-for-orphan case. Ran the full test/bim suite
before and after on the identical harness: 82 failed/1335 passed both times,
same failing tests (all pre-existing, unrelated to this module).

This change was made with the assistance of an AI tool.

* Bonsai: fix EditProjectLibrary leaving stale declarations after reparenting

Per Moult's round 4 review. The assertion change (get_parent_library(root)
now returns the IfcProject instead of None) is correct: in the old
library-only test model a top-level library had neither IfcRelNests nor
IfcRelDeclares, so None meant "top level". In the new spec-valid model a
top-level library is always declared to the guaranteed IfcProject via
IfcRelDeclares, so get_parent_library correctly resolves it through the
HasContext branch instead of falling through to None. get_project_hierarchy
already keys top-level libraries under the project for exactly this reason,
so the library tree still renders correctly.

Auditing every caller found one real bug in EditProjectLibrary, which
Gorgious56 originally wrote for the library-only model. Its move-library
logic assumed a top-level library (previous_parent_library is None) needed
no cleanup before nesting it under a new parent, and that unnesting a
library back to the project needed no new relationship because it was
"already assigned by default". Both assumptions relied on a top-level
library never actually holding a IfcRelDeclares, which is no longer true.
Reproduced live: moving a project-declared library under another library
left its old IfcRelDeclares dangling alongside the new IfcRelNests (an
invalid double parentage), and moving a nested library back to the project
left it with neither relationship, orphaning it out of the tree entirely.

Fixed by tearing down whichever of IfcRelDeclares/IfcRelNests the library
previously had before establishing whichever one the new parent requires,
instead of assuming which prior state applies.

Added tests: get_parent_library resolving a nested sub-library to its
library parent (the third contract case alongside project-declared and
orphaned), and both EditProjectLibrary reparenting directions, which fail
without the operator.py fix and pass with it.

Verified live in headless Blender (isolated profile, source-loaded, never
the real profile): all 20 test/bim/module/project tests pass. Ran the full
test/bim suite before and after on the identical harness: 123 failed/1294
passed before, 123 failed/1297 passed after, identical failing test names
in both runs (diffed), the extra 3 passes are the new tests above.

This change was made with the assistance of an AI tool.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 17:31:44 +10:00
falken10vdl c92a825a94 Cache last shading type to skip redundant material style restores 2026-07-24 09:12:04 +02:00
falken10vdl 4c5bd88877 add material update_tag in restore_material_style_types 2026-07-24 09:12:04 +02:00
falken10vdl 92e3e400f8 Use consistent material style prop accessor 2026-07-24 09:12:04 +02:00
falken10vdl 13c4ba257d Fix initila style when loading (default is SOLID - Flat: Shade) 2026-07-24 09:12:04 +02:00
falken10vdl c77a28c862 Add Flat/Pretty style toggle and dual-branch external style management 2026-07-24 09:12:04 +02:00
118 changed files with 2517 additions and 517 deletions
+2 -2
View File
@@ -21,12 +21,12 @@ jobs:
steps:
- name: Checkout Repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
submodules: recursive
- name: Checkout Build Repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
repository: IfcOpenShell/build-outputs
path: ./build
+2 -2
View File
@@ -9,13 +9,13 @@ jobs:
steps:
- name: Checkout Repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
submodules: recursive
path: IfcOpenShell
- name: Checkout Build Repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
repository: IfcOpenShell/build-outputs
path: ifcopenshell_build
+2 -2
View File
@@ -35,12 +35,12 @@ jobs:
aws --version
- name: Checkout Repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
submodules: recursive
- name: Checkout Build Repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
repository: IfcOpenShell/build-outputs
path: ./build
+2 -2
View File
@@ -35,12 +35,12 @@ jobs:
aws --version
- name: Checkout Repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
submodules: recursive
- name: Checkout Build Repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
repository: IfcOpenShell/build-outputs
path: ./build
+2 -2
View File
@@ -27,12 +27,12 @@ jobs:
steps:
- name: Checkout Repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
submodules: recursive
- name: Checkout Build Repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
repository: IfcOpenShell/build-outputs
path: ${{ matrix.deps_dir }}
+2 -2
View File
@@ -19,8 +19,8 @@ jobs:
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v6 # https://github.com/actions/checkout
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
- uses: actions/checkout@v7 # https://github.com/actions/checkout
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
with:
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
architecture: 'x64' # optional x64 or x86. Defaults to x64 if not specified
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-tags: true
fetch-depth: 0
+3 -3
View File
@@ -65,8 +65,8 @@ jobs:
config:
short_name: macos
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
- uses: actions/checkout@v7
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
with:
architecture: 'x64' # optional x64 or x86. Defaults to x64 if not specified
python-version: '3.11'
@@ -98,7 +98,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout bonsai_unstable_repo repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
repository: IfcOpenShell/bonsai_unstable_repo
token: ${{ secrets.IFCOPENBOT_TOKEN }}
+2 -2
View File
@@ -48,8 +48,8 @@ jobs:
config:
short_name: macos
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
- uses: actions/checkout@v7
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
with:
architecture: 'x64' # optional x64 or x86. Defaults to x64 if not specified
python-version: '3.11'
+2 -2
View File
@@ -18,8 +18,8 @@ jobs:
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
- uses: actions/checkout@v7
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
with:
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
- name: Compile
+2 -2
View File
@@ -18,8 +18,8 @@ jobs:
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
- uses: actions/checkout@v7
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
with:
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
- name: Compile
+2 -2
View File
@@ -18,8 +18,8 @@ jobs:
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
- uses: actions/checkout@v7
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
with:
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
- name: Compile
+2 -2
View File
@@ -18,8 +18,8 @@ jobs:
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
- uses: actions/checkout@v7
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
with:
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
- name: Compile
+2 -2
View File
@@ -18,8 +18,8 @@ jobs:
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
- uses: actions/checkout@v7
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
with:
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
- name: Compile
+2 -2
View File
@@ -37,8 +37,8 @@ jobs:
short_name: macosm164
}
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
- uses: actions/checkout@v7
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
with:
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
architecture: 'x64' # optional x64 or x86. Defaults to x64 if not specified
+2 -2
View File
@@ -18,8 +18,8 @@ jobs:
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
- uses: actions/checkout@v7
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
with:
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
- name: Compile
+2 -2
View File
@@ -18,8 +18,8 @@ jobs:
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
- uses: actions/checkout@v7
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
with:
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
- name: Compile
+2 -2
View File
@@ -18,8 +18,8 @@ jobs:
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: '3.11'
- name: Compile
+2 -2
View File
@@ -18,8 +18,8 @@ jobs:
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
- uses: actions/checkout@v7
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
with:
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
- name: Compile
+2 -2
View File
@@ -18,8 +18,8 @@ jobs:
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: '3.11'
- name: Compile
@@ -21,7 +21,7 @@ jobs:
date: ${{ steps.date.outputs.date }}
verdate: ${{ steps.verdate.outputs.verdate }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Set env
run: echo ok go
@@ -75,7 +75,7 @@ jobs:
echo "ARTIFACTS_DIR=/home/runner/work/artifacts" >> $GITHUB_ENV
fi
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
submodules: recursive
+2 -2
View File
@@ -21,7 +21,7 @@ jobs:
runs-on: ubuntu-22.04
needs: activate
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
submodules: recursive
@@ -86,7 +86,7 @@ jobs:
name: Docker Build, Tag, Push
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
lfs: true
@@ -47,10 +47,10 @@ jobs:
short_name: macosm164
}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
submodules: recursive
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
with:
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
architecture: 'x64' # optional x64 or x86. Defaults to x64 if not specified
+2 -2
View File
@@ -38,10 +38,10 @@ jobs:
short_name: macosm164
}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
submodules: recursive
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
with:
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
architecture: 'x64' # optional x64 or x86. Defaults to x64 if not specified
+2 -2
View File
@@ -18,8 +18,8 @@ jobs:
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
- uses: actions/checkout@v7
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
with:
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
- name: Compile
+2 -2
View File
@@ -18,8 +18,8 @@ jobs:
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: '3.11'
- name: Compile
+2 -2
View File
@@ -25,8 +25,8 @@ jobs:
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
- uses: actions/checkout@v7
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
with:
architecture: 'x64' # optional x64 or x86. Defaults to x64 if not specified
python-version: '3.11'
+2 -2
View File
@@ -19,8 +19,8 @@ jobs:
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
- uses: actions/checkout@v7
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
with:
architecture: 'x64' # optional x64 or x86. Defaults to x64 if not specified
python-version: '3.11'
+2 -2
View File
@@ -10,9 +10,9 @@ jobs:
publish_website:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Checkout ifctester_org_static_html
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
repository: IfcOpenShell/ifctester_org_static_html
token: ${{ secrets.IFCOPENBOT_TOKEN }}
+2 -2
View File
@@ -18,8 +18,8 @@ jobs:
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
- uses: actions/checkout@v7
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
with:
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
- name: Compile
+3 -3
View File
@@ -34,7 +34,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
submodules: recursive
@@ -85,7 +85,7 @@ jobs:
cmake --build build-ifcopenshell --target install -j "$(nproc)"
- name: Set up Python 3.11
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: 3.11
@@ -120,7 +120,7 @@ jobs:
PY
- name: Set up Python 3.12
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: 3.12
+3 -3
View File
@@ -12,15 +12,15 @@ jobs:
MIN_BLENDER_PY_VERSION: "3.11"
steps:
- name: Action - checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Action - install python
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: ${{ env.MIN_IOS_PY_VERSION }}
- name: Action - install python
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: ${{ env.MIN_BLENDER_PY_VERSION }}
@@ -8,7 +8,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout IfcOpenShell
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Install uv
uses: astral-sh/setup-uv@v7
@@ -25,7 +25,7 @@ jobs:
echo "name=$(basename $WHEEL)" >> $GITHUB_OUTPUT
- name: Checkout wasm-wheels
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
repository: IfcOpenShell/wasm-wheels
path: wasm-wheels
+6 -4
View File
@@ -43,12 +43,12 @@ jobs:
CLICOLOR_FORCE: "1"
CMAKE_COLOR_DIAGNOSTICS: "ON"
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
submodules: recursive
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: 3.11
@@ -257,13 +257,15 @@ jobs:
cd ../ifcdiff && make test || ERROR=1
cd ../ifcpatch && make test || ERROR=1
pip install -e ../ifc5d --no-deps
pip install odfpy xlsxwriter
pip install odfpy openpyxl
cd ../ifc5d && make test || ERROR=1
pip install -e ../ifcquery --no-deps
cd ../ifcquery && make test || ERROR=1
pip install -e ../ifcedit --no-deps
cd ../ifcedit && make test || ERROR=1
pip install mcp
# Pinned <2: mcp 2.0.0 renamed mcp.server.fastmcp.FastMCP to
# mcp.server.mcpserver.MCPServer, which ifcmcp doesn't support yet.
pip install "mcp>=1.0,<2"
pip install -e ../ifcmcp --no-deps
cd ../ifcmcp && make test || ERROR=1
pip install -e ../ifctester --no-deps
@@ -11,10 +11,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: '3.x'
+3 -3
View File
@@ -27,12 +27,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout (recursive)
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
submodules: recursive
fetch-depth: 0
- name: Checkout intermediate Pages repo
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
repository: IfcOpenShell/aichat_ifcopenshell_org_static_html
ref: gh-pages
@@ -42,7 +42,7 @@ jobs:
run: |
rsync -av --delete --exclude='.git/' src/ifcchat/ output/
- name: Setup Python
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: "3.x"
- name: Download wheels
@@ -7,7 +7,7 @@ jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v7
@@ -27,12 +27,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout (recursive)
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
submodules: recursive
fetch-depth: 0
- name: Checkout intermediate Pages repo
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
repository: IfcOpenShell/wasm_ifcopenshell_org_static_html
ref: gh-pages
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
submodules: recursive
- name: Install C++ dependencies
+5 -2
View File
@@ -121,7 +121,7 @@ import tarfile
import threading
from datetime import datetime
ssl._create_default_https_context = ssl._create_unverified_context
ssl._create_default_https_context = ssl._create_unverified_context # ty:ignore[invalid-assignment]
import time
from collections.abc import Generator, Sequence
@@ -697,7 +697,10 @@ def build_dependency(
compr = "xz"
else:
raise RuntimeError("fix source for new download type")
download_tarfile = tarfile.open(name=download_tarfile_path, mode=f"r:{compr}")
# ty: false positive bug upstream.
download_tarfile = tarfile.open(
name=download_tarfile_path, mode=f"r:{compr}"
) # ty:ignore[no-matching-overload]
# tarfile seriously doesn't have a function to retrieve the root directory more easily
extract_dir_name = os.path.commonprefix([x for x in download_tarfile.getnames() if x != "."])
# run([tar, "--exclude=\"*/*\"", "-tf", download_name], cwd=build_dir).strip() no longer works
+3 -3
View File
@@ -1,5 +1,5 @@
black==26.3.1
ruff==0.15.22
ruff==0.16.0
poethepoet
ty==0.0.61
gersemi==0.26.1
ty==0.0.63
gersemi==0.28.0
+52 -6
View File
@@ -10,6 +10,7 @@ if bonsai_lib_path:
import argparse
import base64
import json
import urllib.parse
import xml.etree.ElementTree as ET
import pystache
@@ -18,8 +19,53 @@ from aiohttp import web
sio_port = 8080 # default port
def get_asset_version() -> str:
"""A cache-busting token appended to locally served static asset URLs.
Browsers otherwise keep serving a stale cached copy of static/js and
static/css after Bonsai ships a code change, until the user does a hard
refresh. Using the Bonsai version (which includes the build's commit
hash) means the token changes on every shipped update.
"""
if bonsai_version:
return urllib.parse.quote(bonsai_version, safe="")
# Fallback for standalone runs without BONSAI_VERSION set (e.g. running
# sioserver.py directly outside of Blender): derive a token from the
# newest mtime among the static assets, so it still changes whenever the
# shipped files change.
static_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
latest_mtime = 0
for root, _dirs, files in os.walk(static_dir):
for name in files:
latest_mtime = max(latest_mtime, int(os.path.getmtime(os.path.join(root, name))))
return f"dev-{latest_mtime}"
asset_version = get_asset_version()
@web.middleware
async def no_cache_static_middleware(request: web.Request, handler):
"""Force revalidation of locally served static assets.
Query-string version stamping (see `asset_version`) busts the cache for
the HTML-referenced entry points, but JS files that statically import
other local modules (e.g. cost.js/gantt.js importing utilities/costui.js)
reference those modules by an un-stamped relative path. Marking all
/static/ and /jsgantt/ responses as no-cache makes browsers always
revalidate with the server (a cheap conditional GET / 304 when nothing
changed), so nested imports also pick up shipped changes without
requiring a hard refresh.
"""
response = await handler(request)
if request.path.startswith("/static/") or request.path.startswith("/jsgantt/"):
response.headers["Cache-Control"] = "no-cache, must-revalidate"
return response
sio = socketio.AsyncServer(cors_allowed_origins="*", async_mode="aiohttp", max_http_buffer_size=10000000)
app = web.Application()
app = web.Application(middlewares=[no_cache_static_middleware])
sio.attach(app)
@@ -199,28 +245,28 @@ class BlenderNamespace(socketio.AsyncNamespace):
async def schedules(request):
with open("templates/index.html", "r") as f:
template = f.read()
html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version})
html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version, "v": asset_version})
return web.Response(text=html_content, content_type="text/html")
async def costing(request):
with open("templates/costing.html", "r") as f:
template = f.read()
html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version})
html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version, "v": asset_version})
return web.Response(text=html_content, content_type="text/html")
async def sequencing(request):
with open("templates/gantt.html", "r") as f:
template = f.read()
html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version})
html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version, "v": asset_version})
return web.Response(text=html_content, content_type="text/html")
async def documentation(request):
with open("templates/drawings.html", "r") as f:
template = f.read()
html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version})
html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version, "v": asset_version})
return web.Response(text=html_content, content_type="text/html")
@@ -229,7 +275,7 @@ async def documentation(request):
async def demo(request):
with open("templates/demo.html", "r") as f:
template = f.read()
html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version})
html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version, "v": asset_version})
return web.Response(text=html_content, content_type="text/html")
@@ -250,6 +250,14 @@ export class CostUI {
},
});
CostUI.addRibbonButton({
text: "Download CSV",
icon: "fa-solid fa-file-csv",
callback: () => {
CostUI.downloadCsv();
},
});
CostUI.addRibbonButton({
text: "Hide Schedules",
icon: "fa-regular fa-eye-slash",
@@ -523,6 +531,81 @@ export class CostUI {
}
}
static downloadCsv() {
const tables = document.querySelectorAll("table[id^='cost-items-']");
if (tables.length === 0) {
alert("No cost schedule loaded to export!");
return;
}
tables.forEach((table) => {
const scheduleId = table.id.split("-").pop();
const csv = CostUI.tableToCsv(table);
if (csv === null) {
return;
}
const nameEl = document.querySelector(
"#cost-schedule-container-" + scheduleId + " .form-header span"
);
const scheduleName = nameEl
? nameEl.textContent
: "cost_schedule_" + scheduleId;
CostUI.triggerCsvDownload(csv, scheduleName + ".csv");
});
}
static tableToCsv(table) {
const escapeCsvCell = (value) => {
const text = (value === null || value === undefined ? "" : value)
.toString()
.trim();
if (/[",\n]/.test(text)) {
return '"' + text.replace(/"/g, '""') + '"';
}
return text;
};
const cellText = (cell) => {
const input = cell.querySelector("input");
return input ? input.value : cell.innerText;
};
// The Actions column only holds buttons (edit/delete/etc), not data.
const isDataColumn = (column) => column && column !== "Actions";
const headerCells = Array.from(table.querySelectorAll("thead th")).filter(
(th) => isDataColumn(th.getAttribute("data-column"))
);
if (headerCells.length === 0) {
return null;
}
const rows = [headerCells.map((th) => escapeCsvCell(th.textContent)).join(",")];
table.querySelectorAll("tbody tr").forEach((row) => {
const cells = Array.from(row.children).filter((cell) =>
isDataColumn(cell.getAttribute("data-column"))
);
if (cells.length === 0) {
return; // e.g. the "No cost items found" placeholder row.
}
rows.push(cells.map((cell) => escapeCsvCell(cellText(cell))).join(","));
});
return rows.join("\n");
}
static triggerCsvDownload(csvContent, filename) {
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
static createCostTable({ costSchedule, currency, callbacks }) {
const preferences = CostUI.getColumnPreferences();
const isScheduleOfRates = costSchedule.PredefinedType === "SCHEDULEOFRATES";
@@ -7,7 +7,7 @@
<link
rel="stylesheet"
type="text/css"
href="/static/css/cost.css"
href="/static/css/cost.css?v={{v}}"
id="index-stylesheet"
/>
<link
@@ -21,7 +21,7 @@
/>
<script
type="text/javascript"
src="/static/js/jquery.min.js"
src="/static/js/jquery.min.js?v={{v}}"
></script>
<script
type="text/javascript"
@@ -34,7 +34,7 @@
<script>
var SOCKET_PORT = {{port}};
</script>
<script type="module" defer src="./static/js/cost.js"></script>
<script type="module" defer src="./static/js/cost.js?v={{v}}"></script>
</head>
<body>
<nav>
@@ -12,14 +12,14 @@
/>
<!-- here we request the CSS file from the server, -->
<!-- using registered static path in the server -->
<link rel="stylesheet" href="/static/css/demo.css" id="demo-stylesheet" />
<link rel="stylesheet" href="/static/css/demo.css?v={{v}}" id="demo-stylesheet" />
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.6.0/css/all.min.css"
/>
<script
type="text/javascript"
src="/static/js/jquery.min.js"
src="/static/js/jquery.min.js?v={{v}}"
></script>
<script
type="text/javascript"
@@ -33,7 +33,7 @@
</script>
<!-- here we request the JS file from the server-->
<!-- using registered static path in the server -->
<script defer src="./static/js/demo.js"></script>
<script defer src="./static/js/demo.js?v={{v}}"></script>
</head>
<body>
<!-- the navigation bar at the top of the page. -->
@@ -15,7 +15,7 @@
/>
<link
rel="stylesheet"
href="/static/css/drawings.css"
href="/static/css/drawings.css?v={{v}}"
id="drawings-stylesheet"
/>
<link
@@ -24,7 +24,7 @@
/>
<script
type="text/javascript"
src="/static/js/jquery.min.js"
src="/static/js/jquery.min.js?v={{v}}"
></script>
<script
type="text/javascript"
@@ -45,7 +45,7 @@
<script>
var SOCKET_PORT = {{port}};
</script>
<script defer src="./static/js/drawings.js"></script>
<script defer src="./static/js/drawings.js?v={{v}}"></script>
</head>
<body>
<nav>
@@ -9,25 +9,25 @@
type="image/x-icon"
href="https://bonsaibim.org/assets/images/favicon-blender.png"
/>
<link rel="stylesheet" type="text/css" href="/jsgantt/jsgantt.css" />
<link rel="stylesheet" href="/static/css/gantt.css" id="gantt-stylesheet" />
<link rel="stylesheet" type="text/css" href="/jsgantt/jsgantt.css?v={{v}}" />
<link rel="stylesheet" href="/static/css/gantt.css?v={{v}}" id="gantt-stylesheet" />
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.6.0/css/all.min.css"
/>
<script
type="text/javascript"
src="/static/js/jquery.min.js"
src="/static/js/jquery.min.js?v={{v}}"
></script>
<script
type="text/javascript"
src="https://cdn.socket.io/4.0.0/socket.io.min.js"
></script>
<script type="text/javascript" src="./jsgantt/jsgantt.js"></script>
<script type="text/javascript" src="./jsgantt/jsgantt.js?v={{v}}"></script>
<script>
var SOCKET_PORT = {{port}};
</script>
<script type="module" defer src="./static/js/gantt.js"></script>
<script type="module" defer src="./static/js/gantt.js?v={{v}}"></script>
</head>
<body>
<nav class="no-print">
@@ -9,7 +9,7 @@
type="image/x-icon"
href="https://bonsaibim.org/assets/images/favicon-blender.png"
/>
<link rel="stylesheet" href="/static/css/index.css" id="index-stylesheet" />
<link rel="stylesheet" href="/static/css/index.css?v={{v}}" id="index-stylesheet" />
<link
rel="stylesheet"
id="tabulator-stylesheet"
@@ -21,7 +21,7 @@
/>
<script
type="text/javascript"
src="/static/js/jquery.min.js"
src="/static/js/jquery.min.js?v={{v}}"
></script>
<script
type="text/javascript"
@@ -34,7 +34,7 @@
<script>
var SOCKET_PORT = {{port}};
</script>
<script defer src="./static/js/index.js"></script>
<script defer src="./static/js/index.js?v={{v}}"></script>
</head>
<body>
<nav>
+4 -3
View File
@@ -418,9 +418,10 @@ def get_user(ifc: ifcopenshell.file) -> Union[ifcopenshell.entity_instance, None
def viewport_shading_changed_callback(area: bpy.types.Area) -> None:
shading = area.spaces.active.shading.type
if shading == "RENDERED":
tool.Style.get_style_props().active_style_type = "External"
shading_type = area.spaces.active.shading.type
tool.Style.restore_material_style_types(shading_type)
if shading_type == "SOLID":
area.spaces.active.shading.color_type = "MATERIAL"
def subscribe_to_viewport_shading_changes():
-4
View File
@@ -133,10 +133,6 @@ class MaterialCreator:
if shape_has_openings and coords.is_a("IfcIndexedTextureMap"):
continue
tool.Loader.load_indexed_map(coords, self.mesh)
elif tool.Style.get_texture_style(material):
# No explicit coordinate mapping (e.g. IFC2X3 has no IsMappedBy,
# and IFC4 COORD uses generated UVs). Bake XY→UV as fallback.
tool.Loader.load_generated_uv_map(self.mesh)
def assign_material_slots_to_faces(self) -> None:
if not self.mesh["ios_materials"]:
+1 -1
View File
@@ -162,7 +162,7 @@ class ProjectLibraryData:
library_file = IfcStore.library_file
if library_file is None or library_file.schema == "IFC2X3":
return results
root = tool.Project.get_root_context(library_file)
root = library_file.by_type("IfcProject")[0]
results.append((str(root.id()), f"{root.is_a()} {root.Name or 'Unnamed'}", root.Description or ""))
for library_id, data in cls.data["project_libraries"].items():
results.append((str(library_id), data["Name"] or "Unnamed", data["Description"] or ""))
@@ -281,7 +281,7 @@ class RefreshLibrary(bpy.types.Operator):
elements = {e for e in elements if not tool.Project.is_element_assigned_to_project_library(e, rels)}
self.props.add_library_project_library("Unassigned", len(elements), 0, False)
root_context = tool.Project.get_root_context(library_file)
root_context = library_file.by_type("IfcProject")[0]
hierarchy = tool.Project.get_project_hierarchy(library_file)
tool.Project.load_project_libraries_to_ui(root_context, hierarchy)
return {"FINISHED"}
@@ -761,21 +761,22 @@ class EditProjectLibrary(bpy.types.Operator):
attributes = bonsai.bim.helper.export_attributes(props.project_library_attributes)
ifcopenshell.api.attribute.edit_attributes(library_file, project_library, attributes)
# Update parent library.
# Update parent library. Tear down the old IfcRelDeclares/IfcRelNests before
# creating the new one; a library must have exactly one of the two, never both.
previous_parent_library = tool.Project.get_parent_library(project_library)
new_parent_library = library_file.by_id(int(props.parent_library))
if previous_parent_library != new_parent_library:
if previous_parent_library is None:
# Edited library was a root in a library-only file; nest it under the new parent.
if previous_parent_library is not None:
if previous_parent_library.is_a("IfcProject"):
ifcopenshell.api.project.unassign_declaration(
library_file, [project_library], previous_parent_library
)
else:
ifcopenshell.api.nest.unassign_object(library_file, [project_library])
if new_parent_library.is_a("IfcProject"):
ifcopenshell.api.project.assign_declaration(library_file, [project_library], new_parent_library)
else:
ifcopenshell.api.nest.assign_object(library_file, [project_library], new_parent_library)
elif previous_parent_library.is_a("IfcProject"):
# Then new one is IfcProjectLibrary.
ifcopenshell.api.nest.assign_object(library_file, [project_library], new_parent_library)
else: # Previous is IfcProjectLibrary.
ifcopenshell.api.nest.unassign_object(library_file, [project_library])
# If new one is IfcProject, then it's already assigned by default.
if new_parent_library.is_a("IfcProjectLibrary"):
ifcopenshell.api.nest.assign_object(library_file, [project_library], new_parent_library)
props.is_editing_project_library = False
bpy.ops.bim.refresh_library()
@@ -809,12 +810,9 @@ class AddProjectLibrary(bpy.types.Operator):
props = tool.Project.get_project_props()
library_file = IfcStore.library_file
assert library_file
root_context = tool.Project.get_root_context(library_file)
root_context = library_file.by_type("IfcProject")[0]
project_library = ifcopenshell.api.root.create_entity(library_file, "IfcProjectLibrary")
if root_context.is_a("IfcProject"):
ifcopenshell.api.project.assign_declaration(library_file, [project_library], root_context)
else:
ifcopenshell.api.nest.assign_object(library_file, [project_library], root_context)
ifcopenshell.api.project.assign_declaration(library_file, [project_library], root_context)
ProjectLibraryData.load() # Update enum.
props.selected_project_library = str(project_library.id())
props.is_editing_project_library = True
@@ -1301,6 +1299,10 @@ class LoadProjectElements(bpy.types.Operator):
tool.Project.set_default_modeling_dimensions()
tool.Root.reload_grid_decorator()
bonsai.bim.handler.refresh_ui_data()
for screen in bpy.data.screens:
for area in screen.areas:
if area.type == "VIEW_3D":
bonsai.bim.handler.viewport_shading_changed_callback(area)
return {"FINISHED"}
def get_decomposition_elements(self) -> set[ifcopenshell.entity_instance]:
@@ -45,7 +45,9 @@ classes = (
operator.SelectByStyle,
operator.SelectStyleInStylesUI,
operator.SetAssetMaterialToExternalStyle,
operator.SuggestShadeFromExternalStyle,
operator.UnlinkStyle,
operator.TogglePreferIfcShading,
operator.UpdateCurrentStyle,
operator.UpdateStyleColours,
operator.UpdateStyleTextures,
+229 -10
View File
@@ -16,6 +16,7 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import colorsys
import os
from pathlib import Path
from typing import Any, Union
@@ -238,13 +239,15 @@ class UpdateCurrentStyle(bpy.types.Operator):
if not isinstance(obj.data, (bpy.types.Mesh, bpy.types.Curve)):
continue
for mat in obj.data.materials:
if (
mat
and mat not in updated_materials
and (msprops_ := tool.Style.get_material_style_props(mat)).ifc_definition_id != 0
):
msprops_.active_style_type = current_style_type
updated_materials.add(mat)
if not mat:
continue
msprops_ = tool.Style.get_material_style_props(mat)
if msprops_.ifc_definition_id == 0:
continue
if mat in updated_materials:
continue
msprops_.active_style_type = current_style_type
updated_materials.add(mat)
return {"FINISHED"}
@@ -457,10 +460,14 @@ class ActivateExternalStyle(bpy.types.Operator):
self.report({"ERROR"}, f"Error loading external style for \"{material.name}\" - {db['msg']}")
return {"CANCELLED"}
self.copy_material_attributes(db["data_block"], material)
ext_mat = db["data_block"]
self.copy_material_attributes(ext_mat, material)
if tool.Style.get_use_nodes(material):
tool.Blender.copy_node_graph(material, db["data_block"])
bpy.data.materials.remove(db["data_block"])
if material.get("bim_dual_branch"):
tool.Style.update_external_branch(material, ext_mat)
else:
tool.Style.setup_dual_branch(material, ext_mat)
bpy.data.materials.remove(ext_mat)
return {"FINISHED"}
def copy_material_attributes(self, source, target):
@@ -503,6 +510,218 @@ class ActivateExternalStyle(bpy.types.Operator):
set_prop(prop_name)
class TogglePreferIfcShading(bpy.types.Operator):
bl_idname = "bim.toggle_prefer_ifc_shading"
bl_label = "Toggle Flat/Pretty"
bl_description = (
"Toggle between Flat (IFC-native shading) and Pretty (external .blend style) for ALL styles.\n\n"
"SHIFT+CLICK to apply to this style only"
)
bl_options = {"REGISTER", "UNDO"}
material_name: bpy.props.StringProperty(name="Material Name", default="", options={"SKIP_SAVE"})
single_only: bpy.props.BoolProperty(name="Single Only", default=False, options={"SKIP_SAVE"})
def invoke(self, context, event):
if event.shift:
self.single_only = True
return self.execute(context)
def execute(self, context):
wm = context.window_manager
space = tool.Blender.get_view3d_space()
is_solid = space and space.shading.type == "SOLID"
if is_solid:
if space.shading.color_type == "TEXTURE":
space.shading.color_type = "MATERIAL"
else:
meshes_needing_uv = []
for obj in bpy.context.scene.objects:
if not isinstance(obj.data, bpy.types.Mesh):
continue
for slot in obj.material_slots:
mat = slot.material
if not mat or not tool.Blender.get_ifc_definition_id(mat):
continue
style_elements = tool.Style.get_style_elements(mat)
if style_elements.get("IfcSurfaceStyleWithTextures") and not obj.data.uv_layers:
meshes_needing_uv.append(obj.data)
break
wm.progress_begin(0, max(len(meshes_needing_uv), 1))
try:
for i, mesh in enumerate(meshes_needing_uv):
tool.Loader.load_generated_uv_map(mesh)
wm.progress_update(i)
finally:
wm.progress_end()
space.shading.color_type = "TEXTURE"
return {"FINISHED"}
if self.single_only:
mat = bpy.data.materials.get(self.material_name)
if not mat:
return {"CANCELLED"}
msprops = tool.Style.get_material_style_props(mat)
msprops.prefer_ifc_shading = not msprops.prefer_ifc_shading
else:
# Default: apply to all IFC materials
source_mat = bpy.data.materials.get(self.material_name)
new_value = not tool.Style.get_material_style_props(source_mat).prefer_ifc_shading if source_mat else True
ifc_mats = [m for m in bpy.data.materials if tool.Blender.get_ifc_definition_id(m)]
for mat in ifc_mats:
tool.Style.get_material_style_props(mat).prefer_ifc_shading = new_value
return {"FINISHED"}
class SuggestShadeFromExternalStyle(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.suggest_shade_from_external_style"
bl_label = "Suggest Shade from External Style"
bl_description = (
"Generate a Shade style (Surface Colour + Transparency) from the external .blend style.\n\n"
"ALT+CLICK to apply to all styles with an external .blend style"
)
bl_options = {"REGISTER", "UNDO"}
material_name: bpy.props.StringProperty(name="Material Name", default="", options={"SKIP_SAVE"})
all_styles: bpy.props.BoolProperty(name="All Styles", default=False, options={"SKIP_SAVE"})
value_offset: bpy.props.FloatProperty(
name="Value",
description="Offset added to the colour's value (-1 = fully dark, 0 = unchanged, +1 = fully light)",
default=0.0,
min=-1.0,
max=1.0,
step=1,
precision=2,
options={"SKIP_SAVE"},
)
saturation_factor: bpy.props.FloatProperty(
name="Saturation",
description="Scale applied to the colour's saturation (0 = greyscale, 1 = unchanged, >1 = more saturated)",
default=1.0,
min=0.0,
max=2.0,
step=1,
precision=2,
options={"SKIP_SAVE"},
)
def invoke(self, context, event):
if event.alt:
self.all_styles = True
return context.window_manager.invoke_props_dialog(self)
def draw(self, context):
layout = self.layout
layout.prop(self, "value_offset", slider=True)
layout.prop(self, "saturation_factor", slider=True)
if self.all_styles:
layout.label(text="Will apply to all external styles", icon="INFO")
def _execute(self, context):
if self.all_styles:
candidates = [
(mat, tool.Style.get_style_elements(mat))
for mat in bpy.data.materials
if tool.Blender.get_ifc_definition_id(mat)
]
candidates = [(mat, se) for mat, se in candidates if tool.Style.has_blender_external_style(se)]
wm = context.window_manager
wm.progress_begin(0, max(len(candidates), 1))
count = 0
color_cache: dict[tuple[str, str, str], tuple | None] = {}
try:
for i, (mat, style_elements) in enumerate(candidates):
wm.progress_update(i)
if self._apply_to_material(
mat, style_elements, self.value_offset, self.saturation_factor, color_cache
):
count += 1
finally:
wm.progress_end()
self.report({"INFO"}, f"Shade style generated for {count} style(s).")
else:
mat = bpy.data.materials.get(self.material_name)
if not mat:
return {"CANCELLED"}
style_elements = tool.Style.get_style_elements(mat)
if not tool.Style.has_blender_external_style(style_elements):
self.report({"ERROR"}, "No external .blend style assigned. Please assign an external style first.")
return {"CANCELLED"}
self._apply_to_material(mat, style_elements, self.value_offset, self.saturation_factor)
props = tool.Style.get_style_props()
if props.is_editing:
core.load_styles(tool.Style, style_type=props.style_type)
def _apply_to_material(
self,
material: bpy.types.Material,
style_elements: dict,
value_offset: float = 0.0,
saturation_factor: float = 1.0,
color_cache: "dict[tuple[str, str, str], tuple | None] | None" = None,
) -> bool:
external_style = style_elements["IfcExternallyDefinedSurfaceStyle"]
style_path = Path(tool.Ifc.resolve_uri(external_style.Location))
data_block_type, data_block = external_style.Identification.split("/")
cache_key = (str(style_path), data_block_type, data_block)
if color_cache is not None and cache_key in color_cache:
cached = color_cache[cache_key]
if cached is None:
return False # previously failed for this path
surface_colour, transparency = cached
else:
try:
db = tool.Blender.append_data_block(str(style_path), data_block_type, data_block)
except OSError as e:
self.report({"WARNING"}, f'Could not open blend file for "{material.name}": {e}')
if color_cache is not None:
color_cache[cache_key] = None
return False
if not db["data_block"]:
self.report({"WARNING"}, f'Could not load external style for "{material.name}": {db["msg"]}')
if color_cache is not None:
color_cache[cache_key] = None
return False
ext_mat = db["data_block"]
surface_colour, transparency = tool.Style.get_representative_material_color(ext_mat)
bpy.data.materials.remove(ext_mat)
if color_cache is not None:
color_cache[cache_key] = (surface_colour, transparency)
if value_offset != 0.0 or saturation_factor != 1.0:
h, s, v = colorsys.rgb_to_hsv(*surface_colour)
v = max(0.0, min(1.0, v + value_offset))
s = max(0.0, min(1.0, s * saturation_factor))
surface_colour = colorsys.hsv_to_rgb(h, s, v)
ifc_style = tool.Ifc.get_entity(material)
attributes: dict = {
"SurfaceColour": {
"Name": None,
"Red": surface_colour[0],
"Green": surface_colour[1],
"Blue": surface_colour[2],
},
}
if tool.Ifc.get_schema() != "IFC2X3":
attributes["Transparency"] = transparency
shading_style = style_elements.get("IfcSurfaceStyleShading")
if shading_style:
tool.Ifc.run("style.edit_surface_style", style=shading_style, attributes=attributes)
else:
tool.Ifc.run(
"style.add_surface_style",
style=ifc_style,
ifc_class="IfcSurfaceStyleShading",
attributes=attributes,
)
material.diffuse_color = (*surface_colour, 1.0 - transparency)
tool.Style.sync_flat_branch_shading(material, surface_colour, transparency)
return True
class DisableEditingStyles(bpy.types.Operator):
bl_idname = "bim.disable_editing_styles"
bl_options = {"REGISTER", "UNDO"}
@@ -372,6 +372,16 @@ def update_shading_style(self: "BIMStyleProperties", context: bpy.types.Context)
tool.Style.switch_shading(blender_material, self.active_style_type)
def update_prefer_ifc_shading(self: "BIMStyleProperties", context: bpy.types.Context) -> None:
style_elements = tool.Style.get_style_elements(self.id_data)
has_external = tool.Style.has_blender_external_style(style_elements)
if self.prefer_ifc_shading or not has_external:
self.active_style_type = "Shading"
else:
self.active_style_type = "External"
self.id_data.update_tag()
class BIMStyleProperties(PropertyGroup):
ifc_definition_id: IntProperty(name="IFC Definition ID")
active_style_type: EnumProperty(
@@ -381,9 +391,19 @@ class BIMStyleProperties(PropertyGroup):
default="Shading",
update=update_shading_style,
)
prefer_ifc_shading: BoolProperty(
name="Flat / Pretty",
description=(
"Toggle between Flat (IFC-native shading) and Pretty (external .blend style). "
"When set to Flat, viewport switches to Material Preview or Rendered will not activate the external style."
),
default=False,
update=update_prefer_ifc_shading,
)
is_renaming: BoolProperty(description="Used to prevent triggering handler callback.", default=False)
if TYPE_CHECKING:
ifc_definition_id: int
active_style_type: tool.Style.StyleType
prefer_ifc_shading: bool
is_renaming: bool
+65
View File
@@ -110,6 +110,11 @@ class BIM_PT_styles(Panel):
op = row.operator("bim.update_current_style", icon="FILE_REFRESH", text="")
op.style_id = style.ifc_definition_id
if active_style and self.props.style_type == "IfcSurfaceStyle":
if material := style.blender_material:
msprops = tool.Style.get_material_style_props(material)
self.draw_style_status_row(material, msprops)
if self.props.style_type == "IfcSurfaceStyle":
self.layout.label(text="Surface Style Element:")
col = self.layout.column(align=True)
@@ -161,6 +166,66 @@ class BIM_PT_styles(Panel):
edit_label = "Save Lighting Style"
self.draw_edit_ui(edit_label)
def draw_style_status_row(self, material: bpy.types.Material, msprops) -> None:
space = tool.Blender.get_view3d_space()
box = self.layout.box()
obj = bpy.context.active_object
parts = []
if space:
shading_type = space.shading.type
shading_labels = {
"SOLID": "Solid",
"MATERIAL": "Material Preview",
"RENDERED": "Rendered",
"WIREFRAME": "Wireframe",
}
parts.append(f"Viewport: {shading_labels.get(shading_type, shading_type)}")
else:
parts.append("No 3D viewport")
shading_type = None
if obj:
obj_has_uv = isinstance(obj.data, bpy.types.Mesh) and bool(obj.data.uv_layers)
uv_label = "UV \u2713" if obj_has_uv else "UV \u2717"
parts.append(f"Selected Object: {obj.name} {uv_label}")
else:
parts.append("Selected Object: None")
if shading_type == "SOLID":
is_flat = space.shading.color_type != "TEXTURE"
mode_label = "Flat"
dep_label = "Shade"
if not is_flat:
mode_label = "Pretty"
dep_label = "Texture \u2192 Shade"
elif shading_type in ("MATERIAL", "RENDERED"):
is_flat = msprops.prefer_ifc_shading
mode_label = "Flat"
dep_label = "Render+Texture \u2192 Render \u2192 Shade"
if not is_flat:
mode_label = "Pretty"
dep_label = "External \u2192 Render+Texture \u2192 Render \u2192 Shade"
else:
is_flat = False
mode_label = ""
dep_label = ""
row1 = box.row(align=True)
row1.label(text=" | ".join(parts))
if mode_label:
row2 = box.row(align=True)
row2.label(text=f"Current Mode: {mode_label} \u2014 {dep_label}")
row3 = box.row(align=True)
row3.alignment = "RIGHT"
op = row3.operator("bim.suggest_shade_from_external_style", text="", icon="BRUSHES_ALL")
op.material_name = material.name
op = row3.operator("bim.toggle_prefer_ifc_shading", text="", icon="UV_SYNC_SELECT")
op.material_name = material.name
def draw_surface_style_shading(self):
row = self.layout.row()
row.prop(self.props, "surface_colour")
-1
View File
@@ -222,7 +222,6 @@ def generate_space(
if element and element.is_a("IfcSpace"):
spatial.set_space_representation_from_polygon(active_obj, element, space_polygon, h, polygon_is_si=True)
spatial.translate_obj_to_z_location(active_obj, z)
else:
if relating_type:
name = model.generate_occurrence_name(relating_type, "IfcSpace")
+34
View File
@@ -803,6 +803,40 @@ class Blender(bonsai.core.tool.Blender):
# restore shader editor settings
shader_editor.pin = previous_pin_setting
@classmethod
def copy_node_graph_additive(
cls, material_to: bpy.types.Material, material_from: bpy.types.Material
) -> bpy.types.ShaderNodeOutputMaterial | None:
"""Paste nodes from material_from alongside the existing nodes in material_to.
Unlike copy_node_graph this does NOT clear the existing node tree first.
Returns the OUTPUT_MATERIAL node that was added from material_from, or None.
"""
temp_override = cls.get_shader_editor_context()
shader_editor = temp_override["space"]
before_names = {n.name for n in material_to.node_tree.nodes}
previous_pin_setting = shader_editor.pin
shader_editor.pin = True
shader_editor.node_tree = material_from.node_tree
for node in material_from.node_tree.nodes:
node.select = True
with bpy.context.temp_override(**temp_override):
bpy.ops.node.clipboard_copy()
shader_editor.node_tree = material_to.node_tree
with bpy.context.temp_override(**temp_override):
bpy.ops.node.clipboard_paste(offset=(0, 0))
shader_editor.pin = previous_pin_setting
for node in material_to.node_tree.nodes:
if node.name not in before_names and node.type == "OUTPUT_MATERIAL":
return node
return None
@classmethod
def get_material_node(
cls, blender_material: bpy.types.Material, node_type: str, kwargs: Optional[dict] = {}
+3 -3
View File
@@ -168,12 +168,12 @@ class Polyline(bonsai.core.tool.Polyline):
distance = (mouse_vector - last_point).length
if distance < 0:
return
angle, orientation_angle, angle_round_threshold = None, None, None
angle, orientation_angle = None, None
angle_round_threshold = 1000 # Avoids rounding when distance is too big
if distance > 0:
angle = tool.Cad.angle_3_vectors(
second_to_last_point, last_point, mouse_vector, new_angle=None, degrees=True
)
angle_round_threshold = 1000 # Avoids rounding when distance is too big
# Round angle to the nearest 0.05
angle = round(angle / 0.05) * 0.05 if distance < angle_round_threshold else angle
@@ -189,7 +189,7 @@ class Polyline(bonsai.core.tool.Polyline):
angle = 0
orientation_angle = 0
if input_ui:
assert angle is not None and orientation_angle is not None and angle_round_threshold is not None
assert angle is not None and orientation_angle is not None
if should_round:
angle_snap = tool.Snap.get_angle_snap_value(context)
angle = angle_snap * round(angle / angle_snap) if distance < angle_round_threshold else angle
+2 -17
View File
@@ -391,29 +391,14 @@ class Project(bonsai.core.tool.Project):
def get_parent_library(
cls, project_library: ifcopenshell.entity_instance
) -> Union[ifcopenshell.entity_instance, None]:
"""Return the IfcContext that declares or nests ``project_library``.
Returns ``None`` when ``project_library`` is itself the root of a
library-only file (no IfcRelNests, no IfcRelDeclares).
"""
"""Return the IfcContext that declares or nests ``project_library``, or ``None``
if neither relationship is present."""
if nests := project_library.Nests:
return nests[0].RelatingObject
if has_context := project_library.HasContext:
return has_context[0].RelatingContext
return None
@classmethod
def get_root_context(cls, ifc_file: ifcopenshell.file) -> ifcopenshell.entity_instance:
"""Return the file's root IfcContext.
Prefers IfcProject if present, otherwise falls back to IfcProjectLibrary
library-only files are valid per IFC4+ and contain no IfcProject. Caller is
responsible for the IFC2X3 guard; IfcContext does not exist in that schema.
"""
if projects := ifc_file.by_type("IfcProject"):
return projects[0]
return ifc_file.by_type("IfcProjectLibrary")[0]
@classmethod
def get_project_hierarchy(cls, ifc_file: ifcopenshell.file) -> HiearchyDict:
"""Get project hierarchy in the following form:
+363
View File
@@ -597,6 +597,146 @@ class Style(bonsai.core.tool.Style):
external_style = style_elements.get("IfcExternallyDefinedSurfaceStyle", None)
return bool(external_style and external_style.Location and external_style.Location.endswith(".blend"))
@classmethod
def _color_from_principled(cls, node: bpy.types.Node) -> tuple[tuple[float, float, float], float]:
color = cls._resolve_color_socket(node.inputs["Base Color"])
alpha_socket = node.inputs["Alpha"]
alpha_source = cls._upstream_color_source(alpha_socket)
if alpha_source and alpha_source[0] == "IMAGE":
pixels = alpha_source[1].pixels[:]
n = len(pixels) // 4
step = max(1, n // 4096)
a_sum = sum(pixels[i * 4 + 3] for i in range(0, n, step))
count = len(range(0, n, step)) or 1
transparency = 1.0 - (a_sum / count)
else:
transparency = 1.0 - alpha_socket.default_value
return color, transparency
@classmethod
def _color_from_shader_socket(
cls, socket: bpy.types.NodeSocket, seen: set[str] | None = None
) -> tuple[tuple[float, float, float], float] | None:
if seen is None:
seen = set()
for link in socket.links:
node = link.from_node
if node.name in seen:
continue
seen.add(node.name)
if node.type == "BSDF_PRINCIPLED":
return cls._color_from_principled(node)
if node.type in ("BSDF_DIFFUSE", "DIFFUSE_BSDF"):
return cls._resolve_color_socket(node.inputs["Color"]), 0.0
if node.type == "BSDF_GLASS":
return cls._resolve_color_socket(node.inputs["Color"]), 0.0
if node.type in ("MIX_SHADER", "ADD_SHADER"):
for inp in node.inputs:
if inp.type == "SHADER" and inp.is_linked:
result = cls._color_from_shader_socket(inp, seen)
if result:
return result
return None
@classmethod
def get_representative_material_color(
cls, material: bpy.types.Material
) -> tuple[tuple[float, float, float], float]:
if material.node_tree:
nodes = material.node_tree.nodes
output_node = next((n for n in nodes if n.type == "OUTPUT_MATERIAL" and n.is_active_output), None) or next(
(n for n in nodes if n.type == "OUTPUT_MATERIAL"), None
)
if output_node:
result = cls._color_from_shader_socket(output_node.inputs["Surface"])
if result:
return result
# Fallback: scan all shader nodes if no output node or graph traversal found nothing
for node in nodes:
if node.type == "BSDF_PRINCIPLED":
return cls._color_from_principled(node)
for node in nodes:
if node.type in ("BSDF_DIFFUSE", "DIFFUSE_BSDF"):
return cls._resolve_color_socket(node.inputs["Color"]), 0.0
for node in nodes:
if node.type == "BSDF_GLASS":
return cls._resolve_color_socket(node.inputs["Color"]), 0.0
color = tuple(material.diffuse_color[:3])
transparency = 1.0 - material.diffuse_color[3]
return color, transparency
@classmethod
def _collect_upstream_sources(cls, socket: bpy.types.NodeSocket, seen: set[str]) -> list[tuple[str, object]]:
"""Recursively collect all upstream colour/image sources reachable from *socket*."""
results = []
for link in socket.links:
node = link.from_node
if node.name in seen:
continue
seen.add(node.name)
if node.type == "TEX_IMAGE":
results.append(("IMAGE", node.image))
elif node.type == "VALTORGB":
results.append(("COLORRAMP", node))
else:
for inp in node.inputs:
if inp.is_linked:
results.extend(cls._collect_upstream_sources(inp, seen))
return results
@classmethod
def _upstream_color_source(
cls, socket: bpy.types.NodeSocket, seen: set[str] | None = None
) -> tuple[str, object] | None:
sources = cls._collect_upstream_sources(socket, set() if seen is None else seen)
# Prefer a concrete image texture over a colour ramp (which may be greyscale/procedural).
for s in sources:
if s[0] == "IMAGE":
return s
for s in sources:
if s[0] == "COLORRAMP":
return s
return None
@classmethod
def _resolve_color_socket(cls, socket: bpy.types.NodeSocket) -> tuple[float, float, float]:
source = cls._upstream_color_source(socket)
if source is None:
return tuple(socket.default_value[:3])
kind, obj = source
if kind == "IMAGE":
return cls._average_image_color(obj)
if kind == "COLORRAMP":
return cls._average_colorramp_color(obj)
return tuple(socket.default_value[:3])
@staticmethod
def _average_image_color(image: bpy.types.Image) -> tuple[float, float, float]:
pixels = image.pixels[:]
n = len(pixels) // 4
if n == 0:
return (0.5, 0.5, 0.5)
step = max(1, n // 4096)
r_sum = g_sum = b_sum = 0.0
count = 0
for i in range(0, n, step):
base = i * 4
r_sum += pixels[base]
g_sum += pixels[base + 1]
b_sum += pixels[base + 2]
count += 1
return (r_sum / count, g_sum / count, b_sum / count)
@staticmethod
def _average_colorramp_color(node: bpy.types.Node) -> tuple[float, float, float]:
elements = node.color_ramp.elements
if not elements:
return (0.5, 0.5, 0.5)
r = sum(e.color[0] for e in elements) / len(elements)
g = sum(e.color[1] for e in elements) / len(elements)
b = sum(e.color[2] for e in elements) / len(elements)
return (r, g, b)
@classmethod
def is_editing_styles(cls) -> bool:
props = cls.get_style_props()
@@ -674,9 +814,179 @@ class Style(bonsai.core.tool.Style):
props = cls.get_material_style_props(blender_material)
props.active_style_type = props.active_style_type
@classmethod
def get_branch_outputs(
cls, material: bpy.types.Material
) -> tuple[bpy.types.ShaderNode | None, bpy.types.ShaderNode | None]:
"""Return (external_output_node, flat_output_node), or (None, None) if not dual-branch."""
if not material.node_tree:
return None, None
ext = material.node_tree.nodes.get("BIM_Output_External")
fast = material.node_tree.nodes.get("BIM_Output_Flat")
return ext, fast
@classmethod
def _remove_external_branch(cls, material: bpy.types.Material) -> None:
"""Remove all nodes reachable from BIM_Output_External (walks links backwards)."""
if not material.node_tree:
return
nodes = material.node_tree.nodes
output = nodes.get("BIM_Output_External")
if not output:
return
to_remove: set[str] = set()
stack = [output]
while stack:
node = stack.pop()
if node.name in to_remove:
continue
to_remove.add(node.name)
for inp in node.inputs:
for link in inp.links:
stack.append(link.from_node)
for name in list(to_remove):
n = nodes.get(name)
if n:
nodes.remove(n)
@classmethod
def _build_flat_branch_nodes(cls, material: bpy.types.Material) -> bpy.types.ShaderNode:
"""Add a Principled BSDF flat-branch to material's existing node tree.
Reads IfcSurfaceStyleRendering or IfcSurfaceStyleShading from the linked IFC entity.
Defaults to a white BSDF when no IFC shading data is available.
Returns the new Material Output node (named BIM_Output_Flat, is_active_output=False).
"""
from mathutils import Vector
style_elements = cls.get_style_elements(material)
nodes = material.node_tree.nodes
links = material.node_tree.links
bsdf = nodes.new("ShaderNodeBsdfPrincipled")
bsdf.location = Vector((10, -600))
output = nodes.new("ShaderNodeOutputMaterial")
output.name = "BIM_Output_Flat"
output.location = Vector((300, -600))
output.is_active_output = False
links.new(bsdf.outputs["BSDF"], output.inputs["Surface"])
rendering_style = None
shading_only = None
for surface_style in style_elements.values():
if surface_style.is_a() == "IfcSurfaceStyleShading":
shading_only = surface_style
elif surface_style.is_a("IfcSurfaceStyleRendering"):
rendering_style = surface_style
shading_only = None
if rendering_style:
d = tool.Loader.surface_style_to_dict(rendering_style)
if d.get("DiffuseColour"):
ctype, cval = d["DiffuseColour"]
if ctype == "IfcColourRgb":
bsdf.inputs["Base Color"].default_value = cval + (1,)
solid_color = cval
else:
cval = tuple(v * cval for v in d["SurfaceColour"])
bsdf.inputs["Base Color"].default_value = cval + (1,)
solid_color = cval
else:
r, g, b = d["SurfaceColour"]
bsdf.inputs["Base Color"].default_value = (r, g, b, 1.0)
solid_color = (r, g, b)
if d.get("SpecularColour"):
ctype, cval = d["SpecularColour"]
if ctype == "IfcNormalisedRatioMeasure":
bsdf.inputs["Metallic"].default_value = cval
if d.get("SpecularHighlight"):
bsdf.inputs["Roughness"].default_value = d["SpecularHighlight"]
transparency = d.get("Transparency") or 0.0
bsdf.inputs["Alpha"].default_value = 1 - transparency
if transparency > 0:
material.blend_method = "BLEND"
material.diffuse_color = solid_color + (1.0 - transparency,)
elif shading_only:
d = tool.Loader.surface_style_to_dict(shading_only)
r, g, b = d["SurfaceColour"]
alpha = 1 - (d.get("Transparency") or 0.0)
bsdf.inputs["Base Color"].default_value = (r, g, b, 1.0)
bsdf.inputs["Alpha"].default_value = alpha
if alpha < 1.0:
material.blend_method = "BLEND"
material.diffuse_color = (r, g, b, alpha)
# else: leave default white Principled BSDF
return output
@classmethod
def setup_dual_branch(cls, material: bpy.types.Material, ext_material: bpy.types.Material) -> bool:
"""Build a dual-branch node tree: flat branch from IFC data + external branch from ext_material.
Clears any existing nodes and builds both branches from scratch.
External branch output (BIM_Output_External) is set active Pretty mode.
Flat branch output (BIM_Output_Flat) is inactive Flat mode.
Returns True on success; False if no shader editor is available (falls back to single-branch).
"""
cls.set_use_nodes(material, True)
for n in material.node_tree.nodes[:]:
material.node_tree.nodes.remove(n)
cls._build_flat_branch_nodes(material)
ext_output = tool.Blender.copy_node_graph_additive(material, ext_material)
if not ext_output:
# No shader editor available: fall back to single-branch
tool.Blender.copy_node_graph(material, ext_material)
return False
ext_output.name = "BIM_Output_External"
ext_output.is_active_output = True
material["bim_dual_branch"] = True
return True
@classmethod
def update_external_branch(cls, material: bpy.types.Material, ext_material: bpy.types.Material) -> None:
"""Replace the external-branch nodes of an already dual-branch material."""
cls._remove_external_branch(material)
ext_output = tool.Blender.copy_node_graph_additive(material, ext_material)
if ext_output:
ext_output.name = "BIM_Output_External"
ext_output.is_active_output = True
fast = material.node_tree.nodes.get("BIM_Output_Flat")
if fast:
fast.is_active_output = False
@classmethod
def sync_flat_branch_shading(
cls, material: bpy.types.Material, surface_colour: tuple[float, float, float], transparency: float
) -> None:
"""Update the flat-branch Principled BSDF with new shading values.
Call this after creating or editing IfcSurfaceStyleShading so the flat branch
stays in sync without requiring a full setup_dual_branch rebuild.
"""
if not material.node_tree:
return
fast_output = material.node_tree.nodes.get("BIM_Output_Flat")
if not fast_output:
return
for link in fast_output.inputs["Surface"].links:
if link.from_node.type == "BSDF_PRINCIPLED":
bsdf = link.from_node
r, g, b = surface_colour
bsdf.inputs["Base Color"].default_value = (r, g, b, 1.0)
bsdf.inputs["Alpha"].default_value = 1.0 - transparency
break
@classmethod
def switch_shading(cls, blender_material: bpy.types.Material, style_type: StyleType) -> None:
if style_type == "External":
ext, fast = cls.get_branch_outputs(blender_material)
if ext and fast:
ext.is_active_output = True
fast.is_active_output = False
blender_material.update_tag()
return
try:
bpy.ops.bim.activate_external_style(material_name=blender_material.name)
except RuntimeError as error:
@@ -684,21 +994,41 @@ class Style(bonsai.core.tool.Style):
return
raise error
elif style_type == "Shading":
ext, fast = cls.get_branch_outputs(blender_material)
if ext and fast:
fast.is_active_output = True
ext.is_active_output = False
blender_material.update_tag()
return
style_elements = tool.Style.get_style_elements(blender_material)
rendering_style = None
texture_style = None
shading_only_style = None
for surface_style in style_elements.values():
if surface_style.is_a() == "IfcSurfaceStyleShading":
shading_only_style = surface_style
tool.Loader.create_surface_style_shading(blender_material, surface_style)
elif surface_style.is_a("IfcSurfaceStyleRendering"):
rendering_style = surface_style
shading_only_style = None # rendering overrides shading-only path
tool.Loader.create_surface_style_rendering(blender_material, surface_style)
elif surface_style.is_a("IfcSurfaceStyleWithTextures"):
texture_style = surface_style
if rendering_style and texture_style:
tool.Loader.create_surface_style_with_textures(blender_material, rendering_style, texture_style)
elif shading_only_style and not rendering_style:
# create a minimal Principled BSDF so Material Preview/Rendered shows the colour instead of white.
tool.Style.set_use_nodes(blender_material, True)
tool.Loader.restart_material_node_tree(blender_material)
bsdf = tool.Blender.get_material_node(blender_material, "BSDF_PRINCIPLED")
if bsdf:
r, g, b, a = blender_material.diffuse_color
bsdf.inputs["Base Color"].default_value = (r, g, b, 1)
bsdf.inputs["Alpha"].default_value = a
if a < 1.0:
blender_material.blend_method = "BLEND"
else:
assert False, f"Invalid style type found: {style_type}"
@@ -744,3 +1074,36 @@ class Style(bonsai.core.tool.Style):
elements = ifcopenshell.util.element.get_elements_by_style(tool.Ifc.get(), style)
objects = [tool.Ifc.get_object(e) for e in elements]
tool.Geometry.reload_representation(objects)
_last_shading_type: str | None = None
@classmethod
def restore_material_style_types(cls, shading_type: str) -> None:
"""Set each IFC material's active_style_type to the richest available for the given viewport mode.
In SOLID mode all materials use "Shading".
In MATERIAL_PREVIEW / RENDERED, materials with an external .blend style use "External"
unless prefer_ifc_shading is set on that material.
"""
if cls._last_shading_type == shading_type:
return
cls._last_shading_type = shading_type
for material in bpy.data.materials:
if not tool.Blender.get_ifc_definition_id(material):
continue
props = cls.get_material_style_props(material)
style_elements = cls.get_style_elements(material)
if shading_type == "SOLID":
props.active_style_type = "Shading"
shading = style_elements.get("IfcSurfaceStyleRendering") or style_elements.get("IfcSurfaceStyleShading")
if shading:
d = tool.Loader.surface_style_to_dict(shading)
alpha = 1.0 - (d.get("Transparency") or 0.0)
material.diffuse_color = d["SurfaceColour"] + (alpha,)
else: # MATERIAL_PREVIEW or RENDERED
if cls.has_blender_external_style(style_elements) and not props.prefer_ifc_shading:
props.active_style_type = "External"
else:
props.active_style_type = "Shading"
material.update_tag()
@@ -32,64 +32,91 @@ from test.bim.bootstrap import NewIfc
pytestmark = pytest.mark.project
def _make_library_only_file(*, with_child: bool = False) -> ifcopenshell.file:
"""Build a minimal IFC4 file containing only an IfcProjectLibrary (no IfcProject).
def _make_library_file(*, with_child: bool = False) -> ifcopenshell.file:
"""Build a spec-valid IFC4 library file: IfcProject + IfcProjectLibrary declared to it.
Per IFC4+, a file must contain at least one IfcContext; IfcProjectLibrary is a
valid root on its own. ``with_child=True`` nests a sub-library under the root via
IfcRelNests, mirroring real authored library files.
Per the IFC Project Context concept template, every project data set (library
files included) shall contain exactly one IfcProject, and IfcProjectLibrary
instances are assigned to it via IfcRelDeclares. This matches how every library
file shipped in bonsai/bim/data/libraries is actually authored. ``with_child=True``
also nests a sub-library under the root via IfcRelNests.
"""
library_file = ifcopenshell.api.project.create_file(version="IFC4")
project = ifcopenshell.api.root.create_entity(library_file, ifc_class="IfcProject", name="Demo Project")
root = ifcopenshell.api.root.create_entity(library_file, ifc_class="IfcProjectLibrary", name="RootLib")
ifcopenshell.api.project.assign_declaration(library_file, definitions=[root], relating_context=project)
if with_child:
child = ifcopenshell.api.root.create_entity(library_file, ifc_class="IfcProjectLibrary", name="ChildLib")
ifcopenshell.api.nest.assign_object(library_file, [child], root)
return library_file
class TestLibraryOnlyFile(NewIfc):
def test_get_root_context_returns_project_library_when_no_project(self):
library_file = _make_library_only_file()
assert not library_file.by_type("IfcProject")
class TestLibraryFile(NewIfc):
"""Project-library UI code operating on a spec-valid model (IfcProject root).
root = tool.Project.get_root_context(library_file)
A file containing only IfcProjectLibrary and no IfcProject is not valid IFC and
is not supported; see test_parent_libraries_enum_raises_for_a_file_without_a_project.
"""
assert root.is_a("IfcProjectLibrary")
assert root.Name == "RootLib"
def test_parent_libraries_enum_raises_for_a_file_without_a_project(self):
library_file = ifcopenshell.api.project.create_file(version="IFC4")
ifcopenshell.api.root.create_entity(library_file, ifc_class="IfcProjectLibrary", name="RootLib")
IfcStore.library_file = library_file
try:
with pytest.raises(IndexError):
ProjectLibraryData.parent_libraries_enum()
finally:
IfcStore.library_file = None
def test_get_parent_library_returns_none_for_root_library(self):
library_file = _make_library_only_file()
def test_get_parent_library_returns_project_for_declared_root_library(self):
library_file = _make_library_file()
project = library_file.by_type("IfcProject")[0]
root = library_file.by_type("IfcProjectLibrary")[0]
assert tool.Project.get_parent_library(root) is None
assert tool.Project.get_parent_library(root) == project
def test_get_project_hierarchy_skips_root_library(self):
library_file = _make_library_only_file(with_child=True)
def test_get_parent_library_returns_the_library_for_a_nested_sub_library(self):
library_file = _make_library_file(with_child=True)
root = next(lib for lib in library_file.by_type("IfcProjectLibrary") if lib.Name == "RootLib")
child = next(lib for lib in library_file.by_type("IfcProjectLibrary") if lib.Name == "ChildLib")
assert tool.Project.get_parent_library(child) == root
def test_get_parent_library_returns_none_for_an_orphaned_library(self):
library_file = ifcopenshell.api.project.create_file(version="IFC4")
orphan = ifcopenshell.api.root.create_entity(library_file, ifc_class="IfcProjectLibrary", name="Orphan")
assert tool.Project.get_parent_library(orphan) is None
def test_get_project_hierarchy_roots_libraries_under_the_project(self):
library_file = _make_library_file(with_child=True)
project = library_file.by_type("IfcProject")[0]
root = next(lib for lib in library_file.by_type("IfcProjectLibrary") if lib.Name == "RootLib")
child = next(lib for lib in library_file.by_type("IfcProjectLibrary") if lib.Name == "ChildLib")
hierarchy = tool.Project.get_project_hierarchy(library_file)
assert root in hierarchy
assert root in hierarchy[project]
assert child in hierarchy[root]
def test_project_library_data_loads_without_crash(self):
IfcStore.library_file = _make_library_only_file()
def test_project_library_data_loads_with_unique_enum_keys(self):
IfcStore.library_file = _make_library_file(with_child=True)
try:
ProjectLibraryData.is_loaded = False
ProjectLibraryData.load()
assert ProjectLibraryData.is_loaded
enum = ProjectLibraryData.data["parent_libraries_enum"]
assert len(enum) == 1
assert enum[0][1].startswith("IfcProjectLibrary ")
keys = [entry[0] for entry in enum]
assert len(keys) == len(set(keys))
assert enum[0][1].startswith("IfcProject ")
finally:
IfcStore.library_file = None
ProjectLibraryData.is_loaded = False
def test_refresh_library_succeeds_on_library_only_file(self):
def test_refresh_library_succeeds(self):
import bpy
IfcStore.library_file = _make_library_only_file(with_child=True)
IfcStore.library_file = _make_library_file(with_child=True)
try:
result = bpy.ops.bim.refresh_library()
assert result == {"FINISHED"}
@@ -97,13 +124,65 @@ class TestLibraryOnlyFile(NewIfc):
IfcStore.library_file = None
ProjectLibraryData.is_loaded = False
def test_add_project_library_nests_under_root_when_no_project(self):
def test_edit_project_library_moves_a_project_declared_library_under_another_library(self):
import bpy
IfcStore.library_file = _make_library_only_file()
library_file = _make_library_file()
project = library_file.by_type("IfcProject")[0]
root = library_file.by_type("IfcProjectLibrary")[0]
target = ifcopenshell.api.root.create_entity(library_file, ifc_class="IfcProjectLibrary", name="TargetLib")
ifcopenshell.api.project.assign_declaration(library_file, definitions=[target], relating_context=project)
IfcStore.library_file = library_file
try:
props = tool.Project.get_project_props()
props.selected_project_library = str(root.id())
props.is_editing_project_library = True
props.parent_library = str(target.id())
result = bpy.ops.bim.edit_project_library()
assert result == {"FINISHED"}
assert tool.Project.get_parent_library(root) == target
assert root.Nests and root.Nests[0].RelatingObject == target
assert not root.HasContext
finally:
if props.is_editing_project_library:
props.is_editing_project_library = False
IfcStore.library_file = None
ProjectLibraryData.is_loaded = False
def test_edit_project_library_moves_a_nested_library_back_under_the_project(self):
import bpy
library_file = _make_library_file(with_child=True)
project = library_file.by_type("IfcProject")[0]
child = next(lib for lib in library_file.by_type("IfcProjectLibrary") if lib.Name == "ChildLib")
IfcStore.library_file = library_file
try:
props = tool.Project.get_project_props()
props.selected_project_library = str(child.id())
props.is_editing_project_library = True
props.parent_library = str(project.id())
result = bpy.ops.bim.edit_project_library()
assert result == {"FINISHED"}
assert tool.Project.get_parent_library(child) == project
assert child.HasContext and child.HasContext[0].RelatingContext == project
assert not child.Nests
finally:
if props.is_editing_project_library:
props.is_editing_project_library = False
IfcStore.library_file = None
ProjectLibraryData.is_loaded = False
def test_add_project_library_declares_new_library_under_the_project_root(self):
import bpy
IfcStore.library_file = _make_library_file()
library_file = IfcStore.library_file
try:
root = library_file.by_type("IfcProjectLibrary")[0]
project = library_file.by_type("IfcProject")[0]
before = set(library_file.by_type("IfcProjectLibrary"))
result = bpy.ops.bim.add_project_library()
@@ -113,9 +192,9 @@ class TestLibraryOnlyFile(NewIfc):
new_libraries = after - before
assert len(new_libraries) == 1
new_library = next(iter(new_libraries))
assert new_library.Nests
assert new_library.Nests[0].RelatingObject == root
assert not new_library.HasContext
assert new_library.HasContext
assert new_library.HasContext[0].RelatingContext == project
assert not new_library.Nests
finally:
IfcStore.library_file = None
ProjectLibraryData.is_loaded = False
+4
View File
@@ -497,6 +497,8 @@ class TestGitMergetool:
with tempfile.TemporaryDirectory() as tmpdir:
ifc_path = os.path.join(tmpdir, "model.ifc")
mock_repo = mock.MagicMock()
# A clean mergetool resolution leaves no unmerged blobs in the index.
mock_repo.index.unmerged_blobs.return_value = {}
IfcGitRepo.repo = mock_repo
result = IfcGit.git_mergetool("ifcmerge", ifc_path)
assert result is None
@@ -511,6 +513,8 @@ class TestGitMergetool:
report_path = ifc_path + ".ifcmerge"
open(report_path, "w").close()
mock_repo = mock.MagicMock()
# A clean mergetool resolution leaves no unmerged blobs in the index.
mock_repo.index.unmerged_blobs.return_value = {}
IfcGitRepo.repo = mock_repo
result = IfcGit.git_mergetool("ifcmerge", ifc_path)
assert result is None
+28
View File
@@ -56,3 +56,31 @@ class TestValidateInput(NewFile):
# Angle.
assert subject.validate_input("25", "A") == (True, "25.0")
class TestCalculateDistanceAndAngle(NewFile):
def test_it_does_not_crash_when_distance_is_zero_and_should_round(self, monkeypatch):
# Regression test for #8597: right after placing the first polyline
# point, the initial mouse sample can equal the last placed point
# (distance == 0), e.g. entering the viewport on a YZ plane wall.
# angle_round_threshold used to only be assigned in the
# `distance > 0` branch, crashing when should_round reads it here.
# get_increment_snap_value requires a real 3D viewport rv3d, which
# is unrelated to this bug, so it's stubbed out for a headless run.
monkeypatch.setattr(tool.Snap, "get_increment_snap_value", classmethod(lambda cls, context: 1.0))
polyline_props = tool.Model.get_polyline_props()
mouse_point = polyline_props.snap_mouse_point.add()
mouse_point.x, mouse_point.y, mouse_point.z = 0, 0, 0
tool_state = subject.create_tool_state()
tool_state.is_input_on = False
tool_state.use_default_container = False
tool_state.plane_method = "YZ"
input_ui = subject.create_input_ui(input_options=["D", "A", "X", "Y", "Z"])
subject.calculate_distance_and_angle(bpy.context, input_ui, tool_state, should_round=True)
assert input_ui.get_number_value("D") == 0
assert input_ui.get_number_value("A") == 0
+24
View File
@@ -288,3 +288,27 @@ class TestGenerateSpace(NewFile):
)
)
assert np.allclose(TEST_VERTS, sorted([tuple(v.co) for v in mesh.vertices]))
def test_regenerate_space_preserves_z_location(self):
bpy.ops.bim.create_project()
ifc = tool.Ifc.get()
scene = bpy.context.scene
product = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWall")
bpy.ops.mesh.primitive_cube_add(size=10, location=(0, 0, 4))
obj = bpy.data.objects["Cube"]
scene.collection.objects.link(obj)
tool.Ifc.link(product, obj)
scene.cursor.location = (0, 0, 0)
bpy.ops.bim.generate_space()
space = bpy.data.objects["IfcSpace/Space"]
space.location.z = 5
bpy.context.view_layer.update()
bpy.context.view_layer.objects.active = space
space.select_set(True)
obj.select_set(False)
bpy.ops.bim.generate_space()
assert np.isclose(space.location.z, 5), f"Expected z=5, got {space.location.z}"
+99 -38
View File
@@ -386,12 +386,71 @@ class Ifc5Dwriter:
"PredefinedType": cost_schedule.PredefinedType,
}
# Presentation formats (.ods / .xlsx) mirror exactly what the Bonsai cost
# panel shows for a cost item: ID (Identification), Name, Quantity,
# Value (RateSubtotal) and the calculated Total Cost. Everything else
# (internal bookkeeping columns, Description, Unit, per-category cost
# breakdowns) is bonsai/csv2ifc round-trip plumbing and stays out of the
# presentation formats. The .csv format keeps the full column set since
# csv2ifc reads those extra columns back in on import.
PRESENTATION_COLUMNS = ("Identification", "Name", "Quantity", "RateSubtotal", "TotalPrice")
# Header text as shown in presentation formats, matching the Bonsai cost
# panel's own column labels (see BIM_UL_cost_items_trait.draw_header).
PRESENTATION_LABELS = {
"Identification": "ID",
"RateSubtotal": "Value",
"TotalPrice": "Total Cost",
}
def multiply_cells(self, cell1, cell2):
return "={}*{}".format(cell1, cell2)
def sum_cells(self, list_of_cells):
return "=SUM({})".format(",".join(list_of_cells))
def get_visible_headers(self, schedule_id: int) -> list[str]:
"""Internal column keys shown in presentation formats, in panel order."""
headers = self.sheet_data[schedule_id]["headers"]
return [h for h in self.PRESENTATION_COLUMNS if h in headers]
def get_display_label(self, column: str) -> str:
"""Header text to write for a column in presentation formats."""
return self.PRESENTATION_LABELS.get(column, column)
def is_numeric_column(self, column: str) -> bool:
return column in ("Quantity", "RateSubtotal", "TotalPrice") or column.endswith(" Cost")
def get_total_price_formula(self, schedule_id: int, cost_item_index: int, first_data_row: int) -> Union[str, None]:
"""Spreadsheet formula for the TotalPrice cell of a cost item, or None for a plain value.
Sum items get ``=SUM(...)`` over the TotalPrice cells of their direct
children, leaf items with a quantity and a rate get ``=Quantity*RateSubtotal``.
Assumes one cost item per row, in ``cost_items`` order, starting at
``first_data_row`` (1-based).
"""
items = self.sheet_data[schedule_id]["cost_items"]
headers = self.get_visible_headers(schedule_id)
if "TotalPrice" not in headers:
return None
item = items[cost_item_index]
col = lambda name: self.column_indexes[headers.index(name)]
if item["ItemIsASum"]:
prefix = item["Hierarchy"] + "."
child_rows = [
first_data_row + i
for i, other in enumerate(items)
if other["Hierarchy"].startswith(prefix) and "." not in other["Hierarchy"][len(prefix) :]
]
if child_rows:
total_col = col("TotalPrice")
return self.sum_cells(["{}{}".format(total_col, r) for r in child_rows])
return None
if "Quantity" in headers and "RateSubtotal" in headers and item.get("Quantity") and item.get("RateSubtotal"):
row = first_data_row + cost_item_index
return self.multiply_cells("{}{}".format(col("Quantity"), row), "{}{}".format(col("RateSubtotal"), row))
return None
def get_cell_position(self, schedule_id, attribute):
def get_position_in_list(item, item_list):
try:
@@ -482,32 +541,25 @@ class Ifc5DOdsWriter(Ifc5Dwriter):
assert False, type
row.addElement(cell)
def add_cost_item_rows(table, cost_data):
first_data_row = 6 # 3 metadata rows, 1 blank row, 1 header row.
def add_cost_item_rows(table, cost_data, cost_item_index):
row = TableRow()
self.row_count += 1
style = self.colours.get(cost_data["Index"])
for i, column in enumerate(self.sheet_data[cost_schedule.id()]["headers"]):
if column == "Total Price" and cost_data["Quantity"] != 0 and cost_data["Rate Subtotal"]:
cell_quantity = self.get_cell_position(cost_schedule.id(), "Quantity")
cell_subtotal_rate = self.get_cell_position(cost_schedule.id(), "Rate Subtotal")
value = self.multiply_cells(cell_quantity, cell_subtotal_rate)
cell = TableCell(formula=value, stylename=self.colours.get(cost_data["Index"]))
for column in self.get_visible_headers(cost_schedule.id()):
value = cost_data.get(column, "")
formula = None
if column == "TotalPrice":
formula = self.get_total_price_formula(cost_schedule.id(), cost_item_index, first_data_row)
if formula:
cell = TableCell(formula=formula, stylename=style)
elif self.is_numeric_column(column) and isinstance(value, (int, float)):
cell = TableCell(valuetype="float", value=value, stylename=style)
else:
value = cost_data.get(column, "")
cell = TableCell(valuetype="string", stylename=self.colours.get(cost_data["Index"]))
cell = TableCell(valuetype="string", stylename=style)
cell.addElement(P(text=value))
# TODO:FIX QUANTITY AND COST TO SHOW AS NUMBERS AND CURRENCIES
# elif "Cost" in column or "Rate" in column:
# value = cost_data.get(column, "")
# cell = TableCell(valuetype="string", stylename=self.colours.get(cost_data["Index"]))
# cell.addElement(P(text=value))
# # cell.addElement(P(text=u"${}".format(value))) # The current displayed value
# print("Should add rate ", "${}".format(value))
# elif "Quantity" in column:
# value = cost_data.get(column, "")
# cell = TableCell(valuetype="float", stylename=self.colours.get(cost_data["Index"]))
# print("Should add quantity",value)
# cell.addElement(P(text=value))
row.addElement(cell)
table.addElement(row)
@@ -534,20 +586,22 @@ class Ifc5DOdsWriter(Ifc5Dwriter):
table.addElement(new)
header_row = TableRow()
for header in self.sheet_data[cost_schedule.id()]["headers"]:
add_cell(type="text", value=header, row=header_row, style="fed8b1")
for header in self.get_visible_headers(cost_schedule.id()):
add_cell(type="text", value=self.get_display_label(header), row=header_row, style="fed8b1")
table.addElement(header_row)
self.row_count = 5
for cost_item_data in self.sheet_data[cost_schedule.id()]["cost_items"]:
add_cost_item_rows(table, cost_item_data)
for i, cost_item_data in enumerate(self.sheet_data[cost_schedule.id()]["cost_items"]):
add_cost_item_rows(table, cost_item_data, i)
self.doc.spreadsheet.addElement(table)
class Ifc5DXlsxWriter(Ifc5Dwriter):
def write(self) -> None:
import xlsxwriter
# openpyxl rather than xlsxwriter: it is what ifccsv already uses and
# what ships with Bonsai, so XLSX export works out of the box there.
import openpyxl
super().write()
os.makedirs(self.output, exist_ok=True)
@@ -558,24 +612,31 @@ class Ifc5DXlsxWriter(Ifc5Dwriter):
else:
file_name += cost_schedule.Name or ""
self.file_path = os.path.join(self.output, "{}.xlsx".format(file_name))
self.workbook = xlsxwriter.Workbook(self.file_path)
self.workbook = openpyxl.Workbook()
self.workbook.remove(self.workbook.active)
for cost_schedule in self.cost_schedules:
self.write_table(cost_schedule)
self.workbook.close()
self.workbook.save(self.file_path)
def write_table(self, cost_schedule):
worksheet = self.workbook.add_worksheet(self.sheet_data[cost_schedule.id()]["Name"])
headers = self.sheet_data[cost_schedule.id()]["headers"]
for i, header in enumerate(headers):
worksheet.write(0, i, header)
import re
row = 1
for cost_item_data in self.sheet_data[cost_schedule.id()]["cost_items"]:
col = 0
sheet_id = cost_schedule.id()
title = re.sub(r"[\[\]:*?/\\]", "_", self.sheet_data[sheet_id]["Name"])[:31]
worksheet = self.workbook.create_sheet(title)
headers = self.get_visible_headers(sheet_id)
worksheet.append([self.get_display_label(h) for h in headers])
first_data_row = 2 # Row 1 is the header.
for i, cost_item_data in enumerate(self.sheet_data[sheet_id]["cost_items"]):
row = []
for header in headers:
worksheet.write(row, col, cost_item_data.get(header, ""))
col += 1
row += 1
formula = None
if header == "TotalPrice":
formula = self.get_total_price_formula(sheet_id, i, first_data_row)
# openpyxl treats strings starting with "=" as formulas.
row.append(formula if formula else cost_item_data.get(header, None))
worksheet.append(row)
class Ifc5DPdfWriter(Ifc5Dwriter):
+8 -4
View File
@@ -20,10 +20,14 @@ dependencies = [
"typing_extensions",
]
[project.optional-dependencies]
advanced = [
"typst",
]
[project.optional-dependencies]
advanced = [
"typst",
]
spreadsheet = [
"odfpy",
"openpyxl",
]
[project.urls]
Homepage = "http://ifcopenshell.org"
+28
View File
@@ -120,6 +120,34 @@ class TestCsv2Ifc:
assert len(list(Path(temp_csv_dir).glob("*.ods"))) == 1
assert len(list(Path(temp_csv_dir).glob("*.xlsx"))) == 1
def test_xlsx_columns_match_cost_panel(self):
"""ODS/XLSX are presentation formats: they must show exactly what the
Bonsai cost panel shows (ID, Name, Quantity, Value, Total Cost), no
internal bookkeeping columns, no Description/Unit, no per-category
cost breakdown. See #6251."""
import openpyxl
ifc_file = self.setup_ifc_file()
csv_filepath = Path(__file__).parent.parent / "sample_cost_schedule_house_FR.csv"
ifc5d.csv2ifc.Csv2Ifc(str(csv_filepath), ifc_file).execute()
with tempfile.TemporaryDirectory("w") as temp_dir:
writer = ifc5d.ifc5Dspreadsheet.Ifc5DXlsxWriter(ifc_file, temp_dir)
writer.write()
workbook = openpyxl.load_workbook(next(Path(temp_dir).glob("*.xlsx")))
worksheet = workbook.active
headers = [cell.value for cell in next(worksheet.iter_rows())]
assert headers == ["ID", "Name", "Quantity", "Value", "Total Cost"]
# A leaf item (has quantity and value) gets Quantity * Value.
leaf_row = next(row for row in worksheet.iter_rows(min_row=2) if row[0].value == "DB.1.1")
assert leaf_row[4].value == "=C{}*D{}".format(leaf_row[0].row, leaf_row[0].row)
# A parent/sum item gets the sum of its direct children's Total Cost.
parent_row = next(row for row in worksheet.iter_rows(min_row=2) if row[0].value == "DB.1")
assert parent_row[4].value.startswith("=SUM(")
class TestSerialiseCostQuantities:
def test_quantity_name_with_special_characters_round_trips_as_json(self):
+2 -2
View File
@@ -36,7 +36,7 @@ import ifcopenshell.util.representation
import ifcopenshell.util.selector
import numpy as np
from deepdiff import DeepDiff
from orderly_set import OrderedSet
from orderly_set import StableSet
__version__ = version = "0.0.0"
@@ -257,7 +257,7 @@ class IfcDiff:
def json_dump_default(self, obj):
# result of DeepDiff may contain ordered sets
if isinstance(obj, (OrderedSet, set)):
if isinstance(obj, (StableSet, set)):
return list(obj)
return json.JSONEncoder.default(None, obj)
+28
View File
@@ -16,9 +16,14 @@
# 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 os
import tempfile
import ifcopenshell
import ifcopenshell.api.context
import ifcopenshell.api.geometry
import ifcopenshell.api.pset
import ifcopenshell.api.root
import ifcopenshell.api.unit
import ifcopenshell.util.representation
@@ -94,6 +99,29 @@ class TestIfcDiff:
assert ifc_diff.deleted_elements == set()
assert ifc_diff.change_register == {wall.GlobalId: {"attributes_changed": True}}
def test_property_diff_exports_to_json(self):
# Regression test for #8905: comparing "property" relationships makes
# DeepDiff report a dictionary_item_added as a SetOrdered, which
# json.dump couldn't serialise, crashing export() with no results.
ifc_file = setup_project()
wall = ifcopenshell.api.root.create_entity(ifc_file, ifc_class="IfcWall", name="Foo")
new_file = ifc_file.from_string(ifc_file.to_string())
wall_new = new_file.by_id(wall.id())
pset = ifcopenshell.api.pset.add_pset(new_file, product=wall_new, name="Pset_WallCommon")
ifcopenshell.api.pset.edit_pset(new_file, pset=pset, properties={"FireRating": "2HR"})
ifc_diff = ifcdiff.IfcDiff(ifc_file, new_file, relationships=["property"])
ifc_diff.diff()
assert ifc_diff.change_register[wall.GlobalId]["properties_changed"]
with tempfile.TemporaryDirectory() as tmp_dir:
output = os.path.join(tmp_dir, "diff.json")
ifc_diff.export(output)
with open(output) as f:
results = json.load(f)
assert "Pset_WallCommon" in str(results["changed"][wall.GlobalId]["properties_changed"])
def test_changed_geometry(self):
ifc_file = setup_project()
wall = ifcopenshell.api.root.create_entity(ifc_file, ifc_class="IfcWall", name="Foo")
+8 -2
View File
@@ -94,9 +94,15 @@ def list_functions(module: str) -> list[dict]:
def function_docs(module: str, function: str) -> dict:
"""Full documentation for a single API function.
"""Show the full documentation for one ifcopenshell.api function.
Returns a dict with: module, function, description, params (with types/defaults/descriptions), return_type
Returns the summary and long description, every parameter with its type,
default and description, and the return type. Read this before calling
``run_api()`` so that parameter names and value types are correct.
:param module: API module name, for example ``'root'``.
:param function: Function name within the module, for example
``'create_entity'``.
"""
fn = _get_underlying_function(module, function)
if fn is None:
+14 -3
View File
@@ -14,10 +14,21 @@ def list_rules() -> list[dict[str, str]]:
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.
"""Compute base quantities for elements and write them into the model.
Modifies the model in-place by adding/updating IfcElementQuantity psets.
Returns a summary dict with ok, rule, and elements_quantified.
This is a write operation: it derives lengths, areas and volumes from
element geometry and adds or updates their ``IfcElementQuantity`` sets.
It does not report a schedule see ``ifcquery.schedule()`` for the
construction programme and ``ifcquery.cost()`` for cost schedules. An
unrecognised ``rule`` is reported as an error listing the rules that are
available.
:param model: The in-memory IFC model. Modified in-place.
:param rule: Quantity take-off rule set, for example
``'IFC4QtoBaseQuantities'`` or ``'IFC4X3QtoBaseQuantities'``.
:param selector: ifcopenshell selector restricting which elements are
measured, e.g. ``'IfcWall'``. Omit to measure every ``IfcElement`` and
``IfcSpace``.
"""
from ifc5d.qto import edit_qtos, quantify
from ifc5d.qto import rules as rule_sets
+8
View File
@@ -13,6 +13,14 @@ namespace ifcopenshell {
profile_point(const std::array<double, 2>& p, const boost::optional<double>& r = boost::none)
: xy(p), radius(r) {
}
// Recent Boost makes optional's converting constructor explicit,
// and an explicit constructor cannot be used in copy-initialization
// - which is what `{{x, y}, {radius}}` in the profile mappings is.
// Taking the double directly keeps every call site working.
profile_point(const std::array<double, 2>& p, double r)
: xy(p), radius(r) {
}
};
struct profile_point_with_edges {
+133 -45
View File
@@ -35,6 +35,27 @@ from ifcquery import (
from ifcquery import validate as validate_mod
def _use_doc(source: Callable, extra: str = "") -> Callable:
"""Decorator: copy `source`'s docstring onto the decorated method.
Keeps the query/edit logic in ``ifcquery``/``ifcedit`` as the single
source of truth for what a delegating ``IfcSession`` method does, rather
than maintaining a second prose description here. Only ``__doc__`` is
copied unlike `functools.wraps`, this leaves the method's own signature
(and MCP tool schema derived from it) untouched.
:param extra: Optional session-specific note appended after `source`'s
docstring, for the handful of methods that translate an argument
(e.g. a JSON/MCP-friendly default) before delegating.
"""
def decorator(fn: Callable) -> Callable:
fn.__doc__ = (source.__doc__ or "").rstrip() + extra
return fn
return decorator
def _jsonify(x: Any) -> Any:
"""Convert IfcOpenShell objects / iterables into JSON-safe primitives."""
if x is None or isinstance(x, (str, int, float, bool)):
@@ -231,20 +252,47 @@ class IfcSession:
return self.model
def ifc_new(self, schema: str = "IFC4") -> dict[str, Any]:
"""Create a new empty IFC model in memory."""
"""Create a new empty IFC model in memory.
Replaces the model currently held by the session, discarding any unsaved
edits. The new model has no file path of its own, so ``ifc_save`` must be
given an explicit path.
:param schema: IFC schema version ``IFC2X3``, ``IFC4``, ``IFC4X1``,
``IFC4X2`` or ``IFC4X3`` passed straight to ``ifcopenshell.file()``
(default ``IFC4``). ``IFC4X3_ADD2`` is also accepted and, like
``IFC4X3``, produces a model whose ``schema`` reports ``IFC4X3``.
"""
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."""
"""Open an IFC file from disk into the session.
Replaces the model currently held by the session, discarding any unsaved
edits, and remembers the path so a later ``ifc_save`` can overwrite it.
Call this before any query or edit method. Returns a confirmation string
naming the schema version and entity count.
:param path: Filesystem path of the IFC file to open.
"""
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."""
"""Write the in-memory model to disk.
Overwrites the target file without further confirmation. Edits made by
``ifc_edit``, ``ifc_shape`` and ``ifc_quantify`` exist only in memory
until this is called.
:param path: Destination path. Omit to overwrite the file the model was
loaded from; this fails for a model created by ``ifc_new``, which has
no original path.
"""
model = self._require_model()
target = path if path else self.model_path
if not target:
@@ -253,7 +301,11 @@ class IfcSession:
return f"Saved to {target}"
def ifc_reset(self) -> dict[str, Any]:
"""Drop the in-memory model."""
"""Discard the in-memory model.
Drops the model and its file path, throwing away any edits not already
written with ``ifc_save``. Succeeds even when no model is loaded.
"""
self.model = None
self.model_path = None
return {"ok": True}
@@ -261,39 +313,42 @@ class IfcSession:
# -------------
# Query tools
# -------------
@_use_doc(summary.summary)
def ifc_summary(self) -> dict[str, Any]:
"""Model overview: schema, entity counts, project info."""
return summary.summary(self._require_model())
@_use_doc(tree.tree)
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())
@_use_doc(info.info)
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)
@_use_doc(select.select)
def ifc_select(self, query: str) -> list[dict[str, Any]]:
"""Filter elements using ifcopenshell selector syntax.
Examples: ``IfcWall``, ``IfcWall, IfcColumn``, ``! IfcWall``,
``IfcWall, Name = "My Wall"``, ``type = "Concrete Wall"``,
``material = "Concrete"``.
"""
return select.select(self._require_model(), query)
@_use_doc(relations.relations)
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)
@_use_doc(
clash_mod.clash,
extra=(
"\n\nNote: this method takes a plain ``clearance: float`` rather than\n"
'``clearance: float | None`` — ``0.0`` (the default) means "skip the\n'
'clearance check", matching ``None`` in ``ifcquery.clash.clash()``.'
),
)
def ifc_clash(
self,
element_id: int,
@@ -301,7 +356,6 @@ class IfcSession:
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:
@@ -314,33 +368,53 @@ class IfcSession:
scope=scope,
)
@_use_doc(contexts_mod.contexts)
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())
@_use_doc(materials_mod.materials)
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."""
"""Discover the ifcopenshell.api functions available for editing.
With no argument returns every API module with its description,
function names and function count. With a module name returns that
module's functions, each with a one-line description and its
parameters. This is the starting point for ``ifc_docs`` and
``ifc_edit``; it inspects the installed ifcopenshell package and works
without a model loaded.
:param module: API module name, for example ``'root'``, ``'geometry'``
or ``'pset'``. Omit to list all modules.
"""
return list_functions(module) if module else list_modules()
@_use_doc(function_docs)
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.
"""Run an ifcopenshell.api function to modify the model.
params may be:
- JSON string
- dict (from tool calling / JS)
- JsProxy (handled upstream in embedded.py)
This is the general-purpose edit method; use ``ifc_list`` and
``ifc_docs`` first to find the function and its parameters. Changes
are made to the in-memory model only, so ``ifc_save`` is needed to
persist them. Returns ``{"ok": True, "result": ...}``, or
``{"ok": False, "error": ...}`` when the function is unknown, a
parameter cannot be converted, or the call raises.
:param function_path: ``'module.function'``, for example
``'root.create_entity'``.
:param params: Keyword arguments as a JSON string, a dict (tool
calling) or a JsProxy (handled upstream in embedded.py). Pass
entity references as integer step IDs, and arguments typed as an
IFC file as a file path string.
"""
model = self._require_model()
module, function = function_path.split(".", 1)
@@ -359,28 +433,20 @@ class IfcSession:
# ------------------------
# Extended query + edit tools
# ------------------------
@_use_doc(validate_mod.validate)
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)
@_use_doc(schedule.schedule)
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)
@_use_doc(cost_mod.cost)
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)
@_use_doc(schema.schema)
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(
@@ -456,18 +522,43 @@ class IfcSession:
# Shape builder tools
# ------------------------
def ifc_shape_list(self) -> list[dict]:
"""List all ShapeBuilder geometry methods with one-line descriptions and parameter names."""
"""List the ShapeBuilder methods available for constructing geometry.
Returns every public ``ifcopenshell.util.shape_builder.ShapeBuilder``
method with a one-line description and its parameter names, read
directly from that class's own docstrings. Use it to find a method,
then ``ifc_shape_docs`` for the details and ``ifc_shape`` to call it.
Works without a model loaded.
"""
return _list_shape_methods()
def ifc_shape_docs(self, method: str) -> dict:
"""Full documentation for a ShapeBuilder method: params, types, return value."""
"""Show the full documentation for one ShapeBuilder method.
Returns the summary and long description, every parameter with its
type and default, and the return type read directly from
``ShapeBuilder``'s own docstring. Read this before ``ifc_shape`` so
that argument names and value shapes are correct. Works without a
model loaded.
:param method: ShapeBuilder method name, for example ``'polyline'``,
``'rectangle'`` or ``'extrude'``.
"""
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.
"""Call a ShapeBuilder method to build geometry in the model.
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]).
The created entities are added to the in-memory model, so
``ifc_save`` is needed to persist them. On success the result
identifies the created entity by step ID and type; an unknown method
or a failed call is reported as an error instead.
:param method: ShapeBuilder method name, as listed by
``ifc_shape_list``.
:param params: JSON string of keyword arguments. Pass entity
references as integer step IDs and vectors as JSON arrays, e.g.
``[1.0, 0.0, 0.0]``.
"""
model = self._require_model()
@@ -493,11 +584,8 @@ class IfcSession:
except Exception as e:
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
@_use_doc(run_quantify, extra="\n\nCall ``ifc_save`` afterwards to persist the result.")
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)
+30 -24
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import base64
import inspect
from typing import Any
from ifcmcp.core import IfcSession
@@ -9,7 +10,7 @@ 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
except ImportError: # pragma: no cover
FastMCP = None # type: ignore
ImageContent = None # type: ignore
@@ -23,6 +24,11 @@ def build_server() -> Any:
session = IfcSession()
def _tool(fn):
"""Register a tool, taking its MCP description from the identically-named
IfcSession method rather than duplicating it here."""
return server.tool(description=inspect.getdoc(getattr(IfcSession, fn.__name__)))(fn)
server = FastMCP(
name="ifc-mcp",
instructions=(
@@ -33,44 +39,44 @@ def build_server() -> Any:
)
# ---- Lifecycle ----
@server.tool()
@_tool
def ifc_new(schema: str = "IFC4") -> dict[str, Any]:
return session.ifc_new(schema=schema)
@server.tool()
@_tool
def ifc_load(path: str) -> str:
return session.ifc_load(path)
@server.tool()
@_tool
def ifc_save(path: str = "") -> str:
return session.ifc_save(path)
@server.tool()
@_tool
def ifc_reset() -> dict[str, Any]:
return session.ifc_reset()
# ---- Query ----
@server.tool()
@_tool
def ifc_summary() -> dict[str, Any]:
return session.ifc_summary()
@server.tool()
@_tool
def ifc_tree() -> dict[str, Any] | list[dict[str, Any]]:
return session.ifc_tree()
@server.tool()
@_tool
def ifc_info(element_id: int) -> dict[str, Any]:
return session.ifc_info(element_id)
@server.tool()
@_tool
def ifc_select(query: str) -> list[dict[str, Any]]:
return session.ifc_select(query)
@server.tool()
@_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()
@_tool
def ifc_clash(
element_id: int,
clearance: float = 0.0,
@@ -84,58 +90,58 @@ def build_server() -> Any:
scope=scope,
)
@server.tool()
@_tool
def ifc_contexts() -> list[dict[str, Any]]:
return session.ifc_contexts()
@server.tool()
@_tool
def ifc_materials() -> list[dict[str, Any]]:
return session.ifc_materials()
# ---- Edit ----
@server.tool()
@_tool
def ifc_list(module: str = "") -> list[dict]:
return session.ifc_list(module=module)
@server.tool()
@_tool
def ifc_docs(function_path: str) -> dict:
return session.ifc_docs(function_path=function_path)
@server.tool()
@_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()
@_tool
def ifc_validate(express_rules: bool = False) -> dict[str, Any]:
return session.ifc_validate(express_rules=express_rules)
@server.tool()
@_tool
def ifc_schedule(max_depth: int | None = None) -> list[dict[str, Any]]:
return session.ifc_schedule(max_depth=max_depth)
@server.tool()
@_tool
def ifc_cost(max_depth: int | None = None) -> list[dict[str, Any]]:
return session.ifc_cost(max_depth=max_depth)
@server.tool()
@_tool
def ifc_schema(entity_type: str) -> dict[str, Any]:
return session.ifc_schema(entity_type=entity_type)
@server.tool()
@_tool
def ifc_quantify(rule: str, selector: str = "") -> dict[str, Any]:
return session.ifc_quantify(rule=rule, selector=selector)
# ---- Shape builder ----
@server.tool()
@_tool
def ifc_shape_list() -> list[dict]:
return session.ifc_shape_list()
@server.tool()
@_tool
def ifc_shape_docs(method: str) -> dict:
return session.ifc_shape_docs(method=method)
@server.tool()
@_tool
def ifc_shape(method: str, params: str = "{}") -> dict:
return session.ifc_shape(method=method, params=params)
+3 -1
View File
@@ -18,7 +18,9 @@ classifiers = [
dependencies = ["ifcopenshell", "ifcquery", "ifcedit"]
[project.optional-dependencies]
mcp = ["mcp"]
# Pinned <2: mcp 2.0.0 renamed mcp.server.fastmcp.FastMCP to
# mcp.server.mcpserver.MCPServer, which this package doesn't support yet.
mcp = ["mcp>=1.0,<2"]
[project.scripts]
ifcmcp = "ifcmcp.__main__:main"
+6
View File
@@ -30,6 +30,12 @@ class TestServerRegistration:
for name in expected:
assert name in tools, f"Tool {name} not registered"
def test_all_tools_have_descriptions(self):
server = build_server()
tools = server._tool_manager.list_tools()
missing = [t.name for t in tools if not (t.description or "").strip()]
assert not missing, f"Tools with no description: {missing}"
@pytest.fixture
def tool_fns():
@@ -60,7 +60,7 @@ operating systems. GCC (4.7 or newer) or Clang (any version) is required.
.. code-block:: bash
sudo apt-get install git cmake gcc g++ libboost-all-dev libcgal-dev
sudo apt-get install git cmake gcc g++ libboost-all-dev libcgal-dev libeigen3-dev
The CGAL version that ships with Ubuntu 20.04 is too old. Users on Ubuntu 20.04 are advised to manually install CGAL 5.3.
@@ -79,7 +79,7 @@ from .get_layout_curve import get_layout_curve
from .get_layout_segments import get_layout_segments
from .get_mapped_segments import get_mapped_segments
from .get_parent_alignment import get_parent_alignment
from .get_referent_nest import get_referent_nest
from .get_stationing_nest import get_stationing_nest
from .get_vertical_layout import get_vertical_layout
from .has_zero_length_segment import has_zero_length_segment
from .layout_horizontal_alignment_by_pi_method import (
@@ -91,6 +91,7 @@ from .layout_vertical_alignment_by_pi_method import (
from .name_segments import name_segments
from .update_end_point import update_end_point
from .update_fallback_position import update_fallback_position
from .update_key_point_referents import update_key_point_referents
from .util import *
__all__ = [
@@ -124,7 +125,7 @@ __all__ = [
"get_layout_curve",
"get_layout_segments",
"get_parent_alignment",
"get_referent_nest",
"get_stationing_nest",
"get_vertical_layout",
"has_zero_length_segment",
"layout_horizontal_alignment_by_pi_method",
@@ -133,5 +134,6 @@ __all__ = [
"register_referent_name_callback",
"update_end_point",
"update_fallback_position",
"update_key_point_referents",
"get_mapped_segments",
]
@@ -26,9 +26,10 @@ _cant_callback = None
def register_referent_name_callback(horizontal=None, vertical=None, cant=None):
"""
Referents are automatically created at the start of each horizontal, vertical, and cant segment.
The referents represent key points in the alignment layout such as Point of Curvature, Point of Tangent, and others.
Different juristicions use different naming systems for these key points.
Referents are created at the start of each horizontal, vertical, and cant segment by
ifcopenshell.api.alignment.update_key_point_referents. The referents represent key points in the
alignment layout such as Point of Curvature, Point of Tangent, and others. Different
juristicions use different naming systems for these key points.
The referent name callback functions provide a customizable method for naming these referents. If a callback is registered,
it is called when creating the referent name, otherwise the default naming is used.
@@ -39,8 +40,8 @@ def register_referent_name_callback(horizontal=None, vertical=None, cant=None):
The callback function returns a string that is used in the referent name for the referent at the start of `segment`.
The callback must accomodate the following cases:
* prev_segment = None and segment != None - this indicates the last segment so the "End of Alignment" name is returned
* prev_segment != None and segment == None - this indicates the first segment so the "Beginning of Alignment" name is returned
* prev_segment = None and segment != None - this indicates the first segment so the "Beginning of Alignment" name is returned
* prev_segment != None and segment == None - this indicates the last segment so the "End of Alignment" name is returned
* prev_segment != None and segment != None - this indicates an intermediate segment so a name representitive of the transition is returned
Setting any or all of the callbacks to None causes the default naming to be used.
@@ -0,0 +1,27 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.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 typing import Callable
from ifcopenshell import entity_instance
def _sort_nest(nest: entity_instance, key: Callable) -> entity_instance:
"""Sorts the RelatedObjects of an IfcRelNests in place, by an arbitrary key function."""
nest.RelatedObjects = sorted(nest.RelatedObjects, key=key)
return nest
@@ -20,6 +20,7 @@ from typing import Optional
import ifcopenshell
import ifcopenshell.api.alignment
from ifcopenshell.api.alignment._sort_nest import _sort_nest
from ifcopenshell.api.alignment.update_fallback_position import update_fallback_position
import ifcopenshell.api.pset
import ifcopenshell.guid
@@ -114,7 +115,7 @@ def add_stationing_referent(
pset_stationing = ifcopenshell.api.pset.add_pset(file, product=referent, name="Pset_Stationing")
ifcopenshell.api.pset.edit_pset(file, pset=pset_stationing, properties=properties)
nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment)
if nest is None:
nest = file.createIfcRelNests(
GlobalId=ifcopenshell.guid.new(), RelatingObject=alignment, RelatedObjects=(referent,)
@@ -122,8 +123,6 @@ def add_stationing_referent(
else:
nest.RelatedObjects += (referent,)
nest.RelatedObjects = sorted(
nest.RelatedObjects, key=lambda x: ifcopenshell.util.element.get_pset(x, name="Pset_Stationing", prop="Station")
)
_sort_nest(nest, key=lambda x: ifcopenshell.util.element.get_pset(x, name="Pset_Stationing", prop="Station"))
return referent
@@ -64,22 +64,22 @@ def create_representation(
# if the alignment is created without geometry it's stationing referent isn't related to the alignment geometry.
# the stationing referent needs to be updated to have an IfcLinearPlacement that references the basis curve geometry
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment)
if (
referent_nest
and 0 < len(referent_nest.RelatedObjects)
and referent_nest.RelatedObjects[0].ObjectPlacement
and not referent_nest.RelatedObjects[0].ObjectPlacement.is_a("IfcLinearPlacement")
stationing_nest
and 0 < len(stationing_nest.RelatedObjects)
and stationing_nest.RelatedObjects[0].ObjectPlacement
and not stationing_nest.RelatedObjects[0].ObjectPlacement.is_a("IfcLinearPlacement")
):
basis_curve = ifcopenshell.api.alignment.get_basis_curve(alignment)
if referent_nest.RelatedObjects[0].ObjectPlacement:
if referent_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement.Location:
file.remove(referent_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement.Location)
if referent_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement.RefDirection:
file.remove(referent_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement.RefDirection)
file.remove(referent_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement)
file.remove(referent_nest.RelatedObjects[0].ObjectPlacement)
if stationing_nest.RelatedObjects[0].ObjectPlacement:
if stationing_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement.Location:
file.remove(stationing_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement.Location)
if stationing_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement.RefDirection:
file.remove(stationing_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement.RefDirection)
file.remove(stationing_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement)
file.remove(stationing_nest.RelatedObjects[0].ObjectPlacement)
lp = file.createIfcLinearPlacement(
RelativePlacement=file.createIfcAxis2PlacementLinear(
@@ -93,4 +93,4 @@ def create_representation(
)
)
update_fallback_position(file, lp)
referent_nest.RelatedObjects[0].ObjectPlacement = lp
stationing_nest.RelatedObjects[0].ObjectPlacement = lp
@@ -67,8 +67,8 @@ def distance_along_from_station(file: ifcopenshell.file, alignment: entity_insta
print(dist_along) # 100.00
"""
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
if referent_nest is None:
stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment)
if stationing_nest is None:
start_station = ifcopenshell.api.alignment.get_alignment_start_station(file, alignment)
return station - start_station
@@ -77,7 +77,7 @@ def distance_along_from_station(file: ifcopenshell.file, alignment: entity_insta
_distance_along_of_referent(referent),
ifcopenshell.util.element.get_pset(referent, name="Pset_Stationing", prop="Station"),
)
for referent in referent_nest.RelatedObjects
for referent in stationing_nest.RelatedObjects
]
stations.sort(key=lambda entry: entry[0])
@@ -20,12 +20,18 @@ import ifcopenshell
from ifcopenshell import entity_instance
def get_referent_nest(file: ifcopenshell.file, alignment: entity_instance) -> entity_instance:
def get_stationing_nest(file: ifcopenshell.file, alignment: entity_instance) -> entity_instance:
"""
Searches for the IfcRelNest that contains IfcReferent.
Searches for the IfcRelNests that defines the alignment's stationing scheme.
The returned nest is nested to the IfcAlignment and its RelatedObjects contains only the
IfcReferent(s) (PredefinedType="STATION") that establish the alignment's starting station and
any station equations along it, as created by add_stationing_referent. It does not contain any
other kind of referent (e.g. key-point referents from update_key_point_referents live in their
own, separate IfcRelNests).
:param file:
:param alignment: The IfcAlignment which hosts IfcReferent
:param alignment: The IfcAlignment which hosts the stationing IfcReferent(s)
:return: Returns the IfcRelNests or None
"""
if not alignment.is_a("IfcAlignment"):
@@ -0,0 +1,216 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.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 typing import Optional
import ifcopenshell
import ifcopenshell.api.alignment
import ifcopenshell.api.pset
import ifcopenshell.guid
import ifcopenshell.util.alignment
import ifcopenshell.util.element
from ifcopenshell import entity_instance
from ifcopenshell.api.alignment._get_segment_start_point_label import (
_get_segment_start_point_label,
)
from ifcopenshell.api.alignment._sort_nest import _sort_nest
from ifcopenshell.api.alignment.update_fallback_position import update_fallback_position
def _remove_referent(file: ifcopenshell.file, referent: entity_instance) -> None:
"""Cleanly deletes a key-point IfcReferent: its Pset_Stationing, its ObjectPlacement (if
exclusively owned by it), and finally the referent itself."""
for inverse in list(file.get_inverse(referent)):
if inverse.is_a("IfcRelDefinesByProperties"):
ifcopenshell.api.pset.remove_pset(file, product=referent, pset=inverse.RelatingPropertyDefinition)
object_placement = referent.ObjectPlacement
if object_placement and file.get_total_inverses(object_placement) == 1:
referent.ObjectPlacement = None
ifcopenshell.util.element.remove_deep2(file, object_placement)
file.remove(referent) # also strips referent out of any IfcRelNests.RelatedObjects referencing it
def _create_key_point_referent(
file: ifcopenshell.file,
alignment: entity_instance,
curve: Optional[entity_instance],
label: str,
distance_along: float,
station: float,
) -> entity_instance:
if curve and curve.is_a("IfcCompositeCurve") and 0 < len(curve.Segments):
object_placement = file.createIfcLinearPlacement(
RelativePlacement=file.createIfcAxis2PlacementLinear(
Location=file.createIfcPointByDistanceExpression(
DistanceAlong=file.createIfcLengthMeasure(distance_along),
OffsetLateral=None,
OffsetVertical=None,
OffsetLongitudinal=None,
BasisCurve=curve,
)
),
)
update_fallback_position(file, object_placement)
else:
object_placement = file.createIfcLocalPlacement(
PlacementRelTo=None,
RelativePlacement=file.createIfcAxis2Placement2D(
Location=file.createIfcCartesianPoint(alignment.ObjectPlacement.RelativePlacement.Location.Coordinates)
),
)
name = f"{label} ({ifcopenshell.util.alignment.station_as_string(file, station)})"
referent = file.createIfcReferent(
GlobalId=ifcopenshell.guid.new(),
OwnerHistory=None,
Name=name,
Description=None,
ObjectType=None,
ObjectPlacement=object_placement,
Representation=None,
PredefinedType="POSITION",
)
pset_stationing = ifcopenshell.api.pset.add_pset(file, product=referent, name="Pset_Stationing")
ifcopenshell.api.pset.edit_pset(file, pset=pset_stationing, properties={"Station": station})
return referent
def update_key_point_referents(
file: ifcopenshell.file,
layout: entity_instance,
rel_nests: Optional[entity_instance] = None,
clear: bool = False,
) -> entity_instance:
"""
Creates IfcReferent key-point markers for every segment transition in an alignment layout.
Labels are derived from _get_segment_start_point_label (e.g. "P.C.", "P.T.", "P.O.B.",
"P.V.C.", ...), with the station appended, e.g. "P.C. (145+98.32)". Different jurisdictions use
different naming systems for these key points -- register_referent_name_callback() lets a
caller override the default horizontal/vertical/cant labeling before calling this function; if
a callback is registered, its output is used here instead of the built-in labels. Referents are
nested to `rel_nests`, an IfcRelNests distinct from the layout's segment nest (found via
get_alignment_segment_nest) and from the alignment's stationing nest (found via
get_stationing_nest) -- key-point referents never belong in either of those.
:param layout: IfcAlignmentHorizontal, IfcAlignmentVertical, or IfcAlignmentCant
:param rel_nests: an existing IfcRelNests to (re)populate; its RelatingObject must be the
IfcAlignment that nests `layout` (TypeError is raised otherwise). If omitted, a new
IfcRelNests is always created and related to that IfcAlignment -- there is no implicit
search for or reuse of a previously created nest. Callers who want to regenerate into an
existing nest must pass it back in explicitly via `rel_nests`.
:param clear: if True, deletes all IfcReferent currently in rel_nests.RelatedObjects (and their
Pset_Stationing) before regenerating. If False (default), new referents are appended to
whatever already exists -- no deduplication.
:return: the IfcRelNests, with RelatedObjects sorted ascending by Pset_Stationing.Station
Example:
.. code:: python
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
nest = ifcopenshell.api.alignment.update_key_point_referents(model, horizontal)
Example, with custom labels for a jurisdiction that doesn't use the built-in abbreviations:
.. code:: python
def my_horizontal_labels(prev_segment, segment):
if prev_segment is None:
return "Start"
if segment is None:
return "End"
return "Curve Point" # a name representative of the prev_segment -> segment transition
ifcopenshell.api.alignment.register_referent_name_callback(horizontal=my_horizontal_labels)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
nest = ifcopenshell.api.alignment.update_key_point_referents(model, horizontal)
# nest.RelatedObjects[0].Name starts with "Start (" instead of the default "P.O.B. ("
"""
expected_types = ["IfcAlignmentHorizontal", "IfcAlignmentVertical", "IfcAlignmentCant"]
if not layout.is_a() in expected_types:
raise TypeError(
f"Expected entity type to be one of {[_ for _ in expected_types]}, instead received {layout.is_a()}"
)
alignment = ifcopenshell.api.alignment.get_alignment(layout)
if alignment is None:
raise ValueError(f"{layout.is_a()} #{layout.id()} is not nested under an IfcAlignment.")
if rel_nests is not None:
if not rel_nests.RelatingObject.is_a("IfcAlignment"):
raise TypeError(
f"Expected rel_nests.RelatingObject to be IfcAlignment, instead received "
f"{rel_nests.RelatingObject.is_a()}"
)
else:
rel_nests = file.createIfcRelNests(
GlobalId=ifcopenshell.guid.new(), RelatingObject=alignment, RelatedObjects=()
)
if clear:
for referent in list(rel_nests.RelatedObjects):
_remove_referent(file, referent)
rel_nests.RelatedObjects = ()
segments = list(ifcopenshell.api.alignment.get_layout_segments(layout))
if segments and ifcopenshell.api.alignment.has_zero_length_segment(layout):
segments = segments[:-1]
if not segments:
_sort_nest(
rel_nests, key=lambda x: ifcopenshell.util.element.get_pset(x, name="Pset_Stationing", prop="Station")
)
return rel_nests
start_station = ifcopenshell.api.alignment.get_alignment_start_station(file, alignment)
curve = ifcopenshell.api.alignment.get_layout_curve(layout)
is_horizontal = layout.is_a("IfcAlignmentHorizontal")
new_referents = []
distance_along = 0.0
prev_segment = None
for segment in segments:
dp = segment.DesignParameters
seg_distance_along = distance_along if is_horizontal else dp.StartDistAlong
label = _get_segment_start_point_label(prev_segment, segment)
station = start_station + seg_distance_along
new_referents.append(_create_key_point_referent(file, alignment, curve, label, seg_distance_along, station))
if is_horizontal:
distance_along += dp.SegmentLength
else:
distance_along = dp.StartDistAlong + dp.HorizontalLength
prev_segment = segment
label = _get_segment_start_point_label(prev_segment, None)
station = start_station + distance_along
new_referents.append(_create_key_point_referent(file, alignment, curve, label, distance_along, station))
rel_nests.RelatedObjects = tuple(rel_nests.RelatedObjects) + tuple(new_referents)
_sort_nest(rel_nests, key=lambda x: ifcopenshell.util.element.get_pset(x, name="Pset_Stationing", prop="Station"))
return rel_nests
@@ -73,13 +73,6 @@ FILTERED_CARTESIAN_QUOTIENT: Any
EXACT_PREDICATES: Any
EXACT_CONSTRUCTIONS: Any
FT_AUTODETECT: Any
FT_IFCSPF: Any
FT_IFCXML: Any
FT_IFCZIP: Any
FT_ROCKSDB: Any
FT_UNKNOWN: Any
CURVES: Any
SURFACES_AND_SOLIDS: Any
CURVES_SURFACES_AND_SOLIDS: Any
@@ -133,7 +126,6 @@ class BRepElement(Element):
def calculate_projected_surface_area(self, along_x, along_y, along_z): ...
@property
def geometry(self) -> BRep: ...
def geometry_pointer(self): ...
@property
def surface_area(self): ...
@property
@@ -625,7 +617,6 @@ class TriangulationElement(Element):
def __init__(self, *args): ...
@property
def geometry(self) -> Triangulation: ...
def geometry_pointer(self): ...
class TtlWktSerializer(WriteOnlyGeometrySerializer):
def __init__(self, filename, geometry_settings, settings, logger=None): ...
@@ -1799,21 +1790,20 @@ def flatten(deep): ...
def get_feature(x): ...
def get_info_cpp(v, include_identifier=True): ...
def get_log(): ...
def guess_file_type(fn): ...
def helmert_curve_point(A0, A1, A2, s): ...
def kind_to_string(k): ...
def less(arg1, arg2): ...
def line_segments_to_polygons(s, eps, segments): ...
def map_shape(settings, instance): ...
def logger_or_root(logger) -> logger: ...
def logger_or_root(logger: logger | None) -> logger: ...
def nary_union(sequence): ...
def new_IfcBaseClass(schema_identifier: str, name: str) -> entity_instance: ...
def open(fn: str, readonly: bool = False, logger=None) -> file: ...
def parse_ifcxml(filename, logger=None): ...
def polygons_to_svg(*args): ...
def read(data): ...
def register_schema(arg1): ...
def schema_by_name(arg1: str) -> schema_definition: ...
def register_schema(schema: schema_definition) -> None: ...
def schema_by_name(schema_name: str) -> schema_definition: ...
def schema_names() -> tuple[str, ...]: ...
def serialise(schema_name, shape_str, advanced=True): ...
def set_feature(x, v): ...
@@ -1,4 +1,4 @@
lxml==4.9.1
lxml==6.1.0
numpy==1.22.0
regex==2021.4.4
xmlschema==1.6.4
@@ -64,7 +64,10 @@ DEFAULTS = {
"project_globalid": lambda d: compress(uuid.uuid4().hex),
"schema_identifier": lambda d: "IFC4",
"timestamp": lambda d: int(time.time()),
"timestring": lambda d: time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(d.get("timestamp") or time.time())),
"timestring": lambda d: time.strftime(
"%Y-%m-%dT%H:%M:%S",
time.gmtime(d["timestamp"] if d.get("timestamp") is not None else time.time()),
),
"mvd": lambda d: (
"ReferenceView_V1.2"
if d.get("schema_identifier") == "IFC4"
@@ -540,8 +540,15 @@ def _get_element_value(element: ifcopenshell.entity_instance, keys: list[str]) -
value = results or None
if value and len(value) == 1:
value = value[0]
elif key in value:
value = value[key]
else:
value = value.get(key, None)
# A nested complex quantity/property (IfcPhysicalComplexQuantity /
# IfcComplexProperty) is represented as a dict whose nested members
# live under a "properties" sub-dict. Descend into it so that nested
# values are reachable with the natural "Qto.Complex.Nested" path.
subprops = value.get("properties")
value = subprops.get(key, None) if isinstance(subprops, dict) else None
elif isinstance(value, (list, tuple, set)): # If we use regex
if isinstance(key, str) and key.isnumeric():
try:
@@ -39,9 +39,9 @@ def test_add_segment_to_layout():
alignment = ifcopenshell.api.alignment.create(file, "")
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment)
assert (
len(referent_nest.RelatedObjects) == 1
len(stationing_nest.RelatedObjects) == 1
) # the alignment creates the stationing nest and it has one referent to defined the stationing for the alignment
horizontal_alignment = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
@@ -75,8 +75,8 @@ def test_add_segment_to_layout():
assert len(horizontal_alignment.IsNestedBy) == 1
segment_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(horizontal_alignment)
assert len(segment_nest.RelatedObjects) == 2
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
assert len(referent_nest.RelatedObjects) == 1 # test this a second time to make sure that it is still true
stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment)
assert len(stationing_nest.RelatedObjects) == 1 # test this a second time to make sure that it is still true
test_add_segment_to_layout()
@@ -39,8 +39,8 @@ def test_add_stationing_to_alignment():
alignment = ifcopenshell.api.alignment.create(file, "TestAlignment", start_station=2000.0)
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
referent = referent_nest.RelatedObjects[0]
stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment)
referent = stationing_nest.RelatedObjects[0]
assert referent.PredefinedType == "STATION"
assert referent.Name == "2+000.000"
@@ -54,10 +54,10 @@ def test_add_stationing_to_alignment():
file, "4+000.000", alignment, distance_along=1000.0, station=4000.0, incoming_station=3000.0
)
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
assert len(referent_nest.RelatedObjects) == 2
stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment)
assert len(stationing_nest.RelatedObjects) == 2
assert second_referent == referent_nest.RelatedObjects[1]
assert second_referent == stationing_nest.RelatedObjects[1]
assert second_referent.PredefinedType == "STATION"
assert second_referent.Name == "4+000.000"
@@ -36,11 +36,11 @@ def test_add_vertical_alignment():
layout_nest = ifcopenshell.api.alignment.get_alignment_layout_nest(alignment)
assert len(layout_nest.RelatedObjects) == 1
assert layout_nest.RelatedObjects[0].is_a("IfcAlignmentHorizontal")
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment)
assert (
len(referent_nest.RelatedObjects) == 1
len(stationing_nest.RelatedObjects) == 1
) # the alignment creates the stationing nest and it has one referent to defined the stationing for the alignment
assert referent_nest.RelatedObjects[0].is_a("IfcReferent")
assert stationing_nest.RelatedObjects[0].is_a("IfcReferent")
curve = ifcopenshell.api.alignment.get_curve(alignment)
assert curve.is_a("IfcCompositeCurve")
@@ -51,8 +51,8 @@ def test_create_by_pi_method():
layout_nest = ifcopenshell.api.alignment.get_alignment_layout_nest(alignment)
assert len(layout_nest.RelatedObjects) == 2
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
assert len(referent_nest.RelatedObjects) == 1
stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment)
assert len(stationing_nest.RelatedObjects) == 1
horizontal_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
horizontal_segment_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(horizontal_layout)
@@ -46,9 +46,9 @@ def test_horizontal_layout_by_pi_method():
assert len(alignment.IsDecomposedBy) == 0 # no child alignments
assert len(alignment.IsNestedBy) == 2
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment)
layout_nest = ifcopenshell.api.alignment.get_alignment_layout_nest(alignment)
assert referent_nest.RelatedObjects[0].is_a("IfcReferent")
assert stationing_nest.RelatedObjects[0].is_a("IfcReferent")
assert layout_nest.RelatedObjects[0].is_a("IfcAlignmentHorizontal")
segment_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(layout_nest.RelatedObjects[0])
assert len(segment_nest.RelatedObjects) == 3 # segments in horizontal layout
@@ -97,16 +97,32 @@ def callback_alignment():
def test_with_default_names(default_names_alignment):
referent_nest = ifcopenshell.api.alignment.get_referent_nest(None, default_names_alignment)
file = default_names_alignment.file
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(default_names_alignment)
vertical = ifcopenshell.api.alignment.get_vertical_layout(default_names_alignment)
expected = ["P.O.B", "P.C.", "P.T.", "P.O.E.", "V.P.O.B.", "P.V.C.", "P.V.T.", "V.P.O.E"]
for r in referent_nest.RelatedObjects:
assert [x in r.Name for x in expected]
h_nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
v_nest = ifcopenshell.api.alignment.update_key_point_referents(file, vertical)
expected_h = ["P.O.B.", "P.C.", "P.T.", "P.C.", "P.T.", "P.C.", "P.T.", "P.O.E."]
expected_v = ["V.P.O.B.", "P.V.C.", "P.V.T.", "P.V.C.", "P.V.T.", "P.V.C.", "P.V.T.", "P.V.C.", "P.V.T.", "V.P.O.E."]
assert [r.Name.split(" (")[0] for r in h_nest.RelatedObjects] == expected_h
assert [r.Name.split(" (")[0] for r in v_nest.RelatedObjects] == expected_v
def test_with_callbacks(callback_alignment):
referent_nest = ifcopenshell.api.alignment.get_referent_nest(None, callback_alignment)
file = callback_alignment.file
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(callback_alignment)
vertical = ifcopenshell.api.alignment.get_vertical_layout(callback_alignment)
expected = ["A", "Q", "Z", "a", "q", "z"]
for r in referent_nest.RelatedObjects:
assert [x in r.Name for x in expected]
h_nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
v_nest = ifcopenshell.api.alignment.update_key_point_referents(file, vertical)
expected_h = ["A", "Q", "Q", "Q", "Q", "Q", "Q", "Z"]
expected_v = ["a", "q", "q", "q", "q", "q", "q", "q", "q", "z"]
assert [r.Name.split(" (")[0] for r in h_nest.RelatedObjects] == expected_h
assert [r.Name.split(" (")[0] for r in v_nest.RelatedObjects] == expected_v
ifcopenshell.api.alignment.register_referent_name_callback(None, None, None) # reset global state
@@ -0,0 +1,398 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.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 collections import Counter
import pytest
import ifcopenshell.api.alignment
import ifcopenshell.api.context
import ifcopenshell.api.unit
import ifcopenshell.util.alignment
import ifcopenshell.util.element
COORDINATES = [(500.0, 2500.0), (3340.0, 660.0), (4340.0, 5000.0), (7600.0, 4560.0), (8480.0, 2010.0)]
RADII = [1000.0, 1250.0, 950.0]
VPOINTS = [(0.0, 100.0), (2000.0, 135.0), (5000.0, 105.0), (7400.0, 153.0), (9800.0, 105.0), (12800.0, 90.0)]
LENGTHS = [1600.0, 1200.0, 2000.0, 800.0]
def _new_file():
file = ifcopenshell.file(schema="IFC4X3")
file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
length = ifcopenshell.api.unit.add_si_unit(file, unit_type="LENGTHUNIT")
ifcopenshell.api.unit.assign_unit(file, units=[length])
geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model")
ifcopenshell.api.context.add_context(
file,
context_type="Model",
context_identifier="Axis",
target_view="MODEL_VIEW",
parent=geometric_representation_context,
)
return file
def _new_file_no_context():
file = ifcopenshell.file(schema="IFC4X3")
file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
length = ifcopenshell.api.unit.add_si_unit(file, unit_type="LENGTHUNIT")
ifcopenshell.api.unit.assign_unit(file, units=[length])
return file
def _build_alignment(file, start_station=0.0):
return ifcopenshell.api.alignment.create_by_pi_method(
file, "TestAlignment", COORDINATES, RADII, VPOINTS, LENGTHS, start_station
)
def _pset_station(referent):
return ifcopenshell.util.element.get_pset(referent, name="Pset_Stationing", prop="Station")
def test_wrong_layout_type_raises_type_error():
file = _new_file()
alignment = _build_alignment(file)
with pytest.raises(TypeError):
ifcopenshell.api.alignment.update_key_point_referents(file, alignment)
def test_default_rel_nests_created_when_none_provided():
file = _new_file()
alignment = _build_alignment(file)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
segment_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(horizontal)
nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
assert nest.is_a("IfcRelNests")
assert nest.RelatingObject == alignment
assert nest.id() != segment_nest.id()
assert len(nest.RelatedObjects) == 8
assert all(r.is_a("IfcReferent") for r in nest.RelatedObjects)
def test_second_call_without_rel_nests_creates_separate_nest():
file = _new_file()
alignment = _build_alignment(file)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
segment_count_before = len(ifcopenshell.api.alignment.get_alignment_segment_nest(horizontal).RelatedObjects)
nest1 = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
nest2 = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
assert nest1.id() != nest2.id()
assert len(nest1.RelatedObjects) == 8
assert len(nest2.RelatedObjects) == 8
segment_count_after = len(ifcopenshell.api.alignment.get_alignment_segment_nest(horizontal).RelatedObjects)
assert segment_count_after == segment_count_before
def test_passing_previous_nest_back_in_accumulates():
file = _new_file()
alignment = _build_alignment(file)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
nest1 = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
nest2 = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal, rel_nests=nest1)
assert nest1.id() == nest2.id()
assert len(nest2.RelatedObjects) == 16
def test_provided_rel_nests_is_used_as_is():
file = _new_file()
alignment = _build_alignment(file)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
# rel_nests.RelatingObject must be the IfcAlignment that nests `layout`
rel_nests = file.createIfcRelNests(GlobalId=ifcopenshell.guid.new(), RelatingObject=alignment, RelatedObjects=())
result = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal, rel_nests=rel_nests)
assert result.id() == rel_nests.id()
assert result.RelatingObject == alignment
assert len(result.RelatedObjects) == 8
def test_provided_rel_nests_with_wrong_relating_object_raises_type_error():
file = _new_file()
alignment = _build_alignment(file)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
rel_nests = file.createIfcRelNests(GlobalId=ifcopenshell.guid.new(), RelatingObject=horizontal, RelatedObjects=())
with pytest.raises(TypeError):
ifcopenshell.api.alignment.update_key_point_referents(file, horizontal, rel_nests=rel_nests)
def test_clear_true_removes_old_referents_and_psets():
file = _new_file()
alignment = _build_alignment(file)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
old_referent_ids = [r.id() for r in nest.RelatedObjects]
old_pset_ids = [r.IsDefinedBy[0].RelatingPropertyDefinition.id() for r in nest.RelatedObjects]
nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal, rel_nests=nest, clear=True)
assert len(nest.RelatedObjects) == 8
for old_id in old_referent_ids + old_pset_ids:
with pytest.raises(RuntimeError):
file.by_id(old_id)
def test_clear_false_appends_without_dedup():
file = _new_file()
alignment = _build_alignment(file)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
ifcopenshell.api.alignment.update_key_point_referents(file, horizontal, rel_nests=nest, clear=False)
assert len(nest.RelatedObjects) == 16
counts = Counter(r.Name for r in nest.RelatedObjects)
assert len(counts) == 8
assert all(count == 2 for count in counts.values())
def test_default_horizontal_labels_and_order():
file = _new_file()
alignment = _build_alignment(file)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
expected = ["P.O.B.", "P.C.", "P.T.", "P.C.", "P.T.", "P.C.", "P.T.", "P.O.E."]
assert [r.Name.split(" (")[0] for r in nest.RelatedObjects] == expected
stations = [_pset_station(r) for r in nest.RelatedObjects]
assert stations == sorted(stations)
assert stations[0] == 0.0
def test_default_vertical_labels_and_order():
file = _new_file()
alignment = _build_alignment(file)
vertical = ifcopenshell.api.alignment.get_vertical_layout(alignment)
nest = ifcopenshell.api.alignment.update_key_point_referents(file, vertical)
expected = [
"V.P.O.B.",
"P.V.C.",
"P.V.T.",
"P.V.C.",
"P.V.T.",
"P.V.C.",
"P.V.T.",
"P.V.C.",
"P.V.T.",
"V.P.O.E.",
]
assert [r.Name.split(" (")[0] for r in nest.RelatedObjects] == expected
segments = ifcopenshell.api.alignment.get_layout_segments(vertical)
real_segments = segments[:-1] if ifcopenshell.api.alignment.has_zero_length_segment(vertical) else segments
# spot check the interior referents' stations against the segments' StartDistAlong directly
for referent, segment in zip(nest.RelatedObjects[1:-1], real_segments[1:]):
assert _pset_station(referent) == pytest.approx(segment.DesignParameters.StartDistAlong)
def test_name_format():
file = _new_file()
alignment = _build_alignment(file)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
referent = nest.RelatedObjects[0]
station = _pset_station(referent)
assert referent.Name == f"P.O.B. ({ifcopenshell.util.alignment.station_as_string(file, station)})"
def test_geometric_placement_when_layout_has_representation():
file = _new_file()
alignment = _build_alignment(file)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
curve = ifcopenshell.api.alignment.get_layout_curve(horizontal)
nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
for referent in nest.RelatedObjects:
assert referent.ObjectPlacement.is_a("IfcLinearPlacement")
location = referent.ObjectPlacement.RelativePlacement.Location
assert location.is_a("IfcPointByDistanceExpression")
assert location.BasisCurve == curve
assert referent.ObjectPlacement.CartesianPosition is not None
first, last = nest.RelatedObjects[0], nest.RelatedObjects[-1]
assert first.ObjectPlacement.RelativePlacement.Location.DistanceAlong.wrappedValue == pytest.approx(0.0)
assert last.ObjectPlacement.RelativePlacement.Location.DistanceAlong.wrappedValue == pytest.approx(
_pset_station(last)
)
def test_fallback_placement_when_layout_has_no_geometry():
file = _new_file_no_context()
alignment = ifcopenshell.api.alignment.create(file, "A1", include_geometry=False)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
ifcopenshell.api.alignment.layout_horizontal_alignment_by_pi_method(file, horizontal, COORDINATES, RADII)
nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
expected_coordinates = alignment.ObjectPlacement.RelativePlacement.Location.Coordinates
for referent in nest.RelatedObjects:
assert referent.ObjectPlacement.is_a("IfcLocalPlacement")
assert referent.ObjectPlacement.RelativePlacement.Location.Coordinates == expected_coordinates
def test_cant_layout_boundary_labels():
file = _new_file_no_context()
alignment = ifcopenshell.api.alignment.create(file, "A1", include_cant=True, include_geometry=False)
cant = ifcopenshell.api.alignment.get_cant_layout(alignment)
dp1 = file.createIfcAlignmentCantSegment(
StartDistAlong=0.0,
HorizontalLength=100.0,
StartCantLeft=0.0,
EndCantLeft=0.0,
StartCantRight=0.0,
EndCantRight=0.0,
PredefinedType="CONSTANTCANT",
)
ifcopenshell.api.alignment.create_layout_segment(file, cant, dp1)
dp2 = file.createIfcAlignmentCantSegment(
StartDistAlong=100.0,
HorizontalLength=50.0,
StartCantLeft=0.0,
EndCantLeft=0.0,
StartCantRight=0.0,
EndCantRight=0.0,
PredefinedType="CONSTANTCANT",
)
ifcopenshell.api.alignment.create_layout_segment(file, cant, dp2)
nest = ifcopenshell.api.alignment.update_key_point_referents(file, cant)
labels = [r.Name.split(" (")[0] for r in nest.RelatedObjects]
assert labels[0] == "C.P.O.B."
assert labels[-1] == "C.P.O.E."
# CONSTANTCANT -> CONSTANTCANT is currently an unfilled "xx" placeholder in the cant lookup
# table (_get_segment_start_point_label.py) -- out of scope to fill in here.
assert labels[1] == "xx"
stations = [_pset_station(r) for r in nest.RelatedObjects]
assert stations == [0.0, 100.0, 150.0]
def test_no_real_segments_produces_no_referents():
file = _new_file_no_context()
alignment = ifcopenshell.api.alignment.create(file, "A1", include_geometry=False)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
assert nest.RelatedObjects == ()
def test_single_real_segment_produces_only_boundary_labels():
file = _new_file_no_context()
alignment = ifcopenshell.api.alignment.create(file, "A1", include_geometry=False)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
design_parameters = file.createIfcAlignmentHorizontalSegment(
StartTag=None,
EndTag=None,
StartPoint=file.createIfcCartesianPoint((0.0, 0.0)),
StartDirection=0.0,
StartRadiusOfCurvature=0.0,
EndRadiusOfCurvature=0.0,
SegmentLength=100.0,
GravityCenterLineHeight=None,
PredefinedType="LINE",
)
ifcopenshell.api.alignment.create_layout_segment(file, horizontal, design_parameters)
nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
labels = [r.Name.split(" (")[0] for r in nest.RelatedObjects]
assert labels == ["P.O.B.", "P.O.E."]
def test_start_station_composes_for_child_alignment():
file = _new_file()
alignment = ifcopenshell.api.alignment.create(file, "A1", include_vertical=False, start_station=100.0)
ifcopenshell.api.alignment.add_vertical_layout(file, alignment)
ifcopenshell.api.alignment.add_vertical_layout(file, alignment) # forces the child-alignment split
child_alignment = alignment.IsDecomposedBy[0].RelatedObjects[-1]
child_vertical = ifcopenshell.api.alignment.get_vertical_layout(child_alignment)
dp1 = file.createIfcAlignmentVerticalSegment(
StartDistAlong=0.0,
HorizontalLength=500.0,
StartHeight=10.0,
StartGradient=0.01,
EndGradient=0.01,
PredefinedType="CONSTANTGRADIENT",
)
ifcopenshell.api.alignment.create_layout_segment(file, child_vertical, dp1)
dp2 = file.createIfcAlignmentVerticalSegment(
StartDistAlong=500.0,
HorizontalLength=300.0,
StartHeight=15.0,
StartGradient=0.01,
EndGradient=0.01,
PredefinedType="CONSTANTGRADIENT",
)
ifcopenshell.api.alignment.create_layout_segment(file, child_vertical, dp2)
nest = ifcopenshell.api.alignment.update_key_point_referents(file, child_vertical)
stations = [_pset_station(r) for r in nest.RelatedObjects]
assert stations == pytest.approx([100.0, 600.0, 900.0])
def test_returns_ifc_rel_nests():
file = _new_file()
alignment = _build_alignment(file)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
result = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
assert result.is_a("IfcRelNests")
test_wrong_layout_type_raises_type_error()
test_default_rel_nests_created_when_none_provided()
test_second_call_without_rel_nests_creates_separate_nest()
test_passing_previous_nest_back_in_accumulates()
test_provided_rel_nests_is_used_as_is()
test_provided_rel_nests_with_wrong_relating_object_raises_type_error()
test_clear_true_removes_old_referents_and_psets()
test_clear_false_appends_without_dedup()
test_default_horizontal_labels_and_order()
test_default_vertical_labels_and_order()
test_name_format()
test_geometric_placement_when_layout_has_representation()
test_fallback_placement_when_layout_has_no_geometry()
test_cant_layout_boundary_labels()
test_no_real_segments_produces_no_referents()
test_single_real_segment_produces_only_boundary_labels()
test_start_station_composes_for_child_alignment()
test_returns_ifc_rel_nests()
@@ -64,8 +64,8 @@ def test_vertical_layout_by_pi_method():
layout_nest = ifcopenshell.api.alignment.get_alignment_layout_nest(alignment)
assert len(layout_nest.RelatedObjects) == 2
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
assert len(referent_nest.RelatedObjects) == 1
stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment)
assert len(stationing_nest.RelatedObjects) == 1
segment_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(vlayout)
assert len(segment_nest.RelatedObjects) == 3
+7
View File
@@ -0,0 +1,7 @@
def pytest_addoption(parser):
parser.addoption(
"--rule",
action="store",
default=None,
help="Only run test_rules.py fixtures whose filename contains this substring.",
)
+10 -7
View File
@@ -1,6 +1,5 @@
import glob
import os
import sys
import pytest
import tabulate
@@ -9,14 +8,18 @@ import ifcopenshell.express.rule_executor
import ifcopenshell.validate
@pytest.mark.parametrize(
"filename",
[
def pytest_generate_tests(metafunc):
if "filename" not in metafunc.fixturenames:
return
rule = metafunc.config.getoption("--rule")
filenames = [
fn
for fn in glob.glob(os.path.join(os.path.dirname(__file__), "fixtures/rules/*.ifc"))
if len(sys.argv) < 2 or sys.argv[1] in os.path.basename(fn)
],
)
if not rule or rule in os.path.basename(fn)
]
metafunc.parametrize("filename", filenames, ids=[os.path.basename(fn) for fn in filenames])
def test_file(filename):
base = os.path.basename(filename)
file = ifcopenshell.open(filename)

Some files were not shown because too many files have changed in this diff Show More