Skip to content

EMSUSD-2681 - Fix duplicate not duplicating connections to NodeGraph outputs. #4280

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: dev
Choose a base branch
from

Conversation

frohnej-adsk
Copy link
Collaborator

@frohnej-adsk frohnej-adsk commented Jul 25, 2025

The bug could be reproduced easily in LookdevX:

  • Create an add node inside of a compound
  • Connect the add node to an output of the compound
  • Copy/Paste the compound
  • Connection is missing in the copied compound

@frohnej-adsk frohnej-adsk self-assigned this Jul 25, 2025
Copy link
Collaborator Author

@frohnej-adsk frohnej-adsk left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All builds are green except for 2023 (Windows), where 305 - GTest:AL_USDTransactionTests timed out. I can rerun the preflight once the PR was reviewed.

Comment on lines -175 to -179
auto itPath = otherPairs.lower_bound(finalPath);
if (itPath != otherPairs.begin()) {
--itPath;
}
const auto endPath = otherPairs.upper_bound(finalPath);
Copy link
Collaborator Author

@frohnej-adsk frohnej-adsk Jul 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The bug was caused by these lower_bound and upper_bound optimizations.

Cause of the bug

Assume the following hierarchy:

mtl
\---standard_surface1
    \---compound1
        \---add1

Let /mtl/standard_surface1/compound1.outputs:out be connected to /mtl/standard_surface1/compound1/add1.outputs:out.

Upon copying /mtl/standard_surface1/compound1 to the clipboard, updateSdfPathVector() will be called for the attribute /compound1.outputs:out within the clipboard with the following parameters:

  • pathVec = ["/compound1/add1.outputs:out"]
  • duplicatePair = ("/mtl/standard_surface1/compound1", "/compound1")
  • otherPairs = [("/mtl/standard_surface1/compound1", "/compound1")]

Note that pathVec already contains the correct updated path. This case is supposed to be handled by the if (*itPath == duplicatePair) branch. However, due to the optimization, that line is never hit and we ended up erroneously deleting the connection:

SdfPaths are compared lexicographically. Thus, finalPath == /compound1/add1.outputs:out is less than /mtl/standard_surface1/compound1.

  • otherPairs.lower_bound(finalPath) returns the first element e such that finalPath <= e. That's /mtl/standard_surface1/compound1.
  • otherPairs.upper_bound(finalPath) returns the first element e such that finalPath < e. That's /mtl/standard_surface1/compound1.

I.e., lower_bound(finalPath) == upper_bound(finalPath), which causes the loop to never be entered and the connection to be deleted because it's assumed to be external.

Optimization and proposed fix

I'm still struggling to wrap my head around the optimization. I understand why it fails in this case but I don't fully understand how it works in regular cases. The STL lower_bound() and upper_bound() methods don't really do what their name suggests, which makes it kind of confusing (see https://stackoverflow.com/a/67551612) and makes me wonder if it actually works as intended.

Thus, I simply removed the optimization in this PR. That might be good enough and it makes the code easier to read and more similar to the other duplicate command (lib\mayaUsd\ufe\UsdUndoDuplicateSelectionCommand.cpp). If you can think of a good way to fix the bug while keeping the optimization, let me know!

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The big question is "why is a remapping function being called on something that is already remapped". The updated code currently "stumbles" across it via the duplicatePair entry because it iterates everything, which is quite bad performance wise.

Let's try to explain the weird code you are seeing. The map contains prefixes that are the shortest possible, so even though you duplicated both /mtl/standard_surface1/compound1 and /mtl/standard_surface1/compound1/add1 the map will only contain /mtl/standard_surface1/compound1.

Now, let's say I want to know what happened to /mtl/standard_surface1/compound1/add1 and search for it using lower_bound(). I then get an iterator that is 1 element past /mtl/standard_surface1/compound1 so I need to back up one element when possible to include the prefix in the loop. I will end up on the /mtl/standard_surface1/compound1 entry which will allow me to remap correctly using ReplacePrefix.

If, instead I want to know about /mtl/standard_surface1/compound1, then the iterator returned by lower_bound() will actually directly point to it. We still go back one item (for nothing), but will get it on the second iteration. On the second iteration we will hit the equal case and directly rewrite to the destination path in pathVec.

In all cases upper_bound() will either be equal to lower_bound() if finalPath was not directly in the map, or will point one element past it if it was found, which is exactly what we want.

It is an interesting dance, but it actually works. This obviously requires a bit more code documentation.

But let's get back to your problem. The case you are seeing is that you have a connection on the compound boundary that is pointing inside the compound and should be left alone. If you look at what happens in the caller on the exit of that function you either remap modified paths, or remove the attribute. There is no "leave it alone" option.

This means the caller should check for that and remove paths that are obviously correct:

void UsdUndoDuplicateSelectionCommand::execute()
{
...
                    attr.GetConnections(&sources);
                    // Sources that are already inside the duplicate should be ignored:
                    const auto new_end = std::remove_if(sources.begin(), sources.end(), [&duplicatePair](const auto& path){ return path.HasPrefix(duplicatePair.second); });
                    sources.erase(new_end, sources.end());
                    // Now we know we only have external sources left:
                    if (!sources.empty() && updateSdfPathVector(sources, duplicatePair, stageData.second)) {
...
                    // Need the exact same for properties
...
}

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not good enough. Too brutal. If there is a mix of internal and external connections we might end up calling ClearConnections(). I suspect we need to process connections one by one.
What we want is:

finalSources = []
for cnx in sources:
    if cnx.HasPrefix(duplicatePair.second):
        # Internal, keep it:
        finalSources.push_back(cnx)
        continue  # Internal

    remappedCnx = None
    it = stageData.second.lower_bound(cnx)
    if it.first == cnx:
        # Direct match to one of the duplicate sources: remap
        # Note. Probably won't happen.
        remappedCnx = it.second
    else:
        # Can we find the prefix by going back one item?
        if it != stageData.second.begin():
            --it
            if cnx.HasPrefix(it.first):
                remappedCnx = cnx.RemapPrefix(it.first, it.second)
                
    if remappedCnx:
        # Remapped from external to internal:
        finalSources.push_back(remappedCnx)
    
    # Else that external connection gets removed
    
# Identical as before:
if (sources.empty()) {
    attr.ClearConnections();
    if (!attr.HasValue() && !UsdShadeNodeGraph(attr.GetPrim())) {
        p.RemoveProperty(prop.GetName());
    }
} else {
    attr.SetConnections(sources);
}

Could probably be done by the existing algo, by ignoring the changed return value in the caller and by keeping items that are already remapped by adding a (finalPath.HasPrefix(duplicatePair.second) || finalPath == duplicatePair.second) check at the beginning of the loop to continue and leave the item unchanged in the final vector.

Comment on lines -175 to -179
auto itPath = otherPairs.lower_bound(finalPath);
if (itPath != otherPairs.begin()) {
--itPath;
}
const auto endPath = otherPairs.upper_bound(finalPath);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The big question is "why is a remapping function being called on something that is already remapped". The updated code currently "stumbles" across it via the duplicatePair entry because it iterates everything, which is quite bad performance wise.

Let's try to explain the weird code you are seeing. The map contains prefixes that are the shortest possible, so even though you duplicated both /mtl/standard_surface1/compound1 and /mtl/standard_surface1/compound1/add1 the map will only contain /mtl/standard_surface1/compound1.

Now, let's say I want to know what happened to /mtl/standard_surface1/compound1/add1 and search for it using lower_bound(). I then get an iterator that is 1 element past /mtl/standard_surface1/compound1 so I need to back up one element when possible to include the prefix in the loop. I will end up on the /mtl/standard_surface1/compound1 entry which will allow me to remap correctly using ReplacePrefix.

If, instead I want to know about /mtl/standard_surface1/compound1, then the iterator returned by lower_bound() will actually directly point to it. We still go back one item (for nothing), but will get it on the second iteration. On the second iteration we will hit the equal case and directly rewrite to the destination path in pathVec.

In all cases upper_bound() will either be equal to lower_bound() if finalPath was not directly in the map, or will point one element past it if it was found, which is exactly what we want.

It is an interesting dance, but it actually works. This obviously requires a bit more code documentation.

But let's get back to your problem. The case you are seeing is that you have a connection on the compound boundary that is pointing inside the compound and should be left alone. If you look at what happens in the caller on the exit of that function you either remap modified paths, or remove the attribute. There is no "leave it alone" option.

This means the caller should check for that and remove paths that are obviously correct:

void UsdUndoDuplicateSelectionCommand::execute()
{
...
                    attr.GetConnections(&sources);
                    // Sources that are already inside the duplicate should be ignored:
                    const auto new_end = std::remove_if(sources.begin(), sources.end(), [&duplicatePair](const auto& path){ return path.HasPrefix(duplicatePair.second); });
                    sources.erase(new_end, sources.end());
                    // Now we know we only have external sources left:
                    if (!sources.empty() && updateSdfPathVector(sources, duplicatePair, stageData.second)) {
...
                    // Need the exact same for properties
...
}

Comment on lines -175 to -179
auto itPath = otherPairs.lower_bound(finalPath);
if (itPath != otherPairs.begin()) {
--itPath;
}
const auto endPath = otherPairs.upper_bound(finalPath);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not good enough. Too brutal. If there is a mix of internal and external connections we might end up calling ClearConnections(). I suspect we need to process connections one by one.
What we want is:

finalSources = []
for cnx in sources:
    if cnx.HasPrefix(duplicatePair.second):
        # Internal, keep it:
        finalSources.push_back(cnx)
        continue  # Internal

    remappedCnx = None
    it = stageData.second.lower_bound(cnx)
    if it.first == cnx:
        # Direct match to one of the duplicate sources: remap
        # Note. Probably won't happen.
        remappedCnx = it.second
    else:
        # Can we find the prefix by going back one item?
        if it != stageData.second.begin():
            --it
            if cnx.HasPrefix(it.first):
                remappedCnx = cnx.RemapPrefix(it.first, it.second)
                
    if remappedCnx:
        # Remapped from external to internal:
        finalSources.push_back(remappedCnx)
    
    # Else that external connection gets removed
    
# Identical as before:
if (sources.empty()) {
    attr.ClearConnections();
    if (!attr.HasValue() && !UsdShadeNodeGraph(attr.GetPrim())) {
        p.RemoveProperty(prop.GetName());
    }
} else {
    attr.SetConnections(sources);
}

Could probably be done by the existing algo, by ignoring the changed return value in the caller and by keeping items that are already remapped by adding a (finalPath.HasPrefix(duplicatePair.second) || finalPath == duplicatePair.second) check at the beginning of the loop to continue and leave the item unchanged in the final vector.

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