Port the exact loop area, the offset traversal fix, and the 2D sketch ladder

Carries snaporca 572f794c84, d0f9a0052a, 9f7e4e3627 and 3974f8a170. Parity holds: 17 files
identical, 8 diverging by their expected counts.

EXACT AREA. A loop's area is now integrated entity by entity in traversal order — Green's
theorem — instead of being shoelaced over the render polyline, which faceted every arc into 24
chords and lost 2.02 mm2 on a 3706.86 mm2 stadium. 0.054%, invisible on screen, and wrong in a
number reported as "the area".

OFFSET FOLLOWS THE TRAVERSAL. Offsetting a mirrored profile put one half on the wrong side and
split the loop in two, because the chainer only followed p1->p0 links and each entity's offset
side was taken from its stored direction. Chains are now orientation-aware, seeded at a free end,
offset by `reversed ? -d : d`, and normalised head-to-tail on the way out — so offset is correct
for any input ordering and its own output cannot reintroduce the problem.

Both are the same underlying lesson, which has now cost three separate defects: an entity's
STORED direction is not its direction of TRAVEL around the loop.

THE LADDER. scripts/sketch-ladder.py is a graded suite of 2D sketches judged the way a person
judges them — VERTEX, LENGTH, ARC, TANGENT, SYMMETRY, CLOSED — with area only as a cross-check,
because area is derived and nobody can confirm it by eye. Eight rungs from a rectangle up to
MPD5 from the StudyCadCam corpus, a dia 27 x 95 pin reproduced as its revolve half-profile with
the R5 fillet tangency solved exactly. Entirely 2D: no extrude or any solid feature.

Kernel here: all tests passed, 2681 assertions in 231 test cases, including the new
"profile: a mirrored half offsets as one loop, not two". GUI target builds and links.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tommaso Bianchi
2026-08-22 14:26:46 +02:00
co-authored by Claude Opus 5
parent 510e63dff2
commit cbbd24dcb4
4 changed files with 534 additions and 49 deletions
+89 -42
View File
@@ -1072,6 +1072,16 @@ bool off_join(SketchEntity& a, SketchEntity& b)
return true;
}
// Normalise an entity that was offset while traversed REVERSED to head-to-tail traversal order:
// swap its stored ends so p0 is the traversal start and p1 the traversal end. An arc must swap
// its stored sweep too, because "traversed the other way" reverses the stored sweep direction.
void off_reverse(SketchEntity& e)
{
std::swap(e.p0, e.p1);
if (e.type == SketchEntity::Type::Arc)
std::swap(e.start_angle, e.end_angle);
}
} // namespace
std::vector<SketchEntity> SketchEngine::offset_entities(
@@ -1087,60 +1097,97 @@ std::vector<SketchEntity> SketchEngine::offset_entities(
if (off_one(src[i], d, o)) out.push_back(o);
}
// Chain the open curves by shared endpoints. Greedy walk: start from an entity nobody
// precedes (an open chain's head), else from whatever is left (a closed loop).
std::vector<bool> used(open_idx.size(), false);
// Stored endpoints of the k-th open entity.
auto ends = [&](int k, Vec2d& p0, Vec2d& p1) { p0 = src[open_idx[k]].p0; p1 = src[open_idx[k]].p1; };
auto has_predecessor = [&](int k) {
Vec2d p0, p1; ends(k, p0, p1);
// An endpoint shared by no OTHER unused open curve is a FREE end: the loose end of an open
// chain rather than a seam. (`used` matters — entities already pulled into a chain must not
// count, otherwise the far end of the chain we just walked would look shared.)
auto is_free = [&](int k, const Vec2d& pt) {
for (size_t j = 0; j < open_idx.size(); ++j) {
if (int(j) == k || used[j]) continue;
Vec2d q0, q1; ends(int(j), q0, q1);
if (off_same(q1, p0)) return true;
if (off_same(pt, q0) || off_same(pt, q1)) return false;
}
return false;
return true;
};
for (size_t pass = 0; pass < 2; ++pass) {
for (size_t s = 0; s < open_idx.size(); ++s) {
if (used[s]) continue;
// Pass 0 seeds only open-chain heads, so an open chain is never entered mid-way
// (which would split it in two and lose a seam).
if (pass == 0 && has_predecessor(int(s))) continue;
struct Chain {
std::vector<std::pair<int, bool>> items; // (entity index, reversed)
Vec2d start, end; // traversal start/end points
};
std::vector<int> chain{ int(s) };
used[s] = true;
for (;;) {
Vec2d p0, p1; ends(chain.back(), p0, p1);
int nxt = -1;
for (size_t j = 0; j < open_idx.size(); ++j) {
if (used[j]) continue;
Vec2d q0, q1; ends(int(j), q0, q1);
if (off_same(p1, q0)) { nxt = int(j); break; }
}
if (nxt < 0) break;
used[nxt] = true;
chain.push_back(nxt);
// Walk one chain from the unused seed `s`, traversing AWAY from its free end. `reversed`
// means the traversal enters at the entity's p1 and leaves at its p0, i.e. the entity is
// travelled opposite to its STORED direction. An entity whose p1 is free (but p0 is not)
// is the head of an open chain and must start reversed; an isolated entity or a closed
// loop starts forward.
auto walk = [&](int s) -> Chain {
Chain c;
Vec2d s0, s1; ends(s, s0, s1);
const bool rev = !is_free(s, s0) && is_free(s, s1);
c.items.emplace_back(s, rev);
used[s] = true;
c.start = rev ? s1 : s0;
c.end = rev ? s0 : s1;
for (;;) {
int nxt = -1;
bool nrev = false;
for (size_t j = 0; j < open_idx.size(); ++j) {
if (used[j]) continue;
Vec2d q0, q1; ends(int(j), q0, q1);
if (off_same(c.end, q0)) { nxt = int(j); nrev = false; break; }
if (off_same(c.end, q1)) { nxt = int(j); nrev = true; break; }
}
Vec2d h0, h1, t0, t1;
ends(chain.front(), h0, h1);
ends(chain.back(), t0, t1);
const bool closed = chain.size() > 2 && off_same(t1, h0);
std::vector<SketchEntity> off;
for (int k : chain) {
SketchEntity o;
if (off_one(src[open_idx[k]], d, o)) off.push_back(o);
}
if (off.empty()) continue;
for (size_t i = 0; i + 1 < off.size(); ++i) off_join(off[i], off[i + 1]);
if (closed && off.size() > 1) off_join(off.back(), off.front());
for (auto& o : off) out.push_back(o);
if (nxt < 0) break;
c.items.emplace_back(nxt, nrev);
used[nxt] = true;
Vec2d q0, q1; ends(nxt, q0, q1);
c.end = nrev ? q0 : q1;
}
return c;
};
// Offset one chain as traversed: every entity is offset with an EFFECTIVE distance that
// already accounts for how it was walked, then reversed entities have their stored ends
// swapped so the emitted chain is head-to-tail in traversal order (which is what keeps the
// seam repair below — and any later offset/mirror — well-oriented). A chain whose final
// traversal end coincides with its first traversal start is CLOSED.
auto emit = [&](const Chain& c) {
const bool closed = c.items.size() > 1 && off_same(c.end, c.start);
std::vector<SketchEntity> off;
off.reserve(c.items.size());
for (const auto& it : c.items) {
// A reversed entity offsets with -d rather than +d:
// * a line walked backwards has its left-hand side on the other side, so -d;
// * an arc walked backwards has its sweep sign effectively flipped, which is exactly
// the `sgn` term off_one reads, so -d again.
const double ed = it.second ? -d : d;
SketchEntity o;
if (!off_one(src[open_idx[it.first]], ed, o)) continue;
if (it.second) off_reverse(o);
off.push_back(o);
}
if (off.empty()) return;
for (size_t i = 0; i + 1 < off.size(); ++i) off_join(off[i], off[i + 1]);
if (closed && off.size() > 1) off_join(off.back(), off.front());
for (auto& o : off) out.push_back(o);
};
// Pass 1: open chains, seeded at a free end so they are never entered mid-way (which would
// split one open chain in two and lose a seam).
for (size_t s = 0; s < open_idx.size(); ++s) {
if (used[s]) continue;
Vec2d s0, s1; ends(int(s), s0, s1);
if (!is_free(int(s), s0) && !is_free(int(s), s1)) continue;
emit(walk(int(s)));
}
// Pass 2: what is left has no free end and is a CLOSED loop; start anywhere, forward.
for (size_t s = 0; s < open_idx.size(); ++s) {
if (used[s]) continue;
emit(walk(int(s)));
}
return out;
+70 -7
View File
@@ -27,6 +27,11 @@
namespace Slic3r {
namespace GUI {
// How many chords an arc is drawn with. loop_report corrects each arc's area back to the true
// curve using this exact number, so the two MUST agree — changing it here without updating the
// correction silently biases every reported area.
static constexpr int kArcFacets = 24;
// Positioning helpers (defined lower down, used by the dimension methods above them).
static bool entity_ref_point(const SketchEntity& e, Vec2d& out);
static void translate_entity(SketchEntity& e, const Vec2d& d);
@@ -2814,7 +2819,7 @@ std::vector<Vec2d> DesignSketchTool::entity_polyline(const SketchEntity& e, bool
closed = true;
return circle_polygon(e.center, e.radius);
case SketchEntity::Type::Arc: {
const int n = 24;
const int n = kArcFacets;
std::vector<Vec2d> pts; pts.reserve(n + 1);
for (int i = 0; i <= n; ++i) {
const double a = e.start_angle + (e.end_angle - e.start_angle) * double(i) / double(n);
@@ -8883,12 +8888,70 @@ DesignSketchTool::LoopReport DesignSketchTool::loop_report() const
? M_PI * c.radius * c.radius
: M_PI * c.radius * c.rminor;
} else {
double a = 0.0;
for (size_t i = 0; i + 1 < r.poly.size(); ++i)
a += r.poly[i].x() * r.poly[i + 1].y() - r.poly[i + 1].x() * r.poly[i].y();
if (r.poly.size() > 2)
a += r.poly.back().x() * r.poly.front().y() - r.poly.front().x() * r.poly.back().y();
li.area = 0.5 * a;
// EXACT area by Green's theorem over the chain, entity by entity, with no faceting
// anywhere. The obvious alternative — shoelace over the render polyline — is short by
// the slivers between each arc and its chords: 2.02 mm2 on a 3706.86 mm2 stadium,
// 0.054%, invisible on screen and simply wrong in a number reported as "the area".
// Correcting the shoelace afterwards does NOT work: a mirrored arc stores a negated
// sweep, so two corrections that should add cancel instead. Integrating each entity
// in TRAVERSAL order sidesteps the sign question entirely.
// line A->B : x0*y1 - x1*y0
// arc a0->a1: Cx*r*(sin a1 - sin a0) - Cy*r*(cos a1 - cos a0) + r^2*(a1 - a0)
// Both are the integrand of the contour integral, so area2 accumulates 2*area and is
// halved once at the end. (Doubling the arc term instead reads 5913.72 on the stadium
// — exactly one arc's contribution too much, which is how the slip was caught.)
auto ent_ends = [&](int ei, Vec2d& a, Vec2d& b) {
a = m_entities[ei].p0; b = m_entities[ei].p1;
};
const double eps = 1e-6;
double area2 = 0.0;
bool exact = true;
Vec2d cur(0, 0);
for (size_t k = 0; k < r.ents.size(); ++k) {
const int ei = r.ents[k];
if (ei < 0 || ei >= int(m_entities.size())) { exact = false; break; }
const SketchEntity& e = m_entities[ei];
if (e.type != SketchEntity::Type::Line && e.type != SketchEntity::Type::Arc) {
exact = false; break; // spline / ellipse arc: fall back below
}
Vec2d A, B; ent_ends(ei, A, B);
bool rev = false;
if (k == 0) {
// Orient the first entity by whichever of its ends the SECOND one touches:
// that shared point is where this entity must finish.
if (r.ents.size() > 1) {
Vec2d C, D; ent_ends(r.ents[1], C, D);
if ((A - C).norm() < eps || (A - D).norm() < eps) rev = true;
}
} else {
if ((B - cur).norm() < eps) rev = true;
else if ((A - cur).norm() >= eps) { exact = false; break; }
}
const Vec2d P = rev ? B : A;
const Vec2d Q = rev ? A : B;
if (e.type == SketchEntity::Type::Line) {
area2 += P.x() * Q.y() - Q.x() * P.y();
} else {
const double a0 = rev ? e.end_angle : e.start_angle;
const double a1 = rev ? e.start_angle : e.end_angle;
area2 += e.center.x() * e.radius * (std::sin(a1) - std::sin(a0))
- e.center.y() * e.radius * (std::cos(a1) - std::cos(a0))
+ e.radius * e.radius * (a1 - a0);
}
cur = Q;
}
if (exact) {
li.area = 0.5 * area2;
} else {
// Splines and elliptical arcs have no closed form here; the render polyline is
// the honest best estimate, and it is flagged as such by being the fallback.
double a = 0.0;
for (size_t i = 0; i + 1 < r.poly.size(); ++i)
a += r.poly[i].x() * r.poly[i + 1].y() - r.poly[i + 1].x() * r.poly[i].y();
if (r.poly.size() > 2)
a += r.poly.back().x() * r.poly.front().y() - r.poly.front().x() * r.poly.back().y();
li.area = 0.5 * a;
}
}
out.loops.push_back(std::move(li));
}