diff --git a/pr-media/pr-11592-text-shape-demo.gif b/pr-media/pr-11592-text-shape-demo.gif new file mode 100644 index 0000000000..5eddb713f2 Binary files /dev/null and b/pr-media/pr-11592-text-shape-demo.gif differ diff --git a/src/libslic3r/Emboss.cpp b/src/libslic3r/Emboss.cpp index 037c9f5ce1..d6cda0e379 100644 --- a/src/libslic3r/Emboss.cpp +++ b/src/libslic3r/Emboss.cpp @@ -1475,9 +1475,16 @@ void Slic3r::Emboss::text2vshapes(EmbossShape & emboss_shape, text_scales.emplace_back(real_scale); } cur_x = cursor.x() * standard_scale; - text_cursors.emplace_back(cur_x - last_x); - text_absolute_cursors.emplace_back(cur_x); - last_x = cur_x; + // After a line break cursor.x is reset; do not treat that as a negative advance into the previous line. + if (letter == '\n' || letter == '\r') { + text_cursors.emplace_back(0.f); + text_absolute_cursors.emplace_back(cur_x); + last_x = 0.f; + } else { + text_cursors.emplace_back(cur_x - last_x); + text_absolute_cursors.emplace_back(cur_x); + last_x = cur_x; + } } std::vector offset_xy; if (bfc_fn) {// support_backup_fonts @@ -1487,11 +1494,13 @@ void Slic3r::Emboss::text2vshapes(EmbossShape & emboss_shape, } std::vector text_align_offsets; text_align_offsets.resize(text_scales.size()); - for (int i = 0; i < text_scales.size(); i++) { - float x = (float) offset_xy[i][0] * standard_scale; - float y = (float) offset_xy[i][1] * standard_scale; - text_align_offsets[i][0] = x; - text_align_offsets[i][1] = y; + for (int i = 0; i < (int) text_scales.size(); i++) { + if (i < (int) offset_xy.size()) { + text_align_offsets[i][0] = (float) offset_xy[i][0] * standard_scale; + text_align_offsets[i][1] = (float) offset_xy[i][1] * standard_scale; + } else { + text_align_offsets[i] = Vec2f::Zero(); + } } emboss_shape.text_align_offsets = text_align_offsets; std::vector no_use_text_scales; @@ -1693,6 +1702,172 @@ double Emboss::get_text_shape_scale(const FontProp &fp, const FontFile &ff) return scale * SHAPE_SCALE; } +namespace { +double measure_line_width_mm(FontFileWithCache &font_with_cache, const FontProp &font_prop, const std::wstring &line) +{ + if (!font_with_cache.has_value() || line.empty()) + return 0.; + const double scale = get_text_shape_scale(font_prop, *font_with_cache.font_file); + fontinfo_opt font_info_cache; + Point cursor(0, 0); + for (wchar_t letter : line) { + if (letter == '\n' || letter == '\r') + continue; + letter2shapes(letter, cursor, font_with_cache, font_prop, font_info_cache); + } + return cursor.x() * scale; +} + +double measure_text_height_mm(FontFileWithCache &font_with_cache, const FontProp &font_prop, const std::string &text) +{ + if (!font_with_cache.has_value()) + return 0.; + const unsigned lines = std::max(1u, get_count_lines(text)); + const double scale = get_text_shape_scale(font_prop, *font_with_cache.font_file); + return get_line_height(*font_with_cache.font_file, font_prop) * scale * lines; +} +} // namespace + +std::string Emboss::apply_text_wrap(FontFileWithCache &font_with_cache, const std::string &text, const FontProp &font_prop, double wrap_width_mm) +{ + if (!font_with_cache.has_value() || wrap_width_mm <= 1e-3 || text.empty()) + return text; + + const std::wstring src = boost::nowide::widen(text); + std::wstring out; + out.reserve(src.size() + 8); + + size_t para_start = 0; + while (para_start <= src.size()) { + size_t para_end = src.find('\n', para_start); + if (para_end == std::wstring::npos) + para_end = src.size(); + const std::wstring paragraph = src.substr(para_start, para_end - para_start); + + std::wstring line; + auto flush_line = [&]() { + if (line.empty()) + return; + if (!out.empty()) + out.push_back('\n'); + out += line; + line.clear(); + }; + + size_t i = 0; + while (i < paragraph.size()) { + size_t start = i; + if (paragraph[i] == ' ' || paragraph[i] == '\t') { + while (i < paragraph.size() && (paragraph[i] == ' ' || paragraph[i] == '\t')) + ++i; + } else { + while (i < paragraph.size() && paragraph[i] != ' ' && paragraph[i] != '\t') + ++i; + } + const std::wstring token = paragraph.substr(start, i - start); + const std::wstring trial = line + token; + if (line.empty() || measure_line_width_mm(font_with_cache, font_prop, trial) <= wrap_width_mm) { + line = trial; + continue; + } + flush_line(); + if (measure_line_width_mm(font_with_cache, font_prop, token) <= wrap_width_mm) { + line = token; + } else { + for (wchar_t ch : token) { + std::wstring next = line; + next.push_back(ch); + if (!line.empty() && measure_line_width_mm(font_with_cache, font_prop, next) > wrap_width_mm) { + flush_line(); + line.assign(1, ch); + } else { + line.swap(next); + } + } + } + } + flush_line(); + + // Preserve blank lines from consecutive Enter presses + if (para_end < src.size() && paragraph.empty()) { + if (!out.empty()) + out.push_back('\n'); + } + + if (para_end >= src.size()) + break; + para_start = para_end + 1; + } + + return boost::nowide::narrow(out); +} + +std::string Emboss::fit_text_to_box(FontFileWithCache &font_with_cache, + FontProp & font_prop, + const std::string &text, + double wrap_width_mm, + double wrap_height_mm, + bool do_wrap, + bool do_shrink) +{ + if (!font_with_cache.has_value() || text.empty()) + return text; + + const float original_size = font_prop.size_in_mm; + std::string working = text; + // Work on a private glyph cache so fitting does not wipe the caller's style-manager cache. + FontFileWithCache local_font = font_with_cache; + local_font.cache = std::make_shared(); + + auto rebuild = [&](float size) { + font_prop.size_in_mm = size; + local_font.cache = std::make_shared(); + working = do_wrap && wrap_width_mm > 1e-3 ? apply_text_wrap(local_font, text, font_prop, wrap_width_mm) : text; + }; + + rebuild(original_size); + + if (!do_shrink || wrap_width_mm <= 1e-3) + return working; + + auto fits = [&]() { + // Width: every line must fit (wrap already enforces when enabled; still check for shrink-only) + std::wstring ws = boost::nowide::widen(working); + size_t start = 0; + while (start <= ws.size()) { + size_t end = ws.find('\n', start); + if (end == std::wstring::npos) + end = ws.size(); + if (measure_line_width_mm(local_font, font_prop, ws.substr(start, end - start)) > wrap_width_mm + 1e-3) + return false; + if (end >= ws.size()) + break; + start = end + 1; + } + if (wrap_height_mm > 1e-3 && measure_text_height_mm(local_font, font_prop, working) > wrap_height_mm + 1e-3) + return false; + return true; + }; + + if (fits()) + return working; + + float lo = 1.f; + float hi = original_size; + for (int iter = 0; iter < 16; ++iter) { + float mid = 0.5f * (lo + hi); + rebuild(mid); + if (fits()) + lo = mid; + else + hi = mid; + } + rebuild(lo); + if (font_prop.size_in_mm < 1.f) + font_prop.size_in_mm = 1.f; + return working; +} + namespace { void add_quad(uint32_t i1, @@ -2193,8 +2368,8 @@ void align_shape(ExPolygonsWithIds &shapes, std::vector &offset_xy, const // Speed up for left aligned text if (prop.align.first == FontProp::HorizontalAlign::left){ - // already horizontaly aligned - offset_xy.emplace_back(Point(0, y_offset)); + // already horizontaly aligned — still emit one offset per character for callers + offset_xy.assign(shapes.size(), Point(0, y_offset)); for (ExPolygonsWithId& shape : shapes) for (ExPolygon &s : shape.expoly) s.translate(Point(0, y_offset)); @@ -2216,9 +2391,11 @@ void align_shape(ExPolygonsWithIds &shapes, std::vector &offset_xy, const Point offset( get_align_x_offset(prop.align.first, shape_bb, get_line_bb(0)), y_offset); + offset_xy.reserve(shapes.size()); for (size_t i = 0; i < shapes.size(); ++i) { wchar_t letter = text[i]; if (letter == '\n'){ + offset_xy.emplace_back(offset); // keep 1:1 with shapes/text for multiline offset.x() = get_align_x_offset(prop.align.first, shape_bb, get_line_bb(i + 1)); continue; } @@ -2272,9 +2449,11 @@ void align_shape(ExPolygonsWithIds & shapes, // Align x line by line Point main_offset(get_align_x_offset(prop.align.first, shape_bb, get_line_bb(0)), main_y_offset); + offset_xy.reserve(shapes.size()); for (size_t i = 0; i < shapes.size(); ++i) { wchar_t letter = text[i]; if (letter == '\n') {//Enter the next line of text + offset_xy.emplace_back(main_offset); // keep 1:1 with shapes/text for multiline main_offset.x() = get_align_x_offset(prop.align.first, shape_bb, get_line_bb(i + 1)); continue; } diff --git a/src/libslic3r/Emboss.hpp b/src/libslic3r/Emboss.hpp index 121ffaaab6..9c39e9c6b3 100644 --- a/src/libslic3r/Emboss.hpp +++ b/src/libslic3r/Emboss.hpp @@ -169,6 +169,25 @@ namespace Emboss /// Conversion to mm double get_text_shape_scale(const FontProp &fp, const FontFile &ff); + /// + /// Insert line breaks so each line fits wrap_width_mm. Preserves existing '\n'. + /// Word-wraps on spaces; breaks long words by character when needed. + /// + std::string apply_text_wrap(FontFileWithCache &font_with_cache, const std::string &text, const FontProp &font_prop, double wrap_width_mm); + + /// + /// Optionally wrap text to width and/or shrink FontProp::size_in_mm so the layout fits a box. + /// When wrap_height_mm <= 0, only width is enforced. + /// + /// Text used for shape generation (may include inserted '\n') + std::string fit_text_to_box(FontFileWithCache &font_with_cache, + FontProp & font_prop, + const std::string &text, + double wrap_width_mm, + double wrap_height_mm, + bool do_wrap, + bool do_shrink); + /// /// getter of font info by collection defined in prop /// diff --git a/src/libslic3r/Format/bbs_3mf.cpp b/src/libslic3r/Format/bbs_3mf.cpp index 7f8fe32f31..7027b8883d 100644 --- a/src/libslic3r/Format/bbs_3mf.cpp +++ b/src/libslic3r/Format/bbs_3mf.cpp @@ -249,6 +249,10 @@ static constexpr const char* THICKNESS_ATTR = "thickness"; static constexpr const char* EMBEDED_DEPTH_ATTR = "embeded_depth"; static constexpr const char* ROTATE_ANGLE_ATTR = "rotate_angle"; static constexpr const char* TEXT_GAP_ATTR = "text_gap"; +static constexpr const char* WRAP_TEXT_ATTR = "wrap_text"; +static constexpr const char* WRAP_WIDTH_ATTR = "wrap_width"; +static constexpr const char* WRAP_HEIGHT_ATTR = "wrap_height"; +static constexpr const char* AUTO_SHRINK_ATTR = "auto_shrink"; static constexpr const char* BOLD_ATTR = "bold"; static constexpr const char* ITALIC_ATTR = "italic"; static constexpr const char *SURFACE_TYPE = "surface_type"; @@ -4979,6 +4983,10 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) text_info.text_configuration.style.prop.boldness = bbs_get_attribute_value_float(attributes, num_attributes, BOLDNESS_ATTR); text_info.text_configuration.style.prop.skew = bbs_get_attribute_value_float(attributes, num_attributes, SKEW_ATTR); } + if (bbs_has_attribute_value_int(attributes, num_attributes, LINE_GAP_ATTR)) + text_info.text_configuration.style.prop.line_gap = bbs_get_attribute_value_int(attributes, num_attributes, LINE_GAP_ATTR); + if (bbs_has_attribute_value_int(attributes, num_attributes, CHAR_GAP_ATTR)) + text_info.text_configuration.style.prop.char_gap = bbs_get_attribute_value_int(attributes, num_attributes, CHAR_GAP_ATTR); text_info.m_curr_font_idx = bbs_get_attribute_value_int(attributes, num_attributes, FONT_INDEX_ATTR); text_info.m_font_size = bbs_get_attribute_value_float(attributes, num_attributes, FONT_SIZE_ATTR); @@ -4986,6 +4994,14 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) text_info.m_embeded_depth = bbs_get_attribute_value_float(attributes, num_attributes, EMBEDED_DEPTH_ATTR); text_info.m_rotate_angle = bbs_get_attribute_value_float(attributes, num_attributes, ROTATE_ANGLE_ATTR); text_info.m_text_gap = bbs_get_attribute_value_float(attributes, num_attributes, TEXT_GAP_ATTR); + if (bbs_has_attribute_value_int(attributes, num_attributes, WRAP_TEXT_ATTR)) + text_info.m_wrap_text = bbs_get_attribute_value_int(attributes, num_attributes, WRAP_TEXT_ATTR) != 0; + if (bbs_has_attribute_value_int(attributes, num_attributes, WRAP_WIDTH_ATTR)) + text_info.m_wrap_width_mm = bbs_get_attribute_value_float(attributes, num_attributes, WRAP_WIDTH_ATTR); + if (bbs_has_attribute_value_int(attributes, num_attributes, WRAP_HEIGHT_ATTR)) + text_info.m_wrap_height_mm = bbs_get_attribute_value_float(attributes, num_attributes, WRAP_HEIGHT_ATTR); + if (bbs_has_attribute_value_int(attributes, num_attributes, AUTO_SHRINK_ATTR)) + text_info.m_auto_shrink = bbs_get_attribute_value_int(attributes, num_attributes, AUTO_SHRINK_ATTR) != 0; text_info.m_bold = bbs_get_attribute_value_int(attributes, num_attributes, BOLD_ATTR); text_info.m_italic = bbs_get_attribute_value_int(attributes, num_attributes, ITALIC_ATTR); @@ -8015,12 +8031,17 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) void _add_text_info_to_archive(std::stringstream& stream, const TextInfo& text_info) { stream << " <" << TEXT_INFO_TAG << " "; - stream << TEXT_ATTR << "=\"" << xml_escape(text_info.m_text) << "\" "; + // Preserve newlines/tabs in text attributes (XML attribute whitespace normalisation). + stream << TEXT_ATTR << "=\"" << xml_escape_double_quotes_attribute_value(text_info.m_text) << "\" "; stream << FONT_NAME_ATTR << "=\"" << xml_escape(text_info.m_font_name) << "\" "; stream << FONT_VERSION_ATTR << "=\"" << text_info.m_font_version << "\" "; stream << STYLE_NAME_ATTR << "=\"" << xml_escape_double_quotes_attribute_value(text_info.text_configuration.style.name) << "\" "; stream << BOLDNESS_ATTR << "=\"" << text_info.text_configuration.style.prop.boldness.value_or(0) << "\" "; stream << SKEW_ATTR << "=\"" << text_info.text_configuration.style.prop.skew.value_or(0) << "\" "; + if (text_info.text_configuration.style.prop.line_gap.has_value()) + stream << LINE_GAP_ATTR << "=\"" << *text_info.text_configuration.style.prop.line_gap << "\" "; + if (text_info.text_configuration.style.prop.char_gap.has_value()) + stream << CHAR_GAP_ATTR << "=\"" << *text_info.text_configuration.style.prop.char_gap << "\" "; stream << FONT_INDEX_ATTR << "=\"" << text_info.m_curr_font_idx << "\" "; @@ -8029,6 +8050,10 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) stream << EMBEDED_DEPTH_ATTR << "=\"" << text_info.m_embeded_depth << "\" "; stream << ROTATE_ANGLE_ATTR << "=\"" << text_info.m_rotate_angle << "\" "; stream << TEXT_GAP_ATTR << "=\"" << text_info.m_text_gap << "\" "; + stream << WRAP_TEXT_ATTR << "=\"" << (text_info.m_wrap_text ? 1 : 0) << "\" "; + stream << WRAP_WIDTH_ATTR << "=\"" << text_info.m_wrap_width_mm << "\" "; + stream << WRAP_HEIGHT_ATTR << "=\"" << text_info.m_wrap_height_mm << "\" "; + stream << AUTO_SHRINK_ATTR << "=\"" << (text_info.m_auto_shrink ? 1 : 0) << "\" "; stream << BOLD_ATTR << "=\"" << (text_info.m_bold ? 1 : 0) << "\" "; stream << ITALIC_ATTR << "=\"" << (text_info.m_italic ? 1 : 0) << "\" "; diff --git a/src/libslic3r/Model.hpp b/src/libslic3r/Model.hpp index d7b3117d3f..81d2114e57 100644 --- a/src/libslic3r/Model.hpp +++ b/src/libslic3r/Model.hpp @@ -854,6 +854,10 @@ struct TextInfo float m_embeded_depth = 0.f; float m_rotate_angle = 0; float m_text_gap = 0.f; + bool m_wrap_text = false; + float m_wrap_width_mm = 40.f; + float m_wrap_height_mm = 0.f; // 0 = no height limit + bool m_auto_shrink = false; bool m_is_surface_text = false;//for old bool m_keep_horizontal = false;//for old enum TextType { @@ -867,8 +871,8 @@ struct TextInfo RaycastResult m_rr; template void serialize(Archive &ar) { - ar(m_font_name, m_font_version, m_font_size, m_curr_font_idx, m_bold, m_italic, m_thickness, m_embeded_depth, m_rotate_angle, m_text_gap, m_surface_type, m_text, - m_rr,text_configuration); + ar(m_font_name, m_font_version, m_font_size, m_curr_font_idx, m_bold, m_italic, m_thickness, m_embeded_depth, m_rotate_angle, m_text_gap, m_wrap_text, + m_wrap_width_mm, m_wrap_height_mm, m_auto_shrink, m_surface_type, m_text, m_rr, text_configuration); } TextConfiguration text_configuration; }; diff --git a/src/slic3r/GUI/Gizmos/GLGizmoText.cpp b/src/slic3r/GUI/Gizmos/GLGizmoText.cpp index 29c7cddb63..e7d8fee8ff 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoText.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoText.cpp @@ -1369,7 +1369,28 @@ std::unique_ptr GLGizmoText::create_emboss_data_base( base.from_surface = style.distance; FontFileWithCache &font = style_manager.get_font_file_with_cache(); - TextConfiguration tc{static_cast(style), text}; + + // Apply auto-wrap / shrink into the shape string; keep UI m_text as the editable source. + std::string shape_text = text; + if ((m_wrap_text || m_auto_shrink) && font.has_value()) { + FontProp fit_prop = style.prop; + fit_prop.size_in_mm = m_font_size; + shape_text = fit_text_to_box(font, fit_prop, text, m_wrap_width_mm, m_wrap_height_mm, m_wrap_text, m_auto_shrink); + style.prop.size_in_mm = fit_prop.size_in_mm; + m_font_size = fit_prop.size_in_mm; + m_style_manager.get_font_prop().size_in_mm = fit_prop.size_in_mm; + } + + // Multiline placement uses the Emboss 2D layout, so character spacing must live in FontProp. + // Single-line text still applies m_text_gap in GenerateTextJob::calc_position_points. + if (get_count_lines(shape_text) > 1) { + if (std::abs(m_text_gap) > 1e-4f) + style.prop.char_gap = static_cast(std::lround(m_text_gap)); + else + style.prop.char_gap.reset(); + } + + TextConfiguration tc{static_cast(style), shape_text}; auto td_ptr = std::make_unique(std::move(base), font, std::move(tc), style.projection); return td_ptr; } @@ -2357,6 +2378,38 @@ void GLGizmoText::on_render_input_window(float x, float y, float bottom_limit) + ImGui::AlignTextToFramePadding(); + bool wrap_changed = ImGui::Checkbox((_L("Auto wrap")).c_str(), &m_wrap_text); + ImGui::SameLine(); + bool shrink_changed = ImGui::Checkbox((_L("Auto shrink")).c_str(), &m_auto_shrink); + if (wrap_changed || shrink_changed) + m_need_update_text = true; + if (m_wrap_text || m_auto_shrink) { + ImGui::AlignTextToFramePadding(); + m_imgui->text(_L("Wrap width")); + ImGui::SameLine(caption_size); + ImGui::PushItemWidth(temp_input_width); + float old_wrap_w = m_wrap_width_mm; + if (ImGui::InputFloat("###text_wrap_width", &m_wrap_width_mm, 0.0f, 0.0f, "%.2f")) { + m_wrap_width_mm = ImClamp(m_wrap_width_mm, 1.f, 1000.f); + if (old_wrap_w != m_wrap_width_mm) + m_need_update_text = true; + } + ImGui::SameLine(); + m_imgui->text(_L("Wrap height")); + ImGui::SameLine(); + ImGui::PushItemWidth(temp_input_width); + float old_wrap_h = m_wrap_height_mm; + if (ImGui::InputFloat("###text_wrap_height", &m_wrap_height_mm, 0.0f, 0.0f, "%.2f")) { + m_wrap_height_mm = ImClamp(m_wrap_height_mm, 0.f, 1000.f); + if (old_wrap_h != m_wrap_height_mm) + m_need_update_text = true; + } + if (ImGui::IsItemHovered()) + m_imgui->tooltip(_u8L("Optional max height in mm. 0 means no height limit. With Auto shrink, font size is reduced to fit the box."), + m_gui_cfg->max_tooltip_width); + } + ImGui::AlignTextToFramePadding(); m_imgui->text(_L("Text Gap")); ImGui::SameLine(caption_size); @@ -2522,7 +2575,8 @@ bool GLGizmoText::set_height() void GLGizmoText::draw_text_input(int caption_width) { - bool allow_multi_line = false; + // Enter creates a new line; Emboss::text2vshapes + GenerateTextJob baked layout consume '\n'. + bool allow_multi_line = true; bool support_backup_fonts = GUI::wxGetApp().app_config->get_bool("support_backup_fonts"); auto create_range_text_prep = [&mng = m_style_manager, &text = m_text, &exist_unknown = m_text_contain_unknown_glyph, support_backup_fonts]() { if (text.empty()) { return std::string(); } @@ -2983,6 +3037,34 @@ void GLGizmoText::draw_advanced(float caption_size, float slider_width, float sl m_need_update_text = true; } } + + // Line gap (font points) — used by Emboss::get_line_height for multiline text. + std::optional &line_gap = m_style_manager.get_font_prop().line_gap; + float line_gap_value = static_cast(line_gap.value_or(0)); + const float min_line_gap = static_cast(limits.line_gap.min); + const float max_line_gap = static_cast(limits.line_gap.max); + ImGui::AlignTextToFramePadding(); + m_imgui->text(_L("Line gap")); + ImGui::SameLine(caption_size + ad_space_size); + ImGui::PushItemWidth(slider_width); + if (m_imgui->bbl_slider_float_style("##text_line_gap", &line_gap_value, min_line_gap, max_line_gap, "%.0f", 1.0f, true)) { + if (std::abs(line_gap_value) < 0.5f) + line_gap.reset(); + else + line_gap = static_cast(std::lround(line_gap_value)); + } + is_stop_sliding = m_imgui->get_last_slider_status().deactivated_after_edit; + if (is_stop_sliding) + m_need_update_text = true; + ImGui::SameLine(drag_left_width + ad_space_size); + ImGui::PushItemWidth(1.5 * slider_icon_width); + if (ImGui::BBLDragFloat("##text_line_gap_input", &line_gap_value, 1, min_line_gap, max_line_gap, "%.0f")) { + if (std::abs(line_gap_value) < 0.5f) + line_gap.reset(); + else + line_gap = static_cast(std::lround(line_gap_value)); + m_need_update_text = true; + } } void GLGizmoText::init_font_name_texture() @@ -3058,6 +3140,10 @@ void GLGizmoText::reset_text_info() m_embeded_depth = m_style_manager.get_style().projection.embeded_depth; m_rotate_angle = get_angle_from_current_style(); m_text_gap = m_style_manager.get_style().prop.char_gap.value_or(0); + m_wrap_text = false; + m_wrap_width_mm = 40.f; + m_wrap_height_mm = 0.f; + m_auto_shrink = false; m_surface_type = TextInfo::TextType::SURFACE; m_rr = RaycastResult(); m_last_text_mv = nullptr; @@ -3361,6 +3447,11 @@ bool GLGizmoText::generate_text_volume() input_info.m_thickness = m_thickness; input_info.m_cut_plane_dir_in_world = m_cut_plane_dir_in_world; input_info.use_surface = m_surface_type == TextInfo::TextType::SURFACE_CHAR ? true : false; + if (m_style_manager.is_active_font() && m_style_manager.get_font_file_with_cache().has_value()) { + input_info.font_file = m_style_manager.get_font_file_with_cache().font_file; + input_info.font_prop = m_style_manager.get_font_prop(); + input_info.font_prop.size_in_mm = m_font_size; + } TextInfo text_info = get_text_info(); ModelObject *model_object = selection.get_model()->objects[m_object_idx]; @@ -3407,8 +3498,14 @@ TextInfo GLGizmoText::get_text_info() text_info.m_rr.mesh_id = m_rr.mesh_id; text_info.m_rotate_angle = m_rotate_angle; text_info.m_text_gap = m_text_gap; + text_info.m_wrap_text = m_wrap_text; + text_info.m_wrap_width_mm = m_wrap_width_mm; + text_info.m_wrap_height_mm = m_wrap_height_mm; + text_info.m_auto_shrink = m_auto_shrink; text_info.m_surface_type = m_surface_type; text_info.text_configuration = m_ui_text_configuration; + text_info.text_configuration.style.prop.line_gap = m_style_manager.get_font_prop().line_gap; + text_info.text_configuration.style.prop.char_gap = m_style_manager.get_font_prop().char_gap; text_info.m_font_version = CUR_FONT_VERSION; return text_info; } @@ -3432,6 +3529,10 @@ void GLGizmoText::load_from_text_info(const TextInfo &text_info) m_rotate_angle = (float) Geometry::rad2deg(limit_angle); m_text_gap = text_info.m_text_gap; + m_wrap_text = text_info.m_wrap_text; + m_wrap_width_mm = text_info.m_wrap_width_mm; + m_wrap_height_mm = text_info.m_wrap_height_mm; + m_auto_shrink = text_info.m_auto_shrink; m_surface_type = (TextInfo::TextType) text_info.m_surface_type; if (is_old_text_info(text_info)) { // compatible with older versions @@ -3446,6 +3547,8 @@ void GLGizmoText::load_from_text_info(const TextInfo &text_info) } m_custom_boldness = text_info.text_configuration.style.prop.boldness.value_or(0.f); m_custom_skew = text_info.text_configuration.style.prop.skew.value_or(0.f); + m_style_manager.get_font_prop().line_gap = text_info.text_configuration.style.prop.line_gap; + m_style_manager.get_font_prop().char_gap = text_info.text_configuration.style.prop.char_gap; if (is_text_changed) { process(true,std::nullopt,false); } diff --git a/src/slic3r/GUI/Gizmos/GLGizmoText.hpp b/src/slic3r/GUI/Gizmos/GLGizmoText.hpp index acef915108..dea372b5a6 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoText.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoText.hpp @@ -27,8 +27,10 @@ enum class SLAGizmoEventType : unsigned char; //2.2 mean v2.2 bambu version(20250815) fix char gap of text // 2.3 mean v2.3 bambu version(20250826) add custom boldness and skew //2.5 mean v2.3 bambu version(20251130) fix text more accurate layout +//2.6 mean multiline Text Shape (Enter / line breaks + line gap persistence) +//2.7 mean auto-wrap / shrink-to-box + multiline surface curve following const std::string NEW_FONT_BEGIN_VERSION = "2.0"; -const std::string CUR_FONT_VERSION = "2.5"; +const std::string CUR_FONT_VERSION = "2.7"; class GLGizmoText : public GLGizmoBase { private: @@ -56,6 +58,10 @@ class GLGizmoText : public GLGizmoBase const float m_embeded_depth_max = 1000.f; float m_rotate_angle = 0; float m_text_gap = 0.f; + bool m_wrap_text = false; + float m_wrap_width_mm = 40.f; + float m_wrap_height_mm = 0.f; + bool m_auto_shrink = false; TextConfiguration m_ui_text_configuration; TextInfo::TextType m_surface_type{TextInfo::TextType ::SURFACE}; bool m_really_use_surface_calc = false; diff --git a/src/slic3r/GUI/Jobs/EmbossJob.cpp b/src/slic3r/GUI/Jobs/EmbossJob.cpp index 660e345d8c..43c7e5dbcd 100644 --- a/src/slic3r/GUI/Jobs/EmbossJob.cpp +++ b/src/slic3r/GUI/Jobs/EmbossJob.cpp @@ -26,6 +26,7 @@ #include "slic3r/GUI/format.hpp" //#include "slic3r/GUI/3DScene.hpp" #include "slic3r/GUI/Jobs/Worker.hpp" +#include "slic3r/GUI/TextLines.hpp" #include "slic3r/Utils/UndoRedo.hpp" #include // #define EXECUTE_UPDATE_ON_MAIN_THREAD // debug execution on main thread @@ -1282,11 +1283,10 @@ void create_all_char_mesh(DataBase &input, std::vector &result, st if (shape.shapes_with_ids.empty()) return; result.clear(); + result.reserve(shape.shapes_with_ids.size()); auto first_project_tr = calc_project_tran(input, input.shape.scale); - TextConfiguration text_configuration = input.get_text_configuration(); bool support_backup_fonts = std::any_of(shape.text_scales.begin(), shape.text_scales.end(), [](float x) { return x > 0; }); - const char *text = input.get_text_configuration().text.c_str(); wxString input_text = wxString::FromUTF8(input.get_text_configuration().text); wxRegEx re("^ +$"); bool is_all_space = re.Matches(input_text); @@ -1294,16 +1294,19 @@ void create_all_char_mesh(DataBase &input, std::vector &result, st if (input_text.size() != shape.shapes_with_ids.size()) { BOOST_LOG_TRIVIAL(info) <<__FUNCTION__<< "error: input_text.size() != shape.shapes_with_ids.size()"; } - for (int i = 0; i < shape.shapes_with_ids.size(); i++) { + // Keep a 1:1 mesh slot for every glyph/control character (including '\n'). + // Skipping empty shapes desyncs meshes from text_cursors and breaks multiline layout. + for (int i = 0; i < (int) shape.shapes_with_ids.size(); i++) { auto &temp_shape = shape.shapes_with_ids[i]; - if (input_text[i] == ' ') { + const bool is_space = i < (int) input_text.size() && input_text[i] == ' '; + const bool is_line_break = temp_shape.id == ENTER_UNICODE || + (i < (int) input_text.size() && (input_text[i] == '\n' || input_text[i] == '\r')); + if (is_space || is_line_break || temp_shape.expoly.empty()) { result.emplace_back(TriangleMesh()); continue; } - if (temp_shape.expoly.empty()) - continue; if (support_backup_fonts) { - if (i < shape.text_scales.size() && shape.text_scales[i] > 0) { + if (i < (int) shape.text_scales.size() && shape.text_scales[i] > 0) { auto temp_scale = shape.text_scales[i]; auto temp_project_tr = calc_project_tran(input, temp_scale); @@ -1467,19 +1470,28 @@ bool GenerateTextJob::update_text_positions(InputInfo &input_info) input_info.text_lengths = text_lengths; input_info.m_surface_type = GenerateTextJob::SurfaceType::None; + const unsigned count_lines = get_count_lines(input_info.m_text_shape.shapes_with_ids); + const bool is_multiline = count_lines > 1; + // Flat / horizontal text keeps the baked Emboss 2D multiline layout. + // Surface modes use per-line contour sampling instead of a flat stamp. + input_info.m_use_baked_layout = is_multiline && input_info.text_surface_type == TextInfo::TextType::HORIZONAL; + input_info.m_surface_multiline = is_multiline && input_info.text_surface_type != TextInfo::TextType::HORIZONAL; if (input_info.text_surface_type == TextInfo::TextType::HORIZONAL) { - input_info.use_surface = false; + input_info.use_surface = false; Vec3d mouse_normal_world = input_info.m_text_normal_in_world.cast(); - Vec3d world_pos_dir = input_info.m_cut_plane_dir_in_world.cross(mouse_normal_world); auto inv_ = (input_info.m_model_object_in_world_tran.get_matrix_no_offset() * input_info.m_text_tran_in_object.get_matrix_no_offset()).inverse(); - Vec3d pos_dir = Vec3d::UnitX(); auto mouse_normal_local = inv_ * mouse_normal_world; mouse_normal_local.normalize(); - calc_position_points(input_info.m_position_points, text_lengths, input_info.m_text_gap, pos_dir); - - for (int i = 0; i < text_num; ++i) { - input_info.m_normal_points[i] = mouse_normal_local; + if (input_info.m_use_baked_layout) { + for (int i = 0; i < text_num; ++i) { + input_info.m_position_points[i] = Vec3d::Zero(); + input_info.m_normal_points[i] = mouse_normal_local; + } + } else { + calc_position_points(input_info.m_position_points, text_lengths, input_info.m_text_gap, Vec3d::UnitX()); + for (int i = 0; i < text_num; ++i) + input_info.m_normal_points[i] = mouse_normal_local; } return true; } @@ -1492,6 +1504,113 @@ bool GenerateTextJob::generate_text_points(InputInfo &input_info) if (input_info.m_surface_type == GenerateTextJob::SurfaceType::None) { return true; } + // Multiline surface: place each line on its own parallel slice contour. + if (input_info.m_surface_multiline) { + if (!input_info.font_file || !input_info.mo) + return false; + ModelVolumePtrs volumes_to_slice; + for (int i = 0; i < (int) input_info.mo->volumes.size(); ++i) { + if (i == input_info.m_volume_idx) + continue; + ModelVolume *mv = input_info.mo->volumes[i]; + if (mv->is_text() || !mv->is_model_part()) + continue; + volumes_to_slice.push_back(mv); + } + if (volumes_to_slice.empty()) + return false; + + const unsigned count_lines = get_count_lines(input_info.m_text_shape.shapes_with_ids); + TextLines text_lines = create_text_lines(input_info.m_text_tran_in_object.get_matrix(), volumes_to_slice, *input_info.font_file, + input_info.font_prop, count_lines); + if (text_lines.size() != count_lines) + return false; + + std::vector bbs = create_line_bounds(input_info.m_text_shape.shapes_with_ids, count_lines); + const double em_2_mm = std::max(5., (double) input_info.font_prop.size_in_mm); + const int32_t em_2_polygon = static_cast(std::round(scale_(em_2_mm))); + const auto rotate_tran = Geometry::assemble_transform(Vec3d::Zero(), {-0.5 * M_PI, 0.0, 0.0}); + + input_info.m_position_points.assign(input_info.m_chars_mesh_result.size(), Vec3d::Zero()); + input_info.m_normal_points.assign(input_info.m_chars_mesh_result.size(), Vec3d::UnitZ()); + + size_t s_i_offset = 0; + for (size_t line_i = 0; line_i < text_lines.size(); ++line_i) { + const BoundingBoxes &line_bbs = bbs[line_i]; + const TextLine & line = text_lines[line_i]; + if (line.polygon.empty()) { + s_i_offset += line_bbs.size(); + continue; + } + PolygonPoints samples = sample_slice(line, line_bbs, input_info.m_text_shape.scale); + std::vector angles = calculate_angles(em_2_polygon, samples, line.polygon); + for (size_t i = 0; i < line_bbs.size(); ++i) { + const size_t global_i = s_i_offset + i; + if (global_i >= input_info.m_position_points.size()) + break; + if (!line_bbs[i].defined) { + if (i < samples.size()) { + Vec2d p2 = unscale(samples[i].point); + input_info.m_position_points[global_i] = rotate_tran * Vec3d(p2.x(), p2.y(), 0.); + } + continue; + } + const PolygonPoint &sample = samples[i]; + Vec2d p2 = unscale(sample.point); + input_info.m_position_points[global_i] = rotate_tran * Vec3d(p2.x(), p2.y(), 0.); + // Approximate surface normal from contour tangent in the text plane. + const double angle = (i < angles.size()) ? angles[i] : 0.; + Vec3d tangent(std::cos(angle), std::sin(angle), 0.); + Vec3d n = rotate_tran.linear() * Vec3d(-tangent.y(), tangent.x(), 0.); + if (n.norm() > 1e-6) + input_info.m_normal_points[global_i] = n.normalized(); + } + s_i_offset += line_bbs.size(); + } + + // Refine normals from the host mesh (same approach as single-line surface text). + TriangleMesh slice_meshs; + for (ModelVolume *mv : volumes_to_slice) { + TriangleMesh vol_mesh(mv->mesh()); + vol_mesh.transform(mv->get_matrix()); + slice_meshs.merge(vol_mesh); + } + slice_meshs.transform(input_info.m_text_tran_in_object.get_matrix().inverse()); + const bool is_mirrored = (input_info.m_model_object_in_world_tran * input_info.m_text_tran_in_object).is_left_handed(); + auto point_in_triangle_delete_area = [](const Vec3d &point, const Vec3d &point0, const Vec3d &point1, const Vec3d &point2) { + Vec3d p0_p1 = point1 - point0; + Vec3d p0_p2 = point2 - point0; + Vec3d p_p0 = point0 - point; + Vec3d p_p1 = point1 - point; + Vec3d p_p2 = point2 - point; + double s = p0_p1.cross(p0_p2).norm(); + double s0 = p_p0.cross(p_p1).norm(); + double s1 = p_p1.cross(p_p2).norm(); + double s2 = p_p2.cross(p_p0).norm(); + return std::abs(s0 + s1 + s2 - s); + }; + for (size_t i = 0; i < input_info.m_position_points.size(); ++i) { + double best = 1e9; + for (auto indice : slice_meshs.its.indices) { + Vec3d point0 = slice_meshs.its.vertices[indice[0]].cast(); + Vec3d point1 = slice_meshs.its.vertices[indice[1]].cast(); + Vec3d point2 = slice_meshs.its.vertices[indice[2]].cast(); + double abs_area = point_in_triangle_delete_area(input_info.m_position_points[i], point0, point1, point2); + if (best > abs_area) { + best = abs_area; + Vec3d n = (point1 - point0).cross(point2 - point0); + if (n.norm() > 1e-9) { + n.normalize(); + if (is_mirrored) + n = -n; + input_info.m_normal_points[i] = n; + } + } + } + } + input_info.slice_mesh = slice_meshs; + return true; + } auto &m_text_tran_in_object = input_info.m_text_tran_in_object; auto mo = input_info.mo; auto &m_volume_idx = input_info.m_volume_idx; @@ -1897,9 +2016,11 @@ Vec2f GenerateTextJob::calc_mesh_offset(const std::pair &align_type, int i) { Vec2f mesh_offset(Vec2f::Zero()); - if (i < text_absolute_cursors.size()) { + if (i < (int) text_absolute_cursors.size() && i < (int) text_cursors.size()) { if (align_type.first == (int) Slic3r::FontProp::HorizontalAlign::center) { - mesh_offset[0] = -text_absolute_cursors[i] - text_align_offsets[0][0] + text_cursors[i] / 2.f; + const float align_x = (i < (int) text_align_offsets.size()) ? text_align_offsets[i][0] : + (!text_align_offsets.empty() ? text_align_offsets[0][0] : 0.f); + mesh_offset[0] = -text_absolute_cursors[i] - align_x + text_cursors[i] / 2.f; } // else todo } return mesh_offset; @@ -1922,17 +2043,22 @@ void GenerateTextJob::generate_mesh_according_points(InputInfo &input_info) auto inv_text_cs_in_object_no_offset = (m_model_object_in_world_tran.get_matrix_no_offset() * text_tran_in_object.get_matrix_no_offset()).inverse(); ExPolygons ex_polygons; - std::vector bbs; - int line_idx = 0; + BoundingBoxes all_glyph_bbs; SurfaceVolumeData::ModelSources ms_es; DataBase input_db("", std::make_shared>(false)); if (input_info.use_surface) { EmbossShape &es = input_info.m_text_shape; if (es.shapes_with_ids.empty()) throw JobException(_u8L("Font doesn't have any shape for given text.").c_str()); - size_t count_lines = 1; // input1.text_lines.size(); - bbs = create_line_bounds(es.shapes_with_ids, count_lines); - if (bbs.empty()) { + // Flat bounds list indexed by global glyph index (supports multiline). + all_glyph_bbs.reserve(es.shapes_with_ids.size()); + for (const ExPolygonsWithId &shape_id : es.shapes_with_ids) { + BoundingBox bb; + if (!shape_id.expoly.empty()) + bb = get_extents(shape_id.expoly); + all_glyph_bbs.push_back(bb); + } + if (all_glyph_bbs.empty()) { return; } SurfaceVolumeData::ModelSource ms; @@ -1946,14 +2072,30 @@ void GenerateTextJob::generate_mesh_according_points(InputInfo &input_info) input_db.shape.scale = input_info.shape_scale; } auto cut_plane_dir = inv_text_cs_in_object_no_offset * m_cut_plane_dir_in_world; - for (int i = 0; i < m_position_points.size(); ++i) { + for (int i = 0; i < (int) m_position_points.size(); ++i) { auto position = m_position_points[i]; auto normal = m_normal_points[i]; TriangleMesh sub_mesh; auto local_tran = get_sub_mesh_tran(position, normal, cut_plane_dir, m_embeded_depth); - Vec2f mesh_offset = calc_mesh_offset(input_info.m_align_type, input_info.m_text_cursors, input_info.m_text_absolute_cursors, input_info.m_text_align_offsets, i); + Vec2f mesh_offset = Vec2f::Zero(); + if (input_info.m_use_baked_layout) { + mesh_offset = Vec2f::Zero(); + } else if (input_info.m_surface_multiline) { + // Glyph meshes still carry Emboss layout offsets; center each glyph on its curve sample. + if (i < (int) m_chars_mesh_result.size() && !m_chars_mesh_result[i].its.empty()) { + const auto bb = m_chars_mesh_result[i].bounding_box(); + mesh_offset = Vec2f(static_cast(-bb.center().x()), static_cast(-bb.center().y())); + } + } else { + mesh_offset = calc_mesh_offset(input_info.m_align_type, input_info.m_text_cursors, input_info.m_text_absolute_cursors, + input_info.m_text_align_offsets, i); + } if (input_info.use_surface) { - get_text_mesh(sub_mesh, input_info.m_text_shape, bbs[line_idx], ms_es, input_db, i, mesh_offset, text_tran_in_object, local_tran, input_info.slice_mesh); + if (input_info.m_surface_multiline && i < (int) all_glyph_bbs.size() && all_glyph_bbs[i].defined) { + const BoundingBox &glyph_bb = all_glyph_bbs[i]; + mesh_offset = Vec2f(-glyph_bb.center().x() * input_info.shape_scale, 0.f); + } + get_text_mesh(sub_mesh, input_info.m_text_shape, all_glyph_bbs, ms_es, input_db, i, mesh_offset, text_tran_in_object, local_tran, input_info.slice_mesh); } else { get_text_mesh(sub_mesh, m_chars_mesh_result, i, mesh_offset, local_tran); @@ -1974,10 +2116,15 @@ void CreateObjectTextJob::process(Ctl &ctl) { m_input.m_text_absolute_cursors = m_input.m_text_shape.text_absolute_cursors; m_input.m_text_align_offsets = m_input.m_text_shape.text_align_offsets; m_input.m_align_type = m_input.m_text_shape.align_type; + m_input.m_use_baked_layout = get_count_lines(m_input.m_text_shape.shapes_with_ids) > 1; if (m_input.m_chars_mesh_result.empty()) { return; } + if (m_input.m_use_baked_layout) { + m_input.m_position_points.assign(m_input.m_chars_mesh_result.size(), Vec3d::Zero()); + return; + } std::vector text_lengths; calc_text_lengths(text_lengths, m_input.m_text_cursors); calc_position_points(m_input.m_position_points, text_lengths, m_input.text_info.m_text_gap, Vec3d(1, 0, 0)); @@ -1989,11 +2136,14 @@ void CreateObjectTextJob::finalize(bool canceled, std::exception_ptr &eptr) { return create_message("Can't create empty object."); TriangleMesh final_mesh; - for (int i = 0; i < m_input.m_position_points.size();i++) { + for (int i = 0; i < (int) m_input.m_position_points.size();i++) { TriangleMesh sub_mesh; auto position = m_input.m_position_points[i]; auto local_tran = GenerateTextJob::get_sub_mesh_tran(position, Vec3d::UnitZ(), Vec3d(0, 1, 0), m_input.text_info.m_embeded_depth); - Vec2f mesh_offset = GenerateTextJob::calc_mesh_offset(m_input.m_align_type, m_input.m_text_cursors, m_input.m_text_absolute_cursors, m_input.m_text_align_offsets, i); + Vec2f mesh_offset = m_input.m_use_baked_layout ? + Vec2f::Zero() : + GenerateTextJob::calc_mesh_offset(m_input.m_align_type, m_input.m_text_cursors, m_input.m_text_absolute_cursors, + m_input.m_text_align_offsets, i); GenerateTextJob::get_text_mesh(sub_mesh, m_input.m_chars_mesh_result, i, mesh_offset, local_tran); final_mesh.merge(sub_mesh); } diff --git a/src/slic3r/GUI/Jobs/EmbossJob.hpp b/src/slic3r/GUI/Jobs/EmbossJob.hpp index efc5c51e4a..96815b3f2c 100644 --- a/src/slic3r/GUI/Jobs/EmbossJob.hpp +++ b/src/slic3r/GUI/Jobs/EmbossJob.hpp @@ -273,6 +273,8 @@ struct CreateTextInput DataBasePtr base; std::vector m_position_points; + // When true, glyph meshes already contain the final 2D multiline layout from Emboss::text2vshapes. + bool m_use_baked_layout = false; }; class CreateObjectTextJob : public JobNew @@ -384,6 +386,13 @@ class GenerateTextJob : public JobNew bool first_generate = false; Emboss::DataUpdate m_data_update; + // When true, glyph meshes already contain the final 2D multiline layout from Emboss::text2vshapes. + // Placement keeps every character at the text origin and skips single-line re-spacing. + bool m_use_baked_layout = false; + // Multiline surface: place each line on its own slice contour (create_text_lines + sample_slice). + bool m_surface_multiline = false; + std::shared_ptr font_file; + FontProp font_prop; }; static bool update_text_positions(InputInfo &input_info);