From 584d37de509f9646f6f82f37223d4000f4c75229 Mon Sep 17 00:00:00 2001 From: Gorgious Date: Sat, 22 Nov 2025 01:16:39 +0100 Subject: [PATCH] Fix #6048 (and #7373 ?) Adding a distance with a unit for an IfcSpatialElement in the Spatial Decomposition list doesn't save value Support imperial units Spatial element empty is moved in the 3d viewport accordingly --- src/bonsai/bonsai/bim/module/spatial/prop.py | 34 ++- src/bonsai/bonsai/tool/spatial.py | 2 +- src/bonsai/bonsai/tool/unit.py | 269 +++++++++++++++++++ 3 files changed, 295 insertions(+), 10 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/spatial/prop.py b/src/bonsai/bonsai/bim/module/spatial/prop.py index 0b3652ca86..34d2866266 100644 --- a/src/bonsai/bonsai/bim/module/spatial/prop.py +++ b/src/bonsai/bonsai/bim/module/spatial/prop.py @@ -36,6 +36,7 @@ import bonsai.core.geometry import ifcopenshell import ifcopenshell.api.attribute import ifcopenshell.util.unit +from bonsai.tool.unit import parse_distance_string from typing import TYPE_CHECKING, Union, Literal @@ -48,18 +49,33 @@ def get_subelement_class( def update_elevation(self: "BIMContainer", context: bpy.types.Context) -> None: - try: - elevation = float(self.elevation) - if self.elevation != str(elevation): - self.elevation = str(elevation) - return - except: - elevation = 0 + # Try to parse the input string with unit support first + is_valid, parsed_elevation = parse_distance_string(self.elevation, use_project_unit=True) + + if is_valid: + elevation = parsed_elevation + else: + # Fall back to direct float conversion for backward compatibility + try: + elevation = float(self.elevation) + except Exception as e: + print(f"Elevation parsing failed for '{self.elevation}': {e}") + elevation = 0 + + formatted = tool.Unit.format_distance(elevation) + + # Only update the string if it's different from the formatted version + # This prevents infinite loops and normalizes the display + if self.elevation != formatted: + self.elevation = formatted + return # Return early to let the property update trigger this function again + + # Update the object's position in the 3D scene if ifc_definition_id := self.ifc_definition_id: entity = tool.Ifc.get().by_id(ifc_definition_id) if obj := tool.Ifc.get_object(entity): - unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) - obj.matrix_world[2][3] = elevation * unit_scale + # elevation is already in meters, set it directly + obj.matrix_world[2][3] = elevation bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj) diff --git a/src/bonsai/bonsai/tool/spatial.py b/src/bonsai/bonsai/tool/spatial.py index 454d6b5b32..f21375cd63 100644 --- a/src/bonsai/bonsai/tool/spatial.py +++ b/src/bonsai/bonsai/tool/spatial.py @@ -496,7 +496,7 @@ class Spatial(bonsai.core.tool.Spatial): new.long_name = element.LongName or "" if not element.is_a("IfcProject"): elevation = ifcopenshell.util.placement.get_storey_elevation(element) - new["elevation"] = tool.Unit.format_value(elevation) + new["elevation"] = tool.Unit.format_distance(elevation) new.is_expanded = element.id() not in cls.contracted_containers new.level_index = level_index children = ifcopenshell.util.element.get_parts(element) diff --git a/src/bonsai/bonsai/tool/unit.py b/src/bonsai/bonsai/tool/unit.py index 81bc92bd94..d1be61e75f 100644 --- a/src/bonsai/bonsai/tool/unit.py +++ b/src/bonsai/bonsai/tool/unit.py @@ -24,15 +24,284 @@ import ifcopenshell import bonsai.bim.helper import bonsai.core.tool import bonsai.tool as tool +from lark import Lark, Transformer from typing import Union, Literal, Any, TYPE_CHECKING, assert_never 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 = """ + 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: (NUMBER | fraction) (INCH_SYM | INCH_TEXT) + feet_inches: NUMBER (FEET_SYM | FEET_TEXT) "-"? (NUMBER | fraction) (INCH_SYM | INCH_TEXT) + plain_number: NUMBER + fraction: NUMBER "/" NUMBER + + expr: (ADD | SUB) dim | (MUL | DIV) NUMBER + + NUMBER: /-?\\d+(?:\\.\\d+)?/ + FEET_SYM: "'" + FEET_TEXT: "ft" + INCH_SYM: "\\"" + INCH_TEXT: "in" + ADD: "+" + SUB: "-" + MUL: "*" + DIV: "/" + + %ignore " " + """ + + grammar_metric = """ + start: FORMULA? dim expr? + dim: metric + + FORMULA: "=" + + metric: NUMBER (MM | CM | DM | M | DEG)? + + expr: (ADD | SUB | MUL | DIV) dim + + NUMBER: /-?\\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 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 number (or fraction) of inches, 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): + # Grammar: NUMBER (FEET_SYM | FEET_TEXT) "-"? (NUMBER | fraction) (INCH_SYM | INCH_TEXT) + # args will be: [feet_number, feet_unit_token, inches_number, inch_unit_token] + # or with optional dash: [feet_number, feet_unit_token, dash_token, inches_number, inch_unit_token] + # We need to extract just the numbers + feet = args[0] + # Find the inches value - it's the first number after the feet number + inches = None + for arg in args[1:]: + if isinstance(arg, (int, float)): + inches = arg + break + if inches is None: + inches = 0 + # 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) + print(f"Primary parser succeeded for '{input_string}'") + except Exception as e: + print(f"Primary parser failed for '{input_string}': {e}") + # If primary fails, try fallback grammar (allows metric in imperial projects and vice versa) + try: + parse_tree = fallback_parser.parse(input_string) + print(f"Fallback parser succeeded for '{input_string}'") + except Exception as e2: + print(f"Fallback parser failed for '{input_string}': {e2}") + pass + + if parse_tree is None: + print(f"No parse tree for '{input_string}'") + return False, 0.0 + + # Transform the parse tree to get the numeric result + transformer = InputTransform() + result = transformer.transform(parse_tree) + print(f"Parsed '{input_string}' -> {result} meters (unit_scale={unit_scale})") + result = round(result, 4) + + return True, result + except Exception as e: + print(f"Parse exception for '{input_string}': {e}") + return False, 0.0 + + class Unit(bonsai.core.tool.Unit): UNIT_TYPE = Literal["LENGTHUNIT", "AREAUNIT", "VOLUMEUNIT"] + @staticmethod + def format_distance(meters: float, use_imperial: bool = None) -> str: + """ + Format a distance value in meters to a string in the project's unit system. + + :param meters: The distance value in meters + :param use_imperial: If True, format as imperial; if False, format as metric; if None, auto-detect from scene + :return: Formatted string with units + """ + if use_imperial is None: + use_imperial = bpy.context.scene.unit_settings.system == "IMPERIAL" + + if use_imperial: + # Convert meters to feet + total_feet = meters / 0.3048 + feet = int(total_feet) + inches = (total_feet - feet) * 12 + + # If inches is very close to 0, just show feet + if abs(inches) < 0.01: + if feet == 0: + return "0'" + return f"{feet}'" + # If feet is 0, just show inches + elif feet == 0: + return f"{inches:.4g}\"" + # Show both feet and inches + else: + return f"{feet}'{inches:.4g}\"" + else: + # Use metric - choose appropriate unit + if abs(meters) >= 1.0: + return f"{meters:.4g}m" + elif abs(meters) >= 0.01: + return f"{meters * 100:.4g}cm" + else: + return f"{meters * 1000:.4g}mm" + @classmethod def get_unit_props(cls) -> BIMUnitProperties: return bpy.context.scene.BIMUnitProperties