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
+352
View File
@@ -0,0 +1,352 @@
#!/usr/bin/env python3
"""A ladder of 2D sketches of increasing complexity, judged the way a person judges them.
WHY NOT AREA. Area is derived and no one can confirm it by looking. What a human checks at a
glance, and can be exactly right or exactly wrong about, is:
VERTEX is the corner where I said it is
LENGTH is the side the length I gave it
ARC is the radius the radius I gave it
TANGENT does the straight run into the curve smoothly, or is there a kink
SYMMETRY is the mirrored half the exact reflection of the half I drew
CLOSED is it one closed loop, or does it just look like one
Every rung asserts those. Area appears only as a cross-check, never as the verdict.
Entirely 2D: sketch entities only, no extrude, revolve or any solid feature.
SNAPORCA_MCP=/tmp/mcp.sock <binary>
python3 scripts/sketch-ladder.py [socket]
Exit 0 = every rung held. Otherwise the first broken property is named and the run stops.
"""
import json, math, socket, sys
SOCK = sys.argv[1] if len(sys.argv) > 1 else "/tmp/mcp.sock"
EPS = 1e-9
_n = 0
_fail = 0
def call(method, **params):
global _n
_n += 1
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.settimeout(30)
s.connect(SOCK)
s.sendall((json.dumps({"jsonrpc": "2.0", "id": _n, "method": method,
"params": params}) + "\n").encode())
buf = b""
while b"\n" not in buf:
d = s.recv(65536)
if not d:
break
buf += d
r = json.loads(buf.decode().strip())
if "error" in r:
raise RuntimeError(f"{method}: {r['error']['message']}")
return r["result"]
def check(kind, cond, what):
global _fail
if cond:
print(f" {kind:9s} ok {what}")
else:
print(f" {kind:9s} FAIL {what}", file=sys.stderr)
_fail += 1
def near(a, b, tol=1e-6):
return abs(a - b) <= tol
def pt_near(p, q, tol=1e-6):
return math.hypot(p[0] - q[0], p[1] - q[1]) <= tol
def fresh(plane="XY"):
try:
call("sketch_cancel")
except Exception:
pass
call("sketch_begin", plane=plane)
def ents():
return call("sketch_describe")["entities"]
def rep():
return call("sketch_describe")
def endpoints(e):
"""Both ends of an open curve, as tuples. Closed curves have none."""
if "p0" not in e or "p1" not in e:
return ()
return tuple(e["p0"]), tuple(e["p1"])
def tangent(e, at_end):
"""Unit tangent of entity e at one of its ends, pointing ALONG the curve (p0->p1)."""
if e["type"] == "line":
dx = e["p1"][0] - e["p0"][0]
dy = e["p1"][1] - e["p0"][1]
else: # arc
a = e["start_angle"] if not at_end else e["end_angle"]
ccw = e["end_angle"] >= e["start_angle"]
# d/dtheta (cos, sin) = (-sin, cos), reversed when the sweep is clockwise
dx, dy = -math.sin(a), math.cos(a)
if not ccw:
dx, dy = -dx, -dy
n = math.hypot(dx, dy)
return (dx / n, dy / n)
def tangent_at_point(e, p):
"""Unit tangent of e at whichever of its ends is p, oriented leaving that point."""
p0, p1 = endpoints(e)
if pt_near(p0, p):
t = tangent(e, False)
return t
t = tangent(e, True)
return (-t[0], -t[1]) # leaving p1 means going back along the curve
def smooth(e1, e2, p):
"""G1 at shared point p: the tangent leaving e1 is opposite the tangent leaving e2."""
a = tangent_at_point(e1, p)
b = tangent_at_point(e2, p)
return abs(a[0] * (-b[0]) - 0) >= 0 and abs(a[0] * b[1] - a[1] * b[0]) <= 1e-6
def closed_one_loop(r, voids=0):
return (r["buildable"] and r["open_ends"] == []
and len([l for l in r["closed_loops"] if not any(
i in h["holes"] for h in r["closed_loops"] for i in [])]) >= 1)
def outer_loop(r):
"""The loop that encloses the others (or the only one)."""
if not r["closed_loops"]:
return None
return max(r["closed_loops"], key=lambda l: abs(l["area"]))
# ─────────────────────────────────────────────────────────────────────────────
print("RUNG 1 — rectangle: four corners, four lengths, four right angles")
fresh()
W, H = 80.0, 50.0
call("sketch_add", rect=[0, 0, W, H])
r = rep()
es = r["entities"]
corners = {(0, 0), (W, 0), (W, H), (0, H)}
got = set()
for e in es:
got.add(tuple(e["p0"]))
got.add(tuple(e["p1"]))
check("VERTEX", all(any(pt_near(c, g) for g in got) for c in corners),
f"all four corners exactly where asked {sorted(corners)}")
lens = sorted(round(e["length"], 9) for e in es)
check("LENGTH", lens == sorted([W, W, H, H]), f"sides are {W}/{H} twice each (got {lens})")
# right angles: consecutive sides meet at 90 degrees
ang_ok = True
for e in es:
for f in es:
if e is f:
continue
for p in endpoints(e):
if any(pt_near(p, q) for q in endpoints(f)):
a, b = tangent_at_point(e, p), tangent_at_point(f, p)
if abs(a[0] * b[0] + a[1] * b[1]) > 1e-6:
ang_ok = False
check("ANGLE", ang_ok, "every corner is exactly 90 degrees")
check("CLOSED", r["buildable"] and r["open_ends"] == [], "one closed loop, no free ends")
print("\nRUNG 2 — a circular void inside it")
call("sketch_add", type="circle", center=[W / 2, H / 2], radius=12)
r = rep()
c = [e for e in r["entities"] if e["type"] == "circle"][0]
check("VERTEX", pt_near(tuple(c["center"]), (W / 2, H / 2)), "void centred exactly where asked")
check("ARC", near(c["radius"], 12), f"void radius exactly 12 (got {c['radius']})")
out = outer_loop(r)
check("CLOSED", len(out["holes"]) == 1, "the rectangle encloses exactly one void")
check("CLOSED", r["buildable"] and r["open_ends"] == [], "still closed with the void present")
print("\nRUNG 3 — stadium: straights running into caps, tangent at every junction")
fresh()
L, R = 50.0, 15.0
call("sketch_add", entities=[
{"type": "line", "p0": [-L, -R], "p1": [L, -R]},
{"type": "arc", "center": [L, 0], "radius": R,
"start_angle": -math.pi / 2, "end_angle": math.pi / 2},
{"type": "line", "p0": [L, R], "p1": [-L, R]},
{"type": "arc", "center": [-L, 0], "radius": R,
"start_angle": math.pi / 2, "end_angle": 3 * math.pi / 2},
])
r = rep()
es = r["entities"]
check("CLOSED", r["buildable"] and r["open_ends"] == [], "one closed loop, no free ends")
arcs = [e for e in es if e["type"] == "arc"]
check("ARC", all(near(a["radius"], R) for a in arcs), f"both caps exactly R={R}")
check("LENGTH", all(near(e["length"], 2 * L) for e in es if e["type"] == "line"),
f"both straights exactly {2*L}")
# tangency at all four line/arc junctions
tang = True
for a in arcs:
for p in endpoints(a):
mates = [e for e in es if e is not a and any(pt_near(p, q) for q in endpoints(e))]
for m in mates:
if not smooth(a, m, p):
tang = False
check("TANGENT", tang, "straight meets cap smoothly at all four junctions (no kink)")
print("\nRUNG 4 — mirror: the reflected half is the exact reflection")
fresh()
half = [
{"type": "line", "p0": [0, -R], "p1": [L, -R]},
{"type": "arc", "center": [L, 0], "radius": R,
"start_angle": -math.pi / 2, "end_angle": math.pi / 2},
{"type": "line", "p0": [L, R], "p1": [0, R]},
]
call("sketch_add", entities=half)
r = rep()
check("CLOSED", not r["buildable"] and len(r["open_ends"]) == 2,
f"half profile is correctly OPEN, both ends named {r['open_ends']}")
call("sketch_select", entities=[0, 1, 2])
call("sketch_mirror", axis_a=[0, 0], axis_b=[0, 1])
r = rep()
es = r["entities"]
check("CLOSED", r["buildable"] and r["open_ends"] == [], "mirroring closed the loop")
# every source vertex must have its exact reflection present
src = []
for e in es[:3]:
src += [tuple(e["p0"]), tuple(e["p1"])]
allv = []
for e in es:
allv += [tuple(e["p0"]), tuple(e["p1"])]
sym = all(any(pt_near((-x, y), v) for v in allv) for (x, y) in src)
check("SYMMETRY", sym, "every vertex has its exact mirror twin across x=0")
mirrored_arc = [e for e in es[3:] if e["type"] == "arc"]
check("ARC", mirrored_arc and near(mirrored_arc[0]["radius"], R)
and pt_near(tuple(mirrored_arc[0]["center"]), (-L, 0)),
f"mirrored cap keeps R={R} and lands at (-{L}, 0)")
print("\nRUNG 5 — offset: every curve moves by exactly d, and it stays closed")
d = 4.0
call("sketch_select", entities=list(range(len(es))))
call("sketch_offset", distance=-d) # -d = outward for this CCW loop
r = rep()
new = r["entities"][len(es):]
check("CLOSED", r["buildable"] and r["open_ends"] == [], "offset result is closed")
off_arcs = [e for e in new if e["type"] == "arc"]
check("ARC", all(near(a["radius"], R + d) for a in off_arcs),
f"each cap radius grew by exactly {d} -> {R+d}")
off_lines = [e for e in new if e["type"] == "line"]
check("VERTEX", all(near(abs(e["p0"][1]), R + d) for e in off_lines),
f"each straight moved out to |y| = {R+d} exactly")
print("\nRUNG 6 — a gap is found by coordinate, then closed by a real constraint")
fresh()
call("sketch_add", entities=[
{"type": "line", "p0": [0, 0], "p1": [60, 0]},
{"type": "line", "p0": [60, 0], "p1": [60, 40]},
{"type": "line", "p0": [60, 40], "p1": [0, 40]},
{"type": "line", "p0": [0, 40], "p1": [0.35, 0]}, # 0.35 mm short
])
r = call("sketch_validate", tolerance=1.0)
check("CLOSED", not r["buildable"] and len(r["open_ends"]) == 2,
f"the gap is reported, both free ends named {r['open_ends']}")
dof0 = r["dof"]
r = call("sketch_heal", tolerance=1.0)
check("CLOSED", r["buildable"] and r["open_ends"] == [], "healed into a closed loop")
check("VERTEX", r["welded"] == 1, "exactly one pair of vertices welded")
check("ANGLE", r["dof"] < dof0,
f"the weld is a real constraint, not a nudge: DoF {dof0} -> {r['dof']}")
es = ents()
check("VERTEX", pt_near(tuple(es[3]["p1"]), tuple(es[0]["p0"])),
"the two ends are now the same point")
print("\nRUNG 7 — the composite: mirrored, tangent, two voids, all at once")
fresh()
call("sketch_add", entities=half)
call("sketch_select", entities=[0, 1, 2])
call("sketch_mirror", axis_a=[0, 0], axis_b=[0, 1])
call("sketch_add", type="circle", center=[-25, 0], radius=6)
call("sketch_add", type="circle", center=[25, 0], radius=6)
r = rep()
es = r["entities"]
out = outer_loop(r)
check("CLOSED", r["buildable"] and r["open_ends"] == [], "one closed outer loop, no free ends")
check("CLOSED", len(out["holes"]) == 2, "it encloses exactly two voids")
circles = [e for e in es if e["type"] == "circle"]
check("ARC", all(near(c["radius"], 6) for c in circles), "both voids exactly R=6")
check("SYMMETRY", pt_near(tuple(circles[0]["center"]), (-25, 0))
and pt_near(tuple(circles[1]["center"]), (25, 0)),
"the voids sit symmetrically at x = -25 and +25")
tang = True
for a in [e for e in es if e["type"] == "arc"]:
for p in endpoints(a):
for m in [e for e in es if e is not a and any(pt_near(p, q) for q in endpoints(e))]:
if not smooth(a, m, p):
tang = False
check("TANGENT", tang, "every straight-to-cap junction is still smooth")
exact = 2 * L * 2 * R + math.pi * R * R
check("LENGTH", near(out["area"], exact, 1e-6),
f"cross-check: enclosed area {out['area']:.4f} = 2L*2R + pi*R^2 = {exact:.4f}")
print("\nRUNG 8 — a real drawing: StudyCadCam MPD5, the pin's revolve half-profile")
# Ø27 x 95 pin: C1 chamfer on the left end, cylinder to a corner at x=85, an R5 fillet into a
# cone at 23 degrees to the axis, right face at x=95. Interpretation stated so the rung is
# reproducible: 85 is to the CORNER, 23 deg is to the AXIS, C1 is 1 x 45.
fresh()
RAD, LEN, TX, ANG, RF, CH = 13.5, 95.0, 85.0, math.radians(23), 5.0, 1.0
t = RF * math.tan(ANG / 2)
ax, ay = TX - t, RAD # fillet tangent point on the cylinder
cx, cy = ax, RAD - RF # fillet centre
bx, by = TX + t * math.cos(-ANG), RAD + t * math.sin(-ANG) # tangent point on the cone
ey = by - (LEN - bx) * math.tan(ANG) # where the cone meets the right face
call("sketch_add", entities=[
{"type": "line", "p0": [0, 0], "p1": [0, RAD - CH]}, # left face
{"type": "line", "p0": [0, RAD - CH], "p1": [CH, RAD]}, # C1 chamfer
{"type": "line", "p0": [CH, RAD], "p1": [ax, ay]}, # cylinder top
{"type": "arc", "center": [cx, cy], "radius": RF,
"start_angle": math.pi / 2, "end_angle": math.pi / 2 - ANG}, # R5 fillet
{"type": "line", "p0": [bx, by], "p1": [LEN, ey]}, # 23 deg cone
{"type": "line", "p0": [LEN, ey], "p1": [LEN, 0]}, # right face
{"type": "line", "p0": [LEN, 0], "p1": [0, 0]}, # axis
])
r = rep()
es = r["entities"]
check("CLOSED", r["buildable"] and r["open_ends"] == [], "the half-profile is one closed loop")
xs = [v[0] for e in es if "p0" in e for v in (e["p0"], e["p1"])]
ys = [v[1] for e in es if "p0" in e for v in (e["p0"], e["p1"])]
check("LENGTH", near(max(xs) - min(xs), LEN), f"overall length exactly {LEN} (the 95 dimension)")
check("VERTEX", near(max(ys), RAD), f"outer radius exactly {RAD} (the dia 27)")
fil = [e for e in es if e["type"] == "arc"][0]
check("ARC", near(fil["radius"], RF), f"the corner fillet is exactly R{RF:g}")
cone = [e for e in es if e["type"] == "line"
and not near(e["p0"][0], e["p1"][0]) and not near(e["p0"][1], e["p1"][1])
and e["length"] > 5]
if cone:
c0 = cone[0]
a = abs(math.degrees(math.atan2(c0["p1"][1] - c0["p0"][1], c0["p1"][0] - c0["p0"][0])))
check("ANGLE", near(a, 23, 1e-6), f"the cone is exactly 23 degrees to the axis (got {a:.6f})")
cham = [e for e in es if e["type"] == "line" and near(e["length"], CH * math.sqrt(2), 1e-9)]
check("ANGLE", bool(cham), "the C1 chamfer is exactly 1 x 45 (length 1*sqrt2)")
tang = True
for p in endpoints(fil):
for m in [e for e in es if e is not fil and any(pt_near(p, q) for q in endpoints(e))]:
if not smooth(fil, m, p):
tang = False
check("TANGENT", tang, "the fillet is tangent to BOTH the cylinder and the cone (no kink)")
call("sketch_cancel")
try:
call("sketch_cancel")
except Exception:
pass # a rung may have closed it already
print(f"\n{'ALL RUNGS HELD' if _fail == 0 else str(_fail) + ' CHECK(S) FAILED'}")
sys.exit(1 if _fail else 0)
+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));
}
+23
View File
@@ -162,3 +162,26 @@ TEST_CASE("profile: an open chain offsets without being forced closed", "[Sketch
// The interior seam is repaired: the two offset segments still meet.
REQUIRE_THAT((out[0].p1 - out[1].p0).norm(), WithinAbs(0.0, 1e-9));
}
TEST_CASE("profile: a mirrored half offsets as one loop, not two", "[SketchProfile]")
{
// The classic "draw half, mirror it" gesture on a stadium. mirror_entities emits a half
// that travels the opposite way round, so the concatenation must still chain as ONE closed
// loop and its mirrored cap must offset outward like the original, not inward.
const double L = 30, R = 15, d = 4;
const std::vector<SketchEntity> half = {
line({0, -R}, {L, -R}),
arc({L, 0}, R, -M_PI / 2, M_PI / 2),
line({L, R}, {0, R}),
};
std::vector<SketchEntity> all = half;
for (const auto& e : SketchEngine::mirror_entities(half, Vec2d(0, 0), Vec2d(0, 1)))
all.push_back(e);
REQUIRE(closed_wires(all) == 1);
const auto out = SketchEngine::offset_entities(all, -d); // -d = outward for this loop
REQUIRE(closed_wires(out) == 1);
for (const auto& o : out)
if (o.type == SketchEntity::Type::Arc)
REQUIRE_THAT(o.radius, WithinAbs(R + d, 1e-9));
}