Refactor parse_distance_string into tool classmethod and add tests including e9eca5e behaviour

This commit is contained in:
Dion Moult
2026-01-27 19:13:43 +11:00
parent 6ad9949571
commit 2a60f46516
5 changed files with 252 additions and 244 deletions
@@ -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
+1 -2
View File
@@ -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
-1
View File
@@ -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:
+239 -239
View File
@@ -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
+11
View File
@@ -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()