diff --git a/.claude/skills/orca-wxwidgets/references/orca-architecture.md b/.claude/skills/orca-wxwidgets/references/orca-architecture.md index 6c23122910..a31d8fc89d 100644 --- a/.claude/skills/orca-wxwidgets/references/orca-architecture.md +++ b/.claude/skills/orca-wxwidgets/references/orca-architecture.md @@ -405,9 +405,13 @@ page object to insert and remove by pointer). `LazyPage(parent, name, order, fac factory is `new Panel(parent)`. Its `Show(true)` builds the panel the first time (only once the top-level frame is shown — `MainFrame::Show` completes the start page on the frame's first show) and forwards later shows/hides to the panel, so the panel's own `Show()` override stays its activation -hook. A panel built while its page is hidden stays hidden, and `when_built` gives it the dark-UI pass -the frame ran before it existed (`apply_dark_ui_to_lazy_panel`). `pending()` is true only while the page -is in the book. +hook. The build runs before the placeholder's own `wxPanel::Show(true)`, so an on-demand build creates +its controls in a hidden window as a prebuild does: on MSW each control created or moved inside a +shown window re-clips and repaints its shown siblings, which made a large panel's first show take +seconds. A lazy panel's constructor therefore runs off screen (except the start page's) and must not +rely on `IsShownOnScreen()`. A panel built while its page is hidden stays hidden, and `when_built` +gives it the dark-UI pass the frame ran before it existed (`apply_dark_ui_to_lazy_panel`). +`pending()` is true only while the page is in the book. ### Staged construction: StagedBuild @@ -641,8 +645,10 @@ Spacing constants come from `SidebarProps` (`Plater.hpp`): `TitlebarMargin()`, ` ### Docking -`Plater::priv` owns `AuiMgr m_aui_mgr` (a `wxAuiManager` subclass whose `CreateFloatingFrame` returns a -themed `FloatFrame : wxAuiFloatingFrame`), managing the plater. Panes: `"sidebar"` (left, no close +Docks are `AuiMgr`s (`AuiMgr.hpp`), a `wxAuiManager` subclass carrying Orca's dock art, theme and +Wayland rule (`references/webview-gl-aui-media.md`). `Plater::priv` owns `m_aui_mgr`, managing the +plater; the Design tab owns its own for its sidebar (`DesignPanel::m_aui`, layout in +`design_window_layout`). Plater panes: `"sidebar"` (left, no close button, not top/bottom dockable), `"main"` (`CenterPane()`, the `panel_3d`), `"uv_editor"` (right, hidden until the texture-displacement gizmo shows it), plus dynamic dock panes. The default perspective is saved right after `AddPane`; the app-config `window_layout` is applied with diff --git a/.claude/skills/orca-wxwidgets/references/platforms.md b/.claude/skills/orca-wxwidgets/references/platforms.md index 2b7286fbb6..f391aecf27 100644 --- a/.claude/skills/orca-wxwidgets/references/platforms.md +++ b/.claude/skills/orca-wxwidgets/references/platforms.md @@ -271,6 +271,14 @@ Each bullet names the mechanism and the file that owns it. - **Painting:** in 3.3.2 windows are not double-buffered by default (the 3.3.0 global `WS_EX_COMPOSITED` was reverted, `docs/changes.txt:308`). Custom widgets buffer by hand → `references/painting-custom-widgets.md`. +- **Building large panels:** every control is a native child window, and outside a sizer pass a move + or resize is immediate, repainting when the window is shown [source: `src/msw/window.cpp:2036` + `DoMoveSibling` → `MSWMoveWindowToAnyPosition(..., IsShown())`]; `wxStaticText::SetLabel`/`SetFont` + resize the control that way (`src/common/stattextcmn.cpp:334` `AutoResizeIfNecessary`). Created + inside a shown parent, each control re-clips and erases its shown, overlapping siblings, so the + cost grows with the number already built and hundreds of controls take seconds. Build a large + panel while its parent is hidden and show it once complete — `LazyPage::Show` builds its panel + before showing the page for this reason → `references/orca-architecture.md` §Deferred construction. - **Modal loops:** idle events do not run inside the Windows sizing/moving modal loop, so the 3D canvas renders from `on_paint` on MSW (c06a0223a7) → `references/webview-gl-aui-media.md` §GLCanvas3D rendering. - **Mouse capture:** `wxEVT_MOUSE_CAPTURE_LOST` and `wxEVT_MOUSE_CAPTURE_CHANGED` are delivered → diff --git a/.claude/skills/orca-wxwidgets/references/webview-gl-aui-media.md b/.claude/skills/orca-wxwidgets/references/webview-gl-aui-media.md index 9ad1f9a4b2..b8856b21ca 100644 --- a/.claude/skills/orca-wxwidgets/references/webview-gl-aui-media.md +++ b/.claude/skills/orca-wxwidgets/references/webview-gl-aui-media.md @@ -88,6 +88,7 @@ available; GTK2/WebKit1 is an opt-out configuration the GUI does not support. 24. ImGui text uses `_u8L`; ImGui sizes are physical pixels (scale by `GLCanvas3D::get_scale()`); a font atlas must fit `GL_MAX_TEXTURE_SIZE`. §[ImGui boundary](#the-imgui-layer-boundary) 25. After `AddPane` or any `wxAuiPaneInfo` change, call `wxAuiManager::Update()` once for the batch. + Keep a docked pane hidden until the managed window has been laid out. §[AUI](#wxauimanager-and-orcas-docking) 26. Give every pane a unique, stable `Name()` free of layout delimiters (`|`, `;`, `=`, `\`). §[AUI](#wxauimanager-and-orcas-docking) @@ -920,6 +921,16 @@ widgets beside the canvas (a sibling, not a child over it), or in a popup. - **Update.** "Update() must be invoked after AddPane() or InsertPane() … any number of changes may be made to wxAuiPaneInfo structures (retrieved with wxAuiManager::GetPane), but to realize the changes, Update() must be called" (`interface/wx/aui/framemanager.h:747-756`). Batch, then `Update()` once. +- **Dock size** [source]. The docs limit "any new dock" to a `SetDockSizeConstraint` fraction of the + window (`interface/wx/aui/framemanager.h:660-672`). The source applies it to the managed window's client + size at the `Update()` that creates the dock, sizing the dock from its panes' `best_size` and then + raising it to their `min_size` (`src/aui/framemanager.cpp:2748-2848`); a dock with a resizable pane + keeps that size (`2674-2677`), and resizing the managed window only re-lays the docks out + (`4542-4546`). A pane first shown before its managed window is laid out — a `wxDefaultSize` window is + 20×20 until its first sizer layout (`references/sizers-layout.md`) — docks at that width or its + `min_size` and stays there. Hiding a dock's last shown pane empties it, and the next `Update()` + removes it (`2733-2738`) with any `dock_size` a `LoadPerspective` restored, so the pane comes back + at its `best_size`. - **Names** [source]. A duplicate `Name()` hits `wxFAIL` (silent in Orca); duplicate and empty names get a random identifier (`src/aui/framemanager.cpp:949-1010`), which `LoadPerspective` can never match. `AddPane` rejects a null window, but its "already managed" check looks up `paneInfo.window` @@ -953,11 +964,12 @@ widgets beside the canvas (a sibling, not a child over it), or in a popup. - `wxAuiNotebook` (not used by Orca): page indices are logical; use `GetPagePosition()` for on-screen order (`docs/changes.txt:121-126`); the default tab art changed (`35-37`). -**OrcaSlicer.** `Plater::priv` owns `AuiMgr m_aui_mgr`, a `wxAuiManager` whose `CreateFloatingFrame` -returns `FloatFrame : wxAuiFloatingFrame` (applies `UpdateFrameDarkUI`). -- Setup: `SetManagedWindow(q)` (the Plater), `SetDockSizeConstraint(1, 1)`; on Wayland clears - `wxAUI_MGR_ALLOW_FLOATING` (`disable_wayland_floating`); dock-art metrics set here, colours in the - dark-mode update (`Plater::priv::apply_color_mode`). +**OrcaSlicer.** Orca's docks use `AuiMgr` (`AuiMgr.hpp`), a `wxAuiManager` whose `CreateFloatingFrame` +returns a `wxAuiFloatingFrame` that applies `UpdateFrameDarkUI`. `init(window)` calls +`SetManagedWindow(window)` and `SetDockSizeConstraint(1, 1)`, clears `wxAUI_MGR_ALLOW_FLOATING` on +Wayland and sets the dock-art metrics and colours; `apply_color_mode()` re-colours captions, sashes and +borders and is called from the owner's theme-switch handler. `Plater::priv::m_aui_mgr` manages the +Plater (Prepare and Preview); `DesignPanel::m_aui` manages the Design tab's body below its toolbar. - Panes: `"sidebar"` (left, no close button, not top/bottom dockable, `BestSize` in em units), `"uv_editor"` (right, hidden; forced hidden again after a layout load), `"main"` (`CenterPane().PaneBorder(false)`, holding `panel_3d` with View3D, Preview and AssembleView in one @@ -966,8 +978,8 @@ returns `FloatFrame : wxAuiFloatingFrame` (applies `UpdateFrameDarkUI`). `window_layout` app-config string goes through `sanitize_window_layout_for_wayland` (strips floating/floatable bits on Wayland) and `LoadPerspective(layout, false)`, falling back to the default on failure; then one `Update()`. `Plater::priv::reset` saves `SavePerspective()` back to - `window_layout`. An idle handler copies the docked sidebar width into `BestSize` (wxAUI does not record - a dragged sash there), so the width persists. `Plater::priv::reset_window_layout` is the model for + `window_layout`. `AuiMgr::track_docked_size` copies a docked pane's size into `BestSize` on idle + (wxAUI does not record a dragged sash there), so the width persists. `Plater::priv::reset_window_layout` is the model for re-establishing panes after a load: it loads the default perspective, then re-shows each plugin pane the perspective does not list. - Plugin panes: `Plater::add_dock_pane(window, name, caption, dock, size, on_close)` (window must be a @@ -977,13 +989,29 @@ returns `FloatFrame : wxAuiFloatingFrame` (applies `UpdateFrameDarkUI`). `Destroy()`s without running `on_close`; `show_dock_pane` toggles and updates. Pane names come from `plugin_pane_name` (`DockPanel.cpp`), which replaces `|`, `;`, `=`, `\` — the perspective-string delimiters. +- Other pages: a page outside the Plater docks through its own `AuiMgr` member, since the Plater's + manages only the Plater. Its managed window is a body panel below the page's toolbar; a sidebar + takes `AuiMgr::sidebar_pane_info()`; the layout has its own app-config key, is reset from + `Plater::reset_window_layout`, and is saved by a `shutdown()` called from `MainFrame::shutdown`, + which also detaches a floating pane (Lifetime below). Where floating is disabled it forces + `Dock().Floatable(false)` after loading, as plugin panes do. A `LazyPage` builds its panel at 20×20 + and lays it out only as the tab shows, so the sidebar stays hidden until the tab is first shown (Dock + size above). `DesignPanel` is the model + (`docs/HLSD/design-tab.md`). A `GLCanvas3D` beside such a sidebar gets that sidebar's collapse + button with `set_collapse_toolbar`; without it the canvas falls back to the Plater's button, which + collapses Prepare's sidebar. - Lifetime: no explicit `UnInit`. `m_aui_mgr` is a `Plater::priv` member, so it is destroyed in `~Plater`, before `~wxWindow` destroys the Plater's children (`wxWindowBase::DestroyChildren`, `src/common/wincmn.cpp:586-609`); a floating frame is such a child (`CreateFloatingFrame(m_frame, …)`, `src/aui/framemanager.cpp:3151`), so a pane still floating at that point outlives the manager [source]. - Plugin panes are removed earlier (`MainFrame::shutdown` → `Plater::remove_dock_panes`). The sidebar - `GetPane()` reference captured by the idle lambda is safe across `AddPane`; never `DetachPane` a pane - whose reference is captured. + Plugin panes are removed earlier (`MainFrame::shutdown` → `Plater::remove_dock_panes`). A handler + that can outlive its pane looks the pane up when it runs, as `track_docked_size` does: an idle + handler bound on the pane's window outlives the `DetachPane` in `DesignPanel::shutdown`. Never detach + a pane whose `GetPane()` reference is captured (Pane references above). `DesignPanel::shutdown` is + the model for a member manager: it detaches a + floating sidebar, which `Destroy()`s the frame into the pending-delete list, and the main frame's + deletion deletes pending top-level children before its own children (`~wxTopLevelWindowBase`, + `src/common/toplvcmn.cpp:67-93`), while the panel and its manager are still alive [source]. **Pitfalls.** - **Rule:** Call `Update()` once after a batch of `AddPane`/`wxAuiPaneInfo` changes. diff --git a/.claude/skills/orca-wxwidgets/references/wx-33-changes.md b/.claude/skills/orca-wxwidgets/references/wx-33-changes.md index 4a292d89af..3af66805c9 100644 --- a/.claude/skills/orca-wxwidgets/references/wx-33-changes.md +++ b/.claude/skills/orca-wxwidgets/references/wx-33-changes.md @@ -198,7 +198,7 @@ change, its line, and what it means for Orca. | `wxAuiGenericTabArt` subclasses (also via `wxAuiMSWTabArt`) override `DrawPageTab()`/`GetPageTabSize()` instead of `DrawTab()`/`GetTabSize()`; direct `wxAuiTabArt` subclasses still work | :115-120 | none: no Orca tab art | | `wxAuiNotebook` page index is logical (reorder-independent); `GetPagePosition()` gives the screen position | :122-126 | none; index math under `wxAUI_NB_TAB_MOVE` is what breaks | | `wxListbook`/`wxChoicebook` interpret mnemonics in page titles "just as the other wx*book classes already did" | :128-130 | Orca uses neither (`BedShapeDialog` uses `wxSimplebook` + a combo); every book interprets `&` (`interface/wx/bookctrl.h:141-147`) → rule 11 | -| `wxAUI_MGR_HINT_FADE` is not in the default `wxAuiManager` style | :132-133 | Plater's `AuiMgr` keeps the default flags (minus `wxAUI_MGR_ALLOW_FLOATING` on Wayland, `Plater::priv::priv`), so the docking hint no longer fades; add the flag if wanted | +| `wxAUI_MGR_HINT_FADE` is not in the default `wxAuiManager` style | :132-133 | Orca's `AuiMgr` keeps the default flags (minus `wxAUI_MGR_ALLOW_FLOATING` on Wayland, `AuiMgr::init`), so the docking hint no longer fades; add the flag if wanted | | `wxPrintDialogData::SetAllPages(false)`/`SetSelection(false)` changed meaning | :135-137 | none | | `wxGetTranslation()` returns `wxString` by value | :139-142 | pitfall below | | `wxWindow::Raise()` no longer shows a hidden window on any port | :144-146 | pitfall below; ba867cc534 | diff --git a/docs/CAD/design/SKETCHER_TOOL_ARCHITECTURE.md b/docs/CAD/design/SKETCHER_TOOL_ARCHITECTURE.md new file mode 100644 index 0000000000..a9659ac4b0 --- /dev/null +++ b/docs/CAD/design/SKETCHER_TOOL_ARCHITECTURE.md @@ -0,0 +1,1163 @@ +# 2D sketcher tool architecture — the batch series + +The refactor target for `DesignSketchTool`'s tool layer: a flat `Mode` enum of 36 ids plus one +event `switch` per tool becomes **ToolManager / InteractiveContext / ModalOperator**. + +Why the file exists: batches 1–8 were delivered in-session and only their cross-cutting results +were recorded (the six archetypes and the two defect classes, below). From batch 9 on the design +lives here, so an implementer who was never in the room can build it. + +Every claim about the current code carries a `file:line` from `src/slic3r/GUI/CAD/`, verified by +reading the tree. Claims I did not verify are marked **UNVERIFIED**. + +--- + +## 0. Cross-cutting results (batches 1–8) + +### 0.1 The three components + +| Component | Owns | Replaces | +|---|---|---| +| **ToolManager** | activation + a *suspension stack* (`HUDEdit` > `CameraSuspend` > tool), the ESC ladder, key routing **by content** not by focus holder, the sticky `ArmState` the Construction toggle needs | `m_mode` + `set_tool()` + scattered `is_*_mode()` predicates | +| **InteractiveContext** | one `SolveSnap(ray, plane, targets, SnapPolicy)` (const, allocation-free), `PickTopology(ray, TopAbs_ShapeEnum)`, an RAII overlay registry | three inconsistent snap paths — circles/arcs use raw `screen_to_plane`, arcs snap only their endpoints | +| **ModalOperator** | one FSM + one `Params()` block per tool; Tab order **is** the array order | per-tool `switch` arms and hand-written `m_awaiting_*` chains | + +### 0.2 The two defect classes (both belong in the **base** operator) + +1. **Silent no-op** — a failed solve returns `{}` and the anchors are cleared anyway. Three + collinear points create nothing (`DesignSketchTool.cpp:2748` + `:10942`); a minor point on the + major axis creates nothing (`:2995`); `set_slot` bails on a degenerate direction or a rebuild + that changed the entity count (`:2081`, `:2089`). ⇒ a `Refused` state that **keeps** the + anchors, names the reason, and resumes the moment a mouse move makes a solution exist. +2. **Silent clamp** — the kernel replaces the user's number with a bound and the label then shows + a value nobody typed. Ellipse `a ≥ b` (`:1887`), arc slot `w < Rc` (`:2042`), polygon + circumradius from a side (`:1548`). ⇒ `WriteOutcome{Ok, ClampedTo, Refused}` returned by every + parameter write, so the operator can name the bound. + +### 0.3 Archetypes (one per batch) + +`NSolveOperator` (multi-click, possibly-unsolvable last click) · `DerivedDimOperator` (label value +≠ kernel field) · `CoupledAxisOperator` (parameters not independent) · `UnboundedChainOperator` +(no closed form, explicit terminate) · `ProvisionalPlacementOperator` (modal input → provisional +feature → confirm/cancel) · `DeriveOperator` (a pick sequence over foreign topology). + +Batches: **1** Line/Polyline/Point/Construction · **2** Rect×4 · **3** Circle×3 + Arc×3 · +**4** Slot/ArcSlot/Polygon · **5** Ellipse/EllipseArc · **6** Spline(+edit) · **7** Text/SVG · +**8** Project/Construction/Intersection · **9** Select/Measure · **10** Dimensioning · +**11** Constraints · **12** Modification · **13** Dress-up · **14** Transform · then Phase 2 (3D). + +Designed so far, below: 9, 10, 11, 12, 13, 14 — the 2D series is complete. + +--- + +## Batch 9 — Select and Measure + +Two tools that look unrelated and share one root: **a query with no document mutation is still a +tool, and its state is still an FSM** — but this codebase has no place to put a non-mutating +tool, so both were built as side effects of something else. + +### 9.1 The architectural claim: Select is not a tool + +`Mode::Select` is a `Mode` value, but nothing about it behaves like one: it has no anchors, no +parameter block, no commit step, and every other tool *returns to it* +(`DesignSketchTool.cpp:354` `set_tool(Mode::Select)`, `:113` `begin(plane, Mode::Select)`). +It is the InteractiveContext's **rest state plus a selection model**, and the selection model is +what is missing. Today three unrelated pick paths write three unrelated stores: + +| Path | Writes | Scope | +|---|---|---| +| `handle_solid_click` (`:3470`) | `m_solid_sel, m_sel_body, m_sel_face, m_sel_edge, m_sel_vertex_pt` — **one triple** | solid topology | +| Select-mode sketch branch (`:10355`+) | `m_selection` (`std::vector`), `m_point_sel` (`vector>`) | live sketch | +| `pick_bodies_in_rectangle` (`:3298`) | `select_body()` → the same single body slot | whole bodies | + +`DesignPanel::offer_selection_kind()` (`:6084`) then *reconstructs* a type from those stores, and +it can never return `OfferSel::Bodies2`: the enum member exists (`DesignOffer.hpp:27`) and +**nothing in the tree produces it** (`grep -rn Bodies2 src/` → the enum line only). So Union, +Subtract, Intersect and Interference — the four verbs whose `accepts` mask is exactly +`offer_bit(Bodies2)` — are permanently unreachable from a viewport selection, and +`pick_bodies_in_rectangle`'s own comment says why: *"the body with the most samples inside wins +because the selection callback downstream carries exactly one body… Real multi-body selection (and +the homogeneous-set rule that goes with it) is 9xw"* (`:3295-3297`). + +**Verdict.** Select is the InteractiveContext's selection model, and batch 9 builds it. The +`Mode::Select` enum member survives only as the name of the rest state. + +### 9.2 Verified current behaviour (what an implementer must not break) + +Solid picks, in precedence order, measured in **screen pixels** (`:3387-3392`): +vertex 11 px beats edge 8 px beats face; on a narrow face both tolerances shrink to a third of the +face's shorter on-screen side, so a 3 mm plate's middle stays reachable (`:3443-3450`). +`resolve_solid_pick` is **const** and writes only into its out-param — that is what makes hover +safe. Clicking the same sub-element twice (**at the level that was picked**, `:3507-3518`) +escalates to the whole body; the comment at `:3491-3497` argues the escalation is announced by the +status line before the click (I have not traced that announcement to its status text). +Whole bodies also come from the Parts list and from the rubber band. + +The escalation is gated by a boolean the **panel** sets — `set_escalate_on_repick` +(`DesignSketchTool.hpp:108`, `:1192`), flipped in nine places (`DesignPanel.cpp:3878`, `:3889`, +`:3912`, `:6718`, `:6725-6730`, `:6764`, `:6771`, `:6826`, `:6839`), with the clearest comment +being *"While a pick is armed, a click must CAPTURE the face under the cursor, never escalate"* +(`:6725-6730`). That is a fourth store of *what a click means*, readable only by whoever set it +last — the disease this batch exists to cure, in one flag. + +Rubber band (`:3286-3335`, `:10011-10046`): a left-drag past an 8 px budget starts +`GLSelectionRectangle` in state `Select`; on release every sample point **inside** the rectangle +counts (crossing semantics, comment at `:3296`), and the body with the most samples wins. + +Sketch picks (`:10355`+): a point hit grabs the point and arms a drag; an entity hit selects with +Shift/Ctrl to toggle; a double-click adds `connected_loop(hit)`; a plain click on a constraint +badge **deletes that constraint** (`:10370-10378`); a click on a dimension label opens its editor +or promotes a live quote (`:10460-10475`); a click inside a closed region calls +`on_face_selected(reg)` (`:10608-10614`); empty space clears the selection. + +Hover: `update_solid_hover` (`:3530`) pre-highlights what a click *would* take — verified on the +rig 2026-08-14 (snaporca-9xw notes). For **sketch** geometry there is no equivalent: +`update_hover` (`:870`) tracks handles only — `hit_test_handle` — and the sole hover member is +`Handle m_hover_handle` (`DesignSketchTool.hpp:1017`). No `m_hover_entity` exists. + +Keyboard: `DesignSketchTool` has **no key handler at all**. Esc, Del/Backspace, undo and the offer +key arrive at `DesignPanel`'s char hook (`:4231`, `:4260`, `:4243`) and route through +`DesignPanel::escape()` (`:11401`) — the four-level ladder in +`docs/CAD/ux/interaction-model.md`. + +Camera: both the pick (`:3386`) and the rubber band (`GLSelectionRectangle.cpp:41`) resolve the +camera as `wxGetApp().plater()->get_camera()`. In the Design tab this is the *right* camera only +because `DesignCanvas` swaps its own camera with the plater's on enter/leave +(`DesignCanvas.hpp:73-77`, `:361`, `:376`) — a dependency, not a coincidence to build on. + +Cost: `resolve_solid_pick` calls `GeometryEngine::edges_of_face` and `sample_edge_world` (each +returning a fresh `std::vector`) and runs per motion event through +`update_solid_hover`. It is bounded to one face, not the whole body, but it allocates on the +pointer path. + +### 9.3 The selection model — blueprint + +New header, `src/slic3r/GUI/CAD/DesignSelection.hpp`. Everything below is a value type; nothing +owns a GL resource, so the whole model is testable without a canvas. + +```cpp +enum class RefKind : uint8_t { + None, Face, Edge, Vertex, Body, DatumPlane, DatumAxis, CoordSys, Art, SkEntity, SkPoint, SkRegion +}; + +// The FINE class — one level below RefKind, because the offer vocabulary splits what RefKind +// joins: a Face is planar, cylindrical or other; an Edge is straight or circular. This is +// OfferSel (DesignOffer.hpp:19), and classify() is the logic that today lives split between +// DesignPanel::offer_selection_kind() (:6084) and GeometryEngine::circle_of_edge/_cylinder_of_face. +RefClass classify(const Ref&, const CadDocument&); +using RefClass = OfferSel; // no second vocabulary: the offer table's own, from DesignOffer.hpp:19 + +// A reference to one thing, plus the generation it was picked at. Ids expire on every +// recompute (CadDocument::topo_generation); a stale ref must REFUSE, never guess. +struct Ref { + RefKind kind{RefKind::None}; + int32_t body{-1}; // body index, or the sketch feature index for sketch refs + int32_t item{-1}; // face id / edge id / entity index / region id + int32_t role{-1}; // SketchPointRole for SkPoint picks, else -1 + uint32_t generation{0}; +}; + +struct Pick { + Ref ref; + Vec3d point; // the geometry the pick carried (vertex, edge midpoint, face centroid) + Vec3d dir; // edge direction / face normal; zero when the ref has none + bool has_dir{false}; +}; + +enum class SetOp : uint8_t { Replace, Add, Toggle, Remove }; + +// What a tool will accept as its picks. A set is NOT globally homogeneous: 73 of the 86 offer +// verbs accept more than one class (measure accepts twelve, transform nine, delete nine) because +// the mask answers "what can start this verb", not "what one set may contain". So homogeneity is +// a per-tool POLICY, and Measure's is a pair of possibly-different classes, not a set. +struct PickPolicy { + uint32_t accepts{0}; // the same OfferSel bitmask the offer row already carries + uint8_t max_picks{1}; // 1 = single, 2 = a pair (Measure, Mate), 0 = unbounded (Fillet) + bool homogeneous{true}; // every pick shares the class (Fillet) or not (Measure, Delete) +}; + +class SelectionSet { +public: + RefKind kind() const; // RefKind::None while empty + RefClass class_of() const; // the class of front(); a mixed set reports its primary's + bool empty() const { return m_refs.empty(); } + size_t size() const { return m_refs.size(); } + bool contains(const Ref& r) const; + // Ordered: front() is the PRIMARY pick (the anchor), back() the most recent. Two-ref + // queries (Measure, and later Mate) read front()/back() rather than sorting by distance. + const Pick& front() const; + const Pick& back() const; + // Returns what happened, so the caller can say it: refused a stale ref, refused by the + // policy, replaced, added, removed. Silence here is the same disease as a silent clamp. + enum class Outcome : uint8_t { Added, Removed, Replaced, RefusedStale, RefusedPolicy }; + Outcome apply(const Pick& p, SetOp op, const PickPolicy& policy, uint32_t live_generation); + void clear(); +private: + RefKind m_kind{RefKind::None}; + std::vector m_refs; // bounded by policy.max_picks (0 = the sketch-select case) +}; +``` + +Why this shape and not a `std::variant` per kind: the whole tab reads selection through one +question — *which offer rows are live* — and that question is answered by one bitmask over one +vocabulary. A variant would move the vocabulary into the type system and then re-derive the +bitmask at every call site. + +`OfferSel` is also the *only* place that carries cardinality (`Sk2Ent`, `Bodies2`) — a count +folded into a kind vocabulary, which is why `offer_selection_kind()` (`DesignPanel.cpp:6084`) +must collapse `n >= 2` into `Sk2Ent` and loses what the two things are. With `size()` on the set +and the class on the refs, those two enum members retire. + +The two existing stores become projections of this one: `m_selection` (entity indices) is +`items(kind() == SkEntity)`, `m_point_sel` is `items(kind() == SkPoint)`, and the solid triple is +`front()` + `size() == 1`. `offer_selection_kind()` turns into one non-reconstructing switch: + +```cpp +// One place, exhaustive, no inference from three stores. +int offer_kind_from(const SelectionSet& s); // Bodies2 when kind()==Body && size() >= 2 +``` + +### 9.4 Select — FSM + +Select is the rest state, so its FSM has no `Armed` and no `Commit`; it is a **hover + press +machine**. Budget: `kClickPx = 8` (`:10029`). + +| State | Meaning | Event | Guard | Action | Next | +|---|---|---|---|---|---| +| `Idle` | nothing selected | `Move` | over topology | solve pick, store promise, repaint | `Hover` | +| `Hover` | a promise is on screen | `Move` | candidate changed | update promise | `Hover` | +| | | `Move` | left the geometry | clear the promise, repaint | `Idle` | +| | | `LeftDown` | always | latch (`x,y`, remember the promise), **fall through so the canvas may orbit** | `PressPending` | +| `PressPending` | press latched, click vs band undecided | `Drag` | `max(|dx|,|dy|) > 8` | start the band at the ORIGINAL press point | `Band` | +| | | `LeftUp` | — | commit the pick (below) | `Idle` | +| `Band` | rubber band sweeping | `Drag` | — | move the rectangle | `Band` | +| | | any button-up event | — | resolve bodies, stop the band | `Idle` | +| `Grab` | a point/handle was grabbed | `Drag` | left down | move + live re-solve | `Grab` | +| | | `LeftUp` | — | release the grab | `Idle` | +| `Region` | clicked inside a closed loop | — | — | hand to `on_face_selected`, which owns the commit | `Idle` | + +Commit rule (the whole of "what did that click mean"): + +``` +pick = hit_test_point(tol) -> SkPoint, arm Grab + || handle (derived grips) -> SkEntity + Grab + || hit_test(topology) -> Vertex | Edge | Face (solid) + || hit_test(entity) -> SkEntity + || constraint badge -> delete the constraint (plain click only) + || dimension label -> open/promote the value editor + || region -> Region + || nothing -> clear +``` + +Escalation stays exactly as it is: pressing the *same ref* twice at the level that was picked +escalates to its body — one comparison on `Ref.kind/body/item`, no field-tuple equality. + +ESC ladder: `Idle` = `clear()` (the floor — never exits the session, `DesignInteraction.hpp` +invariant). `Hover`/`Band` are not ladder levels: a band is cancelled by releasing the button, +and a band is not a "gesture" in the `CadLevel::Gesture` sense because nothing has changed yet. + +### 9.5 Select — event routing and focus handshake + +| Event | Canvas | Tool | Panel / char hook | Camera | Notes | +|---|---|---|---|---|---| +| `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`) | +| `LeftUp` | — | commit pick **or** resolve band | — | — | `:10035-10041` | +| `MiddleDrag` / `RightDrag` | — | must not see it | — | orbit / pan | camera gestures never reach the FSM | +| `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` | +| `Menu` / `Shift+F10` | — | — | `show_offer_menu(offer_anchor())` | — | `:4243` | +| `Wheel` / SpaceMouse | — | must not see it | — | zoom / fly | never through `wxMouseEvent` | + +The invariant the matrix encodes: **hover rules, camera rules, and the pick rules — and each of +the three consumes only what it owns.** A band that starts on a camera gesture is the classic +failure; so is a tool that eats the press and silently kills orbit. + +### 9.6 Select — the five defects to fix, and how the architecture prevents them + +1. **No sketch hover pre-highlight.** Add `std::optional m_hover` to the context, filled by + the same const `hit_test` the click uses, drawn by the overlay registry. Solids already prove + the pattern; the asymmetry is what makes precedence unlearnable in a sketch. +2. **Rubber band has no direction.** `GLSelectionRectangle::EState` is `{Off, Select, Deselect}` + (`GLSelectionRectangle.hpp:15-19`) — no topology. Add `bool is_crossing() const` returning + `m_start_corner.x() > m_end_corner.x()` (one read of existing members) and use the **same + sample set with two predicates**: LTR = every sample inside (enclosed), RTL = any sample inside + (crossing). No new sampling, no change to the plater's own use of the class. +3. **One body, because the callback carries one.** Make the callback carry a `SelectionSet` and + delete the "most samples wins" rule. `Bodies2` then becomes producible, which unblocks Union / + Subtract / Intersect / Interference in the offer — four verbs that today cannot be reached. +4. **The camera is looked up, not passed.** `solve_pick(const Camera&, …)` and a band that takes + the camera as an argument. Today both work only because `DesignCanvas` swaps the plater's + camera on enter; the day a second canvas is on screen the promise and the click disagree. +5. **The pick allocates on the pointer path.** `sample_edge_world` returns a fresh vector per + edge per motion event. Give the context a reusable scratch buffer (`std::span` out-param) — + the same allocation rule the tool blueprints already carry. + +### 9.7 Measure — the archetype: `TwoRefQueryOperator` + +A tool that mutates nothing, has no commit, and cannot fail — and therefore has no home in this +codebase. Current state, verified: + +- Offer row `{"measure", "Measure", 6, …}` (`DesignOffer.hpp:120`) has `action == nullptr` and + `refusal == nullptr`. The table's own contract (`DesignOffer.hpp:43-48`) is *"nullptr → kernel + support exists, no GUI path yet (row shows disabled)"*, so the row renders as + **"Measure (no GUI route)"** and is permanently disabled (`DesignPanel.cpp:6519`). With no + refusal in the row, it contributes nothing to the family's "why" line either. +- The maths exists, for MCP callers only: `resolve_ref` + `measure` (`McpControl.cpp:522`, `:546`) + — refs `{face|edge|point}`, distance = `|pa - pb|` of **representative points** (face centroid, + edge mid-sample), angle = `acos(da·db)` when both refs carry a direction. +- The correct OCCT primitive is already linked and already used: `BRepExtrema_DistShapeShape` + (`GeometryEngine.cpp:31`, `:457`). +- The Prepare tab ships a full measure gizmo — `GLGizmoMeasure` (`src/slic3r/GUI/Gizmos/`) over + `Measure::SurfaceFeature{Point, Edge, Circle, Plane}` (`libslic3r/Measure.hpp:16`). It is + **mesh/triangle-based** (a Plane feature is a triangle index + normal + point) and bound to the + plater's model objects. `DesignSketchTool.cpp:6959` already borrows its *label* style and + nothing else. + +**Verdict.** Measure must not be `measure()` wired to a row. `measure()` is a centroid proxy: for +point↔face the honest answer is the perpendicular distance to the plane, for two skew edges the +minimum distance between the segments, for a cylindrical face the radius, for two parallel faces +the material thickness — none of which is "distance between two chosen points". The proxy is right +for exactly the case where both refs are points, which is the case a user reaches last. + +### 9.8 Measure — engine, blueprint + +Kernel addition (`GeometryEngine`, next to `surface_deviation`): + +```cpp +struct MeasureResult { + enum class Kind : uint8_t { PointPoint, PointLine, PointPlane, LineLine, LinePlane, + PlanePlane, SameCircle, Mixed }; + Kind kind{Kind::Mixed}; + bool ok{false}; + double distance{0.0}; // BRepExtrema on the two sub-shapes; exact, not a proxy + Vec3d witness_a, witness_b; // PointOnShape1/2 — the line the UI draws + bool has_angle{false}; + double angle_deg{0.0}; // between directions (edge dir / face normal), 0..90 folded + bool has_intrinsic{false}; + double intrinsic{0.0}; // a lone pick: length / radius / diameter / area + const char* refused{nullptr}; // never silent: why no measurement exists +}; +MeasureResult measure_refs(const TopoDS_Shape& a_shape, const TopoDS_Shape& b_shape, + const Pick& a, const Pick& b); +``` + +Rules the engine must honour: the sub-shape is materialised from the ref (`TopoDS_Shape(face)` / +`TopoDS_Shape(edge)`; a vertex from `Pick.point`), so a face-to-face measurement is a *shape* +measurement. `distance` is always the **minimum** distance; `angle` is reported separately and +folded to 0–90° (a CAD user does not care which side of 180° the normals are on); a stale ref +(`Pick.ref.generation != live`) refuses with a reason instead of measuring a moved face. + +Tool state (`ModalOperator`, no mutation, no commit): + +| State | Event | Guard | Action | Next | +|---|---|---|---|---| +| `Idle` | `Move` | over a ref | hover promise, same const pick path as Select | `Hover` | +| | `LeftDown` | on a ref | `apply(Replace)` | `OneRef` | +| `OneRef` | `Move` | over a second ref | preview the two-ref result live | `OneRef` | +| | `LeftDown` | on a second ref | `apply(Add)` → compute | `TwoRefs` | +| | `LeftDown` | empty space | clear | `Idle` | +| | `Esc` | — | disarm the tool (`CadLevel::Tool`) | `Idle` | +| `TwoRefs` | `Move` | over another ref | keep the result, hover the third | `TwoRefs` | +| | `LeftDown` | on any ref | restart with it as the primary | `OneRef` | +| | `Esc` | — | drop the second ref, keep the first | `OneRef` | +| | `RightClick` | — | offer menu | `TwoRefs` | + +`OneRef` is the state the current app cannot express at all: it is what makes the tool feel like +Onshape's — pick a face and you already read its area, its normal and its radius if it is a +cylinder, before the second pick exists. + +Readout, one line per available fact, never an invented one: `distance` · `ΔX, ΔY, ΔZ` (world-axis +components, computed from the two witnesses) · `angle` · a lone pick's `intrinsic`. The readout is +the same label primitive the dimension chain already draws (`render_dimensioning`, +`DesignSketchTool.cpp:6959`), so it costs one more caller, not one more renderer. + +### 9.9 Measure — event routing + +| Event | Tool | Panel | Camera | Notes | +|---|---|---|---|---| +| `Move` | hover promise + live preview | — | passthrough | identical to Select's hover rule | +| `LeftDown/Up` | pick, extend, restart | — | orbit may begin on press | same press-fallthrough as Select | +| `Esc` | `TwoRefs→OneRef→Exit` | ladder routes it | — | the ladder already handles "tool armed" | +| any key | — | swallowed while the field is open | — | a query tool must not steal letters: no tool keys while Measure is armed | +| `MiddleDrag`/`RightDrag` | — | — | orbit/pan | a 3D measurement is unreadable from one angle; the camera must stay live *inside* the tool, exactly like Select | +| MCP `measure` | same engine | — | — | the RPC becomes one caller of `measure_refs`, so the agent surface and the GUI cannot disagree | + +### 9.10 Measure — three friction points, and how the architecture prevents them + +1. **"Pick a face, get the distance to a face I cannot see."** The witness line is the only thing + that proves *which* faces were measured, and a centroid proxy cannot draw one. ⇒ + `witness_a`/`witness_b` from `PointOnShape1/2` are part of `MeasureResult`, not a UI + afterthought; the tool refuses to show a number without a line. +2. **Re-measuring after an edit silently reads the old face.** Ids expire on recompute and + nothing says so (`snaporca-o1l2`). ⇒ `Ref.generation` travels with the pick and a stale ref + refuses **with a reason in the readout**, instead of returning a number that used to be true. +3. **The unit and the coordinate frame are invisible.** A distance in mm on a rotated part is + ambiguous the moment you also show `ΔX/ΔY/ΔZ`. ⇒ components are world-axis and labelled as + such, and every number in the readout passes through the document's unit formatter (the one + the dimension chain uses), so there is exactly one formatting path in the tab. + +--- + +## Batch 10 — Dimensioning + +Archetype: **`ValueDriveOperator`** — one interaction (a label on the geometry, a number typed into +a field over it) over **three different backends**, where the backend is currently decided by which +lambda happened to be built rather than by anything the user can see. + +### 10.1 The architectural claim: there are two Dimension tools, and they disagree + +| | click-to-place (`Mode::Dimension`) | selection-based (the `sk_dimension` row) | +|---|---|---| +| entry | arm the tool, pick in the viewport (`:10651`) | select first, then apply (`DesignCanvas.cpp:1417-1430`) | +| kinds it can make | Length, Diameter, Radius, Distance, DistanceToLine | **also Angle between two lines** | +| sets the value by | `place_dimension` → `constraint_for` → `upsert_constraint` + `upsert_dimension` → live-solve, field optional (`:1160`) | `apply_dimension` → **moves the geometry first**, then `record_dimension_constraint` (`:604`, `:661`) | +| operand roles | the picked `DimAnnot.ra/rb` (`:1133`) | derived: `Point`→`P0`, anything else→`Center` (`:667`) | + +Same six `DimType` values, same labels, two code paths that resolve the same word "Distance" into +different roles and different order of operations. The click path lets the solver drive the +geometry to the number; the selection path moves the geometry to the number and then records a +constraint that asks for it. They agree whenever the sketch is free and disagree as soon as it is +constrained — which is the case the user cares about. + +### 10.2 Verified current behaviour + +**The click path's FSM is two booleans.** `m_dim_has0` + `m_dim_e0/m_dim_r0` (`:10651-10702`). +First `LeftDown`: a point hit anchors and waits; a whole-entity hit maps Line→Length, +Circle→Diameter, Arc→Radius. Second `LeftDown`: a different point → Distance; a line → DistanceToLine. +`RightDown` clears the anchor only. A double-click on a label reopens its editor (`:10653`). + +**Three silent no-ops on that path**, all verified by reading the branch: +click a Point, an Ellipse, an EllipseArc or a BSpline as the FIRST pick — the `if/else if` chain has +no `else`, the event is consumed, nothing happens and nothing is said (`:10679-10687`); +click something that is neither a point nor a line as the SECOND pick — same, and the anchor is +cleared anyway (`:10689-10699`); abort a value field and the placed dimension **keeps driving** at +its measured value (`cancel_dimension_value`, `:2174` — deliberate, but it means a cancelled +gesture has already added a constraint, and nothing says so). + +**`apply_dimension` records a poison value.** Its per-case thresholds move nothing for +`v <= 0` (Length/Diameter/Radius, `:611-613`) or `v < 0` (Distance/DistanceToLine, `:622`, `:639`) — +and then `record_dimension_constraint(v)` runs **unconditionally** (`:659`). The file's own sibling +says it best, at the socket boundary: *"a negative/zero/NaN value that moved nothing would still be +pushed to the solver as a constraint it must satisfy and cannot, silently corrupting the sketch +rather than failing. (`apply_dimension` has the same flaw for any other caller; this guard protects +the socket, not the tool.)"* (`McpControl.cpp:1572-1577`). So the agent surface validates and the GUI +does not — the GUI is the less safe caller of its own function. + +**`record_dimension_constraint` never dedupes at all.** It `push_back`s unconditionally +(`:674`, `:684`, `:691`, `:700`), so applying the selection-path dimension twice leaves **two** +constraints on the same operands — the identical defect `upsert_constraint` (`:1074`) was written to +kill on the click path (`:1056-1068`). The two paths differ in their dedupe policy as well as their +order of operations. + +**`Distance` changes type when the number is zero, and the change is not reversible in place.** +`constraint_for` and `record_dimension_constraint` both emit `Coincident` for `v < 1e-9` +(`:1146`, `:688`); `upsert_constraint` matches on `type` (`:1078`), so type `0`, then type `5`, and a +fresh `Distance` is appended beside the `Coincident` — over-constrained by construction, which is +exactly the failure the `upsert_constraint` comment (`:1056-1068`) says was fixed for duplicates. + +**`DistanceToLine` cannot express a side.** `measure_dim` returns `std::abs(...)` (`:1085`) and the +solver emits `SLVS_C_PT_LINE_DISTANCE` with `std::abs(c.value)` (`SketchSolver.cpp:319-323`), so the +sign is discarded at both ends: the user's number cannot say which side of the line the point is on, +and the solver takes the nearer one. (`apply_dimension` does keep the side, but only when it is the +one moving the geometry — `:645-648` — so the two paths disagree here too.) + +**The label is never re-measured after a solve.** `m_dimensions[i].value` is written by +`place_dimension` (`:1162`), by `set_dimension_value` (`:2167`) and by the chain's lambda (`:1426`) +and by nothing else; `dim_text` prints `a.value` (`:2179`). The only `a.value = measure_dim(a)` in +the file is the auto-edit chain re-measuring a *live quote* before it becomes a step (`:1423`). +So when the solver refuses, the label shows the number you typed while the geometry is unchanged — +the same "the number changed and nothing moved" reading the duplicate-constraint comment describes, +now caused by conflict instead of duplication. The feedback that does exist is aggregate: +`apply_dof_status` prints "✗ Conflicting constraints" or "N degrees of freedom" for the whole sketch +(`DesignPanel.cpp:4426-4445`), and `m_entity_conflict` tints per entity (`:8765`) — **no dimension is +ever named**. + +**One field, two backends.** `AutoEditStep{label, value, apply(v), hi, title}` (`DesignSketchTool.hpp:982`) +carries a `std::function apply` and nothing else: no outcome, no kind, no target. +The chain fills it either with a solver write (`upsert_constraint(constraint_for(a))`, `:1424-1430`) +or with a direct geometry write (`set_line_angle`, `set_polygon_side`, `set_ellipse_axis`, +`open_rounded_rect_editor`, …), and the two are indistinguishable on screen. Memory 1635 records +why the split exists: libslvs has no constraint for a polygon's side count or an arc's sweep, so +those dims are free by construction. + +**Focus is the known-broken part.** The field is a borderless always-on-top frame; the window +manager may refuse it key status (`projects-1p5` is the open bug: the activation request still goes +out with timestamp 0), so `DesignPanel` forwards Enter/Tab and routes Esc (`:4220-4232`, `:4231`). + +**Dead residue:** `DesignCanvas::{pending_dimension_type, set_sketch_dimension_value, +cancel_sketch_dimension}` (`DesignCanvas.hpp:281-283`, defined `:1467-1481`) have no callers — +the retired value-card era. The live path is `open_value_editor` → `on_inline_edit` (installed at +`DesignCanvas.cpp:116`) → the field's own closures. + +### 10.3 The dimension as a first-class value (blueprint) + +```cpp +// One dimension = one target + one value + one explicitly named way of driving it. +enum class DriveKind : uint8_t { + Solver, // a libslvs constraint: Distance / Radius / Diameter / Angle / PointOnLine + Geometric, // no solver constraint exists: the number writes the geometry directly + Reference // measured only; never drives. Today's m_live_quotes. +}; + +struct Dimension { + DimType type{DimType::None}; + DriveKind drive{DriveKind::Solver}; + Pick target; // batch 9's Ref + the geometry it carried — one vocabulary + Pick second; // Distance / DistanceToLine / Angle: the other operand + double value{0.0}; // what the user asked for (Solver/Geometric) + double measured{0.0}; // what the geometry says NOW, refreshed on every solve + int con{-1}; // slot in m_constraints when drive == Solver + // Labels and refusals share one channel so the field can re-open holding the typed text. + WriteOutcome last{WriteOutcome::Ok}; +}; +``` + +Three rules the type enforces, each closing a defect above: + +1. **`measured` is refreshed on every solve; `value` is what was asked for.** The label renders + `value` only while `|value - measured| <= tol`; otherwise it renders `measured` in the refusal + colour with the requested value as the secondary text (`30.0 → 24.6`). A lie becomes a + contradiction you can see. `drive == Reference` renders `measured` only, and the palette is the + one already in the tab (green quote = measured, driving colour = driven). +2. **`apply(value)` returns a `WriteOutcome`** (`Ok | ClampedTo | Refused`, the base-operator type + from §0.2) — the same type for the solver path, the geometric path and the socket. The socket + guard already written in `McpControl.cpp:1572-1577` moves *into* `apply`, so the GUI cannot be the + unsafe caller of its own function any more. `Refused` keeps the field open with the typed text + and the reason, which is also what makes the `Coincident`-flip reversible: the dimension keeps + its `type` and only its value changes. +3. **One dimension per (type, operand pair) lives in one `std::vector`**, and the + constraint slot is a *projection* of it (`upsert` semantics from `:1074-1130`, kept). The click + path and the selection path both become thin callers of one `Dimension` builder, so the operand + roles (`ra/rb` vs derived `Center`) are chosen once. + +### 10.4 Dimension — FSM (the place sequence, one machine for both entry points) + +Budget: pick tolerance as Select (`hit_test_point` / `hit_test`). `Tab` cycles the chain's steps, +`Enter` commits and advances, `Esc` steps the ladder. + +| State | Event | Guard | Action | Next | +|---|---|---|---|---| +| `Idle` | `LeftDown` | on a point | anchor it | `Anchor` | +| | `LeftDown` | on a Line / Circle / Arc | create Length / Diameter / Radius and open the field | `Editing` | +| | `LeftDown` | on anything else | **refuse out loud**: "no dimension for an ellipse yet — pick a line, arc, circle or two points" | `Idle` | +| `Anchor` | `LeftDown` | on a different point | create `Distance` | `Editing` | +| | `LeftDown` | on a Line | create `DistanceToLine` (signed; the side is the one the point is already on) | `Editing` | +| | `LeftDown` | on anything else | refuse out loud, keep the anchor (the user can still pick a valid second target) | `Anchor` | +| | `RightDown` / `Esc` | — | drop the anchor | `Idle` | +| `Editing` | `Enter` / `Tab` | value parses | `apply` → `Ok` ⇒ commit, advance the chain (`Tab` skips forward only) | `Idle` / `Editing` | +| | `Enter` | `apply` → `Refused`/`ClampedTo` | keep the field open with the typed text + the reason | `Editing` | +| | `Esc` | — | keep the dimension at `measured` (today's rule) but **report it**: one status line, because a cancelled gesture that added a constraint must say so | `Idle` | +| | `LeftDown` outside | — | commit as `Enter` | `Idle` | +| `Re-edit` | double-click a label | — | reopen the field on that dimension (solver-backed ones re-open on `value`, geometric ones on `measured`) | `Editing` | + +Hover: Dimension inherits Select's hover promise (§9.6 defect 1) — over a line it must say +*Length*, over a circle *Diameter*, in `Anchor` over a second point *Distance*. Without that, the +first click of a two-click gesture is a guess. + +### 10.5 Dimension — event routing and focus handshake + +| Event | Tool | Value field (canvas-hosted) | Panel char hook | Camera | +|---|---|---|---|---| +| `Move` | hover promise + highlight the candidate (`hi` span) | — | — | passthrough | +| `LeftDown` | pick / advance | **must not reach the tool** — a click outside commits | — | orbit may begin | +| `Enter` / `Tab` | — | commit + advance when the field has focus | forwarded when it does not (`:4220-4232`) | — | +| `Esc` | — | close the field, keep the value (`CadLevel::Transient`) | routes it when focus is refused | — | +| a letter | — | — | **not** a tool switch while a field is open, except the sketch tool keys, which commit the field on the way through (`:4288-4293`) | — | +| `MiddleDrag`/`RightDrag` | — | — | — | orbit / pan: a dimension being placed must not trap the camera | + +The one-route rule is already written and must not be re-derived per widget: *"ONE Esc, ONE route, +whatever holds focus"* (`DesignPanel.cpp:4228-4231`). + +### 10.6 Dimension — three friction points, and how the architecture prevents them + +1. **"I typed 30 and nothing moved, but the label says 30."** The most expensive lie in the tab: + it destroys trust in every number on screen. ⇒ `value` vs `measured` (§10.3 rule 1) plus the + refusing dimension named in the status line, using the per-entity conflict flags that already + exist (`m_entity_conflict`, `:749`, `:8765`) — the information is there, only unattributed. +2. **"Is this number driving the sketch or just describing it?"** Three backends, one appearance. + ⇒ `DriveKind` is visible: the field's title says which (`Length` vs `Length · geometric` vs + `Length · reference`) and the label colour follows the existing quote palette. A user who cannot + tell a driving dimension from a measured one cannot reason about over-constraint at all. +3. **"The number I type is not the number that sticks."** Silent thresholds (`v <= 0` no-ops, + `abs()` on a signed distance, `Coincident` for zero) all discard the user's intent without a + word. ⇒ every discard returns `ClampedTo`/`Refused` with the bound, and the field re-opens with + the typed text — the same rule the base operator already applies to `set_slot` and `set_ellipse_axis` + (§0.2 class 2). The zero-distance case then needs no type flip at all: `Coincident` becomes an + *interpretation* of `Distance = 0` at solve time, not a mutation of the dimension. + +--- + +## Batch 11 — Constraints + +Archetype: **`PlannedConstraintOperator`** — the pick is generic, the meaning is planned, and the +plan is already the best-designed value type in the tab. This batch extends its reach; it does not +invent it. + +### 11.1 The architectural claim: a constraint belongs to the sketch, not to a mode + +`Mode::Constrain` is a **mode you enter** (`DesignSketchTool.cpp:2266`, `:2281`), and the pick lives +in the tool (`m_pick0/1/2`, `:10306-10327`) while the constraint list lives in **two** places: the +live session's `m_constraints` and the committed feature's `entity_constraints`, chosen by +`live_constraint_scope()` inside the card (`DesignPanel.cpp:3053-3058`). The same list, two owners, +one mode-shaped door. + +The mode itself is already half-fiction, which is why that scope switch had to be invented: +`DesignCanvas::is_sketching()` is `m_sketch_tool.is_active()` (`DesignCanvas.cpp:632`) — so a +Constrain session on a *committed* sketch reports "sketching", and the panel has to guard it with +`(m_ui_mode == UiMode::Sketch) &&` (`DesignPanel.cpp:4169`) to get the truth back. A constraint is a +property of a sketch; the mode is a way of editing one. + +Constraints are also the last large block of verbs living outside the offer. The offer has 92 rows +(`DesignOffer.hpp`) and the whole constraint vocabulary is **one** of them (`sk_constrain`, `:168`); +the other 20 are toolbar buttons (`DesignPanel.cpp:1516-1539`). Five enum members have no button at +all: `Distance` comes from the Dimension tool, `LockX`/`LockY` have solver bindings +(`SketchSolver.cpp:191`, `:240`, `:245`) and only a label (`DesignPanel.cpp:8051`), `PointOnLine` is produced only as +the *implementation* of Batch 10's distance-to-line, and `PointOnObject` has no producer — it is +built in exactly one place, the re-anchor path (`:8533`). So the offer's own promise — *"every verb +has ONE row index, that index is the same in every selection"* (`DesignOffer.hpp:4-7`) — is unmet for +20 verbs, and Batch 9's `PickPolicy` cannot describe them. + +### 11.2 Verified current behaviour (what an implementer must NOT rebuild) + +**`ConstraintPlan` is already the archetype.** `SketchEngine.hpp:181`: +`{Kind{Reject, Apply, AskValue}, reason, defs, prefill}` — pure, no wx, no translation (the caller +maps `reason`), testable without a window. `plan_entity_constraint` (`SketchEngine.cpp:2107`) is *"the +ONE place that knows how many picks a type takes"* (`DesignPanel.cpp:7891`) — including the ergonomic +retarget of `EqualLength` on two rounds to `EqualRadius` (`:2118-2121`) and the `Horizontal`/`Vertical` +refusal that exists because *"with a Point or Circle picked, P1 is a role the solver silently drops +while STORING the constraint, so the sketch claims to be constrained when it is not"* (`:2135-2142`). + +**`ConstraintReject` has thirteen reasons** (`SketchEngine.hpp:170-179`) and every one has a specific, +product-voiced message (`constraint_reject_text`, `DesignPanel.cpp:7817-7845`), several of them +type-dependent ("Angle applies between two lines" vs "Parallel, perpendicular and equal length apply +to two lines"). + +**Commit is already a rollback transaction.** `try_add_constraints` (`:2399`): append → solve → keep, +or `resize(mark)` and keep the geometry (`pl5`: the solver only writes back on success). + +**A constraint already has a visible undo.** Badges render in a live sketch (`:8798-8808`, with the +comment recording that they used to be invisible outside Constrain mode), a click deletes +(`remove_constraint_near :2418` → `remove_constraint :2426`, which re-runs `resolve_live()` so the DoF +readout and the conflict tint cannot go stale), and the success message teaches it: *"Applied +constraint · its badge is on the sketch — click the badge to remove it"* (`:7915-7917`). + +**Draw-time inference exists and is measured.** `infer_auto_constraints` (`:2452`): coincidence +(batched, no fallback — "consistent by construction"), H/V (batched, then one at a time), relations +only for batches ≤ 16 entities (`kRelInferMaxBatch`), with the reasoning recorded: `EqualRadius` +couples far-apart entities and merges the connected components the solver partitions on, which cost +main-thread timeouts on seven large sheets in the corpus rung (2026-08-31, `yww4`); and *"a SCRIPTED +ADD IS NOT A DRAWN GESTURE"* (`8xg1`). + +**The solver names the guilty constraint.** `SketchSolveResult.bad` = *indices into `constraints` of +the conflicting ones* (`SketchSolver.hpp:18`, filled at `SketchSolver.cpp:394`, `:521-522`). + +### 11.3 The three defects + +1. **The solver names the constraint and the UI throws the name away.** `r.bad` is converted to + `m_entity_conflict[e] = 1` per *entity* (`:745-751`) for a red tint, and the user is told + "Constraint rejected (over-constrained)" (`DesignPanel.cpp:7908`). On the live path the rolled-back + batch is always the *new* one, so the conflict is almost always with something already in the + sketch — precisely what `r.bad` identifies. This is Batch 10's "no dimension is ever named" + wearing different clothes: the same information, discarded at the same place. +2. **Ctrl+Z does not undo a constraint.** Documented in the code as a known limitation + (`DesignPanel.cpp:7887-7895`): the undo stack holds committed *features*, so during a sketch + Ctrl+Z calls `undo_last_sketch_entity()` (`:4252`) and deletes the last **entity** instead. The + universal undo is therefore both wrong and more destructive than the badge's undo, and the two are + not the same operation. +3. **`roles_of` exists twice and has already diverged once.** Two file-local lambdas + (`:2451` in `infer_auto_constraints`, `:9532` in `heal_coincidences`); the first was missing + `EllipseArc` while the second had it, so *"an ellipse arc's endpoints could be WELDED by the healer + but never auto-inferred coincident at draw time — the same gesture behaved differently depending on + which path ran"* (`:2455-2461`). Fixed in place; the duplication that caused it is still there. +4. *(Not a defect, but a silent policy a user meets without warning)*: relations are simply **not + inferred** when a batch is larger than 16 entities. The reason is sound and measured; the silence + is the part that needs a voice. + +### 11.4 The operator — FSM + +The pick deliberately does not know the type; the *plan* does. So the FSM asks the plan first and the +pick becomes typed: `archetype → PickN → Plan → (Reject | AskValue | Apply)`. + +| State | Event | Guard | Action | Next | +|---|---|---|---|---| +| `Idle` | button / offer row | `plan_entity_constraint(…, type)` with zero picks | `Reject` ⇒ say the reason **before** the first click ("Parallel applies to two lines"), and arm | `PickN` | +| `PickN` | `Move` | over an entity | hover promise naming the slot: *"slot 1 of 2 — Parallel"* | `PickN` | +| | `LeftDown` | slot k of n unfilled | fill it, highlight it red | `PickN` | +| | `LeftDown` | all slots filled | restart from slot 1 (today's rolling rule, `:10322`), announced in the promise | `PickN` | +| | `Esc` | — | drop the picks, keep the tool armed (base `Gesture` level) | `Idle` | +| | `RightClick` | — | offer menu | `PickN` | +| `Plan` | — | always | `plan_entity_constraint` again, now with picks | branch below | +| | | `Reject` | keep the picks, name the slot and the reason | `PickN` | +| | | `AskValue` | open the field with `prefill` (Angle in degrees, DistanceX/Y, Radius, Diameter) | `Editing` | +| | | `Apply` | `try_add_constraints(defs)` | `Idle` on ok, `Refused` on conflict | +| `Editing` | `Enter` | — | `write_plan_value` → commit → same two exits | `Idle` / `Refused` | +| | `Esc` | — | close the field, drop the picks, no constraint | `Idle` | +| `Refused` | — | always | name the constraint from `r.bad` and its operands, keep the picks, offer "remove the conflicting one" | `PickN` | + +`Refused` is the state the base operator already requires (§0.2 class 1) and the one this batch is +really about: today a refusal is a status line that forgets everything. + +### 11.5 Constraints — event routing and focus handshake + +| Event | Tool | Card (the list) | Panel / char hook | Camera | +|---|---|---|---|---| +| `Move` | hover promise + slot legend | — | — | passthrough | +| `LeftDown` | fill the next slot | row click = highlight its entities | — | orbit may begin on press (same rule as §9.5) | +| click a badge | delete that constraint (`remove_constraint_near`) | rebuild the list | — | — | +| `Esc` | drop picks (`Gesture`), then leave Constrain (`Tool`) | — | one route (`:4231`) | — | +| `Ctrl+Z` | **must undo the last constraint** (see 11.6.2) | rebuild | routes it today (`:4252`) | — | +| `Tab` | next slot / next field | — | — | — | + +### 11.6 Constraints — three friction points, and how the architecture prevents them + +1. **"Which constraint is complaining?"** The refusal is anonymous while `r.bad` knows the answer. + ⇒ `Refused` names it: the plan already carries the type, the def carries `ea/eb/ec`, and + `constraint_label` (`DesignPanel.cpp:8040+`) already renders a human phrase per def. The card row + and the badge are one lookup away, so the message can read *"Parallel on Line 3 conflicts with + Horizontal on Line 3"* — and offer to remove the older one, which is a real decision the user is + making at that moment and today is left to guesswork. +2. **"Ctrl+Z deleted my line."** The universal undo must be the universal undo. ⇒ the live sketch gets + its own undo stack (entities *and* constraints in one sequence, which is also what Batch 12's + Trim/Extend needs); the badge click stays as the direct, discoverable route. +3. **"Nothing says how many picks this needs, or which one I am on."** The rolling 3-slot pick is + colour-identical per slot (`:8666-8673` red for any picked slot) and the arity is checked *after* + the pick. ⇒ the operator arms from the plan, so `n` and the slot names are known before the first + click — `Symmetric` announces three slots ("two entities, then the axis", matching + `ConstraintReject::NeedAxisLine`), and the promise says which slot the click will fill. + +--- + +## Batch 12 — Modification (Trim, Extend, Split, Offset, Mirror) + +Archetype: **`ConstraintAwareTransform`** — a modification does not only move geometry, it invalidates +the constraints and dimensions that referred to it. This batch is the first where that second half +exists at all, and today it happens behind the user's back. + +### 12.1 The architectural claim: a modification is a transaction over a constraint-bearing model + +A creation tool adds geometry and infers constraints onto it. A modification **edits geometry that +already carries constraints**, and must therefore decide, for every ref it touches, one of three +things: keep it, re-bind it, or drop it. The tree has three different answers and none of them is +reported: + +| Path | What happens to the constraints | Said to the user | +|---|---|---| +| Trim / Extend (`:1622`) | `drop_constraints_referencing(ei)` — every ref to the cut entity is erased (`:1582`) | nothing | +| Rotation, typed angle (`:1295`, `:1715`, `:1764`, `:1928`) | `drop_orientation_constraints` — H/V/Parallel/Perp/Angle/Lock on the rotated span erased (`:1559`) | nothing | +| Fillet / Chamfer (`confirm_op`, `:7932`) | **surgery**: erase the stale corner Coincident + each leg's own length `Distance`, then re-bind the new entity through a decreasing-ambition ladder of constraint sets until one is accepted | nothing | +| Offset (`:7976-7988`) | adds a relationship between source and copy — `Parallel` for a line, `Concentric` for an arc/circle | nothing | +| Mirror | the copies carry no constraints at all | nothing | +| **Committed** edit ops (`DesignPanel::apply_edit_op`, `:8505-8540`) | drops the stale refs **and then re-anchors** the moved endpoint with a `PointOnObject` onto whatever entity it now lies on — with a rollback if the solver rejects the new binding | nothing | + +That last row is the disagreement worth naming: the **same** operation behaves differently depending +on whether the sketch is live or committed. The live scissors drop (`DesignSketchTool.cpp:1622`, +`drop_constraints_referencing` and nothing else); the committed path re-anchors +(`DesignPanel.cpp:8510-8539`, Line/Circle cutters only, rollback at `:8537-8539`). Memory 1603 records +the re-anchor as the intended design. So a trim on a live sketch loses the coincidence, and the same +trim on a committed one keeps the loop closed — which is the Batch 10 pattern again, one batch later. + +And the drop paths leave the **dimension behind**: both drop functions only remap `DimAnnot.con` +(`:1577`, `:1598`), so a dimension whose constraint was erased stays in `m_dimensions` with `con = -1` +and its label keeps rendering `a.value` (Batch 10: `dim_text` prints the stored value). A trim can +therefore turn a driving dimension into pure decoration, silently. That is the same lie as Batch 10's +"the label says 30 and nothing moved", arrived at from the other direction — and it is exactly what a +`DriveKind` transition (`Solver → Reference`) should *report*. + +### 12.2 Verified current behaviour (what an implementer must NOT break) + +**Trim/Extend are the reference implementation of the hover promise** that §9.6 defect 1 says is +missing everywhere else. `compute_trim_preview` is pure and const, replays the pick and the kernel cut +on a **copy**, and returns the exact sub-polyline a click would remove; the renderer repaints it in red +from the cursor every frame (`:9134-9148`, never persisted). Refusal is spoken — the tool's own comment +cites `nde #15: don't fail silently` and the readout names what to click instead +(`:10156-10162`). One cut per click, right-click exits, drag falls through to the camera. Also: +`trim_entity`/`extend_entity` **slide one endpoint without adding or removing an entity**, which is +why the indices — and therefore every stored constraint index — stay valid (`:1600-1603`). + +**The edit-op family's commit rule is deliberate and documented.** A ready op commits when: the value +is typed and Enter pressed (`open_op_editor`'s commit calls `confirm_op`, `:7872`, with the comment +recording the old failure — *"the most obvious route of all — click the radius, type it, press +Return — ended with the value set, the ghost drawn, and no geometry written"*); the user clicks empty +space (`:10202`); **another tool is armed** (`:237`, and the comment explains both the rule and the +ordering trap that made it read `op_ready() == 0` with `a=0 b=3 val=28.205` sitting right there); and +when the sketch is committed (`:2241`). Commit-on-leave is a rule, not an accident. + +**The kernel already refuses by design and says so in its own documentation**, which is where the +UI message should come from: `offset_entities` *"Ellipses and splines are not offset (a parallel of +either is not the same kind of curve) and are dropped from the result"* (`SketchEngine.hpp:312-318`), +and it preserves chains (shared endpoints offset together, miter-repaired) so a closed profile stays +closed. `mirror_entities` reflects about a line, `make_bridge` exists with no GUI at all. + +### 12.3 The four defects + +1. **Silent constraint loss**, and with it the silent demotion of a dimension to decoration + (§12.1). Nothing on screen changes when a constraint disappears except the badge vanishing — which + is also how the *user* deletes one deliberately (Batch 11). +2. **Offset on an ellipse or a spline is a silent no-op.** The kernel drops them by design, so + `out.empty()` hits `reset_op(); return;` (`:7981`) with no message: the ghost disappears and + the click did nothing. The refusal text exists in the kernel's comment and not in the product. +3. **`mirror_entities` hands out a reversed half** — `snaporca-stn3`, verified open: the producer + "reflects each entity in place and leaves the result traversing the opposite way round", which has + already caused three defects in one day (exact loop area, `572f794c84`; offset to the wrong side, + `3974f8a170`; and any future direction-sensitive op). Both consumers are now defensive; the + producer is still wrong. The issue carries the proposal: emit the reflected entities in **reverse + order, each individually flipped**, so appending the result yields one consistently-oriented chain. +4. **The hover preview shows the geometry change and not the constraint change.** Trim says nothing + about the two constraints it is about to erase, although `drop_constraints_referencing` already + knows exactly which ones. +5. **The live and committed paths of the same operation disagree** (table above): live drops, committed + re-anchors. One of the two is the design; the batch's job is to make it the same one. + +### 12.4 The family's FSM + +Two shapes share one machine: `pick → ready → (ghost | value) → commit`, with Trim/Extend as the +instant form (no ghost, no confirm) and Offset/Fillet/Chamfer/Mirror as the confirmed form. + +| State | Event | Guard | Action | Next | +|---|---|---|---|---| +| `Idle` | tool armed | — | — | `Pick` | +| `Pick` | `Move` | over a candidate | **preview** (pure, const): the geometry delta *and* the constraint delta | `Pick` | +| | `LeftDown` | candidate accepted | Trim/Extend: apply, re-solve, stay armed for the next cut | `Idle` | +| | | candidate accepted | Offset: pick the source, seed `value`, ghost | `Ready` | +| | | candidate accepted | Mirror: first pick = axis line, then targets toggle | `Ready` | +| | | candidate refused | refuse with the kernel's own reason (ellipses/splines are not offset) | `Pick` | +| | `RightDown` | — | exit the tool (today's `request_exit()`) | `Idle` | +| | `Esc` | — | disarm (base `Tool` level) | `Idle` | +| `Ready` | arrow `Drag` | Offset | signed value → ghost | `Ready` | +| | `Enter` in the field | — | **commit** (the value *is* the commit) | `Idle` | +| | `LeftDown` on empty space | — | commit | `Idle` | +| | another tool armed | — | commit, then arm the new tool (documented rule) | `Idle` | +| | `Esc` / `RightDown` | — | `reset_op()` — discard the ghost, change nothing | `Idle` | +| **Commit** | — | kernel returns empty / refuses | **refuse and keep the ghost**, naming the reason | `Ready` | + +The last row is the whole point: today `out.empty()` silently discards the gesture, and a commit that +cannot happen must be a refusal (base operator, §0.2). + +### 12.5 Modification — event routing + +| Event | Tool | Preview overlay | Panel | Camera | +|---|---|---|---|---| +| `Move` | pick + `preview()` | geometry delta (red, as Trim does today) + a one-line constraint delta | — | passthrough | +| `LeftDown` (Trim) | apply at once | — | status line after the cut: *"trimmed · dropped 2 constraints, 1 dimension now reference"* | orbit may begin on press | +| `Enter` / field | commit | — | — | — | +| tool key while ready | commit then switch (`:237`) | — | — | — | +| `Esc` | `Gesture` → drop the ghost; `Tool` → disarm | — | one route (`:4231`) | — | +| `Ctrl+Z` | must undo the *modification* — the same journal Batch 11 needs | — | routes it today | — | + +### 12.6 Modification — three friction points, and how the architecture prevents them + +1. **"My sketch lost its constraints and I did not see it."** ⇒ the operator computes a + `ConstraintDelta{kept, rebound, dropped}` and the refusal/preview channel speaks it; a + modification that drops anything leaves one line on the status bar and one undo entry. The ladder + `confirm_op` already uses for fillet (tangent+tangent → tangent → coincident) generalises into + *rebind before dropping*, so the common case stops being a loss at all. +2. **"Offset did nothing."** ⇒ the kernel's own documented limitation becomes the UI's refusal text + (ellipses and splines are not offset because a parallel of either is not the same kind of curve), + the ghost is not drawn for an ineligible source, and the commit keeps the state instead of + resetting it. +3. **"I mirrored a half and the result walks the wrong way round."** ⇒ `mirror_entities` emits the + reflection in reverse order with each entity flipped (`snaporca-stn3`'s proposal), so the output is + a continuation of the chain rather than a mirror image of it. The consumers' defensive handling + stays — it is what makes offset correct for imported geometry the tool did not generate. + +--- + +## Batch 13 — Dress-up (sketch Fillet and Chamfer) + +Archetype: **`CornerOperator`** — the only family whose subject is *not an entity*. A fillet's input +is two lines and the vertex between them, and that vertex exists only as a computation, performed +twice, in two files, by hand. + +### 13.1 The architectural claim: the corner is computed twice and must agree + +`DesignSketchTool::op_corner` (`:7739-7762`) intersects the two lines to get the corner `C`, the +inward angle bisector and the angle — used to anchor the arrow. `SketchEngine::fillet_lines` / +`chamfer_lines` (`SketchEngine.hpp:343-352`) compute the same corner again to place the arc or the +setback segment. The tool's own comment: *"Mirrors SketchEngine::fillet_lines's geometry so the arrow +tracks the op exactly"* — a hand-maintained duplicate, exactly the `roles_of` pattern from Batch 11 +(`:2455-2461`, where the two copies had already diverged). Any divergence here is visible as an arrow +that points one way while the fillet cuts another. + +### 13.2 Verified current behaviour (what an implementer must NOT break) + +**Kernel scope, stated by the kernel:** `chamfer_lines` is *"symmetric chamfer between two lines +meeting at a corner… False if the lines are parallel or `d` overruns either line"*; `fillet_lines` is +the line-pair equivalent. **Lines only** — a line/arc or arc/arc corner has no implementation, while +Onshape's sketch fillet works on any two entities meeting at a corner. + +**The pick is two lines, in order.** `op_pick` (`:7798-7822`): a non-line pick returns immediately +and silently (`:7804`); the first line becomes `m_op_a`, the second `m_op_b` and seeds the value at +**20 % of the shorter leg** (`max(0.001, 0.2 * min(la, lb))`, `:7811`). + +**Dress-up has a real geometry preview.** `recompute_op_ghost` (`:7762-7780`) runs the *kernel op* +into `a_out/b_out/extra` and stores it in `m_op_ghost`, which `render_op_gizmo` draws ghost-green via +`draw_entities_preview`. The arrow runs along the inward bisector from the corner, its length is the +value, and the label sits at the tip (`R…` for fillet, bare for chamfer, `:7894-7900`). So this family +already does the thing §9.6 says is missing for Select and §12.6 says is missing for the constraint +delta: it previews the *result*, not just the pick. + +**The commit is a decreasing-ambition ladder.** `confirm_op` (`:7936-7971`) first erases the stale +corner `Coincident` and each leg's own length `Distance`, then tries, for a fillet, +`{tangent(a), tangent(b)}` → `{tangent(a)}` → `{}` (coincident bindings only) and keeps the first set +`try_add_constraints` accepts; a chamfer tries the coincident bindings alone. The new entity is +appended and bound; the source lines are replaced in place, so indices stay stable. + +**And `op_ready()` does not check the corner.** It is `m_op_a >= 0 && m_op_b >= 0` (`:7725-7729`), so +a **parallel pair is "ready"**: the arrow is drawn, confirming calls the kernel, the kernel returns +false, and `confirm_op` does `reset_op(); return;` (`:7941`) — the ghost disappears, nothing happens, +nothing is said. Same shape as Batch 12's offset-on-an-ellipse. + +### 13.3 The defects — the first one is above the level of this batch + +1. **`DimAnnot.con` goes stale in two of the five mutators, and it is a corruption path, not a + cosmetic one.** `m_dimensions[i].con` is an *index* into `m_constraints`. Two mutators renumber it + and repair the links (`drop_orientation_constraints` `:1579`, `drop_constraints_referencing` + `:1596`). Two do not: **`remove_constraint`** (`:2434` — the badge click, which is the *taught* + way to delete, Batch 11) and **`confirm_op`** (`:7960` — every fillet and chamfer). After either, + a dimension whose constraint sat after the erased slot points at a *different* constraint; and + `set_dimension_value` validates only the range (`a.con < m_constraints.size()`, `:2168`), not the + identity — so **editing that dimension's label silently overwrites an unrelated constraint**, and + the solver then obeys a def nobody asked for. Reachable in three gestures: draw two dimensioned + lines, click one constraint's badge, then click the other dimension's label and type a value. + This is a data-integrity defect and should be fixed outside the batch's normal ordering. +2. **A doomed op is announced as ready.** `op_ready()` must require `op_corner()` to succeed; then + the parallel pair is refused *before* the ghost, and the overrunning value is refused at the + kernel's own stated condition ("`d` overruns either line") instead of at a silent `reset_op()`. +3. **The corner is computed twice** (§13.1). One `SketchEngine::corner_of(a, b)` — the exact function + `fillet_lines` already needs internally — should be exposed and used by the tool. +4. **The arrow keeps a stale anchor** when `op_corner` fails: `m_op_anchor`/`m_op_dir` are only + assigned inside the `if (op_corner(...))` (`:7768-7769`), so the arrow is drawn at the *previous* + corner while the ghost is absent — the two halves of the preview disagree about which corner is + being edited. +5. **Scope: lines only.** No line/arc or arc/arc corner, so the most common real corner in a drawing + (a fillet between a line and an arc) cannot be made at all. + +### 13.4 The family's FSM + +| State | Event | Guard | Action | Next | +|---|---|---|---|---| +| `Idle` | tool armed (F / H, `:421-422`) | — | — | `Pick1` | +| `Pick1` | `Move` | over a line | highlight; hover promise naming what is needed ("pick the second line") | `Pick1` | +| | `LeftDown` | a line | `m_op_a = ei` | `Pick2` | +| | `LeftDown` | anything else | **refuse**: "a fillet needs two lines — arcs are not supported yet" | `Pick1` | +| `Pick2` | `LeftDown` | a second line, `corner_of` succeeds | `m_op_b`, seed 20 %, ghost, arrow on the bisector | `Ready` | +| | `LeftDown` | a second line, parallel / 180° | **refuse** with the reason; keep `m_op_a` | `Pick2` | +| `Ready` | `Drag` arrow | — | value = projection on the bisector, clamped to what the kernel can take | `Ready` | +| | `Enter` in the field | — | commit (the value *is* the commit, `:7872`) | `Idle` | +| | `LeftDown` empty / another tool | — | commit, then (for a tool) arm it (`:237`) | `Idle` | +| | `Esc` / `RightDown` | — | `reset_op()` — discard, change nothing | `Idle` | +| **Commit** | — | kernel false | **refuse and keep the op**: name the bound the kernel named (`d` overruns the leg) | `Ready` | +| | — | all ladder sets rejected | **refuse**: "the corner is over-constrained — remove a constraint on either leg" | `Ready` | + +The last two rows are what the ladder buys once it reports: today the ladder silently degrades +(tangent×2 → tangent → coincident), and the user cannot tell a tangent fillet from one that merely +touches at two points. + +### 13.5 Dress-up — event routing + +| Event | Tool | Preview | Panel | Camera | +|---|---|---|---|---| +| `Move` | pick + `recompute_op_ghost` | ghost entity set + arrow + label | — | passthrough | +| `LeftDown` | pick / confirm-on-empty | ghost | status after commit: which ladder rung was accepted | orbit may begin on press | +| `Enter` / field | commit | — | — | — | +| tool key while `Ready` | commit then switch (`:237`) | — | — | — | +| `Esc` | drop the op, keep the tool | — | one route (`:4231`) | — | +| `Ctrl+Z` | undo the fillet **and restore what it erased** — the journal Batch 11/12 need, here with the erased `Distance` and `Coincident` | — | routes it today | — | + +### 13.6 Dress-up — three friction points, and how the architecture prevents them + +1. **"I picked two lines and nothing happened."** Parallel legs, a 180° corner and an overrunning + value all end in a silent `reset_op()`. ⇒ `op_ready()` consults `corner_of`, the kernel's own + refusal condition becomes the product's message, and a doomed op is refused at the pick instead of + at the commit. +2. **"The fillet is not tangent and I cannot tell."** The ladder degrades in silence, and a + non-tangent arc at a corner reads as a modelling error. ⇒ the accepted rung is named once, at the + commit, and the badge for the arc carries its `Tangent` constraints, so the loss is visible on the + geometry (Batch 11's badge language) as well as in the line. +3. **"Esc left a mess / Ctrl+Z does not bring the corner back."** A fillet erases two `Distance` + constraints and a `Coincident`; the only way back is to undo the whole sketch gesture. ⇒ the + `SketchEdit` journal (Batch 11/12) records the erased defs with the geometry change, so one Ctrl+Z + restores the legs, their dimensions and the corner`Coincident` together — and with defect 1 fixed, + it restores the dimension *links* too, not just the defs. + +--- + +## Batch 14 — Transform (Move, Rotate, Scale, Array, PolarArray, TransformArt) + +Archetype: **`PivotSetOperator`** — the subject is a *set*, the pivot is *derived*, and the parameter +is a *relationship* (a vector, an angle, a factor, a count) rather than a scalar on one entity. It is +also the only family in the tab with **two** numeric parameters on one gesture, and the only one whose +maps can preserve or break constraints depending on the map — so it is where the constraint table had +to be written explicitly. + +### 14.1 The family, and the sixth archetype's shape + +Five modes share the `m_tf_*` block (`DesignSketchTool.hpp:1081-1097`): `m_tf_targets` (toggle-select +like Mirror, `tf_pick :8137`), `m_tf_pivot` (the targets' centroid, `compute_tf_pivot :8103`), +`m_tf_delta` / `m_tf_angle` / `m_tf_scale` / `m_tf_count{3}`, a ghost and two label anchors. +`tf_ready()` is just "any target" (`:8098`). The handle is *derived per mode* (`tf_handle_pos +:8208-8218`): the translated tip, the ring position at the current angle, or the scaled +X point. +Two labels are drawn and both are pickable — the primary at the handle, the count at the pivot +(`render_tf_gizmo :8283-8340`) — so Array and PolarArray are the tab's only two-parameter gestures, +with `open_tf_editor_a` (`:8250`) and `open_tf_editor_count` (`:8273`). + +Seeding is deliberately lazy (`tf_pick :8150-8168`): Move/Array seed a step of +`max(1.5 * handle_r, 1 mm)` (Array perpendicular to a lone line, else +X), Rotate 45°, PolarArray a +full 360°, Scale ×2 — and only while still neutral, *"so re-picking more targets keeps a value the +user already dialled in"*. + +`TransformArt` is the odd member and is genuinely a different tool: the subject is an imported +*feature* (Text/SVG) rather than entities, the parameters are a 2D offset plus **two independent +scale factors** (`m_xform_sx/sy`, non-uniform by design, floored at 1e-4), and the gizmo is a bounding +box with five square handles — four corners and the centre (`render_xform_gizmo :7706-7724`) — where a +corner drag solves the offset so that the *opposite* corner stays put (`:7690-7700`, `emit_xform :7702`). + +### 14.2 Verified current behaviour: the constraint table, read in the right direction + +`confirm_transform` (`:8342-8445`) `remove_if`s — **returning true means REMOVED** — over every +constraint that touches a target (`is_target` checks `ea`, `eb` and `ec`): + +| Mode | Removed | Kept | +|---|---|---| +| Move | Coincident, PointOnLine, PointOnObject, Concentric, Symmetric, Midpoint, **Fix, LockX, LockY**, cross-entity `Distance` | Horizontal, Vertical, Parallel, Perpendicular, Angle, EqualLength, Radius, Diameter, Collinear, **self**-`Distance` | +| Rotate | everything else — Coincident, Concentric, Tangent, Parallel, Perpendicular, Midpoint, Symmetric, PointOnLine/Object, Horizontal, Vertical, Angle, Fix, Lock | EqualLength, Radius, Diameter, self-`Distance` | +| Scale | everything except the five orientation types | Horizontal, Vertical, Parallel, Perpendicular, Angle | +| Array / PolarArray | additive: copies are appended and bound to their source through a ladder — lines get Parallel+EqualLength (linear) or EqualLength alone (polar), arcs/circles a per-copy Radius plus Concentric when the polar pivot *is* the source's centre | the originals are untouched | + +Then `resolve_live()`. The rationale in the comment is real — *"Surviving classes are satisfied by +construction; the re-solve folds in the new placement"* — and the array ladder is the same +degrade-and-keep-the-first-set pattern as Batch 13's fillet, which is the right instinct. + +### 14.3 The defects + +1. **Two families inside one function disagree about what leaving means.** `set_tool` commits a ready + edit-op (`:237`) and its comment states the rationale in the strongest terms — *"Discarding it here + is most of why Fillet looked like it simply did not work"* — and then, thirty lines later, + `reset_tf(); // drop any in-progress transform gizmo` (`:295`). So arming Line with a pending + fillet keeps the fillet, and arming Line with a pending rotation **throws the rotation away** — + targets picked, value dialled or typed, ghost visible. Same gesture, opposite outcome, no message. +2. **The keep/drop table over-removes, silently.** For every mode there are constraint types the map + provably preserves that are removed anyway: Move removes Coincident/PointOnLine/PointOnObject even + when *all* their operands are targets (a rigid translation of a whole loop preserves them); Rotate + removes Coincident, Concentric, Tangent, Parallel, Perpendicular, Midpoint, Symmetric and + PointOnObject although a rotation preserves all of them; Scale removes Coincident, Tangent, + Midpoint, Symmetric, PointOnLine, PointOnObject and EqualLength although a uniform scale preserves + them (and it keeps Horizontal/Vertical, which only a *non-uniform* scale would break — Scale here + is uniform, so that half is right). The user sees badges vanish and the DoF readout climb, while the + geometry — which *is* correctly mapped — looks fine, so the loss stays invisible until a later edit. +3. **Move releases an explicit anchor.** `Fix`, `LockX` and `LockY` are removed by a translation. A + point the user pinned to protect it can be moved, and the protection silently disappears. Either the + map should obey the anchor (refuse, or carry it) or the release must be reported. +4. **A third erasure site without a `con` remap** (`:8368`), alongside `remove_constraint :2434` and + `confirm_op :7960` — the corruption path recorded in its own memory: a dimension's index now points + at a different constraint, and editing its label overwrites that one. +5. **The action bar's ✓ does not confirm a transform.** For `m_active == Tool::None` inside a sketch it + calls `finish_sketch()` (`DesignPanel.cpp:11304-11307`), and finishing applies the pending transform + on the way (`:2242`). So the only gesture-level confirm is a **click on empty space** (`:10256`), + which nothing announces, and the button a user would reach for ends the sketch instead. Right-click + with targets discards (`:10260`); Esc reaches the same through the ladder. +6. **The array ladder fails silently.** *"else the geometry stays unconstrained"* — the copies appear + with no relationship to their source and nothing says the ladder was rejected. + +### 14.4 The family's FSM + +| State | Event | Guard | Action | Next | +|---|---|---|---|---| +| `Idle` | tool armed (no key for these — see the open calls) | — | — | `Pick` | +| `Pick` | `LeftDown` | on an entity | toggle into `m_tf_targets`, recompute the pivot, seed if still neutral, ghost | `Pick` | +| | `Move` | — | hover promise: the pivot marker and the ghost already *are* the promise | `Pick` | +| | `LeftDown` | empty space, no targets | nothing (today it is a no-op click) | `Pick` | +| `Ready` | `Drag` handle | — | primary parameter; `Ctrl`/`Shift` snaps (not implemented today) | `Ready` | +| | `Enter` / field | — | commit | `Idle` | +| | `LeftDown` empty | — | commit (today's only route) | `Idle` | +| | **another tool armed** | — | **commit**, like a ready edit-op — not `reset_tf()` | `Idle` | +| | ✓ in the action bar | — | commit the gesture, *not* the sketch | `Idle` | +| | `RightDown` / `Esc` | — | discard the gesture, keep the tool | `Pick` | +| **Commit** | — | any constraint removed | one line naming the count and classes, and one undo entry | `Idle` | + +### 14.5 Transform — event routing + +| Event | Tool | Ghost/labels | Panel | Camera | +|---|---|---|---|---| +| `Move` | pick + `recompute_tf_ghost` | ghost, spoke, handle square, pivot marker, two labels | — | passthrough | +| `LeftDown` on an empty point | commit | — | status: what was mapped, what was dropped | orbit may begin on press | +| `LeftDown` on the count label | open the count field | — | — | — | +| `Enter` in either field | commit | — | — | — | +| ✓ / Finish | commit then `finish_sketch()` (`:2242`) | — | `tool_confirm :11304` | — | +| tool key while `Ready` | commit, then arm (must match `:237`) | — | — | — | +| `RightDown` | discard | — | — | — | +| `Esc` | discard, then disarm (ladder) | — | one route (`:4231`) | — | +| `Ctrl+Z` | undo the map **and restore the constraints it removed** — the journal Batch 11-13 need; here it is the difference between "I rotated it" and "I rotated it and lost my sketch's structure" | — | routes it today | — | + +### 14.6 Transform — three friction points, and how the architecture prevents them + +1. **"I moved to the next tool and my rotation disappeared."** The gesture is visible, dialled and + committed-by-intent, yet leaving drops it. ⇒ one `leave()` rule for the whole tool layer: a ready + gesture commits, whatever family it belongs to, and the edit-op rationale (`:228-244`) applies + verbatim. The undo entry makes the commit safe; the drop makes it infuriating. +2. **"Where did my constraints go?"** Three modes erase four to fifteen types each, by a hand-written + table, with no message and no way back. ⇒ the table becomes derived rather than written — for each + `(type, map)` pair the answer is a property of the map, not a choice — and whatever it removes is + named once, at the commit, with one Ctrl+Z to restore it. `Move`'s dropping of `Fix`/`Lock` + deserves to be a visible decision (`"the anchor on point 4 was released by this move"`), because + silently releasing the one constraint whose purpose is to prevent movement is the worst version of + this class. +3. **"The ✓ finished my sketch."** A gesture-level confirm must exist and be the button the user + already presses. ⇒ `tool_confirm` gains the gesture level above the mode level (which is what + `CadLevel::Gesture` already means in `DesignInteraction.hpp`), and `finish_sketch` stops being the + only thing ✓ can mean while a ghost is on screen. + +--- + +## Open scope calls + +- **Batch 8 (carried):** wire edge picking (25a) and the intersection point (27) — both are +- **Batch 9 — resolved by reading the table, not by arguing:** the "homogeneous selection set" +- **Batch 9:** "select other" (a pick list for a face occluded by another) is *not* in this design. +- **Batch 10 — which of the two Dimension paths wins?** Recommended: the **solver drives** (the +- **Batch 10 — signed `DistanceToLine` needs a spike.** The port maps it to +- **Batch 10 — `AutoEditStep::apply` must return `WriteOutcome`**, which touches every chain builder +- **Batch 11 — naming the conflict needs one signature change.** `try_add_constraints` returns `bool` +- **Batch 11 — the live-sketch undo stack is shared work.** It must sequence entities, constraints and +- **Batch 11 — should the 20 constraint verbs get offer rows?** Adding 20 rows to a 92-row table whose +- **Batch 11 — inference for batches > 16 entities should speak.** The policy is correct and measured; +- **Batch 12 — Split has no implementation at all.** Onshape's Split cuts an entity at a crossing and +- **Batch 12 — fix `snaporca-stn3` while the batch is open.** `mirror_entities` emitting a +- **Batch 12 — the constraint delta and Batch 11's conflict name are the same type.** Batch 11 wants +- **Batch 13 — fix `DimAnnot.con` staleness now, and make it impossible.** Two mutators renumber +- **Batch 13 — one `corner_of`, then arc corners.** `SketchEngine::corner_of(a, b)` is the function +- **Batch 13 — `op_ready()` must consult `corner_of`.** Three of the four silent failures in this +- **Batch 14 — no transform tool has a keyboard shortcut.** Nothing in `m_keys_sketch` maps to Move, + Rotate, Scale, Array, PolarArray or TransformArt (`DesignPanel.cpp:408-422` binds sixteen letters, + none of them these), so the family is reachable only from the offer. Either assign keys from the free + letters (G I J K N Q U V W Y Z — K is already Constrain) or state that the family is deliberately + offer-only: `snaporca-fqwu` counts 47 of 86 verbs with no shortcut, and this is a sixth of them. +- **Batch 14 — derive the keep/drop table, do not maintain it.** `confirm_transform :8357-8387` is a + hand-written per-type switch that over-removes in all three mutating modes; for each `(type, map)` + pair the answer is a property of the map (does it preserve the relation?), with the genuine + exceptions (`Fix`/`Lock`, an absolute `Angle`) named as exceptions. +- **Phase 2 (3D) needs its batch list written before it starts** — otherwise it will be designed by + whoever opens the file first. + +--- + +## Closing note — Phase 1 (2D) is designed + +Fourteen batches, and six cross-cutting changes were asked for by more than one of them. An +implementer should land them in this order, because each one is what makes the next one's refusals, +names and undos possible: + +| # | Change | Asked for by | Why first | +|---|---|---|---| +| 1 | `DimAnnot.con` → a stable `SketchConstraintId` (and one `erase_constraint(i)` until then) | 13 (bug) | a data-integrity bug that a later step would build on top of | +| 2 | `WriteOutcome{Ok, ClampedTo, Refused}` on every parameter write, and `AutoEditStep::apply` returning it (19 sites) | 10, 12, 13 | without it every refusal stays silent by construction | +| 3 | `try_add_constraints` returns its `SketchSolveResult`; one `ConstraintDelta{kept, rebound, dropped}` | 11, 12, 13, 14 | the solver already knows the answer; three siblings want to say it | +| 4 | `SelectionSet` + `PickPolicy` (typed, ordered, per-tool) | 9, 11, 14 | the substrate the operators read their subject from | +| 5 | One `SketchEdit` journal + Ctrl+Z in a live sketch | 11, 12, 13, 14 | every modification erases something; today none of it is reversible | +| 6 | `ToolManager.leave()` = commit a ready gesture, one ESC ladder, one hover promise | 9, 12, 13, 14 | the three families currently answer "what does leaving mean" three ways | + +Phase 2 (3D) has no batch list yet — it needs one before it starts, or it will be designed by +whoever opens the file first. diff --git a/docs/HLSD/deferred-page-construction.md b/docs/HLSD/deferred-page-construction.md index 6a25d16fc3..921468c533 100644 --- a/docs/HLSD/deferred-page-construction.md +++ b/docs/HLSD/deferred-page-construction.md @@ -38,7 +38,11 @@ no wx dependency and is unit-tested. The notebook needs a page object for a tab to exist and for tabs to be inserted and removed by pointer, and the placeholder is that object. It builds the real panel inside itself the first time it is shown and forwards showing and hiding afterwards, so a panel's -own show handling stays its activation hook. Nothing builds while the main window is +own show handling stays its activation hook. The build runs before the placeholder shows +itself, so a panel built on demand is created in a hidden window as a prebuilt one is: on +Windows every control created or moved inside a shown window re-clips and repaints its +shown siblings, which makes building a large panel into a shown page many times slower. +Nothing builds while the main window is hidden; the window's first show builds the start page. A page that is out of the book is not prebuilt. A panel built while its page is hidden stays hidden, and gets the theming the window applied before the panel existed. diff --git a/docs/HLSD/design-tab.md b/docs/HLSD/design-tab.md index aa4c7265bc..c4bb9fd297 100644 --- a/docs/HLSD/design-tab.md +++ b/docs/HLSD/design-tab.md @@ -13,8 +13,9 @@ known. Its coupling to the rest of the application is deliberately narrow. It adds no stage to the slicing pipeline and touches neither the preset system nor `Tab`. It reaches the rest of Orca in two places: **Commit to Plate**, which hands finished solids to Prepare as ordinary model -objects, and one optional 3MF archive entry that carries the recipe. Everything else is -contained in `src/libslic3r/CAD/` and `src/slic3r/GUI/CAD/`. +objects (by default one assembly object with a part per body, which keeps the bodies' relative +placement, or one object per body), and one optional 3MF archive entry that carries the recipe. +Everything else is contained in `src/libslic3r/CAD/` and `src/slic3r/GUI/CAD/`. The user-facing manual lives in the wiki ([Design Tab](https://www.orcaslicer.com/wiki/design_tab)), not here. This document covers the @@ -118,6 +119,9 @@ referencing the source file, so a project opens without the STEP or mesh it was The cost is that saved projects are coupled to an OCCT BRep revision. `tests/data/cad_brep_occt76.brep` holds a solid written by OCCT 7.6, and its test fails if the bundled OCCT can no longer read it. +A Text feature follows the same rule: it stores the outlines it was vectorised into alongside +its string, font and height, so the project opens identically on a machine that lacks the font; +the three parameters are only what an edit reopens the dialog with. ## The interaction contract @@ -132,7 +136,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 | -| `Idle` | nothing transient | clears the selection; leaves a sketch session only if it is empty | +| `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 method on the panel, so the ordering that is the entire contract is checkable without a window, @@ -145,26 +149,117 @@ 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. -Right-click is read at button-up against two independent budgets — 3 px of drift and 200 ms — -because drift alone still popped a menu at the end of a slow, careful orbit. The raycast uses -the press position, not the release. An armed sketch tool that already consumed the right +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. +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. + Entering a sketch changes three 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. +## Rendering the bodies + +The tab draws its bodies through the same `GLCanvas3D` object path as Prepare, so how they look +is decided in the shared object shader, not in the tab. The slicer's two lights both sit near +the camera, which leaves the sides of a part in nearly one tone; the Design canvas asks for a +studio model instead — a world-space sky/ground hemisphere, a key and a fill light, a +plastic-like highlight and a darker silhouette — through `GLCanvas3D::set_studio_lighting()` +and the phong shader's `lighting_model` uniform. The program is shared by every canvas, so each +use sets the uniform (0 for the slicer's canvases) rather than relying on a default: a canvas +that left it alone would inherit whatever the last canvas chose. + +The B-rep edges of every body are drawn over it by the sketch overlay as thin view-facing +ribbons, depth tested and pulled a few pixels toward the eye so they win against the faces that +meet at them and still hide behind faces in front; lines are not used because they do not +rasterise under the software GL context the tab also supports. Seams of closed surfaces and +degenerate edges are left out (`GeometryEngine::display_edges`), and the polylines are sampled +once per shape, keyed by its `TShape`, because a recompute that leaves a body unchanged is the +common case. + +## Showing what is selected + +A selection is drawn on the faces it names, never as a tint over the body: a translucent +selection colour blended into the body's own colour turns a different hue on every body and +vanishes on one close to it. Selected faces are split out of their body into a volume of their +own, which the canvas draws opaque in the selection colour through the same shader and lighting +as the body (`DesignCanvas::rebuild_bodies`); the sketch overlay outlines them with a cased line +— a dark band under a selection-coloured one — so the outline still reads on a body that wears +the selection colour itself. A body picked whole, a face picked in the viewport and the faces of +the Feature tree's selected feature all draw this way. The hover pre-highlight is the outline +alone, uncased: it promises a click, it is not one. The automatic body colours keep clear of the +selection colour's blues and teals, so no body looks selected before anything is picked; a colour +the user sets on a body is theirs, and the cased outline keeps its selection readable. + +Selecting a feature row lights the faces that feature made, not the whole body it sits on, so a +fillet row shows its round and the extrude under it keeps the faces the fillet trimmed. +`CadDocument::faces_made_by` answers it without per-feature history: it replays the recipe to +just before the feature and then the feature alone, and a face of the finished model belongs to +the feature when an interior point of it lies on the boundary afterwards and not before, facing +the same way — the facing keeps a block stacked on a base the owner of its bottom face. A feature +that makes no face of its own, such as a Boolean union, answers with the bodies it changed. The +replay costs up to a recompute, so the panel finds the faces once per row and topology +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. + +## Following the app + +The tab is a page of Orca's main window and answers to the same settings as Prepare. + +- **Theme.** Its chrome is coloured from a table of light/dark token pairs. A theme switch + reaches `DesignPanel::on_sys_color_changed` from `MainFrame`, which moves every colour that is + one theme's token onto the other theme's and then runs the app's own dark pass; the icons are + Orca's sidebar grey, which the icon cache maps per theme, so they are re-rasterised rather + than re-tinted. +- **Scale.** Sizes are in DIP, and a DPI change reaches `DesignPanel::msw_rescale`, which + re-rasterises every icon (button faces, flyout rows, card headers, the feature and body lists' + row icons). +- **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. +- **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. +- **Undo.** The tab keeps its own history (the recipe is not part of Prepare's snapshots), but + it has no Undo/Redo of its own: the top bar, `Ctrl+Z` and Edit drive it while the tab is + shown, greyed to what an undo would actually do. +- **Docking.** The sidebar docks like Prepare's — either side, floating, resized, or collapsed + with the canvas's collapse button or `Shift+Tab` — through its own AUI manager under the + toolbar, because Prepare's manages the Plater and the Plater is not on this page. The button is + the canvas's own toolbar rather than Prepare's, which collapses Prepare's sidebar. The layout, + collapse included, is kept apart from Prepare's (`design_window_layout`) and starts where + Prepare's sidebar is, at its width, so the canvas edge holds still across the tab switch until + the user moves one of them. A floating sidebar is a top-level window, so it is hidden with the + tab rather than left over the other pages, and View > Reset Window Layout resets both tabs. + ## The offer is generated, not hand-written Right-clicking geometry opens the *offer*: eight families in a fixed order, each verb at a permanent row index, verbs that do not apply shown disabled **in place with their reason** 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. +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. An invariant across 92 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, @@ -195,6 +290,7 @@ scripted action and a clicked one cannot diverge. It is off unless the variable | `src/libslic3r/CAD/SketchSolver.*` | constraint solving, over the vendored solver | | `src/libslic3r/slvs/` | vendored 2D constraint solver (GPLv3) | | `src/slic3r/GUI/CAD/DesignPanel.*` | the tab: toolbar, feature cards, tree, key maps | +| `src/slic3r/GUI/CAD/DesignRowList.*` | the Feature tree and Bodies lists, with per-row actions | | `src/slic3r/GUI/CAD/DesignCanvas.*` | viewport integration | | `src/slic3r/GUI/CAD/DesignSketchTool.*` | in-canvas sketching | | `src/slic3r/GUI/CAD/DesignInteraction.hpp` | the Esc level contract | diff --git a/localization/i18n/list.txt b/localization/i18n/list.txt index d93a62797b..9b22fa8f5c 100644 --- a/localization/i18n/list.txt +++ b/localization/i18n/list.txt @@ -293,7 +293,10 @@ src/slic3r/GUI/AMSDryControl.cpp src/slic3r/GUI/AMSDryControl.hpp src/libslic3r/PresetBundle.cpp src/slic3r/GUI/CAD/DesignPanel.cpp +src/slic3r/GUI/CAD/DesignSketchTool.cpp src/slic3r/GUI/CAD/SketchInlineEditor.cpp +src/slic3r/GUI/CAD/DesignOffer.hpp +src/slic3r/GUI/CAD/DesignTextDialog.cpp src/slic3r/GUI/Gizmos/GLGizmoPrimitive.cpp src/slic3r/GUI/Gizmos/GLGizmoSketch.cpp src/slic3r/GUI/KeyChord.cpp diff --git a/resources/images/design_arc3pt.svg b/resources/images/design_arc3pt.svg index 1b40569b4f..951db22615 100644 --- a/resources/images/design_arc3pt.svg +++ b/resources/images/design_arc3pt.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_arc_center.svg b/resources/images/design_arc_center.svg index e4c85febfc..05bb4f5a56 100644 --- a/resources/images/design_arc_center.svg +++ b/resources/images/design_arc_center.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_array.svg b/resources/images/design_array.svg index d29da47bd7..a22343e9eb 100644 --- a/resources/images/design_array.svg +++ b/resources/images/design_array.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_boolean.svg b/resources/images/design_boolean.svg index 97813cf2eb..7eace7fc2b 100644 --- a/resources/images/design_boolean.svg +++ b/resources/images/design_boolean.svg @@ -1,4 +1,4 @@ - + diff --git a/resources/images/design_bspline.svg b/resources/images/design_bspline.svg index 16a5dc9b31..180f3fc24a 100644 --- a/resources/images/design_bspline.svg +++ b/resources/images/design_bspline.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_c_angle.svg b/resources/images/design_c_angle.svg index 2359b23b6a..5fe8683f68 100644 --- a/resources/images/design_c_angle.svg +++ b/resources/images/design_c_angle.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_c_coincident.svg b/resources/images/design_c_coincident.svg index 83ab602eca..8a72107cd3 100644 --- a/resources/images/design_c_coincident.svg +++ b/resources/images/design_c_coincident.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_c_collinear.svg b/resources/images/design_c_collinear.svg index 28b8b6a8cd..ddc46acde3 100644 --- a/resources/images/design_c_collinear.svg +++ b/resources/images/design_c_collinear.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_c_concentric.svg b/resources/images/design_c_concentric.svg index 9861381b58..4e3123b6e5 100644 --- a/resources/images/design_c_concentric.svg +++ b/resources/images/design_c_concentric.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_c_diameter.svg b/resources/images/design_c_diameter.svg index 8e3766c4dc..76366179a1 100644 --- a/resources/images/design_c_diameter.svg +++ b/resources/images/design_c_diameter.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_c_dist_x.svg b/resources/images/design_c_dist_x.svg index e811a58df6..7455e3629a 100644 --- a/resources/images/design_c_dist_x.svg +++ b/resources/images/design_c_dist_x.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_c_dist_y.svg b/resources/images/design_c_dist_y.svg index 5aefec85ee..ecfb38a08f 100644 --- a/resources/images/design_c_dist_y.svg +++ b/resources/images/design_c_dist_y.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_c_equal.svg b/resources/images/design_c_equal.svg index f0657b74b0..4c3e2ebd3a 100644 --- a/resources/images/design_c_equal.svg +++ b/resources/images/design_c_equal.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_c_equal_radius.svg b/resources/images/design_c_equal_radius.svg index 4d7ccf2c5c..7ed9b3ca45 100644 --- a/resources/images/design_c_equal_radius.svg +++ b/resources/images/design_c_equal_radius.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_c_fix.svg b/resources/images/design_c_fix.svg index b64024286e..38d0ec5279 100644 --- a/resources/images/design_c_fix.svg +++ b/resources/images/design_c_fix.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_c_horizontal.svg b/resources/images/design_c_horizontal.svg index 9804a8ad82..9dbc1a4462 100644 --- a/resources/images/design_c_horizontal.svg +++ b/resources/images/design_c_horizontal.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_c_midpoint.svg b/resources/images/design_c_midpoint.svg index 6646a6cc1e..cae9ce48d4 100644 --- a/resources/images/design_c_midpoint.svg +++ b/resources/images/design_c_midpoint.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_c_parallel.svg b/resources/images/design_c_parallel.svg index cc78f9c452..7622362d2f 100644 --- a/resources/images/design_c_parallel.svg +++ b/resources/images/design_c_parallel.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_c_perpendicular.svg b/resources/images/design_c_perpendicular.svg index 9a67cebb2b..1072b1f673 100644 --- a/resources/images/design_c_perpendicular.svg +++ b/resources/images/design_c_perpendicular.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_c_radius.svg b/resources/images/design_c_radius.svg index 5e1f1cdadf..906268d3d7 100644 --- a/resources/images/design_c_radius.svg +++ b/resources/images/design_c_radius.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_c_sym_h.svg b/resources/images/design_c_sym_h.svg index 4e152787b1..6743e8a42d 100644 --- a/resources/images/design_c_sym_h.svg +++ b/resources/images/design_c_sym_h.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_c_sym_v.svg b/resources/images/design_c_sym_v.svg index f4cd99371a..5019a82fcc 100644 --- a/resources/images/design_c_sym_v.svg +++ b/resources/images/design_c_sym_v.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_c_symmetric.svg b/resources/images/design_c_symmetric.svg index 7a539be9a3..1f9b07e361 100644 --- a/resources/images/design_c_symmetric.svg +++ b/resources/images/design_c_symmetric.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_c_tangent.svg b/resources/images/design_c_tangent.svg index e82d23240d..66b320a14c 100644 --- a/resources/images/design_c_tangent.svg +++ b/resources/images/design_c_tangent.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_c_vertical.svg b/resources/images/design_c_vertical.svg index 98526e93d7..1399c97012 100644 --- a/resources/images/design_c_vertical.svg +++ b/resources/images/design_c_vertical.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_chamfer.svg b/resources/images/design_chamfer.svg index 861f7b819a..f70bd8c6f8 100644 --- a/resources/images/design_chamfer.svg +++ b/resources/images/design_chamfer.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_check.svg b/resources/images/design_check.svg index 82d5edd48e..d00643de54 100644 --- a/resources/images/design_check.svg +++ b/resources/images/design_check.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_circle.svg b/resources/images/design_circle.svg index edc74b13ea..7ace8bdab5 100644 --- a/resources/images/design_circle.svg +++ b/resources/images/design_circle.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_circle2pt.svg b/resources/images/design_circle2pt.svg index 844b0797eb..7d2716eb10 100644 --- a/resources/images/design_circle2pt.svg +++ b/resources/images/design_circle2pt.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_circle3pt.svg b/resources/images/design_circle3pt.svg index 6fdbab2474..3d82ae3788 100644 --- a/resources/images/design_circle3pt.svg +++ b/resources/images/design_circle3pt.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_constrain.svg b/resources/images/design_constrain.svg index cc9be6ca9b..b8ce387f81 100644 --- a/resources/images/design_constrain.svg +++ b/resources/images/design_constrain.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_construction.svg b/resources/images/design_construction.svg index 89d10f424d..22c4494181 100644 --- a/resources/images/design_construction.svg +++ b/resources/images/design_construction.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_crect.svg b/resources/images/design_crect.svg index 9e2ece0c24..e76ff04c8d 100644 --- a/resources/images/design_crect.svg +++ b/resources/images/design_crect.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_cut.svg b/resources/images/design_cut.svg index 3f68a7002e..0f130768bf 100644 --- a/resources/images/design_cut.svg +++ b/resources/images/design_cut.svg @@ -1,4 +1,4 @@ - + diff --git a/resources/images/design_delete.svg b/resources/images/design_delete.svg index f5d5c6a1c7..4920eb5667 100644 --- a/resources/images/design_delete.svg +++ b/resources/images/design_delete.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_dimension.svg b/resources/images/design_dimension.svg index f456abb6fc..87fb20c95e 100644 --- a/resources/images/design_dimension.svg +++ b/resources/images/design_dimension.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_draft.svg b/resources/images/design_draft.svg index 2957584cb8..6e972c5505 100644 --- a/resources/images/design_draft.svg +++ b/resources/images/design_draft.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_dressup.svg b/resources/images/design_dressup.svg index cc2a09250e..cd3cbfc78f 100644 --- a/resources/images/design_dressup.svg +++ b/resources/images/design_dressup.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_edit.svg b/resources/images/design_edit.svg index 4de84fc390..b1dc0fabaa 100644 --- a/resources/images/design_edit.svg +++ b/resources/images/design_edit.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_ellipse.svg b/resources/images/design_ellipse.svg index 1f946eb5fa..22e486ad15 100644 --- a/resources/images/design_ellipse.svg +++ b/resources/images/design_ellipse.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_ellipse_arc.svg b/resources/images/design_ellipse_arc.svg index e9cd4aa66a..b5dea944a8 100644 --- a/resources/images/design_ellipse_arc.svg +++ b/resources/images/design_ellipse_arc.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_extend.svg b/resources/images/design_extend.svg index 9d4934fada..de0e9e98b3 100644 --- a/resources/images/design_extend.svg +++ b/resources/images/design_extend.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_extrude.svg b/resources/images/design_extrude.svg index 8137336fb2..84c8bd3c7c 100644 --- a/resources/images/design_extrude.svg +++ b/resources/images/design_extrude.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_eye.svg b/resources/images/design_eye.svg index 97296c7465..8499d684a6 100644 --- a/resources/images/design_eye.svg +++ b/resources/images/design_eye.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_eye_off.svg b/resources/images/design_eye_off.svg new file mode 100644 index 0000000000..42f619305d --- /dev/null +++ b/resources/images/design_eye_off.svg @@ -0,0 +1 @@ + diff --git a/resources/images/design_filletedge.svg b/resources/images/design_filletedge.svg index 435d7c2cb9..622a2fc306 100644 --- a/resources/images/design_filletedge.svg +++ b/resources/images/design_filletedge.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_hole.svg b/resources/images/design_hole.svg index 35128ef51e..885462ed53 100644 --- a/resources/images/design_hole.svg +++ b/resources/images/design_hole.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_line.svg b/resources/images/design_line.svg index afb644ba3f..c7ead6b7f8 100644 --- a/resources/images/design_line.svg +++ b/resources/images/design_line.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_loft.svg b/resources/images/design_loft.svg index 4529b52554..574262ae18 100644 --- a/resources/images/design_loft.svg +++ b/resources/images/design_loft.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_mirror.svg b/resources/images/design_mirror.svg index 0a9642f8f3..66616a7636 100644 --- a/resources/images/design_mirror.svg +++ b/resources/images/design_mirror.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_move.svg b/resources/images/design_move.svg index dba4f4f4c2..3b4aaf1582 100644 --- a/resources/images/design_move.svg +++ b/resources/images/design_move.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_movedown.svg b/resources/images/design_movedown.svg index 89c442dc6e..35b8c97852 100644 --- a/resources/images/design_movedown.svg +++ b/resources/images/design_movedown.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_moveup.svg b/resources/images/design_moveup.svg index b2fe5ec127..eada91ed5f 100644 --- a/resources/images/design_moveup.svg +++ b/resources/images/design_moveup.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_offset.svg b/resources/images/design_offset.svg index 74758ad0ba..e71c86a5e9 100644 --- a/resources/images/design_offset.svg +++ b/resources/images/design_offset.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_pattern.svg b/resources/images/design_pattern.svg index c07708509b..57f8a49ddc 100644 --- a/resources/images/design_pattern.svg +++ b/resources/images/design_pattern.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_plane.svg b/resources/images/design_plane.svg index 8a3886158f..3e7017a625 100644 --- a/resources/images/design_plane.svg +++ b/resources/images/design_plane.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_point.svg b/resources/images/design_point.svg index dd5d750545..31bfeefa4f 100644 --- a/resources/images/design_point.svg +++ b/resources/images/design_point.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_polararray.svg b/resources/images/design_polararray.svg index 5e91d49f73..5f2c0f336b 100644 --- a/resources/images/design_polararray.svg +++ b/resources/images/design_polararray.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_polygon.svg b/resources/images/design_polygon.svg index de5cfd6f20..22ebdcbad9 100644 --- a/resources/images/design_polygon.svg +++ b/resources/images/design_polygon.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_polyline.svg b/resources/images/design_polyline.svg index 96538abea7..ea18b80090 100644 --- a/resources/images/design_polyline.svg +++ b/resources/images/design_polyline.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_rect.svg b/resources/images/design_rect.svg index 67b70cc6a1..adeb8feab4 100644 --- a/resources/images/design_rect.svg +++ b/resources/images/design_rect.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_rect_oblique.svg b/resources/images/design_rect_oblique.svg index 5d252ca677..4aecab5786 100644 --- a/resources/images/design_rect_oblique.svg +++ b/resources/images/design_rect_oblique.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_rect_rounded.svg b/resources/images/design_rect_rounded.svg index a8c7022c87..887c684a2b 100644 --- a/resources/images/design_rect_rounded.svg +++ b/resources/images/design_rect_rounded.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_revolve.svg b/resources/images/design_revolve.svg index 3a0e87f6f4..84d9e45eff 100644 --- a/resources/images/design_revolve.svg +++ b/resources/images/design_revolve.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_rib.svg b/resources/images/design_rib.svg index b481e38d23..50e54fe16d 100644 --- a/resources/images/design_rib.svg +++ b/resources/images/design_rib.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_rotate.svg b/resources/images/design_rotate.svg index 5a9b48ebfe..39b71ec06a 100644 --- a/resources/images/design_rotate.svg +++ b/resources/images/design_rotate.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_scale.svg b/resources/images/design_scale.svg index 5a53562ba3..38b470cc8d 100644 --- a/resources/images/design_scale.svg +++ b/resources/images/design_scale.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_select.svg b/resources/images/design_select.svg index 6f2e4f4ae9..aabd953c02 100644 --- a/resources/images/design_select.svg +++ b/resources/images/design_select.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_shell.svg b/resources/images/design_shell.svg index aa11b572e6..4a78b94a33 100644 --- a/resources/images/design_shell.svg +++ b/resources/images/design_shell.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_sketch.svg b/resources/images/design_sketch.svg index 102a9fe493..b4177317eb 100644 --- a/resources/images/design_sketch.svg +++ b/resources/images/design_sketch.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_slot.svg b/resources/images/design_slot.svg index f1c72f8c54..db7c497474 100644 --- a/resources/images/design_slot.svg +++ b/resources/images/design_slot.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_slot_arc.svg b/resources/images/design_slot_arc.svg index f17ab89144..63be75cb8b 100644 --- a/resources/images/design_slot_arc.svg +++ b/resources/images/design_slot_arc.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_step.svg b/resources/images/design_step.svg index 4bdf7811ff..a4a92b7b20 100644 --- a/resources/images/design_step.svg +++ b/resources/images/design_step.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_surface.svg b/resources/images/design_surface.svg index 1299dd1a97..5beec5ce48 100644 --- a/resources/images/design_surface.svg +++ b/resources/images/design_surface.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_svg.svg b/resources/images/design_svg.svg index 34a0685153..daa5f5129a 100644 --- a/resources/images/design_svg.svg +++ b/resources/images/design_svg.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_sweep.svg b/resources/images/design_sweep.svg index f1cc301b1e..876f23745a 100644 --- a/resources/images/design_sweep.svg +++ b/resources/images/design_sweep.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_tangentarc.svg b/resources/images/design_tangentarc.svg index 9e53da5c8e..bbcdd0ed5f 100644 --- a/resources/images/design_tangentarc.svg +++ b/resources/images/design_tangentarc.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_text.svg b/resources/images/design_text.svg index 78da223f62..cae04fb4c2 100644 --- a/resources/images/design_text.svg +++ b/resources/images/design_text.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_thicken.svg b/resources/images/design_thicken.svg index bfcd356cfa..b1c78a145c 100644 --- a/resources/images/design_thicken.svg +++ b/resources/images/design_thicken.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_thread.svg b/resources/images/design_thread.svg index 9bff4d778b..e27125f37a 100644 --- a/resources/images/design_thread.svg +++ b/resources/images/design_thread.svg @@ -1 +1 @@ - + diff --git a/resources/images/design_trim.svg b/resources/images/design_trim.svg index 405e9fe3c4..f052f1fad7 100644 --- a/resources/images/design_trim.svg +++ b/resources/images/design_trim.svg @@ -1 +1 @@ - + diff --git a/resources/images/toolbar_add_plate_bodies.svg b/resources/images/toolbar_add_plate_bodies.svg new file mode 100644 index 0000000000..af5a19d666 --- /dev/null +++ b/resources/images/toolbar_add_plate_bodies.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/images/toolbar_add_plate_bodies_dark.svg b/resources/images/toolbar_add_plate_bodies_dark.svg new file mode 100644 index 0000000000..50af0b90e2 --- /dev/null +++ b/resources/images/toolbar_add_plate_bodies_dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/shaders/110/phong.fs b/resources/shaders/110/phong.fs index 7b9a522eaa..a47a24fdea 100644 --- a/resources/shaders/110/phong.fs +++ b/resources/shaders/110/phong.fs @@ -64,6 +64,10 @@ uniform PrintVolumeDetection print_volume; uniform float z_far; uniform float z_near; uniform bool enable_ssao; +// 1 = the Design tab's studio lighting (studio_shade below); 0 = the two-light model above, which +// every other canvas keeps. world_up_eye is world +Z in eye space, for the hemisphere ambient. +uniform int lighting_model; +uniform vec3 world_up_eye; // Depth-based shadow map (object-on-object and self shadows). shadow_intensity == 0 disables it. uniform sampler2D shadow_map; @@ -210,6 +214,27 @@ float shadow_shade() return 1.0 - shadow_intensity * (sum / 25.0); } +// Studio lighting for the Design tab. The default model lights every face from near the camera, +// so the sides of a part come out in nearly the same tone and its form is hard to read. This one +// separates faces by their orientation in the WORLD (a sky/ground hemisphere: up-facing faces +// cool and bright, down-facing ones warm and dark), keeps a strong key light from the upper left +// and a weak fill from the right, gives a plastic-like highlight, and darkens the base colour +// toward the silhouette while adding a faint sheen there, so curved faces read as round. +vec3 studio_shade(vec3 base, vec3 n, vec3 v) +{ + vec3 key = normalize(vec3(-0.45, 0.60, 0.66)); + vec3 fill = normalize(vec3(0.70, -0.15, 0.70)); + float hemi = 0.5 + 0.5 * dot(n, normalize(world_up_eye)); + vec3 ambient = mix(vec3(0.16, 0.15, 0.14), vec3(0.40, 0.42, 0.46), hemi); + float kd = max(dot(n, key), 0.0); + float fd = max(dot(n, fill), 0.0); + vec3 diffuse = ambient + vec3(0.60) * kd + vec3(0.20) * fd; + float spec = 0.28 * pow(max(dot(n, normalize(key + v)), 0.0), 48.0) + + 0.06 * pow(max(dot(n, normalize(fill + v)), 0.0), 24.0); + float rim = pow(1.0 - clamp(dot(n, v), 0.0, 1.0), 3.0); + return base * diffuse * (1.0 - 0.30 * rim) + vec3(spec + 0.08 * rim); +} + void main() { if (any(lessThan(clipping_planes_dots, ZERO))) @@ -266,9 +291,11 @@ void main() // SSAO is applied in post-process pass. Keep base lighting unchanged here. float shade = shadow_shade(); + vec3 lit = (lighting_model == 1) ? studio_shade(color.rgb, normal, view_dir) + : (vec3(specular) + window_reflection + color.rgb * diffuse) * PHONG_BRIGHTNESS; if (is_outline) { - vec3 shaded_rgb = (vec3(specular) + window_reflection + color.rgb * diffuse) * PHONG_BRIGHTNESS * shade; + vec3 shaded_rgb = lit * shade; vec4 shaded_color = vec4(clamp(shaded_rgb, vec3(0.0), vec3(1.0)), color.a); vec2 fragCoord = gl_FragCoord.xy; float s = DetectSilho(fragCoord); @@ -286,5 +313,5 @@ void main() gl_FragColor = vec4(clamp((0.45 * texture2D(environment_tex, normalize(eye_normal).xy * 0.5 + 0.5).xyz + window_reflection + 0.8 * color.rgb * diffuse) * PHONG_BRIGHTNESS * shade, vec3(0.0), vec3(1.0)), color.a); #endif else - gl_FragColor = vec4(clamp((vec3(specular) + window_reflection + color.rgb * diffuse) * PHONG_BRIGHTNESS * shade, vec3(0.0), vec3(1.0)), color.a); + gl_FragColor = vec4(clamp(lit * shade, vec3(0.0), vec3(1.0)), color.a); } \ No newline at end of file diff --git a/resources/shaders/140/phong.fs b/resources/shaders/140/phong.fs index 03ffbb3e5c..809621b76d 100644 --- a/resources/shaders/140/phong.fs +++ b/resources/shaders/140/phong.fs @@ -74,6 +74,10 @@ uniform PrintVolumeDetection print_volume; uniform float z_far; uniform float z_near; uniform bool enable_ssao; +// 1 = the Design tab's studio lighting (studio_shade below); 0 = the two-light model above, which +// every other canvas keeps. world_up_eye is world +Z in eye space, for the hemisphere ambient. +uniform int lighting_model; +uniform vec3 world_up_eye; // Depth-based shadow map (object-on-object and self shadows). shadow_intensity == 0 disables it. uniform sampler2D shadow_map; @@ -267,6 +271,27 @@ float shadow_shade() return 1.0 - shadow_intensity * (sum / 25.0); } +// Studio lighting for the Design tab. The default model lights every face from near the camera, +// so the sides of a part come out in nearly the same tone and its form is hard to read. This one +// separates faces by their orientation in the WORLD (a sky/ground hemisphere: up-facing faces +// cool and bright, down-facing ones warm and dark), keeps a strong key light from the upper left +// and a weak fill from the right, gives a plastic-like highlight, and darkens the base colour +// toward the silhouette while adding a faint sheen there, so curved faces read as round. +vec3 studio_shade(vec3 base, vec3 n, vec3 v) +{ + vec3 key = normalize(vec3(-0.45, 0.60, 0.66)); + vec3 fill = normalize(vec3(0.70, -0.15, 0.70)); + float hemi = 0.5 + 0.5 * dot(n, normalize(world_up_eye)); + vec3 ambient = mix(vec3(0.16, 0.15, 0.14), vec3(0.40, 0.42, 0.46), hemi); + float kd = max(dot(n, key), 0.0); + float fd = max(dot(n, fill), 0.0); + vec3 diffuse = ambient + vec3(0.60) * kd + vec3(0.20) * fd; + float spec = 0.28 * pow(max(dot(n, normalize(key + v)), 0.0), 48.0) + + 0.06 * pow(max(dot(n, normalize(fill + v)), 0.0), 24.0); + float rim = pow(1.0 - clamp(dot(n, v), 0.0, 1.0), 3.0); + return base * diffuse * (1.0 - 0.30 * rim) + vec3(spec + 0.08 * rim); +} + void main() { if (any(lessThan(clipping_planes_dots, ZERO))) @@ -323,9 +348,11 @@ void main() // SSAO is applied in post-process pass. Keep base lighting unchanged here. float shade = shadow_shade(); + vec3 lit = (lighting_model == 1) ? studio_shade(color.rgb, normal, view_dir) + : (vec3(specular) + window_reflection + color.rgb * diffuse) * PHONG_BRIGHTNESS; if (is_outline) { - vec3 shaded_rgb = (vec3(specular) + window_reflection + color.rgb * diffuse) * PHONG_BRIGHTNESS * shade; + vec3 shaded_rgb = lit * shade; vec4 shaded_color = vec4(clamp(shaded_rgb, vec3(0.0), vec3(1.0)), color.a); float s = DetectSilho(gl_FragCoord.xy); if (s < 0.01) @@ -337,5 +364,5 @@ void main() out_color = vec4(clamp((0.45 * texture(environment_tex, normalize(eye_normal).xy * 0.5 + 0.5).xyz + window_reflection + 0.8 * color.rgb * diffuse) * PHONG_BRIGHTNESS * shade, vec3(0.0), vec3(1.0)), color.a); #endif else - out_color = vec4(clamp((vec3(specular) + window_reflection + color.rgb * diffuse) * PHONG_BRIGHTNESS * shade, vec3(0.0), vec3(1.0)), color.a); + out_color = vec4(clamp(lit * shade, vec3(0.0), vec3(1.0)), color.a); } \ No newline at end of file diff --git a/scripts/CAD/check-gui-context-menu.py b/scripts/CAD/check-gui-context-menu.py index 28e404ecf0..9e76fa9fe1 100644 --- a/scripts/CAD/check-gui-context-menu.py +++ b/scripts/CAD/check-gui-context-menu.py @@ -95,7 +95,8 @@ def load_table(): f.append(cur.strip()) if len(f) < 12: continue - lit = lambda s: None if s == "nullptr" else s.strip('"') + # User-facing fields are wrapped in the L("...") gettext marker; strip it. + lit = lambda s: None if s == "nullptr" else re.sub(r'^L\((.*)\)$', r'\1', s).strip('"') out.append({"id": lit(f[0]), "name": lit(f[1]), "row": int(f[2]), "key": lit(f[3]), "action": lit(f[4]), "accepts": int(f[6].rstrip("u"), 0), "need_bodies": int(f[7]), "need_sketches": int(f[8]), diff --git a/scripts/CAD/gen_offer_table.py b/scripts/CAD/gen_offer_table.py index 442be27697..3716b95db3 100644 --- a/scripts/CAD/gen_offer_table.py +++ b/scripts/CAD/gen_offer_table.py @@ -37,6 +37,12 @@ def cstr(s): return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"' +def tstr(s): + # User-facing text: wrapped in the no-op L() marker so xgettext puts it in the catalogue. + # The DesignPanel translates it at use; without the marker it compiles and never translates. + return "nullptr" if s is None else "L(" + cstr(s) + ")" + + def validate(A): """Refuse an atlas the header cannot represent, naming every fault at once. @@ -81,6 +87,10 @@ def main(): "", "#include ", "", + "#ifndef L", + "#define L(s) s // gettext marker, as in slic3r/GUI/I18N.hpp", + "#endif", + "", "namespace Slic3r { namespace GUI {", "", "// What the viewport has selected. Ordered as in tool_atlas.json; the bitmask in", @@ -103,8 +113,8 @@ def main(): "// nullptr -> kernel support exists, no GUI path yet (row shows disabled)", "struct OfferVerb {", " const char* id;", - " const char* name; // drawing-office word (L10); translated at use with wxGetTranslation", - " int row; // 0..7, the ratified index — NEVER reorder", + " const char* name; // drawing-office word (L10); marked L(); translated at use", + " int row; // index into kOfferRowNames, the ratified address — NEVER reorder", " const char* key; // shortcut shown in the row, or nullptr", " const char* action;", " const char* refusal; // why this row is greyed, in the product's own words", @@ -125,11 +135,20 @@ def main(): "// Row labels, in ratified order.", "static const char* const kOfferRowNames[] = {", ] + # A flat row's label is never shown, so it is not marked for translation. for s in A["slots"]: - lines.append(f' "{s["label"]}",') + lines.append(f' {cstr(s["label"]) if s.get("flat") else tstr(s["label"])},') lines += [ "};", f"static const int kOfferRowCount = {len(slots)};", + "// A flat row is not a family: its verbs come first, at the top level of the offer, each", + "// an item of its own.", + "static const bool kOfferRowFlat[] = {", + ] + for s in A["slots"]: + lines.append(f' {"true" if s.get("flat") else "false"},') + lines += [ + "};", "", "static const OfferVerb kOfferVerbs[] = {", ] @@ -151,12 +170,12 @@ def main(): n = v.get("needs") or {} lines.append( " {%s, %s, %d, %s, %s, %s, 0x%08xu, %d, %d, %s, %s, %s, %s, %s}," % ( - cstr(v["id"]), cstr(v["name"]), slots.index(v["slot"]), - cstr(v.get("key")), cstr(v.get("action")), cstr(v.get("refusal")), + cstr(v["id"]), tstr(v["name"]), slots.index(v["slot"]), + cstr(v.get("key")), cstr(v.get("action")), tstr(v.get("refusal")), mask, n.get("bodies", 0), n.get("sketches", 0), "true" if n.get("sheet") else "false", "true" if v.get("mode") == "sketch" else "false", - cstr(v.get("family")), cstr(v.get("icon")), cstr(v.get("hint")))) + tstr(v.get("family")), cstr(v.get("icon")), tstr(v.get("hint")))) lines += [ "};", f"static const int kOfferVerbCount = {len(A['verbs'])};", diff --git a/scripts/CAD/tool_atlas.json b/scripts/CAD/tool_atlas.json index 224b118c9e..1e7f1bf968 100644 --- a/scripts/CAD/tool_atlas.json +++ b/scripts/CAD/tool_atlas.json @@ -67,6 +67,12 @@ "angle": 225, "label": "Modify", "why": "edits or removes what is already there" + }, + { + "id": "top", + "label": "Top", + "flat": true, + "why": "the verbs a selection is opened for most. A flat slot is not a family: its verbs sit first, at the top level of the offer, each an item of its own, and its label is never shown. Appended last, so no ratified index moved." } ], "selections": [ @@ -326,7 +332,7 @@ "id": "rib", "name": "Rib", "slot": "add", - "key": null, + "key": "R", "feature": "Rib", "mcp": "rib", "accepts": [ @@ -343,7 +349,7 @@ }, { "id": "boolean", - "name": "Union", + "name": "Join", "slot": "add", "key": "Shift+B", "feature": "Boolean", @@ -354,7 +360,7 @@ "needs": { "bodies": 2 }, - "refusal": "Boolean needs two bodies — create or import a second solid", + "refusal": "Boolean needs two bodies — add or import a second one", "gui": true, "action": "btn:bool#0", "icon": "design_boolean", @@ -373,7 +379,7 @@ "needs": { "bodies": 2 }, - "refusal": "Boolean needs two bodies — create or import a second solid", + "refusal": "Boolean needs two bodies — add or import a second one", "gui": true, "action": "btn:bool#1", "mode": null, @@ -393,7 +399,7 @@ "needs": { "bodies": 2 }, - "refusal": "Boolean needs two bodies — create or import a second solid", + "refusal": "Boolean needs two bodies — add or import a second one", "gui": true, "action": "btn:bool#2", "mode": null, @@ -483,7 +489,7 @@ "needs": { "sheet": true }, - "refusal": "target is not a sheet body", + "refusal": "Thicken Surface needs a surface body — make one with a Surface tool first", "gui": true, "action": "fly:surface#5", "icon": "design_thicken", @@ -507,7 +513,7 @@ "gui": true, "action": "key:S+H", "icon": "design_hole", - "hint": "Drill a hole, centred on a picked face or placed on a plane" + "hint": "Drill a hole, centerd on a picked face or placed on a plane" }, { "id": "thread", @@ -543,7 +549,7 @@ "needs": { "bodies": 1 }, - "refusal": "Shell needs a solid body", + "refusal": "Shell needs a solid body — add or import one first", "gui": true, "action": "key:S+K", "icon": "design_shell", @@ -569,7 +575,7 @@ "needs": { "bodies": 1 }, - "refusal": "Create a solid body to cut first", + "refusal": "Cut needs a solid body — add or import one first", "gui": true, "action": "key:S+X", "icon": "design_cut", @@ -594,7 +600,7 @@ "needs": { "bodies": 1 }, - "refusal": "Split needs a solid body", + "refusal": "Split needs a solid body — add or import one first", "gui": false, "action": null, "icon": null, @@ -677,7 +683,7 @@ "needs": { "sheet": true }, - "refusal": "target is not a sheet body", + "refusal": "Surface Offset needs a surface body — make one with a Surface tool first", "gui": true, "action": "fly:surface#4", "icon": "design_offset", @@ -699,7 +705,7 @@ "needs": { "bodies": 1 }, - "refusal": "Create a solid body to pattern first", + "refusal": "Pattern needs a solid body — add or import one first", "gui": true, "action": "btn:pat#0", "icon": "design_array", @@ -721,7 +727,7 @@ "needs": { "bodies": 1 }, - "refusal": "Create a solid body to pattern first", + "refusal": "Pattern needs a solid body — add or import one first", "gui": true, "action": "btn:pat#1", "mode": null, @@ -768,7 +774,7 @@ "needs": { "bodies": 1 }, - "refusal": "Pattern on curve needs a body and a curve", + "refusal": "Pattern on Curve needs a body and a curve", "gui": false, "action": null, "icon": null, @@ -816,11 +822,11 @@ "needs": { "bodies": 2 }, - "refusal": "A mate needs two coordinate systems", + "refusal": "Mate needs two coordinate systems — create them first", "gui": true, "action": "fly:placement#2", "icon": "design_c_coincident", - "hint": "Assembly: align two CoordSys features (fastened, planar, revolute, slider, cylindrical)" + "hint": "Assembly: align two coordinate systems (fastened, planar, revolute, slider, cylindrical)" }, { "id": "align", @@ -835,7 +841,7 @@ "needs": { "bodies": 1 }, - "refusal": "Align needs a body", + "refusal": "Align needs a body — add or import one first", "gui": false, "action": null, "icon": null, @@ -885,7 +891,7 @@ }, { "id": "coordsys_v", - "name": "Coord Sys", + "name": "Coordinate system", "slot": "reference", "key": "Shift+C", "feature": "CoordSys", @@ -972,7 +978,7 @@ }, { "id": "mass_props", - "name": "Mass", + "name": "Volume and area", "slot": "reference", "key": null, "feature": null, @@ -1003,14 +1009,15 @@ "feature": null, "mcp": "check_interference", "accepts": [ + "body_solid", "bodies_2" ], "needs": { "bodies": 2 }, - "refusal": null, - "gui": false, - "action": null, + "refusal": "Interference needs at least two bodies", + "gui": true, + "action": "btn:interference", "icon": null, "hint": "Check whether two bodies overlap — reports, changes nothing" }, @@ -1043,7 +1050,7 @@ { "id": "rename", "name": "Rename…", - "slot": "modify", + "slot": "top", "key": "F2", "feature": "Tree", "mcp": null, @@ -1082,8 +1089,8 @@ }, { "id": "colour", - "name": "Colour", - "slot": "modify", + "name": "Color", + "slot": "top", "key": null, "feature": null, "mcp": null, @@ -1104,7 +1111,7 @@ "gui": true, "action": "btn:colour", "icon": "color_palette", - "hint": "Set the selected body's display colour" + "hint": "Set the selected body's display color" }, { "id": "delete", @@ -1227,7 +1234,7 @@ }, { "id": "sk_rect_center", - "name": "Centre rectangle", + "name": "Center rectangle", "slot": "create", "key": null, "feature": "Sketch", @@ -1296,7 +1303,7 @@ }, { "id": "sk_circle", - "name": "Centre circle", + "name": "Center circle", "slot": "create", "key": "C", "feature": "Sketch", @@ -1411,7 +1418,7 @@ }, { "id": "sk_arc_center", - "name": "Centre-point arc", + "name": "Center-point arc", "slot": "create", "key": null, "feature": "Sketch", @@ -1453,7 +1460,7 @@ "action": "key:S", "family": "Slot", "icon": "design_slot", - "hint": "Slot — two centerline ends, then end radius" + "hint": "Slot — two centerline ends, then the width" }, { "id": "sk_slot_arc", @@ -1567,7 +1574,7 @@ "mode": "sketch", "family": "Polygon", "icon": "design_polygon", - "hint": "Triangle — click centre, then a vertex" + "hint": "Triangle — click center, then a vertex" }, { "id": "sk_poly_4", @@ -1590,7 +1597,7 @@ "mode": "sketch", "family": "Polygon", "icon": "design_polygon", - "hint": "Square — click centre, then a vertex" + "hint": "Square — click center, then a vertex" }, { "id": "sk_poly_5", @@ -1613,7 +1620,7 @@ "mode": "sketch", "family": "Polygon", "icon": "design_polygon", - "hint": "Pentagon — click centre, then a vertex" + "hint": "Pentagon — click center, then a vertex" }, { "id": "sk_polygon", @@ -1636,7 +1643,7 @@ "action": "btn:poly#6", "family": "Polygon", "icon": "design_polygon", - "hint": "Hexagon — click centre, then a vertex" + "hint": "Hexagon — click center, then a vertex" }, { "id": "sk_poly_8", @@ -1659,7 +1666,7 @@ "mode": "sketch", "family": "Polygon", "icon": "design_polygon", - "hint": "Octagon — click centre, then a vertex" + "hint": "Octagon — click center, then a vertex" }, { "id": "sk_poly_12", @@ -1682,7 +1689,7 @@ "mode": "sketch", "family": "Polygon", "icon": "design_polygon", - "hint": "Dodecagon — click centre, then a vertex" + "hint": "Dodecagon — click center, then a vertex" }, { "id": "sk_poly_inscribed", @@ -1772,7 +1779,7 @@ "action": "btn:text", "mode": "sketch", "icon": "design_text", - "hint": "Type text; its outline is added to this sketch as editable lines" + "hint": "Type text; it becomes a Text feature on this sketch's plane, editable later" }, { "id": "sk_svg", @@ -2048,7 +2055,7 @@ { "id": "constrain", "name": "Constrain sketch", - "slot": "modify", + "slot": "reference", "key": null, "feature": "Sketch", "mcp": null, @@ -2067,7 +2074,8 @@ "note": [ "Same verb, model-mode vocabulary: offered when a SKETCH is selected (bit 14, SkLoop), the", "state a user is in right after finishing one. Without this row the only way in was the", - "toolbar icon, and constraints read as absent — see the Onshape-comparison report." + "toolbar icon, and constraints read as absent — see the Onshape-comparison report.", + "It sits in the Reference row, the address the sketch-mode Constrain has: one verb, one row." ] }, { diff --git a/src/libslic3r/CAD/CadDocument.cpp b/src/libslic3r/CAD/CadDocument.cpp index 447ca138b7..033de877dc 100644 --- a/src/libslic3r/CAD/CadDocument.cpp +++ b/src/libslic3r/CAD/CadDocument.cpp @@ -9,10 +9,12 @@ #include #include #include +#include #include #include #include +#include #include #include #include @@ -36,6 +38,7 @@ #include #include #include +#include #include #include #include @@ -78,15 +81,25 @@ #include #include #include +#include +#include +#include #include #include +#include #include #include #include #include #include +#include #include +#include // faces_made_by: is a point on a face +#include +#include +#include +#include #include #include #include @@ -172,6 +185,21 @@ static TopoDS_Wire make_helix_spine(const CadFeature& f, std::string& err) // - internal: the V is CUT from the wall -> a sunken helical groove. The cut MUST // go outward into the wall to be visible; an inward V (the old behaviour) only // sweeps already-empty bore space and removes nothing. +// The V between two radii: its base (the axial edge) at `base_r`, its apex at `apex_r`. The +// base is meant to sit INSIDE the material it is fused to or cut from, the apex out in the space +// the thread reaches into. make_thread_profile below is the legacy layout. +static TopoDS_Wire make_thread_v(const gp_Pnt& origin, const gp_Dir& xdir, const gp_Dir& zdir, + double base_r, double apex_r, double pitch) +{ + gp_Vec vx(xdir), vz(zdir); + const double half = 0.42 * pitch; // < pitch/2: adjacent turns must not touch (see below) + gp_Pnt top (origin.XYZ() + (vx * base_r).XYZ() + (vz * ( half)).XYZ()); + gp_Pnt bot (origin.XYZ() + (vx * base_r).XYZ() + (vz * (-half)).XYZ()); + gp_Pnt apex(origin.XYZ() + (vx * apex_r).XYZ()); + BRepBuilderAPI_MakePolygon poly(top, bot, apex, true); + return poly.Wire(); +} + static TopoDS_Wire make_thread_profile(const gp_Pnt& origin, const gp_Dir& xdir, const gp_Dir& zdir, double radius, double pitch, double depth, bool internal) @@ -206,7 +234,10 @@ static TopoDS_Wire make_thread_profile(const gp_Pnt& origin, const gp_Dir& xdir, // Evaluate an arithmetic expression to a double. Supports + - * /, parentheses, // unary minus, decimal literals, and identifiers resolved via `vars`. Functions: -// sqrt, abs, sin, cos, tan (degrees), min, max, and the constant `pi`. +// sqrt, abs, sin, cos, tan, min, max, deg, rad, and the constant `pi`. +// ANGLES ARE DEGREES — every angle field in a feature is, so sin(30) is 0.5. `pi` is the number +// (for 2 * pi * r); to use it as an angle convert it: sin(deg(pi / 2)) = 1. deg() turns radians +// into degrees and rad() degrees into radians. // Throws std::runtime_error on any parse or lookup error, div-by-zero, bad arity. static double eval_expr(const std::string& src, const std::map& vars) { @@ -363,6 +394,8 @@ static double eval_expr(const std::string& src, const std::map& variables) std::string id = expr.substr(start, i - start); // skip "pi" and function names — they're built-ins, not variables if (id == "pi" || id == "sqrt" || id == "abs" || id == "sin" || id == "cos" || - id == "tan" || id == "min" || id == "max") continue; + id == "tan" || id == "min" || id == "max" || id == "deg" || id == "rad") continue; if (!variables.count(id)) throw std::runtime_error("unknown identifier: " + id); scope[id] = resolve(id); continue; @@ -430,11 +463,17 @@ evaluate_variables(const std::map& variables) static void assign_field(CadFeature& f, const std::string& field, double value) { // double fields (alphabetical) + if (field == "bool_tolerance") { f.bool_tolerance = value; return; } + if (field == "cut_offset") { f.cut_offset = value; return; } if (field == "distance") { f.distance = value; return; } if (field == "distance2") { f.distance2 = value; return; } if (field == "draft_angle") { f.draft_angle = value; return; } if (field == "dressup_size") { f.dressup_size = value; return; } if (field == "height") { f.height = value; return; } + if (field == "helix_height") { f.helix_height = value; return; } + if (field == "helix_pitch") { f.helix_pitch = value; return; } + if (field == "helix_radius") { f.helix_radius = value; return; } + if (field == "helix_taper_deg") { f.helix_taper_deg = value; return; } if (field == "hole_cbore_diameter") { f.hole_cbore_diameter = value; return; } if (field == "hole_cbore_depth") { f.hole_cbore_depth = value; return; } if (field == "hole_csink_angle") { f.hole_csink_angle = value; return; } @@ -445,28 +484,55 @@ static void assign_field(CadFeature& f, const std::string& field, double value) if (field == "hole_y") { f.hole_y = value; return; } if (field == "import_scale_x") { f.import_scale_x = value; return; } if (field == "import_scale_y") { f.import_scale_y = value; return; } + if (field == "mate_angle") { f.mate_angle = value; return; } + if (field == "mate_offset") { f.mate_offset = value; return; } if (field == "pattern_angle") { f.pattern_angle = value; return; } if (field == "pattern_spacing") { f.pattern_spacing = value; return; } if (field == "plane_angle_tilt") { f.plane_angle_tilt = value; return; } if (field == "plane_offset") { f.plane_offset = value; return; } + if (field == "plane_u_size") { f.plane_u_size = value; return; } + if (field == "plane_v_size") { f.plane_v_size = value; return; } if (field == "radius") { f.radius = value; return; } if (field == "revolve_angle") { f.revolve_angle = value; return; } if (field == "rib_depth") { f.rib_depth = value; return; } if (field == "rib_thickness") { f.rib_thickness = value; return; } if (field == "shell_thickness") { f.shell_thickness = value; return; } if (field == "taper_deg") { f.taper_deg = value; return; } + if (field == "thicken_thickness") { f.thicken_thickness = value; return; } if (field == "thread_depth") { f.thread_depth = value; return; } if (field == "thread_height") { f.thread_height = value; return; } if (field == "thread_pitch") { f.thread_pitch = value; return; } if (field == "thread_radius") { f.thread_radius = value; return; } + // The Thread card shows a DIAMETER (the size a thread is named by: M6, 1/4"), so that is + // the name it offers for binding; thread_radius stays for recipes that already use it. + if (field == "thread_diameter") { f.thread_radius = 0.5 * value; return; } if (field == "thread_x") { f.thread_x = value; return; } if (field == "thread_y") { f.thread_y = value; return; } if (field == "width") { f.width = value; return; } + if (field == "xf_angle_deg") { f.xf_angle_deg = value; return; } // int fields (rounded) if (field == "pattern_count") { f.pattern_count = (int)std::lround(value); return; } throw std::runtime_error("unknown parameter: " + field); } +bool CadDocument::produces_body(CadFeatureType t) +{ + switch (t) { + case CadFeatureType::Sketch: case CadFeatureType::Helix: case CadFeatureType::Plane: + case CadFeatureType::Axis: case CadFeatureType::CoordSys: case CadFeatureType::Project: + return false; + default: + return true; + } +} + +bool CadDocument::is_bindable_field(const std::string& field) +{ + CadFeature probe; + try { assign_field(probe, field, 0.0); return true; } + catch (const std::exception&) { return false; } +} + // --------------------------------------------------------------------------- // Sample a sketch entity at parameter t in [0,1]. Handles Line, Arc, BSpline (cubic, 4 poles). @@ -777,29 +843,46 @@ bool CadDocument::solve_sketch_feature(int index) if (f.constraints.empty()) return true; + // Legacy point-profile path. Every index is checked before the solver sees it: an unset + // (-1) or stale index made get_point/residuals read m_vars[2*-1]. A constraint type this + // solver cannot express is a failure, not a silent no-op, and the points are written back + // only when the solve succeeded — the entity path's rule (SketchSolver.cpp) too. + const int np = int(f.profile.points.size()); + auto ok_idx = [np](std::initializer_list ids) { + for (int i : ids) if (i < 0 || i >= np) return false; + return true; + }; SketchConstraints sc; for (const Vec2d& p : f.profile.points) sc.add_point(p.x(), p.y()); for (const SketchConstraintDef& c : f.constraints) { + bool valid = true; switch (c.type) { - case SketchConstraintType::Fix: sc.fix_point(c.a); break; - case SketchConstraintType::Coincident: sc.coincident(c.a, c.b); break; - case SketchConstraintType::Horizontal: sc.horizontal(c.a, c.b); break; - case SketchConstraintType::Vertical: sc.vertical(c.a, c.b); break; - case SketchConstraintType::Distance: sc.distance(c.a, c.b, c.value); break; - case SketchConstraintType::LockX: sc.lock_x(c.a, c.value); break; - case SketchConstraintType::LockY: sc.lock_y(c.a, c.value); break; - case SketchConstraintType::EqualLength: sc.equal_length(c.a, c.b, c.c, c.d); break; - case SketchConstraintType::Parallel: sc.parallel(c.a, c.b, c.c, c.d); break; - case SketchConstraintType::Perpendicular:sc.perpendicular(c.a, c.b, c.c, c.d); break; + case SketchConstraintType::Fix: if ((valid = ok_idx({c.a}))) sc.fix_point(c.a); break; + case SketchConstraintType::Coincident: if ((valid = ok_idx({c.a, c.b}))) sc.coincident(c.a, c.b); break; + case SketchConstraintType::Horizontal: if ((valid = ok_idx({c.a, c.b}))) sc.horizontal(c.a, c.b); break; + case SketchConstraintType::Vertical: if ((valid = ok_idx({c.a, c.b}))) sc.vertical(c.a, c.b); break; + case SketchConstraintType::Distance: if ((valid = ok_idx({c.a, c.b}))) sc.distance(c.a, c.b, c.value); break; + case SketchConstraintType::LockX: if ((valid = ok_idx({c.a}))) sc.lock_x(c.a, c.value); break; + case SketchConstraintType::LockY: if ((valid = ok_idx({c.a}))) sc.lock_y(c.a, c.value); break; + case SketchConstraintType::EqualLength: if ((valid = ok_idx({c.a, c.b, c.c, c.d}))) sc.equal_length(c.a, c.b, c.c, c.d); break; + case SketchConstraintType::Parallel: if ((valid = ok_idx({c.a, c.b, c.c, c.d}))) sc.parallel(c.a, c.b, c.c, c.d); break; + case SketchConstraintType::Perpendicular:if ((valid = ok_idx({c.a, c.b, c.c, c.d}))) sc.perpendicular(c.a, c.b, c.c, c.d); break; + // The legacy solver implements these too; they used to be dropped without a word. + case SketchConstraintType::Midpoint: if ((valid = ok_idx({c.a, c.b, c.c}))) sc.midpoint(c.a, c.b, c.c); break; + case SketchConstraintType::Symmetric: if ((valid = ok_idx({c.a, c.b, c.c, c.d}))) sc.symmetric(c.a, c.b, c.c, c.d); break; + case SketchConstraintType::Angle: if ((valid = ok_idx({c.a, c.b, c.c, c.d}))) sc.angle(c.a, c.b, c.c, c.d, c.value); break; + case SketchConstraintType::PointOnLine: if ((valid = ok_idx({c.a, c.b, c.c}))) sc.point_line_distance(c.a, c.b, c.c, c.value); break; + default: valid = false; break; // entity-only types } + if (!valid) return false; } - const bool ok = sc.solve(); + if (!sc.solve()) return false; for (size_t i = 0; i < f.profile.points.size(); ++i) f.profile.points[i] = sc.get_point(int(i)); - return ok; + return true; } int CadDocument::add_extrude(int sketch_ref, double distance, bool symmetric, @@ -873,6 +956,19 @@ int CadDocument::add_fillet(double radius, int edge_id, const std::string& name) return int(features.size()) - 1; } +int CadDocument::add_fillet(double radius, const std::vector& edge_ids, const std::string& name) +{ + if (edge_ids.size() == 1) return add_fillet(radius, edge_ids.front(), name); + CadFeature f; + f.type = CadFeatureType::Fillet; + f.name = name; + f.dressup_size = radius; + f.dressup_edges = edge_ids; + f.dressup_edge = edge_ids.empty() ? -1 : edge_ids.front(); + features.push_back(f); + return int(features.size()) - 1; +} + int CadDocument::add_chamfer(double distance, FaceGroup faces, const std::string& name) { CadFeature f; @@ -895,6 +991,19 @@ int CadDocument::add_chamfer(double distance, int edge_id, const std::string& na return int(features.size()) - 1; } +int CadDocument::add_chamfer(double distance, const std::vector& edge_ids, const std::string& name) +{ + if (edge_ids.size() == 1) return add_chamfer(distance, edge_ids.front(), name); + CadFeature f; + f.type = CadFeatureType::Chamfer; + f.name = name; + f.dressup_size = distance; + f.dressup_edges = edge_ids; + f.dressup_edge = edge_ids.empty() ? -1 : edge_ids.front(); + features.push_back(f); + return int(features.size()) - 1; +} + int CadDocument::add_hole(double diameter, double depth, bool through, double x, double y, const SketchPlane& plane, const std::string& name) @@ -912,34 +1021,38 @@ int CadDocument::add_hole(double diameter, double depth, bool through, return int(features.size()) - 1; } -// Hole standards lookup table (representative ISO 273 medium / ISO 4762 / ANSI unified). -struct HoleStdEntry { const char* desig; double clearance; double cbore_d; double cbore_depth; double csink_d; }; +// Hole standards: clearance hole, counterbore for a socket head cap screw, countersink for a +// flat head screw. Metric: ISO 273 medium clearance, DIN 974-1 counterbores for ISO 4762 (depth +// = head height + ~0.4 mm), 90° countersinks sized to the ISO 10642 head. Inch: ASME B18.3 / +// B18.6.3 — the counterbore is the head plus 1/32", the countersink is 82°, the angle an inch +// flat head is made with (a 90° seat under an 82° head bears only on its rim). +// Designations are the ones ThreadStandards uses ("1/4-20 UNC"); the bare form without the +// series ("1/4-20") is still accepted, since recipes and scripts were written with it. +struct HoleStdEntry { const char* desig; double clearance; double cbore_d; double cbore_depth; + double csink_d; double csink_angle; }; static const HoleStdEntry kHoleStdTable[] = { - {"M3", 3.4, 6.0, 3.4, 6.3}, - {"M4", 4.5, 8.0, 4.4, 8.4}, - {"M5", 5.5, 10.0, 5.4, 10.4}, - {"M6", 6.6, 11.0, 6.8, 12.6}, - {"M8", 9.0, 15.0, 8.8, 17.3}, - {"M10", 11.0, 18.0, 11.0, 20.0}, - {"#6-32", 3.7, 8.8, 4.2, 8.7}, - {"#8-32", 4.4, 9.9, 5.1, 10.2}, - {"1/4-20", 6.9, 14.4, 7.2, 14.7}, - {"5/16-18", 8.8, 17.0, 8.2, 17.3}, - {"3/8-16", 10.5, 19.6, 9.5, 19.8}, + {"M3", 3.4, 6.5, 3.4, 6.7, 90}, + {"M4", 4.5, 8.0, 4.4, 9.0, 90}, + {"M5", 5.5, 10.0, 5.4, 11.3, 90}, + {"M6", 6.6, 11.0, 6.4, 13.4, 90}, + {"M8", 9.0, 15.0, 8.6, 17.9, 90}, + {"M10", 11.0, 18.0, 10.6, 22.4, 90}, + {"#6-32 UNC", 3.7, 6.4, 3.8, 7.1, 82}, + {"#8-32 UNC", 4.5, 7.9, 4.6, 8.4, 82}, + {"#10-24 UNC", 5.1, 9.5, 5.3, 9.8, 82}, + {"1/4-20 UNC", 6.8, 11.1, 6.9, 12.9, 82}, + {"5/16-18 UNC", 8.4, 13.5, 8.6, 16.1, 82}, + {"3/8-16 UNC", 10.1, 15.9, 10.2, 19.4, 82}, }; -static bool hole_std_lookup(const std::string& desig, double& clearance, - double& cbore_d, double& cbore_depth, double& csink_d) +static const HoleStdEntry* hole_std_lookup(const std::string& desig) { for (const auto& e : kHoleStdTable) { - if (e.desig == desig) { - clearance = e.clearance; - cbore_d = e.cbore_d; - cbore_depth = e.cbore_depth; - csink_d = e.csink_d; - return true; - } + const std::string d(e.desig); + if (d == desig) return &e; + const size_t sp = d.find(' '); + if (sp != std::string::npos && d.compare(0, sp, desig) == 0 && desig.size() == sp) return &e; } - return false; + return nullptr; } int CadDocument::add_hole_styled(double diameter, double depth, bool through, @@ -971,11 +1084,11 @@ int CadDocument::add_hole_standard(const std::string& designation, int style, bo double depth, double x, double y, const SketchPlane& plane, const std::string& name) { - double clearance, cbore_d, cbore_depth, csink_d; - if (!hole_std_lookup(designation, clearance, cbore_d, cbore_depth, csink_d)) + const HoleStdEntry* e = hole_std_lookup(designation); + if (e == nullptr) throw std::runtime_error("unknown hole standard \"" + designation + "\""); - return add_hole_styled(clearance, depth, through, x, y, plane, style, - cbore_d, cbore_depth, csink_d, 90, designation, name); + return add_hole_styled(e->clearance, depth, through, x, y, plane, style, + e->cbore_d, e->cbore_depth, e->csink_d, e->csink_angle, e->desig, name); } int CadDocument::add_thread(double radius, double pitch, double height, double depth, @@ -993,6 +1106,7 @@ int CadDocument::add_thread(double radius, double pitch, double height, double d f.thread_internal = internal; f.thread_x = x; f.thread_y = y; + f.thread_major_nominal = true; // `radius` is the major (nominal) radius features.push_back(f); return int(features.size()) - 1; } @@ -1067,7 +1181,13 @@ int CadDocument::add_pattern(bool circular, int count, double spacing, int dir, f.pattern_spacing = spacing; f.pattern_dir = dir; f.pattern_angle = angle_deg; + f.pattern_inclusive = true; f.target_body = target_body; + // The pattern's plane — its linear axes and the circular pattern's pivot — defaults to the + // XY plane through the MODELING origin (the bed centre), like every other default plane in + // the tab. Left at SketchPlane::XY() it pivoted about the bed's corner. + f.plane = SketchPlane::XY(); + f.plane.origin += modeling_origin; features.push_back(f); return int(features.size()) - 1; } @@ -1537,11 +1657,19 @@ std::vector> CadDocument::resolve_datum_plan // Resolve base reference plane. The default XY/XZ/YZ planes pass through the modeling // origin (bed centre); datum bases (>=3) are already in world coords from earlier passes. SketchPlane base; + bool base_missing = false; if (f.plane_base == 1) { base = SketchPlane::XZ(); base.origin += modeling_origin; } else if (f.plane_base == 2) { base = SketchPlane::YZ(); base.origin += modeling_origin; } else if (f.plane_base >= 3) { const int di = f.plane_base - 3; if (di < int(out.size())) base = out[di].second; + else { + // The datum it was built on is gone or hidden. Fall back to XY through the + // modeling origin like the other defaults, and SAY so in the plane's name — + // this list is what the GUI shows — instead of quietly becoming world XY. + base = SketchPlane::XY(); base.origin += modeling_origin; + base_missing = true; + } } else { base = SketchPlane::XY(); base.origin += modeling_origin; } @@ -1677,7 +1805,7 @@ std::vector> CadDocument::resolve_datum_plan } - out.emplace_back(f.name, result); + out.emplace_back(base_missing ? f.name + " (base plane missing)" : f.name, result); } return out; } @@ -1900,8 +2028,15 @@ void CadDocument::clear() display_mesh = TriangleMesh{}; display_body_meshes.clear(); display_tri_face.clear(); + display_tri_body.clear(); + origin_from_recipe = false; + auto_close_loops = true; + // Variables are document state like the features: left behind, the previous project's + // variables were written into the next project's recipe. + variables.clear(); error.clear(); mate_conflicts.clear(); + ++topo_generation; // every face/edge id handed out before this is stale now // A cleared document is a fresh start with no history. m_undo.clear(); m_redo.clear(); @@ -1945,6 +2080,151 @@ bool CadDocument::redo() return true; } + +// A feature's body index: -1 means "the last body", anything else must exist. An index past +// the end used to fall back to the last body in silence, so a feature whose body had gone away +// went on to work on a different one. +static int pick_body(int ref, int nb) +{ + if (ref == -1) return nb - 1; + if (ref < 0 || ref >= nb) + throw std::runtime_error("a feature works on body " + std::to_string(ref + 1) + + ", which does not exist at that point in the history"); + return ref; +} + +// ---- body and datum-plane references across a change of history --------------------------- +// +// A feature names a body by its INDEX and a datum plane by its ORDINAL (3 + N = the Nth enabled +// Plane feature). Both are positions, and a position only means something against the history +// it was taken in: delete, reorder or hide a feature that makes a body or a datum plane and every +// later reference silently lands on a different one. The fix-up for features[] indices +// (for_each_feature_ref) never covered these two bases. + +// Every field holding a BODY index, and which list it indexes: `true` = the bodies as they stand +// while the feature is applied (recompute's running list), `false` = the finished list (datums +// are resolved against it afterwards). +template +static void for_each_body_ref(CadFeature& f, Visit&& visit) +{ + visit(f.target_body, true); + visit(f.bool_tool_body, true); + visit(f.cut_face_body, true); + visit(f.project_source_body, true); + visit(f.coordsys_body, false); + visit(f.axis_body, false); + visit(f.plane_face_body, false); + visit(f.plane_face2_body, false); + visit(f.plane_edge_body, false); + visit(f.plane_edge2_body, false); +} +static constexpr int kBodyRefCount = 10; +// A body reference whose body was made by a feature that has since been deleted or hidden. +static constexpr int kBodyGone = -1000; + +// Identity of bodies[i]: the feature that made it, and which of that feature's bodies it is. +static CadFeature::BodyId body_id_of(const std::vector& v, int i) +{ + CadFeature::BodyId id; + if (i < 0 || i >= int(v.size())) return id; + id.src = v[i].source_feature; + for (int j = 0; j < i; ++j) + if (v[j].source_feature == id.src) ++id.ord; + return id; +} +static int find_body(const std::vector& v, const CadFeature::BodyId& id) +{ + int ord = 0; + for (int i = 0; i < int(v.size()); ++i) + if (v[i].source_feature == id.src && ord++ == id.ord) return i; + return -1; +} + +// Re-point `f`'s body fields of one kind (`running`) from the identities recorded last time, +// then record the identities they resolve to now. Throws when a pending reference cannot be +// found: the body it named is gone, and working on some other body instead is the bug this +// exists to prevent. +static void settle_body_refs(CadFeature& f, const std::vector& v, bool running) +{ + if (f.body_ref_ids.size() != size_t(kBodyRefCount)) f.body_ref_ids.assign(kBodyRefCount, {}); + int k = 0; + for_each_body_ref(f, [&](int& ref, bool kind) { + CadFeature::BodyId& id = f.body_ref_ids[k++]; + if (kind != running) return; + if (ref == kBodyGone) + throw std::runtime_error("\"" + f.name + "\" works on a body made by a feature that was deleted or hidden"); + if (f.body_refs_pending && id.src >= 0) { + const int i = find_body(v, id); + if (i < 0) + throw std::runtime_error("\"" + f.name + "\" works on a body that does not exist at that point in the history"); + ref = i; + } + id = ref >= 0 ? body_id_of(v, ref) : CadFeature::BodyId{}; + }); +} + +// Datum-plane ordinals: plane_base, axis_plane_a, axis_plane_b hold 3 + N. +template +static void for_each_datum_ref(CadFeature& f, Visit&& visit) +{ + visit(f.plane_base); + visit(f.axis_plane_a); + visit(f.axis_plane_b); +} + +// Prepare every reference for a change of history in which feature `i` ends up at +// `map_feature(i)` (-1 = removed) and enabled as `enabled_after(i)`. Runs on the OLD history and +// writes into `features` in the old order; the caller then performs the change. +static void stage_history_change(std::vector& features, + const std::function& map_feature, + const std::function& enabled_after) +{ + const int n = int(features.size()); + auto alive = [&](int i) { return i >= 0 && i < n && map_feature(i) >= 0 && enabled_after(i); }; + + // Datum planes: ordinal -> old feature index, then -> new ordinal. + std::vector planes_before; + for (int i = 0; i < n; ++i) + if (features[i].type == CadFeatureType::Plane && features[i].enabled) planes_before.push_back(i); + std::vector> after; // (new index, old index) of planes enabled after + for (int i = 0; i < n; ++i) + if (features[i].type == CadFeatureType::Plane && alive(i)) after.emplace_back(map_feature(i), i); + std::sort(after.begin(), after.end()); + auto new_ordinal = [&](int old_feature) { + for (int k = 0; k < int(after.size()); ++k) + if (after[k].second == old_feature) return k; + return -1; + }; + + for (int j = 0; j < n; ++j) { + if (map_feature(j) < 0) continue; + CadFeature& f = features[j]; + for_each_datum_ref(f, [&](int& ref) { + if (ref < 3) return; + const int N = ref - 3; + const int oldf = (N < int(planes_before.size())) ? planes_before[N] : -1; + const int nn = oldf >= 0 ? new_ordinal(oldf) : -1; + // A plane that is gone or hidden stays pointed PAST the end, where resolution + // reports it, rather than quietly sliding onto the next plane. + ref = nn >= 0 ? 3 + nn : 3 + int(after.size()) + 1000; + }); + if (f.body_ref_ids.size() != size_t(kBodyRefCount)) continue; // never resolved: nothing to follow + // A feature hidden after the change is not replayed, so a hidden source is no reason to + // drop its body: it keeps following it and re-points when shown. Shown while the source + // is still hidden, it is staged as replayed and loses the body then. Only deleting the + // source drops a hidden feature's body. + const bool replayed = enabled_after(j); + int k = 0; + for_each_body_ref(f, [&](int& ref, bool) { + CadFeature::BodyId& id = f.body_ref_ids[k++]; + if (ref < 0 || id.src < 0) return; + if (replayed ? !alive(id.src) : map_feature(id.src) < 0) { ref = kBodyGone; id = {}; return; } + id.src = map_feature(id.src); + }); + f.body_refs_pending = true; + } +} + // Re-run recompute(); if it fails for a GENUINE geometry error, restore `snapshot` // and recompute that instead, so a rejected edit leaves the document exactly as it // was. recompute() also returns false for the BENIGN case where the edit simply @@ -1957,7 +2237,7 @@ static bool commit_or_rollback(CadDocument& doc, std::vector& snapsh bool has_solid_feature = false; for (const auto& f : doc.features) - if (f.enabled && f.type != CadFeatureType::Sketch) { has_solid_feature = true; break; } + if (f.enabled && CadDocument::produces_body(f.type)) { has_solid_feature = true; break; } if (!has_solid_feature) { doc.bodies.clear(); doc.body = TopoDS_Shape(); @@ -2031,6 +2311,15 @@ bool CadDocument::remove_feature(int index) std::sort(remove.begin(), remove.end()); remove.erase(std::unique(remove.begin(), remove.end()), remove.end()); + stage_history_change(features, + [&remove](int i) { + if (std::binary_search(remove.begin(), remove.end(), i)) return -1; + int shift = 0; + for (int r : remove) if (r < i) ++shift; + return i - shift; + }, + [this](int i) { return features[i].enabled; }); + // Erase high-to-low so earlier indices stay valid. for (auto it = remove.rbegin(); it != remove.rend(); ++it) features.erase(features.begin() + *it); @@ -2069,6 +2358,9 @@ bool CadDocument::move_feature(int index, int delta) return true; // clamped at the ends — no-op, not a failure std::vector snapshot = features; + stage_history_change(features, + [index, target](int i) { return i == index ? target : i == target ? index : i; }, + [this](int i) { return features[i].enabled; }); std::swap(features[index], features[target]); // The two slots traded places: fix every feature reference that pointed at either — @@ -2080,6 +2372,33 @@ bool CadDocument::move_feature(int index, int delta) for (auto& f : features) for_each_feature_ref(f, swap_ref); + // History runs top to bottom: a feature may only consume what comes BEFORE it. Moving a + // consumer above its sketch (or a sketch below its consumer) would make it read whatever the + // input held the last time round — or, for a Project, nothing. Refuse the move and say so. + for (int fi = 0; fi < int(features.size()); ++fi) { + CadFeature& f = features[fi]; + std::string bad; + auto check = [&](int& ref) { if (ref >= fi && bad.empty()) bad = features[ref].name; }; + check(f.sketch_ref); check(f.sweep_path_ref); check(f.pattern_curve_sketch); check(f.rib_sketch_ref); + for (int& r : f.loft_profile_refs) check(r); + if (!bad.empty()) { + error = "\"" + f.name + "\" uses \"" + bad + "\", so it cannot come before it"; + features.swap(snapshot); + return false; + } + } + + return commit_or_rollback(*this, snapshot); +} + +bool CadDocument::set_feature_enabled(int index, bool enabled) +{ + if (index < 0 || index >= int(features.size())) return false; + if (features[index].enabled == enabled) return true; + std::vector snapshot = features; + stage_history_change(features, [](int i) { return i; }, + [this, index, enabled](int i) { return i == index ? enabled : features[i].enabled; }); + features[index].enabled = enabled; return commit_or_rollback(*this, snapshot); } @@ -2152,6 +2471,26 @@ static std::string open_loop_message(const CadFeature& sketch, return msg; } +// World axis of a Revolve / Surface Revolve whose profile is `sk`: the Line of that sketch named +// by revolve_axis_entity, else the sketch plane's X or Y axis through its origin. +static gp_Ax1 revolve_axis_of(const CadFeature& f, const CadFeature& sk) +{ + if (f.revolve_axis_entity >= 0) { + if (f.revolve_axis_entity >= int(sk.entities.size()) + || sk.entities[f.revolve_axis_entity].type != SketchEntity::Type::Line) + throw std::runtime_error("revolve: the axis line is no longer in the sketch — pick the axis again"); + const SketchEntity& e = sk.entities[f.revolve_axis_entity]; + const Vec3d a = sk.plane.to_world(e.p0), b = sk.plane.to_world(e.p1); + if ((b - a).norm() < 1e-9) + throw std::runtime_error("revolve: the axis line has no length"); + const Vec3d d = (b - a).normalized(); + return gp_Ax1(gp_Pnt(a.x(), a.y(), a.z()), gp_Dir(d.x(), d.y(), d.z())); + } + const Vec3d& adir = (f.revolve_axis == 1) ? sk.plane.y_axis : sk.plane.x_axis; + return gp_Ax1(gp_Pnt(sk.plane.origin.x(), sk.plane.origin.y(), sk.plane.origin.z()), + gp_Dir(adir.x(), adir.y(), adir.z())); +} + TopoDS_Wire CadDocument::build_sketch_wire(const CadFeature& sketch, bool closed_only) const { if (!sketch.entities.empty()) { @@ -2198,6 +2537,28 @@ TopoDS_Wire CadDocument::build_sketch_wire(const CadFeature& sketch, bool closed return prof.to_occt_wire(sketch.plane); } +int CadDocument::body_touching_sketch(int sketch_ref) const +{ + if (sketch_ref < 0 || sketch_ref >= int(features.size()) + || features[sketch_ref].type != CadFeatureType::Sketch) + return -1; + // A query that only picks a default: any failure answers "touches nothing". + try { + const TopoDS_Face face = build_sketch_face(features[sketch_ref]); + if (face.IsNull()) return -1; + for (int i = int(bodies.size()) - 1; i >= 0; --i) { // newest first: the likeliest target + const TopoDS_Shape& b = bodies[i].shape; + if (b.IsNull() || is_sheet_shape(b)) continue; + BRepExtrema_DistShapeShape d(face, b); + if (d.IsDone() && d.Value() <= 1e-4) + return i; + } + } catch (const Standard_Failure&) { // before std::exception: on OCCT >= 8 it derives from it + } catch (const std::exception&) { + } + return -1; +} + TopoDS_Face CadDocument::build_sketch_face(const CadFeature& sketch) const { if (!sketch.entities.empty()) { @@ -2343,7 +2704,7 @@ void CadDocument::apply_feature(TopoDS_Shape& result, bool& have_body, ? features[f.sketch_ref] : f; TopoDS_Wire wire = build_sketch_wire(sk, true); const double ang = f.flip ? -f.revolve_angle : f.revolve_angle; - TopoDS_Shape tool = SketchEngine::make_revolve(wire, sk.plane, ang, f.revolve_axis); + TopoDS_Shape tool = SketchEngine::make_revolve(wire, revolve_axis_of(f, sk), ang); if (!have_body || f.mode == BooleanMode::New) { result = tool; have_body = true; @@ -2387,11 +2748,12 @@ void CadDocument::apply_feature(TopoDS_Shape& result, bool& have_body, throw std::runtime_error("surface-revolve: ref is not a sketch"); TopoDS_Wire wire = build_sketch_wire(sk); if (wire.IsNull()) throw std::runtime_error("surface-revolve: empty profile"); - const Vec3d& adir = (f.revolve_axis == 1) ? sk.plane.y_axis : sk.plane.x_axis; - gp_Pnt o(sk.plane.origin.x(), sk.plane.origin.y(), sk.plane.origin.z()); - gp_Dir xd(adir.x(), adir.y(), adir.z()); - gp_Ax1 axis(o, xd); - const double ang = f.revolve_angle * M_PI / 180.0; + gp_Ax1 axis = revolve_axis_of(f, sk); + // Same angle rules as the solid Revolve (flip reverses it, and a negative sweep is a + // positive one about the reversed axis — MakeRevol wants (0, 2 pi]); this surface + // version used to ignore both. + double ang = (f.flip ? -f.revolve_angle : f.revolve_angle) * M_PI / 180.0; + if (ang < 0) { axis.Reverse(); ang = -ang; } BRepPrimAPI_MakeRevol rev(wire, axis, ang, false); if (!rev.IsDone()) throw std::runtime_error("surface-revolve: revolve failed"); result = rev.Shape(); have_body = true; @@ -2548,7 +2910,9 @@ void CadDocument::apply_feature(TopoDS_Shape& result, bool& have_body, Vec3d o = f.plane.to_world(Vec2d(0, 0)); gp_Ax1 ax(gp_Pnt(o.x(), o.y(), o.z()), gp_Dir(f.plane.normal.x(), f.plane.normal.y(), f.plane.normal.z())); - const double step = (f.pattern_angle * M_PI / 180.0) / double(n); + const bool full = std::abs(std::abs(f.pattern_angle) - 360.0) < 1e-9; + const double div = (f.pattern_inclusive && !full && n > 1) ? double(n - 1) : double(n); + const double step = (f.pattern_angle * M_PI / 180.0) / div; trsf.SetRotation(ax, step * i); } else { const Vec3d& d = (f.pattern_dir == 1) ? f.plane.y_axis : f.plane.x_axis; @@ -2600,15 +2964,15 @@ void CadDocument::apply_feature(TopoDS_Shape& result, bool& have_body, } case CadFeatureType::Fillet: if (!have_body) throw std::runtime_error("fillet needs a body"); - if (f.dressup_edge >= 0) - result = GeometryEngine::apply_fillet(result, f.dressup_size, f.dressup_edge); + if (f.dressup_edge >= 0 || !f.dressup_edges.empty()) + result = GeometryEngine::apply_fillet(result, f.dressup_size, f.dressup_edge_ids()); else result = GeometryEngine::apply_fillet(result, f.dressup_size, f.face_group); break; case CadFeatureType::Chamfer: if (!have_body) throw std::runtime_error("chamfer needs a body"); - if (f.dressup_edge >= 0) - result = GeometryEngine::apply_chamfer(result, f.dressup_size, f.dressup_edge); + if (f.dressup_edge >= 0 || !f.dressup_edges.empty()) + result = GeometryEngine::apply_chamfer(result, f.dressup_size, f.dressup_edge_ids()); else result = GeometryEngine::apply_chamfer(result, f.dressup_size, f.face_group); break; @@ -2655,15 +3019,27 @@ void CadDocument::apply_feature(TopoDS_Shape& result, bool& have_body, } case CadFeatureType::Thread: { // Reject degenerate parameters that make OCCT's helical sweep / boolean unstable (a tiny - // pitch, depth >= half-pitch, an enormous turn count, depth eating the whole wall). Better - // a no-op than a crash. Leave the body unchanged when the spec can't be built safely. + // pitch, depth >= half-pitch, an enormous turn count, depth eating the whole wall), and + // SAY which: a thread that quietly did nothing read, for an internal one, as "the tool + // does not intersect the body", and for an external one as nothing at all. { const double R = f.thread_radius, P = f.thread_pitch, H = f.thread_height, D = f.thread_depth; // ISO external thread depth is ~0.61*P, so allow up to 0.7*P (0.49 wrongly rejected // every real thread -> nothing rendered). Still bound it well under a full pitch. - const bool ok = R > 0.5 && P > 0.1 && D > 1e-3 && D < 0.7 * P && D < 0.45 * R - && H > 0.5 * P && (H / P) < 400.0; - if (!ok) break; // result/have_body untouched + // R >= 0.5 (not >): M1, the smallest size in the standards table, is exactly 0.5. + const char* why = R < 0.5 ? "thread: the diameter must be at least 1 mm" + : P <= 0.1 ? "thread: the pitch must be more than 0.1 mm" + : (D <= 1e-3 || D >= 0.7 * P) ? "thread: the depth must be between 0 and 0.7 x the pitch" + : D >= 0.45 * R ? "thread: the depth is too large for this diameter" + : H <= 0.5 * P ? "thread: the length must be more than half a pitch" + : H / P >= 400.0 ? "thread: too many turns (length / pitch must stay under 400)" + : nullptr; + // Legacy recipes keep their old outcome (the body left as it was) so a project that + // opened before still opens; every thread made now reports the reason instead. + if (why != nullptr) { + if (f.thread_major_nominal) throw std::runtime_error(why); + break; + } } // Axis at the positioned point on the plane; +normal = thread rise. Vec3d c3 = f.plane.to_world(Vec2d(f.thread_x, f.thread_y)); @@ -2672,63 +3048,79 @@ void CadDocument::apply_feature(TopoDS_Shape& result, bool& have_body, gp_Dir xdir(f.plane.x_axis.x(), f.plane.x_axis.y(), f.plane.x_axis.z()); gp_Ax3 ax3(c, zdir, xdir); gp_Ax2 ax2(c, zdir, xdir); + const double R = f.thread_radius, D = f.thread_depth; + // Root the V CLEARLY inside the material (a real overlap, not a tangency) so the boolean + // has clean intersections — near-coincident faces are what make OCCT's fuse/cut unstable. + const double over = std::min(std::max(D, 0.25), R * 0.4); - // Build the swept helical ridge (guarded — never fatal). - TopoDS_Shape ridge; - bool have_ridge = false; - try { - TopoDS_Wire spine = make_helix_wire(ax3, f.thread_radius, - f.thread_pitch, f.thread_height); - TopoDS_Wire prof = make_thread_profile(c, xdir, zdir, f.thread_radius, - f.thread_pitch, f.thread_depth, - f.thread_internal); - // MakePipeShell with a FIXED BINORMAL = cylinder axis keeps the V-profile's orientation - // constant along the helix (axial edge always parallel to the axis, V always pointing - // radially out). The plain MakePipe used a Frenet frame that TWISTED the profile around - // the helix -> the wedge inclination varied and looked mirrored. - BRepOffsetAPI_MakePipeShell pipe(spine); - pipe.SetMode(zdir); - pipe.Add(prof); - pipe.Build(); - if (pipe.IsDone() && pipe.MakeSolid()) { - ridge = pipe.Shape(); - have_ridge = !ridge.IsNull(); + // Sweep a V profile along the helix (guarded — never fatal). `helix_r` is the radius the + // helix runs at: the profile is placed relative to it. + auto sweep = [&](double helix_r, const TopoDS_Wire& prof, TopoDS_Shape& out) { + try { + TopoDS_Wire spine = make_helix_wire(ax3, helix_r, f.thread_pitch, f.thread_height); + // MakePipeShell with a FIXED BINORMAL = cylinder axis keeps the V-profile's + // orientation constant along the helix (axial edge always parallel to the axis). + // The plain MakePipe used a Frenet frame that TWISTED the profile around the helix. + BRepOffsetAPI_MakePipeShell pipe(spine); + pipe.SetMode(zdir); + pipe.Add(prof); + pipe.Build(); + if (pipe.IsDone() && pipe.MakeSolid()) out = pipe.Shape(); + } catch (const Standard_Failure&) { // on OCCT >= 8 it derives from std::exception: first + } catch (const std::exception&) { } - } catch (const Standard_Failure&) { - have_ridge = false; // OCCT failure — on OCCT >= 8 Standard_Failure derives from std::exception, so this handler must come first - } catch (const std::exception&) { - have_ridge = false; // fall back to the bare cylinder/bore below + return !out.IsNull(); + }; + auto cut_from = [](TopoDS_Shape& target, const TopoDS_Shape& tool) { + BRepAlgoAPI_Cut cut(target, tool); + if (cut.IsDone() && !cut.Shape().IsNull()) target = cut.Shape(); + }; + + if (f.thread_major_nominal) { + if (f.thread_internal) { + // Tapped hole: bore to the minor radius R - D, then cut the groove out to the + // major radius R. The bore sits 10 um inside the minor radius so that, threading + // an existing tap-drill hole, it does not coincide with that hole's wall. + if (!have_body) throw std::runtime_error("internal thread needs a body"); + const double bore_r = R - D - 0.01; + cut_from(result, BRepPrimAPI_MakeCylinder(ax2, bore_r, f.thread_height).Shape()); + TopoDS_Shape groove; + if (sweep(R, make_thread_v(c, xdir, zdir, R - D - over, R, f.thread_pitch), groove)) + cut_from(result, groove); + } else { + // External thread: the crests are at R. A groove is cut INTO the rod down to + // R - D; with no body yet, the rod is made first. + if (!have_body || result.IsNull()) { + result = BRepPrimAPI_MakeCylinder(ax2, R, f.thread_height).Shape(); + have_body = true; + } + TopoDS_Shape groove; + if (sweep(R, make_thread_v(c, xdir, zdir, R + over, R - D, f.thread_pitch), groove)) + cut_from(result, groove); + } + break; } + // ---- legacy recipes (thread_major_nominal == false): built exactly as they always were. + TopoDS_Shape ridge; + const bool have_ridge = sweep(R, make_thread_profile(c, xdir, zdir, f.thread_radius, + f.thread_pitch, f.thread_depth, + f.thread_internal), ridge); if (f.thread_internal) { if (!have_body) throw std::runtime_error("internal thread needs a body"); - // Tapped bore: ensure a clean cylindrical pocket, then carve the - // OUTWARD helical groove into its wall. When the thread is invoked on - // an existing hole the bore cut is coincident (a no-op that may report - // !IsDone) — tolerate it so the visible groove cut below still runs. - // Cut the pocket at the MINOR diameter (radius - depth), not the nominal radius. - // A nominal-radius bore that coincides with an existing hole's wall creates - // coincident faces that foul the following groove boolean (the groove then removes - // ~nothing -> invisible thread). The minor bore stays strictly inside any existing - // hole wall, leaving it clean for the groove; on solid stock it forms the tap-drill. + // Tapped bore: cut a clean pocket at radius - depth, then carve the helical groove + // outward into its wall. const double bore_r = std::max(0.5, f.thread_radius - f.thread_depth); - TopoDS_Shape bore = BRepPrimAPI_MakeCylinder(ax2, bore_r, - f.thread_height).Shape(); + TopoDS_Shape bore = BRepPrimAPI_MakeCylinder(ax2, bore_r, f.thread_height).Shape(); try { BRepAlgoAPI_Cut cut_bore(result, bore); if (cut_bore.IsDone() && !cut_bore.Shape().IsNull()) result = cut_bore.Shape(); } catch (const std::exception&) { /* keep existing bore */ } - if (have_ridge) { - BRepAlgoAPI_Cut cut_ridge(result, ridge); - if (cut_ridge.IsDone() && !cut_ridge.Shape().IsNull()) - result = cut_ridge.Shape(); - } + if (have_ridge) cut_from(result, ridge); } else { - // External thread: FUSE the helical ridge ONTO the existing body (the picked cylinder), - // leaving the rest of the part intact. Replacing the body with a bare rod — the old - // behaviour — wiped whatever the user picked; that was the "mess". With no body yet - // (a thread from scratch on a dropdown plane), fall back to a standalone threaded rod. + // External thread: FUSE the helical ridge onto the existing body, or onto a + // standalone rod when there is none. if (have_body && !result.IsNull()) { if (have_ridge) { BRepAlgoAPI_Fuse fuse(result, ridge); @@ -2848,12 +3240,16 @@ static TriangleMesh tessellate_bodies(const std::vector& bodies, void CadDocument::apply_boolean(std::vector& bodies, const CadFeature& f) const { const int nb = int(bodies.size()); - const int tgt = (f.target_body >= 0 && f.target_body < nb) ? f.target_body : nb - 1; - const int tool = (f.bool_tool_body >= 0 && f.bool_tool_body < nb) ? f.bool_tool_body : -1; - if (tgt < 0 || tool < 0 || tgt == tool) return; // need two distinct bodies; otherwise no-op + const int tgt = pick_body(f.target_body, nb); + const int tool = f.bool_tool_body == -1 ? -1 : pick_body(f.bool_tool_body, nb); + // Same rule as Cut, Mirror and Transform: an operation that cannot run says why, rather + // than leaving the bodies as they were and reporting success. + if (tgt < 0) throw std::runtime_error("boolean: no target body"); + if (tool < 0) throw std::runtime_error("boolean: no tool body — pick a second body"); + if (tgt == tool) throw std::runtime_error("boolean: the target and the tool are the same body"); const TopoDS_Shape A = bodies[tgt].shape; // target survives const TopoDS_Shape B = bodies[tool].shape; // tool, consumed unless kept - if (A.IsNull() || B.IsNull()) return; + if (A.IsNull() || B.IsNull()) throw std::runtime_error("boolean: a body has no geometry"); NCollection_List args, tools; args.Append(A); @@ -2871,7 +3267,7 @@ void CadDocument::apply_boolean(std::vector& bodies, const CadFeature& case BooleanMode::Add: { BRepAlgoAPI_Fuse op; result = run(op); break; } // union case BooleanMode::Cut: { BRepAlgoAPI_Cut op; result = run(op); break; } // target - tool case BooleanMode::Intersect: { BRepAlgoAPI_Common op; result = run(op); break; } // overlap - default: return; // BooleanMode::New is meaningless between two existing bodies + default: throw std::runtime_error("boolean: choose Union, Subtract or Intersect"); // New means nothing between two bodies } if (result.IsNull()) throw std::runtime_error("boolean produced an empty shape"); @@ -2883,7 +3279,7 @@ void CadDocument::apply_cut(std::vector& bodies, const CadFeature& f) c { const int nb = int(bodies.size()); if (nb == 0) throw std::runtime_error("cut: no target body"); - const int tgt = (f.target_body >= 0 && f.target_body < nb) ? f.target_body : nb - 1; + const int tgt = pick_body(f.target_body, nb); if (tgt < 0 || bodies[tgt].shape.IsNull()) throw std::runtime_error("cut: no target body"); if (!f.cut_keep_upper && !f.cut_keep_lower) @@ -2891,7 +3287,7 @@ void CadDocument::apply_cut(std::vector& bodies, const CadFeature& f) c SketchPlane cp; if (f.cut_face >= 0) { - const int fb = (f.cut_face_body >= 0 && f.cut_face_body < nb) ? f.cut_face_body : tgt; + const int fb = f.cut_face_body == -1 ? tgt : pick_body(f.cut_face_body, nb); if (bodies[fb].shape.IsNull()) throw std::runtime_error("cut: face body is empty"); TopoDS_Face fc = GeometryEngine::face_by_index(bodies[fb].shape, f.cut_face); if (fc.IsNull()) throw std::runtime_error("cut: face not found"); @@ -2954,7 +3350,7 @@ void CadDocument::apply_mirror(std::vector& bodies, const CadFeature& f { const int nb = int(bodies.size()); if (nb == 0) throw std::runtime_error("mirror: no target body"); - const int tgt = (f.target_body >= 0 && f.target_body < nb) ? f.target_body : nb - 1; + const int tgt = pick_body(f.target_body, nb); if (tgt < 0 || bodies[tgt].shape.IsNull()) throw std::runtime_error("mirror: no target body"); const TopoDS_Shape& src = bodies[tgt].shape; @@ -3001,7 +3397,7 @@ void CadDocument::apply_transform(std::vector& bodies, const CadFeature { const int nb = int(bodies.size()); if (nb == 0) throw std::runtime_error("transform: no target body"); - const int tgt = (f.target_body >= 0 && f.target_body < nb) ? f.target_body : nb - 1; + const int tgt = pick_body(f.target_body, nb); if (tgt < 0 || bodies[tgt].shape.IsNull()) throw std::runtime_error("transform: no target body"); gp_Trsf rot; @@ -3030,7 +3426,7 @@ void CadDocument::apply_thicken(std::vector& bodies, const CadFeature& { const int nb = int(bodies.size()); if (nb == 0) throw std::runtime_error("thicken: no target body"); - const int tgt = (f.target_body >= 0 && f.target_body < nb) ? f.target_body : nb - 1; + const int tgt = pick_body(f.target_body, nb); if (tgt < 0 || bodies[tgt].shape.IsNull()) throw std::runtime_error("thicken: no target body"); TopoDS_Face fc = GeometryEngine::face_by_index(bodies[tgt].shape, f.thicken_face); @@ -3065,7 +3461,7 @@ void CadDocument::apply_thicken_surface(std::vector& bodies, const CadF { const int nb = int(bodies.size()); if (nb == 0) throw std::runtime_error("thicken-surface: no target body"); - const int tgt = (f.target_body >= 0 && f.target_body < nb) ? f.target_body : nb - 1; + const int tgt = pick_body(f.target_body, nb); if (tgt < 0 || bodies[tgt].shape.IsNull()) throw std::runtime_error("thicken-surface: no target body"); if (!is_sheet_shape(bodies[tgt].shape)) throw std::runtime_error("thicken-surface: target is not a sheet body"); if (std::abs(f.thicken_thickness) < 1e-9) throw std::runtime_error("thicken-surface: thickness is zero"); @@ -3168,7 +3564,7 @@ void CadDocument::apply_surface_offset(std::vector& bodies, const CadFe { const int nb = int(bodies.size()); if (nb == 0) throw std::runtime_error("surface-offset: no target body"); - const int tgt = (f.target_body >= 0 && f.target_body < nb) ? f.target_body : nb - 1; + const int tgt = pick_body(f.target_body, nb); if (tgt < 0 || bodies[tgt].shape.IsNull()) throw std::runtime_error("surface-offset: no target body"); if (!is_sheet_shape(bodies[tgt].shape)) throw std::runtime_error("surface-offset: target is not a sheet body"); const double d = f.plane_offset; @@ -3260,8 +3656,7 @@ void CadDocument::apply_project(const std::vector& bodies, CadFeature& f.entities.clear(); const int nb = int(bodies.size()); if (nb == 0) throw std::runtime_error("project: no source body"); - const int src = (f.project_source_body >= 0 && f.project_source_body < nb) - ? f.project_source_body : nb - 1; + const int src = pick_body(f.project_source_body, nb); if (src < 0 || bodies[src].shape.IsNull()) throw std::runtime_error("project: source body is empty"); const TopoDS_Shape& shape = bodies[src].shape; @@ -3321,7 +3716,7 @@ void CadDocument::detect_mate_conflicts() std::string this_name = f.name.empty() ? "Mate" : f.name; mate_conflicts.push_back({fi, "Body " + std::to_string(dst + 1) + " is already positioned by '" + - first_name + "' (feature " + std::to_string(first_fi) + + first_name + "' (feature " + std::to_string(first_fi + 1) + ") — '" + this_name + "' overrides it; suppress one"}); } else { first_driver[dst] = fi; @@ -3445,6 +3840,8 @@ void CadDocument::apply_mate(std::vector& bodies, const CadFeature& f) 0, 0, -1, 0); } + if (f.mate_kind < 0 || f.mate_kind > 4) + throw std::runtime_error("mate: unknown mate type " + std::to_string(f.mate_kind)); gp_Trsf T; if (f.mate_kind == 0) { // Fastened: T = M_A * Rz(mate_angle) * Tz(mate_offset) * F * M_B^-1 @@ -3583,8 +3980,7 @@ void CadDocument::route_feature(std::vector& bodies, const CadFeature& if (f.type == CadFeatureType::SurfaceOffset) { apply_surface_offset(bodies, f); return; } if (f.type == CadFeatureType::Project) return; // sketch-like: consumed downstream, no body // Resolve the target body: explicit target_body when valid, else the last body. - const int t = (f.target_body >= 0 && f.target_body < int(bodies.size())) - ? f.target_body : int(bodies.size()) - 1; + const int t = pick_body(f.target_body, int(bodies.size())); const TopoDS_Shape context = (t >= 0) ? bodies[t].shape : TopoDS_Shape(); // A New extrude (or the very first solid feature) starts a fresh body; everything else // mutates the target body in place. @@ -3635,9 +4031,43 @@ void CadDocument::route_feature(std::vector& bodies, const CadFeature& } } +void CadDocument::bind_expressions() +{ + std::map varvals = evaluate_variables(variables); + for (CadFeature& f : features) + for (const auto& [field, e] : f.expr) + assign_field(f, field, eval_expr(e, varvals)); +} + +void CadDocument::replay_feature(size_t fi, std::vector& built) +{ + CadFeature& f = features[fi]; + if (!f.enabled) return; + if (f.type == CadFeatureType::Sketch) return; // consumed by an extrude + if (f.type == CadFeatureType::Helix) return; // consumed by Sweep as a path + if (f.type == CadFeatureType::Plane) return; // datum: no solid, derived on demand + if (f.type == CadFeatureType::Axis) return; // datum axis + if (f.type == CadFeatureType::CoordSys) return; // datum coordinate system + settle_body_refs(f, built, true); + if (f.type == CadFeatureType::Project) { apply_project(built, f); } + else { route_feature(built, f); } + // Record which feature made each body. "Still unset?" is the whole rule, and it is + // sufficient because of an invariant worth stating: NO feature ever replaces a whole + // CadBody. Every in-place op writes only `.shape` (boolean, cut, mirror-fuse, + // transform, dress-up — checked, all 8 sites), so an existing body keeps the stamp it + // was born with; a consumed body is erased outright, taking its stamp with it; and + // the only bodies still at -1 here are the ones THIS feature just pushed. That also + // means a feature type added later needs no change here, as long as it keeps to the + // same invariant. + for (CadBody& b : built) + if (b.source_feature < 0) + b.source_feature = int(fi); +} + bool CadDocument::recompute() { error.clear(); + set_sketch_auto_close(auto_close_loops); // this document's weld rule, not the last one's detect_mate_conflicts(); std::vector built; // Did any feature in this document even ASK for a solid? A document made only of sketches @@ -3647,38 +4077,13 @@ bool CadDocument::recompute() // the 3MF recipe only "on success", so nothing was written, and deserialize_recipe ends with // `return recompute()`, so a project that did carry a recipe was refused on load with // "Could not restore the CAD model" while its features sat correctly in the list. mtav. - bool any_solid_feature = false; + const bool any_solid_feature = std::any_of(features.begin(), features.end(), [](const CadFeature& f) { + return f.enabled && produces_body(f.type); + }); try { - // Parametric pass: evaluate document variables, then each feature's expression bindings, - // writing the results into the feature's numeric fields before geometry runs. - std::map varvals = evaluate_variables(variables); - for (CadFeature& f : features) - for (const auto& [field, e] : f.expr) - assign_field(f, field, eval_expr(e, varvals)); - for (size_t fi = 0; fi < features.size(); ++fi) { - CadFeature& f = features[fi]; - if (!f.enabled) continue; - if (f.type == CadFeatureType::Sketch) continue; // consumed by an extrude - if (f.type == CadFeatureType::Helix) continue; // consumed by Sweep as a path - if (f.type == CadFeatureType::Plane) continue; // datum: no solid, derived on demand - if (f.type == CadFeatureType::Axis) continue; // datum axis - if (f.type == CadFeatureType::CoordSys) continue; // datum coordinate system - // Past the skips: this feature is one that means to leave a body behind. - any_solid_feature = true; - if (f.type == CadFeatureType::Project) { apply_project(built, f); } - else { route_feature(built, f); } - // Record which feature made each body. "Still unset?" is the whole rule, and it is - // sufficient because of an invariant worth stating: NO feature ever replaces a whole - // CadBody. Every in-place op writes only `.shape` (boolean, cut, mirror-fuse, - // transform, dress-up — checked, all 8 sites), so an existing body keeps the stamp it - // was born with; a consumed body is erased outright, taking its stamp with it; and - // the only bodies still at -1 here are the ones THIS feature just pushed. That also - // means a feature type added later needs no change here, as long as it keeps to the - // same invariant. - for (CadBody& b : built) - if (b.source_feature < 0) - b.source_feature = int(fi); - } + bind_expressions(); + for (size_t fi = 0; fi < features.size(); ++fi) + replay_feature(fi, built); } catch (const std::exception& e) { error = *e.what() ? e.what() : "OCCT operation failed"; return false; @@ -3709,22 +4114,37 @@ bool CadDocument::recompute() } // recompute() replaces the bodies vector wholesale, which would drop any per-body - // colour override (Color tool). Body indices are stable across a rebuild (bodies are - // appended in feature order), so carry the override forward by index — same indexing - // contract the GUI relies on for per-body visibility/Move. - for (size_t i = 0; i < built.size() && i < bodies.size(); ++i) { + // colour override (Color tool) and the name the user gave a body. They are carried forward + // by the body's IDENTITY — the feature that made it and which of its bodies it is — not by + // its index: a Boolean consuming its tool, a Mirror replacing its source or a Cut splitting + // one body into two all shift every later index, and by index the colour and the name + // moved to a different body. + for (size_t i = 0; i < bodies.size(); ++i) { + if (!bodies[i].has_color && !bodies[i].has_user_name) continue; + const int j = find_body(built, body_id_of(bodies, int(i))); + if (j < 0) continue; if (bodies[i].has_color) { - built[i].has_color = true; - built[i].color = bodies[i].color; + built[j].has_color = true; + built[j].color = bodies[i].color; } - // ...and the name the user gave the body, for the same reason and by the same index - // contract. Without this a rename would live exactly until the next feature was added. if (bodies[i].has_user_name) { - built[i].has_user_name = true; - built[i].user_name = bodies[i].user_name; + built[j].has_user_name = true; + built[j].user_name = bodies[i].user_name; } } bodies = std::move(built); + // Datum references index the FINISHED body list; settle them against it, and close every + // pending re-point now that both kinds have been through settle_body_refs. + try { + for (CadFeature& f : features) { + if (!f.enabled) continue; // a hidden feature keeps its pending re-point for when it returns + settle_body_refs(f, bodies, false); + f.body_refs_pending = false; + } + } catch (const std::exception& e) { + error = e.what(); + return false; + } // The face and edge maps have just been rebuilt, so every global id handed out before this // point now means something else. Bump here rather than in each mutator: this is the single // line where the topology is actually replaced, so it cannot be forgotten by a new feature @@ -3810,6 +4230,175 @@ bool CadDocument::preview(const CadFeature& candidate, TriangleMesh& out_mesh, s return preview(candidate, out_mesh, ignore, err); } +namespace { + +// A face of a replayed body and what the boundary test needs from it. The projector is built on +// first use: few probes pass the box test, and building one on a freeform surface samples the +// whole surface. +struct FaceProbe { + TopoDS_Face face; + Handle(Geom_Surface) surface; + Bnd_Box box; + mutable std::unique_ptr proj; +}; + +// How close a point must be to lie on a face: a face rebuilt on the same surface is within +// 1e-7 mm of it, a new face nowhere near. +constexpr double kOnFaceTol = 1e-4; + +std::vector face_probes(const std::vector& bodies) +{ + std::vector out; + for (const CadBody& b : bodies) + for (TopExp_Explorer e(b.shape, TopAbs_FACE); e.More(); e.Next()) { + FaceProbe p; + p.face = TopoDS::Face(e.Current()); + p.surface = BRep_Tool::Surface(p.face); + if (p.surface.IsNull()) continue; + BRepBndLib::Add(p.face, p.box); + p.box.Enlarge(kOnFaceTol); + out.push_back(std::move(p)); + } + return out; +} + +bool outward_normal(const TopoDS_Face& face, double u, double v, gp_Dir& n) +{ + BRepAdaptor_Surface surf(face); + BRepLProp_SLProps props(surf, u, v, 1, Precision::Confusion()); + if (!props.IsNormalDefined()) return false; + n = props.Normal(); + if (face.Orientation() == TopAbs_REVERSED) n.Reverse(); + return true; +} + +// A point inside `face` and its outward normal there: the UV centroid of the largest triangle of +// the face's triangulation, which lies inside the trimmed face even where the centre of its UV +// bounds falls in a hole. Without a triangulation, that centre, if it is inside. +bool interior_point(const TopoDS_Face& face, gp_Pnt& p, gp_Dir& n) +{ + TopLoc_Location loc; + const Handle(Poly_Triangulation) tri = BRep_Tool::Triangulation(face, loc); + double u = 0., v = 0., best = -1.; + if (!tri.IsNull() && tri->HasUVNodes()) + for (int i = 1; i <= tri->NbTriangles(); ++i) { + int a, b, c; + tri->Triangle(i).Get(a, b, c); + const gp_Pnt pa = tri->Node(a); + const double area = gp_Vec(pa, tri->Node(b)).Crossed(gp_Vec(pa, tri->Node(c))).SquareMagnitude(); + if (area <= best) continue; + best = area; + const gp_XY uv = (tri->UVNode(a).XY() + tri->UVNode(b).XY() + tri->UVNode(c).XY()) / 3.; + u = uv.X(); + v = uv.Y(); + } + if (best < 0.) { + double u0, u1, v0, v1; + BRepTools::UVBounds(face, u0, u1, v0, v1); + u = 0.5 * (u0 + u1); + v = 0.5 * (v0 + v1); + if (BRepClass_FaceClassifier(face, gp_Pnt2d(u, v), Precision::Confusion()).State() != TopAbs_IN) + return false; + } + p = BRepAdaptor_Surface(face).Value(u, v); + return outward_normal(face, u, v, n); +} + +// Does `p` lie on one of `probes`, on a face turned the same way as `n`? The facing tells apart +// coincident faces of different bodies: a block stacked on a base has its bottom in the base's +// top face, turned the other way. +bool on_boundary(const std::vector& probes, const gp_Pnt& p, const gp_Dir& n) +{ + for (const FaceProbe& fp : probes) { + if (fp.box.IsOut(p)) continue; + try { + if (!fp.proj) { + // The setup GeomAPI_ProjectPointOnSurf(p, surface) repeats for every point, done once. + double u0, u1, v0, v1; + fp.surface->Bounds(u0, u1, v0, v1); + fp.proj = std::make_unique(); + fp.proj->Init(fp.surface, u0, u1, v0, v1, Precision::Confusion()); + } + GeomAPI_ProjectPointOnSurf& proj = *fp.proj; + proj.Perform(p); + if (proj.NbPoints() == 0 || proj.LowerDistance() > kOnFaceTol) continue; + double u, v; + proj.LowerDistanceParameters(u, v); + if (BRepClass_FaceClassifier(fp.face, gp_Pnt2d(u, v), kOnFaceTol).State() == TopAbs_OUT) continue; + gp_Dir m; + if (outward_normal(fp.face, u, v, m) && m.Dot(n) > 0.) return true; + } catch (const Standard_Failure&) { + // A surface the projection cannot handle says nothing about the point. + } + } + return false; +} + +} // namespace + +std::vector> CadDocument::faces_made_by(int index) const +{ + std::vector> out; + if (index < 0 || index >= int(features.size()) || !features[index].enabled + || !produces_body(features[index].type) || bodies.empty()) + return out; + + // Replay up to the feature, then the feature alone on a copy, so a body it left alone is the + // same shape on both sides. On a scratch document, as a replay settles references in the + // features it runs; it reads only the features, the variables and the process-wide weld rule. + CadDocument tmp; + tmp.features = features; + tmp.variables = variables; + std::vector before, after; + try { + set_sketch_auto_close(auto_close_loops); + tmp.bind_expressions(); + for (size_t fi = 0; fi < size_t(index); ++fi) + tmp.replay_feature(fi, before); + after = before; + tmp.replay_feature(size_t(index), after); + } catch (...) { // OCCT's Standard_Failure as well as std::exception + return out; + } + + // The bodies the step made or replaced. A face on any other body was on the boundary before + // the step too, so only these are probed after it. + std::vector changed; + std::vector changed_bodies; + for (int j = 0; j < int(after.size()); ++j) + if (std::none_of(before.begin(), before.end(), [&](const CadBody& w) { return w.shape.IsSame(after[j].shape); })) { + changed.push_back(j); + changed_bodies.push_back(after[j]); + } + const std::vector was = face_probes(before), now = face_probes(changed_bodies); + for (int b = 0; b < int(bodies.size()); ++b) { + int id = 0; + for (TopExp_Explorer e(bodies[b].shape, TopAbs_FACE); e.More(); e.Next(), ++id) { + gp_Pnt p; + gp_Dir n; + try { + if (interior_point(TopoDS::Face(e.Current()), p, n) && on_boundary(now, p, n) && !on_boundary(was, p, n)) + out.emplace_back(b, id); + } catch (const Standard_Failure&) { + // A face that cannot be sampled is left out, not the whole answer. + } + } + } + if (!out.empty()) + return out; + + // No face of its own: answer with every face of each body the step changed, found again in + // the finished model by the id a body keeps across the rest of the history. + for (int j : changed) { + const int b = find_body(bodies, body_id_of(after, j)); + if (b < 0) continue; + const int n = GeometryEngine::face_count(bodies[b].shape); + for (int id = 0; id < n; ++id) + out.emplace_back(b, id); + } + return out; +} + std::string brep_to_string(const TopoDS_Shape& s) { if (s.IsNull()) return {}; @@ -3882,6 +4471,22 @@ std::string CadDocument::serialize_recipe() const uint32_t blen = static_cast(bb.size()); ar(blen); ar(cereal::binary_data(bb.data(), bb.size())); + // DOCUMENT EXTRAS, appended after the body names on the same terms (older builds stop + // reading before it): the modeling origin, and the body colours. A colour set with the + // Color tool used to live only until the project was closed. + std::map> colours; + for (uint32_t i = 0; i < bodies.size(); ++i) + if (bodies[i].has_color) + colours[i] = { bodies[i].color.r(), bodies[i].color.g(), bodies[i].color.b(), bodies[i].color.a() }; + std::ostringstream xos; + { + cereal::BinaryOutputArchive xa(xos); + xa(modeling_origin.x(), modeling_origin.y(), modeling_origin.z(), colours, auto_close_loops); + } + std::string xb = xos.str(); + uint32_t xlen = static_cast(xb.size()); + ar(xlen); + ar(cereal::binary_data(xb.data(), xb.size())); } return oss.str(); } @@ -3963,6 +4568,26 @@ bool CadDocument::deserialize_recipe(const std::string& blob) } catch (...) { named.clear(); } + // Document extras (origin, colours), when the project has them. The origin must be + // in place BEFORE the rebuild: datum planes are resolved against it. + std::map> colours; + try { + uint32_t xlen; + ar(xlen); + std::string xbuf(xlen, '\0'); + if (xlen > 0) + ar(cereal::binary_data(&xbuf[0], xlen)); + std::istringstream xs(xbuf); + cereal::BinaryInputArchive xa(xs); + double ox = 0, oy = 0, oz = 0; + bool weld = true; + xa(ox, oy, oz, colours, weld); + modeling_origin = Vec3d(ox, oy, oz); + origin_from_recipe = true; + auto_close_loops = weld; + } catch (...) { + colours.clear(); // older project: the caller's origin stands + } // AFTER the rebuild, never before: recompute() replaces the bodies vector wholesale. const bool ok = recompute(); for (const auto& kv : named) @@ -3970,11 +4595,21 @@ bool CadDocument::deserialize_recipe(const std::string& blob) bodies[kv.first].has_user_name = true; bodies[kv.first].user_name = kv.second; } + for (const auto& kv : colours) + if (kv.first < bodies.size()) { + bodies[kv.first].has_color = true; + bodies[kv.first].color = ColorRGBA(kv.second[0], kv.second[1], kv.second[2], kv.second[3]); + } return ok; } if (v == 4) { - // Pre-framing flat path, unchanged: v4 projects keep opening exactly as before. - ar(features); + // Pre-framing flat path: v4 projects keep opening, read with the FROZEN v4 field list + // (CadFeature::load_flat_v4) so fields appended to the framed layout since cannot + // shift every byte after them. + cereal::size_type n = 0; + ar(cereal::make_size_tag(n)); + features.assign(size_t(n), CadFeature{}); + for (CadFeature& f : features) f.load_flat_v4(ar); ar(variables); return recompute(); } diff --git a/src/libslic3r/CAD/CadDocument.hpp b/src/libslic3r/CAD/CadDocument.hpp index 216be818bd..8adbd7b20c 100644 --- a/src/libslic3r/CAD/CadDocument.hpp +++ b/src/libslic3r/CAD/CadDocument.hpp @@ -78,7 +78,7 @@ struct CadFeature { // the OCCT shape verbatim — it is adopted as a base body in route_feature (no parametric // recipe). Downstream face/edge features (fillet/chamfer/cut/shell/...) act on it like any // other body. TopoDS_Shape is a cheap handle, so copying it through recompute/checkpoint - // snapshots is cheap. In-session only for now (no BRep serialization yet). + // snapshots is cheap. Saved with the recipe as a BRep string (see save()/load()). TopoDS_Shape imported_solid; // Non-destructive placement transform for imported_regions (Text/SVG), @@ -116,6 +116,25 @@ struct CadFeature { double dressup_size{1.0}; // fillet radius or chamfer distance FaceGroup face_group{FaceGroup::All}; int dressup_edge{-1}; // global edge id for edge-targeted fillet/chamfer; -1 = use face_group + // Several picked edges dressed in ONE operation, all resolved against the same body, so + // the ids cannot drift the way they do across a chain of single-edge features. When set, + // dressup_edge holds the first of them: a build that predates the list still dresses that + // one edge instead of falling back to the whole face group. + std::vector dressup_edges; + // The edges this dress-up targets: the list, else the single edge, else none (face group). + // Text feature: a Sketch whose imported_regions were vectorised from this string in this font + // (a WxFontUtils descriptor, bold/italic included) at this cap height in mm. The regions are + // what gets built — they are saved too, so the project opens on a machine without the font — + // and these three are what an edit reopens the Text dialog with. Empty = not a text feature. + std::string text_string; + std::string text_font; + double text_height{0.0}; + bool is_text() const { return !text_string.empty(); } + std::vector dressup_edge_ids() const { + if (!dressup_edges.empty()) return dressup_edges; + if (dressup_edge >= 0) return { dressup_edge }; + return {}; + } // Hole params (positioned circular cut into the current body) double hole_diameter{5}; @@ -134,12 +153,21 @@ struct CadFeature { std::string hole_standard; // provenance only, e.g. "M6" / "1/4-20"; not used by geometry // Thread params (helical thread about the plane normal at a positioned point) - double thread_radius{5}; // nominal cylinder radius + double thread_radius{5}; // MAJOR (nominal) radius when thread_major_nominal, + // else the legacy reference radius (see below) double thread_pitch{2}; // axial advance per turn double thread_height{10}; // total axial length - double thread_depth{1}; // radial crest depth of the thread profile - bool thread_internal{false}; // false = external threaded rod (New body); - // true = tapped bore cut into the current body + double thread_depth{1}; // radial depth of the thread profile + bool thread_internal{false}; // false = external: the thread goes on the target body + // (or on a standalone rod when there is none); + // true = tapped bore cut into the target body + // How thread_radius is read. true (every thread made since): it is the nominal MAJOR + // radius, for both kinds — an internal thread bores to R - depth and grooves out to R, an + // external one is a rod of radius R with its groove cut in to R - depth, so an M6 is 6 mm + // across its crests either way. false (older recipes, kept bit-for-bit): the ridge was + // added OUTSIDE R, so an internal thread given the minor diameter came out undersized and + // an external one given the major diameter came out oversized. + bool thread_major_nominal{false}; double thread_x{0}; // axis position on the plane (u/x axis) double thread_y{0}; // axis position on the plane (v/y axis) @@ -156,6 +184,9 @@ struct CadFeature { // target_body. revolve_axis: 0 = plane X axis, 1 = plane Y axis. double revolve_angle{360}; // sweep angle in degrees (1..360) int revolve_axis{0}; // 0 = plane X, 1 = plane Y + // A Line of the profile sketch to revolve about instead (index into its entities: a + // centerline, usually construction, or an edge of the profile itself); -1 = revolve_axis. + int revolve_axis_entity{-1}; // Sweep: profile carried by sketch_ref / entities (like Extrude); the spine is a // second Sketch referenced by sweep_path_ref (an open or closed wire). Reuses @@ -178,6 +209,12 @@ struct CadFeature { double pattern_spacing{20}; // linear step (mm) int pattern_dir{0}; // linear direction: 0 = plane X, 1 = plane Y double pattern_angle{360}; // circular total angle (degrees) + // How a circular pattern spreads its copies over pattern_angle. true (every pattern made + // since): a full turn is split into `count` equal steps, anything less is spanned end to end, + // first copy at 0 and last at pattern_angle — the way a pattern along a curve spans its + // curve. false (older recipes, kept as they were built): always angle / count, so 90° with 3 + // copies stopped at 60°. + bool pattern_inclusive{false}; // Pattern along a curve: when pattern_curve_sketch >= 0 this mode takes precedence over // linear/circular. Copies are placed at equal-parameter points along entity @@ -190,6 +227,16 @@ struct CadFeature { // BEFORE geometry runs. Empty (the common case) means the feature uses its literal fields. std::map expr; + // Transient, never serialized. A body is referred to above by its INDEX, and an index is + // only meaningful against the history it was taken in: delete, reorder or disable a + // feature that makes a body and every later index points at a different body. So recompute + // records, for each body field, the identity of the body it resolved to — the feature that + // made it, and which of that feature's bodies it is — and when the history changes the + // fields are re-pointed from those identities (see CadDocument::recompute). + struct BodyId { int src{-1}; int ord{0}; }; + std::vector body_ref_ids; + bool body_refs_pending{false}; + // Datum/reference plane: a derived SketchPlane the document offers as a selectable // sketch plane (no solid). plane_base selects the reference (0=XY,1=XZ,2=YZ, or 3+N // = the Nth earlier datum plane); plane_offset shifts along the base normal; @@ -361,10 +408,62 @@ struct CadFeature { pattern_curve_sketch, pattern_curve_entity, expr, mate_kind, mate_cs_a, mate_cs_b, mate_offset, mate_angle, mate_flip, - coordsys_face_kind, coordsys_face_edges); + coordsys_face_kind, coordsys_face_edges, + thread_major_nominal, pattern_inclusive, + dressup_edges, + text_string, text_font, text_height, + revolve_axis_entity); } template void load(Archive& ar) { + std::string brep; + ar(type, name, enabled, shape, plane, width, height, radius, + profile, entities, constraints, entity_constraints, imported_regions, + import_offset, import_scale_x, import_scale_y, import_on_face, import_face_body, + sketch_ref, distance, symmetric, mode, extrude_end, distance2, taper_deg, flip, + up_to_face, extrude_src_face, up_to_point, target_body, + dressup_size, face_group, dressup_edge, + hole_diameter, hole_depth, hole_through, hole_x, hole_y, + thread_radius, thread_pitch, thread_height, thread_depth, thread_internal, thread_x, thread_y, + shell_thickness, shell_face, + draft_face, draft_angle, + revolve_angle, revolve_axis, + sweep_path_ref, loft_profile_refs, loft_ruled, + pattern_circular, pattern_count, pattern_spacing, pattern_dir, pattern_angle, + plane_base, plane_offset, plane_angle_tilt, plane_axis, + bool_tool_body, bool_keep_tool, bool_tolerance, bool_target_face, bool_tool_face, + cut_offset, cut_flip, cut_keep_upper, cut_keep_lower, + brep, + plane_type, plane_face_body, plane_face, plane_face2_body, plane_face2, + plane_edge_body, plane_edge, plane_edge2_body, plane_edge2, plane_u_size, plane_v_size, + mirror_keep_original, + axis_type, axis_p1, axis_p2, axis_body, axis_face, axis_edge, axis_plane_a, axis_plane_b, + coordsys_type, coordsys_point, coordsys_body, coordsys_face, coordsys_edge, coordsys_x_hint, + helix_radius, helix_pitch, helix_height, helix_left_handed, helix_taper_deg, + xf_translate, xf_axis, xf_pivot, xf_angle_deg, xf_copy, + thicken_face, thicken_thickness, thicken_flip, + cut_face_body, cut_face, + project_source_body, project_edges, project_face, + delete_faces, + hole_style, hole_cbore_diameter, hole_cbore_depth, + hole_csink_diameter, hole_csink_angle, hole_standard, + rib_sketch_ref, rib_entity, rib_thickness, rib_depth, + pattern_curve_sketch, pattern_curve_entity, + expr, + mate_kind, mate_cs_a, mate_cs_b, mate_offset, mate_angle, mate_flip, + coordsys_face_kind, coordsys_face_edges, + thread_major_nominal, pattern_inclusive, + dressup_edges, + text_string, text_font, text_height, + revolve_axis_entity); + imported_solid = brep_from_string(brep); + } + // The pre-framing (v4) layout, FROZEN. A v4 recipe is one flat stream with no per-feature + // length, so it can only be read with exactly the field list it was written with; reading it + // through load() above breaks the moment a field is appended there (every field added since + // v5 is appended ONLY above, never here). + template + void load_flat_v4(Archive& ar) { std::string brep; ar(type, name, enabled, shape, plane, width, height, radius, profile, entities, constraints, entity_constraints, imported_regions, @@ -403,6 +502,7 @@ struct CadFeature { coordsys_face_kind, coordsys_face_edges); imported_solid = brep_from_string(brep); } + }; // Serialize a TopoDS_Shape to/from a BRep string for cereal persistence. @@ -440,6 +540,13 @@ public: // Named document variables: name -> expression. Evaluated topologically each recompute(); // an expression may reference other variables. Feature `expr` bindings resolve against these. std::map variables; + // Can a feature expression drive this numeric field? The single allow-list the evaluator + // uses, so a caller can refuse a name before it breaks the next recompute. + static bool is_bindable_field(const std::string& field); + // Does a feature of this type leave a body behind? Sketches, datums, the helix curve and + // Project (which emits sketch entities) do not. The one answer recompute(), the rollback + // rule and the GUI's preview all use. + static bool produces_body(CadFeatureType t); // Multi-body result of the last replay. A "New" extrude appends a body; other ops // mutate a target body. Empty after a failed/empty recompute. std::vector bodies; @@ -459,8 +566,16 @@ public: // Modeling origin: the world point the default XY/XZ/YZ planes pass through. The GUI sets this // to the bed centre so sketches/datums land in the middle of the bed (not the bed corner = - // world 0). Not serialized — the GUI re-applies it from the live bed on every tab show. + // world 0) — for a NEW document. It is saved with the recipe: sketches bake it into their + // planes while datum planes add it when they are resolved, so a project reopened on another + // printer must keep the origin it was made with or its datums move and its sketches do not. Vec3d modeling_origin{Vec3d::Zero()}; + bool origin_from_recipe{false}; // modeling_origin came from the loaded project + // Weld sketch endpoints within kSketchJoinTol (the "Auto-close sketch loops" preference, taken + // when the document is started). A property of the DOCUMENT, saved with it: as a machine-wide + // preference it made one project rebuild into a closed solid on one computer and an open + // loop on another. recompute() pushes it into the kernel. + bool auto_close_loops{true}; // Tessellation quality, matched to Orca's OWN STEP importer (Format/STEP.hpp defaults: // linear 0.003, angular 0.5 rad) so a body modelled here reaches the screen at the same @@ -513,8 +628,10 @@ public: BooleanMode mode, const std::string& name); int add_fillet(double radius, FaceGroup faces, const std::string& name); int add_fillet(double radius, int edge_id, const std::string& name); + int add_fillet(double radius, const std::vector& edge_ids, const std::string& name); int add_chamfer(double distance, FaceGroup faces, const std::string& name); int add_chamfer(double distance, int edge_id, const std::string& name); + int add_chamfer(double distance, const std::vector& edge_ids, const std::string& name); int add_hole(double diameter, double depth, bool through, double x, double y, const SketchPlane& plane, const std::string& name); @@ -707,6 +824,9 @@ public: // an Extrude, its sketch_ref are preserved from the original). bool remove_feature(int index); bool move_feature(int index, int delta); + // Show or hide a feature. Transactional like the others, and it keeps body and datum-plane + // references pointing at the same objects, which a bare `enabled` flip does not. + bool set_feature_enabled(int index, bool enabled); bool replace_feature(int index, const CadFeature& edited); // replace_sketch_extrude: a box is two linked features (Sketch + Extrude); // overwrite both slots from one `edited` candidate (sketch params -> @@ -718,6 +838,9 @@ public: // tessellate the result into out_mesh, WITHOUT modifying features/body/ // display_mesh. Returns false (with err set) if the candidate is invalid. // Used by the Design tab to show a translucent ghost before Confirm. + // The solid body the closed profile of sketch `sketch_ref` lies on or touches, or -1. Drives + // the Extrude default: a profile drawn on a body joins it, one in free space is a new body. + int body_touching_sketch(int sketch_ref) const; bool preview(const CadFeature& candidate, TriangleMesh& out_mesh, std::string& err) const; // Same, but also returns the per-body meshes (in `bodies` order; the candidate may append // one), so the GUI can apply its display-only per-body Move transforms to the ghost and keep @@ -725,6 +848,16 @@ public: bool preview(const CadFeature& candidate, TriangleMesh& out_mesh, std::vector& out_body_meshes, std::string& err) const; + // The faces of the current bodies that features[index] made, as (body, face id) pairs: what + // the Design tab highlights when that feature is selected. A face counts when it lies on the + // model's boundary, facing the same way, right after the feature and not right before it, so + // a face a later feature trimmed still belongs to the one that made it. Positions are + // compared, no history is kept: a face a later feature rebuilt in place stays the earlier + // feature's. A feature with no face left of its own (a Boolean union, or one whose faces a + // later feature removed) answers with every face of each body it changed. Empty for a feature + // that leaves no body, a hidden one, or a history that no longer rebuilds up to it. + std::vector> faces_made_by(int index) const; + private: TopoDS_Wire build_sketch_wire(const CadFeature& sketch, bool closed_only = false) const; // The planar region an Extrude sweeps: the sketch's outer loop with its inner loops as @@ -741,6 +874,13 @@ private: // starts a new body (empty list, or an Extrude with mode New) vs mutates an existing // one, then apply_feature. Shared by recompute() (replay all) and preview() (candidate). void route_feature(std::vector& bodies, const CadFeature& f) const; + // The parametric pass every replay starts with: evaluate the variables and write each + // feature's expression bindings into its numeric fields. + void bind_expressions(); + // One step of the replay: route features[fi] into `built` and stamp the bodies it created + // with fi. A hidden feature, a sketch, a helix or a datum leaves `built` alone. Throws on + // failure. + void replay_feature(size_t fi, std::vector& built); // Boolean between two existing bodies: resolve target + tool, optionally snap the tool so // the picked faces mate, run the OCCT op (with fuzzy tolerance), write the result back to the // target and erase the consumed tool. Mutates the bodies vector directly (unlike apply_feature, diff --git a/src/libslic3r/CAD/GeometryEngine.cpp b/src/libslic3r/CAD/GeometryEngine.cpp index d764ab5318..8ef67d1c4a 100644 --- a/src/libslic3r/CAD/GeometryEngine.cpp +++ b/src/libslic3r/CAD/GeometryEngine.cpp @@ -294,21 +294,21 @@ FaceGroup GeometryEngine::classify_face(const TopoDS_Face& face, const TopoDS_Sh std::vector GeometryEngine::collect_edges(const TopoDS_Shape& solid, FaceGroup target) { + // Each edge ONCE. An explorer visits a shared edge from both of its faces, so walking one + // handed every edge to the fillet twice; the de-duplicated map is also what edge_by_index + // numbers edges by. std::vector result; + NCollection_IndexedDataMap, TopTools_ShapeMapHasher> edgeFaceMap; + TopExp::MapShapesAndAncestors(solid, TopAbs_EDGE, TopAbs_FACE, edgeFaceMap); if (target == FaceGroup::All) { - for (TopExp_Explorer exp(solid, TopAbs_EDGE); exp.More(); exp.Next()) - result.push_back(TopoDS::Edge(exp.Current())); + for (int i = 1; i <= edgeFaceMap.Extent(); ++i) + result.push_back(TopoDS::Edge(edgeFaceMap.FindKey(i))); return result; } - // Build edge-to-face map once - NCollection_IndexedDataMap, TopTools_ShapeMapHasher> edgeFaceMap; - TopExp::MapShapesAndAncestors(solid, TopAbs_EDGE, TopAbs_FACE, edgeFaceMap); - - for (TopExp_Explorer edgeExp(solid, TopAbs_EDGE); edgeExp.More(); edgeExp.Next()) { - const TopoDS_Edge& edge = TopoDS::Edge(edgeExp.Current()); - if (!edgeFaceMap.Contains(edge)) continue; - const NCollection_List& faces = edgeFaceMap.FindFromKey(edge); + for (int ei = 1; ei <= edgeFaceMap.Extent(); ++ei) { + const TopoDS_Edge& edge = TopoDS::Edge(edgeFaceMap.FindKey(ei)); + const NCollection_List& faces = edgeFaceMap.FindFromIndex(ei); bool include = false; for (auto it = faces.begin(); it != faces.end(); ++it) { @@ -344,12 +344,14 @@ std::vector GeometryEngine::collect_edges(const TopoDS_Shape& solid // ---- Fillet/Chamfer ---- +// All four dress-up entry points fail the same way — with a reason — instead of two of them +// handing the solid back unchanged, which recompute then reported as a success. TopoDS_Shape GeometryEngine::apply_fillet(const TopoDS_Shape& solid, double radius, FaceGroup faces) { - if (radius <= 0.001) return solid; + if (radius <= 0.001) throw std::runtime_error("the fillet radius must be greater than 0"); std::vector edges = collect_edges(solid, faces); - if (edges.empty()) return solid; + if (edges.empty()) throw std::runtime_error("no edges to fillet in that group"); BRepFilletAPI_MakeFillet fillet(solid); for (const auto& edge : edges) @@ -365,10 +367,10 @@ TopoDS_Shape GeometryEngine::apply_fillet(const TopoDS_Shape& solid, double radi TopoDS_Shape GeometryEngine::apply_chamfer(const TopoDS_Shape& solid, double distance, FaceGroup faces) { - if (distance <= 0.001) return solid; + if (distance <= 0.001) throw std::runtime_error("the chamfer distance must be greater than 0"); std::vector edges = collect_edges(solid, faces); - if (edges.empty()) return solid; + if (edges.empty()) throw std::runtime_error("no edges to chamfer in that group"); BRepFilletAPI_MakeChamfer chamfer(solid); for (const auto& edge : edges) @@ -381,28 +383,42 @@ TopoDS_Shape GeometryEngine::apply_chamfer(const TopoDS_Shape& solid, double dis TopoDS_Shape GeometryEngine::apply_fillet(const TopoDS_Shape& solid, double radius, int edge_id) { - if (radius <= 0.001) return solid; + return apply_fillet(solid, radius, std::vector{ edge_id }); +} - TopoDS_Edge edge = edge_by_index(solid, edge_id); - if (edge.IsNull()) throw std::runtime_error("apply_fillet: invalid edge id"); +TopoDS_Shape GeometryEngine::apply_chamfer(const TopoDS_Shape& solid, double distance, int edge_id) +{ + return apply_chamfer(solid, distance, std::vector{ edge_id }); +} + +TopoDS_Shape GeometryEngine::apply_fillet(const TopoDS_Shape& solid, double radius, const std::vector& edge_ids) +{ + if (radius <= 0.001) throw std::runtime_error("the fillet radius must be greater than 0"); + if (edge_ids.empty()) throw std::runtime_error("apply_fillet: no edge picked"); BRepFilletAPI_MakeFillet mk(solid); - mk.Add(radius, edge); + for (int id : edge_ids) { + TopoDS_Edge edge = edge_by_index(solid, id); + if (edge.IsNull()) throw std::runtime_error("apply_fillet: invalid edge id"); + mk.Add(radius, edge); + } mk.Build(); if (!mk.IsDone()) throw std::runtime_error("apply_fillet: OCCT fillet failed"); return mk.Shape(); } -TopoDS_Shape GeometryEngine::apply_chamfer(const TopoDS_Shape& solid, double distance, int edge_id) +TopoDS_Shape GeometryEngine::apply_chamfer(const TopoDS_Shape& solid, double distance, const std::vector& edge_ids) { - if (distance <= 0.001) return solid; - - TopoDS_Edge edge = edge_by_index(solid, edge_id); - if (edge.IsNull()) throw std::runtime_error("apply_chamfer: invalid edge id"); + if (distance <= 0.001) throw std::runtime_error("the chamfer distance must be greater than 0"); + if (edge_ids.empty()) throw std::runtime_error("apply_chamfer: no edge picked"); BRepFilletAPI_MakeChamfer mk(solid); - mk.Add(distance, edge); + for (int id : edge_ids) { + TopoDS_Edge edge = edge_by_index(solid, id); + if (edge.IsNull()) throw std::runtime_error("apply_chamfer: invalid edge id"); + mk.Add(distance, edge); + } mk.Build(); if (!mk.IsDone()) throw std::runtime_error("apply_chamfer: OCCT chamfer failed"); @@ -614,6 +630,27 @@ std::vector GeometryEngine::sample_edge_world(const TopoDS_Edge& edge, do return pts; } +std::vector> GeometryEngine::display_edges(const TopoDS_Shape& shape, double chord_tol) +{ + NCollection_IndexedDataMap, TopTools_ShapeMapHasher> faces_of_edge; + TopExp::MapShapesAndAncestors(shape, TopAbs_EDGE, TopAbs_FACE, faces_of_edge); + std::vector> out; + for (int i = 1; i <= faces_of_edge.Extent(); ++i) { + const TopoDS_Edge& edge = TopoDS::Edge(faces_of_edge.FindKey(i)); + if (BRep_Tool::Degenerated(edge)) + continue; + bool seam = false; + for (NCollection_List::Iterator it(faces_of_edge.FindFromIndex(i)); it.More() && !seam; it.Next()) + seam = BRep_Tool::IsClosed(edge, TopoDS::Face(it.Value())); + if (seam) + continue; + std::vector pts = sample_edge_world(edge, chord_tol); + if (pts.size() >= 2) + out.push_back(std::move(pts)); + } + return out; +} + Vec3d GeometryEngine::face_centroid_world(const TopoDS_Face& face) { GProp_GProps props; diff --git a/src/libslic3r/CAD/GeometryEngine.hpp b/src/libslic3r/CAD/GeometryEngine.hpp index 6c0fb51daf..e7e92c0438 100644 --- a/src/libslic3r/CAD/GeometryEngine.hpp +++ b/src/libslic3r/CAD/GeometryEngine.hpp @@ -39,8 +39,9 @@ struct PrimitiveParams { double dressup_radius{1.0}; // fillet radius double dressup_chamfer_dist{1.0}; // chamfer distance (symmetric) - // Mesh quality - double linear_deflection{0.01}; + // Mesh quality — the Design tab's own density (CadDocument::linear_deflection), so a + // primitive and the same body modelled in the Design tab reach the screen alike. + double linear_deflection{0.003}; double angular_deflection{0.5}; template @@ -122,9 +123,14 @@ public: FaceGroup faces = FaceGroup::All); static TopoDS_Shape apply_chamfer(const TopoDS_Shape& solid, double distance, int edge_id); + // Several edges in one operation, all ids resolved against `solid`. + static TopoDS_Shape apply_fillet(const TopoDS_Shape& solid, double radius, + const std::vector& edge_ids); + static TopoDS_Shape apply_chamfer(const TopoDS_Shape& solid, double distance, + const std::vector& edge_ids); static TriangleMesh tessellate(const TopoDS_Shape& shape, - double linear_deflection = 0.01, + double linear_deflection = 0.003, double angular_deflection = 0.5); static std::string primitive_name(PrimitiveType type); @@ -146,6 +152,10 @@ public: static Vec3d face_normal_world(const TopoDS_Face& face); // Sample an edge into a world-space polyline (>=2 pts) for pick-distance + highlight. static std::vector sample_edge_world(const TopoDS_Edge& edge, double chord_tol = 0.05); + // The edges a viewer draws over a body, each as a polyline: every edge of the shape once, + // without degenerate edges (a cone apex) and without the seam of a closed surface (the line + // down a cylinder's side), which is where OCCT closes the parameter space, not a real edge. + static std::vector> display_edges(const TopoDS_Shape& shape, double chord_tol); // 0-based edge index into TopExp::MapShapes(shape, TopAbs_EDGE, map). static int edge_count(const TopoDS_Shape& shape); static TopoDS_Edge edge_by_index(const TopoDS_Shape& shape, int index); diff --git a/src/libslic3r/CAD/SketchEngine.cpp b/src/libslic3r/CAD/SketchEngine.cpp index 045145cc85..d06277c74e 100644 --- a/src/libslic3r/CAD/SketchEngine.cpp +++ b/src/libslic3r/CAD/SketchEngine.cpp @@ -19,6 +19,8 @@ #include #include #include +#include +#include #include #include #include @@ -56,6 +58,7 @@ #include #include #include +#include #include #include #include @@ -66,24 +69,18 @@ class Geom_TrimmedCurve; namespace Slic3r { -// Single source of truth for the weld tolerance the viewport and the kernel share. -// Defaults ON so headless/kernel-only callers keep welding; the GUI pushes the -// "auto_close_sketch_loops" preference in via set_sketch_auto_close(). -static bool s_auto_close = true; +// Single source of truth for the weld tolerance the viewport and the kernel share. Defaults ON +// so headless/kernel-only callers keep welding. It is the DOCUMENT's setting +// (CadDocument::auto_close_loops, saved with the recipe), pushed in by CadDocument::recompute: +// the same project must rebuild into the same solid on every machine. Atomic because the GUI +// rebuilds on a worker thread while the viewport reads it. +static std::atomic s_auto_close{true}; -double sketch_join_tol() { return s_auto_close ? kSketchJoinTol : 0.0; } -void set_sketch_auto_close(bool on) { s_auto_close = on; } +double sketch_join_tol() { return s_auto_close.load() ? kSketchJoinTol : 0.0; } +void set_sketch_auto_close(bool on) { s_auto_close.store(on); } // ---- SketchPlane ---- -gp_Pln SketchPlane::to_occt() const -{ - gp_Pnt o(origin.x(), origin.y(), origin.z()); - gp_Dir n(normal.x(), normal.y(), normal.z()); - gp_Dir x(x_axis.x(), x_axis.y(), x_axis.z()); - return gp_Pln(gp_Ax3(o, n, x)); -} - SketchPlane SketchPlane::from_face(const TopoDS_Face& face) { SketchPlane sp; @@ -138,26 +135,6 @@ Vec3d SketchPlane::to_world(const Vec2d& pt) const // ---- SketchProfile ---- -bool SketchProfile::is_closed(double tolerance) const -{ - if (points.size() < 3) return false; - return (points.front() - points.back()).norm() < tolerance; -} - -bool SketchProfile::try_close(double tolerance) -{ - if (is_closed(tolerance)) { - closed = true; - return true; - } - if (points.size() < 2) return false; - if ((points.front() - points.back()).norm() < tolerance) { - closed = true; - return true; - } - return false; -} - TopoDS_Wire SketchProfile::to_occt_wire(const SketchPlane& plane) const { if (points.size() < 2) @@ -292,7 +269,9 @@ TopoDS_Shape SketchEngine::make_extrude_regions( // degenerate OCCT edge and make the wire builder throw — sanitising keeps a // single bad glyph from killing the whole extrude. auto clean = [](const std::vector& pts) { - const double eps2 = 1e-12; // ~1e-6 mm + // The sketch's own joint tolerance: points closer than this are one point everywhere + // else in the sketcher, so they must not become a sub-micron edge here either. + const double eps2 = kSketchJoinTol * kSketchJoinTol; std::vector out; out.reserve(pts.size()); for (const Vec2d& p : pts) @@ -320,6 +299,10 @@ TopoDS_Shape SketchEngine::make_extrude_regions( }; gp_Dir dir(plane.normal.x(), plane.normal.y(), plane.normal.z()); + // Faces are built ON the sketch plane, as wires_to_face does: a surface inferred from the + // outer wire need not share the sketch normal, and then the extrude direction and the hole + // classification disagree with it. + const gp_Pln pln(gp_Pnt(plane.origin.x(), plane.origin.y(), plane.origin.z()), dir); // Accumulate each region's solid into a compound rather than boolean-fusing: // glyphs are independent profiles, so a compound avoids every boolean-failure @@ -340,7 +323,10 @@ TopoDS_Shape SketchEngine::make_extrude_regions( // classify outer vs holes by area/containment and set correct wire // orientations. This is winding-independent, so holed glyphs extrude // with a solid body and empty counters regardless of source winding. - BRepBuilderAPI_MakeFace fm(outer); + // Probe on the inferred surface first: naming a plane makes MakeFace accept a wire + // that bounds nothing, and this check is what skips such a contour. + if (!BRepBuilderAPI_MakeFace(outer).IsDone()) continue; + BRepBuilderAPI_MakeFace fm(pln, outer); if (!fm.IsDone()) continue; for (size_t h = 1; h < region.size(); ++h) { TopoDS_Wire hole = contour_wire(region[h]); @@ -366,20 +352,35 @@ TopoDS_Shape SketchEngine::make_extrude_regions( return count == 1 ? last : TopoDS_Shape(comp); // avoid a compound-of-one } -TopoDS_Shape SketchEngine::make_revolve(const TopoDS_Wire& wire, const SketchPlane& plane, - double angle_deg, int axis_sel) +TopoDS_Shape SketchEngine::make_revolve(const TopoDS_Wire& wire, const gp_Ax1& axis_in, double angle_deg) { BRepBuilderAPI_MakeFace faceMaker(wire); if (!faceMaker.IsDone()) throw std::runtime_error("Failed to make face from wire"); TopoDS_Face face = faceMaker.Face(); - // Revolution axis lies in the sketch plane through its origin: X (0) or Y (1). - const Vec3d& adir = (axis_sel == 1) ? plane.y_axis : plane.x_axis; - gp_Pnt o(plane.origin.x(), plane.origin.y(), plane.origin.z()); - gp_Dir xd(adir.x(), adir.y(), adir.z()); - gp_Ax1 axis(o, xd); - + // A profile on both sides of the axis sweeps through itself; MakeRevol then fails with no + // reason, or builds an invalid solid. Sample every edge and name the cause instead. + { + const gp_Pnt o = axis_in.Location(); + const gp_Dir d = axis_in.Direction(); + bool pos = false, neg = false; + gp_Vec side_ref; + for (TopExp_Explorer ex(wire, TopAbs_EDGE); ex.More(); ex.Next()) { + BRepAdaptor_Curve c(TopoDS::Edge(ex.Current())); + for (int i = 0; i <= 16; ++i) { + const gp_Pnt p = c.Value(c.FirstParameter() + (c.LastParameter() - c.FirstParameter()) * i / 16.0); + const gp_Vec off = gp_Vec(o, p) - gp_Vec(d) * gp_Vec(o, p).Dot(gp_Vec(d)); // from the axis + if (off.Magnitude() < 1e-6) + continue; + if (side_ref.Magnitude() == 0.0) { side_ref = off; pos = true; continue; } + (off.Dot(side_ref) > 0.0 ? pos : neg) = true; + } + } + if (pos && neg) + throw std::runtime_error("the profile crosses the revolve axis — it must lie on one side of it"); + } + gp_Ax1 axis = axis_in; double angle_rad = angle_deg * M_PI / 180.0; // A negative angle is expressed as a positive sweep about the reversed axis, // since BRepPrimAPI_MakeRevol expects an angle in (0, 2*pi]. @@ -387,6 +388,8 @@ TopoDS_Shape SketchEngine::make_revolve(const TopoDS_Wire& wire, const SketchPla BRepPrimAPI_MakeRevol rev(face, axis, angle_rad); if (!rev.IsDone()) throw std::runtime_error("Failed to revolve"); + if (!BRepCheck_Analyzer(rev.Shape()).IsValid()) + throw std::runtime_error("the profile crosses the revolve axis — it must lie on one side of it"); return rev.Shape(); } @@ -581,7 +584,12 @@ std::vector SketchEngine::entities_to_wires(const std::vector gp_Elips { Vec3d c3 = plane.to_world(c.center); gp_Pnt center(c3.x(), c3.y(), c3.z()); - gp_Dir n(plane.normal.x(), plane.normal.y(), plane.normal.z()); + // The frame's normal is x_axis x y_axis, not plane.normal: OCCT takes the ellipse's Y + // as N x X, and the parametric angles were measured in the sketch's own (x, y). The two + // agree on XY and YZ; the XZ base plane stores normal = +Y while x x y = -Y, so there + // every elliptical arc came out mirrored against what the sketch showed. + const Vec3d nz = plane.x_axis.cross(plane.y_axis); + gp_Dir n(nz.x(), nz.y(), nz.z()); Vec2d maj2(std::cos(c.rotation), std::sin(c.rotation)); Vec3d x3 = plane.to_world(c.center + maj2) - c3; gp_Dir xdir(x3.x(), x3.y(), x3.z()); @@ -705,6 +713,9 @@ std::vector SketchEngine::entities_to_wires(const std::vector sketch_open_ends(const std::vector& entities, return out; } +// A closed wire can still fail to bound a region: a loop that crosses itself, or one that +// doubles back along itself (a cusp, e.g. an arc leaving a line tangent to it but in the +// opposite direction). MakeFace reports success on both and the prism built from the face is +// an invalid solid with no caps. Refuse it here, with the reason, instead of shipping that. +static TopoDS_Face checked_profile_face(const TopoDS_Face& f) +{ + BRepCheck_Analyzer an(f); + if (!an.IsValid()) + throw std::runtime_error("the profile crosses or folds back on itself, so it does not bound one region"); + return f; +} + TopoDS_Face SketchEngine::wires_to_face(const std::vector& wires, const SketchPlane& plane) { @@ -922,7 +945,7 @@ TopoDS_Face SketchEngine::wires_to_face(const std::vector& wires, if (wires.size() == 1) { BRepBuilderAPI_MakeFace fm(wires[0]); if (!fm.IsDone()) throw std::runtime_error("sketch loop does not bound a face"); - return fm.Face(); + return checked_profile_face(fm.Face()); } // Two or more loops: build a face per wire and let the largest area be the outer @@ -978,7 +1001,7 @@ TopoDS_Face SketchEngine::wires_to_face(const std::vector& wires, // while a holed SKETCH did not. ShapeFix_Face sff(fm.Face()); sff.FixOrientation(); - return sff.Face(); + return checked_profile_face(sff.Face()); } std::vector SketchEngine::mirror_entities( @@ -1050,8 +1073,11 @@ std::vector SketchEngine::mirror_entities( } else { m.p0 = reflect(e.p0); m.p1 = reflect(e.p1); - // Reflection reverses orientation: recompute parametric angles in - // the reflected frame, original end -> new start (CCW sense kept). + // Reflection reverses orientation. Like the Arc branch above, this pass keeps + // each angle with ITS point (start with p0) and lets the sweep run clockwise; + // the reversal pass below then swaps points and angles together, giving a CCW + // arc whose start is p0. Pairing them crosswise here, as this used to, was + // undone by that same swap and produced the complementary arc. auto param = [&](const Vec2d& P) { const Vec2d d = P - m.center; const double cu = std::cos(m.rotation), su = std::sin(m.rotation); @@ -1059,8 +1085,9 @@ std::vector SketchEngine::mirror_entities( const double v = -d.x() * su + d.y() * cu; return std::atan2(v / std::max(e.rminor, 1e-9), u / std::max(e.radius, 1e-9)); }; - m.start_angle = param(m.p1); - m.end_angle = param(m.p0); + m.start_angle = param(m.p0); + m.end_angle = param(m.p1); + while (m.end_angle >= m.start_angle) m.end_angle -= 2.0 * M_PI; } break; } @@ -1120,9 +1147,14 @@ std::vector SketchEngine::mirror_entities( // get their last-to-first seam repaired too, which is what makes the result closed again. namespace { -constexpr double kOffJoinEps = 1e-6; - -bool off_same(const Vec2d& a, const Vec2d& b) { return (a - b).squaredNorm() < kOffJoinEps * kOffJoinEps; } +// Two ends are the same point when the WIRE BUILDER would weld them: a loop the viewport and +// the kernel treat as closed must offset as one closed chain, not as separate open pieces with +// unrepaired seams. The floor keeps exact coincidence meaningful with auto-close switched off. +bool off_same(const Vec2d& a, const Vec2d& b) +{ + const double tol = std::max(sketch_join_tol(), 1e-6); + return (a - b).squaredNorm() <= tol * tol; +} // Does this entity type take part in chaining (i.e. does it have two ends)? bool off_is_open_curve(const SketchEntity& e) @@ -1228,7 +1260,10 @@ bool off_one(const SketchEntity& e, double d, SketchEntity& out) return true; } case SketchEntity::Type::Circle: { - const double r = e.radius + d; + // A circle runs CCW (it is what a 360° CCW arc chain closes into), so the rule below + // applies to it too: +d is the left side, the inside, and the radius SHRINKS. It used to + // grow, so a full circle and the same outline drawn as arcs offset opposite ways. + const double r = e.radius - d; if (r <= 1e-9) return false; out = e; out.radius = r; out.p0 = out.center; return true; @@ -1467,6 +1502,9 @@ std::vector SketchEngine::transform_entities( out.reserve(src.size()); const double ca = std::cos(angle), sa = std::sin(angle); const double rs = std::abs(scale); // radii are unsigned magnitudes + // A negative scale is |scale| plus a half turn about the pivot: points get that from xf() + // below, and angle-valued fields (arc ends, ellipse axis) need the same extra pi. + const double turn = angle + (scale < 0.0 ? M_PI : 0.0); // Affine map: translate pivot to origin, scale, rotate, then translate by `move`. auto xf = [&](const Vec2d& p) -> Vec2d { const Vec2d d = scale * (p - pivot); @@ -1489,8 +1527,8 @@ std::vector SketchEngine::transform_entities( case SketchEntity::Type::Arc: { m.center = xf(e.center); m.radius = e.radius * rs; - m.start_angle = e.start_angle + angle; - m.end_angle = e.end_angle + angle; // rigid sweep, shifted by rotation + m.start_angle = e.start_angle + turn; + m.end_angle = e.end_angle + turn; // rigid sweep, shifted by rotation m.p0 = m.center + m.radius * Vec2d(std::cos(m.start_angle), std::sin(m.start_angle)); m.p1 = m.center + m.radius * Vec2d(std::cos(m.end_angle), std::sin(m.end_angle)); break; @@ -1500,7 +1538,7 @@ std::vector SketchEngine::transform_entities( m.center = xf(e.center); m.radius = e.radius * rs; m.rminor = e.rminor * rs; - m.rotation = e.rotation + angle; // major axis rotates with the body + m.rotation = e.rotation + turn; // major axis rotates with the body if (e.type == SketchEntity::Type::Ellipse) { m.p0 = m.center; } else { @@ -1847,6 +1885,15 @@ static double wrap_2pi(double x) return x; } +// An arc's endpoints are stored twice: as angles and as p0/p1. Everything downstream reads +// p0/p1 (the wire builder welds on them, the solver seeds from them, snapping uses them), so +// any edit of the angles must write them back or the arc's ends silently stay where they were. +static void sync_arc_ends(SketchEntity& e) +{ + e.p0 = e.center + e.radius * Vec2d(std::cos(e.start_angle), std::sin(e.start_angle)); + e.p1 = e.center + e.radius * Vec2d(std::cos(e.end_angle), std::sin(e.end_angle)); +} + bool SketchEngine::trim_entity(SketchEntity& e, const std::vector& others, const Vec2d& pick) { @@ -1881,6 +1928,7 @@ bool SketchEngine::trim_entity(SketchEntity& e, const std::vector& if (uc == -std::numeric_limits::max()) return false; e.end_angle = e.start_angle + uc * sweep; // drop (uc, 1] } + sync_arc_ends(e); return true; } @@ -1912,6 +1960,7 @@ bool SketchEngine::trim_entity(SketchEntity& e, const std::vector& e.type = SketchEntity::Type::Arc; e.start_angle = hi; e.end_angle = lo + 2.0 * M_PI; + sync_arc_ends(e); // p0 was the circle's centre (circle convention); now the arc's start return true; } @@ -1984,6 +2033,7 @@ bool SketchEngine::extend_entity(SketchEntity& e, const std::vector::max()) return false; if (extend_end) e.end_angle += sgn * best; else e.start_angle -= sgn * best; + sync_arc_ends(e); return true; } @@ -2024,7 +2074,9 @@ bool SketchEngine::extend_entity(SketchEntity& e, const std::vector return 0; } +namespace { + +// An arc's signed angular offset of `ang` from its start, within its sweep: true when the angle +// lies on the arc (with `tol` radians of slack at either end). +bool angle_on_arc(const SketchEntity& e, double ang, double tol) +{ + const double TWO_PI = 2.0 * M_PI; + const double sweep = e.end_angle - e.start_angle; + double d = (sweep >= 0.0) ? ang - e.start_angle : e.start_angle - ang; + d = std::fmod(d, TWO_PI); + if (d < 0.0) d += TWO_PI; + if (d > TWO_PI - tol) d -= TWO_PI; // just before the start counts as the start + return d >= -tol && d <= std::abs(sweep) + tol; +} + +// Every point where two lines/arcs meet (tangent contact included). Exact, no sampling. +void entity_intersections(const SketchEntity& A, const SketchEntity& B, std::vector& out) +{ + using T = SketchEntity::Type; + const double eps = 1e-9; + auto on_seg = [](const SketchEntity& L, const Vec2d& p) { + const Vec2d d = L.p1 - L.p0; + const double l2 = d.squaredNorm(); + if (l2 < 1e-24) return false; + const double t = (p - L.p0).dot(d) / l2; + return t >= -1e-9 && t <= 1.0 + 1e-9; + }; + if (A.type == T::Line && B.type == T::Line) { + const Vec2d r = A.p1 - A.p0, q = B.p1 - B.p0, w = B.p0 - A.p0; + const double den = r.x() * q.y() - r.y() * q.x(); + if (std::abs(den) < eps * r.norm() * q.norm()) { + // Parallel. Collinear overlap is a crossing wherever it is: report the overlap's + // nearest end, which the caller then tests against the shared joints. + if (std::abs(r.x() * w.y() - r.y() * w.x()) > 1e-9 * std::max(1.0, r.norm())) return; + for (const Vec2d& p : { B.p0, B.p1 }) if (on_seg(A, p)) out.push_back(p); + for (const Vec2d& p : { A.p0, A.p1 }) if (on_seg(B, p)) out.push_back(p); + return; + } + const double t = (w.x() * q.y() - w.y() * q.x()) / den; + const double u = (w.x() * r.y() - w.y() * r.x()) / den; + if (t >= -1e-9 && t <= 1.0 + 1e-9 && u >= -1e-9 && u <= 1.0 + 1e-9) out.push_back(A.p0 + t * r); + return; + } + if (A.type == T::Arc && B.type == T::Line) { entity_intersections(B, A, out); return; } + if (A.type == T::Line && B.type == T::Arc) { + const Vec2d d = A.p1 - A.p0, f = A.p0 - B.center; + const double a = d.squaredNorm(), b = 2.0 * f.dot(d), c = f.squaredNorm() - B.radius * B.radius; + if (a < 1e-24) return; + double disc = b * b - 4.0 * a * c; + if (disc < -1e-9 * a * B.radius * B.radius) return; + disc = std::sqrt(std::max(0.0, disc)); + for (double t : { (-b - disc) / (2.0 * a), (-b + disc) / (2.0 * a) }) { + if (t < -1e-9 || t > 1.0 + 1e-9) continue; + const Vec2d p = A.p0 + t * d; + if (angle_on_arc(B, std::atan2(p.y() - B.center.y(), p.x() - B.center.x()), 1e-9)) + out.push_back(p); + } + return; + } + if (A.type == T::Arc && B.type == T::Arc) { + const Vec2d dc = B.center - A.center; + const double dd = dc.norm(); + if (dd < 1e-12) { + // Concentric: they overlap only on the same circle, and then everywhere they share. + if (std::abs(A.radius - B.radius) > 1e-9) return; + for (const Vec2d& p : { B.p0, B.p1 }) + if (angle_on_arc(A, std::atan2(p.y() - A.center.y(), p.x() - A.center.x()), 1e-9)) out.push_back(p); + for (const Vec2d& p : { A.p0, A.p1 }) + if (angle_on_arc(B, std::atan2(p.y() - B.center.y(), p.x() - B.center.x()), 1e-9)) out.push_back(p); + return; + } + if (dd > A.radius + B.radius + 1e-9 || dd < std::abs(A.radius - B.radius) - 1e-9) return; + const double x = (dd * dd + A.radius * A.radius - B.radius * B.radius) / (2.0 * dd); + const double h = std::sqrt(std::max(0.0, A.radius * A.radius - x * x)); + const Vec2d u = dc / dd, m = A.center + x * u, n(-u.y(), u.x()); + for (const Vec2d& p : { Vec2d(m + h * n), Vec2d(m - h * n) }) { + if (angle_on_arc(A, std::atan2(p.y() - A.center.y(), p.x() - A.center.x()), 1e-9) && + angle_on_arc(B, std::atan2(p.y() - B.center.y(), p.x() - B.center.x()), 1e-9)) + out.push_back(p); + if (h < 1e-12) break; + } + } +} + +} // namespace + +bool sketch_loop_defect(const std::vector& ents, const std::vector& order, Vec2d& at) +{ + using T = SketchEntity::Type; + const size_t n = order.size(); + if (n < 2) return false; + for (int ei : order) + if (ei < 0 || ei >= int(ents.size()) || (ents[ei].type != T::Line && ents[ei].type != T::Arc)) + return false; + const double tol = std::max(1e-6, sketch_join_tol()); + + // Traversal direction of each entity: the end it shares with the NEXT one is where it + // finishes. start[k]/end[k] are the traversal's ends. + std::vector start(n), end(n); + std::vector rev(n, false); + for (size_t k = 0; k < n; ++k) { + const SketchEntity& e = ents[order[k]]; + const SketchEntity& nx = ents[order[(k + 1) % n]]; + const double to_next_p1 = std::min((e.p1 - nx.p0).norm(), (e.p1 - nx.p1).norm()); + const double to_next_p0 = std::min((e.p0 - nx.p0).norm(), (e.p0 - nx.p1).norm()); + rev[k] = to_next_p0 < to_next_p1; + start[k] = rev[k] ? e.p1 : e.p0; + end[k] = rev[k] ? e.p0 : e.p1; + } + auto tangent = [&](size_t k, bool at_end) { + const SketchEntity& e = ents[order[k]]; + Vec2d t; + if (e.type == T::Line) { + t = e.p1 - e.p0; + } else { + const double a = at_end ? (rev[k] ? e.start_angle : e.end_angle) + : (rev[k] ? e.end_angle : e.start_angle); + const double s = (e.end_angle >= e.start_angle) ? 1.0 : -1.0; + t = s * Vec2d(-std::sin(a), std::cos(a)); + } + if (rev[k]) t = -t; + const double l = t.norm(); + return l > 1e-15 ? Vec2d(t / l) : Vec2d(0, 0); + }; + + // Cusps: the curve arriving at a joint and the one leaving it point opposite ways. + for (size_t k = 0; k < n; ++k) { + const size_t j = (k + 1) % n; + if (tangent(k, true).dot(tangent(j, false)) < -0.9999) { at = end[k]; return true; } + } + + // Crossings: any contact between two entities away from the joints they share. + for (size_t i = 0; i < n; ++i) + for (size_t j = i + 1; j < n; ++j) { + std::vector hits; + entity_intersections(ents[order[i]], ents[order[j]], hits); + const bool next = (j == i + 1), prev = (i == 0 && j == n - 1); + for (const Vec2d& p : hits) { + bool joint = false; + if (next && (p - end[i]).norm() < tol) joint = true; + if (prev && (p - start[i]).norm() < tol) joint = true; + if (!joint) { at = p; return true; } + } + } + return false; +} + bool sketch_closest_ends(const SketchEntity& A, const SketchEntity& B, SketchPointRole& ra, SketchPointRole& rb, Vec2d& pa, Vec2d& pb) { diff --git a/src/libslic3r/CAD/SketchEngine.hpp b/src/libslic3r/CAD/SketchEngine.hpp index 62f4d03219..93fbf72cb9 100644 --- a/src/libslic3r/CAD/SketchEngine.hpp +++ b/src/libslic3r/CAD/SketchEngine.hpp @@ -6,6 +6,7 @@ #include "libslic3r/CAD/GeometryEngine.hpp" #include +#include #include #include #include @@ -53,9 +54,11 @@ struct SketchPlane { Vec3d x_axis{1,0,0}; Vec3d y_axis{0,1,0}; - gp_Pln to_occt() const; static SketchPlane from_face(const TopoDS_Face& face); static SketchPlane XY() { return {}; } + // NOTE: XZ's stored normal (+Y) is the OPPOSITE of x_axis x y_axis (-Y). Kept as it is — + // extrude directions and saved recipes depend on it — so anything that needs the frame's + // own handedness takes x_axis.cross(y_axis) instead of `normal` (see make_elips). static SketchPlane XZ() { return {{0,0,0}, {0,1,0}, {1,0,0}, {0,0,1}}; } static SketchPlane YZ() { return {{0,0,0}, {1,0,0}, {0,1,0}, {0,0,1}}; } @@ -70,8 +73,6 @@ struct SketchProfile { std::vector points; bool closed{false}; - bool is_closed(double tolerance = 0.5) const; - bool try_close(double tolerance = 0.5); void clear() { points.clear(); closed = false; } TopoDS_Wire to_occt_wire(const SketchPlane& plane) const; @@ -91,9 +92,9 @@ inline constexpr double kSketchJoinTol = 1e-3; // mm // Effective sketch joint tolerance. ONE value for the viewport (region_loops / // loop_report / connected_loop) and the kernel (entities_to_wires): if these ever // disagree again, the viewport shades a region closed that the kernel refuses to -// build, which is how a sketch got extruded into the wrong solid. The GUI pushes -// the "auto_close_sketch_loops" preference in via set_sketch_auto_close(); the -// kernel defaults to ON so headless/kernel-only callers keep welding. +// build, which is how a sketch got extruded into the wrong solid. The document's +// own setting (CadDocument::auto_close_loops) is pushed in via set_sketch_auto_close(); +// the kernel defaults to ON so headless/kernel-only callers keep welding. double sketch_join_tol(); void set_sketch_auto_close(bool on); @@ -109,8 +110,8 @@ enum class SketchConstraintType { // inserting anywhere but the end reinterprets every constraint in every saved recipe. EqualRadius, Collinear, - DistanceX, // |dx| between two points, projected onto the sketch X axis - DistanceY, // |dy| between two points, projected onto the sketch Y axis + DistanceX, // signed dx between two points (eb - ea), projected onto the sketch X axis + DistanceY, // signed dy between two points (eb - ea), projected onto the sketch Y axis SymmetricAboutY, // mirror across the sketch's vertical axis (x = 0); axis is implicit SymmetricAboutX // mirror across the sketch's horizontal axis (y = 0); axis is implicit }; @@ -165,6 +166,15 @@ int sketch_entity_ends(const SketchEntity& e, std::pair bool sketch_closest_ends(const SketchEntity& A, const SketchEntity& B, SketchPointRole& ra, SketchPointRole& rb, Vec2d& pa, Vec2d& pb); +// Where a CLOSED loop fails to bound one region, although every joint meets: two of its +// entities touch somewhere other than a joint they share (the loop crosses itself), or a joint +// where the curve turns straight back along itself (a cusp: an arc leaving a line tangent to it +// but heading the other way). Either makes a face OCCT accepts and then builds an invalid solid +// from. `order` lists the loop's entity indices in traversal order, as the chainer found them. +// Lines and arcs are judged exactly; a loop holding any other kind is not judged (false). +// On true, `at` is the offending point in sketch coordinates. +bool sketch_loop_defect(const std::vector& ents, const std::vector& order, Vec2d& at); + // Why an entity-constraint pick is refused. The caller maps a reason to a localized string; // the planner itself stays translation-free. enum class ConstraintReject { @@ -256,12 +266,10 @@ public: const std::vector>>& regions, const SketchPlane& plane, double length, bool symmetric = false); - // Revolve a planar profile wire about an axis lying in the sketch plane and - // passing through the plane origin: axis_sel 0 = plane X axis, 1 = plane Y axis. - // A negative angle_deg sweeps the opposite direction (Flip). The profile must - // lie to one side of the axis (Onshape rule); a straddling profile self-intersects. - static TopoDS_Shape make_revolve(const TopoDS_Wire& wire, const SketchPlane& plane, - double angle_deg = 360.0, int axis_sel = 0); + // Revolve the closed profile wire about `axis` (world) by angle_deg; a negative angle sweeps + // the other way (Flip). The profile must lie to one side of the axis (Onshape rule): one that + // straddles it sweeps through itself, and that is refused rather than returned broken. + static TopoDS_Shape make_revolve(const TopoDS_Wire& wire, const gp_Ax1& axis, double angle_deg = 360.0); // Sweep a planar profile wire along a path (spine) wire. The profile is turned // into a face and swept with BRepOffsetAPI_MakePipe, which keeps the profile @@ -317,7 +325,7 @@ public: // offset together and their seams repaired (miter join), so a closed profile comes back // closed and can still be extruded; per-entity offsetting cannot do that. Sign convention: // +d moves each curve to the LEFT of its direction of travel, which for a CCW closed loop - // is inward. Ellipses and splines are not offset (a parallel of either is not the same + // is inward; a full circle counts as CCW, so +d shrinks it. Ellipses and splines are not offset (a parallel of either is not the same // kind of curve) and are dropped from the result. static std::vector offset_entities( const std::vector& src, double d); diff --git a/src/libslic3r/CAD/SketchImport.cpp b/src/libslic3r/CAD/SketchImport.cpp index 32bcc608d3..76793c7e87 100644 --- a/src/libslic3r/CAD/SketchImport.cpp +++ b/src/libslic3r/CAD/SketchImport.cpp @@ -75,12 +75,18 @@ ImportRegions text_to_regions(const std::string& utf8, double size_mm, return {}; const std::string path = font_path.empty() ? default_font_path() : font_path; - std::unique_ptr ff = Emboss::create_font_file(path.c_str()); - if (!ff) - return {}; - Emboss::FontFileWithCache fwc(std::move(ff)); - if (!fwc.has_value()) + return text_to_regions(utf8, size_mm, + std::shared_ptr(Emboss::create_font_file(path.c_str()))); +} + +ImportRegions text_to_regions(const std::string& utf8, double size_mm, + const std::shared_ptr& ff) +{ + if (utf8.empty() || size_mm <= 0.0 || !ff) return {}; + Emboss::FontFileWithCache fwc; + fwc.font_file = ff; + fwc.cache = std::make_shared(); FontProp prop(static_cast(size_mm)); // per_glyph=false HealedExPolygons healed = Emboss::text2shapes(fwc, utf8.c_str(), prop); diff --git a/src/libslic3r/CAD/SketchImport.hpp b/src/libslic3r/CAD/SketchImport.hpp index 0873b0b861..5b720f3892 100644 --- a/src/libslic3r/CAD/SketchImport.hpp +++ b/src/libslic3r/CAD/SketchImport.hpp @@ -3,11 +3,14 @@ #include "libslic3r/Point.hpp" // Vec2d +#include #include #include namespace Slic3r { +namespace Emboss { struct FontFile; } + // A rigid imported region: contour[0] = outer loop, contour[1..] = holes; // points in plane (u,v) millimetres. The nested vector type matches // CadFeature::imported_regions exactly, so results assign directly. @@ -19,6 +22,11 @@ using ImportRegions = std::vector; // font (resources/fonts). Returns an empty vector on any failure. ImportRegions text_to_regions(const std::string& utf8, double size_mm, const std::string& font_path = std::string()); +// Same, from an already loaded font — the GUI resolves a system font (face, bold, italic) +// to one and keeps it while the user types. Returns an empty vector when the font is null or +// yields no shape. +ImportRegions text_to_regions(const std::string& utf8, double size_mm, + const std::shared_ptr& font); // Parse an SVG file's filled paths into regions (mm), centred on the origin. // `scale` multiplies the authored size (1.0 = as authored). Returns an empty diff --git a/src/libslic3r/CAD/SketchInference.cpp b/src/libslic3r/CAD/SketchInference.cpp index eca56ee5c1..d809c26dca 100644 --- a/src/libslic3r/CAD/SketchInference.cpp +++ b/src/libslic3r/CAD/SketchInference.cpp @@ -80,6 +80,18 @@ InferenceSnap infer_point_snap(const std::vector& entities, offer(InferenceSnap::Kind::Midpoint, ei, SketchPointRole::P0, Vec2d(e.center.x() + e.radius * std::cos(am), e.center.y() + e.radius * std::sin(am))); + // Nearest point on the arc itself (PointOnObject candidate), as for a line or a + // circle — the solver now takes a point on an arc's rim. + const Vec2d v = query - e.center; + const double n = v.norm(); + const double sweep = e.end_angle - e.start_angle; + if (n > 1e-9 && e.radius > 1e-9 && std::abs(sweep) > 1e-9) { + double u = (std::atan2(v.y(), v.x()) - e.start_angle) / sweep; + while (u < 0.0) u += 2.0 * M_PI / std::abs(sweep); + if (u > 0.02 && u < 0.98) + offer(InferenceSnap::Kind::OnEdge, ei, SketchPointRole::Center, + e.center + v * (e.radius / n)); + } break; } case SketchEntity::Type::Circle: { @@ -147,6 +159,10 @@ infer_relations(const std::vector& entities, int new_ei, { std::vector out; if (new_ei <= 0 || new_ei >= int(entities.size())) return out; + // Ends count as meeting at the same tolerance the wire builder welds them at; floor keeps + // exact coincidence meaningful when auto-close is switched off. + const double weld = std::max(sketch_join_tol(), 1e-7); + auto joined = [weld](const Vec2d& a, const Vec2d& b) { return (a - b).squaredNorm() <= weld * weld; }; // AT MOST ONE constraint per rule per new entity, not one per PAIR. Without this the // function is quadratic in the sketch: a drawing with 200 equal holes yields ~20000 @@ -176,10 +192,9 @@ infer_relations(const std::vector& entities, int new_ei, if (n_line && o_line) { // R1 — parallel / perpendicular, restricted to CONNECTED lines. Connection is // what keeps this from firing on every distant line that is roughly parallel. - const bool connected = (n.p0 - o.p0).squaredNorm() <= 1e-14 || - (n.p0 - o.p1).squaredNorm() <= 1e-14 || - (n.p1 - o.p0).squaredNorm() <= 1e-14 || - (n.p1 - o.p1).squaredNorm() <= 1e-14; + // "Connected" is the wire builder's weld, so what the viewport shows joined is. + const bool connected = joined(n.p0, o.p0) || joined(n.p0, o.p1) || + joined(n.p1, o.p0) || joined(n.p1, o.p1); if (!connected) continue; const double ang = unsigned_angle(n.p1 - n.p0, o.p1 - o.p0); const double par_err = std::min(ang, M_PI - ang); @@ -208,7 +223,7 @@ infer_relations(const std::vector& entities, int new_ei, if (cv.type == SketchEntity::Type::Arc) { const Vec2d ce[2] = { cv.p0, cv.p1 }; for (int m = 0; m < 2; ++m) { - if ((le[k] - ce[m]).squaredNorm() > 1e-14) continue; + if (!joined(le[k], ce[m])) continue; const Vec2d r = ce[m] - cv.center; if (r.squaredNorm() < 1e-18) continue; tangent = std::abs(unsigned_angle(ldir, r) - M_PI / 2.0) <= ang_tol_rad; @@ -216,7 +231,7 @@ infer_relations(const std::vector& entities, int new_ei, } } else { // Circle: shared point is a line endpoint on the rim. const Vec2d r = le[k] - cv.center; - if (std::abs(r.norm() - cv.radius) > 1e-7) continue; + if (std::abs(r.norm() - cv.radius) > weld) continue; if (r.squaredNorm() < 1e-18) continue; tangent = std::abs(unsigned_angle(ldir, r) - M_PI / 2.0) <= ang_tol_rad; } diff --git a/src/libslic3r/CAD/SketchInference.hpp b/src/libslic3r/CAD/SketchInference.hpp index eb30af184a..b2f51f1c0f 100644 --- a/src/libslic3r/CAD/SketchInference.hpp +++ b/src/libslic3r/CAD/SketchInference.hpp @@ -38,8 +38,12 @@ InferenceSnap infer_point_snap(const std::vector& entities, // within `ang_tol_rad` of an axis, returns Horizontal or Vertical (the constraint to // auto-emit on the committed segment); std::nullopt otherwise. Degenerate (near-zero // length) segments return nullopt. +// One angular tolerance for every "is this relation already true?" inference, so a pair of +// lines that reads as horizontal to one rule cannot read as not-quite-parallel to the other. +inline constexpr double kSketchInferAngleTol = 3.0 * M_PI / 180.0; + std::optional -infer_axis_constraint(const Vec2d& anchor, const Vec2d& tip, double ang_tol_rad = 3.0 * M_PI / 180.0); +infer_axis_constraint(const Vec2d& anchor, const Vec2d& tip, double ang_tol_rad = kSketchInferAngleTol); // Relational constraints to auto-emit for a newly drawn entity `new_ei` against the // entities already in the sketch. Pure, no GUI/GL dependencies, unit-testable. @@ -49,7 +53,7 @@ infer_axis_constraint(const Vec2d& anchor, const Vec2d& tip, double ang_tol_rad // relation that is visibly there. Returns an empty vector when nothing qualifies. std::vector infer_relations(const std::vector& entities, int new_ei, - double ang_tol_rad = 2.0 * M_PI / 180.0, + double ang_tol_rad = kSketchInferAngleTol, double len_tol_frac = 0.01); } // namespace Slic3r diff --git a/src/libslic3r/CAD/SketchSolver.cpp b/src/libslic3r/CAD/SketchSolver.cpp index aac34722cd..eaef85633a 100644 --- a/src/libslic3r/CAD/SketchSolver.cpp +++ b/src/libslic3r/CAD/SketchSolver.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include #include @@ -46,12 +47,15 @@ struct Build { { return E(Slvs_MakePoint2d(++eh, g, wp, P(g, u), P(g, v))); } // Generic constraint (entityC unused by Slvs_MakeConstraint — set it manually below). + // `other` / `other2` pick the END point (point[2]) of entity A / B instead of its start + // (point[1]) for the constraints that read an arc's endpoint (the tangencies). void C(int type, double val, Slvs_hEntity ptA, Slvs_hEntity ptB, - Slvs_hEntity eA, Slvs_hEntity eB, Slvs_hEntity eC = 0, int other = 0) + Slvs_hEntity eA, Slvs_hEntity eB, Slvs_hEntity eC = 0, int other = 0, int other2 = 0) { Slvs_Constraint c = Slvs_MakeConstraint(++ch, G_SK, type, wp, val, ptA, ptB, eA, eB); c.entityC = eC; c.other = other; + c.other2 = other2; cons.push_back(c); } }; @@ -113,9 +117,15 @@ static SketchSolveResult solve_system(std::vector& entities, case SketchEntity::Type::Circle: { s.center = b.pt2d(G_SK, e.center.x(), e.center.y()); s.p0 = s.center; // p0 mirrors centre for circles - s.rparam = b.P(G_SK, e.radius > 1e-9 ? e.radius : 1.0); - Slvs_hEntity dist = b.E(Slvs_MakeDistance(++b.eh, G_SK, b.wp, s.rparam)); - s.prim = b.E(Slvs_MakeCircle(++b.eh, G_SK, b.wp, s.center, b.normal, dist)); + // A zero-radius circle is degenerate everywhere else (the wire builder, offset and + // inference all reject it). It used to be seeded with radius 1 here and written + // back, so any unrelated solve silently turned it into a 1 mm circle. It gets no + // primitive: its centre still solves, anything needing the rim is skipped. + if (e.radius > 1e-9) { + s.rparam = b.P(G_SK, e.radius); + Slvs_hEntity dist = b.E(Slvs_MakeDistance(++b.eh, G_SK, b.wp, s.rparam)); + s.prim = b.E(Slvs_MakeCircle(++b.eh, G_SK, b.wp, s.center, b.normal, dist)); + } break; } case SketchEntity::Type::Arc: @@ -175,6 +185,20 @@ static SketchSolveResult solve_system(std::vector& entities, switch (r) { case Role::P0: return e.p0; case Role::P1: return e.p1; case Role::Center: return e.center; } return e.p0; }; + // Which side of line `li` the point (ei, r) is on, in slvs' sign convention for the + // in-workplane PT_LINE_DISTANCE: with d = point[0] - point[1] and a = point[0], the signed + // distance has the sign of d x (p - a) (pinned by the tests "a point below the line stays + // below it" and "tangent line to circle, line below it"). +1 when on the line. + auto side_of = [&](int ei, Role r, int li) -> double { + Vec2d a, bb; + if (li == kSketchRefAxisX) { a = Vec2d(1, 0); bb = Vec2d(0, 0); } + else if (li == kSketchRefAxisY) { a = Vec2d(0, 1); bb = Vec2d(0, 0); } + else if (valid(li)) { a = entities[li].p0; bb = entities[li].p1; } + else return 1.0; + const Vec2d d = a - bb, ap = coordOf(ei, r) - a; + const double cr = d.x() * ap.y() - d.y() * ap.x(); + return cr < 0.0 ? -1.0 : 1.0; + }; // A fixed reference point at (x,y) — used to pin coordinates (Fix / LockX / LockY). auto fixedRef = [&](double x, double y) -> Slvs_hEntity { return b.pt2d(G_FIXED, x, y); }; @@ -212,7 +236,7 @@ static SketchSolveResult solve_system(std::vector& entities, case CT::Collinear: ref_ok = primOf(c.ea) && primOf(c.eb); break; } - if (!ref_ok) continue; + if (!ref_ok) { out.skipped.push_back(int(&c - constraints.data())); continue; } switch (c.type) { case CT::Coincident: b.C(SLVS_C_POINTS_COINCIDENT, 0, ptOf(c.ea, c.ra), ptOf(c.eb, c.rb), 0, 0); @@ -287,9 +311,36 @@ static SketchSolveResult solve_system(std::vector& entities, case CT::Tangent: { const bool aCurve = valid(c.ea) && entities[c.ea].type != SketchEntity::Type::Line; const bool bCurve = valid(c.eb) && entities[c.eb].type != SketchEntity::Type::Line; - if (aCurve && bCurve) - b.C(SLVS_C_CURVE_CURVE_TANGENT, 0, 0, 0, primOf(c.ea), primOf(c.eb)); - else { + const bool aCircle = aCurve && entities[c.ea].type == SketchEntity::Type::Circle; + const bool bCircle = bCurve && entities[c.eb].type == SketchEntity::Type::Circle; + if (aCurve && bCurve && (aCircle || bCircle)) { + // CURVE_CURVE_TANGENT reads each curve's ENDPOINT, which a full circle does not + // have: slvs asserts and takes the process down (the same trap the circle-line + // case below documents). Two round curves are tangent exactly when their + // centres are r1 + r2 apart (outside each other) or |r1 - r2| apart (one inside + // the other); keep whichever the sketch is closer to now. Radii are captured as + // constants, with the same caveat as the circle-line case. + const SketchEntity& A = entities[c.ea]; + const SketchEntity& B = entities[c.eb]; + const double d = (A.center - B.center).norm(); + const double ext = A.radius + B.radius; + const double in = std::abs(A.radius - B.radius); + b.C(SLVS_C_PT_PT_DISTANCE, std::abs(d - ext) <= std::abs(d - in) ? ext : in, + ptOf(c.ea, Role::Center), ptOf(c.eb, Role::Center), 0, 0); + } else if (aCurve && bCurve) { + // Tangent where the two arcs MEET: pick, for each arc, the endpoint closest to + // the other arc's nearest endpoint. Always binding the start point (the old + // behaviour) made a tangency at an arc's end act on its start instead. + const SketchEntity& A = entities[c.ea]; + const SketchEntity& B = entities[c.eb]; + int oa = 0, ob = 0; double best = 1e300; + for (int i = 0; i < 2; ++i) + for (int j = 0; j < 2; ++j) { + const double dd = ((i ? A.p1 : A.p0) - (j ? B.p1 : B.p0)).squaredNorm(); + if (dd < best) { best = dd; oa = i; ob = j; } + } + b.C(SLVS_C_CURVE_CURVE_TANGENT, 0, 0, 0, primOf(c.ea), primOf(c.eb), 0, oa, ob); + } else { const int ci = aCurve ? c.ea : c.eb; // the curve const int li = aCurve ? c.eb : c.ea; // the line if (valid(ci) && entities[ci].type == SketchEntity::Type::Circle) { @@ -310,24 +361,47 @@ static SketchSolveResult solve_system(std::vector& entities, // not being changed by another constraint in the same solve; if some other // constraint drives the radius, re-solving restores tangency. Tying them // would need an auxiliary point constrained onto both circle and line. - b.C(SLVS_C_PT_LINE_DISTANCE, entities[ci].radius, + b.C(SLVS_C_PT_LINE_DISTANCE, side_of(ci, Role::Center, li) * entities[ci].radius, ptOf(ci, Role::Center), 0, primOf(li), 0); } else { - b.C(SLVS_C_ARC_LINE_TANGENT, 0, 0, 0, primOf(ci), primOf(li)); + // The arc endpoint that touches the line is the one the tangency is at. The + // constraint used to bind the START point always, so a fillet — its start on + // one leg, its end on the other — forced both legs parallel. + int other = 0; + if (valid(ci) && valid(li)) { + const SketchEntity& A = entities[ci]; + const SketchEntity& L = entities[li]; + auto seg_dist = [&](const Vec2d& p) { + const Vec2d d = L.p1 - L.p0; + const double t = d.squaredNorm() > 1e-18 + ? std::clamp((p - L.p0).dot(d) / d.squaredNorm(), 0.0, 1.0) : 0.0; + return (L.p0 + t * d - p).norm(); + }; + other = seg_dist(A.p1) < seg_dist(A.p0) ? 1 : 0; + } + b.C(SLVS_C_ARC_LINE_TANGENT, 0, 0, 0, primOf(ci), primOf(li), 0, other); } } break; } case CT::PointOnLine: + // slvs' in-workplane point-line distance is SIGNED. A negative stored value is an + // explicit side; a positive one (what the UI stores) keeps the side the point is on + // now. Passing |value| forced every point to the positive side, flipping any that + // sat on the other one across the line. if (std::abs(c.value) < 1e-9) b.C(SLVS_C_PT_ON_LINE, 0, ptOf(c.ea, c.ra), 0, primOf(c.eb), 0); else - b.C(SLVS_C_PT_LINE_DISTANCE, std::abs(c.value), ptOf(c.ea, c.ra), 0, primOf(c.eb), 0); + b.C(SLVS_C_PT_LINE_DISTANCE, + c.value < 0.0 ? c.value : side_of(c.ea, c.ra, c.eb) * c.value, + ptOf(c.ea, c.ra), 0, primOf(c.eb), 0); break; case CT::PointOnObject: - // Point (ea,ra) lies on entity edge eb: a circle rim -> PT_ON_CIRCLE, - // otherwise the segment line -> PT_ON_LINE. - if (valid(c.eb) && entities[c.eb].type == SketchEntity::Type::Circle) + // Point (ea,ra) lies on entity edge eb: a circle or arc rim -> PT_ON_CIRCLE (slvs + // takes both), otherwise the segment line -> PT_ON_LINE. An arc used to fall to + // PT_ON_LINE, which is not an equation about an arc at all. + if (valid(c.eb) && (entities[c.eb].type == SketchEntity::Type::Circle || + entities[c.eb].type == SketchEntity::Type::Arc)) b.C(SLVS_C_PT_ON_CIRCLE, 0, ptOf(c.ea, c.ra), 0, primOf(c.eb), 0); else b.C(SLVS_C_PT_ON_LINE, 0, ptOf(c.ea, c.ra), 0, primOf(c.eb), 0); @@ -438,6 +512,29 @@ static SketchSolveResult solve_system(std::vector& entities, e.start_angle = ns; e.end_angle = ns + sweep; e.radius = 0.5 * ((e.p0 - e.center).norm() + (e.p1 - e.center).norm()); + } else if (e.type == SketchEntity::Type::EllipseArc && s.center && e.radius > 1e-9 && e.rminor > 1e-9) { + // The solver moves the centre and the two ends as free points (it has no conic), so + // after a solve they need not agree with the stored angles, and the wire builder then + // finds the arc's ends away from its vertices and drops the whole sketch. Re-derive + // the parametric angles from the solved ends and put the ends back ON the ellipse. + const double cr = std::cos(e.rotation), sr = std::sin(e.rotation); + auto param = [&](const Vec2d& p) { + const Vec2d d = p - e.center; + const double x = d.x() * cr + d.y() * sr, y = -d.x() * sr + d.y() * cr; + return std::atan2(y / e.rminor, x / e.radius); + }; + auto at = [&](double t) { + const double x = e.radius * std::cos(t), y = e.rminor * std::sin(t); + return Vec2d(e.center.x() + x * cr - y * sr, e.center.y() + x * sr + y * cr); + }; + const double old_sweep = e.end_angle - e.start_angle; + double t0 = param(e.p0), t1 = param(e.p1); + if (old_sweep >= 0.0) { while (t1 <= t0) t1 += 2.0 * M_PI; } + else { while (t1 >= t0) t1 -= 2.0 * M_PI; } + e.start_angle = t0; + e.end_angle = t1; + e.p0 = at(t0); + e.p1 = at(t1); } } @@ -465,6 +562,21 @@ static SketchSolveResult solve_system(std::vector& entities, // that fits today keeps its exact current behaviour, including its reported degrees of freedom. // A genuinely over-constrained sketch still fails: the conflict lives inside one component and // that component still rejects it. +// Degrees of freedom an entity has on its own, as the whole-system solve counts them. +static int natural_dof(const SketchEntity& e) +{ + switch (e.type) { + case SketchEntity::Type::Line: return 4; + case SketchEntity::Type::Point: return 2; + case SketchEntity::Type::Circle: return e.radius > 1e-9 ? 3 : 2; + case SketchEntity::Type::Arc: return 5; // centre + two ends, ends equidistant + case SketchEntity::Type::Ellipse: return 2; // only the centre is a solver point + case SketchEntity::Type::EllipseArc: return 6; // centre + two ends + case SketchEntity::Type::BSpline: return 2 * int(e.ctrl.size()); + } + return 0; +} + static SketchSolveResult solve_partitioned(std::vector& entities, const std::vector& constraints, int dragged_ei, Role dragged_role) @@ -483,12 +595,18 @@ static SketchSolveResult solve_partitioned(std::vector& entities, }; for (const auto& c : constraints) { unite(c.ea, c.eb); unite(c.ea, c.ec); } - // Group the constraints by the component they belong to. + // Group the constraints by the component they belong to: that of the first ENTITY they + // reference. ea can be a sketch reference (origin / axis, negative) while eb is the entity, + // and such a constraint was dropped outright. std::map> groups; + std::vector constrained(n, false); for (size_t i = 0; i < constraints.size(); ++i) { - const int a = constraints[i].ea; - if (a < 0 || a >= n) continue; + const SketchEntityConstraintDef& c = constraints[i]; + const int a = (c.ea >= 0 && c.ea < n) ? c.ea : (c.eb >= 0 && c.eb < n) ? c.eb + : (c.ec >= 0 && c.ec < n) ? c.ec : -1; + if (a < 0) continue; groups[find(a)].push_back(int(i)); + for (int e : { c.ea, c.eb, c.ec }) if (e >= 0 && e < n) constrained[e] = true; } SketchSolveResult out; @@ -514,7 +632,8 @@ static SketchSolveResult solve_partitioned(std::vector& entities, subc.reserve(cidx.size()); for (int ci : cidx) { SketchEntityConstraintDef d = constraints[ci]; - auto map1 = [&](int& e) { e = (e >= 0 && local.count(e)) ? local[e] : -1; }; + // Sketch references (origin, axes: negative sentinels) are global and pass through. + auto map1 = [&](int& e) { if (e >= 0) e = local.count(e) ? local[e] : -1; }; map1(d.ea); map1(d.eb); map1(d.ec); subc.push_back(d); } @@ -527,8 +646,14 @@ static SketchSolveResult solve_partitioned(std::vector& entities, if (bi >= 0 && bi < int(cidx.size())) out.bad.push_back(cidx[bi]); } if (r.dof > 0) out.dof += r.dof; + for (int si : r.skipped) + if (si >= 0 && si < int(cidx.size())) out.skipped.push_back(cidx[si]); solved.emplace_back(std::move(ents), std::move(sub)); } + // Entities no constraint touches are in no group but still have their freedoms; the + // whole-system solve counts them, so this path must too or the two report different dof. + for (int i = 0; i < n; ++i) + if (!constrained[i]) out.dof += natural_dof(entities[i]); if (!out.ok) return out; for (auto& [ents, sub] : solved) for (size_t k = 0; k < ents.size(); ++k) entities[ents[k]] = sub[k]; diff --git a/src/libslic3r/CAD/SketchSolver.hpp b/src/libslic3r/CAD/SketchSolver.hpp index 1898643568..0c30df40bf 100644 --- a/src/libslic3r/CAD/SketchSolver.hpp +++ b/src/libslic3r/CAD/SketchSolver.hpp @@ -19,6 +19,10 @@ struct SketchSolveResult { int dof{-1}; // remaining degrees of freedom (>0 under-constrained) int result{0}; // raw SLVS_RESULT_* code std::vector bad; // indices (into `constraints`) of conflicting constraints + // Indices of constraints that were NOT applied because an entity they reference has no + // solver representation for the role they need (an ellipse rim, a spline curve, a + // zero-radius circle...). A solve can be ok with some skipped; callers should say so. + std::vector skipped; }; // Solve `constraints` over `entities` in place (writes solved coordinates back into the diff --git a/src/libslic3r/CAD/ThreadStandards.hpp b/src/libslic3r/CAD/ThreadStandards.hpp index e9bfcc314f..c03c2033d8 100644 --- a/src/libslic3r/CAD/ThreadStandards.hpp +++ b/src/libslic3r/CAD/ThreadStandards.hpp @@ -24,6 +24,8 @@ struct ThreadSpec { double thread_depth_mm() const { return 0.6134 * pitch_mm; } // Internal/tapped minor (tap-drill) diameter for the same nominal thread. double minor_diameter_mm() const { return major_diameter_mm - 1.0825 * pitch_mm; } + // Radial depth of the internal (tapped) thread, minor to major: (D - D1) / 2. + double internal_depth_mm() const { return 0.5 * (major_diameter_mm - minor_diameter_mm()); } bool imperial() const { return series == Series::UNC || series == Series::UNF; } }; diff --git a/src/libslic3r/Format/bbs_3mf.cpp b/src/libslic3r/Format/bbs_3mf.cpp index 6d5d6a1187..e555e6ebe5 100644 --- a/src/libslic3r/Format/bbs_3mf.cpp +++ b/src/libslic3r/Format/bbs_3mf.cpp @@ -2016,7 +2016,14 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) else if (boost::algorithm::iequals(name, ORCA_CAD_RECIPE_FILE) || boost::algorithm::iequals(name, LEGACY_CAD_RECIPE_FILE)) { // Restore the editable CAD recipe (optional; absent in non-CAD projects). - if (stat.m_uncomp_size > 0) { + // The current name wins over the legacy one whichever the archive lists + // first, and the size the archive claims is capped before it is allocated. + constexpr mz_uint64 kMaxCadRecipe = mz_uint64(1) << 30; // 1 GiB + const bool legacy = boost::algorithm::iequals(name, LEGACY_CAD_RECIPE_FILE); + if (stat.m_uncomp_size > kMaxCadRecipe) { + BOOST_LOG_TRIVIAL(error) << "3MF: CAD recipe of " << stat.m_uncomp_size + << " bytes exceeds the limit; not loaded"; + } else if (stat.m_uncomp_size > 0 && !(legacy && !model.cad_recipe.empty())) { std::string buf((size_t)stat.m_uncomp_size, '\0'); if (mz_zip_reader_extract_to_mem(&archive, stat.m_file_index, buf.data(), buf.size(), 0)) model.cad_recipe = std::move(buf); diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index 33adcee65e..e7839ad194 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -147,6 +147,8 @@ set(SLIC3R_GUI_SOURCES GUI/WebPanel.hpp GUI/Widgets/WebHosting.cpp GUI/Widgets/WebHosting.hpp + GUI/AuiMgr.cpp + GUI/AuiMgr.hpp GUI/AuiPaneLayout.cpp GUI/AuiPaneLayout.hpp GUI/DragCanvas.cpp @@ -833,6 +835,10 @@ if (SLIC3R_CAD) list(APPEND SLIC3R_GUI_SOURCES GUI/CAD/DesignPanel.cpp GUI/CAD/DesignPanel.hpp + GUI/CAD/DesignRowList.cpp + GUI/CAD/DesignRowList.hpp + GUI/CAD/DesignTextDialog.cpp + GUI/CAD/DesignTextDialog.hpp GUI/CAD/DesignCanvas.cpp GUI/CAD/DesignCanvas.hpp GUI/CAD/DesignSketchTool.cpp diff --git a/src/slic3r/GUI/AuiMgr.cpp b/src/slic3r/GUI/AuiMgr.cpp new file mode 100644 index 0000000000..c4353a95f6 --- /dev/null +++ b/src/slic3r/GUI/AuiMgr.cpp @@ -0,0 +1,84 @@ +#include "AuiMgr.hpp" + +#include "GUI_App.hpp" +#ifdef __WXGTK__ +#include "LinuxDisplayBackend.hpp" +#endif + +#include +#include +#include +#include +#include +#include + +namespace Slic3r { namespace GUI { + +namespace { + +// TODO: listen on dark ui change +class FloatFrame : public wxAuiFloatingFrame +{ +public: + FloatFrame(wxWindow* parent, wxAuiManager* ownerMgr, const wxAuiPaneInfo& pane) : wxAuiFloatingFrame(parent, ownerMgr, pane) + { + wxGetApp().UpdateFrameDarkUI(this); + } +}; + +} // namespace + +void AuiMgr::init(wxWindow* window) +{ + SetManagedWindow(window); + SetDockSizeConstraint(1, 1); +#ifdef __WXGTK__ + // Floating and its docking hints need global pointer positions and window moves, which Wayland does not provide. + if (is_running_on_wayland()) + SetFlags(GetFlags() & ~wxAUI_MGR_ALLOW_FLOATING); +#endif + GetArtProvider()->SetMetric(wxAUI_DOCKART_CAPTION_SIZE, 18); + GetArtProvider()->SetMetric(wxAUI_DOCKART_GRADIENT_TYPE, wxAUI_GRADIENT_NONE); + apply_color_mode(); +} + +void AuiMgr::apply_color_mode() +{ + const bool is_dark = wxGetApp().dark_mode(); + const wxColour sash_color = is_dark ? wxColour(38, 46, 48) : wxColour(206, 206, 206); + GetArtProvider()->SetColour(wxAUI_DOCKART_INACTIVE_CAPTION_COLOUR, sash_color); + GetArtProvider()->SetColour(wxAUI_DOCKART_INACTIVE_CAPTION_TEXT_COLOUR, *wxWHITE); + GetArtProvider()->SetColour(wxAUI_DOCKART_SASH_COLOUR, sash_color); + GetArtProvider()->SetColour(wxAUI_DOCKART_BORDER_COLOUR, is_dark ? *wxBLACK : wxColour(165, 165, 165)); +} + +void AuiMgr::track_docked_size(wxWindow* window) +{ + window->Bind(wxEVT_IDLE, [this, window](wxIdleEvent& evt) { + wxAuiPaneInfo& pane = GetPane(window); + if (pane.IsOk() && pane.IsShown() && pane.IsDocked() && pane.rect.GetWidth() > 0 && pane.rect.GetHeight() > 0) { + const bool horizontal = pane.dock_direction == wxAUI_DOCK_TOP || pane.dock_direction == wxAUI_DOCK_BOTTOM; + pane.BestSize(horizontal ? pane.best_size.GetWidth() : pane.rect.GetWidth(), + horizontal ? pane.rect.GetHeight() : pane.best_size.GetHeight()); + } + evt.Skip(); + }); +} + +wxAuiPaneInfo AuiMgr::sidebar_pane_info() +{ + return wxAuiPaneInfo() + .Name("sidebar") + .Left() + .CloseButton(false) + .TopDockable(false) + .BottomDockable(false) + .BestSize(wxSize(39 * wxGetApp().em_unit(), 90 * wxGetApp().em_unit())); +} + +wxAuiFloatingFrame* AuiMgr::CreateFloatingFrame(wxWindow* parent, const wxAuiPaneInfo& pane) +{ + return new FloatFrame(parent, this, pane); +} + +}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/AuiMgr.hpp b/src/slic3r/GUI/AuiMgr.hpp new file mode 100644 index 0000000000..57b3950b93 --- /dev/null +++ b/src/slic3r/GUI/AuiMgr.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include + +namespace Slic3r { namespace GUI { + +// The wxAuiManager behind Orca's docked sidebars: Orca's caption and sash art in both themes, +// floating frames that follow the app theme, and no floating on Wayland. +class AuiMgr : public wxAuiManager +{ +public: + // Manages `window`, whose own wxEVT_SIZE handlers must Skip(). + void init(wxWindow* window); + // Captions, sashes and borders in the current theme. + void apply_color_mode(); + // Keeps the docked size of `window`'s pane in its best_size, which re-docking and a saved layout + // use: wxAUI does not record a dragged sash there. + void track_docked_size(wxWindow* window); + + // A tab's sidebar pane: docked left by default, movable to the right or floating, no close button. + static wxAuiPaneInfo sidebar_pane_info(); + + wxAuiFloatingFrame* CreateFloatingFrame(wxWindow* parent, const wxAuiPaneInfo& pane) override; +}; + +}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/BBLTopbar.cpp b/src/slic3r/GUI/BBLTopbar.cpp index 581c4b3523..40f6457ae4 100644 --- a/src/slic3r/GUI/BBLTopbar.cpp +++ b/src/slic3r/GUI/BBLTopbar.cpp @@ -24,6 +24,9 @@ #include "wxExtensions.hpp" #include "Plater.hpp" #include "MainFrame.hpp" +#ifdef SLIC3R_CAD +#include "CAD/DesignPanel.hpp" +#endif #include "WebViewDialog.hpp" #include "PartPlate.hpp" @@ -425,6 +428,9 @@ void BBLTopbar::OnSaveProject(wxAuiToolBarEvent& event) void BBLTopbar::OnUndo(wxAuiToolBarEvent& event) { MainFrame* main_frame = dynamic_cast(m_frame); +#ifdef SLIC3R_CAD + if (DesignPanel* design = main_frame->shown_design_panel()) { design->menu_undo_redo(false); return; } +#endif Plater* plater = main_frame->plater(); plater->undo(); } @@ -432,6 +438,9 @@ void BBLTopbar::OnUndo(wxAuiToolBarEvent& event) void BBLTopbar::OnRedo(wxAuiToolBarEvent& event) { MainFrame* main_frame = dynamic_cast(m_frame); +#ifdef SLIC3R_CAD + if (DesignPanel* design = main_frame->shown_design_panel()) { design->menu_undo_redo(true); return; } +#endif Plater* plater = main_frame->plater(); plater->redo(); } @@ -444,6 +453,13 @@ void BBLTopbar::EnableUndoRedoItems() Refresh(); } +void BBLTopbar::EnableUndoRedo(bool undo, bool redo) +{ + this->EnableTool(m_undo_item->GetId(), undo); + this->EnableTool(m_redo_item->GetId(), redo); + Refresh(); +} + void BBLTopbar::DisableUndoRedoItems() { this->EnableTool(m_undo_item->GetId(), false); diff --git a/src/slic3r/GUI/BBLTopbar.hpp b/src/slic3r/GUI/BBLTopbar.hpp index d84d922c72..3d71a56e4c 100644 --- a/src/slic3r/GUI/BBLTopbar.hpp +++ b/src/slic3r/GUI/BBLTopbar.hpp @@ -77,6 +77,7 @@ public: void EnableUndoRedoItems(); void DisableUndoRedoItems(); + void EnableUndoRedo(bool undo, bool redo); void SaveNormalRect(); diff --git a/src/slic3r/GUI/CAD/DesignCanvas.cpp b/src/slic3r/GUI/CAD/DesignCanvas.cpp index 03fa10cfeb..0e7296ccd4 100644 --- a/src/slic3r/GUI/CAD/DesignCanvas.cpp +++ b/src/slic3r/GUI/CAD/DesignCanvas.cpp @@ -8,6 +8,9 @@ #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/ImGuiWrapper.hpp" +#include "slic3r/GUI/GLToolbar.hpp" +#include "slic3r/GUI/Event.hpp" #include "libslic3r/Model.hpp" #include "libslic3r/TriangleMesh.hpp" #include "slic3r/GUI/3DScene.hpp" @@ -15,9 +18,11 @@ #include "libslic3r/Config.hpp" #include #include +#include #include #include +#include #include #include #include @@ -34,6 +39,7 @@ #include "libslic3r/BoundingBox.hpp" #include #include +#include #include // wxGetLocalTimeMillis: the right-click vs right-hold budget #include #include @@ -44,6 +50,11 @@ #include #include "libslic3r/PrintConfig.hpp" +#if defined(__WXMSW__) && wxUSE_POPUPWIN +#include +extern wxPopupWindow* wxCurrentPopupWindow; +#endif + namespace Slic3r { namespace GUI { @@ -66,12 +77,7 @@ DesignCanvas::DesignCanvas(wxWindow* parent) // cannot be null. Borrow the plater's, as the editor canvases do. m_canvas->set_process(&wxGetApp().plater()->background_process()); m_canvas->set_type(GLCanvas3D::ECanvasType::CanvasView3D); - - // CAD navigation, this canvas only: left-drag sweeps a selection rubber band, so orbit - // moves to middle-drag and pan to right-drag. Design is a different modality from - // Prepare/Preview and every CAD the user already knows maps the mouse this way; the other - // tabs are untouched. - m_canvas->set_cad_navigation(true); + m_canvas->set_studio_lighting(true); // see GLCanvas3D::m_studio_lighting and phong.fs m_canvas->enable_picking(false); // viewport face/edge picking is custom (TODO) m_canvas->enable_moving(false); @@ -84,6 +90,7 @@ DesignCanvas::DesignCanvas(wxWindow* parent) m_canvas->enable_collapse_toolbar(false); 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_sketch_tool(&m_sketch_tool); @@ -155,6 +162,8 @@ DesignCanvas::DesignCanvas(wxWindow* parent) [this, commit](double v) { m_sketch_tool.set_inline_busy(false); if (commit) commit(v); + // A refused value re-opens the same field (SketchInlineEditor::refuse). + if (m_inline_editor && m_inline_editor->is_open()) m_sketch_tool.set_inline_busy(true); request_repaint(); }, [this, cancel]() { @@ -172,76 +181,11 @@ DesignCanvas::DesignCanvas(wxWindow* parent) if (m_inline_editor) m_inline_editor->commit(); }; - // Bottom-right viewport HUD: a borderless, non-focusable float label showing the active - // tool's current values. Top-level (a child widget is hidden by the GL surface, same as - // the inline editor). Fed every frame by the tool's on_readout; empty text hides it. - // NON-FOCUSABLE IS THE LOAD-BEARING WORD, and a wxFrame is not: see the header. The chip - // outlives the gesture that drew it, and while it held the X input focus every sketch - // shortcut was swallowed until the user clicked the canvas. Same window class as the status - // chip below for the same reason. Do not "simplify" it back to a wxFrame. - { - wxWindow* top = wxGetTopLevelParent(m_canvas_widget); - m_hud = new wxPopupWindow(top, wxBORDER_NONE); - m_hud->SetBackgroundColour(wxColour(28, 30, 34)); - m_hud_label = new wxStaticText(m_hud, wxID_ANY, wxEmptyString); - m_hud_label->SetForegroundColour(wxColour(0x46, 0xE0, 0xC8)); // teal, reads on dark bed - wxFont f = m_hud_label->GetFont(); f.MakeBold(); m_hud_label->SetFont(f); - auto* hs = new wxBoxSizer(wxHORIZONTAL); - hs->Add(m_hud_label, 0, wxALL, 6); - m_hud->SetSizerAndFit(hs); - m_hud->Hide(); - } - m_sketch_tool.on_readout = [this](const std::string& s) { set_readout(s); }; - - // Bottom-LEFT twin, carrying the status line. Top-level for the same reason as the readout - // (a child widget is hidden by the GL surface) but a wxPopupWindow rather than a wxFrame, - // because a popup cannot take keyboard focus. The readout gets away with a frame only - // because it appears mid-gesture and the next input is the mouse; this one is up - // permanently and is re-raised on every status change. As a frame it took the WM's focus - // each time and the canvas stopped receiving keys at all — every sketch shortcut silently - // dead, which reads as a broken tool. Do not "simplify" it back to a wxFrame. - // Its colour is set per message — the panel decides whether a line is neutral or an error. - { - wxWindow* top = wxGetTopLevelParent(m_canvas_widget); - m_status_hud = new wxPopupWindow(top, wxBORDER_NONE); - m_status_hud->SetBackgroundColour(wxColour(28, 30, 34)); - m_status_hud_label = new wxStaticText(m_status_hud, wxID_ANY, wxEmptyString); - auto* ss = new wxBoxSizer(wxHORIZONTAL); - // The line never wraps — there is a whole window's width down here — so the chip is - // ONE LINE tall. Spacers rather than a wxALL border because the two axes want - // different numbers: roomy at the sides so it reads as a label, and just enough top - // and bottom to clear the descenders. Zero vertical clips the glyphs; 6 (what the - // readout chip uses) makes it look like a two-line box. - ss->AddSpacer(10); - ss->Add(m_status_hud_label, 0, wxTOP | wxBOTTOM, 3); - ss->AddSpacer(10); - m_status_hud->SetSizerAndFit(ss); - m_status_hud->Hide(); - } - // A floating frame does not follow its parent, so the anchor has to be recomputed whenever - // the canvas changes size (that bind is below bind_event_handlers(), for the reason given - // there). The readout HUD gets away without this because it is transient; the status line is - // on screen almost permanently and would visibly detach. - // ...and it does not follow the WINDOW either. A popup is override-redirect: the window - // manager does not own it, so minimising the app leaves the chip sitting on the bare desktop - // (seen on the rig: whole screen black, chip still there), and it stacks above other - // applications rather than behind them. IsShownOnScreen does not catch this — an iconised - // frame still counts as shown — so the frame has to say so itself. Deactivating the app is - // the same case one step weaker: the chip belongs to a viewport the user is no longer - // looking at. Showing it back is safe because a popup cannot take focus, so neither event - // can be re-triggered by our own Show(). - // Members rather than lambdas so unbind_canvas_event_handlers() can Unbind them: these sit on - // a frame that OUTLIVES this canvas, and a lambda cannot be unbound. - if (wxWindow* top = wxGetTopLevelParent(m_canvas_widget)) { - top->Bind(wxEVT_ICONIZE, &DesignCanvas::on_frame_iconize, this); - top->Bind(wxEVT_ACTIVATE, &DesignCanvas::on_frame_activate, this); - // The anchor is an ABSOLUTE SCREEN position (ClientToScreen below), so moving the window - // moves the canvas out from under a chip that stays where it was. Dragging the frame by - // its title bar left the chip stranded mid-viewport until the next size, status or tab - // change happened to re-place it. Nothing on the canvas fires for a move that does not - // also resize, so it has to come from the frame. - top->Bind(wxEVT_MOVE, &DesignCanvas::on_status_hud_reanchor, this); - } + // The two viewport chips — the active tool's values bottom-right, the status line bottom-left + // — are drawn by the canvas itself, in the tool's ImGui pass: they go away with the canvas, + // the tab and the window, and never take the keyboard. + m_sketch_tool.on_readout = [this](const std::string& s) { set_readout(s); }; + m_sketch_tool.render_overlays = [this] { render_hud(); }; refresh_bed(); @@ -253,16 +197,10 @@ DesignCanvas::DesignCanvas(wxWindow* parent) // reverse order of binding, and GLCanvas3D swallows several events without skipping them — // wxEVT_RIGHT_UP and wxEVT_ENTER_WINDOW in on_mouse, and wxEVT_SIZE in on_size, which is // just `m_dirty = true;`. For those, whatever is bound LAST is the only handler that runs. - // The context menu, the focus-follows-mouse and the status-chip re-anchor all depend on - // running first, which is only true while this call stays ahead of them. + // The context menu and the focus-follows-mouse depend on running first, which is only true + // while this call stays ahead of them. m_canvas->bind_event_handlers(); - // The status-chip re-anchor promised above, bound AFTER the call so it runs first — ahead of - // it the handler never ran at all, leaving a stale anchor and wrap width after any resize - // that did not also move the frame or change the text. Its e.Skip() is load-bearing the - // other way: it falls through to on_size, which is what still marks the canvas dirty. - m_canvas_widget->Bind(wxEVT_SIZE, &DesignCanvas::on_status_hud_reanchor, this); - // The Design GL canvas only receives key events (Esc to exit/enter Select, Ctrl+Z undo) // while it holds keyboard focus. Clicking a side-panel button steals focus, after which // Esc/Ctrl+Z silently do nothing until the viewport is clicked again. Restore focus @@ -271,7 +209,14 @@ DesignCanvas::DesignCanvas(wxWindow* parent) // …but NOT while an inline value field is open: the field floats over the canvas, so // the smallest pointer jiggle re-enters the viewport and would yank focus off the // field (the "no cursor focus on the number, click to focus" bug). - if (m_canvas_widget && !m_sketch_tool.inline_busy()) m_canvas_widget->SetFocus(); + bool take_focus = m_canvas_widget && !m_sketch_tool.inline_busy(); +#if defined(__WXMSW__) && wxUSE_POPUPWIN + // …nor while a popup is open. A ribbon dropdown opens over the viewport, and the pointer + // crosses the viewport on its way to it: taking focus then reactivates the frame, which + // on MSW dismisses the popup under the pointer. GLCanvas3D::on_mouse has the same guard. + take_focus = take_focus && !wxCurrentPopupWindow; +#endif + if (take_focus) m_canvas_widget->SetFocus(); e.Skip(); }); @@ -285,18 +230,6 @@ DesignCanvas::DesignCanvas(wxWindow* parent) // Both are idempotent, and neither destroys anything: the destructor still owns that. void DesignCanvas::unbind_canvas_event_handlers() { - if (wxWindow* top = wxGetTopLevelParent(m_canvas_widget)) { - top->Unbind(wxEVT_ICONIZE, &DesignCanvas::on_frame_iconize, this); - top->Unbind(wxEVT_ACTIVATE, &DesignCanvas::on_frame_activate, this); - top->Unbind(wxEVT_MOVE, &DesignCanvas::on_status_hud_reanchor, this); - } - // Before the popups go down, or a resize still in flight re-places and re-shows the chip. - if (m_canvas_widget) - m_canvas_widget->Unbind(wxEVT_SIZE, &DesignCanvas::on_status_hud_reanchor, this); - // A popup is override-redirect: it does not go down with the frame, so one left showing sits - // on the bare desktop for however long the teardown takes. - show_status_hud(false); - if (m_hud) m_hud->Hide(); if (m_canvas) m_canvas->unbind_event_handlers(); } @@ -307,18 +240,19 @@ void DesignCanvas::reset_canvas_volumes() DesignCanvas::~DesignCanvas() { - if (m_hud) m_hud->Destroy(); delete m_canvas; delete m_canvas_widget; } // Distinct per-body colours (Onshape-style). Body 0 keeps the familiar gold; the rest -// cycle through a small saturated palette so coexisting solids read as separate parts. +// cycle through a small saturated palette so coexisting solids read as separate parts. No entry +// may sit near the selection cyan (design_selection_color): a blue or teal body would look +// selected, and its selected faces would barely stand out. static ColorRGBA body_palette(int body_idx) { static const ColorRGBA kPalette[] = { ColorRGBA(0.86f, 0.66f, 0.20f, 1.0f), // gold - ColorRGBA(0.30f, 0.62f, 0.90f, 1.0f), // blue + ColorRGBA(0.84f, 0.42f, 0.66f, 1.0f), // rose ColorRGBA(0.45f, 0.78f, 0.42f, 1.0f), // green ColorRGBA(0.86f, 0.45f, 0.40f, 1.0f), // coral ColorRGBA(0.70f, 0.52f, 0.86f, 1.0f), // violet @@ -363,14 +297,22 @@ void DesignCanvas::request_repaint() } } +// ImGui's display size is shared by every canvas and only refreshed when a canvas sees its own +// size change, so the canvas taking over must re-announce its size (Plater does the same between +// Prepare and Preview). Otherwise the overlays anchored to it, the FPS counter first, are laid +// out for the other canvas. void DesignCanvas::enter_viewport() { if (!m_camera_swapped) swap_camera(); + if (m_canvas) { m_canvas->reset_old_size(); m_canvas->set_as_dirty(); } } void DesignCanvas::leave_viewport() { if (m_camera_swapped) swap_camera(); + if (Plater* plater = wxGetApp().plater()) + if (GLCanvas3D* editor = plater->get_current_canvas3D()) + editor->reset_old_size(); } void DesignCanvas::swap_camera() @@ -410,33 +352,25 @@ void DesignCanvas::reload(bool keep_view) for (int i = 0; i < (int)m_model.objects.size(); ++i) m_canvas->load_object(m_model, i); - const ColorRGBA sel_gold = design_selection_color(); // same colour as every other selection const ColorRGBA ghost(0.26f, 0.66f, 1.0f, 0.45f); const auto& volumes = m_canvas->get_volumes().volumes; for (auto* v : volumes) { int obj_idx = v->object_idx(); if (obj_idx == 0) { - // Object 0 holds one volume per body — colour each by its body index so - // multiple coexisting solids are visually distinct (Onshape per-part colour). - const int b = v->volume_idx(); + // Object 0 holds the bodies (rebuild_bodies): each volume in its body's colour, so + // coexisting solids read as distinct parts (Onshape per-part colour), or in the + // selection colour when it holds the body's selected faces. + const int vi = v->volume_idx(); + const int b = vi < int(m_volumes.size()) ? m_volumes[vi].body : vi; + const bool lit = vi < int(m_volumes.size()) && m_volumes[vi].lit; bool hidden = (b >= 0 && b < int(m_body_visible.size())) && !m_body_visible[b]; // Preview-only mode (fillet/chamfer/draft, once a valid target is picked): hide // every base body so only the result ghost is on screen until Confirm. if (m_body_hidden) hidden = true; v->is_active = !hidden; // per-body visibility toggle if (!hidden) { - // An EXPLICIT colour outranks the selection tint. m_body_selected is a - // document-wide flag raised whenever a non-Sketch feature row is selected — - // the normal resting state after any modelling operation — so painting every - // body gold on it made the Color tool look broken: the override was written, - // carried across recompute and read back correctly, and then overpainted here - // every single frame. A body the user deliberately coloured keeps its colour; - // the rest still tint, which is all the tint was ever for. - const bool overridden = m_color_bodies != nullptr && b >= 0 - && b < int(m_color_bodies->size()) - && (*m_color_bodies)[b].has_color; - ColorRGBA c = (m_body_selected && !overridden) ? sel_gold : body_color(b); + ColorRGBA c = lit ? design_selection_color() : body_color(b); if (b == m_hl_body_target) c = ColorRGBA(0.30f, 0.90f, 0.70f, 1.0f); // target = teal-green else if (b == m_hl_body_tool) c = ColorRGBA(1.00f, 0.55f, 0.15f, 1.0f); // tool = orange if (m_body_translucent) c.a(0.30f); @@ -464,45 +398,23 @@ void DesignCanvas::reload(bool keep_view) m_canvas_widget->Refresh(); } -void DesignCanvas::set_mesh(const TriangleMesh& mesh) -{ - if (m_model.objects.empty()) { - auto* obj = m_model.add_object(); - obj->add_volume(mesh); - obj->add_instance(); - } else { - ModelObject* obj = m_model.objects.front(); - obj->clear_volumes(); - obj->add_volume(mesh); - if (obj->instances.empty()) - obj->add_instance(); - } - - reload(!m_first_frame); -} - -void DesignCanvas::set_bodies(const std::vector& body_meshes, +void DesignCanvas::set_bodies(const std::vector* body_meshes, const std::vector& visible) { - // Object 0 carries one GLVolume per body so reload() can colour each distinctly. - // Falls back to a single-volume object when there's only one body (identical look - // to the old set_mesh path). Picking still uses the combined mesh via set_solid_pick. + // Object 0 is built by rebuild_bodies; picking uses the combined mesh from set_solid_pick. m_body_visible = visible; // empty => all visible; reload() reads this per volume - if (body_meshes.empty()) { clear_mesh(); return; } - - ModelObject* obj = m_model.objects.empty() ? m_model.add_object() - : m_model.objects.front(); - obj->clear_volumes(); - for (const TriangleMesh& m : body_meshes) - obj->add_volume(m); - if (obj->instances.empty()) - obj->add_instance(); + if (body_meshes == nullptr || body_meshes->empty()) { clear_mesh(); return; } + m_body_meshes = body_meshes; + m_lit_faces = m_sketch_tool.selected_faces(); + rebuild_bodies(); reload(!m_first_frame); } void DesignCanvas::clear_mesh() { + m_body_meshes = nullptr; + m_volumes.clear(); if (!m_model.objects.empty()) { m_model.delete_object((size_t)0); reload(true); @@ -649,6 +561,21 @@ void DesignCanvas::set_show_bed(bool b) request_repaint(); } +void DesignCanvas::set_sidebar_collapse(std::function side, std::function toggle) +{ + if (!m_canvas || !m_canvas_widget || !setup_collapse_toolbar(m_collapse_toolbar, toggle)) + return; + m_canvas->set_collapse_toolbar(&m_collapse_toolbar, std::move(side)); + m_canvas->enable_collapse_toolbar(true); + // Shift+Tab: the canvas posts this for its sidebar, as Prepare's canvases do. + m_canvas_widget->Bind(EVT_GLCANVAS_COLLAPSE_SIDEBAR, [toggle](SimpleEvent&) { toggle(); }); +} + +void DesignCanvas::set_sidebar_collapse_tooltip(const std::string& tooltip) +{ + m_collapse_toolbar.set_tooltip(m_collapse_toolbar.get_item_id("collapse_sidebar"), tooltip); +} + bool DesignCanvas::is_sketching() const { return m_sketch_tool.is_active(); } void DesignCanvas::cancel_sketch() @@ -743,12 +670,28 @@ void DesignCanvas::clear_loop_pick() m_sketch_tool.clear_display_pick(); } +void DesignCanvas::clear_solid_pick() +{ + m_sketch_tool.clear_solid_selection(); + sync_selected_faces(); +} + void DesignCanvas::set_loop_pick(int feature, int region) { m_sketch_tool.set_display_pick(feature, region); request_repaint(); } +int DesignCanvas::loop_pick_feature() const +{ + return m_sketch_tool.display_pick(); +} + +int DesignCanvas::loop_pick_region() const +{ + return m_sketch_tool.display_pick_region(); +} + void DesignCanvas::set_escalate_on_repick(bool on) { m_sketch_tool.set_escalate_on_repick(on); @@ -760,7 +703,9 @@ void DesignCanvas::set_solid_pick(const std::vector* bodies, const Tria const std::vector* xform) { m_color_bodies = bodies; // stable address (m_doc.bodies); reload() reads colour overrides + m_tri_face = tri_face; m_sketch_tool.set_solid_pick(bodies, mesh, tri_face, tri_body, visible, xform); + sync_selected_faces(); // the tool just reset its pick } // Effective display colour for a body: per-body override (Color tool) when set, else the @@ -879,9 +824,9 @@ void DesignCanvas::set_on_shell_thickness_changed(std::function cb } void DesignCanvas::begin_revolve_gizmo(const SketchPlane& plane, const Vec2d& centroid, - int axis_sel, double angle, bool flip) + const Vec3d& axis_origin, const Vec3d& axis_dir, double angle, bool flip) { - m_sketch_tool.set_revolve_gizmo(plane, centroid, axis_sel, angle, flip); + m_sketch_tool.set_revolve_gizmo(plane, centroid, axis_origin, axis_dir, angle, flip); request_repaint(); } @@ -956,19 +901,31 @@ void DesignCanvas::set_on_pattern_changed(std::function cb) m_sketch_tool.on_pattern_changed = std::move(cb); } -void DesignCanvas::set_on_solid_selection_changed(std::function cb) +std::vector DesignCanvas::selected_solid_edges() const { - m_sketch_tool.on_solid_selection_changed = std::move(cb); + return m_sketch_tool.selected_edges(); } -void DesignCanvas::set_on_place_on_face(std::function cb) +void DesignCanvas::set_on_solid_selection_changed(std::function cb) { - m_sketch_tool.on_place_on_face = std::move(cb); + // Wrapped so every pick change in the tool also re-splits the filled faces. + m_on_solid_selection_changed = std::move(cb); + m_sketch_tool.on_solid_selection_changed = [this](int level, int body, int face, int edge) { + sync_selected_faces(); + if (m_on_solid_selection_changed) + m_on_solid_selection_changed(level, body, face, edge); + }; +} + +void DesignCanvas::set_on_empty_pick(std::function cb) +{ + m_sketch_tool.on_empty_pick = std::move(cb); } void DesignCanvas::select_body(int body) { m_sketch_tool.select_body(body); + sync_selected_faces(); request_repaint(); } @@ -1078,9 +1035,9 @@ void DesignCanvas::set_on_sketch_exit_refused(std::function cb) m_sketch_tool.on_exit_refused = std::move(cb); } -void DesignCanvas::set_on_move_exit(std::function cb) +void DesignCanvas::set_on_sketch_notice(std::function cb) { - m_sketch_tool.on_move_exit = std::move(cb); + m_sketch_tool.on_notice = std::move(cb); } void DesignCanvas::set_on_context_menu(std::function cb) @@ -1094,25 +1051,26 @@ void DesignCanvas::set_on_context_menu(std::function cb) // 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, at the same 8 px budget - // the left-click pick uses. + // the view. The offer is the release of a STATIONARY right-click (kCadRightClickDriftPx). m_canvas_widget->Bind(wxEVT_RIGHT_DOWN, [this](wxMouseEvent& e) { - m_ctx_press = e.GetPosition(); - m_ctx_press_ms = wxGetLocalTimeMillis().GetValue(); + m_ctx_press = e.GetPosition(); + m_ctx_travelled = false; e.Skip(); // the canvas still needs the press to seed the orbit }); + m_canvas_widget->Bind(wxEVT_MOTION, [this](wxMouseEvent& e) { + if (e.RightIsDown()) { + const wxPoint d = e.GetPosition() - m_ctx_press; + if (std::max(std::abs(d.x), std::abs(d.y)) > kCadRightClickDriftPx) m_ctx_travelled = true; + } + e.Skip(); + }); m_canvas_widget->Bind(wxEVT_RIGHT_UP, [this](wxMouseEvent& e) { const wxPoint d = e.GetPosition() - m_ctx_press; - const long long dt = wxGetLocalTimeMillis().GetValue() - m_ctx_press_ms; // 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? Both budgets must hold: a press that travelled orbited, and a - // press that was HELD was aiming to orbit even if the hand never quite moved. Two - // independent budgets because the two failure modes are independent — the drift one - // alone still popped a menu at the end of a slow, careful orbit. - const bool is_click = std::max(std::abs(d.x), std::abs(d.y)) <= kCadRightClickDriftPx - && dt <= kCadRightClickMs; + // Click, or navigation? A press that travelled 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) { // 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 @@ -1130,11 +1088,6 @@ void DesignCanvas::set_on_context_menu(std::function cb) }); } -void DesignCanvas::set_on_undo_redo(std::function cb) -{ - m_sketch_tool.on_undo_redo = std::move(cb); -} - void DesignCanvas::set_display_sketches(std::vector ds) { m_sketch_tool.set_display_sketches(std::move(ds)); @@ -1209,131 +1162,130 @@ double DesignCanvas::model_mid_z() const void DesignCanvas::set_readout(const std::string& text) { - if (!m_hud || !m_hud_label || !m_canvas_widget) return; - if (text == m_hud_last) return; // only touch the WM on a real change + if (text == m_hud_last) return; m_hud_last = text; - if (text.empty()) { m_hud->Hide(); return; } - m_hud_label->SetLabel(wxString::FromUTF8(text)); - place_readout_hud(); -} - -void DesignCanvas::place_readout_hud() -{ - if (!m_hud || !m_hud_label || !m_canvas_widget) return; - if (m_hud_last.empty() || !m_canvas_widget->IsShownOnScreen()) { m_hud->Hide(); return; } - m_hud->Fit(); - // Anchor to the canvas's bottom-right corner with a small margin (screen coords). - const wxSize cs = m_canvas_widget->GetClientSize(); - const wxSize hs = m_hud->GetSize(); - const wxPoint br = m_canvas_widget->ClientToScreen( - wxPoint(cs.GetWidth() - hs.GetWidth() - 12, cs.GetHeight() - hs.GetHeight() - 12)); - if (!m_hud->IsShown()) m_hud->Show(); // Show before Move (GTK ignores pre-map Move) - m_hud->Move(br); - m_hud->Raise(); -} - -// A popup is override-redirect: the window manager does not own it, so an iconised or -// deactivated app would leave the chip sitting on the bare desktop. The status chip already -// had to answer this; now that the readout is a popup too, it answers it the same way. -void DesignCanvas::show_readout_hud(bool on) -{ - if (!m_hud) return; - if (on) place_readout_hud(); - else m_hud->Hide(); + 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. -// Shared by the placement and by the wrap width, which have to agree or the chip wraps to a -// width it is then not given. -static constexpr int kStatusHudLeftInsetDip = 190; +static constexpr float kStatusHudLeftInset = 190.f; void DesignCanvas::set_status_text(const wxString& text, const wxColour& colour) { - if (!m_status_hud || !m_status_hud_label || !m_canvas_widget) return; if (text == m_status_hud_last && colour == m_status_hud_colour) return; m_status_hud_last = text; m_status_hud_colour = colour; - if (text.IsEmpty()) { m_status_hud->Hide(); return; } - m_status_hud_label->SetForegroundColour(colour); - apply_status_label(); - place_status_hud(); + request_repaint(); } -// SetLabel + Wrap + Fit, in that order and always together. Moving the status out of the panel -// removed the clipping of 8cc but not the underlying problem: the chip is a top-level -// popup that Fit()s to its text, so a long sentence simply grew past the right edge of the canvas -// and hung over the window. Wrapping to the room actually available is what makes the earlier -// promise — "a sentence can be a sentence" — true at every window width, including the charter's -// 1366 reach. Wrap() rewrites the label it is given, so it must follow a fresh SetLabel every -// time; that is the whole reason this is one function instead of three call sites. -void DesignCanvas::apply_status_label() +void DesignCanvas::render_hud() { - if (!m_status_hud || !m_status_hud_label || !m_canvas_widget) return; - m_status_hud_label->SetLabel(m_status_hud_last); - const int avail = m_canvas_widget->GetClientSize().GetWidth() - - m_canvas_widget->FromDIP(kStatusHudLeftInsetDip) - - m_canvas_widget->FromDIP(24); - if (avail > m_canvas_widget->FromDIP(120)) // a uselessly narrow canvas: leave it unwrapped - m_status_hud_label->Wrap(avail); - m_status_hud->Fit(); + if (m_hud_last.empty() && m_status_hud_last.IsEmpty()) return; + ImGuiWrapper& imgui = *wxGetApp().imgui(); + const ImVec2 ds = ImGui::GetIO().DisplaySize; + const float em = imgui.get_style_scaling(); // follows the font, so the DPI + const float margin = 12.f * em; + const int flags = ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_AlwaysAutoResize | + ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoSavedSettings | + ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoNav; + auto chip = [&](const char* id, const std::string& text, float x, float pivot_x, float wrap, + const ImVec4* colour) { + ImGuiWrapper::push_common_window_style(m_canvas->get_scale()); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8.f * em, 4.f * em)); + imgui.set_next_window_pos(x, ds.y - margin, ImGuiCond_Always, pivot_x, 1.f); + imgui.begin(std::string(id), flags); + if (wrap > 0.f) ImGui::PushTextWrapPos(wrap); + if (colour) ImGui::PushStyleColor(ImGuiCol_Text, *colour); + ImGui::TextUnformatted(text.c_str()); + if (colour) ImGui::PopStyleColor(); + if (wrap > 0.f) ImGui::PopTextWrapPos(); + imgui.end(); + ImGui::PopStyleVar(); + ImGuiWrapper::pop_common_window_style(); + }; + if (!m_status_hud_last.IsEmpty()) { + // A sentence can be a sentence: it wraps to the room left of the readout chip. + const float left = kStatusHudLeftInset * em; + 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) + : ImVec4(); + chip("##design_status", m_status_hud_last.ToUTF8().data(), left, 0.f, + std::max(ds.x * 0.6f - left, 120.f * em), m_status_hud_colour.IsOk() ? &col : nullptr); + } + if (!m_hud_last.empty()) + chip("##design_readout", m_hud_last, ds.x - margin, 1.f, 0.f, &ImGuiWrapper::COL_ORCA); } -void DesignCanvas::place_status_hud() +void DesignCanvas::set_highlight_faces(const std::vector>& faces) { - if (!m_status_hud || !m_canvas_widget || m_status_hud_last.IsEmpty()) return; - // The canvas has a client size even while its page is hidden, and it is not the size the - // page will have when shown — anchoring against it put the chip up on the tab bar, where it - // then stayed until the next status change moved it. Nothing to anchor to: stay down. - if (!m_canvas_widget->IsShownOnScreen()) { m_status_hud->Hide(); return; } - const wxSize cs = m_canvas_widget->GetClientSize(); - // Re-wrap first: this also runs on resize, and a chip wrapped for the old width either - // overhangs a narrowed canvas or wastes a widened one. - apply_status_label(); - const wxSize hs = m_status_hud->GetSize(); - const int kLeftInset = m_canvas_widget->FromDIP(kStatusHudLeftInsetDip); - const wxPoint bl = m_canvas_widget->ClientToScreen( - wxPoint(kLeftInset, cs.GetHeight() - hs.GetHeight() - 12)); - // No Raise() and no focus juggling: a popup neither takes focus nor falls behind. This was - // caught with ORCA_CAD_KEYTRACE — shift+S logged a line, the following R logged nothing, and - // the only thing between them was the first status update showing this window. - if (!m_status_hud->IsShown()) m_status_hud->Show(); // Show before Move (GTK ignores pre-map Move) - m_status_hud->Move(bl); + m_sketch_tool.set_highlight_faces(faces); + sync_selected_faces(); + request_repaint(); } -void DesignCanvas::on_frame_iconize(wxIconizeEvent& e) +void DesignCanvas::sync_selected_faces() { - show_status_hud(!e.IsIconized()); - show_readout_hud(!e.IsIconized()); - e.Skip(); + // Deferred to the end of the current event, which may change the selection several times: + // every re-split reloads all the bodies. A set_bodies in between splits by the current + // selection itself and leaves this nothing to do. + if (m_split_pending) + return; + m_split_pending = true; + CallAfter([this] { + m_split_pending = false; + std::vector> want = m_sketch_tool.selected_faces(); + if (want == m_lit_faces) + return; + m_lit_faces = std::move(want); + if (m_body_meshes == nullptr || m_model.objects.empty()) + return; + rebuild_bodies(); + reload(true); + }); } -void DesignCanvas::on_frame_activate(wxActivateEvent& e) +// Object 0: one volume per body, with its selected faces split off into a volume of their own. +// The per-triangle face ids (all bodies, in order) match triangles to faces; when their count +// does not match the meshes, nothing is split. +void DesignCanvas::rebuild_bodies() { - show_status_hud(e.GetActive()); - show_readout_hud(e.GetActive()); - e.Skip(); -} - -// wxEvent& so one handler serves both events that invalidate the anchor: the frame moving out -// from under the chip, and the canvas resizing under it. -void DesignCanvas::on_status_hud_reanchor(wxEvent& e) -{ - place_status_hud(); - e.Skip(); -} - -void DesignCanvas::show_status_hud(bool on) -{ - if (!m_status_hud) return; - if (on) place_status_hud(); // re-anchors first: the page may have been resized while away - else m_status_hud->Hide(); -} - -void DesignCanvas::set_body_highlight(bool on) -{ - if (m_body_selected == on) return; - m_body_selected = on; - reload(true); // recolours the body volume (selected = cyan tint) + ModelObject* obj = m_model.objects.empty() ? m_model.add_object() : m_model.objects.front(); + obj->clear_volumes(); + m_volumes.clear(); + const std::vector& meshes = *m_body_meshes; + size_t ntri = 0; + for (const TriangleMesh& m : meshes) + ntri += m.its.indices.size(); + const bool mapped = m_tri_face != nullptr && m_tri_face->size() == ntri; + size_t off = 0; + for (int b = 0; b < int(meshes.size()); ++b) { + const TriangleMesh& mesh = meshes[b]; + const auto first = std::lower_bound(m_lit_faces.begin(), m_lit_faces.end(), std::make_pair(b, INT_MIN)); + const auto last = std::lower_bound(first, m_lit_faces.end(), std::make_pair(b + 1, INT_MIN)); + if (!mapped || first == last) { + obj->add_volume(mesh); + m_volumes.push_back({ b, false }); + } else { + indexed_triangle_set parts[2]; // [0] the rest of the body, [1] its selected faces + for (size_t i = 0; i < mesh.its.indices.size(); ++i) { + const int f = (*m_tri_face)[off + i]; + const bool lit = std::binary_search(first, last, std::make_pair(b, f)); + parts[lit].indices.push_back(mesh.its.indices[i]); + } + for (int lit = 0; lit < 2; ++lit) { + if (parts[lit].indices.empty()) + continue; + parts[lit].vertices = mesh.its.vertices; + its_compactify_vertices(parts[lit]); + obj->add_volume(TriangleMesh(std::move(parts[lit]))); + m_volumes.push_back({ b, lit == 1 }); + } + } + off += mesh.its.indices.size(); + } + if (obj->instances.empty()) + obj->add_instance(); } void DesignCanvas::set_operand_bodies(int target_body, int tool_body) @@ -1341,7 +1293,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 (same idiom set_body_highlight uses) + reload(true); // recolours the body volumes } void DesignCanvas::set_highlight_sketches(std::vector> hl) @@ -1369,13 +1321,16 @@ 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 } -void DesignCanvas::delete_selected_sketch_entities() +bool DesignCanvas::delete_selected_sketch_entities() { + if (m_sketch_tool.selection().empty()) return false; m_sketch_tool.delete_selected(); request_repaint(); + return true; } bool DesignCanvas::inline_busy() const @@ -1414,16 +1369,16 @@ bool DesignCanvas::live_sketch_has_work() const return m_sketch_tool.live_sketch_has_work(); } -bool DesignCanvas::undo_last_sketch_entity() +bool DesignCanvas::redo_last_sketch_entity() { - const bool did = m_sketch_tool.undo_last_entity(); + const bool did = m_sketch_tool.redo_last_entity(); if (did) request_repaint(); return did; } -bool DesignCanvas::delete_selected_or_last_sketch_entity() +bool DesignCanvas::undo_last_sketch_entity() { - const bool did = m_sketch_tool.delete_selected_or_last(); + const bool did = m_sketch_tool.undo_last_entity(); if (did) request_repaint(); return did; } @@ -1470,6 +1425,7 @@ void DesignCanvas::open_inline_value(double current, std::function [this, commit](double v) { m_sketch_tool.set_inline_busy(false); if (commit) commit(v); + if (m_inline_editor && m_inline_editor->is_open()) m_sketch_tool.set_inline_busy(true); request_repaint(); }, [this, cancel]() { @@ -1580,6 +1536,13 @@ bool DesignCanvas::sketch_abort_gesture() return true; } +bool DesignCanvas::sketch_confirm_pending() +{ + if (!m_sketch_tool.confirm_pending()) return false; + request_repaint(); + return true; +} + bool DesignCanvas::sketch_disarm_tool() { if (!m_sketch_tool.disarm_tool()) return false; @@ -1589,23 +1552,25 @@ bool DesignCanvas::sketch_disarm_tool() bool DesignCanvas::drawing_in_progress() const { - return m_sketch_tool.pending_points() > 0; + return m_sketch_tool.gesture_pending(); } bool DesignCanvas::has_any_selection() const { - return m_sketch_tool.has_solid_selection() || m_sketch_tool.sketch_has_selection(); + return m_sketch_tool.has_solid_selection() || m_sketch_tool.sketch_has_selection() + || m_sketch_tool.display_pick() >= 0; } bool DesignCanvas::clear_any_selection() { if (!has_any_selection()) return false; - // Both, unconditionally: which of the two is live depends on the mode, and Esc at idle means - // "nothing is picked" in either of them. clear_selection() reports through the tool's own + // All three, unconditionally: which is live depends on the mode, and Esc at idle means + // "nothing is picked" in any of them. clear_selection() reports through the tool's own // on_selection_changed; the solid side has no such notification, so the panel refreshes what // depends on it (see DesignPanel::escape). m_sketch_tool.clear_selection(); m_sketch_tool.clear_solid_selection(); + m_sketch_tool.clear_display_pick(); // clear_solid_selection() is silent by design (recomputes call it while ids are invalid), but // the panel mirrors the pick to aim Extrude and the dress-up tools. An Esc that cleared the // highlight without telling the panel would leave those aimed at a body nothing points to. diff --git a/src/slic3r/GUI/CAD/DesignCanvas.hpp b/src/slic3r/GUI/CAD/DesignCanvas.hpp index 56d765d702..9584550980 100644 --- a/src/slic3r/GUI/CAD/DesignCanvas.hpp +++ b/src/slic3r/GUI/CAD/DesignCanvas.hpp @@ -8,7 +8,6 @@ #include #include #include -#include #include #include @@ -17,6 +16,7 @@ #include "slic3r/GUI/3DBed.hpp" #include "slic3r/GUI/Camera.hpp" +#include "slic3r/GUI/GLToolbar.hpp" #include "libslic3r/Model.hpp" #include "libslic3r/CAD/SketchEngine.hpp" #include "slic3r/GUI/CAD/DesignSketchTool.hpp" @@ -46,10 +46,10 @@ public: explicit DesignCanvas(wxWindow* parent); ~DesignCanvas() override; - void set_mesh(const TriangleMesh& mesh); // Multi-body display: one GLVolume per body, each coloured distinctly (per-body colour). - // `visible` (optional, indexed by body) hides bodies whose flag is false. - void set_bodies(const std::vector& body_meshes, + // `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. + void set_bodies(const std::vector* body_meshes, const std::vector& visible = {}); void clear_mesh(); @@ -76,7 +76,7 @@ public: // Is the sketch tool on Select (as opposed to a draw/edit tool being armed)? The // Construction box needs it to tell "convert what I picked" from "arm what I draw next". bool sketch_is_selecting() const { return m_sketch_tool.mode() == DesignSketchTool::Mode::Select; } - // Text / SVG art into the LIVE sketch, as ordinary editable lines. False = no session. + // SVG art into the LIVE sketch, as ordinary editable lines. False = no session. bool add_sketch_regions(const std::vector>>& regions); void set_sketch_polygon_sides(int n); void set_sketch_polygon_circumscribed(bool c); @@ -93,6 +93,10 @@ public: void unbind_canvas_event_handlers(); // app close / language switch, from the plater's teardown void reset_canvas_volumes(); void set_show_bed(bool b); // view option: draw the printer bed + plate grid, or not + // The sidebar's collapse button, as Prepare's: drawn on the edge `side` reports, it runs + // `toggle` on a click and on Shift+Tab. Set before the first paint. + void set_sidebar_collapse(std::function side, std::function toggle); + void set_sidebar_collapse_tooltip(const std::string& tooltip); // N: look straight down the sketch plane's normal, keeping the current zoom. A sketch drawn // at an angle is a sketch drawn wrong, and no amount of orbiting by hand lands exactly square. bool view_normal_to_sketch(); @@ -125,7 +129,12 @@ public: // consumed loop must be compared against. std::vector> region_entity_indices_with_holes(const std::vector& ents) const; void clear_loop_pick(); // drop the click-selected loop highlight (e.g. after extrude) + void clear_solid_pick(); // drop the solid pick and its highlight; no callback, no repaint void set_loop_pick(int feature, int region); // adopt a loop pick made before the commit + // The picked committed sketch (feature index, -1 = none) and its closed region (-1 also when + // the click hit a stroke on no closed loop). The panel reads the pick here and keeps no copy. + int loop_pick_feature() const; + int loop_pick_region() const; void set_escalate_on_repick(bool on); // off while a card has armed a face/edge pick // Solid whole/face/edge selection: point the tool at the bodies + concatenated // tessellation (with per-triangle face & body ids), and a callback fired on each @@ -135,7 +144,8 @@ public: const std::vector* visible = nullptr, const std::vector* xform = nullptr); void set_on_solid_selection_changed(std::function cb); - void set_on_place_on_face(std::function cb); // F key: Place on Face + void set_on_empty_pick(std::function cb); // a click or rubber band took nothing + std::vector selected_solid_edges() const; // the Shift/Ctrl+click edge set, last-clicked at the end void select_body(int body); // Parts-list -> highlight a whole body by index // Effective display colour of a body: the per-body override (Color tool) when set, // otherwise the auto body-index palette. Single source of truth shared with reload(). @@ -174,11 +184,11 @@ public: void clear_shell_gizmo(); bool shelling() const; void set_on_shell_thickness_changed(std::function cb); - // Visual Revolve angle-arc gizmo: the panel feeds the sketch plane + profile centroid + axis - // (0=plane X, 1=plane Y) + angle + flip while its Revolve card is open; drag/edit fire the - // angle callback. + // Visual Revolve angle-arc gizmo: the panel feeds the sketch plane + profile centroid + the + // world axis (a point on it, unit direction) + angle + flip while its Revolve card is open; + // drag/edit fire the angle callback. void begin_revolve_gizmo(const SketchPlane& plane, const Vec2d& centroid, - int axis_sel, double angle, bool flip); + const Vec3d& axis_origin, const Vec3d& axis_dir, double angle, bool flip); void clear_revolve_gizmo(); bool revolving() const; void set_on_revolve_angle_changed(std::function cb); @@ -226,7 +236,7 @@ public: void set_on_datum_base_picked(std::function cb); void set_on_sketch_exit(std::function cb); // Esc -> exit the tool void set_on_sketch_exit_refused(std::function cb); // Esc declined: sketch has work - void set_on_undo_redo(std::function cb); // Ctrl+Z / Ctrl+Shift+Z + void set_on_sketch_notice(std::function cb); // tool refusals/side effects // Persistently draw committed sketches (un-consumed ones stay visible). void set_display_sketches(std::vector ds); void set_highlight_sketches(std::vector> hl); @@ -235,26 +245,23 @@ public: // Mate connectors, drawn as frames so their verse and polarity are visible (wgsc). void set_mate_connectors(std::vector g); void set_mate_links(std::vector> l); - void set_body_highlight(bool on); // tint the solid when its feature is tree-selected + // Draw these (body, face id) faces as selected: the faces the Feature tree's selected feature + // made (CadDocument::faces_made_by). Empty clears them. + void set_highlight_faces(const std::vector>& faces); // The status line, shown along the BASE OF THE VIEWPORT rather than in the side panel: // the panel clips it at ~73 characters with no warning (8cc), the viewport's // bottom margin has the whole window width to spare. Empty text hides it. void set_status_text(const wxString& text, const wxColour& colour); - // Take the status line down / bring it back when the Design page leaves and re-enters view. - // A popup is a TOP-LEVEL window: hiding the page it belongs to does not hide it. Keeps the - // text, so coming back needs no re-selection. - void show_status_hud(bool on); void set_operand_bodies(int target_body, int tool_body); // -1,-1 clears void set_body_translucent(bool on); // render the solid see-through (fillet/chamfer preview) void set_xray_focus(int body); // >=0: fade+lock out every other body (CoordSys picking) void set_body_hidden(bool on); // preview-only: hide base bodies, show only the result ghost - void set_on_move_exit(std::function cb); // right-click finished the move-body gizmo // Right-click (or its platform equivalent) on the viewport with no tool running: open the // object-driven offer there. Fires with SCREEN coordinates. Deliberately NOT fired while a // tool is live — right-click already ends a polyline chain and finishes the move gizmo, and // taking those over would break two working interactions in order to add a third. void set_on_context_menu(std::function cb); - void delete_selected_sketch_entities(); + bool delete_selected_sketch_entities(); // false when nothing was selected bool inline_busy() const; // a sketch value field is open (guard keys) bool inline_has_focus() const; // the field itself holds keyboard focus void inline_commit(); // accept the typed value (Enter/Tab) @@ -264,8 +271,10 @@ public: // panel can do it when focus is not on the canvas. void request_sketch_exit(); bool live_sketch_has_work() const; // the live sketch holds entities a cancel would destroy - bool undo_last_sketch_entity(); // Ctrl+Z in a sketch: drop the last entity - bool delete_selected_or_last_sketch_entity(); // Delete in a sketch: selected, else last + bool undo_last_sketch_entity(); // Ctrl+Z in a sketch: drop the last drawn shape + bool redo_last_sketch_entity(); // Ctrl+Y in a sketch: bring it back + bool can_undo_sketch_entity() const { return m_sketch_tool.can_undo_entity(); } + bool can_redo_sketch_entity() const { return m_sketch_tool.can_redo_entity(); } void clear_sketch_selection(); // View toggles (keys P / A): origin planes, world axis triad. Each returns the new on/off @@ -312,11 +321,12 @@ public: // Esc routing (DesignInteraction.hpp). The panel decides WHICH level one press belongs to; // these are the levels it can act on inside the canvas. Each returns whether it did anything, // so the panel can fall through to the next level without asking twice. - bool sketch_abort_gesture(); // CadLevel::Gesture — drop the entity being drawn + bool sketch_abort_gesture(); // CadLevel::Gesture — drop the entity being drawn, or the tool's picks bool sketch_disarm_tool(); // CadLevel::Tool — armed sketch tool falls back to Select - bool drawing_in_progress() const;// an entity has clicks down but is not committed - bool has_any_selection() const; // model pick or sketch pick - bool clear_any_selection(); // CadLevel::Idle — drop both; true if anything was dropped + bool sketch_confirm_pending(); // Enter — apply a ready edit-op or transform + bool drawing_in_progress() const;// clicks or picks are down but nothing is committed yet + bool has_any_selection() const; // model pick, committed-loop pick or sketch pick + bool clear_any_selection(); // CadLevel::Idle — drop all three; true if anything was dropped bool sketch_first_selected_type(SketchEntity::Type& out) const; // Live sketch session (Fase 4.2 live constraint path): the panel reads the in-session // selection and entities, and commits a planned constraint through the tool's @@ -373,14 +383,30 @@ private: void reload(bool keep_view); 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 + // own, drawn opaque in the selection colour with the body's shader and lighting, so a selection + // is the same colour on every body. The faces are the sketch tool's selected_faces() (the + // Feature tree row's and the committed face or body pick), which the tool outlines. + void rebuild_bodies(); // object 0 from m_body_meshes, split by m_lit_faces + void sync_selected_faces(); // re-split and reload, once queued, if the selection changed + struct BodyVolume { int body; bool lit; }; // an object 0 volume: its body, and whether it holds selected faces + const std::vector* m_body_meshes{nullptr}; // set_bodies + const std::vector* m_tri_face{nullptr}; // per-triangle face id, all bodies in order + std::vector> m_lit_faces; // what object 0 is split by now + std::vector m_volumes; // object 0's volumes, in order + bool m_split_pending{false}; + std::function m_on_solid_selection_changed; + wxGLCanvas* m_canvas_widget{nullptr}; GLCanvas3D* m_canvas{nullptr}; int m_sw_gl{-1}; // -1 unknown, 0 hardware GL, 1 software GL + GLToolbar m_collapse_toolbar{GLToolbar::Normal, "Collapse"}; std::function m_on_context_menu; bool m_ctx_bound{false}; // bind the RIGHT_UP handler once, however often the cb is set wxPoint m_ctx_press{0, 0}; // right-press origin: a right-DRAG orbits, it must not offer - long long m_ctx_press_ms{0}; // and a right-HOLD is navigation too, however still it is held + bool m_ctx_travelled{false}; // the right press wandered past the budget at ANY point, + // so an orbit that ends where it began is still an orbit Bed3D m_bed; // The half of the camera swap above that is NOT on screen: the editor tabs' view while @@ -390,7 +416,6 @@ private: bool m_camera_swapped{false}; // guards a leave without an enter, and the reverse Model m_model; bool m_first_frame{true}; - bool m_body_selected{false}; // tree selected a body feature → tint the solid 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 @@ -408,36 +433,15 @@ private: bool m_section_on{false}; std::unique_ptr m_inline_editor; // floating in-canvas value editor - // Bottom-right viewport HUD: a borderless float label over the GL canvas showing the - // active tool's current values (fed by the tool's on_readout). Empty text hides it. - // A wxPopupWindow for the SAME reason as the status chip below, and it was a wxFrame until - // the reason was measured rather than assumed: "it appears mid-gesture and the next input is - // the mouse" is false. The chip keeps the last value on screen AFTER the gesture ends, and a - // frame holds the X input focus once it has it — so the next keystroke went to a 119x31 - // window that has no use for it. Measured on :10: focus on the chip, `r` produced no - // CHAR_HOOK line at all; one bare canvas click moved focus back and the same key armed the - // tool. That is every sketch shortcut dead after every dimensioned entity. - wxPopupWindow* m_hud{nullptr}; - wxStaticText* m_hud_label{nullptr}; + // The viewport chips, drawn in the tool's ImGui pass (render_hud): bottom-right the active + // tool's current values (fed by the tool's on_readout), bottom-left the status line written by + // DesignPanel. Empty text draws nothing. They were top-level popups once; a popup does not + // follow its frame, so it floated over other applications and outlived the tab. std::string m_hud_last; - void set_readout(const std::string& text); - void place_readout_hud(); // anchor + show, using m_hud_last - void show_readout_hud(bool on); // iconise/deactivate: a popup would float on the desktop - - // Bottom-LEFT viewport HUD: the selection / tool status line, written by DesignPanel. - // A wxPopupWindow, NOT the wxFrame the readout HUD uses: a frame accepts keyboard focus, - // and this one is on screen permanently and re-raised on every status change, so it stole - // the keyboard from the canvas and killed every sketch shortcut in the tab. - wxPopupWindow* m_status_hud{nullptr}; - wxStaticText* m_status_hud_label{nullptr}; wxString m_status_hud_last; - wxColour m_status_hud_colour; - void place_status_hud(); // re-anchors to the canvas corner (also on resize) - void apply_status_label(); // SetLabel + Wrap to the canvas width + Fit, always together - // On the top-level frame, which outlives this canvas — members so they can be unbound. - void on_frame_iconize(wxIconizeEvent& e); - void on_frame_activate(wxActivateEvent& e); - void on_status_hud_reanchor(wxEvent& e); // frame wxEVT_MOVE and canvas wxEVT_SIZE + wxColour m_status_hud_colour; // wxNullColour: the overlay's own text colour + void set_readout(const std::string& text); + void render_hud(); std::function m_on_sketch_commit; std::function&, const std::vector&, diff --git a/src/slic3r/GUI/CAD/DesignInteraction.hpp b/src/slic3r/GUI/CAD/DesignInteraction.hpp index d849b779e4..67a80bd494 100644 --- a/src/slic3r/GUI/CAD/DesignInteraction.hpp +++ b/src/slic3r/GUI/CAD/DesignInteraction.hpp @@ -48,10 +48,10 @@ static_assert(cad_escape_level({false, false, true, true}) == CadLevel::Tool, static_assert(cad_escape_level({false, false, false, true}) == CadLevel::Idle, "selection is idle-level"); static_assert(cad_escape_level({false, false, false, false}) == CadLevel::Idle, "empty is idle"); -// Right-click vs. right-hold-orbit. A press that stays put and is let go promptly is a click and -// summons the offer; anything longer or further was navigation, and navigation must never be -// rewarded with a menu over wherever the camera happened to stop. -inline constexpr int kCadRightClickMs = 200; // press->release budget +// Right-click vs. right-drag-orbit. A press that stays put summons the offer; one that travelled +// was navigation, and navigation must never be rewarded with a menu over wherever the camera +// happened to stop. Distance only, never duration: charter 6.2 rules out any gesture that +// depends on timing, and a press that did not move did not move the camera either. inline constexpr int kCadRightClickDriftPx = 3; // cursor drift budget, max(|dx|,|dy|) }} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/CAD/DesignOffer.hpp b/src/slic3r/GUI/CAD/DesignOffer.hpp index fd15f537d3..67133573a3 100644 --- a/src/slic3r/GUI/CAD/DesignOffer.hpp +++ b/src/slic3r/GUI/CAD/DesignOffer.hpp @@ -10,6 +10,10 @@ #include +#ifndef L +#define L(s) s // gettext marker, as in slic3r/GUI/I18N.hpp +#endif + namespace Slic3r { namespace GUI { // What the viewport has selected. Ordered as in tool_atlas.json; the bitmask in @@ -48,8 +52,8 @@ inline uint32_t offer_bit(OfferSel s) { return 1u << int(s); } // nullptr -> kernel support exists, no GUI path yet (row shows disabled) struct OfferVerb { const char* id; - const char* name; // drawing-office word (L10); translated at use with wxGetTranslation - int row; // 0..7, the ratified index — NEVER reorder + const char* name; // drawing-office word (L10); marked L(); translated at use + int row; // index into kOfferRowNames, the ratified address — NEVER reorder const char* key; // shortcut shown in the row, or nullptr const char* action; const char* refusal; // why this row is greyed, in the product's own words @@ -69,118 +73,133 @@ struct OfferVerb { // Row labels, in ratified order. static const char* const kOfferRowNames[] = { - "Create", - "Add material", - "Remove", - "Fillet / chamfer / draft", - "Repeat", - "Transform", - "Reference", - "Modify", + L("Create"), + L("Add material"), + L("Remove"), + L("Fillet / chamfer / draft"), + L("Repeat"), + L("Transform"), + L("Reference"), + L("Modify"), + "Top", +}; +static const int kOfferRowCount = 9; +// A flat row is not a family: its verbs come first, at the top level of the offer, each +// an item of its own. +static const bool kOfferRowFlat[] = { + false, + false, + false, + false, + false, + false, + false, + false, + true, }; -static const int kOfferRowCount = 8; static const OfferVerb kOfferVerbs[] = { - {"sketch", "Sketch", 0, "Shift+S", "key:S+S", "Click a face or a reference plane in the viewport, then a sketch tool", 0x00000403u, 0, 0, false, false, nullptr, "design_sketch", "Click a face or a reference plane, then pick a drawing tool"}, - {"extrude", "Extrude", 1, "Shift+E", "key:S+E", "Create a sketch, or pick a solid face, first", 0x00004002u, 0, 0, false, false, nullptr, "design_extrude", "Extrude a sketch profile, or push/pull a picked face"}, - {"revolve", "Revolve", 1, "Shift+R", "key:S+R", "Create a sketch profile to revolve first", 0x00004000u, 0, 0, false, false, nullptr, "design_revolve", "Revolve a profile about an axis"}, - {"sweep", "Sweep", 1, "Shift+W", "key:S+W", "Create a profile sketch to sweep first", 0x00004000u, 0, 2, false, false, nullptr, "design_sweep", "Sweep a profile along a path"}, - {"loft", "Loft", 1, "Shift+L", "key:S+L", "Create at least two profile sketches to loft", 0x00004000u, 0, 2, false, false, nullptr, "design_loft", "Loft (skin) between two or more profiles"}, - {"thicken", "Thicken", 1, nullptr, "fly:material#4", "Thicken needs a solid body — add or import one first", 0x0000000au, 1, 0, false, false, nullptr, "design_thicken", "Offset a solid face into a thin plate (new body)"}, - {"rib", "Rib", 1, nullptr, "fly:material#5", "Rib needs a solid body — add or import one first", 0x00010000u, 1, 0, false, false, nullptr, "design_rib", "Grow a thin wall from an open sketch line, fused to a body"}, - {"boolean", "Union", 1, "Shift+B", "btn:bool#0", "Boolean needs two bodies — create or import a second solid", 0x00000200u, 2, 0, false, false, nullptr, "design_boolean", "Fuse the tool body into the target — one solid, no seam"}, - {"bool_subtract", "Subtract", 1, nullptr, "btn:bool#1", "Boolean needs two bodies — create or import a second solid", 0x00000200u, 2, 0, false, false, nullptr, "design_boolean", "Cut the tool body out of the target"}, - {"bool_intersect", "Intersect", 1, nullptr, "btn:bool#2", "Boolean needs two bodies — create or import a second solid", 0x00000200u, 2, 0, false, false, nullptr, "design_boolean", "Keep only where the two bodies overlap"}, - {"surf_extrude", "Surface Extrude", 1, "Shift+G", "key:S+G", "Create a sketch first", 0x00004000u, 0, 0, false, false, nullptr, "design_extrude", "Extrude a sketch into a sheet body (no end caps)"}, - {"surf_revolve", "Surface Revolve", 1, nullptr, "fly:surface#1", "Create a sketch profile to revolve first", 0x00004000u, 0, 0, false, false, nullptr, "design_revolve", "Revolve a sketch profile into a sheet body"}, - {"surf_loft", "Surface Loft", 1, nullptr, "fly:surface#2", "Create at least two profile sketches to loft", 0x00004000u, 0, 2, false, false, nullptr, "design_loft", "Loft (skin) between 2+ profiles, open (no end caps)"}, - {"surf_fill", "Surface Fill", 1, nullptr, "fly:surface#3", "Create a closed sketch first", 0x00004000u, 0, 0, false, false, nullptr, "design_surface", "Fill a sketch boundary with a smooth face"}, - {"thicken_surf", "Thicken Surface", 1, nullptr, "fly:surface#5", "target is not a sheet body", 0x00000100u, 0, 0, true, false, nullptr, "design_thicken", "Thicken a sheet body into a solid"}, - {"hole", "Hole", 2, "Shift+H", "key:S+H", "Pick a face or a plane to drill into", 0x00000402u, 1, 0, false, false, nullptr, "design_hole", "Drill a hole, centred on a picked face or placed on a plane"}, - {"thread", "Thread", 2, "Shift+T", "key:S+T", "Pick a cylindrical surface (bore / outer) or a circular edge for a thread", 0x00000024u, 1, 0, false, false, nullptr, "design_thread", "Thread a cylindrical surface (inner bore / outer) or a circular edge"}, - {"shell", "Shell", 2, "Shift+K", "key:S+K", "Shell needs a solid body", 0x00000082u, 1, 0, false, false, nullptr, "design_shell", "Hollow the body to a wall thickness, opening a picked face"}, - {"cut", "Cut", 2, "Shift+X", "key:S+X", "Create a solid body to cut first", 0x000004feu, 1, 0, false, false, nullptr, "design_cut", "Trim the body with a plane — drag the offset arrow; keep one half or both"}, - {"split", "Split", 2, nullptr, nullptr, "Split needs a solid body", 0x000000feu, 1, 0, false, false, nullptr, nullptr, "Split the body along a picked face into two solids"}, - {"fillet", "Fillet", 3, "Shift+F", "btn:dress#0", "Pick an edge to round", 0x000000b2u, 1, 0, false, false, nullptr, "design_filletedge", "Pick an edge, then drag the radius arrow or type it"}, - {"chamfer", "Chamfer", 3, nullptr, "btn:dress#1", "Pick an edge to bevel", 0x000000b2u, 1, 0, false, false, nullptr, "design_chamfer", "Pick an edge, then drag the distance arrow or type it"}, - {"draft", "Draft", 3, "Shift+D", "key:S+D", "Pick a face to taper", 0x0000000au, 1, 0, false, false, nullptr, "design_draft", "Tilt a picked face by a draft angle"}, - {"surf_offset", "Surface Offset", 3, nullptr, "fly:surface#4", "target is not a sheet body", 0x00000100u, 0, 0, true, false, nullptr, "design_offset", "Offset a sheet body's shell by a signed distance"}, - {"pattern", "Linear pattern", 4, "Shift+N", "btn:pat#0", "Create a solid body to pattern first", 0x00006082u, 1, 0, false, false, nullptr, "design_array", "Repeat the body along a direction — drag the spacing, set the count"}, - {"pattern_circular", "Circular pattern", 4, nullptr, "btn:pat#1", "Create a solid body to pattern first", 0x00006082u, 1, 0, false, false, nullptr, "design_polararray", "Repeat the body around an axis — set the count and sweep"}, - {"mirror", "Mirror", 4, "Shift+Z", "key:S+Z", "Mirror needs a body — add or import one first", 0x000004feu, 1, 0, false, false, nullptr, "design_mirror", "Reflect a body about a plane"}, - {"pat_curve", "Pattern on Curve", 4, nullptr, nullptr, "Pattern on curve needs a body and a curve", 0x00000090u, 1, 0, false, false, nullptr, nullptr, "Repeat the body along a picked curve"}, - {"transform", "Move", 5, "Shift+Y", "key:S+Y", "Transform needs a body — add or import one first", 0x000021feu, 1, 0, false, false, nullptr, "design_move", "Move and/or rotate an existing body"}, - {"mate", "Mate", 5, nullptr, "fly:placement#2", "A mate needs two coordinate systems", 0x00001202u, 2, 0, false, false, nullptr, "design_c_coincident", "Assembly: align two CoordSys features (fastened, planar, revolute, slider, cylindrical)"}, - {"align", "Align to", 5, nullptr, nullptr, "Align needs a body", 0x00000002u, 1, 0, false, false, nullptr, nullptr, "Align the body to a picked face or plane"}, - {"plane", "Plane", 6, "Shift+P", "key:S+P", nullptr, 0x00000453u, 0, 0, false, false, nullptr, "design_plane", "Reference plane (offset / tilt / midplane / tangent / two edges / coincident)"}, - {"axis", "Axis", 6, "Shift+A", "key:S+A", nullptr, 0x00000057u, 0, 0, false, false, nullptr, "design_line", "Datum axis (two points, face normal, cylinder centerline, two planes, along edge)"}, - {"coordsys_v", "Coord Sys", 6, "Shift+C", "key:S+C", nullptr, 0x00000043u, 0, 0, false, false, nullptr, "design_point", "Datum coordinate system (world point, or face + direction edge)"}, - {"helix", "Helix", 6, nullptr, "fly:plane#3", nullptr, 0x00000405u, 0, 0, false, false, nullptr, "design_thread", "Helical curve (spring path) — use as a sweep path for coils / springs / augers"}, - {"project", "Project", 6, nullptr, "fly:plane#4", "Project needs a body — add or import one first", 0x00000482u, 1, 0, false, false, nullptr, "design_sketch", "Project body edges onto a plane as sketch entities"}, - {"measure", "Measure", 6, nullptr, nullptr, nullptr, 0x000b03feu, 0, 0, false, false, nullptr, nullptr, "Measure between the picked points, edges or faces"}, - {"mass_props", "Mass", 6, nullptr, "btn:mass", nullptr, 0x000000feu, 1, 0, false, false, nullptr, "info", "Report the volume and surface area of the selected body"}, - {"interference", "Interference", 6, nullptr, nullptr, nullptr, 0x00000200u, 2, 0, false, false, nullptr, nullptr, "Check whether two bodies overlap — reports, changes nothing"}, - {"edit_feature", "Edit", 7, nullptr, "btn:edit", nullptr, 0x00007d8eu, 0, 0, false, false, nullptr, "design_edit", "Reopen the selected feature to change what it was made from"}, - {"rename", "Rename…", 7, "F2", "btn:rename", "Select a feature, or a body, to rename it", 0x00004080u, 0, 0, false, false, nullptr, nullptr, "Give this feature a name you will recognise in the tree (a body takes its name from the feature that makes it)"}, - {"delete_face", "Delete Face", 7, nullptr, "fly:dressup#3", "Delete Face needs a body — add or import one first", 0x0000000eu, 1, 0, false, false, nullptr, "design_delete", "Remove faces from a body and heal the solid"}, - {"colour", "Colour", 7, nullptr, "btn:colour", nullptr, 0x000001feu, 1, 0, false, false, nullptr, "color_palette", "Set the selected body's display colour"}, - {"delete", "Delete", 7, "Del", "btn:delete", nullptr, 0x000f7c00u, 0, 0, false, false, nullptr, "design_delete", "Delete what is selected"}, - {"delete_body", "Delete Body", 7, nullptr, "btn:delete_body", nullptr, 0x000001feu, 1, 0, false, false, nullptr, "design_delete", "Delete this whole body — removes the feature it was made from"}, - {"sk_line_t", "Line", 0, "L", "key:L", nullptr, 0x000f8000u, 0, 0, false, true, "Line", "design_line", "Line — click start, then end"}, - {"sk_polyline", "Polyline", 0, nullptr, "fly:design_line#1", nullptr, 0x000f8000u, 0, 0, false, true, "Line", "design_polyline", "Click points; click the first point to close the loop, right-click to end it open"}, - {"sk_rect", "Corner rectangle", 0, "R", "key:R", nullptr, 0x000f8000u, 0, 0, false, true, "Rectangle", "design_rect", "Rectangle — click two opposite corners"}, - {"sk_rect_center", "Centre rectangle", 0, nullptr, "fly:design_rect#1", nullptr, 0x000f8000u, 0, 0, false, true, "Rectangle", "design_crect", "Click center, then a corner"}, - {"sk_rect_oblique", "Oblique rectangle", 0, nullptr, "fly:design_rect#2", nullptr, 0x000f8000u, 0, 0, false, true, "Rectangle", "design_rect_oblique", "Click two corners of one edge, then a point for the width"}, - {"sk_rect_rounded", "Rounded rectangle", 0, nullptr, "fly:design_rect#3", nullptr, 0x000f8000u, 0, 0, false, true, "Rectangle", "design_rect_rounded", "Click two opposite corners, then a point for the corner radius"}, - {"sk_circle", "Centre circle", 0, "C", "key:C", nullptr, 0x000f8000u, 0, 0, false, true, "Circle", "design_circle", "Circle — click center, then radius"}, - {"sk_circle_2pt", "2-point circle", 0, nullptr, "fly:design_circle#1", nullptr, 0x000f8000u, 0, 0, false, true, "Circle", "design_circle2pt", "Click two ends of the diameter"}, - {"sk_circle_3pt", "3-point circle", 0, nullptr, "fly:design_circle#2", nullptr, 0x000f8000u, 0, 0, false, true, "Circle", "design_circle3pt", "Click three points on the circle"}, - {"sk_arc_t", "3-point arc", 0, "A", "key:A", nullptr, 0x000f8000u, 0, 0, false, true, "Arc", "design_arc3pt", "Arc — click start, end, then a point"}, - {"sk_arc_tangent", "Tangent arc", 0, nullptr, "fly:design_arc3pt#1", nullptr, 0x000f8000u, 0, 0, false, true, "Arc", "design_tangentarc", "Click start (on the last entity) then end"}, - {"sk_arc_center", "Centre-point arc", 0, nullptr, "fly:design_arc3pt#2", nullptr, 0x000f8000u, 0, 0, false, true, "Arc", "design_arc_center", "Click center, then start, then a point for the end angle"}, - {"sk_slot", "Slot", 0, "S", "key:S", nullptr, 0x000f8000u, 0, 0, false, true, "Slot", "design_slot", "Slot — two centerline ends, then end radius"}, - {"sk_slot_arc", "Arc slot", 0, nullptr, "fly:design_slot#1", nullptr, 0x000f8000u, 0, 0, false, true, "Slot", "design_slot_arc", "Click center, start, end, then a point for the width"}, - {"sk_ellipse", "Ellipse", 0, "E", "key:E", nullptr, 0x000f8000u, 0, 0, false, true, "Ellipse", "design_ellipse", "Ellipse — center, major end, minor point"}, - {"sk_ellipse_arc", "Elliptical arc", 0, nullptr, "fly:design_ellipse#1", nullptr, 0x000f8000u, 0, 0, false, true, "Ellipse", "design_ellipse_arc", "Click center, major-axis end, minor point, then arc start and end"}, - {"sk_spline", "Spline", 0, "B", "key:B", nullptr, 0x000f8000u, 0, 0, false, true, nullptr, "design_bspline", "Spline — click control points"}, - {"sk_poly_3", "Triangle", 0, nullptr, "btn:poly#3", nullptr, 0x000f8000u, 0, 0, false, true, "Polygon", "design_polygon", "Triangle — click centre, then a vertex"}, - {"sk_poly_4", "Square", 0, nullptr, "btn:poly#4", nullptr, 0x000f8000u, 0, 0, false, true, "Polygon", "design_polygon", "Square — click centre, then a vertex"}, - {"sk_poly_5", "Pentagon", 0, nullptr, "btn:poly#5", nullptr, 0x000f8000u, 0, 0, false, true, "Polygon", "design_polygon", "Pentagon — click centre, then a vertex"}, - {"sk_polygon", "Hexagon", 0, "G", "btn:poly#6", nullptr, 0x000f8000u, 0, 0, false, true, "Polygon", "design_polygon", "Hexagon — click centre, then a vertex"}, - {"sk_poly_8", "Octagon", 0, nullptr, "btn:poly#8", nullptr, 0x000f8000u, 0, 0, false, true, "Polygon", "design_polygon", "Octagon — click centre, then a vertex"}, - {"sk_poly_12", "Dodecagon", 0, nullptr, "btn:poly#12", nullptr, 0x000f8000u, 0, 0, false, true, "Polygon", "design_polygon", "Dodecagon — click centre, then a vertex"}, - {"sk_poly_inscribed", "Inscribed", 0, nullptr, "btn:polyfit#0", nullptr, 0x000f8000u, 0, 0, false, true, "Polygon", "design_polygon", "Measure the polygon to its corners (inscribed)"}, - {"sk_poly_circumscribed", "Circumscribed", 0, nullptr, "btn:polyfit#1", nullptr, 0x000f8000u, 0, 0, false, true, "Polygon", "design_polygon", "Measure the polygon to its flats (circumscribed)"}, - {"sk_point_t", "Point", 0, "P", "key:P", nullptr, 0x000f8000u, 0, 0, false, true, nullptr, "design_point", "Point — click to place"}, - {"sk_text", "Text", 0, nullptr, "btn:text", nullptr, 0x000f8000u, 0, 0, false, true, nullptr, "design_text", "Type text; its outline is added to this sketch as editable lines"}, - {"sk_svg", "SVG", 0, nullptr, "btn:svg", nullptr, 0x000f8000u, 0, 0, false, true, nullptr, "design_svg", "Import an SVG outline into this sketch as editable lines"}, - {"sk_offset", "Offset", 1, "O", "key:O", nullptr, 0x000b0000u, 0, 0, false, true, nullptr, "design_offset", "Offset — pick an entity, drag the distance"}, - {"sk_trim", "Trim", 2, "T", "key:T", nullptr, 0x000b0000u, 0, 0, false, true, nullptr, "design_trim", "Trim — click a segment to trim it"}, - {"sk_fillet", "Fillet", 3, "F", "key:F", nullptr, 0x00090000u, 0, 0, false, true, nullptr, "design_filletedge", "Fillet — pick two lines, set the radius"}, - {"sk_chamfer", "Chamfer", 3, "H", "key:H", nullptr, 0x00090000u, 0, 0, false, true, nullptr, "design_chamfer", "Chamfer — pick two lines, set the distance"}, - {"sk_array", "Linear array", 4, nullptr, "fly:design_array#0", nullptr, 0x000b0000u, 0, 0, false, true, "Array", "design_array", "Pick entities, drag the spacing handle, click the count; click empty to apply"}, - {"sk_array_polar", "Polar array", 4, nullptr, "fly:design_array#1", nullptr, 0x000b0000u, 0, 0, false, true, "Array", "design_polararray", "Pick entities, drag the sweep handle, click the count; click empty to apply"}, - {"sk_mirror", "Mirror", 4, "M", "key:M", nullptr, 0x000b0000u, 0, 0, false, true, nullptr, "design_mirror", "Mirror — pick axis, then entities"}, - {"sk_move", "Move", 5, nullptr, "fly:design_move#0", nullptr, 0x000f0000u, 0, 0, false, true, "Move", "design_move", "Pick entities, then drag the handle or click the distance; click empty to apply"}, - {"sk_rotate", "Rotate", 5, nullptr, "fly:design_move#1", nullptr, 0x000f0000u, 0, 0, false, true, "Move", "design_rotate", "Pick entities, then drag around the pivot or click the angle; click empty to apply"}, - {"sk_scale", "Scale", 5, nullptr, "fly:design_move#2", nullptr, 0x000f0000u, 0, 0, false, true, "Move", "design_scale", "Pick entities, then drag the handle or click the factor; click empty to apply"}, - {"sk_dimension", "Dimension", 6, "D", "key:D", nullptr, 0x000f8000u, 0, 0, false, true, nullptr, "design_dimension", "Dimension — click 2 points or an entity"}, - {"sk_constrain", "Constrain", 6, "K", "key:K", nullptr, 0x000f0000u, 0, 0, false, true, nullptr, "design_constrain", "Constrain the selected sketch entities to each other"}, + {"sketch", L("Sketch"), 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")}, + {"extrude", L("Extrude"), 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("Revolve"), 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("Sweep"), 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")}, + {"loft", L("Loft"), 1, "Shift+L", "key:S+L", L("Create at least two profile sketches to loft"), 0x00004000u, 0, 2, false, false, nullptr, "design_loft", L("Loft (skin) between two or more profiles")}, + {"thicken", L("Thicken"), 1, nullptr, "fly:material#4", L("Thicken needs a solid body — add or import one first"), 0x0000000au, 1, 0, false, false, nullptr, "design_thicken", L("Offset a solid face into a thin plate (new body)")}, + {"rib", L("Rib"), 1, "R", "fly:material#5", L("Rib needs a solid body — add or import one first"), 0x00010000u, 1, 0, false, false, nullptr, "design_rib", L("Grow a thin wall from an open sketch line, fused to a body")}, + {"boolean", L("Join"), 1, "Shift+B", "btn:bool#0", L("Boolean needs two bodies — add or import a second one"), 0x00000200u, 2, 0, false, false, nullptr, "design_boolean", L("Fuse the tool body into the target — one solid, no seam")}, + {"bool_subtract", L("Subtract"), 1, nullptr, "btn:bool#1", L("Boolean needs two bodies — add or import a second one"), 0x00000200u, 2, 0, false, false, nullptr, "design_boolean", L("Cut the tool body out of the target")}, + {"bool_intersect", L("Intersect"), 1, nullptr, "btn:bool#2", L("Boolean needs two bodies — add or import a second one"), 0x00000200u, 2, 0, false, false, nullptr, "design_boolean", L("Keep only where the two bodies overlap")}, + {"surf_extrude", L("Surface Extrude"), 1, "Shift+G", "key:S+G", L("Create a sketch first"), 0x00004000u, 0, 0, false, false, nullptr, "design_extrude", L("Extrude a sketch into a sheet body (no end caps)")}, + {"surf_revolve", L("Surface Revolve"), 1, nullptr, "fly:surface#1", L("Create a sketch profile to revolve first"), 0x00004000u, 0, 0, false, false, nullptr, "design_revolve", L("Revolve a sketch profile into a sheet body")}, + {"surf_loft", L("Surface Loft"), 1, nullptr, "fly:surface#2", L("Create at least two profile sketches to loft"), 0x00004000u, 0, 2, false, false, nullptr, "design_loft", L("Loft (skin) between 2+ profiles, open (no end caps)")}, + {"surf_fill", L("Surface Fill"), 1, nullptr, "fly:surface#3", L("Create a closed sketch first"), 0x00004000u, 0, 0, false, false, nullptr, "design_surface", L("Fill a sketch boundary with a smooth face")}, + {"thicken_surf", L("Thicken Surface"), 1, nullptr, "fly:surface#5", L("Thicken Surface needs a surface body — make one with a Surface tool first"), 0x00000100u, 0, 0, true, false, nullptr, "design_thicken", L("Thicken a sheet body into a solid")}, + {"hole", L("Hole"), 2, "Shift+H", "key:S+H", L("Pick a face or a plane to drill into"), 0x00000402u, 1, 0, false, false, nullptr, "design_hole", L("Drill a hole, centerd on a picked face or placed on a plane")}, + {"thread", L("Thread"), 2, "Shift+T", "key:S+T", L("Pick a cylindrical surface (bore / outer) or a circular edge for a thread"), 0x00000024u, 1, 0, false, false, nullptr, "design_thread", L("Thread a cylindrical surface (inner bore / outer) or a circular edge")}, + {"shell", L("Shell"), 2, "Shift+K", "key:S+K", L("Shell needs a solid body — add or import one first"), 0x00000082u, 1, 0, false, false, nullptr, "design_shell", L("Hollow the body to a wall thickness, opening a picked face")}, + {"cut", L("Cut"), 2, "Shift+X", "key:S+X", L("Cut needs a solid body — add or import one first"), 0x000004feu, 1, 0, false, false, nullptr, "design_cut", L("Trim the body with a plane — drag the offset arrow; keep one half or both")}, + {"split", L("Split"), 2, nullptr, nullptr, L("Split needs a solid body — add or import one first"), 0x000000feu, 1, 0, false, false, nullptr, nullptr, L("Split the body along a picked face into two solids")}, + {"fillet", L("Fillet"), 3, "Shift+F", "btn:dress#0", L("Pick an edge to round"), 0x000000b2u, 1, 0, false, false, nullptr, "design_filletedge", L("Pick an edge, then drag the radius arrow or type it")}, + {"chamfer", L("Chamfer"), 3, nullptr, "btn:dress#1", L("Pick an edge to bevel"), 0x000000b2u, 1, 0, false, false, nullptr, "design_chamfer", L("Pick an edge, then drag the distance arrow or type it")}, + {"draft", L("Draft"), 3, "Shift+D", "key:S+D", L("Pick a face to taper"), 0x0000000au, 1, 0, false, false, nullptr, "design_draft", L("Tilt a picked face by a draft angle")}, + {"surf_offset", L("Surface Offset"), 3, nullptr, "fly:surface#4", L("Surface Offset needs a surface body — make one with a Surface tool first"), 0x00000100u, 0, 0, true, false, nullptr, "design_offset", L("Offset a sheet body's shell by a signed distance")}, + {"pattern", L("Linear pattern"), 4, "Shift+N", "btn:pat#0", L("Pattern needs a solid body — add or import one first"), 0x00006082u, 1, 0, false, false, nullptr, "design_array", L("Repeat the body along a direction — drag the spacing, set the count")}, + {"pattern_circular", L("Circular pattern"), 4, nullptr, "btn:pat#1", L("Pattern needs a solid body — add or import one first"), 0x00006082u, 1, 0, false, false, nullptr, "design_polararray", L("Repeat the body around an axis — set the count and sweep")}, + {"mirror", L("Mirror"), 4, "Shift+Z", "key:S+Z", L("Mirror needs a body — add or import one first"), 0x000004feu, 1, 0, false, false, nullptr, "design_mirror", L("Reflect a body about a plane")}, + {"pat_curve", L("Pattern on Curve"), 4, nullptr, nullptr, L("Pattern on Curve needs a body and a curve"), 0x00000090u, 1, 0, false, false, nullptr, nullptr, L("Repeat the body along a picked curve")}, + {"transform", L("Move"), 5, "Shift+Y", "key:S+Y", L("Transform needs a body — add or import one first"), 0x000021feu, 1, 0, false, false, nullptr, "design_move", L("Move and/or rotate an existing body")}, + {"mate", L("Mate"), 5, nullptr, "fly:placement#2", L("Mate needs two coordinate systems — create them first"), 0x00001202u, 2, 0, false, false, nullptr, "design_c_coincident", L("Assembly: align two coordinate systems (fastened, planar, revolute, slider, cylindrical)")}, + {"align", L("Align to"), 5, nullptr, nullptr, L("Align needs a body — add or import one first"), 0x00000002u, 1, 0, false, false, nullptr, nullptr, L("Align the body to a picked face or plane")}, + {"plane", L("Plane"), 6, "Shift+P", "key:S+P", nullptr, 0x00000453u, 0, 0, false, false, nullptr, "design_plane", L("Reference plane (offset / tilt / midplane / tangent / two edges / coincident)")}, + {"axis", L("Axis"), 6, "Shift+A", "key:S+A", nullptr, 0x00000057u, 0, 0, false, false, nullptr, "design_line", L("Datum axis (two points, face normal, cylinder centerline, two planes, along edge)")}, + {"coordsys_v", L("Coordinate system"), 6, "Shift+C", "key:S+C", nullptr, 0x00000043u, 0, 0, false, false, nullptr, "design_point", L("Datum coordinate system (world point, or face + direction edge)")}, + {"helix", L("Helix"), 6, nullptr, "fly:plane#3", nullptr, 0x00000405u, 0, 0, false, false, nullptr, "design_thread", L("Helical curve (spring path) — use as a sweep path for coils / springs / augers")}, + {"project", L("Project"), 6, nullptr, "fly:plane#4", L("Project needs a body — add or import one first"), 0x00000482u, 1, 0, false, false, nullptr, "design_sketch", L("Project body edges onto a plane as sketch entities")}, + {"measure", L("Measure"), 6, nullptr, nullptr, nullptr, 0x000b03feu, 0, 0, false, false, nullptr, nullptr, L("Measure between the picked points, edges or faces")}, + {"mass_props", L("Volume and area"), 6, nullptr, "btn:mass", nullptr, 0x000000feu, 1, 0, false, false, nullptr, "info", L("Report the volume and surface area of the selected body")}, + {"interference", L("Interference"), 6, nullptr, "btn:interference", L("Interference needs at least two bodies"), 0x00000280u, 2, 0, false, false, nullptr, nullptr, L("Check whether two bodies overlap — reports, changes nothing")}, + {"edit_feature", L("Edit"), 7, nullptr, "btn:edit", nullptr, 0x00007d8eu, 0, 0, false, false, nullptr, "design_edit", L("Reopen the selected feature to change what it was made from")}, + {"rename", L("Rename…"), 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("Delete Face"), 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("Color"), 8, nullptr, "btn:colour", nullptr, 0x000001feu, 1, 0, false, false, nullptr, "color_palette", L("Set the selected body's display color")}, + {"delete", L("Delete"), 7, "Del", "btn:delete", nullptr, 0x000f7c00u, 0, 0, false, false, nullptr, "design_delete", L("Delete what is selected")}, + {"delete_body", L("Delete Body"), 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("Line"), 0, "L", "key:L", nullptr, 0x000f8000u, 0, 0, false, true, L("Line"), "design_line", L("Line — click start, then end")}, + {"sk_polyline", L("Polyline"), 0, nullptr, "fly:design_line#1", nullptr, 0x000f8000u, 0, 0, false, true, L("Line"), "design_polyline", L("Click points; click the first point to close the loop, right-click to end it open")}, + {"sk_rect", L("Corner rectangle"), 0, "R", "key:R", nullptr, 0x000f8000u, 0, 0, false, true, L("Rectangle"), "design_rect", L("Rectangle — click two opposite corners")}, + {"sk_rect_center", L("Center rectangle"), 0, nullptr, "fly:design_rect#1", nullptr, 0x000f8000u, 0, 0, false, true, L("Rectangle"), "design_crect", L("Click center, then a corner")}, + {"sk_rect_oblique", L("Oblique rectangle"), 0, nullptr, "fly:design_rect#2", nullptr, 0x000f8000u, 0, 0, false, true, L("Rectangle"), "design_rect_oblique", L("Click two corners of one edge, then a point for the width")}, + {"sk_rect_rounded", L("Rounded rectangle"), 0, nullptr, "fly:design_rect#3", nullptr, 0x000f8000u, 0, 0, false, true, L("Rectangle"), "design_rect_rounded", L("Click two opposite corners, then a point for the corner radius")}, + {"sk_circle", L("Center circle"), 0, "C", "key:C", nullptr, 0x000f8000u, 0, 0, false, true, L("Circle"), "design_circle", L("Circle — click center, then radius")}, + {"sk_circle_2pt", L("2-point circle"), 0, nullptr, "fly:design_circle#1", nullptr, 0x000f8000u, 0, 0, false, true, L("Circle"), "design_circle2pt", L("Click two ends of the diameter")}, + {"sk_circle_3pt", L("3-point circle"), 0, nullptr, "fly:design_circle#2", nullptr, 0x000f8000u, 0, 0, false, true, L("Circle"), "design_circle3pt", L("Click three points on the circle")}, + {"sk_arc_t", L("3-point arc"), 0, "A", "key:A", nullptr, 0x000f8000u, 0, 0, false, true, L("Arc"), "design_arc3pt", L("Arc — click start, end, then a point")}, + {"sk_arc_tangent", L("Tangent arc"), 0, nullptr, "fly:design_arc3pt#1", nullptr, 0x000f8000u, 0, 0, false, true, L("Arc"), "design_tangentarc", L("Click start (on the last entity) then end")}, + {"sk_arc_center", L("Center-point arc"), 0, nullptr, "fly:design_arc3pt#2", nullptr, 0x000f8000u, 0, 0, false, true, L("Arc"), "design_arc_center", L("Click center, then start, then a point for the end angle")}, + {"sk_slot", L("Slot"), 0, "S", "key:S", nullptr, 0x000f8000u, 0, 0, false, true, L("Slot"), "design_slot", L("Slot — two centerline ends, then the width")}, + {"sk_slot_arc", L("Arc slot"), 0, nullptr, "fly:design_slot#1", nullptr, 0x000f8000u, 0, 0, false, true, L("Slot"), "design_slot_arc", L("Click center, start, end, then a point for the width")}, + {"sk_ellipse", L("Ellipse"), 0, "E", "key:E", nullptr, 0x000f8000u, 0, 0, false, true, L("Ellipse"), "design_ellipse", L("Ellipse — center, major end, minor point")}, + {"sk_ellipse_arc", L("Elliptical arc"), 0, nullptr, "fly:design_ellipse#1", nullptr, 0x000f8000u, 0, 0, false, true, L("Ellipse"), "design_ellipse_arc", L("Click center, major-axis end, minor point, then arc start and end")}, + {"sk_spline", L("Spline"), 0, "B", "key:B", nullptr, 0x000f8000u, 0, 0, false, true, nullptr, "design_bspline", L("Spline — click control points")}, + {"sk_poly_3", L("Triangle"), 0, nullptr, "btn:poly#3", nullptr, 0x000f8000u, 0, 0, false, true, L("Polygon"), "design_polygon", L("Triangle — click center, then a vertex")}, + {"sk_poly_4", L("Square"), 0, nullptr, "btn:poly#4", nullptr, 0x000f8000u, 0, 0, false, true, L("Polygon"), "design_polygon", L("Square — click center, then a vertex")}, + {"sk_poly_5", L("Pentagon"), 0, nullptr, "btn:poly#5", nullptr, 0x000f8000u, 0, 0, false, true, L("Polygon"), "design_polygon", L("Pentagon — click center, then a vertex")}, + {"sk_polygon", L("Hexagon"), 0, "G", "btn:poly#6", nullptr, 0x000f8000u, 0, 0, false, true, L("Polygon"), "design_polygon", L("Hexagon — click center, then a vertex")}, + {"sk_poly_8", L("Octagon"), 0, nullptr, "btn:poly#8", nullptr, 0x000f8000u, 0, 0, false, true, L("Polygon"), "design_polygon", L("Octagon — click center, then a vertex")}, + {"sk_poly_12", L("Dodecagon"), 0, nullptr, "btn:poly#12", nullptr, 0x000f8000u, 0, 0, false, true, L("Polygon"), "design_polygon", L("Dodecagon — click center, then a vertex")}, + {"sk_poly_inscribed", L("Inscribed"), 0, nullptr, "btn:polyfit#0", nullptr, 0x000f8000u, 0, 0, false, true, L("Polygon"), "design_polygon", L("Measure the polygon to its corners (inscribed)")}, + {"sk_poly_circumscribed", L("Circumscribed"), 0, nullptr, "btn:polyfit#1", nullptr, 0x000f8000u, 0, 0, false, true, L("Polygon"), "design_polygon", L("Measure the polygon to its flats (circumscribed)")}, + {"sk_point_t", L("Point"), 0, "P", "key:P", nullptr, 0x000f8000u, 0, 0, false, true, nullptr, "design_point", L("Point — click to place")}, + {"sk_text", L("Text"), 0, nullptr, "btn:text", nullptr, 0x000f8000u, 0, 0, false, true, nullptr, "design_text", L("Type text; it becomes a Text feature on this sketch's plane, editable later")}, + {"sk_svg", L("SVG"), 0, nullptr, "btn:svg", nullptr, 0x000f8000u, 0, 0, false, true, nullptr, "design_svg", L("Import an SVG outline into this sketch as editable lines")}, + {"sk_offset", L("Offset"), 1, "O", "key:O", nullptr, 0x000b0000u, 0, 0, false, true, nullptr, "design_offset", L("Offset — pick an entity, drag the distance")}, + {"sk_trim", L("Trim"), 2, "T", "key:T", nullptr, 0x000b0000u, 0, 0, false, true, nullptr, "design_trim", L("Trim — click a segment to trim it")}, + {"sk_fillet", L("Fillet"), 3, "F", "key:F", nullptr, 0x00090000u, 0, 0, false, true, nullptr, "design_filletedge", L("Fillet — pick two lines, set the radius")}, + {"sk_chamfer", L("Chamfer"), 3, "H", "key:H", nullptr, 0x00090000u, 0, 0, false, true, nullptr, "design_chamfer", L("Chamfer — pick two lines, set the distance")}, + {"sk_array", L("Linear array"), 4, nullptr, "fly:design_array#0", nullptr, 0x000b0000u, 0, 0, false, true, L("Array"), "design_array", L("Pick entities, drag the spacing handle, click the count; click empty to apply")}, + {"sk_array_polar", L("Polar array"), 4, nullptr, "fly:design_array#1", nullptr, 0x000b0000u, 0, 0, false, true, L("Array"), "design_polararray", L("Pick entities, drag the sweep handle, click the count; click empty to apply")}, + {"sk_mirror", L("Mirror"), 4, "M", "key:M", nullptr, 0x000b0000u, 0, 0, false, true, nullptr, "design_mirror", L("Mirror — pick axis, then entities")}, + {"sk_move", L("Move"), 5, nullptr, "fly:design_move#0", nullptr, 0x000f0000u, 0, 0, false, true, L("Move"), "design_move", L("Pick entities, then drag the handle or click the distance; click empty to apply")}, + {"sk_rotate", L("Rotate"), 5, nullptr, "fly:design_move#1", nullptr, 0x000f0000u, 0, 0, false, true, L("Move"), "design_rotate", L("Pick entities, then drag around the pivot or click the angle; click empty to apply")}, + {"sk_scale", L("Scale"), 5, nullptr, "fly:design_move#2", nullptr, 0x000f0000u, 0, 0, false, true, L("Move"), "design_scale", L("Pick entities, then drag the handle or click the factor; click empty to apply")}, + {"sk_dimension", L("Dimension"), 6, "D", "key:D", nullptr, 0x000f8000u, 0, 0, false, true, nullptr, "design_dimension", L("Dimension — click 2 points or an entity")}, + {"sk_constrain", L("Constrain"), 6, "K", "key:K", nullptr, 0x000f0000u, 0, 0, false, true, nullptr, "design_constrain", L("Constrain the selected sketch entities to each other")}, // Same verb, model-mode vocabulary: offered when a SKETCH is selected (bit 14, SkLoop), the // state a user is in right after finishing one. Without this row the only way in was the // toolbar icon, and constraints read as absent — see the Onshape-comparison report. - {"constrain", "Constrain sketch", 7, nullptr, "btn:constrain", "Select a sketch to constrain it", 0x00004000u, 0, 1, false, false, nullptr, "design_constrain", "Add dimensions and relations (coincident, tangent, parallel...) to the selected sketch"}, - {"sk_construct", "Construction", 6, "Q", "key:Q", nullptr, 0x000b8000u, 0, 0, false, true, nullptr, nullptr, "Toggle construction: geometry that guides but is never built"}, - {"sk_extend", "Extend", 7, "X", "key:X", nullptr, 0x000b0000u, 0, 0, false, true, nullptr, "design_extend", "Extend — click a line/arc to extend it"}, - {"sk_delete", "Delete", 7, "Del", "btn:sk_delete", nullptr, 0x000f0000u, 0, 0, false, true, nullptr, "design_delete", "Delete the selected sketch entities"}, + // It sits in the Reference row, the address the sketch-mode Constrain has: one verb, one row. + {"constrain", L("Constrain sketch"), 6, nullptr, "btn:constrain", L("Select a sketch to constrain it"), 0x00004000u, 0, 1, false, false, nullptr, "design_constrain", L("Add dimensions and relations (coincident, tangent, parallel...) to the selected sketch")}, + {"sk_construct", L("Construction"), 6, "Q", "key:Q", nullptr, 0x000b8000u, 0, 0, false, true, nullptr, nullptr, L("Toggle construction: geometry that guides but is never built")}, + {"sk_extend", L("Extend"), 7, "X", "key:X", nullptr, 0x000b0000u, 0, 0, false, true, nullptr, "design_extend", L("Extend — click a line/arc to extend it")}, + {"sk_delete", L("Delete"), 7, "Del", "btn:sk_delete", nullptr, 0x000f0000u, 0, 0, false, true, nullptr, "design_delete", L("Delete the selected sketch entities")}, // Typing the defining number of the element you pointed at. Three rows rather than one so // each names the quantity in the drawing-office word for THAT element; all three land on // the same handler, because dimension_kind() already resolves the quantity from the // selection. Without these, an element's own numbers were reachable only by arming the // Dimension tool and re-picking geometry that was already selected. - {"sk_length", "Length…", 7, "V", "key:V", nullptr, 0x00010000u, 0, 0, false, true, nullptr, "design_dimension", "Type the length of this line"}, - {"sk_radius", "Radius / diameter…", 7, "V", "key:V", nullptr, 0x00020000u, 0, 0, false, true, nullptr, "design_dimension", "Type the radius of this arc, or the diameter of this circle"}, - {"sk_angdist", "Angle / distance…", 7, "V", "key:V", nullptr, 0x00080000u, 0, 0, false, true, nullptr, "design_dimension", "Type the angle between two lines, or the distance between the two picks"}, + {"sk_length", L("Length…"), 7, "V", "key:V", nullptr, 0x00010000u, 0, 0, false, true, nullptr, "design_dimension", L("Type the length of this line")}, + {"sk_radius", L("Radius / diameter…"), 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("Angle / distance…"), 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; diff --git a/src/slic3r/GUI/CAD/DesignPanel.cpp b/src/slic3r/GUI/CAD/DesignPanel.cpp index d51da4c6b4..046bf0486d 100644 --- a/src/slic3r/GUI/CAD/DesignPanel.cpp +++ b/src/slic3r/GUI/CAD/DesignPanel.cpp @@ -2,7 +2,12 @@ #include "libslic3r_version.h" #include "slic3r/GUI/CAD/DesignCanvas.hpp" #include "slic3r/GUI/CAD/DesignSketchTool.hpp" +#include "slic3r/GUI/CAD/DesignTextDialog.hpp" // Text: font, height, live outline +#include "slic3r/GUI/CAD/DesignRowList.hpp" // Feature tree and Bodies rows with their own actions #include "slic3r/GUI/CAD/DesignOffer.hpp" // generated offer table — see scripts/CAD/tool_atlas.json +#include "slic3r/GUI/GLToolbar.hpp" +#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/TriangleMesh.hpp" // mesh import: STL/OBJ -> indexed_triangle_set #include "libslic3r/Format/OBJ.hpp" @@ -16,6 +21,9 @@ #include #include #include // the offer/atlas join check reports on the log +#include +#include +#include #include #include // offer_trace: diagnostic row dump for the offer ladder @@ -55,12 +63,11 @@ #include #include #include -#include #include #include +#include +#include #include -#include -#include #include #include #include @@ -73,7 +80,6 @@ #include #include #include // wxWindowDisabler, wxMilliSleep -#include // wxMessageBox #include #include @@ -89,6 +95,9 @@ #include "slic3r/GUI/Widgets/DropDown.hpp" // Orca-themed combo dropdown (white/teal selector) for the tool flyouts #include "slic3r/GUI/Widgets/CheckBox.hpp" // Orca teal check (label lives in the row's left column) #include "slic3r/GUI/Widgets/ComboBox.hpp" // Orca dropdown — replaces wxChoice in every Design card +#include "slic3r/GUI/Widgets/TextInput.hpp" +#include "slic3r/GUI/Widgets/DialogButtons.hpp" +#include "slic3r/GUI/MsgDialog.hpp" #include "slic3r/GUI/Widgets/StaticBox.hpp" // Prepare's rounded white card frame around each tool dialog #include "libslic3r/CAD/SketchImport.hpp" // text_to_regions / svg_to_regions #include "libslic3r/CAD/ThreadStandards.hpp" // ISO metric / Unified imperial thread tables @@ -98,6 +107,7 @@ #include "libslic3r/BuildVolume.hpp" #include "slic3r/GUI/MainFrame.hpp" #include "slic3r/GUI/GUI_ObjectList.hpp" +#include "slic3r/GUI/Shortcuts.hpp" #include "slic3r/GUI/I18N.hpp" // English-only pin for the Design tab (see design-ux-contract): one lever @@ -186,21 +196,42 @@ static bool en_parse(const wxString& text, double& out) return t.ToCDouble(&out); } -// Design-tab chrome tokens. The dark branch returns the EXACT legacy values so the -// (correct) dark theme stays byte-identical; the light branch maps each onto Orca's -// light surface so the ribbon/sidebar follow the app theme instead of staying black. +// Design-tab chrome tokens, one {light, dark} pair each. Controls are coloured from these at +// construction; on a theme switch on_sys_color_changed() walks the panel and moves every colour +// that is one theme's token onto the other theme's, so the tab follows the app theme live. +struct DpToken { wxColour light, dark; }; +enum DpTok { TokRibbonBg, TokRibbonHover, TokPanelBg, TokSecText, TokCtlText, TokItemText, TokItemDim, + TokBorder, TokCount }; +static const DpToken kDpTokens[TokCount] = { + { wxColour(0xEC,0xEC,0xEE), wxColour(0x36,0x36,0x3C) }, // ribbon + { wxColour(0xD7,0xD7,0xDB), wxColour(0x4D,0x4D,0x54) }, // ribbon hover + { wxColour(0xFB,0xFB,0xFD), wxColour(0x2D,0x2D,0x30) }, // sidebar / lists + { wxColour(0x66,0x66,0x68), wxColour(0x81,0x81,0x83) }, // secondary text + { wxColour(0x35,0x35,0x37), wxColour(0xC8,0xC8,0xC8) }, // control text + { wxColour(0x2C,0x2C,0x2E), wxColour(0xE0,0xE0,0xE0) }, // list item text + { wxColour(0xA0,0xA0,0xA2), wxColour(0x80,0x80,0x80) }, // dimmed list item + // Prepare's control-outline grey, sampled from its sidebar: #DBDBDB on light, #4A4A51 on the + // #2D2D31 dark panel. Every framed thing in Design uses this so the tab matches. + { wxColour(0xDB,0xDB,0xDB), wxColour(0x4A,0x4A,0x51) }, +}; static bool dp_dark() { return wxGetApp().dark_mode(); } -static wxColour dp_ribbon_bg() { return dp_dark() ? wxColour(0x36,0x36,0x3C) : wxColour(0xEC,0xEC,0xEE); } -static wxColour dp_ribbon_hover() { return dp_dark() ? wxColour(0x4D,0x4D,0x54) : wxColour(0xD7,0xD7,0xDB); } -static wxColour dp_panel_bg() { return dp_dark() ? wxColour(0x2D,0x2D,0x30) : wxColour(0xFB,0xFB,0xFD); } -static wxColour dp_sec_text() { return dp_dark() ? wxColour(0x81,0x81,0x83) : wxColour(0x66,0x66,0x68); } -static wxColour dp_ctl_text() { return dp_dark() ? wxColour(0xC8,0xC8,0xC8) : wxColour(0x35,0x35,0x37); } -static wxColour dp_item_text() { return dp_dark() ? wxColour(0xE0,0xE0,0xE0) : wxColour(0x2C,0x2C,0x2E); } -static wxColour dp_item_dim() { return dp_dark() ? wxColour(0x80,0x80,0x80) : wxColour(0xA0,0xA0,0xA2); } +static wxColour dp_tok(DpTok t) { return dp_dark() ? kDpTokens[t].dark : kDpTokens[t].light; } +static wxColour dp_ribbon_bg() { return dp_tok(TokRibbonBg); } +static wxColour dp_ribbon_hover() { return dp_tok(TokRibbonHover); } +static wxColour dp_panel_bg() { return dp_tok(TokPanelBg); } +static wxColour dp_sec_text() { return dp_tok(TokSecText); } +static wxColour dp_ctl_text() { return dp_tok(TokCtlText); } +static wxColour dp_item_text() { return dp_tok(TokItemText); } +static wxColour dp_item_dim() { return dp_tok(TokItemDim); } -// Prepare's control-outline grey, sampled from its sidebar: #4A4A51 on the #2D2D31 dark -// panel, #DBDBDB on light. Every framed thing in Design uses this so the tab matches. -static wxColour dp_border_col() { return dp_dark() ? wxColour(0x4A,0x4A,0x51) : wxColour(0xDB,0xDB,0xDB); } +// The other theme's value of a token colour, or `c` itself when it is no token. +static wxColour dp_retheme(const wxColour& c, bool to_dark) +{ + for (const DpToken& t : kDpTokens) + if (c == (to_dark ? t.light : t.dark)) + return to_dark ? t.dark : t.light; + return c; +} // A tool card: Prepare's rounded white-bordered panel (Plater.cpp's panel_printer_preset // idiom — radius 8, #EEEEEE border, green on hover). Every card's controls are parented @@ -209,10 +240,58 @@ static StaticBox* make_card(wxWindow* parent) { auto* c = new StaticBox(parent); c->SetCornerRadius(8); - c->SetBorderColorNormal(dp_border_col()); // no hover accent: the frame is not clickable + // The light literal in a StateColor is mapped to the dark one at paint time, so the frame + // follows a theme switch by itself. No hover accent: the frame is not clickable. + c->SetBorderColor(StateColor(kDpTokens[TokBorder].light)); return c; } +// The sidebar's icon-only buttons (card headers, constraint rows) highlight under the pointer with +// a rounded chip. Orca's ::Button rather than a ScalableButton: a native button cannot take that +// hover colour (macOS ignores a button background, MSW turns the button owner-drawn), while +// ::Button paints itself the same on every platform. Its sizes are in pixels, and the panel +// background is a Design token the dark map does not know, so refresh_icons() re-applies this +// after a DPI or theme change to every button named "design_icon_btn". +static void style_sidebar_icon_btn(::Button* b) +{ + b->SetPaddingSize(b->FromDIP(wxSize(2, 2))); + b->SetMinSize(b->FromDIP(wxSize(24, 24))); // square, whatever the icon size + b->SetCornerRadius(b->FromDIP(4)); + b->SetBackgroundColor(StateColor( + std::pair(wxColour(DesignRowList::hover_chip), StateColor::Hovered), // dark-mapped at paint + std::pair(dp_panel_bg(), StateColor::Normal))); // must stay last +} + +static ::Button* sidebar_icon_btn(wxWindow* parent, const char* icon, const wxString& tip, int px = 20) +{ + auto* b = new ::Button(parent, "", icon, wxBORDER_NONE, px); + b->SetName("design_icon_btn"); + b->SetCanFocus(false); + b->SetIconSpacing(0); // off macOS an empty label still reserves the icon-text gap + b->SetBorderColor(StateColor()); + b->SetToolTip(tip); + style_sidebar_icon_btn(b); + return b; +} + +// Commit to Plate's faces: Prepare's add-plate glyph, drawn for its light GL toolbar, and the same +// glyph with a body on the plate for "as bodies". Each ships a "_dark" twin for the dark ribbon, +// picked the way GLToolbar picks it. +static std::string commit_icon(bool bodies) +{ + const std::string name = bodies ? "toolbar_add_plate_bodies" : "toolbar_add_plate"; + return dp_dark() ? name + "_dark" : name; +} + +// The icons on each Feature tree and Bodies row (DesignRowList::Action::id). +enum RowAction { RowEdit, RowMove, RowVisibility, RowDelete }; + +// 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) +{ + return { RowVisibility, shown ? "design_eye" : "design_eye_off", shown ? _L("Hide") : _L("Show") }; +} + // 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 @@ -223,8 +302,8 @@ static wxSpinCtrlDouble* make_spin(wxWindow* parent, double val, { auto* box = new StaticBox(parent); box->SetCornerRadius(4); - box->SetBorderColorNormal(dp_border_col()); - auto* s = new wxSpinCtrlDouble(box, wxID_ANY, "", wxDefaultPosition, wxSize(90, -1), + box->SetBorderColor(StateColor(kDpTokens[TokBorder].light)); + auto* s = new wxSpinCtrlDouble(box, wxID_ANY, "", wxDefaultPosition, parent->FromDIP(wxSize(90, -1)), wxSP_ARROW_KEYS | wxBORDER_NONE); s->SetRange(mn, mx); s->SetDigits(2); @@ -314,39 +393,28 @@ static SketchPlane face_plane_inward(const TopoDS_Face& face) return p; } -// Highest upward-facing planar face of a solid — the surface the user is looking down on. -// Hole placement defaults here (instead of the z=0 datum) so the footprint sits on the top -// face at the right depth, not on the model's underside where a top-view drag reads parallax- -// shifted. Returns -1 if the shape has no clearly-upward face. -static int top_face_index_of(const TopoDS_Shape& shape) -{ - int best = -1; double bestz = -1e30; - const int n = GeometryEngine::face_count(shape); - for (int i = 0; i < n; ++i) { - const TopoDS_Face f = GeometryEngine::face_by_index(shape, i); - if (f.IsNull()) continue; - const Vec3d nrm = GeometryEngine::face_normal_world(f); - if (nrm.z() < 0.5) continue; // only faces pointing substantially up - const Vec3d c = GeometryEngine::face_centroid_world(f); - if (c.z() > bestz) { bestz = c.z(); best = i; } - } - return best; -} - DesignPanel::DesignPanel(wxWindow* parent) : wxPanel(parent, wxID_ANY) { + // The tab is built on its first show; where that time goes is logged, per phase, because it + // varies by an order of magnitude between machines. + wxStopWatch build_clock; + long build_mark = 0; + auto build_phase = [&build_clock, &build_mark](const char* phase) { + const long now = build_clock.Time(); + BOOST_LOG_TRIVIAL(info) << "Design tab build: " << phase << " " << now - build_mark << " ms"; + build_mark = now; + }; // Left column: a slim feature-tree + docked tool-dialog column. All form // controls are parented to m_form so it can scroll independently of the // live GL viewport. The tool buttons live in the top toolbar (built below). - m_form = new wxScrolledWindow(this, wxID_ANY); - // The sidebar/panel never carried an explicit background, so in light theme it - // inherited the dark window colour and stayed black. Paint it on the light surface; - // dark is left untouched (it already reads correctly via inheritance). - if (!dp_dark()) { - SetBackgroundColour(dp_panel_bg()); - m_form->SetBackgroundColour(dp_panel_bg()); - } + auto* body_panel = new wxPanel(this, wxID_ANY); // below the toolbar, managed by m_aui + m_form = new wxScrolledWindow(body_panel, wxID_ANY); + // Explicit token background in both themes, so a theme switch can move it (an inherited + // colour stays whatever the theme was when the panel was built). + SetBackgroundColour(dp_panel_bg()); + body_panel->SetBackgroundColour(dp_panel_bg()); + m_form->SetBackgroundColour(dp_panel_bg()); auto* root = new wxBoxSizer(wxVERTICAL); { @@ -369,22 +437,20 @@ DesignPanel::DesignPanel(wxWindow* parent) // #4D4D54); light maps onto the app's light chrome so the strip follows the theme. m_toolbar->SetBackgroundColour(dp_ribbon_bg()); - const wxColour tb_bg = dp_ribbon_bg(); - const wxColour tb_hover = dp_ribbon_hover(); - auto icon_btn = [this, tb_bg, tb_hover](const char* icon, const wxString& tip) { + auto icon_btn = [this](const char* icon, const wxString& tip) { // Prepare's main toolbar: 40 px icon cell, 4 px gap (GLToolbar::Default_Icons_Size // and set_gap_size(4)) -> 44 px pitch. Match it exactly. - auto* b = new ScalableButton(m_toolbar, wxID_ANY, icon, "", wxSize(40, 40), + auto* b = new ScalableButton(m_toolbar, wxID_ANY, icon, "", FromDIP(wxSize(40, 40)), wxDefaultPosition, wxBU_EXACTFIT | wxBORDER_NONE, false, 34); b->SetToolTip(tip); - b->SetBackgroundColour(tb_bg); + b->SetBackgroundColour(dp_ribbon_bg()); m_tool_btns.push_back(b); // Hover affordance, honouring the active-tool teal state. - b->Bind(wxEVT_ENTER_WINDOW, [this, b, tb_hover](wxMouseEvent& e) { - b->SetBackgroundColour(b == m_active_tool_btn ? wxColour(0x52, 0xC7, 0xB8) : tb_hover); + b->Bind(wxEVT_ENTER_WINDOW, [this, b](wxMouseEvent& e) { + b->SetBackgroundColour(b == m_active_tool_btn ? wxColour(0x52, 0xC7, 0xB8) : dp_ribbon_hover()); b->Refresh(); e.Skip(); }); - b->Bind(wxEVT_LEAVE_WINDOW, [this, b, tb_bg](wxMouseEvent& e) { - b->SetBackgroundColour(b == m_active_tool_btn ? wxColour(0x00, 0x96, 0x88) : tb_bg); + b->Bind(wxEVT_LEAVE_WINDOW, [this, b](wxMouseEvent& e) { + b->SetBackgroundColour(b == m_active_tool_btn ? wxColour(0x00, 0x96, 0x88) : dp_ribbon_bg()); b->Refresh(); e.Skip(); }); // Mark this tool active (teal) on press — a separate event from the // button's command handler, so it never swallows the click action. @@ -402,7 +468,7 @@ DesignPanel::DesignPanel(wxWindow* parent) }; auto add_sep = [this](wxSizer* row) { row->AddSpacer(5); - row->Add(new wxStaticLine(m_toolbar, wxID_ANY, wxDefaultPosition, wxSize(1, 22), wxLI_VERTICAL), + row->Add(new wxStaticLine(m_toolbar, wxID_ANY, wxDefaultPosition, FromDIP(wxSize(1, 22)), wxLI_VERTICAL), 0, wxALIGN_CENTER_VERTICAL); row->AddSpacer(5); }; @@ -431,10 +497,8 @@ DesignPanel::DesignPanel(wxWindow* parent) m_viewport->set_sketch_tool(mode); } m_viewport->set_sketch_construction(m_construction->GetValue()); - m_status->SetForegroundColour(wxNullColour); - set_status(m_sketch_on.IsEmpty() ? hint + set_status(StatusKind::Info, m_sketch_on.IsEmpty() ? hint : wxString::Format(_L("%s · on %s"), hint, m_sketch_on)); - m_status->Refresh(); }; // Sketch-tool shortcuts (single letters, active only while a sketch is open). Family tools @@ -447,7 +511,7 @@ DesignPanel::DesignPanel(wxWindow* parent) sk_key('R', DesignSketchTool::Mode::CornerRect, _L("Rectangle — click two opposite corners")); sk_key('C', DesignSketchTool::Mode::CenterCircle, _L("Circle — click center, then radius")); sk_key('A', DesignSketchTool::Mode::ThreePointArc,_L("Arc — click start, end, then a point")); - sk_key('S', DesignSketchTool::Mode::Slot, _L("Slot — two centerline ends, then end radius")); + sk_key('S', DesignSketchTool::Mode::Slot, _L("Slot — two centerline ends, then the width")); sk_key('E', DesignSketchTool::Mode::Ellipse, _L("Ellipse — center, major end, minor point")); sk_key('B', DesignSketchTool::Mode::BSpline, _L("Spline — click control points")); sk_key('P', DesignSketchTool::Mode::Point, _L("Point — click to place")); @@ -484,9 +548,7 @@ DesignPanel::DesignPanel(wxWindow* parent) // exactly normal. Sketch map only: in Feature mode the navigator orb owns orientation. m_keys_sketch['N'] = [this] { if (m_viewport && m_viewport->view_normal_to_sketch()) { - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Normal to the sketch plane")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("Normal to the sketch plane")); } }; m_keys_sketch['Q'] = [this] { @@ -512,9 +574,7 @@ DesignPanel::DesignPanel(wxWindow* parent) // View toggles (single letters, active when no sketch is open): P origin planes, A world // axes, X section view (Alt+Wheel slides the cut). Distinct from Shift+P/Shift+X features. auto status_flag = [this](const wxString& on_msg, const wxString& off_msg, bool on) { - m_status->SetForegroundColour(wxNullColour); - set_status(on ? on_msg : off_msg); - m_status->Refresh(); + set_status(StatusKind::Info, on ? on_msg : off_msg); }; m_keys_feature['P'] = [this, status_flag] { if (m_viewport) status_flag(_L("Origin planes shown"), _L("Origin planes hidden"), @@ -525,6 +585,9 @@ DesignPanel::DesignPanel(wxWindow* parent) m_viewport->toggle_axes()); }; m_keys_feature['X'] = [this] { toggle_section_view(); }; // toggle the single section on/off + // F: Place on Face. Lives in this map, not in the canvas, so it works whatever holds focus + // (charter 6.2). While the section view is on, F flips the kept half instead (handled above). + m_keys_feature['F'] = [this] { place_on_face(); }; // Home: axonometric view, fitted to the model. // @@ -540,7 +603,7 @@ DesignPanel::DesignPanel(wxWindow* parent) m_keys_feature[WXK_HOME] = [this] { if (!m_viewport) return; m_viewport->set_view("iso"); - set_status(_L("Isometric view, fitted")); + set_status(_L("Axonometric view, fitted")); }; // Commit to Plate and the bed toggle were mouse-only: a toolbar button and a checkbox with @@ -556,24 +619,8 @@ DesignPanel::DesignPanel(wxWindow* parent) set_status(show ? _L("Bed shown") : _L("Bed hidden")); }; - // Shared flyout glyph tint (used by BOTH the feature and sketch toolbars). Re-tint each - // design_* glyph to the DropDown's resolved TEXT colour so it reads on the popup in either - // theme: text_color is 0x363636, which darkModeColorFor() maps to a light tone in dark mode - // (the popup bg is darkModeColorFor(white) = dark) and leaves dark in light mode. The alpha - // (the glyph shape) is preserved; only RGB is replaced. - // ponytail: wxBitmap(img) drops the HiDPI scale factor (no scale ctor before wx 3.1.6); the - // deploy target runs at scale 1.0, so this is exact there. - const wxColour drop_icon_col = StateColor::darkModeColorFor(wxColour(0x36, 0x36, 0x36)); - auto tint = [](wxBitmap bmp, const wxColour& c) -> wxBitmap { - if (!bmp.IsOk()) return bmp; - wxImage img = bmp.ConvertToImage(); - if (!img.HasAlpha()) img.InitAlpha(); - const int w = img.GetWidth(), h = img.GetHeight(); - for (int y = 0; y < h; ++y) - for (int x = 0; x < w; ++x) - img.SetRGB(x, y, c.Red(), c.Green(), c.Blue()); - return wxBitmap(img); - }; + // Flyout rows show the design_* glyphs as they are: drawn in Orca's icon grey (#949494), which + // the icon cache maps per theme like every other sidebar icon, so they need no re-tint. // --- Feature group: Sketch / Extrude / Fillet-Chamfer / Hole / Thread / Constrain m_tb_feature = new wxBoxSizer(wxHORIZONTAL); @@ -631,13 +678,18 @@ DesignPanel::DesignPanel(wxWindow* parent) DropDown::Item it; it.text = v.tip; it.tip = v.hint; - it.icon = tint(create_scaled_bitmap(v.icon, m_form, 18), drop_icon_col); + it.icon = create_scaled_bitmap(v.icon, m_form, 18); fo->items.push_back(it); fo->actions.push_back(std::move(v.action)); fo->icon_names.emplace_back(v.icon); } fo->btn = b; fo->drop.Create(b); + m_icon_refresh.push_back([this, fp = fo.get()] { + for (size_t i = 0; i < fp->items.size(); ++i) + fp->items[i].icon = create_scaled_bitmap(fp->icon_names[i], m_form, 18); + fp->drop.Invalidate(true); + }); fo->drop.SetUseContentWidth(true, false); fo->drop.Invalidate(true); FeatFlyout* fp = fo.get(); @@ -656,7 +708,7 @@ DesignPanel::DesignPanel(wxWindow* parent) fp->drop.SetUseContentWidth(false, false); fp->drop.SetUseContentWidth(true, false); wxPoint pos = b->ClientToScreen(wxPoint(0, -6)); - fp->drop.Position(pos, wxSize(0, b->GetSize().y + 12)); + fp->drop.Position(pos, wxSize(0, b->GetSize().y + b->FromDIP(12))); fp->drop.Popup(); }); m_flyout_keepalive.push_back(fo); @@ -678,11 +730,9 @@ DesignPanel::DesignPanel(wxWindow* parent) set_ui_mode(UiMode::Sketch); wxString where; const bool have_plane = sketch_plane_target(where); - m_status->SetForegroundColour(wxNullColour); - set_status(have_plane + set_status(StatusKind::Info, have_plane ? wxString::Format(_L("Sketching on %s — pick a tool"), where) : _L("Click a face or a reference plane in the viewport, then a sketch tool")); - m_status->Refresh(); 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("Drawing on %s.\nPick a tool, or press Menu for the list."), where) @@ -707,7 +757,8 @@ DesignPanel::DesignPanel(wxWindow* parent) // Onshape push/pull: an explicitly picked solid face (Face-level cycle, no loop // selected) is extruded as the profile — this takes priority over re-extruding an // already-consumed sketch (resolve_extrude_sketch always returns the last Sketch). - if (m_sel_solid_face >= 0 && !m_doc.body.IsNull() && m_sel_sketch_region < 0) { + if (m_sel_solid_face >= 0 && !m_doc.body.IsNull() + && (m_viewport == nullptr || m_viewport->loop_pick_region() < 0)) { m_extrude_face_src = m_sel_solid_face; m_extrude_sketch_ref = -1; open_tool(Tool::Extrude); @@ -716,9 +767,7 @@ DesignPanel::DesignPanel(wxWindow* parent) m_extrude_face_src = -1; // ordinary sketch/loop extrude m_extrude_sketch_ref = resolve_extrude_sketch(); if (m_extrude_sketch_ref < 0) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Create a sketch, or pick a solid face, first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Create a sketch, or pick a solid face, first")); return; } open_tool(Tool::Extrude); @@ -727,11 +776,10 @@ DesignPanel::DesignPanel(wxWindow* parent) [this] { m_revolve_sketch_ref = resolve_extrude_sketch(); if (m_revolve_sketch_ref < 0) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Create a sketch profile to revolve first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Create a sketch profile to revolve first")); return; } + fill_revolve_axes(m_revolve_axis, m_revolve_axis_ents, m_revolve_sketch_ref, 0, -2); open_tool(Tool::Revolve); }, SHIFT('R')}, {"design_sweep", _L("Sweep"), _L("Sweep a profile along a path"), @@ -739,9 +787,7 @@ DesignPanel::DesignPanel(wxWindow* parent) m_sweep_profile_ref = resolve_extrude_sketch(); m_sweep_path_ref = -1; // fresh sweep: default the picker to the first sketch if (m_sweep_profile_ref < 0) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Create a profile sketch to sweep first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Create a profile sketch to sweep first")); return; } open_tool(Tool::Sweep); @@ -753,9 +799,7 @@ DesignPanel::DesignPanel(wxWindow* parent) for (const auto& f : m_doc.features) if (f.type == CadFeatureType::Sketch) ++n; if (n < 2) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Create at least two profile sketches to loft")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Create at least two profile sketches to loft")); return; } m_loft_refs.clear(); // fresh loft: nothing pre-checked @@ -764,9 +808,7 @@ DesignPanel::DesignPanel(wxWindow* parent) {"design_thicken", _L("Thicken"), _L("Offset a solid face into a thin plate (new body)"), [this] { if (m_doc.bodies.empty()) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Thicken needs a solid body — add or import one first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Thicken needs a solid body — add or import one first")); return; } { @@ -794,9 +836,7 @@ DesignPanel::DesignPanel(wxWindow* parent) {"design_rib", _L("Rib"), _L("Grow a thin wall from an open sketch line, fused to a body"), [this] { if (m_doc.bodies.empty()) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Rib needs a solid body — add or import one first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Rib needs a solid body — add or import one first")); return; } { @@ -822,9 +862,7 @@ DesignPanel::DesignPanel(wxWindow* parent) } if (m_rib_sketch->GetCount() > 0) m_rib_sketch->SetSelection(0); else { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Create a sketch with an open line first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Create a sketch with an open line first")); return; } } @@ -839,9 +877,7 @@ DesignPanel::DesignPanel(wxWindow* parent) std::function act_pattern = [this] { // Pattern replicates an existing body — needs at least one solid. if (m_doc.bodies.empty()) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Create a solid body to pattern first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Create a solid body to pattern first")); return; } open_tool(Tool::Pattern); @@ -861,9 +897,7 @@ DesignPanel::DesignPanel(wxWindow* parent) [this] { m_surf_extrude_sketch_ref = resolve_extrude_sketch(); if (m_surf_extrude_sketch_ref < 0) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Create a sketch first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Create a sketch first")); return; } open_tool(Tool::SurfaceExtrude); @@ -872,11 +906,10 @@ DesignPanel::DesignPanel(wxWindow* parent) [this] { m_surf_revolve_sketch_ref = resolve_extrude_sketch(); if (m_surf_revolve_sketch_ref < 0) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Create a sketch profile to revolve first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Create a sketch profile to revolve first")); return; } + fill_revolve_axes(m_surf_revolve_axis, m_surf_revolve_axis_ents, m_surf_revolve_sketch_ref, 0, -2); open_tool(Tool::SurfaceRevolve); }, SHIFT('J')}, {"design_loft", _L("Surface Loft"), _L("Loft (skin) between 2+ profiles, open (no end caps)"), @@ -885,9 +918,7 @@ DesignPanel::DesignPanel(wxWindow* parent) for (const auto& f : m_doc.features) if (f.type == CadFeatureType::Sketch) ++n; if (n < 2) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Create at least two profile sketches to loft")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Create at least two profile sketches to loft")); return; } m_surf_loft_refs.clear(); @@ -897,9 +928,7 @@ DesignPanel::DesignPanel(wxWindow* parent) [this] { m_surf_fill_sketch_ref = resolve_extrude_sketch(); if (m_surf_fill_sketch_ref < 0) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Create a sketch profile to fill first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Create a sketch profile to fill first")); return; } open_tool(Tool::SurfaceFill); @@ -908,9 +937,7 @@ DesignPanel::DesignPanel(wxWindow* parent) [this] { populate_sheet_body_choices(m_surf_offset_body); if (m_surf_offset_body->GetCount() == 0) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("No sheet body to offset — create a surface feature first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("No sheet body to offset — create a surface feature first")); return; } open_tool(Tool::SurfaceOffset); @@ -919,9 +946,7 @@ DesignPanel::DesignPanel(wxWindow* parent) [this] { populate_sheet_body_choices(m_surf_thicken_body); if (m_surf_thicken_body->GetCount() == 0) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("No sheet body to thicken — create a surface feature first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("No sheet body to thicken — create a surface feature first")); return; } open_tool(Tool::ThickenSurface); @@ -942,7 +967,7 @@ DesignPanel::DesignPanel(wxWindow* parent) reset_axis_refs(); open_tool(Tool::Axis); }, SHIFT('A')}, - {"design_point", _L("Coord Sys"), _L("Datum coordinate system (world point, or face + direction edge)"), + {"design_point", _L("Coordinate system"), _L("Datum coordinate system (world point, or face + direction edge)"), [this] { reset_coordsys_refs(); open_tool(Tool::CoordSys); @@ -957,9 +982,7 @@ DesignPanel::DesignPanel(wxWindow* parent) {"design_sketch", _L("Project"), _L("Project body edges onto a plane as sketch entities"), [this] { if (m_doc.bodies.empty()) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Project needs a body — add or import one first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Project needs a body — add or import one first")); return; } { @@ -973,8 +996,14 @@ DesignPanel::DesignPanel(wxWindow* parent) int(m_proj_source_body->GetCount()) - 1)); } populate_plane_choices(m_proj_plane); - m_sel_solid_face = -1; - m_proj_face_label->SetLabel(_L("(all edges)")); + // Keep what the user pointed at (L3): a picked face on the source body is the + // face to project, exactly as the offer promised when it showed Project on it. + // Only a pick on ANOTHER body is dropped, since it cannot belong to this source. + const int src = m_proj_source_body->GetSelection(); + if (m_sel_solid_face >= 0 && m_sel_solid_body != src) m_sel_solid_face = -1; + m_proj_face_label->SetLabel(m_sel_solid_face >= 0 + ? wxString::Format(_L("Face %d"), m_sel_solid_face) + : _L("(all edges)")); open_tool(Tool::Project); }, 0}, }); @@ -990,9 +1019,7 @@ DesignPanel::DesignPanel(wxWindow* parent) {"design_move", _L("Transform"), _L("Move and/or rotate an existing body"), [this] { if (m_doc.bodies.empty()) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Transform needs a body — add or import one first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Transform needs a body — add or import one first")); return; } { @@ -1010,9 +1037,7 @@ DesignPanel::DesignPanel(wxWindow* parent) {"design_mirror", _L("Mirror"), _L("Reflect a body about a plane"), [this] { if (m_doc.bodies.empty()) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Mirror needs a body — add or import one first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Mirror needs a body — add or import one first")); return; } { @@ -1028,7 +1053,7 @@ DesignPanel::DesignPanel(wxWindow* parent) populate_plane_choices(m_mirror_plane); open_tool(Tool::Mirror); }, SHIFT('Z')}, - {"design_c_coincident", _L("Mate"), _L("Assembly: align two CoordSys features (fastened, planar, revolute, slider, cylindrical)"), + {"design_c_coincident", _L("Mate"), _L("Assembly: align two coordinate systems (fastened, planar, revolute, slider, cylindrical)"), [this] { open_tool(Tool::Mate); }, 0}, @@ -1046,9 +1071,7 @@ DesignPanel::DesignPanel(wxWindow* parent) std::function act_cut = [this] { // A plane cut needs at least one solid to slice. if (m_doc.bodies.empty()) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Create a solid body to cut first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Create a solid body to cut first")); return; } populate_plane_choices(m_cut_plane); @@ -1062,36 +1085,25 @@ DesignPanel::DesignPanel(wxWindow* parent) _L("Cut — needs a solid body to slice")}); // Color — override the selected body's display colour (per-body, survives recompute). - auto* b_color = icon_btn("color_palette", _L("Color — set the selected body's display colour")); + auto* b_color = icon_btn("color_palette", _L("Color — set the selected body's display color")); 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:delete"] = [this] { on_delete_feature(); }; - // Rename exists as a slow double-click on the row too, but the offer is this tab's only - // tool vocabulary — a rename that only a double-click reveals is not discoverable, and - // the row IS the object, so it belongs in the menu (and on F2) as well as on the row. + // 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 + // the feature row's right-click menu and on F2, and opens the editor on the row itself. auto rename_feature = [this] { // A BODY renames ITSELF. The earlier version resolved the body to // CadBody::source_feature and renamed that feature, which is the wrong object: a // body accumulates many features and the first one is not its name. The body row // is editable now (CadBody::user_name), so the verb opens the editor there. - const int b = tree_body_selection(); - if (b >= 0 && b < int(m_tree_body_items.size())) { - const wxTreeItemId row = m_tree_body_items[b]; - // After the menu, not inside it: an editor opened from within PopupMenu's - // nested loop never appears. - CallAfter([this, row] { m_parts->SetFocus(); m_parts->EditLabel(row); }); - return; - } - const int sel = tree_selection(); - if (sel != wxNOT_FOUND && sel < int(m_tree_items.size())) { - const wxTreeItemId row = m_tree_items[sel]; - CallAfter([this, row] { m_tree->SetFocus(); m_tree->EditLabel(row); }); - } else { - m_status->SetForegroundColour(wxNullColour); // "nothing selected" is not an error - set_status(_L("Select a feature, or a body, first — then rename it")); - m_status->Refresh(); - } + 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 + set_status(_L("Select a feature, or a body, first — then rename it")); // not an error }; m_verb_actions["btn:rename"] = rename_feature; m_keys_feature[WXK_F2] = rename_feature; // a function key, so no letter space spent @@ -1108,6 +1120,7 @@ DesignPanel::DesignPanel(wxWindow* parent) m_verb_actions["btn:delete_body"] = [this] { on_delete_body(); }; m_verb_actions["btn:edit"] = [this] { on_edit_feature(); }; m_verb_actions["btn:mass"] = [this] { on_mass_properties(); }; + m_verb_actions["btn:interference"] = [this] { on_check_interference(); }; // Reachable from the offer menu on a SELECTED SKETCH, not only from the toolbar icon. // A user evaluating against Onshape reported that "adding constraints seems to be // missing" — with nineteen constraint types and a solver shipped. The only paths in @@ -1131,9 +1144,7 @@ DesignPanel::DesignPanel(wxWindow* parent) {"design_delete", _L("Delete Face"), _L("Remove faces from a body and heal the solid"), [this] { if (m_doc.bodies.empty()) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Delete Face needs a body — add or import one first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Delete Face needs a body — add or import one first")); return; } { @@ -1154,7 +1165,7 @@ DesignPanel::DesignPanel(wxWindow* parent) // Hole / Thread — drilling into a solid (both face-aware) feat_dropdown("hole", "design_hole", _L("Hole / thread"), { - {"design_hole", _L("Hole"), _L("Drill a hole, centred on a picked face or placed on a plane"), + {"design_hole", _L("Hole"), _L("Drill a hole, centerd on a picked face or placed on a plane"), [this] { // #2: drill on the picked solid face, centred on it (origin = face centroid, // normal = inward). Otherwise fall back to the plane dropdown. m_hole_x/y then @@ -1163,13 +1174,13 @@ DesignPanel::DesignPanel(wxWindow* parent) m_hole_face_body = -1; m_hole_has_bounds = false; set_hole_target_label(-1); - // Use the explicitly-picked face; otherwise default to the top face of the - // selected (or first) body so the hole lands on the surface being viewed, not - // the z=0 datum under the model. The XY/XZ/YZ dropdown still overrides. - int hb = m_sel_solid_body, hf = m_sel_solid_face; - if (hf < 0 && !m_doc.bodies.empty()) { - hb = (hb >= 0 && hb < int(m_doc.bodies.size())) ? hb : 0; - hf = top_face_index_of(m_doc.bodies[hb].shape); + // The hole goes where the user pointed (L1): a picked face. The keyboard route + // used to invent one — the top face of the first body — while the offer refused + // the same verb with nothing picked; both routes now say the same thing. + const int hb = m_sel_solid_body, hf = m_sel_solid_face; + if (hf < 0 || hb < 0 || hb >= int(m_doc.bodies.size())) { + set_status(StatusKind::Error, _L("Pick a face or a plane to drill into")); + return; } if (hf >= 0 && hb >= 0 && hb < int(m_doc.bodies.size())) { const TopoDS_Face face = GeometryEngine::face_by_index( @@ -1223,15 +1234,16 @@ DesignPanel::DesignPanel(wxWindow* parent) m_thread_face_body = m_sel_solid_body; set_thread_target_label(from_face ? m_sel_solid_face : -1, from_face ? -1 : m_sel_solid_edge); - infer_thread_spec(2.0 * cf.radius); // M diameter + pitch + depth from the cylinder - if (m_thread_height && cf.height > 1e-6) m_thread_height->SetValue(cf.height); if (m_thread_internal) m_thread_internal->SetValue(cf.internal); + infer_thread_spec(2.0 * cf.radius, cf.internal); // M size + pitch + depth from the cylinder + if (m_thread_height && cf.height > 1e-6) m_thread_height->SetValue(cf.height); if (m_thread_x) m_thread_x->SetValue(0.0); // on the axis if (m_thread_y) m_thread_y->SetValue(0.0); } else if (m_sel_solid_face >= 0 || m_sel_solid_edge >= 0) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Pick a cylindrical surface (bore / outer) or a circular edge for a thread")); - m_status->Refresh(); + // Refuse and stay closed: opening the card anyway overwrote this reason with + // the preview's status one line later, and left a thread card with no target. + set_status(StatusKind::Error, _L("Pick a cylindrical surface (bore / outer) or a circular edge for a thread")); + return; } open_tool(Tool::Thread); }, SHIFT('T')}, @@ -1315,7 +1327,7 @@ DesignPanel::DesignPanel(wxWindow* parent) DropDown::Item it; it.text = v.tip; it.tip = v.hint; - it.icon = tint(create_scaled_bitmap(v.icon, m_form, 18), drop_icon_col); + it.icon = create_scaled_bitmap(v.icon, m_form, 18); fo->items.push_back(it); fo->modes.push_back(v.mode); fo->hints.push_back(v.hint); @@ -1323,6 +1335,11 @@ DesignPanel::DesignPanel(wxWindow* parent) } fo->btn = b; fo->drop.Create(b); + m_icon_refresh.push_back([this, fp = fo.get()] { + for (size_t i = 0; i < fp->items.size(); ++i) + fp->items[i].icon = create_scaled_bitmap(fp->icon_names[i], m_form, 18); + fp->drop.Invalidate(true); + }); fo->drop.SetUseContentWidth(true, false); fo->drop.Invalidate(true); ToolFlyout* fp = fo.get(); @@ -1344,7 +1361,7 @@ DesignPanel::DesignPanel(wxWindow* parent) fp->drop.SetUseContentWidth(false, false); fp->drop.SetUseContentWidth(true, false); wxPoint pos = b->ClientToScreen(wxPoint(0, -6)); - fp->drop.Position(pos, wxSize(0, b->GetSize().y + 12)); + fp->drop.Position(pos, wxSize(0, b->GetSize().y + b->FromDIP(12))); fp->drop.Popup(); }); m_flyout_keepalive.push_back(fo); @@ -1361,7 +1378,7 @@ DesignPanel::DesignPanel(wxWindow* parent) // (separator dropped: the group it divided is now reached from the offer) dropdown("design_line", _L("Line / polyline"), { {"design_line", DesignSketchTool::Mode::Line, _L("Line"), _L("Click start, then end — then set the exact length")}, - {"design_polyline", DesignSketchTool::Mode::Polyline, _L("Polyline"), _L("Click points; click first / right-click to close the loop")} }); + {"design_polyline", DesignSketchTool::Mode::Polyline, _L("Polyline"), _L("Click points; click the first point to close the loop, right-click or Enter to end the chain")} }); dropdown("design_rect", _L("Rectangle"), { {"design_rect", DesignSketchTool::Mode::CornerRect, _L("Corner rectangle"), _L("Click two opposite corners")}, {"design_crect", DesignSketchTool::Mode::CenterRect, _L("Center rectangle"), _L("Click center, then a corner")}, @@ -1376,13 +1393,13 @@ DesignPanel::DesignPanel(wxWindow* parent) {"design_tangentarc", DesignSketchTool::Mode::TangentArc, _L("Tangent arc"), _L("Click start (on the last entity) then end")}, {"design_arc_center", DesignSketchTool::Mode::CenterArc, _L("Center-point arc"), _L("Click center, then start, then a point for the end angle")} }); dropdown("design_slot", _L("Slot"), { - {"design_slot", DesignSketchTool::Mode::Slot, _L("Slot"), _L("Click two centerline ends, then a point for the end radius")}, + {"design_slot", DesignSketchTool::Mode::Slot, _L("Slot"), _L("Click two centerline ends, then a point for the width")}, {"design_slot_arc", DesignSketchTool::Mode::ArcSlot, _L("Arc slot"), _L("Click center, start, end, then a point for the width")} }); dropdown("design_ellipse", _L("Ellipse"), { {"design_ellipse", DesignSketchTool::Mode::Ellipse, _L("Ellipse"), _L("Click center, a major-axis end, then a point for the minor axis")}, {"design_ellipse_arc", DesignSketchTool::Mode::EllipseArc, _L("Elliptical arc"), _L("Click center, major-axis end, minor point, then arc start and end")} }); skbtn("design_bspline", DesignSketchTool::Mode::BSpline, _L("Spline"), - _L("Click control points; double-click or right-click to finish")); + _L("Click control points; double-click, right-click or Enter to finish")); skbtn("design_point", DesignSketchTool::Mode::Point, _L("Point"), _L("Click to place a point")); // (separator dropped: the group it divided is now reached from the offer) @@ -1429,9 +1446,9 @@ DesignPanel::DesignPanel(wxWindow* parent) // Trim / Extend scissors — standalone sketch tools (NOT inside Constrain): click a // segment to cut it back to / out to its nearest intersection. One cut per click. skbtn("design_trim", DesignSketchTool::Mode::Trim, _L("Trim"), - _L("Click a segment to trim it back to its nearest intersection; right-click exits")); + _L("Click a segment to trim it back to its nearest intersection; Esc exits")); skbtn("design_extend", DesignSketchTool::Mode::Extend, _L("Extend"), - _L("Click a line or arc to extend it to the nearest entity; right-click exits")); + _L("Click a line or arc to extend it to the nearest entity; Esc exits")); // Constrain — grouped with the edit tools so it's easy to find (nde #13: it was buried // far-right next to Construction and went unnoticed). Commits the live sketch in place // and drops into Constrain mode (geometric/dimensional palette). @@ -1487,13 +1504,17 @@ DesignPanel::DesignPanel(wxWindow* parent) if (m_dressup_type) m_dressup_type->SetSelection(0); open_feature(k_dress); }; m_verb_actions["btn:dress#1"] = [this, open_feature] { if (m_dressup_type) m_dressup_type->SetSelection(1); open_feature(k_dress); }; + // Same order for all three families: choose the variant, then open, so the card, its + // header and the first preview are built for the variant that was picked (a circular + // pattern used to open showing the linear ghost and gizmo). for (int op = 0; op < 3; ++op) m_verb_actions["btn:bool#" + std::to_string(op)] = [this, open_feature, op] { - open_feature(k_bool); - if (m_bool_op) { m_bool_op->SetSelection(op); refresh_preview(); } }; + if (m_bool_op) m_bool_op->SetSelection(op); // the variant first, so the card opens on it + open_feature(k_bool); }; for (int t = 0; t < 2; ++t) m_verb_actions["btn:pat#" + std::to_string(t)] = [this, open_feature, t] { - open_feature(k_pat); if (m_pattern_type) m_pattern_type->SetSelection(t); }; + if (m_pattern_type) m_pattern_type->SetSelection(t); + open_feature(k_pat); }; auto* b_poly = icon_btn("design_polygon", _L("Polygon")); b_poly->Bind(wxEVT_BUTTON, [arm_polygon](wxCommandEvent&) { arm_polygon(); }); @@ -1520,11 +1541,9 @@ DesignPanel::DesignPanel(wxWindow* parent) ? m_viewport->toggle_sketch_construction_selection() : 0; if (n > 0) { m_construction->SetValue(!m_construction->GetValue()); // the mode did not move - m_status->SetForegroundColour(wxNullColour); - set_status(wxString::Format( + set_status(StatusKind::Info, wxString::Format( _L("Converted %d entit%s between construction and real geometry"), n, n == 1 ? "y" : "ies")); - m_status->Refresh(); return; } m_viewport->set_sketch_construction(m_construction->GetValue()); }); @@ -1585,67 +1604,38 @@ DesignPanel::DesignPanel(wxWindow* parent) // active (update_action_bar). Replaces the 13 per-card buttons + sketch Finish + Done. m_tb_action = new wxBoxSizer(wxHORIZONTAL); { - auto* ok = new wxButton(m_toolbar, wxID_ANY, _L("✓ Confirm")); - ok->SetForegroundColour(*wxWHITE); - ok->SetBackgroundColour(wxColour(0x00, 0x96, 0x88)); // Orca teal accent + auto* ok = new ::Button(m_toolbar, _L("Confirm")); + ok->SetStyle(ButtonStyle::Confirm, ButtonType::Window); ok->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { tool_confirm(); }); m_confirm_btns.push_back(ok); // refresh_preview greys this on an invalid candidate - auto* no = new wxButton(m_toolbar, wxID_ANY, _L("✗ Cancel")); + auto* no = new ::Button(m_toolbar, _L("Cancel")); + no->SetStyle(ButtonStyle::Regular, ButtonType::Window); no->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { tool_cancel(); }); m_tb_action->Add(ok, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6); m_tb_action->Add(no, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 10); } - // Persistent Undo/Redo group: always visible (not mode-gated like the tool groups), so - // history is reachable from Feature, Sketch and Constrain alike. These are momentary - // actions, so — unlike icon_btn — they are NOT registered in m_tool_btns and never take - // the teal active-tool highlight. They route to the SAME do_undo_redo as the keyboard - // Ctrl+Z / Ctrl+Shift+Z path, and are greyed by update_undo_redo_buttons(). - m_tb_history = new wxBoxSizer(wxHORIZONTAL); - { - auto hist_btn = [this, tb_bg, tb_hover](const char* icon, const wxString& tip) { - auto* b = new ScalableButton(m_toolbar, wxID_ANY, icon, "", wxSize(40, 40), - wxDefaultPosition, wxBU_EXACTFIT | wxBORDER_NONE, false, 34); - b->SetToolTip(tip); - b->SetBackgroundColour(tb_bg); - b->Bind(wxEVT_ENTER_WINDOW, [b, tb_hover](wxMouseEvent& e) { - if (b->IsEnabled()) { b->SetBackgroundColour(tb_hover); b->Refresh(); } e.Skip(); }); - b->Bind(wxEVT_LEAVE_WINDOW, [b, tb_bg](wxMouseEvent& e) { - b->SetBackgroundColour(tb_bg); b->Refresh(); e.Skip(); }); - return b; - }; - m_btn_undo = hist_btn("menu_undo", _L("Undo (Ctrl+Z)")); - m_btn_undo->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { do_undo_redo(false); }); - m_btn_redo = hist_btn("menu_redo", _L("Redo (Ctrl+Shift+Z)")); - m_btn_redo->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { do_undo_redo(true); }); - m_btn_undo->Enable(false); // nothing to undo/redo on a fresh document - m_btn_redo->Enable(false); - m_tb_history->Add(m_btn_undo, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 4); - m_tb_history->Add(m_btn_redo, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 4); - } - - // Document actions, left of Undo/Redo and always visible (not mode-gated like the tools). - // Same styling as the history pair: momentary actions, never the teal active-tool state. + // Document actions, always visible (not mode-gated like the tools): momentary actions, + // never the teal active-tool state. m_tb_doc = new wxBoxSizer(wxHORIZONTAL); - const wxColour tb_glyph_col = StateColor::darkModeColorFor(wxColour(0x36, 0x36, 0x36)); - const wxColour tb_commit_col(0x00, 0x96, 0x88); // Orca Confirm accent { - // Some Orca glyphs (toolbar_add_plate, toolbar_flatten) are drawn for a light toolbar and - // come out the same tone as this dark one — Commit was effectively invisible. Re-tint - // those: Commit in the teal accent it carries as the tab's primary action, the rest in - // the same grey the other toolbar glyphs resolve to. - auto doc_btn = [this, tb_bg, tb_hover, &tint](const char* icon, const wxString& tip, - const wxColour* glyph = nullptr) { - auto* b = new ScalableButton(m_toolbar, wxID_ANY, icon, "", wxSize(40, 40), - wxDefaultPosition, wxBU_EXACTFIT | wxBORDER_NONE, false, 34); - if (glyph != nullptr) - b->SetBitmap(tint(create_scaled_bitmap(icon, m_toolbar, 42), *glyph)); + // Some Orca glyphs (toolbar_flatten) are drawn for Prepare's light GL toolbar and come out + // the same tone as a dark ribbon. Those ship a "_dark" twin, picked per theme here (and + // again on a theme switch) the way GLToolbar picks it. + auto doc_btn = [this](const char* icon, const wxString& tip, bool has_dark_twin = false, + int cell_w = 40, int icon_px = 34) { + const std::string name(icon); + auto themed = [name, has_dark_twin] { return has_dark_twin && dp_dark() ? name + "_dark" : name; }; + auto* b = new ScalableButton(m_toolbar, wxID_ANY, themed(), "", FromDIP(wxSize(cell_w, 40)), + wxDefaultPosition, wxBU_EXACTFIT | wxBORDER_NONE, false, icon_px); + if (has_dark_twin) + m_icon_refresh.push_back([b, themed] { b->SetBitmap_(themed()); }); b->SetToolTip(tip); - b->SetBackgroundColour(tb_bg); - b->Bind(wxEVT_ENTER_WINDOW, [b, tb_hover](wxMouseEvent& e) { - if (b->IsEnabled()) { b->SetBackgroundColour(tb_hover); b->Refresh(); } e.Skip(); }); - b->Bind(wxEVT_LEAVE_WINDOW, [b, tb_bg](wxMouseEvent& e) { - b->SetBackgroundColour(tb_bg); b->Refresh(); e.Skip(); }); + b->SetBackgroundColour(dp_ribbon_bg()); + b->Bind(wxEVT_ENTER_WINDOW, [b](wxMouseEvent& e) { + if (b->IsEnabled()) { b->SetBackgroundColour(dp_ribbon_hover()); b->Refresh(); } e.Skip(); }); + b->Bind(wxEVT_LEAVE_WINDOW, [b](wxMouseEvent& e) { + b->SetBackgroundColour(dp_ribbon_bg()); b->Refresh(); e.Skip(); }); return b; }; auto add_doc = [this](ScalableButton* b) { @@ -1680,12 +1670,13 @@ DesignPanel::DesignPanel(wxWindow* parent) // These act on bodies / the view, so they ride in the feature group, in the slots // the user assigned them (9, 11bis, 16). auto* b_place = doc_btn("toolbar_flatten", _L("Place on Face (F) — lay the picked face on the bed"), - &tb_glyph_col); + true); b_place->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { place_on_face(); }); tb_slot["place"].push_back(b_place); auto* b_section = doc_btn("split_parts", _L("Section View — hide part of the model to see inside. " - "PageUp/PageDown move the plane; Delete removes it.")); + "PageUp/PageDown move the plane; Delete removes it."), + true); b_section->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { toggle_section_view(); }); tb_slot["section"].push_back(b_section); @@ -1694,12 +1685,73 @@ DesignPanel::DesignPanel(wxWindow* parent) m_section_flip_btn->Enable(false); // only usable while a section view is active tb_slot["flip"].push_back(m_section_flip_btn); - // Commit is the tab's primary action and sits far right, next to Confirm/Cancel. + // Commit is the tab's primary action and sits far right, next to Confirm/Cancel. A split + // button like Prepare's Slice: the face commits in the current mode, the chevron's + // dropdown only switches the mode. m_tb_commit = new wxBoxSizer(wxHORIZONTAL); - auto* b_commit = doc_btn("toolbar_add_plate", _L("Commit to Plate — send the solid to Prepare"), - &tb_commit_col); - b_commit->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_commit(); }); - m_tb_commit->Add(b_commit, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 4); + m_commit_btn = doc_btn("toolbar_add_plate", wxEmptyString); // face and tip: set_commit_mode + m_commit_btn->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_commit(); }); + auto* b_mode = doc_btn("drop_down", _L("Choose how Commit to Plate sends the bodies"), false, 16, 16); + m_tb_commit->Add(m_commit_btn, 0, wxALIGN_CENTER_VERTICAL); + m_tb_commit->Add(b_mode, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 4); + + // Same themed-DropDown shape as the FEATURE flyouts; row index == int(CommitMode). + struct CommitFlyout { + std::vector items; + wxLongLong closed_ms; // when the list last closed (wxGetLocalTimeMillis) + DropDown drop; // declared LAST: destroyed before the vector it references + CommitFlyout() : drop(items) {} + }; + auto cf = std::make_shared(); + cf->items.resize(2); + cf->items[int(CommitMode::Assembly)].text = _L("Commit to Plate"); + cf->items[int(CommitMode::Assembly)].tip = _L("All bodies become one object with a part per body, " + "keeping their relative positions"); + cf->items[int(CommitMode::Bodies)].text = _L("Commit to Plate (as bodies)"); + cf->items[int(CommitMode::Bodies)].tip = _L("Each body becomes its own object, placed on its own"); + CommitFlyout* cp = cf.get(); + auto refresh_rows = [this, cp] { + for (size_t i = 0; i < cp->items.size(); ++i) + cp->items[i].icon = create_scaled_bitmap(commit_icon(i == size_t(CommitMode::Bodies)), m_toolbar, 18); + }; + refresh_rows(); + cp->drop.Create(m_commit_btn); + cp->drop.SetUseContentWidth(true, false); + cp->drop.Invalidate(true); + m_commit_drop = &cp->drop; + cp->drop.Bind(wxEVT_COMBOBOX, [this](wxCommandEvent& e) { + const bool bodies = e.GetInt() == int(CommitMode::Bodies); + set_commit_mode(bodies ? CommitMode::Bodies : CommitMode::Assembly); + wxGetApp().app_config->set("design_commit_mode", bodies ? "bodies" : "assembly"); + }); + cp->drop.Bind(EVT_DISMISS, [cp](wxCommandEvent&) { cp->closed_ms = wxGetLocalTimeMillis(); }); + b_mode->Bind(wxEVT_BUTTON, [this, b_mode, cp](wxCommandEvent&) { + // A click on ▾ while the list is open closes it, and that same click reaches this + // button too (on MSW the list's deferred dismissal runs before the button's mouse-up): + // it must not reopen the list. + if (cp->drop.IsShown() || wxGetLocalTimeMillis() - cp->closed_ms < 300) + return; + // A fresh content measure before Popup(), as the FEATURE flyouts do. Invalidate(true) + // also clears the selection, which is the check on the current mode: put it back. + m_commit_drop->Invalidate(true); + m_commit_drop->SetUseContentWidth(false, false); + m_commit_drop->SetUseContentWidth(true, false); + m_commit_drop->SetSelection(int(m_commit_mode)); + // Right-aligned under the split button, which sits at the ribbon's far right. + wxPoint pos = b_mode->ClientToScreen(wxPoint(b_mode->GetSize().x, -6)); + pos.x -= m_commit_drop->GetSize().x; + m_commit_drop->Position(pos, wxSize(0, b_mode->GetSize().y + b_mode->FromDIP(12))); + m_commit_drop->Popup(); + }); + m_icon_refresh.push_back([this, refresh_rows] { + refresh_rows(); + m_commit_drop->Invalidate(true); + set_commit_mode(m_commit_mode); // the face's theme twin, and the check Invalidate cleared + }); + m_flyout_keepalive.push_back(cf); + // Assembly unless the user picked "as bodies": it is the mode that keeps the design as drawn. + set_commit_mode(wxGetApp().app_config->get("design_commit_mode") == "bodies" ? CommitMode::Bodies + : CommitMode::Assembly); } // Feature-group layout, in the requested left-to-right order. @@ -1726,8 +1778,6 @@ DesignPanel::DesignPanel(wxWindow* parent) tbrow->AddSpacer(8); tbrow->Add(m_tb_doc, 0, wxALIGN_CENTER_VERTICAL | wxTOP | wxBOTTOM, 5); add_sep(tbrow); - tbrow->Add(m_tb_history, 0, wxALIGN_CENTER_VERTICAL | wxTOP | wxBOTTOM, 5); - add_sep(tbrow); tbrow->Add(m_tb_feature, 0, wxALIGN_CENTER_VERTICAL | wxTOP | wxBOTTOM, 5); tbrow->Add(m_tb_sketch, 0, wxALIGN_CENTER_VERTICAL | wxTOP | wxBOTTOM, 5); tbrow->Add(m_tb_relations, 0, wxALIGN_CENTER_VERTICAL | wxTOP | wxBOTTOM, 5); @@ -1736,15 +1786,18 @@ DesignPanel::DesignPanel(wxWindow* parent) tbrow->Add(m_tb_action, 0, wxALIGN_CENTER_VERTICAL | wxTOP | wxBOTTOM, 5); tbrow->AddSpacer(8); m_toolbar->SetSizer(tbrow); + build_phase("toolbar"); // Apply the body gates once now: an empty document is exactly the state the bug was reported // in, and feed_bodies() has not run yet on a fresh tab. update_body_gates(); // Onshape-style dialog-card header: feature icon + bold title. out receives // the title control so open_tool() can retitle it per feature. - auto card_header = [](wxWindow* card, const char* icon, const wxString& title, wxStaticText*& out) -> wxSizer* { + auto card_header = [this](wxWindow* card, const char* icon, const wxString& title, wxStaticText*& out) -> wxSizer* { auto* h = new wxBoxSizer(wxHORIZONTAL); auto* ic = new wxStaticBitmap(card, wxID_ANY, create_scaled_bitmap(icon, card, 18)); + m_icon_refresh.push_back([ic, card, name = std::string(icon)] { + ic->SetBitmap(create_scaled_bitmap(name, card, 18)); }); out = new wxStaticText(card, wxID_ANY, title); out->SetFont(Label::Head_14); // Orca shared HarmonyOS card-title font h->Add(ic, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 8); @@ -1815,7 +1868,7 @@ DesignPanel::DesignPanel(wxWindow* parent) mform->Add(m_move_axis, 0, wxEXPAND); m_move_angle = make_spin(m_cards, 0.0, -360.0, 360.0); - mform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Angle °")), 0, wxALIGN_CENTER_VERTICAL); + mform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Angle (°)")), 0, wxALIGN_CENTER_VERTICAL); mform->Add(spin_frame(m_move_angle), 0, wxEXPAND); for (wxSpinCtrlDouble* sp : { m_move_dx, m_move_dy, m_move_dz, m_move_angle }) @@ -1871,7 +1924,7 @@ DesignPanel::DesignPanel(wxWindow* parent) eform->Add(spin_frame(m_distance2), 0, wxEXPAND); m_taper = make_spin(m_cards, 0.0, -89.0, 89.0); // draft angle (deg) - eform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Taper °")), 0, wxALIGN_CENTER_VERTICAL); + eform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Taper (°)")), 0, wxALIGN_CENTER_VERTICAL); eform->Add(spin_frame(m_taper), 0, wxEXPAND); m_mode = make_combo(m_cards); @@ -2034,8 +2087,9 @@ DesignPanel::DesignPanel(wxWindow* parent) m_thread_internal = new CheckBox(m_cards); m_thread_internal->SetValue(false); - // External rod uses the major radius; an internal tapped bore uses the minor - // (tap-drill) radius — re-derive the nominal radius when the role flips. + // The diameter field is the NOMINAL (major) diameter either way; what the role changes is + // the depth — an internal thread's groove runs from the tap-drill (minor) diameter out to + // the major one, an external one's from the major in to the root — so re-derive it. m_thread_internal->Bind(wxEVT_TOGGLEBUTTON, [this](wxCommandEvent& e) { apply_thread_standard(); e.Skip(); @@ -2063,7 +2117,7 @@ DesignPanel::DesignPanel(wxWindow* parent) auto* rform = two_col_form(); m_revolve_angle = make_spin(m_cards, 360.0, 1.0, 360.0); - rform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Angle °")), 0, wxALIGN_CENTER_VERTICAL); + rform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Angle (°)")), 0, wxALIGN_CENTER_VERTICAL); rform->Add(spin_frame(m_revolve_angle), 0, wxEXPAND); m_revolve_axis = make_combo(m_cards); @@ -2074,12 +2128,12 @@ DesignPanel::DesignPanel(wxWindow* parent) rform->Add(m_revolve_axis, 0, wxEXPAND); m_revolve_mode = make_combo(m_cards); - m_revolve_mode->Append(_L("New")); - m_revolve_mode->Append(_L("Add")); + m_revolve_mode->Append(_L("New body")); // same four words as Extrude + m_revolve_mode->Append(_L("Join")); m_revolve_mode->Append(_L("Cut")); m_revolve_mode->Append(_L("Intersect")); m_revolve_mode->SetSelection(0); - rform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Mode")), 0, wxALIGN_CENTER_VERTICAL); + rform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Result")), 0, wxALIGN_CENTER_VERTICAL); rform->Add(m_revolve_mode, 0, wxEXPAND); m_revolve_flip = new CheckBox(m_cards); @@ -2104,12 +2158,12 @@ DesignPanel::DesignPanel(wxWindow* parent) sform->Add(m_sweep_path, 0, wxEXPAND); m_sweep_mode = make_combo(m_cards); - m_sweep_mode->Append(_L("New")); - m_sweep_mode->Append(_L("Add")); + m_sweep_mode->Append(_L("New body")); // same four words as Extrude + m_sweep_mode->Append(_L("Join")); m_sweep_mode->Append(_L("Cut")); m_sweep_mode->Append(_L("Intersect")); m_sweep_mode->SetSelection(0); - sform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Mode")), 0, wxALIGN_CENTER_VERTICAL); + sform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Result")), 0, wxALIGN_CENTER_VERTICAL); sform->Add(m_sweep_mode, 0, wxEXPAND); m_box_sweep->Add(sform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12); @@ -2146,7 +2200,7 @@ DesignPanel::DesignPanel(wxWindow* parent) pform->Add(m_pattern_dir, 0, wxEXPAND); m_pattern_angle = make_spin(m_cards, 360.0, 1.0, 360.0); - pform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Total angle°")), 0, wxALIGN_CENTER_VERTICAL); + pform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Total angle (°)")), 0, wxALIGN_CENTER_VERTICAL); pform->Add(spin_frame(m_pattern_angle), 0, wxEXPAND); m_box_pattern->Add(pform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12); @@ -2161,8 +2215,8 @@ DesignPanel::DesignPanel(wxWindow* parent) auto* bform = two_col_form(); m_bool_op = make_combo(m_cards); - m_bool_op->Append(_L("Union (join)")); - m_bool_op->Append(_L("Subtract (cut)")); + m_bool_op->Append(_L("Join")); // the Extrude result word; the offer row says the same + m_bool_op->Append(_L("Subtract")); m_bool_op->Append(_L("Intersect")); m_bool_op->SetSelection(0); m_bool_op->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { refresh_preview(); }); @@ -2232,7 +2286,7 @@ DesignPanel::DesignPanel(wxWindow* parent) m_box_insert->Add(card_header(m_cards, "design_text", _L("Insert"), m_hdr_insert), 0, wxLEFT | wxRIGHT | wxTOP, 12); m_box_insert->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8); m_box_insert->Add(new wxStaticText(m_cards, wxID_ANY, - _L("Drag a corner to size, the centre to move.\nConfirm or Cancel in the toolbar above.")), + _L("Drag a corner to size, the center to move.\nConfirm or Cancel in the toolbar above.")), 0, wxLEFT | wxRIGHT | wxBOTTOM, 12); cards->Add(m_box_insert, 0, wxEXPAND); @@ -2268,7 +2322,7 @@ DesignPanel::DesignPanel(wxWindow* parent) plform->Add(spin_frame(m_plane_offset), 0, wxEXPAND); m_plane_tilt = make_spin(m_cards, 0.0, -180.0, 180.0); - plform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Angle°")), 0, wxALIGN_CENTER_VERTICAL); + plform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Angle (°)")), 0, wxALIGN_CENTER_VERTICAL); plform->Add(spin_frame(m_plane_tilt), 0, wxEXPAND); m_plane_tilt_axis = make_combo(m_cards); @@ -2279,8 +2333,9 @@ DesignPanel::DesignPanel(wxWindow* parent) plform->Add(m_plane_tilt_axis, 0, wxEXPAND); // Contextual reference picks: arm a target, then click a solid face/edge in the canvas. - auto pick_row = [&](const wxString& label, wxButton*& btn, wxStaticText*& lbl, PlanePick target) { - btn = new wxButton(m_cards, wxID_ANY, label); + auto pick_row = [&](const wxString& label, ::Button*& btn, wxStaticText*& lbl, PlanePick target) { + btn = new ::Button(m_cards, label); + btn->SetStyle(ButtonStyle::Regular, ButtonType::Parameter); lbl = new wxStaticText(m_cards, wxID_ANY, _L("(none)")); btn->Bind(wxEVT_BUTTON, [this, target](wxCommandEvent&) { arm_plane_pick(target); }); plform->Add(btn); @@ -2308,19 +2363,19 @@ DesignPanel::DesignPanel(wxWindow* parent) m_box_loft->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8); m_box_loft->Add(new wxStaticText(m_cards, wxID_ANY, _L("Profiles (check 2+, in order):")), 0, wxLEFT | wxRIGHT | wxTOP, 12); - m_loft_list = new wxCheckListBox(m_cards, wxID_ANY, wxDefaultPosition, wxSize(-1, 120)); + m_loft_list = new wxCheckListBox(m_cards, wxID_ANY, wxDefaultPosition, FromDIP(wxSize(-1, 120))); m_loft_list->Bind(wxEVT_CHECKLISTBOX, [this](wxCommandEvent&) { refresh_preview(); }); m_box_loft->Add(m_loft_list, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12); { auto* lform = two_col_form(); m_loft_mode = make_combo(m_cards); - m_loft_mode->Append(_L("New")); - m_loft_mode->Append(_L("Add")); + m_loft_mode->Append(_L("New body")); // same four words as Extrude + m_loft_mode->Append(_L("Join")); m_loft_mode->Append(_L("Cut")); m_loft_mode->Append(_L("Intersect")); m_loft_mode->SetSelection(0); - lform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Mode")), 0, wxALIGN_CENTER_VERTICAL); + lform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Result")), 0, wxALIGN_CENTER_VERTICAL); lform->Add(m_loft_mode, 0, wxEXPAND); m_box_loft->Add(lform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12); @@ -2359,7 +2414,7 @@ DesignPanel::DesignPanel(wxWindow* parent) { auto* rform = two_col_form(); m_surf_revolve_angle = make_spin(m_cards, 360.0, 1.0, 360.0); - rform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Angle °")), 0, wxALIGN_CENTER_VERTICAL); + rform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Angle (°)")), 0, wxALIGN_CENTER_VERTICAL); rform->Add(spin_frame(m_surf_revolve_angle), 0, wxEXPAND); m_surf_revolve_axis = make_combo(m_cards); m_surf_revolve_axis->Append(_L("Plane X")); @@ -2380,7 +2435,7 @@ DesignPanel::DesignPanel(wxWindow* parent) m_box_surf_loft->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8); m_box_surf_loft->Add(new wxStaticText(m_cards, wxID_ANY, _L("Profiles (check 2+, in order):")), 0, wxLEFT | wxRIGHT | wxTOP, 12); - m_surf_loft_list = new wxCheckListBox(m_cards, wxID_ANY, wxDefaultPosition, wxSize(-1, 120)); + m_surf_loft_list = new wxCheckListBox(m_cards, wxID_ANY, wxDefaultPosition, FromDIP(wxSize(-1, 120))); m_surf_loft_list->Bind(wxEVT_CHECKLISTBOX, [this](wxCommandEvent&) { refresh_preview(); }); m_box_surf_loft->Add(m_surf_loft_list, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12); m_surf_loft_ruled = new CheckBox(m_cards); @@ -2620,7 +2675,7 @@ DesignPanel::DesignPanel(wxWindow* parent) m_rib_sketch->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { refresh_preview(); }); rform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Sketch")), 0, wxALIGN_CENTER_VERTICAL); rform->Add(m_rib_sketch, 0, wxEXPAND); - m_rib_entity = new wxSpinCtrl(m_cards, wxID_ANY, "", wxDefaultPosition, wxSize(90, -1), + m_rib_entity = new wxSpinCtrl(m_cards, wxID_ANY, "", wxDefaultPosition, FromDIP(wxSize(90, -1)), wxSP_ARROW_KEYS | wxBORDER_SIMPLE); m_rib_entity->SetRange(0, 999); m_rib_entity->SetValue(0); @@ -2677,22 +2732,19 @@ DesignPanel::DesignPanel(wxWindow* parent) m_del_face_body->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { refresh_preview(); }); dform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Body")), 0, wxALIGN_CENTER_VERTICAL); dform->Add(m_del_face_body, 0, wxEXPAND); - m_del_face_add_btn = new wxButton(m_cards, wxID_ANY, _L("Add picked face")); + m_del_face_add_btn = new ::Button(m_cards, _L("Add picked face")); + m_del_face_add_btn->SetStyle(ButtonStyle::Regular, ButtonType::Parameter); m_del_face_add_btn->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { // Say why nothing happened. Clicking with no face picked used to be a silent no-op, // which is indistinguishable from the button being broken. if (m_sel_solid_face < 0) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Click a face on the body first, then Add picked face")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Click a face on the body first, then Add picked face")); return; } // Adding the same face twice puts a duplicate id in delete_faces, which the // defeaturing algorithm has no reason to cope with. Re-clicking is a no-op, not an error. if (std::find(m_del_faces.begin(), m_del_faces.end(), m_sel_solid_face) != m_del_faces.end()) { - m_status->SetForegroundColour(wxNullColour); - set_status(wxString::Format(_L("Face %d is already in the list"), m_sel_solid_face)); - m_status->Refresh(); + set_status(StatusKind::Info, wxString::Format(_L("Face %d is already in the list"), m_sel_solid_face)); return; } { @@ -2700,7 +2752,7 @@ DesignPanel::DesignPanel(wxWindow* parent) wxString s; for (size_t i = 0; i < m_del_faces.size(); ++i) { if (i > 0) s += ", "; - s += wxString::Format("Face %d", m_del_faces[i]); + s += wxString::Format(_L("Face %d"), m_del_faces[i]); } m_del_face_list->SetLabel(s.empty() ? _L("(none)") : s); m_del_face_list->GetParent()->Layout(); @@ -2778,8 +2830,9 @@ DesignPanel::DesignPanel(wxWindow* parent) auto* axform = two_col_form(); - auto ax_pick = [&](const wxString& label, wxButton*& btn, wxStaticText*& lbl, AxisPick target) { - btn = new wxButton(m_cards, wxID_ANY, label); + auto ax_pick = [&](const wxString& label, ::Button*& btn, wxStaticText*& lbl, AxisPick target) { + btn = new ::Button(m_cards, label); + btn->SetStyle(ButtonStyle::Regular, ButtonType::Parameter); lbl = new wxStaticText(m_cards, wxID_ANY, _L("(none)")); btn->Bind(wxEVT_BUTTON, [this, target](wxCommandEvent&) { arm_axis_pick(target); }); axform->Add(btn); @@ -2823,7 +2876,7 @@ DesignPanel::DesignPanel(wxWindow* parent) // --- CoordSys (datum coordinate system: point + orthonormal frame) --- m_box_coordsys = new wxBoxSizer(wxVERTICAL); - m_box_coordsys->Add(card_header(m_cards, "design_point", _L("Coord Sys"), m_hdr_coordsys), 0, wxLEFT | wxRIGHT | wxTOP, 12); + m_box_coordsys->Add(card_header(m_cards, "design_point", _L("Coordinate system"), m_hdr_coordsys), 0, wxLEFT | wxRIGHT | wxTOP, 12); m_box_coordsys->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8); { m_coordsys_type = make_combo(m_cards); @@ -2858,8 +2911,9 @@ DesignPanel::DesignPanel(wxWindow* parent) csform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Z")), 0, wxALIGN_CENTER_VERTICAL); csform->Add(spin_frame(m_cs_z), 0, wxEXPAND); - auto cs_pick = [&](const wxString& label, wxButton*& btn, wxStaticText*& lbl, CoordSysPick target) { - btn = new wxButton(m_cards, wxID_ANY, label); + auto cs_pick = [&](const wxString& label, ::Button*& btn, wxStaticText*& lbl, CoordSysPick target) { + btn = new ::Button(m_cards, label); + btn->SetStyle(ButtonStyle::Regular, ButtonType::Parameter); lbl = new wxStaticText(m_cards, wxID_ANY, _L("(none)")); btn->Bind(wxEVT_BUTTON, [this, target](wxCommandEvent&) { arm_coordsys_pick(target); }); csform->Add(btn); @@ -2868,7 +2922,8 @@ DesignPanel::DesignPanel(wxWindow* parent) cs_pick(_L("Pick Face"), m_cs_pick_face, m_cs_face_lbl, CoordSysPick::Face); // ponytail: edge pick for CoordSys with rotation-direction hint - m_cs_pick_edge = new wxButton(m_cards, wxID_ANY, _L("Edge (sets in-plane direction)")); + m_cs_pick_edge = new ::Button(m_cards, _L("Edge (sets in-plane direction)")); + m_cs_pick_edge->SetStyle(ButtonStyle::Regular, ButtonType::Parameter); m_cs_edge_lbl = new wxStaticText(m_cards, wxID_ANY, _L("(none)")); m_cs_pick_edge->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { arm_coordsys_pick(CoordSysPick::Edge); }); csform->Add(m_cs_pick_edge); @@ -2893,7 +2948,7 @@ DesignPanel::DesignPanel(wxWindow* parent) } cards->Add(m_box_coordsys, 0, wxEXPAND); - // --- Mate (assembly: align two CoordSys features) --- + // --- Mate (assembly: align two coordinate systems) --- m_box_mate = new wxBoxSizer(wxVERTICAL); m_box_mate->Add(card_header(m_cards, "design_c_coincident", _L("Mate"), m_hdr_mate), 0, wxLEFT | wxRIGHT | wxTOP, 12); m_box_mate->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8); @@ -2912,11 +2967,11 @@ DesignPanel::DesignPanel(wxWindow* parent) // Populate the CoordSys pickers on open; show "A (fixed)" and "B (moves)" combos. m_mate_cs_a = make_combo(m_cards); - mform->Add(new wxStaticText(m_cards, wxID_ANY, _L("CS A (fixed)")), 0, wxALIGN_CENTER_VERTICAL); + mform->Add(new wxStaticText(m_cards, wxID_ANY, _L("A (fixed)")), 0, wxALIGN_CENTER_VERTICAL); mform->Add(m_mate_cs_a, 0, wxEXPAND); m_mate_cs_b = make_combo(m_cards); - mform->Add(new wxStaticText(m_cards, wxID_ANY, _L("CS B (moves)")), 0, wxALIGN_CENTER_VERTICAL); + mform->Add(new wxStaticText(m_cards, wxID_ANY, _L("B (moves)")), 0, wxALIGN_CENTER_VERTICAL); mform->Add(m_mate_cs_b, 0, wxEXPAND); m_offset_label = new wxStaticText(m_cards, wxID_ANY, _L("Offset")); @@ -3012,8 +3067,10 @@ DesignPanel::DesignPanel(wxWindow* parent) m_box_expr->Add(eform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12); auto* brow = new wxBoxSizer(wxHORIZONTAL); - m_expr_set_btn = new wxButton(m_cards, wxID_ANY, _L("Set"), wxDefaultPosition, wxSize(50, 24)); - m_expr_clear_btn = new wxButton(m_cards, wxID_ANY, _L("Clear"), wxDefaultPosition, wxSize(50, 24)); + m_expr_set_btn = new ::Button(m_cards, _L("Set")); + m_expr_clear_btn = new ::Button(m_cards, _L("Clear")); + m_expr_set_btn->SetStyle(ButtonStyle::Confirm, ButtonType::Parameter); + m_expr_clear_btn->SetStyle(ButtonStyle::Regular, ButtonType::Parameter); m_expr_set_btn->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_set_expr(); }); m_expr_clear_btn->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_clear_expr(); }); brow->Add(m_expr_set_btn, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6); @@ -3040,7 +3097,7 @@ DesignPanel::DesignPanel(wxWindow* parent) // formats per the user locale (comma) with no clean override, so we own the // formatting here to guarantee international '.' decimals. m_value_input = new wxTextCtrl(m_cards, wxID_ANY, "", wxDefaultPosition, - wxSize(90, -1), wxTE_PROCESS_ENTER); + FromDIP(wxSize(90, -1)), wxTE_PROCESS_ENTER); m_value_input->Bind(wxEVT_TEXT_ENTER, [this](wxCommandEvent&) { confirm_value(); }); vrow->Add(new wxStaticText(m_cards, wxID_ANY, _L("Value")), 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 8); vrow->Add(m_value_input, 0, wxALIGN_CENTER_VERTICAL); @@ -3093,8 +3150,8 @@ DesignPanel::DesignPanel(wxWindow* parent) t->Wrap(240); // Feature tree card. Same idiom as Prepare's sections (icon + Head_14 title + rule) via the - // shared card_header helper, instead of the bare micro-label this used to be; the row-edit - // actions live in the header, as Prepare puts its section actions. + // shared card_header helper, instead of the bare micro-label this used to be. What acts on one + // feature sits on that feature's row; the header keeps reordering and the interference check. m_tree_box = make_card(m_form); auto* tree_inner = new wxBoxSizer(wxVERTICAL); m_tree_box->SetSizer(tree_inner); @@ -3108,77 +3165,63 @@ DesignPanel::DesignPanel(wxWindow* parent) FromDIP(SidebarProps::ContentMargin())); tree_inner->Add(new wxStaticLine(m_tree_box), 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, FromDIP(SidebarProps::TitlebarMargin())); - m_tree = new wxTreeCtrl(m_tree_box, wxID_ANY, wxDefaultPosition, wxSize(-1, 64), - wxTR_HIDE_ROOT | wxTR_SINGLE | wxTR_NO_LINES | - wxTR_FULL_ROW_HIGHLIGHT | wxBORDER_SIMPLE | wxTR_EDIT_LABELS); - if (!dp_dark()) m_tree->SetBackgroundColour(dp_panel_bg()); - // Per-feature-type icons (indices match tree_icon_for): sketch/extrude/dressup/hole/thread. - m_tree_images = new wxImageList(16, 16); - m_tree_images->Add(create_scaled_bitmap("design_sketch", nullptr, 16)); // 0 Sketch - m_tree_images->Add(create_scaled_bitmap("design_extrude", nullptr, 16)); // 1 Extrude - m_tree_images->Add(create_scaled_bitmap("design_dressup", nullptr, 16)); // 2 Fillet/Chamfer - m_tree_images->Add(create_scaled_bitmap("design_hole", nullptr, 16)); // 3 Hole - m_tree_images->Add(create_scaled_bitmap("design_thread", nullptr, 16)); // 4 Thread - m_tree_images->Add(create_scaled_bitmap("design_shell", nullptr, 16)); // 5 Shell - m_tree->AssignImageList(m_tree_images); + // Sized to its rows, so a short history wastes no block, and scrolling past 9. + m_tree = new DesignRowList(m_tree_box, 9); + m_tree->SetBackgroundColour(dp_panel_bg()); tree_inner->Add(m_tree, 0, wxEXPAND | wxALL, 12); - // Selecting a body-producing feature (Extrude/Fillet/Chamfer/Hole/Thread) in the - // tree highlights the solid in the viewport; a Sketch row clears the highlight - // (its face is already shown via the persistent sketch overlay). - m_tree->Bind(wxEVT_TREE_SEL_CHANGED, [this](wxTreeEvent&) { + // 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] { if (!m_viewport) return; - // Bodies live in the Parts list now; picking a feature here drops any body selection - // so the two lists can't both claim to be "the target". - // ONLY when this tree actually has a selection. These two lists clear each other's - // selection so that "the target" is never ambiguous, and that was harmless while both - // calls were UnselectAll() — a no-op on a wxTR_SINGLE tree. Now that Unselect() really - // clears, the pair became a loop: clicking a body row runs apply_body_row, which calls - // m_tree->Unselect(), which fires THIS handler, which cleared the body row the user had - // just clicked. The guard keeps the mutual-exclusion and drops the echo. - if (m_parts && tree_selection() != wxNOT_FOUND) m_parts->Unselect(); + // Picking a feature drops any body selection, so the two lists never both claim to be + // "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(); const int sel = tree_selection(); - const bool body = (sel >= 0 && sel < int(m_doc.features.size()) && - m_doc.features[sel].type != CadFeatureType::Sketch && - !m_doc.body.IsNull()); - m_viewport->set_body_highlight(body); + // 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. + if (sel != wxNOT_FOUND && m_active == Tool::None) + drop_solid_pick(); // not the loop pick: clicking a sketch loop selects its row + request_feature_highlight(); // A conflicting mate says WHY on selection, and names the one action that resolves it. // Suppress is the generic per-feature enable toggle (the eye), so the answer is already // one click away on a row the user has just selected — the message points at it instead // of describing a problem with no way out. if (const std::string* why = (sel >= 0) ? mate_conflict_reason(sel) : nullptr) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(wxString::FromUTF8(*why) + _L(" — the eye suppresses this mate")); - m_status->Refresh(); + set_status(StatusKind::Error, wxString::FromUTF8(*why) + _L(" — the eye suppresses this mate")); } else if (sel >= 0 && sel < int(m_doc.features.size())) { // Name the two gestures the row supports, because neither is visible on it. - m_status->SetForegroundColour(wxNullColour); - set_status(wxString::Format( + set_status(StatusKind::Info, wxString::Format( _L("%s selected — F2 or right-click renames it, double-click edits it"), wxString::FromUTF8(m_doc.features[sel].name))); - m_status->Refresh(); } - }); + }; // Double-click a row = Edit, the same gesture that re-opens a committed sketch on the canvas. - // Without it the row only highlights and the feature looks dead until the user finds the - // Edit button in the section header. - m_tree->Bind(wxEVT_TREE_ITEM_ACTIVATED, [this](wxTreeEvent&) { on_edit_feature(); }); + m_tree->on_activate = [this] { on_edit_feature(); }; - // Right-click a row: the three things a row can do. Renaming had no discoverable route at - // all — the header pencil is Edit, a double-click ACTIVATES the row and is also Edit (the - // "slow double-click renames" the old comment promised does not survive wxGTK, which fires - // ITEM_ACTIVATED first), none of the seven header icons renames, and F2 is a function key - // nothing announces. A user who wants to name a sketch tries the row, and now the row - // answers. rename. - m_tree->Bind(wxEVT_TREE_ITEM_RIGHT_CLICK, [this](wxTreeEvent& e) { - m_tree->SelectItem(e.GetItem()); // right-click targets what it points at - const int sel = tree_selection(); - if (sel == wxNOT_FOUND) return; - // EVERYTHING A ROW CAN DO, in one place. The header icons stay as a quick bar, but the - // menu is the reference: the element you click answers with what applies to it, and a - // menu grows without spending an icon nobody recognises. Split into what the row IS - // (name, contents), where it SITS (order, visibility) and what removes it. + // 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) { + if (m_parts) m_parts->unselect(); + switch (id) { + case RowEdit: on_edit_feature(); break; + case RowVisibility: on_toggle_visibility(); break; + case RowDelete: on_delete_feature(); break; + } + }; + + // Right-click a row: everything a row can do. Renaming had no discoverable route at all — a + // double-click is Edit, and F2 is a function key nothing announces. A user who wants to name + // a sketch tries the row, and the row answers. + m_tree->on_menu = [this](int row, const wxPoint& screen) { + // EVERYTHING A ROW CAN DO, in one place. The row's icons are the quick bar, but the menu + // is the reference: the element you click answers with what applies to it, and a menu + // grows without spending an icon nobody recognises. Split into what the row IS (name, + // contents), where it SITS (order, visibility) and what removes it. wxMenu menu; const int id_rename = wxWindow::NewControlId(); const int id_edit = wxWindow::NewControlId(); @@ -3192,8 +3235,8 @@ DesignPanel::DesignPanel(wxWindow* parent) // Scale artwork acts on THIS feature's imported outline, so it belongs to the row and // is offered only where it means something. It used to hide inside the header's Move // button, which otherwise moved a body — two different subjects on one icon. - const bool art = sel < int(m_doc.features.size()) && - !m_doc.features[sel].imported_regions.empty(); + const bool art = row < int(m_doc.features.size()) && + !m_doc.features[row].imported_regions.empty(); if (art) menu.Append(id_art, _L("Scale artwork")); menu.AppendSeparator(); menu.Append(id_up, _L("Move up")); @@ -3201,104 +3244,48 @@ DesignPanel::DesignPanel(wxWindow* parent) menu.Append(id_vis, _L("Show / hide")); menu.AppendSeparator(); menu.Append(id_del, _L("Delete")); - menu.Bind(wxEVT_MENU, [this](wxCommandEvent&) { - const int row = tree_selection(); - if (row != wxNOT_FOUND && row < int(m_tree_items.size())) - m_tree->EditLabel(m_tree_items[row]); - }, id_rename); + menu.Bind(wxEVT_MENU, [this](wxCommandEvent&) { m_verb_actions["btn:rename"](); }, id_rename); menu.Bind(wxEVT_MENU, [this](wxCommandEvent&) { on_edit_feature(); }, id_edit); menu.Bind(wxEVT_MENU, [this](wxCommandEvent&) { on_move_feature(-1); }, id_up); menu.Bind(wxEVT_MENU, [this](wxCommandEvent&) { on_move_feature(+1); }, id_down); menu.Bind(wxEVT_MENU, [this](wxCommandEvent&) { on_toggle_visibility(); }, id_vis); menu.Bind(wxEVT_MENU, [this](wxCommandEvent&) { on_delete_feature(); }, id_del); if (art) - menu.Bind(wxEVT_MENU, [this](wxCommandEvent&) { - const int row = tree_selection(); - if (row != wxNOT_FOUND) on_transform_imported(row); - }, id_art); - m_tree->PopupMenu(&menu); - }); - - // In-place rename of a feature row (slow double-click, the offer's Rename verb, or F2). - // The name is what makes a tree of eight sketches readable, and the tree row IS the object — - // so renaming belongs on the row, not in a side-panel field. Bodies are computed results, - // not named features, so a body row must never open an editor (it cannot, they live in the - // Parts list, but the guard keeps a future change from slipping a body into this tree). - auto item_index = [this](const wxTreeItemId& it) -> int { - for (size_t i = 0; i < m_tree_items.size(); ++i) - if (m_tree_items[i] == it) return int(i); - return wxNOT_FOUND; + menu.Bind(wxEVT_MENU, [this, row](wxCommandEvent&) { on_transform_imported(row); }, id_art); + m_tree->PopupMenu(&menu, m_tree->ScreenToClient(screen)); }; - m_tree->Bind(wxEVT_TREE_BEGIN_LABEL_EDIT, [this, item_index](wxTreeEvent& e) { - // The event's item is the authority for WHAT is being edited; tree_selection() is not, - // because the editor can open on a row that is not the current selection. A row that is - // not a feature (a body, or a stray id) gets the edit vetoed before it can take a name. - if (tree_body_selection() >= 0 || item_index(e.GetItem()) == wxNOT_FOUND) { e.Veto(); return; } - e.Skip(); - }); - m_tree->Bind(wxEVT_TREE_END_LABEL_EDIT, [this, item_index](wxTreeEvent& e) { - if (e.IsEditCancelled()) return; - const int idx = item_index(e.GetItem()); - if (idx == wxNOT_FOUND) { e.Veto(); return; } - wxString label = e.GetLabel(); - label.Trim(true).Trim(false); - if (label.empty()) { e.Veto(); return; } // a nameless row is worse than a badly named one - m_doc.features[idx].name = std::string(label.ToUTF8().data()); - sync_recipe_to_model(); // the name is part of the recipe, so the save path persists it - e.Skip(); // let wx finish applying the label to the item it is holding - // REBUILD LATER, NOT NOW. refresh_tree() deletes and re-creates every wxTreeItemId, and - // we are inside wx's own END_LABEL_EDIT dispatch for one of them — destroying it here - // frees the item the caller is still using and takes the process down. Measured: typing - // a name and pressing Enter killed the app outright, with the keystrokes traced and no - // trace for the Return. Deferring to the next event-loop turn lets wx finish first. - CallAfter([this] { refresh_tree(); }); - }); - // Feature-tree edit actions: act on the selected feature (delete / reorder). These sit in the - // card header (Prepare puts its section actions there too) rather than on a loose row below. + // In-place rename of a feature row (F2, the offer's Rename verb, or the row's menu). The name + // is what makes a tree of eight sketches readable, and the tree row IS the object — so + // renaming belongs on the row, not in a side-panel field. The list hands the name over after + // its editor's events have finished, so rebuilding the rows here is safe. + m_tree->on_rename = [this](int row, const wxString& name) { + if (row < 0 || row >= int(m_doc.features.size())) return; + m_doc.features[row].name = std::string(name.ToUTF8().data()); + refresh_tree(); // which syncs the recipe, so the save path persists the name + }; + + // The header keeps what is not one row's own action: reordering, which moves the selected + // feature among the others, and the interference check, which reports on every body. { wxBoxSizer* trow = m_hdr_tree_row; - auto edit_btn = [this](const char* icon, const wxString& tip) { - // Header-sized: reads as a section action, not a primary control. - auto* b = new ScalableButton(m_tree_box, wxID_ANY, icon, "", wxSize(24, 24), - wxDefaultPosition, wxBU_EXACTFIT | wxBORDER_NONE, false, 20); - b->SetToolTip(tip); - return b; - }; - auto* edit = edit_btn("design_edit", _L("Edit")); - edit->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_edit_feature(); }); // NO Move here. Moving a body is not a feature-row action — this header sits over the // FEATURE tree, and the button had to guess its subject from whatever happened to be // selected, answering a feature row with an instruction about bodies. It lives where a - // body lives: the Bodies card's own action row, and the offer for a selected body. + // body lives: on each body's row in the Bodies list, and in the offer for a selected body. // Scaling imported artwork, which shared this button, moved to the row's own menu. - auto* vis = edit_btn("design_eye", _L("Show / hide")); - vis->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_toggle_visibility(); }); - auto* del = edit_btn("design_delete", _L("Delete")); - del->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { - // A body row deletes the feature that made it. on_delete_body() already resolves - // CadBody::source_feature and asks for confirmation by name; it was reachable only - // from the right-click offer, so this button answered a selected body row with - // "select the FEATURE that created this body" — an instruction the user cannot act - // on, since the tree does not say which feature that is. It does now. - if (tree_body_selection() >= 0) on_delete_body(); - else on_delete_feature(); - }); - auto* up = edit_btn("design_moveup", _L("Move up")); + auto* up = sidebar_icon_btn(m_tree_box, "design_moveup", _L("Move up")); up->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_move_feature(-1); }); - auto* down = edit_btn("design_movedown", _L("Move down")); + auto* down = sidebar_icon_btn(m_tree_box, "design_movedown", _L("Move down")); down->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_move_feature(+1); }); - m_btn_interfere = edit_btn("color_palette", _L("Check interference — find overlapping bodies")); + m_btn_interfere = sidebar_icon_btn(m_tree_box, "color_palette", _L("Check interference — find overlapping bodies")); m_btn_interfere->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_check_interference(); }); const int gap = FromDIP(SidebarProps::ElementSpacing()); - trow->Add(edit, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, gap); - trow->Add(vis, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, gap); - trow->Add(del, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, gap); trow->Add(up, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, gap); trow->Add(down, 0, wxALIGN_CENTER_VERTICAL); // Interference check sits after a rule: it reports, it does not edit the recipe. trow->AddSpacer(8); - trow->Add(new wxStaticLine(m_tree_box, wxID_ANY, wxDefaultPosition, wxSize(1, 22), wxLI_VERTICAL), + trow->Add(new wxStaticLine(m_tree_box, wxID_ANY, wxDefaultPosition, FromDIP(wxSize(1, 22)), wxLI_VERTICAL), 0, wxALIGN_CENTER_VERTICAL); trow->AddSpacer(8); trow->Add(m_btn_interfere, 0, wxALIGN_CENTER_VERTICAL); @@ -3316,47 +3303,20 @@ DesignPanel::DesignPanel(wxWindow* parent) m_parts_hdr = new wxBoxSizer(wxHORIZONTAL); m_parts_hdr->Add(card_header(m_parts_box, "design_extrude", _L("Bodies"), m_parts_label), 0, wxALIGN_CENTER_VERTICAL); - // The bodies card carries the actions that act on a BODY. Move came from the feature-tree - // header, where it had to guess whether its subject was a body or a feature; Show/hide, - // Delete and Colour are deliberate COPIES of feature-tree actions, because a body row is a - // different subject and a user working in this list should not have to travel to another - // card to hide or recolour what they have selected. Boolean is not a copy — it is the one - // body-body operation, gated through on_boolean_tool. Each one already resolves the body row - // itself (on_toggle_visibility, on_delete_body, on_set_body_color), so nothing here decides - // policy — the card only gives them a home next to the rows they act on. + // The bodies card header carries the body actions that are not one row's own. Boolean is the + // one body-body operation, gated through on_boolean_tool, and Colour recolours the selected + // body, resolving it itself (on_set_body_color), so nothing here decides policy. Move, + // Show/hide and Delete act on one body, so they sit on its row, as a feature's actions do. { - auto body_btn = [this](const char* icon, const wxString& tip) { - auto* b = new ScalableButton(m_parts_box, wxID_ANY, icon, "", wxSize(24, 24), - wxDefaultPosition, wxBU_EXACTFIT | wxBORDER_NONE, false, 20); - b->SetToolTip(tip); - return b; - }; - auto* bmove = body_btn("design_move", _L("Move body")); - bmove->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { - if (m_sel_solid_body >= 0 && m_sel_solid_body < int(m_doc.bodies.size())) { - on_move_body(); - } else { - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Select a body row first, then move it")); - m_status->Refresh(); - } - }); // Boolean lives here as well as on the toolbar: combining two bodies is a body action, // and a user working in the body list should not have to leave it to find this. - auto* bbool = body_btn("design_boolean", _L("Boolean — join, subtract or intersect with another body")); + auto* bbool = sidebar_icon_btn(m_parts_box, "design_boolean", _L("Boolean — join, subtract or intersect with another body")); bbool->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_boolean_tool(); }); - auto* bvis = body_btn("design_eye", _L("Show / hide")); - bvis->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_toggle_visibility(); }); - auto* bdel = body_btn("design_delete", _L("Delete")); - bdel->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_delete_body(); }); - auto* bcol = body_btn("color_palette", _L("Colour")); + auto* bcol = sidebar_icon_btn(m_parts_box, "color_palette", _L("Color")); bcol->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_set_body_color(); }); const int bgap = FromDIP(SidebarProps::ElementSpacing()); m_parts_hdr->AddStretchSpacer(1); - m_parts_hdr->Add(bmove, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, bgap); m_parts_hdr->Add(bbool, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, bgap); - m_parts_hdr->Add(bvis, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, bgap); - m_parts_hdr->Add(bdel, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, bgap); m_parts_hdr->Add(bcol, 0, wxALIGN_CENTER_VERTICAL); } parts_inner->Add(m_parts_hdr, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, @@ -3366,10 +3326,8 @@ DesignPanel::DesignPanel(wxWindow* parent) FromDIP(SidebarProps::TitlebarMargin())); m_parts_hdr->ShowItems(false); // no bodies yet on a fresh document m_parts_rule->Hide(); - m_parts = new wxTreeCtrl(m_parts_box, wxID_ANY, wxDefaultPosition, wxSize(-1, 48), - wxTR_HIDE_ROOT | wxTR_SINGLE | wxTR_NO_LINES | - wxTR_FULL_ROW_HIGHLIGHT | wxBORDER_SIMPLE | wxTR_EDIT_LABELS); - if (!dp_dark()) m_parts->SetBackgroundColour(dp_panel_bg()); + m_parts = new DesignRowList(m_parts_box, 6); // scrolls past 6 + m_parts->SetBackgroundColour(dp_panel_bg()); parts_inner->Add(m_parts, 0, wxEXPAND | wxALL, 12); // Start hidden: a fresh document has no bodies, and refresh_parts() only runs on the first // tree rebuild — until then an empty box would sit under the header. @@ -3377,73 +3335,58 @@ DesignPanel::DesignPanel(wxWindow* parent) m_parts_label->Hide(); // Taking a body from the list means the SAME state change however it was asked for, so the // normalisation lives here and not inside a selection handler. That distinction is not - // pedantry: SelectItem() on a row that is ALREADY selected fires no SEL_CHANGED at all, so a - // version of this that only ran on selection left a stale vertex/edge from an earlier - // viewport pick in place — and offer_selection_kind() tests vertex FIRST, so right-clicking - // the body row served the VERTEX offer (Fillet greyed, Mirror in place of Repeat) while the - // row sat highlighted. Measured on the rig 2026-08-02; it is invisible from the code alone. + // pedantry: selecting a row that is ALREADY selected notifies nobody, so a version of this + // that only ran on selection left a stale vertex/edge from an earlier viewport pick in place + // — and offer_selection_kind() tests vertex FIRST, so right-clicking the body row served the + // VERTEX offer (Fillet greyed, Mirror in place of Repeat) while the row sat highlighted. + // Measured on the rig 2026-08-02; it is invisible from the code alone. auto apply_body_row = [this](int b) { if (!m_viewport || b < 0) return; // One selection at a time: a body row and a feature row mean different things to the // op bar, so clear the feature tree's highlight when a body takes over. - if (m_tree) m_tree->Unselect(); // wxTR_SINGLE: UnselectAll() does nothing here - m_viewport->set_body_highlight(false); // the per-body overlay does the tint + if (m_tree) m_tree->unselect(); m_viewport->select_body(b); // also drops the vertex/edge marker m_sel_solid_body = b; m_sel_solid_face = m_sel_solid_edge = -1; m_sel_solid_vertex = false; m_pick_face = m_pick_face_body = -1; // chosen from the list, no face was pointed at - m_status->SetForegroundColour(wxNullColour); - set_status(wxString::Format(_L("Body %d selected — right-click for what applies to it"), b + 1)); - m_status->Refresh(); + 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. + m_parts->on_action = [this, apply_body_row](int row, int id) { + apply_body_row(row); + switch (id) { + case RowMove: on_move_body(); break; + case RowVisibility: on_toggle_visibility(); break; + case RowDelete: on_delete_body(); break; + } }; - m_parts->Bind(wxEVT_TREE_SEL_CHANGED, [this, apply_body_row](wxTreeEvent&) { - apply_body_row(tree_body_selection()); - }); // The third door onto the offer, after the viewport right-click and the Menu key. A body ROW // is an unambiguous body, so the offer reports BodySolid and the body verbs act on the row you // can see highlighted. That is the confirmation a face pick cannot give: pointing at a face // lights the face, never the body the verb will actually change. The status line above has // 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); }); + }; // 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 // its history and renaming that is renaming the wrong object — reported, correctly, as "you // consider the extrusion = the body". CadBody::user_name is carried across recompute() by // index and written into the recipe, so the name outlives both the rebuild and the save. - m_parts->Bind(wxEVT_TREE_BEGIN_LABEL_EDIT, [this](wxTreeEvent& e) { - if (tree_body_selection() < 0) { e.Veto(); return; } - e.Skip(); - }); - m_parts->Bind(wxEVT_TREE_END_LABEL_EDIT, [this](wxTreeEvent& e) { - if (e.IsEditCancelled()) return; - const int b = tree_body_selection(); - if (b < 0 || b >= int(m_doc.bodies.size())) { e.Veto(); return; } - wxString label = e.GetLabel(); - label.Trim(true).Trim(false); - if (label.empty()) { e.Veto(); return; } // a nameless row is worse than a bad name + m_parts->on_rename = [this](int b, const wxString& name) { + if (b < 0 || b >= int(m_doc.bodies.size())) return; m_doc.bodies[b].has_user_name = true; - m_doc.bodies[b].user_name = std::string(label.ToUTF8().data()); + m_doc.bodies[b].user_name = std::string(name.ToUTF8().data()); sync_recipe_to_model(); // the name is part of what gets saved - e.Skip(); - // Rebuild on the NEXT event-loop turn: refresh_parts() destroys every wxTreeItemId and - // we are inside wx's own END_LABEL_EDIT dispatch for one of them. The feature tree - // learned this the hard way — doing it here took the process down. - CallAfter([this] { refresh_parts(); }); - }); - - m_parts->Bind(wxEVT_TREE_ITEM_MENU, [this, apply_body_row](wxTreeEvent& e) { - if (e.GetItem().IsOk()) - m_parts->SelectItem(e.GetItem()); // the row under the cursor, never a stale one - apply_body_row(tree_body_selection()); // unconditional — see above, SelectItem on an - // already-selected row raises no event - // GetPoint() is tree-client; it is (-1,-1) when the KEYBOARD menu key raised this, so fall - // back to the shared anchor rather than popping the menu at a garbage coordinate. - const wxPoint p = e.GetPoint(); - const wxPoint screen = (p.x >= 0 && p.y >= 0) ? m_parts->ClientToScreen(p) : offer_anchor(); - // 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); }); - }); + refresh_parts(); + }; // --- Variables (document-scope named expressions) --- // Below the feature tree + parts, always visible. wxListCtrl in report mode with two @@ -3458,9 +3401,10 @@ DesignPanel::DesignPanel(wxWindow* parent) var_hdr->Add(card_header(m_var_box, "design_constrain", _L("Variables"), var_hdr_title), 0, wxALIGN_CENTER_VERTICAL); var_hdr->AddStretchSpacer(); - m_btn_add_var = new wxButton(m_var_box, wxID_ANY, _L("+"), wxDefaultPosition, wxSize(30, 24)); - m_btn_edit_var = new wxButton(m_var_box, wxID_ANY, _L("Edit"), wxDefaultPosition, wxSize(50, 24)); - m_btn_del_var = new wxButton(m_var_box, wxID_ANY, _L("Del"), wxDefaultPosition, wxSize(42, 24)); + // Icon actions in the card header, as the Feature tree and Bodies cards have them. + m_btn_add_var = sidebar_icon_btn(m_var_box, "add", _L("Add variable")); + m_btn_edit_var = sidebar_icon_btn(m_var_box, "design_edit", _L("Edit variable")); + m_btn_del_var = sidebar_icon_btn(m_var_box, "design_delete", _L("Delete variable")); m_btn_add_var->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_add_variable(); }); m_btn_edit_var->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_edit_variable(); }); m_btn_del_var->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_remove_variable(); }); @@ -3475,7 +3419,7 @@ DesignPanel::DesignPanel(wxWindow* parent) m_var_list = new wxListCtrl(m_var_box, wxID_ANY, wxDefaultPosition, wxSize(-1, FromDIP(64)), wxLC_REPORT | wxLC_SINGLE_SEL); - if (!dp_dark()) m_var_list->SetBackgroundColour(dp_panel_bg()); + m_var_list->SetBackgroundColour(dp_panel_bg()); m_var_list->AppendColumn(_L("Name"), wxLIST_FORMAT_LEFT, FromDIP(90)); m_var_list->AppendColumn(_L("Expression"), wxLIST_FORMAT_LEFT, FromDIP(120)); var_inner->Add(m_var_list, 0, wxEXPAND | wxALL, FromDIP(SidebarProps::ContentMargin())); @@ -3488,7 +3432,6 @@ DesignPanel::DesignPanel(wxWindow* parent) m_status = new wxStaticText(m_form, wxID_ANY, ""); m_status->Hide(); // storage only — the line is drawn over the viewport, see set_status() - m_status_default_fg = m_status->GetForegroundColour(); // capture BEFORE any caller writes root->Add(m_status, 0, wxLEFT | wxRIGHT | wxBOTTOM, 12); // DoF / constraint-state readout (P3). Dedicated line so it never clobbers the @@ -3560,19 +3503,22 @@ DesignPanel::DesignPanel(wxWindow* parent) m_form->FitInside(); m_form->SetScrollRate(0, FromDIP(20)); // vertical only, like Prepare's sidebar: never scroll labels out - m_form->SetMinSize(wxSize(264, -1)); + m_form->SetMinSize(FromDIP(wxSize(264, -1))); + build_phase("sidebar and tool cards"); // Right column: a small view toolbar over the live 3D viewport that mirrors // the CadDocument body. - m_viewport = new DesignCanvas(this); + auto* view_col = new wxPanel(body_panel, wxID_ANY); // the centre pane: sketch banner over the viewport + view_col->SetBackgroundColour(dp_panel_bg()); + m_viewport = new DesignCanvas(view_col); + build_phase("3D canvas"); m_viewport->set_on_sketch_commit([this](const SketchProfile& prof, const SketchPlane& plane) { m_doc.checkpoint(); // undo boundary: committing a sketch m_feature_counter++; - m_doc.add_sketch_profile(prof, plane, "Sketch" + std::to_string(m_feature_counter)); + m_doc.add_sketch_profile(prof, plane, feature_name(_L("Sketch"))); m_doc.recompute(); - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Sketch created — select it, then right-click to Extrude")); + set_status(StatusKind::Info, _L("Sketch created — select it, then right-click to Extrude")); refresh_tree(); }); @@ -3581,9 +3527,7 @@ DesignPanel::DesignPanel(wxWindow* parent) const std::vector& cons, const SketchPlane& plane) { if (ents.empty()) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Sketch empty — nothing committed")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Sketch empty — nothing committed")); return; } m_doc.checkpoint(); // undo boundary: committing / re-editing an entity sketch @@ -3598,8 +3542,7 @@ DesignPanel::DesignPanel(wxWindow* parent) if (m_doc.replace_feature(m_edit_index, edited)) { if (!cons.empty()) m_doc.solve_sketch_feature(m_edit_index); m_doc.recompute(); - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Sketch updated")); + set_status(StatusKind::Info, _L("Sketch updated")); m_edit_index = -1; refresh_tree(); sync_sketch_display(); @@ -3608,11 +3551,10 @@ DesignPanel::DesignPanel(wxWindow* parent) } m_feature_counter++; const int sk = m_doc.add_sketch_entities(ents, plane, - "Sketch" + std::to_string(m_feature_counter), cons); + feature_name(_L("Sketch")), cons); if (!cons.empty()) m_doc.solve_sketch_feature(sk); // enforce driving dimensions m_doc.recompute(); - m_status->SetForegroundColour(wxNullColour); - set_status(cons.empty() + set_status(StatusKind::Info, cons.empty() ? _L("Sketch created — select it, then right-click to Extrude") : wxString::Format(_L("Sketch created (%zu driving dims) — select it, then right-click to Extrude"), cons.size())); @@ -3623,12 +3565,11 @@ DesignPanel::DesignPanel(wxWindow* parent) // Live length/angle readout while drawing a Line/Polyline segment. m_viewport->set_on_cursor_metrics([this](double len, double ang_deg, bool locked) { double a = ang_deg; if (a < 0.0) a += 360.0; // show bearing 0..360 - m_status->SetForegroundColour(wxNullColour); // APPENDED to the step guidance, never in place of it. This fires on every mouse move // while a segment is being dragged, so replacing the line wiped the instruction for the // step the user is in the middle of — one mouse move after the click that armed it. - const wxString metrics = wxString::Format(L"L %.2f mm %.1f°%s", - len, a, locked ? L" (locked)" : L""); + wxString metrics = wxString::Format(_L("Length %.2f mm, angle %.1f°"), len, a); + if (locked) metrics += " " + _L("(locked)"); set_status(m_sketch_step.IsEmpty() ? metrics : m_sketch_step + L" · " + metrics); m_status->Refresh(); @@ -3655,6 +3596,7 @@ DesignPanel::DesignPanel(wxWindow* parent) // Constrain to read stays blank until they happen to change something. m_dof_last = dof; m_dof_last_ok = ok; m_dof_last_has = has_constraints; apply_dof_status(dof, ok, has_constraints); + update_undo_redo_buttons(); // every edit of the live sketch re-solves, so this tracks it }); // Selection no longer writes the status line: on_sketch_step owns it, says the same thing @@ -3669,32 +3611,24 @@ DesignPanel::DesignPanel(wxWindow* parent) m_viewport->finish_sketch(); // commit live sketch (synchronous) m_extrude_sketch_ref = resolve_extrude_sketch(); if (m_extrude_sketch_ref < 0) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Could not resolve the sketch to extrude")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Could not resolve the sketch to extrude")); return; } set_ui_mode(UiMode::Feature); open_tool(Tool::Extrude); // AFTER open_tool, not before: opening the tool re-derives the selection state, so a // region recorded ahead of it is wiped before Extrude ever reads it. - m_sel_sketch_feat = m_extrude_sketch_ref; - m_sel_sketch_region = region; m_sel_solid_face = m_sel_solid_edge = -1; m_pick_face = m_pick_face_body = -1; m_viewport->set_loop_pick(m_extrude_sketch_ref, region); - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Face selected — set the depth and Confirm")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("Face selected — set the depth and Confirm")); }); // Clicking a committed sketch loop on the plate (no live session) selects THAT loop: // the viewport highlights only it (cyan) and its Sketch feature's tree row is selected. - // The (feature, region) pair is remembered so Extrude builds just that one loop. + // The viewport keeps the (feature, region) pair so Extrude builds just that one loop. m_viewport->set_on_display_sketch_selected([this](int feat, int region, int entity) { if (feat < 0 || feat >= int(m_doc.features.size())) return; - m_sel_sketch_feat = feat; - m_sel_sketch_region = region; // Last pick wins (symmetric with the solid-pick handler): selecting a sketch loop drops // any stale solid face/edge pick so Extrude treats this loop as the profile. m_sel_solid_face = m_sel_solid_edge = -1; @@ -3743,13 +3677,11 @@ DesignPanel::DesignPanel(wxWindow* parent) } } set_tree_selection(feat); - m_status->SetForegroundColour(wxNullColour); - set_status(region >= 0 + set_status(StatusKind::Info, region >= 0 // "Region", not "Loop": what is selected — and what Extrude will consume — is the // bounded area including any holes in it, not a single closed curve. ? _L("Region selected — right-click to Extrude, or double-click to edit") : _L("Sketch selected — right-click to Extrude, or double-click to edit")); - m_status->Refresh(); }); // Double-click a committed sketch stroke: open THAT sketch for editing, where its entities @@ -3766,28 +3698,28 @@ DesignPanel::DesignPanel(wxWindow* parent) // F key (Prepare's Place on Face): the tool forwards it here when the Design viewport // has focus; we lay the selected body face on the bed. Returns false when no face is // selected so the key can fall through to the default handler. - m_viewport->set_on_place_on_face([this]() { return place_on_face(); }); - // Clicking a solid cycles whole -> face -> edge. The tool draws the cyan overlay for ALL - // levels now (per-body, so other bodies stay untinted) — no whole-compound set_body_highlight. + // Clicking a solid cycles whole -> face -> edge. The tool draws the selection for every + // level, per body, so other bodies keep their own look. m_viewport->set_on_solid_selection_changed([this](int level, int body, int face, int edge) { + // 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 m_sel_solid_edge = (level == 3) ? edge : -1; + m_sel_solid_edges = (level == 3) ? m_viewport->selected_solid_edges() : std::vector(); m_sel_solid_vertex = (level == 4); // Keep the hit face even at whole-body level: the cycle's first click means "this body", // but the user pointed AT a face and a sketch should be able to use it. 3a2. m_pick_face_body = (level >= 1) ? body : -1; m_pick_face = (level >= 1) ? face : -1; - // Last pick wins: selecting a solid drops any stale committed-sketch loop selection. - // Otherwise a leftover loop keeps `m_sel_sketch_region >= 0`, which blocks the face - // push/pull branch in Extrude (`m_sel_solid_face >= 0 && m_sel_sketch_region < 0`) and - // makes Extrude build a DETACHED new body from the last sketch instead of push/pulling - // the face the user just clicked. - if (level >= 1) { m_sel_sketch_region = -1; m_sel_sketch_feat = -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(); // 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 @@ -3895,9 +3827,9 @@ DesignPanel::DesignPanel(wxWindow* parent) m_thread_on_face = true; m_thread_face_body = m_sel_solid_body; set_thread_target_label(from_face ? face : -1, from_face ? -1 : edge); - infer_thread_spec(2.0 * cf.radius); // M diameter + pitch + depth from the cylinder - if (m_thread_height && cf.height > 1e-6) m_thread_height->SetValue(cf.height); if (m_thread_internal) m_thread_internal->SetValue(cf.internal); + infer_thread_spec(2.0 * cf.radius, cf.internal); // M size + pitch + depth from the cylinder + if (m_thread_height && cf.height > 1e-6) m_thread_height->SetValue(cf.height); refresh_preview(); } } @@ -3949,7 +3881,6 @@ DesignPanel::DesignPanel(wxWindow* parent) } if (got && m_viewport) m_viewport->set_escalate_on_repick(true); } - m_status->SetForegroundColour(wxNullColour); const int nb = int(m_doc.bodies.size()); const wxString bodytag = (nb > 1) ? wxString::Format(_L("Body %d "), body + 1) : wxString(); // Each sub-element line ends by naming the NEXT click (gem). Escalation to the @@ -3966,10 +3897,18 @@ DesignPanel::DesignPanel(wxWindow* parent) set_status(level == 4 ? bodytag + _L("vertex selected — click again for the whole body") : level == 1 ? bodytag + _L("selected (whole body) — right-click for what applies to it") : level == 2 ? bodytag + wxString::Format(_L("face %d selected — right-click to push/pull it, or click again for the whole body"), face) - : level == 3 ? bodytag + wxString::Format(_L("edge %d selected — Fillet/Chamfer to dress it, or click again for the whole body"), edge) + : level == 3 && m_sel_solid_edges.size() > 1 + ? bodytag + wxString::Format(_L_PLURAL("%zu edge selected — Shift+click adds or removes one, Fillet/Chamfer dresses them all", "%zu edges selected — Shift+click adds or removes one, Fillet/Chamfer dresses them all", m_sel_solid_edges.size()), m_sel_solid_edges.size()) + : level == 3 ? bodytag + wxString::Format(_L("edge %d selected — Shift+click to add edges, or click again for the whole body"), edge) : _L("Nothing selected")); m_status->Refresh(); }); + // 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()) + set_status(StatusKind::Info, _L("Nothing selected")); + }); // Visual Extrude gizmo (C5b): dragging/editing the in-canvas depth arrow writes the // matching spin field and re-previews (which re-feeds the gizmo with the new depth). @@ -4057,11 +3996,10 @@ DesignPanel::DesignPanel(wxWindow* parent) m_viewport->set_sketch_plane(plane_from_choice(m_ref_plane)); } const char* nm = (base == 0) ? "XY" : (base == 1) ? "XZ" : (base == 2) ? "YZ" : "datum"; - m_status->SetForegroundColour(wxColour(120, 210, 120)); // 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(m_ui_mode == UiMode::Sketch + 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)); @@ -4100,10 +4038,8 @@ DesignPanel::DesignPanel(wxWindow* parent) const int nb = int(m_doc.bodies.size()); const wxString tag = (nb > 1) ? wxString::Format(_L("Body %d "), body + 1) : wxString(); const Vec3d t = xform.translation(); - m_status->SetForegroundColour(wxNullColour); - set_status(tag + wxString::Format(_L("placed (%.1f, %.1f, %.1f) mm — drag arrows to move, rings to rotate"), + set_status(StatusKind::Info, tag + wxString::Format(_L("placed (%.1f, %.1f, %.1f) mm — drag arrows to move, rings to rotate"), t.x(), t.y(), t.z())); - m_status->Refresh(); }); // Fillet/Chamfer radius gizmo: dragging (or editing) the edge-anchored arrow writes the @@ -4179,23 +4115,21 @@ DesignPanel::DesignPanel(wxWindow* parent) set_ui_mode(UiMode::Feature); sync_sketch_display(); refresh_tree(); - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Tool exited")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("Tool exited")); }); // The tool declined to leave because the session holds geometry. There is no "press it again" // any more — the answer is a deliberate Finish or Cancel — so this is a plain statement of // where you are, not a warning shot. Only the STATUS LINE lives here; the tool reports via // this callback instead of writing text itself. + m_viewport->set_on_sketch_notice([this](const std::string& msg, bool error) { + set_status(error ? StatusKind::Error : StatusKind::Info, wxString::FromUTF8(msg)); + }); m_viewport->set_on_sketch_exit_refused([this]() { - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Sketch kept — Finish to commit it, Cancel to discard")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("Sketch kept — Finish to commit it, Cancel to discard")); }); // Ctrl+Z / Ctrl+Shift+Z (Ctrl+Y) from the viewport → feature-history undo/redo. - m_viewport->set_on_undo_redo([this](bool redo) { do_undo_redo(redo); }); // Esc = the unified Cancel everywhere. Feature cards had no key exit (only the button); // CHAR_HOOK on the panel catches Esc from the card or viewport and routes to tool_cancel. @@ -4268,6 +4202,22 @@ DesignPanel::DesignPanel(wxWindow* parent) // acts on that one only; see DesignInteraction.hpp for the ladder and its invariant. if (key == WXK_ESCAPE) { escape(); return; } + // Enter mirrors the ✓ as Esc mirrors the ✗ (charter 4.2, L9): a ready sketch edit-op or + // transform first, else whatever the ✓ would confirm — gated exactly as the greyed button + // is, so a broken candidate cannot be committed from the keyboard either. An open value + // field keeps its own Enter (handled above or inside the field). + // Inside a sketch, Enter first ends what is in progress — applies a ready edit-op or + // transform, finishes a polyline/spline chain — then ends the armed draw tool (a + // continuous tool is ended, not confirmed, charter 4.2); only from Select does it finish + // the sketch, exactly as the ✓ does. + if (!in_text && !ctrl && (key == WXK_RETURN || key == WXK_NUMPAD_ENTER)) { + if (sketch_mode && m_viewport && m_viewport->is_sketching()) { + if (m_viewport->sketch_confirm_pending()) { update_undo_redo_buttons(); return; } + if (m_viewport->sketch_disarm_tool()) { set_status(_L("Select")); return; } + } + if (confirm_enabled()) { tool_confirm(); return; } + } + // The offer from the keyboard (charter 4.1): the Menu key, or Shift+F10 for keyboards that // do not have one. Same menu the right-click opens — show_offer_menu already decides which // half of the map applies via sketch_map_applies(), so nothing about the content is decided @@ -4287,16 +4237,21 @@ DesignPanel::DesignPanel(wxWindow* parent) if (!in_text && ctrl && (key == 'Z' || key == 'z' || key == WXK_CONTROL_Z || key == 'Y' || key == 'y' || key == WXK_CONTROL_Y)) { const bool redo = (key == 'Y' || key == 'y' || key == WXK_CONTROL_Y) || e.ShiftDown(); - if (sketching) { if (!redo) m_viewport->undo_last_sketch_entity(); } - else { do_undo_redo(redo); } + do_undo_redo(redo); // the same route as the Undo/Redo buttons return; } - // Delete — the selected sketch entities (or the last drawn one if none is selected), or the - // selected feature in Feature mode. Focus-independent, same reason as undo above. + // Delete — the selected sketch entities, or the selected feature in Feature mode. Only + // an explicit selection is ever destroyed (DesignInteraction.hpp): with nothing picked + // the key says so instead of deleting whatever happened to be drawn last. + // Focus-independent, same reason as undo above. // WXK_BACK too: on a keyboard whose Del is a chord (every laptop this runs on), Del is // the one destructive key nobody can reach, and Backspace is what users press. oql1. if (!in_text && (key == WXK_DELETE || (key == WXK_BACK && sketching))) { - if (sketching) { m_viewport->delete_selected_or_last_sketch_entity(); return; } + if (sketching) { + if (!m_viewport->delete_selected_sketch_entities()) + set_status(_L("Select something to delete")); + return; + } if (m_ui_mode == UiMode::Feature && m_active == Tool::None && tree_selection() != wxNOT_FOUND) { on_delete_feature(); return; } } @@ -4350,10 +4305,6 @@ DesignPanel::DesignPanel(wxWindow* parent) e.Skip(); }); - // Right-click finishes the move gizmo in the viewport; mirror that on the panel so the - // action bar (shown while moving) hides and the move state clears. - m_viewport->set_on_move_exit([this]() { m_move_body = -1; show_move_card(false); update_action_bar(); }); - // The offer (§4.1): right-click the geometry, get the verbs that apply to it. Left-click // still only selects, so pointing at things stays quiet. m_viewport->set_on_context_menu([this](const wxPoint& p) { show_offer_menu(p); }); @@ -4379,7 +4330,7 @@ DesignPanel::DesignPanel(wxWindow* parent) // The sketch banner sits ABOVE the viewport rather than floating inside it: a child window // over a wxGLCanvas is a platform argument (it is a native window on GTK and does not // reliably stack over GL), and the banner's job is to be unmissable, not to be clever. - m_sketch_banner = new wxPanel(this, wxID_ANY); + m_sketch_banner = new wxPanel(view_col, wxID_ANY); m_sketch_banner->SetBackgroundColour(wxColour(0, 122, 116)); // Orca teal: not a plate colour m_sketch_banner_txt = new wxStaticText(m_sketch_banner, wxID_ANY, wxString()); m_sketch_banner_txt->SetForegroundColour(*wxWHITE); @@ -4396,16 +4347,33 @@ DesignPanel::DesignPanel(wxWindow* parent) // The bottom 3D-navigator orb handles all view orientation, so no separate view buttons. // Fit view is a double-click on the viewport (the tool intercepts it -> zoom_to_volumes). vcol->Add(m_viewport, 1, wxEXPAND); + view_col->SetSizer(vcol); - // Onshape layout: top toolbar over [ slim left column | center viewport ]. - auto* body = new wxBoxSizer(wxHORIZONTAL); - body->Add(m_form, 0, wxEXPAND); - body->Add(vcol, 1, wxEXPAND); + // Onshape layout: top toolbar over [ slim left column | center viewport ], with the column + // docked like Prepare's sidebar: left by default, movable to the right or floating, resizable + // and collapsible. + m_aui.init(body_panel); + m_aui.AddPane(m_form, AuiMgr::sidebar_pane_info().MinSize(m_form->GetMinSize())); + m_aui.AddPane(view_col, wxAuiPaneInfo().Name("main").CenterPane().PaneBorder(false)); + m_default_layout = m_aui.SavePerspective(); + load_window_layout(); + m_aui.track_docked_size(m_form); + // The collapse button sits on the canvas edge the sidebar is docked on, and is gone while it + // floats. A click arrives inside the canvas's mouse handler, so the relayout waits for it. + m_viewport->set_sidebar_collapse( + [this] { + const wxAuiPaneInfo& sidebar = m_aui.GetPane(m_form); + if (!sidebar.IsOk() || sidebar.IsFloating()) + return CollapseSide::None; + return sidebar.dock_direction == wxAUI_DOCK_RIGHT ? CollapseSide::Right : CollapseSide::Left; + }, + [this] { CallAfter([this] { collapse_sidebar(!m_sidebar_collapsed); }); }); + collapse_sidebar(m_sidebar_collapsed); // the button's tooltip auto* outer = new wxBoxSizer(wxVERTICAL); outer->Add(m_toolbar, 0, wxEXPAND); outer->Add(new wxStaticLine(this, wxID_ANY), 0, wxEXPAND); - outer->Add(body, 1, wxEXPAND); + outer->Add(body_panel, 1, wxEXPAND); SetSizer(outer); // The atlas says a verb is wired; the registrations above say what it runs. Nothing checks @@ -4444,6 +4412,8 @@ DesignPanel::DesignPanel(wxWindow* parent) } set_ui_mode(UiMode::Feature); + build_phase("wiring"); + BOOST_LOG_TRIVIAL(info) << "Design tab build: total " << build_clock.Time() << " ms"; } void DesignPanel::set_active_tool_btn(ScalableButton* b) @@ -4487,6 +4457,10 @@ void DesignPanel::set_ui_mode(UiMode m) { m_ui_mode = m; 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 + // for editing makes one with its first click. + if (m == UiMode::Sketch && m_viewport != nullptr) m_viewport->clear_loop_pick(); // The DoF readout describes a SKETCH's constraint state, so it means nothing back in Feature // mode — where it nonetheless stayed on screen after every Confirm, Cancel and Escape // (752). Cleared here rather than at those three exits because this is the one place @@ -4666,15 +4640,139 @@ void DesignPanel::sync_sketch_display() void DesignPanel::on_add_text() { - wxTextEntryDialog dlg(this, _L("Text to insert:"), _L("Text"), wxEmptyString); - if (dlg.ShowModal() != wxID_OK) + open_text_dialog(-1); +} + +// Text is a FEATURE, "Text N" in the tree, never loose lines in a sketch: that is what makes it +// visible, and editable afterwards (the feature keeps its string, font and height). While the +// dialog is open the feature is drawn in the canvas where it will be — the dialog is modeless +// so it can be moved off it — and Cancel takes it out again. A new text goes on the plane of +// the sketch that is open (committed first if it holds anything), else on the picked face +// (centred on it, ready to engrave), else on the reference plane. +void DesignPanel::open_text_dialog(int feat) +{ + if (m_text_dlg != nullptr) { m_text_dlg->Raise(); return; } + 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; + m_text_offset = Vec2d(0, 0); + + DesignTextDialog::Spec initial; + if (m_text_editing) { + const CadFeature& f = m_doc.features[feat]; + initial = { wxString::FromUTF8(f.text_string), f.text_font, f.text_height }; + m_doc.checkpoint(); // undo boundary: Cancel restores the text as it was + } else { + if (m_viewport && m_viewport->is_sketching()) { + m_text_plane = m_viewport->mcp_sketch_tool().plane(); + if (m_viewport->live_sketch_has_work()) { + m_viewport->sketch_confirm_pending(); + m_viewport->finish_sketch(); // keep what was drawn: it is its own feature + } else { + m_viewport->cancel_sketch(); // an empty sketch was only a way to pick the plane + } + m_edit_index = -1; + set_ui_mode(UiMode::Feature); + sync_sketch_display(); + refresh_tree(); + } else if (m_sel_solid_face >= 0 && m_sel_solid_body >= 0 && m_sel_solid_body < int(m_doc.bodies.size())) { + const TopoDS_Face face = GeometryEngine::face_by_index(m_doc.bodies[m_sel_solid_body].shape, m_sel_solid_face); + if (!face.IsNull()) { + m_text_plane = SketchPlane::from_face(face); + m_text_offset = m_text_plane.project(GeometryEngine::face_centroid_world(face), m_text_plane.normal); + m_text_face_body = m_sel_solid_body; + } else { + m_text_plane = plane_from_choice(m_ref_plane); + } + } else { + m_text_plane = plane_from_choice(m_ref_plane); + } + } + + m_text_dlg = new DesignTextDialog(this, m_text_editing ? &initial : nullptr); + m_text_dlg->on_change = [this] { text_dialog_changed(); }; + m_text_dlg->on_accept = [this] { text_dialog_done(true); }; + m_text_dlg->on_cancel = [this] { text_dialog_done(false); }; + m_text_dlg->Show(); + set_status(StatusKind::Info, m_text_editing ? _L("Edit the text — Enter applies, Esc keeps it as it was") + : _L("Type the text — it appears in the view as you type; Enter inserts, Esc cancels")); +} + +void DesignPanel::text_dialog_changed() +{ + if (m_text_dlg == nullptr) return; + const ImportRegions& regions = m_text_dlg->regions(); + const DesignTextDialog::Spec sp = m_text_dlg->spec(); + const bool have = m_text_feat >= 0 && m_text_feat < int(m_doc.features.size()); + if (regions.empty()) { + // Nothing to draw. An edited text keeps its last outline (OK is disabled until there is + // text again); a new one that has not been accepted simply goes away again. + if (!m_text_editing && have) { + m_doc.undo(); + m_text_feat = -1; + m_feature_counter--; // the next keystroke brings the same "Text N" back + refresh_tree(); + sync_sketch_display(); + } return; - const wxString text = dlg.GetValue(); - if (text.empty()) + } + if (!have) { + m_doc.checkpoint(); // undo boundary: the new text (Cancel removes it) + m_feature_counter++; + CadFeature f; + f.type = CadFeatureType::Sketch; + f.name = feature_name(_L("Text")); + f.plane = m_text_plane; + f.import_offset = m_text_offset; // the regions are centred on the origin + f.import_on_face = m_text_face_body >= 0; + f.import_face_body = m_text_face_body; + m_doc.features.push_back(f); + m_text_feat = int(m_doc.features.size()) - 1; + refresh_tree(); + set_tree_selection(m_text_feat); + } + CadFeature& f = m_doc.features[m_text_feat]; + f.imported_regions = regions; + f.text_string = std::string(sp.text.ToUTF8().data()); + f.text_font = sp.font; + f.text_height = sp.height; + sync_sketch_display(); // the overlay draws the feature's regions: the canvas preview +} + +void DesignPanel::text_dialog_done(bool accepted) +{ + DesignTextDialog* dlg = m_text_dlg; + m_text_dlg = nullptr; + if (dlg != nullptr) dlg->Destroy(); + const int feat = m_text_feat; + const bool editing = m_text_editing; + m_text_feat = -1; + m_text_editing = false; + const bool have = feat >= 0 && feat < int(m_doc.features.size()); + + if (!accepted) { + if (have) m_doc.undo(); // a new text leaves, an edited one gets its old outline back + refresh_tree(); + sync_sketch_display(); + set_status(StatusKind::Info, editing ? _L("Text unchanged") : _L("Text cancelled")); return; - const std::string utf8(text.ToUTF8().data()); - // Insert at a default height; resize in-canvas via the bbox handles (Move/Scale). - add_imported_sketch(text_to_regions(utf8, 10.0), _L("Text")); + } + if (!have) return; + // Drop a solid-face pick now that the text sits on it, so the next Extrude acts on the text. + m_sel_solid_face = m_sel_solid_edge = m_sel_solid_body = -1; + m_doc.recompute(); // a lone sketch yields an empty body; that is expected + refresh_tree(); + set_tree_selection(feat); + sync_sketch_display(); + if (editing) { + set_status_ok(); + return; + } + // A new text still has to be placed: the same move/scale gizmo and Confirm/Cancel card as + // any inserted art. Its Cancel undoes to the checkpoint taken when the text appeared. + on_transform_imported(feat); + m_insert_feat = feat; + open_insert_card(wxString::FromUTF8(m_doc.features[feat].name)); } void DesignPanel::on_import_svg() @@ -4694,8 +4792,14 @@ void DesignPanel::on_import_svg() // app unresponsive and nothing repainted. The dialog is app-modal, so the document cannot be // touched while the worker owns it. Exceptions must not escape the worker: `work` is expected // to swallow them. +// True while a worker owns the document. wxYield below runs pending events — and a queued MCP +// request is one — so the control socket must check this and refuse rather than change the +// document under the worker (DesignPanel::mcp_busy). +static std::atomic s_doc_worker_busy{0}; + static void run_off_ui_thread(wxWindow* parent, const wxString& message, const std::function& work) { + struct Busy { Busy() { ++s_doc_worker_busy; } ~Busy() { --s_doc_worker_busy; } } busy; std::atomic done{false}; std::thread worker([&work, &done]() { work(); @@ -4721,6 +4825,20 @@ static void run_off_ui_thread(wxWindow* parent, const wxString& message, const s worker.join(); } +bool DesignPanel::mcp_busy(bool sketch_method, std::string& why) const +{ + if (s_doc_worker_busy.load() > 0) { why = "the model is being rebuilt; try again when it finishes"; return true; } + // A GUI editor holds a candidate built from the document as it was when it opened; a + // feature added or removed underneath it would be overwritten, or overwrite, on Confirm. + if (m_active != Tool::None || m_edit_index >= 0) { why = "a feature card is open in the Design tab"; return true; } + if (m_text_dlg != nullptr) { why = "the Text dialog is open in the Design tab"; return true; } + if (m_ui_mode == UiMode::Constrain) { why = "the Design tab is constraining a sketch"; return true; } + // Sketch methods drive the live sketch session, which is the point of them; everything else + // changes the feature list, which a live sketch session is about to commit into. + if (!sketch_method && m_ui_mode == UiMode::Sketch) { why = "a sketch is open in the Design tab"; return true; } + return false; +} + // Keep the Model's copy of the recipe in step with the document. // // This used to be written in exactly ONE place — on_commit(), as a side effect of Commit to @@ -4789,10 +4907,8 @@ void DesignPanel::on_import_step() } }); if (solids.empty()) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(err.empty() ? _L("No solids found in STEP") - : (_L("STEP import failed: ") + wxString::FromUTF8(err))); - m_status->Refresh(); + set_status(StatusKind::Error, err.empty() ? _L("No solids found in STEP") + : (wxString::Format(_L("STEP import failed: %s"), kernel_error_text(err)))); return; } m_doc.checkpoint(); // undo boundary: importing STEP solids @@ -4800,7 +4916,7 @@ void DesignPanel::on_import_step() m_feature_counter++; CadFeature f; f.type = CadFeatureType::Import; - f.name = std::string("STEP") + std::to_string(m_feature_counter); + f.name = feature_name("STEP"); f.imported_solid = s; f.mode = BooleanMode::New; // each solid is its own coexisting body m_doc.features.push_back(f); @@ -4814,20 +4930,16 @@ void DesignPanel::on_import_step() } }); if (!rebuilt) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("STEP import failed: ") + wxString::FromUTF8(m_doc.error)); - m_status->Refresh(); + set_status(StatusKind::Error, wxString::Format(_L("STEP import failed: %s"), kernel_error_text(m_doc.error))); return; } set_ui_mode(UiMode::Feature); // imported solids live in the feature timeline refresh_tree(); set_tree_selection(int(m_doc.features.size()) - 1); set_status_ok(); // canonical post-recompute viewport/pick/parts refresh - m_status->SetForegroundColour(wxNullColour); - set_status(wxString::Format( + set_status(StatusKind::Info, wxString::Format( _L("Imported %d solid(s) — pick a face or edge, then Fillet / Cut / Shell to modify"), int(solids.size()))); - m_status->Refresh(); } // Import a triangle mesh as a real B-rep body: the triangles are rebuilt into OCCT faces with @@ -4844,9 +4956,7 @@ void DesignPanel::on_import_mesh() const std::string path(dlg.GetPath().ToUTF8().data()); auto fail = [this](const wxString& msg) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(msg); - m_status->Refresh(); + set_status(StatusKind::Error, msg); }; // Load the triangles with the slicer's own readers — no new mesh dependency. @@ -4859,7 +4969,7 @@ void DesignPanel::on_import_mesh() ObjInfo obj_info; std::string obj_err; if (!load_obj(path.c_str(), &mesh, obj_info, obj_err)) { - fail(_L("Could not read the OBJ file: ") + wxString::FromUTF8(obj_err)); + fail(wxString::Format(_L("Could not read the OBJ file: %s"), wxString::FromUTF8(obj_err))); return; } } else { @@ -4877,7 +4987,8 @@ void DesignPanel::on_import_mesh() "merging, so importing it may take a long time and leave a body that is slow to " "edit. Decimating the mesh first is usually better.\n\nImport anyway?"), int(mesh.its.indices.size())); - if (wxMessageBox(q, _L("Large mesh"), wxYES_NO | wxICON_WARNING, this) != wxYES) + MessageDialog dlg(this, q, _L("Large mesh"), wxYES_NO | wxICON_WARNING); + if (dlg.ShowModal() != wxID_YES) return; } @@ -4888,7 +4999,7 @@ void DesignPanel::on_import_mesh() shape = GeometryEngine::mesh_to_brep(mesh.its, MESH_IMPORT_TOLERANCE, MESH_IMPORT_MERGE_ANGLE_DEG, stats); } catch (const std::exception& e) { - fail(_L("Mesh conversion failed: ") + wxString::FromUTF8(*e.what() ? e.what() : "OCCT error")); + fail(wxString::Format(_L("Mesh conversion failed: %s"), kernel_error_text(e.what()))); return; } if (shape.IsNull()) { fail(_L("Mesh conversion produced no geometry")); return; } @@ -4897,13 +5008,13 @@ void DesignPanel::on_import_mesh() m_feature_counter++; CadFeature f; f.type = CadFeatureType::Import; - f.name = std::string("Mesh") + std::to_string(m_feature_counter); + f.name = feature_name(_L("Mesh")); f.imported_solid = shape; f.mode = BooleanMode::New; // its own coexisting body, like a STEP solid m_doc.features.push_back(f); if (!recompute_guarded(_L("Rebuilding model…"))) { - fail(_L("Mesh import failed: ") + wxString::FromUTF8(m_doc.error)); + fail(wxString::Format(_L("Mesh import failed: %s"), kernel_error_text(m_doc.error))); return; } set_ui_mode(UiMode::Feature); @@ -4915,14 +5026,12 @@ void DesignPanel::on_import_mesh() // watertight, say so and say why (boundary vs non-manifold edges) — that is a defect in the // source mesh the user needs to know about before they start cutting features into it. if (stats.is_solid) { - m_status->SetForegroundColour(wxNullColour); - set_status(wxString::Format( + set_status(StatusKind::Info, wxString::Format( _L("Imported solid — %d triangles → %d faces, volume %.2f mm³. Pick a face or edge, " "then Fillet / Cut / Shell to modify"), stats.kept_tris, stats.faces_final, stats.volume)); } else { - m_status->SetForegroundColour(wxColour(220, 160, 60)); // warning, not an error - set_status(wxString::Format( + set_status(StatusKind::Warning, wxString::Format( _L("Imported as an open shell (not watertight): %d boundary edge(s), %d non-manifold " "edge(s) — %d triangles → %d faces. The source mesh has holes or duplicated " "geometry; boolean features may fail on it"), @@ -4936,27 +5045,24 @@ void DesignPanel::add_imported_sketch( const wxString& base_name) { if (regions.empty()) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("No importable geometry found")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("No importable geometry found")); return; } // DRAWING, not importing: if a sketch is open, the art belongs IN it. The outlines become // ordinary line entities, so they can be constrained, trimmed and extruded with everything // else on that plane. Committing a separate Sketch feature while the user is mid-sketch put - // the text on its own plane-origin feature and left the sketch they were drawing untouched. + // the art on its own plane-origin feature and left the sketch they were drawing untouched. + // (Text no longer comes through here: it is its own feature, see open_text_dialog.) if (m_viewport && m_viewport->is_sketching() && m_viewport->add_sketch_regions(regions)) { - m_status->SetForegroundColour(wxNullColour); - set_status(wxString::Format(_L("%s added to the sketch — Confirm to commit it"), + set_status(StatusKind::Info, wxString::Format(_L("%s added to the sketch — Confirm to commit it"), base_name)); - m_status->Refresh(); return; } m_doc.checkpoint(); // undo boundary: importing Text/SVG art m_feature_counter++; CadFeature f; f.type = CadFeatureType::Sketch; - f.name = std::string(base_name.ToUTF8().data()) + std::to_string(m_feature_counter); + f.name = feature_name(base_name); f.imported_regions = regions; // #4: when a solid face is selected, drop the art ON that face, centred on it (ready to @@ -5010,9 +5116,7 @@ void DesignPanel::open_insert_card(const wxString& base_name) update_cards_frame(); m_form->Layout(); m_form->FitInside(); update_action_bar(); // surface the unified ✓/✗ - m_status->SetForegroundColour(wxNullColour); - set_status(base_name + _L(" — drag to place/size, then Confirm")); - m_status->Refresh(); + set_status(StatusKind::Info, base_name + _L(" — drag to place/size, then Confirm")); } // Confirm: keep the placed art and leave the placement gizmo. The feature is already in @@ -5040,9 +5144,7 @@ void DesignPanel::cancel_insert() set_ui_mode(UiMode::Feature); sync_sketch_display(); refresh_tree(); - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Insert cancelled")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("Insert cancelled")); } void DesignPanel::on_transform_imported(int feat_idx) @@ -5056,9 +5158,7 @@ void DesignPanel::on_transform_imported(int feat_idx) // the centre to move. Values stream back via set_on_imported_transform. m_viewport->begin_imported_transform(feat_idx, f.imported_regions, f.plane, f.import_offset, f.import_scale_x, f.import_scale_y); - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Drag a corner to scale, the centre to move — right-click when done")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("Drag a corner to scale, the center to move — Enter or ✓ when done, Esc to discard")); } void DesignPanel::on_add_sketch() @@ -5069,10 +5169,9 @@ void DesignPanel::on_add_sketch() SketchPlane plane = sketch_plane_from_selection(where); // picked face, else the 3D plane click m_feature_counter++; m_doc.add_sketch(shape, plane, m_width->GetValue(), m_height->GetValue(), - m_radius->GetValue(), "Sketch" + std::to_string(m_feature_counter)); + m_radius->GetValue(), feature_name(_L("Sketch"))); m_doc.recompute(); // a lone sketch yields an empty body; that is expected - m_status->SetForegroundColour(wxNullColour); - set_status(wxString::Format(_L("Sketch added on %s — select it, then right-click to Extrude"), where)); + set_status(StatusKind::Info, wxString::Format(_L("Sketch added on %s — select it, then right-click to Extrude"), where)); refresh_tree(); } @@ -5081,18 +5180,25 @@ void DesignPanel::on_add_sketch() bool DesignPanel::extrude_uses_loop() const { return m_viewport != nullptr - && m_sel_sketch_region >= 0 + && m_viewport->loop_pick_region() >= 0 && m_extrude_sketch_ref >= 0 - && m_extrude_sketch_ref == m_sel_sketch_feat + && m_extrude_sketch_ref == m_viewport->loop_pick_feature() && m_extrude_sketch_ref < int(m_doc.features.size()) && !m_viewport->selected_loop_entities().empty(); } +// Default name of the feature being added: the card's own header word and number ("Extrude 3"), +// so the tree row and the card title that preceded it read the same, in the user's language. +std::string DesignPanel::feature_name(const wxString& kind) const +{ + return std::string((kind + wxString::Format(" %d", m_feature_counter)).ToUTF8().data()); +} + void DesignPanel::on_add_extrude() { BooleanMode mode = static_cast(m_mode->GetSelection()); // New/Add/Cut/Intersect m_feature_counter++; - const std::string name = "Extrude" + std::to_string(m_feature_counter); + const std::string name = feature_name(_L("Extrude")); int idx = -1; if (m_extrude_face_src >= 0) { // Onshape face-extrude: the picked solid face is the profile (no sketch wire). @@ -5103,13 +5209,12 @@ void DesignPanel::on_add_extrude() // other loops intact and still selectable. if (::getenv("ORCA_CAD_PICK_TRACE")) std::fprintf(stderr, "[pick] on_add_extrude: feat=%d reg=%d ents=%zu\n", - m_extrude_sketch_ref, m_sel_sketch_region, + m_extrude_sketch_ref, m_viewport->loop_pick_region(), m_viewport->selected_loop_entities().size()); idx = m_doc.add_extrude_entities(m_viewport->selected_loop_entities(), m_doc.features[m_extrude_sketch_ref].plane, m_distance->GetValue(), false, mode, name); - m_sel_sketch_region = -1; // consume the loop selection - m_viewport->clear_loop_pick(); // drop the now-stale loop highlight + m_viewport->clear_loop_pick(); // consume the loop selection } else { idx = m_doc.add_extrude(m_extrude_sketch_ref, m_distance->GetValue(), false, mode, name); } @@ -5122,7 +5227,8 @@ void DesignPanel::on_add_extrude() f.taper_deg = m_taper->GetValue(); f.flip = m_flip->GetValue(); f.up_to_face = (f.extrude_end == ExtrudeEnd::UpToFace) ? m_sel_solid_face : -1; - f.target_body = m_sel_solid_body; // multi-body: act on the picked body (-1 = last) + // multi-body: act on the picked body, else the body the profile touches (-1 = last) + f.target_body = m_sel_solid_body >= 0 ? m_sel_solid_body : m_extrude_auto_body; // On-face Text/SVG remembers its host body even after the face pick was cleared by // the placement recompute, so the engraving Cut hits the right solid. if (m_extrude_sketch_ref >= 0 && m_extrude_sketch_ref < int(m_doc.features.size()) @@ -5130,7 +5236,7 @@ void DesignPanel::on_add_extrude() f.target_body = m_doc.features[m_extrude_sketch_ref].import_face_body; } if (!recompute_guarded(_L("Rebuilding model…"))) - set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + set_status(StatusKind::Error, wxString::Format(_L("The model could not be rebuilt: %s"), kernel_error_text(m_doc.error))); else set_status_ok(); refresh_tree(); @@ -5147,23 +5253,24 @@ void DesignPanel::on_add_dressup() bool fillet = (m_dressup_type->GetSelection() == 0); m_feature_counter++; - // A click-selected solid edge targets THAT edge; otherwise dress the whole face-group. + // Click-selected solid edges are the target; otherwise dress the whole face-group. int didx = -1; - if (m_sel_solid_edge >= 0) { + const std::vector edges = dressup_edges(); + if (!edges.empty()) { if (fillet) - didx = m_doc.add_fillet(sz, m_sel_solid_edge, "Fillet" + std::to_string(m_feature_counter)); + didx = m_doc.add_fillet(sz, edges, feature_name(_L("Fillet"))); else - didx = m_doc.add_chamfer(sz, m_sel_solid_edge, "Chamfer" + std::to_string(m_feature_counter)); + didx = m_doc.add_chamfer(sz, edges, feature_name(_L("Chamfer"))); } else if (fillet) - didx = m_doc.add_fillet(sz, fg, "Fillet" + std::to_string(m_feature_counter)); + didx = m_doc.add_fillet(sz, fg, feature_name(_L("Fillet"))); else - didx = m_doc.add_chamfer(sz, fg, "Chamfer" + std::to_string(m_feature_counter)); + didx = m_doc.add_chamfer(sz, fg, feature_name(_L("Chamfer"))); // Dress the picked body (its face/edge ids are body-local). -1 = last body. if (didx >= 0 && didx < int(m_doc.features.size())) m_doc.features[didx].target_body = m_sel_solid_body; if (!recompute_guarded(_L("Rebuilding model…"))) - set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + set_status(StatusKind::Error, wxString::Format(_L("The model could not be rebuilt: %s"), kernel_error_text(m_doc.error))); else set_status_ok(); @@ -5219,13 +5326,13 @@ void DesignPanel::on_add_hole() m_feature_counter++; const int hidx = m_doc.add_hole(dia, depth, through, px, py, plane, - "Hole" + std::to_string(m_feature_counter)); + feature_name(_L("Hole"))); // On-face holes drill the body the face belongs to (even after the pick was cleared). if (m_hole_on_face && hidx >= 0 && hidx < int(m_doc.features.size())) m_doc.features[hidx].target_body = m_hole_face_body; if (!recompute_guarded(_L("Rebuilding model…"))) - set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + set_status(StatusKind::Error, wxString::Format(_L("The model could not be rebuilt: %s"), kernel_error_text(m_doc.error))); else set_status_ok(); @@ -5250,47 +5357,45 @@ void DesignPanel::apply_thread_standard() m_thread_std->GetString(sel).utf8_string()); if (!s) return; - // Pitch and depth are the defining "measures" of the standard — always apply. + // Pitch and depth are the defining "measures" of the standard — always apply. The field + // holds the NOMINAL (major) diameter for both roles; the kernel bores an internal thread to + // major - 2 x depth, so its depth is the tapped one, (major - minor) / 2. + const bool internal = m_thread_internal && m_thread_internal->GetValue(); if (m_thread_pitch) m_thread_pitch->SetValue(s->pitch_mm); - if (m_thread_depth) m_thread_depth->SetValue(s->thread_depth_mm()); - - // Nominal diameter: external rod = major diameter; internal tapped bore = minor (tap-drill) - // diameter. On a picked cylindrical surface/edge the diameter comes from the real geometry, - // so don't override it there. (The field holds DIAMETER.) - if (!m_thread_on_face && m_thread_radius) { - const bool internal = m_thread_internal && m_thread_internal->GetValue(); - const double d = internal ? s->minor_diameter_mm() : s->major_diameter_mm; - m_thread_radius->SetValue(d); - } + if (m_thread_depth) m_thread_depth->SetValue(internal ? s->internal_depth_mm() : s->thread_depth_mm()); + if (m_thread_radius) m_thread_radius->SetValue(s->major_diameter_mm); if (m_status) set_status(wxString::Format(_L("Thread standard: %s (pitch %.3g mm)"), m_thread_std->GetString(sel), s->pitch_mm)); } -void DesignPanel::infer_thread_spec(double diameter) +void DesignPanel::infer_thread_spec(double diameter, bool internal) { - // Snap to the nearest standard thread by nominal (major) diameter, so picking a Ø9.9 boss - // gives M10 — the M diameter, pitch AND depth all follow from the cylinder's base diameter. + // Snap to the nearest standard thread, so picking a Ø9.9 boss gives M10 and a Ø5 tap-drill + // hole gives M6 — the M diameter, pitch AND depth all follow from the cylinder. A boss is + // matched on the MAJOR diameter; a hole on the MINOR one, since a hole to be tapped is + // drilled at the tap-drill size. const auto& stds = thread_standards(); int best = -1; double bestErr = 1e30; for (int i = 0; i < int(stds.size()); ++i) { - const double e = std::abs(stds[i].major_diameter_mm - diameter); + const double ref = internal ? stds[i].minor_diameter_mm() : stds[i].major_diameter_mm; + const double e = std::abs(ref - diameter); if (e < bestErr) { bestErr = e; best = i; } } if (best < 0) { if (m_thread_radius) m_thread_radius->SetValue(diameter); return; } const ThreadSpec& s = stds[best]; if (m_thread_std) m_thread_std->SetSelection(best + 1); // row 0 is "Custom" - if (m_thread_radius) m_thread_radius->SetValue(s.major_diameter_mm); // field = DIAMETER + if (m_thread_radius) m_thread_radius->SetValue(s.major_diameter_mm); // field = NOMINAL diameter if (m_thread_pitch) m_thread_pitch->SetValue(s.pitch_mm); - if (m_thread_depth) m_thread_depth->SetValue(s.thread_depth_mm()); + if (m_thread_depth) m_thread_depth->SetValue(internal ? s.internal_depth_mm() : s.thread_depth_mm()); } void DesignPanel::on_add_thread() { bool internal = m_thread_internal->GetValue(); if (internal && m_doc.body.IsNull()) { - set_status(_L("Thread needs a solid body — add or import one first")); + set_status(StatusKind::Warning, _L("Thread needs a solid body — add or import one first")); return; } SketchPlane plane = thread_plane(); @@ -5299,13 +5404,13 @@ void DesignPanel::on_add_thread() const int tidx = m_doc.add_thread(m_thread_radius->GetValue() * 0.5, m_thread_pitch->GetValue(), m_thread_height->GetValue(), m_thread_depth->GetValue(), internal, m_thread_x->GetValue(), m_thread_y->GetValue(), - plane, "Thread" + std::to_string(m_feature_counter)); + plane, feature_name(_L("Thread"))); // On-surface internal thread taps the body the cylindrical face belongs to. if (m_thread_on_face && tidx >= 0 && tidx < int(m_doc.features.size())) m_doc.features[tidx].target_body = m_thread_face_body; if (!recompute_guarded(_L("Rebuilding model…"))) - set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + set_status(StatusKind::Error, wxString::Format(_L("The model could not be rebuilt: %s"), kernel_error_text(m_doc.error))); else set_status_ok(); @@ -5320,16 +5425,18 @@ void DesignPanel::on_add_revolve() } const BooleanMode mode = static_cast(m_revolve_mode->GetSelection()); if (mode != BooleanMode::New && m_doc.body.IsNull()) { - set_status(_L("Revolve needs a solid body — add or import one first")); + set_status(StatusKind::Warning, _L("Revolve needs a solid body — add or import one first")); return; } m_feature_counter++; - m_doc.add_revolve(m_revolve_sketch_ref, m_revolve_angle->GetValue(), - m_revolve_axis->GetSelection(), m_revolve_flip->GetValue(), - mode, "Revolve" + std::to_string(m_feature_counter)); + int axis = 0, axis_entity = -1; + read_revolve_axis(m_revolve_axis, m_revolve_axis_ents, axis, axis_entity); + const int idx = m_doc.add_revolve(m_revolve_sketch_ref, m_revolve_angle->GetValue(), axis, + m_revolve_flip->GetValue(), mode, feature_name(_L("Revolve"))); + m_doc.features[idx].revolve_axis_entity = axis_entity; if (!recompute_guarded(_L("Rebuilding model…"))) - set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + set_status(StatusKind::Error, wxString::Format(_L("The model could not be rebuilt: %s"), kernel_error_text(m_doc.error))); else set_status_ok(); @@ -5351,15 +5458,15 @@ void DesignPanel::on_add_sweep() } const BooleanMode mode = static_cast(m_sweep_mode->GetSelection()); if (mode != BooleanMode::New && m_doc.body.IsNull()) { - set_status(_L("Sweep needs a solid body — add or import one first")); + set_status(StatusKind::Warning, _L("Sweep needs a solid body — add or import one first")); return; } m_feature_counter++; m_doc.add_sweep(m_sweep_profile_ref, path_ref, mode, - "Sweep" + std::to_string(m_feature_counter)); + feature_name(_L("Sweep"))); if (!recompute_guarded(_L("Rebuilding model…"))) - set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + set_status(StatusKind::Error, wxString::Format(_L("The model could not be rebuilt: %s"), kernel_error_text(m_doc.error))); else set_status_ok(); @@ -5379,15 +5486,15 @@ void DesignPanel::on_add_loft() } const BooleanMode mode = static_cast(m_loft_mode->GetSelection()); if (mode != BooleanMode::New && m_doc.body.IsNull()) { - set_status(_L("Loft needs a solid body — add or import one first")); + set_status(StatusKind::Warning, _L("Loft needs a solid body — add or import one first")); return; } m_feature_counter++; m_doc.add_loft(refs, m_loft_ruled->GetValue(), mode, - "Loft" + std::to_string(m_feature_counter)); + feature_name(_L("Loft"))); if (!recompute_guarded(_L("Rebuilding model…"))) - set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + set_status(StatusKind::Error, wxString::Format(_L("The model could not be rebuilt: %s"), kernel_error_text(m_doc.error))); else set_status_ok(); @@ -5402,9 +5509,9 @@ void DesignPanel::on_add_surface_extrude() } m_feature_counter++; m_doc.add_surface_extrude(m_surf_extrude_sketch_ref, m_surf_extrude_distance->GetValue(), - "SurfaceExtrude" + std::to_string(m_feature_counter)); + feature_name(_L("Surface Extrude"))); if (!recompute_guarded(_L("Rebuilding model…"))) - set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + set_status(StatusKind::Error, wxString::Format(_L("The model could not be rebuilt: %s"), kernel_error_text(m_doc.error))); else set_status_ok(); refresh_tree(); @@ -5417,11 +5524,13 @@ void DesignPanel::on_add_surface_revolve() return; } m_feature_counter++; - m_doc.add_surface_revolve(m_surf_revolve_sketch_ref, m_surf_revolve_angle->GetValue(), - m_surf_revolve_axis->GetSelection(), - "SurfaceRevolve" + std::to_string(m_feature_counter)); + int axis = 0, axis_entity = -1; + read_revolve_axis(m_surf_revolve_axis, m_surf_revolve_axis_ents, axis, axis_entity); + const int idx = m_doc.add_surface_revolve(m_surf_revolve_sketch_ref, m_surf_revolve_angle->GetValue(), + axis, feature_name(_L("Surface Revolve"))); + m_doc.features[idx].revolve_axis_entity = axis_entity; if (!recompute_guarded(_L("Rebuilding model…"))) - set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + set_status(StatusKind::Error, wxString::Format(_L("The model could not be rebuilt: %s"), kernel_error_text(m_doc.error))); else set_status_ok(); refresh_tree(); @@ -5439,9 +5548,9 @@ void DesignPanel::on_add_surface_loft() } m_feature_counter++; m_doc.add_surface_loft(refs, m_surf_loft_ruled->GetValue(), - "SurfaceLoft" + std::to_string(m_feature_counter)); + feature_name(_L("Surface Loft"))); if (!recompute_guarded(_L("Rebuilding model…"))) - set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + set_status(StatusKind::Error, wxString::Format(_L("The model could not be rebuilt: %s"), kernel_error_text(m_doc.error))); else set_status_ok(); refresh_tree(); @@ -5455,9 +5564,9 @@ void DesignPanel::on_add_surface_fill() } m_feature_counter++; m_doc.add_surface_fill(m_surf_fill_sketch_ref, - "SurfaceFill" + std::to_string(m_feature_counter)); + feature_name(_L("Surface Fill"))); if (!recompute_guarded(_L("Rebuilding model…"))) - set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + set_status(StatusKind::Error, wxString::Format(_L("The model could not be rebuilt: %s"), kernel_error_text(m_doc.error))); else set_status_ok(); refresh_tree(); @@ -5472,9 +5581,9 @@ void DesignPanel::on_add_surface_offset() } m_feature_counter++; m_doc.add_surface_offset(sel, m_surf_offset_distance->GetValue(), - "SurfaceOffset" + std::to_string(m_feature_counter)); + feature_name(_L("Surface Offset"))); if (!recompute_guarded(_L("Rebuilding model…"))) - set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + set_status(StatusKind::Error, wxString::Format(_L("The model could not be rebuilt: %s"), kernel_error_text(m_doc.error))); else set_status_ok(); refresh_tree(); @@ -5490,9 +5599,9 @@ void DesignPanel::on_add_thicken_surface() m_feature_counter++; m_doc.add_thicken_surface(sel, m_surf_thicken_thickness->GetValue(), m_surf_thicken_flip->GetValue(), - "ThickenSurface" + std::to_string(m_feature_counter)); + feature_name(_L("Thicken Surface"))); if (!recompute_guarded(_L("Rebuilding model…"))) - set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + set_status(StatusKind::Error, wxString::Format(_L("The model could not be rebuilt: %s"), kernel_error_text(m_doc.error))); else set_status_ok(); refresh_tree(); @@ -5575,7 +5684,7 @@ void DesignPanel::on_add_transform() m_move_body = -1; } if (m_doc.bodies.empty()) { - set_status(_L("Transform needs a body — add or import one first")); + set_status(StatusKind::Warning, _L("Transform needs a body — add or import one first")); return; } const int sel = m_xf_body->GetSelection(); @@ -5586,9 +5695,9 @@ void DesignPanel::on_add_transform() const Vec3d pivot(m_xf_pivot_x->GetValue(), m_xf_pivot_y->GetValue(), m_xf_pivot_z->GetValue()); m_feature_counter++; m_doc.add_transform(target, trans, axis, pivot, m_xf_angle->GetValue(), m_xf_copy->GetValue(), - "Transform" + std::to_string(m_feature_counter)); + feature_name(_L("Transform"))); if (!recompute_guarded(_L("Rebuilding model…"))) - set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + set_status(StatusKind::Error, wxString::Format(_L("The model could not be rebuilt: %s"), kernel_error_text(m_doc.error))); else set_status_ok(); refresh_tree(); @@ -5597,7 +5706,7 @@ void DesignPanel::on_add_transform() void DesignPanel::on_add_mirror() { if (m_doc.bodies.empty()) { - set_status(_L("Mirror needs a body — add or import one first")); + set_status(StatusKind::Warning, _L("Mirror needs a body — add or import one first")); return; } const int sel = m_mirror_body->GetSelection(); @@ -5605,9 +5714,9 @@ void DesignPanel::on_add_mirror() const BooleanMode mode = m_mirror_keep->GetValue() ? BooleanMode::New : BooleanMode::Add; m_feature_counter++; m_doc.add_mirror(plane_from_choice(m_mirror_plane->GetSelection()), target, mode, - "Mirror" + std::to_string(m_feature_counter)); + feature_name(_L("Mirror"))); if (!recompute_guarded(_L("Rebuilding model…"))) - set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + set_status(StatusKind::Error, wxString::Format(_L("The model could not be rebuilt: %s"), kernel_error_text(m_doc.error))); else set_status_ok(); refresh_tree(); @@ -5616,22 +5725,20 @@ void DesignPanel::on_add_mirror() void DesignPanel::on_add_thicken() { if (m_doc.bodies.empty()) { - set_status(_L("Thicken needs a solid body — add or import one first")); + set_status(StatusKind::Warning, _L("Thicken needs a solid body — add or import one first")); return; } if (m_sel_solid_face < 0) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Pick a solid face to thicken first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Pick a solid face to thicken first")); return; } const int sel = m_thicken_body->GetSelection(); const int target = (sel != wxNOT_FOUND) ? sel : -1; m_feature_counter++; m_doc.add_thicken(target, m_sel_solid_face, m_thicken_thickness->GetValue(), - m_thicken_flip->GetValue(), "Thicken" + std::to_string(m_feature_counter)); + m_thicken_flip->GetValue(), feature_name(_L("Thicken"))); if (!recompute_guarded(_L("Rebuilding model…"))) - set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + set_status(StatusKind::Error, wxString::Format(_L("The model could not be rebuilt: %s"), kernel_error_text(m_doc.error))); else set_status_ok(); refresh_tree(); @@ -5640,7 +5747,7 @@ void DesignPanel::on_add_thicken() void DesignPanel::on_add_rib() { if (m_doc.bodies.empty()) { - set_status(_L("Rib needs a solid body — add or import one first")); + set_status(StatusKind::Warning, _L("Rib needs a solid body — add or import one first")); return; } const int bsel = m_rib_body->GetSelection(); @@ -5654,9 +5761,9 @@ void DesignPanel::on_add_rib() } m_feature_counter++; m_doc.add_rib(sketch_ref, m_rib_entity->GetValue(), m_rib_thickness->GetValue(), - m_rib_depth->GetValue(), target, "Rib" + std::to_string(m_feature_counter)); + m_rib_depth->GetValue(), target, feature_name(_L("Rib"))); if (!recompute_guarded(_L("Rebuilding model…"))) - set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + set_status(StatusKind::Error, wxString::Format(_L("The model could not be rebuilt: %s"), kernel_error_text(m_doc.error))); else set_status_ok(); refresh_tree(); @@ -5665,7 +5772,7 @@ void DesignPanel::on_add_rib() void DesignPanel::on_add_project() { if (m_doc.bodies.empty()) { - set_status(_L("Project needs a body — add or import one first")); + set_status(StatusKind::Warning, _L("Project needs a body — add or import one first")); return; } const int sel = m_proj_source_body->GetSelection(); @@ -5674,9 +5781,9 @@ void DesignPanel::on_add_project() m_feature_counter++; m_doc.add_project_edges(src_body, {}, face, plane_from_choice(m_proj_plane->GetSelection()), - "Project" + std::to_string(m_feature_counter)); + feature_name(_L("Project"))); if (!recompute_guarded(_L("Rebuilding model…"))) - set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + set_status(StatusKind::Error, wxString::Format(_L("The model could not be rebuilt: %s"), kernel_error_text(m_doc.error))); else set_status_ok(); refresh_tree(); @@ -5685,22 +5792,20 @@ void DesignPanel::on_add_project() void DesignPanel::on_add_delete_face() { if (m_doc.bodies.empty()) { - set_status(_L("Delete Face needs a body — add or import one first")); + set_status(StatusKind::Warning, _L("Delete Face needs a body — add or import one first")); return; } if (m_del_faces.empty()) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Add at least one face to delete first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Add at least one face to delete first")); return; } const int sel = m_del_face_body->GetSelection(); const int target = (sel != wxNOT_FOUND) ? sel : -1; m_feature_counter++; - m_doc.add_delete_face(target, m_del_faces, "DeleteFace" + std::to_string(m_feature_counter)); + m_doc.add_delete_face(target, m_del_faces, feature_name(_L("Delete Face"))); m_del_faces.clear(); // consumed; fresh state for the next use if (!recompute_guarded(_L("Rebuilding model…"))) - set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + set_status(StatusKind::Error, wxString::Format(_L("The model could not be rebuilt: %s"), kernel_error_text(m_doc.error))); else set_status_ok(); refresh_tree(); @@ -5712,9 +5817,9 @@ void DesignPanel::on_add_helix() m_doc.add_helix(plane_from_choice(m_helix_plane->GetSelection()), m_helix_radius->GetValue(), m_helix_pitch->GetValue(), m_helix_height->GetValue(), m_helix_left_handed->GetValue(), - m_helix_taper->GetValue(), "Helix" + std::to_string(m_feature_counter)); + m_helix_taper->GetValue(), feature_name(_L("Helix"))); if (!recompute_guarded(_L("Rebuilding model…"))) - set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + set_status(StatusKind::Error, wxString::Format(_L("The model could not be rebuilt: %s"), kernel_error_text(m_doc.error))); else set_status_ok(); refresh_tree(); @@ -5725,32 +5830,26 @@ void DesignPanel::on_add_mate() const int sel_a = m_mate_cs_a->GetSelection(); const int sel_b = m_mate_cs_b->GetSelection(); if (sel_a == wxNOT_FOUND || sel_b == wxNOT_FOUND) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Mate needs two CoordSys features — create them first")); - m_status->Refresh(); + set_status(StatusKind::Warning, _L("Mate needs two coordinate systems — create them first")); return; } const int cs_a = int(reinterpret_cast(m_mate_cs_a->GetClientData(sel_a))); const int cs_b = int(reinterpret_cast(m_mate_cs_b->GetClientData(sel_b))); if (cs_a == cs_b) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Mate: CS A and CS B must be different CoordSys features")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Mate: A and B must be different coordinate systems")); return; } m_feature_counter++; int idx = m_doc.add_mate(m_mate_kind->GetSelection(), cs_a, cs_b, m_mate_offset->GetValue(), m_mate_angle->GetValue(), m_mate_flip->GetValue(), - "Mate" + std::to_string(m_feature_counter)); + feature_name(_L("Mate"))); if (idx < 0) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Mate rejected")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Mate rejected")); return; } if (!recompute_guarded(_L("Rebuilding model…"))) - set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + set_status(StatusKind::Error, wxString::Format(_L("The model could not be rebuilt: %s"), kernel_error_text(m_doc.error))); else set_status_ok(); refresh_tree(); @@ -5759,86 +5858,60 @@ void DesignPanel::on_add_mate() void DesignPanel::on_check_interference() { if (m_doc.bodies.size() < 2) { - m_status->SetForegroundColour(wxNullColour); - set_status(_L("No interference — need at least two solid bodies to check")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("Interference needs at least two bodies")); return; } const auto pairs = m_doc.check_interference(); if (pairs.empty()) { - m_status->SetForegroundColour(wxNullColour); - set_status(_L("No interference found")); - m_status->Refresh(); + set_status(StatusKind::Ok, _L("No interference found")); return; } - double worst = 0; - for (const auto& p : pairs) - if (p.volume > worst) worst = p.volume; - m_status->SetForegroundColour(wxNullColour); - set_status(wxString::Format(_L("%zu interference pairs, worst %.2f mm³"), - pairs.size(), worst)); - m_status->Refresh(); - wxString msg = _L("Interference pairs:\n\n"); - for (const auto& p : pairs) { - // 1-based, like every other body label in this panel and in the parts tree: - // reporting "Body 1" for what the tree calls "Body 2" is worse than no name. - wxString na = wxString::Format(_L("Body %d"), p.body_a + 1); - wxString nb = wxString::Format(_L("Body %d"), p.body_b + 1); - if (p.body_a >= 0 && p.body_a < int(m_doc.bodies.size()) && !m_doc.bodies[p.body_a].name.empty()) - na = wxString::FromUTF8(m_doc.bodies[p.body_a].name); - if (p.body_b >= 0 && p.body_b < int(m_doc.bodies.size()) && !m_doc.bodies[p.body_b].name.empty()) - nb = wxString::FromUTF8(m_doc.bodies[p.body_b].name); - msg += wxString::Format("%s <-> %s: %.4f mm³\n", na, nb, p.volume); - } - wxMessageBox(msg, _L("Interference"), wxOK, this); + // A report, so it goes to the status line like every other report — no modal to dismiss. + // Worst overlap first; 1-based names, the wording the parts tree uses. + auto body_name = [this](int b) { + if (b >= 0 && b < int(m_doc.bodies.size()) && !m_doc.bodies[b].name.empty()) + return wxString::FromUTF8(m_doc.bodies[b].name); + return wxString::Format(_L("Body %d"), b + 1); + }; + auto sorted = pairs; + std::sort(sorted.begin(), sorted.end(), [](const auto& a, const auto& b) { return a.volume > b.volume; }); + wxString list; + const size_t shown = std::min(sorted.size(), 3); + for (size_t k = 0; k < shown; ++k) + list += (k ? ", " : "") + wxString::Format(_L("%s ↔ %s %.2f mm³"), body_name(sorted[k].body_a), + body_name(sorted[k].body_b), sorted[k].volume); + if (sorted.size() > shown) + list += wxString::FromUTF8(", …"); + set_status(StatusKind::Warning, wxString::Format(_L("%zu overlapping pairs: %s"), sorted.size(), list)); } -// Mass properties of the selected solid. A report, not a feature: it never checkpoints, never -// recomputes and never opens a card, which is why it sits beside the interference check rather -// than in the on_add_* family. The caller only reaches us with m_sel_solid_body in range. +// Volume and surface area of the selected body. A report, not a feature: it never checkpoints, +// never recomputes and never opens a card, which is why it sits beside the interference check +// rather than in the on_add_* family. It reports geometry only — there is no density, so no mass. void DesignPanel::on_mass_properties() { // This bounds check is not defensive padding — it is what makes the verb safe to fire from - // the socket, which has no offer menu to grey the row out. The menu-only route never reached - // here with nothing selected; run_verb does. Nothing selected is not an error, hence the - // neutral colour, not the error red. + // the socket, which has no offer menu to grey the row out. if (m_sel_solid_body < 0 || m_sel_solid_body >= int(m_doc.bodies.size())) { - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Select a solid body first — its mass properties are what is reported")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("Select a body to measure it")); return; } const auto mp = GeometryEngine::mass_properties(m_doc.bodies[m_sel_solid_body].shape); if (!mp.valid) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Mass properties could not be computed for this body")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("This body could not be measured")); return; } - // 1-based, and the body's own name when it has one — the same wording the parts list uses. wxString name = wxString::Format(_L("Body %d"), m_sel_solid_body + 1); if (!m_doc.bodies[m_sel_solid_body].name.empty()) name = wxString::FromUTF8(m_doc.bodies[m_sel_solid_body].name); - if (!mp.is_solid) { + // Same units and precision as the interference report: mm³ and mm², two decimals. + if (!mp.is_solid) // Sheet body: quoting a volume here would be inventing material that is not there. - m_status->SetForegroundColour(wxNullColour); - set_status(wxString::Format(_L("%s: sheet body — %.2f cm² of surface, no volume"), - name, mp.surface_area / 100.0)); - m_status->Refresh(); - wxMessageBox(wxString::Format(_L("%s\n\nSheet body (open shell)\nSurface area: %.2f cm²\n\n" - "A sheet encloses no material, so it has no volume. " - "Thicken it into a solid to get one."), - name, mp.surface_area / 100.0), - _L("Mass properties"), wxOK, this); - return; - } - m_status->SetForegroundColour(wxNullColour); - set_status(wxString::Format(_L("%s: %.3f cm³, %.2f cm²"), - name, mp.volume / 1000.0, mp.surface_area / 100.0)); - m_status->Refresh(); - wxMessageBox(wxString::Format(_L("%s\n\nVolume: %.3f cm³\nSurface area: %.2f cm²"), - name, mp.volume / 1000.0, mp.surface_area / 100.0), - _L("Mass properties"), wxOK, this); + set_status(StatusKind::Info, wxString::Format(_L("%s: sheet body, area %.2f mm², no volume — thicken it to get one"), + name, mp.surface_area)); + else + set_status(StatusKind::Info, wxString::Format(_L("%s: volume %.2f mm³, area %.2f mm²"), + name, mp.volume, mp.surface_area)); } // The rows are only the SHEET bodies, so a row index is NOT a body index — with a solid at 0 @@ -5884,7 +5957,7 @@ void DesignPanel::select_sheet_choice(ComboBox* c, int body) void DesignPanel::on_add_pattern() { if (m_doc.bodies.empty()) { - set_status(_L("Pattern needs a solid body — add or import one first")); + set_status(StatusKind::Warning, _L("Pattern needs a solid body — add or import one first")); return; } const bool circular = (m_pattern_type->GetSelection() == 1); @@ -5894,10 +5967,10 @@ void DesignPanel::on_add_pattern() m_doc.add_pattern(circular, int(m_pattern_count->GetValue()), m_pattern_spacing->GetValue(), m_pattern_dir->GetSelection(), m_pattern_angle->GetValue(), target, - "Pattern" + std::to_string(m_feature_counter)); + feature_name(_L("Pattern"))); if (!recompute_guarded(_L("Rebuilding model…"))) - set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + set_status(StatusKind::Error, wxString::Format(_L("The model could not be rebuilt: %s"), kernel_error_text(m_doc.error))); else set_status_ok(); @@ -5969,7 +6042,7 @@ void DesignPanel::fill_body_choice(ComboBox* c, int as_of_feature, int want) void DesignPanel::on_add_boolean() { if (m_doc.bodies.size() < 2) { - set_status(_L("Boolean needs two solid bodies — add or import a second one")); + set_status(StatusKind::Warning, _L("Boolean needs two bodies — add or import a second one")); return; } const int sel = m_bool_op->GetSelection(); @@ -5979,9 +6052,9 @@ void DesignPanel::on_add_boolean() m_feature_counter++; m_doc.add_boolean(op, m_bool_target->GetSelection(), m_bool_tool->GetSelection(), m_bool_keep->GetValue(), m_bool_tol->GetValue(), -1, -1, - "Boolean" + std::to_string(m_feature_counter)); + feature_name(_L("Boolean"))); if (!recompute_guarded(_L("Rebuilding model…"))) - set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + set_status(StatusKind::Error, wxString::Format(_L("The model could not be rebuilt: %s"), kernel_error_text(m_doc.error))); else set_status_ok(); refresh_tree(); @@ -5990,15 +6063,15 @@ void DesignPanel::on_add_boolean() void DesignPanel::on_add_cut() { if (m_doc.bodies.empty()) { - set_status(_L("Cut needs a solid body — add or import one first")); + set_status(StatusKind::Warning, _L("Cut needs a solid body — add or import one first")); return; } m_feature_counter++; m_doc.add_cut(plane_from_choice(m_cut_plane->GetSelection()), m_cut_offset->GetValue(), /*flip*/ false, /*keep_upper*/ true, /*keep_lower*/ true, - m_cut_target->GetSelection(), "Cut" + std::to_string(m_feature_counter)); + m_cut_target->GetSelection(), feature_name(_L("Cut"))); if (!recompute_guarded(_L("Rebuilding model…"))) - set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + set_status(StatusKind::Error, wxString::Format(_L("The model could not be rebuilt: %s"), kernel_error_text(m_doc.error))); else set_status_ok(); refresh_tree(); @@ -6144,7 +6217,12 @@ int DesignPanel::offer_selection_kind() const m_sel_solid_face); return int(GeometryEngine::cylinder_of_face(f).ok ? OfferSel::FaceCyl : OfferSel::FaceOther); } - if (m_sel_sketch_region >= 0) + // Validated like the body index above: a loop pick that names no sketch, or no closed region + // of one, must not title the menu "Sketch profile" and grey Create, which is what a stale one + // did over an empty document. + if (const int lf = m_viewport ? m_viewport->loop_pick_feature() : -1; + lf >= 0 && lf < int(m_doc.features.size()) && m_doc.features[lf].type == CadFeatureType::Sketch + && m_viewport->loop_pick_region() >= 0) return int(OfferSel::SkLoop); if (m_sel_solid_body >= 0 && m_sel_solid_body < nb) return int(CadDocument::is_sheet_shape(m_doc.bodies[m_sel_solid_body].shape) @@ -6216,9 +6294,52 @@ wxPoint DesignPanel::offer_anchor() const // One place that writes the status line, so every hint wraps instead of clipping at the panel // edge. wxStaticText::Wrap() is destructive, which is fine here: the label is replaced whole // each time, never appended to. -void DesignPanel::set_status(const wxString& text) +// Kernel and OCCT messages are written for developers ("fuse failed", "BRep_API: command not +// done", an empty string). The status line is for the person modelling, so the recurring ones +// are said in the drawing office's words; anything unrecognised is still shown rather than +// hidden, because a vague sentence that hides the only clue is worse than a terse one. +wxString DesignPanel::kernel_error_text(const std::string& err) +{ + struct Map { const char* needle; const char* sentence; }; + static const Map kMap[] = { + { "fuse failed", L("the new shape could not be joined to the body") }, + { "cut failed", L("the new shape could not be cut from the body") }, + { "intersect failed", L("the shapes could not be intersected") }, + { "no solid-producing features", L("nothing in the history makes a solid yet") }, + { "sketch profile wire failed", L("the sketch profile is not a clean closed loop") }, + { "does not intersect", L("the tool does not touch the body") }, + { "unknown identifier", L("an expression uses a variable that does not exist") }, + { "division by zero", L("an expression divides by zero") }, + { "variable cycle", L("two variables are defined in terms of each other") }, + }; + if (err.empty()) + return _L("a geometry operation failed"); + for (const Map& m : kMap) + if (err.find(m.needle) != std::string::npos) + return _(m.sentence); + if (err.find("OCCT") != std::string::npos || err.find("BRep") != std::string::npos + || err.find("Standard_") != std::string::npos || err.find("StdFail") != std::string::npos) + return _L("a geometry operation failed"); + return wxString::FromUTF8(err); +} + +void DesignPanel::set_status(StatusKind kind, const wxString& body) { if (m_status == nullptr) return; + wxColour colour; + const wchar_t* glyph = nullptr; + switch (kind) { + case StatusKind::Info: colour = wxNullColour; break; + case StatusKind::Ok: colour = wxColour(120, 210, 120); glyph = L"\u2713 "; break; // ✓ + case StatusKind::Warning: colour = wxColour(220, 160, 60); glyph = L"\u26A0 "; break; // ⚠ + case StatusKind::Error: colour = wxColour(235, 110, 110); glyph = L"\u2717 "; break; // ✗ + } + m_status->SetForegroundColour(colour); + // Callers that already lead with a glyph (the DOF line) keep theirs. + wxString text = body; + if (glyph != nullptr && !text.IsEmpty() && !text.StartsWith(L"\u2713") && !text.StartsWith(L"\u2717") + && !text.StartsWith(L"\u26A0")) + text = wxString(glyph) + text; m_status->SetLabel(text); // the ONE place that may call SetLabel directly // m_status is HIDDEN and kept only as the owner of the text and its colour — every caller // sets the colour on it just before calling here, so this stays the one place that knows @@ -6227,14 +6348,11 @@ void DesignPanel::set_status(const wxString& text) // 8cc), which silently length-limited every hint in the tab. The viewport's bottom // margin has the whole window width, so a sentence can be a sentence. if (m_viewport != nullptr) { - // wxNullColour means "no opinion", and the dark default text colour is nearly invisible - // on the dark HUD; only a colour a caller actually chose (the error red, the plane-pick - // green) is carried over. Compared against the colour the label was CREATED with — - // comparing against the parent's foreground instead reported "chosen" for every line, - // and the neutral text came out the panel's grey. - const wxColour fg = m_status->GetForegroundColour(); - m_viewport->set_status_text(text, fg != m_status_default_fg ? fg - : wxColour(0xDD, 0xE1, 0xE6)); + // Only a colour a caller actually chose (the error red, the plane-pick green) is carried + // over; a neutral line (wxNullColour above) takes the viewport overlay's own text colour, + // which follows the theme. + m_viewport->set_status_text(text, m_status->UseForegroundColour() ? m_status->GetForegroundColour() + : wxNullColour); } } @@ -6246,8 +6364,9 @@ void DesignPanel::set_status(const wxString& text) // apply); `picks` = the size of the set the gesture accumulates. // // It is deliberately explicit about the gesture that ENDS each tool, because none of them is -// discoverable: a click on empty space applies an edit-op or a transform, right-click cancels it, -// and Esc downgrades an armed tool to Select before it ever exits the sketch. +// discoverable: Enter (or ✓) applies an edit-op or a transform, Esc drops it, and a second Esc +// downgrades the armed tool to Select before it ever exits the sketch. Right-click only abandons +// the gesture in progress; with nothing in progress it opens the offer. static wxString sketch_step_prompt(DesignSketchTool::Mode m, int step, int picks) { using Mode = DesignSketchTool::Mode; @@ -6260,7 +6379,7 @@ static wxString sketch_step_prompt(DesignSketchTool::Mode m, int step, int picks return picks > 0 ? wxString::Format(_L("%d selected · Del removes them · Shift-click adds · " "double-click takes the whole loop"), picks) - : _L("Select — click an entity to pick it · drag an endpoint or centre to move it · " + : _L("Select — click an entity to pick it · drag an endpoint or center to move it · " "Shift-click adds · Del removes"); case Mode::Constrain: return picks > 0 @@ -6272,17 +6391,17 @@ static wxString sketch_step_prompt(DesignSketchTool::Mode m, int step, int picks : _L("Dimension — click the second point"); case Mode::Line: return step == 0 ? _L("Line — click the start point") - : _L("Line — click the end point, or type the length"); + : _L("Line — click the end point; its length and angle can then be typed"); case Mode::Polyline: return step == 0 ? _L("Polyline — click the first point") : pick_more(_L("Polyline — click the next point · click the start point " - "to close it · right-click to end the chain"), step); + "to close it · right-click, double-click or Enter ends the chain"), step); case Mode::CornerRect: return step == 0 ? _L("Rectangle — click one corner") : _L("Rectangle — click the opposite corner"); case Mode::CenterRect: - return step == 0 ? _L("Centre rectangle — click the centre") - : _L("Centre rectangle — click a corner"); + return step == 0 ? _L("Center rectangle — click the center") + : _L("Center rectangle — click a corner"); case Mode::ObliqueRect: return step == 0 ? _L("Oblique rectangle — click the start of the base edge") : step == 1 ? _L("Oblique rectangle — click the end of the base edge (this sets the angle)") @@ -6292,8 +6411,8 @@ static wxString sketch_step_prompt(DesignSketchTool::Mode m, int step, int picks : step == 1 ? _L("Rounded rectangle — click the opposite corner") : _L("Rounded rectangle — click to set the corner radius"); case Mode::CenterCircle: - return step == 0 ? _L("Circle — click the centre") - : _L("Circle — click to set the radius, or type it"); + return step == 0 ? _L("Circle — click the center") + : _L("Circle — click to set the radius; it can then be typed"); case Mode::TwoPointCircle: return step == 0 ? _L("Circle (2 points) — click one end of the diameter") : _L("Circle (2 points) — click the other end of the diameter"); @@ -6309,87 +6428,87 @@ static wxString sketch_step_prompt(DesignSketchTool::Mode m, int step, int picks return step == 0 ? _L("Tangent arc — click the endpoint it leaves from") : _L("Tangent arc — click its far end"); case Mode::CenterArc: - return step == 0 ? _L("Centre arc — click the centre") - : step == 1 ? _L("Centre arc — click the start point (this sets the radius)") - : _L("Centre arc — click the end point"); + return step == 0 ? _L("Center arc — click the center") + : step == 1 ? _L("Center arc — click the start point (this sets the radius)") + : _L("Center arc — click the end point"); case Mode::Slot: - return step == 0 ? _L("Slot — click one end of the centreline") - : step == 1 ? _L("Slot — click the other end of the centreline") + return step == 0 ? _L("Slot — click one end of the centerline") + : step == 1 ? _L("Slot — click the other end of the centerline") : _L("Slot — click to set the width"); case Mode::ArcSlot: - return step == 0 ? _L("Arc slot — click the centre the slot curves about") - : step == 1 ? _L("Arc slot — click the start of the centreline (this sets the radius)") - : step == 2 ? _L("Arc slot — click the end of the centreline") + return step == 0 ? _L("Arc slot — click the center the slot curves about") + : step == 1 ? _L("Arc slot — click the start of the centerline (this sets the radius)") + : step == 2 ? _L("Arc slot — click the end of the centerline") : _L("Arc slot — click to set the width"); case Mode::Polygon: - return step == 0 ? _L("Polygon — click the centre") + return step == 0 ? _L("Polygon — click the center") : _L("Polygon — click a vertex (this sets size and orientation)"); case Mode::Ellipse: - return step == 0 ? _L("Ellipse — click the centre") + return step == 0 ? _L("Ellipse — click the center") : step == 1 ? _L("Ellipse — click the end of the major axis") : _L("Ellipse — click a point on the minor axis"); case Mode::EllipseArc: - return step == 0 ? _L("Elliptical arc — click the centre") + return step == 0 ? _L("Elliptical arc — click the center") : step == 1 ? _L("Elliptical arc — click the end of the major axis") : step == 2 ? _L("Elliptical arc — click a point on the minor axis") : step == 3 ? _L("Elliptical arc — click where the arc starts") : _L("Elliptical arc — click where the arc ends"); case Mode::BSpline: return step == 0 ? _L("Spline — click the first control point") - : pick_more(_L("Spline — click the next control point · right-click to " - "finish the curve"), step); + : pick_more(_L("Spline — click the next control point · right-click, " + "double-click or Enter finishes the curve"), step); case Mode::Point: return _L("Point — click to place one; the tool stays armed for more"); case Mode::Trim: - return _L("Trim — click a segment where it crosses another entity · right-click to exit"); + return _L("Trim — click a segment where it crosses another entity · Esc to exit"); case Mode::Extend: return _L("Extend — click a line or arc to grow it out to the nearest entity · " - "right-click to exit"); + "Esc to exit"); case Mode::Fillet: return step == 0 ? _L("Fillet — click the first of two lines that meet") : step == 1 ? _L("Fillet — click the second line") : _L("Fillet — drag the arrow, or click the number to type the radius · " - "click empty space to apply · right-click cancels"); + "Enter applies · Esc cancels"); case Mode::Chamfer: return step == 0 ? _L("Chamfer — click the first of two lines that meet") : step == 1 ? _L("Chamfer — click the second line") : _L("Chamfer — drag the arrow, or click the number to type the setback · " - "click empty space to apply · right-click cancels"); + "Enter applies · Esc cancels"); case Mode::Offset: - return step == 0 ? _L("Offset — click the entity to offset") + return step == 0 ? _L("Offset — click a curve; its whole outline is offset") : _L("Offset — drag the arrow to either side, or click the number to type " - "the distance · click empty space to apply · right-click cancels"); + "the distance · Enter applies · Esc cancels"); case Mode::Mirror: // The two-phase pick is the one gesture users reported as unguided: nothing said the - // AXIS comes first, and nothing said an empty click is what applies it. + // AXIS comes first, and nothing said how it is applied. return step == 0 ? _L("Mirror — first click the LINE to mirror about (a construction line works)") : picks == 0 - ? _L("Mirror — axis set · now click the entities to mirror · right-click cancels") + ? _L("Mirror — axis set · now click the entities to mirror · Esc cancels") : wxString::Format(_L("Mirror — axis set · %d to mirror · click another to add or " - "remove it · click empty space to apply"), picks); + "remove it · Enter applies"), picks); case Mode::Move: return step == 0 ? _L("Move — click the entities to move") : pick_more(_L("Move — drag the handle, or click the number to type the " - "distance · click empty space to apply"), picks); + "distance · Enter applies"), picks); case Mode::Rotate: return step == 0 ? _L("Rotate — click the entities to rotate") : pick_more(_L("Rotate — drag the handle, or click the number to type the " - "angle · click empty space to apply"), picks); + "angle · Enter applies"), picks); case Mode::Scale: return step == 0 ? _L("Scale — click the entities to scale") : pick_more(_L("Scale — drag the handle, or click the number to type the " - "factor · click empty space to apply"), picks); + "factor · Enter applies"), picks); case Mode::Array: return step == 0 ? _L("Array — click the entities to repeat") : pick_more(_L("Array — drag the handle to set the step, click the count to " - "type it · click empty space to apply"), picks); + "type it · Enter applies"), picks); case Mode::PolarArray: return step == 0 ? _L("Polar array — click the entities to repeat") : pick_more(_L("Polar array — drag the handle to set the sweep, click the " - "count to type it · click empty space to apply"), picks); + "count to type it · Enter applies"), picks); case Mode::TransformArt: - return _L("Drag a corner to scale, the centre to move · right-click when done"); + return _L("Drag a corner to scale, the center to move · Enter or ✓ when done, Esc to discard"); } return wxString(); } @@ -6412,9 +6531,7 @@ void DesignPanel::on_sketch_step(int mode, int step, int picks) text += _L(" · click a constraint badge to remove it"); m_sketch_step = text; if (text.IsEmpty() || m_status == nullptr) return; - m_status->SetForegroundColour(wxNullColour); - set_status(text); - m_status->Refresh(); + set_status(StatusKind::Info, text); } wxMenuItem* DesignPanel::append_offer_item(wxMenu* menu, int id, const wxString& text, @@ -6446,6 +6563,43 @@ static void offer_trace(const char* fmt, ...) fflush(stderr); } +// The offer menu's title line for selection `kind` (an OfferSel), or empty for none. +wxString DesignPanel::offer_header(int kind) const +{ + const int nb = int(m_doc.bodies.size()); + auto body = [&]() { + if (m_sel_solid_body < 0 || m_sel_solid_body >= nb) return wxString(); + const std::string& n = m_doc.bodies[m_sel_solid_body].name; + return n.empty() ? wxString::Format(_L("Body %d"), m_sel_solid_body + 1) : wxString::FromUTF8(n); + }; + switch (OfferSel(kind)) { + case OfferSel::None: return _L("Nothing selected"); + case OfferSel::BodySolid: return wxString::Format(_L("%s — solid body"), body()); + case OfferSel::BodySheet: return wxString::Format(_L("%s — surface body"), body()); + case OfferSel::FacePlanar: return wxString::Format(_L("Flat face %d of %s"), m_sel_solid_face, body()); + case OfferSel::FaceCyl: return wxString::Format(_L("Cylindrical face %d of %s"), m_sel_solid_face, body()); + case OfferSel::FaceOther: return wxString::Format(_L("Face %d of %s"), m_sel_solid_face, body()); + case OfferSel::EdgeStr: + case OfferSel::EdgeCirc: + if (dressup_edges().size() > 1) + return wxString::Format(_L_PLURAL("%zu edge of %s", "%zu edges of %s", dressup_edges().size()), dressup_edges().size(), body()); + return OfferSel(kind) == OfferSel::EdgeStr + ? wxString::Format(_L("Straight edge %d of %s"), m_sel_solid_edge, body()) + : wxString::Format(_L("Circular edge %d of %s"), m_sel_solid_edge, body()); + case OfferSel::Vertex: return wxString::Format(_L("Vertex of %s"), body()); + case OfferSel::SkLoop: return _L("Sketch profile"); + case OfferSel::SkNone: return _L("Sketch — nothing selected"); + case OfferSel::SkLine: return _L("Sketch line"); + case OfferSel::SkArc: return _L("Sketch curve"); + case OfferSel::SkPoint: return _L("Sketch point"); + case OfferSel::Sk2Ent: { + const int n = m_viewport ? m_viewport->sketch_selection_count() : 0; + return wxString::Format(_L("%d sketch entities"), n); + } + default: return wxString(); + } +} + void DesignPanel::show_offer_menu(const wxPoint& screen_pos) { const int kind = offer_selection_kind(); @@ -6497,7 +6651,26 @@ void DesignPanel::show_offer_menu(const wxPoint& screen_pos) std::vector bound; // menu id offset -> verb const int base = wxID_HIGHEST + 4200; - for (int row = 0; row < kOfferRowCount; ++row) { + // Header: WHAT the rows below act on, named the way the tree and the cards name it. The rows + // only make sense against the selection, and the selection is not always visible under the + // cursor that opened the menu. Greyed, so it reads as a title and cannot be run. + { + const wxString head = offer_header(kind); + if (!head.empty()) { + menu.Append(wxID_ANY, head)->Enable(false); + menu.AppendSeparator(); + } + } + + // Flat rows first, then the families, each group in ratified order. A flat row's verbs sit at + // the top level, each an item of its own: they are what a selection is opened for most. + std::vector rows; + for (const bool flat : {true, false}) + for (int row = 0; row < kOfferRowCount; ++row) + if (kOfferRowFlat[row] == flat) rows.push_back(row); + bool flat_items = false; + + for (const int row : rows) { std::vector live, family; for (int i = 0; i < kOfferVerbCount; ++i) { const OfferVerb& v = kOfferVerbs[i]; @@ -6507,6 +6680,34 @@ void DesignPanel::show_offer_menu(const wxPoint& screen_pos) } if (family.empty()) continue; // no verb of this family in this mode + + if (kOfferRowFlat[row]) { + // Only the verbs about this selection; one blocked by the document stays, greyed, + // with its reason, as it would inside a family. + for (const OfferVerb* v : family) { + if (!(v->accepts & bit)) continue; + const int id = base + int(bound.size()); + if (applies(*v)) { + offer_trace("row=%d %s -> %s%s", row, kOfferRowNames[row], v->id, + v->action ? "" : " (no GUI route)"); + append_offer_item(&menu, id, label(*v), *v)->Enable(v->action != nullptr); + bound.push_back(v); + } else { + offer_trace("row=%d %s DISABLED (%s)", row, kOfferRowNames[row], + v->refusal ? v->refusal : "blocked"); + wxString s = tr(v->name); + if (v->refusal) s += wxString::FromUTF8(" — ") + tr(v->refusal); + append_offer_item(&menu, id, s, *v)->Enable(false); + bound.push_back(nullptr); + } + flat_items = true; + } + continue; + } + if (flat_items) { + menu.AppendSeparator(); // between the flat items and the families + flat_items = false; + } const wxString fam = tr(kOfferRowNames[row]); if (live.empty()) { @@ -6533,7 +6734,8 @@ void DesignPanel::show_offer_menu(const wxPoint& screen_pos) why ? why : "no verb accepts this selection"); menu.Append(base + int(bound.size()), s)->Enable(false); bound.push_back(nullptr); - } else if (live.size() == 1) { + } else if (live.size() == 1 && std::none_of(family.begin(), family.end(), [&](const OfferVerb* v) { + return (v->accepts & bit) && !applies(*v); })) { offer_trace("row=%d %s -> %s%s", row, kOfferRowNames[row], live[0]->id, live[0]->action ? "" : " (no GUI route)"); append_offer_item(&menu, base + int(bound.size()), label(*live[0]), *live[0]) @@ -6544,9 +6746,15 @@ void DesignPanel::show_offer_menu(const wxPoint& screen_pos) // (Rectangle -> corner / centre / oblique / rounded); one without sits directly in // the row. Families keep the order of their first member, so the row's layout is // stable across selections — the whole point of a fixed address. + // A verb that is about THIS selection but blocked by the document (no body yet, no + // second sketch) stays in its place, greyed, with its reason (charter 4.1: disabled + // in place, never removed). Verbs that do not accept this selection at all are not + // about it and stay out. auto* sub = new wxMenu(); std::vector> groups; // insertion-ordered - for (const OfferVerb* v : live) { + for (const OfferVerb* v : family) { + const bool ok = applies(*v); + if (!ok && !(v->accepts & bit)) continue; wxMenu* target = sub; if (v->family && *v->family) { auto it = std::find_if(groups.begin(), groups.end(), @@ -6560,6 +6768,14 @@ void DesignPanel::show_offer_menu(const wxPoint& screen_pos) target = it->second; } } + if (!ok) { + offer_trace("row=%d %s ~ %s BLOCKED", row, kOfferRowNames[row], v->id); + wxString s = tr(v->name); + if (v->refusal) s += wxString::FromUTF8(" — ") + tr(v->refusal); + append_offer_item(target, base + int(bound.size()), s, *v)->Enable(false); + bound.push_back(nullptr); + continue; + } offer_trace("row=%d %s > %s%s%s%s", row, kOfferRowNames[row], (v->family && *v->family) ? v->family : "", (v->family && *v->family) ? " > " : "", v->id, @@ -6664,13 +6880,11 @@ void DesignPanel::show_offer_menu(const wxPoint& screen_pos) int idx = m_doc.add_mate(opts[i].kind, cs_a, cs_b, 0.0, 0.0, false, "Mate" + std::to_string(++m_feature_counter)); if (idx < 0) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Mate rejected")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Mate rejected")); return; } if (!recompute_guarded(_L("Rebuilding model…"))) - set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + set_status(StatusKind::Error, wxString::Format(_L("The model could not be rebuilt: %s"), kernel_error_text(m_doc.error))); else set_status_ok(); refresh_tree(); @@ -6691,8 +6905,7 @@ void DesignPanel::show_offer_menu(const wxPoint& screen_pos) const int i = e.GetMenuId() - base; if (i < 0 || i >= int(bound.size()) || bound[i] == nullptr || bound[i]->hint == nullptr) return; - m_status->SetForegroundColour(wxNullColour); - set_status(wxGetTranslation(wxString::FromUTF8(bound[i]->hint))); + set_status(StatusKind::Info, wxGetTranslation(wxString::FromUTF8(bound[i]->hint), SLIC3R_APP_KEY)); m_status->Update(); // the popup owns the loop; without this the line repaints late }, base, base + 499); // 499: the mate section starts at base + 500 (see mate_base) menu.Bind(wxEVT_MENU, [this, &bound](wxCommandEvent& e) { @@ -6750,10 +6963,8 @@ void DesignPanel::arm_plane_pick(PlanePick target) // and reset_plane_refs() restores it when the pick is abandoned; only the arm side was missing. if (m_viewport) m_viewport->set_escalate_on_repick(false); const bool face = (target == PlanePick::FaceA || target == PlanePick::FaceB); - m_status->SetForegroundColour(wxNullColour); - set_status(face ? _L("Click a solid FACE in the viewport") + set_status(StatusKind::Info, face ? _L("Click a solid FACE in the viewport") : _L("Click a solid EDGE in the viewport")); - m_status->Refresh(); } // --- Axis helpers --- @@ -6790,10 +7001,8 @@ void DesignPanel::arm_axis_pick(AxisPick target) { m_axis_pick = target; if (m_viewport) m_viewport->set_escalate_on_repick(false); // same as arm_plane_pick - m_status->SetForegroundColour(wxNullColour); - set_status(target == AxisPick::Face ? _L("Click a solid FACE in the viewport") + set_status(StatusKind::Info, target == AxisPick::Face ? _L("Click a solid FACE in the viewport") : _L("Click a solid EDGE in the viewport")); - m_status->Refresh(); } // --- CoordSys helpers --- @@ -6858,10 +7067,8 @@ void DesignPanel::arm_coordsys_pick(CoordSysPick target) // escalation is off: clicking the face the card is pointing at is the ANSWER here, not a // request for its body. if (m_viewport) m_viewport->set_escalate_on_repick(false); - m_status->SetForegroundColour(wxNullColour); - set_status(target == CoordSysPick::Face ? _L("Click a solid FACE in the viewport") + set_status(StatusKind::Info, target == CoordSysPick::Face ? _L("Click a solid FACE in the viewport") : _L("Click a solid EDGE in the viewport")); - m_status->Refresh(); } bool DesignPanel::on_add_plane() @@ -6872,9 +7079,7 @@ bool DesignPanel::on_add_plane() // a success — the user asks for one construction and silently receives another. The kernel // keeps its fallback (it must return SOMETHING), but no user gesture should reach it. auto refuse = [this](const wxString& why) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(why); - m_status->Refresh(); + set_status(StatusKind::Error, why); }; switch ((PlaneType)m_plane_type->GetSelection()) { case PlaneType::Angle: @@ -6902,11 +7107,10 @@ bool DesignPanel::on_add_plane() m_feature_counter++; int idx = m_doc.add_plane(m_plane_base->GetSelection(), m_plane_offset->GetValue(), m_plane_tilt->GetValue(), m_plane_tilt_axis->GetSelection(), - "Plane" + std::to_string(m_feature_counter)); + feature_name(_L("Plane"))); if (idx >= 0 && idx < int(m_doc.features.size())) apply_plane_refs(m_doc.features[idx]); m_doc.recompute(); // datum-only docs yield no body; that is expected/benign - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Plane added — pick it as a sketch plane")); + set_status(StatusKind::Info, _L("Plane added — pick it as a sketch plane")); refresh_tree(); return true; } @@ -6915,11 +7119,10 @@ void DesignPanel::on_add_axis() { m_feature_counter++; int idx = m_doc.add_axis((AxisType)m_axis_type->GetSelection(), - "Axis" + std::to_string(m_feature_counter)); + feature_name(_L("Axis"))); if (idx >= 0 && idx < int(m_doc.features.size())) apply_axis_refs(m_doc.features[idx]); m_doc.recompute(); - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Axis added")); + set_status(StatusKind::Info, _L("Axis added")); refresh_tree(); } @@ -6928,28 +7131,27 @@ void DesignPanel::on_add_coordsys() m_feature_counter++; Vec3d pt(m_cs_x->GetValue(), m_cs_y->GetValue(), m_cs_z->GetValue()); int idx = m_doc.add_coordsys((CoordSysType)m_coordsys_type->GetSelection(), pt, - "Coord" + std::to_string(m_feature_counter)); + feature_name(_L("Coordinate system"))); if (idx >= 0 && idx < int(m_doc.features.size())) apply_coordsys_refs(m_doc.features[idx]); m_doc.recompute(); - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Coord Sys added")); + set_status(StatusKind::Info, _L("Coordinate system added")); refresh_tree(); } void DesignPanel::on_add_shell() { if (m_doc.body.IsNull()) { - set_status(_L("Shell needs a solid body — add or import one first")); + set_status(StatusKind::Warning, _L("Shell needs a solid body — add or import one first")); return; } const int face = (m_sel_solid_face >= 0) ? m_sel_solid_face : -1; m_feature_counter++; m_doc.add_shell(m_shell_thickness->GetValue(), face, m_sel_solid_body, - "Shell" + std::to_string(m_feature_counter)); + feature_name(_L("Shell"))); if (!recompute_guarded(_L("Rebuilding model…"))) - set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + set_status(StatusKind::Error, wxString::Format(_L("The model could not be rebuilt: %s"), kernel_error_text(m_doc.error))); else set_status_ok(); @@ -6959,65 +7161,64 @@ void DesignPanel::on_add_shell() void DesignPanel::on_add_draft() { if (m_doc.body.IsNull()) { - set_status(_L("Draft needs a solid body — add or import one first")); + set_status(StatusKind::Warning, _L("Draft needs a solid body — add or import one first")); return; } if (m_sel_solid_face < 0) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Draft needs a picked face — click a side face first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Draft needs a picked face — click a side face first")); return; } m_feature_counter++; m_doc.add_draft(m_draft_angle->GetValue(), m_sel_solid_face, m_sel_solid_body, - "Draft" + std::to_string(m_feature_counter)); + feature_name(_L("Draft"))); if (!recompute_guarded(_L("Rebuilding model…"))) - set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); + set_status(StatusKind::Error, wxString::Format(_L("The model could not be rebuilt: %s"), kernel_error_text(m_doc.error))); else set_status_ok(); refresh_tree(); } -int DesignPanel::tree_icon_for(CadFeatureType t) +// The Feature tree's per-type row icon, by family. +const char* DesignPanel::tree_icon_for(CadFeatureType t) { switch (t) { - case CadFeatureType::Sketch: return 0; - case CadFeatureType::Extrude: return 1; + case CadFeatureType::Sketch: + case CadFeatureType::Plane: + case CadFeatureType::Axis: + case CadFeatureType::CoordSys: + case CadFeatureType::Project: return "design_sketch"; // sketches, datums, projections case CadFeatureType::Fillet: - case CadFeatureType::Chamfer: return 2; - case CadFeatureType::Hole: return 3; - case CadFeatureType::Thread: return 4; - case CadFeatureType::Shell: return 5; - case CadFeatureType::Revolve: return 1; - case CadFeatureType::Sweep: return 1; - case CadFeatureType::Pattern: return 1; - case CadFeatureType::Plane: return 0; // datum plane: sketch-family icon - case CadFeatureType::Loft: return 1; - case CadFeatureType::Draft: return 5; // dressup-family icon - case CadFeatureType::Import: return 1; // imported solid: solid-family icon - case CadFeatureType::Boolean: return 1; // body-body combine: solid-family icon - case CadFeatureType::Cut: return 1; // plane split: solid-family icon - case CadFeatureType::Axis: return 0; // datum axis: sketch-family icon - case CadFeatureType::CoordSys: return 0; // datum coord sys: sketch-family icon - case CadFeatureType::SurfaceExtrude: return 1; - case CadFeatureType::SurfaceRevolve: return 1; - case CadFeatureType::SurfaceLoft: return 1; - case CadFeatureType::SurfaceFill: return 1; - case CadFeatureType::ThickenSurface: return 1; - case CadFeatureType::SurfaceOffset: return 1; - case CadFeatureType::Transform: return 1; // solid-family icon - case CadFeatureType::Mirror: return 1; // solid-family icon - case CadFeatureType::Thicken: return 1; // solid-family icon - case CadFeatureType::Rib: return 1; // solid-family icon - case CadFeatureType::Project: return 0; // sketch-family icon (produces sketch) - case CadFeatureType::DeleteFace: return 5; // dressup-family icon - case CadFeatureType::Helix: return 4; // thread-family icon (curve) - case CadFeatureType::Mate: return 2; // dressup-family icon (assembly) + case CadFeatureType::Chamfer: + case CadFeatureType::Mate: return "design_dressup"; + case CadFeatureType::Hole: return "design_hole"; + case CadFeatureType::Thread: + case CadFeatureType::Helix: return "design_thread"; + case CadFeatureType::Shell: + case CadFeatureType::Draft: + case CadFeatureType::DeleteFace: return "design_shell"; + case CadFeatureType::Extrude: + case CadFeatureType::Revolve: + case CadFeatureType::Sweep: + case CadFeatureType::Loft: + case CadFeatureType::Pattern: + case CadFeatureType::Import: + case CadFeatureType::Boolean: + case CadFeatureType::Cut: + case CadFeatureType::Transform: + case CadFeatureType::Mirror: + case CadFeatureType::Thicken: + case CadFeatureType::Rib: + case CadFeatureType::SurfaceExtrude: + case CadFeatureType::SurfaceRevolve: + case CadFeatureType::SurfaceLoft: + case CadFeatureType::SurfaceFill: + case CadFeatureType::ThickenSurface: + case CadFeatureType::SurfaceOffset: return "design_extrude"; // solids and surfaces } - return 0; + return "design_sketch"; } // The reason detect_mate_conflicts() recorded for this feature, or nullptr. A linear scan: an @@ -7048,45 +7249,114 @@ wxString DesignPanel::idle_hint() const // Nothing else in the panel needs to know: the popup keeps its text and comes straight back. void DesignPanel::on_tab_hidden() { - if (m_viewport) { - m_viewport->show_status_hud(false); + if (m_viewport) m_viewport->leave_viewport(); // hand the shared camera back to the editor tabs - } + update_sidebar_pane(); } void DesignPanel::on_tab_shown() { - if (m_viewport) { - m_viewport->show_status_hud(true); // ...and back on the way in + if (m_viewport) m_viewport->enter_viewport(); // borrow the shared camera; on_tab_hidden gives it back - } if (m_active == Tool::None && m_doc.display_mesh.its.indices.empty()) set_status(idle_hint()); // first paint: the tab has never been edited + wxStopWatch show_clock; if (m_viewport) m_viewport->refresh_bed(); - // Modeling origin = bed centre, set BEFORE any recompute/datum-resolve so sketches and datums - // land in the middle of the bed (not the bed corner = world 0). - if (Plater* pl = wxGetApp().plater()) { - const Vec2d bc = pl->build_volume().bed_center(); - m_doc.modeling_origin = Vec3d(bc.x(), bc.y(), 0.0); - } - - // Rehydrate the parametric model from a freshly loaded project (the 3MF carried the - // recipe in Metadata/orca_cad.bin). Only when nothing is in progress here, so we - // never clobber an active design when the user just toggles back to the Design tab. - if (m_doc.features.empty()) { - if (Plater* plater = wxGetApp().plater()) { - const std::string& blob = plater->model().cad_recipe; - if (!blob.empty()) load_recipe(blob); - } - } + hydrate_from_model(); update_reference_planes(); // entering the Design tab: show the XY/XZ/YZ planes if no object yet - sync_sidebar_width(); // keep the panel as wide as Prepare's so the canvas edge doesn't jump + 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; + update_sidebar_pane(); if (m_viewport) m_viewport->force_repaint(); // the page was just re-shown: paint it for real } +void DesignPanel::refresh_icons() +{ + // Plain ScalableButtons re-read their icon for the current scale and theme, and the Orca + // widgets re-measure themselves... + std::function walk = [&walk](wxWindow* w) { + if (auto* b = dynamic_cast(w)) + b->msw_rescale(); + else if (auto* b = dynamic_cast<::Button*>(w)) { + b->Rescale(); + if (b->GetName() == "design_icon_btn") + style_sidebar_icon_btn(b); // pixel sizes and the panel colour, for the new scale and theme + } else if (auto* l = dynamic_cast(w)) + l->Rescale(); + else if (auto* c = dynamic_cast<::CheckBox*>(w)) + c->Rescale(); + else if (auto* c = dynamic_cast<::ComboBox*>(w)) + c->Rescale(); + for (wxWindow* child : w->GetChildren()) + walk(child); + }; + walk(this); + // ...then the icons that are not a button face (and the buttons that swap to a "_dark" twin). + for (auto& refresh : m_icon_refresh) + refresh(); +} + +void DesignPanel::msw_rescale() +{ + refresh_icons(); + if (m_viewport) m_viewport->Refresh(); + Layout(); +} + +void DesignPanel::on_sys_color_changed() +{ + m_aui.apply_color_mode(); + // Every chrome colour here was set from a DpToken in the theme that was current at the time. + // Move each one, background and text, onto the same token in the new theme; any other colour + // (the teal accents, the status colours) is the same in both and stays. + const bool to_dark = dp_dark(); + std::function walk = [&walk, to_dark](wxWindow* w) { + if (w->UseBackgroundColour()) + w->SetBackgroundColour(dp_retheme(w->GetBackgroundColour(), to_dark)); + if (w->UseForegroundColour()) + w->SetForegroundColour(dp_retheme(w->GetForegroundColour(), to_dark)); + for (wxWindow* child : w->GetChildren()) + walk(child); + }; + walk(this); + // The native controls (lists, spins) take the app's own dark pass. + wxGetApp().UpdateDarkUIWin(this); + refresh_icons(); + refresh_tree(); // the rows carry their own text colours + Refresh(); +} + +// Rehydrate the parametric model from a freshly loaded project (the 3MF carried the recipe in +// Metadata/orca_cad.bin). Only into an EMPTY document, so an active design is never clobbered +// when the user toggles back to the tab. Called on tab show and by the control socket — which +// used to skip it, work on an empty document, and then overwrite the project's recipe with +// just its own features. +void DesignPanel::hydrate_from_model() +{ + if (!m_doc.features.empty()) return; + Plater* plater = wxGetApp().plater(); + if (plater == nullptr) return; + // Modeling origin = bed centre, set BEFORE any recompute/datum-resolve so sketches and + // datums land in the middle of the bed (not the bed corner = world 0) — only for a document + // 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). + if (!m_doc.origin_from_recipe) { + const Vec2d bc = plater->build_volume().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). + m_doc.auto_close_loops = wxGetApp().is_auto_close_sketch_loops(); + Slic3r::set_sketch_auto_close(m_doc.auto_close_loops); + } + const std::string& blob = plater->model().cad_recipe; + if (!blob.empty()) load_recipe(blob); +} + // Application close / language switch, from the plater's canvas teardown. void DesignPanel::unbind_canvas_event_handlers() { @@ -7098,19 +7368,85 @@ void DesignPanel::reset_canvas_volumes() if (m_viewport) m_viewport->reset_canvas_volumes(); } -// Match Prepare's sidebar width instead of hardcoding one. Design used a fixed 264 px against -// Prepare's ~467, so the canvas edge jumped sideways on every tab switch; reading the live width -// also means the two stay aligned if Orca ever changes its sidebar. -void DesignPanel::sync_sidebar_width() +void DesignPanel::shutdown() { - if (m_form == nullptr) return; - Plater* pl = wxGetApp().plater(); - if (pl == nullptr) return; - const int w = pl->sidebar().GetSize().GetWidth(); - if (w < 200) return; // sidebar not laid out yet — keep what we have - if (m_form->GetMinSize().GetWidth() == w) return; - m_form->SetMinSize(wxSize(w, -1)); - Layout(); + wxAuiPaneInfo& sidebar = m_aui.GetPane(m_form); + if (!sidebar.IsOk()) // shut down already, floating: a layout saved now would lack the sidebar + return; + // Saved hidden only when collapsed, whichever tab is shown. + sidebar.Show(!m_sidebar_collapsed); + wxGetApp().app_config->set("design_window_layout", m_aui.SavePerspective().utf8_string()); + // A floating frame is a child of the managed panel and dereferences its manager when it is + // deleted, so it must not outlive m_aui, a member. Detaching queues the frame for deletion, which + // runs before the main frame destroys its children (~wxTopLevelWindowBase deletes pending child + // frames). + if (sidebar.IsFloating()) + m_aui.DetachPane(m_form); +} + +void DesignPanel::reset_window_layout() +{ + load_default_layout(); + collapse_sidebar(false); + update_sidebar_pane(true); +} + +// The sidebar where the user left it, collapsed if it was. +void DesignPanel::load_window_layout() +{ + const wxString saved = wxString::FromUTF8(wxGetApp().app_config->get("design_window_layout")); + if (saved.empty() || !m_aui.LoadPerspective(saved, false)) { + if (!saved.empty()) + BOOST_LOG_TRIVIAL(warning) << "Design tab: failed to restore the saved window layout"; + load_default_layout(); + } + wxAuiPaneInfo& sidebar = m_aui.GetPane(m_form); + m_sidebar_collapsed = !sidebar.IsShown(); + if ((m_aui.GetFlags() & wxAUI_MGR_ALLOW_FLOATING) == 0) // saved where windows can float + sidebar.Dock().Floatable(false); + update_sidebar_pane(true); +} + +// Docked where Prepare's sidebar is, and as wide, so the canvas edge does not move between the tabs. +void DesignPanel::load_default_layout() +{ + m_aui.LoadPerspective(m_default_layout, false); + Plater* plater = wxGetApp().plater(); + const Sidebar::DockingState prepare = plater != nullptr ? plater->get_sidebar_docking_state() : Sidebar::None; + if (prepare == Sidebar::None) + return; + wxAuiPaneInfo& sidebar = m_aui.GetPane(m_form); + if (prepare == Sidebar::Right) + sidebar.Right(); + const int width = plater->sidebar().GetSize().GetWidth(); + if (width >= sidebar.min_size.GetWidth()) // Prepare's sidebar has been laid out + sidebar.BestSize(width, sidebar.best_size.GetHeight()); +} + +// The collapse button on the canvas and Shift+Tab, as in Prepare. +void DesignPanel::collapse_sidebar(bool collapse) +{ + m_sidebar_collapsed = collapse; + if (m_viewport) + m_viewport->set_sidebar_collapse_tooltip(wxGetApp().shortcuts().with_key( + (collapse ? _L("Expand sidebar") : _L("Collapse sidebar")).utf8_string(), Shortcut::CollapseSidebar)); + update_sidebar_pane(); +} + +// The sidebar shows unless collapsed, and not before the tab is first shown: wxAUI caps a new dock at +// the managed panel's width at that Update() and the dock keeps that size, while the panel is 20 px +// wide until the page lays it out. A floating one also shows only while the tab is shown, since it is +// a top-level window and does not hide with the page. +void DesignPanel::update_sidebar_pane(bool force_update) +{ + wxAuiPaneInfo& sidebar = m_aui.GetPane(m_form); + const bool show = !m_sidebar_collapsed && m_laid_out && (sidebar.IsDocked() || IsShownOnScreen()); + if (sidebar.IsOk() && sidebar.IsShown() != show) { + sidebar.Show(show); + force_update = true; + } + if (force_update) + m_aui.Update(); } void DesignPanel::load_recipe(const std::string& blob) @@ -7123,14 +7459,13 @@ void DesignPanel::load_recipe(const std::string& blob) // the user unable to tell "update OrcaSlicer" from "your file is damaged". Same // error-loss class as the 31 McpControl sites (1de72de9ed): the message exists, it was // simply not passed on. - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(m_doc.error.empty() + set_status(StatusKind::Error, m_doc.error.empty() ? _L("Could not restore the CAD model from this project") - : _L("Could not restore the CAD model: ") + wxString::FromUTF8(m_doc.error)); - m_status->Refresh(); + : wxString::Format(_L("Could not restore the CAD model: %s"), wxString::FromUTF8(m_doc.error))); return; } m_feature_counter = int(m_doc.features.size()); + drop_selection(); feed_bodies(); // push the restored bodies into the viewport refresh_tree(); // rebuild the feature tree from the restored recipe set_status_ok(); @@ -7156,23 +7491,22 @@ void DesignPanel::refresh_tree() // last feature still clears it, through the tree-edit call site that always did. if (!m_doc.features.empty()) sync_recipe_to_model(); - // Preserve the selected row across the rebuild — wxTreeCtrl::DeleteAllItems - // drops the selection, which made every edit/add feel like it "lost" the - // selection (and broke Edit/Move/Delete on the just-touched feature). + // 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(); - m_tree->DeleteAllItems(); - m_tree_items.clear(); - m_tree_body_items.clear(); - wxTreeItemId root = m_tree->AddRoot("root"); // 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 + std::vector rows; + rows.reserve(m_doc.features.size()); for (size_t fi = 0; fi < m_doc.features.size(); ++fi) { const CadFeature& f = m_doc.features[fi]; - const int img = tree_icon_for(f.type); - wxTreeItemId id = m_tree->AppendItem(root, wxString::FromUTF8(f.name), img, img); + DesignRowList::Row row; + row.icon = tree_icon_for(f.type); + row.label = wxString::FromUTF8(f.name); // Three states, in this order of precedence: // disabled -> dim. A SUPPRESSED mate is the user's answer to a conflict, so it must // read as suppressed rather than keep shouting about the conflict. @@ -7183,23 +7517,20 @@ void DesignPanel::refresh_tree() // A conflict is NOT a document error — the document still evaluates — so the row is // marked and never hidden, and the reason goes to the status line on selection rather // than into a modal that interrupts without offering an action. - m_tree->SetItemTextColour(id, !f.enabled ? dp_item_dim() - : mate_conflict_reason(int(fi)) != nullptr ? wxColour(235, 110, 110) - : dp_item_text()); - m_tree_items.push_back(id); + row.colour = !f.enabled ? dp_item_dim() + : mate_conflict_reason(int(fi)) != nullptr ? wxColour(235, 110, 110) + : dp_item_text(); + row.actions = { + { RowEdit, "design_edit", _L("Edit") }, + eye_action(f.enabled), + { RowDelete, "design_delete", _L("Delete") }, + }; + rows.push_back(std::move(row)); } + m_tree->set_rows(std::move(rows)); refresh_parts(); // bodies live in their own list below the tree, never clipped by history - if (keep >= 0 && keep < int(m_tree_items.size())) - m_tree->SelectItem(m_tree_items[keep]); - - // Size the tree to its content (clamped) so it doesn't waste a fixed-height block when - // there are few features, and scrolls internally past ~9 rows instead of growing forever. - const int rows = int(m_tree_items.size()); // bodies are in their own list now - const int rowH = std::max(m_tree->GetCharHeight() + 8, 20); - const int shown = std::min(std::max(rows, 1), 9); - const wxSize ts(-1, shown * rowH + 8); - m_tree->SetMinSize(ts); - m_tree->SetMaxSize(ts); + m_tree->select(keep); + request_feature_highlight(); // set_rows and select() say nothing when `keep` is gone if (m_form && m_form->GetSizer()) { update_cards_frame(); m_form->Layout(); m_form->FitInside(); } } @@ -7210,11 +7541,9 @@ void DesignPanel::refresh_parts() if (m_parts == nullptr) return; const int keep = tree_body_selection(); - m_parts->DeleteAllItems(); - m_tree_body_items.clear(); - wxTreeItemId proot = m_parts->AddRoot("root"); - sync_body_visible(); // keep flags parallel before reading them for the row colour + std::vector rows; + rows.reserve(m_doc.bodies.size()); for (size_t b = 0; b < m_doc.bodies.size(); ++b) { // "Body N" keeps the positional identity every status line and message uses ("Body 2 // selected", the interference report), and the NAME follows it because that is the part @@ -7228,17 +7557,24 @@ void DesignPanel::refresh_parts() const wxString bname = m_doc.bodies[b].has_user_name ? wxString::FromUTF8(m_doc.bodies[b].user_name) : wxString::FromUTF8(m_doc.bodies[b].name); - wxTreeItemId id = m_parts->AppendItem(proot, - bname.IsEmpty() ? wxString::Format(_L("Body %zu"), b + 1) - : wxString::Format(_L("Body %zu — %s"), b + 1, bname)); - // Hidden bodies are greyed so the show/hide state reads at a glance (eye toggle). - m_parts->SetItemTextColour(id, vis ? dp_item_text() : dp_item_dim()); - m_tree_body_items.push_back(id); + DesignRowList::Row row; + row.label = bname.IsEmpty() ? wxString::Format(_L("Body %zu"), b + 1) + : wxString::Format(_L("Body %zu — %s"), b + 1, bname); + row.edit_text = bname; // the name is the body's to edit, not its number + // 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 = { + { RowMove, "design_move", _L("Move") }, + eye_action(vis), + { RowDelete, "design_delete", _L("Delete") }, + }; + rows.push_back(std::move(row)); } + m_parts->set_rows(std::move(rows)); // Hide the whole block until there is something to list, so an empty document doesn't // show a stray empty box. - const bool any = !m_tree_body_items.empty(); + const bool any = m_parts->GetItemCount() > 0; m_parts->Show(any); if (m_parts_label) m_parts_label->Show(any); if (m_parts_hdr) m_parts_hdr->ShowItems(any); // icon + title live in this sizer @@ -7246,26 +7582,24 @@ void DesignPanel::refresh_parts() // ...and the frame with it, or an empty bordered box floats there. if (m_parts_box && m_form && m_form->GetSizer()) m_form->GetSizer()->Show(m_parts_box, any, false); - if (any) { - const int rowH = std::max(m_parts->GetCharHeight() + 8, 20); - const int shown = std::min(int(m_tree_body_items.size()), 6); // scrolls past 6 - const wxSize ps(-1, shown * rowH + 8); - m_parts->SetMinSize(ps); - m_parts->SetMaxSize(ps); - if (keep >= 0 && keep < int(m_tree_body_items.size())) - m_parts->SelectItem(m_tree_body_items[keep]); - } + if (any) + m_parts->select(keep); if (m_form && m_form->GetSizer()) { update_cards_frame(); m_form->Layout(); m_form->FitInside(); } } int DesignPanel::tree_body_selection() const { - if (m_parts == nullptr) return -1; - const wxTreeItemId sel = m_parts->GetSelection(); - if (!sel.IsOk()) return -1; - for (size_t i = 0; i < m_tree_body_items.size(); ++i) - if (m_tree_body_items[i] == sel) return int(i); - return -1; + return m_parts == nullptr ? -1 : m_parts->selection(); +} + +// Unselect the Feature tree and Bodies rows; each list's on_select clears what its row lit. True +// if a row was selected. +bool DesignPanel::deselect_rows() +{ + const bool any = (m_tree && tree_selection() != wxNOT_FOUND) || tree_body_selection() >= 0; + if (m_tree) m_tree->unselect(); + if (m_parts) m_parts->unselect(); + return any; } void DesignPanel::update_section_flip_btn() @@ -7277,7 +7611,6 @@ void DesignPanel::toggle_section_view() { if (!m_viewport) return; m_section_on = !m_section_on; - m_status->SetForegroundColour(wxNullColour); if (m_section_on) { m_section_cut_z = m_viewport->model_mid_z(); // start at the model's mid-height m_section_upper = false; // keep the lower half by default @@ -7297,10 +7630,8 @@ void DesignPanel::flip_section_view() if (!m_viewport || !m_section_on) return; m_section_upper = !m_section_upper; m_viewport->set_section_plane(true, m_section_cut_z, m_section_upper); - m_status->SetForegroundColour(wxNullColour); - set_status(wxString::Format(_L("Section view — showing the %s half"), + set_status(StatusKind::Info, wxString::Format(_L("Section view — showing the %s half"), m_section_upper ? _L("upper") : _L("lower"))); - m_status->Refresh(); } void DesignPanel::sync_body_visible() @@ -7358,7 +7689,50 @@ void DesignPanel::feed_bodies() // place), so it needs no re-call here — the whole/face/edge selection survives a move drag. if (m_viewport == nullptr) return; rebuild_disp_meshes(); - m_viewport->set_bodies(m_disp_body_meshes, m_body_visible); + m_viewport->set_bodies(&m_disp_body_meshes, m_body_visible); + // A new topology may renumber faces, so the feature's faces are found again; until then the + // viewport drops those on a body whose shape has changed. + if (m_hl_generation != m_doc.topo_generation) + request_feature_highlight(); +} + +void DesignPanel::request_feature_highlight() +{ + if (m_hl_pending) return; + m_hl_pending = true; + // Not on the stack of the click or the rebuild that asked: finding the faces may put up the + // busy dialog, and the document must have settled. + CallAfter([this] { update_feature_highlight(); }); +} + +void DesignPanel::update_feature_highlight() +{ + m_hl_pending = false; + if (m_viewport == nullptr) return; + // A rebuild's busy loop runs queued events while its worker owns the document. Skipped, not + // re-queued (the loop would run it again at once and spin): the rebuild's own refresh + // (feed_bodies, refresh_tree) asks again when it is done. + if (s_doc_worker_busy.load() > 0) return; + 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)) { + 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] { + try { + faces = m_doc.faces_made_by(sel); + } catch (...) { + faces.clear(); // a highlight is not worth an escaped exception + } + }); + m_hl_faces = std::move(faces); + m_hl_feature = sel; + 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>{}); } // Boolean (combine bodies) — one gate for every door onto the tool. A body-body operation @@ -7367,9 +7741,7 @@ void DesignPanel::feed_bodies() void DesignPanel::on_boolean_tool() { if (m_doc.bodies.size() < 2) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Boolean needs two bodies — create or import a second solid")); - m_status->Refresh(); + set_status(StatusKind::Warning, _L("Boolean needs two bodies — add or import a second one")); return; } populate_body_choices(); @@ -7383,10 +7755,8 @@ void DesignPanel::on_move_body() 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. - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(b < 0 ? _L("Select a body first — click it in the viewport or the Bodies list") + set_status(StatusKind::Error, b < 0 ? _L("Select a body first — click it in the viewport or the Bodies list") : _L("That body has no display mesh yet — recompute first")); - m_status->Refresh(); return; } sync_body_xform(); @@ -7409,9 +7779,7 @@ 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 - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Drag the arrows to move, the rings to rotate — then Confirm (Esc cancels)")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("Drag the arrows to move, the rings to rotate — then Confirm (Esc cancels)")); } // Transform card (add mode): arm the same move gizmo on the card's body so the geometry-first @@ -7443,9 +7811,7 @@ void DesignPanel::arm_transform_gizmo() if (m_xf_dy) m_xf_dy->SetValue(0.0); if (m_xf_dz) m_xf_dz->SetValue(0.0); if (m_xf_angle) m_xf_angle->SetValue(0.0); - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Drag the arrows to move, the rings to rotate — the numbers follow")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("Drag the arrows to move, the rings to rotate — the numbers follow")); } // Color tool: open a colour picker on the selected body and store a per-body display-colour @@ -7458,9 +7824,7 @@ void DesignPanel::on_set_body_color() int b = tree_body_selection(); if (b < 0) b = m_sel_solid_body; if (b < 0 || b >= int(m_doc.bodies.size())) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Select a body first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Select a body first")); return; } @@ -7478,9 +7842,7 @@ void DesignPanel::on_set_body_color() (unsigned char)picked.Blue(), (unsigned char)255); feed_bodies(); // same refresh path the visibility toggle uses → viewport updates immediately - m_status->SetForegroundColour(wxNullColour); - set_status(wxString::Format(_L("Body %d colour set"), b + 1)); - m_status->Refresh(); + set_status(StatusKind::Info, wxString::Format(_L("Body %d color set"), b + 1)); } // Prepare's "Place on Face" (F), ported to Design. Pick a body face, then this rotates the @@ -7493,9 +7855,7 @@ bool DesignPanel::place_on_face() const int b = m_sel_solid_body; if (b < 0 || b >= int(m_doc.bodies.size()) || m_sel_solid_face < 0 || b >= int(m_doc.display_body_meshes.size())) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Click a face on the solid, then press F")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Click a face on the solid, then press F")); return false; } const TopoDS_Face face = GeometryEngine::face_by_index(m_doc.bodies[b].shape, m_sel_solid_face); @@ -7516,25 +7876,59 @@ bool DesignPanel::place_on_face() x = Transform3d(Eigen::Translation3d(0.0, 0.0, -probe.bounding_box().min.z())) * x; m_body_xform[b] = x; set_status_ok(); // rebuild display/pick meshes, re-point picking; resets face selection - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Placed on face — body laid flat on the bed")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("Placed on face — body laid flat on the bed")); return true; } int DesignPanel::tree_selection() const { - const wxTreeItemId sel = m_tree->GetSelection(); - if (!sel.IsOk()) return wxNOT_FOUND; - for (size_t i = 0; i < m_tree_items.size(); ++i) - if (m_tree_items[i] == sel) return int(i); - return wxNOT_FOUND; + return m_tree->selection(); } void DesignPanel::set_tree_selection(int row) { - if (row >= 0 && row < int(m_tree_items.size())) - m_tree->SelectItem(m_tree_items[row]); + if (row >= 0 && row < int(m_tree->GetItemCount())) + m_tree->select(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. +void DesignPanel::drop_selection() +{ + if (m_viewport != nullptr) + m_viewport->clear_loop_pick(); + drop_solid_pick(); +} + +void DesignPanel::drop_solid_pick() +{ + m_sel_solid_body = m_sel_solid_face = m_sel_solid_edge = -1; + m_sel_solid_edges.clear(); + m_sel_solid_vertex = false; + m_pick_face = m_pick_face_body = -1; + if (m_viewport != nullptr) + m_viewport->clear_solid_pick(); +} + +// The shared front of delete and reorder, which renumber the feature list. A sketch or constrain +// session, the Text dialog, an Insert placement and the standalone move gizmo can each hold a +// feature or body index (m_edit_index, m_constrain_feat, m_text_feat, m_insert_feat, m_move_body) +// that a renumber would point at something else, so they are refused until Finish or Cancel; Undo +// and mcp_busy() refuse most of the same states. An open feature card is closed instead, +// discarding its candidate, rather than linger out of step with the tree. +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())) { + set_status(StatusKind::Info, _L("Finish or cancel what is open first")); + return false; + } + if (m_active != Tool::None || m_edit_index >= 0) cancel_tool(); + m_doc.checkpoint(); // undo boundary + drop_selection(); + return true; } void DesignPanel::after_tree_edit(bool ok) @@ -7543,14 +7937,14 @@ void DesignPanel::after_tree_edit(bool ok) refresh_tree(); refresh_variables(); if (!ok) { - // The edit was rolled back (recompute failed); the body is unchanged. - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Edit rejected: ") + wxString::FromUTF8(m_doc.error)); - m_status->Refresh(); + // The edit was refused or rolled back; the body is unchanged. Repaint anyway: picks the + // caller dropped before trying it are still drawn, and set_status() repaints only when + // its text changes. + set_status(StatusKind::Error, wxString::Format(_L("Edit rejected: %s"), kernel_error_text(m_doc.error))); + if (m_viewport != nullptr) m_viewport->request_repaint(); return; } sync_recipe_to_model(); // deletes, reorders and suppressions change the document too - m_status->SetForegroundColour(wxNullColour); if (m_doc.display_mesh.its.indices.empty()) { if (m_viewport != nullptr) m_viewport->clear_mesh(); sync_sketch_display(); // empty body: show any un-consumed committed sketch @@ -7576,7 +7970,7 @@ void DesignPanel::after_tree_edit(bool ok) void DesignPanel::on_new_design() { if (m_doc.features.empty() && m_doc.bodies.empty()) { set_status_ok(); return; } - wxMessageDialog dlg(this, + MessageDialog dlg(this, _L("Erase all features and bodies and start a new design? This cannot be undone."), _L("New Design"), wxYES_NO | wxICON_EXCLAMATION); if (dlg.ShowModal() != wxID_YES) return; @@ -7591,6 +7985,9 @@ void DesignPanel::clear_document() { tool_cancel(); // leave any active tool / sketch / constrain cleanly 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); @@ -7604,44 +8001,31 @@ void DesignPanel::clear_document() // The verb the offer names when you point at a body, or at any face/edge/vertex of one. A body // is a recomputed RESULT, so what actually gets deleted is the feature that created it // (CadBody::source_feature). That is an edit to the recipe and can take other features with it, -// so it asks first and NAMES the feature: a body disappearing from the viewport is not by itself -// evidence of which feature went, and this is the one action here that cannot be eyeballed. +// so the status line NAMES the feature that went: a body disappearing from the viewport is not by +// itself evidence of which feature it was. No confirmation — it is one Ctrl+Z away, like every +// other delete here, and a modal in the modelling loop is the thing charter 4.2 removes. void DesignPanel::on_delete_body() { const int nb = int(m_doc.bodies.size()); if (m_sel_solid_body < 0 || m_sel_solid_body >= nb) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Select a body first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Select a body first")); return; } const int src = m_doc.bodies[m_sel_solid_body].source_feature; if (src < 0 || src >= int(m_doc.features.size())) { // Only reachable for a body no feature claims — a stale recipe, or a feature type that // broke the "never replace a whole CadBody" invariant recompute() relies on. - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("This body has no feature to delete — use New Design to start over")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("This body has no feature to delete — use New Design to start over")); return; } const std::string& raw = m_doc.features[src].name; const wxString fname = raw.empty() ? wxString::Format(_L("feature %d"), src + 1) : wxString::FromUTF8(raw); - if (wxMessageBox(wxString::Format( - _L("Delete %s?\n\nThat is the feature this body was made from. " - "Features built on it may be removed or stop working."), fname), - _L("Delete body"), wxYES_NO | wxNO_DEFAULT | wxICON_QUESTION, this) != wxYES) - return; - // A card left open over a feature that is about to vanish goes stale — same reason - // on_delete_feature() closes it. - if (m_active != Tool::None || m_edit_index >= 0) { - reset_edit_state(); - close_tool(); - } - m_doc.checkpoint(); // undo boundary: deleting a body's feature - m_sel_solid_body = m_sel_solid_face = m_sel_solid_edge = -1; // the selection is about to - m_sel_solid_vertex = false; // name a body that is gone - after_tree_edit(m_doc.remove_feature(src)); + if (!begin_renumber()) return; + const bool ok = m_doc.remove_feature(src); + after_tree_edit(ok); + if (ok) + set_status(StatusKind::Ok, wxString::Format(_L("Deleted %s, the feature this body was made from — Ctrl+Z restores it"), fname)); } void DesignPanel::on_delete_feature() @@ -7649,25 +8033,15 @@ void DesignPanel::on_delete_feature() // A Body row has no directly-removable feature (bodies are recomputed results); guide the // user to delete the feature that created it, or use New Design to wipe everything. if (tree_body_selection() >= 0) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Select the FEATURE that created this body (or use New Design)")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Select the FEATURE that created this body (or use New Design)")); return; } int sel = tree_selection(); if (sel == wxNOT_FOUND) { - set_status(_L("Select a feature in the tree first")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("Select a feature in the tree first")); return; } - // If a feature dialog is open (e.g. the feature is being edited), dismiss it first — - // otherwise the deleted feature's settings card lingers in the left panel, out of sync - // with the tree. reset_edit_state() drops the stale m_edit_index; close_tool() hides the card. - if (m_active != Tool::None || m_edit_index >= 0) { - reset_edit_state(); - close_tool(); - } - m_doc.checkpoint(); // undo boundary: deleting a feature + if (!begin_renumber()) return; after_tree_edit(m_doc.remove_feature(sel)); } @@ -7689,39 +8063,30 @@ void DesignPanel::on_toggle_visibility() &m_body_visible, &m_body_xform); } refresh_tree(); - // Keep the row selected for repeat toggles. m_parts, NOT m_tree: these ids belong - // to the Bodies list, and handing a foreign item to the feature tree left the row - // unselected — so the second press of the eye found tree_body_selection() == -1 and - // fell through to the FEATURE-level branch below instead of un-hiding the body. - if (m_parts != nullptr && bsel < int(m_tree_body_items.size())) - m_parts->SelectItem(m_tree_body_items[bsel]); - m_status->SetForegroundColour(wxNullColour); - set_status(wxString::Format(now_visible ? _L("Body %d shown") + // Keep the row selected for repeat toggles. m_parts, NOT m_tree: the row is a body, + // and selecting it in the feature tree left the body row unselected — so the second + // press of the eye found tree_body_selection() == -1 and fell through to the + // FEATURE-level branch below instead of un-hiding the body. + if (m_parts != nullptr) m_parts->select(bsel); + set_status(StatusKind::Info, wxString::Format(now_visible ? _L("Body %d shown") : _L("Body %d hidden"), bsel + 1)); - m_status->Refresh(); } return; } int sel = tree_selection(); if (sel == wxNOT_FOUND || sel >= int(m_doc.features.size())) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Select a feature in the tree first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Select a feature in the tree first")); return; } const bool shown = !m_doc.features[sel].enabled; - m_doc.features[sel].enabled = shown; - - // recompute() reports an all-hidden / sketch-only document as false (no - // solid to build), but that is a VALID state for hide — so clear the body - // explicitly instead of letting after_tree_edit treat it as a rejected edit - // (which would skip the overlay refresh, leaving hidden art on screen). - if (!recompute_guarded(_L("Rebuilding model…"))) { - m_doc.body = TopoDS_Shape(); - m_doc.display_mesh = TriangleMesh{}; - m_doc.error.clear(); - } + // Through the document, not a bare flag flip: hiding a feature that makes a body shifts + // every later body index, and set_feature_enabled keeps those references on their bodies + // (and datum-plane references on their planes). It accepts an all-hidden / sketch-only + // result as the valid empty state it is, and rolls back — saying why — when a later + // feature cannot do without what was hidden. + m_doc.checkpoint(); + if (!m_doc.set_feature_enabled(sel, shown)) { after_tree_edit(false); return; } refresh_tree(); // greys the row set_tree_selection(sel); // keep the toggled feature selected if (m_viewport != nullptr) { @@ -7729,23 +8094,20 @@ void DesignPanel::on_toggle_visibility() else feed_bodies(); } sync_sketch_display(); // skips the hidden sketch + direct-renders - m_status->SetForegroundColour(wxNullColour); - set_status(shown ? _L("Feature shown") : _L("Feature hidden")); - m_status->Refresh(); + set_status(StatusKind::Info, shown ? _L("Feature shown") : _L("Feature hidden")); } void DesignPanel::on_move_feature(int delta) { int sel = tree_selection(); if (sel == wxNOT_FOUND) { - set_status(_L("Select a feature in the tree first")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("Select a feature in the tree first")); return; } int target = sel + delta; if (target < 0 || target >= int(m_doc.features.size())) return; // already at the end - m_doc.checkpoint(); // undo boundary: reordering a feature + if (!begin_renumber()) return; if (m_doc.move_feature(sel, delta)) { after_tree_edit(true); set_tree_selection(target); // keep the moved feature selected @@ -7763,9 +8125,7 @@ bool DesignPanel::enter_constrain_inline() m_viewport->finish_sketch(); // synchronous: packages live entities+constraints -> Sketch const int sk = resolve_extrude_sketch(); // last/selected Sketch feature if (sk < 0) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Draw a sketch first, then Constrain")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Draw a sketch first, then Constrain")); return false; } set_tree_selection(sk); // tree drives on_begin_constrain / the constraint manager @@ -7783,22 +8143,17 @@ void DesignPanel::on_begin_constrain(int sel_override) // this verb from a SkLoop selection (a region clicked on screen), which carries no tree // selection — without this, choosing "Constrain sketch" from the offer would answer // "Select a sketch in the tree first" about a sketch the user has visibly selected. - if ((sel == wxNOT_FOUND || sel >= int(m_doc.features.size())) && m_sel_sketch_feat >= 0 - && m_sel_sketch_feat < int(m_doc.features.size())) { - sel = m_sel_sketch_feat; + if (sel == wxNOT_FOUND || sel >= int(m_doc.features.size())) { + sel = m_viewport ? m_viewport->loop_pick_feature() : wxNOT_FOUND; // range-checked below set_tree_selection(sel); // keep the tree in step with what the viewport says } if (sel == wxNOT_FOUND || sel >= int(m_doc.features.size())) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Select a sketch in the tree first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Select a sketch in the tree first")); return; } CadFeature& f = m_doc.features[sel]; if (f.type != CadFeatureType::Sketch) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Selected feature is not a sketch")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Selected feature is not a sketch")); return; } @@ -7807,17 +8162,13 @@ void DesignPanel::on_begin_constrain(int sel_override) if (!f.entities.empty()) { m_constrain_feat = sel; if (m_viewport) m_viewport->begin_constrain_entities(f.entities, f.plane); - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Pick 1-2 lines, then a constraint; right-click exits")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("Pick 1-2 lines, then a constraint; right-click exits")); return; } // Legacy profile path (Fase 3). if (f.profile.points.size() < 3) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Selected feature is not a sketch")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Selected feature is not a sketch")); return; } m_constrain_feat = sel; @@ -7826,9 +8177,7 @@ void DesignPanel::on_begin_constrain(int sel_override) if (f.constraints.empty()) f.constraints.push_back(SketchConstraintDef{SketchConstraintType::Fix, 0, -1, -1, -1, 0.0}); if (m_viewport) m_viewport->begin_constrain(f.profile, f.plane); - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Pick 1-2 entities, then a constraint or dimension; right-click exits")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("Pick 1-2 entities, then a constraint or dimension; right-click exits")); } // Why an entity-constraint pick was refused, as a localized string. The kernel's @@ -7884,9 +8233,7 @@ void DesignPanel::apply_entity_constraint(SketchConstraintType type) const ConstraintPlan plan = plan_entity_constraint(feat.entities, e0, e1, e2, type); auto fail = [this](const wxString& msg) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(msg); - m_status->Refresh(); + set_status(StatusKind::Error, msg); }; switch (plan.kind) { @@ -7923,9 +8270,7 @@ void DesignPanel::apply_live_constraint(SketchConstraintType type) m_viewport->sketch_entities(), e0, e1, e2, type); auto fail = [this](const wxString& msg) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(msg); - m_status->Refresh(); + set_status(StatusKind::Error, msg); }; // Shared commit: try_add_constraints appends→solves→keeps-or-rolls-back and leaves the // geometry untouched on failure, so the same over-constrained message the committed path @@ -7937,7 +8282,6 @@ void DesignPanel::apply_live_constraint(SketchConstraintType type) return; } m_viewport->request_repaint(); - m_status->SetForegroundColour(wxNullColour); // Say where it went and how to undo it, here at the moment of applying: the hint line // only refreshes when the step tuple changes, which applying a constraint does not. set_status(_L("Applied constraint · its badge is on the sketch — click the badge to remove it")); @@ -7986,9 +8330,7 @@ void DesignPanel::commit_entity_constraints(const std::vectorSetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Constraint rejected (over-constrained)")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Constraint rejected (over-constrained)")); return; } m_doc.recompute(); @@ -7999,9 +8341,7 @@ void DesignPanel::commit_entity_constraints(const std::vectorupdate_constrain_entities(m_doc.features[m_constrain_feat].entities); if (!m_doc.display_mesh.its.indices.empty()) feed_bodies(); - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Applied constraint")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("Applied constraint")); refresh_constrain_dof(); // P3 DoF readout for the Constrain path rebuild_constraint_list(); // C3.4 manager: a row appeared @@ -8053,14 +8393,17 @@ wxString DesignPanel::constraint_label(const SketchEntityConstraintDef& d) const case SketchEntity::Type::BSpline: c = 'B'; break; } } + // Non-ASCII narrow literals go through FromUTF8: wx converts a bare char* with the + // current locale, and under LC_ALL=C (the AppImage sets it) "—"/"·"/"°" fail to convert — + // an empty string, or a NULL format string that crashes wxString::Format. wxString s; s << wxUniChar(c) << ei; // avoid %c assert in Unicode build - if (r == SketchPointRole::P1) s += "·P1"; - else if (r == SketchPointRole::Center) s += "·Ctr"; - else if (r == SketchPointRole::P0) s += "·P0"; + if (r == SketchPointRole::P1) s += wxString::FromUTF8("·P1"); + else if (r == SketchPointRole::Center) s += wxString::FromUTF8("·Ctr"); + else if (r == SketchPointRole::P0) s += wxString::FromUTF8("·P0"); return s; }; auto two = [&](const wxString& name) { - return d.eb >= 0 ? wxString::Format("%s %s — %s", name, tag(d.ea, d.ra), tag(d.eb, d.rb)) + return d.eb >= 0 ? wxString::Format(wxString::FromUTF8("%s %s — %s"), name, tag(d.ea, d.ra), tag(d.eb, d.rb)) : wxString::Format("%s %s", name, tag(d.ea, d.ra)); }; switch (d.type) { @@ -8083,7 +8426,7 @@ wxString DesignPanel::constraint_label(const SketchEntityConstraintDef& d) const tag(d.ea, d.ra), tag(d.eb, d.rb)); case T::SymmetricAboutX: return wxString::Format(_L("Symmetric about X axis %s — %s"), tag(d.ea, d.ra), tag(d.eb, d.rb)); - case T::Angle: return wxString::Format("%s = %s°", two(_L("Angle")), en_format(d.value * 180.0 / M_PI, 1)); + case T::Angle: return wxString::Format(wxString::FromUTF8("%s = %s°"), two(_L("Angle")), en_format(d.value * 180.0 / M_PI, 1)); case T::Radius: return wxString::Format("%s %s = %s", _L("Radius"), tag(d.ea, d.ra), en_format(d.value)); case T::Diameter: return wxString::Format("%s %s = %s", _L("Diameter"), tag(d.ea, d.ra), en_format(d.value)); case T::PointOnLine: return two(_L("On line")); @@ -8127,15 +8470,15 @@ void DesignPanel::rebuild_constraint_list() for (int i = 0; i < int(cons.size()); ++i) { auto* row = new wxBoxSizer(wxHORIZONTAL); // Delete button first (fixed left position, always visible — long labels can - // horizontally scroll but ✗ stays put and clickable). BMP-safe ✗ glyph. - auto* del = new wxButton(m_cards, wxID_ANY, wxString::FromUTF8("✗"), - wxDefaultPosition, wxSize(26, -1)); - del->SetToolTip(_L("Delete constraint")); - del->Bind(wxEVT_BUTTON, [this, i](wxCommandEvent&) { delete_constraint(i); }); + // horizontally scroll but ✗ stays put and clickable). + auto* del = sidebar_icon_btn(m_cards, "design_delete", _L("Delete constraint"), 16); + // After the click: deleting rebuilds these rows, and with them this button, which must + // not be destroyed while its own click is still being dispatched. + del->Bind(wxEVT_BUTTON, [this, i](wxCommandEvent&) { CallAfter([this, i] { delete_constraint(i); }); }); // Clickable label: selecting it highlights the referenced entities. - auto* lbl = new wxButton(m_cards, wxID_ANY, constraint_label(cons[i]), - wxDefaultPosition, wxDefaultSize, wxBU_LEFT | wxBORDER_NONE); - lbl->Bind(wxEVT_BUTTON, [this, i](wxCommandEvent&) { highlight_constraint_entities(i); }); + auto* lbl = new wxStaticText(m_cards, wxID_ANY, constraint_label(cons[i])); + lbl->SetCursor(wxCursor(wxCURSOR_HAND)); + lbl->Bind(wxEVT_LEFT_UP, [this, i](wxMouseEvent&) { highlight_constraint_entities(i); }); row->Add(del, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6); row->Add(lbl, 1, wxALIGN_CENTER_VERTICAL); m_constraint_rows->Add(row, 0, wxEXPAND | wxTOP, 2); @@ -8182,14 +8525,12 @@ void DesignPanel::delete_constraint(int idx) { // Live session: the constraint lives in the sketch tool, not in any feature. Removing it // re-solves and fires on_constraints_changed, which rebuilds these rows — so this branch - // deliberately does NOT call rebuild_constraint_list() itself (it would run twice, and the - // second run would delete the wxButton whose click handler is still on the stack). + // deliberately does NOT call rebuild_constraint_list() itself (it would run twice). A row's ✗ + // calls this after its click has finished, so either rebuild may destroy that button. if (live_constraint_scope()) { if (!m_viewport->remove_sketch_constraint(idx)) return; - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Constraint deleted")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("Constraint deleted")); return; } if (m_constrain_feat < 0 || m_constrain_feat >= int(m_doc.features.size()) || !m_viewport) @@ -8209,9 +8550,7 @@ void DesignPanel::delete_constraint(int idx) m_viewport->update_constrain_entities(m_doc.features[m_constrain_feat].entities); if (!m_doc.display_mesh.its.indices.empty()) feed_bodies(); - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Constraint deleted")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("Constraint deleted")); refresh_constrain_dof(); rebuild_constraint_list(); } @@ -8220,15 +8559,11 @@ void DesignPanel::apply_edit_op(EditOp op) { if (m_constrain_feat < 0 || m_constrain_feat >= int(m_doc.features.size()) || !m_viewport || !m_viewport->is_constraining_entities()) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Press Constrain on a sketch first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Press Constrain on a sketch first")); return; } auto fail = [this](const wxString& msg) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(msg); - m_status->Refresh(); + set_status(StatusKind::Error, msg); }; int e0 = -1, e1 = -1; @@ -8284,8 +8619,7 @@ void DesignPanel::apply_edit_op(EditOp op) if (a >= int(f.entities.size())) return; auto out = SketchEngine::offset_entities({ f.entities[a] }, d); if (out.empty()) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Offset collapsed the entity")); m_status->Refresh(); return; + set_status(StatusKind::Error, _L("Offset collapsed the entity")); return; } const int ni = int(f.entities.size()); // offset copy lands here for (auto& o : out) f.entities.push_back(o); @@ -8326,9 +8660,7 @@ void DesignPanel::apply_edit_op(EditOp op) if (a >= int(f.entities.size()) || b >= int(f.entities.size())) return; SketchEntity a_out, b_out, arc_out; if (!SketchEngine::fillet_lines(f.entities[a], f.entities[b], r, a_out, b_out, arc_out)) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Fillet failed (parallel lines or radius too large)")); - m_status->Refresh(); return; + set_status(StatusKind::Error, _L("Fillet failed (parallel lines or radius too large)")); return; } f.entities[a] = a_out; f.entities[b] = b_out; @@ -8407,9 +8739,7 @@ void DesignPanel::apply_edit_op(EditOp op) if (a >= int(f.entities.size()) || b >= int(f.entities.size())) return; SketchEntity a_out, b_out, seg_out; if (!SketchEngine::chamfer_lines(f.entities[a], f.entities[b], d, a_out, b_out, seg_out)) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Chamfer failed (parallel lines or distance too large)")); - m_status->Refresh(); return; + set_status(StatusKind::Error, _L("Chamfer failed (parallel lines or distance too large)")); return; } f.entities[a] = a_out; f.entities[b] = b_out; @@ -8596,8 +8926,7 @@ void DesignPanel::apply_edit_op(EditOp op) auto copies = SketchEngine::array_entities( { src }, count, sp * dir, 0.0, Vec2d(0, 0)); if (copies.empty()) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Array produced nothing")); m_status->Refresh(); return; + set_status(StatusKind::Error, _L("Array produced nothing")); return; } const int base = int(f.entities.size()); // first copy index for (auto& c : copies) f.entities.push_back(c); @@ -8674,8 +9003,7 @@ void DesignPanel::apply_edit_op(EditOp op) auto out = SketchEngine::transform_entities( { f.entities[a] }, Vec2d(dx, dy), 0.0, 1.0, Vec2d(0, 0)); if (out.empty()) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Move produced nothing")); m_status->Refresh(); return; + set_status(StatusKind::Error, _L("Move produced nothing")); return; } f.entities[a] = out[0]; @@ -8742,8 +9070,7 @@ void DesignPanel::apply_edit_op(EditOp op) auto out = SketchEngine::transform_entities( { f.entities[a] }, Vec2d(0, 0), deg * M_PI / 180.0, 1.0, piv); if (out.empty()) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Rotate produced nothing")); m_status->Refresh(); return; + set_status(StatusKind::Error, _L("Rotate produced nothing")); return; } f.entities[a] = out[0]; @@ -8801,8 +9128,7 @@ void DesignPanel::apply_edit_op(EditOp op) auto out = SketchEngine::transform_entities( { f.entities[a] }, Vec2d(0, 0), 0.0, sf, piv); if (out.empty()) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Scale produced nothing")); m_status->Refresh(); return; + set_status(StatusKind::Error, _L("Scale produced nothing")); return; } f.entities[a] = out[0]; @@ -8871,8 +9197,7 @@ void DesignPanel::apply_edit_op(EditOp op) auto copies = SketchEngine::array_entities( { src }, count, Vec2d(0, 0), angle_step, piv); if (copies.empty()) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Polar array produced nothing")); m_status->Refresh(); return; + set_status(StatusKind::Error, _L("Polar array produced nothing")); return; } const int base = int(f.entities.size()); // first copy index for (auto& c : copies) f.entities.push_back(c); @@ -8944,9 +9269,7 @@ void DesignPanel::after_edit_op() sync_sketch_display(); if (!m_doc.display_mesh.its.indices.empty()) feed_bodies(); - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Applied edit")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("Applied edit")); refresh_constrain_dof(); rebuild_constraint_list(); } @@ -8966,9 +9289,7 @@ void DesignPanel::request_value(const wxString& label, double def, double mn, do m_form->FitInside(); m_value_input->SetFocus(); m_value_input->SetSelection(-1, -1); // select all so typing replaces the value - m_status->SetForegroundColour(wxNullColour); - set_status(label + _L(" — type a value, press Enter (or Confirm)")); - m_status->Refresh(); + set_status(StatusKind::Info, label + _L(" — type a value, press Enter (or Confirm)")); } void DesignPanel::confirm_value() @@ -8997,9 +9318,7 @@ void DesignPanel::cancel_value() update_cards_frame(); m_form->Layout(); m_form->FitInside(); if (was_open) { - m_status->SetForegroundColour(wxNullColour); - set_status(wxString()); - m_status->Refresh(); + set_status(StatusKind::Info, wxString()); } if (on_cancel) on_cancel(); // e.g. keep a pending line segment as drawn @@ -9021,9 +9340,7 @@ void DesignPanel::apply_constraint(SketchConstraintType type) if (m_constrain_feat < 0 || m_constrain_feat >= int(m_doc.features.size()) || m_viewport == nullptr) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Press Constrain on a sketch first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Press Constrain on a sketch first")); return; } @@ -9035,16 +9352,12 @@ void DesignPanel::apply_constraint(SketchConstraintType type) // 3. Legacy profile path. if (!m_viewport->is_constraining()) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Press Constrain on a sketch first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Press Constrain on a sketch first")); return; } int a = -1, b = -1; if (!m_viewport->selected_segment(a, b)) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Pick a segment in the viewport first")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Pick a segment in the viewport first")); return; } CadFeature& feat = m_doc.features[m_constrain_feat]; @@ -9057,9 +9370,7 @@ void DesignPanel::apply_constraint(SketchConstraintType type) feat.constraints.pop_back(); // reject the non-converging addition feat.profile.points = saved_pts; // and restore the pre-solve geometry m_doc.abandon_checkpoint(); // restored: no state change, so no undo step - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Constraint rejected (over-constrained)")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Constraint rejected (over-constrained)")); return; } m_doc.recompute(); @@ -9068,10 +9379,8 @@ void DesignPanel::apply_constraint(SketchConstraintType type) m_viewport->update_constrain_profile(m_doc.features[m_constrain_feat].profile.points); if (!m_doc.display_mesh.its.indices.empty()) feed_bodies(); - m_status->SetForegroundColour(wxNullColour); - set_status(type == SketchConstraintType::Horizontal ? _L("Applied Horizontal") + set_status(StatusKind::Info, type == SketchConstraintType::Horizontal ? _L("Applied Horizontal") : _L("Applied Vertical")); - m_status->Refresh(); } void DesignPanel::reset_edit_state() @@ -9117,7 +9426,8 @@ void DesignPanel::load_feature_into_dialog(const CadFeature& f) m_dressup_type->SetSelection(f.type == CadFeatureType::Fillet ? 0 : 1); m_dressup_size->SetValue(f.dressup_size); m_face_group->SetSelection(static_cast(f.face_group)); - m_sel_solid_edge = f.dressup_edge; // preserve edge-targeting on re-edit + m_sel_solid_edges = f.dressup_edge_ids(); // preserve edge-targeting on re-edit + m_sel_solid_edge = m_sel_solid_edges.empty() ? -1 : m_sel_solid_edges.back(); m_sel_solid_body = f.target_body; // preserve which body on re-edit sync_dressup_target(); // and say which of the two the re-edit is targeting break; @@ -9178,10 +9488,10 @@ void DesignPanel::load_feature_into_dialog(const CadFeature& f) break; case CadFeatureType::Revolve: m_revolve_angle->SetValue(f.revolve_angle); - m_revolve_axis->SetSelection(f.revolve_axis); m_revolve_mode->SetSelection(static_cast(f.mode)); m_revolve_flip->SetValue(f.flip); m_revolve_sketch_ref = f.sketch_ref; + fill_revolve_axes(m_revolve_axis, m_revolve_axis_ents, f.sketch_ref, f.revolve_axis, f.revolve_axis_entity); break; case CadFeatureType::Sweep: m_sweep_profile_ref = f.sketch_ref; @@ -9295,9 +9605,9 @@ void DesignPanel::load_feature_into_dialog(const CadFeature& f) break; case CadFeatureType::SurfaceRevolve: m_surf_revolve_angle->SetValue(f.revolve_angle); - m_surf_revolve_axis->SetSelection(f.revolve_axis); m_surf_revolve_flip->SetValue(f.flip); m_surf_revolve_sketch_ref = f.sketch_ref; + fill_revolve_axes(m_surf_revolve_axis, m_surf_revolve_axis_ents, f.sketch_ref, f.revolve_axis, f.revolve_axis_entity); if (m_surf_revolve_sketch_ref >= 0 && m_surf_revolve_sketch_ref < int(m_doc.features.size())) m_surf_revolve_sketch_label->SetLabel(_L("Sketch: ") + wxString::FromUTF8(m_doc.features[m_surf_revolve_sketch_ref].name)); @@ -9423,8 +9733,7 @@ void DesignPanel::on_edit_feature() { int sel = tree_selection(); if (sel == wxNOT_FOUND) { - set_status(_L("Select a feature in the tree first")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("Select a feature in the tree first")); return; } const CadFeature& f = m_doc.features[sel]; @@ -9432,7 +9741,12 @@ void DesignPanel::on_edit_feature() switch (f.type) { case CadFeatureType::Sketch: - // Imported Text/SVG art has no editable sketch dialog — edit means + // Text reopens its dialog: change the words, the font or the height in place. + if (f.is_text()) { + open_text_dialog(sel); + break; + } + // Imported SVG art has no editable sketch dialog — edit means // move / scale its placement instead, behind the same Confirm/Cancel gate as // the initial insert (Cancel = undo restores the prior placement). if (!f.imported_regions.empty()) { @@ -9454,9 +9768,7 @@ void DesignPanel::on_edit_feature() m_viewport->set_display_sketches({}); m_viewport->edit_sketch(f.entities, f.entity_constraints, f.plane); } - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Editing sketch — drag a handle or click a quote to edit")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("Editing sketch — drag a handle or click a quote to edit")); } else { load_feature_into_dialog(f); open_tool(Tool::Sketch); @@ -9631,16 +9943,12 @@ void DesignPanel::on_edit_feature() // job, and that feature already exists — so point there rather than invent a dialog // that would only duplicate it. (Imported 2D Text/SVG art is different and IS // re-editable; it arrives as a Sketch feature with imported_regions, handled above.) - m_status->SetForegroundColour(wxNullColour); - set_status(_L("An imported solid has no parameters — use Transform to move or rotate it")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("An imported solid has no parameters — use Transform to move or rotate it")); break; default: // Every CadFeatureType now has a case. Kept as a guard so a type added later // announces itself instead of silently swallowing the Edit click. - m_status->SetForegroundColour(wxNullColour); - set_status(_L("This feature type can't be edited yet")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("This feature type can't be edited yet")); break; } } @@ -9651,9 +9959,7 @@ void DesignPanel::on_export_step() if (m_active != Tool::None) confirm_tool(); if (m_doc.bodies.empty()) { - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Nothing to export — add a feature first")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("Nothing to export — add a feature first")); return; } wxFileDialog dlg(this, _L("Export STEP"), wxEmptyString, "model.step", @@ -9664,12 +9970,25 @@ void DesignPanel::on_export_step() sync_body_xform(); // export bodies at their displayed Move-gizmo positions std::string err; const bool ok = m_doc.export_step(dlg.GetPath().ToUTF8().data(), m_body_xform, err); - m_status->SetForegroundColour(ok ? wxColour(120, 210, 120) : wxColour(235, 110, 110)); - set_status(ok ? _L("Exported STEP") - : _L("STEP export failed: ") + wxString::FromUTF8(err)); + set_status(ok ? StatusKind::Ok : StatusKind::Error, + ok ? _L("Exported STEP") + : wxString::Format(_L("STEP export failed: %s"), wxString::FromUTF8(err))); m_status->Refresh(); } +void DesignPanel::set_commit_mode(CommitMode mode) +{ + m_commit_mode = mode; + const bool bodies = mode == CommitMode::Bodies; + if (m_commit_btn) { + m_commit_btn->SetBitmap_(commit_icon(bodies)); + m_commit_btn->SetToolTip(bodies ? _L("Commit to Plate (as bodies) — send each body to Prepare as its own object") + : _L("Commit to Plate — send the solid to Prepare")); + } + if (m_commit_drop) + m_commit_drop->SetSelection(int(mode)); // the check on the current row +} + void DesignPanel::on_commit() { // A feature tool open with a live preview ghost (e.g. a fillet being previewed) is @@ -9686,24 +10005,28 @@ void DesignPanel::on_commit() if (obj_list == nullptr) return; - // Multi-body: ship each (visible) body as its own plate object so they arrive on the - // slicer plate as independent, separately-arrangeable parts (Onshape "Commit all parts"). + // Multi-body: ship the (visible) bodies as the parts of one assembly object that keeps + // their placement (the default), or each as its own plate object so they arrive as + // independent, separately-arrangeable parts (Onshape "Commit all parts"). // Hidden bodies are skipped — what you see on the Design plate is what gets committed. sync_body_visible(); rebuild_disp_meshes(); // ship moved bodies at their Move-gizmo positions if (m_disp_body_meshes.size() > 1) { - int committed = 0; + std::vector> parts; for (size_t b = 0; b < m_disp_body_meshes.size(); ++b) { if (b < m_body_visible.size() && !m_body_visible[b]) continue; // skip hidden if (m_disp_body_meshes[b].its.indices.empty()) continue; - obj_list->load_mesh_object(m_disp_body_meshes[b], - "Design Body " + std::to_string(b + 1)); - ++committed; + parts.emplace_back(&m_disp_body_meshes[b], wxString::FromUTF8("Design Body " + std::to_string(b + 1))); } - if (committed == 0) { // every body hidden — nothing to ship + if (parts.empty()) { // every body hidden — nothing to ship set_status(_L("All bodies hidden — show one before committing")); return; } + if (m_commit_mode == CommitMode::Assembly && parts.size() > 1) + obj_list->load_mesh_object(parts, "Design Assembly"); + else + for (const auto& [mesh, name] : parts) + obj_list->load_mesh_object(*mesh, name); } else { obj_list->load_mesh_object(m_disp_pick_mesh, "Design Body"); } @@ -9785,8 +10108,12 @@ CadFeature DesignPanel::build_candidate(Tool t) const : CadFeatureType::Chamfer; f.dressup_size = m_dressup_size->GetValue(); f.face_group = static_cast(m_face_group->GetSelection()); - // A click-selected solid edge overrides the face-group: dress THAT edge. - f.dressup_edge = m_sel_solid_edge; // -1 when no edge picked + // Click-selected solid edges override the face-group: dress THOSE edges. + { + const std::vector edges = dressup_edges(); + f.dressup_edge = edges.empty() ? -1 : edges.front(); // -1 when no edge picked + f.dressup_edges = edges.size() > 1 ? edges : std::vector(); + } break; case Tool::Hole: f.type = CadFeatureType::Hole; @@ -9808,6 +10135,10 @@ CadFeature DesignPanel::build_candidate(Tool t) const f.thread_internal = m_thread_internal->GetValue(); f.thread_x = m_thread_x->GetValue(); f.thread_y = m_thread_y->GetValue(); + // The field is the nominal (major) diameter. A thread from an older project being + // re-edited keeps the reading it was made with (f was seeded from it), so editing its + // pitch does not also silently change its diameter. + if (!editing) f.thread_major_nominal = true; if (m_thread_on_face) f.target_body = m_thread_face_body; // tap the right body break; case Tool::Shell: @@ -9825,7 +10156,7 @@ CadFeature DesignPanel::build_candidate(Tool t) const f.type = CadFeatureType::Revolve; f.sketch_ref = m_revolve_sketch_ref; f.revolve_angle = m_revolve_angle->GetValue(); - f.revolve_axis = m_revolve_axis->GetSelection(); + read_revolve_axis(m_revolve_axis, m_revolve_axis_ents, f.revolve_axis, f.revolve_axis_entity); f.flip = m_revolve_flip->GetValue(); f.mode = static_cast(m_revolve_mode->GetSelection()); break; @@ -9845,6 +10176,13 @@ CadFeature DesignPanel::build_candidate(Tool t) const f.pattern_spacing = m_pattern_spacing->GetValue(); f.pattern_dir = m_pattern_dir->GetSelection(); f.pattern_angle = m_pattern_angle->GetValue(); + if (!editing) { + // What add_pattern commits, so the preview is the pattern that will be made: the + // bed-centred XY plane, and copies spanning the whole angle. + f.pattern_inclusive = true; + f.plane = SketchPlane::XY(); + f.plane.origin += m_doc.modeling_origin; + } break; case Tool::Plane: f.type = CadFeatureType::Plane; @@ -9902,7 +10240,7 @@ CadFeature DesignPanel::build_candidate(Tool t) const f.type = CadFeatureType::SurfaceRevolve; f.sketch_ref = m_surf_revolve_sketch_ref; f.revolve_angle = m_surf_revolve_angle->GetValue(); - f.revolve_axis = m_surf_revolve_axis->GetSelection(); + read_revolve_axis(m_surf_revolve_axis, m_surf_revolve_axis_ents, f.revolve_axis, f.revolve_axis_entity); f.flip = m_surf_revolve_flip->GetValue(); break; case Tool::SurfaceLoft: { @@ -10033,7 +10371,8 @@ CadFeature DesignPanel::build_candidate(Tool t) const && m_active != Tool::DeleteFace && m_active != Tool::Rib && m_active != Tool::Project && m_active != Tool::Helix && m_active != Tool::SurfaceOffset && m_active != Tool::ThickenSurface) - f.target_body = m_sel_solid_body; + f.target_body = (m_active == Tool::Extrude && m_sel_solid_body < 0) ? m_extrude_auto_body + : m_sel_solid_body; return f; } @@ -10043,12 +10382,21 @@ CadFeature DesignPanel::build_candidate(Tool t) const // user picked in the viewport, or the face-group. build_dressup reads m_sel_solid_edge first and // only falls back to the group, so when an edge is picked the group combo is inert — grey it out // rather than leave it showing a value it will not use. +std::vector DesignPanel::dressup_edges() const +{ + if (m_sel_solid_edge < 0) return {}; + if (!m_sel_solid_edges.empty() && m_sel_solid_edges.back() == m_sel_solid_edge) + return m_sel_solid_edges; + return { m_sel_solid_edge }; +} + void DesignPanel::sync_dressup_target() { if (m_dressup_edge_label == nullptr) return; - const bool have_edge = (m_sel_solid_edge >= 0); - m_dressup_edge_label->SetLabel(have_edge - ? wxString::Format(_L("Edge %d"), m_sel_solid_edge) + const size_t n = dressup_edges().size(); + const bool have_edge = n > 0; + m_dressup_edge_label->SetLabel(n > 1 ? wxString::Format(_L_PLURAL("%zu edge", "%zu edges", n), n) + : have_edge ? wxString::Format(_L("Edge %d"), m_sel_solid_edge) : _L("(no edge picked — group below)")); if (m_face_group != nullptr) m_face_group->Enable(!have_edge); m_dressup_edge_label->Refresh(); @@ -10143,6 +10491,40 @@ void DesignPanel::update_shell_gizmo() m_viewport->begin_shell_gizmo(c, (-n).normalized(), m_shell_thickness->GetValue()); } +void DesignPanel::fill_revolve_axes(ComboBox* combo, std::vector& ents, int sketch_ref, int axis, int entity) +{ + combo->Clear(); + ents.clear(); + combo->Append(_L("Plane X")); + combo->Append(_L("Plane Y")); + int centerline = -1, centerlines = 0; + if (sketch_ref >= 0 && sketch_ref < int(m_doc.features.size())) { + const std::vector& es = m_doc.features[sketch_ref].entities; + for (int i = 0; i < int(es.size()); ++i) { + if (es[i].type != SketchEntity::Type::Line) + continue; + // Named as the constraint list names entities (E0, E1, …), so the two agree. + combo->Append(es[i].construction ? wxString::Format(_L("Centerline E%d"), i) + : wxString::Format(_L("Line E%d"), i)); + ents.push_back(i); + if (es[i].construction) { centerline = i; ++centerlines; } + } + } + if (entity == -2) // fresh revolve: a lone centerline is what the profile was drawn around + entity = centerlines == 1 ? centerline : -1; + int sel = axis == 1 ? 1 : 0; + for (int k = 0; k < int(ents.size()); ++k) + if (ents[k] == entity) sel = 2 + k; + combo->SetSelection(sel); +} + +void DesignPanel::read_revolve_axis(ComboBox* combo, const std::vector& ents, int& axis, int& entity) +{ + const int sel = combo->GetSelection(); + axis = sel == 1 ? 1 : 0; + entity = sel >= 2 && sel - 2 < int(ents.size()) ? ents[sel - 2] : -1; +} + void DesignPanel::update_revolve_gizmo() { if (!m_viewport) return; @@ -10158,6 +10540,7 @@ void DesignPanel::update_revolve_gizmo() if (!sk.entities.empty()) { Vec2d acc(0, 0); int n = 0; for (const SketchEntity& e : sk.entities) { + if (e.construction) continue; // a centerline is the axis, not part of the profile switch (e.type) { case SketchEntity::Type::Line: acc += 0.5 * (e.p0 + e.p1); ++n; break; case SketchEntity::Type::Arc: @@ -10178,7 +10561,16 @@ void DesignPanel::update_revolve_gizmo() for (const Vec2d& p : sk.profile.points) centroid += p; centroid /= double(sk.profile.points.size()); } - m_viewport->begin_revolve_gizmo(sk.plane, centroid, m_revolve_axis->GetSelection(), + // The axis as the kernel resolves it (revolve_axis_of): the picked line, else plane X / Y. + int axis = 0, axis_entity = -1; + read_revolve_axis(m_revolve_axis, m_revolve_axis_ents, axis, axis_entity); + Vec3d ax_o = sk.plane.origin, ax_d = axis == 1 ? sk.plane.y_axis : sk.plane.x_axis; + if (axis_entity >= 0 && axis_entity < int(sk.entities.size()) + && (sk.entities[axis_entity].p1 - sk.entities[axis_entity].p0).norm() > 1e-9) { + ax_o = sk.plane.to_world(sk.entities[axis_entity].p0); + ax_d = sk.plane.to_world(sk.entities[axis_entity].p1) - ax_o; + } + m_viewport->begin_revolve_gizmo(sk.plane, centroid, ax_o, ax_d.normalized(), m_revolve_angle->GetValue(), m_revolve_flip->GetValue()); } @@ -10690,18 +11082,18 @@ void DesignPanel::refresh_preview() if (m_active == Tool::Sketch || m_active == Tool::Plane || m_active == Tool::Axis || m_active == Tool::CoordSys || m_active == Tool::Helix || m_active == Tool::Project) { m_viewport->clear_preview(); - m_status->SetForegroundColour(wxColour(120, 210, 120)); wxString ready; switch (m_active) { case Tool::Plane: ready = _L("Plane ready"); break; case Tool::Axis: ready = _L("Axis ready"); break; - case Tool::CoordSys: ready = _L("Coord Sys ready"); break; + case Tool::CoordSys: ready = _L("Coordinate system ready"); break; case Tool::Helix: ready = _L("Helix ready"); break; case Tool::Project: ready = _L("Project ready"); break; default: ready = _L("Sketch ready"); break; } - set_status(ready); - for (wxButton* b : m_confirm_btns) if (b) b->Enable(true); + set_status(StatusKind::Ok, ready); + m_candidate_ok = true; + update_confirm_button(); m_status->Refresh(); update_datum_gizmo(); // Plane card: show/refresh the in-canvas resize handles update_helix_gizmo(); // Helix card: draw the live curve + drag handles (no solid ghost) @@ -10724,13 +11116,11 @@ void DesignPanel::refresh_preview() if (!has_two) { m_viewport->clear_preview(); m_viewport->set_body_hidden(false); // the ghost replaced them; give them back - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Mate needs at least two CoordSys features")); + set_status(StatusKind::Warning, _L("Mate needs two coordinate systems — create them first")); } else if (same) { m_viewport->clear_preview(); m_viewport->set_body_hidden(false); - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Mate: CS A and CS B must be different")); + set_status(StatusKind::Error, _L("Mate: A and B must be different coordinate systems")); } else { sync_body_xform(); // same reason as the solid path below: drop stale per-body poses const int cs_a = int(reinterpret_cast(m_mate_cs_a->GetClientData(sel_a))); @@ -10741,14 +11131,13 @@ void DesignPanel::refresh_preview() m_mate_angle ? m_mate_angle->GetValue() : 0.0, m_mate_flip && m_mate_flip->GetValue(), err); if (ok) { - m_status->SetForegroundColour(wxColour(120, 210, 120)); - set_status(_L("Mate ready")); + set_status(StatusKind::Ok, _L("Mate ready")); } else { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Invalid: ") + wxString::FromUTF8(err)); + set_status(StatusKind::Error, wxString::Format(_L("Invalid: %s"), kernel_error_text(err))); } } - for (wxButton* b : m_confirm_btns) if (b) b->Enable(ok); + m_candidate_ok = ok; + update_confirm_button(); m_status->Refresh(); return; } @@ -10787,17 +11176,15 @@ void DesignPanel::refresh_preview() if (ok) { m_viewport->set_preview_mesh(mesh); - m_status->SetForegroundColour(wxColour(120, 210, 120)); // ok = green - set_status(wxString::Format(_L("Preview — %zu triangles"), mesh.its.indices.size())); + set_status(StatusKind::Ok, wxString::Format(_L("Preview — %zu triangles"), mesh.its.indices.size())); } else { m_viewport->clear_preview(); - m_status->SetForegroundColour(wxColour(235, 110, 110)); // invalid = red - set_status(_L("Invalid: ") + wxString::FromUTF8(err)); + set_status(StatusKind::Error, wxString::Format(_L("Invalid: %s"), kernel_error_text(err))); } // Onshape parity: a broken candidate cannot be committed. Grey the active dialog's // Confirm so the user sees the gate before clicking; the red status says why. - for (wxButton* b : m_confirm_btns) - if (b != nullptr) b->Enable(ok); + m_candidate_ok = ok; + update_confirm_button(); // Fillet/Chamfer/Draft: 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. @@ -10869,11 +11256,9 @@ void DesignPanel::apply_move_card() m_body_xform[b] = x * m_move_prev; feed_bodies(); if (m_viewport) m_viewport->request_repaint(); - m_status->SetForegroundColour(wxNullColour); - set_status(wxString::Format(_L("Body %d — moved (%.1f, %.1f, %.1f) mm, rotated %.1f°"), + set_status(StatusKind::Info, wxString::Format(_L("Body %d — moved (%.1f, %.1f, %.1f) mm, rotated %.1f°"), b + 1, d.x(), d.y(), d.z(), m_move_angle ? m_move_angle->GetValue() : 0.0)); - m_status->Refresh(); } void DesignPanel::show_move_card(bool show) @@ -10896,6 +11281,7 @@ void DesignPanel::push_polygon_params() void DesignPanel::open_tool(Tool t) { m_active = t; + 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 // here so the body is fully visible for picking the edge/face. @@ -11063,10 +11449,12 @@ void DesignPanel::open_tool(Tool t) else if (m_extrude_sketch_ref >= 0 && m_extrude_sketch_ref < int(m_doc.features.size())) m_extrude_sketch_label->SetLabel(_L("Sketch: ") + wxString::FromUTF8(m_doc.features[m_extrude_sketch_ref].name)); - // A fresh extrude defaults to New body — even when other bodies exist — so - // overlapping extrudes stay SEPARATE solids instead of silently fusing. Joining - // is opt-in (pick "Join"). Engraving art onto a face still defaults to Cut. - // (Edit-mode keeps the feature's stored mode, set below.) + // The default is inferred from where the profile is (charter L6: most extrudes join). + // A profile drawn on or touching a solid joins THAT body; a pushed/pulled face joins + // its own body; a profile in free space is a New body, so overlapping extrudes are + // never silently fused into something they do not touch. Engraving art onto a face + // still defaults to Cut. (Edit-mode keeps the feature's stored mode, set below.) + m_extrude_auto_body = -1; if (m_edit_index < 0) { const bool on_face_import = m_extrude_sketch_ref >= 0 && m_extrude_sketch_ref < int(m_doc.features.size()) @@ -11074,8 +11462,11 @@ void DesignPanel::open_tool(Tool t) if (on_face_import) { m_mode->SetSelection(2); // Cut — engrave into the face m_flip->SetValue(true); // extrude inward (the face normal points out) + } else if (m_extrude_face_src >= 0) { + m_mode->SetSelection(1); // Join — push/pull grows the face's own body } else { - m_mode->SetSelection(0); // New body (was: Add when a body already existed) + m_extrude_auto_body = m_doc.body_touching_sketch(m_extrude_sketch_ref); + m_mode->SetSelection(m_extrude_auto_body >= 0 ? 1 : 0); // Join : New body } } } @@ -11104,7 +11495,7 @@ void DesignPanel::open_tool(Tool t) case Tool::Boolean: m_hdr_boolean->SetLabel(title(_L("Boolean"))); break; case Tool::Cut: m_hdr_cut->SetLabel(title(_L("Cut"))); break; case Tool::Axis: m_hdr_axis->SetLabel(title(_L("Axis"))); break; - case Tool::CoordSys: m_hdr_coordsys->SetLabel(title(_L("Coord Sys"))); break; + case Tool::CoordSys: m_hdr_coordsys->SetLabel(title(_L("Coordinate system"))); break; case Tool::SurfaceExtrude: m_hdr_surf_extrude->SetLabel(title(_L("Surface Extrude"))); break; case Tool::SurfaceRevolve: m_hdr_surf_revolve->SetLabel(title(_L("Surface Revolve"))); break; case Tool::SurfaceLoft: m_hdr_surf_loft->SetLabel(title(_L("Surface Loft"))); break; @@ -11170,6 +11561,7 @@ void DesignPanel::open_tool(Tool t) // the card appears always means "keep this one" regardless of what the last session did. if (t == Tool::Boolean) m_bool_next_slot = 0; + request_feature_highlight(); // the card's ghost and picks take the view over } void DesignPanel::close_tool() @@ -11239,6 +11631,7 @@ void DesignPanel::close_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) + request_feature_highlight(); // the selected row's faces come back with the card gone } void DesignPanel::confirm_tool() @@ -11301,9 +11694,7 @@ void DesignPanel::cancel_tool() close_tool(); // Cancel discards the candidate: clear the stale "Preview …"/"Invalid …" // label and restore the neutral idle colour (Confirm keeps its "OK" status). - m_status->SetForegroundColour(wxNullColour); - set_status(wxString()); - m_status->Refresh(); + set_status(StatusKind::Info, wxString()); } // One Confirm surface for the whole tab. Routes to the right commit by current context: @@ -11322,7 +11713,10 @@ void DesignPanel::tool_confirm() if (m_active == Tool::Insert) { finalize_insert(); return; } if (m_active != Tool::None) { confirm_tool(); return; } if (m_ui_mode == UiMode::Sketch) { - if (m_viewport && m_viewport->is_sketching()) m_viewport->finish_sketch(); + if (m_viewport && m_viewport->is_sketching()) { + m_viewport->sketch_confirm_pending(); // a ready edit-op/transform/chain is kept, not dropped + m_viewport->finish_sketch(); + } set_ui_mode(UiMode::Feature); return; } @@ -11331,9 +11725,7 @@ void DesignPanel::tool_confirm() if (m_viewport) m_viewport->end_constrain(); m_constrain_feat = -1; set_ui_mode(UiMode::Feature); - m_status->SetForegroundColour(wxNullColour); - set_status(wxString()); - m_status->Refresh(); + set_status(StatusKind::Info, wxString()); } } @@ -11351,9 +11743,7 @@ void DesignPanel::tool_cancel() show_move_card(false); feed_bodies(); // re-render the reverted placement update_action_bar(); - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Move cancelled")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("Move cancelled")); return; } if (m_active == Tool::Insert) { cancel_insert(); return; } @@ -11365,7 +11755,7 @@ void DesignPanel::tool_cancel() // the user to press the very button they had just pressed: a sketch could be kept but // never discarded. if (m_viewport && m_viewport->live_sketch_has_work()) { - wxMessageDialog dlg(this, + RichMessageDialog dlg(this, _L("Discard this sketch and everything drawn in it?"), _L("Discard sketch"), wxYES_NO | wxNO_DEFAULT | wxICON_EXCLAMATION); @@ -11377,9 +11767,7 @@ void DesignPanel::tool_cancel() set_ui_mode(UiMode::Feature); sync_sketch_display(); refresh_tree(); - m_status->SetForegroundColour(wxNullColour); - set_status(wxString()); - m_status->Refresh(); + set_status(StatusKind::Info, wxString()); return; } if (m_ui_mode == UiMode::Constrain) { @@ -11387,12 +11775,27 @@ void DesignPanel::tool_cancel() if (m_viewport) m_viewport->end_constrain(); m_constrain_feat = -1; set_ui_mode(UiMode::Feature); - m_status->SetForegroundColour(wxNullColour); - set_status(wxString()); - m_status->Refresh(); + set_status(StatusKind::Info, wxString()); } } +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; + if (m_active == Tool::Insert) return true; + return m_candidate_ok; +} + +void DesignPanel::update_confirm_button() +{ + const bool ok = confirm_enabled(); + for (::Button* b : m_confirm_btns) + if (b != nullptr) b->Enable(ok); +} + // Which level of the interaction stack one Esc press belongs to. The rule itself lives in // DesignInteraction.hpp, decidable without a window; this only answers the four questions it // asks about THIS panel. @@ -11436,9 +11839,7 @@ void DesignPanel::escape() // puts the body back at the pose it had when the gizmo appeared. if (m_viewport && m_viewport->drawing_in_progress()) { m_viewport->sketch_abort_gesture(); - m_status->SetForegroundColour(wxNullColour); - set_status(wxString()); - m_status->Refresh(); + set_status(StatusKind::Info, wxString()); return; } tool_cancel(); @@ -11451,22 +11852,17 @@ void DesignPanel::escape() // 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_viewport && m_viewport->sketch_disarm_tool()) { - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Select")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("Select")); } return; - case CadLevel::Idle: + case CadLevel::Idle: { // Deselect. In a sketch this is the floor: the session is left through Finish or Cancel, // both of which say which one they are, and never through a key pressed on the way out of - // something else. - if (m_viewport && m_viewport->clear_any_selection()) { - m_sel_sketch_region = -1; - m_sel_sketch_feat = -1; - m_status->SetForegroundColour(wxNullColour); - set_status(wxString()); - m_status->Refresh(); + // 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) { + set_status(StatusKind::Info, wxString()); return; } // Nothing selected and nothing to unwind. An EMPTY sketch session may as well close — @@ -11477,28 +11873,33 @@ void DesignPanel::escape() return; } if (m_ui_mode == UiMode::Sketch) { - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Sketch kept — Finish to commit it, Cancel to discard")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("Sketch kept — Finish to commit it, Cancel to discard")); } return; } + } +} + +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; + return redo ? m_doc.can_redo() : m_doc.can_undo(); } void DesignPanel::update_undo_redo_buttons() { - // Grey Undo/Redo to mirror exactly what do_undo_redo will do: it acts only in Feature - // mode with no tool/dialog open (otherwise Esc is the way out), so reflect that gate here - // as well as the document's available history. - if (m_btn_undo == nullptr || m_btn_redo == nullptr) return; - const bool gated = (m_ui_mode != UiMode::Feature) || (m_active != Tool::None); - m_btn_undo->Enable(!gated && m_doc.can_undo()); - m_btn_redo->Enable(!gated && m_doc.can_redo()); + // The tab has no Undo/Redo of its own: the app's (the top bar, Ctrl+Z, Edit) drive this + // history while the tab is shown, greyed to exactly what do_undo_redo will do. + if (MainFrame* frame = wxGetApp().mainframe; frame != nullptr && IsShownOnScreen()) + frame->set_undo_redo_enabled(menu_can_undo_redo(false), menu_can_undo_redo(true)); } void DesignPanel::update_action_bar() { update_undo_redo_buttons(); // mode/tool changes flip the do_undo_redo gate -> refresh greying + update_confirm_button(); // ...and the ✓'s greying if (m_tb_action == nullptr || m_toolbar == nullptr) return; wxSizer* s = m_toolbar->GetSizer(); if (s == nullptr) return; @@ -11530,32 +11931,33 @@ void DesignPanel::update_action_bar() void DesignPanel::do_undo_redo(bool redo) { + // Inside a live sketch, history is the sketch's own: the last drawn shape comes off, and + // comes back. Keys and buttons both land here, so they cannot disagree. + if (m_ui_mode == UiMode::Sketch && m_viewport && m_viewport->is_sketching()) { + const bool ok = redo ? m_viewport->redo_last_sketch_entity() : m_viewport->undo_last_sketch_entity(); + if (!ok) set_status(redo ? _L("Nothing to redo") : _L("Nothing to undo")); + update_undo_redo_buttons(); + return; + } // 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) { - m_status->SetForegroundColour(wxNullColour); - set_status(_L("Finish or cancel the current tool first (Esc)")); - m_status->Refresh(); + set_status(StatusKind::Info, _L("Finish or cancel the current tool first (Esc)")); return; } const bool ok = redo ? m_doc.redo() : m_doc.undo(); if (!ok) { - m_status->SetForegroundColour(wxNullColour); - set_status(redo ? _L("Nothing to redo") : _L("Nothing to undo")); - m_status->Refresh(); + set_status(StatusKind::Info, redo ? _L("Nothing to redo") : _L("Nothing to undo")); return; } - // The solid whole/face/edge pick and any in-place edit reference ids that recompute() - // invalidates — drop them before refreshing from the restored document. - m_sel_solid_body = m_sel_solid_face = m_sel_solid_edge = -1; - m_pick_face = m_pick_face_body = -1; // recompute() invalidated the face ids too + // The picks and any in-place edit reference ids that recompute() invalidates — drop them + // before refreshing from the restored document. + drop_selection(); reset_edit_state(); after_tree_edit(true); // refresh tree + viewport meshes + status from the restored doc - m_status->SetForegroundColour(wxNullColour); - set_status(wxString::Format(redo ? _L("Redo (%zu more)") : _L("Undo (%zu more)"), + set_status(StatusKind::Info, wxString::Format(redo ? _L("Redo (%zu more)") : _L("Undo (%zu more)"), redo ? m_doc.redo_depth() : m_doc.undo_depth())); - m_status->Refresh(); } // --- Document variables panel --------------------------------------------------------- @@ -11572,21 +11974,78 @@ void DesignPanel::refresh_variables() } } +// A design variable's name and expression, in Orca's dialog style: one dialog for both fields +// rather than two bare text prompts. Editing an existing variable keeps its name fixed. +class DesignVariableDialog : public DPIDialog +{ +public: + DesignVariableDialog(wxWindow* parent, const wxString& title, const wxString& name, + const wxString& expr, bool name_editable) + : DPIDialog(parent, wxID_ANY, title, wxDefaultPosition, wxDefaultSize, wxCAPTION | wxCLOSE_BOX) + { + SetBackgroundColour(*wxWHITE); + SetFont(Label::Body_14); + auto* grid = new wxFlexGridSizer(2, FromDIP(8), FromDIP(12)); + grid->AddGrowableCol(1); + auto field = [this, grid](const wxString& label, const wxString& value) { + grid->Add(new wxStaticText(this, wxID_ANY, label), 0, wxALIGN_CENTER_VERTICAL); + auto* in = new ::TextInput(this, value, "", "", wxDefaultPosition, wxSize(FromDIP(240), -1), + wxTE_PROCESS_ENTER); + in->Bind(wxEVT_TEXT_ENTER, [this](wxCommandEvent&) { EndModal(wxID_OK); }); + grid->Add(in, 1, wxEXPAND); + return in; + }; + m_name = field(_L("Name"), name); + m_expr = field(_L("Expression"), expr); + m_name->Enable(name_editable); + + m_buttons = new DialogButtons(this, {"OK", "Cancel"}); + m_buttons->GetOK()->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { EndModal(wxID_OK); }); + m_buttons->GetCANCEL()->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { EndModal(wxID_CANCEL); }); + + auto* sizer = new wxBoxSizer(wxVERTICAL); + sizer->Add(grid, 1, wxEXPAND | wxALL, FromDIP(16)); + sizer->Add(m_buttons, 0, wxEXPAND); + SetSizerAndFit(sizer); + CenterOnParent(); + (name_editable ? m_name : m_expr)->GetTextCtrl()->SetFocus(); + wxGetApp().UpdateDlgDarkUI(this); + } + + wxString name() const { return trimmed(m_name); } + wxString expression() const { return trimmed(m_expr); } + +protected: + void on_dpi_changed(const wxRect&) override + { + m_name->Rescale(); + m_expr->Rescale(); + GetSizer()->SetSizeHints(this); + Refresh(); + } + +private: + static wxString trimmed(const ::TextInput* in) + { + wxString v = in->GetTextCtrl()->GetValue(); + return v.Trim(true).Trim(false); + } + ::TextInput* m_name{nullptr}; + ::TextInput* m_expr{nullptr}; + DialogButtons* m_buttons{nullptr}; +}; + void DesignPanel::on_add_variable() { - wxString name = ::wxGetTextFromUser(_L("Variable name:"), _L("Add Variable"), "", this); - if (name.IsEmpty()) return; - name.Trim(true).Trim(false); + DesignVariableDialog dlg(this, _L("Add Variable"), "", "0", true); + if (dlg.ShowModal() != wxID_OK) return; + const wxString name = dlg.name(); + const wxString expr = dlg.expression(); + if (name.IsEmpty() || expr.IsEmpty()) return; if (name.Contains(' ')) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("Variable name must not contain spaces")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("Variable name must not contain spaces")); return; } - wxString expr = ::wxGetTextFromUser( - wxString::Format(_L("Expression for '%s':"), name), - _L("Add Variable"), "0", this); - if (expr.IsEmpty()) return; const std::string name_str = name.ToUTF8().data(); const std::string expr_str = expr.ToUTF8().data(); @@ -11612,9 +12071,10 @@ void DesignPanel::on_edit_variable() } const std::string name_str = m_var_list->GetItemText(sel, 0).ToUTF8().data(); const std::string old_expr = m_var_list->GetItemText(sel, 1).ToUTF8().data(); - wxString expr = ::wxGetTextFromUser( - wxString::Format(_L("Expression for '%s':"), m_var_list->GetItemText(sel, 0)), - _L("Edit Variable"), wxString::FromUTF8(old_expr), this); + DesignVariableDialog dlg(this, _L("Edit Variable"), m_var_list->GetItemText(sel, 0), + wxString::FromUTF8(old_expr), false); + if (dlg.ShowModal() != wxID_OK) return; + const wxString expr = dlg.expression(); if (expr.IsEmpty()) return; const std::string expr_str = expr.ToUTF8().data(); @@ -11670,7 +12130,7 @@ std::vector DesignPanel::fields_for_tool(Tool t) case T::Extrude: return {"distance", "distance2", "taper_deg"}; case T::Dressup: return {"dressup_size"}; case T::Hole: return {"hole_diameter", "hole_depth", "hole_x", "hole_y"}; - case T::Thread: return {"thread_radius", "thread_pitch", "thread_height", "thread_depth", "thread_x", "thread_y"}; + case T::Thread: return {"thread_diameter", "thread_pitch", "thread_height", "thread_depth", "thread_x", "thread_y"}; case T::Shell: return {"shell_thickness"}; case T::Revolve: return {"revolve_angle"}; case T::Sweep: return {}; @@ -11723,6 +12183,12 @@ void DesignPanel::on_set_expr() const std::string field = fwx.ToUTF8().data(); const std::string expr = ewx.ToUTF8().data(); + // The picker is editable; a name the evaluator does not know would make every later + // recompute fail, so it is refused here with the reason. + if (!CadDocument::is_bindable_field(field)) { + set_status(StatusKind::Error, wxString::Format(_L("\"%s\" is not a value an expression can drive"), fwx)); + return; + } m_doc.checkpoint(); m_doc.features[m_edit_index].expr[field] = expr; @@ -11762,9 +12228,7 @@ void DesignPanel::on_clear_expr() const std::string field = fwx.ToUTF8().data(); auto& feat_expr = m_doc.features[m_edit_index].expr; if (feat_expr.find(field) == feat_expr.end()) { - m_status->SetForegroundColour(wxColour(235, 110, 110)); - set_status(_L("No binding for that field")); - m_status->Refresh(); + set_status(StatusKind::Error, _L("No binding for that field")); return; } diff --git a/src/slic3r/GUI/CAD/DesignPanel.hpp b/src/slic3r/GUI/CAD/DesignPanel.hpp index 5197ea4bbf..5e39362fad 100644 --- a/src/slic3r/GUI/CAD/DesignPanel.hpp +++ b/src/slic3r/GUI/CAD/DesignPanel.hpp @@ -1,7 +1,9 @@ #ifndef slic3r_DesignPanel_hpp_ #define slic3r_DesignPanel_hpp_ +#include #include +#include #include "libslic3r/Point.hpp" #include "libslic3r/CAD/SketchEngine.hpp" #include "libslic3r/TriangleMesh.hpp" @@ -12,7 +14,6 @@ #include #include #include -#include // wxTreeItemId #include #include @@ -21,6 +22,7 @@ #include "libslic3r/CAD/CadDocument.hpp" #include "slic3r/GUI/CAD/DesignInteraction.hpp" // CadLevel: what one Esc press means +#include "slic3r/GUI/AuiMgr.hpp" #include "slic3r/GUI/Lazy.hpp" class ComboBox; // Orca dropdown (Widgets/ComboBox.hpp) — replaces wxChoice everywhere here @@ -29,12 +31,11 @@ class wxCheckBox; class wxCheckListBox; class wxSpinCtrl; class wxSpinCtrlDouble; -class wxTreeCtrl; -class wxImageList; class wxStaticText; class wxStaticLine; class Button; // Orca-styled button (Widgets/Button.hpp) class CheckBox; // Orca teal checkbox (Widgets/CheckBox.hpp) +class DropDown; // Orca themed popup list (Widgets/DropDown.hpp) class wxSizer; // wxBoxSizer, wxTextCtrl and wxListCtrl are used here as pointers only, so a forward // declaration is enough — but they must be declared. Every ordinary build happened to pull @@ -45,12 +46,15 @@ class wxSizer; class wxBoxSizer; class wxTextCtrl; class wxListCtrl; -class wxButton; +class Button; class wxPanel; class ScalableButton; namespace Slic3r { namespace GUI { +class DesignTextDialog; +class DesignRowList; + class DesignCanvas; // Design (CAD) tab: a sketch-first, Onshape-style form-driven CAD panel. @@ -62,8 +66,14 @@ public: explicit DesignPanel(wxWindow* parent); void on_tab_shown(); // re-sync bed to the active printer when the Design tab is activated void on_tab_hidden(); // another tab took over: take the viewport status line down with us + // From MainFrame, like the other pages: re-rasterise the icons at the new scale, and on a theme + // switch also move every token colour onto the new theme. + void msw_rescale(); + void on_sys_color_changed(); void unbind_canvas_event_handlers(); // app close / language switch, from the plater's teardown void reset_canvas_volumes(); + void shutdown(); // app close / language switch, from MainFrame: save the window layout + void reset_window_layout(); // View > Reset Window Layout, with Prepare's void clear_document(); // New Project / Open Project: drop the document with the project // Rebuild off the UI thread (progress dialog only if it turns out to be slow), so a feature // op on a heavy imported solid does not freeze the window. Returns m_doc.recompute()'s result. @@ -96,6 +106,13 @@ public: // Defined out of line in DesignPanel.cpp: it needs kOfferVerbs, which this header deliberately // does not include (the table is generated and belongs to the offer-menu code). bool mcp_run_verb(const char* verb_id); + // Would a request from the control socket collide with what the GUI is doing — a rebuild + // in progress, an open feature card, a sketch session? `sketch_method` = it drives the live + // sketch rather than the feature list. Fills `why` when it would. + bool mcp_busy(bool sketch_method, std::string& why) const; + // Load the project's recipe into an empty document, as showing the tab does. The control + // socket may be the first thing to touch the Design tab after a project was opened. + void hydrate_from_model(); private: enum class Tool { None, Sketch, Extrude, Dressup, Hole, Thread, Shell, Revolve, Sweep, Pattern, Plane, Loft, Draft, Boolean, Cut, Insert, Axis, CoordSys, SurfaceExtrude, SurfaceRevolve, SurfaceLoft, SurfaceFill, SurfaceOffset, ThickenSurface, Transform, Mirror, Thicken, Rib, Project, DeleteFace, Helix, Mate }; @@ -124,17 +141,20 @@ private: // used to be handled in four places that could not see each other, and that is how two // presses in a row reached past a tool and discarded the sketch under it. CadLevel escape_level() const; + bool confirm_enabled() const; // would the ✓ act right now? (its greying follows this) void escape(); void update_action_bar(); // show the ✓/✗ bar iff a tool or mode is active + void update_confirm_button(); // grey the ✓ to confirm_enabled() void on_shape_changed(); void on_add_sketch(); + std::string feature_name(const wxString& kind) const; void on_add_extrude(); void on_add_dressup(); void on_add_hole(); void on_add_thread(); void apply_thread_standard(); // fill pitch/depth/radius from m_thread_std selection - void infer_thread_spec(double diameter); // nearest M-standard from a picked cylinder diameter + void infer_thread_spec(double diameter, bool internal); // nearest standard from a picked cylinder void on_add_revolve(); void on_add_sweep(); void on_add_loft(); @@ -199,6 +219,11 @@ private: // imported_regions (no solver entities). on_add_text/on_import_svg gather // input; add_imported_sketch builds the feature, refreshes tree + display. void on_add_text(); + // Text is its own feature ("Text N"), drawn in the canvas while its (modeless) dialog is + // open. feat < 0 starts a new one; otherwise the text feature `feat` is reopened for editing. + void open_text_dialog(int feat); + void text_dialog_changed(); + void text_dialog_done(bool accepted); void on_import_svg(); void on_import_step(); // STEP -> editable B-rep body (keeps the OCCT solid, not a mesh) void on_import_mesh(); // STL/OBJ -> B-rep body via GeometryEngine::mesh_to_brep @@ -215,6 +240,11 @@ private: // a modal dialog editing the feature's placement transform in place. void on_transform_imported(int feat_idx); void on_commit(); + // What Commit to Plate sends: one object with a part per body (an assembly, which keeps the + // bodies' relative placement), or, "as bodies", one Prepare object per body. The split + // button's dropdown switches it, like Prepare's Slice button, without committing. + enum class CommitMode { Assembly, Bodies }; + void set_commit_mode(CommitMode mode); void on_export_step(); // write all bodies to a .step file (native B-rep) // Rehydrate the parametric model from a project's saved recipe (3MF // Metadata/orca_cad.bin): deserialize -> recompute -> refresh viewport + tree. @@ -271,6 +301,9 @@ private: void after_edit_op(); // shared edit-op refresh tail void on_edit_feature(); // reopen the selected feature's dialog populated void after_tree_edit(bool ok); // shared post-op refresh of tree/viewport/status + void drop_selection(); // forget the selection (solid, hit face, sketch loop): the feature list was replaced or renumbered + void drop_solid_pick(); // the solid and hit-face part of drop_selection + bool begin_renumber(); // before delete/reorder: refuse while an index is held, else close the card, checkpoint, drop picks void load_feature_into_dialog(const CadFeature& f); void reset_edit_state(); // back to add-mode (m_edit_index = -1) @@ -284,6 +317,14 @@ private: // Ctrl+Z / Ctrl+Shift+Z (Ctrl+Y) from the viewport. With a tool/dialog open it // cancels that (Esc-like); otherwise it undoes/redoes the committed feature history. void do_undo_redo(bool redo); +public: + // Edit > Undo / Redo while this tab is shown: the same route and the same gate as the keys + // and the toolbar buttons. + void menu_undo_redo(bool redo) { do_undo_redo(redo); } + bool menu_can_undo_redo(bool redo) const; + // Grey the app's Undo/Redo to this tab's history and gate (shown tab only). + void update_undo_redo_buttons(); +private: // The plane the Hole tool drills on: a picked face (inward, centred) or the dropdown. SketchPlane hole_plane() const; // The plane the Thread tool builds on: a picked cylindrical face (axis) or the dropdown. @@ -323,6 +364,7 @@ private: void update_extrude_gizmo(); void update_fillet_gizmo(); // edge-anchored radius arrow (Dressup card) void sync_dressup_target(); // Dressup card: show picked edge vs group, gate the combo + std::vector dressup_edges() const; // the picked edge(s) a dress-up targets; empty = face group void update_hole_gizmo(); // footprint circle + diameter/depth arrows (Hole card) // A FEATURE button whose tool needs bodies it may not have yet. Greyed with an explanatory // tooltip below min_bodies, rather than accepting the click and refusing afterwards. @@ -366,7 +408,10 @@ private: void show_move_card(bool show); void apply_move_card(); // numeric move/rotate -> same xform the gizmo builds void push_polygon_params(); - wxSizer* m_tb_commit{nullptr}; // far-right Commit to Plate, beside Confirm/Cancel + wxSizer* m_tb_commit{nullptr}; // far-right Commit to Plate split button, beside Confirm/Cancel + CommitMode m_commit_mode{CommitMode::Assembly}; + 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) @@ -403,6 +448,15 @@ private: wxSizer* m_box_insert{nullptr}; // Confirm/Cancel card for placing Text/SVG art wxSizer* m_box_expr{nullptr}; // expression binding card (visible during edit only) int m_insert_feat{-1}; // provisional imported-art feature awaiting Confirm + // The open Text dialog, the feature it draws into (-1 until there is text to draw) and, + // for a new text, where it goes: plane, offset in that plane, and the body of the face it + // sits on (-1 = not on a face). + DesignTextDialog* m_text_dlg{nullptr}; + int m_text_feat{-1}; + bool m_text_editing{false}; + SketchPlane m_text_plane; + Vec2d m_text_offset{0, 0}; + int m_text_face_body{-1}; // Move-body gizmo runs through the unified action bar too: Confirm keeps the placement, // Cancel reverts to the pose captured when the move started. int m_move_body{-1}; @@ -459,6 +513,18 @@ private: wxScrolledWindow* m_form{nullptr}; DesignCanvas* m_viewport{nullptr}; + // Below the toolbar, m_form docks beside the viewport column like Prepare's sidebar: it can + // move to the other side, float, be resized and be collapsed, and the layout is kept between + // sessions. + AuiMgr m_aui; + wxString m_default_layout; + bool m_sidebar_collapsed{false}; + bool m_laid_out{false}; // the tab has been shown, so the panel has its size + void load_window_layout(); + void load_default_layout(); + void collapse_sidebar(bool collapse); + void update_sidebar_pane(bool force_update = false); + // Top contextual toolbar (parented to the panel, above the form/viewport row). UiMode m_ui_mode{UiMode::Feature}; // Sketch environment banner: a strip across the top of the viewport saying, in words, that @@ -477,19 +543,16 @@ private: // Unified Confirm/Cancel action bar (right end of the ribbon). Shown whenever any // tool or mode is active; the single confirm/cancel surface for the whole tab. wxSizer* m_tb_action{nullptr}; - // Persistent Undo/Redo group at the left of the ribbon — always visible, independent - // of the mode-gated tool groups. The buttons are greyed per the document history and - // the do_undo_redo gate (see update_undo_redo_buttons). - wxSizer* m_tb_history{nullptr}; - ScalableButton* m_btn_undo{nullptr}; - ScalableButton* m_btn_redo{nullptr}; - void update_undo_redo_buttons(); // enable/disable Undo/Redo from can_undo/can_redo + gate // All tool buttons, for the active-tool teal highlight (Onshape-style). std::vector m_tool_btns; ScalableButton* m_active_tool_btn{nullptr}; void set_active_tool_btn(ScalableButton* b); // nullptr clears the highlight // Owns the themed DropDown flyouts (and the item vectors they hold by ref). std::vector> m_flyout_keepalive; + // Icons that are not a plain ScalableButton face (flyout rows, card headers, theme-twinned + // buttons): each re-creates its bitmaps for the current scale and theme. + std::vector> m_icon_refresh; + void refresh_icons(); wxCheckBox* m_construction{nullptr}; // sketch-mode construction toggle wxSpinCtrlDouble* m_move_dx{nullptr}; // Move/Rotate card: world translation wxSpinCtrlDouble* m_move_dy{nullptr}; @@ -523,11 +586,13 @@ private: wxStaticText* m_extrude_sketch_label{nullptr}; int m_extrude_sketch_ref{-1}; + int m_extrude_auto_body{-1}; // body the profile touches: the inferred Join target // Revolve controls (sweep a sketch profile about an in-plane axis). wxStaticText* m_revolve_sketch_label{nullptr}; wxSpinCtrlDouble* m_revolve_angle{nullptr}; - ComboBox* m_revolve_axis{nullptr}; // 0 = plane X, 1 = plane Y + ComboBox* m_revolve_axis{nullptr}; // Plane X, Plane Y, then the sketch's lines + std::vector m_revolve_axis_ents; // entity index of each line entry, in order ComboBox* m_revolve_mode{nullptr}; // New/Add/Cut/Intersect CheckBox* m_revolve_flip{nullptr}; int m_revolve_sketch_ref{-1}; @@ -554,7 +619,14 @@ private: // Surface Revolve controls (sheet from sketch about axis). wxStaticText* m_surf_revolve_sketch_label{nullptr}; wxSpinCtrlDouble* m_surf_revolve_angle{nullptr}; - ComboBox* m_surf_revolve_axis{nullptr}; // 0 = plane X, 1 = plane Y + ComboBox* m_surf_revolve_axis{nullptr}; // as m_revolve_axis + std::vector m_surf_revolve_axis_ents; + // Fill a revolve axis combo for the profile sketch `sketch_ref`: "Plane X", "Plane Y", then + // one entry per Line of the sketch (entity index in `ents`), and select axis/entity. A fresh + // revolve passes entity = -2: the sketch's only centerline when it has exactly one, else X. + void fill_revolve_axes(ComboBox* combo, std::vector& ents, int sketch_ref, int axis, int entity); + // The combo's selection as CadFeature::revolve_axis + revolve_axis_entity. + static void read_revolve_axis(ComboBox* combo, const std::vector& ents, int& axis, int& entity); CheckBox* m_surf_revolve_flip{nullptr}; int m_surf_revolve_sketch_ref{-1}; @@ -614,7 +686,7 @@ private: // Delete Face controls (remove faces, heal the solid). ComboBox* m_del_face_body{nullptr}; // target body - wxButton* m_del_face_add_btn{nullptr}; // "Add picked face" button + ::Button* m_del_face_add_btn{nullptr}; // "Add picked face" button wxStaticText* m_del_face_list{nullptr}; // shows the accumulated face ids std::vector m_del_faces; // accumulated face list @@ -639,8 +711,8 @@ private: // Expression binding (per-feature, visible during edit only) ComboBox* m_expr_field{nullptr}; // field-name picker (editable) wxTextCtrl* m_expr_text{nullptr}; // expression string - wxButton* m_expr_set_btn{nullptr}; // Apply / bind - wxButton* m_expr_clear_btn{nullptr}; // Remove binding + ::Button* m_expr_set_btn{nullptr}; // Apply / bind + ::Button* m_expr_clear_btn{nullptr}; // Remove binding wxStaticText* m_expr_status{nullptr}; // shows current bindings for the edited feature void populate_expr_fields(Tool t); // fill m_expr_field from feature-type fields void on_set_expr(); // checkpoint + write -> recompute -> undo on fail @@ -649,16 +721,16 @@ private: // Document variables panel (below the feature tree / parts) StaticBox* m_var_box{nullptr}; wxListCtrl* m_var_list{nullptr}; - wxButton* m_btn_add_var{nullptr}; - wxButton* m_btn_edit_var{nullptr}; - wxButton* m_btn_del_var{nullptr}; + ::Button* m_btn_add_var{nullptr}; + ::Button* m_btn_edit_var{nullptr}; + ::Button* m_btn_del_var{nullptr}; void refresh_variables(); // rebuild m_var_list from m_doc.variables void on_add_variable(); void on_edit_variable(); void on_remove_variable(); // Feature-tree button - ScalableButton* m_btn_interfere{nullptr}; + ::Button* m_btn_interfere{nullptr}; // Pattern controls (replicate the target body: linear or circular). ComboBox* m_pattern_type{nullptr}; // 0 = Linear, 1 = Circular @@ -689,10 +761,10 @@ private: ComboBox* m_plane_tilt_axis{nullptr}; // 0 = base X, 1 = base Y // Plane construction method + contextual face/edge reference picks (Onshape/Fusion parity). ComboBox* m_plane_type{nullptr}; // PlaneType: Offset/Angle/Midplane/Tangent/TwoEdges/Coincident - wxButton* m_plane_pick_faceA{nullptr}; wxStaticText* m_plane_faceA_lbl{nullptr}; - wxButton* m_plane_pick_faceB{nullptr}; wxStaticText* m_plane_faceB_lbl{nullptr}; - wxButton* m_plane_pick_edgeA{nullptr}; wxStaticText* m_plane_edgeA_lbl{nullptr}; - wxButton* m_plane_pick_edgeB{nullptr}; wxStaticText* m_plane_edgeB_lbl{nullptr}; + ::Button* m_plane_pick_faceA{nullptr}; wxStaticText* m_plane_faceA_lbl{nullptr}; + ::Button* m_plane_pick_faceB{nullptr}; wxStaticText* m_plane_faceB_lbl{nullptr}; + ::Button* m_plane_pick_edgeA{nullptr}; wxStaticText* m_plane_edgeA_lbl{nullptr}; + ::Button* m_plane_pick_edgeB{nullptr}; wxStaticText* m_plane_edgeB_lbl{nullptr}; wxSpinCtrlDouble* m_plane_usize{nullptr}; // datum rectangle extent u (mm) — also driven by drag handles wxSpinCtrlDouble* m_plane_vsize{nullptr}; // datum rectangle extent v (mm) // Captured references for the candidate datum (body index + face/edge index, -1 = none). @@ -701,14 +773,14 @@ private: int m_pl_edgeA_body{-1}, m_pl_edgeA{-1}; int m_pl_edgeB_body{-1}, m_pl_edgeB{-1}; PlanePick m_plane_pick{PlanePick::None}; // which ref the next solid pick fills - // Plate loop selection (click a committed sketch loop): the Sketch feature + the - // clicked closed-region index, so Extrude builds just that one loop. -1 = none. - int m_sel_sketch_feat{-1}; - int m_sel_sketch_region{-1}; // Click-selected solid topology (whole/face/edge cycle): face id for up-to-face / dress-up. int m_sel_solid_body{-1}; // which body the face/edge selection is on int m_sel_solid_face{-1}; int m_sel_solid_edge{-1}; + // The picked edge set (Shift/Ctrl+click), m_sel_solid_edge last. Read through dressup_edges(), + // which drops it once m_sel_solid_edge moves on — the many places that reset the single edge + // then need not know the set exists. + std::vector m_sel_solid_edges; bool m_sel_solid_vertex{false}; // a corner is picked (body+point, no face/edge) // The face actually under the last solid click, INDEPENDENT of the whole/face/edge cycle level. // The first click on a solid selects the WHOLE body, but the ray has already resolved which face @@ -727,8 +799,13 @@ private: std::map> m_verb_actions; // Append an offer row with its toolbar glyph. The bitmap must be set BEFORE Append — // wxGTK builds the GtkMenuItem there and only makes an image item if one is present. - // Every status write goes through here so long hints wrap instead of clipping. - void set_status(const wxString& text); + // Every status write goes through here so long hints wrap instead of clipping. The kind + // sets the colour AND a leading glyph, so a message's meaning never rests on colour alone + // (charter 6.2), and every message sets its own kind instead of inheriting the last one's. + enum class StatusKind { Info, Ok, Warning, Error }; + void set_status(StatusKind kind, const wxString& text); + void set_status(const wxString& text) { set_status(StatusKind::Info, text); } + static wxString kernel_error_text(const std::string& err); // a kernel message, in words a modeller reads wxString idle_hint() const; // what to say when nothing is selected // Reason detect_mate_conflicts() recorded for a feature, or nullptr. Marks the tree row and // feeds the status line; a conflict is a diagnostic, not a document error. @@ -743,6 +820,7 @@ private: // monitor, and the menu would map there — detached from the geometry it is about. wxPoint offer_anchor() const; int offer_selection_kind() const; // an OfferSel, as int to keep the header light + wxString offer_header(int kind) const; // the offer menu's title line for that selection // Does the SKETCH half of the map apply? A mode question, not a session one: begin_sketch // does not run until the first tool is armed, so between "press Sketch" and "pick a tool" // is_sketching() is still false — precisely when the drawing tools must be on offer. The @@ -805,8 +883,8 @@ private: // Axis controls (datum axis: line through two points or derived from geometry). ComboBox* m_axis_type{nullptr}; // AxisType: TwoPoints/FaceNormal/CylinderCenterline/PlaneIntersection/AlongEdge - wxButton* m_axis_pick_face{nullptr}; wxStaticText* m_axis_face_lbl{nullptr}; - wxButton* m_axis_pick_edge{nullptr}; wxStaticText* m_axis_edge_lbl{nullptr}; + ::Button* m_axis_pick_face{nullptr}; wxStaticText* m_axis_face_lbl{nullptr}; + ::Button* m_axis_pick_edge{nullptr}; wxStaticText* m_axis_edge_lbl{nullptr}; ComboBox* m_axis_plane_a{nullptr}; ComboBox* m_axis_plane_b{nullptr}; wxSpinCtrlDouble* m_axis_p1x{nullptr}; wxSpinCtrlDouble* m_axis_p1y{nullptr}; wxSpinCtrlDouble* m_axis_p1z{nullptr}; @@ -819,8 +897,8 @@ private: ComboBox* m_coordsys_type{nullptr}; // CoordSysType: PointWorld/FaceAndDirection ComboBox* m_cs_body{nullptr}; // body-focus chooser: restrict picking to one body wxSpinCtrlDouble* m_cs_x{nullptr}; wxSpinCtrlDouble* m_cs_y{nullptr}; wxSpinCtrlDouble* m_cs_z{nullptr}; - wxButton* m_cs_pick_face{nullptr}; wxStaticText* m_cs_face_lbl{nullptr}; - wxButton* m_cs_pick_edge{nullptr}; wxStaticText* m_cs_edge_lbl{nullptr}; + ::Button* m_cs_pick_face{nullptr}; wxStaticText* m_cs_face_lbl{nullptr}; + ::Button* m_cs_pick_edge{nullptr}; wxStaticText* m_cs_edge_lbl{nullptr}; wxSpinCtrlDouble* m_cs_hx{nullptr}; wxSpinCtrlDouble* m_cs_hy{nullptr}; wxSpinCtrlDouble* m_cs_hz{nullptr}; int m_cs_face_body{-1}, m_cs_face{-1}; int m_cs_edge_body{-1}, m_cs_edge{-1}; @@ -835,21 +913,29 @@ 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: a wxTreeCtrl with per-feature-type icons. Callers keep using - // integer row indices via tree_selection()/set_tree_selection(); m_tree_items - // maps feature order -> tree node, rebuilt by refresh_tree(). - wxTreeCtrl* m_tree{nullptr}; - wxTreeCtrl* m_parts{nullptr}; // Bodies list under the feature tree + // 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. + 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. + // 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... + uint64_t m_hl_generation{0}; // ...on this topology + std::vector> m_hl_faces; + bool m_hl_pending{false}; + void request_feature_highlight(); + void update_feature_highlight(); + bool deselect_rows(); // Esc / a click on nothing: drop the tree and Bodies rows + // 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}; wxStaticText* m_parts_label{nullptr}; // its "Bodies" caption (hidden when empty) - wxBoxSizer* m_parts_hdr{nullptr}; // Bodies card header (icon + title) + wxBoxSizer* m_parts_hdr{nullptr}; // Bodies card header (icon + title + body actions) wxStaticLine* m_parts_rule{nullptr}; // rule under that header - wxBoxSizer* m_hdr_tree_row{nullptr}; // Feature tree header: title + row actions + wxBoxSizer* m_hdr_tree_row{nullptr}; // Feature tree header: title + list actions wxStaticText* m_hdr_tree{nullptr}; // its title label - wxImageList* m_tree_images{nullptr}; - std::vector m_tree_items; - // Parts list: tree rows for each body (parallel to m_doc.bodies). Selecting one - // highlights that body and makes it the target for the next op. - std::vector m_tree_body_items; // Section views (non-destructive): named "Section View N" entries listed in the tree, each a // horizontal clip height. View-only — NOT bodies/features, never serialized. Key X adds one; @@ -884,15 +970,13 @@ private: int tree_selection() const; // selected feature row, or wxNOT_FOUND int tree_body_selection() const; // selected Parts-list body index, or -1 void refresh_parts(); // rebuild the Bodies list under the feature tree - void sync_sidebar_width(); // keep the panel as wide as Prepare's sidebar void set_tree_selection(int row); - static int tree_icon_for(CadFeatureType t); + static const char* tree_icon_for(CadFeatureType t); wxStaticText* m_status{nullptr}; // m_status's foreground as created, captured before any caller touches it. Callers signal // "no opinion" by setting wxNullColour, which restores exactly this — so it is the only // reliable way to tell a chosen colour (the error red) from the default. See set_status(). - wxColour m_status_default_fg; // The guidance sentence for the step the armed sketch tool is on, kept so a transient // readout (the live length/angle while a segment is being dragged) can be appended to it // instead of replacing it — the guidance used to vanish on the first mouse move after a @@ -908,7 +992,10 @@ private: bool m_dof_last_has{false}; int m_feature_counter{0}; - std::vector m_confirm_btns; + std::vector<::Button*> m_confirm_btns; + // refresh_preview's verdict on the open card's candidate (open_tool resets it). Kept here, not + // read back from the ✓, which every card and mode shares; the ✓ is greyed from confirm_enabled(). + bool m_candidate_ok{true}; // Edit-in-place state: add-mode is m_edit_index == -1. Single-feature edit // (Sketch or Extrude independently) uses only m_edit_index as the row to replace. diff --git a/src/slic3r/GUI/CAD/DesignRowList.cpp b/src/slic3r/GUI/CAD/DesignRowList.cpp new file mode 100644 index 0000000000..facf0aa5ed --- /dev/null +++ b/src/slic3r/GUI/CAD/DesignRowList.cpp @@ -0,0 +1,387 @@ +#include "slic3r/GUI/CAD/DesignRowList.hpp" + +#include "slic3r/GUI/Widgets/StateColor.hpp" +#include "slic3r/GUI/wxExtensions.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace Slic3r { namespace GUI { + +// Row and icon cell geometry, in DIP. +static constexpr int kIconPx = 16; // type and action icons +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) + , m_max_visible(std::max(max_visible, 1)) +{ + Bind(wxEVT_LISTBOX, [this](wxCommandEvent&) { if (on_select) on_select(); }); + Bind(wxEVT_LISTBOX_DCLICK, [this](wxCommandEvent&) { if (on_activate) on_activate(); }); + + Bind(wxEVT_MOTION, [this](wxMouseEvent& e) { + set_hover(hit_test(e.GetPosition())); + e.Skip(); + }); + Bind(wxEVT_LEAVE_WINDOW, [this](wxMouseEvent& e) { + set_hover(Hit{}); + m_pressed = Hit{}; + e.Skip(); + }); + // A press on an action cell is remembered and skipped, so the list still selects the row. + Bind(wxEVT_LEFT_DOWN, [this](wxMouseEvent& e) { + const Hit h = hit_test(e.GetPosition()); + m_pressed = h.cell >= 0 ? h : Hit{}; + 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; } + m_pressed = h; + }); + Bind(wxEVT_LEFT_UP, [this](wxMouseEvent& e) { + e.Skip(); + const Hit pressed = m_pressed; + m_pressed = Hit{}; + if (pressed.cell < 0 || hit_test(e.GetPosition()) != pressed) + return; + 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. + CallAfter([this, row = pressed.row, id] { + if (row < int(GetItemCount()) && row == GetSelection() && on_action) + on_action(row, id); + }); + }); + // Right-click targets the row under the pointer. Skipped, because on MSW a handled right + // 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); + e.Skip(); + }); + Bind(wxEVT_CONTEXT_MENU, [this](wxContextMenuEvent& e) { + wxPoint screen = e.GetPosition(); // screen coordinates; wxDefaultPosition from the keyboard + int row = GetSelection(); + if (screen == wxDefaultPosition) { + if (row == wxNOT_FOUND) return; + screen = ClientToScreen(GetItemRect(row).GetBottomLeft()); + } else { + row = VirtualHitTest(ScreenToClient(screen).y); + } + if (row == wxNOT_FOUND || row >= int(m_rows.size())) + 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 (on_menu) on_menu(row, screen); + }); + // wxVListBox takes every wheel event, even with nothing to scroll, so a short list would stop + // the sidebar it sits in from scrolling under the pointer. Hand it on instead. Not on GTK, + // where the sidebar scrolls natively and passes on an unhandled wheel by itself. + Bind(wxEVT_MOUSEWHEEL, [this](wxMouseEvent& e) { + end_rename(true); +#ifndef __WXGTK__ + if (GetVisibleRowsBegin() == 0 && GetVisibleRowsEnd() >= GetItemCount()) { + for (wxWindow* w = GetParent(); w != nullptr; w = w->GetParent()) + if (auto* sw = dynamic_cast(w)) { + wxMouseEvent fwd(e); + fwd.SetEventObject(sw); + sw->GetEventHandler()->ProcessEvent(fwd); + return; + } + } +#endif + e.Skip(); + }); + Bind(wxEVT_SIZE, [this](wxSizeEvent& e) { + end_rename(true); // the editor sits on a row position the new size may have moved + e.Skip(); + }); + measure_row(); + fit_rows(); +} + +// Every row is one height. Measured once per font and scale rather than on each OnMeasureItem, +// which wxVListBox calls for every row it walks on every paint and hit test. +void DesignRowList::measure_row() +{ + m_row_h = std::max(GetCharHeight() + 8, FromDIP(kCellDip + 2)); +} + +void DesignRowList::set_rows(std::vector rows) +{ + end_rename(false); // the row it names may no longer be that row + SetSelection(wxNOT_FOUND); + m_rows = std::move(rows); + m_hover = Hit{}; + m_pressed = Hit{}; + UnsetToolTip(); + load_icons(); + SetItemCount(m_rows.size()); + fit_rows(); + Refresh(); +} + +void DesignRowList::select(int row) +{ + if (row < 0 || row >= int(GetItemCount())) + row = wxNOT_FOUND; + if (row == GetSelection()) + return; + SetSelection(row); + if (on_select) on_select(); +} + +void DesignRowList::fit_rows() +{ + const int shown = std::clamp(int(GetItemCount()), 1, m_max_visible); + // Plus the frame, so the last row is not clipped by it. + const int h = shown * m_row_h + (GetSize().y - GetClientSize().y); + SetMinSize(wxSize(-1, h)); + SetMaxSize(wxSize(-1, h)); +} + +void DesignRowList::Rescale() +{ + m_icons.clear(); + load_icons(); + measure_row(); + // The scrollbar's total is only recomputed when the item count is set; RefreshAll() alone + // would leave it at the old scale. + SetItemCount(GetItemCount()); + fit_rows(); + Refresh(); +} + +void DesignRowList::begin_rename(int row) +{ + CallAfter([this, row] { open_editor(row); }); +} + +void DesignRowList::open_editor(int row) +{ + if (row < 0 || row >= int(m_rows.size())) + return; + end_rename(false); + if (m_editor == nullptr) { + // A raw wxTextCtrl, as wxTreeCtrl's own label editor is: it is a transient overlay on one + // row, not a form field, and ::TextInput's frame would not fit inside the row. + m_editor = new wxTextCtrl(this, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, + wxTE_PROCESS_ENTER | wxBORDER_SIMPLE); + m_editor->Hide(); + m_editor->Bind(wxEVT_TEXT_ENTER, [this](wxCommandEvent&) { + end_rename(true); + SetFocus(); + }); + // Esc must stop here. CHAR_HOOK reaches the focused editor before its ancestors, and the + // Design panel's own hook answers Esc whatever holds focus, so a skipped Esc would close + // the open tool as well as the editor. + m_editor->Bind(wxEVT_CHAR_HOOK, [this](wxKeyEvent& e) { + if (e.GetKeyCode() != WXK_ESCAPE) { e.Skip(); return; } + end_rename(false); + SetFocus(); + }); + m_editor->Bind(wxEVT_KILL_FOCUS, [this](wxFocusEvent& e) { + e.Skip(); + end_rename(true); + }); + } + if (!IsRowVisible(row)) + ScrollToRow(row); + const Row& r = m_rows[row]; + const wxRect rc = label_rect(GetItemRect(row), r); + m_editor->SetFont(GetFont()); + m_editor->SetBackgroundColour(GetBackgroundColour()); + m_editor->SetForegroundColour(r.colour.IsOk() ? r.colour : GetForegroundColour()); + m_editor->SetValue(r.edit_text.empty() ? r.label : r.edit_text); + const int h = std::max(rc.height, m_editor->GetBestSize().y); + const int y = std::clamp(rc.y + (rc.height - h) / 2, 0, std::max(0, GetClientSize().y - h)); + m_editor->SetSize(rc.x - FromDIP(2), y, rc.width + FromDIP(2), h); + m_edit_row = row; + m_editor->Show(); + m_editor->SetFocus(); + m_editor->SelectAll(); +} + +void DesignRowList::end_rename(bool commit) +{ + if (m_edit_row == wxNOT_FOUND) + return; + // Cleared first: hiding the focused editor moves the focus, whose kill-focus comes back here. + const int row = m_edit_row; + wxString text = m_editor->GetValue(); + m_edit_row = wxNOT_FOUND; + m_editor->Hide(); + text.Trim(true).Trim(false); + if (commit && on_rename && !text.empty()) // a nameless row is worse than a badly named one + CallAfter([this, row, text] { + if (on_rename && row < int(GetItemCount())) + on_rename(row, text); + }); +} + +wxCoord DesignRowList::OnMeasureItem(size_t) const +{ + return m_row_h; +} + +void DesignRowList::OnDrawBackground(wxDC& dc, const wxRect& rect, size_t n) const +{ + // ObjectList's selection teal, and the dropdowns' lighter hover teal; both are dark-mapped. + wxColour bg; + if (IsSelected(n)) + bg = StateColor::darkModeColorFor(wxColour("#BFE1DE")); + else if (int(n) == m_hover.row) + bg = StateColor::darkModeColorFor(wxColour("#E5F0EE")); + if (!bg.IsOk()) + return; // the list already cleared to its background colour + dc.SetPen(*wxTRANSPARENT_PEN); + dc.SetBrush(wxBrush(bg)); + dc.DrawRectangle(rect); +} + +void DesignRowList::OnDrawItem(wxDC& dc, const wxRect& rect, size_t n) const +{ + if (n >= m_rows.size()) + return; + const Row& r = m_rows[n]; + + if (const ScalableBitmap* bmp = icon(r.icon)) { + const wxSize sz = bmp->GetBmpSize(); + dc.DrawBitmap(bmp->bmp(), rect.x + FromDIP(kPadDip), rect.y + (rect.height - sz.y) / 2, true); + } + + const wxRect lr = label_rect(rect, r); + dc.SetFont(GetFont()); + dc.SetTextForeground(r.colour.IsOk() ? r.colour : GetForegroundColour()); + const wxString text = wxControl::Ellipsize(r.label, dc, wxELLIPSIZE_END, std::max(lr.width, 0)); + dc.DrawText(text, lr.x, lr.y + (lr.height - dc.GetCharHeight()) / 2); + + for (size_t i = 0; i < r.actions.size(); ++i) { + const wxRect cell = cell_rect(rect, r.actions.size(), i); + if (m_hover.row == int(n) && m_hover.cell == int(i)) { + // Drawn through a graphics context so the rounded corners are anti-aliased on MSW too, + // and released before the icon goes on top. + const wxColour chip = StateColor::darkModeColorFor(wxColour(hover_chip)); + const double rad = FromDIP(4); + std::unique_ptr gc(wxGraphicsContext::CreateFromUnknownDC(dc)); + if (gc) { + gc->SetPen(*wxTRANSPARENT_PEN); + gc->SetBrush(wxBrush(chip)); + gc->DrawRoundedRectangle(cell.x, cell.y, cell.width, cell.height, rad); + } else { + dc.SetPen(*wxTRANSPARENT_PEN); + dc.SetBrush(wxBrush(chip)); + dc.DrawRoundedRectangle(cell, rad); + } + } + if (const ScalableBitmap* bmp = icon(r.actions[i].icon)) { + const wxSize sz = bmp->GetBmpSize(); + dc.DrawBitmap(bmp->bmp(), cell.x + (cell.width - sz.x) / 2, cell.y + (cell.height - sz.y) / 2, true); + } + } +} + +DesignRowList::Hit DesignRowList::hit_test(const wxPoint& pt) const +{ + Hit h; + h.row = VirtualHitTest(pt.y); + if (h.row == wxNOT_FOUND || h.row >= int(m_rows.size())) + return Hit{}; + 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)) { + h.cell = int(i); + break; + } + return h; +} + +wxRect DesignRowList::cell_rect(const wxRect& row, size_t count, size_t i) const +{ + const int side = FromDIP(kCellDip); + const int x = row.GetRight() + 1 - FromDIP(kPadDip) - int(count - i) * side; + return wxRect(x, row.y + (row.height - side) / 2, side, side); +} + +wxRect DesignRowList::label_rect(const wxRect& row, const Row& r) const +{ + int left = row.x + FromDIP(kPadDip); + if (const ScalableBitmap* bmp = icon(r.icon)) + left += bmp->GetBmpSize().x + FromDIP(kGapDip); + const int right = r.actions.empty() ? row.GetRight() + 1 - FromDIP(kPadDip) + : cell_rect(row, r.actions.size(), 0).x - FromDIP(kPadDip); + return wxRect(left, row.y, std::max(right - left, 0), row.height); +} + +// nullptr for no icon: load_icons() never stores an empty name. +const ScalableBitmap* DesignRowList::icon(const std::string& name) const +{ + const auto it = m_icons.find(name); + return it == m_icons.end() ? nullptr : &it->second; +} + +void DesignRowList::load_icons() +{ + auto load = [this](const std::string& name) { + if (!name.empty() && m_icons.find(name) == m_icons.end()) + m_icons.emplace(name, ScalableBitmap(this, name, kIconPx)); + }; + for (const Row& r : m_rows) { + load(r.icon); + for (const Action& a : r.actions) + load(a.icon); + } +} + +void DesignRowList::set_hover(const Hit& h) +{ + if (h == m_hover) + return; + const int old = m_hover.row; + m_hover = h; + if (old != wxNOT_FOUND && old < int(GetItemCount())) + RefreshRow(old); + if (h.row != wxNOT_FOUND && h.row != old) + RefreshRow(h.row); + + // The tip names the icon under the pointer, or the whole label where the row cuts it short. + wxString tip; + if (h.row != wxNOT_FOUND) { + const Row& r = m_rows[h.row]; + if (h.cell >= 0) + tip = r.actions[h.cell].tip; + else if (GetTextExtent(r.label).x > label_rect(GetItemRect(h.row), r).width) + tip = r.label; + } + if (tip.empty()) + UnsetToolTip(); + else if (tip != GetToolTipText()) + SetToolTip(tip); +} + +}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/CAD/DesignRowList.hpp b/src/slic3r/GUI/CAD/DesignRowList.hpp new file mode 100644 index 0000000000..ff1ecc482a --- /dev/null +++ b/src/slic3r/GUI/CAD/DesignRowList.hpp @@ -0,0 +1,115 @@ +#ifndef slic3r_DesignRowList_hpp_ +#define slic3r_DesignRowList_hpp_ + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "slic3r/GUI/wxExtensions.hpp" // ScalableBitmap + +class wxTextCtrl; + +namespace Slic3r { namespace GUI { + +// The Design tab's Feature tree and Bodies lists: a single-selection list whose rows carry their +// own action icons. A wxTreeCtrl cannot host a clickable icon per row (it is native on MSW), so +// this list draws its own: each row draws its actions at its right end, highlights the one under +// the pointer, and takes its icons from the owner, so the eye can show whether the row is hidden. +// +// 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. +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" + wxString tip; + }; + struct Row { + std::string icon; // type icon at the left; empty for none + wxString label; + wxString edit_text; // what the rename editor opens with; empty for the label + wxColour colour; // label colour + std::vector actions; // icon cells at the right end, in display order + }; + + // The hover chip behind an action icon, a light colour dark-mapped at paint. The sidebar's + // icon buttons hover in it too. + 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); + + // Replace every row. Clears the selection without notifying and cancels a rename in progress; + // the owner re-selects the row it keeps, which notifies. + void set_rows(std::vector rows); + int selection() const { return GetSelection(); } + // Select `row` (wxNOT_FOUND clears) and call on_select if that changed the selection. + void select(int row); + void unselect() { select(wxNOT_FOUND); } + // Open the in-place editor on a row, once the current event has finished: opened from inside + // a popup menu's nested loop it would never appear. Enter or clicking away commits, Esc cancels. + void begin_rename(int row); + // Re-rasterise the icons and re-measure the rows after a DPI or theme change. + void Rescale(); + + 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. + std::function on_action; + // Context menu on a row, at a screen position; the row under the pointer is selected first. + 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; + +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; + wxCoord OnMeasureItem(size_t n) const override; + +private: + struct Hit { + int row{wxNOT_FOUND}; + int cell{-1}; // index into the row's actions, -1 for the rest of the row + bool operator==(const Hit& o) const { return row == o.row && cell == o.cell; } + bool operator!=(const Hit& o) const { return !(*this == o); } + }; + + Hit hit_test(const wxPoint& pt) const; + wxRect cell_rect(const wxRect& row, size_t count, size_t i) const; + wxRect label_rect(const wxRect& row, const Row& r) const; + const ScalableBitmap* icon(const std::string& name) const; + void load_icons(); + void set_hover(const Hit& h); + void open_editor(int row); + void end_rename(bool commit); + void measure_row(); + void fit_rows(); + + std::vector m_rows; + std::map m_icons; + int m_max_visible; + 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 + wxTextCtrl* m_editor{nullptr}; // created on the first rename, then reused + int m_edit_row{wxNOT_FOUND}; +}; + +}} // namespace Slic3r::GUI + +#endif // slic3r_DesignRowList_hpp_ diff --git a/src/slic3r/GUI/CAD/DesignSketchTool.cpp b/src/slic3r/GUI/CAD/DesignSketchTool.cpp index 5e27a0781a..f27e09be5e 100644 --- a/src/slic3r/GUI/CAD/DesignSketchTool.cpp +++ b/src/slic3r/GUI/CAD/DesignSketchTool.cpp @@ -4,12 +4,16 @@ #include "slic3r/GUI/ImGuiWrapper.hpp" #include "slic3r/GUI/CAD/SketchInlineEditor.hpp" #include "slic3r/GUI/Plater.hpp" +#include "slic3r/GUI/I18N.hpp" +#include "libslic3r/format.hpp" +#include "libslic3r/Utils.hpp" #include #include #include #include #include +#include #include #include #include "libslic3r/BuildVolume.hpp" @@ -17,6 +21,13 @@ #include "slic3r/GUI/3DScene.hpp" #include "slic3r/GUI/GLShader.hpp" #include "libslic3r/CAD/GeometryEngine.hpp" + +#include +#include +#include +#include +#include +#include #include "libslic3r/TriangleMesh.hpp" #include @@ -86,18 +97,9 @@ static double ray_segment_dist3(const Vec3d& ro, const Vec3d& rd, const Vec3d& a return wxPoint(int(sx + 0.5), int(sy + 0.5)); } -// The kernel's weld tolerance follows the app preference, and it must be pushed at EVERY -// point that starts a sketch session: a Constrain session never passes through begin(), and -// it uses region_loops()/connected_loop(), which read the same tolerance. Pushing in one -// place only would leave those sessions on whatever the previous session set. -static void push_auto_close_pref() -{ - Slic3r::set_sketch_auto_close(wxGetApp().is_auto_close_sketch_loops()); -} - void DesignSketchTool::begin(const SketchPlane& plane, Mode mode) { - push_auto_close_pref(); + m_sketch_redo.clear(); m_plane = plane; m_mode = mode; @@ -257,7 +259,14 @@ void DesignSketchTool::set_tool(Mode mode) // This MUST run before m_mode is reassigned: op_ready() and confirm_op() both switch on // m_mode, so after the assignment they would test the tool being switched TO. That read // op_ready()==0 with a=0 b=3 val=28.205 sitting right there — picked, valued, and dropped. + // Charter 4.2: "starting another operation while a valid feature is pending commits it" — + // the ghost is on screen, so switching away keeps it. Esc is the discard (disarm_tool drops + // the pending op/transform before it gets here). if (op_ready()) confirm_op(); + // And the same rule for the transform gizmo: a ready edit-op committed here while a pending + // transform was silently dropped, which is the same "the value was set, the ghost was drawn, + // and nothing was written" failure the comment above records for Fillet. + if (tf_ready()) confirm_transform(); // An OPEN inline value field freezes the canvas (on_mouse_impl returns early while // m_awaiting_length) and blocks every keyboard shortcut (in_text includes inline_busy()). @@ -272,6 +281,7 @@ void DesignSketchTool::set_tool(Mode mode) m_mode = mode; m_points.clear(); m_has_cursor = false; + m_cursor_snap = InferenceSnap{}; // the previous tool's snap marker is not this tool's // DRAIN THE QUEUED DIMENSIONS, do not just resync the baseline. on_inline_commit() above // commits the field that is open, and the commit callback installed by // open_next_autoedit_dim does `++m_autoedit_dim_idx; CallAfter(open_next_autoedit_dim)` — @@ -331,6 +341,7 @@ void DesignSketchTool::set_tool(Mode mode) void DesignSketchTool::cancel() { + m_sketch_redo.clear(); close_session_chrome(); // same orphaned-field freeze as finish() — see yce m_active = false; m_step_mode_last = -1; @@ -358,22 +369,71 @@ void DesignSketchTool::cancel() reset_tf(); } +bool DesignSketchTool::confirm_pending() +{ + if (op_ready()) { confirm_op(); return true; } + if (tf_ready()) { confirm_transform(); return true; } + if (!m_points.empty() && end_chain()) return true; + return false; +} + +// End a Polyline / Spline chain as drawn (open), the one rule behind right-click, double-click +// and Enter. The first click of a double-click lands as an ordinary LeftDown on some platforms, +// so a final point within a few pixels of the one before it is that click again, not a pole. +bool DesignSketchTool::end_chain() +{ + if (m_mode != Mode::Polyline && m_mode != Mode::BSpline) return false; + std::vector pts; + for (const Vec2d& q : m_points) + if (pts.empty() || (q - pts.back()).norm() > m_chain_dup_tol) pts.push_back(q); + const int base = int(m_entities.size()); + if (pts.size() >= 2) { + if (m_mode == Mode::Polyline) push_open_chain(pts); + else append_entities(make_bspline(pts)); + infer_auto_constraints(base); // end poles auto-Coincident -> loops close + } + m_points.clear(); + emit_step_hint(); + return true; +} + +void DesignSketchTool::show_refusal(const std::string& why) +{ + if (inline_editor != nullptr && inline_editor->refuse(why)) return; + notify(why); +} + // CadLevel::Gesture inside a sketch: drop the entity being drawn, keep the tool armed. bool DesignSketchTool::abort_gesture() { - if (m_points.empty()) return false; + if (!gesture_pending()) return false; m_points.clear(); m_has_cursor = false; + // Picks are a gesture too: drop them, never apply them, and keep the tool armed. + if (has_pending_picks()) { + reset_op(); + reset_tf(); + m_dim_has0 = false; + m_dim_e0 = -1; + m_dim_r0 = SketchPointRole::P0; + m_pick0 = m_pick1 = m_pick2 = -1; + m_sel_a = m_sel_b = -1; + m_selection.clear(); + m_point_sel.clear(); + if (on_selection_changed) on_selection_changed(0); + } + emit_step_hint(); return true; } // CadLevel::Tool inside a sketch: an armed draw/edit tool falls back to Select. -// Drop any pending edit-op BEFORE the downgrade: set_tool commits a ready one, and Esc must -// cancel it, never apply it. Right-click already discards it through its own branch. +// Drop any pending edit-op or transform BEFORE the downgrade: set_tool commits a ready one, +// and Esc must cancel it, never apply it. Right-click already discards it through its own branch. bool DesignSketchTool::disarm_tool() { if (m_mode == Mode::Select) return false; reset_op(); + reset_tf(); set_tool(Mode::Select); return true; } @@ -398,11 +458,6 @@ void DesignSketchTool::request_exit() else cancel(); } -void DesignSketchTool::request_undo_redo(bool redo) -{ - if (on_undo_redo) on_undo_redo(redo); -} - void DesignSketchTool::clear_selection() { if (m_selection.empty() && m_point_sel.empty()) return; @@ -411,6 +466,49 @@ void DesignSketchTool::clear_selection() if (on_selection_changed) on_selection_changed(0); } +bool DesignSketchTool::can_redo_entity() const +{ + return m_active && !m_sketch_redo.empty() + && m_sketch_redo.back().after_entities == m_entities.size() + && m_sketch_redo.back().after_constraints == m_constraints.size(); +} + +bool DesignSketchTool::redo_last_entity() +{ + if (!can_redo_entity()) { m_sketch_redo.clear(); return false; } + SketchSnap s = std::move(m_sketch_redo.back()); + m_sketch_redo.pop_back(); + m_entities = std::move(s.entities); + m_constraints = std::move(s.constraints); + m_features = std::move(s.features); + m_dimensions = std::move(s.dimensions); + m_selection.clear(); + m_point_sel.clear(); + reset_autoedit(); + resolve_live(); + if (on_selection_changed) on_selection_changed(0); + return true; +} + +bool DesignSketchTool::undo_last_entity() +{ + if (!m_active || m_entities.empty()) return false; + if (!can_redo_entity()) m_sketch_redo.clear(); // the sketch moved on since the last undo + SketchSnap snap{ m_entities, m_constraints, m_features, m_dimensions, 0, 0 }; + const int last = int(m_entities.size()) - 1; + int begin = last; + for (const Feature& f : m_features) + if (f.begin <= last && last < f.end && f.end == last + 1) begin = std::min(begin, f.begin); + m_selection.clear(); + for (int i = begin; i <= last; ++i) m_selection.push_back(i); + delete_selected(); + reset_autoedit(); + snap.after_entities = m_entities.size(); + snap.after_constraints = m_constraints.size(); + m_sketch_redo.push_back(std::move(snap)); + return true; +} + void DesignSketchTool::delete_selected() { if (m_selection.empty()) return; @@ -425,21 +523,39 @@ void DesignSketchTool::delete_selected() if (!del[i]) remap[i] = next++; for (int i = n - 1; i >= 0; --i) if (del[i]) m_entities.erase(m_entities.begin() + i); - // Drop constraints touching a deleted entity; remap the survivors. + // Drop constraints touching a deleted entity; remap the survivors. A negative reference is + // not "no entity" but a sentinel (origin, X axis, Y axis — kSketchRef*) and passes through + // unchanged: mapping it to -1 silently cut every constraint onto the origin or an axis. std::vector kept; + std::vector con_remap(m_constraints.size(), -1); auto live = [&](int e) { return e < 0 || (e < n && remap[e] >= 0); }; - auto map = [&](int e) { return e < 0 ? -1 : remap[e]; }; - for (SketchEntityConstraintDef c : m_constraints) { + auto map = [&](int e) { return e < 0 ? e : remap[e]; }; + for (int ci = 0; ci < int(m_constraints.size()); ++ci) { + SketchEntityConstraintDef c = m_constraints[ci]; if (!live(c.ea) || !live(c.eb) || !live(c.ec)) continue; c.ea = map(c.ea); c.eb = map(c.eb); c.ec = map(c.ec); + con_remap[ci] = int(kept.size()); kept.push_back(c); } m_constraints.swap(kept); m_selection.clear(); m_point_sel.clear(); - // v1: placed quotes reference entity indices that have shifted; drop them rather - // than risk a dangling reference (the driving constraints survive, reindexed). - m_dimensions.clear(); + // Placed quotes follow their geometry and their driving constraint: a quote whose entity or + // constraint went away goes with it, the others are reindexed. Clearing them all made one + // Delete hide every label while the constraints they showed kept driving the sketch. + { + std::vector kept_d; + for (DimAnnot a : m_dimensions) { + if (!live(a.ea) || !live(a.eb)) continue; + if (a.con >= 0) { + if (a.con >= int(con_remap.size()) || con_remap[a.con] < 0) continue; + a.con = con_remap[a.con]; + } + a.ea = map(a.ea); a.eb = map(a.eb); + kept_d.push_back(a); + } + m_dimensions.swap(kept_d); + } m_dim_has0 = false; m_pending_dim = -1; // The Dimension tool's pending FIRST pick is the same dangling-reference hazard as the placed @@ -626,6 +742,20 @@ void DesignSketchTool::apply_angle_between(int ia, int ib, double deg) void DesignSketchTool::apply_dimension(double v) { + // Refuse BEFORE moving or recording. Each case below has its own threshold (positive for + // length / radius / diameter, non-negative for a distance) and the constraint used to be + // recorded UNCONDITIONALLY after them — so a value that moved nothing was still handed to + // the solver, which then had to satisfy something it never could. The socket guarded against + // this and said so; now the tool does too, and it says why. + const DimType kind = dimension_kind(); + const bool needs_positive = (kind == DimType::Length || kind == DimType::Radius || + kind == DimType::Diameter); + if (!std::isfinite(v) || (needs_positive && v <= 0.0) || + (!needs_positive && kind != DimType::Angle && kind != DimType::None && v < 0.0)) { + show_refusal(needs_positive ? _u8L("The value must be greater than zero") + : _u8L("The value must not be negative (zero means coincident)")); + return; + } switch (dimension_kind()) { case DimType::Length: { SketchEntity& e = m_entities[m_selection[0]]; @@ -674,6 +804,8 @@ void DesignSketchTool::apply_dimension(double v) } record_dimension_constraint(v); // store a driving constraint for this dimension resolve_live(); // live-solve so the viewport shows the solved sketch + if (!m_solve_ok) // the number on screen is not the geometry's number + notify(_u8L("That dimension cannot be satisfied — the sketch is over-constrained")); m_selection.clear(); if (on_selection_changed) on_selection_changed(0); } @@ -694,23 +826,23 @@ void DesignSketchTool::record_dimension_constraint(double v) c.type = SketchConstraintType::Distance; c.ea = m_selection[0]; c.ra = SketchPointRole::P0; c.eb = m_selection[0]; c.rb = SketchPointRole::P1; - c.value = v; m_constraints.push_back(c); break; + c.value = v; upsert_dimension_constraint(c); break; case DimType::Diameter: c.type = SketchConstraintType::Diameter; c.ea = m_selection[0]; c.value = v; - m_constraints.push_back(c); break; + upsert_dimension_constraint(c); break; case DimType::Radius: c.type = SketchConstraintType::Radius; c.ea = m_selection[0]; c.value = v; - m_constraints.push_back(c); break; + upsert_dimension_constraint(c); break; case DimType::Angle: c.type = SketchConstraintType::Angle; c.ea = m_selection[0]; c.eb = m_selection[1]; c.value = v; - m_constraints.push_back(c); break; + upsert_dimension_constraint(c); break; case DimType::Distance: { const int ia = m_selection[0], ib = m_selection[1]; if (v < 1e-9) c.type = SketchConstraintType::Coincident; else { c.type = SketchConstraintType::Distance; c.value = v; } c.ea = ia; c.ra = role(ia); c.eb = ib; c.rb = role(ib); - m_constraints.push_back(c); break; + upsert_dimension_constraint(c); break; } case DimType::DistanceToLine: { // Point-on-line driving constraint: hold the point-like entity at unsigned @@ -720,7 +852,7 @@ void DesignSketchTool::record_dimension_constraint(double v) const int il = m_selection[aLine ? 0 : 1]; // line c.type = SketchConstraintType::PointOnLine; c.ea = ip; c.ra = role(ip); c.eb = il; c.value = v; - m_constraints.push_back(c); break; + upsert_dimension_constraint(c); break; } default: break; // None: no driving constraint recorded } @@ -763,18 +895,46 @@ void DesignSketchTool::resolve_live_drag(int dragged_ei, SketchPointRole dragged : sketch_solve(m_entities, cons); m_dof = r.dof; m_solve_ok = r.ok; + // Constraints the solver had to leave out (no representation for the part they name) + // are said once, when their number changes — resolve_live runs on every drag frame. + if (int(r.skipped.size()) != m_skipped_last) { + m_skipped_last = int(r.skipped.size()); + if (m_skipped_last > 0) + notify(format(_u8L("%1% constraint(s) cannot act on this geometry (an ellipse or spline curve, " + "or a zero-size circle) and are ignored"), m_skipped_last), false); + } // Flag every entity referenced by a conflicting constraint so render() can - // tint it red (Onshape/SolveSpace over-constrained feedback). + // tint it red (Onshape/SolveSpace over-constrained feedback), and remember which + // DIMENSIONS those constraints drive: a label showing a value the geometry does not have + // is the one thing the user must be able to see, and r.bad names it. + m_bad_dims.clear(); for (int bi : r.bad) { if (bi < 0 || bi >= int(m_constraints.size())) continue; const SketchEntityConstraintDef& c = m_constraints[bi]; for (int e : {c.ea, c.eb, c.ec}) if (e >= 0 && e < int(m_entity_conflict.size())) m_entity_conflict[e] = 1; + for (int di = 0; di < int(m_dimensions.size()); ++di) + if (m_dimensions[di].con == bi) m_bad_dims.push_back(di); } } else { m_dof = -1; m_solve_ok = true; + m_bad_dims.clear(); } if (on_solve_state) on_solve_state(m_dof, m_solve_ok, has); + announce_loop_defects(); +} + +// The red tint and marker on a loop that crosses or folds back explain nothing on their own, so +// the first time one appears the status line says what they mean. Once, on the transition: this +// runs on every solve, drags included. +void DesignSketchTool::announce_loop_defects() +{ + bool any = false; + for (const RegionLoop& r : region_loops(m_entities)) + if (r.defect) { any = true; break; } + if (any && !m_loop_defect_shown) + notify(_u8L("This profile crosses or folds back on itself at the red mark, so it does not bound one region")); + m_loop_defect_shown = any; } // ---- Onshape-style visual editing: feature grouping + handles ----------------- @@ -896,8 +1056,8 @@ bool DesignSketchTool::update_hover(GLCanvas3D& canvas, wxMouseEvent& evt) Vec2d p; screen_to_plane(canvas, evt, p); // Zoom-aware pick tolerance: project a point a few px away and measure in plane units. - const Linef3 r2 = canvas.mouse_ray(Point(evt.GetX() + 6, evt.GetY())); - const double tol = std::max(1e-3, (m_plane.project(r2.a, r2.vector()) - p).norm()); + // The SAME budget a click grabs with: a handle that looks un-hovered must not be grabbable. + const double tol = screen_tol(canvas, evt, p, kPickPx); const bool had = m_has_hover_handle; const Handle prev = m_hover_handle; Handle h; @@ -1136,6 +1296,24 @@ int DesignSketchTool::upsert_constraint(const SketchEntityConstraintDef& c) return int(m_constraints.size()) - 1; } +// A Distance and its zero case are ONE dimension slot: typing 0 records a Coincident, and typing +// 5 after that used to record a second constraint beside it — over-constrained by construction, +// which reads as the edit being ignored. Replace across that pair, then upsert normally. +int DesignSketchTool::upsert_dimension_constraint(const SketchEntityConstraintDef& c) +{ + const bool zero_case = (c.type == SketchConstraintType::Coincident); + const bool dist_case = (c.type == SketchConstraintType::Distance); + if (zero_case || dist_case) { + erase_constraints([&](int, const SketchEntityConstraintDef& d) { + const bool same = (d.ea == c.ea && d.eb == c.eb) || (d.ea == c.eb && d.eb == c.ea); + if (!same) return false; + return zero_case ? (d.type == SketchConstraintType::Distance) + : (d.type == SketchConstraintType::Coincident); + }); + } + return upsert_constraint(c); +} + // The same rule for the visible annotation: one quote per (kind, operands), so repeated edits // do not stack labels on top of each other reading different values. int DesignSketchTool::upsert_dimension(const DimAnnot& a) @@ -1183,7 +1361,7 @@ SketchEntityConstraintDef DesignSketchTool::constraint_for(const DimAnnot& a) co int DesignSketchTool::place_dimension(DimAnnot a) { a.value = measure_dim(a); - a.con = upsert_constraint(constraint_for(a)); + a.con = upsert_dimension_constraint(constraint_for(a)); const int di = upsert_dimension(a); resolve_live(); open_value_editor(di); @@ -1266,7 +1444,7 @@ void DesignSketchTool::open_angle_editor(int ei) if (!on_inline_edit) return; DimAnnot a; a.kind = DimType::Angle; a.ea = ei; const wxPoint px(m_last_mouse_x, m_last_mouse_y); - on_inline_edit(px, measure_dim(a), "Angle", + on_inline_edit(px, measure_dim(a), _u8L("Angle (°)"), [this, ei](double deg) { set_line_angle(ei, deg); }, []() {}); } @@ -1284,16 +1462,7 @@ bool DesignSketchTool::open_selection_dimension_editor() if (!on_inline_edit || !selection_valid()) return false; const DimType k = dimension_kind(); if (k == DimType::None) return false; - const char* title = "Value"; - switch (k) { - case DimType::Length: title = "Length"; break; - case DimType::Radius: title = "Radius"; break; - case DimType::Diameter: title = "Diameter"; break; - case DimType::Angle: title = "Angle"; break; - case DimType::Distance: title = "Distance"; break; - case DimType::DistanceToLine: title = "Distance"; break; - default: break; - } + const std::string title = dimtype_title(k); // Anchor over the geometry it belongs to, not the panel: the value belongs to the element. DimAnnot a; a.kind = k; a.ea = m_selection.empty() ? -1 : m_selection[0]; @@ -1351,6 +1520,8 @@ void DesignSketchTool::open_next_autoedit_dim() on_inline_edit(px, step.value, step.title, [this, step](double v) { // commit: apply this dimension, then next if (step.apply) step.apply(v); + // A refused value brought the same field back (show_refusal): stay on this step. + if (inline_editor != nullptr && inline_editor->is_open()) return; ++m_autoedit_dim_idx; wxGetApp().CallAfter([this] { open_next_autoedit_dim(); }); }, @@ -1377,30 +1548,31 @@ void DesignSketchTool::arm_polyline_segment_edit() m_autoedit_dims.clear(); m_autoedit_dims.push_back({ mid, L, [this, k, a](double len) { // Length if (k < int(m_points.size())) { + if (!(len > 0.0)) { show_refusal(_u8L("The value must be greater than zero")); return; } Vec2d dd = m_points[k] - a; const double n = dd.norm(); - if (n > 1e-9 && len > 1e-9) m_points[k] = a + (len / n) * dd; + if (n > 1e-9) m_points[k] = a + (len / n) * dd; } - }, {}, "Length" }); + }, {}, _u8L("Length") }); m_autoedit_dims.push_back({ mid, deg, [this, k, a](double dg) { // Angle if (k < int(m_points.size())) { const double len = (m_points[k] - a).norm(); const double r = dg * M_PI / 180.0; m_points[k] = a + len * Vec2d(std::cos(r), std::sin(r)); } - }, {}, "Angle" }); + }, {}, _u8L("Angle (°)") }); m_autoedit_dim_idx = 0; wxGetApp().CallAfter([this] { open_next_autoedit_dim(); }); } std::string DesignSketchTool::dimtype_title(DimType k) const { switch (k) { - case DimType::Length: return "Length"; - case DimType::Diameter: return "Diameter"; - case DimType::Radius: return "Radius"; - case DimType::Angle: return "Angle"; - case DimType::Distance: return "Distance"; - case DimType::DistanceToLine: return "Distance"; - default: return "Value"; + case DimType::Length: return _u8L("Length"); + case DimType::Diameter: return _u8L("Diameter"); + case DimType::Radius: return _u8L("Radius"); + case DimType::Angle: return _u8L("Angle (°)"); + case DimType::Distance: return _u8L("Distance"); + case DimType::DistanceToLine: return _u8L("Distance"); + default: return _u8L("Value"); } } @@ -1439,15 +1611,21 @@ void DesignSketchTool::open_primary_autoedit() if (q.kind == DimType::Angle) { const int ei = q.ea; m_autoedit_dims.push_back({ q.label_pos, measure_dim(q), - [this, ei](double v) { set_line_angle(ei, v); }, { ei }, "Angle" }); + [this, ei](double v) { set_line_angle(ei, v); }, { ei }, _u8L("Angle (°)") }); continue; } DimAnnot a = q; a.value = measure_dim(a); m_autoedit_dims.push_back({ a.label_pos, a.value, [this, a](double v) mutable { + const bool positive = a.kind == DimType::Length || a.kind == DimType::Radius || a.kind == DimType::Diameter; + if (!std::isfinite(v) || (positive && v <= 0.0) || (!positive && v < 0.0)) { + show_refusal(positive ? _u8L("The value must be greater than zero") + : _u8L("The value must not be negative (zero means coincident)")); + return; + } a.value = v; - a.con = upsert_constraint(constraint_for(a)); + a.con = upsert_dimension_constraint(constraint_for(a)); upsert_dimension(a); resolve_live(); }, { a.ea, a.eb }, dimtype_title(a.kind) }); @@ -1470,8 +1648,8 @@ void DesignSketchTool::open_primary_autoedit() const double side = (m_entities[f.begin].p1 - m_entities[f.begin].p0).norm(); const Vec2d sp = m_entities[f.begin].p0 - f.c0; double deg = std::atan2(sp.y(), sp.x()) * 180.0 / M_PI; if (deg < 0.0) deg += 360.0; - m_autoedit_dims.push_back({ m_live_poly_side_label, side, [this, fi](double v){ set_polygon_side(fi, v); }, span(fi), "Side" }); - m_autoedit_dims.push_back({ m_live_poly_angle_label, deg, [this, fi](double v){ set_polygon_angle(fi, v); }, span(fi), "Angle" }); + m_autoedit_dims.push_back({ m_live_poly_side_label, side, [this, fi](double v){ set_polygon_side(fi, v); }, span(fi), _u8L("Side") }); + m_autoedit_dims.push_back({ m_live_poly_angle_label, deg, [this, fi](double v){ set_polygon_angle(fi, v); }, span(fi), _u8L("Angle (°)") }); } } if (m_live_rrect_fi >= 0) { @@ -1481,43 +1659,43 @@ void DesignSketchTool::open_primary_autoedit() auto rr_w = [this, fi](double v){ const Feature& g = m_features[fi]; set_rounded_rect(fi, v, std::abs(g.c1.y()-g.c0.y()), g.param); }; auto rr_h = [this, fi](double v){ const Feature& g = m_features[fi]; set_rounded_rect(fi, std::abs(g.c1.x()-g.c0.x()), v, g.param); }; auto rr_r = [this, fi](double v){ const Feature& g = m_features[fi]; set_rounded_rect(fi, std::abs(g.c1.x()-g.c0.x()), std::abs(g.c1.y()-g.c0.y()), v); }; - m_autoedit_dims.push_back({ m_live_rrect_w_label, w, rr_w, span(fi), "Width" }); - m_autoedit_dims.push_back({ m_live_rrect_h_label, h, rr_h, span(fi), "Height" }); - m_autoedit_dims.push_back({ m_live_rrect_r_label, r, rr_r, span(fi), "Radius" }); + m_autoedit_dims.push_back({ m_live_rrect_w_label, w, rr_w, span(fi), _u8L("Width") }); + m_autoedit_dims.push_back({ m_live_rrect_h_label, h, rr_h, span(fi), _u8L("Height") }); + m_autoedit_dims.push_back({ m_live_rrect_r_label, r, rr_r, span(fi), _u8L("Radius") }); } if (m_live_aslot_fi >= 0) { const Feature& f = m_features[m_live_aslot_fi]; const int fi = m_live_aslot_fi; const double Rc = (f.c1 - f.c0).norm(), fw = 2.0 * f.param; - m_autoedit_dims.push_back({ m_live_aslot_r_label, Rc, [this, fi](double v){ const Feature& g = m_features[fi]; set_arc_slot(fi, v, g.param); }, span(fi), "Radius" }); - m_autoedit_dims.push_back({ m_live_aslot_w_label, fw, [this, fi](double v){ const Feature& g = m_features[fi]; set_arc_slot(fi, (g.c1-g.c0).norm(), std::max(1e-3, v*0.5)); }, span(fi), "Width" }); + m_autoedit_dims.push_back({ m_live_aslot_r_label, Rc, [this, fi](double v){ const Feature& g = m_features[fi]; set_arc_slot(fi, v, g.param); }, span(fi), _u8L("Radius") }); + m_autoedit_dims.push_back({ m_live_aslot_w_label, fw, [this, fi](double v){ const Feature& g = m_features[fi]; set_arc_slot(fi, (g.c1-g.c0).norm(), v * 0.5); }, span(fi), _u8L("Width") }); } if (m_live_slot_fi >= 0) { const Feature& f = m_features[m_live_slot_fi]; const int fi = m_live_slot_fi; - // Slot dims, in order: (1) inter-centre distance, (2) radius (= half-width), (3) angle. + // Slot dims, in order: (1) inter-centre distance, (2) full width (as the arc slot), (3) angle. const Vec2d d = f.c1 - f.c0; const double Lc = d.norm(); double deg = std::atan2(d.y(), d.x()) * 180.0 / M_PI; if (deg < 0.0) deg += 360.0; - m_autoedit_dims.push_back({ m_live_slot_len_label, Lc, [this, fi](double v){ const Feature& g = m_features[fi]; set_slot(fi, v, g.param); }, span(fi), "Length" }); - m_autoedit_dims.push_back({ m_live_slot_w_label, f.param, [this, fi](double v){ const Feature& g = m_features[fi]; set_slot(fi, (g.c1-g.c0).norm(), std::max(1e-3, v)); }, span(fi), "Radius" }); - m_autoedit_dims.push_back({ m_live_slot_angle_label, deg, [this, fi](double v){ set_slot_angle(fi, v); }, span(fi), "Angle" }); + m_autoedit_dims.push_back({ m_live_slot_len_label, Lc, [this, fi](double v){ const Feature& g = m_features[fi]; set_slot(fi, v, g.param); }, span(fi), _u8L("Length") }); + m_autoedit_dims.push_back({ m_live_slot_w_label, 2.0 * f.param, [this, fi](double v){ const Feature& g = m_features[fi]; set_slot(fi, (g.c1-g.c0).norm(), v * 0.5); }, span(fi), _u8L("Width") }); + m_autoedit_dims.push_back({ m_live_slot_angle_label, deg, [this, fi](double v){ set_slot_angle(fi, v); }, span(fi), _u8L("Angle (°)") }); } if (m_live_arc_ei >= 0) { // arc Radius is already a scalar step above; add its sweep angle const int ei = m_live_arc_ei; const SketchEntity& e = m_entities[ei]; const double swdeg = std::abs(e.end_angle - e.start_angle) * 180.0 / M_PI; - m_autoedit_dims.push_back({ m_live_arc_angle_label, swdeg, [this, ei](double v){ set_arc_sweep(ei, v); }, { ei }, "Angle" }); + m_autoedit_dims.push_back({ m_live_arc_angle_label, swdeg, [this, ei](double v){ set_arc_sweep(ei, v); }, { ei }, _u8L("Angle (°)") }); } if (m_live_ellipse_ei >= 0) { const int ei = m_live_ellipse_ei; const SketchEntity& e = m_entities[ei]; - m_autoedit_dims.push_back({ m_live_ellipse_major_label, e.radius, [this, ei](double v){ set_ellipse_axis(ei, true, v); }, { ei }, "Major" }); - m_autoedit_dims.push_back({ m_live_ellipse_minor_label, e.rminor, [this, ei](double v){ set_ellipse_axis(ei, false, v); }, { ei }, "Minor" }); + m_autoedit_dims.push_back({ m_live_ellipse_major_label, e.radius, [this, ei](double v){ set_ellipse_axis(ei, true, v); }, { ei }, _u8L("Semi-major axis") }); + m_autoedit_dims.push_back({ m_live_ellipse_minor_label, e.rminor, [this, ei](double v){ set_ellipse_axis(ei, false, v); }, { ei }, _u8L("Semi-minor axis") }); if (e.type == SketchEntity::Type::EllipseArc) { // + included sweep const double swdeg = std::abs(e.end_angle - e.start_angle) * 180.0 / M_PI; m_autoedit_dims.push_back({ m_live_ellipsearc_sweep_label, swdeg, - [this, ei](double v){ set_ellipsearc_sweep(ei, v); }, { ei }, "Angle" }); + [this, ei](double v){ set_ellipsearc_sweep(ei, v); }, { ei }, _u8L("Angle (°)") }); } } if (m_live_obrect_fi >= 0) { // oblique rect: W,H already added as scalars; + orientation @@ -1527,7 +1705,7 @@ void DesignSketchTool::open_primary_autoedit() double adeg = std::atan2(e0.p1.y() - e0.p0.y(), e0.p1.x() - e0.p0.x()) * 180.0 / M_PI; if (adeg < 0.0) adeg += 360.0; m_autoedit_dims.push_back({ m_live_obrect_angle_label, adeg, - [this, fi](double v){ set_rect_angle(fi, v); }, span(fi), "Angle" }); + [this, fi](double v){ set_rect_angle(fi, v); }, span(fi), _u8L("Angle (°)") }); } trace_autoedit(m_autoedit_dims.empty() ? "built NO steps (no live quote matched)" : "opening", @@ -1547,7 +1725,7 @@ void DesignSketchTool::open_polygon_side_editor(int fi) if (f.begin < 0 || f.begin >= int(m_entities.size())) return; const double side = (m_entities[f.begin].p1 - m_entities[f.begin].p0).norm(); const wxPoint px(m_last_mouse_x, m_last_mouse_y); - on_inline_edit(px, side, "Side", + on_inline_edit(px, side, _u8L("Side"), [this, fi](double v) { set_polygon_side(fi, v); }, []() {}); } @@ -1561,7 +1739,7 @@ void DesignSketchTool::open_polygon_angle_editor(int fi) double deg = std::atan2(sp.y(), sp.x()) * 180.0 / M_PI; if (deg < 0.0) deg += 360.0; const wxPoint px(m_last_mouse_x, m_last_mouse_y); - on_inline_edit(px, deg, "Angle", + on_inline_edit(px, deg, _u8L("Angle (°)"), [this, fi](double v) { set_polygon_angle(fi, v); }, []() {}); } @@ -1570,7 +1748,8 @@ void DesignSketchTool::open_polygon_angle_editor(int fi) // n-gon, circumradius R = side / (2 sin(pi/n)). void DesignSketchTool::set_polygon_side(int fi, double side) { - if (fi < 0 || fi >= int(m_features.size()) || side < 1e-6) return; + if (fi < 0 || fi >= int(m_features.size())) return; + if (!(side > 0.0)) { show_refusal(_u8L("The value must be greater than zero")); return; } const int n = std::max(3, m_features[fi].sides); const double R = side / (2.0 * std::sin(M_PI / double(n))); set_polygon_radius(fi, R); @@ -1579,6 +1758,25 @@ void DesignSketchTool::set_polygon_side(int fi, double side) // Remove orientation constraints touching [begin,end). A pure rotation makes inferred // per-edge Horizontal/Vertical (and Parallel/Perp/Angle/Lock) inconsistent, so leaving // them in would make resolve_live collapse the shape to satisfy them. +int DesignSketchTool::erase_constraints( + const std::function& drop) +{ + std::vector remap(m_constraints.size(), -1); + std::vector kept; + kept.reserve(m_constraints.size()); + for (int i = 0; i < int(m_constraints.size()); ++i) { + if (drop(i, m_constraints[i])) continue; // remove + remap[i] = int(kept.size()); + kept.push_back(m_constraints[i]); + } + if (kept.size() == m_constraints.size()) return 0; // nothing dropped + const int dropped = int(m_constraints.size() - kept.size()); + m_constraints.swap(kept); + for (DimAnnot& a : m_dimensions) // repair the cached indices + if (a.con >= 0) a.con = (a.con < int(remap.size())) ? remap[a.con] : -1; + return dropped; +} + void DesignSketchTool::drop_orientation_constraints(int begin, int end) { using CT = SketchConstraintType; @@ -1587,36 +1785,16 @@ void DesignSketchTool::drop_orientation_constraints(int begin, int end) t == CT::Perpendicular || t == CT::Angle || t == CT::LockX || t == CT::LockY; }; auto in = [&](int e) { return e >= begin && e < end; }; - std::vector remap(m_constraints.size(), -1); - std::vector kept; - kept.reserve(m_constraints.size()); - for (int i = 0; i < int(m_constraints.size()); ++i) { - const SketchEntityConstraintDef& c = m_constraints[i]; - if (orient(c.type) && (in(c.ea) || in(c.eb))) continue; // drop - remap[i] = int(kept.size()); - kept.push_back(c); - } - if (kept.size() == m_constraints.size()) return; // nothing dropped - m_constraints.swap(kept); - for (DimAnnot& a : m_dimensions) // fix cached con indices - if (a.con >= 0) a.con = (a.con < int(remap.size())) ? remap[a.con] : -1; + erase_constraints([&](int, const SketchEntityConstraintDef& c) { + return orient(c.type) && (in(c.ea) || in(c.eb)); + }); } void DesignSketchTool::drop_constraints_referencing(int ei) { - std::vector remap(m_constraints.size(), -1); - std::vector kept; - kept.reserve(m_constraints.size()); - for (int i = 0; i < int(m_constraints.size()); ++i) { - const SketchEntityConstraintDef& c = m_constraints[i]; - if (c.ea == ei || c.eb == ei || c.ec == ei) continue; // drop refs to the cut entity - remap[i] = int(kept.size()); - kept.push_back(c); - } - if (kept.size() == m_constraints.size()) return; - m_constraints.swap(kept); - for (DimAnnot& a : m_dimensions) - if (a.con >= 0) a.con = (a.con < int(remap.size())) ? remap[a.con] : -1; + erase_constraints([&](int, const SketchEntityConstraintDef& c) { + return c.ea == ei || c.eb == ei || c.ec == ei; // refs to the cut entity + }); } // Onshape scissors on the live sketch: cut the picked entity at its nearest intersection. @@ -1816,7 +1994,7 @@ void DesignSketchTool::open_arc_angle_editor(int ei) if (e.type != SketchEntity::Type::Arc) return; double swdeg = std::abs(e.end_angle - e.start_angle) * 180.0 / M_PI; const wxPoint px(m_last_mouse_x, m_last_mouse_y); - on_inline_edit(px, swdeg, "Angle", + on_inline_edit(px, swdeg, _u8L("Angle (°)"), [this, ei](double v) { set_arc_sweep(ei, v); }, []() {}); } @@ -1830,7 +2008,8 @@ void DesignSketchTool::set_arc_sweep(int ei, double deg) if (ei < 0 || ei >= int(m_entities.size())) return; SketchEntity& e = m_entities[ei]; if (e.type != SketchEntity::Type::Arc || e.radius < 1e-6) return; - double sweep = std::max(1e-3, std::min(deg, 359.999)) * M_PI / 180.0; + if (!(deg > 0.0 && deg < 360.0)) { show_refusal(_u8L("The angle must be between 0° and 360°")); return; } + double sweep = deg * M_PI / 180.0; const double sign = (e.end_angle >= e.start_angle) ? 1.0 : -1.0; e.end_angle = e.start_angle + sign * sweep; e.p1 = e.center + e.radius * Vec2d(std::cos(e.end_angle), std::sin(e.end_angle)); @@ -1901,7 +2080,7 @@ void DesignSketchTool::open_ellipse_axis_editor(int ei, bool major) if (e.type != SketchEntity::Type::Ellipse && e.type != SketchEntity::Type::EllipseArc) return; const double v = major ? e.radius : e.rminor; const wxPoint px(m_last_mouse_x, m_last_mouse_y); - on_inline_edit(px, v, major ? "Major" : "Minor", + on_inline_edit(px, v, major ? _u8L("Semi-major axis") : _u8L("Semi-minor axis"), [this, ei, major](double nv) { set_ellipse_axis(ei, major, nv); }, []() {}); } @@ -1909,11 +2088,16 @@ void DesignSketchTool::open_ellipse_axis_editor(int ei, bool major) // Set a semi-axis to `v`: major -> e.radius, minor -> e.rminor; keep OCCT a >= b. void DesignSketchTool::set_ellipse_axis(int ei, bool major, double v) { - if (ei < 0 || ei >= int(m_entities.size()) || v < 1e-6) return; + if (ei < 0 || ei >= int(m_entities.size())) return; SketchEntity& e = m_entities[ei]; if (e.type != SketchEntity::Type::Ellipse && e.type != SketchEntity::Type::EllipseArc) return; - if (major) e.radius = std::max(v, e.rminor); - else e.rminor = std::min(v, e.radius); + if (!(v > 0.0)) { show_refusal(_u8L("The value must be greater than zero")); return; } + if (major ? v < e.rminor : v > e.radius) { + show_refusal(_u8L("The semi-major axis cannot be shorter than the semi-minor axis")); + return; + } + if (major) e.radius = v; + else e.rminor = v; if (e.type == SketchEntity::Type::EllipseArc) { // endpoints ride the reshaped frame e.p0 = ellipse_point(e.center, e.radius, e.rminor, e.rotation, e.start_angle); e.p1 = ellipse_point(e.center, e.radius, e.rminor, e.rotation, e.end_angle); @@ -1928,7 +2112,8 @@ void DesignSketchTool::set_ellipsearc_sweep(int ei, double deg) if (ei < 0 || ei >= int(m_entities.size())) return; SketchEntity& e = m_entities[ei]; if (e.type != SketchEntity::Type::EllipseArc) return; - const double sweep = std::max(1e-3, std::min(deg, 359.999)) * M_PI / 180.0; + if (!(deg > 0.0 && deg < 360.0)) { show_refusal(_u8L("The angle must be between 0° and 360°")); return; } + const double sweep = deg * M_PI / 180.0; const double sign = (e.end_angle >= e.start_angle) ? 1.0 : -1.0; e.end_angle = e.start_angle + sign * sweep; e.p1 = ellipse_point(e.center, e.radius, e.rminor, e.rotation, e.end_angle); @@ -1996,7 +2181,7 @@ void DesignSketchTool::open_rounded_rect_editor(int fi, int which) const double r = f.param; const double v = (which == 0) ? w : (which == 1) ? h : r; const wxPoint px(m_last_mouse_x, m_last_mouse_y); - on_inline_edit(px, v, which == 0 ? "Width" : which == 1 ? "Height" : "Radius", + on_inline_edit(px, v, which == 0 ? _u8L("Width") : which == 1 ? _u8L("Height") : _u8L("Radius"), [this, fi, which](double nv) { const Feature& g = m_features[fi]; double gw = std::abs(g.c1.x() - g.c0.x()); @@ -2016,8 +2201,11 @@ void DesignSketchTool::set_rounded_rect(int fi, double w, double h, double r) if (fi < 0 || fi >= int(m_features.size())) return; Feature& f = m_features[fi]; if (f.begin < 0 || f.end > int(m_entities.size()) || f.end <= f.begin) return; - w = std::max(w, 1e-3); h = std::max(h, 1e-3); - r = std::max(1e-3, std::min(r, std::min(w, h) * 0.5 - 1e-4)); + if (!(w > 0.0 && h > 0.0 && r > 0.0)) { show_refusal(_u8L("The value must be greater than zero")); return; } + if (r >= std::min(w, h) * 0.5) { + show_refusal(_u8L("The corner radius must be less than half the shorter side")); + return; + } const double xmin = std::min(f.c0.x(), f.c1.x()), ymin = std::min(f.c0.y(), f.c1.y()); const double xmax = xmin + w, ymax = ymin + h; std::vector rebuilt = rounded_rect_entities(xmin, ymin, xmax, ymax, r); @@ -2037,12 +2225,12 @@ void DesignSketchTool::open_arc_slot_editor(int fi, bool radius) const double Rc = (f.c1 - f.c0).norm(); const double v = radius ? Rc : (2.0 * f.param); // width quote shows the FULL width const wxPoint px(m_last_mouse_x, m_last_mouse_y); - on_inline_edit(px, v, radius ? "Radius" : "Width", + on_inline_edit(px, v, radius ? _u8L("Radius") : _u8L("Width"), [this, fi, radius](double nv) { const Feature& g = m_features[fi]; const double gRc = (g.c1 - g.c0).norm(); if (radius) set_arc_slot(fi, nv, g.param); - else set_arc_slot(fi, gRc, std::max(1e-3, nv * 0.5)); // full width -> half + else set_arc_slot(fi, gRc, nv * 0.5); // full width -> half }, []() {}); } @@ -2061,8 +2249,11 @@ void DesignSketchTool::set_arc_slot(int fi, double Rc, double w) Vec2d dirE = Ec - center; if (dirS.squaredNorm() < 1e-12 || dirE.squaredNorm() < 1e-12) return; dirS.normalize(); dirE.normalize(); - Rc = std::max(Rc, 2e-3); - w = std::max(1e-3, std::min(w, Rc - 1e-3)); // make_arc_slot needs w < Rc + if (!(Rc > 0.0 && w > 0.0)) { show_refusal(_u8L("The value must be greater than zero")); return; } + if (w >= Rc) { // make_arc_slot needs w < Rc + show_refusal(_u8L("The slot is too wide for its radius — the width must be less than twice the radius")); + return; + } std::vector rebuilt = make_arc_slot(center, center + Rc * dirS, center + Rc * dirE, w); if (int(rebuilt.size()) != 4) return; @@ -2075,20 +2266,20 @@ void DesignSketchTool::set_arc_slot(int fi, double Rc, double w) } // Open the inline editor for a straight slot's dimension: which 0 = inter-centre distance, -// 1 = radius (half-width), 2 = centreline angle. Drives set_slot / set_slot_angle geometrically. +// 1 = full width (the arc slot's convention), 2 = centreline angle. Drives set_slot / set_slot_angle geometrically. void DesignSketchTool::open_slot_editor(int fi, int which) { if (fi < 0 || fi >= int(m_features.size()) || !on_inline_edit) return; const Feature& f = m_features[fi]; const Vec2d d = f.c1 - f.c0; double deg = std::atan2(d.y(), d.x()) * 180.0 / M_PI; if (deg < 0.0) deg += 360.0; - const double v = (which == 0) ? d.norm() : (which == 1) ? f.param : deg; + const double v = (which == 0) ? d.norm() : (which == 1) ? 2.0 * f.param : deg; const wxPoint px(m_last_mouse_x, m_last_mouse_y); - on_inline_edit(px, v, which == 0 ? "Length" : which == 1 ? "Radius" : "Angle", + on_inline_edit(px, v, which == 0 ? _u8L("Length") : which == 1 ? _u8L("Width") : _u8L("Angle (°)"), [this, fi, which](double nv) { const Feature& g = m_features[fi]; if (which == 0) set_slot(fi, nv, g.param); - else if (which == 1) set_slot(fi, (g.c1 - g.c0).norm(), std::max(1e-3, nv)); + else if (which == 1) set_slot(fi, (g.c1 - g.c0).norm(), nv * 0.5); // full width -> half else set_slot_angle(fi, nv); }, []() {}); @@ -2105,8 +2296,7 @@ void DesignSketchTool::set_slot(int fi, double length, double w) Vec2d dir = f.c1 - f.c0; if (dir.squaredNorm() < 1e-12) return; dir.normalize(); - length = std::max(length, 2e-3); - w = std::max(1e-3, w); + if (!(length > 0.0 && w > 0.0)) { show_refusal(_u8L("The value must be greater than zero")); return; } const Vec2d c0 = f.c0, c1 = f.c0 + length * dir; std::vector rebuilt = make_slot(c0, c1, w); if (int(rebuilt.size()) != 4) return; @@ -2187,10 +2377,18 @@ void DesignSketchTool::set_dimension_value(double v) { if (m_pending_dim < 0 || m_pending_dim >= int(m_dimensions.size())) return; DimAnnot& a = m_dimensions[m_pending_dim]; + const bool positive = a.kind == DimType::Length || a.kind == DimType::Radius || a.kind == DimType::Diameter; + if (!std::isfinite(v) || (positive && v <= 0.0) || (!positive && a.kind != DimType::Angle && v < 0.0)) { + show_refusal(positive ? _u8L("The value must be greater than zero") + : _u8L("The value must not be negative (zero means coincident)")); + return; + } a.value = v; if (a.con >= 0 && a.con < int(m_constraints.size())) m_constraints[a.con] = constraint_for(a); resolve_live(); + if (!m_solve_ok) + notify(format(_u8L("%1% cannot be satisfied — the sketch is over-constrained"), dimtype_title(a.kind))); m_pending_dim = -1; } @@ -2204,18 +2402,23 @@ std::string DesignSketchTool::dim_text(const DimAnnot& a) const char buf[32]; const char* prefix = (a.kind == DimType::Diameter) ? "\xC3\x98" // 'Ø' : (a.kind == DimType::Radius) ? "R" : ""; - const char* suffix = (a.kind == DimType::Angle) ? "\xC2\xB0" : ""; // '°' - std::snprintf(buf, sizeof(buf), "%s%.1f%s", prefix, a.value, suffix); + // Two decimals, trailing zeros dropped: the same precision the HUD readout uses, so a label + // and the readout never show the same length two ways. + std::snprintf(buf, sizeof(buf), "%.2f", a.value); // Force the international (en) decimal point: wx sets LC_NUMERIC to the user - // locale at startup, so snprintf("%.1f") can emit a comma. Normalise it. + // locale at startup, so snprintf can emit a comma. Normalise it. for (char& ch : buf) if (ch == ',') ch = '.'; - std::string out(buf); - if (a.kind != DimType::Angle) { - const bool use_in = wxGetApp().app_config->get_bool("use_inches"); - out += use_in ? " in" : " mm"; + std::string num(buf); + if (num.find('.') != std::string::npos) { + while (num.back() == '0') num.pop_back(); + if (num.back() == '.') num.pop_back(); } - return out; + if (num == "-0") num = "0"; + // The Design tab models in millimetres whatever the slicer's inch preference says: the + // kernel, the value fields and the readouts are all mm. The suffix used to follow that + // preference while the number did not, so an inch user read "25.4 in" for one inch. + return std::string(prefix) + num + (a.kind == DimType::Angle ? "\xC2\xB0" : " mm"); } void DesignSketchTool::apply_segment_length(double len) @@ -2284,7 +2487,6 @@ void DesignSketchTool::finish() void DesignSketchTool::begin_constrain(const SketchProfile& prof, const SketchPlane& plane) { - push_auto_close_pref(); m_plane = plane; m_mode = Mode::Constrain; m_points = prof.points; @@ -2299,7 +2501,6 @@ void DesignSketchTool::begin_constrain(const SketchProfile& prof, const SketchPl void DesignSketchTool::begin_constrain_entities(const std::vector& ents, const SketchPlane& plane) { - push_auto_close_pref(); m_plane = plane; m_mode = Mode::Constrain; m_constrain_entities = true; @@ -2397,6 +2598,28 @@ InferenceSnap DesignSketchTool::infer_at(GLCanvas3D& canvas, const wxMouseEvent& return infer_point_snap(m_entities, raw, tol); } +// Which clicks land on a snap target. Definition points (ends, corners, centres, points on a +// curve) snap; the click that only sets a SIZE (a radius, a width, a sweep direction) is free, +// so it can be placed anywhere without being dragged onto the nearest endpoint. +bool DesignSketchTool::click_snaps() const +{ + const size_t n = m_points.size(); + switch (m_mode) { + case Mode::Line: case Mode::Polyline: case Mode::TwoPointCircle: case Mode::ThreePointCircle: + case Mode::TangentArc: case Mode::BSpline: case Mode::Point: + case Mode::CornerRect: case Mode::CenterRect: + return true; + case Mode::ObliqueRect: case Mode::RoundedRect: case Mode::ThreePointArc: case Mode::Slot: + return n < 2; + case Mode::CenterArc: case Mode::ArcSlot: + return n == 1; + case Mode::CenterCircle: case Mode::Polygon: case Mode::Ellipse: case Mode::EllipseArc: + return n == 0; + default: + return false; + } +} + Vec2d DesignSketchTool::snap_vertex(GLCanvas3D& canvas, const wxMouseEvent& evt, const Vec2d& raw, bool& snapped) const { @@ -2454,7 +2677,9 @@ bool DesignSketchTool::remove_constraint_near(const Vec2d& p) bool DesignSketchTool::remove_constraint(int idx) { if (idx < 0 || idx >= int(m_constraints.size())) return false; - m_constraints.erase(m_constraints.begin() + idx); + const size_t before = m_constraints.size(); + erase_constraints([idx](int i, const SketchEntityConstraintDef&) { return i == idx; }); + if (m_constraints.size() == before) return false; // resolve_live(), not a bare solve: it is the path that recomputes the DoF, clears the // per-entity conflict flags and fires on_solve_state. Solving directly would relax the // geometry while leaving the DoF readout and any red over-constrained tint stale — the @@ -2465,39 +2690,41 @@ bool DesignSketchTool::remove_constraint(int idx) return true; } +// The endpoint roles an entity exposes to coincidence matching. ONE copy, deliberately: the two +// file-local lambdas that used to hold this had already diverged (an ellipse arc could be welded +// by the healer but never auto-inferred coincident at draw time, so the same gesture behaved +// differently depending on which path ran). +static int sketch_endpoint_roles(const SketchEntity& e, SketchPointRole out[2]) +{ + switch (e.type) { + case SketchEntity::Type::Line: + case SketchEntity::Type::Arc: + case SketchEntity::Type::BSpline: + case SketchEntity::Type::EllipseArc: + out[0] = SketchPointRole::P0; out[1] = SketchPointRole::P1; return 2; + case SketchEntity::Type::Point: + out[0] = SketchPointRole::P0; return 1; + default: return 0; // circle: centre coincidence is Concentric's job, not this one's + } +} + void DesignSketchTool::infer_auto_constraints(int base, double ang_tol_rad, double weld_tol) { const int n = int(m_entities.size()); if (base < 0 || base >= n) return; - // Endpoint roles an entity exposes for coincidence matching. - auto roles_of = [](const SketchEntity& e, SketchPointRole out[2]) -> int { - switch (e.type) { - case SketchEntity::Type::Line: out[0] = SketchPointRole::P0; out[1] = SketchPointRole::P1; return 2; - case SketchEntity::Type::Arc: out[0] = SketchPointRole::P0; out[1] = SketchPointRole::P1; return 2; - // EllipseArc was missing here while the otherwise identical roles_of in - // heal_coincidences (below) has it, so an ellipse arc's endpoints could be WELDED by the - // healer but never auto-inferred coincident at draw time -- the same gesture behaved - // differently depending on which path ran. Two copies of one rule is how that happens. - case SketchEntity::Type::EllipseArc: - out[0] = SketchPointRole::P0; out[1] = SketchPointRole::P1; return 2; - case SketchEntity::Type::BSpline:out[0] = SketchPointRole::P0; out[1] = SketchPointRole::P1; return 2; - case SketchEntity::Type::Point: out[0] = SketchPointRole::P0; return 1; - default: return 0; // circle: centre coincidence handled by Concentric, not here - } - }; // 1) Coincident between a new endpoint and any (co-located) endpoint of another // entity. snap_vertex already drove the coordinates together; this records it // so a re-solve keeps the loop closed. std::vector coincs; for (int i = base; i < n; ++i) { - SketchPointRole ir[2]; const int ni = roles_of(m_entities[i], ir); + SketchPointRole ir[2]; const int ni = sketch_endpoint_roles(m_entities[i], ir); for (int a = 0; a < ni; ++a) { Vec2d pa; if (!point_at(i, ir[a], pa)) continue; for (int j = 0; j < n; ++j) { if (j == i) continue; - SketchPointRole jr[2]; const int nj = roles_of(m_entities[j], jr); + SketchPointRole jr[2]; const int nj = sketch_endpoint_roles(m_entities[j], jr); for (int b = 0; b < nj; ++b) { if (j >= base && j < i) continue; // avoid duplicate (i,j)/(j,i) Vec2d pb; if (!point_at(j, jr[b], pb)) continue; @@ -3229,9 +3456,122 @@ void DesignSketchTool::set_solid_pick(const std::vector* bodies, const m_solid_bodies = bodies; m_solid_mesh = mesh; m_solid_tri_face = tri_face; m_solid_tri_body = tri_body; m_solid_visible = visible; m_solid_xform = xform; } + refresh_body_edges(); clear_solid_selection(); } +// The chord tolerance body edges are sampled at: a thousandth of the body's size, so round edges +// stay round at any zoom that shows the whole body, without sampling a large import into millions +// of segments. +static double display_edge_tol(const TopoDS_Shape& shape) +{ + Bnd_Box box; + BRepBndLib::Add(shape, box); + return std::max(1e-3 * (box.IsVoid() ? 1.0 : std::sqrt(box.SquareExtent())), 0.005); +} + +void DesignSketchTool::refresh_body_edges() +{ + const size_t n = m_solid_bodies != nullptr ? m_solid_bodies->size() : 0; + m_body_edges.resize(n); + m_body_edges_key.resize(n, nullptr); + 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) + continue; + const TopoDS_Shape& shape = (*m_solid_bodies)[b].shape; + m_body_edges_tol[b] = display_edge_tol(shape); + try { + m_body_edges[b] = GeometryEngine::display_edges(shape, m_body_edges_tol[b]); + } catch (const Standard_Failure&) { + m_body_edges[b].clear(); // an unsampleable edge costs its body the lines, nothing else + } + } +} + +// 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 +{ + 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(); +} + +// View-facing ribbons along a body's polylines (shape coordinates), `hw` either side of the line +// and moved by `pull` toward the eye, so a line wins the depth test against the faces meeting at +// it while a face in front of it still hides it. +void DesignSketchTool::append_ribbons(GLModel::Geometry& g, int body, const std::vector>& polylines, + const Vec3d& vd, const Vec3d& pull, double hw) const +{ + g.format = { GLModel::Geometry::EPrimitiveType::Triangles, GLModel::Geometry::EVertexLayout::P3 }; + unsigned int base = unsigned(g.vertices_count()); + for (const std::vector& pl : polylines) + for (size_t s = 1; s < pl.size(); ++s) { + const Vec3d a = body_xform_pt(body, pl[s - 1]) + pull, c = body_xform_pt(body, pl[s]) + pull; + Vec3d dir = c - a; if (dir.norm() < 1e-9) continue; dir.normalize(); + Vec3d off = dir.cross(vd); + if (off.norm() < 1e-9) continue; // edge seen end-on: a point, nothing to draw + off = off.normalized() * hw; + g.add_vertex((Vec3f)(a + off).cast()); + g.add_vertex((Vec3f)(c + off).cast()); + g.add_vertex((Vec3f)(c - off).cast()); + g.add_vertex((Vec3f)(a - off).cast()); + g.add_triangle(base, base + 1, base + 2); + g.add_triangle(base, base + 2, base + 3); base += 4; + } +} + +// Ribbons tested against the scene's depth without writing it, and blended. +static void render_ribbons(GLModel& model, GLModel::Geometry&& g, const ColorRGBA& colour) +{ + if (g.is_empty()) + return; + glsafe(::glEnable(GL_DEPTH_TEST)); + glsafe(::glDepthFunc(GL_LEQUAL)); + glsafe(::glDepthMask(GL_FALSE)); + glsafe(::glEnable(GL_BLEND)); + glsafe(::glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)); + model.reset(); + model.init_from(std::move(g)); + model.set_color(colour); + model.render(); + glsafe(::glDepthMask(GL_TRUE)); + glsafe(::glDepthFunc(GL_LESS)); + glsafe(::glDisable(GL_BLEND)); + glsafe(::glDisable(GL_DEPTH_TEST)); +} + +void DesignSketchTool::render_body_edges() +{ + if (m_body_edges_hidden || m_solid_bodies == nullptr) + return; + const Camera& cam = wxGetApp().plater()->get_camera(); + const Vec3d vd = cam.get_dir_forward(); + const double px = 1.0 / std::max(cam.get_zoom(), 1e-6); + const double hw = 1.0 * px; // ~2 px wide: at 1.5 the lines read as hairlines + const Vec3d pull = -vd * (3.0 * px); + // Two passes: the edges of a body faded by body focus are fainter, like the body itself. + for (int pass = 0; pass < 2; ++pass) { + GLModel::Geometry g; + for (int b = 0; b < int(m_body_edges.size()); ++b) { + if (m_solid_visible != nullptr && b < int(m_solid_visible->size()) && !(*m_solid_visible)[b]) + continue; + const bool faded = m_pick_only_body >= 0 && m_pick_only_body < int(m_body_edges.size()) + && b != m_pick_only_body; + if (faded != (pass == 1)) + continue; + append_ribbons(g, b, m_body_edges[b], vd, pull, hw); + } + render_ribbons(m_body_edges_model, std::move(g), ColorRGBA(0.08f, 0.09f, 0.11f, pass == 0 ? 0.85f : 0.25f)); + } +} + // Map a point sampled from the (untransformed) OCCT body shape through the body's display // transform, so edge picking/highlight track a moved body. The pick MESH is already // transformed by the host; only OCCT-sampled edges need this. @@ -3264,12 +3604,22 @@ void DesignSketchTool::clear_solid_selection() m_solid_sel = SolidSel::None; m_sel_body = m_sel_face = m_sel_edge = -1; m_sel_edge_pts.clear(); + m_sel_edges_more.clear(); + m_sel_edges_more_pts.clear(); // The pre-highlight names a face/edge/vertex by index into a shape that a recompute has just // rebuilt, so it expires with the selection it was a promise about. Left behind it would keep // glowing on whatever now sits at those indices — a real entity, but not the one meant. m_pre = SolidPick{}; } +std::vector DesignSketchTool::selected_edges() const +{ + if (m_solid_sel != SolidSel::Edge || m_sel_edge < 0) return {}; + std::vector out = m_sel_edges_more; + out.push_back(m_sel_edge); + return out; +} + void DesignSketchTool::select_body(int body) { // Hidden bodies aren't highlighted (the tint overlay would otherwise draw over a @@ -3282,7 +3632,123 @@ void DesignSketchTool::select_body(int body) m_sel_body = body; m_sel_face = m_sel_edge = -1; m_sel_edge_pts.clear(); - m_solid_sel = SolidSel::Whole; // render_solid_highlight tints just this body + m_sel_edges_more.clear(); + m_sel_edges_more_pts.clear(); + m_solid_sel = SolidSel::Whole; // the canvas fills just this body, render_solid_highlight outlines it +} + +void DesignSketchTool::set_highlight_faces(const std::vector>& faces) +{ + std::map> by_body; + for (const auto& [b, f] : faces) + if (body_key(b) != nullptr) + by_body[b].push_back(f); + // Reuse each body's entry, so re-sending the same faces resamples nothing. + std::vector next; + for (auto& [b, fs] : by_body) { + FaceHighlight h; + for (FaceHighlight& old : m_hl_faces) + if (old.body == b) + h = std::move(old); + cached_face_highlight(h, b, std::move(fs)); + next.push_back(std::move(h)); + } + m_hl_faces = std::move(next); +} + +DesignSketchTool::FaceHighlight DesignSketchTool::make_face_highlight(int body, std::vector faces) const +{ + FaceHighlight h; + h.body = 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()) + 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; + 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; + } + TopoDS_Compound picked; + BRep_Builder builder; + builder.MakeCompound(picked); + for (int f : h.faces) + 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)); + } catch (const Standard_Failure&) { + h.edges.clear(); // an edge that cannot be sampled costs the outline, never the fill + } + return h; +} + +std::vector> DesignSketchTool::picked_faces() const +{ + std::vector> out; + if (body_key(m_sel_body) == 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) + out.emplace_back(m_sel_body, f); + return out; +} + +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)) + for (int f : h.faces) + out.emplace_back(h.body, f); + std::sort(out.begin(), out.end()); + out.erase(std::unique(out.begin(), out.end()), out.end()); + return out; +} + +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)) + cache = make_face_highlight(body, std::move(faces)); + return cache; +} + +// The edges of selected faces, cased: a dark band under a selection-coloured line, so the +// selection's outline reads on a body of any colour, the selection colour included. The faces +// themselves are filled by the canvas (DesignCanvas::rebuild_bodies). `quiet` is the hover's +// 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)) + return; // hidden, or the body was rebuilt and these face ids are stale + + const Camera& cam = wxGetApp().plater()->get_camera(); + // View-facing ribbons like the body edges, pulled a pixel further toward the eye so they win + // over the dark body edges along the same curves. + const Vec3d vd = cam.get_dir_forward(); + const double px = 1.0 / std::max(cam.get_zoom(), 1e-6); + const Vec3d pull = -vd * (4.0 * px); + auto draw = [&](double hw_px, const ColorRGBA& colour) { + GLModel::Geometry g; + append_ribbons(g, h.body, h.edges, vd, pull, hw_px * px); + render_ribbons(m_solid_edge_model, std::move(g), colour); + }; + if (quiet) { + draw(1.0, design_selection_color(0.8f)); + } else { + draw(2.25, ColorRGBA(0.05f, 0.06f, 0.08f, 1.0f)); + draw(1.25, design_selection_color()); + } } // Pick tracing. Selection failures on a real desktop have repeatedly turned out to be an @@ -3354,6 +3820,7 @@ void DesignSketchTool::pick_bodies_in_rectangle() dp_pick_trace("rubber band -> body=%d (%d samples)", best, best_n); if (on_solid_selection_changed) on_solid_selection_changed(int(m_solid_sel), m_sel_body, m_sel_face, m_sel_edge); + if (best < 0 && on_empty_pick) on_empty_pick(); } // Resolve what a pick at (mx,my) would take. CONST, and it writes only into `out`: the hover @@ -3502,6 +3969,42 @@ bool DesignSketchTool::handle_solid_click(GLCanvas3D& canvas, const wxMouseEvent const int prev_body = m_sel_body, prev_face = m_sel_face, prev_edge = m_sel_edge; const Vec3d prev_vtx = m_sel_vertex_pt; + // SHIFT/CTRL+CLICK ON AN EDGE BUILDS AN EDGE SET, the same modifiers that extend a sketch + // selection. Only edges of the one body already picked: a dress-up acts on one body, and a + // set spanning two could not be applied. An edge already in the set leaves it; the last one + // leaving clears the selection. No escalation to the whole body here — a modified click is + // always about the set. + const bool extend = evt.ShiftDown() || evt.ControlDown() || evt.CmdDown(); + if (extend && p.kind == SolidSel::Edge && prev_kind == SolidSel::Edge && p.body == prev_body) { + auto more = std::find(m_sel_edges_more.begin(), m_sel_edges_more.end(), p.edge); + if (p.edge == prev_edge) { + if (m_sel_edges_more.empty()) { + clear_solid_selection(); + } else { // the previous pick becomes the current one + m_sel_edge = m_sel_edges_more.back(); + m_sel_edge_pts = std::move(m_sel_edges_more_pts.back()); + m_sel_edges_more.pop_back(); + m_sel_edges_more_pts.pop_back(); + } + } else if (more != m_sel_edges_more.end()) { + const size_t k = size_t(more - m_sel_edges_more.begin()); + m_sel_edges_more.erase(more); + m_sel_edges_more_pts.erase(m_sel_edges_more_pts.begin() + k); + } else { + m_sel_edges_more.push_back(prev_edge); + m_sel_edges_more_pts.push_back(std::move(m_sel_edge_pts)); + m_sel_edge = p.edge; + m_sel_edge_pts = std::move(p.edge_pts); + m_sel_face = p.face; + } + dp_pick_trace("edge set -> %zu edge(s), current %d", selected_edges().size(), m_sel_edge); + if (on_solid_selection_changed) + on_solid_selection_changed(int(m_solid_sel), m_sel_body, m_sel_face, m_sel_edge); + return true; + } + m_sel_edges_more.clear(); + m_sel_edges_more_pts.clear(); + m_sel_body = p.body; m_sel_face = p.face; m_sel_edge = p.edge; @@ -3568,53 +4071,19 @@ bool DesignSketchTool::update_solid_hover(GLCanvas3D& canvas, const wxMouseEvent return true; } -// One highlight, drawn from explicit arguments rather than from the selection members, so the -// committed selection and the hover pre-highlight cannot drift apart in how they look. alpha_mul -// scales every layer at once: the pre-highlight is the same shape in the same place, quieter. -void DesignSketchTool::render_solid_sel(SolidSel kind, int body, int face, - const std::vector& edge_pts, - const Vec3d& vertex_pt, - const ColorRGBA& rgb, float alpha_mul) +// An edge or vertex highlight, drawn from explicit arguments rather than from the selection +// members, so the committed selection and the hover pre-highlight cannot drift apart in how they +// look. Faces and bodies are filled by the canvas and outlined by render_face_outline. +void DesignSketchTool::render_solid_sel(SolidSel kind, const std::vector& edge_pts, + const Vec3d& vertex_pt, const ColorRGBA& rgb) { using EPT = GLModel::Geometry::EPrimitiveType; using EVL = GLModel::Geometry::EVertexLayout; // Opaque: the edge ribbon and the vertex square render with GL_BLEND OFF, so an alpha below 1 - // here would be silently ignored. Those two are quietened by a MUTED rgb from the caller - // instead; alpha_mul only reaches the face fill, which is the one layer that is blended. + // here would be silently ignored. const ColorRGBA cyan(rgb.r(), rgb.g(), rgb.b(), 1.0f); - // Whole tints the picked BODY (all its triangles, lighter alpha); Face tints just the - // picked face on that body. Both filter by `body` so other bodies stay untinted. - if ((kind == SolidSel::Face || kind == SolidSel::Whole) - && m_solid_mesh != nullptr && m_solid_tri_body != nullptr && body >= 0) { - const bool face_only = (kind == SolidSel::Face); - const indexed_triangle_set& its = m_solid_mesh->its; - GLModel::Geometry g; g.format = { EPT::Triangles, EVL::P3 }; - unsigned int base = 0; - for (size_t i = 0; i < its.indices.size(); ++i) { - if (i >= m_solid_tri_body->size() || (*m_solid_tri_body)[i] != body) continue; - if (face_only && (m_solid_tri_face == nullptr || i >= m_solid_tri_face->size() - || (*m_solid_tri_face)[i] != face)) continue; - const auto& idx = its.indices[i]; - for (int j = 0; j < 3; ++j) g.add_vertex(its.vertices[idx(j)]); - g.add_triangle(base, base + 1, base + 2); base += 3; - } - if (base > 0) { - glsafe(::glEnable(GL_DEPTH_TEST)); - glsafe(::glEnable(GL_POLYGON_OFFSET_FILL)); - glsafe(::glPolygonOffset(-2.0f, -2.0f)); - glsafe(::glEnable(GL_BLEND)); - glsafe(::glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)); - m_solid_face_model.reset(); - m_solid_face_model.init_from(std::move(g)); - m_solid_face_model.set_color(ColorRGBA(rgb.r(), rgb.g(), rgb.b(), - (face_only ? 0.40f : 0.22f) * alpha_mul)); - m_solid_face_model.render(); - glsafe(::glDisable(GL_BLEND)); - glsafe(::glDisable(GL_POLYGON_OFFSET_FILL)); - glsafe(::glDisable(GL_DEPTH_TEST)); - } - } else if (kind == SolidSel::Edge && edge_pts.size() >= 2) { + if (kind == SolidSel::Edge && edge_pts.size() >= 2) { const Camera& cam = wxGetApp().plater()->get_camera(); const Vec3d vd = cam.get_dir_forward(); const double hw = 2.0 / std::max(cam.get_zoom(), 1e-6); // ~2 px ribbon half-width @@ -3666,13 +4135,16 @@ void DesignSketchTool::render_solid_sel(SolidSel kind, int body, int face, } } -// Cyan overlay for the picked face / edge / vertex, plus the quieter pre-highlight of whatever -// the pointer is currently over. Whole-solid tint is the panel's job (set_body_highlight). +// The faces of the Feature tree's selected feature, the picked body / face / edge / vertex, and +// the quieter pre-highlight of whatever the pointer is currently over. // Called from render() while no sketch session is active. void DesignSketchTool::render_solid_highlight() { const ColorRGBA sel_cyan = design_selection_color(); + for (const FaceHighlight& h : m_hl_faces) + render_face_outline(h, false); + // The pre-highlight goes FIRST so the committed selection paints over it where the two // overlap — what you HAVE outranks what you would get. Suppressed entirely when they are the // same thing: two coats of the same colour on the same face reads as a rendering fault, and @@ -3682,14 +4154,27 @@ void DesignSketchTool::render_solid_highlight() : m_pre.kind == SolidSel::Edge ? m_pre.edge == m_sel_edge : m_pre.kind == SolidSel::Face ? m_pre.face == m_sel_face : true); - if (m_pre.kind != SolidSel::None && !pre_is_sel) - // Desaturated toward white rather than a second hue: a distinct colour would read as a - // distinct KIND of selection, when it is the same selection one moment earlier. - render_solid_sel(m_pre.kind, m_pre.body, m_pre.face, m_pre.edge_pts, m_pre.vertex_pt, - design_selection_color(), 0.45f); // hover = the same colour, quieter + if (m_pre.kind == SolidSel::Face && !pre_is_sel) + // A hovered face gets its outline alone: a promise, not a selection. + render_face_outline(cached_face_highlight(m_pre_hl, m_pre.body, { m_pre.face }), true); + else if (m_pre.kind != SolidSel::None && !pre_is_sel) + // The selection's own colour: a second hue would read as a distinct KIND of selection, + // when it is the same selection one moment earlier. + render_solid_sel(m_pre.kind, m_pre.edge_pts, m_pre.vertex_pt, sel_cyan); - render_solid_sel(m_solid_sel, m_sel_body, m_sel_face, m_sel_edge_pts, m_sel_vertex_pt, - sel_cyan, 1.0f); + // A picked body or face looks like the Feature tree's faces: the canvas fills it, this + // outlines it. + if (const std::vector> picked = picked_faces(); !picked.empty()) { + std::vector faces; + for (const auto& bf : picked) + faces.push_back(bf.second); + render_face_outline(cached_face_highlight(m_sel_hl, m_sel_body, std::move(faces)), false); + return; + } + render_solid_sel(m_solid_sel, m_sel_edge_pts, m_sel_vertex_pt, sel_cyan); + if (m_solid_sel == SolidSel::Edge) + for (const std::vector& pts : m_sel_edges_more_pts) + render_solid_sel(SolidSel::Edge, pts, Vec3d::Zero(), sel_cyan); } // Datum/reference planes (Plane feature) have no solid; draw each as a translucent indigo @@ -6070,12 +6555,13 @@ void DesignSketchTool::drag_cut_arrow(GLCanvas3D& canvas, const wxMouseEvent& ev } void DesignSketchTool::set_revolve_gizmo(const SketchPlane& plane, const Vec2d& centroid, - int axis_sel, double angle, bool flip) + const Vec3d& axis_origin, const Vec3d& axis_dir, + double angle, bool flip) { - const Vec3d ax = (axis_sel == 1 ? plane.y_axis : plane.x_axis).normalized(); + const Vec3d ax = axis_dir.normalized(); const Vec3d cw = plane.to_world(centroid); - const double axial = (cw - plane.origin).dot(ax); - m_rv_center = plane.origin + axial * ax; // foot of the centroid on the axis line + const double axial = (cw - axis_origin).dot(ax); + m_rv_center = axis_origin + axial * ax; // foot of the centroid on the axis line Vec3d ref = cw - m_rv_center; // perpendicular to ax by construction double r = ref.norm(); if (r < 1e-6) { ref = plane.normal.normalized(); r = std::max(plane.normal.norm(), 1.0); } @@ -6142,6 +6628,16 @@ void DesignSketchTool::render_revolve_gizmo() draw_strokes(m_rv_stroke_model, segs, std::max(0.8 * upp, 1e-4), arcc); DimAnnot da; da.kind = DimType::Angle; da.value = m_rv_angle; draw_text(m_line_model, dim_text(da), tip * 1.14, th, arcc); + // The axis itself, dashed, past both ends of the sweep: which line is being revolved about + // is the one thing the card's list cannot show. + SketchPlane ap; ap.origin = m_rv_center; ap.x_axis = m_rv_axis; ap.y_axis = m_rv_ref; + ap.normal = m_rv_axis.cross(m_rv_ref); + m_plane = ap; + std::vector> dashes; + const double L = 1.6 * r, dash = std::max(r * 0.08, th * 0.5); + for (double u = -L; u < L; u += 2.0 * dash) + dashes.emplace_back(Vec2d(u, 0.0), Vec2d(std::min(u + dash, L), 0.0)); + draw_strokes(m_rv_stroke_model, dashes, std::max(0.8 * upp, 1e-4), ColorRGBA(1.0f, 0.55f, 0.1f, 1.0f)); m_plane = saved; } @@ -6525,6 +7021,12 @@ DesignSketchTool::region_loops(const std::vector& ents) const } if (best >= 0) regions[best].holes.push_back(int(i)); } + // Closed is not the same as bounding one region: the chainer only asks whether the ends + // meet. A loop that crosses itself, or turns straight back along itself, meets at every + // joint and still cannot be built — the kernel refuses a crossing, and a fold back is never + // what was meant. Say so here, where the region is drawn, instead of after an extrude. + for (RegionLoop& r : regions) + r.defect = sketch_loop_defect(ents, r.ents, r.defect_at); return regions; } @@ -6995,8 +7497,25 @@ void DesignSketchTool::draw_dim_label(const std::string& txt, const Vec2d& plane ImGui::BringWindowToDisplayFront(ImGui::GetCurrentWindow()); ImGui::AlignTextToFramePadding(); ImDrawList* dl = ImGui::GetWindowDrawList(); - const ImVec2 pos = ImGui::GetCursorScreenPos(); const ImVec2 ts = ImGui::CalcTextSize(txt.c_str()); + // Push this label clear of any already drawn this frame (see m_label_rects). + { + ImVec2 wp = ImGui::GetWindowPos(); + const ImVec2 ws = ImGui::GetWindowSize(); + for (int guard = 0; guard < 8; ++guard) { + bool hit = false; + for (const LabelRect& r : m_label_rects) { + const double ix = std::min(double(wp.x) + ws.x, r.x + r.w) - std::max(double(wp.x), r.x); + const double iy = std::min(double(wp.y) + ws.y, r.y + r.h) - std::max(double(wp.y), r.y); + if (ix > 2.0 && iy > 2.0) { hit = true; break; } + } + if (!hit) break; + wp.y += ws.y + 2.0f; // straight down, one label per step + ImGui::SetWindowPos(wp, ImGuiCond_Always); + } + m_label_rects.push_back({double(wp.x), double(wp.y), double(ws.x), double(ws.y)}); + } + const ImVec2 pos = ImGui::GetCursorScreenPos(); const ImGuiStyle& st = ImGui::GetStyle(); dl->AddRectFilled(ImVec2(pos.x - st.FramePadding.x, pos.y + st.FramePadding.y), ImVec2(pos.x + ts.x + 2.0f * st.FramePadding.x, @@ -7015,6 +7534,13 @@ void DesignSketchTool::draw_text(GLModel& /*model*/, const std::string& s, const // 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. + // + // 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 + // every value twice while the auto-edit chain runs (confirmed for rounded rect W/H/R, and + // the chain is shared, so Rect/Circle/Slot/Polygon/ArcSlot behave identically). Position + // compare is exact — the chain stores the very same Vec2d the label draws from. + if ((center - m_autoedit_label_pos).squaredNorm() < 1e-9) return; draw_dim_label(s, center); } @@ -7126,7 +7652,12 @@ void DesignSketchTool::render_dimensions(double unit_per_px) const double th = std::max(15.0 * unit_per_px, 1e-4); for (size_t di = 0; di < m_dimensions.size(); ++di) { Vec2d label; - if (draw_dim_quote(m_dimensions[di], th, dimcol, label)) + // A dimension whose driving constraint the solver rejected is drawn in the refusal + // colour: its label is showing a value the geometry does not have, and this is the only + // thing on screen that points at WHICH number is the lie. + const bool bad = std::find(m_bad_dims.begin(), m_bad_dims.end(), int(di)) != m_bad_dims.end(); + const ColorRGBA col = bad ? ColorRGBA(0.92f, 0.35f, 0.35f, 1.0f) : dimcol; + if (draw_dim_quote(m_dimensions[di], th, col, label)) m_dimensions[di].label_pos = label; } } @@ -7239,7 +7770,7 @@ void DesignSketchTool::render_live_quotes(double unit_per_px) // Distance-between-arc-centres + Radius quotes never registered as editable, so the // labels did nothing on click (nde #6). Draw both labels clear of the fillable face // and remember the feature so open_primary_autoedit / click-to-promote drive set_slot. - // Slot dims: (1) inter-centre distance, (2) radius (= half-width), (3) centreline angle. + // Slot dims: (1) inter-centre distance, (2) full width, (3) centreline angle. const ColorRGBA dc(0.30f, 0.88f, 0.66f, 1.0f); const double th = std::max(15.0 * unit_per_px, 1e-4); Vec2d u = f.c1 - f.c0; @@ -7251,7 +7782,7 @@ void DesignSketchTool::render_live_quotes(double unit_per_px) DimAnnot len; len.kind = DimType::Length; len.value = Lc; m_live_slot_len_label = 0.5 * (f.c0 + f.c1) + n * (w + th * 2.0); draw_text(m_line_model, dim_text(len), m_live_slot_len_label, th, dc); - DimAnnot rd; rd.kind = DimType::Radius; rd.value = w; + DimAnnot rd; rd.kind = DimType::Length; rd.value = 2.0 * w; // full width, as the arc slot m_live_slot_w_label = f.c1 + u * (w + th * 2.0); draw_text(m_line_model, dim_text(rd), m_live_slot_w_label, th, dc); double deg = std::atan2(f.c1.y() - f.c0.y(), f.c1.x() - f.c0.x()) * 180.0 / M_PI; @@ -7614,6 +8145,15 @@ void DesignSketchTool::reset_op() m_op_ghost.clear(); m_op_dragging_arrow = false; m_mirror_targets.clear(); + m_op_chain.clear(); +} + +std::vector DesignSketchTool::op_chain_entities() const +{ + std::vector out; + for (int i : m_op_chain) + if (i >= 0 && i < int(m_entities.size())) out.push_back(m_entities[i]); + return out; } // ---- Imported-art bounding-box transform gizmo (Mode::TransformArt) ---- @@ -7795,10 +8335,34 @@ void DesignSketchTool::recompute_op_ghost() Vec2d u = e.p1 - e.p0; if (u.norm() > 1e-12) u.normalize(); m_op_dir = Vec2d(-u.y(), u.x()); // left normal = +distance side } else if (e.type == SketchEntity::Type::Circle || e.type == SketchEntity::Type::Arc) { + // The same "+distance = left of travel" rule the engine uses: inward for a circle + // and a CCW arc, outward for a CW arc. The arrow pointed outward for all of them, so + // dragging it out shrank a CCW arc. + const bool ccw = e.type == SketchEntity::Type::Circle || e.end_angle >= e.start_angle; m_op_anchor = e.center + Vec2d(e.radius, 0.0); - m_op_dir = Vec2d(1, 0); + m_op_dir = Vec2d(ccw ? -1.0 : 1.0, 0.0); + } + m_op_ghost = SketchEngine::offset_entities(op_chain_entities(), m_op_value); + // In a chain the engine walks the entities in its own traversal order, so the picked + // one may be travelled backwards and "+distance = left" lands on the other side of it. + // Read the side off the ghost instead: the arrow points from the picked entity to its + // offset copy, flipped for a negative distance, so dragging it always follows the ghost. + if (m_op_chain.size() > 1 && std::abs(m_op_value) > 1e-12 && !m_op_ghost.empty()) { + double best = 1e30; Vec2d closest = m_op_anchor; // not "near": a macro in windows.h + for (const SketchEntity& g : m_op_ghost) { + bool closed = false; + const std::vector pl = entity_polyline(g, closed); + for (size_t k = 0; k + 1 < pl.size(); ++k) { + const Vec2d a = pl[k], d = pl[k + 1] - pl[k]; + const double l2 = d.squaredNorm(); + const double t = l2 > 1e-24 ? std::clamp((m_op_anchor - a).dot(d) / l2, 0.0, 1.0) : 0.0; + const Vec2d q = a + t * d; + if ((q - m_op_anchor).norm() < best) { best = (q - m_op_anchor).norm(); closest = q; } + } + } + const Vec2d v = closest - m_op_anchor; + if (v.norm() > 1e-12) m_op_dir = (m_op_value > 0 ? 1.0 : -1.0) * v.normalized(); } - m_op_ghost = SketchEngine::offset_entities({ e }, m_op_value); } else if (m_mode == Mode::Mirror) { if (m_op_a < 0 || m_mirror_targets.empty()) return; const SketchEntity& axis = m_entities[m_op_a]; @@ -7821,6 +8385,11 @@ void DesignSketchTool::op_pick(int ei) if (t != SketchEntity::Type::Line) return; // corner ops need two lines if (m_op_a < 0) m_op_a = ei; else if (ei != m_op_a) { + Vec2d cC, cBis; double cTh = 0.0; + if (!op_corner(m_op_a, ei, cC, cBis, cTh)) { // parallel or straight: no corner + notify(_u8L("Those two lines do not meet at a corner — pick two lines that do")); + return; + } m_op_b = ei; const double la = (m_entities[m_op_a].p1 - m_entities[m_op_a].p0).norm(); const double lb = (m_entities[m_op_b].p1 - m_entities[m_op_b].p0).norm(); @@ -7830,10 +8399,22 @@ void DesignSketchTool::op_pick(int ei) break; case Mode::Offset: { m_op_a = ei; - const SketchEntity& e = m_entities[ei]; - const double sz = (e.type == SketchEntity::Type::Line) ? (e.p1 - e.p0).norm() - : std::max(e.radius * 2.0, 1.0); - m_op_value = std::max(0.001, 0.1 * sz); + // The whole outline the entity belongs to, not the one segment under the pointer: a + // glyph or an imported outline is hundreds of short lines, and offsetting one of them + // gave a ghost a few hundredths of a millimetre long — no visible preview, and a typed + // distance that moved one invisible segment. Same construction state only. + m_op_chain.clear(); + for (int ci : connected_loop(ei)) + if (m_entities[ci].construction == m_entities[ei].construction) m_op_chain.push_back(ci); + if (m_op_chain.empty()) m_op_chain.push_back(ei); + // A starting distance that is visible: a twentieth of the outline's size. + Vec2d lo(1e30, 1e30), hi(-1e30, -1e30); + for (int ci : m_op_chain) { + bool closed = false; + for (const Vec2d& q : entity_polyline(m_entities[ci], closed)) { lo = lo.cwiseMin(q); hi = hi.cwiseMax(q); } + } + const double sz = (hi.x() >= lo.x()) ? std::max(hi.x() - lo.x(), hi.y() - lo.y()) : 1.0; + m_op_value = std::max(0.001, 0.05 * sz); recompute_op_ghost(); break; } @@ -7855,6 +8436,7 @@ void DesignSketchTool::op_pick(int ei) if (m_op_a >= 0) m_selection.push_back(m_op_a); if (m_op_b >= 0) m_selection.push_back(m_op_b); for (int ti : m_mirror_targets) m_selection.push_back(ti); + for (int ci : m_op_chain) if (ci != m_op_a) m_selection.push_back(ci); if (on_selection_changed) on_selection_changed(int(m_selection.size())); } @@ -7876,12 +8458,18 @@ void DesignSketchTool::drag_op_arrow(const Vec2d& target) void DesignSketchTool::open_op_editor() { if (!on_inline_edit || !op_ready() || m_mode == Mode::Mirror) return; - const double sign = (m_mode == Mode::Offset && m_op_value < 0) ? -1.0 : 1.0; const wxPoint px(m_last_mouse_x, m_last_mouse_y); - on_inline_edit(px, std::abs(m_op_value), "", - [this, sign](double v) { - m_op_value = (m_mode == Mode::Offset) ? sign * std::abs(v) : std::max(0.001, v); - // Entering a radius IS the commit. Leaving it as a preview meant the most obvious + // Offset is signed the same way the arrow drag is (the sign picks the side), so the field + // shows and accepts the sign instead of silently keeping the old one. Fillet/Chamfer sizes + // are positive: a non-positive one is refused, not clamped to a hairline. + on_inline_edit(px, m_op_value, m_mode == Mode::Offset ? _u8L("Distance") : m_mode == Mode::Fillet ? _u8L("Radius") : _u8L("Size"), + [this](double v) { + if (m_mode != Mode::Offset && v <= 0.0) { + show_refusal(_u8L("The size must be greater than zero")); + return; + } + m_op_value = v; + // Entering a radius IS the commit. Leaving it as a preview meant the most obvious // route of all — click the radius, type it, press Return — ended with the value set, // the ghost drawn, and no geometry written; the only paths that ever applied it were // finishing the whole sketch or clicking empty space, neither of which is signposted. @@ -7957,7 +8545,13 @@ void DesignSketchTool::confirm_op() const bool ok = fillet ? SketchEngine::fillet_lines(m_entities[m_op_a], m_entities[m_op_b], m_op_value, a_out, b_out, extra) : SketchEngine::chamfer_lines(m_entities[m_op_a], m_entities[m_op_b], m_op_value, a_out, b_out, extra); - if (!ok) { reset_op(); return; } + if (!ok) { + notify(fillet + ? _u8L("Fillet: the radius overruns a leg, or the two lines do not meet at a corner") + : _u8L("Chamfer: the distance overruns a leg, or the two lines do not meet at a corner")); + reset_op(); + return; + } const int a = m_op_a, b = m_op_b; m_entities[a] = a_out; m_entities[b] = b_out; const int xi = int(m_entities.size()); @@ -7972,11 +8566,12 @@ void DesignSketchTool::confirm_op() return (d.ea == e && d.ra == r) || (d.eb == e && d.rb == r); }; auto self_len = [](const SketchEntityConstraintDef& d, int e) { return d.type == CT::Distance && d.ea == e && d.eb == e; }; - auto& cs = m_constraints; - cs.erase(std::remove_if(cs.begin(), cs.end(), [&](const SketchEntityConstraintDef& d) { + const int released = erase_constraints([&](int, const SketchEntityConstraintDef& d) { return (d.type == CT::Coincident && refs(d, a, ra) && refs(d, b, rb)) || self_len(d, a) || self_len(d, b); - }), cs.end()); + }); + if (released > 0) + notify(format(_u8L("%1% constraint(s) the fillet/chamfer invalidated were removed"), released), false); auto coin = [&](R xr, int ln, R lr) { SketchEntityConstraintDef d; d.type = CT::Coincident; d.ea = xi; d.ra = xr; d.eb = ln; d.rb = lr; return d; }; if (fillet) { @@ -7993,13 +8588,20 @@ void DesignSketchTool::confirm_op() } } else if (m_mode == Mode::Offset) { const int a = m_op_a; - auto out = SketchEngine::offset_entities({ m_entities[a] }, m_op_value); - if (out.empty()) { reset_op(); return; } + auto out = SketchEngine::offset_entities(op_chain_entities(), m_op_value); + if (out.empty()) { + notify(_u8L("Offset: an ellipse or a spline has no parallel of its own kind — pick lines, arcs or circles")); + reset_op(); + return; + } const int ni = int(m_entities.size()); + const bool single = m_op_chain.size() <= 1; for (auto& o : out) m_entities.push_back(o); const SketchEntity::Type st = m_entities[a].type; SketchEntityConstraintDef d; d.ea = a; d.eb = ni; - bool emit = true; + // A chain's offset is joined and trimmed at its seams, so its entities no longer map one + // to one onto the originals: it is placed as geometry, without per-entity constraints. + bool emit = single; if (st == SketchEntity::Type::Line) d.type = CT::Parallel; else if (st == SketchEntity::Type::Arc || st == SketchEntity::Type::Circle) d.type = CT::Concentric; else emit = false; @@ -8268,20 +8870,25 @@ void DesignSketchTool::open_tf_editor_a() if (!on_inline_edit || !tf_ready()) return; const wxPoint px(m_last_mouse_x, m_last_mouse_y); double cur; - if (m_mode == Mode::Rotate || m_mode == Mode::PolarArray) cur = std::abs(m_tf_angle) * 180.0 / M_PI; + if (m_mode == Mode::Rotate || m_mode == Mode::PolarArray) cur = m_tf_angle * 180.0 / M_PI; else if (m_mode == Mode::Scale) cur = m_tf_scale; else cur = m_tf_delta.norm(); Vec2d dir = m_tf_delta; if (dir.norm() > 1e-9) dir.normalize(); else dir = Vec2d(1, 0); - const double sgn = (m_tf_angle < 0) ? -1.0 : 1.0; - on_inline_edit(px, cur, "", - [this, dir, sgn](double v) { + const char* title = (m_mode == Mode::Rotate || m_mode == Mode::PolarArray) ? L("Angle (°)") + : (m_mode == Mode::Scale) ? L("Scale factor") : L("Distance"); + on_inline_edit(px, cur, _u8L(title), + [this, dir](double v) { switch (m_mode) { - case Mode::Move: case Mode::Array: m_tf_delta = dir * v; break; - case Mode::Rotate: case Mode::PolarArray: m_tf_angle = sgn * std::abs(v) * M_PI / 180.0; break; - case Mode::Scale: m_tf_scale = std::max(1e-3, v); break; + case Mode::Move: case Mode::Array: m_tf_delta = dir * v; break; // signed: flips the direction + case Mode::Rotate: case Mode::PolarArray: m_tf_angle = v * M_PI / 180.0; break; // signed: CCW positive + case Mode::Scale: + if (v <= 0.0) { show_refusal(_u8L("The scale factor must be greater than zero")); return; } + m_tf_scale = v; + break; default: break; } - recompute_tf_ghost(); + // Enter IS the commit, the same rule the edit-op field follows. + confirm_transform(); }, []() {}); } @@ -8291,8 +8898,15 @@ void DesignSketchTool::open_tf_editor_count() if (!on_inline_edit || !tf_ready()) return; if (m_mode != Mode::Array && m_mode != Mode::PolarArray) return; const wxPoint px(m_last_mouse_x, m_last_mouse_y); - on_inline_edit(px, double(std::max(2, m_tf_count)), "", - [this](double v) { m_tf_count = std::max(2, int(v + 0.5)); recompute_tf_ghost(); }, + on_inline_edit(px, double(std::max(2, m_tf_count)), _u8L("Copies"), + [this](double v) { + if (v < 2.0 || std::abs(v - std::round(v)) > 1e-9) { + show_refusal(_u8L("The number of copies must be a whole number, 2 or more")); + return; + } + m_tf_count = int(std::round(v)); + confirm_transform(); + }, []() {}); } @@ -8380,8 +8994,7 @@ void DesignSketchTool::confirm_transform() out = SketchEngine::transform_entities({ m_entities[ti] }, Vec2d(0, 0), 0.0, m_tf_scale, m_tf_pivot); if (!out.empty()) m_entities[ti] = out[0]; } - auto& cs = m_constraints; - cs.erase(std::remove_if(cs.begin(), cs.end(), [&](const SketchEntityConstraintDef& d) { + const int released = erase_constraints([&](int, const SketchEntityConstraintDef& d) { if (!(is_target(d.ea) || is_target(d.eb) || is_target(d.ec))) return false; const bool self = (d.ea == d.eb); // self-length Distance survives translate/rotate if (mode == Mode::Move) { @@ -8405,7 +9018,9 @@ void DesignSketchTool::confirm_transform() default: return true; // size + position broken } } - }), cs.end()); + }); + if (released > 0) + notify(format(_u8L("%1% constraint(s) released by the transform"), released), false); } else if (m_mode == Mode::Array || m_mode == Mode::PolarArray) { // ADDITIVE: append copies of each subject, then bind each copy to its source. Lines // get Parallel+EqualLength (linear) or EqualLength only (polar — rotation breaks @@ -8457,7 +9072,10 @@ void DesignSketchTool::confirm_transform() ladder = can_conc ? std::vector>{ mk(true), mk(false) } : std::vector>{ mk(false) }; } - for (auto& w : ladder) if (!w.empty() && try_add_constraints(w)) break; + bool bound = false; + for (auto& w : ladder) if (!w.empty() && try_add_constraints(w)) { bound = true; break; } + if (!bound) + notify(_u8L("The array copies are unconstrained — the solver refused every binding"), false); } } reset_tf(); @@ -8487,8 +9105,12 @@ void DesignSketchTool::emit_step_hint() } else if (is_transform_mode()) { picks = int(m_tf_targets.size()); step = m_tf_targets.empty() ? 0 : 1; + } else if (m_mode == Mode::Constrain && m_constrain_entities) { + picks = int(m_pick0 >= 0) + int(m_pick1 >= 0) + int(m_pick2 >= 0); } else if (m_mode == Mode::Select || m_mode == Mode::Constrain) { picks = int(m_selection.size()); + } else if (m_mode == Mode::Dimension) { + step = m_dim_has0 ? 1 : 0; // the Dimension tool keeps its first pick, not m_points } else { step = int(m_points.size()); } @@ -8501,13 +9123,27 @@ void DesignSketchTool::emit_step_hint() void DesignSketchTool::render(GLCanvas3D& canvas) { m_dim_label_seq = 0; + m_label_rects.clear(); m_render_scale = canvas.get_scale(); + // The open value field is anchored OVER the label it edits, and the label draws on top of + // it — the same number twice at the same spot. Record which label that is so draw_text can + // skip exactly it; the shape's other values must stay legible as the chain walks them. + m_autoedit_label_pos = Vec2d(1e18, 1e18); + if (inline_editor != nullptr && m_autoedit_dim_idx >= 0 + && m_autoedit_dim_idx < int(m_autoedit_dims.size())) + m_autoedit_label_pos = m_autoedit_dims[m_autoedit_dim_idx].label; emit_step_hint(); // before the early returns: an armed tool on an empty sketch still guides // The value field, BEFORE every early return below. It can be up in Constrain mode on a // committed feature and on an empty sketch, and a field that is not drawn is a field that is // not there — there is no window to fall back to any more. if (inline_editor != nullptr) inline_editor->render(*wxGetApp().imgui(), m_render_scale); + // ...and on top of the dimension labels, which are all drawn after it and each lift their + // own window to the front (draw_dim_label): without this a label prints across the number + // being typed. A guard, because the labels come from many of the exit paths below. + ScopeGuard field_on_top([this] { if (inline_editor != nullptr) inline_editor->bring_to_front(); }); + if (render_overlays) + render_overlays(); (void)canvas; if (!has_display()) { if (on_readout) on_readout(std::string()); // nothing to show -> hide HUD @@ -8549,6 +9185,8 @@ void DesignSketchTool::render(GLCanvas3D& canvas) shader->set_uniform("view_model_matrix", camera.get_view_matrix()); shader->set_uniform("projection_matrix", camera.get_projection_matrix()); + render_body_edges(); + // Persistent committed sketches (e.g. an un-consumed sketch left visible after its // extrude is removed): faces translucent, outlines orange. Each uses its own plane. if (!m_display_sketches.empty()) { @@ -8627,7 +9265,7 @@ void DesignSketchTool::render(GLCanvas3D& canvas) } // Nothing else to draw when no live sketch session is active — except the solid - // face/edge highlight overlay (whole-solid tint is handled by set_body_highlight). + // selection and the Feature tree's faces (render_solid_highlight). if (!m_active) { render_datum_planes(); render_mate_connectors(); @@ -8666,10 +9304,12 @@ void DesignSketchTool::render(GLCanvas3D& canvas) const ColorRGBA grey(0.55f, 0.55f, 0.60f, 1.0f); if (m_mode == Mode::Constrain) { - const ColorRGBA cyan(0.30f, 0.80f, 1.0f, 1.0f); - const ColorRGBA red(1.0f, 0.25f, 0.25f, 1.0f); + // The live session's palette, so a sketch looks the same whether you are drawing it or + // constraining it: picks wear THE selection colour, idle geometry orange (construction + // grey). It used to draw everything in the old near-selection cyan and the picks RED — + // the colour that means "conflicting" everywhere else in the sketch. + const ColorRGBA sel_col = design_selection_color(); if (m_constrain_entities) { - // Draw all entities cyan; picked Line entities highlighted red. std::vector markers; for (size_t i = 0; i < m_entities.size(); ++i) { const SketchEntity& e = m_entities[i]; @@ -8678,14 +9318,14 @@ void DesignSketchTool::render(GLCanvas3D& canvas) // references glow yellow (picked entities still win as red). const bool hl = !sel && std::find(m_constraint_hl.begin(), m_constraint_hl.end(), int(i)) != m_constraint_hl.end(); - const ColorRGBA col = sel ? red : (hl ? yellow : cyan); + const ColorRGBA col = sel ? sel_col : hl ? yellow : e.construction ? grey : orange; if (e.type == SketchEntity::Type::Point) { markers.push_back(e.p0); continue; } bool closed = false; std::vector poly = entity_polyline(e, closed); draw_quad_strip((sel || hl) ? m_highlight_model : m_line_model, poly, closed, col); } if (!markers.empty()) - draw_vertices(m_vertex_model, markers, cyan); + draw_vertices(m_vertex_model, markers, orange); // Constraint badges (C3.4b): iconic glyphs near each constraint's entity. { const double upp = 1.0 / std::max(camera.get_zoom(), 1e-6); @@ -8701,12 +9341,12 @@ void DesignSketchTool::render(GLCanvas3D& canvas) glsafe(::glEnable(GL_DEPTH_TEST)); return; } - draw_quad_strip(m_line_model, m_points, true, cyan); - draw_vertices(m_vertex_model, m_points, cyan); + draw_quad_strip(m_line_model, m_points, true, orange); + draw_vertices(m_vertex_model, m_points, orange); if (m_sel_a >= 0 && m_sel_b >= 0 && m_sel_a < int(m_points.size()) && m_sel_b < int(m_points.size())) { std::vector seg = { m_points[m_sel_a], m_points[m_sel_b] }; - draw_quad_strip(m_highlight_model, seg, false, red); + draw_quad_strip(m_highlight_model, seg, false, sel_col); } shader->stop_using(); glsafe(::glEnable(GL_CULL_FACE)); @@ -8732,14 +9372,21 @@ void DesignSketchTool::render(GLCanvas3D& canvas) for (const RegionLoop& L : loops) for (int h : L.holes) if (h >= 0 && h < int(is_hole.size())) is_hole[h] = 1; + std::vector defects; for (size_t r = 0; r < loops.size(); ++r) { + if (loops[r].defect) defects.push_back(loops[r].defect_at); if (is_hole[r]) continue; std::vector> hp; for (int h : loops[r].holes) if (h >= 0 && h < int(loops.size())) hp.push_back(loops[h].poly); - draw_fill_holed(m_fill_model, loops[r].poly, hp, design_idle_face_color()); + // A loop that crosses or folds back is tinted red, not offered as a face. + draw_fill_holed(m_fill_model, loops[r].poly, hp, + loops[r].defect ? ColorRGBA(1.0f, 0.22f, 0.22f, 0.18f) : design_idle_face_color()); } glsafe(::glDisable(GL_BLEND)); + if (!defects.empty()) // and the place it goes wrong gets a screen-constant red marker + draw_vertices(m_vertex_model, defects, ColorRGBA(1.0f, 0.22f, 0.22f, 1.0f), + 5.0 / std::max(camera.get_zoom(), 1e-6)); } } @@ -8870,7 +9517,7 @@ void DesignSketchTool::render(GLCanvas3D& canvas) } } if (!mids.empty()) - draw_vertices(m_vertex_model, mids, ColorRGBA(0.35f, 0.75f, 0.85f, 1.0f), 0.9); + draw_vertices(m_vertex_model, mids, ColorRGBA(0.85f, 0.85f, 0.88f, 1.0f), 0.9); // neutral: not the selection hue // Derived feature handles (A3): the circle RadiusHandle is not a SketchPointRole, // so the per-point pass above doesn't draw it. Render it (cyan) + the hovered @@ -8883,7 +9530,7 @@ void DesignSketchTool::render(GLCanvas3D& canvas) h.role == HandleRole::MinorAxis || h.role == HandleRole::BSplineCtrl) radius_h.push_back(h.pos); if (!radius_h.empty()) - draw_vertices(m_vertex_model, radius_h, ColorRGBA(0.30f, 0.75f, 0.95f, 1.0f), + draw_vertices(m_vertex_model, radius_h, ColorRGBA(0.95f, 0.95f, 0.95f, 1.0f), std::max(4.0 * upp, 1e-4)); if (m_has_hover_handle) draw_vertices(m_highlight_model, { m_hover_handle.pos }, white, @@ -9167,7 +9814,7 @@ void DesignSketchTool::render(GLCanvas3D& canvas) ColorRGBA hint(1.0f, 0.55f, 0.1f, 1.0f); // endpoint: orange switch (m_cursor_snap.kind) { case InferenceSnap::Kind::Midpoint: hint = ColorRGBA(0.35f, 0.90f, 0.75f, 1.0f); break; // teal - case InferenceSnap::Kind::Center: hint = ColorRGBA(0.30f, 0.80f, 1.0f, 1.0f); break; // cyan + case InferenceSnap::Kind::Center: hint = ColorRGBA(1.0f, 0.85f, 0.2f, 1.0f); break; // yellow (cyan = selected) case InferenceSnap::Kind::Origin: hint = ColorRGBA(1.0f, 0.30f, 0.85f, 1.0f); break; // magenta case InferenceSnap::Kind::OnEdge: hint = ColorRGBA(0.45f, 0.70f, 1.0f, 1.0f); break; // blue default: break; @@ -9424,6 +10071,8 @@ DesignSketchTool::LoopReport DesignSketchTool::loop_report() const li.ents = r.ents; li.holes = r.holes; li.closed = true; + li.defect = r.defect; + li.defect_at = r.defect_at; // Analytic where the loop IS one closed curve; shoelace only where it is a chain. // region_loops hands back the render polyline, and a circle's is a 64-gon whose area is // 0.3% short — a number reported as "area" must not be the faceting error. @@ -9537,31 +10186,18 @@ DesignSketchTool::LoopReport DesignSketchTool::loop_report() const int DesignSketchTool::heal_coincidences(double tol, bool ignore_construction) { if (tol <= 0.0) tol = 1e-3; - // Endpoint roles an entity exposes, same set infer_auto_constraints matches on. - auto roles_of = [](const SketchEntity& e, SketchPointRole out[2]) -> int { - switch (e.type) { - case SketchEntity::Type::Line: - case SketchEntity::Type::Arc: - case SketchEntity::Type::BSpline: - case SketchEntity::Type::EllipseArc: - out[0] = SketchPointRole::P0; out[1] = SketchPointRole::P1; return 2; - case SketchEntity::Type::Point: - out[0] = SketchPointRole::P0; return 1; - default: return 0; - } - }; const int n = int(m_entities.size()); int welded = 0; std::vector cands; for (int i = 0; i < n; ++i) { if (ignore_construction && m_entities[i].construction) continue; - SketchPointRole ir[2]; const int ni = roles_of(m_entities[i], ir); + SketchPointRole ir[2]; const int ni = sketch_endpoint_roles(m_entities[i], ir); for (int a = 0; a < ni; ++a) { Vec2d pa; if (!point_at(i, ir[a], pa)) continue; for (int j = i + 1; j < n; ++j) { if (ignore_construction && m_entities[j].construction) continue; - SketchPointRole jr[2]; const int nj = roles_of(m_entities[j], jr); + SketchPointRole jr[2]; const int nj = sketch_endpoint_roles(m_entities[j], jr); for (int b = 0; b < nj; ++b) { Vec2d pb; if (!point_at(j, jr[b], pb)) continue; const double d = (pa - pb).norm(); @@ -9729,6 +10365,13 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) // opens somewhere unexpected can't leave the viewport unusable. else if (evt.Dragging() || evt.GetWheelRotation() != 0) return false; + // A click elsewhere accepts the typed value, as Enter would (the field then advances or + // closes). The click itself is still swallowed: it was aimed at leaving the field, not + // at drawing, and a point placed by it would land under a field that just vanished. + else if (evt.LeftDown()) { + if (on_inline_commit) on_inline_commit(); + return true; + } else return true; } @@ -9781,7 +10424,10 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) if (!moved && d < 3) open_move_editor(d); // stationary click on an arrow = edit offset return true; } - if (evt.RightDown()) { clear_move_gizmo(); canvas.set_as_dirty(); if (on_move_exit) on_move_exit(); return true; } + // Right-click opens the offer; the move is kept with Enter/✓ and reverted with Esc/✗, + // like every other pending edit. It used to keep the move and close the gizmo — a + // commit on the one button the charter reserves for "what can I do here". + if (evt.RightDown()) return false; if (evt.LeftDown()) { int axis = -1; if (hit_test_move_arrow(canvas, evt, axis)) { // translate arrows win over rings @@ -10020,9 +10666,9 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) // Left-drag rubber band -> whole body. Past the click budget the press becomes a sweep: // the rectangle is anchored at the ORIGINAL press point (not at the frame where the // threshold was crossed, which would lose the first few pixels) and the events are - // consumed from here on. Left-drag no longer orbits in this canvas — DesignCanvas puts - // orbit on middle-drag and pan on right-drag, the CAD convention — so nothing downstream - // is being starved of a gesture it used to own. + // consumed from here on. The camera follows Preferences > Control as in Prepare, so while + // left-drag is given to Rotate or Pan the band takes Shift+left-drag, Prepare's own + // rectangle selection. // HOVER PRE-HIGHLIGHT (9xw part 3): say what a click would take, before it is // taken. Plain motion only — no button down, no band running — because during a drag the // pointer is doing something else and a promise about clicking would be a lie. Returns @@ -10032,7 +10678,9 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) if (update_solid_hover(canvas, evt)) canvas.set_as_dirty(); return false; } - if (evt.Dragging() && evt.LeftIsDown() && m_pick_pending) { + const bool left_drag_sweeps = + evt.ShiftDown() || std::atoi(wxGetApp().app_config->get("left_mouse_drag_action").c_str()) == 0; + if (evt.Dragging() && evt.LeftIsDown() && m_pick_pending && (left_drag_sweeps || m_rubber.is_dragging())) { if (!m_rubber.is_dragging()) { if (std::max(std::abs(evt.GetX() - m_pick_press_x), std::abs(evt.GetY() - m_pick_press_y)) <= 8) @@ -10142,12 +10790,13 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) return true; } } + if (on_empty_pick) on_empty_pick(); return false; // let the stock canvas orbit / deselect } // In-canvas edit-op tools (Fillet/Chamfer/Offset/Mirror): pick entities, then a // draggable arrow + editable value label (Mirror: a two-phase pick) drives a live - // ghost. A click on empty space confirms; right-click/Esc cancels the gesture. + // ghost. Enter or ✓ applies, Esc or right-click discards (charter 4.2). // Standalone Trim / Extend scissors: click a segment to cut it back to (Trim) or out to // (Extend) its nearest intersection with the other live entities. One cut per click; the // tool stays active for more cuts; right-click exits. Drag falls through so the camera can @@ -10158,19 +10807,19 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) Vec2d p; screen_to_plane(canvas, evt, p); const Linef3 r2 = canvas.mouse_ray(Point(evt.GetX() + 8, evt.GetY())); const double tol = std::max(1e-3, (m_plane.project(r2.a, r2.vector()) - p).norm()); - if (apply_live_trim(p, tol * 3.0, m_mode == Mode::Extend)) { + if (apply_live_trim(p, screen_tol(canvas, evt, p, kToolPickPx), m_mode == Mode::Extend)) { resolve_live(); - } else if (on_readout) { + } else { // nde #15: don't fail silently. The pick found nothing to cut/extend — either // the click missed every live segment, or the picked segment has no crossing / // target among the OTHER live entities (committed sketches aren't trimmed). - on_readout(m_mode == Mode::Extend - ? std::string("Extend: click a line/arc that can reach another live entity") - : std::string("Trim: click a segment where it crosses another live entity")); + notify(m_mode == Mode::Extend + ? _u8L("Extend: click a line or an arc that can reach another entity") + : _u8L("Trim: click a segment where it crosses another entity")); } return true; } - if (evt.RightDown()) { request_exit(); return true; } + if (evt.RightDown()) return false; // nothing pending to abandon: the offer opens return false; // let move/drag orbit the camera } @@ -10195,8 +10844,7 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) const double tol = std::max(1e-3, (m_plane.project(r2.a, r2.vector()) - p).norm()); // 1) live gizmo: click the value label to type, or grab the arrow to drag. if (op_ready() && m_mode != Mode::Mirror) { - const Linef3 rl = canvas.mouse_ray(Point(evt.GetX() + 24, evt.GetY())); - const double ltol = std::max(tol, (m_plane.project(rl.a, rl.vector()) - p).norm()); + const double ltol = std::max(tol, screen_tol(canvas, evt, p, kLabelPx)); if ((m_op_label - p).norm() <= ltol) { open_op_editor(); return true; } if (hit_test_op_arrow(p, tol)) { m_op_dragging_arrow = true; return true; } } @@ -10206,19 +10854,21 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) const double d = entity_pick_dist(p, m_entities[i]); if (d < best) { best = d; bi = int(i); } } - if (bi >= 0 && best <= tol * 3.0) { op_pick(bi); return true; } - // 3) empty click confirms a ready gesture. - if (op_ready()) confirm_op(); + if (bi >= 0 && best <= screen_tol(canvas, evt, p, kToolPickPx)) { op_pick(bi); return true; } + // 3) Empty space never commits (charter 4.2 withdrew that invisible gesture). A ready + // op stays pending and says how to finish it; half-made picks are a selection, and + // empty space clears a selection. + if (op_ready()) notify(_u8L("Press Enter or ✓ to apply, Esc to discard"), false); + else if (m_op_a >= 0) { reset_op(); m_selection.clear(); if (on_selection_changed) on_selection_changed(0); } return true; } if (evt.RightDown()) { - if (m_op_a >= 0 || !m_mirror_targets.empty()) { - reset_op(); - m_selection.clear(); - if (on_selection_changed) on_selection_changed(0); - } else { - request_exit(); - } + // The draw tools' rule (right_abandon): drop what is pending, else hand the click + // back so the offer opens. Leaving the tool is Esc's job, not right-click's. + if (m_op_a < 0 && m_mirror_targets.empty()) return false; + reset_op(); + m_selection.clear(); + if (on_selection_changed) on_selection_changed(0); return true; } return false; @@ -10226,7 +10876,7 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) // In-canvas transform tools (Move/Rotate/Scale/Array/PolarArray): pick subject // entities, then a single draggable handle + editable value label(s) drive a live - // ghost. A click on empty space confirms; right-click drops the gesture / exits. + // ghost. Enter or ✓ applies, Esc or right-click discards the pending transform. if (is_transform_mode()) { if (evt.Moving()) { screen_to_plane(canvas, evt, m_cursor); @@ -10248,8 +10898,7 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) const double tol = std::max(1e-3, (m_plane.project(r2.a, r2.vector()) - p).norm()); // 1) live gizmo: click a value label to type, or grab the handle to drag. if (tf_ready()) { - const Linef3 rl = canvas.mouse_ray(Point(evt.GetX() + 24, evt.GetY())); - const double ltol = std::max(tol, (m_plane.project(rl.a, rl.vector()) - p).norm()); + const double ltol = std::max(tol, screen_tol(canvas, evt, p, kLabelPx)); if ((m_tf_label_a - p).norm() <= ltol) { open_tf_editor_a(); return true; } if ((m_tf_label_b - p).norm() <= ltol) { open_tf_editor_count(); return true; } if (hit_test_tf_handle(p, tol)) { m_tf_dragging = true; return true; } @@ -10260,19 +10909,16 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) const double d = entity_pick_dist(p, m_entities[i]); if (d < best) { best = d; bi = int(i); } } - if (bi >= 0 && best <= tol * 3.0) { tf_pick(bi); return true; } - // 3) empty click confirms a ready gesture. - if (tf_ready()) confirm_transform(); + if (bi >= 0 && best <= screen_tol(canvas, evt, p, kToolPickPx)) { tf_pick(bi); return true; } + // 3) Empty space never commits (charter 4.2): the pending transform waits for Enter/✓. + if (tf_ready()) notify(_u8L("Press Enter or ✓ to apply, Esc to discard"), false); return true; } if (evt.RightDown()) { - if (!m_tf_targets.empty()) { - reset_tf(); - m_selection.clear(); - if (on_selection_changed) on_selection_changed(0); - } else { - request_exit(); - } + if (m_tf_targets.empty()) return false; // nothing to abandon: the offer opens + reset_tf(); + m_selection.clear(); + if (on_selection_changed) on_selection_changed(0); return true; } return false; @@ -10288,8 +10934,7 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) } if (evt.LeftDown()) { Vec2d p; screen_to_plane(canvas, evt, p); - const Linef3 r2 = canvas.mouse_ray(Point(evt.GetX() + 10, evt.GetY())); - const double tol = std::max(1e-3, (m_plane.project(r2.a, r2.vector()) - p).norm()); + const double tol = screen_tol(canvas, evt, p, kPickPx); const int h = hit_test_xform_handle(p, tol); if (h >= 0) { m_xform_handle = h; @@ -10304,7 +10949,9 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) return true; } if (evt.LeftUp()) { m_xform_handle = -1; return true; } - if (evt.RightDown()) { if (on_exit) on_exit(); else cancel(); return true; } + // Right-click opens the offer like everywhere else; the placement is kept with Enter/✓ + // and discarded with Esc/✗ (the panel's Insert card), never by a mouse button. + if (evt.RightDown()) return false; return false; } @@ -10322,6 +10969,12 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) const double d = entity_pick_dist(p, m_entities[i]); if (d < best) { best = d; bi = int(i); } } + // Same reach as every other tool that picks an entity to act on. With no limit, + // a click anywhere on screen picked whatever was nearest; empty space now clears. + if (bi >= 0 && best > screen_tol(canvas, evt, p, kToolPickPx)) { + bi = -1; + m_pick0 = m_pick1 = m_pick2 = -1; + } if (bi >= 0) { // Rolling three-slot selection: slots 0/1 feed all 2-entity // constraints; slot 2 is the Symmetric axis (only filled once @@ -10333,7 +10986,9 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) } return true; } - if (evt.RightDown()) { cancel(); return true; } + // The picks are this mode's selection, and right-click on a selection opens the + // offer for it — the constraint verbs. It used to cancel(), wiping the whole session + // behind the panel's back. Esc drops the picks. return false; } if (evt.LeftDown()) { @@ -10354,11 +11009,7 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) } return true; } - if (evt.RightDown()) { - cancel(); - return true; - } - return false; + return false; // right-click included: the offer opens on the picked segment } // Selection mode: click to pick an entity, Shift/Ctrl to extend, double-click @@ -10459,9 +11110,9 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) Vec2d p; screen_to_plane(canvas, evt, p); if (evt.LeftDClick()) { // double-click a quote label -> edit it - const Linef3 rd = canvas.mouse_ray(Point(evt.GetX() + 28, evt.GetY())); - const double dtol = std::max(2.0, (m_plane.project(rd.a, rd.vector()) - p).norm()); - const int di = hit_test_dimension(p, dtol); + // Labels have ONE budget, single- or double-click, and it is in pixels: the old + // 2 mm floor made a label's target grow without bound as the view zoomed in. + const int di = hit_test_dimension(p, screen_tol(canvas, evt, p, kLabelPx)); if (di >= 0) { edit_dimension(di); return true; } } // Zoom-aware tolerance: project a point 8 px away and measure in plane units. @@ -10475,8 +11126,7 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) // opens — so typing a value sets the precise dimension. Generous label // tolerance (~24 px) since text labels are wider than a point grip. if (evt.LeftDown()) { - const Linef3 rdl = canvas.mouse_ray(Point(evt.GetX() + 24, evt.GetY())); - const double ltol = std::max(tol, (m_plane.project(rdl.a, rdl.vector()) - p).norm()); + const double ltol = std::max(tol, screen_tol(canvas, evt, p, kLabelPx)); const int di = hit_test_dimension(p, ltol); if (di >= 0) { open_value_editor(di); return true; } for (const DimAnnot& q : m_live_quotes) { @@ -10662,10 +11312,7 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) if (evt.LeftDClick()) { // double-click a quote label -> edit it Vec2d p; screen_to_plane(canvas, evt, p); - const Linef3 r2 = canvas.mouse_ray(Point(evt.GetX() + 28, evt.GetY())); - const Vec2d p2 = m_plane.project(r2.a, r2.vector()); - const double di_tol = std::max(2.0, (p2 - p).norm()); - const int di = hit_test_dimension(p, di_tol); + const int di = hit_test_dimension(p, screen_tol(canvas, evt, p, kLabelPx)); if (di >= 0) edit_dimension(di); m_dim_has0 = false; return true; @@ -10688,6 +11335,8 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) if (t == SketchEntity::Type::Line) { a.kind = DimType::Length; place_dimension(a); } else if (t == SketchEntity::Type::Circle) { a.kind = DimType::Diameter; place_dimension(a); } else if (t == SketchEntity::Type::Arc) { a.kind = DimType::Radius; place_dimension(a); } + else + notify(_u8L("No dimension for that entity yet — click a line, a circle, an arc, or two points")); } } else { if (got_pt && !(pe == m_dim_e0 && pr == m_dim_r0)) { @@ -10698,12 +11347,18 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) DimAnnot a; a.kind = DimType::DistanceToLine; a.ea = m_dim_e0; a.ra = m_dim_r0; a.eb = he; place_dimension(a); + } else if (he >= 0) { + notify(_u8L("Click a second point, or a line to measure the distance to"), false); } m_dim_has0 = false; // reset after the second pick } return true; } - if (evt.RightDown()) { m_dim_has0 = false; return true; } + if (evt.RightDown()) { // abandon the first pick, if any (right_abandon's rule) + if (!m_dim_has0) return false; // nothing down: the offer opens + m_dim_has0 = false; + return true; + } return false; // let drag orbit the camera } @@ -10713,7 +11368,10 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) m_has_cursor = true; m_cursor_locked = false; bool vsnap = false; - m_cursor = snap_vertex(canvas, evt, m_cursor, vsnap); // preview-snap to endpoints + // Preview exactly what the next click will do: a snap marker over a click that then + // lands on the raw point promised a coincidence that never happened. + if (click_snaps()) m_cursor = snap_vertex(canvas, evt, m_cursor, vsnap); // preview-snap + else m_cursor_snap = InferenceSnap{}; // The chain's own start is not an entity yet, so snap_vertex cannot see it. Offer it // here: the cursor lands exactly on it, so the rubber band below IS the closing segment. if (snap_chain_start(canvas, evt, m_cursor)) vsnap = true; @@ -10747,17 +11405,13 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) bool lk = false; p = snap_dir(m_points.back(), p, lk); // lock new segment to inference angle } + m_chain_dup_tol = screen_tol(canvas, evt, p, 3.0); m_points.push_back(p); arm_polyline_segment_edit(); // refine this segment's Length+Angle, then continue return true; } if (evt.LeftDClick()) { - if (m_points.size() >= 2) { - const int base = int(m_entities.size()); - push_open_chain(m_points); // end as an open chain - infer_auto_constraints(base); - m_points.clear(); - } + if (m_points.size() >= 2) end_chain(); // end as an open chain return true; } if (evt.RightDown() && m_points.empty()) @@ -10773,11 +11427,7 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) // is this tab's goal and it stays a four-action triangle either way; what changes // is that the closing edge is now something the user saw and chose, not one the // app appended on their behalf. - const int base = int(m_entities.size()); - if (m_points.size() >= 2) - push_open_chain(m_points); - infer_auto_constraints(base); - m_points.clear(); + end_chain(); return true; } break; @@ -10813,6 +11463,7 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) if (evt.LeftDown()) { Vec2d p; screen_to_plane(canvas, evt, p); + { bool vsnap = false; if (click_snaps()) p = snap_vertex(canvas, evt, p, vsnap); } if (m_points.empty()) { m_points.push_back(p); } else { @@ -10835,6 +11486,7 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) if (evt.LeftDown()) { Vec2d p; screen_to_plane(canvas, evt, p); + { bool vsnap = false; if (click_snaps()) p = snap_vertex(canvas, evt, p, vsnap); } if (m_points.empty()) { m_points.push_back(p); } else { @@ -10918,6 +11570,7 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) if (evt.LeftDown()) { Vec2d p; screen_to_plane(canvas, evt, p); + { bool vsnap = false; if (click_snaps()) p = snap_vertex(canvas, evt, p, vsnap); } if (m_points.empty()) { m_points.push_back(p); } else { @@ -10951,6 +11604,7 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) case Mode::ThreePointCircle: { if (evt.LeftDown()) { Vec2d p; screen_to_plane(canvas, evt, p); + { bool vsnap = false; if (click_snaps()) p = snap_vertex(canvas, evt, p, vsnap); } m_points.push_back(p); if (m_points.size() == 3) { append_entities(make_three_point_circle(m_points[0], m_points[1], m_points[2])); @@ -11023,6 +11677,7 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) case Mode::Slot: { if (evt.LeftDown()) { Vec2d p; screen_to_plane(canvas, evt, p); + { bool vsnap = false; if (click_snaps()) p = snap_vertex(canvas, evt, p, vsnap); } if (m_points.size() < 2) { m_points.push_back(p); } else { @@ -11075,6 +11730,7 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) case Mode::Polygon: { if (evt.LeftDown()) { Vec2d p; screen_to_plane(canvas, evt, p); + { bool vsnap = false; if (click_snaps()) p = snap_vertex(canvas, evt, p, vsnap); } if (m_points.empty()) { m_points.push_back(p); } else { @@ -11136,18 +11792,14 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) m_snap_off = evt.ShiftDown(); bool vsnap = false; p = snap_vertex(canvas, evt, p, vsnap); // poles can land on endpoints + m_chain_dup_tol = screen_tol(canvas, evt, p, 3.0); m_points.push_back(p); return true; } if (evt.RightDown() && m_points.empty()) return false; // no poles down — ghcz, let the offer open if (evt.LeftDClick() || evt.RightDown()) { - if (m_points.size() >= 2) { - const int base = int(m_entities.size()); - append_entities(make_bspline(m_points)); - infer_auto_constraints(base); // end poles auto-Coincident -> loops close - } - m_points.clear(); + end_chain(); return true; } break; @@ -11157,6 +11809,7 @@ bool DesignSketchTool::on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas) if (evt.LeftDown()) { Vec2d p; screen_to_plane(canvas, evt, p); + { bool vsnap = false; if (click_snaps()) p = snap_vertex(canvas, evt, p, vsnap); } push_point(p); return true; } diff --git a/src/slic3r/GUI/CAD/DesignSketchTool.hpp b/src/slic3r/GUI/CAD/DesignSketchTool.hpp index f6a3dd2f07..7e1c4a629d 100644 --- a/src/slic3r/GUI/CAD/DesignSketchTool.hpp +++ b/src/slic3r/GUI/CAD/DesignSketchTool.hpp @@ -8,6 +8,7 @@ #include "slic3r/GUI/GLModel.hpp" #include "slic3r/GUI/GLSelectionRectangle.hpp" // left-drag rubber band over the committed bodies #include +#include #include #include "libslic3r/Color.hpp" #include @@ -69,6 +70,17 @@ public: // row off arms a NEIGHBOURING tool and then grades whatever that drew. ekt9. Mode mode() const { return m_mode; } int pending_points() const { return int(m_points.size()); } + // Picks the armed tool is holding that are not yet an entity or a constraint: the Dimension + // tool's first anchor, an edit-op's or a transform's subjects, Constrain's picks. Together + // with pending_points() they are CadLevel::Gesture, so Esc drops them and keeps the tool. + bool has_pending_picks() const { + return m_dim_has0 || m_op_a >= 0 || !m_tf_targets.empty() || m_pick0 >= 0 || m_sel_a >= 0; + } + bool gesture_pending() const { return !m_points.empty() || has_pending_picks(); } + // Enter while an edit-op or a transform is ready: apply it, the same as its value field's + // Enter. False when nothing is pending, so the key can fall through to the tab's ✓. + bool confirm_pending(); + bool end_chain(); // Polyline / Spline: finish the open chain as drawn void emit_step_hint(); // fires on_step_changed when the step actually moved // Is an in-canvas value field open? While one is, the canvas is frozen and every letter is // swallowed — the single most common reason a driven gesture "does nothing". @@ -145,6 +157,8 @@ public: // 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. class SketchInlineEditor* inline_editor{nullptr}; + // The canvas's own overlays (its status and readout chips), drawn in this tool's ImGui pass. + std::function render_overlays; // Persistent committed sketches to draw even when no session is active (e.g. an // un-consumed sketch left visible after its extrude is removed). Each carries its @@ -189,11 +203,24 @@ public: // another solid is reachable without hiding anything. -1 = no restriction. // Survives set_solid_pick() — it is owned by the panel, not by the mesh feed. void set_pick_only_body(int b) { m_pick_only_body = b; } + // Off while the bodies themselves are hidden (a dress-up previewing its result alone), so + // their edges do not float over the preview. + void set_body_edges_hidden(bool h) { m_body_edges_hidden = h; } void clear_solid_selection(); 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. + // Empty unless the selection is at edge level. + std::vector selected_edges() const; // Select a whole body by index (from the Parts list) — Whole-level highlight, no face/edge. // body < 0 or out of range clears the selection. void select_body(int body); + // Outline these (body, face id) faces as selected: the faces the Feature tree's selected + // feature made. A body whose shape changes later drops out, its face ids being stale. The + // canvas fills them (DesignCanvas::rebuild_bodies). + 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; // 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 @@ -205,12 +232,12 @@ public: void clear_move_gizmo(); bool moving_body() const { return m_mv_active; } int move_body_index() const { return m_mv_body; } - // F key forwarded from the canvas (Prepare's Place on Face): returns true if it acted. - bool request_place_on_face() { return on_place_on_face ? on_place_on_face() : false; } - std::function on_place_on_face; 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; + // A click or rubber band (no live session) that took nothing, so the host can drop the + // selections the tool does not hold, such as the panel's list rows. + std::function on_empty_pick; // Click a committed sketch overlay (no live session) -> select that loop: the Sketch // feature index + the clicked closed-region index within it (-1 = no specific loop). // entity = the sketch entity index under the cursor when the click landed on a loop @@ -236,6 +263,8 @@ public: // sketch there is nothing left that would set this — and selected_loop_entities(), which is // what Extrude consumes, reads exactly these two fields. void set_display_pick(int feature, int region) { m_display_pick = feature; m_display_pick_region = region; } + int display_pick() const { return m_display_pick; } + int display_pick_region() const { return m_display_pick_region; } // Visual Extrude gizmo (C5b). The Extrude tool is a DesignPanel docked card, so the // sketch tool is NOT active during it; the panel feeds the profile plane + a 2D centroid @@ -361,12 +390,12 @@ public: void set_mate_links(std::vector> l) { m_mate_links = std::move(l); } void clear_mate_connectors() { m_mate_connectors.clear(); m_mate_links.clear(); } - // Visual Revolve gizmo. The panel feeds the sketch plane + profile centroid + axis (0=plane X, - // 1=plane Y) + angle + flip while its Revolve card is open; an angle-arc is drawn in the - // revolve plane at the profile radius. Dragging the tip sweeps the angle, a stationary click + // Visual Revolve gizmo. The panel feeds the sketch plane + profile centroid + the world axis + // (a point on it and its direction) + angle + flip while its Revolve card is open; an + // angle-arc is drawn in the revolve plane at the profile radius, and the axis dashed. Dragging the tip sweeps the angle, a stationary click // edits it; both fire on_revolve_angle_changed. void set_revolve_gizmo(const SketchPlane& plane, const Vec2d& centroid, - int axis_sel, double angle, bool flip); + const Vec3d& axis_origin, const Vec3d& axis_dir, double angle, bool flip); void clear_revolve_gizmo(); bool revolving() const { return m_rv_active; } std::function on_revolve_angle_changed; @@ -513,6 +542,8 @@ public: std::vector holes; // indices into LoopReport::loops that this loop encloses bool closed{false}; double area{0.0}; // signed shoelace area of the loop polyline + bool defect{false}; // crosses or folds back on itself (see RegionLoop) + Vec2d defect_at{0, 0}; }; struct LoopReport { std::vector loops; @@ -552,25 +583,15 @@ public: reset_autoedit(); if (on_readout) on_readout(std::string()); // the HUD is not redrawn once the tool stops } - // Ctrl+Z while sketching: drop the last drawn entity (reuses delete_selected's remap). - bool undo_last_entity() { - if (!m_active || m_entities.empty()) return false; - m_selection.assign(1, int(m_entities.size()) - 1); - delete_selected(); - reset_autoedit(); - return true; - } - // Delete while sketching: the selected entities, or the last drawn one if none is selected. - bool delete_selected_or_last() { - if (!m_active) return false; - if (m_selection.empty()) { - if (m_entities.empty()) return false; - m_selection.assign(1, int(m_entities.size()) - 1); - } - delete_selected(); - reset_autoedit(); - return true; - } + // Ctrl+Z while sketching: drop the last thing DRAWN — a whole rectangle, slot or polygon + // when the last entity belongs to one, since that was one gesture. Undoing one side of a + // rectangle left three lines and dissolved the shape. + bool undo_last_entity(); + // Ctrl+Y / Ctrl+Shift+Z while sketching: bring back what undo_last_entity removed, as long + // as nothing was drawn or deleted since (then the old state is no longer "the next step"). + bool redo_last_entity(); + bool can_undo_entity() const { return m_active && !m_entities.empty(); } + bool can_redo_entity() const; std::function on_selection_changed; // Dimension tool: infer a driving dimension from the current selection and set @@ -614,6 +635,14 @@ public: // characteristic dimensions). Empty string -> hide the HUD. The owner (DesignCanvas) // shows it as a floating corner label over the GL canvas. std::function on_readout; + // Why something did not happen, or what a gesture did as a side effect. NOT on_readout: + // that one is rewritten every frame from build_readout(), so a message sent through it was + // gone before anyone could read it. The host shows this on its persistent status line. + std::function on_notice; + void notify(const std::string& msg, bool error = true) { if (on_notice) on_notice(msg, error); } + // A typed value the tool cannot take: the open value field comes back with the reason, or, + // when no field is committing, the reason goes to the status line. + void show_refusal(const std::string& why); // Driving dimension constraints accumulated during the session (the Dimension // tool records a SketchEntityConstraintDef per applied dimension); committed @@ -635,17 +664,11 @@ public: // request_exit() declined to leave because the session holds geometry. The panel owns the // status line, so the tool reports through this instead of writing text itself. std::function on_exit_refused; - std::function on_move_exit; // right-click finished the move-body gizmo // The two inner Esc levels, callable on their own so the panel can route one press to one // level (see DesignInteraction.hpp). Each returns whether it had anything to unwind. bool abort_gesture(); // CadLevel::Gesture — drop the entity being drawn bool disarm_tool(); // CadLevel::Tool — armed draw/edit tool falls back to Select void request_exit(); - // Ctrl+Z / Ctrl+Shift+Z (Ctrl+Y) while the Design canvas is focused: undo/redo the - // committed feature history. The tool just forwards to the host, which owns the - // CadDocument (the tool has no document of its own). redo == true requests redo. - std::function on_undo_redo; - void request_undo_redo(bool redo); private: bool screen_to_plane(GLCanvas3D& canvas, const wxMouseEvent& evt, Vec2d& out) const; @@ -662,10 +685,17 @@ private: // chains join across entities (a line + an arc can close into one loop). Shift // disables it. `snapped` reports whether a vertex was hit. Vec2d snap_vertex(GLCanvas3D& canvas, const wxMouseEvent& evt, const Vec2d& raw, bool& snapped) const; + bool click_snaps() const; // does the NEXT click of the armed draw tool land on a snap target? // --- P1 inference / auto-constraint engine --------------------------------- // Plane-units tolerance equivalent to ~`px` screen pixels at the cursor. - double screen_tol(GLCanvas3D& canvas, const wxMouseEvent& evt, const Vec2d& at, double px = 8.0) const; + // The sketcher's pick budgets, in screen pixels, converted to plane units by screen_tol(). + // One set for every tool, so the same thing is equally easy to hit whichever tool is armed. + static constexpr double kPickPx = 8.0; // grab a point, a handle or an edge; hover uses the same + static constexpr double kToolPickPx = 24.0; // pick an entity for a tool to act on (trim, edit-ops, + // transforms, constrain) + static constexpr double kLabelPx = 24.0; // click or double-click a value label + double screen_tol(GLCanvas3D& canvas, const wxMouseEvent& evt, const Vec2d& at, double px = kPickPx) const; // Run kernel inference at the cursor, cache the target for the hint renderer. InferenceSnap infer_at(GLCanvas3D& canvas, const wxMouseEvent& evt, const Vec2d& raw) const; // True if m_constraints already holds an equivalent Coincident between the two refs. @@ -692,6 +722,8 @@ private: bool selection_valid() const; // all selection indices in range void record_dimension_constraint(double v); // append the driving def for the selection void resolve_live(); // solve accumulated constraints on m_entities now + void announce_loop_defects(); // status line, once, when a loop starts crossing itself + bool m_loop_defect_shown{false}; // Drag-aware re-solve: pins the dragged point at its current coord and lets the // solver move the rest (Slvs dragged[]). Used live while a point grab is active. void resolve_live_drag(int dragged_ei, SketchPointRole dragged_role); @@ -810,12 +842,19 @@ private: // EllipseArc endpoint drag: Center translates; P0/P1 move the sweep start/end to the // parametric angle of the cursor on the ellipse frame (radius/shape preserved). void drag_ellipsearc_handle(int ei, SketchPointRole role, const Vec2d& target); + // The ONE way to remove constraints. m_dimensions[i].con caches a POSITIONAL index into + // m_constraints, so an erase that does not repair those links leaves a dimension pointing + // at whatever slid into the hole — and set_dimension_value then writes a def into that + // slot, silently overwriting an unrelated constraint. `drop(idx, def)` returns true to + // remove. Every removal in this file routes through here. + // Returns how many were removed. + int erase_constraints(const std::function& drop); // Drop orientation constraints (H/V/Parallel/Perp/Angle/LockX/LockY) on entities in // [begin,end). A ROTATION makes inferred per-edge H/V inconsistent, so re-solving - // against them collapses the shape — drop them first (fixes up DimAnnot.con indices). + // against them collapses the shape — drop them first. void drop_orientation_constraints(int begin, int end); // Drop every live constraint that references entity `ei` (Trim/Extend slide an endpoint, - // invalidating its constraints) and fix the dimensions' cached constraint indices. + // invalidating its constraints). void drop_constraints_referencing(int ei); // Standalone Trim/Extend scissors on the LIVE sketch: pick the entity nearest `p` (within // `tol` plane units) and cut it back to / out to its nearest intersection with the others. @@ -838,6 +877,9 @@ private: // UPDATE it, not append a rival asking for something else. Both return the index. int upsert_constraint(const SketchEntityConstraintDef& c); int upsert_dimension(const DimAnnot& a); + // The same slot rule for the DRIVING constraint: a Distance and its zero case (recorded as + // a Coincident) are one dimension, so they replace each other instead of stacking. + int upsert_dimension_constraint(const SketchEntityConstraintDef& c); int place_dimension(DimAnnot a); // create+drive+notify std::string dim_text(const DimAnnot& a) const; // rendered label string void render_dimensions(double unit_per_px); // quote lines + labels @@ -946,6 +988,10 @@ private: std::vector poly; std::vector ents; std::vector holes; // indices into the same vector; one nesting level + // Closed, but crossing itself or turning straight back somewhere (sketch_loop_defect): + // it does not bound one region, whatever the chainer says. defect_at names the place. + bool defect{false}; + Vec2d defect_at{0, 0}; }; std::vector region_loops(const std::vector& ents) const; // Index of the closed region containing plane-point p (point-in-polygon), or -1. @@ -990,6 +1036,12 @@ private: }; std::vector m_autoedit_dims; // queued steps to edit in sequence int m_autoedit_dim_idx{-1}; // index into m_autoedit_dims (-1 = idle) + // Plane coords of the label the OPEN value field sits on. The field is anchored OVER its + // label (open_next_autoedit_dim) and the label is drawn after it, on top — the same number + // twice at the same spot. draw_text skips exactly this position while a step is open; the + // shape's other values stay legible as the chain walks them. Far-off sentinel = suppress + // nothing. + Vec2d m_autoedit_label_pos{1e18, 1e18}; std::vector m_selection; // selected entity indices (Mode::Select) std::vector> m_point_sel; // selected individual points int m_last_mouse_x{0}; // last cursor pos (canvas client px), for @@ -1036,6 +1088,16 @@ private: Vec2d m_live_slot_angle_label{0,0}; // straight-slot centreline angle label int m_live_slot_fi{-1}; // the straight-slot Feature (rebuild edits) std::vector m_features; // parametric groups over m_entities + // Sketch-local redo: the state each undo_last_entity() replaced, and the entity/constraint + // counts it left behind (a mismatch means the sketch moved on and the redo is stale). + struct SketchSnap { + std::vector entities; std::vector constraints; + std::vector features; std::vector dimensions; + size_t after_entities{0}; size_t after_constraints{0}; + }; + std::vector m_sketch_redo; + int m_skipped_last{0}; // constraints the last solve could not apply (reported on change) + double m_chain_dup_tol{1e-9}; // plane units ~3 px at the last chain click (double-click repeat) int m_open_feature{-1}; // index of the Feature being built, or -1 // In-canvas edit-op gizmo state (Fillet/Chamfer/Offset/Mirror). GUI-only, reset by @@ -1054,6 +1116,10 @@ private: std::vector m_op_ghost; // live result preview (recomputed on value change) bool m_op_dragging_arrow{false}; // arrowhead drag in progress std::vector m_mirror_targets; // Mirror: entities to be mirrored (axis = m_op_a) + // Offset: the whole chain the picked entity belongs to (connected by shared endpoints), so + // an outline offsets as one outline. A single entity when it is not part of a chain. + std::vector m_op_chain; + std::vector op_chain_entities() const; // In-canvas imported-art transform gizmo (Mode::TransformArt). GUI-only. The art's // untransformed contours + its bbox in base coords; the live offset/scale; the grabbed @@ -1109,6 +1175,7 @@ private: bool m_solve_ok{true}; // solver consistent (no conflicting constraints) std::vector m_entity_conflict; // per-entity flag: touched by a conflicting constraint std::vector m_dimensions; // placed dimension quotes (Mode::Dimension) + std::vector m_bad_dims; // dims whose driving constraint the solver rejected int m_dim_e0{-1}; // first picked point's entity (Dimension) SketchPointRole m_dim_r0{SketchPointRole::P0}; bool m_dim_has0{false}; // a first point is pending @@ -1132,6 +1199,13 @@ private: GLModel m_vertex_model; GLModel m_highlight_model; int m_dim_label_seq{0}; + // Screen rects of the labels already drawn this frame (cleared with m_dim_label_seq). Two + // labels on a SMALL feature collide: every anchor offset in this file is a multiple of the + // text height, so once the feature's on-screen size drops below one, a line's Length and + // Angle labels land on the same spot. Each label is its own centred window and nothing + // detects proximity, so the later one is pushed clear before it is drawn. + struct LabelRect { double x{0}, y{0}, w{0}, h{0}; }; + std::vector m_label_rects; float m_render_scale{1.0f}; // canvas scale for Measure-style dim labels GLModel m_fill_model; // translucent face fill for closed regions std::vector m_display_sketches; // committed sketches drawn persistently @@ -1149,12 +1223,29 @@ private: int m_pick_only_body{-1}; // >=0: only this body catches clicks (body-focus x-ray for CoordSys picking) const std::vector* m_solid_xform{nullptr}; // per-body display transform (for edge sampling) 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. + std::vector>> m_body_edges; + std::vector m_body_edges_key; + 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 + 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 int m_sel_face{-1}; int m_sel_edge{-1}; std::vector m_sel_edge_pts; + // Edges picked BEFORE m_sel_edge in a Shift/Ctrl+click set, same body, with their world + // polylines for the highlight. m_sel_edge stays the last-clicked one, so everything that + // reads a single edge (the radius gizmo, the offer header) keeps working unchanged. + std::vector m_sel_edges_more; + std::vector> m_sel_edges_more_pts; Vec3d m_sel_vertex_pt{Vec3d::Zero()}; // world point of a picked vertex bool handle_solid_click(GLCanvas3D& canvas, const wxMouseEvent& evt); // pick + notify // What a click at (mx,my) WOULD take, resolved without touching the selection. One @@ -1173,9 +1264,8 @@ private: // change) read honestly — the change has to be predictable before the click, not only after. SolidPick m_pre; // what the pointer is currently over (kind None = nothing) bool update_solid_hover(GLCanvas3D& canvas, const wxMouseEvent& evt); // true when it changed - // Left-drag rubber band: sweep a rectangle over the plate to take a whole body. Orbit - // moves to middle-drag in this canvas (DesignCanvas::set_cad_navigation) so the left - // button is free for it, which is the CAD convention (Onshape/SolidWorks). + // Left-drag rubber band: sweep a rectangle over the plate to take a whole body. While + // Preferences give left-drag to the camera it takes Shift+left-drag, as in Prepare. GLSelectionRectangle m_rubber; void pick_bodies_in_rectangle(); // resolve the swept rectangle -> whole-body selection bool on_mouse_impl(wxMouseEvent& evt, GLCanvas3D& canvas); // the body; on_mouse wraps it @@ -1187,10 +1277,28 @@ private: bool m_right_consumed{false}; // last RightDown was a gesture terminator, not a menu bool m_escalate_repick{true}; // re-picking the same sub-element takes the whole body void render_solid_highlight(); - // The shared body of the above: one highlight from explicit arguments, so the committed - // selection and the hover pre-highlight cannot drift apart in how they look. - void render_solid_sel(SolidSel kind, int body, int face, const std::vector& edge_pts, - const Vec3d& vertex_pt, const ColorRGBA& rgb, float alpha_mul); + // The above's edge and vertex highlight, from explicit arguments, so the committed selection + // and the hover pre-highlight cannot drift apart in how they look. + 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. + struct FaceHighlight { + int body{-1}; + std::vector faces; // sorted + const void* key{nullptr}; + std::vector> edges; // in the body's shape coordinates + }; + FaceHighlight make_face_highlight(int body, std::vector faces) const; + // The faces the committed pick names: the one face of a face pick, every face of a picked + // body. Empty for an edge or vertex pick. + std::vector> picked_faces() const; + // `cache`, rebuilt only when it no longer holds these faces of this body's current shape. + const FaceHighlight& cached_face_highlight(FaceHighlight& cache, int body, std::vector faces) const; + void render_face_outline(const FaceHighlight& h, bool quiet); + std::vector m_hl_faces; // set_highlight_faces, one entry per body + FaceHighlight m_sel_hl; // the committed Face/Whole pick + FaceHighlight m_pre_hl; // the face under the pointer void render_datum_planes(); // translucent rectangles for datum/reference planes void render_view_helpers(); // world origin planes + axis triad (P / A toggles) bool m_show_planes{false}; @@ -1207,8 +1315,8 @@ private: std::vector> m_mate_links; GLModel m_mc_stroke_model; GLModel m_mc_fill_model; // the face treatment's shaded facets - GLModel m_solid_face_model; GLModel m_solid_edge_model; + GLModel m_body_edges_model; GLModel m_solid_vertex_model; int m_display_pick_region{-1}; // selected closed-region index within that feature (-1 none) diff --git a/src/slic3r/GUI/CAD/DesignTextDialog.cpp b/src/slic3r/GUI/CAD/DesignTextDialog.cpp new file mode 100644 index 0000000000..334f9afca8 --- /dev/null +++ b/src/slic3r/GUI/CAD/DesignTextDialog.cpp @@ -0,0 +1,316 @@ +#include "DesignTextDialog.hpp" + +#include "slic3r/GUI/GUI_App.hpp" +#include "slic3r/GUI/I18N.hpp" +#include "slic3r/GUI/Widgets/DialogButtons.hpp" +#include "slic3r/GUI/Widgets/TextInput.hpp" +#include "slic3r/GUI/Widgets/ComboBox.hpp" +#include "slic3r/GUI/Widgets/CheckBox.hpp" +#include "slic3r/GUI/Widgets/Label.hpp" +#include "slic3r/Utils/WxFontUtils.hpp" +#include "libslic3r/AppConfig.hpp" +#include "libslic3r/Emboss.hpp" +#include "libslic3r/CAD/SketchImport.hpp" +#include "libslic3r/Point.hpp" +#include "slic3r/GUI/GUI_Utils.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace Slic3r { namespace GUI { + +static const char* kFontKey = "cad_text_font"; // WxFontUtils::store_wxFont descriptor +static const char* kHeightKey = "cad_text_height"; // mm + +// Installed scalable faces, sorted, enumerated once per session: listing them takes long enough on +// a machine with many fonts to be felt each time the dialog opened. +static const wxArrayString& font_faces() +{ + static wxArrayString faces = [] { + // '@' faces are the vertical-writing aliases GTK/MSW list twice. + wxArrayString f = wxFontEnumerator::GetFacenames(wxFONTENCODING_SYSTEM, false); + f.erase(std::remove_if(f.begin(), f.end(), [](const wxString& n) { return n.StartsWith("@"); }), f.end()); + f.Sort(); + return f; + }(); + return faces; +} + +static bool parse_mm(wxString t, double& out) +{ + t.Replace(",", "."); + return t.Trim(true).Trim(false).ToCDouble(&out) && out >= 0.5 && out <= 500.0; +} + +DesignTextDialog::DesignTextDialog(wxWindow* parent, const Spec* initial) + : DPIDialog(parent, wxID_ANY, _L("Text"), wxDefaultPosition, wxDefaultSize, wxCAPTION | wxCLOSE_BOX) +{ + SetFont(Label::Body_14); + SetBackgroundColour(*wxWHITE); // light palette colour, dark-mapped by UpdateDlgDarkUI + const int em = em_unit(); + + // The text being edited, else the last used font and height, else the GUI font at 10 mm. + wxFont font = wxSystemSettings::GetFont(wxSYS_DEFAULT_GUI_FONT); + double height = 10.0; + if (initial != nullptr) { + wxFont f = WxFontUtils::load_wxFont(initial->font); + if (f.IsOk()) font = f; + if (initial->height > 0.0) height = std::clamp(initial->height, 0.5, 500.0); + } else if (AppConfig* cfg = wxGetApp().app_config) { + const std::string desc = cfg->get(kFontKey); + if (!desc.empty()) { + wxFont f = WxFontUtils::load_wxFont(desc); + if (f.IsOk()) font = f; + } + const std::string h = cfg->get(kHeightKey); + if (!h.empty()) { + try { height = std::clamp(std::stod(h), 0.5, 500.0); } catch (...) {} + } + } + m_last_height = height; + + auto* form = new wxFlexGridSizer(2, em / 2, em); + form->AddGrowableCol(1, 1); + auto label = [this, form](const wxString& text) { + form->Add(new wxStaticText(this, wxID_ANY, text), 0, wxALIGN_CENTER_VERTICAL); + }; + + m_text = new ::TextInput(this, initial ? initial->text : wxString(), "", "", wxDefaultPosition, + wxSize(30 * em, -1), wxTE_PROCESS_ENTER); + m_text->GetTextCtrl()->SetHint(_L("Type the text to insert")); + label(_L("Text")); + form->Add(m_text, 1, wxEXPAND); + + m_face = new ::ComboBox(this, wxID_ANY, wxEmptyString, wxDefaultPosition, wxSize(30 * em, -1), 0, nullptr, + wxCB_READONLY); + const wxArrayString& faces = font_faces(); + for (const wxString& f : faces) + m_face->Append(f); + const int sel = faces.Index(font.GetFaceName()); + m_face->SetSelection(sel != wxNOT_FOUND ? sel : 0); + label(_L("Font")); + form->Add(m_face, 1, wxEXPAND); + + // Orca's CheckBox carries no label: each one sits beside its own text. + auto* style = new wxBoxSizer(wxHORIZONTAL); + auto check = [this, style, em](const wxString& text, bool value) { + auto* c = new ::CheckBox(this); + c->SetValue(value); + style->Add(c, 0, wxALIGN_CENTER_VERTICAL); + style->Add(new wxStaticText(this, wxID_ANY, text), 0, wxALIGN_CENTER_VERTICAL | wxLEFT | wxRIGHT, em / 2); + return c; + }; + m_bold = check(_L("Bold"), WxFontUtils::is_bold(font)); + m_italic = check(_L("Italic"), WxFontUtils::is_italic(font)); + form->AddSpacer(0); + form->Add(style, 0); + + m_height = new ::TextInput(this, wxString::FromCDouble(height, 1), _L("mm"), "", wxDefaultPosition, + wxSize(10 * em, -1), wxTE_PROCESS_ENTER); + label(_L("Height")); + form->Add(m_height, 0); + + // The outline of what will be inserted, fitted to the box, with its real size under it. + // A thumbnail only: the text itself is drawn in the canvas, where it will be. + m_preview = new wxWindow(this, wxID_ANY, wxDefaultPosition, wxSize(30 * em, 5 * em)); + m_preview->SetBackgroundStyle(wxBG_STYLE_PAINT); + m_preview->Bind(wxEVT_PAINT, [this](wxPaintEvent&) { draw_preview(m_preview); }); + m_preview->Bind(wxEVT_SIZE, [this](wxSizeEvent& e) { m_preview->Refresh(); e.Skip(); }); + m_size = new wxStaticText(this, wxID_ANY, wxEmptyString); + + auto* buttons = new DialogButtons(this, {"OK", "Cancel"}); + m_ok = buttons->GetOK(); + + auto* top = new wxBoxSizer(wxVERTICAL); + top->Add(form, 0, wxEXPAND | wxALL, em); + top->Add(m_preview, 1, wxEXPAND | wxLEFT | wxRIGHT, em); + top->Add(m_size, 0, wxLEFT | wxRIGHT | wxTOP, em); + top->Add(buttons, 0, wxEXPAND); + + m_text->Bind(wxEVT_TEXT, [this](wxCommandEvent& e) { update_preview(); e.Skip(); }); + m_text->Bind(wxEVT_TEXT_ENTER, [this](wxCommandEvent&) { accept(); }); // Enter = OK, as everywhere in the tab + m_face->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent& e) { load_font(); update_preview(); e.Skip(); }); + for (::CheckBox* c : {m_bold, m_italic}) + c->Bind(wxEVT_TOGGLEBUTTON, [this](wxCommandEvent& e) { e.Skip(); CallAfter([this] { load_font(); update_preview(); }); }); + m_height->Bind(wxEVT_TEXT, [this](wxCommandEvent& e) { update_preview(); e.Skip(); }); + m_height->Bind(wxEVT_TEXT_ENTER, [this](wxCommandEvent&) { accept(); }); + buttons->GetOK()->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { accept(); }); + buttons->GetCANCEL()->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { cancel(); }); + Bind(wxEVT_CLOSE_WINDOW, [this](wxCloseEvent&) { cancel(); }); + Bind(wxEVT_CHAR_HOOK, [this](wxKeyEvent& e) { + if (e.GetKeyCode() == WXK_ESCAPE) cancel(); + else e.Skip(); + }); + + SetSizerAndFit(top); + // Out of the middle of the window, where the text is being placed: top right of the parent. + if (parent != nullptr) { + const wxRect pr = parent->GetScreenRect(); + SetPosition(wxPoint(std::max(pr.GetLeft(), pr.GetRight() - GetSize().x - 2 * em), pr.GetTop() + 8 * em)); + } else { + CenterOnParent(); + } + wxGetApp().UpdateDlgDarkUI(this); + + load_font(); + update_preview(); + m_text->GetTextCtrl()->SetFocus(); +} + +double DesignTextDialog::height_mm() const +{ + double h = 0.0; + if (parse_mm(m_height->GetTextCtrl()->GetValue(), h)) + const_cast(this)->m_last_height = h; + return m_last_height; +} + +wxString DesignTextDialog::text() const { return m_text->GetTextCtrl()->GetValue(); } + +DesignTextDialog::Spec DesignTextDialog::spec() const +{ + return { text(), WxFontUtils::store_wxFont(current_font()), height_mm() }; +} + +void DesignTextDialog::accept() +{ + if (m_done || m_regions.empty()) return; // nothing to insert: the size line says why + m_done = true; + if (AppConfig* cfg = wxGetApp().app_config) { + cfg->set(kFontKey, WxFontUtils::store_wxFont(current_font())); + cfg->set(kHeightKey, std::to_string(height_mm())); + } + if (on_accept) on_accept(); +} + +void DesignTextDialog::cancel() +{ + if (m_done) return; + m_done = true; + if (on_cancel) on_cancel(); +} + +wxFont DesignTextDialog::current_font() const +{ + wxFontInfo info(12); + if (m_face->GetSelection() != wxNOT_FOUND) + info.FaceName(m_face->GetString(m_face->GetSelection())); + info.Bold(m_bold->GetValue()).Italic(m_italic->GetValue()); + return wxFont(info); +} + +void DesignTextDialog::load_font() +{ + const wxFont font = current_font(); + m_font_file.reset(); + if (font.IsOk() && WxFontUtils::can_load(font)) + m_font_file = WxFontUtils::create_font_file(font); +} + +void DesignTextDialog::update_preview() +{ + const std::string utf8(text().ToUTF8().data()); + m_regions.clear(); + if (m_font_file && !utf8.empty()) + m_regions = text_to_regions(utf8, height_mm(), m_font_file); + + wxString line; + if (!m_font_file) + line = _L("This font cannot be used for text — pick another one"); + else if (utf8.empty()) + line = _L("Type the text to see its size"); + else if (m_regions.empty()) + line = _L("This font has no outline for this text"); + else { + double lo_x = std::numeric_limits::max(), lo_y = lo_x; + double hi_x = -lo_x, hi_y = -lo_x; + for (const auto& r : m_regions) + for (const auto& c : r) + for (const Vec2d& p : c) { + lo_x = std::min(lo_x, p.x()); hi_x = std::max(hi_x, p.x()); + lo_y = std::min(lo_y, p.y()); hi_y = std::max(hi_y, p.y()); + } + line = wxString::Format(_L("Size: %.1f × %.1f mm"), hi_x - lo_x, hi_y - lo_y); + } + m_size->SetLabel(line); + if (m_ok) m_ok->Enable(!m_regions.empty()); + m_preview->Refresh(); + if (on_change) on_change(); +} + +void DesignTextDialog::draw_preview(wxWindow* canvas) +{ + wxAutoBufferedPaintDC dc(canvas); + const bool dark = wxGetApp().dark_mode(); + dc.SetBackground(wxBrush(dark ? wxColour(0x1e, 0x1e, 0x22) : wxColour(0xf4, 0xf4, 0xf6))); + dc.Clear(); + if (m_regions.empty()) return; + + double lo_x = std::numeric_limits::max(), lo_y = lo_x, hi_x = -lo_x, hi_y = -lo_x; + for (const auto& r : m_regions) + for (const auto& c : r) + for (const Vec2d& p : c) { + lo_x = std::min(lo_x, p.x()); hi_x = std::max(hi_x, p.x()); + lo_y = std::min(lo_y, p.y()); hi_y = std::max(hi_y, p.y()); + } + const wxSize sz = canvas->GetClientSize(); + const double pad = 0.08 * std::min(sz.x, sz.y); + const double w = std::max(hi_x - lo_x, 1e-6), h = std::max(hi_y - lo_y, 1e-6); + const double k = std::min((sz.x - 2 * pad) / w, (sz.y - 2 * pad) / h); + const double ox = 0.5 * (sz.x - k * w), oy = 0.5 * (sz.y + k * h); // y up -> screen y down + + std::unique_ptr gc(wxGraphicsContext::Create(dc)); + if (!gc) return; + wxGraphicsPath path = gc->CreatePath(); + for (const auto& r : m_regions) + for (const auto& c : r) { + if (c.empty()) continue; + path.MoveToPoint(ox + k * (c[0].x() - lo_x), oy - k * (c[0].y() - lo_y)); + for (size_t i = 1; i < c.size(); ++i) + path.AddLineToPoint(ox + k * (c[i].x() - lo_x), oy - k * (c[i].y() - lo_y)); + path.CloseSubpath(); + } + gc->SetBrush(wxBrush(wxColour(0x00, 0x96, 0x88))); // Orca accent + gc->SetPen(*wxTRANSPARENT_PEN); + gc->FillPath(path, wxODDEVEN_RULE); // holes (the counter of an "o") stay open +} + +void DesignTextDialog::on_dpi_changed(const wxRect&) +{ + m_text->Rescale(); + m_face->Rescale(); + m_height->Rescale(); + m_bold->Rescale(); + m_italic->Rescale(); + GetSizer()->SetSizeHints(this); + Refresh(); +} + +void DesignTextDialog::on_sys_color_changed() +{ + SetBackgroundColour(*wxWHITE); + wxGetApp().UpdateDlgDarkUI(this); + m_preview->Refresh(); // its colours are read at paint time + Refresh(); +} + +}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/CAD/DesignTextDialog.hpp b/src/slic3r/GUI/CAD/DesignTextDialog.hpp new file mode 100644 index 0000000000..247ac76f78 --- /dev/null +++ b/src/slic3r/GUI/CAD/DesignTextDialog.hpp @@ -0,0 +1,81 @@ +#ifndef slic3r_GUI_DesignTextDialog_hpp_ +#define slic3r_GUI_DesignTextDialog_hpp_ + +#include "slic3r/GUI/GUI_Utils.hpp" +#include "libslic3r/CAD/SketchImport.hpp" + +#include +#include + +#include +#include +#include + +class wxStaticText; +class TextInput; +class ComboBox; +class CheckBox; + +namespace Slic3r { +namespace Emboss { struct FontFile; } +namespace GUI { + +// Text for the Design tab: the words, the font (face, bold, italic) and the height, with the +// size in millimetres of exactly what will be inserted. MODELESS: the host draws the text in +// the canvas as it is typed (on_change), so the dialog stays out of the way and can be moved — +// a modal dialog is pinned over the middle of the window on GNOME. Enter or OK accepts, Esc, +// Cancel or closing it cancels (charter 4.2); each fires its callback once and the host then +// destroys the dialog. A new text starts from the last font and height used. +class DesignTextDialog : public DPIDialog +{ +public: + struct Spec { + wxString text; + std::string font; // WxFontUtils::store_wxFont descriptor (face, bold, italic) + double height{10.0}; + }; + // `initial` reopens an existing text for editing; nullptr starts a new one. + DesignTextDialog(wxWindow* parent, const Spec* initial = nullptr); + + // The vectorised text, centred on the origin, in mm. Empty when there is nothing to insert. + const ImportRegions& regions() const { return m_regions; } + wxString text() const; + Spec spec() const; + + std::function on_change; // the outline changed (text, font or height) + std::function on_accept; // Enter / OK with something to insert + std::function on_cancel; // Esc / Cancel / closed + +protected: + void on_dpi_changed(const wxRect& suggested_rect) override; + void on_sys_color_changed() override; + +private: + void load_font(); // m_face/m_bold/m_italic -> m_font_file + void update_preview(); // text/font/height -> m_regions + size label + wxFont current_font() const; + void draw_preview(wxWindow* canvas); + void accept(); + void cancel(); + bool m_done{false}; // accept/cancel fire once + + double height_mm() const; // the typed height, or the last valid one while it is being typed + + ::TextInput* m_text{nullptr}; + ::ComboBox* m_face{nullptr}; + ::CheckBox* m_bold{nullptr}; + ::CheckBox* m_italic{nullptr}; + ::TextInput* m_height{nullptr}; // mm; Orca's SpinInput is integer-only + double m_last_height{10.0}; + wxWindow* m_preview{nullptr}; + wxStaticText* m_size{nullptr}; + wxWindow* m_ok{nullptr}; + + std::shared_ptr m_font_file; + ImportRegions m_regions; +}; + +} // namespace GUI +} // namespace Slic3r + +#endif // slic3r_GUI_DesignTextDialog_hpp_ diff --git a/src/slic3r/GUI/CAD/McpControl.cpp b/src/slic3r/GUI/CAD/McpControl.cpp index 9436f3d686..42f97785a7 100644 --- a/src/slic3r/GUI/CAD/McpControl.cpp +++ b/src/slic3r/GUI/CAD/McpControl.cpp @@ -24,6 +24,8 @@ #include #include #include +#include +#include #include #include @@ -161,15 +163,15 @@ json describe_tools() json{{"name", "profile"}, {"type", "array"}, {"default", json::array()}, {"description", "optional closed contour [[x,y],...] in plane mm; overrides width/height"}}, json{{"name", "boolean"}, {"type", "string"}, {"enum", json::array({"new", "union", "subtract", "intersect"})}, {"default", "new"}}, })}}, - json{{"name", "fillet"}, {"summary", "Round a measured edge of a body (edge id from query_topology on that body)."}, + json{{"name", "fillet"}, {"summary", "Round measured edges of a body (edge ids from query_topology on that body)."}, {"params", json::array({ - json{{"name", "edge"}, {"type", "integer"}}, + json{{"name", "edge"}, {"type", "integer|array"}, {"description", "one edge id, or an array of ids rounded together in one feature"}}, json{{"name", "radius"}, {"type", "number"}, {"unit", "mm"}, {"default", 1}, {"min", 0.01}}, json{{"name", "body"}, {"type", "integer"}, {"default", -1}, {"description", "target body; omit for the last body. edge id is resolved against THIS body."}}, })}}, - json{{"name", "chamfer"}, {"summary", "Chamfer a measured edge of a body (edge id from query_topology on that body)."}, + json{{"name", "chamfer"}, {"summary", "Chamfer measured edges of a body (edge ids from query_topology on that body)."}, {"params", json::array({ - json{{"name", "edge"}, {"type", "integer"}}, + json{{"name", "edge"}, {"type", "integer|array"}, {"description", "one edge id, or an array of ids chamfered together in one feature"}}, json{{"name", "distance"}, {"type", "number"}, {"unit", "mm"}, {"default", 1}, {"min", 0.01}}, json{{"name", "body"}, {"type", "integer"}, {"default", -1}, {"description", "target body; omit for the last body. edge id is resolved against THIS body."}}, })}}, @@ -872,16 +874,30 @@ int target_body_arg(const json& params, const CadDocument& doc) return bi; // <0 -> kernel uses the last body } +// `edge` is one id or an array of ids; an array becomes ONE feature, every id resolved +// against the same body (a chain of single-edge features would see the ids drift). +std::vector edge_ids_arg(const json& params, const char* verb) +{ + if (!params.contains("edge")) + throw std::runtime_error(std::string(verb) + " needs 'edge' (id or array of ids from query_topology)"); + const json& e = params["edge"]; + std::vector ids; + if (e.is_array()) for (const json& v : e) ids.push_back(v.get()); + else ids.push_back(e.get()); + if (ids.empty()) throw std::runtime_error(std::string(verb) + ": 'edge' is an empty array"); + return ids; +} + json action_fillet(DesignPanel* panel, const json& params) { - if (!params.contains("edge")) throw std::runtime_error("fillet needs 'edge' (id from query_topology)"); + const std::vector edges = edge_ids_arg(params, "fillet"); const double radius = params.value("radius", 1.0); if (radius <= 0) throw std::runtime_error("radius must be > 0"); CadDocument& doc = panel->mcp_doc(); if (doc.bodies.empty()) throw std::runtime_error("no body to fillet"); int bi = target_body_arg(params, doc); doc.checkpoint(); - int f = doc.add_fillet(radius, params["edge"].get(), "Fillet"); + int f = doc.add_fillet(radius, edges, "Fillet"); if (bi >= 0) doc.features[f].target_body = bi; // edge id resolved against THIS body's shape bool ok = doc.recompute(); if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; } @@ -892,14 +908,14 @@ json action_fillet(DesignPanel* panel, const json& params) json action_chamfer(DesignPanel* panel, const json& params) { - if (!params.contains("edge")) throw std::runtime_error("chamfer needs 'edge' (id from query_topology)"); + const std::vector edges = edge_ids_arg(params, "chamfer"); const double dist = params.value("distance", 1.0); if (dist <= 0) throw std::runtime_error("distance must be > 0"); CadDocument& doc = panel->mcp_doc(); if (doc.bodies.empty()) throw std::runtime_error("no body to chamfer"); int bi = target_body_arg(params, doc); doc.checkpoint(); - int c = doc.add_chamfer(dist, params["edge"].get(), "Chamfer"); + int c = doc.add_chamfer(dist, edges, "Chamfer"); if (bi >= 0) doc.features[c].target_body = bi; // edge id resolved against THIS body's shape bool ok = doc.recompute(); if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; } @@ -1491,9 +1507,13 @@ json sketch_report(DesignSketchTool& t) } json open_ends = json::array(); for (const Vec2d& p : rep.open_ends) open_ends.push_back(json::array({p.x(), p.y()})); - // A profile is buildable when at least one loop closed and nothing is left dangling. - const bool buildable = !rep.loops.empty() && rep.open_ends.empty(); - return json{{"closed_loops", loops}, {"open_ends", open_ends}, {"buildable", buildable}}; + // A profile is buildable when at least one loop closed, nothing is left dangling and no loop + // crosses or folds back on itself. + json defects = json::array(); + for (const auto& l : rep.loops) + if (l.defect) defects.push_back(json::array({l.defect_at.x(), l.defect_at.y()})); + const bool buildable = !rep.loops.empty() && rep.open_ends.empty() && defects.empty(); + return json{{"closed_loops", loops}, {"open_ends", open_ends}, {"defects", defects}, {"buildable", buildable}}; } json action_sketch_describe(DesignPanel* panel, const json& params) @@ -2002,6 +2022,22 @@ std::string handle_on_main(const std::string& method, const json& params, const DesignPanel* panel = DesignPanel::ensure(); if (!panel) return rpc_error(id, -32001, "Design panel not ready"); + // A project opened without ever showing the Design tab has its recipe only in the Model; + // load it, as showing the tab would, before anything reads or writes the document. + panel->hydrate_from_model(); + + // Methods that only LOOK. Everything else changes the document or the live sketch, and + // must not do it under a GUI editor, a sketch session it does not own, or a rebuild in + // progress (the GUI's worker thread holds the document then). + static const std::set kReadOnly = { + "describe_tools", "describe_scene", "query_topology", "measure", "mass_properties", + "slice_body", "validate_against", "list_verbs", "sketch_describe", "sketch_validate", + "check_interference" }; + if (kReadOnly.count(method) == 0) { + std::string why; + if (panel->mcp_busy(method.rfind("sketch_", 0) == 0, why)) + return rpc_error(id, -32002, "Design tab busy: " + why); + } // Stale-id guard, checked here rather than in each handler. // @@ -2116,11 +2152,16 @@ std::string dispatch_request(const std::string& line) auto prom = std::make_shared>(); auto fut = prom->get_future(); + // 0 = queued, 1 = running, 2 = abandoned. A request that timed out while still QUEUED must + // never run: the client has been told it failed, and a retry would otherwise apply it twice. + auto state = std::make_shared>(0); // Nothing may escape this lambda. It is invoked by the wx event loop, which has no // handler of its own, so an escaping exception is std::terminate — the socket would // become a way for any client to kill the application. handle_on_main() catches what // it knows about; this catches what it does not, and still answers the caller. - wxGetApp().CallAfter([prom, method, params, id]() { + wxGetApp().CallAfter([prom, state, method, params, id]() { + int queued = 0; + if (!state->compare_exchange_strong(queued, 1)) return; // abandoned by a timeout try { prom->set_value(handle_on_main(method, params, id)); } catch (const std::exception& ex) { @@ -2129,20 +2170,33 @@ std::string dispatch_request(const std::string& line) prom->set_value(rpc_error(id, -32000, "internal error: unknown exception")); } }); - if (fut.wait_for(std::chrono::seconds(15)) != std::future_status::ready) - return rpc_error(id, -32000, "main-thread timeout"); + if (fut.wait_for(std::chrono::seconds(15)) != std::future_status::ready) { + int queued = 0; + if (state->compare_exchange_strong(queued, 2)) + return rpc_error(id, -32000, "main-thread timeout: the command was NOT run"); + // Already running: it will finish, so wait for its real answer rather than report a + // failure for a command that is in fact being applied. + fut.wait(); + } return fut.get(); } -// Read newline-delimited requests off one client connection until EOF. +// Read newline-delimited requests off one client connection until EOF. A line may not grow +// past kMaxLine: a client that never sends a newline would otherwise grow this buffer until the +// process runs out of memory. void serve_client(int cfd) { + constexpr size_t kMaxLine = 16u << 20; // 16 MB — far above any real request std::string buf; char chunk[4096]; for (;;) { ssize_t n = ::read(cfd, chunk, sizeof(chunk)); if (n <= 0) break; buf.append(chunk, size_t(n)); + if (buf.size() > kMaxLine && buf.find('\n') == std::string::npos) { + BOOST_LOG_TRIVIAL(error) << "MCP: request line over " << kMaxLine << " bytes; closing the connection"; + return; + } size_t nl; while ((nl = buf.find('\n')) != std::string::npos) { std::string line = buf.substr(0, nl); @@ -2162,9 +2216,30 @@ void serve_client(int cfd) } } +// Where the socket lives, for the exit handler. +std::string g_sock_path; + +void remove_socket_at_exit() +{ + struct stat st{}; + if (!g_sock_path.empty() && ::lstat(g_sock_path.c_str(), &st) == 0 && S_ISSOCK(st.st_mode)) + ::unlink(g_sock_path.c_str()); +} + void server_thread(std::string sock_path) { - ::unlink(sock_path.c_str()); + // Clear a stale socket from an earlier run — and ONLY a socket. ORCA_CAD_MCP names a path, + // and unlinking it unconditionally deleted whatever file that path happened to be. + { + struct stat st{}; + if (::lstat(sock_path.c_str(), &st) == 0) { + if (!S_ISSOCK(st.st_mode)) { + BOOST_LOG_TRIVIAL(error) << "MCP: " << sock_path << " exists and is not a socket; refusing to replace it"; + return; + } + ::unlink(sock_path.c_str()); + } + } int sfd = ::socket(AF_UNIX, SOCK_STREAM, 0); if (sfd < 0) { BOOST_LOG_TRIVIAL(error) << "MCP: socket() failed"; return; } @@ -2189,6 +2264,8 @@ void server_thread(std::string sock_path) ::close(sfd); ::unlink(sock_path.c_str()); return; } if (::listen(sfd, 1) < 0) { BOOST_LOG_TRIVIAL(error) << "MCP: listen() failed"; ::close(sfd); return; } + g_sock_path = sock_path; + std::atexit(remove_socket_at_exit); // do not leave the socket file behind BOOST_LOG_TRIVIAL(info) << "MCP control listening on " << sock_path; for (;;) { diff --git a/src/slic3r/GUI/CAD/McpControl.hpp b/src/slic3r/GUI/CAD/McpControl.hpp index 12e3289eb5..3a07bf920d 100644 --- a/src/slic3r/GUI/CAD/McpControl.hpp +++ b/src/slic3r/GUI/CAD/McpControl.hpp @@ -1,22 +1,24 @@ #ifndef slic3r_GUI_McpControl_hpp_ #define slic3r_GUI_McpControl_hpp_ -// MCP control surface (slice 1): a local JSON-RPC 2.0 server, line-delimited over a -// Unix domain socket, that lets an external MCP bridge drive and perceive the Design -// tab. Off unless the env var ORCA_CAD_MCP is set: +// MCP control surface: a local JSON-RPC 2.0 server, line-delimited over a Unix domain +// socket, that lets an external MCP bridge drive and perceive the Design tab. Off unless the +// CAD feature is enabled in Preferences AND the env var ORCA_CAD_MCP is set: // ORCA_CAD_MCP=1 -> socket at /tmp/orca-cad-mcp.sock -// ORCA_CAD_MCP=/path/to.sock -> socket at that path -// All CAD work is marshalled onto the wx main thread and runs through the SAME -// CadDocument kernel the GUI uses (no parallel engine). Slice-1 methods: -// describe_tools, describe_scene, extrude. +// ORCA_CAD_MCP=/path/to.sock -> socket at that path (an existing non-socket file there is +// left alone and the server does not start) +// The socket is created 0600 and removed at exit. All CAD work is marshalled onto the wx main +// thread and runs through the SAME CadDocument kernel the GUI uses (no parallel engine); +// describe_tools lists the methods. A method that changes the document is refused (-32002) +// while the Design tab is busy with it — a rebuild, an open feature card, a sketch session. // // ponytail: Unix-socket only (POSIX). Windows compiles this to a no-op; add a named // pipe transport when a Windows agent actually needs it. namespace Slic3r { namespace GUI { -// Start the server thread iff ORCA_CAD_MCP is set. Safe to call once after the -// MainFrame + DesignPanel exist. No-op when the env var is unset or on Windows. +// Start the server thread iff ORCA_CAD_MCP is set. Safe to call once the MainFrame exists — +// the Design panel is built on the first request. No-op when the env var is unset or on Windows. void start_mcp_control_if_enabled(); }} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/CAD/SketchInlineEditor.cpp b/src/slic3r/GUI/CAD/SketchInlineEditor.cpp index d2376be633..49d122e14f 100644 --- a/src/slic3r/GUI/CAD/SketchInlineEditor.cpp +++ b/src/slic3r/GUI/CAD/SketchInlineEditor.cpp @@ -6,7 +6,7 @@ #include #include -#include // BringWindowToDisplayFront / GetCurrentWindow +#include // FindWindowByName / BringWindowToDisplayFront #include #include @@ -20,10 +20,13 @@ namespace GUI { namespace { +const char* const kWindowName = "##sketchvalue"; + // Numbers are typed and shown with a POINT, whatever the locale: this field feeds a CAD kernel, // and a decimal comma reaching it as a thousands separator is a silent order-of-magnitude error. // Parsing accepts either separator because a keyboard's numeric pad may only offer one. -std::string fmt_value(double v, int digits = 2) +// Trailing zeros are dropped, so a count opens as "3" and a length as "37.457", not "37.46". +std::string fmt_value(double v, int digits = 3) { char fmt[16]; std::snprintf(fmt, sizeof(fmt), "%%.%df", digits); @@ -31,7 +34,13 @@ std::string fmt_value(double v, int digits = 2) std::snprintf(buf, sizeof(buf), fmt, v); for (char* c = buf; *c; ++c) if (*c == ',') *c = '.'; - return std::string(buf); + std::string s(buf); + if (s.find('.') != std::string::npos) { + while (!s.empty() && s.back() == '0') s.pop_back(); + if (!s.empty() && s.back() == '.') s.pop_back(); + } + if (s == "-0") s = "0"; + return s; } bool parse_value(const char* text, double& out) @@ -81,6 +90,9 @@ void SketchInlineEditor::open(const wxPoint& canvas_px, double value, const std: m_cancel = std::move(on_cancel); const std::string v = fmt_value(value); std::snprintf(m_buf, sizeof(m_buf), "%s", v.c_str()); + m_prefill_value = value; + m_prefill_text = v; + m_active = false; m_open = true; // ImGui takes keyboard focus for one frame on request; asking on the frame the field first // appears is what makes typing land without a click. There is no window manager to consult. @@ -92,6 +104,7 @@ void SketchInlineEditor::close() { m_open = false; m_focus_pending = false; + m_active = false; m_commit = nullptr; m_cancel = nullptr; m_err.clear(); @@ -115,10 +128,32 @@ void SketchInlineEditor::do_cancel() if (cb) cb(); } +bool SketchInlineEditor::refuse(const std::string& why) +{ + // Only meaningful while a commit callback runs and before it opened another field. + if (!m_in_commit || m_open) return false; + m_anchor = m_last.anchor; + m_title = m_last.title; + m_commit = m_last.commit; + m_cancel = m_last.cancel; + m_prefill_value = m_last.prefill_value; + m_prefill_text = m_last.prefill_text; + std::snprintf(m_buf, sizeof(m_buf), "%s", m_last.buf.c_str()); + m_err = why; + m_open = true; + m_focus_pending = true; + ux_trace("refused", m_title, "reason=" + why); + return true; +} + void SketchInlineEditor::do_commit() { double v = 0.0; - if (!parse_value(m_buf, v)) { + // An untouched field commits the value it opened with, not its rounded display: Enter on a + // 37.4567 line must not turn it into a 37.457 driving dimension. + if (m_prefill_text == m_buf) + v = m_prefill_value; + else if (!parse_value(m_buf, v)) { // Refusing input in silence is indistinguishable from the app having frozen: the field // just sits there and the user has no idea what it wants. Say so in the title line and // keep editing. @@ -129,11 +164,15 @@ void SketchInlineEditor::do_commit() } ux_trace("commit", m_title, std::string("typed=") + m_buf + " value=" + fmt_value(v, 4)); auto cb = m_commit; + m_last = Closed{ m_anchor, m_title, std::string(m_buf), m_commit, m_cancel, m_prefill_value, m_prefill_text }; close(); // AFTER close(): the callback may open the next queued dimension (a rectangle queues Width // then Height), and doing that into a field that still believes it is open would drop the // second one's prefill on the floor. + m_in_commit = true; if (cb) cb(v); + m_in_commit = false; + m_last = Closed{}; } bool SketchInlineEditor::render(ImGuiWrapper& imgui, float scale) @@ -145,10 +184,9 @@ bool SketchInlineEditor::render(ImGuiWrapper& imgui, float scale) ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 3.0f); // NoInputs is what every other sketch overlay sets and is exactly what this one must not: // it is the only overlay in the tab that the user types into. - imgui.begin(std::string("##sketchvalue"), + imgui.begin(std::string(kWindowName), ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings); - ImGui::BringWindowToDisplayFront(ImGui::GetCurrentWindow()); if (!m_title.empty() || !m_err.empty()) { if (m_err.empty()) { @@ -168,10 +206,15 @@ bool SketchInlineEditor::render(ImGuiWrapper& imgui, float scale) // EnterReturnsTrue so Enter commits from inside the widget; AutoSelectAll so the prefill is // replaced by the first digit typed, which is what "pre-selected" meant when this was a // wxTextCtrl and is what makes typing a value a single gesture. + // No CharsDecimal: it lets only the LOCALE's decimal separator through, so in a C locale a + // comma could not be typed at all, and it admits '*' and '/' that the parser then refuses. + // parse_value() is the one judge of what is a number. const bool entered = ImGui::InputText("##sketchvalue_in", m_buf, sizeof(m_buf), ImGuiInputTextFlags_EnterReturnsTrue - | ImGuiInputTextFlags_AutoSelectAll - | ImGuiInputTextFlags_CharsDecimal); + | ImGuiInputTextFlags_AutoSelectAll); + m_active = ImGui::IsItemActive(); + // Tab accepts the value like Enter does (and moves on to the next queued field, if any). + const bool tabbed = m_active && ImGui::IsKeyPressed(ImGui::GetKeyIndex(ImGuiKey_Tab)); // MEASUREMENT, not a fix: one line per frame saying whether ImGui believes it owns the // keyboard and whether our widget is the active one. "Typing does not arrive" has two very // different causes — no FRAMES (this canvas repaints on demand only, so an idle canvas never @@ -195,11 +238,19 @@ bool SketchInlineEditor::render(ImGuiWrapper& imgui, float scale) // Act AFTER end(): do_commit can reopen the field for the next queued dimension, and that // must not happen inside this frame's window. - if (entered) + if (entered || tabbed) do_commit(); else if (ImGui::IsKeyPressed(ImGui::GetKeyIndex(ImGuiKey_Escape))) do_cancel(); return true; } +void SketchInlineEditor::bring_to_front() +{ + // Not gated on m_open: a commit closes the field after it was drawn, and that frame still + // shows it. A window that did not Begin this frame is not rendered, so lifting it is inert. + if (ImGuiWindow* w = ImGui::FindWindowByName(kWindowName)) + ImGui::BringWindowToDisplayFront(w); +} + }} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/CAD/SketchInlineEditor.hpp b/src/slic3r/GUI/CAD/SketchInlineEditor.hpp index 162d157864..677e74b3a0 100644 --- a/src/slic3r/GUI/CAD/SketchInlineEditor.hpp +++ b/src/slic3r/GUI/CAD/SketchInlineEditor.hpp @@ -48,11 +48,17 @@ public: void close(); // drop it with neither callback void cancel(); // if open, run the registered cancel (keep-as-drawn) void commit(); // if open, run the registered commit (accept the typed value) + // Called from inside a commit callback that cannot accept the value (a negative length, a + // count of 1): the field comes back with the typed text and `why` in its title line, instead + // of closing as if the value had been taken. + bool refuse(const std::string& why); // true if the field came back bool is_open() const { return m_open; } // Draw it, and let ImGui do the editing. Called from DesignSketchTool::render() inside the // frame's ImGui pass; `scale` is the tool's m_render_scale. Returns true if it drew. bool render(ImGuiWrapper& imgui, float scale); + // Lift the field above every other overlay. Call it after the frame's last overlay is drawn. + void bring_to_front(); // Ask for another frame. THE FIELD DOES NOT WORK WITHOUT THIS, and the reason is a deadlock // that only a per-frame trace shows: @@ -69,11 +75,10 @@ public: // breaks the circle. std::function request_frame; - // Kept because callers ask them, but there is no longer any difference to report: with no - // window there is no state where the field is on screen but logically closed, and no state - // where it is open but somebody else holds the keyboard. bool is_mapped() const { return m_open; } - bool has_focus() const { return m_open; } + // Does the ImGui text widget own the keyboard? False once a click elsewhere deactivated it; + // the panel then forwards Enter/Tab to commit() itself, since ImGui no longer sees them. + bool has_focus() const { return m_open && (m_active || m_focus_pending); } void dismiss() { close(); } private: @@ -88,6 +93,17 @@ private: std::string m_title; std::string m_err; // why the last value was refused, shown in the title line char m_buf[64]{}; // the edited text; ImGui::InputText writes into it + double m_prefill_value{0.0}; // the exact value the field opened with... + std::string m_prefill_text; // ...and how it was shown: an untouched field commits the former + bool m_active{false}; // the InputText was the active item last frame + // What the last commit closed, so refuse() can bring the same field back. + struct Closed { + wxPoint anchor; std::string title; std::string buf; + std::function commit; std::function cancel; + double prefill_value{0.0}; std::string prefill_text; + }; + Closed m_last; + bool m_in_commit{false}; }; }} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/GLCanvas3D.cpp b/src/slic3r/GUI/GLCanvas3D.cpp index 3f099108f9..36f632096c 100644 --- a/src/slic3r/GUI/GLCanvas3D.cpp +++ b/src/slic3r/GUI/GLCanvas3D.cpp @@ -1457,7 +1457,7 @@ void GLCanvas3D::on_change_color_mode(bool is_dark, bool reinit) { // set dirty to re-generate icon texture m_separator_toolbar.set_icon_dirty(); m_main_toolbar.set_icon_dirty(); - wxGetApp().plater()->get_collapse_toolbar().set_icon_dirty(); + collapse_toolbar().set_icon_dirty(); m_assemble_view_toolbar.set_icon_dirty(); m_gizmos.set_icon_dirty(); } @@ -1989,6 +1989,12 @@ void GLCanvas3D::enable_collapse_toolbar(bool enable) m_collapse_toolbar_enabled = enable; } +void GLCanvas3D::set_collapse_toolbar(GLToolbar* toolbar, std::function side) +{ + m_collapse_toolbar = toolbar; + m_collapse_side = std::move(side); +} + void GLCanvas3D::enable_plate_chrome(bool enable) { m_plate_chrome_enabled = enable; @@ -2069,24 +2075,33 @@ void GLCanvas3D::update_volumes_colors_by_extruder() bool GLCanvas3D::is_collapse_toolbar_on_left() const { - auto state = wxGetApp().plater()->get_sidebar_docking_state(); - return state == Sidebar::Left; + return collapse_side() == CollapseSide::Left; } float GLCanvas3D::get_collapse_toolbar_width() const { - GLToolbar& collapse_toolbar = wxGetApp().plater()->get_collapse_toolbar(); - const auto state = wxGetApp().plater()->get_sidebar_docking_state(); - - return state != Sidebar::None ? collapse_toolbar.get_width() : 0; + return collapse_side() != CollapseSide::None ? collapse_toolbar().get_width() : 0; } float GLCanvas3D::get_collapse_toolbar_height() const { - GLToolbar& collapse_toolbar = wxGetApp().plater()->get_collapse_toolbar(); - const auto state = wxGetApp().plater()->get_sidebar_docking_state(); + return collapse_side() != CollapseSide::None ? collapse_toolbar().get_height() : 0; +} - return state != Sidebar::None ? collapse_toolbar.get_height() : 0; +GLToolbar& GLCanvas3D::collapse_toolbar() const +{ + return m_collapse_toolbar != nullptr ? *m_collapse_toolbar : wxGetApp().plater()->get_collapse_toolbar(); +} + +CollapseSide GLCanvas3D::collapse_side() const +{ + if (m_collapse_side) + return m_collapse_side(); + switch (wxGetApp().plater()->get_sidebar_docking_state()) { + case Sidebar::Left: return CollapseSide::Left; + case Sidebar::Right: return CollapseSide::Right; + default: return CollapseSide::None; + } } bool GLCanvas3D::make_current_for_postinit() { @@ -2291,7 +2306,7 @@ void GLCanvas3D::_render_frame(bool scene_dirty, bool only_init) tooltip = m_assemble_view_toolbar.get_tooltip(); if (tooltip.empty()) - tooltip = wxGetApp().plater()->get_collapse_toolbar().get_tooltip(); + tooltip = collapse_toolbar().get_tooltip(); // BBS #if 0 @@ -3495,7 +3510,7 @@ void GLCanvas3D::on_idle(wxIdleEvent& evt) m_overlay_dirty |= m_assemble_view_toolbar.update_items_state(); // BBS //m_dirty |= wxGetApp().plater()->get_view_toolbar().update_items_state(); - m_overlay_dirty |= wxGetApp().plater()->get_collapse_toolbar().update_items_state(); + m_overlay_dirty |= collapse_toolbar().update_items_state(); // apply() DRAINS the 3D-mouse queue, so only the canvas actually on screen may call it: a // hidden canvas renders nothing, so the motion it swallowed moves the shared camera without // ever being drawn and the next visible frame jumps several states at once. @@ -3571,68 +3586,10 @@ void GLCanvas3D::on_char(wxKeyEvent& evt) return; } -#ifdef SLIC3R_CAD - const int ctrlMask = wxMOD_CONTROL; - const int shiftMask = wxMOD_SHIFT; -#endif - - // Design tab: Delete/Backspace removes the selected sketch entities while a - // sketch tool is active and the canvas has focus (dialog text fields are separate - // wx controls, so this never eats their editing keys). -#ifdef SLIC3R_CAD - if (m_design_sketch_tool != nullptr && m_design_sketch_tool->is_active() - && (keyCode == WXK_DELETE || keyCode == WXK_BACK) - && !m_design_sketch_tool->selection().empty()) { - m_design_sketch_tool->delete_selected(); - m_dirty = true; - render(); - return; - } -#endif - - // Esc exits the active sketch tool (Onshape-like, layered: abort in-progress entity -> - // drop to Select -> exit the session back to Feature mode). -#ifdef SLIC3R_CAD - if (m_design_sketch_tool != nullptr && m_design_sketch_tool->is_active() - && keyCode == WXK_ESCAPE) { - m_design_sketch_tool->request_exit(); - m_dirty = true; - render(); - return; - } -#endif - - // Design tab: Ctrl+Z / Ctrl+Shift+Z (and Ctrl+Y) undo/redo the Design feature - // history. Scoped by m_design_sketch_tool — only the Design canvas owns one — so the - // main 3D editor's undo/redo (the CanvasView3D-gated cases further below) is untouched. - // Handled here, before the generic Ctrl block, so it takes precedence and early-returns. -#ifdef SLIC3R_CAD - if (m_design_sketch_tool != nullptr && (evt.GetModifiers() & ctrlMask) != 0) { - const bool is_z = (keyCode == 'z' || keyCode == 'Z' || keyCode == WXK_CONTROL_Z); - const bool is_y = (keyCode == 'y' || keyCode == 'Y' || keyCode == WXK_CONTROL_Y); - if (is_z || is_y) { - const bool redo = is_y || ((evt.GetModifiers() & shiftMask) != 0); - m_design_sketch_tool->request_undo_redo(redo); - m_dirty = true; - render(); - return; - } - } -#endif - - // Design tab: F = Place on Face (Prepare's lay-flat), when the Design viewport is up - // and a body face is selected. The tool forwards to DesignPanel::place_on_face; it returns - // false (no face picked) so F falls through to the default handler below. -#ifdef SLIC3R_CAD - if (m_design_sketch_tool != nullptr && m_design_sketch_tool->has_display() - && (keyCode == 'f' || keyCode == 'F') && (evt.GetModifiers() & ctrlMask) == 0) { - if (m_design_sketch_tool->request_place_on_face()) { - m_dirty = true; - render(); - return; - } - } -#endif + // Design tab: Delete, Esc, Ctrl+Z/Y and F are NOT handled here. DesignPanel's CHAR_HOOK owns + // them (one route per key, whatever holds focus) and only lets a key through to this canvas + // when an in-canvas value field is open -- which is exactly when Backspace must edit the + // number rather than delete the geometry it measures. //BBS: add orient deactivate logic if (keyCode == WXK_ESCAPE @@ -3910,19 +3867,6 @@ static void key_released(int key) { s_keys_down.erase(key); } void GLCanvas3D::on_key(wxKeyEvent& evt) { - // Design tab: Delete/Backspace removes selected sketch entities. GTK delivers - // these as KEY_DOWN rather than CHAR, so handle it here too. -#ifdef SLIC3R_CAD - if (evt.GetEventType() == wxEVT_KEY_DOWN - && m_design_sketch_tool != nullptr && m_design_sketch_tool->is_active() - && (evt.GetKeyCode() == WXK_DELETE || evt.GetKeyCode() == WXK_BACK) - && !m_design_sketch_tool->selection().empty()) { - m_design_sketch_tool->delete_selected(); - m_dirty = true; - render(); - return; - } -#endif const int keyCode = evt.GetKeyCode(); if (evt.GetEventType() == wxEVT_KEY_DOWN) @@ -4358,6 +4302,13 @@ void GLCanvas3D::on_mouse(wxMouseEvent& evt) // (drag/orbit/wheel) so the camera keeps working over the display-only plate. #ifdef SLIC3R_CAD if (m_design_sketch_tool != nullptr && m_design_sketch_tool->has_display()) { + // Except over the sidebar collapse button, which is drawn over the sketch. + if (collapse_toolbar().on_mouse(evt, *this)) { + if (evt.LeftUp() || evt.MiddleUp() || evt.RightUp()) + mouse_up_cleanup(); + m_mouse.set_start_position_3D_as_invalid(); + return; + } 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)) { @@ -4406,7 +4357,7 @@ void GLCanvas3D::on_mouse(wxMouseEvent& evt) return; } - if (!mouse_in_layer_editing && wxGetApp().plater()->get_collapse_toolbar().on_mouse(evt, *this)) { + if (!mouse_in_layer_editing && collapse_toolbar().on_mouse(evt, *this)) { if (evt.LeftUp() || evt.MiddleUp() || evt.RightUp()) mouse_up_cleanup(); m_mouse.set_start_position_3D_as_invalid(); @@ -5078,8 +5029,6 @@ bool GLCanvas3D::is_camera_rotate(const wxMouseEvent& evt, const std::mapget_collapse_toolbar().init(background_data); + collapse_toolbar().init(background_data); // main toolbar { @@ -7415,7 +7362,17 @@ bool GLCanvas3D::_init_view_toolbar() bool GLCanvas3D::_init_collapse_toolbar() { - return wxGetApp().plater()->init_collapse_toolbar(); + if (m_collapse_toolbar == nullptr) + return wxGetApp().plater()->init_collapse_toolbar(); + + // Its owner laid it out; the background needs the GL context. + BackgroundTexture::Metadata background_data; + background_data.filename = m_is_dark ? "toolbar_background_dark.png" : "toolbar_background.png"; + background_data.left = 16; + background_data.top = 16; + background_data.right = 16; + background_data.bottom = 16; + return m_collapse_toolbar->init(background_data); } bool GLCanvas3D::_set_current() @@ -8813,7 +8770,7 @@ void GLCanvas3D::_render_objects(GLVolumeCollection::ERenderType type, bool with else { m_volumes.set_clipping_plane(m_camera_clipping_plane.get_data()); } - if (m_canvas_type == CanvasAssembleView) + if (m_canvas_type == CanvasAssembleView || !m_sinking_contours_enabled) m_volumes.set_show_sinking_contours(false); else m_volumes.set_show_sinking_contours(!m_gizmos.is_hiding_instances()); @@ -8829,10 +8786,11 @@ void GLCanvas3D::_render_objects(GLVolumeCollection::ERenderType type, bool with const bool realistic_mode = _is_realistic_view_enabled(); const bool realistic_phong = wxGetApp().app_config != nullptr && wxGetApp().app_config->get_bool(SETTING_OPENGL_REALISTIC_PHONG); - const std::string shader_name = (realistic_mode && realistic_phong) ? "phong" : "gouraud"; + const std::string shader_name = (m_studio_lighting || (realistic_mode && realistic_phong)) ? "phong" : "gouraud"; GLShaderProgram* shader = wxGetApp().get_shader(shader_name); if (shader == nullptr && shader_name != "gouraud") shader = wxGetApp().get_shader("gouraud"); + const bool studio = m_studio_lighting && shader != nullptr && shader->get_name() == "phong"; ECanvasType canvas_type = this->m_canvas_type; bool partly_inside_enable = canvas_type == ECanvasType::CanvasAssembleView ? false : true; // The edited printer's per-extruder printable heights feed the object shader's @@ -8845,6 +8803,13 @@ void GLCanvas3D::_render_objects(GLVolumeCollection::ERenderType type, bool with const bool phong_ssao = wxGetApp().app_config != nullptr && wxGetApp().app_config->get_bool(SETTING_OPENGL_PHONG_SSAO); shader->set_uniform("enable_ssao", phong_ssao); + // Set on every use: the program is shared, so a canvas that leaves it unset would inherit + // the last canvas's choice. + shader->set_uniform("lighting_model", studio ? 1 : 0); + if (studio) { + const Transform3d& view = wxGetApp().plater()->get_camera().get_view_matrix(); + shader->set_uniform("world_up_eye", Vec3f((view.matrix().block<3, 3>(0, 0) * Vec3d::UnitZ()).cast())); + } // Object-on-object and self shadows: sample the depth map built in _render_shadow_map_pass(). // shadow_intensity == 0 disables the effect entirely (unchanged behavior when off / unsupported). @@ -9172,11 +9137,12 @@ void GLCanvas3D::_render_sequential_clearance() void GLCanvas3D::_check_and_update_toolbar_icon_scale() { // Update collapse toolbar - GLToolbar& collapse_toolbar = wxGetApp().plater()->get_collapse_toolbar(); - collapse_toolbar.set_enabled(wxGetApp().plater()->get_sidebar_docking_state() != Sidebar::None); + GLToolbar& collapse_toolbar = this->collapse_toolbar(); + collapse_toolbar.set_enabled(collapse_side() != CollapseSide::None); - // Don't update a toolbar scale, when we are on a Preview - if (wxGetApp().plater()->is_preview_shown()) { + // Don't update a toolbar scale, when we are on a Preview. Asked of this canvas, not of the + // plater: the Design tab's canvas is on screen while the plater's current view may be Preview. + if (m_canvas_type == CanvasPreview) { IMSlider *m_layers_slider = get_gcode_viewer().get_layers_slider(); IMSlider *m_moves_slider = get_gcode_viewer().get_moves_slider(); float sc = get_scale(); @@ -9248,7 +9214,9 @@ void GLCanvas3D::_check_and_update_toolbar_icon_scale() // set minimum scale as a auto scale for the toolbars float new_scale = std::min(new_h_scale, new_v_scale); new_scale /= get_scale(); - if (fabs(new_scale - scale) > 0.05) // scale is changed by 5% and more + // Only a canvas with toolbars to fit sets the shared size: a collapse button alone (the Design + // tab's canvas) always fits at the largest. + if (items_cnt > collapse_toolbar.get_visible_items_cnt() && fabs(new_scale - scale) > 0.05) // scale is changed by 5% and more wxGetApp().set_auto_toolbar_icon_scale(new_scale); } @@ -9330,7 +9298,7 @@ size_t GLCanvas3D::_overlay_signature(const ImDrawData* draw_data) const size_t hash = ImGuiWrapper::draw_data_signature(draw_data); for (size_t state_hash : { m_main_toolbar.get_state_hash(), m_separator_toolbar.get_state_hash(), m_assemble_view_toolbar.get_state_hash(), - wxGetApp().plater()->get_collapse_toolbar().get_state_hash(), + collapse_toolbar().get_state_hash(), m_gizmos.get_overlay_state_hash() }) boost::hash_combine(hash, state_hash); return hash; @@ -10385,18 +10353,17 @@ void GLCanvas3D::_render_collapse_toolbar() const if (!m_collapse_toolbar_enabled) return; - auto& plater = *wxGetApp().plater(); - const auto sidebar_docking_dir = plater.get_sidebar_docking_state(); - if (sidebar_docking_dir == Sidebar::None) { + const CollapseSide side = collapse_side(); + if (side == CollapseSide::None) { return; } - GLToolbar& collapse_toolbar = plater.get_collapse_toolbar(); + GLToolbar& collapse_toolbar = this->collapse_toolbar(); const Size cnv_size = get_canvas_size(); const float top = 0.5f * (float)cnv_size.get_height(); - const float left = sidebar_docking_dir == Sidebar::Right ? 0.5f * (float) cnv_size.get_width() - (float) collapse_toolbar.get_width() : - -0.5f * (float) cnv_size.get_width(); + const float left = side == CollapseSide::Right ? 0.5f * (float) cnv_size.get_width() - (float) collapse_toolbar.get_width() : + -0.5f * (float) cnv_size.get_width(); collapse_toolbar.set_position(top, left); collapse_toolbar.render(*this); @@ -11792,7 +11759,7 @@ bool GLCanvas3D::_deactivate_layersediting_menu() bool GLCanvas3D::_deactivate_collapse_toolbar_items() { - GLToolbar& collapse_toolbar = wxGetApp().plater()->get_collapse_toolbar(); + GLToolbar& collapse_toolbar = this->collapse_toolbar(); if (collapse_toolbar.is_item_pressed("print")) { collapse_toolbar.force_left_action(collapse_toolbar.get_item_id("print"), *this); return true; diff --git a/src/slic3r/GUI/GLCanvas3D.hpp b/src/slic3r/GUI/GLCanvas3D.hpp index cf2a2aa96d..e53a9d6aba 100644 --- a/src/slic3r/GUI/GLCanvas3D.hpp +++ b/src/slic3r/GUI/GLCanvas3D.hpp @@ -18,6 +18,7 @@ #include "libslic3r/libslic3r.h" #include "libslic3r/Line.hpp" #include +#include #include #include #include @@ -599,6 +600,9 @@ private: mutable float m_sc{1}; mutable float m_paint_toolbar_width; bool m_collapse_toolbar_enabled{true}; + // The collapse button of a sidebar other than Prepare's, from set_collapse_toolbar(). + 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. @@ -606,6 +610,9 @@ private: // 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}; + // Design tab: draw the outline where a volume crosses the bed. GLVolume::SinkingContours slices + // the plater model's mesh by the volume's ids, so a canvas over its own Model must turn it off. + bool m_sinking_contours_enabled{true}; // Design tab: CAD grid drawn on the bed plane in place of the plate's corner-origin grid. // Two GLModels (10 mm minor / 50 mm major) generated from the bed centre so a line passes // exactly through the modeling origin; built once and rebuilt only when the bed shape changes. @@ -622,6 +629,9 @@ private: //BBS: add canvas type for assemble view usage ECanvasType m_canvas_type; + // Objects drawn with the phong shader's studio lighting whatever the realistic-view settings + // (the Design tab's canvas). Off for every canvas of the slicer, which render as before. + bool m_studio_lighting{false}; std::array m_clipping_planes; ClippingPlane m_camera_clipping_plane; bool m_use_clipping_planes; @@ -659,11 +669,6 @@ private: std::array m_old_size{ 0, 0 }; bool m_is_touchpad_navigation{ false }; - // CAD navigation (Design tab only): left-drag is a selection rubber band, so orbit moves - // to middle-drag and pan to right-drag — the Onshape/SolidWorks mapping. Off everywhere - // else, so Prepare/Preview keep the mouse the user already learned. - bool m_cad_navigation{ false }; - // Screen is only refreshed from the OnIdle handler if it is dirty. bool m_dirty; // A frame is needed, and only for the overlay. @@ -866,6 +871,7 @@ public: void set_context(wxGLContext* context) { m_context = context; } void set_type(ECanvasType type) { m_canvas_type = type; } + void set_studio_lighting(bool on) { m_studio_lighting = on; } ECanvasType get_canvas_type() { return m_canvas_type; } wxGLCanvas* get_wxglcanvas() { return m_canvas; } @@ -1014,10 +1020,15 @@ public: void enable_return_toolbar(bool enable); void enable_separator_toolbar(bool enable); void enable_collapse_toolbar(bool enable); + // A canvas beside a sidebar other than Prepare's shows that sidebar's collapse button: `toolbar`, + // set up with setup_collapse_toolbar(), on the edge `side` reports. Call before the canvas is + // 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_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; } @@ -1199,7 +1210,6 @@ public: bool clicked_button_matches_action(const wxMouseEvent& evt, MouseAction action, const std::map& mappings) const; bool is_camera_rotate(const wxMouseEvent& evt, const std::map& mappings) const; bool is_camera_pan(const wxMouseEvent& evt, const std::map& mappings) const; - void set_cad_navigation(bool b) { m_cad_navigation = b; } Size get_canvas_size() const; Vec2d get_local_mouse_position() const; @@ -1381,6 +1391,8 @@ private: // BBS //bool _init_view_toolbar(); bool _init_collapse_toolbar(); + GLToolbar& collapse_toolbar() const; + CollapseSide collapse_side() const; bool _set_current(); bool _set_shown_canvas_current(); diff --git a/src/slic3r/GUI/GLToolbar.cpp b/src/slic3r/GUI/GLToolbar.cpp index efbf8e9d6b..2c1b1515d9 100644 --- a/src/slic3r/GUI/GLToolbar.cpp +++ b/src/slic3r/GUI/GLToolbar.cpp @@ -13,6 +13,7 @@ #include "libslic3r/Utils.hpp" #include #include +#include #include #include #include @@ -1675,5 +1676,27 @@ float GLToolbar::get_scaled_icon_size() return m_layout.icons_size * m_layout.scale; } +bool setup_collapse_toolbar(GLToolbar& toolbar, std::function toggle) +{ + toolbar.set_layout_type(GLToolbar::Layout::Vertical); + toolbar.set_horizontal_orientation(GLToolbar::Layout::HO_Right); + toolbar.set_vertical_orientation(GLToolbar::Layout::VO_Top); + toolbar.set_border(4.0f); + toolbar.set_separator_size(4); + toolbar.set_gap_size(2); + + toolbar.del_all_item(); + + GLToolbarItem::Data item; + + item.name = "collapse_sidebar"; + // set collapse svg name + item.icon_filename = "collapse.svg"; + item.sprite_id = 0; + item.left.action_callback = std::move(toggle); + + return toolbar.add_item(item); +} + } // namespace GUI } // namespace Slic3r diff --git a/src/slic3r/GUI/GLToolbar.hpp b/src/slic3r/GUI/GLToolbar.hpp index 192af7e50d..571f75a35c 100644 --- a/src/slic3r/GUI/GLToolbar.hpp +++ b/src/slic3r/GUI/GLToolbar.hpp @@ -457,6 +457,14 @@ private: bool update_items_enabled_state(); }; +// The canvas edge a sidebar collapse button sits on: the side its sidebar is docked on, or none +// while that sidebar floats. +enum class CollapseSide { None, Left, Right }; + +// Lays `toolbar` out as a sidebar collapse button that runs `toggle`: Prepare's, and those of +// other sidebars (GLCanvas3D::set_collapse_toolbar()). +bool setup_collapse_toolbar(GLToolbar& toolbar, std::function toggle); + } // namespace GUI } // namespace Slic3r diff --git a/src/slic3r/GUI/GUI_ObjectList.cpp b/src/slic3r/GUI/GUI_ObjectList.cpp index f6ac5f64df..fd6da1beef 100644 --- a/src/slic3r/GUI/GUI_ObjectList.cpp +++ b/src/slic3r/GUI/GUI_ObjectList.cpp @@ -2664,6 +2664,11 @@ void ObjectList::load_shape_object(const std::string &type_name) } void ObjectList::load_mesh_object(const TriangleMesh &mesh, const wxString &name, bool center) +{ + load_mesh_object(std::vector>{{&mesh, name}}, name, center); +} + +void ObjectList::load_mesh_object(const std::vector> &parts, const wxString &name, bool center) { // Add mesh to model as a new object Model& model = wxGetApp().plater()->model(); @@ -2673,14 +2678,19 @@ void ObjectList::load_mesh_object(const TriangleMesh &mesh, const wxString &name #endif /* _DEBUG */ std::vector object_idxs; - auto bb = mesh.bounding_box(); + BoundingBoxf3 bb; ModelObject* new_object = model.add_object(); new_object->name = into_u8(name); new_object->add_instance(); // each object should have at least one instance - ModelVolume* new_volume = new_object->add_volume(mesh); + // add_volume() centres each part on its own geometry, so the part offsets carry the + // meshes' placement relative to each other. + for (const auto& [mesh, part_name] : parts) { + bb.merge(mesh->bounding_box()); + ModelVolume* new_volume = new_object->add_volume(*mesh); + new_volume->name = into_u8(part_name); + } new_object->sort_volumes(true); - new_volume->name = into_u8(name); // set a default extruder value, since user can't add it manually // BBS new_object->config.set_key_value("extruder", new ConfigOptionInt(1)); diff --git a/src/slic3r/GUI/GUI_ObjectList.hpp b/src/slic3r/GUI/GUI_ObjectList.hpp index d297b65b2d..03bffb6cdc 100644 --- a/src/slic3r/GUI/GUI_ObjectList.hpp +++ b/src/slic3r/GUI/GUI_ObjectList.hpp @@ -315,6 +315,8 @@ public: void load_generic_subobject(const std::string& type_name, const ModelVolumeType type); void load_shape_object(const std::string &type_name); void load_mesh_object(const TriangleMesh &mesh, const wxString &name, bool center = true); + // One object holding one part per mesh; the parts keep their placement relative to each other. + void load_mesh_object(const std::vector> &parts, const wxString &name, bool center = true); // BBS void switch_to_object_process(); bool del_object(const int obj_idx, bool refresh_immediately = true); diff --git a/src/slic3r/GUI/Gizmos/GLGizmoPrimitive.cpp b/src/slic3r/GUI/Gizmos/GLGizmoPrimitive.cpp index 9e6ec9bc57..d0ddb23d89 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoPrimitive.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoPrimitive.cpp @@ -30,7 +30,11 @@ GLGizmoPrimitive::GLGizmoPrimitive(GLCanvas3D& parent, const std::string& icon_f bool GLGizmoPrimitive::on_init() { return true; } std::string GLGizmoPrimitive::on_get_name() const { return _u8L("Primitive"); } -bool GLGizmoPrimitive::on_is_activable() const { return true; } +// Part of the experimental CAD feature: built into every CAD build, but offered in the Prepare +// toolbar only when that feature is switched on in Preferences — switched off, Prepare must be +// exactly what it is without it. +bool GLGizmoPrimitive::on_is_activable() const { return wxGetApp().is_enable_cad_feature(); } +bool GLGizmoPrimitive::on_is_selectable() const { return wxGetApp().is_enable_cad_feature(); } void GLGizmoPrimitive::on_render() {} void GLGizmoPrimitive::on_set_state() { if (m_state == EState::On) { m_params = PrimitiveParams{}; m_preview_dirty = true; } } @@ -90,14 +94,15 @@ void GLGizmoPrimitive::on_render_input_window(float x, float y, float bottom_lim | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar); - if (ImGui::CollapsingHeader("Shape", ImGuiTreeNodeFlags_DefaultOpen)) { - static const char* names[] = {"Box", "Cylinder", "Sphere", "Cone", "Torus"}; + if (ImGui::CollapsingHeader(_u8L("Shape").c_str(), ImGuiTreeNodeFlags_DefaultOpen)) { + const std::string names_s[] = {_u8L("Box"), _u8L("Cylinder"), _u8L("Sphere"), _u8L("Cone"), _u8L("Torus")}; + const char* names[] = {names_s[0].c_str(), names_s[1].c_str(), names_s[2].c_str(), names_s[3].c_str(), names_s[4].c_str()}; int cur = (int)m_params.type; if (ImGui::Combo("##type", &cur, names, (int)PrimitiveType::COUNT)) { m_params.type = (PrimitiveType)cur; m_preview_dirty = true; } - ImGui::Text("Quick:"); + ImGui::TextUnformatted(_u8L("Quick:").c_str()); ImGui::SameLine(); if (ImGui::SmallButton("10mm")) apply_preset("10mm cube", 10, 10, 10); ImGui::SameLine(); @@ -108,7 +113,7 @@ void GLGizmoPrimitive::on_render_input_window(float x, float y, float bottom_lim ImGui::Separator(); - if (ImGui::CollapsingHeader("Dimensions", ImGuiTreeNodeFlags_DefaultOpen)) { + if (ImGui::CollapsingHeader(_u8L("Dimensions").c_str(), ImGuiTreeNodeFlags_DefaultOpen)) { auto dim = [&](const char* label, double& val, double step=0.5, double fast=5.0) { ImGui::SetNextItemWidth(130); if (ImGui::InputDouble(label, &val, step, fast, "%.1f mm")) m_preview_dirty = true; @@ -116,25 +121,25 @@ void GLGizmoPrimitive::on_render_input_window(float x, float y, float bottom_lim }; switch (m_params.type) { case PrimitiveType::Box: - dim("Width (X)", m_params.box_w); - dim("Depth (Y)", m_params.box_d); - dim("Height (Z)", m_params.box_h); + dim(_u8L("Width (X)").c_str(), m_params.box_w); + dim(_u8L("Depth (Y)").c_str(), m_params.box_d); + dim(_u8L("Height (Z)").c_str(), m_params.box_h); break; case PrimitiveType::Cylinder: - dim("Radius", m_params.cyl_radius); - dim("Height", m_params.cyl_height); + dim(_u8L("Radius").c_str(), m_params.cyl_radius); + dim(_u8L("Height").c_str(), m_params.cyl_height); break; case PrimitiveType::Sphere: - dim("Radius", m_params.sph_radius); + dim(_u8L("Radius").c_str(), m_params.sph_radius); break; case PrimitiveType::Cone: - dim("Bottom R", m_params.cone_r1); - dim("Top R", m_params.cone_r2); - dim("Height", m_params.cone_height); + dim(_u8L("Bottom R").c_str(), m_params.cone_r1); + dim(_u8L("Top R").c_str(), m_params.cone_r2); + dim(_u8L("Height").c_str(), m_params.cone_height); break; case PrimitiveType::Torus: - dim("Major R", m_params.torus_r1); - dim("Minor R", m_params.torus_r2, 0.1, 1.0); + dim(_u8L("Major R").c_str(), m_params.torus_r1); + dim(_u8L("Minor R").c_str(), m_params.torus_r2, 0.1, 1.0); break; default: break; } @@ -142,26 +147,28 @@ void GLGizmoPrimitive::on_render_input_window(float x, float y, float bottom_lim ImGui::Separator(); - if (ImGui::CollapsingHeader("Fillet / Chamfer")) { - ImGui::Checkbox("Enable", &m_params.dressup_enabled); + if (ImGui::CollapsingHeader(_u8L("Fillet / Chamfer").c_str())) { + ImGui::Checkbox(_u8L("Enable").c_str(), &m_params.dressup_enabled); if (m_params.dressup_enabled) { - static const char* dn[] = {"Fillet", "Chamfer"}; + const std::string dn_s[] = {_u8L("Fillet"), _u8L("Chamfer")}; + const char* dn[] = {dn_s[0].c_str(), dn_s[1].c_str()}; int du = (int)m_params.dressup_type; ImGui::SetNextItemWidth(100); if (ImGui::Combo("##dtype", &du, dn, 2)) { m_params.dressup_type = (DressUpType)du; m_preview_dirty = true; } - static const char* fn[] = {"All edges", "Top edges", "Bottom edges", "Lateral edges"}; + const std::string fn_s[] = {_u8L("All edges"), _u8L("Top edges"), _u8L("Bottom edges"), _u8L("Lateral edges")}; + const char* fn[] = {fn_s[0].c_str(), fn_s[1].c_str(), fn_s[2].c_str(), fn_s[3].c_str()}; int fg = (int)m_params.dressup_faces; ImGui::SetNextItemWidth(140); - if (ImGui::Combo("Edges", &fg, fn, 4)) { m_params.dressup_faces = (FaceGroup)fg; m_preview_dirty = true; } + if (ImGui::Combo(_u8L("Edges").c_str(), &fg, fn, 4)) { m_params.dressup_faces = (FaceGroup)fg; m_preview_dirty = true; } if (m_params.dressup_type == DressUpType::Fillet) { ImGui::SetNextItemWidth(100); - if (ImGui::InputDouble("Radius", &m_params.dressup_radius, 0.1, 1.0, "%.1f mm")) { + if (ImGui::InputDouble(_u8L("Radius").c_str(), &m_params.dressup_radius, 0.1, 1.0, "%.1f mm")) { if (m_params.dressup_radius < 0.1) m_params.dressup_radius = 0.1; m_preview_dirty = true; } } else { ImGui::SetNextItemWidth(100); - if (ImGui::InputDouble("Distance", &m_params.dressup_chamfer_dist, 0.1, 1.0, "%.1f mm")) { + if (ImGui::InputDouble(_u8L("Distance").c_str(), &m_params.dressup_chamfer_dist, 0.1, 1.0, "%.1f mm")) { if (m_params.dressup_chamfer_dist < 0.1) m_params.dressup_chamfer_dist = 0.1; m_preview_dirty = true; } @@ -171,9 +178,9 @@ void GLGizmoPrimitive::on_render_input_window(float x, float y, float bottom_lim ImGui::Separator(); - if (ImGui::CollapsingHeader("Quality")) { + if (ImGui::CollapsingHeader(_u8L("Quality").c_str())) { ImGui::SetNextItemWidth(130); - if (ImGui::InputDouble("Mesh resolution", &m_params.linear_deflection, 0.001, 0.1, "%.3f mm")) { + if (ImGui::InputDouble(_u8L("Mesh resolution").c_str(), &m_params.linear_deflection, 0.001, 0.1, "%.3f mm")) { if (m_params.linear_deflection < 0.001) m_params.linear_deflection = 0.001; if (m_params.linear_deflection > 1.0) m_params.linear_deflection = 1.0; m_preview_dirty = true; @@ -182,10 +189,10 @@ void GLGizmoPrimitive::on_render_input_window(float x, float y, float bottom_lim ImGui::Separator(); - if (ImGui::Button("Add Shape", {-1, 28})) + if (ImGui::Button(_u8L("Add Shape").c_str(), {-1, 28})) apply_primitive(); - if (ImGui::Button("Close", {-1, 0})) + if (ImGui::Button(_u8L("Close").c_str(), {-1, 0})) m_parent.reset_all_gizmos(); GizmoImguiEnd(); diff --git a/src/slic3r/GUI/Gizmos/GLGizmoPrimitive.hpp b/src/slic3r/GUI/Gizmos/GLGizmoPrimitive.hpp index 8ac7754007..eef44a64c4 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoPrimitive.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoPrimitive.hpp @@ -24,6 +24,7 @@ protected: bool on_init() override; std::string on_get_name() const override; bool on_is_activable() const override; + bool on_is_selectable() const override; void on_render() override; void on_set_state() override; CommonGizmosDataID on_get_requirements() const override; diff --git a/src/slic3r/GUI/Gizmos/GLGizmoSketch.cpp b/src/slic3r/GUI/Gizmos/GLGizmoSketch.cpp index 786928cbe5..625004767e 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoSketch.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoSketch.cpp @@ -34,8 +34,6 @@ #include "slic3r/GUI/I18N.hpp" #include "slic3r/GUI/Selection.hpp" -#define UL(s) Slic3r::GUI::I18N::translate_utf8((s)).c_str() - namespace Slic3r { namespace GUI { @@ -44,7 +42,11 @@ GLGizmoSketch::GLGizmoSketch(GLCanvas3D& parent, const std::string& icon_filenam bool GLGizmoSketch::on_init() { return true; } std::string GLGizmoSketch::on_get_name() const { return _u8L("Sketch"); } -bool GLGizmoSketch::on_is_activable() const { return true; } +// Part of the experimental CAD feature: built into every CAD build, but offered in the Prepare +// toolbar only when that feature is switched on in Preferences — switched off, Prepare must be +// exactly what it is without it. +bool GLGizmoSketch::on_is_activable() const { return wxGetApp().is_enable_cad_feature(); } +bool GLGizmoSketch::on_is_selectable() const { return wxGetApp().is_enable_cad_feature(); } void GLGizmoSketch::on_render() {} void GLGizmoSketch::on_set_state() { if (m_state == EState::On) clear_all(); } bool GLGizmoSketch::on_mouse(const wxMouseEvent&) { return false; } @@ -276,8 +278,9 @@ void GLGizmoSketch::on_render_input_window(float x, float y, float bottom_limit) | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar); - if (ImGui::CollapsingHeader(UL("Profile"), ImGuiTreeNodeFlags_DefaultOpen)) { - static const char* names[] = {"Line", "Rectangle", "Circle", "Polygon"}; + if (ImGui::CollapsingHeader(_u8L("Profile").c_str(), ImGuiTreeNodeFlags_DefaultOpen)) { + const std::string names_s[] = {_u8L("Line"), _u8L("Rectangle"), _u8L("Circle"), _u8L("Polygon")}; + const char* names[] = {names_s[0].c_str(), names_s[1].c_str(), names_s[2].c_str(), names_s[3].c_str()}; int cur = (int)m_tool; if (ImGui::Combo("##shape", &cur, names, (int)SketchTool::COUNT)) { m_tool = (SketchTool)cur; @@ -285,39 +288,40 @@ void GLGizmoSketch::on_render_input_window(float x, float y, float bottom_limit) } ImGui::SameLine(); if (m_imgui->button("+##newprofile")) m_active_profile = -1; - if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", UL("Start new profile (for holes)")); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", _u8L("Start new profile (for holes)").c_str()); if (m_tool == SketchTool::Rectangle) { - ImGui::SetNextItemWidth(80); if (ImGui::InputDouble("W", &m_rect_w,1,10,"%.0f")) build_preset_profile(); + ImGui::SetNextItemWidth(80); if (ImGui::InputDouble(_u8L("Width").c_str(), &m_rect_w,1,10,"%.0f")) build_preset_profile(); ImGui::SameLine(); - ImGui::SetNextItemWidth(80); if (ImGui::InputDouble("H", &m_rect_h,1,10,"%.0f")) build_preset_profile(); + ImGui::SetNextItemWidth(80); if (ImGui::InputDouble(_u8L("Height").c_str(), &m_rect_h,1,10,"%.0f")) build_preset_profile(); } else if (m_tool == SketchTool::Circle) { - ImGui::SetNextItemWidth(80); if (ImGui::InputDouble("R", &m_circle_r,1,5,"%.0f")) build_preset_profile(); + ImGui::SetNextItemWidth(80); if (ImGui::InputDouble(_u8L("Radius").c_str(), &m_circle_r,1,5,"%.0f")) build_preset_profile(); ImGui::SameLine(); - ImGui::SetNextItemWidth(80); if (ImGui::SliderInt("Seg", &m_circle_seg,8,64)) build_preset_profile(); + ImGui::SetNextItemWidth(80); if (ImGui::SliderInt(_u8L("Segments").c_str(), &m_circle_seg,8,64)) build_preset_profile(); } else if (m_tool == SketchTool::Polygon) { - ImGui::SetNextItemWidth(80); if (ImGui::SliderInt("Sides", &m_poly_sides,3,12)) build_preset_profile(); + ImGui::SetNextItemWidth(80); if (ImGui::SliderInt(_u8L("Sides").c_str(), &m_poly_sides,3,12)) build_preset_profile(); ImGui::SameLine(); - ImGui::SetNextItemWidth(80); if (ImGui::InputDouble("R", &m_poly_r,1,5,"%.0f")) build_preset_profile(); + ImGui::SetNextItemWidth(80); if (ImGui::InputDouble((_u8L("Radius") + "##poly").c_str(), &m_poly_r,1,5,"%.0f")) build_preset_profile(); } else { - ImGui::Text("%s", UL("Click on canvas to draw")); + ImGui::Text("%s", _u8L("Click on canvas to draw").c_str()); } - ImGui::Checkbox(UL("Snap to grid"), &m_snap_grid); + ImGui::Checkbox(_u8L("Snap to grid").c_str(), &m_snap_grid); ImGui::SameLine(); - ImGui::SetNextItemWidth(80); ImGui::InputFloat("Step", &m_grid_step, 1, 5, "%.0f mm"); + ImGui::SetNextItemWidth(80); ImGui::InputFloat(_u8L("Grid step").c_str(), &m_grid_step, 1, 5, "%.0f mm"); draw_canvas(); if (!m_profiles.empty()) { - ImGui::Text("%s: %zu", UL("Profiles"), m_profiles.size()); + ImGui::Text("%s: %zu", _u8L("Profiles").c_str(), m_profiles.size()); for (int i = 0; i < (int)m_profiles.size(); ++i) { auto& p = m_profiles[i]; ImGui::PushID(i); bool outer = (i == 0); ImVec4 col = outer ? ImVec4(0,1,0,1) : ImVec4(1,0.3f,0.3f,1); - const char* label = outer ? "Outer" : "Hole"; - ImGui::TextColored(col, "%s %d: %zu pts %s", label, i+1, p.points.size(), p.closed ? "CLOSED" : ""); + const std::string label = outer ? _u8L("Outer") : _u8L("Hole"); + ImGui::TextColored(col, "%s %d: %zu %s %s", label.c_str(), i+1, p.points.size(), + _u8L("points").c_str(), p.closed ? _u8L("closed").c_str() : ""); ImGui::SameLine(); if (ImGui::SmallButton("X")) delete_profile(i); ImGui::PopID(); @@ -330,59 +334,63 @@ void GLGizmoSketch::on_render_input_window(float x, float y, float bottom_limit) bool is_revolve = false; bool has_sel = false; - if (ImGui::CollapsingHeader(UL("Operation"), ImGuiTreeNodeFlags_DefaultOpen)) { + if (ImGui::CollapsingHeader(_u8L("Operation").c_str(), ImGuiTreeNodeFlags_DefaultOpen)) { static int pi = 0; - if (ImGui::Combo(UL("Plane"), &pi, "XY (Top)\0XZ (Front)\0YZ (Side)\0")) + const std::string planes = "XY (" + _u8L("Top") + ")" + std::string(1, '\0') + "XZ (" + _u8L("Front") + ")" + + std::string(1, '\0') + "YZ (" + _u8L("Side") + ")" + std::string(2, '\0'); + if (ImGui::Combo(_u8L("Plane").c_str(), &pi, planes.c_str())) m_plane = (pi==0) ? SketchPlane::XY() : (pi==1) ? SketchPlane::XZ() : SketchPlane::YZ(); is_revolve = (m_sp.revolve_deg > 0 && m_sp.revolve_deg < 360); ImGui::SetNextItemWidth(100); - if (ImGui::InputDouble(UL("Revolve deg"), &m_sp.revolve_deg, 15, 90, "%.0f")) { + if (ImGui::InputDouble(_u8L("Revolve deg").c_str(), &m_sp.revolve_deg, 15, 90, "%.0f")) { if (m_sp.revolve_deg > 360) m_sp.revolve_deg = 360; if (m_sp.revolve_deg < 0) m_sp.revolve_deg = 0; } - if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", UL("Set to 0 for extrude, >0 for revolve")); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", _u8L("Set to 0 for extrude, >0 for revolve").c_str()); if (!is_revolve) { ImGui::SetNextItemWidth(100); - ImGui::InputDouble(UL("Length"), &m_sp.extrude_len, 0.5, 5, "%.1f mm"); + ImGui::InputDouble(_u8L("Length").c_str(), &m_sp.extrude_len, 0.5, 5, "%.1f mm"); ImGui::SameLine(); - ImGui::Checkbox(UL("Symmetric"), &m_sp.extrude_sym); + ImGui::Checkbox(_u8L("Symmetric").c_str(), &m_sp.extrude_sym); } has_sel = !m_parent.get_selection().is_empty(); if (has_sel) { - if (ImGui::Checkbox(UL("Pocket (cut)"), &m_sp.is_pocket)) + if (ImGui::Checkbox(_u8L("Pocket (cut)").c_str(), &m_sp.is_pocket)) if (m_sp.is_pocket) m_sp.dressup_enabled = false; } else m_sp.is_pocket = false; } ImGui::Separator(); - if (!m_sp.is_pocket && ImGui::CollapsingHeader(UL("Fillet / Chamfer"))) { - ImGui::Checkbox(UL("Enable"), &m_sp.dressup_enabled); + if (!m_sp.is_pocket && ImGui::CollapsingHeader(_u8L("Fillet / Chamfer").c_str())) { + ImGui::Checkbox(_u8L("Enable").c_str(), &m_sp.dressup_enabled); if (m_sp.dressup_enabled) { - static const char* dn[] = {"Fillet", "Chamfer"}; + const std::string dn_s[] = {_u8L("Fillet"), _u8L("Chamfer")}; + const char* dn[] = {dn_s[0].c_str(), dn_s[1].c_str()}; int du = (int)m_sp.dressup_type; ImGui::SetNextItemWidth(100); if (ImGui::Combo("##dtype", &du, dn, 2)) m_sp.dressup_type = (DressUpType)du; - static const char* fn[] = {"All edges", "Top edges", "Bottom edges", "Lateral edges"}; + const std::string fn_s[] = {_u8L("All edges"), _u8L("Top edges"), _u8L("Bottom edges"), _u8L("Lateral edges")}; + const char* fn[] = {fn_s[0].c_str(), fn_s[1].c_str(), fn_s[2].c_str(), fn_s[3].c_str()}; int fg = (int)m_sp.dressup_faces; ImGui::SetNextItemWidth(140); - ImGui::Combo(UL("Edges"), &fg, fn, 4); m_sp.dressup_faces = (FaceGroup)fg; + ImGui::Combo(_u8L("Edges").c_str(), &fg, fn, 4); m_sp.dressup_faces = (FaceGroup)fg; ImGui::SetNextItemWidth(100); if (m_sp.dressup_type == DressUpType::Fillet) - ImGui::InputDouble(UL("Radius"), &m_sp.dressup_radius, 0.1, 1, "%.1f mm"); + ImGui::InputDouble(_u8L("Radius").c_str(), &m_sp.dressup_radius, 0.1, 1, "%.1f mm"); else - ImGui::InputDouble(UL("Distance"), &m_sp.dressup_chamfer_dist, 0.1, 1, "%.1f mm"); + ImGui::InputDouble(_u8L("Distance").c_str(), &m_sp.dressup_chamfer_dist, 0.1, 1, "%.1f mm"); } } ImGui::Separator(); bool ok = has_closed_profile(); - if (ok) ImGui::TextColored({0,1,0,1}, "%zu %s", m_profiles.size(), UL("closed profile(s)")); - else ImGui::TextColored({0.6f,0.6f,0.6f,1}, "%s", UL("Draw a closed profile to enable")); + if (ok) ImGui::TextColored({0,1,0,1}, "%zu %s", m_profiles.size(), _u8L("closed profile(s)").c_str()); + else ImGui::TextColored({0.6f,0.6f,0.6f,1}, "%s", _u8L("Draw a closed profile to enable").c_str()); auto btn = [&](const char* label, bool enabled) { if (!enabled) { ImGui::PushItemFlag(ImGuiItemFlags_Disabled,true); ImGui::PushStyleColor(ImGuiCol_Button,{0.25f,0.25f,0.25f,1}); } @@ -392,14 +400,14 @@ void GLGizmoSketch::on_render_input_window(float x, float y, float bottom_limit) }; if (m_sp.is_pocket && has_sel) { - if (btn(_u8L("Pocket (Cut)").c_str(), ok)) apply_pocket(); + if (btn(_u8L("Pocket (cut)").c_str(), ok)) apply_pocket(); } else if (is_revolve) { if (btn(_u8L("Revolve").c_str(), ok)) apply_revolve(); } else { if (btn(_u8L("Extrude").c_str(), ok)) apply_extrude(); } - if (ImGui::Button(_u8L("Clear All").c_str(), {-1,0})) clear_all(); + if (ImGui::Button(_u8L("Clear all").c_str(), {-1,0})) clear_all(); if (ImGui::Button(_u8L("Close").c_str(), {-1,0})) m_parent.reset_all_gizmos(); GizmoImguiEnd(); @@ -467,7 +475,7 @@ void GLGizmoSketch::apply_pocket() mo->ensure_on_bed(); wxGetApp().plater()->update(); clear_all(); - wxGetApp().notification_manager()->push_notification(NotificationType::CustomNotification, NotificationManager::NotificationLevel::RegularNotificationLevel, UL("Pocket added (negative volume)")); + wxGetApp().notification_manager()->push_notification(NotificationType::CustomNotification, NotificationManager::NotificationLevel::RegularNotificationLevel, _u8L("Pocket added (negative volume)").c_str()); } catch (const std::exception& e) { wxGetApp().notification_manager()->push_notification(NotificationType::CustomNotification, NotificationManager::NotificationLevel::ErrorNotificationLevel, std::string("Pocket: ")+e.what()); } diff --git a/src/slic3r/GUI/Gizmos/GLGizmoSketch.hpp b/src/slic3r/GUI/Gizmos/GLGizmoSketch.hpp index 9e878db152..257fac8c6d 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoSketch.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoSketch.hpp @@ -28,6 +28,7 @@ protected: bool on_init() override; std::string on_get_name() const override; bool on_is_activable() const override; + bool on_is_selectable() const override; void on_render() override; void on_set_state() override; CommonGizmosDataID on_get_requirements() const override; diff --git a/src/slic3r/GUI/LazyPage.hpp b/src/slic3r/GUI/LazyPage.hpp index 520c2bf824..5ef2c4320a 100644 --- a/src/slic3r/GUI/LazyPage.hpp +++ b/src/slic3r/GUI/LazyPage.hpp @@ -58,6 +58,11 @@ public: // virtual from the book's ShowWithEffect() only for wxSHOW_EFFECT_NONE, the default. bool Show(bool show = true) override { + // Built before this page is shown, as an idle prebuild builds it. On MSW every control + // created or moved inside a shown window re-clips and repaints its shown siblings, so + // building a large panel into a shown page took seconds. + if (show && wxGetTopLevelParent(this)->IsShown()) + this->ensure(); const bool changed = wxPanel::Show(show); if (show) { // The book shows its first page as it is inserted, before startup has chosen the diff --git a/src/slic3r/GUI/MainFrame.cpp b/src/slic3r/GUI/MainFrame.cpp index 980abfcc2c..1da22366aa 100644 --- a/src/slic3r/GUI/MainFrame.cpp +++ b/src/slic3r/GUI/MainFrame.cpp @@ -1263,6 +1263,10 @@ void MainFrame::shutdown() m_plugin_pages.shutdown(); if (m_plater != nullptr) m_plater->remove_dock_panes(); +#ifdef SLIC3R_CAD + if (DesignPanel* design = DesignPanel::if_built()) + design->shutdown(); +#endif #ifdef __WXGTK__ // Edge panels are child windows — wxWidgets destroys them automatically. m_edge_bottom = nullptr; @@ -1386,6 +1390,23 @@ void MainFrame::show_option(bool show) } } +void MainFrame::set_undo_redo_enabled(bool undo, bool redo) +{ +#ifndef __APPLE__ + m_topbar->EnableUndoRedo(undo, redo); +#else + (void) undo; (void) redo; // macOS has no top bar; Edit asks the tab when it opens +#endif +} + +#ifdef SLIC3R_CAD +DesignPanel* MainFrame::shown_design_panel() const +{ + DesignPanel* design = DesignPanel::if_built(); + return (design != nullptr && m_design_page != nullptr && m_design_page->IsShownOnScreen()) ? design : nullptr; +} +#endif + void MainFrame::init_tabpanel() { // wxNB_NOPAGETHEME: Disable Windows Vista theme for the Notebook background. The theme performance is terrible on // Windows 10 with multiple high resolution displays connected. @@ -1451,6 +1472,11 @@ void MainFrame::init_tabpanel() { m_topbar->DisableUndoRedoItems(); } #endif +#ifdef SLIC3R_CAD + // Design keeps its own history, and the top bar's Undo/Redo drive it while it is shown. + if (m_design_page != nullptr && panel == m_design_page) + DesignPanel::ensure()->update_undo_redo_buttons(); +#endif if (panel) panel->SetFocus(); @@ -1477,8 +1503,9 @@ void MainFrame::init_tabpanel() { #ifdef SLIC3R_CAD // The experimental feature is off by default, and when it is off the page is never // created, so the tab does not appear at all (the preference takes effect on the next - // start, like the other feature toggles). - if (wxGetApp().is_enable_cad_feature()) { + // start, like the other feature toggles). Nor in the G-code viewer, which has no Design tab to put + // it in — and no business opening a control socket onto one. + if (wxGetApp().is_enable_cad_feature() && wxGetApp().is_editor()) { // Experimental and heavy enough that building it unasked would cost more than it saves. m_design_page = new LazyPage(this, TAB_ID_DESIGN, -1); m_lazy_pages.push_back(m_design_page); @@ -2669,6 +2696,9 @@ void MainFrame::on_dpi_changed(const wxRect& suggested_rect) MonitorPanel::when_built([](MonitorPanel& monitor) { monitor.msw_rescale(); }); MultiMachinePage::when_built([](MultiMachinePage& multi_machine) { multi_machine.msw_rescale(); }); CalibrationPanel::when_built([](CalibrationPanel& calibration) { calibration.msw_rescale(); }); +#ifdef SLIC3R_CAD + DesignPanel::when_built([](DesignPanel& design) { design.msw_rescale(); }); +#endif // BBS #if 0 @@ -2733,6 +2763,9 @@ void MainFrame::on_sys_color_changed() wxGetApp().plater()->sys_color_changed(); MonitorPanel::when_built([](MonitorPanel& monitor) { monitor.on_sys_color_changed(); }); CalibrationPanel::when_built([](CalibrationPanel& calibration) { calibration.on_sys_color_changed(); }); +#ifdef SLIC3R_CAD + DesignPanel::when_built([](DesignPanel& design) { design.on_sys_color_changed(); }); +#endif // update Tabs for (auto tab : wxGetApp().tabs_list) tab->sys_color_changed(); @@ -3071,12 +3104,28 @@ void MainFrame::init_menubar_as_editor() #ifndef __APPLE__ // BBS undo append_shortcut_item(editMenu, Shortcut::Undo, true, _L("Undo"), - _L("Undo"), [this](wxCommandEvent&) { m_plater->undo(); }, - "menu_undo", nullptr, [this](){return m_plater->can_undo(); }, this); + _L("Undo"), [this](wxCommandEvent&) { +#ifdef SLIC3R_CAD + if (DesignPanel* dp = shown_design_panel()) { dp->menu_undo_redo(false); return; } +#endif + m_plater->undo(); }, + "menu_undo", nullptr, [this](){ +#ifdef SLIC3R_CAD + if (DesignPanel* dp = shown_design_panel()) return dp->menu_can_undo_redo(false); +#endif + return m_plater->can_undo(); }, this); // BBS redo append_shortcut_item(editMenu, Shortcut::Redo, true, _L("Redo"), - _L("Redo"), [this](wxCommandEvent&) { m_plater->redo(); }, - "menu_redo", nullptr, [this](){return m_plater->can_redo(); }, this); + _L("Redo"), [this](wxCommandEvent&) { +#ifdef SLIC3R_CAD + if (DesignPanel* dp = shown_design_panel()) { dp->menu_undo_redo(true); return; } +#endif + m_plater->redo(); }, + "menu_redo", nullptr, [this](){ +#ifdef SLIC3R_CAD + if (DesignPanel* dp = shown_design_panel()) return dp->menu_can_undo_redo(true); +#endif + return m_plater->can_redo(); }, this); editMenu->AppendSeparator(); // BBS Cut TODO append_shortcut_item(editMenu, Shortcut::Cut, true, _L("Cut"), @@ -3123,8 +3172,15 @@ void MainFrame::init_menubar_as_editor() if (handle_key_event(e)) { return; } +#ifdef SLIC3R_CAD + if (DesignPanel* dp = shown_design_panel()) { dp->menu_undo_redo(false); return; } +#endif m_plater->undo(); }, - "", nullptr, [this](){return m_plater->can_undo(); }, this); + "", nullptr, [this](){ +#ifdef SLIC3R_CAD + if (DesignPanel* dp = shown_design_panel()) return dp->menu_can_undo_redo(false); +#endif + return m_plater->can_undo(); }, this); // BBS redo append_shortcut_item(editMenu, Shortcut::Redo, false, _L("Redo"), _L("Redo"), [this, handle_key_event](wxCommandEvent&) { @@ -3135,8 +3191,15 @@ void MainFrame::init_menubar_as_editor() if (handle_key_event(e)) { return; } +#ifdef SLIC3R_CAD + if (DesignPanel* dp = shown_design_panel()) { dp->menu_undo_redo(true); return; } +#endif m_plater->redo(); }, - "", nullptr, [this](){return m_plater->can_redo(); }, this); + "", nullptr, [this](){ +#ifdef SLIC3R_CAD + if (DesignPanel* dp = shown_design_panel()) return dp->menu_can_undo_redo(true); +#endif + return m_plater->can_redo(); }, this); editMenu->AppendSeparator(); // BBS Cut TODO append_shortcut_item(editMenu, Shortcut::Cut, false, _L("Cut"), @@ -3332,6 +3395,10 @@ void MainFrame::init_menubar_as_editor() viewMenu, wxID_ANY, _L("Reset Window Layout"), _L("Reset to default window layout"), [this](wxCommandEvent&) { m_plater->reset_window_layout(); }, "", this, [this]() { +#ifdef SLIC3R_CAD + if (shown_design_panel() != nullptr) + return true; +#endif return is_prepare_or_preview_tab() && m_plater->is_sidebar_enabled(); }, this); diff --git a/src/slic3r/GUI/MainFrame.hpp b/src/slic3r/GUI/MainFrame.hpp index 8c25b31770..6bd00b15c5 100644 --- a/src/slic3r/GUI/MainFrame.hpp +++ b/src/slic3r/GUI/MainFrame.hpp @@ -507,7 +507,12 @@ public: // through LazyInstance's statics, and show_device() only moves pages in and out of the book. #ifdef SLIC3R_CAD LazyPage* m_design_page { nullptr }; + // The Design panel when its tab is the one on screen, else null. Edit > Undo/Redo act on + // the tab that is shown: its own history when that is Design, the plater's otherwise. + DesignPanel* shown_design_panel() const; #endif + // The top bar's Undo/Redo, for a tab that keeps its own history (Design). + void set_undo_redo_enabled(bool undo, bool redo); //BBS: GUI refactor LazyPage* m_monitor_page{ nullptr }; diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index a069e89609..a86d205a11 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -191,6 +191,7 @@ #ifdef __WXGTK__ #include "LinuxDisplayBackend.hpp" #endif +#include "AuiMgr.hpp" #include "AuiPaneLayout.hpp" #include "GUI_Utils.hpp" #include "GUI_Factories.hpp" @@ -7107,28 +7108,6 @@ enum ExportingStatus{ EXPORTING_TO_LOCAL }; - -// TODO: listen on dark ui change -class FloatFrame : public wxAuiFloatingFrame -{ -public: - FloatFrame(wxWindow* parent, wxAuiManager* ownerMgr, const wxAuiPaneInfo& pane) : wxAuiFloatingFrame(parent, ownerMgr, pane) - { - wxGetApp().UpdateFrameDarkUI(this); - } -}; - -class AuiMgr : public wxAuiManager -{ -public: - AuiMgr() : wxAuiManager(){} - - virtual wxAuiFloatingFrame* CreateFloatingFrame(wxWindow* parent, const wxAuiPaneInfo& p) override - { - return new FloatFrame(parent, this, p); - } -}; - // Plater / private struct Plater::priv { @@ -7609,7 +7588,6 @@ struct Plater::priv //BBS: change dark/light mode void on_change_color_mode(SimpleEvent& evt); void on_apple_change_color_mode(wxSysColourChangedEvent& evt); - void apply_color_mode(); void on_update_geometry(Vec3dsEvent<2>&); void on_3dcanvas_mouse_dragging_started(SimpleEvent&); void on_3dcanvas_mouse_dragging_finished(SimpleEvent&); @@ -7820,20 +7798,7 @@ Plater::priv::priv(Plater *q, MainFrame *main_frame) { m_is_dark = wxGetApp().app_config->get("dark_color_mode") == "1"; -#ifdef __WXGTK__ - const bool disable_wayland_floating = Slic3r::GUI::is_running_on_wayland(); -#endif - - m_aui_mgr.SetManagedWindow(q); - m_aui_mgr.SetDockSizeConstraint(1, 1); -#ifdef __WXGTK__ - if (disable_wayland_floating) - m_aui_mgr.SetFlags(m_aui_mgr.GetFlags() & ~wxAUI_MGR_ALLOW_FLOATING); -#endif - //m_aui_mgr.GetArtProvider()->SetMetric(wxAUI_DOCKART_PANE_BORDER_SIZE, 0); - //m_aui_mgr.GetArtProvider()->SetMetric(wxAUI_DOCKART_SASH_SIZE, 2); - m_aui_mgr.GetArtProvider()->SetMetric(wxAUI_DOCKART_CAPTION_SIZE, 18); - m_aui_mgr.GetArtProvider()->SetMetric(wxAUI_DOCKART_GRADIENT_TYPE, wxAUI_GRADIENT_NONE); + m_aui_mgr.init(q); this->q->SetFont(Slic3r::GUI::wxGetApp().normal_font()); @@ -7915,13 +7880,7 @@ Plater::priv::priv(Plater *q, MainFrame *main_frame) update(); // Orca: Make sidebar dockable - m_aui_mgr.AddPane(sidebar, wxAuiPaneInfo() - .Name("sidebar") - .Left() - .CloseButton(false) - .TopDockable(false) - .BottomDockable(false) - .BestSize(wxSize(39 * wxGetApp().em_unit(), 90 * wxGetApp().em_unit()))); + m_aui_mgr.AddPane(sidebar, AuiMgr::sidebar_pane_info()); // UV editor pane for GLGizmoTextureDisplacement's LSCM unwrap preview - a resizable/dockable // sibling of "sidebar"/"main" like everything else registered on this same AUI manager, not a @@ -7973,8 +7932,7 @@ Plater::priv::priv(Plater *q, MainFrame *main_frame) if (!layout.empty()) { bool removed_floating_state = false; #ifdef __WXGTK__ - if (disable_wayland_floating) - layout = sanitize_window_layout_for_wayland(layout, &removed_floating_state); + layout = sanitize_window_layout_for_wayland(layout, &removed_floating_state); #endif if (!m_aui_mgr.LoadPerspective(layout, false)) { @@ -7996,12 +7954,7 @@ Plater::priv::priv(Plater *q, MainFrame *main_frame) // Keep tracking the current sidebar size, by storing it using `best_size`, which will be stored // in the config and re-applied when the app is opened again. - this->sidebar->Bind(wxEVT_IDLE, [&sidebar](wxIdleEvent& e) { - if (sidebar.IsShown() && sidebar.IsDocked() && sidebar.rect.GetWidth() > 0) { - sidebar.BestSize(sidebar.rect.GetWidth(), sidebar.best_size.GetHeight()); - } - e.Skip(); - }); + m_aui_mgr.track_docked_size(this->sidebar); // Hide sidebar initially, will re-show it after initialization when we got proper window size sidebar.Hide(); @@ -8225,8 +8178,6 @@ Plater::priv::priv(Plater *q, MainFrame *main_frame) q->SetDropTarget(new PlaterDropTarget(*main_frame, *q)); // if my understanding is right, wxWindow takes the owenership q->Layout(); - apply_color_mode(); - set_current_panel(wxGetApp().is_editor() ? static_cast(view3D) : static_cast(preview)); // updates camera type from .ini file @@ -8705,18 +8656,7 @@ void Plater::priv::add_dock_pane(wxWindow* window, const std::string& name, cons info.Show(dock_pane_visible(dock_pane, info)); m_aui_mgr.AddPane(window, info); - // wxAUI does not record a dragged sash in best_size, so track the docked size like the sidebar - // does, for the saved layout. - window->Bind(wxEVT_IDLE, [this, window](wxIdleEvent& evt) { - wxAuiPaneInfo& pane = m_aui_mgr.GetPane(window); - if (pane.IsOk() && pane.IsShown() && pane.IsDocked() && pane.rect.GetWidth() > 0 && pane.rect.GetHeight() > 0) { - const bool horizontal = pane.dock_direction == wxAUI_DOCK_TOP || pane.dock_direction == wxAUI_DOCK_BOTTOM; - pane.BestSize(horizontal ? pane.best_size.GetWidth() : pane.rect.GetWidth(), - horizontal ? pane.rect.GetHeight() : pane.best_size.GetHeight()); - } - evt.Skip(); - }); - + m_aui_mgr.track_docked_size(window); m_aui_mgr.Update(); } @@ -10269,10 +10209,23 @@ std::vector Plater::priv::load_files(const std::vector& input_ // a user sees after opening a design they spent an hour on — it reads as "your work is gone" // when the recipe has in fact just been loaded and the Design tab will rehydrate it. Count a // recipe that came from THESE files as geometry. - if (tolal_model_count <= 0 && !loaded_cad_recipe && !q->m_exported_file) { + // + // Only where the Design tab can actually show it, though. With the CAD feature switched off + // (or not built) the recipe is still carried through to the next save, but nothing will + // display it, so an empty plate needs saying — and saying why. + bool cad_can_show = false; +#ifdef SLIC3R_CAD + cad_can_show = wxGetApp().is_enable_cad_feature(); +#endif + if (tolal_model_count <= 0 && !q->m_exported_file && (!loaded_cad_recipe || !cad_can_show)) { dlg.Hide(); if (!is_user_cancel) { - MessageDialog msg(wxGetApp().mainframe, _L("The file does not contain any geometry data."), _L("Warning"), wxYES | wxICON_WARNING); + const wxString text = loaded_cad_recipe + ? _L("This project contains a model made in the Design tab and no other geometry. " + "Enable \"CAD feature (experimental)\" in Preferences and restart to see and edit it; " + "it is kept when the project is saved.") + : _L("The file does not contain any geometry data."); + MessageDialog msg(wxGetApp().mainframe, text, _L("Warning"), wxYES | wxICON_WARNING); if (msg.ShowModal() == wxID_YES) {} } } @@ -13807,7 +13760,7 @@ void Plater::priv::on_apple_change_color_mode(wxSysColourChangedEvent& evt) { assemble_view->get_canvas3d()->on_change_color_mode(m_is_dark); } - apply_color_mode(); + m_aui_mgr.apply_color_mode(); } void Plater::priv::on_change_color_mode(SimpleEvent& evt) { @@ -13818,19 +13771,7 @@ void Plater::priv::on_change_color_mode(SimpleEvent& evt) { assemble_view->get_canvas3d()->on_change_color_mode(m_is_dark); if (m_send_to_sdcard_dlg) m_send_to_sdcard_dlg->on_change_color_mode(); - apply_color_mode(); -} - -void Plater::priv::apply_color_mode() -{ - const bool is_dark = wxGetApp().dark_mode(); - wxColour orca_color = wxColour(59, 68, 70);//wxColour(ColorRGBA::ORCA().r_uchar(), ColorRGBA::ORCA().g_uchar(), ColorRGBA::ORCA().b_uchar()); - orca_color = is_dark ? StateColor::darkModeColorFor(orca_color) : StateColor::lightModeColorFor(orca_color); - wxColour sash_color = is_dark ? wxColour(38, 46, 48) : wxColour(206, 206, 206); - m_aui_mgr.GetArtProvider()->SetColour(wxAUI_DOCKART_INACTIVE_CAPTION_COLOUR, sash_color); - m_aui_mgr.GetArtProvider()->SetColour(wxAUI_DOCKART_INACTIVE_CAPTION_TEXT_COLOUR, *wxWHITE); - m_aui_mgr.GetArtProvider()->SetColour(wxAUI_DOCKART_SASH_COLOUR, sash_color); - m_aui_mgr.GetArtProvider()->SetColour(wxAUI_DOCKART_BORDER_COLOUR, is_dark ? *wxBLACK : wxColour(165, 165, 165)); + m_aui_mgr.apply_color_mode(); } static void get_position(wxWindowBase* child, wxWindowBase* until_parent, int& x, int& y) { @@ -14486,26 +14427,9 @@ bool Plater::priv::init_collapse_toolbar() if (!collapse_toolbar.init(background_data)) return false; - collapse_toolbar.set_layout_type(GLToolbar::Layout::Vertical); - collapse_toolbar.set_horizontal_orientation(GLToolbar::Layout::HO_Right); - collapse_toolbar.set_vertical_orientation(GLToolbar::Layout::VO_Top); - collapse_toolbar.set_border(4.0f); - collapse_toolbar.set_separator_size(4); - collapse_toolbar.set_gap_size(2); - - collapse_toolbar.del_all_item(); - - GLToolbarItem::Data item; - - item.name = "collapse_sidebar"; - // set collapse svg name - item.icon_filename = "collapse.svg"; - item.sprite_id = 0; - item.left.action_callback = []() { - wxGetApp().plater()->collapse_sidebar(!wxGetApp().plater()->is_sidebar_collapsed()); - }; - - if (!collapse_toolbar.add_item(item)) + if (!setup_collapse_toolbar(collapse_toolbar, []() { + wxGetApp().plater()->collapse_sidebar(!wxGetApp().plater()->is_sidebar_collapsed()); + })) return false; // Now "collapse" sidebar to current state. This is done so the tooltip @@ -18350,7 +18274,15 @@ bool Plater::is_sidebar_collapsed() const { return p->sidebar_layout.is_collapse void Plater::collapse_sidebar(bool collapse) { p->collapse_sidebar(collapse); } Sidebar::DockingState Plater::get_sidebar_docking_state() const { return p->get_sidebar_docking_state(); } -void Plater::reset_window_layout() { p->reset_window_layout(); } +void Plater::reset_window_layout() +{ + p->reset_window_layout(); +#ifdef SLIC3R_CAD + // The Design tab docks its own sidebar. + if (DesignPanel* design = DesignPanel::if_built()) + design->reset_window_layout(); +#endif +} void Plater::add_dock_pane(wxWindow* window, const std::string& name, const wxString& caption, const std::string& dock, const wxSize& size, std::function on_close) diff --git a/src/slic3r/GUI/Preferences.cpp b/src/slic3r/GUI/Preferences.cpp index 6575465198..ae6d65883a 100644 --- a/src/slic3r/GUI/Preferences.cpp +++ b/src/slic3r/GUI/Preferences.cpp @@ -10,7 +10,6 @@ #include "I18N.hpp" #include "libslic3r/AppConfig.hpp" #include "libslic3r/Format/DRC.hpp" -#include "libslic3r/CAD/SketchEngine.hpp" #include #include #include "slic3r/GUI/Widgets/Label.hpp" @@ -1815,13 +1814,6 @@ void PreferencesDialog::create_items() "parametrically. This feature is experimental and still under development."), "enable_cad_feature", _L("(Requires restart)")); g_sizer->Add(item_cad_feature); - - auto item_auto_close_sketch_loops = create_item_checkbox(_L("Auto-close sketch loops"), - _L("Treat sketch endpoints within 0.001 mm as one joint and weld the loop shut. " - "Off: only exactly coincident endpoints join, so a loop with a tiny gap is " - "shown as open instead of being closed for you."), - "auto_close_sketch_loops"); - g_sizer->Add(item_auto_close_sketch_loops); #endif #if 0 @@ -1911,11 +1903,18 @@ void PreferencesDialog::create_items() "disc with a roll quadrant. A face's orientation is read without being learned. " "Turn this off for the conventional CAD representation."), "design_connector_face_glyph"); g_sizer->Add(item_connector_face_glyph); - } - // Push the weld preference into the kernel now so toggling it takes effect without - // a restart (the sketch tool also re-pushes on activation, see DesignSketchTool::begin). - Slic3r::set_sketch_auto_close(wxGetApp().is_auto_close_sketch_loops()); + // Saved WITH each design (it decides which loops are closed, i.e. what solid a project + // rebuilds into), so it applies to designs started from now on; an open design keeps + // the rule it was made with. + auto item_auto_close_sketch_loops = create_item_checkbox(_L("Auto-close sketch loops"), + _L("Treat sketch endpoints within 0.001 mm as one joint and weld the loop shut. " + "Off: only exactly coincident endpoints join, so a loop with a tiny gap is " + "shown as open instead of being closed for you. Saved with each design; " + "applies to designs started after the change."), + "auto_close_sketch_loops"); + g_sizer->Add(item_auto_close_sketch_loops); + } #endif std::vector ButtonDragActions = {_L("None"), _L("Pan"), _L("Rotate")}; diff --git a/tests/libslic3r/test_caddocument.cpp b/tests/libslic3r/test_caddocument.cpp index 92ab3679d7..84c93a39d0 100644 --- a/tests/libslic3r/test_caddocument.cpp +++ b/tests/libslic3r/test_caddocument.cpp @@ -7,7 +7,11 @@ #include #include #include +#include #include // mainline OrcaSlicer ships Catch2 v3 (v2 was catch2/catch.hpp) +#include +#include +#include #include #include #include @@ -46,11 +50,14 @@ #include #include #include +#include #include #include #include #include #include +#include +#include #include #include #include @@ -626,7 +633,21 @@ TEST_CASE("entity constraints: point-on-line positions a centre onto an axis", " cons.push_back({T::Fix, 0, -1, R::P1, R::P1, 0.0}); cons.push_back({T::PointOnLine, 1, 0, R::P0, R::P0, 2.0}); // hold at distance 2 REQUIRE(solve_sketch_entities(ents, cons)); - REQUIRE(std::abs(std::abs(ents[1].p0.y()) - 2.0) < 1e-6); // 2 mm off the axis + REQUIRE(std::abs(ents[1].p0.y() - 2.0) < 1e-6); // 2 mm off, same side + } + + SECTION("a point below the line stays below it") { + std::vector ents = { + {SketchEntity::Type::Line, Vec2d(0,0), Vec2d(10,0)}, + {SketchEntity::Type::Point, Vec2d(4,-9)}, // point BELOW the axis + }; + std::vector cons; + cons.push_back({T::Fix, 0, -1, R::P0, R::P0, 0.0}); + cons.push_back({T::Fix, 0, -1, R::P1, R::P1, 0.0}); + cons.push_back({T::PointOnLine, 1, 0, R::P0, R::P0, 2.0}); + REQUIRE(solve_sketch_entities(ents, cons)); + // |value| used to be forced onto the positive side, flipping the point across the line. + REQUIRE(std::abs(ents[1].p0.y() + 2.0) < 1e-6); } } @@ -695,7 +716,38 @@ TEST_CASE("entity constraints: tangent/midpoint/symmetric/angle", "[CadDocument] REQUIRE(doc.solve_sketch_feature(sk)); const auto& e = doc.features[sk].entities; - REQUIRE(std::abs(std::abs(e[1].p0.y()) - 5.0) < 1e-3); + // The line started ABOVE the circle (y=8) and must stay on that side: tangency keeps + // the side the geometry is on, it does not pick one. + REQUIRE(std::abs(e[1].p0.y() - 5.0) < 1e-3); + } + + SECTION("tangent line to circle, line below it") { + CadDocument doc; + std::vector ents = { + {SketchEntity::Type::Circle, Vec2d(0,0), Vec2d(0,0), Vec2d(0,0), 5.0}, + {SketchEntity::Type::Line, Vec2d(-10,-8), Vec2d(10,-8)}, + }; + int sk = doc.add_sketch_entities(ents, SketchPlane::XY(), "S"); + auto& ec = doc.features[sk].entity_constraints; + ec.push_back({T::Fix, 0,-1, R::Center,R::Center, 0.0,-1,R::P0}); + ec.push_back({T::LockX, 1,-1, R::P0, R::P0, -10.0,-1,R::P0}); + ec.push_back({T::LockX, 1,-1, R::P1, R::P0, 10.0,-1,R::P0}); + ec.push_back({T::Horizontal, 1, 1, R::P0, R::P1, 0.0,-1,R::P0}); + ec.push_back({T::Tangent, 0, 1, R::Center,R::P0, 0.0,-1,R::P0}); + REQUIRE(doc.solve_sketch_feature(sk)); + REQUIRE(std::abs(doc.features[sk].entities[1].p0.y() + 5.0) < 1e-3); + } + + SECTION("tangent circle to circle does not abort and keeps them touching") { + std::vector ents = { + {SketchEntity::Type::Circle, Vec2d(0,0), Vec2d(0,0), Vec2d(0,0), 5.0}, + {SketchEntity::Type::Circle, Vec2d(9,0), Vec2d(9,0), Vec2d(9,0), 3.0}, + }; + std::vector cons; + cons.push_back({T::Fix, 0,-1, R::Center,R::Center, 0.0,-1,R::P0}); + cons.push_back({T::Tangent, 0, 1, R::Center,R::Center, 0.0,-1,R::P0}); + REQUIRE(solve_sketch_entities(ents, cons)); + CHECK(std::abs((ents[1].center - ents[0].center).norm() - 8.0) < 1e-6); // r1 + r2 } SECTION("symmetric across a line") { @@ -2157,7 +2209,7 @@ TEST_CASE("a v4 project still opens", "[CadDocument][recipe]") ifs.close(); REQUIRE_FALSE(blob.empty()); - // Trusted reference read of the flat v4 layout (what the golden test's Layer 1 does). + // Reference read of the flat v4 layout with the frozen v4 field list. std::vector flat; { std::istringstream iss(blob); @@ -2165,19 +2217,40 @@ TEST_CASE("a v4 project still opens", "[CadDocument][recipe]") uint32_t v; ar(v); REQUIRE(v == 4); - ar(flat); + cereal::size_type n = 0; + ar(cereal::make_size_tag(n)); + flat.assign(size_t(n), CadFeature{}); + for (CadFeature& f : flat) f.load_flat_v4(ar); + // The whole stream must be consumed by features + variables: a layout mismatch leaves + // bytes behind (or runs out), which is exactly what reading v4 with the CURRENT field + // list would do the moment a field is appended to it. + std::map vars; + ar(vars); + CHECK(iss.peek() == std::char_traits::eof()); } REQUIRE_FALSE(flat.empty()); CadDocument doc; - doc.deserialize_recipe(blob); - // Not refused at the gate: the only failure allowed is the fixture's own geometry - // (the golden doc is a serialization-coverage tree whose fillet radius is too large), - // which is orthogonal to the v5 framing change and unchanged by it. - REQUIRE(doc.error.find("older version") == std::string::npos); - REQUIRE(doc.error.find("newer version") == std::string::npos); - // The v4 flat path read the same feature tree as the trusted reference. + const bool ok = doc.deserialize_recipe(blob); + // It must OPEN: either cleanly, or failing only on the fixture's own geometry (the golden + // doc is a serialization-coverage tree whose fillet radius is too large). "CAD data could not + // be read" — a misread stream — passed the old version of this test, which only ruled out + // the version-gate messages. + if (!ok) { + INFO("v4 load error: " << doc.error); + CHECK(doc.error.find("could not be read") == std::string::npos); + CHECK(doc.error.find("older version") == std::string::npos); + CHECK(doc.error.find("newer version") == std::string::npos); + } + // The v4 path read the same feature tree as the reference, field for field. REQUIRE(doc.features.size() == flat.size()); + for (size_t i = 0; i < flat.size(); ++i) { + CHECK(doc.features[i].name == flat[i].name); + CHECK(doc.features[i].type == flat[i].type); + CHECK(doc.features[i].distance == flat[i].distance); + CHECK(doc.features[i].mate_kind == flat[i].mate_kind); + CHECK(doc.features[i].coordsys_face_edges == flat[i].coordsys_face_edges); + } } // Do NOT regenerate cad_recipe_v5.bin either. It was written by the build that predates the @@ -2329,10 +2402,15 @@ TEST_CASE("a truncated feature keeps what it could read", "[CadDocument][recipe] off += 4 + f_len.back(); } - // Shorten feature 1 by dropping its final field (4 bytes): rewrite its length prefix - // and erase the tail bytes. The reader then runs out inside fa(f), throws, and keeps - // everything it had already assigned — that is the whole point of the try/catch. - const size_t drop = sizeof(uint32_t); + // Shorten feature 1 so it ends right after coordsys_face_kind: drop coordsys_face_edges + // (4 bytes), the two flags appended after it (thread_major_nominal, pattern_inclusive: + // 1 byte each), the empty dressup_edges list and the two empty text strings (an 8-byte size + // tag each), text_height (a double) and revolve_axis_entity (an int). Rewrite its length prefix and erase the tail bytes. + // The reader then runs out inside fa(f), throws, and keeps everything it had already + // assigned — that is the whole point of the try/catch. (Cut on a field boundary: a field + // cut in half is read as whatever half arrived.) + const size_t drop = sizeof(uint32_t) + 2 * sizeof(bool) + 3 * sizeof(cereal::size_type) + sizeof(double) + + sizeof(int); REQUIRE(f_len[1] > drop); std::string shortened = blob; shortened.erase(f_off[1] + 4 + f_len[1] - drop, drop); @@ -2348,6 +2426,10 @@ TEST_CASE("a truncated feature keeps what it could read", "[CadDocument][recipe] REQUIRE(loaded.features[1].name == doc.features[1].name); REQUIRE(loaded.features[1].coordsys_face_kind == 777); // right before the cut REQUIRE(loaded.features[1].coordsys_face_edges == -1); // defaulted by the cut + REQUIRE_FALSE(loaded.features[1].thread_major_nominal); // ...and so were the later flags + REQUIRE_FALSE(loaded.features[1].pattern_inclusive); + REQUIRE(loaded.features[1].dressup_edges.empty()); + REQUIRE_FALSE(loaded.features[1].is_text()); REQUIRE(loaded.features[0].name == doc.features[0].name); REQUIRE(loaded.features[2].name == doc.features[2].name); } @@ -4215,8 +4297,12 @@ TEST_CASE("golden recipe v1 still deserialises", "[CadDocument]") cereal::BinaryInputArchive ar(iss); uint32_t v; ar(v); - REQUIRE(v <= CadDocument::ORCA_CAD_RECIPE_VERSION); - ar(features); + REQUIRE(v == 4); + // The fixture is a v4 (flat) blob: read it with the frozen v4 field list. + cereal::size_type n = 0; + ar(cereal::make_size_tag(n)); + features.assign(size_t(n), CadFeature{}); + for (CadFeature& f : features) f.load_flat_v4(ar); } CadDocument expected = make_golden_doc_v1(); @@ -4690,41 +4776,26 @@ TEST_CASE("bridge closes a C profile and extrudes", "[CadDocument][bridge]") // top edge: (10,10) to (-10,10) {SketchEntity::Type::Line, Vec2d(10,10), Vec2d(-10,10)}, }; - // Missing: left edge from (-10,10) to (-10,-10). Build it as a separate line - // entity so the bridge connects two existing lines. + // The C is open on the left: entity 2 (top) ends at (-10,10), entity 0 (bottom) starts at + // (-10,-10). The bridge IS the closing edge. int sk = doc.add_sketch_entities(ents, SketchPlane::XY(), "C"); REQUIRE(sk == 0); - - // Add the closing line as entity 3: (-10,10) to (-10,-10) CadFeature& f = doc.features[sk]; - SketchEntity closing; - closing.type = SketchEntity::Type::Line; - closing.p0 = Vec2d(-10, 10); - closing.p1 = Vec2d(-10, -10); - // The C is entities 0,1,2 (bottom cap, right side, top cap). - // Entity 0 end=1 is (10,-10); entity 2 start=0 is (10,10). That's a U. - // But we need a closed square from C shape. - // Re-think: a C shape open on the left side. - // Entities: 0 = bottom edge (-10,-10)->(10,-10) [end=1 at (10,-10)] - // 1 = right edge (10,-10)->(10,10) [start=0 at (10,-10), end=1 at (10,10)] - // 2 = top edge (10,10)->(-10,10) [start=0 at (10,10), end=1 at (-10,10)] - // The C is open: entity 2's end is at (-10,10) and entity 0's start is at (-10,-10). - // Bridge: entity 2 end=1 (-10,10) -> entity 0 start=0 (-10,-10). - f.entities.push_back(closing); + + int bi = doc.add_bridge(sk, 2/*top edge*/, 1/*end*/, 0/*bottom edge*/, 0/*start*/, "Bridge"); + REQUIRE(bi == 3); REQUIRE(f.entities.size() == 4); - // Now bridge from top end (entity 2 end=1 = (-10,10)) to bottom start (entity 0 end=0 = (-10,-10)) - int bi = doc.add_bridge(sk, 2/*top edge*/, 1/*end*/, 0/*bottom edge*/, 0/*start*/, "Bridge"); - REQUIRE(bi == 4); - REQUIRE(f.entities.size() == 5); - - // Now the entities should form a closed loop -> extrude int ex = doc.add_extrude(sk, 5.0, false, BooleanMode::New, "Extrude"); REQUIRE(ex >= 0); REQUIRE(doc.recompute()); REQUIRE(doc.error.empty()); REQUIRE(doc.display_mesh.facets_count() > 0); - REQUIRE_THAT(double(doc.display_mesh.volume()), WithinRel(20.0 * 20.0 * 5.0, 1e-2)); + // G1 with both edges means the bridge LEAVES the top edge heading -X and ARRIVES on the + // bottom edge heading +X: it bulges out to the left, a cubic whose inner poles sit 20/3 mm + // out, enclosing 3/5 x 20/3 x 20 = 80 mm2 beyond the square. (This test used to expect the + // bare square: the old end pole sent the curve back across x = -10 in an S, a cusp.) + REQUIRE_THAT(double(doc.display_mesh.volume()), WithinRel((20.0 * 20.0 + 80.0) * 5.0, 1e-2)); } TEST_CASE("bridge bad indices throw", "[CadDocument][bridge]") @@ -7432,11 +7503,16 @@ TEST_CASE("Failed sketch solve leaves geometry untouched", "[CadDocument]") auto tang = [&](int ln) { SketchEntityConstraintDef d; d.type = CT::Tangent; d.ea = xi; d.eb = ln; return d; }; - // Rung 1 of the ladder: a tangent on each leg. Over-constrained against the legs' - // own H/V, so it must be rejected -- and must not move a single point. + // A genuinely conflicting batch (the right leg held at two different lengths) must be + // rejected -- and must not move a single point. { std::vector pc = cs; - for (const auto& c : { coin(R::P0, a, R::P0), coin(R::P1, b, R::P1), tang(a), tang(b) }) + SketchEntityConstraintDef d10, d20; // one leg held at two different lengths + d10.type = d20.type = CT::Distance; + d10.ea = d20.ea = d10.eb = d20.eb = 1; + d10.ra = d20.ra = R::P0; d10.rb = d20.rb = R::P1; + d10.value = 100.0; d20.value = 120.0; + for (const auto& c : { coin(R::P0, a, R::P0), coin(R::P1, b, R::P1), d10, d20 }) pc.push_back(c); std::vector e = ents; REQUIRE_FALSE(solve_sketch_entities(e, pc)); @@ -7447,6 +7523,22 @@ TEST_CASE("Failed sketch solve leaves geometry untouched", "[CadDocument]") } } + // Rung 1 of the ladder — a tangent on EACH leg — is a well-posed fillet, not a conflict. It + // used to be rejected because both tangencies were bound to the arc's START point, which + // forced the two legs parallel; each is now bound to the end that touches its leg. + { + std::vector pc = cs; + for (const auto& c : { coin(R::P0, a, R::P0), coin(R::P1, b, R::P1), tang(a), tang(b) }) + pc.push_back(c); + std::vector e = ents; + REQUIRE(solve_sketch_entities(e, pc)); + CHECK(e[xi].radius == Approx(28.205).margin(1e-6)); + // Tangent at both ends: the radius to each end is perpendicular to that end's leg. + const Vec2d da = (e[a].p1 - e[a].p0).normalized(), db = (e[b].p1 - e[b].p0).normalized(); + CHECK(std::abs((e[xi].p0 - e[xi].center).normalized().dot(da)) < 1e-6); + CHECK(std::abs((e[xi].p1 - e[xi].center).normalized().dot(db)) < 1e-6); + } + // Rung 2 (one tangent) solves, and the arc keeps the radius the fillet gave it. { std::vector pc = cs; @@ -7954,6 +8046,204 @@ TEST_CASE("dressup: four chamfer ids captured up-front drift as earlier chamfers REQUIRE(ok); } +// A closed chain that fails to bound one region although every joint meets, recorded on the rig: +// a top line, an arc that leaves its end heading back over it (crossing it again 2.5 mm on), and +// a 241 deg clockwise arc that leaves a 0.28 mm line tangent to it but the other way (a cusp). +// MakeFace accepted it and the prism came out as walls with no caps. The loop analysis must name +// the point, and the extrude must refuse it with a reason; the same arcs swept the other way round +// are an ordinary profile. +namespace { +enum class Fold { None, Crossing, Cusp, Both }; +std::vector folding_profile(Fold fold) +{ + auto line = [](Vec2d a, Vec2d b) { SketchEntity e; e.type = SketchEntity::Type::Line; e.p0 = a; e.p1 = b; return e; }; + auto arc = [](Vec2d a, Vec2d b, Vec2d c, double r, double a0, double a1) { + SketchEntity e; e.type = SketchEntity::Type::Arc; e.p0 = a; e.p1 = b; e.center = c; e.radius = r; + e.start_angle = a0; e.end_angle = a1; return e; }; + const Vec2d A(3.745882, 40.003810), B(54.449392, 40.003810), C(52.800871, -34.610853), + D(7.730833, -51.667501), E(-32.425978, -102.110463), F(-48.295291, -47.364094), + G(-48.017198, -47.364094); + const double TWO_PI = 2.0 * M_PI; + const bool cross = fold == Fold::Crossing || fold == Fold::Both; + const bool cusp = fold == Fold::Cusp || fold == Fold::Both; + // B->C: clockwise it bulges out to the right; counter-clockwise (recorded) it leaves B back + // over the top line and crosses it. G->A, centred right above G so it is tangent to F->G: + // counter-clockwise it carries on from F->G; clockwise (recorded) it turns straight back. + const double b0 = 1.536417, b1 = 4.702588; + const double g0 = -M_PI / 2.0, g1 = std::atan2(A.y() - 11.653958, A.x() + 48.017198); + return { + line(A, B), + arc(B, C, Vec2d(53.166645, 2.706608), 37.319254, b0, cross ? b1 : b1 - TWO_PI), + line(C, D), + line(D, E), + line(E, F), + line(F, G), + arc(G, A, Vec2d(-48.017198, 11.653958), 59.018053, g0, cusp ? g1 - TWO_PI : g1), + }; +} +} // namespace + +TEST_CASE("loop analysis: a closed loop that crosses or folds back names the point", "[sketch]") +{ + const std::vector order{ 0, 1, 2, 3, 4, 5, 6 }; + Vec2d at; + REQUIRE_FALSE(sketch_loop_defect(folding_profile(Fold::None), order, at)); + + REQUIRE(sketch_loop_defect(folding_profile(Fold::Cusp), order, at)); + INFO("cusp at " << at.transpose()); + REQUIRE((at - Vec2d(-48.017198, -47.364094)).norm() < 1e-3); // the joint G + + REQUIRE(sketch_loop_defect(folding_profile(Fold::Crossing), order, at)); + INFO("crossing at " << at.transpose()); + REQUIRE(std::abs(at.y() - 40.003810) < 1e-3); // on the top line... + REQUIRE(at.x() > 50.0); // ...a little before B + REQUIRE(at.x() < 54.4); + + // Traversal order does not matter: the same loop walked backwards. + const std::vector back{ 6, 5, 4, 3, 2, 1, 0 }; + REQUIRE(sketch_loop_defect(folding_profile(Fold::Cusp), back, at)); + REQUIRE_FALSE(sketch_loop_defect(folding_profile(Fold::None), back, at)); + + // Ordinary joints are not defects: a fillet-like tangent join and a straight continuation. + SketchEntity l1; l1.type = SketchEntity::Type::Line; l1.p0 = Vec2d(0, 0); l1.p1 = Vec2d(10, 0); + SketchEntity l2; l2.type = SketchEntity::Type::Line; l2.p0 = Vec2d(10, 0); l2.p1 = Vec2d(20, 0); + SketchEntity a1; a1.type = SketchEntity::Type::Arc; a1.center = Vec2d(20, 5); a1.radius = 5; + a1.start_angle = -M_PI / 2; a1.end_angle = M_PI / 2; a1.p0 = Vec2d(20, 0); a1.p1 = Vec2d(20, 10); + SketchEntity l3; l3.type = SketchEntity::Type::Line; l3.p0 = Vec2d(20, 10); l3.p1 = Vec2d(0, 10); + SketchEntity l4; l4.type = SketchEntity::Type::Line; l4.p0 = Vec2d(0, 10); l4.p1 = Vec2d(0, 0); + REQUIRE_FALSE(sketch_loop_defect({ l1, l2, a1, l3, l4 }, { 0, 1, 2, 3, 4 }, at)); +} + +TEST_CASE("extrude: a profile that folds back on itself is refused with a reason", "[CadDocument][sketch]") +{ + // A cusp alone still makes a face OCCT calls valid (the spike it leaves is 0.009 mm wide), so + // the kernel builds it; the viewport's loop analysis is what flags it. A crossing does not. + for (Fold f : { Fold::Crossing, Fold::Both }) { + CadDocument bad; + const int sk = bad.add_sketch_entities(folding_profile(f), SketchPlane::XY(), "Sketch"); + bad.add_extrude(sk, 69.42, false, BooleanMode::New, "Extrude"); + INFO("fold kind " << int(f) << ": " << bad.error); + REQUIRE_FALSE(bad.recompute()); + REQUIRE(bad.error.find("folds back") != std::string::npos); + } + + CadDocument good; + const int sk2 = good.add_sketch_entities(folding_profile(Fold::None), SketchPlane::XY(), "Sketch"); + good.add_extrude(sk2, 69.42, false, BooleanMode::New, "Extrude"); + REQUIRE(good.recompute()); + REQUIRE(good.error.empty()); + REQUIRE(good.bodies.size() == 1); + const auto mp = GeometryEngine::mass_properties(good.bodies[0].shape); + REQUIRE(mp.is_solid); + REQUIRE(mp.volume > 0.0); +} + +TEST_CASE("text feature: its string, font and height survive a save and load", "[CadDocument][recipe]") +{ + CadDocument doc; + CadFeature f; + f.type = CadFeatureType::Sketch; + f.name = "Text 1"; + f.plane = SketchPlane::XY(); + f.imported_regions = { { { Vec2d(0, 0), Vec2d(4, 0), Vec2d(4, 6), Vec2d(0, 6) } } }; + f.text_string = "Ab ÷ 12"; + f.text_font = "Noto Sans;Bold"; + f.text_height = 7.5; + doc.features.push_back(f); + REQUIRE(doc.recompute()); + + CadDocument back; + REQUIRE(back.deserialize_recipe(doc.serialize_recipe())); + REQUIRE(back.features.size() == 1); + const CadFeature& g = back.features[0]; + REQUIRE(g.is_text()); + REQUIRE(g.text_string == f.text_string); + REQUIRE(g.text_font == f.text_font); + REQUIRE(g.text_height == f.text_height); + REQUIRE(g.imported_regions == f.imported_regions); // the outline is saved, not re-derived +} + +// Several picked edges dressed by ONE feature: every id is resolved against the same body, so +// capturing them up-front is correct here (unlike the chain of single-edge features above). +TEST_CASE("dressup: one fillet on four picked edges equals the Top face group", "[CadDocument][dressup]") +{ + const double half = 10.0, h = 10.0, r = 1.0; + CadDocument doc = make_centred_box(half, h); + REQUIRE(doc.recompute()); + const double v0 = solid_volume(doc.bodies[0].shape); + + std::vector ids; + for (const Vec3d& t : { Vec3d(half, 0.0, h), Vec3d(0.0, half, h), Vec3d(-half, 0.0, h), Vec3d(0.0, -half, h) }) { + const int id = edge_near(doc.bodies[0].shape, t, 1.5); + REQUIRE(id >= 0); + ids.push_back(id); + } + const int fi = doc.add_fillet(r, ids, "Fillet"); + REQUIRE(doc.features[fi].dressup_edges == ids); + REQUIRE(doc.features[fi].dressup_edge == ids.front()); // what an older build falls back to + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + const double v_list = solid_volume(doc.bodies[0].shape); + + CadDocument ref = make_centred_box(half, h); + ref.add_fillet(r, FaceGroup::Top, "Fillet"); + REQUIRE(ref.recompute()); + const double v_group = solid_volume(ref.bodies[0].shape); + + INFO("v0=" << v0 << " list=" << v_list << " group=" << v_group); + REQUIRE(v_list < v0); + REQUIRE(std::abs(v_list - v_group) < 1e-6 * v0); + + SECTION("the list survives a save and load") { + const std::string blob = doc.serialize_recipe(); + CadDocument back; + REQUIRE(back.deserialize_recipe(blob)); + REQUIRE(back.features.size() == doc.features.size()); + REQUIRE(back.features[fi].dressup_edges == ids); + REQUIRE(std::abs(solid_volume(back.bodies[0].shape) - v_list) < 1e-6 * v0); + } +} + +TEST_CASE("dressup: one chamfer on two picked edges, a single id still takes the one-edge path", "[CadDocument][dressup]") +{ + const double half = 10.0, h = 10.0, d = 0.5; + CadDocument doc = make_centred_box(half, h); + REQUIRE(doc.recompute()); + const double v0 = solid_volume(doc.bodies[0].shape); + const int a = edge_near(doc.bodies[0].shape, Vec3d(half, 0.0, h), 1.5); + const int b = edge_near(doc.bodies[0].shape, Vec3d(-half, 0.0, h), 1.5); + REQUIRE(a >= 0); + REQUIRE(b >= 0); + + CadDocument one = make_centred_box(half, h); + REQUIRE(one.recompute()); + const int f1 = one.add_chamfer(d, std::vector{ a }, "Chamfer"); + REQUIRE(one.features[f1].dressup_edges.empty()); // a single pick stays a plain edge feature + REQUIRE(one.features[f1].dressup_edge == a); + REQUIRE(one.recompute()); + const double single = v0 - solid_volume(one.bodies[0].shape); + + doc.add_chamfer(d, std::vector{ a, b }, "Chamfer"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + const double both = v0 - solid_volume(doc.bodies[0].shape); + INFO("single=" << single << " both=" << both); + REQUIRE(single > 0.0); + // Two opposite rim edges share no corner: removing both takes exactly twice one. + REQUIRE(std::abs(both - 2.0 * single) < 1e-6 * v0); +} + +TEST_CASE("dressup: an edge list naming a missing edge fails with a reason", "[CadDocument][dressup]") +{ + CadDocument doc = make_centred_box(10.0, 10.0); + REQUIRE(doc.recompute()); + const int good = edge_near(doc.bodies[0].shape, Vec3d(10.0, 0.0, 10.0), 1.5); + REQUIRE(good >= 0); + doc.add_fillet(1.0, std::vector{ good, 9999 }, "Fillet"); + REQUIRE_FALSE(doc.recompute()); + REQUIRE_FALSE(doc.error.empty()); +} + // --- Face-drift fingerprint: a FaceAndDirection connector warns when its face index slides --- TEST_CASE("a connector records its face fingerprint on first recompute", "[CadDocument][mate]") @@ -8525,3 +8815,534 @@ TEST_CASE("deleting a feature remaps the references of every consumer, not just REQUIRE(doc.features.empty()); } } + +// A sketch past libslvs' MAX_UNKNOWNS is solved component by component. Constraints onto the +// sketch origin / axes reference NEGATIVE sentinels; the partitioned path used to turn them into +// "no entity" and drop the constraint while still reporting success. +TEST_CASE("Partitioned solve keeps constraints onto the origin and the axes", "[CadDocument][sketch]") +{ + using R = SketchPointRole; + using T = SketchConstraintType; + std::vector ents; + std::vector cons; + const int n = 600; // 600 lines x 4 params: well past the 1024-unknown ceiling + for (int i = 0; i < n; ++i) { + SketchEntity e; e.type = SketchEntity::Type::Line; + e.p0 = Vec2d(1.0 + 0.01 * i, 2.0); e.p1 = Vec2d(5.0 + 0.01 * i, 7.0 + i); + ents.push_back(e); + // P0 onto the origin: the sentinel is in eb, the entity in ea... + cons.push_back({T::Coincident, i, kSketchRefOrigin, R::P0, R::P0, 0.0}); + } + // ...and line 1's far end onto the X axis. + SketchEntityConstraintDef on_x; on_x.type = T::PointOnObject; + on_x.ea = 1; on_x.ra = R::P1; on_x.eb = kSketchRefAxisX; + cons.push_back(on_x); + REQUIRE(solve_sketch_entities(ents, cons)); + for (int i = 0; i < n; ++i) + REQUIRE(ents[i].p0.norm() < 1e-6); + CHECK(std::abs(ents[1].p1.y()) < 1e-6); +} + +// ---- references that must follow their target across a change of history ---------------- + +static CadDocument two_boxes_and_a_lift(int& ea, int& eb, int& lift) +{ + CadDocument doc; + SketchPlane pa = SketchPlane::XY(); + SketchPlane pb = SketchPlane::XY(); pb.origin = Vec3d(100, 0, 0); + const int sa = doc.add_sketch(SketchShape::Rectangle, pa, 10, 10, 0, "A"); + ea = doc.add_extrude(sa, 5, false, BooleanMode::New, "EA"); // body 0 + const int sb = doc.add_sketch(SketchShape::Rectangle, pb, 10, 10, 0, "B"); + eb = doc.add_extrude(sb, 5, false, BooleanMode::New, "EB"); // body 1 + lift = doc.add_transform(1, Vec3d(0, 0, 50), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 0, false, "Lift B"); + REQUIRE(doc.recompute()); + return doc; +} + +TEST_CASE("Deleting the feature that made an EARLIER body keeps later features on their body", + "[CadDocument][history]") +{ + int ea, eb, lift; + CadDocument doc = two_boxes_and_a_lift(ea, eb, lift); + REQUIRE(doc.bodies.size() == 2); + // Body A goes away, so B becomes body 0. "Lift B" said body 1: by index it would now name + // nothing (and used to fall back to the last body in silence); by identity it is still B. + REQUIRE(doc.remove_feature(ea)); + REQUIRE(doc.bodies.size() == 1); + CHECK(doc.features[lift - 1].target_body == 0); + const auto bb = doc.display_mesh.bounding_box(); + CHECK(bb.min.z() > 49.0); // B was lifted + CHECK(bb.min.x() > 90.0); // and it is B, not A +} + +TEST_CASE("Deleting the feature that made a body a later feature works on is refused", + "[CadDocument][history]") +{ + int ea, eb, lift; + CadDocument doc = two_boxes_and_a_lift(ea, eb, lift); + const size_t n = doc.features.size(); + CHECK_FALSE(doc.remove_feature(eb)); // "Lift B" would have nothing to lift + CHECK(doc.features.size() == n); // rolled back + CHECK(doc.bodies.size() == 2); + CHECK_FALSE(doc.error.empty()); +} + +TEST_CASE("Hiding a body-making feature keeps later features on their body", "[CadDocument][history]") +{ + int ea, eb, lift; + CadDocument doc = two_boxes_and_a_lift(ea, eb, lift); + REQUIRE(doc.set_feature_enabled(ea, false)); + REQUIRE(doc.bodies.size() == 1); + CHECK(doc.display_mesh.bounding_box().min.z() > 49.0); + // ...and showing it again puts the reference back where it was. + REQUIRE(doc.set_feature_enabled(ea, true)); + REQUIRE(doc.bodies.size() == 2); + CHECK(doc.features[lift].target_body == 1); +} + +TEST_CASE("A hidden feature can be shown again once the feature that made its body is shown", + "[CadDocument][history]") +{ + CadDocument doc; + const int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "S"); + const int ex = doc.add_extrude(sk, 5, false, BooleanMode::New, "E"); + const int ch = doc.add_chamfer(1.0, FaceGroup::All, "C"); + doc.features[ch].target_body = 0; // as the Design tab sets it from picked edges; -1 has no body to follow + REQUIRE(doc.recompute()); + const double chamfered = double(doc.display_mesh.volume()); + + REQUIRE(doc.set_feature_enabled(ch, false)); + REQUIRE(doc.set_feature_enabled(ex, false)); + // The chamfer has no body while the extrude is hidden... + CHECK_FALSE(doc.set_feature_enabled(ch, true)); + CHECK_FALSE(doc.features[ch].enabled); + // ...and gets it back once the extrude is shown. + REQUIRE(doc.set_feature_enabled(ex, true)); + REQUIRE(doc.set_feature_enabled(ch, true)); + CHECK(doc.features[ch].target_body == 0); + CHECK_THAT(double(doc.display_mesh.volume()), Catch::Matchers::WithinRel(chamfered, 1e-6)); +} + +TEST_CASE("Datum-plane references follow their plane when an earlier plane is deleted", + "[CadDocument][history]") +{ + CadDocument doc; + const int p1 = doc.add_plane(0, 10, 0, 0, "P1"); // datum 0 (3 + 0) + doc.add_plane(0, 20, 0, 0, "P2"); // datum 1 (3 + 1) + const int p3 = doc.add_plane(3 + 1, 5, 0, 0, "P3"); // built on P2: z = 25 + auto planes = doc.resolve_datum_planes(); + REQUIRE(planes.size() == 3); + REQUIRE(std::abs(planes[2].second.origin.z() - 25.0) < 1e-9); + REQUIRE(doc.remove_feature(p1)); + CHECK(doc.features[p3 - 1].plane_base == 3 + 0); // P2 is datum 0 now + planes = doc.resolve_datum_planes(); + REQUIRE(planes.size() == 2); + CHECK(std::abs(planes[1].second.origin.z() - 25.0) < 1e-9); // still on P2, not on XY +} + +TEST_CASE("clear() starts a document without the previous one's variables", "[CadDocument]") +{ + CadDocument doc; + doc.variables["w"] = "12"; + doc.clear(); + CHECK(doc.variables.empty()); +} + +TEST_CASE("Expressions: trig takes degrees, deg()/rad() convert, pi is the number", "[CadDocument][expr]") +{ + CadDocument doc; + const int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "S"); + const int ex = doc.add_extrude(sk, 5, false, BooleanMode::New, "E"); + doc.features[ex].expr["distance"] = "10 * sin(deg(pi / 2)) + 10 * cos(rad(0) + 90) + 2 * cos(60)"; + REQUIRE(doc.recompute()); + CHECK(std::abs(doc.features[ex].distance - 11.0) < 1e-9); // 10 + 0 + 1 +} + +TEST_CASE("Every field the Design tab offers for an expression can be bound", "[CadDocument][expr]") +{ + // The per-tool pickers in DesignPanel::fields_for_tool. A name the kernel does not know made + // the next recompute throw "unknown parameter" and the whole model stopped rebuilding. + for (const char* f : { "width", "height", "radius", "distance", "distance2", "taper_deg", + "dressup_size", "hole_diameter", "hole_depth", "hole_x", "hole_y", + "thread_diameter", "thread_pitch", "thread_height", "thread_depth", + "thread_x", "thread_y", "shell_thickness", "revolve_angle", + "pattern_count", "pattern_spacing", "pattern_angle", "plane_offset", + "plane_angle_tilt", "draft_angle", "thicken_thickness", "rib_thickness", + "rib_depth", "helix_radius", "helix_pitch", "helix_height", + "helix_taper_deg" }) { + INFO(f); + CHECK(CadDocument::is_bindable_field(f)); + } + CHECK_FALSE(CadDocument::is_bindable_field("no_such_field")); +} + +TEST_CASE("A circular pattern spans its whole angle", "[CadDocument][pattern]") +{ + // A 2 mm cube 10 mm out on +X, three copies over 90°: at 0°, 45° and 90°. The old spacing + // (angle / count) stopped at 60°, so the last copy never reached the +Y axis. + CadDocument doc; + SketchPlane p = SketchPlane::XY(); p.origin = Vec3d(10, 0, 0); + const int sk = doc.add_sketch(SketchShape::Rectangle, p, 2, 2, 0, "S"); + doc.add_extrude(sk, 2, false, BooleanMode::New, "E"); + doc.add_pattern(true, 3, 0, 0, 90.0, -1, "P"); + REQUIRE(doc.recompute()); + CHECK(doc.display_mesh.bounding_box().max.y() > 10.5); +} + +// ASCII only: CTest passes the name to Catch on the command line, and on Windows a "°" arrives in +// the ANSI code page, matches no test and fails the run. +TEST_CASE("Hole standards: inch sizes by either name, with their 82 degree countersink", "[CadDocument][hole]") +{ + CadDocument doc; + const int a = doc.add_hole_standard("1/4-20", 2, true, 10, 0, 0, SketchPlane::XY(), "H1"); + const int b = doc.add_hole_standard("#10-24 UNC", 2, true, 10, 0, 0, SketchPlane::XY(), "H2"); + CHECK(doc.features[a].hole_standard == "1/4-20 UNC"); + CHECK(doc.features[a].hole_csink_angle == 82.0); + CHECK(doc.features[b].hole_standard == "#10-24 UNC"); + const int m = doc.add_hole_standard("M6", 1, true, 10, 0, 0, SketchPlane::XY(), "H3"); + CHECK(doc.features[m].hole_csink_angle == 90.0); + CHECK(doc.features[m].hole_cbore_diameter == 11.0); +} + +TEST_CASE("Body colours, the modeling origin and the weld rule survive a save and a load", "[CadDocument][recipe]") +{ + CadDocument doc; + const int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "S"); + doc.add_extrude(sk, 5, false, BooleanMode::New, "E"); + doc.modeling_origin = Vec3d(110, 120, 0); + doc.auto_close_loops = false; // the design's weld rule travels with it + REQUIRE(doc.recompute()); + doc.bodies[0].has_color = true; + doc.bodies[0].color = ColorRGBA(0.1f, 0.2f, 0.3f, 1.0f); + const std::string blob = doc.serialize_recipe(); + CadDocument back; + back.modeling_origin = Vec3d(1, 2, 3); // a different printer's bed centre + REQUIRE(back.deserialize_recipe(blob)); + REQUIRE(back.bodies.size() == 1); + CHECK(back.bodies[0].has_color); + CHECK(std::abs(back.bodies[0].color.g() - 0.2f) < 1e-6); + CHECK((back.modeling_origin - Vec3d(110, 120, 0)).norm() < 1e-9); // the project's own + CHECK(back.origin_from_recipe); + CHECK_FALSE(back.auto_close_loops); + back.auto_close_loops = true; // leave the kernel's global as the other tests expect it + back.recompute(); +} + +TEST_CASE("A new thread reads its radius as the nominal (major) radius", "[CadDocument][thread]") +{ + CadDocument doc; + const int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 30, 30, 0, "S"); + doc.add_extrude(sk, 12, false, BooleanMode::New, "E"); + // M6 internal: major radius 3, depth (major - minor) / 2. + const double P = 1.0, depth = 0.5 * 1.0825 * P; + const int t = doc.add_thread(3.0, P, 10.0, depth, true, 0, 0, SketchPlane::XY(), "T"); + CHECK(doc.features[t].thread_major_nominal); + REQUIRE(doc.recompute()); + // A degenerate new thread says why instead of doing nothing. + doc.features[t].thread_depth = 0.9 * P; + CHECK_FALSE(doc.recompute()); + CHECK(doc.error.find("thread") != std::string::npos); +} + +TEST_CASE("CadDocument: a profile on a body face touches it, one in free space does not", "[CadDocument]") +{ + // The Extrude default reads this: a profile drawn on a body joins it (charter L6). + CadDocument doc; + const int base = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Base"); + doc.add_extrude(base, 5.0, false, BooleanMode::New, "Block"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 1); + + SketchPlane top = SketchPlane::XY(); + top.origin = Vec3d(0, 0, 5); // the block's top face + const int on_face = doc.add_sketch(SketchShape::Rectangle, top, 6, 6, 3, "Boss"); + SketchPlane above = SketchPlane::XY(); + above.origin = Vec3d(0, 0, 30); // well clear of the block + const int in_air = doc.add_sketch(SketchShape::Rectangle, above, 6, 6, 3, "Floating"); + REQUIRE(doc.recompute()); + + CHECK(doc.body_touching_sketch(on_face) == 0); + CHECK(doc.body_touching_sketch(in_air) == -1); + CHECK(doc.body_touching_sketch(-1) == -1); + CHECK(doc.body_touching_sketch(base + 1) == -1); // the extrude: not a sketch +} + +TEST_CASE("display edges: every real edge once, no seams, no degenerate apex", "[CadDocument][display]") +{ + const auto box = GeometryEngine::display_edges(BRepPrimAPI_MakeBox(10., 20., 30.).Shape(), 0.01); + CHECK(box.size() == 12); + for (const auto& pl : box) { + REQUIRE(pl.size() >= 2); + const double len = (pl.back() - pl.front()).norm(); + CHECK((std::abs(len - 10.) < 1e-6 || std::abs(len - 20.) < 1e-6 || std::abs(len - 30.) < 1e-6)); + } + + // A cylinder has three edges in OCCT: the two rims and the seam down its side. Only the rims + // are drawn, each sampled finely enough to look round and closed. + const auto cyl = GeometryEngine::display_edges(BRepPrimAPI_MakeCylinder(5., 8.).Shape(), 0.01); + REQUIRE(cyl.size() == 2); + for (const auto& pl : cyl) { + CHECK(pl.size() > 16); + CHECK((pl.front() - pl.back()).norm() < 1e-6); + for (const Vec3d& p : pl) + CHECK(std::abs(std::hypot(p.x(), p.y()) - 5.) < 0.02); + } + + // A cone keeps its base rim; the apex is a degenerate edge and the side has a seam. + const auto cone = GeometryEngine::display_edges(BRepPrimAPI_MakeCone(5., 0., 8.).Shape(), 0.01); + CHECK(cone.size() == 1); +} + +namespace { +// A 10 x 20 rectangle standing on the plane X axis between u = 5 and u = 15, and a construction +// centerline x = 0 from (0,0) to (0,20): the half-profile of a tube, drawn the usual way. +Slic3r::CadFeature tube_half_profile() +{ + using namespace Slic3r; + CadFeature sk; + sk.type = CadFeatureType::Sketch; + sk.plane = SketchPlane::XY(); + auto line = [](Vec2d a, Vec2d b, bool c) { + SketchEntity e; e.type = SketchEntity::Type::Line; e.p0 = a; e.p1 = b; e.construction = c; return e; }; + sk.entities = { line({5, 0}, {15, 0}, false), line({15, 0}, {15, 20}, false), + line({15, 20}, {5, 20}, false), line({5, 20}, {5, 0}, false), + line({0, 0}, {0, 20}, true) }; + return sk; +} +} // namespace + +TEST_CASE("revolve about a line of the sketch", "[CadDocument][revolve]") +{ + using namespace Slic3r; + + SECTION("a construction centerline: the profile sweeps into a tube around it") { + CadDocument doc; + doc.features.push_back(tube_half_profile()); + const int r = doc.add_revolve(0, 360.0, 0, false, BooleanMode::New, "Rev"); + doc.features[r].revolve_axis_entity = 4; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.bodies.size() == 1); + GProp_GProps props; + BRepGProp::VolumeProperties(doc.bodies[0].shape, props); + CHECK(props.Mass() == Approx(M_PI * (15. * 15. - 5. * 5.) * 20.).epsilon(1e-6)); // 4000 pi + Bnd_Box box; + BRepBndLib::Add(doc.bodies[0].shape, box); + double x0, y0, z0, x1, y1, z1; + box.Get(x0, y0, z0, x1, y1, z1); + CHECK(y0 == Approx(0.).margin(0.01)); // the axis runs along Y, as drawn + CHECK(y1 == Approx(20.).margin(0.01)); + CHECK(x1 == Approx(15.).margin(0.01)); + } + + SECTION("an edge of the profile itself: a solid cylinder") { + CadDocument doc; + doc.features.push_back(tube_half_profile()); + const int r = doc.add_revolve(0, 360.0, 0, false, BooleanMode::New, "Rev"); + doc.features[r].revolve_axis_entity = 3; // the rectangle's left side, u = 5 + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + GProp_GProps props; + BRepGProp::VolumeProperties(doc.bodies[0].shape, props); + CHECK(props.Mass() == Approx(M_PI * 10. * 10. * 20.).epsilon(1e-6)); + } + + SECTION("an axis through the profile is refused with the reason") { + CadDocument doc; + CadFeature sk = tube_half_profile(); + sk.entities[4].p0 = Vec2d(10, 0); + sk.entities[4].p1 = Vec2d(10, 20); // straight through the middle of the rectangle + doc.features.push_back(sk); + const int r = doc.add_revolve(0, 360.0, 0, false, BooleanMode::New, "Rev"); + doc.features[r].revolve_axis_entity = 4; + CHECK_FALSE(doc.recompute()); + INFO(doc.error); + CHECK(doc.error.find("crosses the revolve axis") != std::string::npos); + } + + SECTION("an axis index that no longer names a line fails with a reason") { + CadDocument doc; + doc.features.push_back(tube_half_profile()); + const int r = doc.add_revolve(0, 360.0, 0, false, BooleanMode::New, "Rev"); + doc.features[r].revolve_axis_entity = 9; + CHECK_FALSE(doc.recompute()); + CHECK(doc.error.find("axis line") != std::string::npos); + } + + SECTION("the axis line survives save and load") { + CadDocument doc; + doc.features.push_back(tube_half_profile()); + const int r = doc.add_revolve(0, 270.0, 0, false, BooleanMode::New, "Rev"); + doc.features[r].revolve_axis_entity = 4; + REQUIRE(doc.recompute()); + CadDocument loaded; + REQUIRE(loaded.deserialize_recipe(doc.serialize_recipe())); + REQUIRE(loaded.features.size() == 2); + CHECK(loaded.features[1].revolve_axis_entity == 4); + CHECK(loaded.features[1].revolve_angle == Approx(270.)); + } +} + +// faces_made_by is what the Design tab highlights when a feature row is selected: the faces of +// the finished model that this feature made, not the whole body it belongs to. +static GeomAbs_SurfaceType made_face_type(const CadDocument& doc, const std::pair& bf) +{ + return BRepAdaptor_Surface(GeometryEngine::face_by_index(doc.bodies[bf.first].shape, bf.second)).GetType(); +} + +TEST_CASE("A fillet owns its round face, and the extrude keeps the faces it trimmed", "[CadDocument][highlight]") +{ + CadDocument doc; + const int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Sketch"); + const int ex = doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude"); + REQUIRE(doc.recompute()); + const int fi = doc.add_fillet(2.0, 0, "Fillet"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 1); + REQUIRE(GeometryEngine::faces_of(doc.bodies[0].shape).size() == 7); + + const auto fillet = doc.faces_made_by(fi); + REQUIRE(fillet.size() == 1); + CHECK(fillet[0].first == 0); + CHECK(made_face_type(doc, fillet[0]) == GeomAbs_Cylinder); + + const auto extrude = doc.faces_made_by(ex); + CHECK(extrude.size() == 6); + for (const auto& bf : extrude) + CHECK(made_face_type(doc, bf) == GeomAbs_Plane); + + CHECK(doc.faces_made_by(sk).empty()); // a sketch leaves no face behind + + // A hidden feature made nothing in the model on screen. + REQUIRE(doc.set_feature_enabled(fi, false)); + CHECK(doc.faces_made_by(fi).empty()); +} + +TEST_CASE("A chamfer owns its bevel", "[CadDocument][highlight]") +{ + CadDocument doc; + const int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Sketch"); + const int ex = doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude"); + REQUIRE(doc.recompute()); + const int ch = doc.add_chamfer(2.0, 0, "Chamfer"); + REQUIRE(doc.recompute()); + REQUIRE(GeometryEngine::faces_of(doc.bodies[0].shape).size() == 7); + + const auto bevel = doc.faces_made_by(ch); + REQUIRE(bevel.size() == 1); + CHECK(made_face_type(doc, bevel[0]) == GeomAbs_Plane); + const auto extrude = doc.faces_made_by(ex); + CHECK(extrude.size() == 6); + CHECK(std::find(extrude.begin(), extrude.end(), bevel[0]) == extrude.end()); +} + +TEST_CASE("A boss owns its wall and cap, not the face it stands on", "[CadDocument][highlight]") +{ + CadDocument doc; + const int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 30, 20, 10, "Sketch"); + const int ex = doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude"); + REQUIRE(doc.recompute()); + SketchPlane top = SketchPlane::XY(); + top.origin = Vec3d(0, 0, 10); + const int sk2 = doc.add_sketch_entities({circle_entity({0, 0}, 5.0)}, top, "Sketch2"); + const int boss = doc.add_extrude(sk2, 8.0, false, BooleanMode::Add, "Boss"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 1); + + const auto made = doc.faces_made_by(boss); + REQUIRE_FALSE(made.empty()); + int walls = 0, caps = 0; + for (const auto& bf : made) { + const GeomAbs_SurfaceType t = made_face_type(doc, bf); + walls += t == GeomAbs_Cylinder; + // The cap is the plane at the boss's top, not the box's top face it stands on. + if (t == GeomAbs_Plane) { + ++caps; + Bnd_Box bb; + BRepBndLib::Add(GeometryEngine::face_by_index(doc.bodies[0].shape, bf.second), bb); + double x0, y0, z0, x1, y1, z1; + bb.Get(x0, y0, z0, x1, y1, z1); + CHECK(z0 > 17.9); + } + } + CHECK(walls >= 1); + CHECK(caps == 1); + CHECK(doc.faces_made_by(ex).size() == 6); // the box keeps its top face, hole and all +} + +TEST_CASE("A body stacked on another still owns the face they share", "[CadDocument][highlight]") +{ + CadDocument doc; + const int sk0 = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Sketch0"); + doc.add_extrude(sk0, 10.0, false, BooleanMode::New, "Base"); + SketchPlane top = SketchPlane::XY(); + top.origin = Vec3d(0, 0, 10); + const int sk1 = doc.add_sketch(SketchShape::Rectangle, top, 10, 10, 5, "Sketch1"); + const int ex1 = doc.add_extrude(sk1, 5.0, false, BooleanMode::New, "Block"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 2); + + // The block's bottom lies in the base's top face, facing the other way: it is the block's. + const auto made = doc.faces_made_by(ex1); + CHECK(made.size() == 6); + for (const auto& bf : made) + CHECK(bf.first == 1); +} + +TEST_CASE("A Boolean union, which makes no face of its own, answers with the body it changed", "[CadDocument][highlight]") +{ + CadDocument doc; + const int sk0 = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Sketch0"); + doc.add_extrude(sk0, 10.0, false, BooleanMode::New, "Box0"); + SketchPlane beside = SketchPlane::XY(); + beside.origin = Vec3d(10, 5, 0); + const int sk1 = doc.add_sketch(SketchShape::Rectangle, beside, 20, 20, 10, "Sketch1"); + doc.add_extrude(sk1, 10.0, false, BooleanMode::New, "Box1"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 2); + const int fuse = doc.add_boolean(BooleanMode::Add, 0, 1, false, 0.0, -1, -1, "Fuse"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 1); + + const auto made = doc.faces_made_by(fuse); + CHECK(made.size() == GeometryEngine::faces_of(doc.bodies[0].shape).size()); +} + +TEST_CASE("A Boolean subtract owns the walls its tool cut", "[CadDocument][highlight]") +{ + CadDocument doc; + const int sk0 = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Sketch0"); + doc.add_extrude(sk0, 10.0, false, BooleanMode::New, "Box0"); + SketchPlane beside = SketchPlane::XY(); + beside.origin = Vec3d(10, 5, 0); + const int sk1 = doc.add_sketch(SketchShape::Rectangle, beside, 20, 20, 10, "Sketch1"); + doc.add_extrude(sk1, 10.0, false, BooleanMode::New, "Box1"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 2); + const int cut = doc.add_boolean(BooleanMode::Cut, 0, 1, false, 0.0, -1, -1, "Cut"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 1); + + // The tool notches a corner out of the box: the two walls of the notch, the tool's faces + // turned inside out, are the subtract's; what is left of the box's own faces is not. + const auto made = doc.faces_made_by(cut); + CHECK(made.size() == 2); + for (const auto& bf : made) + CHECK(made_face_type(doc, bf) == GeomAbs_Plane); +} + +TEST_CASE("A hole owns its bore", "[CadDocument][highlight]") +{ + CadDocument doc; + const int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Sketch"); + const int ex = doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude"); + REQUIRE(doc.recompute()); + const int hole = doc.add_hole(6.0, 20.0, true, 0.0, 0.0, SketchPlane::XY(), "Hole"); + REQUIRE(doc.recompute()); + + const auto bore = doc.faces_made_by(hole); + REQUIRE_FALSE(bore.empty()); + for (const auto& bf : bore) + CHECK(made_face_type(doc, bf) == GeomAbs_Cylinder); + CHECK(doc.faces_made_by(ex).size() == 6); +} diff --git a/tests/libslic3r/test_sketchedit.cpp b/tests/libslic3r/test_sketchedit.cpp index d98f78d54b..2c4e9aaa5b 100644 --- a/tests/libslic3r/test_sketchedit.cpp +++ b/tests/libslic3r/test_sketchedit.cpp @@ -118,7 +118,10 @@ TEST_CASE("Offset Line by positive d", "[SketchEdit]") REQUIRE_THAT(o.p1.y(), WithinAbs(2.0, 1e-9)); } -TEST_CASE("Offset Circle: expand and collapse", "[SketchEdit]") +// CONTRACT CHANGED: a circle follows the arc's "+d = left of travel" rule and counts as CCW, so +// +d shrinks it and -d grows it. It used to grow on +d, the opposite of the same outline drawn +// as a CCW chain of arcs. +TEST_CASE("Offset Circle: +d shrinks (a circle is CCW), -d grows, too far collapses", "[SketchEdit]") { SketchEntity e; e.type = SketchEntity::Type::Circle; @@ -126,14 +129,36 @@ TEST_CASE("Offset Circle: expand and collapse", "[SketchEdit]") e.p0 = Vec2d(0, 0); e.radius = 5; - auto expanded = SketchEngine::offset_entities({e}, 2.0); - REQUIRE(expanded.size() == 1); - REQUIRE_THAT(expanded[0].radius, WithinAbs(7.0, 1e-9)); + auto inward = SketchEngine::offset_entities({e}, 2.0); + REQUIRE(inward.size() == 1); + REQUIRE_THAT(inward[0].radius, WithinAbs(3.0, 1e-9)); - auto collapsed = SketchEngine::offset_entities({e}, -5.0); + auto outward = SketchEngine::offset_entities({e}, -2.0); + REQUIRE(outward.size() == 1); + REQUIRE_THAT(outward[0].radius, WithinAbs(7.0, 1e-9)); + + auto collapsed = SketchEngine::offset_entities({e}, 5.0); REQUIRE(collapsed.empty()); } +TEST_CASE("Offset: a full circle and the same CCW outline drawn as arcs go the same way", "[SketchEdit]") +{ + SketchEntity c; + c.type = SketchEntity::Type::Circle; c.center = Vec2d(0, 0); c.p0 = c.center; c.radius = 5; + SketchEntity a0, a1; + a0.type = a1.type = SketchEntity::Type::Arc; + a0.center = a1.center = Vec2d(0, 0); + a0.radius = a1.radius = 5; + a0.start_angle = 0.0; a0.end_angle = M_PI; a0.p0 = Vec2d(5, 0); a0.p1 = Vec2d(-5, 0); + a1.start_angle = M_PI; a1.end_angle = 2.0 * M_PI; a1.p0 = Vec2d(-5, 0); a1.p1 = Vec2d(5, 0); + const auto oc = SketchEngine::offset_entities({ c }, 1.0); + const auto oa = SketchEngine::offset_entities({ a0, a1 }, 1.0); + REQUIRE(oc.size() == 1); + REQUIRE(oa.size() == 2); + CHECK_THAT(oc[0].radius, WithinAbs(oa[0].radius, 1e-9)); + CHECK_THAT(oc[0].radius, WithinAbs(oa[1].radius, 1e-9)); +} + // CONTRACT CHANGED: +d used to mean "radius + d" for every arc regardless of its sweep, while // for a line it meant "left of the direction of travel". The two disagreed, so a profile made // of lines AND arcs (any slot outline) offset with its straights going one way and its caps the @@ -383,6 +408,9 @@ TEST_CASE("Trim arc drops the picked (start) side", "[SketchEdit]") REQUIRE_THAT(e.radius, WithinAbs(5.0, 1e-9)); REQUIRE_THAT(e.start_angle, WithinAbs(M_PI / 2.0, 1e-9)); REQUIRE_THAT(e.end_angle, WithinAbs(M_PI, 1e-9)); + // The stored endpoints follow the angles: everything downstream reads p0/p1. + CHECK_THAT(e.p0.x(), WithinAbs(0.0, 1e-9)); CHECK_THAT(e.p0.y(), WithinAbs(5.0, 1e-9)); + CHECK_THAT(e.p1.x(), WithinAbs(-5.0, 1e-9)); CHECK_THAT(e.p1.y(), WithinAbs(0.0, 1e-9)); } TEST_CASE("Trim arc drops the picked (end) side", "[SketchEdit]") @@ -405,6 +433,8 @@ TEST_CASE("Trim arc drops the picked (end) side", "[SketchEdit]") REQUIRE(e.type == SketchEntity::Type::Arc); REQUIRE_THAT(e.start_angle, WithinAbs(0.0, 1e-9)); REQUIRE_THAT(e.end_angle, WithinAbs(M_PI / 2.0, 1e-9)); + CHECK_THAT(e.p0.x(), WithinAbs(5.0, 1e-9)); CHECK_THAT(e.p0.y(), WithinAbs(0.0, 1e-9)); + CHECK_THAT(e.p1.x(), WithinAbs(0.0, 1e-9)); CHECK_THAT(e.p1.y(), WithinAbs(5.0, 1e-9)); } TEST_CASE("Trim circle opens into an arc excluding the pick", "[SketchEdit]") @@ -414,7 +444,7 @@ TEST_CASE("Trim circle opens into an arc excluding the pick", "[SketchEdit]") SketchEntity e; e.type = SketchEntity::Type::Circle; e.center = Vec2d(0, 0); - e.p0 = Vec2d(5, 0); + e.p0 = e.center; // circle convention: p0 mirrors the centre e.radius = 5; SketchEntity cut; @@ -431,6 +461,11 @@ TEST_CASE("Trim circle opens into an arc excluding the pick", "[SketchEdit]") double mid = 0.5 * (e.start_angle + e.end_angle); REQUIRE_THAT(5 * std::cos(mid), WithinAbs(-5.0, 1e-9)); REQUIRE_THAT(5 * std::sin(mid), WithinAbs(0.0, 1e-9)); + // p0 was the circle's centre; as an arc it must be the arc's start, on the rim. + CHECK_THAT((e.p0 - e.center).norm(), WithinAbs(5.0, 1e-9)); + CHECK_THAT((e.p1 - e.center).norm(), WithinAbs(5.0, 1e-9)); + CHECK_THAT(e.p0.x(), WithinAbs(5.0 * std::cos(e.start_angle), 1e-9)); + CHECK_THAT(e.p1.x(), WithinAbs(5.0 * std::cos(e.end_angle), 1e-9)); } TEST_CASE("Extend arc forward (end) to a crossing", "[SketchEdit]") @@ -454,6 +489,7 @@ TEST_CASE("Extend arc forward (end) to a crossing", "[SketchEdit]") REQUIRE(e.type == SketchEntity::Type::Arc); REQUIRE_THAT(e.start_angle, WithinAbs(0.0, 1e-9)); REQUIRE_THAT(e.end_angle, WithinAbs(M_PI, 1e-9)); + CHECK_THAT(e.p1.x(), WithinAbs(-5.0, 1e-9)); CHECK_THAT(e.p1.y(), WithinAbs(0.0, 1e-9)); } TEST_CASE("Extend arc backward (start) to a crossing", "[SketchEdit]") @@ -483,7 +519,7 @@ TEST_CASE("Extend circle returns false (closed)", "[SketchEdit]") SketchEntity e; e.type = SketchEntity::Type::Circle; e.center = Vec2d(0, 0); - e.p0 = Vec2d(5, 0); + e.p0 = e.center; // circle convention: p0 mirrors the centre e.radius = 5; SketchEntity cut; @@ -736,3 +772,66 @@ TEST_CASE("sketch_open_ends names where a chain fails to close", "[SketchEngine] REQUIRE_THAT(got[1].x(), WithinAbs(0.0, 1e-9)); REQUIRE_THAT(got[1].y(), WithinAbs(10.0, 1e-9)); } + +TEST_CASE("Mirror EllipseArc keeps the same arc, not its complement", "[SketchEdit]") +{ + // A quarter of an ellipse a=6 b=3 centred at (10,0), CCW from param 0 to pi/2, mirrored + // across the Y axis: the image is a quarter again (the complement would be three quarters), + // its start angle belongs to its p0, and its middle is still above the X axis. + SketchEntity e; + e.type = SketchEntity::Type::EllipseArc; + e.center = Vec2d(10, 0); e.radius = 6; e.rminor = 3; e.rotation = 0.0; + e.start_angle = 0.0; e.end_angle = M_PI / 2.0; + e.p0 = Vec2d(16, 0); e.p1 = Vec2d(10, 3); + const auto out = SketchEngine::mirror_entities({ e }, Vec2d(0, -1), Vec2d(0, 1)); + REQUIRE(out.size() == 1); + const SketchEntity& m = out[0]; + REQUIRE(m.type == SketchEntity::Type::EllipseArc); + CHECK_THAT(m.end_angle - m.start_angle, WithinAbs(M_PI / 2.0, 1e-9)); + const double cr = std::cos(m.rotation), sr = std::sin(m.rotation); + auto at = [&](double t) { + return Vec2d(m.center.x() + m.radius * std::cos(t) * cr - m.rminor * std::sin(t) * sr, + m.center.y() + m.radius * std::cos(t) * sr + m.rminor * std::sin(t) * cr); + }; + CHECK((at(m.start_angle) - m.p0).norm() < 1e-6); + CHECK((at(m.end_angle) - m.p1).norm() < 1e-6); + CHECK(at(0.5 * (m.start_angle + m.end_angle)).y() > 1.0); +} + +TEST_CASE("Bridge between two collinear lines is straight and stays between them", "[SketchEdit]") +{ + SketchEntity a, b; + a.type = b.type = SketchEntity::Type::Line; + a.p0 = Vec2d(-10, 0); a.p1 = Vec2d(0, 0); // ends at x=0 heading +X + b.p0 = Vec2d(9, 0); b.p1 = Vec2d(19, 0); // starts at x=9 heading +X + const SketchEntity br = SketchEngine::make_bridge(a, 1, b, 0); + REQUIRE(br.ctrl.size() == 4); + // G1 into b: the last inner pole sits BEFORE b's start, on the side the curve arrives from. + CHECK(br.ctrl[1].x() > 0.0); + CHECK(br.ctrl[2].x() < 9.0); + for (const Vec2d& p : br.ctrl) CHECK_THAT(p.y(), WithinAbs(0.0, 1e-9)); +} + +TEST_CASE("Transform with a negative scale keeps an arc on its endpoints", "[SketchEdit]") +{ + SketchEntity e; + e.type = SketchEntity::Type::Arc; + e.center = Vec2d(3, 0); e.radius = 2; e.start_angle = 0.0; e.end_angle = M_PI / 2.0; + e.p0 = Vec2d(5, 0); e.p1 = Vec2d(3, 2); + const auto out = SketchEngine::transform_entities({ e }, Vec2d(0, 0), 0.0, -1.0, Vec2d(0, 0)); + REQUIRE(out.size() == 1); + const SketchEntity& m = out[0]; + // A point reflection through the origin: centre (-3,0), start (-5,0), end (-3,-2). + CHECK((m.center - Vec2d(-3, 0)).norm() < 1e-9); + CHECK((m.p0 - Vec2d(-5, 0)).norm() < 1e-9); + CHECK((m.p1 - Vec2d(-3, -2)).norm() < 1e-9); +} + +TEST_CASE("A zero-radius circle does not build a wire (and does not throw)", "[SketchEdit]") +{ + SketchEntity c; + c.type = SketchEntity::Type::Circle; c.center = Vec2d(0, 0); c.p0 = c.center; c.radius = 0.0; + std::vector w; + REQUIRE_NOTHROW(w = SketchEngine::entities_to_wires({ c }, SketchPlane::XY(), true)); + CHECK(w.empty()); +}