diff --git a/src/ifcviewer/Unit.cpp b/src/ifcviewer/Unit.cpp
new file mode 100644
index 0000000000..9a7162ee24
--- /dev/null
+++ b/src/ifcviewer/Unit.cpp
@@ -0,0 +1,329 @@
+/********************************************************************************
+ * *
+ * This file is part of IfcOpenShell. *
+ * *
+ * IfcOpenShell is free software: you can redistribute it and/or modify *
+ * it under the terms of the Lesser GNU General Public License as published by *
+ * the Free Software Foundation, either version 3.0 of the License, or *
+ * (at your option) any later version. *
+ * *
+ * IfcOpenShell is distributed in the hope that it will be useful, *
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of *
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
+ * Lesser GNU General Public License for more details. *
+ * *
+ * You should have received a copy of the Lesser GNU General Public License *
+ * along with this program. If not, see . *
+ * *
+ ********************************************************************************/
+
+#include "Unit.h"
+
+#include "../ifcparse/file.h"
+#include "../ifcparse/instance_data.h"
+#include "../ifcparse/schema.h"
+
+#include
+#include
+
+const std::unordered_map kSiPrefixes = {
+ { "EXA", 1e18 },
+ { "PETA", 1e15 },
+ { "TERA", 1e12 },
+ { "GIGA", 1e9 },
+ { "MEGA", 1e6 },
+ { "KILO", 1e3 },
+ { "HECTO", 1e2 },
+ { "DECA", 1e1 },
+ { "DECI", 1e-1 },
+ { "CENTI", 1e-2 },
+ { "MILLI", 1e-3 },
+ { "MICRO", 1e-6 },
+ { "NANO", 1e-9 },
+ { "PICO", 1e-12 },
+ { "FEMTO", 1e-15 },
+ { "ATTO", 1e-18 },
+};
+
+const std::unordered_map kSiPrefixSymbols = {
+ { "EXA", "E" },
+ { "PETA", "P" },
+ { "TERA", "T" },
+ { "GIGA", "G" },
+ { "MEGA", "M" },
+ { "KILO", "k" },
+ { "HECTO", "h" },
+ { "DECA", "da" },
+ { "DECI", "d" },
+ { "CENTI", "c" },
+ { "MILLI", "m" },
+ { "MICRO", "\xCE\xBC" }, // μ (UTF-8)
+ { "NANO", "n" },
+ { "PICO", "p" },
+ { "FEMTO", "f" },
+ { "ATTO", "a" },
+};
+
+const std::unordered_map kSiConversions = {
+ { "thou", 0.0000254 },
+ { "inch", 0.0254 },
+ { "foot", 0.3048 },
+ { "yard", 0.914 },
+ { "mile", 1609.0 },
+ { "square thou", 6.4516e-10 },
+ { "square inch", 0.0006452 },
+ { "square foot", 0.09290304 },
+ { "square yard", 0.83612736 },
+ { "acre", 4046.86 },
+ { "square mile", 2588881.0 },
+ { "cubic thou", 1.6387064e-14 },
+ { "cubic inch", 0.00001639 },
+ { "cubic foot", 0.02831684671168849 },
+ { "cubic yard", 0.7636 },
+ { "cubic mile", 4165509529.0 },
+ { "litre", 0.001 },
+ { "fluid ounce uk", 0.0000284130625 },
+ { "fluid ounce us", 0.00002957353 },
+ { "pint uk", 0.000568 },
+ { "pint us", 0.000473 },
+ { "gallon uk", 0.004546 },
+ { "gallon us", 0.003785 },
+ { "degree", 0.0174532925199433 }, // pi / 180
+ { "ounce", 0.02835 },
+ { "pound", 0.454 },
+ { "ton uk", 1016.0469088 },
+ { "ton us", 907.18474 },
+ { "tonne", 1000.0 },
+ { "lbf", 4.4482216153 },
+ { "kip", 4448.2216153 },
+ { "psi", 6894.7572932 },
+ { "ksi", 6894757.2932 },
+ { "minute", 60.0 },
+ { "hour", 3600.0 },
+ { "day", 86400.0 },
+ { "btu", 1055.056 },
+ { "fahrenheit", 1.8 },
+};
+
+const std::unordered_map kImperialTypes = {
+ { "thou", "LENGTHUNIT" }, { "inch", "LENGTHUNIT" }, { "foot", "LENGTHUNIT" },
+ { "yard", "LENGTHUNIT" }, { "mile", "LENGTHUNIT" },
+ { "square thou", "AREAUNIT" }, { "square inch", "AREAUNIT" },
+ { "square foot", "AREAUNIT" }, { "square yard", "AREAUNIT" },
+ { "acre", "AREAUNIT" }, { "square mile", "AREAUNIT" },
+ { "cubic thou", "VOLUMEUNIT" }, { "cubic inch", "VOLUMEUNIT" },
+ { "cubic foot", "VOLUMEUNIT" }, { "cubic yard", "VOLUMEUNIT" },
+ { "cubic mile", "VOLUMEUNIT" }, { "litre", "VOLUMEUNIT" },
+ { "fluid ounce uk", "VOLUMEUNIT" }, { "fluid ounce us", "VOLUMEUNIT" },
+ { "pint uk", "VOLUMEUNIT" }, { "pint us", "VOLUMEUNIT" },
+ { "gallon uk", "VOLUMEUNIT" }, { "gallon us", "VOLUMEUNIT" },
+ { "degree", "PLANEANGLEUNIT" },
+ { "ounce", "MASSUNIT" }, { "pound", "MASSUNIT" },
+ { "ton uk", "MASSUNIT" }, { "ton us", "MASSUNIT" }, { "tonne", "MASSUNIT" },
+ { "lbf", "FORCEUNIT" }, { "kip", "FORCEUNIT" },
+ { "psi", "PRESSUREUNIT" }, { "ksi", "PRESSUREUNIT" },
+ { "minute", "TIMEUNIT" }, { "hour", "TIMEUNIT" }, { "day", "TIMEUNIT" },
+ { "btu", "ENERGYUNIT" },
+ { "fahrenheit", "THERMODYNAMICTEMPERATUREUNIT" },
+};
+
+const std::unordered_map kUnitSymbols = {
+ // SI base / derived
+ { "CUBIC_METRE", "m3" },
+ { "GRAM", "g" },
+ { "SECOND", "s" },
+ { "SQUARE_METRE", "m2" },
+ { "METRE", "m" },
+ { "NEWTON", "N" },
+ { "PASCAL", "Pa" },
+ // Conversion-based
+ { "pound-force", "lbf" },
+ { "pound-force per square inch", "psi" },
+ { "thou", "th" }, { "inch", "in" }, { "foot", "ft" },
+ { "yard", "yd" }, { "mile", "mi" },
+ { "square thou", "th2" }, { "square inch", "in2" },
+ { "square foot", "ft2" }, { "square yard", "yd2" },
+ { "acre", "ac" }, { "square mile", "mi2" },
+ { "cubic thou", "th3" }, { "cubic inch", "in3" },
+ { "cubic foot", "ft3" }, { "cubic yard", "yd3" },
+ { "cubic mile", "mi3" }, { "litre", "L" },
+ { "fluid ounce uk", "fl oz" }, { "fluid ounce us", "fl oz" },
+ { "pint uk", "pt" }, { "pint us", "pt" },
+ { "gallon uk", "gal" }, { "gallon us", "gal" },
+ { "degree", "\xC2\xB0" }, // °
+ { "ounce", "oz" }, { "pound", "lb" },
+ { "ton uk", "ton" }, { "ton us", "ton" }, { "tonne", "t" },
+ { "lbf", "lbf" }, { "kip", "kip" },
+ { "psi", "psi" }, { "ksi", "ksi" },
+ { "minute", "min" }, { "hour", "hr" }, { "day", "day" },
+ { "btu", "btu" },
+ { "fahrenheit", "\xC2\xB0\x46" }, // °F
+};
+
+namespace {
+
+std::string toLower(const std::string& s) {
+ std::string r;
+ r.resize(s.size());
+ std::transform(s.begin(), s.end(), r.begin(),
+ [](unsigned char c) { return std::tolower(c); });
+ return r;
+}
+
+// Pull an enumeration string off an attribute_value, or "" if null/invalid.
+std::string enumString(const attribute_value& av) {
+ if (av.isNull()) return {};
+ if (av.type() != ifcopenshell::Argument_ENUMERATION) return {};
+ enumeration_reference er = av;
+ return std::string(er.value() ? er.value() : "");
+}
+
+} // namespace
+
+double getPrefixMultiplier(const std::string& prefix) {
+ if (prefix.empty()) return 1.0;
+ auto it = kSiPrefixes.find(prefix);
+ return (it == kSiPrefixes.end()) ? 1.0 : it->second;
+}
+
+std::optional siScaleFromNamedUnit(express::Base unit) {
+ double scale = 1.0;
+ while (unit && unit.declaration().is("IfcConversionBasedUnit")) {
+ auto e = unit.as();
+
+ // Fast path: name in si_conversions table — matches python.
+ std::string name;
+ auto name_attr = e.get("Name");
+ if (!name_attr.isNull()) name = (std::string) name_attr;
+ if (auto it = kSiConversions.find(toLower(name));
+ it != kSiConversions.end()) {
+ return scale * it->second;
+ }
+
+ // Otherwise walk the ConversionFactor chain.
+ auto cf_attr = e.get("ConversionFactor");
+ if (cf_attr.isNull()) return std::nullopt;
+ express::Base cf = cf_attr;
+ auto cf_e = cf.as();
+ auto vc_attr = cf_e.get("ValueComponent");
+ if (vc_attr.isNull()) return std::nullopt;
+ express::Base vc = vc_attr;
+ // ValueComponent is an IfcValue SELECT wrapping a measure.
+ scale *= (double) vc.get_attribute_value(0);
+ auto uc_attr = cf_e.get("UnitComponent");
+ if (uc_attr.isNull()) return std::nullopt;
+ unit = (express::Base) uc_attr;
+ }
+
+ if (unit && unit.declaration().is("IfcSIUnit")) {
+ auto e = unit.as();
+ const std::string prefix = enumString(e.get("Prefix"));
+ const std::string name = enumString(e.get("Name"));
+ double m = getPrefixMultiplier(prefix);
+ // SQUARE_/CUBIC_-prefixed SI names: prefix multiplier squared/cubed.
+ if (name.find("SQUARE") != std::string::npos) {
+ m *= getPrefixMultiplier(prefix);
+ } else if (name.find("CUBIC") != std::string::npos) {
+ m *= getPrefixMultiplier(prefix);
+ m *= getPrefixMultiplier(prefix);
+ }
+ return scale * m;
+ }
+
+ if (unit && unit.declaration().is("IfcContextDependentUnit")) {
+ // No conversion to SI is possible for a context-dependent unit.
+ return std::nullopt;
+ }
+
+ return scale;
+}
+
+std::optional getUnitAssignment(ifcopenshell::file* ifc_file) {
+ auto projects = ifc_file->instances_by_type("IfcProject");
+ if (projects.empty()) return std::nullopt;
+ auto ua_attr = projects[0].as().get("UnitsInContext");
+ if (ua_attr.isNull()) return std::nullopt;
+ return (express::Base) ua_attr;
+}
+
+std::optional getProjectUnit(ifcopenshell::file* ifc_file,
+ const std::string& unit_type) {
+ auto ua = getUnitAssignment(ifc_file);
+ if (!ua) return std::nullopt;
+ auto units_attr = ua->as().get("Units");
+ if (units_attr.isNull()) return std::nullopt;
+ std::vector units = units_attr;
+ for (const auto& unit : units) {
+ // IfcMonetaryUnit has no UnitType — guard via declaration check.
+ if (!unit.declaration().is("IfcNamedUnit") &&
+ !unit.declaration().is("IfcDerivedUnit")) {
+ continue;
+ }
+ auto ut = unit.as().get("UnitType");
+ if (enumString(ut) == unit_type) return unit;
+ }
+ return std::nullopt;
+}
+
+double calculateUnitScale(ifcopenshell::file* ifc_file,
+ const std::string& unit_type) {
+ auto unit = getProjectUnit(ifc_file, unit_type);
+ if (!unit) return 1.0;
+ auto scale = siScaleFromNamedUnit(*unit);
+ return scale.value_or(1.0);
+}
+
+double convert(double value,
+ const std::string& from_prefix, const std::string& from_unit,
+ const std::string& to_prefix, const std::string& to_unit) {
+ const std::string fl = toLower(from_unit);
+ const std::string tl = toLower(to_unit);
+
+ if (auto it = kSiConversions.find(fl); it != kSiConversions.end()) {
+ value *= it->second;
+ } else if (!from_prefix.empty()) {
+ value *= getPrefixMultiplier(from_prefix);
+ if (from_unit.find("SQUARE") != std::string::npos) {
+ value *= getPrefixMultiplier(from_prefix);
+ } else if (from_unit.find("CUBIC") != std::string::npos) {
+ value *= getPrefixMultiplier(from_prefix);
+ value *= getPrefixMultiplier(from_prefix);
+ }
+ }
+
+ if (auto it = kSiConversions.find(tl); it != kSiConversions.end()) {
+ return value * (1.0 / it->second);
+ } else if (!to_prefix.empty()) {
+ value *= 1.0 / getPrefixMultiplier(to_prefix);
+ // NB: python ifcopenshell.util.unit.convert checks `from_unit` (not
+ // `to_unit`) here. Mirrored for parity — from_unit and to_unit are
+ // always the same dimension in valid calls, so behaviour is the same.
+ if (from_unit.find("SQUARE") != std::string::npos) {
+ value *= 1.0 / getPrefixMultiplier(to_prefix);
+ } else if (from_unit.find("CUBIC") != std::string::npos) {
+ value *= 1.0 / getPrefixMultiplier(to_prefix);
+ value *= 1.0 / getPrefixMultiplier(to_prefix);
+ }
+ }
+ return value;
+}
+
+double convertUnit(double value, express::Base from_unit, express::Base to_unit) {
+ auto pull = [](express::Base u, std::string& prefix, std::string& name) {
+ auto e = u.as();
+ if (u.declaration().is("IfcSIUnit")) {
+ prefix = enumString(e.get("Prefix"));
+ name = enumString(e.get("Name"));
+ } else {
+ // IfcConversionBasedUnit / IfcContextDependentUnit: no Prefix,
+ // Name is a string attribute.
+ prefix.clear();
+ auto name_attr = e.get("Name");
+ name = name_attr.isNull() ? "" : (std::string) name_attr;
+ }
+ };
+ std::string fp, fn, tp, tn;
+ pull(from_unit, fp, fn);
+ pull(to_unit, tp, tn);
+ return convert(value, fp, fn, tp, tn);
+}
diff --git a/src/ifcviewer/Unit.h b/src/ifcviewer/Unit.h
new file mode 100644
index 0000000000..e171021960
--- /dev/null
+++ b/src/ifcviewer/Unit.h
@@ -0,0 +1,89 @@
+/********************************************************************************
+ * *
+ * This file is part of IfcOpenShell. *
+ * *
+ * IfcOpenShell is free software: you can redistribute it and/or modify *
+ * it under the terms of the Lesser GNU General Public License as published by *
+ * the Free Software Foundation, either version 3.0 of the License, or *
+ * (at your option) any later version. *
+ * *
+ * IfcOpenShell is distributed in the hope that it will be useful, *
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of *
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
+ * Lesser GNU General Public License for more details. *
+ * *
+ * You should have received a copy of the Lesser GNU General Public License *
+ * along with this program. If not, see . *
+ * *
+ ********************************************************************************/
+
+// Port of selected helpers from
+// src/ifcopenshell-python/ifcopenshell/util/unit.py. Lives in src/ifcviewer/
+// for now alongside Geolocation; will move out once ifcopenshell.util is
+// ported to C++.
+
+#ifndef UNIT_H
+#define UNIT_H
+
+#include "../ifcparse/express.h"
+
+#include
+#include
+#include
+
+namespace ifcopenshell { class file; }
+
+// SI prefix multipliers, e.g. "MILLI" -> 1e-3. Empty key not present;
+// callers should pass an empty prefix string for "no prefix".
+extern const std::unordered_map kSiPrefixes;
+
+// SI prefix display symbols, e.g. "MILLI" -> "m".
+extern const std::unordered_map kSiPrefixSymbols;
+
+// Conversion-based unit name (lowercase, IFC convention) -> SI base scale.
+// e.g. "foot" -> 0.3048, "square foot" -> 0.09290304.
+extern const std::unordered_map kSiConversions;
+
+// Conversion-based unit name -> IFC unit type, e.g. "foot" -> "LENGTHUNIT".
+extern const std::unordered_map kImperialTypes;
+
+// Display symbol per unit name. Covers IfcSIUnit names ("METRE" -> "m") and
+// IfcConversionBasedUnit names ("foot" -> "ft").
+extern const std::unordered_map kUnitSymbols;
+
+// Returns the multiplier for an SI prefix. Empty string returns 1.0.
+double getPrefixMultiplier(const std::string& prefix);
+
+// Returns the SI scale for an IfcNamedUnit such that
+// value_in_unit * scale == value_in_si_base
+// Walks IfcConversionBasedUnit chains down to IfcSIUnit. Returns nullopt
+// when the chain bottoms out in IfcContextDependentUnit (cannot convert).
+std::optional siScaleFromNamedUnit(express::Base named_unit);
+
+// IfcProject.UnitsInContext (the IfcUnitAssignment). Returns nullopt if
+// the file has no project or no assignment.
+std::optional getUnitAssignment(ifcopenshell::file* ifc_file);
+
+// First unit in the project's IfcUnitAssignment matching `unit_type`
+// (e.g. "LENGTHUNIT"). Returns nullopt if not found.
+std::optional getProjectUnit(ifcopenshell::file* ifc_file,
+ const std::string& unit_type);
+
+// Project unit -> SI base scale (e.g. project in mm => 0.001). Defaults
+// to 1.0 when no project unit of the requested type is set.
+double calculateUnitScale(ifcopenshell::file* ifc_file,
+ const std::string& unit_type = "LENGTHUNIT");
+
+// Convert between two units identified by name + optional SI prefix.
+// SQUARE_/CUBIC_ prefixed SI names get the prefix multiplier squared/cubed
+// (matches python ifcopenshell.util.unit.convert).
+double convert(double value,
+ const std::string& from_prefix, const std::string& from_unit,
+ const std::string& to_prefix, const std::string& to_unit);
+
+// Convert between two IfcNamedUnit entities. Pulls Name and Prefix off each
+// and delegates to convert(). IfcConversionBasedUnit names that don't appear
+// in kSiConversions return the value unchanged.
+double convertUnit(double value, express::Base from_unit, express::Base to_unit);
+
+#endif // UNIT_H