mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-26 10:21:00 +00:00
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:
co-authored by
Claude Opus 5
parent
df45edb13d
commit
5d7fc8c545
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(); };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user