From 5966b83e71cbf24164bcec849cd3e244884b3189 Mon Sep 17 00:00:00 2001 From: skyward7187 Date: Wed, 21 Oct 2026 16:10:07 -0400 Subject: [PATCH 1/7] VC3D: link two fibers from the Fiber Map ("Link A and B", pending automatic links) Linking two fibers used to take four menus and two dialog opens. The Fiber Map now does it in two gestures: - Selection: Shift+left click keeps the last two clicked fibers selected (a plain click selects one); both draw bold with their control dots and the status line names the pair; a rebuild keeps the pair. - Ctrl+right-click on either selected fiber adds "Link and " above Delete. It finalizes, saves and closes any open annotation of the two fibers (D8), locates the meeting on the drawn map (the crossing nearest the click, else the closest approach within 1 mm; D2), inverts it exactly to a position on each fiber's stored line through the unrolled arclength the map was drawn with (Run::arclengths), searches the fibers' closest 3D pass only within +-5 mm along each fiber (D3), reuses an UNLINKED control within 1 mm or places a new one clear of every control by the strip's minimum spacing, not beyond a kollesis termination, not in a gap span, inside the focus bounds, re-optimizing the two spans synchronously and saving (D4), never refusing on the 3D gap (D5), and links the two controls with a pending AUTOMATIC link whose pair save is drained before success is reported. - Format (D6): "auto": true next to "pending": true on ordinary links, mirrored on both reciprocal refs and by scripts/fiber_merge.py; the loader downgrades a one-sided mark to plain pending on both sides; combining two refs (collapse, dedup, merge) is automatic only if every pending contributor was; approval clears it. - Markers (D7): an automatic pending point draws with its own look everywhere a linked point is drawn (cut views and strips in both renderers, overview bar, slice-view rings, Fiber Map dots); a tagged point keeps its tag ring; a human-made pending link on the same point outranks. Robustness, from the review rounds folded into this commit: the controller's auto-link is guarded after every yield (package, load sequence, volume, fiber generation, save failures, focus bounds, map dependencies, open panes, running solves), re-resolves stored fibers after each, validates control identity before and after each solve, reads a saved control back before any verdict, tracks the installed link's endpoints through moves (TrackedLinkEndpoints) so a failed pair write withdraws every copy (stored records, queued snapshots, open sessions and their rollback copies) and re-saves the withdrawn state, holds the structural-edit guard for its duration, and keeps an operation message on the map's status line across staleness refreshes. The geometry is exact (accurate cross products, exact window clipping, representable boundary candidates, strict spacing gate). A reload's downgrade reaches open sessions. Tests: apps/VC3D/test/ test_fiber_map_auto_link.cpp, the global-layout and link-validation tests, scripts/tests/test_fiber_merge.py. Docs: "Linking two fibers from the Fiber Map" in docs/line_annotation_fibers.md. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PrhaQ58LygRfA7y3Psp7uf --- volume-cartographer/apps/VC3D/CMakeLists.txt | 1 + volume-cartographer/apps/VC3D/CWindow.cpp | 16 +- .../apps/VC3D/FiberMapAutoLink.cpp | 852 ++++++++ .../apps/VC3D/FiberMapAutoLink.hpp | 148 ++ .../apps/VC3D/FiberMapWorkspace.cpp | 742 ++++++- .../apps/VC3D/FiberMapWorkspace.hpp | 66 +- .../apps/VC3D/FiberNetworkLayout.cpp | 160 +- .../apps/VC3D/FiberNetworkLayout.hpp | 59 + .../apps/VC3D/LineAnnotationController.cpp | 1943 +++++++++++++++-- .../apps/VC3D/LineAnnotationController.hpp | 186 +- .../apps/VC3D/LineAnnotationDialog.cpp | 89 +- .../apps/VC3D/LineAnnotationDialog.hpp | 7 + .../apps/VC3D/LineAnnotationFiberLinks.hpp | 130 ++ .../VC3D/LineAnnotationGeneratedViews.cpp | 53 +- .../VC3D/LineAnnotationGeneratedViews.hpp | 14 + .../apps/VC3D/LineAnnotationStoredFiber.hpp | 6 + .../VC3D/overlays/FiberOverlayController.cpp | 64 +- .../VC3D/overlays/FiberOverlayController.hpp | 18 +- .../apps/VC3D/test/CMakeLists.txt | 25 + .../VC3D/test/test_fiber_global_layout.cpp | 70 + .../VC3D/test/test_fiber_map_auto_link.cpp | 808 +++++++ .../test_line_annotation_link_validation.cpp | 170 ++ .../docs/adjacent-fiber-links.html | 2 +- .../docs/line_annotation_fibers.md | 91 +- volume-cartographer/scripts/fiber_merge.py | 85 +- .../scripts/tests/test_fiber_merge.py | 122 ++ 26 files changed, 5554 insertions(+), 373 deletions(-) create mode 100644 volume-cartographer/apps/VC3D/FiberMapAutoLink.cpp create mode 100644 volume-cartographer/apps/VC3D/FiberMapAutoLink.hpp create mode 100644 volume-cartographer/apps/VC3D/test/test_fiber_map_auto_link.cpp diff --git a/volume-cartographer/apps/VC3D/CMakeLists.txt b/volume-cartographer/apps/VC3D/CMakeLists.txt index 94b4f2dbc5..b4d513deed 100644 --- a/volume-cartographer/apps/VC3D/CMakeLists.txt +++ b/volume-cartographer/apps/VC3D/CMakeLists.txt @@ -45,6 +45,7 @@ set(srcs FiberMapKollesis.cpp FiberMapKollesis.hpp FiberNetworkLayout.cpp + FiberMapAutoLink.cpp FiberNetworkLayout.hpp FiberWindingSolver.cpp FiberWindingSolver.hpp diff --git a/volume-cartographer/apps/VC3D/CWindow.cpp b/volume-cartographer/apps/VC3D/CWindow.cpp index c44c34c6ff..62cf2aede0 100644 --- a/volume-cartographer/apps/VC3D/CWindow.cpp +++ b/volume-cartographer/apps/VC3D/CWindow.cpp @@ -8026,11 +8026,17 @@ void CWindow::CreateWidgets(void) } if (const auto it = linkInfoById.find(snapshot.id); it != linkInfoById.end()) { chain.colorId = it->second->linkGroupId; - chain.pointLinkStates.assign(chain.points.size(), 0); - for (const auto& [cpIndex, pending] : it->second->linkedControlPoints) { - if (cpIndex >= 0 && - cpIndex < static_cast(chain.pointLinkStates.size())) { - chain.pointLinkStates[cpIndex] = pending ? 1 : 2; + chain.pointLinkMarks.assign(chain.points.size(), {}); + for (const auto& mark : it->second->linkedControlPoints) { + if (mark.controlIndex >= 0 && + mark.controlIndex < static_cast(chain.pointLinkMarks.size())) { + FiberOverlayController::PointLinkMark& out = + chain.pointLinkMarks[static_cast(mark.controlIndex)]; + out.state = static_cast(mark.state); + out.sameHv = mark.sameHv; + out.kollesisTermination = mark.kollesisTermination; + out.breakTag = mark.breakTag; + out.adjacent = mark.adjacent; } } } diff --git a/volume-cartographer/apps/VC3D/FiberMapAutoLink.cpp b/volume-cartographer/apps/VC3D/FiberMapAutoLink.cpp new file mode 100644 index 0000000000..2fdd4f1956 --- /dev/null +++ b/volume-cartographer/apps/VC3D/FiberMapAutoLink.cpp @@ -0,0 +1,852 @@ +#include "FiberMapAutoLink.hpp" + +#include +#include +#include +#include + +namespace vc3d::fiber_map::autolink +{ +namespace +{ + +struct DrawnRun { + std::vector points; + std::vector arclengths; +}; + +// The drawn geometry of every run (displayRunPoints) with the matching +// arclength per drawn sample: where the overlap sample was replaced by a +// control's drawn position, the arclength is that control's. +std::vector drawnRuns(const PlacedFiber& fiber) +{ + std::vector runs; + runs.reserve(fiber.runs.size()); + for (std::size_t runIndex = 0; runIndex < fiber.runs.size(); ++runIndex) { + const Run& run = fiber.runs[runIndex]; + DrawnRun drawn; + drawn.points = displayRunPoints(fiber, runIndex); + drawn.arclengths = run.arclengths; + if (drawn.arclengths.size() != drawn.points.size()) { + // A run without arclengths cannot be inverted; it is not searched. + drawn.points.clear(); + drawn.arclengths.clear(); + runs.push_back(std::move(drawn)); + continue; + } + if (drawn.points.size() >= 2) { + // The same conditions displayRunPoints replaces the overlap + // samples under (a styled run, or a neighbour of one), decided + // from the run styles - never from comparing points, whose + // fuzzy equality would miss a replacement by a near-coincident + // control. + const auto styled = [](const Run& other) { return other.gap || other.damaged; }; + const bool previousStyled = runIndex > 0 && styled(fiber.runs[runIndex - 1]); + const bool nextStyled = + runIndex + 1 < fiber.runs.size() && styled(fiber.runs[runIndex + 1]); + const auto controlArc = [&fiber](int index) -> std::optional { + if (index < 0 || static_cast(index) >= fiber.controlArclength.size() || + static_cast(index) >= fiber.controlPoints.size()) { + return std::nullopt; + } + return fiber.controlArclength[static_cast(index)]; + }; + if (styled(run) || previousStyled) { + if (const auto s = controlArc(run.firstControl)) { + drawn.arclengths.front() = *s; + } + } + if (styled(run) || nextStyled) { + if (const auto s = controlArc(run.lastControl)) { + drawn.arclengths.back() = *s; + } + } + } + runs.push_back(std::move(drawn)); + } + return runs; +} + +struct Box { + double x0 = 0.0, y0 = 0.0, x1 = 0.0, y1 = 0.0; +}; + +Box boundsOf(const std::vector& points) +{ + Box box{std::numeric_limits::infinity(), std::numeric_limits::infinity(), + -std::numeric_limits::infinity(), -std::numeric_limits::infinity()}; + for (const QPointF& p : points) { + box.x0 = std::min(box.x0, p.x()); + box.y0 = std::min(box.y0, p.y()); + box.x1 = std::max(box.x1, p.x()); + box.y1 = std::max(box.y1, p.y()); + } + return box; +} + +bool boxesOverlap(const Box& a, const Box& b, double margin) +{ + return a.x0 <= b.x1 + margin && b.x0 <= a.x1 + margin && + a.y0 <= b.y1 + margin && b.y0 <= a.y1 + margin; +} + +// a*b - c*d to within ~1.5 ulp whatever the cancellation (Kahan), through +// the fused multiply-add: the cross products below keep their accuracy for +// near-parallel directions, fused or unfused arithmetic alike. +double differenceOfProducts(double a, double b, double c, double d) +{ + const double cd = c * d; + const double err = std::fma(-c, d, cd); + const double dop = std::fma(a, b, -cd); + return dop + err; +} + +double cross(const QPointF& a, const QPointF& b) +{ + return differenceOfProducts(a.x(), b.y(), a.y(), b.x()); +} + +// Inclusive segment-segment intersection (a touch at a vertex or endpoint +// counts). A collinear overlap reports the point of the overlap nearest +// `preferNear` (the click), so the meeting on coincident stretches follows +// the user, not the segment's start. +std::optional> segmentIntersection(const QPointF& p0, const QPointF& p1, + const QPointF& q0, const QPointF& q1, + const QPointF& preferNear) +{ + const QPointF r = p1 - p0; + const QPointF s = q1 - q0; + // Every cross product below is Kahan-accurate (differenceOfProducts), + // so the determinant is zero exactly when the directions are parallel + // in double precision and otherwise correct relative to its size: two + // segments with ANY nonzero angle meet at one point, however shallow + // (a 2e-9 tilt over 10000 voxels included), and the formula finds it. + // Only an exactly parallel pair is tested for a collinear overlap. + const double denom = cross(r, s); + const QPointF qp = q0 - p0; + if (denom == 0.0) { + const double rr = QPointF::dotProduct(r, r); + if (cross(qp, r) != 0.0 || cross(q1 - p0, r) != 0.0) { + return std::nullopt; // parallel, not collinear + } + if (rr <= 0.0) { + return std::nullopt; + } + double t0 = QPointF::dotProduct(qp, r) / rr; + double t1 = QPointF::dotProduct(qp + s, r) / rr; + if (t0 > t1) { + std::swap(t0, t1); + } + const double lo = std::max(0.0, t0); + const double hi = std::min(1.0, t1); + if (lo > hi) { + return std::nullopt; + } + const double t = std::clamp(QPointF::dotProduct(preferNear - p0, r) / rr, lo, hi); + const double ss = QPointF::dotProduct(s, s); + const double u = ss > 0.0 ? std::clamp(QPointF::dotProduct(p0 + t * r - q0, s) / ss, 0.0, 1.0) + : 0.0; + return std::make_pair(t, u); + } + const double t = cross(qp, s) / denom; + const double u = cross(qp, r) / denom; + const double tol = 1e-9; + if (t < -tol || t > 1.0 + tol || u < -tol || u > 1.0 + tol) { + return std::nullopt; + } + return std::make_pair(std::clamp(t, 0.0, 1.0), std::clamp(u, 0.0, 1.0)); +} + +double distance2(const QPointF& a, const QPointF& b) +{ + const QPointF d = a - b; + return QPointF::dotProduct(d, d); +} + +// Closest point of segment [a, b] to p: returns the parameter. +double projectParameter(const QPointF& p, const QPointF& a, const QPointF& b) +{ + const QPointF ab = b - a; + const double len2 = QPointF::dotProduct(ab, ab); + if (len2 <= 0.0) { + return 0.0; + } + return std::clamp(QPointF::dotProduct(p - a, ab) / len2, 0.0, 1.0); +} + +double lerp(double a, double b, double t) +{ + return a + (b - a) * t; +} + +MeetingSide sideAt(const std::vector& runs, std::size_t runIndex, std::size_t sample, double t) +{ + MeetingSide side; + side.runIndex = runIndex; + side.sample = sample; + side.t = t; + const DrawnRun& run = runs[runIndex]; + side.arclength = lerp(run.arclengths[sample], run.arclengths[sample + 1], t); + return side; +} + +} // namespace + +std::optional findMapMeeting(const PlacedFiber& a, + const PlacedFiber& b, + QPointF preferNear, + double maxApproachVx) +{ + const std::vector runsA = drawnRuns(a); + const std::vector runsB = drawnRuns(b); + std::vector boxesB; + boxesB.reserve(runsB.size()); + for (const DrawnRun& run : runsB) { + boxesB.push_back(boundsOf(run.points)); + } + + std::optional best; + double bestScore = std::numeric_limits::infinity(); + // Pass 1: crossings; the one nearest preferNear wins. + for (std::size_t ia = 0; ia < runsA.size(); ++ia) { + const DrawnRun& ra = runsA[ia]; + if (ra.points.size() < 2) { + continue; + } + const Box boxA = boundsOf(ra.points); + for (std::size_t ib = 0; ib < runsB.size(); ++ib) { + const DrawnRun& rb = runsB[ib]; + if (rb.points.size() < 2 || !boxesOverlap(boxA, boxesB[ib], 0.0)) { + continue; + } + for (std::size_t i = 0; i + 1 < ra.points.size(); ++i) { + for (std::size_t j = 0; j + 1 < rb.points.size(); ++j) { + const auto hit = segmentIntersection(ra.points[i], ra.points[i + 1], + rb.points[j], rb.points[j + 1], + preferNear); + if (!hit) { + continue; + } + const QPointF point = ra.points[i] + hit->first * (ra.points[i + 1] - ra.points[i]); + const double score = distance2(point, preferNear); + if (score < bestScore) { + bestScore = score; + MapMeeting meeting; + meeting.point = point; + meeting.crossing = true; + meeting.distance = 0.0; + meeting.a = sideAt(runsA, ia, i, hit->first); + meeting.b = sideAt(runsB, ib, j, hit->second); + best = meeting; + } + } + } + } + } + if (best) { + return best; + } + + // Pass 2: closest approach of the two polylines (point-to-segment both + // ways), accepted within maxApproachVx. + const double limit2 = maxApproachVx * maxApproachVx; + double bestDistance2 = std::numeric_limits::infinity(); + const auto consider = [&](const std::vector& runsP, const std::vector& runsQ, + bool pIsA) { + for (std::size_t ip = 0; ip < runsP.size(); ++ip) { + const DrawnRun& rp = runsP[ip]; + if (rp.points.size() < 2) { + continue; + } + const Box boxP = boundsOf(rp.points); + for (std::size_t iq = 0; iq < runsQ.size(); ++iq) { + const DrawnRun& rq = runsQ[iq]; + if (rq.points.size() < 2 || !boxesOverlap(boxP, boundsOf(rq.points), maxApproachVx)) { + continue; + } + for (std::size_t i = 0; i < rp.points.size(); ++i) { + const QPointF p = rp.points[i]; + for (std::size_t j = 0; j + 1 < rq.points.size(); ++j) { + const double u = projectParameter(p, rq.points[j], rq.points[j + 1]); + const QPointF q = rq.points[j] + u * (rq.points[j + 1] - rq.points[j]); + const double d2 = distance2(p, q); + // Inclusive limit; among equals the first found wins. + if (d2 > limit2 || d2 >= bestDistance2) { + continue; + } + bestDistance2 = d2; + MapMeeting meeting; + meeting.point = 0.5 * (p + q); + meeting.crossing = false; + meeting.distance = std::sqrt(d2); + // The vertex side: the vertex is sample i of its run + // (parameter 0 on segment [i, i+1], or 1 on the last). + const std::size_t vs = i + 1 < rp.points.size() ? i : i - 1; + const double vt = i + 1 < rp.points.size() ? 0.0 : 1.0; + const MeetingSide vertexSide = sideAt(runsP, ip, vs, vt); + const MeetingSide segmentSide = sideAt(runsQ, iq, j, u); + meeting.a = pIsA ? vertexSide : segmentSide; + meeting.b = pIsA ? segmentSide : vertexSide; + best = meeting; + } + } + } + } + }; + consider(runsA, runsB, true); + consider(runsB, runsA, false); + return best; +} + +double sourcePositionAtArclength(const std::vector& raw, double s) +{ + if (raw.empty()) { + return 0.0; + } + const std::size_t n = raw.size(); + if (!(s > raw.front())) { + return 0.0; + } + const auto it = std::lower_bound(raw.begin(), raw.end(), s); + if (it == raw.end()) { + return static_cast(n - 1); + } + const std::size_t j = static_cast(it - raw.begin()); + if (raw[j] == s) { + return static_cast(j); + } + if (j == 0) { + return 0.0; + } + const double span = raw[j] - raw[j - 1]; + return static_cast(j - 1) + (s - raw[j - 1]) / span; +} + +cv::Vec3d pointAtLinePosition(const std::vector& linePoints, double linePosition) +{ + if (linePoints.empty()) { + return {0.0, 0.0, 0.0}; + } + const double maxPosition = static_cast(linePoints.size() - 1); + linePosition = std::clamp(linePosition, 0.0, maxPosition); + const std::size_t lower = static_cast(std::floor(linePosition)); + const std::size_t upper = std::min(lower + 1, linePoints.size() - 1); + const double t = linePosition - static_cast(lower); + return linePoints[lower] * (1.0 - t) + linePoints[upper] * t; +} + +std::vector cumulativeArclengths(const std::vector& linePoints) +{ + std::vector s(linePoints.size(), 0.0); + for (std::size_t i = 1; i < linePoints.size(); ++i) { + s[i] = s[i - 1] + cv::norm(linePoints[i] - linePoints[i - 1]); + } + return s; +} + +namespace +{ + +double arclengthAt(const std::vector& cumulative, double position) +{ + if (cumulative.empty()) { + return 0.0; + } + position = std::clamp(position, 0.0, static_cast(cumulative.size() - 1)); + const std::size_t lower = static_cast(std::floor(position)); + const std::size_t upper = std::min(lower + 1, cumulative.size() - 1); + const double t = position - static_cast(lower); + return cumulative[lower] * (1.0 - t) + cumulative[upper] * t; +} + +// The fractional line position at a cumulative arclength (first match on a +// plateau), clamped to the line. +double positionAtArclength(const std::vector& cumulative, double s) +{ + return sourcePositionAtArclength(cumulative, s); +} + +// The exact window of one side: fractional line positions [lo, hi] whose +// arclength is within +-window of the arclength at `position` (clipped to +// the line), never empty. +std::pair windowPositions(const std::vector& cumulative, + double position, double window) +{ + const std::size_t n = cumulative.size(); + if (n == 0) { + return {0.0, 0.0}; + } + const double s = arclengthAt(cumulative, position); + double lo = positionAtArclength(cumulative, s - window); + double hi = positionAtArclength(cumulative, s + window); + const double maxPosition = static_cast(n - 1); + lo = std::clamp(std::min(lo, position), 0.0, maxPosition); + hi = std::clamp(std::max(hi, position), 0.0, maxPosition); + return {lo, hi}; +} + +cv::Vec3d accurateCross(const cv::Vec3d& u, const cv::Vec3d& v) +{ + return {differenceOfProducts(u[1], v[2], u[2], v[1]), + differenceOfProducts(u[2], v[0], u[0], v[2]), + differenceOfProducts(u[0], v[1], u[1], v[0])}; +} + +// Closest points of two 3D segments. The unconstrained minimum of the +// line-line problem is `s = ((p2 - p1) x d2) . n / |n|^2`, `t = ((p2 - p1) +// x d1) . n / |n|^2` with `n = d1 x d2`; every cross product is computed +// with differenceOfProducts, so the tiny `n` of near-parallel segments is +// accurate relative to its own size and the ratio is right (no `a*e - b*b` +// cancellation). The boundary cases clamp one parameter and re-solve the +// other; the lower of the two candidate pairs wins. +void closestSegmentPoints(const cv::Vec3d& p1, const cv::Vec3d& q1, + const cv::Vec3d& p2, const cv::Vec3d& q2, + double& s, double& t) +{ + const cv::Vec3d d1 = q1 - p1; + const cv::Vec3d d2 = q2 - p2; + const cv::Vec3d w = p2 - p1; + const double a = d1.dot(d1); + const double e = d2.dot(d2); + constexpr double kEps = 1e-300; + const auto tFor = [&](double sValue) { + return e > kEps ? std::clamp((sValue * d1 - w).dot(d2) / e, 0.0, 1.0) : 0.0; + }; + const auto sFor = [&](double tValue) { + return a > kEps ? std::clamp((w + tValue * d2).dot(d1) / a, 0.0, 1.0) : 0.0; + }; + if (a <= kEps || e <= kEps) { + s = a <= kEps ? 0.0 : sFor(0.0); + t = e <= kEps ? 0.0 : tFor(s); + if (a > kEps) { + s = sFor(t); + } + return; + } + const cv::Vec3d n = accurateCross(d1, d2); + const double nn = n.dot(n); + double sStar = 0.0; + double tStar = 0.0; + if (nn <= 1e-40 * a * e) { + // Parallel within rounding: the distance is flat along the overlap; + // segment 2's start decides. + tStar = 0.0; + sStar = sFor(tStar); + tStar = tFor(sStar); + } else { + sStar = accurateCross(w, d2).dot(n) / nn; + tStar = accurateCross(w, d1).dot(n) / nn; + } + s = sStar; + t = tStar; + if (s < 0.0 || s > 1.0 || t < 0.0 || t > 1.0) { + const double sA = std::clamp(sStar, 0.0, 1.0); + const double tA = tFor(sA); + const double tB = std::clamp(tStar, 0.0, 1.0); + const double sB = sFor(tB); + const auto dist2 = [&](double sv, double tv) { + const cv::Vec3d d = (p1 + sv * d1) - (p2 + tv * d2); + return d.dot(d); + }; + if (dist2(sA, tA) <= dist2(sB, tB)) { + s = sA; + t = tA; + } else { + s = sB; + t = tB; + } + } +} + +} // namespace + +std::optional findClosestPass(const FiberSide& a, const FiberSide& b) +{ + if (!a.linePoints || !b.linePoints || !a.cumulative || !b.cumulative || + a.linePoints->empty() || b.linePoints->empty() || + a.cumulative->size() != a.linePoints->size() || + b.cumulative->size() != b.linePoints->size()) { + return std::nullopt; + } + const auto [loA, hiA] = windowPositions(*a.cumulative, a.position, a.windowVx); + const auto [loB, hiB] = windowPositions(*b.cumulative, b.position, b.windowVx); + const std::vector& la = *a.linePoints; + const std::vector& lb = *b.linePoints; + + ClosestPass best; + best.windowLoA = loA; + best.windowHiA = hiA; + best.windowLoB = loB; + best.windowHiB = hiB; + best.firstA = static_cast(std::floor(loA)); + best.lastA = static_cast(std::ceil(hiA)); + best.firstB = static_cast(std::floor(loB)); + best.lastB = static_cast(std::ceil(hiB)); + double bestDistance2 = std::numeric_limits::infinity(); + const auto consider = [&](double posA, double posB) { + const cv::Vec3d pa = pointAtLinePosition(la, posA); + const cv::Vec3d pb = pointAtLinePosition(lb, posB); + const cv::Vec3d d = pa - pb; + const double d2 = d.dot(d); + if (d2 < bestDistance2) { + bestDistance2 = d2; + best.positionA = posA; + best.positionB = posB; + best.pointA = pa; + best.pointB = pb; + best.distance = std::sqrt(d2); + } + }; + // The window's sub-segments: segment i clipped to [lo, hi] exactly, so + // nothing outside +-window is ever searched and the boundary segments are + // neither widened nor dropped. + struct SubSegment { + double from = 0.0; // fractional line position of its start + double to = 0.0; // ... and end (to >= from) + }; + const auto subSegments = [](double lo, double hi) { + std::vector segments; + if (hi <= lo) { + segments.push_back({lo, lo}); + return segments; + } + for (double i = std::floor(lo); i < hi; i += 1.0) { + const double from = std::max(lo, i); + const double to = std::min(hi, i + 1.0); + if (to > from) { + segments.push_back({from, to}); + } + } + if (segments.empty()) { + segments.push_back({lo, hi}); + } + return segments; + }; + const std::vector segmentsA = subSegments(loA, hiA); + const std::vector segmentsB = subSegments(loB, hiB); + for (const SubSegment& sa : segmentsA) { + const cv::Vec3d p1 = pointAtLinePosition(la, sa.from); + const cv::Vec3d q1 = pointAtLinePosition(la, sa.to); + for (const SubSegment& sb : segmentsB) { + const cv::Vec3d p2 = pointAtLinePosition(lb, sb.from); + const cv::Vec3d q2 = pointAtLinePosition(lb, sb.to); + double s = 0.0; + double t = 0.0; + closestSegmentPoints(p1, q1, p2, q2, s, t); + consider(sa.from + s * (sa.to - sa.from), sb.from + t * (sb.to - sb.from)); + } + } + return best; +} + +namespace +{ + +bool insideGap(const ControlChoiceInput& input, double position) +{ + for (const auto& [lower, upper] : input.gapSpans) { + if (lower >= input.controlLineIndex.size() || upper >= input.controlLineIndex.size()) { + continue; + } + const double lo = static_cast(input.controlLineIndex[lower]); + const double hi = static_cast(input.controlLineIndex[upper]); + if (position > std::min(lo, hi) && position < std::max(lo, hi)) { + return true; + } + } + return false; +} + +bool beyondTaggedEnd(const ControlChoiceInput& input, double position) +{ + const std::size_t n = input.controlLineIndex.size(); + if (n == 0) { + return false; + } + const auto tagged = [&input](std::size_t index) { + return index < input.kollesisTerminations.size() && input.kollesisTerminations[index]; + }; + if (tagged(0) && position < static_cast(input.controlLineIndex.front())) { + return true; + } + if (tagged(n - 1) && position > static_cast(input.controlLineIndex.back())) { + return true; + } + return false; +} + +} // namespace + +ControlChoice chooseControlPoint(const ControlChoiceInput& input) +{ + ControlChoice choice; + if (!input.linePoints || !input.cumulative || input.linePoints->empty() || + input.cumulative->size() != input.linePoints->size()) { + choice.reason = "no line geometry"; + return choice; + } + const std::vector& line = *input.linePoints; + const std::vector& cumulative = *input.cumulative; + const double passArc = arclengthAt(cumulative, input.passPosition); + const cv::Vec3d passPoint = pointAtLinePosition(line, input.passPosition); + const auto controlArc = [&](std::size_t control) { + return cumulative[std::min(input.controlLineIndex[control], cumulative.size() - 1)]; + }; + const auto controlPoint = [&](std::size_t control) { + return line[std::min(input.controlLineIndex[control], line.size() - 1)]; + }; + const auto within = [&](const cv::Vec3d& p, const cv::Vec3d& q) { + const cv::Vec3d d = p - q; + return d.dot(d) <= input.ambiguityTolerance * input.ambiguityTolerance; + }; + + // D4: the nearest UNLINKED control within reuseVx. + std::optional reuse; + double reuseDistance = std::numeric_limits::infinity(); + for (std::size_t c = 0; c < input.controlLineIndex.size(); ++c) { + const bool linked = c < input.linked.size() && input.linked[c]; + const double d = std::abs(controlArc(c) - passArc); + if (!linked && d <= input.reuseVx && d < reuseDistance) { + reuseDistance = d; + reuse = c; + } + } + if (reuse) { + const cv::Vec3d p = controlPoint(*reuse); + for (std::size_t c = 0; c < input.controlLineIndex.size(); ++c) { + if (c != *reuse && within(p, controlPoint(c))) { + choice.reason = "two control points share one position"; + return choice; + } + } + choice.kind = ControlChoice::Kind::Reuse; + choice.controlIndex = static_cast(*reuse); + choice.linePosition = static_cast(input.controlLineIndex[*reuse]); + choice.point = p; + return choice; + } + + // Place: the nearest position to the pass (either side) inside the window + // that passes every gate at once. + const auto gatesAt = [&](double position, std::string* why) -> bool { + const cv::Vec3d p = pointAtLinePosition(line, position); + const double arc = arclengthAt(cumulative, position); + for (std::size_t c = 0; c < input.controlLineIndex.size(); ++c) { + // Strict: never a span shorter than the minimum spacing. A + // boundary candidate whose reconstructed arclength comes back a + // few ulps short is covered by its representable neighbours + // (see the candidate list), not by a tolerance here. + if (std::abs(controlArc(c) - arc) < input.minSpacingVx) { + if (why) *why = "too close to an existing control point"; + return false; + } + if (within(p, controlPoint(c))) { + if (why) *why = "coincides with an existing control point"; + return false; + } + } + if (beyondTaggedEnd(input, position)) { + if (why) *why = "beyond a kollesis termination"; + return false; + } + if (insideGap(input, position)) { + if (why) *why = "inside a gap span"; + return false; + } + if (input.inFocusBounds && !input.inFocusBounds(p)) { + if (why) *why = "outside the active focus bounds"; + return false; + } + return true; + }; + std::string lastReason; + if (gatesAt(input.passPosition, &lastReason)) { + choice.kind = ControlChoice::Kind::Place; + choice.linePosition = input.passPosition; + choice.point = passPoint; + return choice; + } + // The admissible set along the line is the window minus the analytic + // exclusions (the minimum-spacing band around every control, the span + // beyond a tagged end, the gap spans); the focus bounds are a point + // predicate. Candidates are the pass, every boundary of those exclusions + // and every line vertex inside the window, each checked against all the + // gates; the nearest admissible candidate to the pass wins. A boundary + // of an exclusion band is the nearest admissible position whenever the + // pass itself lies inside the band, so a valid position between two + // vertices is never missed. + const double lo = std::min(input.windowLo, input.windowHi); + const double hi = std::max(input.windowLo, input.windowHi); + std::vector candidates; + const auto addCandidate = [&](double position) { + if (std::isfinite(position) && position >= lo && position <= hi) { + candidates.push_back(position); + } + }; + // A boundary (of a spacing band, of the focus box) reconstructed + // through an arclength or a slab parameter can land a few ulps on the + // wrong side of the exact edge: the representable positions next to it + // are candidates too, so the first admissible one is found without any + // gate being relaxed. + const auto addBoundaryCandidate = [&](double position) { + addCandidate(position); + // Representable neighbours either way, in two resolutions: ulps of + // the POSITION (1, 2, 4, ... 4096), and steps that move the POINT by + // 1, 2, 4, ... 4096 ulps of its own coordinates along its segment + // (a position near 0 has ulps far smaller than the coordinate's; + // the gates read the point, so the reconstruction error lives in + // the coordinate's ulps). The first admissible position is among + // them whatever the magnitudes. + double up = position; + double down = position; + for (int k = 0; k < 13; ++k) { + const int steps = 1 << k; + for (int i = 0; i < steps; ++i) { + up = std::nextafter(up, std::numeric_limits::infinity()); + down = std::nextafter(down, -std::numeric_limits::infinity()); + } + addCandidate(up); + addCandidate(down); + } + const std::size_t i = static_cast( + std::clamp(std::floor(position), 0.0, static_cast(line.size() - 2))); + const cv::Vec3d p = pointAtLinePosition(line, position); + const cv::Vec3d d = line[i + 1] - line[i]; + const double segmentLength = std::sqrt(d.dot(d)); + if (segmentLength <= 0.0) { + return; + } + const double magnitude = std::max({std::abs(p[0]), std::abs(p[1]), std::abs(p[2]), 1.0}); + const double coordinateUlp = std::nextafter(magnitude, std::numeric_limits::infinity()) - magnitude; + const double stepPosition = coordinateUlp / segmentLength; + for (int k = 0; k < 13; ++k) { + const double delta = static_cast(1 << k) * stepPosition; + addCandidate(position + delta); + addCandidate(position - delta); + } + }; + const auto addArcCandidate = [&](double arc) { + addBoundaryCandidate(positionAtArclength(cumulative, arc)); + }; + // Exact spacing-band boundaries (the gate is strict, so the boundary + // itself is admissible: controls at 0 and 60 with spacing 30 admit 30). + for (std::size_t c = 0; c < input.controlLineIndex.size(); ++c) { + addArcCandidate(controlArc(c) + input.minSpacingVx); + addArcCandidate(controlArc(c) - input.minSpacingVx); + } + // Every line vertex inside the window, and the window's ends. + for (long index = static_cast(std::ceil(lo)); index <= static_cast(std::floor(hi)); + ++index) { + candidates.push_back(static_cast(index)); + } + candidates.push_back(lo); + candidates.push_back(hi); + // Where a window segment leaves the ambiguity sphere (radius + // ambiguityTolerance) around a control: the pass itself can coincide + // with a distant control of a line that returns near it, and the + // admissible stretch then begins a hair along the line. + for (std::size_t c = 0; c < input.controlLineIndex.size(); ++c) { + const cv::Vec3d center = controlPoint(c); + const double radius2 = input.ambiguityTolerance * input.ambiguityTolerance; + (void)radius2; + for (long index = static_cast(std::floor(lo)); index < static_cast(std::ceil(hi)); + ++index) { + const std::size_t i = static_cast(std::max(index, 0)); + if (i + 1 >= line.size()) { + break; + } + const cv::Vec3d a = line[i]; + const cv::Vec3d d = line[i + 1] - a; + const double qa = d.dot(d); + if (qa <= 0.0) { + continue; + } + // Geometrically, not through |m|^2 - r^2 (a tiny radius vanishes + // against a large |m|^2): the foot of the control on the + // segment's line, the distance from it as a vector norm, then + // the half chord. + const double t0 = (center - a).dot(d) / qa; + const cv::Vec3d foot = a + t0 * d; + const cv::Vec3d offset = foot - center; + const double h2 = offset.dot(offset); + if (h2 > radius2) { + continue; + } + const double halfChord = std::sqrt(std::max(0.0, radius2 - h2) / qa); + for (const double tt : {t0 - halfChord, t0 + halfChord}) { + if (tt >= 0.0 && tt <= 1.0) { + addBoundaryCandidate(static_cast(i) + tt); + } + } + } + } + // The exact entry and exit of every window segment through the focus + // box (slab clipping), so an admissible stretch strictly inside a + // segment is found at its boundary. + if (input.focusBox) { + const cv::Vec3d boxLo = input.focusBox->first; + const cv::Vec3d boxHi = input.focusBox->second; + for (long index = static_cast(std::floor(lo)); index < static_cast(std::ceil(hi)); + ++index) { + const std::size_t i = static_cast(std::max(index, 0)); + if (i + 1 >= line.size()) { + break; + } + const cv::Vec3d a = line[i]; + const cv::Vec3d d = line[i + 1] - a; + double t0 = 0.0; + double t1 = 1.0; + bool empty = false; + for (int axis = 0; axis < 3 && !empty; ++axis) { + if (std::abs(d[axis]) < 1e-15) { + if (a[axis] < boxLo[axis] || a[axis] > boxHi[axis]) { + empty = true; + } + continue; + } + double ta = (boxLo[axis] - a[axis]) / d[axis]; + double tb = (boxHi[axis] - a[axis]) / d[axis]; + if (ta > tb) { + std::swap(ta, tb); + } + t0 = std::max(t0, ta); + t1 = std::min(t1, tb); + if (t0 > t1) { + empty = true; + } + } + if (!empty) { + addBoundaryCandidate(static_cast(i) + t0); + addBoundaryCandidate(static_cast(i) + t1); + } + } + } + // Nearest to the pass first; equidistant candidates by line position + // (lower first), so a symmetric choice is the same on every platform. + std::sort(candidates.begin(), candidates.end(), + [&](double x, double y) { + const double dx = std::abs(arclengthAt(cumulative, x) - passArc); + const double dy = std::abs(arclengthAt(cumulative, y) - passArc); + if (dx != dy) { + return dx < dy; + } + return x < y; + }); + std::optional bestPosition; + for (const double position : candidates) { + if (gatesAt(position, &lastReason)) { + bestPosition = position; + break; + } + } + if (!bestPosition) { + choice.reason = lastReason.empty() ? "no admissible position in the search window" : lastReason; + return choice; + } + choice.kind = ControlChoice::Kind::Place; + choice.linePosition = *bestPosition; + choice.point = pointAtLinePosition(line, *bestPosition); + return choice; +} + +} // namespace vc3d::fiber_map::autolink diff --git a/volume-cartographer/apps/VC3D/FiberMapAutoLink.hpp b/volume-cartographer/apps/VC3D/FiberMapAutoLink.hpp new file mode 100644 index 0000000000..1598704138 --- /dev/null +++ b/volume-cartographer/apps/VC3D/FiberMapAutoLink.hpp @@ -0,0 +1,148 @@ +#pragma once + +// The pure geometry behind the Fiber Map's "Link and ": where two +// drawn fibers meet on the map, the exact inversion of a drawn point to a +// position on the fiber's source line points, the closest 3D pass of the two +// fibers inside a bounded window around that position, and which control +// point each fiber links at (an existing one, or a new one placed clear of +// every gate). Everything here is a function of its inputs: the controller +// supplies line points, cumulative arclengths, control line indices, link +// flags, tag flags, gap ranges and a focus predicate; nothing reaches into +// sessions or the scene. + +#include +#include +#include +#include +#include + +#include +#include + +#include "FiberNetworkLayout.hpp" + +namespace vc3d::fiber_map::autolink +{ + +// A meeting of the two DRAWN polylines: a crossing (the one nearest +// `preferNear` when there are several), else the closest approach of the two +// polylines when it is within `maxApproachVx`. +struct MeetingSide { + std::size_t runIndex = 0; + // The hit drawn segment [sample, sample + 1] of that run's display points + // and the parameter along it. + std::size_t sample = 0; + double t = 0.0; + // The hit's raw unrolled arclength, interpolated between the two samples' + // arclengths (controlArclength where displayRunPoints replaced an overlap + // sample by the control's drawn position). + double arclength = 0.0; +}; + +struct MapMeeting { + QPointF point; + bool crossing = false; + // Scene distance between the two polylines at the meeting (0 for a crossing). + double distance = 0.0; + MeetingSide a; + MeetingSide b; +}; + +[[nodiscard]] std::optional findMapMeeting(const PlacedFiber& a, + const PlacedFiber& b, + QPointF preferNear, + double maxApproachVx); + +// Exact inversion of a raw unrolled arclength to a fractional line position: +// `raw` is the fiber's cumulative unrolled arclength per source line point +// (unrolledLineArclengths, monotone non-decreasing). The FIRST exact match +// wins on a plateau (a radial run of the line resolves to its first point); +// otherwise the position interpolates between the predecessor of the first +// element >= s and that element. No tolerance. +[[nodiscard]] double sourcePositionAtArclength(const std::vector& raw, double s); + +// Fractional line position + its interpolated point. +[[nodiscard]] cv::Vec3d pointAtLinePosition(const std::vector& linePoints, + double linePosition); + +// Cumulative 3D arclength per line point (index 0 = 0). The controller can +// pass fiber_slice::cumulativePolylineArclengths' array; this is the same +// quantity for callers without that dependency. +[[nodiscard]] std::vector cumulativeArclengths(const std::vector& linePoints); + +// The closest pass of the two fibers, searched only inside the line-point +// ranges whose cumulative 3D arclength lies within +-windowVx of the +// arclength at each fiber's source position (D3: the only along-fiber bound, +// so a multi-winding fiber never searches another winding). +struct ClosestPass { + double positionA = 0.0; + double positionB = 0.0; + cv::Vec3d pointA{0.0, 0.0, 0.0}; + cv::Vec3d pointB{0.0, 0.0, 0.0}; + double distance = 0.0; + // The searched windows as fractional line positions [lo, hi] (exact + // +-window clipping), which the placement search must stay inside, and + // the enclosing whole line-index ranges for reporting. + double windowLoA = 0.0, windowHiA = 0.0, windowLoB = 0.0, windowHiB = 0.0; + std::size_t firstA = 0, lastA = 0, firstB = 0, lastB = 0; +}; + +struct FiberSide { + const std::vector* linePoints = nullptr; + const std::vector* cumulative = nullptr; + double position = 0.0; + double windowVx = 0.0; +}; + +[[nodiscard]] std::optional findClosestPass(const FiberSide& a, const FiberSide& b); + +// Which control point of one fiber the link uses. +struct ControlChoiceInput { + const std::vector* linePoints = nullptr; + const std::vector* cumulative = nullptr; + // The loader's ordered line index per control (orderedControlPointLineIndices). + std::vector controlLineIndex; + // Per control: any branch link (ordinary or adjacent) on it. + std::vector linked; + // Per control: carries the kollesis termination tag (placement beyond + // such an end is refused, as the interactive handler refuses it). + std::vector kollesisTerminations; + // Gap spans as [lower control, upper control] index pairs (line order): + // nothing is placed inside one. + std::vector> gapSpans; + // The active focus bounds, if any: the predicate every candidate is + // checked against, and the box itself so the exact entry/exit positions + // of each line segment through it are candidates too. + std::function inFocusBounds; + std::optional> focusBox; + // The pass on this fiber and the exact window (fractional line + // positions) it was found in; nothing is placed outside the window. + double passPosition = 0.0; + double windowLo = 0.0; + double windowHi = 0.0; + // D4: an unlinked control within this arclength of the pass is reused. + double reuseVx = 0.0; + // A new control is never placed within this arclength of an existing one + // (kMinimumControlPointSpacingBaseVoxels): no collapse, strict ordering. + double minSpacingVx = 0.0; + // The position-identity tolerance of the link format: a reused control + // with another control within it, or a new control within it of any + // control, is refused (the save canonicalizer identifies controls by + // position under this tolerance). + double ambiguityTolerance = 1.0e-6; +}; + +struct ControlChoice { + enum class Kind { Reuse, Place, Refused }; + Kind kind = Kind::Refused; + // Reuse: the stored control index. Place: the line position and point. + int controlIndex = -1; + double linePosition = 0.0; + cv::Vec3d point{0.0, 0.0, 0.0}; + // Refused: why, in words. + std::string reason; +}; + +[[nodiscard]] ControlChoice chooseControlPoint(const ControlChoiceInput& input); + +} // namespace vc3d::fiber_map::autolink diff --git a/volume-cartographer/apps/VC3D/FiberMapWorkspace.cpp b/volume-cartographer/apps/VC3D/FiberMapWorkspace.cpp index 24f75035c3..955d42b73e 100644 --- a/volume-cartographer/apps/VC3D/FiberMapWorkspace.cpp +++ b/volume-cartographer/apps/VC3D/FiberMapWorkspace.cpp @@ -5,6 +5,8 @@ #include "FiberMapRulerMath.hpp" #include "FiberSliceGeometry.hpp" #include "LineAnnotationController.hpp" +#include "FiberMapAutoLink.hpp" +#include "LineAnnotationFiberLinks.hpp" #include "LineAnnotationFiberClassification.hpp" #include "LineAnnotationGeneratedViews.hpp" @@ -307,6 +309,9 @@ constexpr qreal kMinSceneMarginCm = 3.0; constexpr double kMinChipPixelsPerWinding = 180.0; constexpr double kFiberHitTolerancePx = 14.0; constexpr double kControlDotTolerancePx = 10.0; +// The separate dashed ring of a tagged automatic-link endpoint, relative to +// the dot it surrounds. +constexpr double kAutoLinkRingScale = 1.45; constexpr int kClickSlopPx = 4; // Stand-in voxel size for a package that cannot say how big its voxels are. It @@ -351,6 +356,13 @@ QPen cosmeticPen(const QColor& color, qreal width) // Dotted, for the gap spans and the break rims: distinct from the // interpolated dash by form, from every fiber colour by hue. +QPen dashedPen(const QColor& color, qreal width) +{ + QPen pen = cosmeticPen(color, width); + pen.setStyle(Qt::DashLine); + return pen; +} + QPen dottedPen(const QColor& color, qreal width) { QPen pen = cosmeticPen(color, width); @@ -897,7 +909,7 @@ void FiberMapView::mouseReleaseEvent(QMouseEvent* event) _pressed = false; QGraphicsView::mouseReleaseEvent(event); if (wasPressed) { - emit clicked(mapToScene(event->pos())); + emit clicked(mapToScene(event->pos()), event->modifiers()); } } @@ -1510,8 +1522,55 @@ void FiberMapWorkspace::showStale(const QString& reason) _staleReason = reason; if (_statusLabel) { _statusLabel->setStyleSheet({}); - _statusLabel->setText(withCachedUmbilicusStatus(reason)); + _statusLabel->setText(composeStatus(withCachedUmbilicusStatus(reason))); + } +} + +QString FiberMapWorkspace::composeStatus(const QString& floor) const +{ + if (_operationStatus.isEmpty()) { + return floor; + } + if (floor.isEmpty()) { + return _operationStatus; + } + return _operationStatus + QStringLiteral(" · ") + floor; +} + +void FiberMapWorkspace::redrawStatus() +{ + // Re-shows whatever the line's floor is right now, composed with the + // operation message. The floor's own writers (showStale, publication, + // the selection) each compose too; this is for a message change alone. + if (!_statusLabel) { + return; + } + if (!_staleReason.isEmpty()) { + showStale(_staleReason); + return; + } + if (!_layoutBuilt) { + _statusLabel->setStyleSheet({}); + _statusLabel->setText(composeStatus(withCachedUmbilicusStatus( + _restingReason.isEmpty() ? tr("press Update") : _restingReason))); + return; + } + updateSelectionStatus(); +} + +void FiberMapWorkspace::setOperationStatus(const QString& message) +{ + _operationStatus = message; + redrawStatus(); +} + +void FiberMapWorkspace::clearOperationStatus() +{ + if (_operationStatus.isEmpty()) { + return; } + _operationStatus.clear(); + redrawStatus(); } void FiberMapWorkspace::markStale(const QString& reason) @@ -1535,6 +1594,8 @@ void FiberMapWorkspace::clearLayout(const QString& reason) // instead of geometry; it also owns tearing down the items, the entries and // the highlight, so none of that is repeated here. _layout = {}; + _layoutUmbilicusCenters.clear(); + _endpointReviews.clear(); _gapField.reset(); _pendingGapTiles.clear(); _gapPublishedWanted = false; @@ -1565,6 +1626,8 @@ void FiberMapWorkspace::clearLayout(const QString& reason) // latch goes with the layout it described. _staleReason.clear(); _latchedReason.clear(); + // The fibers the message was about are gone with the layout. + _operationStatus.clear(); if (_tree) { _tree->clear(); } @@ -1666,10 +1729,8 @@ bool FiberMapWorkspace::applyStaleVerdict(const StaleVerdict& verdict) // stays MarkStale until a rebuild or clear. if (!_staleReason.isEmpty() && _layoutBuilt) { _staleReason.clear(); - if (_statusLabel) { - _statusLabel->setStyleSheet(_freshStatusStyle); - _statusLabel->setText(_freshStatus); - } + // The fresh floor with the selection (and the operation message). + updateSelectionStatus(); } break; } @@ -1800,6 +1861,9 @@ struct FiberMapWorkspace::RebuildJobResult { uint64_t builtUmbilicusGeneration = 0; // Inputs (snapshot fibers are consumed by the worker's conversion). LineAnnotationController::FiberMapSnapshot snapshot; + // Every endpoint's review state, from every link of the inputs + // (aggregateEndpointReviews), computed before the layout drops any. + std::map endpointReviews; vc3d::fiber_map::GlobalLayoutParams params; bool hadFibers = false; bool hadUmbilicus = false; @@ -1879,10 +1943,12 @@ void runRebuildJob(const std::shared_ptr& j link.branchControlPointIndex, link.pending, link.adjacent, - link.adjacentExplicit}); + link.adjacentExplicit, + link.autoLink}); } inputs.push_back(std::move(input)); } + job->endpointReviews = vc3d::fiber_map::aggregateEndpointReviews(inputs); // The winding sense is the scroll's, and the catalog states it for // the volumes it orients; only without that does the layout fall // back to solving both senses. Settled before the input digest, @@ -2005,9 +2071,9 @@ void FiberMapWorkspace::showEvent(QShowEvent* event) // changed since (nothing is built, so no dependency comparison will // ever say so). The cache re-resolves when the fingerprint moved. _statusLabel->setStyleSheet({}); - _statusLabel->setText(withCachedUmbilicusStatus( + _statusLabel->setText(composeStatus(withCachedUmbilicusStatus( _restingReason.isEmpty() ? tr("press Update") - : _restingReason)); + : _restingReason))); } } @@ -2016,6 +2082,12 @@ void FiberMapWorkspace::requestRebuild(bool fullRebuild, bool automatic) if (!_controller) { return; } + if (!automatic) { + // A pressed Update is the user moving on; an automatic one (the + // generation bump after a link) is part of the operation itself and + // its publication keeps the message. + clearOperationStatus(); + } switch (_rebuildQueue.request(fullRebuild, automatic)) { case vc3d::fiber_map::FiberMapRebuildQueue::Request::Refused: return; @@ -2279,6 +2351,8 @@ void FiberMapWorkspace::publishRebuild(RebuildJobResult& job) // dependency watermark commits together. _layoutCache = std::move(job.cache); _layout = std::move(job.layout); + _layoutUmbilicusCenters = job.snapshot.umbilicusCenters; + _endpointReviews = std::move(job.endpointReviews); // Null when the checkbox was off at job start or the build failed; the // scene rebuild below draws whatever this is. _gapField = job.gapField; @@ -2360,8 +2434,18 @@ void FiberMapWorkspace::publishRebuild(RebuildJobResult& job) } else if (_layout.fibers.empty()) { emptyMessage = tr("no placeable fibers"); } + // The scene rebuild clears the selection; the same fibers (by runtime + // id, stable within the package) are re-selected when they still exist, + // so an automatic Update after an edit keeps the user's pair. + const std::vector selectedBefore = _selectedFibers; rebuildScene(emptyMessage); rebuildTree(); + if (!selectedBefore.empty()) { + setSelectedFibers(selectedBefore); + if (_highlightedFiber != 0) { + selectFiberRow(_highlightedFiber); + } + } const qint64 publishMs = publishTimer.elapsed(); Logger()->info( "fiber map rebuild: GUI stalls snapshot {} ms + publish {} ms · " @@ -2491,7 +2575,7 @@ void FiberMapWorkspace::publishRebuild(RebuildJobResult& job) ? QStringLiteral("QLabel { color: %1; font-weight: bold; }").arg(kSuspect.name()) : QString(); _statusLabel->setStyleSheet(_freshStatusStyle); - _statusLabel->setText(status); + _statusLabel->setText(composeStatus(status)); if (!_viewFitted && !_layout.fibers.empty()) { _view->fitInView(framedRect(), Qt::KeepAspectRatio); @@ -2566,6 +2650,7 @@ void FiberMapWorkspace::rebuildScene(const QString& emptyMessage) _entries.clear(); clearControlPointDots(); _highlightedFiber = 0; + _selectedFibers.clear(); _networkEmphasized.clear(); _labelChips.clear(); _chipHideScale = 0.0; @@ -2806,6 +2891,69 @@ void FiberMapWorkspace::rebuildScene(const QString& emptyMessage) _entries.insert(fiberId, std::move(entry)); } + // The two endpoint dots of a link, from each endpoint's AGGREGATED look + // (endpointLook: pending outranks approved, dashed when every pending + // link on the point is automatic; a tagged endpoint keeps its tag rim + // and gets the separate dashed ring). Drawn for every link, suspect + // ones included (their red diagnostic rings go on top). + const auto drawEndpointDots = [&](const vc3d::fiber_map::PlacedLink& link, + const QPointF& a, const QPointF& b, + const std::array, 2>& looks, + const LinkPalette& palette, + const std::array& drawEndpoint = {true, true}) { + const std::array tagged{ + isKollesisTermination(link.fiberA, link.cpA), + isKollesisTermination(link.fiberB, link.cpB)}; + const std::array broken{ + isBreak(link.fiberA, link.cpA), + isBreak(link.fiberB, link.cpB)}; + std::size_t endpointIndex = 0; + for (const QPointF& endpoint : {a, b}) { + if (!drawEndpoint[endpointIndex]) { + ++endpointIndex; + continue; + } + const bool kollesis = tagged[endpointIndex]; + const bool breakTag = broken[endpointIndex]; + const EndpointLook look = looks[endpointIndex].value_or( + EndpointLook{palette.brush, palette.pen, link.pending, false}); + const bool autoDashed = look.dashed; + ++endpointIndex; + // Two stacked fills read darker than one; this alpha + // compounds, where the dots overlap, to about the palette's own. + QColor fill = look.fill; + fill.setAlpha(kLinkEndpointFillAlpha); + auto* dot = new ScaledDot(QBrush(fill), + kollesis ? cosmeticPen(kollesisColor(245), kKollesisRimWidthPx) + : breakTag ? dottedPen(breakColor(245), kKollesisRimWidthPx) + : autoDashed ? dashedPen(look.pen, 1.0) + : cosmeticPen(look.pen, 1.0), + crossingDotRadius, + kMinCrossingDotPx, kMaxCrossingDotPx, + crossingDotBounds, + /*triangle=*/looks[endpointIndex - 1] + ? looks[endpointIndex - 1]->adjacent + : link.adjacent); + _scene->addItem(dot); + dot->setPos(endpoint); + // A tagged endpoint sits above its untagged twin where the two + // overlap zoomed out, so the rim stays visible. + dot->setZValue(kollesis || breakTag ? 4.1 : 4.0); + if (autoDashed && (kollesis || breakTag)) { + // The tag keeps the rim; the automatic link state gets + // its own dashed ring just outside it. + auto* ring = new ScaledDot(QBrush(Qt::NoBrush), dashedPen(look.pen, 1.0), + crossingDotRadius * kAutoLinkRingScale, + kMinCrossingDotPx * kAutoLinkRingScale, + kMaxCrossingDotPx * kAutoLinkRingScale, + crossingDotBounds * kAutoLinkRingScale); + _scene->addItem(ring); + ring->setPos(endpoint); + ring->setZValue(4.15); + } + } + }; + for (const vc3d::fiber_map::PlacedLink& link : _layout.links) { const QPointF a(sceneXOf(link.a.x()), -link.a.y()); const QPointF b(sceneXOf(link.b.x()), -link.b.y()); @@ -2820,39 +2968,19 @@ void FiberMapWorkspace::rebuildScene(const QString& emptyMessage) // at the midpoint sat on neither. const LinkPalette& palette = linkPalette(hvTagOf(link.fiberA), hvTagOf(link.fiberB), link.pending); + // Each endpoint dot takes the endpoint's AGGREGATED look + // (endpointLook: pending outranks approved, dashed when every + // pending link on the point is automatic), so two links sharing + // a control point never paint contradicting markers; the + // connector keeps this link's own palette. + const std::array, 2> looks{ + endpointLook(link.fiberA, link.cpA), endpointLook(link.fiberB, link.cpB)}; QPen connectorPen = cosmeticPen(palette.pen, 1.0); connectorPen.setStyle(Qt::DotLine); auto* connector = _scene->addLine(QLineF(a, b)); connector->setPen(connectorPen); connector->setZValue(3.9); - // Two stacked fills read darker than one; this alpha compounds, - // where the dots overlap, to about the palette's own. - QColor fill = palette.brush; - fill.setAlpha(kLinkEndpointFillAlpha); - const std::array tagged{ - isKollesisTermination(link.fiberA, link.cpA), - isKollesisTermination(link.fiberB, link.cpB)}; - const std::array broken{ - isBreak(link.fiberA, link.cpA), - isBreak(link.fiberB, link.cpB)}; - std::size_t endpointIndex = 0; - for (const QPointF& endpoint : {a, b}) { - const bool kollesis = tagged[endpointIndex]; - const bool breakTag = broken[endpointIndex++]; - auto* dot = new ScaledDot(QBrush(fill), - kollesis ? cosmeticPen(kollesisColor(245), kKollesisRimWidthPx) - : breakTag ? dottedPen(breakColor(245), kKollesisRimWidthPx) - : cosmeticPen(palette.pen, 1.0), - crossingDotRadius, - kMinCrossingDotPx, kMaxCrossingDotPx, - crossingDotBounds, - /*triangle=*/link.adjacent); - _scene->addItem(dot); - dot->setPos(endpoint); - // A tagged endpoint sits above its untagged twin where the two - // overlap zoomed out, so the rim stays visible. - dot->setZValue(kollesis || breakTag ? 4.1 : 4.0); - } + drawEndpointDots(link, a, b, looks, palette); continue; } QPen suspectPen(kSuspect); @@ -2862,13 +2990,38 @@ void FiberMapWorkspace::rebuildScene(const QString& emptyMessage) auto* line = _scene->addLine(QLineF(a, b)); line->setPen(suspectPen); line->setZValue(4.0); - for (const QPointF& endpoint : {a, b}) { - auto* ring = new ScaledDot(QBrush(Qt::NoBrush), cosmeticPen(kSuspect, 1.4), - suspectRingRadius, kMinSuspectRingPx, - kMaxSuspectRingPx, suspectRingBounds); - _scene->addItem(ring); - ring->setPos(endpoint); - ring->setZValue(kSuspectRingZ); + { + // The endpoint markers a suspect link's control points carry + // (pending colours, dashing, tag rims) are the same as every + // other link's; the red ring is the diagnostic on top of them. + const LinkPalette& palette = + linkPalette(hvTagOf(link.fiberA), hvTagOf(link.fiberB), link.pending); + const std::array, 2> looks{ + endpointLook(link.fiberA, link.cpA), endpointLook(link.fiberB, link.cpB)}; + // The pre-existing suspect look (rings only) stays for an + // endpoint without an automatic link; an endpoint WITH one gets + // the automatic decorations (pending-colour dot, dashing, tag + // ring) under the red ring - decided per endpoint. + const std::array decorate{looks[0] && looks[0]->anyAuto, + looks[1] && looks[1]->anyAuto}; + if (decorate[0] || decorate[1]) { + drawEndpointDots(link, a, b, looks, palette, decorate); + } + const std::array dashed{ + endpointAutoPending(link.fiberA, link.cpA), + endpointAutoPending(link.fiberB, link.cpB)}; + std::size_t endpointIndex = 0; + for (const QPointF& endpoint : {a, b}) { + // A suspect automatic link keeps the red ring, dashed. + auto* ring = new ScaledDot(QBrush(Qt::NoBrush), + dashed[endpointIndex++] ? dashedPen(kSuspect, 1.4) + : cosmeticPen(kSuspect, 1.4), + suspectRingRadius, kMinSuspectRingPx, + kMaxSuspectRingPx, suspectRingBounds); + _scene->addItem(ring); + ring->setPos(endpoint); + ring->setZValue(kSuspectRingZ); + } } auto* label = _scene->addSimpleText( link.adjacentDisagrees ? tr("adjacent kind disagrees between the fibers") @@ -2883,15 +3036,24 @@ void FiberMapWorkspace::rebuildScene(const QString& emptyMessage) // Kollesis terminations on unlinked control points: a linked one was // already drawn above as its link endpoint dot with the pale yellow rim. { - // Suspect links draw rings, not endpoint dots, so their tagged - // endpoints still need the marker. + // The endpoints whose dot (with the tag rim) was drawn above: every + // non-suspect link's, and a suspect link's only where an automatic + // link made it draw one; the others keep their standalone tag ring. std::set> linkedEndpoints; for (const vc3d::fiber_map::PlacedLink& link : _layout.links) { - if (link.suspect) { + if (!link.suspect) { + linkedEndpoints.emplace(link.fiberA, link.cpA); + linkedEndpoints.emplace(link.fiberB, link.cpB); continue; } - linkedEndpoints.emplace(link.fiberA, link.cpA); - linkedEndpoints.emplace(link.fiberB, link.cpB); + // A suspect link drew a dot only at an endpoint with an + // automatic link. + if (const auto look = endpointLook(link.fiberA, link.cpA); look && look->anyAuto) { + linkedEndpoints.emplace(link.fiberA, link.cpA); + } + if (const auto look = endpointLook(link.fiberB, link.cpB); look && look->anyAuto) { + linkedEndpoints.emplace(link.fiberB, link.cpB); + } } const QPen rim = cosmeticPen(kollesisColor(245), kKollesisRimWidthPx); const QPen breakRim = dottedPen(breakColor(245), kKollesisRimWidthPx); @@ -2906,12 +3068,30 @@ void FiberMapWorkspace::rebuildScene(const QString& emptyMessage) linkedEndpoints.count({fiber.id, static_cast(i)}) != 0) { continue; } - auto* dot = new ScaledDot(fill, kollesis ? rim : breakRim, crossingDotRadius, + // A tagged point whose links the layout could not place (an + // automatic link to a one-point fiber, say) still draws its + // automatic decoration here: the pending fill inside the + // tag ring and the separate dashed ring, as its endpoint dot + // would have - selection and deselection then agree. + const auto look = endpointLook(fiber.id, static_cast(i)); + const bool automatic = look && look->anyAuto; + auto* dot = new ScaledDot(automatic ? QBrush(look->fill) : fill, + kollesis ? rim : breakRim, crossingDotRadius, kMinCrossingDotPx, kMaxCrossingDotPx, - crossingDotBounds); + crossingDotBounds, /*triangle=*/look && look->adjacent); _scene->addItem(dot); dot->setPos(fiber.controlPoints[i]); dot->setZValue(4.1); + if (look && look->dashed) { + auto* ring = new ScaledDot(QBrush(Qt::NoBrush), dashedPen(look->pen, 1.0), + crossingDotRadius * kAutoLinkRingScale, + kMinCrossingDotPx * kAutoLinkRingScale, + kMaxCrossingDotPx * kAutoLinkRingScale, + crossingDotBounds * kAutoLinkRingScale); + _scene->addItem(ring); + ring->setPos(fiber.controlPoints[i]); + ring->setZValue(4.15); + } } } } @@ -3259,7 +3439,7 @@ void FiberMapWorkspace::changeEvent(QEvent* event) _retheming = true; // rebuildScene clears the highlight, so it is restored afterwards: a theme // switch should not cost the user their selection. - const uint64_t highlighted = _highlightedFiber; + const std::vector selectedBefore = _selectedFibers; // A selected error entry survives too: the rebuilt tree lists the same // errors in the same order for the same layout, so its row index is its // identity. @@ -3279,9 +3459,11 @@ void FiberMapWorkspace::changeEvent(QEvent* event) _syncingSelection = true; _tree->setCurrentItem(_tree->topLevelItem(selectedErrorRow)); _syncingSelection = guard; - } else if (highlighted != 0 && _entries.contains(highlighted)) { - setHighlightedFiber(highlighted); - selectFiberRow(highlighted); + } else if (!selectedBefore.empty()) { + setSelectedFibers(selectedBefore); + if (_highlightedFiber != 0) { + selectFiberRow(_highlightedFiber); + } } _retheming = false; } @@ -3341,7 +3523,7 @@ uint64_t FiberMapWorkspace::fiberAt(const QPointF& scenePos) const return best; } -void FiberMapWorkspace::handleSceneClick(const QPointF& scenePos) +void FiberMapWorkspace::handleSceneClick(const QPointF& scenePos, Qt::KeyboardModifiers modifiers) { // A stale map's runtime ids may name different fibers than they did when it // was built, so it stops responding until rebuilt. @@ -3349,7 +3531,27 @@ void FiberMapWorkspace::handleSceneClick(const QPointF& scenePos) return; } const uint64_t fiberId = fiberAt(scenePos); - setHighlightedFiber(fiberId); + // Any click on the map is the user moving on from the last operation. + clearOperationStatus(); + std::vector selection; + if (modifiers.testFlag(Qt::ShiftModifier)) { + // The last two fibers clicked with Shift held: a re-clicked fiber + // moves to the back, the oldest drops off. Shift on empty space + // changes nothing. + if (fiberId == 0) { + return; + } + selection = _selectedFibers; + selection.erase(std::remove(selection.begin(), selection.end(), fiberId), + selection.end()); + selection.push_back(fiberId); + while (selection.size() > 2) { + selection.erase(selection.begin()); + } + } else if (fiberId != 0) { + selection = {fiberId}; + } + setSelectedFibers(std::move(selection)); if (fiberId != 0) { selectFiberRow(fiberId, /*revealHidden=*/true); } @@ -3650,7 +3852,7 @@ void FiberMapWorkspace::refreshGapStatus() _freshStatus = _freshStatusBase + gapStatusSuffix(); // Only the resting text is recomposed; a stale banner keeps the floor. if (_staleReason.isEmpty() && _latchedReason.isEmpty()) { - _statusLabel->setText(_freshStatus); + updateSelectionStatus(); } } @@ -3697,15 +3899,60 @@ void FiberMapWorkspace::updateGapLegend() } } +bool FiberMapWorkspace::endpointAutoPending(uint64_t fiberId, int controlIndex) const +{ + const auto it = _endpointReviews.find(vc3d::fiber_map::EndpointKey{fiberId, controlIndex}); + return it != _endpointReviews.end() && it->second.autoPending; +} + +std::optional FiberMapWorkspace::endpointLook( + uint64_t fiberId, int controlIndex) const +{ + // From the endpoint's review state over EVERY link (the layout keeps + // only the links it placed): the palette of the winning state (pending + // over approved; the orange same-H/V warning when the winning state has + // one), the dashed outline, the triangle. + const auto it = _endpointReviews.find(vc3d::fiber_map::EndpointKey{fiberId, controlIndex}); + if (it == _endpointReviews.end() || !it->second.linked) { + return std::nullopt; + } + const vc3d::fiber_map::EndpointReview& review = it->second; + const LinkPalette& palette = review.pending + ? (review.sameHv ? kLinkSameTypePending : kLinkCrossPending) + : (review.sameHv ? kLinkSameType : kLinkCross); + EndpointLook look; + look.fill = palette.brush; + look.pen = palette.pen; + look.pending = review.pending; + look.dashed = review.autoPending; + look.anyAuto = review.anyAuto; + look.adjacent = review.adjacent; + return look; +} + void FiberMapWorkspace::setHighlightedFiber(uint64_t fiberId) { - if (_highlightedFiber == fiberId) { + clearOperationStatus(); + setSelectedFibers(fiberId != 0 ? std::vector{fiberId} : std::vector{}); +} + +void FiberMapWorkspace::setSelectedFibers(std::vector fibers) +{ + // Only fibers on the map can be selected; order kept, at most two. + fibers.erase(std::remove_if(fibers.begin(), fibers.end(), + [this](uint64_t id) { return !_entries.contains(id); }), + fibers.end()); + while (fibers.size() > 2) { + fibers.erase(fibers.begin()); + } + if (fibers == _selectedFibers) { return; } // Restore the previous selection and its network's glow. - if (const auto previous = _entries.find(_highlightedFiber); - previous != _entries.end()) { - paintFiberEmphasis(*previous, FiberEmphasis::Plain); + for (const uint64_t previous : _selectedFibers) { + if (const auto entry = _entries.find(previous); entry != _entries.end()) { + paintFiberEmphasis(*entry, FiberEmphasis::Plain); + } } for (const uint64_t member : _networkEmphasized) { if (const auto entry = _entries.find(member); entry != _entries.end()) { @@ -3714,85 +3961,179 @@ void FiberMapWorkspace::setHighlightedFiber(uint64_t fiberId) } _networkEmphasized.clear(); clearControlPointDots(); - _highlightedFiber = fiberId; - - const auto entry = _entries.find(fiberId); - if (entry == _entries.end()) { + _selectedFibers = std::move(fibers); + _highlightedFiber = _selectedFibers.empty() ? 0 : _selectedFibers.back(); + updateSelectionStatus(); + if (_highlightedFiber == 0) { return; } - // The whole linked network glows; the selected fiber itself gets the - // full treatment instead. - if (entry->networkId >= 0) { + // The most recent fiber's whole linked network glows; every selected + // fiber itself gets the full treatment and its control dots. + if (const auto entry = _entries.find(_highlightedFiber); + entry != _entries.end() && entry->networkId >= 0) { for (auto other = _entries.begin(); other != _entries.end(); ++other) { if (other->networkId == entry->networkId && - other.key() != fiberId) { + std::find(_selectedFibers.begin(), _selectedFibers.end(), other.key()) == + _selectedFibers.end()) { paintFiberEmphasis(*other, FiberEmphasis::Network); _networkEmphasized.push_back(other.key()); } } } - paintFiberEmphasis(*entry, FiberEmphasis::Selected); - const FiberMapPalette& theme = activePalette(); + for (const uint64_t fiberId : _selectedFibers) { + if (const auto entry = _entries.find(fiberId); entry != _entries.end()) { + paintFiberEmphasis(*entry, FiberEmphasis::Selected); + drawControlPointDots(fiberId); + } + } +} + +void FiberMapWorkspace::updateSelectionStatus() +{ + // Two selected fibers are named in the status line (the fresh status + // only; a stale banner outranks the selection). + if (!_statusLabel || !_layoutBuilt || !_staleReason.isEmpty()) { + return; + } + _statusLabel->setStyleSheet(_freshStatusStyle); + if (_selectedFibers.size() == 2 && _controller) { + _statusLabel->setText(composeStatus( + tr("%1 · Selected: %2, %3") + .arg(_freshStatus, _controller->fiberDisplayName(_selectedFibers[0]), + _controller->fiberDisplayName(_selectedFibers[1])))); + } else { + _statusLabel->setText(composeStatus(_freshStatus)); + } +} + +void FiberMapWorkspace::drawControlPointDots(uint64_t fiberId) +{ + const auto entry = _entries.constFind(fiberId); + if (entry == _entries.constEnd()) { + return; + } + const FiberMapPalette& theme = activePalette(); const QColor color = fiberColor(entry->fiber.hvTag, theme); const double vxPerCm = sceneVxPerCm(); // The selected fiber's dots cover the scene's kollesis markers, so a // tagged point keeps its look here: a hollow yellow ring when unlinked, // the link fill inside the yellow ring when linked. - const char selfTag = entry->fiber.hvTag; - const auto linkFillFor = [this, selfTag](uint64_t fiberId, - int controlIndex) -> std::optional { - for (const vc3d::fiber_map::PlacedLink& link : _layout.links) { - const bool atA = link.fiberA == fiberId && link.cpA == controlIndex; - const bool atB = link.fiberB == fiberId && link.cpB == controlIndex; - if ((!atA && !atB) || link.suspect) { - continue; - } - const auto other = _entries.constFind(atA ? link.fiberB : link.fiberA); - const char otherTag = other == _entries.constEnd() ? '?' : other->fiber.hvTag; - return linkPalette(selfTag, otherTag, link.pending).brush; - } - return std::nullopt; - }; for (std::size_t i = 0; i < entry->fiber.controlPoints.size(); ++i) { QBrush fill(color); QPen rim = cosmeticPen(theme.chipInk, 1.0); const bool kollesis = i < entry->fiber.kollesisTerminations.size() && entry->fiber.kollesisTerminations[i]; const bool breakTag = !kollesis && i < entry->fiber.breaks.size() && entry->fiber.breaks[i]; + // An unreviewed automatic link on this control: dashed rim (untagged) + // or a separate dashed ring outside the tag rim (tagged) - the same + // pair every other renderer draws (linkedMarkerDashed). + const auto look = endpointLook(fiberId, static_cast(i)); + const bool autoDashed = look && look->dashed; + const bool anyAuto = look && look->anyAuto; + const bool triangle = look && look->adjacent; + // The pre-existing rule for the tag fill of a selected point: the + // link's palette only through a placed, non-suspect link (a point + // linked only by suspect links, or only to unplaceable fibers, + // stays hollow); an automatic link fills it regardless. + bool placedNonSuspectLink = false; + for (const vc3d::fiber_map::PlacedLink& link : _layout.links) { + if (link.suspect) { + continue; + } + if ((link.fiberA == fiberId && link.cpA == static_cast(i)) || + (link.fiberB == fiberId && link.cpB == static_cast(i))) { + placedNonSuspectLink = true; + break; + } + } if (kollesis || breakTag) { - if (const auto linkFill = linkFillFor(fiberId, static_cast(i))) { - fill = QBrush(*linkFill); + if (look && (placedNonSuspectLink || anyAuto)) { + fill = QBrush(look->fill); } else { fill = QBrush(Qt::NoBrush); } rim = kollesis ? cosmeticPen(kollesisColor(255), kKollesisRimWidthPx) : dottedPen(breakColor(255), kKollesisRimWidthPx); + } else if (anyAuto && look) { + // An untagged endpoint with an automatic link: the pending + // palette (fill and outline), dashed only when every pending + // link is automatic - a mixed point stays solid in the palette. + fill = QBrush(look->fill); + rim = autoDashed ? dashedPen(look->pen, 1.0) : cosmeticPen(look->pen, 1.0); } auto* dot = new ScaledDot(fill, rim, kControlDotRadiusCm * vxPerCm, kMinControlDotPx, - kMaxControlDotPx, kControlDotBoundsCm * vxPerCm); + kMaxControlDotPx, kControlDotBoundsCm * vxPerCm, + /*triangle=*/triangle); _scene->addItem(dot); dot->setPos(entry->fiber.controlPoints[i]); dot->setZValue(kHighlightZ + 1.0); dot->setData(0, QVariant::fromValue(fiberId)); dot->setData(1, static_cast(i)); _controlPointDots.push_back(dot); + if (autoDashed && look && (kollesis || breakTag)) { + // Outside every retained tag stroke: the selected dot's own + // (kKollesisRimWidthPx) and the endpoint dot's underneath (the + // crossing dot's radius), with the ring's width on top. + const qreal clearancePx = kKollesisRimWidthPx + 1.5; + auto* ring = new ScaledDot(QBrush(Qt::NoBrush), dashedPen(look->pen, 1.0), + std::max(kControlDotRadiusCm, kCrossingDotRadiusCm) * vxPerCm * + kAutoLinkRingScale, + std::max(kMinControlDotPx, kMinCrossingDotPx) * + kAutoLinkRingScale + clearancePx, + std::max(kMaxControlDotPx, kMaxCrossingDotPx) * + kAutoLinkRingScale + clearancePx, + std::max(kControlDotBoundsCm, kCrossingDotBoundsCm) * vxPerCm * + kAutoLinkRingScale); + _scene->addItem(ring); + ring->setPos(entry->fiber.controlPoints[i]); + ring->setZValue(kHighlightZ + 1.1); + ring->setData(0, QVariant::fromValue(fiberId)); + ring->setData(1, static_cast(i)); + _controlPointDots.push_back(ring); + } } } void FiberMapWorkspace::handleControlPointMenu(const QPointF& scenePos, const QPoint& globalPos) { - // The menu acts on the selected fiber only, and only when the click lands + // The menu acts on a selected fiber only, and only when the click lands // on it - its line or one of its control dots. Selecting by left click // first is what says, unambiguously, which fiber "Delete" means; a - // ctrl+right-click elsewhere does nothing rather than guess. - if (_highlightedFiber == 0 || !_controller) { + // ctrl+right-click elsewhere does nothing rather than guess. With two + // fibers selected the one under the click is the subject. + if (_selectedFibers.empty() || !_controller) { return; } if (refreshStaleState()) { return; } - const uint64_t fiberId = _highlightedFiber; + const double subjectTolerance = sceneTolerance(kControlDotTolerancePx); + const auto landsOn = [this, &scenePos, subjectTolerance](uint64_t candidate) { + if (fiberAt(scenePos) == candidate) { + return true; + } + for (QGraphicsItem* dot : _controlPointDots) { + if (dot->data(0).toULongLong() != candidate) { + continue; + } + const QPointF delta = scenePos - dot->pos(); + if (std::sqrt(QPointF::dotProduct(delta, delta)) <= subjectTolerance) { + return true; + } + } + return false; + }; + uint64_t fiberId = 0; + for (auto it = _selectedFibers.rbegin(); it != _selectedFibers.rend(); ++it) { + if (landsOn(*it)) { + fiberId = *it; + break; + } + } + if (fiberId == 0) { + return; + } const auto entry = _entries.constFind(fiberId); if (entry == _entries.constEnd()) { return; @@ -3809,6 +4150,9 @@ void FiberMapWorkspace::handleControlPointMenu(const QPointF& scenePos, const QP int bestIndex = -1; double bestDistance = tolerance; for (QGraphicsItem* dot : _controlPointDots) { + if (dot->data(0).toULongLong() != fiberId) { + continue; + } const QPointF delta = scenePos - dot->pos(); const double distance = std::sqrt(QPointF::dotProduct(delta, delta)); if (distance <= bestDistance) { @@ -3886,8 +4230,30 @@ void FiberMapWorkspace::handleControlPointMenu(const QPointF& scenePos, const QP }); menu.addSeparator(); } + if (_selectedFibers.size() == 2) { + // "Link and ": the automatic link at the fibers' + // closest pass near where they meet on the map, deferred out of the + // menu's nested loop like the delete (it finalizes sessions, runs + // solves and bumps the generation). + const uint64_t otherId = _selectedFibers[0] == fiberId ? _selectedFibers[1] + : _selectedFibers[0]; + const QString otherName = _controller->fiberDisplayName(otherId); + QAction* linkAction = menu.addAction(tr("Link %1 and %2").arg(displayName, otherName)); + linkAction->setEnabled(!_deleteInFlight && !_autoLinkInFlight); + const QPointF clickScene = scenePos; + connect(linkAction, &QAction::triggered, this, + [this, fiberId, otherId, clickScene, menuDependencies]() { + QMetaObject::invokeMethod( + this, + [this, fiberId, otherId, clickScene, menuDependencies]() { + startAutoLink(fiberId, otherId, clickScene, menuDependencies); + }, + Qt::QueuedConnection); + }); + menu.addSeparator(); + } QAction* deleteAction = menu.addAction(tr("Delete %1…").arg(displayName)); - deleteAction->setEnabled(!_deleteInFlight); + deleteAction->setEnabled(!_deleteInFlight && !_autoLinkInFlight); connect(deleteAction, &QAction::triggered, this, [this, fiberId, fileName, displayName, menuDependencies]() { // Deferred past menu.exec()'s nested loop: the confirmation @@ -3904,6 +4270,187 @@ void FiberMapWorkspace::handleControlPointMenu(const QPointF& scenePos, const QP menu.exec(globalPos); } +void FiberMapWorkspace::startAutoLink(uint64_t subjectId, uint64_t otherId, QPointF scenePos, + const vc3d::fiber_map::FiberMapDependencies& menuDependencies) +{ + // Three phases on one queued callback, every one guarded: + // B. the controller finalizes, saves and closes every live session of + // the two fibers and drains the saves; if that moved the fiber + // generation the map is stale and the automatic Update is armed - + // the user chooses Link again on the rebuilt map (the pair stays + // selected); + // C. the meeting of the two DRAWN fibers nearest the click, inverted + // exactly to a position on each fiber's stored line points; + // D. the controller's auto-link (closest 3D pass, control points, + // span re-optimizations, the pending automatic link). + // The workspace and the controller may both go away inside the yields: + // nothing of either is touched afterwards without the QPointer checks. + if (!_controller || _autoLinkInFlight || _deleteInFlight) { + return; + } + // A new attempt supersedes the last one's message. + clearOperationStatus(); + const QPointer self(this); + const QPointer controller(_controller); + const auto fresh = [self, menuDependencies]() { + if (!self) { + return false; + } + return vc3d::fiber_map::staleVerdictFor(menuDependencies, self->currentDependencies(), + /*layoutBuilt=*/true, QString()) + .action == StaleVerdict::Action::Fresh; + }; + const auto bail = [self](const QString& reason) { + if (!self) { + return; + } + // Kept in front of whatever the line's floor becomes (the stale + // banner, the rebuilt map's summary) until the next user action. + self->setOperationStatus(reason); + Logger()->warn("Fiber map auto-link: {}", reason.toStdString()); + }; + // Phase A's capture is re-verified now, exactly as the deferred delete does. + if (!fresh()) { + refreshStaleState(); + Logger()->warn("Fiber map: dependencies changed before the auto-link started"); + return; + } + std::string subjectFile; + std::string otherFile; + if (const auto entry = _entries.constFind(subjectId); entry != _entries.constEnd()) { + subjectFile = entry->fiber.fileName; + } + if (const auto entry = _entries.constFind(otherId); entry != _entries.constEnd()) { + otherFile = entry->fiber.fileName; + } + if (subjectFile.empty() || otherFile.empty() || + !_controller->hasLoadedFiber(subjectId, subjectFile) || + !_controller->hasLoadedFiber(otherId, otherFile)) { + markStale(tr("Fibers changed — press Update")); + return; + } + _autoLinkInFlight = true; + QGuiApplication::setOverrideCursor(Qt::WaitCursor); + // Whatever path leaves this function, the flag and the cursor are + // restored (a controller exception included - it is reported, not + // propagated into the event loop). + const auto cleanup = qScopeGuard([self]() { + if (self) { + self->_autoLinkInFlight = false; + } + QGuiApplication::restoreOverrideCursor(); + }); + + // Phase B. + LineAnnotationController::FiberMapAutoLinkOutcome prepared; + try { + prepared = controller->prepareFiberMapAutoLink(subjectId, subjectFile, otherId, otherFile); + } catch (const std::exception& ex) { + prepared.ok = false; + prepared.message = tr("Could not prepare the link: %1").arg(QString::fromUtf8(ex.what())); + } + if (!self || !controller) { + if (self) { + bail(tr("The annotation controller went away.")); + } + return; + } + if (!prepared.ok) { + bail(prepared.message); + return; + } + if (!fresh()) { + // A session had unsaved work: the map must be rebuilt before a + // meeting can be located on it. The automatic Update is already + // armed by the generation bump; the selection restore keeps the pair. + bail(tr("Saved the open line annotation; the map is updating — choose Link again")); + refreshStaleState(); + return; + } + + // Phase C: the locator on the current map geometry. + const auto subjectEntry = _entries.constFind(subjectId); + const auto otherEntry = _entries.constFind(otherId); + if (subjectEntry == _entries.constEnd() || otherEntry == _entries.constEnd()) { + bail(tr("Fibers changed — press Update")); + return; + } + const double maxApproachVx = 0.1 * sceneVxPerCm(); // 1 mm on the map + const auto meeting = vc3d::fiber_map::autolink::findMapMeeting( + subjectEntry->fiber, otherEntry->fiber, scenePos, maxApproachVx); + if (!meeting) { + bail(tr("%1 and %2 neither cross nor come within 1 mm of each other on the map") + .arg(_controller->fiberDisplayName(subjectId), + _controller->fiberDisplayName(otherId))); + return; + } + const auto sourcePosition = [this, controller](uint64_t fiberId, const std::string& fileName, + const vc3d::fiber_map::PlacedFiber& placed, + double arclength) -> std::optional { + const std::vector* linePoints = controller->storedFiberLinePoints(fiberId, fileName); + if (!linePoints || linePoints->empty()) { + return std::nullopt; + } + const std::vector raw = vc3d::fiber_map::unrolledLineArclengths( + *linePoints, _layoutUmbilicusCenters, placed.thetaScale, placed.offsetRad, + _layout.rRefVx); + return vc3d::fiber_map::autolink::sourcePositionAtArclength(raw, arclength); + }; + const auto positionA = sourcePosition(subjectId, subjectFile, subjectEntry->fiber, + meeting->a.arclength); + const auto positionB = sourcePosition(otherId, otherFile, otherEntry->fiber, + meeting->b.arclength); + if (!positionA || !positionB) { + bail(tr("Fibers changed — press Update")); + return; + } + + // Phase D. + LineAnnotationController::FiberMapAutoLinkEndpoint endpointA; + endpointA.fiberId = subjectId; + endpointA.fileName = subjectFile; + endpointA.linePosition = *positionA; + LineAnnotationController::FiberMapAutoLinkEndpoint endpointB; + endpointB.fiberId = otherId; + endpointB.fileName = otherFile; + endpointB.linePosition = *positionB; + const uint64_t mapGeneration = _layoutGeneration; + const auto mapStillFresh = [self, menuDependencies, mapGeneration]() { + if (!self) { + return false; + } + vc3d::fiber_map::FiberMapDependencies current = self->currentDependencies(); + // The fiber generation is the controller's own guard (only this + // operation's saves may advance it); everything else must hold. + current.fiberGeneration = menuDependencies.fiberGeneration; + return vc3d::fiber_map::staleVerdictFor(menuDependencies, current, true, QString()) + .action == StaleVerdict::Action::Fresh; + }; + LineAnnotationController::FiberMapAutoLinkOutcome outcome; + try { + outcome = controller->autoLinkFibers(endpointA, endpointB, mapGeneration, mapStillFresh, + LineAnnotationController::FiberMapAutoLinkParams{}); + } catch (const std::exception& ex) { + outcome.ok = false; + outcome.message = tr("Linking failed: %1").arg(QString::fromUtf8(ex.what())); + } + if (!self) { + return; + } + // The outcome (a refusal, a retained-control explanation after a partial + // failure, the success summary) outlives the stale gate below and the + // automatic Update it arms: composeStatus() keeps it in front of both. + setOperationStatus(outcome.message); + if (outcome.ok) { + Logger()->info("Fiber map auto-link: {}", outcome.message.toStdString()); + } else { + Logger()->warn("Fiber map auto-link refused: {}", outcome.message.toStdString()); + } + // The link (and any placement) bumped the fiber generation: the stale + // gate arms the automatic Update, which redraws the map with the link. + refreshStaleState(); +} + void FiberMapWorkspace::handleTreeContextMenu(const QPoint& pos) { if (!_tree || !_controller) { @@ -3989,11 +4536,12 @@ void FiberMapWorkspace::confirmAndDeleteFiber( // saves in a nested loop that processes input, so without this a second // confirmation of the same fiber could start a second delete that, once // the first had removed the file, fell back to an unrelated one. - if (_deleteInFlight) { + if (_deleteInFlight || _autoLinkInFlight) { Logger()->warn("Fiber map: a delete is already pending; ignoring {}", fileName); return; } _deleteInFlight = true; + clearOperationStatus(); // The confirmation is modeless (open(), not exec()): a nested loop with a // parented dialog would be undefined if the workspace were torn down // meanwhile, whereas this dialog simply dies with its parent and the diff --git a/volume-cartographer/apps/VC3D/FiberMapWorkspace.hpp b/volume-cartographer/apps/VC3D/FiberMapWorkspace.hpp index 9eadc94765..46d76519f8 100644 --- a/volume-cartographer/apps/VC3D/FiberMapWorkspace.hpp +++ b/volume-cartographer/apps/VC3D/FiberMapWorkspace.hpp @@ -71,7 +71,9 @@ class FiberMapView : public QGraphicsView void setRulerStyle(const FiberMapRulerStyle& style); signals: - void clicked(QPointF scenePos); + // A left click without a drag, with the keyboard modifiers held (Shift + // extends the selection to a second fiber). + void clicked(QPointF scenePos, Qt::KeyboardModifiers modifiers); void controlPointMenuRequested(QPointF scenePos, QPoint globalPos); // The view scale changed (wheel zoom); fitInView callers refresh // scale-dependent state themselves. @@ -194,6 +196,18 @@ class FiberMapWorkspace : public QMainWindow // this is how applyStaleVerdict() surfaces staleness *derived* from the // dependency comparison, which clears itself when its cause reverts. void showStale(const QString& reason); + // The status line composed from its current floor (stale reason, build + // summary with the selection, or resting prompt) with the operation + // message in front of it. Every write of the label goes through + // composeStatus() so no floor change can erase the message. + [[nodiscard]] QString composeStatus(const QString& floor) const; + void redrawStatus(); + // The outcome of the last user-started operation (an auto-link refusal, + // its retained-control explanation, "choose Link again"). It stays on + // the line through staleness polling and the automatic rebuild's + // publication, until the next user action on the map. + void setOperationStatus(const QString& message); + void clearOperationStatus(); // The latching form, for staleness asserted rather than derived — the // invariant-violation defenses. Nothing in the dependency sets can prove a // latched reason wrong, so it survives every comparison until the layout it @@ -297,6 +311,39 @@ class FiberMapWorkspace : public QMainWindow [[nodiscard]] QRectF framedRect() const; void applySceneRect(); void setHighlightedFiber(uint64_t fiberId); + // The selection: at most two fibers, most recent last. A plain click + // selects one (or none); Shift+click keeps the last two clicked. Both + // paint as selected with their control dots; the network glow and the + // dock row follow the most recent (_highlightedFiber). + void setSelectedFibers(std::vector fibers); + void drawControlPointDots(uint64_t fiberId); + void updateSelectionStatus(); + // "Link and ": the deferred three-phase trigger (see the plan in + // the header comment of startAutoLink). + void startAutoLink(uint64_t subjectId, uint64_t otherId, QPointF scenePos, + const vc3d::fiber_map::FiberMapDependencies& menuDependencies); + // The shared marker rule for one link endpoint of the published layout: + // true when the control point has at least one pending link and every + // pending link on it is an unreviewed automatic link + // (line_annotation::autoPendingMarker): its markers draw dashed. + [[nodiscard]] bool endpointAutoPending(uint64_t fiberId, int controlIndex) const; + // The one look of a link endpoint, from the endpoint's AGGREGATED review + // state (every link at that control point): pending outranks approved + // for the palette (as the annotation views' marker precedence), the + // dashed outline follows endpointAutoPending. Every dot drawer uses it. + struct EndpointLook { + QColor fill; + QColor pen; + bool pending = false; + // Dashed outline: every pending link on the point is automatic. + bool dashed = false; + // Any automatic link on the point (a mixed point keeps the pending + // palette solid; without one the pre-existing looks are untouched). + bool anyAuto = false; + // Any adjacent link on the point: triangle instead of circle. + bool adjacent = false; + }; + [[nodiscard]] std::optional endpointLook(uint64_t fiberId, int controlIndex) const; // The gap heat map (FiberMapGapField.hpp). The field is a pure function of // the published layout and the toolbar's settings: it is built on the // rebuild worker alongside the layout, with the settings captured at job @@ -343,7 +390,7 @@ class FiberMapWorkspace : public QMainWindow enum class FiberEmphasis { Plain, Network, Selected }; void paintFiberEmphasis(FiberEntry& entry, FiberEmphasis emphasis); void clearControlPointDots(); - void handleSceneClick(const QPointF& scenePos); + void handleSceneClick(const QPointF& scenePos, Qt::KeyboardModifiers modifiers); // Ctrl+right-click on the map: acts on the selected fiber, and only when // the click lands on it. "Go to control point" when a dot was hit, and // "Delete" always. @@ -433,6 +480,14 @@ class FiberMapWorkspace : public QMainWindow std::vector _pendingGapTiles; bool _pendingGapTilesDark = false; vc3d::fiber_map::GlobalResult _layout; + // The umbilicus centres _layout was built with (a copy of the job's + // snapshot), for unrolledLineArclengths when a drawn point is mapped back + // to a source line position (the auto-link locator). + std::vector _layoutUmbilicusCenters; + // Per link endpoint, the review state aggregated from EVERY link of the + // snapshot (the layout keeps only the links it could place): what the + // dot drawers and endpointLook decide from. + std::map _endpointReviews; // Memoized rebuild state: the cache, whether a verification failure // benched it, and the last build's input/output digests for Full // rebuild's check. @@ -470,6 +525,11 @@ class FiberMapWorkspace : public QMainWindow // it stands rather than take a fresh snapshot to work out the message again. QString _emptyMessage; uint64_t _highlightedFiber = 0; + // The ordered selection (<= 2, back = most recent = _highlightedFiber). + std::vector _selectedFibers; + // "Link A and B" in flight: the menu entry is disabled and a delete + // refuses, like _deleteInFlight the other way round. + bool _autoLinkInFlight = false; // Fibers currently carrying the subtle network emphasis, so the next // selection change can restore exactly them. std::vector _networkEmphasized; @@ -538,6 +598,8 @@ class FiberMapWorkspace : public QMainWindow // froze into itself: showEvent() recomposes the suffix from the live // package, which can change while nothing is built. QString _restingReason; + // See setOperationStatus(). Empty when the line shows only its floor. + QString _operationStatus; QString _umbilicusStatusText; QString _umbilicusStatusFingerprint; bool _umbilicusStatusValid = false; diff --git a/volume-cartographer/apps/VC3D/FiberNetworkLayout.cpp b/volume-cartographer/apps/VC3D/FiberNetworkLayout.cpp index 90dc65bb8a..ca708c472f 100644 --- a/volume-cartographer/apps/VC3D/FiberNetworkLayout.cpp +++ b/volume-cartographer/apps/VC3D/FiberNetworkLayout.cpp @@ -111,10 +111,12 @@ std::vector arclengths(const std::vector& points) // independent of the very uneven raw point spacing. Returns the arclength grid // alongside the smoothed points so positions at any raw arclength can be read // back and land exactly on the drawn curve. -void smoothPolyline(const std::vector& points, double sigma, double step, +// sRaw is the caller's arclengths(points): one array parameterises the +// resampling, the control read-back and the inversion back to line points. +void smoothPolyline(const std::vector& points, const std::vector& sRaw, + double sigma, double step, std::vector& sOut, std::vector& qOut) { - const std::vector sRaw = arclengths(points); const double total = sRaw.empty() ? 0.0 : sRaw.back(); // The unsmoothed fallback also covers parameters that cannot be resampled // sanely: non-finite lengths, and steps so small the sample count would @@ -236,6 +238,41 @@ UmbilicusInterp interpolateUmbilicus(const std::vector& umbilicusCent return interp; } +// Fiber-local unwrapped theta of every line point about the z-interpolated +// umbilicus: the one definition prepareFiber and unrolledLineArclengths share. +std::vector unwrappedThetaLine(const std::vector& linePoints, + const UmbilicusInterp& umbilicus, + std::vector* radiusOut) +{ + std::vector raw(linePoints.size()); + if (radiusOut) { + radiusOut->resize(linePoints.size()); + } + for (std::size_t i = 0; i < linePoints.size(); ++i) { + const cv::Vec3d& point = linePoints[i]; + const double dx = point[0] - interpolate(point[2], umbilicus.z, umbilicus.x); + const double dy = point[1] - interpolate(point[2], umbilicus.z, umbilicus.y); + raw[i] = std::atan2(dy, dx); + if (radiusOut) { + (*radiusOut)[i] = std::sqrt(dx * dx + dy * dy); + } + } + return unwrapAngles(raw); +} + +// The drawn plane: x = (thetaScale * theta + offsetRad) * rRef, y = z. +std::vector unrollLinePoints(const std::vector& thetaLine, + const std::vector& linePoints, + double thetaScale, double offsetRad, double rRefVx) +{ + std::vector unrolled(linePoints.size()); + for (std::size_t i = 0; i < linePoints.size(); ++i) { + unrolled[i] = QPointF((thetaScale * thetaLine[i] + offsetRad) * rRefVx, + linePoints[i][2]); + } + return unrolled; +} + // A fiber unrolled about the umbilicus; angles are fiber-local until the // whole-turn offset is applied. struct PreparedFiber { @@ -259,16 +296,7 @@ PreparedFiber prepareFiber(const InputFiber& fiber, const UmbilicusInterp& umbil { PreparedFiber entry; entry.input = &fiber; - std::vector raw(fiber.linePoints.size()); - entry.radius.resize(fiber.linePoints.size()); - for (std::size_t i = 0; i < fiber.linePoints.size(); ++i) { - const cv::Vec3d& point = fiber.linePoints[i]; - const double dx = point[0] - interpolate(point[2], umbilicus.z, umbilicus.x); - const double dy = point[1] - interpolate(point[2], umbilicus.z, umbilicus.y); - raw[i] = std::atan2(dy, dx); - entry.radius[i] = std::sqrt(dx * dx + dy * dy); - } - entry.thetaLine = unwrapAngles(raw); + entry.thetaLine = unwrappedThetaLine(fiber.linePoints, umbilicus, &entry.radius); entry.controlLineIndex.resize(fiber.controlPoints.size()); for (std::size_t i = 0; i < fiber.controlPoints.size(); ++i) { double best = std::numeric_limits::infinity(); @@ -298,6 +326,8 @@ struct LinkRecord { // both at once is a disagreement between the two files. bool anyAdjacent = false; bool anyExplicitOrdinary = false; + // Any ref said automatic; kept under `pending && !adjacent`. + bool autoLink = false; bool disagrees() const { return anyAdjacent && anyExplicitOrdinary; } }; @@ -347,6 +377,8 @@ struct FiberGeometry { std::vector samples; std::vector controlArclength; std::vector controlPoints; + double thetaScale = 1.0; + double offsetRad = 0.0; }; struct NetworkDraft { @@ -433,14 +465,23 @@ std::vector collectValidLinks( const bool explicitOrdinary = !link.adjacent && link.adjacentExplicit; if (!inserted.second) { LinkRecord& seen = links[inserted.first->second]; + // Automatic only if every pending contributor is automatic: + // a human pending ref outranks (the controller's dedup and + // the markers decide the same way). + const bool thisAuto = link.autoLink && link.pending && !link.adjacent; + const bool humanPending = (seen.pending && !seen.autoLink) || + (link.pending && !thisAuto); seen.pending |= link.pending; seen.adjacent |= link.adjacent; seen.anyAdjacent |= link.adjacent; seen.anyExplicitOrdinary |= explicitOrdinary; + seen.autoLink = seen.pending && !humanPending && (seen.autoLink || thisAuto) && + !seen.adjacent; continue; } links.push_back(LinkRecord{member, ia, other, ib, 0.0, link.pending, link.adjacent, - link.adjacent, explicitOrdinary}); + link.adjacent, explicitOrdinary, + link.autoLink && link.pending && !link.adjacent}); } } std::sort(links.begin(), links.end(), @@ -562,15 +603,14 @@ FiberGeometry buildFiberGeometry(const PreparedFiber& entry, double thetaScale, double resampleStepVx) { const InputFiber& fiber = *entry.input; - std::vector unrolled(fiber.linePoints.size()); - for (std::size_t i = 0; i < fiber.linePoints.size(); ++i) { - unrolled[i] = QPointF((thetaScale * entry.thetaLine[i] + offsetRad) * rRefVx, - fiber.linePoints[i][2]); - } + const std::vector unrolled = + unrollLinePoints(entry.thetaLine, fiber.linePoints, thetaScale, offsetRad, rRefVx); FiberGeometry geo; + geo.thetaScale = thetaScale; + geo.offsetRad = offsetRad; const std::vector rawArclength = arclengths(unrolled); - smoothPolyline(unrolled, sigmaVx, resampleStepVx, geo.sampleArclength, + smoothPolyline(unrolled, rawArclength, sigmaVx, resampleStepVx, geo.sampleArclength, geo.samples); std::vector sampleX(geo.samples.size()); std::vector sampleY(geo.samples.size()); @@ -623,6 +663,9 @@ PlacedFiber makePlacedFiber(const InputFiber& fiber, const FiberGeometry& geo) placedFiber.label = fiber.label; placedFiber.hvTag = fiber.hvTag; placedFiber.controlPoints = geo.controlPoints; + placedFiber.controlArclength = geo.controlArclength; + placedFiber.thetaScale = geo.thetaScale; + placedFiber.offsetRad = geo.offsetRad; // Per-point flags are copied only when they line up with the controls; a // mismatched vector is ignored rather than read misaligned. const auto copyPointFlags = [&](const std::vector& flags, std::vector& out) { @@ -667,6 +710,7 @@ PlacedFiber makePlacedFiber(const InputFiber& fiber, const FiberGeometry& geo) whole.firstControl = 0; whole.lastControl = static_cast(spanCount); whole.points = geo.samples; + whole.arclengths = geo.sampleArclength; placedFiber.runs.push_back(std::move(whole)); } return placedFiber; @@ -698,6 +742,9 @@ PlacedFiber makePlacedFiber(const InputFiber& fiber, const FiberGeometry& geo) run.lastControl = static_cast(j + 1); run.points.assign(geo.samples.begin() + static_cast(from), geo.samples.begin() + static_cast(to)); + run.arclengths.assign( + geo.sampleArclength.begin() + static_cast(from), + geo.sampleArclength.begin() + static_cast(to)); placedFiber.runs.push_back(std::move(run)); } k = j + 1; @@ -1039,6 +1086,7 @@ Result buildLayout(const std::vector& fibers, placedLink.turnErr = link.turnErr; placedLink.pending = link.pending; placedLink.adjacent = link.adjacent; + placedLink.autoLink = link.autoLink; placedLink.adjacentUnpaired = adjacentUnpaired( link.adjacent, ordered[link.a]->hvTag, ordered[link.b]->hvTag); placedLink.adjacentDisagrees = link.disagrees(); @@ -1856,6 +1904,7 @@ GlobalResult buildGlobalLayout(const std::vector& fibers, placedLink.turnErr = solve.linkTurnErrors[l]; placedLink.pending = link.pending; placedLink.adjacent = link.adjacent; + placedLink.autoLink = link.autoLink; placedLink.adjacentUnpaired = adjacentUnpaired(link.adjacent, ordered[link.a]->hvTag, ordered[link.b]->hvTag); placedLink.adjacentDisagrees = link.disagrees(); @@ -2208,6 +2257,7 @@ ContentDigest digestGlobalInputs(const std::vector& fibers, hashU64(digest, link.pending ? 1 : 0); hashU64(digest, link.adjacent ? 1 : 0); hashU64(digest, link.adjacentExplicit ? 1 : 0); + hashU64(digest, link.autoLink ? 1 : 0); } } hashDouble(digest, params.suspectTurns); @@ -2311,12 +2361,22 @@ ContentDigest digestGlobalResult(const GlobalResult& result) hashDouble(digest, point.x()); hashDouble(digest, point.y()); } + hashU64(digest, run.arclengths.size()); + for (const double s : run.arclengths) { + hashDouble(digest, s); + } } hashU64(digest, fiber.fiber.controlPoints.size()); for (const QPointF& point : fiber.fiber.controlPoints) { hashDouble(digest, point.x()); hashDouble(digest, point.y()); } + hashU64(digest, fiber.fiber.controlArclength.size()); + for (const double s : fiber.fiber.controlArclength) { + hashDouble(digest, s); + } + hashDouble(digest, fiber.fiber.thetaScale); + hashDouble(digest, fiber.fiber.offsetRad); hashU64(digest, fiber.fiber.kollesisTerminations.size()); for (const bool tagged : fiber.fiber.kollesisTerminations) { hashU64(digest, tagged ? 1 : 0); @@ -2342,6 +2402,7 @@ ContentDigest digestGlobalResult(const GlobalResult& result) hashU64(digest, link.adjacent ? 1 : 0); hashU64(digest, link.adjacentUnpaired ? 1 : 0); hashU64(digest, link.adjacentDisagrees ? 1 : 0); + hashU64(digest, link.autoLink ? 1 : 0); } hashU64(digest, result.windings.size()); for (const WindingMark& mark : result.windings) { @@ -2421,4 +2482,65 @@ ContentDigest digestGlobalResult(const GlobalResult& result) return digest; } +std::map aggregateEndpointReviews( + const std::vector& fibers) +{ + std::unordered_map tagById; + tagById.reserve(fibers.size()); + for (const InputFiber& fiber : fibers) { + tagById[fiber.id] = fiber.hvTag; + } + struct Accumulator { + bool pending = false; + bool humanPending = false; + bool anyAuto = false; + bool sameHvPending = false; + bool sameHvApproved = false; + bool adjacent = false; + }; + std::map accumulators; + for (const InputFiber& fiber : fibers) { + for (const InputLink& link : fiber.links) { + if (link.controlPointIndex < 0) { + continue; + } + Accumulator& acc = accumulators[EndpointKey{fiber.id, link.controlPointIndex}]; + const bool automatic = link.autoLink && link.pending && !link.adjacent; + acc.pending = acc.pending || link.pending; + acc.humanPending = acc.humanPending || (link.pending && !automatic); + acc.anyAuto = acc.anyAuto || automatic; + acc.adjacent = acc.adjacent || link.adjacent; + const auto other = tagById.find(link.branchFiberId); + const bool sameHv = other != tagById.end() && fiber.hvTag != '?' && + other->second != '?' && other->second == fiber.hvTag; + if (sameHv) { + (link.pending ? acc.sameHvPending : acc.sameHvApproved) = true; + } + } + } + std::map reviews; + for (const auto& [key, acc] : accumulators) { + EndpointReview review; + review.linked = true; + review.pending = acc.pending; + review.autoPending = acc.pending && acc.anyAuto && !acc.humanPending; + review.anyAuto = acc.anyAuto; + review.sameHv = acc.pending ? acc.sameHvPending : acc.sameHvApproved; + review.adjacent = acc.adjacent; + reviews[key] = review; + } + return reviews; +} + +std::vector unrolledLineArclengths(const std::vector& linePoints, + const std::vector& umbilicusCenters, + double thetaScale, + double offsetRad, + double rRefVx) +{ + const UmbilicusInterp umbilicus = interpolateUmbilicus(umbilicusCenters); + const std::vector thetaLine = unwrappedThetaLine(linePoints, umbilicus, nullptr); + return arclengths(unrollLinePoints(thetaLine, linePoints, thetaScale, offsetRad, rRefVx)); +} + } // namespace vc3d::fiber_map diff --git a/volume-cartographer/apps/VC3D/FiberNetworkLayout.hpp b/volume-cartographer/apps/VC3D/FiberNetworkLayout.hpp index 4b54e691e3..5b272ecc89 100644 --- a/volume-cartographer/apps/VC3D/FiberNetworkLayout.hpp +++ b/volume-cartographer/apps/VC3D/FiberNetworkLayout.hpp @@ -55,6 +55,9 @@ struct InputLink { // Two explicit refs of one pair disagreeing (true here, false there) is // reported as PlacedLink::adjacentDisagrees. bool adjacentExplicit = true; + // An unreviewed automatic link (FiberBranchRef::autoLink): the map draws + // its endpoints dashed. ORed on dedup under `pending && !adjacent`. + bool autoLink = false; }; struct InputFiber { @@ -133,6 +136,11 @@ struct Run { int firstControl = -1; int lastControl = -1; std::vector points; + // Parallel to points: each drawn sample's raw unrolled arclength (the + // parameterisation buildFiberGeometry resampled on; see + // unrolledLineArclengths), so a point on the drawn curve can be mapped + // back to a position on the fiber's source line points. + std::vector arclengths; }; struct PlacedFiber { @@ -149,6 +157,14 @@ struct PlacedFiber { std::vector kollesisTerminations; // Parallel to controlPoints: the point carries the break tag (same rule). std::vector breaks; + // Parallel to controlPoints: each control's raw unrolled arclength (what + // controlPoints were read off the curve at). + std::vector controlArclength; + // The unroll this fiber was drawn with (x = (thetaScale * theta + + // offsetRad) * rRef), kept so unrolledLineArclengths can reproduce the + // raw arclength of its source line points exactly. + double thetaScale = 1.0; + double offsetRad = 0.0; }; // The points to DRAW for fiber.runs[runIndex]: the run's own points, except @@ -182,6 +198,9 @@ struct PlacedLink { // ordinary): the annotation is inconsistent between the two files. Always // suspect, constrains nothing; the sync merge arbitrates. bool adjacentDisagrees = false; + // An unreviewed automatic link (InputLink::autoLink, ORed on dedup): + // drawn with dashed endpoint markers in the pending colours. + bool autoLink = false; }; struct WindingMark { @@ -624,4 +643,44 @@ class GlobalLayoutCache { const GlobalLayoutParams& params); [[nodiscard]] ContentDigest digestGlobalResult(const GlobalResult& result); +// The review state of one link endpoint (a control point of a fiber), from +// EVERY link of the inputs - the layout drops links to fibers it cannot +// place, the markers must not. pending wins over approved; autoPending only +// when every pending link on the point is automatic (a human pending link +// outranks); sameHv is the same-orientation warning of the winning state +// (both tags known and equal); adjacent = any adjacent link (triangle). +struct EndpointReview { + bool linked = false; + bool pending = false; + bool autoPending = false; + bool anyAuto = false; + bool sameHv = false; + bool adjacent = false; +}; +struct EndpointKey { + uint64_t fiberId = 0; + int controlIndex = -1; + bool operator<(const EndpointKey& other) const + { + return fiberId != other.fiberId ? fiberId < other.fiberId + : controlIndex < other.controlIndex; + } +}; +[[nodiscard]] std::map aggregateEndpointReviews( + const std::vector& fibers); + +// The raw unrolled arclength of a fiber's source line points - the same +// array buildFiberGeometry resamples on (bitwise: same umbilicus +// interpolation, atan2, unwrapping, unroll and cumulative sum). thetaScale, +// offsetRad and rRefVx are the fiber's PlacedFiber values and +// GlobalResult::rRefVx; umbilicusCenters are the ones the layout was built +// with. Lets a drawn point (Run::arclengths) be inverted to a source line +// position without the layout retaining per-point data. +[[nodiscard]] std::vector unrolledLineArclengths( + const std::vector& linePoints, + const std::vector& umbilicusCenters, + double thetaScale, + double offsetRad, + double rRefVx); + } // namespace vc3d::fiber_map diff --git a/volume-cartographer/apps/VC3D/LineAnnotationController.cpp b/volume-cartographer/apps/VC3D/LineAnnotationController.cpp index 3e798c58e1..66a445bd83 100644 --- a/volume-cartographer/apps/VC3D/LineAnnotationController.cpp +++ b/volume-cartographer/apps/VC3D/LineAnnotationController.cpp @@ -10,6 +10,7 @@ #include "OpenDataLasagna.hpp" #include "FiberSliceGeometry.hpp" #include "LineAnnotationAdjacentLinks.hpp" +#include "FiberMapAutoLink.hpp" #include "LineAnnotationFiberLinks.hpp" #include "LineAnnotationStructuralEdits.hpp" #include "LineAnnotationFiberNaming.hpp" @@ -954,10 +955,15 @@ bool deduplicateBranchLinks( if (duplicate == deduplicated.end()) { deduplicated.push_back(std::move(branch)); } else { - duplicate->pending = duplicate->pending || branch.pending; // Same OR as the fiber map's dedup: an adjacent ref never quietly // becomes an ordinary one because an ordinary duplicate came first. - duplicate->adjacent = duplicate->adjacent || branch.adjacent; + const bool adjacent = duplicate->adjacent || branch.adjacent; + const auto combined = vc3d::line_annotation::combineReviewStates( + duplicate->pending, duplicate->autoLink, branch.pending, branch.autoLink, adjacent); + duplicate->pending = combined.pending; + duplicate->adjacent = adjacent; + duplicate->autoLink = combined.autoLink; + vc3d::line_annotation::normalizeBranchRefReviewState(*duplicate); } } const bool changed = deduplicated.size() != branches.size(); @@ -1323,6 +1329,7 @@ generatedControlMarkers( int controlPointIndex = -1; bool pending = false; bool adjacent = false; + bool autoLink = false; }; std::unordered_map> branchesByControl; branchesByControl.reserve(branches.size()); @@ -1346,10 +1353,16 @@ generatedControlMarkers( targets.push_back({branch.branchFiberId, branch.branchControlPointIndex, branch.pending, - branch.adjacent}); + branch.adjacent, + branch.autoLink && branch.pending && !branch.adjacent}); } else { - duplicate->pending = duplicate->pending || branch.pending; - duplicate->adjacent = duplicate->adjacent || branch.adjacent; + const bool adjacent = duplicate->adjacent || branch.adjacent; + const auto combined = vc3d::line_annotation::combineReviewStates( + duplicate->pending, duplicate->autoLink, branch.pending, + branch.autoLink && branch.pending && !branch.adjacent, adjacent); + duplicate->pending = combined.pending; + duplicate->adjacent = adjacent; + duplicate->autoLink = combined.autoLink; } } @@ -1391,11 +1404,16 @@ generatedControlMarkers( }); for (const auto& target : it->second) { marker.branchIds.push_back(target.fiberId); - marker.branchLinks.push_back( - {target.fiberId, target.controlPointIndex, target.pending, target.adjacent}); + marker.branchLinks.push_back({target.fiberId, + target.controlPointIndex, + target.pending, + target.adjacent, + target.autoLink}); marker.hasPendingLinks = marker.hasPendingLinks || target.pending; marker.hasAdjacentLinks = marker.hasAdjacentLinks || target.adjacent; } + marker.hasAutoPendingLinks = + vc3d::line_annotation::autoPendingMarker(marker.branchLinks); marker.branchIds.erase(std::unique(marker.branchIds.begin(), marker.branchIds.end()), marker.branchIds.end()); marker.hasBranches = !marker.branchLinks.empty(); @@ -2205,8 +2223,10 @@ void LineAnnotationController::launchFromViewerAtPoint(CChunkedVolumeViewer* vie handleLineSeed(surfaceName, sample->position, InitialDirectionMode::ZInOut); } -bool LineAnnotationController::prepareForUserFacingLineAnnotationOpen() +bool LineAnnotationController::prepareForUserFacingLineAnnotationOpen( + const std::function& stillValid) { + const QPointer alive(this); struct DialogRecord { std::string surfaceName; QPointer dialog; @@ -2242,8 +2262,22 @@ bool LineAnnotationController::prepareForUserFacingLineAnnotationOpen() if (!suppressBeforeFinalize && needsFinalOptimization(session)) { session.suppressFiberSave = true; } - if (!finalizeSessionOptimizationSynchronously(session, false)) { - session.suppressFiberSave = suppressBeforeFinalize; + // The finalization and the save below yield: the pane must still + // hold this session afterwards (the record owns it meanwhile). + const auto paneIntact = [this, &record]() { + auto* pane = paneForSurface(record.surfaceName); + return pane && pane->session == record.session; + }; + const auto sessionValid = [&alive, &stillValid, &paneIntact]() { + return alive && (!stillValid || stillValid()) && paneIntact(); + }; + if (!finalizeSessionOptimizationSynchronously(session, false, sessionValid)) { + if (alive && paneIntact()) { + session.suppressFiberSave = suppressBeforeFinalize; + } + return false; + } + if (!alive || (stillValid && !stillValid()) || !paneIntact()) { return false; } session.suppressFiberSave = suppressBeforeFinalize; @@ -2262,6 +2296,9 @@ bool LineAnnotationController::prepareForUserFacingLineAnnotationOpen() // acting destructively. const uint64_t saveFailuresBefore = _fiberSaveFailureCount; saveSessionAsFiber(session); + if (!alive || (stillValid && !stillValid()) || !paneIntact()) { + return false; + } if (_fiberSaveFailureCount != saveFailuresBefore) { session.suppressFiberSave = suppressBeforeFinalize; return false; @@ -2274,8 +2311,15 @@ bool LineAnnotationController::prepareForUserFacingLineAnnotationOpen() if (record.dialog) { record.dialog->setCloseAfterFinalizationAllowed(true); record.dialog->close(); + // The close runs the dialog's teardown slots synchronously. + if (!alive) { + return false; + } } cleanupSurfaceName(record.surfaceName); + if (!alive) { + return false; + } } return true; } @@ -2970,6 +3014,1076 @@ LineAnnotationDialog* LineAnnotationController::openFiberReadOnly(const nlohmann return dialog; } +const std::vector* LineAnnotationController::storedFiberLinePoints( + uint64_t fiberId, const std::string& fileName) const +{ + for (const StoredFiber& fiber : _fibers) { + if (fiber.id == fiberId && fiber.fileName == fileName) { + return &fiber.linePoints; + } + } + return nullptr; +} + +LineAnnotationController::FiberMapAutoLinkOutcome +LineAnnotationController::prepareFiberMapAutoLink(uint64_t fiberIdA, + const std::string& fileNameA, + uint64_t fiberIdB, + const std::string& fileNameB) +{ + FiberMapAutoLinkOutcome outcome; + const auto refuse = [&outcome](const QString& message) { + outcome.ok = false; + outcome.message = message; + return outcome; + }; + const QPointer alive(this); + const uint64_t packageGeneration = _packageGeneration; + const uint64_t loadSequence = _fiberLoadSequence; + const auto resolve = [this](uint64_t id, const std::string& fileName) -> const StoredFiber* { + for (const StoredFiber& fiber : _fibers) { + if (fiber.id == id && fiber.fileName == fileName) { + return &fiber; + } + } + return nullptr; + }; + if (fiberIdA == fiberIdB) { + return refuse(tr("Select two different fibers.")); + } + const StoredFiber* a = resolve(fiberIdA, fileNameA); + const StoredFiber* b = resolve(fiberIdB, fileNameB); + if (!a || !b) { + return refuse(tr("One of the fibers is no longer loaded; press Update.")); + } + if (a->readOnly || b->readOnly) { + return refuse(tr("%1 is read-only.").arg(fiberDisplayName(a->readOnly ? a->id : b->id))); + } + for (const auto& pane : _panes) { + if (pane.session && pane.session->taskState == LineAnnotationSession::TaskState::Running) { + return refuse(tr("Line optimization is already running.")); + } + } + const auto isParticipant = [fiberIdA, fiberIdB](const LineAnnotationSession& session) { + return session.fiberId == fiberIdA || session.fiberId == fiberIdB; + }; + + // The active volume is an input of every scale a finalization resolves + // (model-only sessions resolve theirs before the service start yields): + // a switch during preparation invalidates it. + const auto volume = _state ? _state->currentVolume() : nullptr; + const auto stillValid = [&alive, this, packageGeneration, loadSequence, volume]() { + return alive && _packageGeneration == packageGeneration && + _fiberLoadSequence == loadSequence && _state && _state->currentVolume() == volume; + }; + // D8: dialogs of either fiber are finalized, saved and closed exactly as + // opening another fiber does it (this finalizes EVERY dialog session). + if (!prepareForUserFacingLineAnnotationOpen(stillValid)) { + return refuse(tr("Could not finalize the open line annotation.")); + } + if (!alive) { + return refuse(tr("The annotation controller went away.")); + } + if (_packageGeneration != packageGeneration || _fiberLoadSequence != loadSequence) { + return refuse(tr("The fibers changed meanwhile.")); + } + + // The dialog-less copies the finalize walk never sees: every remaining + // pane of A or B, plus both sessions of the intersection inspection when + // it involves A or B (its teardown erases both). Each is finalized and + // saved BEFORE any pane is erased; a failure retains everything. + std::vector retire; + const auto addRetire = [&retire](const std::string& surfaceName) { + if (!surfaceName.empty() && + std::find(retire.begin(), retire.end(), surfaceName) == retire.end()) { + retire.push_back(surfaceName); + } + }; + for (const auto& pane : _panes) { + if (pane.session && isParticipant(*pane.session)) { + addRetire(pane.surfaceName); + } + } + if (_intersectionInspection) { + const bool involved = + (_intersectionInspection->sourceLineSession && + isParticipant(*_intersectionInspection->sourceLineSession)) || + (_intersectionInspection->targetLineSession && + isParticipant(*_intersectionInspection->targetLineSession)); + if (involved) { + addRetire(_intersectionInspection->sourceSessionSurfaceName); + addRetire(_intersectionInspection->targetSessionSurfaceName); + } + } + // Every retiring session is checked before any is touched: a refusal + // on the second must not leave the first saved-and-suppressed. + for (const std::string& surfaceName : retire) { + const auto* pane = paneForSurface(surfaceName); + if (!pane || !pane->session) { + continue; + } + const LineAnnotationSession& session = *pane->session; + if (session.taskState != LineAnnotationSession::TaskState::Succeeded || + session.optimizedLine.points.empty() || session.controlPoints.empty()) { + return refuse(tr("A session of %1 did not finish its last optimization; close it first.") + .arg(fiberDisplayNameFromFileName(session.fiberFileName))); + } + } + // Saved sessions are marked suppressFiberSave so their close below does + // not save again. A refusal anywhere before the closes are through + // restores every flag: a session left open with its saves suppressed + // would drop later edits (the completion path reads the flag as a + // shutdown) and be skipped by the shutdown save. + std::vector, bool>> suppressed; + bool retired = false; + const auto restoreSuppression = qScopeGuard([&suppressed, &retired]() { + if (retired) { + return; + } + for (auto& [session, before] : suppressed) { + session->suppressFiberSave = before; + } + }); + for (const std::string& surfaceName : retire) { + auto* pane = paneForSurface(surfaceName); + if (!pane || !pane->session) { + continue; + } + // Owned for the duration: the pane can be erased by what the yields + // run; after each one the pane must still exist with this session. + const std::shared_ptr session = pane->session; + const auto paneIntact = [this, &surfaceName, &session]() { + auto* current = paneForSurface(surfaceName); + return current && current->session == session; + }; + const QString name = fiberDisplayNameFromFileName(session->fiberFileName); + if (session->taskState != LineAnnotationSession::TaskState::Succeeded || + session->optimizedLine.points.empty() || session->controlPoints.empty()) { + // Moved on by an earlier session's finalization or save. + return refuse(tr("A session of %1 did not finish its last optimization; close it first.") + .arg(name)); + } + const auto sessionValid = [&stillValid, &paneIntact]() { + return stillValid() && paneIntact(); + }; + if (!finalizeSessionOptimizationSynchronously(*session, false, sessionValid)) { + return refuse(tr("Could not finalize a session of %1.").arg(name)); + } + if (!sessionValid()) { + return refuse(tr("The fibers changed meanwhile.")); + } + const uint64_t failuresBefore = _fiberSaveFailureCount; + saveSessionAsFiber(*session); + if (!sessionValid()) { + return refuse(tr("The fibers changed meanwhile.")); + } + if (_fiberSaveFailureCount != failuresBefore) { + return refuse(tr("Could not save a session of %1.").arg(name)); + } + // Saved: the pane's own close below must not save it again (that + // save could show a modal and yield once more). + suppressed.emplace_back(session, session->suppressFiberSave); + session->suppressFiberSave = true; + } + for (const std::string& surfaceName : retire) { + cleanupSurfaceName(surfaceName); + if (!stillValid()) { + return refuse(tr("The fibers changed meanwhile.")); + } + } + // Every retiring pane is closed: the flags of the (now unowned) + // sessions stay as the closes read them. + retired = true; + closeIntersectionInspectionForRetiredFibers({fiberIdA, fiberIdB}); + if (!alive) { + return refuse(tr("The annotation controller went away.")); + } + + std::string error; + if (!structuralEditPreflight(packageGeneration, loadSequence, &error)) { + return refuse(error.empty() ? tr("The fibers changed meanwhile.") + : QString::fromStdString(error)); + } + if (!alive) { + return refuse(tr("The annotation controller went away.")); + } + for (const auto& pane : _panes) { + if (pane.session && isParticipant(*pane.session)) { + return refuse(tr("A session of %1 is still open.") + .arg(fiberDisplayNameFromFileName(pane.session->fiberFileName))); + } + } + outcome.ok = true; + return outcome; +} + +std::optional LineAnnotationController::linkPairEligibilityError( + const StoredFiber& local, const StoredFiber& far) const +{ + const QString localName = fiberDisplayNameFromFileName(local.fileName); + const QString farName = fiberDisplayNameFromFileName(far.fileName); + if (local.id == far.id) { + return tr("Select two different fibers."); + } + if (local.readOnly || far.readOnly) { + return tr("%1 is read-only.").arg(local.readOnly ? localName : farName); + } + // The loader binds branch_file relative to the OWNER's source root + // (through the dedupe aliases): a pair that would not rebind on the next + // load is not written. + if (loadedFiberLinkKey(local, far.fileName) != + vc3d::fiberSourceFileKey(far.sourceRoot, far.fileName) || + loadedFiberLinkKey(far, local.fileName) != + vc3d::fiberSourceFileKey(local.sourceRoot, local.fileName)) { + return tr("%1 and %2 come from sources that cannot reference each other.") + .arg(localName, farName); + } + return std::nullopt; +} + +std::optional LineAnnotationController::linkStoredControlPoints(StoredFiber& local, + int localControlIndex, + StoredFiber& far, + int farControlIndex, + bool autoLink) +{ + namespace la = vc3d::line_annotation; + if (localControlIndex < 0 || + static_cast(localControlIndex) >= local.controlPoints.size() || + farControlIndex < 0 || static_cast(farControlIndex) >= far.controlPoints.size()) { + return tr("A control point index is out of range."); + } + if (const auto error = linkPairEligibilityError(local, far)) { + return error; + } + const QString localName = fiberDisplayNameFromFileName(local.fileName); + const QString farName = fiberDisplayNameFromFileName(far.fileName); + if (controlPointHasBranchLink(local.branches, static_cast(localControlIndex))) { + return tr("%1 / CP %2 is already linked.").arg(localName).arg(localControlIndex); + } + if (controlPointHasBranchLink(far.branches, static_cast(farControlIndex))) { + return tr("%1 / CP %2 is already linked.").arg(farName).arg(farControlIndex); + } + const std::vector localPositions = la::storedControlPointPositions(local.controlPoints); + const std::vector farPositions = la::storedControlPointPositions(far.controlPoints); + const cv::Vec3d localPoint = localPositions[static_cast(localControlIndex)]; + const cv::Vec3d farPoint = farPositions[static_cast(farControlIndex)]; + // The save canonicalizer and the loader identify a control by position + // (within 1e-6): an ambiguous position is never written. + const auto ambiguous = [](const std::vector& positions, size_t index) { + for (size_t i = 0; i < positions.size(); ++i) { + if (i != index && pointsApproximatelyEqual(positions[i], positions[index])) { + return true; + } + } + return false; + }; + if (ambiguous(localPositions, static_cast(localControlIndex))) { + return tr("%1 has two control points at one position.").arg(localName); + } + if (ambiguous(farPositions, static_cast(farControlIndex))) { + return tr("%1 has two control points at one position.").arg(farName); + } + if (!finitePoint(localPoint) || !finitePoint(farPoint)) { + return tr("Could not determine finite endpoints for the link."); + } + const cv::Vec3d fallbackDirection = normalizedOrZero(farPoint - localPoint); + const cv::Vec3d localDirection = + endpointTangentFromLinePoints(local.linePoints, localPoint, fallbackDirection); + const cv::Vec3d farDirection = + endpointTangentFromLinePoints(far.linePoints, farPoint, -fallbackDirection); + if (!finiteDirection(localDirection) || !finiteDirection(farDirection)) { + return tr("Could not determine finite link directions between the control points."); + } + const FiberBranchRef localRef = la::makeBranchRef( + localControlIndex, localPoint, localDirection, far.id, far.fileName, farControlIndex, + farPoint, farDirection, /*pending=*/true, /*adjacent=*/false, autoLink); + const FiberBranchRef reciprocal = la::reciprocalBranchRef(localRef, local.id, local.fileName); + local.branches.push_back(localRef); + far.branches.push_back(reciprocal); + try { + scheduleFiberPairSave(local, far); + } catch (const std::exception& ex) { + local.branches.pop_back(); + far.branches.pop_back(); + return tr("Could not save linked fibers: %1").arg(QString::fromStdString(ex.what())); + } + // The publication: summaries reach the dock, whose metrics can open a + // dataset picker (a yield that can reallocate _fibers or destroy the + // controller); from here nothing of `local`/`far` is touched, only + // values captured before. + const uint64_t localId = local.id; + const uint64_t farId = far.id; + const QPointer alive(this); + invalidateFiberAlignmentMetrics(localId, true); + invalidateFiberAlignmentMetrics(farId, true); + emitFiberSummaries(); + if (!alive) { + return std::nullopt; + } + refreshBranchLineViews(localId); + refreshBranchLineViews(farId); + return std::nullopt; +} + +LineAnnotationController::FiberMapAutoLinkOutcome +LineAnnotationController::autoLinkFibers(const FiberMapAutoLinkEndpoint& a, + const FiberMapAutoLinkEndpoint& b, + uint64_t mapFiberDataGeneration, + std::function mapStillFresh, + const FiberMapAutoLinkParams& params) +{ + namespace al = vc3d::fiber_map::autolink; + namespace la = vc3d::line_annotation; + FiberMapAutoLinkOutcome outcome; + const auto refuse = [&outcome](const QString& message) { + outcome.ok = false; + outcome.message = message; + return outcome; + }; + if (fiberDataGeneration() != mapFiberDataGeneration) { + return refuse(tr("The Fiber Map is stale; press Update.")); + } + if (a.fiberId == b.fiberId) { + return refuse(tr("Select two different fibers.")); + } + + // The guard: everything this operation's correctness rests on, checked + // after every yield. The expected fiber generation is re-captured after + // this operation's own saves. + const QPointer alive(this); + const uint64_t packageGeneration = _packageGeneration; + const uint64_t loadSequence = _fiberLoadSequence; + uint64_t expectedGeneration = fiberDataGeneration(); + uint64_t saveFailures = _fiberSaveFailureCount; + const uint64_t focusRevision = _state ? _state->focusBoundsRevision() : 0; + const uint64_t idA = a.fiberId; + const uint64_t idB = b.fiberId; + const auto volume = _state ? _state->currentVolume() : nullptr; + if (!volume) { + return refuse(tr("No active volume is loaded.")); + } + // A structural edit for its whole duration: the same guard a merge or + // split holds, so a rename, delete, import, merge or split cannot start + // from the dock inside one of the yields below (a rename would move a + // participant out from under the withdrawal's file-name identity). + if (_structuralEditInProgress) { + return refuse(tr("A merge, split or link is in progress; try again once it has finished.")); + } + if (_deletingFibers) { + return refuse(tr("A fiber delete is in progress; try again once it has finished.")); + } + _structuralEditInProgress = true; + const auto releaseStructuralEdit = qScopeGuard([this, &alive]() { + if (alive) { + _structuralEditInProgress = false; + } + }); + const auto guardCheck = [&](bool includeGeneration) -> bool { + if (!alive) { + return false; + } + if (_packageGeneration != packageGeneration || _fiberLoadSequence != loadSequence) { + return false; + } + // The active volume (its voxel size, its shape the scale was + // resolved against) is an input of every physical threshold and of + // the solve: a switch - even to another pyramid level with the same + // annotation frame - invalidates the operation. + if (!_state || _state->currentVolume() != volume) { + return false; + } + if (includeGeneration && fiberDataGeneration() != expectedGeneration) { + return false; + } + if (_fiberSaveFailureCount != saveFailures) { + return false; + } + if ((_state ? _state->focusBoundsRevision() : 0) != focusRevision) { + return false; + } + if (mapStillFresh && !mapStillFresh()) { + return false; + } + for (const auto& pane : _panes) { + if (!pane.session) { + continue; + } + if (pane.session->fiberId == idA || pane.session->fiberId == idB || + pane.session->taskState == LineAnnotationSession::TaskState::Running) { + return false; + } + } + return true; + }; + const QString changedMessage = tr("The fibers changed meanwhile; nothing was linked."); + // The drains wait on this: lifetime and identity only. A save failure + // (whichever job's) must not cut a drain short - the failure counter is + // read once the queue is empty, when it can be classified. + const auto identityOnly = [&]() -> bool { + return alive && _packageGeneration == packageGeneration && + _fiberLoadSequence == loadSequence && _state && _state->currentVolume() == volume; + }; + if (!guardCheck(true)) { + return refuse(changedMessage); + } + const auto resolve = [this](uint64_t id, const std::string& fileName) -> StoredFiber* { + for (StoredFiber& fiber : _fibers) { + if (fiber.id == id && fiber.fileName == fileName) { + return &fiber; + } + } + return nullptr; + }; + + // Per-fiber working state. + struct Side { + const FiberMapAutoLinkEndpoint* endpoint = nullptr; + StoredFiber* fiber = nullptr; + QString name; + double scale = 1.0; + double baseVoxelSizeUm = 0.0; + double windowVx = 0.0; + double reuseVx = 0.0; + std::vector cumulative; + std::vector orderedIndex; + al::ControlChoice choice; + std::shared_ptr session; + int linkedControlIndex = -1; + bool placed = false; + }; + std::array sides; + sides[0].endpoint = &a; + sides[1].endpoint = &b; + const auto reresolve = [&]() -> bool { + for (Side& side : sides) { + side.fiber = resolve(side.endpoint->fiberId, side.endpoint->fileName); + if (!side.fiber) { + return false; + } + } + return true; + }; + // Every check after a yield also re-resolves the stored fibers: _fibers + // can be reallocated without a generation bump (a read-only open), so a + // pointer taken before a yield is never used after it. + const auto checkAndResolve = [&](bool includeGeneration) -> bool { + return guardCheck(includeGeneration) && reresolve(); + }; + // After the first fiber's new control was saved, a refusal must say that + // this control stays (a plain, unlinked control point). + const auto refuseKeeping = [&](const QString& message) { + QString kept; + for (const Side& side : sides) { + if (side.placed) { + kept += tr(" The new control point placed on %1 was kept (unlinked).") + .arg(side.name); + } + } + return refuse(message + kept); + }; + if (!reresolve()) { + return refuse(tr("One of the fibers is no longer loaded; press Update.")); + } + // Pair eligibility BEFORE anything is mutated: a pair that could never + // be linked (sources that would not rebind) refuses with nothing changed. + if (const auto error = linkPairEligibilityError(*sides[0].fiber, *sides[1].fiber)) { + return refuse(*error); + } + for (Side& side : sides) { + side.name = fiberDisplayNameFromFileName(side.fiber->fileName); + if (side.fiber->readOnly) { + return refuse(tr("%1 is read-only.").arg(side.name)); + } + try { + side.scale = la::resolveFiberBaseToVolumeScale( + resolveStoredFiberCoordinateBaseShape(*side.fiber), volume->shape()); + } catch (const std::exception& ex) { + return refuse(tr("Could not map fiber %1 into the active volume: %2") + .arg(side.name, QString::fromStdString(ex.what()))); + } + side.baseVoxelSizeUm = volume->voxelSize() * side.scale; + if (!(side.baseVoxelSizeUm > 0.0) || !std::isfinite(side.baseVoxelSizeUm)) { + return refuse(tr("The active volume has no usable voxel size.")); + } + side.windowVx = params.searchWindowUm / side.baseVoxelSizeUm; + side.reuseVx = params.reuseUm / side.baseVoxelSizeUm; + } + + // Step 1: choices first (a reuse-only link needs no backend). + const auto planChoices = [&]() -> std::optional { + for (Side& side : sides) { + const StoredFiber& fiber = *side.fiber; + if (fiber.linePoints.size() < 2) { + return tr("%1 has no line to link on.").arg(side.name); + } + const std::vector controls = la::storedControlPointPositions(fiber.controlPoints); + const auto ordered = la::orderedControlPointLineIndices(controls, fiber.linePoints); + if (!ordered || ordered->size() != controls.size()) { + return tr("The control points of %1 do not resolve in line order.").arg(side.name); + } + // The shared placement machinery (splice, span optimizer) resolves + // controls by nearest vertex; it is only correct where that agrees + // with the loader's ordered scan. + for (size_t c = 0; c < controls.size(); ++c) { + if (vc3d::fiber_slice::nearestLinePointIndex(fiber.linePoints, controls[c]) != + (*ordered)[c]) { + return tr("%1 passes through one of its control points twice.").arg(side.name); + } + } + side.orderedIndex = *ordered; + side.cumulative = vc3d::fiber_slice::cumulativePolylineArclengths(fiber.linePoints); + } + al::FiberSide sideA{&sides[0].fiber->linePoints, &sides[0].cumulative, + sides[0].endpoint->linePosition, sides[0].windowVx}; + al::FiberSide sideB{&sides[1].fiber->linePoints, &sides[1].cumulative, + sides[1].endpoint->linePosition, sides[1].windowVx}; + const auto pass = al::findClosestPass(sideA, sideB); + if (!pass) { + return tr("Could not find where the fibers pass each other."); + } + const auto focusBounds = _state ? _state->activeFocusBounds() : std::nullopt; + for (size_t s = 0; s < 2; ++s) { + Side& side = sides[s]; + const StoredFiber& fiber = *side.fiber; + al::ControlChoiceInput input; + input.linePoints = &fiber.linePoints; + input.cumulative = &side.cumulative; + input.controlLineIndex = side.orderedIndex; + input.linked.resize(fiber.controlPoints.size(), false); + input.kollesisTerminations.resize(fiber.controlPoints.size(), false); + for (size_t c = 0; c < fiber.controlPoints.size(); ++c) { + input.linked[c] = controlPointHasBranchLink(fiber.branches, c); + input.kollesisTerminations[c] = la::hasControlPointTag( + fiber.controlPoints[c].tags, la::kKollesisTerminationTag); + } + // Gap spans in stored order: the span descriptor of the lower + // control carries the tag. + for (size_t c = 0; c + 1 < fiber.controlPoints.size(); ++c) { + if (la::spanIsGap(fiber.controlPoints[c].segmentToNext)) { + input.gapSpans.emplace_back(c, c + 1); + } + } + if (focusBounds) { + input.inFocusBounds = [bounds = *focusBounds](const cv::Vec3d& p) { + return contains_point(bounds, p); + }; + input.focusBox = std::make_pair( + cv::Vec3d(focusBounds->low[0], focusBounds->low[1], focusBounds->low[2]), + cv::Vec3d(focusBounds->high[0], focusBounds->high[1], focusBounds->high[2])); + } + input.passPosition = s == 0 ? pass->positionA : pass->positionB; + input.windowLo = s == 0 ? pass->windowLoA : pass->windowLoB; + input.windowHi = s == 0 ? pass->windowHiA : pass->windowHiB; + input.reuseVx = side.reuseVx; + input.minSpacingVx = kMinimumControlPointSpacingBaseVoxels; + side.choice = al::chooseControlPoint(input); + if (side.choice.kind == al::ControlChoice::Kind::Refused) { + return tr("No place for a control point on %1: %2") + .arg(side.name, QString::fromStdString(side.choice.reason)); + } + } + // Two reused controls that are already linked to each other: refuse + // now, before any mutation. + if (sides[0].choice.kind == al::ControlChoice::Kind::Reuse && + sides[1].choice.kind == al::ControlChoice::Kind::Reuse) { + for (const FiberBranchRef& branch : sides[0].fiber->branches) { + if (branch.branchFiberId == sides[1].fiber->id && + branch.controlPointIndex == sides[0].choice.controlIndex && + branch.branchControlPointIndex == sides[1].choice.controlIndex) { + return tr("%1 / CP %2 and %3 / CP %4 are already linked.") + .arg(sides[0].name).arg(sides[0].choice.controlIndex) + .arg(sides[1].name).arg(sides[1].choice.controlIndex); + } + } + } + outcome.message = tr("3D gap %1 vx").arg(pass->distance, 0, 'f', 1); + return std::nullopt; + }; + if (const auto error = planChoices()) { + return refuse(*error); + } + const QString gapText = outcome.message; + + // Step 2: backends, only for fibers that get a new control; sessions are + // built once and kept (the guard proves them fresh after the yields). + for (Side& side : sides) { + if (side.choice.kind != al::ControlChoice::Kind::Place) { + continue; + } + side.session = makeIntersectionLineSession( + *side.fiber, side.choice.linePosition, cv::Vec3d{0.0, 0.0, 1.0}, + "fiber_auto_link_" + side.fiber->fileName, nullptr); + side.session->fiberBaseToVolumeScale = side.scale; + side.session->suppressErrorDialogs = false; + if (!ensureSessionSolveBackends(*side.session, [&]() { return checkAndResolve(true); })) { + return refuse(tr("Could not prepare the solve for %1.").arg(side.name)); + } + if (!checkAndResolve(true)) { + return refuse(changedMessage); + } + } + // Step 3: the real line model (sampled normals) before any edit. The + // samplers throw on unusable normals; a throw is a refusal, not a crash. + for (Side& side : sides) { + if (!side.session) { + continue; + } + // An owned copy: the helper can yield (fiber-follow start) and a + // reference into _fibers would not survive a reallocation. + const std::vector linePointsCopy = side.fiber->linePoints; + try { + if (!prepareSessionLineModel(*side.session, linePointsCopy, + [&]() { return checkAndResolve(true); })) { + return refuse(tr("Could not prepare the line model of %1.").arg(side.name)); + } + } catch (const std::exception& ex) { + return refuse(tr("Could not prepare the line model of %1: %2") + .arg(side.name, QString::fromStdString(ex.what()))); + } + if (!checkAndResolve(true)) { + return refuse(changedMessage); + } + } + + // Step 4: placement, A then B. + for (Side& side : sides) { + if (side.choice.kind == al::ControlChoice::Kind::Reuse) { + side.linkedControlIndex = side.choice.controlIndex; + continue; + } + LineAnnotationSession& session = *side.session; + const size_t countBefore = session.controlPoints.size(); + std::vector currentLinePoints; + currentLinePoints.reserve(session.optimizedLine.points.size()); + for (const auto& point : session.optimizedLine.points) { + currentLinePoints.push_back(point.position); + } + const auto cumulativeArclengths = + vc3d::fiber_slice::cumulativePolylineArclengths(currentLinePoints); + std::vector controlLinePositions; + controlLinePositions.reserve(session.controlPoints.size()); + for (const auto& control : session.controlPoints) { + controlLinePositions.push_back(control.linePosition); + } + la::PreparedControlPointEdit prepared; + try { + GeometricControlPointEdit edit; + edit.linePosition = side.choice.linePosition; + edit.point = side.choice.point; + edit.segmentLength = initialLineDiscretization( + std::max(2, vc3d::settings::line_annotation::EXTRAPOLATION_DISTANCE_VX_DEFAULT * 2)) + .segmentLength; + prepared = applyGeometricControlPointEdit(session, currentLinePoints, + cumulativeArclengths, + controlLinePositions, edit); + } catch (const std::exception& ex) { + return refuseKeeping(tr("Could not place a control point on %1: %2") + .arg(side.name, QString::fromStdString(ex.what()))); + } + // Pre-solve identity validation on the spliced session: nothing has + // been published yet, so a violation simply discards the session. + const auto validateControls = + [&](const std::vector& controls, + const std::vector& line, + bool checkOptimizedIndex) -> std::optional { + if (controls.size() != countBefore + 1) { + return tr("placing the control point changed the control count of %1") + .arg(side.name); + } + std::vector positions; + positions.reserve(controls.size()); + for (const auto& control : controls) { + positions.push_back(control.volumePoint); + } + const auto ordered = la::orderedControlPointLineIndices(positions, line); + if (!ordered || ordered->size() != controls.size()) { + return tr("the control points of %1 no longer resolve in line order") + .arg(side.name); + } + for (size_t i = 0; i + 1 < ordered->size(); ++i) { + if ((*ordered)[i] >= (*ordered)[i + 1]) { + return tr("the control points of %1 no longer resolve in line order") + .arg(side.name); + } + } + // The nearest-vertex rule every consumer of this geometry uses + // (the splice, the span optimizer, the next load's session) must + // agree with the ordered scan for every control, on the spliced + // line AND on the solved line. + for (size_t i = 0; i < controls.size(); ++i) { + if (vc3d::fiber_slice::nearestLinePointIndex(line, positions[i]) != (*ordered)[i]) { + return tr("%1 passes through one of its control points twice") + .arg(side.name); + } + } + if (checkOptimizedIndex) { + for (size_t i = 0; i < controls.size(); ++i) { + if (controls[i].optimizedIndex < 0 || + static_cast(controls[i].optimizedIndex) != (*ordered)[i]) { + return tr("%1 passes through one of its control points twice") + .arg(side.name); + } + } + } + size_t placedCount = 0; + for (size_t i = 0; i < positions.size(); ++i) { + if (pointsApproximatelyEqual(positions[i], side.choice.point)) { + ++placedCount; + } + } + if (placedCount != 1) { + return tr("the new control point of %1 is not unique").arg(side.name); + } + return std::nullopt; + }; + { + std::vector splicedLine; + splicedLine.reserve(session.optimizedLine.points.size()); + for (const auto& point : session.optimizedLine.points) { + splicedLine.push_back(point.position); + } + if (const auto error = validateControls(session.controlPoints, splicedLine, true)) { + return refuseKeeping(tr("Could not place a control point: %1.").arg(*error)); + } + if (prepared.replacementIndex >= session.controlPoints.size() || + !pointsApproximatelyEqual(session.controlPoints[prepared.replacementIndex].volumePoint, + side.choice.point)) { + return refuseKeeping(tr("Could not place a control point on %1.").arg(side.name)); + } + } + // Backends again (a fiber-follow service can have stopped during the + // other fiber's save drain), then the solve, result only. + if (!ensureSessionSolveBackends(session, [&]() { return checkAndResolve(true); })) { + return refuseKeeping(tr("Could not prepare the solve for %1.").arg(side.name)); + } + if (!checkAndResolve(true)) { + return refuseKeeping(changedMessage); + } + OptimizationTaskResult task = runFiberModeSolveSynchronously( + session, prepared.dirtySegmentIndices, "fiber_auto_link_span_opt"); + if (!task.ok) { + return refuseKeeping(tr("Re-optimization of %1 failed: %2") + .arg(side.name, QString::fromStdString(task.error))); + } + { + std::vector solvedLine; + solvedLine.reserve(task.result.line.points.size()); + for (const auto& point : task.result.line.points) { + solvedLine.push_back(point.position); + } + // The solver's own indices must agree with the ordered scan and + // the nearest-vertex rule as well: applyOptimizationTaskResult + // trusts them. + if (const auto error = validateControls(task.controlPoints, solvedLine, true)) { + return refuseKeeping(tr("Re-optimization of %1 was rejected: %2") + .arg(side.name, *error)); + } + } + // Committed from here: the ordinary landing publishes the geometry + // and syncs linked peers. + if (!applyOptimizationTaskResult(session, std::move(task), false, + SessionOptimizationState::Optimized, + "fiber_auto_link_span_opt", false)) { + return refuseKeeping(tr("Could not apply the re-optimization of %1.").arg(side.name)); + } + if (!alive) { + return refuse(changedMessage); + } + const auto snapshot = makeStoredFiberSessionSnapshot(session); + if (prepared.replacementIndex >= snapshot.storedIndexForSessionIndex.size()) { + return refuseKeeping(tr("Could not place a control point on %1.").arg(side.name)); + } + const int storedIndex = snapshot.storedIndexForSessionIndex[prepared.replacementIndex]; + const uint64_t failuresBefore = _fiberSaveFailureCount; + // The other participant, as it is now: the save's notifications can + // yield, and an edit landing on it meanwhile would be overwritten + // by its own detached session later. Compared after the save; a + // change is a refusal (the generation alone cannot tell this + // operation's own bumps from an edit). + const Side& other = &side == &sides[0] ? sides[1] : sides[0]; + nlohmann::json otherBefore = fiberToJson(*other.fiber); + otherBefore.erase("generation"); + saveSessionAsFiber(session); + // The save's failure paths are modal: lifetime first, then the + // save's own outcome (reported as such, before any identity verdict + // could hide it), then identity and the stored fiber. + if (!alive) { + return refuse(changedMessage); + } + // The in-memory commit, read back before any verdict: the save + // updates the fiber list synchronously and then publishes summaries, + // which can yield (the metrics dataset picker), so a refusal from + // here on - a write failure delivered meanwhile, an edit of the other + // fiber - must already say that this control is retained. + // This side alone is resolved for the read-back: the other + // participant may be gone by now (a queued delete during the yield), + // which is a refusal of its own below, not a reason to forget that + // this control was committed. + if (StoredFiber* own = resolve(side.endpoint->fiberId, side.endpoint->fileName)) { + side.fiber = own; + if (storedIndex >= 0 && + static_cast(storedIndex) < own->controlPoints.size() && + pointsApproximatelyEqual(own->controlPoints[static_cast(storedIndex)], + side.choice.point)) { + side.placed = true; + } + } + const auto writeFailedMessage = [&side]() { + return tr("The new control point of %1 could not be written to disk; " + "the loaded fibers may differ from the files - reload the " + "project before further edits.") + .arg(side.name); + }; + if (_fiberSaveFailureCount != failuresBefore) { + // Placed in memory but not on disk is the reload case; a save + // that failed before updating the fiber list changed nothing. + return refuseKeeping(side.placed ? writeFailedMessage() + : tr("Could not save %1.").arg(side.name)); + } + if (!checkAndResolve(false)) { + return refuseKeeping(changedMessage); + } + { + nlohmann::json otherAfter = fiberToJson(*other.fiber); + otherAfter.erase("generation"); + if (otherAfter != otherBefore) { + return refuseKeeping(tr("%1 changed while %2 was being saved; nothing more was done.") + .arg(other.name, side.name)); + } + } + expectedGeneration = fiberDataGeneration(); + if (!side.placed) { + return refuseKeeping(tr("The new control point of %1 was not found after saving.") + .arg(side.name)); + } + // The drain: a save's completion bumps nothing, so the generation + // captured right after this operation's own mutation must hold + // throughout (any edit meanwhile is a refusal). A failed write is + // reported before the identity verdict. + waitForFiberSaves(identityOnly); + if (!alive) { + return refuse(changedMessage); + } + if (_fiberSaveFailureCount != failuresBefore) { + return refuseKeeping(writeFailedMessage()); + } + if (!checkAndResolve(true)) { + return refuseKeeping(changedMessage); + } + side.linkedControlIndex = storedIndex; + } + + // Step 5: the link, on the stored fibers, and its pair save drained + // before success is reported. + // The pair save's OWN result: a successful job emits fiberSaved for + // both fibers; an unrelated job's failure in the same drain must not + // be read as this pair's. + const uint64_t failuresBeforeLink = _fiberSaveFailureCount; + std::set savedIds; + const QMetaObject::Connection savedWatch = connect( + this, &LineAnnotationController::fiberSaved, this, + [&savedIds](uint64_t fiberId, uint64_t) { savedIds.insert(fiberId); }); + const auto unwatch = qScopeGuard([this, &savedWatch]() { disconnect(savedWatch); }); + // Everything the aftermath needs, by value, BEFORE the publication + // (its summaries can open a picker: _fibers may be reallocated, the + // controller destroyed). + const uint64_t fiberIdA = sides[0].fiber->id; + const uint64_t fiberIdB = sides[1].fiber->id; + const int cpA = sides[0].linkedControlIndex; + const int cpB = sides[1].linkedControlIndex; + const cv::Vec3d pointA = la::storedControlPointPositions(sides[0].fiber->controlPoints) + [static_cast(cpA)]; + const cv::Vec3d pointB = la::storedControlPointPositions(sides[1].fiber->controlPoints) + [static_cast(cpB)]; + const std::string fileA = sides[0].fiber->fileName; + const std::string fileB = sides[1].fiber->fileName; + // The pair by endpoint identity (fiber identity + position, the link + // format's own key), so a copy in a session opened meanwhile is found + // whatever its indices are. The link is TRACKED from before its install + // (the install's publication already yields) through the drain: an + // edit landing meanwhile (a control nudged in an open session, synced + // onto the peer's ref) moves the endpoints of some copies and not of + // others (the stored record awaiting that session's save), and + // syncBranchEndpointPositions appends every new position an endpoint + // takes, so each copy matches at whichever position it holds. Exact + // positions only: nothing else of either fiber is ever matched (two + // ordinary links between the same two controls cannot coexist). The + // review state is NOT part of the identity: an approval or a pending + // mark set in a session opened during the drain changes the refs, not + // the link, and a withdrawal must still find them. + TrackedLinkEndpoints tracked; + tracked.fiberIdA = fiberIdA; + tracked.fileA = fileA; + tracked.fiberIdB = fiberIdB; + tracked.fileB = fileB; + tracked.pointsA = {pointA}; + tracked.pointsB = {pointB}; + _trackedLinks.push_back(&tracked); + const auto untrack = qScopeGuard([this, &alive, &tracked]() { + if (alive) { + std::erase(_trackedLinks, &tracked); + } + }); + if (const auto error = linkStoredControlPoints(*sides[0].fiber, sides[0].linkedControlIndex, + *sides[1].fiber, sides[1].linkedControlIndex, + /*autoLink=*/true)) { + return refuseKeeping(*error); + } + if (!alive) { + return refuse(changedMessage); + } + sides[0].fiber = nullptr; // stale until re-resolved + sides[1].fiber = nullptr; + expectedGeneration = fiberDataGeneration(); + const auto isThisLink = [&](const FiberBranchRef& branch, bool fromA) { + const uint64_t farId = fromA ? fiberIdB : fiberIdA; + const std::string& farFile = fromA ? fileB : fileA; + const bool hereHeld = fromA ? tracked.heldA(branch.controlPointPosition) + : tracked.heldB(branch.controlPointPosition); + const bool thereHeld = fromA ? tracked.heldB(branch.branchControlPointPosition) + : tracked.heldA(branch.branchControlPointPosition); + return !branch.adjacent && + la::sameFiberIdentity(branch.branchFiberId, branch.branchFileName, farId, farFile) && + hereHeld && thereHeld; + }; + const auto removeInstalledLink = [&]() { + // Only under the identity this operation started with: another + // package or a reloaded fiber list is not this operation's to edit. + if (!alive || _packageGeneration != packageGeneration || + _fiberLoadSequence != loadSequence) { + return; + } + for (StoredFiber& fiber : _fibers) { + if (fiber.id == fiberIdA && fiber.fileName == fileA) { + std::erase_if(fiber.branches, [&](const FiberBranchRef& b) { return isThisLink(b, true); }); + } else if (fiber.id == fiberIdB && fiber.fileName == fileB) { + std::erase_if(fiber.branches, [&](const FiberBranchRef& b) { return isThisLink(b, false); }); + } + } + // A save queued meanwhile (a peer sync of either fiber) carries a + // snapshot with the refs: it must not write the withdrawn link back. + for (FiberSaveJob& job : _pendingFiberSaveJobs) { + for (FiberSaveSnapshot& snapshot : job.snapshots) { + if (snapshot.fiber.id == fiberIdA && snapshot.fiber.fileName == fileA) { + std::erase_if(snapshot.fiber.branches, + [&](const FiberBranchRef& b) { return isThisLink(b, true); }); + } else if (snapshot.fiber.id == fiberIdB && snapshot.fiber.fileName == fileB) { + std::erase_if(snapshot.fiber.branches, + [&](const FiberBranchRef& b) { return isThisLink(b, false); }); + } + } + } + // A session of either fiber opened during the drain copied the refs: + // it must not save the withdrawn link back - neither from its live + // branches nor from an armed rollback copy (a goal-change solve's + // branchesBeforeModeChange, a collapse rollback) that a later + // failure restores wholesale. + for (auto& pane : _panes) { + if (!pane.session) { + continue; + } + auto& session = *pane.session; + std::optional localIsA; + if (la::sameFiberIdentity(session.fiberId, session.fiberFileName, fiberIdA, fileA)) { + localIsA = true; + } else if (la::sameFiberIdentity(session.fiberId, session.fiberFileName, fiberIdB, + fileB)) { + localIsA = false; + } + if (!localIsA) { + continue; + } + const auto scrub = [&](std::vector& branches) { + std::erase_if(branches, + [&](const FiberBranchRef& b) { return isThisLink(b, *localIsA); }); + }; + scrub(session.branches); + if (session.branchesBeforeModeChange) { + scrub(*session.branchesBeforeModeChange); + } + if (session.controlPointCollapseRollback) { + scrub(session.controlPointCollapseRollback->branches); + } + } + invalidateFiberAlignmentMetrics(fiberIdA, true); + invalidateFiberAlignmentMetrics(fiberIdB, true); + emitFiberSummaries(); + // The summaries can yield (the dock's metrics picker). + if (!alive) { + return; + } + refreshBranchLineViews(fiberIdA); + refreshBranchLineViews(fiberIdB); + }; + waitForFiberSaves(identityOnly); + if (!alive) { + return refuse(changedMessage); + } + const bool pairWritten = savedIds.count(fiberIdA) != 0 && savedIds.count(fiberIdB) != 0; + if (!pairWritten) { + removeInstalledLink(); + // A save of either fiber that ran meanwhile (a successor job started + // before this drain resumed) may have written the link one-sided: + // the withdrawn state is written back and drained to completion, + // and the correction is reported as done only when ITS job wrote + // both files (its own fiberSaved emissions), whatever the earlier + // failure counted. + QString tail; + if (!alive) { + return refuse(changedMessage); + } + if (identityOnly() && reresolve()) { + std::set correctedIds; + const QMetaObject::Connection correctionWatch = connect( + this, &LineAnnotationController::fiberSaved, this, + [&correctedIds](uint64_t fiberId, uint64_t) { correctedIds.insert(fiberId); }); + const auto unwatchCorrection = + qScopeGuard([this, &correctionWatch]() { disconnect(correctionWatch); }); + try { + scheduleFiberPairSave(*sides[0].fiber, *sides[1].fiber); + waitForFiberSaves(identityOnly); + if (alive) { + const bool corrected = + correctedIds.count(fiberIdA) != 0 && correctedIds.count(fiberIdB) != 0; + tail = corrected ? tr(" The fibers were re-saved without it.") + : tr(" The fibers could not be re-saved without it."); + } + } catch (const std::exception& ex) { + tail = tr(" The fibers could not be re-saved (%1).") + .arg(QString::fromStdString(ex.what())); + } + } + if (!alive) { + return refuse(changedMessage); + } + return refuseKeeping(tr("The link could not be written to disk and was withdrawn; the " + "loaded fibers may differ from the files - reload the project " + "before further edits.") + tail); + } + // From here the link is committed (both files written): whatever the + // identity checks say now, the outcome reports the link as made. An + // unrelated save that failed in the same drain is reported as such. + QString committedNote; + if (_fiberSaveFailureCount != failuresBeforeLink) { + committedNote = tr(" Another fiber's save failed meanwhile; the loaded fibers may differ " + "from the files - reload the project before further edits."); + } else if (!checkAndResolve(true)) { + committedNote = tr(" The fibers or the view changed meanwhile; press Update."); + } + outcome.ok = true; + outcome.message = tr("Linked %1 / CP %2 and %3 / CP %4 (%5; %6 on %1, %7 on %3)") + .arg(sides[0].name) + .arg(sides[0].linkedControlIndex) + .arg(sides[1].name) + .arg(sides[1].linkedControlIndex) + .arg(gapText, + sides[0].placed ? tr("new control point") : tr("existing control point"), + sides[1].placed ? tr("new control point") : tr("existing control point")) + + committedNote; + return outcome; +} + void LineAnnotationController::openFiberAtControlPoint(uint64_t fiberId, int controlPointIndex) { openFiberWithControlPoint(fiberId, controlPointIndex, std::nullopt); @@ -3294,7 +4408,7 @@ vc3d::line_annotation::FiberDeleteOutcome LineAnnotationController::deleteFibers return outcome; } if (_structuralEditInProgress) { - outcome.error = tr("A merge or split is in progress; delete once it has finished.") + outcome.error = tr("A merge, split or link is in progress; delete once it has finished.") .toStdString(); showError(QString::fromStdString(outcome.error), true); return outcome; @@ -3447,7 +4561,7 @@ void LineAnnotationController::renameFiberFile(uint64_t fiberId) return; } if (_structuralEditInProgress) { - showError(tr("A merge or split is in progress; rename the file once it has finished.")); + showError(tr("A merge, split or link is in progress; rename the file once it has finished.")); return; } @@ -3644,7 +4758,7 @@ bool LineAnnotationController::importFibersFromPath(const fs::path& importPath, } if (_structuralEditInProgress) { if (errorMessage) { - *errorMessage = tr("A merge or split is in progress; import once it has finished."); + *errorMessage = tr("A merge, split or link is in progress; import once it has finished."); } return false; } @@ -4692,23 +5806,48 @@ void LineAnnotationController::cleanupIntersectionInspectionSurfaces() if (_fiberSliceOverlay) { _fiberSliceOverlay->clearSlice(); } - if (auto* area = _intersectionInspection->targetArea.data()) { + // Surface notifications and window closes run arbitrary slots: the + // controller and the inspection are re-checked after each, the area is + // reached through its QPointer and the names are copied first. + const QPointer alive(this); + const QPointer area = _intersectionInspection->targetArea; + if (area) { + std::vector oldSurfaces; for (QMdiSubWindow* subWindow : area->subWindowList()) { if (!subWindow) { continue; } const QString oldSurface = subWindow->property("vc_intersection_slice_surface").toString(); - if (!oldSurface.isEmpty() && _state) { - _state->setSurface(oldSurface.toStdString(), nullptr); + if (!oldSurface.isEmpty()) { + oldSurfaces.push_back(oldSurface.toStdString()); } } - area->closeAllSubWindows(); + for (const std::string& oldSurface : oldSurfaces) { + if (!alive || !_state) { + return; + } + _state->setSurface(oldSurface, nullptr); + } + if (!alive || !area) { + return; + } + area->closeAllSubWindows(); + if (!alive || !_intersectionInspection) { + return; + } } if (_state) { - for (const auto& name : _intersectionInspection->surfaceNames) { + const std::vector names = _intersectionInspection->surfaceNames; + for (const auto& name : names) { + if (!alive || !_state || !_intersectionInspection) { + return; + } _state->setSurface(name, nullptr); } } + if (!alive || !_intersectionInspection) { + return; + } const std::string sourceSession = _intersectionInspection->sourceSessionSurfaceName; const std::string targetSession = _intersectionInspection->targetSessionSurfaceName; _panes.erase(std::remove_if(_panes.begin(), @@ -5084,14 +6223,8 @@ bool LineAnnotationController::rebuildIntersectionInspection(QString* errorMessa "Intersection side strips require a selected Lasagna dataset; " "not showing a synthetic strip"); } - if (side.editSession->modelOnly) { - if (!ensureFiberFollowService(side.editSession->suppressErrorDialogs)) - throw std::runtime_error("Could not start the fiber-follow model for the intersection view."); - side.editSession->optimizedLine = FiberFollowService::instance().lineModel( - side.fiber->linePoints, _state->currentVolume()->voxelSize() * side.editSession->fiberBaseToVolumeScale); - } else { - side.editSession->optimizedLine = lineModelFromPoints( - side.fiber->linePoints, side.editSession->normalSampler.get()); + if (!prepareSessionLineModel(*side.editSession, side.fiber->linePoints)) { + throw std::runtime_error("Could not start the fiber-follow model for the intersection view."); } vc::lasagna::LineViewConfig sideConfig; sideConfig.controlPointLinePositions.reserve( @@ -7242,7 +8375,8 @@ LineAnnotationController::FiberMapSnapshot LineAnnotationController::fiberMapSna branch.branchControlPointIndex, branch.pending, branch.adjacent, - true}); + true, + branch.autoLink}); } snapshot.fibers.push_back(std::move(entry)); } @@ -7517,8 +8651,9 @@ LineAnnotationController::fiberLinkOverlayInfos() const for (size_t i = 0; i < _fibers.size(); ++i) { const StoredFiber& fiber = _fibers[i]; // Pending wins per control point, matching the annotation GUI's - // marker precedence. - std::map pendingByControlPoint; + // marker precedence; the automatic sub-state follows the shared + // autoPendingMarker rule (every pending link on the point automatic). + std::map> linksByControlPoint; for (const auto& branch : fiber.branches) { if (indexById.find(branch.branchFiberId) == indexById.end()) { continue; @@ -7528,20 +8663,53 @@ LineAnnotationController::fiberLinkOverlayInfos() const static_cast(fiber.controlPoints.size())) { continue; } - auto [it, inserted] = - pendingByControlPoint.emplace(branch.controlPointIndex, branch.pending); - if (!inserted) { - it->second = it->second || branch.pending; - } + linksByControlPoint[branch.controlPointIndex].push_back(&branch); } - if (pendingByControlPoint.empty()) { + if (linksByControlPoint.empty()) { continue; } FiberLinkOverlayInfo info; info.fiberId = fiber.id; info.linkGroupId = minIdByRoot.at(findRoot(i)); - info.linkedControlPoints.assign(pendingByControlPoint.begin(), - pendingByControlPoint.end()); + const QString localHvTag = fiberHvDirectionTag(fiber.id); + for (const auto& [controlIndex, links] : linksByControlPoint) { + struct Deref { + bool pending; + bool autoLink; + }; + std::vector states; + states.reserve(links.size()); + LinkedControlMark mark; + mark.controlIndex = controlIndex; + bool anyPending = false; + bool sameHvPending = false; + bool sameHvApproved = false; + for (const FiberBranchRef* link : links) { + states.push_back({link->pending, link->autoLink}); + anyPending = anyPending || link->pending; + mark.adjacent = mark.adjacent || link->adjacent; + // The same-orientation warning, as the generated views + // decide it (controlMarkersForSession): both tags known + // and equal. + if (!localHvTag.isEmpty() && + fiberHvDirectionTag(link->branchFiberId) == localHvTag) { + (link->pending ? sameHvPending : sameHvApproved) = true; + } + } + mark.state = LinkMarkState::Approved; + if (anyPending) { + mark.state = vc3d::line_annotation::autoPendingMarker(states) + ? LinkMarkState::AutoPending + : LinkMarkState::Pending; + } + mark.sameHv = anyPending ? sameHvPending : sameHvApproved; + const auto& tags = fiber.controlPoints[static_cast(controlIndex)].tags; + mark.kollesisTermination = vc3d::line_annotation::hasControlPointTag( + tags, vc3d::line_annotation::kKollesisTerminationTag); + mark.breakTag = !mark.kollesisTermination && + vc3d::line_annotation::hasControlPointTag(tags, vc3d::line_annotation::kBreakTag); + info.linkedControlPoints.push_back(mark); + } infos.push_back(std::move(info)); } return infos; @@ -7922,6 +9090,85 @@ void LineAnnotationController::handleLineSeed(const std::string& surfaceName, } } +vc3d::line_annotation::PreparedControlPointEdit +LineAnnotationController::applyGeometricControlPointEdit( + LineAnnotationSession& session, + const std::vector& currentLinePoints, + const std::vector& cumulativeArclengths, + const std::vector& controlLinePositions, + GeometricControlPointEdit edit) +{ + // Display-normal inheritance: a new control inside a region of corrected + // normals takes the interpolated normal, with its provenance recorded. + // Captured before inserting a zero-offset CP or splicing the line, on + // the current session geometry (not a display cache from before recent + // edits). + if (!edit.displayNormal && edit.nearbyControlIndices.empty() && + std::any_of(session.controlPoints.begin(), session.controlPoints.end(), + [](const auto& cp) { return cp.displayNormal.has_value(); })) { + const auto field = displayFieldForSession(session, orientedLineNormalsForSession(session)); + std::vector controlArcs; + controlArcs.reserve(controlLinePositions.size()); + for (const double position : controlLinePositions) + controlArcs.push_back(vc3d::fiber_slice::arclengthAtLinePosition(cumulativeArclengths, position)); + edit.displayNormal = vc::fiber_tracer::inheritedFiberDisplayNormal(field, controlArcs, + vc3d::fiber_slice::arclengthAtLinePosition(cumulativeArclengths, edit.linePosition), + edit.linePosition); + edit.manualDisplayNormal = false; + } + + // Purely geometric prepare: provisional display geometry only, in + // microseconds and with NO GUI-thread chunk sampling - the freeze this + // used to cause was up to four synchronous Ceres solves, and a residual + // one was the splice resampling normals through the chunk cache. + // Provisional normals are re-indexed/interpolated from the previous model + // instead; the fiber-mode re-optimization the caller runs afterwards + // rebuilds the dirty spans properly (per-span rollouts, native traces, + // authoritative normals) and replaces this geometry when it lands. + // Throws (std::runtime_error) before touching the session on a failed + // prepare or splice. + vc3d::line_annotation::PreparedControlPointEdit prepared = + vc3d::line_annotation::prepareGeometricControlPointEdit( + currentLinePoints, + session.controlPoints, + edit.nearbyControlIndices, + edit.linePosition, + edit.point, + edit.segmentLength); + vc::lasagna::LineModel preparedLine = prepared.lineReconstructed + ? vc3d::line_annotation::spliceLineModelWithInterpolatedNormals( + session.optimizedLine, + prepared.linePoints, + prepared.replacedStart, + prepared.replacedCount) + : session.optimizedLine; + if (const auto focusBounds = _state ? _state->activeFocusBounds() + : std::nullopt) { + vc3d::line_annotation::constrainLineOpenTailsToBounds( + preparedLine, prepared.controlPoints, *focusBounds); + } + + // Apply display metadata before publishing or scheduling the geometric + // edit, so solve snapshots and autosaves observe one complete operation. + if (edit.displayNormal) { + prepared.controlPoints[prepared.replacementIndex].displayNormal = edit.displayNormal; + prepared.controlPoints[prepared.replacementIndex].displayNormalSource = + edit.manualDisplayNormal ? "manual" : "interpolated"; + } + if (edit.direction) + prepared.controlPoints[prepared.replacementIndex].direction = edit.direction; + session.controlPoints = prepared.controlPoints; + // A collapse can make two break points neighbours (or part them): the + // span tags and the gap goal policy follow before any solve runs. + vc3d::line_annotation::applyGapSpanPolicy(session.controlPoints); + remapCollapsedBranchControlPointIndices(prepared.oldToNewIndices, + session.branches); + session.optimizedLine = std::move(preparedLine); + ++session.lineRevision; + setSessionOptimizationState(session, SessionOptimizationState::Unoptimized); + return prepared; +} + void LineAnnotationController::handleGeneratedControlPoint(const std::string& surfaceName, cv::Vec3f volumePoint, double linePosition, @@ -8060,19 +9307,6 @@ void LineAnnotationController::handleGeneratedControlPoint(const std::string& su } const bool manualDisplayNormal = displayNormal.has_value(); - if (!displayNormal && nearbyControlIndices.empty() && - std::any_of(session.controlPoints.begin(), session.controlPoints.end(), - [](const auto& cp) { return cp.displayNormal.has_value(); })) { - // Capture before inserting a zero-offset CP or splicing the line. Use - // current session geometry, not a display cache from before recent edits. - const auto field = displayFieldForSession(session, orientedLineNormalsForSession(session)); - std::vector controlArcs; - controlArcs.reserve(controlLinePositions.size()); - for (const double position : controlLinePositions) - controlArcs.push_back(vc3d::fiber_slice::arclengthAtLinePosition(cumulativeArclengths, position)); - displayNormal = vc::fiber_tracer::inheritedFiberDisplayNormal(field, controlArcs, - vc3d::fiber_slice::arclengthAtLinePosition(cumulativeArclengths, linePosition), linePosition); - } const std::vector previousControls = session.controlPoints; @@ -8083,52 +9317,40 @@ void LineAnnotationController::handleGeneratedControlPoint(const std::string& su session.focusedControlPoint; const SessionOptimizationState previousOptimizationState = session.optimizationState; + // The collapse rollback needs the pre-edit line; a multi-collapse can + // only come from the nearby set, so the copy is taken only when one is + // possible. + std::optional previousLine; + if (nearbyControlIndices.size() > 1) { + previousLine = session.optimizedLine; + } const bool autoReoptimize = !pane->dialog || pane->dialog->reoptimizationMode() == LineAnnotationDialog::ReoptimizationMode::AutoReoptimize; - // Both modes share the geometric prepare + splice below: the manual - // (no-reoptimize) branch used to update only the control list, leaving - // the line without the moved control's vertex — an in-memory state that - // violates the loader's exact-subset contract and made superseded-solve - // merging impossible. Only the solve bookkeeping at the end differs by - // mode. + // Both modes share the geometric prepare + splice (applyGeometricControlPointEdit): + // the manual (no-reoptimize) branch used to update only the control + // list, leaving the line without the moved control's vertex — an + // in-memory state that violates the loader's exact-subset contract and + // made superseded-solve merging impossible. Only the solve bookkeeping + // at the end differs by mode. const auto updateStart = Clock::now(); vc3d::line_annotation::PreparedControlPointEdit prepared; - vc::lasagna::LineModel preparedLine; try { - // Purely geometric prepare: provisional display geometry only, in - // microseconds and with NO GUI-thread chunk sampling - the freeze - // this used to cause was up to four synchronous Ceres solves, and a - // residual one was the splice resampling normals through the chunk - // cache. Provisional normals are re-indexed/interpolated from the - // previous model instead; the asynchronous fiber-mode - // re-optimization dispatched below rebuilds the dirty spans properly - // (per-span rollouts, native traces, authoritative normals) and - // replaces this geometry when it lands. const int initialCenterlineLengthVx = pane->dialog ? std::max(2, pane->dialog->extrapolationDistanceVx() * 2) : vc3d::settings::line_annotation::EXTRAPOLATION_DISTANCE_VX_DEFAULT * 2; - const auto discretization = initialLineDiscretization(initialCenterlineLengthVx); - prepared = vc3d::line_annotation::prepareGeometricControlPointEdit( - currentLinePoints, - session.controlPoints, - nearbyControlIndices, - linePosition, - clicked, - discretization.segmentLength); - preparedLine = prepared.lineReconstructed - ? vc3d::line_annotation::spliceLineModelWithInterpolatedNormals( - session.optimizedLine, - prepared.linePoints, - prepared.replacedStart, - prepared.replacedCount) - : session.optimizedLine; - if (const auto focusBounds = _state ? _state->activeFocusBounds() - : std::nullopt) { - vc3d::line_annotation::constrainLineOpenTailsToBounds( - preparedLine, prepared.controlPoints, *focusBounds); - } + GeometricControlPointEdit edit; + edit.linePosition = linePosition; + edit.point = clicked; + edit.nearbyControlIndices = nearbyControlIndices; + edit.segmentLength = initialLineDiscretization(initialCenterlineLengthVx).segmentLength; + edit.displayNormal = displayNormal; + edit.manualDisplayNormal = manualDisplayNormal; + edit.direction = direction; + prepared = applyGeometricControlPointEdit(session, currentLinePoints, + cumulativeArclengths, + controlLinePositions, edit); } catch (const std::exception& ex) { showError(tr("Could not update line control point: %1") .arg(QString::fromStdString(ex.what())), @@ -8139,6 +9361,7 @@ void LineAnnotationController::handleGeneratedControlPoint(const std::string& su const size_t collapsedControlCount = prepared.collapsedOldIndices.size(); const std::vector branchRemapControls = prepared.controlPointsBeforeLineUpdate; + const std::vector branchRemapBranches = session.branches; // A rollback captured by an earlier edit or mode/goal change restores a // state this edit is about to build on top of; applying it to a later // solve failure would silently discard this edit too. Each edit owns at @@ -8152,7 +9375,7 @@ void LineAnnotationController::handleGeneratedControlPoint(const std::string& su session.controlPointCollapseRollback = LineAnnotationSession::ControlPointCollapseRollback{ previousControls, - session.optimizedLine, + previousLine ? *previousLine : session.optimizedLine, session.optimizationReport, previousBranches, session.selectedManifestPath, @@ -8163,25 +9386,6 @@ void LineAnnotationController::handleGeneratedControlPoint(const std::string& su session.fiberMetricsMatchStoredFiber, previousOptimizationState}; } - - // Apply display metadata before publishing or scheduling the geometric - // edit, so solve snapshots and autosaves observe one complete operation. - if (displayNormal) { - prepared.controlPoints[prepared.replacementIndex].displayNormal = displayNormal; - prepared.controlPoints[prepared.replacementIndex].displayNormalSource = - manualDisplayNormal ? "manual" : "interpolated"; - } - if (direction) - prepared.controlPoints[prepared.replacementIndex].direction = direction; - session.controlPoints = std::move(prepared.controlPoints); - // A collapse can make two break points neighbours (or part them): the - // span tags and the gap goal policy follow before the solve below runs. - vc3d::line_annotation::applyGapSpanPolicy(session.controlPoints); - remapCollapsedBranchControlPointIndices(prepared.oldToNewIndices, - session.branches); - const std::vector branchRemapBranches = session.branches; - session.optimizedLine = std::move(preparedLine); - ++session.lineRevision; const auto& changed = session.controlPoints[prepared.replacementIndex]; session.focusedLinePosition = changed.linePosition; session.focusedControlPoint = changed.volumePoint; @@ -8193,7 +9397,6 @@ void LineAnnotationController::handleGeneratedControlPoint(const std::string& su session.fiberMetricsMatchStoredFiber = false; invalidateFiberAlignmentMetrics(session.fiberId, true); } - setSessionOptimizationState(session, SessionOptimizationState::Unoptimized); if (collapsedControlCount <= 1) { const BranchMetadataSyncResult branchSync = @@ -8345,17 +9548,12 @@ std::optional LineAnnotationController::createLinkedSeedFiber( linkedSeedRecord->fiberBaseToVolumeScale = templateSession.fiberBaseToVolumeScale; ensureSessionFiberIdentity(*linkedSeedRecord); - FiberBranchRef parentToLinked; - parentToLinked.controlPointIndex = parent.controlPointIndex; - parentToLinked.branchFiberId = linkedSeedRecord->fiberId; - parentToLinked.branchControlPointIndex = 0; - parentToLinked.branchFileName = linkedSeedRecord->fiberFileName; - parentToLinked.controlPointDirection = parentEndpointDirection; - parentToLinked.branchControlPointDirection = linkDirection; - parentToLinked.controlPointPosition = parent.point; - parentToLinked.branchControlPointPosition = seedPoint; - parentToLinked.pending = true; - parentToLinked.adjacent = parent.adjacent; + // The seed keeps the view's requested link direction as its own + // endpoint direction (a one-point child has no tangent). + const FiberBranchRef parentToLinked = vc3d::line_annotation::makeBranchRef( + parent.controlPointIndex, parent.point, parentEndpointDirection, + linkedSeedRecord->fiberId, linkedSeedRecord->fiberFileName, 0, seedPoint, linkDirection, + /*pending=*/true, parent.adjacent, /*autoLink=*/false); try { // Inside the try: with several live copies of the parent a partial @@ -8377,17 +9575,8 @@ std::optional LineAnnotationController::createLinkedSeedFiber( throw std::runtime_error("link entry missing after serialization"); } - FiberBranchRef linkedToParent; - linkedToParent.controlPointIndex = 0; - linkedToParent.branchFiberId = parentFiber.id; - linkedToParent.branchControlPointIndex = storedParentBranch->controlPointIndex; - linkedToParent.branchFileName = parentFiber.fileName; - linkedToParent.controlPointDirection = linkDirection; - linkedToParent.branchControlPointDirection = parentEndpointDirection; - linkedToParent.controlPointPosition = seedPoint; - linkedToParent.branchControlPointPosition = parent.point; - linkedToParent.pending = true; - linkedToParent.adjacent = parent.adjacent; + const FiberBranchRef linkedToParent = vc3d::line_annotation::reciprocalBranchRef( + *storedParentBranch, parentFiber.id, parentFiber.fileName); linkedSeedRecord->branches.push_back(linkedToParent); linkedSeedRecord->showLinkedLineOverlays = false; linkedSeedRecord->surfaceName = "linked_fiber_create_only"; @@ -8819,17 +10008,12 @@ void LineAnnotationController::handleGeneratedControlPointLinkWithCandidate( return; } - FiberBranchRef localRef; - localRef.controlPointIndex = static_cast(controlPointIndex); - localRef.branchFiberId = farIt->id; - localRef.branchControlPointIndex = *farControlIndex; - localRef.branchFileName = farIt->fileName; - localRef.controlPointDirection = localDirection; - localRef.branchControlPointDirection = farDirection; - localRef.controlPointPosition = localPoint; - localRef.branchControlPointPosition = farPoint; - localRef.pending = true; - localRef.adjacent = candidate.adjacent; + // Session-indexed on this side; the stored index is read back from the + // serialized fiber below. + const FiberBranchRef localRef = vc3d::line_annotation::makeBranchRef( + static_cast(controlPointIndex), localPoint, localDirection, + farIt->id, farIt->fileName, *farControlIndex, farPoint, farDirection, + /*pending=*/true, candidate.adjacent, /*autoLink=*/false); const auto duplicateLocal = std::find_if( session.branches.begin(), @@ -8861,17 +10045,8 @@ void LineAnnotationController::handleGeneratedControlPointLinkWithCandidate( throw std::runtime_error("link entry missing after serialization"); } - FiberBranchRef reciprocal; - reciprocal.controlPointIndex = storedLocalBranch->branchControlPointIndex; - reciprocal.branchFiberId = localFiber.id; - reciprocal.branchControlPointIndex = storedLocalBranch->controlPointIndex; - reciprocal.branchFileName = localFiber.fileName; - reciprocal.controlPointDirection = storedLocalBranch->branchControlPointDirection; - reciprocal.branchControlPointDirection = storedLocalBranch->controlPointDirection; - reciprocal.controlPointPosition = storedLocalBranch->branchControlPointPosition; - reciprocal.branchControlPointPosition = storedLocalBranch->controlPointPosition; - reciprocal.pending = storedLocalBranch->pending; - reciprocal.adjacent = storedLocalBranch->adjacent; + const FiberBranchRef reciprocal = vc3d::line_annotation::reciprocalBranchRef( + *storedLocalBranch, localFiber.id, localFiber.fileName); // storedFiberFromSession runs the branch metadata sync hook, which may // touch _fibers; re-find the far fiber before mutating it. @@ -9017,8 +10192,21 @@ bool LineAnnotationController::structuralEditPreflight(uint64_t packageGeneratio // Every session edit still behind a debounced autosave, and every queued // job, lands before the edit plans: the plan reads _fibers, and a job // that ran later would overwrite a redirected peer with its old links. - flushAllPendingSessionAutoSaves(); - waitForFiberSaves(); + // Both can yield (a save failure's modal); nothing of `this` is read + // afterwards unless it is still alive. + const QPointer alive(this); + const auto identity = [&alive, this, packageGeneration, loadSequence]() { + return alive && _packageGeneration == packageGeneration && + _fiberLoadSequence == loadSequence; + }; + flushAllPendingSessionAutoSaves(identity); + if (!identity()) { + return false; + } + waitForFiberSaves(identity); + if (!identity()) { + return false; + } if (_packageGeneration != packageGeneration) { Logger()->warn("Structural edit abandoned: the package changed while saves drained"); return false; @@ -9271,6 +10459,7 @@ LineAnnotationController::commitStructuralEdit(StructuralEditRequest request, bo l.branchControlPointIndex != r.branchControlPointIndex || fs::path(l.branchFileName).filename() != fs::path(r.branchFileName).filename() || l.pending != r.pending || l.adjacent != r.adjacent || + l.autoLink != r.autoLink || !pointsApproximatelyEqual(l.controlPointPosition, r.controlPointPosition) || !pointsApproximatelyEqual(l.branchControlPointPosition, r.branchControlPointPosition)) { @@ -9652,7 +10841,7 @@ void LineAnnotationController::handleGeneratedControlPointMergeWithCandidate( } const bool suppressErrors = session.suppressErrorDialogs; if (_structuralEditInProgress) { - showError(tr("Another merge or split is still in progress."), suppressErrors); + showError(tr("Another merge, split or link is still in progress."), suppressErrors); return; } _structuralEditInProgress = true; @@ -9947,7 +11136,7 @@ void LineAnnotationController::handleGeneratedSpanSplit( } const bool suppressErrors = session.suppressErrorDialogs; if (_structuralEditInProgress) { - showError(tr("Another merge or split is still in progress."), suppressErrors); + showError(tr("Another merge, split or link is still in progress."), suppressErrors); return; } std::string reason; @@ -10248,6 +11437,9 @@ void LineAnnotationController::handleGeneratedControlPointSetLinkPending( (branchControlPointIndex < 0 || branch.branchControlPointIndex == branchControlPointIndex)) { branch.pending = pending; + // Approving clears the automatic mark; re-flagging an approved + // link makes a plain (human) pending link. + branch.autoLink = false; updatedBranches.push_back(branch); } } @@ -10275,6 +11467,7 @@ void LineAnnotationController::handleGeneratedControlPointSetLinkPending( candidate.controlPointIndex == updatedBranch.branchControlPointIndex && sameLocalEndpoint) { candidate.pending = pending; + candidate.autoLink = false; changed = true; } } @@ -10571,7 +11764,9 @@ void LineAnnotationController::handleGeneratedControlPointDelete(const std::stri } } -bool LineAnnotationController::ensureDatasetForSession(LineAnnotationSession& session, bool required) +bool LineAnnotationController::ensureDatasetForSession(LineAnnotationSession& session, + bool required, + const std::function& stillValid) { if (session.modelOnly) { try { @@ -10650,6 +11845,12 @@ bool LineAnnotationController::ensureDatasetForSession(LineAnnotationSession& se auto picked = (_datasetPicker && !headless) ? _datasetPicker(_parentWidget, startDir) : std::optional{}; + // The picker ran the event loop: an operation that must not continue + // past a package switch or a teardown says so here, before anything + // is attached to the session or the package. + if (stillValid && !stillValid()) { + return false; + } if (!picked || picked->empty()) { if (headless) { showError(tr("No Lasagna dataset is selected for the active volume."), true); @@ -10808,7 +12009,8 @@ void LineAnnotationController::handleFiberInferenceDatasetSelectionChanged( } bool LineAnnotationController::ensureFiberInferenceDatasetForSession( - LineAnnotationSession& session) + LineAnnotationSession& session, + const std::function& stillValid) { const bool headless = session.suppressErrorDialogs || _errorDialogsSuppressed; if (!_state || !_state->vpkg()) { @@ -10844,6 +12046,10 @@ bool LineAnnotationController::ensureFiberInferenceDatasetForSession( auto picked = (_datasetPicker && !headless) ? _datasetPicker(_parentWidget, startDir) : std::optional{}; + // Same rule as ensureDatasetForSession: the picker yielded. + if (stillValid && !stillValid()) { + return false; + } if (!picked || picked->empty()) { if (headless) { showError(tr("No fiber inference dataset is selected for the active project."), @@ -11980,14 +13186,20 @@ bool LineAnnotationController::applyOptimizationTaskResult(LineAnnotationSession return true; } -bool LineAnnotationController::finalizeSessionOptimizationSynchronously( +bool LineAnnotationController::ensureSessionSolveBackends( LineAnnotationSession& session, - bool fireSuccessCallback) -{ - if (!needsFinalOptimization(session)) { - return true; + const std::function& stillValid) +{ + // Every backend a fiber-mode solve of this session can need, resolved + // before the solve: the Lasagna dataset (normals), and for a fiber that + // traces, the fiber-follow service or the inference dataset. Each of + // these can yield (a picker, the service start); `stillValid` is checked + // by the helpers right after their yield and here after each returns, + // before the next one touches controller state. + if (!ensureDatasetForSession(session, true, stillValid)) { + return false; } - if (!ensureDatasetForSession(session)) { + if (stillValid && !stillValid()) { return false; } if (!session.normalSampler && !session.modelOnly) { @@ -11995,51 +13207,97 @@ bool LineAnnotationController::finalizeSessionOptimizationSynchronously( session.suppressErrorDialogs); return false; } + if (!(session.controlPoints.size() >= 2 || session.modelOnly)) { + return true; + } + const bool needsTrace = session.fiberOptimizationMode == + vc3d::line_annotation::FiberOptimizationMode::NativeFiberTrace3d || + std::any_of(session.controlPoints.begin(), session.controlPoints.end(), + [](const auto& control) { + return control.segmentToNext && + control.segmentToNext->interpGoal == + vc3d::line_annotation::SegmentInterpolationGoal::Trace; + }); + if (needsTrace && (session.modelOnly || fiberFollowEnabled())) { + auto* pane = paneForSurface(session.surfaceName); + if (!ensureFiberFollowService(session.suppressErrorDialogs, + pane ? pane->dialog.data() : nullptr, stillValid)) + return false; + if (stillValid && !stillValid()) { + return false; + } + } else if (needsTrace) { + if (!ensureFiberInferenceDatasetForSession(session, stillValid)) { + return false; + } + if (stillValid && !stillValid()) { + return false; + } + if (!session.fiberPredictionField || !session.traceNormalSampler) { + return false; + } + } + return true; +} + +LineAnnotationController::OptimizationTaskResult +LineAnnotationController::runFiberModeSolveSynchronously( + const LineAnnotationSession& session, + std::optional> dirtySegments, + const std::string& eventName) const +{ + // The synchronous fiber-mode solve, result only: nothing is applied to + // the session here (applyOptimizationTaskResult publishes, including the + // reciprocal sync of linked peers), so a caller can validate the result + // first. Backends must be resolved (ensureSessionSolveBackends); the + // solve itself does not process events. + OptimizationTaskResult task; + task.manifestPath = session.selectedManifestPath; + task.eventName = eventName; + task.seedPoint = session.seedPoint; + task.focusBoundsBase = _state ? _state->activeFocusBounds() : std::nullopt; + try { + auto optimized = + vc3d::line_annotation::optimizeFiberWithNativeFallback( + makeFiberModeOptimizationRequest(session, false, std::move(dirtySegments))); + task.controlPoints = std::move(optimized.controlPoints); + std::vector points; + points.reserve(optimized.optimization.line.points.size()); + for (const auto& point : optimized.optimization.line.points) { + points.push_back(point.position); + } + task.result = std::move(optimized.optimization); + if (!session.modelOnly) + task.result.line = lineModelFromPoints(points, session.normalSampler.get()); + task.ok = true; + } catch (const std::exception& ex) { + task.error = ex.what(); + } catch (...) { + task.error = "Unknown fiber-mode finalization error."; + } + return task; +} + +bool LineAnnotationController::finalizeSessionOptimizationSynchronously( + LineAnnotationSession& session, + bool fireSuccessCallback, + const std::function& stillValid) +{ + if (!needsFinalOptimization(session)) { + return true; + } + if (!ensureSessionSolveBackends(session, stillValid)) { + return false; + } const auto focusBoundsBase = _state ? _state->activeFocusBounds() : std::nullopt; if (session.controlPoints.size() >= 2 || session.modelOnly) { - const bool needsTrace = session.fiberOptimizationMode == - vc3d::line_annotation::FiberOptimizationMode::NativeFiberTrace3d || - std::any_of(session.controlPoints.begin(), session.controlPoints.end(), - [](const auto& control) { - return control.segmentToNext && - control.segmentToNext->interpGoal == - vc3d::line_annotation::SegmentInterpolationGoal::Trace; - }); - if (needsTrace && (session.modelOnly || fiberFollowEnabled())) { - auto* pane = paneForSurface(session.surfaceName); - if (!ensureFiberFollowService(session.suppressErrorDialogs, pane ? pane->dialog.data() : nullptr)) - return false; - } else if (needsTrace && - (!ensureFiberInferenceDatasetForSession(session) || - !session.fiberPredictionField || !session.traceNormalSampler)) { + OptimizationTaskResult task = runFiberModeSolveSynchronously( + session, std::nullopt, "segment_interpolation_final_full_line_opt"); + if (stillValid && !stillValid()) { return false; } - OptimizationTaskResult task; - task.manifestPath = session.selectedManifestPath; - task.eventName = "segment_interpolation_final_full_line_opt"; - task.seedPoint = session.seedPoint; - task.focusBoundsBase = focusBoundsBase; - try { - auto optimized = - vc3d::line_annotation::optimizeFiberWithNativeFallback( - makeFiberModeOptimizationRequest(session, false)); - task.controlPoints = std::move(optimized.controlPoints); - std::vector points; - points.reserve(optimized.optimization.line.points.size()); - for (const auto& point : optimized.optimization.line.points) { - points.push_back(point.position); - } - task.result = std::move(optimized.optimization); - if (!session.modelOnly) - task.result.line = lineModelFromPoints(points, session.normalSampler.get()); - task.ok = true; - } catch (const std::exception& ex) { - task.error = ex.what(); - } catch (...) { - task.error = "Unknown fiber-mode finalization error."; - } const bool applied = applyOptimizationTaskResult( session, std::move(task), @@ -12379,7 +13637,9 @@ bool LineAnnotationController::fiberFollowEnabled() vc3d::settings::line_annotation::FIBER_FOLLOW_ENABLED_DEFAULT).toBool(); } -bool LineAnnotationController::ensureFiberFollowService(bool headless, LineAnnotationDialog* dialog) +bool LineAnnotationController::ensureFiberFollowService(bool headless, + LineAnnotationDialog* dialog, + const std::function& stillValid) { QSettings settings(vc3d::settingsFilePath(), QSettings::IniFormat); QMetaObject::Connection progress; @@ -12394,17 +13654,27 @@ bool LineAnnotationController::ensureFiberFollowService(bool headless, LineAnnot ? QString::fromStdString(volume->remoteUrl()) : QString(); const QString volumeLocator = volume && volume->isRemote() ? QString::fromStdString(volume->remoteLocator()) : QString(); + // The service start processes events: the dialog is only reached again + // through a QPointer. + const QPointer dialogGuard(dialog); QString error; - if (FiberFollowService::instance().ensureRunning( - settings.value(vc3d::settings::line_annotation::FIBER_FOLLOW_CHECKPOINT).toString(), - settings.value(vc3d::settings::line_annotation::FIBER_FOLLOW_PYTHON).toString(), - &error, volumeSource, volumeLocator)) { - if (dialog) - dialog->setFiberTraceStatus(tr("Fiber follow: model ready"), {}, false); + const bool running = FiberFollowService::instance().ensureRunning( + settings.value(vc3d::settings::line_annotation::FIBER_FOLLOW_CHECKPOINT).toString(), + settings.value(vc3d::settings::line_annotation::FIBER_FOLLOW_PYTHON).toString(), + &error, volumeSource, volumeLocator); + // The service start processes events while the model boots: an + // operation that must not continue past a package switch or a teardown + // says so here, before the dialog or the error path is touched. + if (stillValid && !stillValid()) { + return false; + } + if (running) { + if (dialogGuard) + dialogGuard->setFiberTraceStatus(tr("Fiber follow: model ready"), {}, false); return true; } - if (dialog) - dialog->setFiberTraceStatus(tr("Fiber follow: model could not start — %1").arg(error), error, false, true); + if (dialogGuard) + dialogGuard->setFiberTraceStatus(tr("Fiber follow: model could not start — %1").arg(error), error, false, true); showError(tr("Fiber-follow model: %1").arg(error), headless); return false; } @@ -14635,8 +15905,15 @@ void LineAnnotationController::cleanupSurfaceName(const std::string& surfaceName return matchesClosingSurface(item.first); }); + // The save and every surface notification below can run arbitrary + // slots (a modal, a teardown): nothing of `this` is touched afterwards + // unless it is alive. The names are held by value. + const QPointer alive(this); if (sessionToSave) { saveSessionAsFiber(*sessionToSave); + if (!alive) { + return; + } } if (readOnlyFiber) { std::erase_if(_fibers, [id = *readOnlyFiber](const StoredFiber& fiber) { @@ -14645,9 +15922,13 @@ void LineAnnotationController::cleanupSurfaceName(const std::string& surfaceName } if (_state) { - _state->setSurface(surfaceName, nullptr); + const QPointer state(_state); + state->setSurface(surfaceName, nullptr); for (const auto& name : generatedSurfaceNames) { - _state->setSurface(name, nullptr); + if (!alive || !state) { + return; + } + state->setSurface(name, nullptr); } } } @@ -15192,6 +16473,7 @@ void LineAnnotationController::loadFibersForCurrentPackage() } } dropLinkEntriesFromOpenSessions(droppedAtLoad, _fibers); + downgradeAutomaticLinksInOpenSessions(_fibers); if (!loadErrors.empty()) { for (const auto& error : loadErrors) { Logger()->warn("{}", error); @@ -17670,6 +18952,40 @@ bool LineAnnotationController::confirmLinkedControlPointEdits( return response == QMessageBox::Yes; } +bool LineAnnotationController::TrackedLinkEndpoints::heldA(const cv::Vec3d& point) const +{ + return std::any_of(pointsA.begin(), pointsA.end(), + [&point](const cv::Vec3d& held) { return pointsApproximatelyEqual(held, point); }); +} + +bool LineAnnotationController::TrackedLinkEndpoints::heldB(const cv::Vec3d& point) const +{ + return std::any_of(pointsB.begin(), pointsB.end(), + [&point](const cv::Vec3d& held) { return pointsApproximatelyEqual(held, point); }); +} + +void LineAnnotationController::noteTrackedEndpointMove(uint64_t fiberId, + const std::string& fileName, + const cv::Vec3d& from, + const cv::Vec3d& to) +{ + if (_trackedLinks.empty() || pointsApproximatelyEqual(from, to)) { + return; + } + for (TrackedLinkEndpoints* tracked : _trackedLinks) { + if (vc3d::line_annotation::sameFiberIdentity(fiberId, fileName, tracked->fiberIdA, + tracked->fileA) && + tracked->heldA(from) && !tracked->heldA(to)) { + tracked->pointsA.push_back(to); + } + if (vc3d::line_annotation::sameFiberIdentity(fiberId, fileName, tracked->fiberIdB, + tracked->fileB) && + tracked->heldB(from) && !tracked->heldB(to)) { + tracked->pointsB.push_back(to); + } + } +} + std::vector LineAnnotationController::syncBranchEndpointPositions( LineAnnotationSession& session) { @@ -17687,7 +19003,7 @@ std::vector LineAnnotationController::syncBranchEndpointPositions( affectedFiberIds.push_back(fiberId); }; - auto updateReciprocal = [&session]( + auto updateReciprocal = [this, &session]( std::vector& targetBranches, const FiberBranchRef& sourceBranch, int targetControlPointIndex, @@ -17707,6 +19023,10 @@ std::vector LineAnnotationController::syncBranchEndpointPositions( !pointsToSession || !sameLocalEndpoint || !sameLinkedEndpoint) { continue; } + noteTrackedEndpointMove(sourceBranch.branchFiberId, sourceBranch.branchFileName, + reciprocal.controlPointPosition, targetPoint); + noteTrackedEndpointMove(session.fiberId, session.fiberFileName, + reciprocal.branchControlPointPosition, sourcePoint); reciprocal.controlPointIndex = targetControlPointIndex; reciprocal.branchFiberId = session.fiberId; reciprocal.branchFileName = session.fiberFileName; @@ -17727,6 +19047,8 @@ std::vector LineAnnotationController::syncBranchEndpointPositions( } const cv::Vec3d sourcePoint = session.controlPoints[static_cast(branch.controlPointIndex)].volumePoint; + noteTrackedEndpointMove(session.fiberId, session.fiberFileName, + branch.controlPointPosition, sourcePoint); branch.controlPointPosition = sourcePoint; branch.controlPointDirection = endpointTangentFromLinePoints(sessionLinePoints, @@ -17757,6 +19079,8 @@ std::vector LineAnnotationController::syncBranchEndpointPositions( .volumePoint; const std::vector targetLinePoints = linePointPositions(pane.session->optimizedLine); + noteTrackedEndpointMove(branch.branchFiberId, branch.branchFileName, + branch.branchControlPointPosition, targetPoint); branch.branchControlPointIndex = *targetIndex; branch.branchControlPointPosition = targetPoint; branch.branchControlPointDirection = @@ -17786,6 +19110,8 @@ std::vector LineAnnotationController::syncBranchEndpointPositions( branch.branchControlPointPosition)) { const cv::Vec3d targetPoint = fiber.controlPoints[static_cast(*targetIndex)]; + noteTrackedEndpointMove(branch.branchFiberId, branch.branchFileName, + branch.branchControlPointPosition, targetPoint); branch.branchControlPointIndex = *targetIndex; branch.branchControlPointPosition = targetPoint; branch.branchControlPointDirection = @@ -18064,6 +19390,29 @@ cv::Vec3d LineAnnotationController::seedTraceSourceNormalForStoredFiber( return {0.0, 0.0, 1.0}; } +bool LineAnnotationController::prepareSessionLineModel(LineAnnotationSession& session, + const std::vector& linePoints, + const std::function& stillValid) +{ + // A pane-less session starts on a synthetic line model (normals all + // +z); the real model - sampled normals, or the fiber-follow model when + // the fiber is model-only - is what display-normal inheritance and the + // solve read. The dataset (normalSampler) must already be resolved. + if (session.modelOnly) { + if (!ensureFiberFollowService(session.suppressErrorDialogs, nullptr, stillValid)) { + return false; + } + if (stillValid && !stillValid()) { + return false; + } + session.optimizedLine = FiberFollowService::instance().lineModel( + linePoints, _state->currentVolume()->voxelSize() * session.fiberBaseToVolumeScale); + } else { + session.optimizedLine = lineModelFromPoints(linePoints, session.normalSampler.get()); + } + return true; +} + std::shared_ptr LineAnnotationController::makeIntersectionLineSession( const StoredFiber& fiber, @@ -18404,7 +19753,8 @@ void LineAnnotationController::flushSessionAutoSave(const std::string& surfaceNa saveSessionAsFiber(session); } -void LineAnnotationController::flushAllPendingSessionAutoSaves() +void LineAnnotationController::flushAllPendingSessionAutoSaves( + const std::function& stillValid) { std::vector surfaces; surfaces.reserve(_panes.size()); @@ -18413,13 +19763,23 @@ void LineAnnotationController::flushAllPendingSessionAutoSaves() surfaces.push_back(pane.session->surfaceName); } } + // A flushed save can show a modal (serialization error) and the + // controller can be destroyed inside it; the next flush must not run on + // a destroyed object. + const QPointer alive(this); for (const auto& surfaceName : surfaces) { flushSessionAutoSave(surfaceName); + if (!alive || (stillValid && !stillValid())) { + return; + } } } void LineAnnotationController::saveSessionAsFiber(LineAnnotationSession& session) { + // The summaries emitted at the end can yield (the dock's metrics + // picker): nothing of `this` is touched after them unless it is alive. + const QPointer alive(this); // Any direct save supersedes a pending debounced autosave. session.autoSaveScheduled = false; if (session.readOnly) { @@ -18631,6 +19991,9 @@ void LineAnnotationController::saveSessionAsFiber(LineAnnotationSession& session } } emitFiberSummaries(); + if (!alive) { + return; + } refreshBranchLineViews(savedFiberId); } catch (const std::exception& ex) { // Counted like an asynchronous write failure: callers that gate @@ -18745,9 +20108,7 @@ nlohmann::json LineAnnotationController::fiberSaveSnapshotToJson( throw std::runtime_error("Branch link is missing branch_file"); } branchJson["branch_file"] = branchFileName; - if (branch.pending) { - branchJson["pending"] = true; - } + vc3d::line_annotation::writeBranchReviewStateJson(branchJson, branch); root[branch.adjacent ? kAdjacentBranchesJsonKey : "branches"] .push_back(std::move(branchJson)); } @@ -18983,6 +20344,18 @@ void LineAnnotationController::finishFiberSaveJob( } watcher->deleteLater(); + // The failure report below is modal and the emissions/callbacks run + // arbitrary code: the controller can be destroyed (a project close) or + // its package/fiber list replaced, after which the remaining completion + // work (more emissions, the next job) must not run against the + // replacement state. + const QPointer alive(this); + const uint64_t packageGeneration = _packageGeneration; + const uint64_t loadSequence = _fiberLoadSequence; + const auto identityHolds = [&]() { + return alive && _packageGeneration == packageGeneration && + _fiberLoadSequence == loadSequence; + }; QString errorMessage; if (result.ok) { // No generation bump here: the mutation that prompted this save already @@ -18993,6 +20366,11 @@ void LineAnnotationController::finishFiberSaveJob( const uint64_t generation = i < result.generations.size() ? result.generations[i] : 0; emit fiberSaved(result.fiberIds[i], generation); + // A synchronous slot can tear the controller down or replace + // the package. + if (!identityHolds()) { + return; + } } } else { ++_fiberSaveFailureCount; @@ -19010,20 +20388,37 @@ void LineAnnotationController::finishFiberSaveJob( Logger()->warn("Line Annotation (headless save): {}", errorMessage.toStdString()); } + if (!identityHolds()) { + return; + } } + // fiberSaved slots and the batch callbacks run arbitrary code (dialogs, + // teardown): nothing of `this` is touched afterwards unless it is alive. + if (!identityHolds()) { + return; + } for (const auto& batch : batches) { if (!batch) { continue; } batch->finishJob(errorMessage); + if (!identityHolds()) { + return; + } } startNextFiberSaveJob(); } -void LineAnnotationController::waitForFiberSaves() +void LineAnnotationController::waitForFiberSaves(const std::function& stillValid) { + // The drain spins an event loop: the controller can be destroyed inside + // it (a save failure's modal, a project close). The watcher is a child of + // the controller, so its finished connection dies with it - the local + // loop also quits on the controller's destruction, and nothing of `this` + // is read afterwards. + const QPointer alive(this); while (_fiberSaveRunning || !_pendingFiberSaveJobs.empty()) { if (!_fiberSaveRunning) { startNextFiberSaveJob(); @@ -19036,7 +20431,11 @@ void LineAnnotationController::waitForFiberSaves() &QFutureWatcher::finished, &loop, &QEventLoop::quit); + connect(this, &QObject::destroyed, &loop, &QEventLoop::quit); loop.exec(); + if (!alive || (stillValid && !stillValid())) { + return; + } } } @@ -19282,8 +20681,13 @@ std::optional LineAnnotationController::l pointFromJson(branchJson.at("control_point_position")); branch.branchControlPointPosition = pointFromJson(branchJson.at("branch_control_point_position")); - branch.pending = branchJson.value("pending", false); branch.adjacent = std::string_view(kind) == kAdjacentBranchesJsonKey; + { + const auto review = vc3d::line_annotation::readBranchReviewStateJson( + branchJson, branch.adjacent); + branch.pending = review.pending; + branch.autoLink = review.autoLink; + } if (branch.controlPointIndex < 0 || static_cast(branch.controlPointIndex) >= fiber.controlPoints.size()) { @@ -19626,6 +21030,77 @@ void LineAnnotationController::dropLinkEntriesFromOpenSessions( } } +void LineAnnotationController::downgradeAutomaticLinksInOpenSessions( + const std::vector& records) +{ + std::vector changedSessionIds; + for (auto& pane : _panes) { + if (!pane.session || pane.session->fiberDeleted) { + continue; + } + auto& session = *pane.session; + const auto record = std::find_if( + records.begin(), records.end(), [&session](const StoredFiber& fiber) { + return vc3d::line_annotation::sameFiberIdentity( + fiber.id, fiber.fileName, session.fiberId, session.fiberFileName); + }); + if (record == records.end()) { + continue; + } + // The record's entry for a session ref: same kind, same target file, + // same pair of positions (the identity dropLinkEntriesFromOpenSessions + // matches on; a stored index is never compared with a session one). + const auto recordEntry = [&record](const FiberBranchRef& branch) -> const FiberBranchRef* { + const std::string target = fs::path(branch.branchFileName).filename().string(); + const auto it = std::find_if( + record->branches.begin(), record->branches.end(), + [&](const FiberBranchRef& kept) { + return kept.adjacent == branch.adjacent && + fs::path(kept.branchFileName).filename().string() == target && + pointsApproximatelyEqual(kept.controlPointPosition, + branch.controlPointPosition) && + pointsApproximatelyEqual(kept.branchControlPointPosition, + branch.branchControlPointPosition); + }); + return it == record->branches.end() ? nullptr : &*it; + }; + const auto downgrade = [&recordEntry](std::vector& branches) { + std::size_t count = 0; + for (FiberBranchRef& branch : branches) { + if (!branch.autoLink) { + continue; + } + if (const FiberBranchRef* kept = recordEntry(branch); kept && !kept->autoLink) { + branch.autoLink = false; + ++count; + } + } + return count; + }; + std::size_t downgraded = downgrade(session.branches); + if (session.branchesBeforeModeChange) { + downgraded += downgrade(*session.branchesBeforeModeChange); + } + if (session.controlPointCollapseRollback) { + downgraded += downgrade(session.controlPointCollapseRollback->branches); + } + if (downgraded > 0) { + Logger()->info("Line Annotation: {} automatic link mark{} of the open session of {} " + "downgraded to plain pending with the loaded file", + downgraded, downgraded == 1 ? "" : "s", session.fiberFileName); + if (std::find(changedSessionIds.begin(), changedSessionIds.end(), session.fiberId) == + changedSessionIds.end()) { + changedSessionIds.push_back(session.fiberId); + } + } + } + // The dialogs cache the review state in their control-point markers + // (the dashed ring, the approve label): rebuilt from the refs. + for (const uint64_t sessionFiberId : changedSessionIds) { + refreshBranchLineViews(sessionFiberId); + } +} + std::size_t LineAnnotationController::neutralizeLoadedFiberBranchLinks( std::vector& fibers, const std::vector& issues) const @@ -19764,6 +21239,34 @@ bool LineAnnotationController::validateLoadedFiberLinks(std::vector } } } + // The automatic mark is mirrored on both refs of a link. A file an older + // VC3D re-saved lost the key on its side: the link is then a plain + // pending link on BOTH sides in memory (the documented downgrade; the + // next pair save converges the files), never automatic on one side only. + std::unordered_map fiberById; + fiberById.reserve(fibers.size()); + for (auto& fiber : fibers) { + fiberById[fiber.id] = &fiber; + } + for (auto& fiber : fibers) { + for (auto& branch : fiber.branches) { + if (!branch.autoLink) { + continue; + } + const auto target = fiberById.find(branch.branchFiberId); + if (target == fiberById.end()) { + continue; + } + const auto reciprocal = std::find_if( + target->second->branches.begin(), target->second->branches.end(), + [&fiber, &branch](const FiberBranchRef& candidate) { + return isReciprocalBranchRef(fiber, branch, candidate); + }); + if (reciprocal != target->second->branches.end() && !reciprocal->autoLink) { + branch.autoLink = false; + } + } + } return true; } diff --git a/volume-cartographer/apps/VC3D/LineAnnotationController.hpp b/volume-cartographer/apps/VC3D/LineAnnotationController.hpp index 63d9dc7b3f..1f34b216ac 100644 --- a/volume-cartographer/apps/VC3D/LineAnnotationController.hpp +++ b/volume-cartographer/apps/VC3D/LineAnnotationController.hpp @@ -174,6 +174,9 @@ class LineAnnotationController : public QObject // two refs of one pair with different kinds are a real disagreement. // Missing adjacent arrays are healed before load-time validation. bool adjacentExplicit = true; + // Mirrors FiberBranchRef::autoLink (an unreviewed automatic link): + // the map draws its endpoints dashed. + bool autoLink = false; }; struct FiberMapFiber { @@ -278,12 +281,26 @@ class LineAnnotationController : public QObject // the smallest fiber id in the fiber's connected component over all // branch links, pending included — same union-find semantics as // fiberSummaries(). + // How a linked control point is marked in the slice overlays. The values + // are the overlay's pointLinkStates bytes (FiberOverlayController::Chain). + enum class LinkMarkState : uint8_t { Pending = 1, Approved = 2, AutoPending = 3 }; + // One linked control point as the slice overlays draw it: the review + // state (pending winning when a point carries both, AutoPending when + // every pending link on it is automatic), the same-orientation warning + // of the winning state, the point tags and the adjacent-link shape - the + // same look the annotation views give the point. + struct LinkedControlMark { + int controlIndex = -1; + LinkMarkState state = LinkMarkState::Approved; + bool sameHv = false; + bool kollesisTermination = false; + bool breakTag = false; + bool adjacent = false; + }; struct FiberLinkOverlayInfo { uint64_t fiberId = 0; uint64_t linkGroupId = 0; - // (local control point index, pending); one entry per linked control - // point, pending winning when a point carries both link states. - std::vector> linkedControlPoints; + std::vector linkedControlPoints; }; using DatasetPicker = @@ -345,6 +362,53 @@ class LineAnnotationController : public QObject QString* error = nullptr); void openFiberAtControlPoint(uint64_t fiberId, int controlPointIndex); void openFiberAtLinePointIndex(uint64_t fiberId, int linePointIndex); + + // The Fiber Map's "Link and " (automatic link), in two calls the + // workspace makes from one queued callback (see FiberMapWorkspace:: + // startAutoLink). Both resolve a fiber by runtime id AND file name. + struct FiberMapAutoLinkOutcome { + bool ok = false; + QString message; + }; + // Phase B: finalizes, saves and closes every live session of the two + // fibers (dialogs through the ordinary open preparation, dialog-less + // panes and the intersection inspection explicitly, each saved before + // its pane is erased), then the structural-edit preflight (save drain, + // save-failure and reload checks). After it, no session of either fiber + // exists; the caller re-verifies its map dependencies (a save may have + // moved the fiber generation) before locating the meeting on the map. + FiberMapAutoLinkOutcome prepareFiberMapAutoLink(uint64_t fiberIdA, + const std::string& fileNameA, + uint64_t fiberIdB, + const std::string& fileNameB); + struct FiberMapAutoLinkEndpoint { + uint64_t fiberId = 0; + std::string fileName; + // The meeting's position on this fiber's stored line points (the + // locator's exact inversion of the drawn point). + double linePosition = 0.0; + }; + struct FiberMapAutoLinkParams { + // D3: +-window along each fiber around the meeting (physical). + double searchWindowUm = 5000.0; + // D4: an unlinked control within this arclength of the pass is reused. + double reuseUm = 1000.0; + }; + // Phase D: the closest 3D pass inside the windows, a control point per + // fiber (reused or newly placed with a span re-optimization, saved), and + // the pending automatic link between them. mapFiberDataGeneration is + // the generation the map was built from; mapStillFresh is the + // workspace's own dependency check (everything but the fiber + // generation), consulted after every yield. + FiberMapAutoLinkOutcome autoLinkFibers(const FiberMapAutoLinkEndpoint& a, + const FiberMapAutoLinkEndpoint& b, + uint64_t mapFiberDataGeneration, + std::function mapStillFresh, + const FiberMapAutoLinkParams& params); + // The stored line points of a loaded fiber (nullptr when not loaded under + // that id and name): what the locator inverts drawn points against. + [[nodiscard]] const std::vector* storedFiberLinePoints( + uint64_t fiberId, const std::string& fileName) const; void openFiberSpan(uint64_t fiberId, int firstControlIndex, int secondControlIndex); void deleteFiber(uint64_t fiberId); // Deletes the requested fibers' files and drops them from the package. @@ -666,7 +730,9 @@ private slots: std::string nextSurfaceName(); void cleanupSurfaceName(const std::string& surfaceName); - bool prepareForUserFacingLineAnnotationOpen(); + // stillValid (optional): forwarded to each session's finalization and + // checked after each save (both can yield); false makes it return false. + bool prepareForUserFacingLineAnnotationOpen(const std::function& stillValid = {}); bool launchSession(SourceKind sourceKind, const std::string& surfaceName, std::shared_ptr sourceSurface, @@ -881,15 +947,40 @@ private slots: std::vector markers, const LineAnnotationSession* session) const; [[nodiscard]] bool fibersShareHvDirection(uint64_t localFiberId, uint64_t linkedFiberId) const; - bool ensureDatasetForSession(LineAnnotationSession& session, bool required = true); - bool ensureFiberInferenceDatasetForSession(LineAnnotationSession& session); + // stillValid (optional): checked right after the helper's own yield (a + // dataset picker) before anything is attached to the session or the + // package; false makes the helper return false at once. + bool ensureDatasetForSession(LineAnnotationSession& session, bool required = true, + const std::function& stillValid = {}); + bool ensureFiberInferenceDatasetForSession(LineAnnotationSession& session, + const std::function& stillValid = {}); + // Every backend a fiber-mode solve of the session needs (dataset, and + // the fiber-follow service or the inference dataset when it traces), + // resolved up front; stillValid as above, checked after each yielding + // helper returns as well. + bool ensureSessionSolveBackends(LineAnnotationSession& session, + const std::function& stillValid = {}); + // The synchronous fiber-mode solve as a result only (nothing applied): + // dirtySegments nullopt = every span (the finalizer's whole-line solve). + [[nodiscard]] OptimizationTaskResult runFiberModeSolveSynchronously( + const LineAnnotationSession& session, + std::optional> dirtySegments, + const std::string& eventName) const; + // Replaces a pane-less session's synthetic line model by the real one + // (sampled normals, or the fiber-follow model when model-only). + bool prepareSessionLineModel(LineAnnotationSession& session, + const std::vector& linePoints, + const std::function& stillValid = {}); void refreshLineAnnotationDatasetMenus() const; void refreshLineAnnotationDatasetMenu(LineAnnotationDialog* dialog) const; void handleLasagnaDatasetSelectionChanged(const std::string& location); void handleFiberInferenceDatasetSelectionChanged(const std::string& location); bool needsFinalOptimization(const LineAnnotationSession& session) const; + // stillValid (optional): forwarded to the backend helpers and checked + // once more after the solve, before the result is applied. bool finalizeSessionOptimizationSynchronously(LineAnnotationSession& session, - bool fireSuccessCallback); + bool fireSuccessCallback, + const std::function& stillValid = {}); void setSessionOptimizationState(LineAnnotationSession& session, SessionOptimizationState state); void refreshSessionOptimizationStatus(const LineAnnotationSession& session); @@ -900,6 +991,29 @@ private slots: mutable uint64_t _nextControlIdentity = 1; bool placementAllowedByFocusBounds(const cv::Vec3d& point, bool suppressErrorDialogs) const; + // The geometric part of placing / moving a control point, shared by the + // interactive handler and the pane-less automatic placement: display- + // normal inheritance, the geometric prepare and splice, the open-tail + // focus clamp, assignment into the session, the gap span policy, the + // branch index remap, the line revision bump and the Unoptimized state. + // SESSION-ONLY (no peer sync, no dialog bookkeeping). Throws + // std::runtime_error before touching the session when the prepare or the + // splice fails. + struct GeometricControlPointEdit { + double linePosition = 0.0; + cv::Vec3d point{0.0, 0.0, 0.0}; + std::vector nearbyControlIndices; + double segmentLength = 0.0; + std::optional displayNormal; + bool manualDisplayNormal = false; + std::optional direction; + }; + vc3d::line_annotation::PreparedControlPointEdit applyGeometricControlPointEdit( + LineAnnotationSession& session, + const std::vector& currentLinePoints, + const std::vector& cumulativeArclengths, + const std::vector& controlLinePositions, + GeometricControlPointEdit edit); bool applyOptimizationTaskResult(LineAnnotationSession& session, OptimizationTaskResult task, bool updateGeneratedViews, @@ -932,7 +1046,8 @@ private slots: [[nodiscard]] static bool fiberFollowEnabled(); // GUI thread: starts the fiber-follow service if needed; false (error // shown unless headless) when it cannot run. - bool ensureFiberFollowService(bool headless, LineAnnotationDialog* dialog = nullptr); + bool ensureFiberFollowService(bool headless, LineAnnotationDialog* dialog = nullptr, + const std::function& stillValid = {}); // Points the request's tail extrapolation and Trace spans at the running // fiber-follow service. familyHint: "H", "V" or empty (from the direction). void installFiberFollowTracer(vc3d::line_annotation::FiberModeOptimizationRequest& request, @@ -1136,6 +1251,29 @@ private slots: [[nodiscard]] bool controlPointHasBranch(const LineAnnotationSession& session, int controlPointIndex) const; std::vector syncBranchEndpointPositions(LineAnnotationSession& session); + // A link an operation must still be able to find after its yields: the + // identity of its two fibers and EVERY position each endpoint has held + // since (the installed one first). syncBranchEndpointPositions() appends + // a moved endpoint's new position, so a copy of the link that an edit + // during the yield moved (a session's ref, the peer's synced reciprocal) + // and one it did not reach yet (the stored record awaiting that save) + // both match. Registered by address for the operation's scope. + struct TrackedLinkEndpoints { + uint64_t fiberIdA = 0; + std::string fileA; + uint64_t fiberIdB = 0; + std::string fileB; + std::vector pointsA; + std::vector pointsB; + // True when `point` is one of the positions this endpoint has held. + [[nodiscard]] bool heldA(const cv::Vec3d& point) const; + [[nodiscard]] bool heldB(const cv::Vec3d& point) const; + }; + std::vector _trackedLinks; + // A control of fiber (fiberId, fileName) moved from `from` to `to`: + // every tracked link with an endpoint there follows. + void noteTrackedEndpointMove(uint64_t fiberId, const std::string& fileName, + const cv::Vec3d& from, const cv::Vec3d& to); static void scaleStoredFiber(StoredFiber& fiber, double scale); [[nodiscard]] static vc::lasagna::LineModel lineModelFromPoints( const std::vector& points, @@ -1162,7 +1300,9 @@ private slots: // so nothing reads a fiber whose newest geometry is still session-only. void scheduleSessionAutoSave(LineAnnotationSession& session); void flushSessionAutoSave(const std::string& surfaceName); - void flushAllPendingSessionAutoSaves(); + // stillValid (optional): checked after each flushed save (a yield); + // false stops the flush. + void flushAllPendingSessionAutoSaves(const std::function& stillValid = {}); [[nodiscard]] nlohmann::json fiberToJson(const StoredFiber& fiber, double scale = 1.0) const; void saveFiberNow(const StoredFiber& fiber) const; void scheduleFiberSave(const StoredFiber& fiber); @@ -1175,7 +1315,9 @@ private slots: void finishFiberSaveJob(QFutureWatcher* watcher, bool showErrors, std::vector> batches); - void waitForFiberSaves(); + // stillValid (optional): checked after each event loop turn of the + // drain; false stops waiting (the caller decides what that means). + void waitForFiberSaves(const std::function& stillValid = {}); [[nodiscard]] FiberSaveSnapshot makeFiberSaveSnapshot(const StoredFiber& fiber) const; [[nodiscard]] static nlohmann::json fiberSaveSnapshotToJson( const FiberSaveSnapshot& snapshot, @@ -1564,8 +1706,32 @@ private slots: // descriptor knows. void dropLinkEntriesFromOpenSessions(const std::vector& dropped, const std::vector& records); + // The load's automatic-link downgrade (validateLoadedFiberLinks: a link + // automatic on one side only becomes plain pending on both) applied to + // every open session (its live branches and its armed rollback copies): + // an entry the session's own record in `records` holds as a plain + // pending link loses its automatic mark, so the session's next save does + // not write the mark back and have the canonicalizer mirror it onto the + // other file. Entries the record does not hold (unsaved additions) are + // left alone; nothing is ever upgraded. Affected dialogs rebuild their + // markers. + void downgradeAutomaticLinksInOpenSessions(const std::vector& records); bool structuralEditPreflight(uint64_t packageGeneration, uint64_t loadSequence, std::string* error); + // Links two STORED control points (stored indices) of two loaded fibers + // with a pending link: duplicate / already-linked / position-ambiguity / + // rebind checks, both refs (makeBranchRef + reciprocalBranchRef), the pair + // save, metrics, summaries and views. The error names what refused. + std::optional linkStoredControlPoints(StoredFiber& local, + int localControlIndex, + StoredFiber& far, + int farControlIndex, + bool autoLink); + // The pair-level part of linkStoredControlPoints' checks (same fiber, + // read-only, the loader's source rebind), so an operation can refuse + // BEFORE it mutates anything. + [[nodiscard]] std::optional linkPairEligibilityError(const StoredFiber& local, + const StoredFiber& far) const; [[nodiscard]] bool structuralEditParticipantAllowed(const std::filesystem::path& sourceRoot, const std::string& fileName, std::string* error) const; diff --git a/volume-cartographer/apps/VC3D/LineAnnotationDialog.cpp b/volume-cartographer/apps/VC3D/LineAnnotationDialog.cpp index c7369e1f85..5029cf0f49 100644 --- a/volume-cartographer/apps/VC3D/LineAnnotationDialog.cpp +++ b/volume-cartographer/apps/VC3D/LineAnnotationDialog.cpp @@ -506,6 +506,12 @@ class LineAnnotationOverviewBar final : public QWidget std::optional edge; // Dotted edge (the break ring). bool dottedEdge = false; + // Dashed edge: an unreviewed automatic pending link on an untagged + // point (the cut views' dashed outline). + bool dashedEdge = false; + // A tagged automatic pending point keeps its tag edge and gets this + // separate dashed ring, two units outside, in the pending colour. + std::optional linkRing; qreal radius = 4.5; // Adjacent-winding link (or adjacent candidate): drawn as a triangle. bool triangle = false; @@ -637,6 +643,8 @@ class LineAnnotationOverviewBar final : public QWidget QPen edgePen = dot.edge ? QPen(*dot.edge, 1.5) : QPen(dot.color.darker(150), 1.0); if (dot.dottedEdge) { edgePen.setStyle(Qt::DotLine); + } else if (dot.dashedEdge) { + edgePen.setStyle(Qt::DashLine); } painter.setPen(edgePen); painter.setBrush(dot.color); @@ -646,6 +654,13 @@ class LineAnnotationOverviewBar final : public QWidget } else { painter.drawEllipse(QPointF(x, midY), dot.radius, dot.radius); } + if (dot.linkRing) { + QPen ringPen(*dot.linkRing, 1.5); + ringPen.setStyle(Qt::DashLine); + painter.setPen(ringPen); + painter.setBrush(Qt::NoBrush); + painter.drawEllipse(QPointF(x, midY), dot.radius + 2.0, dot.radius + 2.0); + } } } @@ -5394,6 +5409,10 @@ void LineAnnotationDialog::updateGeneratedDynamicOverlaysFast(bool updateCurrent !_fastCurrentCutOverlayItems.pendingBranchControlPoints || !_fastCurrentCutOverlayItems.sameHvBranchControlPoints || !_fastCurrentCutOverlayItems.sameHvPendingBranchControlPoints || + !_fastCurrentCutOverlayItems.autoPendingBranchControlPoints || + !_fastCurrentCutOverlayItems.sameHvAutoPendingBranchControlPoints || + !_fastCurrentCutOverlayItems.autoPendingLinkRings || + !_fastCurrentCutOverlayItems.sameHvAutoPendingLinkRings || !_fastCurrentCutOverlayItems.kollesisRings || !_fastCurrentCutOverlayItems.breakRings || !_fastCurrentCutOverlayItems.fiberIntersections || @@ -5477,6 +5496,30 @@ void LineAnnotationDialog::updateGeneratedDynamicOverlaysFast(bool updateCurrent sameHvPendingBranchControlBrush); _fastCurrentCutOverlayItems.sameHvPendingBranchControlPoints->setZValue(162.5); + // Unreviewed automatic pending links: the two pending palettes with a + // dashed outline (untagged points), and a dashed hollow ring for + // tagged points, drawn outside their tag ring. + const auto makeDashed = [](QGraphicsPathItem* item, QPen pen, const QBrush& brush, + qreal z) { + pen.setStyle(Qt::DashLine); + item->setPen(pen); + item->setBrush(brush); + item->setZValue(z); + }; + _fastCurrentCutOverlayItems.autoPendingBranchControlPoints = new QGraphicsPathItem(); + makeDashed(_fastCurrentCutOverlayItems.autoPendingBranchControlPoints, + pendingBranchControlPen, pendingBranchControlBrush, 162.51); + _fastCurrentCutOverlayItems.sameHvAutoPendingBranchControlPoints = + new QGraphicsPathItem(); + makeDashed(_fastCurrentCutOverlayItems.sameHvAutoPendingBranchControlPoints, + sameHvPendingBranchControlPen, sameHvPendingBranchControlBrush, 162.51); + _fastCurrentCutOverlayItems.autoPendingLinkRings = new QGraphicsPathItem(); + makeDashed(_fastCurrentCutOverlayItems.autoPendingLinkRings, + pendingBranchControlPen, QBrush(Qt::NoBrush), 162.8); + _fastCurrentCutOverlayItems.sameHvAutoPendingLinkRings = new QGraphicsPathItem(); + makeDashed(_fastCurrentCutOverlayItems.sameHvAutoPendingLinkRings, + sameHvPendingBranchControlPen, QBrush(Qt::NoBrush), 162.8); + // Same look as controlStyleForMarker's kollesis branch in the shared // renderer: ring only, above every link-state fill. QPen kollesisRingPen(vc3d::line_annotation::generatedKollesisTerminationColor(245)); @@ -5562,6 +5605,10 @@ void LineAnnotationDialog::updateGeneratedDynamicOverlaysFast(bool updateCurrent _fastCurrentCutOverlayItems.pendingBranchControlPoints, _fastCurrentCutOverlayItems.sameHvBranchControlPoints, _fastCurrentCutOverlayItems.sameHvPendingBranchControlPoints, + _fastCurrentCutOverlayItems.autoPendingBranchControlPoints, + _fastCurrentCutOverlayItems.sameHvAutoPendingBranchControlPoints, + _fastCurrentCutOverlayItems.autoPendingLinkRings, + _fastCurrentCutOverlayItems.sameHvAutoPendingLinkRings, _fastCurrentCutOverlayItems.kollesisRings, _fastCurrentCutOverlayItems.breakRings, _fastCurrentCutOverlayItems.fiberIntersections, @@ -5622,6 +5669,10 @@ void LineAnnotationDialog::updateGeneratedDynamicOverlaysFast(bool updateCurrent QPainterPath pendingBranchControlPath; QPainterPath sameHvBranchControlPath; QPainterPath sameHvPendingBranchControlPath; + QPainterPath autoPendingBranchControlPath; + QPainterPath sameHvAutoPendingBranchControlPath; + QPainterPath autoPendingLinkRingPath; + QPainterPath sameHvAutoPendingLinkRingPath; QPainterPath kollesisRingPath; QPainterPath breakRingPath; const double lineRadius = @@ -5697,8 +5748,24 @@ void LineAnnotationDialog::updateGeneratedDynamicOverlaysFast(bool updateCurrent } }; if (control.hasPendingLinks) { - addLinkedMarker(control.hasSameHvPendingLinks ? sameHvPendingBranchControlPath - : pendingBranchControlPath); + // The dashed outline of an automatic pending point belongs to an + // untagged marker; a tagged one keeps its tag ring and gets the + // separate dashed link ring (linkedMarkerDashed, shared rule). + const bool tagged = control.isKollesisTermination || control.isBreak; + const bool dashed = vc3d::line_annotation::linkedMarkerDashed( + true, control.hasAutoPendingLinks); + if (dashed && !tagged) { + addLinkedMarker(control.hasSameHvPendingLinks ? sameHvAutoPendingBranchControlPath + : autoPendingBranchControlPath); + } else { + addLinkedMarker(control.hasSameHvPendingLinks ? sameHvPendingBranchControlPath + : pendingBranchControlPath); + } + if (dashed && tagged) { + (control.hasSameHvPendingLinks ? sameHvAutoPendingLinkRingPath + : autoPendingLinkRingPath) + .addEllipse(scenePoint, baseRadius + 5.0, baseRadius + 5.0); + } } else if (control.hasBranches) { addLinkedMarker(control.hasSameHvBranches ? sameHvBranchControlPath : branchControlPath); @@ -5708,6 +5775,13 @@ void LineAnnotationDialog::updateGeneratedDynamicOverlaysFast(bool updateCurrent controlPath.addEllipse(scenePoint, 10.0, 10.0); } } + _fastCurrentCutOverlayItems.autoPendingBranchControlPoints->setPath( + autoPendingBranchControlPath); + _fastCurrentCutOverlayItems.sameHvAutoPendingBranchControlPoints->setPath( + sameHvAutoPendingBranchControlPath); + _fastCurrentCutOverlayItems.autoPendingLinkRings->setPath(autoPendingLinkRingPath); + _fastCurrentCutOverlayItems.sameHvAutoPendingLinkRings->setPath( + sameHvAutoPendingLinkRingPath); _fastCurrentCutOverlayItems.controlPoints->setPath(controlPath); _fastCurrentCutOverlayItems.seedPoints->setPath(seedPath); _fastCurrentCutOverlayItems.linkCandidatePoints->setPath(linkCandidatePath); @@ -6733,6 +6807,14 @@ void LineAnnotationDialog::rebuildOverviewBarLayout() dot.radius = control.isSeed ? 6.0 : 4.5; dot.triangle = control.isAdjacentLinkCandidate || (control.hasAdjacentLinks && !control.isLinkCandidate); + // Automatic pending point: dashed edge on an untagged dot, a separate + // dashed ring in the pending colour on a tagged one (set below). + const bool autoPending = !control.isLinkCandidate && control.hasPendingLinks && + vc3d::line_annotation::linkedMarkerDashed(true, control.hasAutoPendingLinks); + const QColor pendingColor = dot.color; + if (autoPending && !(control.isKollesisTermination || control.isBreak)) { + dot.dashedEdge = true; + } if (control.isKollesisTermination && !control.isLinkCandidate) { // As in the cut views: a hollow yellow ring when unlinked, the // link fill inside a yellow ring when linked. @@ -6750,6 +6832,9 @@ void LineAnnotationDialog::rebuildOverviewBarLayout() dot.dottedEdge = true; dot.radius += 1.0; } + if (autoPending && (control.isKollesisTermination || control.isBreak)) { + dot.linkRing = pendingColor; + } dots.push_back(dot); } bar->setLineData(_generatedViews.linePoints.size(), diff --git a/volume-cartographer/apps/VC3D/LineAnnotationDialog.hpp b/volume-cartographer/apps/VC3D/LineAnnotationDialog.hpp index 9e9f49d584..728c7abe95 100644 --- a/volume-cartographer/apps/VC3D/LineAnnotationDialog.hpp +++ b/volume-cartographer/apps/VC3D/LineAnnotationDialog.hpp @@ -91,6 +91,13 @@ class LineAnnotationDialog : public QMainWindow QGraphicsPathItem* pendingBranchControlPoints = nullptr; QGraphicsPathItem* sameHvBranchControlPoints = nullptr; QGraphicsPathItem* sameHvPendingBranchControlPoints = nullptr; + // Unreviewed automatic pending links (dashed outline, the two pending + // palettes), and the separate dashed link ring a TAGGED automatic + // pending point gets next to its tag ring. + QGraphicsPathItem* autoPendingBranchControlPoints = nullptr; + QGraphicsPathItem* sameHvAutoPendingBranchControlPoints = nullptr; + QGraphicsPathItem* autoPendingLinkRings = nullptr; + QGraphicsPathItem* sameHvAutoPendingLinkRings = nullptr; // Hollow yellow rings of kollesis-tagged points, drawn over whichever // fill (link state) the point keeps; an unlinked tagged point has no // fill at all. diff --git a/volume-cartographer/apps/VC3D/LineAnnotationFiberLinks.hpp b/volume-cartographer/apps/VC3D/LineAnnotationFiberLinks.hpp index 75c91a219d..b79aa250e6 100644 --- a/volume-cartographer/apps/VC3D/LineAnnotationFiberLinks.hpp +++ b/volume-cartographer/apps/VC3D/LineAnnotationFiberLinks.hpp @@ -20,6 +20,7 @@ #include #include +#include #include #include "FiberSliceGeometry.hpp" @@ -161,6 +162,131 @@ inline cv::Vec3d endpointTangentFromLinePoints(const std::vector& lin return finiteDirection(fallback) ? normalizedOrZero(fallback) : cv::Vec3d{0.0, 0.0, 0.0}; } +// Review-state invariant of FiberBranchRef: an automatic link is a pending, +// ordinary (non-adjacent) link. Applied at every boundary a ref crosses. +inline void normalizeBranchRefReviewState(FiberBranchRef& ref) +{ + ref.autoLink = ref.autoLink && ref.pending && !ref.adjacent; +} + +// The review state of two refs of ONE link combined (a dedup, a collapse +// of two control points): pending if either is pending; automatic only if +// every PENDING contributor is automatic (a human pending link outranks, +// an approved contributor says nothing about it); never adjacent-automatic. +struct CombinedReviewState { + bool pending = false; + bool autoLink = false; +}; +inline CombinedReviewState combineReviewStates(bool pendingA, bool autoA, + bool pendingB, bool autoB, + bool adjacent) +{ + CombinedReviewState state; + state.pending = pendingA || pendingB; + const bool humanPending = (pendingA && !autoA) || (pendingB && !autoB); + state.autoLink = state.pending && !humanPending && (autoA || autoB) && !adjacent; + return state; +} + +// The two JSON review-state keys of a link entry: "pending" and, only with +// it and only on an ordinary link, "auto". Written true-or-absent, never +// false (the same convention fiber_merge.py's snap_flag keeps). +inline void writeBranchReviewStateJson(nlohmann::json& entry, const FiberBranchRef& ref) +{ + if (!ref.pending) { + return; + } + entry["pending"] = true; + if (ref.autoLink && !ref.adjacent) { + entry["auto"] = true; + } +} + +struct BranchReviewState { + bool pending = false; + bool autoLink = false; +}; + +inline BranchReviewState readBranchReviewStateJson(const nlohmann::json& entry, bool adjacent) +{ + BranchReviewState state; + state.pending = entry.value("pending", false); + state.autoLink = state.pending && !adjacent && entry.value("auto", false); + return state; +} + +// Field assembly of a directed link entry and of its reciprocal. Callers +// supply the directions (the candidate path uses endpointTangentFromLinePoints +// with the pair's fallback, the seed path keeps its requested direction). +inline FiberBranchRef makeBranchRef(int localIndex, + const cv::Vec3d& localPoint, + const cv::Vec3d& localDirection, + uint64_t farFiberId, + const std::string& farFileName, + int farIndex, + const cv::Vec3d& farPoint, + const cv::Vec3d& farDirection, + bool pending, + bool adjacent, + bool autoLink) +{ + FiberBranchRef ref; + ref.controlPointIndex = localIndex; + ref.branchFiberId = farFiberId; + ref.branchControlPointIndex = farIndex; + ref.branchFileName = farFileName; + ref.controlPointDirection = localDirection; + ref.branchControlPointDirection = farDirection; + ref.controlPointPosition = localPoint; + ref.branchControlPointPosition = farPoint; + ref.pending = pending; + ref.adjacent = adjacent; + ref.autoLink = autoLink; + normalizeBranchRefReviewState(ref); + return ref; +} + +inline FiberBranchRef reciprocalBranchRef(const FiberBranchRef& ref, + uint64_t ownerFiberId, + const std::string& ownerFileName) +{ + FiberBranchRef reciprocal; + reciprocal.controlPointIndex = ref.branchControlPointIndex; + reciprocal.branchFiberId = ownerFiberId; + reciprocal.branchControlPointIndex = ref.controlPointIndex; + reciprocal.branchFileName = ownerFileName; + reciprocal.controlPointDirection = ref.branchControlPointDirection; + reciprocal.branchControlPointDirection = ref.controlPointDirection; + reciprocal.controlPointPosition = ref.branchControlPointPosition; + reciprocal.branchControlPointPosition = ref.controlPointPosition; + reciprocal.pending = ref.pending; + reciprocal.adjacent = ref.adjacent; + reciprocal.autoLink = ref.autoLink; + normalizeBranchRefReviewState(reciprocal); + return reciprocal; +} + +// The one marker rule for a control point carrying several links: it draws +// as an AUTOMATIC pending point (dashed) only when it has at least one +// pending link and every pending link on it is automatic - a human-made +// pending link outranks. Works over any range of items with `pending` and +// `autoLink` members (FiberBranchRef, the overlay BranchLink, ...). +template +bool autoPendingMarker(const Links& links) +{ + bool anyPending = false; + for (const auto& link : links) { + if (!link.pending) { + continue; + } + anyPending = true; + if (!link.autoLink) { + return false; + } + } + return anyPending; +} + // Save-time canonicalization of a batch of snapshots (any type with a // `StoredFiber fiber` member): local link endpoints are re-resolved on the // fiber's own control points and their directions recomputed from its line; @@ -190,6 +316,7 @@ void canonicalizeFiberSaveSnapshots(std::vector& snapshots) branch.controlPointPosition = snapshot.fiber.controlPoints[static_cast(*localIndex)]; } + normalizeBranchRefReviewState(branch); branch.controlPointDirection = endpointTangentFromLinePoints(snapshot.fiber.linePoints, branch.controlPointPosition, @@ -270,7 +397,10 @@ void canonicalizeFiberSaveSnapshots(std::vector& snapshots) reciprocal->branchControlPointPosition = branch.controlPointPosition; reciprocal->controlPointDirection = branch.branchControlPointDirection; reciprocal->branchControlPointDirection = branch.controlPointDirection; + // Review state follows the snapshot being saved, both flags. reciprocal->pending = branch.pending; + reciprocal->autoLink = branch.autoLink; + normalizeBranchRefReviewState(*reciprocal); } } } diff --git a/volume-cartographer/apps/VC3D/LineAnnotationGeneratedViews.cpp b/volume-cartographer/apps/VC3D/LineAnnotationGeneratedViews.cpp index 42e212dcd6..50b0cdecca 100644 --- a/volume-cartographer/apps/VC3D/LineAnnotationGeneratedViews.cpp +++ b/volume-cartographer/apps/VC3D/LineAnnotationGeneratedViews.cpp @@ -457,6 +457,24 @@ std::string applyGeneratedOverlay(CChunkedVolumeViewer* viewer, const auto drawsTagRing = [&](const GeneratedOverlay::ControlPointMarker& control) { return drawsKollesisRing(control) || drawsBreakRing(control); }; + // The separate dashed link ring of a TAGGED automatic pending point: the + // tag ring (kollesis / break) keeps the marker's outline, so the link + // state gets its own glyph two units outside it (the fast current-cut + // renderer and the overview bar draw the same pair). + const auto drawsAutoPendingLinkRing = [&](const GeneratedOverlay::ControlPointMarker& control) { + return control.hasPendingLinks && !control.isLinkCandidate && + linkedMarkerDashed(true, control.hasAutoPendingLinks) && drawsTagRing(control); + }; + auto autoPendingLinkRingStyle = [&](const GeneratedOverlay::ControlPointMarker& control) + -> ViewerOverlayControllerBase::OverlayStyle { + ViewerOverlayControllerBase::OverlayStyle style = control.hasSameHvPendingLinks + ? sameHvPendingBranchControlPointStyle + : pendingBranchControlPointStyle; + style.brushColor = Qt::transparent; + style.penStyle = Qt::DashLine; + style.z += 0.3; + return style; + }; // Triangle instead of circle: an adjacent-winding link on the point, or // the point designated as an adjacent link candidate (a split candidate // keeps its own red circle). @@ -474,6 +492,13 @@ std::string applyGeneratedOverlay(CChunkedVolumeViewer* viewer, if (control.hasPendingLinks) { style = control.hasSameHvPendingLinks ? sameHvPendingBranchControlPointStyle : pendingBranchControlPointStyle; + // An unreviewed automatic link: same colours, dashed outline. A + // tagged point keeps its tag ring and gets a separate dashed + // link ring instead (autoPendingLinkRingStyle below). + if (linkedMarkerDashed(true, control.hasAutoPendingLinks) && + !drawsTagRing(control)) { + style.penStyle = Qt::DashLine; + } } else if (control.hasBranches) { style = control.hasSameHvBranches ? sameHvBranchControlPointStyle : branchControlPointStyle; @@ -741,6 +766,15 @@ std::string applyGeneratedOverlay(CChunkedVolumeViewer* viewer, primitives.push_back(ViewerOverlayControllerBase::CirclePrimitive{ controlScene, radius, true, controlStyleForMarker(control)}); } + if (drawsAutoPendingLinkRing(control)) { + // The strip's tagged automatic point: the same + // separate dashed link ring as the cut views, scaled + // to the strip's marker size. + // Clear of the tag stroke (2.5 wide) with the ring's + // own width (2): centre at radius + 3. + primitives.push_back(ViewerOverlayControllerBase::CirclePrimitive{ + controlScene, radius + 3.0, true, autoPendingLinkRingStyle(control)}); + } } } for (const auto& predSnap : overlay.predSnapPoints) { @@ -844,9 +878,17 @@ std::string applyGeneratedOverlay(CChunkedVolumeViewer* viewer, generatedTriangleMarkerPath(controlScene, radius), controlStyleForMarker(control)}); } + if (drawsAutoPendingLinkRing(control)) { + addVolumePointMarker(control.point, radius + 3.0, + autoPendingLinkRingStyle(control)); + } continue; } addVolumePointMarker(control.point, radius, controlStyleForMarker(control)); + if (drawsAutoPendingLinkRing(control)) { + addVolumePointMarker(control.point, radius + 3.0, + autoPendingLinkRingStyle(control)); + } } } @@ -1607,14 +1649,23 @@ GeneratedControlPointContextResult showGeneratedControlPointContextMenu( } }; std::vector pendingLinks; + std::vector autoPendingLinks; std::vector approvedLinks; for (const auto& branch : selectedControl.branchLinks) { - (branch.pending ? pendingLinks : approvedLinks).push_back(branch); + (branch.pending ? (branch.autoLink ? autoPendingLinks : pendingLinks) + : approvedLinks) + .push_back(branch); } addPendingChangeActions(approveActions, pendingLinks, QWidget::tr("Approve link to %1 / CP %2"), QWidget::tr("Approve link")); + // An unreviewed automatic link (the Fiber Map's "Link A and B") says + // so in its approve entry; approving it makes an ordinary approved link. + addPendingChangeActions(approveActions, + autoPendingLinks, + QWidget::tr("Approve auto link to %1 / CP %2"), + QWidget::tr("Approve auto link")); addPendingChangeActions(markPendingActions, approvedLinks, QWidget::tr("Mark link as pending (%1 / CP %2)"), diff --git a/volume-cartographer/apps/VC3D/LineAnnotationGeneratedViews.hpp b/volume-cartographer/apps/VC3D/LineAnnotationGeneratedViews.hpp index 6baf0547fa..ab259d5f1d 100644 --- a/volume-cartographer/apps/VC3D/LineAnnotationGeneratedViews.hpp +++ b/volume-cartographer/apps/VC3D/LineAnnotationGeneratedViews.hpp @@ -84,6 +84,8 @@ struct GeneratedOverlay { bool pending = false; // Adjacent-winding link (FiberBranchRef::adjacent). bool adjacent = false; + // Unreviewed automatic link (FiberBranchRef::autoLink). + bool autoLink = false; }; size_t controlIndex = std::numeric_limits::max(); @@ -103,6 +105,9 @@ struct GeneratedOverlay { bool hasDamagedToNext = false; bool hasBranches = false; bool hasPendingLinks = false; + // Every pending link on this point is an unreviewed automatic link + // (autoPendingMarker): the pending marker draws with a dashed outline. + bool hasAutoPendingLinks = false; // Same-orientation links (H-H / V-V) render in the orange warning // palette; H-V links keep the default blue/purple. Set by the // controller, which owns the fiber HV state. @@ -1875,6 +1880,15 @@ struct GeneratedLinkCandidateMenuState { // Defined in the .cpp: this header is also compiled into QtCore-only tests. [[nodiscard]] QColor generatedLinkStateColor(bool pending, bool sameHv, int alpha); +// Marker outline rule shared by every renderer (generated views, the fast +// current-cut path, the overview bar, the slice overlay, the Fiber Map): +// dashed iff the point draws in a pending colour AND is an automatic pending +// point (ControlPointMarker::hasAutoPendingLinks, autoPendingMarker). +inline bool linkedMarkerDashed(bool drawsPending, bool autoPending) +{ + return drawsPending && autoPending; +} + // The kollesis-termination ring colour: the ordinary control-point yellow. // The tag is told apart by form, not hue: a tagged point draws as a hollow // yellow ring (a linked one keeps its link-state fill inside the ring). diff --git a/volume-cartographer/apps/VC3D/LineAnnotationStoredFiber.hpp b/volume-cartographer/apps/VC3D/LineAnnotationStoredFiber.hpp index 0482ebcfac..d4b871b5fd 100644 --- a/volume-cartographer/apps/VC3D/LineAnnotationStoredFiber.hpp +++ b/volume-cartographer/apps/VC3D/LineAnnotationStoredFiber.hpp @@ -51,6 +51,12 @@ struct FiberBranchRef { // for a link (delete and re-link to change), mirrored on both // reciprocal refs. bool adjacent = false; + // Created by the Fiber Map's automatic link ("Link A and B") and not yet + // reviewed: drawn with a dashed marker in the pending colours. Invariant + // `autoLink => pending && !adjacent`, enforced wherever a ref is read, + // merged or written; approving the link clears it with `pending`. + // Mirrored on both reciprocal refs like `pending` (JSON key "auto"). + bool autoLink = false; }; struct StoredFiber { diff --git a/volume-cartographer/apps/VC3D/overlays/FiberOverlayController.cpp b/volume-cartographer/apps/VC3D/overlays/FiberOverlayController.cpp index 5c254d3b14..58938013ac 100644 --- a/volume-cartographer/apps/VC3D/overlays/FiberOverlayController.cpp +++ b/volume-cartographer/apps/VC3D/overlays/FiberOverlayController.cpp @@ -209,7 +209,7 @@ void FiberOverlayController::collectPrimitives(VolumeViewerBase* viewer, const uint64_t colorId = (_showLinked && chain.colorId != 0) ? chain.colorId : chain.id; const PointChainStyle style = fiberStyle(fiberColor(colorId), _viewDistance); - const bool needsRings = _showLinked && !chain.pointLinkStates.empty(); + const bool needsRings = _showLinked && !chain.pointLinkMarks.empty(); if (!needsRings) { renderPointChain(viewer, builder, chain.points, style); continue; @@ -238,24 +238,68 @@ void FiberOverlayController::collectPrimitives(VolumeViewerBase* viewer, continue; } const std::size_t sourceIndex = filtered.sourceIndices[i]; - if (sourceIndex >= chain.pointLinkStates.size()) { + if (sourceIndex >= chain.pointLinkMarks.size()) { continue; } - const uint8_t state = chain.pointLinkStates[sourceIndex]; - if (state == 0) { + const PointLinkMark& mark = chain.pointLinkMarks[sourceIndex]; + if (mark.state == 0) { continue; } - const bool pending = state == 1; + // The Line Annotation GUI's palettes: blue / purple for H-V + // links, the orange warning pair for same-H/V links, pending vs + // approved; an automatic pending point draws dashed. + const bool pending = mark.state == 1 || mark.state == 3; + const bool automatic = mark.state == 3; + const bool tagged = mark.kollesisTermination || mark.breakTag; OverlayStyle style; - style.penColor = pending ? QColor(80, 150, 255, 245) - : QColor(210, 95, 255, 245); - style.brushColor = pending ? QColor(80, 150, 255, 175) - : QColor(210, 95, 255, 175); + if (pending) { + style.penColor = mark.sameHv ? QColor(255, 190, 120, 245) : QColor(80, 150, 255, 245); + style.brushColor = mark.sameHv ? QColor(255, 190, 120, 175) : QColor(80, 150, 255, 175); + } else { + style.penColor = mark.sameHv ? QColor(255, 140, 0, 245) : QColor(210, 95, 255, 245); + style.brushColor = mark.sameHv ? QColor(255, 140, 0, 175) : QColor(210, 95, 255, 175); + } + const QColor linkPen = style.penColor; style.penColor.setAlphaF(style.penColor.alphaF() * opacity); style.brushColor.setAlphaF(style.brushColor.alphaF() * opacity); style.penWidth = 2.0; + if (automatic && !tagged) { + style.penStyle = Qt::DashLine; + } + if (tagged) { + // The tag ring replaces the outline, the link fill stays. + style.penColor = mark.kollesisTermination ? QColor(255, 230, 0, 245) + : QColor(255, 190, 60, 245); + style.penColor.setAlphaF(style.penColor.alphaF() * opacity); + style.penWidth = 2.5; + style.penStyle = mark.breakTag ? Qt::DotLine : Qt::SolidLine; + } style.z = 95.0; - builder.addPoint(filtered.scenePoints[i], 6.25, style); + const qreal radius = tagged ? 7.25 : 6.25; + const QPointF& center = filtered.scenePoints[i]; + if (mark.adjacent) { + QPainterPath triangle; + const qreal r = radius; + triangle.moveTo(center.x(), center.y() - r); + triangle.lineTo(center.x() + r * 0.8660254, center.y() + r * 0.5); + triangle.lineTo(center.x() - r * 0.8660254, center.y() + r * 0.5); + triangle.closeSubpath(); + builder.addPainterPath(triangle, style); + } else { + builder.addPoint(center, radius, style); + } + if (automatic && tagged) { + // The separate dashed link ring of a tagged automatic point, + // clear of the tag stroke. + OverlayStyle ring; + ring.penColor = linkPen; + ring.penColor.setAlphaF(ring.penColor.alphaF() * opacity); + ring.brushColor = Qt::transparent; + ring.penWidth = 2.0; + ring.penStyle = Qt::DashLine; + ring.z = 95.1; + builder.addPoint(center, radius + 3.5, ring); + } } } } diff --git a/volume-cartographer/apps/VC3D/overlays/FiberOverlayController.hpp b/volume-cartographer/apps/VC3D/overlays/FiberOverlayController.hpp index ea949d811a..de70e2f7c7 100644 --- a/volume-cartographer/apps/VC3D/overlays/FiberOverlayController.hpp +++ b/volume-cartographer/apps/VC3D/overlays/FiberOverlayController.hpp @@ -13,13 +13,27 @@ class FiberOverlayController final : public ViewerOverlayControllerBase Q_OBJECT public: + // The look of one linked control point, as the Line Annotation views + // draw it: state 0 none, 1 pending, 2 approved, 3 pending automatic + // (pending colours, dashed outline); sameHv = the orange same-H/V + // warning palette; the point tags keep their ring (yellow kollesis, + // dotted amber break) and an automatic point then gets a separate + // dashed ring; adjacent = triangle instead of circle. + struct PointLinkMark { + uint8_t state = 0; + bool sameHv = false; + bool kollesisTermination = false; + bool breakTag = false; + bool adjacent = false; + }; struct Chain { uint64_t id = 0; std::vector points; // Color-group representative when "show linked" is active; 0 = use id. uint64_t colorId = 0; - // Parallel to points (may be empty): 0 none, 1 pending link, 2 approved. - std::vector pointLinkStates; + // Parallel to points (may be empty): the linked control points' + // marks (PointLinkMark), state 0 for an unlinked point. + std::vector pointLinkMarks; }; explicit FiberOverlayController(QObject* parent = nullptr); diff --git a/volume-cartographer/apps/VC3D/test/CMakeLists.txt b/volume-cartographer/apps/VC3D/test/CMakeLists.txt index 6d38bfa477..19f4127c1f 100644 --- a/volume-cartographer/apps/VC3D/test/CMakeLists.txt +++ b/volume-cartographer/apps/VC3D/test/CMakeLists.txt @@ -500,6 +500,31 @@ add_test(NAME fiber_global_layout COMMAND test_fiber_global_layout) vc_register_test_target(test_fiber_global_layout) vc_register_ctest(fiber_global_layout test_fiber_global_layout) +add_executable(test_fiber_map_auto_link + test_fiber_map_auto_link.cpp + ${PROJECT_SOURCE_DIR}/apps/VC3D/FiberMapAutoLink.cpp + ${PROJECT_SOURCE_DIR}/apps/VC3D/FiberMapAutoLink.hpp + ${PROJECT_SOURCE_DIR}/apps/VC3D/FiberNetworkLayout.cpp + ${PROJECT_SOURCE_DIR}/apps/VC3D/FiberNetworkLayout.hpp + ${PROJECT_SOURCE_DIR}/apps/VC3D/FiberWindingSolver.cpp + ${PROJECT_SOURCE_DIR}/apps/VC3D/FiberWindingSolver.hpp) + +set_target_properties(test_fiber_map_auto_link PROPERTIES + AUTOMOC ON AUTOUIC OFF AUTORCC OFF) + +target_include_directories(test_fiber_map_auto_link PRIVATE + ${PROJECT_SOURCE_DIR}/apps/VC3D + ${OpenCV_INCLUDE_DIRS}) + +target_link_libraries(test_fiber_map_auto_link PRIVATE + Qt6::Core + Qt6::Test + opencv_core) + +add_test(NAME fiber_map_auto_link COMMAND test_fiber_map_auto_link) +vc_register_test_target(test_fiber_map_auto_link) +vc_register_ctest(fiber_map_auto_link test_fiber_map_auto_link) + add_executable(test_fiber_map_gap_field test_fiber_map_gap_field.cpp ${PROJECT_SOURCE_DIR}/apps/VC3D/FiberMapGapField.cpp diff --git a/volume-cartographer/apps/VC3D/test/test_fiber_global_layout.cpp b/volume-cartographer/apps/VC3D/test/test_fiber_global_layout.cpp index 64aacac225..1163018a4d 100644 --- a/volume-cartographer/apps/VC3D/test/test_fiber_global_layout.cpp +++ b/volume-cartographer/apps/VC3D/test/test_fiber_global_layout.cpp @@ -2222,6 +2222,76 @@ private slots: QVERIFY(!(vc3d::fiber_map::digestGlobalResult(tweaked) == baseline)); } } + + // The automatic-link mark reaches the placed link combined over the + // pair's two refs under its invariant (pending, ordinary) and the + // human-outranks rule: automatic only when every pending ref says so (a + // plain pending reciprocal - a side an older VC3D re-saved - makes the + // pair a plain pending link, as the loader's downgrade does); an auto + // mark on an adjacent ref is dropped; the drawn arclengths and the mark + // are in the output digest. + void autoLinkMarkSurvivesDedupUnderItsInvariant() + { + const std::vector umbilicus = straightUmbilicus(40000); + { + std::vector fibers = adjacentPair(4000.0, 0.0, 0.3 * kTwoPi, 'V'); + fibers[0].links.clear(); + fibers[1].links.clear(); + fibers[0].links.push_back({1, fibers[1].id, 1, true, false, true, true}); + fibers[1].links.push_back({1, fibers[0].id, 1, true, false, true, false}); + const GlobalResult mixed = + vc3d::fiber_map::buildGlobalLayout(fibers, umbilicus, defaultParams()); + QCOMPARE(mixed.links.size(), std::size_t{1}); + QVERIFY(mixed.links.front().pending); + QVERIFY(!mixed.links.front().autoLink); + fibers[1].links.front().autoLink = true; + const GlobalResult result = + vc3d::fiber_map::buildGlobalLayout(fibers, umbilicus, defaultParams()); + QCOMPARE(result.links.size(), std::size_t{1}); + QVERIFY(result.links.front().pending); + QVERIFY(result.links.front().autoLink); + for (const GlobalPlacedFiber& placed : result.fibers) { + for (const auto& run : placed.fiber.runs) { + QCOMPARE(run.arclengths.size(), run.points.size()); + } + QCOMPARE(placed.fiber.controlArclength.size(), placed.fiber.controlPoints.size()); + } + const ContentDigest baseline = vc3d::fiber_map::digestGlobalResult(result); + GlobalResult tweaked = result; + tweaked.links.front().autoLink = false; + QVERIFY(!(vc3d::fiber_map::digestGlobalResult(tweaked) == baseline)); + tweaked = result; + tweaked.fibers.front().fiber.runs.front().arclengths.front() += 1.0; + QVERIFY(!(vc3d::fiber_map::digestGlobalResult(tweaked) == baseline)); + tweaked = result; + tweaked.fibers.front().fiber.offsetRad += 0.5; + QVERIFY(!(vc3d::fiber_map::digestGlobalResult(tweaked) == baseline)); + } + { + std::vector fibers = adjacentPair(4000.0, 150.0, 0.3 * kTwoPi, 'V'); + fibers[0].links.clear(); + fibers[1].links.clear(); + fibers[0].links.push_back({1, fibers[1].id, 1, true, true, true, true}); + fibers[1].links.push_back({1, fibers[0].id, 1, true, true, true, true}); + const GlobalResult result = + vc3d::fiber_map::buildGlobalLayout(fibers, umbilicus, defaultParams()); + QCOMPARE(result.links.size(), std::size_t{1}); + QVERIFY(result.links.front().adjacent); + QVERIFY(!result.links.front().autoLink); + } + { + std::vector fibers = adjacentPair(4000.0, 0.0, 0.3 * kTwoPi, 'V'); + fibers[0].links.clear(); + fibers[1].links.clear(); + fibers[0].links.push_back({1, fibers[1].id, 1, false, false, true, true}); + fibers[1].links.push_back({1, fibers[0].id, 1, false, false, true, true}); + const GlobalResult result = + vc3d::fiber_map::buildGlobalLayout(fibers, umbilicus, defaultParams()); + QCOMPARE(result.links.size(), std::size_t{1}); + QVERIFY(!result.links.front().pending); + QVERIFY(!result.links.front().autoLink); + } + } }; QTEST_APPLESS_MAIN(TestFiberGlobalLayout) diff --git a/volume-cartographer/apps/VC3D/test/test_fiber_map_auto_link.cpp b/volume-cartographer/apps/VC3D/test/test_fiber_map_auto_link.cpp new file mode 100644 index 0000000000..583d8634dc --- /dev/null +++ b/volume-cartographer/apps/VC3D/test/test_fiber_map_auto_link.cpp @@ -0,0 +1,808 @@ +// Coverage for apps/VC3D/FiberMapAutoLink.cpp (the Fiber Map's "Link A and +// B" geometry) and for the layout outputs it rests on: the raw arclength +// correspondence (Run::arclengths, PlacedFiber::controlArclength, +// unrolledLineArclengths), the drawn meeting of two fibers, the exact +// inversion to a source line position, the bounded closest 3D pass and the +// control-point choice. + +#include + +#include +#include +#include +#include +#include +#include + +#include "FiberMapAutoLink.hpp" +#include "FiberNetworkLayout.hpp" + +using vc3d::fiber_map::GlobalLayoutParams; +using vc3d::fiber_map::GlobalPlacedFiber; +using vc3d::fiber_map::GlobalResult; +using vc3d::fiber_map::InputFiber; +using vc3d::fiber_map::PlacedFiber; +using vc3d::fiber_map::Run; +namespace autolink = vc3d::fiber_map::autolink; + +namespace +{ + +constexpr double kTwoPi = 2.0 * M_PI; +constexpr double kVxPerCm = 10000.0 / 2.4; + +constexpr double vx(double centimetres) +{ + return centimetres * kVxPerCm; +} + +std::vector straightUmbilicus(int zMax) +{ + std::vector centers; + for (int z = 0; z <= zMax; ++z) { + centers.push_back(cv::Vec3f(0.0f, 0.0f, static_cast(z))); + } + return centers; +} + +InputFiber makeFiber(uint64_t id, const QString& label, char hvTag, + std::vector linePoints, const std::vector& controlIndices) +{ + InputFiber fiber; + fiber.id = id; + fiber.fileName = label.toStdString() + ".json"; + fiber.label = label; + fiber.hvTag = hvTag; + fiber.linePoints = std::move(linePoints); + for (int index : controlIndices) { + if (index < 0 || static_cast(index) >= fiber.linePoints.size()) { + throw std::out_of_range("makeFiber: control index out of range"); + } + fiber.controlPoints.push_back(fiber.linePoints[static_cast(index)]); + } + if (fiber.controlPoints.size() > 1) { + fiber.tracedSegments.assign(fiber.controlPoints.size() - 1, true); + } + return fiber; +} + +GlobalLayoutParams defaultParams() +{ + GlobalLayoutParams params; + params.smoothVx = 0.0; + params.resampleStepVx = vx(0.025); + params.minPadXVx = vx(2.2); + params.minPadYVx = vx(1.6); + return params; +} + +// A two-turn H helix (radius growing by `radiusPerTurn` per turn) sampled +// every `step` radians, and a V fiber at `angle` touching the OUTER turn. +std::vector helix(double radius0, double radiusPerTurn, double turns, double z, + double step) +{ + std::vector points; + const int count = static_cast(std::floor(turns * kTwoPi / step)) + 1; + for (int i = 0; i < count; ++i) { + const double theta = static_cast(i) * step; + const double r = radius0 + radiusPerTurn * theta / kTwoPi; + points.push_back(cv::Vec3d(r * std::cos(theta), r * std::sin(theta), z)); + } + return points; +} + +std::vector vertical(double theta, double radius, double zBegin, double zEnd, + double zStep) +{ + std::vector points; + const int count = static_cast(std::floor((zEnd - zBegin) / zStep)) + 1; + for (int i = 0; i < count; ++i) { + const double z = zBegin + static_cast(i) * zStep; + points.push_back(cv::Vec3d(radius * std::cos(theta), radius * std::sin(theta), z)); + } + return points; +} + +const GlobalPlacedFiber* placedById(const GlobalResult& result, uint64_t id) +{ + for (const GlobalPlacedFiber& placed : result.fibers) { + if (placed.fiber.id == id) { + return &placed; + } + } + return nullptr; +} + +PlacedFiber straightRun(const std::vector& points, const std::vector& arclengths) +{ + PlacedFiber fiber; + Run run; + run.firstControl = 0; + run.lastControl = 1; + run.points = points; + run.arclengths = arclengths; + fiber.runs.push_back(run); + fiber.controlPoints = {points.front(), points.back()}; + fiber.controlArclength = {arclengths.front(), arclengths.back()}; + return fiber; +} + +std::vector arcs(const std::vector& points) +{ + std::vector s(points.size(), 0.0); + for (std::size_t i = 1; i < points.size(); ++i) { + const QPointF d = points[i] - points[i - 1]; + s[i] = s[i - 1] + std::sqrt(QPointF::dotProduct(d, d)); + } + return s; +} + +} // namespace + +class TestFiberMapAutoLink : public QObject +{ + Q_OBJECT + +private slots: + // The arclength the layout resampled on is what unrolledLineArclengths + // recomputes from the stored inputs, bitwise, and every drawn sample's + // arclength is one of that parameterisation. + void unrolledArclengthMatchesTheLayout() + { + const std::vector umbilicus = straightUmbilicus(40000); + std::vector fibers; + fibers.push_back(makeFiber(1, QStringLiteral("h"), 'H', + helix(4000.0, 300.0, 1.5, 30000.0, kTwoPi / 1256.0), + {0, 600, 1200, 1800})); + fibers.push_back(makeFiber(2, QStringLiteral("v"), 'V', + vertical(0.3 * kTwoPi, 4000.0 + 300.0 * 0.3, 29000.0, + 31000.0, 25.0), + {0, 40, 80})); + // Smoothing ON (the production default, 1.2 mm) and a styled run + // (a gap span): the correspondence must hold through the resampling + // and through displayRunPoints' endpoint replacement. + GlobalLayoutParams params = defaultParams(); + params.smoothVx = vx(0.12); + fibers[0].gapSegments = {false, true, false}; + const GlobalResult result = + vc3d::fiber_map::buildGlobalLayout(fibers, umbilicus, params); + const GlobalPlacedFiber* h = placedById(result, 1); + QVERIFY(h != nullptr); + const std::vector raw = vc3d::fiber_map::unrolledLineArclengths( + fibers[0].linePoints, umbilicus, h->fiber.thetaScale, h->fiber.offsetRad, + result.rRefVx); + QCOMPARE(raw.size(), fibers[0].linePoints.size()); + QVERIFY(std::is_sorted(raw.begin(), raw.end())); + // Every control's arclength is BITWISE the raw arclength of its line + // point (buildFiberGeometry reads controls off at rawArclength[index]). + const auto sameBits = [](double x, double y) { + return std::memcmp(&x, &y, sizeof(double)) == 0; + }; + QCOMPARE(h->fiber.controlArclength.size(), std::size_t{4}); + QVERIFY(sameBits(h->fiber.controlArclength[0], raw[0])); + QVERIFY(sameBits(h->fiber.controlArclength[1], raw[600])); + QVERIFY(sameBits(h->fiber.controlArclength[2], raw[1200])); + QVERIFY(sameBits(h->fiber.controlArclength[3], raw[1800])); + // Every run carries an arclength per drawn sample, monotone, and a + // drawn sample's point is where the unrolled source line is at that + // arclength (within the smoothing's own displacement: the resampled + // curve is parameterised by the raw arclength). + QVERIFY(h->fiber.runs.size() >= 3); // traced, gap, traced + const std::vector unrolled = [&]() { + std::vector points; + for (std::size_t i = 0; i < raw.size(); ++i) { + const cv::Vec3d& p = fibers[0].linePoints[i]; + const double theta = std::atan2(p[1], p[0]); + (void)theta; + points.push_back(QPointF(0.0, p[2])); + } + return points; + }(); + for (std::size_t runIndex = 0; runIndex < h->fiber.runs.size(); ++runIndex) { + const Run& run = h->fiber.runs[runIndex]; + QCOMPARE(run.arclengths.size(), run.points.size()); + QVERIFY(std::is_sorted(run.arclengths.begin(), run.arclengths.end())); + for (std::size_t k = 0; k < run.points.size(); k += 7) { + const double position = autolink::sourcePositionAtArclength(raw, run.arclengths[k]); + const std::size_t lo = static_cast(std::floor(position)); + const std::size_t hi = std::min(lo + 1, raw.size() - 1); + // The inverted position brackets the sample's arclength ... + QVERIFY(raw[lo] <= run.arclengths[k] + 1e-9); + QVERIFY(run.arclengths[k] <= raw[hi] + 1e-9); + // ... and the drawn sample's height is the source line's + // height there (y = z is not smoothed away: the helix is + // flat, so every sample sits at the fiber's z). + QVERIFY(std::abs(run.points[k].y() - unrolled[lo].y()) < 1e-6); + } + // displayRunPoints replaces the overlap samples of a styled run + // (and its neighbours) by the bounding controls' drawn + // positions; the locator maps those to controlArclength. + const std::vector display = vc3d::fiber_map::displayRunPoints(h->fiber, runIndex); + QCOMPARE(display.size(), run.points.size()); + if (run.gap) { + QCOMPARE(display.front(), h->fiber.controlPoints[static_cast(run.firstControl)]); + QCOMPARE(display.back(), h->fiber.controlPoints[static_cast(run.lastControl)]); + } + } + // The drawn meeting of the V fiber (at 0.3 turn) with the helix + // inverts to a source position at 0.3 turn on one of the helix's + // turns (1256 samples per turn): the locator's arclength + // correspondence closes the loop from the drawn crossing back to the + // line index. + const GlobalPlacedFiber* v = placedById(result, 2); + QVERIFY(v != nullptr); + const auto meeting = autolink::findMapMeeting(h->fiber, v->fiber, QPointF(0.0, 0.0), + vx(0.1)); + QVERIFY(meeting.has_value()); + const double position = autolink::sourcePositionAtArclength(raw, meeting->a.arclength); + const double turnPhase = std::fmod(position, 1256.0); + QVERIFY(std::abs(turnPhase - 0.3 * 1256.0) < 40.0); + } + + // Interior correspondence, exactly: with smoothing off the drawn + // samples are the unrolled source line resampled by raw arclength, so a + // sample's point is the unrolled line interpolated at its inverted + // position (varying z, so the height is not a constant). + void drawnSamplesAreTheUnrolledLineAtTheirInvertedPosition() + { + const std::vector umbilicus = straightUmbilicus(40000); + const double step = kTwoPi / 1256.0; + std::vector points; + for (int i = 0; i <= 1800; ++i) { + const double theta = static_cast(i) * step; + const double r = 4000.0 + 300.0 * theta / kTwoPi; + points.push_back(cv::Vec3d(r * std::cos(theta), r * std::sin(theta), + 30000.0 + 400.0 * std::sin(3.0 * theta))); + } + std::vector fibers; + fibers.push_back(makeFiber(1, QStringLiteral("h"), 'H', points, {0, 900, 1800})); + fibers.push_back(makeFiber(2, QStringLiteral("v"), 'V', + vertical(0.3 * kTwoPi, 4000.0 + 300.0 * 0.3, 29000.0, + 31000.0, 25.0), + {0, 40, 80})); + const GlobalResult result = + vc3d::fiber_map::buildGlobalLayout(fibers, umbilicus, defaultParams()); + const GlobalPlacedFiber* h = placedById(result, 1); + QVERIFY(h != nullptr); + const std::vector raw = vc3d::fiber_map::unrolledLineArclengths( + points, umbilicus, h->fiber.thetaScale, h->fiber.offsetRad, result.rRefVx); + // The unrolled source polyline, as the layout built it. + std::vector unrolled; + for (std::size_t i = 0; i < points.size(); ++i) { + const double theta = static_cast(i) * step; // unwrapped atan2 of the helix + unrolled.push_back(QPointF((h->fiber.thetaScale * theta + h->fiber.offsetRad) * result.rRefVx, + points[i][2])); + } + int checked = 0; + for (const Run& run : h->fiber.runs) { + for (std::size_t k = 0; k < run.points.size(); k += 5) { + const double position = autolink::sourcePositionAtArclength(raw, run.arclengths[k]); + const std::size_t lo = static_cast(std::floor(position)); + const std::size_t hi = std::min(lo + 1, unrolled.size() - 1); + const double t = position - static_cast(lo); + const QPointF expected = unrolled[lo] * (1.0 - t) + unrolled[hi] * t; + QVERIFY(std::abs(expected.x() - run.points[k].x()) < 1e-6 * std::abs(expected.x()) + 1e-6); + QVERIFY(std::abs(expected.y() - run.points[k].y()) < 1e-6); + ++checked; + } + } + QVERIFY(checked > 50); + } + + // The per-endpoint review state the map draws from: every link of the + // inputs (a link to an unplaceable one-point seed included), pending + // over approved, automatic only without a human pending link, the + // same-H/V warning of the winning state, the adjacent shape. + void endpointReviewsAggregateEveryLink() + { + InputFiber h; + h.id = 1; + h.hvTag = 'H'; + h.controlPoints = {{0, 0, 0}, {10, 0, 0}, {20, 0, 0}}; + InputFiber v; + v.id = 2; + v.hvTag = 'V'; + InputFiber seed; + seed.id = 3; + seed.hvTag = '?'; + InputFiber h2; + h2.id = 4; + h2.hvTag = 'H'; + // CP0: automatic to V + human pending to the seed -> pending, not auto. + h.links.push_back({0, 2, 0, true, false, true, true}); + h.links.push_back({0, 3, 0, true, false, true, false}); + // CP1: automatic to V + approved to H2 -> pending, auto, no warning. + h.links.push_back({1, 2, 1, true, false, true, true}); + h.links.push_back({1, 4, 0, false, false, true, false}); + // CP2: automatic H-V + pending H-H -> pending, auto, same-H/V warning; + // plus an approved adjacent link -> triangle. + h.links.push_back({2, 2, 2, true, false, true, true}); + h.links.push_back({2, 4, 1, true, false, true, true}); + h.links.push_back({2, 4, 2, false, true, true, false}); + const auto reviews = vc3d::fiber_map::aggregateEndpointReviews({h, v, seed, h2}); + const auto at = [&](int cp) { return reviews.at(vc3d::fiber_map::EndpointKey{1, cp}); }; + QVERIFY(at(0).pending); + QVERIFY(!at(0).autoPending); + QVERIFY(at(0).anyAuto); + QVERIFY(at(1).pending); + QVERIFY(at(1).autoPending); + QVERIFY(!at(1).sameHv); + QVERIFY(!at(1).adjacent); + QVERIFY(at(2).autoPending); + QVERIFY(at(2).sameHv); + QVERIFY(at(2).adjacent); + QVERIFY(reviews.find(vc3d::fiber_map::EndpointKey{1, 5}) == reviews.end()); + } + + void inversionIsExactFirstMatchAndInterpolates() + { + // Round-3/4 counterexample: projected arcs with a radial run. + const std::vector raw{0.0, 0.1, 0.2, 0.3, 100.0, 200.0, 1000.0}; + QVERIFY(std::abs(autolink::sourcePositionAtArclength(raw, 1.0) - 3.00702) < 1e-4); + // Plateau: first exact match; beyond it, interpolation. + const std::vector plateau{0.0, 1.0, 1.0, 1.0, 2.0}; + QCOMPARE(autolink::sourcePositionAtArclength(plateau, 1.0), 1.0); + QCOMPARE(autolink::sourcePositionAtArclength(plateau, 1.5), 3.5); + // Near-plateau: no tolerance snapping. + const std::vector near{0.0, 1.0, 1.00000000001, 2.0}; + QVERIFY(std::abs(autolink::sourcePositionAtArclength(near, 1.000000000005) - 1.5) < 1e-3); + QCOMPARE(autolink::sourcePositionAtArclength(raw, -5.0), 0.0); + QCOMPARE(autolink::sourcePositionAtArclength(raw, 5000.0), 6.0); + } + + void meetingPrefersTheCrossingNearestTheClick() + { + // A straight horizontal fiber and a zigzag crossing it twice. + const std::vector horizontal{{0.0, 0.0}, {100.0, 0.0}, {200.0, 0.0}, + {300.0, 0.0}, {400.0, 0.0}}; + const std::vector zigzag{{50.0, -50.0}, {150.0, 50.0}, {250.0, -50.0}, + {350.0, 50.0}}; + const PlacedFiber a = straightRun(horizontal, arcs(horizontal)); + const PlacedFiber b = straightRun(zigzag, arcs(zigzag)); + const auto nearLeft = autolink::findMapMeeting(a, b, QPointF(90.0, 10.0), 10.0); + QVERIFY(nearLeft.has_value()); + QVERIFY(nearLeft->crossing); + QVERIFY(std::abs(nearLeft->point.x() - 100.0) < 1e-9); + QVERIFY(std::abs(nearLeft->a.arclength - 100.0) < 1e-9); + const auto nearRight = autolink::findMapMeeting(a, b, QPointF(310.0, 10.0), 10.0); + QVERIFY(nearRight.has_value()); + QVERIFY(std::abs(nearRight->point.x() - 300.0) < 1e-9); + QVERIFY(std::abs(nearRight->a.arclength - 300.0) < 1e-9); + // A crossing exactly at a vertex of both polylines is found. + const std::vector throughVertex{{200.0, -50.0}, {200.0, 0.0}, {200.0, 50.0}}; + const PlacedFiber c = straightRun(throughVertex, arcs(throughVertex)); + const auto atVertex = autolink::findMapMeeting(a, c, QPointF(0.0, 0.0), 10.0); + QVERIFY(atVertex.has_value()); + QVERIFY(atVertex->crossing); + QVERIFY(std::abs(atVertex->point.x() - 200.0) < 1e-9); + QVERIFY(std::abs(atVertex->b.arclength - 50.0) < 1e-9); + } + + void meetingFallsBackToTheClosestApproachWithinTheLimit() + { + const std::vector lower{{0.0, 0.0}, {100.0, 0.0}, {200.0, 0.0}}; + const std::vector upper{{0.0, 30.0}, {100.0, 8.0}, {200.0, 30.0}}; + const PlacedFiber a = straightRun(lower, arcs(lower)); + const PlacedFiber b = straightRun(upper, arcs(upper)); + const auto within = autolink::findMapMeeting(a, b, QPointF(0.0, 0.0), 10.0); + QVERIFY(within.has_value()); + QVERIFY(!within->crossing); + QVERIFY(std::abs(within->distance - 8.0) < 1e-9); + QVERIFY(std::abs(within->point.x() - 100.0) < 1e-9); + const auto beyond = autolink::findMapMeeting(a, b, QPointF(0.0, 0.0), 5.0); + QVERIFY(!beyond.has_value()); + // The limit is inclusive: exactly 8 apart is accepted at 8. + const auto exact = autolink::findMapMeeting(a, b, QPointF(0.0, 0.0), 8.0); + QVERIFY(exact.has_value()); + QVERIFY(std::abs(exact->distance - 8.0) < 1e-9); + // A shallow crossing of two long segments is a crossing (x = 500), + // not a collinear overlap following the click. + const std::vector flat{{0.0, 0.0}, {1000.0, 0.0}}; + const std::vector shallow{{0.0, -1e-7}, {1000.0, 1e-7}}; + const PlacedFiber f1 = straightRun(flat, arcs(flat)); + const PlacedFiber f2 = straightRun(shallow, arcs(shallow)); + const auto shallowHit = autolink::findMapMeeting(f1, f2, QPointF(900.0, 0.0), 10.0); + QVERIFY(shallowHit.has_value()); + QVERIFY(shallowHit->crossing); + QVERIFY(std::abs(shallowHit->point.x() - 500.0) < 1e-3); + // ... and so is a 2e-9 tilt over 10000 voxels far from the origin. + const std::vector far1{{0.0, 30000.0}, {10000.0, 30000.0}}; + const std::vector far2{{0.0, 30000.0 - 2e-9}, {10000.0, 30000.0 + 2e-9}}; + const PlacedFiber g1 = straightRun(far1, arcs(far1)); + const PlacedFiber g2 = straightRun(far2, arcs(far2)); + const auto farHit = autolink::findMapMeeting(g1, g2, QPointF(9000.0, 30000.0), 10.0); + QVERIFY(farHit.has_value()); + QVERIFY(farHit->crossing); + QVERIFY(std::abs(farHit->point.x() - 5000.0) < 1.0); + // A collinear overlap follows the click. + const std::vector same{{0.0, 0.0}, {200.0, 0.0}}; + const PlacedFiber c = straightRun(same, arcs(same)); + const auto collinear = autolink::findMapMeeting(a, c, QPointF(90.0, 5.0), 10.0); + QVERIFY(collinear.has_value()); + QVERIFY(collinear->crossing); + QVERIFY(std::abs(collinear->point.x() - 90.0) < 1e-9); + QVERIFY(std::abs(collinear->a.arclength - 90.0) < 1e-9); + QVERIFY(std::abs(collinear->b.arclength - 90.0) < 1e-9); + } + + // Near-parallel 3D segments: the closest pass is the unique shallow + // crossing at x = 500, found without the a*e - b*b cancellation. + void closestPassIsStableForNearParallelSegments() + { + const std::vector a{{0.0, 0.0, 0.0}, {1000.0, 0.0, 0.0}}; + const std::vector b{{0.0, -5e-6, 0.0}, {1000.0, 5e-6, 0.0}}; + const std::vector ca = autolink::cumulativeArclengths(a); + const std::vector cb = autolink::cumulativeArclengths(b); + autolink::FiberSide sideA{&a, &ca, 0.5, 10000.0}; + autolink::FiberSide sideB{&b, &cb, 0.5, 10000.0}; + const auto pass = autolink::findClosestPass(sideA, sideB); + QVERIFY(pass.has_value()); + QVERIFY(std::abs(pass->pointA[0] - 500.0) < 1e-3); + QVERIFY(pass->distance < 1e-9); + // Rotated and offset: A along the diagonal, B nearly parallel to it, + // crossing near x = 1300 (B's line passes 1e-6 below A's start and + // 1e-6 above its end). + const std::vector ra{{1000.0, 1000.0, 100.0}, {2000.0, 2000.0, 100.0}}; + const std::vector rb{{800.0, 799.999999, 100.0}, {1800.0, 1800.000001, 100.0}}; + const std::vector cra = autolink::cumulativeArclengths(ra); + const std::vector crb = autolink::cumulativeArclengths(rb); + autolink::FiberSide rsA{&ra, &cra, 0.5, 10000.0}; + autolink::FiberSide rsB{&rb, &crb, 0.5, 10000.0}; + const auto rotated = autolink::findClosestPass(rsA, rsB); + QVERIFY(rotated.has_value()); + QVERIFY(std::abs(rotated->pointA[0] - 1300.0) < 1.0); + QVERIFY(rotated->distance < 1e-6); + } + + // Translated coordinates: the focus entry reconstructs more than a few + // ulps outside; the doubling walk still finds the first position inside. + void placementFindsTheFocusEntryAtLargeCoordinates() + { + const std::vector segment{{7110.6, 0.0, 0.0}, {7917.320000000001, 0.0, 0.0}}; + const std::vector arcs = autolink::cumulativeArclengths(segment); + autolink::ControlChoiceInput input; + input.linePoints = &segment; + input.cumulative = &arcs; + input.controlLineIndex = {0, 1}; + input.linked = {true, true}; + input.kollesisTerminations = {false, false}; + input.passPosition = 0.005; + input.windowLo = 0.0; + input.windowHi = 1.0; + input.reuseVx = 0.0; + input.minSpacingVx = 8.0; + input.focusBox = std::make_pair(cv::Vec3d(7119.0, -1.0, -1.0), cv::Vec3d(7800.0, 1.0, 1.0)); + input.inFocusBounds = [](const cv::Vec3d& p) { return p[0] >= 7119.0 && p[0] <= 7800.0; }; + const auto choice = autolink::chooseControlPoint(input); + QCOMPARE(static_cast(choice.kind), static_cast(autolink::ControlChoice::Kind::Place)); + QVERIFY(std::abs(choice.point[0] - 7119.0) < 1e-6); + QVERIFY(choice.point[0] >= 7119.0); + } + + // The focus entry a hair past a segment's start: the admissible + // position (3e-12 along the first segment) is far below the + // position's own ulp walk but within one coordinate ulp of the point. + void placementFindsAFocusEntryAtTheSegmentStart() + { + const std::vector line{{7118.999999999, 0.0, 0.0}, {7419.99, 0.0, 0.0}, + {7519.99, 0.0, 0.0}}; + const std::vector arcs = autolink::cumulativeArclengths(line); + autolink::ControlChoiceInput input; + input.linePoints = &line; + input.cumulative = &arcs; + input.controlLineIndex = {1, 2}; + input.linked = {true, true}; + input.kollesisTerminations = {false, false}; + input.passPosition = 0.0; + input.windowLo = 0.0; + input.windowHi = 1.0; + input.reuseVx = 0.0; + input.minSpacingVx = 8.0; + input.focusBox = std::make_pair(cv::Vec3d(7119.0, -1.0, -1.0), cv::Vec3d(7400.0, 1.0, 1.0)); + input.inFocusBounds = [](const cv::Vec3d& p) { return p[0] >= 7119.0 && p[0] <= 7400.0; }; + const auto choice = autolink::chooseControlPoint(input); + QCOMPARE(static_cast(choice.kind), static_cast(autolink::ControlChoice::Kind::Place)); + QVERIFY(std::abs(choice.point[0] - 7119.0) < 1e-6); + QVERIFY(choice.point[0] >= 7119.0); + } + + // A line that returns to within the ambiguity tolerance of a distant + // control: the admissible stretch begins a hair along the line, not at + // the far side of the window; and equidistant candidates choose the + // lower line position. + void placementLeavesTheAmbiguitySphereAndBreaksTiesDeterministically() + { + const std::vector line{{0.0, 0.0, 0.0}, {100.0, 0.0, 0.0}, {100.0, 100.0, 0.0}, + {0.0, 100.0, 0.0}, {0.0, 0.0, 5e-7}, {-100.0, 0.0, 5e-7}}; + const std::vector arcs = autolink::cumulativeArclengths(line); + autolink::ControlChoiceInput input; + input.linePoints = &line; + input.cumulative = &arcs; + input.controlLineIndex = {0, 5}; + input.linked = {true, true}; + input.kollesisTerminations = {false, false}; + input.passPosition = 4.0; + input.windowLo = 0.0; + input.windowHi = 5.0; + input.reuseVx = 0.0; + input.minSpacingVx = 8.0; + auto choice = autolink::chooseControlPoint(input); + QCOMPARE(static_cast(choice.kind), static_cast(autolink::ControlChoice::Kind::Place)); + // The sphere's exits on the segment into the pass and on the one + // out of it are equidistant: the lower position wins (a hair before + // index 4), still a millionth of a voxel from the pass. + QVERIFY(std::abs(choice.linePosition - 4.0) < 0.001); + QVERIFY(std::abs(choice.point[0]) < 0.1 && std::abs(choice.point[1]) < 0.1); + // The same with large coordinates: the sphere's chord must not + // vanish against |m|^2 (the pass at 2.5 sits on a segment the line + // runs back along, within the tolerance of control 0's position). + const std::vector far{{2000.0, 2000.0, 0.0}, {3000.0, 3000.0, 0.0}, + {3000.0, 2000.0, 0.0}, {1000.0, 2000.0, 0.0}, + {1000.0, 3000.0, 0.0}}; + const std::vector farArcs = autolink::cumulativeArclengths(far); + autolink::ControlChoiceInput farInput; + farInput.linePoints = &far; + farInput.cumulative = &farArcs; + farInput.controlLineIndex = {0, 4}; + farInput.linked = {true, true}; + farInput.kollesisTerminations = {false, false}; + farInput.passPosition = 2.5; + farInput.windowLo = 2.0; + farInput.windowHi = 3.0; + farInput.reuseVx = 0.0; + farInput.minSpacingVx = 8.0; + const auto farChoice = autolink::chooseControlPoint(farInput); + QCOMPARE(static_cast(farChoice.kind), static_cast(autolink::ControlChoice::Kind::Place)); + QVERIFY(std::abs(farChoice.linePosition - 2.5) < 1e-6); + // Ties: a linked control at 64 on x = [0, 64, 128], spacing 8, pass + // at 64 -> 56 and 72 are equidistant; the lower position wins. + const std::vector sym{{0.0, 0.0, 0.0}, {64.0, 0.0, 0.0}, {128.0, 0.0, 0.0}}; + const std::vector symArcs = autolink::cumulativeArclengths(sym); + input.linePoints = &sym; + input.cumulative = &symArcs; + input.controlLineIndex = {1}; + input.linked = {true}; + input.kollesisTerminations = {false}; + input.passPosition = 1.0; + input.windowLo = 0.0; + input.windowHi = 2.0; + choice = autolink::chooseControlPoint(input); + QCOMPARE(static_cast(choice.kind), static_cast(autolink::ControlChoice::Kind::Place)); + QVERIFY(std::abs(choice.point[0] - 56.0) < 1e-9); + } + + // A styled run's replaced overlap sample maps to its bounding control's + // arclength: a crossing on the gap run's first display segment reports + // the control's arclength, not the overlap sample's. + void styledRunEndpointInvertsThroughTheControl() + { + PlacedFiber fiber; + Run traced; + traced.firstControl = 0; + traced.lastControl = 1; + traced.points = {{0.0, 0.0}, {50.0, 0.0}, {100.0, 0.0}, {110.0, 0.0}}; + traced.arclengths = {0.0, 50.0, 100.0, 110.0}; + Run gap; + gap.gap = true; + gap.firstControl = 1; + gap.lastControl = 2; + // The overlap sample sits 5e-9 from the control: fuzzy point + // equality would call them equal, the style rule still replaces. + gap.points = {{100.0 + 5e-9, 0.0}, {150.0, 0.0}, {200.0, 0.0}}; + gap.arclengths = {90.0, 150.0, 200.0}; + fiber.runs = {traced, gap}; + fiber.controlPoints = {{0.0, 0.0}, {100.0, 0.0}, {200.0, 0.0}}; + fiber.controlArclength = {0.0, 100.0, 200.0}; + const std::vector vertical{{120.0, -10.0}, {120.0, 10.0}}; + const PlacedFiber v = straightRun(vertical, arcs(vertical)); + const auto meeting = autolink::findMapMeeting(fiber, v, QPointF(120.0, 0.0), 1.0); + QVERIFY(meeting.has_value()); + QVERIFY(meeting->crossing); + // The hit is on the gap run's display segment [control 1 (100), 150] + // at x = 120: arclength interpolates from the CONTROL's 100, not 90. + QCOMPARE(meeting->a.runIndex, std::size_t{1}); + QVERIFY(std::abs(meeting->a.arclength - 120.0) < 1e-9); + } + + // The window never leaves the drawn turn: a two-turn helix passes the V + // fiber closer on its INNER turn, but the meeting lies on the outer one. + void closestPassStaysOnTheMeetingTurn() + { + const double step = kTwoPi / 1256.0; + const std::vector h = helix(4000.0, 300.0, 2.0, 30000.0, step); + // V fiber at 0.3 turn, 10 vx inside the inner turn's radius: the inner + // turn is 10 vx away, the outer turn 310 vx away. + const double theta = 0.3 * kTwoPi; + const std::vector v = + vertical(theta, 4000.0 + 300.0 * 0.3 - 10.0, 29000.0, 31000.0, 25.0); + const std::vector hArcs = autolink::cumulativeArclengths(h); + const std::vector vArcs = autolink::cumulativeArclengths(v); + // The meeting on the OUTER turn: theta + 2 pi. + const double outerPosition = (theta + kTwoPi) / step; + const double vPosition = 40.0; // z = 30000 + autolink::FiberSide sideH{&h, &hArcs, outerPosition, vx(0.5)}; + autolink::FiberSide sideV{&v, &vArcs, vPosition, vx(0.5)}; + const auto pass = autolink::findClosestPass(sideH, sideV); + QVERIFY(pass.has_value()); + QVERIFY(std::abs(pass->positionA - outerPosition) < 3.0); + QVERIFY(std::abs(pass->distance - 310.0) < 5.0); + QVERIFY(pass->firstA > kTwoPi / step * 0.9); // never reaches the inner turn + // With the meeting on the inner turn, the pass is the inner one. + autolink::FiberSide sideInner{&h, &hArcs, theta / step, vx(0.5)}; + const auto inner = autolink::findClosestPass(sideInner, sideV); + QVERIFY(inner.has_value()); + QVERIFY(std::abs(inner->distance - 10.0) < 5.0); + } + + // The window never searches outside +-window: a long segment that + // starts inside the window but runs far past it is clipped at the + // exact arclength limit. + void closestPassClipsBoundarySegments() + { + // Fiber A: x from 0 to 1000 in two long segments (0, 500, 1000). + const std::vector a{{0.0, 0.0, 0.0}, {500.0, 0.0, 0.0}, {1000.0, 0.0, 0.0}}; + // Fiber B: a short vertical segment that touches A at x = 900. + const std::vector b{{900.0, -10.0, 0.0}, {900.0, 10.0, 0.0}}; + const std::vector ca = autolink::cumulativeArclengths(a); + const std::vector cb = autolink::cumulativeArclengths(b); + // Meeting at x = 600 (position 1.2) with a +-100 window: [500, 700]. + // The closest point to B inside the window is x = 700, not 900. + autolink::FiberSide sideA{&a, &ca, 1.2, 100.0}; + autolink::FiberSide sideB{&b, &cb, 0.5, 100.0}; + const auto pass = autolink::findClosestPass(sideA, sideB); + QVERIFY(pass.has_value()); + QVERIFY(std::abs(pass->pointA[0] - 700.0) < 1e-6); + QVERIFY(std::abs(pass->positionA - 1.4) < 1e-6); + QVERIFY(std::abs(pass->distance - 200.0) < 1e-6); + } + + // A pass inside a linked control's spacing band moves to the band's + // boundary, a fractional position between two line vertices. + void controlChoiceFindsPositionsBetweenVertices() + { + const std::vector line{{0.0, 0.0, 0.0}, {100.0, 0.0, 0.0}, {200.0, 0.0, 0.0}}; + const std::vector cumulative = autolink::cumulativeArclengths(line); + autolink::ControlChoiceInput input; + input.linePoints = &line; + input.cumulative = &cumulative; + input.controlLineIndex = {0, 1}; + input.linked = {true, true}; + input.kollesisTerminations = {false, false}; + input.passPosition = 0.1; // x = 10 + input.windowLo = 0; + input.windowHi = 2; + input.reuseVx = 0.0; + input.minSpacingVx = 30.0; + auto choice = autolink::chooseControlPoint(input); + QCOMPARE(static_cast(choice.kind), static_cast(autolink::ControlChoice::Kind::Place)); + QVERIFY(choice.linePosition > 0.29 && choice.linePosition < 0.31); // x = 30 + QVERIFY(std::abs(choice.point[0] - 30.0) < 1e-6); + // The spacing boundary reconstructed through an arclength that comes + // back a few ulps short: segment [0, 49] with linked endpoint + // controls, spacing 8, pass at 4.9 -> the position with arclength 8 + // (8/49), not the far side. + { + const std::vector segment{{0.0, 0.0, 0.0}, {49.0, 0.0, 0.0}}; + const std::vector segmentArcs = autolink::cumulativeArclengths(segment); + autolink::ControlChoiceInput rounding; + rounding.linePoints = &segment; + rounding.cumulative = &segmentArcs; + rounding.controlLineIndex = {0, 1}; + rounding.linked = {true, true}; + rounding.kollesisTerminations = {false, false}; + rounding.passPosition = 0.1; + rounding.windowLo = 0.0; + rounding.windowHi = 1.0; + rounding.reuseVx = 0.0; + rounding.minSpacingVx = 8.0; + const auto near = autolink::chooseControlPoint(rounding); + QCOMPARE(static_cast(near.kind), static_cast(autolink::ControlChoice::Kind::Place)); + QVERIFY(std::abs(near.point[0] - 8.0) < 1e-6); + // The strict gate: the chosen position is never inside a band. + QVERIFY(near.point[0] >= 8.0 - 1e-12); + // The same with the focus box [8, 41]: the box's entry reconstructs + // a few ulps outside; its representable neighbour at 8 is taken. + rounding.focusBox = std::make_pair(cv::Vec3d(8.0, -1.0, -1.0), cv::Vec3d(41.0, 1.0, 1.0)); + rounding.inFocusBounds = [](const cv::Vec3d& p) { return p[0] >= 8.0 && p[0] <= 41.0; }; + const auto focused = autolink::chooseControlPoint(rounding); + QCOMPARE(static_cast(focused.kind), static_cast(autolink::ControlChoice::Kind::Place)); + QVERIFY(std::abs(focused.point[0] - 8.0) < 1e-6); + QVERIFY(focused.point[0] >= 8.0); + } + // A tight fit: linked controls at 0 and 60 with spacing 30 admit + // exactly x = 30 (the boundary is admissible, no nudge). + const std::vector tight{{0.0, 0.0, 0.0}, {60.0, 0.0, 0.0}, {120.0, 0.0, 0.0}}; + const std::vector tightArcs = autolink::cumulativeArclengths(tight); + input.linePoints = &tight; + input.cumulative = &tightArcs; + input.controlLineIndex = {0, 1}; + input.passPosition = 0.2; + choice = autolink::chooseControlPoint(input); + QCOMPARE(static_cast(choice.kind), static_cast(autolink::ControlChoice::Kind::Place)); + QVERIFY(std::abs(choice.point[0] - 30.0) < 1e-6); + // A focus box strictly inside a segment: x in [40, 60] on 0..100 with + // a linked control at 0 (spacing 30) and the pass at 10: the box's + // entry at x = 40 is the nearest admissible position. + input.linePoints = &line; + input.cumulative = &cumulative; + input.controlLineIndex = {0}; + input.linked = {true}; + input.kollesisTerminations = {false}; + input.passPosition = 0.1; + input.windowLo = 0.0; + input.windowHi = 2.0; + input.focusBox = std::make_pair(cv::Vec3d(40.0, -1.0, -1.0), cv::Vec3d(60.0, 1.0, 1.0)); + input.inFocusBounds = [](const cv::Vec3d& p) { return p[0] >= 40.0 && p[0] <= 60.0; }; + choice = autolink::chooseControlPoint(input); + QCOMPARE(static_cast(choice.kind), static_cast(autolink::ControlChoice::Kind::Place)); + QVERIFY(std::abs(choice.point[0] - 40.0) < 1e-6); + } + + void controlChoiceReusesPlacesAndRefuses() + { + // A straight line along x, one point per voxel. + std::vector line; + for (int i = 0; i <= 2000; ++i) { + line.push_back(cv::Vec3d(static_cast(i), 0.0, 0.0)); + } + const std::vector cumulative = autolink::cumulativeArclengths(line); + autolink::ControlChoiceInput input; + input.linePoints = &line; + input.cumulative = &cumulative; + input.controlLineIndex = {100, 1000, 1900}; + input.linked = {false, false, false}; + input.kollesisTerminations = {false, false, false}; + input.passPosition = 1050.0; + input.windowLo = 600; + input.windowHi = 1500; + input.reuseVx = 100.0; + input.minSpacingVx = 30.0; + // Reuse: the unlinked control 50 vx away. + auto choice = autolink::chooseControlPoint(input); + QCOMPARE(static_cast(choice.kind), static_cast(autolink::ControlChoice::Kind::Reuse)); + QCOMPARE(choice.controlIndex, 1); + // Linked: place instead, at the pass. + input.linked = {false, true, false}; + choice = autolink::chooseControlPoint(input); + QCOMPARE(static_cast(choice.kind), static_cast(autolink::ControlChoice::Kind::Place)); + QCOMPARE(choice.linePosition, 1050.0); + // Pass within the minimum spacing of the linked control: the new + // control moves outward to the nearer admissible side. + input.passPosition = 1010.0; + choice = autolink::chooseControlPoint(input); + QCOMPARE(static_cast(choice.kind), static_cast(autolink::ControlChoice::Kind::Place)); + QVERIFY(choice.linePosition >= 1030.0); + QVERIFY(choice.linePosition < 1031.0); + // ... unless that side is outside the focus bounds: then the farther side. + input.inFocusBounds = [](const cv::Vec3d& p) { return p[0] < 1005.0; }; + choice = autolink::chooseControlPoint(input); + QCOMPARE(static_cast(choice.kind), static_cast(autolink::ControlChoice::Kind::Place)); + QVERIFY(choice.linePosition <= 970.0); + QVERIFY(choice.linePosition >= 969.0); + // A saturated window refuses. + input.inFocusBounds = [](const cv::Vec3d&) { return false; }; + choice = autolink::chooseControlPoint(input); + QCOMPARE(static_cast(choice.kind), static_cast(autolink::ControlChoice::Kind::Refused)); + QVERIFY(!choice.reason.empty()); + // A gap span and a tagged end are closed to placement. + input.inFocusBounds = nullptr; + input.linked = {false, false, false}; + input.reuseVx = 0.0; + input.gapSpans = {{0, 1}}; + input.passPosition = 500.0; + input.windowLo = 400; + input.windowHi = 600; + choice = autolink::chooseControlPoint(input); + QCOMPARE(static_cast(choice.kind), static_cast(autolink::ControlChoice::Kind::Refused)); + input.gapSpans.clear(); + input.kollesisTerminations = {false, false, true}; + input.passPosition = 1950.0; + input.windowLo = 1920; + input.windowHi = 2000; + choice = autolink::chooseControlPoint(input); + QCOMPARE(static_cast(choice.kind), static_cast(autolink::ControlChoice::Kind::Refused)); + } +}; + +QTEST_APPLESS_MAIN(TestFiberMapAutoLink) +#include "test_fiber_map_auto_link.moc" diff --git a/volume-cartographer/core/test/test_line_annotation_link_validation.cpp b/volume-cartographer/core/test/test_line_annotation_link_validation.cpp index 1ab9a3dc01..075a041890 100644 --- a/volume-cartographer/core/test/test_line_annotation_link_validation.cpp +++ b/volume-cartographer/core/test/test_line_annotation_link_validation.cpp @@ -16,6 +16,7 @@ #include "LineAnnotationAdjacentLinks.hpp" #include "LineAnnotationFiberLinkValidation.hpp" #include "LineAnnotationFiberLinks.hpp" +#include "LineAnnotationGeneratedViews.hpp" #include "LineAnnotationStoredFiber.hpp" using vc3d::line_annotation::BranchLinkValidationIssue; @@ -206,3 +207,172 @@ TEST_CASE("link validation: reversed line orientation is what breaks a copied di const cv::Vec3d onReversed = vc3d::line_annotation::endpointTangentFromLinePoints(reversed, point); CHECK_FALSE(vc3d::line_annotation::branchDirectionsCompatible(forward, onReversed)); } + + +// --- the automatic-link mark (FiberBranchRef::autoLink, JSON "auto") --- + +namespace { + +struct SaveSnapshot { + StoredFiber fiber; +}; + +std::vector linkedPair(bool autoOnA, bool autoOnB, bool pending) +{ + auto a = makeFiber("a.json", 0.0, 0.0); + auto b = makeFiber("b.json", 0.0, 50.0); + a.id = 1; + b.id = 2; + link(a, 2, b, 2); + a.branches[0].pending = pending; + b.branches[0].pending = pending; + a.branches[0].autoLink = autoOnA; + b.branches[0].autoLink = autoOnB; + return {SaveSnapshot{a}, SaveSnapshot{b}}; +} + +} // namespace + +TEST_CASE("auto link: the first snapshot visited propagates its state, like pending") +{ + { + // A (visited first) says auto, B does not: A's pass writes B's + // reciprocal, so when B is visited it already says auto and writes + // that back - the FIRST snapshot visited wins for the pair, exactly + // as `pending` behaves. + auto batch = linkedPair(true, false, true); + vc3d::line_annotation::canonicalizeFiberSaveSnapshots(batch); + CHECK(batch[0].fiber.branches[0].autoLink); + CHECK(batch[1].fiber.branches[0].autoLink); + } + { + // The reverse order: B (visited first) says NOT auto. + auto batch = linkedPair(true, false, true); + std::swap(batch[0], batch[1]); + vc3d::line_annotation::canonicalizeFiberSaveSnapshots(batch); + CHECK_FALSE(batch[0].fiber.branches[0].autoLink); + CHECK_FALSE(batch[1].fiber.branches[0].autoLink); + } + { + auto batch = linkedPair(true, true, true); + vc3d::line_annotation::canonicalizeFiberSaveSnapshots(batch); + CHECK(batch[0].fiber.branches[0].autoLink); + CHECK(batch[1].fiber.branches[0].autoLink); + } + { + // Approved: the mark cannot survive canonicalization. + auto batch = linkedPair(true, true, false); + vc3d::line_annotation::canonicalizeFiberSaveSnapshots(batch); + CHECK_FALSE(batch[0].fiber.branches[0].autoLink); + CHECK_FALSE(batch[1].fiber.branches[0].autoLink); + } +} + +TEST_CASE("auto link: ref builders assemble a reciprocal pair under the invariant") +{ + using vc3d::line_annotation::makeBranchRef; + using vc3d::line_annotation::reciprocalBranchRef; + const cv::Vec3d pa(1.0, 2.0, 3.0); + const cv::Vec3d pb(4.0, 5.0, 6.0); + const cv::Vec3d da(1.0, 0.0, 0.0); + const cv::Vec3d db(0.0, 1.0, 0.0); + const FiberBranchRef ref = makeBranchRef(3, pa, da, 7, "b.json", 5, pb, db, true, false, true); + CHECK(ref.controlPointIndex == 3); + CHECK(ref.branchFiberId == 7); + CHECK(ref.branchControlPointIndex == 5); + CHECK(ref.branchFileName == "b.json"); + CHECK(ref.controlPointPosition == pa); + CHECK(ref.branchControlPointPosition == pb); + CHECK(ref.controlPointDirection == da); + CHECK(ref.branchControlPointDirection == db); + CHECK(ref.pending); + CHECK_FALSE(ref.adjacent); + CHECK(ref.autoLink); + const FiberBranchRef back = reciprocalBranchRef(ref, 9, "a.json"); + CHECK(back.controlPointIndex == 5); + CHECK(back.branchFiberId == 9); + CHECK(back.branchControlPointIndex == 3); + CHECK(back.branchFileName == "a.json"); + CHECK(back.controlPointPosition == pb); + CHECK(back.branchControlPointPosition == pa); + CHECK(back.controlPointDirection == db); + CHECK(back.branchControlPointDirection == da); + CHECK(back.pending); + CHECK(back.autoLink); + // The invariant: no mark on an adjacent or on an approved link. + CHECK_FALSE(makeBranchRef(3, pa, da, 7, "b.json", 5, pb, db, true, true, true).autoLink); + CHECK_FALSE(makeBranchRef(3, pa, da, 7, "b.json", 5, pb, db, false, false, true).autoLink); +} + +TEST_CASE("auto link: the JSON review-state keys round trip under the invariant") +{ + using vc3d::line_annotation::readBranchReviewStateJson; + using vc3d::line_annotation::writeBranchReviewStateJson; + FiberBranchRef ref; + ref.pending = true; + ref.autoLink = true; + nlohmann::json entry; + writeBranchReviewStateJson(entry, ref); + CHECK(entry.value("pending", false)); + CHECK(entry.value("auto", false)); + const auto state = readBranchReviewStateJson(entry, /*adjacent=*/false); + CHECK(state.pending); + CHECK(state.autoLink); + // Read on the adjacent array: the mark is dropped. + CHECK_FALSE(readBranchReviewStateJson(entry, /*adjacent=*/true).autoLink); + // "auto" without "pending" is dropped. + nlohmann::json stale = {{"auto", true}}; + const auto staleState = readBranchReviewStateJson(stale, false); + CHECK_FALSE(staleState.pending); + CHECK_FALSE(staleState.autoLink); + // Approved: neither key is written. + FiberBranchRef approved; + nlohmann::json none; + writeBranchReviewStateJson(none, approved); + CHECK_FALSE(none.contains("pending")); + CHECK_FALSE(none.contains("auto")); + // Adjacent pending: pending only. + FiberBranchRef adjacent; + adjacent.pending = true; + adjacent.adjacent = true; + adjacent.autoLink = true; + nlohmann::json adjacentJson; + writeBranchReviewStateJson(adjacentJson, adjacent); + CHECK(adjacentJson.value("pending", false)); + CHECK_FALSE(adjacentJson.contains("auto")); +} + +TEST_CASE("auto link: the marker rule is 'every pending link on the point is automatic'") +{ + using vc3d::line_annotation::autoPendingMarker; + using vc3d::line_annotation::linkedMarkerDashed; + struct L { + bool pending; + bool autoLink; + }; + CHECK(autoPendingMarker(std::vector{{true, true}})); + CHECK(autoPendingMarker(std::vector{{true, true}, {false, false}})); + CHECK_FALSE(autoPendingMarker(std::vector{{true, true}, {true, false}})); + CHECK_FALSE(autoPendingMarker(std::vector{{false, false}})); + CHECK_FALSE(autoPendingMarker(std::vector{})); + CHECK(linkedMarkerDashed(true, true)); + CHECK_FALSE(linkedMarkerDashed(true, false)); + CHECK_FALSE(linkedMarkerDashed(false, true)); +} + + +TEST_CASE("auto link: combining two refs of one link keeps a human pending link human") +{ + using vc3d::line_annotation::combineReviewStates; + // automatic + human pending -> pending, not automatic + CHECK_FALSE(combineReviewStates(true, true, true, false, false).autoLink); + // automatic + approved -> pending, automatic + CHECK(combineReviewStates(true, true, false, false, false).autoLink); + CHECK(combineReviewStates(true, true, false, false, false).pending); + // automatic + automatic -> automatic + CHECK(combineReviewStates(true, true, true, true, false).autoLink); + // approved + approved -> approved + CHECK_FALSE(combineReviewStates(false, false, false, false, false).pending); + // adjacent never automatic + CHECK_FALSE(combineReviewStates(true, true, true, true, true).autoLink); +} diff --git a/volume-cartographer/docs/adjacent-fiber-links.html b/volume-cartographer/docs/adjacent-fiber-links.html index e170f1ec45..248c5fbab0 100644 --- a/volume-cartographer/docs/adjacent-fiber-links.html +++ b/volume-cartographer/docs/adjacent-fiber-links.html @@ -22,7 +22,7 @@ .note{border-left:3px solid var(--accent);padding:6px 12px;color:var(--ink-soft);margin:14px 0}

Adjacent fiber links

-

Proposal for PR #1829: adjacent links live in their own top-level adjacent_branches array. The array name is the kind; entries have exactly the existing branches schema.

+

Proposal for PR #1829: adjacent links live in their own top-level adjacent_branches array. The array name is the kind; entries have the existing branches schema with one exception: the automatic-link mark "auto": true (an unreviewed link made by the Fiber Map's "Link A and B", only ever next to "pending": true) is never written on an adjacent entry, and a reader drops it from one.

What it means

An ordinary link ties two control points to the same winding of the sheet. An adjacent link ties a vertical fiber to a horizontal fiber one winding outside it: WV = WH − 1. The effective H/V tags determine which side is inside. An adjacent link between fibers that are not one H and one V remains stored, is flagged as an error in the fiber map, and contributes no winding constraint.

diff --git a/volume-cartographer/docs/line_annotation_fibers.md b/volume-cartographer/docs/line_annotation_fibers.md index cde2e1c1c8..06d99fb024 100644 --- a/volume-cartographer/docs/line_annotation_fibers.md +++ b/volume-cartographer/docs/line_annotation_fibers.md @@ -284,6 +284,94 @@ and nothing is predicted then. The overlay is display-only: it changes no layout, heat-map or publishing result. The grouping and statistics live in `FiberMapKollesis.hpp`. +## Linking two fibers from the Fiber Map + +The Fiber Map links two fibers in two gestures, without opening either in the +Line Annotation tab. + +Selection: a left click selects one fiber (as before). Shift+left click keeps +the last two fibers clicked with Shift held selected: clicking a third drops the +oldest, re-clicking a selected one moves it to the back, Shift on empty space +changes nothing. Both selected fibers draw bold with their control dots; the +linked-network glow and the dock row follow the most recent one, and the status +line names the pair. A rebuild (an automatic Update, a theme change) keeps the +pair selected. + +With two fibers selected, Ctrl+right-click on either of them (its line or one +of its dots) adds **Link and ** to the menu, above Delete. It: + +1. finalizes, saves and closes any open Line Annotation of either fiber (what + opening another fiber does), and every dialog-less session of them (the + intersection inspection included), saving each first. If that saved + something the map is stale; it updates by itself and the status line asks + you to choose Link again on the rebuilt map (the pair stays selected); +2. finds where the two fibers meet on the map: the drawn crossing nearest the + click, or, when the drawn lines never cross, their closest approach if it + is within 1 mm on the map (otherwise the link is refused). That point is + inverted exactly to a position on each fiber's stored line through the + unrolled arclength the map was drawn with; +3. searches the fibers' closest 3D pass only within ±5 mm along each fiber + around that position (a multi-winding fiber never searches another + winding), and never refuses because the pass is far apart: the Fiber + Map's own link checks flag a bad link afterwards; +4. picks a control point on each fiber: an UNLINKED existing control within + 1 mm along the fiber of the pass is reused (no re-optimization of that + fiber); otherwise a new control is placed at the nearest position to the + pass that is clear of every existing control by the strip's minimum + spacing (so nothing collapses), not beyond a kollesis termination, not in + a gap span and inside the focus bounds, then the two spans around it are + re-optimized synchronously (wait cursor) and the fiber is saved; +5. links the two control points with a **pending automatic link**, saves both + files and waits for the writes before reporting success in the status line. + +While step 4 and 5 run, the operation holds the same guard a merge or split +holds: a rename, delete, import, merge or split started from the dock meanwhile +is refused ("A merge, split or link is in progress"). If the pair save fails, +the link is withdrawn from the loaded fibers (every copy, including an edit's +synced moves of its endpoints during the wait) and the status line says so. + +A pending automatic link is an ordinary pending link whose control-point +markers draw with a DASHED outline in the pending colours (blue, or the orange +same-H/V warning palette) everywhere a linked point is drawn: the cut views and +strips (both renderers), the overview bar, the slice-view rings and the Fiber +Map dots. A tagged point (kollesis termination, break) keeps its tag ring and +gets a separate dashed ring just outside it. The rule for a point carrying +several links: dashed only when every pending link on it is automatic; a +human-made pending link outranks. The control-point menu's approve entry reads +"Approve auto link to / CP n"; approving makes an ordinary approved +link, exactly like approving a pending link. "Mark link as pending" on an +approved link makes a plain (human) pending link. + +File format: the link entry carries `"auto": true` next to `"pending": true`, +only while pending and only in the ordinary `branches` array (never on an +adjacent link); approving removes both keys. The key is mirrored onto the +reciprocal entry like `pending` (`fiber_merge.py` keeps the two in lockstep and +drops a stray `auto` that lost its `pending` or sits in `adjacent_branches`). +An older VC3D strips the key on resave; the link then reads as a plain pending +link on BOTH sides (the loader reconciles the two refs that way, and a reload +applies the same downgrade to the refs of any open session of either fiber, so +a later save of that session cannot write the mark back), and the next pair +save converges the files. Whenever two refs of one link are combined (a +collapse of two control points, a dedup, the sync merge), the result is +automatic only if every pending contributor was; a human-made pending link +outranks, and an approved contributor says nothing about it. In the sync merge +a differing mark with both sides still pending is arbitrated against the base +(the side that changed it wins; without a base the human state wins). + +Refusals are shown in the map's status line. Everything decidable up front +refuses with nothing changed: the fibers do not meet on the map, one of them +is read-only or open in a session that did not finish its optimization, a +fiber's control points do not resolve in line order (or its line passes +through one of its controls twice), no admissible position for a new control +in the search window, the pair would not resolve on reload (different +sources), the two existing controls are already linked to each other. A +failure after the first fiber's new control was placed and saved (the second +fiber's re-optimization or save, a change to the fibers, the package, the +umbilicus, the focus bounds or the active volume while the operation yielded, +or the link's own write) leaves that first control in place - a plain, unlinked +control point - and the message says so; a failed write means the loaded +fibers may differ from the files, so reload before further edits. + ## Overview bar during re-optimization The overview bar above the cut views places each control point at a @@ -373,7 +461,8 @@ menu offers the same three. The control-point menu is ordered by use in four sections: navigation and linking (go to linked annotation, go to nearby annotation, delete, link or merge with the candidate, designate as link or adjacent link candidate, -unlink, approve or mark pending); the point tags (kollesis termination, +unlink, approve or mark pending - an unreviewed automatic link's entry reads +"Approve auto link"); the point tags (kollesis termination, break); new annotations at the click; and the corrections reset. The top-level `optimization_mode` is either `lasagna` or diff --git a/volume-cartographer/scripts/fiber_merge.py b/volume-cartographer/scripts/fiber_merge.py index f74ff712bb..c901056246 100644 --- a/volume-cartographer/scripts/fiber_merge.py +++ b/volume-cartographer/scripts/fiber_merge.py @@ -851,6 +851,33 @@ def _stripped_array_conflicts(base_doc, local_doc, remote_doc): if (message := legacy_regression(doc, base_doc))] +def normalize_auto_flag(entry, kind='branches'): + """VC3D's review-state invariant for the automatic-link mark: `auto` + (a link made by the Fiber Map's "Link A and B", not yet reviewed) is + only ever true together with `pending`, and only on an ordinary link. + Drops the key where the invariant does not hold; returns True when the + entry changed.""" + if not isinstance(entry, dict) or 'auto' not in entry: + return False + if (bool(entry.get('auto', False)) and bool(entry.get('pending', False)) + and kind == 'branches'): + return False + entry.pop('auto', None) + return True + + +def normalize_auto_flags(doc): + """normalize_auto_flag over both branch arrays of a document (the + merge shortcuts return a side's document as-is; its stray marks are + dropped like the entry-wise merge drops them).""" + changed = False + for kind in BRANCH_ARRAYS: + for entry in _branches_of(doc, kind): + if normalize_auto_flag(entry, kind): + changed = True + return changed + + def merge_branches(base_doc, local_doc, remote_doc, prefer_local, kind='branches'): """Base-aware set merge of structured link entries. Additions from either side survive; untouched-here-but-gone-there means deletion; @@ -887,7 +914,9 @@ def merge_one_sided(entry, side_name): # the base, so it reads as an addition and survives even if the # other side deleted the original. Kept deliberately: a moved # link is indistinguishable from a re-created one. - merged.append(copy.deepcopy(entry)) + kept = copy.deepcopy(entry) + normalize_auto_flag(kept, kind) + merged.append(kept) stats['links_added_%s' % side_name] += 1 return # A re-link in the other array replaces the original, including its @@ -902,7 +931,9 @@ def merge_one_sided(entry, side_name): notes.append(f"link to {entry.get('branch_file', '?')} deleted on " f"{'remote' if side_name == 'local' else 'local'}") return - merged.append(copy.deepcopy(entry)) + kept = copy.deepcopy(entry) + normalize_auto_flag(kept, kind) + merged.append(kept) notes.append(f"kept link to {entry.get('branch_file', '?')} modified " f"on {side_name} but deleted on the other side") stats['links_kept'] += 1 @@ -936,7 +967,30 @@ def merge_one_sided(entry, side_name): "re-flagged as pending") else: stats['links_approved'] += 1 - merged.append(copy.deepcopy(chosen)) + # The chosen entry carries its own review state; an approval (or an + # adjacent kind) cannot keep the automatic mark. + kept = copy.deepcopy(chosen) + if local_pending == remote_pending and local_pending: + # Both still pending but the automatic mark differs: the side + # that changed it relative to the base wins (a human review + # that cleared it, or a re-link that set it), independently of + # which side is newer; without a base the human state wins. + local_auto = bool(local_entry.get('auto', False)) + remote_auto = bool(remote_entry.get('auto', False)) + if local_auto != remote_auto: + if base_entry is not None: + base_auto = bool(base_entry.get('auto', False)) + winner = local_auto if local_auto != base_auto else remote_auto + else: + winner = False + if winner: + kept['auto'] = True + else: + kept.pop('auto', None) + notes.append(f"link to {_branch_target(chosen) or '?'}: automatic mark " + f"{'kept' if winner else 'cleared'} (base-aware)") + normalize_auto_flag(kept, kind) + merged.append(kept) stats['links_kept'] += 1 for i, remote_entry in enumerate(remote_entries): @@ -1240,6 +1294,7 @@ def lineage_version(doc): merged = keep_uuid(lineage_version(copy.deepcopy(local))) merged.setdefault('width_gap_fraction', 0.2) normal_defaults(merged) + normalize_auto_flags(merged) result.update(ok=True, merged=merged, peer_files=short_circuit_peers(local), notes=(["remote side unchanged; kept local"] @@ -1250,6 +1305,7 @@ def lineage_version(doc): merged = keep_uuid(lineage_version(copy.deepcopy(remote))) merged.setdefault('width_gap_fraction', 0.2) normal_defaults(merged) + normalize_auto_flags(merged) result.update(ok=True, merged=merged, peer_files=short_circuit_peers(remote), notes=["local side unchanged; took remote"]) @@ -1448,6 +1504,8 @@ def directions_match(doc): "VC3D recomputes it on load") for kind in BRANCH_ARRAYS: entries = merged_branches[kind] + opaque_branches[kind] + for entry in entries: + normalize_auto_flag(entry, kind) if not entries and all(kind not in doc for doc in (local, remote)): # Neither content side carried the array and the merge produced # nothing for it: writing one would turn "a legacy writer saved @@ -1553,6 +1611,14 @@ def _refresh_pair_links_kind(a_doc, b_doc, a_name, b_name, base_doc, kind): return out a_cps, a_line = a['control_points'], a['line_points'] b_cps, b_line = b['control_points'], b['line_points'] + # The invariant holds for every entry the refresh writes back, the + # pair's and the unrelated ones alike. + for entry in _branches_of(a, kind): + if normalize_auto_flag(entry, kind): + out['a_changed'] = True + for entry in _branches_of(b, kind): + if normalize_auto_flag(entry, kind): + out['b_changed'] = True a_entries = links_to(a, b_name, kind) if a_entries and (len(a_line) < 2 or len(b_line) < 2): out['conflicts'].append( @@ -1590,6 +1656,14 @@ def snap_flag(entry, key, desired, doc_flag): def snap_pending(entry, desired, doc_flag): snap_flag(entry, 'pending', desired, doc_flag) + def snap_auto(entry, source, doc_flag): + # Mirrors VC3D: the automatic mark follows `pending` onto the + # reciprocal, under the invariant (pending, ordinary kind only). + snap_flag(entry, 'auto', + bool(source.get('pending', False)) and + bool(source.get('auto', False)) and kind == 'branches', + doc_flag) + used_reciprocals = [] for entry in a_entries: local_index = resolve_cp_index(a_cps, entry['control_point_position']) @@ -1606,6 +1680,8 @@ def snap_pending(entry, desired, doc_flag): pb = _cp_position(b_cps[far_index]) da = endpoint_tangent(a_line, pa) db = endpoint_tangent(b_line, pb) + if normalize_auto_flag(entry, kind): + out['a_changed'] = True set_field(entry, 'control_point_index', local_index, 'a_changed') snap_position(entry, 'control_point_position', pa, 'a_changed') @@ -1638,6 +1714,8 @@ def snap_pending(entry, desired, doc_flag): } if entry.get('pending', False): reciprocal['pending'] = True + if entry.get('auto', False) and kind == 'branches': + reciprocal['auto'] = True b[kind] = _branches_of(b, kind) + [reciprocal] out['b_changed'] = True out['notes'].append(f"restored reciprocal link {b_name} -> {a_name}") @@ -1654,6 +1732,7 @@ def snap_pending(entry, desired, doc_flag): # Review state stays in lockstep on both refs, as VC3D keeps it. snap_pending(reciprocal, bool(entry.get('pending', False)), 'b_changed') + snap_auto(reciprocal, entry, 'b_changed') # Pairs present in the base but absent from merged A were deleted by # the merge: mirror the deletion. Unrelated B->A entries (pairs A never diff --git a/volume-cartographer/scripts/tests/test_fiber_merge.py b/volume-cartographer/scripts/tests/test_fiber_merge.py index d884397d19..9c713cc33d 100644 --- a/volume-cartographer/scripts/tests/test_fiber_merge.py +++ b/volume-cartographer/scripts/tests/test_fiber_merge.py @@ -2122,3 +2122,125 @@ def test_v3_save_over_a_v4_base_with_span_tags_is_a_regression_conflict(): downgraded = copy.deepcopy(plain) downgraded['version'] = 3 assert fiber_merge.legacy_regression(downgraded, plain) is None + + +# --- the automatic-link mark ("auto": an unreviewed link made by the Fiber +# Map's "Link A and B"; VC3D's invariant: only with pending, ordinary kind) --- + +def test_refresh_mirrors_auto_onto_existing_reciprocal(): + b_cps = [cp(i, dz=50.0) for i in range(4)] + a, b = make_pair('a.json', 'b.json', BASE_CPS, b_cps, 2, 1, pending=True) + a['branches'][0]['auto'] = True + out = refresh_pair_links(a, b, 'a.json', 'b.json') + assert out['ok'] and out['b_changed'] and not out['a_changed'] + assert out['b_doc']['branches'][0]['pending'] is True + assert out['b_doc']['branches'][0]['auto'] is True + assert loader_issues({'a.json': out['a_doc'], 'b.json': out['b_doc']}) == [] + again = refresh_pair_links(out['a_doc'], out['b_doc'], 'a.json', 'b.json') + assert again['ok'] and not again['a_changed'] and not again['b_changed'] + + +def test_refresh_restores_reciprocal_with_auto(): + b_cps = [cp(i, dz=50.0) for i in range(4)] + a, b = make_pair('a.json', 'b.json', BASE_CPS, b_cps, 2, 1, pending=True) + a['branches'][0]['auto'] = True + b['branches'] = [] + out = refresh_pair_links(a, b, 'a.json', 'b.json') + assert out['ok'] and out['b_changed'] + assert out['b_doc']['branches'][0].get('auto') is True + assert out['b_doc']['branches'][0].get('pending') is True + + +def test_refresh_drops_auto_without_pending_and_on_adjacent_kind(): + b_cps = [cp(i, dz=50.0) for i in range(4)] + a, b = make_pair('a.json', 'b.json', BASE_CPS, b_cps, 2, 1, pending=False) + a['branches'][0]['auto'] = True # approved: the mark is invalid + b['branches'][0]['auto'] = True + out = refresh_pair_links(a, b, 'a.json', 'b.json') + assert out['ok'] and out['a_changed'] and out['b_changed'] + assert 'auto' not in out['a_doc']['branches'][0] + assert 'auto' not in out['b_doc']['branches'][0] + + a, b = adjacent_pair() + a['adjacent_branches'][0]['auto'] = True # adjacent: never automatic + out = refresh_pair_links(a, b, 'a.json', 'b.json') + assert out['ok'] + assert 'auto' not in out['a_doc']['adjacent_branches'][0] + assert 'auto' not in out['b_doc']['adjacent_branches'][0] + + +def test_merge_keeps_auto_on_pending_and_drops_it_on_approval(): + auto_link = dict(link('kb_a.json', BASE_CPS[2], OTHER, 2, pending=True), auto=True) + approved_link = link('kb_a.json', BASE_CPS[2], OTHER, 2, pending=False) + # Both sides still pending and automatic: the mark survives the merge. + base = make_fiber(BASE_CPS, branches=[auto_link]) + local = make_fiber_with_marker(make_fiber(BASE_CPS, branches=[auto_link], generation=5)) + remote = make_fiber(BASE_CPS, branches=[auto_link], generation=2) + result = merge_fibers(base, local, remote) + assert result['ok'] + assert result['merged']['branches'][0].get('auto') is True + # Approval on the remote wins and takes the mark with it. + remote = make_fiber(BASE_CPS, branches=[approved_link], generation=2) + result = merge_fibers(base, local, remote) + assert result['ok'] + assert result['merged']['branches'][0]['pending'] is False + assert 'auto' not in result['merged']['branches'][0] + + +def test_merge_shortcuts_normalize_the_auto_mark(): + stray = dict(link('kb_a.json', BASE_CPS[2], OTHER, 2, pending=False), auto=True) + base = make_fiber(BASE_CPS, branches=[stray]) + # identical sides, remote unchanged, local unchanged: every shortcut + for local, remote in ((base, base), + (make_fiber_with_marker(base), base), + (base, make_fiber_with_marker(base))): + result = merge_fibers(base, local, remote) + assert result['ok'] + assert 'auto' not in result['merged']['branches'][0] + + +def test_full_merge_and_refresh_normalize_every_copied_entry(): + # Full merge: an unrelated (third-fiber) entry with a stray mark on one + # side is copied into the merge normalized. + stray = dict(link('kb_c.json', BASE_CPS[1], OTHER, 1, pending=False), auto=True) + base = make_fiber(BASE_CPS) + local = make_fiber_with_marker(make_fiber(BASE_CPS, branches=[stray], generation=5)) + remote = make_fiber(BASE_CPS, generation=2, tags=['other']) + result = merge_fibers(base, local, remote) + assert result['ok'] + assert all('auto' not in e for e in result['merged']['branches']) + # Refresh: an unrelated entry of B (to a third fiber) is normalized too. + b_cps = [cp(i, dz=50.0) for i in range(4)] + a, b = make_pair('a.json', 'b.json', BASE_CPS, b_cps, 2, 1, pending=True) + b['branches'].append(dict(link('kb_c.json', b_cps[3], OTHER, 1, pending=False), auto=True)) + out = refresh_pair_links(a, b, 'a.json', 'b.json') + assert out['ok'] and out['b_changed'] + assert all('auto' not in e for e in out['b_doc']['branches']) + + +def test_merge_arbitrates_the_auto_mark_against_the_base_when_both_pending(): + auto_link = dict(link('kb_a.json', BASE_CPS[2], OTHER, 2, pending=True), auto=True) + human_link = link('kb_a.json', BASE_CPS[2], OTHER, 2, pending=True) + # Base automatic; local cleared the mark (a human took it over), remote + # kept it with unrelated newer edits: the cleared mark wins. + base = make_fiber(BASE_CPS, branches=[auto_link]) + local = make_fiber(BASE_CPS, branches=[human_link], generation=2) + remote = make_fiber(BASE_CPS, branches=[auto_link], generation=9, tags=['remote-edit']) + result = merge_fibers(base, local, remote) + assert result['ok'] + assert result['merged']['branches'][0]['pending'] is True + assert 'auto' not in result['merged']['branches'][0] + # Base human pending; remote re-linked automatically, local untouched but + # newer elsewhere: the new automatic link survives. + base = make_fiber(BASE_CPS, branches=[human_link]) + local = make_fiber(BASE_CPS, branches=[human_link], generation=9, tags=['local-edit']) + remote = make_fiber(BASE_CPS, branches=[auto_link], generation=2) + result = merge_fibers(base, local, remote) + assert result['ok'] + assert result['merged']['branches'][0].get('auto') is True + # No base: the human state wins. + local = make_fiber(BASE_CPS, branches=[human_link], generation=2) + remote = make_fiber(BASE_CPS, branches=[auto_link], generation=9, tags=['remote-edit']) + result = merge_fibers(make_fiber(BASE_CPS), local, remote) + assert result['ok'] + assert 'auto' not in result['merged']['branches'][0] From 6a85404c0ecdd548f04e5a8aadacdc9d2f17556d Mon Sep 17 00:00:00 2001 From: skyward7187 Date: Wed, 21 Oct 2026 16:10:07 -0400 Subject: [PATCH 2/7] VC3D auto-link: one gesture when an annotation had to be saved first Phase B finalized every open Line Annotation dialog and every save bumps the fiber generation, so any open annotation made the map stale and Link had to be chosen twice. Now only dialogs of the two fibers are finalized (prepareForUserFacingLineAnnotationOpen takes a session filter), and when a save did make the map stale the workspace keeps the request (_autoLinkResume: the pair, the click, the two fibers' drawn geometry captured before phase B) and runs it again by itself once the automatic Update publishes, provided both fibers draw exactly as the click saw them and the layout is fresh; otherwise the status line asks for Link again. A cancellation token covers the queued callback and a click inside phase B's own drains; cancelling clears the promise. Also carried here (replaced by the palette commit after the merge): an interim paler fill for automatic pending markers. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PrhaQ58LygRfA7y3Psp7uf --- .../apps/VC3D/FiberMapWorkspace.cpp | 168 ++++++++++++++++-- .../apps/VC3D/FiberMapWorkspace.hpp | 29 +++ .../apps/VC3D/LineAnnotationController.cpp | 14 +- .../apps/VC3D/LineAnnotationController.hpp | 8 +- .../apps/VC3D/LineAnnotationDialog.cpp | 54 +++++- .../apps/VC3D/LineAnnotationDialog.hpp | 5 + .../VC3D/LineAnnotationGeneratedViews.cpp | 13 +- .../VC3D/LineAnnotationGeneratedViews.hpp | 10 ++ .../VC3D/overlays/FiberOverlayController.cpp | 7 + .../docs/line_annotation_fibers.md | 19 +- 10 files changed, 295 insertions(+), 32 deletions(-) diff --git a/volume-cartographer/apps/VC3D/FiberMapWorkspace.cpp b/volume-cartographer/apps/VC3D/FiberMapWorkspace.cpp index 955d42b73e..08ef3d9048 100644 --- a/volume-cartographer/apps/VC3D/FiberMapWorkspace.cpp +++ b/volume-cartographer/apps/VC3D/FiberMapWorkspace.cpp @@ -356,6 +356,18 @@ QPen cosmeticPen(const QColor& color, qreal width) // Dotted, for the gap spans and the break rims: distinct from the // interpolated dash by form, from every fiber colour by hue. +// The pale fill of an automatic pending endpoint (dashed look): the +// shared autoPendingFillAlpha rule on whatever alpha the dot would have had. +QColor endpointFill(const QColor& fill, bool automaticDashed) +{ + if (!automaticDashed) { + return fill; + } + QColor pale = fill; + pale.setAlpha(vc3d::line_annotation::autoPendingFillAlpha(fill.alpha())); + return pale; +} + QPen dashedPen(const QColor& color, qreal width) { QPen pen = cosmeticPen(color, width); @@ -1125,6 +1137,9 @@ FiberMapWorkspace::FiberMapWorkspace(LineAnnotationController* controller, if (fiberId == 0 && !errorExtent.isValid()) { return; } + // Choosing a row is moving on from a waiting Link (before the + // stale gate below, which returns on the map it waits for). + cancelAutoLinkResume(); // The same gate the scene click and the control-point menu use; // acting on a map whose dependencies moved is showing a wrong // picture, not a late one. Evaluated here and applied on the next @@ -1628,6 +1643,7 @@ void FiberMapWorkspace::clearLayout(const QString& reason) _latchedReason.clear(); // The fibers the message was about are gone with the layout. _operationStatus.clear(); + cancelAutoLinkResume(); if (_tree) { _tree->clear(); } @@ -2446,6 +2462,8 @@ void FiberMapWorkspace::publishRebuild(RebuildJobResult& job) selectFiberRow(_highlightedFiber); } } + // A Link that waited for this rebuild (see _autoLinkResume). + resumeAutoLinkAfterPublish(); const qint64 publishMs = publishTimer.elapsed(); Logger()->info( "fiber map rebuild: GUI stalls snapshot {} ms + publish {} ms · " @@ -2923,6 +2941,7 @@ void FiberMapWorkspace::rebuildScene(const QString& emptyMessage) // compounds, where the dots overlap, to about the palette's own. QColor fill = look.fill; fill.setAlpha(kLinkEndpointFillAlpha); + fill = endpointFill(fill, autoDashed); auto* dot = new ScaledDot(QBrush(fill), kollesis ? cosmeticPen(kollesisColor(245), kKollesisRimWidthPx) : breakTag ? dottedPen(breakColor(245), kKollesisRimWidthPx) @@ -3075,7 +3094,8 @@ void FiberMapWorkspace::rebuildScene(const QString& emptyMessage) // would have - selection and deselection then agree. const auto look = endpointLook(fiber.id, static_cast(i)); const bool automatic = look && look->anyAuto; - auto* dot = new ScaledDot(automatic ? QBrush(look->fill) : fill, + auto* dot = new ScaledDot( + automatic ? QBrush(endpointFill(look->fill, look->dashed)) : fill, kollesis ? rim : breakRim, crossingDotRadius, kMinCrossingDotPx, kMaxCrossingDotPx, crossingDotBounds, /*triangle=*/look && look->adjacent); @@ -3525,13 +3545,16 @@ uint64_t FiberMapWorkspace::fiberAt(const QPointF& scenePos) const void FiberMapWorkspace::handleSceneClick(const QPointF& scenePos, Qt::KeyboardModifiers modifiers) { + // Any click on the map is the user moving on from the last operation - + // before the stale gate, so a click on the stale map a Link is waiting + // to be rebuilt still cancels that Link. + cancelAutoLinkResume(); // A stale map's runtime ids may name different fibers than they did when it // was built, so it stops responding until rebuilt. if (refreshStaleState()) { return; } const uint64_t fiberId = fiberAt(scenePos); - // Any click on the map is the user moving on from the last operation. clearOperationStatus(); std::vector selection; if (modifiers.testFlag(Qt::ShiftModifier)) { @@ -3932,6 +3955,7 @@ std::optional FiberMapWorkspace::endpointLook( void FiberMapWorkspace::setHighlightedFiber(uint64_t fiberId) { + cancelAutoLinkResume(); clearOperationStatus(); setSelectedFibers(fiberId != 0 ? std::vector{fiberId} : std::vector{}); } @@ -4048,7 +4072,7 @@ void FiberMapWorkspace::drawControlPointDots(uint64_t fiberId) } if (kollesis || breakTag) { if (look && (placedNonSuspectLink || anyAuto)) { - fill = QBrush(look->fill); + fill = QBrush(endpointFill(look->fill, autoDashed)); } else { fill = QBrush(Qt::NoBrush); } @@ -4058,7 +4082,7 @@ void FiberMapWorkspace::drawControlPointDots(uint64_t fiberId) // An untagged endpoint with an automatic link: the pending // palette (fill and outline), dashed only when every pending // link is automatic - a mixed point stays solid in the palette. - fill = QBrush(look->fill); + fill = QBrush(endpointFill(look->fill, autoDashed)); rim = autoDashed ? dashedPen(look->pen, 1.0) : cosmeticPen(look->pen, 1.0); } auto* dot = new ScaledDot(fill, rim, @@ -4270,14 +4294,96 @@ void FiberMapWorkspace::handleControlPointMenu(const QPointF& scenePos, const QP menu.exec(globalPos); } +bool FiberMapWorkspace::sameDrawnFiber(const vc3d::fiber_map::PlacedFiber& a, + const vc3d::fiber_map::PlacedFiber& b) +{ + if (a.id != b.id || a.fileName != b.fileName || a.runs.size() != b.runs.size() || + a.controlPoints != b.controlPoints) { + return false; + } + for (std::size_t i = 0; i < a.runs.size(); ++i) { + const vc3d::fiber_map::Run& ra = a.runs[i]; + const vc3d::fiber_map::Run& rb = b.runs[i]; + if (ra.traced != rb.traced || ra.gap != rb.gap || ra.damaged != rb.damaged || + ra.firstControl != rb.firstControl || ra.lastControl != rb.lastControl || + ra.points != rb.points || ra.arclengths != rb.arclengths) { + return false; + } + } + return true; +} + +void FiberMapWorkspace::resumeAutoLinkAfterPublish() +{ + if (!_autoLinkResume || !_layoutBuilt || !_controller || _autoLinkInFlight || + _deleteInFlight) { + return; + } + // Only a layout that is fresh against the dependencies of this moment + // can be linked on: a publication that landed inside phase B followed by + // another change (the umbilicus file, a setting) leaves _entries stale + // while the next automatic Update is still to come. The request stays + // and this runs again from that publication. + const vc3d::fiber_map::FiberMapDependencies dependencies = layoutDependencies(); + if (vc3d::fiber_map::staleVerdictFor(dependencies, currentDependencies(), + /*layoutBuilt=*/true, QString()) + .action != StaleVerdict::Action::Fresh) { + return; + } + const AutoLinkResume resume = std::move(*_autoLinkResume); + _autoLinkResume.reset(); + const auto subject = _entries.constFind(resume.subjectId); + const auto other = _entries.constFind(resume.otherId); + if (subject == _entries.constEnd() || other == _entries.constEnd() || + !sameDrawnFiber(subject->fiber, resume.subject) || + !sameDrawnFiber(other->fiber, resume.other)) { + // The saved work moved one of the fibers on the map: the click no + // longer names a meeting on it. + setOperationStatus(tr("Saved the open line annotation; the rebuilt map draws the " + "fibers differently — choose Link again")); + Logger()->warn("Fiber map auto-link: the rebuilt map draws the fibers differently; " + "not resumed"); + return; + } + // Deferred exactly like the menu's trigger, against the dependencies the + // published layout was built from (phase A's capture): the resumed + // start re-verifies them against the current ones first. + const QPointer self(this); + const uint64_t token = _autoLinkResumeToken; + QMetaObject::invokeMethod( + this, + [self, resume, dependencies, token]() { + if (!self || self->_autoLinkResumeToken != token) { + // Cancelled between the publication and this callback. + return; + } + Logger()->info("Fiber map auto-link: resumed after the map update"); + self->startAutoLink(resume.subjectId, resume.otherId, resume.scenePos, dependencies); + }, + Qt::QueuedConnection); +} + +void FiberMapWorkspace::cancelAutoLinkResume() +{ + const bool waiting = _autoLinkResume.has_value(); + _autoLinkResume.reset(); + ++_autoLinkResumeToken; + if (waiting) { + // The line promised the link would continue by itself; it will not. + clearOperationStatus(); + } +} + void FiberMapWorkspace::startAutoLink(uint64_t subjectId, uint64_t otherId, QPointF scenePos, const vc3d::fiber_map::FiberMapDependencies& menuDependencies) { // Three phases on one queued callback, every one guarded: // B. the controller finalizes, saves and closes every live session of - // the two fibers and drains the saves; if that moved the fiber - // generation the map is stale and the automatic Update is armed - - // the user chooses Link again on the rebuilt map (the pair stays + // the two fibers (only those) and drains the saves; if that moved + // the fiber generation the map is stale and the automatic Update is + // armed - the request is kept (_autoLinkResume) and run again from + // publishRebuild when the rebuilt map draws both fibers as the click + // saw them, else the user chooses Link again (the pair stays // selected); // C. the meeting of the two DRAWN fibers nearest the click, inverted // exactly to a position on each fiber's stored line points; @@ -4288,8 +4394,13 @@ void FiberMapWorkspace::startAutoLink(uint64_t subjectId, uint64_t otherId, QPoi if (!_controller || _autoLinkInFlight || _deleteInFlight) { return; } - // A new attempt supersedes the last one's message. + // A new attempt supersedes the last one's message and any waiting resume. + cancelAutoLinkResume(); clearOperationStatus(); + // The request's token: a click or a row chosen inside phase B's drains + // bumps it, and that is this request cancelled - nothing is stored or + // continued after preparation then. + const uint64_t requestToken = _autoLinkResumeToken; const QPointer self(this); const QPointer controller(_controller); const auto fresh = [self, menuDependencies]() { @@ -4329,6 +4440,10 @@ void FiberMapWorkspace::startAutoLink(uint64_t subjectId, uint64_t otherId, QPoi markStale(tr("Fibers changed — press Update")); return; } + // The geometry the click was made on, by value, before phase B's yields + // (a publication can land inside them): what a resume compares against. + const vc3d::fiber_map::PlacedFiber subjectAtClick = _entries.constFind(subjectId)->fiber; + const vc3d::fiber_map::PlacedFiber otherAtClick = _entries.constFind(otherId)->fiber; _autoLinkInFlight = true; QGuiApplication::setOverrideCursor(Qt::WaitCursor); // Whatever path leaves this function, the flag and the cursor are @@ -4359,12 +4474,40 @@ void FiberMapWorkspace::startAutoLink(uint64_t subjectId, uint64_t otherId, QPoi bail(prepared.message); return; } + if (_autoLinkResumeToken != requestToken) { + bail(tr("The link was cancelled by the click on the map")); + refreshStaleState(); + return; + } if (!fresh()) { - // A session had unsaved work: the map must be rebuilt before a - // meeting can be located on it. The automatic Update is already - // armed by the generation bump; the selection restore keeps the pair. - bail(tr("Saved the open line annotation; the map is updating — choose Link again")); + // A session of one of the fibers was saved: the map must be rebuilt + // before a meeting can be located on it. The automatic Update is + // already armed by the generation bump and the selection restore + // keeps the pair; the request itself waits for the publication + // (resumeAutoLinkAfterPublish), which runs it again when both + // fibers still draw as they did under the click. A publication + // that already landed inside phase B's yields is taken up now. + _autoLinkResume = AutoLinkResume{subjectId, otherId, scenePos, subjectAtClick, otherAtClick}; + bail(tr("Saved the open line annotation; the map is updating and the link continues by itself")); refreshStaleState(); + if (!self) { + return; + } + if (_layoutBuilt && + vc3d::fiber_map::staleVerdictFor(layoutDependencies(), currentDependencies(), + /*layoutBuilt=*/true, QString()) + .action == StaleVerdict::Action::Fresh) { + // Queued: this operation is still in flight here (the scope guard + // releases _autoLinkInFlight on return), and the helper refuses + // while it is. + QMetaObject::invokeMethod( + this, [self]() { + if (self) { + self->resumeAutoLinkAfterPublish(); + } + }, + Qt::QueuedConnection); + } return; } @@ -4541,6 +4684,7 @@ void FiberMapWorkspace::confirmAndDeleteFiber( return; } _deleteInFlight = true; + cancelAutoLinkResume(); clearOperationStatus(); // The confirmation is modeless (open(), not exec()): a nested loop with a // parented dialog would be undefined if the workspace were torn down diff --git a/volume-cartographer/apps/VC3D/FiberMapWorkspace.hpp b/volume-cartographer/apps/VC3D/FiberMapWorkspace.hpp index 46d76519f8..af716fe03f 100644 --- a/volume-cartographer/apps/VC3D/FiberMapWorkspace.hpp +++ b/volume-cartographer/apps/VC3D/FiberMapWorkspace.hpp @@ -530,6 +530,35 @@ class FiberMapWorkspace : public QMainWindow // "Link A and B" in flight: the menu entry is disabled and a delete // refuses, like _deleteInFlight the other way round. bool _autoLinkInFlight = false; + // A "Link A and B" that had to save an open annotation of one of its + // fibers first (phase B moved the fiber generation, so the map it was + // clicked on is stale): the request waits for the automatic Update and + // runs again by itself from publishRebuild when the rebuilt map draws + // both fibers exactly as the click saw them; a rebuilt map that draws + // them differently asks for Link again instead, because the click + // would then name geometry that no longer exists. Dropped by any other + // user action on the map (a click, a tree row, a new Link, a delete, a + // layout clear); a pressed Update keeps it, it is the rebuild it waits + // for. + struct AutoLinkResume { + uint64_t subjectId = 0; + uint64_t otherId = 0; + QPointF scenePos; + vc3d::fiber_map::PlacedFiber subject; + vc3d::fiber_map::PlacedFiber other; + }; + std::optional _autoLinkResume; + // Bumped by every cancellation: the queued callback a publication hands + // the request to carries the token it was queued under and starts + // nothing when the token moved (a click between the publication and + // the callback must cancel the resume, not only the waiting request). + uint64_t _autoLinkResumeToken = 0; + void cancelAutoLinkResume(); + void resumeAutoLinkAfterPublish(); + // Same drawn geometry: the runs (flags, points, arclengths) and the + // control-point positions the locator reads. + [[nodiscard]] static bool sameDrawnFiber(const vc3d::fiber_map::PlacedFiber& a, + const vc3d::fiber_map::PlacedFiber& b); // Fibers currently carrying the subtle network emphasis, so the next // selection change can restore exactly them. std::vector _networkEmphasized; diff --git a/volume-cartographer/apps/VC3D/LineAnnotationController.cpp b/volume-cartographer/apps/VC3D/LineAnnotationController.cpp index 66a445bd83..8d77ff0390 100644 --- a/volume-cartographer/apps/VC3D/LineAnnotationController.cpp +++ b/volume-cartographer/apps/VC3D/LineAnnotationController.cpp @@ -2224,7 +2224,8 @@ void LineAnnotationController::launchFromViewerAtPoint(CChunkedVolumeViewer* vie } bool LineAnnotationController::prepareForUserFacingLineAnnotationOpen( - const std::function& stillValid) + const std::function& stillValid, + const std::function& only) { const QPointer alive(this); struct DialogRecord { @@ -2239,6 +2240,11 @@ bool LineAnnotationController::prepareForUserFacingLineAnnotationOpen( if (!pane.dialog) { continue; } + // With a filter, a dialog without a session has nothing to decide + // on and stays; without one every dialog goes, as before. + if (only && (!pane.session || !only(*pane.session))) { + continue; + } dialogs.push_back({pane.surfaceName, pane.dialog, pane.session}); } if (dialogs.empty()) { @@ -3077,8 +3083,10 @@ LineAnnotationController::prepareFiberMapAutoLink(uint64_t fiberIdA, _fiberLoadSequence == loadSequence && _state && _state->currentVolume() == volume; }; // D8: dialogs of either fiber are finalized, saved and closed exactly as - // opening another fiber does it (this finalizes EVERY dialog session). - if (!prepareForUserFacingLineAnnotationOpen(stillValid)) { + // opening another fiber does it - but ONLY those: an unrelated dialog + // stays open and unsaved, so it neither bumps the fiber generation + // (which would make the map stale for nothing) nor loses its place. + if (!prepareForUserFacingLineAnnotationOpen(stillValid, isParticipant)) { return refuse(tr("Could not finalize the open line annotation.")); } if (!alive) { diff --git a/volume-cartographer/apps/VC3D/LineAnnotationController.hpp b/volume-cartographer/apps/VC3D/LineAnnotationController.hpp index 1f34b216ac..856928ddc9 100644 --- a/volume-cartographer/apps/VC3D/LineAnnotationController.hpp +++ b/volume-cartographer/apps/VC3D/LineAnnotationController.hpp @@ -732,7 +732,13 @@ private slots: void cleanupSurfaceName(const std::string& surfaceName); // stillValid (optional): forwarded to each session's finalization and // checked after each save (both can yield); false makes it return false. - bool prepareForUserFacingLineAnnotationOpen(const std::function& stillValid = {}); + // only (optional): finalizes, saves and closes just the dialogs whose + // session satisfies it (the Fiber Map's auto-link: the two fibers being + // linked), leaving every other dialog open and unsaved; unset walks + // every dialog, as opening another fiber does. + bool prepareForUserFacingLineAnnotationOpen( + const std::function& stillValid = {}, + const std::function& only = {}); bool launchSession(SourceKind sourceKind, const std::string& surfaceName, std::shared_ptr sourceSurface, diff --git a/volume-cartographer/apps/VC3D/LineAnnotationDialog.cpp b/volume-cartographer/apps/VC3D/LineAnnotationDialog.cpp index 5029cf0f49..96f4a3f759 100644 --- a/volume-cartographer/apps/VC3D/LineAnnotationDialog.cpp +++ b/volume-cartographer/apps/VC3D/LineAnnotationDialog.cpp @@ -640,7 +640,11 @@ class LineAnnotationOverviewBar final : public QWidget continue; } const qreal x = xForFraction(dot.fraction); - QPen edgePen = dot.edge ? QPen(*dot.edge, 1.5) : QPen(dot.color.darker(150), 1.0); + // The default edge is the fill's darker shade at full opacity: + // an automatic pending dot's pale fill keeps a legible outline. + QColor edgeBase = dot.color; + edgeBase.setAlpha(255); + QPen edgePen = dot.edge ? QPen(*dot.edge, 1.5) : QPen(edgeBase.darker(150), 1.0); if (dot.dottedEdge) { edgePen.setStyle(Qt::DotLine); } else if (dot.dashedEdge) { @@ -5411,6 +5415,8 @@ void LineAnnotationDialog::updateGeneratedDynamicOverlaysFast(bool updateCurrent !_fastCurrentCutOverlayItems.sameHvPendingBranchControlPoints || !_fastCurrentCutOverlayItems.autoPendingBranchControlPoints || !_fastCurrentCutOverlayItems.sameHvAutoPendingBranchControlPoints || + !_fastCurrentCutOverlayItems.autoPendingTaggedBranchControlPoints || + !_fastCurrentCutOverlayItems.sameHvAutoPendingTaggedBranchControlPoints || !_fastCurrentCutOverlayItems.autoPendingLinkRings || !_fastCurrentCutOverlayItems.sameHvAutoPendingLinkRings || !_fastCurrentCutOverlayItems.kollesisRings || @@ -5506,13 +5512,38 @@ void LineAnnotationDialog::updateGeneratedDynamicOverlaysFast(bool updateCurrent item->setBrush(brush); item->setZValue(z); }; + // The pale automatic fill (autoPendingFillAlpha, the shared rule). + QColor autoPendingFill = pendingBranchControlBrush.color(); + autoPendingFill.setAlpha( + vc3d::line_annotation::autoPendingFillAlpha(autoPendingFill.alpha())); + const QBrush autoPendingBranchControlBrush(autoPendingFill); + QColor sameHvAutoPendingFill = sameHvPendingBranchControlBrush.color(); + sameHvAutoPendingFill.setAlpha( + vc3d::line_annotation::autoPendingFillAlpha(sameHvAutoPendingFill.alpha())); + const QBrush sameHvAutoPendingBranchControlBrush(sameHvAutoPendingFill); _fastCurrentCutOverlayItems.autoPendingBranchControlPoints = new QGraphicsPathItem(); makeDashed(_fastCurrentCutOverlayItems.autoPendingBranchControlPoints, - pendingBranchControlPen, pendingBranchControlBrush, 162.51); + pendingBranchControlPen, autoPendingBranchControlBrush, 162.51); _fastCurrentCutOverlayItems.sameHvAutoPendingBranchControlPoints = new QGraphicsPathItem(); makeDashed(_fastCurrentCutOverlayItems.sameHvAutoPendingBranchControlPoints, - sameHvPendingBranchControlPen, sameHvPendingBranchControlBrush, 162.51); + sameHvPendingBranchControlPen, sameHvAutoPendingBranchControlBrush, 162.51); + // Tagged automatic points: the same pale fill with the solid pending + // outline (the tag ring above replaces it), as the shared renderer. + _fastCurrentCutOverlayItems.autoPendingTaggedBranchControlPoints = + new QGraphicsPathItem(); + _fastCurrentCutOverlayItems.autoPendingTaggedBranchControlPoints->setPen( + pendingBranchControlPen); + _fastCurrentCutOverlayItems.autoPendingTaggedBranchControlPoints->setBrush( + autoPendingBranchControlBrush); + _fastCurrentCutOverlayItems.autoPendingTaggedBranchControlPoints->setZValue(162.5); + _fastCurrentCutOverlayItems.sameHvAutoPendingTaggedBranchControlPoints = + new QGraphicsPathItem(); + _fastCurrentCutOverlayItems.sameHvAutoPendingTaggedBranchControlPoints->setPen( + sameHvPendingBranchControlPen); + _fastCurrentCutOverlayItems.sameHvAutoPendingTaggedBranchControlPoints->setBrush( + sameHvAutoPendingBranchControlBrush); + _fastCurrentCutOverlayItems.sameHvAutoPendingTaggedBranchControlPoints->setZValue(162.5); _fastCurrentCutOverlayItems.autoPendingLinkRings = new QGraphicsPathItem(); makeDashed(_fastCurrentCutOverlayItems.autoPendingLinkRings, pendingBranchControlPen, QBrush(Qt::NoBrush), 162.8); @@ -5607,6 +5638,8 @@ void LineAnnotationDialog::updateGeneratedDynamicOverlaysFast(bool updateCurrent _fastCurrentCutOverlayItems.sameHvPendingBranchControlPoints, _fastCurrentCutOverlayItems.autoPendingBranchControlPoints, _fastCurrentCutOverlayItems.sameHvAutoPendingBranchControlPoints, + _fastCurrentCutOverlayItems.autoPendingTaggedBranchControlPoints, + _fastCurrentCutOverlayItems.sameHvAutoPendingTaggedBranchControlPoints, _fastCurrentCutOverlayItems.autoPendingLinkRings, _fastCurrentCutOverlayItems.sameHvAutoPendingLinkRings, _fastCurrentCutOverlayItems.kollesisRings, @@ -5671,6 +5704,8 @@ void LineAnnotationDialog::updateGeneratedDynamicOverlaysFast(bool updateCurrent QPainterPath sameHvPendingBranchControlPath; QPainterPath autoPendingBranchControlPath; QPainterPath sameHvAutoPendingBranchControlPath; + QPainterPath autoPendingTaggedBranchControlPath; + QPainterPath sameHvAutoPendingTaggedBranchControlPath; QPainterPath autoPendingLinkRingPath; QPainterPath sameHvAutoPendingLinkRingPath; QPainterPath kollesisRingPath; @@ -5757,6 +5792,10 @@ void LineAnnotationDialog::updateGeneratedDynamicOverlaysFast(bool updateCurrent if (dashed && !tagged) { addLinkedMarker(control.hasSameHvPendingLinks ? sameHvAutoPendingBranchControlPath : autoPendingBranchControlPath); + } else if (dashed) { + addLinkedMarker(control.hasSameHvPendingLinks + ? sameHvAutoPendingTaggedBranchControlPath + : autoPendingTaggedBranchControlPath); } else { addLinkedMarker(control.hasSameHvPendingLinks ? sameHvPendingBranchControlPath : pendingBranchControlPath); @@ -5779,6 +5818,10 @@ void LineAnnotationDialog::updateGeneratedDynamicOverlaysFast(bool updateCurrent autoPendingBranchControlPath); _fastCurrentCutOverlayItems.sameHvAutoPendingBranchControlPoints->setPath( sameHvAutoPendingBranchControlPath); + _fastCurrentCutOverlayItems.autoPendingTaggedBranchControlPoints->setPath( + autoPendingTaggedBranchControlPath); + _fastCurrentCutOverlayItems.sameHvAutoPendingTaggedBranchControlPoints->setPath( + sameHvAutoPendingTaggedBranchControlPath); _fastCurrentCutOverlayItems.autoPendingLinkRings->setPath(autoPendingLinkRingPath); _fastCurrentCutOverlayItems.sameHvAutoPendingLinkRings->setPath( sameHvAutoPendingLinkRingPath); @@ -6812,6 +6855,11 @@ void LineAnnotationDialog::rebuildOverviewBarLayout() const bool autoPending = !control.isLinkCandidate && control.hasPendingLinks && vc3d::line_annotation::linkedMarkerDashed(true, control.hasAutoPendingLinks); const QColor pendingColor = dot.color; + if (autoPending) { + // The pale automatic fill (the shared rule); the edge and the + // separate ring keep the full pending colour. + dot.color.setAlpha(vc3d::line_annotation::autoPendingFillAlpha(255)); + } if (autoPending && !(control.isKollesisTermination || control.isBreak)) { dot.dashedEdge = true; } diff --git a/volume-cartographer/apps/VC3D/LineAnnotationDialog.hpp b/volume-cartographer/apps/VC3D/LineAnnotationDialog.hpp index 728c7abe95..1cd0394ea4 100644 --- a/volume-cartographer/apps/VC3D/LineAnnotationDialog.hpp +++ b/volume-cartographer/apps/VC3D/LineAnnotationDialog.hpp @@ -96,6 +96,11 @@ class LineAnnotationDialog : public QMainWindow // pending point gets next to its tag ring. QGraphicsPathItem* autoPendingBranchControlPoints = nullptr; QGraphicsPathItem* sameHvAutoPendingBranchControlPoints = nullptr; + // The TAGGED automatic pending point's marker: the pale fill under + // its tag ring with the pending outline solid (the tag ring covers + // it), beside the separate dashed link ring below. + QGraphicsPathItem* autoPendingTaggedBranchControlPoints = nullptr; + QGraphicsPathItem* sameHvAutoPendingTaggedBranchControlPoints = nullptr; QGraphicsPathItem* autoPendingLinkRings = nullptr; QGraphicsPathItem* sameHvAutoPendingLinkRings = nullptr; // Hollow yellow rings of kollesis-tagged points, drawn over whichever diff --git a/volume-cartographer/apps/VC3D/LineAnnotationGeneratedViews.cpp b/volume-cartographer/apps/VC3D/LineAnnotationGeneratedViews.cpp index 50b0cdecca..6465e7e6bc 100644 --- a/volume-cartographer/apps/VC3D/LineAnnotationGeneratedViews.cpp +++ b/volume-cartographer/apps/VC3D/LineAnnotationGeneratedViews.cpp @@ -492,11 +492,14 @@ std::string applyGeneratedOverlay(CChunkedVolumeViewer* viewer, if (control.hasPendingLinks) { style = control.hasSameHvPendingLinks ? sameHvPendingBranchControlPointStyle : pendingBranchControlPointStyle; - // An unreviewed automatic link: same colours, dashed outline. A - // tagged point keeps its tag ring and gets a separate dashed - // link ring instead (autoPendingLinkRingStyle below). - if (linkedMarkerDashed(true, control.hasAutoPendingLinks) && - !drawsTagRing(control)) { + // An unreviewed automatic link: same colours, paler fill, dashed + // outline. A tagged point keeps its tag ring and gets a separate + // dashed link ring instead (autoPendingLinkRingStyle below). + const bool automatic = linkedMarkerDashed(true, control.hasAutoPendingLinks); + if (automatic) { + style.brushColor.setAlpha(autoPendingFillAlpha(style.brushColor.alpha())); + } + if (automatic && !drawsTagRing(control)) { style.penStyle = Qt::DashLine; } } else if (control.hasBranches) { diff --git a/volume-cartographer/apps/VC3D/LineAnnotationGeneratedViews.hpp b/volume-cartographer/apps/VC3D/LineAnnotationGeneratedViews.hpp index ab259d5f1d..1acc2cee89 100644 --- a/volume-cartographer/apps/VC3D/LineAnnotationGeneratedViews.hpp +++ b/volume-cartographer/apps/VC3D/LineAnnotationGeneratedViews.hpp @@ -1889,6 +1889,16 @@ inline bool linkedMarkerDashed(bool drawsPending, bool autoPending) return drawsPending && autoPending; } +// The fill of an automatic pending marker (every point linkedMarkerDashed +// says yes to, tagged or not) is thinned to two fifths of the pending +// palette's fill alpha: on a small marker the dashed outline alone does not +// tell it from a human-made pending link at a glance, the paler fill does. +// Shared by every renderer so the two looks agree everywhere. +[[nodiscard]] inline int autoPendingFillAlpha(int pendingFillAlpha) +{ + return (pendingFillAlpha * 2) / 5; +} + // The kollesis-termination ring colour: the ordinary control-point yellow. // The tag is told apart by form, not hue: a tagged point draws as a hollow // yellow ring (a linked one keeps its link-state fill inside the ring). diff --git a/volume-cartographer/apps/VC3D/overlays/FiberOverlayController.cpp b/volume-cartographer/apps/VC3D/overlays/FiberOverlayController.cpp index 58938013ac..66f85449c3 100644 --- a/volume-cartographer/apps/VC3D/overlays/FiberOverlayController.cpp +++ b/volume-cartographer/apps/VC3D/overlays/FiberOverlayController.cpp @@ -1,5 +1,7 @@ #include "FiberOverlayController.hpp" +#include "../LineAnnotationGeneratedViews.hpp" + #include "OverlayBatchItem.hpp" #include "../volume_viewers/VolumeViewerBase.hpp" @@ -260,6 +262,11 @@ void FiberOverlayController::collectPrimitives(VolumeViewerBase* viewer, style.brushColor = mark.sameHv ? QColor(255, 140, 0, 175) : QColor(210, 95, 255, 175); } const QColor linkPen = style.penColor; + if (automatic) { + // The pale automatic fill (the shared rule), tagged or not. + style.brushColor.setAlpha( + vc3d::line_annotation::autoPendingFillAlpha(style.brushColor.alpha())); + } style.penColor.setAlphaF(style.penColor.alphaF() * opacity); style.brushColor.setAlphaF(style.brushColor.alphaF() * opacity); style.penWidth = 2.0; diff --git a/volume-cartographer/docs/line_annotation_fibers.md b/volume-cartographer/docs/line_annotation_fibers.md index 06d99fb024..39356763c2 100644 --- a/volume-cartographer/docs/line_annotation_fibers.md +++ b/volume-cartographer/docs/line_annotation_fibers.md @@ -301,10 +301,13 @@ With two fibers selected, Ctrl+right-click on either of them (its line or one of its dots) adds **Link and ** to the menu, above Delete. It: 1. finalizes, saves and closes any open Line Annotation of either fiber (what - opening another fiber does), and every dialog-less session of them (the - intersection inspection included), saving each first. If that saved - something the map is stale; it updates by itself and the status line asks - you to choose Link again on the rebuilt map (the pair stays selected); + opening another fiber does; annotations of other fibers stay open), and + every dialog-less session of them (the intersection inspection included), + saving each first. If that saved something the map is stale: it updates + by itself and the link continues on the rebuilt map without a second + gesture, as long as both fibers still draw where the click saw them. If + the saved work moved one of them on the map, the status line asks you to + choose Link again (the pair stays selected); 2. finds where the two fibers meet on the map: the drawn crossing nearest the click, or, when the drawn lines never cross, their closest approach if it is within 1 mm on the map (otherwise the link is refused). That point is @@ -331,10 +334,10 @@ the link is withdrawn from the loaded fibers (every copy, including an edit's synced moves of its endpoints during the wait) and the status line says so. A pending automatic link is an ordinary pending link whose control-point -markers draw with a DASHED outline in the pending colours (blue, or the orange -same-H/V warning palette) everywhere a linked point is drawn: the cut views and -strips (both renderers), the overview bar, the slice-view rings and the Fiber -Map dots. A tagged point (kollesis termination, break) keeps its tag ring and +markers draw with a DASHED outline and a paler (more transparent) fill in the +pending colours (blue, or the orange same-H/V warning palette) everywhere a +linked point is drawn: the cut views and strips (both renderers), the overview +bar, the slice-view rings and the Fiber Map dots. A tagged point (kollesis termination, break) keeps its tag ring and gets a separate dashed ring just outside it. The rule for a point carrying several links: dashed only when every pending link on it is automatic; a human-made pending link outranks. The control-point menu's approve entry reads From fea6a53a8a3550c8399f196732ef40d2338a2598 Mon Sep 17 00:00:00 2001 From: skyward7187 Date: Wed, 21 Oct 2026 16:10:07 -0400 Subject: [PATCH 3/7] VC3D auto-link: post-merge fixes The merged single-control Lasagna fallback in ensureSessionSolveBackends is taken only by a caller with the out-parameter (the finalizer); any other caller, the auto-link's pre-placement check of a one-control fiber included, ensures the trace inputs as it did before the merge. A fresh verdict that reverts staleness retries a waiting resume (freshness can return without a publication), and a resume cancelled while its callback is queued clears the continuation promise. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PrhaQ58LygRfA7y3Psp7uf --- .../apps/VC3D/FiberMapWorkspace.cpp | 20 +++++++++++++++---- .../apps/VC3D/FiberMapWorkspace.hpp | 5 +++++ .../apps/VC3D/LineAnnotationController.cpp | 12 +++++------ 3 files changed, 27 insertions(+), 10 deletions(-) diff --git a/volume-cartographer/apps/VC3D/FiberMapWorkspace.cpp b/volume-cartographer/apps/VC3D/FiberMapWorkspace.cpp index 03fc7091fb..f06e20fb1f 100644 --- a/volume-cartographer/apps/VC3D/FiberMapWorkspace.cpp +++ b/volume-cartographer/apps/VC3D/FiberMapWorkspace.cpp @@ -1774,6 +1774,11 @@ bool FiberMapWorkspace::applyStaleVerdict(const StaleVerdict& verdict) // The fresh floor with the selection (and the operation message). updateSelectionStatus(); } + // A Link waiting for a fresh layout (resumeAutoLinkAfterPublish + // kept it while the layout was stale) gets its chance here too: + // freshness can return without a rebuild (a volume switched away + // and back), and then no publication would ever retry it. + resumeAutoLinkAfterPublish(); break; } return false; @@ -4420,11 +4425,17 @@ void FiberMapWorkspace::resumeAutoLinkAfterPublish() // start re-verifies them against the current ones first. const QPointer self(this); const uint64_t token = _autoLinkResumeToken; + _autoLinkResumeQueued = true; QMetaObject::invokeMethod( this, [self, resume, dependencies, token]() { - if (!self || self->_autoLinkResumeToken != token) { - // Cancelled between the publication and this callback. + if (!self) { + return; + } + self->_autoLinkResumeQueued = false; + if (self->_autoLinkResumeToken != token) { + // Cancelled between the publication and this callback (the + // cancellation cleared the promise). return; } Logger()->info("Fiber map auto-link: resumed after the map update"); @@ -4435,10 +4446,11 @@ void FiberMapWorkspace::resumeAutoLinkAfterPublish() void FiberMapWorkspace::cancelAutoLinkResume() { - const bool waiting = _autoLinkResume.has_value(); + const bool promised = _autoLinkResume.has_value() || _autoLinkResumeQueued; _autoLinkResume.reset(); + _autoLinkResumeQueued = false; ++_autoLinkResumeToken; - if (waiting) { + if (promised) { // The line promised the link would continue by itself; it will not. clearOperationStatus(); } diff --git a/volume-cartographer/apps/VC3D/FiberMapWorkspace.hpp b/volume-cartographer/apps/VC3D/FiberMapWorkspace.hpp index 376483bf3a..da98ab1ce5 100644 --- a/volume-cartographer/apps/VC3D/FiberMapWorkspace.hpp +++ b/volume-cartographer/apps/VC3D/FiberMapWorkspace.hpp @@ -559,6 +559,11 @@ class FiberMapWorkspace : public QMainWindow // nothing when the token moved (a click between the publication and // the callback must cancel the resume, not only the waiting request). uint64_t _autoLinkResumeToken = 0; + // True from the moment a publication hands the request to its queued + // callback until that callback runs: a cancellation in between must + // clear the promise on the status line exactly like one of a waiting + // request. + bool _autoLinkResumeQueued = false; void cancelAutoLinkResume(); void resumeAutoLinkAfterPublish(); // Same drawn geometry: the runs (flags, points, arclengths) and the diff --git a/volume-cartographer/apps/VC3D/LineAnnotationController.cpp b/volume-cartographer/apps/VC3D/LineAnnotationController.cpp index 9d3789deaa..3ba3a16185 100644 --- a/volume-cartographer/apps/VC3D/LineAnnotationController.cpp +++ b/volume-cartographer/apps/VC3D/LineAnnotationController.cpp @@ -13468,17 +13468,17 @@ bool LineAnnotationController::ensureSessionSolveBackends( if (stillValid && !stillValid()) { return false; } - } else if (needsTrace && !rayOnly && session.controlPoints.size() == 1 && + } else if (needsTrace && !rayOnly && solveAsLasagna && session.controlPoints.size() == 1 && (!session.fiberPredictionField || !session.traceNormalSampler)) { // A seed placed without an inference dataset (VC3D allows that; the // seed itself was a Lasagna line). The synchronous save/close path // never opened a dataset picker for one control and must not start // to: this one solve runs as Lasagna (tails grown or rays); the - // session's persisted optimization mode is untouched. A caller that - // cannot take that decision (no out-parameter) is refused instead. - if (!solveAsLasagna) { - return false; - } + // session's persisted optimization mode is untouched. Only a caller + // that can take that decision (the finalizer, with the + // out-parameter) gets it; any other (the auto-link's pre-placement + // check of a one-control fiber) ensures the trace inputs below, as + // it always did. *solveAsLasagna = true; Logger()->info( "Line annotation finalize: single control without trace inputs, " From 4c0f76d641f4cad3ef61a836f32f61c07be1fbdb Mon Sep 17 00:00:00 2001 From: skyward7187 Date: Wed, 21 Oct 2026 16:10:07 -0400 Subject: [PATCH 4/7] VC3D auto-link: automatic pending links in their own solid palette (cyan, pink) Replaces the dashed outline and the paler fill: an unreviewed automatic link draws solid like every other link state, bright cyan (#20f0ff) for an H/V link and light pink (#ffa8d8) for a same-H/V link (D9), everywhere a linked point is drawn and on the linked fiber's connector and projected X. One QtCore-free hue table (linkStateRgb) feeds every renderer, the Fiber Map palettes included; the separate rings, the tagged variants and the pale-fill helper are gone. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PrhaQ58LygRfA7y3Psp7uf --- .../apps/VC3D/FiberMapWorkspace.cpp | 162 ++++++------------ .../apps/VC3D/FiberMapWorkspace.hpp | 9 +- .../apps/VC3D/LineAnnotationController.cpp | 2 + .../apps/VC3D/LineAnnotationDialog.cpp | 135 +++------------ .../apps/VC3D/LineAnnotationDialog.hpp | 12 +- .../VC3D/LineAnnotationGeneratedViews.cpp | 84 ++++----- .../VC3D/LineAnnotationGeneratedViews.hpp | 51 ++++-- .../VC3D/overlays/FiberOverlayController.cpp | 41 ++--- .../test_line_annotation_link_validation.cpp | 8 +- .../docs/line_annotation_fibers.md | 18 +- 10 files changed, 168 insertions(+), 354 deletions(-) diff --git a/volume-cartographer/apps/VC3D/FiberMapWorkspace.cpp b/volume-cartographer/apps/VC3D/FiberMapWorkspace.cpp index f06e20fb1f..c01d8ca028 100644 --- a/volume-cartographer/apps/VC3D/FiberMapWorkspace.cpp +++ b/volume-cartographer/apps/VC3D/FiberMapWorkspace.cpp @@ -163,19 +163,31 @@ const QColor kSuspect(QStringLiteral("#ff6b6b")); // Link markers use the line annotation's branch-link palette verbatim, so a // crossing reads the same here as it does in the slice and generated views: -// H/V links are violet, same-type (H-H, V-V) links orange, and both go pale -// blue / pale orange while they await review. These four rows mirror -// apps/VC3D/overlays/FiberOverlayController.cpp and -// apps/VC3D/LineAnnotationGeneratedViews.cpp and must stay in sync with them. +// H/V links are violet, same-type (H-H, V-V) links orange, both go pale +// blue / pale orange while they await review, and an unreviewed automatic +// link is cyan / pink. Every row is built from the one shared table +// (line_annotation::linkStateRgb), as the slice overlay and the generated +// views build theirs. struct LinkPalette { QColor pen; QColor brush; }; -const LinkPalette kLinkCross{QColor(210, 95, 255, 245), QColor(210, 95, 255, 175)}; -const LinkPalette kLinkCrossPending{QColor(80, 150, 255, 245), QColor(80, 150, 255, 175)}; -const LinkPalette kLinkSameType{QColor(255, 140, 0, 245), QColor(255, 140, 0, 175)}; -const LinkPalette kLinkSameTypePending{QColor(255, 190, 120, 245), - QColor(255, 190, 120, 175)}; +// Built from the one shared table (line_annotation::linkStateRgb): pen at +// 245, brush at 175, as the annotation views draw them. +LinkPalette linkPaletteFromTable(bool pending, bool sameType, bool automatic) +{ + const vc3d::line_annotation::LinkStateRgb rgb = + vc3d::line_annotation::linkStateRgb(pending, sameType, automatic); + return LinkPalette{QColor(rgb.r, rgb.g, rgb.b, 245), QColor(rgb.r, rgb.g, rgb.b, 175)}; +} +const LinkPalette kLinkCross = linkPaletteFromTable(false, false, false); +const LinkPalette kLinkCrossPending = linkPaletteFromTable(true, false, false); +const LinkPalette kLinkSameType = linkPaletteFromTable(false, true, false); +const LinkPalette kLinkSameTypePending = linkPaletteFromTable(true, true, false); +// Unreviewed automatic pending links: bright cyan, light pink same-type (the +// cyan is well away from the theme's teal H fibers). +const LinkPalette kLinkCrossAuto = linkPaletteFromTable(true, false, true); +const LinkPalette kLinkSameTypeAuto = linkPaletteFromTable(true, true, true); // The map draws a link as a dot on each of its two control points, which // overlap when zoomed out; the fill is thinned so the two stacked compound @@ -310,9 +322,6 @@ constexpr qreal kMinSceneMarginCm = 3.0; constexpr double kMinChipPixelsPerWinding = 180.0; constexpr double kFiberHitTolerancePx = 14.0; constexpr double kControlDotTolerancePx = 10.0; -// The separate dashed ring of a tagged automatic-link endpoint, relative to -// the dot it surrounds. -constexpr double kAutoLinkRingScale = 1.45; constexpr int kClickSlopPx = 4; // Stand-in voxel size for a package that cannot say how big its voxels are. It @@ -357,25 +366,6 @@ QPen cosmeticPen(const QColor& color, qreal width) // Dotted, for the gap spans and the break rims: distinct from the // interpolated dash by form, from every fiber colour by hue. -// The pale fill of an automatic pending endpoint (dashed look): the -// shared autoPendingFillAlpha rule on whatever alpha the dot would have had. -QColor endpointFill(const QColor& fill, bool automaticDashed) -{ - if (!automaticDashed) { - return fill; - } - QColor pale = fill; - pale.setAlpha(vc3d::line_annotation::autoPendingFillAlpha(fill.alpha())); - return pale; -} - -QPen dashedPen(const QColor& color, qreal width) -{ - QPen pen = cosmeticPen(color, width); - pen.setStyle(Qt::DashLine); - return pen; -} - QPen dottedPen(const QColor& color, qreal width) { QPen pen = cosmeticPen(color, width); @@ -2947,10 +2937,10 @@ void FiberMapWorkspace::rebuildScene(const QString& emptyMessage) } // The two endpoint dots of a link, from each endpoint's AGGREGATED look - // (endpointLook: pending outranks approved, dashed when every pending - // link on the point is automatic; a tagged endpoint keeps its tag rim - // and gets the separate dashed ring). Drawn for every link, suspect - // ones included (their red diagnostic rings go on top). + // (endpointLook: pending outranks approved, the automatic palette when + // every pending link on the point is automatic; a tagged endpoint keeps + // its tag rim over the fill). Drawn for every link, suspect ones + // included (their red diagnostic rings go on top). const auto drawEndpointDots = [&](const vc3d::fiber_map::PlacedLink& link, const QPointF& a, const QPointF& b, const std::array, 2>& looks, @@ -2972,18 +2962,15 @@ void FiberMapWorkspace::rebuildScene(const QString& emptyMessage) const bool breakTag = broken[endpointIndex]; const EndpointLook look = looks[endpointIndex].value_or( EndpointLook{palette.brush, palette.pen, link.pending, false}); - const bool autoDashed = look.dashed; ++endpointIndex; // Two stacked fills read darker than one; this alpha // compounds, where the dots overlap, to about the palette's own. QColor fill = look.fill; fill.setAlpha(kLinkEndpointFillAlpha); - fill = endpointFill(fill, autoDashed); auto* dot = new ScaledDot(QBrush(fill), kollesis ? cosmeticPen(kollesisColor(245), kKollesisRimWidthPx) : breakTag ? dottedPen(breakColor(245), kKollesisRimWidthPx) - : autoDashed ? dashedPen(look.pen, 1.0) - : cosmeticPen(look.pen, 1.0), + : cosmeticPen(look.pen, 1.0), crossingDotRadius, kMinCrossingDotPx, kMaxCrossingDotPx, crossingDotBounds, @@ -2995,18 +2982,6 @@ void FiberMapWorkspace::rebuildScene(const QString& emptyMessage) // A tagged endpoint sits above its untagged twin where the two // overlap zoomed out, so the rim stays visible. dot->setZValue(kollesis || breakTag ? 4.1 : 4.0); - if (autoDashed && (kollesis || breakTag)) { - // The tag keeps the rim; the automatic link state gets - // its own dashed ring just outside it. - auto* ring = new ScaledDot(QBrush(Qt::NoBrush), dashedPen(look.pen, 1.0), - crossingDotRadius * kAutoLinkRingScale, - kMinCrossingDotPx * kAutoLinkRingScale, - kMaxCrossingDotPx * kAutoLinkRingScale, - crossingDotBounds * kAutoLinkRingScale); - _scene->addItem(ring); - ring->setPos(endpoint); - ring->setZValue(4.15); - } } }; updateFocusBounds(); @@ -3026,8 +3001,8 @@ void FiberMapWorkspace::rebuildScene(const QString& emptyMessage) const LinkPalette& palette = linkPalette(hvTagOf(link.fiberA), hvTagOf(link.fiberB), link.pending); // Each endpoint dot takes the endpoint's AGGREGATED look - // (endpointLook: pending outranks approved, dashed when every - // pending link on the point is automatic), so two links sharing + // (endpointLook: pending outranks approved, the automatic palette + // when every pending link on the point is automatic), so two links sharing // a control point never paint contradicting markers; the // connector keeps this link's own palette. const std::array, 2> looks{ @@ -3049,30 +3024,23 @@ void FiberMapWorkspace::rebuildScene(const QString& emptyMessage) line->setZValue(4.0); { // The endpoint markers a suspect link's control points carry - // (pending colours, dashing, tag rims) are the same as every - // other link's; the red ring is the diagnostic on top of them. + // (link palettes, tag rims) are the same as every other link's; + // the red ring is the diagnostic on top of them. const LinkPalette& palette = linkPalette(hvTagOf(link.fiberA), hvTagOf(link.fiberB), link.pending); const std::array, 2> looks{ endpointLook(link.fiberA, link.cpA), endpointLook(link.fiberB, link.cpB)}; // The pre-existing suspect look (rings only) stays for an // endpoint without an automatic link; an endpoint WITH one gets - // the automatic decorations (pending-colour dot, dashing, tag + // the automatic decorations (the automatic-palette dot, tag // ring) under the red ring - decided per endpoint. const std::array decorate{looks[0] && looks[0]->anyAuto, looks[1] && looks[1]->anyAuto}; if (decorate[0] || decorate[1]) { drawEndpointDots(link, a, b, looks, palette, decorate); } - const std::array dashed{ - endpointAutoPending(link.fiberA, link.cpA), - endpointAutoPending(link.fiberB, link.cpB)}; - std::size_t endpointIndex = 0; for (const QPointF& endpoint : {a, b}) { - // A suspect automatic link keeps the red ring, dashed. - auto* ring = new ScaledDot(QBrush(Qt::NoBrush), - dashed[endpointIndex++] ? dashedPen(kSuspect, 1.4) - : cosmeticPen(kSuspect, 1.4), + auto* ring = new ScaledDot(QBrush(Qt::NoBrush), cosmeticPen(kSuspect, 1.4), suspectRingRadius, kMinSuspectRingPx, kMaxSuspectRingPx, suspectRingBounds); _scene->addItem(ring); @@ -3127,29 +3095,18 @@ void FiberMapWorkspace::rebuildScene(const QString& emptyMessage) } // A tagged point whose links the layout could not place (an // automatic link to a one-point fiber, say) still draws its - // automatic decoration here: the pending fill inside the - // tag ring and the separate dashed ring, as its endpoint dot - // would have - selection and deselection then agree. + // automatic decoration here: the link fill inside the tag + // ring, as its endpoint dot would have - selection and + // deselection then agree. const auto look = endpointLook(fiber.id, static_cast(i)); const bool automatic = look && look->anyAuto; - auto* dot = new ScaledDot( - automatic ? QBrush(endpointFill(look->fill, look->dashed)) : fill, + auto* dot = new ScaledDot(automatic ? QBrush(look->fill) : fill, kollesis ? rim : breakRim, crossingDotRadius, kMinCrossingDotPx, kMaxCrossingDotPx, crossingDotBounds, /*triangle=*/look && look->adjacent); _scene->addItem(dot); dot->setPos(fiber.controlPoints[i]); dot->setZValue(4.1); - if (look && look->dashed) { - auto* ring = new ScaledDot(QBrush(Qt::NoBrush), dashedPen(look->pen, 1.0), - crossingDotRadius * kAutoLinkRingScale, - kMinCrossingDotPx * kAutoLinkRingScale, - kMaxCrossingDotPx * kAutoLinkRingScale, - crossingDotBounds * kAutoLinkRingScale); - _scene->addItem(ring); - ring->setPos(fiber.controlPoints[i]); - ring->setZValue(4.15); - } } } } @@ -4009,20 +3966,22 @@ std::optional FiberMapWorkspace::endpointLook( // From the endpoint's review state over EVERY link (the layout keeps // only the links it placed): the palette of the winning state (pending // over approved; the orange same-H/V warning when the winning state has - // one), the dashed outline, the triangle. + // one; the automatic palette when every pending link is automatic), the + // triangle. const auto it = _endpointReviews.find(vc3d::fiber_map::EndpointKey{fiberId, controlIndex}); if (it == _endpointReviews.end() || !it->second.linked) { return std::nullopt; } const vc3d::fiber_map::EndpointReview& review = it->second; const LinkPalette& palette = review.pending - ? (review.sameHv ? kLinkSameTypePending : kLinkCrossPending) + ? (review.autoPending ? (review.sameHv ? kLinkSameTypeAuto : kLinkCrossAuto) + : (review.sameHv ? kLinkSameTypePending : kLinkCrossPending)) : (review.sameHv ? kLinkSameType : kLinkCross); EndpointLook look; look.fill = palette.brush; look.pen = palette.pen; look.pending = review.pending; - look.dashed = review.autoPending; + look.automatic = review.autoPending; look.anyAuto = review.anyAuto; look.adjacent = review.adjacent; return look; @@ -4123,11 +4082,9 @@ void FiberMapWorkspace::drawControlPointDots(uint64_t fiberId) const bool kollesis = i < entry->fiber.kollesisTerminations.size() && entry->fiber.kollesisTerminations[i]; const bool breakTag = !kollesis && i < entry->fiber.breaks.size() && entry->fiber.breaks[i]; - // An unreviewed automatic link on this control: dashed rim (untagged) - // or a separate dashed ring outside the tag rim (tagged) - the same - // pair every other renderer draws (linkedMarkerDashed). + // An unreviewed automatic link on this control: the automatic + // palette (automaticLinkMarker, the rule every renderer draws). const auto look = endpointLook(fiberId, static_cast(i)); - const bool autoDashed = look && look->dashed; const bool anyAuto = look && look->anyAuto; const bool triangle = look && look->adjacent; // The pre-existing rule for the tag fill of a selected point: the @@ -4147,18 +4104,18 @@ void FiberMapWorkspace::drawControlPointDots(uint64_t fiberId) } if (kollesis || breakTag) { if (look && (placedNonSuspectLink || anyAuto)) { - fill = QBrush(endpointFill(look->fill, autoDashed)); + fill = QBrush(look->fill); } else { fill = QBrush(Qt::NoBrush); } rim = kollesis ? cosmeticPen(kollesisColor(255), kKollesisRimWidthPx) : dottedPen(breakColor(255), kKollesisRimWidthPx); } else if (anyAuto && look) { - // An untagged endpoint with an automatic link: the pending - // palette (fill and outline), dashed only when every pending - // link is automatic - a mixed point stays solid in the palette. - fill = QBrush(endpointFill(look->fill, autoDashed)); - rim = autoDashed ? dashedPen(look->pen, 1.0) : cosmeticPen(look->pen, 1.0); + // An untagged endpoint with an automatic link: the look's palette + // (the automatic one when every pending link is automatic, the + // pending one for a mixed point), fill and outline. + fill = QBrush(look->fill); + rim = cosmeticPen(look->pen, 1.0); } auto* dot = new ScaledDot(fill, rim, kControlDotRadiusCm * vxPerCm, kMinControlDotPx, @@ -4170,27 +4127,6 @@ void FiberMapWorkspace::drawControlPointDots(uint64_t fiberId) dot->setData(0, QVariant::fromValue(fiberId)); dot->setData(1, static_cast(i)); _controlPointDots.push_back(dot); - if (autoDashed && look && (kollesis || breakTag)) { - // Outside every retained tag stroke: the selected dot's own - // (kKollesisRimWidthPx) and the endpoint dot's underneath (the - // crossing dot's radius), with the ring's width on top. - const qreal clearancePx = kKollesisRimWidthPx + 1.5; - auto* ring = new ScaledDot(QBrush(Qt::NoBrush), dashedPen(look->pen, 1.0), - std::max(kControlDotRadiusCm, kCrossingDotRadiusCm) * vxPerCm * - kAutoLinkRingScale, - std::max(kMinControlDotPx, kMinCrossingDotPx) * - kAutoLinkRingScale + clearancePx, - std::max(kMaxControlDotPx, kMaxCrossingDotPx) * - kAutoLinkRingScale + clearancePx, - std::max(kControlDotBoundsCm, kCrossingDotBoundsCm) * vxPerCm * - kAutoLinkRingScale); - _scene->addItem(ring); - ring->setPos(entry->fiber.controlPoints[i]); - ring->setZValue(kHighlightZ + 1.1); - ring->setData(0, QVariant::fromValue(fiberId)); - ring->setData(1, static_cast(i)); - _controlPointDots.push_back(ring); - } } } diff --git a/volume-cartographer/apps/VC3D/FiberMapWorkspace.hpp b/volume-cartographer/apps/VC3D/FiberMapWorkspace.hpp index da98ab1ce5..c99e76b9d9 100644 --- a/volume-cartographer/apps/VC3D/FiberMapWorkspace.hpp +++ b/volume-cartographer/apps/VC3D/FiberMapWorkspace.hpp @@ -331,18 +331,19 @@ class FiberMapWorkspace : public QMainWindow // The shared marker rule for one link endpoint of the published layout: // true when the control point has at least one pending link and every // pending link on it is an unreviewed automatic link - // (line_annotation::autoPendingMarker): its markers draw dashed. + // (line_annotation::autoPendingMarker): its markers take the automatic + // palette. [[nodiscard]] bool endpointAutoPending(uint64_t fiberId, int controlIndex) const; // The one look of a link endpoint, from the endpoint's AGGREGATED review // state (every link at that control point): pending outranks approved // for the palette (as the annotation views' marker precedence), the - // dashed outline follows endpointAutoPending. Every dot drawer uses it. + // automatic palette follows endpointAutoPending. Every dot drawer uses it. struct EndpointLook { QColor fill; QColor pen; bool pending = false; - // Dashed outline: every pending link on the point is automatic. - bool dashed = false; + // The automatic palette: every pending link on the point is automatic. + bool automatic = false; // Any automatic link on the point (a mixed point keeps the pending // palette solid; without one the pre-existing looks are untouched). bool anyAuto = false; diff --git a/volume-cartographer/apps/VC3D/LineAnnotationController.cpp b/volume-cartographer/apps/VC3D/LineAnnotationController.cpp index 3ba3a16185..318022d5d8 100644 --- a/volume-cartographer/apps/VC3D/LineAnnotationController.cpp +++ b/volume-cartographer/apps/VC3D/LineAnnotationController.cpp @@ -7358,6 +7358,7 @@ LineAnnotationController::markLinkCandidateFiberIntersections( marker.isLinkCandidateFiber = candidateFiberId != 0 && marker.fiberId == candidateFiberId; marker.pendingBranchLink = false; + marker.autoPendingBranchLink = false; marker.sameHvBranchLink = false; if (!marker.projectedBranchLink || !marker.connectorStart) { continue; @@ -7370,6 +7371,7 @@ LineAnnotationController::markLinkCandidateFiberIntersections( cv::norm(toVec3f(branch.controlPointPosition) - *marker.connectorStart) <= 1.0e-3) { marker.pendingBranchLink = true; + marker.autoPendingBranchLink = branch.autoLink; break; } } diff --git a/volume-cartographer/apps/VC3D/LineAnnotationDialog.cpp b/volume-cartographer/apps/VC3D/LineAnnotationDialog.cpp index 412d44c1c9..7016020a5c 100644 --- a/volume-cartographer/apps/VC3D/LineAnnotationDialog.cpp +++ b/volume-cartographer/apps/VC3D/LineAnnotationDialog.cpp @@ -506,12 +506,6 @@ class LineAnnotationOverviewBar final : public QWidget std::optional edge; // Dotted edge (the break ring). bool dottedEdge = false; - // Dashed edge: an unreviewed automatic pending link on an untagged - // point (the cut views' dashed outline). - bool dashedEdge = false; - // A tagged automatic pending point keeps its tag edge and gets this - // separate dashed ring, two units outside, in the pending colour. - std::optional linkRing; qreal radius = 4.5; // Adjacent-winding link (or adjacent candidate): drawn as a triangle. bool triangle = false; @@ -640,15 +634,9 @@ class LineAnnotationOverviewBar final : public QWidget continue; } const qreal x = xForFraction(dot.fraction); - // The default edge is the fill's darker shade at full opacity: - // an automatic pending dot's pale fill keeps a legible outline. - QColor edgeBase = dot.color; - edgeBase.setAlpha(255); - QPen edgePen = dot.edge ? QPen(*dot.edge, 1.5) : QPen(edgeBase.darker(150), 1.0); + QPen edgePen = dot.edge ? QPen(*dot.edge, 1.5) : QPen(dot.color.darker(150), 1.0); if (dot.dottedEdge) { edgePen.setStyle(Qt::DotLine); - } else if (dot.dashedEdge) { - edgePen.setStyle(Qt::DashLine); } painter.setPen(edgePen); painter.setBrush(dot.color); @@ -658,13 +646,6 @@ class LineAnnotationOverviewBar final : public QWidget } else { painter.drawEllipse(QPointF(x, midY), dot.radius, dot.radius); } - if (dot.linkRing) { - QPen ringPen(*dot.linkRing, 1.5); - ringPen.setStyle(Qt::DashLine); - painter.setPen(ringPen); - painter.setBrush(Qt::NoBrush); - painter.drawEllipse(QPointF(x, midY), dot.radius + 2.0, dot.radius + 2.0); - } } } @@ -5457,10 +5438,6 @@ void LineAnnotationDialog::updateGeneratedDynamicOverlaysFast(bool updateCurrent !_fastCurrentCutOverlayItems.sameHvPendingBranchControlPoints || !_fastCurrentCutOverlayItems.autoPendingBranchControlPoints || !_fastCurrentCutOverlayItems.sameHvAutoPendingBranchControlPoints || - !_fastCurrentCutOverlayItems.autoPendingTaggedBranchControlPoints || - !_fastCurrentCutOverlayItems.sameHvAutoPendingTaggedBranchControlPoints || - !_fastCurrentCutOverlayItems.autoPendingLinkRings || - !_fastCurrentCutOverlayItems.sameHvAutoPendingLinkRings || !_fastCurrentCutOverlayItems.kollesisRings || !_fastCurrentCutOverlayItems.breakRings || !_fastCurrentCutOverlayItems.fiberIntersections || @@ -5544,54 +5521,23 @@ void LineAnnotationDialog::updateGeneratedDynamicOverlaysFast(bool updateCurrent sameHvPendingBranchControlBrush); _fastCurrentCutOverlayItems.sameHvPendingBranchControlPoints->setZValue(162.5); - // Unreviewed automatic pending links: the two pending palettes with a - // dashed outline (untagged points), and a dashed hollow ring for - // tagged points, drawn outside their tag ring. - const auto makeDashed = [](QGraphicsPathItem* item, QPen pen, const QBrush& brush, - qreal z) { - pen.setStyle(Qt::DashLine); + // Unreviewed automatic pending links: the automatic palette (cyan, + // pink same-H/V; generatedLinkStateColor), solid like every other + // link state. A tagged automatic point keeps this fill under its tag + // ring exactly as a tagged pending point does. + const auto makeAutoPending = [](QGraphicsPathItem* item, bool sameHv) { + QPen pen(vc3d::line_annotation::generatedLinkStateColor(true, sameHv, 245, true)); + pen.setWidthF(2.0); item->setPen(pen); - item->setBrush(brush); - item->setZValue(z); + item->setBrush( + QBrush(vc3d::line_annotation::generatedLinkStateColor(true, sameHv, 175, true))); + item->setZValue(162.5); }; - // The pale automatic fill (autoPendingFillAlpha, the shared rule). - QColor autoPendingFill = pendingBranchControlBrush.color(); - autoPendingFill.setAlpha( - vc3d::line_annotation::autoPendingFillAlpha(autoPendingFill.alpha())); - const QBrush autoPendingBranchControlBrush(autoPendingFill); - QColor sameHvAutoPendingFill = sameHvPendingBranchControlBrush.color(); - sameHvAutoPendingFill.setAlpha( - vc3d::line_annotation::autoPendingFillAlpha(sameHvAutoPendingFill.alpha())); - const QBrush sameHvAutoPendingBranchControlBrush(sameHvAutoPendingFill); _fastCurrentCutOverlayItems.autoPendingBranchControlPoints = new QGraphicsPathItem(); - makeDashed(_fastCurrentCutOverlayItems.autoPendingBranchControlPoints, - pendingBranchControlPen, autoPendingBranchControlBrush, 162.51); + makeAutoPending(_fastCurrentCutOverlayItems.autoPendingBranchControlPoints, false); _fastCurrentCutOverlayItems.sameHvAutoPendingBranchControlPoints = new QGraphicsPathItem(); - makeDashed(_fastCurrentCutOverlayItems.sameHvAutoPendingBranchControlPoints, - sameHvPendingBranchControlPen, sameHvAutoPendingBranchControlBrush, 162.51); - // Tagged automatic points: the same pale fill with the solid pending - // outline (the tag ring above replaces it), as the shared renderer. - _fastCurrentCutOverlayItems.autoPendingTaggedBranchControlPoints = - new QGraphicsPathItem(); - _fastCurrentCutOverlayItems.autoPendingTaggedBranchControlPoints->setPen( - pendingBranchControlPen); - _fastCurrentCutOverlayItems.autoPendingTaggedBranchControlPoints->setBrush( - autoPendingBranchControlBrush); - _fastCurrentCutOverlayItems.autoPendingTaggedBranchControlPoints->setZValue(162.5); - _fastCurrentCutOverlayItems.sameHvAutoPendingTaggedBranchControlPoints = - new QGraphicsPathItem(); - _fastCurrentCutOverlayItems.sameHvAutoPendingTaggedBranchControlPoints->setPen( - sameHvPendingBranchControlPen); - _fastCurrentCutOverlayItems.sameHvAutoPendingTaggedBranchControlPoints->setBrush( - sameHvAutoPendingBranchControlBrush); - _fastCurrentCutOverlayItems.sameHvAutoPendingTaggedBranchControlPoints->setZValue(162.5); - _fastCurrentCutOverlayItems.autoPendingLinkRings = new QGraphicsPathItem(); - makeDashed(_fastCurrentCutOverlayItems.autoPendingLinkRings, - pendingBranchControlPen, QBrush(Qt::NoBrush), 162.8); - _fastCurrentCutOverlayItems.sameHvAutoPendingLinkRings = new QGraphicsPathItem(); - makeDashed(_fastCurrentCutOverlayItems.sameHvAutoPendingLinkRings, - sameHvPendingBranchControlPen, QBrush(Qt::NoBrush), 162.8); + makeAutoPending(_fastCurrentCutOverlayItems.sameHvAutoPendingBranchControlPoints, true); // Same look as controlStyleForMarker's kollesis branch in the shared // renderer: ring only, above every link-state fill. @@ -5680,10 +5626,6 @@ void LineAnnotationDialog::updateGeneratedDynamicOverlaysFast(bool updateCurrent _fastCurrentCutOverlayItems.sameHvPendingBranchControlPoints, _fastCurrentCutOverlayItems.autoPendingBranchControlPoints, _fastCurrentCutOverlayItems.sameHvAutoPendingBranchControlPoints, - _fastCurrentCutOverlayItems.autoPendingTaggedBranchControlPoints, - _fastCurrentCutOverlayItems.sameHvAutoPendingTaggedBranchControlPoints, - _fastCurrentCutOverlayItems.autoPendingLinkRings, - _fastCurrentCutOverlayItems.sameHvAutoPendingLinkRings, _fastCurrentCutOverlayItems.kollesisRings, _fastCurrentCutOverlayItems.breakRings, _fastCurrentCutOverlayItems.fiberIntersections, @@ -5746,10 +5688,6 @@ void LineAnnotationDialog::updateGeneratedDynamicOverlaysFast(bool updateCurrent QPainterPath sameHvPendingBranchControlPath; QPainterPath autoPendingBranchControlPath; QPainterPath sameHvAutoPendingBranchControlPath; - QPainterPath autoPendingTaggedBranchControlPath; - QPainterPath sameHvAutoPendingTaggedBranchControlPath; - QPainterPath autoPendingLinkRingPath; - QPainterPath sameHvAutoPendingLinkRingPath; QPainterPath kollesisRingPath; QPainterPath breakRingPath; const double lineRadius = @@ -5825,28 +5763,18 @@ void LineAnnotationDialog::updateGeneratedDynamicOverlaysFast(bool updateCurrent } }; if (control.hasPendingLinks) { - // The dashed outline of an automatic pending point belongs to an - // untagged marker; a tagged one keeps its tag ring and gets the - // separate dashed link ring (linkedMarkerDashed, shared rule). - const bool tagged = control.isKollesisTermination || control.isBreak; - const bool dashed = vc3d::line_annotation::linkedMarkerDashed( + // The automatic palette for an automatic pending point (the + // shared rule, automaticLinkMarker); tagged or not, the tag ring + // goes over it like over any linked marker. + const bool automatic = vc3d::line_annotation::automaticLinkMarker( true, control.hasAutoPendingLinks); - if (dashed && !tagged) { + if (automatic) { addLinkedMarker(control.hasSameHvPendingLinks ? sameHvAutoPendingBranchControlPath : autoPendingBranchControlPath); - } else if (dashed) { - addLinkedMarker(control.hasSameHvPendingLinks - ? sameHvAutoPendingTaggedBranchControlPath - : autoPendingTaggedBranchControlPath); } else { addLinkedMarker(control.hasSameHvPendingLinks ? sameHvPendingBranchControlPath : pendingBranchControlPath); } - if (dashed && tagged) { - (control.hasSameHvPendingLinks ? sameHvAutoPendingLinkRingPath - : autoPendingLinkRingPath) - .addEllipse(scenePoint, baseRadius + 5.0, baseRadius + 5.0); - } } else if (control.hasBranches) { addLinkedMarker(control.hasSameHvBranches ? sameHvBranchControlPath : branchControlPath); @@ -5860,13 +5788,6 @@ void LineAnnotationDialog::updateGeneratedDynamicOverlaysFast(bool updateCurrent autoPendingBranchControlPath); _fastCurrentCutOverlayItems.sameHvAutoPendingBranchControlPoints->setPath( sameHvAutoPendingBranchControlPath); - _fastCurrentCutOverlayItems.autoPendingTaggedBranchControlPoints->setPath( - autoPendingTaggedBranchControlPath); - _fastCurrentCutOverlayItems.sameHvAutoPendingTaggedBranchControlPoints->setPath( - sameHvAutoPendingTaggedBranchControlPath); - _fastCurrentCutOverlayItems.autoPendingLinkRings->setPath(autoPendingLinkRingPath); - _fastCurrentCutOverlayItems.sameHvAutoPendingLinkRings->setPath( - sameHvAutoPendingLinkRingPath); _fastCurrentCutOverlayItems.controlPoints->setPath(controlPath); _fastCurrentCutOverlayItems.seedPoints->setPath(seedPath); _fastCurrentCutOverlayItems.linkCandidatePoints->setPath(linkCandidatePath); @@ -6892,18 +6813,11 @@ void LineAnnotationDialog::rebuildOverviewBarLayout() dot.radius = control.isSeed ? 6.0 : 4.5; dot.triangle = control.isAdjacentLinkCandidate || (control.hasAdjacentLinks && !control.isLinkCandidate); - // Automatic pending point: dashed edge on an untagged dot, a separate - // dashed ring in the pending colour on a tagged one (set below). - const bool autoPending = !control.isLinkCandidate && control.hasPendingLinks && - vc3d::line_annotation::linkedMarkerDashed(true, control.hasAutoPendingLinks); - const QColor pendingColor = dot.color; - if (autoPending) { - // The pale automatic fill (the shared rule); the edge and the - // separate ring keep the full pending colour. - dot.color.setAlpha(vc3d::line_annotation::autoPendingFillAlpha(255)); - } - if (autoPending && !(control.isKollesisTermination || control.isBreak)) { - dot.dashedEdge = true; + // Automatic pending point: the automatic palette (the shared rule). + if (!control.isLinkCandidate && control.hasPendingLinks && + vc3d::line_annotation::automaticLinkMarker(true, control.hasAutoPendingLinks)) { + dot.color = vc3d::line_annotation::generatedLinkStateColor( + true, control.hasSameHvPendingLinks, 255, true); } if (control.isKollesisTermination && !control.isLinkCandidate) { // As in the cut views: a hollow yellow ring when unlinked, the @@ -6922,9 +6836,6 @@ void LineAnnotationDialog::rebuildOverviewBarLayout() dot.dottedEdge = true; dot.radius += 1.0; } - if (autoPending && (control.isKollesisTermination || control.isBreak)) { - dot.linkRing = pendingColor; - } dots.push_back(dot); } bar->setLineData(_generatedViews.linePoints.size(), diff --git a/volume-cartographer/apps/VC3D/LineAnnotationDialog.hpp b/volume-cartographer/apps/VC3D/LineAnnotationDialog.hpp index e23d4c7507..c8b5ff15fd 100644 --- a/volume-cartographer/apps/VC3D/LineAnnotationDialog.hpp +++ b/volume-cartographer/apps/VC3D/LineAnnotationDialog.hpp @@ -91,18 +91,10 @@ class LineAnnotationDialog : public QMainWindow QGraphicsPathItem* pendingBranchControlPoints = nullptr; QGraphicsPathItem* sameHvBranchControlPoints = nullptr; QGraphicsPathItem* sameHvPendingBranchControlPoints = nullptr; - // Unreviewed automatic pending links (dashed outline, the two pending - // palettes), and the separate dashed link ring a TAGGED automatic - // pending point gets next to its tag ring. + // Unreviewed automatic pending links: the automatic palette (cyan, + // pink same-H/V), solid like the other link states. QGraphicsPathItem* autoPendingBranchControlPoints = nullptr; QGraphicsPathItem* sameHvAutoPendingBranchControlPoints = nullptr; - // The TAGGED automatic pending point's marker: the pale fill under - // its tag ring with the pending outline solid (the tag ring covers - // it), beside the separate dashed link ring below. - QGraphicsPathItem* autoPendingTaggedBranchControlPoints = nullptr; - QGraphicsPathItem* sameHvAutoPendingTaggedBranchControlPoints = nullptr; - QGraphicsPathItem* autoPendingLinkRings = nullptr; - QGraphicsPathItem* sameHvAutoPendingLinkRings = nullptr; // Hollow yellow rings of kollesis-tagged points, drawn over whichever // fill (link state) the point keeps; an unlinked tagged point has no // fill at all. diff --git a/volume-cartographer/apps/VC3D/LineAnnotationGeneratedViews.cpp b/volume-cartographer/apps/VC3D/LineAnnotationGeneratedViews.cpp index 6465e7e6bc..24fc3e5f68 100644 --- a/volume-cartographer/apps/VC3D/LineAnnotationGeneratedViews.cpp +++ b/volume-cartographer/apps/VC3D/LineAnnotationGeneratedViews.cpp @@ -152,12 +152,10 @@ QPainterPath generatedTriangleMarkerPath(const QPointF& center, qreal radius) return path; } -QColor generatedLinkStateColor(bool pending, bool sameHv, int alpha) +QColor generatedLinkStateColor(bool pending, bool sameHv, int alpha, bool automatic) { - if (sameHv) { - return pending ? QColor(255, 190, 120, alpha) : QColor(255, 140, 0, alpha); - } - return pending ? QColor(80, 150, 255, alpha) : QColor(210, 95, 255, alpha); + const LinkStateRgb rgb = linkStateRgb(pending, sameHv, automatic); + return QColor(rgb.r, rgb.g, rgb.b, alpha); } QPointF generatedStripLinePositionToScene(CChunkedVolumeViewer* viewer, @@ -436,6 +434,18 @@ std::string applyGeneratedOverlay(CChunkedVolumeViewer* viewer, sameHvPendingBranchControlPointStyle.penColor = QColor(255, 190, 120, 245); sameHvPendingBranchControlPointStyle.brushColor = QColor(255, 190, 120, 175); + // Unreviewed automatic pending links: their own hues (generatedLinkStateColor). + ViewerOverlayControllerBase::OverlayStyle autoPendingBranchControlPointStyle = + pendingBranchControlPointStyle; + autoPendingBranchControlPointStyle.penColor = generatedLinkStateColor(true, false, 245, true); + autoPendingBranchControlPointStyle.brushColor = generatedLinkStateColor(true, false, 175, true); + ViewerOverlayControllerBase::OverlayStyle sameHvAutoPendingBranchControlPointStyle = + pendingBranchControlPointStyle; + sameHvAutoPendingBranchControlPointStyle.penColor = + generatedLinkStateColor(true, true, 245, true); + sameHvAutoPendingBranchControlPointStyle.brushColor = + generatedLinkStateColor(true, true, 175, true); + ViewerOverlayControllerBase::OverlayStyle linkCandidateControlPointStyle = branchControlPointStyle; linkCandidateControlPointStyle.penColor = QColor(60, 235, 120, 245); linkCandidateControlPointStyle.brushColor = QColor(60, 235, 120, 175); @@ -457,24 +467,6 @@ std::string applyGeneratedOverlay(CChunkedVolumeViewer* viewer, const auto drawsTagRing = [&](const GeneratedOverlay::ControlPointMarker& control) { return drawsKollesisRing(control) || drawsBreakRing(control); }; - // The separate dashed link ring of a TAGGED automatic pending point: the - // tag ring (kollesis / break) keeps the marker's outline, so the link - // state gets its own glyph two units outside it (the fast current-cut - // renderer and the overview bar draw the same pair). - const auto drawsAutoPendingLinkRing = [&](const GeneratedOverlay::ControlPointMarker& control) { - return control.hasPendingLinks && !control.isLinkCandidate && - linkedMarkerDashed(true, control.hasAutoPendingLinks) && drawsTagRing(control); - }; - auto autoPendingLinkRingStyle = [&](const GeneratedOverlay::ControlPointMarker& control) - -> ViewerOverlayControllerBase::OverlayStyle { - ViewerOverlayControllerBase::OverlayStyle style = control.hasSameHvPendingLinks - ? sameHvPendingBranchControlPointStyle - : pendingBranchControlPointStyle; - style.brushColor = Qt::transparent; - style.penStyle = Qt::DashLine; - style.z += 0.3; - return style; - }; // Triangle instead of circle: an adjacent-winding link on the point, or // the point designated as an adjacent link candidate (a split candidate // keeps its own red circle). @@ -490,18 +482,15 @@ std::string applyGeneratedOverlay(CChunkedVolumeViewer* viewer, const bool linked = control.hasPendingLinks || control.hasBranches; ViewerOverlayControllerBase::OverlayStyle style; if (control.hasPendingLinks) { - style = control.hasSameHvPendingLinks ? sameHvPendingBranchControlPointStyle - : pendingBranchControlPointStyle; - // An unreviewed automatic link: same colours, paler fill, dashed - // outline. A tagged point keeps its tag ring and gets a separate - // dashed link ring instead (autoPendingLinkRingStyle below). - const bool automatic = linkedMarkerDashed(true, control.hasAutoPendingLinks); - if (automatic) { - style.brushColor.setAlpha(autoPendingFillAlpha(style.brushColor.alpha())); - } - if (automatic && !drawsTagRing(control)) { - style.penStyle = Qt::DashLine; - } + // An unreviewed automatic link: the automatic palette (cyan, or + // pink same-H/V), solid like every other marker; a tagged point + // keeps that fill inside its tag ring, as any linked point does. + const bool automatic = automaticLinkMarker(true, control.hasAutoPendingLinks); + style = automatic + ? (control.hasSameHvPendingLinks ? sameHvAutoPendingBranchControlPointStyle + : autoPendingBranchControlPointStyle) + : (control.hasSameHvPendingLinks ? sameHvPendingBranchControlPointStyle + : pendingBranchControlPointStyle); } else if (control.hasBranches) { style = control.hasSameHvBranches ? sameHvBranchControlPointStyle : branchControlPointStyle; @@ -769,15 +758,6 @@ std::string applyGeneratedOverlay(CChunkedVolumeViewer* viewer, primitives.push_back(ViewerOverlayControllerBase::CirclePrimitive{ controlScene, radius, true, controlStyleForMarker(control)}); } - if (drawsAutoPendingLinkRing(control)) { - // The strip's tagged automatic point: the same - // separate dashed link ring as the cut views, scaled - // to the strip's marker size. - // Clear of the tag stroke (2.5 wide) with the ring's - // own width (2): centre at radius + 3. - primitives.push_back(ViewerOverlayControllerBase::CirclePrimitive{ - controlScene, radius + 3.0, true, autoPendingLinkRingStyle(control)}); - } } } for (const auto& predSnap : overlay.predSnapPoints) { @@ -881,17 +861,9 @@ std::string applyGeneratedOverlay(CChunkedVolumeViewer* viewer, generatedTriangleMarkerPath(controlScene, radius), controlStyleForMarker(control)}); } - if (drawsAutoPendingLinkRing(control)) { - addVolumePointMarker(control.point, radius + 3.0, - autoPendingLinkRingStyle(control)); - } continue; } addVolumePointMarker(control.point, radius, controlStyleForMarker(control)); - if (drawsAutoPendingLinkRing(control)) { - addVolumePointMarker(control.point, radius + 3.0, - autoPendingLinkRingStyle(control)); - } } } @@ -929,7 +901,7 @@ std::string applyGeneratedOverlay(CChunkedVolumeViewer* viewer, auto linkXStyle = branchLinkFiberIntersectionStyle; linkXStyle.penColor = generatedLinkStateColor(intersection.pendingBranchLink, intersection.sameHvBranchLink, - 245); + 245, intersection.autoPendingBranchLink); // Pending glyphs keep drawing over approved ones where they overlap. linkXStyle.z = intersection.pendingBranchLink ? 168.3 : 168.25; if (intersection.connectorStart && @@ -938,9 +910,11 @@ std::string applyGeneratedOverlay(CChunkedVolumeViewer* viewer, if (finiteScenePoint(connectorScene) && finiteScenePoint(scenePoint)) { auto connectorStyle = branchLinkStyle; connectorStyle.penColor = generatedLinkStateColor( - intersection.pendingBranchLink, intersection.sameHvBranchLink, 225); + intersection.pendingBranchLink, intersection.sameHvBranchLink, 225, + intersection.autoPendingBranchLink); connectorStyle.brushColor = generatedLinkStateColor( - intersection.pendingBranchLink, intersection.sameHvBranchLink, 165); + intersection.pendingBranchLink, intersection.sameHvBranchLink, 165, + intersection.autoPendingBranchLink); primitives.push_back(ViewerOverlayControllerBase::LineStripPrimitive{ {connectorScene, scenePoint}, false, diff --git a/volume-cartographer/apps/VC3D/LineAnnotationGeneratedViews.hpp b/volume-cartographer/apps/VC3D/LineAnnotationGeneratedViews.hpp index 1acc2cee89..7abc4b495f 100644 --- a/volume-cartographer/apps/VC3D/LineAnnotationGeneratedViews.hpp +++ b/volume-cartographer/apps/VC3D/LineAnnotationGeneratedViews.hpp @@ -200,6 +200,9 @@ struct GeneratedOverlay { // Set with projectedBranchLink when the local and linked fibers share // an H/V classification (the orange warning palette). bool sameHvBranchLink = false; + // Set with pendingBranchLink when that pending link is automatic + // (the connector and the X take the automatic palette). + bool autoPendingBranchLink = false; bool isLinkCandidateFiber = false; std::optional connectorStart; }; @@ -1876,27 +1879,41 @@ struct GeneratedLinkCandidateMenuState { // The link-state palette shared by linked control points, their connector // lines and the linked fiber's projected X marker: purple approved, blue -// pending, orange same-H/V approved, light orange same-H/V pending. -// Defined in the .cpp: this header is also compiled into QtCore-only tests. -[[nodiscard]] QColor generatedLinkStateColor(bool pending, bool sameHv, int alpha); - -// Marker outline rule shared by every renderer (generated views, the fast -// current-cut path, the overview bar, the slice overlay, the Fiber Map): -// dashed iff the point draws in a pending colour AND is an automatic pending -// point (ControlPointMarker::hasAutoPendingLinks, autoPendingMarker). -inline bool linkedMarkerDashed(bool drawsPending, bool autoPending) +// pending, orange same-H/V approved, light orange same-H/V pending; an +// AUTOMATIC pending link (automatic = true, pending = true) is bright cyan, +// or light pink when same-H/V - hues of their own, so an unreviewed +// automatic link reads apart from a human-made pending one at a glance and +// from the Fiber Map's teal H fibers. +// The hue itself is a QtCore-free inline rule (linkStateRgb) so every +// renderer, the slice overlay's QtGui-only test target included, builds its +// colours from the one table; generatedLinkStateColor (defined in the .cpp, +// this header is also compiled into QtCore-only tests) is its QColor form. +struct LinkStateRgb { + int r = 0; + int g = 0; + int b = 0; +}; +[[nodiscard]] inline LinkStateRgb linkStateRgb(bool pending, bool sameHv, bool automatic = false) { - return drawsPending && autoPending; + if (pending && automatic) { + return sameHv ? LinkStateRgb{255, 168, 216} : LinkStateRgb{32, 240, 255}; + } + if (sameHv) { + return pending ? LinkStateRgb{255, 190, 120} : LinkStateRgb{255, 140, 0}; + } + return pending ? LinkStateRgb{80, 150, 255} : LinkStateRgb{210, 95, 255}; } +[[nodiscard]] QColor generatedLinkStateColor(bool pending, bool sameHv, int alpha, + bool automatic = false); -// The fill of an automatic pending marker (every point linkedMarkerDashed -// says yes to, tagged or not) is thinned to two fifths of the pending -// palette's fill alpha: on a small marker the dashed outline alone does not -// tell it from a human-made pending link at a glance, the paler fill does. -// Shared by every renderer so the two looks agree everywhere. -[[nodiscard]] inline int autoPendingFillAlpha(int pendingFillAlpha) +// Marker palette rule shared by every renderer (generated views, the fast +// current-cut path, the overview bar, the slice overlay, the Fiber Map): the +// automatic palette iff the point draws in a pending colour AND is an +// automatic pending point (ControlPointMarker::hasAutoPendingLinks, +// autoPendingMarker: every pending link on it is automatic). +inline bool automaticLinkMarker(bool drawsPending, bool autoPending) { - return (pendingFillAlpha * 2) / 5; + return drawsPending && autoPending; } // The kollesis-termination ring colour: the ordinary control-point yellow. diff --git a/volume-cartographer/apps/VC3D/overlays/FiberOverlayController.cpp b/volume-cartographer/apps/VC3D/overlays/FiberOverlayController.cpp index 66f85449c3..b1b34585d7 100644 --- a/volume-cartographer/apps/VC3D/overlays/FiberOverlayController.cpp +++ b/volume-cartographer/apps/VC3D/overlays/FiberOverlayController.cpp @@ -247,32 +247,23 @@ void FiberOverlayController::collectPrimitives(VolumeViewerBase* viewer, if (mark.state == 0) { continue; } - // The Line Annotation GUI's palettes: blue / purple for H-V - // links, the orange warning pair for same-H/V links, pending vs - // approved; an automatic pending point draws dashed. + // The Line Annotation GUI's palettes (generatedLinkStateColor): + // blue / purple for H-V links, the orange warning pair for + // same-H/V links, pending vs approved, cyan / pink for an + // automatic pending point. const bool pending = mark.state == 1 || mark.state == 3; const bool automatic = mark.state == 3; const bool tagged = mark.kollesisTermination || mark.breakTag; + // (linkStateRgb: this overlay's test target links no generated-views + // source, so the inline table rather than its QColor form.) + const vc3d::line_annotation::LinkStateRgb rgb = + vc3d::line_annotation::linkStateRgb(pending, mark.sameHv, automatic); OverlayStyle style; - if (pending) { - style.penColor = mark.sameHv ? QColor(255, 190, 120, 245) : QColor(80, 150, 255, 245); - style.brushColor = mark.sameHv ? QColor(255, 190, 120, 175) : QColor(80, 150, 255, 175); - } else { - style.penColor = mark.sameHv ? QColor(255, 140, 0, 245) : QColor(210, 95, 255, 245); - style.brushColor = mark.sameHv ? QColor(255, 140, 0, 175) : QColor(210, 95, 255, 175); - } - const QColor linkPen = style.penColor; - if (automatic) { - // The pale automatic fill (the shared rule), tagged or not. - style.brushColor.setAlpha( - vc3d::line_annotation::autoPendingFillAlpha(style.brushColor.alpha())); - } + style.penColor = QColor(rgb.r, rgb.g, rgb.b, 245); + style.brushColor = QColor(rgb.r, rgb.g, rgb.b, 175); style.penColor.setAlphaF(style.penColor.alphaF() * opacity); style.brushColor.setAlphaF(style.brushColor.alphaF() * opacity); style.penWidth = 2.0; - if (automatic && !tagged) { - style.penStyle = Qt::DashLine; - } if (tagged) { // The tag ring replaces the outline, the link fill stays. style.penColor = mark.kollesisTermination ? QColor(255, 230, 0, 245) @@ -295,18 +286,6 @@ void FiberOverlayController::collectPrimitives(VolumeViewerBase* viewer, } else { builder.addPoint(center, radius, style); } - if (automatic && tagged) { - // The separate dashed link ring of a tagged automatic point, - // clear of the tag stroke. - OverlayStyle ring; - ring.penColor = linkPen; - ring.penColor.setAlphaF(ring.penColor.alphaF() * opacity); - ring.brushColor = Qt::transparent; - ring.penWidth = 2.0; - ring.penStyle = Qt::DashLine; - ring.z = 95.1; - builder.addPoint(center, radius + 3.5, ring); - } } } } diff --git a/volume-cartographer/core/test/test_line_annotation_link_validation.cpp b/volume-cartographer/core/test/test_line_annotation_link_validation.cpp index 075a041890..c8772bcc0b 100644 --- a/volume-cartographer/core/test/test_line_annotation_link_validation.cpp +++ b/volume-cartographer/core/test/test_line_annotation_link_validation.cpp @@ -345,7 +345,7 @@ TEST_CASE("auto link: the JSON review-state keys round trip under the invariant" TEST_CASE("auto link: the marker rule is 'every pending link on the point is automatic'") { using vc3d::line_annotation::autoPendingMarker; - using vc3d::line_annotation::linkedMarkerDashed; + using vc3d::line_annotation::automaticLinkMarker; struct L { bool pending; bool autoLink; @@ -355,9 +355,9 @@ TEST_CASE("auto link: the marker rule is 'every pending link on the point is aut CHECK_FALSE(autoPendingMarker(std::vector{{true, true}, {true, false}})); CHECK_FALSE(autoPendingMarker(std::vector{{false, false}})); CHECK_FALSE(autoPendingMarker(std::vector{})); - CHECK(linkedMarkerDashed(true, true)); - CHECK_FALSE(linkedMarkerDashed(true, false)); - CHECK_FALSE(linkedMarkerDashed(false, true)); + CHECK(automaticLinkMarker(true, true)); + CHECK_FALSE(automaticLinkMarker(true, false)); + CHECK_FALSE(automaticLinkMarker(false, true)); } diff --git a/volume-cartographer/docs/line_annotation_fibers.md b/volume-cartographer/docs/line_annotation_fibers.md index a43d69afd5..cb5a17fd8d 100644 --- a/volume-cartographer/docs/line_annotation_fibers.md +++ b/volume-cartographer/docs/line_annotation_fibers.md @@ -428,14 +428,16 @@ is refused ("A merge, split or link is in progress"). If the pair save fails, the link is withdrawn from the loaded fibers (every copy, including an edit's synced moves of its endpoints during the wait) and the status line says so. -A pending automatic link is an ordinary pending link whose control-point -markers draw with a DASHED outline and a paler (more transparent) fill in the -pending colours (blue, or the orange same-H/V warning palette) everywhere a -linked point is drawn: the cut views and strips (both renderers), the overview -bar, the slice-view rings and the Fiber Map dots. A tagged point (kollesis termination, break) keeps its tag ring and -gets a separate dashed ring just outside it. The rule for a point carrying -several links: dashed only when every pending link on it is automatic; a -human-made pending link outranks. The control-point menu's approve entry reads +A pending automatic link is an ordinary pending link drawn in its own +palette: bright cyan (#20f0ff), or light pink (#ffa8d8) for a same-H/V link, +instead of the pending blue / orange - solid markers like every other link +state, everywhere a linked point is drawn: the cut views and strips (both +renderers), the overview bar, the slice-view rings and the Fiber Map dots, and +the connector and projected X of the linked fiber in the cut views. A tagged +point (kollesis termination, break) keeps its tag ring over that fill, as any +linked point does. The rule for a point carrying several links: the automatic +palette only when every pending link on it is automatic; a human-made pending +link outranks (pending blue / orange). The control-point menu's approve entry reads "Approve auto link to / CP n"; approving makes an ordinary approved link, exactly like approving a pending link. "Mark link as pending" on an approved link makes a plain (human) pending link. From 9240867cb02bc5483f1f23ec2686c19077e11203 Mon Sep 17 00:00:00 2001 From: skyward7187 Date: Wed, 21 Oct 2026 16:10:07 -0400 Subject: [PATCH 5/7] VC3D line annotation: drawn-line anchor and nearest-line-position helpers Pure geometry for the in-dialog "Link and " entry: generatedDrawnLineVertices (the renderer now builds its polyline runs from it, so the anchor rule and the screen agree on which segments exist), effectiveLineTailControlRange (the renderer's tail-range rule in one place), nearestDrawnLinePosition (the foot of a scene point on the nearest drawn, non-tail segment as a fractional line position) and autolink::nearestLinePosition (the foot of a 3D point over ALL segments of a polyline). Tests in the existing QtCore targets. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PrhaQ58LygRfA7y3Psp7uf --- .../apps/VC3D/FiberMapAutoLink.cpp | 32 ++++++++ .../apps/VC3D/FiberMapAutoLink.hpp | 9 ++ .../VC3D/LineAnnotationGeneratedViews.cpp | 25 +++--- .../VC3D/LineAnnotationGeneratedViews.hpp | 82 +++++++++++++++++++ .../VC3D/test/test_fiber_map_auto_link.cpp | 25 ++++++ .../test_line_annotation_generated_views.cpp | 62 ++++++++++++++ 6 files changed, 225 insertions(+), 10 deletions(-) diff --git a/volume-cartographer/apps/VC3D/FiberMapAutoLink.cpp b/volume-cartographer/apps/VC3D/FiberMapAutoLink.cpp index 2fdd4f1956..2713f43d2a 100644 --- a/volume-cartographer/apps/VC3D/FiberMapAutoLink.cpp +++ b/volume-cartographer/apps/VC3D/FiberMapAutoLink.cpp @@ -335,6 +335,38 @@ cv::Vec3d pointAtLinePosition(const std::vector& linePoints, double l return linePoints[lower] * (1.0 - t) + linePoints[upper] * t; } +std::optional nearestLinePosition(const std::vector& linePoints, + const cv::Vec3d& point) +{ + if (linePoints.empty()) { + return std::nullopt; + } + if (linePoints.size() == 1) { + return 0.0; + } + std::optional best; + double bestDistanceSq = std::numeric_limits::infinity(); + for (std::size_t i = 0; i + 1 < linePoints.size(); ++i) { + const cv::Vec3d a = linePoints[i]; + const cv::Vec3d d = linePoints[i + 1] - a; + const double lengthSq = d.dot(d); + double t = 0.0; + if (lengthSq > 0.0) { + t = std::clamp((point - a).dot(d) / lengthSq, 0.0, 1.0); + } + const cv::Vec3d foot = a + t * d; + const cv::Vec3d offset = foot - point; + const double distanceSq = offset.dot(offset); + const double position = static_cast(i) + t; + if (distanceSq < bestDistanceSq || + (distanceSq == bestDistanceSq && best && position < *best)) { + bestDistanceSq = distanceSq; + best = position; + } + } + return best; +} + std::vector cumulativeArclengths(const std::vector& linePoints) { std::vector s(linePoints.size(), 0.0); diff --git a/volume-cartographer/apps/VC3D/FiberMapAutoLink.hpp b/volume-cartographer/apps/VC3D/FiberMapAutoLink.hpp index 1598704138..8a63cbd841 100644 --- a/volume-cartographer/apps/VC3D/FiberMapAutoLink.hpp +++ b/volume-cartographer/apps/VC3D/FiberMapAutoLink.hpp @@ -70,6 +70,15 @@ struct MapMeeting { // quantity for callers without that dependency. [[nodiscard]] std::vector cumulativeArclengths(const std::vector& linePoints); +// The fractional line position of the point of a polyline nearest to `point`: +// the foot on the segment with the smallest point-to-segment distance over +// ALL segments (t clamped to [0, 1]; exact ties take the lower position), so +// a point beside a later pass of the line is never anchored to a nearer +// VERTEX of an earlier one. A single-vertex line gives 0; an empty one +// nullopt. +[[nodiscard]] std::optional nearestLinePosition(const std::vector& linePoints, + const cv::Vec3d& point); + // The closest pass of the two fibers, searched only inside the line-point // ranges whose cumulative 3D arclength lies within +-windowVx of the // arclength at each fiber's source position (D3: the only along-fiber bound, diff --git a/volume-cartographer/apps/VC3D/LineAnnotationGeneratedViews.cpp b/volume-cartographer/apps/VC3D/LineAnnotationGeneratedViews.cpp index 24fc3e5f68..b3f5de7a3c 100644 --- a/volume-cartographer/apps/VC3D/LineAnnotationGeneratedViews.cpp +++ b/volume-cartographer/apps/VC3D/LineAnnotationGeneratedViews.cpp @@ -815,17 +815,24 @@ std::string applyGeneratedOverlay(CChunkedVolumeViewer* viewer, } } } else if (!overlay.linePoints.empty()) { - sceneLine.reserve(overlay.linePoints.size()); + // Projected per vertex, then enumerated by the one shared rule + // (generatedDrawnLineVertices): the anchor search of the + // "Link ... and ..." menu entry walks exactly these segments. + std::vector scenePoints(overlay.linePoints.size(), + QPointF(std::numeric_limits::quiet_NaN(), + std::numeric_limits::quiet_NaN())); for (size_t pointIndex = 0; pointIndex < overlay.linePoints.size(); ++pointIndex) { const auto& point = overlay.linePoints[pointIndex]; - if (!finiteGeneratedPoint(point)) { - continue; - } - const QPointF scenePoint = viewer->volumeToScene(point); - if (finiteScenePoint(scenePoint)) { - sceneLine.push_back({scenePoint, static_cast(pointIndex)}); + if (finiteGeneratedPoint(point)) { + scenePoints[pointIndex] = viewer->volumeToScene(point); } } + const std::vector drawn = + generatedDrawnLineVertices(overlay.linePoints, scenePoints); + sceneLine.reserve(drawn.size()); + for (const size_t pointIndex : drawn) { + sceneLine.push_back({scenePoints[pointIndex], static_cast(pointIndex)}); + } } if (!overlay.useSurfaceCenterLine) { @@ -955,9 +962,7 @@ std::string applyGeneratedOverlay(CChunkedVolumeViewer* viewer, } if (!overlay.useSurfaceCenterLine && sceneLine.size() >= 2) { - const auto controlRange = overlay.lineTailControlRange.has_value() - ? overlay.lineTailControlRange - : generatedControlLinePositionRange(overlay.controlPoints); + const auto controlRange = effectiveLineTailControlRange(overlay); // Consecutive non-tail segments accumulate into one polyline // primitive per run (same pattern as the branch lines above): a // primitive per segment meant one QGraphicsPathItem per segment, diff --git a/volume-cartographer/apps/VC3D/LineAnnotationGeneratedViews.hpp b/volume-cartographer/apps/VC3D/LineAnnotationGeneratedViews.hpp index 7abc4b495f..0920ff13fb 100644 --- a/volume-cartographer/apps/VC3D/LineAnnotationGeneratedViews.hpp +++ b/volume-cartographer/apps/VC3D/LineAnnotationGeneratedViews.hpp @@ -1630,6 +1630,88 @@ inline bool generatedLineSegmentIsTail( return midpoint < controlRange->first || midpoint > controlRange->second; } +// The vertices of an overlay line the renderer DRAWS, in order: every index +// whose line point and whose projection are both finite. The drawn segments +// join consecutive entries of this list, so a blanked (NaN) vertex does not +// break the polyline - the segment bridges it, exactly as the renderer's +// polyline runs do. One enumeration, shared by the renderer and the anchor +// rule below so the two can never disagree about what is on screen. +inline std::vector generatedDrawnLineVertices( + const std::vector& linePoints, + const std::vector& scenePoints) +{ + std::vector drawn; + const std::size_t count = std::min(linePoints.size(), scenePoints.size()); + drawn.reserve(count); + for (std::size_t i = 0; i < count; ++i) { + const auto& point = linePoints[i]; + const QPointF& scene = scenePoints[i]; + if (!std::isfinite(point[0]) || !std::isfinite(point[1]) || !std::isfinite(point[2]) || + !std::isfinite(scene.x()) || !std::isfinite(scene.y())) { + continue; + } + drawn.push_back(i); + } + return drawn; +} + +// The control range the renderer classifies tails against: the overlay's +// own lineTailControlRange when a builder set one (a windowed side cut), else +// the range of the overlay's controls. The renderer's rule, in one place. +inline std::optional> effectiveLineTailControlRange( + const GeneratedOverlay& overlay) +{ + return overlay.lineTailControlRange.has_value() + ? overlay.lineTailControlRange + : generatedControlLinePositionRange(overlay.controlPoints); +} + +// The position on the DRAWN line nearest to a scene point: the foot of the +// target on the nearest drawn, non-tail segment (generatedDrawnLineVertices, +// generatedLineSegmentIsTail), as the fractional line position i + t (j - i) +// of that foot with t clamped to [0, 1]. Exact ties take the lower position. +// Nothing drawn -> nullopt. Pure: no viewer, the caller projects. +inline std::optional nearestDrawnLinePosition( + const std::vector& linePoints, + const std::vector& scenePoints, + const std::optional>& tailControlRange, + const QPointF& target) +{ + if (!std::isfinite(target.x()) || !std::isfinite(target.y())) { + return std::nullopt; + } + const std::vector drawn = generatedDrawnLineVertices(linePoints, scenePoints); + std::optional best; + double bestDistanceSq = std::numeric_limits::infinity(); + for (std::size_t k = 1; k < drawn.size(); ++k) { + const std::size_t i = drawn[k - 1]; + const std::size_t j = drawn[k]; + if (generatedLineSegmentIsTail(static_cast(i), static_cast(j), + tailControlRange)) { + continue; + } + const QPointF a = scenePoints[i]; + const QPointF b = scenePoints[j]; + const double dx = b.x() - a.x(); + const double dy = b.y() - a.y(); + const double lengthSq = dx * dx + dy * dy; + double t = 0.0; + if (lengthSq > 0.0) { + t = ((target.x() - a.x()) * dx + (target.y() - a.y()) * dy) / lengthSq; + t = std::clamp(t, 0.0, 1.0); + } + const double fx = a.x() + t * dx - target.x(); + const double fy = a.y() + t * dy - target.y(); + const double distanceSq = fx * fx + fy * fy; + const double position = static_cast(i) + t * static_cast(j - i); + if (distanceSq < bestDistanceSq || (distanceSq == bestDistanceSq && best && position < *best)) { + bestDistanceSq = distanceSq; + best = position; + } + } + return best; +} + // Defined further down (with the other tagged-end helpers); declared here for // the overlay builders' blocked-marker state. inline bool generatedLinePositionBeyondKollesisTermination( diff --git a/volume-cartographer/apps/VC3D/test/test_fiber_map_auto_link.cpp b/volume-cartographer/apps/VC3D/test/test_fiber_map_auto_link.cpp index 583d8634dc..d8bfd5cf8b 100644 --- a/volume-cartographer/apps/VC3D/test/test_fiber_map_auto_link.cpp +++ b/volume-cartographer/apps/VC3D/test/test_fiber_map_auto_link.cpp @@ -144,6 +144,7 @@ class TestFiberMapAutoLink : public QObject Q_OBJECT private slots: + void nearestLinePositionFoot(); // The arclength the layout resampled on is what unrolledLineArclengths // recomputes from the stored inputs, bitwise, and every drawn sample's // arclength is one of that parameterisation. @@ -804,5 +805,29 @@ private slots: } }; +void TestFiberMapAutoLink::nearestLinePositionFoot() +{ + // The counterexample to a nearest-vertex rule: the point sits ON the + // first segment (position 0.5) while the nearest vertex is the third. + const std::vector zigzag{{-10, 0, 0}, {10, 0, 0}, {0, 1, 0}, {0, 2, 0}}; + const auto foot = autolink::nearestLinePosition(zigzag, cv::Vec3d{0, 0, 0}); + QVERIFY(foot.has_value()); + QCOMPARE(*foot, 0.5); + // Clamped at an end. + const auto before = autolink::nearestLinePosition(zigzag, cv::Vec3d{-20, 0, 0}); + QVERIFY(before.has_value()); + QCOMPARE(*before, 0.0); + // A tie takes the lower position. + const std::vector square{{0, 0, 0}, {10, 0, 0}, {10, 10, 0}, {0, 10, 0}}; + const auto tie = autolink::nearestLinePosition(square, cv::Vec3d{5, 5, 0}); + QVERIFY(tie.has_value()); + QCOMPARE(*tie, 0.5); + // Degenerate inputs. + QVERIFY(!autolink::nearestLinePosition({}, cv::Vec3d{0, 0, 0}).has_value()); + const auto single = autolink::nearestLinePosition({cv::Vec3d{1, 1, 1}}, cv::Vec3d{0, 0, 0}); + QVERIFY(single.has_value()); + QCOMPARE(*single, 0.0); +} + QTEST_APPLESS_MAIN(TestFiberMapAutoLink) #include "test_fiber_map_auto_link.moc" diff --git a/volume-cartographer/core/test/test_line_annotation_generated_views.cpp b/volume-cartographer/core/test/test_line_annotation_generated_views.cpp index 299294868d..290fc093cc 100644 --- a/volume-cartographer/core/test/test_line_annotation_generated_views.cpp +++ b/volume-cartographer/core/test/test_line_annotation_generated_views.cpp @@ -7762,3 +7762,65 @@ TEST_CASE("Mirrored control edits report tail-policy and goal changes per list") CHECK(!outcome.changed); } } + +TEST_CASE("Nearest drawn line position: the foot on the nearest drawn, non-tail segment") +{ + using vc3d::line_annotation::nearestDrawnLinePosition; + constexpr float kNanF = std::numeric_limits::quiet_NaN(); + const double kNan = std::numeric_limits::quiet_NaN(); + const auto scene = [](const std::vector& points) { + std::vector out; + for (const auto& p : points) { + out.emplace_back(std::isfinite(p[0]) ? p[0] : std::numeric_limits::quiet_NaN(), + std::isfinite(p[1]) ? p[1] : std::numeric_limits::quiet_NaN()); + } + return out; + }; + SUBCASE("exact fractional foot") + { + const std::vector line{{0, 0, 0}, {10, 0, 0}, {20, 0, 0}}; + const auto position = nearestDrawnLinePosition(line, scene(line), std::nullopt, QPointF(5, 3)); + REQUIRE(position.has_value()); + CHECK(*position == doctest::Approx(0.5)); + const auto later = nearestDrawnLinePosition(line, scene(line), std::nullopt, QPointF(17.5, -2)); + REQUIRE(later.has_value()); + CHECK(*later == doctest::Approx(1.75)); + } + SUBCASE("a blanked prefix is never chosen; a blanked interior vertex is bridged") + { + const std::vector prefixBlank{{kNanF, kNanF, kNanF}, {10, 0, 0}, {20, 0, 0}}; + const auto p = nearestDrawnLinePosition(prefixBlank, scene(prefixBlank), std::nullopt, QPointF(0, 1)); + REQUIRE(p.has_value()); + CHECK(*p == doctest::Approx(1.0)); // clamped to the first drawn vertex + const std::vector interiorBlank{{0, 0, 0}, {kNanF, kNanF, kNanF}, {20, 0, 0}}; + const auto bridged = nearestDrawnLinePosition(interiorBlank, scene(interiorBlank), std::nullopt, QPointF(10, 3)); + REQUIRE(bridged.has_value()); + CHECK(*bridged == doctest::Approx(1.0)); // the bridge's midpoint, as the renderer draws it + const std::vector suffixBlank{{0, 0, 0}, {10, 0, 0}, {kNanF, kNanF, kNanF}}; + const auto s = nearestDrawnLinePosition(suffixBlank, scene(suffixBlank), std::nullopt, QPointF(30, 0)); + REQUIRE(s.has_value()); + CHECK(*s == doctest::Approx(1.0)); + } + SUBCASE("a tail segment is excluded") + { + const std::vector line{{0, 0, 0}, {10, 0, 0}, {20, 0, 0}}; + // Controls at 1 and 2: the segment 0-1 (midpoint 0.5) is a tail. + const std::optional> range{{1.0, 2.0}}; + const auto p = nearestDrawnLinePosition(line, scene(line), range, QPointF(2, 1)); + REQUIRE(p.has_value()); + CHECK(*p == doctest::Approx(1.0)); + } + SUBCASE("ties take the lower position; nothing drawn gives nullopt") + { + const std::vector square{{0, 0, 0}, {10, 0, 0}, {10, 10, 0}, {0, 10, 0}}; + const auto tie = nearestDrawnLinePosition(square, scene(square), std::nullopt, QPointF(5, 5)); + REQUIRE(tie.has_value()); + CHECK(*tie == doctest::Approx(0.5)); + const std::vector blank{{kNanF, kNanF, kNanF}, {kNanF, kNanF, kNanF}}; + CHECK_FALSE(nearestDrawnLinePosition(blank, scene(blank), std::nullopt, QPointF(0, 0)).has_value()); + const std::vector one{{0, 0, 0}}; + CHECK_FALSE(nearestDrawnLinePosition(one, scene(one), std::nullopt, QPointF(0, 0)).has_value()); + CHECK_FALSE(nearestDrawnLinePosition(square, scene(square), std::nullopt, QPointF(kNan, 0)).has_value()); + } +} + From b623782aa1bae91722af183949c0b87c71e55f6b Mon Sep 17 00:00:00 2001 From: skyward7187 Date: Wed, 21 Oct 2026 16:10:07 -0400 Subject: [PATCH 6/7] VC3D line annotation: extract the shared placement, link, choice and withdrawal steps Mechanical extractions for the in-dialog automatic link, no behaviour change for the existing callers beyond the lifetime hardening noted: handleGeneratedControlPoint = prepareSessionForControlPlacement (the autosave flush and dataset yields, with lifetime, validity and the pane re-checked after each; the handler owns its session across them) + placeControlInSession (the tail over an edit whose collapse set the caller decided); materializeGeneratedViews takes an optional validity callback; handleGeneratedControlPointLinkWithCandidate's link body becomes linkSessionControlToStoredControl (both refs removed on a synchronous failure, the pane re-resolved after the publication); prepareFiberMapAutoLink becomes prepareAutoLinkParticipants over any participant set with one pane kept open; autoLinkFibers' choice step runs over plain data (planAutoLinkChoices), its placement over a prepared session is placePreparedControl, its withdrawal is withdrawTrackedLink; the map's preparation order is unchanged. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PrhaQ58LygRfA7y3Psp7uf --- .../apps/VC3D/LineAnnotationController.cpp | 1243 ++++++++++------- .../apps/VC3D/LineAnnotationController.hpp | 100 +- 2 files changed, 860 insertions(+), 483 deletions(-) diff --git a/volume-cartographer/apps/VC3D/LineAnnotationController.cpp b/volume-cartographer/apps/VC3D/LineAnnotationController.cpp index 318022d5d8..b144bbaa7e 100644 --- a/volume-cartographer/apps/VC3D/LineAnnotationController.cpp +++ b/volume-cartographer/apps/VC3D/LineAnnotationController.cpp @@ -3108,6 +3108,21 @@ LineAnnotationController::prepareFiberMapAutoLink(uint64_t fiberIdA, const std::string& fileNameA, uint64_t fiberIdB, const std::string& fileNameB) +{ + if (fiberIdA == fiberIdB) { + FiberMapAutoLinkOutcome outcome; + outcome.ok = false; + outcome.message = tr("Select two different fibers."); + return outcome; + } + return prepareAutoLinkParticipants({{fiberIdA, fileNameA}, {fiberIdB, fileNameB}}, + std::nullopt); +} + +LineAnnotationController::FiberMapAutoLinkOutcome +LineAnnotationController::prepareAutoLinkParticipants( + const std::vector& participants, + const std::optional& keepSurfaceName) { FiberMapAutoLinkOutcome outcome; const auto refuse = [&outcome](const QString& message) { @@ -3126,24 +3141,30 @@ LineAnnotationController::prepareFiberMapAutoLink(uint64_t fiberIdA, } return nullptr; }; - if (fiberIdA == fiberIdB) { - return refuse(tr("Select two different fibers.")); - } - const StoredFiber* a = resolve(fiberIdA, fileNameA); - const StoredFiber* b = resolve(fiberIdB, fileNameB); - if (!a || !b) { - return refuse(tr("One of the fibers is no longer loaded; press Update.")); - } - if (a->readOnly || b->readOnly) { - return refuse(tr("%1 is read-only.").arg(fiberDisplayName(a->readOnly ? a->id : b->id))); + std::vector participantIds; + for (const AutoLinkParticipant& participant : participants) { + const StoredFiber* fiber = resolve(participant.fiberId, participant.fileName); + if (!fiber) { + return refuse(tr("One of the fibers is no longer loaded; press Update.")); + } + if (fiber->readOnly) { + return refuse(tr("%1 is read-only.").arg(fiberDisplayName(fiber->id))); + } + participantIds.push_back(fiber->id); } for (const auto& pane : _panes) { if (pane.session && pane.session->taskState == LineAnnotationSession::TaskState::Running) { return refuse(tr("Line optimization is already running.")); } } - const auto isParticipant = [fiberIdA, fiberIdB](const LineAnnotationSession& session) { - return session.fiberId == fiberIdA || session.fiberId == fiberIdB; + const auto isParticipant = [participantIds](const LineAnnotationSession& session) { + return std::find(participantIds.begin(), participantIds.end(), session.fiberId) != + participantIds.end(); + }; + // The one pane that stays (the dialog the link is made from): never + // finalized, retired or counted as still open. + const auto kept = [&keepSurfaceName](const std::string& surfaceName) { + return keepSurfaceName && *keepSurfaceName == surfaceName; }; // The active volume is an input of every scale a finalization resolves @@ -3158,7 +3179,10 @@ LineAnnotationController::prepareFiberMapAutoLink(uint64_t fiberIdA, // opening another fiber does it - but ONLY those: an unrelated dialog // stays open and unsaved, so it neither bumps the fiber generation // (which would make the map stale for nothing) nor loses its place. - if (!prepareForUserFacingLineAnnotationOpen(stillValid, isParticipant)) { + const auto finalizeFilter = [&isParticipant, &kept](const LineAnnotationSession& session) { + return isParticipant(session) && !kept(session.surfaceName); + }; + if (!prepareForUserFacingLineAnnotationOpen(stillValid, finalizeFilter)) { return refuse(tr("Could not finalize the open line annotation.")); } if (!alive) { @@ -3180,7 +3204,7 @@ LineAnnotationController::prepareFiberMapAutoLink(uint64_t fiberIdA, } }; for (const auto& pane : _panes) { - if (pane.session && isParticipant(*pane.session)) { + if (pane.session && isParticipant(*pane.session) && !kept(pane.surfaceName)) { addRetire(pane.surfaceName); } } @@ -3274,7 +3298,7 @@ LineAnnotationController::prepareFiberMapAutoLink(uint64_t fiberIdA, // Every retiring pane is closed: the flags of the (now unowned) // sessions stay as the closes read them. retired = true; - closeIntersectionInspectionForRetiredFibers({fiberIdA, fiberIdB}); + closeIntersectionInspectionForRetiredFibers(participantIds); if (!alive) { return refuse(tr("The annotation controller went away.")); } @@ -3288,7 +3312,7 @@ LineAnnotationController::prepareFiberMapAutoLink(uint64_t fiberIdA, return refuse(tr("The annotation controller went away.")); } for (const auto& pane : _panes) { - if (pane.session && isParticipant(*pane.session)) { + if (pane.session && isParticipant(*pane.session) && !kept(pane.surfaceName)) { return refuse(tr("A session of %1 is still open.") .arg(fiberDisplayNameFromFileName(pane.session->fiberFileName))); } @@ -3406,6 +3430,435 @@ std::optional LineAnnotationController::linkStoredControlPoints(StoredF return std::nullopt; } +std::optional LineAnnotationController::placePreparedControl( + AutoLinkPlacement& placement, + const AutoLinkPlacementContext& context) +{ + // The automatic link's placement of ONE new control on a session that + // the caller prepared (makeIntersectionLineSession, the solve backends, + // the real line model): the geometric prepare + splice, the pre-solve + // identity validation, the synchronous span re-optimization validated + // before it is applied, the save, the read-back of the in-memory commit + // and the drain. Every yield is followed by the caller's checks through + // `context`. placement.placed records a committed control (a refusal + // after it must say the control was kept), placement.storedIndex the + // control's stored index on success, placement.ownRecord the stored + // record as re-resolved after the save. Returns the refusal message. + namespace la = vc3d::line_annotation; + namespace al = vc3d::fiber_map::autolink; + const QPointer alive(this); + LineAnnotationSession& session = *placement.session; + const size_t countBefore = session.controlPoints.size(); + std::vector currentLinePoints; + currentLinePoints.reserve(session.optimizedLine.points.size()); + for (const auto& point : session.optimizedLine.points) { + currentLinePoints.push_back(point.position); + } + const auto cumulativeArclengths = + vc3d::fiber_slice::cumulativePolylineArclengths(currentLinePoints); + std::vector controlLinePositions; + controlLinePositions.reserve(session.controlPoints.size()); + for (const auto& control : session.controlPoints) { + controlLinePositions.push_back(control.linePosition); + } + la::PreparedControlPointEdit prepared; + try { + GeometricControlPointEdit edit; + edit.linePosition = placement.choice.linePosition; + edit.point = placement.choice.point; + edit.segmentLength = initialLineDiscretization( + std::max(2, vc3d::settings::line_annotation::EXTRAPOLATION_DISTANCE_VX_DEFAULT * 2)) + .segmentLength; + prepared = applyGeometricControlPointEdit(session, currentLinePoints, + cumulativeArclengths, + controlLinePositions, edit); + } catch (const std::exception& ex) { + return (tr("Could not place a control point on %1: %2") + .arg(placement.name, QString::fromStdString(ex.what()))); + } + // Pre-solve identity validation on the spliced session: nothing has + // been published yet, so a violation simply discards the session. + const auto validateControls = + [&](const std::vector& controls, + const std::vector& line, + bool checkOptimizedIndex) -> std::optional { + if (controls.size() != countBefore + 1) { + return tr("placing the control point changed the control count of %1") + .arg(placement.name); + } + std::vector positions; + positions.reserve(controls.size()); + for (const auto& control : controls) { + positions.push_back(control.volumePoint); + } + const auto ordered = la::orderedControlPointLineIndices(positions, line); + if (!ordered || ordered->size() != controls.size()) { + return tr("the control points of %1 no longer resolve in line order") + .arg(placement.name); + } + for (size_t i = 0; i + 1 < ordered->size(); ++i) { + if ((*ordered)[i] >= (*ordered)[i + 1]) { + return tr("the control points of %1 no longer resolve in line order") + .arg(placement.name); + } + } + // The nearest-vertex rule every consumer of this geometry uses + // (the splice, the span optimizer, the next load's session) must + // agree with the ordered scan for every control, on the spliced + // line AND on the solved line. + for (size_t i = 0; i < controls.size(); ++i) { + if (vc3d::fiber_slice::nearestLinePointIndex(line, positions[i]) != (*ordered)[i]) { + return tr("%1 passes through one of its control points twice") + .arg(placement.name); + } + } + if (checkOptimizedIndex) { + for (size_t i = 0; i < controls.size(); ++i) { + if (controls[i].optimizedIndex < 0 || + static_cast(controls[i].optimizedIndex) != (*ordered)[i]) { + return tr("%1 passes through one of its control points twice") + .arg(placement.name); + } + } + } + size_t placedCount = 0; + for (size_t i = 0; i < positions.size(); ++i) { + if (pointsApproximatelyEqual(positions[i], placement.choice.point)) { + ++placedCount; + } + } + if (placedCount != 1) { + return tr("the new control point of %1 is not unique").arg(placement.name); + } + return std::nullopt; + }; + { + std::vector splicedLine; + splicedLine.reserve(session.optimizedLine.points.size()); + for (const auto& point : session.optimizedLine.points) { + splicedLine.push_back(point.position); + } + if (const auto error = validateControls(session.controlPoints, splicedLine, true)) { + return (tr("Could not place a control point: %1.").arg(*error)); + } + if (prepared.replacementIndex >= session.controlPoints.size() || + !pointsApproximatelyEqual(session.controlPoints[prepared.replacementIndex].volumePoint, + placement.choice.point)) { + return (tr("Could not place a control point on %1.").arg(placement.name)); + } + } + // Backends again (a fiber-follow service can have stopped during the + // other fiber's save drain), then the solve, result only. + if (!ensureSessionSolveBackends(session, [&]() { return context.checkAndResolve(true); })) { + return (tr("Could not prepare the solve for %1.").arg(placement.name)); + } + if (!context.checkAndResolve(true)) { + return (context.changedMessage); + } + OptimizationTaskResult task = runFiberModeSolveSynchronously( + session, prepared.dirtySegmentIndices, "fiber_auto_link_span_opt"); + if (!task.ok) { + return (tr("Re-optimization of %1 failed: %2") + .arg(placement.name, QString::fromStdString(task.error))); + } + { + std::vector solvedLine; + solvedLine.reserve(task.result.line.points.size()); + for (const auto& point : task.result.line.points) { + solvedLine.push_back(point.position); + } + // The solver's own indices must agree with the ordered scan and + // the nearest-vertex rule as well: applyOptimizationTaskResult + // trusts them. + if (const auto error = validateControls(task.controlPoints, solvedLine, true)) { + return (tr("Re-optimization of %1 was rejected: %2") + .arg(placement.name, *error)); + } + } + // Committed from here: the ordinary landing publishes the geometry + // and syncs linked peers. + if (!applyOptimizationTaskResult(session, std::move(task), false, + SessionOptimizationState::Optimized, + "fiber_auto_link_span_opt", false)) { + return (tr("Could not apply the re-optimization of %1.").arg(placement.name)); + } + if (!alive) { + return (context.changedMessage); + } + const auto snapshot = makeStoredFiberSessionSnapshot(session); + if (prepared.replacementIndex >= snapshot.storedIndexForSessionIndex.size()) { + return (tr("Could not place a control point on %1.").arg(placement.name)); + } + const int storedIndex = snapshot.storedIndexForSessionIndex[prepared.replacementIndex]; + const uint64_t failuresBefore = _fiberSaveFailureCount; + // The other participant, as it is now: the save's notifications can + // yield, and an edit landing on it meanwhile would be overwritten + // by its own detached session later. Compared after the save; a + // change is a refusal (the generation alone cannot tell this + // operation's own bumps from an edit). + const std::optional otherBefore = + context.otherSnapshot ? context.otherSnapshot() : std::nullopt; + saveSessionAsFiber(session); + // The save's failure paths are modal: lifetime first, then the + // save's own outcome (reported as such, before any identity verdict + // could hide it), then identity and the stored fiber. + if (!alive) { + return (context.changedMessage); + } + // The in-memory commit, read back before any verdict: the save + // updates the fiber list synchronously and then publishes summaries, + // which can yield (the metrics dataset picker), so a refusal from + // here on - a write failure delivered meanwhile, an edit of the other + // fiber - must already say that this control is retained. + // This side alone is resolved for the read-back: the other + // participant may be gone by now (a queued delete during the yield), + // which is a refusal of its own below, not a reason to forget that + // this control was committed. + if (StoredFiber* own = context.resolveOwn ? context.resolveOwn() : nullptr) { + placement.ownRecord = own; + if (storedIndex >= 0 && + static_cast(storedIndex) < own->controlPoints.size() && + pointsApproximatelyEqual(own->controlPoints[static_cast(storedIndex)], + placement.choice.point)) { + placement.placed = true; + } + } + const auto writeFailedMessage = [&placement]() { + return tr("The new control point of %1 could not be written to disk; " + "the loaded fibers may differ from the files - reload the " + "project before further edits.") + .arg(placement.name); + }; + if (_fiberSaveFailureCount != failuresBefore) { + // Placed in memory but not on disk is the reload case; a save + // that failed before updating the fiber list changed nothing. + return (placement.placed ? writeFailedMessage() + : tr("Could not save %1.").arg(placement.name)); + } + if (!context.checkAndResolve(false)) { + return (context.changedMessage); + } + if (context.otherSnapshot) { + const std::optional otherAfter = context.otherSnapshot(); + if (otherAfter != otherBefore) { + return (tr("%1 changed while %2 was being saved; nothing more was done.") + .arg(context.otherName, placement.name)); + } + } + *context.expectedGeneration = fiberDataGeneration(); + if (!placement.placed) { + return (tr("The new control point of %1 was not found after saving.") + .arg(placement.name)); + } + // The drain: a save's completion bumps nothing, so the generation + // captured right after this operation's own mutation must hold + // throughout (any edit meanwhile is a refusal). A failed write is + // reported before the identity verdict. + waitForFiberSaves(context.identityOnly); + if (!alive) { + return (context.changedMessage); + } + if (_fiberSaveFailureCount != failuresBefore) { + return (writeFailedMessage()); + } + if (!context.checkAndResolve(true)) { + return (context.changedMessage); + } + placement.storedIndex = storedIndex; + return std::nullopt; +} + +LineAnnotationController::AutoLinkChoiceSource +LineAnnotationController::choiceSourceFromStoredFiber(const StoredFiber& fiber) const +{ + namespace la = vc3d::line_annotation; + AutoLinkChoiceSource source; + source.linePoints = &fiber.linePoints; + source.controls = la::storedControlPointPositions(fiber.controlPoints); + source.linked.resize(fiber.controlPoints.size(), false); + source.kollesisTerminations.resize(fiber.controlPoints.size(), false); + for (size_t c = 0; c < fiber.controlPoints.size(); ++c) { + source.linked[c] = controlPointHasBranchLink(fiber.branches, c); + source.kollesisTerminations[c] = + la::hasControlPointTag(fiber.controlPoints[c].tags, la::kKollesisTerminationTag); + } + // Gap spans in stored order: the span descriptor of the lower control + // carries the tag. + for (size_t c = 0; c + 1 < fiber.controlPoints.size(); ++c) { + if (la::spanIsGap(fiber.controlPoints[c].segmentToNext)) { + source.gapSpans.emplace_back(c, c + 1); + } + } + return source; +} + +std::optional LineAnnotationController::planAutoLinkChoices( + std::array& sides, + double* passDistance) const +{ + // The automatic link's choices over plain data (line points, control + // positions, linked flags, tags, gap spans - base voxels), so a stored + // fiber and a live session feed the same rule: the ordered-scan / + // nearest-vertex agreement, the closest 3D pass inside the windows, a + // control per side (reused within reuseVx when unlinked, else placed). + namespace la = vc3d::line_annotation; + namespace al = vc3d::fiber_map::autolink; + for (AutoLinkChoiceSide& side : sides) { + const std::vector& linePoints = *side.source.linePoints; + if (linePoints.size() < 2) { + return tr("%1 has no line to link on.").arg(side.name); + } + const auto ordered = la::orderedControlPointLineIndices(side.source.controls, linePoints); + if (!ordered || ordered->size() != side.source.controls.size()) { + return tr("The control points of %1 do not resolve in line order.").arg(side.name); + } + // The shared placement machinery (splice, span optimizer) resolves + // controls by nearest vertex; it is only correct where that agrees + // with the loader's ordered scan. + for (size_t c = 0; c < side.source.controls.size(); ++c) { + if (vc3d::fiber_slice::nearestLinePointIndex(linePoints, side.source.controls[c]) != + (*ordered)[c]) { + return tr("%1 passes through one of its control points twice.").arg(side.name); + } + } + side.orderedIndex = *ordered; + side.cumulative = vc3d::fiber_slice::cumulativePolylineArclengths(linePoints); + } + al::FiberSide sideA{sides[0].source.linePoints, &sides[0].cumulative, + sides[0].anchorPosition, sides[0].windowVx}; + al::FiberSide sideB{sides[1].source.linePoints, &sides[1].cumulative, + sides[1].anchorPosition, sides[1].windowVx}; + const auto pass = al::findClosestPass(sideA, sideB); + if (!pass) { + return tr("Could not find where the fibers pass each other."); + } + const auto focusBounds = _state ? _state->activeFocusBounds() : std::nullopt; + for (size_t s = 0; s < 2; ++s) { + AutoLinkChoiceSide& side = sides[s]; + al::ControlChoiceInput input; + input.linePoints = side.source.linePoints; + input.cumulative = &side.cumulative; + input.controlLineIndex = side.orderedIndex; + input.linked = side.source.linked; + input.kollesisTerminations = side.source.kollesisTerminations; + input.gapSpans = side.source.gapSpans; + if (focusBounds) { + input.inFocusBounds = [bounds = *focusBounds](const cv::Vec3d& p) { + return contains_point(bounds, p); + }; + input.focusBox = std::make_pair( + cv::Vec3d(focusBounds->low[0], focusBounds->low[1], focusBounds->low[2]), + cv::Vec3d(focusBounds->high[0], focusBounds->high[1], focusBounds->high[2])); + } + input.passPosition = s == 0 ? pass->positionA : pass->positionB; + input.windowLo = s == 0 ? pass->windowLoA : pass->windowLoB; + input.windowHi = s == 0 ? pass->windowHiA : pass->windowHiB; + input.reuseVx = side.reuseVx; + input.minSpacingVx = kMinimumControlPointSpacingBaseVoxels; + side.choice = al::chooseControlPoint(input); + if (side.choice.kind == al::ControlChoice::Kind::Refused) { + return tr("No place for a control point on %1: %2") + .arg(side.name, QString::fromStdString(side.choice.reason)); + } + } + if (passDistance) { + *passDistance = pass->distance; + } + return std::nullopt; +} + +void LineAnnotationController::withdrawTrackedLink(const TrackedLinkEndpoints& tracked, + uint64_t packageGeneration, + uint64_t loadSequence) +{ + // The withdrawal of an installed automatic link whose pair save did not + // write both files: every copy of the link (the stored records, queued + // save snapshots, open sessions' live branches and armed rollback + // copies) is removed, matched by the tracked endpoint positions (every + // position each endpoint has held since the install) and the two + // fibers' identity - never by review state or index. Only under the + // identity the operation started with. + namespace la = vc3d::line_annotation; + const QPointer alive(this); + const auto isThisLink = [&](const FiberBranchRef& branch, bool fromA) { + const uint64_t farId = fromA ? tracked.fiberIdB : tracked.fiberIdA; + const std::string& farFile = fromA ? tracked.fileB : tracked.fileA; + const bool hereHeld = fromA ? tracked.heldA(branch.controlPointPosition) + : tracked.heldB(branch.controlPointPosition); + const bool thereHeld = fromA ? tracked.heldB(branch.branchControlPointPosition) + : tracked.heldA(branch.branchControlPointPosition); + return !branch.adjacent && + la::sameFiberIdentity(branch.branchFiberId, branch.branchFileName, farId, farFile) && + hereHeld && thereHeld; + }; + // Only under the identity this operation started with: another + // package or a reloaded fiber list is not this operation's to edit. + if (_packageGeneration != packageGeneration || _fiberLoadSequence != loadSequence) { + return; + } + for (StoredFiber& fiber : _fibers) { + if (fiber.id == tracked.fiberIdA && fiber.fileName == tracked.fileA) { + std::erase_if(fiber.branches, [&](const FiberBranchRef& b) { return isThisLink(b, true); }); + } else if (fiber.id == tracked.fiberIdB && fiber.fileName == tracked.fileB) { + std::erase_if(fiber.branches, [&](const FiberBranchRef& b) { return isThisLink(b, false); }); + } + } + // A save queued meanwhile (a peer sync of either fiber) carries a + // snapshot with the refs: it must not write the withdrawn link back. + for (FiberSaveJob& job : _pendingFiberSaveJobs) { + for (FiberSaveSnapshot& snapshot : job.snapshots) { + if (snapshot.fiber.id == tracked.fiberIdA && snapshot.fiber.fileName == tracked.fileA) { + std::erase_if(snapshot.fiber.branches, + [&](const FiberBranchRef& b) { return isThisLink(b, true); }); + } else if (snapshot.fiber.id == tracked.fiberIdB && snapshot.fiber.fileName == tracked.fileB) { + std::erase_if(snapshot.fiber.branches, + [&](const FiberBranchRef& b) { return isThisLink(b, false); }); + } + } + } + // A session of either fiber opened during the drain copied the refs: + // it must not save the withdrawn link back - neither from its live + // branches nor from an armed rollback copy (a goal-change solve's + // branchesBeforeModeChange, a collapse rollback) that a later + // failure restores wholesale. + for (auto& pane : _panes) { + if (!pane.session) { + continue; + } + auto& session = *pane.session; + std::optional localIsA; + if (la::sameFiberIdentity(session.fiberId, session.fiberFileName, tracked.fiberIdA, tracked.fileA)) { + localIsA = true; + } else if (la::sameFiberIdentity(session.fiberId, session.fiberFileName, tracked.fiberIdB, + tracked.fileB)) { + localIsA = false; + } + if (!localIsA) { + continue; + } + const auto scrub = [&](std::vector& branches) { + std::erase_if(branches, + [&](const FiberBranchRef& b) { return isThisLink(b, *localIsA); }); + }; + scrub(session.branches); + if (session.branchesBeforeModeChange) { + scrub(*session.branchesBeforeModeChange); + } + if (session.controlPointCollapseRollback) { + scrub(session.controlPointCollapseRollback->branches); + } + } + invalidateFiberAlignmentMetrics(tracked.fiberIdA, true); + invalidateFiberAlignmentMetrics(tracked.fiberIdB, true); + emitFiberSummaries(); + // The summaries can yield (the dock's metrics picker). + if (!alive) { + return; + } + refreshBranchLineViews(tracked.fiberIdA); + refreshBranchLineViews(tracked.fiberIdB); +} + LineAnnotationController::FiberMapAutoLinkOutcome LineAnnotationController::autoLinkFibers(const FiberMapAutoLinkEndpoint& a, const FiberMapAutoLinkEndpoint& b, @@ -3592,366 +4045,125 @@ LineAnnotationController::autoLinkFibers(const FiberMapAutoLinkEndpoint& a, // Step 1: choices first (a reuse-only link needs no backend). const auto planChoices = [&]() -> std::optional { - for (Side& side : sides) { - const StoredFiber& fiber = *side.fiber; - if (fiber.linePoints.size() < 2) { - return tr("%1 has no line to link on.").arg(side.name); - } - const std::vector controls = la::storedControlPointPositions(fiber.controlPoints); - const auto ordered = la::orderedControlPointLineIndices(controls, fiber.linePoints); - if (!ordered || ordered->size() != controls.size()) { - return tr("The control points of %1 do not resolve in line order.").arg(side.name); - } - // The shared placement machinery (splice, span optimizer) resolves - // controls by nearest vertex; it is only correct where that agrees - // with the loader's ordered scan. - for (size_t c = 0; c < controls.size(); ++c) { - if (vc3d::fiber_slice::nearestLinePointIndex(fiber.linePoints, controls[c]) != - (*ordered)[c]) { - return tr("%1 passes through one of its control points twice.").arg(side.name); - } - } - side.orderedIndex = *ordered; - side.cumulative = vc3d::fiber_slice::cumulativePolylineArclengths(fiber.linePoints); + std::array choiceSides; + for (size_t s = 0; s < 2; ++s) { + choiceSides[s].name = sides[s].name; + choiceSides[s].source = choiceSourceFromStoredFiber(*sides[s].fiber); + choiceSides[s].anchorPosition = sides[s].endpoint->linePosition; + choiceSides[s].windowVx = sides[s].windowVx; + choiceSides[s].reuseVx = sides[s].reuseVx; } - al::FiberSide sideA{&sides[0].fiber->linePoints, &sides[0].cumulative, - sides[0].endpoint->linePosition, sides[0].windowVx}; - al::FiberSide sideB{&sides[1].fiber->linePoints, &sides[1].cumulative, - sides[1].endpoint->linePosition, sides[1].windowVx}; - const auto pass = al::findClosestPass(sideA, sideB); - if (!pass) { - return tr("Could not find where the fibers pass each other."); + double passDistance = 0.0; + if (const auto error = planAutoLinkChoices(choiceSides, &passDistance)) { + return error; } - const auto focusBounds = _state ? _state->activeFocusBounds() : std::nullopt; for (size_t s = 0; s < 2; ++s) { - Side& side = sides[s]; - const StoredFiber& fiber = *side.fiber; - al::ControlChoiceInput input; - input.linePoints = &fiber.linePoints; - input.cumulative = &side.cumulative; - input.controlLineIndex = side.orderedIndex; - input.linked.resize(fiber.controlPoints.size(), false); - input.kollesisTerminations.resize(fiber.controlPoints.size(), false); - for (size_t c = 0; c < fiber.controlPoints.size(); ++c) { - input.linked[c] = controlPointHasBranchLink(fiber.branches, c); - input.kollesisTerminations[c] = la::hasControlPointTag( - fiber.controlPoints[c].tags, la::kKollesisTerminationTag); - } - // Gap spans in stored order: the span descriptor of the lower - // control carries the tag. - for (size_t c = 0; c + 1 < fiber.controlPoints.size(); ++c) { - if (la::spanIsGap(fiber.controlPoints[c].segmentToNext)) { - input.gapSpans.emplace_back(c, c + 1); - } - } - if (focusBounds) { - input.inFocusBounds = [bounds = *focusBounds](const cv::Vec3d& p) { - return contains_point(bounds, p); - }; - input.focusBox = std::make_pair( - cv::Vec3d(focusBounds->low[0], focusBounds->low[1], focusBounds->low[2]), - cv::Vec3d(focusBounds->high[0], focusBounds->high[1], focusBounds->high[2])); - } - input.passPosition = s == 0 ? pass->positionA : pass->positionB; - input.windowLo = s == 0 ? pass->windowLoA : pass->windowLoB; - input.windowHi = s == 0 ? pass->windowHiA : pass->windowHiB; - input.reuseVx = side.reuseVx; - input.minSpacingVx = kMinimumControlPointSpacingBaseVoxels; - side.choice = al::chooseControlPoint(input); - if (side.choice.kind == al::ControlChoice::Kind::Refused) { - return tr("No place for a control point on %1: %2") - .arg(side.name, QString::fromStdString(side.choice.reason)); - } + sides[s].orderedIndex = std::move(choiceSides[s].orderedIndex); + sides[s].cumulative = std::move(choiceSides[s].cumulative); + sides[s].choice = choiceSides[s].choice; } // Two reused controls that are already linked to each other: refuse // now, before any mutation. if (sides[0].choice.kind == al::ControlChoice::Kind::Reuse && sides[1].choice.kind == al::ControlChoice::Kind::Reuse) { for (const FiberBranchRef& branch : sides[0].fiber->branches) { - if (branch.branchFiberId == sides[1].fiber->id && - branch.controlPointIndex == sides[0].choice.controlIndex && - branch.branchControlPointIndex == sides[1].choice.controlIndex) { - return tr("%1 / CP %2 and %3 / CP %4 are already linked.") - .arg(sides[0].name).arg(sides[0].choice.controlIndex) - .arg(sides[1].name).arg(sides[1].choice.controlIndex); - } - } - } - outcome.message = tr("3D gap %1 vx").arg(pass->distance, 0, 'f', 1); - return std::nullopt; - }; - if (const auto error = planChoices()) { - return refuse(*error); - } - const QString gapText = outcome.message; - - // Step 2: backends, only for fibers that get a new control; sessions are - // built once and kept (the guard proves them fresh after the yields). - for (Side& side : sides) { - if (side.choice.kind != al::ControlChoice::Kind::Place) { - continue; - } - side.session = makeIntersectionLineSession( - *side.fiber, side.choice.linePosition, cv::Vec3d{0.0, 0.0, 1.0}, - "fiber_auto_link_" + side.fiber->fileName, nullptr); - side.session->fiberBaseToVolumeScale = side.scale; - side.session->suppressErrorDialogs = false; - if (!ensureSessionSolveBackends(*side.session, [&]() { return checkAndResolve(true); })) { - return refuse(tr("Could not prepare the solve for %1.").arg(side.name)); - } - if (!checkAndResolve(true)) { - return refuse(changedMessage); - } - } - // Step 3: the real line model (sampled normals) before any edit. The - // samplers throw on unusable normals; a throw is a refusal, not a crash. - for (Side& side : sides) { - if (!side.session) { - continue; - } - // An owned copy: the helper can yield (fiber-follow start) and a - // reference into _fibers would not survive a reallocation. - const std::vector linePointsCopy = side.fiber->linePoints; - try { - if (!prepareSessionLineModel(*side.session, linePointsCopy, - [&]() { return checkAndResolve(true); })) { - return refuse(tr("Could not prepare the line model of %1.").arg(side.name)); - } - } catch (const std::exception& ex) { - return refuse(tr("Could not prepare the line model of %1: %2") - .arg(side.name, QString::fromStdString(ex.what()))); - } - if (!checkAndResolve(true)) { - return refuse(changedMessage); - } - } - - // Step 4: placement, A then B. - for (Side& side : sides) { - if (side.choice.kind == al::ControlChoice::Kind::Reuse) { - side.linkedControlIndex = side.choice.controlIndex; - continue; - } - LineAnnotationSession& session = *side.session; - const size_t countBefore = session.controlPoints.size(); - std::vector currentLinePoints; - currentLinePoints.reserve(session.optimizedLine.points.size()); - for (const auto& point : session.optimizedLine.points) { - currentLinePoints.push_back(point.position); - } - const auto cumulativeArclengths = - vc3d::fiber_slice::cumulativePolylineArclengths(currentLinePoints); - std::vector controlLinePositions; - controlLinePositions.reserve(session.controlPoints.size()); - for (const auto& control : session.controlPoints) { - controlLinePositions.push_back(control.linePosition); - } - la::PreparedControlPointEdit prepared; - try { - GeometricControlPointEdit edit; - edit.linePosition = side.choice.linePosition; - edit.point = side.choice.point; - edit.segmentLength = initialLineDiscretization( - std::max(2, vc3d::settings::line_annotation::EXTRAPOLATION_DISTANCE_VX_DEFAULT * 2)) - .segmentLength; - prepared = applyGeometricControlPointEdit(session, currentLinePoints, - cumulativeArclengths, - controlLinePositions, edit); - } catch (const std::exception& ex) { - return refuseKeeping(tr("Could not place a control point on %1: %2") - .arg(side.name, QString::fromStdString(ex.what()))); - } - // Pre-solve identity validation on the spliced session: nothing has - // been published yet, so a violation simply discards the session. - const auto validateControls = - [&](const std::vector& controls, - const std::vector& line, - bool checkOptimizedIndex) -> std::optional { - if (controls.size() != countBefore + 1) { - return tr("placing the control point changed the control count of %1") - .arg(side.name); - } - std::vector positions; - positions.reserve(controls.size()); - for (const auto& control : controls) { - positions.push_back(control.volumePoint); - } - const auto ordered = la::orderedControlPointLineIndices(positions, line); - if (!ordered || ordered->size() != controls.size()) { - return tr("the control points of %1 no longer resolve in line order") - .arg(side.name); - } - for (size_t i = 0; i + 1 < ordered->size(); ++i) { - if ((*ordered)[i] >= (*ordered)[i + 1]) { - return tr("the control points of %1 no longer resolve in line order") - .arg(side.name); - } - } - // The nearest-vertex rule every consumer of this geometry uses - // (the splice, the span optimizer, the next load's session) must - // agree with the ordered scan for every control, on the spliced - // line AND on the solved line. - for (size_t i = 0; i < controls.size(); ++i) { - if (vc3d::fiber_slice::nearestLinePointIndex(line, positions[i]) != (*ordered)[i]) { - return tr("%1 passes through one of its control points twice") - .arg(side.name); - } - } - if (checkOptimizedIndex) { - for (size_t i = 0; i < controls.size(); ++i) { - if (controls[i].optimizedIndex < 0 || - static_cast(controls[i].optimizedIndex) != (*ordered)[i]) { - return tr("%1 passes through one of its control points twice") - .arg(side.name); - } - } - } - size_t placedCount = 0; - for (size_t i = 0; i < positions.size(); ++i) { - if (pointsApproximatelyEqual(positions[i], side.choice.point)) { - ++placedCount; - } - } - if (placedCount != 1) { - return tr("the new control point of %1 is not unique").arg(side.name); - } - return std::nullopt; - }; - { - std::vector splicedLine; - splicedLine.reserve(session.optimizedLine.points.size()); - for (const auto& point : session.optimizedLine.points) { - splicedLine.push_back(point.position); - } - if (const auto error = validateControls(session.controlPoints, splicedLine, true)) { - return refuseKeeping(tr("Could not place a control point: %1.").arg(*error)); - } - if (prepared.replacementIndex >= session.controlPoints.size() || - !pointsApproximatelyEqual(session.controlPoints[prepared.replacementIndex].volumePoint, - side.choice.point)) { - return refuseKeeping(tr("Could not place a control point on %1.").arg(side.name)); + if (branch.branchFiberId == sides[1].fiber->id && + branch.controlPointIndex == sides[0].choice.controlIndex && + branch.branchControlPointIndex == sides[1].choice.controlIndex) { + return tr("%1 / CP %2 and %3 / CP %4 are already linked.") + .arg(sides[0].name).arg(sides[0].choice.controlIndex) + .arg(sides[1].name).arg(sides[1].choice.controlIndex); + } } } - // Backends again (a fiber-follow service can have stopped during the - // other fiber's save drain), then the solve, result only. - if (!ensureSessionSolveBackends(session, [&]() { return checkAndResolve(true); })) { - return refuseKeeping(tr("Could not prepare the solve for %1.").arg(side.name)); + outcome.message = tr("3D gap %1 vx").arg(passDistance, 0, 'f', 1); + return std::nullopt; + }; + if (const auto error = planChoices()) { + return refuse(*error); + } + const QString gapText = outcome.message; + + // Step 2: backends, only for fibers that get a new control; sessions are + // built once and kept (the guard proves them fresh after the yields). + for (Side& side : sides) { + if (side.choice.kind != al::ControlChoice::Kind::Place) { + continue; + } + side.session = makeIntersectionLineSession( + *side.fiber, side.choice.linePosition, cv::Vec3d{0.0, 0.0, 1.0}, + "fiber_auto_link_" + side.fiber->fileName, nullptr); + side.session->fiberBaseToVolumeScale = side.scale; + side.session->suppressErrorDialogs = false; + if (!ensureSessionSolveBackends(*side.session, [&]() { return checkAndResolve(true); })) { + return refuse(tr("Could not prepare the solve for %1.").arg(side.name)); } if (!checkAndResolve(true)) { - return refuseKeeping(changedMessage); + return refuse(changedMessage); } - OptimizationTaskResult task = runFiberModeSolveSynchronously( - session, prepared.dirtySegmentIndices, "fiber_auto_link_span_opt"); - if (!task.ok) { - return refuseKeeping(tr("Re-optimization of %1 failed: %2") - .arg(side.name, QString::fromStdString(task.error))); + } + // Step 3: the real line model (sampled normals) before any edit. The + // samplers throw on unusable normals; a throw is a refusal, not a crash. + for (Side& side : sides) { + if (!side.session) { + continue; } - { - std::vector solvedLine; - solvedLine.reserve(task.result.line.points.size()); - for (const auto& point : task.result.line.points) { - solvedLine.push_back(point.position); - } - // The solver's own indices must agree with the ordered scan and - // the nearest-vertex rule as well: applyOptimizationTaskResult - // trusts them. - if (const auto error = validateControls(task.controlPoints, solvedLine, true)) { - return refuseKeeping(tr("Re-optimization of %1 was rejected: %2") - .arg(side.name, *error)); + // An owned copy: the helper can yield (fiber-follow start) and a + // reference into _fibers would not survive a reallocation. + const std::vector linePointsCopy = side.fiber->linePoints; + try { + if (!prepareSessionLineModel(*side.session, linePointsCopy, + [&]() { return checkAndResolve(true); })) { + return refuse(tr("Could not prepare the line model of %1.").arg(side.name)); } + } catch (const std::exception& ex) { + return refuse(tr("Could not prepare the line model of %1: %2") + .arg(side.name, QString::fromStdString(ex.what()))); } - // Committed from here: the ordinary landing publishes the geometry - // and syncs linked peers. - if (!applyOptimizationTaskResult(session, std::move(task), false, - SessionOptimizationState::Optimized, - "fiber_auto_link_span_opt", false)) { - return refuseKeeping(tr("Could not apply the re-optimization of %1.").arg(side.name)); - } - if (!alive) { + if (!checkAndResolve(true)) { return refuse(changedMessage); } - const auto snapshot = makeStoredFiberSessionSnapshot(session); - if (prepared.replacementIndex >= snapshot.storedIndexForSessionIndex.size()) { - return refuseKeeping(tr("Could not place a control point on %1.").arg(side.name)); + } + + // Step 4: placement, A then B (placePreparedControl: the map's Step 4 + // over the session Steps 2-3 prepared). + for (Side& side : sides) { + if (side.choice.kind == al::ControlChoice::Kind::Reuse) { + side.linkedControlIndex = side.choice.controlIndex; + continue; } - const int storedIndex = snapshot.storedIndexForSessionIndex[prepared.replacementIndex]; - const uint64_t failuresBefore = _fiberSaveFailureCount; - // The other participant, as it is now: the save's notifications can - // yield, and an edit landing on it meanwhile would be overwritten - // by its own detached session later. Compared after the save; a - // change is a refusal (the generation alone cannot tell this - // operation's own bumps from an edit). const Side& other = &side == &sides[0] ? sides[1] : sides[0]; - nlohmann::json otherBefore = fiberToJson(*other.fiber); - otherBefore.erase("generation"); - saveSessionAsFiber(session); - // The save's failure paths are modal: lifetime first, then the - // save's own outcome (reported as such, before any identity verdict - // could hide it), then identity and the stored fiber. - if (!alive) { - return refuse(changedMessage); - } - // The in-memory commit, read back before any verdict: the save - // updates the fiber list synchronously and then publishes summaries, - // which can yield (the metrics dataset picker), so a refusal from - // here on - a write failure delivered meanwhile, an edit of the other - // fiber - must already say that this control is retained. - // This side alone is resolved for the read-back: the other - // participant may be gone by now (a queued delete during the yield), - // which is a refusal of its own below, not a reason to forget that - // this control was committed. - if (StoredFiber* own = resolve(side.endpoint->fiberId, side.endpoint->fileName)) { - side.fiber = own; - if (storedIndex >= 0 && - static_cast(storedIndex) < own->controlPoints.size() && - pointsApproximatelyEqual(own->controlPoints[static_cast(storedIndex)], - side.choice.point)) { - side.placed = true; - } - } - const auto writeFailedMessage = [&side]() { - return tr("The new control point of %1 could not be written to disk; " - "the loaded fibers may differ from the files - reload the " - "project before further edits.") - .arg(side.name); + AutoLinkPlacement placement; + placement.session = side.session; + placement.choice = side.choice; + placement.name = side.name; + AutoLinkPlacementContext context; + context.checkAndResolve = [&](bool includeGeneration) { return checkAndResolve(includeGeneration); }; + context.identityOnly = [&]() { return identityOnly(); }; + context.resolveOwn = [&]() { return resolve(side.endpoint->fiberId, side.endpoint->fileName); }; + context.otherSnapshot = [&]() -> std::optional { + if (!other.fiber) { + return std::nullopt; + } + nlohmann::json snapshot = fiberToJson(*other.fiber); + snapshot.erase("generation"); + return snapshot; }; - if (_fiberSaveFailureCount != failuresBefore) { - // Placed in memory but not on disk is the reload case; a save - // that failed before updating the fiber list changed nothing. - return refuseKeeping(side.placed ? writeFailedMessage() - : tr("Could not save %1.").arg(side.name)); - } - if (!checkAndResolve(false)) { - return refuseKeeping(changedMessage); - } - { - nlohmann::json otherAfter = fiberToJson(*other.fiber); - otherAfter.erase("generation"); - if (otherAfter != otherBefore) { - return refuseKeeping(tr("%1 changed while %2 was being saved; nothing more was done.") - .arg(other.name, side.name)); - } - } - expectedGeneration = fiberDataGeneration(); - if (!side.placed) { - return refuseKeeping(tr("The new control point of %1 was not found after saving.") - .arg(side.name)); - } - // The drain: a save's completion bumps nothing, so the generation - // captured right after this operation's own mutation must hold - // throughout (any edit meanwhile is a refusal). A failed write is - // reported before the identity verdict. - waitForFiberSaves(identityOnly); + context.otherName = other.name; + context.expectedGeneration = &expectedGeneration; + context.changedMessage = changedMessage; + const auto error = placePreparedControl(placement, context); + side.placed = placement.placed; if (!alive) { return refuse(changedMessage); } - if (_fiberSaveFailureCount != failuresBefore) { - return refuseKeeping(writeFailedMessage()); + if (placement.ownRecord) { + side.fiber = placement.ownRecord; } - if (!checkAndResolve(true)) { - return refuseKeeping(changedMessage); + if (error) { + return refuseKeeping(*error); } - side.linkedControlIndex = storedIndex; + side.linkedControlIndex = placement.storedIndex; } // Step 5: the link, on the stored fibers, and its pair save drained @@ -4028,73 +4240,7 @@ LineAnnotationController::autoLinkFibers(const FiberMapAutoLinkEndpoint& a, hereHeld && thereHeld; }; const auto removeInstalledLink = [&]() { - // Only under the identity this operation started with: another - // package or a reloaded fiber list is not this operation's to edit. - if (!alive || _packageGeneration != packageGeneration || - _fiberLoadSequence != loadSequence) { - return; - } - for (StoredFiber& fiber : _fibers) { - if (fiber.id == fiberIdA && fiber.fileName == fileA) { - std::erase_if(fiber.branches, [&](const FiberBranchRef& b) { return isThisLink(b, true); }); - } else if (fiber.id == fiberIdB && fiber.fileName == fileB) { - std::erase_if(fiber.branches, [&](const FiberBranchRef& b) { return isThisLink(b, false); }); - } - } - // A save queued meanwhile (a peer sync of either fiber) carries a - // snapshot with the refs: it must not write the withdrawn link back. - for (FiberSaveJob& job : _pendingFiberSaveJobs) { - for (FiberSaveSnapshot& snapshot : job.snapshots) { - if (snapshot.fiber.id == fiberIdA && snapshot.fiber.fileName == fileA) { - std::erase_if(snapshot.fiber.branches, - [&](const FiberBranchRef& b) { return isThisLink(b, true); }); - } else if (snapshot.fiber.id == fiberIdB && snapshot.fiber.fileName == fileB) { - std::erase_if(snapshot.fiber.branches, - [&](const FiberBranchRef& b) { return isThisLink(b, false); }); - } - } - } - // A session of either fiber opened during the drain copied the refs: - // it must not save the withdrawn link back - neither from its live - // branches nor from an armed rollback copy (a goal-change solve's - // branchesBeforeModeChange, a collapse rollback) that a later - // failure restores wholesale. - for (auto& pane : _panes) { - if (!pane.session) { - continue; - } - auto& session = *pane.session; - std::optional localIsA; - if (la::sameFiberIdentity(session.fiberId, session.fiberFileName, fiberIdA, fileA)) { - localIsA = true; - } else if (la::sameFiberIdentity(session.fiberId, session.fiberFileName, fiberIdB, - fileB)) { - localIsA = false; - } - if (!localIsA) { - continue; - } - const auto scrub = [&](std::vector& branches) { - std::erase_if(branches, - [&](const FiberBranchRef& b) { return isThisLink(b, *localIsA); }); - }; - scrub(session.branches); - if (session.branchesBeforeModeChange) { - scrub(*session.branchesBeforeModeChange); - } - if (session.controlPointCollapseRollback) { - scrub(session.controlPointCollapseRollback->branches); - } - } - invalidateFiberAlignmentMetrics(fiberIdA, true); - invalidateFiberAlignmentMetrics(fiberIdB, true); - emitFiberSummaries(); - // The summaries can yield (the dock's metrics picker). - if (!alive) { - return; - } - refreshBranchLineViews(fiberIdA); - refreshBranchLineViews(fiberIdB); + withdrawTrackedLink(tracked, packageGeneration, loadSequence); }; waitForFiberSaves(identityOnly); if (!alive) { @@ -9274,7 +9420,10 @@ void LineAnnotationController::handleGeneratedControlPoint(const std::string& su return; } - auto& session = *pane->session; + // Owned for the duration: the preparation below yields, and the pane + // is re-resolved by name afterwards (it can be erased meanwhile). + const std::shared_ptr owned = pane->session; + auto& session = *owned; // Control-point edits deliberately do NOT refuse while a solve is in // flight: the session is mutated immediately, the in-flight solve is // superseded (epoch bump + cooperative cancel), and one solve over the @@ -9287,14 +9436,11 @@ void LineAnnotationController::handleGeneratedControlPoint(const std::string& su toVec3d(volumePoint), session.suppressErrorDialogs)) { return; } - // A debounced autosave still holds the LAST landing's geometry; once - // this edit mutates the session it can never be saved (the flush refuses - // unoptimized sessions), so persist it first, like the synchronous - // per-landing save used to. - if (session.autoSaveScheduled) { - flushSessionAutoSave(session.surfaceName); + if (!prepareSessionForControlPlacement(surfaceName, owned, {})) { + return; } - if (!ensureDatasetForSession(session)) { + pane = paneForSurface(surfaceName); + if (!pane || pane->session != owned) { return; } @@ -9399,7 +9545,73 @@ void LineAnnotationController::handleGeneratedControlPoint(const std::string& su } } - const bool manualDisplayNormal = displayNormal.has_value(); + GeometricControlPointEdit edit; + edit.linePosition = linePosition; + edit.point = clicked; + edit.nearbyControlIndices = nearbyControlIndices; + edit.displayNormal = displayNormal; + edit.manualDisplayNormal = displayNormal.has_value(); + edit.direction = direction; + (void)placeControlInSession(surfaceName, owned, currentLinePoints, cumulativeArclengths, + controlLinePositions, std::move(edit), {}); +} + +bool LineAnnotationController::prepareSessionForControlPlacement( + const std::string& surfaceName, + const std::shared_ptr& owned, + const std::function& stillValid) +{ + // The yields that precede a placement's geometry capture, in the order + // the interactive handler always ran them: a debounced autosave still + // holds the LAST landing's geometry and could never be saved once the + // edit mutates the session (the flush refuses unoptimized sessions), so + // it is persisted first; then the Lasagna dataset (a picker). After + // each, lifetime, validity and the pane (by name) are re-checked: the + // caller captures its geometry only after this returns true. + const QPointer alive(this); + LineAnnotationSession& session = *owned; + const auto paneIntact = [this, &surfaceName, &owned]() { + auto* pane = paneForSurface(surfaceName); + return pane && pane->session == owned; + }; + if (session.autoSaveScheduled) { + flushSessionAutoSave(session.surfaceName); + if (!alive || (stillValid && !stillValid()) || !paneIntact()) { + return false; + } + } + if (!ensureDatasetForSession(session, true, stillValid)) { + return false; + } + if (!alive || (stillValid && !stillValid()) || !paneIntact()) { + return false; + } + return true; +} + +std::optional LineAnnotationController::placeControlInSession( + const std::string& surfaceName, + const std::shared_ptr& owned, + const std::vector& currentLinePoints, + const std::vector& cumulativeArclengths, + const std::vector& controlLinePositions, + GeometricControlPointEdit edit, + const std::function& stillValid) +{ + // The placement tail shared by the interactive handler (which computed + // edit.nearbyControlIndices from its click) and the automatic link + // (which passes an explicitly empty collapse set): rollback capture, + // the geometric prepare + splice, branch-metadata sync and saves, the + // dialog overlays, the span-solve dispatch (auto mode) or the view + // materialization (manual mode), debug json. Returns the index of the + // placed (or replacing) control in the session, nullopt when nothing + // was placed (the error was shown here). + const QPointer alive(this); + auto* pane = paneForSurface(surfaceName); + if (!pane || pane->session != owned) { + return std::nullopt; + } + LineAnnotationSession& session = *owned; const std::vector previousControls = session.controlPoints; @@ -9414,7 +9626,7 @@ void LineAnnotationController::handleGeneratedControlPoint(const std::string& su // only come from the nearby set, so the copy is taken only when one is // possible. std::optional previousLine; - if (nearbyControlIndices.size() > 1) { + if (edit.nearbyControlIndices.size() > 1) { previousLine = session.optimizedLine; } const bool autoReoptimize = @@ -9433,14 +9645,7 @@ void LineAnnotationController::handleGeneratedControlPoint(const std::string& su const int initialCenterlineLengthVx = pane->dialog ? std::max(2, pane->dialog->extrapolationDistanceVx() * 2) : vc3d::settings::line_annotation::EXTRAPOLATION_DISTANCE_VX_DEFAULT * 2; - GeometricControlPointEdit edit; - edit.linePosition = linePosition; - edit.point = clicked; - edit.nearbyControlIndices = nearbyControlIndices; edit.segmentLength = initialLineDiscretization(initialCenterlineLengthVx).segmentLength; - edit.displayNormal = displayNormal; - edit.manualDisplayNormal = manualDisplayNormal; - edit.direction = direction; prepared = applyGeometricControlPointEdit(session, currentLinePoints, cumulativeArclengths, controlLinePositions, edit); @@ -9448,7 +9653,7 @@ void LineAnnotationController::handleGeneratedControlPoint(const std::string& su showError(tr("Could not update line control point: %1") .arg(QString::fromStdString(ex.what())), session.suppressErrorDialogs); - return; + return std::nullopt; } const bool editedExistingControl = !prepared.collapsedOldIndices.empty(); const size_t collapsedControlCount = prepared.collapsedOldIndices.size(); @@ -9607,11 +9812,20 @@ void LineAnnotationController::handleGeneratedControlPoint(const std::string& su // intersection query works against a display whose geometry no // longer matches the session. (While a superseded solve is still // running, its landing refreshes the views instead.) - (void)materializeGeneratedViews(session); + materializeGeneratedViews(session, stillValid); + // The materialization can show a modal (a normal reset): lifetime + // and validity first, then the pane by name. + if (!alive || (stillValid && !stillValid())) { + return std::nullopt; + } + pane = paneForSurface(surfaceName); + if (!pane || pane->session != owned) { + return std::nullopt; + } } syncDialogLineSolveActivity(session); + return prepared.replacementIndex; } - std::optional LineAnnotationController::createLinkedSeedFiber( const LinkedSeedParent& parent, const cv::Vec3d& seedPoint, @@ -10016,7 +10230,10 @@ void LineAnnotationController::handleGeneratedControlPointLinkWithCandidate( if (!pane || !pane->session) { return; } - auto& session = *pane->session; + // Owned for the duration: the autosave flush below yields, and the pane + // is re-resolved by name afterwards. + const std::shared_ptr owned = pane->session; + auto& session = *owned; if (session.fiberDeleted) { // The fiber was deleted underneath this session; a structural edit // would be persisted as a new file under the deleted name. @@ -10031,6 +10248,10 @@ void LineAnnotationController::handleGeneratedControlPointLinkWithCandidate( // Cross-fiber operation: make sure no participating fiber's newest // geometry is still session-only behind a debounced autosave. flushAllPendingSessionAutoSaves(); + pane = paneForSurface(surfaceName); + if (!pane || pane->session != owned) { + return; + } if (controlPointIndex >= session.controlPoints.size()) { return; } @@ -10081,11 +10302,61 @@ void LineAnnotationController::handleGeneratedControlPointLinkWithCandidate( showError(tr("The link candidate is already linked; unlink it first.")); return; } - const cv::Vec3d farPoint = farIt->controlPoints[static_cast(*farControlIndex)]; + if (const auto error = linkSessionControlToStoredControl( + surfaceName, owned, controlPointIndex, farIt->id, *farControlIndex, + candidate.adjacent, /*autoLink=*/false, {})) { + showError(*error); + return; + } + _linkCandidate.reset(); +} + +std::optional LineAnnotationController::linkSessionControlToStoredControl( + const std::string& surfaceName, + const std::shared_ptr& owned, + size_t controlPointIndex, + uint64_t farFiberId, + int farControlIndex, + bool adjacent, + bool autoLink, + const std::function& stillValid) +{ + // The link of a SESSION control point to a stored fiber's control point, + // shared by the link-candidate menu (adjacent or ordinary, human pending) + // and the automatic link made from a fiber-intersection marker + // (ordinary, automatic). The session-side ref carries the session index + // (the stored index is read back from the serialized fiber), the + // reciprocal is written onto the far record, the pair save scheduled, + // the fiber list updated. On a synchronous failure BOTH refs are removed + // again. The summary publication can yield (the dock's metrics picker): + // lifetime and validity are checked after it and the pane re-resolved + // by name before its overlays are refreshed. + const QPointer alive(this); + LineAnnotationSession& session = *owned; + const auto findFarFiber = [this, farFiberId]() { + return std::find_if(_fibers.begin(), _fibers.end(), + [farFiberId](const StoredFiber& fiber) { + return fiber.id == farFiberId; + }); + }; + auto farIt = findFarFiber(); + if (farIt == _fibers.end()) { + return tr("The linked fiber no longer exists."); + } + if (controlPointIndex >= session.controlPoints.size() || farControlIndex < 0 || + static_cast(farControlIndex) >= farIt->controlPoints.size()) { + return tr("A control point index is out of range."); + } + if (controlPointHasBranchLink(session.branches, controlPointIndex)) { + return tr("This control point is already linked; unlink it first."); + } + if (controlPointHasBranchLink(farIt->branches, static_cast(farControlIndex))) { + return tr("The other control point is already linked; unlink it first."); + } + const cv::Vec3d farPoint = farIt->controlPoints[static_cast(farControlIndex)]; const cv::Vec3d localPoint = session.controlPoints[controlPointIndex].volumePoint; if (!finitePoint(localPoint) || !finitePoint(farPoint)) { - showError(tr("Could not determine finite endpoints for the link.")); - return; + return tr("Could not determine finite endpoints for the link."); } const cv::Vec3d fallbackDirection = normalizedOrZero(farPoint - localPoint); const cv::Vec3d localDirection = endpointTangentFromLinePoints( @@ -10097,16 +10368,15 @@ void LineAnnotationController::handleGeneratedControlPointLinkWithCandidate( farPoint, -fallbackDirection); if (!finiteDirection(localDirection) || !finiteDirection(farDirection)) { - showError(tr("Could not determine finite link directions between the control points.")); - return; + return tr("Could not determine finite link directions between the control points."); } // Session-indexed on this side; the stored index is read back from the // serialized fiber below. const FiberBranchRef localRef = vc3d::line_annotation::makeBranchRef( static_cast(controlPointIndex), localPoint, localDirection, - farIt->id, farIt->fileName, *farControlIndex, farPoint, farDirection, - /*pending=*/true, candidate.adjacent, /*autoLink=*/false); + farIt->id, farIt->fileName, farControlIndex, farPoint, farDirection, + /*pending=*/true, adjacent, autoLink); const auto duplicateLocal = std::find_if( session.branches.begin(), @@ -10117,13 +10387,24 @@ void LineAnnotationController::handleGeneratedControlPointLinkWithCandidate( branch.branchControlPointIndex == localRef.branchControlPointIndex; }); if (duplicateLocal != session.branches.end()) { - showError(tr("These control points are already linked.")); - return; + return tr("These control points are already linked."); } session.branches.push_back(localRef); const uint64_t localFiberId = session.fiberId; - const uint64_t farFiberId = farIt->id; + const auto removeLocalRef = [&session, &localRef]() { + session.branches.erase( + std::remove_if(session.branches.begin(), + session.branches.end(), + [&localRef](const FiberBranchRef& branch) { + return branch.branchFiberId == localRef.branchFiberId && + branch.controlPointIndex == localRef.controlPointIndex && + branch.branchControlPointIndex == + localRef.branchControlPointIndex; + }), + session.branches.end()); + }; + std::optional pushedReciprocal; try { StoredFiber localFiber = storedFiberFromSession(session); const auto storedLocalBranch = std::find_if( @@ -10158,6 +10439,7 @@ void LineAnnotationController::handleGeneratedControlPointLinkWithCandidate( }); if (duplicateFar == farIt->branches.end()) { farIt->branches.push_back(reciprocal); + pushedReciprocal = reciprocal; } const StoredFiber farFiber = *farIt; scheduleFiberPairSave(localFiber, farFiber); @@ -10176,17 +10458,22 @@ void LineAnnotationController::handleGeneratedControlPointLinkWithCandidate( *localIt = std::move(localFiber); } } catch (const std::exception& ex) { - session.branches.erase( - std::remove_if(session.branches.begin(), - session.branches.end(), - [&localRef](const FiberBranchRef& branch) { - return branch.branchFiberId == localRef.branchFiberId && - branch.controlPointIndex == localRef.controlPointIndex && - branch.branchControlPointIndex == - localRef.branchControlPointIndex; - }), - session.branches.end()); - if (pane->dialog) { + // Both refs go: the session's, and the reciprocal already written + // onto the far record (a one-sided ref would fail the next load's + // reciprocity check). + removeLocalRef(); + if (pushedReciprocal) { + if (auto far = findFarFiber(); far != _fibers.end()) { + const FiberBranchRef& r = *pushedReciprocal; + std::erase_if(far->branches, [&r](const FiberBranchRef& branch) { + return branch.adjacent == r.adjacent && + branch.branchFiberId == r.branchFiberId && + branch.controlPointIndex == r.controlPointIndex && + branch.branchControlPointIndex == r.branchControlPointIndex; + }); + } + } + if (auto* pane = paneForSurface(surfaceName); pane && pane->session == owned && pane->dialog) { pane->dialog->setGeneratedBranchOverlayData( controlMarkersForSession(session), generatedBranchLinePointsForSession(session), @@ -10194,17 +10481,19 @@ void LineAnnotationController::handleGeneratedControlPointLinkWithCandidate( true, generatedSpanAlignmentMetricsForSession(session)); } - showError(tr("Could not save linked fibers: %1") - .arg(QString::fromStdString(ex.what()))); - return; + return tr("Could not save linked fibers: %1").arg(QString::fromStdString(ex.what())); } - _linkCandidate.reset(); session.fiberMetricsMatchStoredFiber = true; invalidateFiberAlignmentMetrics(localFiberId, true); invalidateFiberAlignmentMetrics(farFiberId, true); emitFiberSummaries(); - if (pane->dialog) { + // The summaries can yield (the dock's metrics picker): the link is made + // and saved whatever happens now; only the refresh waits on validity. + if (!alive || (stillValid && !stillValid())) { + return std::nullopt; + } + if (auto* pane = paneForSurface(surfaceName); pane && pane->session == owned && pane->dialog) { pane->dialog->setGeneratedBranchOverlayData( controlMarkersForSession(session), generatedBranchLinePointsForSession(session), @@ -10213,25 +10502,8 @@ void LineAnnotationController::handleGeneratedControlPointLinkWithCandidate( generatedSpanAlignmentMetricsForSession(session)); } refreshBranchLineViews(localFiberId); + return std::nullopt; } - -// --------------------------------------------------------------------------- -// Structural edits: merge two fibers into one, split one into two. -// -// Phase A (the menu callback) validates, captures the participants by -// identity and position, runs the one modal it needs, and schedules phase B -// on a clean stack. Phase B drains the save queue, re-resolves everything, -// plans on stored snapshots (LineAnnotationStructuralEdits.hpp), redirects -// the peers' links, canonicalizes and validates the whole batch, checks the -// graph the next load will see, and commits with ONE runFiberSaveJob (new -// files + redirected peers as payloads, originals as retirements). Only -// after that commit is the prebuilt memory state installed, with swaps and -// flag writes that cannot throw. A failure anywhere before the commit leaves -// disk and memory as they were; the save job restores disk on its own -// failure. The re-fit that follows is ordinary and its outcome does not -// change the committed graph. -// --------------------------------------------------------------------------- - bool LineAnnotationController::structuralEditParticipantAllowed( const std::filesystem::path& sourceRoot, const std::string& fileName, @@ -15585,8 +15857,10 @@ vc::fiber_tracer::FiberDisplayField LineAnnotationController::displayFieldForSes displayPoints, orientedNormals, displayControlPositions, manualNormals, controlDirections); } -bool LineAnnotationController::materializeGeneratedViews(LineAnnotationSession& session) +bool LineAnnotationController::materializeGeneratedViews(LineAnnotationSession& session, + const std::function& stillValid) { + const QPointer alive(this); session.inspectionFrameOnly = std::any_of( session.optimizedLine.points.begin(), session.optimizedLine.points.end(), [](const auto& point) { return point.sampledNormal.reason == "display frame only"; }); @@ -15637,6 +15911,11 @@ bool LineAnnotationController::materializeGeneratedViews(LineAnnotationSession& saveFiberDisplayAnnotations(session); showError(tr("Normal offset reset to 0 for %1 control points because their stored normals could not be projected into the cross-section plane.") .arg(session.displayField.resetControls.size()), session.suppressErrorDialogs); + // The warning is modal: nothing of the controller or the session is + // touched, and nothing published, unless both are still valid. + if (!alive || (stillValid && !stillValid())) { + return false; + } } const bool hasManualNormals = std::any_of(session.controlPoints.begin(), session.controlPoints.end(), [](const auto& control) { return control.displayNormal.has_value(); }); diff --git a/volume-cartographer/apps/VC3D/LineAnnotationController.hpp b/volume-cartographer/apps/VC3D/LineAnnotationController.hpp index ab67c126ee..cbc01934b2 100644 --- a/volume-cartographer/apps/VC3D/LineAnnotationController.hpp +++ b/volume-cartographer/apps/VC3D/LineAnnotationController.hpp @@ -37,6 +37,7 @@ #include "LineAnnotationFiberSegments.hpp" #include "LineAnnotationFiberLinkValidation.hpp" #include "LineAnnotationStoredFiber.hpp" +#include "FiberMapAutoLink.hpp" #include "LineAnnotationStructuralEdits.hpp" #include "LineAnnotationGeneratedViews.hpp" #include "vc/atlas/FiberIntersections.hpp" @@ -381,6 +382,7 @@ class LineAnnotationController : public QObject const std::string& fileNameA, uint64_t fiberIdB, const std::string& fileNameB); + struct FiberMapAutoLinkEndpoint { uint64_t fiberId = 0; std::string fileName; @@ -941,6 +943,22 @@ private slots: void handleGeneratedControlPointLinkWithCandidate(const std::string& surfaceName, size_t controlPointIndex, cv::Vec3f volumePoint); + // The link of a session control point to a stored fiber's control point + // (session index on this side, stored index on the far side), shared by + // the candidate menu and the automatic link from a fiber-intersection + // marker: both refs written, the pair save scheduled, the fiber list + // updated, both refs removed again on a synchronous failure. Returns the + // error to show, nullopt on success. stillValid is checked after the + // summary publication (which can yield) before the pane is refreshed. + std::optional linkSessionControlToStoredControl( + const std::string& surfaceName, + const std::shared_ptr& owned, + size_t controlPointIndex, + uint64_t farFiberId, + int farControlIndex, + bool adjacent, + bool autoLink, + const std::function& stillValid); // Concatenates the link candidate's fiber onto the session's fiber // end-to-end (both control points must be endpoints) into one brand-new // fiber: tags unioned, third-party links remapped, the pair link between @@ -1081,6 +1099,24 @@ private slots: const std::vector& cumulativeArclengths, const std::vector& controlLinePositions, GeometricControlPointEdit edit); + // The two halves of handleGeneratedControlPoint, shared with the + // automatic link made from a fiber-intersection marker: the yields that + // precede a placement's geometry capture (autosave flush, dataset), with + // lifetime / validity / pane re-checked after each; and the placement + // tail over an edit whose collapse set the caller decided (the handler + // from its click, the automatic link explicitly empty). The tail + // returns the placed control's session index, nullopt when nothing was + // placed. + bool prepareSessionForControlPlacement(const std::string& surfaceName, + const std::shared_ptr& owned, + const std::function& stillValid); + std::optional placeControlInSession(const std::string& surfaceName, + const std::shared_ptr& owned, + const std::vector& currentLinePoints, + const std::vector& cumulativeArclengths, + const std::vector& controlLinePositions, + GeometricControlPointEdit edit, + const std::function& stillValid); bool applyOptimizationTaskResult(LineAnnotationSession& session, OptimizationTaskResult task, bool updateGeneratedViews, @@ -1166,7 +1202,10 @@ private slots: const LineAnnotationSession& session); [[nodiscard]] vc::fiber_tracer::FiberDisplayField displayFieldForSession( const LineAnnotationSession& session, const std::vector& orientedNormals) const; - bool materializeGeneratedViews(LineAnnotationSession& session); + // stillValid (optional): the normal-reset warning inside is modal; + // false right after it returns false without publishing anything. + bool materializeGeneratedViews(LineAnnotationSession& session, + const std::function& stillValid = {}); bool materializeGeneratedViews(LineAnnotationSession& session, const std::string& surfacePrefix); void handleShowAsMesh(const std::string& surfaceName); @@ -1343,6 +1382,65 @@ private slots: // every tracked link with an endpoint there follows. void noteTrackedEndpointMove(uint64_t fiberId, const std::string& fileName, const cv::Vec3d& from, const cv::Vec3d& to); + // The same preparation over any set of participants, with one pane that + // stays (the Line Annotation the automatic link is made FROM): its + // session is neither finalized nor retired nor counted as still open. + struct AutoLinkParticipant { + uint64_t fiberId = 0; + std::string fileName; + }; + FiberMapAutoLinkOutcome prepareAutoLinkParticipants( + const std::vector& participants, + const std::optional& keepSurfaceName); + // The automatic link's choice step over plain data (base voxels), fed by + // a stored fiber (choiceSourceFromStoredFiber) or a live session. + struct AutoLinkChoiceSource { + const std::vector* linePoints = nullptr; + std::vector controls; + std::vector linked; + std::vector kollesisTerminations; + std::vector> gapSpans; + }; + struct AutoLinkChoiceSide { + QString name; + AutoLinkChoiceSource source; + double anchorPosition = 0.0; + double windowVx = 0.0; + double reuseVx = 0.0; + std::vector cumulative; + std::vector orderedIndex; + vc3d::fiber_map::autolink::ControlChoice choice; + }; + [[nodiscard]] AutoLinkChoiceSource choiceSourceFromStoredFiber(const StoredFiber& fiber) const; + std::optional planAutoLinkChoices(std::array& sides, + double* passDistance) const; + // The placement of one new control on a prepared detached session (the + // map's Step 4), driven through the caller's guard callbacks. + struct AutoLinkPlacement { + std::shared_ptr session; + vc3d::fiber_map::autolink::ControlChoice choice; + QString name; + bool placed = false; + int storedIndex = -1; + StoredFiber* ownRecord = nullptr; + }; + struct AutoLinkPlacementContext { + std::function checkAndResolve; + std::function identityOnly; + std::function resolveOwn; + std::function()> otherSnapshot; + QString otherName; + uint64_t* expectedGeneration = nullptr; + QString changedMessage; + }; + std::optional placePreparedControl(AutoLinkPlacement& placement, + const AutoLinkPlacementContext& context); + // The withdrawal of an installed link whose pair save did not write both + // files: every copy, matched by the tracked endpoints (see + // TrackedLinkEndpoints), under the operation's starting identity only. + void withdrawTrackedLink(const TrackedLinkEndpoints& tracked, + uint64_t packageGeneration, + uint64_t loadSequence); static void scaleStoredFiber(StoredFiber& fiber, double scale); [[nodiscard]] static vc::lasagna::LineModel lineModelFromPoints( const std::vector& points, From 7cabd72f7b6243dbc83c558b60b6771a81d5f733 Mon Sep 17 00:00:00 2001 From: skyward7187 Date: Wed, 21 Oct 2026 16:10:07 -0400 Subject: [PATCH 7/7] VC3D line annotation: "Link and " from a fiber-intersection X The automatic link made from inside an open Line Annotation: Ctrl+right- click on another fiber's yellow X (current cut, side cut or strip) offers "Link A and B" directly under "Go to nearby annotation". The open fiber's control point is anchored at the pass of its DRAWN line nearest the X in that view (strip: the X's column; side cut: the nearest drawn non-tail segment of the windowed projection; current cut: the cut position), the other fiber's at the X; from those anchors the map's +-5 mm closest-pass search and 1 mm reuse rule decide the two controls (D10). The open fiber's new control is placed like a manual placement through the interactive tail with an empty collapse set (asynchronous span re-optimization, autosave, the same placement limit; the dialog stays open; D11); the other fiber's open dialog and dialog-less sessions are saved and closed first (D12); the link is a pending automatic link made right after the placement. Phase A in the slot verifies the identity captured when the menu opened (package, load sequence, volume, fiber generation), the anchor's line revision, the pair's eligibility and the placement limit, converts the X into base voxels only then, reserves the structural guard and defers the rest on a clean stack. Phases B-D retire the other fiber's sessions, run the choices over the live session and the stored record (one shared QtCore-free facts builder, autoLinkChoiceFacts, tested for parity), place the other fiber's control on a prepared detached session, place this fiber's through the shared tail, install the link through the shared session-link body, await its pair write (attributed by fiber generation, the link required still present in memory) and withdraw it on a failed write with the state re-saved. The guard distinguishes the stages (revision until placement, the placed control's identity until the install, the live-tracked link after); every yield - modals inside materialization and the metadata save included - is followed by its check. The disabled entry explains itself in a tooltip. Docs: "Linking from a fiber-intersection X". Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PrhaQ58LygRfA7y3Psp7uf --- .../apps/VC3D/LineAnnotationController.cpp | 916 ++++++++++++++++-- .../apps/VC3D/LineAnnotationController.hpp | 84 +- .../apps/VC3D/LineAnnotationDialog.cpp | 155 ++- .../apps/VC3D/LineAnnotationDialog.hpp | 27 +- .../apps/VC3D/LineAnnotationFiberLinks.hpp | 74 ++ .../VC3D/LineAnnotationGeneratedViews.cpp | 32 + .../VC3D/LineAnnotationGeneratedViews.hpp | 9 + .../VC3D/test/test_fiber_map_auto_link.cpp | 40 + .../test_line_annotation_generated_views.cpp | 46 + .../docs/line_annotation_fibers.md | 24 + 10 files changed, 1282 insertions(+), 125 deletions(-) diff --git a/volume-cartographer/apps/VC3D/LineAnnotationController.cpp b/volume-cartographer/apps/VC3D/LineAnnotationController.cpp index b144bbaa7e..61a2f4afb3 100644 --- a/volume-cartographer/apps/VC3D/LineAnnotationController.cpp +++ b/volume-cartographer/apps/VC3D/LineAnnotationController.cpp @@ -2806,6 +2806,14 @@ bool LineAnnotationController::launchSession(LineAnnotationController::SourceKin fiberId, fiberBasePointFromViewer(surfaceName, volumePoint)); }); + connect(dialog, + &LineAnnotationDialog::generatedNearbyFiberLinkRequested, + this, + [this](const std::string& name, uint64_t fiberId, cv::Vec3f intersectionPoint, + double anchorLinePosition, uint64_t anchorLineRevision) { + handleGeneratedLinkNearbyFiber(name, fiberId, intersectionPoint, + anchorLinePosition, anchorLineRevision); + }); connect(dialog, &LineAnnotationDialog::generatedControlPointUnlinkRequested, this, @@ -3142,15 +3150,19 @@ LineAnnotationController::prepareAutoLinkParticipants( return nullptr; }; std::vector participantIds; + std::vector records; for (const AutoLinkParticipant& participant : participants) { const StoredFiber* fiber = resolve(participant.fiberId, participant.fileName); if (!fiber) { return refuse(tr("One of the fibers is no longer loaded; press Update.")); } + records.push_back(fiber); + participantIds.push_back(fiber->id); + } + for (const StoredFiber* fiber : records) { if (fiber->readOnly) { return refuse(tr("%1 is read-only.").arg(fiberDisplayName(fiber->id))); } - participantIds.push_back(fiber->id); } for (const auto& pane : _panes) { if (pane.session && pane.session->taskState == LineAnnotationSession::TaskState::Running) { @@ -3442,8 +3454,9 @@ std::optional LineAnnotationController::placePreparedControl( // and the drain. Every yield is followed by the caller's checks through // `context`. placement.placed records a committed control (a refusal // after it must say the control was kept), placement.storedIndex the - // control's stored index on success, placement.ownRecord the stored - // record as re-resolved after the save. Returns the refusal message. + // control's stored index on success. The caller's checkAndResolve + // re-resolves the stored records; nothing captured before a yield is + // handed back. Returns the refusal message. namespace la = vc3d::line_annotation; namespace al = vc3d::fiber_map::autolink; const QPointer alive(this); @@ -3615,7 +3628,6 @@ std::optional LineAnnotationController::placePreparedControl( // which is a refusal of its own below, not a reason to forget that // this control was committed. if (StoredFiber* own = context.resolveOwn ? context.resolveOwn() : nullptr) { - placement.ownRecord = own; if (storedIndex >= 0 && static_cast(storedIndex) < own->controlPoints.size() && pointsApproximatelyEqual(own->controlPoints[static_cast(storedIndex)], @@ -3671,24 +3683,9 @@ std::optional LineAnnotationController::placePreparedControl( LineAnnotationController::AutoLinkChoiceSource LineAnnotationController::choiceSourceFromStoredFiber(const StoredFiber& fiber) const { - namespace la = vc3d::line_annotation; AutoLinkChoiceSource source; source.linePoints = &fiber.linePoints; - source.controls = la::storedControlPointPositions(fiber.controlPoints); - source.linked.resize(fiber.controlPoints.size(), false); - source.kollesisTerminations.resize(fiber.controlPoints.size(), false); - for (size_t c = 0; c < fiber.controlPoints.size(); ++c) { - source.linked[c] = controlPointHasBranchLink(fiber.branches, c); - source.kollesisTerminations[c] = - la::hasControlPointTag(fiber.controlPoints[c].tags, la::kKollesisTerminationTag); - } - // Gap spans in stored order: the span descriptor of the lower control - // carries the tag. - for (size_t c = 0; c + 1 < fiber.controlPoints.size(); ++c) { - if (la::spanIsGap(fiber.controlPoints[c].segmentToNext)) { - source.gapSpans.emplace_back(c, c + 1); - } - } + source.facts = vc3d::line_annotation::autoLinkChoiceFactsFromStoredFiber(fiber); return source; } @@ -3705,18 +3702,19 @@ std::optional LineAnnotationController::planAutoLinkChoices( namespace al = vc3d::fiber_map::autolink; for (AutoLinkChoiceSide& side : sides) { const std::vector& linePoints = *side.source.linePoints; + const std::vector& controls = side.source.facts.controls; if (linePoints.size() < 2) { return tr("%1 has no line to link on.").arg(side.name); } - const auto ordered = la::orderedControlPointLineIndices(side.source.controls, linePoints); - if (!ordered || ordered->size() != side.source.controls.size()) { + const auto ordered = la::orderedControlPointLineIndices(controls, linePoints); + if (!ordered || ordered->size() != controls.size()) { return tr("The control points of %1 do not resolve in line order.").arg(side.name); } // The shared placement machinery (splice, span optimizer) resolves // controls by nearest vertex; it is only correct where that agrees // with the loader's ordered scan. - for (size_t c = 0; c < side.source.controls.size(); ++c) { - if (vc3d::fiber_slice::nearestLinePointIndex(linePoints, side.source.controls[c]) != + for (size_t c = 0; c < controls.size(); ++c) { + if (vc3d::fiber_slice::nearestLinePointIndex(linePoints, controls[c]) != (*ordered)[c]) { return tr("%1 passes through one of its control points twice.").arg(side.name); } @@ -3739,9 +3737,9 @@ std::optional LineAnnotationController::planAutoLinkChoices( input.linePoints = side.source.linePoints; input.cumulative = &side.cumulative; input.controlLineIndex = side.orderedIndex; - input.linked = side.source.linked; - input.kollesisTerminations = side.source.kollesisTerminations; - input.gapSpans = side.source.gapSpans; + input.linked = side.source.facts.linked; + input.kollesisTerminations = side.source.facts.kollesisTerminations; + input.gapSpans = side.source.facts.gapSpans; if (focusBounds) { input.inFocusBounds = [bounds = *focusBounds](const cv::Vec3d& p) { return contains_point(bounds, p); @@ -3859,6 +3857,53 @@ void LineAnnotationController::withdrawTrackedLink(const TrackedLinkEndpoints& t refreshBranchLineViews(tracked.fiberIdB); } +QString LineAnnotationController::correctivePairResaveTail( + uint64_t fiberIdA, + uint64_t fiberIdB, + const std::function& resolveA, + const std::function& resolveB, + const std::function& identityOnly) +{ + // A save of either fiber that ran meanwhile (a successor job started + // before the drain resumed) may have written the withdrawn link + // one-sided: the withdrawn state is written back and drained to + // completion, and the correction is reported as done only when ITS job + // wrote both files (its own fiberSaved emissions), whatever an earlier + // failure counted. + const QPointer alive(this); + if (!identityOnly()) { + return {}; + } + StoredFiber* a = resolveA(); + StoredFiber* b = resolveB(); + if (!a || !b) { + return {}; + } + QString tail; + std::set correctedIds; + const QMetaObject::Connection correctionWatch = connect( + this, &LineAnnotationController::fiberSaved, this, + [&correctedIds](uint64_t fiberId, uint64_t) { correctedIds.insert(fiberId); }); + const auto unwatchCorrection = qScopeGuard([this, &alive, &correctionWatch]() { + if (alive) { + disconnect(correctionWatch); + } + }); + try { + scheduleFiberPairSave(*a, *b); + waitForFiberSaves(identityOnly); + if (alive) { + const bool corrected = + correctedIds.count(fiberIdA) != 0 && correctedIds.count(fiberIdB) != 0; + tail = corrected ? tr(" The fibers were re-saved without it.") + : tr(" The fibers could not be re-saved without it."); + } + } catch (const std::exception& ex) { + tail = tr(" The fibers could not be re-saved (%1).").arg(QString::fromStdString(ex.what())); + } + return tail; +} + LineAnnotationController::FiberMapAutoLinkOutcome LineAnnotationController::autoLinkFibers(const FiberMapAutoLinkEndpoint& a, const FiberMapAutoLinkEndpoint& b, @@ -4157,9 +4202,8 @@ LineAnnotationController::autoLinkFibers(const FiberMapAutoLinkEndpoint& a, if (!alive) { return refuse(changedMessage); } - if (placement.ownRecord) { - side.fiber = placement.ownRecord; - } + // side.fiber was re-resolved by the helper's last checkAndResolve + // (after the drain); nothing captured before the drain is kept. if (error) { return refuseKeeping(*error); } @@ -4249,37 +4293,14 @@ LineAnnotationController::autoLinkFibers(const FiberMapAutoLinkEndpoint& a, const bool pairWritten = savedIds.count(fiberIdA) != 0 && savedIds.count(fiberIdB) != 0; if (!pairWritten) { removeInstalledLink(); - // A save of either fiber that ran meanwhile (a successor job started - // before this drain resumed) may have written the link one-sided: - // the withdrawn state is written back and drained to completion, - // and the correction is reported as done only when ITS job wrote - // both files (its own fiberSaved emissions), whatever the earlier - // failure counted. - QString tail; if (!alive) { return refuse(changedMessage); } - if (identityOnly() && reresolve()) { - std::set correctedIds; - const QMetaObject::Connection correctionWatch = connect( - this, &LineAnnotationController::fiberSaved, this, - [&correctedIds](uint64_t fiberId, uint64_t) { correctedIds.insert(fiberId); }); - const auto unwatchCorrection = - qScopeGuard([this, &correctionWatch]() { disconnect(correctionWatch); }); - try { - scheduleFiberPairSave(*sides[0].fiber, *sides[1].fiber); - waitForFiberSaves(identityOnly); - if (alive) { - const bool corrected = - correctedIds.count(fiberIdA) != 0 && correctedIds.count(fiberIdB) != 0; - tail = corrected ? tr(" The fibers were re-saved without it.") - : tr(" The fibers could not be re-saved without it."); - } - } catch (const std::exception& ex) { - tail = tr(" The fibers could not be re-saved (%1).") - .arg(QString::fromStdString(ex.what())); - } - } + const QString tail = correctivePairResaveTail( + fiberIdA, fiberIdB, + [&]() { return resolve(sides[0].endpoint->fiberId, sides[0].endpoint->fileName); }, + [&]() { return resolve(sides[1].endpoint->fiberId, sides[1].endpoint->fileName); }, + identityOnly); if (!alive) { return refuse(changedMessage); } @@ -4310,6 +4331,713 @@ LineAnnotationController::autoLinkFibers(const FiberMapAutoLinkEndpoint& a, return outcome; } +LineAnnotationController::AutoLinkChoiceSource +LineAnnotationController::choiceSourceFromSession(const LineAnnotationSession& session, + const std::vector* linePoints) const +{ + namespace la = vc3d::line_annotation; + // The generic shape the shared builder takes: positions (base voxels), + // the session's refs, each control's tags and each span's gap flag. + std::vector controls; + std::vector> tags; + std::vector gaps; + controls.reserve(session.controlPoints.size()); + for (const auto& control : session.controlPoints) { + controls.push_back(control.volumePoint); + tags.push_back(control.tags); + gaps.push_back(la::spanIsGap(control.segmentToNext)); + } + AutoLinkChoiceSource source; + source.linePoints = linePoints; + source.facts = la::autoLinkChoiceFacts(controls, session.branches, tags, gaps); + return source; +} + +void LineAnnotationController::handleGeneratedLinkNearbyFiber(const std::string& surfaceName, + uint64_t fiberIdB, + cv::Vec3f viewerPoint, + double anchorLinePosition, + uint64_t anchorLineRevision) +{ + // Phase A, synchronous in the slot: nothing is mutated here. + const ContextMenuIdentity menuIdentity = _contextMenuIdentity; + _contextMenuIdentity = ContextMenuIdentity{}; + if (!menuIdentity.valid || menuIdentity.surfaceName != surfaceName) { + showError(tr("The menu's context is gone; choose Link again.")); + return; + } + if (menuIdentity.packageGeneration != _packageGeneration || + menuIdentity.loadSequence != _fiberLoadSequence || + menuIdentity.fiberGeneration != fiberDataGeneration() || + !_state || _state->currentVolume() != menuIdentity.volume) { + showError(tr("The fibers or the view changed meanwhile; choose Link again.")); + return; + } + auto* pane = paneForSurface(surfaceName); + if (!pane || !pane->session) { + return; + } + const std::shared_ptr owned = pane->session; + LineAnnotationSession& session = *owned; + if (session.fiberDeleted) { + showError(tr("This fiber was deleted; it can no longer be edited."), + session.suppressErrorDialogs); + return; + } + for (const auto& other : _panes) { + if (other.session && other.session->taskState == LineAnnotationSession::TaskState::Running) { + showError(tr("Line optimization is already running.")); + return; + } + } + if (_structuralEditInProgress) { + showError(tr("A merge, split or link is in progress; try again once it has finished.")); + return; + } + if (_deletingFibers) { + showError(tr("A fiber delete is in progress; try again once it has finished.")); + return; + } + if (session.lineRevision != anchorLineRevision) { + showError(tr("The annotation changed; choose Link again.")); + return; + } + ensureSessionFiberIdentity(session); + if (session.fiberId == 0 || session.fiberId == fiberIdB) { + showError(tr("Select a marker of a different fiber.")); + return; + } + const auto findById = [this](uint64_t id) -> const StoredFiber* { + const auto it = std::find_if(_fibers.begin(), _fibers.end(), + [id](const StoredFiber& fiber) { return fiber.id == id; }); + return it == _fibers.end() ? nullptr : &*it; + }; + const StoredFiber* a = findById(session.fiberId); + const StoredFiber* b = findById(fiberIdB); + if (!a) { + showError(tr("This annotation has not been saved yet; save it before linking.")); + return; + } + if (!b) { + showError(tr("Could not find fiber %1 for the link.").arg(static_cast(fiberIdB))); + return; + } + if (a->readOnly || b->readOnly) { + showError(tr("%1 is read-only.").arg(fiberDisplayName(a->readOnly ? a->id : b->id))); + return; + } + // Pair eligibility BEFORE anything is placed (sources that could not + // reference each other, as the map checks). + if (const auto error = linkPairEligibilityError(*a, *b)) { + showError(*error); + return; + } + // The identity verified, the X's point is converted into the session's + // canonical base voxels (the scale is part of that identity). + const cv::Vec3f pointB = fiberBasePointFromViewer(surfaceName, viewerPoint); + if (!finitePoint(pointB)) { + showError(tr("The marker has no finite position.")); + return; + } + DialogAutoLinkCapture capture; + capture.packageGeneration = _packageGeneration; + capture.loadSequence = _fiberLoadSequence; + capture.volume = _state->currentVolume(); + capture.surfaceName = surfaceName; + capture.session = owned; + capture.fiberIdA = a->id; + capture.fileA = a->fileName; + capture.lineRevision = session.lineRevision; + capture.anchorLinePosition = anchorLinePosition; + capture.extrapolationLimitVx = + pane->dialog ? pane->dialog->maxControlPointExtrapolationDistanceVx() : 0; + capture.fiberIdB = b->id; + capture.fileB = b->fileName; + capture.pointB = toVec3d(pointB); + // The structural guard is reserved here and handed to the callback (as + // the merge does), so nothing structural can slip in between the enqueue + // and the execution; the callback's scope guard releases it. + _structuralEditInProgress = true; + const QPointer self(this); + QMetaObject::invokeMethod( + this, + [self, capture]() { + if (!self) { + return; + } + const auto release = qScopeGuard([self]() { + if (self) { + self->_structuralEditInProgress = false; + } + }); + self->runDialogAutoLink(capture); + }, + Qt::QueuedConnection); +} + +void LineAnnotationController::runDialogAutoLink(const DialogAutoLinkCapture& c) +{ + namespace al = vc3d::fiber_map::autolink; + namespace la = vc3d::line_annotation; + const QPointer alive(this); + const auto refuse = [&](const QString& message) { + Logger()->warn("Line annotation auto-link refused: {}", message.toStdString()); + if (alive) { + showError(message); + } + }; + const QString changedMessage = tr("The fibers changed meanwhile; nothing was linked."); + const auto identityOnly = [&]() -> bool { + return alive && _packageGeneration == c.packageGeneration && + _fiberLoadSequence == c.loadSequence && _state && _state->currentVolume() == c.volume; + }; + const auto paneIntact = [&]() { + auto* pane = paneForSurface(c.surfaceName); + return pane && pane->session == c.session; + }; + // The identities captured at the menu, verified before any retirement. + if (!identityOnly()) { + return refuse(changedMessage); + } + if (_deletingFibers) { + return refuse(tr("A fiber delete is in progress; try again once it has finished.")); + } + if (!paneIntact()) { + return refuse(tr("The annotation was closed; nothing was linked.")); + } + LineAnnotationSession& sessionA = *c.session; + if (sessionA.lineRevision != c.lineRevision) { + return refuse(tr("The annotation changed meanwhile; choose Link again.")); + } + const auto resolveA = [&]() -> StoredFiber* { + for (StoredFiber& fiber : _fibers) { + if (fiber.id == c.fiberIdA && fiber.fileName == c.fileA) { + return &fiber; + } + } + return nullptr; + }; + const auto resolveB = [&]() -> StoredFiber* { + for (StoredFiber& fiber : _fibers) { + if (fiber.id == c.fiberIdB && fiber.fileName == c.fileB) { + return &fiber; + } + } + return nullptr; + }; + const QString nameA = fiberDisplayNameFromFileName(c.fileA); + const QString nameB = fiberDisplayNameFromFileName(c.fileB); + + // Phase B (D12): the OTHER fiber's dialogs and dialog-less sessions are + // finalized, saved and closed; this pane stays. Then the structural + // preflight. + { + const FiberMapAutoLinkOutcome prepared = + prepareAutoLinkParticipants({{c.fiberIdB, c.fileB}}, c.surfaceName); + if (!alive) { + return; + } + if (!prepared.ok) { + return refuse(prepared.message); + } + } + + // The guard, checked after every yield from here on. The expected + // fiber generation is captured right after the retirement and the + // preflight and re-captured only after this operation's own mutations. + uint64_t expectedGeneration = fiberDataGeneration(); + const uint64_t saveFailures = _fiberSaveFailureCount; + const uint64_t focusRevision = _state ? _state->focusBoundsRevision() : 0; + // The A-side predicate by stage: the captured revision until A's + // geometric mutation; while the placement tail runs (Placing) either + // that or the control already present and unlinked (the tail bumps the + // revision before its manual-mode materialization can yield); the + // placed control present and unlinked until the install; from the + // install, the tracked link present on A's session and on B's record. + enum class Stage { BeforePlacement, Placing, PlacedUnlinked, Installed }; + Stage stage = Stage::BeforePlacement; + std::optional placedPointA; + // The REGISTERED tracker (syncBranchEndpointPositions appends to it when + // an endpoint moves), never a copy: the install sets it. + const TrackedLinkEndpoints* trackedLink = nullptr; + const auto placedControlUnlinked = [&]() { + if (!placedPointA) { + return false; + } + for (size_t i = 0; i < sessionA.controlPoints.size(); ++i) { + if (pointsApproximatelyEqual(sessionA.controlPoints[i].volumePoint, *placedPointA)) { + return !controlPointHasBranchLink(sessionA.branches, i); + } + } + return false; + }; + const auto installedLinkPresent = [&]() { + if (!trackedLink) { + return false; + } + const TrackedLinkEndpoints& t = *trackedLink; + const auto refOnA = [&](const FiberBranchRef& b) { + return !b.adjacent && + la::sameFiberIdentity(b.branchFiberId, b.branchFileName, t.fiberIdB, t.fileB) && + t.heldA(b.controlPointPosition) && t.heldB(b.branchControlPointPosition); + }; + // A's side: the live session while its pane holds it, else (the + // dialog closed during a drain, its session saved) A's stored record. + bool onA = false; + if (paneIntact()) { + onA = std::any_of(sessionA.branches.begin(), sessionA.branches.end(), refOnA); + } else if (const StoredFiber* a = resolveA()) { + onA = std::any_of(a->branches.begin(), a->branches.end(), refOnA); + } + if (!onA) { + return false; + } + for (const StoredFiber& fiber : _fibers) { + if (fiber.id != t.fiberIdB || fiber.fileName != t.fileB) { + continue; + } + return std::any_of(fiber.branches.begin(), fiber.branches.end(), + [&](const FiberBranchRef& b) { + return !b.adjacent && + la::sameFiberIdentity(b.branchFiberId, b.branchFileName, t.fiberIdA, t.fileA) && + t.heldB(b.controlPointPosition) && t.heldA(b.branchControlPointPosition); + }); + } + return false; + }; + const auto guardCheck = [&](bool includeGeneration) -> bool { + if (!identityOnly()) { + return false; + } + if (includeGeneration && fiberDataGeneration() != expectedGeneration) { + return false; + } + if (_fiberSaveFailureCount != saveFailures) { + return false; + } + if ((_state ? _state->focusBoundsRevision() : 0) != focusRevision) { + return false; + } + if (!paneIntact()) { + return false; + } + auto* pane = paneForSurface(c.surfaceName); + if (pane && pane->dialog && + pane->dialog->maxControlPointExtrapolationDistanceVx() != c.extrapolationLimitVx) { + return false; + } + switch (stage) { + case Stage::BeforePlacement: + if (sessionA.lineRevision != c.lineRevision || + sessionA.taskState == LineAnnotationSession::TaskState::Running) { + return false; + } + break; + case Stage::Placing: + if (sessionA.lineRevision != c.lineRevision && !placedControlUnlinked()) { + return false; + } + break; + case Stage::PlacedUnlinked: + // The placed control, by identity: present and still unlinked + // (a solve landing bumps the revision but moves no control). + if (!placedControlUnlinked()) { + return false; + } + break; + case Stage::Installed: + if (!installedLinkPresent()) { + return false; + } + break; + } + for (const auto& other : _panes) { + if (!other.session || other.session == c.session) { + continue; + } + if (other.session->fiberId == c.fiberIdB || + other.session->taskState == LineAnnotationSession::TaskState::Running) { + return false; + } + } + return true; + }; + if (!guardCheck(true) || !resolveA() || !resolveB()) { + return refuse(changedMessage); + } + const auto volume = c.volume; + if (!volume) { + return refuse(tr("No active volume is loaded.")); + } + + // Choices (section 4 of the plan): A over its live session, B over its + // stored record, both in base voxels. + FiberMapAutoLinkParams params; + double scaleB = 1.0; + try { + scaleB = la::resolveFiberBaseToVolumeScale( + resolveStoredFiberCoordinateBaseShape(*resolveB()), volume->shape()); + } catch (const std::exception& ex) { + return refuse(tr("Could not map fiber %1 into the active volume: %2") + .arg(nameB, QString::fromStdString(ex.what()))); + } + const double voxelA = volume->voxelSize() * sessionA.fiberBaseToVolumeScale; + const double voxelB = volume->voxelSize() * scaleB; + if (!(voxelA > 0.0) || !std::isfinite(voxelA) || !(voxelB > 0.0) || !std::isfinite(voxelB)) { + return refuse(tr("The active volume has no usable voxel size.")); + } + std::vector lineA; + lineA.reserve(sessionA.optimizedLine.points.size()); + for (const auto& point : sessionA.optimizedLine.points) { + lineA.push_back(point.position); + } + const auto anchorB = al::nearestLinePosition(resolveB()->linePoints, c.pointB); + if (!anchorB) { + return refuse(tr("%1 has no line to link on.").arg(nameB)); + } + std::array choiceSides; + choiceSides[0].name = nameA; + choiceSides[0].source = choiceSourceFromSession(sessionA, &lineA); + choiceSides[0].anchorPosition = + std::clamp(c.anchorLinePosition, 0.0, static_cast(std::max(lineA.size(), 1) - 1)); + choiceSides[0].windowVx = params.searchWindowUm / voxelA; + choiceSides[0].reuseVx = params.reuseUm / voxelA; + choiceSides[1].name = nameB; + choiceSides[1].source = choiceSourceFromStoredFiber(*resolveB()); + choiceSides[1].anchorPosition = *anchorB; + choiceSides[1].windowVx = params.searchWindowUm / voxelB; + choiceSides[1].reuseVx = params.reuseUm / voxelB; + double passDistance = 0.0; + if (const auto error = planAutoLinkChoices(choiceSides, &passDistance)) { + return refuse(*error); + } + AutoLinkChoiceSide& sideA = choiceSides[0]; + AutoLinkChoiceSide& sideB = choiceSides[1]; + // Two reused controls that are already linked to each other. + if (sideA.choice.kind == al::ControlChoice::Kind::Reuse && + sideB.choice.kind == al::ControlChoice::Kind::Reuse) { + for (const FiberBranchRef& branch : sessionA.branches) { + if (branch.branchFiberId == c.fiberIdB && + branch.controlPointIndex == sideA.choice.controlIndex && + branch.branchControlPointIndex == sideB.choice.controlIndex) { + return refuse(tr("%1 / CP %2 and %3 / CP %4 are already linked.") + .arg(nameA).arg(sideA.choice.controlIndex) + .arg(nameB).arg(sideB.choice.controlIndex)); + } + } + } + // A's placement limit (the manual handler's rule): checked on the chosen + // position now and again right before A's mutation. + const auto withinLimitA = [&]() { + if (sideA.choice.kind != al::ControlChoice::Kind::Place || c.extrapolationLimitVx <= 0) { + return true; + } + std::vector controlLinePositions; + for (const auto& control : sessionA.controlPoints) { + controlLinePositions.push_back(control.linePosition); + } + return vc3d::fiber_slice::linePositionWithinControlExtrapolationDistance( + sideA.cumulative, sideA.choice.linePosition, controlLinePositions, + static_cast(c.extrapolationLimitVx)); + }; + if (!withinLimitA()) { + return refuse(tr("The pass lies beyond the placement limit of this annotation.")); + } + + // Refusals after a control was committed say so. + AutoLinkPlacement placementB; + placementB.name = nameB; + bool placedA = false; + const auto refuseKeeping = [&](const QString& message) { + QString kept; + if (placementB.placed) { + kept += tr(" The new control point placed on %1 was kept (unlinked).").arg(nameB); + } + if (placedA) { + kept += tr(" The new control point placed on %1 was kept (unlinked).").arg(nameA); + } + refuse(message + kept); + }; + const auto checkAndResolve = [&](bool includeGeneration) { + return guardCheck(includeGeneration) && resolveA() && resolveB(); + }; + + // B: preparation (the map's Steps 2-3) then placement (Step 4), before + // anything touches A. + int linkedIndexB = -1; + if (sideB.choice.kind == al::ControlChoice::Kind::Reuse) { + linkedIndexB = sideB.choice.controlIndex; + } else { + placementB.session = makeIntersectionLineSession( + *resolveB(), sideB.choice.linePosition, cv::Vec3d{0.0, 0.0, 1.0}, + "fiber_auto_link_" + c.fileB, nullptr); + placementB.session->fiberBaseToVolumeScale = scaleB; + placementB.session->suppressErrorDialogs = false; + placementB.choice = sideB.choice; + if (!ensureSessionSolveBackends(*placementB.session, [&]() { return checkAndResolve(true); })) { + return refuse(tr("Could not prepare the solve for %1.").arg(nameB)); + } + if (!checkAndResolve(true)) { + return refuse(changedMessage); + } + const std::vector linePointsCopy = resolveB()->linePoints; + try { + if (!prepareSessionLineModel(*placementB.session, linePointsCopy, + [&]() { return checkAndResolve(true); })) { + return refuse(tr("Could not prepare the line model of %1.").arg(nameB)); + } + } catch (const std::exception& ex) { + return refuse(tr("Could not prepare the line model of %1: %2") + .arg(nameB, QString::fromStdString(ex.what()))); + } + if (!checkAndResolve(true)) { + return refuse(changedMessage); + } + AutoLinkPlacementContext context; + context.checkAndResolve = checkAndResolve; + context.identityOnly = identityOnly; + context.resolveOwn = resolveB; + context.otherSnapshot = [&]() -> std::optional { + StoredFiber* a = resolveA(); + if (!a) { + return std::nullopt; + } + nlohmann::json snapshot = fiberToJson(*a); + snapshot.erase("generation"); + return snapshot; + }; + context.otherName = nameA; + context.expectedGeneration = &expectedGeneration; + context.changedMessage = changedMessage; + const auto error = placePreparedControl(placementB, context); + if (!alive) { + return; + } + if (error) { + return refuseKeeping(*error); + } + linkedIndexB = placementB.storedIndex; + } + + // A (D11): the interactive placement tail with an explicitly empty + // collapse set, after the preparation yields and the limit re-check. + size_t linkedIndexA = 0; + if (sideA.choice.kind == al::ControlChoice::Kind::Reuse) { + linkedIndexA = static_cast(sideA.choice.controlIndex); + } else { + if (!prepareSessionForControlPlacement(c.surfaceName, c.session, + [&]() { return checkAndResolve(true); })) { + return refuseKeeping(tr("Could not prepare %1 for the placement.").arg(nameA)); + } + if (!checkAndResolve(true)) { + return refuseKeeping(changedMessage); + } + if (!withinLimitA()) { + return refuseKeeping(tr("The pass lies beyond the placement limit of this annotation.")); + } + std::vector currentLinePoints; + currentLinePoints.reserve(sessionA.optimizedLine.points.size()); + for (const auto& point : sessionA.optimizedLine.points) { + currentLinePoints.push_back(point.position); + } + const auto cumulative = vc3d::fiber_slice::cumulativePolylineArclengths(currentLinePoints); + std::vector controlLinePositions; + for (const auto& control : sessionA.controlPoints) { + controlLinePositions.push_back(control.linePosition); + } + GeometricControlPointEdit edit; + edit.linePosition = sideA.choice.linePosition; + edit.point = sideA.choice.point; + placedPointA = sideA.choice.point; + stage = Stage::Placing; + const auto index = placeControlInSession( + c.surfaceName, c.session, currentLinePoints, cumulative, controlLinePositions, + std::move(edit), [&]() { return checkAndResolve(true); }, + [&]() { + // The tail's own mutation and metadata saves are done (their + // generation bumps are this operation's): the control is + // placed, the expectation advances over them. + stage = Stage::PlacedUnlinked; + expectedGeneration = fiberDataGeneration(); + }); + if (!alive) { + return; + } + // Whatever the tail returned, a control at the chosen point is a + // committed placement (a refusal from here on must say it was kept). + if (paneIntact()) { + for (size_t i = 0; i < sessionA.controlPoints.size(); ++i) { + if (pointsApproximatelyEqual(sessionA.controlPoints[i].volumePoint, + sideA.choice.point)) { + placedA = true; + linkedIndexA = i; + break; + } + } + } + if (!placedA || !index || *index != linkedIndexA) { + return refuseKeeping(tr("Could not place a control point on %1.").arg(nameA)); + } + stage = Stage::PlacedUnlinked; + expectedGeneration = fiberDataGeneration(); + if (!checkAndResolve(true)) { + return refuseKeeping(changedMessage); + } + } + + // The link (Step 5): tracked from before its install, its pair write + // awaited, withdrawn on a failed write. + StoredFiber* b = resolveB(); + if (!b || linkedIndexB < 0 || static_cast(linkedIndexB) >= b->controlPoints.size() || + linkedIndexA >= sessionA.controlPoints.size()) { + return refuseKeeping(changedMessage); + } + const cv::Vec3d pointA = sessionA.controlPoints[linkedIndexA].volumePoint; + const cv::Vec3d pointB = b->controlPoints[static_cast(linkedIndexB)]; + // Every save A's placement queued (B's branch-metadata save, A's display + // metadata) lands BEFORE the pair watch starts, so a fiberSaved for + // either id during the link's drain can only come from the link's own + // pair job (B has no session: nothing else writes B). + waitForFiberSaves(identityOnly); + if (!alive) { + return; + } + if (!checkAndResolve(false)) { + return refuseKeeping(changedMessage); + } + b = resolveB(); + if (!b || static_cast(linkedIndexB) >= b->controlPoints.size() || + !pointsApproximatelyEqual(b->controlPoints[static_cast(linkedIndexB)], pointB)) { + return refuseKeeping(changedMessage); + } + // A stays editable through the drain: its control is resolved again by + // position (an earlier control inserted or deleted meanwhile shifts the + // session index), and must still be unlinked. + { + std::optional again; + for (size_t i = 0; i < sessionA.controlPoints.size(); ++i) { + if (pointsApproximatelyEqual(sessionA.controlPoints[i].volumePoint, pointA)) { + again = i; + break; + } + } + if (!again || controlPointHasBranchLink(sessionA.branches, *again)) { + return refuseKeeping(changedMessage); + } + linkedIndexA = *again; + } + const uint64_t failuresBeforeLink = _fiberSaveFailureCount; + // The pair write, attributed by fiber generation: fiberSaved reports the + // generation a job wrote; the link's own job is scheduled with the two + // records' generations (scheduledGenerations), and a later job that + // rewrites a record from memory carries a higher one - and memory holds + // the link (verified below) - so "both records written at or past the + // scheduled generations, and the link still installed" is the state of + // the files, whichever jobs wrote them. + std::map savedGenerations; + const QMetaObject::Connection savedWatch = connect( + this, &LineAnnotationController::fiberSaved, this, + [&savedGenerations](uint64_t fiberId, uint64_t generation) { + auto& best = savedGenerations[fiberId]; + best = std::max(best, generation); + }); + const auto unwatch = qScopeGuard([this, &savedWatch]() { disconnect(savedWatch); }); + TrackedLinkEndpoints tracked; + tracked.fiberIdA = c.fiberIdA; + tracked.fileA = c.fileA; + tracked.fiberIdB = c.fiberIdB; + tracked.fileB = c.fileB; + tracked.pointsA = {pointA}; + tracked.pointsB = {pointB}; + _trackedLinks.push_back(&tracked); + const auto untrack = qScopeGuard([this, &alive, &tracked]() { + if (alive) { + std::erase(_trackedLinks, &tracked); + } + }); + std::pair scheduledGenerations{0, 0}; + const auto error = linkSessionControlToStoredControl( + c.surfaceName, c.session, linkedIndexA, c.fiberIdB, linkedIndexB, + /*adjacent=*/false, /*autoLink=*/true, + // The helper's own post-publication check: the full guard in the + // Installed stage, generation included. + [&]() { return checkAndResolve(true); }, + [&]() { + // Installed (both refs written, the pair save scheduled) before + // the helper's publication can yield. The publication + // (emitFiberSummaries) bumps the fiber generation exactly once + // before it delivers its signals, so the expectation is advanced + // over that one owned bump here: an edit landing inside the + // publication's yield is then a mismatch, not absorbed. + trackedLink = &tracked; + stage = Stage::Installed; + expectedGeneration = fiberDataGeneration() + 1; + }, + &scheduledGenerations); + if (!alive) { + return; + } + if (error) { + return refuseKeeping(*error); + } + waitForFiberSaves(identityOnly); + if (!alive) { + return; + } + const auto writtenAtOrPast = [&](uint64_t fiberId, uint64_t generation) { + const auto it = savedGenerations.find(fiberId); + return it != savedGenerations.end() && it->second >= generation; + }; + const bool pairWritten = writtenAtOrPast(c.fiberIdA, scheduledGenerations.first) && + writtenAtOrPast(c.fiberIdB, scheduledGenerations.second); + if (pairWritten && identityOnly() && !installedLinkPresent()) { + // Written, but removed from memory meanwhile (an unlink during the + // drain, from A's dialog or after it closed): the files must follow + // memory, and the unlink's own save may have failed, so the state + // without the link is written back and drained, a stray one-sided + // copy scrubbed first. + withdrawTrackedLink(tracked, c.packageGeneration, c.loadSequence); + if (!alive) { + return; + } + const QString tail = + correctivePairResaveTail(c.fiberIdA, c.fiberIdB, resolveA, resolveB, identityOnly); + if (!alive) { + return; + } + return refuseKeeping(tr("The link was removed meanwhile; nothing is linked.") + tail); + } + if (!pairWritten) { + withdrawTrackedLink(tracked, c.packageGeneration, c.loadSequence); + if (!alive) { + return; + } + const QString tail = + correctivePairResaveTail(c.fiberIdA, c.fiberIdB, resolveA, resolveB, identityOnly); + if (!alive) { + return; + } + return refuseKeeping(tr("The link could not be written to disk and was withdrawn; the " + "loaded fibers may differ from the files - reload the project " + "before further edits.") + tail); + } + // Committed (both files written): the outcome is the link, whatever the + // checks say now; what changed meanwhile is reported, not undone. + if (_fiberSaveFailureCount != failuresBeforeLink) { + showError(tr("Linked %1 and %2, but another fiber's save failed meanwhile; the loaded " + "fibers may differ from the files - reload the project before further edits.") + .arg(nameA, nameB)); + } else if (!checkAndResolve(true)) { + Logger()->warn("Line annotation auto-link: linked {} and {}, but the fibers or the view " + "changed meanwhile (the link is written; the annotation or the map may " + "need a refresh)", c.fileA, c.fileB); + } + Logger()->info("Line annotation auto-link: linked {} / CP {} and {} / CP {} (3D gap {:.1f} vx; {} on {}, {} on {})", + c.fileA, linkedIndexA, c.fileB, linkedIndexB, passDistance, + placedA ? "new control point" : "existing control point", c.fileA, + placementB.placed ? "new control point" : "existing control point", c.fileB); +} + void LineAnnotationController::openFiberAtControlPoint(uint64_t fiberId, int controlPointIndex) { openFiberWithControlPoint(fiberId, controlPointIndex, std::nullopt); @@ -7703,6 +8431,15 @@ bool LineAnnotationController::showGeneratedControlPointContextMenu(CChunkedVolu vc3d::line_annotation::GeneratedControlPointContextResult result = vc3d::line_annotation::GeneratedControlPointContextResult::None; if (pane->dialog) { + // What the deferred automatic link must still see (the menu runs a + // nested loop; a volume switch inside it would otherwise go unseen). + _contextMenuIdentity = ContextMenuIdentity{}; + _contextMenuIdentity.valid = true; + _contextMenuIdentity.surfaceName = viewer->surfName(); + _contextMenuIdentity.packageGeneration = _packageGeneration; + _contextMenuIdentity.loadSequence = _fiberLoadSequence; + _contextMenuIdentity.fiberGeneration = fiberDataGeneration(); + _contextMenuIdentity.volume = _state ? _state->currentVolume() : nullptr; result = pane->dialog->showGeneratedControlPointContextMenu( viewer->surfName(), viewer, @@ -7711,7 +8448,8 @@ bool LineAnnotationController::showGeneratedControlPointContextMenu(CChunkedVolu linkCandidateMenuState(*pane->session), mergeCandidateMenuState(*pane->session), newLinkedToCandidateMenuState(), - [this](uint64_t fiberId) { return fiberDisplayNameForId(fiberId); }); + [this](uint64_t fiberId) { return fiberDisplayNameForId(fiberId); }, + pane->session->fiberId); } else if (_intersectionInspection) { const auto contextIt = _intersectionInspection->generatedSurfaceContexts.find(viewer->surfName()); @@ -9596,7 +10334,8 @@ std::optional LineAnnotationController::placeControlInSession( const std::vector& cumulativeArclengths, const std::vector& controlLinePositions, GeometricControlPointEdit edit, - const std::function& stillValid) + const std::function& stillValid, + const std::function& mutated) { // The placement tail shared by the interactive handler (which computed // edit.nearbyControlIndices from its click) and the automatic link @@ -9604,8 +10343,10 @@ std::optional LineAnnotationController::placeControlInSession( // the geometric prepare + splice, branch-metadata sync and saves, the // dialog overlays, the span-solve dispatch (auto mode) or the view // materialization (manual mode), debug json. Returns the index of the - // placed (or replacing) control in the session, nullopt when nothing - // was placed (the error was shown here). + // placed (or replacing) control in the session; nullopt means either + // that nothing was placed (the error was shown here) or that a validity + // check failed AFTER the geometric mutation committed (a caller that + // must know reads the control back by its position). const QPointer alive(this); auto* pane = paneForSurface(surfaceName); if (!pane || pane->session != owned) { @@ -9716,6 +10457,12 @@ std::optional LineAnnotationController::placeControlInSession( disarmRollbackSnapshots(session); } + // The operation's own mutations (the splice, the metadata saves and + // their generation bumps) are done: a caller advances its expectations + // here, before the materialization below can yield. + if (mutated) { + mutated(); + } // Immediate feedback: the new control point appears in the overlays now; // the strip geometry itself follows when the asynchronous solve lands. if (pane->dialog) { @@ -10302,13 +11049,20 @@ void LineAnnotationController::handleGeneratedControlPointLinkWithCandidate( showError(tr("The link candidate is already linked; unlink it first.")); return; } - if (const auto error = linkSessionControlToStoredControl( - surfaceName, owned, controlPointIndex, farIt->id, *farControlIndex, - candidate.adjacent, /*autoLink=*/false, {})) { - showError(*error); + // The candidate is consumed once the link is installed and BEFORE the + // publication yields (as before the extraction): a candidate designated + // during that yield must survive. Nothing of this controller is touched + // after the helper returns unless it still exists. + const QPointer alive(this); + const auto error = linkSessionControlToStoredControl( + surfaceName, owned, controlPointIndex, farIt->id, *farControlIndex, + candidate.adjacent, /*autoLink=*/false, {}, [this]() { _linkCandidate.reset(); }); + if (!alive) { return; } - _linkCandidate.reset(); + if (error) { + showError(*error); + } } std::optional LineAnnotationController::linkSessionControlToStoredControl( @@ -10319,7 +11073,9 @@ std::optional LineAnnotationController::linkSessionControlToStoredContr int farControlIndex, bool adjacent, bool autoLink, - const std::function& stillValid) + const std::function& stillValid, + const std::function& installed, + std::pair* scheduledGenerations) { // The link of a SESSION control point to a stored fiber's control point, // shared by the link-candidate menu (adjacent or ordinary, human pending) @@ -10442,6 +11198,9 @@ std::optional LineAnnotationController::linkSessionControlToStoredContr pushedReciprocal = reciprocal; } const StoredFiber farFiber = *farIt; + if (scheduledGenerations) { + *scheduledGenerations = {localFiber.generation, farFiber.generation}; + } scheduleFiberPairSave(localFiber, farFiber); auto localIt = std::find_if( @@ -10484,6 +11243,12 @@ std::optional LineAnnotationController::linkSessionControlToStoredContr return tr("Could not save linked fibers: %1").arg(QString::fromStdString(ex.what())); } + // Installed and scheduled for saving: the caller's bookkeeping (a + // consumed candidate, an operation's stage) runs BEFORE the publication + // below can yield. + if (installed) { + installed(); + } session.fiberMetricsMatchStoredFiber = true; invalidateFiberAlignmentMetrics(localFiberId, true); invalidateFiberAlignmentMetrics(farFiberId, true); @@ -15908,7 +16673,11 @@ bool LineAnnotationController::materializeGeneratedViews(LineAnnotationSession& if (pointsApproximatelyEqual(cp.volumePoint, session.controlPoints[index].volumePoint)) cp.displayNormal.reset(); } - saveFiberDisplayAnnotations(session); + saveFiberDisplayAnnotations(session, stillValid); + // The metadata save can show a save-error modal of its own. + if (!alive || (stillValid && !stillValid())) { + return false; + } showError(tr("Normal offset reset to 0 for %1 control points because their stored normals could not be projected into the cross-section plane.") .arg(session.displayField.resetControls.size()), session.suppressErrorDialogs); // The warning is modal: nothing of the controller or the session is @@ -20223,8 +20992,10 @@ void LineAnnotationController::setSessionFiberWidth(LineAnnotationSession& sessi } } -void LineAnnotationController::saveFiberDisplayAnnotations(LineAnnotationSession& session) +void LineAnnotationController::saveFiberDisplayAnnotations(LineAnnotationSession& session, + const std::function& stillValid) { + const QPointer alive(this); // Save metadata without invoking a solve or replacing stored geometry. // Newly moved/inserted CPs are persisted with the next geometry save. auto it = std::find_if(_fibers.begin(), _fibers.end(), [&](const auto& fiber) { return fiber.id == session.fiberId; }); @@ -20247,6 +21018,11 @@ void LineAnnotationController::saveFiberDisplayAnnotations(LineAnnotationSession *it = std::move(updated); } catch (const std::exception& ex) { showError(tr("Could not save fiber display annotations: %1").arg(QString::fromUtf8(ex.what())), session.suppressErrorDialogs); + // The modal: nothing scheduled on a controller or session that + // is no longer valid. + if (!alive || (stillValid && !stillValid())) { + return; + } } } scheduleSessionAutoSave(session); diff --git a/volume-cartographer/apps/VC3D/LineAnnotationController.hpp b/volume-cartographer/apps/VC3D/LineAnnotationController.hpp index cbc01934b2..7aee0ffcf8 100644 --- a/volume-cartographer/apps/VC3D/LineAnnotationController.hpp +++ b/volume-cartographer/apps/VC3D/LineAnnotationController.hpp @@ -950,6 +950,9 @@ private slots: // updated, both refs removed again on a synchronous failure. Returns the // error to show, nullopt on success. stillValid is checked after the // summary publication (which can yield) before the pane is refreshed. + // scheduledGenerations (optional out): the fiber generations the pair + // save was scheduled with (local, far) - what fiberSaved reports when + // that job, or a later one rewriting the same records, lands. std::optional linkSessionControlToStoredControl( const std::string& surfaceName, const std::shared_ptr& owned, @@ -958,7 +961,9 @@ private slots: int farControlIndex, bool adjacent, bool autoLink, - const std::function& stillValid); + const std::function& stillValid, + const std::function& installed = {}, + std::pair* scheduledGenerations = nullptr); // Concatenates the link candidate's fiber onto the session's fiber // end-to-end (both control points must be endpoints) into one brand-new // fiber: tags unioned, third-party links remapped, the pair link between @@ -990,6 +995,20 @@ private slots: size_t secondControlPointIndex, bool enabled); void handleGeneratedOpenNearbyAnnotation(uint64_t fiberId, cv::Vec3f volumePoint); + // "Link and " from a fiber-intersection X inside an open + // Line Annotation: phase A (validation, capture, the structural guard + // reserved) in the slot, phases B-D deferred on a clean stack + // (runDialogAutoLink): the other fiber's sessions retired, the choices + // over the live session and the stored fiber, the other fiber's control + // placed on a detached session, this fiber's through the interactive + // placement tail, the pending automatic link awaited and withdrawn on a + // failed write. The intersection point is in the viewer's volume grid: + // converted only after the menu-time identity was verified. + void handleGeneratedLinkNearbyFiber(const std::string& surfaceName, + uint64_t fiberIdB, + cv::Vec3f viewerPoint, + double anchorLinePosition, + uint64_t anchorLineRevision); void handleGeneratedControlPointUnlink(const std::string& surfaceName, size_t controlPointIndex, uint64_t branchFiberId, @@ -1105,8 +1124,13 @@ private slots: // lifetime / validity / pane re-checked after each; and the placement // tail over an edit whose collapse set the caller decided (the handler // from its click, the automatic link explicitly empty). The tail - // returns the placed control's session index, nullopt when nothing was - // placed. + // returns the placed control's session index; nullopt means either that + // nothing was placed (the error was shown) OR that a validity check + // failed after the geometric mutation committed - a caller that must + // know reads the control back by its position. `mutated` runs right + // after the session's mutation and its metadata saves, before anything + // that can yield, so a caller can advance its own expectations (the + // fiber generation) over the operation's own bumps. bool prepareSessionForControlPlacement(const std::string& surfaceName, const std::shared_ptr& owned, const std::function& stillValid); @@ -1116,7 +1140,8 @@ private slots: const std::vector& cumulativeArclengths, const std::vector& controlLinePositions, GeometricControlPointEdit edit, - const std::function& stillValid); + const std::function& stillValid, + const std::function& mutated = {}); bool applyOptimizationTaskResult(LineAnnotationSession& session, OptimizationTaskResult task, bool updateGeneratedViews, @@ -1396,10 +1421,7 @@ private slots: // a stored fiber (choiceSourceFromStoredFiber) or a live session. struct AutoLinkChoiceSource { const std::vector* linePoints = nullptr; - std::vector controls; - std::vector linked; - std::vector kollesisTerminations; - std::vector> gapSpans; + vc3d::line_annotation::AutoLinkChoiceFacts facts; }; struct AutoLinkChoiceSide { QString name; @@ -1422,7 +1444,6 @@ private slots: QString name; bool placed = false; int storedIndex = -1; - StoredFiber* ownRecord = nullptr; }; struct AutoLinkPlacementContext { std::function checkAndResolve; @@ -1441,6 +1462,46 @@ private slots: void withdrawTrackedLink(const TrackedLinkEndpoints& tracked, uint64_t packageGeneration, uint64_t loadSequence); + // After a withdrawal: the withdrawn state written back and drained, the + // correction reported as done only when ITS job wrote both files. + // Returns the sentence to append to the refusal (empty when the identity + // no longer allows a save). + QString correctivePairResaveTail(uint64_t fiberIdA, + uint64_t fiberIdB, + const std::function& resolveA, + const std::function& resolveB, + const std::function& identityOnly); + // The identity tokens captured right before a generated context menu + // opens, for the one deferred action that must still see them (the + // automatic link): a volume switch during the menu changes the session + // scale without touching the line revision. + struct ContextMenuIdentity { + bool valid = false; + std::string surfaceName; + uint64_t packageGeneration = 0; + uint64_t loadSequence = 0; + uint64_t fiberGeneration = 0; + std::shared_ptr volume; + }; + ContextMenuIdentity _contextMenuIdentity; + struct DialogAutoLinkCapture { + uint64_t packageGeneration = 0; + uint64_t loadSequence = 0; + std::shared_ptr volume; + std::string surfaceName; + std::shared_ptr session; + uint64_t fiberIdA = 0; + std::string fileA; + uint64_t lineRevision = 0; + double anchorLinePosition = 0.0; + int extrapolationLimitVx = 0; + uint64_t fiberIdB = 0; + std::string fileB; + cv::Vec3d pointB{0.0, 0.0, 0.0}; + }; + void runDialogAutoLink(const DialogAutoLinkCapture& capture); + [[nodiscard]] AutoLinkChoiceSource choiceSourceFromSession( + const LineAnnotationSession& session, const std::vector* linePoints) const; static void scaleStoredFiber(StoredFiber& fiber, double scale); [[nodiscard]] static vc::lasagna::LineModel lineModelFromPoints( const std::vector& points, @@ -1558,7 +1619,10 @@ private slots: [[nodiscard]] std::shared_ptr resolveAlignmentMetricsDataset(); void requestFiberAlignmentMetricsForFibers(std::vector fiberIds); - void saveFiberDisplayAnnotations(LineAnnotationSession& session); + // stillValid (optional): a failed metadata save shows a modal; false + // right after it returns without scheduling the autosave. + void saveFiberDisplayAnnotations(LineAnnotationSession& session, + const std::function& stillValid = {}); void setSessionFiberWidth(LineAnnotationSession& session, double width); void publishFiberAlignmentMetrics(uint64_t fiberId, CachedFiberAlignmentMetrics metrics); diff --git a/volume-cartographer/apps/VC3D/LineAnnotationDialog.cpp b/volume-cartographer/apps/VC3D/LineAnnotationDialog.cpp index 7016020a5c..25b720201b 100644 --- a/volume-cartographer/apps/VC3D/LineAnnotationDialog.cpp +++ b/volume-cartographer/apps/VC3D/LineAnnotationDialog.cpp @@ -3332,7 +3332,8 @@ LineAnnotationDialog::showGeneratedControlPointContextMenu( const vc3d::line_annotation::GeneratedLinkCandidateMenuState& linkCandidateState, const vc3d::line_annotation::GeneratedLinkCandidateMenuState& mergeCandidateState, const vc3d::line_annotation::GeneratedLinkCandidateMenuState& newLinkedToCandidateState, - std::function fiberDisplayNameForId) + std::function fiberDisplayNameForId, + uint64_t currentFiberId) { if (!viewer || !_hasGeneratedViews || _generatedViews.controlPoints.empty() || _generatedViews.linePoints.empty()) { @@ -3484,6 +3485,16 @@ LineAnnotationDialog::showGeneratedControlPointContextMenu( options.openNearbyAnnotation = [this](uint64_t fiberId, cv::Vec3f volumePoint) { emit generatedNearbyAnnotationOpenRequested(fiberId, volumePoint); }; + options.currentFiberId = currentFiberId; + options.nearestVisibleLinePosition = [this, viewer](const QPointF& point) { + return nearestVisibleLinePosition(viewer, point); + }; + options.linkNearbyFiber = [this, surfaceName](uint64_t fiberId, cv::Vec3f intersectionPoint, + double anchorLinePosition, + uint64_t anchorLineRevision) { + emit generatedNearbyFiberLinkRequested(surfaceName, fiberId, intersectionPoint, + anchorLinePosition, anchorLineRevision); + }; options.unlinkBranch = [this, surfaceName](size_t controlPointIndex, uint64_t branchFiberId, int branchControlPointIndex) { @@ -3699,6 +3710,104 @@ void LineAnnotationDialog::clearControlPointContextPreview(const std::string& su vc3d::line_annotation::clearGeneratedControlPointContextPreview(viewer, surfaceName); } +vc3d::line_annotation::GeneratedOverlay LineAnnotationDialog::sideCutOverlayFor( + const GeneratedViews& views, + const vc3d::line_annotation::GeneratedControlPointLinePositionIndex& controlIndex, + double linePosition) const +{ + GeneratedOverlay overlay = zSliceOverlay(views, + controlIndex, + linePosition, + true, + _sideCutViewer, + views.sideCutSurface.get()); + overlay.linePoints = views.linePoints; + // Only the stretch within half a wrap of the current position. The + // side cut's plane contains the sheet normal, so for a fiber annotated + // across several wraps EVERY wrap of this stretch lies (nearly) in the + // plane, and projecting the whole line drew them all on top of each + // other. Out-of-window points are blanked rather than erased so the + // point indices the tail detection keys on stay meaningful, and the + // tail range is taken from the plane-near controls BEFORE the window + // trims them, so interior spans do not turn into tails. + const auto window = vc3d::line_annotation::generatedLineIndexRangeWithinWinding( + views.lineWindingAngles, + views.linePoints.size(), + linePosition, + vc3d::line_annotation::kGeneratedSideCutHalfWrapAngle); + const size_t pointCount = overlay.linePoints.size(); + if (pointCount > 0 && (window.first > 0 || window.second + 1 < pointCount)) { + constexpr float kNanF = std::numeric_limits::quiet_NaN(); + const cv::Vec3f blank{kNanF, kNanF, kNanF}; + for (size_t i = 0; i < window.first; ++i) { + overlay.linePoints[i] = blank; + } + for (size_t i = window.second + 1; i < pointCount; ++i) { + overlay.linePoints[i] = blank; + } + overlay.lineTailControlRange = + vc3d::line_annotation::generatedControlLinePositionRange(overlay.controlPoints); + const double lowPosition = static_cast(window.first) - 0.5; + const double highPosition = static_cast(window.second) + 0.5; + std::erase_if(overlay.controlPoints, [&](const auto& control) { + return std::isfinite(control.linePosition) && + (control.linePosition < lowPosition || + control.linePosition > highPosition); + }); + } + return overlay; +} + +std::optional> LineAnnotationDialog::nearestVisibleLinePosition( + CChunkedVolumeViewer* viewer, const QPointF& scenePoint) const +{ + if (!viewer || !_hasGeneratedViews || _generatedViews.linePoints.empty() || + !std::isfinite(scenePoint.x()) || !std::isfinite(scenePoint.y())) { + return std::nullopt; + } + const uint64_t revision = _generatedViews.lineRevision; + const double maxPosition = static_cast(_generatedViews.linePoints.size() - 1); + if (viewer == _currentCutViewer) { + // The current cut draws no polyline of the open fiber, only the + // marker where the line pierces the cut plane: that is its one + // visible pass. A held frame shows a position the session has moved + // away from. + if (_currentCutOverlaySwapPending) { + return std::nullopt; + } + return std::make_pair(std::clamp(_currentLinePosition, 0.0, maxPosition), revision); + } + if (viewer == _sideCutViewer) { + if (_sideCutOverlaySwapPending || !_generatedViews.sideCutSurface) { + return std::nullopt; + } + const GeneratedOverlay overlay = + sideCutOverlayFor(_generatedViews, _generatedControlIndex, _currentLinePosition); + std::vector scenePoints(overlay.linePoints.size(), + QPointF(std::numeric_limits::quiet_NaN(), + std::numeric_limits::quiet_NaN())); + for (size_t i = 0; i < overlay.linePoints.size(); ++i) { + if (finitePoint(overlay.linePoints[i])) { + scenePoints[i] = viewer->volumeToScene(overlay.linePoints[i]); + } + } + const auto position = vc3d::line_annotation::nearestDrawnLinePosition( + overlay.linePoints, scenePoints, + vc3d::line_annotation::effectiveLineTailControlRange(overlay), scenePoint); + if (!position) { + return std::nullopt; + } + return std::make_pair(*position, revision); + } + // A strip: the open fiber's line is its centre row, so the nearest point + // is the scene point's column. + const double position = linePositionFromStripScene(viewer, scenePoint); + if (!std::isfinite(position) || position < 0.0 || position > maxPosition) { + return std::nullopt; + } + return std::make_pair(position, revision); +} + double LineAnnotationDialog::linePositionFromStripScene(CChunkedVolumeViewer* viewer, const QPointF& scenePoint) const { @@ -5364,49 +5473,7 @@ void LineAnnotationDialog::updateGeneratedDynamicOverlaysFast(bool updateCurrent _sideCutOverlaySwapPending ? _heldControlIndex : _generatedControlIndex; const double sidePosition = _sideCutOverlaySwapPending ? _heldLinePosition : _currentLinePosition; - GeneratedOverlay sideOverlay = zSliceOverlay(sideViews, - sideIndex, - sidePosition, - true, - _sideCutViewer, - sideViews.sideCutSurface.get()); - sideOverlay.linePoints = sideViews.linePoints; - // Only the stretch within half a wrap of the current position. The - // side cut's plane contains the sheet normal, so for a fiber annotated - // across several wraps EVERY wrap of this stretch lies (nearly) in the - // plane, and projecting the whole line drew them all on top of each - // other. Out-of-window points are blanked rather than erased so the - // point indices the tail detection keys on stay meaningful, and the - // tail range is taken from the plane-near controls BEFORE the window - // trims them, so interior spans do not turn into tails. - { - const auto window = vc3d::line_annotation::generatedLineIndexRangeWithinWinding( - sideViews.lineWindingAngles, - sideViews.linePoints.size(), - sidePosition, - vc3d::line_annotation::kGeneratedSideCutHalfWrapAngle); - const size_t pointCount = sideOverlay.linePoints.size(); - if (pointCount > 0 && (window.first > 0 || window.second + 1 < pointCount)) { - constexpr float kNanF = std::numeric_limits::quiet_NaN(); - const cv::Vec3f blank{kNanF, kNanF, kNanF}; - for (size_t i = 0; i < window.first; ++i) { - sideOverlay.linePoints[i] = blank; - } - for (size_t i = window.second + 1; i < pointCount; ++i) { - sideOverlay.linePoints[i] = blank; - } - sideOverlay.lineTailControlRange = - vc3d::line_annotation::generatedControlLinePositionRange( - sideOverlay.controlPoints); - const double lowPosition = static_cast(window.first) - 0.5; - const double highPosition = static_cast(window.second) + 0.5; - std::erase_if(sideOverlay.controlPoints, [&](const auto& control) { - return std::isfinite(control.linePosition) && - (control.linePosition < lowPosition || - control.linePosition > highPosition); - }); - } - } + GeneratedOverlay sideOverlay = sideCutOverlayFor(sideViews, sideIndex, sidePosition); // Highlight the live cursor position on the line. The cross-slice overlay's emphasized // marker otherwise sits at the static focus/seed point; override it to the current // position so the highlight tracks the cursor as it moves along the line. Use the diff --git a/volume-cartographer/apps/VC3D/LineAnnotationDialog.hpp b/volume-cartographer/apps/VC3D/LineAnnotationDialog.hpp index c8b5ff15fd..04a6dda2b2 100644 --- a/volume-cartographer/apps/VC3D/LineAnnotationDialog.hpp +++ b/volume-cartographer/apps/VC3D/LineAnnotationDialog.hpp @@ -157,7 +157,8 @@ class LineAnnotationDialog : public QMainWindow const vc3d::line_annotation::GeneratedLinkCandidateMenuState& linkCandidateState = {}, const vc3d::line_annotation::GeneratedLinkCandidateMenuState& mergeCandidateState = {}, const vc3d::line_annotation::GeneratedLinkCandidateMenuState& newLinkedToCandidateState = {}, - std::function fiberDisplayNameForId = {}); + std::function fiberDisplayNameForId = {}, + uint64_t currentFiberId = 0); const std::vector& panes() const { return _panes; } ReoptimizationMode reoptimizationMode() const; int initialCenterlineLengthVx() const; @@ -288,6 +289,14 @@ class LineAnnotationDialog : public QMainWindow size_t secondControlPointIndex, bool enabled); void generatedNearbyAnnotationOpenRequested(uint64_t fiberId, cv::Vec3f volumePoint); + // "Link and " on a fiber-intersection X: the marker's + // fiber and point (viewer volume grid), the anchor on the open fiber's + // drawn line and the line revision it was computed on. + void generatedNearbyFiberLinkRequested(const std::string& surfaceName, + uint64_t fiberId, + cv::Vec3f intersectionPoint, + double anchorLinePosition, + uint64_t anchorLineRevision); void generatedControlPointUnlinkRequested(const std::string& surfaceName, size_t controlPointIndex, uint64_t branchFiberId, @@ -364,6 +373,22 @@ class LineAnnotationDialog : public QMainWindow CChunkedVolumeViewer* viewer, const GeneratedOverlay& overlay); double linePositionFromStripScene(CChunkedVolumeViewer* viewer, const QPointF& scenePoint) const; + // The position of the open fiber's DRAWN line nearest a scene point of + // this pane, with the line revision it was computed on: a strip's centre + // row (the point's column), the side cut's windowed projection (the + // nearest drawn, non-tail segment), the current cut's one visible pass + // (the cut position). nullopt while the pane shows a held frame or when + // nothing of the line is drawn near the point. + std::optional> nearestVisibleLinePosition( + CChunkedVolumeViewer* viewer, const QPointF& scenePoint) const; + // The side cut's overlay as updateGeneratedDynamicOverlaysFast draws it + // (the cross-slice overlay, the line windowed to half a wrap, the tail + // range from the plane-near controls), without the cursor marker; one + // builder for the refresh and for nearestVisibleLinePosition. + GeneratedOverlay sideCutOverlayFor( + const GeneratedViews& views, + const vc3d::line_annotation::GeneratedControlPointLinePositionIndex& controlIndex, + double linePosition) const; // Coalesced entry point for the mouse-follow path: stores the latest line position and // applies it at most once per ~render tick via _lineUpdateTimer, so a burst of mouse-move // events collapses into a single (potentially O(N)) plane/overlay rebuild instead of one diff --git a/volume-cartographer/apps/VC3D/LineAnnotationFiberLinks.hpp b/volume-cartographer/apps/VC3D/LineAnnotationFiberLinks.hpp index b79aa250e6..be79ab83bd 100644 --- a/volume-cartographer/apps/VC3D/LineAnnotationFiberLinks.hpp +++ b/volume-cartographer/apps/VC3D/LineAnnotationFiberLinks.hpp @@ -266,6 +266,80 @@ inline FiberBranchRef reciprocalBranchRef(const FiberBranchRef& ref, return reciprocal; } +// The facts the automatic link's choice step needs about one fiber's +// controls, as plain data in base voxels: positions, which are linked (an +// ordinary or adjacent branch ref names the control), which carry the +// kollesis termination tag, and the gap spans as (lower, upper) control +// pairs. One builder over the generic shape (a session: positions, refs, +// tags, per-span gap flags) and one over a stored record, so the two +// representations of the same fiber produce identical facts - and the +// chooser's control index is the index in the given order (a session's own +// index for a session). +struct AutoLinkChoiceFacts { + std::vector controls; + std::vector linked; + std::vector kollesisTerminations; + std::vector> gapSpans; + bool operator==(const AutoLinkChoiceFacts& other) const + { + if (controls.size() != other.controls.size() || linked != other.linked || + kollesisTerminations != other.kollesisTerminations || gapSpans != other.gapSpans) { + return false; + } + for (std::size_t i = 0; i < controls.size(); ++i) { + if (controls[i] != other.controls[i]) { + return false; + } + } + return true; + } +}; + +inline bool branchLinkOnControl(const std::vector& branches, std::size_t index) +{ + return std::any_of(branches.begin(), branches.end(), [index](const FiberBranchRef& branch) { + return branch.controlPointIndex == static_cast(index); + }); +} + +inline AutoLinkChoiceFacts autoLinkChoiceFacts( + const std::vector& controls, + const std::vector& branches, + const std::vector>& tagsPerControl, + const std::vector& spanIsGapPerSpan) +{ + AutoLinkChoiceFacts facts; + facts.controls = controls; + facts.linked.resize(controls.size(), false); + facts.kollesisTerminations.resize(controls.size(), false); + for (std::size_t c = 0; c < controls.size(); ++c) { + facts.linked[c] = branchLinkOnControl(branches, c); + facts.kollesisTerminations[c] = + c < tagsPerControl.size() && hasControlPointTag(tagsPerControl[c], kKollesisTerminationTag); + } + // Gap spans in control order: the span descriptor of the lower control + // carries the tag. + for (std::size_t c = 0; c + 1 < controls.size(); ++c) { + if (c < spanIsGapPerSpan.size() && spanIsGapPerSpan[c]) { + facts.gapSpans.emplace_back(c, c + 1); + } + } + return facts; +} + +inline AutoLinkChoiceFacts autoLinkChoiceFactsFromStoredFiber(const StoredFiber& fiber) +{ + std::vector> tags; + std::vector gaps; + tags.reserve(fiber.controlPoints.size()); + for (const auto& control : fiber.controlPoints) { + tags.push_back(control.tags); + gaps.push_back(spanIsGap(control.segmentToNext)); + } + return autoLinkChoiceFacts(storedControlPointPositions(fiber.controlPoints), fiber.branches, + tags, gaps); +} + // The one marker rule for a control point carrying several links: it draws // as an AUTOMATIC pending point (dashed) only when it has at least one // pending link and every pending link on it is automatic - a human-made diff --git a/volume-cartographer/apps/VC3D/LineAnnotationGeneratedViews.cpp b/volume-cartographer/apps/VC3D/LineAnnotationGeneratedViews.cpp index b3f5de7a3c..28d37e67d8 100644 --- a/volume-cartographer/apps/VC3D/LineAnnotationGeneratedViews.cpp +++ b/volume-cartographer/apps/VC3D/LineAnnotationGeneratedViews.cpp @@ -1270,6 +1270,10 @@ const GeneratedOverlay::FiberIntersectionMarker* nearbyFiberIntersection( // fiber marker under it. struct ClickLocationActions { QAction* openNearbyAnnotation = nullptr; + QAction* linkNearbyFiber = nullptr; + // The anchor (and its line revision) captured BEFORE the menu opens: the + // geometry can be replaced while the menu is up. + std::optional> linkAnchor; QAction* newLineAnnotation = nullptr; QAction* newModelLineAnnotation = nullptr; QAction* newLinkedLineAnnotation = nullptr; @@ -1285,6 +1289,28 @@ struct ClickLocationActions { QWidget::tr("Go to nearby annotation (%1)") .arg(fiberName(nearbyIntersection->fiberId))); } + // Directly under it: the automatic link to the marker's fiber, as + // the Fiber Map makes it, anchored on the open fiber's drawn line. + if (options.linkNearbyFiber && nearbyIntersection && options.currentFiberId != 0 && + nearbyIntersection->fiberId != options.currentFiberId) { + const QPointF intersectionScene = + options.viewer->volumeToScene(nearbyIntersection->point); + linkAnchor = options.nearestVisibleLinePosition && finiteScenePoint(intersectionScene) + ? options.nearestVisibleLinePosition(intersectionScene) + : std::nullopt; + linkNearbyFiber = menu.addAction( + QWidget::tr("Link %1 and %2") + .arg(fiberName(options.currentFiberId), + fiberName(nearbyIntersection->fiberId))); + if (!linkAnchor) { + linkNearbyFiber->setEnabled(false); + linkNearbyFiber->setToolTip( + QWidget::tr("No pass of this fiber is visible near the marker, or the view " + "is still updating.")); + // QMenu shows action tooltips only when asked to. + menu.setToolTipsVisible(true); + } + } } void addNewAnnotations(QMenu& menu, const GeneratedControlPointContextMenuOptions& options) @@ -1330,6 +1356,12 @@ struct ClickLocationActions { options.openNearbyAnnotation(nearbyIntersection->fiberId, nearbyIntersection->point); return GeneratedControlPointContextResult::Handled; } + if (linkNearbyFiber && selected == linkNearbyFiber && linkNearbyFiber->isEnabled() && + nearbyIntersection && linkAnchor) { + options.linkNearbyFiber(nearbyIntersection->fiberId, nearbyIntersection->point, + linkAnchor->first, linkAnchor->second); + return GeneratedControlPointContextResult::Handled; + } return std::nullopt; } }; diff --git a/volume-cartographer/apps/VC3D/LineAnnotationGeneratedViews.hpp b/volume-cartographer/apps/VC3D/LineAnnotationGeneratedViews.hpp index 0920ff13fb..d5c16ddc52 100644 --- a/volume-cartographer/apps/VC3D/LineAnnotationGeneratedViews.hpp +++ b/volume-cartographer/apps/VC3D/LineAnnotationGeneratedViews.hpp @@ -3334,6 +3334,15 @@ struct GeneratedControlPointContextMenuOptions { std::function linkWithCandidate; std::function mergeWithCandidate; std::function openNearbyAnnotation; + // "Link and " on a fiber-intersection X (the automatic + // link made from inside the annotation): the current fiber's id (0 for a + // never-saved session: no entry), the dialog's anchor rule (the position + // of the open fiber's DRAWN line nearest a scene point, with the line + // revision it was computed on; nullopt = no visible pass, entry + // disabled) and the request (fiber, X point, anchor, revision). + uint64_t currentFiberId = 0; + std::function>(const QPointF&)> nearestVisibleLinePosition; + std::function linkNearbyFiber; // --- Span menu (strip viewers only: a Ctrl+right-click on the centre // line away from every control point). Each takes the two controls of // the span, in line-position order. The interpolation goal lives here diff --git a/volume-cartographer/apps/VC3D/test/test_fiber_map_auto_link.cpp b/volume-cartographer/apps/VC3D/test/test_fiber_map_auto_link.cpp index d8bfd5cf8b..a02586fc30 100644 --- a/volume-cartographer/apps/VC3D/test/test_fiber_map_auto_link.cpp +++ b/volume-cartographer/apps/VC3D/test/test_fiber_map_auto_link.cpp @@ -145,6 +145,7 @@ class TestFiberMapAutoLink : public QObject private slots: void nearestLinePositionFoot(); + void chooserRespectsLinkedAndGapFacts(); // The arclength the layout resampled on is what unrolledLineArclengths // recomputes from the stored inputs, bitwise, and every drawn sample's // arclength is one of that parameterisation. @@ -829,5 +830,44 @@ void TestFiberMapAutoLink::nearestLinePositionFoot() QCOMPARE(*single, 0.0); } +void TestFiberMapAutoLink::chooserRespectsLinkedAndGapFacts() +{ + // The facts a session or a stored record hand the chooser (built by the + // shared autoLinkChoiceFacts builders, tested for parity in the + // generated-views test): a linked control is never reused, a gap span is + // closed to placement, and the reported index is the index in the given + // control order (a session's own index for a session). + std::vector line; + for (int i = 0; i <= 200; ++i) { + line.emplace_back(static_cast(i), 0.0, 0.0); + } + const auto cumulative = autolink::cumulativeArclengths(line); + autolink::ControlChoiceInput input; + input.linePoints = &line; + input.cumulative = &cumulative; + // Controls at 20, 100 (linked) and 180; a gap span 100-180. + input.controlLineIndex = {20, 100, 180}; + input.linked = {false, true, false}; + input.kollesisTerminations = {false, false, false}; + input.gapSpans = {{1, 2}}; + input.passPosition = 100.3; + input.windowLo = 60.0; + input.windowHi = 140.0; + input.reuseVx = 5.0; + input.minSpacingVx = 8.0; + const auto placed = autolink::chooseControlPoint(input); + // The linked control at 100 is NOT reused; a new control is placed + // clear of it and outside the gap span (below 100). + QVERIFY(placed.kind == autolink::ControlChoice::Kind::Place); + QVERIFY(placed.linePosition < 100.0 - 8.0 + 1e-9); + QVERIFY(placed.linePosition >= 60.0); + // With the control at 100 unlinked it is reused, by its index in the + // given order. + input.linked = {false, false, false}; + const auto reused = autolink::chooseControlPoint(input); + QVERIFY(reused.kind == autolink::ControlChoice::Kind::Reuse); + QCOMPARE(reused.controlIndex, 1); +} + QTEST_APPLESS_MAIN(TestFiberMapAutoLink) #include "test_fiber_map_auto_link.moc" diff --git a/volume-cartographer/core/test/test_line_annotation_generated_views.cpp b/volume-cartographer/core/test/test_line_annotation_generated_views.cpp index 290fc093cc..11782d24ea 100644 --- a/volume-cartographer/core/test/test_line_annotation_generated_views.cpp +++ b/volume-cartographer/core/test/test_line_annotation_generated_views.cpp @@ -11,6 +11,7 @@ #include "LineAnnotationFiberSegments.hpp" #include "vc/lasagna/LineSpline.hpp" #include "LineAnnotationGeneratedViews.hpp" +#include "LineAnnotationFiberLinks.hpp" #include "LineAnnotationShiftScroll.hpp" #include "vc/fiber_tracer/FiberJson.hpp" #include "vc/fiber_tracer/FiberDisplay.hpp" @@ -7824,3 +7825,48 @@ TEST_CASE("Nearest drawn line position: the foot on the nearest drawn, non-tail } } +TEST_CASE("Auto-link choice facts: a stored record and a session-shaped view of the same fiber agree") +{ + using vc3d::line_annotation::autoLinkChoiceFacts; + using vc3d::line_annotation::autoLinkChoiceFactsFromStoredFiber; + using vc3d::line_annotation::FiberBranchRef; + using vc3d::line_annotation::StoredFiber; + // Three controls: the middle one linked to another fiber, the last one a + // kollesis termination, a gap span between the middle and the last. + StoredFiber fiber; + fiber.id = 7; + fiber.fileName = "a.json"; + for (int i = 0; i < 3; ++i) { + fiber.controlPoints.emplace_back(cv::Vec3d(10.0 * i, 1.0, 2.0)); + } + vc3d::line_annotation::setControlPointTag(fiber.controlPoints[2].tags, + vc3d::line_annotation::kKollesisTerminationTag, true); + fiber.controlPoints[1].segmentToNext = vc3d::line_annotation::FiberTraceSegmentMetadata{}; + vc3d::line_annotation::setControlPointTag(fiber.controlPoints[1].segmentToNext->tags, + vc3d::line_annotation::kGapSpanTag, true); + FiberBranchRef link; + link.controlPointIndex = 1; + link.branchFiberId = 9; + link.branchFileName = "b.json"; + link.branchControlPointIndex = 0; + fiber.branches.push_back(link); + + const auto stored = autoLinkChoiceFactsFromStoredFiber(fiber); + // The session-shaped view: positions, the same refs, per-control tags, + // per-span gap flags - what choiceSourceFromSession hands the builder. + const std::vector positions{{0, 1, 2}, {10, 1, 2}, {20, 1, 2}}; + const std::vector> tags{ + {}, {}, {std::string(vc3d::line_annotation::kKollesisTerminationTag)}}; + const std::vector gaps{false, true, false}; + const auto session = autoLinkChoiceFacts(positions, fiber.branches, tags, gaps); + CHECK(stored == session); + REQUIRE(stored.controls.size() == 3); + CHECK(stored.linked == std::vector{false, true, false}); + CHECK(stored.kollesisTerminations == std::vector{false, false, true}); + REQUIRE(stored.gapSpans.size() == 1); + CHECK(stored.gapSpans[0] == std::pair{1, 2}); + // The control index the chooser reports is the index in this order: the + // session's own index for a session. + CHECK(stored.controls[1] == cv::Vec3d(10, 1, 2)); +} + diff --git a/volume-cartographer/docs/line_annotation_fibers.md b/volume-cartographer/docs/line_annotation_fibers.md index cb5a17fd8d..646e2a8be8 100644 --- a/volume-cartographer/docs/line_annotation_fibers.md +++ b/volume-cartographer/docs/line_annotation_fibers.md @@ -428,6 +428,30 @@ is refused ("A merge, split or link is in progress"). If the pair save fails, the link is withdrawn from the loaded fibers (every copy, including an edit's synced moves of its endpoints during the wait) and the status line says so. +### Linking from a fiber-intersection X + +Inside an open Line Annotation, Ctrl+right-click on the yellow X of another +fiber's intersection (in the current cut, the side cut or a strip) and choose +**Link and **, directly under "Go to nearby +annotation". It makes the same pending automatic link as the Fiber Map does, +with the open fiber's control point anchored at the pass of its DRAWN line +nearest to the X in that view (a strip: the X's column; the side cut: the +nearest drawn segment of the windowed projection; the current cut: the one +visible pass, the cut position) and the other fiber's at the X itself; from +those two anchors the same ±5 mm closest-pass search and 1 mm reuse rule +decide the two control points. Differences from the map: + +- this annotation stays open: its new control point is placed exactly like a + manual placement (asynchronous span re-optimization, autosave, the same + placement limit), and the link is made right after the placement; +- the OTHER fiber's open annotation or dialog-less sessions are saved and + closed first (the map's rule for both fibers, applied to that one); +- the entry is disabled while the view is still updating or no pass of this + fiber is drawn near the marker, and absent for a never-saved annotation. + +Failures are reported in the usual error dialog with the map's wording; a +control point already committed before a refusal is kept, unlinked. + A pending automatic link is an ordinary pending link drawn in its own palette: bright cyan (#20f0ff), or light pink (#ffa8d8) for a same-H/V link, instead of the pending blue / orange - solid markers like every other link