Normalize the junction direction vector over XYZE (#15308)

* Normalize the junction direction vector over XYZE

calc_vmax_junction_deviation() treats the dot product of two jd_unit_vec as a
cosine, but the vectors were scaled by 1 / block.distance, which is the XYZ
length. On an extruding move the E component then pushes the 4D norm above 1 and
the dot product below -1, so the corner reads as straighter than it is and is
planned too fast -- the more so the higher the flow. Measured on a 6 degree
corner at scv 5: 86.9mm/s with no extrusion, 94.4mm/s at 0.029mm/mm, 150.0mm/s
at 0.1mm/mm.

Neither firmware does that. Marlin normalizes over XYZE for any extruding move
(planner.cpp: `if (... || esteps > 0) normalize_junction_vector(unit_vec)`) and
Klipper leaves E out of the cosine entirely, dotting only axes_r[0..2]
(toolhead.py::Move.calc_junction). Normalizing satisfies both: with E normalized
in, the cosine differs from the XYZ-only one by ~1e-5 at printing flow rates.

This is a deliberate divergence from PrusaSlicer, which still scales by
1 / distance -- it carries an older Marlin's behaviour.

Travel moves are unaffected, their vector was already unit length.

Reported by Copilot in review of #15304.

* Test that extrusion rate does not change corner planning

The junction deviation tests were all travel-only, which is exactly why the E
component of the junction vector went unchecked. Cover it: the same corner has
to be planned the same whether nothing, an ordinary 0.42 x 0.2 line, or a fat
large-nozzle line is extruded through it, on both Klipper and Marlin 2.

Reported by Copilot in review of #15304.
This commit is contained in:
Ian Bassi
2026-08-20 18:50:23 -03:00
committed by GitHub
parent 87ca2bc42e
commit ca65f0fd8e
3 changed files with 55 additions and 19 deletions

View File

@@ -5037,10 +5037,10 @@ void GCodeProcessor::process_G1(const std::array<std::optional<double>, 4>& axes
if (!is_extrusion_only_move(delta_pos))
curr.enter_direction = curr.enter_direction / norm;
curr.exit_direction = curr.enter_direction;
curr.jd_unit_vec = Vec4f(static_cast<float>(delta_pos[X]) * inv_distance,
static_cast<float>(delta_pos[Y]) * inv_distance,
static_cast<float>(delta_pos[Z]) * inv_distance,
static_cast<float>(delta_pos[E]) * inv_distance);
curr.jd_unit_vec = Vec4f(static_cast<float>(delta_pos[X]),
static_cast<float>(delta_pos[Y]),
static_cast<float>(delta_pos[Z]),
static_cast<float>(delta_pos[E])).normalized();
TimeBlock block;
block.move_type = type;
@@ -5415,10 +5415,10 @@ void GCodeProcessor::process_VG1(const GCodeReader::GCodeLine& line)
if (!is_extrusion_only_move(delta_pos))
curr.enter_direction = curr.enter_direction / norm;
curr.exit_direction = curr.enter_direction;
curr.jd_unit_vec = Vec4f(static_cast<float>(delta_pos[X]) * inv_distance,
static_cast<float>(delta_pos[Y]) * inv_distance,
static_cast<float>(delta_pos[Z]) * inv_distance,
static_cast<float>(delta_pos[E]) * inv_distance);
curr.jd_unit_vec = Vec4f(static_cast<float>(delta_pos[X]),
static_cast<float>(delta_pos[Y]),
static_cast<float>(delta_pos[Z]),
static_cast<float>(delta_pos[E])).normalized();
TimeBlock block;
block.move_type = type;
@@ -7245,6 +7245,11 @@ float GCodeProcessor::calc_vmax_junction_deviation(const TimeBlock& block, const
return 0.0f; // starts from rest, the planner raises this on the reverse pass
// -1 for a straight continuation, +1 for a full reversal. Half angle identity, no acos()/sin().
// Both vectors are unit length over XYZE, so this really is a cosine: scaling by 1 / distance
// instead, as PrusaSlicer does, leaves an E term that makes extruding corners look straighter
// than they are. Marlin normalizes over XYZE for any extruding move (planner.cpp, esteps > 0)
// and Klipper keeps E out of the cosine entirely (toolhead.py::Move.calc_junction); both agree
// that the corner is planned by its geometry, and normalizing matches them to within 1e-5.
float junction_cos_theta = (-prev.jd_unit_vec).dot(curr.jd_unit_vec);
if (junction_cos_theta > 0.999999f)
return 0.0f; // the path doubles back, the machine has to stop

View File

@@ -637,9 +637,8 @@ class Print;
//For line move, there are same. For arc move, there are different.
Vec3f enter_direction;
Vec3f exit_direction;
// Orca: move direction over all four axes, scaled by 1 / block.distance. Used by
// calc_vmax_junction_deviation(), which needs E to see extrusion-rate changes
// between collinear moves the way Marlin and Klipper do.
// Orca: move direction over all four axes, unit length. Used by
// calc_vmax_junction_deviation(); see there for why E is normalized in.
Vec4f jd_unit_vec;
void reset();

View File

@@ -468,22 +468,27 @@ FullPrintConfig make_junction_config(GCodeFlavor flavor, double corner_velocity,
constexpr double junction_x = 60.0;
constexpr double junction_y = 60.0;
// Two 40mm travels meeting at (junction_x, junction_y) with the given turn, rotated by `orientation`.
// Two 40mm moves meeting at (junction_x, junction_y) with the given turn, rotated by `orientation`.
// 40mm is long enough to reach the commanded 150mm/s and brake back to any corner speed these tests
// produce. Travels (no E) keep the junction vector purely geometric, as the formulas below assume.
std::string corner_gcode(double turn_deg, double orientation_deg)
// produce. `e_per_mm` of zero makes them travels, which keeps the junction vector purely geometric
// as the formulas below assume.
std::string corner_gcode(double turn_deg, double orientation_deg, double e_per_mm = 0.0)
{
const double len = 40.0;
const double a_in = orientation_deg * M_PI / 180.0;
const double a_out = (orientation_deg + turn_deg) * M_PI / 180.0;
std::ostringstream extrude;
if (e_per_mm > 0.0)
extrude << std::fixed << std::setprecision(4) << " E" << len * e_per_mm;
std::ostringstream os;
os << std::fixed << std::setprecision(4)
<< "M83\n"
<< "G1 Z0.2 F1200\n"
<< "G1 X" << junction_x - len * std::cos(a_in) << " Y" << junction_y - len * std::sin(a_in) << " F6000\n"
<< "G1 X" << junction_x << " Y" << junction_y << " F9000\n"
<< "G1 X" << junction_x + len * std::cos(a_out) << " Y" << junction_y + len * std::sin(a_out) << " F9000\n";
<< "G1 X" << junction_x << " Y" << junction_y << extrude.str() << " F9000\n"
<< "G1 X" << junction_x + len * std::cos(a_out) << " Y" << junction_y + len * std::sin(a_out)
<< extrude.str() << " F9000\n";
return os.str();
}
@@ -492,7 +497,7 @@ std::string corner_gcode(double turn_deg, double orientation_deg)
double corner_speed(const GCodeProcessorResult& r)
{
for (const auto& mv : r.moves)
if (mv.type == EMoveType::Travel &&
if ((mv.type == EMoveType::Travel || mv.type == EMoveType::Extrude) &&
std::abs(mv.position.x() - junction_x) < 1e-3 &&
std::abs(mv.position.y() - junction_y) < 1e-3)
return mv.actual_feedrate;
@@ -500,11 +505,11 @@ double corner_speed(const GCodeProcessorResult& r)
}
double planned_corner_speed(GCodeFlavor flavor, double corner_velocity, double junction_deviation,
double turn_deg, double orientation_deg = 0.0)
double turn_deg, double orientation_deg = 0.0, double e_per_mm = 0.0)
{
GCodeProcessor proc;
run_processor(proc, make_junction_config(flavor, corner_velocity, junction_deviation),
corner_gcode(turn_deg, orientation_deg).c_str());
corner_gcode(turn_deg, orientation_deg, e_per_mm).c_str());
return corner_speed(proc.get_result());
}
@@ -582,3 +587,30 @@ TEST_CASE("Junction deviation is only used where the firmware actually plans wit
Catch::Matchers::WithinRel(without, 1e-4));
}
}
TEST_CASE("How fast a corner is taken does not depend on how much is extruded through it",
"[GCodeTiming][JunctionDeviation]")
{
// The junction cosine is taken over XYZE, so the direction vectors have to be unit length or the
// E term makes the two paths look more parallel than they are and the corner comes out too fast,
// the more so the higher the flow. Marlin normalizes over XYZE on any extruding move
// (planner.cpp, esteps > 0) and Klipper leaves E out of the cosine altogether
// (toolhead.py::Move.calc_junction); on both, this corner is planned by its geometry alone.
const double scv = 5.0;
const double turn = 6.0;
const double geometric = planned_corner_speed(gcfKlipper, scv, 0.0, turn);
REQUIRE(geometric > 0.0);
// 0.029mm/mm is an ordinary 0.42 x 0.2 line on 1.75mm filament; 0.1 is a fat large-nozzle one.
// Unnormalized these came out at 94.4 and 150.0mm/s against a geometric 86.9.
for (double e_per_mm : {0.029, 0.1})
REQUIRE_THAT(planned_corner_speed(gcfKlipper, scv, 0.0, turn, 0.0, e_per_mm),
Catch::Matchers::WithinRel(geometric, 0.02));
SECTION("and the same holds on Marlin 2") {
const double marlin = planned_corner_speed(gcfMarlinFirmware, scv, 0.05, turn);
REQUIRE(marlin > 0.0);
REQUIRE_THAT(planned_corner_speed(gcfMarlinFirmware, scv, 0.05, turn, 0.0, 0.029),
Catch::Matchers::WithinRel(marlin, 0.02));
}
}