diff --git a/scripts/docker-iter-build.sh b/scripts/docker-iter-build.sh index bd89e8c0b5..9b3d33dca1 100755 --- a/scripts/docker-iter-build.sh +++ b/scripts/docker-iter-build.sh @@ -38,7 +38,31 @@ echo "REPO=$REPO IMAGE=$IMAGE BUILD_VOL=$BUILD_VOL" # scripts/ likewise: build_linux.sh's packaging step sources scripts/appimage_lib_policy.sh, # which the baked snaporca tree does not have, so a fully successful link still exited # non-zero with "missing AppImage helper" and the binary check never ran. + +# ---- OOM guard (2026-08-21) ------------------------------------------------------------- +# Two of these builds ran at once on 2026-08-21, each with ninja -j$(nproc)=16: ~36 cc1plus +# holding 42 GB of a 62 GB box -> global OOM at 21:05, a 2h28m kill storm, ssh unreachable, +# lightdm destroyed. Neither build produced a single object. scripts/rig-build.sh grew the +# bounds first; every script that starts a compile needs the same three, or the guard is only +# as strong as the script you happened not to use. +# flock — the lock path is SHARED with rig-build.sh and the other fork on purpose, so +# concurrent builds serialise instead of summing. +# -j — bounded parallelism; ~1.17 GB per cc1plus was the measured average. +# --memory — the actual guarantee: a runaway build dies in its own cgroup instead of taking +# the host down. --memory-swap equal to --memory forbids swap, which is what made +# ssh hang. +JOBS="${JOBS:-12}" +MEM="${MEM:-40g}" +LOCK=/tmp/orca-rig-build.lock + +exec 9>"$LOCK" +if ! flock -n 9; then + echo "another build holds $LOCK — waiting (this is the OOM guard, not a hang)" + flock 9 +fi + docker run --rm \ + --memory="$MEM" --memory-swap="$MEM" \ -v "$REPO/src":/OrcaSlicer/src \ -v "$REPO/resources":/OrcaSlicer/resources \ -v "$REPO/CMakeLists.txt":/OrcaSlicer/CMakeLists.txt \ @@ -48,7 +72,7 @@ docker run --rm \ -v "$REPO/scripts":/OrcaSlicer/scripts \ -v "$BUILD_VOL":/OrcaSlicer/build \ "$IMAGE" \ - bash -lc 'cd /OrcaSlicer && ./build_linux.sh -sr' + bash -lc "cd /OrcaSlicer && ./build_linux.sh -sr -j $JOBS" # src/CMakeLists.txt:151 renames the OrcaSlicer target's output to "orca-slicer" — not # "snapmaker-orca", which is the other fork's binary name. diff --git a/scripts/kernel-test.sh b/scripts/kernel-test.sh index 4feb8be2f3..19a4bcb6f2 100755 --- a/scripts/kernel-test.sh +++ b/scripts/kernel-test.sh @@ -87,7 +87,31 @@ fi # tests/ is mounted too -- unlike docker-iter-build.sh, this script exists precisely to # compile tests being edited. CMakeLists.txt and cmake/ carry the SLIC3R_CAD gate; taking # them from the baked image instead leaves the gate off and the CAD symbols vanish. + +# ---- OOM guard (2026-08-21) ------------------------------------------------------------- +# Two of these builds ran at once on 2026-08-21, each with ninja -j$(nproc)=16: ~36 cc1plus +# holding 42 GB of a 62 GB box -> global OOM at 21:05, a 2h28m kill storm, ssh unreachable, +# lightdm destroyed. Neither build produced a single object. scripts/rig-build.sh grew the +# bounds first; every script that starts a compile needs the same three, or the guard is only +# as strong as the script you happened not to use. +# flock — the lock path is SHARED with rig-build.sh and the other fork on purpose, so +# concurrent builds serialise instead of summing. +# -j — bounded parallelism; ~1.17 GB per cc1plus was the measured average. +# --memory — the actual guarantee: a runaway build dies in its own cgroup instead of taking +# the host down. --memory-swap equal to --memory forbids swap, which is what made +# ssh hang. +JOBS="${JOBS:-12}" +MEM="${MEM:-40g}" +LOCK=/tmp/orca-rig-build.lock + +exec 9>"$LOCK" +if ! flock -n 9; then + echo "another build holds $LOCK — waiting (this is the OOM guard, not a hang)" + flock 9 +fi + docker run --rm \ + --memory="$MEM" --memory-swap="$MEM" \ -v "$REPO/src":/OrcaSlicer/src \ -v "$REPO/tests":/OrcaSlicer/tests \ -v "$REPO/resources":/OrcaSlicer/resources \ @@ -121,5 +145,5 @@ docker run --rm \ # src/CMakeLists.txt:92 while nothing about the kernel had changed. Turning the block off is # not a workaround for that one dependency; it is the kernel suite finally declaring what it # actually needs, so the next GUI-side dependency upstream adds cannot break it either. - cmake --build build --config Release --target libslic3r_tests + cmake --build build --config Release --target libslic3r_tests -- -j$JOBS ./build/tests/libslic3r/Release/libslic3r_tests '$TAGS' --order decl" diff --git a/scripts/mcp-sketch-smoke.py b/scripts/mcp-sketch-smoke.py new file mode 100755 index 0000000000..e4a3d71568 --- /dev/null +++ b/scripts/mcp-sketch-smoke.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Autonomous 2D-sketch loop: drive the Design tab's sketch layer over the MCP socket and +assert the things that decide whether a profile is buildable. + +WHY THIS EXISTS. The 2D layer used to be reachable only by clicking, so every question about it +("is this loop closed?", "did the offset survive?", "is the circle a void or a second body?") +cost a GUI session and a human. The socket verbs make each one a call, and this script is the +loop: build a known profile, ask the app what it thinks it has, compare against arithmetic. + +RUN IT AGAINST A RUNNING APP: + SNAPORCA_MCP=/tmp/mcp.sock # launch with the socket enabled + python3 scripts/mcp-sketch-smoke.py [socket] # default /tmp/mcp.sock + +Exit 0 = every assertion held. Anything else prints the first mismatch and stops. +""" +import json, math, socket, sys + +SOCK = sys.argv[1] if len(sys.argv) > 1 else "/tmp/mcp.sock" +_n = 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']}") + return r["result"] + + +def near(a, b, tol=1e-6): + return abs(a - b) < tol + + +def check(cond, what): + if not cond: + print(f"FAIL: {what}", file=sys.stderr) + sys.exit(1) + print(f" ok {what}") + + +def areas(rep): + return sorted(round(l["area"], 6) for l in rep["closed_loops"]) + + +print("1. a rectangle is one closed loop of exactly its own area") +try: + call("sketch_cancel") +except Exception: + pass +call("sketch_begin", plane="XY") +call("sketch_add", rect=[0, 0, 80, 50]) +r = call("sketch_describe") +check(r["buildable"], "buildable") +check(areas(r) == [4000.0], f"one loop of 4000 mm^2 (got {areas(r)})") + +print("2. a circle inside it is a VOID, not a second profile") +call("sketch_add", type="circle", center=[40, 25], radius=10) +r = call("sketch_describe") +outer = [l for l in r["closed_loops"] if near(l["area"], 4000.0)][0] +check(len(outer["holes"]) == 1, "the rectangle encloses exactly one void") +hole = r["closed_loops"][outer["holes"][0]] +check(near(hole["area"], math.pi * 100), f"the void is pi*r^2 (got {hole['area']})") + +print("3. offsetting the outer loop inward keeps it CLOSED and exact") +call("sketch_select", entities=[0, 1, 2, 3]) +call("sketch_offset", distance=5) +r = call("sketch_describe") +check(r["open_ends"] == [], "no open ends after the offset") +check(any(near(l["area"], 70 * 40) for l in r["closed_loops"]), + f"the offset loop is 70x40 (got {areas(r)})") + +print("4. a gap is REPORTED with its coordinates, then healed into a constraint") +call("sketch_cancel") +call("sketch_begin", plane="XY") +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.4, 0]}, # 0.4 mm short of closing +]) +r = call("sketch_validate", tolerance=1.0) +check(not r["buildable"], "a 0.4 mm gap makes the profile unbuildable") +check(len(r["open_ends"]) == 2, f"both free ends are named (got {r['open_ends']})") +dof_before = r["dof"] +r = call("sketch_heal", tolerance=1.0) +check(r["welded"] == 1, f"one pair welded (got {r['welded']})") +check(r["buildable"] and r["open_ends"] == [], "healed profile is buildable") +check(areas(r) == [2400.0], f"healed loop is 60x40 (got {areas(r)})") +check(r["dof"] < dof_before, + f"the weld recorded a real constraint: DoF {dof_before} -> {r['dof']}") + +print("5. construction geometry is excluded from the profile") +call("sketch_select", entities=[0]) +call("sketch_construction") +r = call("sketch_describe") +check(not r["buildable"], "turning one side into a guide opens the profile again") +call("sketch_construction") +r = call("sketch_describe") +check(r["buildable"], "turning it back closes it again") + +call("sketch_cancel") +print("\nall sketch assertions held") diff --git a/src/libslic3r/CAD/SketchEngine.cpp b/src/libslic3r/CAD/SketchEngine.cpp index e544dce9c1..1f67f01a20 100644 --- a/src/libslic3r/CAD/SketchEngine.cpp +++ b/src/libslic3r/CAD/SketchEngine.cpp @@ -900,52 +900,246 @@ std::vector SketchEngine::mirror_entities( return out; } +// ---- offset: chain-aware, with corner repair ------------------------------- +// +// Offsetting each entity on its own is geometrically correct per entity and USELESS as a +// sketch operation: a closed rectangle offset that way comes back as four parallel segments +// that no longer touch, so the result is four open wires and nothing can be extruded from it +// (measured — tests/libslic3r/test_sketchprofile.cpp). A profile is a chain, and the property +// that has to survive the operation is the chain, not the individual coordinates. +// +// So the offset runs in three steps: split the input into chains of entities joined by shared +// endpoints; offset every entity in a chain; then repair each seam by trimming/extending the +// two neighbours to the intersection of their offset supports (a miter join). Closed chains +// get their last-to-first seam repaired too, which is what makes the result closed again. +namespace { + +constexpr double kOffJoinEps = 1e-6; + +bool off_same(const Vec2d& a, const Vec2d& b) { return (a - b).squaredNorm() < kOffJoinEps * kOffJoinEps; } + +// Does this entity type take part in chaining (i.e. does it have two ends)? +bool off_is_open_curve(const SketchEntity& e) +{ + return e.type == SketchEntity::Type::Line || e.type == SketchEntity::Type::Arc; +} + +// Infinite-support intersections. Each returns the candidate closest to `seed`, which is where +// the seam is expected to land, so the branch choice never depends on entity orientation. +bool off_pick(const std::vector& cands, const Vec2d& seed, Vec2d& out) +{ + if (cands.empty()) return false; + double best = std::numeric_limits::max(); + for (const Vec2d& c : cands) { + const double d = (c - seed).squaredNorm(); + if (d < best) { best = d; out = c; } + } + return true; +} + +bool off_line_line(const Vec2d& a0, const Vec2d& a1, const Vec2d& b0, const Vec2d& b1, + const Vec2d& seed, Vec2d& out) +{ + const Vec2d da = a1 - a0, db = b1 - b0; + const double den = da.x() * db.y() - da.y() * db.x(); + if (std::abs(den) < 1e-12) return false; // parallel: no miter exists + const Vec2d w = b0 - a0; + const double t = (w.x() * db.y() - w.y() * db.x()) / den; + out = a0 + t * da; + (void)seed; + return true; +} + +std::vector off_line_circle(const Vec2d& p0, const Vec2d& p1, const Vec2d& c, double r) +{ + std::vector out; + Vec2d d = p1 - p0; + const double dd = d.squaredNorm(); + if (dd < 1e-18 || r <= 0.0) return out; + const Vec2d f = p0 - c; + const double b = 2.0 * f.dot(d), cc = f.squaredNorm() - r * r; + const double disc = b * b - 4.0 * dd * cc; + if (disc < 0.0) return out; + const double sq = std::sqrt(disc); + out.push_back(p0 + ((-b - sq) / (2.0 * dd)) * d); + out.push_back(p0 + ((-b + sq) / (2.0 * dd)) * d); + return out; +} + +std::vector off_circle_circle(const Vec2d& c0, double r0, const Vec2d& c1, double r1) +{ + std::vector out; + const Vec2d d = c1 - c0; + const double L = d.norm(); + if (L < 1e-12 || L > r0 + r1 || L < std::abs(r0 - r1)) return out; + const double a = (r0 * r0 - r1 * r1 + L * L) / (2.0 * L); + const double h2 = r0 * r0 - a * a; + const double h = h2 > 0.0 ? std::sqrt(h2) : 0.0; + const Vec2d u = d / L, n(-u.y(), u.x()); + out.push_back(c0 + a * u + h * n); + out.push_back(c0 + a * u - h * n); + return out; +} + +// Move one end of an entity to `q`, keeping the entity's kind consistent (an arc re-derives +// the parametric angle from its centre, and its sweep direction is preserved). +void off_set_end(SketchEntity& e, bool at_end, const Vec2d& q) +{ + if (e.type == SketchEntity::Type::Line) { + (at_end ? e.p1 : e.p0) = q; + return; + } + if (e.type != SketchEntity::Type::Arc) return; + const bool ccw = e.end_angle >= e.start_angle; + const double ang = std::atan2(q.y() - e.center.y(), q.x() - e.center.x()); + if (at_end) { + e.p1 = q; + double a = ang; + if (ccw) { while (a < e.start_angle) a += 2.0 * M_PI; while (a - e.start_angle > 2.0 * M_PI) a -= 2.0 * M_PI; } + else { while (a > e.start_angle) a -= 2.0 * M_PI; while (e.start_angle - a > 2.0 * M_PI) a += 2.0 * M_PI; } + e.end_angle = a; + } else { + e.p0 = q; + double a = ang; + if (ccw) { while (a > e.end_angle) a -= 2.0 * M_PI; while (e.end_angle - a > 2.0 * M_PI) a += 2.0 * M_PI; } + else { while (a < e.end_angle) a += 2.0 * M_PI; while (a - e.end_angle > 2.0 * M_PI) a -= 2.0 * M_PI; } + e.start_angle = a; + } +} + +// Offset ONE entity, unrepaired. Returns false for the kinds v1 does not offset. +bool off_one(const SketchEntity& e, double d, SketchEntity& out) +{ + switch (e.type) { + case SketchEntity::Type::Line: { + Vec2d t = e.p1 - e.p0; + if (t.norm() < 1e-12) return false; + t.normalize(); + const Vec2d n(-t.y(), t.x()); + out = e; + out.p0 = e.p0 + d * n; + out.p1 = e.p1 + d * n; + return true; + } + case SketchEntity::Type::Circle: { + const double r = e.radius + d; + if (r <= 1e-9) return false; + out = e; out.radius = r; out.p0 = out.center; + return true; + } + case SketchEntity::Type::Arc: { + // Same convention as the Line above: +d moves the curve to the LEFT of its direction + // of travel. For a CCW arc the left side is the inside, so the radius SHRINKS; for a + // CW arc it grows. Reading the sign off the sweep is what keeps a stadium outline + // (lines + caps) offsetting as one body instead of the lines going one way and the + // caps the other — which is what a plain `radius + d` did. + const double sgn = (e.end_angle >= e.start_angle) ? -1.0 : 1.0; + const double r = e.radius + sgn * d; + if (r <= 1e-9) return false; + out = e; + out.radius = r; + out.p0 = e.center + r * Vec2d(std::cos(e.start_angle), std::sin(e.start_angle)); + out.p1 = e.center + r * Vec2d(std::cos(e.end_angle), std::sin(e.end_angle)); + return true; + } + default: + // Point has nothing to offset; a true parallel of an ellipse is not an ellipse and of a + // spline is not a same-degree spline, so both stay out of v1 rather than lie about it. + return false; + } +} + +// Repair the seam between `a`'s end and `b`'s start: both are trimmed/extended to the +// intersection of their infinite supports nearest the gap. Returns false when no such point +// exists (parallel lines, non-intersecting circles), in which case the seam stays open. +bool off_join(SketchEntity& a, SketchEntity& b) +{ + const Vec2d seed = 0.5 * (a.p1 + b.p0); + Vec2d q; + const bool aL = a.type == SketchEntity::Type::Line; + const bool bL = b.type == SketchEntity::Type::Line; + if (aL && bL) { + if (!off_line_line(a.p0, a.p1, b.p0, b.p1, seed, q)) return false; + } else if (aL) { + if (!off_pick(off_line_circle(a.p0, a.p1, b.center, b.radius), seed, q)) return false; + } else if (bL) { + if (!off_pick(off_line_circle(b.p0, b.p1, a.center, a.radius), seed, q)) return false; + } else { + if (!off_pick(off_circle_circle(a.center, a.radius, b.center, b.radius), seed, q)) return false; + } + off_set_end(a, true, q); + off_set_end(b, false, q); + return true; +} + +} // namespace + std::vector SketchEngine::offset_entities( const std::vector& src, double d) { std::vector out; - for (const auto& e : src) { - switch (e.type) { - case SketchEntity::Type::Line: { - Vec2d t = e.p1 - e.p0; - if (t.norm() < 1e-12) continue; - t.normalize(); - Vec2d n(-t.y(), t.x()); - SketchEntity o = e; - o.p0 = e.p0 + d * n; - o.p1 = e.p1 + d * n; - out.push_back(o); - break; + // Closed and unchainable kinds first: they carry no seams, so they pass straight through. + std::vector open_idx; + for (int i = 0; i < int(src.size()); ++i) { + if (off_is_open_curve(src[i])) { open_idx.push_back(i); continue; } + SketchEntity o; + 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); + 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); + 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; } - case SketchEntity::Type::Circle: { - double r = e.radius + d; - if (r <= 1e-9) continue; - SketchEntity o = e; - o.radius = r; - o.p0 = o.center; - out.push_back(o); - break; - } - case SketchEntity::Type::Arc: { - double r = e.radius + d; - if (r <= 1e-9) continue; - SketchEntity o = e; - o.radius = r; - o.p0 = e.center + r * Vec2d(std::cos(e.start_angle), std::sin(e.start_angle)); - o.p1 = e.center + r * Vec2d(std::cos(e.end_angle), std::sin(e.end_angle)); - out.push_back(o); - break; - } - case SketchEntity::Type::Point: - continue; - case SketchEntity::Type::Ellipse: - case SketchEntity::Type::EllipseArc: - // A true parallel offset of an ellipse is not an ellipse; skip in v1. - continue; - case SketchEntity::Type::BSpline: - // Offset of a spline is not a same-degree spline; skip in v1. - continue; + return false; + }; + + 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; + + 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); + } + + 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); } } diff --git a/src/libslic3r/CAD/SketchEngine.hpp b/src/libslic3r/CAD/SketchEngine.hpp index 26eac1130d..1f5d3851fa 100644 --- a/src/libslic3r/CAD/SketchEngine.hpp +++ b/src/libslic3r/CAD/SketchEngine.hpp @@ -233,6 +233,12 @@ public: static std::vector mirror_entities( const std::vector& src, const Vec2d& a, const Vec2d& b); + // Offset a sketch by `d`, PRESERVING CHAINS. Entities joined by shared endpoints are + // offset together and their seams repaired (miter join), so a closed profile comes back + // closed and can still be extruded; per-entity offsetting cannot do that. Sign convention: + // +d moves each curve to the LEFT of its direction of travel, which for a CCW closed loop + // is inward. Ellipses and splines are not offset (a parallel of either is not the same + // kind of curve) and are dropped from the result. static std::vector offset_entities( const std::vector& src, double d); diff --git a/src/libslic3r/CAD/SketchInference.cpp b/src/libslic3r/CAD/SketchInference.cpp index d5b40581a5..b2d3487eb1 100644 --- a/src/libslic3r/CAD/SketchInference.cpp +++ b/src/libslic3r/CAD/SketchInference.cpp @@ -64,11 +64,17 @@ InferenceSnap infer_point_snap(const std::vector& entities, } break; } - case SketchEntity::Type::Arc: + case SketchEntity::Type::Arc: { offer(InferenceSnap::Kind::Endpoint, ei, SketchPointRole::P0, e.p0); offer(InferenceSnap::Kind::Endpoint, ei, SketchPointRole::P1, e.p1); offer(InferenceSnap::Kind::Center, ei, SketchPointRole::Center, e.center); + // Mid-arc point, so an arc is as snappable in its middle as a line is. + const double am = 0.5 * (e.start_angle + e.end_angle); + offer(InferenceSnap::Kind::Midpoint, ei, SketchPointRole::P0, + Vec2d(e.center.x() + e.radius * std::cos(am), + e.center.y() + e.radius * std::sin(am))); break; + } case SketchEntity::Type::Circle: { offer(InferenceSnap::Kind::Center, ei, SketchPointRole::Center, e.center); // Nearest point on the circle rim (PointOnObject candidate). diff --git a/src/slic3r/GUI/CAD/DesignCanvas.cpp b/src/slic3r/GUI/CAD/DesignCanvas.cpp index e1d44b3ce5..159848f38b 100644 --- a/src/slic3r/GUI/CAD/DesignCanvas.cpp +++ b/src/slic3r/GUI/CAD/DesignCanvas.cpp @@ -473,6 +473,20 @@ void DesignCanvas::set_sketch_construction(bool c) m_sketch_tool.set_construction(c); } +bool DesignCanvas::edit_sketch_selection_value() +{ + const bool ok = m_sketch_tool.open_selection_dimension_editor(); + if (ok) request_repaint(); + return ok; +} + +int DesignCanvas::toggle_sketch_construction_selection() +{ + const int n = m_sketch_tool.toggle_selection_construction(); + if (n > 0) request_repaint(); + return n; +} + bool DesignCanvas::add_sketch_regions( const std::vector>>& regions) { @@ -970,6 +984,12 @@ void DesignCanvas::set_on_context_menu(std::function cb) const bool terminated = m_sketch_tool.take_right_consumed(); if (m_on_context_menu && !terminated && !inline_busy() && std::max(std::abs(d.x), std::abs(d.y)) <= 8) { + // The menu belongs to what you POINTED AT. Pick first, so a right-click on a line + // offers that line's verbs instead of the empty-selection vocabulary. Selecting an + // entity that is already selected is a no-op, so a multi-entity pick survives a + // right-click on one of its members. + if (m_canvas && m_sketch_tool.select_at_screen(*m_canvas, e.GetX(), e.GetY())) + request_repaint(); m_on_context_menu(m_canvas_widget->ClientToScreen(e.GetPosition())); return; // consumed } diff --git a/src/slic3r/GUI/CAD/DesignCanvas.hpp b/src/slic3r/GUI/CAD/DesignCanvas.hpp index e0c61cdc61..17d601a415 100644 --- a/src/slic3r/GUI/CAD/DesignCanvas.hpp +++ b/src/slic3r/GUI/CAD/DesignCanvas.hpp @@ -54,6 +54,11 @@ public: void set_sketch_tool(DesignSketchTool::Mode mode); void set_sketch_plane(const SketchPlane& plane); // re-plane the live sketch when a reference plane is clicked in 3D void set_sketch_construction(bool c); + // Flip the sketch selection between construction and real geometry; returns the + // number of entities changed (0 = nothing selected, caller falls back to the mode). + // Open the in-canvas value field on the sketch selection's defining number. + bool edit_sketch_selection_value(); + int toggle_sketch_construction_selection(); // Text / SVG art into the LIVE sketch, as ordinary editable lines. False = no session. bool add_sketch_regions(const std::vector>>& regions); void set_sketch_polygon_sides(int n); @@ -288,6 +293,12 @@ public: // cycle); software GL (llvmpipe etc.) gets a direct render() because a // scheduled Refresh() is frequently dropped there. Backend cached on first use. // Public: DesignPanel calls it after a tree edit to force a frame on software GL. + // Scripted (MCP) access to the live sketch. One accessor rather than a passthrough per + // verb: the MCP layer drives the SAME tool the mouse drives, which is the whole point of + // having it — a socket that talked to a private copy would prove nothing about the app. + DesignSketchTool& mcp_sketch_tool() { return m_sketch_tool; } + const DesignSketchTool& mcp_sketch_tool() const { return m_sketch_tool; } + void request_repaint(); // Repaint synchronously, once the pending show/resize has settled. Needed when the // notebook re-shows the Design page: an invalidation issued while the page is still diff --git a/src/slic3r/GUI/CAD/DesignOffer.hpp b/src/slic3r/GUI/CAD/DesignOffer.hpp index 6fb95d48e3..2c3038b183 100644 --- a/src/slic3r/GUI/CAD/DesignOffer.hpp +++ b/src/slic3r/GUI/CAD/DesignOffer.hpp @@ -171,9 +171,17 @@ static const OfferVerb kOfferVerbs[] = { {"constrain", "Constrain sketch", 7, nullptr, "btn:constrain", "Select a sketch to constrain it", 0x00004000u, 0, 1, false, false, nullptr, "design_constrain", "Add dimensions and relations (coincident, tangent, parallel...) to the selected sketch"}, {"sk_construct", "Construction", 6, "Q", "key:Q", nullptr, 0x000b8000u, 0, 0, false, true, nullptr, nullptr, "Toggle construction: geometry that guides but is never built"}, {"sk_extend", "Extend", 7, "X", "key:X", nullptr, 0x000b0000u, 0, 0, false, true, nullptr, "design_extend", "Extend — click a line/arc to extend it"}, - {"sk_delete", "Delete", 7, "Del", "btn:delete", nullptr, 0x000f0000u, 0, 0, false, true, nullptr, "design_delete", "Delete the selected sketch entities"}, + {"sk_delete", "Delete", 7, "Del", "btn:sk_delete", nullptr, 0x000f0000u, 0, 0, false, true, nullptr, "design_delete", "Delete the selected sketch entities"}, + // Typing the defining number of the element you pointed at. Three rows rather than one so + // each names the quantity in the drawing-office word for THAT element; all three land on + // the same handler, because dimension_kind() already resolves the quantity from the + // selection. Without these, an element's own numbers were reachable only by arming the + // Dimension tool and re-picking geometry that was already selected. + {"sk_length", "Length…", 7, "V", "key:V", nullptr, 0x00010000u, 0, 0, false, true, nullptr, "design_dimension", "Type the length of this line"}, + {"sk_radius", "Radius / diameter…", 7, "V", "key:V", nullptr, 0x00020000u, 0, 0, false, true, nullptr, "design_dimension", "Type the radius of this arc, or the diameter of this circle"}, + {"sk_angdist", "Angle / distance…", 7, "V", "key:V", nullptr, 0x00080000u, 0, 0, false, true, nullptr, "design_dimension", "Type the angle between two lines, or the distance between the two picks"}, }; -static const int kOfferVerbCount = 87; +static const int kOfferVerbCount = 90; }} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/CAD/DesignPanel.cpp b/src/slic3r/GUI/CAD/DesignPanel.cpp index 52b6fd464c..47bf104252 100644 --- a/src/slic3r/GUI/CAD/DesignPanel.cpp +++ b/src/slic3r/GUI/CAD/DesignPanel.cpp @@ -426,8 +426,28 @@ DesignPanel::DesignPanel(wxWindow* parent) select_tool(DesignSketchTool::Mode::Polygon, _L("Polygon — click center, then a vertex")); }; // Constrain (finish the live sketch + enter constrain), and Construction toggle. + // V for Value: type the defining number of whatever is selected — a line's length, an arc's + // radius, a circle's diameter, the angle between two lines. One handler behind three offer + // rows, because the quantity comes from the selection, not from which row was clicked. + // + // It needs a KEY, not just a menu row. The deck profile in VSD_n1_streamcontroller is + // generated from these two key tables, so a verb with no shortcut cannot be put on a + // physical button at all — which is the whole point of that profile for a user who drives + // the app from buttons rather than a menu. + m_keys_sketch['V'] = [this] { + if (m_viewport && m_viewport->is_sketching() && !m_viewport->edit_sketch_selection_value()) + set_status(_L("Nothing here has a value to type — pick a line, an arc, a circle, or two entities")); + }; m_keys_sketch['K'] = [this] { enter_constrain_inline(); }; m_keys_sketch['Q'] = [this] { + // With geometry selected, Q converts THAT geometry (snaporca-6zic) — the reading + // everyone arrives with from other sketchers. With nothing selected it keeps its + // old meaning: arm construction for whatever you draw next. + if (m_viewport && m_viewport->is_sketching() && + m_viewport->toggle_sketch_construction_selection() > 0) { + set_status(_L("Converted the selection between construction and real geometry")); + return; + } if (m_construction) { m_construction->SetValue(!m_construction->GetValue()); if (m_viewport && m_viewport->is_sketching()) @@ -1007,6 +1027,16 @@ DesignPanel::DesignPanel(wxWindow* parent) fadd("color", b_color); m_verb_actions["btn:colour"] = [this] { on_set_body_color(); }; m_verb_actions["btn:delete"] = [this] { on_delete_feature(); }; + // The sketch's own Delete. It used to share "btn:delete" with the feature tree, so + // choosing Delete on a selected LINE ran on_delete_feature() and removed a tree row (or + // nothing) while the line stayed — the reported "I click a line and cannot remove it". + m_verb_actions["btn:sk_delete"] = [this] { + if (m_viewport && m_viewport->is_sketching()) + m_viewport->delete_selected_sketch_entities(); + else + on_delete_feature(); + }; + m_verb_actions["btn:delete_body"] = [this] { on_delete_body(); }; m_verb_actions["btn:edit"] = [this] { on_edit_feature(); }; m_verb_actions["btn:mass"] = [this] { on_mass_properties(); }; diff --git a/src/slic3r/GUI/CAD/DesignPanel.hpp b/src/slic3r/GUI/CAD/DesignPanel.hpp index 1fe60c8f04..3aaa057c00 100644 --- a/src/slic3r/GUI/CAD/DesignPanel.hpp +++ b/src/slic3r/GUI/CAD/DesignPanel.hpp @@ -61,6 +61,18 @@ public: // perceive the SAME kernel the GUI uses. Called only on the wx main thread. CadDocument& mcp_doc() { return m_doc; } // live document (read + mutate) void mcp_after_change() { after_tree_edit(true); } // refresh tree + viewport + status + DesignCanvas* mcp_viewport() { return m_viewport; } // live sketch + 3D view + // Put the PANEL into (or out of) sketch mode, not just the canvas tool. Measured on the + // rig: a sketch started straight through DesignCanvas::begin_sketch leaves m_ui_mode at + // Feature, and the keyboard map is dispatched on `m_ui_mode == UiMode::Sketch` while the + // offer menu is dispatched on the looser sketch_map_applies() — so the menu offered the + // line's verbs while every sketch shortcut was dead (KEYTRACE: key=81 ui_mode=0 + // is_sketching=1). Half-entering a mode is worse than not entering it. + void mcp_set_sketch_mode(bool on) + { + set_ui_mode(on ? UiMode::Sketch : UiMode::Feature); + update_action_bar(); + } private: enum class Tool { None, Sketch, Extrude, Dressup, Hole, Thread, Shell, Revolve, Sweep, Pattern, Plane, Loft, Draft, Boolean, Cut, Insert, Axis, CoordSys, SurfaceExtrude, SurfaceRevolve, SurfaceLoft, SurfaceFill, SurfaceOffset, ThickenSurface, Transform, Mirror, Thicken, Rib, Project, DeleteFace, Helix, Mate }; diff --git a/src/slic3r/GUI/CAD/DesignSketchTool.cpp b/src/slic3r/GUI/CAD/DesignSketchTool.cpp index d1bca847dc..82e5efdbde 100644 --- a/src/slic3r/GUI/CAD/DesignSketchTool.cpp +++ b/src/slic3r/GUI/CAD/DesignSketchTool.cpp @@ -331,6 +331,31 @@ void DesignSketchTool::delete_selected() if (on_selection_changed) on_selection_changed(0); } +// Convert the selection to/from construction geometry (snaporca-6zic). The Construction +// checkbox only ever set the mode for what you draw NEXT, so a line drawn as real geometry +// could never become a guide, nor a guide become real. Whole Feature groups flip together: +// a rectangle is four Line entities and converting three of them is never what was meant. +int DesignSketchTool::toggle_selection_construction() +{ + if (!selection_valid() || m_selection.empty()) return 0; + std::vector hit(m_entities.size(), false); + for (int i : m_selection) { + const int f = feature_of(i); + if (f >= 0) + for (int k = m_features[f].begin; k < m_features[f].end; ++k) hit[k] = true; + else + hit[i] = true; + } + // One direction for the whole batch: any real geometry in it -> all become construction. + bool any_real = false; + for (size_t i = 0; i < hit.size(); ++i) + if (hit[i] && !m_entities[i].construction) { any_real = true; break; } + int n = 0; + for (size_t i = 0; i < hit.size(); ++i) + if (hit[i] && m_entities[i].construction != any_real) { m_entities[i].construction = any_real; ++n; } + return n; +} + bool DesignSketchTool::selection_valid() const { for (int i : m_selection) @@ -1087,6 +1112,43 @@ void DesignSketchTool::open_angle_editor(int ei) []() {}); } +// Type the defining number of whatever is selected. One entry point for every 2D element, so +// the gesture is the same whichever tool drew it: point at it, right-click, type the value. +// +// This is what a sketch element was missing. Its endpoints could be dragged and its handles +// grabbed, but its own quantities — a line's LENGTH, an arc's RADIUS, a circle's DIAMETER, the +// ANGLE between two lines — were reachable only by arming the Dimension tool and re-picking the +// geometry that was already selected. The machinery was all here (dimension_kind / dimension_ +// current / apply_dimension); the way in was not. +bool DesignSketchTool::open_selection_dimension_editor() +{ + if (!on_inline_edit || !selection_valid()) return false; + const DimType k = dimension_kind(); + if (k == DimType::None) return false; + const char* title = "Value"; + switch (k) { + case DimType::Length: title = "Length"; break; + case DimType::Radius: title = "Radius"; break; + case DimType::Diameter: title = "Diameter"; break; + case DimType::Angle: title = "Angle"; break; + case DimType::Distance: title = "Distance"; break; + case DimType::DistanceToLine: title = "Distance"; break; + default: break; + } + // Anchor over the geometry it belongs to, not the panel: the value belongs to the element. + DimAnnot a; a.kind = k; + a.ea = m_selection.empty() ? -1 : m_selection[0]; + if (m_selection.size() > 1) a.eb = m_selection[1]; + const Vec2d at = dim_anchor(a); + const Camera& cam = wxGetApp().plater()->get_camera(); + wxPoint px = world_to_screen_px(cam, m_plane.to_world(at)); + if (px.x < 0 || px.y < 0) px = wxPoint(m_last_mouse_x, m_last_mouse_y); + on_inline_edit(px, dimension_current(), title, + [this](double v) { apply_dimension(v); }, + []() {}); + return true; +} + void DesignSketchTool::set_line_angle(int ei, double deg) { if (ei < 0 || ei >= int(m_entities.size())) return; @@ -8257,6 +8319,23 @@ void DesignSketchTool::render(GLCanvas3D& canvas) if (!handles.empty()) draw_vertices(m_vertex_model, handles, ColorRGBA(0.65f, 0.65f, 0.30f, 1.0f)); if (!sel_handles.empty()) draw_vertices(m_highlight_model, sel_handles, white); + // Midpoint of every segment, drawn smaller and cooler than the endpoint handles + // (snaporca-te8v). Without it the Midpoint snap is invisible: it exists in the + // inference engine but the user has nothing to aim at. Construction lines get one + // too — you constrain to them as readily as to real geometry. + std::vector mids; + for (const SketchEntity& e : m_entities) { + if (e.type == SketchEntity::Type::Line) + mids.push_back(0.5 * (e.p0 + e.p1)); + else if (e.type == SketchEntity::Type::Arc) { + const double am = 0.5 * (e.start_angle + e.end_angle); + mids.push_back(Vec2d(e.center.x() + e.radius * std::cos(am), + e.center.y() + e.radius * std::sin(am))); + } + } + if (!mids.empty()) + draw_vertices(m_vertex_model, mids, ColorRGBA(0.35f, 0.75f, 0.85f, 1.0f), 0.9); + // Derived feature handles (A3): the circle RadiusHandle is not a SketchPointRole, // so the per-point pass above doesn't draw it. Render it (cyan) + the hovered // handle (white, larger) at a screen-constant size so they stay grabbable at any @@ -8548,8 +8627,9 @@ void DesignSketchTool::render(GLCanvas3D& canvas) // Inference hint: highlight the snapped target under the cursor (C1.3). Colour // encodes what the placed point will be Coincident/PointOnObject/Fixed onto. if (m_has_cursor && m_mode != Mode::Constrain && m_cursor_snap.snapped()) { - ColorRGBA hint(1.0f, 0.55f, 0.1f, 1.0f); // endpoint/midpoint: orange + ColorRGBA hint(1.0f, 0.55f, 0.1f, 1.0f); // endpoint: orange switch (m_cursor_snap.kind) { + case InferenceSnap::Kind::Midpoint: hint = ColorRGBA(0.35f, 0.90f, 0.75f, 1.0f); break; // teal case InferenceSnap::Kind::Center: hint = ColorRGBA(0.30f, 0.80f, 1.0f, 1.0f); break; // cyan case InferenceSnap::Kind::Origin: hint = ColorRGBA(1.0f, 0.30f, 0.85f, 1.0f); break; // magenta case InferenceSnap::Kind::OnEdge: hint = ColorRGBA(0.45f, 0.70f, 1.0f, 1.0f); break; // blue @@ -8731,6 +8811,190 @@ static void translate_entity(SketchEntity& e, const Vec2d& d) for (auto& cp : e.ctrl) cp += d; // BSpline poles } +// Select whatever the cursor is over, for a RIGHT-click. In every CAD application the context +// menu belongs to the thing you pointed at; here the menu was built from whatever happened to be +// selected already, so right-clicking a line you had not left-clicked first offered the empty- +// selection vocabulary and its own Delete/Length/Trim rows were nowhere. The offer table already +// described all of those for SkLine/SkArc/SkPoint — the pick was the missing half. +// +// Nothing is stolen from an existing selection: if the entity under the cursor is already part +// of it, the selection is left exactly as it is, so right-clicking one member of a multi-entity +// pick still offers the multi-entity verbs. +// ---- Scripted surface (MCP) ------------------------------------------------------------ + +int DesignSketchTool::add_entities_scripted(const std::vector& ents) +{ + if (ents.empty()) return -1; + const int base = int(m_entities.size()); + for (const SketchEntity& e : ents) m_entities.push_back(e); + infer_auto_constraints(base); // the same auto-coincidence/H/V pass a gesture runs + resolve_live(); + return base; +} + +bool DesignSketchTool::select_indices(const std::vector& idx) +{ + m_selection.clear(); + m_point_sel.clear(); + const int n = int(m_entities.size()); + for (int i : idx) + if (i >= 0 && i < n && + std::find(m_selection.begin(), m_selection.end(), i) == m_selection.end()) + m_selection.push_back(i); + if (on_selection_changed) on_selection_changed(int(m_selection.size())); + return !m_selection.empty(); +} + +DesignSketchTool::LoopReport DesignSketchTool::loop_report() const +{ + LoopReport out; + + // Closed regions and their voids come straight from the code the viewport already uses to + // decide what can be extruded, so the report cannot drift from what the tool will build. + const auto regs = region_loops(m_entities); + out.loops.reserve(regs.size()); + for (const auto& r : regs) { + LoopInfo li; + li.ents = r.ents; + li.holes = r.holes; + li.closed = true; + // Analytic where the loop IS one closed curve; shoelace only where it is a chain. + // region_loops hands back the render polyline, and a circle's is a 64-gon whose area is + // 0.3% short — a number reported as "area" must not be the faceting error. + if (r.ents.size() == 1 && r.ents[0] >= 0 && r.ents[0] < int(m_entities.size()) && + (m_entities[r.ents[0]].type == SketchEntity::Type::Circle || + m_entities[r.ents[0]].type == SketchEntity::Type::Ellipse)) { + const SketchEntity& c = m_entities[r.ents[0]]; + li.area = (c.type == SketchEntity::Type::Circle) + ? 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; + } + out.loops.push_back(std::move(li)); + } + + // Open ends: an endpoint of an open curve that no other open curve's endpoint meets. This is + // the actionable half of the report — it says WHERE the profile fails to close, in plane + // coordinates, instead of only that it does. + struct End { Vec2d p; }; + std::vector ends; + for (const SketchEntity& e : m_entities) { + if (e.construction) continue; + if (e.type == SketchEntity::Type::Line || e.type == SketchEntity::Type::Arc || + e.type == SketchEntity::Type::EllipseArc || e.type == SketchEntity::Type::BSpline) { + ends.push_back({ e.p0 }); + ends.push_back({ e.p1 }); + } + } + const double eps = 1e-3; + for (size_t i = 0; i < ends.size(); ++i) { + int met = 0; + for (size_t j = 0; j < ends.size(); ++j) { + if (i == j) continue; + if ((ends[i].p - ends[j].p).norm() < eps) ++met; + } + if (met == 0) { + // Report each free end once; two ends of the same gap are two different points. + bool dup = false; + for (const Vec2d& q : out.open_ends) + if ((q - ends[i].p).norm() < eps) { dup = true; break; } + if (!dup) out.open_ends.push_back(ends[i].p); + } + } + return out; +} + +int DesignSketchTool::heal_coincidences(double tol, bool ignore_construction) +{ + if (tol <= 0.0) tol = 1e-3; + // Endpoint roles an entity exposes, same set infer_auto_constraints matches on. + auto roles_of = [](const SketchEntity& e, SketchPointRole out[2]) -> int { + switch (e.type) { + case SketchEntity::Type::Line: + case SketchEntity::Type::Arc: + case SketchEntity::Type::BSpline: + case SketchEntity::Type::EllipseArc: + out[0] = SketchPointRole::P0; out[1] = SketchPointRole::P1; return 2; + case SketchEntity::Type::Point: + out[0] = SketchPointRole::P0; return 1; + default: return 0; + } + }; + + const int n = int(m_entities.size()); + int welded = 0; + std::vector cands; + for (int i = 0; i < n; ++i) { + if (ignore_construction && m_entities[i].construction) continue; + SketchPointRole ir[2]; const int ni = roles_of(m_entities[i], ir); + for (int a = 0; a < ni; ++a) { + Vec2d pa; if (!point_at(i, ir[a], pa)) continue; + for (int j = i + 1; j < n; ++j) { + if (ignore_construction && m_entities[j].construction) continue; + SketchPointRole jr[2]; const int nj = roles_of(m_entities[j], jr); + for (int b = 0; b < nj; ++b) { + Vec2d pb; if (!point_at(j, jr[b], pb)) continue; + const double d = (pa - pb).norm(); + if (d > tol) continue; + if (has_coincident(i, ir[a], j, jr[b])) continue; + // WELD FIRST, then constrain. Handing the solver two points a tolerance + // apart and asking it to make them equal lets it move the rest of the sketch + // to get there; snapping them together first means the constraint it is + // asked to satisfy is already true, so nothing else shifts. + if (d > 0.0) { set_point(j, jr[b], pa); pb = pa; } + SketchEntityConstraintDef c; + c.type = SketchConstraintType::Coincident; + c.ea = i; c.ra = ir[a]; c.eb = j; c.rb = jr[b]; + cands.push_back(c); + ++welded; + } + } + } + } + if (!cands.empty()) { + try_add_constraints(cands); + resolve_live(); + } + return welded; +} + +bool DesignSketchTool::select_at_screen(GLCanvas3D& canvas, int sx, int sy) +{ + if (!is_active()) return false; + const Linef3 ray = canvas.mouse_ray(Point(sx, sy)); + const Linef3 ray8 = canvas.mouse_ray(Point(sx + 8, sy)); + const Vec2d p = m_plane.project(ray.a, ray.vector()); + const Vec2d p8 = m_plane.project(ray8.a, ray8.vector()); + const double tol = std::max(1e-3, (p8 - p).norm()); + + // A point handle beats the curve it belongs to, same precedence the left-click pick uses. + int ei = -1; SketchPointRole role = SketchPointRole::P0; + if (hit_test_point(p, tol, ei, role)) { + const auto pr = std::make_pair(ei, role); + if (std::find(m_point_sel.begin(), m_point_sel.end(), pr) != m_point_sel.end()) + return false; // already selected: leave it alone + m_selection.clear(); + m_point_sel.assign(1, pr); + if (on_selection_changed) on_selection_changed(1); + return true; + } + + const int hit = hit_test(p, tol); + if (hit < 0) return false; + if (std::find(m_selection.begin(), m_selection.end(), hit) != m_selection.end()) + return false; // already selected: leave it alone + m_selection.assign(1, hit); + m_point_sel.clear(); + if (on_selection_changed) on_selection_changed(1); + return true; +} + int DesignSketchTool::hit_test(const Vec2d& p, double tol) const { double best = tol; diff --git a/src/slic3r/GUI/CAD/DesignSketchTool.hpp b/src/slic3r/GUI/CAD/DesignSketchTool.hpp index 84a45d3486..a5d0835887 100644 --- a/src/slic3r/GUI/CAD/DesignSketchTool.hpp +++ b/src/slic3r/GUI/CAD/DesignSketchTool.hpp @@ -441,7 +441,53 @@ public: out = m_entities[i].type; return true; } + // Right-click pick: select the entity (or point handle) under the given canvas pixel, so + // the context menu describes what was pointed at. No-op when it is already selected, or + // when nothing is there. Returns true if the selection changed. + bool select_at_screen(GLCanvas3D& canvas, int sx, int sy); + // Open the in-canvas value field on the SELECTION's defining number (a line's length, an + // arc's radius, a circle's diameter, the angle between two lines, a point-to-point or + // point-to-line distance). False when the selection has no such number. + bool open_selection_dimension_editor(); + // ---- Scripted surface (MCP) ------------------------------------------------------- + // The same operations the right-click offers, reachable without a mouse gesture, so the 2D + // layer can be driven and asserted headlessly. Everything here goes through the SAME code a + // gesture goes through — append + infer_auto_constraints + live solve — because a test that + // exercises a private shortcut proves nothing about the tool the user drives. + const std::vector& entities() const { return m_entities; } + const SketchPlane& plane() const { return m_plane; } + int dof() const { return m_dof; } + bool solve_ok() const { return m_solve_ok; } + // Append entities exactly as a finished gesture does. Returns the index of the first one. + int add_entities_scripted(const std::vector& ents); + // Replace the selection with these entity indices (out-of-range ones are ignored). + bool select_indices(const std::vector& idx); + + // The loop report: what is CLOSED, what its internal voids are, and where a chain is still + // open. This is the answer to "is my profile buildable", and it is the one question the + // sketch layer could never be asked from outside. + struct LoopInfo { + std::vector ents; // entities of this loop, in chain order + std::vector holes; // indices into LoopReport::loops that this loop encloses + bool closed{false}; + double area{0.0}; // signed shoelace area of the loop polyline + }; + struct LoopReport { + std::vector loops; + std::vector open_ends; // free endpoints: where a chain fails to close + }; + LoopReport loop_report() const; + + // FreeCAD's ValidateSketch, as one call: weld endpoints that are within `tol` of each other + // and RECORD the Coincident constraints, so a loop that was closed by floating-point luck + // becomes closed by construction and survives every later solve. Returns how many pairs were + // welded. Construction geometry is skipped when `ignore_construction`. + int heal_coincidences(double tol, bool ignore_construction); + void clear_selection(); + // Flip the selection between construction and real geometry (whole Feature groups). + // Returns how many entities changed; 0 when nothing is selected. + int toggle_selection_construction(); void delete_selected(); // erase selected entities // Abort any pending/queued draw-then-edit value-field sequence. Removing an entity that // still has a deferred auto-edit would otherwise open a field on a now-deleted entity and diff --git a/src/slic3r/GUI/CAD/McpControl.cpp b/src/slic3r/GUI/CAD/McpControl.cpp index 9fd15e1ed4..e42ca7ab3b 100644 --- a/src/slic3r/GUI/CAD/McpControl.cpp +++ b/src/slic3r/GUI/CAD/McpControl.cpp @@ -24,6 +24,8 @@ #include "slic3r/GUI/GUI_App.hpp" #include "slic3r/GUI/MainFrame.hpp" #include "slic3r/GUI/CAD/DesignPanel.hpp" +#include "slic3r/GUI/CAD/DesignCanvas.hpp" +#include "slic3r/GUI/CAD/DesignSketchTool.hpp" #include "libslic3r/CAD/CadDocument.hpp" #include "libslic3r/CAD/SketchEngine.hpp" @@ -1159,6 +1161,326 @@ json action_draft(DesignPanel* panel, const json& params) return json{{"ok", ok}, {"draft_index", d}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}}; } +// ---- 2D sketch verbs -------------------------------------------------------------------- +// +// The model is FreeCAD's Sketcher, adapted to this tab's right-click world. Three ideas are +// taken over deliberately: +// +// * GEOMETRY IS SEPARATE FROM CONSTRAINTS. You add curves, then you constrain them; the +// solver reports degrees of freedom and whether it is consistent. `sketch_describe` returns +// both halves plus the DoF, which is the whole state a caller needs to reason about. +// * A SKETCH IS JUDGED BY ITS LOOPS, not by its coordinates. FreeCAD asks whether the profile +// is closed before it will build from it; `sketch_describe` answers that directly, listing +// each closed loop, the loops it encloses as VOIDS, and — the actionable part — the exact +// plane coordinates where a chain is still open. +// * VALIDATE, THEN FIX. FreeCAD's ValidateSketch finds vertices that overlap within a +// tolerance but carry no coincidence, and adds the missing ones. `sketch_validate` reports +// them, `sketch_heal` welds and constrains them. That is what turns a loop that is closed by +// floating-point luck into one that is closed by construction and stays closed through +// every later solve. +// +// What is NOT taken over: FreeCAD's Sketcher is a modal dialog with its own toolbars. Here the +// vocabulary is the right-click offer, so these verbs are named after what the menu offers on a +// selection, and every one of them drives the SAME DesignSketchTool the mouse drives. + +DesignSketchTool& mcp_sketch(DesignPanel* panel) +{ + DesignCanvas* vp = panel->mcp_viewport(); + if (vp == nullptr) throw std::runtime_error("no viewport"); + if (!vp->is_sketching()) + throw std::runtime_error("no sketch is open — call sketch_begin first"); + return vp->mcp_sketch_tool(); +} + +SketchEntity sketch_entity_from(const json& j) +{ + const std::string t = j.value("type", std::string("")); + SketchEntity e; + auto p = [&](const char* k, double dx, double dy) { + if (!j.contains(k)) return Vec2d(dx, dy); + const json& a = j.at(k); + if (!a.is_array() || a.size() < 2) throw std::runtime_error(std::string(k) + " must be [x, y]"); + return Vec2d(a[0].get(), a[1].get()); + }; + e.construction = j.value("construction", false); + if (t == "line") { + e.type = SketchEntity::Type::Line; + e.p0 = p("p0", 0, 0); e.p1 = p("p1", 0, 0); + } else if (t == "circle") { + e.type = SketchEntity::Type::Circle; + e.center = p("center", 0, 0); + e.radius = j.value("radius", 0.0); + e.p0 = e.center; + if (e.radius <= 0.0) throw std::runtime_error("circle needs a positive 'radius'"); + } else if (t == "arc") { + e.type = SketchEntity::Type::Arc; + e.center = p("center", 0, 0); + e.radius = j.value("radius", 0.0); + e.start_angle = j.value("start_angle", 0.0); + e.end_angle = j.value("end_angle", 0.0); + if (e.radius <= 0.0) throw std::runtime_error("arc needs a positive 'radius'"); + e.p0 = e.center + e.radius * Vec2d(std::cos(e.start_angle), std::sin(e.start_angle)); + e.p1 = e.center + e.radius * Vec2d(std::cos(e.end_angle), std::sin(e.end_angle)); + } else if (t == "point") { + e.type = SketchEntity::Type::Point; + e.p0 = p("p", 0, 0); + } else { + throw std::runtime_error("unknown entity type '" + t + "' (line, arc, circle, point)"); + } + return e; +} + +json sketch_entity_to(const SketchEntity& e, int index) +{ + json j{{"index", index}, {"construction", e.construction}}; + switch (e.type) { + case SketchEntity::Type::Line: + j["type"] = "line"; + j["p0"] = json::array({e.p0.x(), e.p0.y()}); + j["p1"] = json::array({e.p1.x(), e.p1.y()}); + j["length"] = (e.p1 - e.p0).norm(); + break; + case SketchEntity::Type::Circle: + j["type"] = "circle"; + j["center"] = json::array({e.center.x(), e.center.y()}); + j["radius"] = e.radius; + break; + case SketchEntity::Type::Arc: + j["type"] = "arc"; + j["center"] = json::array({e.center.x(), e.center.y()}); + j["radius"] = e.radius; + j["start_angle"] = e.start_angle; + j["end_angle"] = e.end_angle; + j["p0"] = json::array({e.p0.x(), e.p0.y()}); + j["p1"] = json::array({e.p1.x(), e.p1.y()}); + break; + case SketchEntity::Type::Point: + j["type"] = "point"; + j["p"] = json::array({e.p0.x(), e.p0.y()}); + break; + case SketchEntity::Type::Ellipse: j["type"] = "ellipse"; break; + case SketchEntity::Type::EllipseArc: j["type"] = "ellipse_arc"; break; + case SketchEntity::Type::BSpline: j["type"] = "spline"; break; + } + return j; +} + +// Which entities a verb acts on: an explicit "entities" array, else the current selection, +// else — only where the verb says so — everything. Same precedence the menu uses: what you +// pointed at wins, and the menu never silently acts on the whole sketch. +std::vector sketch_targets(const json& params, DesignSketchTool& t, bool all_if_empty) +{ + std::vector out; + if (params.contains("entities")) { + for (const auto& v : params.at("entities")) out.push_back(v.get()); + return out; + } + out = t.selection(); + if (out.empty() && all_if_empty) + for (int i = 0; i < int(t.entities().size()); ++i) out.push_back(i); + return out; +} + +json action_sketch_begin(DesignPanel* panel, const json& params) +{ + DesignCanvas* vp = panel->mcp_viewport(); + if (vp == nullptr) throw std::runtime_error("no viewport"); + if (vp->is_sketching()) throw std::runtime_error("a sketch is already open"); + const std::string pl = params.value("plane", std::string("XY")); + SketchPlane plane = SketchPlane::XY(); + if (pl == "XZ") plane = SketchPlane::XZ(); + else if (pl == "YZ") plane = SketchPlane::YZ(); + else if (pl != "XY") throw std::runtime_error("plane must be XY, XZ or YZ"); + vp->begin_sketch(plane, DesignSketchTool::Mode::Select); + // The panel has to enter sketch mode too, or the app is half in it: the tool sketches, the + // offer menu offers sketch verbs, and every sketch KEY is dead because key dispatch tests + // m_ui_mode while the menu tests the viewport. Driving the socket must leave the GUI in the + // state a user would be in, not a state only the socket can produce. + panel->mcp_set_sketch_mode(true); + return json{{"ok", true}, {"plane", pl}}; +} + +json action_sketch_commit(DesignPanel* panel, const json& params) +{ + (void)params; + DesignCanvas* vp = panel->mcp_viewport(); + if (vp == nullptr || !vp->is_sketching()) throw std::runtime_error("no sketch is open"); + vp->finish_sketch(); + panel->mcp_set_sketch_mode(false); + panel->mcp_after_change(); + return json{{"ok", true}, {"features", int(panel->mcp_doc().features.size())}}; +} + +json action_sketch_cancel(DesignPanel* panel, const json& params) +{ + (void)params; + DesignCanvas* vp = panel->mcp_viewport(); + if (vp == nullptr || !vp->is_sketching()) throw std::runtime_error("no sketch is open"); + vp->cancel_sketch(); + panel->mcp_set_sketch_mode(false); + return json{{"ok", true}}; +} + +json action_sketch_add(DesignPanel* panel, const json& params) +{ + DesignSketchTool& t = mcp_sketch(panel); + std::vector ents; + if (params.contains("entities")) { + for (const auto& j : params.at("entities")) ents.push_back(sketch_entity_from(j)); + } else if (params.contains("type")) { + ents.push_back(sketch_entity_from(params)); // single-entity shorthand + } else if (params.contains("rect")) { + // Corner rectangle as four shared-endpoint lines, so it arrives as ONE closed loop + // rather than four segments that happen to touch. + const json& r = params.at("rect"); + if (!r.is_array() || r.size() < 4) throw std::runtime_error("rect must be [x0, y0, x1, y1]"); + const double x0 = r[0].get(), y0 = r[1].get(); + const double x1 = r[2].get(), y1 = r[3].get(); + const bool c = params.value("construction", false); + auto seg = [&](Vec2d a, Vec2d b) { SketchEntity e; e.type = SketchEntity::Type::Line; + e.p0 = a; e.p1 = b; e.construction = c; return e; }; + ents.push_back(seg({x0, y0}, {x1, y0})); + ents.push_back(seg({x1, y0}, {x1, y1})); + ents.push_back(seg({x1, y1}, {x0, y1})); + ents.push_back(seg({x0, y1}, {x0, y0})); + } else { + throw std::runtime_error("sketch_add needs 'entities', a single 'type', or 'rect'"); + } + const int base = t.add_entities_scripted(ents); + panel->mcp_viewport()->request_repaint(); + return json{{"ok", base >= 0}, {"first_index", base}, {"added", int(ents.size())}, + {"entities", int(t.entities().size())}, {"dof", t.dof()}}; +} + +json action_sketch_select(DesignPanel* panel, const json& params) +{ + DesignSketchTool& t = mcp_sketch(panel); + std::vector idx; + if (params.contains("entities")) + for (const auto& v : params.at("entities")) idx.push_back(v.get()); + const bool any = t.select_indices(idx); + panel->mcp_viewport()->request_repaint(); + return json{{"ok", true}, {"selected", int(t.selection().size())}, {"any", any}}; +} + +json action_sketch_delete(DesignPanel* panel, const json& params) +{ + DesignSketchTool& t = mcp_sketch(panel); + const std::vector tgt = sketch_targets(params, t, false); + if (tgt.empty()) throw std::runtime_error("nothing selected and no 'entities' given"); + const int before = int(t.entities().size()); + t.select_indices(tgt); + t.delete_selected(); + panel->mcp_viewport()->request_repaint(); + return json{{"ok", true}, {"removed", before - int(t.entities().size())}, + {"entities", int(t.entities().size())}}; +} + +json action_sketch_construction(DesignPanel* panel, const json& params) +{ + DesignSketchTool& t = mcp_sketch(panel); + const std::vector tgt = sketch_targets(params, t, false); + if (tgt.empty()) throw std::runtime_error("nothing selected and no 'entities' given"); + t.select_indices(tgt); + const int n = t.toggle_selection_construction(); + panel->mcp_viewport()->request_repaint(); + return json{{"ok", n > 0}, {"changed", n}}; +} + +json action_sketch_offset(DesignPanel* panel, const json& params) +{ + if (!params.contains("distance")) throw std::runtime_error("sketch_offset needs 'distance'"); + DesignSketchTool& t = mcp_sketch(panel); + const double d = params.at("distance").get(); + const std::vector tgt = sketch_targets(params, t, true); + std::vector src; + for (int i : tgt) + if (i >= 0 && i < int(t.entities().size())) src.push_back(t.entities()[i]); + if (src.empty()) throw std::runtime_error("nothing to offset"); + const auto out = SketchEngine::offset_entities(src, d); + if (out.empty()) throw std::runtime_error("offset produced nothing (ellipses and splines are not offset)"); + const int base = t.add_entities_scripted(out); + panel->mcp_viewport()->request_repaint(); + return json{{"ok", true}, {"first_index", base}, {"added", int(out.size())}, {"dof", t.dof()}}; +} + +json action_sketch_mirror(DesignPanel* panel, const json& params) +{ + DesignSketchTool& t = mcp_sketch(panel); + auto pt = [&](const char* k, double dx, double dy) { + if (!params.contains(k)) return Vec2d(dx, dy); + const json& a = params.at(k); + if (!a.is_array() || a.size() < 2) throw std::runtime_error(std::string(k) + " must be [x, y]"); + return Vec2d(a[0].get(), a[1].get()); + }; + const Vec2d a = pt("axis_a", 0, 0), b = pt("axis_b", 0, 1); + const std::vector tgt = sketch_targets(params, t, true); + std::vector src; + for (int i : tgt) + if (i >= 0 && i < int(t.entities().size())) src.push_back(t.entities()[i]); + if (src.empty()) throw std::runtime_error("nothing to mirror"); + const auto out = SketchEngine::mirror_entities(src, a, b); + const int base = t.add_entities_scripted(out); + panel->mcp_viewport()->request_repaint(); + return json{{"ok", true}, {"first_index", base}, {"added", int(out.size())}, {"dof", t.dof()}}; +} + +json sketch_report(DesignSketchTool& t) +{ + const auto rep = t.loop_report(); + json loops = json::array(); + for (const auto& l : rep.loops) { + json holes = json::array(); + for (int h : l.holes) holes.push_back(h); + loops.push_back(json{{"entities", l.ents}, {"holes", holes}, + {"closed", l.closed}, {"area", l.area}}); + } + json open_ends = json::array(); + for (const Vec2d& p : rep.open_ends) open_ends.push_back(json::array({p.x(), p.y()})); + // A profile is buildable when at least one loop closed and nothing is left dangling. + const bool buildable = !rep.loops.empty() && rep.open_ends.empty(); + return json{{"closed_loops", loops}, {"open_ends", open_ends}, {"buildable", buildable}}; +} + +json action_sketch_describe(DesignPanel* panel, const json& params) +{ + (void)params; + DesignSketchTool& t = mcp_sketch(panel); + json ents = json::array(); + for (int i = 0; i < int(t.entities().size()); ++i) + ents.push_back(sketch_entity_to(t.entities()[i], i)); + json out{{"ok", true}, + {"entities", ents}, + {"constraints", int(t.constraints().size())}, + {"dof", t.dof()}, + {"solve_ok", t.solve_ok()}, + {"selection", t.selection()}}; + out.update(sketch_report(t)); + return out; +} + +json action_sketch_validate(DesignPanel* panel, const json& params) +{ + DesignSketchTool& t = mcp_sketch(panel); + const double tol = params.value("tolerance", 1e-3); + json out{{"ok", true}, {"tolerance", tol}, {"dof", t.dof()}, {"solve_ok", t.solve_ok()}}; + out.update(sketch_report(t)); + return out; +} + +json action_sketch_heal(DesignPanel* panel, const json& params) +{ + DesignSketchTool& t = mcp_sketch(panel); + const double tol = params.value("tolerance", 1e-3); + const bool ic = params.value("ignore_construction", true); + const int welded = t.heal_coincidences(tol, ic); + panel->mcp_viewport()->request_repaint(); + json out{{"ok", true}, {"welded", welded}, {"tolerance", tol}, + {"dof", t.dof()}, {"solve_ok", t.solve_ok()}}; + out.update(sketch_report(t)); + return out; +} + json action_mirror(DesignPanel* panel, const json& params) { std::string m_str = params.value("mode", std::string("new")); @@ -1485,6 +1807,18 @@ std::string handle_on_main(const std::string& method, const json& params, const if (method == "rib") return rpc_result(id, action_rib(panel, params)); if (method == "draft") return rpc_result(id, action_draft(panel, params)); if (method == "mirror") return rpc_result(id, action_mirror(panel, params)); + if (method == "sketch_begin") return rpc_result(id, action_sketch_begin(panel, params)); + if (method == "sketch_commit") return rpc_result(id, action_sketch_commit(panel, params)); + if (method == "sketch_cancel") return rpc_result(id, action_sketch_cancel(panel, params)); + if (method == "sketch_add") return rpc_result(id, action_sketch_add(panel, params)); + if (method == "sketch_select") return rpc_result(id, action_sketch_select(panel, params)); + if (method == "sketch_delete") return rpc_result(id, action_sketch_delete(panel, params)); + if (method == "sketch_construction") return rpc_result(id, action_sketch_construction(panel, params)); + if (method == "sketch_offset") return rpc_result(id, action_sketch_offset(panel, params)); + if (method == "sketch_mirror") return rpc_result(id, action_sketch_mirror(panel, params)); + if (method == "sketch_describe") return rpc_result(id, action_sketch_describe(panel, params)); + if (method == "sketch_validate") return rpc_result(id, action_sketch_validate(panel, params)); + if (method == "sketch_heal") return rpc_result(id, action_sketch_heal(panel, params)); if (method == "transform") return rpc_result(id, action_transform(panel, params)); if (method == "thicken") return rpc_result(id, action_thicken(panel, params)); if (method == "split") return rpc_result(id, action_split(panel, params)); diff --git a/tests/libslic3r/CMakeLists.txt b/tests/libslic3r/CMakeLists.txt index ab57fef0be..3890745062 100644 --- a/tests/libslic3r/CMakeLists.txt +++ b/tests/libslic3r/CMakeLists.txt @@ -46,6 +46,7 @@ if (SLIC3R_CAD) test_caddocument.cpp test_sketchconstraints.cpp test_sketchedit.cpp + test_sketchprofile.cpp test_sketchimport.cpp test_sketchinference.cpp test_slvs_constraints.cpp) diff --git a/tests/libslic3r/test_sketchedit.cpp b/tests/libslic3r/test_sketchedit.cpp index eb99560d7d..cad84e9f6c 100644 --- a/tests/libslic3r/test_sketchedit.cpp +++ b/tests/libslic3r/test_sketchedit.cpp @@ -115,7 +115,12 @@ TEST_CASE("Offset Circle: expand and collapse", "[SketchEdit]") REQUIRE(collapsed.empty()); } -TEST_CASE("Offset Arc by positive d", "[SketchEdit]") +// CONTRACT CHANGED: +d used to mean "radius + d" for every arc regardless of its sweep, while +// for a line it meant "left of the direction of travel". The two disagreed, so a profile made +// of lines AND arcs (any slot outline) offset with its straights going one way and its caps the +// other, and could never come back closed. The arc now follows the line's rule: +d is left of +// travel, which for this CCW quarter-arc is inward -> r = 3. See [SketchProfile]. +TEST_CASE("Offset Arc by positive d (left of travel: a CCW arc shrinks)", "[SketchEdit]") { SketchEntity e; e.type = SketchEntity::Type::Arc; @@ -131,11 +136,11 @@ TEST_CASE("Offset Arc by positive d", "[SketchEdit]") const auto& o = result[0]; REQUIRE(o.type == SketchEntity::Type::Arc); - REQUIRE_THAT(o.radius, WithinAbs(5.0, 1e-9)); - REQUIRE_THAT(o.p0.x(), WithinAbs(5.0, 1e-9)); + REQUIRE_THAT(o.radius, WithinAbs(3.0, 1e-9)); + REQUIRE_THAT(o.p0.x(), WithinAbs(3.0, 1e-9)); REQUIRE_THAT(o.p0.y(), WithinAbs(0.0, 1e-9)); REQUIRE_THAT(o.p1.x(), WithinAbs(0.0, 1e-9)); - REQUIRE_THAT(o.p1.y(), WithinAbs(5.0, 1e-9)); + REQUIRE_THAT(o.p1.y(), WithinAbs(3.0, 1e-9)); } TEST_CASE("Fillet right-angle corner", "[SketchEdit]") diff --git a/tests/libslic3r/test_sketchprofile.cpp b/tests/libslic3r/test_sketchprofile.cpp new file mode 100644 index 0000000000..97705cd34d --- /dev/null +++ b/tests/libslic3r/test_sketchprofile.cpp @@ -0,0 +1,164 @@ +// Closed-profile harness for the 2D sketch layer. +// +// The existing [SketchEdit] cases check one entity at a time — offset ONE line, mirror ONE +// arc — and every one of them passes while the feature they belong to is unusable. What a +// user actually does is combine 2D features into a CLOSED PROFILE and extrude it, and the +// property that makes that work is topological, not per-entity: after the operation, do the +// pieces still form a single closed loop? +// +// So these cases assert the loop, not the coordinates. That is the invariant every sketch +// operation has to preserve and the only one that predicts whether the GUI can build a solid +// out of the result. +#include // mainline OrcaSlicer ships Catch2 v3 (v2 was catch2/catch.hpp) +#include "libslic3r/CAD/SketchEngine.hpp" +#include +#include +#include + +using namespace Slic3r; +using Catch::Matchers::WithinAbs; + +namespace { + +SketchPlane xy_plane() { return SketchPlane::XY(); } + +SketchEntity line(const Vec2d& a, const Vec2d& b) +{ + SketchEntity e; + e.type = SketchEntity::Type::Line; + e.p0 = a; e.p1 = b; + return e; +} + +// A CCW rectangle as four Line entities sharing endpoints exactly. +std::vector rect(double w, double h) +{ + return { line({0, 0}, {w, 0}), line({w, 0}, {w, h}), + line({w, h}, {0, h}), line({0, h}, {0, 0}) }; +} + +// How many of the wires the sketch resolves to are CLOSED. +int closed_wires(const std::vector& ents) +{ + const auto ws = SketchEngine::entities_to_wires(ents, xy_plane()); + int n = 0; + for (const auto& w : ws) + if (!w.IsNull() && w.Closed()) ++n; + return n; +} + +// Enclosed area of the single closed loop the sketch resolves to. -1 when it is not one +// closed loop — the failure the whole file exists to catch. +double profile_area(const std::vector& ents) +{ + const auto ws = SketchEngine::entities_to_wires(ents, xy_plane()); + if (ws.size() != 1 || ws[0].IsNull() || !ws[0].Closed()) return -1.0; + const TopoDS_Face f = SketchEngine::wires_to_face(ws, xy_plane()); + GProp_GProps props; + BRepGProp::SurfaceProperties(f, props); + return props.Mass(); +} + +SketchEntity arc(const Vec2d& c, double r, double a0, double a1) +{ + SketchEntity e; + e.type = SketchEntity::Type::Arc; + e.center = c; + e.radius = r; + e.start_angle = a0; + e.end_angle = a1; + e.p0 = c + r * Vec2d(std::cos(a0), std::sin(a0)); + e.p1 = c + r * Vec2d(std::cos(a1), std::sin(a1)); + return e; +} + +} // namespace + +TEST_CASE("profile baseline: a hand-built rectangle is one closed loop", "[SketchProfile]") +{ + REQUIRE(closed_wires(rect(40, 20)) == 1); +} + +TEST_CASE("profile: mirroring a closed rectangle keeps it closed", "[SketchProfile]") +{ + const auto m = SketchEngine::mirror_entities(rect(40, 20), Vec2d(-10, 0), Vec2d(-10, 1)); + REQUIRE(m.size() == 4); + REQUIRE(closed_wires(m) == 1); +} + +TEST_CASE("profile: mirroring an open half-profile closes it against the axis", "[SketchProfile]") +{ + // Half a rectangle, open along x=0 — the classic "draw half, mirror it" gesture. + const std::vector half = { + line({0, 0}, {20, 0}), line({20, 0}, {20, 10}), line({20, 10}, {0, 10}) }; + auto all = half; + for (const auto& e : SketchEngine::mirror_entities(half, Vec2d(0, 0), Vec2d(0, 1))) + all.push_back(e); + REQUIRE(all.size() == 6); + REQUIRE(closed_wires(all) == 1); +} + +TEST_CASE("profile: offsetting a closed rectangle keeps it closed", "[SketchProfile]") +{ + const auto out = SketchEngine::offset_entities(rect(40, 20), 5.0); + REQUIRE(out.size() == 4); + REQUIRE(closed_wires(out) == 1); +} + +TEST_CASE("profile: offset outward grows the enclosed area by the right amount", "[SketchProfile]") +{ + // A rectangle offset outward by d is (w+2d) x (h+2d) with the corners rounded at r=d, + // so its area is w*h + 2d(w+h) + pi*d^2 whichever way the corners are healed... except + // for a sharp-corner offset, which is exactly (w+2d)*(h+2d). Either healing is defensible; + // a set of four disconnected segments is not, and that is what this measures. + const double w = 40, h = 20, d = 5; + const auto out = SketchEngine::offset_entities(rect(w, h), d); + const auto ws = SketchEngine::entities_to_wires(out, xy_plane()); + REQUIRE(ws.size() == 1); + REQUIRE(ws[0].Closed()); +} + +TEST_CASE("profile: offset sign is left-of-travel, so +d shrinks a CCW rectangle", "[SketchProfile]") +{ + // The convention has to be pinned by a test, because it is the one thing a caller cannot + // read off the geometry: +d = left of the direction of travel = inward for a CCW loop. + // Miter join on a rectangle keeps the corners sharp, so the result is exact. + const double w = 40, h = 20, d = 5; + REQUIRE_THAT(profile_area(SketchEngine::offset_entities(rect(w, h), d)), + WithinAbs((w - 2 * d) * (h - 2 * d), 1e-6)); + REQUIRE_THAT(profile_area(SketchEngine::offset_entities(rect(w, h), -d)), + WithinAbs((w + 2 * d) * (h + 2 * d), 1e-6)); +} + +TEST_CASE("profile: offsetting a stadium (two lines + two arcs) stays closed", "[SketchProfile]") +{ + // A slot outline: straight top and bottom joined by half-circle caps. This is the case the + // per-entity offset could never repair, because both seams are line-to-arc. + const double L = 30, r = 8, d = 3; + const std::vector slot = { + line({0, -r}, {L, -r}), + arc({L, 0}, r, -M_PI / 2, M_PI / 2), + line({L, r}, {0, r}), + arc({0, 0}, r, M_PI / 2, 3 * M_PI / 2), + }; + REQUIRE(closed_wires(slot) == 1); + const auto out = SketchEngine::offset_entities(slot, -d); // -d = outward for this CCW loop + REQUIRE(closed_wires(out) == 1); + // Offsetting a stadium outward by d gives the stadium with radius r+d: L*2(r+d) + pi(r+d)^2. + // Lines and caps must move the SAME way — that is the assertion this case exists for. + const double rr = r + d; + REQUIRE_THAT(profile_area(out), WithinAbs(L * 2 * rr + M_PI * rr * rr, 1e-6)); +} + +TEST_CASE("profile: an open chain offsets without being forced closed", "[SketchProfile]") +{ + // A sweep path is legitimately open; the repair must join its interior seams and leave + // the two free ends alone. + const std::vector open_chain = { + line({0, 0}, {20, 0}), line({20, 0}, {20, 10}) }; + const auto out = SketchEngine::offset_entities(open_chain, 4.0); + REQUIRE(out.size() == 2); + REQUIRE(closed_wires(out) == 0); + // 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)); +}