Compare commits

...

25 Commits

Author SHA1 Message Date
Richard Brice d506f06f95 Add target-unit pickers to the Qto panels; keep unit symbols live 2026-08-10 14:37:36 -07:00
Richard Brice ebb498ddd0 Add dimensional-analysis fallback to get_project_unit() for IfcDerivedUnit 2026-08-10 14:36:28 -07:00
Richard Brice 7727a4fb4d Make quantity take-off respect manual Unit overrides; add target-unit support 2026-08-10 14:36:26 -07:00
Richard Brice c0f967e81e Add UI to pick/override a property or quantity's unit of measure in the Pset/Qto editor
Bonsai's Pset/Qto editor could display a property or quantity's own Unit
override, but had no UI to author one -- only the project-level Project
Units panel existed, which sets defaults, not per-instance overrides.
Builds on the edit_pset/edit_qto Unit-wrapping support and the
get_unit_scale/get_candidate_units helpers added in the previous commit.

- bim/prop.py: Attribute gains unit_id (the STEP id of the property's own
  override, 0 = project default) and unit_id_enum (the dropdown-driving
  dynamic enum, "Default (<symbol>)" plus every candidate unit for the
  attribute's measure type). update_attribute_unit_id converts the stored
  value live when a different unit is picked, so the physical quantity is
  preserved rather than the number being silently relabeled.
- tool/pset.py: is_measurable_special_type/get_candidate_units_for_special_type/
  resolve_effective_unit/convert_attribute_unit support the picker and the
  live conversion. get_special_type_for_prop classifies a property by its
  value's own declared measure type, falling back to an explicitly-attached
  Unit for generic numeric types (e.g. IfcReal) whose spec carries no unit
  semantics of its own but which may still legitimately carry one. Seeding
  in import_pset_from_existing ignores a stray Unit attached to a property
  whose value has no numeric/measure semantics at all (e.g. text), which
  used to crash trying to select an identifier the picker's enum items
  never include.
- bim/module/pset/ui.py: the picker widget itself, next to the value field
  in edit mode, gated on the attribute being measurable.
- bim/module/pset/operator.py: EditPset wraps measurable values with their
  chosen Unit on save, for both properties and quantities. The qto
  rounding-loop fix reaches into the wrapped dict instead of assuming a
  bare float/int, which would otherwise zero out every unit-overridden
  quantity.

Adds regression tests across all of the above, including conversion
correctness, explicit-clear/default round-trips, an unrelated sibling
property's override surviving untouched, and the stray-Unit crash guard.
2026-08-10 11:02:05 -07:00
Richard Brice 3a3c00e6f6 Add per-property/quantity Unit-override support to edit_pset/edit_qto, plus unit-scale and candidate-unit helpers
edit_pset()'s unpack_unit_value() couldn't distinguish "no Unit dict was
passed" from "{"Unit": None, ...} passed to explicitly clear an existing
override" -- both collapsed to a bare None, and every consuming call site
checked truthiness, so there was no way to actually clear a previously-set
property Unit override once one existed. Fixed with a private _NO_UNIT
sentinel; bare (unwrapped) values still leave Unit untouched exactly as
before.

edit_qto() had no Unit-handling capability at all: neither
update_existing_property() nor add_new_properties() ever read or wrote a
quantity's Unit attribute. Added the same {"Unit": ..., "NominalValue": ...}
wrapped-dict convention edit_pset() already supports, disambiguated from
the pre-existing IfcPhysicalComplexQuantity dict convention
({"Discrimination": ..., "HasQuantities": ...}) by checking for a "Unit"
key -- a complex-quantity spec never contains one.

ifcopenshell.util.unit gains two small helpers:
- get_unit_scale(unit): dispatches to get_derived_unit_scale/
  get_named_unit_scale depending on unit type, also used to de-duplicate
  calculate_unit_scale()'s own inline dispatch of the same logic.
- get_candidate_units(ifc_file, unit_type): all units in a file matching a
  given unit type, unlike get_project_unit()'s single-default lookup.

Adds regression tests for all of the above, including explicit-clear,
bare-value-preserves-override, and complex-quantity-routing-unaffected
cases.
2026-08-10 11:02:01 -07:00
Richard Brice b8211143b1 Show resolved unit symbols in read-only Pset/Qto view; add write-back and fallback regression tests
Previously, unit symbols only appeared while a Pset/Qto was in edit mode
(pencil icon) -- the read-only summary view read raw {name: value} dicts
straight from ifcopenshell.util.element.get_psets(), a completely separate
path from the Attribute/unit_symbol machinery, so it never showed a label
even after the earlier fixes. This matters for the "someone in the field
just looking at values" use case, not just editing.

- bim/module/pset/data.py: switch to get_psets(verbose=True) to get each
  property's own entity id, then resolve its unit symbol the same
  override-aware way the edit-mode path does (tool.Pset.get_unit_symbol_for_prop).
  Falls back gracefully (empty symbol) for IfcPreDefinedPropertySet
  attributes, which aren't IfcProperty entities and can't carry a Unit
  override.
- bim/module/pset/ui.py: read-only value button now shows "250 mm" instead
  of just "250".

Also adds the regression tests planned but not yet committed:
- test/tool/test_pset.py: edit a property with its own Unit override and
  write it back, confirming no rescale and the override survives.
- test/bim/test_prop.py (new): get_display_name() falls back to the plain
  name (no crash) when no unit is resolvable or the project has no units
  assigned at all.
2026-08-10 11:01:56 -07:00
Richard Brice b401393fa8 Fix get_property_unit() crash on IfcPropertySingleValue.NominalValue = None
NominalValue is optional -- IfcPropertySingleValue permits a null value --
but get_property_unit() unconditionally accessed prop.NominalValue.is_a(),
crashing on any single-value property that's legitimately blank.

Also adds a regression test confirming IfcContextDependentUnit symbols
("each", "boxes", etc.) aren't shadowed by the IfcDerivedUnit branch added
in the previous commit.
2026-08-10 11:01:35 -07:00
Richard Brice 439ea7bf9c Add IfcDerivedUnit support to ifcopenshell.util.unit (scale, symbol, dimension identification) 2026-08-10 11:00:12 -07:00
Richard Brice ae6eb4d18a Fix calculate_unit_scale() crash on SQLite-linked files
IfcSIUnit.Dimensions is a schema-derived attribute that isn't computed for
Bonsai's SQLite-linked "large model" file representation, returning None
there instead of an IfcDimensionalExponents entity. #9278 added an
unconditional unit.Dimensions.LengthExponent access to every IfcSIUnit
processed by calculate_unit_scale(), so it crashed project loading for
any linked file, even ones with no unit prefixes at all -- not just the
prefixed-area/volume case the fix targeted.

Fixed by reading dimensions from the existing si_dimensions table (keyed
by the unit's stored Name, not the unresolvable derived attribute) instead
of unit.Dimensions.

See the PR discussion for a standalone reproduction script.
2026-08-10 10:59:25 -07:00
Robert Sigmundsson f05dd4aea5 Fix #9278. calculate_unit_scale raises the SI prefix to the length exponent for prefixed SQUARE_METRE/CUBIC_METRE units.
An SI prefix attaches to the base unit symbol and the prefixed symbol is
raised to the power as a whole: DECI CUBIC_METRE is dm3 = a litre = 1e-3 m3,
not 0.1 m3. The scale factor previously applied the prefix multiplier
linearly for all IfcSIUnits, inflating volumes x100 and areas x10 for such
declarations (produced e.g. by MagiCAD for Revit MEP exports).

Following the reviewer note in #9278, the exponent is taken from the
derived attribute IfcSIUnit.Dimensions rather than from substring matching
on the unit name: the multiplier is raised to LengthExponent only when the
unit's dimensions are a pure power of length, so prefixed derived units
(KILO PASCAL, MEGA NEWTON) and non-length units (KILO GRAM) correctly keep
the linear multiplier. This matches the exponent handling already present
in convert() and named_dimensions in the same module.

Adds regression tests for prefixed AREAUNIT/VOLUMEUNIT and for the
linear-prefix behaviour of PRESSUREUNIT/MASSUNIT.
2026-08-09 08:41:15 +02:00
Richard Brice 7ed8584edc Revised update_alignment_parameter_segment_tags to make EndTag optional 2026-08-08 10:35:20 -07:00
Richard Brice c5ba22451f Adds update_alignment_parameter_segment_tags function 2026-08-07 14:33:04 -07:00
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
92 changed files with 3444 additions and 334 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
fail-fast: false
steps:
- uses: actions/checkout@v7 # https://github.com/actions/checkout
- 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
+1 -1
View File
@@ -66,7 +66,7 @@ jobs:
short_name: macos
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
- 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'
+1 -1
View File
@@ -49,7 +49,7 @@ jobs:
short_name: macos
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
- 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'
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
fail-fast: false
steps:
- uses: actions/checkout@v7
- 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
- name: Compile
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
fail-fast: false
steps:
- uses: actions/checkout@v7
- 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
- name: Compile
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
fail-fast: false
steps:
- uses: actions/checkout@v7
- 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
- name: Compile
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
fail-fast: false
steps:
- uses: actions/checkout@v7
- 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
- name: Compile
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
fail-fast: false
steps:
- uses: actions/checkout@v7
- 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
- name: Compile
+1 -1
View File
@@ -38,7 +38,7 @@ jobs:
}
steps:
- uses: actions/checkout@v7
- 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
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
fail-fast: false
steps:
- uses: actions/checkout@v7
- 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
- name: Compile
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
fail-fast: false
steps:
- uses: actions/checkout@v7
- 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
- name: Compile
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
fail-fast: false
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
- uses: actions/setup-python@v7
with:
python-version: '3.11'
- name: Compile
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
fail-fast: false
steps:
- uses: actions/checkout@v7
- 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
- name: Compile
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
fail-fast: false
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
- uses: actions/setup-python@v7
with:
python-version: '3.11'
- name: Compile
@@ -50,7 +50,7 @@ jobs:
- 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
+1 -1
View File
@@ -41,7 +41,7 @@ jobs:
- 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
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
fail-fast: false
steps:
- uses: actions/checkout@v7
- 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
- name: Compile
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
fail-fast: false
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
- uses: actions/setup-python@v7
with:
python-version: '3.11'
- name: Compile
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
fail-fast: false
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
- 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'
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
fail-fast: false
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
- 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'
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
fail-fast: false
steps:
- uses: actions/checkout@v7
- 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
- name: Compile
+2 -2
View File
@@ -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
+2 -2
View File
@@ -15,12 +15,12 @@ jobs:
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 }}
+4 -2
View File
@@ -48,7 +48,7 @@ jobs:
submodules: recursive
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: 3.11
@@ -263,7 +263,9 @@ jobs:
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
@@ -14,7 +14,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: '3.x'
+1 -1
View File
@@ -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
+2 -5
View File
@@ -236,11 +236,8 @@ def import_attribute(
elif data_type == "integer":
new.int_value = 0 if new.is_null else int(data[attribute.name()])
elif data_type == "float":
attribute_type = attribute.type_of_attribute()
if attribute_type._is("IfcLengthMeasure"):
new.special_type = "LENGTH"
elif attribute_type._is("IfcForceMeasure"):
new.special_type = "FORCE"
measure_class = attribute.type_of_attribute().declared_type().name()
new.special_type = tool.Pset.get_special_type_for_measure_class(measure_class)
new.float_value = 0.0 if new.is_null else float(data[attribute.name()])
elif data_type == "enum":
attribute_type = attribute.type_of_attribute()
+17 -2
View File
@@ -54,7 +54,7 @@ class Data:
ifc_file = tool.Ifc.get()
results = []
psetqtos = ifcopenshell.util.element.get_psets(
element, psets_only=psets_only, qtos_only=qtos_only, should_inherit=False
element, psets_only=psets_only, qtos_only=qtos_only, should_inherit=False, verbose=True
)
for name, data in sorted(psetqtos.items()):
pset = ifc_file.by_id(data["id"])
@@ -69,13 +69,28 @@ class Data:
"id": data["id"],
"Name": name,
"is_expanded": is_expanded.get(data["id"], True),
"Properties": [{"Name": k, "NominalValue": v} for k, v in sorted(data.items()) if k != "id"],
"Properties": [
cls.property_display_data(ifc_file, k, v) for k, v in sorted(data.items()) if k != "id"
],
"shared_pset_uses": len(pset_uses),
"has_template": has_template,
}
)
return sorted(results, key=lambda v: v["Name"])
@classmethod
def property_display_data(cls, ifc_file: ifcopenshell.file, name: str, verbose_value: Any) -> dict[str, Any]:
# Predefined property sets (e.g. IfcDoorPanelProperties) expose plain
# attribute values even in verbose mode, since they're typed IFC
# attributes rather than IfcProperty entities with their own id/Unit.
if not isinstance(verbose_value, dict):
return {"Name": name, "NominalValue": verbose_value, "UnitSymbol": ""}
unit_symbol = ""
if (prop_id := verbose_value.get("id")) and (prop_entity := ifc_file.by_id(prop_id)):
unit_symbol = tool.Pset.get_unit_symbol_for_prop(prop_entity, ifc_file)
return {"Name": name, "NominalValue": verbose_value["value"], "UnitSymbol": unit_symbol}
@classmethod
def format_pset_enum(cls, psets):
enum_items = []
+24 -9
View File
@@ -120,13 +120,20 @@ class EditPset(bpy.types.Operator, tool.Ifc.Operator):
properties = json.loads(self.properties)
else:
for prop in props.properties:
metadata = prop.metadata
if prop.value_type == "IfcPropertySingleValue":
properties[prop.metadata.name] = prop.metadata.get_value()
value = metadata.get_value()
elif prop.value_type == "IfcPropertyEnumeratedValue":
value_name = prop.metadata.get_value_name()
properties[prop.metadata.name] = [
e[value_name] for e in prop.enumerated_value.enumerated_values if e.is_selected
]
value_name = metadata.get_value_name()
value = [e[value_name] for e in prop.enumerated_value.enumerated_values if e.is_selected]
else:
continue
# None (a purge/skip-creation signal, handled by edit_pset/edit_qto before any
# unit wrapping is unpacked) must stay bare -- only wrap real values.
if value is not None and tool.Pset.is_measurable_special_type(metadata.special_type):
unit = self.file.by_id(metadata.unit_id) if metadata.unit_id else None
value = {"NominalValue": value, "Unit": unit}
properties[metadata.name] = value
if pset.is_a() in ("IfcPropertySet", "IfcMaterialProperties", "IfcProfileProperties"):
ifcopenshell.api.pset.edit_pset(
@@ -140,10 +147,18 @@ class EditPset(bpy.types.Operator, tool.Ifc.Operator):
for key, value in properties.items():
if value is None:
continue
if isinstance(value, float):
properties[key] = round(value, 4)
elif not isinstance(value, int):
properties[key] = 0
is_wrapped = isinstance(value, dict) and "Unit" in value
raw = value["NominalValue"] if is_wrapped else value
if raw is None:
continue
if isinstance(raw, float):
raw = round(raw, 4)
elif not isinstance(raw, int):
raw = 0
if is_wrapped:
value["NominalValue"] = raw
else:
properties[key] = raw
ifcopenshell.api.pset.edit_qto(
self.file,
qto=pset,
+8 -3
View File
@@ -67,6 +67,10 @@ def draw_single_property(prop: IfcProperty, layout: bpy.types.UILayout, copy_ope
if prop.metadata.special_type == "URI":
op = layout.operator("bim.select_uri_attribute", text="", icon="FILE_FOLDER")
op.attribute_data_path = tool.Blender.get_full_data_path(prop.metadata)
if tool.Pset.is_measurable_special_type(prop.metadata.special_type):
unit_row = layout.row(align=True)
unit_row.scale_x = 0.5
prop_with_search(unit_row, prop.metadata, "unit_id_enum", text="")
if prop.metadata.is_optional:
layout.prop(prop.metadata, "is_null", icon="RADIOBUT_OFF" if prop.metadata.is_null else "RADIOBUT_ON", text="")
if copy_operator:
@@ -203,9 +207,10 @@ def draw_psetqto_ui(
row = box.row(align=True)
row.scale_y = 0.8
row.label(text=prop["Name"])
op = row.operator(
"bim.select_similar", text=get_display_value(nominal_value), icon="NONE", emboss=False
)
display_value = get_display_value(nominal_value)
if unit_symbol := prop["UnitSymbol"]:
display_value = f"{display_value} {unit_symbol}"
op = row.operator("bim.select_similar", text=display_value, icon="NONE", emboss=False)
op.key = '"' + pset["Name"].replace('"', '\\"') + '"."' + prop["Name"].replace('"', '\\"') + '"'
# calculate sum of all selected objects
if active_operator:
+2 -2
View File
@@ -151,7 +151,7 @@ class CalculateSingleQuantity(bpy.types.Operator, tool.Ifc.Operator):
ifc_file = tool.Ifc.get()
with Profiler("Quantify function time:"):
results = ifc5d.qto.quantify(ifc_file, elements, rules)
ifc5d.qto.edit_qtos(ifc_file, results)
ifc5d.qto.edit_qtos(ifc_file, results, target_units=tool.Qto.get_target_units(), rules=rules)
not_quantified_elements = elements - set(results.keys())
not_quantified_message = tool.Qto.get_not_quantified_elements_message(not_quantified_elements)
@@ -194,7 +194,7 @@ class PerformQuantityTakeOff(bpy.types.Operator, tool.Ifc.Operator):
ifc_file = tool.Ifc.get()
with Profiler("Quantify function time:"):
results = ifc5d.qto.quantify(ifc_file, elements, rules)
ifc5d.qto.edit_qtos(ifc_file, results)
ifc5d.qto.edit_qtos(ifc_file, results, target_units=tool.Qto.get_target_units(), rules=rules)
not_quantified_elements = elements - set(results.keys())
return not_quantified_elements
+28
View File
@@ -27,10 +27,28 @@ from bpy.props import (
)
from bpy.types import PropertyGroup
import bonsai.bim.prop
import bonsai.tool as tool
CALCULATOR_FUNCTION_ENUM_ITEMS: list[Union[tuple[str, str, str], None]] = []
# Measure class (matching ifc5d.qto's Function.measure / SI2ProjectUnitConverter.project_units'
# keys) -> (BIMQtoProperties field name, tool.Pset special_type, UI label).
MEASURE_TO_TARGET_UNIT_FIELD: dict[str, tuple[str, str, str]] = {
"IfcLengthMeasure": ("target_unit_length", "LENGTH", "Length"),
"IfcAreaMeasure": ("target_unit_area", "AREA", "Area"),
"IfcVolumeMeasure": ("target_unit_volume", "VOLUME", "Volume"),
"IfcMassMeasure": ("target_unit_mass", "MASS", "Mass"),
"IfcTimeMeasure": ("target_unit_time", "TIME", "Time"),
}
def _target_unit_items(special_type: str):
def getter(self: "BIMQtoProperties", context: bpy.types.Context) -> tool.Blender.BLENDER_ENUM_ITEMS:
return bonsai.bim.prop.get_unit_enum_items_for_special_type(special_type, tool.Ifc.get())
return getter
def get_qto_rule(self: "BIMQtoProperties", context: bpy.types.Context) -> list[tuple[str, str, str]]:
results: list[tuple[str, str, str]] = []
@@ -92,6 +110,11 @@ class BIMQtoProperties(PropertyGroup):
),
default=False,
)
target_unit_length: EnumProperty(items=_target_unit_items("LENGTH"), name="Length Unit")
target_unit_area: EnumProperty(items=_target_unit_items("AREA"), name="Area Unit")
target_unit_volume: EnumProperty(items=_target_unit_items("VOLUME"), name="Volume Unit")
target_unit_mass: EnumProperty(items=_target_unit_items("MASS"), name="Mass Unit")
target_unit_time: EnumProperty(items=_target_unit_items("TIME"), name="Time Unit")
if TYPE_CHECKING:
qto_rule: str
@@ -101,3 +124,8 @@ class BIMQtoProperties(PropertyGroup):
qto_name: str
prop_name: str
fallback: bool
target_unit_length: str
target_unit_area: str
target_unit_volume: str
target_unit_mass: str
target_unit_time: str
+20
View File
@@ -17,9 +17,12 @@
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import bpy
import ifc5d.qto
import bonsai.tool as tool
from bonsai.bim.helper import prop_with_search
from bonsai.bim.module.qto.data import QtoData
from bonsai.bim.module.qto.prop import MEASURE_TO_TARGET_UNIT_FIELD
class BIM_PT_qto(bpy.types.Panel):
@@ -44,6 +47,14 @@ class BIM_PT_qto(bpy.types.Panel):
row = layout.row()
row.prop(props, "qto_rule", text="")
row.prop(props, "fallback", text="", icon="RADIOBUT_ON" if props.fallback else "RADIOBUT_OFF")
box = layout.box()
box.label(text="Target Units (optional, otherwise project default)")
for field_name, _special_type, label in MEASURE_TO_TARGET_UNIT_FIELD.values():
row = box.row(align=True)
row.label(text=label)
prop_with_search(row, props, field_name, text="")
row = layout.row()
row.operator("bim.perform_quantity_take_off")
@@ -66,6 +77,15 @@ class BIM_PT_qto_manual(bpy.types.Panel):
row = layout.row()
row.prop(props, "calculator_function", text="Function")
calculator = ifc5d.qto.calculators.get(props.calculator)
function = calculator.functions.get(props.calculator_function) if calculator else None
target_unit_field = MEASURE_TO_TARGET_UNIT_FIELD.get(function.measure) if function else None
if target_unit_field:
field_name, _special_type, label = target_unit_field
row = layout.row(align=True)
row.label(text=f"{label} Unit")
prop_with_search(row, props, field_name, text="")
row = layout.row(align=True)
row.prop(props, "qto_name", text="")
row.prop(props, "prop_name", text="")
+79 -37
View File
@@ -21,7 +21,6 @@ import os
from typing import TYPE_CHECKING, Any, Literal, Union, assert_never, get_args
import bpy
import ifcopenshell.util.unit
from bpy.props import (
BoolProperty,
CollectionProperty,
@@ -34,6 +33,8 @@ from bpy.props import (
)
from bpy.types import PropertyGroup
import ifcopenshell.util.unit
import bonsai.bim
import bonsai.bim.handler
import bonsai.tool as tool
@@ -122,6 +123,57 @@ def get_attribute_enum_values(prop: "Attribute", context: bpy.types.Context) ->
return items
def get_unit_enum_items_for_special_type(
special_type: str, ifc_file: Union[ifcopenshell.file, None]
) -> tool.Blender.BLENDER_ENUM_ITEMS:
"""Items for a unit-override picker: "Default (<symbol>)" plus every candidate unit
matching `special_type`, filtered per-caller since candidates depend on the measure type
in question (unlike the globally-shared lists in `bonsai.bim.ui.EnumData`).
"""
if not ifc_file or not tool.Pset.is_measurable_special_type(special_type):
return [(cache_string("0"), cache_string("Default"), "")]
default_symbol = tool.Pset.get_unit_symbol_for_special_type(special_type, ifc_file)
items: list[tuple[str, str, str]] = [
(cache_string("0"), cache_string(f"Default ({default_symbol})" if default_symbol else "Default"), "")
]
for unit in tool.Pset.get_candidate_units_for_special_type(special_type, ifc_file):
name = getattr(unit, "Name", None) or unit.is_a()
symbol = ifcopenshell.util.unit.get_unit_symbol(unit)
label = f"{name} ({symbol})" if symbol else name
items.append((cache_string(str(unit.id())), cache_string(label), ""))
return items
def get_attribute_unit_enum_items(prop: "Attribute", context: bpy.types.Context) -> tool.Blender.BLENDER_ENUM_ITEMS:
"""Items for `Attribute.unit_id_enum`. Wraps `get_unit_enum_items_for_special_type` with a
defensive addition: real-world files sometimes carry a Unit that doesn't cleanly match our
candidate-matching logic (e.g. a mismatched UnitType). Always keep the attribute's own
current override selectable/representable, however unusual, so setting unit_id_enum to
match an already-seeded unit_id can never raise "enum not found".
"""
ifc_file = tool.Ifc.get()
items = get_unit_enum_items_for_special_type(prop.special_type, ifc_file)
if prop.unit_id and prop.unit_id not in {int(i[0]) for i in items}:
own_unit = ifc_file.by_id(prop.unit_id)
name = getattr(own_unit, "Name", None) or own_unit.is_a()
symbol = ifcopenshell.util.unit.get_unit_symbol(own_unit)
label = f"{name} ({symbol})" if symbol else name
items.append((cache_string(str(prop.unit_id)), cache_string(label), ""))
return items
def update_attribute_unit_id(self: "Attribute", context: bpy.types.Context) -> None:
new_unit_id = int(tool.Blender.get_enum_safe(self, "unit_id_enum") or "0")
if ifc_file := tool.Ifc.get():
# Must run before self.unit_id is overwritten: convert_attribute_unit needs the OLD
# unit_id to know what unit the current value is expressed in.
tool.Pset.convert_attribute_unit(self, new_unit_id, ifc_file)
self.unit_id = new_unit_id
def update_schema_dir(self: "BIMProperties", context: bpy.types.Context) -> None:
import bonsai.bim.schema
@@ -250,44 +302,33 @@ def set_numerical_value(self: "Attribute", value_name: str, new_value: Union[flo
self[value_name] = new_value
def get_length_value(self: "Attribute") -> float:
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
return self.float_value * si_conversion
def set_length_value(self: "Attribute", value: float) -> None:
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
self.float_value = value / si_conversion
def get_display_name(self: "Attribute") -> str:
DISPLAY_UNIT_TYPES = ("AREA", "VOLUME", "FORCE")
name = self.name
if not self.special_type or self.special_type not in DISPLAY_UNIT_TYPES:
if not self.unit_symbol:
return name
return f"{name}, {self.unit_symbol}"
unit_type = f"{self.special_type}UNIT"
project_unit = ifcopenshell.util.unit.get_project_unit(tool.Ifc.get(), unit_type)
if not project_unit:
return name
unit_symbol = ifcopenshell.util.unit.get_unit_symbol(project_unit)
return f"{name}, {unit_symbol}"
def get_unit_symbol(self: "Attribute") -> str:
"""The symbol for whatever unit the value is currently expressed in: this property's own
override (`unit_id`) if set, else the project default for `special_type`. Computed fresh on
every access (rather than cached at import time) so it stays correct immediately after the
unit picker changes `unit_id`, and after the project's own default units are edited.
"""
if not tool.Pset.is_measurable_special_type(self.special_type):
return ""
if not (ifc_file := tool.Ifc.get()):
return ""
unit = tool.Pset.resolve_effective_unit(self.special_type, self.unit_id, ifc_file)
return ifcopenshell.util.unit.get_unit_symbol(unit) if unit else ""
AttributeDataType = Literal["string", "integer", "float", "boolean", "enum", "file", "list[string]"]
AttributeSpecialType = Literal[
"",
"DATE",
"DATETIME",
"LENGTH",
"AREA",
"VOLUME",
"FORCE",
"LOGICAL",
"URI",
"DURATION",
]
# Either "", "DATE", "DATETIME", "LOGICAL", "URI", "DURATION", or an
# IfcUnitEnum/IfcDerivedUnitEnum value with the "UNIT" suffix stripped (e.g.
# "LENGTH", "PRESSURE", "MODULUSOFELASTICITY") as returned by
# tool.Pset.get_special_type_for_prop().
AttributeSpecialType = str
class Attribute(PropertyGroup):
@@ -318,9 +359,6 @@ class Attribute(PropertyGroup):
get=lambda self: float(self.get("float_value", 0.0)),
set=set_float_value,
)
length_value: FloatProperty(
name="Value", description=tooltip, get=get_length_value, set=set_length_value, unit="LENGTH"
)
enum_items: StringProperty(name="Value")
"""Json serialized mapping of enum items:
Typically a dictionary of string identifiers to item names.
@@ -342,6 +380,10 @@ class Attribute(PropertyGroup):
value_max: FloatProperty(description="This is used to validate int_value and float_value")
value_max_constraint: BoolProperty(default=False, description="True if the numerical value has an upper bound")
special_type: StringProperty(name="Special Value Type", default="")
unit_symbol: StringProperty(name="Unit Symbol", get=get_unit_symbol)
unit_id: IntProperty(name="Unit Override", default=0)
"""STEP id of this property/quantity's own Unit override. 0 means "use the project default"."""
unit_id_enum: EnumProperty(items=get_attribute_unit_enum_items, name="Unit", update=update_attribute_unit_id)
use_explorer_ui: BoolProperty()
metadata: StringProperty(name="Metadata", description="For storing some additional information about the attribute")
update: StringProperty(name="Update", description="Custom update function to be executed")
@@ -357,7 +399,6 @@ class Attribute(PropertyGroup):
bool_value: bool
int_value: int
float_value: float
length_value: float
enum_items: str
enum_items_dynamic: str
enum_descriptions: bpy.types.bpy_prop_collection_idprop[StrProperty]
@@ -373,6 +414,9 @@ class Attribute(PropertyGroup):
value_min_constraint: bool
value_max: float
value_max_constraint: bool
unit_symbol: str
unit_id: int
unit_id_enum: str
use_explorer_ui: bool
metadata: str
update: str
@@ -430,8 +474,6 @@ class Attribute(PropertyGroup):
elif data_type == "integer":
return "int_value"
elif data_type == "float":
if display_only and self.special_type == "LENGTH":
return "length_value"
return "float_value"
elif data_type == "enum":
return "enum_value"
-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")
+147 -33
View File
@@ -26,6 +26,7 @@ import ifcopenshell
import ifcopenshell.api.pset
import ifcopenshell.util.attribute
import ifcopenshell.util.element
import ifcopenshell.util.unit
import bonsai.bim.helper
import bonsai.bim.schema
@@ -168,42 +169,144 @@ class Pset(bonsai.core.tool.Pset):
pset_id=0, pset_name=cls.get_pset_name(obj, obj_type), pset_type="PSET", obj=obj, obj_type=obj_type
)
# Templates for quantities can specify their kind via TemplateType (e.g.
# "Q_LENGTH") instead of PrimaryMeasureType. IfcQuantityCount has no
# associated measure/unit, so it is intentionally absent here.
QUANTITY_TEMPLATE_TYPE_TO_SPECIAL_TYPE = {
"Q_LENGTH": "LENGTH",
"Q_AREA": "AREA",
"Q_VOLUME": "VOLUME",
"Q_WEIGHT": "MASS",
"Q_TIME": "TIME",
}
@classmethod
def get_special_type_for_prop(
cls, prop_or_prop_template: ifcopenshell.entity_instance
) -> Literal["LENGTH"] | Literal["AREA"] | Literal["VOLUME"] | Literal["URI"] | Literal[""]:
special_type = ""
def get_special_type_for_measure_class(cls, measure_class: str) -> str:
"""Get the ``special_type`` (an IfcUnitEnum value with "UNIT" stripped) for an IFC measure class.
:param measure_class: An IFC measure class name, e.g. "IfcLengthMeasure".
:return: E.g. "LENGTH", or "" if the class has no associated unit type.
"""
if not measure_class.endswith("Measure"):
return ""
unit_type = ifcopenshell.util.unit.get_measure_unit_type(measure_class)
return unit_type[: -len("UNIT")] if unit_type.endswith("UNIT") else ""
@classmethod
def get_special_type_for_unit(cls, unit: ifcopenshell.entity_instance) -> str:
"""Get the ``special_type`` (an IfcUnitEnum value with "UNIT" stripped) directly from
a Unit entity, for properties whose NominalValue is a generic numeric type (e.g.
IfcReal) rather than a proper measure class, but which still carry a real Unit.
"""
unit_type = getattr(unit, "UnitType", None)
if unit_type and unit_type != "USERDEFINED":
return unit_type[: -len("UNIT")] if unit_type.endswith("UNIT") else ""
dimension_type = ifcopenshell.util.unit.identify_unit_dimensions(unit)
return dimension_type[: -len("UNIT")] if dimension_type else ""
@classmethod
def get_special_type_for_prop(cls, prop_or_prop_template: ifcopenshell.entity_instance) -> str:
"""Classify a property/quantity/template by its measure type.
:return: An IfcUnitEnum value with the "UNIT" suffix stripped (e.g.
"LENGTH", "PRESSURE"), "URI" for IfcURIReference, or "" if the
value has no associated unit type.
"""
if prop_or_prop_template.is_a("IfcPropertyTemplate"):
primary_measure_type = prop_or_prop_template.PrimaryMeasureType
template_type = prop_or_prop_template.TemplateType
if primary_measure_type in ("IfcPositiveLengthMeasure", "IfcLengthMeasure") or template_type == "Q_LENGTH":
special_type = "LENGTH"
elif primary_measure_type == "IfcAreaMeasure" or template_type == "Q_AREA":
special_type = "AREA"
elif primary_measure_type == "IfcVolumeMeasure" or template_type == "Q_VOLUME":
special_type = "VOLUME"
elif primary_measure_type == "IfcURIReference":
special_type = "URI"
else:
if prop_or_prop_template.is_a("IfcPropertySingleValue"):
value = prop_or_prop_template.NominalValue
if value is not None:
value_type = value.is_a()
if value_type in ("IfcLengthMeasure", "IfcPositiveLengthMeasure"):
special_type = "LENGTH"
elif value_type == "IfcAreaMeasure":
special_type = "AREA"
elif value_type == "IfcVolumeMeasure":
special_type = "VOLUME"
elif prop_or_prop_template.is_a("IfcPhysicalSimpleQuantity"):
prop_class = prop_or_prop_template.is_a()
if prop_class == "IfcQuantityArea":
special_type = "AREA"
elif prop_class == "IfcQuantityVolume":
special_type = "VOLUME"
elif prop_class == "IfcQuantityLength":
special_type = "LENGTH"
return special_type
if primary_measure_type == "IfcURIReference":
return "URI"
if primary_measure_type:
return cls.get_special_type_for_measure_class(primary_measure_type)
return cls.QUANTITY_TEMPLATE_TYPE_TO_SPECIAL_TYPE.get(prop_or_prop_template.TemplateType, "")
elif prop_or_prop_template.is_a("IfcPropertySingleValue"):
value = prop_or_prop_template.NominalValue
if value is not None:
special_type = cls.get_special_type_for_measure_class(value.is_a())
if special_type:
return special_type
# Some property sets declare a generic numeric type (e.g. IfcReal) rather
# than a proper measure class, relying on an explicit Unit attribute alone to
# convey the dimension. Still measurable -- derive special_type from the Unit
# itself rather than (fruitlessly) from NominalValue's declared type.
if value.is_a() in ("IfcReal", "IfcInteger"):
if unit := getattr(prop_or_prop_template, "Unit", None):
return cls.get_special_type_for_unit(unit)
elif prop_or_prop_template.is_a("IfcPhysicalSimpleQuantity"):
entity = prop_or_prop_template.wrapped_data.declaration().as_entity()
measure_class = entity.attribute_by_index(3).type_of_attribute().declared_type().name()
return cls.get_special_type_for_measure_class(measure_class)
return ""
@classmethod
def get_unit_symbol_for_special_type(cls, special_type: str, ifc_file: ifcopenshell.file) -> str:
"""Get the project's default unit symbol for a `special_type` (see `get_special_type_for_prop`).
Used where there's no property instance to check for a `Unit` override
(e.g. a template, or a native IFC entity attribute, neither of which
can carry one).
"""
if not special_type or special_type == "URI":
return ""
unit = ifcopenshell.util.unit.get_project_unit(ifc_file, f"{special_type}UNIT")
return ifcopenshell.util.unit.get_unit_symbol(unit) if unit else ""
@classmethod
def get_unit_symbol_for_prop(cls, prop: ifcopenshell.entity_instance, ifc_file: ifcopenshell.file) -> str:
"""Get the unit symbol for an existing property/quantity, respecting its own `Unit` override.
Gated on the property being classified as measurable (see `get_special_type_for_prop`,
which already accounts for a Unit attached to a generic numeric value) -- this only
excludes a Unit attached to a property whose value has no numeric/measure semantics at
all (e.g. text), where a stray Unit shouldn't be surfaced as a resolved unit.
"""
if not cls.is_measurable_special_type(cls.get_special_type_for_prop(prop)):
return ""
unit = ifcopenshell.util.unit.get_property_unit(prop, ifc_file)
return ifcopenshell.util.unit.get_unit_symbol(unit) if unit else ""
# special_type values that don't denote a real unit-bearing measure (see get_special_type_for_prop).
NON_MEASURABLE_SPECIAL_TYPES = frozenset({"", "DATE", "DATETIME", "LOGICAL", "URI", "DURATION"})
@classmethod
def is_measurable_special_type(cls, special_type: str) -> bool:
"""True if `special_type` (see `get_special_type_for_prop`) denotes a real unit-bearing measure."""
return special_type not in cls.NON_MEASURABLE_SPECIAL_TYPES
@classmethod
def get_candidate_units_for_special_type(
cls, special_type: str, ifc_file: ifcopenshell.file
) -> list[ifcopenshell.entity_instance]:
"""All units in the file usable as an override for a `special_type` (see `get_special_type_for_prop`)."""
if not cls.is_measurable_special_type(special_type):
return []
return ifcopenshell.util.unit.get_candidate_units(ifc_file, f"{special_type}UNIT")
@classmethod
def resolve_effective_unit(
cls, special_type: str, unit_id: int, ifc_file: ifcopenshell.file
) -> Union[ifcopenshell.entity_instance, None]:
"""The unit a value is currently expressed in: its own override (`unit_id`, a STEP id,
0 meaning "no override"), or the project default for `special_type` otherwise."""
if unit_id:
return ifc_file.by_id(unit_id)
return ifcopenshell.util.unit.get_project_unit(ifc_file, f"{special_type}UNIT")
@classmethod
def convert_attribute_unit(cls, metadata: "Attribute", new_unit_id: int, ifc_file: ifcopenshell.file) -> None:
"""Rescale `metadata.float_value` in place so its physical quantity is preserved when
switching from its current effective unit to the unit named by `new_unit_id` (0 = project
default). No-op for non-measurable attributes or when old and new resolve to the same unit.
"""
if not cls.is_measurable_special_type(metadata.special_type):
return
old_unit = cls.resolve_effective_unit(metadata.special_type, metadata.unit_id, ifc_file)
new_unit = cls.resolve_effective_unit(metadata.special_type, new_unit_id, ifc_file)
if old_unit is None or new_unit is None or old_unit == new_unit:
return
old_scale = ifcopenshell.util.unit.get_unit_scale(old_unit)
new_scale = ifcopenshell.util.unit.get_unit_scale(new_unit)
metadata.float_value = metadata.float_value * old_scale / new_scale
@classmethod
def import_pset_from_existing(
@@ -283,6 +386,15 @@ class Pset(bonsai.core.tool.Pset):
metadata.is_null = value is None
metadata.is_optional = True
metadata.special_type = cls.get_special_type_for_prop(prop)
# The prop's OWN Unit override only -- metadata.unit_symbol is computed fresh from
# special_type/unit_id on every access (see Attribute.get_unit_symbol), so it
# already accounts for the project-default fallback once unit_id is set below.
# Some real-world files (e.g. certain exporters) set Unit on properties that
# aren't actually measures -- ignore it there, since we only ever treat Unit as
# meaningful for measurable special_types (matching the UI picker's own gating).
own_unit = getattr(prop, "Unit", None) if cls.is_measurable_special_type(metadata.special_type) else None
metadata.unit_id = own_unit.id() if own_unit else 0
metadata.unit_id_enum = str(metadata.unit_id)
metadata.set_value(metadata.get_value_default() if metadata.is_null else value)
process_prop_description(metadata)
@@ -409,6 +521,8 @@ class Pset(bonsai.core.tool.Pset):
cls.import_single_value_from_template(pset_template, prop_template, simplified_data, props)
elif prop_template.TemplateType.startswith("Q_"):
if prop_data:
continue # Existing quantity will be added later by import_pset_from_existing.
cls.import_single_value_from_template(pset_template, prop_template, simplified_data, props)
elif prop_template.TemplateType == "P_ENUMERATEDVALUE":
+13
View File
@@ -176,6 +176,19 @@ class Qto(bonsai.core.tool.Qto):
is_ifc4x3 = ifc_file.schema == "IFC4X3"
return {rule_id: rule for rule_id, rule in ifc5d.qto.rules.items() if rule_id.startswith("IFC4X3") == is_ifc4x3}
@classmethod
def get_target_units(cls) -> dict[str, ifcopenshell.entity_instance]:
from bonsai.bim.module.qto.prop import MEASURE_TO_TARGET_UNIT_FIELD
props = cls.get_qto_props()
ifc_file = tool.Ifc.get()
target_units: dict[str, ifcopenshell.entity_instance] = {}
for measure_class, (field_name, _special_type, _label) in MEASURE_TO_TARGET_UNIT_FIELD.items():
unit_id = int(tool.Blender.get_enum_safe(props, field_name) or "0")
if unit_id:
target_units[measure_class] = ifc_file.by_id(unit_id)
return target_units
@classmethod
def get_not_quantified_elements_message(cls, not_quantified_elements: set[ifcopenshell.entity_instance]) -> str:
not_quantified_message = ""
+1 -15
View File
@@ -361,31 +361,22 @@ Scenario: Edit pset length property
Given an empty IFC project
And I press "mesh.add_stair"
And the variable "pset" is "tool.Pset.get_element_pset(tool.Ifc.get_entity(bpy.context.active_object), 'Pset_StairFlightCommon').id()"
And the variable "si_conversion" is "ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())"
And I press "bim.enable_pset_editing(pset_id={pset}, obj='IfcStairFlight/StairFlight', obj_type='Object')"
# Testing IfcPositiveLengthMeasure type of prop
Then "active_object.PsetProperties.properties['TreadLength'].metadata.special_type" is "LENGTH"
And "active_object.PsetProperties.properties['TreadLength'].metadata.float_value" is "250"
And "active_object.PsetProperties.properties['TreadLength'].metadata.length_value" is roughly "0.25"
When I set "active_object.PsetProperties.properties['TreadLength'].metadata.float_value" to "350"
Then "active_object.PsetProperties.properties['TreadLength'].metadata.float_value" is roughly "350"
When I set "active_object.PsetProperties.properties['TreadLength'].metadata.length_value" to "0.45"
Then "active_object.PsetProperties.properties['TreadLength'].metadata.float_value" is roughly "450"
# Testing IfcLengthMeasure type of prop
Then "active_object.PsetProperties.properties['NosingLength'].metadata.special_type" is "LENGTH"
And "active_object.PsetProperties.properties['NosingLength'].metadata.float_value" is "0.0"
And "active_object.PsetProperties.properties['NosingLength'].metadata.length_value" is roughly "0.0"
When I set "active_object.PsetProperties.properties['NosingLength'].metadata.float_value" to "350"
Then "active_object.PsetProperties.properties['NosingLength'].metadata.float_value" is roughly "350"
When I set "active_object.PsetProperties.properties['NosingLength'].metadata.length_value" to "0.45"
Then "active_object.PsetProperties.properties['NosingLength'].metadata.float_value" is roughly "450"
When I press "bim.edit_pset(obj='IfcStairFlight/StairFlight', obj_type='Object')"
Then nothing happens
@@ -394,19 +385,14 @@ Scenario: Edit qset length property
And I press "mesh.add_stair"
And I press "bim.perform_quantity_take_off"
And the variable "pset" is "tool.Pset.get_element_pset(tool.Ifc.get_entity(bpy.context.active_object), 'Qto_StairFlightBaseQuantities').id()"
And the variable "si_conversion" is "ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())"
And I press "bim.enable_pset_editing(pset_id={pset}, obj='IfcStairFlight/StairFlight', obj_type='Object')"
# Testing Q_LENGTH type of prop
Then "active_object.PsetProperties.properties['Length'].metadata.special_type" is "LENGTH"
And "active_object.PsetProperties.properties['Length'].metadata.float_value" is roughly "2156.485"
And "active_object.PsetProperties.properties['Length'].metadata.length_value" is roughly "2.156"
When I set "active_object.PsetProperties.properties['Length'].metadata.float_value" to "350"
Then "active_object.PsetProperties.properties['Length'].metadata.length_value" is roughly "0.35"
When I set "active_object.PsetProperties.properties['Length'].metadata.length_value" to "0.45"
Then "active_object.PsetProperties.properties['Length'].metadata.float_value" is roughly "450"
Then "active_object.PsetProperties.properties['Length'].metadata.float_value" is roughly "350"
When I press "bim.edit_pset(obj='IfcStairFlight/StairFlight', obj_type='Object')"
Then nothing happens
+328
View File
@@ -0,0 +1,328 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import bpy
import ifcopenshell
import ifcopenshell.api.pset
import ifcopenshell.api.root
import ifcopenshell.api.unit
import pytest
import bonsai.bim.prop
import bonsai.tool as tool
from test.bim.bootstrap import NewFile
def import_single_property(ifc, element, prop):
"""Import a single existing IfcProperty into a real, addon-registered
PsetProperties collection, exactly as the property editor does, and
return its `metadata` (an `Attribute`)."""
pset = ifcopenshell.api.pset.add_pset(ifc, product=element, name="Pset_Test")
pset.HasProperties = [prop]
obj = bpy.data.objects.new(prop.Name, None)
tool.Ifc.link(element, obj)
props = obj.PsetProperties
tool.Pset.import_pset_from_existing(pset, props, None)
return props.properties[prop.Name].metadata
class TestGetDisplayName(NewFile):
def test_appends_the_resolved_unit_symbol(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
pressure = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="PRESSUREUNIT")
ifcopenshell.api.unit.assign_unit(ifc, units=[pressure])
element = ifc.createIfcWall()
prop = ifc.createIfcPropertySingleValue(Name="Foo", NominalValue=ifc.createIfcPressureMeasure(5.0))
metadata = import_single_property(ifc, element, prop)
assert metadata.display_name == "Foo, Pa"
def test_falls_back_to_the_plain_name_when_no_unit_is_resolvable(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
# No units assigned to the project at all -- nothing to resolve.
element = ifc.createIfcWall()
prop = ifc.createIfcPropertySingleValue(Name="Foo", NominalValue=ifc.createIfcPressureMeasure(5.0))
metadata = import_single_property(ifc, element, prop)
assert metadata.unit_symbol == ""
assert metadata.display_name == "Foo"
def test_falls_back_to_the_plain_name_for_a_non_measure_property(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
element = ifc.createIfcWall()
prop = ifc.createIfcPropertySingleValue(Name="Foo", NominalValue=ifc.createIfcText("Bar"))
metadata = import_single_property(ifc, element, prop)
assert metadata.unit_symbol == ""
assert metadata.display_name == "Foo"
def test_resolves_a_unit_explicitly_attached_to_a_generic_numeric_value(self):
# A generic IfcReal has no unit semantics per its own declared type, but a property
# set may still explicitly attach a real Unit to a specific instance to convey the
# dimension the spec's generic typing doesn't. That explicit Unit is real, deliberate
# data (not incidental/stray), so it should resolve normally.
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
length_mm = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT", prefix="MILLI")
element = ifc.createIfcWall()
prop = ifc.createIfcPropertySingleValue(Name="Foo", NominalValue=ifc.createIfcReal(150.0), Unit=length_mm)
metadata = import_single_property(ifc, element, prop)
assert metadata.unit_symbol == "mm"
assert metadata.display_name == "Foo, mm"
class TestGetAttributeUnitEnumItems(NewFile):
def test_returns_default_plus_one_per_candidate_for_a_measurable_type(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
length_mm = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT", prefix="MILLI")
ifcopenshell.api.unit.assign_unit(ifc, units=[length_mm])
length_m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
element = ifc.createIfcWall()
prop = ifc.createIfcPropertySingleValue(Name="Foo", NominalValue=ifc.createIfcLengthMeasure(2.5))
metadata = import_single_property(ifc, element, prop)
items = bonsai.bim.prop.get_attribute_unit_enum_items(metadata, bpy.context)
identifiers = [i[0] for i in items]
assert identifiers[0] == "0"
assert str(length_mm.id()) in identifiers
assert str(length_m.id()) in identifiers
assert len(items) == 3 # Default + mm + m
def test_returns_just_default_for_non_measurable_types(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
element = ifc.createIfcWall()
prop = ifc.createIfcPropertySingleValue(Name="Foo", NominalValue=ifc.createIfcText("Bar"))
metadata = import_single_property(ifc, element, prop)
items = bonsai.bim.prop.get_attribute_unit_enum_items(metadata, bpy.context)
assert [i[0] for i in items] == ["0"]
class TestGetUnitEnumItemsForSpecialType(NewFile):
def test_matches_the_attribute_wrapper_output(self):
# Regression test for extracting get_unit_enum_items_for_special_type out of
# get_attribute_unit_enum_items: the wrapper must still produce identical items for
# the plain (no own-unit-fallback-needed) case.
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
length_mm = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT", prefix="MILLI")
ifcopenshell.api.unit.assign_unit(ifc, units=[length_mm])
ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
element = ifc.createIfcWall()
prop = ifc.createIfcPropertySingleValue(Name="Foo", NominalValue=ifc.createIfcLengthMeasure(2.5))
metadata = import_single_property(ifc, element, prop)
direct_items = bonsai.bim.prop.get_unit_enum_items_for_special_type(metadata.special_type, ifc)
wrapper_items = bonsai.bim.prop.get_attribute_unit_enum_items(metadata, bpy.context)
assert direct_items == wrapper_items
def test_returns_just_default_when_ifc_file_is_none(self):
assert bonsai.bim.prop.get_unit_enum_items_for_special_type("LENGTH", None) == [("0", "Default", "")]
class TestUnitSymbolWithAreaVolumeDerivedFromLength(NewFile):
"""Regression test: AREAUNIT/VOLUMEUNIT have no IfcDerivedUnitEnum member, so a project
whose area/volume default is an IfcDerivedUnit rather than a literal-UnitType-matching
IfcSIUnit/IfcConversionBasedUnit has no literal UnitType to match on.
ifcopenshell.util.unit.get_project_unit() used to only match by literal UnitType, so the
read-only unit symbol and the edit-mode "Default (<symbol>)" picker entry both silently
fell back to no symbol at all in that case.
"""
def setup_project_with_derived_area_and_volume(self, ifc):
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
length = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
area = ifcopenshell.api.unit.add_derived_unit(ifc, "USERDEFINED", "area-ish", {length: 2})
volume = ifcopenshell.api.unit.add_derived_unit(ifc, "USERDEFINED", "volume-ish", {length: 3})
ifcopenshell.api.unit.assign_unit(ifc, units=[length, area, volume])
def test_default_picker_entry_shows_the_resolved_symbol(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
self.setup_project_with_derived_area_and_volume(ifc)
element = ifc.createIfcWall()
prop = ifc.createIfcPropertySingleValue(Name="Foo", NominalValue=ifc.createIfcAreaMeasure(5.0))
metadata = import_single_property(ifc, element, prop)
items = bonsai.bim.prop.get_attribute_unit_enum_items(metadata, bpy.context)
assert items[0][1] == "Default (m2)"
def test_read_only_display_resolves_the_symbol(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
self.setup_project_with_derived_area_and_volume(ifc)
element = ifc.createIfcWall()
prop = ifc.createIfcPropertySingleValue(Name="Foo", NominalValue=ifc.createIfcVolumeMeasure(5.0))
metadata = import_single_property(ifc, element, prop)
assert metadata.unit_symbol == "m3"
assert metadata.display_name == "Foo, m3"
class TestUpdateAttributeUnitId(NewFile):
def test_syncs_unit_id_and_converts_float_value_when_unit_id_enum_changes(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
length_mm = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT", prefix="MILLI")
ifcopenshell.api.unit.assign_unit(ifc, units=[length_mm])
length_m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
element = ifc.createIfcWall()
prop = ifc.createIfcPropertySingleValue(Name="Foo", NominalValue=ifc.createIfcLengthMeasure(2500.0))
metadata = import_single_property(ifc, element, prop)
assert metadata.unit_id == 0
assert metadata.float_value == 2500.0
assert metadata.unit_symbol == "mm"
assert metadata.display_name == "Foo, mm"
metadata.unit_id_enum = str(length_m.id())
assert metadata.unit_id == length_m.id()
assert metadata.float_value == pytest.approx(2.5) # converted, not just relabeled
# Regression test: unit_symbol/display_name used to be a snapshot taken once at import
# time, so picking a different unit converted the value but left the label showing the
# old unit's symbol.
assert metadata.unit_symbol == "m"
assert metadata.display_name == "Foo, m"
class TestUnitSymbolReflectsLiveProjectState(NewFile):
def test_symbol_updates_after_a_project_default_unit_is_assigned_later(self):
# Regression test: unit_symbol used to be a snapshot computed once at import time, so a
# property/quantity imported before its measure type had a project default unit assigned
# kept showing no symbol even after one was added, unless the panel was closed and
# reopened (re-triggering import). It's now computed fresh on every access.
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
# No AREAUNIT assigned yet.
element = ifc.createIfcWall()
prop = ifc.createIfcPropertySingleValue(Name="Foo", NominalValue=ifc.createIfcAreaMeasure(5.0))
metadata = import_single_property(ifc, element, prop)
assert metadata.unit_symbol == ""
assert metadata.display_name == "Foo"
area = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="AREAUNIT")
ifcopenshell.api.unit.assign_unit(ifc, units=[area])
assert metadata.unit_symbol == "m2"
assert metadata.display_name == "Foo, m2"
class TestImportPsetFromExistingWithAGenericNumericValueAndAnExplicitUnit(NewFile):
def test_run(self):
# Regression test: some property set specifications declare a property as a generic
# IfcReal rather than a proper measure class, relying on an explicit Unit attribute
# alone to convey the dimension. get_property_unit() already handled this fine for
# display (it checks prop.Unit before looking at NominalValue's type at all), but
# get_special_type_for_prop() only looked at NominalValue's class ending in "Measure"
# -- so special_type came back "", the picker never appeared, and the real Unit
# override never got seeded into unit_id even though it was legitimately set.
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
length_mm = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT", prefix="MILLI")
element = ifc.createIfcWall()
prop = ifc.createIfcPropertySingleValue(
Name="Foo", NominalValue=ifc.createIfcReal(150.0), Unit=length_mm
)
metadata = import_single_property(ifc, element, prop)
assert metadata.special_type == "LENGTH"
assert tool.Pset.is_measurable_special_type(metadata.special_type)
assert metadata.unit_symbol == "mm"
assert metadata.unit_id == length_mm.id()
assert metadata.unit_id_enum == str(length_mm.id())
items = bonsai.bim.prop.get_attribute_unit_enum_items(metadata, bpy.context)
assert str(length_mm.id()) in [i[0] for i in items]
class TestImportPsetFromExistingWithAStrayUnitOnANonMeasureProperty(NewFile):
def test_run(self):
# Regression test: some real-world exporters set a Unit on a property whose
# NominalValue isn't actually a measure (e.g. a text classification), which used
# to crash import_pset_from_existing with "enum '<id>' not found in ('0')" -- unit_id
# was seeded from prop.Unit unconditionally, before the special_type gate that decides
# whether Unit is even meaningful for this property.
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
length_m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
element = ifc.createIfcWall()
prop = ifc.createIfcPropertySingleValue(
Name="Foo", NominalValue=ifc.createIfcLabel("Bar"), Unit=length_m
)
metadata = import_single_property(ifc, element, prop) # must not raise
assert metadata.special_type == ""
assert metadata.unit_id == 0
assert metadata.unit_id_enum == "0"
class TestGetAttributeUnitEnumItemsWithAMismatchedUnit(NewFile):
def test_own_unit_is_always_representable_even_if_not_a_normal_candidate(self):
# Regression test: a property's own Unit might not satisfy
# get_candidate_units_for_special_type's matching (e.g. mismatched UnitType in messy
# real-world data). Seeding must never crash trying to select it, and it should still
# show up in the picker so the user can see/change it.
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
# A LENGTHUNIT attached to a PRESSURE-typed property -- a real mismatch, not a candidate
# get_candidate_units_for_special_type("PRESSURE", ...) would ever return.
mismatched_unit = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
element = ifc.createIfcWall()
prop = ifc.createIfcPropertySingleValue(
Name="Foo", NominalValue=ifc.createIfcPressureMeasure(5.0), Unit=mismatched_unit
)
metadata = import_single_property(ifc, element, prop) # must not raise
assert metadata.special_type == "PRESSURE"
assert metadata.unit_id == mismatched_unit.id()
assert metadata.unit_id_enum == str(mismatched_unit.id())
items = bonsai.bim.prop.get_attribute_unit_enum_items(metadata, bpy.context)
assert str(mismatched_unit.id()) in [i[0] for i in items]
+340
View File
@@ -20,6 +20,9 @@ import bpy
import ifcopenshell
import ifcopenshell.api
import ifcopenshell.api.pset
import ifcopenshell.api.root
import ifcopenshell.api.unit
import pytest
import bonsai.core.tool
import bonsai.tool as tool
@@ -52,3 +55,340 @@ class TestIsPsetEmpty(NewFile):
assert subject.is_pset_empty(pset) is False
ifcopenshell.api.pset.edit_pset(ifc, pset=pset, properties={"Foo": None})
assert subject.is_pset_empty(pset) is True
class TestEditingAnOverriddenUnitPropertyRoundTrips(NewFile):
def test_run(self):
# Project default is mm, but this property is authored directly in m.
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
length_mm = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT", prefix="MILLI")
ifcopenshell.api.unit.assign_unit(ifc, units=[length_mm])
length_m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
element = ifc.createIfcWall()
pset = ifcopenshell.api.pset.add_pset(ifc, product=element, name="Pset_Test")
prop = ifc.createIfcPropertySingleValue(
Name="Foo", NominalValue=ifc.createIfcLengthMeasure(2.5), Unit=length_m
)
pset.HasProperties = [prop]
obj = bpy.data.objects.new("Wall", None)
tool.Ifc.link(element, obj)
blender_props = obj.PsetProperties
subject.import_pset_from_existing(pset, blender_props, None)
metadata = blender_props.properties["Foo"].metadata
assert metadata.unit_symbol == "m"
assert metadata.float_value == 2.5 # raw stored value, not rescaled to the project's mm
# Simulate a user edit in the property editor.
metadata.float_value = 3.5
# Simulate what EditPset.execute() does: collect the raw value straight
# off the metadata and write it back, with no rescaling step.
properties = {"Foo": metadata.get_value()}
ifcopenshell.api.pset.edit_pset(ifc, pset=pset, properties=properties)
assert prop.NominalValue.wrappedValue == 3.5 # not rescaled to 3500mm
assert prop.Unit == length_m # override preserved
class TestImportingATemplatedQuantityRespectsItsOwnUnitOverride(NewFile):
def test_run(self):
# Regression test: import_pset_from_template's Q_ branch used to
# unconditionally re-template existing quantities, which shadowed
# their own Unit override with the project default -- edit mode
# showed "m" while the read-only panel correctly showed "mm".
# Project default is m, but this quantity is authored directly in mm.
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
length_m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
ifcopenshell.api.unit.assign_unit(ifc, units=[length_m])
length_mm = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT", prefix="MILLI")
element = ifc.createIfcBeam()
qto = ifcopenshell.api.pset.add_qto(ifc, product=element, name="Qto_Test")
quantity = ifc.createIfcQuantityLength(Name="Foo", Unit=length_mm, LengthValue=2500.0)
qto.Quantities = [quantity]
pset_template = ifc.createIfcPropertySetTemplate(
Name="Qto_Test",
TemplateType="PSET_TYPEDRIVENOVERRIDE",
ApplicableEntity="IfcBeam",
HasPropertyTemplates=[ifc.createIfcSimplePropertyTemplate(Name="Foo", TemplateType="Q_LENGTH")],
)
obj = bpy.data.objects.new("Beam", None)
tool.Ifc.link(element, obj)
blender_props = obj.PsetProperties
# Mirrors core/pset.py's enable_pset_editing: template pass, then existing-data pass.
subject.import_pset_from_template(pset_template, qto, blender_props)
subject.import_pset_from_existing(qto, blender_props, pset_template)
assert len(blender_props.properties) == 1 # not duplicated by the template pass
metadata = blender_props.properties["Foo"].metadata
assert metadata.unit_symbol == "mm" # the quantity's own override, not the project default "m"
assert metadata.float_value == 2500.0 # raw stored value, not rescaled
class TestIsMeasurableSpecialType(NewFile):
def test_run(self):
for special_type in ("", "DATE", "DATETIME", "LOGICAL", "URI", "DURATION"):
assert subject.is_measurable_special_type(special_type) is False
assert subject.is_measurable_special_type("LENGTH") is True
assert subject.is_measurable_special_type("PRESSURE") is True
class TestGetCandidateUnitsForSpecialType(NewFile):
def test_returns_candidates_matching_the_special_type(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
length_mm = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT", prefix="MILLI")
length_m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
ifcopenshell.api.unit.add_si_unit(ifc, unit_type="AREAUNIT")
assert set(subject.get_candidate_units_for_special_type("LENGTH", ifc)) == {length_mm, length_m}
def test_gating_returns_empty_for_non_measurable_special_types(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
assert subject.get_candidate_units_for_special_type("", ifc) == []
assert subject.get_candidate_units_for_special_type("URI", ifc) == []
class TestResolveEffectiveUnit(NewFile):
def test_own_override_takes_precedence_over_project_default(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
length_mm = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT", prefix="MILLI")
ifcopenshell.api.unit.assign_unit(ifc, units=[length_mm])
length_m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
assert subject.resolve_effective_unit("LENGTH", length_m.id(), ifc) == length_m
def test_falls_back_to_project_default_when_unit_id_is_zero(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
length_mm = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT", prefix="MILLI")
ifcopenshell.api.unit.assign_unit(ifc, units=[length_mm])
assert subject.resolve_effective_unit("LENGTH", 0, ifc) == length_mm
class TestConvertAttributeUnit(NewFile):
def _new_metadata(self, ifc: ifcopenshell.file):
element = ifc.createIfcWall()
obj = bpy.data.objects.new("Wall", None)
tool.Ifc.link(element, obj)
props = obj.PsetProperties
new_prop = props.properties.add()
new_prop.name = "Foo"
return new_prop.metadata
def test_converts_value_between_two_explicit_units(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
length_mm = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT", prefix="MILLI")
length_m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
metadata = self._new_metadata(ifc)
metadata.special_type = "LENGTH"
metadata.unit_id = length_mm.id()
metadata.float_value = 2500.0
subject.convert_attribute_unit(metadata, length_m.id(), ifc)
assert metadata.float_value == pytest.approx(2.5)
def test_converts_value_when_switching_to_and_from_the_project_default(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
length_m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
ifcopenshell.api.unit.assign_unit(ifc, units=[length_m])
length_ft = ifcopenshell.api.unit.add_conversion_based_unit(ifc, name="foot")
metadata = self._new_metadata(ifc)
metadata.special_type = "LENGTH"
metadata.unit_id = length_ft.id()
metadata.float_value = 10.0 # 10 ft
subject.convert_attribute_unit(metadata, 0, ifc) # 0 = switch to project default (m)
assert metadata.float_value == pytest.approx(3.048)
def test_noop_when_old_and_new_resolve_to_the_same_unit(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
length_m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
ifcopenshell.api.unit.assign_unit(ifc, units=[length_m])
metadata = self._new_metadata(ifc)
metadata.special_type = "LENGTH"
metadata.unit_id = 0 # already resolves to length_m (the project default)
metadata.float_value = 5.0
subject.convert_attribute_unit(metadata, length_m.id(), ifc)
assert metadata.float_value == 5.0
def test_noop_for_a_non_measurable_special_type(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
length_m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
metadata = self._new_metadata(ifc)
metadata.special_type = ""
metadata.unit_id = 0
metadata.float_value = 5.0
subject.convert_attribute_unit(metadata, length_m.id(), ifc)
assert metadata.float_value == 5.0
def _build_wrapped_properties_from_ui(blender_props) -> dict:
"""Mirrors EditPset._execute()'s properties-building loop (operator.py)."""
properties = {}
for entry in blender_props.properties:
metadata = entry.metadata
value = metadata.get_value()
if value is not None and subject.is_measurable_special_type(metadata.special_type):
unit = tool.Ifc.get().by_id(metadata.unit_id) if metadata.unit_id else None
value = {"NominalValue": value, "Unit": unit}
properties[metadata.name] = value
return properties
class TestEditPsetWithUnitOverridePicker(NewFile):
def test_picking_a_different_unit_converts_the_displayed_value_and_writes_it_back(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
length_mm = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT", prefix="MILLI")
ifcopenshell.api.unit.assign_unit(ifc, units=[length_mm])
length_m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
element = ifc.createIfcWall()
pset = ifcopenshell.api.pset.add_pset(ifc, product=element, name="Pset_Test")
prop = ifc.createIfcPropertySingleValue(Name="Foo", NominalValue=ifc.createIfcLengthMeasure(2500.0))
pset.HasProperties = [prop]
obj = bpy.data.objects.new("Wall", None)
tool.Ifc.link(element, obj)
blender_props = obj.PsetProperties
subject.import_pset_from_existing(pset, blender_props, None)
metadata = blender_props.properties["Foo"].metadata
assert metadata.unit_id == 0
assert metadata.float_value == 2500.0
# Simulate the user picking "m" in the unit picker dropdown.
metadata.unit_id_enum = str(length_m.id())
assert metadata.float_value == pytest.approx(2.5) # converted live, not just relabeled
assert metadata.unit_id == length_m.id()
properties = _build_wrapped_properties_from_ui(blender_props)
ifcopenshell.api.pset.edit_pset(ifc, pset=pset, properties=properties)
assert prop.NominalValue.wrappedValue == pytest.approx(2.5)
assert prop.Unit == length_m
def test_picking_default_after_an_override_converts_back_and_clears_the_unit(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
length_mm = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT", prefix="MILLI")
ifcopenshell.api.unit.assign_unit(ifc, units=[length_mm])
length_m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
element = ifc.createIfcWall()
pset = ifcopenshell.api.pset.add_pset(ifc, product=element, name="Pset_Test")
prop = ifc.createIfcPropertySingleValue(
Name="Foo", NominalValue=ifc.createIfcLengthMeasure(2.5), Unit=length_m
)
pset.HasProperties = [prop]
obj = bpy.data.objects.new("Wall", None)
tool.Ifc.link(element, obj)
blender_props = obj.PsetProperties
subject.import_pset_from_existing(pset, blender_props, None)
metadata = blender_props.properties["Foo"].metadata
assert metadata.unit_id == length_m.id()
assert metadata.float_value == 2.5
# Simulate picking "Default" (mm).
metadata.unit_id_enum = "0"
assert metadata.float_value == pytest.approx(2500.0)
assert metadata.unit_id == 0
properties = _build_wrapped_properties_from_ui(blender_props)
ifcopenshell.api.pset.edit_pset(ifc, pset=pset, properties=properties)
assert prop.Unit is None
assert prop.NominalValue.wrappedValue == pytest.approx(2500.0)
def test_editing_an_unrelated_sibling_property_does_not_disturb_this_ones_override(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
length_m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
ifcopenshell.api.unit.assign_unit(ifc, units=[length_m])
length_ft = ifcopenshell.api.unit.add_conversion_based_unit(ifc, name="foot")
element = ifc.createIfcWall()
pset = ifcopenshell.api.pset.add_pset(ifc, product=element, name="Pset_Test")
overridden_prop = ifc.createIfcPropertySingleValue(
Name="Foo", NominalValue=ifc.createIfcLengthMeasure(10.0), Unit=length_ft
)
untouched_prop = ifc.createIfcPropertySingleValue(Name="Bar", NominalValue=ifc.createIfcLengthMeasure(3.0))
pset.HasProperties = [overridden_prop, untouched_prop]
obj = bpy.data.objects.new("Wall", None)
tool.Ifc.link(element, obj)
blender_props = obj.PsetProperties
subject.import_pset_from_existing(pset, blender_props, None)
# Edit only "Bar", never touching "Foo"'s unit dropdown.
blender_props.properties["Bar"].metadata.float_value = 4.0
properties = _build_wrapped_properties_from_ui(blender_props)
ifcopenshell.api.pset.edit_pset(ifc, pset=pset, properties=properties)
assert overridden_prop.Unit == length_ft # untouched override survives
assert overridden_prop.NominalValue.wrappedValue == 10.0
assert untouched_prop.NominalValue.wrappedValue == 4.0
class TestEditQtoRoundingLoopPreservesUnitWrappedValues(NewFile):
def test_run(self):
# Regression test for EditPset._execute()'s qto post-processing loop: it must reach
# into {"Unit": ..., "NominalValue": ...}-wrapped values to round them, rather than
# treating the whole dict as a bare float/int (which would zero it out).
properties = {
"Foo": {"NominalValue": 2.123456, "Unit": None},
"Bar": 3,
}
for key, value in properties.items():
if value is None:
continue
is_wrapped = isinstance(value, dict) and "Unit" in value
raw = value["NominalValue"] if is_wrapped else value
if raw is None:
continue
if isinstance(raw, float):
raw = round(raw, 4)
elif not isinstance(raw, int):
raw = 0
if is_wrapped:
value["NominalValue"] = raw
else:
properties[key] = raw
assert properties["Foo"]["NominalValue"] == 2.1235
assert properties["Foo"]["Unit"] is None
assert properties["Bar"] == 3
+36
View File
@@ -181,6 +181,42 @@ class TestGetCalculatedObjectQuantities(test.bim.bootstrap.NewFile):
assert quantities["NetVolume"] == 282.517
class TestGetTargetUnits(test.bim.bootstrap.NewFile):
def setup_file(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject", name="Test")
return ifc
def test_default_scene_state_returns_nothing(self):
self.setup_file()
assert subject.get_target_units() == {}
def test_setting_a_field_maps_it_to_its_measure_class(self):
ifc = self.setup_file()
metre = ifc.createIfcSIUnit(None, "LENGTHUNIT", None, "METRE")
millimetre = ifc.createIfcSIUnit(None, "LENGTHUNIT", "MILLI", "METRE")
ifcopenshell.api.unit.assign_unit(ifc, units=[metre])
props = tool.Qto.get_qto_props()
props.target_unit_length = str(millimetre.id())
assert subject.get_target_units() == {"IfcLengthMeasure": millimetre}
def test_untouched_fields_are_excluded(self):
ifc = self.setup_file()
metre = ifc.createIfcSIUnit(None, "LENGTHUNIT", None, "METRE")
millimetre = ifc.createIfcSIUnit(None, "LENGTHUNIT", "MILLI", "METRE")
gram = ifc.createIfcSIUnit(None, "MASSUNIT", None, "GRAM")
ifcopenshell.api.unit.assign_unit(ifc, units=[metre, gram])
props = tool.Qto.get_qto_props()
props.target_unit_length = str(millimetre.id())
props.target_unit_mass = "0" # explicitly left at "Default"
assert subject.get_target_units() == {"IfcLengthMeasure": millimetre}
class TestGetBaseQto(test.bim.bootstrap.NewFile):
def test_run(self):
ifc = ifcopenshell.file()
+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}"
+86 -4
View File
@@ -24,7 +24,7 @@ import os
import types
from collections import defaultdict
from collections.abc import Iterable
from typing import Any, Literal, NamedTuple, Union, get_args
from typing import Any, Literal, NamedTuple, Optional, Union, get_args
import ifcopenshell
import ifcopenshell.api.pset
@@ -127,8 +127,61 @@ def quantify(ifc_file: ifcopenshell.file, elements: set[ifcopenshell.entity_inst
return results
def edit_qtos(ifc_file: ifcopenshell.file, results: ResultsDict) -> None:
"""Apply quantification results as quantity sets."""
def get_quantity_measures(rules: dict) -> dict[str, dict[str, str]]:
"""Statically derive each quantity's measure class from the rule set that defines it,
reading it straight from the calculator's own Function table (the same source the
calculator itself used to compute the value) -- not guessed from the quantity name.
:param rules: A rule set as accepted by :func:`quantify`, e.g. from `ifc5d.qto.rules`.
:return: `qto_name -> quantity_name -> measure class` (e.g. "IfcLengthMeasure"), matching
the keys used by `SI2ProjectUnitConverter.project_units`.
"""
measures: dict[str, dict[str, str]] = {}
for calculator_name, queries in rules.get("calculators", {}).items():
calculator = calculators[calculator_name]
for _entity_or_query, qtos in queries.items():
for qto_name, quantities in qtos.items():
for quantity_name, formula in quantities.items():
if not formula:
continue
function = calculator.functions.get(formula)
if function is None:
continue
measures.setdefault(qto_name, {})[quantity_name] = function.measure
return measures
def _reconvert(ifc_file: ifcopenshell.file, value: float, to_unit: ifcopenshell.entity_instance) -> float:
"""Re-express `value` (as computed by `SI2ProjectUnitConverter` -- the project's default
unit for its dimension, or, if the project has none, raw SI, mirroring `convert()`'s own
fallback below) in `to_unit`, which shares `to_unit`'s dimension (`UnitType`).
"""
unit_type = getattr(to_unit, "UnitType", None)
from_unit = ifcopenshell.util.unit.get_project_unit(ifc_file, unit_type) if unit_type else None
from_scale = ifcopenshell.util.unit.get_unit_scale(from_unit) if from_unit else 1.0 # already SI
return value * from_scale / ifcopenshell.util.unit.get_unit_scale(to_unit)
def edit_qtos(
ifc_file: ifcopenshell.file,
results: ResultsDict,
target_units: Optional[dict[str, ifcopenshell.entity_instance]] = None,
rules: Optional[dict] = None,
) -> None:
"""Apply quantification results as quantity sets.
:param target_units: Optional map of measure class (e.g. "IfcLengthMeasure", matching
`SI2ProjectUnitConverter.project_units`'s keys) to a unit to express *newly created*
quantities of that measure in, instead of the project default. Ignored unless `rules`
is also given (needed to resolve each quantity's measure class -- see
`get_quantity_measures`). Has no effect on quantities that already exist -- those are
always re-expressed in whatever Unit they already carry (see below), regardless of
`target_units`.
:param rules: The rule set used to produce `results` (the same object passed to
`quantify()`), used only to resolve `target_units` via `get_quantity_measures()`.
"""
quantity_measures = get_quantity_measures(rules) if (target_units and rules) else {}
for element, qtos in results.items():
for name, quantities in qtos.items():
qto = ifcopenshell.util.element.get_pset(element, name, should_inherit=False)
@@ -136,7 +189,36 @@ def edit_qtos(ifc_file: ifcopenshell.file, results: ResultsDict) -> None:
qto = ifc_file.by_id(qto["id"])
else:
qto = ifcopenshell.api.pset.add_qto(ifc_file, element, name)
ifcopenshell.api.pset.edit_qto(ifc_file, qto=qto, properties=quantities)
existing_by_name = {q.Name: q for q in (qto.Quantities or ())}
wrapped_quantities: dict[str, Any] = {}
for quantity_name, value in quantities.items():
existing_unit = getattr(existing_by_name.get(quantity_name), "Unit", None)
if existing_unit is not None:
# A quantity that already carries its own Unit override must be
# re-expressed in that unit, not overwritten with a value computed in
# the project default while the stale Unit label stays put.
wrapped_quantities[quantity_name] = {
"NominalValue": _reconvert(ifc_file, value, existing_unit),
"Unit": existing_unit,
}
continue
measure = quantity_measures.get(name, {}).get(quantity_name)
target_unit = target_units.get(measure) if (target_units and measure) else None
if target_unit is not None:
# Brand new quantity, proactively expressed in the chosen target unit.
wrapped_quantities[quantity_name] = {
"NominalValue": _reconvert(ifc_file, value, target_unit),
"Unit": target_unit,
}
continue
wrapped_quantities[quantity_name] = value # unchanged bare-float path
ifcopenshell.api.pset.edit_qto(ifc_file, qto=qto, properties=wrapped_quantities)
class SI2ProjectUnitConverter:
+139
View File
@@ -22,6 +22,7 @@ import ifcopenshell
import ifcopenshell.api.context
import ifcopenshell.api.root
import ifcopenshell.api.unit
import ifcopenshell.util.element
import pytest
import ifc5d.qto
@@ -90,3 +91,141 @@ class TestOpeningQuantities:
assert quantities["Depth"] == pytest.approx(0.3)
assert quantities["Area"] == pytest.approx(0.5)
assert quantities["Volume"] == pytest.approx(0.15)
class TestGetQuantityMeasures:
def test_resolves_measures_from_the_calculator_function_table(self):
measures = ifc5d.qto.get_quantity_measures(ifc5d.qto.rules["IFC4X3QtoBaseQuantities"])
assert measures["Qto_WallBaseQuantities"]["Length"] == "IfcLengthMeasure"
assert measures["Qto_WallBaseQuantities"]["NetWeight"] == "IfcMassMeasure"
class TestEditQtos:
def setup_method(self):
self.file = ifcopenshell.file(schema="IFC4X3")
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject", name="Test")
self.wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
def get_quantity(self, name: str) -> ifcopenshell.entity_instance:
pset = ifcopenshell.util.element.get_pset(self.wall, "Qto_WallBaseQuantities", should_inherit=False)
qto = self.file.by_id(pset["id"])
return next(q for q in qto.Quantities if q.Name == name)
def test_new_quantity_with_no_target_unit_is_a_bare_value(self):
metre = self.file.createIfcSIUnit(None, "LENGTHUNIT", None, "METRE")
ifcopenshell.api.unit.assign_unit(self.file, units=[metre])
ifc5d.qto.edit_qtos(self.file, {self.wall: {"Qto_WallBaseQuantities": {"Length": 5.0}}})
quantity = self.get_quantity("Length")
assert quantity.LengthValue == pytest.approx(5.0)
assert quantity.Unit is None
def test_existing_manual_unit_override_is_reconverted_not_left_stale(self):
metre = self.file.createIfcSIUnit(None, "LENGTHUNIT", None, "METRE")
ifcopenshell.api.unit.assign_unit(self.file, units=[metre])
millimetre = self.file.createIfcSIUnit(None, "LENGTHUNIT", "MILLI", "METRE")
ifc5d.qto.edit_qtos(self.file, {self.wall: {"Qto_WallBaseQuantities": {"Length": 5.0}}})
quantity = self.get_quantity("Length")
# Simulate a user picking a millimetre override via the per-property picker.
quantity.Unit = millimetre
quantity.LengthValue = 5000.0
# Re-running take-off recomputes the value in the project default (metres) again --
# this must not leave the recomputed metres value mislabeled as millimetres.
ifc5d.qto.edit_qtos(self.file, {self.wall: {"Qto_WallBaseQuantities": {"Length": 6.0}}})
quantity = self.get_quantity("Length")
assert quantity.Unit == millimetre
assert quantity.LengthValue == pytest.approx(6000.0)
def test_target_unit_applies_only_to_brand_new_quantities(self):
metre = self.file.createIfcSIUnit(None, "LENGTHUNIT", None, "METRE")
ifcopenshell.api.unit.assign_unit(self.file, units=[metre])
millimetre = self.file.createIfcSIUnit(None, "LENGTHUNIT", "MILLI", "METRE")
rules = {"calculators": {"IfcOpenShell": {"IfcWall": {"Qto_WallBaseQuantities": {"Length": "net_get_x"}}}}}
ifc5d.qto.edit_qtos(
self.file,
{self.wall: {"Qto_WallBaseQuantities": {"Length": 5.0}}},
target_units={"IfcLengthMeasure": millimetre},
rules=rules,
)
quantity = self.get_quantity("Length")
assert quantity.Unit == millimetre
assert quantity.LengthValue == pytest.approx(5000.0)
def test_target_units_are_ignored_without_rules(self):
metre = self.file.createIfcSIUnit(None, "LENGTHUNIT", None, "METRE")
ifcopenshell.api.unit.assign_unit(self.file, units=[metre])
millimetre = self.file.createIfcSIUnit(None, "LENGTHUNIT", "MILLI", "METRE")
# `rules` is required to resolve a quantity's measure class -- without it, target_units
# has nothing to key off, so brand new quantities fall back to today's bare-float path.
ifc5d.qto.edit_qtos(
self.file,
{self.wall: {"Qto_WallBaseQuantities": {"Length": 5.0}}},
target_units={"IfcLengthMeasure": millimetre},
)
quantity = self.get_quantity("Length")
assert quantity.Unit is None
assert quantity.LengthValue == pytest.approx(5.0)
def test_reconvert_treats_a_missing_project_default_as_raw_si(self):
# No LENGTHUNIT is assigned to the project at all, so SI2ProjectUnitConverter.convert()
# would have left the calculated value as raw SI (metres) -- _reconvert must match.
millimetre = self.file.createIfcSIUnit(None, "LENGTHUNIT", "MILLI", "METRE")
rules = {"calculators": {"IfcOpenShell": {"IfcWall": {"Qto_WallBaseQuantities": {"Length": "net_get_x"}}}}}
ifc5d.qto.edit_qtos(
self.file,
{self.wall: {"Qto_WallBaseQuantities": {"Length": 5.0}}},
target_units={"IfcLengthMeasure": millimetre},
rules=rules,
)
quantity = self.get_quantity("Length")
assert quantity.LengthValue == pytest.approx(5000.0)
class TestEditQtosIntegration:
"""A real quantify() + edit_qtos() round trip, guarding against edit_qto's own
class-inference disagreeing with get_quantity_measures()'s notion of measure.
"""
def test_target_unit_produces_the_correct_quantity_class(self):
file = ifcopenshell.file(schema="IFC4X3")
ifcopenshell.api.root.create_entity(file, ifc_class="IfcProject", name="Test")
metre = file.createIfcSIUnit(None, "LENGTHUNIT", None, "METRE")
sqm = file.createIfcSIUnit(None, "AREAUNIT", None, "SQUARE_METRE")
cum = file.createIfcSIUnit(None, "VOLUMEUNIT", None, "CUBIC_METRE")
ifcopenshell.api.unit.assign_unit(file, units=[metre, sqm, cum])
model = ifcopenshell.api.context.add_context(file, context_type="Model")
body = ifcopenshell.api.context.add_context(
file, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=model
)
wall = ifcopenshell.api.root.create_entity(file, ifc_class="IfcWall")
wall.ObjectPlacement = file.createIfcLocalPlacement(
None, file.createIfcAxis2Placement3D(file.createIfcCartesianPoint((0.0, 0.0, 0.0)), None, None)
)
profile = file.createIfcRectangleProfileDef("AREA", None, None, 5.0, 0.2)
position = file.createIfcAxis2Placement3D(file.createIfcCartesianPoint((0.0, 0.0, 0.0)), None, None)
solid = file.createIfcExtrudedAreaSolid(profile, position, file.createIfcDirection((0.0, 0.0, 1.0)), 3.0)
rep = file.createIfcShapeRepresentation(body, "Body", "SweptSolid", [solid])
wall.Representation = file.createIfcProductDefinitionShape(None, None, [rep])
millimetre = file.createIfcSIUnit(None, "LENGTHUNIT", "MILLI", "METRE")
rules = ifc5d.qto.rules["IFC4X3QtoBaseQuantities"]
results = ifc5d.qto.quantify(file, {wall}, rules)
ifc5d.qto.edit_qtos(file, results, target_units={"IfcLengthMeasure": millimetre}, rules=rules)
pset = ifcopenshell.util.element.get_pset(wall, "Qto_WallBaseQuantities", should_inherit=False)
qto = file.by_id(pset["id"])
length = next(q for q in qto.Quantities if q.Name == "Length")
assert length.is_a("IfcQuantityLength")
assert length.Unit == millimetre
assert length.LengthValue == pytest.approx(5000.0)
+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.
@@ -25,7 +25,7 @@ are automatically created and maintained.
Alignments are created with stationing referents. Each layout segment is assigned a position referent that informs about
the start point of the segment. An example is the point of curvature of a horizontal circular curve. The referent is
nested to the segment representing the circular arc and is named with a indicator of the position and the station, e.g. "P.C. (145+98.32)"
nested to the segment representing the circular arc and is named with the alignment name and an indicator of the position and the station, e.g. "MyAlignment 145+98.32 (P.C.)"
This API does not determine alignment parameters based on rules, such as minimum curve radius as a function of design speed or sight distance.
@@ -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 (
@@ -89,8 +89,10 @@ from .layout_vertical_alignment_by_pi_method import (
layout_vertical_alignment_by_pi_method,
)
from .name_segments import name_segments
from .update_alignment_parameter_segment_tags import update_alignment_parameter_segment_tags
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,14 +126,16 @@ __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",
"layout_vertical_alignment_by_pi_method",
"name_segments",
"register_referent_name_callback",
"update_alignment_parameter_segment_tags",
"update_end_point",
"update_fallback_position",
"update_key_point_referents",
"get_mapped_segments",
]
@@ -0,0 +1,30 @@
# 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/>.
import ifcopenshell
import ifcopenshell.util.alignment
def _get_key_point_tag(file: ifcopenshell.file, label: str, station: float) -> str:
"""
Builds the station-and-label text shared by update_alignment_parameter_segment_tags (used
directly as IfcAlignmentParameterSegment.StartTag/EndTag) and update_key_point_referents (used,
prefixed with the alignment name, as IfcReferent.Name): "<station> (<label>)", e.g.
"145+98.32 (P.O.B.)".
"""
return f"{ifcopenshell.util.alignment.station_as_string(file, station)} ({label})"
@@ -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,108 @@
# 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/>.
import ifcopenshell
import ifcopenshell.api.alignment
from ifcopenshell import entity_instance
from ifcopenshell.api.alignment._get_key_point_tag import _get_key_point_tag
from ifcopenshell.api.alignment._get_segment_start_point_label import (
_get_segment_start_point_label,
)
def update_alignment_parameter_segment_tags(
file: ifcopenshell.file, layout: entity_instance, label_end_tag: bool = False
) -> None:
"""
Sets IfcAlignmentParameterSegment.StartTag (and, optionally, EndTag) for every segment
transition in an alignment layout. Unlike update_key_point_referents, this does not create any
IfcReferent or IfcRelNests -- it only mutates the StartTag/EndTag string attributes already
present on each segment's DesignParameters.
Every real segment's StartTag is set to a computed tag describing the point where it begins,
using the same label-and-station format as update_key_point_referents' Name minus the alignment
name (via _get_key_point_tag), e.g. "145+98.32 (P.C.)". The first segment's StartTag comes from
the "Beginning of Alignment" boundary label.
EndTag is left untouched unless `label_end_tag` is True. When enabled, for each transition
between two consecutive segments, the outgoing segment's EndTag is set to the same tag as the
incoming segment's StartTag (they describe the same physical point), and the last segment's
EndTag is set from the "End of Alignment" boundary label.
Labels come from _get_segment_start_point_label -- if a callback has been registered via
register_referent_name_callback(), its output is used instead of the built-in labels, exactly as
in update_key_point_referents.
:param layout: IfcAlignmentHorizontal, IfcAlignmentVertical, or IfcAlignmentCant
:param label_end_tag: if True, also sets EndTag on every real segment. If False (default),
EndTag is left untouched.
:return: None -- this function mutates segment.DesignParameters.StartTag/EndTag in place
Example:
.. code:: python
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
ifcopenshell.api.alignment.update_alignment_parameter_segment_tags(model, horizontal)
"""
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.")
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:
return
start_station = ifcopenshell.api.alignment.get_alignment_start_station(file, alignment)
is_horizontal = layout.is_a("IfcAlignmentHorizontal")
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
tag = _get_key_point_tag(file, label, station)
dp.StartTag = tag
if prev_segment is not None and label_end_tag:
prev_segment.DesignParameters.EndTag = tag
if is_horizontal:
distance_along += dp.SegmentLength
else:
distance_along = dp.StartDistAlong + dp.HorizontalLength
prev_segment = segment
if label_end_tag:
label = _get_segment_start_point_label(prev_segment, None)
station = start_station + distance_along
prev_segment.DesignParameters.EndTag = _get_key_point_tag(file, label, station)
@@ -0,0 +1,217 @@
# 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.element
from ifcopenshell import entity_instance
from ifcopenshell.api.alignment._get_key_point_tag import _get_key_point_tag
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"{alignment.Name} {_get_key_point_tag(file, label, 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.", ...), and combined with the alignment name and station to build the Name, e.g.
"MyAlignment 145+98.32 (P.C.)". 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 ends 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
@@ -23,6 +23,10 @@ import ifcopenshell
import ifcopenshell.util.element
import ifcopenshell.util.pset
# Sentinel distinguishing "no Unit dict was passed at all" from "a Unit dict was passed
# with Unit explicitly set to None" (i.e. explicitly clear an existing override).
_NO_UNIT = object()
def edit_pset(
file: ifcopenshell.file,
@@ -285,7 +289,7 @@ class Usecase:
f'Value "{self.settings["properties"][prop.Name]}" is not a valid value for enum property {prop.Name}.'
)
if unit:
if unit is not _NO_UNIT:
prop.Unit = unit
del self.settings["properties"][prop.Name]
return prop
@@ -310,7 +314,7 @@ class Usecase:
)
value = self.cast_value_to_primary_measure_type(value, primary_measure_type)
prop.NominalValue = self.file.create_entity(primary_measure_type, value)
if unit:
if unit is not _NO_UNIT:
prop.Unit = unit
del self.settings["properties"][prop.Name]
return prop
@@ -329,7 +333,7 @@ class Usecase:
# If it's not an entity, then it's a primitive data type
elif not value.is_entity():
kwargs = {"Name": name, "NominalValue": value}
if unit:
if unit is not None and unit is not _NO_UNIT:
kwargs["Unit"] = unit
properties.append(self.file.create_entity("IfcPropertySingleValue", **kwargs))
@@ -353,7 +357,7 @@ class Usecase:
"IfcPropertyListValue",
Name=name,
ListValues=[self.file.create_entity(ifc_class, v) for v in value],
Unit=unit,
Unit=unit if (unit is not None and unit is not _NO_UNIT) else None,
)
)
break
@@ -363,7 +367,7 @@ class Usecase:
"IFCPROPERTYENUMERATION",
Name=name,
EnumerationValues=pset_template.Enumerators.EnumerationValues,
**({"Unit": unit} if unit else {}),
**({"Unit": unit} if (unit is not None and unit is not _NO_UNIT) else {}),
)
prop_enum_value = self.file.create_entity(
"IFCPROPERTYENUMERATEDVALUE",
@@ -389,7 +393,7 @@ class Usecase:
value = self.cast_value_to_primary_measure_type(value, primary_measure_type)
nominal_value = self.file.create_entity(primary_measure_type, value)
args = {"Name": name, "NominalValue": nominal_value}
if unit:
if unit is not None and unit is not _NO_UNIT:
args["Unit"] = unit
properties.append(self.file.create_entity("IfcPropertySingleValue", **args))
@@ -479,12 +483,16 @@ class Usecase:
def unpack_unit_value(value_candidate):
"""
Returns tuple of the format: (Unit, NominalValue)
NOTE: Unit fallbacks to None
NOTE: Unit is the module-level _NO_UNIT sentinel when no Unit was specified at all
(bare value, or a dict without a "Unit" key), so that callers can distinguish "leave
the existing Unit untouched" from an explicit `{"Unit": None, ...}` (clear the
existing Unit override, falling back to the project default).
"""
if value_candidate is None:
return (None, None)
if isinstance(value_candidate, dict): # Custom IfcUnits can be passed in a dict along with the pset value
return (value_candidate["Unit"], value_candidate["NominalValue"])
return (value_candidate.get("Unit", _NO_UNIT), value_candidate["NominalValue"])
return (None, value_candidate)
return (_NO_UNIT, value_candidate)
@@ -136,10 +136,32 @@ def edit_qto(
return usecase.execute()
# Sentinel distinguishing "no Unit dict was passed at all" from "a Unit dict was passed
# with Unit explicitly set to None" (i.e. explicitly clear an existing override).
_NO_UNIT = object()
class Usecase:
file: ifcopenshell.file
settings: dict[str, Any]
@staticmethod
def unpack_unit_value(value_candidate):
"""
Returns tuple of the format: (Unit, NominalValue)
NOTE: a dict value_candidate is ambiguous with the IfcPhysicalComplexQuantity spec
convention ({"Discrimination": ..., "HasQuantities": ...}) used elsewhere in this
module -- callers must check for that case (absence of a "Unit" key) before calling
this. Unit is the module-level _NO_UNIT sentinel when no Unit was specified at all
(bare value, or a dict without a "Unit" key), so that callers can distinguish "leave
the existing Unit untouched" from an explicit `{"Unit": None, ...}` (clear the
existing Unit override, falling back to the project default).
"""
if isinstance(value_candidate, dict) and "Unit" in value_candidate:
return (value_candidate["Unit"], value_candidate["NominalValue"])
return (_NO_UNIT, value_candidate)
def execute(self):
self.qto_idx = 5
if self.settings["qto"].is_a("IfcPhysicalComplexQuantity"):
@@ -173,16 +195,19 @@ class Usecase:
name = prop.Name
if value is None:
self.file.remove(prop)
elif prop.is_a("IfcPhysicalComplexQuantity") and isinstance(value, dict):
elif prop.is_a("IfcPhysicalComplexQuantity") and isinstance(value, dict) and "Unit" not in value:
prop.Discrimination = value.get("Discrimination", prop.Discrimination)
ifcopenshell.api.pset.edit_qto(self.file, qto=prop, properties=value["HasQuantities"])
elif prop.is_a("IfcPhysicalSimpleQuantity"):
unit, value = self.unpack_unit_value(value)
value = value.wrappedValue if isinstance(value, ifcopenshell.entity_instance) else value
# 3 IfcPhysicalSimpleQuantity.XXXValue
if self.file.schema == "IFC4X3" and prop.is_a("IfcQuantityCount"):
prop[3] = int(value)
else:
prop[3] = float(value)
if unit is not _NO_UNIT:
prop.Unit = unit
del self.settings["properties"][name]
def add_new_properties(self) -> list[ifcopenshell.entity_instance]:
@@ -190,21 +215,20 @@ class Usecase:
for name, value in self.settings["properties"].items():
if value is None:
continue
if isinstance(value, dict):
if isinstance(value, dict) and "Unit" not in value:
complex_qto = self.file.create_entity(
"IfcPhysicalComplexQuantity", Name=name, Discrimination=value["Discrimination"]
)
properties.append(complex_qto)
ifcopenshell.api.pset.edit_qto(self.file, qto=complex_qto, properties=value["HasQuantities"])
else:
unit, value = self.unpack_unit_value(value)
property_type = self.get_canonical_property_type(name, value)
value = value.wrappedValue if isinstance(value, ifcopenshell.entity_instance) else value
properties.append(
self.file.create_entity(
"IfcQuantity{}".format(property_type),
**{"Name": name, "{}Value".format(property_type): value},
)
)
kwargs = {"Name": name, "{}Value".format(property_type): value}
if unit is not None and unit is not _NO_UNIT:
kwargs["Unit"] = unit
properties.append(self.file.create_entity("IfcQuantity{}".format(property_type), **kwargs))
return properties
def extend_qto_with_new_properties(self, new_properties: list[ifcopenshell.entity_instance]) -> None:
@@ -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"
+189 -17
View File
@@ -406,6 +406,51 @@ def get_named_dimensions(name):
return named_dimensions.get(name, (0, 0, 0, 0, 0, 0, 0))
def get_unit_dimensions(unit: ifcopenshell.entity_instance) -> tuple[int, int, int, int, int, int, int]:
"""Get the dimensional exponents of a unit, per IfcDimensionalExponents.
Supports IfcSIUnit, IfcConversionBasedUnit, IfcContextDependentUnit, and
IfcDerivedUnit (composed recursively from its elements).
:param unit: The unit to inspect.
:return: A 7-tuple of (Length, Mass, Time, ElectricCurrent,
ThermodynamicTemperature, AmountOfSubstance, LuminousIntensity).
"""
if unit.is_a("IfcDerivedUnit"):
dimensions = [0, 0, 0, 0, 0, 0, 0]
for element in unit.Elements:
element_dimensions = get_unit_dimensions(element.Unit)
for i in range(7):
dimensions[i] += element_dimensions[i] * element.Exponent
return tuple(dimensions)
if unit.is_a("IfcSIUnit"):
return get_si_dimensions(unit.Name.replace("METER", "METRE"))
return get_named_dimensions(getattr(unit, "UnitType", None))
def identify_unit_dimensions(unit: ifcopenshell.entity_instance) -> Union[str, None]:
"""Identify which named IfcUnitEnum type a unit's dimensions correspond to.
This is mainly useful for an IfcDerivedUnit that has no named
IfcDerivedUnitEnum match for its measure type, allowing it to still be
recognised as, e.g., a pressure unit by dimensional analysis alone.
Note that dimensionless quantities (e.g. plane angle, solid angle, or a
genuinely unitless value) are dimensionally indistinguishable, so this
heuristically returns the first zero-dimension match rather than
disambiguating them.
:param unit: The unit to identify.
:return: An uppercase IfcUnitEnum value, or None if no named type shares
the same dimensions.
"""
dimensions = get_unit_dimensions(unit)
for name, named in named_dimensions.items():
if named == dimensions:
return name
return None
def get_unit_assignment(ifc_file: ifcopenshell.file) -> Union[ifcopenshell.entity_instance, None]:
return ifc_file.by_type("IfcProject")[0].UnitsInContext
@@ -421,7 +466,16 @@ def cache_units(ifc_file: ifcopenshell.file) -> None:
"""
ifc_file.units = {}
if assignment := get_unit_assignment(ifc_file):
ifc_file.units = {u.UnitType: u for u in assignment.Units if getattr(u, "UnitType", None)}
all_units = assignment.Units or []
units = {u.UnitType: u for u in all_units if getattr(u, "UnitType", None)}
# As in get_project_unit(): a literal match always wins; an IfcDerivedUnit with no
# literal UnitType match is matched dimensionally instead, only to fill a gap.
for unit in all_units:
if unit.is_a("IfcDerivedUnit"):
dimension = identify_unit_dimensions(unit)
if dimension and dimension not in units:
units[dimension] = unit
ifc_file.units = units
def clear_unit_cache(ifc_file: ifcopenshell.file) -> None:
@@ -437,6 +491,10 @@ def get_project_unit(
) -> Union[ifcopenshell.entity_instance, None]:
"""Get the default project unit of a particular unit type
IfcDerivedUnit is matched first by a literal `UnitType` match, then, as a fallback, by
dimensional analysis (:func:`identify_unit_dimensions`), mirroring
:func:`get_candidate_units`.
:param ifc_file: The IFC file.
:param unit_type: The type of unit, taken from the list of IFC unit types,
such as "LENGTHUNIT".
@@ -448,9 +506,39 @@ def get_project_unit(
if units := ifc_file.units:
return units.get(unit_type, None)
if unit_assignment := get_unit_assignment(ifc_file):
dimensional_match = None
for unit in unit_assignment.Units or []:
if getattr(unit, "UnitType", None) == unit_type:
return unit
if dimensional_match is None and unit.is_a("IfcDerivedUnit") and identify_unit_dimensions(unit) == unit_type:
dimensional_match = unit
return dimensional_match
def get_candidate_units(ifc_file: ifcopenshell.file, unit_type: str) -> list[ifcopenshell.entity_instance]:
"""Get all units in the file usable as an override for `unit_type`.
Unlike :func:`get_project_unit`, this returns every matching unit defined
in the file (e.g. both an mm and an m IfcSIUnit might be present), not
just the one currently assigned as the project default.
IfcDerivedUnit is matched first by a literal `UnitType` match, then, as a
fallback, by dimensional analysis (:func:`identify_unit_dimensions`) --
that fallback only helps for the dimension families covered by
`named_dimensions` (the core `IfcUnitEnum` types); it won't match e.g.
`"MODULUSOFELASTICITYUNIT"` by dimension alone, only by literal `UnitType`.
:param ifc_file: The IFC file.
:param unit_type: The type of unit, taken from the list of IFC unit
types, such as "LENGTHUNIT", or an IfcDerivedUnitEnum value such as
"MODULUSOFELASTICITYUNIT".
:return: All matching IfcNamedUnit / IfcDerivedUnit entities in the file.
"""
candidates = [u for u in ifc_file.by_type("IfcNamedUnit") if getattr(u, "UnitType", None) == unit_type]
for unit in ifc_file.by_type("IfcDerivedUnit"):
if getattr(unit, "UnitType", None) == unit_type or identify_unit_dimensions(unit) == unit_type:
candidates.append(unit)
return candidates
def get_property_unit(
@@ -480,7 +568,8 @@ def get_property_unit(
entity = prop.wrapped_data.declaration().as_entity()
measure_class = entity.attribute_by_index(3).type_of_attribute().declared_type().name()
elif prop.is_a("IfcPropertySingleValue"):
measure_class = prop.NominalValue.is_a()
if value := prop.NominalValue:
measure_class = value.is_a()
elif prop.is_a("IfcPropertyEnumeratedValue"):
if prop.EnumerationReference:
if unit := prop.EnumerationReference.Unit:
@@ -622,6 +711,8 @@ def get_symbol_quantity_class(symbol: Optional[str] = None) -> QUANTITY_CLASS:
def get_unit_symbol(unit: ifcopenshell.entity_instance) -> str:
if unit.is_a("IfcDerivedUnit"):
return get_derived_unit_symbol(unit)
symbol: str = ""
if unit.is_a("IfcSIUnit"):
symbol += prefix_symbols.get(unit.Prefix, "")
@@ -631,6 +722,28 @@ def get_unit_symbol(unit: ifcopenshell.entity_instance) -> str:
return symbol
def get_derived_unit_symbol(unit: ifcopenshell.entity_instance) -> str:
"""Compose a unit symbol for an IfcDerivedUnit from its elements.
E.g. a derived unit of NEWTON / SQUARE_METRE composes to "N/m2".
:param unit: The IfcDerivedUnit.
:return: The composed symbol.
"""
numerator = []
denominator = []
for element in unit.Elements:
symbol = get_unit_symbol(element.Unit)
exponent = abs(element.Exponent)
if exponent != 1:
symbol += str(exponent)
(numerator if element.Exponent > 0 else denominator).append(symbol)
result = ".".join(numerator) or "1"
if denominator:
result += "/" + ".".join(denominator)
return result
def convert_unit(value: float, from_unit: ifcopenshell.entity_instance, to_unit: ifcopenshell.entity_instance) -> float:
"""Convert from one unit to another unit
@@ -684,6 +797,70 @@ def convert(value: float, from_prefix: Optional[str], from_unit: str, to_prefix:
return value
def get_named_unit_scale(unit: ifcopenshell.entity_instance) -> float:
"""Get the scale factor to convert a value in a named unit to SI units.
Supports IfcSIUnit and IfcConversionBasedUnit (including chains of
conversion-based units). Does not support IfcDerivedUnit -- see
:func:`get_derived_unit_scale` for that.
:param unit: The IfcNamedUnit.
:returns: The scale factor.
"""
scale = 1.0
while unit.is_a("IfcConversionBasedUnit"):
conversion_factor = unit.ConversionFactor
scale *= conversion_factor.ValueComponent.wrappedValue
unit = conversion_factor.UnitComponent
if unit.is_a("IfcSIUnit"):
prefix_multiplier = get_prefix_multiplier(unit.Prefix)
# An SI prefix attaches to the base unit symbol, and the prefixed
# symbol is raised to the power as a whole: dm3 = (dm)3 = 1e-3 m3,
# not 0.1 m3. For units whose dimensions are a pure power of length
# (METRE, SQUARE_METRE, CUBIC_METRE) the prefix multiplier must
# therefore be raised to the length exponent. Units with mixed or
# non-length dimensions (PASCAL, NEWTON, GRAM, ...) keep the linear
# multiplier, as there the prefix scales the derived unit itself.
# https://github.com/IfcOpenShell/IfcOpenShell/issues/9278
#
# Dimensions is looked up from si_dimensions by name rather than via
# unit.Dimensions (the schema-derived IfcDimensionalExponents), since
# the derived attribute isn't computed for SQLite-linked files and
# would return None there.
length_exponent, *other_exponents = get_si_dimensions(unit.Name.replace("METER", "METRE"))
if length_exponent > 0 and not any(other_exponents):
prefix_multiplier **= length_exponent
scale *= prefix_multiplier
return scale
def get_derived_unit_scale(unit: ifcopenshell.entity_instance) -> float:
"""Get the scale factor to convert a value in an IfcDerivedUnit to SI units.
:param unit: The IfcDerivedUnit.
:returns: The scale factor.
"""
scale = 1.0
for element in unit.Elements:
scale *= get_named_unit_scale(element.Unit) ** element.Exponent
return scale
def get_unit_scale(unit: ifcopenshell.entity_instance) -> float:
"""Get the scale factor to convert a value in `unit` to SI units.
Dispatches to :func:`get_derived_unit_scale` for IfcDerivedUnit, or
:func:`get_named_unit_scale` otherwise (IfcSIUnit / IfcConversionBasedUnit,
including chains).
:param unit: The unit to get the scale factor for.
:returns: The scale factor.
"""
if unit.is_a("IfcDerivedUnit"):
return get_derived_unit_scale(unit)
return get_named_unit_scale(unit)
def calculate_unit_scale(ifc_file: ifcopenshell.file, unit_type: str = "LENGTHUNIT") -> float:
"""Returns a unit scale factor to convert to and from IFC project units and SI units.
@@ -695,17 +872,17 @@ def calculate_unit_scale(ifc_file: ifcopenshell.file, unit_type: str = "LENGTHUN
si_meters / unit_scale = ifc_project_length
:param ifc_file: The IFC file.
:param unit_type: The type of SI unit, defaults to "LENGTHUNIT"
:param unit_type: The type of SI unit, defaults to "LENGTHUNIT". This may
also be an IfcDerivedUnitEnum value (e.g. "MASSDENSITYUNIT") to
support project units defined as an IfcDerivedUnit.
:returns: The scale factor
"""
if (
type(ifc_file) is ifcopenshell.file
and unit_type
not in ifcopenshell.ifcopenshell_wrapper.schema_by_name(ifc_file.schema_identifier)
.declaration_by_name("IfcUnitEnum")
.enumeration_items()
):
raise ValueError(f"Unit type {unit_type!r} does not name a valid type")
if type(ifc_file) is ifcopenshell.file and unit_type:
schema = ifcopenshell.ifcopenshell_wrapper.schema_by_name(ifc_file.schema_identifier)
valid_types = set(schema.declaration_by_name("IfcUnitEnum").enumeration_items())
valid_types |= set(schema.declaration_by_name("IfcDerivedUnitEnum").enumeration_items())
if unit_type not in valid_types:
raise ValueError(f"Unit type {unit_type!r} does not name a valid type")
# Currently we assume that all ifc projects must have IfcProject.
if not (projects := ifc_file.by_type("IfcProject")) or not (units := projects[0].UnitsInContext):
@@ -715,12 +892,7 @@ def calculate_unit_scale(ifc_file: ifcopenshell.file, unit_type: str = "LENGTHUN
for unit in units.Units:
if getattr(unit, "UnitType", ...) != unit_type:
continue
while unit.is_a("IfcConversionBasedUnit"):
conversion_factor = unit.ConversionFactor
unit_scale *= conversion_factor.ValueComponent.wrappedValue
unit = conversion_factor.UnitComponent
if unit.is_a("IfcSIUnit"):
unit_scale *= get_prefix_multiplier(unit.Prefix)
unit_scale *= get_unit_scale(unit)
return unit_scale
@@ -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
@@ -96,17 +96,37 @@ def callback_alignment():
yield alignment
def test_with_default_names(default_names_alignment):
referent_nest = ifcopenshell.api.alignment.get_referent_nest(None, default_names_alignment)
def _label(name):
return name.rsplit("(", 1)[1].rstrip(")")
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]
def test_with_default_names(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)
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 [_label(r.Name) for r in h_nest.RelatedObjects] == expected_h
assert [_label(r.Name) 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 [_label(r.Name) for r in h_nest.RelatedObjects] == expected_h
assert [_label(r.Name) for r in v_nest.RelatedObjects] == expected_v
ifcopenshell.api.alignment.register_referent_name_callback(None, None, None) # reset global state
@@ -0,0 +1,307 @@
# 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/>.
import pytest
import ifcopenshell.api.alignment
import ifcopenshell.api.context
import ifcopenshell.api.unit
import ifcopenshell.util.alignment
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 _real_segments(layout):
segments = ifcopenshell.api.alignment.get_layout_segments(layout)
return segments[:-1] if ifcopenshell.api.alignment.has_zero_length_segment(layout) else segments
def _label(tag):
return tag.rsplit("(", 1)[1].rstrip(")")
def test_wrong_layout_type_raises_type_error():
file = _new_file()
alignment = _build_alignment(file)
with pytest.raises(TypeError):
ifcopenshell.api.alignment.update_alignment_parameter_segment_tags(file, alignment)
def test_not_nested_under_alignment_raises_value_error():
file = _new_file_no_context()
horizontal = file.createIfcAlignmentHorizontal(GlobalId=ifcopenshell.guid.new())
with pytest.raises(ValueError):
ifcopenshell.api.alignment.update_alignment_parameter_segment_tags(file, horizontal)
def test_returns_none():
file = _new_file()
alignment = _build_alignment(file)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
result = ifcopenshell.api.alignment.update_alignment_parameter_segment_tags(file, horizontal)
assert result is None
def test_no_referents_or_rel_nests_created():
file = _new_file()
alignment = _build_alignment(file)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
referents_before = len(file.by_type("IfcReferent"))
rel_nests_before = len(file.by_type("IfcRelNests"))
ifcopenshell.api.alignment.update_alignment_parameter_segment_tags(file, horizontal)
assert len(file.by_type("IfcReferent")) == referents_before
assert len(file.by_type("IfcRelNests")) == rel_nests_before
def test_no_real_segments_leaves_tags_none():
file = _new_file_no_context()
alignment = ifcopenshell.api.alignment.create(file, "A1", include_geometry=False)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
result = ifcopenshell.api.alignment.update_alignment_parameter_segment_tags(file, horizontal)
assert result is None
segments = ifcopenshell.api.alignment.get_layout_segments(horizontal)
assert len(segments) == 1 # only the auto zero-length segment
assert segments[0].DesignParameters.StartTag is None
assert segments[0].DesignParameters.EndTag is None
def test_single_real_segment_produces_only_boundary_tags():
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)
ifcopenshell.api.alignment.update_alignment_parameter_segment_tags(file, horizontal, label_end_tag=True)
segments = _real_segments(horizontal)
assert len(segments) == 1
dp = segments[0].DesignParameters
assert _label(dp.StartTag) == "P.O.B."
assert _label(dp.EndTag) == "P.O.E."
def test_end_tag_not_labelled_by_default():
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)
ifcopenshell.api.alignment.update_alignment_parameter_segment_tags(file, horizontal)
segments = _real_segments(horizontal)
assert len(segments) == 1
dp = segments[0].DesignParameters
assert _label(dp.StartTag) == "P.O.B."
assert dp.EndTag is None
def test_horizontal_tag_labels_and_adjacency():
file = _new_file()
alignment = _build_alignment(file)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
ifcopenshell.api.alignment.update_alignment_parameter_segment_tags(file, horizontal, label_end_tag=True)
segments = _real_segments(horizontal)
assert len(segments) == 7
start_labels = [_label(s.DesignParameters.StartTag) for s in segments]
end_labels = [_label(s.DesignParameters.EndTag) for s in segments]
assert start_labels == ["P.O.B.", "P.C.", "P.T.", "P.C.", "P.T.", "P.C.", "P.T."]
assert end_labels == ["P.C.", "P.T.", "P.C.", "P.T.", "P.C.", "P.T.", "P.O.E."]
# every real segment has both tags set
assert all(s.DesignParameters.StartTag is not None for s in segments)
assert all(s.DesignParameters.EndTag is not None for s in segments)
# adjacent segments agree on the tag describing their shared transition point
for i in range(len(segments) - 1):
assert segments[i].DesignParameters.EndTag == segments[i + 1].DesignParameters.StartTag
def test_vertical_tag_labels_and_adjacency():
file = _new_file()
alignment = _build_alignment(file)
vertical = ifcopenshell.api.alignment.get_vertical_layout(alignment)
ifcopenshell.api.alignment.update_alignment_parameter_segment_tags(file, vertical, label_end_tag=True)
segments = _real_segments(vertical)
assert len(segments) == 9
start_labels = [_label(s.DesignParameters.StartTag) for s in segments]
end_labels = [_label(s.DesignParameters.EndTag) for s in segments]
assert start_labels == [
"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.",
]
assert end_labels == [
"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 all(s.DesignParameters.StartTag is not None for s in segments)
assert all(s.DesignParameters.EndTag is not None for s in segments)
for i in range(len(segments) - 1):
assert segments[i].DesignParameters.EndTag == segments[i + 1].DesignParameters.StartTag
def test_cant_layout_boundary_tags():
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)
ifcopenshell.api.alignment.update_alignment_parameter_segment_tags(file, cant, label_end_tag=True)
segments = _real_segments(cant)
assert _label(segments[0].DesignParameters.StartTag) == "C.P.O.B."
assert _label(segments[-1].DesignParameters.EndTag) == "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 _label(segments[0].DesignParameters.EndTag) == "xx"
assert _label(segments[-1].DesignParameters.StartTag) == "xx"
def test_exact_tag_format():
file = _new_file()
alignment = _build_alignment(file)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
ifcopenshell.api.alignment.update_alignment_parameter_segment_tags(file, horizontal)
start_station = ifcopenshell.api.alignment.get_alignment_start_station(file, alignment)
segments = _real_segments(horizontal)
assert segments[0].DesignParameters.StartTag == (
f"{ifcopenshell.util.alignment.station_as_string(file, start_station)} (P.O.B.)"
)
test_wrong_layout_type_raises_type_error()
test_not_nested_under_alignment_raises_value_error()
test_returns_none()
test_no_referents_or_rel_nests_created()
test_no_real_segments_leaves_tags_none()
test_single_real_segment_produces_only_boundary_tags()
test_end_tag_not_labelled_by_default()
test_horizontal_tag_labels_and_adjacency()
test_vertical_tag_labels_and_adjacency()
test_cant_layout_boundary_tags()
test_exact_tag_format()
@@ -0,0 +1,402 @@
# 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 _label(name):
return name.rsplit("(", 1)[1].rstrip(")")
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 [_label(r.Name) 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 [_label(r.Name) 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"{alignment.Name} {ifcopenshell.util.alignment.station_as_string(file, station)} (P.O.B.)"
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 = [_label(r.Name) 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 = [_label(r.Name) 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
@@ -188,6 +188,36 @@ class TestEditPsetIFC2X3(test.bootstrap.IFC2X3):
assert unit.Prefix == "GIGA"
assert unit.Name == "PASCAL"
def test_explicitly_clearing_a_propertys_unit_override(self):
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
custom_unit = self.file.createIfcSIUnit(UnitType="PRESSUREUNIT", Prefix="GIGA", Name="PASCAL")
pset = ifcopenshell.api.pset.add_pset(self.file, product=element, name="Foo_Bar")
ifcopenshell.api.pset.edit_pset(
self.file, pset=pset, properties={"MyCustom": {"NominalValue": 30.0, "Unit": custom_unit}}
)
prop = pset.HasProperties[0]
assert prop.Unit == custom_unit
ifcopenshell.api.pset.edit_pset(
self.file, pset=pset, properties={"MyCustom": {"NominalValue": 40.0, "Unit": None}}
)
assert prop.Unit is None
assert prop.NominalValue.wrappedValue == 40.0
def test_a_bare_value_does_not_disturb_an_existing_units_override(self):
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
custom_unit = self.file.createIfcSIUnit(UnitType="PRESSUREUNIT", Prefix="GIGA", Name="PASCAL")
pset = ifcopenshell.api.pset.add_pset(self.file, product=element, name="Foo_Bar")
ifcopenshell.api.pset.edit_pset(
self.file, pset=pset, properties={"MyCustom": {"NominalValue": 30.0, "Unit": custom_unit}}
)
prop = pset.HasProperties[0]
assert prop.Unit == custom_unit
ifcopenshell.api.pset.edit_pset(self.file, pset=pset, properties={"MyCustom": 42.0})
assert prop.Unit == custom_unit
assert prop.NominalValue.wrappedValue == 42.0
def test_editing_properties_of_non_rooted_elements(self):
element = self.file.createIfcMaterial()
pset = ifcopenshell.api.pset.add_pset(self.file, product=element, name="Foo_Bar")
@@ -18,6 +18,7 @@
import ifcopenshell.api.pset
import ifcopenshell.api.root
import ifcopenshell.api.unit
import test.bootstrap
@@ -150,3 +151,87 @@ class TestEditQto(test.bootstrap.IFC4):
qto = element.IsDefinedBy[0].RelatingPropertyDefinition
assert qto.Quantities[0].Name == "MyLength"
assert qto.Quantities[0].LengthValue == 34
def test_adding_a_new_quantity_with_a_custom_unit(self):
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
custom_unit = self.file.createIfcSIUnit(UnitType="LENGTHUNIT", Prefix="MILLI", Name="METRE")
qto = ifcopenshell.api.pset.add_qto(self.file, product=element, name="Foo_Bar")
ifcopenshell.api.pset.edit_qto(
self.file, qto=qto, properties={"MyLength": {"NominalValue": 30.0, "Unit": custom_unit}}
)
qto = element.IsDefinedBy[0].RelatingPropertyDefinition
assert qto.Quantities[0].Name == "MyLength"
assert qto.Quantities[0].LengthValue == 30.0
assert qto.Quantities[0].Unit == custom_unit
def test_editing_an_existing_quantitys_unit(self):
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
custom_unit = self.file.createIfcSIUnit(UnitType="LENGTHUNIT", Prefix="MILLI", Name="METRE")
qto = ifcopenshell.api.pset.add_qto(self.file, product=element, name="Foo_Bar")
ifcopenshell.api.pset.edit_qto(self.file, qto=qto, properties={"MyLength": 12.0})
quantity = qto.Quantities[0]
assert quantity.Unit is None
ifcopenshell.api.pset.edit_qto(
self.file, qto=qto, properties={"MyLength": {"NominalValue": 30.0, "Unit": custom_unit}}
)
assert quantity.Unit == custom_unit
assert quantity.LengthValue == 30.0
def test_explicitly_clearing_a_quantitys_unit_override(self):
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
custom_unit = self.file.createIfcSIUnit(UnitType="LENGTHUNIT", Prefix="MILLI", Name="METRE")
qto = ifcopenshell.api.pset.add_qto(self.file, product=element, name="Foo_Bar")
ifcopenshell.api.pset.edit_qto(
self.file, qto=qto, properties={"MyLength": {"NominalValue": 30.0, "Unit": custom_unit}}
)
quantity = qto.Quantities[0]
assert quantity.Unit == custom_unit
ifcopenshell.api.pset.edit_qto(
self.file, qto=qto, properties={"MyLength": {"NominalValue": 40.0, "Unit": None}}
)
assert quantity.Unit is None
assert quantity.LengthValue == 40.0
def test_a_bare_value_does_not_disturb_an_existing_units_override(self):
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
custom_unit = self.file.createIfcSIUnit(UnitType="LENGTHUNIT", Prefix="MILLI", Name="METRE")
qto = ifcopenshell.api.pset.add_qto(self.file, product=element, name="Foo_Bar")
ifcopenshell.api.pset.edit_qto(
self.file, qto=qto, properties={"MyLength": {"NominalValue": 30.0, "Unit": custom_unit}}
)
quantity = qto.Quantities[0]
assert quantity.Unit == custom_unit
ifcopenshell.api.pset.edit_qto(self.file, qto=qto, properties={"MyLength": 42.0})
assert quantity.Unit == custom_unit
assert quantity.LengthValue == 42.0
def test_complex_quantity_editing_is_unaffected_by_the_unit_wrapper_convention(self):
# Regression guard: dict values are already overloaded to mean "this is an
# IfcPhysicalComplexQuantity spec" ({"Discrimination": ..., "HasQuantities": ...}).
# The new {"Unit": ..., "NominalValue": ...} convention must not be confused with it.
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
qto = ifcopenshell.api.pset.add_qto(self.file, product=element, name="Foo_Bar")
ifcopenshell.api.pset.edit_qto(
self.file,
qto=qto,
properties={"Layers": {"Discrimination": "layer", "HasQuantities": {"Width": 5.0}}},
)
qto = element.IsDefinedBy[0].RelatingPropertyDefinition
complex_qty = qto.Quantities[0]
assert complex_qty.is_a("IfcPhysicalComplexQuantity")
assert complex_qty.Name == "Layers"
assert complex_qty.Discrimination == "layer"
assert complex_qty.HasQuantities[0].Name == "Width"
assert complex_qty.HasQuantities[0].LengthValue == 5.0
# Editing it again (update_existing_property's complex-quantity branch) still works too.
ifcopenshell.api.pset.edit_qto(
self.file,
qto=qto,
properties={"Layers": {"Discrimination": "layer2", "HasQuantities": {"Width": 6.0}}},
)
assert complex_qty.Discrimination == "layer2"
assert complex_qty.HasQuantities[0].LengthValue == 6.0
+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)
@@ -16,7 +16,9 @@
# 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 tempfile
from math import pi
from pathlib import Path
import numpy as np
import pytest
@@ -29,8 +31,10 @@ import ifcopenshell.api.unit
import ifcopenshell.util.element
import ifcopenshell.util.geolocation
import ifcopenshell.util.unit as subject
import ifcpatch
import test.bootstrap
from ifcopenshell.util.shape_builder import ShapeBuilder
from ifcpatch.recipes import Ifc2Sql
class TestMmToM:
@@ -90,6 +94,80 @@ class TestGetProjectUnit(test.bootstrap.IFC4):
assert subject.get_project_unit(self.file, "LENGTHUNIT", use_cache=True) == length2
assert self.file.units == {"LENGTHUNIT": length2, "AREAUNIT": area}
def test_area_and_volume_derived_from_length_are_matched_dimensionally(self):
# AREAUNIT/VOLUMEUNIT have no IfcDerivedUnitEnum member, so a project whose area/volume
# default is an IfcDerivedUnit has no literal UnitType match for either -- get_project_unit
# must still resolve them by dimensional analysis, like get_candidate_units already does.
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
length = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT")
area = ifcopenshell.api.unit.add_derived_unit(self.file, "USERDEFINED", "area-ish", {length: 2})
volume = ifcopenshell.api.unit.add_derived_unit(self.file, "USERDEFINED", "volume-ish", {length: 3})
ifcopenshell.api.unit.assign_unit(self.file, units=[length, area, volume])
assert subject.get_project_unit(self.file, "AREAUNIT") == area
assert subject.get_project_unit(self.file, "VOLUMEUNIT") == volume
def test_literal_unit_type_match_takes_priority_over_dimensional(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
length = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT")
literal_area = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="AREAUNIT")
derived_area = ifcopenshell.api.unit.add_derived_unit(self.file, "USERDEFINED", "area-ish", {length: 2})
ifcopenshell.api.unit.assign_unit(self.file, units=[length, literal_area, derived_area])
assert subject.get_project_unit(self.file, "AREAUNIT") == literal_area
def test_dimensional_fallback_also_applies_when_using_a_cache(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
length = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT")
area = ifcopenshell.api.unit.add_derived_unit(self.file, "USERDEFINED", "area-ish", {length: 2})
ifcopenshell.api.unit.assign_unit(self.file, units=[length, area])
assert subject.get_project_unit(self.file, "AREAUNIT", use_cache=True) == area
assert self.file.units["AREAUNIT"] == area
class TestGetCandidateUnits(test.bootstrap.IFC4):
def test_returns_only_units_matching_the_unit_type(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
mm = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT", prefix="MILLI")
m = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT")
area = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="AREAUNIT")
candidates = subject.get_candidate_units(self.file, "LENGTHUNIT")
assert set(candidates) == {mm, m}
assert area not in candidates
def test_returns_all_matching_units_not_just_the_assigned_default(self):
# Unlike get_project_unit, which only returns the one assigned default.
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
mm = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT", prefix="MILLI")
m = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT")
ifcopenshell.api.unit.assign_unit(self.file, units=[mm])
assert subject.get_project_unit(self.file, "LENGTHUNIT") == mm
assert set(subject.get_candidate_units(self.file, "LENGTHUNIT")) == {mm, m}
def test_derived_unit_matched_by_literal_unit_type(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
force = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="FORCEUNIT")
area = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="AREAUNIT")
modulus = ifcopenshell.api.unit.add_derived_unit(self.file, "MODULUSOFELASTICITYUNIT", None, {force: 1, area: -1})
assert subject.get_candidate_units(self.file, "MODULUSOFELASTICITYUNIT") == [modulus]
def test_userdefined_derived_unit_matched_by_dimensional_fallback(self):
# No literal UnitType match (USERDEFINED), but dimensionally it's a pressure unit,
# and PRESSUREUNIT is one of the core families covered by named_dimensions.
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
force = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="FORCEUNIT")
area = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="AREAUNIT")
weird_pressure = ifcopenshell.api.unit.add_derived_unit(
self.file, "USERDEFINED", "pressure-ish", {force: 1, area: -1}
)
assert subject.get_candidate_units(self.file, "PRESSUREUNIT") == [weird_pressure]
def test_empty_when_nothing_matches(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT")
assert subject.get_candidate_units(self.file, "MASSUNIT") == []
class TestGetPropertyUnit(test.bootstrap.IFC4):
def test_no_unit(self):
@@ -119,6 +197,12 @@ class TestGetPropertyUnit(test.bootstrap.IFC4):
prop.Unit = length2
assert subject.get_property_unit(prop, self.file) == length2
def test_single_value_with_no_nominal_value(self):
# NominalValue is optional -- a property may be null. Must not crash.
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
prop = self.file.createIfcPropertySingleValue(Name="Foo", NominalValue=None)
assert subject.get_property_unit(prop, self.file) is None
def test_enumerated_value(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
length = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT", prefix="MILLI")
@@ -200,6 +284,152 @@ class TestCalculateUnitScale(test.bootstrap.IFC4):
ifcopenshell.api.unit.assign_unit(self.file, units=[angle])
assert subject.calculate_unit_scale(self.file, "PLANEANGLEUNIT") == pi / 180 * 0.001
def test_derived_units_are_considered(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
force = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="FORCEUNIT")
area = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="AREAUNIT", prefix="MILLI")
modulus = ifcopenshell.api.unit.add_derived_unit(self.file, "MODULUSOFELASTICITYUNIT", None, {force: 1, area: -1})
ifcopenshell.api.unit.assign_unit(self.file, units=[modulus])
# AREAUNIT is a pure power of length, so its MILLI prefix is raised to
# the length exponent (2) per #9278: (1e-3)**2 = 1e-6, inverted by the
# derived unit's -1 exponent to give 1e6.
assert subject.calculate_unit_scale(self.file, "MODULUSOFELASTICITYUNIT") == pytest.approx(1_000_000.0)
def test_prefix_is_raised_to_the_length_exponent_for_area_and_volume(self):
# A prefixed square/cubic metre is (prefix-metre) squared/cubed:
# DECI SQUARE_METRE = dm2 = 1e-2 m2, DECI CUBIC_METRE = dm3 (litre) = 1e-3 m3.
# https://github.com/IfcOpenShell/IfcOpenShell/issues/9278
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
area = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="AREAUNIT")
area.Prefix = "DECI"
volume = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="VOLUMEUNIT")
volume.Prefix = "DECI"
ifcopenshell.api.unit.assign_unit(self.file, units=[area, volume])
assert subject.calculate_unit_scale(self.file, "AREAUNIT") == pytest.approx(0.1**2)
assert subject.calculate_unit_scale(self.file, "VOLUMEUNIT") == pytest.approx(0.1**3)
def test_prefix_stays_linear_for_units_that_are_not_a_pure_power_of_length(self):
# For derived and non-length SI units the prefix scales the unit itself:
# KILO PASCAL = 1e3 Pa, KILO GRAM = 1e3 g.
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
pressure = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="PRESSUREUNIT")
pressure.Prefix = "KILO"
mass = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="MASSUNIT")
mass.Prefix = "KILO"
ifcopenshell.api.unit.assign_unit(self.file, units=[pressure, mass])
assert subject.calculate_unit_scale(self.file, "PRESSUREUNIT") == pytest.approx(1000)
assert subject.calculate_unit_scale(self.file, "MASSUNIT") == pytest.approx(1000)
class TestCalculateUnitScaleOnLinkedFile(test.bootstrap.IFC4):
def test_run(self):
# Regression test: IfcSIUnit.Dimensions is a schema-*derived*
# attribute that isn't computed for SQLite-linked files (used for
# Bonsai's "linked project" large-model workflow), so it returns None
# there instead of an IfcDimensionalExponents entity. calculate_unit_scale()
# used to access unit.Dimensions.LengthExponent unconditionally for
# every IfcSIUnit, which crashed project loading for any linked file.
# See the PR discussion for a standalone reproduction script.
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
length = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT")
ifcopenshell.api.unit.assign_unit(self.file, units=[length])
patcher = Ifc2Sql.Patcher(self.file, sql_type="SQLite")
patcher.patch()
tmp_file = Path(tempfile.mkstemp(suffix=".ifcsqlite")[1])
ifcpatch.write(patcher.get_output(), tmp_file)
try:
linked_file = ifcopenshell.open(str(tmp_file))
assert linked_file.by_type("IfcSIUnit")[0].Dimensions is None
assert subject.calculate_unit_scale(linked_file, "LENGTHUNIT") == 1.0
finally:
if isinstance(linked_file, ifcopenshell.sqlite):
linked_file.db.close()
tmp_file.unlink(missing_ok=True)
class TestGetNamedUnitScale(test.bootstrap.IFC4):
def test_prefix_is_raised_to_the_length_exponent_for_area_and_volume(self):
area = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="AREAUNIT", prefix="DECI")
assert subject.get_named_unit_scale(area) == pytest.approx(0.1**2)
def test_prefix_stays_linear_for_units_that_are_not_a_pure_power_of_length(self):
pressure = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="PRESSUREUNIT", prefix="KILO")
assert subject.get_named_unit_scale(pressure) == pytest.approx(1000)
class TestGetDerivedUnitScale(test.bootstrap.IFC4):
def test_composes_scale_from_elements(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
mass = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="MASSUNIT", prefix="KILO")
volume = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="VOLUMEUNIT")
density = ifcopenshell.api.unit.add_derived_unit(self.file, "MASSDENSITYUNIT", None, {mass: 1, volume: -1})
assert subject.get_derived_unit_scale(density) == 1000.0
def test_unnamed_derived_unit_still_composes(self):
# A made-up "force per unit time" derived unit with no IfcDerivedUnitEnum match.
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
force = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="FORCEUNIT")
time = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="TIMEUNIT", prefix="MILLI")
weird = ifcopenshell.api.unit.add_derived_unit(self.file, "USERDEFINED", "force per time", {force: 1, time: -1})
assert subject.get_derived_unit_scale(weird) == pytest.approx(1 / 0.001)
class TestGetUnitScale(test.bootstrap.IFC4):
def test_dispatches_to_named_unit_scale_for_si_and_conversion_based_units(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
mm = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT", prefix="MILLI")
ft = ifcopenshell.api.unit.add_conversion_based_unit(self.file, name="foot")
assert subject.get_unit_scale(mm) == subject.get_named_unit_scale(mm)
assert subject.get_unit_scale(ft) == subject.get_named_unit_scale(ft)
def test_dispatches_to_derived_unit_scale_for_derived_units(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
mass = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="MASSUNIT", prefix="KILO")
volume = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="VOLUMEUNIT")
density = ifcopenshell.api.unit.add_derived_unit(self.file, "MASSDENSITYUNIT", None, {mass: 1, volume: -1})
assert subject.get_unit_scale(density) == subject.get_derived_unit_scale(density)
class TestGetUnitSymbol(test.bootstrap.IFC4):
def test_derived_unit_composes_a_symbol(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
force = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="FORCEUNIT")
area = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="AREAUNIT")
modulus = ifcopenshell.api.unit.add_derived_unit(self.file, "MODULUSOFELASTICITYUNIT", None, {force: 1, area: -1})
assert subject.get_unit_symbol(modulus) == "N/m2"
def test_unnamed_derived_unit_still_composes_a_symbol_without_crashing(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
force = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="FORCEUNIT")
time = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="TIMEUNIT")
weird = ifcopenshell.api.unit.add_derived_unit(self.file, "USERDEFINED", "force per time", {force: 1, time: -1})
assert subject.get_unit_symbol(weird) == "N/s"
def test_context_dependent_userdefined_unit_is_not_shadowed_by_the_derived_unit_check(self):
# IfcContextDependentUnit (e.g. "each", "boxes") is a distinct entity
# from IfcDerivedUnit, so the is_a("IfcDerivedUnit") check added for
# derived-unit symbol composition must not shadow this fallback.
each = ifcopenshell.api.unit.add_context_dependent_unit(self.file, name="EACH")
assert subject.get_unit_symbol(each) == "EACH"
class TestIdentifyUnitDimensions(test.bootstrap.IFC4):
def test_matches_a_named_unit_type_by_dimension(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
force = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="FORCEUNIT")
area = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="AREAUNIT")
modulus = ifcopenshell.api.unit.add_derived_unit(self.file, "MODULUSOFELASTICITYUNIT", None, {force: 1, area: -1})
assert subject.identify_unit_dimensions(modulus) == "PRESSUREUNIT"
def test_returns_none_for_no_match(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
force = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="FORCEUNIT")
time = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="TIMEUNIT")
weird = ifcopenshell.api.unit.add_derived_unit(self.file, "USERDEFINED", "force per time", {force: 1, time: -1})
assert subject.identify_unit_dimensions(weird) is None
class TestFormatLength(test.bootstrap.IFC4):
def test_run(self):
+14
View File
@@ -46,6 +46,13 @@ struct FullBufferImpl final : FileReader::Impl {
#else
auto stream = fopen(fn.c_str(), "rb");
#endif
if (stream == nullptr) {
// Missing or unreadable file. Leave the buffer empty so the
// caller sees a zero-length input and reports a read error;
// handing a null FILE* to the CRT below terminates the whole
// process instead of failing the parse.
return;
}
fseek(stream, 0, SEEK_END);
buf_.resize((size_t)ftell(stream));
rewind(stream);
@@ -84,6 +91,13 @@ struct PagedFileImpl final : FileReader::Impl {
#else
fp_ = fopen(fn.c_str(), "rb");
#endif
if (fp_ == nullptr) {
// As above: behave like an empty file rather than passing a
// null FILE* to fseek. get() then throws out_of_range for any
// position and fetchPage_() is never reached.
file_size_ = 0;
return;
}
fseek(fp_, 0, SEEK_END);
file_size_ = (size_t)ftell(fp_);
rewind(fp_);
+6 -2
View File
@@ -47,6 +47,10 @@ class Patcher:
This is for AGS version 3.
The input file is not read. A new IFC4X3 model is built from the AGS
data, containing one IfcBorehole per location with its strata nested
inside as IfcGeotechnicalStratum.
:param ags_file: The AGS file to convert.
:filter_glob ags_file: *.ags
:param docs_dir: The directory URI where documents are stored
@@ -55,8 +59,8 @@ class Patcher:
.. code:: python
result = ifcpatch.execute({"input": fn, "file": model, "recipe": "ExtractPropertiesToSQLite"})
ifcpatch.write(result, "output.sqlite")
result = ifcpatch.execute({"file": model, "recipe": "AGS2IFC", "arguments": ["data.ags", "docs"]})
ifcpatch.write(result, "boreholes.ifc")
"""
self.file = file
self.logger = logger
+16 -3
View File
@@ -102,13 +102,26 @@ def clash(
tolerance: float = 0.002,
scope: str = "storey",
) -> dict[str, Any]:
"""Check element for geometric clashes against other elements.
"""Check one element for geometric clashes against other elements.
Reports hard intersections and, optionally, violations of a required
clearance. Returns the overall ``pass``, the ``scope`` actually used, a
``checks`` block in which each clash names the other ``element``, the
clash ``type``, the ``distance`` and the two closest points ``p1``/``p2``,
and a de-duplicated flat ``elements`` list of everything involved.
Geometry is computed for every element in scope, so this is slow on large
models; ``pass`` is ``None`` with an ``error`` when the element has no
usable geometry.
:param model: The IFC model.
:param element: The element to check.
:param clearance: Minimum clearance distance; if provided, runs clearance check.
:param clearance: Minimum required clearance distance; when given, also
runs the clearance check alongside the intersection check.
:param tolerance: Intersection tolerance in meters (default 0.002).
:param scope: Which elements to check against: "storey" or "all".
:param scope: ``"storey"`` (default) checks only elements sharing the
same spatial container; ``"all"`` checks every ``IfcElement``.
``"storey"`` falls back to ``"all"`` when the element has no spatial
container.
:return: Dict with clash results suitable for JSON serialization.
"""
result: dict[str, Any] = {"element": _ref(element)}
+12 -3
View File
@@ -26,10 +26,19 @@ def _cost_item_to_dict(item: ifcopenshell.entity_instance, max_depth: int | None
def cost(model: ifcopenshell.file, max_depth: int | None = None) -> list[dict[str, Any]]:
"""Return a list of IfcCostSchedule entries with nested cost item trees.
"""List the cost schedules: bills of quantities and their cost items.
max_depth limits how many levels of subitems are expanded (None = unlimited).
At the cutoff level, subitems is replaced with {"truncated": True, "count": N}.
Covers ``IfcCostSchedule`` only — this is the money dimension of the
model; see ``schedule()`` in this module for the construction programme.
Each cost item reports its cost ``values`` as ``formula`` label and
``category`` pairs, together with its nested ``subitems``. Returns an
empty list when the model has no cost schedules.
:param model: The in-memory IFC model.
:param max_depth: Levels of cost item nesting to expand, counting root
items as level 1. Past the cutoff ``subitems`` is replaced by a
``{"truncated": True, "count": N}`` marker giving the number of items
not expanded. ``None`` (default) expands to unlimited depth.
"""
result = []
for cost_schedule in model.by_type("IfcCostSchedule"):
+10 -1
View File
@@ -188,7 +188,16 @@ def _material_to_dict(material: ifcopenshell.entity_instance | None) -> dict[str
def info(model: ifcopenshell.file, element: ifcopenshell.entity_instance) -> dict[str, Any]:
"""Return deep inspection data for an element."""
"""Inspect a single entity in depth.
Returns the entity's direct ``attributes`` plus, where present,
``property_sets``, ``element_type``, ``material``, ``container``,
``placement`` and ``geometry_summary``. Keys are omitted when the
information is unavailable.
:param model: The in-memory IFC model.
:param element: The entity to inspect.
"""
result: dict[str, Any] = {
"id": element.id(),
"type": element.is_a(),
+7 -1
View File
@@ -22,7 +22,13 @@ import ifcopenshell
def materials(model: ifcopenshell.file) -> list[dict]:
"""Return all materials and material sets from the model.
"""List the materials and material sets defined in the model.
Returns a single list covering ``IfcMaterial`` (with its category),
``IfcMaterialLayerSet`` (each layer's name, thickness, material and
ventilation flag), ``IfcMaterialConstituentSet`` (constituent names,
materials and fractions) and ``IfcMaterialProfileSet`` (profile names and
materials). Every entry carries the step ID of the material entity.
:param model: The in-memory IFC model.
:return: List of dicts covering IfcMaterial, IfcMaterialLayerSet,
+16 -1
View File
@@ -182,7 +182,22 @@ def _collect_elements(data: Any, seen: set[int], result: list[dict[str, Any]]) -
def relations(
model: ifcopenshell.file, element: ifcopenshell.entity_instance, traverse: str | None = None
) -> dict[str, Any] | list[dict[str, Any]]:
"""Return relationships for an element, or hierarchy chain if traverse='up'."""
"""Show how an element relates to the rest of the model.
By default returns a dict whose optional blocks are ``hierarchy`` (parent,
container, aggregate, nest, filled void, voided element), ``children``
(contained, parts, components, openings), ``type_relationship``,
``groups``, ``systems``, ``zones``, ``material``, ``referenced_structures``
and ``connections`` (connected to/from, ports), plus a de-duplicated flat
``elements`` list of everything referenced. Blocks with nothing to report
are omitted.
:param model: The IFC model.
:param element: The element to examine.
:param traverse: Set to ``'up'`` to instead return the chain of ancestors
from the element to ``IfcProject`` as a flat list. Any other value
gives the default behaviour.
"""
if traverse == "up":
return _traverse_up(element)
result = _all_relations(model, element)
+13 -3
View File
@@ -37,10 +37,20 @@ def _task_to_dict(task: ifcopenshell.entity_instance, max_depth: int | None, dep
def schedule(model: ifcopenshell.file, max_depth: int | None = None) -> list[dict[str, Any]]:
"""Return a list of IfcWorkSchedule entries with nested task trees.
"""List the construction programme: work schedules and their task trees.
max_depth limits how many levels of subtasks are expanded (None = unlimited).
At the cutoff level, subtasks is replaced with {"truncated": True, "count": N}.
Covers ``IfcWorkSchedule`` only — this is the time dimension of the
model; see ``cost()`` in this module for the money dimension. Each
schedule lists its tasks recursively, and each task carries its scheduled
``start`` and ``finish``, an ``is_milestone`` flag, the products it
``outputs`` and its ``subtasks``. Returns an empty list when the model has
no work schedules.
:param model: The in-memory IFC model.
:param max_depth: Levels of subtask nesting to expand, counting root tasks
as level 1. Past the cutoff ``subtasks`` is replaced by a
``{"truncated": True, "count": N}`` marker giving the number of tasks
not expanded. ``None`` (default) expands to unlimited depth.
"""
result = []
for work_schedule in model.by_type("IfcWorkSchedule"):
+9 -1
View File
@@ -8,7 +8,15 @@ import ifcopenshell.util.doc
def schema(model: ifcopenshell.file, entity_type: str) -> dict[str, Any]:
"""Return IFC class documentation for entity_type from model's schema version."""
"""Look up the IFC documentation for an entity class.
Returns the class ``description``, its ``predefined_types``, per-attribute
documentation and a ``spec_url``, resolved against the model's schema
version. Returns an ``error`` key for an unknown class.
:param model: The in-memory IFC model, used only for its schema version.
:param entity_type: IFC class name, for example ``'IfcWall'``.
"""
schema_name = model.schema
try:
doc = ifcopenshell.util.doc.get_entity_doc(schema_name, entity_type)
+9 -1
View File
@@ -26,7 +26,15 @@ import ifcopenshell
def summary(model: ifcopenshell.file) -> dict[str, Any]:
"""Return a model overview with schema, element counts, and project info."""
"""Summarise the model: schema, entity counts and project info.
Returns the ``schema`` version, ``total_entities``, and a ``project``
block with the id, name and description of the first ``IfcProject``
(omitted if the model has none). The count covers every entity in the
file, not just physical elements.
:param model: The in-memory IFC model.
"""
# Count elements by IFC type, sorted by count descending
type_counter: Counter[str] = Counter()
total = 0
+11 -1
View File
@@ -59,7 +59,17 @@ def _build_spatial_node(element: ifcopenshell.entity_instance) -> dict[str, Any]
def tree(model: ifcopenshell.file) -> dict[str, Any] | list[dict[str, Any]]:
"""Return the spatial hierarchy tree starting from IfcProject."""
"""Return the spatial hierarchy of the model as a nested tree.
Starts at ``IfcProject`` and descends through decomposition (site,
building, storeys) and containment (the elements placed in each storey).
Every node carries ``id``, ``type`` and ``name``; ``children`` holds
decomposed sub-spaces and ``elements`` holds contained elements, and
either key is omitted when empty. Returns a list when the file contains
several projects, or an ``error`` key when it contains none.
:param model: The in-memory IFC model.
"""
projects = model.by_type("IfcProject")
if not projects:
return {"error": "No IfcProject found in model"}
+10 -1
View File
@@ -8,7 +8,16 @@ import ifcopenshell.validate
def validate(model: ifcopenshell.file, express_rules: bool = False) -> dict[str, Any]:
"""Validate the model and return a dict with 'valid' bool and 'issues' list."""
"""Validate the model against the IFC schema.
Returns ``valid`` together with a list of ``issues``, each carrying a
``level`` and a ``message``. Worth running after a batch of edits and
before writing the model back to disk.
:param model: The in-memory IFC model.
:param express_rules: Also evaluate the schema's EXPRESS rules. Catches
more problems but is considerably slower (default ``False``).
"""
logger = ifcopenshell.validate.json_logger()
ifcopenshell.validate.validate(model, logger, express_rules=express_rules)
issues = [{"level": s["level"], "message": s["message"]} for s in logger.statements]