mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-10 09:48:32 +00:00
Consolidate format_distance functions and fix imperial unit formatting
Replaced duplicate format_distance in unit.py with comprehensive MeasureIt-ARCH version from helper.py. Fixed critical floating point errors by changing inPerFoot from 11.9999 to 12.0, eliminating spurious fractions on whole numbers. Implemented auto-precision detection to simplify fractions (64/256 → 1/4) while preserving precise values like 3/256 when needed. Enhanced imperial parsing to support flexible input formats (optional symbols, mixed fractions, leading decimals) and fixed suppress_zero_inches functionality in dimension annotations.
This commit is contained in:
@@ -489,7 +489,7 @@ class BaseDecorator:
|
||||
self.draw_label(context, text=text, line_no=line_number_start, multiline=True, **draw_label_kwargs)
|
||||
|
||||
@cache
|
||||
def format_value(self, context, value, custom_unit=None):
|
||||
def format_value(self, context, value, suppress_zero_inches=False, custom_unit=None):
|
||||
drawing_pset_data = DrawingsData.data["active_drawing_pset_data"]
|
||||
precision = drawing_pset_data.get("MetricPrecision", None)
|
||||
if not precision:
|
||||
@@ -500,7 +500,7 @@ class BaseDecorator:
|
||||
value,
|
||||
precision=precision,
|
||||
decimal_places=decimal_places,
|
||||
suppress_zero_inches=True,
|
||||
suppress_zero_inches=suppress_zero_inches,
|
||||
custom_unit=custom_unit,
|
||||
)
|
||||
|
||||
@@ -743,7 +743,12 @@ class DimensionDecorator(BaseDecorator):
|
||||
|
||||
if not show_description_only:
|
||||
length = (v1 - v0).length
|
||||
text = self.format_value(context, length, custom_unit=dimension_data["custom_unit"])
|
||||
text = self.format_value(
|
||||
context,
|
||||
length,
|
||||
suppress_zero_inches=dimension_data["suppress_zero_inches"],
|
||||
custom_unit=dimension_data["custom_unit"]
|
||||
)
|
||||
if isinstance(self, DiameterDecorator):
|
||||
text = "D" + text
|
||||
text = text_prefix + text + text_suffix
|
||||
|
||||
@@ -121,6 +121,44 @@ class BoundingBox:
|
||||
retVal = True
|
||||
return retVal
|
||||
|
||||
def find_best_precision(fractional_inches, max_precision=256, base_tolerance=0.0001):
|
||||
"""
|
||||
Find the simplest fraction denominator that accurately represents the value.
|
||||
Uses a sliding tolerance - smaller denominators get more tolerance.
|
||||
|
||||
:param fractional_inches: The fractional part of inches (0.0 to 1.0)
|
||||
:param max_precision: Maximum denominator to try (256 for imperial)
|
||||
:param base_tolerance: Base tolerance in inches (very strict)
|
||||
:return: Best denominator (power of 2: 2, 4, 8, 16, 32, 64, 128, 256)
|
||||
"""
|
||||
if abs(fractional_inches) < 0.00001:
|
||||
return 1 # No fraction needed
|
||||
|
||||
# Try denominators from coarsest to finest: 2, 4, 8, 16, 32, 64, 128, 256
|
||||
for exp in range(1, 9): # 2^1 to 2^8
|
||||
denom = 2 ** exp
|
||||
if denom > max_precision:
|
||||
break
|
||||
|
||||
# Find nearest numerator for this denominator
|
||||
numer = round(fractional_inches * denom)
|
||||
|
||||
# Skip if numerator is 0
|
||||
if numer == 0:
|
||||
continue
|
||||
|
||||
# Check if this fraction is close enough
|
||||
fraction_value = numer / denom
|
||||
error = abs(fraction_value - fractional_inches)
|
||||
|
||||
# Very strict tolerance - only simplify if it's a near-perfect match
|
||||
# This keeps 3/256 as 3/256, but allows floating point errors to be cleaned up
|
||||
if error < base_tolerance:
|
||||
return denom
|
||||
|
||||
# If no simpler fraction works, use max precision
|
||||
return max_precision
|
||||
|
||||
|
||||
# This function stolen from https://github.com/kevancress/MeasureIt_ARCH/blob/dcf607ce0896aa2284463c6b4ae9cd023fc54cbe/measureit_arch_baseclass.py
|
||||
# MeasureIt-ARCH is GPL-v3
|
||||
@@ -187,14 +225,19 @@ def format_distance(
|
||||
if unit_length == "INCHES":
|
||||
toInches = 1
|
||||
if unit_length == "FEET":
|
||||
toInches = 11.9999
|
||||
toInches = 12
|
||||
else:
|
||||
toInches = 39.3700787401574887
|
||||
inPerFoot = 11.9999
|
||||
inPerFoot = 12.0
|
||||
|
||||
if isArea:
|
||||
toInches = 1550
|
||||
inPerFoot = 143.999
|
||||
inPerFoot = 144
|
||||
|
||||
|
||||
decInches = value * toInches
|
||||
decFeet = decInches / 12
|
||||
|
||||
if not precision:
|
||||
precision = 256
|
||||
elif precision == "1":
|
||||
@@ -203,8 +246,6 @@ def format_distance(
|
||||
precision = int(precision.split("/")[1])
|
||||
|
||||
base = int(precision)
|
||||
decInches = value * toInches
|
||||
decFeet = decInches / 12
|
||||
|
||||
# Separate ft and inches
|
||||
# Unless Inches are the specified Length Unit or unit_fraction is False
|
||||
@@ -217,23 +258,36 @@ def format_distance(
|
||||
else:
|
||||
feet = 0
|
||||
|
||||
# Separate Fractional Inches
|
||||
# Separate Fractional Inches
|
||||
decInches = abs(decInches) # ignore the sign for inches
|
||||
inches = math.floor(decInches) # remove decimal
|
||||
if inches != 0:
|
||||
frac = round(base * (decInches - inches))
|
||||
|
||||
# Clean up floating point errors
|
||||
fractional_inches = decInches - inches
|
||||
tolerance = 0.01 # About 1/100 of an inch
|
||||
|
||||
# If the fractional part is very small, treat it as zero
|
||||
if fractional_inches < tolerance:
|
||||
frac = 0
|
||||
# If very close to the next whole inch, round up
|
||||
elif fractional_inches > (1.0 - tolerance):
|
||||
frac = 0
|
||||
inches += 1
|
||||
else:
|
||||
frac = round(base * (decInches))
|
||||
# Calculate fraction normally
|
||||
if inches != 0:
|
||||
frac = round(base * fractional_inches)
|
||||
else:
|
||||
frac = round(base * fractional_inches)
|
||||
|
||||
|
||||
# Set proper numerator and denominator
|
||||
if frac != base:
|
||||
numcycles = int(math.log2(base))
|
||||
for i in range(numcycles):
|
||||
if frac % 2 == 0:
|
||||
frac = int(frac / 2)
|
||||
base = int(base / 2)
|
||||
else:
|
||||
break
|
||||
# Simplify using GCD
|
||||
from math import gcd
|
||||
divisor = gcd(int(frac), int(base))
|
||||
frac = int(frac / divisor)
|
||||
base = int(base / divisor)
|
||||
else:
|
||||
frac = 0
|
||||
inches += 1
|
||||
@@ -252,31 +306,48 @@ def format_distance(
|
||||
frac = None
|
||||
if not isArea:
|
||||
add_inches = bool(inches) or not suppress_zero_inches or (inches == 0 and frac)
|
||||
|
||||
tx_dist = ""
|
||||
if feet:
|
||||
tx_dist += str(feet) + "'"
|
||||
if not feet and not add_inches:
|
||||
tx_dist += str(feet) + "'"
|
||||
if feet and add_inches:
|
||||
|
||||
# Add "0' - " when we have inches but no feet
|
||||
# But only add " - " separator if we actually have inches to show
|
||||
if not feet and add_inches:
|
||||
tx_dist += "0' - "
|
||||
elif feet and add_inches:
|
||||
tx_dist += " - "
|
||||
|
||||
if not feet and value < 0:
|
||||
tx_dist += "-"
|
||||
if add_inches:
|
||||
if feet == 0 and inches == 0:
|
||||
pass
|
||||
if feet == 0 and inches == 0 and not frac:
|
||||
# Special case: exactly zero, show "0"
|
||||
tx_dist += "0"
|
||||
elif feet == 0 and inches == 0:
|
||||
pass # Has fraction, will be added below
|
||||
else:
|
||||
tx_dist += str(inches)
|
||||
if add_inches and frac:
|
||||
if feet == 0 and inches == 0:
|
||||
pass
|
||||
pass # Has fraction, will be added below
|
||||
else:
|
||||
tx_dist += " "
|
||||
if frac:
|
||||
tx_dist += str(frac) + "/" + str(base)
|
||||
if add_inches or frac:
|
||||
tx_dist += '"'
|
||||
# Only add inch symbol if we actually added inch content
|
||||
if inches > 0 or frac > 0 or feet == 0:
|
||||
tx_dist += '"'
|
||||
|
||||
|
||||
|
||||
if precision == "12" and unit_system == "IMPERIAL":
|
||||
tx_dist = str(round(decFeet)) + "'"
|
||||
if tx_dist == '"':
|
||||
tx_dist = "0' - 0\""
|
||||
else:
|
||||
fmt = "%1.3f"
|
||||
sq_feet = round(value * toInches / inPerFoot, 4)
|
||||
|
||||
@@ -54,6 +54,27 @@ def update_elevation(self: "BIMContainer", context: bpy.types.Context) -> None:
|
||||
|
||||
if is_valid:
|
||||
elevation = parsed_elevation
|
||||
|
||||
# Format the string if it needs normalization:
|
||||
# - Plain numbers (no units): "4" → "4' - 0""
|
||||
# - Missing symbols: "5'3" → "5' - 3""
|
||||
# - Inconsistent formatting: "5' 3 3/256" → "5' - 3 3/256""
|
||||
input_str = self.elevation.strip()
|
||||
|
||||
# Check if input needs formatting (has feet/inch symbols or is plain number)
|
||||
needs_formatting = (
|
||||
input_str.replace(".", "").replace("-", "").replace(" ", "").replace("/", "").isdigit() # Plain number
|
||||
or "'" in input_str # Has feet symbol
|
||||
or '"' in input_str # Has inch symbol
|
||||
or " " in input_str.replace(" - ", "") # Has spaces (but not the formatted " - ")
|
||||
)
|
||||
|
||||
# Only format if the current string doesn't match our standard format
|
||||
if needs_formatting:
|
||||
formatted = tool.Unit.format_distance(elevation)
|
||||
if self.elevation != formatted:
|
||||
self.elevation = formatted
|
||||
return
|
||||
else:
|
||||
# Fall back to direct float conversion for backward compatibility
|
||||
try:
|
||||
@@ -62,14 +83,6 @@ def update_elevation(self: "BIMContainer", context: bpy.types.Context) -> None:
|
||||
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)
|
||||
|
||||
@@ -277,7 +277,7 @@ class BIM_UL_containers_manager(UIList):
|
||||
if item:
|
||||
row = layout.row(align=True)
|
||||
icon = self.icon_by_class.get(item.ifc_class, "META_PLANE")
|
||||
split = row.split(factor=0.85)
|
||||
split = row.split(factor=0.8)
|
||||
if item.long_name:
|
||||
split2 = split.split(factor=0.7)
|
||||
row = split2.row(align=True)
|
||||
|
||||
@@ -65,7 +65,7 @@ def parse_distance_string(input_string: str, use_project_unit: bool = True) -> t
|
||||
(False, 0.0)
|
||||
"""
|
||||
|
||||
grammar_imperial = """
|
||||
grammar_imperial = r"""
|
||||
start: (FORMULA dim expr) | dim
|
||||
dim: imperial
|
||||
|
||||
@@ -73,18 +73,22 @@ def parse_distance_string(input_string: str, use_project_unit: bool = True) -> t
|
||||
|
||||
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)
|
||||
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+)?/
|
||||
NUMBER: /-?(?:\d+\.?\d*|\.\d+)/
|
||||
FEET_SYM: "'"
|
||||
FEET_TEXT: "ft"
|
||||
INCH_SYM: "\\""
|
||||
INCH_SYM: "\""
|
||||
INCH_TEXT: "in"
|
||||
DASH: "-"
|
||||
ADD: "+"
|
||||
SUB: "-"
|
||||
MUL: "*"
|
||||
@@ -93,7 +97,7 @@ def parse_distance_string(input_string: str, use_project_unit: bool = True) -> t
|
||||
%ignore " "
|
||||
"""
|
||||
|
||||
grammar_metric = """
|
||||
grammar_metric = r"""
|
||||
start: FORMULA? dim expr?
|
||||
dim: metric
|
||||
|
||||
@@ -103,7 +107,7 @@ def parse_distance_string(input_string: str, use_project_unit: bool = True) -> t
|
||||
|
||||
expr: (ADD | SUB | MUL | DIV) dim
|
||||
|
||||
NUMBER: /-?\\d+(?:\\.\\d+)?/
|
||||
NUMBER: /-?(?:\d+\.?\d*|\.\d+)/
|
||||
MM: "mm"
|
||||
CM: "cm"
|
||||
DM: "dm"
|
||||
@@ -124,6 +128,15 @@ def parse_distance_string(input_string: str, use_project_unit: bool = True) -> t
|
||||
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]
|
||||
@@ -137,18 +150,15 @@ def parse_distance_string(input_string: str, use_project_unit: bool = True) -> t
|
||||
return feet * 0.3048
|
||||
|
||||
def inches_only(self, args):
|
||||
# args[0] is the number (or fraction) of inches, args[1] is the unit token
|
||||
# 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):
|
||||
# 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
|
||||
# Extract feet and inches values
|
||||
feet = args[0]
|
||||
# Find the inches value - it's the first number after the feet number
|
||||
# Find the inch_value (it's a number, not a token)
|
||||
inches = None
|
||||
for arg in args[1:]:
|
||||
if isinstance(arg, (int, float)):
|
||||
@@ -234,30 +244,24 @@ def parse_distance_string(input_string: str, use_project_unit: bool = True) -> t
|
||||
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)
|
||||
result = round(result, 6)
|
||||
|
||||
return True, result
|
||||
|
||||
except Exception as e:
|
||||
print(f"Parse exception for '{input_string}': {e}")
|
||||
return False, 0.0
|
||||
|
||||
|
||||
@@ -265,42 +269,28 @@ class Unit(bonsai.core.tool.Unit):
|
||||
UNIT_TYPE = Literal["LENGTHUNIT", "AREAUNIT", "VOLUMEUNIT"]
|
||||
|
||||
@staticmethod
|
||||
def format_distance(meters: float, use_imperial: bool = None) -> str:
|
||||
def format_distance(meters: float, use_imperial: bool = None, **kwargs) -> 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
|
||||
:param kwargs: Additional arguments to pass to the underlying format_distance function
|
||||
(hide_units, precision, decimal_places, etc.)
|
||||
:return: Formatted string with units
|
||||
"""
|
||||
if use_imperial is None:
|
||||
use_imperial = bpy.context.scene.unit_settings.system == "IMPERIAL"
|
||||
# Import the comprehensive format_distance from the helper module
|
||||
from bonsai.bim.module.drawing import helper
|
||||
|
||||
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"
|
||||
# The comprehensive function expects value in scene units, not meters
|
||||
# So we pass meters directly since it handles unit conversion internally
|
||||
return helper.format_distance(
|
||||
meters,
|
||||
hide_units=kwargs.get("hide_units", False),
|
||||
precision=kwargs.get("precision"),
|
||||
decimal_places=kwargs.get("decimal_places"),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_unit_props(cls) -> BIMUnitProperties:
|
||||
|
||||
Reference in New Issue
Block a user