mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-10 09:48:32 +00:00
Refactor parse_distance_string into tool classmethod and add tests including e9eca5e behaviour
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user