From e6cfadddca790d854512ec43f7720ad699c9253c Mon Sep 17 00:00:00 2001 From: Rad Date: Tue, 10 Feb 2026 03:49:45 +0100 Subject: [PATCH] Enhance color blending in MixedFilament: Introduce RYB pigment-style blending for improved color mixing accuracy. Add RGB to RYB and RYB to RGB conversion functions, and update blend_color method to utilize the new blending approach. Improve error handling in hex color parsing. --- docs/U1_Local_Z_Dithering_Design_Draft.md | 223 ++++++++++++++++++++++ src/libslic3r/MixedFilament.cpp | 170 ++++++++++++++--- src/libslic3r/MixedFilament.hpp | 8 +- 3 files changed, 366 insertions(+), 35 deletions(-) create mode 100644 docs/U1_Local_Z_Dithering_Design_Draft.md diff --git a/docs/U1_Local_Z_Dithering_Design_Draft.md b/docs/U1_Local_Z_Dithering_Design_Draft.md new file mode 100644 index 0000000000..7f02e9e101 --- /dev/null +++ b/docs/U1_Local_Z_Dithering_Design_Draft.md @@ -0,0 +1,223 @@ +# U1 Local Z Dithering Design Draft + +Status: Draft for later implementation +Last updated: 2026-02-10 +Owner: Snapmaker Orca engineering + +## 1. Problem Statement + +Current dithering support can alternate component filaments in Z, but layer height is still resolved globally per object layer. +This means: + +- `dithering_z_step_size` can only change full layers (or full Z bands), not only painted XY zones. +- The requested behavior (`---===`) is not achievable today: + - non-painted area keeps base height (example: `0.12`) + - painted mixed area is subdivided (example: `0.06 + 0.06`) in the same nominal Z interval + +## 2. Current Architecture Constraints + +Key code paths: + +- Layer heights are created before segmentation: + - `src/libslic3r/PrintObjectSlice.cpp` (`update_layer_height_profile`, `generate_object_layers`) +- Mixed painting segmentation is applied after layers already exist: + - `src/libslic3r/PrintObjectSlice.cpp` (`apply_mm_segmentation`) + - `src/libslic3r/MultiMaterialSegmentation.cpp` +- `Layer` has one `height`, `slice_z`, `print_z` for the entire layer: + - `src/libslic3r/Layer.hpp` + +Conclusion: existing pipeline assumes one global Z step per layer. Local per-XY sublayering needs a new planning model. + +## 3. Goals + +- Support local Z subdivision for mixed-painted zones only. +- Keep base regions at user base layer height whenever possible. +- Preserve current mixed filament alternation logic (A/B cadence). +- Keep existing behavior when local mode is disabled. +- Avoid regressions in non-mixed prints. + +## 4. Non-Goals (Phase 1) + +- Full non-planar slicing. +- Arbitrary local adaptive mesh refinement outside mixed-painted zones. +- Rewriting all perimeter/infill algorithms from scratch. + +## 5. Proposed Feature Model + +Add a new mode on top of current dithering: + +- `dithering_local_z_mode` (bool, default `false`) +- Existing `dithering_z_step_size` remains the micro step for painted zones. +- Existing `dithering_step_painted_zones_only` remains as compatibility switch for current global mode. + +When `dithering_local_z_mode = true`: + +- For each base Z interval `[z0, z1]`: + - if no mixed paint intersects interval: print normally at base layer height. + - if mixed paint intersects interval: split interval into sublayers at `dithering_z_step_size`. + - mixed-painted XY polygons are printed on each sublayer with alternating components. + - non-mixed XY polygons are printed as a base-height pass in that interval (not duplicated every sublayer). + +## 6. High-Level Architecture + +Introduce a two-level planning pipeline: + +1. Base Layer Plan (existing): + - Build base object layers as today. +2. Local Z Expansion Plan (new): + - For base layers that intersect mixed-painted areas, build `SubLayerPlan` entries. +3. Toolpath Assignment Plan (new): + - Route painted polygons to sublayers. + - Route non-painted polygons to one base-height pass within same interval. +4. G-code Scheduler (extended): + - Emit sublayer passes in Z order while respecting extrusion height per pass. + +## 7. New Data Structures (Draft) + +Add new planning structs (names tentative): + +```cpp +struct LocalZInterval { + double z_lo; + double z_hi; + double base_height; // e.g. 0.12 + double sublayer_height; // e.g. 0.06 + bool has_mixed_paint; +}; + +struct SubLayerPlan { + double z_lo; + double z_hi; + double print_z; + double flow_height; + // per extruder painted masks for this sublayer + std::vector painted_masks_by_extruder; + // polygons printed as normal/non-mixed in this pass + ExPolygons base_masks; +}; +``` + +Attach `std::vector` to `PrintObject` (or a dedicated planner cache) without immediately replacing `Layer`. + +## 8. Algorithm Draft + +### 8.1 Build Local Z Intervals + +- Start from base layer intervals from `generate_object_layers`. +- For each base interval, query whether mixed-painted states are present in that Z range. +- If not present, interval remains single-pass. +- If present, split into `N = ceil(base_height / z_step)` subintervals. + +### 8.2 Build Painted Masks Per SubLayer + +- Re-run or adapt `multi_material_segmentation_by_painting` to produce painted masks at sublayer Z samples (not only base layer Z samples). +- For each sublayer: + - derive active mixed pair (A/B) based on cadence index. + - assign painted XY polygons to active physical extruder for that sublayer. + +### 8.3 Preserve Base Regions at Base Height + +- For non-painted polygons inside a locally-split base interval: + - emit once with `flow_height = base_height` in a designated pass (typically final sublayer pass of interval). + - do not emit these polygons on intermediate sublayers. + +### 8.4 Boundary Handling + +- At boundaries between painted and non-painted masks: + - enforce overlap/tolerance compensation to avoid cracks. + - clip with robust polygon booleans and min-area filtering. +- Add seam strategy notes for transitions (TBD in implementation). + +## 9. Required Code Areas + +Primary: + +- `src/libslic3r/PrintObjectSlice.cpp` (new local-Z planning stage, call ordering) +- `src/libslic3r/MultiMaterialSegmentation.cpp` (sublayer mask generation) +- `src/libslic3r/PrintObject.cpp` (cache invalidation + storage for local-Z plans) +- `src/libslic3r/GCode/*` (scheduler/tool ordering to emit sublayer passes correctly) + +Likely: + +- `src/libslic3r/Layer*` (if promoting local-z plan into first-class layer abstraction) +- `src/libslic3r/PrintApply.cpp` (config propagation and reset handling) +- `src/slic3r/GUI/Tab.cpp` and `src/libslic3r/PrintConfig.*` (new option + tooltips) + +## 10. Compatibility and Migration + +- Keep existing `dithering_z_step_size` behavior when `dithering_local_z_mode=false`. +- Hide/disable local-Z checkbox unless mixed virtual filament is enabled. +- Project files without new key must load as current behavior. +- Fallback: if local-Z planner fails, auto-fallback to current global mode with warning. + +## 11. Validation Plan + +### Unit/logic tests + +- Interval splitting: + - no mixed paint -> no split + - mixed paint -> expected number of sublayers +- Cadence: + - A/B alternation across sublayers matches configured ratio/step +- Config changes: + - step size changes between slices must invalidate planner cache + +### Integration tests + +- Painted stripe through object: + - verify non-painted region keeps base pass count + - verify painted region gets subdivided passes +- Multi-object plate with only one mixed object. +- Support + mixed paint interaction. + +### G-code assertions + +- In affected intervals: + - expected `Z` move cadence in mixed zones + - base region extrusion appears once per base interval +- In unaffected intervals: + - unchanged base layer Z cadence + +## 12. Risks + +- High complexity around perimeter/fill continuity at mask boundaries. +- Performance impact from finer slicing and extra polygon clipping. +- Increased memory use for per-sublayer painted masks. +- Potential regressions in wipe tower/tool ordering and support synchronization. + +## 13. Rollout Plan + +Phase A - Planner skeleton (no G-code changes yet): + +- Build and debug `LocalZInterval` and `SubLayerPlan`. +- Add debug export (SVG/JSON) for visual verification. + +Phase B - Limited emission path: + +- Enable local-Z only for perimeter walls on painted regions. +- Keep infill/base regions on current behavior for early validation. + +Phase C - Full emission: + +- Perimeters + infill + top/bottom in local-Z path. +- Boundary compensation and seam cleanup. + +Phase D - Stabilization: + +- Performance tuning, cache policy, presets/profile updates. +- Expand regression suite and add fixture models. + +## 14. Open Questions + +- Should non-painted regions in split intervals be printed on first or last sublayer pass? +- Do we allow base-height extrusion in an interval where painted sublayers already deposited material nearby, or enforce sublayer-only flow there? +- How should top/bottom skin logic behave when only part of a layer is sublayered? +- Should local-Z mode require a minimum nozzle/step ratio gate for print safety? + +## 15. Recommended Next Step + +Prototype only Phase A: + +- Build `LocalZInterval`/`SubLayerPlan` and dump debug artifacts. +- No toolpath emission changes yet. +- Use this to validate that painted masks and interval splitting are stable before committing to full pipeline rewrite. diff --git a/src/libslic3r/MixedFilament.cpp b/src/libslic3r/MixedFilament.cpp index 52983b6f92..51124c9c38 100644 --- a/src/libslic3r/MixedFilament.cpp +++ b/src/libslic3r/MixedFilament.cpp @@ -16,14 +16,125 @@ struct RGB { int r = 0, g = 0, b = 0; }; +struct RGBf { + float r = 0.f, g = 0.f, b = 0.f; +}; + +static float clamp01(float v) +{ + return std::max(0.f, std::min(1.f, v)); +} + +static RGBf to_rgbf(const RGB &c) +{ + return { + clamp01(static_cast(c.r) / 255.f), + clamp01(static_cast(c.g) / 255.f), + clamp01(static_cast(c.b) / 255.f) + }; +} + +static RGB to_rgb8(const RGBf &c) +{ + auto to_u8 = [](float v) -> int { + return std::clamp(static_cast(std::round(clamp01(v) * 255.f)), 0, 255); + }; + return { to_u8(c.r), to_u8(c.g), to_u8(c.b) }; +} + +// Convert RGB to an artist-pigment style RYB space. +// This is an approximation, but it gives expected pair mixes: +// Red + Blue -> Purple, Blue + Yellow -> Green, Red + Yellow -> Orange. +static RGBf rgb_to_ryb(RGBf in) +{ + float r = clamp01(in.r); + float g = clamp01(in.g); + float b = clamp01(in.b); + + const float white = std::min({ r, g, b }); + r -= white; + g -= white; + b -= white; + + const float max_g = std::max({ r, g, b }); + + float y = std::min(r, g); + r -= y; + g -= y; + + if (b > 0.f && g > 0.f) { + b *= 0.5f; + g *= 0.5f; + } + + y += g; + b += g; + + const float max_y = std::max({ r, y, b }); + if (max_y > 1e-6f) { + const float n = max_g / max_y; + r *= n; + y *= n; + b *= n; + } + + r += white; + y += white; + b += white; + return { clamp01(r), clamp01(y), clamp01(b) }; +} + +static RGBf ryb_to_rgb(RGBf in) +{ + float r = clamp01(in.r); + float y = clamp01(in.g); + float b = clamp01(in.b); + + const float white = std::min({ r, y, b }); + r -= white; + y -= white; + b -= white; + + const float max_y = std::max({ r, y, b }); + + float g = std::min(y, b); + y -= g; + b -= g; + + if (b > 0.f && g > 0.f) { + b *= 2.f; + g *= 2.f; + } + + r += y; + g += y; + + const float max_g = std::max({ r, g, b }); + if (max_g > 1e-6f) { + const float n = max_y / max_g; + r *= n; + g *= n; + b *= n; + } + + r += white; + g += white; + b += white; + return { clamp01(r), clamp01(g), clamp01(b) }; +} + // Parse "#RRGGBB" to RGB. Returns black on failure. static RGB parse_hex_color(const std::string &hex) { RGB c; if (hex.size() >= 7 && hex[0] == '#') { - c.r = std::stoi(hex.substr(1, 2), nullptr, 16); - c.g = std::stoi(hex.substr(3, 2), nullptr, 16); - c.b = std::stoi(hex.substr(5, 2), nullptr, 16); + try { + c.r = std::stoi(hex.substr(1, 2), nullptr, 16); + c.g = std::stoi(hex.substr(3, 2), nullptr, 16); + c.b = std::stoi(hex.substr(5, 2), nullptr, 16); + } catch (...) { + c = {}; + } } return c; } @@ -124,38 +235,34 @@ std::string MixedFilamentManager::blend_color(const std::string &color_a, const std::string &color_b, int ratio_a, int ratio_b) { - RGB a = parse_hex_color(color_a); - RGB b = parse_hex_color(color_b); - - // Additive blend: min(a + b, 255) per channel. - // For unequal ratios, weight accordingly. - const float total = static_cast(ratio_a + ratio_b); - const float wa = (total > 0.f) ? static_cast(ratio_a) / total : 0.5f; + const int safe_a = std::max(0, ratio_a); + const int safe_b = std::max(0, ratio_b); + const float total = static_cast(safe_a + safe_b); + const float wa = (total > 0.f) ? static_cast(safe_a) / total : 0.5f; const float wb = 1.f - wa; - // Use screen blending which is additive-like without oversaturation: - // screen(A, B) = A + B - A*B/255 - // Weighted variant: blend each channel independently. - auto screen_ch = [](int ca, int cb, float wa, float wb) -> int { - // Weighted additive with clamping – matches user expectation: - // Red(255,0,0) + Green(0,255,0) = Yellow(255,255,0) - float v = static_cast(ca) * wa + static_cast(cb) * wb; - // Boost towards additive: add the minimum so pure colours combine fully. - float additive = std::min(static_cast(ca + cb), 255.f); - // Blend between weighted-average and full-additive based on colour distance. - float result = wa * static_cast(ca) + wb * static_cast(cb); - // For the 1:1 case, use pure additive (clamped) to get R+G=Y. - if (std::abs(wa - wb) < 0.01f) - result = additive; - return std::min(static_cast(std::round(result)), 255); - }; + const RGBf rgb_a = to_rgbf(parse_hex_color(color_a)); + const RGBf rgb_b = to_rgbf(parse_hex_color(color_b)); + const RGBf ryb_a = rgb_to_ryb(rgb_a); + const RGBf ryb_b = rgb_to_ryb(rgb_b); - RGB out; - out.r = screen_ch(a.r, b.r, wa, wb); - out.g = screen_ch(a.g, b.g, wa, wb); - out.b = screen_ch(a.b, b.b, wa, wb); + RGBf ryb_out; + ryb_out.r = wa * ryb_a.r + wb * ryb_b.r; + ryb_out.g = wa * ryb_a.g + wb * ryb_b.g; + ryb_out.b = wa * ryb_a.b + wb * ryb_b.b; - return rgb_to_hex(out); + RGBf rgb_out = ryb_to_rgb(ryb_out); + const float v_out = std::max({ rgb_out.r, rgb_out.g, rgb_out.b }); + const float v_tgt = wa * std::max({ rgb_a.r, rgb_a.g, rgb_a.b }) + + wb * std::max({ rgb_b.r, rgb_b.g, rgb_b.b }); + if (v_out > 1e-6f && v_tgt > 0.f) { + const float scale = v_tgt / v_out; + rgb_out.r = clamp01(rgb_out.r * scale); + rgb_out.g = clamp01(rgb_out.g * scale); + rgb_out.b = clamp01(rgb_out.b * scale); + } + + return rgb_to_hex(to_rgb8(rgb_out)); } size_t MixedFilamentManager::enabled_count() const @@ -177,3 +284,4 @@ std::vector MixedFilamentManager::display_colors() const } } // namespace Slic3r + diff --git a/src/libslic3r/MixedFilament.hpp b/src/libslic3r/MixedFilament.hpp index 676ff8d49f..3fe2e4f37a 100644 --- a/src/libslic3r/MixedFilament.hpp +++ b/src/libslic3r/MixedFilament.hpp @@ -9,8 +9,9 @@ namespace Slic3r { // Represents a virtual "mixed" filament created by alternating layers of two -// physical filaments. The display colour is an additive RGB blend so that, -// for example, Red + Green previews as Yellow. +// physical filaments. The display colour uses an RYB pigment-style blend so +// pair previews better match expected print mixing (for example Blue+Yellow +// -> Green, Red+Yellow -> Orange, Red+Blue -> Purple). struct MixedFilament { // 1-based physical filament IDs that are combined. @@ -79,8 +80,7 @@ public: // mixed filament. unsigned int resolve(unsigned int filament_id, size_t num_physical, int layer_index) const; - // Compute a display colour by additively blending the two component - // colours. `filament_colours` contains the physical colours only. + // Compute a display colour by blending in RYB pigment space. static std::string blend_color(const std::string &color_a, const std::string &color_b, int ratio_a, int ratio_b);