diff --git a/src/bonsai/bonsai/bim/module/drawing/decoration.py b/src/bonsai/bonsai/bim/module/drawing/decoration.py index 0ffc714ac9..8f15282bc4 100644 --- a/src/bonsai/bonsai/bim/module/drawing/decoration.py +++ b/src/bonsai/bonsai/bim/module/drawing/decoration.py @@ -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 diff --git a/src/bonsai/bonsai/bim/module/drawing/helper.py b/src/bonsai/bonsai/bim/module/drawing/helper.py index 474abc496f..8f2246a636 100644 --- a/src/bonsai/bonsai/bim/module/drawing/helper.py +++ b/src/bonsai/bonsai/bim/module/drawing/helper.py @@ -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) diff --git a/src/bonsai/bonsai/bim/module/spatial/prop.py b/src/bonsai/bonsai/bim/module/spatial/prop.py index 34d2866266..b7ae727a26 100644 --- a/src/bonsai/bonsai/bim/module/spatial/prop.py +++ b/src/bonsai/bonsai/bim/module/spatial/prop.py @@ -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) diff --git a/src/bonsai/bonsai/bim/module/spatial/ui.py b/src/bonsai/bonsai/bim/module/spatial/ui.py index 0372a6f77e..ffb7773ac2 100644 --- a/src/bonsai/bonsai/bim/module/spatial/ui.py +++ b/src/bonsai/bonsai/bim/module/spatial/ui.py @@ -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) diff --git a/src/bonsai/bonsai/tool/unit.py b/src/bonsai/bonsai/tool/unit.py index 26387cfc33..d01d51f3cb 100644 --- a/src/bonsai/bonsai/tool/unit.py +++ b/src/bonsai/bonsai/tool/unit.py @@ -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: