From 8f3f8356366404a7214ec108508ee95b268a5474 Mon Sep 17 00:00:00 2001 From: Tommaso Bianchi Date: Mon, 31 Aug 2026 22:12:26 +0200 Subject: [PATCH] Constrain while you sketch, and stop losing work to Esc and to invisible points Five defects from ten minutes of real use, and the mode split behind the worst of them. One commit because the changes overlap in the same functions; the pieces are separable in the diff, not in the file. CONSTRAINING NO LONGER NEEDS A COMMITTED SKETCH. A constraint could only be applied by committing the sketch, selecting it in the feature tree, pressing the padlock, and only then picking. While drawing, the CONSTRAIN toolbar was not even on screen (set_ui_mode showed it in UiMode::Constrain alone) and apply_constraint answered "Press Constrain on a sketch first" -- in a status line nobody looks at. Draw two lines, press Parallel, get nothing: that is what a user reported as "the UX is a mess", and they were right. apply_constraint now takes the live session first, reading the picks from the selection model the sketch tool already had (click, ctrl-click to extend, double-click for the loop) and applying through try_add_constraints, which already did append -> solve -> keep-or-rollback. The committed Constrain path stays for editing an old sketch; it is no longer the only way in. The twenty constraint buttons now show in Sketch mode as well. The discriminator is is_sketching() && !is_constraining() && !is_constraining_entities(). Both begin_constrain and begin_constrain_entities set m_active, so is_sketching() alone is true DURING a constrain session and the new path would hijack the old one -- compiling perfectly and failing in behaviour. ONE PLANNER, NOT TWO. A second caller meant duplicating the logic that decides whether a constraint is legal, which roles it binds and whether it needs a typed value. That duplication is how today's Coincident bug survived: fixed in one branch, alive in the next one down. plan_entity_constraint() now lives in the kernel -- pure, no wx, no translation -- and both UI paths call it. DesignPanel loses 304 lines and gains 155. Being in the kernel makes it TESTABLE. The Parallel defect existed because a constraint type met an entity type nobody had tried, and the only instrument was a 13-minute GUI ladder. 19 new kernel cases cover the matrix: 264 -> 283 cases, 7648 -> 7867 assertions. Parallel, Perpendicular and EqualLength gain the two-line guard they never had. On non-lines they used to emit a def the solver silently dropped -- the sketch reported itself constrained when it was not, the same class as Horizontal on a Point. EqualLength on two rounds still promotes to EqualRadius first. Symmetric is planned completely, including its axis pick: the plan carries a VECTOR of defs because Symmetric on two lines is two constraints (P0/P0 and P1/P1). A single def would have half-applied it -- one end pinned, one free, looking correct until something moves. A PLACED POINT SURVIVES THE COMMIT. Type::Point was created correctly and never drawn once committed: both renderers skip it, correctly, since entity_polyline gives a point nothing. What was missing is the vertex-marker path the live session already used. rung_point passed throughout because it asserts the document, and the point was always in the document -- the pixels lied. ESC STOPS EATING AN UNSAVED SKETCH. The third press reached cancel_sketch(), clearing m_entities with no warning and nothing to undo. live_sketch_has_work() existed and was never consulted. The exit layer refuses once when there is work and lets a second consecutive Esc through; the refusal re-arms on a button press, never on mouse motion, or Esc could never exit while the hand moves. TWO NEW RUNGS. D10 drives Parallel through the committed path -- it passes on the PRE-fix binary, which is how we know the user's failure was the mode and not the constraint. D11 is the acceptance for the collapse: draw, pick both, press Parallel, no commit and no padlock. Ladder 126 -> 135 properties, all holding. CON_BTN_SKETCH is measured, not derived: in Sketch mode the group renders after the sketch toolbar, so the first button is at 677, not 449. Pitch 42, twenty buttons, read off a screenshot. Deriving it by offset is how that table drifted the last time. Known limit, commented at the call site: a constraint added to a LIVE sketch is not on the document undo stack, so Ctrl+Z will not take it back until the sketch is committed. snaporca-itp4, snaporca-oyhx, snaporca-l2vm --- scripts/CAD/check-gui-sketching.py | 92 +++++ src/libslic3r/CAD/SketchEngine.cpp | 296 ++++++++++++++ src/libslic3r/CAD/SketchEngine.hpp | 38 ++ src/slic3r/GUI/CAD/DesignCanvas.cpp | 20 + src/slic3r/GUI/CAD/DesignCanvas.hpp | 7 + src/slic3r/GUI/CAD/DesignPanel.cpp | 433 +++++++-------------- src/slic3r/GUI/CAD/DesignPanel.hpp | 6 +- src/slic3r/GUI/CAD/DesignSketchTool.cpp | 43 +- src/slic3r/GUI/CAD/DesignSketchTool.hpp | 14 +- tests/libslic3r/test_sketchconstraints.cpp | 227 +++++++++++ 10 files changed, 870 insertions(+), 306 deletions(-) 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); + } + } +}