mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-21 12:13:43 +00:00
WIP
This commit is contained in:
@@ -187,10 +187,11 @@ class ALIGN_OT_add_alignment(Operator, tool.Ifc.Operator):
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
alignment_name: StringProperty(name="Name", default="Alignment")
|
||||
start_station: FloatProperty(
|
||||
start_station: StringProperty(
|
||||
name="Start Station",
|
||||
description="Station value at the start of the alignment (distance along 0)",
|
||||
default=0.0,
|
||||
description="Station value at the start of the alignment (distance along 0). "
|
||||
"Accepts a plain number or stationing notation, e.g. 10+00 or 1+000",
|
||||
default="0",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@@ -202,7 +203,13 @@ class ALIGN_OT_add_alignment(Operator, tool.Ifc.Operator):
|
||||
|
||||
def _execute(self, context):
|
||||
try:
|
||||
alignment = core.create_alignment(tool.Ifc, tool.Alignment, self.alignment_name, self.start_station)
|
||||
start_station = tool.Alignment.parse_station(self.start_station)
|
||||
except ValueError as e:
|
||||
self.report({"ERROR"}, f"Invalid start station: {e}")
|
||||
return {"CANCELLED"}
|
||||
|
||||
try:
|
||||
alignment = core.create_alignment(tool.Ifc, tool.Alignment, self.alignment_name, start_station)
|
||||
except ValueError as e:
|
||||
self.report({"ERROR"}, str(e))
|
||||
return {"CANCELLED"}
|
||||
@@ -275,7 +282,11 @@ class ALIGN_OT_set_start_station(Operator, tool.Ifc.Operator):
|
||||
bl_description = "Change the alignment's start station"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
station: FloatProperty(name="Start Station", default=0.0)
|
||||
station: StringProperty(
|
||||
name="Start Station",
|
||||
description="Accepts a plain number or stationing notation, e.g. 10+00 or 1+000",
|
||||
default="0",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
@@ -288,23 +299,30 @@ class ALIGN_OT_set_start_station(Operator, tool.Ifc.Operator):
|
||||
|
||||
def invoke(self, context, event):
|
||||
alignment = tool.Alignment.get_active_alignment()
|
||||
self.station = ifcopenshell.api.alignment.get_alignment_start_station(tool.Ifc.get(), alignment) or 0.0
|
||||
current = ifcopenshell.api.alignment.get_alignment_start_station(tool.Ifc.get(), alignment) or 0.0
|
||||
self.station = tool.Alignment.format_station(current)
|
||||
return context.window_manager.invoke_props_dialog(self)
|
||||
|
||||
def _execute(self, context):
|
||||
try:
|
||||
station = tool.Alignment.parse_station(self.station)
|
||||
except ValueError as e:
|
||||
self.report({"ERROR"}, f"Invalid station: {e}")
|
||||
return {"CANCELLED"}
|
||||
|
||||
alignment = tool.Alignment.get_active_alignment()
|
||||
start_referent = tool.Alignment.find_stationing_referent_at(alignment, 0.0)
|
||||
if start_referent is None:
|
||||
# No stationing at all yet (e.g. an alignment from before this
|
||||
# feature existed) -- add the start referent rather than error.
|
||||
start_referent = ifcopenshell.api.alignment.add_stationing_referent(
|
||||
tool.Ifc.get(), tool.Alignment.format_station(self.station), alignment, 0.0, self.station
|
||||
tool.Ifc.get(), tool.Alignment.format_station(station), alignment, 0.0, station
|
||||
)
|
||||
else:
|
||||
tool.Alignment.set_stationing_referent_station(start_referent, self.station)
|
||||
tool.Alignment.set_stationing_referent_station(start_referent, station)
|
||||
tool.Alignment.create_object_for_referent(start_referent)
|
||||
alignment_decorator.AlignmentSegmentDecorator.refresh()
|
||||
self.report({"INFO"}, f"Start station set to {tool.Alignment.format_station(self.station)}")
|
||||
self.report({"INFO"}, f"Start station set to {tool.Alignment.format_station(station)}")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
@@ -327,17 +345,19 @@ class _StationEquationFields:
|
||||
distance_along: FloatProperty(
|
||||
name="Distance Along", description="Distance along the alignment where the equation applies", default=0.0
|
||||
)
|
||||
station: FloatProperty(
|
||||
station: StringProperty(
|
||||
name="Outgoing Station",
|
||||
description="The station value immediately after this point",
|
||||
default=0.0,
|
||||
description="The station value immediately after this point. Accepts a plain "
|
||||
"number or stationing notation, e.g. 10+00 or 1+000",
|
||||
default="0",
|
||||
update=_on_station_equation_station_update,
|
||||
)
|
||||
incoming_station: FloatProperty(
|
||||
incoming_station: StringProperty(
|
||||
name="Incoming Station",
|
||||
description="The station value immediately before this point. Leave equal to Outgoing "
|
||||
"Station (the default) for no gap/overlap",
|
||||
default=0.0,
|
||||
"Station (the default) for no gap/overlap. Accepts a plain number or stationing "
|
||||
"notation, e.g. 10+00 or 1+000",
|
||||
default="0",
|
||||
)
|
||||
reverse_direction: BoolProperty(
|
||||
name="Reverse Stationing Direction",
|
||||
@@ -345,7 +365,7 @@ class _StationEquationFields:
|
||||
default=False,
|
||||
)
|
||||
# Bookkeeping only, for _on_station_equation_station_update — not shown, not saved.
|
||||
station_snapshot: FloatProperty(options={"HIDDEN", "SKIP_SAVE"}, default=0.0)
|
||||
station_snapshot: StringProperty(options={"HIDDEN", "SKIP_SAVE"}, default="0")
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
@@ -354,9 +374,19 @@ class _StationEquationFields:
|
||||
layout.prop(self, "station")
|
||||
layout.prop(self, "reverse_direction")
|
||||
|
||||
@property
|
||||
def _has_gap_or_overlap(self) -> bool:
|
||||
return self.incoming_station != self.station
|
||||
def _parse_stations(self):
|
||||
"""Parse the station/incoming_station text fields.
|
||||
|
||||
Returns (station, incoming_station, has_gap_or_overlap) — comparing
|
||||
the parsed values rather than raw text so "1000" and "1+000" (the
|
||||
same station, different notation) don't register as a gap.
|
||||
|
||||
Raises:
|
||||
ValueError: If either field isn't a valid station.
|
||||
"""
|
||||
station = tool.Alignment.parse_station(self.station)
|
||||
incoming_station = tool.Alignment.parse_station(self.incoming_station)
|
||||
return station, incoming_station, incoming_station != station
|
||||
|
||||
|
||||
class ALIGN_OT_add_station_equation(Operator, tool.Ifc.Operator, _StationEquationFields):
|
||||
@@ -385,15 +415,21 @@ class ALIGN_OT_add_station_equation(Operator, tool.Ifc.Operator, _StationEquatio
|
||||
return context.window_manager.invoke_props_dialog(self)
|
||||
|
||||
def _execute(self, context):
|
||||
try:
|
||||
station, incoming_station, has_gap_or_overlap = self._parse_stations()
|
||||
except ValueError as e:
|
||||
self.report({"ERROR"}, f"Invalid station: {e}")
|
||||
return {"CANCELLED"}
|
||||
|
||||
alignment = tool.Alignment.get_active_alignment()
|
||||
name = tool.Alignment.format_station(self.station)
|
||||
name = tool.Alignment.format_station(station)
|
||||
referent = ifcopenshell.api.alignment.add_stationing_referent(
|
||||
tool.Ifc.get(),
|
||||
name,
|
||||
alignment,
|
||||
self.distance_along,
|
||||
self.station,
|
||||
incoming_station=self.incoming_station if self._has_gap_or_overlap else None,
|
||||
station,
|
||||
incoming_station=incoming_station if has_gap_or_overlap else None,
|
||||
has_increasing_station=False if self.reverse_direction else None,
|
||||
)
|
||||
tool.Alignment.create_object_for_referent(referent)
|
||||
@@ -433,9 +469,10 @@ class ALIGN_OT_edit_station_equation(Operator, tool.Ifc.Operator, _StationEquati
|
||||
return {"CANCELLED"}
|
||||
|
||||
self.distance_along = _referent_distance_along(referent)
|
||||
self.station = ifcopenshell.util.element.get_pset(referent, name="Pset_Stationing", prop="Station") or 0.0
|
||||
station_val = ifcopenshell.util.element.get_pset(referent, name="Pset_Stationing", prop="Station") or 0.0
|
||||
self.station = tool.Alignment.format_station(station_val)
|
||||
incoming = ifcopenshell.util.element.get_pset(referent, name="Pset_Stationing", prop="IncomingStation")
|
||||
self.incoming_station = incoming if incoming is not None else self.station
|
||||
self.incoming_station = tool.Alignment.format_station(incoming if incoming is not None else station_val)
|
||||
self.reverse_direction = (
|
||||
ifcopenshell.util.element.get_pset(referent, name="Pset_Stationing", prop="HasIncreasingStation") is False
|
||||
)
|
||||
@@ -443,6 +480,12 @@ class ALIGN_OT_edit_station_equation(Operator, tool.Ifc.Operator, _StationEquati
|
||||
return context.window_manager.invoke_props_dialog(self)
|
||||
|
||||
def _execute(self, context):
|
||||
try:
|
||||
station, incoming_station, has_gap_or_overlap = self._parse_stations()
|
||||
except ValueError as e:
|
||||
self.report({"ERROR"}, f"Invalid station: {e}")
|
||||
return {"CANCELLED"}
|
||||
|
||||
ifc = tool.Ifc.get()
|
||||
try:
|
||||
referent = ifc.by_id(self.referent_id)
|
||||
@@ -463,14 +506,14 @@ class ALIGN_OT_edit_station_equation(Operator, tool.Ifc.Operator, _StationEquati
|
||||
bpy.data.objects.remove(obj, do_unlink=True)
|
||||
ifcopenshell.api.run("root.remove_product", ifc, product=referent)
|
||||
|
||||
name = tool.Alignment.format_station(self.station)
|
||||
name = tool.Alignment.format_station(station)
|
||||
new_referent = ifcopenshell.api.alignment.add_stationing_referent(
|
||||
ifc,
|
||||
name,
|
||||
alignment,
|
||||
self.distance_along,
|
||||
self.station,
|
||||
incoming_station=self.incoming_station if self._has_gap_or_overlap else None,
|
||||
station,
|
||||
incoming_station=incoming_station if has_gap_or_overlap else None,
|
||||
has_increasing_station=False if self.reverse_direction else None,
|
||||
)
|
||||
tool.Alignment.create_object_for_referent(new_referent)
|
||||
|
||||
@@ -1296,6 +1296,7 @@ class Alignment:
|
||||
def remove_layout_segment_objects(cls, h_layout): pass
|
||||
# Stationing
|
||||
def format_station(cls, station): pass
|
||||
def parse_station(cls, text): pass
|
||||
# CSV import
|
||||
def create_alignment_from_csv(cls, filepath): pass
|
||||
def create_hierarchy_for_alignment(cls, alignment): pass
|
||||
|
||||
@@ -898,6 +898,27 @@ class Alignment:
|
||||
return f"{float(station):.2f}"
|
||||
return ifcopenshell.util.alignment.station_as_string(ifc_file, float(station))
|
||||
|
||||
@classmethod
|
||||
def parse_station(cls, text: str) -> float:
|
||||
"""Parse a station typed by the user into a float (project units).
|
||||
|
||||
Accepts either a plain number (``"1000"``) or stationing notation —
|
||||
the inverse of format_station() — such as ``"10+00"`` (Imperial) or
|
||||
``"1+000"`` (SI); either notation is accepted regardless of the
|
||||
project's own unit system. Falls back to a plain float parse when no
|
||||
IFC file is open (e.g. dialog previews before a project exists).
|
||||
|
||||
Raises:
|
||||
ValueError: If ``text`` is neither a plain number nor valid
|
||||
stationing notation.
|
||||
"""
|
||||
import ifcopenshell.util.alignment
|
||||
|
||||
ifc_file = tool.Ifc.get()
|
||||
if ifc_file is None:
|
||||
return float(text)
|
||||
return ifcopenshell.util.alignment.station_from_string(ifc_file, text)
|
||||
|
||||
@classmethod
|
||||
def _remove_blender_object(cls, obj: bpy.types.Object) -> bool:
|
||||
"""Safely remove a Blender object and its data.
|
||||
|
||||
@@ -491,3 +491,45 @@ class TestFormatStation(NewFile):
|
||||
|
||||
def test_without_project_falls_back_to_plain_number(self):
|
||||
assert subject.format_station(1234.5) == "1234.50"
|
||||
|
||||
|
||||
class TestParseStation(NewFile):
|
||||
"""tool.Alignment.parse_station — the inverse of format_station()."""
|
||||
|
||||
def _make_file(self, length):
|
||||
import ifcopenshell.api.root
|
||||
import ifcopenshell.api.unit
|
||||
|
||||
ifc = ifcopenshell.file(schema="IFC4X3_ADD2")
|
||||
tool.Ifc.set(ifc)
|
||||
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
|
||||
ifcopenshell.api.unit.assign_unit(ifc, length=length)
|
||||
return ifc
|
||||
|
||||
def test_plain_number_is_taken_literally(self):
|
||||
self._make_file(length={"is_metric": True, "raw": "METERS"})
|
||||
assert subject.parse_station("1000") == 1000.0
|
||||
assert subject.parse_station("1000.5") == 1000.5
|
||||
|
||||
def test_metric_stationing_notation(self):
|
||||
self._make_file(length={"is_metric": True, "raw": "METERS"})
|
||||
assert subject.parse_station("10+050.000") == 10050.0
|
||||
assert subject.parse_station("1+000") == 1000.0
|
||||
|
||||
def test_imperial_stationing_notation(self):
|
||||
self._make_file(length={"is_metric": False, "raw": "FEET"})
|
||||
assert subject.parse_station("100+50.00") == 10050.0
|
||||
assert subject.parse_station("10+00") == 1000.0
|
||||
|
||||
def test_round_trips_with_format_station(self):
|
||||
self._make_file(length={"is_metric": True, "raw": "METERS"})
|
||||
for value in (0.0, 100.0, 10050.0, -50.0):
|
||||
assert subject.parse_station(subject.format_station(value)) == pytest.approx(value, abs=0.01)
|
||||
|
||||
def test_without_project_falls_back_to_plain_float_parse(self):
|
||||
assert subject.parse_station("1234.5") == 1234.5
|
||||
|
||||
def test_raises_on_invalid_input(self):
|
||||
self._make_file(length={"is_metric": True, "raw": "METERS"})
|
||||
with pytest.raises(ValueError):
|
||||
subject.parse_station("not a station")
|
||||
|
||||
@@ -109,3 +109,57 @@ def station_as_string(file: ifcopenshell.file, sta: float):
|
||||
station_string = "-" + station_string
|
||||
|
||||
return station_string
|
||||
|
||||
|
||||
def station_from_string(file: ifcopenshell.file, s: str) -> float:
|
||||
"""
|
||||
Parses a station value typed by a user, in either of two forms:
|
||||
|
||||
- A plain real number, e.g. "1000" or "1000.5" -- taken literally as the
|
||||
station value, in project units.
|
||||
- Stationing notation "V1+V2", the inverse of :func:`station_as_string`,
|
||||
e.g. "10+00" (a project with Imperial units) or "1+000" (a project
|
||||
with SI units). V1 is worth 100 display-feet (Imperial) or 1000
|
||||
display-metres (SI) -- matching station_as_string()'s plus-separator
|
||||
placement -- and V2 is added to that, before converting the result
|
||||
from the format's display unit (foot for Imperial, metre for SI) back
|
||||
to the file's actual project length unit.
|
||||
|
||||
:param file: the IFC file, used to resolve the project's LENGTHUNIT
|
||||
:param s: the station string to parse
|
||||
:return: the station, in project units
|
||||
:raises ValueError: if ``s`` is neither a plain number nor valid
|
||||
stationing notation
|
||||
"""
|
||||
s = s.strip()
|
||||
if not s:
|
||||
raise ValueError("Station value is empty")
|
||||
|
||||
if "+" not in s:
|
||||
return float(s)
|
||||
|
||||
is_negative = s.startswith("-")
|
||||
body = s[1:] if is_negative else s
|
||||
left, separator, right = body.partition("+")
|
||||
if not separator or not left.strip() or not right.strip():
|
||||
raise ValueError(f"Invalid stationing notation: {s!r}")
|
||||
|
||||
v1 = float(left)
|
||||
v2 = float(right)
|
||||
|
||||
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 not None and unit_type.is_a("IfcConversionBasedUnit"):
|
||||
# Imperial: display value is in feet, plus-separator is worth 100.
|
||||
shifter = 100.0
|
||||
metres_per_display_unit = 0.3048
|
||||
else:
|
||||
# SI: display value is in metres, plus-separator is worth 1000.
|
||||
shifter = 1000.0
|
||||
metres_per_display_unit = 1.0
|
||||
|
||||
display_value = v1 * shifter + v2
|
||||
if is_negative:
|
||||
display_value = -display_value
|
||||
|
||||
return display_value * metres_per_display_unit / project_unit_to_metres
|
||||
|
||||
@@ -152,3 +152,62 @@ def test_station_as_string():
|
||||
_test_si_stations_millimeter()
|
||||
_test_us_stations()
|
||||
_test_custom_named_conversion_based_unit_stations()
|
||||
|
||||
|
||||
def _si_file(unit_type="foot"):
|
||||
file = ifcopenshell.file(schema="IFC4X3")
|
||||
project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
|
||||
length = ifcopenshell.api.unit.add_si_unit(file, unit_type="LENGTHUNIT") # meter
|
||||
ifcopenshell.api.unit.assign_unit(file, units=[length])
|
||||
return file
|
||||
|
||||
|
||||
def _us_file():
|
||||
file = ifcopenshell.file(schema="IFC4X3")
|
||||
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])
|
||||
return file
|
||||
|
||||
|
||||
def test_station_from_string_plain_number():
|
||||
file = _si_file()
|
||||
assert sta.station_from_string(file, "1000") == 1000.0
|
||||
assert sta.station_from_string(file, "1000.5") == 1000.5
|
||||
assert sta.station_from_string(file, "-50") == -50.0
|
||||
|
||||
|
||||
def test_station_from_string_si_notation():
|
||||
file = _si_file()
|
||||
assert sta.station_from_string(file, "1+000") == 1000.0
|
||||
assert sta.station_from_string(file, "1+000.000") == 1000.0
|
||||
assert sta.station_from_string(file, "0+100.000") == 100.0
|
||||
assert sta.station_from_string(file, "-0+100.000") == -100.0
|
||||
assert sta.station_from_string(file, "123+456.789") == pytest.approx(123456.789)
|
||||
|
||||
|
||||
def test_station_from_string_us_notation():
|
||||
file = _us_file()
|
||||
assert sta.station_from_string(file, "10+00") == pytest.approx(1000.0)
|
||||
assert sta.station_from_string(file, "1+00.00") == pytest.approx(100.0)
|
||||
assert sta.station_from_string(file, "-1+00.00") == pytest.approx(-100.0)
|
||||
assert sta.station_from_string(file, "1234+56.79") == pytest.approx(123456.79, abs=0.01)
|
||||
|
||||
|
||||
def test_station_from_string_round_trips_with_station_as_string():
|
||||
for file in (_si_file(), _us_file()):
|
||||
for value in (0.0, 100.0, 1000.0, 123456.789, -123456.789):
|
||||
s = sta.station_as_string(file, value)
|
||||
assert sta.station_from_string(file, s) == pytest.approx(value, abs=0.01)
|
||||
|
||||
|
||||
def test_station_from_string_raises_on_invalid_input():
|
||||
file = _si_file()
|
||||
with pytest.raises(ValueError):
|
||||
sta.station_from_string(file, "not a number")
|
||||
with pytest.raises(ValueError):
|
||||
sta.station_from_string(file, "1+")
|
||||
with pytest.raises(ValueError):
|
||||
sta.station_from_string(file, "+1")
|
||||
with pytest.raises(ValueError):
|
||||
sta.station_from_string(file, "")
|
||||
|
||||
Reference in New Issue
Block a user