#include "TexturePainting.hpp" #include #include #include #include #include #include #include #include #include #include "TextureToColor/TextureToColor.hpp" #include "TextureToColor/ColorUtils.hpp" #include "Model.hpp" #include "TriangleMesh.hpp" #include "TriangleSelector.hpp" namespace Slic3r { static cv::Mat decode_texture_image(const TextureImage& img) { if (img.data.empty()) return {}; // Raw encoded image data (PNG/JPEG) from glTF loader: width == -1 if (img.width <= 0 || img.height <= 0) { std::vector buf(img.data.begin(), img.data.end()); cv::Mat raw(1, static_cast(buf.size()), CV_8UC1, buf.data()); cv::Mat decoded = cv::imdecode(raw, cv::IMREAD_COLOR); return decoded; } int cv_type = (img.channels == 4) ? CV_8UC4 : CV_8UC3; std::vector pixel_buf(img.data.begin(), img.data.end()); cv::Mat src(img.height, img.width, cv_type, pixel_buf.data()); cv::Mat bgr; if (img.channels == 4) cv::cvtColor(src, bgr, cv::COLOR_RGBA2BGR); else if (img.channels == 3) cv::cvtColor(src, bgr, cv::COLOR_RGB2BGR); else return {}; return bgr; } static void build_tex2color_mesh( const TexturedMesh& textured, tex2color::TriMesh& mesh, std::vector>& uv_coords) { const size_t nv = textured.vertices.size(); const size_t nf = textured.indices.size(); mesh.vertices.resize(nv); for (size_t i = 0; i < nv; ++i) { mesh.vertices[i] = Vec3f( textured.vertices[i][0], textured.vertices[i][1], textured.vertices[i][2]); } mesh.indices.resize(nf); for (size_t i = 0; i < nf; ++i) { mesh.indices[i] = Vec3i32( textured.indices[i][0], textured.indices[i][1], textured.indices[i][2]); } uv_coords.resize(nf); for (size_t fi = 0; fi < nf; ++fi) { uv_coords[fi].resize(3); for (int vi = 0; vi < 3; ++vi) { if (textured.has_face_uvs()) { int uv_idx = textured.uv_indices[fi][vi]; if (uv_idx >= 0 && static_cast(uv_idx) < textured.uv_coords.size()) { uv_coords[fi][vi] = Vec2f( textured.uv_coords[uv_idx][0], textured.uv_coords[uv_idx][1]); } else { uv_coords[fi][vi] = Vec2f(0.f, 0.f); } } else { int vtx_idx = textured.indices[fi][vi]; if (vtx_idx >= 0 && static_cast(vtx_idx) < textured.uvs.size()) { uv_coords[fi][vi] = Vec2f( textured.uvs[vtx_idx][0], textured.uvs[vtx_idx][1]); } else { uv_coords[fi][vi] = Vec2f(0.f, 0.f); } } } } } static void extract_painted_mesh( const tex2color::TriMesh& color_mesh, const std::vector>& face_colors, PaintedMesh& painted) { const size_t nv = color_mesh.vertices.size(); const size_t nf = color_mesh.indices.size(); painted.vertices.resize(nv); for (size_t i = 0; i < nv; ++i) { const auto& v = color_mesh.vertices[i]; painted.vertices[i] = {v.x(), v.y(), v.z()}; } painted.indices.resize(nf); for (size_t i = 0; i < nf; ++i) { const auto& f = color_mesh.indices[i]; painted.indices[i] = {f[0], f[1], f[2]}; } painted.face_colors = face_colors; std::set> unique_colors(face_colors.begin(), face_colors.end()); painted.cluster_colors.assign(unique_colors.begin(), unique_colors.end()); } // Build a vertically-stacked atlas from multiple textures and remap per-face UVs. // // Sub-textures are laid out left-aligned (x=0) at successive y offsets, with // atlas_w taken as the maximum width across all sub-textures. UVs must therefore // be remapped on BOTH axes so that faces belonging to a sub-texture narrower // than atlas_w sample inside that sub-texture's region (left side of the atlas) // instead of the right-side zero-padding. Materials that carry only a baseColor // (no map_Kd / glTF baseColorTexture) get their own 1x1 swatch at the bottom of // the atlas so their faces sample the correct flat colour rather than being // silently aliased onto textures[0]. static bool build_multi_texture_atlas( const TexturedMesh& textured, cv::Mat& out_atlas, std::vector>& out_uv_coords) { std::vector decoded; decoded.reserve(textured.textures.size()); for (const auto& ti : textured.textures) decoded.push_back(decode_texture_image(ti)); const bool has_mapping = !textured.material_texture_map.empty(); const size_t nf = textured.indices.size(); auto resolve_tex_idx = [&](int mat_idx) -> int { if (!has_mapping || mat_idx < 0 || static_cast(mat_idx) >= textured.material_texture_map.size()) return -1; const int ti = textured.material_texture_map[mat_idx]; if (ti < 0 || static_cast(ti) >= decoded.size() || decoded[ti].empty()) return -1; return ti; }; // Determine atlas width (max width across all textures) and per-texture row offsets. int atlas_w = 0; int atlas_h = 0; std::vector y_offsets(decoded.size(), 0); int first_usable_tex = -1; for (size_t i = 0; i < decoded.size(); ++i) { if (decoded[i].empty()) continue; if (first_usable_tex < 0) first_usable_tex = static_cast(i); y_offsets[i] = atlas_h; atlas_w = std::max(atlas_w, decoded[i].cols); atlas_h += decoded[i].rows; } if (atlas_w == 0 || atlas_h == 0) return false; // Collect materials that have a baseColor but no usable texture so we can // route their faces to a dedicated 1x1 solid swatch instead of aliasing // them onto textures[0]. std::map mat_solid_y; // mat_idx -> y row in atlas std::map> mat_solid_color; // mat_idx -> baseColor (RGBA) for (size_t fi = 0; fi < nf; ++fi) { const int mat_idx = (fi < textured.material_ids.size()) ? textured.material_ids[fi] : -1; if (mat_idx < 0) continue; if (resolve_tex_idx(mat_idx) >= 0) continue; if (static_cast(mat_idx) >= textured.material_colors.size()) continue; if (mat_solid_y.find(mat_idx) != mat_solid_y.end()) continue; mat_solid_y[mat_idx] = atlas_h++; mat_solid_color[mat_idx] = textured.material_colors[mat_idx]; } out_atlas = cv::Mat::zeros(atlas_h, atlas_w, CV_8UC3); for (size_t i = 0; i < decoded.size(); ++i) { if (decoded[i].empty()) continue; cv::Mat roi = out_atlas(cv::Rect(0, y_offsets[i], decoded[i].cols, decoded[i].rows)); decoded[i].copyTo(roi); } for (const auto& kv : mat_solid_color) { const auto& c = kv.second; // OpenCV stores BGR; baseColor is RGBA in [0,1]. out_atlas.at(mat_solid_y[kv.first], 0) = cv::Vec3b( static_cast(std::clamp(c[2] * 255.f, 0.f, 255.f)), static_cast(std::clamp(c[1] * 255.f, 0.f, 255.f)), static_cast(std::clamp(c[0] * 255.f, 0.f, 255.f))); } out_uv_coords.resize(nf); for (size_t fi = 0; fi < nf; ++fi) { const int mat_idx = (fi < textured.material_ids.size()) ? textured.material_ids[fi] : -1; const int tex_idx = resolve_tex_idx(mat_idx); // Pick the atlas region this face samples from. int y_off = 0, x_off = 0, th = atlas_h, tw = atlas_w; bool use_solid = false; if (tex_idx >= 0) { y_off = y_offsets[tex_idx]; th = decoded[tex_idx].rows; tw = decoded[tex_idx].cols; } else if (mat_idx >= 0 && mat_solid_y.count(mat_idx) > 0) { y_off = mat_solid_y[mat_idx]; th = 1; tw = 1; use_solid = true; } else if (first_usable_tex >= 0) { // Last-resort fallback: faces without a material or without any // baseColor still need somewhere to sample; the first usable // texture preserves legacy behaviour and, with the per-axis // remapping below, no longer aliases onto the zero-padded right // margin even when sub-textures have unequal widths. y_off = y_offsets[first_usable_tex]; th = decoded[first_usable_tex].rows; tw = decoded[first_usable_tex].cols; } out_uv_coords[fi].resize(3); for (int vi = 0; vi < 3; ++vi) { float u = 0.f, v = 0.f; if (textured.has_face_uvs()) { int uv_idx = textured.uv_indices[fi][vi]; if (uv_idx >= 0 && static_cast(uv_idx) < textured.uv_coords.size()) { u = textured.uv_coords[uv_idx][0]; v = textured.uv_coords[uv_idx][1]; } } else { int vtx_idx = textured.indices[fi][vi]; if (vtx_idx >= 0 && static_cast(vtx_idx) < textured.uvs.size()) { u = textured.uvs[vtx_idx][0]; v = textured.uvs[vtx_idx][1]; } } if (use_solid) { // Aim at the centre of the 1x1 swatch so bilinear sampling // (in tex2color) cannot drift into neighbouring rows. const float u_atlas = (x_off + 0.5f) / static_cast(atlas_w); const float v_atlas = (y_off + 0.5f) / static_cast(atlas_h); out_uv_coords[fi][vi] = Vec2f(u_atlas, v_atlas); } else { // Wrap to [0,1) on both axes (OBJ tile UVs may step outside // the unit square), then scale by the sub-texture extents so // samples land inside its actual region. Without scaling u, // any sub-texture narrower than atlas_w would have all its // faces sampled from the right-side zero-padding. u = u - std::floor(u); v = v - std::floor(v); const float u_atlas = (x_off + u * tw) / static_cast(atlas_w); const float v_atlas = (y_off + v * th) / static_cast(atlas_h); out_uv_coords[fi][vi] = Vec2f(u_atlas, v_atlas); } } } return true; } bool texture_to_painting( const TexturedMesh& textured, PaintedMesh& painted, const TexturePaintingSettings& settings, PaintProgressCallback progress, PaintCancelCallback cancel) { if (textured.vertices.empty() || textured.indices.empty() || textured.textures.empty()) return false; cv::Mat texture; tex2color::TriMesh input_mesh; std::vector> uv_coords; const bool multi_tex = textured.textures.size() > 1 && !textured.material_texture_map.empty(); if (multi_tex) { if (!build_multi_texture_atlas(textured, texture, uv_coords)) return false; // Build mesh geometry (atlas UVs already computed above) const size_t nv = textured.vertices.size(); const size_t nf = textured.indices.size(); input_mesh.vertices.resize(nv); for (size_t i = 0; i < nv; ++i) input_mesh.vertices[i] = Vec3f( textured.vertices[i][0], textured.vertices[i][1], textured.vertices[i][2]); input_mesh.indices.resize(nf); for (size_t i = 0; i < nf; ++i) input_mesh.indices[i] = Vec3i32( textured.indices[i][0], textured.indices[i][1], textured.indices[i][2]); } else { texture = decode_texture_image(textured.textures[0]); if (texture.empty()) return false; build_tex2color_mesh(textured, input_mesh, uv_coords); } tex2color::TextureToColorSettings algo_settings; algo_settings.target_colors_num = settings.target_colors_num; algo_settings.smooth_weight = settings.smooth_weight; algo_settings.oversampling_iters = settings.oversampling_iters; switch (settings.mesh_repair_decision) { case TexturePaintingSettings::MeshRepairDecision::Ask: algo_settings.mesh_repair_decision = tex2color::MeshRepairDecision::Ask; break; case TexturePaintingSettings::MeshRepairDecision::RepairAndImport: algo_settings.mesh_repair_decision = tex2color::MeshRepairDecision::RepairAndImport; break; case TexturePaintingSettings::MeshRepairDecision::ImportWithoutRepair: default: algo_settings.mesh_repair_decision = tex2color::MeshRepairDecision::ImportWithoutRepair; break; } tex2color::AlgoProgressCallback algo_progress = nullptr; if (progress) { algo_progress = [&progress](tex2color::AlgoProgress p) { progress(p.percent, p.message); }; } tex2color::AlgoCancelCallback algo_cancel = nullptr; if (cancel) { algo_cancel = [&cancel]() -> bool { return cancel(); }; } tex2color::TriMesh color_mesh; std::vector> face_colors; algo_settings.mesh_repair_decision_required = settings.mesh_repair_decision_required; algo_settings.mesh_repair_callback = settings.mesh_repair_callback; bool ok = tex2color::TextureToColor( input_mesh, uv_coords, texture, color_mesh, face_colors, algo_settings, algo_progress, algo_cancel); if (!ok) return false; extract_painted_mesh(color_mesh, face_colors, painted); return true; } bool face_colors_to_painting( const TexturedMesh& mesh, PaintedMesh& painted, const TexturePaintingSettings& settings, PaintProgressCallback progress, PaintCancelCallback cancel) { if (mesh.vertices.empty() || mesh.indices.empty() || mesh.precomputed_face_colors.empty()) return false; // Build tex2color::TriMesh from input geometry tex2color::TriMesh input_mesh; input_mesh.vertices.resize(mesh.vertices.size()); for (size_t i = 0; i < mesh.vertices.size(); ++i) input_mesh.vertices[i] = Vec3f(mesh.vertices[i][0], mesh.vertices[i][1], mesh.vertices[i][2]); input_mesh.indices.resize(mesh.indices.size()); for (size_t i = 0; i < mesh.indices.size(); ++i) input_mesh.indices[i] = Vec3i32(mesh.indices[i][0], mesh.indices[i][1], mesh.indices[i][2]); // Forward settings to tex2color tex2color::TextureToColorSettings algo_settings; algo_settings.target_colors_num = settings.target_colors_num; algo_settings.smooth_weight = settings.smooth_weight; switch (settings.mesh_repair_decision) { case TexturePaintingSettings::MeshRepairDecision::Ask: algo_settings.mesh_repair_decision = tex2color::MeshRepairDecision::Ask; break; case TexturePaintingSettings::MeshRepairDecision::RepairAndImport: algo_settings.mesh_repair_decision = tex2color::MeshRepairDecision::RepairAndImport; break; case TexturePaintingSettings::MeshRepairDecision::ImportWithoutRepair: default: algo_settings.mesh_repair_decision = tex2color::MeshRepairDecision::ImportWithoutRepair; break; } algo_settings.mesh_repair_decision_required = settings.mesh_repair_decision_required; algo_settings.mesh_repair_callback = settings.mesh_repair_callback; tex2color::AlgoProgressCallback algo_progress = nullptr; if (progress) { algo_progress = [&progress](tex2color::AlgoProgress p) { progress(p.percent, p.message); }; } tex2color::AlgoCancelCallback algo_cancel = nullptr; if (cancel) { algo_cancel = [&cancel]() -> bool { return cancel(); }; } tex2color::TriMesh out_mesh; std::vector> out_face_colors; bool ok = tex2color::ClusterAndSmooth( input_mesh, mesh.precomputed_face_colors, out_mesh, out_face_colors, algo_settings, algo_progress, algo_cancel, mesh.precomputed_vertex_colors); if (!ok) return false; extract_painted_mesh(out_mesh, out_face_colors, painted); return true; } double compute_delta_e( const std::array& rgb1, const std::array& rgba2) { return tex2color::color_utils::calc_rgb_color_difference_by_ciede2000( rgb1, { static_cast(rgba2[0] * 255.0f), static_cast(rgba2[1] * 255.0f), static_cast(rgba2[2] * 255.0f) }); } std::vector match_clusters_to_filaments( const std::vector>& cluster_colors, const std::vector>& filament_colors, const std::vector& /*filament_names*/) { std::vector matches(cluster_colors.size()); for (size_t ci = 0; ci < cluster_colors.size(); ++ci) { matches[ci].cluster_index = static_cast(ci); matches[ci].cluster_color = cluster_colors[ci]; matches[ci].delta_e = 1e9; for (size_t fi = 0; fi < filament_colors.size(); ++fi) { double de = compute_delta_e(cluster_colors[ci], filament_colors[fi]); if (de < matches[ci].delta_e) { matches[ci].delta_e = de; matches[ci].filament_index = static_cast(fi); matches[ci].filament_color = filament_colors[fi]; } } } return matches; } bool apply_painted_mesh_to_volume( const PaintedMesh& painted, const std::vector& matches, ModelVolume& volume) { if (painted.face_colors.empty() || matches.empty()) return false; const auto& cluster_colors = painted.cluster_colors; std::map, int> color_to_filament; for (const auto& m : matches) { if (m.cluster_index >= 0 && m.cluster_index < (int)cluster_colors.size() && m.filament_index >= 0) color_to_filament[cluster_colors[m.cluster_index]] = m.filament_index; } indexed_triangle_set its; its.vertices.resize(painted.vertices.size()); for (size_t i = 0; i < painted.vertices.size(); ++i) { its.vertices[i] = Vec3f( painted.vertices[i][0], painted.vertices[i][1], painted.vertices[i][2]); } its.indices.resize(painted.indices.size()); for (size_t i = 0; i < painted.indices.size(); ++i) { its.indices[i] = Vec3i32( painted.indices[i][0], painted.indices[i][1], painted.indices[i][2]); } TriangleMesh new_mesh(std::move(its)); // The volume already went through ModelObject::add_volume -> // center_geometry_after_creation, which translated its mesh by // -source.mesh_offset (and folded that shift into the volume // transformation). The painted mesh, however, is derived from the // raw textured mesh and is therefore expressed in the original // un-centered coordinate frame. Reuse the exact recorded shift to // align it -- do NOT compute it from the bounding-box centers of // the two meshes: tex2color::TextureToColor performs subdivision // and CGAL polygon-soup repair, so the painted vertex count and // bbox no longer match the original textured mesh and a bbox- // center alignment would silently displace the geometry. // // If the model has been scaled by Model::convert_from_meters / // convert_from_imperial_units after load, the painted mesh fed // here is already in millimetres (Model::convert_* also scales // texture_mesh in place) while source.mesh_offset was recorded // before the conversion and therefore still lives in the original // pre-scaled frame. Bring it into the same frame as the painted // vertices so the alignment shift below stays correct on the // textured-import path. This compensation is scoped to this // function so that other (non-textured) import paths are not // affected. Vec3d mesh_offset = volume.source.mesh_offset; double unit_scale = 1.0; if (volume.source.is_converted_from_meters) unit_scale = 1000.0; else if (volume.source.is_converted_from_inches) unit_scale = 25.4; if (unit_scale != 1.0) mesh_offset *= unit_scale; if (!mesh_offset.isApprox(Vec3d::Zero())) new_mesh.translate(-mesh_offset.cast()); new_mesh.set_init_shift(mesh_offset); // Log bbox drift for diagnostics. Subdivision + CGAL polygon-soup // repair routinely changes vertex count and bbox, so moderate drift // is expected and must not block the apply. if (!new_mesh.empty() && !volume.mesh().empty()) { const Vec3d new_center = new_mesh.bounding_box().center(); const Vec3d cur_center = volume.mesh().bounding_box().center(); const double diag = volume.mesh().bounding_box().size().norm(); const double drift = (new_center - cur_center).norm(); if (drift > 0.05 * std::max(1.0, diag)) BOOST_LOG_TRIVIAL(warning) << "apply_painted_mesh_to_volume: painted bbox center drifted by " << drift << " (bbox diag=" << diag << ", unit_scale=" << unit_scale << ", from_meters=" << volume.source.is_converted_from_meters << ", from_inches=" << volume.source.is_converted_from_inches << ")"; else if (drift > 1e-3 * std::max(1.0, diag)) BOOST_LOG_TRIVIAL(info) << "apply_painted_mesh_to_volume: minor bbox drift " << drift << " (bbox diag=" << diag << ", unit_scale=" << unit_scale << ")"; } volume.set_mesh(std::move(new_mesh)); volume.calculate_convex_hull(); // Re-center the replaced mesh so its bbox center sits at the origin, // matching what center_geometry_after_creation did for the original mesh. // CGAL repair / subdivision may shift the bbox center (drift); without // re-centering, the volume offset (which was computed for the original // centered mesh) no longer matches, causing the model to float or clip. // Pass false to keep source.mesh_offset unchanged. volume.center_geometry_after_creation(false); volume.invalidate_convex_hull_2d(); // Mesh geometry has been replaced; any per-face annotation indexed // against the previous triangle set is now stale. mmu_segmentation_facets // is rewritten below from the new selector; reset the others so future // import paths that carry support / seam / fuzzy_skin painting cannot // leak indices from the old mesh into the new one. volume.supported_facets.reset(); volume.fuzzy_skin_facets.reset(); volume.seam_facets.reset(); if (ModelObject* obj = volume.get_object()) obj->invalidate_bounding_box(); TriangleSelector selector(volume.mesh()); for (size_t fi = 0; fi < painted.face_colors.size() && fi < (size_t)volume.mesh().its.indices.size(); ++fi) { auto it = color_to_filament.find(painted.face_colors[fi]); if (it != color_to_filament.end()) { int extruder_idx = it->second; auto state = static_cast( static_cast(EnforcerBlockerType::Extruder1) + extruder_idx); if (state <= EnforcerBlockerType::ExtruderMax) selector.set_facet(static_cast(fi), state); } } volume.mmu_segmentation_facets.set(selector); return true; } bool decode_texture_to_pixels( const TextureImage& img, std::vector& out_pixels, int& out_w, int& out_h) { cv::Mat decoded = decode_texture_image(img); if (decoded.empty()) return false; // decoded is BGR, CV_8UC3 out_w = decoded.cols; out_h = decoded.rows; size_t nbytes = (size_t)out_w * out_h * 3; out_pixels.resize(nbytes); if (decoded.isContinuous()) { std::memcpy(out_pixels.data(), decoded.data, nbytes); } else { for (int r = 0; r < out_h; ++r) std::memcpy(out_pixels.data() + r * out_w * 3, decoded.ptr(r), out_w * 3); } return true; } // Sample face color from texture using 3 explicit UV values (centroid + bilinear). static std::array sample_face_from_uvs( const cv::Mat& tex, const std::array& uv0, const std::array& uv1, const std::array& uv2) { float cu = (uv0[0] + uv1[0] + uv2[0]) / 3.f; float cv_val = (uv0[1] + uv1[1] + uv2[1]) / 3.f; cu = cu - std::floor(cu); cv_val = cv_val - std::floor(cv_val); float fx = cu * (tex.cols - 1); float fy = cv_val * (tex.rows - 1); int x0 = std::clamp(static_cast(fx), 0, tex.cols - 1); int y0 = std::clamp(static_cast(fy), 0, tex.rows - 1); int x1 = std::min(x0 + 1, tex.cols - 1); int y1 = std::min(y0 + 1, tex.rows - 1); float wx = fx - x0; float wy = fy - y0; const int ch = tex.channels(); auto sample = [&](int row, int col) -> std::array { const uchar* ptr = tex.data + row * tex.step[0] + col * ch; return {static_cast(ptr[2]), static_cast(ptr[1]), static_cast(ptr[0])}; }; auto c00 = sample(y0, x0); auto c10 = sample(y0, x1); auto c01 = sample(y1, x0); auto c11 = sample(y1, x1); std::array color; for (int i = 0; i < 3; ++i) { float top = c00[i] * (1.f - wx) + c10[i] * wx; float bot = c01[i] * (1.f - wx) + c11[i] * wx; color[i] = static_cast(std::clamp(top * (1.f - wy) + bot * wy, 0.f, 255.f)); } return color; } // Legacy overload: look up UVs from per-vertex array by vertex indices. static std::array sample_face_from_texture( const cv::Mat& tex, const std::vector>& uvs, const std::array& face) { std::array uv0 = {0.f, 0.f}, uv1 = {0.f, 0.f}, uv2 = {0.f, 0.f}; if (face[0] >= 0 && static_cast(face[0]) < uvs.size()) uv0 = uvs[face[0]]; if (face[1] >= 0 && static_cast(face[1]) < uvs.size()) uv1 = uvs[face[1]]; if (face[2] >= 0 && static_cast(face[2]) < uvs.size()) uv2 = uvs[face[2]]; return sample_face_from_uvs(tex, uv0, uv1, uv2); } bool sample_original_face_colors( const TexturedMesh& textured, std::vector>& out_face_colors) { if (textured.indices.empty()) return false; // Decode all textures up front std::vector decoded_textures; decoded_textures.reserve(textured.textures.size()); for (const auto& ti : textured.textures) { decoded_textures.push_back(decode_texture_image(ti)); } const bool has_mapping = !textured.material_texture_map.empty(); const size_t nf = textured.indices.size(); out_face_colors.resize(nf); for (size_t fi = 0; fi < nf; ++fi) { int mat_idx = (fi < textured.material_ids.size()) ? textured.material_ids[fi] : -1; int tex_idx = -1; if (has_mapping && mat_idx >= 0 && static_cast(mat_idx) < textured.material_texture_map.size()) tex_idx = textured.material_texture_map[mat_idx]; else if (!decoded_textures.empty()) tex_idx = 0; // fallback: single-texture model if (tex_idx >= 0 && static_cast(tex_idx) < decoded_textures.size() && !decoded_textures[tex_idx].empty()) { if (textured.has_face_uvs()) { const auto& ui = textured.uv_indices[fi]; auto get_uv = [&](int vi) -> std::array { int idx = ui[vi]; if (idx >= 0 && static_cast(idx) < textured.uv_coords.size()) return textured.uv_coords[idx]; return {0.f, 0.f}; }; out_face_colors[fi] = sample_face_from_uvs( decoded_textures[tex_idx], get_uv(0), get_uv(1), get_uv(2)); } else { out_face_colors[fi] = sample_face_from_texture( decoded_textures[tex_idx], textured.uvs, textured.indices[fi]); } } else if (has_mapping && mat_idx >= 0 && static_cast(mat_idx) < textured.material_colors.size()) { // No texture — use baseColorFactor as solid color const auto& c = textured.material_colors[mat_idx]; out_face_colors[fi] = { static_cast(std::clamp(c[0] * 255.f, 0.f, 255.f)), static_cast(std::clamp(c[1] * 255.f, 0.f, 255.f)), static_cast(std::clamp(c[2] * 255.f, 0.f, 255.f)) }; } else { out_face_colors[fi] = {192, 192, 192}; // default gray } } return true; } } // namespace Slic3r