Feature: Add inward wipe for external perimeters (#15407)

This commit is contained in:
Valerii Bokhan
2026-09-15 20:01:39 -03:00
committed by GitHub
parent a0ada1aa88
commit 3e1daccd7c
19 changed files with 3041 additions and 83 deletions
+170
View File
@@ -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.
+2
View File
@@ -260,6 +260,8 @@ set(lisbslic3r_sources
GCode/SmallAreaInfillFlowCompensator.hpp GCode/SmallAreaInfillFlowCompensator.hpp
GCode/SpiralVase.cpp GCode/SpiralVase.cpp
GCode/SpiralVase.hpp GCode/SpiralVase.hpp
GCode/WipePathHelpers.cpp
GCode/WipePathHelpers.hpp
GCode/ThumbnailData.cpp GCode/ThumbnailData.cpp
GCode/ThumbnailData.hpp GCode/ThumbnailData.hpp
GCode/Thumbnails.cpp GCode/Thumbnails.cpp
+119 -79
View File
@@ -1,5 +1,6 @@
#include "BoundingBox.hpp" #include "BoundingBox.hpp"
#include "Config.hpp" #include "Config.hpp"
#include "GCode/WipePathHelpers.hpp"
#include "GCodeWriter.hpp" #include "GCodeWriter.hpp"
#include "Polygon.hpp" #include "Polygon.hpp"
#include "PrintConfig.hpp" #include "PrintConfig.hpp"
@@ -438,7 +439,6 @@ static std::vector<Vec2d> get_path_of_change_filament(const Print& print)
auto& writer = gcodegen.writer(); auto& writer = gcodegen.writer();
auto& config = gcodegen.config(); auto& config = gcodegen.config();
auto extruder = writer.filament(); auto extruder = writer.filament();
auto extruder_id = extruder->extruder_id();
auto last_pos = gcodegen.last_pos(); auto last_pos = gcodegen.last_pos();
// Declare & initialize retraction lengths // Declare & initialize retraction lengths
@@ -475,13 +475,13 @@ static std::vector<Vec2d> get_path_of_change_filament(const Print& print)
wipe_speed = std::max(wipe_speed, 10.0); wipe_speed = std::max(wipe_speed, 10.0);
// Process wipe path & calculate wipe path length // 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}; Polyline wipe_path = {last_pos};
wipe_path.append(this->path.points.begin() + 1, this->path.points.end()); wipe_path.append(this->path.points.begin() + 1, this->path.points.end());
double wipe_path_length = std::min(wipe_path.length(), wipe_dist); double wipe_path_length = std::min(wipe_path.length(), wipe_dist);
// Calculate the maximum retraction amount during wipe // 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; unscale_(wipe_path_length) / wipe_speed;
// If the maximum retraction amount during wipe is too small, // If the maximum retraction amount during wipe is too small,
@@ -564,6 +564,16 @@ static std::vector<Vec2d> get_path_of_change_filament(const Print& print)
return default_value; 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 Wipe::wipe(GCode& gcodegen,double length, bool toolchange, bool is_last)
{ {
std::string gcode; std::string gcode;
@@ -616,14 +626,11 @@ static std::vector<Vec2d> get_path_of_change_filament(const Print& print)
if (gcodegen.enable_cooling_markers() && !is_last) if (gcodegen.enable_cooling_markers() && !is_last)
cooling_mark = /*gcodegen.config().role_based_wipe_speed ? ";_EXTERNAL_PERIMETER" : */";_WIPE"; 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); gcode += gcodegen.writer().set_speed(_wipe_speed * 60, "", cooling_mark);
for (const Line& line : wipe_path.lines()) { for (const Line& line : wipe_path.lines()) {
double segment_length = line.length(); double segment_length = line.length();
double dE = length * (segment_length / wipe_dist); 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( gcode += gcodegen.writer().extrude_to_xy(
gcodegen.point_to_gcode(line.b), gcodegen.point_to_gcode(line.b),
-dE, -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 bool skip_config_block = print.config().gcode_skip_config_block;
const WipeTowerType wipe_tower_type = print.wipe_tower_type(); const WipeTowerType wipe_tower_type = print.wipe_tower_type();
m_calib_config.clear(); 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 // resets analyzer's tracking data
m_last_height = 0.f; m_last_height = 0.f;
m_last_layer_z = 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, const std::string& description,
double speed, double speed,
const ExtrusionEntitiesPtr& region_perimeters, 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 // get a copy; don't modify the orientation of the original loop object otherwise
// next copies (if any) would not detect the correct orientation // 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<float>(seam_scarf_distance_mm); m_processor.result().print_statistics.total_seam_scarf_distance += static_cast<float>(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<Point> 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)) { if (m_wipe.enable && FILAMENT_CONFIG(wipe)) {
m_wipe.path = Polyline(); m_wipe.update_path(paths);
for (ExtrusionPath &path : paths) {
//BBS: Don't need to save duplicated point into wipe path // Orca: loop wipe paths retain print direction. Their material side is
if (!m_wipe.path.empty() && !path.empty() && // therefore left for CCW contours and right for CW contours, with the
m_wipe.path.last_point() == Point(path.first_point().x(), path.first_point().y())) { // result inverted for holes. Only external perimeters are eligible.
// Convert Points3 to Points // Calibration overrides are applied during extrusion, after the region
for (auto it = path.polyline.points.begin() + 1; it != path.polyline.points.end(); ++it) // context was created. Check the effective setting again at execution.
m_wipe.path.append(Point(it->x(), it->y())); if (m_config.wipe_inward && m_config.wipe_inward_distance.value > 0. &&
} else wipe_support != nullptr && !wipe_support->inner_lines.empty() &&
m_wipe.path.append(path.polyline.to_polyline()); // TODO: don't limit wipe to last path // 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 // Orca: make the configured inward move before leaving the 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) { if (wipe_on_loops_dest) {
// detect angle between last and first segment gcode += m_writer.extrude_to_xy(
// the side depends on the original winding order of the polygon (inwards for contours, outwards for holes) this->point_to_gcode(*wipe_on_loops_dest), 0, "move inwards before travel", true);
//FIXME improve the algorithm in case the loop is tiny. this->set_last_pos(*wipe_on_loops_dest);
//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<double>().head<2>();
Vec2d p2 = paths.front().polyline.points[1].cast<double>().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<coord_t>();
if (nd * nd < l2)
pt = (p1 + threshold * v * (nd / sqrt(l2))).cast<coord_t>();
//Point pt = ((nd * nd >= l2) ? (p1+v*0.4): (p1 + 0.2 * v * (nd / sqrt(l2)))).cast<coord_t>();
const Point3 &center3 = 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);
} }
// 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; return gcode;
} }
@@ -7524,21 +7562,9 @@ std::string GCode::extrude_multi_path(const ExtrusionMultiPath& multipath, const
m_multi_flow_segment_path_pa_set = true; m_multi_flow_segment_path_pa_set = true;
} }
// BBS // Orca: multipath wipes retrace the extrusion in reverse order.
if (m_wipe.enable && FILAMENT_CONFIG(wipe)) { if (m_wipe.enable && FILAMENT_CONFIG(wipe))
m_wipe.path = Polyline(); m_wipe.update_path(multipath.paths, true);
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();
}
return gcode; return gcode;
} }
@@ -7546,14 +7572,15 @@ std::string GCode::extrude_multi_path(const ExtrusionMultiPath& multipath, const
std::string GCode::extrude_entity(const ExtrusionEntity& entity, std::string GCode::extrude_entity(const ExtrusionEntity& entity,
const std::string& description, const std::string& description,
double speed, double speed,
const ExtrusionEntitiesPtr& region_perimeters) const ExtrusionEntitiesPtr& region_perimeters,
const WipeInwardSupport* wipe_support)
{ {
if (const ExtrusionPath* path = dynamic_cast<const ExtrusionPath*>(&entity)) if (const ExtrusionPath* path = dynamic_cast<const ExtrusionPath*>(&entity))
return this->extrude_path(*path, description, speed); return this->extrude_path(*path, description, speed);
else if (const ExtrusionMultiPath* multipath = dynamic_cast<const ExtrusionMultiPath*>(&entity)) else if (const ExtrusionMultiPath* multipath = dynamic_cast<const ExtrusionMultiPath*>(&entity))
return this->extrude_multi_path(*multipath, description, speed); return this->extrude_multi_path(*multipath, description, speed);
else if (const ExtrusionLoop* loop = dynamic_cast<const ExtrusionLoop*>(&entity)) else if (const ExtrusionLoop* loop = dynamic_cast<const ExtrusionLoop*>(&entity))
return this->extrude_loop(*loop, description, speed, region_perimeters); return this->extrude_loop(*loop, description, speed, region_perimeters, nullptr, wipe_support);
else else
throw Slic3r::InvalidArgument("Invalid argument supplied to extrude()"); throw Slic3r::InvalidArgument("Invalid argument supplied to extrude()");
return ""; return "";
@@ -7567,6 +7594,7 @@ std::string GCode::extrude_path(const ExtrusionPath& path, const std::string& de
// description += ExtrusionEntity::role_to_string(path.role()); // description += ExtrusionEntity::role_to_string(path.role());
std::string gcode = this->_extrude(path, description, speed); std::string gcode = this->_extrude(path, description, speed);
if (m_wipe.enable && FILAMENT_CONFIG(wipe)) { if (m_wipe.enable && FILAMENT_CONFIG(wipe)) {
m_wipe.reset_path();
m_wipe.path = path.polyline.to_polyline(); m_wipe.path = path.polyline.to_polyline();
if (is_tree(this->config().support_type) && is_support(path.role())) { if (is_tree(this->config().support_type) && is_support(path.role())) {
if ((m_wipe.path.first_point() - m_wipe.path.last_point()).cast<double>().norm() > scale_(0.2)) { if ((m_wipe.path.first_point() - m_wipe.path.last_point()).cast<double>().norm() > scale_(0.2)) {
@@ -7599,8 +7627,19 @@ std::string GCode::extrude_perimeters(const Print &print, const std::vector<Obje
: (m_config.is_infill_first == is_infill_first); : (m_config.is_infill_first == is_infill_first);
if (!should_print) continue; if (!should_print) continue;
for (const ExtrusionEntity* ee : region.perimeters) // Build the printed prefix once in emission order, scoped to this
gcode += this->extrude_entity(*ee, "perimeter", -1., region.perimeters); // region. Disabled or zero-length wipes need no support geometry.
std::optional<WipeInwardSupport> 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; 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. // 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 // 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(); 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; const bool _last_pos_undefined = !m_last_pos_defined;
double z = DBL_MAX; double z = DBL_MAX;
+9 -4
View File
@@ -39,6 +39,7 @@ namespace Slic3r {
// Forward declarations. // Forward declarations.
class GCode; class GCode;
struct WipeInwardSupport;
namespace CustomGCode{ struct Item; } namespace CustomGCode{ struct Item; }
struct PrintInstance; struct PrintInstance;
@@ -61,7 +62,7 @@ public:
bool enable; bool enable;
Polyline path; Polyline path;
// Orca: // Orca: retraction portions emitted before, during, and after the wipe move.
struct RetractionValues{ struct RetractionValues{
double retraction_length_before_wipe = 0.; double retraction_length_before_wipe = 0.;
double retraction_length_during_wipe = 0.; double retraction_length_during_wipe = 0.;
@@ -73,8 +74,10 @@ public:
void reset_path() { this->path = Polyline(); } void reset_path() { this->path = Polyline(); }
std::string wipe(GCode &gcodegen, double length, bool toolchange = false, bool is_last = false); 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); 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 { class WipeTowerIntegration {
@@ -430,14 +433,16 @@ private:
std::string extrude_entity(const ExtrusionEntity& entity, std::string extrude_entity(const ExtrusionEntity& entity,
const std::string& description = "", const std::string& description = "",
double speed = -1., 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 // Orca: pass the complete collection of region perimeters to the extrude loop to check whether the wipe before external loop
// should be executed // should be executed
std::string extrude_loop(const ExtrusionLoop& loop, std::string extrude_loop(const ExtrusionLoop& loop,
const std::string& description, const std::string& description,
double speed = -1., double speed = -1.,
const ExtrusionEntitiesPtr& region_perimeters = ExtrusionEntitiesPtr(), 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_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.); std::string extrude_path(const ExtrusionPath& path, const std::string& description = "", double speed = -1.);
+920
View File
@@ -0,0 +1,920 @@
#include "WipePathHelpers.hpp"
#include "../AABBTreeLines.hpp"
#include <algorithm>
#include <cassert>
#include <cmath>
#include <limits>
#include <tuple>
namespace Slic3r {
void WipeInwardSupport::append(const ExtrusionEntity &entity)
{
const ExtrusionPaths *paths = nullptr;
if (const auto *loop = dynamic_cast<const ExtrusionLoop *>(&entity))
paths = &loop->paths;
else if (const auto *multipath = dynamic_cast<const ExtrusionMultiPath *>(&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<double>().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<double>();
const Vec2d v_next = (pts[i + 1] - pts[i]).cast<double>();
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>();
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<double>().normalized();
const Vec2d next = (source[i + 1] - source[i]).cast<double>().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<double>();
const Vec2d outgoing = (path.points[2] - path.points[1]).cast<double>();
// 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<double>();
const double length = segment.norm();
if (length <= SCALED_EPSILON)
continue;
if (target <= length)
return (result.cast<double>() + segment * (target / length)).cast<coord_t>();
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<double>();
const double projection = (wipe_start - actual_path.points[entry]).cast<double>().dot(edge);
if (projection <= 0.)
break;
if (projection < edge.squaredNorm()) {
actual_path.points[entry] = (actual_path.points[entry].cast<double>() +
edge * (projection / edge.squaredNorm())).cast<coord_t>();
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<double>() + translation).cast<coord_t>();
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<Line> &distancer,
double max_distance)
{
const Point midpoint = ((start.cast<double>() + end.cast<double>()) * 0.5).cast<coord_t>();
const auto [distance, line_index, nearest] = distancer.distance_from_lines_extra<false>(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<false>(start) > max_distance ||
distancer.distance_from_lines<false>(end) > max_distance)
return false;
// Conservatively reject an unresolved transition at coordinate precision.
if ((end - start).cast<double>().norm() <= SCALED_EPSILON)
return false;
return segment_is_supported(start, midpoint, distancer, max_distance) &&
segment_is_supported(midpoint, end, distancer, max_distance);
}
std::optional<double> wipe_path_support_score(
const Polyline &polyline, Point wipe_start,
const AABBTreeLines::LinesDistancer<Line> &target_distancer,
const AABBTreeLines::LinesDistancer<Line> &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<false>(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<false>(polyline.points[1]);
}
static bool initial_connector_is_clear(
const Polyline &polyline, Point wipe_start, Point seam_start,
AABBTreeLines::LinesDistancer<Line> &current_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<false>(connector);
for (const auto &intersection : intersections) {
if ((intersection.first - wipe_start).cast<double>().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<double>().norm() > contact_tolerance)
return false;
return true;
}
static std::optional<Vec2d> support_offset_at_start(
const Polyline &source, Point local_origin, bool disambiguate_branch,
AABBTreeLines::LinesDistancer<Line> &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<false>(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<double>();
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<double>();
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<double>::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<double>() - local_origin.cast<double>();
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<double>().norm();
for (size_t index = 2; index < stored_path.points.size(); ++index)
length += (stored_path.points[index] - stored_path.points[index - 1]).cast<double>().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<double>().normalized();
const Vec2d incoming = (seam - *incoming_it).cast<double>().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<double>().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<Line> &target_perimeter_distancer,
const AABBTreeLines::LinesDistancer<Line> &current_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<double>();
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<double>();
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<false>(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<double>()).norm() <= SCALED_EPSILON && previous.b == line.a)
normal += left_normal(previous);
if ((nearest - line.b.cast<double>()).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<coord_t>();
const Vec2d support_point = std::get<2>(
target_perimeter_distancer.distance_from_lines_extra<false>(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<double>() - 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<double>();
const size_t samples = std::max<size_t>(1, size_t(std::ceil(segment.norm() / effective_offset)));
for (size_t sample = 1; sample <= samples; ++sample) {
const Point point = (previous.cast<double>() +
segment * (double(sample) / double(samples))).cast<coord_t>();
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 &current_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<Line> support_distancer(*candidate_support_lines);
const std::optional<Vec2d> 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<Line> all_support_distancer(std::move(all_support_lines));
AABBTreeLines::LinesDistancer<Line> 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<Candidate> {
// 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<double> 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<Candidate> {
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<Candidate> preferred = offset_candidate(preferred_dir);
std::optional<Candidate> 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<Candidate> {
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<Candidate> 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<Candidate> {
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<double>() + direction * candidate_offset).cast<coord_t>();
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<Candidate> direct = direct_candidate(seam_end, toward_support);
const double length_margin = wipe_tolerance(max_wipe_length);
std::optional<Candidate> 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<Vec2d> 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<Candidate> 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<Point> 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
+96
View File
@@ -0,0 +1,96 @@
#pragma once
#include <optional>
#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 <typename LineType> 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<double> wipe_path_support_score(
const Polyline &polyline, Point wipe_start,
const AABBTreeLines::LinesDistancer<Line> &target_distancer,
const AABBTreeLines::LinesDistancer<Line> &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<Line> &target_perimeter_distancer,
const AABBTreeLines::LinesDistancer<Line> &current_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 &current_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<Point> wipe_on_loops_destination(const ExtrusionPaths &paths, double nozzle_diam_scaled,
bool is_ccw, bool is_hole);
} // namespace Slic3r
+2
View File
@@ -1282,6 +1282,8 @@ static std::vector<std::string> s_Preset_print_options{
"accel_to_decel_enable", "accel_to_decel_enable",
"accel_to_decel_factor", "accel_to_decel_factor",
"wipe_on_loops", "wipe_on_loops",
"wipe_inward",
"wipe_inward_distance",
"wipe_before_external_loop", "wipe_before_external_loop",
"bridge_density", "bridge_density",
"internal_bridge_density", "internal_bridge_density",
+2
View File
@@ -233,6 +233,8 @@ bool Print::invalidate_state_by_config_options(const ConfigOptionResolver & /* n
"accel_to_decel_enable", "accel_to_decel_enable",
"accel_to_decel_factor", "accel_to_decel_factor",
"wipe_on_loops", "wipe_on_loops",
"wipe_inward",
"wipe_inward_distance",
"gcode_comments", "gcode_comments",
"gcode_label_objects", "gcode_label_objects",
"exclude_object", "exclude_object",
+29
View File
@@ -6267,6 +6267,35 @@ void PrintConfigDef::init_fff_params()
def->mode = comAdvanced; def->mode = comAdvanced;
def->set_default_value(new ConfigOptionBool(false)); 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 = this->add("wipe_before_external_loop", coBool);
def->label = L("Wipe before external loop"); def->label = L("Wipe before external loop");
def->category = L("Quality"); def->category = L("Quality");
+2
View File
@@ -1391,6 +1391,8 @@ PRINT_CONFIG_CLASS_DEFINE(
((ConfigOptionBool, role_based_wipe_speed)) ((ConfigOptionBool, role_based_wipe_speed))
((ConfigOptionFloatOrPercent, wipe_speed)) ((ConfigOptionFloatOrPercent, wipe_speed))
((ConfigOptionBool, wipe_on_loops)) ((ConfigOptionBool, wipe_on_loops))
((ConfigOptionBool, wipe_inward))
((ConfigOptionFloatOrPercent, wipe_inward_distance))
((ConfigOptionBool, wipe_before_external_loop)) ((ConfigOptionBool, wipe_before_external_loop))
((ConfigOptionEnum<WallInfillOrder>, wall_infill_order)) ((ConfigOptionEnum<WallInfillOrder>, wall_infill_order))
((ConfigOptionBool, precise_outer_wall)) ((ConfigOptionBool, precise_outer_wall))
+2
View File
@@ -1574,6 +1574,8 @@ bool PrintObject::invalidate_state_by_config_options(
|| opt_key == "brim_flow_ratio" || opt_key == "brim_flow_ratio"
|| opt_key == "filament_flow_ratio" || opt_key == "filament_flow_ratio"
|| opt_key == "scarf_joint_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_starting_flow_ratio"
|| opt_key == "spiral_finishing_flow_ratio") { || opt_key == "spiral_finishing_flow_ratio") {
invalidated |= m_print->invalidate_step(psGCodeExport); invalidated |= m_print->invalidate_step(psGCodeExport);
+3
View File
@@ -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"); auto is_role_based_wipe_speed = config->opt_bool("role_based_wipe_speed");
toggle_field("wipe_speed",!is_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"}) for (auto el : {"accel_to_decel_enable", "accel_to_decel_factor"})
toggle_line(el, gcf_is_klipper); toggle_line(el, gcf_is_klipper);
if(gcf_is_klipper) if(gcf_is_klipper)
+2
View File
@@ -15755,6 +15755,7 @@ void Plater::calib_pa(const Calib_Params& params)
auto printer_config = &wxGetApp().preset_bundle->printers.get_edited_preset().config; 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("overhang_reverse", new ConfigOptionBool(false));
print_config->set_key_value("precise_z_height", 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}); printer_config->set_key_value("resonance_avoidance", new ConfigOptionBool{false});
switch (params.mode) { switch (params.mode) {
case CalibMode::Calib_PA_Line: case CalibMode::Calib_PA_Line:
@@ -16440,6 +16441,7 @@ void Plater::calib_retraction(const Calib_Params& params)
auto obj = model().objects[0]; auto obj = model().objects[0];
print_config->set_key_value("enable_wrapping_detection", new ConfigOptionBool(false)); 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<ConfigOptionFloats>("nozzle_diameter")->get_at(0); float nozzle_diameter = printer_config->option<ConfigOptionFloats>("nozzle_diameter")->get_at(0);
float layer_height; float layer_height;
+2
View File
@@ -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("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_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_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"); optgroup->append_single_option_line("wipe_before_external_loop","quality_settings_seam#wipe-before-external");
+2
View File
@@ -1096,6 +1096,7 @@ bool CalibUtils::calib_generic_PA(const CalibInfo &calib_info, wxString &error_m
calib_pa_pattern(calib_info, model); calib_pa_pattern(calib_info, model);
DynamicPrintConfig print_config = calib_info.print_prest->config; 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 filament_config = calib_info.filament_prest->config;
DynamicPrintConfig printer_config = calib_info.printer_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); read_model_from_file(input_file, model);
DynamicPrintConfig print_config = calib_info.print_prest->config; 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 filament_config = calib_info.filament_prest->config;
DynamicPrintConfig printer_config = calib_info.printer_prest->config; DynamicPrintConfig printer_config = calib_info.printer_prest->config;
+1
View File
@@ -21,6 +21,7 @@ add_executable(${_TEST_NAME}_tests
test_support_material.cpp test_support_material.cpp
test_tree_support.cpp test_tree_support.cpp
test_trianglemesh.cpp test_trianglemesh.cpp
test_wipe.cpp
test_wipe_tower.cpp test_wipe_tower.cpp
) )
target_link_libraries(${_TEST_NAME}_tests test_common libslic3r Catch2::Catch2WithMain) target_link_libraries(${_TEST_NAME}_tests test_common libslic3r Catch2::Catch2WithMain)
+653
View File
@@ -0,0 +1,653 @@
#include <catch2/catch_all.hpp>
#include <algorithm>
#include <cmath>
#include <map>
#include <string>
#include <string_view>
#include <vector>
#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<Vec2d> destinations;
};
std::vector<WipeTrajectory> 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<WipeTrajectory> 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<Vec2d> wipe_destinations(const std::string &gcode)
{
std::vector<Vec2d> 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<Vec2d> &lhs, const std::vector<Vec2d> &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<std::vector<ConfigBase::SetDeserializeItem>> 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<Vec2d> regular = wipe_destinations(
slice({make_cube(10., 10., 1.)}, wipe_config(wall_generator, false)));
const std::vector<Vec2d> 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<ExtrusionEntityCollection *>(entity)) {
for (ExtrusionEntity *child : collection->entities)
self(self, child);
} else if (auto *loop = dynamic_cast<ExtrusionLoop *>(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, &region->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<Vec2d> regular = wipe_destinations(
slice({make_cube(10., 10., 1.)}, wipe_config(wall_generator, false, "50%", "0%")));
const std::vector<Vec2d> 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<WipeTrajectory> inward = wipe_trajectories(slice({make_cube(27., 27., 1.)}, inward_config));
REQUIRE_FALSE(inward.empty());
std::map<double, bool> 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<Vec2d> regular = wipe_destinations(
slice({make_cube(10., 10., 1.)}, wipe_config(wall_generator, false)));
const std::vector<Vec2d> full_width = wipe_destinations(
slice({make_cube(10., 10., 1.)}, wipe_config(wall_generator, true, "100%")));
const std::vector<Vec2d> 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<Vec2d> regular = wipe_destinations(
slice({make_cube(10., 10., 1.)}, wipe_config(wall_generator, false, "50%", "10%", false, "1")));
const std::vector<Vec2d> 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<Vec2d> regular = wipe_destinations(
slice({make_cube(10., 10., 1.)}, regular_config));
const std::vector<Vec2d> 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<Vec2d> 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<Vec2d> 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<Vec2d> 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<double, std::vector<Vec2d>> 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<WipeTrajectory> combined_trajectories = wipe_trajectories(combined);
const std::vector<WipeTrajectory> 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);
}
+1
View File
@@ -43,6 +43,7 @@ add_executable(${_TEST_NAME}_tests
test_voronoi.cpp test_voronoi.cpp
test_wipe_tower_estimate.cpp test_wipe_tower_estimate.cpp
test_wipe_tower.cpp test_wipe_tower.cpp
test_wipe_path.cpp
test_optimizers.cpp test_optimizers.cpp
test_ordering_strategies.cpp test_ordering_strategies.cpp
# test_png_io.cpp # test_png_io.cpp
File diff suppressed because it is too large Load Diff