diff --git a/scripts/sketch-ladder.py b/scripts/sketch-ladder.py new file mode 100755 index 0000000000..e901829f78 --- /dev/null +++ b/scripts/sketch-ladder.py @@ -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 + 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) diff --git a/src/libslic3r/CAD/SketchEngine.cpp b/src/libslic3r/CAD/SketchEngine.cpp index 1f67f01a20..62ecc4c97d 100644 --- a/src/libslic3r/CAD/SketchEngine.cpp +++ b/src/libslic3r/CAD/SketchEngine.cpp @@ -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 SketchEngine::offset_entities( @@ -1087,60 +1097,97 @@ std::vector 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 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> items; // (entity index, reversed) + Vec2d start, end; // traversal start/end points + }; - std::vector 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 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 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; diff --git a/src/slic3r/GUI/CAD/DesignSketchTool.cpp b/src/slic3r/GUI/CAD/DesignSketchTool.cpp index 1f652f4580..cf172dac01 100644 --- a/src/slic3r/GUI/CAD/DesignSketchTool.cpp +++ b/src/slic3r/GUI/CAD/DesignSketchTool.cpp @@ -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 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 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)); } diff --git a/tests/libslic3r/test_sketchprofile.cpp b/tests/libslic3r/test_sketchprofile.cpp index 97705cd34d..2bbe0cd71e 100644 --- a/tests/libslic3r/test_sketchprofile.cpp +++ b/tests/libslic3r/test_sketchprofile.cpp @@ -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 half = { + line({0, -R}, {L, -R}), + arc({L, 0}, R, -M_PI / 2, M_PI / 2), + line({L, R}, {0, R}), + }; + std::vector 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)); +}