This commit is contained in:
Richard Brice
2026-09-13 13:36:13 -07:00
parent 2cbb48e2ef
commit fe01c35dbb
6 changed files with 247 additions and 27 deletions
@@ -187,10 +187,11 @@ class ALIGN_OT_add_alignment(Operator, tool.Ifc.Operator):
bl_options = {"REGISTER", "UNDO"} bl_options = {"REGISTER", "UNDO"}
alignment_name: StringProperty(name="Name", default="Alignment") alignment_name: StringProperty(name="Name", default="Alignment")
start_station: FloatProperty( start_station: StringProperty(
name="Start Station", name="Start Station",
description="Station value at the start of the alignment (distance along 0)", description="Station value at the start of the alignment (distance along 0). "
default=0.0, "Accepts a plain number or stationing notation, e.g. 10+00 or 1+000",
default="0",
) )
@classmethod @classmethod
@@ -202,7 +203,13 @@ class ALIGN_OT_add_alignment(Operator, tool.Ifc.Operator):
def _execute(self, context): def _execute(self, context):
try: 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: except ValueError as e:
self.report({"ERROR"}, str(e)) self.report({"ERROR"}, str(e))
return {"CANCELLED"} 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_description = "Change the alignment's start station"
bl_options = {"REGISTER", "UNDO"} 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 @classmethod
def poll(cls, context): def poll(cls, context):
@@ -288,23 +299,30 @@ class ALIGN_OT_set_start_station(Operator, tool.Ifc.Operator):
def invoke(self, context, event): def invoke(self, context, event):
alignment = tool.Alignment.get_active_alignment() 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) return context.window_manager.invoke_props_dialog(self)
def _execute(self, context): 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() alignment = tool.Alignment.get_active_alignment()
start_referent = tool.Alignment.find_stationing_referent_at(alignment, 0.0) start_referent = tool.Alignment.find_stationing_referent_at(alignment, 0.0)
if start_referent is None: if start_referent is None:
# No stationing at all yet (e.g. an alignment from before this # No stationing at all yet (e.g. an alignment from before this
# feature existed) -- add the start referent rather than error. # feature existed) -- add the start referent rather than error.
start_referent = ifcopenshell.api.alignment.add_stationing_referent( 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: 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) tool.Alignment.create_object_for_referent(start_referent)
alignment_decorator.AlignmentSegmentDecorator.refresh() 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"} return {"FINISHED"}
@@ -327,17 +345,19 @@ class _StationEquationFields:
distance_along: FloatProperty( distance_along: FloatProperty(
name="Distance Along", description="Distance along the alignment where the equation applies", default=0.0 name="Distance Along", description="Distance along the alignment where the equation applies", default=0.0
) )
station: FloatProperty( station: StringProperty(
name="Outgoing Station", name="Outgoing Station",
description="The station value immediately after this point", description="The station value immediately after this point. Accepts a plain "
default=0.0, "number or stationing notation, e.g. 10+00 or 1+000",
default="0",
update=_on_station_equation_station_update, update=_on_station_equation_station_update,
) )
incoming_station: FloatProperty( incoming_station: StringProperty(
name="Incoming Station", name="Incoming Station",
description="The station value immediately before this point. Leave equal to Outgoing " description="The station value immediately before this point. Leave equal to Outgoing "
"Station (the default) for no gap/overlap", "Station (the default) for no gap/overlap. Accepts a plain number or stationing "
default=0.0, "notation, e.g. 10+00 or 1+000",
default="0",
) )
reverse_direction: BoolProperty( reverse_direction: BoolProperty(
name="Reverse Stationing Direction", name="Reverse Stationing Direction",
@@ -345,7 +365,7 @@ class _StationEquationFields:
default=False, default=False,
) )
# Bookkeeping only, for _on_station_equation_station_update — not shown, not saved. # 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): def draw(self, context):
layout = self.layout layout = self.layout
@@ -354,9 +374,19 @@ class _StationEquationFields:
layout.prop(self, "station") layout.prop(self, "station")
layout.prop(self, "reverse_direction") layout.prop(self, "reverse_direction")
@property def _parse_stations(self):
def _has_gap_or_overlap(self) -> bool: """Parse the station/incoming_station text fields.
return self.incoming_station != self.station
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): 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) return context.window_manager.invoke_props_dialog(self)
def _execute(self, context): 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() 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( referent = ifcopenshell.api.alignment.add_stationing_referent(
tool.Ifc.get(), tool.Ifc.get(),
name, name,
alignment, alignment,
self.distance_along, self.distance_along,
self.station, station,
incoming_station=self.incoming_station if self._has_gap_or_overlap else None, incoming_station=incoming_station if has_gap_or_overlap else None,
has_increasing_station=False if self.reverse_direction else None, has_increasing_station=False if self.reverse_direction else None,
) )
tool.Alignment.create_object_for_referent(referent) tool.Alignment.create_object_for_referent(referent)
@@ -433,9 +469,10 @@ class ALIGN_OT_edit_station_equation(Operator, tool.Ifc.Operator, _StationEquati
return {"CANCELLED"} return {"CANCELLED"}
self.distance_along = _referent_distance_along(referent) 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") 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 = ( self.reverse_direction = (
ifcopenshell.util.element.get_pset(referent, name="Pset_Stationing", prop="HasIncreasingStation") is False 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) return context.window_manager.invoke_props_dialog(self)
def _execute(self, context): 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() ifc = tool.Ifc.get()
try: try:
referent = ifc.by_id(self.referent_id) 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) bpy.data.objects.remove(obj, do_unlink=True)
ifcopenshell.api.run("root.remove_product", ifc, product=referent) 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( new_referent = ifcopenshell.api.alignment.add_stationing_referent(
ifc, ifc,
name, name,
alignment, alignment,
self.distance_along, self.distance_along,
self.station, station,
incoming_station=self.incoming_station if self._has_gap_or_overlap else None, incoming_station=incoming_station if has_gap_or_overlap else None,
has_increasing_station=False if self.reverse_direction else None, has_increasing_station=False if self.reverse_direction else None,
) )
tool.Alignment.create_object_for_referent(new_referent) tool.Alignment.create_object_for_referent(new_referent)
+1
View File
@@ -1296,6 +1296,7 @@ class Alignment:
def remove_layout_segment_objects(cls, h_layout): pass def remove_layout_segment_objects(cls, h_layout): pass
# Stationing # Stationing
def format_station(cls, station): pass def format_station(cls, station): pass
def parse_station(cls, text): pass
# CSV import # CSV import
def create_alignment_from_csv(cls, filepath): pass def create_alignment_from_csv(cls, filepath): pass
def create_hierarchy_for_alignment(cls, alignment): pass def create_hierarchy_for_alignment(cls, alignment): pass
+21
View File
@@ -898,6 +898,27 @@ class Alignment:
return f"{float(station):.2f}" return f"{float(station):.2f}"
return ifcopenshell.util.alignment.station_as_string(ifc_file, float(station)) 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 @classmethod
def _remove_blender_object(cls, obj: bpy.types.Object) -> bool: def _remove_blender_object(cls, obj: bpy.types.Object) -> bool:
"""Safely remove a Blender object and its data. """Safely remove a Blender object and its data.
+42
View File
@@ -491,3 +491,45 @@ class TestFormatStation(NewFile):
def test_without_project_falls_back_to_plain_number(self): def test_without_project_falls_back_to_plain_number(self):
assert subject.format_station(1234.5) == "1234.50" 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 station_string = "-" + station_string
return 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_si_stations_millimeter()
_test_us_stations() _test_us_stations()
_test_custom_named_conversion_based_unit_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, "")