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
This commit is contained in:
Gorgious
2025-11-22 01:16:39 +01:00
parent ca2058d56b
commit 584d37de50
3 changed files with 295 additions and 10 deletions
+25 -9
View File
@@ -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)
+1 -1
View File
@@ -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)
+269
View File
@@ -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