Repository: openscad/openscad
Pull request #5850: improve color utils
Author: login: AaronVerDow
Created: 2025-04-23T17:49:29Z | Merged: 2025-04-26T03:58:27Z

# Changes

* Create new `core/ColorUtil.cc` functions `parse_color` and `getColor`. These both accept a string, check it against webcolors, and then attempt to parse it as hex.
  * `parse_color` will return false if nothing is found, allowing for functions to handle the error.
  * `getColor` will always return a color and will revert to the default while throwing a warning message
* All code has been updated to use these new functions, reducing duplicate logic and standardizing color processing.
  * Export 3MF can now have colors specified by name via the command line.
  * Pyfunction will now apply alpha channel to colors specified by name and hex.

This change will reduce the amount of duplicate code while implementing additional color support for PDF and SVG outputs: https://github.com/openscad/openscad/pull/5845

# Examples

_test.scad_
```
color("blue")
sphere(d=10);
```

Confirm color is still rendered in GUI: 
![blue](https://github.com/user-attachments/assets/b69fefac-6873-4437-9175-cf3327f6852f)

Export with new settings:
```
./build/openscad test.scad -o test.3mf -O export-3mf/color-mode=selected-only -O export-3mf/color=red
```

![red](https://github.com/user-attachments/assets/ecbb06f0-d396-47ad-a96d-0560b54221ab)

__Pyfunctions are untested.__


Diff:
diff --git a/src/core/ColorNode.cc b/src/core/ColorNode.cc
index 5764084c36d..d9a33a5bc8b 100644
--- a/src/core/ColorNode.cc
+++ b/src/core/ColorNode.cc
@@ -40,168 +40,11 @@
 #include <cstddef>
 #include <string>
 #include <iterator>
-#include <unordered_map>
 #include <boost/algorithm/string/case_conv.hpp>
 #include <boost/assign/std/vector.hpp>
 #include <boost/assign/list_of.hpp>
 using namespace boost::assign; // bring 'operator+=()' into scope
 
-// Colors extracted from https://drafts.csswg.org/css-color/ on 2015-08-02
-// CSS Color Module Level 4 - Editor’s Draft, 29 May 2015
-std::unordered_map<std::string, Color4f> webcolors{
-  {"aliceblue", {240, 248, 255}},
-  {"antiquewhite", {250, 235, 215}},
-  {"aqua", {0, 255, 255}},
-  {"aquamarine", {127, 255, 212}},
-  {"azure", {240, 255, 255}},
-  {"beige", {245, 245, 220}},
-  {"bisque", {255, 228, 196}},
-  {"black", {0, 0, 0}},
-  {"blanchedalmond", {255, 235, 205}},
-  {"blue", {0, 0, 255}},
-  {"blueviolet", {138, 43, 226}},
-  {"brown", {165, 42, 42}},
-  {"burlywood", {222, 184, 135}},
-  {"cadetblue", {95, 158, 160}},
-  {"chartreuse", {127, 255, 0}},
-  {"chocolate", {210, 105, 30}},
-  {"coral", {255, 127, 80}},
-  {"cornflowerblue", {100, 149, 237}},
-  {"cornsilk", {255, 248, 220}},
-  {"crimson", {220, 20, 60}},
-  {"cyan", {0, 255, 255}},
-  {"darkblue", {0, 0, 139}},
-  {"darkcyan", {0, 139, 139}},
-  {"darkgoldenrod", {184, 134, 11}},
-  {"darkgray", {169, 169, 169}},
-  {"darkgreen", {0, 100, 0}},
-  {"darkgrey", {169, 169, 169}},
-  {"darkkhaki", {189, 183, 107}},
-  {"darkmagenta", {139, 0, 139}},
-  {"darkolivegreen", {85, 107, 47}},
-  {"darkorange", {255, 140, 0}},
-  {"darkorchid", {153, 50, 204}},
-  {"darkred", {139, 0, 0}},
-  {"darksalmon", {233, 150, 122}},
-  {"darkseagreen", {143, 188, 143}},
-  {"darkslateblue", {72, 61, 139}},
-  {"darkslategray", {47, 79, 79}},
-  {"darkslategrey", {47, 79, 79}},
-  {"darkturquoise", {0, 206, 209}},
-  {"darkviolet", {148, 0, 211}},
-  {"deeppink", {255, 20, 147}},
-  {"deepskyblue", {0, 191, 255}},
-  {"dimgray", {105, 105, 105}},
-  {"dimgrey", {105, 105, 105}},
-  {"dodgerblue", {30, 144, 255}},
-  {"firebrick", {178, 34, 34}},
-  {"floralwhite", {255, 250, 240}},
-  {"forestgreen", {34, 139, 34}},
-  {"fuchsia", {255, 0, 255}},
-  {"gainsboro", {220, 220, 220}},
-  {"ghostwhite", {248, 248, 255}},
-  {"gold", {255, 215, 0}},
-  {"goldenrod", {218, 165, 32}},
-  {"gray", {128, 128, 128}},
-  {"green", {0, 128, 0}},
-  {"greenyellow", {173, 255, 47}},
-  {"grey", {128, 128, 128}},
-  {"honeydew", {240, 255, 240}},
-  {"hotpink", {255, 105, 180}},
-  {"indianred", {205, 92, 92}},
-  {"indigo", {75, 0, 130}},
-  {"ivory", {255, 255, 240}},
-  {"khaki", {240, 230, 140}},
-  {"lavender", {230, 230, 250}},
-  {"lavenderblush", {255, 240, 245}},
-  {"lawngreen", {124, 252, 0}},
-  {"lemonchiffon", {255, 250, 205}},
-  {"lightblue", {173, 216, 230}},
-  {"lightcoral", {240, 128, 128}},
-  {"lightcyan", {224, 255, 255}},
-  {"lightgoldenrodyellow", {250, 250, 210}},
-  {"lightgray", {211, 211, 211}},
-  {"lightgreen", {144, 238, 144}},
-  {"lightgrey", {211, 211, 211}},
-  {"lightpink", {255, 182, 193}},
-  {"lightsalmon", {255, 160, 122}},
-  {"lightseagreen", {32, 178, 170}},
-  {"lightskyblue", {135, 206, 250}},
-  {"lightslategray", {119, 136, 153}},
-  {"lightslategrey", {119, 136, 153}},
-  {"lightsteelblue", {176, 196, 222}},
-  {"lightyellow", {255, 255, 224}},
-  {"lime", {0, 255, 0}},
-  {"limegreen", {50, 205, 50}},
-  {"linen", {250, 240, 230}},
-  {"magenta", {255, 0, 255}},
-  {"maroon", {128, 0, 0}},
-  {"mediumaquamarine", {102, 205, 170}},
-  {"mediumblue", {0, 0, 205}},
-  {"mediumorchid", {186, 85, 211}},
-  {"mediumpurple", {147, 112, 219}},
-  {"mediumseagreen", {60, 179, 113}},
-  {"mediumslateblue", {123, 104, 238}},
-  {"mediumspringgreen", {0, 250, 154}},
-  {"mediumturquoise", {72, 209, 204}},
-  {"mediumvioletred", {199, 21, 133}},
-  {"midnightblue", {25, 25, 112}},
-  {"mintcream", {245, 255, 250}},
-  {"mistyrose", {255, 228, 225}},
-  {"moccasin", {255, 228, 181}},
-  {"navajowhite", {255, 222, 173}},
-  {"navy", {0, 0, 128}},
-  {"oldlace", {253, 245, 230}},
-  {"olive", {128, 128, 0}},
-  {"olivedrab", {107, 142, 35}},
-  {"orange", {255, 165, 0}},
-  {"orangered", {255, 69, 0}},
-  {"orchid", {218, 112, 214}},
-  {"palegoldenrod", {238, 232, 170}},
-  {"palegreen", {152, 251, 152}},
-  {"paleturquoise", {175, 238, 238}},
-  {"palevioletred", {219, 112, 147}},
-  {"papayawhip", {255, 239, 213}},
-  {"peachpuff", {255, 218, 185}},
-  {"peru", {205, 133, 63}},
-  {"pink", {255, 192, 203}},
-  {"plum", {221, 160, 221}},
-  {"powderblue", {176, 224, 230}},
-  {"purple", {128, 0, 128}},
-  {"rebeccapurple", {102, 51, 153}},
-  {"red", {255, 0, 0}},
-  {"rosybrown", {188, 143, 143}},
-  {"royalblue", {65, 105, 225}},
-  {"saddlebrown", {139, 69, 19}},
-  {"salmon", {250, 128, 114}},
-  {"sandybrown", {244, 164, 96}},
-  {"seagreen", {46, 139, 87}},
-  {"seashell", {255, 245, 238}},
-  {"sienna", {160, 82, 45}},
-  {"silver", {192, 192, 192}},
-  {"skyblue", {135, 206, 235}},
-  {"slateblue", {106, 90, 205}},
-  {"slategray", {112, 128, 144}},
-  {"slategrey", {112, 128, 144}},
-  {"snow", {255, 250, 250}},
-  {"springgreen", {0, 255, 127}},
-  {"steelblue", {70, 130, 180}},
-  {"tan", {210, 180, 140}},
-  {"teal", {0, 128, 128}},
-  {"thistle", {216, 191, 216}},
-  {"tomato", {255, 99, 71}},
-  {"turquoise", {64, 224, 208}},
-  {"violet", {238, 130, 238}},
-  {"wheat", {245, 222, 179}},
-  {"white", {255, 255, 255}},
-  {"whitesmoke", {245, 245, 245}},
-  {"yellow", {255, 255, 0}},
-  {"yellowgreen", {154, 205, 50}},
-
-  // additional OpenSCAD specific entry
-  {"transparent", {0, 0, 0, 0}}
-};
-
 static std::shared_ptr<AbstractNode> builtin_color(const ModuleInstantiation *inst, Arguments arguments, const Children& children)
 {
   auto node = std::make_shared<ColorNode>(inst);
@@ -217,18 +60,12 @@ static std::shared_ptr<AbstractNode> builtin_color(const ModuleInstantiation *in
     }
   } else if (parameters["c"].type() == Value::Type::STRING) {
     auto colorname = parameters["c"].toString();
-    boost::algorithm::to_lower(colorname);
-    if (webcolors.find(colorname) != webcolors.end()) {
-      node->color = webcolors.at(colorname);
+    const auto parsed_color = OpenSCAD::parse_color(colorname);
+    if (parsed_color) {
+      node->color = *parsed_color;
     } else {
-      // Try parsing it as a hex color such as "#rrggbb".
-      const auto hexColor = OpenSCAD::parse_hex_color(colorname);
-      if (hexColor) {
-        node->color = *hexColor;
-      } else {
-        LOG(message_group::Warning, inst->location(), parameters.documentRoot(), "Unable to parse color \"%1$s\"", colorname);
-        LOG("Please see https://en.wikipedia.org/wiki/Web_colors");
-      }
+      LOG(message_group::Warning, inst->location(), parameters.documentRoot(), "Unable to parse color \"%1$s\"", colorname);
+      LOG("Please see https://en.wikipedia.org/wiki/Web_colors");
     }
   }
   if (parameters["alpha"].type() == Value::Type::NUMBER) {
diff --git a/src/core/ColorUtil.cc b/src/core/ColorUtil.cc
index ee194461b5b..3288e42bde9 100644
--- a/src/core/ColorUtil.cc
+++ b/src/core/ColorUtil.cc
@@ -1,8 +1,168 @@
 #include <string>
+#include <unordered_map>
 #include "geometry/linalg.h"
 #include "core/ColorUtil.h"
+#include "utils/printutils.h"
+#include <boost/algorithm/string/case_conv.hpp>
+
+namespace {
+
+// Colors extracted from https://drafts.csswg.org/css-color/ on 2015-08-02
+// CSS Color Module Level 4 - Editor’s Draft, 29 May 2015
+std::unordered_map<std::string, Color4f> webcolors{
+  {"aliceblue", {240, 248, 255}},
+  {"antiquewhite", {250, 235, 215}},
+  {"aqua", {0, 255, 255}},
+  {"aquamarine", {127, 255, 212}},
+  {"azure", {240, 255, 255}},
+  {"beige", {245, 245, 220}},
+  {"bisque", {255, 228, 196}},
+  {"black", {0, 0, 0}},
+  {"blanchedalmond", {255, 235, 205}},
+  {"blue", {0, 0, 255}},
+  {"blueviolet", {138, 43, 226}},
+  {"brown", {165, 42, 42}},
+  {"burlywood", {222, 184, 135}},
+  {"cadetblue", {95, 158, 160}},
+  {"chartreuse", {127, 255, 0}},
+  {"chocolate", {210, 105, 30}},
+  {"coral", {255, 127, 80}},
+  {"cornflowerblue", {100, 149, 237}},
+  {"cornsilk", {255, 248, 220}},
+  {"crimson", {220, 20, 60}},
+  {"cyan", {0, 255, 255}},
+  {"darkblue", {0, 0, 139}},
+  {"darkcyan", {0, 139, 139}},
+  {"darkgoldenrod", {184, 134, 11}},
+  {"darkgray", {169, 169, 169}},
+  {"darkgreen", {0, 100, 0}},
+  {"darkgrey", {169, 169, 169}},
+  {"darkkhaki", {189, 183, 107}},
+  {"darkmagenta", {139, 0, 139}},
+  {"darkolivegreen", {85, 107, 47}},
+  {"darkorange", {255, 140, 0}},
+  {"darkorchid", {153, 50, 204}},
+  {"darkred", {139, 0, 0}},
+  {"darksalmon", {233, 150, 122}},
+  {"darkseagreen", {143, 188, 143}},
+  {"darkslateblue", {72, 61, 139}},
+  {"darkslategray", {47, 79, 79}},
+  {"darkslategrey", {47, 79, 79}},
+  {"darkturquoise", {0, 206, 209}},
+  {"darkviolet", {148, 0, 211}},
+  {"deeppink", {255, 20, 147}},
+  {"deepskyblue", {0, 191, 255}},
+  {"dimgray", {105, 105, 105}},
+  {"dimgrey", {105, 105, 105}},
+  {"dodgerblue", {30, 144, 255}},
+  {"firebrick", {178, 34, 34}},
+  {"floralwhite", {255, 250, 240}},
+  {"forestgreen", {34, 139, 34}},
+  {"fuchsia", {255, 0, 255}},
+  {"gainsboro", {220, 220, 220}},
+  {"ghostwhite", {248, 248, 255}},
+  {"gold", {255, 215, 0}},
+  {"goldenrod", {218, 165, 32}},
+  {"gray", {128, 128, 128}},
+  {"green", {0, 128, 0}},
+  {"greenyellow", {173, 255, 47}},
+  {"grey", {128, 128, 128}},
+  {"honeydew", {240, 255, 240}},
+  {"hotpink", {255, 105, 180}},
+  {"indianred", {205, 92, 92}},
+  {"indigo", {75, 0, 130}},
+  {"ivory", {255, 255, 240}},
+  {"khaki", {240, 230, 140}},
+  {"lavender", {230, 230, 250}},
+  {"lavenderblush", {255, 240, 245}},
+  {"lawngreen", {124, 252, 0}},
+  {"lemonchiffon", {255, 250, 205}},
+  {"lightblue", {173, 216, 230}},
+  {"lightcoral", {240, 128, 128}},
+  {"lightcyan", {224, 255, 255}},
+  {"lightgoldenrodyellow", {250, 250, 210}},
+  {"lightgray", {211, 211, 211}},
+  {"lightgreen", {144, 238, 144}},
+  {"lightgrey", {211, 211, 211}},
+  {"lightpink", {255, 182, 193}},
+  {"lightsalmon", {255, 160, 122}},
+  {"lightseagreen", {32, 178, 170}},
+  {"lightskyblue", {135, 206, 250}},
+  {"lightslategray", {119, 136, 153}},
+  {"lightslategrey", {119, 136, 153}},
+  {"lightsteelblue", {176, 196, 222}},
+  {"lightyellow", {255, 255, 224}},
+  {"lime", {0, 255, 0}},
+  {"limegreen", {50, 205, 50}},
+  {"linen", {250, 240, 230}},
+  {"magenta", {255, 0, 255}},
+  {"maroon", {128, 0, 0}},
+  {"mediumaquamarine", {102, 205, 170}},
+  {"mediumblue", {0, 0, 205}},
+  {"mediumorchid", {186, 85, 211}},
+  {"mediumpurple", {147, 112, 219}},
+  {"mediumseagreen", {60, 179, 113}},
+  {"mediumslateblue", {123, 104, 238}},
+  {"mediumspringgreen", {0, 250, 154}},
+  {"mediumturquoise", {72, 209, 204}},
+  {"mediumvioletred", {199, 21, 133}},
+  {"midnightblue", {25, 25, 112}},
+  {"mintcream", {245, 255, 250}},
+  {"mistyrose", {255, 228, 225}},
+  {"moccasin", {255, 228, 181}},
+  {"navajowhite", {255, 222, 173}},
+  {"navy", {0, 0, 128}},
+  {"oldlace", {253, 245, 230}},
+  {"olive", {128, 128, 0}},
+  {"olivedrab", {107, 142, 35}},
+  {"orange", {255, 165, 0}},
+  {"orangered", {255, 69, 0}},
+  {"orchid", {218, 112, 214}},
+  {"palegoldenrod", {238, 232, 170}},
+  {"palegreen", {152, 251, 152}},
+  {"paleturquoise", {175, 238, 238}},
+  {"palevioletred", {219, 112, 147}},
+  {"papayawhip", {255, 239, 213}},
+  {"peachpuff", {255, 218, 185}},
+  {"peru", {205, 133, 63}},
+  {"pink", {255, 192, 203}},
+  {"plum", {221, 160, 221}},
+  {"powderblue", {176, 224, 230}},
+  {"purple", {128, 0, 128}},
+  {"rebeccapurple", {102, 51, 153}},
+  {"red", {255, 0, 0}},
+  {"rosybrown", {188, 143, 143}},
+  {"royalblue", {65, 105, 225}},
+  {"saddlebrown", {139, 69, 19}},
+  {"salmon", {250, 128, 114}},
+  {"sandybrown", {244, 164, 96}},
+  {"seagreen", {46, 139, 87}},
+  {"seashell", {255, 245, 238}},
+  {"sienna", {160, 82, 45}},
+  {"silver", {192, 192, 192}},
+  {"skyblue", {135, 206, 235}},
+  {"slateblue", {106, 90, 205}},
+  {"slategray", {112, 128, 144}},
+  {"slategrey", {112, 128, 144}},
+  {"snow", {255, 250, 250}},
+  {"springgreen", {0, 255, 127}},
+  {"steelblue", {70, 130, 180}},
+  {"tan", {210, 180, 140}},
+  {"teal", {0, 128, 128}},
+  {"thistle", {216, 191, 216}},
+  {"tomato", {255, 99, 71}},
+  {"turquoise", {64, 224, 208}},
+  {"violet", {238, 130, 238}},
+  {"wheat", {245, 222, 179}},
+  {"white", {255, 255, 255}},
+  {"whitesmoke", {245, 245, 245}},
+  {"yellow", {255, 255, 0}},
+  {"yellowgreen", {154, 205, 50}},
+
+  // additional OpenSCAD specific entry
+  {"transparent", {0, 0, 0, 0}}
+};
 
-namespace OpenSCAD {
 
 // See http://lolengine.net/blog/2013/01/13/fast-rgb-to-hsv
 void rgbtohsv(float r, float g, float b, float& h, float& s, float& v)
@@ -25,42 +185,6 @@ void rgbtohsv(float r, float g, float b, float& h, float& s, float& v)
   v = r;
 }
 
-Color4f getColorHSV(const Color4f& col)
-{
-  float h, s, v;
-  OpenSCAD::rgbtohsv(col[0], col[1], col[2], h, s, v);
-  return {h, s, v, col[3]};
-}
-
-/**
- * Calculate contrast color. Based on the article
- * http://gamedev.stackexchange.com/questions/38536/given-a-rgb-color-x-how-to-find-the-most-contrasting-color-y
- *
- * @param col the input color
- * @return a color with high contrast to the input color
- */
-Color4f getContrastColor(const Color4f& col)
-{
-  Color4f hsv = getColorHSV(col);
-  float Y = 0.2126f * col[0] + 0.7152f * col[1] + 0.0722f * col[2];
-  float S = hsv[1];
-
-  if (S < 0.5) {
-    // low saturation, choose between black / white based on luminance Y
-    float val = Y > 0.5 ? 0.0f : 1.0f;
-    return {val, val, val, 1.0f};
-  } else {
-    float H = 360 * hsv[0];
-    if ((H < 60) || (H > 300)) {
-      return {0.0f, 1.0f, 1.0f, 1.0f}; // red -> cyan
-    } else if (H < 180) {
-      return {1.0f, 0.0f, 1.0f, 1.0f}; // green -> magenta
-    } else {
-      return {1.0f, 1.0f, 0.0f, 1.0f}; // blue -> yellow
-    }
-  }
-}
-
 // Parses hex colors according to: https://drafts.csswg.org/css-color/#typedef-hex-color.
 // If the input is invalid, returns boost::none.
 // Supports the following formats:
@@ -78,8 +202,8 @@ std::optional<Color4f> parse_hex_color(const std::string& hex) {
   if (hex[0] != '#') return {};
   if (!std::all_of(std::begin(hex) + 1, std::end(hex),
                    [](char c) {
-    return std::isxdigit(static_cast<unsigned char>(c));
-  })) {
+      return std::isxdigit(static_cast<unsigned char>(c));
+    })) {
     return {};
   }
 
@@ -93,11 +217,84 @@ std::optional<Color4f> parse_hex_color(const std::string& hex) {
   for (unsigned i = 0; i < (hex.size() - 1) / stride; ++i) {
     const std::string chunk = hex.substr(1 + i * stride, stride);
 
-    // convert the hex character(s) from base 16 to base 10
+    // convert the hex character(s) to a fraction between 0 and 1, inclusive
     rgba[i] = stoi(chunk, nullptr, 16) / channel_max;
   }
 
   return rgba;
 }
 
-} // namespace
\ No newline at end of file
+std::optional<Color4f> parse_web_color(const std::string& col) {
+  std::string colorname = boost::algorithm::to_lower_copy(col);
+  if (webcolors.find(colorname) != webcolors.end()) {
+    return webcolors.at(colorname);
+  }
+  return {};
+}
+
+} // namespace
+namespace OpenSCAD {
+
+std::optional<Color4f> parse_color(const std::string& col) {
+  const auto webcolor = ::parse_web_color(col);
+  if (webcolor) {
+    return webcolor;
+  }
+
+  const auto hexcolor = ::parse_hex_color(col);
+  if (hexcolor) {
+    return hexcolor;
+  }
+
+  return {};
+}
+
+Color4f getColor(const std::string& col, const Color4f& defaultcolor)
+{
+  const auto parsed = parse_color(col);
+
+  if (!parsed) {
+    LOG(message_group::Warning, "Unable to parse color \"%1$s\", reverting to default color.", col);
+    LOG("Please see https://en.wikipedia.org/wiki/Web_colors");
+  }
+
+  return parsed.value_or(defaultcolor);
+}
+
+Color4f getColorHSV(const Color4f& col)
+{
+  float h, s, v;
+  ::rgbtohsv(col[0], col[1], col[2], h, s, v);
+  return {h, s, v, col[3]};
+}
+
+/**
+ * Calculate contrast color. Based on the article
+ * http://gamedev.stackexchange.com/questions/38536/given-a-rgb-color-x-how-to-find-the-most-contrasting-color-y
+ *
+ * @param col the input color
+ * @return a color with high contrast to the input color
+ */
+Color4f getContrastColor(const Color4f& col)
+{
+  Color4f hsv = getColorHSV(col);
+  float Y = 0.2126f * col[0] + 0.7152f * col[1] + 0.0722f * col[2];
+  float S = hsv[1];
+
+  if (S < 0.5) {
+    // low saturation, choose between black / white based on luminance Y
+    float val = Y > 0.5 ? 0.0f : 1.0f;
+    return {val, val, val, 1.0f};
+  } else {
+    float H = 360 * hsv[0];
+    if ((H < 60) || (H > 300)) {
+      return {0.0f, 1.0f, 1.0f, 1.0f}; // red -> cyan
+    } else if (H < 180) {
+      return {1.0f, 0.0f, 1.0f, 1.0f}; // green -> magenta
+    } else {
+      return {1.0f, 1.0f, 0.0f, 1.0f}; // blue -> yellow
+    }
+  }
+}
+
+} // namespace OpenSCAD
diff --git a/src/core/ColorUtil.h b/src/core/ColorUtil.h
index 8cbf2bcfd7a..ac86cc7a1ca 100644
--- a/src/core/ColorUtil.h
+++ b/src/core/ColorUtil.h
@@ -5,27 +5,27 @@
 
 #include "geometry/linalg.h"
 
-template<> struct std::hash<Color4f> {
-    std::size_t operator()(Color4f const& c) const noexcept {
-      std::size_t hash = 0;
-      for (int idx = 0;idx < 4;idx++) {
-        std::size_t h = std::hash<float>{}(c[idx]);
-        hash = h ^ (hash << 1);
-      }
-      return hash;
+template <> struct std::hash<Color4f> {
+  std::size_t operator()(Color4f const& c) const noexcept {
+    std::size_t hash = 0;
+    for (int idx = 0; idx < 4; idx++) {
+      std::size_t h = std::hash<float>{}(c[idx]);
+      hash = h ^ (hash << 1);
     }
+    return hash;
+  }
 };
 
 namespace OpenSCAD {
 
 inline Color4f CORNFIELD_FACE_COLOR{ 0xf9, 0xd7, 0x2c, 255 };
 
-void rgbtohsv(float r, float g, float b, float& h, float& s, float& v);
+std::optional<Color4f> parse_color(const std::string& col);
 
-std::optional<Color4f> parse_hex_color(const std::string& hex);
+Color4f getColor(const std::string& col, const Color4f& defaultcolor);
 
 Color4f getContrastColor(const Color4f& col);
 
 Color4f getColorHSV(const Color4f& col);
 
-} // namespace
\ No newline at end of file
+} // namespace OpenSCAD
diff --git a/src/io/export_3mf_v1.cc b/src/io/export_3mf_v1.cc
index fa6fab7f999..91450fb8d71 100644
--- a/src/io/export_3mf_v1.cc
+++ b/src/io/export_3mf_v1.cc
@@ -25,7 +25,7 @@
  */
 
 #include "io/export.h"
- 
+
 #include <algorithm>
 #include <cassert>
 #include <cstdint>
@@ -100,24 +100,24 @@ BYTE get_color_channel(const Color4f& col, int idx)
 }
 
 int count_mesh_objects(PLib3MFModel *& model) {
-    PLib3MFModelResourceIterator *it;
-    if (lib3mf_model_getmeshobjects(model, &it) != LIB3MF_OK) {
+  PLib3MFModelResourceIterator *it;
+  if (lib3mf_model_getmeshobjects(model, &it) != LIB3MF_OK) {
+    return 0;
+  }
+
+  BOOL hasNext;
+  int count = 0;
+  while (true) {
+    if (lib3mf_resourceiterator_movenext(it, &hasNext) != LIB3MF_OK) {
       return 0;
     }
-
-    BOOL hasNext;
-    int count = 0;
-    while (true) {
-      if (lib3mf_resourceiterator_movenext(it, &hasNext) != LIB3MF_OK) {
-          return 0;
-      }
-      if (!hasNext) {
-        break;
-      }
-       ++count;
+    if (!hasNext) {
+      break;
     }
+    ++count;
+  }
 
-    return count;
+  return count;
 }
 
 bool handle_triangle_color(PLib3MFPropertyHandler *propertyhandler, const std::unique_ptr<PolySet>& ps, int triangle_index, int color_index, ExportContext& ctx)
@@ -175,34 +175,34 @@ bool append_polyset(const std::shared_ptr<const PolySet>& ps, ExportContext& ctx
     export_3mf_error("Can't set name for 3MF model.", ctx.model);
     return false;
   }
-  
+
   auto vertexFunc = [&](const Vector3d& coords) -> bool {
-    const auto f = coords.cast<float>();
-    MODELMESHVERTEX v{f[0], f[1], f[2]};
-    return lib3mf_meshobject_addvertex(mesh, &v, nullptr) == LIB3MF_OK;
-  };
+      const auto f = coords.cast<float>();
+      MODELMESHVERTEX v{f[0], f[1], f[2]};
+      return lib3mf_meshobject_addvertex(mesh, &v, nullptr) == LIB3MF_OK;
+    };
 
   auto triangleFunc = [&](const IndexedFace& indices) -> bool {
-    MODELMESHTRIANGLE t{(DWORD)indices[0], (DWORD)indices[1], (DWORD)indices[2]};
-    return lib3mf_meshobject_addtriangle(mesh, &t, nullptr) == LIB3MF_OK;
-  };
+      MODELMESHTRIANGLE t{(DWORD)indices[0], (DWORD)indices[1], (DWORD)indices[2]};
+      return lib3mf_meshobject_addtriangle(mesh, &t, nullptr) == LIB3MF_OK;
+    };
 
   auto materialFunc = [&](int idx, const Color4f& col) -> DWORD {
-    const auto colname = "Color " + std::to_string(idx);
-
-    DWORD id = 0;
-    lib3mf_basematerial_addmaterialutf8(ctx.basematerial,
-      colname.c_str(),
-      get_color_channel(col, 0),
-      get_color_channel(col, 1),
-      get_color_channel(col, 2),
-      &id);
-    return id;
-  };
+      const auto colname = "Color " + std::to_string(idx);
+
+      DWORD id = 0;
+      lib3mf_basematerial_addmaterialutf8(ctx.basematerial,
+                                          colname.c_str(),
+                                          get_color_channel(col, 0),
+                                          get_color_channel(col, 1),
+                                          get_color_channel(col, 2),
+                                          &id);
+      return id;
+    };
 
   auto sorted_ps = createSortedPolySet(*ps);
 
-  for (const auto &v : sorted_ps->vertices) {
+  for (const auto& v : sorted_ps->vertices) {
     if (!vertexFunc(v)) {
       export_3mf_error("Can't add vertex to 3MF model.", ctx.model);
       return false;
@@ -239,22 +239,22 @@ bool append_polyset(const std::shared_ptr<const PolySet>& ps, ExportContext& ctx
           lib3mf_basematerial_getcount(resource, &count);
           materials = count;
         }
-      };
+      }
     }
 
     ctx.materialids.reserve(sorted_ps->colors.size());
-    for (int i = 0;i < sorted_ps->colors.size();i++) {
+    for (int i = 0; i < sorted_ps->colors.size(); i++) {
       ctx.materialids.push_back(materialFunc(materials + i, sorted_ps->colors[i]));
     }
   }
 
   PLib3MFPropertyHandler *propertyhandler = nullptr;
-	if (lib3mf_meshobject_createpropertyhandler(mesh, &propertyhandler) != LIB3MF_OK) {
+  if (lib3mf_meshobject_createpropertyhandler(mesh, &propertyhandler) != LIB3MF_OK) {
     export_3mf_error("Can't create property handler for 3MF model.", ctx.model);
     return false;
   }
 
-  for (int i = 0;i < sorted_ps->color_indices.size();++i) {
+  for (int i = 0; i < sorted_ps->color_indices.size(); ++i) {
     const int32_t idx = sorted_ps->color_indices[i];
     if (!handle_triangle_color(propertyhandler, sorted_ps, i, idx, ctx)) {
       return false;
@@ -264,7 +264,7 @@ bool append_polyset(const std::shared_ptr<const PolySet>& ps, ExportContext& ctx
   lib3mf_release(propertyhandler);
 
   PLib3MFPropertyHandler *defaultpropertyhandler = nullptr;
-	if (lib3mf_object_createdefaultpropertyhandler(mesh, &defaultpropertyhandler) != LIB3MF_OK) {
+  if (lib3mf_object_createdefaultpropertyhandler(mesh, &defaultpropertyhandler) != LIB3MF_OK) {
     export_3mf_error("Can't create default property handler for 3MF model.", ctx.model);
     return false;
   }
@@ -273,10 +273,10 @@ bool append_polyset(const std::shared_ptr<const PolySet>& ps, ExportContext& ctx
     lib3mf_defaultpropertyhandler_setbasematerial(defaultpropertyhandler, ctx.basematerialid, ctx.defaultColorId);
   } else if (ctx.usecolors) {
     lib3mf_defaultpropertyhandler_setcolorrgba(defaultpropertyhandler,
-        get_color_channel(ctx.defaultColor, 0),
-        get_color_channel(ctx.defaultColor, 1),
-        get_color_channel(ctx.defaultColor, 2),
-        get_color_channel(ctx.defaultColor, 3));
+                                               get_color_channel(ctx.defaultColor, 0),
+                                               get_color_channel(ctx.defaultColor, 1),
+                                               get_color_channel(ctx.defaultColor, 2),
+                                               get_color_channel(ctx.defaultColor, 3));
   }
 
   lib3mf_release(defaultpropertyhandler);
@@ -317,7 +317,7 @@ bool append_nef(const CGAL_Nef_polyhedron& root_N, ExportContext& ctx)
   export_3mf_error("Error converting NEF Polyhedron.", ctx.model);
   return false;
 }
-#endif
+#endif // ifdef ENABLE_CGAL
 
 static bool append_3mf(const std::shared_ptr<const Geometry>& geom, ExportContext& ctx)
 {
@@ -383,29 +383,28 @@ void export_3mf(const std::shared_ptr<const Geometry>& geom, std::ostream& outpu
 
   const auto& options3mf = exportInfo.options3mf ? exportInfo.options3mf : std::make_shared<Export3mfOptions>();
   switch (options3mf->unit) {
-    case Export3mfUnit::micron:
-      lib3mf_model_setunit(model, eModelUnit::MODELUNIT_MICROMETER);
-      break;
-    case Export3mfUnit::centimeter:
-      lib3mf_model_setunit(model, eModelUnit::MODELUNIT_CENTIMETER);
-      break;
-    case Export3mfUnit::meter:
-      lib3mf_model_setunit(model, eModelUnit::MODELUNIT_METER);
-      break;
-    case Export3mfUnit::inch:
-      lib3mf_model_setunit(model, eModelUnit::MODELUNIT_INCH);
-      break;
-    case Export3mfUnit::foot:
-      lib3mf_model_setunit(model, eModelUnit::MODELUNIT_FOOT);
-      break;
-    default:
-      lib3mf_model_setunit(model, eModelUnit::MODELUNIT_MILLIMETER);
-      break;
+  case Export3mfUnit::micron:
+    lib3mf_model_setunit(model, eModelUnit::MODELUNIT_MICROMETER);
+    break;
+  case Export3mfUnit::centimeter:
+    lib3mf_model_setunit(model, eModelUnit::MODELUNIT_CENTIMETER);
+    break;
+  case Export3mfUnit::meter:
+    lib3mf_model_setunit(model, eModelUnit::MODELUNIT_METER);
+    break;
+  case Export3mfUnit::inch:
+    lib3mf_model_setunit(model, eModelUnit::MODELUNIT_INCH);
+    break;
+  case Export3mfUnit::foot:
+    lib3mf_model_setunit(model, eModelUnit::MODELUNIT_FOOT);
+    break;
+  default:
+    lib3mf_model_setunit(model, eModelUnit::MODELUNIT_MILLIMETER);
+    break;
   }
 
   Color4f defaultColor;
   DWORD defaultColorId = 0;
-  const auto settingsColor = OpenSCAD::parse_hex_color(options3mf->color);
 
   bool usecolors = false;
   DWORD basematerialid = 0;
@@ -415,11 +414,7 @@ void export_3mf(const std::shared_ptr<const Geometry>& geom, std::ostream& outpu
       // use default color that ultimately should come from the color scheme
       defaultColor = exportInfo.defaultColor;
     } else {
-      if (!settingsColor) {
-      // use color selected in the export dialog and stored in settings (if valid)
-        LOG(message_group::Warning, "Default color in settings is invalid ('%1$s'), using default from model.", options3mf->color);
-      }
-      defaultColor = settingsColor.value_or(exportInfo.defaultColor);
+      defaultColor = OpenSCAD::getColor(options3mf->color, exportInfo.defaultColor);
     }
     if (options3mf->materialType == Export3mfMaterialType::basematerial) {
       if (lib3mf_model_addbasematerialgroup(model, &basematerial) != LIB3MF_OK) {
@@ -431,10 +426,10 @@ void export_3mf(const std::shared_ptr<const Geometry>& geom, std::ostream& outpu
         return;
       }
       if (lib3mf_basematerial_addmaterialutf8(basematerial, "Default",
-        get_color_channel(defaultColor, 0),
-        get_color_channel(defaultColor, 1),
-        get_color_channel(defaultColor, 2),
-        &defaultColorId) != LIB3MF_OK) {
+                                              get_color_channel(defaultColor, 0),
+                                              get_color_channel(defaultColor, 1),
+                                              get_color_channel(defaultColor, 2),
+                                              &defaultColorId) != LIB3MF_OK) {
         export_3mf_error("Can't add default material color.", model);
         return;
       }
diff --git a/src/io/export_3mf_v2.cc b/src/io/export_3mf_v2.cc
index 200cc4a24bf..de5198f75d1 100644
--- a/src/io/export_3mf_v2.cc
+++ b/src/io/export_3mf_v2.cc
@@ -25,7 +25,7 @@
  */
 
 #include "io/export.h"
- 
+
 #include <algorithm>
 #include <cstddef>
 #include <cassert>
@@ -94,10 +94,10 @@ Lib3MF_uint8 get_color_channel(const Color4f& col, int idx)
 }
 
 int count_mesh_objects(const Lib3MF::PModel& model) {
-    const auto mesh_object_it = model->GetMeshObjects();
-    int count = 0;
-    while (mesh_object_it->MoveNext()) ++count;
-    return count;
+  const auto mesh_object_it = model->GetMeshObjects();
+  int count = 0;
+  while (mesh_object_it->MoveNext()) ++count;
+  return count;
 }
 
 void handle_triangle_color(const std::shared_ptr<const PolySet>& ps, ExportContext& ctx, Lib3MF::PMeshObject& mesh, Lib3MF_uint32 triangle, int color_index) {
@@ -144,13 +144,13 @@ void handle_triangle_color(const std::shared_ptr<const PolySet>& ps, ExportConte
 
   if (res_id > 0) {
     mesh->SetTriangleProperties(triangle, {
-      res_id,
-      {
-        col_idx,
-        col_idx,
-        col_idx
-      }
-    });
+        res_id,
+        {
+          col_idx,
+          col_idx,
+          col_idx
+        }
+      });
   }
 }
 
@@ -174,39 +174,39 @@ bool append_polyset(const std::shared_ptr<const PolySet>& ps, ExportContext& ctx
     }
 
     auto vertexFunc = [&](const Vector3d& coords) -> bool {
-      const auto f = coords.cast<float>();
-      try {
-        const Lib3MF::sPosition v{f[0], f[1], f[2]};
-        mesh->AddVertex(v);
-      } catch (Lib3MF::ELib3MFException& e) {
-        export_3mf_error(e.what());
-        return false;
-      }
-      return true;
-    };
+        const auto f = coords.cast<float>();
+        try {
+          const Lib3MF::sPosition v{f[0], f[1], f[2]};
+          mesh->AddVertex(v);
+        } catch (Lib3MF::ELib3MFException& e) {
+          export_3mf_error(e.what());
+          return false;
+        }
+        return true;
+      };
 
     auto triangleFunc = [&](const IndexedFace& indices, int color_index) -> bool {
-      try {
-        const auto triangle = mesh->AddTriangle({
-          static_cast<Lib3MF_uint32>(indices[0]),
-          static_cast<Lib3MF_uint32>(indices[1]),
-          static_cast<Lib3MF_uint32>(indices[2])
-        });
-
-        handle_triangle_color(ps, ctx, mesh, triangle, color_index);
-      } catch (Lib3MF::ELib3MFException& e) {
-        export_3mf_error(e.what());
-        return false;
-      }
-      return true;
-    };
+        try {
+          const auto triangle = mesh->AddTriangle({
+            static_cast<Lib3MF_uint32>(indices[0]),
+            static_cast<Lib3MF_uint32>(indices[1]),
+            static_cast<Lib3MF_uint32>(indices[2])
+          });
+
+          handle_triangle_color(ps, ctx, mesh, triangle, color_index);
+        } catch (Lib3MF::ELib3MFException& e) {
+          export_3mf_error(e.what());
+          return false;
+        }
+        return true;
+      };
 
     std::shared_ptr<const PolySet> out_ps = ps;
     if (Feature::ExperimentalPredictibleOutput.is_enabled()) {
       out_ps = createSortedPolySet(*ps);
     }
 
-    for (const auto &v : out_ps->vertices) {
+    for (const auto& v : out_ps->vertices) {
       if (!vertexFunc(v)) {
         export_3mf_error("Can't add vertex to 3MF model.");
         return false;
@@ -254,7 +254,7 @@ bool append_nef(const CGAL_Nef_polyhedron& root_N, ExportContext& ctx)
   export_3mf_error("Error converting NEF Polyhedron.");
   return false;
 }
-#endif
+#endif // ifdef ENABLE_CGAL
 
 bool append_3mf(const std::shared_ptr<const Geometry>& geom, ExportContext& ctx)
 {
@@ -355,21 +355,15 @@ void export_3mf(const std::shared_ptr<const Geometry>& geom, std::ostream& outpu
     break;
   }
 
-  const auto settingsColor = OpenSCAD::parse_hex_color(options3mf->color);
+  // use default color that ultimately should come from the color scheme
+  Color4f color = exportInfo.defaultColor;
 
   Lib3MF::PColorGroup colorgroup;
   Lib3MF::PBaseMaterialGroup basematerialgroup;
   if (options3mf->colorMode != Export3mfColorMode::none) {
-    Color4f color;
-    if (options3mf->colorMode == Export3mfColorMode::model) {
-      // use default color that ultimately should come from the color scheme
-      color = exportInfo.defaultColor;
-    } else {
+    if (options3mf->colorMode != Export3mfColorMode::model) {
       // use color selected in the export dialog and stored in settings (if valid)
-      if (!settingsColor) {
-        LOG(message_group::Warning, "Default color in settings is invalid ('%1$s'), using default from model.", options3mf->color);
-      }
-      color = settingsColor.value_or(exportInfo.defaultColor);
+      color = OpenSCAD::getColor(options3mf->color, exportInfo.defaultColor);
     }
     if (options3mf->materialType == Export3mfMaterialType::basematerial) {
       basematerialgroup = model->AddBaseMaterialGroup();
@@ -408,7 +402,7 @@ void export_3mf(const std::shared_ptr<const Geometry>& geom, std::ostream& outpu
     .colorgroup = colorgroup,
     .basematerialgroup = basematerialgroup,
     .modelcount = 1,
-    .selectedColor = settingsColor.value_or(exportInfo.defaultColor),
+    .selectedColor = color,
     .info = exportInfo,
     .options = options3mf
   };
diff --git a/src/python/pyfunctions.cc b/src/python/pyfunctions.cc
index 4ab7294eebf..a254a8bc989 100644
--- a/src/python/pyfunctions.cc
+++ b/src/python/pyfunctions.cc
@@ -50,8 +50,6 @@
 #include "handle_dep.h"
 
 
-extern std::unordered_map<std::string, Color4f> webcolors;
-
 PyObject *python_cube(PyObject *self, PyObject *args, PyObject *kwargs)
 {
   DECLARE_INSTANCE
@@ -1042,19 +1040,13 @@ PyObject *python_color_core(PyObject *obj, PyObject *color, double alpha)
   else if(PyUnicode_Check(color)) {
     PyObject* value = PyUnicode_AsEncodedString(color, "utf-8", "~");
     char *colorname =  PyBytes_AS_STRING(value);
-    boost::algorithm::to_lower(colorname);
-    if (webcolors.find(colorname) != webcolors.end()) {
-      node->color = webcolors.at(colorname);
+    const auto color = OpenSCAD::parse_color(colorname);
+    if (color) {
+      node->color = *color;
       node->color[3]=alpha;
     } else {
-    // Try parsing it as a hex color such as "#rrggbb".
-      const auto hexColor = OpenSCAD::parse_hex_color(colorname);
-      if (hexColor) {
-        node->color = *hexColor;
-      } else {
-        PyErr_SetString(PyExc_TypeError, "Cannot parse color");
-        return NULL;
-      }
+      PyErr_SetString(PyExc_TypeError, "Cannot parse color");
+      return NULL;
     }
   } else {
     PyErr_SetString(PyExc_TypeError, "Unknown color representation");
diff --git a/tests/regression/echo/errors-warnings-expected.echo b/tests/regression/echo/errors-warnings-expected.echo
index 9e29dd400e0..f86d9743cae 100644
--- a/tests/regression/echo/errors-warnings-expected.echo
+++ b/tests/regression/echo/errors-warnings-expected.echo
@@ -26,7 +26,7 @@ WARNING: Cylinder parameters ambiguous in file errors-warnings.scad, line 34
 WARNING: Cylinder parameters ambiguous in file errors-warnings.scad, line 35
 WARNING: color() expects numbers between 0.0 and 1.0. Value of -1.0 is out of range in file errors-warnings.scad, line 42
 WARNING: color() expects numbers between 0.0 and 1.0. Value of 2.0 is out of range in file errors-warnings.scad, line 42
-WARNING: Unable to parse color "notaname" in file errors-warnings.scad, line 43
+WARNING: Unable to parse color "notAName" in file errors-warnings.scad, line 43
 Please see https://en.wikipedia.org/wiki/Web_colors
 WARNING: Ignoring unknown module 'box' in file errors-warnings.scad, line 44
 WARNING: $fs too small - clamping to 0.010000 in file errors-warnings.scad, line 46
diff --git a/tests/regression/echo/errors-warnings-included-expected.echo b/tests/regression/echo/errors-warnings-included-expected.echo
index c153fd1bfa9..68460b5fcb6 100644
--- a/tests/regression/echo/errors-warnings-included-expected.echo
+++ b/tests/regression/echo/errors-warnings-included-expected.echo
@@ -27,7 +27,7 @@ WARNING: Cylinder parameters ambiguous in file errors-warnings.scad, line 34
 WARNING: Cylinder parameters ambiguous in file errors-warnings.scad, line 35
 WARNING: color() expects numbers between 0.0 and 1.0. Value of -1.0 is out of range in file errors-warnings.scad, line 42
 WARNING: color() expects numbers between 0.0 and 1.0. Value of 2.0 is out of range in file errors-warnings.scad, line 42
-WARNING: Unable to parse color "notaname" in file errors-warnings.scad, line 43
+WARNING: Unable to parse color "notAName" in file errors-warnings.scad, line 43
 Please see https://en.wikipedia.org/wiki/Web_colors
 WARNING: Ignoring unknown module 'box' in file errors-warnings.scad, line 44
 WARNING: $fs too small - clamping to 0.010000 in file errors-warnings.scad, line 46


Reviews:
user: login: kintel
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-24T00:11:03Z
user: login: kintel
body: Added some comments. A bit nitpicky, but I think that with such low-level APIs we should take any opportunity to proactively clean them up as that will serve us in the future.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-24T20:30:56Z
user: login: jordanbrown0
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T20:47:05Z
user: login: jordanbrown0
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T20:48:12Z
user: login: AaronVerDow
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T23:20:20Z
user: login: kintel
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-24T23:23:23Z
user: login: AaronVerDow
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T03:06:19Z
user: login: AaronVerDow
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T03:06:50Z
user: login: kintel
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T04:25:24Z
user: login: kintel
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T19:02:11Z
user: login: AaronVerDow
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T23:21:19Z
user: login: kintel
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-26T03:55:45Z

Review comments:
diff_hunk: @@ -100,4 +103,194 @@ std::optional<Color4f> parse_hex_color(const std::string& hex) {
   return rgba;
 }
 
-} // namespace
\ No newline at end of file
+std::optional<Color4f> parse_web_color(const std::string& col) {
+  std::string colorname = boost::algorithm::to_lower_copy(col);
path: src/core/ColorUtil.cc
user: login: kintel
body: The reason for test failures is that previously, the results of to_lower() was used to also print errors/warnings. I think your approach is more correct, you just have to update tests accordingly
created_at: 2025-04-24T00:11:00Z
author_association: MEMBER
diff_hunk: @@ -100,4 +103,194 @@ std::optional<Color4f> parse_hex_color(const std::string& hex) {
   return rgba;
 }
 
-} // namespace
\ No newline at end of file
+std::optional<Color4f> parse_web_color(const std::string& col) {
+  std::string colorname = boost::algorithm::to_lower_copy(col);
+  if (webcolors.find(colorname) != webcolors.end()) {
+    return webcolors.at(colorname);
+  } 
+  return {};
+}
+
+std::optional<Color4f> parse_color(const std::string& col) {
+  const auto webcolor = parse_web_color(col);
+  if (webcolor) {
+    return webcolor;
+  }
+
+  const auto hexcolor = parse_hex_color(col);
+  if (hexcolor) {
+    return hexcolor;
+  }
path: src/core/ColorUtil.cc
user: login: kintel
body: Nit: Since hexcolor is cheap to test (as it has specific length requirements and needs to start with a `#`, perhaps check it first.
created_at: 2025-04-24T20:03:10Z
author_association: MEMBER
diff_hunk: @@ -24,8 +25,16 @@ void rgbtohsv(float r, float g, float b, float& h, float& s, float& v);
 
 std::optional<Color4f> parse_hex_color(const std::string& hex);
 
+std::optional<Color4f> parse_web_color(const std::string& col);
+
+std::optional<Color4f> parse_color(const std::string& col);
+
+Color4f getColor(const std::string& col, const Color4f& defaultcolor = Color4f(0.0f, 0.0f, 0.0f));
+
 Color4f getContrastColor(const Color4f& col);
 
 Color4f getColorHSV(const Color4f& col);
 
-} // namespace
\ No newline at end of file
+extern std::unordered_map<std::string, Color4f> webcolors;
+
+} // namespace
path: src/core/ColorUtil.h
user: login: kintel
body: `} // namespace OpenSCAD`

created_at: 2025-04-24T20:09:20Z
author_association: MEMBER
diff_hunk: @@ -24,8 +25,16 @@ void rgbtohsv(float r, float g, float b, float& h, float& s, float& v);
 
 std::optional<Color4f> parse_hex_color(const std::string& hex);
 
+std::optional<Color4f> parse_web_color(const std::string& col);
+
+std::optional<Color4f> parse_color(const std::string& col);
+
+Color4f getColor(const std::string& col, const Color4f& defaultcolor = Color4f(0.0f, 0.0f, 0.0f));
path: src/core/ColorUtil.h
user: login: kintel
body: getColor() is never called without providing a default, so I'd skip the default value.
However, since the only function of this extra parameter is to convert from an optional<Color4f> to a Color4f, why not simply expose the optional? That way clients are responsible for dealing with it, as well as providing a contextually correct error message.

..which means you can simply kill getColor() and call parse_color() everywhere; one less function to deal with.
created_at: 2025-04-24T20:20:51Z
author_association: MEMBER
diff_hunk: @@ -24,8 +25,16 @@ void rgbtohsv(float r, float g, float b, float& h, float& s, float& v);
 
 std::optional<Color4f> parse_hex_color(const std::string& hex);
 
+std::optional<Color4f> parse_web_color(const std::string& col);
path: src/core/ColorUtil.h
user: login: kintel
body: parse_web_color(),  parse_hex_color(), and rgbtohsv() are implementation details and can be removed from the header.
created_at: 2025-04-24T20:24:36Z
author_association: MEMBER
diff_hunk: @@ -100,4 +103,194 @@ std::optional<Color4f> parse_hex_color(const std::string& hex) {
   return rgba;
 }
 
-} // namespace
\ No newline at end of file
+std::optional<Color4f> parse_web_color(const std::string& col) {
+  std::string colorname = boost::algorithm::to_lower_copy(col);
+  if (webcolors.find(colorname) != webcolors.end()) {
+    return webcolors.at(colorname);
+  } 
+  return {};
+}
+
+std::optional<Color4f> parse_color(const std::string& col) {
+  const auto webcolor = parse_web_color(col);
+  if (webcolor) {
+    return webcolor;
+  }
+
+  const auto hexcolor = parse_hex_color(col);
+  if (hexcolor) {
+    return hexcolor;
+  }
+
+  return {};
+}
+
+Color4f getColor(const std::string& col, const Color4f& defaultcolor)
+{
+  const auto parsed = parse_color(col);
+
+  if (!parsed) {
+    LOG(message_group::Warning, "Unable to parse color \"%1$s\", reverting to default color.", col);
+    LOG("Please see https://en.wikipedia.org/wiki/Web_colors");
+  } 
+
+  return parsed.value_or(defaultcolor);
+}
+
+// Colors extracted from https://drafts.csswg.org/css-color/ on 2015-08-02
+// CSS Color Module Level 4 - Editor’s Draft, 29 May 2015
+std::unordered_map<std::string, Color4f> webcolors{
path: src/core/ColorUtil.cc
user: login: kintel
body: Once you separate the public from the private API, you can move the private functions, as well as this map, into an anonymous namespace
created_at: 2025-04-24T20:25:37Z
author_association: MEMBER
diff_hunk: @@ -100,4 +103,194 @@ std::optional<Color4f> parse_hex_color(const std::string& hex) {
   return rgba;
path: src/core/ColorUtil.cc
user: login: jordanbrown0
body: As long as you're nearby, a few lines up on old line 96 there is a comment that says
```
// convert the hex character(s) from base 16 to base 10
```
The part about base 10 is not at all correct; there is no base 10 anywhere in the picture.  It's converting to integer (a binary format) and thence to floating point (also a binary format).  
created_at: 2025-04-24T20:47:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -100,4 +103,194 @@ std::optional<Color4f> parse_hex_color(const std::string& hex) {
   return rgba;
path: src/core/ColorUtil.cc
user: login: jordanbrown0
body: Best might be "to a fraction between 0 and 1 inclusive".
created_at: 2025-04-24T20:48:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -24,8 +25,16 @@ void rgbtohsv(float r, float g, float b, float& h, float& s, float& v);
 
 std::optional<Color4f> parse_hex_color(const std::string& hex);
 
+std::optional<Color4f> parse_web_color(const std::string& col);
+
+std::optional<Color4f> parse_color(const std::string& col);
+
+Color4f getColor(const std::string& col, const Color4f& defaultcolor = Color4f(0.0f, 0.0f, 0.0f));
path: src/core/ColorUtil.h
user: login: AaronVerDow
body: I am planning to use `getColor` for PDF and SVG exports and there I will be using the default. Here's an example from a closed pull request: https://github.com/openscad/openscad/pull/5845/files#r2059363559 (The function is called `parse_color` in that version.)
created_at: 2025-04-24T23:20:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -24,8 +25,16 @@ void rgbtohsv(float r, float g, float b, float& h, float& s, float& v);
 
 std::optional<Color4f> parse_hex_color(const std::string& hex);
 
+std::optional<Color4f> parse_web_color(const std::string& col);
+
+std::optional<Color4f> parse_color(const std::string& col);
+
+Color4f getColor(const std::string& col, const Color4f& defaultcolor = Color4f(0.0f, 0.0f, 0.0f));
path: src/core/ColorUtil.h
user: login: kintel
body: Ah, that makes more sense, as a common place to emit the warning. I'd still kill the default value though, as black isn't necessarily a universal color fallback.
created_at: 2025-04-24T23:23:21Z
author_association: MEMBER
diff_hunk: @@ -100,4 +103,194 @@ std::optional<Color4f> parse_hex_color(const std::string& hex) {
   return rgba;
 }
 
-} // namespace
\ No newline at end of file
+std::optional<Color4f> parse_web_color(const std::string& col) {
+  std::string colorname = boost::algorithm::to_lower_copy(col);
+  if (webcolors.find(colorname) != webcolors.end()) {
+    return webcolors.at(colorname);
+  } 
+  return {};
+}
+
+std::optional<Color4f> parse_color(const std::string& col) {
+  const auto webcolor = parse_web_color(col);
+  if (webcolor) {
+    return webcolor;
+  }
+
+  const auto hexcolor = parse_hex_color(col);
+  if (hexcolor) {
+    return hexcolor;
+  }
path: src/core/ColorUtil.cc
user: login: AaronVerDow
body: The logic to test this is already in `parse_hex_color`. It accepts four different string lengths, and testing is necessary to choose how it's parsed. I don't see any value in duplicating that logic here.
created_at: 2025-04-25T03:06:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -100,4 +103,194 @@ std::optional<Color4f> parse_hex_color(const std::string& hex) {
   return rgba;
 }
 
-} // namespace
\ No newline at end of file
+std::optional<Color4f> parse_web_color(const std::string& col) {
+  std::string colorname = boost::algorithm::to_lower_copy(col);
+  if (webcolors.find(colorname) != webcolors.end()) {
+    return webcolors.at(colorname);
+  } 
+  return {};
+}
+
+std::optional<Color4f> parse_color(const std::string& col) {
+  const auto webcolor = parse_web_color(col);
+  if (webcolor) {
+    return webcolor;
+  }
+
+  const auto hexcolor = parse_hex_color(col);
+  if (hexcolor) {
+    return hexcolor;
+  }
+
+  return {};
+}
+
+Color4f getColor(const std::string& col, const Color4f& defaultcolor)
+{
+  const auto parsed = parse_color(col);
+
+  if (!parsed) {
+    LOG(message_group::Warning, "Unable to parse color \"%1$s\", reverting to default color.", col);
+    LOG("Please see https://en.wikipedia.org/wiki/Web_colors");
+  } 
+
+  return parsed.value_or(defaultcolor);
+}
+
+// Colors extracted from https://drafts.csswg.org/css-color/ on 2015-08-02
+// CSS Color Module Level 4 - Editor’s Draft, 29 May 2015
+std::unordered_map<std::string, Color4f> webcolors{
path: src/core/ColorUtil.cc
user: login: AaronVerDow
body: Not sure if I moved this correctly.
created_at: 2025-04-25T03:06:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -100,4 +103,194 @@ std::optional<Color4f> parse_hex_color(const std::string& hex) {
   return rgba;
 }
 
-} // namespace
\ No newline at end of file
+std::optional<Color4f> parse_web_color(const std::string& col) {
+  std::string colorname = boost::algorithm::to_lower_copy(col);
+  if (webcolors.find(colorname) != webcolors.end()) {
+    return webcolors.at(colorname);
+  } 
+  return {};
+}
+
+std::optional<Color4f> parse_color(const std::string& col) {
+  const auto webcolor = parse_web_color(col);
+  if (webcolor) {
+    return webcolor;
+  }
+
+  const auto hexcolor = parse_hex_color(col);
+  if (hexcolor) {
+    return hexcolor;
+  }
+
+  return {};
+}
+
+Color4f getColor(const std::string& col, const Color4f& defaultcolor)
+{
+  const auto parsed = parse_color(col);
+
+  if (!parsed) {
+    LOG(message_group::Warning, "Unable to parse color \"%1$s\", reverting to default color.", col);
+    LOG("Please see https://en.wikipedia.org/wiki/Web_colors");
+  } 
+
+  return parsed.value_or(defaultcolor);
+}
+
+// Colors extracted from https://drafts.csswg.org/css-color/ on 2015-08-02
+// CSS Color Module Level 4 - Editor’s Draft, 29 May 2015
+std::unordered_map<std::string, Color4f> webcolors{
path: src/core/ColorUtil.cc
user: login: kintel
body: By "anonymous namespace" I meant create a block like this:
```
namespace {

std::unordered_map<std::string, Color4f> webcolors{ [ your stuff here ] };

std::optional<Color4f> parse_web_color(const std::string& col) { ...}

etc.

}  // namespace
```

That way, all of those implementations will be taken off the global namespace and only be available within this file. This is a very common way of hiding implementation details in C++
created_at: 2025-04-25T04:25:19Z
author_association: MEMBER
diff_hunk: @@ -29,3 +29,15 @@ Color4f getContrastColor(const Color4f& col);
 Color4f getColorHSV(const Color4f& col);
 
 } // namespace OpenSCAD
+
+namespace {
+
+void rgbtohsv(float r, float g, float b, float& h, float& s, float& v);
+
+std::optional<Color4f> parse_hex_color(const std::string& hex);
+
+std::optional<Color4f> parse_web_color(const std::string& col);
+
+extern std::unordered_map<std::string, Color4f> webcolors;
+
+} // namespace
path: src/core/ColorUtil.h
user: login: kintel
body: Almost: Remove this from the header, then wrap the corresponding implementations in an anonymous namespace in the .cc file.
created_at: 2025-04-25T19:02:05Z
author_association: MEMBER
diff_hunk: @@ -29,3 +29,15 @@ Color4f getContrastColor(const Color4f& col);
 Color4f getColorHSV(const Color4f& col);
 
 } // namespace OpenSCAD
+
+namespace {
+
+void rgbtohsv(float r, float g, float b, float& h, float& s, float& v);
+
+std::optional<Color4f> parse_hex_color(const std::string& hex);
+
+std::optional<Color4f> parse_web_color(const std::string& col);
+
+extern std::unordered_map<std::string, Color4f> webcolors;
+
+} // namespace
path: src/core/ColorUtil.h
user: login: AaronVerDow
body: Sorry, this is the largest C++ project I've worked on.
created_at: 2025-04-25T23:21:19Z
author_association: CONTRIBUTOR

PR comments:
user: login: AaronVerDow
created_at: 2025-04-23T21:40:24Z
body: Tests pass locally:

```
100% tests passed, 0 tests failed out of 2544
```
author_association: CONTRIBUTOR
user: login: gsohler
created_at: 2025-04-23T21:44:08Z
body: not many tests for python yet.
I am excited to use the new ColorUtil class.
do you have a function to convert a Color4d into an hexstring ?

author_association: CONTRIBUTOR
user: login: AaronVerDow
created_at: 2025-04-23T22:29:36Z
body: > not many tests for python yet. I am excited to use the new ColorUtil class. do you have a function to convert a Color4d into an hexstring ?

Not yet, though I will need that for SVG export. Do you know of any specific places that could use that functionality now?
author_association: CONTRIBUTOR
user: login: AaronVerDow
created_at: 2025-04-24T23:20:25Z
body: > Added some comments. A bit nitpicky, but I think that with such low-level APIs we should take any opportunity to proactively clean them up as that will serve us in the future.

I'm down with some cleanup.
author_association: CONTRIBUTOR
user: login: kintel
created_at: 2025-04-26T03:57:57Z
body: Thanks for hanging in there. There are lots of refactoring opportunities in the OpenSCAD codebase, and we tend to address that on demand. One tip for the future if you're motivated to continue contributing: Sometimes it's worth it to simply clean up existing code first without adding any new features, and open a separate PR for that. That makes it much easier to review PRs quickly and makes PRs containing real changes nice and small.
author_association: MEMBER