feat: a backwards wire re-sorts the graph instead of being refused
The evaluator walks the node list in order, which used to make the order the user's problem -- an input of an earlier node was not even drawn as connectable. `math_connect()` runs Kahn's algorithm over the trial edge set, with ties broken by the current index so a graph already in order is not permuted, and remaps every index the dialog holds in one block. A self wire and a cycle are refused with a message and change nothing.
This commit is contained in:
1 parent
4c726adb54
commit
5ca762031d
3 files changed
+168
-7
No files matched your search
@@ -215,15 +215,15 @@ double math_step(const MathChannel& channel, MathRuntime& rt, const float* value
|
|||||||
return rts[static_cast<std::size_t>(out)].value;
|
return rts[static_cast<std::size_t>(out)].value;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool math_channel_valid(const MathChannel& channel, std::string* why) {
|
bool math_channel_valid_nodes(const std::vector<MathNode>& nodes, int output, std::string* why) {
|
||||||
const std::size_t n = channel.nodes.size();
|
const std::size_t n = nodes.size();
|
||||||
const auto fail = [&](const std::string& text) {
|
const auto fail = [&](const std::string& text) {
|
||||||
if (why != nullptr) *why = text;
|
if (why != nullptr) *why = text;
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
if (n == 0) return fail("empty graph");
|
if (n == 0) return fail("empty graph");
|
||||||
for (std::size_t i = 0; i < n; ++i) {
|
for (std::size_t i = 0; i < n; ++i) {
|
||||||
const MathNode& node = channel.nodes[i];
|
const MathNode& node = nodes[i];
|
||||||
if (node.op == MathOp::Source && node.source.empty()) {
|
if (node.op == MathOp::Source && node.source.empty()) {
|
||||||
return fail("node " + std::to_string(i + 1) + ": no channel chosen");
|
return fail("node " + std::to_string(i + 1) + ": no channel chosen");
|
||||||
}
|
}
|
||||||
@@ -238,10 +238,15 @@ bool math_channel_valid(const MathChannel& channel, std::string* why) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (channel.output < 0 || static_cast<std::size_t>(channel.output) >= n) {
|
if (output < 0 || static_cast<std::size_t>(output) >= n) {
|
||||||
return fail("no output chosen");
|
return fail("no output chosen");
|
||||||
}
|
}
|
||||||
|
if (why != nullptr) why->clear();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool math_channel_valid(const MathChannel& channel, std::string* why) {
|
||||||
|
return math_channel_valid_nodes(channel.nodes, channel.output, why);
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace udpscope
|
} // namespace udpscope
|
||||||
@@ -117,4 +117,9 @@ double math_step(const MathChannel& channel, MathRuntime& rt, const float* value
|
|||||||
/// input it needs connected to a node that exists (with `Source` nodes naming something).
|
/// input it needs connected to a node that exists (with `Source` nodes naming something).
|
||||||
bool math_channel_valid(const MathChannel& channel, std::string* why = nullptr);
|
bool math_channel_valid(const MathChannel& channel, std::string* why = nullptr);
|
||||||
|
|
||||||
|
/// The same check over a bare node list and output index, for a caller that has a graph but not yet
|
||||||
|
/// a `MathChannel` -- the editor, and the headless probe that drives it.
|
||||||
|
bool math_channel_valid_nodes(const std::vector<MathNode>& nodes, int output,
|
||||||
|
std::string* why = nullptr);
|
||||||
|
|
||||||
} // namespace udpscope
|
} // namespace udpscope
|
||||||
+154
-3
@@ -3565,6 +3565,97 @@ MathWireRef math_wire_at_canvas(const MathDialog* d, double cx, double cy) {
|
|||||||
return math_wire_at(d, gx, gy, kWirePick / d->view_scale);
|
return math_wire_at(d, gx, gy, kWirePick / d->view_scale);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wires `from`'s output into `to`'s input `which`. The evaluator walks the node list in order, so
|
||||||
|
/// a reader must sit after what it reads -- and rather than refuse a wire drawn right to left (the
|
||||||
|
/// old editor did not even draw such an input as connectable, which made the order the *user's*
|
||||||
|
/// problem), the list is re-sorted so that it holds. Returns false and sets `why` for a wire that
|
||||||
|
/// cannot be ordered at all, i.e. one that would close a cycle; nothing is written in that case.
|
||||||
|
bool math_connect(MathDialog* d, int from, int to, int which, std::string* why) {
|
||||||
|
const int n = static_cast<int>(d->nodes.size());
|
||||||
|
if (from < 0 || from >= n || to < 0 || to >= n) return false;
|
||||||
|
if (from == to) {
|
||||||
|
if (why != nullptr) *why = "a node cannot read itself";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Kahn's algorithm over the *trial* edge set, so a cycle is found before anything is written.
|
||||||
|
std::vector<int> in_a(static_cast<std::size_t>(n));
|
||||||
|
std::vector<int> in_b(static_cast<std::size_t>(n));
|
||||||
|
for (int i = 0; i < n; ++i) {
|
||||||
|
in_a[static_cast<std::size_t>(i)] = d->nodes[static_cast<std::size_t>(i)].in_a;
|
||||||
|
in_b[static_cast<std::size_t>(i)] = d->nodes[static_cast<std::size_t>(i)].in_b;
|
||||||
|
}
|
||||||
|
(which == 0 ? in_a : in_b)[static_cast<std::size_t>(to)] = from;
|
||||||
|
|
||||||
|
std::vector<int> indegree(static_cast<std::size_t>(n), 0);
|
||||||
|
const auto edges_of = [&](int i, int (&out)[2]) {
|
||||||
|
const MathNode& node = d->nodes[static_cast<std::size_t>(i)];
|
||||||
|
const int arity = math_op_arity(node.op);
|
||||||
|
out[0] = arity >= 1 ? in_a[static_cast<std::size_t>(i)] : -1;
|
||||||
|
out[1] = arity >= 2 ? in_b[static_cast<std::size_t>(i)] : -1;
|
||||||
|
};
|
||||||
|
for (int i = 0; i < n; ++i) {
|
||||||
|
int e[2] = {-1, -1};
|
||||||
|
edges_of(i, e);
|
||||||
|
for (int k = 0; k < 2; ++k) {
|
||||||
|
if (e[k] >= 0 && e[k] < n && e[k] != i) ++indegree[static_cast<std::size_t>(i)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ties broken by the current list index, so a graph that is already in order is not permuted:
|
||||||
|
// the positions are the user's layout as much as the evaluator's order, and shuffling a valid
|
||||||
|
// graph on every wire would read as the editor rearranging itself.
|
||||||
|
std::vector<int> order;
|
||||||
|
order.reserve(static_cast<std::size_t>(n));
|
||||||
|
std::vector<bool> done(static_cast<std::size_t>(n), false);
|
||||||
|
for (int step = 0; step < n; ++step) {
|
||||||
|
int pick = -1;
|
||||||
|
for (int i = 0; i < n; ++i) {
|
||||||
|
if (!done[static_cast<std::size_t>(i)] && indegree[static_cast<std::size_t>(i)] == 0) {
|
||||||
|
pick = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pick < 0) {
|
||||||
|
if (why != nullptr) *why = "that wire would make a loop";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
done[static_cast<std::size_t>(pick)] = true;
|
||||||
|
order.push_back(pick);
|
||||||
|
for (int i = 0; i < n; ++i) {
|
||||||
|
if (done[static_cast<std::size_t>(i)]) continue;
|
||||||
|
int e[2] = {-1, -1};
|
||||||
|
edges_of(i, e);
|
||||||
|
for (int k = 0; k < 2; ++k) {
|
||||||
|
if (e[k] == pick) --indegree[static_cast<std::size_t>(i)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `order[p]` is the node that moves to position `p`; `where[old]` is where it ends up. Every
|
||||||
|
// index the dialog holds is in the old space and has to be remapped in one block -- a missed one
|
||||||
|
// silently renames an operation or re-points a wire.
|
||||||
|
std::vector<int> where(static_cast<std::size_t>(n), -1);
|
||||||
|
for (int p = 0; p < n; ++p) where[static_cast<std::size_t>(order[static_cast<std::size_t>(p)])] = p;
|
||||||
|
std::vector<MathNode> sorted;
|
||||||
|
sorted.reserve(static_cast<std::size_t>(n));
|
||||||
|
for (int p = 0; p < n; ++p) sorted.push_back(d->nodes[static_cast<std::size_t>(order[static_cast<std::size_t>(p)])]);
|
||||||
|
const auto remap = [&](int i) { return i >= 0 && i < n ? where[static_cast<std::size_t>(i)] : -1; };
|
||||||
|
for (int p = 0; p < n; ++p) {
|
||||||
|
MathNode& node = sorted[static_cast<std::size_t>(p)];
|
||||||
|
const int old = order[static_cast<std::size_t>(p)];
|
||||||
|
node.in_a = math_op_arity(node.op) >= 1 ? remap(in_a[static_cast<std::size_t>(old)]) : -1;
|
||||||
|
node.in_b = math_op_arity(node.op) >= 2 ? remap(in_b[static_cast<std::size_t>(old)]) : -1;
|
||||||
|
}
|
||||||
|
d->nodes = std::move(sorted);
|
||||||
|
d->output = remap(d->output);
|
||||||
|
d->selected = remap(d->selected);
|
||||||
|
d->drag_node = remap(d->drag_node);
|
||||||
|
d->wire_from = remap(d->wire_from);
|
||||||
|
d->wire_detach = remap(d->wire_detach);
|
||||||
|
d->selected_wire.node = remap(d->selected_wire.node);
|
||||||
|
if (why != nullptr) why->clear();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
void math_canvas_draw(GtkDrawingArea*, cairo_t* cr, int width, int height, gpointer user) {
|
void math_canvas_draw(GtkDrawingArea*, cairo_t* cr, int width, int height, gpointer user) {
|
||||||
MathDialog* d = static_cast<MathDialog*>(user);
|
MathDialog* d = static_cast<MathDialog*>(user);
|
||||||
cairo_set_source_rgb(cr, 0.06, 0.078, 0.094);
|
cairo_set_source_rgb(cr, 0.06, 0.078, 0.094);
|
||||||
@@ -3793,10 +3884,16 @@ void math_canvas_drag_end(GtkGestureDrag*, double dx, double dy, gpointer user)
|
|||||||
int which = kPortOut;
|
int which = kPortOut;
|
||||||
const int target = math_port_at_canvas(d, d->press_cx + dx, d->press_cy + dy, which);
|
const int target = math_port_at_canvas(d, d->press_cx + dx, d->press_cy + dy, which);
|
||||||
if (target >= 0 && which != kPortOut && target != d->wire_from) {
|
if (target >= 0 && which != kPortOut && target != d->wire_from) {
|
||||||
MathNode& n = d->nodes[static_cast<std::size_t>(target)];
|
std::string why;
|
||||||
(which == 0 ? n.in_a : n.in_b) = d->wire_from;
|
if (math_connect(d, d->wire_from, target, which, &why)) {
|
||||||
d->selected = target;
|
// `math_connect()` may have permuted the list, so the target's index is no longer `target`.
|
||||||
math_dialog_sync(d);
|
math_dialog_sync(d);
|
||||||
|
} else if (d->wire_detach >= 0) {
|
||||||
|
// A refused wire must not also lose the one the drag detached to start with.
|
||||||
|
MathNode& back = d->nodes[static_cast<std::size_t>(d->wire_detach)];
|
||||||
|
(d->wire_detach_which == 0 ? back.in_a : back.in_b) = d->wire_from;
|
||||||
|
math_dialog_sync(d);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// A release over nothing leaves the input the drag emptied empty: that *is* the detach.
|
// A release over nothing leaves the input the drag emptied empty: that *is* the detach.
|
||||||
d->drag_mode = MathDrag::None;
|
d->drag_mode = MathDrag::None;
|
||||||
@@ -7343,6 +7440,60 @@ gboolean math_probe(gpointer user) {
|
|||||||
p->selected_wire = MathWireRef{-1, 0};
|
p->selected_wire = MathWireRef{-1, 0};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (a->math_dialog != nullptr) {
|
||||||
|
auto* c = static_cast<MathDialog*>(
|
||||||
|
g_object_get_data(G_OBJECT(a->math_dialog), "udpscope-math-dialog"));
|
||||||
|
if (c != nullptr && c->nodes.size() >= 2) {
|
||||||
|
// A wire drawn right-to-left is legal: the editor re-sorts so that the reader still sits
|
||||||
|
// after what it reads. Node 0 is the source and node 1 the gain, so wiring the gain into
|
||||||
|
// the source is impossible (a source has no inputs) -- a second gain reading the first,
|
||||||
|
// placed *before* it, is the case.
|
||||||
|
c->nodes.clear();
|
||||||
|
MathNode src;
|
||||||
|
src.op = MathOp::Source;
|
||||||
|
src.source = name;
|
||||||
|
// The reader is placed *before* what it reads, which is the case the evaluator cannot run
|
||||||
|
// and the editor has to repair. Index 1 will be wired to read index 2, and index 2 reads
|
||||||
|
// the source.
|
||||||
|
MathNode reader;
|
||||||
|
reader.op = MathOp::Gain;
|
||||||
|
MathNode feeder;
|
||||||
|
feeder.op = MathOp::Gain;
|
||||||
|
c->nodes = {src, reader, feeder};
|
||||||
|
c->nodes[2].in_a = 0;
|
||||||
|
// The output is the reader, so the remap is visible in `output` as well as in the wiring.
|
||||||
|
c->output = 1;
|
||||||
|
c->selected = 1;
|
||||||
|
std::string why;
|
||||||
|
const bool ok = math_connect(c, 2, 1, 0, &why);
|
||||||
|
// After the sort node 1 must still be the one that reads 0, and the reader of it must sit
|
||||||
|
// after it. The op is the same for both, so the *wiring* is the reading.
|
||||||
|
std::string order;
|
||||||
|
for (std::size_t i = 0; i < c->nodes.size(); ++i) {
|
||||||
|
order += (i != 0 ? "," : "");
|
||||||
|
order += std::to_string(i) + ":" + std::to_string(c->nodes[i].in_a);
|
||||||
|
}
|
||||||
|
std::string valid_why;
|
||||||
|
const bool valid = math_channel_valid_nodes(c->nodes, c->output, &valid_why);
|
||||||
|
std::fprintf(stderr,
|
||||||
|
"[math-probe] wire: backwards ok=%d order=[%s] output=%d valid=%d why=[%s] "
|
||||||
|
"(want 1 ... 1 [])\n",
|
||||||
|
ok ? 1 : 0, order.c_str(), c->output, valid ? 1 : 0, valid_why.c_str());
|
||||||
|
|
||||||
|
std::string self_why;
|
||||||
|
const bool self_ok = math_connect(c, 1, 1, 0, &self_why);
|
||||||
|
std::fprintf(stderr, "[math-probe] wire: self refused=%d why=[%s] (want 1)\n",
|
||||||
|
self_ok ? 0 : 1, self_why.c_str());
|
||||||
|
|
||||||
|
// A cycle: the node that already feeds the other is asked to read it back.
|
||||||
|
const int reader_idx = c->nodes[1].in_a == 0 ? 2 : 1;
|
||||||
|
const int feeder_idx = reader_idx == 2 ? 1 : 2;
|
||||||
|
std::string cyc_why;
|
||||||
|
const bool cyc_ok = math_connect(c, reader_idx, feeder_idx, 0, &cyc_why);
|
||||||
|
std::fprintf(stderr, "[math-probe] wire: cycle refused=%d why=[%s] (want 1)\n",
|
||||||
|
cyc_ok ? 0 : 1, cyc_why.c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
std::fflush(stderr);
|
std::fflush(stderr);
|
||||||
return G_SOURCE_REMOVE;
|
return G_SOURCE_REMOVE;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user