mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-18 14:32:36 +00:00
Hole: with no face explicitly picked, the tool fell back to the XY datum at z=0 (the model's underside), so placing a hole from a top view read parallax- shifted. Default to the solid's top face (top_face_index_of) so the footprint sits on the surface being viewed; the XY/XZ/YZ dropdown still overrides. Internal thread: the bore was re-cut at the nominal radius, which coincides with an existing hole's wall — the coincident faces fouled the groove boolean so it removed ~nothing (invisible thread). Cut the bore at the minor diameter (radius - depth) instead: strictly inside any existing wall, leaving it clean for the groove; on solid stock it forms the tap-drill. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
6078 lines
303 KiB
C++
6078 lines
303 KiB
C++
#include "DesignPanel.hpp"
|
|
#include "DesignCanvas.hpp"
|
|
#include "DesignSketchTool.hpp"
|
|
#include "libslic3r/GeometryEngine.hpp" // face_by_index for face-extrude gizmo anchor
|
|
|
|
#include <wx/sizer.h>
|
|
#include <wx/button.h>
|
|
#include <wx/stattext.h>
|
|
#include <wx/choice.h>
|
|
#include <wx/checkbox.h>
|
|
#include <wx/checklst.h>
|
|
#include <wx/spinctrl.h>
|
|
#include <wx/treectrl.h>
|
|
#include <wx/imaglist.h>
|
|
#include <wx/statline.h>
|
|
#include <wx/statbmp.h>
|
|
#include <wx/image.h>
|
|
#include <wx/font.h>
|
|
#include <wx/textdlg.h>
|
|
#include <wx/filedlg.h>
|
|
#include <wx/dialog.h>
|
|
#include <wx/colordlg.h>
|
|
#include <wx/menu.h>
|
|
|
|
#include <string>
|
|
#include <memory>
|
|
#include <functional>
|
|
#include <cmath>
|
|
#include <cstdio>
|
|
#include <algorithm>
|
|
|
|
#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<Vec3d> 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<void()> action; int key = 0; };
|
|
struct FeatFlyout {
|
|
std::vector<DropDown::Item> items; // mainline DropDown is Item-based (text/tip/icon per row)
|
|
std::vector<std::function<void()>> actions;
|
|
std::vector<std::string> 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<FeatVar> 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<FeatFlyout>();
|
|
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<void()> 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<void()> 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<void()> 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<void()> 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<void()> 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<DropDown::Item> items; // mainline DropDown is Item-based (text/tip/icon per row)
|
|
std::vector<DesignSketchTool::Mode> modes;
|
|
std::vector<wxString> hints;
|
|
std::vector<std::string> 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<SkVar> 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<ToolFlyout>();
|
|
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<SketchEntity>& ents,
|
|
const std::vector<SketchEntityConstraintDef>& 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<wxTextCtrl*>(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<bool> 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<std::vector<SketchEntity>> 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<SketchEntity>& a, const std::vector<SketchEntity>& 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<DesignSketchTool::DisplaySketch> 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<char> drop(f.entities.size(), 0);
|
|
for (const std::vector<int>& loop : m_viewport->region_entity_indices(f.entities)) {
|
|
std::vector<SketchEntity> 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<SketchEntity>& 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<SketchEntity> 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<SketchEntity> 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<TopoDS_Shape> 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<std::vector<std::vector<Vec2d>>>& 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<BooleanMode>(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<ExtrudeEnd>(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<FaceGroup>(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<BooleanMode>(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<intptr_t>(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<BooleanMode>(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<int> 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<BooleanMode>(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<CadBody>* src = &m_doc.bodies;
|
|
std::vector<CadBody> 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<TriangleMesh>& 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<R, Vec2d> aps[2] = {{R::P0, A.p0}, {R::P1, A.p1}};
|
|
const std::pair<R, Vec2d> 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<SketchEntityConstraintDef> 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<SketchEntityConstraintDef>& 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<SketchEntity> 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<SketchEntity> 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<SketchEntityConstraintDef> empty;
|
|
const std::vector<SketchEntityConstraintDef>& 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<int> 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<SketchEntity> 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<std::vector<SketchEntityConstraintDef>> 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<SketchEntity> 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<SketchEntity> 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<SketchEntity> 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<void(double)> cont,
|
|
std::function<void()> 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<Vec2d> 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<int>(f.mode)); // New=0,Add=1,Cut=2,Intersect=3
|
|
m_extrude_end->SetSelection(static_cast<int>(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<int>(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<int>(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<int>(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<int>(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<ExtrudeEnd>(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<FaceGroup>(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<BooleanMode>(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<intptr_t>(m_sweep_path->GetClientData(sel))) : -1;
|
|
f.mode = static_cast<BooleanMode>(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<BooleanMode>(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<std::pair<int, ColorRGBA>> 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<SketchEntity> 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<ExtrudeEnd>(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<SketchPlane> 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<Vec2d> 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<SketchPlane> bp = { xy, xz, yz };
|
|
std::vector<int> bi = { 0, 1, 2 };
|
|
std::vector<std::string> 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<TriangleMesh>& 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<TriangleMesh> 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<void*>(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
|