From fe01c35dbb8161ff96b4cdd31c3259d18fbf7288 Mon Sep 17 00:00:00 2001 From: Richard Brice <37087370+RickBrice@users.noreply.github.com> Date: Sun, 13 Sep 2026 13:36:13 -0700 Subject: [PATCH] WIP --- .../bonsai/bim/module/alignment/operator.py | 97 +++++++++++++------ src/bonsai/bonsai/core/tool.py | 1 + src/bonsai/bonsai/tool/alignment.py | 21 ++++ src/bonsai/test/tool/test_alignment.py | 42 ++++++++ .../ifcopenshell/util/alignment.py | 54 +++++++++++ .../test/util/test_alignment.py | 59 +++++++++++ 6 files changed, 247 insertions(+), 27 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/alignment/operator.py b/src/bonsai/bonsai/bim/module/alignment/operator.py index 9b915b4ca7..a92c3fbf54 100644 --- a/src/bonsai/bonsai/bim/module/alignment/operator.py +++ b/src/bonsai/bonsai/bim/module/alignment/operator.py @@ -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) diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index 4a0e5b0c0d..1fb3048806 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -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 diff --git a/src/bonsai/bonsai/tool/alignment.py b/src/bonsai/bonsai/tool/alignment.py index 0932e3aa7f..3f8674ff1e 100644 --- a/src/bonsai/bonsai/tool/alignment.py +++ b/src/bonsai/bonsai/tool/alignment.py @@ -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. diff --git a/src/bonsai/test/tool/test_alignment.py b/src/bonsai/test/tool/test_alignment.py index 8edd4a4515..aab879320a 100644 --- a/src/bonsai/test/tool/test_alignment.py +++ b/src/bonsai/test/tool/test_alignment.py @@ -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") diff --git a/src/ifcopenshell-python/ifcopenshell/util/alignment.py b/src/ifcopenshell-python/ifcopenshell/util/alignment.py index bee380ab23..a150bef283 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/alignment.py +++ b/src/ifcopenshell-python/ifcopenshell/util/alignment.py @@ -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 diff --git a/src/ifcopenshell-python/test/util/test_alignment.py b/src/ifcopenshell-python/test/util/test_alignment.py index 522572e97d..96d37188cd 100644 --- a/src/ifcopenshell-python/test/util/test_alignment.py +++ b/src/ifcopenshell-python/test/util/test_alignment.py @@ -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, "")