Skip to content
This repository has been archived by the owner on Apr 9, 2024. It is now read-only.

Modify ConnectionEvents to specify both endpoints, and implement many to many connections #30

Open
wants to merge 3 commits into
base: main
Choose a base branch
from

Conversation

gmorenz
Copy link
Contributor

@gmorenz gmorenz commented May 26, 2022

This PR could be split into two independent PRs, if you want me to do that let me know. The only reason I'm putting them together is that the last commit depends on the first two (and I don't think github supports stacked PRs?).

The second half is as discussed in #20.

The first half is something I forgot to open an issue on earlier (oops). It's modifying ConnectEventEnded and DisconectEvent to specify both endpoints instead of only the most recent. This simplifies some logic internally to this library, and it means that if the end-application wants to track connection changes it doesn't need to store redundant state about what connection is in progress (the latter of which is why I initially implemented it). The drawback is that if the user code for some reason wants to know on which port the connection ended (instead of just the set of ports a connection was added/removed from) it now needs to track state... I don't imagine that's a common need.

I imagine I could implement many to many connections without implementing the events change, but it simplifies the logic, and I already had it.

Currently graph connections are always one output to many inputs. This
is appropriate for most "data type values", but not always correct. The
motivating example for a place where it is incorrect is "control flow",
where many nodes might "output" control flow to the same node's control
flow input.

This changes that so that it is configurable via data-type whether nodes
are one-many, many-one, many-many, or even one-one with the concept of
splittable data-types (data types that can be copied from one output to
many inputs) and mergeable data-types (data types that can accept many
outputs into a single input).
Copy link
Owner

@setzer22 setzer22 left a comment

Choose a reason for hiding this comment

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

Still need to finish my review (will get to it later). But I had a question about disconnect events in the meantime.

Also, I think the new changes need to be showcased in the example project. Could you add a new data type, something like a VectorList and a new node that takes that vector list as input and adds all the vectors together?

Comment on lines -412 to +413
if is_connected_input {
responses.push(NodeResponse::DisconnectEvent(param_id.assume_input()));
} else {
responses.push(NodeResponse::ConnectEventStarted(node_id, param_id));
}
let response = match unique_connection {
Some(AnyParameterId::Input(input)) => NodeResponse::DisconnectEvent {
input,
output: param_id.assume_output(),
},
Some(AnyParameterId::Output(output)) => NodeResponse::DisconnectEvent {
input: param_id.assume_input(),
output,
},
None => NodeResponse::ConnectEventStarted(node_id, param_id),
};
responses.push(response);
Copy link
Owner

@setzer22 setzer22 May 26, 2022

Choose a reason for hiding this comment

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

How do you disconnect a node when the connection is not unique? I think dragging from an input should start a disconnect event even if the input port has multiple incoming connections.

The logic to select which connection is a bit more nuanced... Here's how blender does it for reference:
blender_multi_conn

In Blender's case, they have some sort of "wide" port and depending on where exactly in the port you drag from you get different connections. Since we don't have this logic here, we could devise a simpler mechanism where you simply disconnect the last connection in the list. This will be a bit iconvenient for users, but I can't think of a better way. I think in the long run we need something like blender's wide ports.

EDIT: Also important to note that for some nodes taking multiple inputs users will care about the order in which those inputs are processed. This again brings us back to blender's solution of the problem.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oh no, forgot about this issue for many-many nodes.

So, for many->one types (which is what I'm using this for in my project), the behavior is just the reverse of one->many nodes. Dragging from the input creates a new edge, dragging from the output lets you delete the edge. This feels right to me. I'm not using one-one nodes, but dragging from either output removes the current edge (which also sounds right).

For many-many data-types, I'm inclined to think that the right solution is to make it possible to select edges. Right now it's not possible to delete many-many edges short of deleting nodes. Alternatively it might just be better to not have many-many data types, and use an alternative solution for them (one of blenders... see below).


I didn't realize this, but blender actually supports (at least) two different solutions for many-many edges.
image

One is a sort of wide port, the other keeps adding more inputs as you fill up the existing inputs.

The latter is something the events changes make pretty easy to implement, I've done it (except for outputs)... and it's also one of the reasons I want #32.

image

Both of these solutions strike me as a decision that should be made by the port, not data that should attach to the data type. I.e. the same data type should be able to be used with wide and "thin" ports.


Circling back to what to do in this PR.

I think selecting edges and/or wide ports really belongs in a follow up PR (it seems unlikely to be a completely trivial change) - but I should probably make sure that wide ports are at least reasonably simple to implement.

If we don't want to go the selecting edges route, it might make sense to change the API for defining data-types so that many-many datatypes are unrepresentable (which shouldn't forbid the use of many-one data-types with wide-ports to emulate them).

We could also conceivably abandon this approach and just go with wide ports. I think this is a worse solution for control flow like data which really is naturally one->many, but it might have some simplicity benefits for programs that don't need that, and it might be possible to make it configurable enough to get good behavior with a bit of extra work.

Copy link
Owner

@setzer22 setzer22 May 29, 2022

Choose a reason for hiding this comment

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

Both of these solutions strike me as a decision that should be made by the port, not data that should attach to the data type. I.e. the same data type should be able to be used with wide and "thin" ports.

You are quite right here, the "cardinality" of a port is a property of a port, so my VectorList datatype example was a bad one. Instead, we should add a port that accepts multiple inputs of type Vector.

it might make sense to change the API for defining data-types so that many-many datatypes are unrepresentable

I think that wouldn't be a very flexible solution. In a regular "data flow" dependency graph, everywhere many-to-one is useful, many-to-many is also useful. Consider this example, here a node like "Join Geometry" would be far less useful if it affected their inputs preventing them from being shared to other outputs. That would be quite a weird thing for a node graph (although Rust has it, feels pretty much like move semantics, and I guess that's why it works well in your case, since you're not modelling data but control flow 🤔)
image

Copy link
Owner

@setzer22 setzer22 May 29, 2022

Choose a reason for hiding this comment

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

After thinking about this a bit more, I think I have come up with a good understanding of the issue and a solution that fits all our use cases 🤔

The common logic in your PR is that dragging away from a port that is "full" (i.e. doesn't accept any more connections) initiates a disconnection event. This works for many-to-one, one-to-many and one-to-one ports, but not for many-to-many ports, because they are never "full". The problem here is that there are two orthogonal concepts that are being mixed together. Let me ellaborate:

We can classify ports in the following ways:

  • Ports have order. A port can be ordered or unordered. An ordered port cares about the order of their incoming/outgoing connections, while an unordered port only cares that the connections exist.
  • On a different scale, ports have cardinality, which can be one-to-one, many-to-one, one-to-many and many-to-many. This affects the allowed connection and disconnection events on that port.

Your control flow "join" port is a one-to-many unordered port, and blender's "Join Geometry" input is a many-to-many ordered port. Other examples exist, and after giving it some thought I think all combinations can have valid use cases 🤔

In the long term (not for this PR) ordered ports can be represented as wide ports, just like in Blender. This allows users to see the order of the different inputs visually, while unordered ports are just a single dot, just like now.

But then, when it comes to this PR, I think the key to solving our issue is separate the cardinality of a port from its disconnect behavior. When a user drags away from a port, instead of using splittable and mergeable to determine whether we should initiate a connect or disconnect event, we instead add a different set of methods disconnect_when_input and disconnect_when_output, so that users can implement their own behavior.

Your control flow ports could return disconnect_when_input = false and disconnect_when_output = true and this would get you the desired behavior. Meanwhile, in an application closer to blender, where the data flow is being modelled, users can set the usual disconnect_when_input = true and disconnect_when_input = false that would be consistent with one-to-many ports. For now, since there are no wide ports, disconnecting from a "many" port would simply drag the last (or whatever) connection, we can improve this UX later on with wide ports.

There is still room for splittable and mergeable. These fields indicate the cardinality of the port, and that is still checked when finishing the connection event. If a port is not mergeable, you won't add an additional connection to it when the user releases an ongoing connection on top of it.

I think this does not take a lot of extra work and solves the issue for everyone. What do you think?

Copy link

Choose a reason for hiding this comment

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

I like the idea of having orthogonal traits of order vs cardinality, but I think I would strongly recommend using wide ports regardless of whether the port is ordered or unordered.

I think it's very unlikely that there would be a use case where someone always wants all connections to be pulled off of a port when clicking and dragging on a port. Instead I think "ordered" vs "unordered" should just decide whether or not the library is allowed to reorder the connections in the wide port to eliminate tangling. That would probably be a future feature outside the scope of an initial many-to-many port PR.

To allow users to choose between removing a connection vs creating a connection for wide ports, I recommend a tweak to the UI, drafted in this image:
User Interface

  • Pulling and dragging from the white box area of the Multi Port will always begin to create a connection.
  • Hovering the mouse inside the border of the box will make the whole box highlighted with white.
  • Pulling on the unused dot will also begin to create a connection.
  • Pulling on the dot of an existing connection will pull that connection off of the port.
  • Dragging the dot of another port anywhere inside the white box will create a connection to that port.
  • The white box area will grow vertically as more connections are added, and the box will always wrap around all the connections that the port has.

Having this additional square element will also help to visually communicate that multiple connections to this port are allowed. We can have the unused dot disappear when the port reaches its limit for number of connections to communicate that the port does not accept any more connections.

I naively assume that this should be a relatively simple interface to implement. Probably the hardest part will be making the row height for this port dynamically grow.

If this suggestion sounds reasonable then I can try taking a stab at implementing for this PR.

Copy link
Owner

Choose a reason for hiding this comment

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

@mxgrey Right, you have a point there. My original comment was a bit biased by the fact that we didn't have a wide port, but if the plan is to implement it, I agree it makes sense to have it on both cases.

I like your description for the UX of the wide port. I'd be happy to have something like that. Feels more explicit than blender's approach 👍

@gmorenz
Copy link
Contributor Author

gmorenz commented May 26, 2022

Could you add a new data type, something like a VectorList and a new node that takes that vector list as input and adds all the vectors together?

Hmm, so a few issues

Currently DataType's decide whether or not we can feed them with a Eq implementation, so I can't accept Vector's into a VectorList input. Maybe something to think about changing, but definitely not here. I guess I could make a "convertToList" node though that does a mutli-input thing like in my sequence screenshot in the above discussion (which would be a good example of how to use events, if nothing else).

Lists also strike me as something that belongs in the normal many-one world, because merging them cares about order, I guess I could do something with sets instead though so that's not a huge issue. Sets at least plausibly being a many-many data type with implicit union.

I'll throw together some examples, and we can see what we want to keep.

@setzer22
Copy link
Owner

setzer22 commented May 29, 2022

Currently DataType's decide whether or not we can feed them with a Eq implementation, so I can't accept Vector's into a VectorList input.

I think this raises an excellent point in favor of dropping the idea of VectorList. Your comment above made me think about this and I think it's best if we keep everything Vector and make the cardinality a property of ports, not datatypes.

@setzer22
Copy link
Owner

Just a heads up, I pushed a minor fix in #36 that will likely conflict with this PR. The code needs to be adapted so that when a node is deleted, the relevant DisconnectEvents are sent to the user. Right now this only happens for inputs, but it will need to happen for outputs as well in the new version.

@mxgrey
Copy link

mxgrey commented Jul 15, 2022

After looking over the existing PR and making a vain attempt to merge main into this branch, I think I'd like to start from a fresh slate and make a new PR directly off of main that implements my suggestions here.

Rather than adding mergeable and splittable to DataTypeTrait I think I'd like to introduce the concept of a Port to replace the (String, InputId) tuple here. The Port type would specify a name and an Option<usize> connection limit with None indicating no limit to the number of connections.

@setzer22
Copy link
Owner

After looking over the existing PR and making a vain attempt to merge main into this branch, I think I'd like to start from a fresh slate and make a new PR directly off of main that implements my suggestions here.

Rather than adding mergeable and splittable to DataTypeTrait I think I'd like to introduce the concept of a Port to replace the (String, InputId) tuple here. The Port type would specify a name and an Option<usize> connection limit with None indicating no limit to the number of connections.

Yes, unfortunately main has moved quite a bit from this PR. I think your idea looks reasonable so if you find it easier to start a new PR please feel free! 👍

@setzer22
Copy link
Owner

Hi @mxgrey! Any news on this? 😄

@mxgrey
Copy link

mxgrey commented Sep 15, 2022

I did make a lot of progress which you can see in the hierarchical_design branch of my fork. The changes are so extensive that it's not in a usable state yet, and unfortunately I had to shelve the effort because of an onslaught of urgent deadlines at my day job.

I fully intend to resume working on it as soon as time is available, and in the best case scenario I may be able to use time from my day job for that effort.

A quick overview of the design that I'm working towards is that the node graphs will have a more hierarchical architecture.

  • A graph will contain some number of nodes and connections.
  • A node implements the NodeTrait allowing custom node design and contains some number of ports.
  • A port implements the PortTrait allowing custom port design and contains some number of hooks. The exact behavior of a port (e.g. how many hooks it supports) can be customized by the implementer of PortTrait.
  • A hook is a unique ID within a port. The tuple of (node_id, port_id, hook_id) defines a fully unique connection endpoint.
  • A connection contains two (node_id, port_id, hook_id) tuples, one for the output connection and one for the input connection.

Many-to-many, many-to-one, and one-to-many connections are achieved by defining the behavior of each port (e.g. how many hooks are available, and whether that number can grow). Disconnecting a connection is done by requesting the removal of a connection from the (node_id, port_id, hook_id) of the input or output endpoint of a connection (the opposite endpoint will automatically be freed as well). Visually you would click and drag the connection off of the little ball that represents the hook.

I'll open a PR as soon as the changes are usable. I can't offer an estimate on when that will be at the moment because I still have some urgent deadlines, but at the absolute latest I plan on taking time off work in late December, so I'll certainly be able to make quick progress at that point.

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants