diff --git a/docs/CAD/design/SKETCHER_TOOL_ARCHITECTURE.md b/docs/CAD/design/SKETCHER_TOOL_ARCHITECTURE.md index a9659ac4b0..b450835daa 100644 --- a/docs/CAD/design/SKETCHER_TOOL_ARCHITECTURE.md +++ b/docs/CAD/design/SKETCHER_TOOL_ARCHITECTURE.md @@ -262,9 +262,9 @@ and a band is not a "gesture" in the `CadLevel::Gesture` sense because nothing h |---|---|---|---|---|---| | `Move` | — | `update_hover` / `update_solid_hover` (**non-consuming**, returns false) | — | passthrough | hover only asks for a repaint, `:10022-10025` | | `LeftDown` | orbit may begin | latch press, **return false** | — | consume-or-orbit is the canvas's call | `:10058-10063`; consuming here killed orbit once already | -| `LeftDrag` > 8 px | — | start + drive band, **consume** | — | no longer orbits in this canvas | middle-drag orbits, right-drag pans (`:10013-10016`) | +| `LeftDrag` > 8 px | — | start + drive band, **consume**, when Shift is held or the left button has no camera action | — | otherwise the left button's drag action (Preferences > Control) | the band is Prepare's Shift+left-drag rectangle selection | | `LeftUp` | — | commit pick **or** resolve band | — | — | `:10035-10041` | -| `MiddleDrag` / `RightDrag` | — | must not see it | — | orbit / pan | camera gestures never reach the FSM | +| `MiddleDrag` / `RightDrag` | — | must not see it | — | the button's drag action (Preferences > Control) | camera gestures never reach the FSM; a right press reaches the tool only once its release shows it was a click | | `RightClick` | — | — | offer menu | — | `snaporca-xmh6` open: a right-click that only clears the sketch selection eats the offer | | `Esc` | — | — | `escape()` ladder | — | one route whatever holds focus (`:4228-4231`) | | `Del` / `Backspace` | — | `delete_selected_or_last_sketch_entity()` | char hook | — | `:4260` | diff --git a/docs/HLSD/design-tab.md b/docs/HLSD/design-tab.md index c4bb9fd297..9b8010ce2c 100644 --- a/docs/HLSD/design-tab.md +++ b/docs/HLSD/design-tab.md @@ -135,7 +135,7 @@ contract between them is stated in code rather than spread across handlers. | --- | --- | --- | | `Transient` | a value field or a popup menu | closes it; the tool stays armed | | `Gesture` | an uncommitted delta — an entity being drawn, a body being dragged | reverts it; committed work is untouched | -| `Tool` | a feature card, an armed sketch tool, a constrain session | exits it; drawn entities survive | +| `Tool` | a feature card, Sketch waiting for its plane, an armed sketch tool, a constrain session | exits it; drawn entities survive | | `Idle` | nothing transient | clears the selection, a Feature tree or Bodies row included; leaves a sketch session only if it is empty | `cad_escape_level()` is a `constexpr` free function over a POD of four booleans rather than a @@ -149,26 +149,66 @@ explicit selection, the sketch ribbon's Cancel, which asks first, or `Ctrl+Z`. A *session* is deliberately not a `Tool` level; it is the environment the `Idle` level lives in, which makes the destructive path unrepresentable rather than merely unlikely. +A body Move is the one `Gesture` that outlives the press: its gizmo stays up between drags until +Confirm keeps the placement or `Esc` or Cancel puts the body back. Until then the selection is +held — a click off the gizmo only steers the camera — and undo is refused, since the placement +is not in the history. Anything that starts another edit (a feature card, a sketch, placing +imported art or text, another body's Move, a rebuild) keeps the placement, as switching gizmos +keeps a move in Prepare. The panel ends the Move in one place (`DesignPanel::end_body_move`), so +the gizmo, the Move / Rotate card and the ✓/✗ cannot outlive one another. + Right-click is read at button-up against one budget, 3 px of drift, applied to the whole press rather than to its end points: a press that wandered past the budget at any moment is navigation, even if it comes back to where it started, which is what stops a slow, careful orbit from ending in a menu. There is no time budget — a gesture that means something different when it is slow is exactly what the interaction charter rules out. The raycast uses the press -position, not the release. An armed sketch tool that already consumed the right -button (to terminate a chain, say) declines to also open a menu, through a read-and-clear flag. -Past either budget the event is navigation, and navigation does not transition the state -machine. +position, not the release. The sketch tool sees a right press only once the release has shown it +was a click: the press itself goes to the camera, which may pan or orbit with that button, and the +canvas replays it to the tool on a stationary release. A tool that uses the click (to terminate a +chain, say) keeps the menu closed. Past either budget the event is navigation, and navigation +does not transition the state machine. Navigation itself is Prepare's: the camera reads the drag actions set in Preferences > Control -for each button. The left button is shared with picking, so a whole body is swept with a -rectangle on plain left-drag only while no camera action is assigned to it, and with -Shift+left-drag otherwise — Prepare's own rectangle selection. +for each button, and in the Touchpad camera style a move with Alt held orbits and one with Shift +held pans, whatever tool is armed. The left button is shared with picking and drawing, so a tool +handle or a press that draws takes it first, as a gizmo does in Prepare; a whole body is swept +with a rectangle on plain left-drag only while no camera action is assigned to the left button, +and with Shift+left-drag otherwise — Prepare's own rectangle selection. -Entering a sketch changes three things at once so the mode is legible: a banner above the +Entering a sketch changes two things at once so the mode is legible: a banner above the canvas (a sibling of the canvas, not a child over it — on GTK a child window over a -`wxGLCanvas` is a native window and does not reliably stack over GL), the printer bed muted so -a plate grid is never read as a sketch grid, and `N` to look normal to the plane. Code that -changes any of the three belongs with a change to this section. +`wxGLCanvas` is a native window and does not reliably stack over GL), and `N` to look normal to +the plane. The printer bed stays: there is no sketch grid, so the plate grid is the only ground +reference a sketch has. Code that changes either belongs with a change to this section. + +Sketch mode is never entered without a plane under it, so the banner, the sketch keys and the +sketch offer always have a session to act on. Sketch on a picked flat face or reference plane opens +the session on it at once. With nothing picked it stays in Feature mode and waits for one — an +armed `Tool`, left with `Esc` or ✗, and ended by anything that starts another edit — and the +reference plane or flat face clicked next opens the session. A picked plane is a selection like a +face: the sketch on it uses it up, and `Esc` or a click on nothing lets go of it, so a plane that +can no longer be seen never decides where the next sketch goes. + +The reference planes — XY, XZ and YZ through the modeling origin, with their half-axes — are +drawn on demand, because three translucent squares over every model are noise once they are not +the thing being picked. Sketch brings them up while it waits for a plane, which is exactly when +they are picked, and the session the pick opens takes them away; a live session draws none. The +Feature tree's Origin row keeps them up outside a sketch. Its state is a view preference in +AppConfig rather than part of the recipe, so it costs the project format nothing. The Plane tool +keeps its own rule: the planes and the datums as Offset bases, and nothing for the other methods, +where a click on a plane would rewrite the datum's references. The `P` and `A` keys are a +separate, unpickable view helper and do not follow the Origin row. + +The Bed row, under the Origin row, is the printer bed's switch in the same way: it draws or hides +the bed and its plate grid in every mode. It is a view preference in AppConfig too, and the bed is +shown until it is turned off. + +The two rows are view switches, not history, and the tree says so: they sit unframed on the Feature +tree's card, above the features' own framed list, and stay put while the features scroll. A click +never selects either row, since a selected Origin or Bed would have nothing to edit, move or +delete; the eye and the right-click menu are the only targets, and a row's label dims while its +thing is hidden, as a hidden body's does. Because the block never takes the focus, `Ctrl+Shift+O` +and `Ctrl+Shift+B` flip the Origin and the Bed from the keyboard. ## Rendering the bodies @@ -189,6 +229,15 @@ degenerate edges are left out (`GeometryEngine::display_edges`), and the polylin once per shape, keyed by its `TShape`, because a recompute that leaves a body unchanged is the common case. +While a feature card is open, its preview ghost is the whole model the candidate would produce, +drawn translucent over the bodies, so every face the feature leaves alone is in both at the same +depth. The ghost is drawn with a depth bias that pushes it back (`GLVolume::depth_bias`), so on a shared face +the body always wins instead of the two copies z-fighting, and the ghost shows only where the +result reaches past the bodies. Material a feature removes lies inside the old solid and would not +show at all, so the tools whose result mostly coincides with the body — Fillet/Chamfer, Draft, +Hole and the Mate hover — hide the bodies once the preview is valid and draw the result alone, +opaque. + ## Showing what is selected A selection is drawn on the faces it names, never as a tint over the body: a translucent @@ -214,7 +263,17 @@ replay costs up to a recompute, so the panel finds the faces once per row and to generation, off the UI thread, and only while no feature card is open. One selection is live at a time: a viewport pick clears the feature row and a feature row clears the viewport pick, as the Feature tree and Bodies list do between themselves. `Esc`, a click on empty space and an -empty rubber band all let go of it, whichever list or pick made it. +empty rubber band all let go of it, whichever list or pick made it — except while a body Move is +open, which holds the selection until it ends (see the interaction contract). + +Zoom to selection, on a Feature tree or Bodies row and in its right-click menu, frames one thing +along the current view direction, as the canvas's Fit button frames the selection. On a body it +frames the body whole, hidden or not, from its display mesh: the viewport never selects a hidden +body, so the selection cannot stand in for it. On a sketch it frames the sketch's own geometry, +drawn, consumed or suppressed; on any other feature, the faces the feature made, found as the row +highlight finds them and from the same cache. From the offer it frames whatever the selection is, +a body again included. A feature that is not a sketch and makes no faces, such as a datum plane +or a suppressed Extrude, has no Zoom to selection. ## Following the app @@ -231,9 +290,21 @@ The tab is a page of Orca's main window and answers to the same settings as Prep - **Sidebar icons.** Every clickable icon in the sidebar shows a hover chip. The card-header and constraint-row buttons are Orca's self-painted `Button`, because a native button cannot take a hover background on macOS. The Feature tree and Bodies lists are a custom-drawn - `DesignRowList` rather than a `wxTreeCtrl`, so each row carries its own actions — Edit, - Show/hide and Delete on a feature, Move, Show/hide and Delete on a body — and the eye shows - whether that row is hidden. + `DesignRowList` rather than a `wxTreeCtrl`, so each row carries its own actions — Zoom to + selection, Edit, Show/hide and Delete on a feature, Zoom to selection, Move, Show/hide and + Delete on a body, and only Show/hide on the Origin and Bed rows, a separate non-selectable list + above the features — and the eye shows whether that row is hidden. A feature with nothing to + frame keeps a blank cell where Zoom to selection would be, so every icon stays in its column. +- **Plates.** This is the one thing the tab does not follow. The canvas has a bed of its own at + the printer bed's home position, whichever plate Prepare has current, and a new document's + modeling origin is that bed's centre. A bed that followed the current plate would slide out + from under a design: the origin is fixed once per document, baked into every sketch plane and + saved in the recipe, while the current plate can change between visits. Commit to Plate does + not need it either, since the committed object is placed on an empty spot of the current + plate. What the canvas does read from the plate is moved onto its bed: the exclude areas, the + plate box the camera orbits about when nothing is picked (`GLCanvas3D::_current_plate_box`), + and the first view, which starts from Prepare's camera turned to the tab's iso view: the CAD + isometric from the front-right corner that Home returns to, not Prepare's front-left one. - **Viewport text.** The status line and the active tool's values are drawn by the canvas in its ImGui pass, so they go with the canvas: a top-level window over GL does not follow its frame and was left floating over other applications. @@ -256,12 +327,12 @@ permanent row index, verbs that do not apply shown disabled **in place with thei rather than removed. The invariant is that a verb's row index is identical in every selection where it appears and that adding a verb never moves an existing one — the hand learns the position, so the menu is never re-sorted, compacted or adaptively ordered. Above the families -sits one *flat* row, holding Rename and Color — what a selection is opened for most: its verbs are -items of their own at the top of the menu rather than a family's submenu. It is appended after -the eight, so it moved no existing index, and it reads the same from the viewport and from a row -of the Bodies list. +sits one *flat* row, holding Rename, Color and Zoom to selection — what a selection is opened for +most: its verbs are items of their own at the top of the menu rather than a family's submenu. It +is appended after the eight, so it moved no existing index, and it reads the same from the +viewport and from a row of the Bodies list. -An invariant across 92 verbs and 20 selection kinds does not survive by review, so the map +An invariant across 93 verbs and 20 selection kinds does not survive by review, so the map exists once, as data: `scripts/CAD/tool_atlas.json` carries every verb with its row, key, icon, accepted selections, preconditions and refusal string, and `scripts/CAD/gen_offer_table.py` emits `src/slic3r/GUI/CAD/DesignOffer.hpp` from it. The header is checked in and never diff --git a/resources/images/design_bed.svg b/resources/images/design_bed.svg new file mode 100644 index 0000000000..a9441e7b5d --- /dev/null +++ b/resources/images/design_bed.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_export.svg b/resources/images/design_export.svg new file mode 100644 index 0000000000..b998ea19fc --- /dev/null +++ b/resources/images/design_export.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_import_mesh.svg b/resources/images/design_import_mesh.svg new file mode 100644 index 0000000000..7517578578 --- /dev/null +++ b/resources/images/design_import_mesh.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_new.svg b/resources/images/design_new.svg new file mode 100644 index 0000000000..dfe7425de4 --- /dev/null +++ b/resources/images/design_new.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_new_sketch.svg b/resources/images/design_new_sketch.svg new file mode 100644 index 0000000000..a645cf1dff --- /dev/null +++ b/resources/images/design_new_sketch.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_step.svg b/resources/images/design_step.svg index a4a92b7b20..45974dfd77 100644 --- a/resources/images/design_step.svg +++ b/resources/images/design_step.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_zoom.svg b/resources/images/design_zoom.svg new file mode 100644 index 0000000000..e9048dcff4 --- /dev/null +++ b/resources/images/design_zoom.svg @@ -0,0 +1 @@ + diff --git a/scripts/CAD/check-gui-click-edit.py b/scripts/CAD/check-gui-click-edit.py index 159bdb19f1..5eb9b3c4f2 100755 --- a/scripts/CAD/check-gui-click-edit.py +++ b/scripts/CAD/check-gui-click-edit.py @@ -147,6 +147,7 @@ def seed_datadir(datadir): # ~90% case expects. A ladder whose result depends on the developer's own preferences is not # a gate. app["auto_close_sketch_loops"] = True + app["design_show_bed"] = True # SILENCE THE NETWORK PLUGIN PROMPT. Without this, GUI_App::post_init() re-raises "Bambu # Network Plug-in Required" from an IDLE event — after any modal sweep this driver does at # startup — and ShowModal() then runs a nested event loop. The app is alive, its window is @@ -480,10 +481,14 @@ def type_into_open_field(value, mark): def enter_sketch(timeout=180): """Open a real sketch on a real plane, and PROVE it with the socket before drawing anything. - THE SEQUENCE MATTERS AND IT IS NOT OBVIOUS. Shift+S enters sketch MODE and pops the plane - offer; the offer must be dismissed; and the plane itself is chosen by clicking it in the - viewport BEFORE Shift+S. check-gui-sketching.py has always done all four steps. This ladder - did two of them — Design tab, then Shift+S — and went straight to the tool letters. + THE SEQUENCE MATTERS AND IT IS NOT OBVIOUS. The reference planes are hidden until Shift+S, + with nothing picked, brings them up to choose from, so the plane is clicked AFTER it. That + click opens the session on the plane and pops the tool offer, and a click outside a popup + menu only closes it, so the spot is clicked twice, apart: the second click closes the offer, + and inside the session it is empty canvas. No Escape — with no offer up, Escape in an empty + sketch leaves it. Then a tool letter arms the tool. check-gui-sketching.py does the same + steps. This ladder once did two of them — Design tab, then Shift+S — and went straight to + the tool letters. That intermediate state is the trap. `is_sketching` reads 1, every tool key is accepted and traced, and not one click draws anything, because there is no plane under them. The ladder @@ -499,10 +504,10 @@ def enter_sketch(timeout=180): click(132, 53) # Design tab time.sleep(2.0) dismiss_modals() - click(*PLANE_PX) # pick the plane IN THE VIEWPORT — before Shift+S - key("shift+s", 1.0) - key("Escape", 0.5) # entering sketch mode pops the offer; dismiss it - key("p", 0.6) # any sketch tool starts the session on that plane + key("shift+s", 1.0) # nothing picked: the reference planes come up + click(*PLANE_PX, pause=0.8) # picks the plane IN THE VIEWPORT: session open, offer up... + click(*PLANE_PX) # ...and this closes the offer + key("p", 0.6) # arms Point in the session if try_call("sketch_describe") is not None: # NO Escape here. Every rung already opens with one to drop whatever tool the last # one left armed, and Escape in the Design tab walks a LIFO: first press drops the @@ -511,14 +516,15 @@ def enter_sketch(timeout=180): # reported all eleven checks failed with "nothing opened" — the tools were arming # into an empty Feature-mode document. return - die("no sketch opened after plane click + Shift+S within " + die("no sketch opened after Shift+S + plane click within " f"{timeout}s — sketch_describe never answered on {A.sock} (trace {TRACE})") # tool key, the clicks that draw it, and one distinct value per queued field. The values are # deliberately nothing like the as-drawn size, so a committed prefill cannot coincide with them. -# Where the plane label sits in the viewport before a sketch is open. Same constant the gesture -# ladder uses; it is a label on the 3D view, not a widget, so it moves only if the camera does. +# Where the plane label sits in the viewport once Shift+S has brought the planes up. Same +# constant the gesture ladder uses; it is a label on the 3D view, not a widget, so it moves only +# if the camera does. PLANE_PX = (913, 359) # Every coordinate below stays inside 1000..1400 x 500..760 — the box check-gui-sketching.py's diff --git a/scripts/CAD/check-gui-sketching.py b/scripts/CAD/check-gui-sketching.py index d4ebd1f1ca..df83803819 100644 --- a/scripts/CAD/check-gui-sketching.py +++ b/scripts/CAD/check-gui-sketching.py @@ -229,7 +229,20 @@ CHROME_DY = int(os.environ.get("ORCA_CAD_CHROME_DY", "26")) DESIGN_TAB = (128, 29 + CHROME_DY) -# Feature-tree rows, measured on the rig at 1920x1080: first row centre, then 23 px apart. +# The ribbon's document group lost its Bed checkbox and label when the bed toggle moved to the +# Feature tree, and every group to its right moved left by their width: wxLEFT 6 + the 18 px +# CheckBox + wxLEFT 4 + the "Bed" label (~24 px) + wxRIGHT 4, about 56 px. The x of +# CONSTRUCTION_CHECKBOX, CON_BTN and CON_BTN_SKETCH below is the one measured before that, plus +# this. DERIVED, NOT MEASURED: re-measure on the rig and fold it in. CONFIRM_BTN sits after the +# ribbon's stretch spacer and did not move. +RIBBON_DX = -56 + +# Feature-tree rows, measured on the rig at 1920x1080: the list's first row is centred at y=215, +# then 23 px apart. The first two rows are the Origin and the Bed, unframed on the card above the +# features' own framed list, so the first FEATURE row sits two rows and TREE_GAP further down; +# reset_document and the reopening double-click both need a feature. TREE_GAP is the list's +# 12 px top margin plus its 1 px frame, derived rather than measured -- re-measure on the rig, like +# RIBBON_DX. # x=300, not the label: a second click ON the label opens the inline rename, and Delete then # edits the text instead of removing the feature. # @@ -237,7 +250,8 @@ DESIGN_TAB = (128, 29 + CHROME_DY) # unshifted click lands 26 px BELOW the first row -- just past its 23 px height -- so the row is # never selected, Delete does nothing, and reset_document spends 40 rounds on it before dying # with "could not empty the feature tree". That names the feature tree, which is not the fault. -TREE_ROW0 = (300, 215 + CHROME_DY) +TREE_GAP = 12 + 1 +TREE_ROW0 = (300, 215 + 2 * 23 + TREE_GAP + CHROME_DY) def go_design(): @@ -266,18 +280,21 @@ def reset_document(): def enter_sketch(tool_key, plane_px=(913, 359)): """Enter a sketch the way the design law says: pick the plane in the viewport, then the tool. - Shift+S enters sketch MODE and pops the offer; Escape dismisses it; the tool letter then - starts the session on the plane the click selected. All four steps are real input — nothing - here goes through the socket. + leave_sketch()'s Escapes let go of every pick, so Shift+S finds nothing to sketch on and only + brings the reference planes up; the click on one opens the session on it and pops the tool + offer. A click outside a popup menu only closes it, so the same spot is clicked again, apart: + inside the session it is empty canvas, so it selects nothing if the offer is slow to appear. + No Escape: with no offer up, Escape in an empty sketch leaves it. The tool letter then arms + the tool. All of it is real input — nothing here goes through the socket. """ leave_sketch() - click(*plane_px) key("shift+s", 0.8) - key("Escape", 0.4) # entering sketch mode pops the offer; dismiss it + click(*plane_px, pause=0.8) # picks the plane: the session opens and the offer pops... + click(*plane_px) # ...and this closes the offer key("p", 0.6) if try_call("sketch_describe") is None: shot("/shots/gl-enter-failed.png") - die("no sketch opened after plane click + Shift+S (see /shots/gl-enter-failed.png)") + die("no sketch opened after Shift+S + plane click (see /shots/gl-enter-failed.png)") calibrate_here() # THIS sketch's own camera map, on THIS sketch's own plane key(tool_key, 0.6) @@ -737,7 +754,7 @@ def corner_pair(ents): die("no adjacent pair in what should be a rectangle") -CONSTRUCTION_CHECKBOX = (419, 75 + CHROME_DY) +CONSTRUCTION_CHECKBOX = (419 + RIBBON_DX, 75 + CHROME_DY) def draw_line(x0, y0, x1, y1, length, angle): @@ -866,7 +883,7 @@ def rung_extend(): # because the ladder only ever clicks "perpendicular" (3) and "equal" (5), both of which sit # before the first insertion. The next rung to use "tangent" would have clicked "collinear". CON_BTN_Y = 76 + CHROME_DY -CON_BTN = {n: (449 + 42 * i, CON_BTN_Y) for i, n in enumerate( +CON_BTN = {n: (449 + RIBBON_DX + 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"])} @@ -880,7 +897,7 @@ CON_BTN = {n: (449 + 42 * i, CON_BTN_Y) for i, n in enumerate( # which already carries CHROME_DY), but the X start depends on how wide the sketch toolbar to the # left of this group renders, and this fork keeps mainline's top row. Re-measure before trusting # D11 here: screenshot in sketch mode and detect the icon columns, do not derive it by offset. -CON_BTN_SKETCH = {n: (677 + 42 * i, CON_BTN_Y) for i, n in enumerate( +CON_BTN_SKETCH = {n: (677 + RIBBON_DX + 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"])} diff --git a/scripts/CAD/tool_atlas.json b/scripts/CAD/tool_atlas.json index 1e7f1bf968..af0653feff 100644 --- a/scripts/CAD/tool_atlas.json +++ b/scripts/CAD/tool_atlas.json @@ -229,11 +229,11 @@ "none" ], "needs": {}, - "refusal": "Click a face or a reference plane in the viewport, then a sketch tool", + "refusal": "Select a flat face or a reference plane to sketch on", "gui": true, "action": "key:S+S", "icon": "design_sketch", - "hint": "Click a face or a reference plane, then pick a drawing tool" + "hint": "Sketch on the selected flat face or plane, or click one next" }, { "id": "extrude", @@ -1113,6 +1113,32 @@ "icon": "color_palette", "hint": "Set the selected body's display color" }, + { + "id": "zoom_to", + "name": "Zoom to selection", + "slot": "top", + "key": null, + "feature": null, + "mcp": null, + "mode": "model", + "accepts": [ + "body_solid", + "body_sheet", + "face_planar", + "face_cyl", + "face_other", + "edge_str", + "edge_circ", + "vertex", + "sk_loop" + ], + "needs": {}, + "refusal": null, + "gui": true, + "action": "btn:zoom_to", + "icon": "design_zoom", + "hint": "Frame the selection in the view, keeping the view direction" + }, { "id": "delete", "name": "Delete", diff --git a/src/libslic3r/AppConfig.cpp b/src/libslic3r/AppConfig.cpp index ebaa17a4b7..984524b0ba 100644 --- a/src/libslic3r/AppConfig.cpp +++ b/src/libslic3r/AppConfig.cpp @@ -370,6 +370,10 @@ void AppConfig::set_defaults() // restores the conventional CAD representation for users who expect it (x0kd). if (get("design_connector_face_glyph").empty()) set_bool("design_connector_face_glyph", true); + + // Design tab: draw the printer bed and its plate grid (the Feature tree's Bed row). + if (get("design_show_bed").empty()) + set_bool("design_show_bed", true); #endif //#ifdef SUPPORT_SHOW_HINTS diff --git a/src/slic3r/GUI/3DScene.cpp b/src/slic3r/GUI/3DScene.cpp index 597b960710..77010cbf9d 100644 --- a/src/slic3r/GUI/3DScene.cpp +++ b/src/slic3r/GUI/3DScene.cpp @@ -310,6 +310,7 @@ GLVolume::GLVolume(float r, float g, float b, float a) , force_native_color(false) , force_neutral_color(false) , force_sinking_contours(false) + , depth_bias(false) , picking(false) , tverts_range(0, size_t(-1)) { @@ -1332,11 +1333,17 @@ void GLVolumeCollection::render(GLVolumeCollection::ERenderType type, shader->set_uniform("projection_matrix", projection_matrix); const Matrix3d view_normal_matrix = view_matrix.matrix().block(0, 0, 3, 3) * model_matrix.matrix().block(0, 0, 3, 3).inverse().transpose(); shader->set_uniform("view_normal_matrix", view_normal_matrix); + if (volume.first->depth_bias) { + glsafe(::glEnable(GL_POLYGON_OFFSET_FILL)); + glsafe(::glPolygonOffset(1.0f, 1.0f)); + } //BBS: add outline related logic if (volume.first->selected && shader_can_outline && GUI::wxGetApp().show_outline()) volume.first->render_with_outline(cnv_size); else volume.first->render(); + if (volume.first->depth_bias) + glsafe(::glDisable(GL_POLYGON_OFFSET_FILL)); #if ENABLE_ENVIRONMENT_MAP if (use_environment_texture) diff --git a/src/slic3r/GUI/3DScene.hpp b/src/slic3r/GUI/3DScene.hpp index 663979690f..664cd109cf 100644 --- a/src/slic3r/GUI/3DScene.hpp +++ b/src/slic3r/GUI/3DScene.hpp @@ -232,6 +232,10 @@ public: bool force_neutral_color : 1; // Whether or not to force rendering of sinking contours bool force_sinking_contours : 1; + // Orca: draw this volume with a positive depth bias (glPolygonOffset, pushed away from the + // camera), so on a surface it shares with another volume the other volume wins the depth + // test instead of z-fighting it + bool depth_bias : 1; // Is render for picking bool picking : 1; // slice error diff --git a/src/slic3r/GUI/CAD/DesignCanvas.cpp b/src/slic3r/GUI/CAD/DesignCanvas.cpp index 0e7296ccd4..0489df35c1 100644 --- a/src/slic3r/GUI/CAD/DesignCanvas.cpp +++ b/src/slic3r/GUI/CAD/DesignCanvas.cpp @@ -8,6 +8,7 @@ #include "slic3r/GUI/Camera.hpp" // N: look down the sketch plane normal #include "slic3r/GUI/GUI_App.hpp" #include "slic3r/GUI/Plater.hpp" +#include "slic3r/GUI/PartPlate.hpp" // the current plate's origin: the first view's offset #include "slic3r/GUI/ImGuiWrapper.hpp" #include "slic3r/GUI/GLToolbar.hpp" #include "slic3r/GUI/Event.hpp" @@ -58,6 +59,16 @@ extern wxPopupWindow* wxCurrentPopupWindow; namespace Slic3r { namespace GUI { +// The Design tab's iso view is the CAD one: a true isometric from the front-right corner, which +// shows the navigator's Front, Right and Top faces. Camera's own "iso" looks from the front-left +// at 45 degrees and stays Prepare's and the thumbnails'. +static void select_iso_view(Camera& camera) +{ + const Vec3d target = camera.get_target(); + camera.look_at(target + camera.get_distance() * Vec3d(1., -1., 1.).normalized(), target, Vec3d::UnitZ()); + camera.auto_type(Camera::EType::Perspective); +} + DesignCanvas::DesignCanvas(wxWindow* parent) : wxPanel() { @@ -91,7 +102,7 @@ DesignCanvas::DesignCanvas(wxWindow* parent) m_canvas->enable_plate_chrome(false); m_canvas->enable_labels(false); m_canvas->enable_sinking_contours(false); // they would be sliced from the plater's meshes - m_canvas->set_axes_at_bed_center(true); // triad at bed centre = modeling origin + m_canvas->set_design_canvas(true); // home-position bed, triad and CAD grid at the modeling origin m_canvas->set_design_sketch_tool(&m_sketch_tool); m_sketch_tool.on_commit = [this](const SketchProfile& prof, const SketchPlane& pl) { @@ -190,8 +201,13 @@ DesignCanvas::DesignCanvas(wxWindow* parent) refresh_bed(); // The view this canvas opens on. Built lazily, on the way into the Design tab, so this - // is the view the user is looking at right now. + // is the view the user is looking at right now — moved off the current plate onto the + // Design bed, which stays at the printer bed's home whichever plate is current — turned to + // the iso view Home returns to, keeping its target and zoom. m_parked_camera = wxGetApp().plater()->get_camera(); + if (PartPlate* plate = wxGetApp().plater()->get_partplate_list().get_curr_plate()) + m_parked_camera.translate_world(-plate->get_origin()); + select_iso_view(m_parked_camera); // Before any of this class's own Binds below: wx calls dynamically bound handlers in // reverse order of binding, and GLCanvas3D swallows several events without skipping them — @@ -345,7 +361,7 @@ void DesignCanvas::repaint_now() m_canvas_widget->Update(); // service the pending paint immediately (a modal popup owns the loop) } -void DesignCanvas::reload(bool keep_view) +void DesignCanvas::reload() { m_canvas->reset_volumes(); @@ -378,21 +394,19 @@ void DesignCanvas::reload(bool keep_view) v->set_color(c); } } else if (obj_idx == 1) { - // The ghost is normally a faint blue overlay on the visible body. In preview-only - // mode it IS the result (base bodies hidden), so render it opaque so it reads as a - // finished solid rather than a see-through hint. + // The ghost is the whole resulting model, normally drawn as a faint blue overlay on + // the visible bodies. Every face the feature leaves alone is in both, at the same + // depth, so the ghost is drawn with a depth bias: the bodies win on those faces instead + // of the two copies z-fighting, and the ghost shows only where the result reaches past + // the bodies. In preview-only mode it IS the result (base bodies hidden), so render it + // opaque so it reads as a finished solid rather than a see-through hint. v->set_color(m_body_hidden ? ColorRGBA(0.40f, 0.82f, 1.0f, 1.0f) : ghost); + v->depth_bias = true; } } - if (!keep_view) { - if (m_first_frame && !m_model.objects.empty()) { - m_canvas->select_view("iso"); - m_canvas->zoom_to_volumes(); - m_first_frame = false; - } - } - + // The camera stays where the user left it, even for the first body: Home and a double-click + // fit on demand. m_canvas->set_as_dirty(); if (m_canvas_widget) m_canvas_widget->Refresh(); @@ -406,18 +420,21 @@ void DesignCanvas::set_bodies(const std::vector* body_meshes, if (body_meshes == nullptr || body_meshes->empty()) { clear_mesh(); return; } m_body_meshes = body_meshes; + m_sketch_tool.refresh_body_edges(); // of the bodies set_solid_pick() pointed the tool at m_lit_faces = m_sketch_tool.selected_faces(); rebuild_bodies(); - reload(!m_first_frame); + reload(); } void DesignCanvas::clear_mesh() { m_body_meshes = nullptr; m_volumes.clear(); + // No solid, so no edge lines, pick or hover either, as after a rebuild that leaves no body. + m_sketch_tool.set_solid_pick(nullptr, nullptr, nullptr, nullptr); if (!m_model.objects.empty()) { m_model.delete_object((size_t)0); - reload(true); + reload(); } } @@ -431,14 +448,14 @@ void DesignCanvas::set_preview_mesh(const TriangleMesh& mesh) obj->add_volume(mesh); obj->add_instance(); - reload(true); + reload(); } void DesignCanvas::clear_preview() { if (m_model.objects.size() > 1) { m_model.delete_object((size_t)1); - reload(true); + reload(); } } @@ -452,10 +469,23 @@ void DesignCanvas::fit_view() } } +bool DesignCanvas::zoom_to_box(BoundingBoxf3 box) +{ + if (m_canvas == nullptr || !box.defined) + return false; + DesignSketchTool::pad_box(box); + m_canvas->zoom_to_box(box); + request_repaint(); + return true; +} + void DesignCanvas::set_view(const std::string& view_name) { if (m_canvas) { - m_canvas->select_view(view_name); + if (view_name == "iso") + select_iso_view(wxGetApp().plater()->get_camera()); // the Design camera while the tab is shown + else + m_canvas->select_view(view_name); m_canvas->zoom_to_volumes(); m_canvas->set_as_dirty(); if (m_canvas_widget) @@ -470,13 +500,6 @@ void DesignCanvas::begin_sketch(const SketchPlane& plane, DesignSketchTool::Mode if (m_canvas_widget) m_canvas_widget->Refresh(); } -void DesignCanvas::set_sketch_plane(const SketchPlane& plane) -{ - m_sketch_tool.set_plane(plane); // keeps the 2D entities; only the carrier plane changes - if (m_canvas) m_canvas->set_as_dirty(); - if (m_canvas_widget) m_canvas_widget->Refresh(); -} - void DesignCanvas::edit_sketch(const std::vector& entities, const std::vector& constraints, const SketchPlane& plane) @@ -550,6 +573,7 @@ void DesignCanvas::refresh_bed() double printable_height = 100.0; const auto* ph_opt = config->opt("printable_height"); if (ph_opt) printable_height = ph_opt->value; + // No position: the Design bed stays at the printer bed's home, whichever plate is current. m_bed.set_shape(bed_shape_opt->values, printable_height, {}, {}, "", false); // mainline added extruder_areas/heights params } @@ -731,8 +755,6 @@ void DesignCanvas::clear_move_gizmo() request_repaint(); } -bool DesignCanvas::moving_body() const { return m_sketch_tool.moving_body(); } - void DesignCanvas::set_on_body_move_changed(std::function cb) { m_sketch_tool.on_body_move_changed = std::move(cb); @@ -1025,6 +1047,11 @@ void DesignCanvas::set_on_datum_base_picked(std::function cb) m_sketch_tool.on_datum_base_picked = std::move(cb); } +void DesignCanvas::set_selected_base(std::function cb) +{ + m_sketch_tool.selected_base = std::move(cb); +} + void DesignCanvas::set_on_sketch_exit(std::function cb) { m_sketch_tool.on_exit = std::move(cb); @@ -1047,15 +1074,18 @@ void DesignCanvas::set_on_context_menu(std::function cb) return; m_ctx_bound = true; // Bound AFTER GLCanvas3D's own handlers, so this runs first and can consume the event. - // It only consumes when it actually opens the offer; every other right-click still falls - // through to the polyline-chain end and the move gizmo, which were there first. - // Right-drag pans. Without remembering where the press landed, every pan ended by popping - // the offer over wherever the camera stopped — the menu appearing as the reward for moving - // the view. The offer is the release of a STATIONARY right-click (kCadRightClickDriftPx). + // It only consumes when it actually opens the offer. + // Right-drag may pan or orbit (Preferences > Control). Without remembering where the press + // landed, every such drag ended by popping the offer over wherever the camera stopped — the + // menu appearing as the reward for moving the view. A right-click is the release of a + // STATIONARY press (kCadRightClickDriftPx); only that reaches the sketch tool or the offer. m_canvas_widget->Bind(wxEVT_RIGHT_DOWN, [this](wxMouseEvent& e) { m_ctx_press = e.GetPosition(); m_ctx_travelled = false; - e.Skip(); // the canvas still needs the press to seed the orbit + // Drop any press the tool still keeps (only a click's release takes it, so a pan's stays) + // before the canvas offers it this one, which ImGui may take instead. + m_sketch_tool.drop_right_click(); + e.Skip(); // the canvas still needs the press to seed a pan or an orbit }); m_canvas_widget->Bind(wxEVT_MOTION, [this](wxMouseEvent& e) { if (e.RightIsDown()) { @@ -1066,12 +1096,13 @@ void DesignCanvas::set_on_context_menu(std::function cb) }); m_canvas_widget->Bind(wxEVT_RIGHT_UP, [this](wxMouseEvent& e) { const wxPoint d = e.GetPosition() - m_ctx_press; - // Always read-and-clear, even when another guard already rules the offer out, or a - // terminator recorded under one condition would still be pending under the next. - const bool terminated = m_sketch_tool.take_right_consumed(); - // Click, or navigation? A press that travelled orbited; one that did not, did not. + // Click, or navigation? A press that travelled panned or orbited; one that did not, did not. const bool is_click = !m_ctx_travelled && std::max(std::abs(d.x), std::abs(d.y)) <= kCadRightClickDriftPx; - if (m_on_context_menu && !terminated && !inline_busy() && is_click) { + // Ending a chain or abandoning an anchor uses the click up. + const bool terminated = is_click && m_canvas && m_sketch_tool.take_right_click(*m_canvas); + if (terminated) + m_canvas->set_as_dirty(); // drawn by the canvas's own RightUp (e.Skip below) or at idle + else if (m_on_context_menu && !inline_busy() && is_click) { // The menu belongs to what you POINTED AT — and pointing happened at the PRESS, not // at the release, so the raycast uses the press position. Within a 3 px budget the // two are the same pixel in practice; using the press is what makes that a @@ -1167,9 +1198,6 @@ void DesignCanvas::set_readout(const std::string& text) if (m_canvas) m_canvas->set_as_dirty(); // drawn by the next frame (the tool feeds this from one) } -// Clear of the view cube and the two round view buttons, which own the bottom-left corner. -static constexpr float kStatusHudLeftInset = 190.f; - void DesignCanvas::set_status_text(const wxString& text, const wxColour& colour) { if (text == m_status_hud_last && colour == m_status_hud_colour) return; @@ -1204,8 +1232,11 @@ void DesignCanvas::render_hud() ImGuiWrapper::pop_common_window_style(); }; if (!m_status_hud_last.IsEmpty()) { + // Past the view cube and the round view buttons, which own the bottom-left corner. Asked + // of the canvas, which lays them out: they follow the monitor's DPI on Windows, where `em` + // does not, so a fixed inset in `em` let them cover the start of the line at 150%. // A sentence can be a sentence: it wraps to the room left of the readout chip. - const float left = kStatusHudLeftInset * em; + const float left = m_canvas->get_canvas_toolbar_right() + margin; const ImVec4 col = m_status_hud_colour.IsOk() ? ImVec4(m_status_hud_colour.Red() / 255.f, m_status_hud_colour.Green() / 255.f, m_status_hud_colour.Blue() / 255.f, 1.f) @@ -1241,7 +1272,7 @@ void DesignCanvas::sync_selected_faces() if (m_body_meshes == nullptr || m_model.objects.empty()) return; rebuild_bodies(); - reload(true); + reload(); }); } @@ -1293,7 +1324,7 @@ void DesignCanvas::set_operand_bodies(int target_body, int tool_body) if (m_hl_body_target == target_body && m_hl_body_tool == tool_body) return; m_hl_body_target = target_body; m_hl_body_tool = tool_body; - reload(true); // recolours the body volumes + reload(); // recolours the body volumes } void DesignCanvas::set_highlight_sketches(std::vector> hl) @@ -1306,7 +1337,7 @@ void DesignCanvas::set_body_translucent(bool on) { if (m_body_translucent == on) return; m_body_translucent = on; - reload(true); // re-applies object-0 alpha so the solid fades for the fillet preview + reload(); // re-applies object-0 alpha so the solid fades for the fillet preview } void DesignCanvas::set_xray_focus(int body) @@ -1314,7 +1345,7 @@ void DesignCanvas::set_xray_focus(int body) if (m_xray_focus == body) return; m_xray_focus = body; m_sketch_tool.set_pick_only_body(body); - reload(true); // re-applies per-body alpha so the non-focused bodies fade + reload(); // re-applies per-body alpha so the non-focused bodies fade } void DesignCanvas::set_body_hidden(bool on) @@ -1322,7 +1353,7 @@ void DesignCanvas::set_body_hidden(bool on) if (m_body_hidden == on) return; m_body_hidden = on; m_sketch_tool.set_body_edges_hidden(on); - reload(true); // hides/show base bodies + flips the ghost opaque/faint for preview-only mode + reload(); // hides/show base bodies + flips the ghost opaque/faint for preview-only mode } bool DesignCanvas::delete_selected_sketch_entities() diff --git a/src/slic3r/GUI/CAD/DesignCanvas.hpp b/src/slic3r/GUI/CAD/DesignCanvas.hpp index 9584550980..a7413dd370 100644 --- a/src/slic3r/GUI/CAD/DesignCanvas.hpp +++ b/src/slic3r/GUI/CAD/DesignCanvas.hpp @@ -3,6 +3,8 @@ #include #include "libslic3r/Point.hpp" +#include "libslic3r/BoundingBox.hpp" +#include "libslic3r/BuildVolume.hpp" #include "libslic3r/Color.hpp" #include #include @@ -48,16 +50,26 @@ public: // Multi-body display: one GLVolume per body, each coloured distinctly (per-body colour). // `visible` (optional, indexed by body) hides bodies whose flag is false. `body_meshes` is kept - // by address (a stable panel member) and read again whenever the selection changes. + // by address (a stable panel member) and read again whenever the selection changes. The + // bodies' edge lines are refreshed with them (DesignSketchTool::refresh_body_edges). void set_bodies(const std::vector* body_meshes, const std::vector& visible = {}); - void clear_mesh(); + void clear_mesh(); // no bodies: drops their volumes and the tool's edge lines, pick and selection void set_preview_mesh(const TriangleMesh& mesh); void clear_preview(); void fit_view(); void set_view(const std::string& view_name); + // Frame `box` along the current view direction, as the canvas's Fit button frames a selection. + // False, the camera left alone, when there is nothing to frame: an undefined box. + bool zoom_to_box(BoundingBoxf3 box); + // Boxes to hand zoom_to_box: the selection and body faces (see DesignSketchTool). + BoundingBoxf3 selection_box() const { return m_sketch_tool.selection_box(); } + BoundingBoxf3 faces_box(std::vector> faces) const + { + return m_sketch_tool.faces_box(std::move(faces)); + } void begin_sketch(const SketchPlane& plane, DesignSketchTool::Mode mode); // Re-open a committed entity sketch for full in-canvas editing (load geometry + @@ -66,7 +78,6 @@ public: const std::vector& constraints, const SketchPlane& plane); void set_sketch_tool(DesignSketchTool::Mode mode); - void set_sketch_plane(const SketchPlane& plane); // re-plane the live sketch when a reference plane is clicked in 3D void set_sketch_construction(bool c); // Flip the sketch selection between construction and real geometry; returns the // number of entities changed (0 = nothing selected, caller falls back to the mode). @@ -83,6 +94,8 @@ public: void finish_sketch(); bool is_sketching() const; void refresh_bed(); // re-sync the bed to the current printer (call on tab activation) + // Centre of the Design bed: the printer bed at its home position, whichever plate is current. + Vec2d bed_center() const { return m_bed.build_volume().bed_center(); } // The Camera is Plater-owned and shared with Prepare/Preview/Assemble; GLCanvas3D has no // per-canvas camera, so every orbit here would otherwise overwrite what the editor tabs // show. Exactly one of the two views is live at a time, so entering and leaving are the @@ -157,7 +170,6 @@ public: void begin_move_body(int body, const Vec3d& pivot, const Transform3d& base_xform, double body_radius); void clear_move_gizmo(); - bool moving_body() const; void set_on_body_move_changed(std::function cb); // Visual Fillet/Chamfer radius gizmo: when a solid edge is picked, anchor a radius arrow on // it; drag/edit fire the radius callback. Returns false if no edge is currently picked. @@ -234,6 +246,7 @@ public: std::vector labels = {}); // clickable labelled reference planes void clear_base_pick(); void set_on_datum_base_picked(std::function cb); + void set_selected_base(std::function cb); // the reference plane drawn selected, or -1 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_sketch_notice(std::function cb); // tool refusals/side effects @@ -380,7 +393,7 @@ public: void repaint_now(); private: - void reload(bool keep_view); + void reload(); void swap_camera(); // enter_viewport / leave_viewport, in the one direction they share // Selected faces are filled by the canvas: each body's selected faces become a volume of their @@ -415,7 +428,6 @@ private: Camera m_parked_camera; bool m_camera_swapped{false}; // guards a leave without an enter, and the reverse Model m_model; - bool m_first_frame{true}; int m_hl_body_target{-1}; int m_hl_body_tool{-1}; bool m_body_translucent{false};// fillet/chamfer preview → render the body see-through diff --git a/src/slic3r/GUI/CAD/DesignOffer.hpp b/src/slic3r/GUI/CAD/DesignOffer.hpp index 846a55d72f..2c2d40d5fc 100644 --- a/src/slic3r/GUI/CAD/DesignOffer.hpp +++ b/src/slic3r/GUI/CAD/DesignOffer.hpp @@ -102,7 +102,7 @@ static const bool kOfferRowFlat[] = { }; static const OfferVerb kOfferVerbs[] = { - {"sketch", L_CONTEXT("Sketch", "Design"), 0, "Shift+S", "key:S+S", L("Click a face or a reference plane in the viewport, then a sketch tool"), 0x00000403u, 0, 0, false, false, nullptr, "design_sketch", L("Click a face or a reference plane, then pick a drawing tool")}, + {"sketch", L_CONTEXT("Sketch", "Design"), 0, "Shift+S", "key:S+S", L("Select a flat face or a reference plane to sketch on"), 0x00000403u, 0, 0, false, false, nullptr, "design_sketch", L("Sketch on the selected flat face or plane, or click one next")}, {"extrude", L_CONTEXT("Extrude", "Design"), 1, "Shift+E", "key:S+E", L("Create a sketch, or pick a solid face, first"), 0x00004002u, 0, 0, false, false, nullptr, "design_extrude", L("Extrude a sketch profile, or push/pull a picked face")}, {"revolve", L_CONTEXT("Revolve", "Design"), 1, "Shift+R", "key:S+R", L("Create a sketch profile to revolve first"), 0x00004000u, 0, 0, false, false, nullptr, "design_revolve", L("Revolve a profile about an axis")}, {"sweep", L_CONTEXT("Sweep", "Design"), 1, "Shift+W", "key:S+W", L("Create a profile sketch to sweep first"), 0x00004000u, 0, 2, false, false, nullptr, "design_sweep", L("Sweep a profile along a path")}, @@ -145,6 +145,7 @@ static const OfferVerb kOfferVerbs[] = { {"rename", L_CONTEXT("Rename…", "Design"), 8, "F2", "btn:rename", L("Select a feature, or a body, to rename it"), 0x00004080u, 0, 0, false, false, nullptr, nullptr, L("Give this feature a name you will recognise in the tree (a body takes its name from the feature that makes it)")}, {"delete_face", L_CONTEXT("Delete Face", "Design"), 7, nullptr, "fly:dressup#3", L("Delete Face needs a body — add or import one first"), 0x0000000eu, 1, 0, false, false, nullptr, "design_delete", L("Remove faces from a body and heal the solid")}, {"colour", L_CONTEXT("Color", "Design"), 8, nullptr, "btn:colour", nullptr, 0x000001feu, 1, 0, false, false, nullptr, "color_palette", L("Set the selected body's display color")}, + {"zoom_to", L_CONTEXT("Zoom to selection", "Design"), 8, nullptr, "btn:zoom_to", nullptr, 0x000041feu, 0, 0, false, false, nullptr, "design_zoom", L("Frame the selection in the view, keeping the view direction")}, {"delete", L_CONTEXT("Delete", "Design"), 7, "Del", "btn:delete", nullptr, 0x000f7c00u, 0, 0, false, false, nullptr, "design_delete", L("Delete what is selected")}, {"delete_body", L_CONTEXT("Delete Body", "Design"), 7, nullptr, "btn:delete_body", nullptr, 0x000001feu, 1, 0, false, false, nullptr, "design_delete", L("Delete this whole body — removes the feature it was made from")}, {"sk_line_t", L_CONTEXT("Line", "Design"), 0, "L", "key:L", nullptr, 0x000f8000u, 0, 0, false, true, L("Line"), "design_line", L("Line — click start, then end")}, @@ -204,7 +205,7 @@ static const OfferVerb kOfferVerbs[] = { {"sk_radius", L_CONTEXT("Radius / diameter…", "Design"), 7, "V", "key:V", nullptr, 0x00020000u, 0, 0, false, true, nullptr, "design_dimension", L("Type the radius of this arc, or the diameter of this circle")}, {"sk_angdist", L_CONTEXT("Angle / distance…", "Design"), 7, "V", "key:V", nullptr, 0x00080000u, 0, 0, false, true, nullptr, "design_dimension", L("Type the angle between two lines, or the distance between the two picks")}, }; -static const int kOfferVerbCount = 92; +static const int kOfferVerbCount = 93; }} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/CAD/DesignPanel.cpp b/src/slic3r/GUI/CAD/DesignPanel.cpp index e21fc18085..a98bdad3b4 100644 --- a/src/slic3r/GUI/CAD/DesignPanel.cpp +++ b/src/slic3r/GUI/CAD/DesignPanel.cpp @@ -9,6 +9,7 @@ #include "slic3r/GUI/GUI_Utils.hpp" #include "slic3r/GUI/Widgets/Button.hpp" #include "libslic3r/CAD/GeometryEngine.hpp" // face_by_index for face-extrude gizmo anchor +#include "libslic3r/AppConfig.hpp" // design_* view preferences #include "libslic3r/TriangleMesh.hpp" // mesh import: STL/OBJ -> indexed_triangle_set #include "libslic3r/Format/OBJ.hpp" #include "libslic3r/Format/bbs_3mf.hpp" // put_other_changes: mark the project dirty outside the undo stack @@ -35,6 +36,7 @@ #include "libslic3r/Point.hpp" #include #include +#include #include "libslic3r/BoundingBox.hpp" #include "libslic3r/Color.hpp" #include "libslic3r/CAD/SketchSolver.hpp" @@ -104,7 +106,6 @@ #include "libslic3r/Model.hpp" #include "slic3r/GUI/GUI_App.hpp" #include "slic3r/GUI/Plater.hpp" -#include "libslic3r/BuildVolume.hpp" #include "slic3r/GUI/MainFrame.hpp" #include "slic3r/GUI/GUI_ObjectList.hpp" #include "slic3r/GUI/Shortcuts.hpp" @@ -275,7 +276,9 @@ static std::string commit_icon(bool bodies) } // The icons on each Feature tree and Bodies row (DesignRowList::Action::id). -enum RowAction { RowEdit, RowMove, RowVisibility, RowDelete }; +enum RowAction { RowEdit, RowMove, RowVisibility, RowDelete, RowZoom }; +// The rows of the Feature tree's pinned block, above the features. +static constexpr int kOriginRow = 0, kBedRow = 1; // A row's eye shows the state the row is in; its tip names what a click does. static DesignRowList::Action eye_action(bool shown) @@ -283,6 +286,25 @@ static DesignRowList::Action eye_action(bool shown) return { RowVisibility, shown ? "design_eye" : "design_eye_off", shown ? _L("Hide") : _L("Show") }; } +// What the Origin row's eye and its menu item do. +static wxString origin_toggle_text(bool shown) +{ + return shown ? _L("Hide reference planes and axes") : _L("Show reference planes and axes"); +} + +// What the Bed row's eye and its menu item do. +static wxString bed_toggle_text(bool shown) +{ + return shown ? _L("Hide the printer bed and its plate grid") + : _L("Show the printer bed and its plate grid"); +} + +// What Sketch asks for while it waits for a plane (DesignPanel::start_sketch). +static wxString sketch_plane_prompt() +{ + return _L("Click a reference plane or a flat face to sketch on it — Esc cancels"); +} + // Prepare outlines every numeric field (rounded, #4A4A51 on dark). wxSpinCtrlDouble is a // native GTK control that cannot draw that, and Orca's own SpinInput is int-only — it would // silently truncate a 2.5 mm radius. So the double spin keeps its arrows and validation and @@ -471,22 +493,11 @@ DesignPanel::DesignPanel(wxWindow* parent) m_construction->SetForegroundColour(dp_ctl_text()); auto select_tool = [this](DesignSketchTool::Mode mode, const wxString& hint) { if (!m_viewport) return; - if (!m_viewport->is_sketching()) { - wxString on; - const SketchPlane plane = sketch_plane_from_selection(on); - m_viewport->begin_sketch(plane, mode); - m_construction->SetValue(false); // a fresh session starts non-construction - m_sketch_on = on; // shown with the tool hint, so the target is visible - // The face has been CONSUMED as the sketch plane, so drop the pick. Leaving it live - // meant the next Extrude saw a selected face and push/pulled it instead of extruding - // the sketch just drawn — the same trap the imported-art path already guards against. - if (m_sel_solid_face >= 0 || m_pick_face >= 0) { - m_sel_solid_face = m_sel_solid_edge = m_sel_solid_body = -1; - m_pick_face = m_pick_face_body = -1; - } - } else { - m_viewport->set_sketch_tool(mode); - } + // Sketch mode opens with its session (start_sketch_on_target); this covers a mode a + // failed Constrain left without one. + if (!m_viewport->is_sketching()) + start_sketch_on_target(false); + m_viewport->set_sketch_tool(mode); m_viewport->set_sketch_construction(m_construction->GetValue()); set_status(StatusKind::Info, m_sketch_on.IsEmpty() ? hint : wxString::Format(_L("%s · plane: %s"), hint, m_sketch_on)); @@ -597,17 +608,19 @@ DesignPanel::DesignPanel(wxWindow* parent) set_status(_L("Axonometric view, fitted")); }; - // Commit to Plate and the bed toggle were mouse-only: a toolbar button and a checkbox with - // no accelerator between them, so neither could be reached from the keyboard at all, nor by - // anything driving the keyboard. Ctrl+Shift+P is Plate, Ctrl+Shift+B is Bed; neither - // collides with Orca's own Ctrl+Shift+S (Save as) or Ctrl+Shift+G (Print plate). + // Commit to Plate and the bed and origin toggles are mouse targets — a toolbar button and the + // Feature tree's Bed and Origin rows, which never take the focus — so without an accelerator + // none could be reached from the keyboard, nor by anything driving the keyboard. Ctrl+Shift+P + // is Plate, Ctrl+Shift+B is Bed, Ctrl+Shift+O is Origin; none collides with Orca's own + // Ctrl+Shift+S (Save as) or Ctrl+Shift+G (Print plate). m_keys_feature['P' | SC_SHIFT | SC_CTRL] = [this] { on_commit(); }; m_keys_feature['B' | SC_SHIFT | SC_CTRL] = [this] { - if (!m_show_bed) return; - const bool show = !m_show_bed->GetValue(); - m_show_bed->SetValue(show); - if (m_viewport) m_viewport->set_show_bed(show); - set_status(show ? _L("Bed shown") : _L("Bed hidden")); + toggle_bed(); + set_status(m_show_bed ? _L("Bed shown") : _L("Bed hidden")); + }; + m_keys_feature['O' | SC_SHIFT | SC_CTRL] = [this] { + toggle_origin(); + set_status(m_show_origin ? _L("Reference planes shown") : _L("Reference planes hidden")); }; // Flyout rows show the design_* glyphs as they are: drawn in Orca's icon grey (#949494), which @@ -625,14 +638,13 @@ DesignPanel::DesignPanel(wxWindow* parent) // the addresses are created inside the widget-building loops, so not building would silently // delete 42 verbs from the offer while they still rendered. 7ih records the cleanup // that lets the construction go away too. - // What stays: the two doc-row imports (consumed by add_doc below) and the view controls, - // which are chrome_only in the atlas and so have no offer row to fall back on. - static const std::set kBarKeep = { "step", "mesh", "place", "section", "flip" }; + // What stays: the view controls, which are chrome_only in the atlas and so have no offer row + // to fall back on, and Sketch, the group's lead. All four are built with the doc buttons. + static const std::set kBarKeep = { "place", "section", "flip" }; auto fadd = [&tb_slot](const char* id, wxWindow* w) { if (kBarKeep.count(id) == 0) { w->Hide(); return; } tb_slot[id].push_back(w); }; - m_tb_feature->Add(caption(_L("FEATURES")), 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 8); { // Onshape-style FEATURE flyouts: same themed-DropDown pattern as the sketch toolbar // (tinted glyphs, Body_14 measure, content-width popup) but each entry runs an @@ -710,36 +722,10 @@ DesignPanel::DesignPanel(wxWindow* parent) fadd(id, chev); }; - auto* b_sketch = icon_btn("design_sketch", _L("Sketch")); - // Sketch means a tool. Pressing it used to set the mode and then ask, unconditionally, - // for the very thing the user had just done — click XZ, read "XZ plane selected, press - // Sketch", press Sketch, and be told to click a reference plane. The plane was never - // lost (m_ref_plane holds it and begin_sketch captures it when the first tool is armed); - // the sentence was simply false, and with right-click excluded in sketch mode there was - // no door to the tools at all, so the only way on was the toolbar this tab is retiring. - std::function act_sketch = [this] { - set_ui_mode(UiMode::Sketch); - wxString where; - const bool have_plane = sketch_plane_target(where); - set_status(StatusKind::Info, have_plane - ? wxString::Format(_L("Sketch plane: %s — pick a tool"), where) - : _L("Click a face or a reference plane in the viewport, then a sketch tool")); - if (m_sketch_hint) { // the card must agree with the status line, not argue with it - m_sketch_hint->SetLabel(have_plane - ? wxString::Format(_L("Sketch plane: %s.\nPick a tool, or press Menu for the list."), where) - : _L("Click a face or a reference plane, then a sketch tool.")); - m_sketch_hint->Refresh(); - m_cards->Layout(); - } - // Hand over the tools rather than naming them in a status line. CallAfter so the - // mode change has settled before a modal menu takes the loop; the menu carries each - // tool's shortcut, so pressing the key instead of picking a row costs nothing. - if (have_plane) - CallAfter([this] { show_offer_menu(offer_anchor()); }); - }; - b_sketch->Bind(wxEVT_BUTTON, [act_sketch](wxCommandEvent&) { act_sketch(); }); - m_keys_feature[SHIFT('S')] = act_sketch; - fadd("sketch", b_sketch); + // Sketch opens a sketch on the face or plane already picked, or asks for one first + // (start_sketch): sketch mode is never entered without a plane under it. Its button is + // built with the doc buttons below. + m_keys_feature[SHIFT('S')] = [this] { start_sketch(); }; // Add material: every feature that grows new solid material — from a profile // (extrude/revolve/sweep/loft), from a face (thicken) or from a line (rib). feat_dropdown("material", "design_extrude", _L("Add material (extrude / revolve / sweep / loft / thicken / rib)"), { @@ -1080,6 +1066,7 @@ DesignPanel::DesignPanel(wxWindow* parent) b_color->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_set_body_color(); }); fadd("color", b_color); m_verb_actions["btn:colour"] = [this] { on_set_body_color(); }; + m_verb_actions["btn:zoom_to"] = [this] { zoom_to_selection(); }; m_verb_actions["btn:delete"] = [this] { on_delete_feature(); }; // A row's double-click is Edit, so renaming needs a door of its own. The offer is this // tab's only tool vocabulary and the row IS the object, so rename sits in the offer, in @@ -1091,8 +1078,8 @@ DesignPanel::DesignPanel(wxWindow* parent) // is editable now (CadBody::user_name), so the verb opens the editor there. if (const int b = tree_body_selection(); b >= 0) m_parts->begin_rename(b); - else if (const int sel = tree_selection(); sel != wxNOT_FOUND) - m_tree->begin_rename(sel); + else if (tree_selection() != wxNOT_FOUND) + m_tree->begin_rename(m_tree->selection()); else set_status(_L("Select a feature, or a body, first — then rename it")); // not an error }; @@ -1240,18 +1227,7 @@ DesignPanel::DesignPanel(wxWindow* parent) }, SHIFT('T')}, }); // Text / SVG insert tools live in the SKETCH toolbar (they produce 2D profiles = - // sketches), not here. STEP stays in Features: it imports a whole B-rep solid. - // Import STEP — standalone: a STEP comes in as a whole editable B-rep body, not a profile. - auto* b_step = icon_btn("design_step", _L("Import STEP (editable B-rep solid)")); - b_step->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_import_step(); }); - m_keys_feature[SHIFT('I')] = [this] { on_import_step(); }; - fadd("step", b_step); - // Import mesh — same destination as STEP (an editable B-rep body), but the geometry has - // to be reconstructed from triangles first (GeometryEngine::mesh_to_brep). - auto* b_mesh = icon_btn("param_triangles", _L("Import mesh (STL/OBJ) as an editable B-rep solid")); - b_mesh->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_import_mesh(); }); - m_keys_feature[SHIFT('M')] = [this] { on_import_mesh(); }; - fadd("mesh", b_mesh); + // sketches), not here. Import STEP / mesh are document actions: see the doc row. auto* b_constrain = icon_btn("design_constrain", _L("Constrain selected sketch")); b_constrain->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_begin_constrain(); @@ -1398,12 +1374,11 @@ DesignPanel::DesignPanel(wxWindow* parent) // the sketch tools, not in the generic Features strip. Each places the art // in-canvas, then commits via the Insert card's Confirm. { - // In Sketch MODE the art must land in the sketch — but begin_sketch() does not run - // until the first tool is armed, so pressing Sketch and then Text would find no - // session and commit a separate feature. Arm Select first: it starts the session on - // the picked plane without drawing anything, so Text behaves the same whether or not - // you had already drawn a line. (MODE vs SESSION — the distinction that has bitten - // this panel before.) + // In Sketch MODE the art must land in the sketch. The mode opens with its session + // (start_sketch_on_target), but a failed Constrain can leave it without one, and Text + // would then find no session and commit a separate feature. Arm Select first: it + // starts the session without drawing anything. (MODE vs SESSION — the distinction + // that has bitten this panel before.) auto ensure_sketch = [this, select_tool] { if (m_ui_mode == UiMode::Sketch && m_viewport && !m_viewport->is_sketching()) select_tool(DesignSketchTool::Mode::Select, @@ -1540,10 +1515,10 @@ DesignPanel::DesignPanel(wxWindow* parent) } m_viewport->set_sketch_construction(m_construction->GetValue()); }); // STAYS on the bar. Construction is not a tool, it is a persistent MODE — the same kind - // of thing as the Bed checkbox — and the sketch bar is already shown only in Sketch mode, - // so it appears exactly while it can apply. Hiding it left Q and the offer's Construction - // row still toggling a checkbox nobody could see: you could not tell whether the next - // line would be construction geometry. A stateful toggle has to show its state. + // of thing as the Feature tree's Bed row — and the sketch bar is already shown only in + // Sketch mode, so it appears exactly while it can apply. Hiding it left Q and the offer's + // Construction row still toggling a checkbox nobody could see: you could not tell whether + // the next line would be construction geometry. A stateful toggle has to show its state. sadd_bar(m_construction); add_sep(m_tb_sketch); auto* b_del = icon_btn("design_delete", _L("Delete selected")); @@ -1633,31 +1608,29 @@ DesignPanel::DesignPanel(wxWindow* parent) auto add_doc = [this](ScalableButton* b) { m_tb_doc->Add(b, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 4); }; - auto* b_new = doc_btn("add", _L("New Design — clear the feature tree")); + auto* b_new = doc_btn("design_new", _L("New Design — clear the feature tree")); b_new->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_new_design(); }); add_doc(b_new); - add_doc(static_cast(tb_slot["step"][0])); // 2. Import STEP - add_doc(static_cast(tb_slot["mesh"][0])); // 3. Import mesh - auto* b_export = doc_btn("save", _L("Export STEP…")); + // Import STEP — a STEP comes in as a whole editable B-rep body, not a profile. + auto* b_step = doc_btn("design_step", _L("Import STEP (editable B-rep solid)")); + b_step->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_import_step(); }); + m_keys_feature[SHIFT('I')] = [this] { on_import_step(); }; + add_doc(b_step); + // Import mesh — same destination as STEP (an editable B-rep body), but the geometry has + // to be reconstructed from triangles first (GeometryEngine::mesh_to_brep). + auto* b_mesh = doc_btn("design_import_mesh", _L("Import mesh (STL/OBJ) as an editable B-rep solid")); + b_mesh->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_import_mesh(); }); + m_keys_feature[SHIFT('M')] = [this] { on_import_mesh(); }; + add_doc(b_mesh); + auto* b_export = doc_btn("design_export", _L("Export STEP…")); b_export->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_export_step(); }); add_doc(b_export); - // View option, not a document action: hide the printer bed to model without it. Lives in - // this row because it must stay reachable with no tool open — a card would come and go. - m_show_bed = new CheckBox(m_toolbar); - m_show_bed->SetValue(true); // bed visible by default, as the tab opens today - m_show_bed->SetToolTip(_L("Show the printer bed and its plate grid")); - // wxEVT_TOGGLEBUTTON, NOT wxEVT_CHECKBOX: Orca's CheckBox derives from - // wxBitmapToggleButton (Widgets/CheckBox.hpp), so a wxEVT_CHECKBOX handler never fires. - // Read the control rather than the event so the state cannot disagree with the glyph. - m_show_bed->Bind(wxEVT_TOGGLEBUTTON, [this](wxCommandEvent& e) { - if (m_viewport) m_viewport->set_show_bed(m_show_bed->GetValue()); - e.Skip(); - }); - auto* bed_lbl = new wxStaticText(m_toolbar, wxID_ANY, _L("Bed")); - bed_lbl->SetForegroundColour(dp_sec_text()); - m_tb_doc->Add(m_show_bed, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, 6); - m_tb_doc->Add(bed_lbl, 0, wxALIGN_CENTER_VERTICAL | wxLEFT | wxRIGHT, 4); + // Sketch leads the feature group. Momentary like the imports: start_sketch() may only ask + // for a plane, or refuse, and a teal active-tool state would outlive either. + auto* b_sketch = doc_btn("design_new_sketch", _L("Create a new sketch")); + b_sketch->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { start_sketch(); }); + tb_slot["sketch"].push_back(b_sketch); // These act on bodies / the view, so they ride in the feature group, in the slots // the user assigned them (9, 11bis, 16). @@ -3111,11 +3084,9 @@ DesignPanel::DesignPanel(wxWindow* parent) // looking and pointing. A combo duplicated that decision somewhere the geometry could not // see it, and once a face could be picked it went further and displayed a stale row that // contradicted the real target. e1p. - // Kept as a member, not a local: the card has to be able to STOP saying this. It asked - // for a plane even when one had just been picked, directly contradicting the status line - // two inches below it, which by then read "Sketching on XZ". - m_sketch_hint = new wxStaticText(m_cards, wxID_ANY, - _L("Click a face or a reference plane, then a sketch tool.")); + // A member, not a local: it names the plane the session opened on, which is known only + // when it opens (start_sketch_on_target writes it), so the card and the status line agree. + m_sketch_hint = new wxStaticText(m_cards, wxID_ANY, _L("Pick a tool, or press Menu for the list.")); m_sketch_hint->SetForegroundColour(dp_sec_text()); m_box_sketch_session->Add(m_sketch_hint, 0, wxLEFT | wxRIGHT | wxTOP | wxBOTTOM, 12); } @@ -3159,11 +3130,34 @@ DesignPanel::DesignPanel(wxWindow* parent) FromDIP(SidebarProps::ContentMargin())); tree_inner->Add(new wxStaticLine(m_tree_box), 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, FromDIP(SidebarProps::TitlebarMargin())); - // Sized to its rows, so a short history wastes no block, and scrolling past 9. + // The Origin and Bed switches sit on the card itself, unframed, above the features' own framed + // list: view switches, not history. A list of their own so they stay put while the features + // scroll, and a non-selectable one because selecting them would do nothing: only their eyes + // answer. 13 = the feature list's margin plus its frame, so the two lists' icons line up. + m_pinned = new DesignRowList(m_tree_box, 2, false, wxBORDER_NONE); + m_pinned->SetBackgroundColour(dp_panel_bg()); + tree_inner->Add(m_pinned, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 13); + // Sized to its rows, so a short history wastes no block, and scrolling past 9 features. m_tree = new DesignRowList(m_tree_box, 9); m_tree->SetBackgroundColour(dp_panel_bg()); tree_inner->Add(m_tree, 0, wxEXPAND | wxALL, 12); + m_pinned->on_action = [this](int row, int) { + if (row == kBedRow) + toggle_bed(); + else if (row == kOriginRow) + toggle_origin(); + }; + // Exactly what the eye does, named. + m_pinned->on_menu = [this](int row, const wxPoint& screen) { + const bool bed = (row == kBedRow); + wxMenu menu; + const int id_toggle = wxWindow::NewControlId(); + menu.Append(id_toggle, bed ? bed_toggle_text(m_show_bed) : origin_toggle_text(m_show_origin)); + menu.Bind(wxEVT_MENU, [this, bed](wxCommandEvent&) { bed ? toggle_bed() : toggle_origin(); }, id_toggle); + m_pinned->PopupMenu(&menu, m_pinned->ScreenToClient(screen)); + }; + // Selecting a feature that leaves a body (Extrude, Fillet, Chamfer, Hole, ...) lights the // faces it made in the viewport — the fillet's round, not the whole part it sits on. m_tree->on_select = [this] { @@ -3172,7 +3166,7 @@ DesignPanel::DesignPanel(wxWindow* parent) // "the target" — but ONLY when this tree has a selection. Each list notifies on every // change, so clicking a body row runs apply_body_row, whose m_tree->unselect() fires // THIS handler, which would otherwise clear the body row the user had just clicked. - if (m_parts && tree_selection() != wxNOT_FOUND) m_parts->unselect(); + if (m_parts && m_tree->selection() != wxNOT_FOUND) m_parts->unselect(); const int sel = tree_selection(); // Likewise a viewport pick, which would be drawn just like the feature's faces. Not while // a card is open: the card reads that pick. @@ -3194,14 +3188,17 @@ DesignPanel::DesignPanel(wxWindow* parent) }; // Double-click a row = Edit, the same gesture that re-opens a committed sketch on the canvas. - m_tree->on_activate = [this] { on_edit_feature(); }; + m_tree->on_activate = [this] { + if (tree_selection() != wxNOT_FOUND) on_edit_feature(); + }; - // The row's own Edit / Show-hide / Delete, on the row the click selected. The body list is - // cleared here too, not left to on_select, which re-clicking the selected row does not run: - // a body row still selected would be what on_toggle_visibility acts on. - m_tree->on_action = [this](int, int id) { + // The row's own Zoom to selection / Edit / Show-hide / Delete, on the row the click selected. + // The body list is cleared here too, not left to on_select, which re-clicking the selected row + // does not run: a body row still selected would be what on_toggle_visibility acts on. + m_tree->on_action = [this](int row, int id) { if (m_parts) m_parts->unselect(); switch (id) { + case RowZoom: zoom_to_feature(row); break; case RowEdit: on_edit_feature(); break; case RowVisibility: on_toggle_visibility(); break; case RowDelete: on_delete_feature(); break; @@ -3222,6 +3219,7 @@ DesignPanel::DesignPanel(wxWindow* parent) const int id_up = wxWindow::NewControlId(); const int id_down = wxWindow::NewControlId(); const int id_vis = wxWindow::NewControlId(); + const int id_zoom = wxWindow::NewControlId(); const int id_art = wxWindow::NewControlId(); const int id_del = wxWindow::NewControlId(); menu.Append(id_rename, _L("Rename\tF2")); @@ -3236,6 +3234,9 @@ DesignPanel::DesignPanel(wxWindow* parent) menu.Append(id_up, _L("Move up")); menu.Append(id_down, _L("Move down")); menu.Append(id_vis, _L("Show / hide")); + // Offered where the row's own icon is: on a row with something to frame. + const bool zoom = can_zoom_to_feature(row); + if (zoom) menu.Append(id_zoom, _L("Zoom to selection")); menu.AppendSeparator(); menu.Append(id_del, _L("Delete")); menu.Bind(wxEVT_MENU, [this](wxCommandEvent&) { m_verb_actions["btn:rename"](); }, id_rename); @@ -3246,6 +3247,8 @@ DesignPanel::DesignPanel(wxWindow* parent) menu.Bind(wxEVT_MENU, [this](wxCommandEvent&) { on_delete_feature(); }, id_del); if (art) menu.Bind(wxEVT_MENU, [this, row](wxCommandEvent&) { on_transform_imported(row); }, id_art); + if (zoom) + menu.Bind(wxEVT_MENU, [this, row](wxCommandEvent&) { zoom_to_feature(row); }, id_zoom); m_tree->PopupMenu(&menu, m_tree->ScreenToClient(screen)); }; @@ -3347,12 +3350,14 @@ DesignPanel::DesignPanel(wxWindow* parent) set_status(StatusKind::Info, wxString::Format(_L("Body %d selected — right-click for what applies to it"), b + 1)); }; m_parts->on_select = [this, apply_body_row] { apply_body_row(tree_body_selection()); }; - // The row's own Move / Show-hide / Delete. apply_body_row runs unconditionally, as for the - // menu below: a face picked in the viewport since the row was selected has moved - // m_sel_solid_body, which is the body on_move_body and on_delete_body act on. + // The row's own Zoom to selection / Move / Show-hide / Delete. apply_body_row runs + // unconditionally, as for the menu below: a face picked in the viewport since the row was + // selected has moved m_sel_solid_body, which is the body on_move_body and on_delete_body act + // on. m_parts->on_action = [this, apply_body_row](int row, int id) { apply_body_row(row); switch (id) { + case RowZoom: zoom_to_body(row); break; case RowMove: on_move_body(); break; case RowVisibility: on_toggle_visibility(); break; case RowDelete: on_delete_body(); break; @@ -3365,9 +3370,10 @@ DesignPanel::DesignPanel(wxWindow* parent) // been promising this right-click since before it existed. m_parts->on_menu = [this, apply_body_row](int row, const wxPoint& screen) { apply_body_row(row); // unconditional — see above - // Let the modal menu take the loop after this handler returns — same CallAfter as the - // sketch path, which learned it the hard way. - CallAfter([this, screen] { show_offer_menu(screen); }); + // From inside the event, as the feature rows do, never CallAfter'd: wxGTK sends the + // context menu on the right PRESS, and a menu GTK pops after that event has finished + // has no trigger time, so the button's release closes it the moment it opens. + show_offer_menu(screen); }; // Renaming a BODY names the body itself. It does NOT rename the feature that created it: // an Extrude, a Cut and a Fillet all land on one body, so source_feature is one operation in @@ -3699,8 +3705,6 @@ DesignPanel::DesignPanel(wxWindow* parent) // One selection at a time: a pick replaces the Feature tree row. Not while a card is open: // the feature being edited keeps its row while the card's picks are made. if (level >= 1 && m_active == Tool::None && m_tree) m_tree->unselect(); - // A pick that fell through the move gizmo (clicked off the arrows) exits move mode. - if (m_viewport->moving_body()) m_viewport->clear_move_gizmo(); // Remember which body + face/edge so Extrude / dress-up target the RIGHT body. m_sel_solid_body = (level >= 1) ? body : -1; m_sel_solid_face = (level == 2) ? face : -1; // 4 = Vertex: a corner is not its face @@ -3712,8 +3716,29 @@ DesignPanel::DesignPanel(wxWindow* parent) m_pick_face_body = (level >= 1) ? body : -1; m_pick_face = (level >= 1) ? face : -1; // Last pick wins: a leftover loop pick would block Extrude's face push/pull branch, so - // Extrude would extrude a sketch instead of push/pulling the clicked face. - if (level >= 1) m_viewport->clear_loop_pick(); + // Extrude would extrude a sketch instead of push/pulling the clicked face. And a reference + // plane picked before stops being the one chosen, as a plane pick drops a face: otherwise it + // would come back as the sketch plane, and be drawn selected, once this pick is let go. + if (level >= 1) { + m_viewport->clear_loop_pick(); + m_plane_picked = false; + } + // Sketch is waiting for a plane: a flat face is one. m_pick_face is the face the click + // landed on whether it took the face, one of its edges or a corner — the rule Sketch + // applies to a face picked beforehand. A curved face says why it was not taken; a swept + // body or a click that let go of the pick leaves the prompt up. + if (sketch_plane_pick_live()) { + wxString on; + if (level >= 1 && sketch_plane_target(on)) { + start_sketch_on_target(true); + return; + } + set_status(StatusKind::Info, level >= 1 && m_pick_face >= 0 + ? _L("That face is not flat — click a flat face or a reference plane") + : sketch_plane_prompt()); + m_status->Refresh(); + return; + } // Say what got picked. Without this the ONLY feedback is the viewport highlight, so a // pick that registers but draws faintly is indistinguishable from one that never // happened — which is precisely how this failure was reported and why it resisted @@ -3909,7 +3934,9 @@ DesignPanel::DesignPanel(wxWindow* parent) // A click on nothing drops a list row as it drops a pick. Not while a card is open: the // feature being edited keeps its row. m_viewport->set_on_empty_pick([this] { - if (m_active == Tool::None && deselect_rows()) + if (m_active != Tool::None) return; + const bool row = deselect_rows(); + if (drop_plane_pick() || row) set_status(StatusKind::Info, _L("Nothing selected")); }); @@ -3987,28 +4014,37 @@ DesignPanel::DesignPanel(wxWindow* parent) refresh_plane_labels(); refresh_preview(); // re-resolve the frame + move the gizmo/ghosts to the new base } else { - // Clicking a reference plane in 3D IS how a sketch plane is chosen now. Record it and, - // when a session is already live, re-plane it immediately: begin_sketch captures the - // plane at first-tool-pick, so without this the entities would stay on the old plane - // while the committed feature landed on the new one. + // Clicking a reference plane in 3D IS how a sketch plane is chosen. The planes are + // only pickable outside a session (DesignSketchTool hit-tests them while no session + // is live), and the canvas reports the click after clearing the solid selection on the + // same miss, so the face pick is already empty and the sketch falls back to this plane. if (base >= 0) { m_ref_plane = base; m_plane_picked = true; // chosen, not merely defaulted to m_pick_face = m_pick_face_body = -1; // last pick wins: a plane beats a stale face - if (m_viewport && m_viewport->is_sketching()) - m_viewport->set_sketch_plane(plane_from_choice(m_ref_plane)); + } + if (base >= 0 && sketch_plane_pick_live()) { + start_sketch_on_target(true); // Sketch was waiting for exactly this + return; } const wxString nm = ref_plane_name(base); - // Both halves named the TOOLBAR, which no longer carries either button: the tools - // moved to the offer. Name the gesture that actually works in each mode, and say - // what a plain click does, since the two are easy to confuse on a plane. - set_status(StatusKind::Ok, m_ui_mode == UiMode::Sketch - ? wxString::Format(_L("%s plane selected — right-click for the drawing tools"), nm) - : wxString::Format(_L("%s plane selected — right-click to sketch on it, " - "or click an object to select it"), nm)); + // Name the gesture that works, and what a plain click does, since the two are easy to + // confuse on a plane. + set_status(StatusKind::Ok, + wxString::Format(_L("%s plane selected — right-click to sketch on it, " + "or click an object to select it"), nm)); m_status->Refresh(); } }); + // The reference plane drawn selected: the Plane card's base, or else the plane a sketch would go + // on — chosen, and not overridden by a picked face (sketch_plane_target's test, without its + // per-face OCCT lookup, as this is asked every frame). + m_viewport->set_selected_base([this] { + if (m_active == Tool::Plane) + return m_plane_base != nullptr && m_pl_faceA < 0 ? m_plane_base->GetSelection() : -1; + const bool face = (m_sel_solid_face >= 0 && m_sel_solid_body >= 0) || (m_pick_face >= 0 && m_pick_face_body >= 0); + return m_plane_picked && !face ? m_ref_plane : -1; + }); // Move-body gizmo (M5): each drag/edit reports the body's new translation. Store it as a // display-only per-body transform and re-feed the moved meshes (the OCCT shape is untouched, @@ -4144,13 +4180,8 @@ DesignPanel::DesignPanel(wxWindow* parent) const int key = e.GetKeyCode(); const bool ctrl = e.ControlDown() || e.CmdDown(); const bool sketching = (m_ui_mode == UiMode::Sketch) && m_viewport && m_viewport->is_sketching(); - // Which key MAP applies is a question about the MODE, not about whether a session is - // already running. Gating the sketch map on is_sketching() made it unreachable by - // keyboard: is_sketching() only turns true inside select_tool()'s begin_sketch(), and - // select_tool() is what the sketch keys call — so the first letter after entering - // sketch mode fell through to the feature map, matched nothing (feature keys are - // Shift+letter), and did nothing. The mouse worked only because the toolbar flyout - // reaches select_tool() directly. That is why all 17 keys read as dead. 0ud. + // Which key MAP applies is a question about the MODE, not about the canvas tool: + // is_sketching() is true in Constrain too, which keeps the feature map. 0ud. const bool sketch_mode = (m_ui_mode == UiMode::Sketch); // Never steal editing keys from a focused text field or an open in-canvas value field — // Delete/Ctrl+Z there must edit the text, not the model. @@ -4416,7 +4447,13 @@ DesignPanel::DesignPanel(wxWindow* parent) assert(dead.empty()); // debug builds stop here; release ships the log line } + m_show_origin = wxGetApp().app_config->get_bool("design_show_origin"); + m_show_bed = wxGetApp().app_config->get_bool("design_show_bed"); + if (m_viewport) m_viewport->set_show_bed(m_show_bed); set_ui_mode(UiMode::Feature); + // The Origin and Bed rows from the first paint: nothing else builds them before the first + // edit, project load or theme switch. + refresh_pinned(); build_phase("wiring"); BOOST_LOG_TRIVIAL(info) << "Design tab build: total " << build_clock.Time() << " ms"; } @@ -4460,7 +4497,13 @@ void DesignPanel::apply_dof_status(int dof, bool ok, bool has_constraints) void DesignPanel::set_ui_mode(UiMode m) { + // A session's ✓/✗ and Esc are its own; a Move left open would answer them instead, unseen, + // since the gizmo is not drawn in a sketch. + if (m != UiMode::Feature) end_body_move(true); m_ui_mode = m; + // Any mode change ends a plane choice under way; the refreshes below put the planes and the + // ✓/✗ right for it. + m_choosing_sketch_plane = false; if (m != UiMode::Sketch) m_sketch_on.clear(); // no stale "on the picked face" on the next hint // A committed loop picked before the session means nothing to the sketch map, but it would // still count as a selection and swallow the first Esc. The double-click that opens a sketch @@ -4507,9 +4550,8 @@ void DesignPanel::set_ui_mode(UiMode m) m_form->FitInside(); } update_action_bar(); // Sketch/Constrain modes show the unified ✓/✗; Feature idle hides it - // The origin planes follow the mode: entering Sketch offers them even when a body exists, - // leaving it takes them back. Without this they would only refresh on the next tree - // rebuild, which is not an event that happens when you merely press Sketch. + // The reference planes follow the plane choice the mode change just ended + // (update_reference_planes); without this they would wait for the next tree rebuild. update_reference_planes(); // Say where you are, in words, across the top of the viewport. @@ -4519,8 +4561,8 @@ void DesignPanel::set_ui_mode(UiMode m) // Seen on the rig: pick XY, arm Line, and the viewport is an empty grey field — no bed, no // grid, no origin, nothing to judge a length or a direction against. The plate grid was // carrying the ground reference for the whole tab. The banner already says where you are; - // taking the floor away as well only made the sketch harder to draw. The Bed checkbox is the - // one thing that governs the bed, in every mode. + // taking the floor away as well only made the sketch harder to draw. The Feature tree's Bed + // row is the one thing that governs the bed, in every mode. if (m_sketch_banner != nullptr) { const bool sketching = (m == UiMode::Sketch); if (sketching && m_sketch_banner_txt != nullptr) @@ -4546,7 +4588,9 @@ void DesignPanel::set_status_ok() set_status(wxString::Format(_L("OK — %zu triangles"), m_doc.display_mesh.its.indices.size())); if (m_viewport != nullptr) { - m_viewport->clear_move_gizmo(); // a recompute invalidates the gizmo's body centroid + // A recompute invalidates the gizmo's body centroid; a Move ends with it, keeping its pose. + if (body_move_pending()) end_body_move(true); + else m_viewport->clear_move_gizmo(); // the Transform card's, if up rebuild_disp_meshes(); // apply per-body Move transforms to the display/pick meshes // Point the solid-pick at the fresh body + TRANSFORMED pick mesh (stable address) + the // per-body xform vector (for edge sampling). Resets the whole/face/edge selection, whose @@ -4657,6 +4701,8 @@ void DesignPanel::on_add_text() void DesignPanel::open_text_dialog(int feat) { if (m_text_dlg != nullptr) { m_text_dlg->Raise(); return; } + end_body_move(true); + end_sketch_plane_choice(); // the text goes on the plane picked before, not on the next click m_text_editing = feat >= 0 && feat < int(m_doc.features.size()) && m_doc.features[feat].is_text(); m_text_feat = m_text_editing ? feat : -1; m_text_face_body = -1; @@ -5116,7 +5162,9 @@ void DesignPanel::add_imported_sketch( // Show the Insert Confirm/Cancel card while the imported art is being placed/sized. void DesignPanel::open_insert_card(const wxString& base_name) { + end_body_move(true); m_active = Tool::Insert; + end_sketch_plane_choice(); if (m_hdr_insert) m_hdr_insert->SetLabel(base_name); wxSizer* s = m_cards->GetSizer(); s->Show(m_box_insert, true, true); @@ -5161,6 +5209,8 @@ void DesignPanel::on_transform_imported(int feat_idx) const CadFeature& f = m_doc.features[feat_idx]; if (f.imported_regions.empty()) return; + end_body_move(true); // the art's placement gizmo and ✓/✗ take over from a Move left open + end_sketch_plane_choice(); // ...and from a plane choice: Scale artwork reaches here directly // In-canvas bbox handles (replaces the Move/Scale dialog): drag a corner to scale, // the centre to move. Values stream back via set_on_imported_transform. m_viewport->begin_imported_transform(feat_idx, f.imported_regions, f.plane, @@ -5688,7 +5738,6 @@ void DesignPanel::on_add_transform() // below does not — without this the body would keep showing the // dragged pose after a Transform that failed to build. m_xf_gizmo_body = -1; - m_move_body = -1; } if (m_doc.bodies.empty()) { set_status(StatusKind::Warning, _L("Transform needs a body — add or import one first")); @@ -7247,7 +7296,7 @@ wxString DesignPanel::idle_hint() const { return m_doc.features.empty() ? _L("Nothing yet — import a STEP or a mesh from the toolbar,\n" - "or click a reference plane and right-click it to start a sketch.") + "or press Sketch and click a reference plane to start a sketch.") : _L("No solid yet — select a sketch and right-click it to Extrude."); } @@ -7275,7 +7324,7 @@ void DesignPanel::on_tab_shown() if (m_viewport) m_viewport->refresh_bed(); hydrate_from_model(); - update_reference_planes(); // entering the Design tab: show the XY/XZ/YZ planes if no object yet + update_reference_planes(); // entering the Design tab if (show_clock.Time() > 100) // a slow first show is what users report; the usual one is not news BOOST_LOG_TRIVIAL(info) << "Design tab shown: bed, project recipe and planes in " << show_clock.Time() << " ms"; m_laid_out = true; @@ -7336,6 +7385,7 @@ void DesignPanel::on_sys_color_changed() wxGetApp().UpdateDarkUIWin(this); refresh_icons(); refresh_tree(); // the rows carry their own text colours + refresh_pinned(); Refresh(); } @@ -7354,8 +7404,12 @@ void DesignPanel::hydrate_from_model() // that has none yet. A design in progress keeps its origin: its sketches have it baked into // their planes, so moving it under them (a printer change between visits) would slide the // datum planes off the geometry. A loaded project brings its own (load_recipe). + // The centre of the Design bed, not the plater's: the plater moves its bed onto the current + // plate, so on plate 2+ the origin would land one plate stride away from the bed drawn here. + // Re-synced first because the control socket calls this without on_tab_shown(). if (!m_doc.origin_from_recipe) { - const Vec2d bc = plater->build_volume().bed_center(); + m_viewport->refresh_bed(); + const Vec2d bc = m_viewport->bed_center(); m_doc.modeling_origin = Vec3d(bc.x(), bc.y(), 0.0); // A document started here takes the weld rule from the preference; a loaded one // brings its own (load_recipe). @@ -7503,12 +7557,12 @@ void DesignPanel::refresh_tree() // Preserve the selected row across the rebuild — set_rows() drops the selection, which made // every edit/add feel like it "lost" the selection (and broke Edit/Move/Delete on the // just-touched feature). - const int keep = tree_selection(); + const int keep = m_tree->selection(); // Datum/reference planes carry no solid; feed them to the viewport so they render as // translucent rectangles (otherwise a Plane feature is invisible in the canvas). refresh_datum_planes(); - update_reference_planes(); // body added/removed -> show/hide the XY/XZ/YZ origin planes + update_reference_planes(); std::vector rows; rows.reserve(m_doc.features.size()); for (size_t fi = 0; fi < m_doc.features.size(); ++fi) { @@ -7529,7 +7583,9 @@ void DesignPanel::refresh_tree() row.colour = !f.enabled ? dp_item_dim() : mate_conflict_reason(int(fi)) != nullptr ? wxColour(235, 110, 110) : dp_item_text(); + // A row with nothing to frame keeps a blank cell, so the icons stay in their columns. row.actions = { + { RowZoom, can_zoom_to_feature(int(fi)) ? "design_zoom" : "", _L("Zoom to selection") }, { RowEdit, "design_edit", _L("Edit") }, eye_action(f.enabled), { RowDelete, "design_delete", _L("Delete") }, @@ -7573,6 +7629,7 @@ void DesignPanel::refresh_parts() // Hidden bodies are greyed and their eye is closed, so the state reads at a glance. row.colour = vis ? dp_item_text() : dp_item_dim(); row.actions = { + { RowZoom, "design_zoom", _L("Zoom to selection") }, { RowMove, "design_move", _L("Move") }, eye_action(vis), { RowDelete, "design_delete", _L("Delete") }, @@ -7605,12 +7662,22 @@ int DesignPanel::tree_body_selection() const // if a row was selected. bool DesignPanel::deselect_rows() { - const bool any = (m_tree && tree_selection() != wxNOT_FOUND) || tree_body_selection() >= 0; + const bool any = (m_tree && m_tree->selection() != wxNOT_FOUND) || tree_body_selection() >= 0; if (m_tree) m_tree->unselect(); if (m_parts) m_parts->unselect(); return any; } +// A picked reference plane is a selection like a face: the next Sketch opens on it, so letting go +// of everything has to let go of it too. The viewport asks for it every frame (set_selected_base). +bool DesignPanel::drop_plane_pick() +{ + if (!m_plane_picked) return false; + m_plane_picked = false; + if (m_viewport) m_viewport->request_repaint(); + return true; +} + void DesignPanel::update_section_flip_btn() { if (m_section_flip_btn) m_section_flip_btn->Enable(m_section_on); @@ -7725,23 +7792,108 @@ void DesignPanel::update_feature_highlight() const int sel = tree_selection(); // Hidden while a feature card is open: the card's ghost and picks are what the view is about. const bool wanted = sel >= 0 && sel < int(m_doc.features.size()) && m_active == Tool::None; - if (wanted && (sel != m_hl_feature || m_hl_generation != m_doc.topo_generation)) { + // Re-sending the same faces is cheap: the viewport reuses what it has. + m_viewport->set_highlight_faces(wanted ? feature_faces(sel) : std::vector>{}); +} + +const std::vector>& DesignPanel::feature_faces(int f) +{ + if (f < 0 || f >= int(m_doc.features.size())) { + static const std::vector> none; + return none; + } + if (f != m_hl_feature || m_hl_generation != m_doc.topo_generation) { std::vector> faces; - const CadFeature& f = m_doc.features[sel]; - if (f.enabled && CadDocument::produces_body(f.type)) // the rest make no faces - run_off_ui_thread(this, _L("Finding the feature's faces…"), [this, sel, &faces] { + const CadFeature& feat = m_doc.features[f]; + if (feat.enabled && CadDocument::produces_body(feat.type)) // the rest make no faces + run_off_ui_thread(this, _L("Finding the feature's faces…"), [this, f, &faces] { try { - faces = m_doc.faces_made_by(sel); + faces = m_doc.faces_made_by(f); } catch (...) { faces.clear(); // a highlight is not worth an escaped exception } }); m_hl_faces = std::move(faces); - m_hl_feature = sel; + m_hl_feature = f; m_hl_generation = m_doc.topo_generation; } - // Re-sending the same faces is cheap: the viewport reuses what it has. - m_viewport->set_highlight_faces(wanted ? m_hl_faces : std::vector>{}); + return m_hl_faces; +} + +bool DesignPanel::can_zoom_to_feature(int f) const +{ + if (f < 0 || f >= int(m_doc.features.size())) + return false; + const CadFeature& feat = m_doc.features[f]; + return feat.type == CadFeatureType::Sketch || (feat.enabled && CadDocument::produces_body(feat.type)); +} + +void DesignPanel::zoom_to_feature(int f) +{ + // A rebuild's busy loop runs queued clicks while its worker owns the document. + if (s_doc_worker_busy.load() > 0) { + set_status(StatusKind::Info, _L("The model is being rebuilt — zoom again when it finishes")); + return; + } + if (m_viewport == nullptr || !can_zoom_to_feature(f)) + return; + const CadFeature& feat = m_doc.features[f]; + const wxString name = wxString::FromUTF8(feat.name); // before finding faces runs the event loop + BoundingBoxf3 box; + if (feat.type == CadFeatureType::Sketch) { + // Whichever geometry the sketch keeps, drawn or not: a sketch an Extrude consumed is + // still where the Extrude starts. + const auto add = [&box, &feat](const Vec2d& p) { box.merge(feat.plane.to_world(p)); }; + if (!feat.entities.empty()) { + box = DesignSketchTool::sketch_box(feat.entities, feat.plane); + } else if (!feat.imported_regions.empty()) { + for (const auto& region : transform_regions(feat.imported_regions, feat.import_offset, + feat.import_scale_x, feat.import_scale_y)) + for (const auto& contour : region) + for (const Vec2d& p : contour) + add(p); + } else if (!feat.profile.points.empty()) { + for (const Vec2d& p : feat.profile.points) + add(p); + } else { + // The legacy shape, centred on the plane origin (CadDocument::build_sketch_wire). + const Vec2d h = feat.shape == SketchShape::Circle ? Vec2d(feat.radius, feat.radius) + : Vec2d(feat.width, feat.height) * 0.5; + for (const Vec2d& p : { Vec2d(-h.x(), -h.y()), Vec2d(h.x(), -h.y()), Vec2d(h.x(), h.y()), Vec2d(-h.x(), h.y()) }) + add(p); + } + } else { + box = m_viewport->faces_box(feature_faces(f)); + } + if (!m_viewport->zoom_to_box(box)) + set_status(StatusKind::Info, wxString::Format(_L("%s has nothing on show to zoom to"), name)); +} + +void DesignPanel::zoom_to_body(int b) +{ + if (m_viewport == nullptr) + return; + // From the body's display mesh, Move applied, rather than the selection: the viewport selects + // no hidden body (DesignSketchTool::select_body). An empty mesh's box still reads defined. + BoundingBoxf3 box; + if (b >= 0 && b < int(m_disp_body_meshes.size()) && !m_disp_body_meshes[b].empty()) + box = m_disp_body_meshes[b].bounding_box(); + if (!m_viewport->zoom_to_box(box)) + set_status(StatusKind::Error, _L("That body has no display mesh yet — recompute first")); +} + +void DesignPanel::zoom_to_selection() +{ + if (m_viewport == nullptr) + return; + // A body, as its row frames it; the kind has checked the index. + const OfferSel kind = OfferSel(offer_selection_kind()); + if (kind == OfferSel::BodySolid || kind == OfferSel::BodySheet) { + zoom_to_body(m_sel_solid_body); + return; + } + if (!m_viewport->zoom_to_box(m_viewport->selection_box())) + set_status(StatusKind::Info, _L("Select something to zoom to")); } // Boolean (combine bodies) — one gate for every door onto the tool. A body-body operation @@ -7761,6 +7913,12 @@ void DesignPanel::on_move_body() { const int b = m_sel_solid_body; if (m_viewport == nullptr) return; + // Whatever is open owns the canvas and the ✓/✗, so a Move armed under it could not be left. + // The Bodies list stays clickable meanwhile, hence the check here. + if (m_active != Tool::None || m_ui_mode != UiMode::Feature || m_text_dlg != nullptr) { + set_status(StatusKind::Info, _L("Finish or cancel what is open first")); + return; + } if (b < 0 || b >= int(m_doc.display_body_meshes.size())) { // Never fail silently here: the caller gates on bodies.size() while this needs a // tessellated per-body mesh, and when those disagreed the click did nothing at all. @@ -7768,6 +7926,14 @@ void DesignPanel::on_move_body() : _L("That body has no display mesh yet — recompute first")); return; } + const wxString hint = _L("Drag the arrows to move, the rings to rotate — then Confirm (Esc cancels)"); + // Already moving it: Cancel still goes back to where it began. Another body re-arms below, + // and the one being moved keeps its placement. + if (body_move_pending() && b == m_move_body) { + set_status(StatusKind::Info, hint); + return; + } + end_sketch_plane_choice(); // the gizmo takes the clicks a plane choice was waiting for sync_body_xform(); // Delta gizmo: pivot at the body's CURRENT world centroid; the tool composes the drag deltas // onto its current pose, so move + rotate both work (incl. on an already place-on-face'd body). @@ -7788,7 +7954,25 @@ void DesignPanel::on_move_body() if (m_move_angle) m_move_angle->SetValue(0.0); show_move_card(true); update_action_bar(); // surface the unified ✓/✗ while moving - set_status(StatusKind::Info, _L("Drag the arrows to move, the rings to rotate — then Confirm (Esc cancels)")); + set_status(StatusKind::Info, hint); +} + +// The one way out of the Move button's session, so the gizmo, the Move / Rotate card and the ✓/✗ +// go together. keep = true leaves the pose on screen, as starting another edit does; only Esc and +// Cancel put the body back. A no-op when no Move is up. +void DesignPanel::end_body_move(bool keep) +{ + if (!body_move_pending()) return; + if (!keep) { + sync_body_xform(); + if (m_move_body < int(m_body_xform.size())) + m_body_xform[m_move_body] = m_move_prev; + feed_bodies(); // re-render the reverted placement + } + m_viewport->clear_move_gizmo(); + m_move_body = -1; + show_move_card(false); + update_action_bar(); } // Transform card (add mode): arm the same move gizmo on the card's body so the geometry-first @@ -7809,8 +7993,6 @@ void DesignPanel::arm_transform_gizmo() if (m_viewport) m_viewport->begin_move_body(b, pivot, base, radius); m_xf_gizmo_body = b; m_xf_gizmo_base = base; - m_move_body = b; // the existing revert paths key off these two - m_move_prev = base; // Write the pivot into the card so the parametric feature reproduces what was dragged; // dx/dy/dz and angle start at zero (the gizmo reports deltas from this pose). if (m_xf_pivot_x) m_xf_pivot_x->SetValue(pivot.x()); @@ -7869,6 +8051,7 @@ bool DesignPanel::place_on_face() } const TopoDS_Face face = GeometryEngine::face_by_index(m_doc.bodies[b].shape, m_sel_solid_face); if (face.IsNull()) return false; + end_sketch_plane_choice(); // the body moves; a sketch was not started sync_body_xform(); const Transform3d old_x = m_body_xform[b]; // Outward face normal in the body's CURRENT displayed orientation. @@ -7902,8 +8085,7 @@ void DesignPanel::set_tree_selection(int row) // The selection (the solid pick, the hit face and the committed-loop pick) names bodies, faces and // features by index, so replacing or renumbering the feature list (undo/redo, New Design, load, -// delete, reorder) drops it, the viewport's highlights with it. The solid highlight too: a rebuild -// that leaves no body never reaches set_solid_pick. The callers repaint. +// delete, reorder) drops it, the viewport's highlights with it. The callers repaint. void DesignPanel::drop_selection() { if (m_viewport != nullptr) @@ -7930,7 +8112,7 @@ void DesignPanel::drop_solid_pick() bool DesignPanel::begin_renumber() { if (m_ui_mode != UiMode::Feature || m_text_dlg != nullptr || m_active == Tool::Insert - || (m_active == Tool::None && m_viewport != nullptr && m_viewport->moving_body())) { + || body_move_pending()) { set_status(StatusKind::Info, _L("Finish or cancel what is open first")); return false; } @@ -7942,6 +8124,9 @@ bool DesignPanel::begin_renumber() void DesignPanel::after_tree_edit(bool ok) { + // Any edit of the history (undo, delete, show/hide, a socket call...) writes the status line + // over the plane prompt, so it ends the choice rather than leave it waiting unannounced. + end_sketch_plane_choice(); update_undo_redo_buttons(); refresh_tree(); refresh_variables(); @@ -7993,13 +8178,12 @@ void DesignPanel::on_new_design() void DesignPanel::clear_document() { tool_cancel(); // leave any active tool / sketch / constrain cleanly + end_body_move(true); // ...and a Move, which tool_cancel leaves while a value is pending m_doc.clear(); // features + bodies + meshes + history m_doc.auto_close_loops = wxGetApp().is_auto_close_sketch_loops(); // a new design: today's preference Slic3r::set_sketch_auto_close(m_doc.auto_close_loops); drop_selection(); m_edit_index = -1; - m_move_body = -1; - show_move_card(false); m_body_xform.clear(); if (m_viewport) { m_viewport->clear_move_gizmo(); m_viewport->clear_mesh(); } after_tree_edit(true); // rebuild the (now empty) tree + clear the viewport @@ -8083,6 +8267,11 @@ void DesignPanel::on_toggle_visibility() return; } + // Hiding or showing a feature can renumber the bodies, and the Move holds a body index. + if (body_move_pending()) { + set_status(StatusKind::Info, _L("Finish or cancel what is open first")); + return; + } int sel = tree_selection(); if (sel == wxNOT_FOUND || sel >= int(m_doc.features.size())) { set_status(StatusKind::Error, _L("Select a feature in the tree first")); @@ -8099,8 +8288,18 @@ void DesignPanel::on_toggle_visibility() refresh_tree(); // greys the row set_tree_selection(sel); // keep the toggled feature selected if (m_viewport != nullptr) { - if (m_doc.display_mesh.its.indices.empty()) m_viewport->clear_mesh(); - else feed_bodies(); + if (m_doc.display_mesh.its.indices.empty()) { + m_viewport->clear_mesh(); + } else { + // Not set_status_ok(), which would take the gizmo from an open Transform card. + feed_bodies(); + // Hiding or showing a feature rebuilds the bodies and can renumber them, so the pick is + // re-pointed and its selection dropped. That also brings picking back for a body shown + // after everything was hidden (clear_mesh() dropped it). + m_viewport->set_solid_pick(&m_doc.bodies, &m_disp_pick_mesh, + &m_doc.display_tri_face, &m_doc.display_tri_body, + &m_body_visible, &m_body_xform); + } } sync_sketch_display(); // skips the hidden sketch + direct-renders set_status(StatusKind::Info, shown ? _L("Feature shown") : _L("Feature hidden")); @@ -10008,6 +10207,7 @@ void DesignPanel::on_commit() // is shown on screen, not the pre-feature solid. (confirm_tool() applies + closes.) if (m_active != Tool::None) confirm_tool(); + end_sketch_plane_choice(); // the bodies leave for the plate; a sketch was not started if (m_doc.display_mesh.its.indices.empty()) { set_status(_L("Nothing to commit — add a feature first")); @@ -10990,10 +11190,9 @@ void DesignPanel::update_rib_gizmo() m_rib_thickness ? m_rib_thickness->GetValue() : 0.0); } -// Onshape default planes: the XY/XZ/YZ reference planes are persistent, transparent, labelled, and -// larger than the bed — shown as the FALLBACK when there is no object yet. When the Plane tool is -// open they additionally surface existing datums so a base can be picked. Single authority for the -// reference-plane overlay (set/clear_base_pick). +// The XY/XZ/YZ reference planes and their axes. Single authority for the overlay +// (set/clear_base_pick): the Plane tool offers them with the datums as Offset bases; otherwise they +// are up while the Feature tree's Origin row shows them, and while a sketch plane is being chosen. void DesignPanel::update_reference_planes() { if (!m_viewport) return; @@ -11020,26 +11219,117 @@ void DesignPanel::update_reference_planes() } return; } - // Fallback (Onshape default planes): show the 3 reference planes while there is no SOLID body - // yet — so they persist through the 2D-sketch phase and reappear after a sketch is confirmed - // (a sketch creates no body). They no longer block selection: clicking existing geometry wins, - // a base-plane pick only fires on a click that hit nothing else (see on_mouse fall-through). - // Available while there is no solid yet OR while the user is actually choosing a sketch - // plane. The second half fixes a dead end: delete a sketch on a document that still has a - // body, press Sketch, and act_sketch says "click a face or a reference plane" — with the - // reference planes already taken away, because a body existed. The instruction was - // impossible to follow and there was no way to start a sketch at all short of finding a - // face to click. - // - // Not simply always-on: m_dbp_active both RENDERS and picks, so three translucent planes - // would otherwise float over every finished model. Tying them to Sketch mode shows them - // exactly when they are the thing being chosen, and hides them again on Finish. - if (m_doc.bodies.empty() || m_ui_mode == UiMode::Sketch) + // Up while Sketch waits for a plane to be picked (start_sketch), which the session it opens + // takes away; a live session would draw none anyway (DesignSketchTool::draws_reference_axes). + // They never block selection: clicking existing geometry wins, and a base-plane pick only + // fires on a click that hit nothing else (see on_mouse fall-through). + if (m_show_origin || m_choosing_sketch_plane) m_viewport->set_base_pick(std::move(bp), std::move(bi), std::move(bl)); else m_viewport->clear_base_pick(); } +void DesignPanel::toggle_origin() +{ + m_show_origin = !m_show_origin; + wxGetApp().app_config->set_bool("design_show_origin", m_show_origin); + // A plane picked while they were up is not a selection once nobody can see it. + if (!m_show_origin) drop_plane_pick(); + // Not an edit: a plane choice under way stays, and keeps its planes up. + update_reference_planes(); // repaints + refresh_pinned(); // the Origin row's eye +} + +void DesignPanel::toggle_bed() +{ + m_show_bed = !m_show_bed; + wxGetApp().app_config->set_bool("design_show_bed", m_show_bed); + if (m_viewport) m_viewport->set_show_bed(m_show_bed); // repaints + refresh_pinned(); // the Bed row's eye +} + +// View switches, not history: a view toggle rebuilds these two rows and leaves the features +// alone. The label dims while its thing is hidden, as a hidden body's does. +void DesignPanel::refresh_pinned() +{ + std::vector rows; + auto add = [&rows](const char* icon, const wxString& label, bool shown, const wxString& tip) { + DesignRowList::Action eye = eye_action(shown); + eye.tip = tip; + // A blank cell under the features' Delete, so the eye sits in the features' eye column. + rows.push_back({ icon, label, {}, shown ? dp_item_text() : dp_item_dim(), { eye, {} } }); + }; + // In kOriginRow / kBedRow order. + add("design_plane", _L("Origin"), m_show_origin, origin_toggle_text(m_show_origin)); + add("design_bed", _L("Bed"), m_show_bed, bed_toggle_text(m_show_bed)); + m_pinned->set_rows(std::move(rows)); +} + +void DesignPanel::start_sketch() +{ + if (m_ui_mode != UiMode::Feature) return; // already sketching or constraining + if (m_active != Tool::None || m_text_dlg != nullptr || body_move_pending()) { + set_status(StatusKind::Info, _L("Finish or cancel what is open first")); + return; + } + wxString where; + if (sketch_plane_target(where)) { + start_sketch_on_target(true); + return; + } + // Nothing to sketch on yet: offer the planes and stay in Feature mode until one is picked + // (set_on_datum_base_picked, set_on_solid_selection_changed). + m_choosing_sketch_plane = true; + update_reference_planes(); + update_action_bar(); // ✗ leaves the choice + set_status(StatusKind::Info, sketch_plane_prompt()); +} + +void DesignPanel::start_sketch_on_target(bool offer_tools) +{ + if (!m_viewport) return; + wxString on; + const SketchPlane plane = sketch_plane_from_selection(on); + m_viewport->begin_sketch(plane, DesignSketchTool::Mode::Select); + // The face or plane has been CONSUMED as the sketch plane, so drop the pick. A face left live + // meant the next Extrude saw a selected face and push/pulled it instead of extruding the + // sketch just drawn — the same trap the imported-art path already guards against — and a + // plane left picked would open the next sketch on it, unseen once the planes are hidden. + // The canvas's half goes too: left lit there, the next click on that face read as a second + // click on it and took the whole body, which has no plane to sketch on. + drop_solid_pick(); + m_plane_picked = false; + m_construction->SetValue(false); // a fresh session starts non-construction + set_ui_mode(UiMode::Sketch); // ends the plane choice, so the planes go + m_sketch_on = on; // shown with the tool hint, so the target is visible + set_status(StatusKind::Info, wxString::Format(_L("Sketch plane: %s — pick a tool"), on)); + if (m_sketch_hint) { // the card must agree with the status line, not argue with it + m_sketch_hint->SetLabel(wxString::Format(_L("Sketch plane: %s.\nPick a tool, or press Menu for the list."), on)); + m_sketch_hint->Refresh(); + m_cards->Layout(); + } + // Hand over the tools rather than naming them in a status line: the sketch bar carries none. + // CallAfter so the mode change has settled, and the pick that got here has returned, before a + // modal menu takes the loop; the menu carries each tool's shortcut, so pressing the key + // instead of picking a row costs nothing. + if (offer_tools) + CallAfter([this] { show_offer_menu(offer_anchor()); }); +} + +void DesignPanel::end_sketch_plane_choice() +{ + if (!m_choosing_sketch_plane) return; + m_choosing_sketch_plane = false; + update_reference_planes(); + update_action_bar(); +} + +bool DesignPanel::sketch_plane_pick_live() const +{ + return m_choosing_sketch_plane && m_ui_mode == UiMode::Feature && m_active == Tool::None + && m_text_dlg == nullptr && !body_move_pending(); +} + TriangleMesh DesignPanel::ghost_from_bodies(const std::vector& per_body) const { TriangleMesh out; @@ -11197,10 +11487,12 @@ void DesignPanel::refresh_preview() // Confirm so the user sees the gate before clicking; the red status says why. m_candidate_ok = ok; update_confirm_button(); - // Fillet/Chamfer/Draft: once the target edge/face yields a valid result, show ONLY the + // Fillet/Chamfer/Draft/Hole: once the target edge/face yields a valid result, show ONLY the // preview (hide the base bodies) so the user sees the finished shape, not the old solid - // doubled with the ghost. Before a valid pick the body stays visible so it can be picked. - m_viewport->set_body_hidden((m_active == Tool::Dressup || m_active == Tool::Draft) && ok); + // doubled with the ghost. A hole's cut lies inside the old solid, so without this the body + // would hide it entirely. Before a valid pick the body stays visible so it can be picked. + m_viewport->set_body_hidden((m_active == Tool::Dressup || m_active == Tool::Draft || + m_active == Tool::Hole) && ok); m_status->Refresh(); // Refresh the in-canvas Extrude depth arrow (self-gates: only while the Extrude card is open). @@ -11292,7 +11584,9 @@ void DesignPanel::push_polygon_params() void DesignPanel::open_tool(Tool t) { + end_body_move(true); // first: arm_transform_gizmo below takes over the same gizmo m_active = t; + end_sketch_plane_choice(); // after m_active: the Plane card keeps its planes m_candidate_ok = true; // a fresh card is confirmable until its preview says otherwise // Fillet/Chamfer/Draft no longer fade the body see-through; instead, once a valid target // is picked, refresh_preview hides the base bodies entirely (preview-only). Keep it opaque @@ -11590,7 +11884,6 @@ void DesignPanel::close_tool() if (m_viewport) m_viewport->clear_move_gizmo(); feed_bodies(); m_xf_gizmo_body = -1; - m_move_body = -1; } if (m_viewport) { m_viewport->set_body_translucent(false); m_viewport->set_body_hidden(false); m_viewport->set_xray_focus(-1); } // restore the opaque solid wxSizer* s = m_cards->GetSizer(); @@ -11639,7 +11932,7 @@ void DesignPanel::close_tool() m_viewport->clear_datum_gizmo(); m_viewport->set_operand_bodies(-1, -1); m_viewport->set_highlight_sketches({}); - update_reference_planes(); // back to no-tool: show the origin planes if there is no object yet + update_reference_planes(); // back to no-tool update_cards_frame(); m_form->Layout(); m_form->FitInside(); update_action_bar(); // no feature tool active -> hide the bar (unless a mode keeps it) @@ -11714,11 +12007,8 @@ void DesignPanel::cancel_tool() void DesignPanel::tool_confirm() { if (m_value_cont) { confirm_value(); return; } // value card owns ribbon ✓ while a value is pending - if (m_active == Tool::None && m_viewport && m_viewport->moving_body()) { // keep the placement, drop the gizmo - m_viewport->clear_move_gizmo(); - m_move_body = -1; - show_move_card(false); - update_action_bar(); + if (body_move_pending()) { // keep the placement, drop the gizmo + end_body_move(true); set_status_ok(); return; } @@ -11746,20 +12036,18 @@ void DesignPanel::tool_confirm() void DesignPanel::tool_cancel() { if (m_value_cont) { cancel_value(); return; } // value card owns ribbon ✗ while a value is pending - if (m_active == Tool::None && m_viewport && m_viewport->moving_body()) { // revert to the pose at move-start - sync_body_xform(); - if (m_move_body >= 0 && m_move_body < int(m_body_xform.size())) - m_body_xform[m_move_body] = m_move_prev; - m_viewport->clear_move_gizmo(); - m_move_body = -1; - show_move_card(false); - feed_bodies(); // re-render the reverted placement - update_action_bar(); + if (body_move_pending()) { // revert to the pose at move-start + end_body_move(false); set_status(StatusKind::Info, _L("Move canceled")); return; } if (m_active == Tool::Insert) { cancel_insert(); return; } if (m_active != Tool::None) { cancel_tool(); return; } + if (m_choosing_sketch_plane) { // Sketch was waiting for a plane: no sketch, planes away + end_sketch_plane_choice(); + set_status(StatusKind::Info, wxString()); + return; + } if (m_ui_mode == UiMode::Sketch) { // THE explicit discard. Esc no longer arrives here at all (it routes through escape(), // which cannot destroy anything), so this button is now the only way a drawn sketch is @@ -11795,8 +12083,7 @@ bool DesignPanel::confirm_enabled() const { if (m_value_cont) return true; if (m_active == Tool::None) - return (m_viewport && m_viewport->moving_body()) - || m_ui_mode == UiMode::Sketch || m_ui_mode == UiMode::Constrain; + return body_move_pending() || m_ui_mode == UiMode::Sketch || m_ui_mode == UiMode::Constrain; if (m_active == Tool::Insert) return true; return m_candidate_ok; } @@ -11820,12 +12107,12 @@ CadLevel DesignPanel::escape_level() const || (m_viewport && m_viewport->inline_busy()); // An uncommitted delta: clicks are down on an entity that does not exist yet, or a body is // being moved by a gizmo that has not been confirmed. - st.gesture_active = m_viewport - && (m_viewport->drawing_in_progress() - || (m_active == Tool::None && m_viewport->moving_body())); - // Something is armed and waiting for input: a feature card, a sketch draw tool, Constrain. - // A sketch SESSION is deliberately not in this list — see escape(). + st.gesture_active = body_move_pending() + || (m_viewport && m_viewport->drawing_in_progress()); + // Something is armed and waiting for input: a feature card, Sketch waiting for its plane, a + // sketch draw tool, Constrain. A sketch SESSION is deliberately not in this list — see escape(). st.tool_armed = (m_active != Tool::None) + || m_choosing_sketch_plane || m_ui_mode == UiMode::Constrain || (m_ui_mode == UiMode::Sketch && m_viewport && !m_viewport->sketch_is_selecting()); st.has_selection = m_viewport && m_viewport->has_any_selection(); @@ -11862,7 +12149,10 @@ void DesignPanel::escape() // CANDIDATE (never a committed feature — in edit mode reset_edit_state only forgets which // feature was being edited, the feature itself is untouched); an armed sketch tool falls // back to Select, leaving every entity already drawn exactly where it is. - if (m_active != Tool::None || m_ui_mode == UiMode::Constrain) { tool_cancel(); return; } + if (m_active != Tool::None || m_choosing_sketch_plane || m_ui_mode == UiMode::Constrain) { + tool_cancel(); + return; + } if (m_viewport && m_viewport->sketch_disarm_tool()) { set_status(StatusKind::Info, _L("Select")); } @@ -11873,7 +12163,8 @@ void DesignPanel::escape() // both of which say which one they are, and never through a key pressed on the way out of // something else. The Feature tree and Bodies rows count as selections too. const bool row = deselect_rows(); - if ((m_viewport && m_viewport->clear_any_selection()) || row) { + const bool plane = drop_plane_pick(); + if ((m_viewport && m_viewport->clear_any_selection()) || row || plane) { set_status(StatusKind::Info, wxString()); return; } @@ -11896,7 +12187,7 @@ bool DesignPanel::menu_can_undo_redo(bool redo) const { if (m_ui_mode == UiMode::Sketch && m_viewport && m_viewport->is_sketching()) return redo ? m_viewport->can_redo_sketch_entity() : m_viewport->can_undo_sketch_entity(); - if (m_ui_mode != UiMode::Feature || m_active != Tool::None) return false; + if (m_ui_mode != UiMode::Feature || m_active != Tool::None || body_move_pending()) return false; return redo ? m_doc.can_redo() : m_doc.can_undo(); } @@ -11916,9 +12207,10 @@ void DesignPanel::update_action_bar() wxSizer* s = m_toolbar->GetSizer(); if (s == nullptr) return; const bool active = (m_active != Tool::None) + || m_choosing_sketch_plane // ✗ only: ✓ has nothing to confirm || m_ui_mode == UiMode::Sketch || m_ui_mode == UiMode::Constrain - || (m_viewport && m_viewport->moving_body()); + || body_move_pending(); s->Show(m_tb_action, active, true); // HIDING THE BAR ORPHANS THE KEYBOARD, and that is the "app does not consent to sketch" // report. The ✓/✗ live in this bar, so the click that confirms a feature leaves focus on a @@ -11954,7 +12246,8 @@ void DesignPanel::do_undo_redo(bool redo) // v1: act only in Feature mode. While authoring/constraining a sketch (m_ui_mode) or // with a feature dialog open (m_active), Esc/Cancel is the way out — popping committed // history mid-tool would be ambiguous (and could orphan the tool's referenced feature). - if (m_ui_mode != UiMode::Feature || m_active != Tool::None) { + // A Move is not in the history at all, so an undo under it would keep it without asking. + if (m_ui_mode != UiMode::Feature || m_active != Tool::None || body_move_pending()) { set_status(StatusKind::Info, _L("Finish or cancel the current tool first (Esc)")); return; } diff --git a/src/slic3r/GUI/CAD/DesignPanel.hpp b/src/slic3r/GUI/CAD/DesignPanel.hpp index 5e39362fad..823282bb57 100644 --- a/src/slic3r/GUI/CAD/DesignPanel.hpp +++ b/src/slic3r/GUI/CAD/DesignPanel.hpp @@ -383,7 +383,16 @@ private: void update_rib_gizmo(); // in-plane slab footprint + thickness handles (Rib card) void refresh_datum_planes(); // push resolved datum frames + per-plane u/v extents to viewport void refresh_mate_connectors(); // push connector frames so verse + polarity are visible - void update_reference_planes(); // persistent XY/XZ/YZ reference planes (fallback when no object) + void update_reference_planes(); // the XY/XZ/YZ planes + axes: Origin row, plane choice, Plane tool + void toggle_origin(); // the Origin row's eye (and Ctrl+Shift+O): keep the reference planes up + void toggle_bed(); // the Bed row's eye (and Ctrl+Shift+B): draw the printer bed, or not + void refresh_pinned(); // rebuild the Origin and Bed rows (their eyes and label colours) + // Sketch: open a session on the picked face or plane, or with none picked, put the planes up + // and wait for one (m_choosing_sketch_plane). + void start_sketch(); + void start_sketch_on_target(bool offer_tools); // a session on sketch_plane_from_selection() + void end_sketch_plane_choice(); // leave the plane choice without a sketch; no-op outside it + bool sketch_plane_pick_live() const; // may a pick made now still open the sketch? CadDocument m_doc; @@ -413,7 +422,6 @@ private: ScalableButton* m_commit_btn{nullptr}; // main face: runs the current commit mode DropDown* m_commit_drop{nullptr}; // commit-mode choices, owned via m_flyout_keepalive wxSizer* m_tb_doc{nullptr}; // toolbar document/view actions (new, commit, export, section, place) - CheckBox* m_show_bed{nullptr}; // view option: draw the printer bed + plate grid, or not wxSizer* m_box_move{nullptr}; // Move/Rotate numeric options (distance, axis, angle) wxSizer* m_box_sketch{nullptr}; wxSizer* m_box_extrude{nullptr}; @@ -571,8 +579,20 @@ private: // deliberately no dropdown for it. e1p. int m_ref_plane{0}; // m_ref_plane is always a VALID plane, so it cannot itself distinguish "the user chose XY" - // from "nobody has chosen anything yet". This does. + // from "nobody has chosen anything yet". This does. A selection like a picked face: the sketch + // opened on it uses it up, and Esc or a click on nothing lets go of it. bool m_plane_picked{false}; + // Sketch was pressed with nothing to sketch on: the reference planes are up and the next + // reference plane or flat face clicked opens the sketch on it. Still Feature mode — sketch + // mode is entered only with the session (start_sketch_on_target). + bool m_choosing_sketch_plane{false}; + // The Feature tree's Origin row: keeps the reference planes and their axes up + // (update_reference_planes). A view preference (AppConfig "design_show_origin"), not part of + // the recipe. + bool m_show_origin{false}; + // The Feature tree's Bed row: draws the printer bed and its plate grid. A view preference + // (AppConfig "design_show_bed"), not part of the recipe. + bool m_show_bed{true}; ComboBox* m_shape{nullptr}; ComboBox* m_mode{nullptr}; wxSpinCtrlDouble* m_width{nullptr}; @@ -913,12 +933,16 @@ private: std::function m_value_cont; // deferred apply, run on Confirm std::function m_value_cancel; // optional action when the card is cancelled - // Feature tree: one row per feature, in feature order, with a per-type icon and the row's - // own Edit / Show-hide / Delete icons. Callers use row indices via - // tree_selection()/set_tree_selection(); refresh_tree() rebuilds the rows. + // Feature tree: the Origin and Bed rows, a fixed block of view switches that never scrolls + // and selects nothing, then, in a frame of its own below them, one row per feature, in feature + // order, with a per-type icon and the row's own Zoom to selection / Edit / Show-hide / Delete + // icons. Callers use row indices via tree_selection()/set_tree_selection(); refresh_tree() + // rebuilds the rows. + DesignRowList* m_pinned{nullptr}; DesignRowList* m_tree{nullptr}; - // The faces the selected feature row made (CadDocument::faces_made_by), drawn as selected. - // Finding them replays the history, so they are kept per row and topology generation. + // The faces the selected feature row made (CadDocument::faces_made_by), drawn as selected, and + // what Zoom to selection frames for a row. Finding them replays the history, so they are kept + // per row and topology generation (feature_faces). // request_feature_highlight() refreshes them after the current event; every change of row, // card or topology calls it. int m_hl_feature{-1}; // the row m_hl_faces were found for... @@ -927,7 +951,10 @@ private: bool m_hl_pending{false}; void request_feature_highlight(); void update_feature_highlight(); + // The faces feature `f` made, found again when the cached ones are another row's or topology's. + const std::vector>& feature_faces(int f); bool deselect_rows(); // Esc / a click on nothing: drop the tree and Bodies rows + bool drop_plane_pick(); // ...and a picked reference plane; true if one was picked // Bodies list under the feature tree: one row per body (parallel to m_doc.bodies). Selecting // one highlights that body and makes it the target for the next op. DesignRowList* m_parts{nullptr}; @@ -964,8 +991,17 @@ private: void rebuild_disp_meshes(); // recompute m_disp_* from m_doc + m_body_xform void feed_bodies(); // push m_disp_* + visibility/xform to the viewport void on_move_body(); // start the move gizmo on the selected body + bool body_move_pending() const { return m_move_body >= 0; } // the Move button's session is open + void end_body_move(bool keep); // leave it: keep the dragged pose, or put the body back void arm_transform_gizmo(); // arm the move gizmo on the Transform card's body (add mode only) void on_set_body_color(); // Color tool: pick a per-body display colour override + // Zoom to selection: frame one thing along the current view, as the canvas's Fit button frames + // a selection. A body is framed whole, hidden or not; a feature by its sketch or the faces it + // made; anything else the offer is opened on, by what the Fit button frames for it. + void zoom_to_body(int b); + void zoom_to_feature(int f); + void zoom_to_selection(); + bool can_zoom_to_feature(int f) const; // a sketch, or a shown feature that makes faces void on_boolean_tool(); // Boolean (combine bodies): needs two solids, then opens the tool int tree_selection() const; // selected feature row, or wxNOT_FOUND int tree_body_selection() const; // selected Parts-list body index, or -1 diff --git a/src/slic3r/GUI/CAD/DesignRowList.cpp b/src/slic3r/GUI/CAD/DesignRowList.cpp index facf0aa5ed..1c39009cd5 100644 --- a/src/slic3r/GUI/CAD/DesignRowList.cpp +++ b/src/slic3r/GUI/CAD/DesignRowList.cpp @@ -29,11 +29,19 @@ static constexpr int kCellDip = 20; // square action cell, hover chip included static constexpr int kPadDip = 4; // row edges, and the gap before the action cells static constexpr int kGapDip = 6; // type icon to label -DesignRowList::DesignRowList(wxWindow* parent, int max_visible) - // wxVListBox defaults to wxBORDER_THEME; the sidebar's lists take a simple frame. - : wxVListBox(parent, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxBORDER_SIMPLE) +DesignRowList::DesignRowList(wxWindow* parent, int max_visible, bool selectable, long style) + // wxVListBox defaults to wxBORDER_THEME; `style` defaults to the simple frame the sidebar's + // lists take, and a list meant to sit unframed on its card passes wxBORDER_NONE. + : wxVListBox(parent, wxID_ANY, wxDefaultPosition, wxDefaultSize, style) , m_max_visible(std::max(max_visible, 1)) + , m_selectable(selectable) { + // A non-selectable list takes no focus: from the mouse because it consumes the presses that + // would focus it, below, and from the keyboard through AcceptsFocus(). wxGTK reads that once, + // while the base class is being built, before the override exists, so it is told here. + if (!m_selectable) + SetCanFocus(false); + Bind(wxEVT_LISTBOX, [this](wxCommandEvent&) { if (on_select) on_select(); }); Bind(wxEVT_LISTBOX_DCLICK, [this](wxCommandEvent&) { if (on_activate) on_activate(); }); @@ -46,18 +54,20 @@ DesignRowList::DesignRowList(wxWindow* parent, int max_visible) m_pressed = Hit{}; e.Skip(); }); - // A press on an action cell is remembered and skipped, so the list still selects the row. + // A press on an action cell is remembered and skipped, so the list still selects the row. A + // non-selectable list keeps every press from wxVListBox, which would select the row and take + // the focus. Bind(wxEVT_LEFT_DOWN, [this](wxMouseEvent& e) { const Hit h = hit_test(e.GetPosition()); m_pressed = h.cell >= 0 ? h : Hit{}; - e.Skip(); + if (m_selectable) e.Skip(); }); // The second press of a double-click on a cell is a press too, and is not skipped: the list // would otherwise turn it into a row double-click, so a quick double toggle of the eye would // also open the feature for editing. Bind(wxEVT_LEFT_DCLICK, [this](wxMouseEvent& e) { const Hit h = hit_test(e.GetPosition()); - if (h.cell < 0) { m_pressed = Hit{}; e.Skip(); return; } + if (h.cell < 0) { m_pressed = Hit{}; if (m_selectable) e.Skip(); return; } m_pressed = h; }); Bind(wxEVT_LEFT_UP, [this](wxMouseEvent& e) { @@ -69,9 +79,9 @@ DesignRowList::DesignRowList(wxWindow* parent, int max_visible) const int id = m_rows[pressed.row].actions[pressed.cell].id; // After the click has finished dispatching: the action may rebuild these rows. The row // must still exist and still be the selected one, or the click is dropped rather than - // applied to whatever row took its place. + // applied to whatever row took its place. A non-selectable list has no selected row. CallAfter([this, row = pressed.row, id] { - if (row < int(GetItemCount()) && row == GetSelection() && on_action) + if (row < int(GetItemCount()) && (!m_selectable || row == GetSelection()) && on_action) on_action(row, id); }); }); @@ -79,7 +89,7 @@ DesignRowList::DesignRowList(wxWindow* parent, int max_visible) // press suppresses the wxEVT_CONTEXT_MENU that follows it. Bind(wxEVT_RIGHT_DOWN, [this](wxMouseEvent& e) { const Hit h = hit_test(e.GetPosition()); - if (h.row != wxNOT_FOUND) select(h.row); + if (m_selectable && h.row != wxNOT_FOUND) select(h.row); e.Skip(); }); Bind(wxEVT_CONTEXT_MENU, [this](wxContextMenuEvent& e) { @@ -95,7 +105,7 @@ DesignRowList::DesignRowList(wxWindow* parent, int max_visible) return; // On MSW the menu comes with the button's release, and the pointer may have moved to // another row since the press selected one: the menu is for the row it opens over. - select(row); + if (m_selectable) select(row); if (on_menu) on_menu(row, screen); }); // wxVListBox takes every wheel event, even with nothing to scroll, so a short list would stop @@ -254,7 +264,7 @@ void DesignRowList::OnDrawBackground(wxDC& dc, const wxRect& rect, size_t n) con wxColour bg; if (IsSelected(n)) bg = StateColor::darkModeColorFor(wxColour("#BFE1DE")); - else if (int(n) == m_hover.row) + else if (m_selectable && int(n) == m_hover.row) // a non-selectable row lights only its icons bg = StateColor::darkModeColorFor(wxColour("#E5F0EE")); if (!bg.IsOk()) return; // the list already cleared to its background colour @@ -314,7 +324,7 @@ DesignRowList::Hit DesignRowList::hit_test(const wxPoint& pt) const const wxRect rect = GetItemRect(h.row); const auto& acts = m_rows[h.row].actions; for (size_t i = 0; i < acts.size(); ++i) - if (cell_rect(rect, acts.size(), i).Contains(pt)) { + if (!acts[i].icon.empty() && cell_rect(rect, acts.size(), i).Contains(pt)) { h.cell = int(i); break; } diff --git a/src/slic3r/GUI/CAD/DesignRowList.hpp b/src/slic3r/GUI/CAD/DesignRowList.hpp index ff1ecc482a..7c2c524691 100644 --- a/src/slic3r/GUI/CAD/DesignRowList.hpp +++ b/src/slic3r/GUI/CAD/DesignRowList.hpp @@ -29,12 +29,16 @@ namespace Slic3r { namespace GUI { // Selection follows wxTreeCtrl's contract, which DesignPanel's mutual exclusion between the two // lists relies on: on_select runs on every change, whether the user or select() made it, and // selecting the row that is already selected changes nothing and notifies nobody. +// +// A list built non-selectable is a row of switches, not of objects: a click never selects a row, +// the pointer highlights only the action icon under it, and the list never takes the focus. Its +// action icons and its context menu are the only things that answer. class DesignRowList : public wxVListBox { public: struct Action { int id; // the owner's code for it, handed back to on_action - std::string icon; // icon name, e.g. "design_eye" + std::string icon; // icon name, e.g. "design_eye"; empty for a blank cell that only holds a column wxString tip; }; struct Row { @@ -50,7 +54,8 @@ public: static constexpr const char* hover_chip = "#D4D4D4"; // The list is as tall as its rows, at least one and at most `max_visible`; past that it scrolls. - DesignRowList(wxWindow* parent, int max_visible); + // `style` is its frame: wxBORDER_NONE for a list that sits unframed on its card. + DesignRowList(wxWindow* parent, int max_visible, bool selectable = true, long style = wxBORDER_SIMPLE); // Replace every row. Clears the selection without notifying and cancels a rename in progress; // the owner re-selects the row it keeps, which notifies. @@ -67,15 +72,20 @@ public: std::function on_select; // the selection changed std::function on_activate; // the selected row was double-clicked - // A row's action icon was clicked. Runs after the click has finished dispatching, and only - // while that row is still the selected one; the click itself selected it. + // A row's action icon was clicked. Runs after the click has finished dispatching, while the + // row still exists and — in a selectable list, where the click itself selected it — is still + // the selected one. std::function on_action; - // Context menu on a row, at a screen position; the row under the pointer is selected first. + // Context menu on a row, at a screen position; the row under the pointer is selected first, + // unless the list is non-selectable. Pop the menu from inside the call: wxGTK sends it on the + // right press, and a menu deferred past that event closes on the button's release. std::function on_menu; // The editor committed `name` for `row`, trimmed and never empty (an empty commit cancels). // Runs after the editor's own events have finished, so the owner may rebuild the rows here. std::function on_rename; + bool AcceptsFocus() const override { return m_selectable && wxVListBox::AcceptsFocus(); } + protected: void OnDrawItem(wxDC& dc, const wxRect& rect, size_t n) const override; void OnDrawBackground(wxDC& dc, const wxRect& rect, size_t n) const override; @@ -103,6 +113,7 @@ private: std::vector m_rows; std::map m_icons; int m_max_visible; + bool m_selectable; int m_row_h{0}; // every row's height, from measure_row() Hit m_hover; Hit m_pressed; // action cell under the last left press diff --git a/src/slic3r/GUI/CAD/DesignSketchTool.cpp b/src/slic3r/GUI/CAD/DesignSketchTool.cpp index 4d6851b473..d5007239c1 100644 --- a/src/slic3r/GUI/CAD/DesignSketchTool.cpp +++ b/src/slic3r/GUI/CAD/DesignSketchTool.cpp @@ -30,7 +30,7 @@ #include #include "libslic3r/TriangleMesh.hpp" -#include +#include #include "libslic3r/CAD/SketchEngine.hpp" #include "libslic3r/Point.hpp" #include @@ -53,6 +53,7 @@ #include #include #include +#include #include "libslic3r/AppConfig.hpp" #include "libslic3r/Color.hpp" #include "slic3r/GUI/GLSelectionRectangle.hpp" @@ -77,6 +78,7 @@ static bool point_in_poly(const Vec2d& q, const std::vector& poly); static bool ray_triangle(const Vec3d& ro, const Vec3d& rd, const Vec3d& v0, const Vec3d& v1, const Vec3d& v2, double& t); static double ray_segment_dist3(const Vec3d& ro, const Vec3d& rd, const Vec3d& a, const Vec3d& b); +static double ray_axis_proj(const Linef3& ray, const Vec3d& anchor, const Vec3d& dir); // Project a world-space point to canvas screen pixels (device px, GL viewport units; // origin top-left after the GL y-flip). Mirrors GLCanvas3D's world->screen pattern: @@ -3287,7 +3289,7 @@ std::vector DesignSketchTool::make_bspline(const std::vector DesignSketchTool::entity_polyline(const SketchEntity& e, bool& closed) const +std::vector DesignSketchTool::entity_polyline(const SketchEntity& e, bool& closed) { closed = false; switch (e.type) { @@ -3474,18 +3476,19 @@ static double display_edge_tol(const TopoDS_Shape& shape) void DesignSketchTool::refresh_body_edges() { const size_t n = m_solid_bodies != nullptr ? m_solid_bodies->size() : 0; + bool rebuilt = n < m_body_edges_shape.size(); // a body went away m_body_edges.resize(n); - m_body_edges_key.resize(n, nullptr); + m_body_edges_shape.resize(n); m_body_edges_tol.resize(n, 0.0); for (size_t b = 0; b < n; ++b) { - const void* key = body_key(int(b)); - if (key == m_body_edges_key[b] && key != nullptr) - continue; - m_body_edges_key[b] = key; - m_body_edges[b].clear(); - if (key == nullptr) + if (is_current_shape(int(b), m_body_edges_shape[b])) continue; + rebuilt = rebuilt || !m_body_edges_shape[b].IsNull(); // a body sampled before was rebuilt const TopoDS_Shape& shape = (*m_solid_bodies)[b].shape; + m_body_edges_shape[b] = shape; + m_body_edges[b].clear(); + if (shape.IsNull()) + continue; m_body_edges_tol[b] = display_edge_tol(shape); try { m_body_edges[b] = GeometryEngine::display_edges(shape, m_body_edges_tol[b]); @@ -3493,15 +3496,36 @@ void DesignSketchTool::refresh_body_edges() m_body_edges[b].clear(); // an unsampleable edge costs its body the lines, nothing else } } + // A pick expires once the body it was taken on is rebuilt, which renumbers its faces and + // edges. Judged by that body's shape, not by these lines: a recompute that refreshed no lines + // (adding a datum, say) may come before a pick on the bodies it built, and that pick holds. + // The hover is resolved again on the next mouse move, so any rebuild just drops it. + if (m_solid_sel != SolidSel::None && !is_current_shape(m_sel_body, m_sel_shape)) + clear_solid_selection(); + else if (rebuilt) + m_pre = SolidPick{}; + // The face outlines of a rebuilt body let go of its shape, which may be a large import. + const auto stale = [this](const FaceHighlight& h) { return !is_current_shape(h.body, h.shape); }; + if (stale(m_sel_hl)) m_sel_hl = FaceHighlight{}; + if (stale(m_pre_hl)) m_pre_hl = FaceHighlight{}; + m_hl_faces.erase(std::remove_if(m_hl_faces.begin(), m_hl_faces.end(), stale), m_hl_faces.end()); } -// The identity of a body's current shape. Face ids and sampled edges hold while it does. -const void* DesignSketchTool::body_key(int body) const +const TopoDS_Shape* DesignSketchTool::body_shape(int body) const { if (m_solid_bodies == nullptr || body < 0 || body >= int(m_solid_bodies->size())) return nullptr; const TopoDS_Shape& shape = (*m_solid_bodies)[body].shape; - return shape.IsNull() ? nullptr : shape.TShape().get(); + return shape.IsNull() ? nullptr : &shape; +} + +// Whether `sampled` is still the body's shape. The caches hold the shape itself rather than its +// address, so the sampled TShape stays alive and no rebuilt body can be allocated in its place. +// IsSame also compares the Location, which the sampled lines are in. +bool DesignSketchTool::is_current_shape(int body, const TopoDS_Shape& sampled) const +{ + const TopoDS_Shape* shape = body_shape(body); + return shape != nullptr && sampled.IsSame(*shape); } // View-facing ribbons along a body's polylines (shape coordinates), `hw` either side of the line @@ -3604,6 +3628,7 @@ void DesignSketchTool::clear_solid_selection() { m_solid_sel = SolidSel::None; m_sel_body = m_sel_face = m_sel_edge = -1; + m_sel_shape = TopoDS_Shape(); m_sel_edge_pts.clear(); m_sel_edges_more.clear(); m_sel_edges_more_pts.clear(); @@ -3631,6 +3656,7 @@ void DesignSketchTool::select_body(int body) return; } m_sel_body = body; + m_sel_shape = (*m_solid_bodies)[body].shape; m_sel_face = m_sel_edge = -1; m_sel_edge_pts.clear(); m_sel_edges_more.clear(); @@ -3642,7 +3668,7 @@ void DesignSketchTool::set_highlight_faces(const std::vector { std::map> by_body; for (const auto& [b, f] : faces) - if (body_key(b) != nullptr) + if (body_shape(b) != nullptr) by_body[b].push_back(f); // Reuse each body's entry, so re-sending the same faces resamples nothing. std::vector next; @@ -3664,13 +3690,16 @@ DesignSketchTool::FaceHighlight DesignSketchTool::make_face_highlight(int body, std::sort(faces.begin(), faces.end()); faces.erase(std::unique(faces.begin(), faces.end()), faces.end()); h.faces = std::move(faces); - h.key = body_key(body); - if (h.key == nullptr || h.faces.empty()) + const TopoDS_Shape* shape = body_shape(body); + if (shape == nullptr) + return h; + h.shape = *shape; + if (h.faces.empty()) return h; // Sampled at the body edges' tolerance; a whole body reuses its body edges. - const TopoDS_Shape& shape = (*m_solid_bodies)[body].shape; - const std::vector all = GeometryEngine::faces_of(shape); - const bool cached = body < int(m_body_edges_key.size()) && m_body_edges_key[body] == h.key; + const std::vector all = GeometryEngine::faces_of(*shape); + const bool cached = body < int(m_body_edges_shape.size()) + && is_current_shape(body, m_body_edges_shape[body]); if (cached && h.faces.size() == all.size() && h.faces.front() == 0 && h.faces.back() == int(all.size()) - 1) { h.edges = m_body_edges[body]; return h; @@ -3682,7 +3711,7 @@ DesignSketchTool::FaceHighlight DesignSketchTool::make_face_highlight(int body, if (f >= 0 && f < int(all.size())) builder.Add(picked, all[f]); try { - h.edges = GeometryEngine::display_edges(picked, cached ? m_body_edges_tol[body] : display_edge_tol(shape)); + h.edges = GeometryEngine::display_edges(picked, cached ? m_body_edges_tol[body] : display_edge_tol(*shape)); } catch (const Standard_Failure&) { h.edges.clear(); // an edge that cannot be sampled costs the outline, never the fill } @@ -3692,12 +3721,13 @@ DesignSketchTool::FaceHighlight DesignSketchTool::make_face_highlight(int body, std::vector> DesignSketchTool::picked_faces() const { std::vector> out; - if (body_key(m_sel_body) == nullptr) + const TopoDS_Shape* shape = body_shape(m_sel_body); + if (shape == nullptr) return out; if (m_solid_sel == SolidSel::Face && m_sel_face >= 0) out.emplace_back(m_sel_body, m_sel_face); else if (m_solid_sel == SolidSel::Whole) - for (int f = 0, n = GeometryEngine::face_count((*m_solid_bodies)[m_sel_body].shape); f < n; ++f) + for (int f = 0, n = GeometryEngine::face_count(*shape); f < n; ++f) out.emplace_back(m_sel_body, f); return out; } @@ -3706,7 +3736,7 @@ std::vector> DesignSketchTool::selected_faces() const { std::vector> out = picked_faces(); for (const FaceHighlight& h : m_hl_faces) - if (h.key != nullptr && h.key == body_key(h.body)) + if (is_current_shape(h.body, h.shape)) for (int f : h.faces) out.emplace_back(h.body, f); std::sort(out.begin(), out.end()); @@ -3714,12 +3744,99 @@ std::vector> DesignSketchTool::selected_faces() const return out; } +BoundingBoxf3 DesignSketchTool::faces_box(std::vector> faces) const +{ + // From the pick mesh: it is in world coordinates already, moved bodies and all. + BoundingBoxf3 box; + if (faces.empty() || m_solid_mesh == nullptr || m_solid_tri_face == nullptr || m_solid_tri_body == nullptr) + return box; + std::sort(faces.begin(), faces.end()); + const indexed_triangle_set& its = m_solid_mesh->its; + const size_t n = std::min({ its.indices.size(), m_solid_tri_face->size(), m_solid_tri_body->size() }); + for (size_t t = 0; t < n; ++t) + if (std::binary_search(faces.begin(), faces.end(), std::make_pair((*m_solid_tri_body)[t], (*m_solid_tri_face)[t]))) + for (int i = 0; i < 3; ++i) + box.merge(its.vertices[its.indices[t][i]].cast()); + return box; +} + +BoundingBoxf3 DesignSketchTool::sketch_box(const std::vector& entities, const SketchPlane& plane) +{ + BoundingBoxf3 box; + for (const SketchEntity& e : entities) { + bool closed = false; + for (const Vec2d& p : entity_polyline(e, closed)) + box.merge(plane.to_world(p)); + } + return box; +} + +BoundingBoxf3 DesignSketchTool::selection_box() const +{ + // The selected faces, a whole body's included. + BoundingBoxf3 box = faces_box(selected_faces()); + if (m_solid_sel == SolidSel::Edge) { + for (const Vec3d& p : m_sel_edge_pts) + box.merge(p); + for (const std::vector& pts : m_sel_edges_more_pts) + for (const Vec3d& p : pts) + box.merge(p); + } else if (m_solid_sel == SolidSel::Vertex) + box.merge(m_sel_vertex_pt); + // A committed sketch's picked region with its holes; a stroke outside any region, the sketch. + if (m_display_pick >= 0) + for (const DisplaySketch& d : m_display_sketches) + if (d.feature == m_display_pick) + box.merge(sketch_box(m_display_pick_region >= 0 ? selected_loop_entities() : d.entities, d.plane)); + if (m_active) { + for (int i : m_selection) + if (i >= 0 && i < int(m_entities.size())) + box.merge(sketch_box({ m_entities[i] }, m_plane)); + for (const auto& [i, role] : m_point_sel) + if (i >= 0 && i < int(m_entities.size())) { + const SketchEntity& e = m_entities[i]; + box.merge(m_plane.to_world(role == SketchPointRole::Center ? e.center : + role == SketchPointRole::P1 ? e.p1 : e.p0)); + } + } + return box; +} + +BoundingBoxf3 DesignSketchTool::fit_box(const GLVolumeCollection& volumes) const +{ + BoundingBoxf3 box = selection_box(); + // Nothing selected: everything on show. A hidden body's volume is inactive. + if (!box.defined) { + for (const GLVolume* v : volumes.volumes) + if (v->is_active) + box.merge(v->transformed_bounding_box()); + for (const DisplaySketch& d : m_display_sketches) + box.merge(sketch_box(d.entities, d.plane)); + if (m_active) + box.merge(sketch_box(m_entities, m_plane)); + } + pad_box(box); + return box; +} + +void DesignSketchTool::pad_box(BoundingBoxf3& box) +{ + // A vertex has no size, nor has a sketch along its normal, and the camera cannot frame an + // extent it does not see. Every axis gets at least a small neighbourhood. + if (!box.defined) + return; + constexpr double min_extent = 10.; // mm + const Vec3d grow = (Vec3d::Constant(min_extent) - box.size()).cwiseMax(0.) * 0.5; + box.min -= grow; + box.max += grow; +} + const DesignSketchTool::FaceHighlight& DesignSketchTool::cached_face_highlight(FaceHighlight& cache, int body, std::vector faces) const { std::sort(faces.begin(), faces.end()); faces.erase(std::unique(faces.begin(), faces.end()), faces.end()); - if (cache.body != body || cache.faces != faces || cache.key != body_key(body)) + if (cache.body != body || cache.faces != faces || !is_current_shape(body, cache.shape)) cache = make_face_highlight(body, std::move(faces)); return cache; } @@ -3730,7 +3847,7 @@ const DesignSketchTool::FaceHighlight& DesignSketchTool::cached_face_highlight(F // version: the line alone, thinner and fainter. void DesignSketchTool::render_face_outline(const FaceHighlight& h, bool quiet) { - if (m_body_edges_hidden || h.key == nullptr || h.key != body_key(h.body) || !body_pickable(h.body)) + if (m_body_edges_hidden || !is_current_shape(h.body, h.shape) || !body_pickable(h.body)) return; // hidden, or the body was rebuilt and these face ids are stale const Camera& cam = wxGetApp().plater()->get_camera(); @@ -4007,6 +4124,7 @@ bool DesignSketchTool::handle_solid_click(GLCanvas3D& canvas, const wxMouseEvent m_sel_edges_more_pts.clear(); m_sel_body = p.body; + m_sel_shape = (*m_solid_bodies)[p.body].shape; // resolve_solid_pick checked p.body m_sel_face = p.face; m_sel_edge = p.edge; m_sel_edge_pts = std::move(p.edge_pts); @@ -5284,7 +5402,148 @@ void DesignSketchTool::drag_rib_handle(GLCanvas3D& canvas, const wxMouseEvent& e if (on_rib_thickness_changed) on_rib_thickness_changed(m_rb_thickness); } -// ---- Reference/base planes (Onshape-style default planes) ----------------------------- +// ---- Reference/base planes ------------------------------------------------------------- +namespace { +// `text` in the stroke font, `height` tall and centred on the origin; `size` is its extent. With the +// font, further down. +std::vector> text_strokes(const std::string& text, double height, Vec2d& size); + +// One level of a BSP tree whose splitters are the planes themselves, in order: the painter's +// algorithm made exact for polygons that cross. Pieces of plane k (and of any plane lying in it) +// are in the splitter; every other piece is wholly on one side or is cut in two. Far side, then the +// splitter's own pieces, then the near side is back to front. A piece is only cut when it has +// corners strictly on both sides, so neither half can be degenerate. +void paint_back_to_front(std::vector&& in, size_t k, const std::vector& planes, double eps, + const Vec3d& eye, const Vec3d& forward, bool perspective, std::vector& out) +{ + if (in.empty()) + return; + if (k == planes.size()) { // not reached: every piece is in the splitter at its own plane's level + std::move(in.begin(), in.end(), std::back_inserter(out)); + return; + } + // The square's own normal: SketchPlane::normal is reversed on XZ, and the side tests only need + // one consistent choice. + const Vec3d n = planes[k].x_axis.cross(planes[k].y_axis).normalized(); + const double d = n.dot(planes[k].origin); + std::vector front, back, on; + for (PlanePiece& piece : in) { + if (piece.plane == int(k)) { + on.push_back(std::move(piece)); + continue; + } + std::vector s; + int sides = 0; + for (const Vec3d& c : piece.corners) { + s.push_back(n.dot(c) - d); + sides |= s.back() > eps ? 1 : s.back() < -eps ? 2 : 0; + } + if (sides == 0) + on.push_back(std::move(piece)); + else if (sides == 1) + front.push_back(std::move(piece)); + else if (sides == 2) + back.push_back(std::move(piece)); + else { + PlanePiece f{ piece.plane, {} }, b{ piece.plane, {} }; + const size_t m = piece.corners.size(); + for (size_t i = 0; i < m; ++i) { + const size_t j = (i + 1) % m; + const Vec3d& a = piece.corners[i]; + if (s[i] >= -eps) f.corners.push_back(a); + if (s[i] <= eps) b.corners.push_back(a); + if ((s[i] > eps && s[j] < -eps) || (s[i] < -eps && s[j] > eps)) { + const Vec3d x = a + (piece.corners[j] - a) * (s[i] / (s[i] - s[j])); + f.corners.push_back(x); + b.corners.push_back(x); + } + } + front.push_back(std::move(f)); + back.push_back(std::move(b)); + } + } + // An orthographic eye is at infinity behind the view direction. Camera::get_position() is a + // finite point there and can sit on the wrong side of a plane, so only the direction counts. + const bool eye_in_front = perspective ? n.dot(eye) - d > 0. : n.dot(forward) < 0.; + paint_back_to_front(std::move(eye_in_front ? back : front), k + 1, planes, eps, eye, forward, perspective, out); + std::move(on.begin(), on.end(), std::back_inserter(out)); + paint_back_to_front(std::move(eye_in_front ? front : back), k + 1, planes, eps, eye, forward, perspective, out); +} + +// A base square's name: its cap height, and where the text is centred in the square's frame — inset +// from the frame's (+x, +y) corner, which lies by an axis toward its far end or at the outer corner, +// away from the other two names. +double label_height(double half) { return 0.30 * half; } +Vec2d label_centre(double half, const Vec2d& size) { return Vec2d::Constant(0.88 * half) - 0.5 * size; } + +// A datum's name is not in the stroke font: it is an ImGui chip anchored at this point of its square. +Vec2d datum_label_anchor(double half) { return Vec2d(-0.8 * half, 0.84 * half); } + +bool is_base_plane(int base) { return base >= 0 && base < 3; } + +// The octant the base squares sit in. Each name is written facing x_axis x y_axis, which is +Z for +// XY, -Y for XZ and +X for YZ: all three point into it, so a camera there looks into the corner the +// squares form and reads every name, and from the front XY lies below the X axis rather than behind XZ. +Vec3d reference_octant() { return Vec3d(1., -1., 1.); } +} // namespace + +std::vector planes_back_to_front(const std::vector& planes, double half, + const Vec3d& eye, const Vec3d& forward, bool perspective) +{ + std::vector squares; + for (int i = 0; i < int(planes.size()); ++i) { + const SketchPlane& p = planes[i]; + squares.push_back({ i, { p.to_world(Vec2d(-half, -half)), p.to_world(Vec2d(half, -half)), + p.to_world(Vec2d(half, half)), p.to_world(Vec2d(-half, half)) } }); + } + std::vector out; + paint_back_to_front(std::move(squares), 0, planes, 1e-6 * std::max(half, 1.), eye, forward, perspective, out); + return out; +} + +SketchPlane reference_square(const SketchPlane& plane, int base, double half) +{ + SketchPlane square = plane; + if (is_base_plane(base)) { + const Vec3d o = reference_octant(); + square.origin += (reference_square_gap(half) + half) * (plane.x_axis.dot(o) * plane.x_axis + plane.y_axis.dot(o) * plane.y_axis); + } + return square; +} + +std::array reference_label_box(const SketchPlane& square, double half, const std::string& text) +{ + const double th = label_height(half); + Vec2d size; + text_strokes(text, th, size); + const Vec2d c = label_centre(half, size); + const Vec2d r = 0.5 * size + Vec2d::Constant(0.15 * th); // a margin round the strokes, to aim at + return { square.to_world(c + Vec2d(-r.x(), -r.y())), square.to_world(c + Vec2d(r.x(), -r.y())), + square.to_world(c + Vec2d(r.x(), r.y())), square.to_world(c + Vec2d(-r.x(), r.y())) }; +} + +int pick_reference_square(const std::vector& squares, double half, const Vec3d& from, const Vec3d& dir) +{ + int best = -1; + double best_t = std::numeric_limits::max(); + for (int i = 0; i < int(squares.size()); ++i) { + const SketchPlane& s = squares[i]; + const Vec3d n = s.x_axis.cross(s.y_axis); + const double dn = n.dot(dir); + if (std::abs(dn) < 1e-12) + continue; // the ray runs along the square + const double t = n.dot(s.origin - from) / dn; + if (t <= 0. || t >= best_t) + continue; // behind the eye, or no nearer than one already hit + const Vec3d d = from + dir * t - s.origin; + if (std::abs(d.dot(s.x_axis)) <= half && std::abs(d.dot(s.y_axis)) <= half) { + best = i; + best_t = t; + } + } + return best; +} + void DesignSketchTool::set_base_pick(std::vector planes, std::vector bases, std::vector labels) { @@ -5304,117 +5563,237 @@ void DesignSketchTool::clear_base_pick() m_dbp_hover = -1; } -// Reference planes sit INSIDE the bed. They used to be 0.6 * the bed's larger side, i.e. a square -// 1.2x the plate, and three of them are drawn with depth testing off — so they painted over the -// plate grid from edge to edge and the bed simply was not readable any more. "The planes hide the -// bed", reported exactly that way. Small enough to leave the grid legible around them is also the -// Onshape look this was reaching for: a modest square at the origin, not a tablecloth. +// Sized from the bed, so the squares keep their size against the plate on screen: each is 0.16 of +// the bed's larger side, about the share of the view Fusion's origin planes take at the default zoom. +// Larger squares were reported as hiding the bed. double DesignSketchTool::dbp_half_extent() const { - double half = 75.0; + double half = 20.0; if (auto* pl = wxGetApp().plater()) { const BoundingBoxf bb = pl->build_volume().bounding_volume2d(); const double w = bb.max.x() - bb.min.x(), d = bb.max.y() - bb.min.y(); - if (w > 1.0 && d > 1.0) half = 0.3 * std::max(w, d); + if (w > 1.0 && d > 1.0) half = 0.08 * std::max(w, d); } return half; } -// Draw the reference planes as large translucent labelled squares; the hovered one brightens. +std::vector DesignSketchTool::dbp_squares(double half) const +{ + std::vector squares; + for (size_t i = 0; i < m_dbp_planes.size(); ++i) + squares.push_back(reference_square(m_dbp_planes[i], i < m_dbp_base.size() ? m_dbp_base[i] : -1, half)); + return squares; +} + +// Dash-dot half-axes from the origin into the squares' octant, out past them along the gaps between +// them, and a disc on the origin. Both face the camera at a constant width on screen. +void DesignSketchTool::render_reference_axes(const Vec3d& origin, double half) +{ + using EPT = GLModel::Geometry::EPrimitiveType; + using EVL = GLModel::Geometry::EVertexLayout; + const Camera& cam = wxGetApp().plater()->get_camera(); + const Vec3d vd = cam.get_dir_forward(); + const double upp = 1.0 / std::max(cam.get_zoom(), 1e-6); // world units per screen pixel + const double len = 1.2 * (reference_square_gap(half) + 2.0 * half); + // A dash, then a dot, every kPeriod of the axis. + constexpr double kDash = 0.07, kDotFrom = 0.10, kDotTo = 0.115, kPeriod = 0.145; + const ColorRGBA colours[3] = { ColorRGBA(0.92f, 0.28f, 0.28f, 1.0f), ColorRGBA(0.30f, 0.80f, 0.34f, 1.0f), + ColorRGBA(0.32f, 0.55f, 0.95f, 1.0f) }; + for (int k = 0; k < 3; ++k) { + const Vec3d axis = Vec3d::Unit(k) * (len * reference_octant()[k]); + std::vector> strokes; + for (double t = 0.; t < 1.; t += kPeriod) { + strokes.push_back({ origin + axis * t, origin + axis * std::min(t + kDash, 1.) }); + if (t + kDotFrom < 1.) + strokes.push_back({ origin + axis * (t + kDotFrom), origin + axis * std::min(t + kDotTo, 1.) }); + } + GLModel::Geometry g; + append_ribbons(g, -1, strokes, vd, Vec3d::Zero(), 1.5 * upp); // body -1: already world coordinates + if (g.is_empty()) + continue; // seen end-on + GLModel m; + m.init_from(std::move(g)); + m.set_color(colours[k]); + m.render(); + } + + const Vec3d right = cam.get_dir_right(), up = cam.get_dir_up(); + auto disc = [&](double radius_px, const ColorRGBA& colour) { + constexpr int kSides = 24; + GLModel::Geometry g; + g.format = { EPT::Triangles, EVL::P3 }; + g.add_vertex((Vec3f) origin.cast()); + for (int i = 0; i < kSides; ++i) { + const double a = 2. * M_PI * i / kSides; + g.add_vertex((Vec3f) (origin + (right * std::cos(a) + up * std::sin(a)) * (radius_px * upp)).cast()); + } + for (int i = 0; i < kSides; ++i) + g.add_triangle(0, 1 + i, 1 + (i + 1) % kSides); + GLModel m; + m.init_from(std::move(g)); + m.set_color(colour); + m.render(); + }; + disc(6.0, ColorRGBA(0.30f, 0.30f, 0.32f, 1.0f)); // a dark ring round + disc(4.5, ColorRGBA(0.92f, 0.92f, 0.92f, 1.0f)); // a light centre +} + +// The reference planes, as in Fusion: translucent squares with the hovered one grey and the selected +// one solid, each base plane's name written in it, and the half-axes and origin under them. Depth +// testing is off (they overlay the bed and any bodies), so draw order is the blend order: back to +// front, piece by piece. void DesignSketchTool::render_base_pick() { if (!m_dbp_active || m_dbp_planes.empty()) return; using EPT = GLModel::Geometry::EPrimitiveType; using EVL = GLModel::Geometry::EVertexLayout; - const double H = dbp_half_extent(); - // Onshape-ish per-plane tints: XY blue, XZ green, YZ red (keyed by base index 0/1/2; datums grey). - auto tint = [](int base, bool hot) -> ColorRGBA { - float a = hot ? 0.10f : 0.047f; // base planes kept faint (reduced ~2/3 from 0.30/0.14) - if (base == 0) return ColorRGBA(0.30f, 0.55f, 0.95f, a); - if (base == 1) return ColorRGBA(0.35f, 0.80f, 0.45f, a); - if (base == 2) return ColorRGBA(0.92f, 0.42f, 0.42f, a); - return ColorRGBA(0.70f, 0.72f, 0.78f, a); + const double H = dbp_half_extent(); + const std::vector squares = dbp_squares(H); + const int selected = selected_base ? selected_base() : -1; + auto base_of = [this](size_t i) { return i < m_dbp_base.size() ? m_dbp_base[i] : -1; }; + enum class Look { Idle, Hover, Selected }; + auto look = [&](size_t i) { + return base_of(i) >= 0 && base_of(i) == selected ? Look::Selected : int(i) == m_dbp_hover ? Look::Hover : Look::Idle; }; + // Each base plane in the colour of the axis it is normal to: XY blue, XZ green, YZ red. A datum the + // Plane card offers as a base keeps a slate of its own, clear of the grey hover. + auto hue = [&](size_t i) { + switch (base_of(i)) { + case 0: return ColorRGBA(0.30f, 0.55f, 0.95f, 1.0f); + case 1: return ColorRGBA(0.35f, 0.80f, 0.45f, 1.0f); + case 2: return ColorRGBA(0.92f, 0.42f, 0.42f, 1.0f); + default: return ColorRGBA(0.55f, 0.60f, 0.72f, 1.0f); + } + }; + // Where squares overlap on screen, two layers of alpha a blended in either order differ by only + // a^2 of their colour difference, so much fainter fills would hide which one is in front however + // well the pieces are sorted. The selected plane goes near solid in its own colour: a fixed + // selection colour would match one of the three. + auto fill = [&](size_t i) { + ColorRGBA c = look(i) == Look::Hover ? ColorRGBA(0.80f, 0.80f, 0.80f, 1.0f) : hue(i); + c.a(look(i) == Look::Selected ? 0.85f : look(i) == Look::Hover ? 0.45f : 0.50f); + return c; + }; + auto ink = [&](size_t i) { + switch (look(i)) { + case Look::Selected: return ColorRGBA(0.20f, 0.25f, 0.35f, 1.0f); + case Look::Hover: return ColorRGBA(0.62f, 0.62f, 0.62f, 1.0f); + default: return hue(i); + } + }; + const Camera& cam = wxGetApp().plater()->get_camera(); glsafe(::glDisable(GL_DEPTH_TEST)); glsafe(::glDisable(GL_CULL_FACE)); - glsafe(::glEnable(GL_BLEND)); // alpha is ignored without this - glsafe(::glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)); - const SketchPlane saved_plane = m_plane; - for (size_t i = 0; i < m_dbp_planes.size(); ++i) { - const SketchPlane& p = m_dbp_planes[i]; - const Vec3d q0 = p.to_world(Vec2d(-H, -H)), q1 = p.to_world(Vec2d(H, -H)), - q2 = p.to_world(Vec2d(H, H)), q3 = p.to_world(Vec2d(-H, H)); - GLModel::Geometry quad; quad.format = { EPT::Triangles, EVL::P3 }; - quad.add_vertex((Vec3f)q0.cast()); quad.add_vertex((Vec3f)q1.cast()); - quad.add_vertex((Vec3f)q2.cast()); quad.add_vertex((Vec3f)q3.cast()); - quad.add_triangle(0, 1, 2); quad.add_triangle(0, 2, 3); - GLModel m; m.init_from(std::move(quad)); - const bool hot = (int(i) == m_dbp_hover); - const int base = (i < m_dbp_base.size()) ? m_dbp_base[i] : -1; - m.set_color(tint(base, hot)); + glsafe(::glEnable(GL_BLEND)); // alpha is ignored without this; destination alpha stays 1 + glsafe(::glBlendFuncSeparate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ONE, GL_ONE_MINUS_SRC_ALPHA)); + + for (size_t i = 0; i < m_dbp_planes.size(); ++i) + if (is_base_plane(base_of(i))) { // the base planes all pass through the modeling origin + render_reference_axes(m_dbp_planes[i].origin, H); + break; + } + + const std::vector pieces = planes_back_to_front(squares, H, cam.get_position(), cam.get_dir_forward(), + cam.get_type() == Camera::EType::Perspective); + const SketchPlane saved_plane = m_plane; + for (size_t k = 0; k < pieces.size(); ++k) { + const PlanePiece& piece = pieces[k]; + const size_t i = size_t(piece.plane); + GLModel::Geometry g; + g.format = { EPT::Triangles, EVL::P3 }; + for (const Vec3d& q : piece.corners) g.add_vertex((Vec3f)q.cast()); + for (unsigned int c = 1; c + 1 < piece.corners.size(); ++c) g.add_triangle(0, c, c + 1); // convex: a fan + GLModel m; + m.init_from(std::move(g)); + m.set_color(fill(i)); m.render(); - // Label near the top-left corner, drawn in the plane (draw_text lifts through m_plane). - if (i < m_dbp_labels.size() && !m_dbp_labels[i].empty()) { - m_plane = p; - const double th = H * 0.10; - const ColorRGBA lc = tint(base, true); ColorRGBA lcs(lc.r(), lc.g(), lc.b(), 1.0f); - draw_text(m_line_model, m_dbp_labels[i], Vec2d(-H + th * 2.0, H - th * 1.6), th, lcs); + // A base plane's name goes down with its last piece, so a square in front tints it as it + // tints the fill. + if (!is_base_plane(base_of(i)) || i >= m_dbp_labels.size() || m_dbp_labels[i].empty() + || std::any_of(pieces.begin() + k + 1, pieces.end(), [&](const PlanePiece& p) { return p.plane == piece.plane; })) + continue; + const double th = label_height(H); + Vec2d size; + std::vector> strokes = text_strokes(m_dbp_labels[i], th, size); + const Vec2d c = label_centre(H, size); + for (auto& [a, b] : strokes) { + a += c; + b += c; } + m_plane = squares[i]; // draw_strokes lifts plane coordinates through m_plane + draw_strokes(m_line_model, strokes, 0.07 * th, ink(i)); } - m_plane = saved_plane; // draw_text renders each label immediately (draw_strokes self-renders) + for (size_t i = 0; i < m_dbp_planes.size() && i < m_dbp_labels.size(); ++i) + if (!is_base_plane(base_of(i)) && !m_dbp_labels[i].empty()) { + m_plane = squares[i]; // draw_text anchors through m_plane too + draw_text(m_line_model, m_dbp_labels[i], datum_label_anchor(H), H * 0.10, ink(i)); + } + m_plane = saved_plane; glsafe(::glDisable(GL_BLEND)); } -// Ray-pick the reference planes: intersect the mouse ray with each plane, keep hits inside the -// square, return the index of the nearest by |t|. -1 on miss. +// The reference plane under the cursor, or -1. A plane's name wins over the squares: it is the part +// of a plane a user aims at deliberately, and from the back the XZ square stands between the eye and +// the XY name, so a ray pick alone reports "XZ plane selected" for a click on the word XY. int DesignSketchTool::hit_test_base_pick(GLCanvas3D& canvas, const wxMouseEvent& evt) const { if (!m_dbp_active) return -1; - const double H = dbp_half_extent(); - - // THE LABEL WINS, and it has to. Each plane's name is a screen-space chip centred on its - // own in-plane anchor, and it is the one part of a base plane a user aims at deliberately — - // the quads are near-transparent and overlap everywhere. Ray-casting the quads alone made - // the labels pure decoration: on a fresh document at 1920x1060, clicking "XY" reported - // "XZ plane selected", because the XZ quad happens to sit in front at that pixel. Nothing - // about the click was ambiguous to the user; they clicked the word XY. - // Anchor and text height must track render_base_pick's, which is where they are drawn. - const Camera& cam = wxGetApp().plater()->get_camera(); - const double th = H * 0.10; - const Vec2d anchor(-H + th * 2.0, H - th * 1.6); - int lbest = -1; double lbest_d = 1e30; - for (size_t i = 0; i < m_dbp_planes.size(); ++i) { - if (i >= m_dbp_labels.size() || m_dbp_labels[i].empty()) continue; - const wxPoint sp = world_to_screen_px(cam, m_dbp_planes[i].to_world(anchor)); - if (sp.x < 0 && sp.y < 0) continue; // behind the camera - const double dx = std::abs(double(evt.GetX() - sp.x)); - const double dy = std::abs(double(evt.GetY() - sp.y)); - // Chip half-extents in px, scaled like the label itself. Generous rather than tight: - // missing the text and silently selecting a different plane is the failure being fixed. - const double hw = (9.0 + 5.0 * double(m_dbp_labels[i].size())) * double(m_render_scale); - const double hh = 11.0 * double(m_render_scale); - if (dx > hw || dy > hh) continue; - const double d = dx * dx + dy * dy; // nearest label if chips overlap + const double H = dbp_half_extent(); + const std::vector squares = dbp_squares(H); + const Camera& cam = wxGetApp().plater()->get_camera(); + // As world_to_screen_px, but refusing a point behind the eye, which would come back mirrored. + const Eigen::Matrix4d to_clip = cam.get_projection_matrix().matrix() * cam.get_view_matrix().matrix(); + const std::array& vp = cam.get_viewport(); + auto to_screen = [&](const Vec3d& x, Vec2d& out) { + const Eigen::Vector4d clip = to_clip * x.homogeneous(); + if (clip.w() <= 1e-9) + return false; + const Vec3d ndc = clip.head<3>() / clip.w(); + out = Vec2d(vp[0] + (ndc.x() * 0.5 + 0.5) * vp[2], vp[1] + (1.0 - (ndc.y() * 0.5 + 0.5)) * vp[3]); + return true; + }; + const Vec2d mouse(evt.GetX(), evt.GetY()); + int lbest = -1; + double lbest_d = std::numeric_limits::max(); + for (size_t i = 0; i < squares.size() && i < m_dbp_labels.size(); ++i) { + if (m_dbp_labels[i].empty()) continue; + Vec2d centre; + if (is_base_plane(i < m_dbp_base.size() ? m_dbp_base[i] : -1)) { + // The name lies in its square, so its box is hit as projected, turning with the plane. + const std::array box = reference_label_box(squares[i], H, m_dbp_labels[i]); + std::array q; + bool seen = true; + for (size_t c = 0; c < 4 && seen; ++c) seen = to_screen(box[c], q[c]); + if (!seen) continue; + double area = 0.; + bool left = true, right = true; // the cursor on one side of every edge: inside + for (size_t c = 0; c < 4; ++c) { + const Vec2d& a = q[c]; + const Vec2d& b = q[(c + 1) % 4]; + const double s = (b.x() - a.x()) * (mouse.y() - a.y()) - (b.y() - a.y()) * (mouse.x() - a.x()); + left = left && s >= 0.; + right = right && s <= 0.; + area += a.x() * b.y() - b.x() * a.y(); + } + if (std::abs(area) < 8. || !(left || right)) + continue; // edge-on, or outside + centre = 0.25 * (q[0] + q[1] + q[2] + q[3]); + } else { + // A datum's name is a screen chip. Generous rather than tight: missing the text and + // silently selecting a different plane is the failure this guards against. + if (!to_screen(squares[i].to_world(datum_label_anchor(H)), centre)) continue; + const double hw = (9.0 + 5.0 * double(m_dbp_labels[i].size())) * double(m_render_scale); + const double hh = 11.0 * double(m_render_scale); + if (std::abs(mouse.x() - centre.x()) > hw || std::abs(mouse.y() - centre.y()) > hh) continue; + } + const double d = (mouse - centre).squaredNorm(); // the nearest name if several hold the cursor if (d < lbest_d) { lbest_d = d; lbest = int(i); } } if (lbest >= 0) return lbest; const Linef3 r = canvas.mouse_ray(Point(evt.GetX(), evt.GetY())); - const Vec3d ro = r.a, rd = r.b - r.a; - int best = -1; double best_t = 1e30; - for (size_t i = 0; i < m_dbp_planes.size(); ++i) { - const SketchPlane& p = m_dbp_planes[i]; - const double dn = rd.dot(p.normal); - if (std::abs(dn) < 1e-9) continue; // ray parallel to plane - const double t = (p.origin - ro).dot(p.normal) / dn; - if (t < 0) continue; // behind the camera - const Vec3d hit = ro + rd * t; - const Vec3d d = hit - p.origin; - if (std::abs(d.dot(p.x_axis)) > H || std::abs(d.dot(p.y_axis)) > H) continue; - if (t < best_t) { best_t = t; best = int(i); } - } - return best; + return pick_reference_square(squares, H, r.a, r.b - r.a); } // ---- Move-body gizmo (M5) ------------------------------------------------------------- @@ -5429,6 +5808,10 @@ void DesignSketchTool::set_move_gizmo(int body, const Vec3d& pivot, const Transf m_mv_rot = Eigen::Matrix3d::Identity(); m_mv_drag = -1; m_mv_radius = std::max(body_radius, 0.0); + // The selection is held while the gizmo is up (see on_mouse_impl): no press half-way to a + // pick, and no hover outline promising a click that will not be taken. + m_pick_pending = false; + m_pre = SolidPick{}; } // Gizmo arm length in world mm. Orca's Prepare gizmos size themselves from the selection's @@ -5558,19 +5941,22 @@ bool DesignSketchTool::hit_test_move_arrow(GLCanvas3D& canvas, const wxMouseEven axis = best; return true; } -// Skew-line closest point of the mouse ray to the axis line through the ORIGINAL centroid -// -> signed offset along that axis (no clamp; a body can move either way). -void DesignSketchTool::drag_move_arrow(GLCanvas3D& canvas, const wxMouseEvent& evt, int axis) +// Record how far along the arrow it was grabbed (NaN while the camera looks down the axis; the +// first drag sample that projects stands in), so the body's centre does not snap to the grab. +void DesignSketchTool::grab_move_arrow(int axis, const Linef3& ray) { - const Linef3 r = canvas.mouse_ray(Point(evt.GetX(), evt.GetY())); - const Vec3d ro = r.a, rd = r.b - r.a; - const Vec3d axes[3] = { Vec3d::UnitX(), Vec3d::UnitY(), Vec3d::UnitZ() }; - const Vec3d e = axes[axis]; - const Vec3d w0 = m_mv_base - ro; - const double a = e.dot(e), b = e.dot(rd), c = rd.dot(rd), dd = e.dot(w0), ee = rd.dot(w0); - const double denom = a * c - b * b; - if (std::abs(denom) < 1e-7) return; // camera ∥ axis: leave offset as-is - m_mv_offset[axis] = (b * ee - c * dd) / denom; + m_mv_drag = axis; + m_mv_grab_along = ray_axis_proj(ray, m_mv_base + m_mv_offset, Vec3d::Unit(axis)); +} + +// Slide the body along the grabbed axis by the cursor's travel (no clamp; it can move either way). +void DesignSketchTool::drag_move_arrow(const Linef3& ray) +{ + if (m_mv_drag < 0 || m_mv_drag > 2) return; + const double proj = ray_axis_proj(ray, m_mv_base + m_mv_offset, Vec3d::Unit(m_mv_drag)); + if (std::isnan(proj)) return; // camera ∥ axis: leave offset as-is + if (std::isnan(m_mv_grab_along)) { m_mv_grab_along = proj; return; } + m_mv_offset[m_mv_drag] += proj - m_mv_grab_along; if (on_body_move_changed) on_body_move_changed(m_mv_body, compose_move_xform()); } @@ -5940,21 +6326,10 @@ int DesignSketchTool::hit_test_hole_handle(GLCanvas3D& canvas, const wxMouseEven return best; } -// Skew-line closest point of the mouse ray to an axis (anchor + t*dir) -> signed distance along -// dir. NaN when the camera is ~parallel to the axis (no meaningful projection). double DesignSketchTool::hole_axis_proj(GLCanvas3D& canvas, const wxMouseEvent& evt, const Vec3d& anchor, const Vec3d& dir) const { - const Linef3 r = canvas.mouse_ray(Point(evt.GetX(), evt.GetY())); - const Vec3d ro = r.a, rd = r.b - r.a; - const Vec3d e = dir; - const Vec3d w0 = anchor - ro; - const double a = e.dot(e), b = e.dot(rd), c = rd.dot(rd), dd = e.dot(w0), ee = rd.dot(w0); - const double denom = a * c - b * b; - // Relative near-parallel guard: when the camera ray is ~along the axis (e.g. the depth axis in - // top view) denom collapses; a tiny absolute floor lets a huge, unstable projection through. - if (std::abs(denom) < 1e-4 * std::max(a * c, 1e-12)) return std::nan(""); - return (b * ee - c * dd) / denom; + return ray_axis_proj(canvas.mouse_ray(Point(evt.GetX(), evt.GetY())), anchor, dir); } void DesignSketchTool::start_hole_drag(GLCanvas3D& canvas, const wxMouseEvent& evt, int which) @@ -7361,9 +7736,9 @@ void DesignSketchTool::draw_strokes(GLModel& model, const std::vector>& out, double& ad break; } } + +std::vector> text_strokes(const std::string& text, double height, Vec2d& size) +{ + std::vector> out; + double pen = 0.; + for (char c : text) { + std::vector> glyph; + double advance = 0.; + glyph_strokes(c, glyph, advance); + for (const auto& [a, b] : glyph) + out.emplace_back((a + Vec2d(pen, 0.)) * height, (b + Vec2d(pen, 0.)) * height); + pen += advance; + } + BoundingBoxf box; + for (const auto& [a, b] : out) { + box.merge(a); + box.merge(b); + } + if (!box.defined) { + size = Vec2d::Zero(); + return out; + } + const Vec2d mid = box.center(); + for (auto& [a, b] : out) { + a -= mid; + b -= mid; + } + size = box.size(); + return out; +} } // namespace void DesignSketchTool::draw_dim_label(const std::string& txt, const Vec2d& plane_center) @@ -7533,8 +7938,9 @@ void DesignSketchTool::draw_text(GLModel& /*model*/, const std::string& s, const double /*height*/, const ColorRGBA& /*color*/) { // ponytail: all sketch labels now render as Measure-gizmo-style ImGui labels for visual - // parity with the Prepare/Preview tabs; the old vector-font path (glyph_strokes/draw_strokes - // for text) is retired. Leader lines/arrows still draw via draw_strokes at the call sites. + // parity with the Prepare/Preview tabs; the vector font (glyph_strokes/draw_strokes) is kept + // only for text lying in a plane, the reference planes' names. Leader lines/arrows still draw + // via draw_strokes at the call sites. // // The one label we do NOT draw is the one under an OPEN value field: the field is anchored // over it and carries the same number plus its title, so leaving the label in place shows @@ -9097,7 +9503,10 @@ const ColorRGBA* DesignSketchTool::sketch_hl_color(int feature) const // forgotten by the next one. Cheap: three ints compared per frame. void DesignSketchTool::emit_step_hint() { - if (!on_step_changed) return; + // No session, no step. render() also runs while only the reference planes are up, and m_mode + // then holds the last session's tool (Polyline before the first), whose prompt used to replace + // the panel's own line — "click a reference plane" while Sketch waits for one. + if (!on_step_changed || !m_active) return; int step = 0, picks = 0; if (is_edit_op_mode()) { picks = (m_mode == Mode::Mirror) ? int(m_mirror_targets.size()) @@ -9271,7 +9680,7 @@ void DesignSketchTool::render(GLCanvas3D& canvas) render_datum_planes(); render_mate_connectors(); render_solid_highlight(); - if (m_dbp_active) render_base_pick(); + if (draws_reference_axes()) render_base_pick(); // the canvas drops the bed's triad on the same test if (m_dz_active) render_datum_gizmo(); if (m_hx_active) render_helix_gizmo(); if (m_rb_active) render_rib_gizmo(); @@ -9918,6 +10327,21 @@ static double ray_segment_dist3(const Vec3d& ro, const Vec3d& rd, const Vec3d& a return (pr - ps).norm(); } +// Skew-line closest point of the mouse ray to an axis (anchor + t*dir) -> signed distance along +// dir. NaN when the camera is ~parallel to the axis (no meaningful projection). +static double ray_axis_proj(const Linef3& ray, const Vec3d& anchor, const Vec3d& dir) +{ + const Vec3d ro = ray.a, rd = ray.vector(); + const Vec3d e = dir; + const Vec3d w0 = anchor - ro; + const double a = e.dot(e), b = e.dot(rd), c = rd.dot(rd), dd = e.dot(w0), ee = rd.dot(w0); + const double denom = a * c - b * b; + // Relative near-parallel guard: when the camera ray is ~along the axis (e.g. the depth axis in + // top view) denom collapses; a tiny absolute floor lets a huge, unstable projection through. + if (std::abs(denom) < 1e-4 * std::max(a * c, 1e-12)) return std::nan(""); + return (b * ee - c * dd) / denom; +} + // Screen-plane distance from p to a sketch entity, for click picking in Constrain // mode. Circles/arcs measure distance to the ring; points to their position. static double entity_pick_dist(const Vec2d& p, const SketchEntity& e) @@ -10316,14 +10740,14 @@ std::vector DesignSketchTool::connected_loop(int seed) const // the offer was excluded in sketch mode wholesale so a right-click could end a polyline chain, // abandon an anchor or exit a tool. That made every sketch row in the atlas unreachable. // The honest test is not "which mode are we in" but "did the tool actually USE this right-click", -// and only the tool knows. Wrapping on_mouse records that once, for every terminator, instead of -// threading a flag through the twenty-odd sites that consume a RightDown. +// and only the tool knows: take_right_click returns it for every terminator, from the +// twenty-odd sites that consume a RightDown. // Right-click abandons the anchor a draw tool has down. With NOTHING down there is nothing to // abandon — and consuming the click anyway made the offer unreachable from every armed draw tool: -// on_mouse records the consumption in m_right_consumed and DesignCanvas's RIGHT_UP handler -// suppresses the menu whenever it is set, so right-click became a no-op that also hid the one door -// to half the vocabulary (47 of 86 verbs have no shortcut). Measured on the rig: with Line armed, -// two right-clicks in a row produced no menu and no tool change; only Escape freed it. +// DesignCanvas's RIGHT_UP handler suppresses the menu whenever the tool used the click, so +// right-click became a no-op that also hid the one door to half the vocabulary (47 of 86 verbs +// have no shortcut). Measured on the rig: with Line armed, two right-clicks in a row produced no +// menu and no tool change; only Escape freed it. // Same rule as xmh6, which said it for the selection: clearing nothing is not a gesture // terminator. ghcz. bool DesignSketchTool::right_abandon() @@ -10335,12 +10759,24 @@ bool DesignSketchTool::right_abandon() return true; } +// The camera follows Preferences > Control here as in Prepare, so no tool may take a gesture the +// camera owns. A right press may start whatever drag action the right button is given, and +// whether it did is known only at the release: ending a chain on the press made every pan or +// orbit started there end the chain too. The press goes to the camera and is kept; +// DesignCanvas's RIGHT_UP handler replays it through take_right_click when it was a click. bool DesignSketchTool::on_mouse(wxMouseEvent& evt, GLCanvas3D& canvas) { - const bool consumed = on_mouse_impl(evt, canvas); - if (evt.RightDown()) - m_right_consumed = consumed; - return consumed; + if (evt.RightDown()) { + m_right_press = evt; + return false; + } + return on_mouse_impl(evt, canvas); +} + +bool DesignSketchTool::take_right_click(GLCanvas3D& canvas) +{ + auto press = std::exchange(m_right_press, {}); + return press && on_mouse_impl(*press, canvas); } bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) @@ -10405,16 +10841,12 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) canvas.zoom_to_volumes(); return true; } - // Visual Extrude gizmo (C5b): while the Extrude card is open the depth arrow is - // grabbable — drag changes the depth live; a click (no drag) on the arrow opens the - // inline depth editor. Intercept before the early no-LeftDown bailout so Dragging/ - // LeftUp reach us; a LeftDown that misses the arrow falls through to solid/loop pick. // Move-body gizmo (M5): three world-axis arrows on the selected body. Drag an arrow to // translate live; a stationary click on it opens the inline offset editor; a right click - // exits move mode. A LeftDown that misses the arrows falls through to solid re-pick. + // opens the offer. Anything else is the camera's: see the end of this block. if (m_mv_active) { if (m_mv_drag >= 0 && evt.Dragging() && evt.LeftIsDown()) { - if (m_mv_drag < 3) drag_move_arrow(canvas, evt, m_mv_drag); + if (m_mv_drag < 3) drag_move_arrow(canvas.mouse_ray(Point(evt.GetX(), evt.GetY()))); else drag_move_arc(canvas, evt, m_mv_drag - 3); return true; } @@ -10432,7 +10864,8 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) if (evt.LeftDown()) { int axis = -1; if (hit_test_move_arrow(canvas, evt, axis)) { // translate arrows win over rings - m_mv_drag = axis; m_mv_press_x = evt.GetX(); m_mv_press_y = evt.GetY(); + grab_move_arrow(axis, canvas.mouse_ray(Point(evt.GetX(), evt.GetY()))); + m_mv_press_x = evt.GetX(); m_mv_press_y = evt.GetY(); return true; } if (hit_test_move_arc(canvas, evt, axis)) { @@ -10441,7 +10874,11 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) double a0; if (arc_mouse_angle(canvas, evt, axis, a0)) m_mv_arc_a0 = a0; return true; } + // Off the gizmo a press only steers the camera: no pick, so the selection the + // Move acts on is held until it ends. + return false; } + if (evt.Moving()) return false; // no hover outline: see set_move_gizmo } // Datum-plane resize gizmo (C3): while the Plane card is open the 4 edge handles are // grabbable — drag changes the u/v extent live. A LeftDown that misses falls through. @@ -10500,6 +10937,10 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) if (h >= 0) return true; // caller render()s on true -> hover repaints on software GL } } + // Visual Extrude gizmo (C5b): while the Extrude card is open the depth arrow is + // grabbable — drag changes the depth live; a click (no drag) on the arrow opens the + // inline depth editor. Intercept before the early no-LeftDown bailout so Dragging/ + // LeftUp reach us; a LeftDown that misses the arrow falls through to solid/loop pick. if (m_ex_active) { if (m_ex_drag >= 0 && evt.Dragging() && evt.LeftIsDown()) { drag_extrude_arrow(canvas, evt, m_ex_drag); @@ -11289,9 +11730,9 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) return true; } if (evt.RightDown()) { - // Hand the click back (return false) so the offer opens: the m_right_consumed flag - // this return value feeds means "the tool USED this right-click", and a plain - // right-click in Select mode is not a gesture terminator. + // Hand the click back (return false) so the offer opens: take_right_click returns + // this value as "the tool USED this right-click", and a plain right-click in Select + // mode is not a gesture terminator. // // But do NOT drop the selection on the way out. The offer menu describes WHAT IS // SELECTED, so clearing first guaranteed it could only ever describe nothing: select diff --git a/src/slic3r/GUI/CAD/DesignSketchTool.hpp b/src/slic3r/GUI/CAD/DesignSketchTool.hpp index 7e1c4a629d..70d76cb9eb 100644 --- a/src/slic3r/GUI/CAD/DesignSketchTool.hpp +++ b/src/slic3r/GUI/CAD/DesignSketchTool.hpp @@ -2,26 +2,32 @@ #define slic3r_DesignSketchTool_hpp_ #include "libslic3r/Point.hpp" +#include "libslic3r/BoundingBox.hpp" +#include "libslic3r/Line.hpp" #include "libslic3r/CAD/SketchEngine.hpp" #include "libslic3r/CAD/CadDocument.hpp" // CadBody for per-body solid picking +#include #include "libslic3r/CAD/SketchInference.hpp" #include "slic3r/GUI/GLModel.hpp" #include "slic3r/GUI/GLSelectionRectangle.hpp" // left-drag rubber band over the committed bodies #include +#include #include +#include #include +#include #include "libslic3r/Color.hpp" #include #include #include #include -class wxMouseEvent; class wxPoint; namespace Slic3r { class TriangleMesh; // fwd (libslic3r) — solid-pick mesh, non-owning pointer +class GLVolumeCollection; namespace GUI { @@ -50,6 +56,36 @@ inline ColorRGBA design_idle_face_color() { return ColorRGBA(0.72f, 0.76f, 0.80f, 0.14f); } + +// A convex piece of the square drawn on planes[plane]. +struct PlanePiece +{ + int plane; + std::vector corners; +}; +// The squares of half-extent `half` centred on `planes`' origins, cut where they cross one another +// and ordered back to front for an eye at `eye` (perspective) or looking along `forward` +// (orthographic). Translucent planes that cross cannot be drawn in any per-plane order: each is +// partly in front of and partly behind the others. Drawn piece by piece in this order, each one +// tints only what is behind it. +std::vector planes_back_to_front(const std::vector& planes, double half, + const Vec3d& eye, const Vec3d& forward, bool perspective); + +// The square a reference plane is drawn as, given as the frame at its centre, half-extent `half`. +// The base planes XY, XZ and YZ (`base` 0, 1, 2) sit in the octant (+X, -Y, +Z), the one all three +// names face, reference_square_gap(half) clear of the two axes bounding each, so the three never cross +// and the axes run between them. Any other base (a datum) stays centred on its own origin. The frame is the same +// plane moved within itself, so planes_back_to_front and pick_reference_square take it unchanged. +SketchPlane reference_square(const SketchPlane& plane, int base, double half); +inline double reference_square_gap(double half) { return 0.25 * half; } +// The corners of the box around a base square's label, in the square: inset from its (+x, +y) corner and +// written along the square's x axis with its y axis up, so it turns with the plane. The box the hit +// test takes for the label: round the strokes render_base_pick draws, from the same layout, with a +// margin to aim at. +std::array reference_label_box(const SketchPlane& square, double half, const std::string& text); +// The nearest of `squares` (half-extent `half`) the ray from `from` along `dir` crosses, or -1. +int pick_reference_square(const std::vector& squares, double half, const Vec3d& from, const Vec3d& dir); + class DesignSketchTool { public: enum class Mode { Select, Dimension, Polyline, Line, CornerRect, CenterRect, ObliqueRect, @@ -137,7 +173,6 @@ public: // Returns false when no session is live, so the caller can fall back to a new feature. bool add_imported_regions(const std::vector>>& regions); void set_tool(Mode mode); // switch tool, keep accumulated entities - void set_plane(const SketchPlane& plane) { m_plane = plane; } // re-plane a live sketch (a reference plane was clicked mid-session); entities are 2D, re-lifted through the new plane void set_construction(bool c) { m_construction = c; } void set_polygon_sides(int n) { m_polygon_sides = (n < 3 ? 3 : n); } void set_polygon_circumscribed(bool c) { m_polygon_circumscribed = c; } @@ -149,10 +184,10 @@ public: // Right-click on a draw tool: true when an in-progress anchor was abandoned, false when // there was nothing to abandon — and false is what lets the offer menu open. ghcz. bool right_abandon(); - // True if the LAST right-press was consumed as a gesture terminator (end a polyline chain, - // abandon an anchor, exit a tool). Read-and-clear: the canvas asks on the matching release to - // decide whether that right-click was the user's, in which case it opens the offer. - bool take_right_consumed() { const bool b = m_right_consumed; m_right_consumed = false; return b; } + // Replays and clears the right press on_mouse kept for the camera: true if the tool used it as + // a gesture terminator (end a polyline chain, abandon an anchor), so no offer opens. + bool take_right_click(GLCanvas3D& canvas); + void drop_right_click() { m_right_press.reset(); } void render(GLCanvas3D& canvas); // The in-canvas value field, drawn by render() before any early return. Owned by // DesignCanvas; null until it sets it. Not a window — see SketchInlineEditor.hpp. @@ -207,6 +242,9 @@ public: // their edges do not float over the preview. void set_body_edges_hidden(bool h) { m_body_edges_hidden = h; } void clear_solid_selection(); + // Resample the edges of each body whose shape changed and drop those of bodies that are gone. + // Also expires a pick or hover on a rebuilt body. The canvas calls it whenever it gets the bodies. + void refresh_body_edges(); bool has_solid_selection() const { return m_solid_sel != SolidSel::None; } // Every picked edge when the selection is an edge set (Shift/Ctrl+click adds and removes // edges of the same body): the earlier picks first, the last-clicked edge at the end. @@ -221,6 +259,20 @@ public: void set_highlight_faces(const std::vector>& faces); // Every face drawn as selected, sorted: the committed pick's and the still-valid ones above. std::vector> selected_faces() const; + // What the canvas's Fit button frames: the selection — faces (the Feature tree's included), a + // body, edges, a vertex, a sketch region, the live sketch's picked entities — else everything + // on show: the visible bodies, the preview of the feature being edited and every sketch. + // Undefined when the tab shows none of it. `volumes` are the canvas's: bodies and preview. + BoundingBoxf3 fit_box(const GLVolumeCollection& volumes) const; + // The selection fit_box frames, with no fallback: undefined when nothing is selected. + BoundingBoxf3 selection_box() const; + // World box of (body, face id) faces on the pick mesh, hidden bodies included. + BoundingBoxf3 faces_box(std::vector> faces) const; + // World box of sketch entities drawn on `plane`. + static BoundingBoxf3 sketch_box(const std::vector& entities, const SketchPlane& plane); + // Grow a box the camera is to frame to at least a small extent on every axis: a vertex has no + // size, nor has a sketch along its normal. + static void pad_box(BoundingBoxf3& box); // Move-body gizmo (M5): translate a whole body with three world-axis drag arrows // (X red / Y green / Z blue) anchored at the body centroid. Display-only — the host @@ -230,8 +282,10 @@ public: void set_move_gizmo(int body, const Vec3d& pivot, const Transform3d& base_xform, double body_radius = 0.0); void clear_move_gizmo(); - bool moving_body() const { return m_mv_active; } int move_body_index() const { return m_mv_body; } + // Press, then drag, translate arrow `axis` (0..2 = X/Y/Z) with the mouse ray under the cursor. + void grab_move_arrow(int axis, const Linef3& ray); + void drag_move_arrow(const Linef3& ray); std::function on_body_move_changed; // Fired on each cycle change: (level 0=None/1=Whole/2=Face/3=Edge, body index, face id, edge id). std::function on_solid_selection_changed; @@ -316,6 +370,12 @@ public: std::vector labels = {}); void clear_base_pick(); std::function on_datum_base_picked; + // The base drawn as selected, or -1. Asked once a frame rather than set, because what decides it + // (the panel's chosen sketch plane, a picked face, the Plane card's base) changes in many places. + std::function selected_base; + // The reference planes are drawn this frame, with their own half-axes and origin mark: the canvas + // leaves out the bed's axes triad, which would sit on top of them. + bool draws_reference_axes() const { return m_dbp_active && !m_active; } // Visual Fillet/Chamfer gizmo. The Dressup tool is a DesignPanel docked card, so the sketch // tool is NOT active during it; when a solid EDGE is picked the panel passes the body centroid @@ -971,7 +1031,7 @@ private: bool op_ready() const; // required entities picked -> arrow/ghost live // Sample an entity into a 2D polyline for the overlay renderer. - std::vector entity_polyline(const SketchEntity& e, bool& closed) const; + static std::vector entity_polyline(const SketchEntity& e, bool& closed); // Closed regions formed by the current (non-construction) entities: each a CCW- // ordered boundary polygon on the plane. A circle is its own region; line/arc @@ -1225,19 +1285,20 @@ private: Vec3d body_xform_pt(int body, const Vec3d& p) const; // map an OCCT-shape point through the body xform // The bodies' B-rep edges, drawn as dark lines over the solids so faces and features read // apart. One polyline set per body in its own shape coordinates, resampled only for a body - // whose shape changed (keyed by the TShape), since set_solid_pick runs on every recompute. + // whose shape changed (see is_current_shape). std::vector>> m_body_edges; - std::vector m_body_edges_key; + std::vector m_body_edges_shape; // the shape each set was sampled from std::vector m_body_edges_tol; // the chord tolerance they were sampled at bool m_body_edges_hidden{false}; - void refresh_body_edges(); void render_body_edges(); - const void* body_key(int body) const; // the body's TShape, nullptr when there is none + const TopoDS_Shape* body_shape(int body) const; // nullptr when there is no such body or it has no shape + bool is_current_shape(int body, const TopoDS_Shape& sampled) const; // `sampled` is still the body's shape void append_ribbons(GLModel::Geometry& g, int body, const std::vector>& polylines, const Vec3d& vd, const Vec3d& pull, double hw) const; bool body_pickable(int b) const; // false when the body is explicitly hidden SolidSel m_solid_sel{SolidSel::None}; int m_sel_body{-1}; // which body the face/edge selection is on + TopoDS_Shape m_sel_shape; // that body's shape when picked: the ids index into it int m_sel_face{-1}; int m_sel_edge{-1}; std::vector m_sel_edge_pts; @@ -1274,7 +1335,7 @@ private: void hit_display_sketch(const DisplaySketch& d, const Vec2d& p, double tol, int& edge_feat, int& edge_reg, int& edge_ent, double& edge_d, int& face_feat, int& face_reg) const; - bool m_right_consumed{false}; // last RightDown was a gesture terminator, not a menu + std::optional m_right_press; // right press not yet known to be a click bool m_escalate_repick{true}; // re-picking the same sub-element takes the whole body void render_solid_highlight(); // The above's edge and vertex highlight, from explicit arguments, so the committed selection @@ -1282,11 +1343,11 @@ private: void render_solid_sel(SolidSel kind, const std::vector& edge_pts, const Vec3d& vertex_pt, const ColorRGBA& rgb); // A set of selected faces of one body, with their edges sampled once, keyed by the body's - // TShape so a recompute that rebuilt the body retires it. + // shape so a recompute that rebuilt the body retires it. struct FaceHighlight { int body{-1}; std::vector faces; // sorted - const void* key{nullptr}; + TopoDS_Shape shape; std::vector> edges; // in the body's shape coordinates }; FaceHighlight make_face_highlight(int body, std::vector faces) const; @@ -1385,7 +1446,9 @@ private: std::vector m_dbp_base; std::vector m_dbp_labels; int m_dbp_hover{-1}; - double dbp_half_extent() const; // bed-derived: reference planes are larger than the bed + double dbp_half_extent() const; // bed-derived square size + std::vector dbp_squares(double half) const; // reference_square of each entry + void render_reference_axes(const Vec3d& origin, double half); void render_base_pick(); int hit_test_base_pick(GLCanvas3D& canvas, const wxMouseEvent& evt) const; @@ -1403,6 +1466,7 @@ private: int m_mv_drag{-1}; // 0..2 = X/Y/Z arrow, 3..5 = X/Y/Z ring, -1 none double m_mv_radius{0.0}; // body bounding-sphere radius (mm); 0 = unknown int m_mv_press_x{0}, m_mv_press_y{0}; + double m_mv_grab_along{0.0}; // arrow grab point's distance from the anchor; NaN = unknown Transform3d compose_move_xform() const; // T(offset)*T(pivot)*rot*T(-pivot)*base_xform void ring_basis(int axis, Vec3d& e, Vec3d& u, Vec3d& v) const; // world axis + in-plane basis void render_move_gizmo(); @@ -1410,7 +1474,6 @@ private: double move_gizmo_arm(const Camera& cam) const; bool hit_test_move_arrow(GLCanvas3D& canvas, const wxMouseEvent& evt, int& axis) const; bool hit_test_move_arc(GLCanvas3D& canvas, const wxMouseEvent& evt, int& axis) const; - void drag_move_arrow(GLCanvas3D& canvas, const wxMouseEvent& evt, int axis); void drag_move_arc(GLCanvas3D& canvas, const wxMouseEvent& evt, int axis); bool arc_mouse_angle(GLCanvas3D& canvas, const wxMouseEvent& evt, int axis, double& ang) const; void open_move_editor(int axis); diff --git a/src/slic3r/GUI/GLCanvas3D.cpp b/src/slic3r/GUI/GLCanvas3D.cpp index e9724f41ed..d8e0e97212 100644 --- a/src/slic3r/GUI/GLCanvas3D.cpp +++ b/src/slic3r/GUI/GLCanvas3D.cpp @@ -1875,7 +1875,7 @@ BoundingBoxf3 GLCanvas3D::volumes_bounding_box(bool current_plate_only) const bool is_limit = m_canvas_type != ECanvasType::CanvasAssembleView; if (is_limit) { if (current_plate_only) { - expand_part_plate_list_box = wxGetApp().plater()->get_partplate_list().get_curr_plate()->get_bounding_box(); + expand_part_plate_list_box = _current_plate_box(); } else { auto plate_list_box = wxGetApp().plater()->get_partplate_list().get_bounding_box(); auto horizontal_radius = 0.5 * sqrt(std::pow(plate_list_box.min[0] - plate_list_box.max[0], 2) + std::pow(plate_list_box.min[1] - plate_list_box.max[1], 2)); @@ -2088,6 +2088,25 @@ float GLCanvas3D::get_collapse_toolbar_height() const return collapse_side() != CollapseSide::None ? collapse_toolbar().get_height() : 0; } +// The bottom-left corner: the 3D navigator, then the column of round canvas buttons beside it, in +// units of the toolbar scale. Shared by _render_3d_navigator, _render_canvas_toolbar and +// get_canvas_toolbar_right, so an overlay kept clear of the corner follows its layout. +static constexpr float NAVIGATOR_SIZE = 128.f; +static constexpr float CANVAS_TOOLBAR_MARGIN = 10.f; // off the canvas edge, when there is no navigator +static constexpr float CANVAS_TOOLBAR_PADDING = 2.f; +static constexpr float CANVAS_TOOLBAR_BUTTON = 36.f; + +float GLCanvas3D::get_canvas_toolbar_right() const +{ + // The toolbar scale, which on Windows follows the monitor's DPI where the ImGui style does not. + float sc = get_scale(); +#ifdef WIN32 + sc *= (float) get_dpi_for_window(wxGetApp().GetTopWindow()) / (float) DPI_DEFAULT; +#endif // WIN32 + const float left = wxGetApp().show_3d_navigator() ? NAVIGATOR_SIZE : CANVAS_TOOLBAR_MARGIN; + return (left + 2.f * CANVAS_TOOLBAR_PADDING + CANVAS_TOOLBAR_BUTTON) * sc; +} + GLToolbar& GLCanvas3D::collapse_toolbar() const { return m_collapse_toolbar != nullptr ? *m_collapse_toolbar : wxGetApp().plater()->get_collapse_toolbar(); @@ -2395,7 +2414,7 @@ void GLCanvas3D::_render_scene(const Camera& camera, const Size& cnv_size) } else if (gizmo_type == GLGizmosManager::BrimEars && !camera.is_looking_downward()) show_grid = false; - if (m_axes_at_bed_center) + if (m_design_canvas) // Design tab: the plate grid is generated from the plate's front-left corner, so it // floats mid-cell under the modeling-origin triad. Suppress it here; a CAD grid centred // on the origin is rendered in its place (see _render_cad_grid). @@ -2406,12 +2425,15 @@ void GLCanvas3D::_render_scene(const Camera& camera, const Size& cnv_size) if (m_canvas_type == ECanvasType::CanvasView3D) { // m_show_bed gates the plate list too: hiding the bed but leaving its grid and outline // floating would read as a rendering fault rather than a deliberate view option. + // Design tab: while its reference planes are up they draw their own axes from the modeling + // origin, where the bed's triad would otherwise sit on top of them. if (show_bed) - _render_bed(camera.get_view_matrix(), camera.get_projection_matrix(), !camera.is_looking_downward(), m_show_world_axes); + _render_bed(camera.get_view_matrix(), camera.get_projection_matrix(), !camera.is_looking_downward(), + m_show_world_axes && !(m_design_sketch_tool != nullptr && m_design_sketch_tool->draws_reference_axes())); m_frame_profiler.mark("bed"); if (show_bed) //BBS: add outline logic _render_platelist(camera.get_view_matrix(), camera.get_projection_matrix(), !camera.is_looking_downward(), only_current, only_body, hover_id, true, show_grid); - if (m_axes_at_bed_center && show_bed) + if (m_design_canvas && show_bed) // Design tab: replace the plate's corner-origin grid with the origin-centred CAD grid. _render_cad_grid(camera.get_view_matrix(), camera.get_projection_matrix()); m_frame_profiler.mark("plates"); @@ -4311,7 +4333,10 @@ void GLCanvas3D::on_mouse(wxMouseEvent& evt) } if (evt.LeftDown() && m_canvas != nullptr) m_canvas->SetFocus(); // grab keyboard focus so Delete/keys reach this canvas - if (m_design_sketch_tool->on_mouse(evt, *this)) { + // A Touchpad-style orbit or pan (a plain move with Alt or Shift held) is the camera's, + // whatever the tool would make of the move. Only a drag consults the button mappings. + const bool camera_move = evt.Moving() && (is_camera_rotate(evt, {}) || is_camera_pan(evt, {})); + if (!camera_move && m_design_sketch_tool->on_mouse(evt, *this)) { m_dirty = true; render(); // force an immediate redraw so the sketch overlay updates live return; @@ -6355,7 +6380,7 @@ void GLCanvas3D::_render_3d_navigator() } } - const float size = 128 * sc; + const float size = NAVIGATOR_SIZE * sc; m_canvas_toolbar_pos[0] = size; const auto result = ImGuizmo::ViewManipulate(cameraView, cameraProjection, ImGuizmo::OPERATION::ROTATE, ImGuizmo::MODE::WORLD, nullptr, camDistance, ImVec2(viewManipulateLeft, viewManipulateTop - size), ImVec2(size, size), @@ -8258,7 +8283,7 @@ void GLCanvas3D::_render_bed(const Transform3d& view_matrix, const Transform3d& */ //bool show_texture = true; //BBS set axes mode - if (m_axes_at_bed_center) { + if (m_design_canvas) { // Design tab: triad at the bed centre = modeling origin (set every frame because // set_shape/set_axes_mode otherwise reset it to the bed corner). const Vec2d bc = m_bed.build_volume().bed_center(); @@ -8271,7 +8296,30 @@ void GLCanvas3D::_render_bed(const Transform3d& view_matrix, const Transform3d& void GLCanvas3D::_render_platelist(const Transform3d& view_matrix, const Transform3d& projection_matrix, bool bottom, bool only_current, bool only_body, int hover_id, bool render_cali, bool show_grid) { - wxGetApp().plater()->get_partplate_list().render(view_matrix, projection_matrix, bottom, only_current, only_body, hover_id, render_cali, show_grid, !m_plate_chrome_enabled); + PartPlateList& plate_list = wxGetApp().plater()->get_partplate_list(); + // Design tab: its bed stays at the printer bed's home whichever plate is current, so the + // current plate's exclude areas are moved from that plate onto it. + PartPlate* curr_plate = plate_list.get_curr_plate(); + const Transform3d plate_view_matrix = m_design_canvas && curr_plate != nullptr ? + Transform3d(view_matrix * Geometry::translation_transform(-curr_plate->get_origin())) : view_matrix; + plate_list.render(plate_view_matrix, projection_matrix, bottom, only_current, only_body, hover_id, render_cali, show_grid, !m_plate_chrome_enabled); +} + +BoundingBoxf3 GLCanvas3D::_current_plate_box() const +{ + // Design tab: its own bed stands in for the current plate (see _render_platelist). + const BuildVolume& build_volume = m_bed.build_volume(); + if (m_design_canvas && build_volume.valid()) { + // Flat at z = 0 like the plate's own box. Merged, as PartPlate builds it: the min/max + // constructor leaves a flat box undefined. + const BoundingBoxf bb = build_volume.bounding_volume2d(); + BoundingBoxf3 box; + box.merge(Vec3d(bb.min.x(), bb.min.y(), 0.)); + box.merge(Vec3d(bb.max.x(), bb.max.y(), 0.)); + return box; + } + PartPlate* curr_plate = wxGetApp().plater()->get_partplate_list().get_curr_plate(); + return curr_plate != nullptr ? curr_plate->get_bounding_box() : BoundingBoxf3(); } // Design tab: CAD grid on the bed plane, drawn in place of the plate's corner-origin grid. @@ -10045,10 +10093,10 @@ void GLCanvas3D::_render_canvas_toolbar() sc *= (float) dpi / (float) DPI_DEFAULT; #endif // WIN32 - ImVec2 btn_size = ImVec2(36.f, 36.f) * sc; - ImVec2 margin = ImVec2(m_canvas_toolbar_pos[0] > 0 ? 0.f : (10.f * sc), 10.f * sc); + ImVec2 btn_size = ImVec2(CANVAS_TOOLBAR_BUTTON, CANVAS_TOOLBAR_BUTTON) * sc; + ImVec2 margin = ImVec2(m_canvas_toolbar_pos[0] > 0 ? 0.f : (CANVAS_TOOLBAR_MARGIN * sc), CANVAS_TOOLBAR_MARGIN * sc); ImVec2 spacing = ImVec2(6.f, 6.f) * sc; - ImVec2 padding = ImVec2(2.f, 2.f) * sc; + ImVec2 padding = ImVec2(CANVAS_TOOLBAR_PADDING, CANVAS_TOOLBAR_PADDING) * sc; Vec2i32 pos = { m_canvas_toolbar_pos[0] + margin.x, get_canvas_size().get_height() - margin.y @@ -10106,14 +10154,25 @@ void GLCanvas3D::_render_canvas_toolbar() ImTextureID z_hover_id = m_gizmos.get_icon_texture_id(m_is_dark ? GLGizmosManager::MENU_ICON_NAME::IC_CANVAS_ZOOM_DARK_HOVER : GLGizmosManager::MENU_ICON_NAME::IC_CANVAS_ZOOM_HOVER); if (ImGui::ImageButton3(z_normal_id, z_hover_id, btn_size)) { - select_view("plate"); - if (m_selection.is_empty()) { - if (m_canvas_type == ECanvasType::CanvasAssembleView) - zoom_to_volumes(); - else - zoom_to_bed(); - } else { - zoom_to_selection(); +#ifdef SLIC3R_CAD + // The Design tab selects and sketches outside the canvas's selection and volumes, so + // it names what to frame. Framed along the current view, which is often square to a + // sketch plane; an empty tab falls through to the bed. + const BoundingBoxf3 design_box = m_design_sketch_tool != nullptr ? m_design_sketch_tool->fit_box(m_volumes) : BoundingBoxf3(); + if (design_box.defined) + _zoom_to_box(design_box); + else +#endif + { + select_view("plate"); + if (m_selection.is_empty()) { + if (m_canvas_type == ECanvasType::CanvasAssembleView) + zoom_to_volumes(); + else + zoom_to_bed(); + } else { + zoom_to_selection(); + } } } else if (ImGui::IsItemHovered()) { auto tooltip_str_wx = _L("Fit camera to scene or selected object."); @@ -11066,10 +11125,9 @@ std::optional GLCanvas3D::get_camera_orbit_target(ECameraNavigationType n { // Orca: Centralize the pre-existing pivot rules so orbiting and pan fallback cannot // choose different reference depths for the same canvas and active tool. - PartPlate* current_plate = wxGetApp().plater()->get_partplate_list().get_curr_plate(); + const BoundingBoxf3 plate_box = _current_plate_box(); if (navigation_type == ECameraNavigationType::Gesture) - return current_plate == nullptr ? std::nullopt : - std::make_optional(current_plate->get_bounding_box().center()); + return plate_box.defined ? std::make_optional(plate_box.center()) : std::nullopt; const GLGizmosManager::EType gizmo_type = m_gizmos.get_current_type(); const bool use_scene_target = m_canvas_type == ECanvasType::CanvasAssembleView || @@ -11089,15 +11147,15 @@ std::optional GLCanvas3D::get_camera_orbit_target(ECameraNavigationType n Vec3d target = Vec3d::Zero(); if (m_canvas_type == ECanvasType::CanvasPreview) { - if (current_plate != nullptr) - target = current_plate->get_bounding_box().center(); + if (plate_box.defined) + target = plate_box.center(); } else if (!m_selection.is_empty()) { target = m_selection.get_bounding_box().center(); } else { // Orca: Match regular mouse orbit: objects on the active plate, then the plate itself. BoundingBoxf3 bbox = volumes_bounding_box(true); - if (!bbox.defined && current_plate != nullptr) - bbox = current_plate->get_bounding_box(); + if (!bbox.defined) + bbox = plate_box; if (bbox.defined) target = bbox.center(); } diff --git a/src/slic3r/GUI/GLCanvas3D.hpp b/src/slic3r/GUI/GLCanvas3D.hpp index e53a9d6aba..faf826ed7c 100644 --- a/src/slic3r/GUI/GLCanvas3D.hpp +++ b/src/slic3r/GUI/GLCanvas3D.hpp @@ -604,9 +604,11 @@ private: GLToolbar* m_collapse_toolbar{nullptr}; std::function m_collapse_side; bool m_plate_chrome_enabled{true}; - // Design tab: render the world-axis triad at the bed centre (= modeling origin) instead of - // the bed corner. Default false preserves the main editor's corner triad. - bool m_axes_at_bed_center{false}; + // This canvas is the Design tab's. Its bed stays at the printer bed's home whichever plate is + // current, with the world-axis triad and a CAD grid at the bed centre (= modeling origin) in + // place of the corner triad and the plate grid, and the plate data it reads (exclude areas, + // the current plate's box) moved onto that bed. Default false leaves the editor tabs untouched. + bool m_design_canvas{false}; // Design tab: draw the printer bed and its plate grid at all. Default true, so the // main editor is untouched; the Design tab lets the user hide it to model without a bed. bool m_show_bed{true}; @@ -1025,13 +1027,15 @@ public: // initialized, which loads the toolbar's background. void set_collapse_toolbar(GLToolbar* toolbar, std::function side); void enable_plate_chrome(bool enable); - void set_axes_at_bed_center(bool b) { m_axes_at_bed_center = b; } + void set_design_canvas(bool b) { m_design_canvas = b; } void set_show_bed(bool b) { m_show_bed = b; } bool get_show_bed() const { return m_show_bed; } void enable_sinking_contours(bool enable) { m_sinking_contours_enabled = enable; } #ifdef SLIC3R_CAD void set_design_sketch_tool(DesignSketchTool* tool) { m_design_sketch_tool = tool; } DesignSketchTool* get_design_sketch_tool() const { return m_design_sketch_tool; } + // The Design tab frames what it selects itself (DesignCanvas::zoom_to_box), as the Fit button does. + void zoom_to_box(const BoundingBoxf3& box) { _zoom_to_box(box); } #endif void enable_dynamic_background(bool enable) { m_dynamic_background_enabled = enable; } void enable_labels(bool enable) { m_labels.enable(enable); } @@ -1059,6 +1063,9 @@ public: bool is_collapse_toolbar_on_left() const; float get_collapse_toolbar_width() const; float get_collapse_toolbar_height() const; + // Right edge, in canvas pixels, of the bottom-left corner the 3D navigator and the round + // canvas buttons own. An overlay along the bottom edge starts past it. + float get_canvas_toolbar_right() const; void update_volumes_colors_by_extruder(); @@ -1441,9 +1448,11 @@ private: void _render_shadows(const Transform3d& view_matrix, const Transform3d& projection_matrix); //BBS: add part plate related logic void _render_platelist(const Transform3d& view_matrix, const Transform3d& projection_matrix, bool bottom, bool only_current, bool only_body = false, int hover_id = -1, bool render_cali = false, bool show_grid = true); + // The current plate's box (XY, at z = 0); in the Design tab, its own bed's. + BoundingBoxf3 _current_plate_box() const; // Design tab: draw the CAD grid (minor 10 mm + major 50 mm) in place of the plate's - // corner-origin grid when the axes sit at the bed centre (modeling origin). Rebuilds its - // GLModels lazily, only when the bed shape changed. + // corner-origin grid, centred on the modeling origin. Rebuilds its GLModels lazily, only + // when the bed shape changed. void _render_cad_grid(const Transform3d& view_matrix, const Transform3d& projection_matrix); //BBS: add outline drawing logic void _render_objects(GLVolumeCollection::ERenderType type, bool with_outline = true); diff --git a/tests/slic3rutils/CMakeLists.txt b/tests/slic3rutils/CMakeLists.txt index b495f35526..97a282825b 100644 --- a/tests/slic3rutils/CMakeLists.txt +++ b/tests/slic3rutils/CMakeLists.txt @@ -40,6 +40,10 @@ add_executable(${_TEST_NAME}_tests ../fff_print/test_helpers.cpp ) +if (SLIC3R_CAD) + target_sources(${_TEST_NAME}_tests PRIVATE test_design_sketch_tool.cpp) +endif () + if (MSVC) target_link_libraries(${_TEST_NAME}_tests Setupapi.lib) endif () diff --git a/tests/slic3rutils/test_design_sketch_tool.cpp b/tests/slic3rutils/test_design_sketch_tool.cpp new file mode 100644 index 0000000000..ee84c01d35 --- /dev/null +++ b/tests/slic3rutils/test_design_sketch_tool.cpp @@ -0,0 +1,463 @@ +// Orca: This suite links libslic3r_gui; the Design tab's sketch tool needs no wx application or GL +// context until it renders. +#ifdef WIN32 + #ifndef WIN32_LEAN_AND_MEAN + #define WIN32_LEAN_AND_MEAN + #endif + #ifndef NOMINMAX + #define NOMINMAX + #endif + #include + // Match the GUI precompiled header: wx/msw/wrapcctl.h needs HDITEM from CommCtrl.h. + #include +#endif + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "libslic3r/BoundingBox.hpp" +#include "libslic3r/CAD/SketchEngine.hpp" +#include "libslic3r/Line.hpp" +#include "libslic3r/Point.hpp" +#include "libslic3r/TriangleMesh.hpp" +#include "libslic3r/CAD/CadDocument.hpp" +#include "slic3r/GUI/3DScene.hpp" +#include "slic3r/GUI/CAD/DesignSketchTool.hpp" + +using namespace Slic3r; +using namespace Slic3r::GUI; +using Catch::Matchers::WithinAbs; +using Catch::Matchers::WithinRel; + +namespace { + +SketchEntity circle(const Vec2d& c, double r) +{ + SketchEntity e; + e.type = SketchEntity::Type::Circle; + e.center = e.p0 = c; + e.radius = r; + return e; +} + +// Two committed sketches on XY: feature 1 a 10 mm circle at (50, 20), feature 2 a 5 mm one at (-60, 0). +void show_two_sketches(DesignSketchTool& tool) +{ + tool.set_display_sketches({ { { circle({ 50., 20. }, 10.) }, SketchPlane::XY(), 1 }, + { { circle({ -60., 0. }, 5.) }, SketchPlane::XY(), 2 } }); +} + +// The Design tab's base planes, all through one origin, as DesignPanel shows them. +std::vector base_planes() { return { SketchPlane::XY(), SketchPlane::XZ(), SketchPlane::YZ() }; } + +// The squares the base planes are drawn as, through a modeling origin at `origin`. +std::vector reference_squares(const Vec3d& origin, double half) +{ + std::vector squares; + for (SketchPlane plane : base_planes()) { + plane.origin += origin; + squares.push_back(reference_square(plane, int(squares.size()), half)); + } + return squares; +} + +struct View +{ + Vec3d eye; + Vec3d forward; + bool perspective; +}; + +// Eyes in four octants, off every plane, plus two orthographic directions. +const View kViews[] = { + { Vec3d(300., -400., 250.), Vec3d(-300., 400., -250.).normalized(), true }, + { Vec3d(-350., -200., 300.), Vec3d(350., 200., -300.).normalized(), true }, + { Vec3d(250., 300., -200.), Vec3d(-250., -300., 200.).normalized(), true }, + { Vec3d(-300., 350., -250.), Vec3d(300., -350., 250.).normalized(), true }, + { Vec3d::Zero(), Vec3d(-0.5, 0.7, -0.5).normalized(), false }, + { Vec3d::Zero(), Vec3d(0.3, 0.4, 0.85).normalized(), false }, +}; + +double area(const PlanePiece& piece, const SketchPlane& plane) +{ + Vec3d sum = Vec3d::Zero(); + for (size_t i = 0; i < piece.corners.size(); ++i) + sum += piece.corners[i].cross(piece.corners[(i + 1) % piece.corners.size()]); + return 0.5 * std::abs(sum.dot(plane.x_axis.cross(plane.y_axis))); +} + +// How far along the ray (from, unit dir) it crosses `piece`, or nothing if it misses. +std::optional hit_distance(const PlanePiece& piece, const SketchPlane& plane, const Vec3d& from, const Vec3d& dir) +{ + const Vec3d n = plane.x_axis.cross(plane.y_axis); + const double dn = n.dot(dir); + if (std::abs(dn) < 1e-9) + return std::nullopt; + const double t = n.dot(piece.corners.front() - from) / dn; + if (t <= 0.) + return std::nullopt; + const Vec3d x = from + dir * t; + double lo = 0., hi = 0.; + for (size_t i = 0; i < piece.corners.size(); ++i) { + const Vec3d& a = piece.corners[i]; + const Vec3d& b = piece.corners[(i + 1) % piece.corners.size()]; + const double s = (b - a).cross(x - a).dot(n); + lo = std::min(lo, s); + hi = std::max(hi, s); + } + if (lo < 0. && hi > 0.) + return std::nullopt; // outside one of the edges + return t; +} + +struct SightLines +{ + int overlapping = 0; // sight lines through two or more pieces, where draw order matters + int out_of_order = 0; // ...of which meet a nearer piece before a farther one +}; + +// Translucent pieces blend correctly only if, along every line of sight, each piece is drawn after +// every piece behind it. The lines of sight aim at points of the box [lo, hi]. +SightLines sight_lines(const std::vector& planes, double half, const View& view, const Vec3d& lo, const Vec3d& hi) +{ + const std::vector pieces = planes_back_to_front(planes, half, view.eye, view.forward, view.perspective); + std::mt19937 rng(7); + std::uniform_real_distribution unit(0., 1.); + SightLines seen; + for (int r = 0; r < 500; ++r) { + const Vec3d target = lo + (hi - lo).cwiseProduct(Vec3d(unit(rng), unit(rng), unit(rng))); + const Vec3d from = view.perspective ? view.eye : Vec3d(target - view.forward * (10. * half)); + const Vec3d dir = (target - from).normalized(); + double last = std::numeric_limits::max(); + int hits = 0; + bool ok = true; + for (const PlanePiece& piece : pieces) + if (const std::optional t = hit_distance(piece, planes[piece.plane], from, dir)) { + ok = ok && *t <= last + 1e-6 * half; + last = *t; + ++hits; + } + if (hits >= 2) { + ++seen.overlapping; + seen.out_of_order += ok ? 0 : 1; + } + } + return seen; +} + +} // namespace + +TEST_CASE("Fit frames the picked sketch region, not the other sketches", "[DesignSketchTool]") +{ + DesignSketchTool tool; + GLVolumeCollection no_bodies; + show_two_sketches(tool); + tool.set_display_pick(1, 0); + + const BoundingBoxf3 box = tool.fit_box(no_bodies); + REQUIRE(box.defined); + CHECK_THAT(box.min.x(), WithinAbs(40., 0.5)); + CHECK_THAT(box.max.x(), WithinAbs(60., 0.5)); + CHECK_THAT(box.min.y(), WithinAbs(10., 0.5)); + CHECK_THAT(box.max.y(), WithinAbs(30., 0.5)); + // Zoom to selection frames the same region, the padding left to the canvas. + CHECK_THAT(tool.selection_box().min.x(), WithinAbs(40., 0.5)); + CHECK_THAT(tool.selection_box().size().z(), WithinAbs(0., 1e-9)); +} + +TEST_CASE("Fit frames every sketch when nothing is picked", "[DesignSketchTool]") +{ + DesignSketchTool tool; + GLVolumeCollection no_bodies; + show_two_sketches(tool); + + const BoundingBoxf3 box = tool.fit_box(no_bodies); + REQUIRE(box.defined); + CHECK_THAT(box.min.x(), WithinAbs(-65., 0.5)); + CHECK_THAT(box.max.x(), WithinAbs(60., 0.5)); + // Zoom to selection has no such fallback. + CHECK_FALSE(tool.selection_box().defined); +} + +TEST_CASE("Fit frames the sketch being drawn along with the committed ones", "[DesignSketchTool]") +{ + DesignSketchTool tool; + GLVolumeCollection no_bodies; + show_two_sketches(tool); + // A 100 mm line up the XZ plane, whose y axis is world Z. + SketchEntity line; + line.p0 = { 0., 0. }; + line.p1 = { 0., 100. }; + tool.begin_edit({ line }, {}, SketchPlane::XZ()); + + const BoundingBoxf3 box = tool.fit_box(no_bodies); + REQUIRE(box.defined); + CHECK_THAT(box.max.z(), WithinAbs(100., 1e-6)); + CHECK_THAT(box.min.x(), WithinAbs(-65., 0.5)); +} + +TEST_CASE("Fit gives a flat sketch depth, so it can be framed edge-on", "[DesignSketchTool]") +{ + DesignSketchTool tool; + GLVolumeCollection no_bodies; + show_two_sketches(tool); + tool.set_display_pick(2, 0); + + const BoundingBoxf3 box = tool.fit_box(no_bodies); + REQUIRE(box.defined); + CHECK(box.size().z() > 0.); + CHECK_THAT(box.center().z(), WithinAbs(0., 1e-9)); +} + +TEST_CASE("Fit frames nothing when the Design tab shows nothing", "[DesignSketchTool]") +{ + DesignSketchTool tool; + GLVolumeCollection no_bodies; + CHECK_FALSE(tool.fit_box(no_bodies).defined); +} + +TEST_CASE("A sketch's box lies on its own plane", "[DesignSketchTool]") +{ + // A 5 mm circle at (10, 20) on XZ, whose y axis is world Z. + const BoundingBoxf3 box = DesignSketchTool::sketch_box({ circle({ 10., 20. }, 5.) }, SketchPlane::XZ()); + REQUIRE(box.defined); + CHECK_THAT(box.min.x(), WithinAbs(5., 0.05)); + CHECK_THAT(box.max.x(), WithinAbs(15., 0.05)); + CHECK_THAT(box.min.z(), WithinAbs(15., 0.05)); + CHECK_THAT(box.max.z(), WithinAbs(25., 0.05)); + CHECK_THAT(box.size().y(), WithinAbs(0., 1e-9)); + CHECK_FALSE(DesignSketchTool::sketch_box({}, SketchPlane::XY()).defined); + + SketchEntity point; + point.type = SketchEntity::Type::Point; + point.p0 = { 3., 4. }; + const BoundingBoxf3 dot = DesignSketchTool::sketch_box({ point }, SketchPlane::XY()); + REQUIRE(dot.defined); + CHECK_THAT(dot.min.x(), WithinAbs(3., 1e-9)); + CHECK_THAT(dot.min.y(), WithinAbs(4., 1e-9)); +} + +TEST_CASE("A face's box comes from the pick mesh, on a hidden body too", "[DesignSketchTool]") +{ + // One triangle per body: body 0 near the origin, body 1, hidden, 100 mm along X. + indexed_triangle_set its; + its.vertices = { { 0.f, 0.f, 0.f }, { 10.f, 0.f, 0.f }, { 0.f, 10.f, 5.f }, + { 100.f, 0.f, 0.f }, { 110.f, 0.f, 0.f }, { 100.f, 10.f, 5.f } }; + its.indices = { { 0, 1, 2 }, { 3, 4, 5 } }; + const TriangleMesh mesh(its); + const std::vector bodies(2); + const std::vector tri_face{ 0, 0 }; + const std::vector tri_body{ 0, 1 }; + const std::vector visible{ true, false }; + DesignSketchTool tool; + tool.set_solid_pick(&bodies, &mesh, &tri_face, &tri_body, &visible); + + const BoundingBoxf3 box = tool.faces_box({ { 1, 0 } }); + REQUIRE(box.defined); + CHECK_THAT(box.min.x(), WithinAbs(100., 1e-6)); + CHECK_THAT(box.max.x(), WithinAbs(110., 1e-6)); + CHECK_THAT(box.max.z(), WithinAbs(5., 1e-6)); + CHECK_FALSE(tool.faces_box({}).defined); + + // CadDocument::faces_made_by may list a body's faces after a later body's. + const BoundingBoxf3 both = tool.faces_box({ { 1, 0 }, { 0, 0 } }); + REQUIRE(both.defined); + CHECK_THAT(both.min.x(), WithinAbs(0., 1e-6)); + CHECK_THAT(both.max.x(), WithinAbs(110., 1e-6)); +} + +TEST_CASE("Padding a box to frame gives every axis a small extent and leaves a long one alone", "[DesignSketchTool]") +{ + BoundingBoxf3 box; + box.merge(Vec3d(0., 0., 3.)); + box.merge(Vec3d(100., 4., 3.)); + DesignSketchTool::pad_box(box); + CHECK_THAT(box.size().x(), WithinAbs(100., 1e-9)); + CHECK_THAT(box.size().y(), WithinAbs(10., 1e-9)); + CHECK_THAT(box.size().z(), WithinAbs(10., 1e-9)); + CHECK_THAT(box.center().z(), WithinAbs(3., 1e-9)); + + BoundingBoxf3 none; + DesignSketchTool::pad_box(none); + CHECK_FALSE(none.defined); +} + +TEST_CASE("Crossing base planes are drawn back to front from any viewpoint", "[DesignSketchTool]") +{ + const View& view = kViews[GENERATE(range(0, int(std::size(kViews))))]; + const SightLines seen = sight_lines(base_planes(), 75., view, Vec3d::Constant(-0.95 * 75.), Vec3d::Constant(0.95 * 75.)); + CHECK(seen.overlapping >= 200); // of the 500: most lines of sight into the planes cross two + CHECK(seen.out_of_order == 0); +} + +TEST_CASE("Datum planes are drawn back to front among the base planes", "[DesignSketchTool]") +{ + // A datum parallel to XY 30 mm up, and one tilted 30 degrees about X through (0, 0, 10). + std::vector planes = base_planes(); + SketchPlane raised = SketchPlane::XY(); + raised.origin = Vec3d(0., 0., 30.); + SketchPlane tilted; + tilted.origin = Vec3d(0., 0., 10.); + tilted.y_axis = Vec3d(0., std::cos(M_PI / 6.), std::sin(M_PI / 6.)); + tilted.normal = tilted.x_axis.cross(tilted.y_axis); + planes.push_back(raised); + planes.push_back(tilted); + + const View& view = kViews[GENERATE(range(0, int(std::size(kViews))))]; + const SightLines seen = sight_lines(planes, 75., view, Vec3d::Constant(-0.95 * 75.), Vec3d::Constant(0.95 * 75.)); + CHECK(seen.overlapping >= 200); + CHECK(seen.out_of_order == 0); +} + +TEST_CASE("Cutting the base planes along each other keeps every plane whole", "[DesignSketchTool]") +{ + const std::vector planes = base_planes(); + const double half = 75.; + const View& view = kViews[0]; + std::vector covered(planes.size(), 0.); + for (const PlanePiece& piece : planes_back_to_front(planes, half, view.eye, view.forward, view.perspective)) + covered[piece.plane] += area(piece, planes[piece.plane]); + for (double a : covered) + CHECK_THAT(a, WithinRel(4. * half * half, 1e-9)); +} + +TEST_CASE("Base reference planes sit in the octant their names face, clear of the axes", "[DesignSketchTool]") +{ + // Each square is flat in one coordinate and at least `gap` out along the other two, toward + // (+X, -Y, +Z), so no two of them meet: a point of XY has z = 0, every point of XZ and YZ has + // z >= gap. + const Vec3d origin(128., 128., 0.), octant(1., -1., 1.); + const double half = 32., gap = reference_square_gap(half); + const std::vector squares = reference_squares(origin, half); + const int flat[] = { 2, 1, 0 }; // XY in z, XZ in y, YZ in x + for (int i = 0; i < 3; ++i) { + // The side a name reads from faces into the same octant. + CHECK(squares[i].x_axis.cross(squares[i].y_axis).dot(octant) > 0.); + for (const Vec2d& corner : { Vec2d(-half, -half), Vec2d(half, -half), Vec2d(half, half), Vec2d(-half, half) }) { + const Vec3d x = squares[i].to_world(corner) - origin; + for (int k = 0; k < 3; ++k) + if (k == flat[i]) + CHECK_THAT(x[k], WithinAbs(0., 1e-9)); + else { + CHECK(octant[k] * x[k] >= gap - 1e-9); + CHECK(octant[k] * x[k] <= gap + 2. * half + 1e-9); + } + } + } +} + +TEST_CASE("From the front no base reference plane stands behind another", "[DesignSketchTool]") +{ + // Looking from the front and above, as the Design tab opens: XY lies below the X axis on screen + // and XZ above it, and YZ is seen edge-on, so no line of sight crosses two squares. + const double half = 32., e = reference_square_gap(half) + 2. * half; + const std::vector squares = reference_squares(Vec3d::Zero(), half); + const double elevation = M_PI / 180. * GENERATE(20., 45., 70.); + const View front{ Vec3d::Zero(), Vec3d(0., std::cos(elevation), -std::sin(elevation)), false }; + CHECK(sight_lines(squares, half, front, Vec3d(0., -e, 0.), Vec3d(e, 0., e)).overlapping == 0); +} + +TEST_CASE("A datum's reference square stays centred on its origin", "[DesignSketchTool]") +{ + SketchPlane datum = SketchPlane::XY(); + datum.origin = Vec3d(10., 20., 30.); + CHECK((reference_square(datum, 3, 32.).origin - datum.origin).norm() < 1e-12); +} + +TEST_CASE("Base reference planes are drawn back to front without being cut", "[DesignSketchTool]") +{ + const double half = 32.; + const std::vector squares = reference_squares(Vec3d::Zero(), half); + int overlapping = 0; + for (const View& view : kViews) { + const std::vector pieces = planes_back_to_front(squares, half, view.eye, view.forward, view.perspective); + CHECK(pieces.size() == 3); + for (const PlanePiece& piece : pieces) + CHECK(piece.corners.size() == 4); + const double e = reference_square_gap(half) + 2. * half; + const SightLines seen = sight_lines(squares, half, view, Vec3d(0., -e, 0.), Vec3d(e, 0., e)); + CHECK(seen.out_of_order == 0); + overlapping += seen.overlapping; + } + // These views do see squares behind one another, so the order is actually put to the test. + CHECK(overlapping >= 50); +} + +TEST_CASE("A click takes the nearest reference square along the ray", "[DesignSketchTool]") +{ + const double half = 32.; // the squares span 8..72 out along both their axes + const std::vector squares = reference_squares(Vec3d::Zero(), half); + // From the front left through YZ (x = 0) at (0, -20, 40), then on through XZ (y = 0) at (20, 0, 40). + const Vec3d along(1., 1., 0.); + CHECK(pick_reference_square(squares, half, Vec3d(-40., -60., 40.), along) == 2); + // The same line walked the other way meets XZ first. + CHECK(pick_reference_square(squares, half, Vec3d(60., 40., 40.), -along) == 1); + // Straight down onto XY, and down the gap beside it, which holds nothing. + CHECK(pick_reference_square(squares, half, Vec3d(40., -40., 100.), Vec3d(0., 0., -1.)) == 0); + CHECK(pick_reference_square(squares, half, Vec3d(4., -40., 100.), Vec3d(0., 0., -1.)) == -1); + // Looking away from the squares. + CHECK(pick_reference_square(squares, half, Vec3d(40., -40., 100.), Vec3d(0., 0., 1.)) == -1); +} + +TEST_CASE("A base plane's name lies inside its own square", "[DesignSketchTool]") +{ + const double half = 32.; + const std::vector squares = reference_squares(Vec3d(128., 128., 0.), half); + const char* names[] = { "XY", "XZ", "YZ" }; + for (int i = 0; i < 3; ++i) + for (const Vec3d& corner : reference_label_box(squares[i], half, names[i])) { + const Vec3d d = corner - squares[i].origin; + CHECK_THAT(d.dot(squares[i].x_axis.cross(squares[i].y_axis)), WithinAbs(0., 1e-9)); + CHECK(std::abs(d.dot(squares[i].x_axis)) < half); + CHECK(std::abs(d.dot(squares[i].y_axis)) < half); + } +} + +TEST_CASE("A move arrow drag moves the body by the cursor's travel, wherever the arrow is grabbed", "[DesignSketchTool]") +{ + DesignSketchTool tool; + Transform3d moved = Transform3d::Identity(); + tool.on_body_move_changed = [&moved](int, const Transform3d& xform) { moved = xform; }; + tool.set_move_gizmo(0, Vec3d::Zero(), Transform3d::Identity(), 10.); + // Looking straight down onto the X arrow, the cursor over x = `x` on it. + const auto ray_at = [](double x) { return Linef3(Vec3d(x, 0., 100.), Vec3d(x, 0., 0.)); }; + + // Grabbed 12 mm out from the body centre: a 1 mm move moves the body 1 mm, not 13. + tool.grab_move_arrow(0, ray_at(12.)); + tool.drag_move_arrow(ray_at(13.)); + CHECK_THAT(moved.translation().x(), WithinAbs(1., 1e-9)); + tool.drag_move_arrow(ray_at(17.)); + CHECK_THAT(moved.translation().x(), WithinAbs(5., 1e-9)); + + // The next drag carries on from where the body was left: grabbed 3 mm past it, moved 2 mm. + tool.grab_move_arrow(0, ray_at(8.)); + tool.drag_move_arrow(ray_at(10.)); + CHECK_THAT(moved.translation().x(), WithinAbs(7., 1e-9)); +} + +TEST_CASE("A move arrow pressed while looking down its axis does not jump on the first move", "[DesignSketchTool]") +{ + DesignSketchTool tool; + Transform3d moved = Transform3d::Identity(); + tool.on_body_move_changed = [&moved](int, const Transform3d& xform) { moved = xform; }; + tool.set_move_gizmo(0, Vec3d::Zero(), Transform3d::Identity(), 10.); + const auto ray_at = [](double x) { return Linef3(Vec3d(x, 0., 100.), Vec3d(x, 0., 0.)); }; + + // The press projects nowhere on the X axis, so the first move only finds where it was grabbed. + tool.grab_move_arrow(0, Linef3(Vec3d(100., 0., 0.), Vec3d::Zero())); + tool.drag_move_arrow(ray_at(13.)); + CHECK_THAT(moved.translation().x(), WithinAbs(0., 1e-9)); + tool.drag_move_arrow(ray_at(15.)); + CHECK_THAT(moved.translation().x(), WithinAbs(2., 1e-9)); +}