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 7feb9d4d05..40b48c5d80 100644 --- a/volume-cartographer/apps/VC3D/CWindow.cpp +++ b/volume-cartographer/apps/VC3D/CWindow.cpp @@ -8027,11 +8027,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..2713f43d2a --- /dev/null +++ b/volume-cartographer/apps/VC3D/FiberMapAutoLink.cpp @@ -0,0 +1,884 @@ +#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::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); + 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..8a63cbd841 --- /dev/null +++ b/volume-cartographer/apps/VC3D/FiberMapAutoLink.hpp @@ -0,0 +1,157 @@ +#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 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, +// 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 688ff84996..c01d8ca028 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" @@ -161,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 @@ -923,7 +937,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()); } } @@ -1139,6 +1153,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 @@ -1536,8 +1553,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) @@ -1561,6 +1625,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; @@ -1591,6 +1657,9 @@ 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(); + cancelAutoLinkResume(); if (_tree) { _tree->clear(); } @@ -1692,11 +1761,14 @@ 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(); } + // 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; @@ -1826,6 +1898,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; @@ -1905,10 +1980,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, @@ -2032,9 +2109,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))); } } @@ -2043,6 +2120,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; @@ -2306,6 +2389,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; @@ -2387,8 +2472,20 @@ 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); + } + } + // 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 · " @@ -2518,7 +2615,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); @@ -2593,6 +2690,7 @@ void FiberMapWorkspace::rebuildScene(const QString& emptyMessage) _entries.clear(); clearControlPointDots(); _highlightedFiber = 0; + _selectedFibers.clear(); _networkEmphasized.clear(); _labelChips.clear(); _chipHideScale = 0.0; @@ -2838,6 +2936,54 @@ 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, 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, + 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}); + ++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) + : 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); + } + }; updateFocusBounds(); for (const vc3d::fiber_map::PlacedLink& link : _layout.links) { @@ -2854,39 +3000,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, 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{ + 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); @@ -2896,13 +3022,31 @@ 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 + // (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 (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); + } + 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); + } } auto* label = _scene->addSimpleText( link.adjacentDisagrees ? tr("adjacent kind disagrees between the fibers") @@ -2917,15 +3061,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); @@ -2940,9 +3093,17 @@ 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 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(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); @@ -3293,7 +3454,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. @@ -3313,9 +3474,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; } @@ -3375,15 +3538,38 @@ 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) { + // 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); - setHighlightedFiber(fiberId); + 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); } @@ -3721,7 +3907,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(); } } @@ -3768,15 +3954,63 @@ 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 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.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.automatic = review.autoPending; + look.anyAuto = review.anyAuto; + look.adjacent = review.adjacent; + return look; +} + void FiberMapWorkspace::setHighlightedFiber(uint64_t fiberId) { - if (_highlightedFiber == fiberId) { + cancelAutoLinkResume(); + 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()) { @@ -3785,63 +4019,108 @@ 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: the automatic + // palette (automaticLinkMarker, the rule every renderer draws). + const auto look = endpointLook(fiberId, static_cast(i)); + 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 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, - kMaxControlDotPx, kControlDotBoundsCm * vxPerCm); + kMaxControlDotPx, kControlDotBoundsCm * vxPerCm, + /*triangle=*/triangle); _scene->addItem(dot); dot->setPos(entry->fiber.controlPoints[i]); dot->setZValue(kHighlightZ + 1.0); @@ -3853,17 +4132,43 @@ void FiberMapWorkspace::setHighlightedFiber(uint64_t fiberId) 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; @@ -3880,6 +4185,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) { @@ -3957,8 +4265,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 @@ -3975,6 +4305,313 @@ 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; + _autoLinkResumeQueued = true; + QMetaObject::invokeMethod( + this, + [self, resume, dependencies, token]() { + 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"); + self->startAutoLink(resume.subjectId, resume.otherId, resume.scenePos, dependencies); + }, + Qt::QueuedConnection); +} + +void FiberMapWorkspace::cancelAutoLinkResume() +{ + const bool promised = _autoLinkResume.has_value() || _autoLinkResumeQueued; + _autoLinkResume.reset(); + _autoLinkResumeQueued = false; + ++_autoLinkResumeToken; + if (promised) { + // 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 (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; + // 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 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]() { + 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; + } + // 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 + // 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 (_autoLinkResumeToken != requestToken) { + bail(tr("The link was cancelled by the click on the map")); + refreshStaleState(); + return; + } + if (!fresh()) { + // 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; + } + + // 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) { @@ -4060,11 +4697,13 @@ 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; + 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 // 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 9ac89f8368..c99e76b9d9 100644 --- a/volume-cartographer/apps/VC3D/FiberMapWorkspace.hpp +++ b/volume-cartographer/apps/VC3D/FiberMapWorkspace.hpp @@ -72,7 +72,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. @@ -200,6 +202,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 @@ -303,6 +317,40 @@ 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 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 + // automatic palette follows endpointAutoPending. Every dot drawer uses it. + struct EndpointLook { + QColor fill; + QColor pen; + bool pending = 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; + // 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 @@ -349,7 +397,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. @@ -439,6 +487,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. @@ -476,6 +532,45 @@ 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; + // 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; + // 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 + // 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; @@ -544,6 +639,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 6cd8b23f10..07caa31940 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; std::vector worldSamples; }; @@ -434,14 +466,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(), @@ -563,15 +604,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()); @@ -632,6 +672,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; placedFiber.controlWorldPoints = fiber.controlPoints; // Per-point flags are copied only when they line up with the controls; a // mismatched vector is ignored rather than read misaligned. @@ -677,6 +720,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; whole.worldPoints = geo.worldSamples; placedFiber.runs.push_back(std::move(whole)); } @@ -709,6 +753,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)); run.worldPoints.assign(geo.worldSamples.begin() + static_cast(from), geo.worldSamples.begin() + static_cast(to)); placedFiber.runs.push_back(std::move(run)); @@ -1066,6 +1113,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(); @@ -1883,6 +1931,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(); @@ -2235,6 +2284,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); @@ -2338,12 +2388,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); @@ -2369,6 +2429,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) { @@ -2448,4 +2509,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 2d2c8816cb..a6e019b37d 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; // Original annotation-frame positions at the same arclength samples. // Display-only, never used by the winding/layout solve. std::vector worldPoints; @@ -152,6 +160,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; std::vector controlWorldPoints; }; @@ -188,6 +204,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 { @@ -630,4 +649,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 8b6ddf10b7..61a2f4afb3 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" @@ -945,10 +946,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(); @@ -1314,6 +1320,7 @@ generatedControlMarkers( int controlPointIndex = -1; bool pending = false; bool adjacent = false; + bool autoLink = false; }; std::unordered_map> branchesByControl; branchesByControl.reserve(branches.size()); @@ -1337,10 +1344,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; } } @@ -1382,11 +1395,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(); @@ -2303,8 +2321,11 @@ void LineAnnotationController::launchFromViewerAtPoint(CChunkedVolumeViewer* vie handleLineSeed(surfaceName, sample->position, InitialDirectionMode::ZInOut); } -bool LineAnnotationController::prepareForUserFacingLineAnnotationOpen() +bool LineAnnotationController::prepareForUserFacingLineAnnotationOpen( + const std::function& stillValid, + const std::function& only) { + const QPointer alive(this); struct DialogRecord { std::string surfaceName; QPointer dialog; @@ -2317,6 +2338,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()) { @@ -2340,8 +2366,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; @@ -2360,6 +2400,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; @@ -2372,8 +2415,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; } @@ -2756,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, @@ -2956,90 +3014,2028 @@ bool LineAnnotationController::launchSession(LineAnnotationController::SourceKin cleanupSurfaceName(surfaceName); }); - refreshSessionOptimizationStatus(*session); - if (!session->optimizedLine.points.empty()) { - session->taskState = LineAnnotationSession::TaskState::Succeeded; - setSessionOptimizationState(*session, SessionOptimizationState::Optimized); - materializeGeneratedViews(*session); + refreshSessionOptimizationStatus(*session); + if (!session->optimizedLine.points.empty()) { + session->taskState = LineAnnotationSession::TaskState::Succeeded; + setSessionOptimizationState(*session, SessionOptimizationState::Optimized); + materializeGeneratedViews(*session); + } + if (!session->deferShowUntilGenerated || !session->optimizedLine.points.empty()) { + emit lineAnnotationWorkspaceRequested(dialog, tr("Line Annotation")); + dialog->showWithSavedGeometry(); + dialog->raise(); + dialog->activateWindow(); + } + return true; +} + +void LineAnnotationController::openFiber(uint64_t fiberId) +{ + openFiberWithControlPoint(fiberId, std::nullopt, std::nullopt); +} + +LineAnnotationDialog* LineAnnotationController::openCollectionFiber(uint64_t fiberId, QString* error) +{ + // The caller reports failures in its own status area. + QScopedValueRollback quiet(_errorDialogsSuppressed, true); + QScopedValueRollback previousError(_lastSuppressedError, QString{}); + const QPointer previous = mostRecentLineAnnotationDialog(); + openFiberWithControlPoint(fiberId, std::nullopt, std::nullopt, std::nullopt, true); + auto* dialog = mostRecentLineAnnotationDialog(); + if (!_lastSuppressedError.isEmpty() || !dialog || dialog == previous || + _panes.empty() || !_panes.back().session || + _panes.back().session->fiberId != fiberId || + _panes.back().session->generatedLineSurfaceName.empty()) { + if (error) + *error = _lastSuppressedError.isEmpty() + ? tr("Could not open the flattened fiber. The previous annotation may still be busy.") + : _lastSuppressedError; + return nullptr; + } + return dialog; +} + +LineAnnotationDialog* LineAnnotationController::openFiberReadOnly(const nlohmann::json& root, + const std::string& key, + QString* error) +{ + const auto fail = [error](const QString& message) -> LineAnnotationDialog* { + if (error) { + *error = message; + } + return nullptr; + }; + for (const auto& pane : _panes) { + if (pane.session && pane.session->readOnly && pane.session->fiberFileName == key && pane.dialog) { + pane.dialog->raise(); + pane.dialog->activateWindow(); + return pane.dialog; + } + } + std::optional fiber; + try { + fiber = loadFiberJson(root, fs::path(key)); + } catch (const std::exception& ex) { + return fail(tr("Could not read the fiber: %1").arg(QString::fromStdString(ex.what()))); + } + if (!fiber) { + return fail(tr("Not a VC3D fiber.")); + } + // In memory only, for this view: no file name in the package, no source + // root, no links (nothing to resolve them against), never saved. + fiber->readOnly = true; + fiber->branches.clear(); + fiber->fileName = key; + fiber->sourceRoot.clear(); + fiber->needsSave = false; + fiber->id = nextFiberId(); + fiber->hvClassification = vc3d::line_annotation::classifyFiberHv( + vc3d::line_annotation::storedControlPointPositions(fiber->controlPoints)); + const uint64_t id = fiber->id; + _fibers.push_back(std::move(*fiber)); + auto* dialog = openCollectionFiber(id, error); + if (!dialog) { + std::erase_if(_fibers, [id](const StoredFiber& stored) { return stored.id == id && stored.readOnly; }); + } + 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) +{ + 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) { + 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; + }; + 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))); + } + } + 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 = [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 + // (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 - 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. + 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) { + 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) && !kept(pane.surfaceName)) { + 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(participantIds); + 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) && !kept(pane.surfaceName)) { + 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; +} + +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. 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); + 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) { + 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 +{ + AutoLinkChoiceSource source; + source.linePoints = &fiber.linePoints; + source.facts = vc3d::line_annotation::autoLinkChoiceFactsFromStoredFiber(fiber); + 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; + 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(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 < 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); + } + } + 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.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); + }; + 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); +} + +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, + 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 { + 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; + } + double passDistance = 0.0; + if (const auto error = planAutoLinkChoices(choiceSides, &passDistance)) { + return error; + } + for (size_t s = 0; s < 2; ++s) { + 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(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 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 (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 Side& other = &side == &sides[0] ? sides[1] : sides[0]; + 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; + }; + 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); + } + // 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); + } + side.linkedControlIndex = placement.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 = [&]() { + withdrawTrackedLink(tracked, packageGeneration, loadSequence); + }; + waitForFiberSaves(identityOnly); + if (!alive) { + return refuse(changedMessage); + } + const bool pairWritten = savedIds.count(fiberIdA) != 0 && savedIds.count(fiberIdB) != 0; + if (!pairWritten) { + removeInstalledLink(); + if (!alive) { + return refuse(changedMessage); + } + 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); + } + 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; +} + +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); } - if (!session->deferShowUntilGenerated || !session->optimizedLine.points.empty()) { - emit lineAnnotationWorkspaceRequested(dialog, tr("Line Annotation")); - dialog->showWithSavedGeometry(); - dialog->raise(); - dialog->activateWindow(); + 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; } - return true; -} - -void LineAnnotationController::openFiber(uint64_t fiberId) -{ - openFiberWithControlPoint(fiberId, std::nullopt, std::nullopt); -} - -LineAnnotationDialog* LineAnnotationController::openCollectionFiber(uint64_t fiberId, QString* error) -{ - // The caller reports failures in its own status area. - QScopedValueRollback quiet(_errorDialogsSuppressed, true); - QScopedValueRollback previousError(_lastSuppressedError, QString{}); - const QPointer previous = mostRecentLineAnnotationDialog(); - openFiberWithControlPoint(fiberId, std::nullopt, std::nullopt, std::nullopt, true); - auto* dialog = mostRecentLineAnnotationDialog(); - if (!_lastSuppressedError.isEmpty() || !dialog || dialog == previous || - _panes.empty() || !_panes.back().session || - _panes.back().session->fiberId != fiberId || - _panes.back().session->generatedLineSurfaceName.empty()) { - if (error) - *error = _lastSuppressedError.isEmpty() - ? tr("Could not open the flattened fiber. The previous annotation may still be busy.") - : _lastSuppressedError; - return nullptr; + if (!checkAndResolve(false)) { + return refuseKeeping(changedMessage); } - return dialog; -} - -LineAnnotationDialog* LineAnnotationController::openFiberReadOnly(const nlohmann::json& root, - const std::string& key, - QString* error) -{ - const auto fail = [error](const QString& message) -> LineAnnotationDialog* { - if (error) { - *error = message; + 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; + } } - return nullptr; - }; - for (const auto& pane : _panes) { - if (pane.session && pane.session->readOnly && pane.session->fiberFileName == key && pane.dialog) { - pane.dialog->raise(); - pane.dialog->activateWindow(); - return pane.dialog; + 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; } - std::optional fiber; - try { - fiber = loadFiberJson(root, fs::path(key)); - } catch (const std::exception& ex) { - return fail(tr("Could not read the fiber: %1").arg(QString::fromStdString(ex.what()))); + if (error) { + return refuseKeeping(*error); } - if (!fiber) { - return fail(tr("Not a VC3D fiber.")); + waitForFiberSaves(identityOnly); + if (!alive) { + return; } - // In memory only, for this view: no file name in the package, no source - // root, no links (nothing to resolve them against), never saved. - fiber->readOnly = true; - fiber->branches.clear(); - fiber->fileName = key; - fiber->sourceRoot.clear(); - fiber->needsSave = false; - fiber->id = nextFiberId(); - fiber->hvClassification = vc3d::line_annotation::classifyFiberHv( - vc3d::line_annotation::storedControlPointPositions(fiber->controlPoints)); - const uint64_t id = fiber->id; - _fibers.push_back(std::move(*fiber)); - auto* dialog = openCollectionFiber(id, error); - if (!dialog) { - std::erase_if(_fibers, [id](const StoredFiber& stored) { return stored.id == id && stored.readOnly; }); + 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); } - return dialog; + 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) @@ -3367,7 +5363,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; @@ -3520,7 +5516,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; } @@ -3716,7 +5712,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; } @@ -4762,23 +6758,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()); } } + 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(), @@ -5154,14 +7175,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( @@ -6217,6 +8232,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; @@ -6229,6 +8245,7 @@ LineAnnotationController::markLinkCandidateFiberIntersections( cv::norm(toVec3f(branch.controlPointPosition) - *marker.connectorStart) <= 1.0e-3) { marker.pendingBranchLink = true; + marker.autoPendingBranchLink = branch.autoLink; break; } } @@ -6414,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, @@ -6422,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()); @@ -7315,7 +9342,8 @@ LineAnnotationController::FiberMapSnapshot LineAnnotationController::fiberMapSna branch.branchControlPointIndex, branch.pending, branch.adjacent, - true}); + true, + branch.autoLink}); } snapshot.fibers.push_back(std::move(entry)); } @@ -7590,8 +9618,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; @@ -7601,20 +9630,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; @@ -7995,6 +10057,95 @@ 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; + // Placing beyond a lone end break: the new control is a break too, so + // the span between them is a gap (closed to placement, bridged by a + // cubic spline by the policy below) and the damaged papyrus past the + // break is never traced. session.controlPoints is still the pre-edit + // list here (the prepared snapshot already holds the inserted control). + if (const auto tagIndex = vc3d::line_annotation::appendedControlToTagAsBreak( + session.controlPoints, prepared)) { + vc3d::line_annotation::setControlPointTag( + prepared.controlPoints[*tagIndex].tags, vc3d::line_annotation::kBreakTag, true); + } + 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, @@ -8007,7 +10158,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 @@ -8020,14 +10174,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; } @@ -8132,20 +10283,76 @@ 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); + 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, + const std::function& mutated) +{ + // 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 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) { + return std::nullopt; } + LineAnnotationSession& session = *owned; const std::vector previousControls = session.controlPoints; @@ -8156,62 +10363,44 @@ 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 (edit.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); - } + edit.segmentLength = initialLineDiscretization(initialCenterlineLengthVx).segmentLength; + 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())), session.suppressErrorDialogs); - return; + return std::nullopt; } const bool editedExistingControl = !prepared.collapsedOldIndices.empty(); 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 @@ -8224,7 +10413,7 @@ void LineAnnotationController::handleGeneratedControlPoint(const std::string& su session.controlPointCollapseRollback = LineAnnotationSession::ControlPointCollapseRollback{ previousControls, - session.optimizedLine, + previousLine ? *previousLine : session.optimizedLine, session.optimizationReport, previousBranches, session.selectedManifestPath, @@ -8236,34 +10425,6 @@ void LineAnnotationController::handleGeneratedControlPoint(const std::string& su 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; - // Placing beyond a lone end break: the new control is a break too, so - // the span between them is a gap (closed to placement, bridged by a - // cubic spline by the policy below) and the damaged papyrus past the - // break is never traced. previousControls is the pre-edit list (the - // prepared snapshot already holds the inserted control). - if (const auto tagIndex = vc3d::line_annotation::appendedControlToTagAsBreak( - previousControls, prepared)) { - vc3d::line_annotation::setControlPointTag( - prepared.controlPoints[*tagIndex].tags, vc3d::line_annotation::kBreakTag, true); - } - 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; @@ -8275,7 +10436,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 = @@ -8297,6 +10457,12 @@ void LineAnnotationController::handleGeneratedControlPoint(const std::string& su 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) { @@ -8393,11 +10559,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, @@ -8427,17 +10602,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 @@ -8459,17 +10629,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"; @@ -8816,7 +10977,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. @@ -8831,6 +10995,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; } @@ -8867,25 +11035,84 @@ void LineAnnotationController::handleGeneratedControlPointLinkWithCandidate( showError(tr("The link candidate's fiber no longer exists.")); return; } - const auto farControlIndex = matchingStoredControlPointIndex( - farIt->controlPoints, - candidate.storedControlPointIndexHint, - candidate.position); - if (!farControlIndex) { - _linkCandidate.reset(); - showError(tr("The link candidate control point no longer exists.")); - return; + const auto farControlIndex = matchingStoredControlPointIndex( + farIt->controlPoints, + candidate.storedControlPointIndexHint, + candidate.position); + if (!farControlIndex) { + _linkCandidate.reset(); + showError(tr("The link candidate control point no longer exists.")); + return; + } + if (controlPointHasBranchLink(farIt->branches, + static_cast(*farControlIndex))) { + showError(tr("The link candidate is already linked; unlink it first.")); + return; + } + // 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; + } + if (error) { + showError(*error); + } +} + +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, + 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) + // 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(farIt->branches, - static_cast(*farControlIndex))) { - showError(tr("The link candidate is already linked; unlink it first.")); - return; + if (controlPointHasBranchLink(session.branches, controlPointIndex)) { + return tr("This control point is already linked; unlink it first."); } - const cv::Vec3d farPoint = farIt->controlPoints[static_cast(*farControlIndex)]; + 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( @@ -8897,21 +11124,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."); } - 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, adjacent, autoLink); const auto duplicateLocal = std::find_if( session.branches.begin(), @@ -8922,13 +11143,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( @@ -8943,17 +11175,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. @@ -8972,8 +11195,12 @@ void LineAnnotationController::handleGeneratedControlPointLinkWithCandidate( }); if (duplicateFar == farIt->branches.end()) { farIt->branches.push_back(reciprocal); + pushedReciprocal = reciprocal; } const StoredFiber farFiber = *farIt; + if (scheduledGenerations) { + *scheduledGenerations = {localFiber.generation, farFiber.generation}; + } scheduleFiberPairSave(localFiber, farFiber); auto localIt = std::find_if( @@ -8990,17 +11217,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), @@ -9008,17 +11240,25 @@ 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(); + // 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); 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), @@ -9027,25 +11267,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, @@ -9099,8 +11322,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; @@ -9353,6 +11589,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)) { @@ -9734,7 +11971,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; @@ -10029,7 +12266,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; @@ -10330,6 +12567,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); } } @@ -10357,6 +12597,7 @@ void LineAnnotationController::handleGeneratedControlPointSetLinkPending( candidate.controlPointIndex == updatedBranch.branchControlPointIndex && sameLocalEndpoint) { candidate.pending = pending; + candidate.autoLink = false; changed = true; } } @@ -10653,7 +12894,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 { @@ -10732,6 +12975,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); @@ -10890,7 +13139,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()) { @@ -10926,6 +13176,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."), @@ -12201,14 +14455,24 @@ 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, + bool* solveAsLasagna) +{ + if (solveAsLasagna) { + *solveAsLasagna = false; + } + // 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) { @@ -12216,8 +14480,116 @@ bool LineAnnotationController::finalizeSessionOptimizationSynchronously( session.suppressErrorDialogs); return false; } - const auto focusBoundsBase = _state ? _state->activeFocusBounds() - : std::nullopt; + // Every session solves through the fiber-mode solver, a single control + // too (its branch regenerates the line from the control, so the open + // tails follow the current tail policy). + if (session.controlPoints.empty()) { + 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; + }); + // A single control whose both tails are rays traces nothing, so the + // trace inputs are not needed for it. The model service is always + // ensured for a model session: its request installs the callbacks the + // solver requires whatever the tails do. + const bool rayOnly = vc3d::line_annotation::singleControlRayOnly(session.controlPoints); + if (needsTrace && (session.modelOnly || (fiberFollowEnabled() && !rayOnly))) { + 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 && !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. 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, " + "solving its tails as Lasagna"); + } else if (needsTrace && !rayOnly) { + 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, + bool solveAsLasagna) 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 request = makeFiberModeOptimizationRequest(session, false, std::move(dirtySegments)); + if (solveAsLasagna) { + request.globalMode = vc3d::line_annotation::FiberOptimizationMode::Lasagna; + } + auto optimized = + vc3d::line_annotation::optimizeFiberWithNativeFallback(std::move(request)); + 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; + } + bool solveAsLasagna = false; + if (!ensureSessionSolveBackends(session, stillValid, &solveAsLasagna)) { + return false; + } // Every session finalizes through the fiber-mode solver, a single // control too (its branch regenerates the line from the control, so the @@ -12225,67 +14597,11 @@ bool LineAnnotationController::finalizeSessionOptimizationSynchronously( // a kollesis end, the ordinary tails once such a tag is removed); the // asynchronous solves have always used it for one control as well. if (!session.controlPoints.empty()) { - 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; - }); - // A single control whose both tails are rays traces nothing, so the - // trace inputs are not needed for it. The model service is always - // ensured for a model session: its request installs the callbacks - // the solver requires whatever the tails do. - const bool rayOnly = vc3d::line_annotation::singleControlRayOnly(session.controlPoints); - bool solveAsLasagna = false; - if (needsTrace && (session.modelOnly || (fiberFollowEnabled() && !rayOnly))) { - auto* pane = paneForSurface(session.surfaceName); - if (!ensureFiberFollowService(session.suppressErrorDialogs, pane ? pane->dialog.data() : nullptr)) - return false; - } else if (needsTrace && !rayOnly && 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. - solveAsLasagna = true; - Logger()->info( - "Line annotation finalize: single control without trace inputs, " - "solving its tails as Lasagna"); - } else if (needsTrace && !rayOnly && - (!ensureFiberInferenceDatasetForSession(session) || - !session.fiberPredictionField || !session.traceNormalSampler)) { + OptimizationTaskResult task = runFiberModeSolveSynchronously( + session, std::nullopt, "segment_interpolation_final_full_line_opt", solveAsLasagna); + 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 request = makeFiberModeOptimizationRequest(session, false); - if (solveAsLasagna) { - request.globalMode = vc3d::line_annotation::FiberOptimizationMode::Lasagna; - } - auto optimized = - vc3d::line_annotation::optimizeFiberWithNativeFallback(std::move(request)); - 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), @@ -12595,7 +14911,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; @@ -12610,17 +14928,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; } @@ -14294,8 +16622,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"; }); @@ -14343,9 +16673,18 @@ 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 + // 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(); }); @@ -14869,8 +17208,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) { @@ -14879,9 +17225,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); } } } @@ -15424,6 +17774,7 @@ void LineAnnotationController::loadFibersForCurrentPackage() } } dropLinkEntriesFromOpenSessions(droppedAtLoad, _fibers); + downgradeAutomaticLinksInOpenSessions(_fibers); if (!loadErrors.empty()) { for (const auto& error : loadErrors) { Logger()->warn("{}", error); @@ -17926,6 +20277,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) { @@ -17943,7 +20328,7 @@ std::vector LineAnnotationController::syncBranchEndpointPositions( affectedFiberIds.push_back(fiberId); }; - auto updateReciprocal = [&session]( + auto updateReciprocal = [this, &session]( std::vector& targetBranches, const FiberBranchRef& sourceBranch, int targetControlPointIndex, @@ -17963,6 +20348,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; @@ -17983,6 +20372,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, @@ -18013,6 +20404,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 = @@ -18042,6 +20435,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 = @@ -18290,6 +20685,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, @@ -18574,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; }); @@ -18598,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); @@ -18640,7 +21065,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()); @@ -18649,13 +21075,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) { @@ -18867,6 +21303,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 @@ -18983,9 +21422,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)); } @@ -19261,6 +21698,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 @@ -19271,6 +21720,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; @@ -19288,20 +21742,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(); @@ -19314,7 +21785,11 @@ void LineAnnotationController::waitForFiberSaves() &QFutureWatcher::finished, &loop, &QEventLoop::quit); + connect(this, &QObject::destroyed, &loop, &QEventLoop::quit); loop.exec(); + if (!alive || (stillValid && !stillValid())) { + return; + } } } @@ -19560,8 +22035,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()) { @@ -19904,6 +22384,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 @@ -20042,6 +22593,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 44e6ac9bfb..7aee0ffcf8 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" @@ -174,6 +175,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 +282,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 +363,54 @@ 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 +732,15 @@ 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. + // 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, @@ -869,6 +943,27 @@ 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. + // 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, + size_t controlPointIndex, + uint64_t farFiberId, + int farControlIndex, + bool adjacent, + bool autoLink, + 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 @@ -900,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, @@ -934,15 +1043,48 @@ 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. + // solveAsLasagna (optional out): set when a single control without + // trace inputs is to be solved as Lasagna for this one synchronous solve + // (the close/save path must not open a dataset picker for one control); + // without the out-parameter that case is a refusal, as before. + bool ensureSessionSolveBackends(LineAnnotationSession& session, + const std::function& stillValid = {}, + bool* solveAsLasagna = nullptr); + // The synchronous fiber-mode solve as a result only (nothing applied): + // dirtySegments nullopt = every span (the finalizer's whole-line solve). + // solveAsLasagna: this one solve runs in Lasagna mode whatever the + // session's persisted optimization mode (see ensureSessionSolveBackends). + [[nodiscard]] OptimizationTaskResult runFiberModeSolveSynchronously( + const LineAnnotationSession& session, + std::optional> dirtySegments, + const std::string& eventName, + bool solveAsLasagna = false) 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); @@ -953,6 +1095,53 @@ 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); + // 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 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); + 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, + const std::function& mutated = {}); bool applyOptimizationTaskResult(LineAnnotationSession& session, OptimizationTaskResult task, bool updateGeneratedViews, @@ -985,7 +1174,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, @@ -1037,7 +1227,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); @@ -1191,6 +1384,125 @@ 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); + // 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; + vc3d::line_annotation::AutoLinkChoiceFacts facts; + }; + 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; + }; + 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); + // 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, const vc::lasagna::NormalSampler* normalSampler); @@ -1216,7 +1528,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) const; void saveFiberNow(StoredFiber& fiber) const; void scheduleFiberSave(const StoredFiber& fiber); @@ -1229,7 +1543,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); [[nodiscard]] static nlohmann::json fiberSaveSnapshotToJson( const FiberSaveSnapshot& snapshot); @@ -1303,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); @@ -1618,8 +1937,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 b7f134d522..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 @@ -5436,6 +5503,8 @@ void LineAnnotationDialog::updateGeneratedDynamicOverlaysFast(bool updateCurrent !_fastCurrentCutOverlayItems.pendingBranchControlPoints || !_fastCurrentCutOverlayItems.sameHvBranchControlPoints || !_fastCurrentCutOverlayItems.sameHvPendingBranchControlPoints || + !_fastCurrentCutOverlayItems.autoPendingBranchControlPoints || + !_fastCurrentCutOverlayItems.sameHvAutoPendingBranchControlPoints || !_fastCurrentCutOverlayItems.kollesisRings || !_fastCurrentCutOverlayItems.breakRings || !_fastCurrentCutOverlayItems.fiberIntersections || @@ -5519,6 +5588,24 @@ void LineAnnotationDialog::updateGeneratedDynamicOverlaysFast(bool updateCurrent sameHvPendingBranchControlBrush); _fastCurrentCutOverlayItems.sameHvPendingBranchControlPoints->setZValue(162.5); + // 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( + QBrush(vc3d::line_annotation::generatedLinkStateColor(true, sameHv, 175, true))); + item->setZValue(162.5); + }; + _fastCurrentCutOverlayItems.autoPendingBranchControlPoints = new QGraphicsPathItem(); + makeAutoPending(_fastCurrentCutOverlayItems.autoPendingBranchControlPoints, false); + _fastCurrentCutOverlayItems.sameHvAutoPendingBranchControlPoints = + new QGraphicsPathItem(); + makeAutoPending(_fastCurrentCutOverlayItems.sameHvAutoPendingBranchControlPoints, true); + // 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)); @@ -5604,6 +5691,8 @@ void LineAnnotationDialog::updateGeneratedDynamicOverlaysFast(bool updateCurrent _fastCurrentCutOverlayItems.pendingBranchControlPoints, _fastCurrentCutOverlayItems.sameHvBranchControlPoints, _fastCurrentCutOverlayItems.sameHvPendingBranchControlPoints, + _fastCurrentCutOverlayItems.autoPendingBranchControlPoints, + _fastCurrentCutOverlayItems.sameHvAutoPendingBranchControlPoints, _fastCurrentCutOverlayItems.kollesisRings, _fastCurrentCutOverlayItems.breakRings, _fastCurrentCutOverlayItems.fiberIntersections, @@ -5664,6 +5753,8 @@ void LineAnnotationDialog::updateGeneratedDynamicOverlaysFast(bool updateCurrent QPainterPath pendingBranchControlPath; QPainterPath sameHvBranchControlPath; QPainterPath sameHvPendingBranchControlPath; + QPainterPath autoPendingBranchControlPath; + QPainterPath sameHvAutoPendingBranchControlPath; QPainterPath kollesisRingPath; QPainterPath breakRingPath; const double lineRadius = @@ -5739,8 +5830,18 @@ void LineAnnotationDialog::updateGeneratedDynamicOverlaysFast(bool updateCurrent } }; if (control.hasPendingLinks) { - addLinkedMarker(control.hasSameHvPendingLinks ? sameHvPendingBranchControlPath - : pendingBranchControlPath); + // 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 (automatic) { + addLinkedMarker(control.hasSameHvPendingLinks ? sameHvAutoPendingBranchControlPath + : autoPendingBranchControlPath); + } else { + addLinkedMarker(control.hasSameHvPendingLinks ? sameHvPendingBranchControlPath + : pendingBranchControlPath); + } } else if (control.hasBranches) { addLinkedMarker(control.hasSameHvBranches ? sameHvBranchControlPath : branchControlPath); @@ -5750,6 +5851,10 @@ void LineAnnotationDialog::updateGeneratedDynamicOverlaysFast(bool updateCurrent controlPath.addEllipse(scenePoint, 10.0, 10.0); } } + _fastCurrentCutOverlayItems.autoPendingBranchControlPoints->setPath( + autoPendingBranchControlPath); + _fastCurrentCutOverlayItems.sameHvAutoPendingBranchControlPoints->setPath( + sameHvAutoPendingBranchControlPath); _fastCurrentCutOverlayItems.controlPoints->setPath(controlPath); _fastCurrentCutOverlayItems.seedPoints->setPath(seedPath); _fastCurrentCutOverlayItems.linkCandidatePoints->setPath(linkCandidatePath); @@ -6775,6 +6880,12 @@ void LineAnnotationDialog::rebuildOverviewBarLayout() dot.radius = control.isSeed ? 6.0 : 4.5; dot.triangle = control.isAdjacentLinkCandidate || (control.hasAdjacentLinks && !control.isLinkCandidate); + // 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 // link fill inside a yellow ring when linked. diff --git a/volume-cartographer/apps/VC3D/LineAnnotationDialog.hpp b/volume-cartographer/apps/VC3D/LineAnnotationDialog.hpp index 3495ab6fee..04a6dda2b2 100644 --- a/volume-cartographer/apps/VC3D/LineAnnotationDialog.hpp +++ b/volume-cartographer/apps/VC3D/LineAnnotationDialog.hpp @@ -91,6 +91,10 @@ class LineAnnotationDialog : public QMainWindow QGraphicsPathItem* pendingBranchControlPoints = nullptr; QGraphicsPathItem* sameHvBranchControlPoints = nullptr; QGraphicsPathItem* sameHvPendingBranchControlPoints = nullptr; + // Unreviewed automatic pending links: the automatic palette (cyan, + // pink same-H/V), solid like the other link states. + QGraphicsPathItem* autoPendingBranchControlPoints = nullptr; + QGraphicsPathItem* sameHvAutoPendingBranchControlPoints = 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. @@ -153,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; @@ -284,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, @@ -360,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 75c91a219d..be79ab83bd 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,205 @@ 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 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 +// 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 +390,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 +471,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..28d37e67d8 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); @@ -472,8 +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: 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; @@ -798,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) { @@ -884,7 +908,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 && @@ -893,9 +917,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, @@ -936,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, @@ -1246,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; @@ -1261,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) @@ -1306,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; } }; @@ -1607,14 +1663,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..d5c16ddc52 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. @@ -195,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; }; @@ -1622,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( @@ -1871,9 +1961,42 @@ 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); +// 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) +{ + 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); + +// 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 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 @@ -3211,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/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..b1b34585d7 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" @@ -209,7 +211,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 +240,52 @@ 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 (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; - 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); + 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 (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); + } } } } 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 57bbc8c096..949168bc60 100644 --- a/volume-cartographer/apps/VC3D/test/CMakeLists.txt +++ b/volume-cartographer/apps/VC3D/test/CMakeLists.txt @@ -529,6 +529,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..a02586fc30 --- /dev/null +++ b/volume-cartographer/apps/VC3D/test/test_fiber_map_auto_link.cpp @@ -0,0 +1,873 @@ +// 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: + 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. + 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)); + } +}; + +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); +} + +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 299294868d..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" @@ -7762,3 +7763,110 @@ 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()); + } +} + +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/core/test/test_line_annotation_link_validation.cpp b/volume-cartographer/core/test/test_line_annotation_link_validation.cpp index 1ab9a3dc01..c8772bcc0b 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::automaticLinkMarker; + 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(automaticLinkMarker(true, true)); + CHECK_FALSE(automaticLinkMarker(true, false)); + CHECK_FALSE(automaticLinkMarker(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 43bfb52701..646e2a8be8 100644 --- a/volume-cartographer/docs/line_annotation_fibers.md +++ b/volume-cartographer/docs/line_annotation_fibers.md @@ -379,6 +379,123 @@ 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; 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 + 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. + +### 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 +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. + +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 @@ -468,7 +585,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]