From 3e1daccd7c567a0a3d2b5721841d30845f21307e Mon Sep 17 00:00:00 2001 From: Valerii Bokhan <80919135+valerii-bokhan@users.noreply.github.com> Date: Wed, 16 Sep 2026 01:01:39 +0200 Subject: [PATCH] Feature: Add inward wipe for external perimeters (#15407) --- docs/HLSD/wipe-inward.md | 170 ++++ src/libslic3r/CMakeLists.txt | 2 + src/libslic3r/GCode.cpp | 198 +++-- src/libslic3r/GCode.hpp | 13 +- src/libslic3r/GCode/WipePathHelpers.cpp | 920 ++++++++++++++++++++ src/libslic3r/GCode/WipePathHelpers.hpp | 96 +++ src/libslic3r/Preset.cpp | 2 + src/libslic3r/Print.cpp | 2 + src/libslic3r/PrintConfig.cpp | 29 + src/libslic3r/PrintConfig.hpp | 2 + src/libslic3r/PrintObject.cpp | 2 + src/slic3r/GUI/ConfigManipulation.cpp | 3 + src/slic3r/GUI/Plater.cpp | 2 + src/slic3r/GUI/Tab.cpp | 2 + src/slic3r/Utils/CalibUtils.cpp | 2 + tests/fff_print/CMakeLists.txt | 1 + tests/fff_print/test_wipe.cpp | 653 +++++++++++++++ tests/libslic3r/CMakeLists.txt | 1 + tests/libslic3r/test_wipe_path.cpp | 1024 +++++++++++++++++++++++ 19 files changed, 3041 insertions(+), 83 deletions(-) create mode 100644 docs/HLSD/wipe-inward.md create mode 100644 src/libslic3r/GCode/WipePathHelpers.cpp create mode 100644 src/libslic3r/GCode/WipePathHelpers.hpp create mode 100644 tests/fff_print/test_wipe.cpp create mode 100644 tests/libslic3r/test_wipe_path.cpp diff --git a/docs/HLSD/wipe-inward.md b/docs/HLSD/wipe-inward.md new file mode 100644 index 0000000000..9f1c617cc3 --- /dev/null +++ b/docs/HLSD/wipe-inward.md @@ -0,0 +1,170 @@ +# Wipe inward — High Level Design + +## Purpose and scope + +Wipe inward reduces reheating of fresh plastic and visible seam artifacts by +moving the hot nozzle toward adjacent printed material during the external-wall +wipe. Wipe marks are especially visible at layer heights below 0.1 mm. +The option applies only to wipes after external walls, including walls around +holes. It does not offset wipes after inner walls, infill or supports. For an +outer contour the move is inward; for a hole it is away from the hole, toward +the surrounding material. The path must remain supported by material that is +already present when the wipe executes. + +The operation belongs to G-code generation. It uses extrusion paths, their actual +widths and their print order. Changing its settings invalidates G-code export +while preserving the sliced geometry. + +## Settings and eligibility + +`wipe_inward` defaults to disabled and requires Wipe while retracting to be +enabled for the active filament. `wipe_inward_distance` defaults to 50% of the +actual external-wall extrusion width; it also accepts an absolute distance in +millimeters. Using the path width makes Auto width and Arachne's variable widths +meaningful. The effective offset is limited by that width and the spacing to the +adjacent wall. A zero distance disables the offset. + +Only external perimeters with a suitable, previously printed inner perimeter +are eligible. A configured wall count alone cannot establish eligibility: +the local geometry may contain fewer walls, and walls scheduled later do not +provide support. Outer/Inner wall order therefore normally retains the regular +wipe path. + +Retraction and pressure advance calibrations disable inward wiping so it cannot +mask the behavior being measured. The calibration settings turn it off, and +G-code generation enforces this even if a profile or object override enables it. + +## Path selection and support + +The planner identifies an adjacent inner perimeter on the material side of the +outgoing wall. Contour winding and the distinction between outer contours and +holes establish a preferred direction; local printed geometry resolves ambiguous +or self-touching contours. + +Candidate paths offset or translate the portion needed for the configured wipe +distance. A wide seam gap can prevent a supported forward path; following the +incoming printed wall backwards is also a candidate. If translating that wall +cannot provide a complete wipe around a curve, the planner tries an offset of +the reversed wall. Direction checks allow coordinate-rounding error at a +perpendicular entry, while rejecting actual backtracking. The planner checks the +complete executable path, including its connector from the nozzle position, +against the current and earlier printed perimeters. Nearby endpoints alone do +not establish support across a gap. + +Each region accumulates its printed perimeter prefix once, in extrusion order. +Every entity contributes its geometry only after it is printed, and the prefix +is discarded when the region ends. This collection is skipped when inward wiping +is disabled or its configured distance is zero. A mixed inner-wall loop remains +an eligible target even when its first path is an overhang: ordinary inner-wall +paths elsewhere in the loop identify it. Likewise, an external loop with an +overhanging start remains eligible when other segments identify the external +wall. It is available for support checks but is not an inner-wall target. +Candidate-specific support filtering and AABB trees are built only for eligible +external loops, then reused across their candidate paths. + +Material-side validation applies with or without a seam gap. Along each +candidate, local wall normals point toward the adjacent printed inner wall; +samples on the opposite side are rejected even when they remain close enough +to the external wall to pass the support check. This uses the open wall geometry +without treating it as a closed polygon. Full paths at a zero-gap seam also +retain clearance from the external wall after their initial connector. At a +clipped corner, another branch can be closer than the requested offset, so +material-side and support checks apply without that additional clearance rule. + +An accepted candidate replaces the stored wipe path as a whole. A short direct +inward move is also eligible when longer candidates fail validation. It may +waive full wall clearance, but must pass the material-side check. Its initial +direction is checked from the actual nozzle position after any loop pre-move; +the original wall endpoint is retained separately for intersection checks. It takes +priority over the alternate offset when the preferred and translated paths +are unusable. A longer reversed path may replace the selected candidate only +when its distance to the target inner wall is no worse within tolerance. + +## Fallback to the regular wipe + +The original wipe path is retained when: + +- No suitable adjacent inner wall has already been printed near the seam. This + includes single-wall areas, locally missing inner walls and normally Outer/Inner + wall order. A distant wall or a wall on the air side does not qualify. +- The requested or available offset, or the configured wipe distance, is zero + or too small at the geometry's coordinate precision. +- Degenerate geometry prevents construction of a usable candidate, or all + candidates fail the checks for printed support, direction, wall clearance or + the connector from the actual nozzle position. This can occur at tight corners, + narrow features or seam gaps. + +Corners and seam gaps do not automatically trigger fallback: an offset, +translated, reversed or short direct inward path may still be valid. The regular +wipe is retained only when no candidate is accepted. + +Fallback uses the path and retraction rules for `wipe_inward` disabled. +Wipe while retracting must still be enabled for a wipe to occur; `wipe_on_loops` +remains controlled by its own setting. + +## Interaction with Wipe on loop + +`wipe_on_loops` is an independent option that makes a short move before leaving +an external loop. It can operate with `wipe_inward` disabled. When both options +are enabled, its destination is the starting position for the inward wipe. + +The loop move samples the outgoing and incoming paths by distance across path +boundaries. The sampling distance is bounded by the nozzle diameter and one +quarter of the total path length. It samples the outgoing path at up to 20% of +the nozzle diameter and rotates that point around the seam through one third +of the material-side corner angle. For a closed square outer contour, this +produces a move of 20% of the nozzle diameter at 30 degrees into the corner. +Coincident samples or degenerate angles suppress the move. + +The nozzle position stored by G-code generation must match the emitted loop +move. Both travel planning and wipe execution depend on this position, including +when Wipe inward is disabled. + +With a seam gap, a loop move may advance past the inward offset's original entry. +If that alone makes the connector backtrack, the entry advances to the nozzle's +projection on the offset. The planner extends the source as needed to preserve +the configured wipe length and validates the new connector and complete path. +Joins that already backtrack across the seam gap are not adjusted this way. + +## Execution and retraction + +The stored wipe path uses a sentinel first point. Execution starts from the +actual nozzle position and proceeds to the second stored point. Path selection, +support validation and wipe-length calculation must all use this same executable +geometry, especially after a Wipe on loop move. + +An accepted inward path executes at the end of the external loop, after any +Wipe on loop move, without retracting filament. It consumes the stored path and +updates the nozzle position before travel planning. A short travel to the next +wall cannot discard this wipe or force a retraction or Z-hop. Subsequent travel +uses the normal minimum-travel threshold and retraction/lift settings from the +new position. The regular wipe, including fallback, remains deferred until a +normal retraction uses it. + +Retraction is divided into portions before, during and after wiping. The amount +that can be retracted during the wipe depends on its executable length, wipe +speed and the active filament's retraction speed. Fractional retraction speeds +are retained in this calculation. For a 2 mm wipe at 100 mm/s and a retraction +speed of 25.5 mm/s, the wipe can retract 0.51 mm. With a total retraction of 0.8 mm +and both before/after percentages set to zero, the remaining 0.29 mm is retracted +before wiping. This split applies to regular deferred wipes, including fallback; +an accepted inward wipe executes separately without retraction. + +## Implementation and verification + +- [GCode.cpp](../../src/libslic3r/GCode.cpp) integrates path selection, nozzle + position and retraction; [Print.cpp](../../src/libslic3r/Print.cpp) controls + invalidation, and [PrintConfig.cpp](../../src/libslic3r/PrintConfig.cpp) defines + the settings. +- [WipePathHelpers](../../src/libslic3r/GCode/WipePathHelpers.hpp) implements path + sampling, offset selection and support checks. +- [Geometry tests](../../tests/libslic3r/test_wipe_path.cpp) cover support, + degenerate paths, contour and hole orientations, and exact loop-move geometry + across path subdivisions. +- [FFF tests](../../tests/fff_print/test_wipe.cpp) cover emitted trajectories, + fallback, minimum-travel retraction and Z-hop rules, and export invalidation. + With Wipe inward disabled, they check the loop move's direction and magnitude + for Classic and Arachne, the subsequent wipe's start and length, and fractional + retraction splitting in absolute and relative E modes. + Loop-move checks use reserved role/wipe markers and extrusion state, and run + with human-readable G-code comments both enabled and disabled. diff --git a/src/libslic3r/CMakeLists.txt b/src/libslic3r/CMakeLists.txt index ffc6b5cee6..202c317e7f 100644 --- a/src/libslic3r/CMakeLists.txt +++ b/src/libslic3r/CMakeLists.txt @@ -260,6 +260,8 @@ set(lisbslic3r_sources GCode/SmallAreaInfillFlowCompensator.hpp GCode/SpiralVase.cpp GCode/SpiralVase.hpp + GCode/WipePathHelpers.cpp + GCode/WipePathHelpers.hpp GCode/ThumbnailData.cpp GCode/ThumbnailData.hpp GCode/Thumbnails.cpp diff --git a/src/libslic3r/GCode.cpp b/src/libslic3r/GCode.cpp index 12a3a73e7c..501b5d0264 100644 --- a/src/libslic3r/GCode.cpp +++ b/src/libslic3r/GCode.cpp @@ -1,5 +1,6 @@ #include "BoundingBox.hpp" #include "Config.hpp" +#include "GCode/WipePathHelpers.hpp" #include "GCodeWriter.hpp" #include "Polygon.hpp" #include "PrintConfig.hpp" @@ -438,7 +439,6 @@ static std::vector get_path_of_change_filament(const Print& print) auto& writer = gcodegen.writer(); auto& config = gcodegen.config(); auto extruder = writer.filament(); - auto extruder_id = extruder->extruder_id(); auto last_pos = gcodegen.last_pos(); // Declare & initialize retraction lengths @@ -475,13 +475,13 @@ static std::vector get_path_of_change_filament(const Print& print) wipe_speed = std::max(wipe_speed, 10.0); // Process wipe path & calculate wipe path length - double wipe_dist = scale_(config.wipe_distance.get_at(extruder_id)); + double wipe_dist = scale_(config.wipe_distance.get_at(extruder->config_index())); Polyline wipe_path = {last_pos}; wipe_path.append(this->path.points.begin() + 1, this->path.points.end()); double wipe_path_length = std::min(wipe_path.length(), wipe_dist); // Calculate the maximum retraction amount during wipe - retraction_length_during_wipe = config.retraction_speed.get_at(extruder_id) * + retraction_length_during_wipe = config.retraction_speed.get_at(extruder->config_index()) * unscale_(wipe_path_length) / wipe_speed; // If the maximum retraction amount during wipe is too small, @@ -564,6 +564,16 @@ static std::vector get_path_of_change_filament(const Print& print) return default_value; } + // Orca: rebuild the stored wipe path while preserving Polyline's boundary deduplication. + void Wipe::update_path(const ExtrusionPaths &paths, bool reverse) + { + reset_path(); + for (const ExtrusionPath& extrusion_path : paths) + path.append(extrusion_path.polyline.to_polyline()); + if (reverse) + path.reverse(); + } + std::string Wipe::wipe(GCode& gcodegen,double length, bool toolchange, bool is_last) { std::string gcode; @@ -616,14 +626,11 @@ static std::vector get_path_of_change_filament(const Print& print) if (gcodegen.enable_cooling_markers() && !is_last) cooling_mark = /*gcodegen.config().role_based_wipe_speed ? ";_EXTERNAL_PERIMETER" : */";_WIPE"; + // Orca: set speed once because wipe_speed is constant for all segments. gcode += gcodegen.writer().set_speed(_wipe_speed * 60, "", cooling_mark); for (const Line& line : wipe_path.lines()) { double segment_length = line.length(); double dE = length * (segment_length / wipe_dist); - //BBS: fix this FIXME - //FIXME one shall not generate the unnecessary G1 Fxxx commands, here wipe_speed is a constant inside this cycle. - // Is it here for the cooling markers? Or should it be outside of the cycle? - //gcode += gcodegen.writer().set_speed(wipe_speed * 60, "", gcodegen.enable_cooling_markers() ? ";_WIPE" : ""); gcode += gcodegen.writer().extrude_to_xy( gcodegen.point_to_gcode(line.b), -dE, @@ -2901,6 +2908,19 @@ void GCode::_do_export(Print& print, GCodeOutputStream &file, ThumbnailsGenerato const bool skip_config_block = print.config().gcode_skip_config_block; const WipeTowerType wipe_tower_type = print.wipe_tower_type(); m_calib_config.clear(); + // Orca: Calibration overrides are reapplied after object/region settings in _extrude(). + // Keep inward wiping from masking retraction and pressure advance artifacts. + switch (print.calib_mode()) { + case CalibMode::Calib_PA_Line: + case CalibMode::Calib_PA_Pattern: + case CalibMode::Calib_PA_Tower: + case CalibMode::Calib_Auto_PA_Line: + case CalibMode::Calib_Retraction_tower: + m_calib_config.set_key_value("wipe_inward", new ConfigOptionBool(false)); + break; + default: + break; + } // resets analyzer's tracking data m_last_height = 0.f; m_last_layer_z = 0.f; @@ -7204,7 +7224,8 @@ std::string GCode::extrude_loop(const ExtrusionLoop& loop_ref, const std::string& description, double speed, const ExtrusionEntitiesPtr& region_perimeters, - const Point* start_point) + const Point* start_point, + const WipeInwardSupport* wipe_support) { // get a copy; don't modify the orientation of the original loop object otherwise // next copies (if any) would not detect the correct orientation @@ -7434,63 +7455,80 @@ std::string GCode::extrude_loop(const ExtrusionLoop& loop_ref, m_processor.result().print_statistics.total_seam_scarf_distance += static_cast(seam_scarf_distance_mm); } - // BBS + // Orca: share the post-extrusion nozzle position between wipe_inward and wipe_on_loops. + const bool is_ccw = loop.is_counter_clockwise(); + + std::optional wipe_on_loops_dest; + if (m_config.wipe_on_loops.value && paths.back().role() == erExternalPerimeter && + m_layer != nullptr && m_config.wall_loops.value > 1 && paths.front().size() >= 2 && + paths.back().polyline.points.size() >= 2) + wipe_on_loops_dest = wipe_on_loops_destination(paths, scale_(nozzle_diameter), is_ccw, is_hole); + + bool wipe_inward_applied = false; + // Orca: store loop paths in print order because inward offsets use this orientation. if (m_wipe.enable && FILAMENT_CONFIG(wipe)) { - m_wipe.path = Polyline(); - for (ExtrusionPath &path : paths) { - //BBS: Don't need to save duplicated point into wipe path - if (!m_wipe.path.empty() && !path.empty() && - m_wipe.path.last_point() == Point(path.first_point().x(), path.first_point().y())) { - // Convert Points3 to Points - for (auto it = path.polyline.points.begin() + 1; it != path.polyline.points.end(); ++it) - m_wipe.path.append(Point(it->x(), it->y())); - } else - m_wipe.path.append(path.polyline.to_polyline()); // TODO: don't limit wipe to last path + m_wipe.update_path(paths); + + // Orca: loop wipe paths retain print direction. Their material side is + // therefore left for CCW contours and right for CW contours, with the + // result inverted for holes. Only external perimeters are eligible. + // Calibration overrides are applied during extrusion, after the region + // context was created. Check the effective setting again at execution. + if (m_config.wipe_inward && m_config.wipe_inward_distance.value > 0. && + wipe_support != nullptr && !wipe_support->inner_lines.empty() && + // A loop's role is its first path's role. An overhanging start must + // not hide ordinary external-wall segments elsewhere in the loop. + std::any_of(paths.begin(), paths.end(), + [](const ExtrusionPath &path) { return is_external_perimeter(path.role()); }) && + m_wipe.path.points.size() >= 2) { + // Orca: use the actual extrusion width from the path, not the config + // value — outer_wall_line_width=0 (Auto) would make get_abs_value + // return 0 and silently disable the feature, and Arachne may produce + // a different width than the config default. + const double outer_wall_line_width = paths.front().width; + const double requested_offset = m_config.wipe_inward_distance.get_abs_value(outer_wall_line_width); + const double offset_dist = scale_(std::min(requested_offset, outer_wall_line_width)); + if (offset_dist > SCALED_EPSILON) { + const Point seam_start = paths.front().first_point(); + const Point seam_end = paths.back().last_point(); + const Point wipe_start = wipe_on_loops_dest.value_or(seam_end); + const double max_wipe_length = scale_(FILAMENT_CONFIG(wipe_distance)); + // Orca: Wipe::wipe() replaces points[0] with last_pos and executes + // from points[1]. The helper preserves that sentinel and atomically + // replaces the remaining points, or leaves the path untouched. + // Orca: a configured wall count does not guarantee that Arachne + // generated an adjacent wall for this particular loop. Only + // earlier entities are considered because later walls have + // not been printed yet (for example with Outer/Inner order). + // Inner walls determine the material side; every earlier wall + // remains available to validate the executable wipe path. + const double support_distance = scale_(std::max(nozzle_diameter, outer_wall_line_width)); + Polyline inward_path = m_wipe.path; + if (offset_wipe_path_toward_support( + inward_path, seam_start, seam_end, wipe_start, + wipe_offset_direction(is_ccw, is_hole), offset_dist, max_wipe_length, + wipe_support->inner_lines, wipe_support->printed_lines, + m_wipe.path.lines(), support_distance)) { + m_wipe.path = std::move(inward_path); + wipe_inward_applied = true; + } + } } } - // make a little move inwards before leaving loop - if (m_config.wipe_on_loops.value && paths.back().role() == erExternalPerimeter && m_layer != NULL && m_config.wall_loops.value > 1 && paths.front().size() >= 2 && paths.back().polyline.points.size() >= 3) { - // detect angle between last and first segment - // the side depends on the original winding order of the polygon (inwards for contours, outwards for holes) - //FIXME improve the algorithm in case the loop is tiny. - //FIXME improve the algorithm in case the loop is split into segments with a low number of points (see the Point b query). - const Point3 &a3 = paths.front().polyline.points[1]; // second point - Point a = Point(a3.x(), a3.y()); - const Point3 &b3 = *(paths.back().polyline.points.end()-3); // second to last point - Point b = Point(b3.x(), b3.y()); - if (is_hole == loop.is_counter_clockwise()) { - // swap points - Point c = a; a = b; b = c; - } - - double angle = paths.front().first_point().ccw_angle(a, b) / 3; - - // turn inwards if contour, turn outwards if hole - if (is_hole == loop.is_counter_clockwise()) angle *= -1; - - // create the destination point along the first segment and rotate it - // we make sure we don't exceed the segment length because we don't know - // the rotation of the second segment so we might cross the object boundary - Vec2d p1 = paths.front().polyline.points.front().cast().head<2>(); - Vec2d p2 = paths.front().polyline.points[1].cast().head<2>(); - Vec2d v = p2 - p1; - double nd = scale_(EXTRUDER_CONFIG(nozzle_diameter)); - double l2 = v.squaredNorm(); - // Shift by no more than a nozzle diameter. - //FIXME Hiding the seams will not work nicely for very densely discretized contours! - //BBS. shorten the travel distant before the wipe path - double threshold = 0.2; - Point pt = (p1 + v * threshold).cast(); - if (nd * nd < l2) - pt = (p1 + threshold * v * (nd / sqrt(l2))).cast(); - //Point pt = ((nd * nd >= l2) ? (p1+v*0.4): (p1 + 0.2 * v * (nd / sqrt(l2)))).cast(); - const Point3 ¢er3 = paths.front().polyline.points.front(); - pt.rotate(angle, Point(center3.x(), center3.y())); - // generate the travel move - gcode += m_writer.extrude_to_xy(this->point_to_gcode(pt), 0, "move inwards before travel", true); + // Orca: make the configured inward move before leaving the loop. + if (wipe_on_loops_dest) { + gcode += m_writer.extrude_to_xy( + this->point_to_gcode(*wipe_on_loops_dest), 0, "move inwards before travel", true); + this->set_last_pos(*wipe_on_loops_dest); } + // Execute the accepted path before another extrusion replaces it. Wiping + // must not force retraction or Z-hop across a short travel to the next wall. + // Ordinary travel planning decides whether to retract from the new position. + if (wipe_inward_applied) + gcode += m_wipe.wipe(*this, 0.); + return gcode; } @@ -7524,21 +7562,9 @@ std::string GCode::extrude_multi_path(const ExtrusionMultiPath& multipath, const m_multi_flow_segment_path_pa_set = true; } - // BBS - if (m_wipe.enable && FILAMENT_CONFIG(wipe)) { - m_wipe.path = Polyline(); - for (const ExtrusionPath &path : multipath.paths) { - //BBS: Don't need to save duplicated point into wipe path - if (!m_wipe.path.empty() && !path.empty() && - m_wipe.path.last_point() == Point(path.first_point().x(), path.first_point().y())) { - // Convert Points3 to Points - for (auto it = path.polyline.points.begin() + 1; it != path.polyline.points.end(); ++it) - m_wipe.path.append(Point(it->x(), it->y())); - } else - m_wipe.path.append(path.polyline.to_polyline()); // TODO: don't limit wipe to last path - } - m_wipe.path.reverse(); - } + // Orca: multipath wipes retrace the extrusion in reverse order. + if (m_wipe.enable && FILAMENT_CONFIG(wipe)) + m_wipe.update_path(multipath.paths, true); return gcode; } @@ -7546,14 +7572,15 @@ std::string GCode::extrude_multi_path(const ExtrusionMultiPath& multipath, const std::string GCode::extrude_entity(const ExtrusionEntity& entity, const std::string& description, double speed, - const ExtrusionEntitiesPtr& region_perimeters) + const ExtrusionEntitiesPtr& region_perimeters, + const WipeInwardSupport* wipe_support) { if (const ExtrusionPath* path = dynamic_cast(&entity)) return this->extrude_path(*path, description, speed); else if (const ExtrusionMultiPath* multipath = dynamic_cast(&entity)) return this->extrude_multi_path(*multipath, description, speed); else if (const ExtrusionLoop* loop = dynamic_cast(&entity)) - return this->extrude_loop(*loop, description, speed, region_perimeters); + return this->extrude_loop(*loop, description, speed, region_perimeters, nullptr, wipe_support); else throw Slic3r::InvalidArgument("Invalid argument supplied to extrude()"); return ""; @@ -7567,6 +7594,7 @@ std::string GCode::extrude_path(const ExtrusionPath& path, const std::string& de // description += ExtrusionEntity::role_to_string(path.role()); std::string gcode = this->_extrude(path, description, speed); if (m_wipe.enable && FILAMENT_CONFIG(wipe)) { + m_wipe.reset_path(); m_wipe.path = path.polyline.to_polyline(); if (is_tree(this->config().support_type) && is_support(path.role())) { if ((m_wipe.path.first_point() - m_wipe.path.last_point()).cast().norm() > scale_(0.2)) { @@ -7599,8 +7627,19 @@ std::string GCode::extrude_perimeters(const Print &print, const std::vectorextrude_entity(*ee, "perimeter", -1., region.perimeters); + // Build the printed prefix once in emission order, scoped to this + // region. Disabled or zero-length wipes need no support geometry. + std::optional wipe_support; + if (m_wipe.enable && FILAMENT_CONFIG(wipe) && m_config.wipe_inward && + m_config.wipe_inward_distance.value > 0. && + scale_(FILAMENT_CONFIG(wipe_distance)) > SCALED_EPSILON) + wipe_support.emplace(); + for (const ExtrusionEntity* ee : region.perimeters) { + gcode += this->extrude_entity(*ee, "perimeter", -1., region.perimeters, + wipe_support ? &*wipe_support : nullptr); + if (wipe_support) + wipe_support->append(*ee); + } } return gcode; } @@ -7841,7 +7880,8 @@ std::string GCode::_extrude(const ExtrusionPath &path, std::string description, // path is 2D. But in slope lift case, lift z is done in travel_to function. // Add m_need_change_layer_lift_z when change_layer in case of no lift if m_last_pos is equal to path.first_point() by chance Point first_point = path.first_point(); - if (!m_last_pos_defined || m_last_pos.to_point() != first_point || m_need_change_layer_lift_z || slope_need_z_travel) { + if (!m_last_pos_defined || m_last_pos.to_point() != first_point || m_need_change_layer_lift_z || + slope_need_z_travel) { const bool _last_pos_undefined = !m_last_pos_defined; double z = DBL_MAX; diff --git a/src/libslic3r/GCode.hpp b/src/libslic3r/GCode.hpp index 990bf0fee7..29e4638a94 100644 --- a/src/libslic3r/GCode.hpp +++ b/src/libslic3r/GCode.hpp @@ -39,6 +39,7 @@ namespace Slic3r { // Forward declarations. class GCode; +struct WipeInwardSupport; namespace CustomGCode{ struct Item; } struct PrintInstance; @@ -61,7 +62,7 @@ public: bool enable; Polyline path; - // Orca: + // Orca: retraction portions emitted before, during, and after the wipe move. struct RetractionValues{ double retraction_length_before_wipe = 0.; double retraction_length_during_wipe = 0.; @@ -73,8 +74,10 @@ public: void reset_path() { this->path = Polyline(); } std::string wipe(GCode &gcodegen, double length, bool toolchange = false, bool is_last = false); - // Orca: + // Orca: calculate the retraction portions that can be emitted at wipe speed. RetractionValues calculateWipeRetractionLengths(GCode& gcodegen, bool toolchange); + // Orca: rebuild the stored path while deduplicating shared path boundaries. + void update_path(const ExtrusionPaths &paths, bool reverse = false); }; class WipeTowerIntegration { @@ -430,14 +433,16 @@ private: std::string extrude_entity(const ExtrusionEntity& entity, const std::string& description = "", double speed = -1., - const ExtrusionEntitiesPtr& region_perimeters = ExtrusionEntitiesPtr()); + const ExtrusionEntitiesPtr& region_perimeters = ExtrusionEntitiesPtr(), + const WipeInwardSupport* wipe_support = nullptr); // Orca: pass the complete collection of region perimeters to the extrude loop to check whether the wipe before external loop // should be executed std::string extrude_loop(const ExtrusionLoop& loop, const std::string& description, double speed = -1., const ExtrusionEntitiesPtr& region_perimeters = ExtrusionEntitiesPtr(), - const Point* start_point = nullptr); + const Point* start_point = nullptr, + const WipeInwardSupport* wipe_support = nullptr); std::string extrude_multi_path(const ExtrusionMultiPath& multipath, const std::string& description = "", double speed = -1.); std::string extrude_path(const ExtrusionPath& path, const std::string& description = "", double speed = -1.); diff --git a/src/libslic3r/GCode/WipePathHelpers.cpp b/src/libslic3r/GCode/WipePathHelpers.cpp new file mode 100644 index 0000000000..d785a16a8c --- /dev/null +++ b/src/libslic3r/GCode/WipePathHelpers.cpp @@ -0,0 +1,920 @@ +#include "WipePathHelpers.hpp" + +#include "../AABBTreeLines.hpp" + +#include +#include +#include +#include +#include + +namespace Slic3r { + +void WipeInwardSupport::append(const ExtrusionEntity &entity) +{ + const ExtrusionPaths *paths = nullptr; + if (const auto *loop = dynamic_cast(&entity)) + paths = &loop->paths; + else if (const auto *multipath = dynamic_cast(&entity)) + paths = &multipath->paths; + + // A loop's role is its first path's role. An overhanging start must not + // hide the ordinary inner-wall segments elsewhere in the same loop. + const bool is_inner = paths ? std::any_of(paths->begin(), paths->end(), + [](const ExtrusionPath &path) { return is_internal_perimeter(path.role()); }) : + is_internal_perimeter(entity.role()); + const Lines lines = entity.as_polyline().lines(); + printed_lines.insert(printed_lines.end(), lines.begin(), lines.end()); + if (is_inner) + inner_lines.insert(inner_lines.end(), lines.begin(), lines.end()); +} + +// Orca: miter limit ratio. Matches DefaultMiterLimit from ClipperUtils.hpp. +// When the miter join extends more than miter_limit * offset_dist from the +// original vertex, the miter is replaced by a bevel join. +static constexpr double miter_limit = 3.0; + +// Orca: threshold for detecting near-reversal (backtracking spike). +// Normalized dot product below this means the segments point in nearly +// opposite directions (angle > ~172°). Offsetting such a path is unsafe. +static constexpr double reversal_dot_threshold = -0.99; + +// Orca: candidates pointing more than 60 degrees away from the selected inner +// wall are too tangent to distinguish the material side reliably at a cusp. +static constexpr double min_support_alignment = 0.5; + +// Keep a scaled-coordinate rounding floor while allowing the tolerance to +// follow the relevant offset or path length. Clearance allows a larger fraction. +static double wipe_tolerance(double distance, double relative_tolerance = 0.1) +{ + return std::max(4. * SCALED_EPSILON, relative_tolerance * distance); +} + +Point sample_path_at_distance(const ExtrusionPaths &paths, bool forward, double target) +{ + assert(!paths.empty()); + if (paths.empty()) + return Point(0, 0); + + double remaining = target; + Point result = forward ? paths.front().first_point() : paths.back().last_point(); + for (int pi = forward ? 0 : (int)paths.size() - 1; + pi >= 0 && pi < (int)paths.size() && remaining > 0.; + pi += forward ? 1 : -1) { + const Points3 &pts = paths[pi].polyline.points; + for (int i = forward ? 0 : (int)pts.size() - 1; + remaining > 0. && (forward ? i + 1 < (int)pts.size() : i > 0); + i += forward ? 1 : -1) { + const int j = forward ? i + 1 : i - 1; + const Point cur(pts[i].x(), pts[i].y()); + const Point next(pts[j].x(), pts[j].y()); + const double segment_length = (next - cur).cast().norm(); + if (segment_length < SCALED_EPSILON) + continue; + if (remaining <= segment_length) { + const double ratio = remaining / segment_length; + return Point(coord_t(cur.x() + ratio * (next.x() - cur.x())), + coord_t(cur.y() + ratio * (next.y() - cur.y()))); + } + remaining -= segment_length; + result = next; + } + } + return result; +} + +// Orca: consecutive duplicates carry no path length and can be removed safely. +// A reversal, however, is real travelled distance: removing its vertex would +// replace a long backtracking wipe with a short, unrelated shortcut. +static bool prepare_source(Points &pts) +{ + pts.erase(std::unique(pts.begin(), pts.end()), pts.end()); + + if (pts.size() < 2) + return false; + + for (size_t i = 1; i + 1 < pts.size(); ++i) { + const Vec2d v_prev = (pts[i] - pts[i - 1]).cast(); + const Vec2d v_next = (pts[i + 1] - pts[i]).cast(); + const double dot = v_prev.dot(v_next) / (v_prev.norm() * v_next.norm()); + if (dot < reversal_dot_threshold) + return false; + } + return true; +} + +static bool build_offset_polyline(const Points &original, int dir, double offset_dist, + Points &result, size_t &first_join_index) +{ + if (original.size() < 2) + return false; + + // Orca: collapse all consecutive duplicates first, then reject any + // backtracking in the cleaned path instead of replacing travelled distance + // with a shortcut. + Points source = original; + if (! prepare_source(source)) + return false; + + const size_t n = source.size(); + + // Orca: compute the perpendicular offset for segment i->i+1 as an infinite Line. + auto offset_segment = [dir, offset_dist](const Point &a, const Point &b) -> Line { + Vec2d v = (b - a).cast(); + double len = v.norm(); + Vec2d perp(0, 0); + if (len > SCALED_EPSILON) + perp = Vec2d(-v.y(), v.x()) * (dir * offset_dist / len); + return Line(Point(coord_t(a.x() + perp.x()), coord_t(a.y() + perp.y())), + Point(coord_t(b.x() + perp.x()), coord_t(b.y() + perp.y()))); + }; + + result.clear(); + result.reserve(n); + first_join_index = 0; + + // Orca: the first point is perpendicular to the first segment. + Line l_prev = offset_segment(source[0], source[1]); + result.push_back(l_prev.a); + + // Orca: use the analytic intersection of adjacent offset segments for a + // miter join. Intersecting the already rounded Line endpoints amplifies + // coordinate quantization when the source segments are nearly parallel. + for (size_t i = 1; i + 1 < n; ++i) { + Line l_next = offset_segment(source[i], source[i + 1]); + const Vec2d previous = (source[i] - source[i - 1]).cast().normalized(); + const Vec2d next = (source[i + 1] - source[i]).cast().normalized(); + const double denominator = 1. + previous.dot(next); + + bool need_bevel = denominator <= EPSILON; + Point pt; + if (! need_bevel) { + const Vec2d previous_normal(-previous.y(), previous.x()); + const Vec2d next_normal(-next.y(), next.x()); + const Vec2d miter = (previous_normal + next_normal) * (dir * offset_dist / denominator); + if (miter.norm() > miter_limit * offset_dist) { + need_bevel = true; + } else { + pt = Point(coord_t(source[i].x() + miter.x()), + coord_t(source[i].y() + miter.y())); + } + } + + if (need_bevel) { + result.push_back(l_prev.b); + if (l_next.a != result.back()) + result.push_back(l_next.a); + } else { + result.push_back(pt); + } + if (i == 1) + first_join_index = result.size() - 1; + l_prev = l_next; + } + + // Orca: the last point is perpendicular to the last segment. + result.push_back(l_prev.b); + + return true; +} + +int wipe_offset_direction(bool is_ccw, bool is_hole) +{ + const int loop_inside = is_ccw ? +1 : -1; + return is_hole ? -loop_inside : loop_inside; +} + +static bool starts_by_backtracking(const Polyline &path, Point actual_start) +{ + if (path.points.size() < 3) + return false; + // Orca: points[0] is only a storage sentinel; use the nozzle position for + // the executable connector, particularly after a wipe_on_loops pre-move. + const Vec2d connector = (path.points[1] - actual_start).cast(); + const Vec2d outgoing = (path.points[2] - path.points[1]).cast(); + // An inward connector may be perpendicular to the outgoing offset edge. + // Rounded joins must not turn that right angle into a false backtrack. + return connector.dot(outgoing) < -4. * SCALED_EPSILON * outgoing.norm(); +} + +// Orca: sample the outgoing perimeter without copying or clipping its full loop. +static Point sample_polyline_at_distance(const Polyline &polyline, double target) +{ + assert(! polyline.points.empty()); + Point result = polyline.first_point(); + for (size_t i = 1; i < polyline.points.size() && target > 0.; ++i) { + const Vec2d segment = (polyline.points[i] - result).cast(); + const double length = segment.norm(); + if (length <= SCALED_EPSILON) + continue; + if (target <= length) + return (result.cast() + segment * (target / length)).cast(); + target -= length; + result = polyline.points[i]; + } + return result; +} + +// Orca: convert an executable path into Wipe::wipe()'s stored representation. +// The first point is a dummy replaced by the actual nozzle position, while the +// remaining points are clipped to the configured wipe distance. +static bool store_wipe_path(Polyline &destination, Point seam_start, + Polyline actual_path, double max_wipe_length) +{ + if (actual_path.points.size() < 2 || max_wipe_length <= SCALED_EPSILON) + return false; + + const double actual_length = actual_path.length(); + if (actual_length <= SCALED_EPSILON) + return false; + if (actual_length - max_wipe_length > SCALED_EPSILON) + actual_path.clip_end(actual_length - max_wipe_length); + if (actual_path.points.size() < 2) + return false; + for (size_t i = 1; i < actual_path.points.size(); ++i) + if (actual_path.points[i - 1] == actual_path.points[i]) + return false; + + Polyline stored_path; + stored_path.points.reserve(actual_path.points.size()); + stored_path.points.push_back(seam_start); + stored_path.points.insert(stored_path.points.end(), actual_path.points.begin() + 1, actual_path.points.end()); + stored_path.reset_to_linear_move(); + destination = std::move(stored_path); + return true; +} + +bool offset_wipe_path(Polyline &polyline, Point seam_start, Point seam_end, Point wipe_start, + int dir, double offset_dist, double max_wipe_length) +{ + assert(dir == +1 || dir == -1); + assert(offset_dist > 0); + if (polyline.points.empty() || polyline.first_point() != seam_start || + max_wipe_length <= SCALED_EPSILON) + return false; + + const Polyline original = polyline; + const double original_length = original.length(); + if (original_length <= SCALED_EPSILON) + return false; + + double source_length = std::min(original_length, max_wipe_length); + for (;;) { + Polyline source = original; + const double clip_distance = original_length - source_length; + if (clip_distance > SCALED_EPSILON) + source.clip_end(clip_distance); + + Points wrapped_source; + wrapped_source.reserve(source.points.size() + 1); + if (seam_start == seam_end) { + // Orca: the stored loop is open at seam_start even when the seam gap is + // zero. Prepend the closing edge so build_offset_polyline() creates + // the proper join between that edge and the first outgoing edge, + // instead of leaving the first offset point on the closing wall. + size_t closing_index = original.points.size(); + while (closing_index > 0 && original.points[closing_index - 1] == seam_start) + --closing_index; + if (closing_index == 0) + return false; // Orca: the entire path is a single point. + wrapped_source.push_back(original.points[closing_index - 1]); + } else { + // Orca: use the unextruded seam-gap edge to determine the incoming + // direction at the seam. Its offset is construction geometry only; + // wiping along it would create a Z-shaped detour before the outgoing + // perimeter offset. + wrapped_source.push_back(seam_end); + } + wrapped_source.insert(wrapped_source.end(), source.points.begin(), source.points.end()); + + Points offset_points; + size_t first_join_index = 0; + if (! build_offset_polyline(wrapped_source, dir, offset_dist, offset_points, first_join_index) || + first_join_index == 0 || first_join_index >= offset_points.size()) + return false; + // Orca: discard the offset of the prepended edge and, for a bevel, its + // incoming endpoint. The executable wipe starts at the seam join and + // then follows only the already printed outgoing perimeter. + offset_points.erase(offset_points.begin(), offset_points.begin() + first_join_index); + + Polyline actual_path; + actual_path.points.reserve(offset_points.size() + 1); + actual_path.points.push_back(wipe_start); + actual_path.points.insert(actual_path.points.end(), offset_points.begin(), offset_points.end()); + + // A loop pre-move may advance past an otherwise valid offset join. + // Enter at the nozzle's projection instead of returning to the join. + // Do not repair a join that already backtracks across the seam gap; + // the caller must still validate wall crossings, material side and support. + if (seam_start != seam_end && wipe_start != seam_start && wipe_start != seam_end && + starts_by_backtracking(actual_path, wipe_start) && ! starts_by_backtracking(actual_path, seam_end)) { + size_t entry = 1; + while (entry + 1 < actual_path.points.size()) { + const Vec2d edge = (actual_path.points[entry + 1] - actual_path.points[entry]).cast(); + const double projection = (wipe_start - actual_path.points[entry]).cast().dot(edge); + if (projection <= 0.) + break; + if (projection < edge.squaredNorm()) { + actual_path.points[entry] = (actual_path.points[entry].cast() + + edge * (projection / edge.squaredNorm())).cast(); + break; + } + ++entry; + } + actual_path.points.erase(actual_path.points.begin() + 1, actual_path.points.begin() + entry); + } + + if (seam_start != seam_end && wipe_start == seam_end && + starts_by_backtracking(actual_path, wipe_start)) { + // Orca: a wide seam gap or a sharp cusp may put the first miter + // behind its outgoing edge. Reject this offset candidate so the + // caller can try the opposite side or the translated fallback. + return false; + } + + const double actual_length = actual_path.length(); + const bool source_exhausted = original_length - source_length <= SCALED_EPSILON; + if (actual_length + SCALED_EPSILON < max_wipe_length && ! source_exhausted) { + // Orca: offset joins may shorten the path at every corner. Grow the + // source until the executable offset path, not a heuristic source + // margin, reaches the configured wipe distance. + const double deficit = max_wipe_length - actual_length; + const double next_length = std::min(original_length, + source_length + std::max(deficit, 2. * SCALED_EPSILON)); + if (next_length - source_length <= SCALED_EPSILON) + return false; + source_length = next_length; + continue; + } + + // Orca: unlike an extruded offset, a wipe may safely cross or retrace the + // just-printed perimeter. The caller validates the complete executable + // path against current and earlier printed perimeter geometry. + return store_wipe_path(polyline, seam_start, std::move(actual_path), max_wipe_length); + } +} + +static bool translated_wipe_path(Polyline &polyline, Point seam_start, Point seam_end, Point wipe_start, + const Vec2d &translation, double max_wipe_length) +{ + if (translation.norm() <= SCALED_EPSILON || max_wipe_length <= SCALED_EPSILON) + return false; + + const Polyline original = polyline; + Polyline actual_path; + actual_path.points.reserve(original.points.size() + 2); + actual_path.points.push_back(wipe_start); + + const auto append_translated = [&actual_path, &translation](const Point &point) { + const Point translated = (point.cast() + translation).cast(); + if (translated != actual_path.points.back()) + actual_path.points.push_back(translated); + }; + + // Orca: translate the seam join directly. Translating seam_end and then + // following the unextruded gap back to seam_start makes the wipe double + // back whenever a gap ends near a sharp corner. + append_translated(seam_start); + for (const Point &point : original.points) + append_translated(point); + + if (seam_start != seam_end && wipe_start == seam_end && + starts_by_backtracking(actual_path, wipe_start)) { + // Orca: at a wide gap next to a cusp, the translated seam join may + // lie behind the outgoing edge. Prefer a shorter local inward move + // at the actual extrusion end over a longer lightning-shaped wipe. + actual_path.points.resize(1); + append_translated(seam_end); + } + + return store_wipe_path(polyline, seam_start, std::move(actual_path), max_wipe_length); +} + +// A segment whose endpoints lie within one line's distance capsule is fully +// supported, since that capsule is convex. Subdivide only when support changes +// between lines; fixed-distance sampling can miss an unsupported gap. +static bool segment_is_supported(Point start, Point end, + const AABBTreeLines::LinesDistancer &distancer, + double max_distance) +{ + const Point midpoint = ((start.cast() + end.cast()) * 0.5).cast(); + const auto [distance, line_index, nearest] = distancer.distance_from_lines_extra(midpoint); + if (distance > max_distance) + return false; + + const Line &line = distancer.get_line(line_index); + if (line.distance_to(start) <= max_distance && line.distance_to(end) <= max_distance) + return true; + if (distancer.distance_from_lines(start) > max_distance || + distancer.distance_from_lines(end) > max_distance) + return false; + + // Conservatively reject an unresolved transition at coordinate precision. + if ((end - start).cast().norm() <= SCALED_EPSILON) + return false; + return segment_is_supported(start, midpoint, distancer, max_distance) && + segment_is_supported(midpoint, end, distancer, max_distance); +} + +std::optional wipe_path_support_score( + const Polyline &polyline, Point wipe_start, + const AABBTreeLines::LinesDistancer &target_distancer, + const AABBTreeLines::LinesDistancer &all_support_distancer, + double max_distance) +{ + if (polyline.points.size() < 2 || target_distancer.get_lines().empty() || max_distance <= 0) + return std::nullopt; + + // Orca: require a local neighbour, not merely an earlier perimeter elsewhere in + // the region. At a convex corner, an inner wall's miter is farther from the + // external seam than its normal wall spacing, so allow the same bounded miter + // reach as the offset construction without accepting a remote island. + if (target_distancer.distance_from_lines(wipe_start) > + miter_limit * max_distance + 4. * SCALED_EPSILON) + return std::nullopt; + + Point previous = wipe_start; + for (size_t i = 1; i < polyline.points.size(); ++i) { + // Orca: a tightly curved inward path may cross back over the current wall. + // This is safe for a non-extruding wipe as long as the complete path + // remains over current or earlier printed perimeter geometry. + // Allow the same coordinate-rounding tolerance at every point, including + // the actual start substituted for the stored sentinel. + if (! segment_is_supported(previous, polyline.points[i], all_support_distancer, + max_distance + 4. * SCALED_EPSILON)) + return std::nullopt; + previous = polyline.points[i]; + } + + // Orca: decide direction at the seam. Scoring the complete path may select + // the wrong initial side when two contours converge and the later prefix + // happens to run closer to unrelated support. + return target_distancer.distance_from_lines(polyline.points[1]); +} + +static bool initial_connector_is_clear( + const Polyline &polyline, Point wipe_start, Point seam_start, + AABBTreeLines::LinesDistancer ¤t_perimeter_distancer, + double contact_tolerance) +{ + if (polyline.points.size() < 2 || polyline.points[1] == wipe_start) + return false; + + // Orca: without a seam gap, the connector necessarily starts at the wall + // and a self-touching cusp may share that same endpoint on several edges. + if (seam_start == wipe_start) + return true; + + const Line connector(wipe_start, polyline.points[1]); + const auto intersections = current_perimeter_distancer.intersections_with_line(connector); + for (const auto &intersection : intersections) { + if ((intersection.first - wipe_start).cast().norm() > contact_tolerance) + return false; + } + + Point closest; + // Orca: integer offset joins may miss the exact seam-start coordinate by + // a few microns. Treat a close pass through that point as retracing the + // external wall, but keep the unavoidable contact at the actual start. + if (connector.distance_to_squared(seam_start, &closest) <= contact_tolerance * contact_tolerance && + (closest - wipe_start).cast().norm() > contact_tolerance) + return false; + + return true; +} + +static std::optional support_offset_at_start( + const Polyline &source, Point local_origin, bool disambiguate_branch, + AABBTreeLines::LinesDistancer &support_distancer, + double max_support_distance) +{ + if (source.points.size() < 2) + return std::nullopt; + + // Orca: a nonzero gap may put the seam beside the wrong branch of a cusp. + // Sample farther along the path to identify its actual neighbouring wall. + const Point support_query = disambiguate_branch ? + sample_polyline_at_distance(source, 2. * max_support_distance) : source.first_point(); + const auto nearest_result = support_distancer.distance_from_lines_extra(support_query); + const Line &nearest_line = support_distancer.get_line(std::get<1>(nearest_result)); + Vec2d sampled_offset = std::get<2>(nearest_result) - support_query.cast(); + + if (disambiguate_branch) { + // Orca: an endpoint projection also contains distance along the support + // segment. Remove that tangent component before comparing wall sides. + const Vec2d support_edge = (nearest_line.b - nearest_line.a).cast(); + if (support_edge.norm() > SCALED_EPSILON) { + const Vec2d support_tangent = support_edge.normalized(); + sampled_offset -= support_tangent * sampled_offset.dot(support_tangent); + } + } + if (sampled_offset.norm() <= SCALED_EPSILON) + return std::nullopt; + + if (! disambiguate_branch) + return sampled_offset; + + // Orca: find the local point on the same material-side branch. Using the + // sampled point itself would add the distance already travelled along the + // perimeter and turn a normal transition into a long diagonal move. + const Vec2d sampled_direction = sampled_offset.normalized(); + Vec2d local_offset = sampled_offset; + double best_local_score = std::numeric_limits::infinity(); + for (size_t line_index : support_distancer.all_lines_in_radius( + local_origin, 2. * max_support_distance + 4. * SCALED_EPSILON)) { + Point local_support; + const Line &line = support_distancer.get_line(line_index); + const double distance_squared = line.distance_to_squared(local_origin, &local_support); + const Vec2d candidate_offset = local_support.cast() - local_origin.cast(); + const double candidate_distance = std::sqrt(distance_squared); + if (candidate_distance <= SCALED_EPSILON) + continue; + const double alignment = candidate_offset.normalized().dot(sampled_direction); + if (alignment < min_support_alignment) + continue; + const double score = candidate_distance / alignment; + if (score < best_local_score) { + best_local_score = score; + local_offset = candidate_offset; + } + } + return local_offset; +} + +static double executable_path_length(const Polyline &stored_path, Point wipe_start) +{ + if (stored_path.points.size() < 2) + return 0.; + + // Orca: points[0] is the storage sentinel, so measure the first segment + // from the actual nozzle position and the remaining stored segments normally. + double length = (stored_path.points[1] - wipe_start).cast().norm(); + for (size_t index = 2; index < stored_path.points.size(); ++index) + length += (stored_path.points[index] - stored_path.points[index - 1]).cast().norm(); + return length; +} + +static Lines material_side_support_lines(const Polyline &path, Point seam, int preferred_dir, + const Lines &support_lines) +{ + if (path.points.size() < 4 || path.first_point() != path.last_point()) + return {}; + + // Orca: the bisector of the incoming and outgoing material-side normals is + // a local side test that remains valid for globally self-touching Arachne + // contours. Ignore repeated seam points when obtaining both tangents. + const auto outgoing_it = std::find_if( + path.points.begin() + 1, path.points.end(), [seam](const Point &point) { return point != seam; }); + const auto incoming_it = std::find_if( + path.points.rbegin() + 1, path.points.rend(), [seam](const Point &point) { return point != seam; }); + if (outgoing_it == path.points.end() || incoming_it == path.points.rend()) + return {}; + + const Vec2d outgoing = (*outgoing_it - seam).cast().normalized(); + const Vec2d incoming = (seam - *incoming_it).cast().normalized(); + const Vec2d material_direction = + (Vec2d(-outgoing.y(), outgoing.x()) + Vec2d(-incoming.y(), incoming.x())) * preferred_dir; + if (material_direction.norm() <= EPSILON) + return {}; + + Lines result; + result.reserve(support_lines.size()); + for (const Line &line : support_lines) { + Point closest; + line.distance_to_squared(seam, &closest); + if ((closest - seam).cast().dot(material_direction) > SCALED_EPSILON) + result.push_back(line); + } + return result; +} + +bool wipe_path_stays_on_material_side( + const Polyline &path, Point path_start, const Vec2d &support_direction, + const AABBTreeLines::LinesDistancer &target_perimeter_distancer, + const AABBTreeLines::LinesDistancer ¤t_perimeter_distancer, + double effective_offset, bool require_clearance) +{ + if (path.points.size() < 2 || support_direction.norm() <= EPSILON || + target_perimeter_distancer.get_lines().empty() || current_perimeter_distancer.get_lines().empty() || + effective_offset <= SCALED_EPSILON) + return false; + + const Vec2d initial_offset = (path.points[1] - path_start).cast(); + if (initial_offset.norm() <= SCALED_EPSILON || + initial_offset.normalized().dot(support_direction.normalized()) < min_support_alignment) + return false; + // Orca: after the connector has left the extrusion endpoint, an inward + // offset must retain most of its requested clearance from the current + // external wall. Otherwise a tight turn may send an initially correct path + // back onto that wall, or make the opposite-side candidate look supported. + const double clearance_tolerance = wipe_tolerance(effective_offset, 0.25); + const double minimum_clearance = effective_offset - clearance_tolerance; + const Lines &lines = current_perimeter_distancer.get_lines(); + const auto left_normal = [](const Line &line) -> Vec2d { + const Vec2d edge = (line.b - line.a).cast(); + if (edge.norm() <= SCALED_EPSILON) + return Vec2d::Zero(); + return Vec2d(-edge.y(), edge.x()).normalized(); + }; + const auto on_material_side = [&](const Point &point, bool check_clearance) { + const auto [distance, line_index, nearest] = + current_perimeter_distancer.distance_from_lines_extra(point); + if (line_index >= lines.size()) + return false; + const Line &line = lines[line_index]; + Vec2d normal = left_normal(line); + // At a shared vertex use both incident edges, so the result does not + // depend on which equally close edge the AABB query happens to return. + const Line &previous = lines[(line_index + lines.size() - 1) % lines.size()]; + const Line &next = lines[(line_index + 1) % lines.size()]; + if ((nearest - line.a.cast()).norm() <= SCALED_EPSILON && previous.b == line.a) + normal += left_normal(previous); + if ((nearest - line.b.cast()).norm() <= SCALED_EPSILON && next.a == line.b) + normal += left_normal(next); + if (normal.norm() <= EPSILON) + return false; + + // An open or self-touching wall has no reliable polygon-wide sign. + // Orient its local normal toward the neighbouring printed inner wall, + // then test the candidate on that side at every sample. + normal.normalize(); + const Point wall_point = nearest.cast(); + const Vec2d support_point = std::get<2>( + target_perimeter_distancer.distance_from_lines_extra(wall_point)); + const double support_side = (support_point - nearest).dot(normal); + if (std::abs(support_side) <= 4. * SCALED_EPSILON) + return false; + const double side = (point.cast() - nearest).dot(normal) * (support_side > 0. ? 1. : -1.); + return side >= -4. * SCALED_EPSILON && + (! check_clearance || distance + 4. * SCALED_EPSILON >= minimum_clearance); + }; + + Point previous = path.points[1]; + if (! on_material_side(previous, require_clearance)) + return false; + for (size_t index = 2; index < path.points.size(); ++index) { + const Vec2d segment = (path.points[index] - previous).cast(); + const size_t samples = std::max(1, size_t(std::ceil(segment.norm() / effective_offset))); + for (size_t sample = 1; sample <= samples; ++sample) { + const Point point = (previous.cast() + + segment * (double(sample) / double(samples))).cast(); + if (! on_material_side(point, require_clearance)) + return false; + } + previous = path.points[index]; + } + return true; +} + +bool offset_wipe_path_toward_support(Polyline &polyline, Point seam_start, Point seam_end, Point wipe_start, + int preferred_dir, double offset_dist, double max_wipe_length, + const Lines &target_perimeter_lines, const Lines &printed_perimeter_lines, + const Lines ¤t_perimeter_lines, + double max_support_distance) +{ + assert(preferred_dir == +1 || preferred_dir == -1); + if (polyline.points.size() < 2 || target_perimeter_lines.empty() || current_perimeter_lines.empty() || + offset_dist <= SCALED_EPSILON || + max_wipe_length <= SCALED_EPSILON || max_support_distance <= SCALED_EPSILON) + return false; + + Lines material_support_lines; + const Lines *candidate_support_lines = &target_perimeter_lines; + if (seam_start == seam_end) { + // Orca: another contour may have a geometrically closer inner wall on + // this loop's air side. Restrict zero-gap support using the local seam + // normals before choosing the nearest wall. + material_support_lines = material_side_support_lines( + polyline, seam_start, preferred_dir, target_perimeter_lines); + if (material_support_lines.empty()) + return false; + candidate_support_lines = &material_support_lines; + } + + AABBTreeLines::LinesDistancer support_distancer(*candidate_support_lines); + const std::optional support_offset = support_offset_at_start( + polyline, seam_end, seam_start != seam_end, + support_distancer, max_support_distance); + if (! support_offset) + return false; + const Vec2d toward_support = *support_offset; + const double local_support_distance = toward_support.norm(); + const double effective_offset = std::min(offset_dist, local_support_distance); + if (effective_offset <= SCALED_EPSILON) + return false; + const Vec2d support_direction = toward_support / local_support_distance; + + // Orca: every candidate is validated against the same generated geometry. + // Build these AABB trees once per loop instead of rebuilding them for each + // preferred, alternate, translated, direct, or reversed candidate. + Lines all_support_lines = printed_perimeter_lines; + all_support_lines.insert(all_support_lines.end(), current_perimeter_lines.begin(), current_perimeter_lines.end()); + AABBTreeLines::LinesDistancer all_support_distancer(std::move(all_support_lines)); + AABBTreeLines::LinesDistancer current_perimeter_distancer(current_perimeter_lines); + + // Orca: allow only the contact needed to leave the extrusion endpoint. A + // connector that meets the current wall again is a seam-gap retrace, even + // if the rest of the non-extruding wipe remains over printed material. + const double contact_tolerance = wipe_tolerance(effective_offset); + + struct Candidate { + Polyline path; + // Orca: support score chooses the material-side path; length is used + // only to replace a corner-truncated path with the reverse fallback. + double support_score; + double path_length; + }; + + // Direction and wall contact have different origins after a loop pre-move. + // Keep the construction's wall endpoint for intersection checks even when + // the candidate's direction must be checked from the current nozzle position. + const auto validate_candidate = [&](Polyline path, Point path_start, Point direction_start, + double path_contact_tolerance, + const Vec2d &candidate_support_direction, + double candidate_offset, + bool require_clearance = true) -> std::optional { + // Orca: backtracking indicates a wrong join only across a nonzero gap. + // A closed zero-gap offset may initially turn back at its miter while + // still remaining on the supported material side of the perimeter. + const bool backtracks_across_gap = seam_start != seam_end && starts_by_backtracking(path, wipe_start); + // At a clipped corner another branch of the current wall may be closer + // than the requested offset. Preserve the zero-gap clearance rule, but + // check direction and local material side independently for every gap. + const bool material_side = wipe_path_stays_on_material_side( + path, direction_start, candidate_support_direction, + support_distancer, current_perimeter_distancer, candidate_offset, + require_clearance && seam_start == seam_end); + const bool connector_clear = initial_connector_is_clear( + path, wipe_start, path_start, current_perimeter_distancer, path_contact_tolerance); + if (backtracks_across_gap || ! material_side || ! connector_clear) + return std::nullopt; + const std::optional score = wipe_path_support_score( + path, wipe_start, support_distancer, all_support_distancer, max_support_distance); + if (! score) + return std::nullopt; + const double path_length = executable_path_length(path, wipe_start); + return Candidate{std::move(path), *score, path_length}; + }; + + const auto offset_candidate = [&](int dir) -> std::optional { + Polyline path = polyline; + if (! offset_wipe_path(path, seam_start, seam_end, wipe_start, dir, + effective_offset, max_wipe_length)) + return std::nullopt; + return validate_candidate(std::move(path), seam_start, seam_start, + contact_tolerance, support_direction, effective_offset); + }; + + std::optional preferred = offset_candidate(preferred_dir); + std::optional alternate = offset_candidate(-preferred_dir); + + // Orca: forward and reverse fallbacks share the same clamping, translation, + // connector tolerance, and complete-path validation. + const auto translated_candidate = [&](Polyline source, Point source_start, Point source_end, + const Vec2d &candidate_support_offset) -> std::optional { + const double support_distance = candidate_support_offset.norm(); + const double candidate_offset = std::min(offset_dist, support_distance); + if (candidate_offset <= SCALED_EPSILON) + return std::nullopt; + + const Vec2d candidate_translation = candidate_support_offset * (candidate_offset / support_distance); + if (! translated_wipe_path(source, source_start, source_end, wipe_start, + candidate_translation, max_wipe_length)) + return std::nullopt; + const double candidate_tolerance = wipe_tolerance(candidate_offset); + return validate_candidate(std::move(source), source_start, source_start, candidate_tolerance, + candidate_support_offset / support_distance, candidate_offset); + }; + + std::optional translated = translated_candidate(polyline, seam_start, seam_end, toward_support); + + // Orca: if every full-length construction folds back onto the external + // wall, retain a short direct inward move instead of accepting an outward + // candidate or falling back to the standard wipe along the outer wall. + const auto direct_candidate = [&](Point origin, const Vec2d &candidate_support_offset) -> std::optional { + const double support_distance = candidate_support_offset.norm(); + const double candidate_offset = std::min(offset_dist, support_distance); + if (candidate_offset <= SCALED_EPSILON) + return std::nullopt; + const Vec2d direction = candidate_support_offset / support_distance; + const Point destination = (origin.cast() + direction * candidate_offset).cast(); + if (destination == wipe_start) + return std::nullopt; + + Polyline path; + if (! store_wipe_path(path, seam_start, Polyline{wipe_start, destination}, max_wipe_length)) + return std::nullopt; + const double candidate_tolerance = wipe_tolerance(candidate_offset); + // Check the executed direction from the nozzle after any loop pre-move, + // but retain the wall origin for the connector's intersection checks. + return validate_candidate(std::move(path), origin, wipe_start, + candidate_tolerance, direction, candidate_offset, false); + }; + std::optional direct = direct_candidate(seam_end, toward_support); + + const double length_margin = wipe_tolerance(max_wipe_length); + std::optional reversed; + if (seam_start != seam_end && polyline.last_point() == seam_end) { + // Orca: when a large gap straddles a sharp corner, connecting the + // extrusion end to the forward offset may either reverse or leave only + // a short local move. The already printed incoming wall is equally safe: + // follow it backwards and determine its own material-side support. + Polyline reversed_source = polyline; + reversed_source.reverse(); + const std::optional reversed_support_offset = support_offset_at_start( + reversed_source, seam_end, true, support_distancer, max_support_distance); + if (reversed_support_offset) { + reversed = translated_candidate(reversed_source, seam_end, seam_end, *reversed_support_offset); + // A translated reverse path can backtrack or leave the material on + // a curved wall. Offset the incoming wall itself when translation + // cannot supply a complete wipe, retaining all candidate checks. + if (! reversed || reversed->path_length + length_margin < max_wipe_length) { + const double reverse_offset = std::min(offset_dist, reversed_support_offset->norm()); + if (reverse_offset > SCALED_EPSILON && + offset_wipe_path(reversed_source, seam_end, seam_start, wipe_start, + -preferred_dir, reverse_offset, max_wipe_length)) { + reversed_source.points.front() = seam_start; + auto candidate = validate_candidate(std::move(reversed_source), seam_end, seam_end, + wipe_tolerance(reverse_offset), reversed_support_offset->normalized(), reverse_offset); + if (candidate && (! reversed || + (candidate->path_length > reversed->path_length + length_margin && + candidate->support_score <= reversed->support_score + wipe_tolerance(reverse_offset)))) + reversed = std::move(candidate); + } + } + } + } + + // Orca: conventional offsets at a narrow cusp may form a bevel across the + // cusp. Candidates pointing away from the actual inner wall are rejected + // during validation; among the remaining paths, prefer the one whose first + // point is materially closer to that wall. + const double direction_change_margin = wipe_tolerance(effective_offset); + std::optional selected = std::move(preferred); + if (translated) { + if (! selected || translated->support_score + direction_change_margin < selected->support_score) + selected = std::move(translated); + } + if (! selected) + selected = std::move(direct); + // Prefer a direct inward move when the normal offset cannot be used. + // An alternate offset is eligible only after the same material-side checks. + if (! selected) + selected = std::move(alternate); + + // Orca: prefer a complete reverse wipe over a forward fallback that had to + // stop at the corner. Equal-length paths keep the normal forward behavior. + if (reversed && (! selected || + (reversed->path_length > selected->path_length + length_margin && + reversed->support_score <= selected->support_score + direction_change_margin))) + selected = std::move(reversed); + if (! selected) + return false; + + polyline = std::move(selected->path); + return true; +} + +std::optional wipe_on_loops_destination(const ExtrusionPaths &paths, double nozzle_diam_scaled, + bool is_ccw, bool is_hole) +{ + assert(!paths.empty()); + assert(nozzle_diam_scaled > 0); + if (paths.empty() || nozzle_diam_scaled <= 0) + return std::nullopt; + + // Orca: clamp sample distance to L/4 so forward/backward samples cannot meet. + double total_length = 0.; + for (const ExtrusionPath &path : paths) + total_length += path.length(); + const double sample_distance = std::min(nozzle_diam_scaled, total_length * 0.25); + + Point a = sample_path_at_distance(paths, true, sample_distance); + Point b = sample_path_at_distance(paths, false, sample_distance); + + const Point seam_start = paths.front().first_point(); + + // Orca: skip the inward move for degenerate geometry. + if (a == b || a == seam_start || b == seam_start) + return std::nullopt; + + const bool reverse_turn = is_hole == is_ccw; + if (reverse_turn) + std::swap(a, b); + + double angle = seam_start.ccw_angle(a, b) / 3; + + // Orca: reject degenerate angles near 0 or 2π. + static constexpr double angle_epsilon = 0.01; + if (angle < angle_epsilon || angle > 2 * PI / 3 - angle_epsilon) + return std::nullopt; + + if (reverse_turn) + angle *= -1; + + Point pt = sample_path_at_distance(paths, true, std::min(0.2 * nozzle_diam_scaled, sample_distance)); + pt.rotate(angle, seam_start); + return pt; +} + +} // namespace Slic3r diff --git a/src/libslic3r/GCode/WipePathHelpers.hpp b/src/libslic3r/GCode/WipePathHelpers.hpp new file mode 100644 index 0000000000..616e5dc88b --- /dev/null +++ b/src/libslic3r/GCode/WipePathHelpers.hpp @@ -0,0 +1,96 @@ +#pragma once + +#include + +#include "../ExtrusionEntity.hpp" +#include "../Polyline.hpp" +#include "../Line.hpp" + +namespace Slic3r { + +// Printed prefix of one region's perimeter sequence. Append each entity only +// after extrusion; later walls and other regions cannot support an inward wipe. +struct WipeInwardSupport { + Lines printed_lines; + Lines inner_lines; + void append(const ExtrusionEntity &entity); +}; + +namespace AABBTreeLines { +template class LinesDistancer; +} + +// Orca: sample a point at a given distance along ExtrusionPaths, walking +// across segment boundaries. forward=true walks from paths.front, false from +// paths.back. For tiny loops the walk stops early and returns the last +// reachable point. Returns the start point if target is zero. +// Precondition: paths must be non-empty. +Point sample_path_at_distance(const ExtrusionPaths &paths, bool forward, double target); + +// Orca: return the side of the printed path on which the material lies. +// dir +1 is left and -1 is right, matching the offset-builder convention. +int wipe_offset_direction(bool is_ccw, bool is_hole); + +// Orca: atomically offset a stored wipe path. The seam-gap or closing edge +// determines the join with the first outgoing perimeter edge, but its offset +// is not part of the executable wipe. Only the prefix needed by Wipe::wipe() +// is offset. Returns false and leaves polyline unchanged if that path cannot +// be constructed without degenerate segments. This only constructs a candidate; +// offset_wipe_path_toward_support() validates its support, material side and +// connector before accepting it. The first stored point +// remains a dummy preserving Wipe::wipe()'s convention of skipping points[0]. +// Precondition: polyline starts at seam_start, dir is +1 or -1, and +// offset_dist > 0. A non-positive max_wipe_length returns false. +bool offset_wipe_path(Polyline &polyline, Point seam_start, Point seam_end, Point wipe_start, + int dir, double offset_dist, double max_wipe_length); + +// Orca: score a candidate's first destination by distance to the target inner +// walls. Return nullopt if no target wall is near wipe_start or any executable +// segment lacks support. target_distancer contains eligible earlier walls; +// all_support_distancer includes the current wall and all earlier walls. +// The stored first point is a dummy: the first segment starts at wipe_start. +// This checks support only; material-side and connector checks belong to +// offset_wipe_path_toward_support(). Trees are reused across its candidates. +std::optional wipe_path_support_score( + const Polyline &polyline, Point wipe_start, + const AABBTreeLines::LinesDistancer &target_distancer, + const AABBTreeLines::LinesDistancer &all_support_distancer, + double max_distance); + +// Validate the initial inward direction and the local material side along the +// executable path, using the inner wall to orient the open current wall's +// normals. Clearance is optional for clipped corners and short direct fallbacks; +// the material-side check is mandatory. The straight connector is checked by +// its initial direction and separately by support and intersection validation. +// path_start is the construction origin; points[0] is only a storage sentinel. +bool wipe_path_stays_on_material_side( + const Polyline &path, Point path_start, const Vec2d &support_direction, + const AABBTreeLines::LinesDistancer &target_perimeter_distancer, + const AABBTreeLines::LinesDistancer ¤t_perimeter_distancer, + double effective_offset, bool require_clearance); + +// Orca: identify the adjacent inner perimeter from the outgoing wall, excluding +// support on the air side of a closed zero-gap loop. Clamp the requested offset +// to the distance from the seam end to that support, then select the safest +// supported offset or translated path. If a wide seam gap at a corner truncates +// every forward candidate, the incoming printed wall may be followed backwards +// instead. All earlier printed perimeters still participate in the complete-path +// safety check. This handles converging, locally ambiguous, or self-touching +// contours whose global winding alone does not identify the material side. +// Returns false and leaves polyline unchanged when no candidate is supported. +// Precondition: preferred_dir is +1 or -1. Distances must be positive. +bool offset_wipe_path_toward_support(Polyline &polyline, Point seam_start, Point seam_end, Point wipe_start, + int preferred_dir, double offset_dist, double max_wipe_length, + const Lines &target_perimeter_lines, const Lines &printed_perimeter_lines, + const Lines ¤t_perimeter_lines, + double max_support_distance); + +// Orca: compute the inward destination point for wipe_on_loops, or +// std::nullopt when the geometry is degenerate (tiny loop, coincident samples, +// angle near 0 or 2π). Returns the rotated destination or nullopt to skip the +// inward move entirely. +// Precondition: paths non-empty, nozzle_diam_scaled > 0. +std::optional wipe_on_loops_destination(const ExtrusionPaths &paths, double nozzle_diam_scaled, + bool is_ccw, bool is_hole); + +} // namespace Slic3r diff --git a/src/libslic3r/Preset.cpp b/src/libslic3r/Preset.cpp index e974ffd7f8..d4209abe1f 100644 --- a/src/libslic3r/Preset.cpp +++ b/src/libslic3r/Preset.cpp @@ -1282,6 +1282,8 @@ static std::vector s_Preset_print_options{ "accel_to_decel_enable", "accel_to_decel_factor", "wipe_on_loops", + "wipe_inward", + "wipe_inward_distance", "wipe_before_external_loop", "bridge_density", "internal_bridge_density", diff --git a/src/libslic3r/Print.cpp b/src/libslic3r/Print.cpp index 60f747ce04..9c4edabfdb 100644 --- a/src/libslic3r/Print.cpp +++ b/src/libslic3r/Print.cpp @@ -233,6 +233,8 @@ bool Print::invalidate_state_by_config_options(const ConfigOptionResolver & /* n "accel_to_decel_enable", "accel_to_decel_factor", "wipe_on_loops", + "wipe_inward", + "wipe_inward_distance", "gcode_comments", "gcode_label_objects", "exclude_object", diff --git a/src/libslic3r/PrintConfig.cpp b/src/libslic3r/PrintConfig.cpp index 0b0fe71dc0..7c34fb9542 100644 --- a/src/libslic3r/PrintConfig.cpp +++ b/src/libslic3r/PrintConfig.cpp @@ -6267,6 +6267,35 @@ void PrintConfigDef::init_fff_params() def->mode = comAdvanced; def->set_default_value(new ConfigOptionBool(false)); + def = this->add("wipe_inward", coBool); + def->label = L("Wipe inward"); + def->category = L("Quality"); + def->tooltip = L("Applies only to external walls, including hole boundaries. Moves the hot nozzle toward printed " + "inner walls during wiping to reduce reheating of freshly printed plastic and seam marks.\n\n" + "Especially useful at layer heights below 0.1 mm, where wipe marks are more visible.\n\n" + "Uses the regular wipe if no adjacent inner wall is already printed (single-wall areas or " + "Outer/Inner wall order), or if no supported inward path can be found, for example at tight " + "corners or seam gaps."); + def->mode = comExpert; + def->set_default_value(new ConfigOptionBool(false)); + + def = this->add("wipe_inward_distance", coFloatOrPercent); + def->label = L("Wipe inward distance"); + def->category = L("Quality"); + def->tooltip = L("The distance the wipe path is shifted away from the external perimeter, specified in millimeters " + "or as a percentage of the actual outer-wall extrusion width.\n\n" + "For example, 50% shifts the path by half of the outer-wall width. The effective offset is limited " + "by both the actual outer-wall width and the available spacing to the adjacent wall, so values " + "above 100% or an equivalent absolute distance have no additional effect. " + "Set to 0 to disable the offset."); + def->sidetext = L("mm or %"); + def->ratio_over = "outer_wall_line_width"; + def->min = 0; + def->max = 100; + def->max_literal = 2; // Orca: G-code generation also clamps literal values to the actual outer-wall width. + def->mode = comExpert; + def->set_default_value(new ConfigOptionFloatOrPercent(50, true)); + def = this->add("wipe_before_external_loop", coBool); def->label = L("Wipe before external loop"); def->category = L("Quality"); diff --git a/src/libslic3r/PrintConfig.hpp b/src/libslic3r/PrintConfig.hpp index 18e66adb34..6beaeed104 100644 --- a/src/libslic3r/PrintConfig.hpp +++ b/src/libslic3r/PrintConfig.hpp @@ -1391,6 +1391,8 @@ PRINT_CONFIG_CLASS_DEFINE( ((ConfigOptionBool, role_based_wipe_speed)) ((ConfigOptionFloatOrPercent, wipe_speed)) ((ConfigOptionBool, wipe_on_loops)) + ((ConfigOptionBool, wipe_inward)) + ((ConfigOptionFloatOrPercent, wipe_inward_distance)) ((ConfigOptionBool, wipe_before_external_loop)) ((ConfigOptionEnum, wall_infill_order)) ((ConfigOptionBool, precise_outer_wall)) diff --git a/src/libslic3r/PrintObject.cpp b/src/libslic3r/PrintObject.cpp index 54378b3b16..bb2a355daa 100644 --- a/src/libslic3r/PrintObject.cpp +++ b/src/libslic3r/PrintObject.cpp @@ -1574,6 +1574,8 @@ bool PrintObject::invalidate_state_by_config_options( || opt_key == "brim_flow_ratio" || opt_key == "filament_flow_ratio" || opt_key == "scarf_joint_flow_ratio" + || opt_key == "wipe_inward" + || opt_key == "wipe_inward_distance" || opt_key == "spiral_starting_flow_ratio" || opt_key == "spiral_finishing_flow_ratio") { invalidated |= m_print->invalidate_step(psGCodeExport); diff --git a/src/slic3r/GUI/ConfigManipulation.cpp b/src/slic3r/GUI/ConfigManipulation.cpp index 5bd74e107d..ba91ffb7c2 100644 --- a/src/slic3r/GUI/ConfigManipulation.cpp +++ b/src/slic3r/GUI/ConfigManipulation.cpp @@ -1104,6 +1104,9 @@ void ConfigManipulation::toggle_print_fff_options(DynamicPrintConfig *config, in auto is_role_based_wipe_speed = config->opt_bool("role_based_wipe_speed"); toggle_field("wipe_speed",!is_role_based_wipe_speed); + const bool have_wipe_inward = config->opt_bool("wipe_inward"); + toggle_line("wipe_inward_distance", have_wipe_inward); + for (auto el : {"accel_to_decel_enable", "accel_to_decel_factor"}) toggle_line(el, gcf_is_klipper); if(gcf_is_klipper) diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index 0fdb9dcd94..06d953aa25 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -15755,6 +15755,7 @@ void Plater::calib_pa(const Calib_Params& params) auto printer_config = &wxGetApp().preset_bundle->printers.get_edited_preset().config; print_config->set_key_value("overhang_reverse", new ConfigOptionBool(false)); print_config->set_key_value("precise_z_height", new ConfigOptionBool(false)); + print_config->set_key_value("wipe_inward", new ConfigOptionBool(false)); printer_config->set_key_value("resonance_avoidance", new ConfigOptionBool{false}); switch (params.mode) { case CalibMode::Calib_PA_Line: @@ -16440,6 +16441,7 @@ void Plater::calib_retraction(const Calib_Params& params) auto obj = model().objects[0]; print_config->set_key_value("enable_wrapping_detection", new ConfigOptionBool(false)); + print_config->set_key_value("wipe_inward", new ConfigOptionBool(false)); float nozzle_diameter = printer_config->option("nozzle_diameter")->get_at(0); float layer_height; diff --git a/src/slic3r/GUI/Tab.cpp b/src/slic3r/GUI/Tab.cpp index 914e4cc7bb..c0f78ee9c5 100644 --- a/src/slic3r/GUI/Tab.cpp +++ b/src/slic3r/GUI/Tab.cpp @@ -2669,6 +2669,8 @@ void TabPrint::build() optgroup->append_single_option_line("role_based_wipe_speed","quality_settings_seam#role-based-wipe-speed"); optgroup->append_single_option_line("wipe_speed", "quality_settings_seam#wipe-speed"); optgroup->append_single_option_line("wipe_on_loops","quality_settings_seam#wipe-on-loop-inward-movement"); + optgroup->append_single_option_line("wipe_inward", "quality_settings_seam#wipe-inward"); + optgroup->append_single_option_line("wipe_inward_distance", "quality_settings_seam#wipe-inward"); optgroup->append_single_option_line("wipe_before_external_loop","quality_settings_seam#wipe-before-external"); diff --git a/src/slic3r/Utils/CalibUtils.cpp b/src/slic3r/Utils/CalibUtils.cpp index 25aad85d2f..e432ccc152 100644 --- a/src/slic3r/Utils/CalibUtils.cpp +++ b/src/slic3r/Utils/CalibUtils.cpp @@ -1096,6 +1096,7 @@ bool CalibUtils::calib_generic_PA(const CalibInfo &calib_info, wxString &error_m calib_pa_pattern(calib_info, model); DynamicPrintConfig print_config = calib_info.print_prest->config; + print_config.set_key_value("wipe_inward", new ConfigOptionBool(false)); DynamicPrintConfig filament_config = calib_info.filament_prest->config; DynamicPrintConfig printer_config = calib_info.printer_prest->config; @@ -1357,6 +1358,7 @@ void CalibUtils::calib_retraction(const CalibInfo &calib_info, wxString &error_m read_model_from_file(input_file, model); DynamicPrintConfig print_config = calib_info.print_prest->config; + print_config.set_key_value("wipe_inward", new ConfigOptionBool(false)); DynamicPrintConfig filament_config = calib_info.filament_prest->config; DynamicPrintConfig printer_config = calib_info.printer_prest->config; diff --git a/tests/fff_print/CMakeLists.txt b/tests/fff_print/CMakeLists.txt index 3247bfda66..60e1721817 100644 --- a/tests/fff_print/CMakeLists.txt +++ b/tests/fff_print/CMakeLists.txt @@ -21,6 +21,7 @@ add_executable(${_TEST_NAME}_tests test_support_material.cpp test_tree_support.cpp test_trianglemesh.cpp + test_wipe.cpp test_wipe_tower.cpp ) target_link_libraries(${_TEST_NAME}_tests test_common libslic3r Catch2::Catch2WithMain) diff --git a/tests/fff_print/test_wipe.cpp b/tests/fff_print/test_wipe.cpp new file mode 100644 index 0000000000..46ee0d6441 --- /dev/null +++ b/tests/fff_print/test_wipe.cpp @@ -0,0 +1,653 @@ +#include + +#include +#include +#include +#include +#include +#include + +#include "libslic3r/GCode/GCodeProcessor.hpp" +#include "libslic3r/GCodeReader.hpp" +#include "libslic3r/PrintConfig.hpp" +#include "libslic3r/Layer.hpp" + +#include "test_helpers.hpp" + +using namespace Slic3r; +using namespace Slic3r::Test; + +namespace { + +DynamicPrintConfig wipe_config(const char *wall_generator, bool wipe_inward, + const char *wipe_inward_distance = "50%", + const char *seam_gap = "10%", bool wipe_on_loops = false, + const char *wall_loops = "2", + const char *wall_sequence = "inner wall/outer wall", + bool alternate_extra_wall = false, + const char *sparse_infill_density = "0%", + const char *seam_position = "aligned") +{ + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.set_deserialize_strict({ + { "nozzle_diameter", "0.4" }, + { "layer_height", "0.2" }, + { "initial_layer_print_height", "0.2" }, + { "line_width", "0.45" }, + { "outer_wall_line_width", "0" }, // Orca: Auto must use the actual path width. + { "wall_loops", wall_loops }, + { "wall_generator", wall_generator }, + { "wall_sequence", wall_sequence }, + { "top_shell_layers", "0" }, + { "bottom_shell_layers", "0" }, + { "sparse_infill_density", sparse_infill_density }, + { "seam_position", seam_position }, + { "seam_gap", seam_gap }, + { "wipe", "1" }, + { "wipe_distance", "2" }, + { "retraction_length", "0.8" }, + { "retract_when_changing_layer", "1" }, + { "wipe_inward", wipe_inward ? "1" : "0" }, + { "wipe_inward_distance", wipe_inward_distance }, + { "wipe_on_loops", wipe_on_loops ? "1" : "0" }, + { "alternate_extra_wall", alternate_extra_wall ? "1" : "0" }, + { "gcode_comments", "1" }, + { "machine_start_gcode", "" }, + { "machine_end_gcode", "" }, + }); + return config; +} + +struct WipeTrajectory { + Vec2d start; + double z; + std::vector destinations; +}; + +std::vector wipe_trajectories(const std::string &gcode) +{ + const std::string &start_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Wipe_Start); + const std::string &end_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Wipe_End); + std::vector trajectories; + bool in_wipe = false; + + GCodeReader parser; + parser.parse_buffer(gcode, [&](GCodeReader &self, const GCodeReader::GCodeLine &line) { + const std::string_view comment = line.comment(); + if (comment.find(start_tag) != std::string_view::npos) { + in_wipe = true; + trajectories.push_back({Vec2d(self.x(), self.y()), self.z(), {}}); + return; + } + if (comment.find(end_tag) != std::string_view::npos) { + in_wipe = false; + return; + } + if (in_wipe && line.dist_XY(self) > EPSILON) + trajectories.back().destinations.emplace_back(line.new_X(self), line.new_Y(self)); + }); + return trajectories; +} + +std::vector wipe_destinations(const std::string &gcode) +{ + std::vector destinations; + for (const WipeTrajectory &trajectory : wipe_trajectories(gcode)) + destinations.insert(destinations.end(), trajectory.destinations.begin(), trajectory.destinations.end()); + return destinations; +} + +bool trajectories_differ(const std::vector &lhs, const std::vector &rhs) +{ + if (lhs.size() != rhs.size()) + return true; + for (size_t i = 0; i < lhs.size(); ++i) + if ((lhs[i] - rhs[i]).norm() > 0.01) + return true; + return false; +} + +double trajectory_length(const WipeTrajectory &trajectory) +{ + double length = 0.; + Vec2d previous = trajectory.start; + for (const Vec2d &destination : trajectory.destinations) { + length += (destination - previous).norm(); + previous = destination; + } + return length; +} + +} // namespace + +TEST_CASE("Wipe retraction preserves fractional speed with inward wipe disabled", "[Wipe][Regression]") +{ + const char *retraction_speed = GENERATE("25.25", "25.5", "25.75"); + const char *relative_e = GENERATE("0", "1"); + INFO("retraction speed: " << retraction_speed); + INFO("relative E: " << relative_e); + DynamicPrintConfig config = wipe_config("classic", false); + config.set_deserialize_strict({ + {"gcode_flavor", "marlin2"}, + {"use_relative_e_distances", relative_e}, + {"retraction_speed", retraction_speed}, + {"retraction_length", "0.8"}, + {"retract_before_wipe", "0%"}, + {"retract_after_wipe", "0%"}, + {"role_based_wipe_speed", "0"}, + {"wipe_speed", "100"}, + {"wipe_distance", "2"}, + }); + const std::string output = slice({make_cube(10., 10., 1.)}, config); + const auto &start_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Wipe_Start); + const auto &end_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Wipe_End); + double before_wipe = 0.; + double during_wipe = 0.; + bool in_wipe = false; + bool complete = false; + GCodeReader parser; + parser.apply_config(config); + parser.parse_buffer(output, [&](GCodeReader &self, const GCodeReader::GCodeLine &line) { + if (complete) + return; + if (line.comment().find(start_tag) != std::string_view::npos) { + in_wipe = true; + } else if (in_wipe && line.comment().find(end_tag) != std::string_view::npos) { + complete = true; + } else if (line.retracting(self)) { + (in_wipe ? during_wipe : before_wipe) -= line.dist_E(self); + } else if (line.extruding(self)) { + before_wipe = 0.; + } + }); + + REQUIRE(complete); + // At 100 mm/s, the 2 mm wipe lasts 0.02 seconds. The remaining part of + // the configured 0.8 mm retraction must be emitted before that wipe. + const double expected_during = std::stod(retraction_speed) * 2. / 100.; + CHECK_THAT(during_wipe, Catch::Matchers::WithinAbs(expected_during, 0.00005)); + CHECK_THAT(before_wipe, Catch::Matchers::WithinAbs(0.8 - expected_during, 0.00005)); +} + +TEST_CASE("Inward wipe respects the minimum travel for retraction and Z hop", "[Wipe][Regression]") +{ + const char *wall_generator = GENERATE("classic", "arachne"); + const char *relative_e = GENERATE("0", "1"); + const char *reduce_crossing_wall = GENERATE("0", "1"); + const char *minimum_travel = GENERATE("5", "0"); + CAPTURE(wall_generator, relative_e, reduce_crossing_wall, minimum_travel); + DynamicPrintConfig config = wipe_config( + wall_generator, true, "50%", "10%", false, "3", "inner-outer-inner wall"); + config.set_deserialize_strict({ + {"gcode_flavor", "marlin2"}, + {"use_relative_e_distances", relative_e}, + {"reduce_crossing_wall", reduce_crossing_wall}, + {"retraction_minimum_travel", minimum_travel}, + {"retract_when_changing_layer", "0"}, + {"use_firmware_retraction", "0"}, + {"retract_before_wipe", "0%"}, + {"retract_after_wipe", "0%"}, + {"retraction_speed", "25.5"}, + {"role_based_wipe_speed", "0"}, + {"wipe_speed", "100"}, + {"z_hop", "0.4"}, + {"retract_lift_above", "0"}, + {"retract_lift_below", "0"}, + }); + config.set_key_value("z_hop_types", new ConfigOptionEnumsGeneric{zhtNormal}); + config.set_key_value("retract_lift_enforce", new ConfigOptionEnumsGeneric{rletAllSurfaces}); + const std::string output = slice({make_cube(10., 10., 1.)}, config); + const auto &role_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Role); + const auto &start_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Wipe_Start); + const auto &end_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Wipe_End); + ExtrusionRole role = erNone; + bool after_outer_wall = false; + bool in_wipe = false; + size_t transitions = 0; + size_t same_layer_transitions = 0; + size_t inward_wipes = 0; + double retraction = 0.; + double lift = 0.; + double outer_z = 0.; + GCodeReader parser; + parser.apply_config(config); + parser.parse_buffer(output, [&](GCodeReader &self, const GCodeReader::GCodeLine &line) { + if (line.comment().find(role_tag) == 0) + role = ExtrusionEntity::string_to_role(line.comment().substr(role_tag.size())); + if (line.comment().find(start_tag) == 0) { + in_wipe = true; + if (after_outer_wall) + ++inward_wipes; + } else if (line.comment().find(end_tag) == 0) { + in_wipe = false; + } + if (line.extruding(self) && line.dist_XY(self) > EPSILON) { + if (role == erExternalPerimeter) { + after_outer_wall = true; + retraction = lift = 0.; + outer_z = line.new_Z(self); + } else if (after_outer_wall) { + REQUIRE(role == erPerimeter); + ++transitions; + const double layer_rise = std::max(0., double(self.z()) - outer_z); + if (layer_rise < EPSILON) + ++same_layer_transitions; + // A 5 mm threshold suppresses retraction across a few wall widths. + // A zero threshold still permits the ordinary retract and lift. + const bool retract = std::stod(minimum_travel) == 0.; + CHECK_THAT(retraction, Catch::Matchers::WithinAbs(retract ? 0.8 : 0., 0.00005)); + // Exclude an ordinary layer change from the accumulated upward motion. + CHECK_THAT(lift - layer_rise, Catch::Matchers::WithinAbs(retract ? 0.4 : 0., 0.001)); + after_outer_wall = false; + } + } else if (after_outer_wall) { + if (line.retracting(self)) + retraction -= line.dist_E(self); + lift += std::max(0., double(line.dist_Z(self))); + if (in_wipe) + CHECK_THAT(line.dist_E(self), Catch::Matchers::WithinAbs(0., 0.00005)); + } + }); + // The 1 mm cube has five 0.2 mm layers: every outer wall must still wipe. + REQUIRE(transitions == 5); + REQUIRE(same_layer_transitions >= 4); + REQUIRE(inward_wipes == transitions); +} + +TEST_CASE("Changing inward wipe settings preserves the sliced geometry", "[Wipe][Regression]") +{ + const char *key = GENERATE("wipe_inward", "wipe_inward_distance"); + DynamicPrintConfig config = wipe_config("classic", false); + Print print; + Model model; + init_print({make_cube(10., 10., 1.)}, print, model, config); + gcode(print); + const PrintObject &object = *print.objects().front(); + REQUIRE(object.is_step_done(posPerimeters)); + REQUIRE(object.is_step_done(posInfill)); + REQUIRE(print.is_step_done(psWipeTower)); + REQUIRE(print.is_step_done(psGCodeExport)); + + DynamicPrintConfig changed = config; + changed.set_deserialize_strict({{key, std::string(key) == "wipe_inward" ? "1" : "75%"}}); + print.apply(model, changed); + + CHECK(print.objects().front()->is_step_done(posPerimeters)); + CHECK(print.objects().front()->is_step_done(posInfill)); + CHECK(print.is_step_done(psWipeTower)); + CHECK_FALSE(print.is_step_done(psGCodeExport)); +} + +TEST_CASE("Retraction and pressure advance calibration suppress inward wipe overrides", "[Wipe][Regression]") +{ + const auto mode = GENERATE(CalibMode::Calib_None, CalibMode::Calib_PA_Tower, + CalibMode::Calib_Auto_PA_Line, CalibMode::Calib_Retraction_tower, + CalibMode::Calib_Flow_Rate); + const char *wall_generator = GENERATE("classic", "arachne"); + const bool per_object = GENERATE(false, true); + INFO("calibration mode: " << int(mode) << ", wall generator: " << wall_generator + << ", per-object override: " << per_object); + + const auto trajectories = [&](bool inward) { + DynamicPrintConfig config = wipe_config(wall_generator, inward && !per_object); + const std::vector> overrides{ + {{"wipe_inward", inward ? "1" : "0"}} + }; + Print print; + Model model; + init_print({make_cube(10., 10., 1.)}, print, model, config, per_object ? &overrides : nullptr); + Calib_Params params; + params.mode = mode; + params.start = 0.2; + params.end = 0.4; + params.step = 0.1; + print.set_calib_params(params); + return wipe_destinations(gcode(print)); + }; + + const auto regular = trajectories(false); + const auto inward = trajectories(true); + REQUIRE_FALSE(regular.empty()); + REQUIRE_FALSE(inward.empty()); + // Other calibration modes and ordinary prints must still honor the option. + const bool should_differ = mode == CalibMode::Calib_None || mode == CalibMode::Calib_Flow_Rate; + CHECK(trajectories_differ(regular, inward) == should_differ); +} + +TEST_CASE("Inactive inward wipe settings preserve the exported trajectory", "[Wipe][Regression]") +{ + const char *wall_generator = GENERATE("classic", "arachne"); + const bool disable_wiping = GENERATE(false, true); + DynamicPrintConfig regular = wipe_config(wall_generator, false); + DynamicPrintConfig inward = wipe_config(wall_generator, true, disable_wiping ? "50%" : "0"); + if (disable_wiping) { + regular.set_deserialize_strict({{"wipe", "0"}}); + inward.set_deserialize_strict({{"wipe", "0"}}); + } + const auto regular_paths = wipe_destinations(slice({make_cube(10., 10., 1.)}, regular)); + const auto inward_paths = wipe_destinations(slice({make_cube(10., 10., 1.)}, inward)); + if (!disable_wiping) + REQUIRE_FALSE(regular_paths.empty()); + CHECK_FALSE(trajectories_differ(regular_paths, inward_paths)); +} + +TEST_CASE("Inward wipe changes the exported trajectory when outer wall width is Auto", "[Wipe][Regression]") +{ + const char *wall_generator = GENERATE("classic", "arachne"); + INFO("wall generator: " << wall_generator); + + const std::vector regular = wipe_destinations( + slice({make_cube(10., 10., 1.)}, wipe_config(wall_generator, false))); + const std::vector inward = wipe_destinations( + slice({make_cube(10., 10., 1.)}, wipe_config(wall_generator, true))); + + REQUIRE_FALSE(regular.empty()); + REQUIRE_FALSE(inward.empty()); + REQUIRE(trajectories_differ(regular, inward)); +} + +TEST_CASE("Inward wipe recognizes an external wall starting on an overhang", "[Wipe][Regression]") +{ + const char *wall_generator = GENERATE("classic", "arachne"); + const bool inward = GENERATE(false, true); + CAPTURE(wall_generator, inward); + const auto config = wipe_config(wall_generator, inward, "50%", "0%", false, + "3", "inner-outer-inner wall", false, "0%", "back"); + Print print; + Model model; + init_print({make_cube(10., 10., 1.)}, print, model, config); + print.process(); + size_t mixed_loops = 0; + const auto mark_overhangs = [&](auto &&self, ExtrusionEntity *entity) -> void { + if (auto *collection = dynamic_cast(entity)) { + for (ExtrusionEntity *child : collection->entities) + self(self, child); + } else if (auto *loop = dynamic_cast(entity); loop && is_external_perimeter(loop->role())) { + // Keep the printed geometry intact and give the back seam overhang + // roles. The front edge remains an ordinary external-wall segment. + ExtrusionPaths paths; + bool has_overhang = false; + bool has_external = false; + for (const ExtrusionPath &source : loop->paths) { + for (size_t i = 1; i < source.polyline.points.size(); ++i) { + ExtrusionPath path = source; + path.polyline.points = {source.polyline.points[i - 1], source.polyline.points[i]}; + const bool overhang = path.polyline.points.front().y() > 0 || path.polyline.points.back().y() > 0; + path.set_extrusion_role(overhang ? erOverhangPerimeter : erExternalPerimeter); + has_overhang |= overhang; + has_external |= !overhang; + paths.push_back(std::move(path)); + } + } + REQUIRE(has_overhang); + REQUIRE(has_external); + loop->paths = std::move(paths); + ++mixed_loops; + } + }; + for (const PrintObject *object : print.objects()) + for (Layer *layer : object->layers()) + for (LayerRegion *region : layer->regions()) + mark_overhangs(mark_overhangs, ®ion->perimeters); + REQUIRE(mixed_loops > 0); + + bool has_inward_wipe = false; + for (const WipeTrajectory &trajectory : wipe_trajectories(gcode(print))) { + if (trajectory.destinations.empty()) + continue; + const Vec2d move = trajectory.destinations.front() - trajectory.start; + if (trajectory.start.x() > 4. && trajectory.start.y() > 4. && move.x() < -0.05 && move.y() < -0.05) + has_inward_wipe = true; + } + CHECK(has_inward_wipe == inward); +} + +TEST_CASE("Inward wipe keeps its offset when seam gap is zero", "[Wipe][Regression]") +{ + const char *wall_generator = GENERATE("classic", "arachne"); + INFO("wall generator: " << wall_generator); + + const std::vector regular = wipe_destinations( + slice({make_cube(10., 10., 1.)}, wipe_config(wall_generator, false, "50%", "0%"))); + const std::vector inward = wipe_destinations( + slice({make_cube(10., 10., 1.)}, wipe_config(wall_generator, true, "50%", "0%"))); + + REQUIRE_FALSE(regular.empty()); + REQUIRE_FALSE(inward.empty()); + REQUIRE(trajectories_differ(regular, inward)); +} + +TEST_CASE("Inward wipe is retained across layers with a back seam", "[Wipe][Regression]") +{ + const char *wall_generator = GENERATE("classic", "arachne"); + INFO("wall generator: " << wall_generator); + + const DynamicPrintConfig inward_config = wipe_config( + wall_generator, true, "50%", "0%", false, "3", "inner-outer-inner wall", false, "0%", "back"); + const std::vector inward = wipe_trajectories(slice({make_cube(27., 27., 1.)}, inward_config)); + + REQUIRE_FALSE(inward.empty()); + std::map inward_wipe_by_layer; + for (const WipeTrajectory &trajectory : inward) { + bool &has_inward_wipe = inward_wipe_by_layer[trajectory.z]; + if (trajectory.destinations.empty()) + continue; + const Vec2d first_move = trajectory.destinations.front() - trajectory.start; + // Orca: a back seam lands on the cube's positive-X/positive-Y corner. + // Its inward wipe must move diagonally away from both external faces. + has_inward_wipe = has_inward_wipe || + (trajectory.start.x() > 13. && trajectory.start.y() > 13. && + first_move.x() < -0.05 && first_move.y() < -0.05); + } + REQUIRE(inward_wipe_by_layer.size() == 5); + for (const auto &[z, has_inward_wipe] : inward_wipe_by_layer) { + INFO("layer Z: " << z); + REQUIRE(has_inward_wipe); + } +} + +TEST_CASE("Literal inward wipe distance is clamped to the outer wall width", "[Wipe][Regression]") +{ + const char *wall_generator = GENERATE("classic", "arachne"); + INFO("wall generator: " << wall_generator); + + const std::vector regular = wipe_destinations( + slice({make_cube(10., 10., 1.)}, wipe_config(wall_generator, false))); + const std::vector full_width = wipe_destinations( + slice({make_cube(10., 10., 1.)}, wipe_config(wall_generator, true, "100%"))); + const std::vector oversized = wipe_destinations( + slice({make_cube(10., 10., 1.)}, wipe_config(wall_generator, true, "2"))); + + REQUIRE_FALSE(full_width.empty()); + REQUIRE(trajectories_differ(regular, full_width)); + REQUIRE(oversized.size() == full_width.size()); + for (size_t i = 0; i < full_width.size(); ++i) + REQUIRE_THAT((oversized[i] - full_width[i]).norm(), Catch::Matchers::WithinAbs(0., 0.01)); +} + +TEST_CASE("Inward wipe is not applied without an adjacent wall", "[Wipe][Regression]") +{ + const char *wall_generator = GENERATE("classic", "arachne"); + INFO("wall generator: " << wall_generator); + + const std::vector regular = wipe_destinations( + slice({make_cube(10., 10., 1.)}, wipe_config(wall_generator, false, "50%", "10%", false, "1"))); + const std::vector inward = wipe_destinations( + slice({make_cube(10., 10., 1.)}, wipe_config(wall_generator, true, "50%", "10%", false, "1"))); + + REQUIRE_FALSE(regular.empty()); + REQUIRE_FALSE(trajectories_differ(regular, inward)); +} + +TEST_CASE("Inward wipe uses an alternate extra wall when the configured wall count is one", "[Wipe][Regression]") +{ + const char *wall_generator = GENERATE("classic", "arachne"); + INFO("wall generator: " << wall_generator); + + const DynamicPrintConfig regular_config = wipe_config( + wall_generator, false, "50%", "10%", false, "1", "inner wall/outer wall", true, "15%"); + const DynamicPrintConfig inward_config = wipe_config( + wall_generator, true, "50%", "10%", false, "1", "inner wall/outer wall", true, "15%"); + const std::vector regular = wipe_destinations( + slice({make_cube(10., 10., 1.)}, regular_config)); + const std::vector inward = wipe_destinations( + slice({make_cube(10., 10., 1.)}, inward_config)); + + REQUIRE_FALSE(regular.empty()); + REQUIRE_FALSE(inward.empty()); + REQUIRE(trajectories_differ(regular, inward)); +} + +TEST_CASE("Inward wipe is not applied before the adjacent wall is printed", "[Wipe][Regression]") +{ + const char *wall_generator = GENERATE("classic", "arachne"); + INFO("wall generator: " << wall_generator); + + const std::vector regular = wipe_destinations( + slice({make_cube(10., 10., 1.)}, wipe_config( + wall_generator, false, "50%", "10%", false, "2", "outer wall/inner wall"))); + const std::vector inward = wipe_destinations( + slice({make_cube(10., 10., 1.)}, wipe_config( + wall_generator, true, "50%", "10%", false, "2", "outer wall/inner wall"))); + + REQUIRE_FALSE(regular.empty()); + REQUIRE_FALSE(trajectories_differ(regular, inward)); +} + +TEST_CASE("Wipe on loops preserves the corner move with inward wipe disabled", "[Wipe][Regression]") +{ + const char *wall_generator = GENERATE("classic", "arachne"); + const char *nozzle_diameter = GENERATE("0.4", "0.8"); + const char *comments = GENERATE("0", "1"); + CAPTURE(comments); + INFO("wall generator: " << wall_generator << ", nozzle diameter: " << nozzle_diameter); + // A closed square gives a 90-degree material-side corner at the seam. + DynamicPrintConfig config = wipe_config(wall_generator, false, "50%", "0", true); + config.set_deserialize_strict({{"nozzle_diameter", nozzle_diameter}, {"seam_position", "nearest"}, + {"gcode_comments", comments}}); + const std::string output = slice({make_cube(10., 10., 1.)}, config); + const auto &role_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Role); + const auto &wipe_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Wipe_Start); + ExtrusionRole role = erNone; + std::vector loop; + bool after_extrusion = false; + size_t moves = 0; + GCodeReader parser; + parser.apply_config(config); + parser.parse_buffer(output, [&](GCodeReader &self, const GCodeReader::GCodeLine &line) { + if (line.comment().find(role_tag) == 0) { + role = ExtrusionEntity::string_to_role(line.comment().substr(role_tag.size())); + loop.clear(); + after_extrusion = false; + } + if (line.comment().find(wipe_tag) == 0) + after_extrusion = false; + if (role != erExternalPerimeter) + return; + if (line.extruding(self) && line.dist_XY(self) > EPSILON) { + if (loop.empty()) + loop.emplace_back(self.x(), self.y()); + loop.emplace_back(line.new_X(self), line.new_Y(self)); + after_extrusion = true; + return; + } + // The loop move is the first non-extruding XY move after the external + // wall and before the reserved wipe marker, regardless of comment text. + if (!after_extrusion || line.dist_XY(self) <= EPSILON) + return; + after_extrusion = false; + + ++moves; + INFO("layer Z: " << self.z()); + REQUIRE(loop.size() >= 4); + const Vec2d seam = loop.front(); + REQUIRE_THAT((loop.back() - seam).norm(), Catch::Matchers::WithinAbs(0., 0.003)); + const Vec2d outgoing = (loop[1] - seam).normalized(); + const Vec2d into_corner = (loop[loop.size() - 2] - seam).normalized(); + REQUIRE_THAT(outgoing.dot(into_corner), Catch::Matchers::WithinAbs(0., 0.01)); + const Vec2d move = Vec2d(line.new_X(self), line.new_Y(self)) - seam; + // The legacy corner move is 20% of the nozzle diameter, turned 30 degrees + // from the outgoing edge into the square. Check both components independently. + const double distance = 0.2 * std::stod(nozzle_diameter); + CHECK_THAT(move.dot(outgoing), Catch::Matchers::WithinAbs(distance * std::sqrt(3.) / 2., 0.003)); + CHECK_THAT(move.dot(into_corner), Catch::Matchers::WithinAbs(distance / 2., 0.003)); + }); + REQUIRE(moves == 5); +} + +TEST_CASE("Inward wipe remains valid after wipe on loops moves the nozzle", "[Wipe][Regression]") +{ + const char *wall_generator = GENERATE("classic", "arachne"); + const char *comments = GENERATE("0", "1"); + CAPTURE(comments); + INFO("wall generator: " << wall_generator); + + DynamicPrintConfig config = wipe_config(wall_generator, false, "50%", "10%", true); + config.set_deserialize_strict({{"gcode_comments", comments}}); + const std::string loop_move = slice({make_cube(10., 10., 1.)}, config); + config.set_deserialize_strict({{"wipe_inward", "1"}}); + const std::string combined = slice({make_cube(10., 10., 1.)}, config); + config.set_deserialize_strict({{"wipe_on_loops", "0"}}); + const std::string inward_only = slice({make_cube(10., 10., 1.)}, config); + + for (const std::string *output : {&loop_move, &combined}) { + INFO("wipe_inward: " << (output == &combined)); + std::map> loop_moves_by_layer; + const auto &role_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Role); + const auto &wipe_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Wipe_Start); + ExtrusionRole role = erNone; + bool after_extrusion = false; + GCodeReader parser; + parser.apply_config(config); + parser.parse_buffer(*output, [&](GCodeReader &self, const GCodeReader::GCodeLine &line) { + if (line.comment().find(role_tag) == 0) { + role = ExtrusionEntity::string_to_role(line.comment().substr(role_tag.size())); + after_extrusion = false; + } + if (line.comment().find(wipe_tag) == 0) + after_extrusion = false; + if (role != erExternalPerimeter || line.dist_XY(self) <= EPSILON) + return; + if (line.extruding(self)) { + after_extrusion = true; + } else if (after_extrusion) { + loop_moves_by_layer[line.new_Z(self)].emplace_back(line.new_X(self), line.new_Y(self)); + after_extrusion = false; + } + }); + + // The 1 mm cube at 0.2 mm layer height has one external loop on each of five layers. + const auto trajectories = wipe_trajectories(*output); + REQUIRE(loop_moves_by_layer.size() == 5); + for (size_t layer = 1; layer <= 5; ++layer) { + const double z = layer * 0.2; + const auto moves = std::find_if(loop_moves_by_layer.begin(), loop_moves_by_layer.end(), + [z](const auto &entry) { return std::abs(entry.first - z) < 0.001; }); + REQUIRE(moves != loop_moves_by_layer.end()); + REQUIRE(moves->second.size() == 1); + const auto wipe = std::find_if(trajectories.begin(), trajectories.end(), [&](const WipeTrajectory &trajectory) { + return std::abs(trajectory.z - z) < 0.001 && + (trajectory.start - moves->second.front()).norm() < 0.001; + }); + REQUIRE(wipe != trajectories.end()); + // The configured 2 mm wipe must be measured from the inward move's + // endpoint, including when wipe_inward is off (set_last_pos regression). + CHECK_THAT(trajectory_length(*wipe), Catch::Matchers::WithinAbs(2., 0.003)); + } + } + + const std::vector combined_trajectories = wipe_trajectories(combined); + const std::vector inward_trajectories = wipe_trajectories(inward_only); + REQUIRE_FALSE(combined_trajectories.empty()); + REQUIRE(combined_trajectories.size() == inward_trajectories.size()); + REQUIRE(trajectories_differ(wipe_destinations(combined), wipe_destinations(loop_move))); + + bool start_changed = false; + for (size_t i = 0; i < combined_trajectories.size(); ++i) { + start_changed = start_changed || + (combined_trajectories[i].start - inward_trajectories[i].start).norm() > 0.01; + REQUIRE_THAT(trajectory_length(combined_trajectories[i]), + Catch::Matchers::WithinAbs(trajectory_length(inward_trajectories[i]), 0.01)); + } + REQUIRE(start_changed); +} diff --git a/tests/libslic3r/CMakeLists.txt b/tests/libslic3r/CMakeLists.txt index 2f859f46fe..bc5a0a1e80 100644 --- a/tests/libslic3r/CMakeLists.txt +++ b/tests/libslic3r/CMakeLists.txt @@ -43,6 +43,7 @@ add_executable(${_TEST_NAME}_tests test_voronoi.cpp test_wipe_tower_estimate.cpp test_wipe_tower.cpp + test_wipe_path.cpp test_optimizers.cpp test_ordering_strategies.cpp # test_png_io.cpp diff --git a/tests/libslic3r/test_wipe_path.cpp b/tests/libslic3r/test_wipe_path.cpp new file mode 100644 index 0000000000..b637ad2b87 --- /dev/null +++ b/tests/libslic3r/test_wipe_path.cpp @@ -0,0 +1,1024 @@ +#include + +#include "libslic3r/GCode/WipePathHelpers.hpp" +#include "libslic3r/AABBTreeLines.hpp" +#include "libslic3r/Polyline.hpp" +#include "libslic3r/Point.hpp" +#include "libslic3r/Line.hpp" +#include "libslic3r/libslic3r.h" + +#include +#include +#include + +using namespace Slic3r; +using Slic3r::AABBTreeLines::LinesDistancer; + +TEST_CASE("Stored wipe path retains its length around a curved wall after a seam gap", "[WipePath][Regression]") +{ + const int mirror = GENERATE(1, -1); + const double wipe_length = GENERATE(0.8, 1.0); + CAPTURE(mirror, wipe_length); + // A 0.02 mm seam gap on a curved 0.24 mm wall leaves a short outgoing + // segment whose inward offset backtracks. Coordinates use internal scaling + // from the affected loop; the adjacent inner wall is already printed. + const auto point = [mirror](coord_t x, coord_t y) { return Point(mirror * x, y); }; + const Polyline original{ + point(671861, 7772276), point(688586, 7765098), point(781082, 7687014), + point(852059, 7608861), point(889773, 7556912), point(958919, 7382963), + point(977048, 7259018), point(977325, 7173839), point(944250, 7039370), + point(911230, 6952087), point(880323, 6894944), point(760243, 6763860), + point(598587, 6641719), point(492626, 6593610), point(362533, 6543938), + point(170173, 6513482), point(114917, 6509380), point(18418, 6513666), + point(-145550, 6537251), point(-259087, 6580797), point(-413987, 6690495), + point(-485220, 6767022), point(-561189, 6893573), point(-576897, 6965717), + point(-595201, 7089303), point(-597977, 7164172), point(-590614, 7239553), + point(-574031, 7305533), point(-539668, 7383293), point(-442552, 7550465), + point(-332173, 7659644), point(-257819, 7717153), point(-209522, 7749563), + point(-121695, 7793438), point(399, 7844160), point(228137, 7880068), + point(363721, 7881585), point(431909, 7865985), point(569648, 7816149), + point(653482, 7780164), + }; + const Polyline inner{ + point(739251, 7241332), point(739377, 7202281), point(716708, 7110113), + point(694416, 7051190), point(685088, 7033943), point(599527, 6940541), + point(476243, 6847393), point(400952, 6813209), point(300851, 6774988), + point(142724, 6749952), point(111379, 6747625), point(40681, 6750765), + point(-85281, 6768883), point(-146010, 6792175), point(-256555, 6870462), + point(-295251, 6912034), point(-336173, 6978125), point(-357996, 7111200), + point(-359693, 7156985), point(-355612, 7198777), point(-348288, 7227916), + point(-327412, 7275156), point(-252784, 7403618), point(-175201, 7480358), + point(-89616, 7543582), point(-22802, 7576960), point(65459, 7613627), + point(248096, 7642423), point(338175, 7643431), point(364673, 7637369), + point(482204, 7594844), point(562221, 7560499), point(615604, 7515433), + point(679764, 7441323), point(727601, 7320981), point(739251, 7241332), + }; + const Point seam_start = original.first_point(); + const Point seam_end = original.last_point(); + const double offset = scale_(0.239999); + Polyline forward = original; + REQUIRE_FALSE(offset_wipe_path(forward, seam_start, seam_end, seam_end, + -mirror, offset, scale_(wipe_length))); + + Polyline path = original; + REQUIRE(offset_wipe_path_toward_support(path, seam_start, seam_end, seam_end, + -mirror, offset, scale_(wipe_length), inner.lines(), inner.lines(), original.lines(), offset)); + REQUIRE(path.first_point() == seam_start); + REQUIRE(path.points.size() > 2); + // Wipe::wipe replaces the sentinel with the actual extrusion endpoint. + path.points.front() = seam_end; + CHECK_THAT(unscale_(path.length()), Catch::Matchers::WithinAbs(wipe_length, 0.0004)); + CHECK(mirror * (path.points[1].x() - seam_end.x()) < 0); + CHECK(path.points[1].y() < seam_end.y()); + Lines support = inner.lines(); + const Lines current = original.lines(); + support.insert(support.end(), current.begin(), current.end()); + REQUIRE(wipe_path_support_score(path, seam_end, LinesDistancer(inner.lines()), + LinesDistancer(support), offset).has_value()); +} + +TEST_CASE("Stored wipe path retains its length after a loop pre-move at a curved seam", "[WipePath][Regression]") +{ + const int mirror = GENERATE(1, -1); + const bool pre_move = GENERATE(false, true); + CAPTURE(mirror, pre_move); + const auto point = [mirror](coord_t x, coord_t y) { return Point(mirror * x, y); }; + // A 0.02 mm seam gap on a curved 0.24 mm wall, with the adjacent inner + // wall already printed. The loop pre-move advances the nozzle near the seam. + const Polyline original{ + point(686772, 7813199), point(516334, 7887188), point(411017, 7915098), + point(346190, 7926015), point(246957, 7925330), point(-16945, 7881611), + point(-116443, 7842513), point(-255907, 7773886), point(-378126, 7681053), + point(-499258, 7552879), point(-574414, 7438250), point(-613558, 7370259), + point(-626313, 7341394), point(-650774, 7263424), point(-669964, 7169919), + point(-666798, 7058662), point(-631336, 6876547), point(-624410, 6852946), + point(-577832, 6762704), point(-517493, 6693143), point(-455794, 6631765), + point(-315549, 6531304), point(-169627, 6468424), point(-1908, 6443726), + point(143562, 6438395), point(314277, 6465470), point(380448, 6480795), + point(519922, 6526617), point(673990, 6611110), point(801581, 6705610), + point(927505, 6840928), point(969616, 6912718), point(1004269, 7009155), + point(1044144, 7171737), point(1046617, 7228598), point(1028598, 7358360), + point(950280, 7560914), point(867133, 7675444), point(732946, 7788889), + point(706168, 7804780), point(705118, 7805235), + }; + const Polyline inner{ + point(808905, 7211140), point(796801, 7298317), point(739603, 7446245), + point(691575, 7512401), point(595745, 7593416), point(438069, 7661865), + point(360694, 7682370), point(327119, 7688024), point(266586, 7687607), + point(53302, 7653657), point(-20276, 7624745), point(-130307, 7570601), + point(-218687, 7503470), point(-311907, 7404832), point(-371722, 7313599), + point(-401113, 7262547), point(-420207, 7203763), point(-431422, 7149118), + point(-429596, 7084952), point(-401187, 6939055), point(-379519, 6897073), + point(-343546, 6855603), point(-301665, 6813940), point(-197879, 6739595), + point(-104131, 6699197), point(19838, 6680942), point(129154, 6676936), + point(268758, 6699077), point(316366, 6710103), point(424812, 6745731), + point(545430, 6811879), point(642396, 6883697), point(735572, 6983824), + point(753259, 7013976), point(776223, 7077887), point(808905, 7211140), + }; + const Point seam_start = original.first_point(); + const Point seam_end = original.last_point(); + const Point wipe_start = pre_move ? point(652751, 7792162) : seam_end; + const double offset = scale_(0.239999); + Polyline path = original; + REQUIRE(offset_wipe_path_toward_support(path, seam_start, seam_end, wipe_start, + mirror, offset, scale_(0.8), inner.lines(), inner.lines(), original.lines(), offset)); + REQUIRE(path.first_point() == seam_start); + path.points.front() = wipe_start; + CHECK_THAT(unscale_(path.length()), Catch::Matchers::WithinAbs(0.8, 0.0004)); + Lines support = inner.lines(); + const Lines current = original.lines(); + support.insert(support.end(), current.begin(), current.end()); + REQUIRE(wipe_path_support_score(path, wipe_start, LinesDistancer(inner.lines()), + LinesDistancer(support), offset).has_value()); +} + +// Orca: helpers for constructing the extrusion geometry used by wipe tests. + +static ExtrusionPath make_path(const std::vector &pts, ExtrusionRole role = erExternalPerimeter, + float width = 0.4f, float height = 0.2f) +{ + ExtrusionPath p(role, 0.5, width, height); + for (const Point &pt : pts) + p.polyline.append(Point3(pt.x(), pt.y(), coord_t(0))); + return p; +} + +static ExtrusionPaths make_paths(const std::vector &pts, ExtrusionRole role = erExternalPerimeter, + float width = 0.4f) +{ + ExtrusionPaths paths; + paths.push_back(make_path(pts, role, width)); + return paths; +} + +TEST_CASE("Inward wipe support recognizes an inner wall starting on an overhang", "[WipePath][Regression]") +{ + const bool overhang_first = GENERATE(false, true); + const auto point = [](double x, double y) { return Point::new_scale(x, y); }; + ExtrusionPaths paths{ + make_path({point(0.4, 0.4), point(0.4, 2.)}, erOverhangPerimeter), + make_path({point(0.4, 2.), point(0.4, 9.6), point(5.6, 9.6), point(5.6, 0.4), point(0.4, 0.4)}, erPerimeter) + }; + if (!overhang_first) + std::rotate(paths.begin(), paths.begin() + 1, paths.end()); + const ExtrusionLoop inner(paths); + REQUIRE(inner.role() == (overhang_first ? erOverhangPerimeter : erPerimeter)); + + WipeInwardSupport support; + support.append(inner); + REQUIRE(support.inner_lines.size() == inner.as_polyline().lines().size()); + // The overhanging portion itself is already printed and can support the wipe. + const LinesDistancer inner_distancer(support.inner_lines); + CHECK_THAT(inner_distancer.distance_from_lines(point(0.4, 1.)), + Catch::Matchers::WithinAbs(0., SCALED_EPSILON)); + const Polyline original{point(0., 0.), point(0., 10.), point(6., 10.), point(6., 0.), point(0., 0.)}; + Polyline wipe = original; + REQUIRE(offset_wipe_path_toward_support(wipe, original.first_point(), original.first_point(), + original.first_point(), -1, scale_(0.2), scale_(2.), support.inner_lines, + support.printed_lines, original.lines(), scale_(0.6))); + CHECK(wipe.points[1].x() > original.first_point().x()); +} + +TEST_CASE("Inward wipe support accumulates earlier walls without treating outer walls as targets", "[WipePath][Regression]") +{ + const auto point = [](double x, double y) { return Point::new_scale(x, y); }; + WipeInwardSupport support; + const ExtrusionPath inner = make_path({point(0.4, 0.), point(0.4, 5.)}, erPerimeter); + support.append(inner); + const ExtrusionLoop outer(ExtrusionPaths{ + make_path({point(0., 0.), point(0., 5.)}, erOverhangPerimeter), + make_path({point(0., 5.), point(-5., 5.), point(-5., 0.), point(0., 0.)}) + }); + support.append(outer); + REQUIRE(support.inner_lines.size() == 1); + REQUIRE(support.printed_lines.size() == 5); + const LinesDistancer targets(support.inner_lines); + CHECK_THAT(targets.distance_from_lines(point(0., 2.)), + Catch::Matchers::WithinAbs(scale_(0.4), SCALED_EPSILON)); +} + +static ExtrusionPaths make_loop_paths(const std::vector &contour_pts, float width = 0.4f) +{ + ExtrusionPaths paths; + size_t mid = contour_pts.size() / 2; + ExtrusionPath first(erExternalPerimeter, 0.5, width, 0.2f); + for (size_t i = 0; i <= mid; ++i) + first.polyline.append(Point3(contour_pts[i].x(), contour_pts[i].y(), coord_t(0))); + ExtrusionPath second(erExternalPerimeter, 0.5, width, 0.2f); + for (size_t i = mid; i < contour_pts.size(); ++i) + second.polyline.append(Point3(contour_pts[i].x(), contour_pts[i].y(), coord_t(0))); + second.polyline.append(Point3(contour_pts[0].x(), contour_pts[0].y(), coord_t(0))); + paths.push_back(std::move(first)); + paths.push_back(std::move(second)); + return paths; +} + +// Orca: sample_path_at_distance coverage. + +TEST_CASE("sample_path_at_distance forward returns start for zero target", "[WipePath]") +{ + const coord_t s = scale_(1.0); + auto paths = make_paths({Point(0, 0), Point(100 * s, 0), Point(100 * s, 100 * s)}); + REQUIRE(sample_path_at_distance(paths, true, 0.0) == Point(0, 0)); +} + +TEST_CASE("sample_path_at_distance forward samples along path", "[WipePath]") +{ + const coord_t s = scale_(1.0); + auto paths = make_paths({Point(0, 0), Point(100 * s, 0), Point(100 * s, 100 * s)}); + + Point result = sample_path_at_distance(paths, true, 50 * s); + REQUIRE_THAT(result.x(), Catch::Matchers::WithinAbs(50 * s, 2)); + REQUIRE_THAT(result.y(), Catch::Matchers::WithinAbs(0, 2)); +} + +TEST_CASE("sample_path_at_distance forward crosses segment boundary", "[WipePath]") +{ + const coord_t s = scale_(1.0); + auto paths = make_paths({Point(0, 0), Point(100 * s, 0), Point(100 * s, 100 * s)}); + + Point result = sample_path_at_distance(paths, true, 150 * s); + REQUIRE_THAT(result.x(), Catch::Matchers::WithinAbs(100 * s, 2)); + REQUIRE_THAT(result.y(), Catch::Matchers::WithinAbs(50 * s, 2)); +} + +TEST_CASE("sample_path_at_distance backward from end", "[WipePath]") +{ + const coord_t s = scale_(1.0); + auto paths = make_paths({Point(0, 0), Point(100 * s, 0), Point(100 * s, 100 * s)}); + + Point result = sample_path_at_distance(paths, false, 50 * s); + REQUIRE_THAT(result.x(), Catch::Matchers::WithinAbs(100 * s, 2)); + REQUIRE_THAT(result.y(), Catch::Matchers::WithinAbs(50 * s, 2)); +} + +TEST_CASE("sample_path_at_distance on short path returns reachable point", "[WipePath]") +{ + const coord_t s = scale_(1.0); + auto paths = make_paths({Point(0, 0), Point(10 * s, 0)}); + + Point result = sample_path_at_distance(paths, true, 1000 * s); + REQUIRE(result == Point(10 * s, 0)); +} + +TEST_CASE("sample_path_at_distance on zero-length path returns start", "[WipePath]") +{ + const coord_t s = scale_(1.0); + auto paths = make_paths({Point(50 * s, 50 * s)}); + + REQUIRE(sample_path_at_distance(paths, true, 100 * s) == Point(50 * s, 50 * s)); + REQUIRE(sample_path_at_distance(paths, false, 100 * s) == Point(50 * s, 50 * s)); +} + +TEST_CASE("Wipe offset direction follows the material side", "[WipePath]") +{ + REQUIRE(wipe_offset_direction(true, false) == +1); + REQUIRE(wipe_offset_direction(false, false) == -1); + REQUIRE(wipe_offset_direction(true, true) == -1); + REQUIRE(wipe_offset_direction(false, true) == +1); +} + +TEST_CASE("Stored wipe path leaves source crossings to support validation", "[WipePath]") +{ + const coord_t s = scale_(1.0); + Polyline path{Point(10 * s, 0), Point(100 * s, 0), Point(coord_t(13.4 * s), coord_t(50 * s))}; + + // Orca: crossing the just-printed wall is harmless for a non-extruding wipe. + // The caller decides whether the result is supported by printed geometry. + REQUIRE(offset_wipe_path(path, Point(10 * s, 0), Point(0, 0), Point(0, 0), +1, 5 * s, 1000 * s)); +} + +TEST_CASE("Stored wipe path builds the join after a nonzero seam gap", "[WipePath]") +{ + const coord_t s = scale_(1.0); + Polyline path{Point(10 * s, 0), Point(10 * s, 0), Point(10 * s, 100 * s)}; + + REQUIRE(offset_wipe_path(path, Point(10 * s, 0), Point(0, 0), Point(0, 0), +1, 5 * s, 1000 * s)); + REQUIRE(path.points.size() == 3); + REQUIRE(path.points[1] == Point(5 * s, 5 * s)); + REQUIRE(path.points[2] == Point(5 * s, 100 * s)); + REQUIRE(path.fitting_result.size() == 1); + REQUIRE(path.fitting_result.front().end_point_index == path.points.size() - 1); +} + +TEST_CASE("Stored wipe path rejects an offset seam join that turns backward", "[WipePath][Regression]") +{ + const coord_t s = scale_(1.0); + const Point seam_start(s, s); + const Point seam_end(0, 0); + Polyline path{seam_start, Point(s, -10 * s), Point(s, -20 * s)}; + const Polyline original = path; + + REQUIRE_FALSE(offset_wipe_path(path, seam_start, seam_end, seam_end, +1, s, 5 * s)); + REQUIRE(path.points == original.points); +} + +TEST_CASE("Stored wipe path continues after an inward pre-move", "[WipePath][Regression]") +{ + const coord_t s = scale_(1.0); + const Point seam_start(s, s); + const Point seam_end(0, 0); + const Point wipe_start(2 * s, 2 * s); + Polyline path{seam_start, Point(s, -10 * s), Point(s, -20 * s)}; + + REQUIRE(offset_wipe_path(path, seam_start, seam_end, wipe_start, +1, s, 5 * s)); + REQUIRE(path.points.size() >= 3); + path.points.front() = wipe_start; // Orca: reproduce Wipe::wipe()'s executable representation. + CHECK_THAT(path.length(), Catch::Matchers::WithinAbs(5. * s, 2.)); +} + +TEST_CASE("Stored wipe path does not retrace a translated seam gap", "[WipePath][Regression]") +{ + const coord_t s = scale_(1.0); + const Point seam_start(s, 0); + const Point seam_end(0, 0); + Polyline path{seam_start, seam_end, Point(-10 * s, 0)}; + const Polyline original = path; + const Lines support{Line(Point(-10 * s, s), Point(10 * s, s))}; + + // Orca: the exact reversal at seam_start forces the translated fallback. + // The seam gap supplies its incoming direction but must not become an + // inward-outward-inward detour in the executable path. + REQUIRE(offset_wipe_path_toward_support( + path, seam_start, seam_end, seam_end, +1, s, 5 * s, + support, support, original.lines(), s)); + REQUIRE(path.points.size() == 2); + CHECK(path.points[1].y() > seam_end.y()); +} + +TEST_CASE("Stored wipe path keeps its first offset point when seam gap is zero", "[WipePath]") +{ + const coord_t s = scale_(1.0); + Polyline path{Point(0, 0), Point(100 * s, 0), Point(100 * s, 100 * s), + Point(0, 100 * s), Point(0, 0)}; + + REQUIRE(offset_wipe_path(path, Point(0, 0), Point(0, 0), Point(0, 0), +1, 5 * s, 20 * s)); + REQUIRE(path.points.size() >= 3); + REQUIRE(path.points[0] == Point(0, 0)); + REQUIRE_THAT(path.points[1].x(), Catch::Matchers::WithinAbs(5 * s, 2)); + REQUIRE_THAT(path.points[1].y(), Catch::Matchers::WithinAbs(5 * s, 2)); +} + +TEST_CASE("Stored wipe path ignores unsafe geometry beyond the used prefix", "[WipePath]") +{ + const coord_t s = scale_(1.0); + Polyline path{Point(0, 0), Point(1000 * s, 0), Point(1000 * s, 20 * s), + Point(900 * s, 20 * s), Point(0, 20 * s), Point(0, 0)}; + + REQUIRE(offset_wipe_path(path, Point(0, 0), Point(0, 0), Point(0, 0), +1, 30 * s, 10 * s)); + REQUIRE(path.points.size() == 2); + REQUIRE_THAT(path.length(), Catch::Matchers::WithinAbs(10 * s, 2)); +} + +TEST_CASE("Stored wipe path grows its source until the offset reaches the requested length", "[WipePath]") +{ + const coord_t s = scale_(1.0); + Polyline path{Point(0, 0), Point(100 * s, 0), Point(100 * s, 100 * s), Point(0, 100 * s)}; + const double wipe_length = 250 * s; + + // Orca: two inward corners shorten this offset by more than 2 * offset_dist. + REQUIRE(offset_wipe_path(path, Point(0, 0), Point(0, 0), Point(0, 0), + +1, 10 * s, wipe_length)); + REQUIRE_THAT(path.length(), Catch::Matchers::WithinAbs(wipe_length, 2)); +} + +TEST_CASE("Stored wipe path is unchanged when wipe distance is zero", "[WipePath]") +{ + const coord_t s = scale_(1.0); + Polyline path{Point(0, 0), Point(100 * s, 0)}; + const Polyline orig = path; + + REQUIRE_FALSE(offset_wipe_path(path, Point(0, 0), Point(0, 0), Point(0, 0), +1, 5 * s, 0)); + REQUIRE(path.points == orig.points); +} + +TEST_CASE("Stored wipe path defers actual-start crossings to support validation", "[WipePath]") +{ + const coord_t s = scale_(1.0); + Polyline path{Point(0, 0), Point(100 * s, 0), Point(100 * s, 100 * s), + Point(0, 100 * s), Point(0, 0)}; + const Lines current = path.lines(); + const Lines remote{Line(Point(0, 50 * s), Point(100 * s, 50 * s))}; + const Point wipe_start(50 * s, -10 * s); + + REQUIRE(offset_wipe_path(path, Point(0, 0), Point(0, 0), wipe_start, +1, 5 * s, 100 * s)); + Lines all_support = remote; + all_support.insert(all_support.end(), current.begin(), current.end()); + REQUIRE_FALSE(wipe_path_support_score(path, wipe_start, + LinesDistancer(remote), LinesDistancer(all_support), 5 * s).has_value()); +} + +TEST_CASE("Stored wipe path keeps the closing join when its prefix ends at the closing vertex", "[WipePath]") +{ + const coord_t s = scale_(1.0); + Polyline path{Point(0, 0), Point(0, 100 * s), Point(100 * s, 100 * s), + Point(100 * s, 0), Point(0, 0)}; + + REQUIRE(offset_wipe_path(path, Point(0, 0), Point(0, 0), Point(0, 0), + +1, 5 * s, 300 * s)); + REQUIRE(path.points.size() >= 2); + REQUIRE(path.points[1] == Point(-5 * s, -5 * s)); +} + +TEST_CASE("Stored wipe path rejects a two-point zero-gap loop", "[WipePath]") +{ + const coord_t s = scale_(1.0); + Polyline path{Point(0, 0), Point(100 * s, 0), Point(0, 0)}; + const Polyline original = path; + + REQUIRE_FALSE(offset_wipe_path(path, Point(0, 0), Point(0, 0), Point(0, 0), + +1, 5 * s, 100 * s)); + REQUIRE(path.points == original.points); +} + +TEST_CASE("Stored wipe path tolerates quantized contact at its actual start", "[WipePath]") +{ + const coord_t s = scale_(1.0); + const coord_t quantization = coord_t(SCALED_EPSILON / 2); + Polyline path{Point(0, quantization), Point(100 * s, quantization), + Point(100 * s, 100 * s + quantization), Point(0, 100 * s + quantization), + Point(0, quantization)}; + + // Orca: the executable transition starts within the geometry epsilon of the + // source endpoint. Treat this as the allowed start contact, while contacts + // farther along the transition remain unsafe. + REQUIRE(offset_wipe_path(path, Point(0, quantization), Point(0, quantization), + Point(0, 0), +1, 5 * s, 20 * s)); +} + +TEST_CASE("Stored wipe path requires nearby generated perimeter geometry", "[WipePath]") +{ + const coord_t s = scale_(1.0); + const Polyline path{Point(0, 0), Point(0, 2 * s), Point(10 * s, 2 * s)}; + const Lines adjacent{Line(Point(0, 4 * s), Point(10 * s, 4 * s))}; + const Lines remote{Line(Point(0, 20 * s), Point(10 * s, 20 * s))}; + const Lines current = path.lines(); + + const LinesDistancer adjacent_distancer(adjacent); + const LinesDistancer remote_distancer(remote); + Lines all_support = remote; + all_support.insert(all_support.end(), current.begin(), current.end()); + const LinesDistancer all_support_distancer(all_support); + + const auto score = wipe_path_support_score(path, Point(0, 2 * s), adjacent_distancer, adjacent_distancer, 3 * s); + REQUIRE(score.has_value()); + CHECK_THAT(*score, Catch::Matchers::WithinAbs(2. * s, 2.)); + REQUIRE_FALSE(wipe_path_support_score(path, Point(0, 2 * s), adjacent_distancer, adjacent_distancer, 0).has_value()); + REQUIRE_FALSE(wipe_path_support_score(path, Point(0, 2 * s), remote_distancer, remote_distancer, 3 * s).has_value()); + REQUIRE_FALSE(wipe_path_support_score(path, Point(0, 2 * s), remote_distancer, all_support_distancer, 3 * s).has_value()); + REQUIRE_FALSE(wipe_path_support_score(path, Point(0, 2 * s), LinesDistancer(Lines{}), + all_support_distancer, 3 * s).has_value()); +} + +TEST_CASE("Stored wipe path checks the first segment from its actual start", "[WipePath]") +{ + const coord_t s = scale_(1.0); + const Polyline path{Point(0, 0), Point(10 * s, 0)}; + const Lines support_near_ends{ + Line(Point(0, -s), Point(0, s)), + Line(Point(10 * s, -s), Point(10 * s, s)) + }; + + // Orca: both endpoints are supported, but the middle of the executable segment + // from wipe_start is not. The dummy path[0] must not hide that segment. + const LinesDistancer support_distancer(support_near_ends); + REQUIRE_FALSE(wipe_path_support_score(path, Point(0, 0), support_distancer, support_distancer, 2 * s).has_value()); +} + +TEST_CASE("Stored wipe path rejects unsupported gaps between nearby samples", "[WipePath][Regression]") +{ + const Point start = Point::new_scale(0., 0.); + const Point end = Point::new_scale(0.8, 0.); + const Polyline path{start, end}; + const double support_y = GENERATE(0.8, 0.95); + const Lines support{ + Line(Point::new_scale(0., support_y), Point::new_scale(0., 2.)), + Line(Point::new_scale(0.8, support_y), Point::new_scale(0.8, 2.)) + }; + + // Both endpoints are within 1 mm of support and the move is shorter than + // the old sampling interval. Only the 0.8 mm case supports its midpoint. + const LinesDistancer support_distancer(support); + const bool supported = wipe_path_support_score(path, start, support_distancer, support_distancer, scale_(1.)).has_value(); + CHECK(supported == (support_y < 0.9)); +} + +TEST_CASE("Stored wipe path checks support at the actual nozzle position", "[WipePath][Regression]") +{ + const Point end = Point::new_scale(0., 0.); + const Polyline path{end, end}; + const Lines support{Line(Point::new_scale(-1., 0.), Point::new_scale(1., 0.))}; + + const LinesDistancer support_distancer(support); + REQUIRE_FALSE(wipe_path_support_score(path, Point::new_scale(0., -2.), + support_distancer, support_distancer, scale_(1.)).has_value()); +} + +TEST_CASE("Direct inward fallback respects a short wipe distance before validation", "[WipePath][Regression]") +{ + const Point seam = Point::new_scale(0., 0.); + Polyline path{seam, Point::new_scale(10., 0.), Point::new_scale(10., 10.), + Point::new_scale(0., 10.), seam}; + const Lines current = path.lines(); + const Lines support{Line(Point::new_scale(0.4, 0.4), Point::new_scale(9.6, 0.4))}; + const bool pre_move = GENERATE(false, true); + const Point wipe_start = pre_move ? Point::new_scale(0.02, 0.02) : seam; + const double wipe_length = scale_(0.05); + + REQUIRE(offset_wipe_path_toward_support( + path, seam, seam, wipe_start, +1, scale_(0.2), wipe_length, + support, support, current, scale_(0.4))); + REQUIRE(path.points.size() == 2); + path.points.front() = wipe_start; + CHECK_THAT(path.length(), Catch::Matchers::WithinAbs(wipe_length, 2.)); + CHECK(path.last_point().x() > wipe_start.x()); + CHECK(path.last_point().y() > wipe_start.y()); +} + +TEST_CASE("Stored wipe path uses a stable zero-gap join for nearly parallel segments", "[WipePath][Regression]") +{ + const auto point = [](double x, double y) { return Point::new_scale(x, y); }; + const Point seam = point(58.777, 61.985); + Polyline path{ + seam, point(58.822, 61.918), point(58.900, 61.789), point(58.980, 61.641), + point(59.054, 61.480), point(59.260, 60.980), point(58.412, 62.485), + point(58.631, 62.202), seam, + }; + + REQUIRE(offset_wipe_path(path, seam, seam, seam, -1, scale_(0.23), scale_(0.8))); + REQUIRE(path.points.size() >= 3); + + const Vec2d first = (path.points[1] - seam).cast(); + const Vec2d second = (path.points[2] - path.points[1]).cast(); + CHECK(first.dot(second) >= 0.); +} + +TEST_CASE("Stored wipe path follows the inner wall at a narrow external cusp", "[WipePath][Regression]") +{ + const auto point = [](double x, double y) { return Point::new_scale(x, y); }; + const Point seam = point(55.139, 60.077); + Polyline path{ + seam, point(55.156, 60.010), point(55.205, 59.961), point(55.237, 59.934), + point(55.304, 59.907), point(55.392, 59.872), point(55.630, 59.791), + point(56.564, 59.430), point(55.061, 59.956), point(55.108, 60.008), seam, + }; + const Polyline original = path; + const Lines target_support{ + Line(point(54.983, 59.648), point(55.121, 59.745)), + }; + Lines printed_support = target_support; + printed_support.emplace_back(point(54.75, 60.25), point(55.50, 60.10)); + + REQUIRE(offset_wipe_path_toward_support( + path, seam, seam, seam, -1, scale_(0.270341), scale_(0.8), + target_support, printed_support, original.lines(), scale_(0.4))); + REQUIRE(path.points.size() >= 2); + CHECK(path.points[1].y() < seam.y() - scale_(0.2)); + CHECK(std::abs(path.points[1].x() - seam.x()) < scale_(0.1)); +} + +TEST_CASE("Stored wipe path keeps a supported zero-gap join that initially backtracks", "[WipePath][Regression]") +{ + const auto point = [](double x, double y) { return Point::new_scale(x, y); }; + const Point seam = point(56.737, 62.049); + Polyline path{ + seam, point(56.759, 62.142), point(56.727, 62.294), point(56.682, 62.447), + point(56.631, 62.570), point(56.581, 62.669), point(56.512, 62.776), + point(54.0, 64.0), point(50.0, 60.0), point(54.0, 58.0), + point(56.773, 62.031), seam, + }; + const Polyline original = path; + const Lines target_support{ + Line(point(56.546, 62.012), point(56.534, 62.104)), + Line(point(56.534, 62.104), point(56.506, 62.238)), + Line(point(56.506, 62.238), point(56.467, 62.371)), + Line(point(56.467, 62.371), point(56.424, 62.474)), + Line(point(56.424, 62.474), point(56.382, 62.556)), + }; + + Polyline inward = path; + REQUIRE(offset_wipe_path(inward, seam, seam, seam, +1, scale_(0.23), scale_(0.8))); + REQUIRE(inward.points.size() >= 3); + const Vec2d connector = (inward.points[1] - seam).cast(); + const Vec2d outgoing = (inward.points[2] - inward.points[1]).cast(); + REQUIRE(connector.dot(outgoing) < 0.); + + REQUIRE(offset_wipe_path_toward_support( + path, seam, seam, seam, +1, scale_(0.23), scale_(0.8), + target_support, target_support, original.lines(), scale_(0.4))); + CHECK(path.points[1].x() < seam.x() - scale_(0.1)); +} + +TEST_CASE("Stored wipe path leaves a narrow cusp directly after a seam gap", "[WipePath][Regression]") +{ + const auto point = [](double x, double y) { return Point::new_scale(x, y); }; + const Point seam_start = point(55.139, 60.077); + const Point seam_end = point(55.141, 60.067); + Polyline path{ + seam_start, point(55.107, 60.008), point(55.061, 59.956), point(55.027, 59.943), + point(54.982, 59.924), point(54.922, 59.879), point(54.868, 59.845), + point(54.754, 59.783), point(54.391, 59.635), point(54.053, 59.471), + }; + const Polyline original = path; + const Lines target_support{ + Line(point(55.132, 59.744), point(55.121, 59.745)), + Line(point(55.121, 59.745), point(54.983, 59.648)), + Line(point(54.983, 59.648), point(54.938, 59.623)), + Line(point(54.938, 59.623), point(54.866, 59.584)), + Line(point(54.866, 59.584), point(54.483, 59.427)), + Line(point(54.483, 59.427), point(54.157, 59.268)), + }; + + REQUIRE(offset_wipe_path_toward_support( + path, seam_start, seam_end, seam_end, -1, scale_(0.270341), scale_(0.8), + target_support, target_support, original.lines(), scale_(0.4))); + REQUIRE(path.points.size() >= 2); + CHECK(path.points[1].y() < seam_end.y() - scale_(0.2)); + CHECK(std::abs(path.points[1].x() - seam_end.x()) < scale_(0.05)); + + // Orca: the inward connector must not run back through the first extruded + // point after the gap, which would put the wipe on the external wall. + const Line connector(seam_end, path.points[1]); + CHECK(connector.distance_to(original.points[1]) > scale_(0.02)); +} + +TEST_CASE("Stored wipe path does not reverse after an inward pre-move at a wide gap", "[WipePath][Regression]") +{ + const auto point = [](double x, double y) { return Point::new_scale(x, y); }; + const Point seam_start = point(55.139, 60.077); + const Point seam_end = point(55.163, 60.002); + const Point wipe_start = point(55.142, 60.037); + Polyline path{ + seam_start, point(55.107, 60.008), point(55.061, 59.956), point(55.027, 59.943), + point(54.982, 59.924), point(54.922, 59.879), point(54.868, 59.845), + point(54.754, 59.783), point(54.391, 59.635), point(54.053, 59.471), + point(50.2, 55.0), point(50.2, 50.0), point(60.8, 50.0), point(60.8, 55.0), + point(56.564, 59.430), point(55.824, 59.708), point(55.392, 59.872), + point(55.237, 59.934), point(55.205, 59.961), seam_end, + }; + const Polyline original = path; + const Lines target_support{ + Line(point(55.132, 59.744), point(55.121, 59.745)), + Line(point(55.121, 59.745), point(54.983, 59.648)), + Line(point(54.983, 59.648), point(54.866, 59.584)), + Line(point(54.866, 59.584), point(54.483, 59.427)), + Line(point(54.483, 59.427), point(54.157, 59.268)), + }; + + REQUIRE(offset_wipe_path_toward_support( + path, seam_start, seam_end, wipe_start, -1, scale_(0.270341), scale_(0.8), + target_support, target_support, original.lines(), scale_(0.4))); + REQUIRE(path.points.size() >= 3); + + const Vec2d connector = (path.points[1] - wipe_start).cast(); + const Vec2d outgoing = (path.points[2] - path.points[1]).cast(); + CHECK(connector.dot(outgoing) >= 0.); + path.points.front() = wipe_start; + CHECK_THAT(path.length(), Catch::Matchers::WithinAbs(scale_(0.8), 2.)); +} + +TEST_CASE("Stored wipe path follows the incoming wall when a corner gap truncates the forward path", + "[WipePath][Regression]") +{ + const auto point = [](double x, double y) { return Point::new_scale(x, y); }; + const Point seam_start = point(46.047, 61.988); + const Point seam_end = point(46.118, 61.917); + Polyline path{ + seam_start, point(39.139, 55.080), point(46.047, 48.171), + point(52.956, 55.080), seam_end, + }; + const Polyline original = path; + const Lines target_support{ + Line(point(46.047, 61.672), point(39.461, 55.080)), + Line(point(39.461, 55.080), point(46.047, 48.493)), + Line(point(46.047, 48.493), point(52.633, 55.080)), + Line(point(52.633, 55.080), point(46.047, 61.672)), + }; + + REQUIRE(offset_wipe_path_toward_support( + path, seam_start, seam_end, seam_end, +1, scale_(0.23), scale_(0.8), + target_support, target_support, original.lines(), scale_(0.4))); + path.points.front() = seam_end; + CHECK_THAT(path.length(), Catch::Matchers::WithinAbs(scale_(0.8), 2.)); + REQUIRE(path.points.size() >= 3); + CHECK(path.points[1].x() < seam_end.x()); + CHECK(path.points[1].y() < seam_end.y()); +} + +TEST_CASE("Stored wipe path prefers support on the material side of a seam gap", "[WipePath][Regression]") +{ + const coord_t s = scale_(1.0); + const Point seam_start(s, 0); + const Point seam_end(0, 0); + Polyline path{seam_start, Point(s, 10 * s), Point(s, 20 * s)}; + const Polyline original = path; + const Lines target_support{ + Line(Point(0, s), Point(0, 3 * s)), + Line(Point(s / 2, -s / 10), Point(3 * s / 2, -s / 10)), + }; + + // Orca: the lower line is closest at the cusp and the preferred winding + // points toward it, but the outgoing wall is adjacent to the upper line. + REQUIRE(offset_wipe_path_toward_support( + path, seam_start, seam_end, seam_end, -1, s, 5 * s, + target_support, target_support, original.lines(), 2 * s)); + CHECK(path.points[1].y() > seam_end.y()); +} + +TEST_CASE("Stored wipe path rejects an outward offset at a reflex seam gap", "[WipePath][Regression]") +{ + const double offset = GENERATE(0.2, 0.4); // 50% and 100% of a 0.4 mm wall. + const double mirror = GENERATE(1., -1.); + CAPTURE(offset, mirror); + const auto point = [mirror](double x, double y) { return Point::new_scale(mirror * x, y); }; + const Point seam_start = point(0., 0.); + const double gap_component = 0.04 / std::sqrt(2.); // Default 10% seam gap for a 0.4 mm nozzle. + const Point seam_end = point(-gap_component, -gap_component); + const Polyline original{seam_start, point(0., -10.)}; + const Lines support{Line(point(0.4, -10.), point(0.4, 1.))}; + const int preferred_dir = mirror > 0. ? +1 : -1; + + // The inward miter backtracks. The opposite offset can still be supported + // by the outer bead, so support alone must not make it an inward candidate. + Polyline outward = original; + REQUIRE(offset_wipe_path(outward, seam_start, seam_end, seam_end, + -preferred_dir, scale_(offset), scale_(2.))); + Lines all_support = support; + const Lines current = original.lines(); + all_support.insert(all_support.end(), current.begin(), current.end()); + REQUIRE(wipe_path_support_score(outward, seam_end, + LinesDistancer(support), LinesDistancer(all_support), scale_(0.4)).has_value()); + REQUIRE(mirror * outward.points[1].x() < 0.); + + Polyline path = original; + if (offset_wipe_path_toward_support(path, seam_start, seam_end, seam_end, + preferred_dir, scale_(offset), scale_(2.), support, support, current, scale_(0.4))) { + REQUIRE(path.points.size() >= 2); + CHECK(mirror * path.points[1].x() > 0.); + } else { + CHECK(path.points == original.points); + } + + // An inward pre-move provides a clear connector to the direct fallback. + // The fix must retain this usable inward path, rather than reject all wipes. + const Point wipe_start = point(0.05, -0.04); + path = original; + REQUIRE(offset_wipe_path_toward_support(path, seam_start, seam_end, wipe_start, + preferred_dir, scale_(offset), scale_(2.), support, support, current, scale_(0.4))); + REQUIRE(path.points.size() == 2); + CHECK(mirror * path.points[1].x() > mirror * wipe_start.x()); +} + +TEST_CASE("Direct inward wipes respect the nozzle position and intervening walls", "[WipePath][Regression]") +{ + const int mirror = GENERATE(1, -1); + const bool crossing_wall = GENERATE(false, true); + CAPTURE(mirror, crossing_wall); + const auto point = [mirror](double x, double y) { return Point::new_scale(mirror * x, y); }; + const Point seam_start = point(0., 0.); + const double gap_component = 0.04 / std::sqrt(2.); + const Point seam_end = point(-gap_component, -gap_component); + const Polyline original{seam_start, point(0., -10.)}; + const Lines support{Line(point(0.4, -10.), point(0.4, 1.))}; + Lines current = original.lines(); + // The direct destination is near x=0.172. A nozzle already farther inward + // must not return toward the wall. An inward connector from x=0.05 must + // still be rejected when another wall lies between it and the destination. + const Point wipe_start = point(crossing_wall ? 0.05 : 0.3, -0.04); + if (crossing_wall) + current.emplace_back(point(0.1, -0.2), point(0.1, 0.2)); + Polyline path = original; + REQUIRE_FALSE(offset_wipe_path_toward_support(path, seam_start, seam_end, wipe_start, + mirror, scale_(0.2), scale_(2.), support, support, current, scale_(0.4))); + CHECK(path.points == original.points); +} + +TEST_CASE("Inward wipe checks the material side after leaving an open wall endpoint", "[WipePath][Regression]") +{ + const bool require_clearance = GENERATE(false, true); + CAPTURE(require_clearance); + const auto point = [](double x, double y) { return Point::new_scale(x, y); }; + const Point seam = point(0., 0.); + const LinesDistancer current(Lines{Line(seam, point(2., 0.))}); + const LinesDistancer support(Lines{Line(point(0., 0.4), point(2., 0.4))}); + Polyline path{seam, point(0.1, 0.2), point(0.5, 0.2), point(-0.2, 0.2)}; + REQUIRE(wipe_path_stays_on_material_side( + path, seam, Vec2d(0., 1.), support, current, scale_(0.2), require_clearance)); + + // Rounding the open endpoint keeps 0.2 mm of unsigned clearance while + // moving to the air side. Checking only the first direction cannot catch it. + path.points.push_back(point(-0.2, -0.2)); + path.points.push_back(point(0.5, -0.2)); + REQUIRE_FALSE(wipe_path_stays_on_material_side( + path, seam, Vec2d(0., 1.), support, current, scale_(0.2), require_clearance)); +} + +TEST_CASE("Direct inward fallbacks check the material side without requiring clearance", "[WipePath][Regression]") +{ + const auto point = [](double x, double y) { return Point::new_scale(x, y); }; + const Point seam = point(0., 0.); + const LinesDistancer current(Lines{Line(point(-2., 0.), point(2., 0.))}); + const LinesDistancer support(Lines{Line(point(-2., 0.4), point(2., 0.4))}); + REQUIRE(wipe_path_stays_on_material_side( + Polyline{seam, point(0., 0.05)}, seam, Vec2d(0., 1.), support, current, scale_(0.2), false)); + + // Even if the construction's initial direction points outward, the nearby + // inner wall still identifies the material side independently of that hint. + REQUIRE_FALSE(wipe_path_stays_on_material_side( + Polyline{seam, point(0., -0.05)}, seam, Vec2d(0., -1.), support, current, scale_(0.2), false)); +} + +TEST_CASE("Stored wipe path may return to the current wall after reaching an earlier wall", "[WipePath]") +{ + const coord_t s = scale_(1.0); + const Polyline path{Point(0, 0), Point(0, 2 * s), Point(10 * s, 0)}; + const Lines earlier{Line(Point(0, 2 * s), Point(10 * s, 2 * s))}; + const Lines current{Line(Point(0, 0), Point(10 * s, 0))}; + + Lines all_support = earlier; + all_support.insert(all_support.end(), current.begin(), current.end()); + REQUIRE(wipe_path_support_score(path, Point(0, 0), + LinesDistancer(earlier), LinesDistancer(all_support), s).has_value()); +} + +TEST_CASE("Stored wipe path tolerates compounded coordinate quantization", "[WipePath]") +{ + const coord_t s = scale_(1.0); + const coord_t rounding = coord_t(3.5 * SCALED_EPSILON); + const Point destination(0, 2 * s + rounding); + const Polyline path{Point(0, 0), destination}; + const Lines earlier{Line(Point(-s, 0), Point(s, 0))}; + + const LinesDistancer support_distancer(earlier); + REQUIRE(wipe_path_support_score(path, destination, support_distancer, support_distancer, 2 * s).has_value()); +} + +TEST_CASE("Stored wipe path stays on the inner side of a short external loop", "[WipePath][Regression]") +{ + const auto point = [](double x, double y) { return Point::new_scale(x, y); }; + const Point seam = point(55.270, 41.666); + Polyline path{ + seam, point(55.241, 41.568), point(55.210, 41.518), point(55.195, 41.506), + point(55.173, 41.496), point(55.141, 41.479), point(55.126, 41.473), + point(55.068, 41.421), point(55.055, 41.416), point(55.006, 41.382), + point(54.808, 41.231), point(54.687, 41.153), point(54.590, 41.069), + point(54.529, 41.027), point(54.441, 40.949), point(54.299, 40.803), + point(54.219, 40.674), point(54.183, 40.581), point(54.172, 40.511), + point(54.182, 40.450), point(54.225, 40.358), point(54.256, 40.318), + point(54.341, 40.251), point(54.418, 40.211), point(54.499, 40.176), + point(54.675, 40.124), point(54.797, 40.106), point(54.978, 40.092), + point(55.245, 40.093), point(55.443, 40.103), point(55.591, 40.128), + point(55.771, 40.164), point(55.962, 40.217), point(56.103, 40.264), + point(56.167, 40.295), point(56.246, 40.341), point(56.338, 40.412), + point(56.382, 40.469), point(56.396, 40.527), point(56.386, 40.609), + point(56.313, 40.740), point(56.208, 40.867), point(56.071, 40.991), + point(55.946, 41.094), point(55.812, 41.198), point(55.722, 41.262), + point(55.665, 41.294), point(55.556, 41.398), point(55.520, 41.414), + point(55.495, 41.424), point(55.478, 41.437), point(55.442, 41.469), + point(55.407, 41.505), point(55.386, 41.510), point(55.367, 41.516), + point(55.335, 41.535), point(55.292, 41.575), seam, + }; + const Polyline original = path; + const Polyline inner{ + point(55.111, 41.176), point(54.946, 41.050), point(54.824, 40.970), + point(54.733, 40.892), point(54.668, 40.846), point(54.598, 40.784), + point(54.480, 40.662), point(54.424, 40.572), point(54.403, 40.516), + point(54.420, 40.479), point(54.465, 40.443), point(54.577, 40.390), + point(54.723, 40.347), point(54.823, 40.333), point(54.986, 40.320), + point(55.239, 40.321), point(55.418, 40.330), point(55.550, 40.352), + point(55.718, 40.386), point(55.896, 40.435), point(56.017, 40.476), + point(56.061, 40.497), point(56.118, 40.530), point(56.154, 40.558), + point(56.124, 40.611), point(56.043, 40.709), point(55.921, 40.819), + point(55.804, 40.916), point(55.676, 41.015), point(55.600, 41.069), + point(55.526, 41.114), point(55.426, 41.207), point(55.379, 41.235), + point(55.349, 41.259), point(55.291, 41.317), point(55.252, 41.293), + point(55.192, 41.238), point(55.111, 41.176), + }; + Lines target_support = inner.lines(); + // Orca: a different contour has a slightly closer inner wall on the air + // side of this short loop. It must not override the loop's material side. + target_support.emplace_back(point(55.159, 42.147), point(55.299, 42.011)); + + REQUIRE(offset_wipe_path_toward_support( + path, seam, seam, seam, +1, scale_(0.293166), scale_(0.8), + target_support, target_support, original.lines(), scale_(0.4))); + REQUIRE(path.points.size() >= 2); + + // Orca: the nearest inner wall is below the seam; accepting the opposite + // offset would send the wipe into air outside this small contour. + CHECK(path.points[1].y() < seam.y()); +} + +TEST_CASE("Stored wipe path does not return to the external wall after moving inward", "[WipePath][Regression]") +{ + const auto point = [](double x, double y) { return Point::new_scale(x, y); }; + const Point seam = point(47.451, 54.647); + Polyline path{ + seam, point(47.370, 54.634), point(47.345, 54.619), point(47.333, 54.604), + point(47.322, 54.572), point(47.312, 54.518), point(47.315, 54.445), + point(47.345, 54.257), point(47.357, 54.206), point(47.380, 54.135), + point(47.418, 54.065), point(47.514, 53.917), point(47.537, 53.886), + point(47.597, 53.834), point(47.705, 53.769), point(47.747, 53.748), + point(47.785, 53.734), point(47.825, 53.735), point(47.862, 53.746), + point(47.889, 53.763), point(47.939, 53.817), point(47.964, 53.856), + point(47.979, 53.897), point(47.986, 53.943), point(47.986, 54.005), + point(47.977, 54.075), point(47.949, 54.188), point(47.902, 54.321), + point(47.871, 54.388), point(47.835, 54.444), point(47.765, 54.521), + point(47.741, 54.542), point(47.675, 54.589), point(47.615, 54.620), + point(47.518, 54.642), seam, + }; + const Polyline original = path; + const Polyline inner{ + point(47.541, 54.281), point(47.545, 54.258), point(47.560, 54.212), + point(47.577, 54.181), point(47.682, 54.017), point(47.707, 53.995), + point(47.789, 53.946), point(47.791, 53.958), point(47.791, 53.992), + point(47.785, 54.039), point(47.762, 54.132), point(47.721, 54.249), + point(47.700, 54.294), point(47.680, 54.324), point(47.628, 54.382), + point(47.574, 54.422), point(47.548, 54.435), point(47.513, 54.443), + point(47.541, 54.281), + }; + const double offset = scale_(0.229999); + + REQUIRE(offset_wipe_path_toward_support( + path, seam, seam, seam, +1, offset, scale_(0.8), + inner.lines(), inner.lines(), original.lines(), scale_(0.4))); + + // Orca: after reaching the inner wall, a full-width inward wipe must not + // collapse back onto the external perimeter at a tight turn. + for (size_t index = 1; index < path.points.size(); ++index) { + double clearance = std::numeric_limits::infinity(); + for (const Line &line : original.lines()) + clearance = std::min(clearance, line.distance_to(path.points[index])); + CHECK(clearance >= 0.75 * offset); + } +} + +// Orca: wipe_on_loops_destination coverage for every orientation. + +TEST_CASE("wipe_on_loops destination is on the material side for every orientation", "[WipePath]") +{ + const auto [is_ccw, is_hole] = GENERATE( + table({{true, false}, {false, false}, {false, true}, {true, true}})); + INFO("is_ccw=" << is_ccw << ", is_hole=" << is_hole); + const double nozzle_diameter = GENERATE(0.4, 0.8); + const bool subdivided = GENERATE(false, true); + INFO("nozzle diameter=" << nozzle_diameter << ", subdivided=" << subdivided); + + const coord_t s = scale_(1.0); + std::vector contour = {Point(0, 0), Point(20 * s, 0), Point(20 * s, 20 * s), Point(0, 20 * s)}; + if (subdivided) { + // The same square, with path boundaries inside both sampling distances near the seam. + contour = {Point(0, 0), Point(scale_(0.03), 0.), Point(scale_(0.2), 0.), + Point(20 * s, 0), Point(20 * s, 20 * s), Point(0, 20 * s), + Point(0., scale_(0.2)), Point(0., scale_(0.03))}; + } + if (!is_ccw) + for (Point &point : contour) + std::swap(point.x(), point.y()); + ExtrusionPaths paths; + if (subdivided) { + for (size_t i = 0; i < contour.size(); ++i) + paths.push_back(make_path({contour[i], contour[(i + 1) % contour.size()]})); + } else { + paths = make_loop_paths(contour); + } + + const std::optional destination = + wipe_on_loops_destination(paths, scale_(nozzle_diameter), is_ccw, is_hole); + REQUIRE(destination.has_value()); + + const Point seam_start = paths.front().first_point(); + const Vec2d first_edge = (paths.front().polyline.points[1].to_point() - seam_start).cast(); + Vec2d material_normal(-first_edge.y(), first_edge.x()); + if (is_ccw == is_hole) + material_normal = -material_normal; + + // Orca: contours use their winding's inside; holes use the opposite side. + const Vec2d move = destination->cast() - seam_start.cast(); + REQUIRE(move.dot(material_normal) > 0.); + // Move 20% of the nozzle diameter, turning through one third of the material-side + // corner: 90 degrees for a contour, 270 degrees for a hole. + const double distance = scale_(0.2 * nozzle_diameter); + const double angle = is_hole ? PI / 2. : PI / 6.; + CHECK_THAT(move.dot(first_edge.normalized()), Catch::Matchers::WithinAbs(distance * std::cos(angle), 2.)); + CHECK_THAT(move.dot(material_normal.normalized()), Catch::Matchers::WithinAbs(distance * std::sin(angle), 2.)); +} + +TEST_CASE("wipe_on_loops returns destination for small but nonzero loop", "[WipePath]") +{ + // Orca: a 0.5 mm square is tight for a 0.4 mm nozzle but remains valid. + const coord_t s = scale_(1.0); + auto paths = make_loop_paths({Point(0, 0), Point(s / 2, 0), Point(s / 2, s / 2), Point(0, s / 2)}); + + auto dest = wipe_on_loops_destination(paths, scale_(0.4), true, false); + REQUIRE(dest.has_value()); +} + +TEST_CASE("wipe_on_loops destination is nullopt for degenerate single-point path", "[WipePath]") +{ + const coord_t s = scale_(1.0); + auto paths = make_paths({Point(50 * s, 50 * s)}); + + auto dest = wipe_on_loops_destination(paths, scale_(0.4), true, false); + REQUIRE_FALSE(dest.has_value()); +}