mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-24 13:56:50 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cffb933efb | |||
| 223f358f28 | |||
| 8dc1ee55b5 | |||
| 33f61ef344 | |||
| f05dd4aea5 | |||
| 7ed8584edc | |||
| c5ba22451f | |||
| 048242783e |
@@ -170,7 +170,7 @@ class Bsdd(bonsai.core.tool.Bsdd):
|
||||
psets.setdefault(pset, {})
|
||||
|
||||
predefined_value = prop.get("predefinedValue")
|
||||
if predefined_value:
|
||||
if predefined_value and predefined_value != "None":
|
||||
possible_values = [predefined_value]
|
||||
else:
|
||||
possible_values = prop.get("allowedValues", []) or []
|
||||
@@ -369,7 +369,7 @@ class Bsdd(bonsai.core.tool.Bsdd):
|
||||
data = cls.bsdd_properties[bsdd_prop.uri]
|
||||
|
||||
predefined_value = data.get("predefinedValue")
|
||||
if predefined_value:
|
||||
if predefined_value and predefined_value != "None":
|
||||
possible_values = [predefined_value]
|
||||
else:
|
||||
possible_values = data.get("allowedValues", []) or []
|
||||
@@ -401,7 +401,13 @@ class Bsdd(bonsai.core.tool.Bsdd):
|
||||
uris.add(uri)
|
||||
psets = set()
|
||||
for uri in uris:
|
||||
if not (bsdd_class := cls.bsdd_classes.get(uri, None)):
|
||||
try:
|
||||
# Cache may not be populated yet (e.g. a fresh session that never
|
||||
# browsed this class), so fetch on a cache miss instead of skipping.
|
||||
bsdd_class = cls.get_bsdd_class(uri)
|
||||
except Exception:
|
||||
continue
|
||||
if not bsdd_class:
|
||||
continue
|
||||
for class_pset in bsdd_class.get("classProperties", []):
|
||||
if not (pset_name := class_pset.get("propertySet", None)):
|
||||
@@ -409,6 +415,37 @@ class Bsdd(bonsai.core.tool.Bsdd):
|
||||
psets.add((uri, bsdd_class["name"], pset_name))
|
||||
return psets
|
||||
|
||||
@classmethod
|
||||
def get_bsdd_pset_property_values(
|
||||
cls, element: ifcopenshell.entity_instance, pset_name: str
|
||||
) -> dict[str, list[str]]:
|
||||
"""Map bSDD property code -> allowed/predefined values for properties bSDD says
|
||||
are applicable to `element` (via its classification references) under `pset_name`.
|
||||
|
||||
Used to recognise a Pset property as bSDD-sourced even outside the dedicated
|
||||
bSDD add-property flow (e.g. a Pset created in a previous session), so it can
|
||||
still be edited as a picklist.
|
||||
"""
|
||||
result: dict[str, list[str]] = {}
|
||||
for uri, class_name, applicable_pset_name in cls.get_applicable_psets(element):
|
||||
if applicable_pset_name != pset_name:
|
||||
continue
|
||||
bsdd_class = cls.get_bsdd_class(uri)
|
||||
for class_prop in bsdd_class.get("classProperties", []):
|
||||
if class_prop.get("propertySet") != pset_name:
|
||||
continue
|
||||
code = class_prop.get("propertyCode")
|
||||
if not code or code in result:
|
||||
continue
|
||||
predefined_value = class_prop.get("predefinedValue")
|
||||
if predefined_value and predefined_value != "None":
|
||||
result[code] = [predefined_value]
|
||||
continue
|
||||
allowed_values = class_prop.get("allowedValues", []) or []
|
||||
if allowed_values:
|
||||
result[code] = [v["value"] for v in allowed_values]
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def is_applicable(cls, pset_uri: str, element: ifcopenshell.entity_instance) -> bool:
|
||||
uris = set()
|
||||
|
||||
@@ -223,6 +223,18 @@ class Pset(bonsai.core.tool.Pset):
|
||||
elif pset.is_a("IfcMaterialProperties") or pset.is_a("IfcProfileProperties"):
|
||||
pset_props = pset.Properties
|
||||
|
||||
# If this Pset's owning element is classified against a bSDD class that defines
|
||||
# this Pset, recognise properties matching bSDD property codes as picklists,
|
||||
# even though the Pset wasn't necessarily created through the bSDD add-property UI.
|
||||
bsdd_allowed_values: dict[str, list[str]] = {}
|
||||
if pset.is_a("IfcPropertySet"):
|
||||
elements = ifcopenshell.util.element.get_elements_by_pset(pset)
|
||||
if elements:
|
||||
try:
|
||||
bsdd_allowed_values = tool.Bsdd.get_bsdd_pset_property_values(next(iter(elements)), pset.Name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
prop_templates: dict[str, ifcopenshell.entity_instance] = {}
|
||||
if pset_template:
|
||||
prop_templates = {prop.Name: prop for prop in pset_template.HasPropertyTemplates}
|
||||
@@ -286,6 +298,17 @@ class Pset(bonsai.core.tool.Pset):
|
||||
metadata.set_value(metadata.get_value_default() if metadata.is_null else value)
|
||||
process_prop_description(metadata)
|
||||
|
||||
if prop.is_a("IfcPropertySingleValue") and (possible_values := bsdd_allowed_values.get(prop.Name)):
|
||||
str_value = None if value is None else str(value)
|
||||
if str_value is not None and str_value not in possible_values:
|
||||
# Preserve a legacy/imported value that doesn't match the current
|
||||
# bSDD enumeration instead of silently dropping it.
|
||||
possible_values = [*possible_values, str_value]
|
||||
metadata.enum_items = json.dumps(possible_values)
|
||||
metadata.data_type = "enum"
|
||||
if str_value is not None:
|
||||
metadata.enum_value = str_value
|
||||
|
||||
@classmethod
|
||||
def get_prop_template_primitive_type(cls, prop_template: ifcopenshell.entity_instance) -> str:
|
||||
if prop_template.TemplateType in ["Q_LENGTH", "Q_AREA", "Q_VOLUME", "Q_WEIGHT", "Q_TIME"]:
|
||||
|
||||
+1
-1
@@ -1026,7 +1026,7 @@ def apply_ifc_classification_properties(
|
||||
psets = ifcopenshell.util.element.get_psets(element)
|
||||
for prop in classificationProperties:
|
||||
predefinedValue = prop.get("predefinedValue")
|
||||
if not predefinedValue or prop.get("propertyDomainName") != "IFC":
|
||||
if not predefinedValue or predefinedValue == "None" or prop.get("propertyDomainName") != "IFC":
|
||||
continue
|
||||
pset = psets.get(prop["propertySet"])
|
||||
if pset:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -89,6 +89,7 @@ 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
|
||||
@@ -132,6 +133,7 @@ __all__ = [
|
||||
"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",
|
||||
|
||||
@@ -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})"
|
||||
+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)
|
||||
@@ -22,9 +22,9 @@ import ifcopenshell
|
||||
import ifcopenshell.api.alignment
|
||||
import ifcopenshell.api.pset
|
||||
import ifcopenshell.guid
|
||||
import ifcopenshell.util.alignment
|
||||
import ifcopenshell.util.element
|
||||
from ifcopenshell import entity_instance
|
||||
from ifcopenshell.api.alignment._get_key_point_tag import _get_key_point_tag
|
||||
from ifcopenshell.api.alignment._get_segment_start_point_label import (
|
||||
_get_segment_start_point_label,
|
||||
)
|
||||
@@ -32,21 +32,6 @@ from ifcopenshell.api.alignment._sort_nest import _sort_nest
|
||||
from ifcopenshell.api.alignment.update_fallback_position import update_fallback_position
|
||||
|
||||
|
||||
def _get_key_point_referent_nest(layout: entity_instance) -> Optional[entity_instance]:
|
||||
"""
|
||||
Searches layout.IsNestedBy for the IfcRelNests whose RelatedObjects are IfcReferent.
|
||||
|
||||
This is distinct from both get_stationing_nest (scoped to the parent IfcAlignment, and
|
||||
specifically the STATION/station-equation nest) and get_alignment_segment_nest (the *segment*
|
||||
nest that also lives on layout.IsNestedBy, holding IfcAlignmentSegment, never IfcReferent).
|
||||
"""
|
||||
for nest in layout.IsNestedBy:
|
||||
for related_object in nest.RelatedObjects:
|
||||
if related_object.is_a("IfcReferent"):
|
||||
return nest
|
||||
return None
|
||||
|
||||
|
||||
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."""
|
||||
@@ -91,7 +76,7 @@ def _create_key_point_referent(
|
||||
),
|
||||
)
|
||||
|
||||
name = f"{label} ({ifcopenshell.util.alignment.station_as_string(file, station)})"
|
||||
name = f"{alignment.Name} {_get_key_point_tag(file, label, station)}"
|
||||
|
||||
referent = file.createIfcReferent(
|
||||
GlobalId=ifcopenshell.guid.new(),
|
||||
@@ -120,7 +105,8 @@ def update_key_point_referents(
|
||||
Creates IfcReferent key-point markers for every segment transition in an alignment layout.
|
||||
|
||||
Labels are derived from _get_segment_start_point_label (e.g. "P.C.", "P.T.", "P.O.B.",
|
||||
"P.V.C.", ...), with the station appended, e.g. "P.C. (145+98.32)". Different jurisdictions use
|
||||
"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
|
||||
@@ -129,9 +115,11 @@ def update_key_point_referents(
|
||||
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. May live anywhere (e.g. the parent
|
||||
IfcAlignment, the layout, or elsewhere) -- the caller decides. If omitted, an existing
|
||||
referent-nest already on `layout` is reused, or a new one is created and related to `layout`.
|
||||
:param rel_nests: an existing IfcRelNests to (re)populate; its RelatingObject must be the
|
||||
IfcAlignment that nests `layout` (TypeError is raised otherwise). If omitted, a new
|
||||
IfcRelNests is always created and related to that IfcAlignment -- there is no implicit
|
||||
search for or reuse of a previously created nest. Callers who want to regenerate into an
|
||||
existing nest must pass it back in explicitly via `rel_nests`.
|
||||
:param clear: if True, deletes all IfcReferent currently in rel_nests.RelatedObjects (and their
|
||||
Pset_Stationing) before regenerating. If False (default), new referents are appended to
|
||||
whatever already exists -- no deduplication.
|
||||
@@ -158,7 +146,7 @@ def update_key_point_referents(
|
||||
ifcopenshell.api.alignment.register_referent_name_callback(horizontal=my_horizontal_labels)
|
||||
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
|
||||
nest = ifcopenshell.api.alignment.update_key_point_referents(model, horizontal)
|
||||
# nest.RelatedObjects[0].Name starts with "Start (" instead of the default "P.O.B. ("
|
||||
# nest.RelatedObjects[0].Name ends with "(Start)" instead of the default "(P.O.B.)"
|
||||
"""
|
||||
|
||||
expected_types = ["IfcAlignmentHorizontal", "IfcAlignmentVertical", "IfcAlignmentCant"]
|
||||
@@ -167,12 +155,20 @@ def update_key_point_referents(
|
||||
f"Expected entity type to be one of {[_ for _ in expected_types]}, instead received {layout.is_a()}"
|
||||
)
|
||||
|
||||
if rel_nests is None:
|
||||
rel_nests = _get_key_point_referent_nest(layout)
|
||||
if rel_nests is None:
|
||||
rel_nests = file.createIfcRelNests(
|
||||
GlobalId=ifcopenshell.guid.new(), RelatingObject=layout, RelatedObjects=()
|
||||
alignment = ifcopenshell.api.alignment.get_alignment(layout)
|
||||
if alignment is None:
|
||||
raise ValueError(f"{layout.is_a()} #{layout.id()} is not nested under an IfcAlignment.")
|
||||
|
||||
if rel_nests is not None:
|
||||
if not rel_nests.RelatingObject.is_a("IfcAlignment"):
|
||||
raise TypeError(
|
||||
f"Expected rel_nests.RelatingObject to be IfcAlignment, instead received "
|
||||
f"{rel_nests.RelatingObject.is_a()}"
|
||||
)
|
||||
else:
|
||||
rel_nests = file.createIfcRelNests(
|
||||
GlobalId=ifcopenshell.guid.new(), RelatingObject=alignment, RelatedObjects=()
|
||||
)
|
||||
|
||||
if clear:
|
||||
for referent in list(rel_nests.RelatedObjects):
|
||||
@@ -189,7 +185,6 @@ def update_key_point_referents(
|
||||
)
|
||||
return rel_nests
|
||||
|
||||
alignment = ifcopenshell.api.alignment.get_alignment(layout)
|
||||
start_station = ifcopenshell.api.alignment.get_alignment_start_station(file, alignment)
|
||||
curve = ifcopenshell.api.alignment.get_layout_curve(layout)
|
||||
is_horizontal = layout.is_a("IfcAlignmentHorizontal")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -96,6 +96,10 @@ def callback_alignment():
|
||||
yield alignment
|
||||
|
||||
|
||||
def _label(name):
|
||||
return name.rsplit("(", 1)[1].rstrip(")")
|
||||
|
||||
|
||||
def test_with_default_names(default_names_alignment):
|
||||
file = default_names_alignment.file
|
||||
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(default_names_alignment)
|
||||
@@ -107,8 +111,8 @@ def test_with_default_names(default_names_alignment):
|
||||
expected_h = ["P.O.B.", "P.C.", "P.T.", "P.C.", "P.T.", "P.C.", "P.T.", "P.O.E."]
|
||||
expected_v = ["V.P.O.B.", "P.V.C.", "P.V.T.", "P.V.C.", "P.V.T.", "P.V.C.", "P.V.T.", "P.V.C.", "P.V.T.", "V.P.O.E."]
|
||||
|
||||
assert [r.Name.split(" (")[0] for r in h_nest.RelatedObjects] == expected_h
|
||||
assert [r.Name.split(" (")[0] for r in v_nest.RelatedObjects] == expected_v
|
||||
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):
|
||||
@@ -122,7 +126,7 @@ def test_with_callbacks(callback_alignment):
|
||||
expected_h = ["A", "Q", "Q", "Q", "Q", "Q", "Q", "Z"]
|
||||
expected_v = ["a", "q", "q", "q", "q", "q", "q", "q", "q", "z"]
|
||||
|
||||
assert [r.Name.split(" (")[0] for r in h_nest.RelatedObjects] == expected_h
|
||||
assert [r.Name.split(" (")[0] for r in v_nest.RelatedObjects] == expected_v
|
||||
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()
|
||||
@@ -66,6 +66,10 @@ 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)
|
||||
@@ -82,13 +86,13 @@ def test_default_rel_nests_created_when_none_provided():
|
||||
nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
|
||||
|
||||
assert nest.is_a("IfcRelNests")
|
||||
assert nest.RelatingObject == horizontal
|
||||
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_reuses_existing_nest():
|
||||
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)
|
||||
@@ -97,18 +101,31 @@ def test_second_call_without_rel_nests_reuses_existing_nest():
|
||||
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(nest2.RelatedObjects) == 16
|
||||
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)
|
||||
|
||||
# the nest may live anywhere the caller chooses, e.g. hung off the parent IfcAlignment
|
||||
# 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)
|
||||
@@ -118,6 +135,17 @@ def test_provided_rel_nests_is_used_as_is():
|
||||
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)
|
||||
@@ -157,7 +185,7 @@ def test_default_horizontal_labels_and_order():
|
||||
nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
|
||||
|
||||
expected = ["P.O.B.", "P.C.", "P.T.", "P.C.", "P.T.", "P.C.", "P.T.", "P.O.E."]
|
||||
assert [r.Name.split(" (")[0] for r in nest.RelatedObjects] == expected
|
||||
assert [_label(r.Name) for r in nest.RelatedObjects] == expected
|
||||
|
||||
stations = [_pset_station(r) for r in nest.RelatedObjects]
|
||||
assert stations == sorted(stations)
|
||||
@@ -183,7 +211,7 @@ def test_default_vertical_labels_and_order():
|
||||
"P.V.T.",
|
||||
"V.P.O.E.",
|
||||
]
|
||||
assert [r.Name.split(" (")[0] for r in nest.RelatedObjects] == expected
|
||||
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
|
||||
@@ -200,7 +228,7 @@ def test_name_format():
|
||||
nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
|
||||
referent = nest.RelatedObjects[0]
|
||||
station = _pset_station(referent)
|
||||
assert referent.Name == f"P.O.B. ({ifcopenshell.util.alignment.station_as_string(file, station)})"
|
||||
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():
|
||||
@@ -268,7 +296,7 @@ def test_cant_layout_boundary_labels():
|
||||
|
||||
nest = ifcopenshell.api.alignment.update_key_point_referents(file, cant)
|
||||
|
||||
labels = [r.Name.split(" (")[0] for r in nest.RelatedObjects]
|
||||
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
|
||||
@@ -307,7 +335,7 @@ def test_single_real_segment_produces_only_boundary_labels():
|
||||
ifcopenshell.api.alignment.create_layout_segment(file, horizontal, design_parameters)
|
||||
|
||||
nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
|
||||
labels = [r.Name.split(" (")[0] for r in nest.RelatedObjects]
|
||||
labels = [_label(r.Name) for r in nest.RelatedObjects]
|
||||
assert labels == ["P.O.B.", "P.O.E."]
|
||||
|
||||
|
||||
@@ -356,8 +384,10 @@ def test_returns_ifc_rel_nests():
|
||||
|
||||
test_wrong_layout_type_raises_type_error()
|
||||
test_default_rel_nests_created_when_none_provided()
|
||||
test_second_call_without_rel_nests_reuses_existing_nest()
|
||||
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()
|
||||
|
||||
@@ -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]
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user