diff --git a/scripts/CAD/check-gui-sketching.py b/scripts/CAD/check-gui-sketching.py index 9e10f882ad..3b55411bd6 100644 --- a/scripts/CAD/check-gui-sketching.py +++ b/scripts/CAD/check-gui-sketching.py @@ -111,6 +111,18 @@ def click(px, py, pause=0.45, btn=1): time.sleep(pause) +def click_ctrl(px, py, pause=0.45): + """Ctrl+click: EXTEND the sketch selection instead of replacing it. + + A plain second click clears the first pick (DesignSketchTool's Select branch only keeps + one entity unless `extend` is set), so a two-entity constraint driven by two plain clicks + silently arrives with one pick and is rejected for the wrong reason. + """ + _, X, Y, _, _ = win() + xdo(f"keydown ctrl mousemove {X+int(px)} {Y+int(py)} click --delay 120 1 keyup ctrl") + time.sleep(pause) + + def move(px, py, pause=0.2): _, X, Y, _, _ = win() xdo(f"mousemove {X+int(px)} {Y+int(py)}") @@ -870,6 +882,16 @@ CON_BTN = {n: (449 + 42 * i, CON_BTN_Y) for i, n in enumerate( "equal_radius", "collinear", "concentric", "tangent", "midpoint", "symmetric", "sym_v", "sym_h", "angle", "radius", "diameter", "fix", "dist_x", "dist_y"])} +# The SAME twenty buttons, at their SKETCH-mode x. Constraining during a sketch put the group +# after the (wide) sketch toolbar instead of at the start of an otherwise empty row, so every +# button sits 228 px further right. Measured, not derived: the strip was screenshotted in sketch +# mode and the icon columns detected — first centre 677, pitch 42, twenty of them. Deriving it +# from the Constrain-mode map is exactly how this table drifted the last time. +CON_BTN_SKETCH = {n: (677 + 42 * i, CON_BTN_Y) for i, n in enumerate( + ["horizontal", "vertical", "parallel", "perpendicular", "coincident", "equal", + "equal_radius", "collinear", "concentric", "tangent", "midpoint", "symmetric", + "sym_v", "sym_h", "angle", "radius", "diameter", "fix", "dist_x", "dist_y"])} + def draw_rect_undimensioned(): """A rectangle by two clicks, with both queued value fields dismissed (Esc keeps it as drawn).""" @@ -996,6 +1018,75 @@ def angle_between(a, b): return math.degrees(math.acos(max(-1.0, min(1.0, c)))) +def parallel_gap(a, b): + """How far from parallel, in degrees. Parallel reads 0 or 180; both mean parallel.""" + ang = angle_between(a, b) + return min(ang, 180.0 - ang) + + +def rung_parallel(): + reset_document() + print("\nD10 constrain — two divergent lines made parallel (the button had no rung at all)") + enter_sketch("l") + clickmm(-50, -30); clickmm(30, -18) + key("Escape", 0.7); key("Escape", 0.7) + key("l", 0.6) + # Well outside inference's 3 deg snap, for the same reason D3 starts at 56: a pair that + # arrives already parallel would let a dead button pass the rung. + clickmm(30, -18); clickmm(55, 35) + key("Escape", 0.7); key("Escape", 0.7) + d0 = describe() + g0 = parallel_gap(d0["entities"][0], d0["entities"][1]) + check("ANGLE", g0 > 1e-3, f"they start {g0:.6f} deg from parallel") + key("k", 1.5) + d1 = describe() + clickmm(*mid(d1["entities"][0])); clickmm(*mid(d1["entities"][1])) + click(*CON_BTN["parallel"]) + time.sleep(1.0) + d = describe() + g = parallel_gap(d["entities"][0], d["entities"][1]) + check("ANGLE", near(g, 0.0, 1e-6), f"now {g:.9f} deg from parallel") + check("LENGTH", d["entities"][0]["length"] > 1.0 and d["entities"][1]["length"] > 1.0, + f"neither line collapsed: {d['entities'][0]['length']:.6f}, {d['entities'][1]['length']:.6f}") + d2 = confirm_and_reopen() + check("CLOSED", d2["constraints"] > 0 and d2["solve_ok"], + f"{d2['constraints']} constraints survived the commit, dof {d2['dof']}") + leave_sketch() + + +def rung_live_constrain(): + reset_document() + print("\nD11 constrain WHILE SKETCHING — no commit, no tree pick, no padlock") + enter_sketch("l") + clickmm(-50, -30); clickmm(30, -18) + key("Escape", 0.7); key("Escape", 0.7) + key("l", 0.6) + clickmm(30, -18); clickmm(55, 35) + # Two Escapes only: the first clears the polyline's pending point, the second drops the + # tool to Select. The session stays LIVE -- that is the whole point of this rung. + key("Escape", 0.7); key("Escape", 0.7) + d0 = describe() + check("VERTEX", len(d0["entities"]) == 2, f"{len(d0['entities'])} entities in the live sketch") + g0 = parallel_gap(d0["entities"][0], d0["entities"][1]) + check("ANGLE", g0 > 1e-3, f"they start {g0:.6f} deg from parallel") + # Pick both in the LIVE session, then press the button. No "k": pressing Constrain is + # exactly the step this rung exists to prove is no longer necessary. + clickmm(*mid(d0["entities"][0])) + cx, cy = mid(d0["entities"][1]) + click_ctrl(*px(cx, cy)) + click(*CON_BTN_SKETCH["parallel"]) + time.sleep(1.2) + d = describe() + g = parallel_gap(d["entities"][0], d["entities"][1]) + check("ANGLE", near(g, 0.0, 1e-6), f"parallel without ever leaving the sketch: {g:.9f} deg") + check("LENGTH", d["entities"][0]["length"] > 1.0 and d["entities"][1]["length"] > 1.0, + f"neither line collapsed: {d['entities'][0]['length']:.6f}, {d['entities'][1]['length']:.6f}") + d2 = confirm_and_reopen() + check("CLOSED", d2["constraints"] > 0 and d2["solve_ok"], + f"{d2['constraints']} constraints survived the commit, dof {d2['dof']}") + leave_sketch() + + # =================================================================== DURABILITY # Exactness that does not survive an undo or a save is not exactness. @@ -1541,6 +1632,7 @@ RUNGS = {"rect": rung_rect, "circle": rung_circle, "line": rung_line, "arc": run "distance_xy": rung_distance_xy, "symmetric_axis": rung_symmetric_axis, "coincident_points": rung_coincident_points, "type_guards": rung_type_guards, + "parallel": rung_parallel, "live_constrain": rung_live_constrain, "undo": rung_undo, "feature_undo": rung_feature_undo, "roundtrip": rung_roundtrip, "scale": rung_scale} diff --git a/src/libslic3r/CAD/SketchEngine.cpp b/src/libslic3r/CAD/SketchEngine.cpp index f7d2d10764..199139d632 100644 --- a/src/libslic3r/CAD/SketchEngine.cpp +++ b/src/libslic3r/CAD/SketchEngine.cpp @@ -1900,4 +1900,300 @@ SketchEntity SketchEngine::make_bridge(const SketchEntity& a, int a_end, return e; } +// ---- entity-constraint planning (Fase 4.2) ---- +// Pure kernel port of DesignPanel::apply_entity_constraint's decision logic, so the GUI +// and the live-sketch tool share ONE legality/role/value decision instead of each carrying +// its own copy. The Coincident phantom-p1 defect was fixed in one branch and stayed alive +// in the next one down precisely because the logic lived in a wx method that could not be +// unit-tested. No wx, no translation: the caller maps ConstraintReject to a string. + +int sketch_entity_ends(const SketchEntity& e, std::pair out[2]) +{ + using ET = SketchEntity::Type; + using R = SketchPointRole; + switch (e.type) { + case ET::Line: case ET::Arc: case ET::BSpline: case ET::EllipseArc: + out[0] = {R::P0, e.p0}; out[1] = {R::P1, e.p1}; return 2; + case ET::Point: + out[0] = {R::P0, e.p0}; return 1; + case ET::Circle: case ET::Ellipse: + out[0] = {R::Center, e.center}; return 1; + } + return 0; +} + +bool sketch_closest_ends(const SketchEntity& A, const SketchEntity& B, + SketchPointRole& ra, SketchPointRole& rb, Vec2d& pa, Vec2d& pb) +{ + std::pair aps[2], bps[2]; + const int na = sketch_entity_ends(A, aps), nb = sketch_entity_ends(B, bps); + if (na == 0 || nb == 0) return false; + double best = 1e30; + ra = aps[0].first; rb = bps[0].first; pa = aps[0].second; pb = bps[0].second; + for (int i = 0; i < na; ++i) + for (int j = 0; j < nb; ++j) { + const double d = (aps[i].second - bps[j].second).squaredNorm(); + if (d < best) { + best = d; + ra = aps[i].first; rb = bps[j].first; + pa = aps[i].second; pb = bps[j].second; + } + } + return true; +} + +ConstraintPlan plan_entity_constraint(const std::vector& ents, + int e0, int e1, int e2, SketchConstraintType type) +{ + using R = SketchPointRole; + using T = SketchConstraintType; + const int n = int(ents.size()); + + ConstraintPlan plan; + + auto is_round = [](const SketchEntity& e) { + return e.type == SketchEntity::Type::Circle || e.type == SketchEntity::Type::Arc; }; + + // One Equal button, two meanings: lines get equal length, curves equal radius. + if (type == T::EqualLength && e0 >= 0 && e1 >= 0 && e0 < n && e1 < n && + is_round(ents[e0]) && is_round(ents[e1])) + type = T::EqualRadius; + + const bool needs_two = (type == T::Parallel || type == T::Perpendicular || + type == T::EqualLength || type == T::Coincident || + type == T::Concentric || type == T::Tangent || + type == T::Angle || type == T::Midpoint || + type == T::Symmetric || type == T::EqualRadius || + type == T::Collinear || + type == T::SymmetricAboutY || type == T::SymmetricAboutX || + type == T::DistanceX || type == T::DistanceY); + if (e0 < 0 || e0 >= n || (needs_two && (e1 < 0 || e1 >= n))) { + plan.kind = ConstraintPlan::Kind::Reject; + plan.reason = needs_two ? ConstraintReject::NeedTwoEntities : ConstraintReject::NeedOneEntity; + return plan; + } + + plan.kind = ConstraintPlan::Kind::Apply; + SketchEntityConstraintDef def; + def.type = type; + def.value = 0.0; + switch (type) { + case T::Horizontal: + case T::Vertical: + // One line: level/plumb its own two endpoints. Not pedantry -- with a Point or + // Circle picked, P1 is a role the solver silently drops while STORING the + // constraint, so the sketch claims to be constrained when it is not. + if (ents[e0].type != SketchEntity::Type::Line) { + plan.kind = ConstraintPlan::Kind::Reject; + plan.reason = ConstraintReject::NeedALine; + return plan; + } + def.ea = e0; def.ra = R::P0; + def.eb = e0; def.rb = R::P1; + break; + case T::Parallel: + case T::Perpendicular: + case T::EqualLength: + // Two whole line segments (roles unused). Guard ADDED here (the GUI does not check + // this yet): a non-line pick produced a def the solver drops, the same silent no-op + // as Horizontal on a Point above. + if (ents[e0].type != SketchEntity::Type::Line || + ents[e1].type != SketchEntity::Type::Line) { + plan.kind = ConstraintPlan::Kind::Reject; + plan.reason = ConstraintReject::NeedTwoLines; + return plan; + } + def.ea = e0; def.eb = e1; + break; + case T::Coincident: { + // Join the closest point pair, NOT {p0,p1} on both -- two Points would otherwise + // resolve to their phantom (0,0) p1s and the constraint would do nothing at all. + R ra, rb; Vec2d pa, pb; + if (!sketch_closest_ends(ents[e0], ents[e1], ra, rb, pa, pb)) { + plan.kind = ConstraintPlan::Kind::Reject; + plan.reason = ConstraintReject::NeedJoinablePoints; + return plan; + } + def.ea = e0; def.ra = ra; def.eb = e1; def.rb = rb; + break; + } + case T::DistanceX: + case T::DistanceY: { + R ra, rb; Vec2d pa, pb; + if (!sketch_closest_ends(ents[e0], ents[e1], ra, rb, pa, pb)) { + plan.kind = ConstraintPlan::Kind::Reject; + plan.reason = ConstraintReject::NeedMeasurablePoints; + return plan; + } + // The constraint is SIGNED (fixes (pB - pA).dot(axis)). Order the refs so the shown + // value is the positive one -- accepting a dimension must be a no-op, not a flip. + int a = e0, b = e1; + double delta = (type == T::DistanceX) ? (pb.x() - pa.x()) : (pb.y() - pa.y()); + if (delta < 0.0) { std::swap(a, b); std::swap(ra, rb); delta = -delta; } + plan.kind = ConstraintPlan::Kind::AskValue; + def.ea = a; def.ra = ra; + def.eb = b; def.rb = rb; + plan.prefill = delta; + break; + } + case T::Concentric: + if (!is_round(ents[e0]) || !is_round(ents[e1])) { + plan.kind = ConstraintPlan::Kind::Reject; + plan.reason = ConstraintReject::NeedTwoRounds; + return plan; + } + def.ea = e0; def.ra = R::Center; def.eb = e1; def.rb = R::Center; + break; + case T::Tangent: { + // line+round or round+round; the kernel detects the entity types. + const bool ok = (is_round(ents[e0]) && ents[e1].type == SketchEntity::Type::Line) || + (is_round(ents[e1]) && ents[e0].type == SketchEntity::Type::Line) || + (is_round(ents[e0]) && is_round(ents[e1])); + if (!ok) { + plan.kind = ConstraintPlan::Kind::Reject; + plan.reason = ConstraintReject::NeedTangentPair; + return plan; + } + def.ea = e0; def.eb = e1; + break; + } + case T::Angle: { + // Angle between two line segments. "Line segments" is a check, not an assumption: + // p1-p0 on a Circle is (0,0)-centre, so two circles used to pre-fill with the angle + // between their centre POSITION vectors. + if (ents[e0].type != SketchEntity::Type::Line || + ents[e1].type != SketchEntity::Type::Line) { + plan.kind = ConstraintPlan::Kind::Reject; + plan.reason = ConstraintReject::NeedTwoLines; + return plan; + } + const int a = e0, b = e1; + const Vec2d da = ents[a].p1 - ents[a].p0; + const Vec2d db = ents[b].p1 - ents[b].p0; + double cur = 90.0; + const double na = da.norm(), nb = db.norm(); + if (na > 1e-9 && nb > 1e-9) { + const double c = std::max(-1.0, std::min(1.0, da.dot(db) / (na * nb))); + cur = std::acos(c) * 180.0 / M_PI; + } + plan.kind = ConstraintPlan::Kind::AskValue; + def.ea = a; def.eb = b; + plan.prefill = cur; // degrees; the caller converts to radians on commit + break; + } + case T::Midpoint: { + // One pick is a Point, the other a Line: the point is the line's midpoint. + const SketchEntity& A = ents[e0]; + const SketchEntity& B = ents[e1]; + int pt = -1, ln = -1; + if (A.type == SketchEntity::Type::Point && B.type == SketchEntity::Type::Line) { pt = e0; ln = e1; } + else if (B.type == SketchEntity::Type::Point && A.type == SketchEntity::Type::Line) { pt = e1; ln = e0; } + else { + plan.kind = ConstraintPlan::Kind::Reject; + plan.reason = ConstraintReject::NeedPointAndLine; + return plan; + } + def.ea = pt; def.ra = R::P0; def.eb = ln; + break; + } + case T::Symmetric: { + // Two entities made symmetric about a third (axis) line: slot0=A, slot1=B, e2=axis. + // Two Points -> one pair; two Lines -> two endpoint pairs (P0/P0 and P1/P1), exactly + // the defs DesignPanel builds today. + const int axis = e2; + if (axis < 0 || axis >= n || ents[axis].type != SketchEntity::Type::Line) { + plan.kind = ConstraintPlan::Kind::Reject; + plan.reason = ConstraintReject::NeedAxisLine; + return plan; + } + using ET = SketchEntity::Type; + const ET ta = ents[e0].type, tb = ents[e1].type; + if (!((ta == ET::Point && tb == ET::Point) || (ta == ET::Line && tb == ET::Line))) { + plan.kind = ConstraintPlan::Kind::Reject; + plan.reason = ConstraintReject::NeedTwoPointsOrLines; + return plan; + } + auto mk = [&](R ra, R rb) { + SketchEntityConstraintDef d; + d.type = T::Symmetric; + d.ea = e0; d.ra = ra; d.eb = e1; d.rb = rb; d.ec = axis; + plan.defs.push_back(d); + }; + if (ta == ET::Point) { mk(R::P0, R::P0); } + else { mk(R::P0, R::P0); mk(R::P1, R::P1); } + return plan; + } + case T::SymmetricAboutY: + case T::SymmetricAboutX: { + // Two entities made symmetric about the sketch's vertical/horizontal axis, which is + // implicit (no picked axis line): e2 is ignored and the axis is a negative sentinel + // in ec. + using ET = SketchEntity::Type; + const ET ta = ents[e0].type, tb = ents[e1].type; + if (!((ta == ET::Point && tb == ET::Point) || (ta == ET::Line && tb == ET::Line))) { + plan.kind = ConstraintPlan::Kind::Reject; + plan.reason = ConstraintReject::NeedTwoPointsOrLines; + return plan; + } + const int axis = (type == T::SymmetricAboutY) ? kSketchRefAxisY : kSketchRefAxisX; + auto mk = [&](R ra, R rb) { + SketchEntityConstraintDef d; + d.type = type; + d.ea = e0; d.ra = ra; d.eb = e1; d.rb = rb; d.ec = axis; + plan.defs.push_back(d); + }; + if (ta == ET::Point) { mk(R::P0, R::P0); } + else { mk(R::P0, R::P0); mk(R::P1, R::P1); } + return plan; + } + case T::EqualRadius: + if (!is_round(ents[e0]) || !is_round(ents[e1])) { + plan.kind = ConstraintPlan::Kind::Reject; + plan.reason = ConstraintReject::NeedTwoRounds; + return plan; + } + def.ea = e0; def.eb = e1; + break; + case T::Collinear: + if (ents[e0].type != SketchEntity::Type::Line || ents[e1].type != SketchEntity::Type::Line) { + plan.kind = ConstraintPlan::Kind::Reject; + plan.reason = ConstraintReject::NeedTwoLines; + return plan; + } + def.ea = e0; def.eb = e1; + break; + case T::Fix: { + // Anchor the picked entity's reference point to its current coordinate. A single + // point -- not both endpoints -- so it composes with an existing H/V/length + // constraint instead of duplicating it. + using ET = SketchEntity::Type; + const ET et = ents[e0].type; + def.ea = e0; + def.ra = (et == ET::Circle || et == ET::Ellipse || + et == ET::Arc || et == ET::EllipseArc) ? R::Center : R::P0; + break; + } + case T::Radius: + case T::Diameter: { + if (!is_round(ents[e0])) { + plan.kind = ConstraintPlan::Kind::Reject; + plan.reason = ConstraintReject::NeedRound; + return plan; + } + plan.kind = ConstraintPlan::Kind::AskValue; + def.ea = e0; def.ra = R::Center; + plan.prefill = (type == T::Diameter) ? 2.0 * ents[e0].radius : ents[e0].radius; + break; + } + default: + // Distance / LockX / LockY / PointOnLine / PointOnObject (and any future type) have + // no entity-constraint binding; the GUI's own switch falls to "Unsupported". + plan.kind = ConstraintPlan::Kind::Reject; + plan.reason = ConstraintReject::Unsupported; + return plan; + } + plan.defs.push_back(def); + return plan; +} + } // namespace Slic3r diff --git a/src/libslic3r/CAD/SketchEngine.hpp b/src/libslic3r/CAD/SketchEngine.hpp index 2f2fcdf7ca..7ccc807103 100644 --- a/src/libslic3r/CAD/SketchEngine.hpp +++ b/src/libslic3r/CAD/SketchEngine.hpp @@ -11,6 +11,7 @@ #include #include #include +#include namespace Slic3r { @@ -137,6 +138,43 @@ constexpr int kSketchRefAxisY = -4; // the sketch Y axis, through the origin, inline bool is_sketch_ref(int ei) { return ei <= kSketchRefOrigin; } +// How many real endpoints a type exposes, and which roles they are. p1 is UNUSED for +// Circle/Point/Ellipse (SketchEntity::p1 above) and reads (0,0) — walking {P0,P1} blindly +// over those invents a phantom endpoint at the origin, which for a pair of Points always +// wins a closest-pair search at distance 0 and binds a role the solver silently refuses. +int sketch_entity_ends(const SketchEntity& e, std::pair out[2]); +bool sketch_closest_ends(const SketchEntity& A, const SketchEntity& B, + SketchPointRole& ra, SketchPointRole& rb, Vec2d& pa, Vec2d& pb); + +// Why an entity-constraint pick is refused. The caller maps a reason to a localized string; +// the planner itself stays translation-free. +enum class ConstraintReject { + None, NeedOneEntity, NeedTwoEntities, NeedALine, NeedTwoLines, + NeedTwoRounds, NeedTangentPair, NeedJoinablePoints, NeedMeasurablePoints, + // The following are not in the GUI's current switch but are the faithful outcomes of + // its remaining branches; they need a reason too or the caller cannot tell them apart. + NeedPointAndLine, // Midpoint: one Point + one Line + NeedTwoPointsOrLines, // Symmetric / SymmetricAboutX/Y: two Points or two Lines + NeedAxisLine, // Symmetric: e2 must be a Line to act as the axis + NeedRound, // Radius/Diameter: a Circle or Arc + Unsupported // entity-constraint path has no binding for this type +}; + +struct ConstraintPlan { + enum class Kind { Reject, Apply, AskValue }; + Kind kind{Kind::Reject}; + ConstraintReject reason{ConstraintReject::None}; + // Apply/AskValue only: the defs to commit. One element for every ordinary type, TWO for + // Symmetric/SymmetricAboutX/Y on two lines (P0/P0 and P1/P1), matching the GUI's builds. + std::vector defs{}; + double prefill{0.0}; // AskValue only: the value to show pre-filled +}; + +// Pure: no wx, no translation, no UI. The caller maps `reason` to a localized string. +// e2 is the axis-line pick Symmetric needs (def.ec); every other type ignores it. +ConstraintPlan plan_entity_constraint(const std::vector& ents, + int e0, int e1, int e2, SketchConstraintType type); + // Solve a bare entity list in place against entity-form constraints. Shared by // CadDocument::solve_sketch_feature (committed features) and the in-session GUI // sketch tool (live solving as dimensions/constraints are added). Returns true on diff --git a/src/slic3r/GUI/CAD/DesignCanvas.cpp b/src/slic3r/GUI/CAD/DesignCanvas.cpp index 148b32db4f..bba4d42014 100644 --- a/src/slic3r/GUI/CAD/DesignCanvas.cpp +++ b/src/slic3r/GUI/CAD/DesignCanvas.cpp @@ -1014,6 +1014,11 @@ void DesignCanvas::set_on_sketch_exit(std::function cb) m_sketch_tool.on_exit = std::move(cb); } +void DesignCanvas::set_on_sketch_exit_refused(std::function cb) +{ + m_sketch_tool.on_exit_refused = std::move(cb); +} + void DesignCanvas::set_on_move_exit(std::function cb) { m_sketch_tool.on_move_exit = std::move(cb); @@ -1474,6 +1479,21 @@ bool DesignCanvas::sketch_first_selected_type(SketchEntity::Type& out) const return m_sketch_tool.first_selected_type(out); } +const std::vector& DesignCanvas::sketch_selection() const +{ + return m_sketch_tool.selection(); +} + +const std::vector& DesignCanvas::sketch_entities() const +{ + return m_sketch_tool.entities(); +} + +bool DesignCanvas::try_add_sketch_constraints(const std::vector& defs) +{ + return m_sketch_tool.try_add_constraints(defs); +} + bool DesignCanvas::selected_constrain_entities(int& e0, int& e1) const { return m_sketch_tool.selected_constrain_entities(e0, e1); diff --git a/src/slic3r/GUI/CAD/DesignCanvas.hpp b/src/slic3r/GUI/CAD/DesignCanvas.hpp index f8c5b6bc57..7f8de25a04 100644 --- a/src/slic3r/GUI/CAD/DesignCanvas.hpp +++ b/src/slic3r/GUI/CAD/DesignCanvas.hpp @@ -209,6 +209,7 @@ public: void clear_base_pick(); void set_on_datum_base_picked(std::function cb); void set_on_sketch_exit(std::function cb); // Esc -> exit the tool + void set_on_sketch_exit_refused(std::function cb); // Esc declined: sketch has work void set_on_undo_redo(std::function cb); // Ctrl+Z / Ctrl+Shift+Z // Persistently draw committed sketches (un-consumed ones stay visible). void set_display_sketches(std::vector ds); @@ -293,6 +294,12 @@ public: // one is. Returns 0 when nothing is selected. int sketch_selection_count() const; bool sketch_first_selected_type(SketchEntity::Type& out) const; + // Live sketch session (Fase 4.2 live constraint path): the panel reads the in-session + // selection and entities, and commits a planned constraint through the tool's + // append->solve->keep-or-rollback, rather than reaching into mcp_sketch_tool(). + const std::vector& sketch_selection() const; + const std::vector& sketch_entities() const; + bool try_add_sketch_constraints(const std::vector& defs); // In-canvas bbox transform of imported Text/SVG art (replaces the Move/Scale dialog). void begin_imported_transform(int feat, diff --git a/src/slic3r/GUI/CAD/DesignPanel.cpp b/src/slic3r/GUI/CAD/DesignPanel.cpp index 6e8bc719ed..c80d29ec6f 100644 --- a/src/slic3r/GUI/CAD/DesignPanel.cpp +++ b/src/slic3r/GUI/CAD/DesignPanel.cpp @@ -1505,9 +1505,12 @@ DesignPanel::DesignPanel(wxWindow* parent) } // --- Constrain group: geometric constraints + dimensions + edit ops + Done - m_tb_constrain = new wxBoxSizer(wxHORIZONTAL); - auto cadd = [this](wxWindow* w) { m_tb_constrain->Add(w, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 2); }; - m_tb_constrain->Add(caption(_L("CONSTRAIN")), 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 8); + // Fase 4.2 live path: these buttons are shown during a SKETCH too, not only in Constrain + // mode, so a constraint applies to the live selection without committing first. The caption + // travels with them; the session's Confirm/Cancel stay in m_tb_action (mode-appropriate). + m_tb_relations = new wxBoxSizer(wxHORIZONTAL); + auto cadd = [this](wxWindow* w) { m_tb_relations->Add(w, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 2); }; + m_tb_relations->Add(caption(_L("CONSTRAIN")), 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 8); { auto cbtn = [&](const char* icon, const wxString& tip, SketchConstraintType type) { auto* b = icon_btn(icon, tip); @@ -1689,7 +1692,7 @@ DesignPanel::DesignPanel(wxWindow* parent) add_sep(tbrow); tbrow->Add(m_tb_feature, 0, wxALIGN_CENTER_VERTICAL | wxTOP | wxBOTTOM, 5); tbrow->Add(m_tb_sketch, 0, wxALIGN_CENTER_VERTICAL | wxTOP | wxBOTTOM, 5); - tbrow->Add(m_tb_constrain, 0, wxALIGN_CENTER_VERTICAL | wxTOP | wxBOTTOM, 5); + tbrow->Add(m_tb_relations, 0, wxALIGN_CENTER_VERTICAL | wxTOP | wxBOTTOM, 5); tbrow->AddStretchSpacer(); tbrow->Add(m_tb_commit, 0, wxALIGN_CENTER_VERTICAL | wxTOP | wxBOTTOM, 5); tbrow->Add(m_tb_action, 0, wxALIGN_CENTER_VERTICAL | wxTOP | wxBOTTOM, 5); @@ -4124,6 +4127,16 @@ DesignPanel::DesignPanel(wxWindow* parent) m_status->Refresh(); }); + // The tool refuses the exit layer of Esc when the sketch still has unsaved geometry (a + // second consecutive Esc is let through). The refusal lives in the tool's request_exit(); + // only the STATUS LINE lives here, so the tool reports via this callback instead of + // writing text itself. + m_viewport->set_on_sketch_exit_refused([this]() { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(_L("Sketch has unsaved geometry — use Confirm to keep it, or Cancel to discard")); + m_status->Refresh(); + }); + // Ctrl+Z / Ctrl+Shift+Z (Ctrl+Y) from the viewport → feature-history undo/redo. m_viewport->set_on_undo_redo([this](bool redo) { do_undo_redo(redo); }); @@ -4418,7 +4431,9 @@ void DesignPanel::set_ui_mode(UiMode m) wxSizer* s = m_toolbar->GetSizer(); s->Show(m_tb_feature, m == UiMode::Feature, true); s->Show(m_tb_sketch, m == UiMode::Sketch, true); - s->Show(m_tb_constrain, m == UiMode::Constrain, true); + // Constraint buttons are live in BOTH Sketch and Constrain (Fase 4.2 live path); the + // Confirm/Cancel action bar (m_tb_action) is already mode-appropriate and stays untouched. + s->Show(m_tb_relations, m == UiMode::Sketch || m == UiMode::Constrain, true); m_toolbar->Layout(); m_toolbar->FitInside(); // refresh the horizontal scroll range for the new group widths set_active_tool_btn(nullptr); // no tool selected right after a mode switch @@ -7720,68 +7735,57 @@ void DesignPanel::on_begin_constrain(int sel_override) m_status->Refresh(); } -// The point roles an entity ACTUALLY exposes, for the closest-pair searches below. -// -// Enumerating {P0,p0},{P1,p1} blindly is a silent-no-op generator: a Point's p1 is unused and -// reads (0,0) (SketchEngine.hpp:32), as does a Circle's, so the search picks those two phantom -// origins at distance 0 -- which for a pair of Points ALWAYS wins, being the smallest distance -// there is. The constraint is then added against a role the solver cannot resolve -// (ptOf(Point,P1) -> s.p1 -> 0), ref_ok fails, and it is dropped. Nothing errors: the button -// just does nothing, on every press. Measured on both the DistanceX/Y and the Coincident paths. -// -// Returns the number of roles written, 0 for a type with no usable point. Same role set as -// roles_of() in DesignSketchTool::heal_coincidences (the copy at 9348 -- the one inside -// infer_auto_constraints omits EllipseArc), plus the circle centre, which is a real solver -// handle for BOTH Circle (SketchSolver.cpp:109) and Ellipse (:126), and the only sensible thing -// a round entity can be coincident with or measured from. -// -// Every one of the seven types returns at least one role today, so the callers' "no usable -// point" branch is unreachable and their message cannot currently fire. It is kept for the -// eighth type, not as protection against the defect above -- that one was never a missing role, -// it was a role the solver silently refused. -static int entity_ends(const SketchEntity& e, std::pair out[2]) +// Why an entity-constraint pick was refused, as a localized string. The kernel's +// ConstraintReject is coarse on purpose (one reason covers several constraint types whose +// BUTTONS wear different words), so `type` disambiguates the wording without touching the +// kernel. Reuses today's exact strings so nothing regresses for a user or the ladder. +static wxString constraint_reject_text(ConstraintReject reason, SketchConstraintType type) { - using ET = SketchEntity::Type; - using R = SketchPointRole; - switch (e.type) { - case ET::Line: case ET::Arc: case ET::BSpline: case ET::EllipseArc: - out[0] = {R::P0, e.p0}; out[1] = {R::P1, e.p1}; return 2; - case ET::Point: - out[0] = {R::P0, e.p0}; return 1; - case ET::Circle: case ET::Ellipse: - out[0] = {R::Center, e.center}; return 1; + using T = SketchConstraintType; + switch (reason) { + case ConstraintReject::NeedOneEntity: return _L("Pick an entity first"); + case ConstraintReject::NeedTwoEntities: return _L("Pick two entities first"); + case ConstraintReject::NeedALine: return _L("Horizontal and Vertical apply to a line"); + case ConstraintReject::NeedTwoLines: + if (type == T::Angle) return _L("Angle applies between two lines"); + if (type == T::Collinear) return _L("Collinear needs two lines"); + return _L("Parallel, perpendicular and equal length apply to two lines"); + case ConstraintReject::NeedTwoRounds: + if (type == T::EqualRadius) return _L("Equal radius needs two circles or arcs"); + return _L("Concentric needs two circles or arcs"); + case ConstraintReject::NeedTangentPair: return _L("Tangent needs a line and a circle/arc, or two circles/arcs"); + case ConstraintReject::NeedJoinablePoints: return _L("This constraint needs two entities with a point to join"); + case ConstraintReject::NeedMeasurablePoints: return _L("This dimension needs two entities with a point to measure between"); + case ConstraintReject::NeedPointAndLine: return _L("Midpoint needs a point and a line"); + case ConstraintReject::NeedTwoPointsOrLines: + if (type == T::Symmetric) return _L("Symmetric needs two points or two lines + an axis"); + return _L("Symmetric needs two points or two lines"); + case ConstraintReject::NeedAxisLine: return _L("Symmetric: pick two entities, then an axis line"); + case ConstraintReject::NeedRound: return _L("Radius/Diameter needs a circle or arc"); + case ConstraintReject::Unsupported: return _L("Unsupported constraint"); + case ConstraintReject::None: + default: return wxString(); } - return 0; } -// The closest (role, role) pair between two entities, over the roles each really exposes. -// Returns false when either side has none, so the caller can say so instead of going quiet. -static bool closest_ends(const SketchEntity& A, const SketchEntity& B, - SketchPointRole& ra, SketchPointRole& rb, Vec2d& pa, Vec2d& pb) +// Write the typed value into every planned def. The planner returns the prefill in DISPLAY +// units and leaves def.value = 0; Angle is the one type whose stored value is not the number +// the user sees (it is radians), so the conversion lives here, exactly as the old +// apply_entity_constraint did on its Angle branch. +static void write_plan_value(std::vector& defs, double v) { - std::pair aps[2], bps[2]; - const int na = entity_ends(A, aps), nb = entity_ends(B, bps); - if (na == 0 || nb == 0) return false; - double best = 1e30; - ra = aps[0].first; rb = bps[0].first; pa = aps[0].second; pb = bps[0].second; - for (int i = 0; i < na; ++i) - for (int j = 0; j < nb; ++j) { - const double d = (aps[i].second - bps[j].second).squaredNorm(); - if (d < best) { - best = d; - ra = aps[i].first; rb = bps[j].first; - pa = aps[i].second; pb = bps[j].second; - } - } - return true; + for (auto& d : defs) + d.value = (d.type == SketchConstraintType::Angle) ? v * M_PI / 180.0 : v; } void DesignPanel::apply_entity_constraint(SketchConstraintType type) { - using R = SketchPointRole; - using T = SketchConstraintType; int e0 = -1, e1 = -1; m_viewport->selected_constrain_entities(e0, e1); + const int e2 = m_viewport->selected_constrain_axis(); // Symmetric axis pick, -1 otherwise + + CadFeature& feat = m_doc.features[m_constrain_feat]; + const ConstraintPlan plan = plan_entity_constraint(feat.entities, e0, e1, e2, type); auto fail = [this](const wxString& msg) { m_status->SetForegroundColour(wxColour(235, 110, 110)); @@ -7789,252 +7793,72 @@ void DesignPanel::apply_entity_constraint(SketchConstraintType type) m_status->Refresh(); }; - CadFeature& feat = m_doc.features[m_constrain_feat]; - - auto is_round = [](const SketchEntity& e) { - return e.type == SketchEntity::Type::Circle || e.type == SketchEntity::Type::Arc; }; - - // One Equal button, two meanings: lines get equal length, curves equal radius. - if (type == T::EqualLength && e0 >= 0 && e1 >= 0 && - e0 < int(feat.entities.size()) && e1 < int(feat.entities.size()) && - is_round(feat.entities[e0]) && is_round(feat.entities[e1])) - type = T::EqualRadius; - - const bool needs_two = (type == T::Parallel || type == T::Perpendicular || - type == T::EqualLength || type == T::Coincident || - type == T::Concentric || type == T::Tangent || - type == T::Angle || type == T::Midpoint || - type == T::Symmetric || type == T::EqualRadius || - type == T::Collinear || - type == T::SymmetricAboutY || type == T::SymmetricAboutX || - type == T::DistanceX || type == T::DistanceY); - if (e0 < 0 || e0 >= int(feat.entities.size()) || - (needs_two && (e1 < 0 || e1 >= int(feat.entities.size())))) { - fail(needs_two ? _L("Pick two entities first") : _L("Pick an entity first")); + switch (plan.kind) { + case ConstraintPlan::Kind::Reject: + fail(constraint_reject_text(plan.reason, type)); + return; + case ConstraintPlan::Kind::AskValue: + m_viewport->open_inline_value(plan.prefill, [this, plan](double v) { + std::vector defs = plan.defs; + write_plan_value(defs, v); + commit_entity_constraints(defs); + }); + return; // deferred: commit runs on the typed value + case ConstraintPlan::Kind::Apply: + commit_entity_constraints(plan.defs); return; } - - SketchEntityConstraintDef def; - def.type = type; - def.value = 0.0; - switch (type) { - case T::Horizontal: - case T::Vertical: - // One line: level/plumb its own two endpoints. The type check is not pedantry -- with a - // Point or a Circle picked, P1 is a role the solver cannot resolve, so the constraint is - // dropped at ref_ok (SketchSolver.cpp:184) while still being STORED in the feature. It - // then sits in the Constraints list, permanently doing nothing, which is worse than the - // button refusing: the panel says the sketch is constrained when it is not. Measured on - // the rig: Horizontal on a lone Point commits, constraints goes 0 -> 1, nothing moves. - if (feat.entities[e0].type != SketchEntity::Type::Line) { - fail(_L("Horizontal and Vertical apply to a line")); - return; - } - def.ea = e0; def.ra = R::P0; - def.eb = e0; def.rb = R::P1; - break; - case T::Parallel: - case T::Perpendicular: - case T::EqualLength: - def.ea = e0; def.eb = e1; // two whole line segments (roles unused) - break; - case T::Coincident: { - // Join the closest point pair of the two picked entities. NOT {p0,p1} on both: see - // entity_ends above -- two Points always resolved to their phantom (0,0) p1s, so - // Coincident on a pair of points did nothing at all, on every press. - R ra, rb; Vec2d pa, pb; - if (!closest_ends(feat.entities[e0], feat.entities[e1], ra, rb, pa, pb)) { - fail(_L("This constraint needs two entities with a point to join")); - return; - } - def.ea = e0; def.ra = ra; def.eb = e1; def.rb = rb; - break; - } - case T::DistanceX: - case T::DistanceY: { - // Axis-projected distance between the closest endpoint pair of the two picked - // entities. Typed in-canvas pre-filled with the current projection, committed on - // the typed value (same deferred pattern as Angle). - // Only over the roles each entity really exposes -- see entity_ends above. - R ra, rb; Vec2d pa, pb; - if (!closest_ends(feat.entities[e0], feat.entities[e1], ra, rb, pa, pb)) { - fail(_L("This dimension needs two entities with a point to measure between")); - return; - } - // The constraint is SIGNED: PROJ_PT_DISTANCE fixes (pB - pA).dot(axis), not its - // magnitude. Showing |delta| while the current signed delta is negative would mean - // that opening the dimension and simply accepting the number on screen flips the - // point to the other side of its anchor. Opening a dimension and accepting its own - // value must be a no-op, so order the two refs to make the shown value the positive - // one -- which is also how a dimension ought to read. - int a = e0, b = e1; - double delta = (type == T::DistanceX) ? (pb.x() - pa.x()) : (pb.y() - pa.y()); - if (delta < 0.0) { std::swap(a, b); std::swap(ra, rb); delta = -delta; } - const double cur = delta; - const T tt = type; - m_viewport->open_inline_value(cur, [this, a, b, ra, rb, tt](double v) { - SketchEntityConstraintDef d; - d.type = tt; d.ea = a; d.ra = ra; d.eb = b; d.rb = rb; d.value = v; - commit_entity_constraint(d); - }); - return; // deferred: commit runs on the typed value - } - case T::Concentric: { - // Two circles/arcs: make their centres coincide. - if (!is_round(feat.entities[e0]) || !is_round(feat.entities[e1])) { - fail(_L("Concentric needs two circles or arcs")); return; - } - def.ea = e0; def.ra = R::Center; def.eb = e1; def.rb = R::Center; - break; - } - case T::Tangent: { - // line+round or round+round; the kernel detects the entity types. - const bool ok = (is_round(feat.entities[e0]) && feat.entities[e1].type == SketchEntity::Type::Line) || - (is_round(feat.entities[e1]) && feat.entities[e0].type == SketchEntity::Type::Line) || - (is_round(feat.entities[e0]) && is_round(feat.entities[e1])); - if (!ok) { fail(_L("Tangent needs a line and a circle/arc, or two circles/arcs")); return; } - def.ea = e0; def.eb = e1; - break; - } - case T::Angle: { - // Angle between two line segments; typed in-canvas at the cursor (no card), - // pre-filled with the current angle between the picked lines. - // - // "Line segments" was an assumption, not a check. p1-p0 on a Circle is (0,0)-centre, so - // picking two circles pre-filled the field with the angle between their centre POSITION - // VECTORS -- measured on the rig: two circles on the x axis opened at 178.83 deg. Accept - // that and SLVS_C_ANGLE is emitted on two circle prims, which are not directions. - if (feat.entities[e0].type != SketchEntity::Type::Line || - feat.entities[e1].type != SketchEntity::Type::Line) { - fail(_L("Angle applies between two lines")); - return; - } - const int a = e0, b = e1; - const Vec2d da = feat.entities[a].p1 - feat.entities[a].p0; - const Vec2d db = feat.entities[b].p1 - feat.entities[b].p0; - double cur = 90.0; - const double na = da.norm(), nb = db.norm(); - if (na > 1e-9 && nb > 1e-9) { - const double c = std::max(-1.0, std::min(1.0, da.dot(db) / (na * nb))); - cur = std::acos(c) * 180.0 / M_PI; - } - m_viewport->open_inline_value(cur, [this, a, b](double deg) { - SketchEntityConstraintDef d; - d.type = T::Angle; d.ea = a; d.eb = b; - d.value = deg * M_PI / 180.0; - commit_entity_constraint(d); - }); - return; // deferred: commit runs on the typed value - } - case T::Midpoint: { - // One pick is a Point, the other a Line: the point is the line's midpoint. - const SketchEntity& A = feat.entities[e0]; - const SketchEntity& B = feat.entities[e1]; - int pt = -1, ln = -1; - if (A.type == SketchEntity::Type::Point && B.type == SketchEntity::Type::Line) { pt = e0; ln = e1; } - else if (B.type == SketchEntity::Type::Point && A.type == SketchEntity::Type::Line) { pt = e1; ln = e0; } - else { fail(_L("Midpoint needs a point and a line")); return; } - def.ea = pt; def.ra = R::P0; def.eb = ln; - break; - } - case T::Symmetric: { - // Two entities made symmetric about a third (axis) line. Picks: slot0=A, - // slot1=B, slot2=axis. Two Points -> one pair; two Lines -> endpoint pairs. - using ET = SketchEntity::Type; - const int axis = m_viewport->selected_constrain_axis(); - if (axis < 0 || axis >= int(feat.entities.size()) || - feat.entities[axis].type != ET::Line) { - fail(_L("Symmetric: pick two entities, then an axis line")); return; - } - const ET ta = feat.entities[e0].type, tb = feat.entities[e1].type; - std::vector defs; - auto mk = [&](R ra, R rb) { - SketchEntityConstraintDef d; - d.type = T::Symmetric; - d.ea = e0; d.ra = ra; d.eb = e1; d.rb = rb; d.ec = axis; - defs.push_back(d); - }; - if (ta == ET::Point && tb == ET::Point) { mk(R::P0, R::P0); } - else if (ta == ET::Line && tb == ET::Line) { mk(R::P0, R::P0); mk(R::P1, R::P1); } - else { fail(_L("Symmetric needs two points or two lines + an axis")); return; } - commit_entity_constraints(defs); - return; // multi-def commit done here - } - case T::SymmetricAboutY: - case T::SymmetricAboutX: { - // Two entities made symmetric about the sketch's vertical/horizontal axis, which - // is implicit (no picked axis line). Picks: slot0=A, slot1=B. Two Points -> one - // pair; two Lines -> endpoint pairs. The axis is a negative sentinel in ec. - using ET = SketchEntity::Type; - const int axis = (type == T::SymmetricAboutY) ? kSketchRefAxisY : kSketchRefAxisX; - const ET ta = feat.entities[e0].type, tb = feat.entities[e1].type; - std::vector defs; - auto mk = [&](R ra, R rb) { - SketchEntityConstraintDef d; - d.type = type; - d.ea = e0; d.ra = ra; d.eb = e1; d.rb = rb; d.ec = axis; - defs.push_back(d); - }; - if (ta == ET::Point && tb == ET::Point) { mk(R::P0, R::P0); } - else if (ta == ET::Line && tb == ET::Line) { mk(R::P0, R::P0); mk(R::P1, R::P1); } - else { fail(_L("Symmetric needs two points or two lines")); return; } - commit_entity_constraints(defs); - return; // multi-def commit done here - } - case T::EqualRadius: { - if (!is_round(feat.entities[e0]) || !is_round(feat.entities[e1])) { - fail(_L("Equal radius needs two circles or arcs")); return; - } - def.ea = e0; def.eb = e1; - break; - } - case T::Collinear: { - using ET = SketchEntity::Type; - if (feat.entities[e0].type != ET::Line || feat.entities[e1].type != ET::Line) { - fail(_L("Collinear needs two lines")); return; - } - def.ea = e0; def.eb = e1; - break; - } - case T::Fix: { - // Anchor the picked entity's reference point to its current coordinate (the - // kernel pins it to a fixed reference). A single point — not both endpoints — - // so it composes with any existing Horizontal/Vertical/length constraint - // instead of duplicating it (pinning both endpoints of an already-horizontal - // line is redundant → over-constrained). Removes 2 DoF (the entity's position); - // combine with H/V + a dimension to reach fully constrained. - using ET = SketchEntity::Type; - const ET et = feat.entities[e0].type; - def.ea = e0; - def.ra = (et == ET::Circle || et == ET::Ellipse || - et == ET::Arc || et == ET::EllipseArc) ? R::Center : R::P0; - break; - } - case T::Radius: - case T::Diameter: { - const SketchEntity& A = feat.entities[e0]; - if (!is_round(A)) { fail(_L("Radius/Diameter needs a circle or arc")); return; } - const double cur = (type == T::Diameter) ? 2.0 * A.radius : A.radius; - const int a = e0; const T tt = type; - // Typed in-canvas at the cursor (no docked card), pre-filled with the current value. - m_viewport->open_inline_value(cur, [this, a, tt](double v) { - SketchEntityConstraintDef d; - d.type = tt; d.ea = a; d.ra = R::Center; d.value = v; - commit_entity_constraint(d); - }); - return; // deferred: commit runs on the typed value - } - default: - fail(_L("Unsupported constraint")); - return; - } - - commit_entity_constraint(def); } -void DesignPanel::commit_entity_constraint(const SketchEntityConstraintDef& def) +void DesignPanel::apply_live_constraint(SketchConstraintType type) { - commit_entity_constraints({ def }); + // The in-session selection is the pick: first three indices, e2 being the axis Symmetric + // needs. Fewer than the type needs are left at -1 so plan_entity_constraint — the ONE + // place that knows how many picks a type takes — rejects them rather than this site + // guessing. Known limitation (comment, not solved): a constraint added to a LIVE sketch + // is not on the document undo stack — that stack holds committed features — so Ctrl+Z + // will not take it back until the sketch is committed. + const std::vector& sel = m_viewport->sketch_selection(); + const int e0 = sel.size() > 0 ? sel[0] : -1; + const int e1 = sel.size() > 1 ? sel[1] : -1; + const int e2 = sel.size() > 2 ? sel[2] : -1; + + const ConstraintPlan plan = plan_entity_constraint( + m_viewport->sketch_entities(), e0, e1, e2, type); + + auto fail = [this](const wxString& msg) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + set_status(msg); + m_status->Refresh(); + }; + // Shared commit: try_add_constraints appends→solves→keeps-or-rolls-back and leaves the + // geometry untouched on failure, so the same over-constrained message the committed path + // uses is the correct failure report here too. + auto commit = [this, fail](std::vector defs, double v) { + write_plan_value(defs, v); + if (!m_viewport->try_add_sketch_constraints(defs)) { + fail(_L("Constraint rejected (over-constrained)")); + return; + } + m_viewport->request_repaint(); + m_status->SetForegroundColour(wxNullColour); + set_status(_L("Applied constraint")); + m_status->Refresh(); + }; + + switch (plan.kind) { + case ConstraintPlan::Kind::Reject: + fail(constraint_reject_text(plan.reason, type)); + return; + case ConstraintPlan::Kind::AskValue: + m_viewport->open_inline_value(plan.prefill, [this, plan, commit](double v) { + commit(plan.defs, v); + }); + return; + case ConstraintPlan::Kind::Apply: + commit(plan.defs, 0.0); + return; + } } void DesignPanel::commit_entity_constraints(const std::vector& defs) @@ -9050,6 +8874,18 @@ void DesignPanel::cancel_value() void DesignPanel::apply_constraint(SketchConstraintType type) { + // 1. LIVE sketch session: constrain the in-session selection while drawing. The + // discriminator must exclude BOTH constrain modes — begin_constrain_entities AND + // begin_constrain both set m_active, so is_sketching() alone is true during a committed + // Constrain session; without the guards this new path hijacks those and breaks every + // existing constraint rung. !is_constraining() also covers is_constraining_entities() + // (both require Mode::Constrain); it is spelled out for the two reasons a reader expects. + if (m_viewport && m_viewport->is_sketching() && + !m_viewport->is_constraining() && !m_viewport->is_constraining_entities()) { + apply_live_constraint(type); + return; + } + if (m_constrain_feat < 0 || m_constrain_feat >= int(m_doc.features.size()) || m_viewport == nullptr) { m_status->SetForegroundColour(wxColour(235, 110, 110)); @@ -9058,12 +8894,13 @@ void DesignPanel::apply_constraint(SketchConstraintType type) return; } - // Entity sketches (Fase 4.2) route through the entity-constraint path. + // 2. Entity sketches (Fase 4.2) route through the entity-constraint path. if (m_viewport->is_constraining_entities()) { apply_entity_constraint(type); return; } + // 3. Legacy profile path. if (!m_viewport->is_constraining()) { m_status->SetForegroundColour(wxColour(235, 110, 110)); set_status(_L("Press Constrain on a sketch first")); diff --git a/src/slic3r/GUI/CAD/DesignPanel.hpp b/src/slic3r/GUI/CAD/DesignPanel.hpp index 2410eb0742..3b8e091bae 100644 --- a/src/slic3r/GUI/CAD/DesignPanel.hpp +++ b/src/slic3r/GUI/CAD/DesignPanel.hpp @@ -223,6 +223,7 @@ private: bool enter_constrain_inline(); void apply_constraint(SketchConstraintType type); void apply_entity_constraint(SketchConstraintType type); // Fase 4.2 entity path + void apply_live_constraint(SketchConstraintType type); // Fase 4.2 live-sketch path (no commit needed) enum class EditOp { Mirror, Offset, Fillet, Trim, Extend, Array, Move, Chamfer, Rotate, Scale, PolarArray }; // Fase 4.4/4.5/4.6 sketch edit ops void apply_edit_op(EditOp op); // mutate selected sketch entities // Onshape-style docked value entry (replaces wxGetTextFromUser popups for @@ -233,7 +234,6 @@ private: std::function on_cancel = nullptr); void confirm_value(); void cancel_value(); - void commit_entity_constraint(const SketchEntityConstraintDef& def); // shared solve/refresh tail void commit_entity_constraints(const std::vector& defs); // multi-def (Symmetric) // Constraint manager (C3.4): a docked list of the constrained sketch's @@ -441,7 +441,9 @@ private: wxScrolledWindow* m_toolbar{nullptr}; // horizontally scrollable so the action bar stays reachable on narrow windows wxSizer* m_tb_feature{nullptr}; wxSizer* m_tb_sketch{nullptr}; - wxSizer* m_tb_constrain{nullptr}; + // The 20 constraint icon buttons, shown during BOTH Sketch and Constrain (Fase 4.2 live + // path: a constraint must be applicable while drawing, not only after committing). + wxSizer* m_tb_relations{nullptr}; // Unified Confirm/Cancel action bar (right end of the ribbon). Shown whenever any // tool or mode is active; the single confirm/cancel surface for the whole tab. wxSizer* m_tb_action{nullptr}; diff --git a/src/slic3r/GUI/CAD/DesignSketchTool.cpp b/src/slic3r/GUI/CAD/DesignSketchTool.cpp index fa14a7a34c..80915760c7 100644 --- a/src/slic3r/GUI/CAD/DesignSketchTool.cpp +++ b/src/slic3r/GUI/CAD/DesignSketchTool.cpp @@ -214,6 +214,7 @@ void DesignSketchTool::rebuild_features_from_entities() void DesignSketchTool::set_tool(Mode mode) { + m_exit_refused = false; // any new action re-arms the one-shot exit refusal // A READY edit-op carries the user's typed or dragged value, so switching tools commits it // rather than dropping it — the same rule Tab follows in the dimension editor. Discarding it // here is most of why Fillet looked like it simply did not work: every documented route (type @@ -331,7 +332,20 @@ void DesignSketchTool::request_exit() // Drop any pending edit-op BEFORE the downgrade: set_tool commits a ready one, and Esc must // cancel it, never apply it. Right-click already discards it through its own branch. if (m_mode != Mode::Select) { reset_op(); set_tool(Mode::Select); return; } - if (on_exit) on_exit(); else cancel(); + if (on_exit) { + // This is the layer that would destroy a drawn-but-uncommitted sketch. That is the one + // thing Esc must not do silently: refuse the FIRST time work exists, and only let a + // second consecutive Esc through. The panel reports the refusal; the tool only decides. + if (live_sketch_has_work() && !m_exit_refused) { + m_exit_refused = true; + if (on_exit_refused) on_exit_refused(); + return; + } + m_exit_refused = false; + on_exit(); + } else { + cancel(); + } } void DesignSketchTool::request_undo_redo(bool redo) @@ -350,6 +364,7 @@ void DesignSketchTool::clear_selection() void DesignSketchTool::delete_selected() { if (m_selection.empty()) return; + m_exit_refused = false; // deleting is an action; re-arm the exit refusal const int n = int(m_entities.size()); std::vector del(n, false); for (int i : m_selection) @@ -7442,12 +7457,17 @@ void DesignSketchTool::build_constraint_glyphs(double unit_per_px, void DesignSketchTool::draw_entities_preview(const std::vector& ents, const ColorRGBA& color) { + std::vector point_markers; for (const SketchEntity& e : ents) { - if (e.type == SketchEntity::Type::Point) continue; + // A Point has no polyline (entity_polyline returns nothing), so the strip path below + // cannot draw it; render it as a vertex marker, the same way the live session does. + if (e.type == SketchEntity::Type::Point) { point_markers.push_back(e.p0); continue; } bool closed = false; std::vector poly = entity_polyline(e, closed); draw_quad_strip(m_highlight_model, poly, closed, color); } + if (!point_markers.empty()) + draw_vertices(m_highlight_model, point_markers, color); } // ---- In-canvas edit-op gizmo (Fillet/Chamfer/Offset/Mirror toolbar tools) ---------- @@ -8448,15 +8468,28 @@ void DesignSketchTool::render(GLCanvas3D& canvas) for (int h : loops[m_display_pick_region].holes) mark(h); } } + std::vector point_markers, sel_point_markers; for (int i = 0; i < int(ds.entities.size()); ++i) { const SketchEntity& e = ds.entities[i]; - if (e.type == SketchEntity::Type::Point) continue; + // A Point has no polyline to strip; draw it as a vertex marker so a committed + // point stays visible (it vanished on commit). Selection colouring keeps working: + // sel_ent[] stays index-aligned with ds.entities, untouched for the other types. + if (e.type == SketchEntity::Type::Point) { + (sel_ent[i] ? sel_point_markers : point_markers).push_back(e.p0); + continue; + } bool closed = false; std::vector poly = entity_polyline(e, closed); const ColorRGBA* hlc = sketch_hl_color(ds.feature); ColorRGBA wc = sel_ent[i] ? swire : (hlc ? *hlc : dwire); draw_quad_strip(m_line_model, poly, closed, wc); } + if (!point_markers.empty()) { + const ColorRGBA* hlc = sketch_hl_color(ds.feature); + draw_vertices(m_vertex_model, point_markers, hlc ? *hlc : dwire); + } + if (!sel_point_markers.empty()) + draw_vertices(m_highlight_model, sel_point_markers, swire); } m_plane = saved_plane; } @@ -9525,6 +9558,10 @@ bool DesignSketchTool::on_mouse(wxMouseEvent& evt, GLCanvas3D& canvas) bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) { + // Re-arm the one-shot exit refusal on a BUTTON press only, never on motion: moving the + // mouse between the two Esc presses is what anyone would do, and re-arming there would + // make the second Esc refuse again — an Esc that can never exit while the hand moves. + if (evt.LeftDown() || evt.RightDown() || evt.MiddleDown()) m_exit_refused = false; // Track the cursor in canvas client px so the in-canvas value editor can open right // where the user clicked (Onshape places the field at the click, not via a camera // projection — the design canvas's viewport isn't valid outside its own paint). diff --git a/src/slic3r/GUI/CAD/DesignSketchTool.hpp b/src/slic3r/GUI/CAD/DesignSketchTool.hpp index fd2f406705..0e2fcf8e1e 100644 --- a/src/slic3r/GUI/CAD/DesignSketchTool.hpp +++ b/src/slic3r/GUI/CAD/DesignSketchTool.hpp @@ -481,6 +481,10 @@ public: int add_entities_scripted(const std::vector& ents); // Replace the selection with these entity indices (out-of-range ones are ignored). bool select_indices(const std::vector& idx); + // Append candidates, live-solve, and roll back the batch if it turns the system + // inconsistent. Returns true when the batch was kept. Public so the panel's live-constraint + // path can commit a plan through the SAME append→solve→keep-or-rollback the gestures use. + bool try_add_constraints(const std::vector& cands); // 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 @@ -610,6 +614,9 @@ public: // Feature mode). Layered: an in-progress entity or a non-Select draw tool is dropped // first; a second Esc exits the session. std::function on_exit; + // Esc refusal: request_exit() declined to destroy a sketch that still has geometry. The + // panel owns the status line, so the tool reports through this instead of writing text itself. + std::function on_exit_refused; std::function on_move_exit; // right-click finished the move-body gizmo void request_exit(); // Ctrl+Z / Ctrl+Shift+Z (Ctrl+Y) while the Design canvas is focused: undo/redo the @@ -641,9 +648,6 @@ private: InferenceSnap infer_at(GLCanvas3D& canvas, const wxMouseEvent& evt, const Vec2d& raw) const; // True if m_constraints already holds an equivalent Coincident between the two refs. bool has_coincident(int ea, SketchPointRole ra, int eb, SketchPointRole rb) const; - // Append candidates, live-solve, and roll back the batch if it turns the system - // inconsistent. Returns true when the batch was kept. - bool try_add_constraints(const std::vector& cands); // After entities [base, end) were committed, auto-emit the constraints that make // the new geometry stick: Coincident between co-located endpoints (so loops close // on their own) and Horizontal/Vertical on axis-aligned new segments. @@ -1153,6 +1157,10 @@ private: double& edge_d, int& face_feat, int& face_reg) const; bool m_right_consumed{false}; // last RightDown was a gesture terminator, not a menu bool m_escalate_repick{true}; // re-picking the same sub-element takes the whole body + // One-shot exit confirmation: request_exit() refused once because the sketch has unsaved + // geometry. The NEXT exit request (with nothing in between) is allowed through; any other + // action re-arms the refusal, so the warning is never a permanent block. + bool m_exit_refused{false}; void render_solid_highlight(); // The shared body of the above: one highlight from explicit arguments, so the committed // selection and the hover pre-highlight cannot drift apart in how they look. diff --git a/tests/libslic3r/test_sketchconstraints.cpp b/tests/libslic3r/test_sketchconstraints.cpp index e64b5d1ba9..d0c5844b40 100644 --- a/tests/libslic3r/test_sketchconstraints.cpp +++ b/tests/libslic3r/test_sketchconstraints.cpp @@ -1,8 +1,26 @@ #include // mainline OrcaSlicer ships Catch2 v3 (v2 was catch2/catch.hpp) #include "libslic3r/CAD/SketchConstraints.hpp" +#include "libslic3r/CAD/SketchEngine.hpp" using namespace Slic3r; +namespace { +SketchEntity mk_line(double x0, double y0, double x1, double y1) +{ + SketchEntity e; e.type = SketchEntity::Type::Line; + e.p0 = Vec2d(x0, y0); e.p1 = Vec2d(x1, y1); return e; +} +SketchEntity mk_point(double x, double y) +{ + SketchEntity e; e.type = SketchEntity::Type::Point; e.p0 = Vec2d(x, y); return e; +} +SketchEntity mk_circle(double cx, double cy, double r) +{ + SketchEntity e; e.type = SketchEntity::Type::Circle; + e.center = Vec2d(cx, cy); e.radius = r; return e; +} +} + TEST_CASE("Coincident with anchor", "[SketchConstraints]") { SketchConstraints sc; @@ -167,3 +185,212 @@ TEST_CASE("point-line distance", "[SketchConstraints]") REQUIRE_THAT(std::abs(pp.y()), Catch::Matchers::WithinAbs(5.0, 1e-3)); REQUIRE_THAT(pp.x(), Catch::Matchers::WithinAbs(3.0, 1e-3)); } + +// ---- entity-constraint planner (kernel port of DesignPanel::apply_entity_constraint) ---- + +TEST_CASE("sketch_entity_ends exposes real roles only", "[SketchConstraints]") +{ + std::pair out[2]; + REQUIRE(sketch_entity_ends(mk_point(3, 4), out) == 1); + REQUIRE(out[0].first == SketchPointRole::P0); + REQUIRE(sketch_entity_ends(mk_circle(1, 2, 5), out) == 1); + REQUIRE(out[0].first == SketchPointRole::Center); + REQUIRE_THAT(out[0].second.x(), Catch::Matchers::WithinAbs(1.0, 1e-9)); + REQUIRE_THAT(out[0].second.y(), Catch::Matchers::WithinAbs(2.0, 1e-9)); + REQUIRE(sketch_entity_ends(mk_line(0, 0, 10, 0), out) == 2); + REQUIRE(out[0].first == SketchPointRole::P0); + REQUIRE(out[1].first == SketchPointRole::P1); +} + +TEST_CASE("Coincident on two Points binds P0/P0, not phantom p1", "[SketchConstraints]") +{ + std::vector ents = { mk_point(0, 0), mk_point(5, 5) }; + ConstraintPlan p = plan_entity_constraint(ents, 0, 1, -1, SketchConstraintType::Coincident); + REQUIRE(p.kind == ConstraintPlan::Kind::Apply); + REQUIRE(p.defs.size() == 1); + REQUIRE(p.defs[0].type == SketchConstraintType::Coincident); + REQUIRE(p.defs[0].ea == 0); + REQUIRE(p.defs[0].ra == SketchPointRole::P0); + REQUIRE(p.defs[0].eb == 1); + REQUIRE(p.defs[0].rb == SketchPointRole::P0); +} + +TEST_CASE("DistanceX on two Points binds real roles with non-negative prefill", "[SketchConstraints]") +{ + // e0 is right of e1, so the raw projected delta is negative: the plan must swap the + // refs so accepting the shown (positive) value is a no-op, not a sign flip. + std::vector ents = { mk_point(5, 1), mk_point(2, 3) }; + ConstraintPlan p = plan_entity_constraint(ents, 0, 1, -1, SketchConstraintType::DistanceX); + REQUIRE(p.kind == ConstraintPlan::Kind::AskValue); + REQUIRE(p.defs.size() == 1); + REQUIRE(p.defs[0].type == SketchConstraintType::DistanceX); + REQUIRE(p.defs[0].ra == SketchPointRole::P0); + REQUIRE(p.defs[0].rb == SketchPointRole::P0); + REQUIRE(p.prefill >= 0.0); + REQUIRE(p.defs[0].ea == 1); + REQUIRE(p.defs[0].eb == 0); +} + +TEST_CASE("Horizontal on a Point rejects with NeedALine", "[SketchConstraints]") +{ + std::vector ents = { mk_point(1, 2) }; + ConstraintPlan p = plan_entity_constraint(ents, 0, -1, -1, SketchConstraintType::Horizontal); + REQUIRE(p.kind == ConstraintPlan::Kind::Reject); + REQUIRE(p.reason == ConstraintReject::NeedALine); +} + +TEST_CASE("Angle on two Circles rejects with NeedTwoLines", "[SketchConstraints]") +{ + std::vector ents = { mk_circle(0, 0, 1), mk_circle(5, 0, 1) }; + ConstraintPlan p = plan_entity_constraint(ents, 0, 1, -1, SketchConstraintType::Angle); + REQUIRE(p.kind == ConstraintPlan::Kind::Reject); + REQUIRE(p.reason == ConstraintReject::NeedTwoLines); +} + +TEST_CASE("Parallel on a Line + Circle rejects with NeedTwoLines (new guard)", "[SketchConstraints]") +{ + std::vector ents = { mk_line(0, 0, 1, 0), mk_circle(5, 0, 1) }; + ConstraintPlan p = plan_entity_constraint(ents, 0, 1, -1, SketchConstraintType::Parallel); + REQUIRE(p.kind == ConstraintPlan::Kind::Reject); + REQUIRE(p.reason == ConstraintReject::NeedTwoLines); +} + +TEST_CASE("Equal on two Circles promotes to EqualRadius", "[SketchConstraints]") +{ + std::vector ents = { mk_circle(0, 0, 1), mk_circle(5, 0, 2) }; + ConstraintPlan p = plan_entity_constraint(ents, 0, 1, -1, SketchConstraintType::EqualLength); + REQUIRE(p.kind == ConstraintPlan::Kind::Apply); + REQUIRE(p.defs.size() == 1); + REQUIRE(p.defs[0].type == SketchConstraintType::EqualRadius); + REQUIRE(p.defs[0].ea == 0); + REQUIRE(p.defs[0].eb == 1); +} + +TEST_CASE("Symmetric on two Lines returns two defs with ec set to the axis", "[SketchConstraints]") +{ + std::vector ents = { mk_line(0, 1, 5, 1), mk_line(0, -1, 5, -1), mk_line(0, 0, 0, 1) }; + ConstraintPlan p = plan_entity_constraint(ents, 0, 1, 2, SketchConstraintType::Symmetric); + REQUIRE(p.kind == ConstraintPlan::Kind::Apply); + REQUIRE(p.defs.size() == 2); + for (const auto& d : p.defs) { + REQUIRE(d.type == SketchConstraintType::Symmetric); + REQUIRE(d.ea == 0); + REQUIRE(d.eb == 1); + REQUIRE(d.ec == 2); + } + REQUIRE(p.defs[0].ra == SketchPointRole::P0); + REQUIRE(p.defs[0].rb == SketchPointRole::P0); + REQUIRE(p.defs[1].ra == SketchPointRole::P1); + REQUIRE(p.defs[1].rb == SketchPointRole::P1); +} + +TEST_CASE("Symmetric with no axis rejects with NeedAxisLine", "[SketchConstraints]") +{ + std::vector ents = { mk_point(0, 0), mk_point(5, 0) }; + ConstraintPlan p = plan_entity_constraint(ents, 0, 1, -1, SketchConstraintType::Symmetric); + REQUIRE(p.kind == ConstraintPlan::Kind::Reject); + REQUIRE(p.reason == ConstraintReject::NeedAxisLine); +} + +TEST_CASE("SymmetricAboutY on two Points returns one def with ec == kSketchRefAxisY", "[SketchConstraints]") +{ + std::vector ents = { mk_point(1, 0), mk_point(-2, 0) }; + ConstraintPlan p = plan_entity_constraint(ents, 0, 1, -1, SketchConstraintType::SymmetricAboutY); + REQUIRE(p.kind == ConstraintPlan::Kind::Apply); + REQUIRE(p.defs.size() == 1); + REQUIRE(p.defs[0].type == SketchConstraintType::SymmetricAboutY); + REQUIRE(p.defs[0].ec == kSketchRefAxisY); + REQUIRE(p.defs[0].ea == 0); + REQUIRE(p.defs[0].eb == 1); +} + +TEST_CASE("constraint planner apply/askvalue matrix", "[SketchConstraints]") +{ + struct C { + const char* name; SketchConstraintType type; std::vector ents; + int e0, e1, e2; ConstraintPlan::Kind kind; + }; + const std::vector cases = { + { "Fix", SketchConstraintType::Fix, { mk_point(1, 2) }, 0, -1, -1, ConstraintPlan::Kind::Apply }, + { "Coincident", SketchConstraintType::Coincident, { mk_point(0, 0), mk_point(5, 5) }, 0, 1, -1, ConstraintPlan::Kind::Apply }, + { "Horizontal", SketchConstraintType::Horizontal, { mk_line(0, 0, 5, 0) }, 0, -1, -1, ConstraintPlan::Kind::Apply }, + { "Vertical", SketchConstraintType::Vertical, { mk_line(0, 0, 0, 5) }, 0, -1, -1, ConstraintPlan::Kind::Apply }, + { "Parallel", SketchConstraintType::Parallel, { mk_line(0, 0, 1, 0), mk_line(0, 1, 1, 1) }, 0, 1, -1, ConstraintPlan::Kind::Apply }, + { "Perpendicular", SketchConstraintType::Perpendicular, { mk_line(0, 0, 1, 0), mk_line(0, 0, 0, 1) }, 0, 1, -1, ConstraintPlan::Kind::Apply }, + { "EqualLength", SketchConstraintType::EqualLength, { mk_line(0, 0, 1, 0), mk_line(0, 1, 2, 1) }, 0, 1, -1, ConstraintPlan::Kind::Apply }, + { "Concentric", SketchConstraintType::Concentric, { mk_circle(0, 0, 1), mk_circle(5, 0, 1) }, 0, 1, -1, ConstraintPlan::Kind::Apply }, + { "Tangent", SketchConstraintType::Tangent, { mk_line(0, 0, 1, 0), mk_circle(0, 1, 1) }, 0, 1, -1, ConstraintPlan::Kind::Apply }, + { "Midpoint", SketchConstraintType::Midpoint, { mk_point(2, 0), mk_line(0, 0, 5, 0) }, 0, 1, -1, ConstraintPlan::Kind::Apply }, + { "Symmetric", SketchConstraintType::Symmetric, { mk_point(0, 0), mk_point(5, 0), mk_line(0, -1, 0, 1) }, 0, 1, 2, ConstraintPlan::Kind::Apply }, + { "SymmetricAboutY", SketchConstraintType::SymmetricAboutY, { mk_point(1, 0), mk_point(-2, 0) }, 0, 1, -1, ConstraintPlan::Kind::Apply }, + { "SymmetricAboutX", SketchConstraintType::SymmetricAboutX, { mk_point(0, 1), mk_point(0, -2) }, 0, 1, -1, ConstraintPlan::Kind::Apply }, + { "EqualRadius", SketchConstraintType::EqualRadius, { mk_circle(0, 0, 1), mk_circle(5, 0, 2) }, 0, 1, -1, ConstraintPlan::Kind::Apply }, + { "Collinear", SketchConstraintType::Collinear, { mk_line(0, 0, 1, 0), mk_line(2, 0, 3, 0) }, 0, 1, -1, ConstraintPlan::Kind::Apply }, + { "Angle", SketchConstraintType::Angle, { mk_line(0, 0, 1, 0), mk_line(0, 0, 0, 1) }, 0, 1, -1, ConstraintPlan::Kind::AskValue }, + { "Radius", SketchConstraintType::Radius, { mk_circle(0, 0, 2.5) }, 0, -1, -1, ConstraintPlan::Kind::AskValue }, + { "Diameter", SketchConstraintType::Diameter, { mk_circle(0, 0, 2.5) }, 0, -1, -1, ConstraintPlan::Kind::AskValue }, + { "DistanceX", SketchConstraintType::DistanceX, { mk_point(0, 0), mk_point(5, 3) }, 0, 1, -1, ConstraintPlan::Kind::AskValue }, + { "DistanceY", SketchConstraintType::DistanceY, { mk_point(0, 0), mk_point(5, 3) }, 0, 1, -1, ConstraintPlan::Kind::AskValue }, + }; + for (const C& c : cases) { + DYNAMIC_SECTION("apply " << c.name) { + ConstraintPlan p = plan_entity_constraint(c.ents, c.e0, c.e1, c.e2, c.type); + REQUIRE(p.kind == c.kind); + REQUIRE(p.defs.size() >= 1); + for (const auto& d : p.defs) REQUIRE(d.type == c.type); + } + } +} + +TEST_CASE("constraint planner reject matrix", "[SketchConstraints]") +{ + struct C { + const char* name; SketchConstraintType type; std::vector ents; + int e0, e1, e2; ConstraintReject reason; + }; + const std::vector cases = { + { "Fix", SketchConstraintType::Fix, {}, 0, -1, -1, ConstraintReject::NeedOneEntity }, + { "Coincident", SketchConstraintType::Coincident, { mk_point(0, 0) }, 0, -1, -1, ConstraintReject::NeedTwoEntities }, + { "Horizontal", SketchConstraintType::Horizontal, { mk_point(1, 2) }, 0, -1, -1, ConstraintReject::NeedALine }, + { "Vertical", SketchConstraintType::Vertical, { mk_circle(0, 0, 1) }, 0, -1, -1, ConstraintReject::NeedALine }, + { "Parallel", SketchConstraintType::Parallel, { mk_line(0, 0, 1, 0), mk_circle(5, 0, 1) }, 0, 1, -1, ConstraintReject::NeedTwoLines }, + { "Perpendicular", SketchConstraintType::Perpendicular, { mk_circle(0, 0, 1), mk_line(0, 0, 1, 0) }, 0, 1, -1, ConstraintReject::NeedTwoLines }, + { "EqualLength", SketchConstraintType::EqualLength, { mk_line(0, 0, 1, 0), mk_circle(5, 0, 1) }, 0, 1, -1, ConstraintReject::NeedTwoLines }, + { "Concentric", SketchConstraintType::Concentric, { mk_line(0, 0, 1, 0), mk_circle(5, 0, 1) }, 0, 1, -1, ConstraintReject::NeedTwoRounds }, + { "Tangent", SketchConstraintType::Tangent, { mk_line(0, 0, 1, 0), mk_line(0, 1, 1, 1) }, 0, 1, -1, ConstraintReject::NeedTangentPair }, + { "Midpoint", SketchConstraintType::Midpoint, { mk_line(0, 0, 1, 0), mk_line(0, 1, 1, 1) }, 0, 1, -1, ConstraintReject::NeedPointAndLine }, + { "Symmetric", SketchConstraintType::Symmetric, { mk_line(0, 0, 1, 0), mk_point(1, 1), mk_line(0, -1, 0, 1) }, 0, 1, 2, ConstraintReject::NeedTwoPointsOrLines }, + { "SymmetricAboutY", SketchConstraintType::SymmetricAboutY, { mk_line(0, 0, 1, 0), mk_point(1, 1) }, 0, 1, -1, ConstraintReject::NeedTwoPointsOrLines }, + { "SymmetricAboutX", SketchConstraintType::SymmetricAboutX, { mk_point(1, 1), mk_line(0, 0, 1, 0) }, 0, 1, -1, ConstraintReject::NeedTwoPointsOrLines }, + { "EqualRadius", SketchConstraintType::EqualRadius, { mk_line(0, 0, 1, 0), mk_circle(5, 0, 1) }, 0, 1, -1, ConstraintReject::NeedTwoRounds }, + { "Collinear", SketchConstraintType::Collinear, { mk_line(0, 0, 1, 0), mk_circle(5, 0, 1) }, 0, 1, -1, ConstraintReject::NeedTwoLines }, + { "Angle", SketchConstraintType::Angle, { mk_circle(0, 0, 1), mk_circle(5, 0, 1) }, 0, 1, -1, ConstraintReject::NeedTwoLines }, + { "Radius", SketchConstraintType::Radius, { mk_line(0, 0, 1, 0) }, 0, -1, -1, ConstraintReject::NeedRound }, + { "Diameter", SketchConstraintType::Diameter, { mk_point(1, 2) }, 0, -1, -1, ConstraintReject::NeedRound }, + { "DistanceX", SketchConstraintType::DistanceX, { mk_point(0, 0) }, 0, -1, -1, ConstraintReject::NeedTwoEntities }, + { "DistanceY", SketchConstraintType::DistanceY, { mk_point(0, 0) }, 0, -1, -1, ConstraintReject::NeedTwoEntities }, + }; + for (const C& c : cases) { + DYNAMIC_SECTION("reject " << c.name) { + ConstraintPlan p = plan_entity_constraint(c.ents, c.e0, c.e1, c.e2, c.type); + REQUIRE(p.kind == ConstraintPlan::Kind::Reject); + REQUIRE(p.reason == c.reason); + } + } +} + +TEST_CASE("constraint planner rejects types with no entity binding", "[SketchConstraints]") +{ + const SketchConstraintType unsupported[] = { + SketchConstraintType::Distance, SketchConstraintType::LockX, SketchConstraintType::LockY, + SketchConstraintType::PointOnLine, SketchConstraintType::PointOnObject, + }; + std::vector ents = { mk_point(0, 0), mk_point(1, 1) }; + for (SketchConstraintType t : unsupported) { + DYNAMIC_SECTION("unsupported " << int(t)) { + ConstraintPlan p = plan_entity_constraint(ents, 0, 1, -1, t); + REQUIRE(p.kind == ConstraintPlan::Kind::Reject); + REQUIRE(p.reason == ConstraintReject::Unsupported); + } + } +}