Skip to content

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

Merged
seando-adsk merged 7 commits intodevfrom
frohnej/EMSUSD-2681/fixCopyCompoundOutputConnection
Jul 31, 2025
Merged

EMSUSD-2681 - Fix duplicate not duplicating connections to NodeGraph outputs.#4280
seando-adsk merged 7 commits intodevfrom
frohnej/EMSUSD-2681/fixCopyCompoundOutputConnection

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
Contributor

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
Contributor

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.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Thank you, this was very helpful to understand the idea of the optimization!

This is my current understanding and what I implemented in my latest commit. Please double-check if it's correct.

The goal is to check if any path in the DuplicatePathsMap is a prefix of the path we want to update. Due to lexicographical ordering, the prefix of a path is always less than or equal to the path itself. We can utilize that to check only a single candidate: The last/greatest path that's less then or equal to the path we want to update. This candidate is right before the path returned by upper_bound(), which is the first path that's greater than the path we want to update.

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
Contributor

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
Contributor

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.

@seando-adsk seando-adsk requested review from pierrebai-adsk and removed request for seando-adsk July 28, 2025 14:36
@seando-adsk
Copy link
Collaborator

@pierrebai-adsk Can you code review these changes?

Copy link
Contributor

@JGamache-autodesk JGamache-autodesk left a comment

Choose a reason for hiding this comment

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

Thanks, the code is easier to understand now.

Copy link
Collaborator

@pierrebai-adsk pierrebai-adsk left a comment

Choose a reason for hiding this comment

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

Just one question about your own comment.

pierrebai-adsk
pierrebai-adsk previously approved these changes Jul 29, 2025
Copy link
Contributor

@JGamache-autodesk JGamache-autodesk left a comment

Choose a reason for hiding this comment

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

LGTM. Thanks!

@frohnej-adsk frohnej-adsk added the ready-for-merge Development process is finished, PR is ready for merge label Jul 30, 2025
@seando-adsk seando-adsk added the ufe-usd Related to UFE-USD plugin in Maya-Usd label Jul 31, 2025
@seando-adsk seando-adsk merged commit 136cf2b into dev Jul 31, 2025
12 of 13 checks passed
@seando-adsk seando-adsk deleted the frohnej/EMSUSD-2681/fixCopyCompoundOutputConnection branch July 31, 2025 12:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-merge Development process is finished, PR is ready for merge ufe-usd Related to UFE-USD plugin in Maya-Usd

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants