mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-21 04:32:23 +00:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dff9e7d1c2 | |||
| 57c4dde34d | |||
| 1af5cc9bbe | |||
| 307836049f | |||
| 223f358f28 | |||
| 8dc1ee55b5 | |||
| 33f61ef344 | |||
| f05dd4aea5 | |||
| 7ed8584edc | |||
| c5ba22451f | |||
| 048242783e | |||
| 6f3acc84ee | |||
| e077390e3d | |||
| 80cc603932 | |||
| a11ebdf8c4 | |||
| b997726564 | |||
| 25713a486a | |||
| d3b6b82151 | |||
| 9e6797e172 | |||
| 1f9a0a53bb | |||
| b82c4c53fe | |||
| 8deefe497c | |||
| 9621388953 |
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -671,7 +671,13 @@ class BIMCameraProperties(PropertyGroup):
|
||||
# Rounding is necessary to avoid float garbage differences
|
||||
# forcing unnecessary representation update.
|
||||
def round_(f: float) -> float:
|
||||
return round(f, 6)
|
||||
# "+ 0.0" normalises -0.0 to 0.0. round() keeps the sign of zero and
|
||||
# json.dumps writes it out as "-0.0", so without this a matrix differing
|
||||
# only in a zero's sign serialises to a different string forever: the
|
||||
# cached blob never matches and the camera datablock is rebuilt on every
|
||||
# create_drawing (resetting every PropertyGroup on it). Reflected plan
|
||||
# views hit this because get_camera_shape_matrix negates mat[1][1].
|
||||
return round(f, 6) + 0.0
|
||||
|
||||
representation = json.dumps(
|
||||
{
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -350,6 +350,15 @@ class Geometry(bonsai.core.tool.Geometry):
|
||||
Note that clearing scale has no impact on cameras.
|
||||
"""
|
||||
if cls.is_scaled(obj):
|
||||
if isinstance(obj.data, (bpy.types.Camera, bpy.types.Light, bpy.types.Speaker)):
|
||||
# object.transform_apply cannot touch this data -- it just reports
|
||||
# "Objects have no data to transform" and leaves the scale alone, which
|
||||
# is why the docstring says cameras are unaffected. Skipping the call
|
||||
# keeps that behaviour and avoids the operator resolving bpy.context
|
||||
# from inside the temp_override below, which has been seen to segfault
|
||||
# (EXCEPTION_ACCESS_VIOLATION in BPY_context_member_get) when reached
|
||||
# from a UI-invoked operator via bim.update_representation.
|
||||
return
|
||||
if not obj.data:
|
||||
location, rotation, _ = obj.matrix_world.decompose()
|
||||
obj.matrix_world = Matrix.Translation(location) @ rotation.to_matrix().to_4x4()
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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})"
|
||||
+6
-5
@@ -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
|
||||
|
||||
@@ -51,6 +51,10 @@ def create(
|
||||
|
||||
If geometric representations are created, the alignment stationing referent is also created using the start_station value. IfcReferent.ObjectPlacement
|
||||
is required for linear positiion elements and IfcLinearPlacement is defined relative to alignment curve geometry.
|
||||
This referent's Name follows the same "<alignment name> <station>" convention update_key_point_referents() uses
|
||||
for its own key-point referents (e.g. "MyAlignment 49+00.00"), so that every referent nested under an alignment
|
||||
is identifiable by name alone, without needing to inspect its Pset_Stationing or placement to know which
|
||||
alignment it belongs to.
|
||||
|
||||
:param file:
|
||||
:param name: name assigned to IfcAlignment.Name
|
||||
@@ -86,7 +90,7 @@ def create(
|
||||
if include_geometry:
|
||||
_create_geometric_representation(file, alignment)
|
||||
|
||||
referent_name = ifcopenshell.util.alignment.station_as_string(file, start_station)
|
||||
referent_name = f"{name} {ifcopenshell.util.alignment.station_as_string(file, start_station)}"
|
||||
referent = ifcopenshell.api.alignment.add_stationing_referent(file, referent_name, alignment, 0.0, start_station)
|
||||
|
||||
for layout in alignment_layouts:
|
||||
|
||||
@@ -128,6 +128,11 @@ def create_as_polyline(
|
||||
|
||||
The IfcAlignment is aggreated to IfcProject
|
||||
|
||||
The stationing referent created from start_station has Name "<alignment name> <station>"
|
||||
(e.g. "MyAlignment 49+00.00"), the same convention update_key_point_referents() and
|
||||
create() use for their own referents, so every referent nested under an alignment is
|
||||
identifiable by name alone.
|
||||
|
||||
:param file:
|
||||
:param name: name assigned to IfcAlignment.Name
|
||||
:param points: sequence of points defining the polyline
|
||||
@@ -142,8 +147,8 @@ def create_as_polyline(
|
||||
_create_polyline_representation(file, alignment, points)
|
||||
|
||||
# define stationing
|
||||
name = ifcopenshell.util.alignment.station_as_string(file, start_station)
|
||||
referent = ifcopenshell.api.alignment.add_stationing_referent(file, name, alignment, 0.0, start_station)
|
||||
referent_name = f"{alignment.Name} {ifcopenshell.util.alignment.station_as_string(file, start_station)}"
|
||||
referent = ifcopenshell.api.alignment.add_stationing_referent(file, referent_name, alignment, 0.0, start_station)
|
||||
|
||||
# IFC 4.1.4.1.1 Alignment Aggregation To Project
|
||||
project = file.by_type("IfcProject")[0]
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
+9
-3
@@ -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"):
|
||||
+108
@@ -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,233 @@
|
||||
# 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 an
|
||||
IfcAlignment (TypeError is raised otherwise), but need not be the IfcAlignment that
|
||||
directly nests `layout` -- passing an ancestor's own IfcRelNests is supported
|
||||
specifically so that a vertical/cant layout living under a child IfcAlignment (per CT
|
||||
4.1.4.4.1.2, once a second vertical layout is added) can still have its key-point
|
||||
referents named after and nested to the top-level parent alignment, matching how the
|
||||
alignment's horizontal key points are named, rather than a generic "Child of X" name.
|
||||
When `rel_nests` is given, `rel_nests.RelatingObject` -- not `layout`'s own direct
|
||||
parent -- is used for both the created referents' Name and the returned IfcRelNests. If
|
||||
omitted, a new IfcRelNests is always created and related to `layout`'s own direct
|
||||
parent alignment -- 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()}"
|
||||
)
|
||||
|
||||
layout_alignment = ifcopenshell.api.alignment.get_alignment(layout)
|
||||
if layout_alignment is None:
|
||||
raise ValueError(f"{layout.is_a()} #{layout.id()} is not nested under an IfcAlignment.")
|
||||
|
||||
# `alignment` is used below for referent naming (and as the fallback-placement basis) --
|
||||
# it defaults to layout's own direct parent, but an explicitly passed rel_nests overrides
|
||||
# it with rel_nests.RelatingObject instead (see the rel_nests docstring above). Station
|
||||
# computation always uses layout_alignment, unaffected by this -- get_alignment_start_station
|
||||
# already walks up to the true top-level alignment's own stationing referent regardless of
|
||||
# which (possibly child) alignment it's given.
|
||||
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()}"
|
||||
)
|
||||
alignment = rel_nests.RelatingObject
|
||||
else:
|
||||
alignment = layout_alignment
|
||||
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, layout_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
|
||||
@@ -28,7 +28,7 @@ import ifcopenshell.util.sequence
|
||||
|
||||
def create_baseline(
|
||||
file: ifcopenshell.file, work_schedule: ifcopenshell.entity_instance, name: Optional[str] = None
|
||||
) -> None:
|
||||
) -> ifcopenshell.entity_instance:
|
||||
"""Creates a baseline for your Work Schedule
|
||||
|
||||
Using a IfcWorkSchdule having PredefinedType=PLANNED,
|
||||
@@ -42,7 +42,7 @@ def create_baseline(
|
||||
* Same Construction Resources
|
||||
* Same Resource Relationships
|
||||
|
||||
:param work_schedule: The planned work_schedule to baseline
|
||||
:param work_schedule: The planned work schedule to baseline
|
||||
:param name: baseline work schedule name
|
||||
:return: The baseline work_schedule
|
||||
|
||||
@@ -51,7 +51,7 @@ def create_baseline(
|
||||
.. code:: python
|
||||
|
||||
# We have a Work Schedule
|
||||
planned_work_schedule = WorkSchedule(name="Design new feature",predefinedType="PLANNED", deadline="2023-03-01")
|
||||
planned_work_schedule = ifcopenshell.api.sequence.add_work_schedule(model, name="Planned Construction Schedule")
|
||||
|
||||
# And now we have a baseline for our Work Schedule
|
||||
baseline_work_schedule = ifcopenshell.api.sequence.create_baseline(file, work_schedule=planned_work_schedule, name="Baseline 1")
|
||||
@@ -64,24 +64,23 @@ def create_baseline(
|
||||
class Usecase:
|
||||
file: ifcopenshell.file
|
||||
|
||||
def execute(self, work_schedule: ifcopenshell.entity_instance, name: Union[str, None]) -> None:
|
||||
# create work schedule
|
||||
if not work_schedule.PredefinedType == "PLANNED":
|
||||
return
|
||||
def execute(
|
||||
self, work_schedule: ifcopenshell.entity_instance, name: Union[str, None]
|
||||
) -> ifcopenshell.entity_instance:
|
||||
if work_schedule.PredefinedType != "PLANNED":
|
||||
raise ValueError("Only a PLANNED work schedule can be baselined.")
|
||||
baseline_work_schedule = ifcopenshell.api.sequence.add_work_schedule(
|
||||
self.file, name=work_schedule.Name, predefined_type="BASELINE"
|
||||
self.file, name=name or work_schedule.Name, predefined_type="BASELINE"
|
||||
)
|
||||
baseline_work_schedule.Name = name
|
||||
self.create_baseline_reference(work_schedule, baseline_work_schedule)
|
||||
for summary_task in ifcopenshell.util.sequence.get_root_tasks(work_schedule):
|
||||
res = ifcopenshell.api.sequence.duplicate_task(self.file, task=summary_task)
|
||||
assert isinstance(res, list)
|
||||
current, duplicate = res
|
||||
current, duplicate = ifcopenshell.api.sequence.duplicate_task(self.file, task=summary_task)
|
||||
ifcopenshell.api.control.assign_control(
|
||||
self.file, relating_control=baseline_work_schedule, related_objects=[duplicate[0]]
|
||||
)
|
||||
for i, task in enumerate(current):
|
||||
self.create_baseline_reference(task, duplicate[i])
|
||||
return baseline_work_schedule
|
||||
|
||||
def create_baseline_reference(
|
||||
self, relating_object: ifcopenshell.entity_instance, related_object: ifcopenshell.entity_instance
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -69,21 +69,21 @@ def station_as_string(file: ifcopenshell.file, sta: float):
|
||||
Returns a stringized version of a station. Example 100.0 is 1+00.00 as a stationing string.
|
||||
If the project units are SI-based, the string is in the format xxx+yyy.zzz
|
||||
If the project units are Emperial-based, the string is in the format xx+yy.zz
|
||||
|
||||
:param station: the station to be stringized
|
||||
:return: stringized station
|
||||
"""
|
||||
|
||||
unit_type = ifcopenshell.util.unit.get_project_unit(file, "LENGTHUNIT")
|
||||
project_unit_to_metres = ifcopenshell.util.unit.calculate_unit_scale(file)
|
||||
if unit_type.is_a("IfcConversionBasedUnit"):
|
||||
station = ifcopenshell.util.unit.convert(
|
||||
sta, from_unit=unit_type.Name, from_prefix=None, to_unit="foot", to_prefix=None
|
||||
)
|
||||
# xx+yy.zz display is inherently foot-based, regardless of which foot variant
|
||||
# (international vs. US survey, etc.) the project's own unit actually is.
|
||||
station = sta * project_unit_to_metres / 0.3048
|
||||
plus_seperator = 2
|
||||
precision = 2
|
||||
else:
|
||||
station = ifcopenshell.util.unit.convert(
|
||||
sta, from_unit=unit_type.Name, from_prefix=unit_type.Prefix, to_unit="meter", to_prefix=None
|
||||
)
|
||||
station = sta * project_unit_to_metres
|
||||
plus_seperator = 3
|
||||
precision = 3
|
||||
|
||||
|
||||
@@ -720,7 +720,29 @@ def calculate_unit_scale(ifc_file: ifcopenshell.file, unit_type: str = "LENGTHUN
|
||||
unit_scale *= conversion_factor.ValueComponent.wrappedValue
|
||||
unit = conversion_factor.UnitComponent
|
||||
if unit.is_a("IfcSIUnit"):
|
||||
unit_scale *= get_prefix_multiplier(unit.Prefix)
|
||||
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 = unit.Dimensions
|
||||
length_exponent = dimensions.LengthExponent
|
||||
if length_exponent > 0 and not any(
|
||||
(
|
||||
dimensions.MassExponent,
|
||||
dimensions.TimeExponent,
|
||||
dimensions.ElectricCurrentExponent,
|
||||
dimensions.ThermodynamicTemperatureExponent,
|
||||
dimensions.AmountOfSubstanceExponent,
|
||||
dimensions.LuminousIntensityExponent,
|
||||
)
|
||||
):
|
||||
prefix_multiplier **= length_exponent
|
||||
unit_scale *= prefix_multiplier
|
||||
return unit_scale
|
||||
|
||||
|
||||
@@ -940,7 +962,8 @@ def convert_file_length_units(ifc_file: ifcopenshell.file, target_units: str = "
|
||||
)
|
||||
|
||||
unit_assignment = get_unit_assignment(file_patched)
|
||||
unit_assignment.Units = [new_length, *(u for u in unit_assignment.Units if u.UnitType != new_length.UnitType)]
|
||||
# UnitType not available on IfcMonetaryUnit
|
||||
unit_assignment.Units = [new_length, *(u for u in unit_assignment.Units if getattr(u, 'UnitType', None) != new_length.UnitType)]
|
||||
if not file_patched.get_total_inverses(old_length):
|
||||
ifcopenshell.util.element.remove_deep2(file_patched, old_length)
|
||||
|
||||
|
||||
@@ -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,11 +39,11 @@ 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"
|
||||
assert referent.Name == "TestAlignment 2+000.000"
|
||||
assert ifcopenshell.util.element.get_pset(element=referent, name="Pset_Stationing")
|
||||
assert ifcopenshell.util.element.get_pset(element=referent, name="Pset_Stationing", prop="Station") == 2000.0
|
||||
assert referent.ObjectPlacement != None
|
||||
@@ -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")
|
||||
|
||||
@@ -20,6 +20,39 @@
|
||||
import ifcopenshell.api.alignment
|
||||
import ifcopenshell.api.context
|
||||
import ifcopenshell.api.unit
|
||||
import ifcopenshell.util.element
|
||||
|
||||
|
||||
def test_create_stationing_referent_name_includes_alignment_name():
|
||||
"""create() creates an initial stationing IfcReferent from start_station
|
||||
(see add_stationing_referent()). Its Name must include the alignment's
|
||||
own name, the same "<alignment name> <station>" convention
|
||||
update_key_point_referents() uses for its own referents -- otherwise
|
||||
this referent is indistinguishable by name alone from the same-named
|
||||
referent of any OTHER alignment in the same file, unlike every other
|
||||
referent in the model."""
|
||||
file = ifcopenshell.file(schema="IFC4X3_ADD2")
|
||||
project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
|
||||
length = ifcopenshell.api.unit.add_conversion_based_unit(file, name="foot")
|
||||
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,
|
||||
)
|
||||
|
||||
alignment = ifcopenshell.api.alignment.create(file, "TestAlignment", start_station=4900.0)
|
||||
|
||||
referents = [
|
||||
r
|
||||
for r in ifcopenshell.util.element.get_components(alignment)
|
||||
if r.is_a("IfcReferent") and ifcopenshell.util.element.get_pset(r, name="Pset_Stationing", prop="Station") == 4900.0
|
||||
]
|
||||
assert len(referents) == 1
|
||||
assert referents[0].Name == "TestAlignment 49+00.00"
|
||||
|
||||
|
||||
def test_create():
|
||||
@@ -84,3 +117,4 @@ def test_create():
|
||||
|
||||
|
||||
test_create()
|
||||
test_create_stationing_referent_name_includes_alignment_name()
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import ifcopenshell.api.alignment
|
||||
import ifcopenshell.api.unit
|
||||
import ifcopenshell.util.element
|
||||
|
||||
|
||||
def test_create_as_polyline():
|
||||
@@ -40,10 +41,19 @@ def test_create_as_polyline():
|
||||
file.createIfcCartesianPoint((-585.0, 3275.2, 56.2)),
|
||||
]
|
||||
|
||||
alignment = ifcopenshell.api.alignment.create_as_polyline(file, "A1", points)
|
||||
alignment = ifcopenshell.api.alignment.create_as_polyline(file, "A1", points, start_station=100.0)
|
||||
curve = ifcopenshell.api.alignment.get_curve(alignment)
|
||||
assert curve.is_a("IfcPolyline")
|
||||
assert len(curve.Points) == 10
|
||||
|
||||
# stationing referent's Name must include the alignment's own name, the
|
||||
# same "<alignment name> <station>" convention create() and
|
||||
# update_key_point_referents() use -- previously this reassigned the
|
||||
# local `name` variable (shadowing the "A1" parameter) to just the bare
|
||||
# station string, losing the alignment name entirely.
|
||||
referents = [r for r in ifcopenshell.util.element.get_components(alignment) if r.is_a("IfcReferent")]
|
||||
assert len(referents) == 1
|
||||
assert referents[0].Name == "A1 0+100.000"
|
||||
|
||||
|
||||
test_create_as_polyline()
|
||||
|
||||
@@ -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
|
||||
|
||||
+307
@@ -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,438 @@
|
||||
# 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_rel_nests_from_ancestor_used_for_naming_and_nesting():
|
||||
"""A vertical layout living under a child alignment (once a second vertical layout is
|
||||
added, per CT 4.1.4.4.1.2) can still have its key-point referents named after and nested
|
||||
to an ancestor alignment's own rel_nests -- e.g. the same one already holding that
|
||||
ancestor's horizontal key points -- rather than the child's generic "Child of X" name."""
|
||||
file = _new_file()
|
||||
alignment = ifcopenshell.api.alignment.create(file, "A1", include_vertical=False, start_station=100.0)
|
||||
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
|
||||
horizontal_nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
|
||||
horizontal_count = len(horizontal_nest.RelatedObjects)
|
||||
|
||||
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)
|
||||
|
||||
dp = 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, dp)
|
||||
|
||||
result = ifcopenshell.api.alignment.update_key_point_referents(file, child_vertical, rel_nests=horizontal_nest)
|
||||
|
||||
assert result == horizontal_nest
|
||||
assert result.RelatingObject == alignment
|
||||
assert len(result.RelatedObjects) == horizontal_count + 2
|
||||
assert all(r.Name.startswith("A1 ") for r in result.RelatedObjects)
|
||||
assert not any("Child of" in r.Name for r in result.RelatedObjects)
|
||||
|
||||
|
||||
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_rel_nests_from_ancestor_used_for_naming_and_nesting()
|
||||
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
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
# IfcOpenShell - IFC toolkit and geometry engine
|
||||
# Copyright (C) 2026 IfcOpenShell contributors
|
||||
#
|
||||
# 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.
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
import ifcopenshell.api.root
|
||||
import ifcopenshell.api.sequence
|
||||
import ifcopenshell.util.sequence
|
||||
import test.bootstrap
|
||||
|
||||
|
||||
class TestCreateBaseline(test.bootstrap.IFC4):
|
||||
def create_planned_schedule(self, name="Design & Build"):
|
||||
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
|
||||
return ifcopenshell.api.sequence.add_work_schedule(self.file, name=name, predefined_type="PLANNED")
|
||||
|
||||
def test_returns_the_created_baseline_schedule(self):
|
||||
planned = self.create_planned_schedule()
|
||||
root_task = ifcopenshell.api.sequence.add_task(self.file, work_schedule=planned, name="Design")
|
||||
|
||||
baseline = ifcopenshell.api.sequence.create_baseline(self.file, work_schedule=planned, name="Baseline 1")
|
||||
|
||||
assert baseline.is_a("IfcWorkSchedule")
|
||||
assert baseline.Name == "Baseline 1"
|
||||
assert baseline.PredefinedType == "BASELINE"
|
||||
baseline_roots = ifcopenshell.util.sequence.get_root_tasks(baseline)
|
||||
assert [task.Name for task in baseline_roots] == [root_task.Name]
|
||||
assert baseline_roots != [root_task]
|
||||
|
||||
def test_falls_back_to_the_planned_schedule_name(self):
|
||||
planned = self.create_planned_schedule()
|
||||
|
||||
baseline = ifcopenshell.api.sequence.create_baseline(self.file, work_schedule=planned)
|
||||
|
||||
assert baseline.Name == "Design & Build"
|
||||
|
||||
def test_leaves_the_name_null_when_both_names_are_omitted(self):
|
||||
planned = self.create_planned_schedule()
|
||||
planned.Name = None
|
||||
|
||||
baseline = ifcopenshell.api.sequence.create_baseline(self.file, work_schedule=planned)
|
||||
|
||||
assert baseline.Name is None
|
||||
|
||||
def test_rejects_a_non_planned_schedule(self):
|
||||
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
|
||||
actual = ifcopenshell.api.sequence.add_work_schedule(self.file, predefined_type="ACTUAL")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
ifcopenshell.api.sequence.create_baseline(self.file, work_schedule=actual)
|
||||
|
||||
def test_baselines_a_schedule_without_tasks(self):
|
||||
planned = self.create_planned_schedule()
|
||||
|
||||
baseline = ifcopenshell.api.sequence.create_baseline(self.file, work_schedule=planned, name="Baseline 1")
|
||||
|
||||
assert ifcopenshell.util.sequence.get_root_tasks(baseline) == []
|
||||
|
||||
def test_baselines_every_root_task(self):
|
||||
planned = self.create_planned_schedule()
|
||||
ifcopenshell.api.sequence.add_task(self.file, work_schedule=planned, name="Design")
|
||||
ifcopenshell.api.sequence.add_task(self.file, work_schedule=planned, name="Construction")
|
||||
|
||||
baseline = ifcopenshell.api.sequence.create_baseline(self.file, work_schedule=planned, name="Baseline 1")
|
||||
|
||||
baseline_roots = ifcopenshell.util.sequence.get_root_tasks(baseline)
|
||||
assert sorted(task.Name for task in baseline_roots) == ["Construction", "Design"]
|
||||
|
||||
def test_baselines_nested_tasks(self):
|
||||
planned = self.create_planned_schedule()
|
||||
root_task = ifcopenshell.api.sequence.add_task(self.file, work_schedule=planned, name="Construction")
|
||||
ifcopenshell.api.sequence.add_task(self.file, parent_task=root_task, name="Foundations")
|
||||
ifcopenshell.api.sequence.add_task(self.file, parent_task=root_task, name="Superstructure")
|
||||
|
||||
baseline = ifcopenshell.api.sequence.create_baseline(self.file, work_schedule=planned, name="Baseline 1")
|
||||
|
||||
baseline_root = ifcopenshell.util.sequence.get_root_tasks(baseline)[0]
|
||||
nested = ifcopenshell.util.sequence.get_nested_tasks(baseline_root)
|
||||
assert sorted(task.Name for task in nested) == ["Foundations", "Superstructure"]
|
||||
assert len(self.file.by_type("IfcTask")) == 6
|
||||
|
||||
def test_baselines_task_attributes_and_times(self):
|
||||
planned = self.create_planned_schedule()
|
||||
task = ifcopenshell.api.sequence.add_task(
|
||||
self.file, work_schedule=planned, name="Foundations", identification="A1", description="Pour concrete"
|
||||
)
|
||||
ifcopenshell.api.sequence.add_task_time(self.file, task=task)
|
||||
ifcopenshell.api.sequence.edit_task_time(
|
||||
self.file, task_time=task.TaskTime, attributes={"ScheduleDuration": "P5D"}
|
||||
)
|
||||
|
||||
baseline = ifcopenshell.api.sequence.create_baseline(self.file, work_schedule=planned, name="Baseline 1")
|
||||
|
||||
baseline_task = ifcopenshell.util.sequence.get_root_tasks(baseline)[0]
|
||||
assert baseline_task.Identification == "A1"
|
||||
assert baseline_task.Description == "Pour concrete"
|
||||
assert baseline_task.TaskTime != task.TaskTime
|
||||
assert baseline_task.TaskTime.ScheduleDuration == "P5D"
|
||||
|
||||
def test_baselines_sequence_relationships_between_tasks(self):
|
||||
planned = self.create_planned_schedule()
|
||||
root_task = ifcopenshell.api.sequence.add_task(self.file, work_schedule=planned, name="Construction")
|
||||
predecessor = ifcopenshell.api.sequence.add_task(self.file, parent_task=root_task, name="Foundations")
|
||||
successor = ifcopenshell.api.sequence.add_task(self.file, parent_task=root_task, name="Superstructure")
|
||||
ifcopenshell.api.sequence.assign_sequence(self.file, relating_process=predecessor, related_process=successor)
|
||||
|
||||
baseline = ifcopenshell.api.sequence.create_baseline(self.file, work_schedule=planned, name="Baseline 1")
|
||||
|
||||
baseline_root = ifcopenshell.util.sequence.get_root_tasks(baseline)[0]
|
||||
nested = {task.Name: task for task in ifcopenshell.util.sequence.get_nested_tasks(baseline_root)}
|
||||
rels = nested["Foundations"].IsPredecessorTo
|
||||
assert len(rels) == 1
|
||||
assert rels[0].RelatedProcess == nested["Superstructure"]
|
||||
|
||||
def test_references_the_planned_schedule_and_tasks(self):
|
||||
planned = self.create_planned_schedule()
|
||||
root_task = ifcopenshell.api.sequence.add_task(self.file, work_schedule=planned, name="Construction")
|
||||
subtask = ifcopenshell.api.sequence.add_task(self.file, parent_task=root_task, name="Foundations")
|
||||
|
||||
baseline = ifcopenshell.api.sequence.create_baseline(self.file, work_schedule=planned, name="Baseline 1")
|
||||
|
||||
baseline_root = ifcopenshell.util.sequence.get_root_tasks(baseline)[0]
|
||||
baseline_subtask = ifcopenshell.util.sequence.get_nested_tasks(baseline_root)[0]
|
||||
references = {
|
||||
rel.RelatingObject: list(rel.RelatedObjects) for rel in self.file.by_type("IfcRelDefinesByObject")
|
||||
}
|
||||
assert references[planned] == [baseline]
|
||||
assert references[root_task] == [baseline_root]
|
||||
assert references[subtask] == [baseline_subtask]
|
||||
|
||||
def test_reuses_the_existing_reference_for_further_baselines(self):
|
||||
planned = self.create_planned_schedule()
|
||||
|
||||
first = ifcopenshell.api.sequence.create_baseline(self.file, work_schedule=planned, name="Baseline 1")
|
||||
second = ifcopenshell.api.sequence.create_baseline(self.file, work_schedule=planned, name="Baseline 2")
|
||||
|
||||
assert len(planned.Declares) == 1
|
||||
assert list(planned.Declares[0].RelatedObjects) == [first, second]
|
||||
@@ -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.",
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -77,10 +77,72 @@ def _test_us_stations():
|
||||
assert s == "-1234+56.79"
|
||||
|
||||
|
||||
def _test_custom_named_conversion_based_unit_stations():
|
||||
"""Regression test: station_as_string() must work for an
|
||||
IfcConversionBasedUnit whose Name isn't one of the fixed set
|
||||
ifcopenshell.util.unit.si_conversions recognises (e.g. a project that,
|
||||
reasonably, names its foot-based unit something other than the bare
|
||||
"foot" IfcOpenShell's own add_conversion_based_unit() produces -- for
|
||||
instance to distinguish the US survey foot, 1200/3937 m exactly, from
|
||||
the international foot, 0.3048 m exactly, which differ by ~2 ppm and
|
||||
are NOT interchangeable once a project is tied to a US state plane CRS,
|
||||
virtually all of which are defined in US survey feet).
|
||||
|
||||
Previously, station_as_string() converted via
|
||||
ifcopenshell.util.unit.convert(), which looks up the conversion factor
|
||||
BY NAME in si_conversions -- silently substituting a factor of 1.0
|
||||
(i.e. treating the value as if it were already in the display unit) for
|
||||
any unrecognised name, rather than raising an error. For a project unit
|
||||
like "US survey foot" this inflated every station string by the
|
||||
project-unit<->metre ratio (~3.28x), even though the underlying
|
||||
Pset_Stationing.Station numeric value written by
|
||||
ifcopenshell.api.alignment.create()/update_key_point_referents was
|
||||
correct throughout -- only the display text was wrong.
|
||||
"""
|
||||
file = ifcopenshell.file(schema="IFC4X3_ADD2")
|
||||
project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
|
||||
|
||||
# Hand-built rather than via add_conversion_based_unit(), since that
|
||||
# API also resolves its conversion factor by name (si_conversions) and
|
||||
# can't produce a custom name paired with a specific factor.
|
||||
si_unit = file.createIfcSIUnit(UnitType="LENGTHUNIT", Name="METRE")
|
||||
value_component = file.create_entity("IfcReal", wrappedValue=1200.0 / 3937.0) # US survey foot, exact
|
||||
conversion_factor = file.createIfcMeasureWithUnit(value_component, si_unit)
|
||||
exponents = file.createIfcDimensionalExponents(1, 0, 0, 0, 0, 0, 0)
|
||||
length = file.createIfcConversionBasedUnit(exponents, "LENGTHUNIT", "US survey foot", conversion_factor)
|
||||
ifcopenshell.api.unit.assign_unit(file, units=[length])
|
||||
|
||||
# US survey foot and international foot differ by ~2 ppm. At small
|
||||
# station values that's invisible at 2-decimal-place precision, so
|
||||
# these match _test_us_stations()'s "foot" case exactly.
|
||||
s = sta.station_as_string(file, 0.0)
|
||||
assert s == "0+00.00"
|
||||
|
||||
s = sta.station_as_string(file, 100.00)
|
||||
assert s == "1+00.00"
|
||||
|
||||
s = sta.station_as_string(file, -100.00)
|
||||
assert s == "-1+00.00"
|
||||
|
||||
# At a large enough station, ~2 ppm DOES become visible at 2 decimal
|
||||
# places (123456.789 * 2e-6 =~ 0.25) -- this is the real, correct US
|
||||
# survey foot vs. international foot difference, not a bug. Before the
|
||||
# fix, the name-based lookup's silent 1.0 fallback inflated this same
|
||||
# input by ~3.28x to "1234+57.036" -> "4050+82.90"-ish territory, wildly
|
||||
# different from either correct answer -- so this still exercises the
|
||||
# regression, it's just not identical to the "foot" case's value.
|
||||
s = sta.station_as_string(file, 123456.789)
|
||||
assert s == "1234+57.04"
|
||||
|
||||
s = sta.station_as_string(file, -123456.789)
|
||||
assert s == "-1234+57.04"
|
||||
|
||||
|
||||
def test_station_as_string():
|
||||
_test_si_stations()
|
||||
_test_si_stations_millimeter()
|
||||
_test_us_stations()
|
||||
_test_custom_named_conversion_based_unit_stations()
|
||||
|
||||
|
||||
test_station_as_string()
|
||||
|
||||
@@ -200,6 +200,31 @@ 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_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 TestFormatLength(test.bootstrap.IFC4):
|
||||
def test_run(self):
|
||||
|
||||
@@ -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_);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user