Port the sketch layer work from snaporca: offset chains, right-click, MCP verbs

Carries snaporca 971320e129, 6b049f0dc6, 4aae782029, 444d59f212, 74cf3d7e54 and the build
guards from 597557a6e4. Parity re-verified after every hunk: 17 files identical, 8 diverging by
their expected counts — DesignPanel.cpp still 32, DesignCanvas.cpp still 16, which is the proof
each hunk landed on the right side rather than being copied over a real divergence.

OFFSET OFFSETS THE CHAIN. Per-entity offsetting returned a closed rectangle as four parallel
segments that no longer touch, so entities_to_wires gave four OPEN wires and nothing could be
extruded. offset_entities now chains by shared endpoints and repairs each seam by mitering the
neighbours to their intersection. Second bug, invisible to any single-entity test: +d meant
"left of travel" for a line but "radius + d" for an arc regardless of sweep, so a slot outline
offset with its straights going one way and its caps the other. The convention is now written on
the declaration and pinned by a test.

tests/libslic3r/test_sketchprofile.cpp is new and asserts the LOOP rather than coordinates —
the property that decides whether a profile can be built, and the one the existing single-entity
[SketchEdit] cases cannot see. Its include is catch2/catch_all.hpp here: this fork ships Catch2
v3 while snaporca is on v2, which is why the test files are a tolerated divergence.

RIGHT-CLICK PICKS WHAT YOU POINTED AT, so a line's own verbs are offered instead of the
empty-selection vocabulary; sk_delete stops sharing btn:delete with the feature tree; and an
element's defining number (length / radius / diameter / angle / distance) can be typed, from the
menu or from V.

TWELVE MCP SKETCH VERBS. The socket had ~40 verbs and none touched a sketch, so the 2D layer
could only be exercised by driving a GUI with synthetic clicks. sketch_describe reports each
closed loop, the loops it encloses as voids, exact areas, and where a chain is still open;
sketch_validate/sketch_heal are FreeCAD's ValidateSketch — find vertices that overlap within a
tolerance but carry no coincidence, then weld them AND record the constraint, so a loop closed
by floating-point luck becomes one closed by construction. scripts/mcp-sketch-smoke.py is the
loop that asserts all of it.

Kernel suite on this fork: all tests passed, 2677 assertions in 230 test cases. The GUI target
links against the rebuilt deps image (the wxInspector blockage is gone) and the binary carries
the new verbs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tommaso Bianchi
2026-08-22 09:00:26 +02:00
co-authored by Claude Opus 5
parent df45edb13d
commit 5d7fc8c545
17 changed files with 1313 additions and 50 deletions
+25 -1
View File
@@ -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.
+25 -1
View File
@@ -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"
+114
View File
@@ -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 <binary> # 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")
+234 -40
View File
@@ -900,52 +900,246 @@ std::vector<SketchEntity> 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<Vec2d>& cands, const Vec2d& seed, Vec2d& out)
{
if (cands.empty()) return false;
double best = std::numeric_limits<double>::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<Vec2d> off_line_circle(const Vec2d& p0, const Vec2d& p1, const Vec2d& c, double r)
{
std::vector<Vec2d> 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<Vec2d> off_circle_circle(const Vec2d& c0, double r0, const Vec2d& c1, double r1)
{
std::vector<Vec2d> 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<SketchEntity> SketchEngine::offset_entities(
const std::vector<SketchEntity>& src, double d)
{
std::vector<SketchEntity> 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<int> 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<bool> 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<int> chain{ int(s) };
used[s] = true;
for (;;) {
Vec2d p0, p1; ends(chain.back(), p0, p1);
int nxt = -1;
for (size_t j = 0; j < open_idx.size(); ++j) {
if (used[j]) continue;
Vec2d q0, q1; ends(int(j), q0, q1);
if (off_same(p1, q0)) { nxt = int(j); break; }
}
if (nxt < 0) break;
used[nxt] = true;
chain.push_back(nxt);
}
Vec2d h0, h1, t0, t1;
ends(chain.front(), h0, h1);
ends(chain.back(), t0, t1);
const bool closed = chain.size() > 2 && off_same(t1, h0);
std::vector<SketchEntity> off;
for (int k : chain) {
SketchEntity o;
if (off_one(src[open_idx[k]], d, o)) off.push_back(o);
}
if (off.empty()) continue;
for (size_t i = 0; i + 1 < off.size(); ++i) off_join(off[i], off[i + 1]);
if (closed && off.size() > 1) off_join(off.back(), off.front());
for (auto& o : off) out.push_back(o);
}
}
+6
View File
@@ -233,6 +233,12 @@ public:
static std::vector<SketchEntity> mirror_entities(
const std::vector<SketchEntity>& 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<SketchEntity> offset_entities(
const std::vector<SketchEntity>& src, double d);
+7 -1
View File
@@ -64,11 +64,17 @@ InferenceSnap infer_point_snap(const std::vector<SketchEntity>& 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).
+20
View File
@@ -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<std::vector<std::vector<Vec2d>>>& regions)
{
@@ -970,6 +984,12 @@ void DesignCanvas::set_on_context_menu(std::function<void(const wxPoint&)> 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
}
+11
View File
@@ -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<std::vector<std::vector<Vec2d>>>& 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
+10 -2
View File
@@ -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
+30
View File
@@ -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(); };
+12
View File
@@ -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 };
+265 -1
View File
@@ -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<bool> 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<Vec2d> 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<SketchEntity>& 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<int>& 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<End> 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<SketchEntityConstraintDef> 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;
+46
View File
@@ -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<SketchEntity>& 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<SketchEntity>& ents);
// Replace the selection with these entity indices (out-of-range ones are ignored).
bool select_indices(const std::vector<int>& 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<int> ents; // entities of this loop, in chain order
std::vector<int> 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<LoopInfo> loops;
std::vector<Vec2d> 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
+334
View File
@@ -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<double>(), a[1].get<double>());
};
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<int> sketch_targets(const json& params, DesignSketchTool& t, bool all_if_empty)
{
std::vector<int> out;
if (params.contains("entities")) {
for (const auto& v : params.at("entities")) out.push_back(v.get<int>());
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<SketchEntity> 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<double>(), y0 = r[1].get<double>();
const double x1 = r[2].get<double>(), y1 = r[3].get<double>();
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<int> idx;
if (params.contains("entities"))
for (const auto& v : params.at("entities")) idx.push_back(v.get<int>());
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<int> 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<int> 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<double>();
const std::vector<int> tgt = sketch_targets(params, t, true);
std::vector<SketchEntity> 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<double>(), a[1].get<double>());
};
const Vec2d a = pt("axis_a", 0, 0), b = pt("axis_b", 0, 1);
const std::vector<int> tgt = sketch_targets(params, t, true);
std::vector<SketchEntity> 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));
+1
View File
@@ -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)
+9 -4
View File
@@ -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]")
+164
View File
@@ -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 <catch2/catch_all.hpp> // mainline OrcaSlicer ships Catch2 v3 (v2 was catch2/catch.hpp)
#include "libslic3r/CAD/SketchEngine.hpp"
#include <BRepGProp.hxx>
#include <GProp_GProps.hxx>
#include <cmath>
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<SketchEntity> 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<SketchEntity>& 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<SketchEntity>& 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<SketchEntity> 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<SketchEntity> 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<SketchEntity> 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));
}