From 2a60f46516bda2ff34439b118392926d7c195db6 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 27 Jan 2026 19:13:43 +1100 Subject: [PATCH] Refactor parse_distance_string into tool classmethod and add tests including e9eca5e behaviour --- .../bonsai/bim/module/drawing/gizmos.py | 3 +- src/bonsai/bonsai/bim/module/spatial/prop.py | 3 +- src/bonsai/bonsai/core/tool.py | 1 - src/bonsai/bonsai/tool/unit.py | 478 +++++++++--------- src/bonsai/test/tool/test_unit.py | 11 + 5 files changed, 252 insertions(+), 244 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/gizmos.py b/src/bonsai/bonsai/bim/module/drawing/gizmos.py index 6624d2010c..a83bd0a46a 100644 --- a/src/bonsai/bonsai/bim/module/drawing/gizmos.py +++ b/src/bonsai/bonsai/bim/module/drawing/gizmos.py @@ -157,7 +157,6 @@ from mathutils.kdtree import KDTree import bonsai.tool as tool from bonsai.bim.module.drawing.shaders import ExtrusionGuidesShader -from bonsai.tool.unit import parse_distance_string SNAP_POINT_SIZE = 10.0 SNAP_POINT_COLOR = (1.0, 0.5, 0.0, 1.0) @@ -1028,7 +1027,7 @@ class NumericInputState: return input_str = self.get_input_string() - is_valid, value = parse_distance_string(input_str) + is_valid, value = tool.Unit.parse_distance_string(input_str) if is_valid: self.parsed_value = value diff --git a/src/bonsai/bonsai/bim/module/spatial/prop.py b/src/bonsai/bonsai/bim/module/spatial/prop.py index 2f12e164fc..18ddc6080d 100644 --- a/src/bonsai/bonsai/bim/module/spatial/prop.py +++ b/src/bonsai/bonsai/bim/module/spatial/prop.py @@ -39,7 +39,6 @@ import bonsai.core.geometry import bonsai.tool as tool from bonsai.bim.module.spatial.data import SpatialDecompositionData from bonsai.bim.prop import ObjProperty -from bonsai.tool.unit import parse_distance_string def get_subelement_class( @@ -52,7 +51,7 @@ def get_subelement_class( def update_elevation(self: "BIMContainer", context: bpy.types.Context) -> None: # Try to parse the input string with unit support first - is_valid, parsed_elevation = parse_distance_string(self.elevation, use_project_unit=True) + is_valid, parsed_elevation = tool.Unit.parse_distance_string(self.elevation, use_project_unit=True) if is_valid: elevation = parsed_elevation diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index c07ac9f483..9d78297c4e 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -1132,7 +1132,6 @@ class Unit: def set_active_unit(cls, unit): pass def get_project_currency_unit(cls): pass def get_currency_name(cls): pass - def add_mass_and_time_units(cls): pass @interface class Voider: diff --git a/src/bonsai/bonsai/tool/unit.py b/src/bonsai/bonsai/tool/unit.py index d7b35e3178..66b56612fe 100644 --- a/src/bonsai/bonsai/tool/unit.py +++ b/src/bonsai/bonsai/tool/unit.py @@ -34,245 +34,6 @@ if TYPE_CHECKING: from bonsai.bim.module.unit.prop import BIMUnitProperties -def parse_distance_string(input_string: str, use_project_unit: bool = True) -> tuple[bool, float]: - """ - Parse a distance string with optional unit suffixes and convert to meters. - - This function parses distance inputs with units (e.g., "5m", "10ft", "3.5cm") - and converts them to meters (SI units) for use in IFC models. - - Supports: - - Metric units: mm, cm, dm, m - - Imperial units: ft/feet ('), in/inches (") - - Arithmetic expressions: +, -, *, / - - Fractions for imperial units (e.g., 1/2") - - Formula mode: values starting with "=" - - :param input_string: The string to parse (e.g., "5m", "10ft", "10'6\"", "3.5cm", "12in") - :param use_project_unit: If True, uses project unit scale; if False, uses Blender unit scale - :return: Tuple (is_valid, value_in_meters) where is_valid indicates successful parsing - and value_in_meters is the converted value in meters - - Examples: - >>> parse_distance_string("5m") - (True, 5.0) - >>> parse_distance_string("30cm") - (True, 0.3) - >>> parse_distance_string("10ft") - (True, 3.048) - >>> parse_distance_string("12in") - (True, 0.3048) - >>> parse_distance_string("5'6\"") - (True, 1.6764) - >>> parse_distance_string("invalid") - (False, 0.0) - """ - - grammar_imperial = r""" - start: (FORMULA dim expr) | dim - dim: imperial - - FORMULA: "=" - - imperial: feet_inches | feet_only | inches_only | plain_number - feet_only: NUMBER (FEET_SYM | FEET_TEXT) - inches_only: inch_value (INCH_SYM | INCH_TEXT) - feet_inches: NUMBER (FEET_SYM | FEET_TEXT) DASH? inch_value (INCH_SYM | INCH_TEXT)? - plain_number: NUMBER - - inch_value: NUMBER fraction | fraction | NUMBER - - fraction: NUMBER "/" NUMBER - - expr: (ADD | SUB) dim | (MUL | DIV) NUMBER - - NUMBER: /-?(?:\d+\.?\d*|\.\d+)/ - FEET_SYM: "'" - FEET_TEXT: "ft" - INCH_SYM: "\"" - INCH_TEXT: "in" - DASH: "-" - ADD: "+" - SUB: "-" - MUL: "*" - DIV: "/" - - %ignore " " - """ - - grammar_metric = r""" - start: FORMULA? dim expr? - dim: metric - - FORMULA: "=" - - metric: NUMBER (MM | CM | DM | M | DEG)? - - expr: (ADD | SUB | MUL | DIV) dim - - NUMBER: /-?(?:\d+\.?\d*|\.\d+)/ - MM: "mm" - CM: "cm" - DM: "dm" - M: "m" - DEG: "°" - ADD: "+" - SUB: "-" - MUL: "*" - DIV: "/" - - %ignore " " - """ - - class InputTransform(Transformer): - def NUMBER(self, n): - return float(n) - - def fraction(self, numbers): - return numbers[0] / numbers[1] - - def inch_value(self, args): - # Can be: NUMBER fraction, fraction, or NUMBER - if len(args) == 2: - # NUMBER fraction (e.g., "9 1/64") - return args[0] + args[1] - else: - # Just fraction or just NUMBER - return args[0] - - def plain_number(self, args): - # A plain number in imperial context is assumed to be feet - feet = args[0] - # Convert feet to meters (1 foot = 0.3048 meters) - return feet * 0.3048 - - def feet_only(self, args): - # args[0] is the number of feet, args[1] is the unit token (we can ignore it) - feet = args[0] - # Convert feet to meters (1 foot = 0.3048 meters) - return feet * 0.3048 - - def inches_only(self, args): - # args[0] is the inch_value, args[1] is the unit token - inches = args[0] - # Convert inches to meters (1 inch = 0.0254 meters) - return inches * 0.0254 - - def feet_inches(self, args): - # Extract feet and inches values - feet = args[0] - # Find the inch_value (it's a number, not a token) - inches = None - for arg in args[1:]: - if isinstance(arg, (int, float)): - inches = arg - break - if inches is None: - inches = 0 - - # If feet is negative, inches should also be negative (subtractive) - if feet < 0: - inches = -inches - - # Convert to meters - total_meters = (feet * 0.3048) + (inches * 0.0254) - return total_meters - - def imperial(self, args): - # Just return the value from the sub-rule (feet_only, inches_only, or feet_inches) - return args[0] - - def metric(self, args): - # args[0] is the NUMBER, args[1] if present is the unit - value = args[0] - if len(args) > 1: - unit = str(args[1]) - # Convert to meters based on unit - if unit == "mm": - value = value / 1000.0 - elif unit == "cm": - value = value / 100.0 - elif unit == "dm": - value = value / 10.0 - elif unit == "m": - value = value # already in meters - elif unit == "°": - value = value # degrees, pass through - # If no unit specified, assume it's already in the project's unit system - return value - - def dim(self, args): - return args[0] - - def expr(self, args): - op = args[0] - value = float(args[1]) - if op == "+": - return lambda x: x + value - elif op == "-": - return lambda x: x - value - elif op == "*": - return lambda x: x * value - elif op == "/": - return lambda x: x / value - - def FORMULA(self, args): - return args[0] - - def start(self, args): - i = 0 - if args[0] == "=": - i += 1 - else: - if len(args) > 1: - raise ValueError("Invalid input.") - dimension = args[i] - if len(args) > i + 1: - expression = args[i + 1] - return expression(dimension) - else: - return dimension - - try: - # Determine unit scale - if use_project_unit and tool.Ifc.get(): - unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) - else: - unit_scale = tool.Blender.get_unit_scale() - - # Try to parse with the project's default grammar first - if bpy.context.scene.unit_settings.system == "IMPERIAL": - primary_parser = Lark(grammar_imperial) - fallback_parser = Lark(grammar_metric) - else: - primary_parser = Lark(grammar_metric) - fallback_parser = Lark(grammar_imperial) - - # Try parsing with primary grammar - parse_tree = None - try: - parse_tree = primary_parser.parse(input_string) - except Exception as e: - # If primary fails, try fallback grammar (allows metric in imperial projects and vice versa) - try: - parse_tree = fallback_parser.parse(input_string) - except Exception as e2: - pass - - if parse_tree is None: - return False, 0.0 - - # Transform the parse tree to get the numeric result - transformer = InputTransform() - result = transformer.transform(parse_tree) - result = round(result, 6) - - return True, result - - except Exception as e: - return False, 0.0 - - class Unit(bonsai.core.tool.Unit): UNIT_TYPE = Literal["LENGTHUNIT", "AREAUNIT", "VOLUMEUNIT", "MASSUNIT", "TIMEUNIT"] @@ -300,6 +61,245 @@ class Unit(bonsai.core.tool.Unit): **kwargs, ) + @classmethod + def parse_distance_string(cls, input_string: str, use_project_unit: bool = True) -> tuple[bool, float]: + """ + Parse a distance string with optional unit suffixes and convert to meters. + + This function parses distance inputs with units (e.g., "5m", "10ft", "3.5cm") + and converts them to meters (SI units) for use in IFC models. + + Supports: + - Metric units: mm, cm, dm, m + - Imperial units: ft/feet ('), in/inches (") + - Arithmetic expressions: +, -, *, / + - Fractions for imperial units (e.g., 1/2") + - Formula mode: values starting with "=" + + :param input_string: The string to parse (e.g., "5m", "10ft", "10'6\"", "3.5cm", "12in") + :param use_project_unit: If True, uses project unit scale; if False, uses Blender unit scale + :return: Tuple (is_valid, value_in_meters) where is_valid indicates successful parsing + and value_in_meters is the converted value in meters + + Examples: + >>> parse_distance_string("5m") + (True, 5.0) + >>> parse_distance_string("30cm") + (True, 0.3) + >>> parse_distance_string("10ft") + (True, 3.048) + >>> parse_distance_string("12in") + (True, 0.3048) + >>> parse_distance_string("5'6\"") + (True, 1.6764) + >>> parse_distance_string("invalid") + (False, 0.0) + """ + + grammar_imperial = r""" + start: (FORMULA dim expr) | dim + dim: imperial + + FORMULA: "=" + + imperial: feet_inches | feet_only | inches_only | plain_number + feet_only: NUMBER (FEET_SYM | FEET_TEXT) + inches_only: inch_value (INCH_SYM | INCH_TEXT) + feet_inches: NUMBER (FEET_SYM | FEET_TEXT) DASH? inch_value (INCH_SYM | INCH_TEXT)? + plain_number: NUMBER + + inch_value: NUMBER fraction | fraction | NUMBER + + fraction: NUMBER "/" NUMBER + + expr: (ADD | SUB) dim | (MUL | DIV) NUMBER + + NUMBER: /-?(?:\d+\.?\d*|\.\d+)/ + FEET_SYM: "'" + FEET_TEXT: "ft" + INCH_SYM: "\"" + INCH_TEXT: "in" + DASH: "-" + ADD: "+" + SUB: "-" + MUL: "*" + DIV: "/" + + %ignore " " + """ + + grammar_metric = r""" + start: FORMULA? dim expr? + dim: metric + + FORMULA: "=" + + metric: NUMBER (MM | CM | DM | M | DEG)? + + expr: (ADD | SUB | MUL | DIV) dim + + NUMBER: /-?(?:\d+\.?\d*|\.\d+)/ + MM: "mm" + CM: "cm" + DM: "dm" + M: "m" + DEG: "°" + ADD: "+" + SUB: "-" + MUL: "*" + DIV: "/" + + %ignore " " + """ + + class InputTransform(Transformer): + def NUMBER(self, n): + return float(n) + + def fraction(self, numbers): + return numbers[0] / numbers[1] + + def inch_value(self, args): + # Can be: NUMBER fraction, fraction, or NUMBER + if len(args) == 2: + # NUMBER fraction (e.g., "9 1/64") + return args[0] + args[1] + else: + # Just fraction or just NUMBER + return args[0] + + def plain_number(self, args): + # A plain number in imperial context is assumed to be feet + feet = args[0] + # Convert feet to meters (1 foot = 0.3048 meters) + return feet * 0.3048 + + def feet_only(self, args): + # args[0] is the number of feet, args[1] is the unit token (we can ignore it) + feet = args[0] + # Convert feet to meters (1 foot = 0.3048 meters) + return feet * 0.3048 + + def inches_only(self, args): + # args[0] is the inch_value, args[1] is the unit token + inches = args[0] + # Convert inches to meters (1 inch = 0.0254 meters) + return inches * 0.0254 + + def feet_inches(self, args): + # Extract feet and inches values + feet = args[0] + # Find the inch_value (it's a number, not a token) + inches = None + for arg in args[1:]: + if isinstance(arg, (int, float)): + inches = arg + break + if inches is None: + inches = 0 + + # If feet is negative, inches should also be negative (subtractive) + if feet < 0: + inches = -inches + + # Convert to meters + total_meters = (feet * 0.3048) + (inches * 0.0254) + return total_meters + + def imperial(self, args): + # Just return the value from the sub-rule (feet_only, inches_only, or feet_inches) + return args[0] + + def metric(self, args): + # args[0] is the NUMBER, args[1] if present is the unit + value = args[0] + if len(args) > 1: + unit = str(args[1]) + # Convert to meters based on unit + if unit == "mm": + value = value / 1000.0 + elif unit == "cm": + value = value / 100.0 + elif unit == "dm": + value = value / 10.0 + elif unit == "m": + value = value # already in meters + elif unit == "°": + value = value # degrees, pass through + # If no unit specified, assume it's already in the project's unit system + return value + + def dim(self, args): + return args[0] + + def expr(self, args): + op = args[0] + value = float(args[1]) + if op == "+": + return lambda x: x + value + elif op == "-": + return lambda x: x - value + elif op == "*": + return lambda x: x * value + elif op == "/": + return lambda x: x / value + + def FORMULA(self, args): + return args[0] + + def start(self, args): + i = 0 + if args[0] == "=": + i += 1 + else: + if len(args) > 1: + raise ValueError("Invalid input.") + dimension = args[i] + if len(args) > i + 1: + expression = args[i + 1] + return expression(dimension) + else: + return dimension + + try: + # Determine unit scale + if use_project_unit and tool.Ifc.get(): + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + else: + unit_scale = tool.Blender.get_unit_scale() + + # Try to parse with the project's default grammar first + if bpy.context.scene.unit_settings.system == "IMPERIAL": + primary_parser = Lark(grammar_imperial) + fallback_parser = Lark(grammar_metric) + else: + primary_parser = Lark(grammar_metric) + fallback_parser = Lark(grammar_imperial) + + # Try parsing with primary grammar + parse_tree = None + try: + parse_tree = primary_parser.parse(input_string) + except Exception as e: + # If primary fails, try fallback grammar (allows metric in imperial projects and vice versa) + try: + parse_tree = fallback_parser.parse(input_string) + except Exception as e2: + pass + + if parse_tree is None: + return False, 0.0 + + # Transform the parse tree to get the numeric result + transformer = InputTransform() + result = transformer.transform(parse_tree) + result = round(result, 6) + + return True, result + + except Exception as e: + return False, 0.0 + @classmethod def get_unit_props(cls) -> BIMUnitProperties: return bpy.context.scene.BIMUnitProperties diff --git a/src/bonsai/test/tool/test_unit.py b/src/bonsai/test/tool/test_unit.py index dcd96c4dea..df3acc1b3e 100644 --- a/src/bonsai/test/tool/test_unit.py +++ b/src/bonsai/test/tool/test_unit.py @@ -33,6 +33,17 @@ class TestImplementsTool(NewFile): assert isinstance(subject(), bonsai.core.tool.Unit) +class TestParseDistanceString(NewFile): + def test_run(self): + assert subject.parse_distance_string("5m") == (True, 5.0) + assert subject.parse_distance_string("30cm") == (True, 0.3) + assert subject.parse_distance_string("10ft") == (True, 3.048) + assert subject.parse_distance_string("12in") == (True, 0.3048) + assert subject.parse_distance_string("5'6\"") == (True, 1.6764) + assert subject.parse_distance_string("-5'6\"") == (True, -1.6764) + assert subject.parse_distance_string("invalid") == (False, 0.0) + + class TestClearActiveUnit(NewFile): def test_run(self): props = tool.Unit.get_unit_props()