mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-18 14:32:36 +00:00
DesignPanel.cpp was never listed in localization/i18n/list.txt, and xgettext
extracts only what that file names. All 934 of the Design tab's _L() calls
were therefore invisible to every translator in every language — not merely
untranslated, unextractable. Adding the one line is the actual fix; the rest
follows from it.
Regenerating the .pot brings the catalogue from 6007 to 6536 msgids. 549 of
the new ones are now translated into Italian: 5230 to 5779 translated
messages. Verified no existing work was destroyed — every msgid that survived
into the new .pot kept its translation, and the 36 that lost one are genuinely
gone from the sources. msgfmt --check-format passes, which matters here
because a large share of these carry %d / %s / %zu.
CAD terms follow Italian CAD convention rather than literal glosses: Fillet ->
Raccordo, Chamfer -> Smusso, Draft -> Sformo, Rib -> Nervatura, Mate ->
Accoppiamento, Shell -> Svuotamento, Pattern -> Serie, Sheet body -> Corpo
superficie. Strings identical in both languages are deliberately left
untranslated so gettext falls back to the msgid.
The long mixed-filament / Local-Z dithering tooltips are left untranslated on
purpose: slicer internals, outside a Design i18n task, and untranslated before
this commit too.
One string changed rather than translated. The Coord Sys hint read "Without an
edge the frame's rotation about its normal follows world X, not the body" —
that described the defect fixed in 1726e93760, so it was a lie as of that
commit. It now says X comes from the face's first edge.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
9014 lines
453 KiB
C++
9014 lines
453 KiB
C++
#include "DesignPanel.hpp"
|
|
#include "DesignCanvas.hpp"
|
|
#include "DesignSketchTool.hpp"
|
|
#include "libslic3r/GeometryEngine.hpp" // face_by_index for face-extrude gizmo anchor
|
|
#include "libslic3r/TriangleMesh.hpp" // mesh import: STL/OBJ -> indexed_triangle_set
|
|
#include "libslic3r/Format/OBJ.hpp"
|
|
|
|
#include <boost/algorithm/string/case_conv.hpp>
|
|
#include <boost/filesystem/path.hpp>
|
|
#include <Standard_Failure.hxx>
|
|
|
|
#include <map>
|
|
#include <wx/sizer.h>
|
|
#include <wx/button.h>
|
|
#include <wx/stattext.h>
|
|
#include <wx/checkbox.h>
|
|
#include <wx/checklst.h>
|
|
#include <wx/spinctrl.h>
|
|
#include <wx/listctrl.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 <wx/progdlg.h>
|
|
#include <wx/utils.h> // wxWindowDisabler, wxMilliSleep
|
|
#include <wx/msgdlg.h> // wxMessageBox
|
|
|
|
#include <string>
|
|
#include <memory>
|
|
#include <functional>
|
|
#include <cmath>
|
|
#include <cstdio>
|
|
#include <algorithm>
|
|
#include <thread>
|
|
#include <atomic>
|
|
|
|
#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 "Widgets/Button.hpp" // Orca-styled Button (ButtonStyle/ButtonType) — same look as Prepare
|
|
#include "Widgets/CheckBox.hpp" // Orca teal check (label lives in the row's left column)
|
|
#include "Widgets/ComboBox.hpp" // Orca dropdown — replaces wxChoice in every Design card
|
|
#include "Widgets/StaticBox.hpp" // Prepare's rounded white card frame around each tool dialog
|
|
#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 {
|
|
|
|
// Mesh -> B-rep import defaults (see GeometryEngine::mesh_to_brep).
|
|
// Tolerance is a vertex-dedup cell, not a sew tolerance: 10 um is well under any printable
|
|
// feature yet coarse enough to weld the float noise a mesh exporter leaves on shared vertices.
|
|
static constexpr double MESH_IMPORT_TOLERANCE = 0.01; // mm
|
|
// Merge coplanar neighbours so the body has real, pickable faces instead of one face per
|
|
// triangle. 5 deg tolerates the small normal jitter of an exported/scanned flat face while
|
|
// still keeping genuinely curved regions faceted.
|
|
static constexpr double MESH_IMPORT_MERGE_ANGLE_DEG = 5.0;
|
|
// Above this, warn before converting: the build is one OCCT face per triangle.
|
|
static constexpr size_t MESH_IMPORT_TRIANGLE_WARN = 50000;
|
|
|
|
// Two-column parameter form (label left, control right), matching Prepare's row idiom. The
|
|
// control column grows, so every control lines up on the panel's right edge instead of sitting
|
|
// at its natural width beside the label — the forms used a plain non-growable grid before, which
|
|
// is why Design's rows looked nothing like Prepare's.
|
|
static wxFlexGridSizer* two_col_form()
|
|
{
|
|
auto* f = new wxFlexGridSizer(2, 6, 8);
|
|
f->AddGrowableCol(1, 1);
|
|
f->SetFlexibleDirection(wxHORIZONTAL);
|
|
return f;
|
|
}
|
|
|
|
|
|
// Orca's dropdown, sized like Prepare's sidebar combos. Read-only: every Design
|
|
// picker is a fixed list, never free text.
|
|
static ComboBox* make_combo(wxWindow* parent)
|
|
{
|
|
// Explicit width: ComboBox's natural best size is its widest item, which inflated the
|
|
// sidebar's virtual width past the viewport and left the panel horizontally scrolled —
|
|
// that is what clipped the first letter of every label. The form column grows anyway.
|
|
auto* c = new ComboBox(parent, wxID_ANY, wxEmptyString, wxDefaultPosition,
|
|
wxSize(parent->FromDIP(90), parent->FromDIP(24)), 0, nullptr, wxCB_READONLY);
|
|
c->SetMinSize(wxSize(parent->FromDIP(90), parent->FromDIP(24)));
|
|
return c;
|
|
}
|
|
|
|
// 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); }
|
|
|
|
// Prepare's control-outline grey, sampled from its sidebar: #4A4A51 on the #2D2D31 dark
|
|
// panel, #DBDBDB on light. Every framed thing in Design uses this so the tab matches.
|
|
static wxColour dp_border_col() { return dp_dark() ? wxColour(0x4A,0x4A,0x51) : wxColour(0xDB,0xDB,0xDB); }
|
|
|
|
// A tool card: Prepare's rounded white-bordered panel (Plater.cpp's panel_printer_preset
|
|
// idiom — radius 8, #EEEEEE border, green on hover). Every card's controls are parented
|
|
// to it, so the border actually encloses them.
|
|
static StaticBox* make_card(wxWindow* parent)
|
|
{
|
|
auto* c = new StaticBox(parent);
|
|
c->SetCornerRadius(8);
|
|
c->SetBorderColorNormal(dp_border_col()); // no hover accent: the frame is not clickable
|
|
return c;
|
|
}
|
|
|
|
// Prepare outlines every numeric field (rounded, #4A4A51 on dark). wxSpinCtrlDouble is a
|
|
// native GTK control that cannot draw that, and Orca's own SpinInput is int-only — it would
|
|
// silently truncate a 2.5 mm radius. So the double spin keeps its arrows and validation and
|
|
// sits inside an Orca StaticBox that supplies the frame. spin_frame() returns that box, which
|
|
// is what goes into the layout sizer.
|
|
static wxSpinCtrlDouble* make_spin(wxWindow* parent, double val,
|
|
double mn = 0.1, double mx = 1000.0)
|
|
{
|
|
auto* box = new StaticBox(parent);
|
|
box->SetCornerRadius(4);
|
|
box->SetBorderColorNormal(dp_border_col());
|
|
auto* s = new wxSpinCtrlDouble(box, wxID_ANY, "", wxDefaultPosition, wxSize(90, -1),
|
|
wxSP_ARROW_KEYS | wxBORDER_NONE);
|
|
s->SetRange(mn, mx);
|
|
s->SetDigits(2);
|
|
s->SetValue(val);
|
|
auto* sz = new wxBoxSizer(wxHORIZONTAL);
|
|
sz->Add(s, 1, wxEXPAND | wxALL, parent->FromDIP(2));
|
|
box->SetSizer(sz);
|
|
return s;
|
|
}
|
|
|
|
// The framed spin's parent IS its frame; lay that out instead of the bare control.
|
|
static wxWindow* spin_frame(wxWindow* spin) { return spin->GetParent(); }
|
|
|
|
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 ComboBox 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's main toolbar: 40 px icon cell, 4 px gap (GLToolbar::Default_Icons_Size
|
|
// and set_gap_size(4)) -> 44 px pitch. Match it exactly.
|
|
auto* b = new ScalableButton(m_toolbar, wxID_ANY, icon, "", wxSize(40, 40),
|
|
wxDefaultPosition, wxBU_EXACTFIT | wxBORDER_NONE, false, 34);
|
|
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());
|
|
show_polygon_card(mode == DesignSketchTool::Mode::Polygon);
|
|
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) {
|
|
push_polygon_params();
|
|
}
|
|
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);
|
|
// Buttons are created in whatever order reads best in code, but laid out in the order the
|
|
// user specified. fadd() records into tb_slot; the flush below the doc group emits them.
|
|
// A dropdown occupies two entries (button + chevron) that must stay adjacent.
|
|
std::map<std::string, std::vector<wxWindow*>> tb_slot;
|
|
auto fadd = [&tb_slot](const char* id, wxWindow* w) { tb_slot[id].push_back(w); };
|
|
m_tb_feature->Add(caption(_L("FEATURES")), 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 8);
|
|
{
|
|
// Onshape-style FEATURE flyouts: same themed-DropDown pattern as the sketch toolbar
|
|
// (tinted glyphs, Body_14 measure, content-width popup) but each entry runs an
|
|
// 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* id, 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(id, b);
|
|
auto* chev = new wxStaticText(m_toolbar, wxID_ANY, wxString::FromUTF8("\xE2\x96\xBE"));
|
|
chev->SetForegroundColour(dp_sec_text());
|
|
chev->SetFont(Label::Body_9);
|
|
fadd(id, chev);
|
|
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("sketch", b_sketch);
|
|
// Add material: every feature that grows new solid material — from a profile
|
|
// (extrude/revolve/sweep/loft), from a face (thicken) or from a line (rib).
|
|
feat_dropdown("material", "design_extrude", _L("Add material (extrude / revolve / sweep / loft / thicken / rib)"), {
|
|
{"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')},
|
|
{"design_thicken", _L("Thicken"), _L("Offset a solid face into a thin plate (new body)"),
|
|
[this] {
|
|
if (m_doc.bodies.empty()) {
|
|
m_status->SetForegroundColour(wxColour(235, 110, 110));
|
|
m_status->SetLabel(_L("Thicken needs a solid body — add or import one first"));
|
|
m_status->Refresh();
|
|
return;
|
|
}
|
|
{
|
|
m_thicken_body->Clear();
|
|
for (size_t i = 0; i < m_doc.bodies.size(); ++i) {
|
|
const std::string& n = m_doc.bodies[i].name;
|
|
m_thicken_body->Append(n.empty() ? wxString::Format(_L("Body %zu"), i + 1) : wxString::FromUTF8(n));
|
|
}
|
|
if (m_thicken_body->GetCount() > 0) m_thicken_body->SetSelection(0);
|
|
}
|
|
m_sel_solid_face = -1;
|
|
m_thicken_face_label->SetLabel(_L("(pick a solid face)"));
|
|
open_tool(Tool::Thicken);
|
|
}, 0},
|
|
{"design_rib", _L("Rib"), _L("Grow a thin wall from an open sketch line, fused to a body"),
|
|
[this] {
|
|
if (m_doc.bodies.empty()) {
|
|
m_status->SetForegroundColour(wxColour(235, 110, 110));
|
|
m_status->SetLabel(_L("Rib needs a solid body — add or import one first"));
|
|
m_status->Refresh();
|
|
return;
|
|
}
|
|
{
|
|
m_rib_body->Clear();
|
|
for (size_t i = 0; i < m_doc.bodies.size(); ++i) {
|
|
const std::string& n = m_doc.bodies[i].name;
|
|
m_rib_body->Append(n.empty() ? wxString::Format(_L("Body %zu"), i + 1) : wxString::FromUTF8(n));
|
|
}
|
|
if (m_rib_body->GetCount() > 0) m_rib_body->SetSelection(0);
|
|
}
|
|
{
|
|
m_rib_sketch->Clear();
|
|
for (int i = 0; i < int(m_doc.features.size()); ++i) {
|
|
// 3-arg Append: ComboBox's own Append(text, bitmap) hides
|
|
// wxItemContainer's (text, void*) — see the Sweep picker.
|
|
// Project features too: they carry Line entities the kernel ribs from
|
|
// just like a drawn sketch, so a projected body edge is a valid path.
|
|
if (m_doc.features[i].type == CadFeatureType::Sketch ||
|
|
m_doc.features[i].type == CadFeatureType::Project)
|
|
m_rib_sketch->Append(wxString::FromUTF8(m_doc.features[i].name), wxNullBitmap,
|
|
reinterpret_cast<void*>(intptr_t(i)));
|
|
}
|
|
if (m_rib_sketch->GetCount() > 0) m_rib_sketch->SetSelection(0);
|
|
else {
|
|
m_status->SetForegroundColour(wxColour(235, 110, 110));
|
|
m_status->SetLabel(_L("Create a sketch with an open line first"));
|
|
m_status->Refresh();
|
|
return;
|
|
}
|
|
}
|
|
open_tool(Tool::Rib);
|
|
}, 0},
|
|
});
|
|
|
|
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("pattern", b_pattern);
|
|
|
|
// Surface: sheet-body tools (extrude / revolve / loft / fill / offset / thicken)
|
|
// The drawer BUTTON is design_surface, not design_extrude: sharing a face with the
|
|
// Add-material drawer made the two buttons indistinguishable in the bar. The entries
|
|
// inside may reuse the solid glyphs — a menu row carries its own text label.
|
|
feat_dropdown("surface", "design_surface", _L("Surface (extrude / revolve / loft / fill / offset / thicken)"), {
|
|
{"design_extrude", _L("Surface Extrude"), _L("Extrude a sketch into a sheet body (no end caps)"),
|
|
[this] {
|
|
m_surf_extrude_sketch_ref = resolve_extrude_sketch();
|
|
if (m_surf_extrude_sketch_ref < 0) {
|
|
m_status->SetForegroundColour(wxColour(235, 110, 110));
|
|
m_status->SetLabel(_L("Create a sketch first"));
|
|
m_status->Refresh();
|
|
return;
|
|
}
|
|
open_tool(Tool::SurfaceExtrude);
|
|
}, SHIFT('G')},
|
|
{"design_revolve", _L("Surface Revolve"), _L("Revolve a sketch profile into a sheet body"),
|
|
[this] {
|
|
m_surf_revolve_sketch_ref = resolve_extrude_sketch();
|
|
if (m_surf_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::SurfaceRevolve);
|
|
}, SHIFT('J')},
|
|
{"design_loft", _L("Surface Loft"), _L("Loft (skin) between 2+ profiles, open (no end caps)"),
|
|
[this] {
|
|
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_surf_loft_refs.clear();
|
|
open_tool(Tool::SurfaceLoft);
|
|
}, SHIFT('O')},
|
|
{"design_surface", _L("Surface Fill"), _L("Fill a sketch boundary with a smooth face"),
|
|
[this] {
|
|
m_surf_fill_sketch_ref = resolve_extrude_sketch();
|
|
if (m_surf_fill_sketch_ref < 0) {
|
|
m_status->SetForegroundColour(wxColour(235, 110, 110));
|
|
m_status->SetLabel(_L("Create a sketch profile to fill first"));
|
|
m_status->Refresh();
|
|
return;
|
|
}
|
|
open_tool(Tool::SurfaceFill);
|
|
}, SHIFT('Q')},
|
|
{"design_offset", _L("Surface Offset"), _L("Offset a sheet body's shell by a signed distance"),
|
|
[this] {
|
|
populate_sheet_body_choices(m_surf_offset_body);
|
|
if (m_surf_offset_body->GetCount() == 0) {
|
|
m_status->SetForegroundColour(wxColour(235, 110, 110));
|
|
m_status->SetLabel(_L("No sheet body to offset — create a surface feature first"));
|
|
m_status->Refresh();
|
|
return;
|
|
}
|
|
open_tool(Tool::SurfaceOffset);
|
|
}, SHIFT('U')},
|
|
{"design_thicken", _L("Thicken Surface"), _L("Thicken a sheet body into a solid"),
|
|
[this] {
|
|
populate_sheet_body_choices(m_surf_thicken_body);
|
|
if (m_surf_thicken_body->GetCount() == 0) {
|
|
m_status->SetForegroundColour(wxColour(235, 110, 110));
|
|
m_status->SetLabel(_L("No sheet body to thicken — create a surface feature first"));
|
|
m_status->Refresh();
|
|
return;
|
|
}
|
|
open_tool(Tool::ThickenSurface);
|
|
}, SHIFT('V')},
|
|
});
|
|
|
|
feat_dropdown("plane", "design_plane", _L("Datum / Curve (plane / axis / coord sys / helix / project)"), {
|
|
{"design_plane", _L("Plane"), _L("Reference plane (offset / tilt / midplane / tangent / two edges / coincident)"),
|
|
[this] {
|
|
populate_plane_choices(m_plane_base);
|
|
reset_plane_refs();
|
|
open_tool(Tool::Plane);
|
|
}, SHIFT('P')},
|
|
{"design_line", _L("Axis"), _L("Datum axis (two points, face normal, cylinder centerline, two planes, along edge)"),
|
|
[this] {
|
|
populate_plane_choices(m_axis_plane_a);
|
|
populate_plane_choices(m_axis_plane_b);
|
|
reset_axis_refs();
|
|
open_tool(Tool::Axis);
|
|
}, SHIFT('A')},
|
|
{"design_point", _L("Coord Sys"), _L("Datum coordinate system (world point, or face + direction edge)"),
|
|
[this] {
|
|
reset_coordsys_refs();
|
|
open_tool(Tool::CoordSys);
|
|
}, SHIFT('C')},
|
|
{"design_thread", _L("Helix"), _L("Helical curve (spring path) — use as a sweep path for coils / springs / augers"),
|
|
[this] {
|
|
populate_plane_choices(m_helix_plane);
|
|
open_tool(Tool::Helix);
|
|
}, 0},
|
|
// Project belongs here, not in Dress-up: it consumes a body but PRODUCES sketch
|
|
// geometry, so it is reference/curve creation like the four above, not a finishing op.
|
|
{"design_sketch", _L("Project"), _L("Project body edges onto a plane as sketch entities"),
|
|
[this] {
|
|
if (m_doc.bodies.empty()) {
|
|
m_status->SetForegroundColour(wxColour(235, 110, 110));
|
|
m_status->SetLabel(_L("Project needs a body — add or import one first"));
|
|
m_status->Refresh();
|
|
return;
|
|
}
|
|
{
|
|
m_proj_source_body->Clear();
|
|
for (size_t i = 0; i < m_doc.bodies.size(); ++i) {
|
|
const std::string& n = m_doc.bodies[i].name;
|
|
m_proj_source_body->Append(n.empty() ? wxString::Format(_L("Body %zu"), i + 1) : wxString::FromUTF8(n));
|
|
}
|
|
if (m_proj_source_body->GetCount() > 0) m_proj_source_body->SetSelection(0);
|
|
}
|
|
populate_plane_choices(m_proj_plane);
|
|
m_sel_solid_face = -1;
|
|
m_proj_face_label->SetLabel(_L("(all edges)"));
|
|
open_tool(Tool::Project);
|
|
}, 0},
|
|
});
|
|
|
|
// Placement — operations that MOVE a body without changing its shape. Transform and
|
|
// Mirror place one body directly; a Mate places one body relative to another. They were
|
|
// in Dress-up (fillet/draft/shell) and Datum, which mixed shape-finishing and reference
|
|
// geometry with rigid-body placement.
|
|
// Own slot id: "place" is taken by the Place-on-Face button, and put() formats slot
|
|
// item 0 as the control and every later item as its chevron, so sharing a slot would
|
|
// bottom-align this drawer's button like a chevron.
|
|
feat_dropdown("placement", "design_move", _L("Placement (transform / mirror / mate)"), {
|
|
{"design_move", _L("Transform"), _L("Move and/or rotate an existing body"),
|
|
[this] {
|
|
if (m_doc.bodies.empty()) {
|
|
m_status->SetForegroundColour(wxColour(235, 110, 110));
|
|
m_status->SetLabel(_L("Transform needs a body — add or import one first"));
|
|
m_status->Refresh();
|
|
return;
|
|
}
|
|
{
|
|
m_xf_body->Clear();
|
|
for (size_t i = 0; i < m_doc.bodies.size(); ++i) {
|
|
const std::string& n = m_doc.bodies[i].name;
|
|
m_xf_body->Append(n.empty() ? wxString::Format(_L("Body %zu"), i + 1) : wxString::FromUTF8(n));
|
|
}
|
|
if (m_xf_body->GetCount() > 0) m_xf_body->SetSelection(0);
|
|
}
|
|
open_tool(Tool::Transform);
|
|
}, SHIFT('Y')},
|
|
{"design_mirror", _L("Mirror"), _L("Reflect a body about a plane"),
|
|
[this] {
|
|
if (m_doc.bodies.empty()) {
|
|
m_status->SetForegroundColour(wxColour(235, 110, 110));
|
|
m_status->SetLabel(_L("Mirror needs a body — add or import one first"));
|
|
m_status->Refresh();
|
|
return;
|
|
}
|
|
{
|
|
m_mirror_body->Clear();
|
|
for (size_t i = 0; i < m_doc.bodies.size(); ++i) {
|
|
const std::string& n = m_doc.bodies[i].name;
|
|
m_mirror_body->Append(n.empty() ? wxString::Format(_L("Body %zu"), i + 1) : wxString::FromUTF8(n));
|
|
}
|
|
if (m_mirror_body->GetCount() > 0) m_mirror_body->SetSelection(0);
|
|
}
|
|
populate_plane_choices(m_mirror_plane);
|
|
open_tool(Tool::Mirror);
|
|
}, SHIFT('Z')},
|
|
{"design_c_coincident", _L("Mate"), _L("Assembly: align two CoordSys features (fastened, planar, revolute, slider, cylindrical)"),
|
|
[this] {
|
|
open_tool(Tool::Mate);
|
|
}, 0},
|
|
});
|
|
|
|
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("boolean", 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("cut", 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("color", b_color);
|
|
|
|
// Dress-up: finishing operations on the faces and edges of an existing solid — nothing
|
|
// that moves a body (see the Placement drawer) and nothing that creates geometry.
|
|
feat_dropdown("dressup", "design_dressup", _L("Dress-up (fillet / chamfer / draft / shell / delete face)"), {
|
|
{"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')},
|
|
{"design_delete", _L("Delete Face"), _L("Remove faces from a body and heal the solid"),
|
|
[this] {
|
|
if (m_doc.bodies.empty()) {
|
|
m_status->SetForegroundColour(wxColour(235, 110, 110));
|
|
m_status->SetLabel(_L("Delete Face needs a body — add or import one first"));
|
|
m_status->Refresh();
|
|
return;
|
|
}
|
|
{
|
|
m_del_face_body->Clear();
|
|
for (size_t i = 0; i < m_doc.bodies.size(); ++i) {
|
|
const std::string& n = m_doc.bodies[i].name;
|
|
m_del_face_body->Append(n.empty() ? wxString::Format(_L("Body %zu"), i + 1) : wxString::FromUTF8(n));
|
|
}
|
|
if (m_del_face_body->GetCount() > 0) m_del_face_body->SetSelection(0);
|
|
}
|
|
m_del_faces.clear();
|
|
m_del_face_list->SetLabel(_L("(none)"));
|
|
open_tool(Tool::DeleteFace);
|
|
}, 0},
|
|
});
|
|
|
|
// Hole / Thread — drilling into a solid (both face-aware)
|
|
feat_dropdown("hole", "design_hole", _L("Hole / thread"), {
|
|
{"design_hole", _L("Hole"), _L("Drill a hole, centred on a picked face or placed on a plane"),
|
|
[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')},
|
|
});
|
|
// Text / SVG insert tools live in the SKETCH toolbar (they produce 2D profiles =
|
|
// sketches), not here. STEP stays in Features: it imports a whole B-rep solid.
|
|
// Import STEP — standalone: a STEP comes in as a whole editable B-rep body, not a profile.
|
|
auto* b_step = icon_btn("design_step", _L("Import STEP (editable B-rep solid)"));
|
|
b_step->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_import_step(); });
|
|
m_keys_feature[SHIFT('I')] = [this] { on_import_step(); };
|
|
fadd("step", b_step);
|
|
// Import mesh — same destination as STEP (an editable B-rep body), but the geometry has
|
|
// to be reconstructed from triangles first (GeometryEngine::mesh_to_brep).
|
|
auto* b_mesh = icon_btn("param_triangles", _L("Import mesh (STL/OBJ) as an editable B-rep solid"));
|
|
b_mesh->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_import_mesh(); });
|
|
m_keys_feature[SHIFT('M')] = [this] { on_import_mesh(); };
|
|
fadd("mesh", b_mesh);
|
|
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("constrain", b_constrain);
|
|
}
|
|
|
|
// --- Sketch group: plane + entity tools + Construction + Finish
|
|
m_tb_sketch = new wxBoxSizer(wxHORIZONTAL);
|
|
// Sketch carries the most tools of any mode: pack it tight (no inter-icon gap) so the
|
|
// whole entity palette fits without crowding the right-hand actions.
|
|
auto sadd = [this](wxWindow* w) { m_tb_sketch->Add(w, 0, wxALIGN_CENTER_VERTICAL); };
|
|
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, 2);
|
|
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")} });
|
|
|
|
// Polygon's side count / circumscribed flag used to sit inline in this row; they are
|
|
// tool parameters, so they live in the sidebar card that opens with the tool.
|
|
auto* b_poly = icon_btn("design_polygon", _L("Polygon"));
|
|
b_poly->Bind(wxEVT_BUTTON, [this, select_tool](wxCommandEvent&) {
|
|
push_polygon_params();
|
|
select_tool(DesignSketchTool::Mode::Polygon, _L("Click center then a vertex")); });
|
|
sadd(b_poly);
|
|
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(40, 40),
|
|
wxDefaultPosition, wxBU_EXACTFIT | wxBORDER_NONE, false, 34);
|
|
b->SetToolTip(tip);
|
|
b->SetBackgroundColour(tb_bg);
|
|
b->Bind(wxEVT_ENTER_WINDOW, [b, tb_hover](wxMouseEvent& e) {
|
|
if (b->IsEnabled()) { b->SetBackgroundColour(tb_hover); b->Refresh(); } e.Skip(); });
|
|
b->Bind(wxEVT_LEAVE_WINDOW, [b, tb_bg](wxMouseEvent& e) {
|
|
b->SetBackgroundColour(tb_bg); b->Refresh(); e.Skip(); });
|
|
return b;
|
|
};
|
|
m_btn_undo = hist_btn("menu_undo", _L("Undo (Ctrl+Z)"));
|
|
m_btn_undo->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { do_undo_redo(false); });
|
|
m_btn_redo = hist_btn("menu_redo", _L("Redo (Ctrl+Shift+Z)"));
|
|
m_btn_redo->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { do_undo_redo(true); });
|
|
m_btn_undo->Enable(false); // nothing to undo/redo on a fresh document
|
|
m_btn_redo->Enable(false);
|
|
m_tb_history->Add(m_btn_undo, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 4);
|
|
m_tb_history->Add(m_btn_redo, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 4);
|
|
}
|
|
|
|
// Document actions, left of Undo/Redo and always visible (not mode-gated like the tools).
|
|
// Same styling as the history pair: momentary actions, never the teal active-tool state.
|
|
m_tb_doc = new wxBoxSizer(wxHORIZONTAL);
|
|
const wxColour tb_glyph_col = StateColor::darkModeColorFor(wxColour(0x36, 0x36, 0x36));
|
|
const wxColour tb_commit_col(0x00, 0x96, 0x88); // Orca Confirm accent
|
|
{
|
|
// Some Orca glyphs (toolbar_add_plate, toolbar_flatten) are drawn for a light toolbar and
|
|
// come out the same tone as this dark one — Commit was effectively invisible. Re-tint
|
|
// those: Commit in the teal accent it carries as the tab's primary action, the rest in
|
|
// the same grey the other toolbar glyphs resolve to.
|
|
auto doc_btn = [this, tb_bg, tb_hover, &tint](const char* icon, const wxString& tip,
|
|
const wxColour* glyph = nullptr) {
|
|
auto* b = new ScalableButton(m_toolbar, wxID_ANY, icon, "", wxSize(40, 40),
|
|
wxDefaultPosition, wxBU_EXACTFIT | wxBORDER_NONE, false, 34);
|
|
if (glyph != nullptr)
|
|
b->SetBitmap(tint(create_scaled_bitmap(icon, m_toolbar, 42), *glyph));
|
|
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;
|
|
};
|
|
auto add_doc = [this](ScalableButton* b) {
|
|
m_tb_doc->Add(b, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 4); };
|
|
|
|
auto* b_new = doc_btn("add", _L("New Design — clear the feature tree"));
|
|
b_new->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_new_design(); });
|
|
add_doc(b_new);
|
|
add_doc(static_cast<ScalableButton*>(tb_slot["step"][0])); // 2. Import STEP
|
|
add_doc(static_cast<ScalableButton*>(tb_slot["mesh"][0])); // 3. Import mesh
|
|
auto* b_export = doc_btn("save", _L("Export STEP…"));
|
|
b_export->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_export_step(); });
|
|
add_doc(b_export);
|
|
|
|
// View option, not a document action: hide the printer bed to model without it. Lives in
|
|
// this row because it must stay reachable with no tool open — a card would come and go.
|
|
m_show_bed = new CheckBox(m_toolbar);
|
|
m_show_bed->SetValue(true); // bed visible by default, as the tab opens today
|
|
m_show_bed->SetToolTip(_L("Show the printer bed and its plate grid"));
|
|
// wxEVT_TOGGLEBUTTON, NOT wxEVT_CHECKBOX: Orca's CheckBox derives from
|
|
// wxBitmapToggleButton (Widgets/CheckBox.hpp), so a wxEVT_CHECKBOX handler never fires.
|
|
// Read the control rather than the event so the state cannot disagree with the glyph.
|
|
m_show_bed->Bind(wxEVT_TOGGLEBUTTON, [this](wxCommandEvent& e) {
|
|
if (m_viewport) m_viewport->set_show_bed(m_show_bed->GetValue());
|
|
e.Skip();
|
|
});
|
|
auto* bed_lbl = new wxStaticText(m_toolbar, wxID_ANY, _L("Bed"));
|
|
bed_lbl->SetForegroundColour(dp_sec_text());
|
|
m_tb_doc->Add(m_show_bed, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, 6);
|
|
m_tb_doc->Add(bed_lbl, 0, wxALIGN_CENTER_VERTICAL | wxLEFT | wxRIGHT, 4);
|
|
|
|
// These act on bodies / the view, so they ride in the feature group, in the slots
|
|
// the user assigned them (9, 11bis, 16).
|
|
auto* b_place = doc_btn("toolbar_flatten", _L("Place on Face (F) — lay the picked face on the bed"),
|
|
&tb_glyph_col);
|
|
b_place->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { place_on_face(); });
|
|
tb_slot["place"].push_back(b_place);
|
|
|
|
auto* b_section = doc_btn("split_parts", _L("Section View — hide part of the model to see inside. "
|
|
"PageUp/PageDown move the plane; Delete removes it."));
|
|
b_section->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { toggle_section_view(); });
|
|
tb_slot["section"].push_back(b_section);
|
|
|
|
m_section_flip_btn = doc_btn("design_mirror", _L("Flip Section — show the opposite half"));
|
|
m_section_flip_btn->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { flip_section_view(); });
|
|
m_section_flip_btn->Enable(false); // only usable while a section view is active
|
|
tb_slot["flip"].push_back(m_section_flip_btn);
|
|
|
|
// Commit is the tab's primary action and sits far right, next to Confirm/Cancel.
|
|
m_tb_commit = new wxBoxSizer(wxHORIZONTAL);
|
|
auto* b_commit = doc_btn("toolbar_add_plate", _L("Commit to Plate — send the solid to Prepare"),
|
|
&tb_commit_col);
|
|
b_commit->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_commit(); });
|
|
m_tb_commit->Add(b_commit, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 4);
|
|
}
|
|
|
|
// Feature-group layout, in the requested left-to-right order.
|
|
{
|
|
auto put = [&](const char* id) {
|
|
auto it = tb_slot.find(id);
|
|
if (it == tb_slot.end()) return;
|
|
for (size_t i = 0; i < it->second.size(); ++i)
|
|
m_tb_feature->Add(it->second[i], 0,
|
|
i == 0 ? (wxALIGN_CENTER_VERTICAL | wxRIGHT)
|
|
: (wxALIGN_BOTTOM | wxBOTTOM | wxRIGHT),
|
|
i == 0 ? 4 : 5);
|
|
};
|
|
put("sketch"); put("constrain"); // 7, 7bis
|
|
add_sep(m_tb_feature);
|
|
put("material"); put("place"); put("placement"); put("plane"); // 8, 9, 9bis, 10
|
|
put("dressup"); put("section"); // 11, 11bis
|
|
put("hole"); put("boolean"); put("cut"); // 12, 13, 14
|
|
put("surface"); put("color"); put("flip"); // 14bis, 15, 16
|
|
put("pattern"); // kept, at the end
|
|
}
|
|
|
|
auto* tbrow = new wxBoxSizer(wxHORIZONTAL);
|
|
tbrow->AddSpacer(8);
|
|
tbrow->Add(m_tb_doc, 0, wxALIGN_CENTER_VERTICAL | wxTOP | wxBOTTOM, 5);
|
|
add_sep(tbrow);
|
|
tbrow->Add(m_tb_history, 0, wxALIGN_CENTER_VERTICAL | wxTOP | wxBOTTOM, 5);
|
|
add_sep(tbrow);
|
|
tbrow->Add(m_tb_feature, 0, wxALIGN_CENTER_VERTICAL | wxTOP | wxBOTTOM, 5);
|
|
tbrow->Add(m_tb_sketch, 0, wxALIGN_CENTER_VERTICAL | wxTOP | wxBOTTOM, 5);
|
|
tbrow->Add(m_tb_constrain, 0, wxALIGN_CENTER_VERTICAL | wxTOP | wxBOTTOM, 5);
|
|
tbrow->AddStretchSpacer();
|
|
tbrow->Add(m_tb_commit, 0, wxALIGN_CENTER_VERTICAL | wxTOP | wxBOTTOM, 5);
|
|
tbrow->Add(m_tb_action, 0, wxALIGN_CENTER_VERTICAL | wxTOP | wxBOTTOM, 5);
|
|
tbrow->AddSpacer(8);
|
|
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 = [](wxWindow* card, const char* icon, const wxString& title, wxStaticText*& out) -> wxSizer* {
|
|
auto* h = new wxBoxSizer(wxHORIZONTAL);
|
|
auto* ic = new wxStaticBitmap(card, wxID_ANY, create_scaled_bitmap(icon, card, 18));
|
|
out = new wxStaticText(card, wxID_ANY, title);
|
|
out->SetFont(Label::Head_14); // Orca shared HarmonyOS card-title font
|
|
h->Add(ic, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 8);
|
|
h->Add(out, 0, wxALIGN_CENTER_VERTICAL);
|
|
return h;
|
|
};
|
|
|
|
// Every tool dialog lives inside ONE framed card (only one is ever visible), so the
|
|
// active dialog reads as a bordered panel like Prepare's sidebar boxes instead of
|
|
// free-floating rows. `cards` is the frame's sizer; open_tool() shows/hides within it.
|
|
m_cards = make_card(m_form);
|
|
auto* cards = new wxBoxSizer(wxVERTICAL);
|
|
m_cards->SetSizer(cards);
|
|
root->Add(m_cards, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 8);
|
|
|
|
// --- Sketch dialog (shape definition only — no distance/mode) ---
|
|
auto* form = two_col_form();
|
|
|
|
m_shape = make_combo(m_cards);
|
|
m_shape->Append(_L("Rectangle"));
|
|
m_shape->Append(_L("Circle"));
|
|
m_shape->SetSelection(0);
|
|
form->Add(new wxStaticText(m_cards, wxID_ANY, _L("Shape")), 0, wxALIGN_CENTER_VERTICAL);
|
|
form->Add(m_shape, 0, wxEXPAND);
|
|
|
|
m_plane = make_combo(m_cards);
|
|
m_plane->Append(_L("XY"));
|
|
m_plane->Append(_L("XZ"));
|
|
m_plane->Append(_L("YZ"));
|
|
m_plane->SetSelection(0);
|
|
form->Add(new wxStaticText(m_cards, wxID_ANY, _L("Plane")), 0, wxALIGN_CENTER_VERTICAL);
|
|
form->Add(m_plane, 0, wxEXPAND);
|
|
|
|
m_width = make_spin(m_cards, 20);
|
|
form->Add(new wxStaticText(m_cards, wxID_ANY, _L("Width / X")), 0, wxALIGN_CENTER_VERTICAL);
|
|
form->Add(spin_frame(m_width), 0, wxEXPAND);
|
|
|
|
m_height = make_spin(m_cards, 20);
|
|
form->Add(new wxStaticText(m_cards, wxID_ANY, _L("Height / Y")), 0, wxALIGN_CENTER_VERTICAL);
|
|
form->Add(spin_frame(m_height), 0, wxEXPAND);
|
|
|
|
m_radius = make_spin(m_cards, 10);
|
|
form->Add(new wxStaticText(m_cards, wxID_ANY, _L("Radius")), 0, wxALIGN_CENTER_VERTICAL);
|
|
form->Add(spin_frame(m_radius), 0, wxEXPAND);
|
|
|
|
m_box_sketch = new wxBoxSizer(wxVERTICAL);
|
|
m_box_sketch->Add(card_header(m_cards, "design_sketch", _L("Sketch"), m_hdr_sketch), 0, wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_box_sketch->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8);
|
|
m_box_sketch->Add(form, 0, wxEXPAND | wxALL, 12);
|
|
cards->Add(m_box_sketch, 0, wxEXPAND);
|
|
|
|
// --- Move / Rotate body ---
|
|
{
|
|
auto* mform = two_col_form();
|
|
m_move_dx = make_spin(m_cards, 0.0, -100000.0, 100000.0);
|
|
mform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Distance X")), 0, wxALIGN_CENTER_VERTICAL);
|
|
mform->Add(spin_frame(m_move_dx), 0, wxEXPAND);
|
|
m_move_dy = make_spin(m_cards, 0.0, -100000.0, 100000.0);
|
|
mform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Distance Y")), 0, wxALIGN_CENTER_VERTICAL);
|
|
mform->Add(spin_frame(m_move_dy), 0, wxEXPAND);
|
|
m_move_dz = make_spin(m_cards, 0.0, -100000.0, 100000.0);
|
|
mform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Distance Z")), 0, wxALIGN_CENTER_VERTICAL);
|
|
mform->Add(spin_frame(m_move_dz), 0, wxEXPAND);
|
|
|
|
m_move_axis = make_combo(m_cards);
|
|
m_move_axis->Append(_L("X"));
|
|
m_move_axis->Append(_L("Y"));
|
|
m_move_axis->Append(_L("Z"));
|
|
m_move_axis->SetSelection(2);
|
|
mform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Rotation axis")), 0, wxALIGN_CENTER_VERTICAL);
|
|
mform->Add(m_move_axis, 0, wxEXPAND);
|
|
|
|
m_move_angle = make_spin(m_cards, 0.0, -360.0, 360.0);
|
|
mform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Angle °")), 0, wxALIGN_CENTER_VERTICAL);
|
|
mform->Add(spin_frame(m_move_angle), 0, wxEXPAND);
|
|
|
|
for (wxSpinCtrlDouble* sp : { m_move_dx, m_move_dy, m_move_dz, m_move_angle })
|
|
sp->Bind(wxEVT_SPINCTRLDOUBLE, [this](wxSpinDoubleEvent& e) { apply_move_card(); e.Skip(); });
|
|
m_move_axis->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent& e) { apply_move_card(); e.Skip(); });
|
|
|
|
m_box_move = new wxBoxSizer(wxVERTICAL);
|
|
m_box_move->Add(card_header(m_cards, "design_move", _L("Move / Rotate"), m_hdr_move), 0,
|
|
wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_box_move->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8);
|
|
m_box_move->Add(mform, 0, wxEXPAND | wxALL, 12);
|
|
cards->Add(m_box_move, 0, wxEXPAND);
|
|
}
|
|
|
|
// --- Polygon (sketch tool options) ---
|
|
{
|
|
auto* pform = two_col_form();
|
|
m_sides = new wxSpinCtrl(m_cards, wxID_ANY, "6", wxDefaultPosition, wxSize(90, -1));
|
|
m_sides->SetRange(3, 64);
|
|
m_sides->SetValue(6);
|
|
m_sides->Bind(wxEVT_SPINCTRL, [this](wxSpinEvent&) {
|
|
if (m_viewport) m_viewport->set_sketch_polygon_sides(m_sides->GetValue()); });
|
|
pform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Sides")), 0, wxALIGN_CENTER_VERTICAL);
|
|
pform->Add(m_sides, 0, wxEXPAND);
|
|
|
|
m_poly_circ = new CheckBox(m_cards);
|
|
m_poly_circ->Bind(wxEVT_TOGGLEBUTTON, [this](wxCommandEvent& e) {
|
|
if (m_viewport) m_viewport->set_sketch_polygon_circumscribed(m_poly_circ->GetValue());
|
|
e.Skip(); });
|
|
pform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Circumscribed")), 0, wxALIGN_CENTER_VERTICAL);
|
|
pform->Add(m_poly_circ, 0, wxALIGN_LEFT | wxALIGN_CENTER_VERTICAL);
|
|
|
|
m_box_polygon = new wxBoxSizer(wxVERTICAL);
|
|
m_box_polygon->Add(card_header(m_cards, "design_polygon", _L("Polygon"), m_hdr_polygon), 0,
|
|
wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_box_polygon->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8);
|
|
m_box_polygon->Add(pform, 0, wxEXPAND | wxALL, 12);
|
|
cards->Add(m_box_polygon, 0, wxEXPAND);
|
|
}
|
|
|
|
// --- Extrude dialog (consumes the selected sketch) ---
|
|
m_box_extrude = new wxBoxSizer(wxVERTICAL);
|
|
m_box_extrude->Add(card_header(m_cards, "design_extrude", _L("Extrude"), m_hdr_extrude), 0, wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_box_extrude->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8);
|
|
m_extrude_sketch_label = new wxStaticText(m_cards, wxID_ANY, _L("Sketch: —"));
|
|
m_box_extrude->Add(m_extrude_sketch_label, 0, wxLEFT | wxRIGHT | wxTOP, 12);
|
|
{
|
|
auto* eform = two_col_form();
|
|
|
|
m_distance = make_spin(m_cards, 10);
|
|
eform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Extrude dist")), 0, wxALIGN_CENTER_VERTICAL);
|
|
eform->Add(spin_frame(m_distance), 0, wxEXPAND);
|
|
|
|
// 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 = make_combo(m_cards);
|
|
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_cards, wxID_ANY, _L("End")), 0, wxALIGN_CENTER_VERTICAL);
|
|
eform->Add(m_extrude_end, 0, wxEXPAND);
|
|
|
|
m_distance2 = make_spin(m_cards, 5, 0.0, 100000.0); // second-side depth (Two-sided)
|
|
eform->Add(new wxStaticText(m_cards, wxID_ANY, _L("2nd dist")), 0, wxALIGN_CENTER_VERTICAL);
|
|
eform->Add(spin_frame(m_distance2), 0, wxEXPAND);
|
|
|
|
m_taper = make_spin(m_cards, 0.0, -89.0, 89.0); // draft angle (deg)
|
|
eform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Taper °")), 0, wxALIGN_CENTER_VERTICAL);
|
|
eform->Add(spin_frame(m_taper), 0, wxEXPAND);
|
|
|
|
m_mode = make_combo(m_cards);
|
|
// 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_cards, wxID_ANY, _L("Result")), 0, wxALIGN_CENTER_VERTICAL);
|
|
eform->Add(m_mode, 0, wxEXPAND);
|
|
|
|
m_flip = new CheckBox(m_cards);
|
|
eform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Flip direction")), 0, wxALIGN_CENTER_VERTICAL);
|
|
eform->Add(m_flip, 0, wxALIGN_LEFT | wxALIGN_CENTER_VERTICAL);
|
|
|
|
m_box_extrude->Add(eform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12);
|
|
}
|
|
cards->Add(m_box_extrude, 0, wxEXPAND);
|
|
|
|
// --- Dress-up (Fillet / Chamfer) ---
|
|
auto* dform = two_col_form();
|
|
|
|
m_dressup_type = make_combo(m_cards);
|
|
m_dressup_type->Append(_L("Fillet"));
|
|
m_dressup_type->Append(_L("Chamfer"));
|
|
m_dressup_type->SetSelection(0);
|
|
dform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Dress-up")), 0, wxALIGN_CENTER_VERTICAL);
|
|
dform->Add(m_dressup_type, 0, wxEXPAND);
|
|
|
|
m_face_group = make_combo(m_cards);
|
|
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_cards, wxID_ANY, _L("Edges")), 0, wxALIGN_CENTER_VERTICAL);
|
|
dform->Add(m_face_group, 0, wxEXPAND);
|
|
|
|
m_dressup_size = make_spin(m_cards, 2.0);
|
|
dform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Size (r/dist)")), 0, wxALIGN_CENTER_VERTICAL);
|
|
dform->Add(spin_frame(m_dressup_size), 0, wxEXPAND);
|
|
|
|
m_box_dressup = new wxBoxSizer(wxVERTICAL);
|
|
m_box_dressup->Add(card_header(m_cards, "design_dressup", _L("Fillet / Chamfer"), m_hdr_dressup), 0, wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_box_dressup->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8);
|
|
m_box_dressup->Add(dform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12);
|
|
cards->Add(m_box_dressup, 0, wxEXPAND);
|
|
|
|
// --- Hole (positioned circular cut) ---
|
|
auto* hform = two_col_form();
|
|
|
|
m_hole_plane = make_combo(m_cards);
|
|
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_COMBOBOX, [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_cards, wxID_ANY, _L("Hole plane")), 0, wxALIGN_CENTER_VERTICAL);
|
|
hform->Add(m_hole_plane, 0, wxEXPAND);
|
|
|
|
m_hole_diameter = make_spin(m_cards, 6.0);
|
|
hform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Diameter")), 0, wxALIGN_CENTER_VERTICAL);
|
|
hform->Add(spin_frame(m_hole_diameter), 0, wxEXPAND);
|
|
|
|
m_hole_depth = make_spin(m_cards, 10.0);
|
|
hform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Depth (blind)")), 0, wxALIGN_CENTER_VERTICAL);
|
|
hform->Add(spin_frame(m_hole_depth), 0, wxEXPAND);
|
|
|
|
m_hole_x = make_spin(m_cards, 0.0, -1000.0, 1000.0);
|
|
hform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Pos X")), 0, wxALIGN_CENTER_VERTICAL);
|
|
hform->Add(spin_frame(m_hole_x), 0, wxEXPAND);
|
|
|
|
m_hole_y = make_spin(m_cards, 0.0, -1000.0, 1000.0);
|
|
hform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Pos Y")), 0, wxALIGN_CENTER_VERTICAL);
|
|
hform->Add(spin_frame(m_hole_y), 0, wxEXPAND);
|
|
|
|
m_hole_through = new CheckBox(m_cards);
|
|
m_hole_through->SetValue(true);
|
|
hform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Through")), 0, wxALIGN_CENTER_VERTICAL);
|
|
hform->Add(m_hole_through, 0, wxALIGN_LEFT | wxALIGN_CENTER_VERTICAL);
|
|
|
|
m_box_hole = new wxBoxSizer(wxVERTICAL);
|
|
m_box_hole->Add(card_header(m_cards, "design_hole", _L("Hole"), m_hdr_hole), 0, wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_box_hole->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8);
|
|
m_box_hole->Add(hform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12);
|
|
cards->Add(m_box_hole, 0, wxEXPAND);
|
|
|
|
// --- Thread (helical) ---
|
|
auto* tform = two_col_form();
|
|
|
|
m_thread_plane = make_combo(m_cards);
|
|
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_cards, wxID_ANY, _L("Thread plane")), 0, wxALIGN_CENTER_VERTICAL);
|
|
tform->Add(m_thread_plane, 0, wxEXPAND);
|
|
|
|
// 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 = make_combo(m_cards);
|
|
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_COMBOBOX, [this](wxCommandEvent&) { apply_thread_standard(); });
|
|
tform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Standard")), 0, wxALIGN_CENTER_VERTICAL);
|
|
tform->Add(m_thread_std, 0, wxEXPAND);
|
|
|
|
// 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_cards, 10.0);
|
|
tform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Diameter")), 0, wxALIGN_CENTER_VERTICAL);
|
|
tform->Add(spin_frame(m_thread_radius), 0, wxEXPAND);
|
|
|
|
m_thread_pitch = make_spin(m_cards, 2.0);
|
|
tform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Pitch")), 0, wxALIGN_CENTER_VERTICAL);
|
|
tform->Add(spin_frame(m_thread_pitch), 0, wxEXPAND);
|
|
|
|
m_thread_height = make_spin(m_cards, 10.0);
|
|
tform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Length")), 0, wxALIGN_CENTER_VERTICAL);
|
|
tform->Add(spin_frame(m_thread_height), 0, wxEXPAND);
|
|
|
|
m_thread_depth = make_spin(m_cards, 1.0);
|
|
tform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Thread depth")), 0, wxALIGN_CENTER_VERTICAL);
|
|
tform->Add(spin_frame(m_thread_depth), 0, wxEXPAND);
|
|
|
|
m_thread_x = make_spin(m_cards, 0.0, -1000.0, 1000.0);
|
|
tform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Pos X")), 0, wxALIGN_CENTER_VERTICAL);
|
|
tform->Add(spin_frame(m_thread_x), 0, wxEXPAND);
|
|
|
|
m_thread_y = make_spin(m_cards, 0.0, -1000.0, 1000.0);
|
|
tform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Pos Y")), 0, wxALIGN_CENTER_VERTICAL);
|
|
tform->Add(spin_frame(m_thread_y), 0, wxEXPAND);
|
|
|
|
m_thread_internal = new CheckBox(m_cards);
|
|
m_thread_internal->SetValue(false);
|
|
// External rod uses the major radius; an internal tapped bore uses the minor
|
|
// (tap-drill) radius — re-derive the nominal radius when the role flips.
|
|
m_thread_internal->Bind(wxEVT_TOGGLEBUTTON, [this](wxCommandEvent& e) {
|
|
apply_thread_standard();
|
|
e.Skip();
|
|
});
|
|
tform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Internal")), 0, wxALIGN_CENTER_VERTICAL);
|
|
tform->Add(m_thread_internal, 0, wxALIGN_LEFT | wxALIGN_CENTER_VERTICAL);
|
|
|
|
m_box_thread = new wxBoxSizer(wxVERTICAL);
|
|
m_box_thread->Add(card_header(m_cards, "design_thread", _L("Thread"), m_hdr_thread), 0, wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_box_thread->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8);
|
|
m_box_thread->Add(tform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12);
|
|
cards->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(m_cards, "design_revolve", _L("Revolve"), m_hdr_revolve), 0, wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_box_revolve->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8);
|
|
m_revolve_sketch_label = new wxStaticText(m_cards, wxID_ANY, _L("Sketch: —"));
|
|
m_box_revolve->Add(m_revolve_sketch_label, 0, wxLEFT | wxRIGHT | wxTOP, 12);
|
|
{
|
|
auto* rform = two_col_form();
|
|
|
|
m_revolve_angle = make_spin(m_cards, 360.0, 1.0, 360.0);
|
|
rform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Angle °")), 0, wxALIGN_CENTER_VERTICAL);
|
|
rform->Add(spin_frame(m_revolve_angle), 0, wxEXPAND);
|
|
|
|
m_revolve_axis = make_combo(m_cards);
|
|
m_revolve_axis->Append(_L("Plane X"));
|
|
m_revolve_axis->Append(_L("Plane Y"));
|
|
m_revolve_axis->SetSelection(0);
|
|
rform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Axis")), 0, wxALIGN_CENTER_VERTICAL);
|
|
rform->Add(m_revolve_axis, 0, wxEXPAND);
|
|
|
|
m_revolve_mode = make_combo(m_cards);
|
|
m_revolve_mode->Append(_L("New"));
|
|
m_revolve_mode->Append(_L("Add"));
|
|
m_revolve_mode->Append(_L("Cut"));
|
|
m_revolve_mode->Append(_L("Intersect"));
|
|
m_revolve_mode->SetSelection(0);
|
|
rform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Mode")), 0, wxALIGN_CENTER_VERTICAL);
|
|
rform->Add(m_revolve_mode, 0, wxEXPAND);
|
|
|
|
m_revolve_flip = new CheckBox(m_cards);
|
|
rform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Flip direction")), 0, wxALIGN_CENTER_VERTICAL);
|
|
rform->Add(m_revolve_flip, 0, wxALIGN_LEFT | wxALIGN_CENTER_VERTICAL);
|
|
|
|
m_box_revolve->Add(rform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12);
|
|
}
|
|
cards->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(m_cards, "design_sweep", _L("Sweep"), m_hdr_sweep), 0, wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_box_sweep->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8);
|
|
m_sweep_profile_label = new wxStaticText(m_cards, wxID_ANY, _L("Profile: —"));
|
|
m_box_sweep->Add(m_sweep_profile_label, 0, wxLEFT | wxRIGHT | wxTOP, 12);
|
|
{
|
|
auto* sform = two_col_form();
|
|
|
|
m_sweep_path = make_combo(m_cards);
|
|
sform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Path")), 0, wxALIGN_CENTER_VERTICAL);
|
|
sform->Add(m_sweep_path, 0, wxEXPAND);
|
|
|
|
m_sweep_mode = make_combo(m_cards);
|
|
m_sweep_mode->Append(_L("New"));
|
|
m_sweep_mode->Append(_L("Add"));
|
|
m_sweep_mode->Append(_L("Cut"));
|
|
m_sweep_mode->Append(_L("Intersect"));
|
|
m_sweep_mode->SetSelection(0);
|
|
sform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Mode")), 0, wxALIGN_CENTER_VERTICAL);
|
|
sform->Add(m_sweep_mode, 0, wxEXPAND);
|
|
|
|
m_box_sweep->Add(sform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12);
|
|
}
|
|
cards->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(m_cards, "design_pattern", _L("Pattern"), m_hdr_pattern), 0, wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_box_pattern->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8);
|
|
{
|
|
auto* pform = two_col_form();
|
|
|
|
m_pattern_type = make_combo(m_cards);
|
|
m_pattern_type->Append(_L("Linear"));
|
|
m_pattern_type->Append(_L("Circular"));
|
|
m_pattern_type->SetSelection(0);
|
|
pform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Type")), 0, wxALIGN_CENTER_VERTICAL);
|
|
pform->Add(m_pattern_type, 0, wxEXPAND);
|
|
|
|
m_pattern_count = make_spin(m_cards, 3, 1, 999);
|
|
pform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Count")), 0, wxALIGN_CENTER_VERTICAL);
|
|
pform->Add(spin_frame(m_pattern_count), 0, wxEXPAND);
|
|
|
|
m_pattern_spacing = make_spin(m_cards, 20.0, 0.01, 100000.0);
|
|
pform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Spacing")), 0, wxALIGN_CENTER_VERTICAL);
|
|
pform->Add(spin_frame(m_pattern_spacing), 0, wxEXPAND);
|
|
|
|
m_pattern_dir = make_combo(m_cards);
|
|
m_pattern_dir->Append(_L("Plane X"));
|
|
m_pattern_dir->Append(_L("Plane Y"));
|
|
m_pattern_dir->SetSelection(0);
|
|
pform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Direction")), 0, wxALIGN_CENTER_VERTICAL);
|
|
pform->Add(m_pattern_dir, 0, wxEXPAND);
|
|
|
|
m_pattern_angle = make_spin(m_cards, 360.0, 1.0, 360.0);
|
|
pform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Total angle°")), 0, wxALIGN_CENTER_VERTICAL);
|
|
pform->Add(spin_frame(m_pattern_angle), 0, wxEXPAND);
|
|
|
|
m_box_pattern->Add(pform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12);
|
|
}
|
|
cards->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(m_cards, "design_boolean", _L("Boolean"), m_hdr_boolean), 0, wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_box_boolean->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8);
|
|
{
|
|
auto* bform = two_col_form();
|
|
|
|
m_bool_op = make_combo(m_cards);
|
|
m_bool_op->Append(_L("Union (join)"));
|
|
m_bool_op->Append(_L("Subtract (cut)"));
|
|
m_bool_op->Append(_L("Intersect"));
|
|
m_bool_op->SetSelection(0);
|
|
m_bool_op->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { refresh_preview(); });
|
|
bform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Operation")), 0, wxALIGN_CENTER_VERTICAL);
|
|
bform->Add(m_bool_op, 0, wxEXPAND);
|
|
|
|
m_bool_target = make_combo(m_cards);
|
|
m_bool_target->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { refresh_preview(); });
|
|
bform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Target (kept)")), 0, wxALIGN_CENTER_VERTICAL);
|
|
bform->Add(m_bool_target, 0, wxEXPAND);
|
|
|
|
m_bool_tool = make_combo(m_cards);
|
|
m_bool_tool->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { refresh_preview(); });
|
|
bform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Tool")), 0, wxALIGN_CENTER_VERTICAL);
|
|
bform->Add(m_bool_tool, 0, wxEXPAND);
|
|
|
|
// 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_cards, 0.0, 0.0, 100.0);
|
|
m_bool_tol->Bind(wxEVT_SPINCTRLDOUBLE, [this](wxSpinDoubleEvent&) { refresh_preview(); });
|
|
bform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Tolerance")), 0, wxALIGN_CENTER_VERTICAL);
|
|
bform->Add(spin_frame(m_bool_tol), 0, wxEXPAND);
|
|
|
|
m_box_boolean->Add(bform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12);
|
|
|
|
m_bool_keep = new CheckBox(m_cards);
|
|
m_bool_keep->Bind(wxEVT_TOGGLEBUTTON, [this](wxCommandEvent&) { refresh_preview(); });
|
|
auto* keeprow = new wxBoxSizer(wxHORIZONTAL);
|
|
keeprow->Add(new wxStaticText(m_cards, wxID_ANY, _L("Keep tool body")), 0, wxALIGN_CENTER_VERTICAL);
|
|
keeprow->AddStretchSpacer();
|
|
keeprow->Add(m_bool_keep, 0, wxALIGN_CENTER_VERTICAL);
|
|
m_box_boolean->Add(keeprow, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12);
|
|
}
|
|
cards->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(m_cards, "design_cut", _L("Cut"), m_hdr_cut), 0, wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_box_cut->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8);
|
|
{
|
|
auto* cform = two_col_form();
|
|
|
|
m_cut_target = make_combo(m_cards);
|
|
m_cut_target->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { refresh_preview(); });
|
|
cform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Body")), 0, wxALIGN_CENTER_VERTICAL);
|
|
cform->Add(m_cut_target, 0, wxEXPAND);
|
|
|
|
m_cut_plane = make_combo(m_cards);
|
|
m_cut_plane->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { refresh_preview(); });
|
|
cform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Plane")), 0, wxALIGN_CENTER_VERTICAL);
|
|
cform->Add(m_cut_plane, 0, wxEXPAND);
|
|
|
|
m_cut_offset = make_spin(m_cards, 0.0, -10000.0, 10000.0);
|
|
m_cut_offset->Bind(wxEVT_SPINCTRLDOUBLE, [this](wxSpinDoubleEvent&) { refresh_preview(); });
|
|
cform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Offset")), 0, wxALIGN_CENTER_VERTICAL);
|
|
cform->Add(spin_frame(m_cut_offset), 0, wxEXPAND);
|
|
|
|
m_box_cut->Add(cform, 0, wxEXPAND | 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.
|
|
}
|
|
cards->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(m_cards, "design_text", _L("Insert"), m_hdr_insert), 0, wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_box_insert->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8);
|
|
m_box_insert->Add(new wxStaticText(m_cards, wxID_ANY,
|
|
_L("Drag a corner to size, the centre to move.\nConfirm or Cancel in the toolbar above.")),
|
|
0, wxLEFT | wxRIGHT | wxBOTTOM, 12);
|
|
cards->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(m_cards, "design_plane", _L("Plane"), m_hdr_plane), 0, wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_box_plane->Add(new wxStaticLine(m_cards), 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 = make_combo(m_cards);
|
|
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_cards, 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 = two_col_form();
|
|
|
|
m_plane_base = make_combo(m_cards);
|
|
populate_plane_choices(m_plane_base); // XY/XZ/YZ + any existing datum planes
|
|
plform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Base")), 0, wxALIGN_CENTER_VERTICAL);
|
|
plform->Add(m_plane_base, 0, wxEXPAND);
|
|
|
|
m_plane_offset = make_spin(m_cards, 20.0, -100000.0, 100000.0);
|
|
plform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Offset")), 0, wxALIGN_CENTER_VERTICAL);
|
|
plform->Add(spin_frame(m_plane_offset), 0, wxEXPAND);
|
|
|
|
m_plane_tilt = make_spin(m_cards, 0.0, -180.0, 180.0);
|
|
plform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Angle°")), 0, wxALIGN_CENTER_VERTICAL);
|
|
plform->Add(spin_frame(m_plane_tilt), 0, wxEXPAND);
|
|
|
|
m_plane_tilt_axis = make_combo(m_cards);
|
|
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_cards, wxID_ANY, _L("Tilt axis")), 0, wxALIGN_CENTER_VERTICAL);
|
|
plform->Add(m_plane_tilt_axis, 0, wxEXPAND);
|
|
|
|
// Contextual reference picks: arm a target, then click a solid face/edge in the canvas.
|
|
auto pick_row = [&](const wxString& label, wxButton*& btn, wxStaticText*& lbl, PlanePick target) {
|
|
btn = new wxButton(m_cards, wxID_ANY, label);
|
|
lbl = new wxStaticText(m_cards, 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_cards, 60.0, 1.0, 100000.0);
|
|
plform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Size U")), 0, wxALIGN_CENTER_VERTICAL);
|
|
plform->Add(spin_frame(m_plane_usize), 0, wxEXPAND);
|
|
m_plane_vsize = make_spin(m_cards, 60.0, 1.0, 100000.0);
|
|
plform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Size V")), 0, wxALIGN_CENTER_VERTICAL);
|
|
plform->Add(spin_frame(m_plane_vsize), 0, wxEXPAND);
|
|
|
|
m_box_plane->Add(plform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12);
|
|
}
|
|
cards->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(m_cards, "design_loft", _L("Loft"), m_hdr_loft), 0, wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_box_loft->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8);
|
|
m_box_loft->Add(new wxStaticText(m_cards, wxID_ANY, _L("Profiles (check 2+, in order):")),
|
|
0, wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_loft_list = new wxCheckListBox(m_cards, wxID_ANY, wxDefaultPosition, wxSize(-1, 120));
|
|
m_loft_list->Bind(wxEVT_CHECKLISTBOX, [this](wxCommandEvent&) { refresh_preview(); });
|
|
m_box_loft->Add(m_loft_list, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12);
|
|
{
|
|
auto* lform = two_col_form();
|
|
|
|
m_loft_mode = make_combo(m_cards);
|
|
m_loft_mode->Append(_L("New"));
|
|
m_loft_mode->Append(_L("Add"));
|
|
m_loft_mode->Append(_L("Cut"));
|
|
m_loft_mode->Append(_L("Intersect"));
|
|
m_loft_mode->SetSelection(0);
|
|
lform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Mode")), 0, wxALIGN_CENTER_VERTICAL);
|
|
lform->Add(m_loft_mode, 0, wxEXPAND);
|
|
|
|
m_box_loft->Add(lform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12);
|
|
}
|
|
m_loft_ruled = new CheckBox(m_cards);
|
|
{
|
|
auto* lrow = new wxBoxSizer(wxHORIZONTAL);
|
|
lrow->Add(new wxStaticText(m_cards, wxID_ANY, _L("Ruled (straight) sections")), 0, wxALIGN_CENTER_VERTICAL);
|
|
lrow->AddStretchSpacer();
|
|
lrow->Add(m_loft_ruled, 0, wxALIGN_CENTER_VERTICAL);
|
|
m_box_loft->Add(lrow, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12);
|
|
}
|
|
cards->Add(m_box_loft, 0, wxEXPAND);
|
|
|
|
// --- Surface Extrude (sheet body from sketch) ---
|
|
m_box_surf_extrude = new wxBoxSizer(wxVERTICAL);
|
|
m_box_surf_extrude->Add(card_header(m_cards, "design_extrude", _L("Surface Extrude"), m_hdr_surf_extrude), 0, wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_box_surf_extrude->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8);
|
|
m_surf_extrude_sketch_label = new wxStaticText(m_cards, wxID_ANY, _L("Sketch: —"));
|
|
m_box_surf_extrude->Add(m_surf_extrude_sketch_label, 0, wxLEFT | wxRIGHT | wxTOP, 12);
|
|
{
|
|
auto* sform = two_col_form();
|
|
m_surf_extrude_distance = make_spin(m_cards, 10);
|
|
sform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Distance")), 0, wxALIGN_CENTER_VERTICAL);
|
|
sform->Add(spin_frame(m_surf_extrude_distance), 0, wxEXPAND);
|
|
m_box_surf_extrude->Add(sform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12);
|
|
}
|
|
cards->Add(m_box_surf_extrude, 0, wxEXPAND);
|
|
|
|
// --- Surface Revolve (sheet body from sketch about axis) ---
|
|
m_box_surf_revolve = new wxBoxSizer(wxVERTICAL);
|
|
m_box_surf_revolve->Add(card_header(m_cards, "design_revolve", _L("Surface Revolve"), m_hdr_surf_revolve), 0, wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_box_surf_revolve->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8);
|
|
m_surf_revolve_sketch_label = new wxStaticText(m_cards, wxID_ANY, _L("Sketch: —"));
|
|
m_box_surf_revolve->Add(m_surf_revolve_sketch_label, 0, wxLEFT | wxRIGHT | wxTOP, 12);
|
|
{
|
|
auto* rform = two_col_form();
|
|
m_surf_revolve_angle = make_spin(m_cards, 360.0, 1.0, 360.0);
|
|
rform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Angle °")), 0, wxALIGN_CENTER_VERTICAL);
|
|
rform->Add(spin_frame(m_surf_revolve_angle), 0, wxEXPAND);
|
|
m_surf_revolve_axis = make_combo(m_cards);
|
|
m_surf_revolve_axis->Append(_L("Plane X"));
|
|
m_surf_revolve_axis->Append(_L("Plane Y"));
|
|
m_surf_revolve_axis->SetSelection(0);
|
|
rform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Axis")), 0, wxALIGN_CENTER_VERTICAL);
|
|
rform->Add(m_surf_revolve_axis, 0, wxEXPAND);
|
|
m_surf_revolve_flip = new CheckBox(m_cards);
|
|
rform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Flip direction")), 0, wxALIGN_CENTER_VERTICAL);
|
|
rform->Add(m_surf_revolve_flip, 0, wxALIGN_LEFT | wxALIGN_CENTER_VERTICAL);
|
|
m_box_surf_revolve->Add(rform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12);
|
|
}
|
|
cards->Add(m_box_surf_revolve, 0, wxEXPAND);
|
|
|
|
// --- Surface Loft (sheet skin through 2+ ordered profile sketches) ---
|
|
m_box_surf_loft = new wxBoxSizer(wxVERTICAL);
|
|
m_box_surf_loft->Add(card_header(m_cards, "design_loft", _L("Surface Loft"), m_hdr_surf_loft), 0, wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_box_surf_loft->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8);
|
|
m_box_surf_loft->Add(new wxStaticText(m_cards, wxID_ANY, _L("Profiles (check 2+, in order):")),
|
|
0, wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_surf_loft_list = new wxCheckListBox(m_cards, wxID_ANY, wxDefaultPosition, wxSize(-1, 120));
|
|
m_surf_loft_list->Bind(wxEVT_CHECKLISTBOX, [this](wxCommandEvent&) { refresh_preview(); });
|
|
m_box_surf_loft->Add(m_surf_loft_list, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_surf_loft_ruled = new CheckBox(m_cards);
|
|
{
|
|
auto* lrow = new wxBoxSizer(wxHORIZONTAL);
|
|
lrow->Add(new wxStaticText(m_cards, wxID_ANY, _L("Ruled (straight) sections")), 0, wxALIGN_CENTER_VERTICAL);
|
|
lrow->AddStretchSpacer();
|
|
lrow->Add(m_surf_loft_ruled, 0, wxALIGN_CENTER_VERTICAL);
|
|
m_box_surf_loft->Add(lrow, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12);
|
|
}
|
|
cards->Add(m_box_surf_loft, 0, wxEXPAND);
|
|
|
|
// --- Surface Fill (one-face sheet from sketch boundary) ---
|
|
m_box_surf_fill = new wxBoxSizer(wxVERTICAL);
|
|
m_box_surf_fill->Add(card_header(m_cards, "design_surface", _L("Surface Fill"), m_hdr_surf_fill), 0, wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_box_surf_fill->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8);
|
|
m_surf_fill_sketch_label = new wxStaticText(m_cards, wxID_ANY, _L("Sketch: —"));
|
|
m_box_surf_fill->Add(m_surf_fill_sketch_label, 0, wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_box_surf_fill->Add(new wxStaticText(m_cards, wxID_ANY,
|
|
_L("Fills the sketch's closed boundary with a smooth face.")),
|
|
0, wxLEFT | wxRIGHT, 12);
|
|
cards->Add(m_box_surf_fill, 0, wxEXPAND);
|
|
|
|
// --- Surface Offset (offset a sheet body) ---
|
|
m_box_surf_offset = new wxBoxSizer(wxVERTICAL);
|
|
m_box_surf_offset->Add(card_header(m_cards, "design_offset", _L("Surface Offset"), m_hdr_surf_offset), 0, wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_box_surf_offset->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8);
|
|
{
|
|
auto* oform = two_col_form();
|
|
m_surf_offset_body = make_combo(m_cards);
|
|
m_surf_offset_body->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { refresh_preview(); });
|
|
oform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Sheet body")), 0, wxALIGN_CENTER_VERTICAL);
|
|
oform->Add(m_surf_offset_body, 0, wxEXPAND);
|
|
m_surf_offset_distance = make_spin(m_cards, 1.0, -100000.0, 100000.0);
|
|
m_surf_offset_distance->Bind(wxEVT_SPINCTRLDOUBLE, [this](wxSpinDoubleEvent&) { refresh_preview(); });
|
|
oform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Offset")), 0, wxALIGN_CENTER_VERTICAL);
|
|
oform->Add(spin_frame(m_surf_offset_distance), 0, wxEXPAND);
|
|
m_box_surf_offset->Add(oform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_box_surf_offset->Add(new wxStaticText(m_cards, wxID_ANY,
|
|
_L("Offsets a sheet body's shell. Positive = outward, negative = inward.")),
|
|
0, wxLEFT | wxRIGHT, 12);
|
|
}
|
|
cards->Add(m_box_surf_offset, 0, wxEXPAND);
|
|
|
|
// --- Thicken Surface (thicken a sheet body into a solid) ---
|
|
m_box_surf_thicken = new wxBoxSizer(wxVERTICAL);
|
|
m_box_surf_thicken->Add(card_header(m_cards, "design_thicken", _L("Thicken Surface"), m_hdr_surf_thicken), 0, wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_box_surf_thicken->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8);
|
|
{
|
|
auto* tform = two_col_form();
|
|
m_surf_thicken_body = make_combo(m_cards);
|
|
m_surf_thicken_body->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { refresh_preview(); });
|
|
tform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Sheet body")), 0, wxALIGN_CENTER_VERTICAL);
|
|
tform->Add(m_surf_thicken_body, 0, wxEXPAND);
|
|
m_surf_thicken_thickness = make_spin(m_cards, 1.0, 0.01, 100000.0);
|
|
m_surf_thicken_thickness->Bind(wxEVT_SPINCTRLDOUBLE, [this](wxSpinDoubleEvent&) { refresh_preview(); });
|
|
tform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Thickness")), 0, wxALIGN_CENTER_VERTICAL);
|
|
tform->Add(spin_frame(m_surf_thicken_thickness), 0, wxEXPAND);
|
|
m_surf_thicken_flip = new CheckBox(m_cards);
|
|
m_surf_thicken_flip->Bind(wxEVT_TOGGLEBUTTON, [this](wxCommandEvent&) { refresh_preview(); });
|
|
tform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Flip direction")), 0, wxALIGN_CENTER_VERTICAL);
|
|
tform->Add(m_surf_thicken_flip, 0, wxALIGN_LEFT | wxALIGN_CENTER_VERTICAL);
|
|
m_box_surf_thicken->Add(tform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_box_surf_thicken->Add(new wxStaticText(m_cards, wxID_ANY,
|
|
_L("Thickens a sheet body into a solid. Flip reverses the direction.")),
|
|
0, wxLEFT | wxRIGHT, 12);
|
|
}
|
|
cards->Add(m_box_surf_thicken, 0, wxEXPAND);
|
|
|
|
// --- Shell (hollow the current body to a wall thickness, removing one picked face) ---
|
|
auto* sform = two_col_form();
|
|
m_shell_thickness = make_spin(m_cards, 2.0, 0.01, 100000.0);
|
|
sform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Thickness")), 0, wxALIGN_CENTER_VERTICAL);
|
|
sform->Add(spin_frame(m_shell_thickness), 0, wxEXPAND);
|
|
m_shell_face_label = new wxStaticText(m_cards, wxID_ANY, _L("(all faces — closed hollow)"));
|
|
sform->Add(new wxStaticText(m_cards, 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(m_cards, "design_shell", _L("Shell"), m_hdr_shell), 0, wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_box_shell->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8);
|
|
m_box_shell->Add(new wxStaticText(m_cards, 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, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12);
|
|
cards->Add(m_box_shell, 0, wxEXPAND);
|
|
|
|
// --- Draft (taper a single picked solid face about the body bottom) ---
|
|
auto* drform = two_col_form();
|
|
m_draft_angle = make_spin(m_cards, 5.0, -89.0, 89.0);
|
|
drform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Angle (°)")), 0, wxALIGN_CENTER_VERTICAL);
|
|
drform->Add(spin_frame(m_draft_angle), 0, wxEXPAND);
|
|
m_draft_face_label = new wxStaticText(m_cards, wxID_ANY, _L("(pick a side face)"));
|
|
drform->Add(new wxStaticText(m_cards, 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(m_cards, "design_draft", _L("Draft"), m_hdr_draft), 0, wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_box_draft->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8);
|
|
m_box_draft->Add(new wxStaticText(m_cards, 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, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12);
|
|
cards->Add(m_box_draft, 0, wxEXPAND);
|
|
|
|
// --- Transform (rigid move/rotate of an existing body) ---
|
|
m_box_transform = new wxBoxSizer(wxVERTICAL);
|
|
m_box_transform->Add(card_header(m_cards, "design_move", _L("Transform"), m_hdr_transform), 0, wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_box_transform->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8);
|
|
{
|
|
auto* xform = two_col_form();
|
|
m_xf_body = make_combo(m_cards);
|
|
m_xf_body->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { refresh_preview(); });
|
|
xform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Body")), 0, wxALIGN_CENTER_VERTICAL);
|
|
xform->Add(m_xf_body, 0, wxEXPAND);
|
|
m_xf_dx = make_spin(m_cards, 0.0, -100000.0, 100000.0);
|
|
m_xf_dx->Bind(wxEVT_SPINCTRLDOUBLE, [this](wxSpinDoubleEvent&) { refresh_preview(); });
|
|
xform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Translate X")), 0, wxALIGN_CENTER_VERTICAL);
|
|
xform->Add(spin_frame(m_xf_dx), 0, wxEXPAND);
|
|
m_xf_dy = make_spin(m_cards, 0.0, -100000.0, 100000.0);
|
|
m_xf_dy->Bind(wxEVT_SPINCTRLDOUBLE, [this](wxSpinDoubleEvent&) { refresh_preview(); });
|
|
xform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Translate Y")), 0, wxALIGN_CENTER_VERTICAL);
|
|
xform->Add(spin_frame(m_xf_dy), 0, wxEXPAND);
|
|
m_xf_dz = make_spin(m_cards, 0.0, -100000.0, 100000.0);
|
|
m_xf_dz->Bind(wxEVT_SPINCTRLDOUBLE, [this](wxSpinDoubleEvent&) { refresh_preview(); });
|
|
xform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Translate Z")), 0, wxALIGN_CENTER_VERTICAL);
|
|
xform->Add(spin_frame(m_xf_dz), 0, wxEXPAND);
|
|
m_xf_axis = make_combo(m_cards);
|
|
m_xf_axis->Append(_L("X"));
|
|
m_xf_axis->Append(_L("Y"));
|
|
m_xf_axis->Append(_L("Z"));
|
|
m_xf_axis->SetSelection(2); // default Z
|
|
m_xf_axis->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { refresh_preview(); });
|
|
xform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Rotate axis")), 0, wxALIGN_CENTER_VERTICAL);
|
|
xform->Add(m_xf_axis, 0, wxEXPAND);
|
|
m_xf_angle = make_spin(m_cards, 0.0, -360.0, 360.0);
|
|
m_xf_angle->Bind(wxEVT_SPINCTRLDOUBLE, [this](wxSpinDoubleEvent&) { refresh_preview(); });
|
|
xform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Angle (°)")), 0, wxALIGN_CENTER_VERTICAL);
|
|
xform->Add(spin_frame(m_xf_angle), 0, wxEXPAND);
|
|
m_xf_pivot_x = make_spin(m_cards, 0.0, -100000.0, 100000.0);
|
|
m_xf_pivot_x->Bind(wxEVT_SPINCTRLDOUBLE, [this](wxSpinDoubleEvent&) { refresh_preview(); });
|
|
xform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Pivot X")), 0, wxALIGN_CENTER_VERTICAL);
|
|
xform->Add(spin_frame(m_xf_pivot_x), 0, wxEXPAND);
|
|
m_xf_pivot_y = make_spin(m_cards, 0.0, -100000.0, 100000.0);
|
|
m_xf_pivot_y->Bind(wxEVT_SPINCTRLDOUBLE, [this](wxSpinDoubleEvent&) { refresh_preview(); });
|
|
xform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Pivot Y")), 0, wxALIGN_CENTER_VERTICAL);
|
|
xform->Add(spin_frame(m_xf_pivot_y), 0, wxEXPAND);
|
|
m_xf_pivot_z = make_spin(m_cards, 0.0, -100000.0, 100000.0);
|
|
m_xf_pivot_z->Bind(wxEVT_SPINCTRLDOUBLE, [this](wxSpinDoubleEvent&) { refresh_preview(); });
|
|
xform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Pivot Z")), 0, wxALIGN_CENTER_VERTICAL);
|
|
xform->Add(spin_frame(m_xf_pivot_z), 0, wxEXPAND);
|
|
m_box_transform->Add(xform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_xf_copy = new CheckBox(m_cards);
|
|
m_xf_copy->Bind(wxEVT_TOGGLEBUTTON, [this](wxCommandEvent&) { refresh_preview(); });
|
|
auto* cpro = new wxBoxSizer(wxHORIZONTAL);
|
|
cpro->Add(new wxStaticText(m_cards, wxID_ANY, _L("Keep original (make a copy)")), 0, wxALIGN_CENTER_VERTICAL);
|
|
cpro->AddStretchSpacer();
|
|
cpro->Add(m_xf_copy, 0, wxALIGN_CENTER_VERTICAL);
|
|
m_box_transform->Add(cpro, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_box_transform->Add(new wxStaticText(m_cards, wxID_ANY,
|
|
_L("Translate and/or rotate the selected body. Rotation is applied about the pivot, then translation follows.")),
|
|
0, wxLEFT | wxRIGHT, 12);
|
|
}
|
|
cards->Add(m_box_transform, 0, wxEXPAND);
|
|
|
|
// --- Mirror (reflect a body about a plane) ---
|
|
m_box_mirror = new wxBoxSizer(wxVERTICAL);
|
|
m_box_mirror->Add(card_header(m_cards, "design_mirror", _L("Mirror"), m_hdr_mirror), 0, wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_box_mirror->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8);
|
|
{
|
|
auto* mform = two_col_form();
|
|
m_mirror_body = make_combo(m_cards);
|
|
m_mirror_body->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { refresh_preview(); });
|
|
mform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Body")), 0, wxALIGN_CENTER_VERTICAL);
|
|
mform->Add(m_mirror_body, 0, wxEXPAND);
|
|
m_mirror_plane = make_combo(m_cards);
|
|
m_mirror_plane->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { refresh_preview(); });
|
|
mform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Mirror plane")), 0, wxALIGN_CENTER_VERTICAL);
|
|
mform->Add(m_mirror_plane, 0, wxEXPAND);
|
|
m_box_mirror->Add(mform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_mirror_keep = new CheckBox(m_cards);
|
|
m_mirror_keep->SetValue(true);
|
|
m_mirror_keep->Bind(wxEVT_TOGGLEBUTTON, [this](wxCommandEvent&) { refresh_preview(); });
|
|
auto* mkrow = new wxBoxSizer(wxHORIZONTAL);
|
|
mkrow->Add(new wxStaticText(m_cards, wxID_ANY, _L("Keep original")), 0, wxALIGN_CENTER_VERTICAL);
|
|
mkrow->AddStretchSpacer();
|
|
mkrow->Add(m_mirror_keep, 0, wxALIGN_CENTER_VERTICAL);
|
|
m_box_mirror->Add(mkrow, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_box_mirror->Add(new wxStaticText(m_cards, wxID_ANY,
|
|
_L("Reflect the selected body about a plane. The mirror is always a new body; keeping the original gives you both.")),
|
|
0, wxLEFT | wxRIGHT, 12);
|
|
}
|
|
cards->Add(m_box_mirror, 0, wxEXPAND);
|
|
|
|
// --- Thicken (offset one solid face into a thin plate) ---
|
|
m_box_thicken = new wxBoxSizer(wxVERTICAL);
|
|
m_box_thicken->Add(card_header(m_cards, "design_thicken", _L("Thicken"), m_hdr_thicken), 0, wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_box_thicken->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8);
|
|
{
|
|
auto* tform = two_col_form();
|
|
m_thicken_body = make_combo(m_cards);
|
|
m_thicken_body->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { refresh_preview(); });
|
|
tform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Body")), 0, wxALIGN_CENTER_VERTICAL);
|
|
tform->Add(m_thicken_body, 0, wxEXPAND);
|
|
m_thicken_face_label = new wxStaticText(m_cards, wxID_ANY, _L("(pick a solid face)"));
|
|
tform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Face")), 0, wxALIGN_CENTER_VERTICAL);
|
|
tform->Add(m_thicken_face_label, 0, wxALIGN_CENTER_VERTICAL);
|
|
m_thicken_thickness = make_spin(m_cards, 2.0, 0.01, 100000.0);
|
|
m_thicken_thickness->Bind(wxEVT_SPINCTRLDOUBLE, [this](wxSpinDoubleEvent&) { refresh_preview(); });
|
|
tform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Thickness")), 0, wxALIGN_CENTER_VERTICAL);
|
|
tform->Add(spin_frame(m_thicken_thickness), 0, wxEXPAND);
|
|
m_box_thicken->Add(tform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_thicken_flip = new CheckBox(m_cards);
|
|
m_thicken_flip->Bind(wxEVT_TOGGLEBUTTON, [this](wxCommandEvent&) { refresh_preview(); });
|
|
auto* trow = new wxBoxSizer(wxHORIZONTAL);
|
|
trow->Add(new wxStaticText(m_cards, wxID_ANY, _L("Flip direction")), 0, wxALIGN_CENTER_VERTICAL);
|
|
trow->AddStretchSpacer();
|
|
trow->Add(m_thicken_flip, 0, wxALIGN_CENTER_VERTICAL);
|
|
m_box_thicken->Add(trow, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_box_thicken->Add(new wxStaticText(m_cards, wxID_ANY,
|
|
_L("Pick a solid face and offset it into a new thin body. Not for sheet bodies — use Thicken Surface for those.")),
|
|
0, wxLEFT | wxRIGHT, 12);
|
|
}
|
|
cards->Add(m_box_thicken, 0, wxEXPAND);
|
|
|
|
// --- Rib (thin wall from an open sketch line, fused to a body) ---
|
|
m_box_rib = new wxBoxSizer(wxVERTICAL);
|
|
m_box_rib->Add(card_header(m_cards, "design_rib", _L("Rib"), m_hdr_rib), 0, wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_box_rib->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8);
|
|
{
|
|
auto* rform = two_col_form();
|
|
m_rib_body = make_combo(m_cards);
|
|
m_rib_body->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { refresh_preview(); });
|
|
rform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Target body")), 0, wxALIGN_CENTER_VERTICAL);
|
|
rform->Add(m_rib_body, 0, wxEXPAND);
|
|
m_rib_sketch = make_combo(m_cards);
|
|
m_rib_sketch->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { refresh_preview(); });
|
|
rform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Sketch")), 0, wxALIGN_CENTER_VERTICAL);
|
|
rform->Add(m_rib_sketch, 0, wxEXPAND);
|
|
m_rib_entity = new wxSpinCtrl(m_cards, wxID_ANY, "", wxDefaultPosition, wxSize(90, -1),
|
|
wxSP_ARROW_KEYS | wxBORDER_SIMPLE);
|
|
m_rib_entity->SetRange(0, 999);
|
|
m_rib_entity->SetValue(0);
|
|
m_rib_entity->Bind(wxEVT_SPINCTRL, [this](wxSpinEvent&) { refresh_preview(); });
|
|
rform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Entity index")), 0, wxALIGN_CENTER_VERTICAL);
|
|
rform->Add(m_rib_entity, 0, wxEXPAND);
|
|
m_rib_thickness = make_spin(m_cards, 2.0, 0.01, 100000.0);
|
|
m_rib_thickness->Bind(wxEVT_SPINCTRLDOUBLE, [this](wxSpinDoubleEvent&) { refresh_preview(); });
|
|
rform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Thickness")), 0, wxALIGN_CENTER_VERTICAL);
|
|
rform->Add(spin_frame(m_rib_thickness), 0, wxEXPAND);
|
|
m_rib_depth = make_spin(m_cards, 10.0, 0.01, 100000.0);
|
|
m_rib_depth->Bind(wxEVT_SPINCTRLDOUBLE, [this](wxSpinDoubleEvent&) { refresh_preview(); });
|
|
rform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Depth")), 0, wxALIGN_CENTER_VERTICAL);
|
|
rform->Add(spin_frame(m_rib_depth), 0, wxEXPAND);
|
|
m_box_rib->Add(rform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_box_rib->Add(new wxStaticText(m_cards, wxID_ANY,
|
|
_L("Grow a thin wall from an open sketch Line entity, fused to the target body. Entity index is the position of the open Line in the sketch.")),
|
|
0, wxLEFT | wxRIGHT, 12);
|
|
}
|
|
cards->Add(m_box_rib, 0, wxEXPAND);
|
|
|
|
// --- Project (project body edges onto a plane as sketch entities) ---
|
|
m_box_project = new wxBoxSizer(wxVERTICAL);
|
|
m_box_project->Add(card_header(m_cards, "design_sketch", _L("Project"), m_hdr_project), 0, wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_box_project->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8);
|
|
{
|
|
auto* pform = two_col_form();
|
|
m_proj_source_body = make_combo(m_cards);
|
|
m_proj_source_body->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { refresh_preview(); });
|
|
pform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Source body")), 0, wxALIGN_CENTER_VERTICAL);
|
|
pform->Add(m_proj_source_body, 0, wxEXPAND);
|
|
m_proj_face_label = new wxStaticText(m_cards, wxID_ANY, _L("(all edges)"));
|
|
pform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Face")), 0, wxALIGN_CENTER_VERTICAL);
|
|
pform->Add(m_proj_face_label, 0, wxALIGN_CENTER_VERTICAL);
|
|
m_proj_plane = make_combo(m_cards);
|
|
populate_plane_choices(m_proj_plane);
|
|
m_proj_plane->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { refresh_preview(); });
|
|
pform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Target plane")), 0, wxALIGN_CENTER_VERTICAL);
|
|
pform->Add(m_proj_plane, 0, wxEXPAND);
|
|
m_box_project->Add(pform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_box_project->Add(new wxStaticText(m_cards, wxID_ANY,
|
|
_L("Project the edges of a body onto a plane as sketch entities. Pick a face to project only its edges, or leave the face empty to project the whole body.")),
|
|
0, wxLEFT | wxRIGHT, 12);
|
|
}
|
|
cards->Add(m_box_project, 0, wxEXPAND);
|
|
|
|
// --- Delete Face (remove faces, heal the solid) ---
|
|
m_box_delete_face = new wxBoxSizer(wxVERTICAL);
|
|
m_box_delete_face->Add(card_header(m_cards, "design_delete", _L("Delete Face"), m_hdr_delete_face), 0, wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_box_delete_face->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8);
|
|
{
|
|
auto* dform = two_col_form();
|
|
m_del_face_body = make_combo(m_cards);
|
|
m_del_face_body->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { refresh_preview(); });
|
|
dform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Body")), 0, wxALIGN_CENTER_VERTICAL);
|
|
dform->Add(m_del_face_body, 0, wxEXPAND);
|
|
m_del_face_add_btn = new wxButton(m_cards, wxID_ANY, _L("Add picked face"));
|
|
m_del_face_add_btn->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) {
|
|
// Say why nothing happened. Clicking with no face picked used to be a silent no-op,
|
|
// which is indistinguishable from the button being broken.
|
|
if (m_sel_solid_face < 0) {
|
|
m_status->SetForegroundColour(wxColour(235, 110, 110));
|
|
m_status->SetLabel(_L("Click a face on the body first, then Add picked face"));
|
|
m_status->Refresh();
|
|
return;
|
|
}
|
|
// Adding the same face twice puts a duplicate id in delete_faces, which the
|
|
// defeaturing algorithm has no reason to cope with. Re-clicking is a no-op, not an error.
|
|
if (std::find(m_del_faces.begin(), m_del_faces.end(), m_sel_solid_face) != m_del_faces.end()) {
|
|
m_status->SetForegroundColour(wxNullColour);
|
|
m_status->SetLabel(wxString::Format(_L("Face %d is already in the list"), m_sel_solid_face));
|
|
m_status->Refresh();
|
|
return;
|
|
}
|
|
{
|
|
m_del_faces.push_back(m_sel_solid_face);
|
|
wxString s;
|
|
for (size_t i = 0; i < m_del_faces.size(); ++i) {
|
|
if (i > 0) s += ", ";
|
|
s += wxString::Format("Face %d", m_del_faces[i]);
|
|
}
|
|
m_del_face_list->SetLabel(s.empty() ? _L("(none)") : s);
|
|
m_del_face_list->GetParent()->Layout();
|
|
m_del_face_list->Refresh();
|
|
refresh_preview();
|
|
}
|
|
});
|
|
dform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Faces to delete")), 0, wxALIGN_CENTER_VERTICAL);
|
|
dform->Add(m_del_face_add_btn, 0, wxALIGN_CENTER_VERTICAL);
|
|
m_del_face_list = new wxStaticText(m_cards, wxID_ANY, _L("(none)"));
|
|
dform->Add(0, 0);
|
|
dform->Add(m_del_face_list, 0, wxALIGN_CENTER_VERTICAL);
|
|
m_box_delete_face->Add(dform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_box_delete_face->Add(new wxStaticText(m_cards, wxID_ANY,
|
|
_L("Pick a solid face, then click 'Add picked face' to accumulate faces. Confirm to remove all listed faces and heal the solid.")),
|
|
0, wxLEFT | wxRIGHT, 12);
|
|
}
|
|
cards->Add(m_box_delete_face, 0, wxEXPAND);
|
|
|
|
// --- Helix (helical curve) ---
|
|
m_box_helix = new wxBoxSizer(wxVERTICAL);
|
|
m_box_helix->Add(card_header(m_cards, "design_thread", _L("Helix"), m_hdr_helix), 0, wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_box_helix->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8);
|
|
{
|
|
auto* hform = two_col_form();
|
|
m_helix_plane = make_combo(m_cards);
|
|
populate_plane_choices(m_helix_plane);
|
|
m_helix_plane->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { refresh_preview(); });
|
|
hform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Plane")), 0, wxALIGN_CENTER_VERTICAL);
|
|
hform->Add(m_helix_plane, 0, wxEXPAND);
|
|
m_helix_radius = make_spin(m_cards, 10.0, 0.01, 10000.0);
|
|
m_helix_radius->Bind(wxEVT_SPINCTRLDOUBLE, [this](wxSpinDoubleEvent&) { refresh_preview(); });
|
|
hform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Radius")), 0, wxALIGN_CENTER_VERTICAL);
|
|
hform->Add(spin_frame(m_helix_radius), 0, wxEXPAND);
|
|
m_helix_pitch = make_spin(m_cards, 5.0, 0.01, 10000.0);
|
|
m_helix_pitch->Bind(wxEVT_SPINCTRLDOUBLE, [this](wxSpinDoubleEvent&) { refresh_preview(); });
|
|
hform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Pitch")), 0, wxALIGN_CENTER_VERTICAL);
|
|
hform->Add(spin_frame(m_helix_pitch), 0, wxEXPAND);
|
|
m_helix_height = make_spin(m_cards, 20.0, 0.01, 10000.0);
|
|
m_helix_height->Bind(wxEVT_SPINCTRLDOUBLE, [this](wxSpinDoubleEvent&) { refresh_preview(); });
|
|
hform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Height")), 0, wxALIGN_CENTER_VERTICAL);
|
|
hform->Add(spin_frame(m_helix_height), 0, wxEXPAND);
|
|
m_box_helix->Add(hform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_helix_left_handed = new CheckBox(m_cards);
|
|
m_helix_left_handed->Bind(wxEVT_TOGGLEBUTTON, [this](wxCommandEvent&) { refresh_preview(); });
|
|
auto* hrow = new wxBoxSizer(wxHORIZONTAL);
|
|
hrow->Add(new wxStaticText(m_cards, wxID_ANY, _L("Left-handed")), 0, wxALIGN_CENTER_VERTICAL);
|
|
hrow->AddStretchSpacer();
|
|
hrow->Add(m_helix_left_handed, 0, wxALIGN_CENTER_VERTICAL);
|
|
m_box_helix->Add(hrow, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_helix_taper = make_spin(m_cards, 0.0, -89.0, 89.0);
|
|
m_helix_taper->Bind(wxEVT_SPINCTRLDOUBLE, [this](wxSpinDoubleEvent&) { refresh_preview(); });
|
|
auto* tvalform = two_col_form();
|
|
tvalform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Taper (°)")), 0, wxALIGN_CENTER_VERTICAL);
|
|
tvalform->Add(spin_frame(m_helix_taper), 0, wxEXPAND);
|
|
m_box_helix->Add(tvalform, 0, wxEXPAND | wxLEFT | wxRIGHT, 12);
|
|
m_box_helix->Add(new wxStaticText(m_cards, wxID_ANY,
|
|
_L("A helical curve (spring path). Use as a sweep path to build springs, coils, or augers.")),
|
|
0, wxLEFT | wxRIGHT, 12);
|
|
}
|
|
cards->Add(m_box_helix, 0, wxEXPAND);
|
|
|
|
// --- Axis (datum axis: line through two points or derived from geometry) ---
|
|
m_box_axis = new wxBoxSizer(wxVERTICAL);
|
|
m_box_axis->Add(card_header(m_cards, "design_line", _L("Axis"), m_hdr_axis), 0, wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_box_axis->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8);
|
|
{
|
|
m_axis_type = make_combo(m_cards);
|
|
for (const wxString& t : { _L("Two points"), _L("Face normal"), _L("Cylinder centerline"),
|
|
_L("Plane intersection"), _L("Along edge") })
|
|
m_axis_type->Append(t);
|
|
m_axis_type->SetSelection(0);
|
|
m_box_axis->Add(new wxStaticText(m_cards, wxID_ANY, _L("Axis type")), 0, wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_box_axis->Add(m_axis_type, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12);
|
|
|
|
auto* axform = two_col_form();
|
|
|
|
auto ax_pick = [&](const wxString& label, wxButton*& btn, wxStaticText*& lbl, AxisPick target) {
|
|
btn = new wxButton(m_cards, wxID_ANY, label);
|
|
lbl = new wxStaticText(m_cards, wxID_ANY, _L("(none)"));
|
|
btn->Bind(wxEVT_BUTTON, [this, target](wxCommandEvent&) { arm_axis_pick(target); });
|
|
axform->Add(btn);
|
|
axform->Add(lbl, 0, wxALIGN_CENTER_VERTICAL);
|
|
};
|
|
ax_pick(_L("Pick Face"), m_axis_pick_face, m_axis_face_lbl, AxisPick::Face);
|
|
ax_pick(_L("Pick Edge"), m_axis_pick_edge, m_axis_edge_lbl, AxisPick::Edge);
|
|
|
|
m_axis_plane_a = make_combo(m_cards);
|
|
populate_plane_choices(m_axis_plane_a);
|
|
axform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Plane A")), 0, wxALIGN_CENTER_VERTICAL);
|
|
axform->Add(m_axis_plane_a, 0, wxEXPAND);
|
|
m_axis_plane_b = make_combo(m_cards);
|
|
populate_plane_choices(m_axis_plane_b);
|
|
axform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Plane B")), 0, wxALIGN_CENTER_VERTICAL);
|
|
axform->Add(m_axis_plane_b, 0, wxEXPAND);
|
|
|
|
m_axis_p1x = make_spin(m_cards, 0.0, -100000.0, 100000.0);
|
|
m_axis_p1y = make_spin(m_cards, 0.0, -100000.0, 100000.0);
|
|
m_axis_p1z = make_spin(m_cards, 0.0, -100000.0, 100000.0);
|
|
axform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Point 1 X")), 0, wxALIGN_CENTER_VERTICAL);
|
|
axform->Add(spin_frame(m_axis_p1x), 0, wxEXPAND);
|
|
axform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Point 1 Y")), 0, wxALIGN_CENTER_VERTICAL);
|
|
axform->Add(spin_frame(m_axis_p1y), 0, wxEXPAND);
|
|
axform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Point 1 Z")), 0, wxALIGN_CENTER_VERTICAL);
|
|
axform->Add(spin_frame(m_axis_p1z), 0, wxEXPAND);
|
|
|
|
m_axis_p2x = make_spin(m_cards, 10.0, -100000.0, 100000.0);
|
|
m_axis_p2y = make_spin(m_cards, 0.0, -100000.0, 100000.0);
|
|
m_axis_p2z = make_spin(m_cards, 0.0, -100000.0, 100000.0);
|
|
axform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Point 2 X")), 0, wxALIGN_CENTER_VERTICAL);
|
|
axform->Add(spin_frame(m_axis_p2x), 0, wxEXPAND);
|
|
axform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Point 2 Y")), 0, wxALIGN_CENTER_VERTICAL);
|
|
axform->Add(spin_frame(m_axis_p2y), 0, wxEXPAND);
|
|
axform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Point 2 Z")), 0, wxALIGN_CENTER_VERTICAL);
|
|
axform->Add(spin_frame(m_axis_p2z), 0, wxEXPAND);
|
|
|
|
m_box_axis->Add(axform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12);
|
|
}
|
|
cards->Add(m_box_axis, 0, wxEXPAND);
|
|
|
|
// --- CoordSys (datum coordinate system: point + orthonormal frame) ---
|
|
m_box_coordsys = new wxBoxSizer(wxVERTICAL);
|
|
m_box_coordsys->Add(card_header(m_cards, "design_point", _L("Coord Sys"), m_hdr_coordsys), 0, wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_box_coordsys->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8);
|
|
{
|
|
m_coordsys_type = make_combo(m_cards);
|
|
for (const wxString& t : { _L("Point (world)"), _L("Face + direction") })
|
|
m_coordsys_type->Append(t);
|
|
m_coordsys_type->SetSelection(0);
|
|
m_box_coordsys->Add(new wxStaticText(m_cards, wxID_ANY, _L("Type")), 0, wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_box_coordsys->Add(m_coordsys_type, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12);
|
|
|
|
auto* csform = two_col_form();
|
|
|
|
m_cs_x = make_spin(m_cards, 0.0, -100000.0, 100000.0);
|
|
m_cs_y = make_spin(m_cards, 0.0, -100000.0, 100000.0);
|
|
m_cs_z = make_spin(m_cards, 0.0, -100000.0, 100000.0);
|
|
csform->Add(new wxStaticText(m_cards, wxID_ANY, _L("X")), 0, wxALIGN_CENTER_VERTICAL);
|
|
csform->Add(spin_frame(m_cs_x), 0, wxEXPAND);
|
|
csform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Y")), 0, wxALIGN_CENTER_VERTICAL);
|
|
csform->Add(spin_frame(m_cs_y), 0, wxEXPAND);
|
|
csform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Z")), 0, wxALIGN_CENTER_VERTICAL);
|
|
csform->Add(spin_frame(m_cs_z), 0, wxEXPAND);
|
|
|
|
auto cs_pick = [&](const wxString& label, wxButton*& btn, wxStaticText*& lbl, CoordSysPick target) {
|
|
btn = new wxButton(m_cards, wxID_ANY, label);
|
|
lbl = new wxStaticText(m_cards, wxID_ANY, _L("(none)"));
|
|
btn->Bind(wxEVT_BUTTON, [this, target](wxCommandEvent&) { arm_coordsys_pick(target); });
|
|
csform->Add(btn);
|
|
csform->Add(lbl, 0, wxALIGN_CENTER_VERTICAL);
|
|
};
|
|
cs_pick(_L("Pick Face"), m_cs_pick_face, m_cs_face_lbl, CoordSysPick::Face);
|
|
|
|
// ponytail: edge pick for CoordSys with rotation-direction hint
|
|
m_cs_pick_edge = new wxButton(m_cards, wxID_ANY, _L("Edge (sets in-plane direction)"));
|
|
m_cs_edge_lbl = new wxStaticText(m_cards, wxID_ANY, _L("(none)"));
|
|
m_cs_pick_edge->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { arm_coordsys_pick(CoordSysPick::Edge); });
|
|
csform->Add(m_cs_pick_edge);
|
|
csform->Add(m_cs_edge_lbl, 0, wxALIGN_CENTER_VERTICAL);
|
|
auto* edge_hint = new wxStaticText(m_cards, wxID_ANY,
|
|
_L("Without an edge the frame takes X from the face's first edge — pick one to choose it yourself"));
|
|
edge_hint->SetForegroundColour(dp_sec_text());
|
|
csform->Add(0, 0); // empty left column
|
|
csform->Add(edge_hint, 0, wxALIGN_CENTER_VERTICAL);
|
|
|
|
m_cs_hx = make_spin(m_cards, 1.0, -100000.0, 100000.0);
|
|
m_cs_hy = make_spin(m_cards, 0.0, -100000.0, 100000.0);
|
|
m_cs_hz = make_spin(m_cards, 0.0, -100000.0, 100000.0);
|
|
csform->Add(new wxStaticText(m_cards, wxID_ANY, _L("X hint X")), 0, wxALIGN_CENTER_VERTICAL);
|
|
csform->Add(spin_frame(m_cs_hx), 0, wxEXPAND);
|
|
csform->Add(new wxStaticText(m_cards, wxID_ANY, _L("X hint Y")), 0, wxALIGN_CENTER_VERTICAL);
|
|
csform->Add(spin_frame(m_cs_hy), 0, wxEXPAND);
|
|
csform->Add(new wxStaticText(m_cards, wxID_ANY, _L("X hint Z")), 0, wxALIGN_CENTER_VERTICAL);
|
|
csform->Add(spin_frame(m_cs_hz), 0, wxEXPAND);
|
|
|
|
m_box_coordsys->Add(csform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12);
|
|
}
|
|
cards->Add(m_box_coordsys, 0, wxEXPAND);
|
|
|
|
// --- Mate (assembly: align two CoordSys features) ---
|
|
m_box_mate = new wxBoxSizer(wxVERTICAL);
|
|
m_box_mate->Add(card_header(m_cards, "design_c_coincident", _L("Mate"), m_hdr_mate), 0, wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_box_mate->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8);
|
|
{
|
|
auto* mform = two_col_form();
|
|
|
|
m_mate_kind = make_combo(m_cards);
|
|
m_mate_kind->Append(_L("Fastened — 6 DOF locked, B driven onto A exactly"));
|
|
m_mate_kind->Append(_L("Planar — z aligned, offset distance; free in-plane slide"));
|
|
m_mate_kind->Append(_L("Revolute — axes collinear; free rotation about z"));
|
|
m_mate_kind->Append(_L("Slider — orientation locked; free axial slide"));
|
|
m_mate_kind->Append(_L("Cylindrical — axes collinear; free spin + axial slide"));
|
|
m_mate_kind->SetSelection(0);
|
|
mform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Kind")), 0, wxALIGN_CENTER_VERTICAL);
|
|
mform->Add(m_mate_kind, 0, wxEXPAND);
|
|
|
|
// Populate the CoordSys pickers on open; show "A (fixed)" and "B (moves)" combos.
|
|
m_mate_cs_a = make_combo(m_cards);
|
|
mform->Add(new wxStaticText(m_cards, wxID_ANY, _L("CS A (fixed)")), 0, wxALIGN_CENTER_VERTICAL);
|
|
mform->Add(m_mate_cs_a, 0, wxEXPAND);
|
|
|
|
m_mate_cs_b = make_combo(m_cards);
|
|
mform->Add(new wxStaticText(m_cards, wxID_ANY, _L("CS B (moves)")), 0, wxALIGN_CENTER_VERTICAL);
|
|
mform->Add(m_mate_cs_b, 0, wxEXPAND);
|
|
|
|
m_offset_label = new wxStaticText(m_cards, wxID_ANY, _L("Offset"));
|
|
m_mate_offset = make_spin(m_cards, 0.0, -100000.0, 100000.0);
|
|
mform->Add(m_offset_label, 0, wxALIGN_CENTER_VERTICAL);
|
|
mform->Add(spin_frame(m_mate_offset), 0, wxEXPAND);
|
|
|
|
m_angle_label = new wxStaticText(m_cards, wxID_ANY, _L("Angle \u00b0"));
|
|
m_mate_angle = make_spin(m_cards, 0.0, -360.0, 360.0);
|
|
mform->Add(m_angle_label, 0, wxALIGN_CENTER_VERTICAL);
|
|
mform->Add(spin_frame(m_mate_angle), 0, wxEXPAND);
|
|
|
|
m_mate_flip = new CheckBox(m_cards);
|
|
auto* frow = new wxBoxSizer(wxHORIZONTAL);
|
|
frow->Add(new wxStaticText(m_cards, wxID_ANY, _L("Flip (oppose z axes)")), 0, wxALIGN_CENTER_VERTICAL);
|
|
frow->AddStretchSpacer();
|
|
frow->Add(m_mate_flip, 0, wxALIGN_CENTER_VERTICAL);
|
|
mform->Add(0, 0);
|
|
mform->Add(frow, 0, wxEXPAND);
|
|
|
|
// Retitle offset/angle labels when the kind changes.
|
|
m_mate_kind->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent& e) {
|
|
const int kind = m_mate_kind->GetSelection();
|
|
switch (kind) {
|
|
case 0: // Fastened
|
|
m_offset_label->SetLabel(_L("Offset"));
|
|
m_angle_label->SetLabel(_L("Angle \u00b0"));
|
|
break;
|
|
case 1: // Planar
|
|
m_offset_label->SetLabel(_L("Plane distance"));
|
|
m_angle_label->SetLabel(_L("Angle \u00b0"));
|
|
break;
|
|
case 2: // Revolute
|
|
m_offset_label->SetLabel(_L("Axial position"));
|
|
m_angle_label->SetLabel(_L("Angle \u00b0"));
|
|
break;
|
|
case 3: // Slider
|
|
m_offset_label->SetLabel(_L("Axial position"));
|
|
m_angle_label->SetLabel(_L("Angle \u00b0"));
|
|
break;
|
|
case 4: // Cylindrical
|
|
m_offset_label->SetLabel(_L("Axial position"));
|
|
m_angle_label->SetLabel(_L("Angle \u00b0"));
|
|
break;
|
|
}
|
|
m_cards->Layout(); m_form->Layout(); m_form->FitInside();
|
|
refresh_preview();
|
|
e.Skip();
|
|
});
|
|
|
|
m_box_mate->Add(mform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12);
|
|
}
|
|
cards->Add(m_box_mate, 0, wxEXPAND);
|
|
// Refresh the confirm state whenever the mate inputs change.
|
|
m_mate_cs_a->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { refresh_preview(); });
|
|
m_mate_cs_b->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { refresh_preview(); });
|
|
m_mate_offset->Bind(wxEVT_SPINCTRLDOUBLE, [this](wxSpinDoubleEvent&) { refresh_preview(); });
|
|
m_mate_angle->Bind(wxEVT_SPINCTRLDOUBLE, [this](wxSpinDoubleEvent&) { refresh_preview(); });
|
|
m_mate_flip->Bind(wxEVT_TOGGLEBUTTON, [this](wxCommandEvent&) { refresh_preview(); });
|
|
|
|
// --- Expression binding card (visible during feature edit only) ---
|
|
// Lets the user bind a numeric field to a document-variable expression. Field-name
|
|
// combo is populated per feature type and is editable for power users. Set applies
|
|
// the binding with checkpoint+recompute+undo-on-failure; Clear removes it.
|
|
m_box_expr = new wxBoxSizer(wxVERTICAL);
|
|
{
|
|
wxStaticText* expr_hdr = nullptr;
|
|
m_box_expr->Add(card_header(m_cards, "design_constrain", _L("Expression"), expr_hdr), 0,
|
|
wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_box_expr->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8);
|
|
|
|
auto* eform = two_col_form();
|
|
|
|
// The only editable combo in this panel, hence not make_combo(): that passes
|
|
// wxCB_READONLY, and Orca's ComboBox HIDES its text ctrl under that style
|
|
// (ComboBox.cpp:51), so there is nothing to type into and no SetEditable() to
|
|
// turn it back on. Constructed with style 0, the ctrl is shown with
|
|
// wxTE_PROCESS_ENTER and GetTextLabel() returns what the user typed — which is
|
|
// what makes the 21 unmapped feature types reachable at all.
|
|
m_expr_field = new ComboBox(m_cards, wxID_ANY, wxEmptyString, wxDefaultPosition,
|
|
wxSize(FromDIP(90), FromDIP(24)), 0, nullptr, 0);
|
|
m_expr_field->SetMinSize(wxSize(FromDIP(90), FromDIP(24)));
|
|
eform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Field")), 0, wxALIGN_CENTER_VERTICAL);
|
|
eform->Add(m_expr_field, 0, wxEXPAND);
|
|
|
|
m_expr_text = new wxTextCtrl(m_cards, wxID_ANY, "", wxDefaultPosition,
|
|
wxSize(FromDIP(120), FromDIP(24)),
|
|
wxTE_PROCESS_ENTER | wxBORDER_SIMPLE);
|
|
m_expr_text->Bind(wxEVT_TEXT_ENTER, [this](wxCommandEvent&) { on_set_expr(); });
|
|
eform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Expr")), 0, wxALIGN_CENTER_VERTICAL);
|
|
eform->Add(m_expr_text, 0, wxEXPAND);
|
|
|
|
m_box_expr->Add(eform, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12);
|
|
|
|
auto* brow = new wxBoxSizer(wxHORIZONTAL);
|
|
m_expr_set_btn = new wxButton(m_cards, wxID_ANY, _L("Set"), wxDefaultPosition, wxSize(50, 24));
|
|
m_expr_clear_btn = new wxButton(m_cards, wxID_ANY, _L("Clear"), wxDefaultPosition, wxSize(50, 24));
|
|
m_expr_set_btn->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_set_expr(); });
|
|
m_expr_clear_btn->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_clear_expr(); });
|
|
brow->Add(m_expr_set_btn, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6);
|
|
brow->Add(m_expr_clear_btn, 0, wxALIGN_CENTER_VERTICAL);
|
|
brow->AddStretchSpacer();
|
|
m_box_expr->Add(brow, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12);
|
|
|
|
m_expr_status = new wxStaticText(m_cards, wxID_ANY, _L("(no bindings)"));
|
|
m_expr_status->SetForegroundColour(dp_sec_text());
|
|
m_box_expr->Add(m_expr_status, 0, wxLEFT | wxRIGHT | wxBOTTOM, 12);
|
|
}
|
|
cards->Add(m_box_expr, 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(m_cards, "design_constrain", _L("Value"), m_value_label), 0, wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_box_value->Add(new wxStaticLine(m_cards), 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_cards, 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_cards, 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);
|
|
}
|
|
cards->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(m_cards, "design_sketch", _L("Sketch"), m_hdr_sketch_session),
|
|
0, wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_box_sketch_session->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8);
|
|
{
|
|
auto* prow = new wxBoxSizer(wxHORIZONTAL);
|
|
prow->Add(new wxStaticText(m_cards, wxID_ANY, _L("Plane")), 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 8);
|
|
m_draw_plane = make_combo(m_cards);
|
|
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_COMBOBOX, [this](wxCommandEvent&) {
|
|
if (m_viewport && m_viewport->is_sketching())
|
|
m_viewport->set_sketch_plane(plane_from_choice(m_draw_plane->GetSelection()));
|
|
});
|
|
prow->AddStretchSpacer(); // label left, control right — same row idiom as the grids
|
|
prow->Add(m_draw_plane, 0, wxALIGN_CENTER_VERTICAL);
|
|
m_box_sketch_session->Add(prow, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12);
|
|
auto* hint = new wxStaticText(m_cards, 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);
|
|
}
|
|
cards->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(m_cards, "design_constrain", _L("Constraints"), m_hdr_constraints),
|
|
0, wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_box_constraints->Add(new wxStaticLine(m_cards), 0, wxEXPAND | wxALL, 8);
|
|
m_constraint_rows = new wxBoxSizer(wxVERTICAL);
|
|
m_box_constraints->Add(m_constraint_rows, 0, wxEXPAND | wxLEFT | wxRIGHT | wxBOTTOM, 12);
|
|
cards->Add(m_box_constraints, 0, wxEXPAND);
|
|
|
|
// Feature tree card. Same idiom as Prepare's sections (icon + Head_14 title + rule) via the
|
|
// shared card_header helper, instead of the bare micro-label this used to be; the row-edit
|
|
// actions live in the header, as Prepare puts its section actions.
|
|
m_tree_box = make_card(m_form);
|
|
auto* tree_inner = new wxBoxSizer(wxVERTICAL);
|
|
m_tree_box->SetSizer(tree_inner);
|
|
root->Add(m_tree_box, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 8);
|
|
|
|
m_hdr_tree_row = new wxBoxSizer(wxHORIZONTAL);
|
|
m_hdr_tree_row->Add(card_header(m_tree_box, "design_sketch", _L("Feature tree"), m_hdr_tree), 0,
|
|
wxALIGN_CENTER_VERTICAL);
|
|
m_hdr_tree_row->AddStretchSpacer();
|
|
tree_inner->Add(m_hdr_tree_row, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP,
|
|
FromDIP(SidebarProps::ContentMargin()));
|
|
tree_inner->Add(new wxStaticLine(m_tree_box), 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP,
|
|
FromDIP(SidebarProps::TitlebarMargin()));
|
|
m_tree = new wxTreeCtrl(m_tree_box, wxID_ANY, wxDefaultPosition, wxSize(-1, 64),
|
|
wxTR_HIDE_ROOT | wxTR_SINGLE | wxTR_NO_LINES |
|
|
wxTR_FULL_ROW_HIGHLIGHT | wxBORDER_SIMPLE);
|
|
if (!dp_dark()) m_tree->SetBackgroundColour(dp_panel_bg());
|
|
// Per-feature-type icons (indices match tree_icon_for): sketch/extrude/dressup/hole/thread.
|
|
m_tree_images = new wxImageList(16, 16);
|
|
m_tree_images->Add(create_scaled_bitmap("design_sketch", nullptr, 16)); // 0 Sketch
|
|
m_tree_images->Add(create_scaled_bitmap("design_extrude", nullptr, 16)); // 1 Extrude
|
|
m_tree_images->Add(create_scaled_bitmap("design_dressup", nullptr, 16)); // 2 Fillet/Chamfer
|
|
m_tree_images->Add(create_scaled_bitmap("design_hole", nullptr, 16)); // 3 Hole
|
|
m_tree_images->Add(create_scaled_bitmap("design_thread", nullptr, 16)); // 4 Thread
|
|
m_tree_images->Add(create_scaled_bitmap("design_shell", nullptr, 16)); // 5 Shell
|
|
m_tree->AssignImageList(m_tree_images);
|
|
tree_inner->Add(m_tree, 0, wxEXPAND | wxALL, 12);
|
|
|
|
// Selecting a body-producing feature (Extrude/Fillet/Chamfer/Hole/Thread) in the
|
|
// tree highlights the solid in the viewport; a Sketch row clears the highlight
|
|
// (its face is already shown via the persistent sketch overlay).
|
|
m_tree->Bind(wxEVT_TREE_SEL_CHANGED, [this](wxTreeEvent&) {
|
|
if (!m_viewport) return;
|
|
// Bodies live in the Parts list now; picking a feature here drops any body selection
|
|
// so the two lists can't both claim to be "the target".
|
|
if (m_parts) m_parts->UnselectAll();
|
|
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 actions: act on the selected feature (delete / reorder). These sit in the
|
|
// card header (Prepare puts its section actions there too) rather than on a loose row below.
|
|
{
|
|
wxBoxSizer* trow = m_hdr_tree_row;
|
|
auto edit_btn = [this](const char* icon, const wxString& tip) {
|
|
// Header-sized: reads as a section action, not a primary control.
|
|
auto* b = new ScalableButton(m_tree_box, wxID_ANY, icon, "", wxSize(24, 24),
|
|
wxDefaultPosition, wxBU_EXACTFIT | wxBORDER_NONE, false, 20);
|
|
b->SetToolTip(tip);
|
|
return b;
|
|
};
|
|
auto* edit = edit_btn("design_edit", _L("Edit"));
|
|
edit->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_edit_feature(); });
|
|
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); });
|
|
m_btn_interfere = edit_btn("color_palette", _L("Check interference — find overlapping bodies"));
|
|
m_btn_interfere->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_check_interference(); });
|
|
const int gap = FromDIP(SidebarProps::ElementSpacing());
|
|
trow->Add(edit, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, gap);
|
|
trow->Add(move, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, gap);
|
|
trow->Add(vis, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, gap);
|
|
trow->Add(del, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, gap);
|
|
trow->Add(up, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, gap);
|
|
trow->Add(down, 0, wxALIGN_CENTER_VERTICAL);
|
|
// Interference check sits after a rule: it reports, it does not edit the recipe.
|
|
trow->AddSpacer(8);
|
|
trow->Add(new wxStaticLine(m_tree_box, wxID_ANY, wxDefaultPosition, wxSize(1, 22), wxLI_VERTICAL),
|
|
0, wxALIGN_CENTER_VERTICAL);
|
|
trow->AddSpacer(8);
|
|
trow->Add(m_btn_interfere, 0, wxALIGN_CENTER_VERTICAL);
|
|
// trow IS the header sizer — already added to root above the tree.
|
|
}
|
|
|
|
// Parts list (Onshape's Features + Parts split). Bodies used to be appended after the
|
|
// features INSIDE the feature tree, so they were pushed out of view as the history grew —
|
|
// with no way to select a body at all. Their own list keeps them reachable regardless.
|
|
m_parts_box = make_card(m_form);
|
|
auto* parts_inner = new wxBoxSizer(wxVERTICAL);
|
|
m_parts_box->SetSizer(parts_inner);
|
|
root->Add(m_parts_box, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 8);
|
|
|
|
m_parts_hdr = new wxBoxSizer(wxHORIZONTAL);
|
|
m_parts_hdr->Add(card_header(m_parts_box, "design_extrude", _L("Bodies"), m_parts_label), 0,
|
|
wxALIGN_CENTER_VERTICAL);
|
|
parts_inner->Add(m_parts_hdr, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP,
|
|
FromDIP(SidebarProps::ContentMargin()));
|
|
m_parts_rule = new wxStaticLine(m_parts_box);
|
|
parts_inner->Add(m_parts_rule, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP,
|
|
FromDIP(SidebarProps::TitlebarMargin()));
|
|
m_parts_hdr->ShowItems(false); // no bodies yet on a fresh document
|
|
m_parts_rule->Hide();
|
|
m_parts = new wxTreeCtrl(m_parts_box, wxID_ANY, wxDefaultPosition, wxSize(-1, 48),
|
|
wxTR_HIDE_ROOT | wxTR_SINGLE | wxTR_NO_LINES |
|
|
wxTR_FULL_ROW_HIGHLIGHT | wxBORDER_SIMPLE);
|
|
if (!dp_dark()) m_parts->SetBackgroundColour(dp_panel_bg());
|
|
parts_inner->Add(m_parts, 0, wxEXPAND | wxALL, 12);
|
|
// Start hidden: a fresh document has no bodies, and refresh_parts() only runs on the first
|
|
// tree rebuild — until then an empty box would sit under the header.
|
|
m_parts->Hide();
|
|
m_parts_label->Hide();
|
|
m_parts->Bind(wxEVT_TREE_SEL_CHANGED, [this](wxTreeEvent&) {
|
|
if (!m_viewport) return;
|
|
const int b = tree_body_selection();
|
|
if (b < 0) return;
|
|
// One selection at a time: a body row and a feature row mean different things to the
|
|
// op bar, so clear the feature tree's highlight when a body takes over.
|
|
if (m_tree) m_tree->UnselectAll();
|
|
m_viewport->set_body_highlight(false); // the per-body overlay does the tint
|
|
m_viewport->select_body(b);
|
|
m_sel_solid_body = b;
|
|
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"), b + 1));
|
|
m_status->Refresh();
|
|
});
|
|
|
|
// --- Variables (document-scope named expressions) ---
|
|
// Below the feature tree + parts, always visible. wxListCtrl in report mode with two
|
|
// columns (Name, Expression). Add / edit open a small dialog; delete removes the
|
|
// selected row. Every mutation goes through checkpoint+recompute+undo-on-failure.
|
|
m_var_box = make_card(m_form);
|
|
auto* var_inner = new wxBoxSizer(wxVERTICAL);
|
|
m_var_box->SetSizer(var_inner);
|
|
{
|
|
auto* var_hdr = new wxBoxSizer(wxHORIZONTAL);
|
|
wxStaticText* var_hdr_title = nullptr;
|
|
var_hdr->Add(card_header(m_var_box, "design_constrain", _L("Variables"), var_hdr_title), 0,
|
|
wxALIGN_CENTER_VERTICAL);
|
|
var_hdr->AddStretchSpacer();
|
|
m_btn_add_var = new wxButton(m_var_box, wxID_ANY, _L("+"), wxDefaultPosition, wxSize(30, 24));
|
|
m_btn_edit_var = new wxButton(m_var_box, wxID_ANY, _L("Edit"), wxDefaultPosition, wxSize(50, 24));
|
|
m_btn_del_var = new wxButton(m_var_box, wxID_ANY, _L("Del"), wxDefaultPosition, wxSize(42, 24));
|
|
m_btn_add_var->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_add_variable(); });
|
|
m_btn_edit_var->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_edit_variable(); });
|
|
m_btn_del_var->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_remove_variable(); });
|
|
const int vhgap = FromDIP(SidebarProps::ElementSpacing());
|
|
var_hdr->Add(m_btn_add_var, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, vhgap);
|
|
var_hdr->Add(m_btn_edit_var, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, vhgap);
|
|
var_hdr->Add(m_btn_del_var, 0, wxALIGN_CENTER_VERTICAL);
|
|
var_inner->Add(var_hdr, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP,
|
|
FromDIP(SidebarProps::ContentMargin()));
|
|
var_inner->Add(new wxStaticLine(m_var_box), 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP,
|
|
FromDIP(SidebarProps::TitlebarMargin()));
|
|
|
|
m_var_list = new wxListCtrl(m_var_box, wxID_ANY, wxDefaultPosition,
|
|
wxSize(-1, FromDIP(64)), wxLC_REPORT | wxLC_SINGLE_SEL);
|
|
if (!dp_dark()) m_var_list->SetBackgroundColour(dp_panel_bg());
|
|
m_var_list->AppendColumn(_L("Name"), wxLIST_FORMAT_LEFT, FromDIP(90));
|
|
m_var_list->AppendColumn(_L("Expression"), wxLIST_FORMAT_LEFT, FromDIP(120));
|
|
var_inner->Add(m_var_list, 0, wxEXPAND | wxALL, FromDIP(SidebarProps::ContentMargin()));
|
|
}
|
|
root->Add(m_var_box, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 8);
|
|
|
|
// Orca-styled full-width buttons (ButtonType::Expanded), so the Design sidebar reads like
|
|
// Prepare's instead of showing raw OS-default wxButtons.
|
|
const int cm = FromDIP(SidebarProps::ContentMargin());
|
|
|
|
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);
|
|
|
|
|
|
m_shape->Bind(wxEVT_COMBOBOX, [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_COMBOBOX, [this](wxCommandEvent& e) { refresh_preview(); e.Skip(); });
|
|
m_form->Bind(wxEVT_CHECKBOX, [this](wxCommandEvent& e) { refresh_preview(); e.Skip(); });
|
|
m_form->Bind(wxEVT_TOGGLEBUTTON, [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_parts_box, false, false); // no bodies on a fresh document
|
|
root->Show(m_cards, false, false); // no tool open yet: don't draw an empty frame
|
|
cards->Show(m_box_sketch, false, true);
|
|
cards->Show(m_box_polygon, false, true);
|
|
cards->Show(m_box_move, false, true);
|
|
cards->Show(m_box_extrude, false, true);
|
|
cards->Show(m_box_revolve, false, true);
|
|
cards->Show(m_box_sweep, false, true);
|
|
cards->Show(m_box_pattern, false, true);
|
|
cards->Show(m_box_plane, false, true);
|
|
cards->Show(m_box_axis, false, true);
|
|
cards->Show(m_box_coordsys, false, true);
|
|
cards->Show(m_box_loft, false, true);
|
|
cards->Show(m_box_draft, false, true);
|
|
cards->Show(m_box_boolean, false, true);
|
|
cards->Show(m_box_cut, false, true);
|
|
cards->Show(m_box_insert, false, true);
|
|
cards->Show(m_box_dressup, false, true);
|
|
cards->Show(m_box_hole, false, true);
|
|
cards->Show(m_box_thread, false, true);
|
|
cards->Show(m_box_shell, false, true);
|
|
cards->Show(m_box_surf_extrude, false, true);
|
|
cards->Show(m_box_surf_revolve, false, true);
|
|
cards->Show(m_box_surf_loft, false, true);
|
|
cards->Show(m_box_surf_fill, false, true);
|
|
cards->Show(m_box_surf_offset, false, true);
|
|
cards->Show(m_box_surf_thicken, false, true);
|
|
cards->Show(m_box_value, false, true);
|
|
cards->Show(m_box_sketch_session, false, true);
|
|
cards->Show(m_box_constraints, false, true);
|
|
cards->Show(m_box_expr, false, true);
|
|
// A card added to the cards sizer is VISIBLE until something hides it. close_tool()'s
|
|
// hide-all only runs on a tool switch, so any card missing from THIS block renders
|
|
// stacked in the sidebar from the moment the tab opens. These eight were wired into
|
|
// close_tool() but not here, which is what bloated the panel.
|
|
cards->Show(m_box_transform, false, true);
|
|
cards->Show(m_box_mirror, false, true);
|
|
cards->Show(m_box_thicken, false, true);
|
|
cards->Show(m_box_rib, false, true);
|
|
cards->Show(m_box_project, false, true);
|
|
cards->Show(m_box_delete_face, false, true);
|
|
cards->Show(m_box_helix, false, true);
|
|
cards->Show(m_box_mate, false, true);
|
|
|
|
m_form->FitInside();
|
|
m_form->SetScrollRate(0, 10); // vertical only, like Prepare's sidebar: never scroll labels out
|
|
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()); m_dof_status->Show(!m_dof_status->GetLabel().IsEmpty());
|
|
} else if (!ok) {
|
|
m_dof_status->SetForegroundColour(wxColour(235, 80, 80));
|
|
m_dof_status->SetLabel(_L("✗ Conflicting constraints")); m_dof_status->Show(!m_dof_status->GetLabel().IsEmpty());
|
|
} else if (dof == 0) {
|
|
m_dof_status->SetForegroundColour(wxColour(80, 200, 110));
|
|
m_dof_status->SetLabel(_L("✓ Fully constrained")); m_dof_status->Show(!m_dof_status->GetLabel().IsEmpty());
|
|
} else if (dof > 0) {
|
|
m_dof_status->SetForegroundColour(dp_ctl_text());
|
|
m_dof_status->SetLabel(wxString::Format(_L("%d degrees of freedom"), dof)); m_dof_status->Show(!m_dof_status->GetLabel().IsEmpty());
|
|
} else {
|
|
m_dof_status->SetLabel(wxString()); m_dof_status->Show(!m_dof_status->GetLabel().IsEmpty());
|
|
}
|
|
m_dof_status->Refresh();
|
|
update_cards_frame(); 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; }
|
|
// Say what got picked. Without this the ONLY feedback is the viewport highlight, so a
|
|
// pick that registers but draws faintly is indistinguishable from one that never
|
|
// happened — which is precisely how this failure was reported and why it resisted
|
|
// diagnosis. Cards that show their own labels still do.
|
|
if (m_status != nullptr) {
|
|
m_status->SetForegroundColour(wxNullColour);
|
|
if (level <= 0)
|
|
m_status->SetLabel(_L("Selection cleared"));
|
|
else if (level == 1)
|
|
m_status->SetLabel(wxString::Format(_L("Body %d selected — click again for a face"), body + 1));
|
|
else if (level == 2)
|
|
m_status->SetLabel(wxString::Format(_L("Body %d, face %d — click again for an edge"), body + 1, face));
|
|
else
|
|
m_status->SetLabel(wxString::Format(_L("Body %d, edge %d selected"), body + 1, edge));
|
|
m_status->Refresh();
|
|
}
|
|
// 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(); }
|
|
}
|
|
// Axis tool with a pick armed: face or edge.
|
|
if (m_active == Tool::Axis && m_axis_pick != AxisPick::None) {
|
|
bool got = false;
|
|
switch (m_axis_pick) {
|
|
case AxisPick::Face: if (m_sel_solid_face >= 0) { m_ax_face_body = m_sel_solid_body; m_ax_face = m_sel_solid_face; got = true; } break;
|
|
case AxisPick::Edge: if (m_sel_solid_edge >= 0) { m_ax_face_body = m_sel_solid_body; m_ax_edge = m_sel_solid_edge; got = true; } break;
|
|
default: break;
|
|
}
|
|
if (got) { m_axis_pick = AxisPick::None; refresh_axis_labels(); }
|
|
}
|
|
// CoordSys tool with a pick armed: face or edge.
|
|
if (m_active == Tool::CoordSys && m_coordsys_pick != CoordSysPick::None) {
|
|
bool got = false;
|
|
switch (m_coordsys_pick) {
|
|
case CoordSysPick::Face: if (m_sel_solid_face >= 0) { m_cs_face_body = m_sel_solid_body; m_cs_face = m_sel_solid_face; got = true; } break;
|
|
case CoordSysPick::Edge: if (m_sel_solid_edge >= 0) { m_cs_face_body = m_sel_solid_body; m_cs_edge = m_sel_solid_edge; got = true; } break;
|
|
default: break;
|
|
}
|
|
if (got) { m_coordsys_pick = CoordSysPick::None; refresh_coordsys_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();
|
|
});
|
|
|
|
// Cut gizmo: dragging the offset arrow writes the Cut-card offset and refreshes the ghost.
|
|
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; show_move_card(false); 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_cards->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_cards->GetSizer()->Show(m_box_sketch_session, m == UiMode::Sketch, true);
|
|
update_cards_frame(); 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_cards->GetSizer() != nullptr) {
|
|
if (m == UiMode::Constrain)
|
|
rebuild_constraint_list();
|
|
else
|
|
m_cards->GetSizer()->Show(m_box_constraints, false, true);
|
|
update_cards_frame(); 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"));
|
|
}
|
|
|
|
// Run a long CAD computation off the UI thread. A big STEP costs tens of seconds in OCCT
|
|
// (parse + tessellate); running it inline froze the whole window — the compositor marked the
|
|
// app unresponsive and nothing repainted. The dialog is app-modal, so the document cannot be
|
|
// touched while the worker owns it. Exceptions must not escape the worker: `work` is expected
|
|
// to swallow them (OCCT throws Standard_Failure, which is not a std::exception).
|
|
static void run_off_ui_thread(wxWindow* parent, const wxString& message, const std::function<void()>& work)
|
|
{
|
|
std::atomic<bool> done{false};
|
|
std::thread worker([&work, &done]() {
|
|
work();
|
|
done.store(true, std::memory_order_release);
|
|
});
|
|
|
|
// Input stays blocked for the whole operation: the worker owns the document, so nothing
|
|
// in the UI may mutate it meanwhile. The dialog only appears if the work is actually slow —
|
|
// a fillet on a small body finishes in milliseconds and must not flash a dialog.
|
|
wxWindowDisabler disabler;
|
|
std::unique_ptr<wxProgressDialog> dlg;
|
|
int elapsed_ms = 0;
|
|
while (!done.load(std::memory_order_acquire)) {
|
|
if (dlg == nullptr && elapsed_ms >= 300)
|
|
dlg = std::make_unique<wxProgressDialog>(_L("SnapOrca"), message, 100, parent,
|
|
wxPD_AUTO_HIDE | wxPD_SMOOTH);
|
|
if (dlg != nullptr)
|
|
dlg->Pulse();
|
|
wxYield(); // keep the window painting instead of going unresponsive
|
|
wxMilliSleep(30);
|
|
elapsed_ms += 30;
|
|
}
|
|
worker.join();
|
|
}
|
|
|
|
// Rebuild the document off the UI thread. Every feature op (fillet, cut, shell, boolean, ...)
|
|
// goes through recompute(), and on a heavy imported solid that is seconds of OCCT work — inline
|
|
// it freezes the window. OCCT throws Standard_Failure, which is not a std::exception and would
|
|
// terminate the process if it escaped the worker, so both are caught here.
|
|
bool DesignPanel::recompute_guarded(const wxString& message)
|
|
{
|
|
bool ok = false;
|
|
run_off_ui_thread(this, message, [this, &ok]() {
|
|
try {
|
|
ok = m_doc.recompute();
|
|
} catch (const Standard_Failure& e) {
|
|
const char* what = e.GetMessageString();
|
|
m_doc.error = (what != nullptr && *what != '\0') ? what : "OCCT failure";
|
|
ok = false;
|
|
} catch (const std::exception& e) {
|
|
m_doc.error = e.what();
|
|
ok = false;
|
|
}
|
|
});
|
|
return ok;
|
|
}
|
|
|
|
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.
|
|
std::vector<TopoDS_Shape> solids;
|
|
run_off_ui_thread(this, _L("Reading STEP…"), [&]() {
|
|
try {
|
|
solids = GeometryEngine::read_step_solids(path, err);
|
|
} catch (const Standard_Failure& e) {
|
|
const char* what = e.GetMessageString();
|
|
err = (what != nullptr && *what != '\0') ? what : "OCCT failure";
|
|
} catch (const std::exception& e) {
|
|
err = e.what();
|
|
}
|
|
});
|
|
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);
|
|
}
|
|
bool rebuilt = false;
|
|
run_off_ui_thread(this, _L("Rebuilding model…"), [&]() {
|
|
try {
|
|
rebuilt = m_doc.recompute();
|
|
} catch (const Standard_Failure& e) {
|
|
const char* what = e.GetMessageString();
|
|
m_doc.error = (what != nullptr && *what != '\0') ? what : "OCCT failure";
|
|
} catch (const std::exception& e) {
|
|
m_doc.error = e.what();
|
|
}
|
|
});
|
|
if (!rebuilt) {
|
|
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();
|
|
}
|
|
|
|
// Import a triangle mesh as a real B-rep body: the triangles are rebuilt into OCCT faces with
|
|
// shared topology, then coplanar neighbours are merged so the result has pickable CAD faces
|
|
// rather than one face per triangle. Lands in the same CadFeatureType::Import as a STEP, so
|
|
// every downstream feature tool (fillet / cut / shell / face-extrude) works on it unchanged.
|
|
void DesignPanel::on_import_mesh()
|
|
{
|
|
wxFileDialog dlg(this, _L("Import mesh"), wxEmptyString, wxEmptyString,
|
|
"Mesh files (*.stl;*.obj)|*.stl;*.obj|All files|*.*",
|
|
wxFD_OPEN | wxFD_FILE_MUST_EXIST);
|
|
if (dlg.ShowModal() != wxID_OK)
|
|
return;
|
|
const std::string path(dlg.GetPath().ToUTF8().data());
|
|
|
|
auto fail = [this](const wxString& msg) {
|
|
m_status->SetForegroundColour(wxColour(235, 110, 110));
|
|
m_status->SetLabel(msg);
|
|
m_status->Refresh();
|
|
};
|
|
|
|
// Load the triangles with the slicer's own readers — no new mesh dependency.
|
|
TriangleMesh mesh;
|
|
const std::string ext = boost::algorithm::to_lower_copy(
|
|
boost::filesystem::path(path).extension().string());
|
|
if (ext == ".stl") {
|
|
if (!mesh.ReadSTLFile(path.c_str())) { fail(_L("Could not read the STL file")); return; }
|
|
} else if (ext == ".obj") {
|
|
ObjInfo obj_info;
|
|
std::string obj_err;
|
|
if (!load_obj(path.c_str(), &mesh, obj_info, obj_err)) {
|
|
fail(_L("Could not read the OBJ file: ") + wxString::FromUTF8(obj_err));
|
|
return;
|
|
}
|
|
} else {
|
|
fail(_L("Unsupported mesh format (STL and OBJ are supported)"));
|
|
return;
|
|
}
|
|
if (mesh.its.indices.empty()) { fail(_L("The mesh contains no triangles")); return; }
|
|
|
|
// One planar face per triangle before merging, so the cost is driven by the triangle count.
|
|
// A dense organic scan has few coplanar neighbours to merge away and stays heavy afterwards;
|
|
// warn rather than silently freezing the CAD kernel on every subsequent recompute.
|
|
if (mesh.its.indices.size() > MESH_IMPORT_TRIANGLE_WARN) {
|
|
const wxString q = wxString::Format(
|
|
_L("This mesh has %d triangles. Every triangle becomes a B-rep face before coplanar "
|
|
"merging, so importing it may take a long time and leave a body that is slow to "
|
|
"edit. Decimating the mesh first is usually better.\n\nImport anyway?"),
|
|
int(mesh.its.indices.size()));
|
|
if (wxMessageBox(q, _L("Large mesh"), wxYES_NO | wxICON_WARNING, this) != wxYES)
|
|
return;
|
|
}
|
|
|
|
wxBusyCursor busy;
|
|
GeometryEngine::MeshBrepStats stats;
|
|
TopoDS_Shape shape;
|
|
try {
|
|
shape = GeometryEngine::mesh_to_brep(mesh.its, MESH_IMPORT_TOLERANCE,
|
|
MESH_IMPORT_MERGE_ANGLE_DEG, stats);
|
|
} catch (const std::exception& e) {
|
|
fail(_L("Mesh conversion failed: ") + wxString::FromUTF8(e.what()));
|
|
return;
|
|
} catch (const Standard_Failure& e) { // OCCT throws outside std::exception
|
|
fail(_L("Mesh conversion failed: ") + wxString::FromUTF8(
|
|
e.GetMessageString() ? e.GetMessageString() : "OCCT error"));
|
|
return;
|
|
}
|
|
if (shape.IsNull()) { fail(_L("Mesh conversion produced no geometry")); return; }
|
|
|
|
m_doc.checkpoint(); // undo boundary: importing a mesh as a B-rep body
|
|
m_feature_counter++;
|
|
CadFeature f;
|
|
f.type = CadFeatureType::Import;
|
|
f.name = std::string("Mesh") + std::to_string(m_feature_counter);
|
|
f.imported_solid = shape;
|
|
f.mode = BooleanMode::New; // its own coexisting body, like a STEP solid
|
|
m_doc.features.push_back(f);
|
|
|
|
if (!recompute_guarded(_L("Rebuilding model…"))) {
|
|
fail(_L("Mesh import failed: ") + wxString::FromUTF8(m_doc.error));
|
|
return;
|
|
}
|
|
set_ui_mode(UiMode::Feature);
|
|
refresh_tree();
|
|
set_tree_selection(int(m_doc.features.size()) - 1);
|
|
set_status_ok();
|
|
|
|
// Report what the mesh actually was, never dress an open shell up as a solid: if it is not
|
|
// watertight, say so and say why (boundary vs non-manifold edges) — that is a defect in the
|
|
// source mesh the user needs to know about before they start cutting features into it.
|
|
if (stats.is_solid) {
|
|
m_status->SetForegroundColour(wxNullColour);
|
|
m_status->SetLabel(wxString::Format(
|
|
_L("Imported solid — %d triangles → %d faces, volume %.2f mm³. Pick a face or edge, "
|
|
"then Fillet / Cut / Shell to modify"),
|
|
stats.kept_tris, stats.faces_final, stats.volume));
|
|
} else {
|
|
m_status->SetForegroundColour(wxColour(220, 160, 60)); // warning, not an error
|
|
m_status->SetLabel(wxString::Format(
|
|
_L("Imported as an open shell (not watertight): %d boundary edge(s), %d non-manifold "
|
|
"edge(s) — %d triangles → %d faces. The source mesh has holes or duplicated "
|
|
"geometry; boolean features may fail on it"),
|
|
stats.boundary_edges, stats.nonmanifold_edges, stats.kept_tris, stats.faces_final));
|
|
}
|
|
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_cards->GetSizer();
|
|
s->Show(m_box_insert, true, true);
|
|
update_cards_frame(); 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 (!recompute_guarded(_L("Rebuilding model…")))
|
|
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 (!recompute_guarded(_L("Rebuilding model…")))
|
|
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 (!recompute_guarded(_L("Rebuilding model…")))
|
|
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 (!recompute_guarded(_L("Rebuilding model…")))
|
|
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 (!recompute_guarded(_L("Rebuilding model…")))
|
|
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 (!recompute_guarded(_L("Rebuilding model…")))
|
|
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 (!recompute_guarded(_L("Rebuilding model…")))
|
|
m_status->SetLabel(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error));
|
|
else
|
|
set_status_ok();
|
|
|
|
refresh_tree();
|
|
}
|
|
|
|
void DesignPanel::on_add_surface_extrude()
|
|
{
|
|
if (m_surf_extrude_sketch_ref < 0 || m_surf_extrude_sketch_ref >= int(m_doc.features.size())) {
|
|
m_status->SetLabel(_L("Pick a sketch profile to extrude first"));
|
|
return;
|
|
}
|
|
m_feature_counter++;
|
|
m_doc.add_surface_extrude(m_surf_extrude_sketch_ref, m_surf_extrude_distance->GetValue(),
|
|
"SurfaceExtrude" + std::to_string(m_feature_counter));
|
|
if (!recompute_guarded(_L("Rebuilding model…")))
|
|
m_status->SetLabel(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error));
|
|
else
|
|
set_status_ok();
|
|
refresh_tree();
|
|
}
|
|
|
|
void DesignPanel::on_add_surface_revolve()
|
|
{
|
|
if (m_surf_revolve_sketch_ref < 0 || m_surf_revolve_sketch_ref >= int(m_doc.features.size())) {
|
|
m_status->SetLabel(_L("Pick a sketch profile to revolve first"));
|
|
return;
|
|
}
|
|
m_feature_counter++;
|
|
m_doc.add_surface_revolve(m_surf_revolve_sketch_ref, m_surf_revolve_angle->GetValue(),
|
|
m_surf_revolve_axis->GetSelection(),
|
|
"SurfaceRevolve" + std::to_string(m_feature_counter));
|
|
if (!recompute_guarded(_L("Rebuilding model…")))
|
|
m_status->SetLabel(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error));
|
|
else
|
|
set_status_ok();
|
|
refresh_tree();
|
|
}
|
|
|
|
void DesignPanel::on_add_surface_loft()
|
|
{
|
|
std::vector<int> refs;
|
|
for (unsigned i = 0; i < m_surf_loft_list->GetCount(); ++i)
|
|
if (m_surf_loft_list->IsChecked(i) && i < m_surf_loft_sketch_idx.size())
|
|
refs.push_back(m_surf_loft_sketch_idx[i]);
|
|
if (refs.size() < 2) {
|
|
m_status->SetLabel(_L("Check at least two profile sketches to loft"));
|
|
return;
|
|
}
|
|
m_feature_counter++;
|
|
m_doc.add_surface_loft(refs, m_surf_loft_ruled->GetValue(),
|
|
"SurfaceLoft" + std::to_string(m_feature_counter));
|
|
if (!recompute_guarded(_L("Rebuilding model…")))
|
|
m_status->SetLabel(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error));
|
|
else
|
|
set_status_ok();
|
|
refresh_tree();
|
|
}
|
|
|
|
void DesignPanel::on_add_surface_fill()
|
|
{
|
|
if (m_surf_fill_sketch_ref < 0 || m_surf_fill_sketch_ref >= int(m_doc.features.size())) {
|
|
m_status->SetLabel(_L("Pick a sketch to fill first"));
|
|
return;
|
|
}
|
|
m_feature_counter++;
|
|
m_doc.add_surface_fill(m_surf_fill_sketch_ref,
|
|
"SurfaceFill" + std::to_string(m_feature_counter));
|
|
if (!recompute_guarded(_L("Rebuilding model…")))
|
|
m_status->SetLabel(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error));
|
|
else
|
|
set_status_ok();
|
|
refresh_tree();
|
|
}
|
|
|
|
void DesignPanel::on_add_surface_offset()
|
|
{
|
|
const int sel = sheet_choice_body(m_surf_offset_body);
|
|
if (sel < 0 || sel >= int(m_doc.bodies.size())) {
|
|
m_status->SetLabel(_L("Select a sheet body first"));
|
|
return;
|
|
}
|
|
m_feature_counter++;
|
|
m_doc.add_surface_offset(sel, m_surf_offset_distance->GetValue(),
|
|
"SurfaceOffset" + std::to_string(m_feature_counter));
|
|
if (!recompute_guarded(_L("Rebuilding model…")))
|
|
m_status->SetLabel(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error));
|
|
else
|
|
set_status_ok();
|
|
refresh_tree();
|
|
}
|
|
|
|
void DesignPanel::on_add_thicken_surface()
|
|
{
|
|
const int sel = sheet_choice_body(m_surf_thicken_body);
|
|
if (sel < 0 || sel >= int(m_doc.bodies.size())) {
|
|
m_status->SetLabel(_L("Select a sheet body first"));
|
|
return;
|
|
}
|
|
m_feature_counter++;
|
|
m_doc.add_thicken_surface(sel, m_surf_thicken_thickness->GetValue(),
|
|
m_surf_thicken_flip->GetValue(),
|
|
"ThickenSurface" + std::to_string(m_feature_counter));
|
|
if (!recompute_guarded(_L("Rebuilding model…")))
|
|
m_status->SetLabel(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error));
|
|
else
|
|
set_status_ok();
|
|
refresh_tree();
|
|
}
|
|
|
|
void DesignPanel::on_add_transform()
|
|
{
|
|
if (m_doc.bodies.empty()) {
|
|
m_status->SetLabel(_L("Transform needs a body — add or import one first"));
|
|
return;
|
|
}
|
|
const int sel = m_xf_body->GetSelection();
|
|
const int target = (sel != wxNOT_FOUND) ? sel : -1;
|
|
const Vec3d trans(m_xf_dx->GetValue(), m_xf_dy->GetValue(), m_xf_dz->GetValue());
|
|
const int ax = m_xf_axis->GetSelection();
|
|
const Vec3d axis = (ax == 0) ? Vec3d(1, 0, 0) : (ax == 1) ? Vec3d(0, 1, 0) : Vec3d(0, 0, 1);
|
|
const Vec3d pivot(m_xf_pivot_x->GetValue(), m_xf_pivot_y->GetValue(), m_xf_pivot_z->GetValue());
|
|
m_feature_counter++;
|
|
m_doc.add_transform(target, trans, axis, pivot, m_xf_angle->GetValue(), m_xf_copy->GetValue(),
|
|
"Transform" + std::to_string(m_feature_counter));
|
|
if (!recompute_guarded(_L("Rebuilding model…")))
|
|
m_status->SetLabel(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error));
|
|
else
|
|
set_status_ok();
|
|
refresh_tree();
|
|
}
|
|
|
|
void DesignPanel::on_add_mirror()
|
|
{
|
|
if (m_doc.bodies.empty()) {
|
|
m_status->SetLabel(_L("Mirror needs a body — add or import one first"));
|
|
return;
|
|
}
|
|
const int sel = m_mirror_body->GetSelection();
|
|
const int target = (sel != wxNOT_FOUND) ? sel : -1;
|
|
const BooleanMode mode = m_mirror_keep->GetValue() ? BooleanMode::New : BooleanMode::Add;
|
|
m_feature_counter++;
|
|
m_doc.add_mirror(plane_from_choice(m_mirror_plane->GetSelection()), target, mode,
|
|
"Mirror" + std::to_string(m_feature_counter));
|
|
if (!recompute_guarded(_L("Rebuilding model…")))
|
|
m_status->SetLabel(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error));
|
|
else
|
|
set_status_ok();
|
|
refresh_tree();
|
|
}
|
|
|
|
void DesignPanel::on_add_thicken()
|
|
{
|
|
if (m_doc.bodies.empty()) {
|
|
m_status->SetLabel(_L("Thicken needs a solid body — add or import one first"));
|
|
return;
|
|
}
|
|
if (m_sel_solid_face < 0) {
|
|
m_status->SetForegroundColour(wxColour(235, 110, 110));
|
|
m_status->SetLabel(_L("Pick a solid face to thicken first"));
|
|
m_status->Refresh();
|
|
return;
|
|
}
|
|
const int sel = m_thicken_body->GetSelection();
|
|
const int target = (sel != wxNOT_FOUND) ? sel : -1;
|
|
m_feature_counter++;
|
|
m_doc.add_thicken(target, m_sel_solid_face, m_thicken_thickness->GetValue(),
|
|
m_thicken_flip->GetValue(), "Thicken" + std::to_string(m_feature_counter));
|
|
if (!recompute_guarded(_L("Rebuilding model…")))
|
|
m_status->SetLabel(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error));
|
|
else
|
|
set_status_ok();
|
|
refresh_tree();
|
|
}
|
|
|
|
void DesignPanel::on_add_rib()
|
|
{
|
|
if (m_doc.bodies.empty()) {
|
|
m_status->SetLabel(_L("Rib needs a solid body — add or import one first"));
|
|
return;
|
|
}
|
|
const int bsel = m_rib_body->GetSelection();
|
|
const int target = (bsel != wxNOT_FOUND) ? bsel : -1;
|
|
const int ssel = m_rib_sketch->GetSelection();
|
|
const int sketch_ref = (ssel != wxNOT_FOUND)
|
|
? int(reinterpret_cast<intptr_t>(m_rib_sketch->GetClientData(ssel))) : -1;
|
|
if (sketch_ref < 0) {
|
|
m_status->SetLabel(_L("Pick a sketch with an open line first"));
|
|
return;
|
|
}
|
|
m_feature_counter++;
|
|
m_doc.add_rib(sketch_ref, m_rib_entity->GetValue(), m_rib_thickness->GetValue(),
|
|
m_rib_depth->GetValue(), target, "Rib" + std::to_string(m_feature_counter));
|
|
if (!recompute_guarded(_L("Rebuilding model…")))
|
|
m_status->SetLabel(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error));
|
|
else
|
|
set_status_ok();
|
|
refresh_tree();
|
|
}
|
|
|
|
void DesignPanel::on_add_project()
|
|
{
|
|
if (m_doc.bodies.empty()) {
|
|
m_status->SetLabel(_L("Project needs a body — add or import one first"));
|
|
return;
|
|
}
|
|
const int sel = m_proj_source_body->GetSelection();
|
|
const int src_body = (sel != wxNOT_FOUND) ? sel : -1;
|
|
const int face = (m_sel_solid_face >= 0) ? m_sel_solid_face : -1;
|
|
m_feature_counter++;
|
|
m_doc.add_project_edges(src_body, {}, face,
|
|
plane_from_choice(m_proj_plane->GetSelection()),
|
|
"Project" + std::to_string(m_feature_counter));
|
|
if (!recompute_guarded(_L("Rebuilding model…")))
|
|
m_status->SetLabel(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error));
|
|
else
|
|
set_status_ok();
|
|
refresh_tree();
|
|
}
|
|
|
|
void DesignPanel::on_add_delete_face()
|
|
{
|
|
if (m_doc.bodies.empty()) {
|
|
m_status->SetLabel(_L("Delete Face needs a body — add or import one first"));
|
|
return;
|
|
}
|
|
if (m_del_faces.empty()) {
|
|
m_status->SetForegroundColour(wxColour(235, 110, 110));
|
|
m_status->SetLabel(_L("Add at least one face to delete first"));
|
|
m_status->Refresh();
|
|
return;
|
|
}
|
|
const int sel = m_del_face_body->GetSelection();
|
|
const int target = (sel != wxNOT_FOUND) ? sel : -1;
|
|
m_feature_counter++;
|
|
m_doc.add_delete_face(target, m_del_faces, "DeleteFace" + std::to_string(m_feature_counter));
|
|
m_del_faces.clear(); // consumed; fresh state for the next use
|
|
if (!recompute_guarded(_L("Rebuilding model…")))
|
|
m_status->SetLabel(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error));
|
|
else
|
|
set_status_ok();
|
|
refresh_tree();
|
|
}
|
|
|
|
void DesignPanel::on_add_helix()
|
|
{
|
|
m_feature_counter++;
|
|
m_doc.add_helix(plane_from_choice(m_helix_plane->GetSelection()),
|
|
m_helix_radius->GetValue(), m_helix_pitch->GetValue(),
|
|
m_helix_height->GetValue(), m_helix_left_handed->GetValue(),
|
|
m_helix_taper->GetValue(), "Helix" + std::to_string(m_feature_counter));
|
|
if (!recompute_guarded(_L("Rebuilding model…")))
|
|
m_status->SetLabel(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error));
|
|
else
|
|
set_status_ok();
|
|
refresh_tree();
|
|
}
|
|
|
|
void DesignPanel::on_add_mate()
|
|
{
|
|
const int sel_a = m_mate_cs_a->GetSelection();
|
|
const int sel_b = m_mate_cs_b->GetSelection();
|
|
if (sel_a == wxNOT_FOUND || sel_b == wxNOT_FOUND) {
|
|
m_status->SetForegroundColour(wxColour(235, 110, 110));
|
|
m_status->SetLabel(_L("Mate needs two CoordSys features — create them first"));
|
|
m_status->Refresh();
|
|
return;
|
|
}
|
|
const int cs_a = int(reinterpret_cast<intptr_t>(m_mate_cs_a->GetClientData(sel_a)));
|
|
const int cs_b = int(reinterpret_cast<intptr_t>(m_mate_cs_b->GetClientData(sel_b)));
|
|
if (cs_a == cs_b) {
|
|
m_status->SetForegroundColour(wxColour(235, 110, 110));
|
|
m_status->SetLabel(_L("Mate: CS A and CS B must be different CoordSys features"));
|
|
m_status->Refresh();
|
|
return;
|
|
}
|
|
m_feature_counter++;
|
|
int idx = m_doc.add_mate(m_mate_kind->GetSelection(), cs_a, cs_b,
|
|
m_mate_offset->GetValue(), m_mate_angle->GetValue(),
|
|
m_mate_flip->GetValue(),
|
|
"Mate" + std::to_string(m_feature_counter));
|
|
if (idx < 0) {
|
|
m_status->SetForegroundColour(wxColour(235, 110, 110));
|
|
m_status->SetLabel(_L("Mate rejected"));
|
|
m_status->Refresh();
|
|
return;
|
|
}
|
|
if (!recompute_guarded(_L("Rebuilding model…")))
|
|
m_status->SetLabel(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error));
|
|
else
|
|
set_status_ok();
|
|
refresh_tree();
|
|
}
|
|
|
|
void DesignPanel::on_check_interference()
|
|
{
|
|
if (m_doc.bodies.size() < 2) {
|
|
m_status->SetForegroundColour(wxNullColour);
|
|
m_status->SetLabel(_L("No interference — need at least two solid bodies to check"));
|
|
m_status->Refresh();
|
|
return;
|
|
}
|
|
const auto pairs = m_doc.check_interference();
|
|
if (pairs.empty()) {
|
|
m_status->SetForegroundColour(wxNullColour);
|
|
m_status->SetLabel(_L("No interference found"));
|
|
m_status->Refresh();
|
|
return;
|
|
}
|
|
double worst = 0;
|
|
for (const auto& p : pairs)
|
|
if (p.volume > worst) worst = p.volume;
|
|
m_status->SetForegroundColour(wxNullColour);
|
|
m_status->SetLabel(wxString::Format(_L("%zu interference pairs, worst %.2f mm³"),
|
|
pairs.size(), worst));
|
|
m_status->Refresh();
|
|
wxString msg = _L("Interference pairs:\n\n");
|
|
for (const auto& p : pairs) {
|
|
// 1-based, like every other body label in this panel and in the parts tree:
|
|
// reporting "Body 1" for what the tree calls "Body 2" is worse than no name.
|
|
wxString na = wxString::Format(_L("Body %d"), p.body_a + 1);
|
|
wxString nb = wxString::Format(_L("Body %d"), p.body_b + 1);
|
|
if (p.body_a >= 0 && p.body_a < int(m_doc.bodies.size()) && !m_doc.bodies[p.body_a].name.empty())
|
|
na = wxString::FromUTF8(m_doc.bodies[p.body_a].name);
|
|
if (p.body_b >= 0 && p.body_b < int(m_doc.bodies.size()) && !m_doc.bodies[p.body_b].name.empty())
|
|
nb = wxString::FromUTF8(m_doc.bodies[p.body_b].name);
|
|
msg += wxString::Format("%s <-> %s: %.4f mm³\n", na, nb, p.volume);
|
|
}
|
|
wxMessageBox(msg, _L("Interference"), wxOK, this);
|
|
}
|
|
|
|
// The rows are only the SHEET bodies, so a row index is NOT a body index — with a solid at 0
|
|
// and a sheet at 1 the single row is row 0 but body 1. Every caller must therefore read the
|
|
// real body index out of the client data (3-arg Append; the 2-arg form takes a bitmap), never
|
|
// GetSelection(). Getting this wrong targets a solid and the kernel rejects it with
|
|
// "target is not a sheet", which reads as a kernel bug rather than a picker bug.
|
|
void DesignPanel::populate_sheet_body_choices(ComboBox* c) const
|
|
{
|
|
if (!c) return;
|
|
const int keep = c->GetSelection();
|
|
c->Clear();
|
|
for (size_t i = 0; i < m_doc.bodies.size(); ++i) {
|
|
if (!CadDocument::is_sheet_shape(m_doc.bodies[i].shape)) continue;
|
|
const std::string& n = m_doc.bodies[i].name;
|
|
c->Append(n.empty() ? wxString::Format(_L("Body %zu"), i + 1) : wxString::FromUTF8(n),
|
|
wxNullBitmap, reinterpret_cast<void*>(intptr_t(i)));
|
|
}
|
|
if (c->GetCount() > 0)
|
|
c->SetSelection(std::min(std::max(keep, 0), int(c->GetCount()) - 1));
|
|
}
|
|
|
|
// Real body index behind the current row of a sheet-filtered picker, or -1.
|
|
int DesignPanel::sheet_choice_body(ComboBox* c)
|
|
{
|
|
if (!c) return -1;
|
|
const int sel = c->GetSelection();
|
|
if (sel == wxNOT_FOUND) return -1;
|
|
return int(reinterpret_cast<intptr_t>(c->GetClientData(sel)));
|
|
}
|
|
|
|
// Select the row whose body index is `body`, so a re-edit restores the stored target rather
|
|
// than treating it as a row number.
|
|
void DesignPanel::select_sheet_choice(ComboBox* c, int body)
|
|
{
|
|
if (!c) return;
|
|
for (unsigned i = 0; i < c->GetCount(); ++i) {
|
|
if (int(reinterpret_cast<intptr_t>(c->GetClientData(i))) == body) { c->SetSelection(int(i)); return; }
|
|
}
|
|
if (c->GetCount() > 0) c->SetSelection(0);
|
|
}
|
|
|
|
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 (!recompute_guarded(_L("Rebuilding model…")))
|
|
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 = [&](ComboBox* 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 (!recompute_guarded(_L("Rebuilding model…")))
|
|
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 (!recompute_guarded(_L("Rebuilding model…")))
|
|
m_status->SetLabel(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error));
|
|
else
|
|
set_status_ok();
|
|
refresh_tree();
|
|
}
|
|
|
|
void DesignPanel::populate_plane_choices(ComboBox* 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();
|
|
}
|
|
|
|
// --- Axis helpers ---
|
|
|
|
void DesignPanel::apply_axis_refs(CadFeature& f) const
|
|
{
|
|
f.axis_type = (AxisType)m_axis_type->GetSelection();
|
|
f.axis_face = m_ax_face;
|
|
f.axis_edge = m_ax_edge;
|
|
f.axis_plane_a = m_axis_plane_a->GetSelection();
|
|
f.axis_plane_b = m_axis_plane_b->GetSelection();
|
|
f.axis_p1 = Vec3d(m_axis_p1x->GetValue(), m_axis_p1y->GetValue(), m_axis_p1z->GetValue());
|
|
f.axis_p2 = Vec3d(m_axis_p2x->GetValue(), m_axis_p2y->GetValue(), m_axis_p2z->GetValue());
|
|
f.axis_body = m_ax_face_body;
|
|
}
|
|
|
|
void DesignPanel::refresh_axis_labels()
|
|
{
|
|
auto txt = [](int idx) { return idx >= 0 ? wxString::Format("#%d", idx) : wxString(_L("(none)")); };
|
|
if (m_axis_face_lbl) m_axis_face_lbl->SetLabel(txt(m_ax_face));
|
|
if (m_axis_edge_lbl) m_axis_edge_lbl->SetLabel(txt(m_ax_edge));
|
|
}
|
|
|
|
void DesignPanel::reset_axis_refs()
|
|
{
|
|
m_ax_face_body = m_ax_face = -1;
|
|
m_ax_edge = -1;
|
|
m_axis_pick = AxisPick::None;
|
|
refresh_axis_labels();
|
|
}
|
|
|
|
void DesignPanel::arm_axis_pick(AxisPick target)
|
|
{
|
|
m_axis_pick = target;
|
|
m_status->SetForegroundColour(wxNullColour);
|
|
m_status->SetLabel(target == AxisPick::Face ? _L("Click a solid FACE in the viewport")
|
|
: _L("Click a solid EDGE in the viewport"));
|
|
m_status->Refresh();
|
|
}
|
|
|
|
// --- CoordSys helpers ---
|
|
|
|
void DesignPanel::apply_coordsys_refs(CadFeature& f) const
|
|
{
|
|
f.coordsys_type = (CoordSysType)m_coordsys_type->GetSelection();
|
|
f.coordsys_point = Vec3d(m_cs_x->GetValue(), m_cs_y->GetValue(), m_cs_z->GetValue());
|
|
f.coordsys_body = m_cs_face_body;
|
|
f.coordsys_face = m_cs_face;
|
|
f.coordsys_edge = m_cs_edge;
|
|
f.coordsys_x_hint = Vec3d(m_cs_hx->GetValue(), m_cs_hy->GetValue(), m_cs_hz->GetValue());
|
|
}
|
|
|
|
void DesignPanel::refresh_coordsys_labels()
|
|
{
|
|
auto txt = [](int idx) { return idx >= 0 ? wxString::Format("#%d", idx) : wxString(_L("(none)")); };
|
|
if (m_cs_face_lbl) m_cs_face_lbl->SetLabel(txt(m_cs_face));
|
|
if (m_cs_edge_lbl) m_cs_edge_lbl->SetLabel(txt(m_cs_edge));
|
|
}
|
|
|
|
void DesignPanel::reset_coordsys_refs()
|
|
{
|
|
m_cs_face_body = m_cs_face = -1;
|
|
m_cs_edge = -1;
|
|
m_coordsys_pick = CoordSysPick::None;
|
|
refresh_coordsys_labels();
|
|
}
|
|
|
|
void DesignPanel::arm_coordsys_pick(CoordSysPick target)
|
|
{
|
|
m_coordsys_pick = target;
|
|
m_status->SetForegroundColour(wxNullColour);
|
|
m_status->SetLabel(target == CoordSysPick::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_axis()
|
|
{
|
|
m_feature_counter++;
|
|
int idx = m_doc.add_axis((AxisType)m_axis_type->GetSelection(),
|
|
"Axis" + std::to_string(m_feature_counter));
|
|
if (idx >= 0 && idx < int(m_doc.features.size())) apply_axis_refs(m_doc.features[idx]);
|
|
m_doc.recompute();
|
|
m_status->SetForegroundColour(wxNullColour);
|
|
m_status->SetLabel(_L("Axis added"));
|
|
refresh_tree();
|
|
}
|
|
|
|
void DesignPanel::on_add_coordsys()
|
|
{
|
|
m_feature_counter++;
|
|
Vec3d pt(m_cs_x->GetValue(), m_cs_y->GetValue(), m_cs_z->GetValue());
|
|
int idx = m_doc.add_coordsys((CoordSysType)m_coordsys_type->GetSelection(), pt,
|
|
"Coord" + std::to_string(m_feature_counter));
|
|
if (idx >= 0 && idx < int(m_doc.features.size())) apply_coordsys_refs(m_doc.features[idx]);
|
|
m_doc.recompute();
|
|
m_status->SetForegroundColour(wxNullColour);
|
|
m_status->SetLabel(_L("Coord Sys added"));
|
|
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 (!recompute_guarded(_L("Rebuilding model…")))
|
|
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 (!recompute_guarded(_L("Rebuilding model…")))
|
|
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
|
|
case CadFeatureType::Axis: return 0; // datum axis: sketch-family icon
|
|
case CadFeatureType::CoordSys: return 0; // datum coord sys: sketch-family icon
|
|
case CadFeatureType::SurfaceExtrude: return 1;
|
|
case CadFeatureType::SurfaceRevolve: return 1;
|
|
case CadFeatureType::SurfaceLoft: return 1;
|
|
case CadFeatureType::SurfaceFill: return 1;
|
|
case CadFeatureType::ThickenSurface: return 1;
|
|
case CadFeatureType::SurfaceOffset: return 1;
|
|
case CadFeatureType::Transform: return 1; // solid-family icon
|
|
case CadFeatureType::Mirror: return 1; // solid-family icon
|
|
case CadFeatureType::Thicken: return 1; // solid-family icon
|
|
case CadFeatureType::Rib: return 1; // solid-family icon
|
|
case CadFeatureType::Project: return 0; // sketch-family icon (produces sketch)
|
|
case CadFeatureType::DeleteFace: return 5; // dressup-family icon
|
|
case CadFeatureType::Helix: return 4; // thread-family icon (curve)
|
|
case CadFeatureType::Mate: return 2; // dressup-family icon (assembly)
|
|
}
|
|
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
|
|
sync_sidebar_width(); // keep the panel as wide as Prepare's so the canvas edge doesn't jump
|
|
if (m_viewport) m_viewport->force_repaint(); // the page was just re-shown: paint it for real
|
|
}
|
|
|
|
// Match Prepare's sidebar width instead of hardcoding one. Design used a fixed 264 px against
|
|
// Prepare's ~467, so the canvas edge jumped sideways on every tab switch; reading the live width
|
|
// also means the two stay aligned if Orca ever changes its sidebar.
|
|
void DesignPanel::sync_sidebar_width()
|
|
{
|
|
if (m_form == nullptr) return;
|
|
Plater* pl = wxGetApp().plater();
|
|
if (pl == nullptr) return;
|
|
const int w = pl->sidebar().GetSize().GetWidth();
|
|
if (w < 200) return; // sidebar not laid out yet — keep what we have
|
|
if (m_form->GetMinSize().GetWidth() == w) return;
|
|
m_form->SetMinSize(wxSize(w, -1));
|
|
Layout();
|
|
}
|
|
|
|
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);
|
|
}
|
|
refresh_parts(); // bodies live in their own list below the tree, never clipped by history
|
|
if (keep >= 0 && keep < int(m_tree_items.size()))
|
|
m_tree->SelectItem(m_tree_items[keep]);
|
|
|
|
// Size the tree to its content (clamped) so it doesn't waste a fixed-height block when
|
|
// there are few features, and scrolls internally past ~9 rows instead of growing forever.
|
|
const int rows = int(m_tree_items.size()); // bodies are in their own list now
|
|
const int rowH = std::max(m_tree->GetCharHeight() + 8, 20);
|
|
const int shown = std::min(std::max(rows, 1), 9);
|
|
const wxSize ts(-1, shown * rowH + 8);
|
|
m_tree->SetMinSize(ts);
|
|
m_tree->SetMaxSize(ts);
|
|
if (m_form && m_form->GetSizer()) { update_cards_frame(); m_form->Layout(); m_form->FitInside(); }
|
|
}
|
|
|
|
// Rebuild the Parts list from the document's bodies, preserving the selected row so a
|
|
// recompute (fillet, hole, ...) doesn't drop the user's body selection under them.
|
|
void DesignPanel::refresh_parts()
|
|
{
|
|
if (m_parts == nullptr) return;
|
|
const int keep = tree_body_selection();
|
|
|
|
m_parts->DeleteAllItems();
|
|
m_tree_body_items.clear();
|
|
wxTreeItemId proot = m_parts->AddRoot("root");
|
|
|
|
sync_body_visible(); // keep flags parallel before reading them for the row colour
|
|
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_parts->AppendItem(proot, wxString::Format(_L("Body %zu"), b + 1));
|
|
// Hidden bodies are greyed so the show/hide state reads at a glance (eye toggle).
|
|
m_parts->SetItemTextColour(id, vis ? dp_item_text() : dp_item_dim());
|
|
m_tree_body_items.push_back(id);
|
|
}
|
|
|
|
// Hide the whole block until there is something to list, so an empty document doesn't
|
|
// show a stray empty box.
|
|
const bool any = !m_tree_body_items.empty();
|
|
m_parts->Show(any);
|
|
if (m_parts_label) m_parts_label->Show(any);
|
|
if (m_parts_hdr) m_parts_hdr->ShowItems(any); // icon + title live in this sizer
|
|
if (m_parts_rule) m_parts_rule->Show(any);
|
|
// ...and the frame with it, or an empty bordered box floats there.
|
|
if (m_parts_box && m_form && m_form->GetSizer()) m_form->GetSizer()->Show(m_parts_box, any, false);
|
|
|
|
if (any) {
|
|
const int rowH = std::max(m_parts->GetCharHeight() + 8, 20);
|
|
const int shown = std::min(int(m_tree_body_items.size()), 6); // scrolls past 6
|
|
const wxSize ps(-1, shown * rowH + 8);
|
|
m_parts->SetMinSize(ps);
|
|
m_parts->SetMaxSize(ps);
|
|
if (keep >= 0 && keep < int(m_tree_body_items.size()))
|
|
m_parts->SelectItem(m_tree_body_items[keep]);
|
|
}
|
|
if (m_form && m_form->GetSizer()) { update_cards_frame(); m_form->Layout(); m_form->FitInside(); }
|
|
}
|
|
|
|
int DesignPanel::tree_body_selection() const
|
|
{
|
|
if (m_parts == nullptr) return -1;
|
|
const wxTreeItemId sel = m_parts->GetSelection();
|
|
if (!sel.IsOk()) return -1;
|
|
for (size_t i = 0; i < m_tree_body_items.size(); ++i)
|
|
if (m_tree_body_items[i] == sel) return int(i);
|
|
return -1;
|
|
}
|
|
|
|
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) return;
|
|
if (b < 0 || b >= int(m_doc.display_body_meshes.size())) {
|
|
// Never fail silently here: the caller gates on bodies.size() while this needs a
|
|
// tessellated per-body mesh, and when those disagreed the click did nothing at all.
|
|
m_status->SetForegroundColour(wxColour(235, 110, 110));
|
|
m_status->SetLabel(b < 0 ? _L("Select a body first — click it in the viewport or the Bodies list")
|
|
: _L("That body has no display mesh yet — recompute first"));
|
|
m_status->Refresh();
|
|
return;
|
|
}
|
|
sync_body_xform();
|
|
// 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 BoundingBoxf3 bb = m_doc.display_body_meshes[b].bounding_box();
|
|
const Vec3d pivot = base * bb.center();
|
|
// Bounding-sphere radius (world): the gizmo scales with it so the rotation rings sit clear of
|
|
// the body instead of collapsing into a tangle inside it — same sizing rule as Orca's Prepare
|
|
// gizmos. The scale part of `base` is applied so a scaled body still gets a correct radius.
|
|
const double radius = (base.linear() * (bb.size() * 0.5)).norm();
|
|
m_viewport->begin_move_body(b, pivot, base, radius);
|
|
m_move_body = b; // for the action bar: Cancel reverts to this pose
|
|
m_move_prev = base;
|
|
// Reset the numeric fields to "no change" and show the card beside the drag gizmo.
|
|
if (m_move_dx) m_move_dx->SetValue(0.0);
|
|
if (m_move_dy) m_move_dy->SetValue(0.0);
|
|
if (m_move_dz) m_move_dz->SetValue(0.0);
|
|
if (m_move_angle) m_move_angle->SetValue(0.0);
|
|
show_move_card(true);
|
|
update_action_bar(); // surface the unified ✓/✗ while moving
|
|
m_status->SetForegroundColour(wxNullColour);
|
|
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();
|
|
refresh_tree();
|
|
refresh_variables();
|
|
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;
|
|
show_move_card(false);
|
|
m_body_xform.clear();
|
|
if (m_viewport) { m_viewport->clear_move_gizmo(); m_viewport->clear_mesh(); }
|
|
after_tree_edit(true); // rebuild the (now empty) tree + clear the viewport
|
|
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 (!recompute_guarded(_L("Rebuilding model…"))) {
|
|
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")); m_dof_status->Show(!m_dof_status->GetLabel().IsEmpty());
|
|
} else if (r.dof == 0) {
|
|
m_dof_status->SetForegroundColour(wxColour(80, 200, 110));
|
|
m_dof_status->SetLabel(_L("✓ Fully constrained")); m_dof_status->Show(!m_dof_status->GetLabel().IsEmpty());
|
|
} 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)); m_dof_status->Show(!m_dof_status->GetLabel().IsEmpty());
|
|
} else {
|
|
m_dof_status->SetLabel(wxString()); m_dof_status->Show(!m_dof_status->GetLabel().IsEmpty());
|
|
}
|
|
m_dof_status->Refresh();
|
|
update_cards_frame(); 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_cards->GetSizer()->Show(m_box_constraints, m_ui_mode == UiMode::Constrain, true);
|
|
update_cards_frame(); 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_cards->GetSizer()->Show(m_box_value, true, true);
|
|
update_cards_frame(); 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_cards->GetSizer()->Show(m_box_value, false, true);
|
|
update_cards_frame(); 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_cards->GetSizer()->Show(m_box_value, false, true);
|
|
update_cards_frame(); 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::Axis:
|
|
m_axis_type->SetSelection((int)f.axis_type);
|
|
m_ax_face_body = f.axis_body; m_ax_face = f.axis_face;
|
|
m_ax_edge = f.axis_edge;
|
|
populate_plane_choices(m_axis_plane_a);
|
|
populate_plane_choices(m_axis_plane_b);
|
|
m_axis_plane_a->SetSelection(f.axis_plane_a >= 0 ? f.axis_plane_a : 0);
|
|
m_axis_plane_b->SetSelection(f.axis_plane_b >= 0 ? f.axis_plane_b : 0);
|
|
m_axis_p1x->SetValue(f.axis_p1.x()); m_axis_p1y->SetValue(f.axis_p1.y()); m_axis_p1z->SetValue(f.axis_p1.z());
|
|
m_axis_p2x->SetValue(f.axis_p2.x()); m_axis_p2y->SetValue(f.axis_p2.y()); m_axis_p2z->SetValue(f.axis_p2.z());
|
|
m_axis_pick = AxisPick::None;
|
|
refresh_axis_labels();
|
|
break;
|
|
case CadFeatureType::CoordSys:
|
|
m_coordsys_type->SetSelection((int)f.coordsys_type);
|
|
m_cs_x->SetValue(f.coordsys_point.x());
|
|
m_cs_y->SetValue(f.coordsys_point.y());
|
|
m_cs_z->SetValue(f.coordsys_point.z());
|
|
m_cs_face_body = f.coordsys_body; m_cs_face = f.coordsys_face;
|
|
m_cs_edge = f.coordsys_edge;
|
|
m_cs_hx->SetValue(f.coordsys_x_hint.x());
|
|
m_cs_hy->SetValue(f.coordsys_x_hint.y());
|
|
m_cs_hz->SetValue(f.coordsys_x_hint.z());
|
|
m_coordsys_pick = CoordSysPick::None;
|
|
refresh_coordsys_labels();
|
|
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;
|
|
case CadFeatureType::SurfaceExtrude:
|
|
m_surf_extrude_distance->SetValue(f.distance);
|
|
m_surf_extrude_sketch_ref = f.sketch_ref;
|
|
if (m_surf_extrude_sketch_ref >= 0 && m_surf_extrude_sketch_ref < int(m_doc.features.size()))
|
|
m_surf_extrude_sketch_label->SetLabel(_L("Sketch: ") +
|
|
wxString::FromUTF8(m_doc.features[m_surf_extrude_sketch_ref].name));
|
|
break;
|
|
case CadFeatureType::SurfaceRevolve:
|
|
m_surf_revolve_angle->SetValue(f.revolve_angle);
|
|
m_surf_revolve_axis->SetSelection(f.revolve_axis);
|
|
m_surf_revolve_flip->SetValue(f.flip);
|
|
m_surf_revolve_sketch_ref = f.sketch_ref;
|
|
if (m_surf_revolve_sketch_ref >= 0 && m_surf_revolve_sketch_ref < int(m_doc.features.size()))
|
|
m_surf_revolve_sketch_label->SetLabel(_L("Sketch: ") +
|
|
wxString::FromUTF8(m_doc.features[m_surf_revolve_sketch_ref].name));
|
|
break;
|
|
case CadFeatureType::SurfaceLoft:
|
|
m_surf_loft_refs = f.loft_profile_refs;
|
|
m_surf_loft_ruled->SetValue(f.loft_ruled);
|
|
break;
|
|
case CadFeatureType::SurfaceFill:
|
|
m_surf_fill_sketch_ref = f.sketch_ref;
|
|
if (m_surf_fill_sketch_ref >= 0 && m_surf_fill_sketch_ref < int(m_doc.features.size()))
|
|
m_surf_fill_sketch_label->SetLabel(_L("Sketch: ") +
|
|
wxString::FromUTF8(m_doc.features[m_surf_fill_sketch_ref].name));
|
|
break;
|
|
case CadFeatureType::SurfaceOffset:
|
|
populate_sheet_body_choices(m_surf_offset_body);
|
|
m_surf_offset_distance->SetValue(f.plane_offset);
|
|
// target_body is a BODY index; the rows are sheets only, so match, don't index.
|
|
select_sheet_choice(m_surf_offset_body, f.target_body);
|
|
break;
|
|
case CadFeatureType::ThickenSurface:
|
|
populate_sheet_body_choices(m_surf_thicken_body);
|
|
m_surf_thicken_thickness->SetValue(f.thicken_thickness);
|
|
m_surf_thicken_flip->SetValue(f.thicken_flip);
|
|
select_sheet_choice(m_surf_thicken_body, f.target_body);
|
|
break;
|
|
case CadFeatureType::Transform: {
|
|
{
|
|
m_xf_body->Clear();
|
|
for (size_t i = 0; i < m_doc.bodies.size(); ++i) {
|
|
const std::string& n = m_doc.bodies[i].name;
|
|
m_xf_body->Append(n.empty() ? wxString::Format(_L("Body %zu"), i + 1) : wxString::FromUTF8(n));
|
|
}
|
|
if (f.target_body >= 0 && f.target_body < int(m_xf_body->GetCount()))
|
|
m_xf_body->SetSelection(f.target_body);
|
|
else if (m_xf_body->GetCount() > 0) m_xf_body->SetSelection(0);
|
|
}
|
|
m_xf_dx->SetValue(f.xf_translate.x());
|
|
m_xf_dy->SetValue(f.xf_translate.y());
|
|
m_xf_dz->SetValue(f.xf_translate.z());
|
|
const int ax = (std::abs(f.xf_axis.x()) > 0.5) ? 0 : (std::abs(f.xf_axis.y()) > 0.5) ? 1 : 2;
|
|
m_xf_axis->SetSelection(ax);
|
|
m_xf_angle->SetValue(f.xf_angle_deg);
|
|
m_xf_pivot_x->SetValue(f.xf_pivot.x());
|
|
m_xf_pivot_y->SetValue(f.xf_pivot.y());
|
|
m_xf_pivot_z->SetValue(f.xf_pivot.z());
|
|
m_xf_copy->SetValue(f.xf_copy);
|
|
break;
|
|
}
|
|
case CadFeatureType::Mirror: {
|
|
{
|
|
m_mirror_body->Clear();
|
|
for (size_t i = 0; i < m_doc.bodies.size(); ++i) {
|
|
const std::string& n = m_doc.bodies[i].name;
|
|
m_mirror_body->Append(n.empty() ? wxString::Format(_L("Body %zu"), i + 1) : wxString::FromUTF8(n));
|
|
}
|
|
if (f.target_body >= 0 && f.target_body < int(m_mirror_body->GetCount()))
|
|
m_mirror_body->SetSelection(f.target_body);
|
|
else if (m_mirror_body->GetCount() > 0) m_mirror_body->SetSelection(0);
|
|
}
|
|
populate_plane_choices(m_mirror_plane);
|
|
m_mirror_plane->SetSelection(index_from_plane(f.plane));
|
|
m_mirror_keep->SetValue(f.mirror_keep_original);
|
|
break;
|
|
}
|
|
case CadFeatureType::Thicken: {
|
|
{
|
|
m_thicken_body->Clear();
|
|
for (size_t i = 0; i < m_doc.bodies.size(); ++i) {
|
|
const std::string& n = m_doc.bodies[i].name;
|
|
m_thicken_body->Append(n.empty() ? wxString::Format(_L("Body %zu"), i + 1) : wxString::FromUTF8(n));
|
|
}
|
|
if (f.target_body >= 0 && f.target_body < int(m_thicken_body->GetCount()))
|
|
m_thicken_body->SetSelection(f.target_body);
|
|
else if (m_thicken_body->GetCount() > 0) m_thicken_body->SetSelection(0);
|
|
}
|
|
m_sel_solid_face = f.thicken_face;
|
|
m_thicken_face_label->SetLabel(f.thicken_face >= 0
|
|
? wxString::Format(_L("Face %d"), f.thicken_face)
|
|
: _L("(pick a solid face)"));
|
|
m_thicken_thickness->SetValue(f.thicken_thickness);
|
|
m_thicken_flip->SetValue(f.thicken_flip);
|
|
break;
|
|
}
|
|
case CadFeatureType::Rib: {
|
|
{
|
|
m_rib_body->Clear();
|
|
for (size_t i = 0; i < m_doc.bodies.size(); ++i) {
|
|
const std::string& n = m_doc.bodies[i].name;
|
|
m_rib_body->Append(n.empty() ? wxString::Format(_L("Body %zu"), i + 1) : wxString::FromUTF8(n));
|
|
}
|
|
if (f.target_body >= 0 && f.target_body < int(m_rib_body->GetCount()))
|
|
m_rib_body->SetSelection(f.target_body);
|
|
else if (m_rib_body->GetCount() > 0) m_rib_body->SetSelection(0);
|
|
}
|
|
{
|
|
m_rib_sketch->Clear();
|
|
int pre_sel = wxNOT_FOUND;
|
|
for (int i = 0; i < int(m_doc.features.size()); ++i) {
|
|
if (m_doc.features[i].type == CadFeatureType::Sketch) {
|
|
const int pos = m_rib_sketch->Append(wxString::FromUTF8(m_doc.features[i].name), wxNullBitmap,
|
|
reinterpret_cast<void*>(intptr_t(i)));
|
|
if (i == f.rib_sketch_ref) pre_sel = pos;
|
|
}
|
|
}
|
|
if (pre_sel != wxNOT_FOUND) m_rib_sketch->SetSelection(pre_sel);
|
|
else if (m_rib_sketch->GetCount() > 0) m_rib_sketch->SetSelection(0);
|
|
}
|
|
m_rib_entity->SetValue(f.rib_entity);
|
|
m_rib_thickness->SetValue(f.rib_thickness);
|
|
m_rib_depth->SetValue(f.rib_depth);
|
|
break;
|
|
}
|
|
case CadFeatureType::Project: {
|
|
{
|
|
m_proj_source_body->Clear();
|
|
for (size_t i = 0; i < m_doc.bodies.size(); ++i) {
|
|
const std::string& n = m_doc.bodies[i].name;
|
|
m_proj_source_body->Append(n.empty() ? wxString::Format(_L("Body %zu"), i + 1) : wxString::FromUTF8(n));
|
|
}
|
|
if (f.project_source_body >= 0 && f.project_source_body < int(m_proj_source_body->GetCount()))
|
|
m_proj_source_body->SetSelection(f.project_source_body);
|
|
else if (m_proj_source_body->GetCount() > 0) m_proj_source_body->SetSelection(0);
|
|
}
|
|
populate_plane_choices(m_proj_plane);
|
|
m_proj_plane->SetSelection(index_from_plane(f.plane));
|
|
m_sel_solid_face = f.project_face;
|
|
m_proj_face_label->SetLabel(f.project_face >= 0
|
|
? wxString::Format(_L("Face %d"), f.project_face)
|
|
: _L("(all edges)"));
|
|
break;
|
|
}
|
|
case CadFeatureType::DeleteFace: {
|
|
{
|
|
m_del_face_body->Clear();
|
|
for (size_t i = 0; i < m_doc.bodies.size(); ++i) {
|
|
const std::string& n = m_doc.bodies[i].name;
|
|
m_del_face_body->Append(n.empty() ? wxString::Format(_L("Body %zu"), i + 1) : wxString::FromUTF8(n));
|
|
}
|
|
if (f.target_body >= 0 && f.target_body < int(m_del_face_body->GetCount()))
|
|
m_del_face_body->SetSelection(f.target_body);
|
|
else if (m_del_face_body->GetCount() > 0) m_del_face_body->SetSelection(0);
|
|
}
|
|
m_del_faces = f.delete_faces;
|
|
{
|
|
wxString s;
|
|
for (size_t i = 0; i < m_del_faces.size(); ++i) {
|
|
if (i > 0) s += ", ";
|
|
s += wxString::Format("Face %d", m_del_faces[i]);
|
|
}
|
|
m_del_face_list->SetLabel(s.empty() ? _L("(none)") : s);
|
|
}
|
|
break;
|
|
}
|
|
case CadFeatureType::Helix:
|
|
populate_plane_choices(m_helix_plane);
|
|
m_helix_plane->SetSelection(index_from_plane(f.plane));
|
|
m_helix_radius->SetValue(f.helix_radius);
|
|
m_helix_pitch->SetValue(f.helix_pitch);
|
|
m_helix_height->SetValue(f.helix_height);
|
|
m_helix_left_handed->SetValue(f.helix_left_handed);
|
|
m_helix_taper->SetValue(f.helix_taper_deg);
|
|
break;
|
|
case CadFeatureType::Mate:
|
|
m_mate_kind->SetSelection(f.mate_kind);
|
|
// CoordSys pickers are repopulated by open_tool(Mate); pre-selection
|
|
// is done there too via the stored feature index.
|
|
m_mate_offset->SetValue(f.mate_offset);
|
|
m_mate_angle->SetValue(f.mate_angle);
|
|
m_mate_flip->SetValue(f.mate_flip);
|
|
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::Axis:
|
|
m_edit_index = sel;
|
|
load_feature_into_dialog(f);
|
|
open_tool(Tool::Axis);
|
|
break;
|
|
case CadFeatureType::CoordSys:
|
|
m_edit_index = sel;
|
|
load_feature_into_dialog(f);
|
|
open_tool(Tool::CoordSys);
|
|
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;
|
|
case CadFeatureType::SurfaceExtrude:
|
|
m_edit_index = sel;
|
|
load_feature_into_dialog(f);
|
|
open_tool(Tool::SurfaceExtrude);
|
|
break;
|
|
case CadFeatureType::SurfaceRevolve:
|
|
m_edit_index = sel;
|
|
load_feature_into_dialog(f);
|
|
open_tool(Tool::SurfaceRevolve);
|
|
break;
|
|
case CadFeatureType::SurfaceLoft:
|
|
m_edit_index = sel;
|
|
load_feature_into_dialog(f);
|
|
open_tool(Tool::SurfaceLoft);
|
|
break;
|
|
case CadFeatureType::SurfaceFill:
|
|
m_edit_index = sel;
|
|
load_feature_into_dialog(f);
|
|
open_tool(Tool::SurfaceFill);
|
|
break;
|
|
case CadFeatureType::SurfaceOffset:
|
|
m_edit_index = sel;
|
|
load_feature_into_dialog(f);
|
|
open_tool(Tool::SurfaceOffset);
|
|
break;
|
|
case CadFeatureType::ThickenSurface:
|
|
m_edit_index = sel;
|
|
load_feature_into_dialog(f);
|
|
open_tool(Tool::ThickenSurface);
|
|
break;
|
|
case CadFeatureType::Transform:
|
|
m_edit_index = sel;
|
|
load_feature_into_dialog(f);
|
|
open_tool(Tool::Transform);
|
|
break;
|
|
case CadFeatureType::Mirror:
|
|
m_edit_index = sel;
|
|
load_feature_into_dialog(f);
|
|
open_tool(Tool::Mirror);
|
|
break;
|
|
case CadFeatureType::Thicken:
|
|
m_edit_index = sel;
|
|
load_feature_into_dialog(f);
|
|
open_tool(Tool::Thicken);
|
|
break;
|
|
case CadFeatureType::Rib:
|
|
m_edit_index = sel;
|
|
load_feature_into_dialog(f);
|
|
open_tool(Tool::Rib);
|
|
break;
|
|
case CadFeatureType::Project:
|
|
m_edit_index = sel;
|
|
load_feature_into_dialog(f);
|
|
open_tool(Tool::Project);
|
|
break;
|
|
case CadFeatureType::DeleteFace:
|
|
m_edit_index = sel;
|
|
load_feature_into_dialog(f);
|
|
open_tool(Tool::DeleteFace);
|
|
break;
|
|
case CadFeatureType::Helix:
|
|
m_edit_index = sel;
|
|
load_feature_into_dialog(f);
|
|
open_tool(Tool::Helix);
|
|
break;
|
|
case CadFeatureType::Mate:
|
|
m_edit_index = sel;
|
|
m_mate_kind->SetSelection(f.mate_kind);
|
|
m_mate_offset->SetValue(f.mate_offset);
|
|
m_mate_angle->SetValue(f.mate_angle);
|
|
m_mate_flip->SetValue(f.mate_flip);
|
|
open_tool(Tool::Mate); // populates CS pickers; pre-selects from current selection
|
|
// Now set the re-edit cs_a/cs_b after populating (override the open_tool default)
|
|
{
|
|
int pre_a = wxNOT_FOUND, pre_b = wxNOT_FOUND;
|
|
for (unsigned i = 0; i < m_mate_cs_a->GetCount(); ++i) {
|
|
const auto* cd = m_mate_cs_a->GetClientData(i);
|
|
if (cd && int(reinterpret_cast<intptr_t>(cd)) == f.mate_cs_a) pre_a = int(i);
|
|
}
|
|
for (unsigned i = 0; i < m_mate_cs_b->GetCount(); ++i) {
|
|
const auto* cd = m_mate_cs_b->GetClientData(i);
|
|
if (cd && int(reinterpret_cast<intptr_t>(cd)) == f.mate_cs_b) pre_b = int(i);
|
|
}
|
|
if (pre_a != wxNOT_FOUND) m_mate_cs_a->SetSelection(pre_a);
|
|
if (pre_b != wxNOT_FOUND) m_mate_cs_b->SetSelection(pre_b);
|
|
}
|
|
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::Axis:
|
|
f.type = CadFeatureType::Axis;
|
|
apply_axis_refs(f);
|
|
break;
|
|
case Tool::CoordSys:
|
|
f.type = CadFeatureType::CoordSys;
|
|
apply_coordsys_refs(f);
|
|
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::SurfaceExtrude:
|
|
f.type = CadFeatureType::SurfaceExtrude;
|
|
f.sketch_ref = m_surf_extrude_sketch_ref;
|
|
f.distance = m_surf_extrude_distance->GetValue();
|
|
break;
|
|
case Tool::SurfaceRevolve:
|
|
f.type = CadFeatureType::SurfaceRevolve;
|
|
f.sketch_ref = m_surf_revolve_sketch_ref;
|
|
f.revolve_angle = m_surf_revolve_angle->GetValue();
|
|
f.revolve_axis = m_surf_revolve_axis->GetSelection();
|
|
f.flip = m_surf_revolve_flip->GetValue();
|
|
break;
|
|
case Tool::SurfaceLoft: {
|
|
f.type = CadFeatureType::SurfaceLoft;
|
|
f.loft_ruled = m_surf_loft_ruled->GetValue();
|
|
f.loft_profile_refs.clear();
|
|
for (unsigned i = 0; i < m_surf_loft_list->GetCount(); ++i)
|
|
if (m_surf_loft_list->IsChecked(i) && i < m_surf_loft_sketch_idx.size())
|
|
f.loft_profile_refs.push_back(m_surf_loft_sketch_idx[i]);
|
|
break;
|
|
}
|
|
case Tool::SurfaceFill:
|
|
f.type = CadFeatureType::SurfaceFill;
|
|
f.sketch_ref = m_surf_fill_sketch_ref;
|
|
break;
|
|
case Tool::SurfaceOffset: {
|
|
f.type = CadFeatureType::SurfaceOffset;
|
|
f.target_body = sheet_choice_body(m_surf_offset_body);
|
|
f.plane_offset = m_surf_offset_distance->GetValue();
|
|
break;
|
|
}
|
|
case Tool::ThickenSurface: {
|
|
f.type = CadFeatureType::ThickenSurface;
|
|
f.target_body = sheet_choice_body(m_surf_thicken_body);
|
|
f.thicken_thickness = m_surf_thicken_thickness->GetValue();
|
|
f.thicken_flip = m_surf_thicken_flip->GetValue();
|
|
break;
|
|
}
|
|
case Tool::Transform: {
|
|
f.type = CadFeatureType::Transform;
|
|
const int sel = m_xf_body->GetSelection();
|
|
f.target_body = (sel != wxNOT_FOUND) ? sel : -1;
|
|
f.xf_translate = Vec3d(m_xf_dx->GetValue(), m_xf_dy->GetValue(), m_xf_dz->GetValue());
|
|
const int ax = m_xf_axis->GetSelection();
|
|
f.xf_axis = (ax == 0) ? Vec3d(1, 0, 0) : (ax == 1) ? Vec3d(0, 1, 0) : Vec3d(0, 0, 1);
|
|
f.xf_pivot = Vec3d(m_xf_pivot_x->GetValue(), m_xf_pivot_y->GetValue(), m_xf_pivot_z->GetValue());
|
|
f.xf_angle_deg = m_xf_angle->GetValue();
|
|
f.xf_copy = m_xf_copy->GetValue();
|
|
break;
|
|
}
|
|
case Tool::Mirror: {
|
|
f.type = CadFeatureType::Mirror;
|
|
const int sel = m_mirror_body->GetSelection();
|
|
f.target_body = (sel != wxNOT_FOUND) ? sel : -1;
|
|
f.plane = plane_from_choice(m_mirror_plane->GetSelection());
|
|
f.mirror_keep_original = m_mirror_keep->GetValue();
|
|
f.mode = f.mirror_keep_original ? BooleanMode::New : BooleanMode::Add;
|
|
break;
|
|
}
|
|
case Tool::Thicken: {
|
|
f.type = CadFeatureType::Thicken;
|
|
const int sel = m_thicken_body->GetSelection();
|
|
f.target_body = (sel != wxNOT_FOUND) ? sel : -1;
|
|
f.thicken_face = (m_sel_solid_face >= 0) ? m_sel_solid_face : -1;
|
|
f.thicken_thickness = m_thicken_thickness->GetValue();
|
|
f.thicken_flip = m_thicken_flip->GetValue();
|
|
break;
|
|
}
|
|
case Tool::Rib: {
|
|
f.type = CadFeatureType::Rib;
|
|
const int bsel = m_rib_body->GetSelection();
|
|
f.target_body = (bsel != wxNOT_FOUND) ? bsel : -1;
|
|
const int ssel = m_rib_sketch->GetSelection();
|
|
f.rib_sketch_ref = (ssel != wxNOT_FOUND)
|
|
? int(reinterpret_cast<intptr_t>(m_rib_sketch->GetClientData(ssel))) : -1;
|
|
f.rib_entity = m_rib_entity->GetValue();
|
|
f.rib_thickness = m_rib_thickness->GetValue();
|
|
f.rib_depth = m_rib_depth->GetValue();
|
|
break;
|
|
}
|
|
case Tool::Project: {
|
|
f.type = CadFeatureType::Project;
|
|
const int sel = m_proj_source_body->GetSelection();
|
|
f.project_source_body = (sel != wxNOT_FOUND) ? sel : -1;
|
|
f.plane = plane_from_choice(m_proj_plane->GetSelection());
|
|
if (m_sel_solid_face >= 0) {
|
|
f.project_face = m_sel_solid_face;
|
|
} else {
|
|
f.project_face = -1;
|
|
}
|
|
f.project_edges.clear(); // empty => use project_face
|
|
break;
|
|
}
|
|
case Tool::DeleteFace: {
|
|
f.type = CadFeatureType::DeleteFace;
|
|
const int sel = m_del_face_body->GetSelection();
|
|
f.target_body = (sel != wxNOT_FOUND) ? sel : -1;
|
|
f.delete_faces = m_del_faces;
|
|
break;
|
|
}
|
|
case Tool::Helix: {
|
|
f.type = CadFeatureType::Helix;
|
|
f.plane = plane_from_choice(m_helix_plane->GetSelection());
|
|
f.helix_radius = m_helix_radius->GetValue();
|
|
f.helix_pitch = m_helix_pitch->GetValue();
|
|
f.helix_height = m_helix_height->GetValue();
|
|
f.helix_left_handed = m_helix_left_handed->GetValue();
|
|
f.helix_taper_deg = m_helix_taper->GetValue();
|
|
break;
|
|
}
|
|
case Tool::Mate: {
|
|
f.type = CadFeatureType::Mate;
|
|
f.mate_kind = m_mate_kind->GetSelection();
|
|
f.mate_cs_a = m_mate_cs_a->GetSelection() != wxNOT_FOUND
|
|
? int(reinterpret_cast<intptr_t>(m_mate_cs_a->GetClientData(m_mate_cs_a->GetSelection()))) : -1;
|
|
f.mate_cs_b = m_mate_cs_b->GetSelection() != wxNOT_FOUND
|
|
? int(reinterpret_cast<intptr_t>(m_mate_cs_b->GetClientData(m_mate_cs_b->GetSelection()))) : -1;
|
|
f.mate_offset = m_mate_offset->GetValue();
|
|
f.mate_angle = m_mate_angle->GetValue();
|
|
f.mate_flip = m_mate_flip->GetValue();
|
|
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
|
|
&& m_active != Tool::Transform && m_active != Tool::Mirror && m_active != Tool::Thicken
|
|
&& m_active != Tool::DeleteFace && m_active != Tool::Rib && m_active != Tool::Project
|
|
&& m_active != Tool::Helix)
|
|
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; }
|
|
|
|
// Features that produce NO solid: a sketch, the three datums, a helix curve, and Project
|
|
// (which emits sketch entities). They have no ghost to build, so they must not go through
|
|
// the solid-preview path below — it finds nothing and reports "invalid: preview produced
|
|
// no geometry", which is what Axis / CoordSys / Helix / Project did on an empty document.
|
|
// route_feature() skips these for the same reason, so the two agree on what is not a solid.
|
|
if (m_active == Tool::Sketch || m_active == Tool::Plane || m_active == Tool::Axis ||
|
|
m_active == Tool::CoordSys || m_active == Tool::Helix || m_active == Tool::Project) {
|
|
m_viewport->clear_preview();
|
|
m_status->SetForegroundColour(wxColour(120, 210, 120));
|
|
wxString ready;
|
|
switch (m_active) {
|
|
case Tool::Plane: ready = _L("Plane ready"); break;
|
|
case Tool::Axis: ready = _L("Axis ready"); break;
|
|
case Tool::CoordSys: ready = _L("Coord Sys ready"); break;
|
|
case Tool::Helix: ready = _L("Helix ready"); break;
|
|
case Tool::Project: ready = _L("Project ready"); break;
|
|
default: ready = _L("Sketch ready"); break;
|
|
}
|
|
m_status->SetLabel(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;
|
|
}
|
|
|
|
if (m_active == Tool::Mate) {
|
|
// Mate has no 3D ghost — it is an assembly constraint driven by CoordSys features.
|
|
// Confirm must be disabled when <2 CoordSys exist or when A == B.
|
|
m_viewport->clear_preview();
|
|
const bool has_two = m_mate_cs_a && m_mate_cs_a->GetCount() >= 2;
|
|
const int sel_a = has_two ? m_mate_cs_a->GetSelection() : wxNOT_FOUND;
|
|
const int sel_b = has_two ? m_mate_cs_b->GetSelection() : wxNOT_FOUND;
|
|
const bool same = has_two && sel_a != wxNOT_FOUND && sel_b != wxNOT_FOUND && sel_a == sel_b;
|
|
bool ok = has_two && !same;
|
|
if (!has_two) {
|
|
m_status->SetForegroundColour(wxColour(235, 110, 110));
|
|
m_status->SetLabel(_L("Mate needs at least two CoordSys features"));
|
|
} else if (same) {
|
|
m_status->SetForegroundColour(wxColour(235, 110, 110));
|
|
m_status->SetLabel(_L("Mate: CS A and CS B must be different"));
|
|
} else {
|
|
m_status->SetForegroundColour(wxColour(120, 210, 120));
|
|
m_status->SetLabel(_L("Mate ready"));
|
|
}
|
|
for (wxButton* b : m_confirm_btns) if (b) b->Enable(ok);
|
|
m_status->Refresh();
|
|
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-rectangle + offset arrow (self-gates: only while the Cut card is open).
|
|
update_cut_gizmo();
|
|
// 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();
|
|
update_operand_highlight();
|
|
}
|
|
|
|
// The tool-card frame is only worth drawing when a card is actually inside it —
|
|
// otherwise an empty bordered box floats above the feature tree.
|
|
void DesignPanel::update_cards_frame()
|
|
{
|
|
if (m_cards == nullptr || m_cards->GetSizer() == nullptr) return;
|
|
wxSizer* root = m_form ? m_form->GetSizer() : nullptr;
|
|
if (root == nullptr) return;
|
|
bool any = false;
|
|
for (const wxSizerItem* it : m_cards->GetSizer()->GetChildren())
|
|
if (it->IsShown()) { any = true; break; }
|
|
root->Show(m_cards, any, false); // non-recursive: don't re-show the hidden cards inside
|
|
}
|
|
|
|
// Move/Rotate card: numeric entry that composes the same transform the drag gizmo builds —
|
|
// translation in world mm plus a rotation about the body's centroid, applied onto the pose the
|
|
// body had when Move opened (m_move_prev), so typing and dragging cannot fight each other.
|
|
void DesignPanel::apply_move_card()
|
|
{
|
|
const int b = m_move_body;
|
|
if (b < 0 || b >= int(m_body_xform.size()) || b >= int(m_doc.display_body_meshes.size())) return;
|
|
|
|
const double ang = m_move_angle ? m_move_angle->GetValue() * M_PI / 180.0 : 0.0;
|
|
const int ax = m_move_axis ? m_move_axis->GetSelection() : 2;
|
|
const Vec3d axis = (ax == 0) ? Vec3d::UnitX() : (ax == 1) ? Vec3d::UnitY() : Vec3d::UnitZ();
|
|
const Vec3d d(m_move_dx ? m_move_dx->GetValue() : 0.0,
|
|
m_move_dy ? m_move_dy->GetValue() : 0.0,
|
|
m_move_dz ? m_move_dz->GetValue() : 0.0);
|
|
|
|
// Rotate about the body's own centre, not the world origin.
|
|
const Vec3d pivot = m_move_prev * m_doc.display_body_meshes[b].bounding_box().center();
|
|
Transform3d x = Transform3d::Identity();
|
|
x.translate(d + pivot);
|
|
x.rotate(Eigen::AngleAxisd(ang, axis));
|
|
x.translate(-pivot);
|
|
|
|
m_body_xform[b] = x * m_move_prev;
|
|
feed_bodies();
|
|
if (m_viewport) m_viewport->request_repaint();
|
|
m_status->SetForegroundColour(wxNullColour);
|
|
m_status->SetLabel(wxString::Format(_L("Body %d — moved (%.1f, %.1f, %.1f) mm, rotated %.1f°"),
|
|
b + 1, d.x(), d.y(), d.z(),
|
|
m_move_angle ? m_move_angle->GetValue() : 0.0));
|
|
m_status->Refresh();
|
|
}
|
|
|
|
void DesignPanel::show_move_card(bool show)
|
|
{
|
|
if (m_box_move == nullptr || m_cards == nullptr || m_cards->GetSizer() == nullptr) return;
|
|
m_cards->GetSizer()->Show(m_box_move, show, true);
|
|
update_cards_frame();
|
|
if (m_form) { m_form->Layout(); m_form->FitInside(); }
|
|
}
|
|
|
|
// Polygon's options live in a sidebar card that is shown only while the tool is armed.
|
|
void DesignPanel::show_polygon_card(bool show)
|
|
{
|
|
if (m_box_polygon == nullptr || m_cards == nullptr || m_cards->GetSizer() == nullptr) return;
|
|
m_cards->GetSizer()->Show(m_box_polygon, show, true);
|
|
update_cards_frame();
|
|
if (m_form) { m_form->Layout(); m_form->FitInside(); }
|
|
}
|
|
|
|
// Push the card's current values into the live sketch tool before Polygon starts.
|
|
void DesignPanel::push_polygon_params()
|
|
{
|
|
if (m_viewport == nullptr) return;
|
|
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());
|
|
}
|
|
|
|
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_cards->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_axis, t == Tool::Axis, true);
|
|
s->Show(m_box_coordsys, t == Tool::CoordSys, 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_surf_extrude, t == Tool::SurfaceExtrude, true);
|
|
s->Show(m_box_surf_revolve, t == Tool::SurfaceRevolve, true);
|
|
s->Show(m_box_surf_loft, t == Tool::SurfaceLoft, true);
|
|
s->Show(m_box_surf_fill, t == Tool::SurfaceFill, true);
|
|
s->Show(m_box_surf_offset, t == Tool::SurfaceOffset, true);
|
|
s->Show(m_box_surf_thicken, t == Tool::ThickenSurface, true);
|
|
s->Show(m_box_transform, t == Tool::Transform, true);
|
|
s->Show(m_box_mirror, t == Tool::Mirror, true);
|
|
s->Show(m_box_thicken, t == Tool::Thicken, true);
|
|
s->Show(m_box_rib, t == Tool::Rib, true);
|
|
s->Show(m_box_project, t == Tool::Project, true);
|
|
s->Show(m_box_delete_face, t == Tool::DeleteFace, true);
|
|
s->Show(m_box_helix, t == Tool::Helix, true);
|
|
s->Show(m_box_mate, t == Tool::Mate, true);
|
|
s->Show(m_box_insert, t == Tool::Insert, true);
|
|
s->Show(m_box_expr, m_edit_index >= 0, true); // expression binding available during edit only
|
|
|
|
if (t == Tool::Mate) {
|
|
// Populate both CoordSys pickers with every CoordSys feature; store the real
|
|
// feature index in client data. Keep prior selection when re-editing.
|
|
int keep_a = -1, keep_b = -1;
|
|
if (m_mate_cs_a->GetCount() > 0 && m_mate_cs_a->GetSelection() != wxNOT_FOUND) {
|
|
const auto* cl = m_mate_cs_a->GetClientData(m_mate_cs_a->GetSelection());
|
|
if (cl) keep_a = int(reinterpret_cast<intptr_t>(cl));
|
|
}
|
|
if (m_mate_cs_b->GetCount() > 0 && m_mate_cs_b->GetSelection() != wxNOT_FOUND) {
|
|
const auto* cl = m_mate_cs_b->GetClientData(m_mate_cs_b->GetSelection());
|
|
if (cl) keep_b = int(reinterpret_cast<intptr_t>(cl));
|
|
}
|
|
m_mate_cs_a->Clear();
|
|
m_mate_cs_b->Clear();
|
|
for (int i = 0; i < int(m_doc.features.size()); ++i) {
|
|
if (m_doc.features[i].type != CadFeatureType::CoordSys) continue;
|
|
const wxString nm = wxString::FromUTF8(m_doc.features[i].name);
|
|
void* const cd = reinterpret_cast<void*>(intptr_t(i));
|
|
const int pos_a = m_mate_cs_a->Append(nm, wxNullBitmap, cd);
|
|
const int pos_b = m_mate_cs_b->Append(nm, wxNullBitmap, cd);
|
|
if (i == keep_a) m_mate_cs_a->SetSelection(pos_a);
|
|
if (i == keep_b) m_mate_cs_b->SetSelection(pos_b);
|
|
}
|
|
if (keep_a < 0 && m_mate_cs_a->GetCount() > 0) m_mate_cs_a->SetSelection(0);
|
|
if (keep_b < 0 && m_mate_cs_b->GetCount() > 0) m_mate_cs_b->SetSelection(0);
|
|
}
|
|
|
|
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;
|
|
// ComboBox's own Append(text, bitmap) hides wxItemContainer's (text, void*),
|
|
// so the client data goes through the 3-arg form.
|
|
const int pos = m_sweep_path->Append(wxString::FromUTF8(sf.name), wxNullBitmap,
|
|
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::SurfaceExtrude) {
|
|
if (m_surf_extrude_sketch_ref >= 0 && m_surf_extrude_sketch_ref < int(m_doc.features.size()))
|
|
m_surf_extrude_sketch_label->SetLabel(_L("Sketch: ") +
|
|
wxString::FromUTF8(m_doc.features[m_surf_extrude_sketch_ref].name));
|
|
}
|
|
|
|
if (t == Tool::SurfaceRevolve) {
|
|
if (m_surf_revolve_sketch_ref >= 0 && m_surf_revolve_sketch_ref < int(m_doc.features.size()))
|
|
m_surf_revolve_sketch_label->SetLabel(_L("Sketch: ") +
|
|
wxString::FromUTF8(m_doc.features[m_surf_revolve_sketch_ref].name));
|
|
}
|
|
|
|
if (t == Tool::SurfaceLoft) {
|
|
m_surf_loft_list->Clear();
|
|
m_surf_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_surf_loft_list->Append(wxString::FromUTF8(sf.name));
|
|
m_surf_loft_sketch_idx.push_back(i);
|
|
if (std::find(m_surf_loft_refs.begin(), m_surf_loft_refs.end(), i) != m_surf_loft_refs.end())
|
|
m_surf_loft_list->Check(row, true);
|
|
}
|
|
}
|
|
|
|
if (t == Tool::SurfaceFill) {
|
|
if (m_surf_fill_sketch_ref >= 0 && m_surf_fill_sketch_ref < int(m_doc.features.size()))
|
|
m_surf_fill_sketch_label->SetLabel(_L("Sketch: ") +
|
|
wxString::FromUTF8(m_doc.features[m_surf_fill_sketch_ref].name));
|
|
}
|
|
|
|
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::Axis: m_hdr_axis->SetLabel(title(_L("Axis"))); break;
|
|
case Tool::CoordSys: m_hdr_coordsys->SetLabel(title(_L("Coord Sys"))); break;
|
|
case Tool::SurfaceExtrude: m_hdr_surf_extrude->SetLabel(title(_L("Surface Extrude"))); break;
|
|
case Tool::SurfaceRevolve: m_hdr_surf_revolve->SetLabel(title(_L("Surface Revolve"))); break;
|
|
case Tool::SurfaceLoft: m_hdr_surf_loft->SetLabel(title(_L("Surface Loft"))); break;
|
|
case Tool::SurfaceFill: m_hdr_surf_fill->SetLabel(title(_L("Surface Fill"))); break;
|
|
case Tool::SurfaceOffset: m_hdr_surf_offset->SetLabel(title(_L("Surface Offset"))); break;
|
|
case Tool::ThickenSurface: m_hdr_surf_thicken->SetLabel(title(_L("Thicken Surface"))); break;
|
|
case Tool::Transform: m_hdr_transform->SetLabel(title(_L("Transform"))); break;
|
|
case Tool::Mirror: m_hdr_mirror->SetLabel(title(_L("Mirror"))); break;
|
|
case Tool::Thicken: m_hdr_thicken->SetLabel(title(_L("Thicken"))); break;
|
|
case Tool::Rib: m_hdr_rib->SetLabel(title(_L("Rib"))); break;
|
|
case Tool::Project: m_hdr_project->SetLabel(title(_L("Project"))); break;
|
|
case Tool::DeleteFace: m_hdr_delete_face->SetLabel(title(_L("Delete Face"))); break;
|
|
case Tool::Helix: m_hdr_helix->SetLabel(title(_L("Helix"))); break;
|
|
case Tool::Mate: m_hdr_mate->SetLabel(title(_L("Mate"))); break;
|
|
case Tool::Insert: break; // header set by open_insert_card()
|
|
case Tool::None: break;
|
|
}
|
|
|
|
if (editing) {
|
|
populate_expr_fields(t); // field-name combo for this feature type
|
|
// Display current expression bindings on the edited feature
|
|
const CadFeature& ef = m_doc.features[m_edit_index];
|
|
if (ef.expr.empty()) {
|
|
m_expr_status->SetLabel(_L("(no bindings)"));
|
|
m_expr_status->SetForegroundColour(dp_sec_text());
|
|
} else {
|
|
wxString s;
|
|
for (const auto& [field, e] : ef.expr) {
|
|
if (!s.IsEmpty()) s += "; ";
|
|
s += wxString::FromUTF8(field) + " = " + wxString::FromUTF8(e);
|
|
}
|
|
m_expr_status->SetLabel(s);
|
|
m_expr_status->SetForegroundColour(dp_ctl_text());
|
|
}
|
|
}
|
|
|
|
update_cards_frame(); 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_cards->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_axis, false, true);
|
|
s->Show(m_box_coordsys, 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_surf_extrude, false, true);
|
|
s->Show(m_box_surf_revolve, false, true);
|
|
s->Show(m_box_surf_loft, false, true);
|
|
s->Show(m_box_surf_fill, false, true);
|
|
s->Show(m_box_surf_offset, false, true);
|
|
s->Show(m_box_surf_thicken, false, true);
|
|
s->Show(m_box_transform, false, true);
|
|
s->Show(m_box_mirror, false, true);
|
|
s->Show(m_box_thicken, false, true);
|
|
s->Show(m_box_rib, false, true);
|
|
s->Show(m_box_project, false, true);
|
|
s->Show(m_box_delete_face, false, true);
|
|
s->Show(m_box_helix, false, true);
|
|
s->Show(m_box_mate, false, true);
|
|
s->Show(m_box_insert, false, true);
|
|
s->Show(m_box_expr, 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->clear_datum_gizmo();
|
|
m_viewport->set_operand_bodies(-1, -1);
|
|
m_viewport->set_highlight_sketches({});
|
|
update_reference_planes(); // back to no-tool: show the origin planes if there is no object yet
|
|
update_cards_frame(); 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::Axis: on_add_axis(); break;
|
|
case Tool::CoordSys: on_add_coordsys(); break;
|
|
case Tool::Mate: on_add_mate(); break;
|
|
case Tool::SurfaceExtrude: on_add_surface_extrude(); break;
|
|
case Tool::SurfaceRevolve: on_add_surface_revolve(); break;
|
|
case Tool::SurfaceLoft: on_add_surface_loft(); break;
|
|
case Tool::SurfaceFill: on_add_surface_fill(); break;
|
|
case Tool::SurfaceOffset: on_add_surface_offset(); break;
|
|
case Tool::ThickenSurface: on_add_thicken_surface(); break;
|
|
case Tool::Transform: on_add_transform(); break;
|
|
case Tool::Mirror: on_add_mirror(); break;
|
|
case Tool::Thicken: on_add_thicken(); break;
|
|
case Tool::Rib: on_add_rib(); break;
|
|
case Tool::Project: on_add_project(); break;
|
|
case Tool::DeleteFace: on_add_delete_face(); break;
|
|
case Tool::Helix: on_add_helix(); 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;
|
|
show_move_card(false);
|
|
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;
|
|
show_move_card(false);
|
|
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();
|
|
}
|
|
|
|
// --- Document variables panel ---------------------------------------------------------
|
|
|
|
void DesignPanel::refresh_variables()
|
|
{
|
|
if (!m_var_list) return;
|
|
m_var_list->DeleteAllItems();
|
|
int row = 0;
|
|
for (const auto& [name, expr] : m_doc.variables) {
|
|
m_var_list->InsertItem(row, wxString::FromUTF8(name));
|
|
m_var_list->SetItem(row, 1, wxString::FromUTF8(expr));
|
|
++row;
|
|
}
|
|
}
|
|
|
|
void DesignPanel::on_add_variable()
|
|
{
|
|
wxString name = ::wxGetTextFromUser(_L("Variable name:"), _L("Add Variable"), "", this);
|
|
if (name.IsEmpty()) return;
|
|
name.Trim(true).Trim(false);
|
|
if (name.Contains(' ')) {
|
|
m_status->SetForegroundColour(wxColour(235, 110, 110));
|
|
m_status->SetLabel(_L("Variable name must not contain spaces"));
|
|
m_status->Refresh();
|
|
return;
|
|
}
|
|
wxString expr = ::wxGetTextFromUser(
|
|
wxString::Format(_L("Expression for '%s':"), name),
|
|
_L("Add Variable"), "0", this);
|
|
if (expr.IsEmpty()) return;
|
|
|
|
const std::string name_str = name.ToUTF8().data();
|
|
const std::string expr_str = expr.ToUTF8().data();
|
|
|
|
m_doc.checkpoint();
|
|
m_doc.variables[name_str] = expr_str;
|
|
bool ok = m_doc.recompute();
|
|
// undo() recomputes, which SUCCEEDS and clears doc.error — so the reason the edit was
|
|
// rejected is gone before anything can display it. Carry it across the rollback.
|
|
if (!ok) { const std::string why = m_doc.error; m_doc.undo(); m_doc.error = why; }
|
|
after_tree_edit(ok);
|
|
refresh_variables();
|
|
}
|
|
|
|
void DesignPanel::on_edit_variable()
|
|
{
|
|
if (!m_var_list) return;
|
|
const long sel = m_var_list->GetNextItem(-1, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED);
|
|
if (sel < 0) {
|
|
m_status->SetLabel(_L("Select a variable first"));
|
|
m_status->Refresh();
|
|
return;
|
|
}
|
|
const std::string name_str = m_var_list->GetItemText(sel, 0).ToUTF8().data();
|
|
const std::string old_expr = m_var_list->GetItemText(sel, 1).ToUTF8().data();
|
|
wxString expr = ::wxGetTextFromUser(
|
|
wxString::Format(_L("Expression for '%s':"), m_var_list->GetItemText(sel, 0)),
|
|
_L("Edit Variable"), wxString::FromUTF8(old_expr), this);
|
|
if (expr.IsEmpty()) return;
|
|
|
|
const std::string expr_str = expr.ToUTF8().data();
|
|
m_doc.checkpoint();
|
|
m_doc.variables[name_str] = expr_str;
|
|
bool ok = m_doc.recompute();
|
|
// undo() recomputes, which SUCCEEDS and clears doc.error — so the reason the edit was
|
|
// rejected is gone before anything can display it. Carry it across the rollback.
|
|
if (!ok) { const std::string why = m_doc.error; m_doc.undo(); m_doc.error = why; }
|
|
after_tree_edit(ok);
|
|
refresh_variables();
|
|
}
|
|
|
|
void DesignPanel::on_remove_variable()
|
|
{
|
|
if (!m_var_list) return;
|
|
const long sel = m_var_list->GetNextItem(-1, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED);
|
|
if (sel < 0) {
|
|
m_status->SetLabel(_L("Select a variable first"));
|
|
m_status->Refresh();
|
|
return;
|
|
}
|
|
const std::string name_str = m_var_list->GetItemText(sel, 0).ToUTF8().data();
|
|
|
|
m_doc.checkpoint();
|
|
m_doc.variables.erase(name_str);
|
|
bool ok = m_doc.recompute();
|
|
if (!ok) {
|
|
// Capture before undo(): its recompute succeeds and clears doc.error.
|
|
const std::string why = m_doc.error;
|
|
m_doc.undo();
|
|
// Almost always a feature expr still referencing the variable — but say so as the
|
|
// likely cause and keep the real message, rather than asserting a diagnosis that
|
|
// would be wrong for any other failure.
|
|
m_doc.error = wxString::Format(
|
|
_L("Variable '%s' could not be removed (likely still referenced by a feature "
|
|
"expression): %s"),
|
|
m_var_list->GetItemText(sel, 0), wxString::FromUTF8(why)).ToUTF8().data();
|
|
}
|
|
after_tree_edit(ok);
|
|
refresh_variables();
|
|
}
|
|
|
|
// --- Expression binding ---------------------------------------------------------------
|
|
|
|
// Field-name lists per feature type. The combo is editable so a power user can type any
|
|
// valid field name; these lists pre-populate the picker for the common operations.
|
|
std::vector<std::string> DesignPanel::fields_for_tool(Tool t)
|
|
{
|
|
using T = DesignPanel::Tool;
|
|
switch (t) {
|
|
case T::Sketch: return {"width", "height", "radius"};
|
|
case T::Extrude: return {"distance", "distance2", "taper_deg"};
|
|
case T::Dressup: return {"dressup_size"};
|
|
case T::Hole: return {"hole_diameter", "hole_depth", "hole_x", "hole_y"};
|
|
case T::Thread: return {"thread_radius", "thread_pitch", "thread_height", "thread_depth", "thread_x", "thread_y"};
|
|
case T::Shell: return {"shell_thickness"};
|
|
case T::Revolve: return {"revolve_angle"};
|
|
case T::Sweep: return {};
|
|
case T::Pattern: return {"pattern_count", "pattern_spacing", "pattern_angle"};
|
|
case T::Plane: return {"plane_offset", "plane_angle_tilt"};
|
|
case T::Loft: return {};
|
|
case T::Draft: return {"draft_angle"};
|
|
case T::Boolean: return {};
|
|
case T::Cut: return {};
|
|
case T::SurfaceExtrude: return {"distance"};
|
|
case T::SurfaceRevolve: return {"revolve_angle"};
|
|
case T::SurfaceLoft: return {};
|
|
case T::SurfaceFill: return {};
|
|
case T::SurfaceOffset: return {"plane_offset"};
|
|
case T::ThickenSurface: return {"thicken_thickness"};
|
|
case T::Transform: return {};
|
|
case T::Mirror: return {};
|
|
case T::Thicken: return {"thicken_thickness"};
|
|
case T::Rib: return {"rib_thickness", "rib_depth"};
|
|
case T::Project: return {};
|
|
case T::DeleteFace: return {};
|
|
case T::Helix: return {"helix_radius", "helix_pitch", "helix_height", "helix_taper_deg"};
|
|
case T::Axis: return {};
|
|
case T::CoordSys: return {};
|
|
case T::Mate: return {};
|
|
case T::Insert: return {};
|
|
case T::None: return {};
|
|
}
|
|
return {};
|
|
}
|
|
|
|
void DesignPanel::populate_expr_fields(Tool t)
|
|
{
|
|
if (!m_expr_field) return;
|
|
m_expr_field->Clear();
|
|
for (const std::string& f : fields_for_tool(t))
|
|
m_expr_field->Append(wxString::FromUTF8(f));
|
|
if (m_expr_field->GetCount() > 0)
|
|
m_expr_field->SetSelection(0);
|
|
}
|
|
|
|
void DesignPanel::on_set_expr()
|
|
{
|
|
if (m_edit_index < 0 || m_edit_index >= int(m_doc.features.size())) return;
|
|
if (!m_expr_field || !m_expr_text) return;
|
|
|
|
const wxString fwx = m_expr_field->GetValue();
|
|
const wxString ewx = m_expr_text->GetValue();
|
|
if (fwx.IsEmpty()) return;
|
|
|
|
const std::string field = fwx.ToUTF8().data();
|
|
const std::string expr = ewx.ToUTF8().data();
|
|
|
|
m_doc.checkpoint();
|
|
m_doc.features[m_edit_index].expr[field] = expr;
|
|
bool ok = m_doc.recompute();
|
|
// undo() recomputes, which SUCCEEDS and clears doc.error — so the reason the edit was
|
|
// rejected is gone before anything can display it. Carry it across the rollback.
|
|
if (!ok) { const std::string why = m_doc.error; m_doc.undo(); m_doc.error = why; }
|
|
after_tree_edit(ok);
|
|
if (ok) m_expr_text->Clear();
|
|
|
|
// Refresh the status line showing current bindings
|
|
if (m_edit_index >= 0 && m_edit_index < int(m_doc.features.size())) {
|
|
const CadFeature& ef = m_doc.features[m_edit_index];
|
|
if (ef.expr.empty()) {
|
|
m_expr_status->SetLabel(_L("(no bindings)"));
|
|
m_expr_status->SetForegroundColour(dp_sec_text());
|
|
} else {
|
|
wxString s;
|
|
for (const auto& [f, e] : ef.expr) {
|
|
if (!s.IsEmpty()) s += "; ";
|
|
s += wxString::FromUTF8(f) + " = " + wxString::FromUTF8(e);
|
|
}
|
|
m_expr_status->SetLabel(s);
|
|
m_expr_status->SetForegroundColour(dp_ctl_text());
|
|
}
|
|
}
|
|
}
|
|
|
|
void DesignPanel::on_clear_expr()
|
|
{
|
|
if (m_edit_index < 0 || m_edit_index >= int(m_doc.features.size())) return;
|
|
if (!m_expr_field) return;
|
|
|
|
const wxString fwx = m_expr_field->GetValue();
|
|
if (fwx.IsEmpty()) return;
|
|
|
|
const std::string field = fwx.ToUTF8().data();
|
|
auto& feat_expr = m_doc.features[m_edit_index].expr;
|
|
if (feat_expr.find(field) == feat_expr.end()) {
|
|
m_status->SetForegroundColour(wxColour(235, 110, 110));
|
|
m_status->SetLabel(_L("No binding for that field"));
|
|
m_status->Refresh();
|
|
return;
|
|
}
|
|
|
|
m_doc.checkpoint();
|
|
feat_expr.erase(field);
|
|
bool ok = m_doc.recompute();
|
|
// undo() recomputes, which SUCCEEDS and clears doc.error — so the reason the edit was
|
|
// rejected is gone before anything can display it. Carry it across the rollback.
|
|
if (!ok) { const std::string why = m_doc.error; m_doc.undo(); m_doc.error = why; }
|
|
after_tree_edit(ok);
|
|
|
|
if (m_edit_index >= 0 && m_edit_index < int(m_doc.features.size())) {
|
|
const CadFeature& ef = m_doc.features[m_edit_index];
|
|
if (ef.expr.empty()) {
|
|
m_expr_status->SetLabel(_L("(no bindings)"));
|
|
m_expr_status->SetForegroundColour(dp_sec_text());
|
|
} else {
|
|
wxString s;
|
|
for (const auto& [f, e] : ef.expr) {
|
|
if (!s.IsEmpty()) s += "; ";
|
|
s += wxString::FromUTF8(f) + " = " + wxString::FromUTF8(e);
|
|
}
|
|
m_expr_status->SetLabel(s);
|
|
m_expr_status->SetForegroundColour(dp_ctl_text());
|
|
}
|
|
}
|
|
}
|
|
|
|
}} // namespace Slic3r::GUI
|