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:
Ryan Schultz
2025-11-22 13:49:30 -06:00
parent 9c39ac0a48
commit b7b6e4aadf
5 changed files with 161 additions and 82 deletions
@@ -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
+92 -21
View File
@@ -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)
+21 -8
View File
@@ -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)
+1 -1
View File
@@ -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)
+39 -49
View File
@@ -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: