Skip to content

work around Eulerian paths bug causing #687 and #688#733

Closed
tomjnixon wants to merge 1 commit intopcb2gcode:masterfrom
tomjnixon:fix_eulerian_paths
Closed

work around Eulerian paths bug causing #687 and #688#733
tomjnixon wants to merge 1 commit intopcb2gcode:masterfrom
tomjnixon:fix_eulerian_paths

Conversation

@tomjnixon
Copy link
Copy Markdown

This is a work-around for issues like #687 and #688.

I've spent a few evenings debugging this, and the problem is in the Eulerian paths code. The paths are valid at the input, and not at the output, because sitich_loop sometimes finds a path which is not a loop, and splices it in the middle of another path, causing an arbitrary jump.

The ultimate cause of this is not completely clear to me yet. I think the code is well-behaved, but I'm suspicious of the algorithm. Specifically The conditions for Eulerian paths on mixed graphs are a bit more complex than those for directed/undirected graphs:

https://en.wikipedia.org/wiki/Eulerian_path#Mixed_Eulerian_graphs

It seems like the application of Hierholzer's algorithm to mixed graphs might not be straightforward. As a concrete example, I don't think the assumptions in this comment hold:

// All vertices have out edges <= in edges. But total out edges == total in
// edges so all vertices must have an equal number of out and in edges. So
// if we make a path from one, it is sure to end back where it started.
// We'll go over all our current Euler paths and stitch in loops anywhere
// that there is an unvisited edge.

With something like this at that position, plenty of vertices with odd edge counts can be seen in problematic examples:

for (const auto& vertex : all_start_vertices) {
  auto out_edges = start_vertex_to_unvisited_path_index.count(vertex);
  auto in_edges = end_vertex_to_unvisited_path_index.count(vertex);
  auto bidi_edges = bidi_vertex_to_unvisited_path_index.count(vertex);

  if ((bidi_edges - std::abs((int)out_edges - (int)in_edges)) % 2 != 0) {
    std::cout << "bad vertex: " << out_edges << " " << in_edges << " " << bidi_edges << "\n";
  }
}

The work-around is to detect unclosed loops in stitch_loops, and add them as new paths instead. These are buffered to avoid invalidating iterators or references. It would be good to document the cause/work-around in the code, but I'd rather wait until we understand it better (or give up).

There are probably some failing tests which need tidying up. As part of finding this I wrote a script to find minimal examples by running pcb2gcode on randomly-generated gerbers and checking the output. With this change this no longer finds any issues. I've attached a minimal example for completeness:

minimal_example.zip

This fails with current master ( 520920e ) and:

Boost: 108700
Gerbv: 2.10.0
Geos: Not installed

The related issues mention a boost change as a possible cause. I think this is a red herring; I saw failures with boost 1.77 too. Very minor geometry changes can cause large changes in graph topology, which triggers or avoids the bug.

`sitich_loop` sometimes finds a path which is not a loop, and splices it in the
middle of another path, causing an arbitrary jump. detect unclosed loops
and add them as new paths instead

Fixes issues like those in pcb2gcode#687 and pcb2gcode#688.
@tomjnixon tomjnixon changed the title work around eulerian paths bug causing #687 and #688 work around Eulerian paths bug causing #687 and #688 Dec 12, 2025
@pcb2gcode pcb2gcode deleted a comment from EasyCov-bot Dec 14, 2025
@eyal0
Copy link
Copy Markdown
Contributor

eyal0 commented Dec 14, 2025

I'll have to think about this. I would like to find a small example of the problem to put into a unit test!

@tomjnixon
Copy link
Copy Markdown
Author

I know that I worked pretty hard on this algorithm

Yeah, it shows. It's great to work on code like this!

I wish there was a small counter-example too, I’ll try to work on that. I think that would help narrow down where the actual problem is.

@tomjnixon
Copy link
Copy Markdown
Author

Here's a simple one:

  using point_t = int;
  using linestring_t = std::vector<point_t>;

  std::vector<std::pair<linestring_t, bool>> paths = {
    {{1, 0}, false},
    {{2, 0}, false},
    {{1, 2}, true},
  };
  auto euler_paths = eulerian_paths::get_eulerian_paths<point_t, linestring_t>(paths);

No points are considered must_start, so only the final step does anything. It starts at point 1, makes a path {1, 0}, then stitch_loops tries to stitch in {1, 2, 0}, making {1, 2, 0, 0}, which is not a valid result. With the change it returns {1, 0} and {1, 2, 0}.

It would be good to find one where the first call to stitch_loops fails, maybe tomorrow.

@eyal0
Copy link
Copy Markdown
Contributor

eyal0 commented Dec 16, 2025

I've got a good idea how to fix this, just thinking about the right way to implement it.

@eyal0
Copy link
Copy Markdown
Contributor

eyal0 commented Dec 16, 2025

Working on it in #736 . @tomjnixon Really good find that the bug is in eulerian paths. I wouldn't have guessed!

I was able to fix the error that you found here. I didn't think through the logic correctly and I thought that it was possible to stitch loops early. It works like this:

In a normal example with all bi-directional edges, you can know for sure that every intersection of points that has an odd number of edges must be the start/end of a path. So you go to one of those and then you make a path on edges as long as you can until you reach a vertex where you cannot continue because there are no more untraveled edges out of it. Then you find another vertex that still has an odd number of edges and you follow that until you hit a dead end. Do that until there are no more vertices with dead-ends.

After that, now you can take a path that you have already created and walk along it looking for any vertices with untraveled edges. Any path from there must eventually return there because after you traverse from it, there will only be one more vertex that could have a dead-end, and it is the same one. That is a loop and you stitch it into your path. You then keep doing that on the path until you get all the loops done. Do it for all paths.

Finally, do it for all nodes that aren't a part of any path and you're done.

The issue is with directional edges. We can only treat a node as having an even number of nodes if it could be even. That comes from the number of out edges matching the number of in edges where some of the bi-directional edges could be used as out or in.

My error was that I didn't realize that not only do we have to take care of nodes that have a combination of in, out, and bidi edges that require them to be a starting point, we are also reaching situations where nodes have a combination of in/out/bidi that require them to be end points.

I'm now handling that and this solved some of the problems but not all of them.


So far, in #736, I have some progress and some of the errors went away, but not all. I'm going to write some code that checks the inputs against the output of the algorithm, and will print out the buggy input/output pair. Then I can put it into the unit test and work against that, which will be faster. I hope that the error case will not be too big.

@eyal0
Copy link
Copy Markdown
Contributor

eyal0 commented Dec 24, 2025

Possibly fixed in #743. Reopen this if the bug is not fixed.

@eyal0 eyal0 closed this Dec 24, 2025
@tomjnixon
Copy link
Copy Markdown
Author

Nice, thank you. I'll give it a go with my random test generator.

I think I was wrong to say that there isn't a boost-geometry-related issue though. I've got a test case where the buffer operation goes wrong, causing some traces to be missing from the SegmentTree; i'll open a new issue with that when I've tidied it up.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants