diff --git a/src/math_channel.cpp b/src/math_channel.cpp index e633c5f..f96d586 100644 --- a/src/math_channel.cpp +++ b/src/math_channel.cpp @@ -215,15 +215,15 @@ double math_step(const MathChannel& channel, MathRuntime& rt, const float* value return rts[static_cast(out)].value; } -bool math_channel_valid(const MathChannel& channel, std::string* why) { - const std::size_t n = channel.nodes.size(); +bool math_channel_valid_nodes(const std::vector& nodes, int output, std::string* why) { + const std::size_t n = nodes.size(); const auto fail = [&](const std::string& text) { if (why != nullptr) *why = text; return false; }; if (n == 0) return fail("empty graph"); 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()) { 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(channel.output) >= n) { + if (output < 0 || static_cast(output) >= n) { return fail("no output chosen"); } + if (why != nullptr) why->clear(); return true; } +bool math_channel_valid(const MathChannel& channel, std::string* why) { + return math_channel_valid_nodes(channel.nodes, channel.output, why); +} + } // namespace udpscope diff --git a/src/math_channel.hpp b/src/math_channel.hpp index d898d16..70ad52d 100644 --- a/src/math_channel.hpp +++ b/src/math_channel.hpp @@ -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). 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& nodes, int output, + std::string* why = nullptr); + } // namespace udpscope diff --git a/src/ui.cpp b/src/ui.cpp index 6757ed8..15d48a8 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -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); } +/// 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(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 in_a(static_cast(n)); + std::vector in_b(static_cast(n)); + for (int i = 0; i < n; ++i) { + in_a[static_cast(i)] = d->nodes[static_cast(i)].in_a; + in_b[static_cast(i)] = d->nodes[static_cast(i)].in_b; + } + (which == 0 ? in_a : in_b)[static_cast(to)] = from; + + std::vector indegree(static_cast(n), 0); + const auto edges_of = [&](int i, int (&out)[2]) { + const MathNode& node = d->nodes[static_cast(i)]; + const int arity = math_op_arity(node.op); + out[0] = arity >= 1 ? in_a[static_cast(i)] : -1; + out[1] = arity >= 2 ? in_b[static_cast(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(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 order; + order.reserve(static_cast(n)); + std::vector done(static_cast(n), false); + for (int step = 0; step < n; ++step) { + int pick = -1; + for (int i = 0; i < n; ++i) { + if (!done[static_cast(i)] && indegree[static_cast(i)] == 0) { + pick = i; + break; + } + } + if (pick < 0) { + if (why != nullptr) *why = "that wire would make a loop"; + return false; + } + done[static_cast(pick)] = true; + order.push_back(pick); + for (int i = 0; i < n; ++i) { + if (done[static_cast(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(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 where(static_cast(n), -1); + for (int p = 0; p < n; ++p) where[static_cast(order[static_cast(p)])] = p; + std::vector sorted; + sorted.reserve(static_cast(n)); + for (int p = 0; p < n; ++p) sorted.push_back(d->nodes[static_cast(order[static_cast(p)])]); + const auto remap = [&](int i) { return i >= 0 && i < n ? where[static_cast(i)] : -1; }; + for (int p = 0; p < n; ++p) { + MathNode& node = sorted[static_cast(p)]; + const int old = order[static_cast(p)]; + node.in_a = math_op_arity(node.op) >= 1 ? remap(in_a[static_cast(old)]) : -1; + node.in_b = math_op_arity(node.op) >= 2 ? remap(in_b[static_cast(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) { MathDialog* d = static_cast(user); 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; 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) { - MathNode& n = d->nodes[static_cast(target)]; - (which == 0 ? n.in_a : n.in_b) = d->wire_from; - d->selected = target; - math_dialog_sync(d); + std::string why; + if (math_connect(d, d->wire_from, target, which, &why)) { + // `math_connect()` may have permuted the list, so the target's index is no longer `target`. + 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(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. d->drag_mode = MathDrag::None; @@ -7343,6 +7440,60 @@ gboolean math_probe(gpointer user) { p->selected_wire = MathWireRef{-1, 0}; } } + if (a->math_dialog != nullptr) { + auto* c = static_cast( + 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); return G_SOURCE_REMOVE; }