#include "DesignPanel.hpp" #include "DesignCanvas.hpp" #include "DesignSketchTool.hpp" #include "libslic3r/GeometryEngine.hpp" // face_by_index for face-extrude gizmo anchor #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "slic3r/GUI/wxExtensions.hpp" // ScalableButton, create_scaled_bitmap #include "Widgets/Label.hpp" // HarmonyOS Sans fonts (Head_*/Body_*) shared with the rest of Orca #include "Widgets/DropDown.hpp" // Orca-themed combo dropdown (white/teal selector) for the tool flyouts #include "libslic3r/SketchImport.hpp" // text_to_regions / svg_to_regions #include "libslic3r/ThreadStandards.hpp" // ISO metric / Unified imperial thread tables #include "libslic3r/Model.hpp" #include "slic3r/GUI/GUI_App.hpp" #include "slic3r/GUI/Plater.hpp" #include "libslic3r/BuildVolume.hpp" #include "slic3r/GUI/MainFrame.hpp" #include "slic3r/GUI/GUI_ObjectList.hpp" // English-only pin for the Design tab (see snaporca-design-ux-contract): one lever // de-translates this whole TU so our strings never half-translate against the host's // localized chrome. Host UI still follows the app locale; only this tab is pinned EN. // GOTCHA: every _L(...) in this file must take a STRING LITERAL (FromUTF8 wants const char*). #ifdef _L #undef _L #endif #define _L(s) wxString::FromUTF8(s) namespace Slic3r { namespace GUI { // Format a value with the international ('.') decimal separator regardless of the // app's LC_NUMERIC locale (wx sets it to the user locale at startup). snprintf may // emit a comma, so normalise it. static wxString en_format(double v, int digits = 2) { char fmt[16]; std::snprintf(fmt, sizeof(fmt), "%%.%df", digits); char buf[64]; std::snprintf(buf, sizeof(buf), fmt, v); for (char* c = buf; *c; ++c) if (*c == ',') *c = '.'; return wxString::FromUTF8(buf); } // Parse a user-typed value accepting either '.' or ',' as the decimal separator. static bool en_parse(const wxString& text, double& out) { wxString t(text); t.Replace(wxT(","), wxT(".")); 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. 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 wxSpinCtrlDouble* make_spin(wxWindow* parent, double val, double mn = 0.1, double mx = 1000.0) { auto* s = new wxSpinCtrlDouble(parent, wxID_ANY, "", wxDefaultPosition, wxSize(90, -1)); s->SetRange(mn, mx); s->SetDigits(2); s->SetValue(val); return s; } static SketchPlane plane_from_index(int i) { switch (i) { case 1: return SketchPlane::XZ(); case 2: return SketchPlane::YZ(); default: return SketchPlane::XY(); } } // Inverse of plane_from_index: recover the wxChoice row from a plane's normal. // XY normal=(0,0,1)->0, XZ normal=(0,1,0)->1, YZ normal=(1,0,0)->2. static int index_from_plane(const SketchPlane& p) { if (std::abs(p.normal.y()) > 0.5) return 1; // XZ if (std::abs(p.normal.x()) > 0.5) return 2; // YZ return 0; // XY } // #2: a sketch plane on `face` with origin at the face centroid and normal pointing INTO the // solid, so a positioned hole drills inward and its (x,y) read as the offset from the face // centre. A hole is rotationally symmetric, so the arbitrary in-plane basis is harmless. static SketchPlane face_plane_inward(const TopoDS_Face& face) { SketchPlane p; p.origin = GeometryEngine::face_centroid_world(face); p.normal = (-GeometryEngine::face_normal_world(face)).normalized(); // inward // Align the in-plane x-axis with the face's LONGEST straight edge so the (u,v) frame matches // the face sides — then "distance from a side" (the hole construction dims) reads correctly. Vec3d x(0, 0, 0); double best = 0; for (const TopoDS_Edge& e : GeometryEngine::edges_of_face(face)) { const std::vector pts = GeometryEngine::sample_edge_world(e); if (pts.size() < 2) continue; Vec3d d = pts.back() - pts.front(); d = d - p.normal * d.dot(p.normal); // project the edge direction into the plane const double len = d.norm(); if (len > best) { best = len; x = d / len; } } if (best < 1e-9) { // curved/edgeless face: fall back to an arbitrary in-plane basis const Vec3d ref = std::abs(p.normal.z()) < 0.9 ? Vec3d(0, 0, 1) : Vec3d(1, 0, 0); x = ref.cross(p.normal).normalized(); } p.x_axis = x.normalized(); p.y_axis = p.normal.cross(p.x_axis).normalized(); 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) { // 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* root = new wxBoxSizer(wxVERTICAL); { auto* hdr = new wxStaticText(m_form, wxID_ANY, _L("Design")); hdr->SetFont(Label::Head_16); // Orca shared HarmonyOS section-title font root->Add(hdr, 0, wxLEFT | wxRIGHT | wxTOP, 12); root->AddSpacer(2); } // === Top contextual toolbar (Onshape-style icon strip) === // Parented to the panel (sits above the form/viewport row). Only the active // mode's group is shown; the others are hidden by set_ui_mode(). // Scrollable ribbon: on a narrow/windowed screen the far-right action bar (Confirm/Cancel) // used to be clipped off the edge with no way to reach it. Horizontal-only scroll (vertical // rate 0) keeps it reachable; on a wide screen the stretch spacer still pins it far-right. m_toolbar = new wxScrolledWindow(this, wxID_ANY); m_toolbar->SetScrollRate(15, 0); m_toolbar->ShowScrollbars(wxSHOW_SB_DEFAULT, wxSHOW_SB_NEVER); // Theme-aware tool ribbon: dark uses Orca's elevated surface (#36363C / hover // #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) { // Prepare-toolbar-sized buttons (40px cell / 28px glyph) so the Design // ribbon matches the rest of the app instead of feeling tiny. auto* b = new ScalableButton(m_toolbar, wxID_ANY, icon, "", wxSize(52, 52), wxDefaultPosition, wxBU_EXACTFIT | wxBORDER_NONE, false, 42); b->SetToolTip(tip); b->SetBackgroundColour(tb_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->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->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. b->Bind(wxEVT_LEFT_DOWN, [this, b](wxMouseEvent& e) { set_active_tool_btn(b); e.Skip(); }); return b; }; // Small grey group caption (Onshape-style section hint) for each toolbar mode. auto caption = [this](const wxString& t) { auto* s = new wxStaticText(m_toolbar, wxID_ANY, t); wxFont f = Label::Body_12; f.SetWeight(wxFONTWEIGHT_BOLD); s->SetFont(f); s->SetForegroundColour(dp_sec_text()); // Orca dark secondary text return s; }; auto add_sep = [this](wxSizer* row) { row->AddSpacer(5); row->Add(new wxStaticLine(m_toolbar, wxID_ANY, wxDefaultPosition, wxSize(1, 22), wxLI_VERTICAL), 0, wxALIGN_CENTER_VERTICAL); row->AddSpacer(5); }; // Shared sketch-tool selector: begins a session on first use, then switches // the active entity tool. The Construction toggle marks following entities as // construction geometry (excluded from the wire). m_construction = new wxCheckBox(m_toolbar, wxID_ANY, _L("Construction")); m_construction->SetForegroundColour(dp_ctl_text()); auto select_tool = [this](DesignSketchTool::Mode mode, const wxString& hint) { if (!m_viewport) return; if (!m_viewport->is_sketching()) { const SketchPlane plane = plane_from_choice(m_draw_plane->GetSelection()); m_viewport->begin_sketch(plane, mode); m_construction->SetValue(false); // a fresh session starts non-construction } else { m_viewport->set_sketch_tool(mode); } m_viewport->set_sketch_construction(m_construction->GetValue()); m_status->SetForegroundColour(wxNullColour); m_status->SetLabel(hint); m_status->Refresh(); }; // Sketch-tool shortcuts (single letters, active only while a sketch is open). Family tools // bind to their default mode; the other modes stay in the toolbar flyout. Registered here // where select_tool is in scope; the closures run at key-press time (members are live by then). auto sk_key = [this, select_tool](int ch, DesignSketchTool::Mode m, const wxString& h) { m_keys_sketch[ch] = [this, select_tool, m, h] { select_tool(m, h); }; }; sk_key('L', DesignSketchTool::Mode::Line, _L("Line — click start, then end")); 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 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")); sk_key('D', DesignSketchTool::Mode::Dimension, _L("Dimension — click 2 points or an entity")); sk_key('T', DesignSketchTool::Mode::Trim, _L("Trim — click a segment to trim it")); sk_key('X', DesignSketchTool::Mode::Extend, _L("Extend — click a line/arc to extend it")); sk_key('O', DesignSketchTool::Mode::Offset, _L("Offset — pick an entity, drag the distance")); sk_key('M', DesignSketchTool::Mode::Mirror, _L("Mirror — pick axis, then entities")); sk_key('F', DesignSketchTool::Mode::Fillet, _L("Fillet — pick two lines, set the radius")); sk_key('H', DesignSketchTool::Mode::Chamfer, _L("Chamfer — pick two lines, set the distance")); // Polygon needs its side count / circumscribed flag pushed to the tool before it starts. m_keys_sketch['G'] = [this, select_tool] { if (m_viewport) { m_viewport->set_sketch_polygon_sides(m_sides ? m_sides->GetValue() : 6); m_viewport->set_sketch_polygon_circumscribed(m_poly_circ && m_poly_circ->GetValue()); } select_tool(DesignSketchTool::Mode::Polygon, _L("Polygon — click center, then a vertex")); }; // Constrain (finish the live sketch + enter constrain), and Construction toggle. m_keys_sketch['K'] = [this] { enter_constrain_inline(); }; m_keys_sketch['Q'] = [this] { if (m_construction) { m_construction->SetValue(!m_construction->GetValue()); if (m_viewport && m_viewport->is_sketching()) m_viewport->set_sketch_construction(m_construction->GetValue()); } }; // Shift+letter encoder for the feature-tool shortcuts (registered via FeatVar::key below, // and explicitly for the standalone feature buttons). auto SHIFT = [](int ch) { return ch | SC_SHIFT; }; // 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); m_status->SetLabel(on ? on_msg : off_msg); m_status->Refresh(); }; m_keys_feature['P'] = [this, status_flag] { if (m_viewport) status_flag(_L("Origin planes shown"), _L("Origin planes hidden"), m_viewport->toggle_planes()); }; m_keys_feature['A'] = [this, status_flag] { if (m_viewport) status_flag(_L("World axes shown"), _L("World axes hidden"), m_viewport->toggle_axes()); }; m_keys_feature['X'] = [this] { toggle_section_view(); }; // toggle the single section on/off // 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); }; // --- Feature group: Sketch / Extrude / Fillet-Chamfer / Hole / Thread / Constrain m_tb_feature = new wxBoxSizer(wxHORIZONTAL); auto fadd = [this](wxWindow* w) { m_tb_feature->Add(w, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 2); }; m_tb_feature->Add(caption(_L("FEATURES")), 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 8); { // Onshape-style FEATURE flyouts: same themed-DropDown pattern as the sketch toolbar // (tinted glyphs, Body_14 measure, content-width popup) but each entry runs an // arbitrary action — the existing per-feature handler — instead of selecting a Mode. struct FeatVar { const char* icon; wxString tip; wxString hint; std::function action; int key = 0; }; struct FeatFlyout { std::vector items; // mainline DropDown is Item-based (text/tip/icon per row) std::vector> actions; std::vector icon_names; ScalableButton* btn = nullptr; DropDown drop; // declared LAST: destroyed before the vector it references FeatFlyout() : drop(items) {} }; auto feat_dropdown = [&](const char* def_icon, const wxString& grp, std::vector vars) { auto* b = icon_btn(def_icon, grp); b->SetFont(Label::Body_14); // measure popup labels in the popup's font (no truncation) auto fo = std::make_shared(); for (auto& v : vars) { 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); fo->items.push_back(it); fo->actions.push_back(std::move(v.action)); fo->icon_names.emplace_back(v.icon); if (v.key) m_keys_feature[v.key] = fo->actions.back(); // key runs the same action } fo->btn = b; fo->drop.Create(b); fo->drop.SetUseContentWidth(true, false); fo->drop.Invalidate(true); FeatFlyout* fp = fo.get(); fo->drop.Bind(wxEVT_COMBOBOX, [this, fp](wxCommandEvent& e) { int i = e.GetInt(); if (i >= 0 && i < (int) fp->actions.size()) { fp->btn->SetBitmap_(fp->icon_names[i]); // button face follows the last pick fp->actions[i](); set_active_tool_btn(fp->btn); } }); b->Bind(wxEVT_BUTTON, [b, fp](wxCommandEvent&) { // Force a fresh content measure before Popup() (ComboBox does this via the // private autoPosition()); otherwise the popup maps at a stale narrow size. fp->drop.Invalidate(true); 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.Popup(); }); m_flyout_keepalive.push_back(fo); fadd(b); auto* chev = new wxStaticText(m_toolbar, wxID_ANY, wxString::FromUTF8("\xE2\x96\xBE")); chev->SetForegroundColour(dp_sec_text()); chev->SetFont(Label::Body_9); m_tb_feature->Add(chev, 0, wxALIGN_BOTTOM | wxBOTTOM | wxRIGHT, 5); return b; }; auto* b_sketch = icon_btn("design_sketch", _L("Sketch")); std::function act_sketch = [this] { populate_plane_choices(m_draw_plane); // surface datum planes in the picker set_ui_mode(UiMode::Sketch); m_status->SetForegroundColour(wxNullColour); m_status->SetLabel(_L("Pick a plane and a sketch tool, then draw")); m_status->Refresh(); }; b_sketch->Bind(wxEVT_BUTTON, [act_sketch](wxCommandEvent&) { act_sketch(); }); m_keys_feature[SHIFT('S')] = act_sketch; fadd(b_sketch); add_sep(m_tb_feature); // Add material: Extrude / Revolve / Sweep / Loft feat_dropdown("design_extrude", _L("Add material (extrude / revolve / sweep / loft)"), { {"design_extrude", _L("Extrude"), _L("Extrude a sketch profile, or push/pull a picked face"), [this] { // 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) { m_extrude_face_src = m_sel_solid_face; m_extrude_sketch_ref = -1; open_tool(Tool::Extrude); return; } 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)); m_status->SetLabel(_L("Create a sketch, or pick a solid face, first")); m_status->Refresh(); return; } open_tool(Tool::Extrude); }, SHIFT('E')}, {"design_revolve", _L("Revolve"), _L("Revolve a profile about an axis"), [this] { m_revolve_sketch_ref = resolve_extrude_sketch(); if (m_revolve_sketch_ref < 0) { m_status->SetForegroundColour(wxColour(235, 110, 110)); m_status->SetLabel(_L("Create a sketch profile to revolve first")); m_status->Refresh(); return; } open_tool(Tool::Revolve); }, SHIFT('R')}, {"design_sweep", _L("Sweep"), _L("Sweep a profile along a path"), [this] { 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)); m_status->SetLabel(_L("Create a profile sketch to sweep first")); m_status->Refresh(); return; } open_tool(Tool::Sweep); }, SHIFT('W')}, {"design_loft", _L("Loft"), _L("Loft (skin) between two or more profiles"), [this] { // Loft skins 2+ profile sketches; need at least two to be meaningful. int n = 0; for (const auto& f : m_doc.features) if (f.type == CadFeatureType::Sketch) ++n; if (n < 2) { m_status->SetForegroundColour(wxColour(235, 110, 110)); m_status->SetLabel(_L("Create at least two profile sketches to loft")); m_status->Refresh(); return; } m_loft_refs.clear(); // fresh loft: nothing pre-checked open_tool(Tool::Loft); }, SHIFT('L')}, }); auto* b_pattern = icon_btn("design_pattern", _L("Pattern")); 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)); m_status->SetLabel(_L("Create a solid body to pattern first")); m_status->Refresh(); return; } open_tool(Tool::Pattern); }; b_pattern->Bind(wxEVT_BUTTON, [act_pattern](wxCommandEvent&) { act_pattern(); }); m_keys_feature[SHIFT('N')] = act_pattern; fadd(b_pattern); auto* b_plane = icon_btn("design_plane", _L("Plane")); std::function act_plane = [this] { populate_plane_choices(m_plane_base); // refresh base list w/ existing datum planes reset_plane_refs(); // fresh datum: no captured face/edge refs open_tool(Tool::Plane); }; b_plane->Bind(wxEVT_BUTTON, [act_plane](wxCommandEvent&) { act_plane(); }); m_keys_feature[SHIFT('P')] = act_plane; fadd(b_plane); auto* b_boolean = icon_btn("design_boolean", _L("Boolean (combine bodies)")); std::function act_boolean = [this] { // A body-body boolean needs at least two solids to combine. if (m_doc.bodies.size() < 2) { m_status->SetForegroundColour(wxColour(235, 110, 110)); m_status->SetLabel(_L("Boolean needs two bodies — create or import a second solid")); m_status->Refresh(); return; } populate_body_choices(); open_tool(Tool::Boolean); }; b_boolean->Bind(wxEVT_BUTTON, [act_boolean](wxCommandEvent&) { act_boolean(); }); m_keys_feature[SHIFT('B')] = act_boolean; fadd(b_boolean); auto* b_cut = icon_btn("design_cut", _L("Cut (split a body with a plane)")); 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)); m_status->SetLabel(_L("Create a solid body to cut first")); m_status->Refresh(); return; } populate_plane_choices(m_cut_plane); populate_body_choices(); open_tool(Tool::Cut); }; b_cut->Bind(wxEVT_BUTTON, [act_cut](wxCommandEvent&) { act_cut(); }); m_keys_feature[SHIFT('X')] = act_cut; fadd(b_cut); // 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")); b_color->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_set_body_color(); }); fadd(b_color); // Dress-up: Fillet/Chamfer / Draft / Shell feat_dropdown("design_dressup", _L("Dress-up (fillet / chamfer / draft / shell)"), { {"design_dressup", _L("Fillet / Chamfer"), _L("Round or bevel a picked edge"), [this] { open_tool(Tool::Dressup); }, SHIFT('F')}, {"design_draft", _L("Draft (taper a face)"), _L("Tilt a picked face by a draft angle"), [this] { open_tool(Tool::Draft); }, SHIFT('D')}, {"design_shell", _L("Shell"), _L("Hollow the body to a wall thickness, opening a picked face"), [this] { open_tool(Tool::Shell); }, SHIFT('K')}, }); // Hole / Thread — drilling into a solid (both face-aware) feat_dropdown("design_hole", _L("Hole / thread"), { {"design_hole", _L("Hole"), _L("Drill a hole, centred 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 // read as the offset from the face centre (editable for precise placement). m_hole_on_face = false; m_hole_face_body = -1; m_hole_has_bounds = false; // 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); } if (hf >= 0 && hb >= 0 && hb < int(m_doc.bodies.size())) { const TopoDS_Face face = GeometryEngine::face_by_index( m_doc.bodies[hb].shape, hf); if (!face.IsNull()) { m_hole_face_plane = face_plane_inward(face); m_hole_on_face = true; m_hole_face_body = hb; // Face (u,v) extents so the hole dims read from the sides (#2 Part B). m_hole_has_bounds = GeometryEngine::face_plane_bounds( face, m_hole_face_plane.origin, m_hole_face_plane.x_axis, m_hole_face_plane.y_axis, m_hole_umin, m_hole_umax, m_hole_vmin, m_hole_vmax); if (m_hole_x) m_hole_x->SetValue(0.0); // start at the face centre if (m_hole_y) m_hole_y->SetValue(0.0); // Reflect the face's orientation in the dropdown so it doesn't keep // showing a stale "XY" while the hole actually drills on this face. if (m_hole_plane) m_hole_plane->SetSelection(index_from_plane(m_hole_face_plane)); } } open_tool(Tool::Hole); }, SHIFT('H')}, {"design_thread", _L("Thread"), _L("Thread a cylindrical surface (inner bore / outer) or a circular edge"), [this] { // Driven by a picked CYLINDRICAL surface (inner bore = internal, outer = external) // OR a circular EDGE (a cylinder's rim) — axis + diameter come from the geometry, so // the user never types a radius. The diameter field shows what was derived. m_thread_on_face = false; m_thread_face_body = -1; GeometryEngine::CylinderFace cf; if (m_sel_solid_body >= 0 && m_sel_solid_body < int(m_doc.bodies.size())) { const TopoDS_Shape& shape = m_doc.bodies[m_sel_solid_body].shape; if (m_sel_solid_face >= 0) cf = GeometryEngine::cylinder_of_face(GeometryEngine::face_by_index(shape, m_sel_solid_face)); if (!cf.ok && m_sel_solid_edge >= 0) cf = GeometryEngine::circle_of_edge(GeometryEngine::edge_by_index(shape, m_sel_solid_edge)); } if (cf.ok) { SketchPlane p; // plane on the axis (origin at the base) p.origin = cf.base; p.normal = cf.axis; const Vec3d ref = std::abs(cf.axis.z()) < 0.9 ? Vec3d(0, 0, 1) : Vec3d(1, 0, 0); p.x_axis = ref.cross(cf.axis).normalized(); p.y_axis = cf.axis.cross(p.x_axis).normalized(); m_thread_face_plane = p; m_thread_on_face = true; m_thread_face_body = m_sel_solid_body; 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); 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)); m_status->SetLabel(_L("Pick a cylindrical surface (bore / outer) or a circular edge for a thread")); m_status->Refresh(); } open_tool(Tool::Thread); }, SHIFT('T')}, }); add_sep(m_tb_feature); // Text / SVG insert tools live in the SKETCH toolbar (they produce 2D profiles = // sketches), not here. STEP stays in Features: it imports a whole B-rep solid. // Import STEP — standalone: a STEP comes in as a whole editable B-rep body, not a profile. auto* b_step = icon_btn("design_step", _L("Import STEP (editable B-rep solid)")); b_step->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_import_step(); }); m_keys_feature[SHIFT('I')] = [this] { on_import_step(); }; fadd(b_step); add_sep(m_tb_feature); auto* b_constrain = icon_btn("design_constrain", _L("Constrain selected sketch")); b_constrain->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_begin_constrain(); if (m_viewport && (m_viewport->is_constraining() || m_viewport->is_constraining_entities())) set_ui_mode(UiMode::Constrain); }); fadd(b_constrain); } // --- Sketch group: plane + entity tools + Construction + Finish m_tb_sketch = new wxBoxSizer(wxHORIZONTAL); auto sadd = [this](wxWindow* w) { m_tb_sketch->Add(w, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 2); }; m_tb_sketch->Add(caption(_L("SKETCH")), 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 8); { // The plane/orientation choice lives in the docked Sketch card (Phase 3), // not in the toolbar; the toolbar carries only the drawing tools. auto skbtn = [&](const char* icon, DesignSketchTool::Mode mode, const wxString& tip, const wxString& hint) { auto* b = icon_btn(icon, tip); b->Bind(wxEVT_BUTTON, [select_tool, mode, hint](wxCommandEvent&) { select_tool(mode, hint); }); sadd(b); }; // Onshape-style family flyout, rendered with Orca's themed DropDown // (white/teal selector, #DBDBDB border, HarmonyOS Body_14) — same widget // as the settings combo dropdowns. The button shows the current variant's // icon; clicking drops the variants; a small chevron marks it as a group. struct SkVar { const char* icon; DesignSketchTool::Mode mode; wxString tip; wxString hint; }; struct ToolFlyout { std::vector items; // mainline DropDown is Item-based (text/tip/icon per row) std::vector modes; std::vector hints; std::vector icon_names; ScalableButton* btn = nullptr; DropDown drop; // declared LAST: destroyed before the vector it references ToolFlyout() : drop(items) {} }; auto dropdown = [&](const char* def_icon, const wxString& grp, std::vector vars) { auto* b = icon_btn(def_icon, grp); // messureSize() measures labels with the PARENT's font (this button) but the // popup draws them in Body_14 — so an under-sized button font truncates rows. // The button is icon-only (no label), so giving it Body_14 is invisible and // makes the content-width measure match the draw. b->SetFont(Label::Body_14); auto fo = std::make_shared(); for (auto& v : vars) { 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); fo->items.push_back(it); fo->modes.push_back(v.mode); fo->hints.push_back(v.hint); fo->icon_names.emplace_back(v.icon); } fo->btn = b; fo->drop.Create(b); fo->drop.SetUseContentWidth(true, false); fo->drop.Invalidate(true); ToolFlyout* fp = fo.get(); fo->drop.Bind(wxEVT_COMBOBOX, [this, fp, select_tool](wxCommandEvent& e) { int i = e.GetInt(); if (i >= 0 && i < (int) fp->modes.size()) { fp->btn->SetBitmap_(fp->icon_names[i]); select_tool(fp->modes[i], fp->hints[i]); set_active_tool_btn(fp->btn); } }); b->Bind(wxEVT_BUTTON, [b, fp](wxCommandEvent&) { // autoPosition()/messureSize() are private; ComboBox calls them before // Popup() so the window is sized to its content first. Without that the // popup maps at a stale narrow size and labels ellipsize ("Oblique // rectang…"). Force a fresh content measure by toggling use_content_width // (messureSize only runs when the flag actually changes), then show. fp->drop.Invalidate(true); 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.Popup(); }); m_flyout_keepalive.push_back(fo); sadd(b); auto* chev = new wxStaticText(m_toolbar, wxID_ANY, wxString::FromUTF8("\xE2\x96\xBE")); chev->SetForegroundColour(dp_sec_text()); chev->SetFont(Label::Body_9); m_tb_sketch->Add(chev, 0, wxALIGN_BOTTOM | wxBOTTOM | wxRIGHT, 5); return b; }; skbtn("design_select", DesignSketchTool::Mode::Select, _L("Select"), _L("Click to select; Shift to add; double-click for a whole loop")); skbtn("design_dimension", DesignSketchTool::Mode::Dimension, _L("Dimension"), _L("Click 2 points or a line / circle / arc to place a dimension")); add_sep(m_tb_sketch); 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")} }); 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")}, {"design_rect_oblique", DesignSketchTool::Mode::ObliqueRect, _L("Oblique rectangle"), _L("Click two corners of one edge, then a point for the width")}, {"design_rect_rounded", DesignSketchTool::Mode::RoundedRect, _L("Rounded rectangle"), _L("Click two opposite corners, then a point for the corner radius")} }); dropdown("design_circle", _L("Circle"), { {"design_circle", DesignSketchTool::Mode::CenterCircle, _L("Center circle"), _L("Click center, then radius")}, {"design_circle2pt", DesignSketchTool::Mode::TwoPointCircle, _L("2-point circle"), _L("Click two ends of the diameter")}, {"design_circle3pt", DesignSketchTool::Mode::ThreePointCircle, _L("3-point circle"), _L("Click three points on the circle")} }); dropdown("design_arc3pt", _L("Arc"), { {"design_arc3pt", DesignSketchTool::Mode::ThreePointArc, _L("3-point arc"), _L("Click start, end, then a point on the arc")}, {"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 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")); skbtn("design_point", DesignSketchTool::Mode::Point, _L("Point"), _L("Click to place a point")); add_sep(m_tb_sketch); // Insert tools — Text / SVG produce a 2D profile (a sketch), so they belong with // the sketch tools, not in the generic Features strip. Each places the art // in-canvas, then commits via the Insert card's Confirm. { auto* b_text = icon_btn("design_text", _L("Text — emboss text as a profile")); b_text->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_add_text(); }); sadd(b_text); auto* b_svg = icon_btn("design_svg", _L("SVG — import an outline as a profile")); b_svg->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_import_svg(); }); sadd(b_svg); } add_sep(m_tb_sketch); // In-canvas edit-op tools (drag gizmo / click label), grouped by family. dropdown("design_filletedge", _L("Fillet / chamfer"), { {"design_filletedge", DesignSketchTool::Mode::Fillet, _L("Fillet"), _L("Pick two lines, then drag the arrow or click the radius to set it")}, {"design_chamfer", DesignSketchTool::Mode::Chamfer, _L("Chamfer"), _L("Pick two lines, then drag the arrow or click the distance to set it")} }); skbtn("design_offset", DesignSketchTool::Mode::Offset, _L("Offset"), _L("Pick an entity, then drag the arrow or click the distance; click empty to apply")); skbtn("design_mirror", DesignSketchTool::Mode::Mirror, _L("Mirror"), _L("Pick a mirror-axis line, then the entities to mirror; click empty to apply")); // 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")); skbtn("design_extend", DesignSketchTool::Mode::Extend, _L("Extend"), _L("Click a line or arc to extend it to the nearest entity; right-click 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). auto* b_constrain_sk = icon_btn("design_constrain", _L("Constrain — add geometric/dimensional relations")); b_constrain_sk->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { enter_constrain_inline(); }); sadd(b_constrain_sk); dropdown("design_move", _L("Move / rotate / scale"), { {"design_move", DesignSketchTool::Mode::Move, _L("Move (translate)"), _L("Pick entities, then drag the handle or click the distance; click empty to apply")}, {"design_rotate", DesignSketchTool::Mode::Rotate, _L("Rotate (about centroid)"), _L("Pick entities, then drag around the pivot or click the angle; click empty to apply")}, {"design_scale", DesignSketchTool::Mode::Scale, _L("Scale (about centroid)"), _L("Pick entities, then drag the handle or click the factor; click empty to apply")} }); dropdown("design_array", _L("Linear / polar array"), { {"design_array", DesignSketchTool::Mode::Array, _L("Linear array"), _L("Pick entities, drag the spacing handle, click the count; click empty to apply")}, {"design_polararray", DesignSketchTool::Mode::PolarArray, _L("Polar array (about centroid)"), _L("Pick entities, drag the sweep handle, click the count; click empty to apply")} }); m_sides = new wxSpinCtrl(m_toolbar, wxID_ANY, "6", wxDefaultPosition, wxSize(50, -1)); m_sides->SetRange(3, 64); m_sides->SetValue(6); auto* b_poly = icon_btn("design_polygon", _L("Polygon")); b_poly->Bind(wxEVT_BUTTON, [this, select_tool](wxCommandEvent&) { if (m_viewport) { m_viewport->set_sketch_polygon_sides(m_sides->GetValue()); m_viewport->set_sketch_polygon_circumscribed(m_poly_circ && m_poly_circ->GetValue()); } select_tool(DesignSketchTool::Mode::Polygon, _L("Click center then a vertex")); }); m_sides->Bind(wxEVT_SPINCTRL, [this](wxSpinEvent&) { if (m_viewport) m_viewport->set_sketch_polygon_sides(m_sides->GetValue()); }); sadd(b_poly); sadd(m_sides); m_poly_circ = new wxCheckBox(m_toolbar, wxID_ANY, _L("Circumscribed")); m_poly_circ->SetForegroundColour(dp_ctl_text()); m_poly_circ->Bind(wxEVT_CHECKBOX, [this](wxCommandEvent&) { if (m_viewport) m_viewport->set_sketch_polygon_circumscribed(m_poly_circ->GetValue()); }); sadd(m_poly_circ); add_sep(m_tb_sketch); m_construction->Bind(wxEVT_CHECKBOX, [this](wxCommandEvent&) { if (m_viewport && m_viewport->is_sketching()) m_viewport->set_sketch_construction(m_construction->GetValue()); }); sadd(m_construction); add_sep(m_tb_sketch); auto* b_del = icon_btn("design_delete", _L("Delete selected")); b_del->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { if (m_viewport) m_viewport->delete_selected_sketch_entities(); }); sadd(b_del); // Finish sketch = the unified ✓ Confirm in the action bar (tool_confirm). } // --- Constrain group: geometric constraints + dimensions + edit ops + Done m_tb_constrain = new wxBoxSizer(wxHORIZONTAL); auto cadd = [this](wxWindow* w) { m_tb_constrain->Add(w, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 2); }; m_tb_constrain->Add(caption(_L("CONSTRAIN")), 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 8); { auto cbtn = [&](const char* icon, const wxString& tip, SketchConstraintType type) { auto* b = icon_btn(icon, tip); b->Bind(wxEVT_BUTTON, [this, type](wxCommandEvent&) { apply_constraint(type); }); cadd(b); }; cbtn("design_c_horizontal", _L("Horizontal"), SketchConstraintType::Horizontal); cbtn("design_c_vertical", _L("Vertical"), SketchConstraintType::Vertical); cbtn("design_c_parallel", _L("Parallel"), SketchConstraintType::Parallel); cbtn("design_c_perpendicular", _L("Perpendicular"), SketchConstraintType::Perpendicular); cbtn("design_c_coincident", _L("Coincident"), SketchConstraintType::Coincident); cbtn("design_c_equal", _L("Equal length"), SketchConstraintType::EqualLength); cbtn("design_c_concentric", _L("Concentric"), SketchConstraintType::Concentric); cbtn("design_c_tangent", _L("Tangent"), SketchConstraintType::Tangent); cbtn("design_c_midpoint", _L("Midpoint"), SketchConstraintType::Midpoint); cbtn("design_c_symmetric", _L("Symmetric"), SketchConstraintType::Symmetric); cbtn("design_c_angle", _L("Angle"), SketchConstraintType::Angle); cbtn("design_c_radius", _L("Radius"), SketchConstraintType::Radius); cbtn("design_c_diameter", _L("Diameter"), SketchConstraintType::Diameter); cbtn("design_c_fix", _L("Fix point (anchor in place)"), SketchConstraintType::Fix); // Trim/Extend are now standalone SKETCH scissors (Mode::Trim/Extend) in the sketch // toolbar, NOT Constrain buttons. The other edit ops (Mirror/Offset/Fillet/Chamfer/ // Move/…) are first-class sketch tools too. Done constraining = the action-bar ✓. } // Unified action bar: the ONE Confirm/Cancel surface for every tool and mode. Lives at // the right end of the ribbon (the "tool dashboard"); shown only while a tool/mode is // 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 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")); 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(52, 52), wxDefaultPosition, wxBU_EXACTFIT | wxBORDER_NONE, false, 42); 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, 2); m_tb_history->Add(m_btn_redo, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 2); } auto* tbrow = new wxBoxSizer(wxHORIZONTAL); tbrow->AddSpacer(8); 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_constrain, 0, wxALIGN_CENTER_VERTICAL | wxTOP | wxBOTTOM, 5); tbrow->AddStretchSpacer(); tbrow->Add(m_tb_action, 0, wxALIGN_CENTER_VERTICAL | wxTOP | wxBOTTOM, 5); m_toolbar->SetSizer(tbrow); // 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 = [this](const char* icon, const wxString& title, wxStaticText*& out) -> wxSizer* { auto* h = new wxBoxSizer(wxHORIZONTAL); auto* ic = new wxStaticBitmap(m_form, wxID_ANY, create_scaled_bitmap(icon, m_form, 18)); out = new wxStaticText(m_form, wxID_ANY, title); out->SetFont(Label::Head_14); // Orca shared HarmonyOS card-title font h->Add(ic, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 8); h->Add(out, 0, wxALIGN_CENTER_VERTICAL); return h; }; // --- Sketch dialog (shape definition only — no distance/mode) --- auto* form = new wxFlexGridSizer(2, 6, 8); m_shape = new wxChoice(m_form, wxID_ANY); m_shape->Append(_L("Rectangle")); m_shape->Append(_L("Circle")); m_shape->SetSelection(0); form->Add(new wxStaticText(m_form, wxID_ANY, _L("Shape")), 0, wxALIGN_CENTER_VERTICAL); form->Add(m_shape); m_plane = new wxChoice(m_form, wxID_ANY); m_plane->Append(_L("XY")); m_plane->Append(_L("XZ")); m_plane->Append(_L("YZ")); m_plane->SetSelection(0); form->Add(new wxStaticText(m_form, wxID_ANY, _L("Plane")), 0, wxALIGN_CENTER_VERTICAL); form->Add(m_plane); m_width = make_spin(m_form, 20); form->Add(new wxStaticText(m_form, wxID_ANY, _L("Width / X")), 0, wxALIGN_CENTER_VERTICAL); form->Add(m_width); m_height = make_spin(m_form, 20); form->Add(new wxStaticText(m_form, wxID_ANY, _L("Height / Y")), 0, wxALIGN_CENTER_VERTICAL); form->Add(m_height); m_radius = make_spin(m_form, 10); form->Add(new wxStaticText(m_form, wxID_ANY, _L("Radius")), 0, wxALIGN_CENTER_VERTICAL); form->Add(m_radius); m_box_sketch = new wxBoxSizer(wxVERTICAL); m_box_sketch->Add(card_header("design_sketch", _L("Sketch"), m_hdr_sketch), 0, wxLEFT | wxRIGHT | wxTOP, 12); m_box_sketch->Add(new wxStaticLine(m_form), 0, wxEXPAND | wxALL, 8); m_box_sketch->Add(form, 0, wxALL, 12); root->Add(m_box_sketch, 0, wxEXPAND); // --- Extrude dialog (consumes the selected sketch) --- m_box_extrude = new wxBoxSizer(wxVERTICAL); m_box_extrude->Add(card_header("design_extrude", _L("Extrude"), m_hdr_extrude), 0, wxLEFT | wxRIGHT | wxTOP, 12); m_box_extrude->Add(new wxStaticLine(m_form), 0, wxEXPAND | wxALL, 8); m_extrude_sketch_label = new wxStaticText(m_form, wxID_ANY, _L("Sketch: —")); m_box_extrude->Add(m_extrude_sketch_label, 0, wxLEFT | wxRIGHT | wxTOP, 12); { auto* eform = new wxFlexGridSizer(2, 6, 8); m_distance = make_spin(m_form, 10); eform->Add(new wxStaticText(m_form, wxID_ANY, _L("Extrude dist")), 0, wxALIGN_CENTER_VERTICAL); eform->Add(m_distance); // End condition (order MUST match ExtrudeEnd: Blind/Symmetric/TwoSided/ThroughAll/ // UpToFace/UpToVertex). Up-to-face uses the currently click-selected solid face. m_extrude_end = new wxChoice(m_form, wxID_ANY); for (const char* s : { "Blind", "Symmetric", "Two-sided", "Through all", "Up to face", "Up to vertex" }) m_extrude_end->Append(s); m_extrude_end->SetSelection(0); eform->Add(new wxStaticText(m_form, wxID_ANY, _L("End")), 0, wxALIGN_CENTER_VERTICAL); eform->Add(m_extrude_end); m_distance2 = make_spin(m_form, 5, 0.0, 100000.0); // second-side depth (Two-sided) eform->Add(new wxStaticText(m_form, wxID_ANY, _L("2nd dist")), 0, wxALIGN_CENTER_VERTICAL); eform->Add(m_distance2); m_taper = make_spin(m_form, 0.0, -89.0, 89.0); // draft angle (deg) eform->Add(new wxStaticText(m_form, wxID_ANY, _L("Taper °")), 0, wxALIGN_CENTER_VERTICAL); eform->Add(m_taper); m_mode = new wxChoice(m_form, wxID_ANY); // Order is load-bearing: index maps to BooleanMode (New=0, Add=1, Cut=2, Intersect=3). // Labels use Onshape wording so the choice reads as the user thinks of it. m_mode->Append(_L("New body")); // separate coexisting solid m_mode->Append(_L("Join")); // fuse into the target body (was "Add") m_mode->Append(_L("Cut")); // subtract from the target body m_mode->Append(_L("Intersect")); // keep only the overlap m_mode->SetSelection(0); eform->Add(new wxStaticText(m_form, wxID_ANY, _L("Result")), 0, wxALIGN_CENTER_VERTICAL); eform->Add(m_mode); m_flip = new wxCheckBox(m_form, wxID_ANY, _L("Flip direction")); eform->Add(new wxStaticText(m_form, wxID_ANY, wxEmptyString)); eform->Add(m_flip); m_box_extrude->Add(eform, 0, wxLEFT | wxRIGHT | wxTOP, 12); } root->Add(m_box_extrude, 0, wxEXPAND); // --- Dress-up (Fillet / Chamfer) --- auto* dform = new wxFlexGridSizer(2, 6, 8); m_dressup_type = new wxChoice(m_form, wxID_ANY); m_dressup_type->Append(_L("Fillet")); m_dressup_type->Append(_L("Chamfer")); m_dressup_type->SetSelection(0); dform->Add(new wxStaticText(m_form, wxID_ANY, _L("Dress-up")), 0, wxALIGN_CENTER_VERTICAL); dform->Add(m_dressup_type); m_face_group = new wxChoice(m_form, wxID_ANY); m_face_group->Append(_L("Top")); // index 0 -> FaceGroup::Top m_face_group->Append(_L("Bottom")); // 1 -> Bottom m_face_group->Append(_L("Lateral")); // 2 -> Lateral m_face_group->Append(_L("All")); // 3 -> All m_face_group->SetSelection(3); dform->Add(new wxStaticText(m_form, wxID_ANY, _L("Edges")), 0, wxALIGN_CENTER_VERTICAL); dform->Add(m_face_group); m_dressup_size = make_spin(m_form, 2.0); dform->Add(new wxStaticText(m_form, wxID_ANY, _L("Size (r/dist)")), 0, wxALIGN_CENTER_VERTICAL); dform->Add(m_dressup_size); m_box_dressup = new wxBoxSizer(wxVERTICAL); m_box_dressup->Add(card_header("design_dressup", _L("Fillet / Chamfer"), m_hdr_dressup), 0, wxLEFT | wxRIGHT | wxTOP, 12); m_box_dressup->Add(new wxStaticLine(m_form), 0, wxEXPAND | wxALL, 8); m_box_dressup->Add(dform, 0, wxLEFT | wxRIGHT | wxTOP, 12); root->Add(m_box_dressup, 0, wxEXPAND); // --- Hole (positioned circular cut) --- auto* hform = new wxFlexGridSizer(2, 6, 8); m_hole_plane = new wxChoice(m_form, wxID_ANY); m_hole_plane->Append(_L("XY")); m_hole_plane->Append(_L("XZ")); m_hole_plane->Append(_L("YZ")); m_hole_plane->SetSelection(0); // Picking a plane here is an explicit choice: drop any on-face hijack (a stale face pick // could keep m_hole_on_face true, so the dropdown was ignored and the hole drilled on the // face's plane instead of the chosen XY/XZ/YZ). m_hole_plane->Bind(wxEVT_CHOICE, [this](wxCommandEvent& e) { m_hole_on_face = false; m_hole_has_bounds = false; update_hole_gizmo(); refresh_preview(); e.Skip(); }); hform->Add(new wxStaticText(m_form, wxID_ANY, _L("Hole plane")), 0, wxALIGN_CENTER_VERTICAL); hform->Add(m_hole_plane); m_hole_diameter = make_spin(m_form, 6.0); hform->Add(new wxStaticText(m_form, wxID_ANY, _L("Diameter")), 0, wxALIGN_CENTER_VERTICAL); hform->Add(m_hole_diameter); m_hole_depth = make_spin(m_form, 10.0); hform->Add(new wxStaticText(m_form, wxID_ANY, _L("Depth (blind)")), 0, wxALIGN_CENTER_VERTICAL); hform->Add(m_hole_depth); m_hole_x = make_spin(m_form, 0.0, -1000.0, 1000.0); hform->Add(new wxStaticText(m_form, wxID_ANY, _L("Pos X")), 0, wxALIGN_CENTER_VERTICAL); hform->Add(m_hole_x); m_hole_y = make_spin(m_form, 0.0, -1000.0, 1000.0); hform->Add(new wxStaticText(m_form, wxID_ANY, _L("Pos Y")), 0, wxALIGN_CENTER_VERTICAL); hform->Add(m_hole_y); m_hole_through = new wxCheckBox(m_form, wxID_ANY, _L("Through")); m_hole_through->SetValue(true); hform->Add(new wxStaticText(m_form, wxID_ANY, _L("Mode")), 0, wxALIGN_CENTER_VERTICAL); hform->Add(m_hole_through); m_box_hole = new wxBoxSizer(wxVERTICAL); m_box_hole->Add(card_header("design_hole", _L("Hole"), m_hdr_hole), 0, wxLEFT | wxRIGHT | wxTOP, 12); m_box_hole->Add(new wxStaticLine(m_form), 0, wxEXPAND | wxALL, 8); m_box_hole->Add(hform, 0, wxLEFT | wxRIGHT | wxTOP, 12); root->Add(m_box_hole, 0, wxEXPAND); // --- Thread (helical) --- auto* tform = new wxFlexGridSizer(2, 6, 8); m_thread_plane = new wxChoice(m_form, wxID_ANY); m_thread_plane->Append(_L("XY")); m_thread_plane->Append(_L("XZ")); m_thread_plane->Append(_L("YZ")); m_thread_plane->SetSelection(0); tform->Add(new wxStaticText(m_form, wxID_ANY, _L("Thread plane")), 0, wxALIGN_CENTER_VERTICAL); tform->Add(m_thread_plane); // Standard designation picker — fills pitch/depth (and nominal radius) from the // ISO metric / Unified imperial tables. "Custom" leaves the manual spins alone. m_thread_std = new wxChoice(m_form, wxID_ANY); m_thread_std->Append(_L("Custom")); for (const ThreadSpec& s : thread_standards()) m_thread_std->Append(s.name); m_thread_std->SetSelection(0); m_thread_std->Bind(wxEVT_CHOICE, [this](wxCommandEvent&) { apply_thread_standard(); }); tform->Add(new wxStaticText(m_form, wxID_ANY, _L("Standard")), 0, wxALIGN_CENTER_VERTICAL); tform->Add(m_thread_std); // Threads are specified by DIAMETER (M6 = Ø6); the value is derived from the picked cylindrical // surface / circular edge, so it's a readout users rarely type. Stored field holds the diameter. m_thread_radius = make_spin(m_form, 10.0); tform->Add(new wxStaticText(m_form, wxID_ANY, _L("Diameter")), 0, wxALIGN_CENTER_VERTICAL); tform->Add(m_thread_radius); m_thread_pitch = make_spin(m_form, 2.0); tform->Add(new wxStaticText(m_form, wxID_ANY, _L("Pitch")), 0, wxALIGN_CENTER_VERTICAL); tform->Add(m_thread_pitch); m_thread_height = make_spin(m_form, 10.0); tform->Add(new wxStaticText(m_form, wxID_ANY, _L("Length")), 0, wxALIGN_CENTER_VERTICAL); tform->Add(m_thread_height); m_thread_depth = make_spin(m_form, 1.0); tform->Add(new wxStaticText(m_form, wxID_ANY, _L("Thread depth")), 0, wxALIGN_CENTER_VERTICAL); tform->Add(m_thread_depth); m_thread_x = make_spin(m_form, 0.0, -1000.0, 1000.0); tform->Add(new wxStaticText(m_form, wxID_ANY, _L("Pos X")), 0, wxALIGN_CENTER_VERTICAL); tform->Add(m_thread_x); m_thread_y = make_spin(m_form, 0.0, -1000.0, 1000.0); tform->Add(new wxStaticText(m_form, wxID_ANY, _L("Pos Y")), 0, wxALIGN_CENTER_VERTICAL); tform->Add(m_thread_y); m_thread_internal = new wxCheckBox(m_form, wxID_ANY, _L("Internal (tapped bore)")); 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. m_thread_internal->Bind(wxEVT_CHECKBOX, [this](wxCommandEvent& e) { apply_thread_standard(); e.Skip(); }); tform->Add(new wxStaticText(m_form, wxID_ANY, _L("Internal")), 0, wxALIGN_CENTER_VERTICAL); tform->Add(m_thread_internal); m_box_thread = new wxBoxSizer(wxVERTICAL); m_box_thread->Add(card_header("design_thread", _L("Thread"), m_hdr_thread), 0, wxLEFT | wxRIGHT | wxTOP, 12); m_box_thread->Add(new wxStaticLine(m_form), 0, wxEXPAND | wxALL, 8); m_box_thread->Add(tform, 0, wxLEFT | wxRIGHT | wxTOP, 12); root->Add(m_box_thread, 0, wxEXPAND); // --- Revolve (sweep a sketch profile about an in-plane axis) --- m_box_revolve = new wxBoxSizer(wxVERTICAL); m_box_revolve->Add(card_header("design_extrude", _L("Revolve"), m_hdr_revolve), 0, wxLEFT | wxRIGHT | wxTOP, 12); m_box_revolve->Add(new wxStaticLine(m_form), 0, wxEXPAND | wxALL, 8); m_revolve_sketch_label = new wxStaticText(m_form, wxID_ANY, _L("Sketch: —")); m_box_revolve->Add(m_revolve_sketch_label, 0, wxLEFT | wxRIGHT | wxTOP, 12); { auto* rform = new wxFlexGridSizer(2, 6, 8); m_revolve_angle = make_spin(m_form, 360.0, 1.0, 360.0); rform->Add(new wxStaticText(m_form, wxID_ANY, _L("Angle °")), 0, wxALIGN_CENTER_VERTICAL); rform->Add(m_revolve_angle); m_revolve_axis = new wxChoice(m_form, wxID_ANY); m_revolve_axis->Append(_L("Plane X")); m_revolve_axis->Append(_L("Plane Y")); m_revolve_axis->SetSelection(0); rform->Add(new wxStaticText(m_form, wxID_ANY, _L("Axis")), 0, wxALIGN_CENTER_VERTICAL); rform->Add(m_revolve_axis); m_revolve_mode = new wxChoice(m_form, wxID_ANY); m_revolve_mode->Append(_L("New")); m_revolve_mode->Append(_L("Add")); m_revolve_mode->Append(_L("Cut")); m_revolve_mode->Append(_L("Intersect")); m_revolve_mode->SetSelection(0); rform->Add(new wxStaticText(m_form, wxID_ANY, _L("Mode")), 0, wxALIGN_CENTER_VERTICAL); rform->Add(m_revolve_mode); m_revolve_flip = new wxCheckBox(m_form, wxID_ANY, _L("Flip direction")); rform->Add(new wxStaticText(m_form, wxID_ANY, wxEmptyString)); rform->Add(m_revolve_flip); m_box_revolve->Add(rform, 0, wxLEFT | wxRIGHT | wxTOP, 12); } root->Add(m_box_revolve, 0, wxEXPAND); // --- Sweep (sweep a profile sketch along a path sketch) --- m_box_sweep = new wxBoxSizer(wxVERTICAL); m_box_sweep->Add(card_header("design_extrude", _L("Sweep"), m_hdr_sweep), 0, wxLEFT | wxRIGHT | wxTOP, 12); m_box_sweep->Add(new wxStaticLine(m_form), 0, wxEXPAND | wxALL, 8); m_sweep_profile_label = new wxStaticText(m_form, wxID_ANY, _L("Profile: —")); m_box_sweep->Add(m_sweep_profile_label, 0, wxLEFT | wxRIGHT | wxTOP, 12); { auto* sform = new wxFlexGridSizer(2, 6, 8); m_sweep_path = new wxChoice(m_form, wxID_ANY); sform->Add(new wxStaticText(m_form, wxID_ANY, _L("Path")), 0, wxALIGN_CENTER_VERTICAL); sform->Add(m_sweep_path); m_sweep_mode = new wxChoice(m_form, wxID_ANY); m_sweep_mode->Append(_L("New")); m_sweep_mode->Append(_L("Add")); m_sweep_mode->Append(_L("Cut")); m_sweep_mode->Append(_L("Intersect")); m_sweep_mode->SetSelection(0); sform->Add(new wxStaticText(m_form, wxID_ANY, _L("Mode")), 0, wxALIGN_CENTER_VERTICAL); sform->Add(m_sweep_mode); m_box_sweep->Add(sform, 0, wxLEFT | wxRIGHT | wxTOP, 12); } root->Add(m_box_sweep, 0, wxEXPAND); // --- Pattern (replicate the target body: linear or circular) --- m_box_pattern = new wxBoxSizer(wxVERTICAL); m_box_pattern->Add(card_header("design_extrude", _L("Pattern"), m_hdr_pattern), 0, wxLEFT | wxRIGHT | wxTOP, 12); m_box_pattern->Add(new wxStaticLine(m_form), 0, wxEXPAND | wxALL, 8); { auto* pform = new wxFlexGridSizer(2, 6, 8); m_pattern_type = new wxChoice(m_form, wxID_ANY); m_pattern_type->Append(_L("Linear")); m_pattern_type->Append(_L("Circular")); m_pattern_type->SetSelection(0); pform->Add(new wxStaticText(m_form, wxID_ANY, _L("Type")), 0, wxALIGN_CENTER_VERTICAL); pform->Add(m_pattern_type); m_pattern_count = make_spin(m_form, 3, 1, 999); pform->Add(new wxStaticText(m_form, wxID_ANY, _L("Count")), 0, wxALIGN_CENTER_VERTICAL); pform->Add(m_pattern_count); m_pattern_spacing = make_spin(m_form, 20.0, 0.01, 100000.0); pform->Add(new wxStaticText(m_form, wxID_ANY, _L("Spacing")), 0, wxALIGN_CENTER_VERTICAL); pform->Add(m_pattern_spacing); m_pattern_dir = new wxChoice(m_form, wxID_ANY); m_pattern_dir->Append(_L("Plane X")); m_pattern_dir->Append(_L("Plane Y")); m_pattern_dir->SetSelection(0); pform->Add(new wxStaticText(m_form, wxID_ANY, _L("Direction")), 0, wxALIGN_CENTER_VERTICAL); pform->Add(m_pattern_dir); m_pattern_angle = make_spin(m_form, 360.0, 1.0, 360.0); pform->Add(new wxStaticText(m_form, wxID_ANY, _L("Total angle°")), 0, wxALIGN_CENTER_VERTICAL); pform->Add(m_pattern_angle); m_box_pattern->Add(pform, 0, wxLEFT | wxRIGHT | wxTOP, 12); } root->Add(m_box_pattern, 0, wxEXPAND); // --- Boolean (combine two existing bodies: union / subtract / intersect) --- m_box_boolean = new wxBoxSizer(wxVERTICAL); m_box_boolean->Add(card_header("design_boolean", _L("Boolean"), m_hdr_boolean), 0, wxLEFT | wxRIGHT | wxTOP, 12); m_box_boolean->Add(new wxStaticLine(m_form), 0, wxEXPAND | wxALL, 8); { auto* bform = new wxFlexGridSizer(2, 6, 8); m_bool_op = new wxChoice(m_form, wxID_ANY); m_bool_op->Append(_L("Union (join)")); m_bool_op->Append(_L("Subtract (cut)")); m_bool_op->Append(_L("Intersect")); m_bool_op->SetSelection(0); m_bool_op->Bind(wxEVT_CHOICE, [this](wxCommandEvent&) { refresh_preview(); }); bform->Add(new wxStaticText(m_form, wxID_ANY, _L("Operation")), 0, wxALIGN_CENTER_VERTICAL); bform->Add(m_bool_op); m_bool_target = new wxChoice(m_form, wxID_ANY); m_bool_target->Bind(wxEVT_CHOICE, [this](wxCommandEvent&) { refresh_preview(); }); bform->Add(new wxStaticText(m_form, wxID_ANY, _L("Target (kept)")), 0, wxALIGN_CENTER_VERTICAL); bform->Add(m_bool_target); m_bool_tool = new wxChoice(m_form, wxID_ANY); m_bool_tool->Bind(wxEVT_CHOICE, [this](wxCommandEvent&) { refresh_preview(); }); bform->Add(new wxStaticText(m_form, wxID_ANY, _L("Tool")), 0, wxALIGN_CENTER_VERTICAL); bform->Add(m_bool_tool); // Fuzzy tolerance (mm): the main use is a tool body cutting a destination — a small // tolerance lets near-coincident mating faces resolve into a clean cut instead of a // failed boolean or sliver faces. 0 = exact. m_bool_tol = make_spin(m_form, 0.0, 0.0, 100.0); m_bool_tol->Bind(wxEVT_SPINCTRLDOUBLE, [this](wxSpinDoubleEvent&) { refresh_preview(); }); bform->Add(new wxStaticText(m_form, wxID_ANY, _L("Tolerance")), 0, wxALIGN_CENTER_VERTICAL); bform->Add(m_bool_tol); m_box_boolean->Add(bform, 0, wxLEFT | wxRIGHT | wxTOP, 12); m_bool_keep = new wxCheckBox(m_form, wxID_ANY, _L("Keep tool body")); m_bool_keep->Bind(wxEVT_CHECKBOX, [this](wxCommandEvent&) { refresh_preview(); }); m_box_boolean->Add(m_bool_keep, 0, wxLEFT | wxRIGHT | wxTOP, 12); } root->Add(m_box_boolean, 0, wxEXPAND); // --- Cut (split a body with a plane): parameters only; ✓/✗ live on the ribbon --- m_box_cut = new wxBoxSizer(wxVERTICAL); m_box_cut->Add(card_header("design_cut", _L("Cut"), m_hdr_cut), 0, wxLEFT | wxRIGHT | wxTOP, 12); m_box_cut->Add(new wxStaticLine(m_form), 0, wxEXPAND | wxALL, 8); { auto* cform = new wxFlexGridSizer(2, 6, 8); m_cut_target = new wxChoice(m_form, wxID_ANY); m_cut_target->Bind(wxEVT_CHOICE, [this](wxCommandEvent&) { refresh_preview(); }); cform->Add(new wxStaticText(m_form, wxID_ANY, _L("Body")), 0, wxALIGN_CENTER_VERTICAL); cform->Add(m_cut_target); m_cut_plane = new wxChoice(m_form, wxID_ANY); m_cut_plane->Bind(wxEVT_CHOICE, [this](wxCommandEvent&) { refresh_preview(); }); cform->Add(new wxStaticText(m_form, wxID_ANY, _L("Plane")), 0, wxALIGN_CENTER_VERTICAL); cform->Add(m_cut_plane); m_cut_offset = make_spin(m_form, 0.0, -10000.0, 10000.0); m_cut_offset->Bind(wxEVT_SPINCTRLDOUBLE, [this](wxSpinDoubleEvent&) { refresh_preview(); }); cform->Add(new wxStaticText(m_form, wxID_ANY, _L("Offset")), 0, wxALIGN_CENTER_VERTICAL); cform->Add(m_cut_offset); m_box_cut->Add(cform, 0, wxLEFT | wxRIGHT | wxTOP, 12); // The cut always leaves BOTH pieces as separate bodies (a non-destructive split); // delete one from the tree afterwards if you only want a half. } root->Add(m_box_cut, 0, wxEXPAND); // --- Insert (Text / SVG placement): Confirm/Cancel for the in-canvas art transform --- m_box_insert = new wxBoxSizer(wxVERTICAL); m_box_insert->Add(card_header("design_text", _L("Insert"), m_hdr_insert), 0, wxLEFT | wxRIGHT | wxTOP, 12); m_box_insert->Add(new wxStaticLine(m_form), 0, wxEXPAND | wxALL, 8); m_box_insert->Add(new wxStaticText(m_form, wxID_ANY, _L("Drag a corner to size, the centre to move.\nConfirm or Cancel in the toolbar above.")), 0, wxLEFT | wxRIGHT | wxBOTTOM, 12); root->Add(m_box_insert, 0, wxEXPAND); // --- Plane (datum/reference plane: offset + tilt from a base plane; no solid) --- m_box_plane = new wxBoxSizer(wxVERTICAL); m_box_plane->Add(card_header("design_sketch", _L("Plane"), m_hdr_plane), 0, wxLEFT | wxRIGHT | wxTOP, 12); m_box_plane->Add(new wxStaticLine(m_form), 0, wxEXPAND | wxALL, 8); { // Plane type chooses which inputs matter (Onshape/Fusion parity): // Offset = Base (or Face A) + Offset (+ Tilt about a base axis) // Angle = Edge A (line) + Base/Face A reference + Angle° // Midplane = Face A + Face B (halfway between) // Tangent = Face A (a cylinder) + Angle° around its axis // Two edges = Edge A + Edge B // Coincident = Face A (lie on that face) m_plane_type = new wxChoice(m_form, wxID_ANY); for (const wxString& t : { _L("Offset"), _L("Angle"), _L("Midplane"), _L("Tangent"), _L("Two edges"), _L("Coincident") }) m_plane_type->Append(t); m_plane_type->SetSelection(0); m_box_plane->Add(new wxStaticText(m_form, wxID_ANY, _L("Plane type")), 0, wxLEFT | wxRIGHT | wxTOP, 12); m_box_plane->Add(m_plane_type, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12); auto* plform = new wxFlexGridSizer(2, 6, 8); m_plane_base = new wxChoice(m_form, wxID_ANY); populate_plane_choices(m_plane_base); // XY/XZ/YZ + any existing datum planes plform->Add(new wxStaticText(m_form, wxID_ANY, _L("Base")), 0, wxALIGN_CENTER_VERTICAL); plform->Add(m_plane_base); m_plane_offset = make_spin(m_form, 20.0, -100000.0, 100000.0); plform->Add(new wxStaticText(m_form, wxID_ANY, _L("Offset")), 0, wxALIGN_CENTER_VERTICAL); plform->Add(m_plane_offset); m_plane_tilt = make_spin(m_form, 0.0, -180.0, 180.0); plform->Add(new wxStaticText(m_form, wxID_ANY, _L("Angle°")), 0, wxALIGN_CENTER_VERTICAL); plform->Add(m_plane_tilt); m_plane_tilt_axis = new wxChoice(m_form, wxID_ANY); m_plane_tilt_axis->Append(_L("Base X")); m_plane_tilt_axis->Append(_L("Base Y")); m_plane_tilt_axis->SetSelection(0); plform->Add(new wxStaticText(m_form, wxID_ANY, _L("Tilt axis")), 0, wxALIGN_CENTER_VERTICAL); plform->Add(m_plane_tilt_axis); // 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_form, wxID_ANY, label); lbl = new wxStaticText(m_form, wxID_ANY, _L("(none)")); btn->Bind(wxEVT_BUTTON, [this, target](wxCommandEvent&) { arm_plane_pick(target); }); plform->Add(btn); plform->Add(lbl, 0, wxALIGN_CENTER_VERTICAL); }; pick_row(_L("Pick Face A"), m_plane_pick_faceA, m_plane_faceA_lbl, PlanePick::FaceA); pick_row(_L("Pick Face B"), m_plane_pick_faceB, m_plane_faceB_lbl, PlanePick::FaceB); pick_row(_L("Pick Edge A"), m_plane_pick_edgeA, m_plane_edgeA_lbl, PlanePick::EdgeA); pick_row(_L("Pick Edge B"), m_plane_pick_edgeB, m_plane_edgeB_lbl, PlanePick::EdgeB); m_plane_usize = make_spin(m_form, 60.0, 1.0, 100000.0); plform->Add(new wxStaticText(m_form, wxID_ANY, _L("Size U")), 0, wxALIGN_CENTER_VERTICAL); plform->Add(m_plane_usize); m_plane_vsize = make_spin(m_form, 60.0, 1.0, 100000.0); plform->Add(new wxStaticText(m_form, wxID_ANY, _L("Size V")), 0, wxALIGN_CENTER_VERTICAL); plform->Add(m_plane_vsize); m_box_plane->Add(plform, 0, wxLEFT | wxRIGHT | wxTOP, 12); } root->Add(m_box_plane, 0, wxEXPAND); // --- Loft (skin a solid through 2+ ordered profile sketches) --- m_box_loft = new wxBoxSizer(wxVERTICAL); m_box_loft->Add(card_header("design_extrude", _L("Loft"), m_hdr_loft), 0, wxLEFT | wxRIGHT | wxTOP, 12); m_box_loft->Add(new wxStaticLine(m_form), 0, wxEXPAND | wxALL, 8); m_box_loft->Add(new wxStaticText(m_form, wxID_ANY, _L("Profiles (check 2+, in order):")), 0, wxLEFT | wxRIGHT | wxTOP, 12); m_loft_list = new wxCheckListBox(m_form, wxID_ANY, wxDefaultPosition, 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 = new wxFlexGridSizer(2, 6, 8); m_loft_mode = new wxChoice(m_form, wxID_ANY); m_loft_mode->Append(_L("New")); m_loft_mode->Append(_L("Add")); m_loft_mode->Append(_L("Cut")); m_loft_mode->Append(_L("Intersect")); m_loft_mode->SetSelection(0); lform->Add(new wxStaticText(m_form, wxID_ANY, _L("Mode")), 0, wxALIGN_CENTER_VERTICAL); lform->Add(m_loft_mode); m_box_loft->Add(lform, 0, wxLEFT | wxRIGHT | wxTOP, 12); } m_loft_ruled = new wxCheckBox(m_form, wxID_ANY, _L("Ruled (straight) sections")); m_box_loft->Add(m_loft_ruled, 0, wxLEFT | wxRIGHT | wxTOP, 12); root->Add(m_box_loft, 0, wxEXPAND); // --- Shell (hollow the current body to a wall thickness, removing one picked face) --- auto* sform = new wxFlexGridSizer(2, 6, 8); m_shell_thickness = make_spin(m_form, 2.0, 0.01, 100000.0); sform->Add(new wxStaticText(m_form, wxID_ANY, _L("Thickness")), 0, wxALIGN_CENTER_VERTICAL); sform->Add(m_shell_thickness); m_shell_face_label = new wxStaticText(m_form, wxID_ANY, _L("(all faces — closed hollow)")); sform->Add(new wxStaticText(m_form, wxID_ANY, _L("Open face")), 0, wxALIGN_CENTER_VERTICAL); sform->Add(m_shell_face_label, 0, wxALIGN_CENTER_VERTICAL); m_box_shell = new wxBoxSizer(wxVERTICAL); m_box_shell->Add(card_header("design_dressup", _L("Shell"), m_hdr_shell), 0, wxLEFT | wxRIGHT | wxTOP, 12); m_box_shell->Add(new wxStaticLine(m_form), 0, wxEXPAND | wxALL, 8); m_box_shell->Add(new wxStaticText(m_form, wxID_ANY, _L("Pick a solid face to open it, then set the wall thickness.")), 0, wxLEFT | wxRIGHT, 12); m_box_shell->Add(sform, 0, wxLEFT | wxRIGHT | wxTOP, 12); root->Add(m_box_shell, 0, wxEXPAND); // --- Draft (taper a single picked solid face about the body bottom) --- auto* drform = new wxFlexGridSizer(2, 6, 8); m_draft_angle = make_spin(m_form, 5.0, -89.0, 89.0); drform->Add(new wxStaticText(m_form, wxID_ANY, _L("Angle (°)")), 0, wxALIGN_CENTER_VERTICAL); drform->Add(m_draft_angle); m_draft_face_label = new wxStaticText(m_form, wxID_ANY, _L("(pick a side face)")); drform->Add(new wxStaticText(m_form, wxID_ANY, _L("Face")), 0, wxALIGN_CENTER_VERTICAL); drform->Add(m_draft_face_label, 0, wxALIGN_CENTER_VERTICAL); m_box_draft = new wxBoxSizer(wxVERTICAL); m_box_draft->Add(card_header("design_dressup", _L("Draft"), m_hdr_draft), 0, wxLEFT | wxRIGHT | wxTOP, 12); m_box_draft->Add(new wxStaticLine(m_form), 0, wxEXPAND | wxALL, 8); m_box_draft->Add(new wxStaticText(m_form, wxID_ANY, _L("Pick a side face, then set the draft angle. The face pivots about the body base.")), 0, wxLEFT | wxRIGHT, 12); m_box_draft->Add(drform, 0, wxLEFT | wxRIGHT | wxTOP, 12); root->Add(m_box_draft, 0, wxEXPAND); // --- Docked value-entry card (Onshape Button->Dialog->Confirm for dimensions) --- m_box_value = new wxBoxSizer(wxVERTICAL); { // Header title doubles as the operation label (set by request_value()). m_box_value->Add(card_header("design_constrain", _L("Value"), m_value_label), 0, wxLEFT | wxRIGHT | wxTOP, 12); m_box_value->Add(new wxStaticLine(m_form), 0, wxEXPAND | wxALL, 8); auto* vrow = new wxBoxSizer(wxHORIZONTAL); // wxTE_PROCESS_ENTER so the user can just type a value and press Enter to // apply it (the natural CAD-dimension gesture), not only click Confirm. // Plain text field (not a spin control): on wxGTK the native GtkSpinButton // 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_form, wxID_ANY, "", wxDefaultPosition, wxSize(90, -1), wxTE_PROCESS_ENTER); m_value_input->Bind(wxEVT_TEXT_ENTER, [this](wxCommandEvent&) { confirm_value(); }); vrow->Add(new wxStaticText(m_form, wxID_ANY, _L("Value")), 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 8); vrow->Add(m_value_input, 0, wxALIGN_CENTER_VERTICAL); m_box_value->Add(vrow, 0, wxLEFT | wxRIGHT | wxTOP, 12); } root->Add(m_box_value, 0, wxEXPAND); // --- Sketch-entry card (Phase 3): plane/orientation, opens on "New sketch", // persists until Finish. The toolbar holds only the drawing tools. --- m_box_sketch_session = new wxBoxSizer(wxVERTICAL); m_box_sketch_session->Add(card_header("design_sketch", _L("Sketch"), m_hdr_sketch_session), 0, wxLEFT | wxRIGHT | wxTOP, 12); m_box_sketch_session->Add(new wxStaticLine(m_form), 0, wxEXPAND | wxALL, 8); { auto* prow = new wxBoxSizer(wxHORIZONTAL); prow->Add(new wxStaticText(m_form, wxID_ANY, _L("Plane")), 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 8); m_draw_plane = new wxChoice(m_form, wxID_ANY); m_draw_plane->Append(_L("XY")); m_draw_plane->Append(_L("XZ")); m_draw_plane->Append(_L("YZ")); m_draw_plane->SetSelection(0); // Live re-plane: begin_sketch captures the plane only at first-tool-pick, so changing the // dropdown afterwards used to be inert (sketch stayed on its original plane while the // committed feature would silently land on the new one). Honour the change immediately — // the 2D entities are re-lifted through the chosen plane, matching what Finish commits. m_draw_plane->Bind(wxEVT_CHOICE, [this](wxCommandEvent&) { if (m_viewport && m_viewport->is_sketching()) m_viewport->set_sketch_plane(plane_from_choice(m_draw_plane->GetSelection())); }); prow->Add(m_draw_plane, 0, wxALIGN_CENTER_VERTICAL); m_box_sketch_session->Add(prow, 0, wxLEFT | wxRIGHT | wxTOP, 12); auto* hint = new wxStaticText(m_form, wxID_ANY, _L("Pick a plane, then draw. Finish (✓) when done.")); hint->SetForegroundColour(dp_sec_text()); m_box_sketch_session->Add(hint, 0, wxLEFT | wxRIGHT | wxTOP | wxBOTTOM, 12); } root->Add(m_box_sketch_session, 0, wxEXPAND); // --- Constraint-manager card (C3.4): list of the constrained sketch's // entity-constraints; each row selects (highlights) + deletes. Shown only // in Constrain mode; rebuilt by rebuild_constraint_list(). m_box_constraints = new wxBoxSizer(wxVERTICAL); m_box_constraints->Add(card_header("design_constrain", _L("Constraints"), m_hdr_constraints), 0, wxLEFT | wxRIGHT | wxTOP, 12); m_box_constraints->Add(new wxStaticLine(m_form), 0, wxEXPAND | wxALL, 8); m_constraint_rows = new wxBoxSizer(wxVERTICAL); m_box_constraints->Add(m_constraint_rows, 0, wxEXPAND | wxLEFT | wxRIGHT | wxBOTTOM, 12); root->Add(m_box_constraints, 0, wxEXPAND); root->Add(new wxStaticText(m_form, wxID_ANY, _L("Feature tree")), 0, wxLEFT | wxTOP, 12); m_tree = new wxTreeCtrl(m_form, wxID_ANY, wxDefaultPosition, wxSize(-1, 64), wxTR_HIDE_ROOT | wxTR_SINGLE | wxTR_NO_LINES | wxTR_FULL_ROW_HIGHLIGHT | wxBORDER_SIMPLE); 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_dressup", nullptr, 16)); // 5 Shell m_tree->AssignImageList(m_tree_images); root->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&) { if (!m_viewport) return; // A Parts-list body row: highlight that body and make it the op target. const int bsel = tree_body_selection(); if (bsel >= 0) { m_viewport->set_body_highlight(false); // the per-body overlay does the tint m_viewport->select_body(bsel); m_sel_solid_body = bsel; m_sel_solid_face = m_sel_solid_edge = -1; m_status->SetForegroundColour(wxNullColour); m_status->SetLabel(wxString::Format(_L("Body %d selected — next Extrude / Fillet acts on it"), bsel + 1)); m_status->Refresh(); return; } 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); }); // Feature-tree edit row: act on the selected feature (delete / reorder). { auto* trow = new wxBoxSizer(wxHORIZONTAL); auto edit_btn = [this](const char* icon, const wxString& tip) { // Enlarged to match the main ribbon's weight (largest that fits 6 // across the ~264px form column). auto* b = new ScalableButton(m_form, wxID_ANY, icon, "", wxSize(36, 36), wxDefaultPosition, wxBU_EXACTFIT | wxBORDER_NONE, false, 30); b->SetToolTip(tip); return b; }; auto* edit = edit_btn("design_edit", _L("Edit")); edit->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_edit_feature(); }); auto* move = edit_btn("design_move", _L("Move body / Scale imported Text-SVG")); move->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { const int sel = tree_selection(); if (sel != wxNOT_FOUND && sel < int(m_doc.features.size()) && !m_doc.features[sel].imported_regions.empty()) { on_transform_imported(sel); } else if (m_sel_solid_body >= 0 && m_sel_solid_body < int(m_doc.bodies.size())) { on_move_body(); // translate the selected body with the 3-axis gizmo } else { m_status->SetForegroundColour(wxColour(235, 110, 110)); m_status->SetLabel(_L("Select a body to move it, or an imported Text/SVG to scale")); m_status->Refresh(); } }); 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&) { on_delete_feature(); }); auto* up = edit_btn("design_moveup", _L("Move up")); up->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_move_feature(-1); }); auto* down = edit_btn("design_movedown", _L("Move down")); down->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_move_feature(+1); }); trow->Add(edit, 0, wxRIGHT, 4); trow->Add(move, 0, wxRIGHT, 4); trow->Add(vis, 0, wxRIGHT, 4); trow->Add(del, 0, wxRIGHT, 4); trow->Add(up, 0, wxRIGHT, 4); trow->Add(down, 0); root->Add(trow, 0, wxLEFT | wxRIGHT | wxBOTTOM, 12); } // Prepare's "Place on Face (F)" for the selected body: pick a face, lay it flat on the bed. auto* place = new wxButton(m_form, wxID_ANY, _L("Place on Face (F)")); place->SetToolTip(_L("Select a body face (click a solid, click again to a face), then lay that face on the bed")); place->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { place_on_face(); }); root->Add(place, 0, wxLEFT | wxRIGHT | wxBOTTOM, 12); m_status = new wxStaticText(m_form, wxID_ANY, ""); root->Add(m_status, 0, wxLEFT | wxRIGHT | wxBOTTOM, 12); // DoF / constraint-state readout (P3). Dedicated line so it never clobbers the // tool hint in m_status; updated by the on_solve_state callback after each solve. m_dof_status = new wxStaticText(m_form, wxID_ANY, ""); { wxFont f = m_dof_status->GetFont(); f.SetWeight(wxFONTWEIGHT_BOLD); m_dof_status->SetFont(f); } root->Add(m_dof_status, 0, wxLEFT | wxRIGHT | wxBOTTOM, 12); // Section View — clear text button (non-destructive: hides part of the model to inspect // inside; adds a named "Section View N", never a body). Distinct from the Cut tool. auto* section_btn = new wxButton(m_form, wxID_ANY, _L("Section View")); section_btn->SetToolTip(_L("Hide part of the model to see inside (non-destructive). " "PageUp/PageDown move the plane; Delete removes it.")); section_btn->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { toggle_section_view(); }); root->Add(section_btn, 0, wxLEFT | wxRIGHT | wxTOP, 12); // Flip the active section to the opposite half — only usable while a section view is active. m_section_flip_btn = new wxButton(m_form, wxID_ANY, _L("Flip Section")); m_section_flip_btn->SetToolTip(_L("Show the opposite half of the active section view")); m_section_flip_btn->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { flip_section_view(); }); m_section_flip_btn->Enable(false); root->Add(m_section_flip_btn, 0, wxLEFT | wxRIGHT | wxTOP, 6); auto* new_design = new wxButton(m_form, wxID_ANY, _L("New Design")); new_design->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_new_design(); }); root->Add(new_design, 0, wxLEFT | wxRIGHT | wxTOP, 12); auto* commit = new wxButton(m_form, wxID_ANY, _L("Commit to Plate")); commit->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_commit(); }); root->Add(commit, 0, wxLEFT | wxRIGHT | wxTOP, 12); auto* export_step = new wxButton(m_form, wxID_ANY, _L("Export STEP…")); export_step->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_export_step(); }); root->Add(export_step, 0, wxALL, 12); m_shape->Bind(wxEVT_CHOICE, [this](wxCommandEvent& e) { on_shape_changed(); e.Skip(); }); on_shape_changed(); // Any parameter edit refreshes the translucent preview. Command events from the // spin/choice/checkbox children propagate up to m_form, so one binding each suffices. m_form->Bind(wxEVT_SPINCTRLDOUBLE, [this](wxSpinDoubleEvent& e) { refresh_preview(); e.Skip(); }); m_form->Bind(wxEVT_CHOICE, [this](wxCommandEvent& e) { refresh_preview(); e.Skip(); }); m_form->Bind(wxEVT_CHECKBOX, [this](wxCommandEvent& e) { refresh_preview(); e.Skip(); }); m_form->SetSizer(root); // Start with every tool dialog hidden (only the toolbar + tree + Commit show). root->Show(m_box_sketch, false, true); root->Show(m_box_extrude, false, true); root->Show(m_box_revolve, false, true); root->Show(m_box_sweep, false, true); root->Show(m_box_pattern, false, true); root->Show(m_box_plane, false, true); root->Show(m_box_loft, false, true); root->Show(m_box_draft, false, true); root->Show(m_box_boolean, false, true); root->Show(m_box_cut, false, true); root->Show(m_box_insert, false, true); root->Show(m_box_dressup, false, true); root->Show(m_box_hole, false, true); root->Show(m_box_thread, false, true); root->Show(m_box_shell, false, true); root->Show(m_box_value, false, true); root->Show(m_box_sketch_session, false, true); root->Show(m_box_constraints, false, true); m_form->FitInside(); m_form->SetScrollRate(10, 10); m_form->SetMinSize(wxSize(264, -1)); // Right column: a small view toolbar over the live 3D viewport that mirrors // the CadDocument body. m_viewport = new DesignCanvas(this); 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.recompute(); m_status->SetForegroundColour(wxNullColour); m_status->SetLabel(_L("Sketch created — select it and Extrude")); refresh_tree(); }); m_viewport->set_on_sketch_entities_commit( [this](const std::vector& ents, const std::vector& cons, const SketchPlane& plane) { if (ents.empty()) { m_status->SetForegroundColour(wxColour(235, 110, 110)); m_status->SetLabel(_L("Sketch empty — nothing committed")); m_status->Refresh(); return; } m_doc.checkpoint(); // undo boundary: committing / re-editing an entity sketch // Re-edit of a committed entity sketch: REPLACE it in place (keep its name + // tree position) instead of appending a duplicate. if (m_edit_index >= 0 && m_edit_index < int(m_doc.features.size()) && m_doc.features[m_edit_index].type == CadFeatureType::Sketch) { CadFeature edited = m_doc.features[m_edit_index]; edited.entities = ents; edited.entity_constraints = cons; edited.plane = plane; 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); m_status->SetLabel(_L("Sketch updated")); m_edit_index = -1; refresh_tree(); sync_sketch_display(); } return; } m_feature_counter++; const int sk = m_doc.add_sketch_entities(ents, plane, "Sketch" + std::to_string(m_feature_counter), cons); if (!cons.empty()) m_doc.solve_sketch_feature(sk); // enforce driving dimensions m_doc.recompute(); m_status->SetForegroundColour(wxNullColour); m_status->SetLabel(cons.empty() ? _L("Sketch created — select it and Extrude") : wxString::Format(_L("Sketch created (%zu driving dims) — select it and Extrude"), cons.size())); refresh_tree(); sync_sketch_display(); // keep the just-committed sketch visible as a face }); // 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); m_status->SetLabel(wxString::Format(L"L %.2f mm %.1f°%s", len, a, locked ? L" (locked)" : L"")); m_status->Refresh(); }); // DoF feedback (P3): after each live solve, report constraint state on its own // line. Green = fully constrained, red = conflicting, neutral = N remaining DoF. m_viewport->set_on_solve_state([this](int dof, bool ok, bool has_constraints) { if (!m_dof_status) return; if (!has_constraints) { m_dof_status->SetLabel(wxString()); } else if (!ok) { m_dof_status->SetForegroundColour(wxColour(235, 80, 80)); m_dof_status->SetLabel(_L("✗ Conflicting constraints")); } else if (dof == 0) { m_dof_status->SetForegroundColour(wxColour(80, 200, 110)); m_dof_status->SetLabel(_L("✓ Fully constrained")); } else if (dof > 0) { m_dof_status->SetForegroundColour(dp_ctl_text()); m_dof_status->SetLabel(wxString::Format(_L("%d degrees of freedom"), dof)); } else { m_dof_status->SetLabel(wxString()); } m_dof_status->Refresh(); m_form->Layout(); }); // Selection (Select tool): reflect the count in the status line. m_viewport->set_on_sketch_selection_changed([this](int count) { m_status->SetForegroundColour(wxNullColour); m_status->SetLabel(count > 0 ? wxString::Format(_L("%d selected — Delete removes them"), count) : _L("Click to select; click a filled face to extrude; Shift to add")); m_status->Refresh(); }); // Onshape flow: clicking inside a closed-loop face commits the sketch and opens // the Extrude dialog (with a ghost preview) targeting that sketch. m_viewport->set_on_sketch_face_selected([this]() { if (!m_viewport) return; 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)); m_status->SetLabel(_L("Could not resolve the sketch to extrude")); m_status->Refresh(); return; } set_ui_mode(UiMode::Feature); open_tool(Tool::Extrude); m_status->SetForegroundColour(wxNullColour); m_status->SetLabel(_L("Face selected — set the depth and Confirm")); m_status->Refresh(); }); // 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. m_viewport->set_on_display_sketch_selected([this](int feat, int region) { 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; set_tree_selection(feat); m_status->SetForegroundColour(wxNullColour); m_status->SetLabel(region >= 0 ? _L("Loop selected — Extrude it, or Edit / Delete the sketch") : _L("Sketch selected — Extrude it, or Edit / Delete from the tree")); m_status->Refresh(); }); // 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. m_viewport->set_on_solid_selection_changed([this](int level, int body, int face, int edge) { // 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; m_sel_solid_edge = (level == 3) ? edge : -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; } // If the Fillet/Chamfer card is open, re-anchor (or drop) the radius arrow on the new pick // and rebuild the ghost — once an edge is picked the preview-only mode hides the base body. if (m_active == Tool::Dressup) { update_fillet_gizmo(); refresh_preview(); } // If the Shell card is open, a face pick chooses the open face: update the label + gizmo // + ghost so the hollow updates live. if (m_active == Tool::Shell) { m_shell_face_label->SetLabel(m_sel_solid_face >= 0 ? wxString::Format(_L("Face %d"), m_sel_solid_face) : _L("(all faces — closed hollow)")); refresh_preview(); // rebuilds the shell ghost + re-anchors the thickness gizmo } // Draft card open: a face pick chooses the face to taper; update label + ghost live. if (m_active == Tool::Draft) { m_draft_face_label->SetLabel(m_sel_solid_face >= 0 ? wxString::Format(_L("Face %d"), m_sel_solid_face) : _L("(pick a side face)")); refresh_preview(); } // Hole card open: clicking a solid FACE re-targets the hole ONTO that face (Orca-style), // so the hole lives on the object's face — not on a stale dropdown/datum plane. Uses the // face under the cursor from the FIRST click (handle_solid_click reports it even at the // Whole level), so no whole->face cycle is needed. if (m_active == Tool::Hole && face >= 0 && body >= 0 && body < int(m_doc.bodies.size())) { const TopoDS_Face fc = GeometryEngine::face_by_index(m_doc.bodies[body].shape, face); if (!fc.IsNull()) { m_hole_face_plane = face_plane_inward(fc); m_hole_on_face = true; m_hole_face_body = body; m_hole_has_bounds = GeometryEngine::face_plane_bounds( fc, m_hole_face_plane.origin, m_hole_face_plane.x_axis, m_hole_face_plane.y_axis, m_hole_umin, m_hole_umax, m_hole_vmin, m_hole_vmax); if (m_hole_x) m_hole_x->SetValue(0.0); // centre of the picked face if (m_hole_y) m_hole_y->SetValue(0.0); if (m_hole_plane) m_hole_plane->SetSelection(index_from_plane(m_hole_face_plane)); refresh_preview(); // re-place the gizmo + ghost on the new face } } // Thread card open: clicking a cylindrical face or circular edge re-derives the thread. if (m_active == Tool::Thread && body >= 0 && body < int(m_doc.bodies.size())) { const TopoDS_Shape& shape = m_doc.bodies[body].shape; GeometryEngine::CylinderFace cf; if (face >= 0) cf = GeometryEngine::cylinder_of_face(GeometryEngine::face_by_index(shape, face)); if (!cf.ok && edge >= 0) cf = GeometryEngine::circle_of_edge(GeometryEngine::edge_by_index(shape, edge)); if (cf.ok) { SketchPlane p; p.origin = cf.base; p.normal = cf.axis; const Vec3d ref = std::abs(cf.axis.z()) < 0.9 ? Vec3d(0, 0, 1) : Vec3d(1, 0, 0); p.x_axis = ref.cross(cf.axis).normalized(); p.y_axis = cf.axis.cross(p.x_axis).normalized(); m_thread_face_plane = p; m_thread_on_face = true; m_thread_face_body = m_sel_solid_body; 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); refresh_preview(); } } // Plane tool with a pick armed: capture the right kind of reference (face for Face A/B, // edge for Edge A/B). If the click wasn't the right kind, stay armed so the user retries. if (m_active == Tool::Plane && m_plane_pick != PlanePick::None) { bool got = false; switch (m_plane_pick) { case PlanePick::FaceA: if (m_sel_solid_face >= 0) { m_pl_faceA_body = m_sel_solid_body; m_pl_faceA = m_sel_solid_face; got = true; } break; case PlanePick::FaceB: if (m_sel_solid_face >= 0) { m_pl_faceB_body = m_sel_solid_body; m_pl_faceB = m_sel_solid_face; got = true; } break; case PlanePick::EdgeA: if (m_sel_solid_edge >= 0) { m_pl_edgeA_body = m_sel_solid_body; m_pl_edgeA = m_sel_solid_edge; got = true; } break; case PlanePick::EdgeB: if (m_sel_solid_edge >= 0) { m_pl_edgeB_body = m_sel_solid_body; m_pl_edgeB = m_sel_solid_edge; got = true; } break; default: break; } if (got) { m_plane_pick = PlanePick::None; refresh_plane_labels(); } } 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(); m_status->SetLabel(level == 1 ? bodytag + _L("selected (whole) — click again for a face") : level == 2 ? bodytag + wxString::Format(_L("face %d selected — Extrude to push/pull it, or click again for an edge"), face) : level == 3 ? bodytag + wxString::Format(_L("edge %d selected — open Fillet/Chamfer to dress it, or click again to reset"), edge) : _L("Nothing selected")); m_status->Refresh(); }); // 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). m_viewport->set_on_extrude_depth_changed([this](double depth, bool second) { if (second) { if (m_distance2) m_distance2->SetValue(depth); } else { if (m_distance) m_distance->SetValue(depth); } refresh_preview(); }); // Datum-plane resize handles (C3): a handle drag reports the new u/v extent. Mirror it into the // Size spins and, when editing a committed datum, into the feature so the rendered rectangle // follows live. SetValue doesn't emit a command event, so no refresh_preview recursion. m_viewport->set_on_datum_size_changed([this](double u, double v) { if (m_plane_usize) m_plane_usize->SetValue(u); if (m_plane_vsize) m_plane_vsize->SetValue(v); if (m_edit_index >= 0 && m_edit_index < int(m_doc.features.size()) && m_doc.features[m_edit_index].type == CadFeatureType::Plane) { m_doc.features[m_edit_index].plane_u_size = u; m_doc.features[m_edit_index].plane_v_size = v; refresh_datum_planes(); // committed datum rectangle follows the drag } m_viewport->request_repaint(); }); // Offset arrow drag: mirror the new offset into the spin + (when editing) the committed feature. m_viewport->set_on_datum_offset_changed([this](double off) { if (m_plane_offset) m_plane_offset->SetValue(off); if (m_edit_index >= 0 && m_edit_index < int(m_doc.features.size()) && m_doc.features[m_edit_index].type == CadFeatureType::Plane) { m_doc.features[m_edit_index].plane_offset = off; refresh_datum_planes(); } m_viewport->request_repaint(); }); // Clicking a ghost base plane sets the base graphically (replaces the dropdown). A base pick // drops any offset-from-face choice so the picked base plane wins, then re-resolves the preview. m_viewport->set_on_datum_base_picked([this](int base) { if (m_active == Tool::Plane) { // Plane tool open: the click sets the datum's base plane (replaces the dropdown). if (m_plane_base && base >= 0 && base < int(m_plane_base->GetCount())) m_plane_base->SetSelection(base); m_pl_faceA_body = m_pl_faceA = -1; refresh_plane_labels(); refresh_preview(); // re-resolve the frame + move the gizmo/ghosts to the new base } else { // Fallback (no object yet): clicking a reference plane selects it as the sketch plane. if (m_draw_plane && base >= 0 && base < int(m_draw_plane->GetCount())) m_draw_plane->SetSelection(base); const char* nm = (base == 0) ? "XY" : (base == 1) ? "XZ" : (base == 2) ? "YZ" : "datum"; m_status->SetForegroundColour(wxColour(120, 210, 120)); m_status->SetLabel(wxString::Format(_L("%s plane selected — press Sketch to draw on it"), nm)); m_status->Refresh(); } }); // Move-body gizmo (M5): each drag/edit reports the body's new translation. Store it as a // display-only per-body transform and re-feed the moved meshes (the OCCT shape is untouched, // so face/edge ids the dress-up ops target stay valid). m_viewport->set_on_body_move_changed([this](int body, const Transform3d& xform) { sync_body_xform(); if (body < 0 || body >= int(m_body_xform.size())) return; m_body_xform[body] = xform; // full move+rotate transform, baked into the mesh at Commit feed_bodies(); // rebuilds the transformed meshes in place + refreshes display + pick 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); m_status->SetLabel(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 // Dress-up size and refreshes the ghost. SetValue is silent in wx, so refresh explicitly. m_viewport->set_on_fillet_radius_changed([this](double radius) { if (m_dressup_size) m_dressup_size->SetValue(radius); refresh_preview(); // rebuilds the candidate fillet ghost at the new radius }); // Hole gizmo: dragging/editing the centre, diameter, or depth handle writes the four Hole-card // spins and refreshes the ghost. SetValue is silent in wx, so refresh explicitly. m_viewport->set_on_hole_changed([this](double x, double y, double diameter, double depth) { if (m_hole_x) m_hole_x->SetValue(x); if (m_hole_y) m_hole_y->SetValue(y); if (m_hole_diameter) m_hole_diameter->SetValue(diameter); if (m_hole_depth) m_hole_depth->SetValue(depth); refresh_preview(); // rebuilds the candidate hole ghost at the new position/size }); // Thread gizmo: dragging/editing the centre, radius, or length handle writes the Thread-card // spins and refreshes the ghost (SetValue is silent in wx). m_viewport->set_on_thread_changed([this](double x, double y, double radius, double height) { if (m_thread_x) m_thread_x->SetValue(x); if (m_thread_y) m_thread_y->SetValue(y); if (m_thread_radius) m_thread_radius->SetValue(2.0 * radius); // gizmo reports radius; field = diameter if (m_thread_height) m_thread_height->SetValue(height); refresh_preview(); }); // Shell gizmo: dragging/editing the inward thickness arrow writes the Shell-card thickness // and refreshes the ghost (SetValue is silent in wx). m_viewport->set_on_shell_thickness_changed([this](double thickness) { if (m_shell_thickness) m_shell_thickness->SetValue(thickness); refresh_preview(); }); m_viewport->set_on_revolve_angle_changed([this](double angle) { if (m_revolve_angle) m_revolve_angle->SetValue(angle); refresh_preview(); }); m_viewport->set_on_draft_angle_changed([this](double angle) { if (m_draft_angle) m_draft_angle->SetValue(angle); refresh_preview(); }); m_viewport->set_on_cut_offset_changed([this](double v) { if (m_cut_offset) m_cut_offset->SetValue(v); refresh_preview(); }); m_viewport->set_on_pattern_changed([this](double value) { // Linear drag feeds spacing; circular drag feeds angle. The card knows which is live. if (m_pattern_type && m_pattern_type->GetSelection() == 1) { if (m_pattern_angle) m_pattern_angle->SetValue(value); } else if (m_pattern_spacing) { m_pattern_spacing->SetValue(value); } refresh_preview(); }); // Esc exits the active sketch tool: drop the live session, restore Feature mode + // the committed-sketch overlay (an in-progress draw is discarded). The tool's layered // request_exit only calls this once it's an idle Select session. m_viewport->set_on_sketch_exit([this]() { // While placing imported Text/SVG art, right-click = Confirm (keep the art) — the // Insert card is the explicit gate, this is the in-canvas shortcut to it. if (m_active == Tool::Insert) { finalize_insert(); return; } if (m_viewport) m_viewport->cancel_sketch(); m_edit_index = -1; set_ui_mode(UiMode::Feature); sync_sketch_display(); refresh_tree(); m_status->SetForegroundColour(wxNullColour); m_status->SetLabel(_L("Tool exited")); m_status->Refresh(); }); // Ctrl+Z / Ctrl+Shift+Z (Ctrl+Y) from the viewport → feature-history undo/redo. m_viewport->set_on_undo_redo([this](bool redo) { do_undo_redo(redo); }); // 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. // Sketch/Constrain keep the viewport's per-gesture Esc (abort the current point first), // so we only intercept Esc here when a feature/insert card is the thing to dismiss. Bind(wxEVT_CHAR_HOOK, [this](wxKeyEvent& e) { const int key = e.GetKeyCode(); const bool ctrl = e.ControlDown() || e.CmdDown(); const bool sketching = (m_ui_mode == UiMode::Sketch) && m_viewport && m_viewport->is_sketching(); // Never steal editing keys from a focused text field or an open in-canvas value field — // Delete/Ctrl+Z there must edit the text, not the model. const bool in_text = (dynamic_cast(wxWindow::FindFocus()) != nullptr) || (m_viewport && m_viewport->inline_busy()); const bool dismissable = m_active != Tool::None || (m_viewport && m_viewport->moving_body()); if (key == WXK_ESCAPE && dismissable) { tool_cancel(); return; } // Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y — undo/redo handled here (not only in the GL canvas) so // it works even when the canvas lost keyboard focus. In a sketch, undo drops the last entity. 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); } 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. if (!in_text && key == WXK_DELETE) { if (sketching) { m_viewport->delete_selected_or_last_sketch_entity(); return; } if (m_ui_mode == UiMode::Feature && m_active == Tool::None && tree_selection() != wxNOT_FOUND) { on_delete_feature(); return; } } // Section view controls while it is on (Alt+Wheel is unreliable under remote desktops / is // grabbed by GLCanvas3D, so the keyboard drives it): PageUp/PageDown move the plane, F flips // which half is kept (so you can inspect the opposite part). if (!in_text && !sketching && m_section_on) { if (key == WXK_PAGEUP || key == WXK_PAGEDOWN) { m_section_cut_z += (key == WXK_PAGEUP ? 2.0 : -2.0); if (m_viewport) m_viewport->set_section_plane(true, m_section_cut_z, m_section_upper); return; } if (key == 'F' || key == 'f') { flip_section_view(); return; } } // Tool shortcuts (Onshape-style). While a sketch is open, single letters drive sketch // tools; otherwise Shift+letter drives feature tools and single letters drive view // toggles / section. Ctrl-combos and focused text fields are never intercepted. if (!in_text && !ctrl) { const int up = (key >= 'a' && key <= 'z') ? key - 'a' + 'A' : key; // normalise case if (sketching) { auto it = m_keys_sketch.find(up); if (it != m_keys_sketch.end()) { it->second(); return; } } else { auto it = m_keys_feature.find(up | (e.ShiftDown() ? SC_SHIFT : 0)); if (it != m_keys_feature.end()) { it->second(); return; } } } 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; update_action_bar(); }); // The Line tool's length and the Dimension tool's value are both entered in-canvas now // (live quote labels + the floating SketchInlineEditor), so the old docked-card // callbacks (on_segment_drawn / on_dimension_pick_complete) are no longer wired. // Imported-art bbox transform streams the live offset/scale back here; write them to // the feature and re-sync the overlay so the art tracks the drag. m_viewport->set_on_imported_transform([this](int feat, Vec2d off, double sx, double sy) { if (feat < 0 || feat >= int(m_doc.features.size())) return; CadFeature& f = m_doc.features[feat]; if (f.imported_regions.empty()) return; f.import_offset = off; f.import_scale_x = sx; f.import_scale_y = sy; m_doc.recompute(); sync_sketch_display(); }); auto* vcol = new wxBoxSizer(wxVERTICAL); // 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); // 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); 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); SetSizer(outer); set_ui_mode(UiMode::Feature); } void DesignPanel::set_active_tool_btn(ScalableButton* b) { // Onshape-style: the active tool's button gets the Orca accent (teal); the // rest revert to the ribbon surface. nullptr clears the whole strip. m_active_tool_btn = b; const wxColour bg = dp_ribbon_bg(), teal(0x00, 0x96, 0x88); for (auto* btn : m_tool_btns) { if (btn == nullptr) continue; btn->SetBackgroundColour(btn == b ? teal : bg); btn->Refresh(); } } void DesignPanel::set_ui_mode(UiMode m) { m_ui_mode = m; wxSizer* s = m_toolbar->GetSizer(); s->Show(m_tb_feature, m == UiMode::Feature, true); s->Show(m_tb_sketch, m == UiMode::Sketch, true); s->Show(m_tb_constrain, m == UiMode::Constrain, true); m_toolbar->Layout(); m_toolbar->FitInside(); // refresh the horizontal scroll range for the new group widths set_active_tool_btn(nullptr); // no tool selected right after a mode switch // Phase 3: the docked Sketch card (plane/orientation) shows for the whole Sketch // session and hides on Finish/Constrain. if (m_box_sketch_session != nullptr && m_form != nullptr && m_form->GetSizer() != nullptr) { if (m == UiMode::Sketch && m_hdr_sketch_session != nullptr) m_hdr_sketch_session->SetLabel(wxString::Format(_L("Sketch %d"), m_feature_counter + 1)); m_form->GetSizer()->Show(m_box_sketch_session, m == UiMode::Sketch, true); m_form->Layout(); m_form->FitInside(); } // Constraint-manager card follows Constrain mode; rebuilt from the active feature. if (m_box_constraints != nullptr && m_form != nullptr && m_form->GetSizer() != nullptr) { if (m == UiMode::Constrain) rebuild_constraint_list(); else m_form->GetSizer()->Show(m_box_constraints, false, true); m_form->Layout(); m_form->FitInside(); } update_action_bar(); // Sketch/Constrain modes show the unified ✓/✗; Feature idle hides it } void DesignPanel::on_shape_changed() { bool rect = (m_shape->GetSelection() == 0); m_width->Enable(rect); m_height->Enable(rect); m_radius->Enable(!rect); } void DesignPanel::set_status_ok() { m_status->SetLabel(wxString::Format(_L("OK — %zu triangles"), m_doc.display_mesh.its.indices.size())); if (m_viewport != nullptr) { m_viewport->clear_move_gizmo(); // a recompute invalidates the gizmo's body centroid rebuild_disp_meshes(); // apply per-body Move transforms to the display/pick meshes // Point the solid-pick at the fresh body + TRANSFORMED pick mesh (stable address) + the // per-body xform vector (for edge sampling). Resets the whole/face/edge selection, whose // ids invalidate on every recompute. Null body is handled inside. m_viewport->set_solid_pick(&m_doc.bodies, &m_disp_pick_mesh, &m_doc.display_tri_face, &m_doc.display_tri_body, &m_body_visible, &m_body_xform); feed_bodies(); } sync_sketch_display(); } // Draw every committed sketch that no enabled Extrude consumes, so a sketch stays // visible (as a translucent face + outline) when it is not part of the solid — e.g. // after its Extrude is removed, or right after Finish. void DesignPanel::sync_sketch_display() { if (m_viewport == nullptr) return; const int n = int(m_doc.features.size()); std::vector consumed(n, false); // Per-loop extrudes (sketch_ref < 0) carry a verbatim copy of the one loop they // consumed; collect those so that loop is hidden from its source sketch overlay. std::vector> consumed_loops; for (const CadFeature& f : m_doc.features) { if (f.type != CadFeatureType::Extrude || !f.enabled) continue; if (f.sketch_ref >= 0 && f.sketch_ref < n) consumed[f.sketch_ref] = true; else if (f.sketch_ref < 0 && !f.entities.empty()) consumed_loops.push_back(f.entities); } // Two loops match when their entities are the same geometry in the same order — the // per-loop extrude stored a verbatim copy, so this is an exact comparison. auto same_loop = [](const std::vector& a, const std::vector& b) { if (a.size() != b.size() || a.empty()) return false; auto eq = [](const Vec2d& u, const Vec2d& v) { return (u - v).squaredNorm() < 1e-10; }; for (size_t k = 0; k < a.size(); ++k) { const SketchEntity& x = a[k]; const SketchEntity& y = b[k]; if (x.type != y.type || !eq(x.p0, y.p0) || !eq(x.p1, y.p1) || !eq(x.center, y.center) || std::abs(x.radius - y.radius) > 1e-7) return false; } return true; }; std::vector ds; for (int i = 0; i < n; ++i) { const CadFeature& f = m_doc.features[i]; if (f.type != CadFeatureType::Sketch || consumed[i] || !f.enabled) continue; if (!f.entities.empty()) { if (consumed_loops.empty()) { ds.push_back({ f.entities, f.plane, i }); } else { // Drop the entities of any loop already extruded; keep the rest (other // loops + non-loop entities) so they stay visible and selectable. std::vector drop(f.entities.size(), 0); for (const std::vector& loop : m_viewport->region_entity_indices(f.entities)) { std::vector es; for (int ei : loop) if (ei >= 0 && ei < int(f.entities.size())) es.push_back(f.entities[ei]); bool gone = false; for (const std::vector& c : consumed_loops) if (same_loop(es, c)) { gone = true; break; } if (gone) for (int ei : loop) if (ei >= 0 && ei < int(drop.size())) drop[ei] = 1; } std::vector shown; for (int ei = 0; ei < int(f.entities.size()); ++ei) if (!drop[ei]) shown.push_back(f.entities[ei]); if (!shown.empty()) ds.push_back({ std::move(shown), f.plane, i }); } } else if (!f.imported_regions.empty()) { // Imported art (Text/SVG) carries no solver entities; synthesize // closed line loops from each region contour so it shows as an // outline overlay (display only — never stored on the feature). // Apply the feature's placement transform so the overlay tracks // moves / scales. const auto regions = transform_regions(f.imported_regions, f.import_offset, f.import_scale_x, f.import_scale_y); std::vector lines; for (const auto& region : regions) for (const auto& contour : region) { const int m = int(contour.size()); for (int k = 0; k < m; ++k) { SketchEntity e; e.type = SketchEntity::Type::Line; e.p0 = contour[k]; e.p1 = contour[(k + 1) % m]; lines.push_back(e); } } if (!lines.empty()) ds.push_back({ std::move(lines), f.plane, i }); } } m_viewport->set_display_sketches(std::move(ds)); } void DesignPanel::on_add_text() { wxTextEntryDialog dlg(this, _L("Text to insert:"), _L("Text"), wxEmptyString); if (dlg.ShowModal() != wxID_OK) return; const wxString text = dlg.GetValue(); if (text.empty()) 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")); } void DesignPanel::on_import_svg() { wxFileDialog dlg(this, _L("Import SVG"), wxEmptyString, wxEmptyString, "SVG files (*.svg)|*.svg|All files|*.*", wxFD_OPEN | wxFD_FILE_MUST_EXIST); if (dlg.ShowModal() != wxID_OK) return; const std::string path(dlg.GetPath().ToUTF8().data()); // Import at 1:1; resize in-canvas via the bbox handles (Move/Scale). add_imported_sketch(svg_to_regions(path, 1.0), _L("SVG")); } void DesignPanel::on_import_step() { wxFileDialog dlg(this, _L("Import STEP"), wxEmptyString, wxEmptyString, "STEP files (*.step;*.stp)|*.step;*.stp|All files|*.*", wxFD_OPEN | wxFD_FILE_MUST_EXIST); if (dlg.ShowModal() != wxID_OK) return; const std::string path(dlg.GetPath().ToUTF8().data()); std::string err; // Keep the OCCT B-rep (don't mesh it like the slicer importer): each top-level solid // becomes a coexisting CadBody, fully editable by the on-face/edge feature tools. const std::vector solids = GeometryEngine::read_step_solids(path, err); if (solids.empty()) { m_status->SetForegroundColour(wxColour(235, 110, 110)); m_status->SetLabel(err.empty() ? _L("No solids found in STEP") : (_L("STEP import failed: ") + wxString::FromUTF8(err))); m_status->Refresh(); return; } m_doc.checkpoint(); // undo boundary: importing STEP solids for (const TopoDS_Shape& s : solids) { m_feature_counter++; CadFeature f; f.type = CadFeatureType::Import; f.name = std::string("STEP") + std::to_string(m_feature_counter); f.imported_solid = s; f.mode = BooleanMode::New; // each solid is its own coexisting body m_doc.features.push_back(f); } if (!m_doc.recompute()) { m_status->SetForegroundColour(wxColour(235, 110, 110)); m_status->SetLabel(_L("STEP import failed: ") + wxString::FromUTF8(m_doc.error)); m_status->Refresh(); 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); m_status->SetLabel(wxString::Format( _L("Imported %d solid(s) — pick a face or edge, then Fillet / Cut / Shell to modify"), int(solids.size()))); m_status->Refresh(); } void DesignPanel::add_imported_sketch( const std::vector>>& regions, const wxString& base_name) { if (regions.empty()) { m_status->SetForegroundColour(wxColour(235, 110, 110)); m_status->SetLabel(_L("No importable geometry found")); 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.imported_regions = regions; // #4: when a solid face is selected, drop the art ON that face, centred on it (ready to // engrave). Otherwise place it on the draw-plane dropdown at the plane origin (legacy). bool on_face = false; 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()) { f.plane = SketchPlane::from_face(face); const Vec3d cw = GeometryEngine::face_centroid_world(face); const Vec2d c_uv = f.plane.project(cw, f.plane.normal); // face centre in plane (u,v) Vec2d lo(1e30, 1e30), hi(-1e30, -1e30); // bbox of the imported art for (const auto& reg : regions) for (const auto& loop : reg) for (const Vec2d& p : loop) { lo = lo.cwiseMin(p); hi = hi.cwiseMax(p); } f.import_offset = c_uv - 0.5 * (lo + hi); // centre the art on the face f.import_on_face = true; f.import_face_body = m_sel_solid_body; on_face = true; } } if (!on_face) { if (m_draw_plane) f.plane = plane_from_choice(m_draw_plane->GetSelection()); else { f.plane = SketchPlane::XY(); f.plane.origin += m_doc.modeling_origin; } } // Drop the live face selection (its body is now remembered on import_face_body): otherwise // the next Extrude would push/pull that face instead of extruding the placed art. m_sel_solid_face = m_sel_solid_edge = m_sel_solid_body = -1; m_doc.features.push_back(f); m_doc.recompute(); // a lone sketch yields an empty body; that is expected refresh_tree(); const int newidx = int(m_doc.features.size()) - 1; set_tree_selection(newidx); // select the new art sync_sketch_display(); on_transform_imported(newidx); // in-canvas place/size gizmo ON // The feature is provisional until the user explicitly Confirms (Onshape gate). The // Insert card carries Confirm/Cancel; Cancel undoes this insert. m_insert_feat = newidx; open_insert_card(base_name); } // Show the Insert Confirm/Cancel card while the imported art is being placed/sized. void DesignPanel::open_insert_card(const wxString& base_name) { m_active = Tool::Insert; if (m_hdr_insert) m_hdr_insert->SetLabel(base_name); wxSizer* s = m_form->GetSizer(); s->Show(m_box_insert, true, true); m_form->Layout(); m_form->FitInside(); update_action_bar(); // surface the unified ✓/✗ m_status->SetForegroundColour(wxNullColour); m_status->SetLabel(base_name + _L(" — drag to place/size, then Confirm")); m_status->Refresh(); } // Confirm: keep the placed art and leave the placement gizmo. The feature is already in // the timeline (added provisionally); we just tear down the transient tool/gizmo state. void DesignPanel::finalize_insert() { const int feat = m_insert_feat; m_insert_feat = -1; if (m_viewport) m_viewport->cancel_sketch(); // exit the TransformArt gizmo close_tool(); // hides the Insert card, clears m_active set_ui_mode(UiMode::Feature); // imported art lives in the feature timeline if (feat >= 0 && feat < int(m_doc.features.size())) set_tree_selection(feat); sync_sketch_display(); refresh_tree(); set_status_ok(); } // Cancel: discard the provisional insert (undo restores the pre-insert feature list). void DesignPanel::cancel_insert() { m_insert_feat = -1; if (m_viewport) m_viewport->cancel_sketch(); // exit the TransformArt gizmo m_doc.undo(); // remove the just-added imported feature close_tool(); set_ui_mode(UiMode::Feature); sync_sketch_display(); refresh_tree(); m_status->SetForegroundColour(wxNullColour); m_status->SetLabel(_L("Insert cancelled")); m_status->Refresh(); } void DesignPanel::on_transform_imported(int feat_idx) { if (feat_idx < 0 || feat_idx >= int(m_doc.features.size()) || !m_viewport) return; const CadFeature& f = m_doc.features[feat_idx]; if (f.imported_regions.empty()) return; // In-canvas bbox handles (replaces the Move/Scale dialog): drag a corner to scale, // the centre to move. Values stream back via set_on_imported_transform. m_viewport->begin_imported_transform(feat_idx, f.imported_regions, f.plane, f.import_offset, f.import_scale_x, f.import_scale_y); m_status->SetForegroundColour(wxNullColour); m_status->SetLabel(_L("Drag a corner to scale, the centre to move — right-click when done")); m_status->Refresh(); } void DesignPanel::on_add_sketch() { SketchShape shape = (m_shape->GetSelection() == 1) ? SketchShape::Circle : SketchShape::Rectangle; SketchPlane plane = plane_from_choice(m_plane->GetSelection()); 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_doc.recompute(); // a lone sketch yields an empty body; that is expected m_status->SetForegroundColour(wxNullColour); m_status->SetLabel(_L("Sketch added — select it and Extrude")); refresh_tree(); } // Extrude should consume only the clicked loop when a specific region of the resolved // sketch is selected and that loop actually has entities. bool DesignPanel::extrude_uses_loop() const { return m_viewport != nullptr && m_sel_sketch_region >= 0 && m_extrude_sketch_ref >= 0 && m_extrude_sketch_ref == m_sel_sketch_feat && m_extrude_sketch_ref < int(m_doc.features.size()) && !m_viewport->selected_loop_entities().empty(); } 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); int idx = -1; if (m_extrude_face_src >= 0) { // Onshape face-extrude: the picked solid face is the profile (no sketch wire). idx = m_doc.add_extrude_face(m_extrude_face_src, m_distance->GetValue(), false, mode, name); m_extrude_face_src = -1; // consume the face-profile selection } else if (extrude_uses_loop()) { // Extrude just the selected loop (its entity subset), leaving the source sketch's // other loops intact and still selectable. 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 } else { idx = m_doc.add_extrude(m_extrude_sketch_ref, m_distance->GetValue(), false, mode, name); } // Carry the Onshape end-condition / taper / flip / up-to-face onto the new feature so the // committed solid matches the preview (build_candidate sets the same fields). if (idx >= 0 && idx < int(m_doc.features.size())) { CadFeature& f = m_doc.features[idx]; f.extrude_end = static_cast(m_extrude_end->GetSelection()); f.distance2 = m_distance2->GetValue(); 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) // 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()) && m_doc.features[m_extrude_sketch_ref].import_on_face) f.target_body = m_doc.features[m_extrude_sketch_ref].import_face_body; } if (!m_doc.recompute()) m_status->SetLabel(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); else set_status_ok(); refresh_tree(); } void DesignPanel::on_add_dressup() { if (m_doc.body.IsNull()) { m_status->SetLabel(_L("Add a solid (sketch + extrude) first")); return; } FaceGroup fg = static_cast(m_face_group->GetSelection()); // Top=0..All=3 double sz = m_dressup_size->GetValue(); bool fillet = (m_dressup_type->GetSelection() == 0); m_feature_counter++; // A click-selected solid edge targets THAT edge; otherwise dress the whole face-group. int didx = -1; if (m_sel_solid_edge >= 0) { if (fillet) didx = m_doc.add_fillet(sz, m_sel_solid_edge, "Fillet" + std::to_string(m_feature_counter)); else didx = m_doc.add_chamfer(sz, m_sel_solid_edge, "Chamfer" + std::to_string(m_feature_counter)); } else if (fillet) didx = m_doc.add_fillet(sz, fg, "Fillet" + std::to_string(m_feature_counter)); else didx = m_doc.add_chamfer(sz, fg, "Chamfer" + std::to_string(m_feature_counter)); // 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 (!m_doc.recompute()) m_status->SetLabel(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); else set_status_ok(); refresh_tree(); } SketchPlane DesignPanel::hole_plane() const { if (m_hole_on_face) return m_hole_face_plane; SketchPlane p = plane_from_index(m_hole_plane->GetSelection()); p.origin += m_doc.modeling_origin; return p; } void DesignPanel::on_add_hole() { if (m_doc.body.IsNull()) { m_status->SetLabel(_L("Add a solid (sketch + extrude) first")); return; } SketchPlane plane = hole_plane(); double dia = m_hole_diameter->GetValue(); double depth = m_hole_depth->GetValue(); bool through = m_hole_through->GetValue(); double px = m_hole_x->GetValue(); double py = m_hole_y->GetValue(); m_feature_counter++; const int hidx = m_doc.add_hole(dia, depth, through, px, py, plane, "Hole" + std::to_string(m_feature_counter)); // 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 (!m_doc.recompute()) m_status->SetLabel(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); else set_status_ok(); refresh_tree(); } SketchPlane DesignPanel::thread_plane() const { if (m_thread_on_face) return m_thread_face_plane; SketchPlane p = plane_from_index(m_thread_plane->GetSelection()); p.origin += m_doc.modeling_origin; return p; } void DesignPanel::apply_thread_standard() { if (!m_thread_std) return; const int sel = m_thread_std->GetSelection(); if (sel <= 0) return; // 0 = "Custom" → leave the manual spins untouched const ThreadSpec* s = find_thread_standard( m_thread_std->GetString(sel).utf8_string()); if (!s) return; // Pitch and depth are the defining "measures" of the standard — always apply. 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_status) m_status->SetLabel(wxString::Format(_L("Thread standard: %s (pitch %.3g mm)"), m_thread_std->GetString(sel), s->pitch_mm)); } void DesignPanel::infer_thread_spec(double diameter) { // 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. 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); 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_pitch) m_thread_pitch->SetValue(s.pitch_mm); if (m_thread_depth) m_thread_depth->SetValue(s.thread_depth_mm()); } void DesignPanel::on_add_thread() { bool internal = m_thread_internal->GetValue(); if (internal && m_doc.body.IsNull()) { m_status->SetLabel(_L("Thread needs a solid body — add or import one first")); return; } SketchPlane plane = thread_plane(); m_feature_counter++; 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)); // 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 (!m_doc.recompute()) m_status->SetLabel(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); else set_status_ok(); refresh_tree(); } void DesignPanel::on_add_revolve() { if (m_revolve_sketch_ref < 0 || m_revolve_sketch_ref >= int(m_doc.features.size())) { m_status->SetLabel(_L("Pick a sketch profile to revolve first")); return; } const BooleanMode mode = static_cast(m_revolve_mode->GetSelection()); if (mode != BooleanMode::New && m_doc.body.IsNull()) { m_status->SetLabel(_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)); if (!m_doc.recompute()) m_status->SetLabel(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); else set_status_ok(); refresh_tree(); } void DesignPanel::on_add_sweep() { if (m_sweep_profile_ref < 0 || m_sweep_profile_ref >= int(m_doc.features.size())) { m_status->SetLabel(_L("Pick a profile sketch to sweep first")); return; } const int sel = m_sweep_path->GetSelection(); const int path_ref = (sel != wxNOT_FOUND) ? int(reinterpret_cast(m_sweep_path->GetClientData(sel))) : -1; if (path_ref < 0) { m_status->SetLabel(_L("Pick a path sketch for the sweep")); return; } const BooleanMode mode = static_cast(m_sweep_mode->GetSelection()); if (mode != BooleanMode::New && m_doc.body.IsNull()) { m_status->SetLabel(_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)); if (!m_doc.recompute()) m_status->SetLabel(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); else set_status_ok(); refresh_tree(); } void DesignPanel::on_add_loft() { // Collect the checked profile sketches in list (recipe) order. std::vector refs; for (unsigned i = 0; i < m_loft_list->GetCount(); ++i) if (m_loft_list->IsChecked(i) && i < m_loft_sketch_idx.size()) refs.push_back(m_loft_sketch_idx[i]); if (refs.size() < 2) { m_status->SetLabel(_L("Check at least two profile sketches to loft")); return; } const BooleanMode mode = static_cast(m_loft_mode->GetSelection()); if (mode != BooleanMode::New && m_doc.body.IsNull()) { m_status->SetLabel(_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)); if (!m_doc.recompute()) m_status->SetLabel(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); else set_status_ok(); refresh_tree(); } void DesignPanel::on_add_pattern() { if (m_doc.bodies.empty()) { m_status->SetLabel(_L("Pattern needs a solid body — add or import one first")); return; } const bool circular = (m_pattern_type->GetSelection() == 1); const int target = (m_sel_solid_body >= 0 && m_sel_solid_body < int(m_doc.bodies.size())) ? m_sel_solid_body : -1; m_feature_counter++; 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)); if (!m_doc.recompute()) m_status->SetLabel(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); else set_status_ok(); refresh_tree(); } void DesignPanel::populate_body_choices(int as_of_feature) { // Re-editing a Boolean: list the bodies as they existed just before it ran, so a tool body // it consumed still shows and the saved target/tool selections round-trip. Replay a copy of // the recipe truncated to [0, as_of_feature). Fall back to the live bodies if the replay is // degenerate (e.g. fewer than the saved refs need). const std::vector* src = &m_doc.bodies; std::vector as_of; if (as_of_feature >= 0 && as_of_feature <= int(m_doc.features.size())) { CadDocument tmp = m_doc; tmp.features.resize(as_of_feature); if (tmp.recompute() && !tmp.bodies.empty()) { as_of = tmp.bodies; src = &as_of; } } auto fill = [&](wxChoice* c, int def) { if (!c) return; c->Clear(); for (size_t i = 0; i < src->size(); ++i) { const std::string& n = (*src)[i].name; c->Append(n.empty() ? wxString::Format(_L("Body %zu"), i + 1) : wxString::FromUTF8(n)); } if (c->GetCount() > 0) c->SetSelection(std::min(def, int(c->GetCount()) - 1)); // selection index == body index }; fill(m_bool_target, 0); fill(m_bool_tool, 1); // default: combine body 0 (target) with body 1 (tool) fill(m_cut_target, 0); // Cut tool: default to the first body } void DesignPanel::on_add_boolean() { if (m_doc.bodies.size() < 2) { m_status->SetLabel(_L("Boolean needs two solid bodies — add or import a second one")); return; } const int sel = m_bool_op->GetSelection(); const BooleanMode op = (sel == 1) ? BooleanMode::Cut : (sel == 2) ? BooleanMode::Intersect : BooleanMode::Add; // 0 = Union 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)); if (!m_doc.recompute()) m_status->SetLabel(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); else set_status_ok(); refresh_tree(); } void DesignPanel::on_add_cut() { if (m_doc.bodies.empty()) { m_status->SetLabel(_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)); if (!m_doc.recompute()) m_status->SetLabel(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); else set_status_ok(); refresh_tree(); } void DesignPanel::populate_plane_choices(wxChoice* c) const { if (!c) return; const int keep = c->GetSelection(); c->Clear(); c->Append(_L("XY")); c->Append(_L("XZ")); c->Append(_L("YZ")); for (const auto& dp : m_doc.resolve_datum_planes()) c->Append(wxString::FromUTF8(dp.first)); c->SetSelection((keep >= 0 && keep < int(c->GetCount())) ? keep : 0); } SketchPlane DesignPanel::plane_from_choice(int row) const { if (row < 3) { // 0=XY,1=XZ,2=YZ through the modeling origin SketchPlane p = plane_from_index(row); p.origin += m_doc.modeling_origin; return p; } // Datums are already in world coords (resolve_datum_planes applied the origin to their base). auto datums = m_doc.resolve_datum_planes(); const int di = row - 3; if (di >= 0 && di < int(datums.size())) return datums[di].second; SketchPlane p = SketchPlane::XY(); p.origin += m_doc.modeling_origin; return p; } void DesignPanel::apply_plane_refs(CadFeature& f) const { f.plane_type = (PlaneType)m_plane_type->GetSelection(); f.plane_face_body = m_pl_faceA_body; f.plane_face = m_pl_faceA; f.plane_face2_body = m_pl_faceB_body; f.plane_face2 = m_pl_faceB; f.plane_edge_body = m_pl_edgeA_body; f.plane_edge = m_pl_edgeA; f.plane_edge2_body = m_pl_edgeB_body; f.plane_edge2 = m_pl_edgeB; f.plane_u_size = m_plane_usize->GetValue(); f.plane_v_size = m_plane_vsize->GetValue(); } void DesignPanel::refresh_plane_labels() { auto txt = [](int idx) { return idx >= 0 ? wxString::Format("#%d", idx) : wxString(_L("(none)")); }; if (m_plane_faceA_lbl) m_plane_faceA_lbl->SetLabel(txt(m_pl_faceA)); if (m_plane_faceB_lbl) m_plane_faceB_lbl->SetLabel(txt(m_pl_faceB)); if (m_plane_edgeA_lbl) m_plane_edgeA_lbl->SetLabel(txt(m_pl_edgeA)); if (m_plane_edgeB_lbl) m_plane_edgeB_lbl->SetLabel(txt(m_pl_edgeB)); } void DesignPanel::reset_plane_refs() { m_pl_faceA_body = m_pl_faceA = -1; m_pl_faceB_body = m_pl_faceB = -1; m_pl_edgeA_body = m_pl_edgeA = -1; m_pl_edgeB_body = m_pl_edgeB = -1; m_plane_pick = PlanePick::None; refresh_plane_labels(); } void DesignPanel::arm_plane_pick(PlanePick target) { m_plane_pick = target; const bool face = (target == PlanePick::FaceA || target == PlanePick::FaceB); m_status->SetForegroundColour(wxNullColour); m_status->SetLabel(face ? _L("Click a solid FACE in the viewport") : _L("Click a solid EDGE in the viewport")); m_status->Refresh(); } void 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)); 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); m_status->SetLabel(_L("Plane added — pick it as a sketch plane")); refresh_tree(); } void DesignPanel::on_add_shell() { if (m_doc.body.IsNull()) { m_status->SetLabel(_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)); if (!m_doc.recompute()) m_status->SetLabel(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); else set_status_ok(); refresh_tree(); } void DesignPanel::on_add_draft() { if (m_doc.body.IsNull()) { m_status->SetLabel(_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)); m_status->SetLabel(_L("Draft needs a picked face — click a side face first")); m_status->Refresh(); 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)); if (!m_doc.recompute()) m_status->SetLabel(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error)); else set_status_ok(); refresh_tree(); } int DesignPanel::tree_icon_for(CadFeatureType t) { switch (t) { case CadFeatureType::Sketch: return 0; case CadFeatureType::Extrude: return 1; 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 } return 0; } void DesignPanel::on_tab_shown() { 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/SnapOrca_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); } } update_reference_planes(); // entering the Design tab: show the XY/XZ/YZ planes if no object yet } void DesignPanel::load_recipe(const std::string& blob) { if (blob.empty()) return; if (!m_doc.deserialize_recipe(blob)) { m_status->SetLabel(_L("Could not restore the CAD model from this project")); return; } m_feature_counter = int(m_doc.features.size()); feed_bodies(); // push the restored bodies into the viewport refresh_tree(); // rebuild the feature tree from the restored recipe set_status_ok(); } void DesignPanel::refresh_tree() { // 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). 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 for (const auto& f : m_doc.features) { const int img = tree_icon_for(f.type); wxTreeItemId id = m_tree->AppendItem(root, wxString::FromUTF8(f.name), img, img); // Hidden (disabled) features are greyed so the show/hide state reads at a glance. m_tree->SetItemTextColour(id, f.enabled ? dp_item_text() : dp_item_dim()); m_tree_items.push_back(id); } // Parts list: a Bodies group listing each independent solid. Shown only with >1 body // (a single body is just "the solid"); selecting a row highlights it + targets it. if (m_doc.bodies.size() > 1) { sync_body_visible(); // keep flags parallel before reading them for the row colour wxTreeItemId grp = m_tree->AppendItem(root, _L("Bodies")); m_tree->SetItemTextColour(grp, dp_sec_text()); for (size_t b = 0; b < m_doc.bodies.size(); ++b) { // Label "Body N" (matches the viewport/status); the originating feature name is // kept on the CadBody for tooltips/debug but isn't shown as the row label. const bool vis = b >= m_body_visible.size() || m_body_visible[b]; wxTreeItemId id = m_tree->AppendItem(grp, wxString::Format(_L("Body %zu"), b + 1)); // Hidden bodies are greyed so the show/hide state reads at a glance (eye toggle). m_tree->SetItemTextColour(id, vis ? dp_item_text() : dp_item_dim()); m_tree_body_items.push_back(id); } m_tree->Expand(grp); } 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. int rows = int(m_tree_items.size()); if (m_doc.bodies.size() > 1) rows += 1 + int(m_doc.bodies.size()); // "Bodies" header + rows 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); if (m_form && m_form->GetSizer()) { m_form->Layout(); m_form->FitInside(); } } int DesignPanel::tree_body_selection() const { const wxTreeItemId sel = m_tree->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; } void DesignPanel::update_section_flip_btn() { if (m_section_flip_btn) m_section_flip_btn->Enable(m_section_on); } 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 m_viewport->set_section_plane(true, m_section_cut_z, m_section_upper); m_status->SetLabel(_L("Section view on — hides half the model to see inside; " "PageUp / PageDown move the plane, Flip shows the other half")); } else { m_viewport->set_section_plane(false, 0.0); m_status->SetLabel(_L("Section view off")); } m_status->Refresh(); update_section_flip_btn(); } 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); m_status->SetLabel(wxString::Format(_L("Section view — showing the %s half"), m_section_upper ? _L("upper") : _L("lower"))); m_status->Refresh(); } void DesignPanel::sync_body_visible() { // Keep the visibility vector parallel to bodies; newly-created bodies default visible. // Bodies are appended in feature order, so existing indices keep their flag on resize. m_body_visible.resize(m_doc.bodies.size(), true); } void DesignPanel::sync_body_xform() { // Parallel to bodies; new bodies default to identity (no move). Stable on resize. m_body_xform.resize(m_doc.bodies.size(), Transform3d::Identity()); } // Build the display + pick meshes with each body's Move transform applied. The pick mesh is // re-merged from the transformed per-body meshes IN THE SAME body order as tessellate_bodies, // so display_tri_face/display_tri_body stay aligned. m_disp_pick_mesh keeps a stable address — // the tool holds a pointer to it, so an in-place rebuild updates picking without re-pointing. void DesignPanel::rebuild_disp_meshes() { sync_body_visible(); sync_body_xform(); const std::vector& src = m_doc.display_body_meshes; bool any = false; for (const Transform3d& t : m_body_xform) if (!t.isApprox(Transform3d::Identity())) { any = true; break; } if (!any) { // no body moved: identical to the untransformed meshes m_disp_body_meshes = src; m_disp_pick_mesh = m_doc.display_mesh; return; } m_disp_body_meshes.clear(); m_disp_body_meshes.reserve(src.size()); m_disp_pick_mesh = TriangleMesh{}; for (size_t b = 0; b < src.size(); ++b) { TriangleMesh m = src[b]; if (b < m_body_xform.size()) m.transform(m_body_xform[b]); m_disp_pick_mesh.merge(m); // same order as tessellate_bodies -> tri_* stay aligned m_disp_body_meshes.push_back(std::move(m)); } } void DesignPanel::feed_bodies() { // Rebuild the transformed meshes first so every display-refresh path (recompute, tint, // visibility, live move) shows the bodies at their current Move offsets. The solid-pick // keeps a STABLE pointer to m_disp_pick_mesh / m_body_visible / m_body_xform (rebuilt in // 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); } void DesignPanel::on_move_body() { const int b = m_sel_solid_body; if (m_viewport == nullptr || b < 0 || b >= int(m_doc.display_body_meshes.size())) return; sync_body_xform(); // Delta gizmo: pivot at the body's CURRENT world centroid; the tool composes the drag deltas // onto its current pose, so move + rotate both work (incl. on an already place-on-face'd body). const Transform3d base = m_body_xform[b]; const Vec3d pivot = base * m_doc.display_body_meshes[b].bounding_box().center(); m_viewport->begin_move_body(b, pivot, base); m_move_body = b; // for the action bar: Cancel reverts to this pose m_move_prev = base; update_action_bar(); // surface the unified ✓/✗ while moving m_status->SetForegroundColour(wxNullColour); m_status->SetLabel(_L("Drag the arrows to move, the rings to rotate — then Confirm (Esc cancels)")); m_status->Refresh(); } // Color tool: open a colour picker on the selected body and store a per-body display-colour // override on its CadBody. The override is carried across recompute() by body index and is // read back by DesignCanvas::body_color()/reload(), so the body keeps its colour through edits. void DesignPanel::on_set_body_color() { // Same body-selection source Move / visibility use: the Parts-list row first, falling // back to the in-canvas picked solid so either selection path works. 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)); m_status->SetLabel(_L("Select a body first")); m_status->Refresh(); return; } // Seed the picker with the body's current effective colour (override or auto palette). const ColorRGBA cur = (m_viewport != nullptr) ? m_viewport->body_color(b) : m_doc.bodies[b].color; wxColourData data; data.SetColour(wxColour(cur.r_uchar(), cur.g_uchar(), cur.b_uchar())); wxColourDialog dlg(this, &data); if (dlg.ShowModal() != wxID_OK) return; const wxColour picked = dlg.GetColourData().GetColour(); m_doc.bodies[b].has_color = true; m_doc.bodies[b].color = ColorRGBA((unsigned char)picked.Red(), (unsigned char)picked.Green(), (unsigned char)picked.Blue(), (unsigned char)255); feed_bodies(); // same refresh path the visibility toggle uses → viewport updates immediately m_status->SetForegroundColour(wxNullColour); m_status->SetLabel(wxString::Format(_L("Body %d colour set"), b + 1)); m_status->Refresh(); } // Prepare's "Place on Face" (F), ported to Design. Pick a body face, then this rotates the // body so that face's outward normal points straight down (-Z) and drops it onto the bed — // Orca's exact math (Selection::flattening_rotate). Writes the per-body display transform // m_body_xform (baked into the mesh at Commit), like the Move gizmo; no shape mutation. // Returns false (with a hint) when no body face is selected, so the F key can fall through. 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)); m_status->SetLabel(_L("Pick a body face first (click a solid, then click again to a face), then press F")); m_status->Refresh(); return false; } const TopoDS_Face face = GeometryEngine::face_by_index(m_doc.bodies[b].shape, m_sel_solid_face); if (face.IsNull()) return false; sync_body_xform(); const Transform3d old_x = m_body_xform[b]; // Outward face normal in the body's CURRENT displayed orientation. const Vec3d n = (old_x.linear() * GeometryEngine::face_normal_world(face)).normalized(); if (!n.allFinite() || n.norm() < 0.5) return false; // Align that normal with the down vector (-Z): the face ends up on the bed. const Transform3d R(Eigen::Quaterniond().setFromTwoVectors(n, -Vec3d::UnitZ())); // Rotate about the body's current world centroid so it spins in place, not about the origin. const Vec3d c = old_x * m_doc.display_body_meshes[b].bounding_box().center(); Transform3d x = Eigen::Translation3d(c) * R * Eigen::Translation3d(-c) * old_x; // Drop the re-oriented body so its lowest point sits on the bed (min Z -> 0). TriangleMesh probe = m_doc.display_body_meshes[b]; probe.transform(x); 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); m_status->SetLabel(_L("Placed on face — body laid flat on the bed")); m_status->Refresh(); 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; } void DesignPanel::set_tree_selection(int row) { if (row >= 0 && row < int(m_tree_items.size())) m_tree->SelectItem(m_tree_items[row]); } void DesignPanel::after_tree_edit(bool ok) { update_undo_redo_buttons(); // every commit/undo/redo funnels through here -> refresh greying refresh_tree(); if (!ok) { // The edit was rolled back (recompute failed); the body is unchanged. m_status->SetForegroundColour(wxColour(235, 110, 110)); m_status->SetLabel(_L("Edit rejected: ") + wxString::FromUTF8(m_doc.error)); m_status->Refresh(); return; } 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 m_status->SetLabel(wxString()); } else { // nde #19/20: a delete/reorder that leaves bodies behind must re-feed the per-body // GLVolumes — otherwise the viewport keeps showing the pre-edit solid (the deleted // feature's artifact lingered). feed_bodies() is idempotent for the edit/replace path. if (m_viewport != nullptr) feed_bodies(); set_status_ok(); } // Force a frame: under software GL (llvmpipe on the :10 test box) reload()'s scheduled // Refresh() is dropped, so a deleted solid stayed on screen until the next orbit. if (m_viewport != nullptr) m_viewport->request_repaint(); m_status->Refresh(); } // Erase the whole document (every feature + body) and start fresh. The single "wipe" the // feature tree's per-row Delete can't give you — also the way out when a body has no // removable owning feature. void DesignPanel::on_new_design() { if (m_doc.features.empty() && m_doc.bodies.empty()) { set_status_ok(); return; } wxMessageDialog 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; tool_cancel(); // leave any active tool / sketch / constrain cleanly m_doc.clear(); // features + bodies + meshes + history m_edit_index = -1; m_move_body = -1; m_body_xform.clear(); if (m_viewport) { m_viewport->clear_move_gizmo(); m_viewport->clear_mesh(); } after_tree_edit(true); // rebuild the (now empty) tree + clear the viewport update_action_bar(); set_status_ok(); } 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)); m_status->SetLabel(_L("Select the FEATURE that created this body (or use New Design)")); m_status->Refresh(); return; } int sel = tree_selection(); if (sel == wxNOT_FOUND) { m_status->SetLabel(_L("Select a feature in the tree first")); m_status->Refresh(); 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 after_tree_edit(m_doc.remove_feature(sel)); } void DesignPanel::on_toggle_visibility() { // A selected Body row toggles that body's visibility (per-body show/hide). The solid // stays in the document; only its GLVolume + pickability flip. Falls through to the // feature-level toggle below when a feature row (not a body row) is selected. const int bsel = tree_body_selection(); if (bsel >= 0) { sync_body_visible(); if (bsel < int(m_body_visible.size())) { const bool now_visible = !m_body_visible[bsel]; m_body_visible[bsel] = now_visible; if (m_viewport != nullptr) { feed_bodies(); // flips is_active; m_solid_visible is a stable pointer (live) m_viewport->set_solid_pick(&m_doc.bodies, &m_disp_pick_mesh, &m_doc.display_tri_face, &m_doc.display_tri_body, &m_body_visible, &m_body_xform); } refresh_tree(); if (bsel < int(m_tree_body_items.size())) // keep the row selected for repeat toggles m_tree->SelectItem(m_tree_body_items[bsel]); m_status->SetForegroundColour(wxNullColour); m_status->SetLabel(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)); m_status->SetLabel(_L("Select a feature in the tree first")); m_status->Refresh(); 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 (!m_doc.recompute()) { m_doc.body = TopoDS_Shape(); m_doc.display_mesh = TriangleMesh{}; m_doc.error.clear(); } refresh_tree(); // greys the row set_tree_selection(sel); // keep the toggled feature selected if (m_viewport != nullptr) { if (m_doc.display_mesh.its.indices.empty()) m_viewport->clear_mesh(); else feed_bodies(); } sync_sketch_display(); // skips the hidden sketch + direct-renders m_status->SetForegroundColour(wxNullColour); m_status->SetLabel(shown ? _L("Feature shown") : _L("Feature hidden")); m_status->Refresh(); } void DesignPanel::on_move_feature(int delta) { int sel = tree_selection(); if (sel == wxNOT_FOUND) { m_status->SetLabel(_L("Select a feature in the tree first")); m_status->Refresh(); 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 (m_doc.move_feature(sel, delta)) { after_tree_edit(true); set_tree_selection(target); // keep the moved feature selected } else { after_tree_edit(false); } } // Commit the live sketch in place, then enter Constrain mode on the just-committed sketch. // One-click bridge from the SKETCH toolbar: removes the "Finish -> find in tree -> select -> // Constrain" friction, so the constraint palette + Trim/Extend are reachable mid-sketch. bool DesignPanel::enter_constrain_inline() { if (m_viewport && m_viewport->is_sketching()) 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)); m_status->SetLabel(_L("Draw a sketch first, then Constrain")); m_status->Refresh(); return false; } set_tree_selection(sk); // tree drives on_begin_constrain / the constraint manager on_begin_constrain(sk); const bool entered = m_viewport && (m_viewport->is_constraining() || m_viewport->is_constraining_entities()); if (entered) set_ui_mode(UiMode::Constrain); return entered; } void DesignPanel::on_begin_constrain(int sel_override) { int sel = (sel_override >= 0) ? sel_override : tree_selection(); if (sel == wxNOT_FOUND || sel >= int(m_doc.features.size())) { m_status->SetForegroundColour(wxColour(235, 110, 110)); m_status->SetLabel(_L("Select a sketch in the tree first")); m_status->Refresh(); return; } CadFeature& f = m_doc.features[sel]; if (f.type != CadFeatureType::Sketch) { m_status->SetForegroundColour(wxColour(235, 110, 110)); m_status->SetLabel(_L("Selected feature is not a sketch")); m_status->Refresh(); return; } // Entity sketches (Fase 4.2): pick Line entities; constraints solve against // entity endpoints in the kernel. if (!f.entities.empty()) { m_constrain_feat = sel; if (m_viewport) m_viewport->begin_constrain_entities(f.entities, f.plane); m_status->SetForegroundColour(wxNullColour); m_status->SetLabel(_L("Pick 1-2 lines, then a constraint; right-click exits")); m_status->Refresh(); return; } // Legacy profile path (Fase 3). if (f.profile.points.size() < 3) { m_status->SetForegroundColour(wxColour(235, 110, 110)); m_status->SetLabel(_L("Selected feature is not a sketch")); m_status->Refresh(); return; } m_constrain_feat = sel; // Anchor the first profile point so H/V constraints don't let the sketch // float freely; fix_point captures the point's current position in the solver. 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); m_status->SetLabel(_L("Pick 1-2 entities, then a constraint or dimension; right-click exits")); m_status->Refresh(); } void DesignPanel::apply_entity_constraint(SketchConstraintType type) { using R = SketchPointRole; using T = SketchConstraintType; int e0 = -1, e1 = -1; m_viewport->selected_constrain_entities(e0, e1); auto fail = [this](const wxString& msg) { m_status->SetForegroundColour(wxColour(235, 110, 110)); m_status->SetLabel(msg); m_status->Refresh(); }; CadFeature& feat = m_doc.features[m_constrain_feat]; const bool needs_two = (type == T::Parallel || type == T::Perpendicular || type == T::EqualLength || type == T::Coincident || type == T::Concentric || type == T::Tangent || type == T::Angle || type == T::Midpoint || type == T::Symmetric); if (e0 < 0 || e0 >= int(feat.entities.size()) || (needs_two && (e1 < 0 || e1 >= int(feat.entities.size())))) { fail(needs_two ? _L("Pick two entities first") : _L("Pick an entity first")); return; } auto is_round = [](const SketchEntity& e) { return e.type == SketchEntity::Type::Circle || e.type == SketchEntity::Type::Arc; }; SketchEntityConstraintDef def; def.type = type; def.value = 0.0; switch (type) { case T::Horizontal: case T::Vertical: // One line: level/plumb its own two endpoints. def.ea = e0; def.ra = R::P0; def.eb = e0; def.rb = R::P1; break; case T::Parallel: case T::Perpendicular: case T::EqualLength: def.ea = e0; def.eb = e1; // two whole line segments (roles unused) break; case T::Coincident: { // Join the closest endpoint pair of the two picked lines. const SketchEntity& A = feat.entities[e0]; const SketchEntity& B = feat.entities[e1]; const std::pair aps[2] = {{R::P0, A.p0}, {R::P1, A.p1}}; const std::pair bps[2] = {{R::P0, B.p0}, {R::P1, B.p1}}; R ra = R::P1, rb = R::P0; double best = 1e30; for (const auto& ap : aps) for (const auto& bp : bps) { const double d = (ap.second - bp.second).squaredNorm(); if (d < best) { best = d; ra = ap.first; rb = bp.first; } } def.ea = e0; def.ra = ra; def.eb = e1; def.rb = rb; break; } case T::Concentric: { // Two circles/arcs: make their centres coincide. if (!is_round(feat.entities[e0]) || !is_round(feat.entities[e1])) { fail(_L("Concentric needs two circles or arcs")); return; } def.ea = e0; def.ra = R::Center; def.eb = e1; def.rb = R::Center; break; } case T::Tangent: { // line+round or round+round; the kernel detects the entity types. const bool ok = (is_round(feat.entities[e0]) && feat.entities[e1].type == SketchEntity::Type::Line) || (is_round(feat.entities[e1]) && feat.entities[e0].type == SketchEntity::Type::Line) || (is_round(feat.entities[e0]) && is_round(feat.entities[e1])); if (!ok) { fail(_L("Tangent needs a line and a circle/arc, or two circles/arcs")); return; } def.ea = e0; def.eb = e1; break; } case T::Angle: { // Angle between two line segments; typed in-canvas at the cursor (no card), // pre-filled with the current angle between the picked lines. const int a = e0, b = e1; const Vec2d da = feat.entities[a].p1 - feat.entities[a].p0; const Vec2d db = feat.entities[b].p1 - feat.entities[b].p0; double cur = 90.0; const double na = da.norm(), nb = db.norm(); if (na > 1e-9 && nb > 1e-9) { const double c = std::max(-1.0, std::min(1.0, da.dot(db) / (na * nb))); cur = std::acos(c) * 180.0 / M_PI; } m_viewport->open_inline_value(cur, [this, a, b](double deg) { SketchEntityConstraintDef d; d.type = T::Angle; d.ea = a; d.eb = b; d.value = deg * M_PI / 180.0; commit_entity_constraint(d); }); return; // deferred: commit runs on the typed value } case T::Midpoint: { // One pick is a Point, the other a Line: the point is the line's midpoint. const SketchEntity& A = feat.entities[e0]; const SketchEntity& B = feat.entities[e1]; int pt = -1, ln = -1; if (A.type == SketchEntity::Type::Point && B.type == SketchEntity::Type::Line) { pt = e0; ln = e1; } else if (B.type == SketchEntity::Type::Point && A.type == SketchEntity::Type::Line) { pt = e1; ln = e0; } else { fail(_L("Midpoint needs a point and a line")); return; } def.ea = pt; def.ra = R::P0; def.eb = ln; break; } case T::Symmetric: { // Two entities made symmetric about a third (axis) line. Picks: slot0=A, // slot1=B, slot2=axis. Two Points -> one pair; two Lines -> endpoint pairs. using ET = SketchEntity::Type; const int axis = m_viewport->selected_constrain_axis(); if (axis < 0 || axis >= int(feat.entities.size()) || feat.entities[axis].type != ET::Line) { fail(_L("Symmetric: pick two entities, then an axis line")); return; } const ET ta = feat.entities[e0].type, tb = feat.entities[e1].type; std::vector defs; auto mk = [&](R ra, R rb) { SketchEntityConstraintDef d; d.type = T::Symmetric; d.ea = e0; d.ra = ra; d.eb = e1; d.rb = rb; d.ec = axis; defs.push_back(d); }; if (ta == ET::Point && tb == ET::Point) { mk(R::P0, R::P0); } else if (ta == ET::Line && tb == ET::Line) { mk(R::P0, R::P0); mk(R::P1, R::P1); } else { fail(_L("Symmetric needs two points or two lines + an axis")); return; } commit_entity_constraints(defs); return; // multi-def commit done here } case T::Fix: { // Anchor the picked entity's reference point to its current coordinate (the // kernel pins it to a fixed reference). A single point — not both endpoints — // so it composes with any existing Horizontal/Vertical/length constraint // instead of duplicating it (pinning both endpoints of an already-horizontal // line is redundant → over-constrained). Removes 2 DoF (the entity's position); // combine with H/V + a dimension to reach fully constrained. using ET = SketchEntity::Type; const ET et = feat.entities[e0].type; def.ea = e0; def.ra = (et == ET::Circle || et == ET::Ellipse || et == ET::Arc || et == ET::EllipseArc) ? R::Center : R::P0; break; } case T::Radius: case T::Diameter: { const SketchEntity& A = feat.entities[e0]; if (!is_round(A)) { fail(_L("Radius/Diameter needs a circle or arc")); return; } const double cur = (type == T::Diameter) ? 2.0 * A.radius : A.radius; const int a = e0; const T tt = type; // Typed in-canvas at the cursor (no docked card), pre-filled with the current value. m_viewport->open_inline_value(cur, [this, a, tt](double v) { SketchEntityConstraintDef d; d.type = tt; d.ea = a; d.ra = R::Center; d.value = v; commit_entity_constraint(d); }); return; // deferred: commit runs on the typed value } default: fail(_L("Unsupported constraint")); return; } commit_entity_constraint(def); } void DesignPanel::commit_entity_constraint(const SketchEntityConstraintDef& def) { commit_entity_constraints({ def }); } void DesignPanel::commit_entity_constraints(const std::vector& defs) { if (m_constrain_feat < 0 || m_constrain_feat >= int(m_doc.features.size()) || !m_viewport || defs.empty()) return; CadFeature& feat = m_doc.features[m_constrain_feat]; // solve_sketch_feature rewrites entity coords even on failure, so snapshot // to roll back a rejected (over-constrained) addition cleanly. Multiple defs // (Symmetric on two lines) must solve together, so push all then resize back. const std::vector saved = feat.entities; const size_t before = feat.entity_constraints.size(); for (const auto& d : defs) feat.entity_constraints.push_back(d); if (!m_doc.solve_sketch_feature(m_constrain_feat)) { feat.entity_constraints.resize(before); feat.entities = saved; m_status->SetForegroundColour(wxColour(235, 110, 110)); m_status->SetLabel(_L("Constraint rejected (over-constrained)")); m_status->Refresh(); return; } m_doc.recompute(); 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); m_status->SetLabel(_L("Applied constraint")); m_status->Refresh(); refresh_constrain_dof(); // P3 DoF readout for the Constrain path rebuild_constraint_list(); // C3.4 manager: a row appeared } // Re-derive the solve state of the constrained feature and mirror it into the same // DoF readout the in-session path uses, so "✓ Fully constrained" is reachable here. void DesignPanel::refresh_constrain_dof() { if (!m_dof_status || m_constrain_feat < 0 || m_constrain_feat >= int(m_doc.features.size())) return; const CadFeature& feat = m_doc.features[m_constrain_feat]; std::vector ents = feat.entities; // already solved; re-solve is a cheap no-op const SketchSolveResult r = sketch_solve(ents, feat.entity_constraints); if (!r.ok) { m_dof_status->SetForegroundColour(wxColour(235, 80, 80)); m_dof_status->SetLabel(_L("✗ Conflicting constraints")); } else if (r.dof == 0) { m_dof_status->SetForegroundColour(wxColour(80, 200, 110)); m_dof_status->SetLabel(_L("✓ Fully constrained")); } else if (r.dof > 0) { m_dof_status->SetForegroundColour(dp_ctl_text()); m_dof_status->SetLabel(wxString::Format(_L("%d degrees of freedom"), r.dof)); } else { m_dof_status->SetLabel(wxString()); } m_dof_status->Refresh(); m_form->Layout(); } // Human-readable label for a constraint row, e.g. "Coincident L0·P1 — L1·P0", // "Horizontal L2", "Radius C3 = 7.00". Entities are tagged by type letter + index. wxString DesignPanel::constraint_label(const SketchEntityConstraintDef& d) const { using T = SketchConstraintType; const CadFeature* feat = (m_constrain_feat >= 0 && m_constrain_feat < int(m_doc.features.size())) ? &m_doc.features[m_constrain_feat] : nullptr; auto tag = [&](int ei, SketchPointRole r) -> wxString { if (ei < 0) return wxString(); char c = 'E'; if (feat && ei < int(feat->entities.size())) { switch (feat->entities[ei].type) { case SketchEntity::Type::Line: c = 'L'; break; case SketchEntity::Type::Circle: c = 'C'; break; case SketchEntity::Type::Arc: c = 'A'; break; case SketchEntity::Type::Point: c = 'P'; break; case SketchEntity::Type::Ellipse: case SketchEntity::Type::EllipseArc: c = 'E'; break; case SketchEntity::Type::BSpline: c = 'B'; break; } } 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"; 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)) : wxString::Format("%s %s", name, tag(d.ea, d.ra)); }; switch (d.type) { case T::Fix: return wxString::Format(_L("Fix %s"), tag(d.ea, d.ra)); case T::Coincident: return two(_L("Coincident")); case T::Horizontal: return two(_L("Horizontal")); case T::Vertical: return two(_L("Vertical")); case T::Distance: return wxString::Format("%s = %s", two(_L("Distance")), en_format(d.value)); case T::LockX: return wxString::Format(_L("Lock X %s"), tag(d.ea, d.ra)); case T::LockY: return wxString::Format(_L("Lock Y %s"), tag(d.ea, d.ra)); case T::EqualLength: return two(_L("Equal")); case T::Parallel: return two(_L("Parallel")); case T::Perpendicular: return two(_L("Perpendicular")); case T::Concentric: return two(_L("Concentric")); case T::Tangent: return two(_L("Tangent")); case T::Midpoint: return two(_L("Midpoint")); case T::Symmetric: return wxString::Format(_L("Symmetric %s — %s / %s"), tag(d.ea, d.ra), tag(d.eb, d.rb), tag(d.ec, d.rc)); case T::Angle: return wxString::Format("%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")); case T::PointOnObject: return two(_L("On edge")); } return _L("Constraint"); } // Rebuild the constraint-row list from the constrained feature's entity_constraints. void DesignPanel::rebuild_constraint_list() { if (m_constraint_rows == nullptr || m_form == nullptr) return; m_constraint_rows->Clear(true /* delete windows */); m_constraint_sel = -1; const bool active = (m_constrain_feat >= 0 && m_constrain_feat < int(m_doc.features.size())); const std::vector empty; const std::vector& cons = active ? m_doc.features[m_constrain_feat].entity_constraints : empty; if (m_hdr_constraints) m_hdr_constraints->SetLabel(wxString::Format(_L("Constraints (%d)"), int(cons.size()))); if (cons.empty()) { auto* none = new wxStaticText(m_form, wxID_ANY, _L("No constraints yet")); none->SetForegroundColour(dp_sec_text()); m_constraint_rows->Add(none, 0, wxTOP, 4); } 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_form, wxID_ANY, wxString::FromUTF8("✗"), wxDefaultPosition, wxSize(26, -1)); del->SetToolTip(_L("Delete constraint")); del->Bind(wxEVT_BUTTON, [this, i](wxCommandEvent&) { delete_constraint(i); }); // Clickable label: selecting it highlights the referenced entities. auto* lbl = new wxButton(m_form, wxID_ANY, constraint_label(cons[i]), wxDefaultPosition, wxDefaultSize, wxBU_LEFT | wxBORDER_NONE); lbl->Bind(wxEVT_BUTTON, [this, i](wxCommandEvent&) { 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); } // Feed the same list to the viewport for the on-sketch glyph badges (C3.4b). if (m_viewport) m_viewport->set_constraint_glyphs(cons); m_form->GetSizer()->Show(m_box_constraints, m_ui_mode == UiMode::Constrain, true); m_form->Layout(); m_form->FitInside(); } // Push the entities referenced by constraint `idx` to the viewport as a yellow // highlight (toggle off if the same row is clicked again). void DesignPanel::highlight_constraint_entities(int idx) { if (!m_viewport || m_constrain_feat < 0 || m_constrain_feat >= int(m_doc.features.size())) return; const auto& cons = m_doc.features[m_constrain_feat].entity_constraints; if (idx < 0 || idx >= int(cons.size())) return; if (m_constraint_sel == idx) { // second click clears m_constraint_sel = -1; m_viewport->set_constraint_highlight({}); return; } m_constraint_sel = idx; const SketchEntityConstraintDef& d = cons[idx]; std::vector ents; for (int e : { d.ea, d.eb, d.ec }) if (e >= 0) ents.push_back(e); m_viewport->set_constraint_highlight(std::move(ents)); } // Drop constraint `idx`, re-solve the feature, and refresh viewport + list + DoF. void DesignPanel::delete_constraint(int idx) { if (m_constrain_feat < 0 || m_constrain_feat >= int(m_doc.features.size()) || !m_viewport) return; CadFeature& feat = m_doc.features[m_constrain_feat]; if (idx < 0 || idx >= int(feat.entity_constraints.size())) return; feat.entity_constraints.erase(feat.entity_constraints.begin() + idx); // Re-solve the remaining system (deleting a constraint can only free DoF, so it // cannot fail for over-constraint; ignore the bool and refresh either way). m_doc.solve_sketch_feature(m_constrain_feat); m_doc.recompute(); m_viewport->set_constraint_highlight({}); 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); m_status->SetLabel(_L("Constraint deleted")); m_status->Refresh(); refresh_constrain_dof(); rebuild_constraint_list(); } 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)); m_status->SetLabel(_L("Press Constrain on a sketch first")); m_status->Refresh(); return; } auto fail = [this](const wxString& msg) { m_status->SetForegroundColour(wxColour(235, 110, 110)); m_status->SetLabel(msg); m_status->Refresh(); }; int e0 = -1, e1 = -1; m_viewport->selected_constrain_entities(e0, e1); CadFeature& feat = m_doc.features[m_constrain_feat]; const int n = int(feat.entities.size()); if (e0 < 0 || e0 >= n) { fail(_L("Pick an entity first")); return; } using Type = SketchEntity::Type; switch (op) { case EditOp::Mirror: { if (e1 < 0 || e1 >= n) { fail(_L("Pick the entity, then a mirror-axis line")); return; } const SketchEntity& axis = feat.entities[e1]; if (axis.type != Type::Line) { fail(_L("Mirror axis must be a line")); return; } auto out = SketchEngine::mirror_entities({ feat.entities[e0] }, axis.p0, axis.p1); if (out.empty()) { fail(_L("Mirror produced nothing")); return; } const int mi = n; // index the single mirrored copy lands at (n entities before push) for (auto& m : out) feat.entities.push_back(m); // C4a: bind the mirror to its source with Symmetric constraints about the // axis, so the pair stays mirror-symmetric under later solves and drags. // mirror_entities preserves P0/P1/Center ordering, so the constraints are // satisfied by construction; if the solver still rejects them (degenerate // axis, redundancy) keep the geometry and drop only the binding. { using R = SketchPointRole; using CT = SketchConstraintType; const std::vector saved_ents = feat.entities; const size_t cons_before = feat.entity_constraints.size(); SketchEntityConstraintDef d; d.type = CT::Symmetric; d.ea = e0; d.eb = mi; d.ec = e1; const Type st = feat.entities[e0].type; if (st == Type::Line) { d.ra = R::P0; d.rb = R::P0; feat.entity_constraints.push_back(d); d.ra = R::P1; d.rb = R::P1; feat.entity_constraints.push_back(d); } else if (st == Type::Arc || st == Type::Circle) { d.ra = R::Center; d.rb = R::Center; feat.entity_constraints.push_back(d); } else if (st == Type::Point) { d.ra = R::P0; d.rb = R::P0; feat.entity_constraints.push_back(d); } if (feat.entity_constraints.size() != cons_before && !m_doc.solve_sketch_feature(m_constrain_feat)) { feat.entity_constraints.resize(cons_before); feat.entities = saved_ents; } } break; } case EditOp::Offset: { const int a = e0; request_value(_L("Offset distance (+left / -right of direction)"), 1.0, -100000.0, 100000.0, [this, a](double d) { CadFeature& f = m_doc.features[m_constrain_feat]; 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)); m_status->SetLabel(_L("Offset collapsed the entity")); m_status->Refresh(); return; } const int ni = int(f.entities.size()); // offset copy lands here for (auto& o : out) f.entities.push_back(o); // C4c: bind the offset copy to its source. Offset only ADDS geometry // (the source is untouched), so unlike trim/fillet there are no stale // constraints to drop — just glue the pair. A line offset stays // Parallel to its source; an arc/circle offset stays Concentric (same // centre). Single constraint, so no degradation ladder; solve and roll // the binding back if the solver rejects it (keep the geometry). { using CT = SketchConstraintType; const Type st = f.entities[a].type; SketchEntityConstraintDef d2; d2.ea = a; d2.eb = ni; bool emit = true; if (st == Type::Line) d2.type = CT::Parallel; else if (st == Type::Arc || st == Type::Circle) d2.type = CT::Concentric; else emit = false; if (emit) { const size_t cbefore = f.entity_constraints.size(); f.entity_constraints.push_back(d2); if (!m_doc.solve_sketch_feature(m_constrain_feat)) f.entity_constraints.resize(cbefore); } } after_edit_op(); }); return; // deferred: edit runs on Confirm } case EditOp::Fillet: { if (e1 < 0 || e1 >= n) { fail(_L("Pick two lines to fillet")); return; } if (feat.entities[e0].type != Type::Line || feat.entities[e1].type != Type::Line) { fail(_L("Fillet needs two lines")); return; } const int a = e0, b = e1; request_value(_L("Fillet radius"), 1.0, 0.001, 100000.0, [this, a, b](double r) { CadFeature& f = m_doc.features[m_constrain_feat]; 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)); m_status->SetLabel(_L("Fillet failed (parallel lines or radius too large)")); m_status->Refresh(); return; } f.entities[a] = a_out; f.entities[b] = b_out; const int arc = int(f.entities.size()); f.entities.push_back(arc_out); // C4b: glue the fillet arc to the two trimmed lines so it survives a // re-solve instead of floating free. arc.p0 sits on line a's moved // endpoint, arc.p1 on line b's; recover the exact endpoint roles by // nearest match, then emit Coincident (essential — keeps the corner // joined) + Tangent (smoothness). The full set can be redundant for the // arc, so try it first and drop tangents progressively until the solver // accepts it; the Coincident pins survive even if tangency is rejected. { using R = SketchPointRole; using CT = SketchConstraintType; auto role_near = [](const SketchEntity& ln, const Vec2d& p) -> R { return ((ln.p0 - p).squaredNorm() <= (ln.p1 - p).squaredNorm()) ? R::P0 : R::P1; }; const R ra = role_near(f.entities[a], arc_out.p0); const R rb = role_near(f.entities[b], arc_out.p1); // Fillet trims both lines back from the shared corner, so any // constraint anchored to a trimmed endpoint is now stale: the corner // Coincident that joined (a,ra)·(b,rb), and each line's own length // Distance (its length just changed). Drop them before re-binding — // leaving them would fight the new arc geometry and reject every // binding below. auto refs = [](const SketchEntityConstraintDef& d, int e, R r) { 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 = f.entity_constraints; cs.erase(std::remove_if(cs.begin(), cs.end(), [&](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()); auto coin = [&](R arc_role, int ln, R ln_role) { SketchEntityConstraintDef d; d.type = CT::Coincident; d.ea = arc; d.ra = arc_role; d.eb = ln; d.rb = ln_role; return d; }; auto tang = [&](int ln) { SketchEntityConstraintDef d; d.type = CT::Tangent; d.ea = arc; d.eb = ln; return d; }; const std::vector> ladder = { { coin(R::P0, a, ra), coin(R::P1, b, rb), tang(a), tang(b) }, { coin(R::P0, a, ra), coin(R::P1, b, rb), tang(a) }, { coin(R::P0, a, ra), coin(R::P1, b, rb) }, }; const std::vector saved = f.entities; const size_t cbefore = f.entity_constraints.size(); for (const auto& set : ladder) { for (const auto& d : set) f.entity_constraints.push_back(d); if (m_doc.solve_sketch_feature(m_constrain_feat)) break; // accepted f.entity_constraints.resize(cbefore); f.entities = saved; } } after_edit_op(); }); return; // deferred: edit runs on Confirm } case EditOp::Chamfer: { if (e1 < 0 || e1 >= n) { fail(_L("Pick two lines to chamfer")); return; } if (feat.entities[e0].type != Type::Line || feat.entities[e1].type != Type::Line) { fail(_L("Chamfer needs two lines")); return; } const int a = e0, b = e1; request_value(_L("Chamfer distance"), 1.0, 0.001, 100000.0, [this, a, b](double d) { CadFeature& f = m_doc.features[m_constrain_feat]; 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)); m_status->SetLabel(_L("Chamfer failed (parallel lines or distance too large)")); m_status->Refresh(); return; } f.entities[a] = a_out; f.entities[b] = b_out; const int seg = int(f.entities.size()); f.entities.push_back(seg_out); // C4.6: like fillet, chamfer trims both lines back from the shared corner // and inserts a connecting segment. MUTATING op: drop the stale corner // Coincident that joined the two trimmed endpoints and each line's own // length Distance (lengths just changed), then pin the new segment's ends // onto the trimmed line endpoints with Coincident so it survives re-solve. { using R = SketchPointRole; using CT = SketchConstraintType; auto role_near = [](const SketchEntity& ln, const Vec2d& p) -> R { return ((ln.p0 - p).squaredNorm() <= (ln.p1 - p).squaredNorm()) ? R::P0 : R::P1; }; const R ra = role_near(f.entities[a], seg_out.p0); const R rb = role_near(f.entities[b], seg_out.p1); auto refs = [](const SketchEntityConstraintDef& dd, int e, R r) { return (dd.ea == e && dd.ra == r) || (dd.eb == e && dd.rb == r); }; auto self_len = [](const SketchEntityConstraintDef& dd, int e) { return dd.type == CT::Distance && dd.ea == e && dd.eb == e; }; auto& cs = f.entity_constraints; cs.erase(std::remove_if(cs.begin(), cs.end(), [&](const SketchEntityConstraintDef& dd) { return (dd.type == CT::Coincident && refs(dd, a, ra) && refs(dd, b, rb)) || self_len(dd, a) || self_len(dd, b); }), cs.end()); auto coin = [&](R seg_role, int ln, R ln_role) { SketchEntityConstraintDef dd; dd.type = CT::Coincident; dd.ea = seg; dd.ra = seg_role; dd.eb = ln; dd.rb = ln_role; return dd; }; const std::vector saved = f.entities; const size_t cbefore = f.entity_constraints.size(); f.entity_constraints.push_back(coin(R::P0, a, ra)); f.entity_constraints.push_back(coin(R::P1, b, rb)); if (!m_doc.solve_sketch_feature(m_constrain_feat)) { f.entity_constraints.resize(cbefore); // keep geometry, drop pins f.entities = saved; } } after_edit_op(); }); return; // deferred: edit runs on Confirm } case EditOp::Trim: case EditOp::Extend: { // Trim accepts Line/Arc/Circle subjects; Extend accepts Line/Arc (a Circle // is already closed, so there is nothing to extend). const Type st = feat.entities[e0].type; const bool subject_ok = (op == EditOp::Trim) ? (st == Type::Line || st == Type::Arc || st == Type::Circle) : (st == Type::Line || st == Type::Arc); if (!subject_ok) { fail(op == EditOp::Trim ? _L("Trim works on lines, arcs and circles") : _L("Extend works on lines and arcs")); return; } Vec2d pick; if (!m_viewport->pick0_point(pick)) { fail(_L("Pick the edge to trim/extend")); return; } std::vector others; others.reserve(n > 0 ? n - 1 : 0); for (int i = 0; i < n; ++i) if (i != e0) others.push_back(feat.entities[i]); const SketchEntity before = feat.entities[e0]; // C4.1: detect the moved endpoint const bool ok = (op == EditOp::Trim) ? SketchEngine::trim_entity(feat.entities[e0], others, pick) : SketchEngine::extend_entity(feat.entities[e0], others, pick); if (!ok) { fail(op == EditOp::Trim ? _L("Nothing to trim at the pick") : _L("No edge to extend to")); return; } // C4.1: trim/extend slides ONE endpoint of the subject along its own // direction (line) or sweep (arc). That (a) kills the subject's // self-length Distance dim and (b) detaches the moved endpoint from any // corner Coincident/PointOn* it used to hold. Drop both stale classes, // then re-anchor the moved endpoint onto the entity it now lands on with a // PointOnObject (the bridge picks PT_ON_LINE / PT_ON_CIRCLE). A Circle // subject restructures into an Arc (both endpoints new) — skip the // re-anchor there; its self constraints (Radius/Concentric) survive, so // there is nothing stale to drop either. if (st == Type::Line || st == Type::Arc) { using R = SketchPointRole; using CT = SketchConstraintType; const SketchEntity& aft = feat.entities[e0]; R moved = R::P0; Vec2d P; if (st == Type::Line) { const bool p0_moved = (before.p0 - aft.p0).squaredNorm() > (before.p1 - aft.p1).squaredNorm(); moved = p0_moved ? R::P0 : R::P1; P = p0_moved ? aft.p0 : aft.p1; } else { const bool start_moved = std::abs(before.start_angle - aft.start_angle) > std::abs(before.end_angle - aft.end_angle); moved = start_moved ? R::P0 : R::P1; const double ang = start_moved ? aft.start_angle : aft.end_angle; P = aft.center + aft.radius * Vec2d(std::cos(ang), std::sin(ang)); } // Drop stale: subject self-length Distance + any Coincident/PointOn* // pinning the moved endpoint to its old corner. auto refs = [&](const SketchEntityConstraintDef& d, R r) { return (d.ea == e0 && d.ra == r) || (d.eb == e0 && d.rb == r); }; auto& cs = feat.entity_constraints; cs.erase(std::remove_if(cs.begin(), cs.end(), [&](const SketchEntityConstraintDef& d) { if (d.type == CT::Distance && d.ea == e0 && d.eb == e0) return true; return (d.type == CT::Coincident || d.type == CT::PointOnLine || d.type == CT::PointOnObject) && refs(d, moved); }), cs.end()); // Find which other entity the moved endpoint now lies on (Line/Circle // cutters only — PT_ON_* needs a line or circle primitive). int cutter = -1; const double tol = 1e-5; for (int i = 0; i < int(feat.entities.size()); ++i) { if (i == e0) continue; const SketchEntity& o = feat.entities[i]; if (o.type == Type::Line) { Vec2d dv = o.p1 - o.p0; const double L2 = dv.dot(dv); if (L2 < 1e-18) continue; const double t = (P - o.p0).dot(dv) / L2; if (t < -1e-6 || t > 1.0 + 1e-6) continue; if ((o.p0 + t * dv - P).norm() < tol) { cutter = i; break; } } else if (o.type == Type::Circle) { if (std::abs((P - o.center).norm() - o.radius) < tol) { cutter = i; break; } } } // Re-anchor with PointOnObject; keep geometry + the stale-drop even if // the solver rejects the new (possibly redundant) binding. if (cutter >= 0) { const size_t cbefore = feat.entity_constraints.size(); SketchEntityConstraintDef d; d.type = CT::PointOnObject; d.ea = e0; d.ra = moved; d.eb = cutter; feat.entity_constraints.push_back(d); if (!m_doc.solve_sketch_feature(m_constrain_feat)) feat.entity_constraints.resize(cbefore); } } break; } case EditOp::Array: { // C4.4 linear array. Additive op (originals untouched, copies appended) -> // no stale constraints to drop. Collect count, then spacing; the array runs // along the subject line's own direction (or +X for non-lines). Copies are // pure translates, so for lines they are Parallel + EqualLength to the // source by construction -> bind each copy to the source in a star web; for // arc/circle subjects the translate preserves radius (but not the centre), // so the web is a per-copy Radius dimension instead (see below). const int a = e0; request_value(_L("Array count (incl. original)"), 3.0, 2.0, 200.0, [this, a](double cnt_d) { const int count = std::max(2, int(cnt_d + 0.5)); request_value(_L("Spacing (mm)"), 20.0, -100000.0, 100000.0, [this, a, count](double sp) { CadFeature& f = m_doc.features[m_constrain_feat]; if (a >= int(f.entities.size())) return; using Type = SketchEntity::Type; // Snapshot everything we need from the source BEFORE pushing the // copies: push_back can reallocate f.entities and dangle any // reference into it. Copy the subject by value. const SketchEntity src = f.entities[a]; const Type src_type = src.type; // Default direction: perpendicular to a line (so copies stack // into a visible, non-overlapping parallel pattern rather than // extending collinearly); +X for non-line subjects. Vec2d dir(1.0, 0.0); if (src_type == Type::Line) { const Vec2d t = src.p1 - src.p0; if (t.norm() > 1e-9) { const Vec2d u = t.normalized(); dir = Vec2d(-u.y(), u.x()); } } auto copies = SketchEngine::array_entities( { src }, count, sp * dir, 0.0, Vec2d(0, 0)); if (copies.empty()) { m_status->SetForegroundColour(wxColour(235, 110, 110)); m_status->SetLabel(_L("Array produced nothing")); m_status->Refresh(); return; } const int base = int(f.entities.size()); // first copy index for (auto& c : copies) f.entities.push_back(c); if (src_type == Type::Line) { using CT = SketchConstraintType; // Bind every copy to the source in a star web. Emit the // WHOLE web before solving (a per-constraint solve would run // while later copies are still unconstrained, which the // solver rejects), then degrade as a set: try Parallel + // EqualLength, fall back to Parallel only (EqualLength can be // rank-deficient on exact congruent copies), then to bare // geometry. Keep the geometry regardless. const size_t cb = f.entity_constraints.size(); auto build_web = [&](bool with_equal) { f.entity_constraints.resize(cb); for (int k = 0; k < int(copies.size()); ++k) { SketchEntityConstraintDef dp; dp.type = CT::Parallel; dp.ea = a; dp.eb = base + k; f.entity_constraints.push_back(dp); if (with_equal) { SketchEntityConstraintDef de; de.type = CT::EqualLength; de.ea = a; de.eb = base + k; f.entity_constraints.push_back(de); } } }; build_web(true); if (!m_doc.solve_sketch_feature(m_constrain_feat)) { build_web(false); if (!m_doc.solve_sketch_feature(m_constrain_feat)) f.entity_constraints.resize(cb); } } else if (src_type == Type::Arc || src_type == Type::Circle) { // Curved subject: translation preserves the radius but // marches the centres apart, so the copies are NOT // concentric. There is no EQUAL_RADIUS in the constraint // enum, so pin each copy's radius to the source value with // a per-copy Radius dimension (keeps the array equal-radius // and documents intent, mirroring the line web). Solve and // roll the whole web back if the solver rejects it. using CT = SketchConstraintType; const size_t cb = f.entity_constraints.size(); for (int k = 0; k < int(copies.size()); ++k) { SketchEntityConstraintDef dr; dr.type = CT::Radius; dr.ea = base + k; dr.value = src.radius; f.entity_constraints.push_back(dr); } if (!m_doc.solve_sketch_feature(m_constrain_feat)) f.entity_constraints.resize(cb); } after_edit_op(); }); }); return; // deferred: edit runs on the two Confirms } case EditOp::Move: { // C4.5 Transform (move). MUTATING op: the subject is translated in place // (kernel transform_entities with angle=0, scale=1). Pure translation // PRESERVES orientation and length, so intrinsic + orientation constraints // survive (Horizontal/Vertical/Parallel/Perpendicular/EqualLength/Angle, // self-length Distance, Radius/Diameter); it BREAKS position-coupling ones // (Coincident/PointOn*/Concentric/Symmetric/Midpoint/Fix/LockX/LockY, and // any Distance tying the subject to a *different* entity). Per the governing // P4 insight, drop those before re-solving — otherwise the solver drags the // subject straight back to satisfy them and the move never sticks. const int a = e0; request_value(_L("Move dX (mm)"), 20.0, -100000.0, 100000.0, [this, a](double dx) { request_value(_L("Move dY (mm)"), 0.0, -100000.0, 100000.0, [this, a, dx](double dy) { CadFeature& f = m_doc.features[m_constrain_feat]; if (a >= int(f.entities.size())) return; 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)); m_status->SetLabel(_L("Move produced nothing")); m_status->Refresh(); return; } f.entities[a] = out[0]; using CT = SketchConstraintType; auto refs_a = [&](const SketchEntityConstraintDef& d) { return d.ea == a || d.eb == a || d.ec == a; }; auto& cs = f.entity_constraints; cs.erase(std::remove_if(cs.begin(), cs.end(), [&](const SketchEntityConstraintDef& d) { if (!refs_a(d)) return false; switch (d.type) { case CT::Coincident: case CT::PointOnLine: case CT::PointOnObject: case CT::Concentric: case CT::Symmetric: case CT::Midpoint: case CT::Fix: case CT::LockX: case CT::LockY: return true; // position-coupling: broken by translation case CT::Distance: // self-length (ea==eb==a) survives translation; a // distance to a *different* entity does not. return !(d.ea == a && d.eb == a); default: return false; // orientation/length: preserved } }), cs.end()); // The surviving constraints are satisfied by construction // (translation preserves them); re-solve to fold the new // position in, keep the geometry even if the solver balks. m_doc.solve_sketch_feature(m_constrain_feat); after_edit_op(); }); }); return; // deferred: edit runs on the two Confirms } case EditOp::Rotate: { // C4.5b Transform (rotate-in-place about the subject centroid). MUTATING op. // Rotation PRESERVES intrinsic size (length/radius) but changes the subject's // ORIENTATION and the POSITION of its points. So only the size constraints // survive (EqualLength/Radius/Diameter + self-length Distance); every // position- or orientation-coupling constraint is broken and must be dropped // before re-solving, otherwise the solver spins the subject back to satisfy // them and the rotation never sticks (governing P4 insight). const int a = e0; request_value(_L("Rotate angle (deg)"), 45.0, -360.0, 360.0, [this, a](double deg) { CadFeature& f = m_doc.features[m_constrain_feat]; if (a >= int(f.entities.size())) return; auto centroid_of = [](const SketchEntity& e) -> Vec2d { using T = SketchEntity::Type; switch (e.type) { case T::Line: return 0.5 * (e.p0 + e.p1); case T::Arc: case T::Circle: case T::Ellipse: case T::EllipseArc: return e.center; case T::BSpline: if (!e.ctrl.empty()) { Vec2d s(0, 0); for (auto& p : e.ctrl) s += p; return s / double(e.ctrl.size()); } return 0.5 * (e.p0 + e.p1); default: return e.p0; // Point } }; const Vec2d piv = centroid_of(f.entities[a]); 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)); m_status->SetLabel(_L("Rotate produced nothing")); m_status->Refresh(); return; } f.entities[a] = out[0]; using CT = SketchConstraintType; auto refs_a = [&](const SketchEntityConstraintDef& d) { return d.ea == a || d.eb == a || d.ec == a; }; auto& cs = f.entity_constraints; cs.erase(std::remove_if(cs.begin(), cs.end(), [&](const SketchEntityConstraintDef& d) { if (!refs_a(d)) return false; switch (d.type) { case CT::EqualLength: case CT::Radius: case CT::Diameter: return false; // intrinsic size: preserved by rotation case CT::Distance: return !(d.ea == a && d.eb == a); // self-length survives default: return true; // position/orientation-coupling: broken } }), cs.end()); m_doc.solve_sketch_feature(m_constrain_feat); after_edit_op(); }); return; // deferred: edit runs on Confirm } case EditOp::Scale: { // C4.5c Transform (uniform scale-in-place about the subject centroid). MUTATING // op. Uniform scaling PRESERVES orientation and angles (Horizontal/Vertical/ // Parallel/Perpendicular/Angle survive) but changes SIZE and point POSITIONS: // drop every size constraint (Radius/Diameter/EqualLength/any Distance) and // every position-coupling constraint before re-solving, else the solver // rescales the subject back to satisfy them. const int a = e0; request_value(_L("Scale factor"), 2.0, 0.01, 1000.0, [this, a](double sf) { CadFeature& f = m_doc.features[m_constrain_feat]; if (a >= int(f.entities.size())) return; auto centroid_of = [](const SketchEntity& e) -> Vec2d { using T = SketchEntity::Type; switch (e.type) { case T::Line: return 0.5 * (e.p0 + e.p1); case T::Arc: case T::Circle: case T::Ellipse: case T::EllipseArc: return e.center; case T::BSpline: if (!e.ctrl.empty()) { Vec2d s(0, 0); for (auto& p : e.ctrl) s += p; return s / double(e.ctrl.size()); } return 0.5 * (e.p0 + e.p1); default: return e.p0; // Point } }; const Vec2d piv = centroid_of(f.entities[a]); 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)); m_status->SetLabel(_L("Scale produced nothing")); m_status->Refresh(); return; } f.entities[a] = out[0]; using CT = SketchConstraintType; auto refs_a = [&](const SketchEntityConstraintDef& d) { return d.ea == a || d.eb == a || d.ec == a; }; auto& cs = f.entity_constraints; cs.erase(std::remove_if(cs.begin(), cs.end(), [&](const SketchEntityConstraintDef& d) { if (!refs_a(d)) return false; switch (d.type) { case CT::Horizontal: case CT::Vertical: case CT::Parallel: case CT::Perpendicular: case CT::Angle: return false; // orientation/angle: preserved by uniform scale default: return true; // size + position-coupling: broken } }), cs.end()); m_doc.solve_sketch_feature(m_constrain_feat); after_edit_op(); }); return; // deferred: edit runs on Confirm } case EditOp::PolarArray: { // Polar array about the subject centroid. ADDITIVE op (originals untouched, // count-1 rotated copies appended) -> no stale constraints to drop. Copies // are rigid rotations of the source, so they preserve LENGTH but NOT // orientation: bind each copy to the source with EqualLength only (Parallel // does NOT hold under rotation, unlike the linear-array web). Arc/circle // subjects rotate about their own centre, so their copies stay Concentric + // equal-radius instead (see below). Dialogs: count // then total sweep; angle_step = sweep/count spreads them evenly (last copy // lands just shy of the original on a full 360). const int a = e0; request_value(_L("Polar count (incl. original)"), 6.0, 2.0, 200.0, [this, a](double cnt_d) { const int count = std::max(2, int(cnt_d + 0.5)); request_value(_L("Total sweep (deg)"), 360.0, -360.0, 360.0, [this, a, count](double sweep_deg) { CadFeature& f = m_doc.features[m_constrain_feat]; if (a >= int(f.entities.size())) return; using Type = SketchEntity::Type; // Snapshot the subject by value BEFORE pushing copies: push_back // can reallocate f.entities and dangle a reference into it. const SketchEntity src = f.entities[a]; const Type src_type = src.type; auto centroid_of = [](const SketchEntity& e) -> Vec2d { using T = SketchEntity::Type; switch (e.type) { case T::Line: return 0.5 * (e.p0 + e.p1); case T::Arc: case T::Circle: case T::Ellipse: case T::EllipseArc: return e.center; case T::BSpline: if (!e.ctrl.empty()) { Vec2d s(0, 0); for (auto& p : e.ctrl) s += p; return s / double(e.ctrl.size()); } return 0.5 * (e.p0 + e.p1); default: return e.p0; // Point } }; const Vec2d piv = centroid_of(src); const double angle_step = (sweep_deg * M_PI / 180.0) / double(count); auto copies = SketchEngine::array_entities( { src }, count, Vec2d(0, 0), angle_step, piv); if (copies.empty()) { m_status->SetForegroundColour(wxColour(235, 110, 110)); m_status->SetLabel(_L("Polar array produced nothing")); m_status->Refresh(); return; } const int base = int(f.entities.size()); // first copy index for (auto& c : copies) f.entities.push_back(c); if (src_type == Type::Line) { using CT = SketchConstraintType; // Rotational web: each copy is EqualLength to the source // (rotation preserves length; orientation differs so NO // Parallel). Emit the whole web before solving, then fall // back to bare geometry if it is rank-deficient. const size_t cb = f.entity_constraints.size(); for (int k = 0; k < int(copies.size()); ++k) { SketchEntityConstraintDef de; de.type = CT::EqualLength; de.ea = a; de.eb = base + k; f.entity_constraints.push_back(de); } if (!m_doc.solve_sketch_feature(m_constrain_feat)) f.entity_constraints.resize(cb); } else if (src_type == Type::Arc || src_type == Type::Circle) { // Curved subject: the polar pivot is the subject centroid, // which for an arc/circle IS its own centre. Rotating about // that centre keeps every copy CONCENTRIC with the source and // at the same radius (only the angular position shifts). Bind // each copy with Concentric + a per-copy Radius dimension; // degrade to Radius-only, then to bare geometry, keeping the // geometry regardless. using CT = SketchConstraintType; const size_t cb = f.entity_constraints.size(); auto build_web = [&](bool with_concentric) { f.entity_constraints.resize(cb); for (int k = 0; k < int(copies.size()); ++k) { if (with_concentric) { SketchEntityConstraintDef dc; dc.type = CT::Concentric; dc.ea = a; dc.eb = base + k; f.entity_constraints.push_back(dc); } SketchEntityConstraintDef dr; dr.type = CT::Radius; dr.ea = base + k; dr.value = src.radius; f.entity_constraints.push_back(dr); } }; build_web(true); if (!m_doc.solve_sketch_feature(m_constrain_feat)) { build_web(false); if (!m_doc.solve_sketch_feature(m_constrain_feat)) f.entity_constraints.resize(cb); } } after_edit_op(); }); }); return; // deferred: edit runs on the two Confirms } } after_edit_op(); } void DesignPanel::after_edit_op() { if (m_constrain_feat < 0 || m_constrain_feat >= int(m_doc.features.size()) || !m_viewport) return; m_doc.recompute(); m_viewport->set_constraint_highlight({}); // entity indices may have shifted m_viewport->update_constrain_entities(m_doc.features[m_constrain_feat].entities); // Refresh the committed-sketch overlay too: it caches the entity list at // constrain-entry, so a relocating edit (Move/Trim/Extend) would otherwise // leave a stale ghost of the pre-edit geometry beside the new position. sync_sketch_display(); if (!m_doc.display_mesh.its.indices.empty()) feed_bodies(); m_status->SetForegroundColour(wxNullColour); m_status->SetLabel(_L("Applied edit")); m_status->Refresh(); refresh_constrain_dof(); rebuild_constraint_list(); } void DesignPanel::request_value(const wxString& label, double def, double mn, double mx, std::function cont, std::function on_cancel) { m_value_cont = std::move(cont); m_value_cancel = std::move(on_cancel); m_value_min = mn; m_value_max = mx; m_value_label->SetLabel(label); m_value_input->ChangeValue(en_format(def)); // '.' decimals, no EVT_TEXT feedback m_form->GetSizer()->Show(m_box_value, true, true); m_form->Layout(); m_form->FitInside(); m_value_input->SetFocus(); m_value_input->SetSelection(-1, -1); // select all so typing replaces the value m_status->SetForegroundColour(wxNullColour); m_status->SetLabel(label + _L(" — type a value, press Enter (or Confirm)")); m_status->Refresh(); } void DesignPanel::confirm_value() { if (!m_value_cont) { cancel_value(); return; } double v = 0.0; if (!en_parse(m_value_input->GetValue(), v)) { m_value_input->SetFocus(); return; } v = std::min(std::max(v, m_value_min), m_value_max); // clamp to range auto cont = m_value_cont; // copy, then clear before running so a m_value_cont = nullptr; // re-entrant request_value can re-arm cleanly m_value_cancel = nullptr; // confirmed: drop the cancel action m_form->GetSizer()->Show(m_box_value, false, true); m_form->Layout(); m_form->FitInside(); cont(v); // run the deferred constraint / edit-op apply } void DesignPanel::cancel_value() { const bool was_open = (m_value_cont != nullptr); m_value_cont = nullptr; auto on_cancel = m_value_cancel; // copy, clear, then run (re-entrancy safe) m_value_cancel = nullptr; if (m_box_value) m_form->GetSizer()->Show(m_box_value, false, true); m_form->Layout(); m_form->FitInside(); if (was_open) { m_status->SetForegroundColour(wxNullColour); m_status->SetLabel(wxString()); m_status->Refresh(); } if (on_cancel) on_cancel(); // e.g. keep a pending line segment as drawn } 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)); m_status->SetLabel(_L("Press Constrain on a sketch first")); m_status->Refresh(); return; } // Entity sketches (Fase 4.2) route through the entity-constraint path. if (m_viewport->is_constraining_entities()) { apply_entity_constraint(type); return; } if (!m_viewport->is_constraining()) { m_status->SetForegroundColour(wxColour(235, 110, 110)); m_status->SetLabel(_L("Press Constrain on a sketch first")); m_status->Refresh(); return; } int a = -1, b = -1; if (!m_viewport->selected_segment(a, b)) { m_status->SetForegroundColour(wxColour(235, 110, 110)); m_status->SetLabel(_L("Pick a segment in the viewport first")); m_status->Refresh(); return; } CadFeature& feat = m_doc.features[m_constrain_feat]; // solve_sketch_feature rewrites profile.points even on failure, so snapshot // the geometry to roll back a rejected constraint cleanly. const std::vector saved_pts = feat.profile.points; feat.constraints.push_back(SketchConstraintDef{type, a, b, -1, -1, 0.0}); if (!m_doc.solve_sketch_feature(m_constrain_feat)) { feat.constraints.pop_back(); // reject the non-converging addition feat.profile.points = saved_pts; // and restore the pre-solve geometry m_status->SetForegroundColour(wxColour(235, 110, 110)); m_status->SetLabel(_L("Constraint rejected (over-constrained)")); m_status->Refresh(); return; } m_doc.recompute(); 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); m_status->SetLabel(type == SketchConstraintType::Horizontal ? _L("Applied Horizontal") : _L("Applied Vertical")); m_status->Refresh(); } void DesignPanel::reset_edit_state() { m_edit_index = -1; } int DesignPanel::resolve_extrude_sketch() const { int sel = tree_selection(); if (sel != wxNOT_FOUND && sel < int(m_doc.features.size()) && m_doc.features[sel].type == CadFeatureType::Sketch) return sel; for (int i = int(m_doc.features.size()) - 1; i >= 0; --i) if (m_doc.features[i].type == CadFeatureType::Sketch) return i; return -1; } void DesignPanel::load_feature_into_dialog(const CadFeature& f) { switch (f.type) { case CadFeatureType::Sketch: m_shape->SetSelection(f.shape == SketchShape::Circle ? 1 : 0); m_plane->SetSelection(index_from_plane(f.plane)); m_width->SetValue(f.width); m_height->SetValue(f.height); m_radius->SetValue(f.radius); break; case CadFeatureType::Extrude: m_distance->SetValue(f.distance); m_mode->SetSelection(static_cast(f.mode)); // New=0,Add=1,Cut=2,Intersect=3 m_extrude_end->SetSelection(static_cast(f.extrude_end)); m_distance2->SetValue(f.distance2); m_taper->SetValue(f.taper_deg); m_flip->SetValue(f.flip); m_extrude_sketch_ref = f.sketch_ref; m_sel_solid_body = f.target_body; // preserve which body on re-edit 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)); break; case CadFeatureType::Fillet: case CadFeatureType::Chamfer: 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_body = f.target_body; // preserve which body on re-edit break; case CadFeatureType::Hole: m_hole_plane->SetSelection(index_from_plane(f.plane)); m_hole_diameter->SetValue(f.hole_diameter); m_hole_depth->SetValue(f.hole_depth); m_hole_through->SetValue(f.hole_through); m_hole_x->SetValue(f.hole_x); m_hole_y->SetValue(f.hole_y); break; case CadFeatureType::Thread: m_thread_plane->SetSelection(index_from_plane(f.plane)); m_thread_radius->SetValue(f.thread_radius * 2.0); // field = diameter m_thread_pitch->SetValue(f.thread_pitch); m_thread_height->SetValue(f.thread_height); m_thread_depth->SetValue(f.thread_depth); m_thread_internal->SetValue(f.thread_internal); m_thread_x->SetValue(f.thread_x); m_thread_y->SetValue(f.thread_y); if (m_thread_std) m_thread_std->SetSelection(0); // Custom: spins reflect the stored feature break; case CadFeatureType::Shell: m_shell_thickness->SetValue(f.shell_thickness); m_sel_solid_face = f.shell_face; m_shell_face_label->SetLabel(f.shell_face >= 0 ? wxString::Format(_L("Face %d"), f.shell_face) : _L("(all faces — closed hollow)")); 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; break; case CadFeatureType::Sweep: m_sweep_profile_ref = f.sketch_ref; m_sweep_path_ref = f.sweep_path_ref; // show_tool pre-selects this in the picker m_sweep_mode->SetSelection(static_cast(f.mode)); break; case CadFeatureType::Pattern: m_pattern_type->SetSelection(f.pattern_circular ? 1 : 0); m_pattern_count->SetValue(f.pattern_count); m_pattern_spacing->SetValue(f.pattern_spacing); m_pattern_dir->SetSelection(f.pattern_dir); m_pattern_angle->SetValue(f.pattern_angle); break; case CadFeatureType::Plane: populate_plane_choices(m_plane_base); m_plane_base->SetSelection(f.plane_base); m_plane_offset->SetValue(f.plane_offset); m_plane_tilt->SetValue(f.plane_angle_tilt); m_plane_tilt_axis->SetSelection(f.plane_axis); m_plane_type->SetSelection((int)f.plane_type); m_pl_faceA_body = f.plane_face_body; m_pl_faceA = f.plane_face; m_pl_faceB_body = f.plane_face2_body; m_pl_faceB = f.plane_face2; m_pl_edgeA_body = f.plane_edge_body; m_pl_edgeA = f.plane_edge; m_pl_edgeB_body = f.plane_edge2_body; m_pl_edgeB = f.plane_edge2; m_plane_usize->SetValue(f.plane_u_size); m_plane_vsize->SetValue(f.plane_v_size); m_plane_pick = PlanePick::None; refresh_plane_labels(); break; case CadFeatureType::Loft: m_loft_refs = f.loft_profile_refs; // show_tool re-checks these in the list m_loft_ruled->SetValue(f.loft_ruled); m_loft_mode->SetSelection(static_cast(f.mode)); break; case CadFeatureType::Draft: m_draft_angle->SetValue(f.draft_angle); m_sel_solid_face = f.draft_face; m_draft_face_label->SetLabel(f.draft_face >= 0 ? wxString::Format(_L("Face %d"), f.draft_face) : _L("(pick a side face)")); break; case CadFeatureType::Boolean: // List the bodies available to the boolean (as-of its timeline slot), so the consumed // tool body still appears and the saved selections below land on the right entries. populate_body_choices(m_edit_index); m_bool_op->SetSelection(f.mode == BooleanMode::Cut ? 1 : f.mode == BooleanMode::Intersect ? 2 : 0); // 0 = Union/Add if (f.target_body >= 0 && f.target_body < int(m_bool_target->GetCount())) m_bool_target->SetSelection(f.target_body); if (f.bool_tool_body >= 0 && f.bool_tool_body < int(m_bool_tool->GetCount())) m_bool_tool->SetSelection(f.bool_tool_body); m_bool_keep->SetValue(f.bool_keep_tool); m_bool_tol->SetValue(f.bool_tolerance); break; default: break; } } void DesignPanel::on_edit_feature() { int sel = tree_selection(); if (sel == wxNOT_FOUND) { m_status->SetLabel(_L("Select a feature in the tree first")); m_status->Refresh(); return; } const CadFeature& f = m_doc.features[sel]; reset_edit_state(); switch (f.type) { case CadFeatureType::Sketch: // Imported Text/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()) { m_doc.checkpoint(); // undo boundary: re-placing imported art on_transform_imported(sel); m_insert_feat = sel; open_insert_card(wxString::FromUTF8(f.name)); break; } m_edit_index = sel; if (!f.entities.empty()) { // Entity sketcher: re-open the geometry for full in-canvas editing (handles, // live quotes, regular-polygon drag) in the ENTITY sketch UI (the top sketch // toolbar + session card), NOT the legacy parametric card. The commit handler // replaces this feature in place (see m_edit_index). Hide its display overlay // so the live tool is the only copy drawn. set_ui_mode(UiMode::Sketch); if (m_viewport) { m_viewport->set_display_sketches({}); m_viewport->edit_sketch(f.entities, f.entity_constraints, f.plane); } m_status->SetForegroundColour(wxNullColour); m_status->SetLabel(_L("Editing sketch — drag a handle or click a quote to edit")); m_status->Refresh(); } else { load_feature_into_dialog(f); open_tool(Tool::Sketch); } break; case CadFeatureType::Extrude: m_edit_index = sel; load_feature_into_dialog(f); open_tool(Tool::Extrude); break; case CadFeatureType::Fillet: case CadFeatureType::Chamfer: m_edit_index = sel; load_feature_into_dialog(f); open_tool(Tool::Dressup); break; case CadFeatureType::Hole: m_edit_index = sel; load_feature_into_dialog(f); open_tool(Tool::Hole); break; case CadFeatureType::Thread: m_edit_index = sel; load_feature_into_dialog(f); open_tool(Tool::Thread); break; case CadFeatureType::Shell: m_edit_index = sel; load_feature_into_dialog(f); open_tool(Tool::Shell); break; case CadFeatureType::Revolve: m_edit_index = sel; load_feature_into_dialog(f); open_tool(Tool::Revolve); break; case CadFeatureType::Sweep: m_edit_index = sel; load_feature_into_dialog(f); open_tool(Tool::Sweep); break; case CadFeatureType::Pattern: m_edit_index = sel; load_feature_into_dialog(f); open_tool(Tool::Pattern); break; case CadFeatureType::Plane: m_edit_index = sel; load_feature_into_dialog(f); open_tool(Tool::Plane); break; case CadFeatureType::Loft: m_edit_index = sel; load_feature_into_dialog(f); open_tool(Tool::Loft); break; case CadFeatureType::Draft: m_edit_index = sel; load_feature_into_dialog(f); open_tool(Tool::Draft); break; case CadFeatureType::Boolean: m_edit_index = sel; load_feature_into_dialog(f); open_tool(Tool::Boolean); break; default: // Import / Cut have no parametric edit dialog yet (follow-up // snaporca-nu9). Don't silently swallow the Edit click — tell the user. m_status->SetForegroundColour(wxNullColour); m_status->SetLabel(_L("This feature type can't be edited yet")); m_status->Refresh(); break; } } void DesignPanel::on_export_step() { // Bake any open feature preview first, so the STEP matches what is shown (mirrors on_commit). if (m_active != Tool::None) confirm_tool(); if (m_doc.bodies.empty()) { m_status->SetForegroundColour(wxNullColour); m_status->SetLabel(_L("Nothing to export — add a feature first")); m_status->Refresh(); return; } wxFileDialog dlg(this, _L("Export STEP"), wxEmptyString, "model.step", "STEP files (*.step;*.stp)|*.step;*.stp", wxFD_SAVE | wxFD_OVERWRITE_PROMPT); if (dlg.ShowModal() != wxID_OK) return; 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)); m_status->SetLabel(ok ? _L("Exported STEP") : _L("STEP export failed: ") + wxString::FromUTF8(err)); m_status->Refresh(); } void DesignPanel::on_commit() { // A feature tool open with a live preview ghost (e.g. a fillet being previewed) is // NOT yet part of the body. Apply it first so "Commit to Plate" ships exactly what // is shown on screen, not the pre-feature solid. (confirm_tool() applies + closes.) if (m_active != Tool::None) confirm_tool(); if (m_doc.display_mesh.its.indices.empty()) { m_status->SetLabel(_L("Nothing to commit — add a feature first")); return; } ObjectList* obj_list = wxGetApp().obj_list(); 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"). // 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; 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; } if (committed == 0) { // every body hidden — nothing to ship m_status->SetLabel(_L("All bodies hidden — show one before committing")); return; } } else { obj_list->load_mesh_object(m_disp_pick_mesh, "Design Body"); } // Persist the editable parametric recipe alongside the committed meshes so the // saved 3MF reopens with the full feature tree, not just the baked solid. An empty // doc clears it, keeping non-CAD projects clean. if (Plater* plater = wxGetApp().plater()) plater->model().cad_recipe = m_doc.features.empty() ? std::string() : m_doc.serialize_recipe(); if (wxGetApp().mainframe != nullptr) wxGetApp().mainframe->select_tab(size_t(MainFrame::tp3DEditor)); } CadFeature DesignPanel::build_candidate(Tool t) const { CadFeature f; // EDIT MODE: a feature card edits only scalar parameters. The feature's structural // identity — profile source (sketch_ref / entities / picked face), its plane, the // up-to-face target and the target body — must be preserved from the feature being // edited, NOT re-derived from the live tool state (which still reflects the last *add* // flow). GUI extrudes carry their profile as `entities` with sketch_ref = -1, which the // card never restores, so a fresh rebuild produced an empty profile -> a misplaced new // box. Seed from the original; the cases below overlay card scalars and skip the // structural assignments while editing. const bool editing = (m_edit_index >= 0 && m_edit_index < int(m_doc.features.size())); if (editing) f = m_doc.features[m_edit_index]; switch (t) { case Tool::Sketch: f.type = CadFeatureType::Sketch; f.shape = (m_shape->GetSelection() == 0) ? SketchShape::Rectangle : SketchShape::Circle; f.plane = plane_from_choice(m_plane->GetSelection()); f.width = m_width->GetValue(); f.height = m_height->GetValue(); f.radius = m_radius->GetValue(); break; case Tool::Extrude: f.type = CadFeatureType::Extrude; f.distance = m_distance->GetValue(); f.symmetric = false; f.extrude_end = static_cast(m_extrude_end->GetSelection()); f.distance2 = m_distance2->GetValue(); f.taper_deg = m_taper->GetValue(); f.flip = m_flip->GetValue(); f.mode = (m_mode->GetSelection() == 0) ? BooleanMode::New : (m_mode->GetSelection() == 1) ? BooleanMode::Add : (m_mode->GetSelection() == 2) ? BooleanMode::Cut : BooleanMode::Intersect; // Profile source + up-to-face target are structural: re-derive them from the live // tool state only when ADDING. While editing they are preserved from the seeded // original (the card changed only depth/taper/flip/mode). if (!editing) { f.up_to_face = (f.extrude_end == ExtrudeEnd::UpToFace) ? m_sel_solid_face : -1; if (m_extrude_face_src >= 0) { // Face-as-profile extrude: the kernel grabs the body face by id. f.extrude_src_face = m_extrude_face_src; f.sketch_ref = -1; } else if (extrude_uses_loop()) { // Just the click-selected loop: carry its entity subset on the feature // (sketch_ref = -1 -> build_sketch_wire uses f.entities). f.sketch_ref = -1; f.entities = m_viewport->selected_loop_entities(); f.plane = m_doc.features[m_extrude_sketch_ref].plane; } else { f.sketch_ref = m_extrude_sketch_ref; // On-face engraving Cut against the host body (matches the commit). if (m_extrude_sketch_ref >= 0 && m_extrude_sketch_ref < int(m_doc.features.size()) && m_doc.features[m_extrude_sketch_ref].import_on_face) f.target_body = m_doc.features[m_extrude_sketch_ref].import_face_body; } } break; case Tool::Dressup: f.type = (m_dressup_type->GetSelection() == 0) ? CadFeatureType::Fillet : 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 break; case Tool::Hole: f.type = CadFeatureType::Hole; f.plane = hole_plane(); f.hole_diameter = m_hole_diameter->GetValue(); f.hole_depth = m_hole_depth->GetValue(); f.hole_through = m_hole_through->GetValue(); f.hole_x = m_hole_x->GetValue(); f.hole_y = m_hole_y->GetValue(); if (m_hole_on_face) f.target_body = m_hole_face_body; // preview the right body break; case Tool::Thread: f.type = CadFeatureType::Thread; f.plane = thread_plane(); f.thread_radius = m_thread_radius->GetValue() * 0.5; // field = diameter -> kernel radius f.thread_pitch = m_thread_pitch->GetValue(); f.thread_height = m_thread_height->GetValue(); f.thread_depth = m_thread_depth->GetValue(); f.thread_internal = m_thread_internal->GetValue(); f.thread_x = m_thread_x->GetValue(); f.thread_y = m_thread_y->GetValue(); if (m_thread_on_face) f.target_body = m_thread_face_body; // tap the right body break; case Tool::Shell: f.type = CadFeatureType::Shell; f.shell_thickness = m_shell_thickness->GetValue(); // A picked solid face opens the shell there; -1 = closed hollow. f.shell_face = (m_sel_solid_face >= 0) ? m_sel_solid_face : -1; break; case Tool::Draft: f.type = CadFeatureType::Draft; f.draft_angle = m_draft_angle->GetValue(); f.draft_face = (m_sel_solid_face >= 0) ? m_sel_solid_face : -1; break; case Tool::Revolve: 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(); f.flip = m_revolve_flip->GetValue(); f.mode = static_cast(m_revolve_mode->GetSelection()); break; case Tool::Sweep: { f.type = CadFeatureType::Sweep; f.sketch_ref = m_sweep_profile_ref; const int sel = m_sweep_path ? m_sweep_path->GetSelection() : wxNOT_FOUND; f.sweep_path_ref = (sel != wxNOT_FOUND) ? int(reinterpret_cast(m_sweep_path->GetClientData(sel))) : -1; f.mode = static_cast(m_sweep_mode->GetSelection()); break; } case Tool::Pattern: f.type = CadFeatureType::Pattern; f.pattern_circular = (m_pattern_type->GetSelection() == 1); f.pattern_count = int(m_pattern_count->GetValue()); f.pattern_spacing = m_pattern_spacing->GetValue(); f.pattern_dir = m_pattern_dir->GetSelection(); f.pattern_angle = m_pattern_angle->GetValue(); break; case Tool::Plane: f.type = CadFeatureType::Plane; f.plane_base = m_plane_base->GetSelection(); f.plane_offset = m_plane_offset->GetValue(); f.plane_angle_tilt = m_plane_tilt->GetValue(); f.plane_axis = m_plane_tilt_axis->GetSelection(); apply_plane_refs(f); // plane_type + face/edge refs + u/v size from the card break; case Tool::Loft: { f.type = CadFeatureType::Loft; f.loft_ruled = m_loft_ruled->GetValue(); f.mode = static_cast(m_loft_mode->GetSelection()); f.loft_profile_refs.clear(); for (unsigned i = 0; i < m_loft_list->GetCount(); ++i) if (m_loft_list->IsChecked(i) && i < m_loft_sketch_idx.size()) f.loft_profile_refs.push_back(m_loft_sketch_idx[i]); break; } case Tool::Boolean: { f.type = CadFeatureType::Boolean; const int sel = m_bool_op->GetSelection(); f.mode = (sel == 1) ? BooleanMode::Cut : (sel == 2) ? BooleanMode::Intersect : BooleanMode::Add; // 0 = Union f.target_body = m_bool_target->GetSelection(); f.bool_tool_body = m_bool_tool->GetSelection(); f.bool_keep_tool = m_bool_keep->GetValue(); f.bool_tolerance = m_bool_tol->GetValue(); // OCCT fuzzy: robust cut on near-coincident faces break; } case Tool::Cut: f.type = CadFeatureType::Cut; f.plane = plane_from_choice(m_cut_plane->GetSelection()); f.cut_offset = m_cut_offset->GetValue(); f.cut_flip = false; f.cut_keep_upper = true; // always split: keep both pieces as separate bodies f.cut_keep_lower = true; f.target_body = m_cut_target->GetSelection(); break; case Tool::Insert: // imported art is committed by add_imported_sketch, not build_candidate case Tool::None: break; } // Boolean drives its own target/tool body from the card; every other tool targets the // picked body (face-extrude reads its source face there, dress-up / hole / boolean-mode // extrude mutate it). -1 when nothing is picked => auto (last body). // Targeting the picked body is an ADD-time concern; while editing, the original feature's // target_body is preserved from the seed (the card did not re-pick a body). if (!editing && m_active != Tool::Boolean && m_active != Tool::Cut) f.target_body = m_sel_solid_body; return f; } // Resolve the active Extrude's profile plane + a representative 2D centroid (arrow anchor) // and push them to the viewport gizmo. Self-gates: clears the gizmo unless Extrude is open. void DesignPanel::update_fillet_gizmo() { if (!m_viewport) return; // Only while the Fillet/Chamfer card is open AND a solid EDGE is the target. Face-group // dress-up (no picked edge) keeps the docked card with no in-canvas handle. The body centroid // comes from the transformed display mesh so it matches the (transformed) edge sample points. const bool ok = (m_active == Tool::Dressup) && m_sel_solid_edge >= 0 && m_sel_solid_body >= 0 && m_sel_solid_body < int(m_disp_body_meshes.size()); if (!ok) { m_viewport->clear_fillet_gizmo(); return; } const Vec3d centroid = m_disp_body_meshes[m_sel_solid_body].bounding_box().center(); m_viewport->begin_fillet_gizmo(centroid, m_dressup_size->GetValue()); } // Push the active Hole card's plane + position + diameter/depth to the viewport gizmo. // Self-gates: clears the gizmo unless the Hole card is open. void DesignPanel::update_hole_gizmo() { if (!m_viewport) return; if (m_active != Tool::Hole) { m_viewport->clear_hole_gizmo(); return; } const SketchPlane plane = hole_plane(); m_viewport->set_hole_face_bounds(m_hole_has_bounds, m_hole_umin, m_hole_umax, m_hole_vmin, m_hole_vmax); m_viewport->begin_hole_gizmo(plane, m_hole_x->GetValue(), m_hole_y->GetValue(), m_hole_diameter->GetValue(), m_hole_depth->GetValue(), m_hole_through->GetValue()); } // Push the active Thread card's plane + position + radius/length to the viewport gizmo. // Self-gates: clears the gizmo unless the Thread card is open. void DesignPanel::update_thread_gizmo() { if (!m_viewport) return; if (m_active != Tool::Thread) { m_viewport->clear_thread_gizmo(); return; } const SketchPlane plane = thread_plane(); m_viewport->begin_thread_gizmo(plane, m_thread_x->GetValue(), m_thread_y->GetValue(), m_thread_radius->GetValue() * 0.5, m_thread_height->GetValue()); } // Anchor an inward thickness arrow at the picked open face's centroid (along -outward-normal). // Self-gates: clears unless the Shell card is open AND a face is picked. The face centroid/normal // come from the kernel shape, then carry the body's display-only Move transform. void DesignPanel::update_shell_gizmo() { if (!m_viewport) return; const int b = m_sel_solid_body; const bool ok = (m_active == Tool::Shell) && m_sel_solid_face >= 0 && b >= 0 && b < int(m_doc.bodies.size()); if (!ok) { m_viewport->clear_shell_gizmo(); return; } const TopoDS_Face fc = GeometryEngine::face_by_index(m_doc.bodies[b].shape, m_sel_solid_face); if (fc.IsNull()) { m_viewport->clear_shell_gizmo(); return; } Vec3d c = GeometryEngine::face_centroid_world(fc); Vec3d n = GeometryEngine::face_normal_world(fc); sync_body_xform(); if (b < int(m_body_xform.size())) { c = m_body_xform[b] * c; n = m_body_xform[b].linear() * n; } if (n.norm() < 1e-9) { m_viewport->clear_shell_gizmo(); return; } // Arrow points inward (into the wall): -outward normal. m_viewport->begin_shell_gizmo(c, (-n).normalized(), m_shell_thickness->GetValue()); } void DesignPanel::update_revolve_gizmo() { if (!m_viewport) return; if (m_active != Tool::Revolve || m_revolve_sketch_ref < 0 || m_revolve_sketch_ref >= int(m_doc.features.size())) { m_viewport->clear_revolve_gizmo(); return; } const CadFeature& sk = m_doc.features[m_revolve_sketch_ref]; // Profile centroid in sketch coords (same rule as the Extrude gizmo: average entity centres, // else profile points, else the plane origin for primitive shapes). Vec2d centroid(0, 0); if (!sk.entities.empty()) { Vec2d acc(0, 0); int n = 0; for (const SketchEntity& e : sk.entities) { switch (e.type) { case SketchEntity::Type::Line: acc += 0.5 * (e.p0 + e.p1); ++n; break; case SketchEntity::Type::Arc: case SketchEntity::Type::EllipseArc: case SketchEntity::Type::Circle: case SketchEntity::Type::Ellipse: acc += e.center; ++n; break; case SketchEntity::Type::Point: acc += e.p0; ++n; break; case SketchEntity::Type::BSpline: if (!e.ctrl.empty()) { Vec2d s(0, 0); for (const Vec2d& q : e.ctrl) s += q; acc += s / double(e.ctrl.size()); ++n; } break; } } if (n > 0) centroid = acc / double(n); } else if (!sk.profile.points.empty()) { 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(), m_revolve_angle->GetValue(), m_revolve_flip->GetValue()); } void DesignPanel::update_draft_gizmo() { if (!m_viewport) return; if (m_active != Tool::Draft || m_sel_solid_face < 0 || m_sel_solid_body < 0 || m_sel_solid_body >= int(m_doc.bodies.size())) { m_viewport->clear_draft_gizmo(); return; } const TopoDS_Face f = GeometryEngine::face_by_index(m_doc.bodies[m_sel_solid_body].shape, m_sel_solid_face); const Vec3d c = GeometryEngine::face_centroid_world(f); const Vec3d n = GeometryEngine::face_normal_world(f); m_viewport->set_draft_gizmo(c, n, m_draft_angle->GetValue()); } void DesignPanel::update_cut_gizmo() { if (!m_viewport) return; if (m_active != Tool::Cut) { m_viewport->clear_cut_gizmo(); return; } const int bi = m_cut_target ? m_cut_target->GetSelection() : -1; if (bi < 0 || bi >= int(m_doc.bodies.size()) || bi >= int(m_doc.display_body_meshes.size())) { m_viewport->clear_cut_gizmo(); return; } const SketchPlane plane = plane_from_choice(m_cut_plane->GetSelection()); const BoundingBoxf3 bb = m_doc.display_body_meshes[bi].bounding_box(); const Vec3d center = bb.center(); const double half = std::max(0.5 * (bb.max - bb.min).norm(), 10.0); m_viewport->set_cut_gizmo(plane, m_cut_offset->GetValue(), center, half); } void DesignPanel::update_operand_highlight() { if (!m_viewport) return; // default: nothing highlighted int bt = -1, bl = -1; std::vector> sk; if (m_active == Tool::Boolean) { if (m_bool_target) bt = m_bool_target->GetSelection(); if (m_bool_tool) bl = m_bool_tool->GetSelection(); } else if (m_active == Tool::Sweep) { if (m_sweep_profile_ref >= 0) sk.emplace_back(m_sweep_profile_ref, ColorRGBA(0.30f, 0.85f, 1.0f, 1.0f)); // profile = cyan if (m_sweep_path_ref >= 0) sk.emplace_back(m_sweep_path_ref, ColorRGBA(1.00f, 0.40f, 0.90f, 1.0f)); // path = magenta } else if (m_active == Tool::Loft) { // checked rows of m_loft_list map to feature indices via m_loft_sketch_idx // (exactly as build_candidate(Tool::Loft) reads them). if (m_loft_list) for (unsigned i = 0; i < m_loft_list->GetCount(); ++i) if (m_loft_list->IsChecked(i) && i < m_loft_sketch_idx.size()) sk.emplace_back(m_loft_sketch_idx[i], ColorRGBA(0.40f, 0.90f, 0.50f, 1.0f)); // profiles = green } m_viewport->set_operand_bodies(bt, bl); m_viewport->set_highlight_sketches(std::move(sk)); } void DesignPanel::update_pattern_gizmo() { if (!m_viewport) return; if (m_active != Tool::Pattern || m_doc.display_body_meshes.empty()) { m_viewport->clear_pattern_gizmo(); return; } // Pattern operates in the default world XY plane (matches the kernel: linear along world X/Y, // circular about world Z through the origin). Anchor on the target body's bbox centre; if that // body carries a display-only Move transform, shift the plane origin + anchor by it so the // gizmo sits on the body where the ghost copies actually appear. const int b = (m_sel_solid_body >= 0 && m_sel_solid_body < int(m_doc.display_body_meshes.size())) ? m_sel_solid_body : int(m_doc.display_body_meshes.size()) - 1; SketchPlane plane; // world XY axes by default Vec3d base = m_doc.display_body_meshes[b].bounding_box().center(); if (b < int(m_body_xform.size())) { plane.origin = m_body_xform[b].translation(); base = m_body_xform[b] * base; } m_viewport->begin_pattern_gizmo(plane, base, m_pattern_type->GetSelection() == 1, int(m_pattern_count->GetValue()), m_pattern_dir->GetSelection(), m_pattern_spacing->GetValue(), m_pattern_angle->GetValue()); } void DesignPanel::update_extrude_gizmo() { if (!m_viewport) return; if (m_active != Tool::Extrude) { m_viewport->clear_extrude_gizmo(); return; } SketchPlane plane; std::vector ents; Vec2d centroid(0, 0); bool have = false, have_centroid = false; if (extrude_uses_loop()) { plane = m_doc.features[m_extrude_sketch_ref].plane; ents = m_viewport->selected_loop_entities(); have = true; } else if (m_extrude_sketch_ref >= 0 && m_extrude_sketch_ref < int(m_doc.features.size())) { const CadFeature& sk = m_doc.features[m_extrude_sketch_ref]; plane = sk.plane; ents = sk.entities; have = true; if (ents.empty() && !sk.profile.points.empty()) { for (const Vec2d& p : sk.profile.points) centroid += p; centroid /= double(sk.profile.points.size()); have_centroid = true; } // Primitive shape sketches (no entities/profile) are centred at the plane origin -> (0,0). } else if (m_extrude_face_src >= 0 && !m_doc.bodies.empty()) { // Face-as-profile (push/pull): anchor the arrow at the picked face's CENTROID, pointing // along its outward normal. The face id is LOCAL to the owner body — route_feature reads // it from `context` (the target, else the last body) — so look it up on that SAME body, // never the whole-document compound (m_doc.body). from_face's plane origin sits at the // plane's canonical point near the world origin, NOT on the face, which is why the arrow // used to land on the bed. Carry the body's display Move transform so the arrow sits where // the body is actually shown. const int b = int(m_doc.bodies.size()) - 1; // matches route_feature's default context TopoDS_Face srcf = GeometryEngine::face_by_index(m_doc.bodies[b].shape, m_extrude_face_src); if (!srcf.IsNull()) { plane = SketchPlane::from_face(srcf); Vec3d c = GeometryEngine::face_centroid_world(srcf); Vec3d n = GeometryEngine::face_normal_world(srcf); if (srcf.Orientation() == TopAbs_REVERSED) n = -n; // outward, matches the kernel push sync_body_xform(); if (b < int(m_body_xform.size())) { c = m_body_xform[b] * c; n = m_body_xform[b].linear() * n; } plane.origin = c; if (n.norm() > 1e-9) plane.normal = n.normalized(); have = true; } } if (!have) { m_viewport->clear_extrude_gizmo(); return; } if (!ents.empty()) { Vec2d acc(0, 0); int n = 0; for (const SketchEntity& e : ents) { switch (e.type) { case SketchEntity::Type::Line: acc += 0.5 * (e.p0 + e.p1); ++n; break; case SketchEntity::Type::Arc: case SketchEntity::Type::EllipseArc: case SketchEntity::Type::Circle: case SketchEntity::Type::Ellipse: acc += e.center; ++n; break; case SketchEntity::Type::Point: acc += e.p0; ++n; break; case SketchEntity::Type::BSpline: if (!e.ctrl.empty()) { Vec2d s(0, 0); for (const Vec2d& q : e.ctrl) s += q; acc += s / double(e.ctrl.size()); ++n; } break; } } if (n > 0) { centroid = acc / double(n); have_centroid = true; } } (void)have_centroid; // centroid defaults to (0,0) for primitive sketches const ExtrudeEnd end = static_cast(m_extrude_end->GetSelection()); m_viewport->set_extrude_gizmo(plane, centroid, m_distance->GetValue(), m_distance2->GetValue(), end == ExtrudeEnd::TwoSided, m_flip->GetValue()); } void DesignPanel::refresh_datum_planes() { if (!m_viewport) return; std::vector dplanes; for (const auto& dp : m_doc.resolve_datum_planes()) dplanes.push_back(dp.second); // Parallel per-plane extents, in the SAME order resolve_datum_planes emits // (enabled Plane features, document order), so each datum draws at its u/v size. std::vector dsizes; for (const auto& f : m_doc.features) if (f.type == CadFeatureType::Plane && f.enabled) dsizes.emplace_back(f.plane_u_size, f.plane_v_size); m_viewport->set_datum_planes(std::move(dplanes), std::move(dsizes)); } void DesignPanel::update_datum_gizmo() { if (!m_viewport) return; if (m_active != Tool::Plane) { m_viewport->clear_datum_gizmo(); update_reference_planes(); return; } // Resolve the candidate plane's FRAME against the doc. resolve_datum_planes() is const and // doesn't rebuild bodies, so transiently swap/append the candidate to read its resolved frame, // then restore — works for both a fresh (uncommitted) plane and an edit of a committed one. CadFeature f = build_candidate(Tool::Plane); const bool editing = (m_edit_index >= 0 && m_edit_index < int(m_doc.features.size()) && m_doc.features[m_edit_index].type == CadFeatureType::Plane); CadFeature saved; int slot; if (editing) { saved = m_doc.features[m_edit_index]; m_doc.features[m_edit_index] = f; slot = m_edit_index; } else { m_doc.features.push_back(f); slot = int(m_doc.features.size()) - 1; } auto datums = m_doc.resolve_datum_planes(); // Ordinal of `slot` among enabled Plane features = its index in the resolved list. int ord = -1; for (int i = 0; i <= slot; ++i) if (m_doc.features[i].type == CadFeatureType::Plane && m_doc.features[i].enabled) ++ord; const bool ok = (ord >= 0 && ord < int(datums.size())); SketchPlane frame; if (ok) frame = datums[ord].second; if (editing) m_doc.features[m_edit_index] = saved; else m_doc.features.pop_back(); if (!ok) { m_viewport->clear_datum_gizmo(); update_reference_planes(); return; } // Offset arrow anchor = the base/face origin (datum origin walked back along its normal by the // offset). Works for both Offset-from-base (no tilt) and Offset-from-face exactly. const Vec3d anchor = frame.origin - frame.normal * f.plane_offset; const bool offset_on = (f.plane_type == PlaneType::Offset); m_viewport->set_datum_gizmo(frame, f.plane_u_size, f.plane_v_size, anchor, frame.normal, f.plane_offset, offset_on); update_reference_planes(); // base ghosts (origins + datums) follow the tool/model state } // Onshape default planes: the XY/XZ/YZ reference planes are persistent, transparent, labelled, and // larger than the bed — shown as the FALLBACK when there is no object yet. When the Plane tool is // open they additionally surface existing datums so a base can be picked. Single authority for the // reference-plane overlay (set/clear_base_pick). void DesignPanel::update_reference_planes() { if (!m_viewport) return; // Default planes pass through the modeling origin (bed centre) — same point the kernel uses to // resolve XY/XZ/YZ datum bases, so the ghosts, the datums and new sketches all coincide. const Vec3d o = m_doc.modeling_origin; SketchPlane xy = SketchPlane::XY(); xy.origin += o; SketchPlane xz = SketchPlane::XZ(); xz.origin += o; SketchPlane yz = SketchPlane::YZ(); yz.origin += o; std::vector bp = { xy, xz, yz }; std::vector bi = { 0, 1, 2 }; std::vector bl = { "XY", "XZ", "YZ" }; if (m_active == Tool::Plane) { // Base picking only makes sense for the Offset method (others reference faces/edges). if (m_plane_type && (PlaneType)m_plane_type->GetSelection() == PlaneType::Offset) { auto datums = m_doc.resolve_datum_planes(); // already in world coords (origin applied) for (int i = 0; i < int(datums.size()); ++i) { bp.push_back(datums[i].second); bi.push_back(3 + i); bl.push_back(datums[i].first); } m_viewport->set_base_pick(std::move(bp), std::move(bi), std::move(bl)); } else { m_viewport->clear_base_pick(); } return; } // Fallback (Onshape default planes): show the 3 reference planes while there is no SOLID body // yet — so they persist through the 2D-sketch phase and reappear after a sketch is confirmed // (a sketch creates no body). They no longer block selection: clicking existing geometry wins, // a base-plane pick only fires on a click that hit nothing else (see on_mouse fall-through). if (m_doc.bodies.empty()) m_viewport->set_base_pick(std::move(bp), std::move(bi), std::move(bl)); else m_viewport->clear_base_pick(); } void DesignPanel::refresh_preview() { if (m_active == Tool::None) { m_viewport->clear_preview(); return; } if (m_active == Tool::Sketch || m_active == Tool::Plane) { // A sketch / datum plane carries no 3D solid; there is no ghost to show. Always // valid, so just enable Confirm and clear any stale ghost. m_viewport->clear_preview(); m_status->SetForegroundColour(wxColour(120, 210, 120)); m_status->SetLabel(m_active == Tool::Plane ? _L("Plane ready") : _L("Sketch ready")); for (wxButton* b : m_confirm_btns) if (b) b->Enable(true); m_status->Refresh(); update_datum_gizmo(); // Plane card: show/refresh the in-canvas resize handles return; } // Trim m_body_xform to the LIVE committed bodies before building the ghost. A move // writes a per-body display transform keyed by index; if a moved body is later deleted // or consumed, its stale transform must not survive and get re-applied to whatever new // body lands at that index — that was painting fresh extrudes as a moved+rotated ghost // far from the sketch. resize() drops entries beyond the current body count. sync_body_xform(); CadFeature cand = build_candidate(m_active); TriangleMesh mesh; std::string err; bool ok = false; // The body is displayed through its per-body Move transform (m_body_xform); the ghost is // built from the untransformed kernel, so without this it floats back at the origin once a // body has been moved. Re-merge the per-body ghost meshes with the same transforms applied. auto ghost_from = [this](const std::vector& pbm) -> TriangleMesh { TriangleMesh out; for (size_t b = 0; b < pbm.size(); ++b) { TriangleMesh m = pbm[b]; if (b < m_body_xform.size()) m.transform(m_body_xform[b]); out.merge(m); } return out; }; const bool editing_single = (m_edit_index >= 0); if (editing_single) { // Edit-mode preview: stacking the candidate on top of the live body would // re-apply the feature being edited (fillet-on-fillet) — wrong, and a // source of OCCT failures. Instead evaluate the *replace* on a throwaway // copy so the ghost is the true post-edit body. CadDocument tmp = m_doc; ok = tmp.replace_feature(m_edit_index, cand); if (ok) mesh = ghost_from(tmp.display_body_meshes); else err = tmp.error; } else { std::vector pbm; ok = m_doc.preview(cand, mesh, pbm, err); if (ok) mesh = ghost_from(pbm); } if (ok) { m_viewport->set_preview_mesh(mesh); m_status->SetForegroundColour(wxColour(120, 210, 120)); // ok = green m_status->SetLabel(wxString::Format(_L("Preview — %zu triangles"), mesh.its.indices.size())); } else { m_viewport->clear_preview(); m_status->SetForegroundColour(wxColour(235, 110, 110)); // invalid = red m_status->SetLabel(_L("Invalid: ") + wxString::FromUTF8(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); // 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. m_viewport->set_body_hidden((m_active == Tool::Dressup || m_active == Tool::Draft) && ok); m_status->Refresh(); // Refresh the in-canvas Extrude depth arrow (self-gates: only while the Extrude card is open). update_extrude_gizmo(); // Same for the Fillet/Chamfer radius arrow (self-gates: Dressup card + a picked edge). update_fillet_gizmo(); // Same for the Hole footprint circle + diameter/depth arrows (self-gates: Hole card). update_hole_gizmo(); // Same for the Thread footprint circle + radius/length arrows (self-gates: Thread card). update_thread_gizmo(); // Same for the Shell thickness arrow on the picked face (self-gates: Shell card + a face). update_shell_gizmo(); // Same for the Revolve angle-arc around the axis (self-gates: only while the Revolve card is open). update_revolve_gizmo(); // Same for the Draft angle-arc around the face centroid (self-gates: only while the Draft card is open). update_draft_gizmo(); // Same for the Cut plane offset arrow + rectangle (self-gates: only while the Cut card is open). update_cut_gizmo(); // Operand highlight for Boolean (body tints) / Sweep / Loft (sketch tints); self-gates by tool. update_operand_highlight(); // Same for the Pattern spacing arrow / angle-arc (self-gates: only while the Pattern card is open). update_pattern_gizmo(); // Datum-plane resize handles (self-gates: only while the Plane card is open). update_datum_gizmo(); } void DesignPanel::open_tool(Tool t) { m_active = t; // 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. if (m_viewport) { m_viewport->set_body_translucent(false); m_viewport->set_body_hidden(false); } wxSizer* s = m_form->GetSizer(); s->Show(m_box_sketch, t == Tool::Sketch, true); s->Show(m_box_extrude, t == Tool::Extrude, true); s->Show(m_box_dressup, t == Tool::Dressup, true); s->Show(m_box_hole, t == Tool::Hole, true); s->Show(m_box_thread, t == Tool::Thread, true); s->Show(m_box_shell, t == Tool::Shell, true); s->Show(m_box_revolve, t == Tool::Revolve, true); s->Show(m_box_sweep, t == Tool::Sweep, true); s->Show(m_box_pattern, t == Tool::Pattern, true); s->Show(m_box_plane, t == Tool::Plane, true); s->Show(m_box_loft, t == Tool::Loft, true); s->Show(m_box_boolean, t == Tool::Boolean, true); s->Show(m_box_cut, t == Tool::Cut, true); s->Show(m_box_draft, t == Tool::Draft, true); s->Show(m_box_insert, t == Tool::Insert, true); if (t == Tool::Revolve && m_revolve_sketch_ref >= 0 && m_revolve_sketch_ref < int(m_doc.features.size())) m_revolve_sketch_label->SetLabel(_L("Sketch: ") + wxString::FromUTF8(m_doc.features[m_revolve_sketch_ref].name)); if (t == Tool::Sweep) { if (m_sweep_profile_ref >= 0 && m_sweep_profile_ref < int(m_doc.features.size())) m_sweep_profile_label->SetLabel(_L("Profile: ") + wxString::FromUTF8(m_doc.features[m_sweep_profile_ref].name)); // Populate the path picker with every Sketch feature except the profile itself; // the feature index rides in the entry's client data. Pre-select the stored path // (re-edit), else the first available sketch. m_sweep_path->Clear(); int sel_idx = wxNOT_FOUND; for (int i = 0; i < int(m_doc.features.size()); ++i) { const CadFeature& sf = m_doc.features[i]; if (sf.type != CadFeatureType::Sketch || i == m_sweep_profile_ref) continue; const int pos = m_sweep_path->Append(wxString::FromUTF8(sf.name), reinterpret_cast(intptr_t(i))); if (i == m_sweep_path_ref) sel_idx = pos; } if (sel_idx != wxNOT_FOUND) m_sweep_path->SetSelection(sel_idx); else if (m_sweep_path->GetCount() > 0) m_sweep_path->SetSelection(0); } if (t == Tool::Loft) { // List every Sketch feature; the feature index for each row rides in // m_loft_sketch_idx. Re-check the stored profile refs (re-edit). m_loft_list->Clear(); m_loft_sketch_idx.clear(); for (int i = 0; i < int(m_doc.features.size()); ++i) { const CadFeature& sf = m_doc.features[i]; if (sf.type != CadFeatureType::Sketch) continue; const int row = m_loft_list->Append(wxString::FromUTF8(sf.name)); m_loft_sketch_idx.push_back(i); if (std::find(m_loft_refs.begin(), m_loft_refs.end(), i) != m_loft_refs.end()) m_loft_list->Check(row, true); } } if (t == Tool::Extrude) { if (m_extrude_face_src >= 0) m_extrude_sketch_label->SetLabel( wxString::Format(_L("Face %d (push/pull)"), m_extrude_face_src)); 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.) if (m_edit_index < 0) { const bool on_face_import = m_extrude_sketch_ref >= 0 && m_extrude_sketch_ref < int(m_doc.features.size()) && m_doc.features[m_extrude_sketch_ref].import_on_face; 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 { m_mode->SetSelection(0); // New body (was: Add when a body already existed) } } } // Retitle the active card's header: edit-mode shows the feature's real name, // add-mode previews the type + next feature number (Onshape "Extrude 1"). const bool editing = (m_edit_index >= 0 && m_edit_index < int(m_doc.features.size())); auto title = [&](const wxString& base) -> wxString { return editing ? wxString::FromUTF8(m_doc.features[m_edit_index].name) : base + wxString::Format(" %d", m_feature_counter + 1); }; switch (t) { case Tool::Sketch: m_hdr_sketch->SetLabel(title(_L("Sketch"))); break; case Tool::Extrude: m_hdr_extrude->SetLabel(title(_L("Extrude"))); break; case Tool::Dressup: m_hdr_dressup->SetLabel(title( m_dressup_type->GetSelection() == 0 ? _L("Fillet") : _L("Chamfer"))); break; case Tool::Hole: m_hdr_hole->SetLabel(title(_L("Hole"))); break; case Tool::Thread: m_hdr_thread->SetLabel(title(_L("Thread"))); break; case Tool::Shell: m_hdr_shell->SetLabel(title(_L("Shell"))); break; case Tool::Revolve: m_hdr_revolve->SetLabel(title(_L("Revolve"))); break; case Tool::Sweep: m_hdr_sweep->SetLabel(title(_L("Sweep"))); break; case Tool::Pattern: m_hdr_pattern->SetLabel(title(_L("Pattern"))); break; case Tool::Plane: m_hdr_plane->SetLabel(title(_L("Plane"))); break; case Tool::Loft: m_hdr_loft->SetLabel(title(_L("Loft"))); break; case Tool::Draft: m_hdr_draft->SetLabel(title(_L("Draft"))); break; case Tool::Boolean: m_hdr_boolean->SetLabel(title(_L("Boolean"))); break; case Tool::Cut: m_hdr_cut->SetLabel(title(_L("Cut"))); break; case Tool::Insert: break; // header set by open_insert_card() case Tool::None: break; } m_form->Layout(); m_form->FitInside(); update_action_bar(); // a tool is now active -> show the unified ✓/✗ refresh_preview(); } void DesignPanel::close_tool() { m_active = Tool::None; set_active_tool_btn(nullptr); // clear the active-tool teal highlight if (m_viewport) { m_viewport->set_body_translucent(false); m_viewport->set_body_hidden(false); } // restore the opaque solid wxSizer* s = m_form->GetSizer(); s->Show(m_box_sketch, false, true); s->Show(m_box_extrude, false, true); s->Show(m_box_dressup, false, true); s->Show(m_box_hole, false, true); s->Show(m_box_thread, false, true); s->Show(m_box_shell, false, true); s->Show(m_box_revolve, false, true); s->Show(m_box_sweep, false, true); s->Show(m_box_pattern, false, true); s->Show(m_box_plane, false, true); s->Show(m_box_loft, false, true); s->Show(m_box_boolean, false, true); s->Show(m_box_cut, false, true); s->Show(m_box_draft, false, true); s->Show(m_box_insert, false, true); m_viewport->clear_preview(); m_viewport->clear_extrude_gizmo(); m_viewport->clear_fillet_gizmo(); m_viewport->clear_hole_gizmo(); m_viewport->clear_thread_gizmo(); m_viewport->clear_shell_gizmo(); m_viewport->clear_revolve_gizmo(); m_viewport->clear_draft_gizmo(); m_viewport->clear_cut_gizmo(); m_viewport->clear_pattern_gizmo(); m_viewport->set_operand_bodies(-1, -1); m_viewport->set_highlight_sketches({}); m_viewport->clear_datum_gizmo(); update_reference_planes(); // back to no-tool: show the origin planes if there is no object yet m_form->Layout(); m_form->FitInside(); update_action_bar(); // no feature tool active -> hide the bar (unless a mode keeps it) } void DesignPanel::confirm_tool() { // One undo boundary per committed feature (Extrude/Dressup/Hole/Thread/Shell, the // legacy Sketch card via on_add_sketch, and edit-mode replace all funnel here). m_doc.checkpoint(); const bool editing_single = (m_edit_index >= 0); if (editing_single) { // Edit mode: overwrite the existing feature instead of appending. CadFeature cand = build_candidate(m_active); bool ok = m_doc.replace_feature(m_edit_index, cand); reset_edit_state(); close_tool(); // clears the preview ghost after_tree_edit(ok); // refresh tree/viewport/status (or "Edit rejected") return; } switch (m_active) { case Tool::Sketch: on_add_sketch(); break; case Tool::Extrude: on_add_extrude(); break; case Tool::Dressup: on_add_dressup(); break; case Tool::Hole: on_add_hole(); break; case Tool::Thread: on_add_thread(); break; case Tool::Shell: on_add_shell(); break; case Tool::Revolve: on_add_revolve(); break; case Tool::Sweep: on_add_sweep(); break; case Tool::Pattern: on_add_pattern(); break; case Tool::Plane: on_add_plane(); break; case Tool::Loft: on_add_loft(); break; case Tool::Draft: on_add_draft(); break; case Tool::Boolean: on_add_boolean(); break; case Tool::Cut: on_add_cut(); break; case Tool::Insert: return; // committed via finalize_insert(), never here case Tool::None: return; } close_tool(); // also clears the preview ghost; the committed body is now shown } void DesignPanel::cancel_tool() { reset_edit_state(); // abort an in-progress edit: back to add-mode 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); m_status->SetLabel(wxString()); m_status->Refresh(); } // One Confirm surface for the whole tab. Routes to the right commit by current context: // a feature card, the Insert placement, the Sketch session, or the Constrain session. void DesignPanel::tool_confirm() { if (m_value_cont) { confirm_value(); return; } // value card owns ribbon ✓ while a value is pending if (m_viewport && m_viewport->moving_body()) { // keep the placement, drop the gizmo m_viewport->clear_move_gizmo(); m_move_body = -1; update_action_bar(); set_status_ok(); return; } 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(); set_ui_mode(UiMode::Feature); return; } if (m_ui_mode == UiMode::Constrain) { cancel_value(); if (m_viewport) m_viewport->end_constrain(); m_constrain_feat = -1; set_ui_mode(UiMode::Feature); m_status->SetForegroundColour(wxNullColour); m_status->SetLabel(wxString()); m_status->Refresh(); } } // One Cancel/exit surface (also bound to Esc). Discards the active feature/insert, or a // drawn-but-uncommitted Sketch, or exits Constrain. void DesignPanel::tool_cancel() { if (m_value_cont) { cancel_value(); return; } // value card owns ribbon ✗ while a value is pending if (m_viewport && m_viewport->moving_body()) { // revert to the pose at move-start sync_body_xform(); if (m_move_body >= 0 && m_move_body < int(m_body_xform.size())) m_body_xform[m_move_body] = m_move_prev; m_viewport->clear_move_gizmo(); m_move_body = -1; feed_bodies(); // re-render the reverted placement update_action_bar(); m_status->SetForegroundColour(wxNullColour); m_status->SetLabel(_L("Move cancelled")); m_status->Refresh(); return; } if (m_active == Tool::Insert) { cancel_insert(); return; } if (m_active != Tool::None) { cancel_tool(); return; } if (m_ui_mode == UiMode::Sketch) { if (m_viewport) m_viewport->cancel_sketch(); // drop the live session (committed art stays) m_edit_index = -1; set_ui_mode(UiMode::Feature); sync_sketch_display(); refresh_tree(); m_status->SetForegroundColour(wxNullColour); m_status->SetLabel(wxString()); m_status->Refresh(); return; } if (m_ui_mode == UiMode::Constrain) { cancel_value(); if (m_viewport) m_viewport->end_constrain(); m_constrain_feat = -1; set_ui_mode(UiMode::Feature); m_status->SetForegroundColour(wxNullColour); m_status->SetLabel(wxString()); m_status->Refresh(); } } 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()); } void DesignPanel::update_action_bar() { update_undo_redo_buttons(); // mode/tool changes flip the do_undo_redo gate -> refresh greying if (m_tb_action == nullptr || m_toolbar == nullptr) return; wxSizer* s = m_toolbar->GetSizer(); if (s == nullptr) return; const bool active = (m_active != Tool::None) || m_ui_mode == UiMode::Sketch || m_ui_mode == UiMode::Constrain || (m_viewport && m_viewport->moving_body()); s->Show(m_tb_action, active, true); m_toolbar->Layout(); m_toolbar->FitInside(); // refresh scroll range when the action bar shows/hides } void DesignPanel::do_undo_redo(bool redo) { // v1: act only in Feature mode. While authoring/constraining a sketch (m_ui_mode) or // with a feature dialog open (m_active), Esc/Cancel is the way out — popping committed // history mid-tool would be ambiguous (and could orphan the tool's referenced feature). if (m_ui_mode != UiMode::Feature || m_active != Tool::None) { m_status->SetForegroundColour(wxNullColour); m_status->SetLabel(_L("Finish or cancel the current tool first (Esc)")); m_status->Refresh(); return; } const bool ok = redo ? m_doc.redo() : m_doc.undo(); if (!ok) { m_status->SetForegroundColour(wxNullColour); m_status->SetLabel(redo ? _L("Nothing to redo") : _L("Nothing to undo")); m_status->Refresh(); 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; reset_edit_state(); after_tree_edit(true); // refresh tree + viewport meshes + status from the restored doc m_status->SetForegroundColour(wxNullColour); m_status->SetLabel(wxString::Format(redo ? _L("Redo (%zu more)") : _L("Undo (%zu more)"), redo ? m_doc.redo_depth() : m_doc.undo_depth())); m_status->Refresh(); } }} // namespace Slic3r::GUI