mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-18 14:32:36 +00:00
exussum12 on PR #15238: "Esc hardly ever works". He was right, and the word that matters is "hardly" — it works while you are mid-entity and stops working the moment you finish one. The branch asked whether a DRAW TOOL was armed, not whether a SKETCH SESSION was open: if (key == WXK_ESCAPE && m_viewport && m_viewport->is_sketching()) is_sketching() is DesignSketchTool::is_active(), true only between arming a tool and finishing with it. Commit an entity and you are left at ui_mode=Sketch with no tool armed, and from there Esc did nothing at all, no matter how many times you pressed it — the Cancel button was the only way out. Reproduced on the headless rig with SNAPORCA_KEYTRACE, which is what settled it rather than reading: the key ARRIVES and focus is fine, [KEYTRACE] key=27 ui_mode=1 is_sketching=0 in_text=0 inline_busy=0 focus=w so this was never the focus problem it looks like from the outside. Gate on the session instead. request_exit() is already layered — abort the in-progress entity, else drop the tool to Select, else exit to Feature — so widening the gate adds no new behaviour, it just lets the ladder be reached from its own last rung. is_sketching() stays in the condition as an OR, so nothing about the armed-tool path changes. This is the same mistake snaporca-0ud fixed thirty lines above in the same handler, where gating the sketch key MAP on is_sketching() made all 17 keys read as dead. The comment there now has a sibling. VERIFIED ON THE RIG, before and after, same sequence (Design > XY > Shift+S > click canvas): before, two Escapes left the toolbar on SKETCH with zero pixels changed; after, the toolbar reads FEATURES — one Esc drops the armed tool to Select, the second exits the session. Kernel suite green, 2568 assertions in 191 test cases. libslic3r_gui builds clean on both forks. Parity 17 identical / 8 diverging as expected.
11566 lines
610 KiB
C++
11566 lines
610 KiB
C++
#include "slic3r/GUI/CAD/DesignPanel.hpp"
|
|
#include "slic3r/GUI/CAD/DesignCanvas.hpp"
|
|
#include "slic3r/GUI/CAD/DesignSketchTool.hpp"
|
|
#include "slic3r/GUI/CAD/DesignOffer.hpp" // generated offer table — see docs/ux/tool_atlas.json
|
|
#include "libslic3r/CAD/GeometryEngine.hpp" // face_by_index for face-extrude gizmo anchor
|
|
#include "libslic3r/TriangleMesh.hpp" // mesh import: STL/OBJ -> indexed_triangle_set
|
|
#include "libslic3r/Format/OBJ.hpp"
|
|
#include "libslic3r/Format/bbs_3mf.hpp" // put_other_changes: mark the project dirty outside the undo stack
|
|
|
|
#include <boost/algorithm/string/case_conv.hpp>
|
|
#include <boost/filesystem/path.hpp>
|
|
#include <boost/log/trivial.hpp> // the offer/atlas join check reports on the log
|
|
#include <Standard_Failure.hxx>
|
|
|
|
#include <cassert>
|
|
#include <cstdarg> // offer_trace: diagnostic row dump for the offer ladder
|
|
#include <map>
|
|
#include <set>
|
|
#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 <algorithm>
|
|
#include <thread>
|
|
#include <atomic>
|
|
|
|
#include "slic3r/GUI/wxExtensions.hpp" // ScalableButton, create_scaled_bitmap
|
|
#include "slic3r/GUI/Widgets/Label.hpp" // HarmonyOS Sans fonts (Head_*/Body_*) shared with the rest of Orca
|
|
#include "slic3r/GUI/Widgets/DropDown.hpp" // Orca-themed combo dropdown (white/teal selector) for the tool flyouts
|
|
#include "slic3r/GUI/Widgets/Button.hpp" // Orca-styled Button (ButtonStyle/ButtonType) — same look as Prepare
|
|
#include "slic3r/GUI/Widgets/CheckBox.hpp" // Orca teal check (label lives in the row's left column)
|
|
#include "slic3r/GUI/Widgets/ComboBox.hpp" // Orca dropdown — replaces wxChoice in every Design card
|
|
#include "slic3r/GUI/Widgets/StaticBox.hpp" // Prepare's rounded white card frame around each tool dialog
|
|
#include "libslic3r/CAD/SketchImport.hpp" // text_to_regions / svg_to_regions
|
|
#include "libslic3r/CAD/ThreadStandards.hpp" // ISO metric / Unified imperial thread tables
|
|
#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;
|
|
}
|
|
|
|
// Append a row that carries a feature/body index as client data.
|
|
//
|
|
// It must go through SetClientData(), not Append()'s clientData argument. Orca's ComboBox keeps
|
|
// client data in its own vector and its Append() writes that vector directly, never routing
|
|
// through wxItemContainer — so the container's m_clientDataItemsType stays wxClientData_None.
|
|
// wxItemContainer::GetClientData() opens with
|
|
// wxCHECK_MSG( HasClientUntypedData(), NULL, ... );
|
|
// and wxCHECK_MSG is an early RETURN, not a debug-only assert. So the read handed back NULL for
|
|
// every row and every caller resolved it to index 0 no matter what the user had picked — silently,
|
|
// because index 0 is a legal answer. SetClientData() flips the type to wxClientData_Void, after
|
|
// which the value survives the round trip.
|
|
static int combo_append_index(ComboBox* c, const wxString& label, int index)
|
|
{
|
|
const int row = c->Append(label, wxNullBitmap);
|
|
c->SetClientData(unsigned(row), reinterpret_cast<void*>(intptr_t(index)));
|
|
return row;
|
|
}
|
|
|
|
// 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);
|
|
// THE WHEEL SCROLLS THE PANEL, IT DOES NOT EDIT THE VALUE. wxSpinCtrlDouble takes the wheel
|
|
// whenever the pointer is over it, so a card taller than the panel could not be scrolled past
|
|
// without silently incrementing whatever field happened to be under the cursor — measured:
|
|
// eight notches turned an X hint from 1,00 into 6,00, and the same gesture over an Extrude
|
|
// distance or a mate Offset is a silent model change made by someone who thought they were
|
|
// navigating. Skipping the event lets it reach the scrolled cards panel. A spin the user has
|
|
// deliberately focused still takes the wheel, which is the one case where editing is meant.
|
|
s->Bind(wxEVT_MOUSEWHEEL, [s](wxMouseEvent& e) {
|
|
if (wxWindow::FindFocus() == s) e.Skip(); // focused: wheel edits, as expected
|
|
else if (wxWindow* p = s->GetParent()) // otherwise hand it to the panel
|
|
wxPostEvent(p, e);
|
|
});
|
|
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
|
|
}
|
|
|
|
// Does this plane survive a round trip through the Hole/Thread plane dropdown? The dropdown holds
|
|
// only XY/XZ/YZ and index_from_plane SNAPS anything else to the nearest of the three, so a plane
|
|
// that is not one of them cannot be restored from the row and has to be carried explicitly.
|
|
// Origin is compared too, not just the axes: a face plane parallel to XY but 12 mm up snaps to
|
|
// row 0 and would otherwise come back at z=0. Vector norms rather than isApprox, which compares
|
|
// relative to magnitude and so is useless against the zero origin.
|
|
// modeling_origin is not optional: hole_plane()/thread_plane() ADD it to the dropdown plane before
|
|
// the feature stores it, so on a document with a shifted origin every dropdown hole would fail a
|
|
// bare XY/XZ/YZ comparison and be mistaken for a face pick.
|
|
static bool is_base_plane(const SketchPlane& p, const Vec3d& modeling_origin)
|
|
{
|
|
SketchPlane b = plane_from_index(index_from_plane(p));
|
|
b.origin += modeling_origin;
|
|
const double e = 1e-6;
|
|
return (p.origin - b.origin).norm() < e && (p.normal - b.normal).norm() < e
|
|
&& (p.x_axis - b.x_axis).norm() < e && (p.y_axis - b.y_axis).norm() < e;
|
|
}
|
|
|
|
// #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()) {
|
|
wxString on;
|
|
const SketchPlane plane = sketch_plane_from_selection(on);
|
|
m_viewport->begin_sketch(plane, mode);
|
|
m_construction->SetValue(false); // a fresh session starts non-construction
|
|
m_sketch_on = on; // shown with the tool hint, so the target is visible
|
|
// The face has been CONSUMED as the sketch plane, so drop the pick. Leaving it live
|
|
// meant the next Extrude saw a selected face and push/pulled it instead of extruding
|
|
// the sketch just drawn — the same trap the imported-art path already guards against.
|
|
if (m_sel_solid_face >= 0 || m_pick_face >= 0) {
|
|
m_sel_solid_face = m_sel_solid_edge = m_sel_solid_body = -1;
|
|
m_pick_face = m_pick_face_body = -1;
|
|
}
|
|
} else {
|
|
m_viewport->set_sketch_tool(mode);
|
|
}
|
|
m_viewport->set_sketch_construction(m_construction->GetValue());
|
|
m_status->SetForegroundColour(wxNullColour);
|
|
set_status(m_sketch_on.IsEmpty() ? hint
|
|
: wxString::Format(_L("%s · on %s"), hint, m_sketch_on));
|
|
m_status->Refresh();
|
|
};
|
|
|
|
// Sketch-tool shortcuts (single letters, active only while a sketch is open). Family tools
|
|
// 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 end radius"));
|
|
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.
|
|
// V for Value: type the defining number of whatever is selected — a line's length, an arc's
|
|
// radius, a circle's diameter, the angle between two lines. One handler behind three offer
|
|
// rows, because the quantity comes from the selection, not from which row was clicked.
|
|
//
|
|
// It needs a KEY, not just a menu row. The deck profile in VSD_n1_streamcontroller is
|
|
// generated from these two key tables, so a verb with no shortcut cannot be put on a
|
|
// physical button at all — which is the whole point of that profile for a user who drives
|
|
// the app from buttons rather than a menu.
|
|
m_keys_sketch['V'] = [this] {
|
|
if (m_viewport && m_viewport->is_sketching() && !m_viewport->edit_sketch_selection_value())
|
|
set_status(_L("Nothing here has a value to type — pick a line, an arc, a circle, or two entities"));
|
|
};
|
|
m_keys_sketch['K'] = [this] { enter_constrain_inline(); };
|
|
m_keys_sketch['Q'] = [this] {
|
|
// With geometry selected, Q converts THAT geometry (snaporca-6zic) — the reading
|
|
// everyone arrives with from other sketchers. With nothing selected it keeps its
|
|
// old meaning: arm construction for whatever you draw next.
|
|
if (m_viewport && m_viewport->is_sketching() &&
|
|
m_viewport->toggle_sketch_construction_selection() > 0) {
|
|
set_status(_L("Converted the selection between construction and real geometry"));
|
|
return;
|
|
}
|
|
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);
|
|
set_status(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
|
|
|
|
// Home: axonometric view, fitted to the model.
|
|
//
|
|
// DesignCanvas::set_view() and fit_view() were written and then never called from
|
|
// anywhere in the tree, so the Design viewport had NO way back to a standard view —
|
|
// no key, no button, nothing but orbiting by hand until the model happened to be in
|
|
// frame. A camera left pointing along the bed plane looks exactly like a renderer
|
|
// that has failed, which is how this was found.
|
|
//
|
|
// Home rather than a letter because every letter A-Z is already a Shift+letter tool
|
|
// shortcut, and because Home is the reset-the-view key users arrive with. set_view()
|
|
// already does select_view + zoom_to_volumes, so this is fit and orient in one.
|
|
m_keys_feature[WXK_HOME] = [this] {
|
|
if (!m_viewport) return;
|
|
m_viewport->set_view("iso");
|
|
set_status(_L("Isometric view, fitted"));
|
|
};
|
|
|
|
// Commit to Plate and the bed toggle were mouse-only: a toolbar button and a checkbox with
|
|
// no accelerator between them, so neither could be reached from the keyboard at all, nor by
|
|
// anything driving the keyboard. Ctrl+Shift+P is Plate, Ctrl+Shift+B is Bed; neither
|
|
// collides with Orca's own Ctrl+Shift+S (Save as) or Ctrl+Shift+G (Print plate).
|
|
m_keys_feature['P' | SC_SHIFT | SC_CTRL] = [this] { on_commit(); };
|
|
m_keys_feature['B' | SC_SHIFT | SC_CTRL] = [this] {
|
|
if (!m_show_bed) return;
|
|
const bool show = !m_show_bed->GetValue();
|
|
m_show_bed->SetValue(show);
|
|
if (m_viewport) m_viewport->set_show_bed(show);
|
|
set_status(show ? _L("Bed shown") : _L("Bed hidden"));
|
|
};
|
|
|
|
// 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;
|
|
// THE TOOLBAR IS CHROME. Every CAD verb is reached from the offer, so a tool's button is
|
|
// still BUILT — that is what registers its "fly:<family>#<row>" address and its Shift+key —
|
|
// but it is never placed on the bar. Hiding rather than skipping construction is deliberate:
|
|
// the addresses are created inside the widget-building loops, so not building would silently
|
|
// delete 42 verbs from the offer while they still rendered. snaporca-7ih records the cleanup
|
|
// that lets the construction go away too.
|
|
// What stays: the two doc-row imports (consumed by add_doc below) and the view controls,
|
|
// which are chrome_only in the atlas and so have no offer row to fall back on.
|
|
static const std::set<std::string> kBarKeep = { "step", "mesh", "place", "section", "flip" };
|
|
auto fadd = [&tb_slot](const char* id, wxWindow* w) {
|
|
if (kBarKeep.count(id) == 0) { w->Hide(); return; }
|
|
tb_slot[id].push_back(w);
|
|
};
|
|
m_tb_feature->Add(caption(_L("FEATURES")), 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 8);
|
|
{
|
|
// Onshape-style FEATURE flyouts: same themed-DropDown pattern as the sketch toolbar
|
|
// (tinted glyphs, Body_14 measure, content-width popup) but each entry runs an
|
|
// 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) {
|
|
// REGISTER FIRST, BUILD SECOND. A verb's two addresses — "fly:<family>#<row>" for the
|
|
// offer and its Shift+key — are data. The widget is one door onto them, not their
|
|
// owner. Doing this before any wxWindow exists is what lets the build below be skipped
|
|
// outright for a family the bar no longer carries. It used to sit INSIDE the build
|
|
// loop, so a retired family still had to be constructed and then Hide()n: skipping it
|
|
// would have deleted 42 verbs from the offer while their rows still rendered and did
|
|
// nothing when picked. snaporca-7ih.
|
|
// Keyed on "fly:<family>#<row>" so the generated table can name a variant without the
|
|
// item struct growing a field at 26 call sites.
|
|
for (size_t i = 0; i < vars.size(); ++i) {
|
|
if (vars[i].key) m_keys_feature[vars[i].key] = vars[i].action; // key runs the same action
|
|
m_verb_actions["fly:" + std::string(id) + "#" + std::to_string(i)] = vars[i].action;
|
|
}
|
|
if (kBarKeep.count(id) == 0)
|
|
return; // reached from the offer alone — no button, no chevron, no popup
|
|
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);
|
|
}
|
|
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);
|
|
};
|
|
|
|
auto* b_sketch = icon_btn("design_sketch", _L("Sketch"));
|
|
// Sketch means a tool. Pressing it used to set the mode and then ask, unconditionally,
|
|
// for the very thing the user had just done — click XZ, read "XZ plane selected, press
|
|
// Sketch", press Sketch, and be told to click a reference plane. The plane was never
|
|
// lost (m_ref_plane holds it and begin_sketch captures it when the first tool is armed);
|
|
// the sentence was simply false, and with right-click excluded in sketch mode there was
|
|
// no door to the tools at all, so the only way on was the toolbar this tab is retiring.
|
|
std::function<void()> act_sketch = [this] {
|
|
set_ui_mode(UiMode::Sketch);
|
|
wxString where;
|
|
const bool have_plane = sketch_plane_target(where);
|
|
m_status->SetForegroundColour(wxNullColour);
|
|
set_status(have_plane
|
|
? wxString::Format(_L("Sketching on %s — pick a tool"), where)
|
|
: _L("Click a face or a reference plane in the viewport, then a sketch tool"));
|
|
m_status->Refresh();
|
|
if (m_sketch_hint) { // the card must agree with the status line, not argue with it
|
|
m_sketch_hint->SetLabel(have_plane
|
|
? wxString::Format(_L("Drawing on %s.\nPick a tool, or press Menu for the list."), where)
|
|
: _L("Click a face or a reference plane, then a sketch tool."));
|
|
m_sketch_hint->Refresh();
|
|
m_cards->Layout();
|
|
}
|
|
// Hand over the tools rather than naming them in a status line. CallAfter so the
|
|
// mode change has settled before a modal menu takes the loop; the menu carries each
|
|
// tool's shortcut, so pressing the key instead of picking a row costs nothing.
|
|
if (have_plane)
|
|
CallAfter([this] { show_offer_menu(offer_anchor()); });
|
|
};
|
|
b_sketch->Bind(wxEVT_BUTTON, [act_sketch](wxCommandEvent&) { act_sketch(); });
|
|
m_keys_feature[SHIFT('S')] = act_sketch;
|
|
fadd("sketch", b_sketch);
|
|
// 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));
|
|
set_status(_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));
|
|
set_status(_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));
|
|
set_status(_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));
|
|
set_status(_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));
|
|
set_status(_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(std::min(selected_body_default(),
|
|
int(m_thicken_body->GetCount()) - 1));
|
|
}
|
|
// KEEP a face the user has already picked. Clearing it unconditionally was right
|
|
// when the only door was a toolbar button, which carries no selection: you pressed
|
|
// Thicken and were then asked to point at something. Reached from the offer the
|
|
// verb is invoked ON a face, so discarding it opened the card reading "(pick a
|
|
// solid face)" over an immediate "thicken: face not found" — the user pointed at
|
|
// the face and the card said it could not find one. snaporca-kgx.
|
|
// The index is per-body, so it only survives if the body combo landed on the body
|
|
// it came from; selected_body_default() above returns exactly that when valid.
|
|
if (m_thicken_body->GetSelection() != m_sel_solid_body)
|
|
m_sel_solid_face = -1;
|
|
open_tool(Tool::Thicken); // syncs m_thicken_face_label from m_sel_solid_face
|
|
}, 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));
|
|
set_status(_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(std::min(selected_body_default(),
|
|
int(m_rib_body->GetCount()) - 1));
|
|
}
|
|
{
|
|
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)
|
|
combo_append_index(m_rib_sketch, wxString::FromUTF8(m_doc.features[i].name), i);
|
|
}
|
|
if (m_rib_sketch->GetCount() > 0) m_rib_sketch->SetSelection(0);
|
|
else {
|
|
m_status->SetForegroundColour(wxColour(235, 110, 110));
|
|
set_status(_L("Create a sketch with an open line first"));
|
|
m_status->Refresh();
|
|
return;
|
|
}
|
|
}
|
|
open_tool(Tool::Rib);
|
|
}, 'R'}, // plain R, not SHIFT+R (that is Revolve): every SHIFT letter A-Z
|
|
// was already taken, and the feature map already carries unshifted
|
|
// keys (P, A, X). Rib was the only verb in this dropdown with no
|
|
// shortcut at all, which also made its card unreachable to the rig.
|
|
});
|
|
|
|
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));
|
|
set_status(_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);
|
|
m_body_gates.push_back({b_pattern, 1, b_pattern->GetToolTipText(),
|
|
_L("Pattern — needs a solid body to replicate")});
|
|
|
|
// 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));
|
|
set_status(_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));
|
|
set_status(_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));
|
|
set_status(_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));
|
|
set_status(_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));
|
|
set_status(_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));
|
|
set_status(_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));
|
|
set_status(_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(std::min(selected_body_default(),
|
|
int(m_proj_source_body->GetCount()) - 1));
|
|
}
|
|
populate_plane_choices(m_proj_plane);
|
|
m_sel_solid_face = -1;
|
|
m_proj_face_label->SetLabel(_L("(all edges)"));
|
|
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));
|
|
set_status(_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(std::min(selected_body_default(),
|
|
int(m_xf_body->GetCount()) - 1));
|
|
}
|
|
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));
|
|
set_status(_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(std::min(selected_body_default(),
|
|
int(m_mirror_body->GetCount()) - 1));
|
|
}
|
|
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));
|
|
set_status(_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);
|
|
m_body_gates.push_back({b_boolean, 2, b_boolean->GetToolTipText(),
|
|
_L("Boolean — needs two solid bodies to combine")});
|
|
|
|
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));
|
|
set_status(_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);
|
|
m_body_gates.push_back({b_cut, 1, b_cut->GetToolTipText(),
|
|
_L("Cut — needs a solid body to slice")});
|
|
|
|
// Color — override the selected body's display colour (per-body, survives recompute).
|
|
auto* b_color = icon_btn("color_palette", _L("Color — set the selected body's display colour"));
|
|
b_color->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_set_body_color(); });
|
|
fadd("color", b_color);
|
|
m_verb_actions["btn:colour"] = [this] { on_set_body_color(); };
|
|
m_verb_actions["btn:delete"] = [this] { on_delete_feature(); };
|
|
// Rename exists as a slow double-click on the row too, but the offer is this tab's only
|
|
// tool vocabulary — a rename that only a double-click reveals is not discoverable, and
|
|
// the row IS the object, so it belongs in the menu (and on F2) as well as on the row.
|
|
auto rename_feature = [this] {
|
|
// A BODY renames ITSELF. The earlier version resolved the body to
|
|
// CadBody::source_feature and renamed that feature, which is the wrong object: a
|
|
// body accumulates many features and the first one is not its name. The body row
|
|
// is editable now (CadBody::user_name), so the verb opens the editor there.
|
|
const int b = tree_body_selection();
|
|
if (b >= 0 && b < int(m_tree_body_items.size())) {
|
|
const wxTreeItemId row = m_tree_body_items[b];
|
|
// After the menu, not inside it: an editor opened from within PopupMenu's
|
|
// nested loop never appears.
|
|
CallAfter([this, row] { m_parts->SetFocus(); m_parts->EditLabel(row); });
|
|
return;
|
|
}
|
|
const int sel = tree_selection();
|
|
if (sel != wxNOT_FOUND && sel < int(m_tree_items.size())) {
|
|
const wxTreeItemId row = m_tree_items[sel];
|
|
CallAfter([this, row] { m_tree->SetFocus(); m_tree->EditLabel(row); });
|
|
} else {
|
|
m_status->SetForegroundColour(wxNullColour); // "nothing selected" is not an error
|
|
set_status(_L("Select a feature, or a body, first — then rename it"));
|
|
m_status->Refresh();
|
|
}
|
|
};
|
|
m_verb_actions["btn:rename"] = rename_feature;
|
|
m_keys_feature[WXK_F2] = rename_feature; // a function key, so no letter space spent
|
|
// The sketch's own Delete. It used to share "btn:delete" with the feature tree, so
|
|
// choosing Delete on a selected LINE ran on_delete_feature() and removed a tree row (or
|
|
// nothing) while the line stayed — the reported "I click a line and cannot remove it".
|
|
m_verb_actions["btn:sk_delete"] = [this] {
|
|
if (m_viewport && m_viewport->is_sketching())
|
|
m_viewport->delete_selected_sketch_entities();
|
|
else
|
|
on_delete_feature();
|
|
};
|
|
|
|
m_verb_actions["btn:delete_body"] = [this] { on_delete_body(); };
|
|
m_verb_actions["btn:edit"] = [this] { on_edit_feature(); };
|
|
m_verb_actions["btn:mass"] = [this] { on_mass_properties(); };
|
|
// Reachable from the offer menu on a SELECTED SKETCH, not only from the toolbar icon.
|
|
// A user evaluating against Onshape reported that "adding constraints seems to be
|
|
// missing" — with nineteen constraint types and a solver shipped. The only paths in
|
|
// were an icon-only button and a sketch-mode-only offer row filed under "Reference",
|
|
// so after finishing a sketch there was no affordance where users actually look.
|
|
m_verb_actions["btn:constrain"] = [this] {
|
|
on_begin_constrain();
|
|
if (m_viewport && (m_viewport->is_constraining() || m_viewport->is_constraining_entities()))
|
|
set_ui_mode(UiMode::Constrain);
|
|
};
|
|
|
|
// 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("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));
|
|
set_status(_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(std::min(selected_body_default(),
|
|
int(m_del_face_body->GetCount()) - 1));
|
|
}
|
|
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;
|
|
set_hole_target_label(-1);
|
|
// Use the explicitly-picked face; otherwise default to the top face of the
|
|
// selected (or first) body so the hole lands on the surface being viewed, not
|
|
// the z=0 datum under the model. The XY/XZ/YZ dropdown still overrides.
|
|
int hb = m_sel_solid_body, hf = m_sel_solid_face;
|
|
if (hf < 0 && !m_doc.bodies.empty()) {
|
|
hb = (hb >= 0 && hb < int(m_doc.bodies.size())) ? hb : 0;
|
|
hf = top_face_index_of(m_doc.bodies[hb].shape);
|
|
}
|
|
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;
|
|
set_hole_target_label(hf); // may be the top-face default, not a pick
|
|
// 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;
|
|
set_thread_target_label(-1, -1);
|
|
GeometryEngine::CylinderFace cf;
|
|
bool from_face = false; // which of the two the cylinder actually came from
|
|
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));
|
|
from_face = cf.ok;
|
|
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;
|
|
set_thread_target_label(from_face ? m_sel_solid_face : -1,
|
|
from_face ? -1 : m_sel_solid_edge);
|
|
infer_thread_spec(2.0 * cf.radius); // M diameter + pitch + depth from the cylinder
|
|
if (m_thread_height && cf.height > 1e-6) m_thread_height->SetValue(cf.height);
|
|
if (m_thread_internal) m_thread_internal->SetValue(cf.internal);
|
|
if (m_thread_x) m_thread_x->SetValue(0.0); // on the axis
|
|
if (m_thread_y) m_thread_y->SetValue(0.0);
|
|
} else if (m_sel_solid_face >= 0 || m_sel_solid_edge >= 0) {
|
|
m_status->SetForegroundColour(wxColour(235, 110, 110));
|
|
set_status(_L("Pick a cylindrical surface (bore / outer) or a circular edge for a thread"));
|
|
m_status->Refresh();
|
|
}
|
|
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.
|
|
// Same for the sketch bar: the drawing tools live in the offer. Only Delete selected stays,
|
|
// via sadd_bar. sadd() still runs so sk_key/fly: registrations and the flyout popups survive.
|
|
auto sadd = [](wxWindow* w) { w->Hide(); };
|
|
auto sadd_bar = [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) {
|
|
// Register first, build second — the same law as feat_dropdown, for the same reason.
|
|
// The offer reaches each tool by its ratified address; without these the offer could
|
|
// name a family but only ever arm its FIRST tool: picking "Rectangle" ran key:R and
|
|
// gave you a corner rectangle, with oblique and rounded unreachable. Keyed on the icon
|
|
// id (already unique per family) so no call site grows an argument. snaporca-6vs.
|
|
for (size_t i = 0; i < vars.size(); ++i) {
|
|
const DesignSketchTool::Mode mode = vars[i].mode;
|
|
const wxString hint = vars[i].hint;
|
|
m_verb_actions["fly:" + std::string(def_icon) + "#" + std::to_string(i)] =
|
|
[mode, hint, select_tool] { select_tool(mode, hint); };
|
|
}
|
|
if (kBarKeep.count(def_icon) == 0)
|
|
return; // the drawing tools live in the offer; nothing of this family is built
|
|
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);
|
|
sadd(chev); // follows its button off the bar
|
|
};
|
|
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"));
|
|
// (separator dropped: the group it divided is now reached from the offer)
|
|
dropdown("design_line", _L("Line / polyline"), {
|
|
{"design_line", DesignSketchTool::Mode::Line, _L("Line"), _L("Click start, then end — then set the exact length")},
|
|
{"design_polyline", DesignSketchTool::Mode::Polyline, _L("Polyline"), _L("Click points; click first / right-click to close the loop")} });
|
|
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 the end radius")},
|
|
{"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"));
|
|
// (separator dropped: the group it divided is now reached from the offer)
|
|
// 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.
|
|
{
|
|
// In Sketch MODE the art must land in the sketch — but begin_sketch() does not run
|
|
// until the first tool is armed, so pressing Sketch and then Text would find no
|
|
// session and commit a separate feature. Arm Select first: it starts the session on
|
|
// the picked plane without drawing anything, so Text behaves the same whether or not
|
|
// you had already drawn a line. (MODE vs SESSION — the distinction that has bitten
|
|
// this panel before.)
|
|
auto ensure_sketch = [this, select_tool] {
|
|
if (m_ui_mode == UiMode::Sketch && m_viewport && !m_viewport->is_sketching())
|
|
select_tool(DesignSketchTool::Mode::Select,
|
|
_L("Select — click to select; Shift to add"));
|
|
};
|
|
auto* b_text = icon_btn("design_text", _L("Text — emboss text as a profile"));
|
|
b_text->Bind(wxEVT_BUTTON, [this, ensure_sketch](wxCommandEvent&) {
|
|
ensure_sketch(); 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, ensure_sketch](wxCommandEvent&) {
|
|
ensure_sketch(); on_import_svg(); });
|
|
sadd(b_svg);
|
|
// …and reachable from the offer's Create row. These were on the atlas's chrome_only
|
|
// list under "document-level actions act on the DOCUMENT, not on a selection" — which
|
|
// is not what they do: both call add_imported_sketch(), which drops the art ON a
|
|
// picked solid face (SketchPlane::from_face, centred on it) exactly as Sketch does.
|
|
// Selection-consuming profile creators, so they belong with the other Create verbs.
|
|
m_verb_actions["btn:text"] = [this, ensure_sketch] { ensure_sketch(); on_add_text(); };
|
|
m_verb_actions["btn:svg"] = [this, ensure_sketch] { ensure_sketch(); on_import_svg(); };
|
|
}
|
|
// (separator dropped: the group it divided is now reached from the offer)
|
|
// 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 and fit are TOOL PARAMETERS, so they are chosen from the tool:
|
|
// the offer's Create > Polygon submenu. They used to sit inline in this row, then in a
|
|
// sidebar card; both put the choice somewhere you had to leave the geometry to reach,
|
|
// and the count cannot be recovered afterwards (a drawn polygon's inline editor offers
|
|
// Side and Angle, never the count). snaporca-e1p.
|
|
auto arm_polygon = [this, select_tool] {
|
|
push_polygon_params();
|
|
select_tool(DesignSketchTool::Mode::Polygon,
|
|
wxString::Format(_L("Click center then a vertex — %d sides, %s"),
|
|
m_poly_sides,
|
|
m_poly_circumscribed ? _L("circumscribed") : _L("inscribed")));
|
|
};
|
|
for (int n : {3, 4, 5, 6, 8, 12})
|
|
m_verb_actions["btn:poly#" + std::to_string(n)] =
|
|
[this, arm_polygon, n] { m_poly_sides = n; arm_polygon(); };
|
|
for (int c : {0, 1})
|
|
m_verb_actions["btn:polyfit#" + std::to_string(c)] =
|
|
[this, arm_polygon, c] { m_poly_circumscribed = (c == 1); arm_polygon(); };
|
|
|
|
// Same defect, one level up: a combo whose entries are different VERBS wearing a single
|
|
// name. "Dress-up" is Fillet or Chamfer; "Combine" is union, subtract or intersect;
|
|
// "Pattern" is linear or circular. The choice decides WHAT YOU ARE DOING, so it belongs
|
|
// where you chose the tool — not behind a card you must open to discover it existed.
|
|
// Each address opens the tool exactly as its shortcut does, then says which one.
|
|
// The members are read at INVOCATION, not capture: the cards are built after this row.
|
|
// snaporca-e1p.
|
|
auto open_feature = [this](int key) {
|
|
auto it = m_keys_feature.find(key);
|
|
if (it != m_keys_feature.end() && it->second) it->second();
|
|
};
|
|
// SHIFT() is a constructor-local helper, so resolve the codes here rather than inside
|
|
// the stored lambdas, which outlive it.
|
|
const int k_dress = SHIFT('F'), k_bool = SHIFT('B'), k_pat = SHIFT('N');
|
|
// Choose FIRST, then open: open_tool() titles the card from m_dressup_type, so setting
|
|
// it afterwards left the header reading "Fillet 1" over a chamfer. Nothing in the
|
|
// opener resets the combo, so this order is safe.
|
|
m_verb_actions["btn:dress#0"] = [this, open_feature, k_dress] {
|
|
if (m_dressup_type) m_dressup_type->SetSelection(0); open_feature(k_dress); };
|
|
m_verb_actions["btn:dress#1"] = [this, open_feature, k_dress] {
|
|
if (m_dressup_type) m_dressup_type->SetSelection(1); open_feature(k_dress); };
|
|
for (int op = 0; op < 3; ++op)
|
|
m_verb_actions["btn:bool#" + std::to_string(op)] = [this, open_feature, k_bool, op] {
|
|
open_feature(k_bool);
|
|
if (m_bool_op) { m_bool_op->SetSelection(op); refresh_preview(); } };
|
|
for (int t = 0; t < 2; ++t)
|
|
m_verb_actions["btn:pat#" + std::to_string(t)] = [this, open_feature, k_pat, t] {
|
|
open_feature(k_pat); if (m_pattern_type) m_pattern_type->SetSelection(t); };
|
|
|
|
auto* b_poly = icon_btn("design_polygon", _L("Polygon"));
|
|
b_poly->Bind(wxEVT_BUTTON, [arm_polygon](wxCommandEvent&) { arm_polygon(); });
|
|
sadd(b_poly);
|
|
// (separator dropped: the group it divided is now reached from the offer)
|
|
// Q's semantics, on the control that carries the word. With geometry selected the box
|
|
// CONVERTS it — that is what a user who has just selected a construction circle and
|
|
// reached for the box labelled "Construction" is asking for, and until now it was the
|
|
// only one of the three routes (Q, the offer's Reference row, this box) that could not
|
|
// do it: it armed the mode for the NEXT entity, silently, changing nothing about the
|
|
// shape on screen and flipping the draw mode behind the user's back. The tick is a MODE
|
|
// indicator, so after a conversion it goes back to what it was.
|
|
m_construction->Bind(wxEVT_CHECKBOX, [this](wxCommandEvent&) {
|
|
if (!m_viewport || !m_viewport->is_sketching())
|
|
return;
|
|
// ONLY IN SELECT MODE. Drawing auto-selects what was just drawn (draw-then-edit), so
|
|
// with a draw tool armed "there is a selection" does not mean the user picked
|
|
// anything — it means they finished a line. Converting there turns the box into a
|
|
// trap: arm construction, draw the axis, click the box to go back to real geometry,
|
|
// and instead of disarming the mode it converts the axis you just drew. The gesture
|
|
// ladder's mirror rung does exactly that and reported three construction entities
|
|
// where it wanted one. In Select mode the intent is unambiguous.
|
|
const int n = m_viewport->sketch_is_selecting()
|
|
? m_viewport->toggle_sketch_construction_selection() : 0;
|
|
if (n > 0) {
|
|
m_construction->SetValue(!m_construction->GetValue()); // the mode did not move
|
|
m_status->SetForegroundColour(wxNullColour);
|
|
set_status(wxString::Format(
|
|
_L("Converted %d entit%s between construction and real geometry"),
|
|
n, n == 1 ? "y" : "ies"));
|
|
m_status->Refresh();
|
|
return;
|
|
}
|
|
m_viewport->set_sketch_construction(m_construction->GetValue()); });
|
|
// STAYS on the bar. Construction is not a tool, it is a persistent MODE — the same kind
|
|
// of thing as the Bed checkbox — and the sketch bar is already shown only in Sketch mode,
|
|
// so it appears exactly while it can apply. Hiding it left Q and the offer's Construction
|
|
// row still toggling a checkbox nobody could see: you could not tell whether the next
|
|
// line would be construction geometry. A stateful toggle has to show its state.
|
|
sadd_bar(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_bar(b_del); // keep-list: Delete selected stays on the bar
|
|
// 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);
|
|
// Apply the body gates once now: an empty document is exactly the state the bug was reported
|
|
// in, and feed_bodies() has not run yet on a fresh tab.
|
|
update_body_gates();
|
|
|
|
// Onshape-style dialog-card header: feature icon + bold title. out receives
|
|
// the title control so open_tool() can retitle it per feature.
|
|
auto card_header = [](wxWindow* card, const char* icon, const wxString& title, wxStaticText*& out) -> wxSizer* {
|
|
auto* 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);
|
|
|
|
// NO plane row. A sketch takes its plane from what is picked in the VIEWPORT — a planar face
|
|
// on a solid, or one of the reference-plane ghosts clicked in 3D — resolved by
|
|
// sketch_plane_from_selection(). A three-row XY/XZ/YZ combo could not express either of those
|
|
// targets, so it displayed a value that was at best redundant and at worst false. snaporca-e1p.
|
|
|
|
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) ---
|
|
{
|
|
// NO polygon card. Sides and Circumscribed are tool parameters and are chosen from the
|
|
// tool — the offer's Create > Polygon submenu names the common side counts and the two
|
|
// fits, and arming from there sets both. A spin field on the left could not be reached
|
|
// without leaving the geometry, and the count is unrecoverable afterwards: the inline
|
|
// editor a drawn polygon opens offers Side and Angle, never the count. snaporca-e1p.
|
|
}
|
|
|
|
// --- 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("Type")), 0, wxALIGN_CENTER_VERTICAL);
|
|
dform->Add(m_dressup_type, 0, wxEXPAND);
|
|
|
|
// The card can dress ONE picked edge or a whole face-group, and which one it will do is
|
|
// decided by a viewport pick the card previously said nothing about — so a user who had not
|
|
// picked an edge saw only the group combo and concluded per-edge rounding did not exist,
|
|
// while a user who HAD picked one saw the combo still reading "All" and was told the opposite
|
|
// of what Confirm would do. This row states the actual target, as Shell and Draft already do.
|
|
m_dressup_edge_label = new wxStaticText(m_cards, wxID_ANY, _L("(no edge picked — group below)"));
|
|
dform->Add(new wxStaticText(m_cards, wxID_ANY, _L("Target")), 0, wxALIGN_CENTER_VERTICAL);
|
|
dform->Add(m_dressup_edge_label, 0, wxALIGN_CENTER_VERTICAL);
|
|
|
|
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;
|
|
set_hole_target_label(-1);
|
|
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_hole_target_label = new wxStaticText(m_cards, wxID_ANY, _L("(none — uses Hole plane)"));
|
|
hform->Add(new wxStaticText(m_cards, wxID_ANY, _L("On face")), 0, wxALIGN_CENTER_VERTICAL);
|
|
hform->Add(m_hole_target_label, 0, 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_thread_target_label = new wxStaticText(m_cards, wxID_ANY, _L("(none — uses Thread plane)"));
|
|
tform->Add(new wxStaticText(m_cards, wxID_ANY, _L("On face")), 0, wxALIGN_CENTER_VERTICAL);
|
|
tform->Add(m_thread_target_label, 0, 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));
|
|
set_status(_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);
|
|
set_status(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);
|
|
|
|
m_cs_body = make_combo(m_cards);
|
|
m_cs_body->Append(_L("(all)"));
|
|
m_cs_body->SetSelection(0);
|
|
m_cs_body->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) {
|
|
if (m_viewport) m_viewport->set_xray_focus(m_cs_body->GetSelection() - 1);
|
|
});
|
|
m_box_coordsys->Add(new wxStaticText(m_cards, wxID_ANY, _L("Body")), 0, wxLEFT | wxRIGHT | wxTOP, 12);
|
|
m_box_coordsys->Add(m_cs_body, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12);
|
|
auto* body_hint = new wxStaticText(m_cards, wxID_ANY,
|
|
_L("Restrict picking to one body — the others go see-through and stop catching clicks"));
|
|
body_hint->SetForegroundColour(dp_sec_text());
|
|
m_box_coordsys->Add(body_hint, 0, 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);
|
|
{
|
|
// NO plane dropdown. The sketch plane comes from what is picked in the VIEWPORT — a face
|
|
// on a solid, or one of the reference-plane ghosts — because that is where the user is
|
|
// looking and pointing. A combo duplicated that decision somewhere the geometry could not
|
|
// see it, and once a face could be picked it went further and displayed a stale row that
|
|
// contradicted the real target. snaporca-e1p.
|
|
// Kept as a member, not a local: the card has to be able to STOP saying this. It asked
|
|
// for a plane even when one had just been picked, directly contradicting the status line
|
|
// two inches below it, which by then read "Sketching on XZ".
|
|
m_sketch_hint = new wxStaticText(m_cards, wxID_ANY,
|
|
_L("Click a face or a reference plane, then a sketch tool."));
|
|
m_sketch_hint->SetForegroundColour(dp_sec_text());
|
|
m_box_sketch_session->Add(m_sketch_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);
|
|
|
|
// Wrap every card label now that the whole stack exists. wxStaticText never wraps on its
|
|
// own, so a one-sentence help line under a card sets that card's sizer min width to the
|
|
// width of the entire sentence — the same failure make_combo's explicit width fixes above,
|
|
// and with the same symptom: the card is wider than the sidebar, so every control in it is
|
|
// clipped at the panel's right edge. Found by click-testing Transform, Mirror and Coord Sys,
|
|
// whose hints are the longest. 240 px sits under m_form's 264 px minimum, so a wrapped hint
|
|
// always fits; the short two-column labels are far below it and Wrap() leaves them alone.
|
|
for (wxWindow* w : m_cards->GetChildren())
|
|
if (auto* t = dynamic_cast<wxStaticText*>(w))
|
|
t->Wrap(240);
|
|
|
|
// Feature tree card. Same idiom as Prepare's sections (icon + Head_14 title + rule) via the
|
|
// shared card_header helper, instead of the bare micro-label this used to be; the row-edit
|
|
// actions live in the header, as Prepare puts its section actions.
|
|
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 | wxTR_EDIT_LABELS);
|
|
if (!dp_dark()) m_tree->SetBackgroundColour(dp_panel_bg());
|
|
// Per-feature-type icons (indices match tree_icon_for): sketch/extrude/dressup/hole/thread.
|
|
m_tree_images = new wxImageList(16, 16);
|
|
m_tree_images->Add(create_scaled_bitmap("design_sketch", nullptr, 16)); // 0 Sketch
|
|
m_tree_images->Add(create_scaled_bitmap("design_extrude", nullptr, 16)); // 1 Extrude
|
|
m_tree_images->Add(create_scaled_bitmap("design_dressup", nullptr, 16)); // 2 Fillet/Chamfer
|
|
m_tree_images->Add(create_scaled_bitmap("design_hole", nullptr, 16)); // 3 Hole
|
|
m_tree_images->Add(create_scaled_bitmap("design_thread", nullptr, 16)); // 4 Thread
|
|
m_tree_images->Add(create_scaled_bitmap("design_shell", nullptr, 16)); // 5 Shell
|
|
m_tree->AssignImageList(m_tree_images);
|
|
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".
|
|
// ONLY when this tree actually has a selection. These two lists clear each other's
|
|
// selection so that "the target" is never ambiguous, and that was harmless while both
|
|
// calls were UnselectAll() — a no-op on a wxTR_SINGLE tree. Now that Unselect() really
|
|
// clears, the pair became a loop: clicking a body row runs apply_body_row, which calls
|
|
// m_tree->Unselect(), which fires THIS handler, which cleared the body row the user had
|
|
// just clicked. The guard keeps the mutual-exclusion and drops the echo.
|
|
if (m_parts && tree_selection() != wxNOT_FOUND) m_parts->Unselect();
|
|
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);
|
|
// A conflicting mate says WHY on selection, and names the one action that resolves it.
|
|
// Suppress is the generic per-feature enable toggle (the eye), so the answer is already
|
|
// one click away on a row the user has just selected — the message points at it instead
|
|
// of describing a problem with no way out.
|
|
if (const std::string* why = (sel >= 0) ? mate_conflict_reason(sel) : nullptr) {
|
|
m_status->SetForegroundColour(wxColour(235, 110, 110));
|
|
set_status(wxString::FromUTF8(*why) + _L(" — the eye suppresses this mate"));
|
|
m_status->Refresh();
|
|
} else if (sel >= 0 && sel < int(m_doc.features.size())) {
|
|
// Name the two gestures the row supports, because neither is visible on it.
|
|
m_status->SetForegroundColour(wxNullColour);
|
|
set_status(wxString::Format(
|
|
_L("%s selected — F2 or right-click renames it, double-click edits it"),
|
|
wxString::FromUTF8(m_doc.features[sel].name)));
|
|
m_status->Refresh();
|
|
}
|
|
});
|
|
|
|
// Double-click a row = Edit, the same gesture that re-opens a committed sketch on the canvas.
|
|
// Without it the row only highlights and the feature looks dead until the user finds the
|
|
// Edit button in the section header.
|
|
m_tree->Bind(wxEVT_TREE_ITEM_ACTIVATED, [this](wxTreeEvent&) { on_edit_feature(); });
|
|
|
|
// Right-click a row: the three things a row can do. Renaming had no discoverable route at
|
|
// all — the header pencil is Edit, a double-click ACTIVATES the row and is also Edit (the
|
|
// "slow double-click renames" the old comment promised does not survive wxGTK, which fires
|
|
// ITEM_ACTIVATED first), none of the seven header icons renames, and F2 is a function key
|
|
// nothing announces. A user who wants to name a sketch tries the row, and now the row
|
|
// answers. snaporca-rename.
|
|
m_tree->Bind(wxEVT_TREE_ITEM_RIGHT_CLICK, [this](wxTreeEvent& e) {
|
|
m_tree->SelectItem(e.GetItem()); // right-click targets what it points at
|
|
const int sel = tree_selection();
|
|
if (sel == wxNOT_FOUND) return;
|
|
// EVERYTHING A ROW CAN DO, in one place. The header icons stay as a quick bar, but the
|
|
// menu is the reference: the element you click answers with what applies to it, and a
|
|
// menu grows without spending an icon nobody recognises. Split into what the row IS
|
|
// (name, contents), where it SITS (order, visibility) and what removes it.
|
|
wxMenu menu;
|
|
const int id_rename = wxWindow::NewControlId();
|
|
const int id_edit = wxWindow::NewControlId();
|
|
const int id_up = wxWindow::NewControlId();
|
|
const int id_down = wxWindow::NewControlId();
|
|
const int id_vis = wxWindow::NewControlId();
|
|
const int id_art = wxWindow::NewControlId();
|
|
const int id_del = wxWindow::NewControlId();
|
|
menu.Append(id_rename, _L("Rename\tF2"));
|
|
menu.Append(id_edit, _L("Edit"));
|
|
// Scale artwork acts on THIS feature's imported outline, so it belongs to the row and
|
|
// is offered only where it means something. It used to hide inside the header's Move
|
|
// button, which otherwise moved a body — two different subjects on one icon.
|
|
const bool art = sel < int(m_doc.features.size()) &&
|
|
!m_doc.features[sel].imported_regions.empty();
|
|
if (art) menu.Append(id_art, _L("Scale artwork"));
|
|
menu.AppendSeparator();
|
|
menu.Append(id_up, _L("Move up"));
|
|
menu.Append(id_down, _L("Move down"));
|
|
menu.Append(id_vis, _L("Show / hide"));
|
|
menu.AppendSeparator();
|
|
menu.Append(id_del, _L("Delete"));
|
|
menu.Bind(wxEVT_MENU, [this](wxCommandEvent&) {
|
|
const int row = tree_selection();
|
|
if (row != wxNOT_FOUND && row < int(m_tree_items.size()))
|
|
m_tree->EditLabel(m_tree_items[row]);
|
|
}, id_rename);
|
|
menu.Bind(wxEVT_MENU, [this](wxCommandEvent&) { on_edit_feature(); }, id_edit);
|
|
menu.Bind(wxEVT_MENU, [this](wxCommandEvent&) { on_move_feature(-1); }, id_up);
|
|
menu.Bind(wxEVT_MENU, [this](wxCommandEvent&) { on_move_feature(+1); }, id_down);
|
|
menu.Bind(wxEVT_MENU, [this](wxCommandEvent&) { on_toggle_visibility(); }, id_vis);
|
|
menu.Bind(wxEVT_MENU, [this](wxCommandEvent&) { on_delete_feature(); }, id_del);
|
|
if (art)
|
|
menu.Bind(wxEVT_MENU, [this](wxCommandEvent&) {
|
|
const int row = tree_selection();
|
|
if (row != wxNOT_FOUND) on_transform_imported(row);
|
|
}, id_art);
|
|
m_tree->PopupMenu(&menu);
|
|
});
|
|
|
|
// In-place rename of a feature row (slow double-click, the offer's Rename verb, or F2).
|
|
// The name is what makes a tree of eight sketches readable, and the tree row IS the object —
|
|
// so renaming belongs on the row, not in a side-panel field. Bodies are computed results,
|
|
// not named features, so a body row must never open an editor (it cannot, they live in the
|
|
// Parts list, but the guard keeps a future change from slipping a body into this tree).
|
|
auto item_index = [this](const wxTreeItemId& it) -> int {
|
|
for (size_t i = 0; i < m_tree_items.size(); ++i)
|
|
if (m_tree_items[i] == it) return int(i);
|
|
return wxNOT_FOUND;
|
|
};
|
|
m_tree->Bind(wxEVT_TREE_BEGIN_LABEL_EDIT, [this, item_index](wxTreeEvent& e) {
|
|
// The event's item is the authority for WHAT is being edited; tree_selection() is not,
|
|
// because the editor can open on a row that is not the current selection. A row that is
|
|
// not a feature (a body, or a stray id) gets the edit vetoed before it can take a name.
|
|
if (tree_body_selection() >= 0 || item_index(e.GetItem()) == wxNOT_FOUND) { e.Veto(); return; }
|
|
e.Skip();
|
|
});
|
|
m_tree->Bind(wxEVT_TREE_END_LABEL_EDIT, [this, item_index](wxTreeEvent& e) {
|
|
if (e.IsEditCancelled()) return;
|
|
const int idx = item_index(e.GetItem());
|
|
if (idx == wxNOT_FOUND) { e.Veto(); return; }
|
|
wxString label = e.GetLabel();
|
|
label.Trim(true).Trim(false);
|
|
if (label.empty()) { e.Veto(); return; } // a nameless row is worse than a badly named one
|
|
m_doc.features[idx].name = std::string(label.ToUTF8().data());
|
|
sync_recipe_to_model(); // the name is part of the recipe, so the save path persists it
|
|
e.Skip(); // let wx finish applying the label to the item it is holding
|
|
// REBUILD LATER, NOT NOW. refresh_tree() deletes and re-creates every wxTreeItemId, and
|
|
// we are inside wx's own END_LABEL_EDIT dispatch for one of them — destroying it here
|
|
// frees the item the caller is still using and takes the process down. Measured: typing
|
|
// a name and pressing Enter killed the app outright, with the keystrokes traced and no
|
|
// trace for the Return. Deferring to the next event-loop turn lets wx finish first.
|
|
CallAfter([this] { refresh_tree(); });
|
|
});
|
|
|
|
// Feature-tree edit actions: act on the selected feature (delete / reorder). These sit in the
|
|
// card header (Prepare puts its section actions there too) rather than on a loose row below.
|
|
{
|
|
wxBoxSizer* trow = m_hdr_tree_row;
|
|
auto edit_btn = [this](const char* icon, const wxString& tip) {
|
|
// Header-sized: reads as a section action, not a primary control.
|
|
auto* b = new ScalableButton(m_tree_box, wxID_ANY, icon, "", wxSize(24, 24),
|
|
wxDefaultPosition, wxBU_EXACTFIT | wxBORDER_NONE, false, 20);
|
|
b->SetToolTip(tip);
|
|
return b;
|
|
};
|
|
auto* edit = edit_btn("design_edit", _L("Edit"));
|
|
edit->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_edit_feature(); });
|
|
// NO Move here. Moving a body is not a feature-row action — this header sits over the
|
|
// FEATURE tree, and the button had to guess its subject from whatever happened to be
|
|
// selected, answering a feature row with an instruction about bodies. It lives where a
|
|
// body lives: the Bodies card's own action row, and the offer for a selected body.
|
|
// Scaling imported artwork, which shared this button, moved to the row's own menu.
|
|
auto* vis = edit_btn("design_eye", _L("Show / hide"));
|
|
vis->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_toggle_visibility(); });
|
|
auto* del = edit_btn("design_delete", _L("Delete"));
|
|
del->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) {
|
|
// A body row deletes the feature that made it. on_delete_body() already resolves
|
|
// CadBody::source_feature and asks for confirmation by name; it was reachable only
|
|
// from the right-click offer, so this button answered a selected body row with
|
|
// "select the FEATURE that created this body" — an instruction the user cannot act
|
|
// on, since the tree does not say which feature that is. It does now.
|
|
if (tree_body_selection() >= 0) on_delete_body();
|
|
else on_delete_feature();
|
|
});
|
|
auto* up = edit_btn("design_moveup", _L("Move up"));
|
|
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(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);
|
|
// The bodies card carries the actions that act on a BODY. Move came from the feature-tree
|
|
// header, where it had to guess whether its subject was a body or a feature; Show/hide,
|
|
// Delete and Colour are deliberate COPIES of feature-tree actions, because a body row is a
|
|
// different subject and a user working in this list should not have to travel to another
|
|
// card to hide or recolour what they have selected. Each one already resolves the body row
|
|
// itself (on_toggle_visibility, on_delete_body, on_set_body_color), so nothing here decides
|
|
// policy — the card only gives them a home next to the rows they act on.
|
|
{
|
|
auto body_btn = [this](const char* icon, const wxString& tip) {
|
|
auto* b = new ScalableButton(m_parts_box, wxID_ANY, icon, "", wxSize(24, 24),
|
|
wxDefaultPosition, wxBU_EXACTFIT | wxBORDER_NONE, false, 20);
|
|
b->SetToolTip(tip);
|
|
return b;
|
|
};
|
|
auto* bmove = body_btn("design_move", _L("Move body"));
|
|
bmove->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) {
|
|
if (m_sel_solid_body >= 0 && m_sel_solid_body < int(m_doc.bodies.size())) {
|
|
on_move_body();
|
|
} else {
|
|
m_status->SetForegroundColour(wxNullColour);
|
|
set_status(_L("Select a body row first, then move it"));
|
|
m_status->Refresh();
|
|
}
|
|
});
|
|
auto* bvis = body_btn("design_eye", _L("Show / hide"));
|
|
bvis->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_toggle_visibility(); });
|
|
auto* bdel = body_btn("design_delete", _L("Delete"));
|
|
bdel->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_delete_body(); });
|
|
auto* bcol = body_btn("color_palette", _L("Colour"));
|
|
bcol->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_set_body_color(); });
|
|
const int bgap = FromDIP(SidebarProps::ElementSpacing());
|
|
m_parts_hdr->AddStretchSpacer(1);
|
|
m_parts_hdr->Add(bmove, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, bgap);
|
|
m_parts_hdr->Add(bvis, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, bgap);
|
|
m_parts_hdr->Add(bdel, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, bgap);
|
|
m_parts_hdr->Add(bcol, 0, wxALIGN_CENTER_VERTICAL);
|
|
}
|
|
parts_inner->Add(m_parts_hdr, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP,
|
|
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 | wxTR_EDIT_LABELS);
|
|
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();
|
|
// Taking a body from the list means the SAME state change however it was asked for, so the
|
|
// normalisation lives here and not inside a selection handler. That distinction is not
|
|
// pedantry: SelectItem() on a row that is ALREADY selected fires no SEL_CHANGED at all, so a
|
|
// version of this that only ran on selection left a stale vertex/edge from an earlier
|
|
// viewport pick in place — and offer_selection_kind() tests vertex FIRST, so right-clicking
|
|
// the body row served the VERTEX offer (Fillet greyed, Mirror in place of Repeat) while the
|
|
// row sat highlighted. Measured on the rig 2026-08-02; it is invisible from the code alone.
|
|
auto apply_body_row = [this](int b) {
|
|
if (!m_viewport || b < 0) return;
|
|
// One selection at a time: a body row and a feature row mean different things to the
|
|
// op bar, so clear the feature tree's highlight when a body takes over.
|
|
if (m_tree) m_tree->Unselect(); // wxTR_SINGLE: UnselectAll() does nothing here
|
|
m_viewport->set_body_highlight(false); // the per-body overlay does the tint
|
|
m_viewport->select_body(b); // also drops the vertex/edge marker
|
|
m_sel_solid_body = b;
|
|
m_sel_solid_face = m_sel_solid_edge = -1;
|
|
m_sel_solid_vertex = false;
|
|
m_pick_face = m_pick_face_body = -1; // chosen from the list, no face was pointed at
|
|
m_status->SetForegroundColour(wxNullColour);
|
|
set_status(wxString::Format(_L("Body %d selected — right-click for what applies to it"), b + 1));
|
|
m_status->Refresh();
|
|
};
|
|
m_parts->Bind(wxEVT_TREE_SEL_CHANGED, [this, apply_body_row](wxTreeEvent&) {
|
|
apply_body_row(tree_body_selection());
|
|
});
|
|
// The third door onto the offer, after the viewport right-click and the Menu key. A body ROW
|
|
// is an unambiguous body, so the offer reports BodySolid and the body verbs act on the row you
|
|
// can see highlighted. That is the confirmation a face pick cannot give: pointing at a face
|
|
// lights the face, never the body the verb will actually change. The status line above has
|
|
// been promising this right-click since before it existed.
|
|
// Renaming a BODY names the body itself. It does NOT rename the feature that created it:
|
|
// an Extrude, a Cut and a Fillet all land on one body, so source_feature is one operation in
|
|
// its history and renaming that is renaming the wrong object — reported, correctly, as "you
|
|
// consider the extrusion = the body". CadBody::user_name is carried across recompute() by
|
|
// index and written into the recipe, so the name outlives both the rebuild and the save.
|
|
m_parts->Bind(wxEVT_TREE_BEGIN_LABEL_EDIT, [this](wxTreeEvent& e) {
|
|
if (tree_body_selection() < 0) { e.Veto(); return; }
|
|
e.Skip();
|
|
});
|
|
m_parts->Bind(wxEVT_TREE_END_LABEL_EDIT, [this](wxTreeEvent& e) {
|
|
if (e.IsEditCancelled()) return;
|
|
const int b = tree_body_selection();
|
|
if (b < 0 || b >= int(m_doc.bodies.size())) { e.Veto(); return; }
|
|
wxString label = e.GetLabel();
|
|
label.Trim(true).Trim(false);
|
|
if (label.empty()) { e.Veto(); return; } // a nameless row is worse than a bad name
|
|
m_doc.bodies[b].has_user_name = true;
|
|
m_doc.bodies[b].user_name = std::string(label.ToUTF8().data());
|
|
sync_recipe_to_model(); // the name is part of what gets saved
|
|
e.Skip();
|
|
// Rebuild on the NEXT event-loop turn: refresh_parts() destroys every wxTreeItemId and
|
|
// we are inside wx's own END_LABEL_EDIT dispatch for one of them. The feature tree
|
|
// learned this the hard way — doing it here took the process down.
|
|
CallAfter([this] { refresh_parts(); });
|
|
});
|
|
|
|
m_parts->Bind(wxEVT_TREE_ITEM_MENU, [this, apply_body_row](wxTreeEvent& e) {
|
|
if (e.GetItem().IsOk())
|
|
m_parts->SelectItem(e.GetItem()); // the row under the cursor, never a stale one
|
|
apply_body_row(tree_body_selection()); // unconditional — see above, SelectItem on an
|
|
// already-selected row raises no event
|
|
// GetPoint() is tree-client; it is (-1,-1) when the KEYBOARD menu key raised this, so fall
|
|
// back to the shared anchor rather than popping the menu at a garbage coordinate.
|
|
const wxPoint p = e.GetPoint();
|
|
const wxPoint screen = (p.x >= 0 && p.y >= 0) ? m_parts->ClientToScreen(p) : offer_anchor();
|
|
// Let the modal menu take the loop after this handler returns — same CallAfter as the
|
|
// sketch path, which learned it the hard way.
|
|
CallAfter([this, screen] { show_offer_menu(screen); });
|
|
});
|
|
|
|
// --- 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, "");
|
|
m_status->Hide(); // storage only — the line is drawn over the viewport, see set_status()
|
|
m_status_default_fg = m_status->GetForegroundColour(); // capture BEFORE any caller writes
|
|
root->Add(m_status, 0, wxLEFT | wxRIGHT | wxBOTTOM, 12);
|
|
|
|
// DoF / constraint-state readout (P3). Dedicated line so it never clobbers the
|
|
// 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_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);
|
|
set_status(_L("Sketch created — select it, then right-click to 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));
|
|
set_status(_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);
|
|
set_status(_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);
|
|
set_status(cons.empty()
|
|
? _L("Sketch created — select it, then right-click to Extrude")
|
|
: wxString::Format(_L("Sketch created (%zu driving dims) — select it, then right-click to Extrude"),
|
|
cons.size()));
|
|
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);
|
|
// APPENDED to the step guidance, never in place of it. This fires on every mouse move
|
|
// while a segment is being dragged, so replacing the line wiped the instruction for the
|
|
// step the user is in the middle of — one mouse move after the click that armed it.
|
|
const wxString metrics = wxString::Format(L"L %.2f mm %.1f°%s",
|
|
len, a, locked ? L" (locked)" : L"");
|
|
set_status(m_sketch_step.IsEmpty() ? metrics
|
|
: m_sketch_step + L" · " + metrics);
|
|
m_status->Refresh();
|
|
});
|
|
|
|
// Live per-step guidance: the armed tool says which step it is on, we write the sentence.
|
|
m_viewport->set_on_sketch_step([this](DesignSketchTool::Mode mode, int step, int picks) {
|
|
on_sketch_step(int(mode), step, picks);
|
|
});
|
|
|
|
// 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) {
|
|
// Remembered as well as shown: the readout is fed ONLY by a live solve, and entering
|
|
// Constrain mode triggers none — so without a cache the very line the user goes to
|
|
// Constrain to read stays blank until they happen to change something.
|
|
m_dof_last = dof; m_dof_last_ok = ok; m_dof_last_has = has_constraints;
|
|
apply_dof_status(dof, ok, has_constraints);
|
|
});
|
|
|
|
// Selection no longer writes the status line: on_sketch_step owns it, says the same thing
|
|
// for Select mode and — unlike this callback, which also fired while an edit-op mirrored its
|
|
// picks into the selection — never claims "N selected, Delete removes them" in the middle of
|
|
// a Mirror gesture, where Delete does nothing of the sort. snaporca-1c0c.
|
|
|
|
// 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](int region) {
|
|
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));
|
|
set_status(_L("Could not resolve the sketch to extrude"));
|
|
m_status->Refresh();
|
|
return;
|
|
}
|
|
set_ui_mode(UiMode::Feature);
|
|
open_tool(Tool::Extrude);
|
|
// AFTER open_tool, not before: opening the tool re-derives the selection state, so a
|
|
// region recorded ahead of it is wiped before Extrude ever reads it.
|
|
m_sel_sketch_feat = m_extrude_sketch_ref;
|
|
m_sel_sketch_region = region;
|
|
m_sel_solid_face = m_sel_solid_edge = -1;
|
|
m_pick_face = m_pick_face_body = -1;
|
|
m_viewport->set_loop_pick(m_extrude_sketch_ref, region);
|
|
m_status->SetForegroundColour(wxNullColour);
|
|
set_status(_L("Face selected — set the depth and Confirm"));
|
|
m_status->Refresh();
|
|
});
|
|
|
|
// 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, int entity) {
|
|
if (feat < 0 || feat >= int(m_doc.features.size())) return;
|
|
m_sel_sketch_feat = feat;
|
|
m_sel_sketch_region = region;
|
|
// Last pick wins (symmetric with the solid-pick handler): selecting a sketch loop drops
|
|
// any stale solid face/edge pick so Extrude treats this loop as the profile.
|
|
m_sel_solid_face = m_sel_solid_edge = -1;
|
|
m_pick_face = m_pick_face_body = -1;
|
|
// Rib card open: point at the LINE, do not type its index. 'Entity index' was a bare
|
|
// wxSpinCtrl ranged 0-999 standing in for a line sitting visible on screen, with nothing
|
|
// anywhere telling you which integer was which — the purest case of the thing this epic
|
|
// exists to remove. Only a stroke hit carries an entity (an interior click is a region,
|
|
// not a line), so a click inside a loop deliberately leaves the field alone rather than
|
|
// resetting it to something arbitrary. The sketch picker follows the same pick, so
|
|
// pointing at a line in a different sketch retargets both together. snaporca-3648.
|
|
if (m_active == Tool::Rib && entity >= 0) {
|
|
if (m_rib_sketch != nullptr)
|
|
for (unsigned i = 0; i < m_rib_sketch->GetCount(); ++i)
|
|
if (int(reinterpret_cast<intptr_t>(m_rib_sketch->GetClientData(i))) == feat) {
|
|
m_rib_sketch->SetSelection(i);
|
|
break;
|
|
}
|
|
if (m_rib_entity != nullptr) m_rib_entity->SetValue(entity);
|
|
refresh_preview();
|
|
}
|
|
// Sweep card open: the sketch you point at becomes the PATH. The profile is already
|
|
// settled selection-first — you pick a sketch and then invoke Sweep, which is the design
|
|
// law's own canonical example — so the path is the input that was still trapped in a
|
|
// combo. The picker excludes the profile, so pointing at the profile correctly does
|
|
// nothing. e1p item 6; same shape as the Boolean and Mirror fixes.
|
|
if (m_active == Tool::Sweep && m_sweep_path != nullptr) {
|
|
for (unsigned i = 0; i < m_sweep_path->GetCount(); ++i) {
|
|
if (int(reinterpret_cast<intptr_t>(m_sweep_path->GetClientData(i))) != feat) continue;
|
|
m_sweep_path->SetSelection(i);
|
|
m_sweep_path_ref = feat;
|
|
refresh_preview();
|
|
break;
|
|
}
|
|
}
|
|
// Loft card open: pointing at a sketch TOGGLES its membership, so a loft is built by
|
|
// clicking the profiles in the viewport instead of hunting rows in a check-list. The
|
|
// list stays visible as the typed half and still shows the order, which matters here —
|
|
// loft order is not commutative.
|
|
if (m_active == Tool::Loft && m_loft_list != nullptr) {
|
|
for (size_t i = 0; i < m_loft_sketch_idx.size() && i < m_loft_list->GetCount(); ++i) {
|
|
if (m_loft_sketch_idx[i] != feat) continue;
|
|
m_loft_list->Check(unsigned(i), !m_loft_list->IsChecked(unsigned(i)));
|
|
refresh_preview();
|
|
break;
|
|
}
|
|
}
|
|
set_tree_selection(feat);
|
|
m_status->SetForegroundColour(wxNullColour);
|
|
set_status(region >= 0
|
|
// "Region", not "Loop": what is selected — and what Extrude will consume — is the
|
|
// bounded area including any holes in it, not a single closed curve.
|
|
? _L("Region selected — right-click to Extrude, or double-click to edit")
|
|
: _L("Sketch selected — right-click to Extrude, or double-click to edit"));
|
|
m_status->Refresh();
|
|
});
|
|
|
|
// Double-click a committed sketch stroke: open THAT sketch for editing, where its entities
|
|
// are individually selectable and their quotes editable. on_edit_feature already does the
|
|
// whole job — it was only ever reachable from the tree, which is precisely the side-panel
|
|
// dependency being retired. The pick has already lit the right tree row by the time the
|
|
// double-click arrives, but set it again so the path does not depend on that ordering.
|
|
m_viewport->set_on_display_sketch_activated([this](int feat) {
|
|
if (feat < 0 || feat >= int(m_doc.features.size())) return;
|
|
set_tree_selection(feat);
|
|
on_edit_feature();
|
|
});
|
|
|
|
// 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; // 4 = Vertex: a corner is not its face
|
|
m_sel_solid_edge = (level == 3) ? edge : -1;
|
|
m_sel_solid_vertex = (level == 4);
|
|
// Keep the hit face even at whole-body level: the cycle's first click means "this body",
|
|
// but the user pointed AT a face and a sketch should be able to use it. snaporca-3a2.
|
|
m_pick_face_body = (level >= 1) ? body : -1;
|
|
m_pick_face = (level >= 1) ? face : -1;
|
|
// Last pick wins: selecting a solid drops any stale committed-sketch loop selection.
|
|
// Otherwise a leftover loop keeps `m_sel_sketch_region >= 0`, which blocks the face
|
|
// push/pull branch in Extrude (`m_sel_solid_face >= 0 && m_sel_sketch_region < 0`) and
|
|
// makes Extrude build a DETACHED new body from the last sketch instead of push/pulling
|
|
// the face the user just clicked.
|
|
if (level >= 1) { m_sel_sketch_region = -1; m_sel_sketch_feat = -1; }
|
|
// 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. The label itself is written
|
|
// ONCE, at the end of this handler — a second writer here only ever produced text that
|
|
// the later one overwrote before a frame was drawn, and reading it as the live string
|
|
// is how a vertex pick came to be "fixed" in a branch that never reaches the screen.
|
|
// 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) { sync_dressup_target(); update_fillet_gizmo(); refresh_preview(); }
|
|
// Boolean card open: the VIEWPORT is how you choose the two operands. Until now they
|
|
// could only come from two combos — the one control the charter names for this tool
|
|
// (e1p item 4), and the same pair snaporca-7xx caught silently resolving every row to
|
|
// index 0. The highlight already flowed card -> viewport; this closes the loop the
|
|
// other way. First pick is the target (kept), second is the tool (consumed); the
|
|
// combos mirror both, so the typed half of L2 still works and still round-trips.
|
|
// A body cannot be both operands — that boolean is a no-op — but the resolution is to
|
|
// SWAP, never to refuse: the pick is the user pointing at a body, and it has to win.
|
|
// Refusing it was measurably wrong. Both combos are pre-filled when the card opens
|
|
// (target = body 0, tool = a different one), so the very first viewport pick usually
|
|
// names a body the other slot already holds; ignoring it left the defaults in place and
|
|
// the commit produced body0 - body1 (75000 mm3) when the picks asked for body1 - body0
|
|
// (43000 mm3). Swapping keeps the pair distinct AND honours the click.
|
|
if (m_active == Tool::Boolean && m_sel_solid_body >= 0) {
|
|
ComboBox* dst = (m_bool_next_slot == 0) ? m_bool_target : m_bool_tool;
|
|
ComboBox* other = (m_bool_next_slot == 0) ? m_bool_tool : m_bool_target;
|
|
const int row = m_sel_solid_body; // selection index == body index
|
|
if (dst != nullptr && row < int(dst->GetCount())) {
|
|
const int prev = dst->GetSelection();
|
|
dst->SetSelection(row);
|
|
if (other != nullptr && other->GetSelection() == row && prev >= 0 && prev != row)
|
|
other->SetSelection(prev); // displaced operand takes the old slot
|
|
m_bool_next_slot ^= 1;
|
|
refresh_preview(); // re-tints the operands + rebuilds the ghost
|
|
}
|
|
}
|
|
// Mirror card open: the body you point at is the body that gets mirrored. Same one-way
|
|
// flow Boolean had (snaporca-310o) and the same fix — the combo stays as the typed half.
|
|
// Only one operand here, so there is no slot to alternate and no swap to do.
|
|
if (m_active == Tool::Mirror && m_sel_solid_body >= 0 && m_mirror_body != nullptr &&
|
|
m_sel_solid_body < int(m_mirror_body->GetCount())) {
|
|
m_mirror_body->SetSelection(m_sel_solid_body); // selection index == body index
|
|
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
|
|
}
|
|
// Thicken card open: the face pick IS the feature's input, and until now nothing here
|
|
// wrote its label — the value reached m_sel_solid_face and Confirm worked, but the card
|
|
// read "(pick a solid face)" however many faces you had picked. Invisible because the
|
|
// ghost did update, so the card looked wrong while behaving right.
|
|
if (m_active == Tool::Thicken) {
|
|
m_thicken_face_label->SetLabel(m_sel_solid_face >= 0
|
|
? wxString::Format(_L("Face %d"), m_sel_solid_face)
|
|
: _L("(pick a solid face)"));
|
|
refresh_preview();
|
|
}
|
|
// 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;
|
|
set_hole_target_label(face);
|
|
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));
|
|
const bool from_face = cf.ok; // which of the two the cylinder actually came from
|
|
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;
|
|
set_thread_target_label(from_face ? face : -1, from_face ? -1 : edge);
|
|
infer_thread_spec(2.0 * cf.radius); // M diameter + pitch + depth from the cylinder
|
|
if (m_thread_height && cf.height > 1e-6) m_thread_height->SetValue(cf.height);
|
|
if (m_thread_internal) m_thread_internal->SetValue(cf.internal);
|
|
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(); }
|
|
if (got && m_viewport) m_viewport->set_escalate_on_repick(true);
|
|
}
|
|
// 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(); }
|
|
if (got && m_viewport) m_viewport->set_escalate_on_repick(true);
|
|
}
|
|
// 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) {
|
|
// A picked face or edge is only meaningful to FaceAndDirection. Left on the
|
|
// default Point (world), datum_frame ignores the pick entirely and resolves the
|
|
// connector to coordsys_point — which is (0,0,0) unless the user typed
|
|
// otherwise. Two such connectors then share one frame, so a mate between them
|
|
// computes an IDENTITY transform: the feature commits, the recompute succeeds,
|
|
// and nothing moves. Picking a face IS the choice of a face-based frame, so make
|
|
// the type follow the pick rather than asking for it twice.
|
|
if (m_coordsys_type && m_coordsys_type->GetSelection() != (int)CoordSysType::FaceAndDirection)
|
|
m_coordsys_type->SetSelection((int)CoordSysType::FaceAndDirection);
|
|
m_coordsys_pick = CoordSysPick::None;
|
|
refresh_coordsys_labels();
|
|
}
|
|
if (got && m_viewport) m_viewport->set_escalate_on_repick(true);
|
|
}
|
|
m_status->SetForegroundColour(wxNullColour);
|
|
const int nb = int(m_doc.bodies.size());
|
|
const wxString bodytag = (nb > 1) ? wxString::Format(_L("Body %d "), body + 1) : wxString();
|
|
// Each sub-element line ends by naming the NEXT click (snaporca-gem). Escalation to the
|
|
// whole body is a gesture nothing on screen would otherwise reveal, and the status line
|
|
// is the only surface that can teach it at the moment it applies. It REPLACES the old
|
|
// per-level verb hints ("right-click to push/pull it", "Fillet/Chamfer to dress it")
|
|
// rather than joining them: the line is clipped at the panel edge past ~55 characters
|
|
// (set_status's Wrap() does not take effect — snaporca-8cc), and those verbs are shown
|
|
// with their icons in the offer anyway, while this gesture is shown nowhere else.
|
|
// Both clauses fit now that the line is drawn over the viewport instead of squeezed
|
|
// into the panel. Say "what applies to it", never "verbs" — that is this codebase's
|
|
// word for a tool-offer entry, not a word the drawing office uses, and the plane and
|
|
// bodies-list lines already say it the right way.
|
|
set_status(level == 4 ? bodytag + _L("vertex selected — click again for the whole body")
|
|
: level == 1 ? bodytag + _L("selected (whole body) — right-click for what applies to it")
|
|
: level == 2 ? bodytag + wxString::Format(_L("face %d selected — right-click to push/pull it, or click again for the whole body"), face)
|
|
: level == 3 ? bodytag + wxString::Format(_L("edge %d selected — Fillet/Chamfer to dress it, or click again for the whole body"), edge)
|
|
: _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) {
|
|
// One arrow, three tools: Extrude owns the two-sided pair, the other two have a single
|
|
// distance each. Routing here rather than arming three near-identical gizmos is the whole
|
|
// reason these two tools got a handle at all — the arrow was already written.
|
|
if (m_active == Tool::SurfaceExtrude) {
|
|
if (m_surf_extrude_distance) m_surf_extrude_distance->SetValue(depth);
|
|
} else if (m_active == Tool::Thicken) {
|
|
if (m_thicken_thickness) m_thicken_thickness->SetValue(depth);
|
|
} else if (m_active == Tool::Rib) {
|
|
if (m_rib_depth) m_rib_depth->SetValue(depth); // depth; thickness has its own in-plane pair
|
|
} else if (m_active == Tool::SurfaceOffset) {
|
|
if (m_surf_offset_distance) m_surf_offset_distance->SetValue(depth);
|
|
} else if (m_active == Tool::ThickenSurface) {
|
|
if (m_surf_thicken_thickness) m_surf_thicken_thickness->SetValue(depth);
|
|
} else 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();
|
|
});
|
|
|
|
// Helix handles: a drag reports the whole triple, since pitch and height are coupled
|
|
// through the turn count and reading one without the others would show a stale curve.
|
|
m_viewport->set_on_helix_changed([this](double radius, double pitch, double height) {
|
|
if (m_helix_radius) m_helix_radius->SetValue(radius);
|
|
if (m_helix_pitch) m_helix_pitch->SetValue(pitch);
|
|
if (m_helix_height) m_helix_height->SetValue(height);
|
|
update_helix_gizmo(); // re-feed so the curve follows the drag
|
|
m_viewport->request_repaint();
|
|
});
|
|
|
|
// Rib thickness handle: writes the spin and re-feeds the ghost, same as the depth arrow.
|
|
m_viewport->set_on_rib_thickness_changed([this](double thickness) {
|
|
if (m_rib_thickness) m_rib_thickness->SetValue(thickness);
|
|
refresh_preview(); // Rib HAS a solid ghost, so the full preview path is correct here
|
|
});
|
|
|
|
// 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 {
|
|
// Clicking a reference plane in 3D IS how a sketch plane is chosen now. Record it and,
|
|
// when a session is already live, re-plane it immediately: begin_sketch captures the
|
|
// plane at first-tool-pick, so without this the entities would stay on the old plane
|
|
// while the committed feature landed on the new one.
|
|
if (base >= 0) {
|
|
m_ref_plane = base;
|
|
m_plane_picked = true; // chosen, not merely defaulted to
|
|
m_pick_face = m_pick_face_body = -1; // last pick wins: a plane beats a stale face
|
|
if (m_viewport && m_viewport->is_sketching())
|
|
m_viewport->set_sketch_plane(plane_from_choice(m_ref_plane));
|
|
}
|
|
const char* nm = (base == 0) ? "XY" : (base == 1) ? "XZ" : (base == 2) ? "YZ" : "datum";
|
|
m_status->SetForegroundColour(wxColour(120, 210, 120));
|
|
// Both halves named the TOOLBAR, which no longer carries either button: the tools
|
|
// moved to the offer. Name the gesture that actually works in each mode, and say
|
|
// what a plain click does, since the two are easy to confuse on a plane.
|
|
set_status(m_ui_mode == UiMode::Sketch
|
|
? wxString::Format(_L("%s plane selected — right-click for the drawing tools"), nm)
|
|
: wxString::Format(_L("%s plane selected — right-click to sketch on it, "
|
|
"or click an object to select it"), nm));
|
|
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
|
|
// When the move gizmo is serving the Transform card, mirror the drag into the card's
|
|
// numeric fields (the card is the typed half, the gizmo the geometry-first half).
|
|
if (body == m_xf_gizmo_body && m_active == Tool::Transform) {
|
|
const Transform3d d = xform * m_xf_gizmo_base.inverse();
|
|
const Vec3d t = d.translation();
|
|
if (m_xf_dx) m_xf_dx->SetValue(t.x());
|
|
if (m_xf_dy) m_xf_dy->SetValue(t.y());
|
|
if (m_xf_dz) m_xf_dz->SetValue(t.z());
|
|
const Eigen::AngleAxisd aa(Eigen::Quaterniond(d.linear()).normalized());
|
|
// The gizmo's rings are per-world-axis, so a ring drag yields an exactly axial
|
|
// rotation and this is lossless for the normal interaction. A composed multi-ring
|
|
// pose is not axial; the card can only express one axis, so report the dominant one
|
|
// rather than refusing to answer.
|
|
int ax = 0; double best = std::abs(aa.axis().x());
|
|
if (std::abs(aa.axis().y()) > best) { ax = 1; best = std::abs(aa.axis().y()); }
|
|
if (std::abs(aa.axis().z()) > best) { ax = 2; best = std::abs(aa.axis().z()); }
|
|
const double sign = (aa.axis()[ax] < 0.0) ? -1.0 : 1.0;
|
|
if (m_xf_axis) m_xf_axis->SetSelection(ax);
|
|
if (m_xf_angle) m_xf_angle->SetValue(sign * aa.angle() * 180.0 / M_PI);
|
|
}
|
|
const int nb = int(m_doc.bodies.size());
|
|
const wxString tag = (nb > 1) ? wxString::Format(_L("Body %d "), body + 1) : wxString();
|
|
const Vec3d t = xform.translation();
|
|
m_status->SetForegroundColour(wxNullColour);
|
|
set_status(tag + wxString::Format(_L("placed (%.1f, %.1f, %.1f) mm — drag arrows to move, rings to rotate"),
|
|
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);
|
|
set_status(_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();
|
|
// Which key MAP applies is a question about the MODE, not about whether a session is
|
|
// already running. Gating the sketch map on is_sketching() made it unreachable by
|
|
// keyboard: is_sketching() only turns true inside select_tool()'s begin_sketch(), and
|
|
// select_tool() is what the sketch keys call — so the first letter after entering
|
|
// sketch mode fell through to the feature map, matched nothing (feature keys are
|
|
// Shift+letter), and did nothing. The mouse worked only because the toolbar flyout
|
|
// reaches select_tool() directly. That is why all 17 keys read as dead. snaporca-0ud.
|
|
const bool sketch_mode = (m_ui_mode == UiMode::Sketch);
|
|
// Never steal editing keys from a focused text field or an open in-canvas value field —
|
|
// Delete/Ctrl+Z there must edit the text, not the model.
|
|
const bool in_text = (dynamic_cast<wxTextCtrl*>(wxWindow::FindFocus()) != nullptr)
|
|
|| (m_viewport && m_viewport->inline_busy());
|
|
if (getenv("SNAPORCA_KEYTRACE")) {
|
|
wxWindow* fw = wxWindow::FindFocus();
|
|
fprintf(stderr, "[KEYTRACE] key=%d ui_mode=%d is_sketching=%d in_text=%d inline_busy=%d focus=%s\n",
|
|
key, int(m_ui_mode), (m_viewport && m_viewport->is_sketching()) ? 1 : 0, in_text ? 1 : 0,
|
|
(m_viewport && m_viewport->inline_busy()) ? 1 : 0,
|
|
fw ? (const char*) fw->GetClassInfo()->GetClassName() : "(none)");
|
|
fflush(stderr);
|
|
}
|
|
|
|
// NOTE the position: this sits AFTER the KEYTRACE block on purpose. It returns early,
|
|
// and putting it first made every forwarded key invisible to the tracer — the one
|
|
// instrument that diagnosed this bug in the first place.
|
|
// The in-canvas value field is a borderless, always-on-top top-level frame, so whether it
|
|
// may take keyboard focus is the platform's decision, not ours: macOS denies key status to a
|
|
// borderless window, mutter's focus-stealing prevention refuses a re-mapped window, and the
|
|
// rig never grants it. When focus is refused, Enter/Esc/Tab are delivered HERE (to the panel)
|
|
// instead of the field, its own wxEVT_TEXT_ENTER / WXK_ESCAPE bindings never fire, and the
|
|
// queued-dimension chain (a line queues Length then Angle) becomes unwalkable. Forwarding them
|
|
// makes the field behave the same everywhere WITHOUT fighting the window manager for focus,
|
|
// which is what seven earlier attempts did unsuccessfully. When the field DOES hold focus we
|
|
// deliberately do nothing here, so its own bindings run and typing keeps working.
|
|
if (m_viewport && m_viewport->inline_busy() && !m_viewport->inline_has_focus()) {
|
|
if (key == WXK_RETURN || key == WXK_NUMPAD_ENTER || key == WXK_TAB) {
|
|
m_viewport->inline_commit();
|
|
return;
|
|
}
|
|
if (key == WXK_ESCAPE) {
|
|
m_viewport->inline_cancel();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Esc must exit the sketch wherever focus happens to be. Which widget holds focus is an
|
|
// accident of where the user last clicked (a toolbar button, the Construction checkbox),
|
|
// and Esc must not depend on it — request_exit() is the layered behaviour the GL-canvas
|
|
// path already uses, so Esc means the same thing here as it does over the viewport.
|
|
// The predicate is the SESSION, not the armed tool. is_sketching() is
|
|
// DesignSketchTool::is_active(), true only while a draw tool is armed, and the state you
|
|
// are left in after committing an entity is ui_mode=Sketch with no tool armed -- so this
|
|
// branch used to be skipped exactly when a user reaches for Esc, and the Cancel button
|
|
// was the only way out ("Esc hardly ever works", exussum12 on PR #15238). Same mistake as
|
|
// snaporca-0ud, which gated the sketch key MAP on is_sketching() thirty lines above.
|
|
// request_exit() is layered and already handles the idle case, so widening the gate
|
|
// costs nothing: in-progress entity -> drop to Select -> exit the session.
|
|
if (key == WXK_ESCAPE && m_viewport
|
|
&& (m_ui_mode == UiMode::Sketch || m_viewport->is_sketching())) {
|
|
m_viewport->request_sketch_exit();
|
|
return;
|
|
}
|
|
|
|
const bool dismissable = m_active != Tool::None || (m_viewport && m_viewport->moving_body());
|
|
if (key == WXK_ESCAPE && dismissable) { tool_cancel(); return; }
|
|
|
|
// The offer from the keyboard (charter 4.1): the Menu key, or Shift+F10 for keyboards that
|
|
// do not have one. Same menu the right-click opens — show_offer_menu already decides which
|
|
// half of the map applies via sketch_map_applies(), so nothing about the content is decided
|
|
// here. With no press to anchor it, offer_anchor() puts it on the viewport.
|
|
// WXK_MENU is the GTK code for the physical Menu key (GDK_KEY_Menu); wxMSW instead sends
|
|
// WXK_WINDOWS_MENU for VK_APPS and maps Alt to WXK_ALT, so accepting both is safe
|
|
// everywhere. CallAfter because the menu is modal: let the key event finish dispatching
|
|
// before a nested loop takes the queue, the same reason the Sketch entry does it.
|
|
if (!in_text && !ctrl
|
|
&& (key == WXK_MENU || key == WXK_WINDOWS_MENU || (key == WXK_F10 && e.ShiftDown()))) {
|
|
CallAfter([this] { show_offer_menu(offer_anchor()); });
|
|
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.
|
|
// WXK_BACK too: on a keyboard whose Del is a chord (every laptop this runs on), Del is
|
|
// the one destructive key nobody can reach, and Backspace is what users press. snaporca-oql1.
|
|
if (!in_text && (key == WXK_DELETE || (key == WXK_BACK && sketching))) {
|
|
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 && !sketch_mode && 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.
|
|
// A SKETCH SHORTCUT MAY LEAVE AN OPEN VALUE FIELD. in_text is true while an inline
|
|
// dimension editor is up, and drawing a rectangle opens one automatically for its
|
|
// Width/Height — so after a rectangle every single-letter tool key was swallowed and the
|
|
// sketch could not be continued at all. A value field holds a NUMBER; a letter is never
|
|
// meant for it, so a letter that names a sketch tool is unambiguously a tool switch.
|
|
// set_tool() commits the field on the way through, so the typed value is kept.
|
|
// Deliberately narrow: sketch mode only, only keys that are actually bound, and only the
|
|
// in-canvas field — a focused wxTextCtrl elsewhere (the variables table, a card spin)
|
|
// still keeps every key.
|
|
const bool inline_field_only = (dynamic_cast<wxTextCtrl*>(wxWindow::FindFocus()) == nullptr)
|
|
&& m_viewport && m_viewport->inline_busy();
|
|
if (in_text && inline_field_only && sketch_mode && !ctrl) {
|
|
const int up2 = (key >= 'a' && key <= 'z') ? key - 'a' + 'A' : key;
|
|
auto it2 = m_keys_sketch.find(up2);
|
|
if (it2 != m_keys_sketch.end()) { it2->second(); return; }
|
|
}
|
|
// Ctrl+Shift first: the block below deliberately ignores every Ctrl-combo, which is
|
|
// exactly what makes this layer free to use.
|
|
if (!in_text && ctrl && e.ShiftDown()) {
|
|
const int up = (key >= 'a' && key <= 'z') ? key - 'a' + 'A' : key;
|
|
auto it = m_keys_feature.find(up | SC_SHIFT | SC_CTRL);
|
|
if (it != m_keys_feature.end()) { it->second(); return; }
|
|
}
|
|
if (!in_text && !ctrl) {
|
|
const int up = (key >= 'a' && key <= 'z') ? key - 'a' + 'A' : key; // normalise case
|
|
if (sketch_mode) {
|
|
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 offer (§4.1): right-click the geometry, get the verbs that apply to it. Left-click
|
|
// still only selects, so pointing at things stays quiet.
|
|
m_viewport->set_on_context_menu([this](const wxPoint& p) { show_offer_menu(p); });
|
|
|
|
// 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);
|
|
|
|
// The atlas says a verb is wired; the registrations above say what it runs. Nothing checks
|
|
// that the two agree, and a broken join is INVISIBLE — the row renders, is enabled, and does
|
|
// nothing when picked. That defect has shipped three times (edit_feature and sk_move carrying
|
|
// action:null, then a whole flyout family the moment its widget stopped being built), and it
|
|
// cannot be seen by reading either side alone. Verify the join once, here, where every
|
|
// registration is complete. Cheap: 86 map lookups, once per panel.
|
|
{
|
|
std::vector<std::string> dead;
|
|
for (int i = 0; i < kOfferVerbCount; ++i) {
|
|
const OfferVerb& v = kOfferVerbs[i];
|
|
if (v.action == nullptr || *v.action == '\0')
|
|
continue; // kernel support, no GUI path — the row shows disabled
|
|
const std::string a(v.action);
|
|
bool ok = false;
|
|
if (a.rfind("key:", 0) == 0) { // resolved exactly as run_offer_action() resolves it
|
|
const std::string k = a.substr(4);
|
|
if (k.size() >= 3 && k[0] == 'S' && k[1] == '+') {
|
|
auto it = m_keys_feature.find(int(k[2]) | SC_SHIFT);
|
|
ok = it != m_keys_feature.end() && bool(it->second);
|
|
} else if (!k.empty()) {
|
|
auto it = m_keys_sketch.find(int(k[0]));
|
|
ok = it != m_keys_sketch.end() && bool(it->second);
|
|
}
|
|
} else {
|
|
auto it = m_verb_actions.find(a);
|
|
ok = it != m_verb_actions.end() && bool(it->second);
|
|
}
|
|
if (!ok)
|
|
dead.emplace_back(std::string(v.id) + " -> " + a);
|
|
}
|
|
for (const std::string& d : dead)
|
|
BOOST_LOG_TRIVIAL(error) << "Design offer: wired verb has no action: " << d;
|
|
assert(dead.empty()); // debug builds stop here; release ships the log line
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
// Paint the DoF line. Split out of the solve callback so entering Constrain can re-apply the
|
|
// last known state — see m_dof_last.
|
|
void DesignPanel::apply_dof_status(int dof, bool ok, bool has_constraints)
|
|
{
|
|
if (!m_dof_status) return;
|
|
if (!has_constraints) {
|
|
m_dof_status->SetLabel(wxString());
|
|
} else if (!ok) {
|
|
m_dof_status->SetForegroundColour(wxColour(235, 80, 80));
|
|
m_dof_status->SetLabel(_L("✗ Conflicting constraints"));
|
|
} else if (dof == 0) {
|
|
m_dof_status->SetForegroundColour(wxColour(80, 200, 110));
|
|
m_dof_status->SetLabel(_L("✓ Fully constrained"));
|
|
} else if (dof > 0) {
|
|
m_dof_status->SetForegroundColour(dp_ctl_text());
|
|
m_dof_status->SetLabel(wxString::Format(_L("%d degrees of freedom"), dof));
|
|
} else {
|
|
m_dof_status->SetLabel(wxString());
|
|
}
|
|
m_dof_status->Show(!m_dof_status->GetLabel().IsEmpty());
|
|
m_dof_status->Refresh();
|
|
update_cards_frame(); m_form->Layout();
|
|
}
|
|
|
|
void DesignPanel::set_ui_mode(UiMode m)
|
|
{
|
|
m_ui_mode = m;
|
|
if (m != UiMode::Sketch) m_sketch_on.clear(); // no stale "on the picked face" on the next hint
|
|
// The DoF readout describes a SKETCH's constraint state, so it means nothing back in Feature
|
|
// mode — where it nonetheless stayed on screen after every Confirm, Cancel and Escape
|
|
// (snaporca-752). Cleared here rather than at those three exits because this is the one place
|
|
// all of them pass through, and a fourth exit added later would otherwise reintroduce it.
|
|
// Constrain mode keeps it: that is where the number is the whole point.
|
|
if (m == UiMode::Feature && m_dof_status != nullptr) {
|
|
m_dof_status->SetLabel(wxString());
|
|
m_dof_status->Show(false);
|
|
}
|
|
// Constrain is where the number is the whole point, and clearing it on the way out of
|
|
// Sketch left it blank on the way in — no solve fires merely because the mode changed.
|
|
if (m == UiMode::Constrain)
|
|
apply_dof_status(m_dof_last, m_dof_last_ok, m_dof_last_has);
|
|
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
|
|
// The origin planes follow the mode: entering Sketch offers them even when a body exists,
|
|
// leaving it takes them back. Without this they would only refresh on the next tree
|
|
// rebuild, which is not an event that happens when you merely press Sketch.
|
|
update_reference_planes();
|
|
}
|
|
|
|
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()
|
|
{
|
|
set_status(wxString::Format(_L("OK — %zu triangles"),
|
|
m_doc.display_mesh.its.indices.size()));
|
|
if (m_viewport != nullptr) {
|
|
m_viewport->clear_move_gizmo(); // a recompute invalidates the gizmo's body centroid
|
|
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_with_holes(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("Design"), 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.
|
|
// Keep the Model's copy of the recipe in step with the document.
|
|
//
|
|
// This used to be written in exactly ONE place — on_commit(), as a side effect of Commit to
|
|
// Plate — so a user who modelled for an hour and pressed Ctrl+S saved a project containing no
|
|
// feature history at all, and the app reported success (snaporca-vjk5). The 3MF exporter was
|
|
// never at fault: nothing had handed it a recipe.
|
|
//
|
|
// Every save path (Ctrl+S, Save As, autosave, crash recovery) reads model.cad_recipe, so
|
|
// keeping it current after each document change is what makes all of them correct at once,
|
|
// rather than teaching each one to ask the Design tab. Commit to Plate means "send this to the
|
|
// slicer"; making saving depend on it was the bug, not the cure.
|
|
//
|
|
// Cost is a serialization per recompute — tens of KB against an OCCT rebuild that just ran.
|
|
void DesignPanel::sync_recipe_to_model()
|
|
{
|
|
Plater* plater = wxGetApp().plater();
|
|
if (plater == nullptr) return;
|
|
// An empty document CLEARS it, so a non-CAD project never carries a stale recipe.
|
|
std::string recipe = m_doc.features.empty() ? std::string() : m_doc.serialize_recipe();
|
|
if (recipe == plater->model().cad_recipe)
|
|
return; // no change — this is the rehydrate of a project that was just opened
|
|
plater->model().cad_recipe = std::move(recipe);
|
|
|
|
// The plater's dirty flag rides its own undo/redo stack, which Design edits never touch, and
|
|
// a design that has not been committed to the plate has no ModelObjects either — so without
|
|
// this the project reads as clean: no autosave, and no "unsaved changes" prompt on quit.
|
|
// Same hook the auxiliary-files panel uses for project data that lives outside the model.
|
|
Slic3r::put_other_changes();
|
|
}
|
|
|
|
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;
|
|
}
|
|
});
|
|
// Only on success: a failed recompute leaves the document mid-edit, and persisting that
|
|
// would save a model the user never had.
|
|
if (ok) sync_recipe_to_model();
|
|
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));
|
|
set_status(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));
|
|
set_status(_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);
|
|
set_status(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));
|
|
set_status(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);
|
|
set_status(wxString::Format(
|
|
_L("Imported solid — %d triangles → %d faces, volume %.2f mm³. Pick a face or edge, "
|
|
"then Fillet / Cut / Shell to modify"),
|
|
stats.kept_tris, stats.faces_final, stats.volume));
|
|
} else {
|
|
m_status->SetForegroundColour(wxColour(220, 160, 60)); // warning, not an error
|
|
set_status(wxString::Format(
|
|
_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));
|
|
set_status(_L("No importable geometry found"));
|
|
m_status->Refresh();
|
|
return;
|
|
}
|
|
// DRAWING, not importing: if a sketch is open, the art belongs IN it. The outlines become
|
|
// ordinary line entities, so they can be constrained, trimmed and extruded with everything
|
|
// else on that plane. Committing a separate Sketch feature while the user is mid-sketch put
|
|
// the text on its own plane-origin feature and left the sketch they were drawing untouched.
|
|
if (m_viewport && m_viewport->is_sketching() && m_viewport->add_sketch_regions(regions)) {
|
|
m_status->SetForegroundColour(wxNullColour);
|
|
set_status(wxString::Format(_L("%s added to the sketch — Confirm to commit it"),
|
|
base_name));
|
|
m_status->Refresh();
|
|
return;
|
|
}
|
|
m_doc.checkpoint(); // undo boundary: importing Text/SVG art
|
|
m_feature_counter++;
|
|
CadFeature f;
|
|
f.type = CadFeatureType::Sketch;
|
|
f.name = std::string(base_name.ToUTF8().data()) + std::to_string(m_feature_counter);
|
|
f.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) {
|
|
f.plane = plane_from_choice(m_ref_plane);
|
|
}
|
|
|
|
// 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);
|
|
set_status(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);
|
|
set_status(_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);
|
|
set_status(_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;
|
|
wxString where; // named for the status line
|
|
SketchPlane plane = sketch_plane_from_selection(where); // picked face, else the 3D plane click
|
|
m_feature_counter++;
|
|
m_doc.add_sketch(shape, plane, m_width->GetValue(), m_height->GetValue(),
|
|
m_radius->GetValue(), "Sketch" + std::to_string(m_feature_counter));
|
|
m_doc.recompute(); // a lone sketch yields an empty body; that is expected
|
|
m_status->SetForegroundColour(wxNullColour);
|
|
set_status(wxString::Format(_L("Sketch added on %s — select it, then right-click to Extrude"), where));
|
|
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.
|
|
if (::getenv("SNAPORCA_PICK_TRACE"))
|
|
std::fprintf(stderr, "[pick] on_add_extrude: feat=%d reg=%d ents=%zu\n",
|
|
m_extrude_sketch_ref, m_sel_sketch_region,
|
|
m_viewport->selected_loop_entities().size());
|
|
idx = m_doc.add_extrude_entities(m_viewport->selected_loop_entities(),
|
|
m_doc.features[m_extrude_sketch_ref].plane,
|
|
m_distance->GetValue(), false, mode, name);
|
|
m_sel_sketch_region = -1; // consume the loop selection
|
|
m_viewport->clear_loop_pick(); // drop the now-stale loop highlight
|
|
} 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…")))
|
|
set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error));
|
|
else
|
|
set_status_ok();
|
|
refresh_tree();
|
|
}
|
|
|
|
void DesignPanel::on_add_dressup()
|
|
{
|
|
if (m_doc.body.IsNull()) {
|
|
set_status(_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…")))
|
|
set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error));
|
|
else
|
|
set_status_ok();
|
|
|
|
refresh_tree();
|
|
}
|
|
|
|
// Hole and Thread LATCH the geometry they were opened or picked on; Thicken / Shell / Draft read
|
|
// the live selection instead. Both models are right for what they are — a placement tool with its
|
|
// own plane state, versus an operation whose operand IS the selected face — and the latch is the
|
|
// kinder of the two now that a click on empty canvas clears the selection (snaporca-od0): a stray
|
|
// click costs a Thicken pick, and costs a Hole nothing. What was missing is that nothing in the
|
|
// Hole/Thread card NAMED the latched face, so after such a click the only words on screen were
|
|
// the viewport's "Nothing selected" — over a ghost still drawn on the face Confirm would drill.
|
|
// That reads as a contradiction and was filed as one (snaporca-200). The card now says what it
|
|
// holds, the way the other three cards already do. Pass -1 for "none, using the plane dropdown".
|
|
void DesignPanel::set_hole_target_label(int face)
|
|
{
|
|
if (m_hole_target_label)
|
|
m_hole_target_label->SetLabel(face >= 0 ? wxString::Format(_L("Face %d"), face)
|
|
: _L("(none — uses Hole plane)"));
|
|
}
|
|
|
|
// Thread also latches onto a circular EDGE (a cylinder's rim), so it has two kinds to name.
|
|
void DesignPanel::set_thread_target_label(int face, int edge)
|
|
{
|
|
if (!m_thread_target_label) return;
|
|
m_thread_target_label->SetLabel(
|
|
face >= 0 ? wxString::Format(_L("Face %d"), face)
|
|
: edge >= 0 ? wxString::Format(_L("Edge %d"), edge)
|
|
: _L("(none — uses Thread plane)"));
|
|
}
|
|
|
|
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()) {
|
|
set_status(_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…")))
|
|
set_status(_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)
|
|
set_status(wxString::Format(_L("Thread standard: %s (pitch %.3g mm)"),
|
|
m_thread_std->GetString(sel), s->pitch_mm));
|
|
}
|
|
|
|
void DesignPanel::infer_thread_spec(double diameter)
|
|
{
|
|
// 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()) {
|
|
set_status(_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…")))
|
|
set_status(_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())) {
|
|
set_status(_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()) {
|
|
set_status(_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…")))
|
|
set_status(_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())) {
|
|
set_status(_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) {
|
|
set_status(_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()) {
|
|
set_status(_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…")))
|
|
set_status(_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) {
|
|
set_status(_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()) {
|
|
set_status(_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…")))
|
|
set_status(_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())) {
|
|
set_status(_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…")))
|
|
set_status(_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())) {
|
|
set_status(_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…")))
|
|
set_status(_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) {
|
|
set_status(_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…")))
|
|
set_status(_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())) {
|
|
set_status(_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…")))
|
|
set_status(_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())) {
|
|
set_status(_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…")))
|
|
set_status(_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())) {
|
|
set_status(_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…")))
|
|
set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error));
|
|
else
|
|
set_status_ok();
|
|
refresh_tree();
|
|
}
|
|
|
|
// Compose the same matrix apply_transform() builds in the kernel: rotate about the pivot first,
|
|
// then translate. Kept here so the preview and the committed feature can never disagree.
|
|
static Transform3d xf_compose(const Vec3d& t, const Vec3d& axis, const Vec3d& pivot, double deg)
|
|
{
|
|
Transform3d r = Transform3d::Identity();
|
|
if (std::abs(deg) > 1e-12 && axis.norm() > 1e-9)
|
|
r = Transform3d(Eigen::Translation3d(pivot))
|
|
* Transform3d(Eigen::AngleAxisd(deg * M_PI / 180.0, axis.normalized()))
|
|
* Transform3d(Eigen::Translation3d(-pivot));
|
|
return Transform3d(Eigen::Translation3d(t)) * r;
|
|
}
|
|
|
|
// Live preview for the Transform card. The gizmo already writes its drag into the body's display
|
|
// transform; the typed fields must use the SAME channel or the numbers and the geometry disagree
|
|
// until Confirm — which is what makes typing a distance look like it did nothing. In edit mode the
|
|
// committed transform is already baked into the kernel geometry, so undo it first: without that,
|
|
// typing 80 over a committed 34 would show the body at 114.
|
|
void DesignPanel::xf_live_preview()
|
|
{
|
|
if (m_active != Tool::Transform || m_xf_dx == nullptr) return;
|
|
if (m_xf_copy && m_xf_copy->GetValue()) return; // a copy leaves the original where it is
|
|
sync_body_xform();
|
|
const int sel = m_xf_body ? m_xf_body->GetSelection() : wxNOT_FOUND;
|
|
const int b = (sel != wxNOT_FOUND) ? sel : int(m_body_xform.size()) - 1;
|
|
if (b < 0 || b >= int(m_body_xform.size())) return;
|
|
|
|
if (m_xf_prev_body != b) {
|
|
xf_clear_preview(); // the card retargeted: hand the old body back first
|
|
m_xf_prev_body = b;
|
|
m_xf_prev_base = (b == m_xf_gizmo_body) ? m_xf_gizmo_base : m_body_xform[b];
|
|
}
|
|
const int ax = m_xf_axis->GetSelection();
|
|
const Vec3d axis = (ax == 0) ? Vec3d::UnitX() : (ax == 1) ? Vec3d::UnitY() : Vec3d::UnitZ();
|
|
const Vec3d pivot(m_xf_pivot_x->GetValue(), m_xf_pivot_y->GetValue(), m_xf_pivot_z->GetValue());
|
|
const Transform3d want = xf_compose(Vec3d(m_xf_dx->GetValue(), m_xf_dy->GetValue(),
|
|
m_xf_dz->GetValue()),
|
|
axis, pivot, m_xf_angle->GetValue());
|
|
Transform3d undo = Transform3d::Identity();
|
|
if (m_edit_index >= 0 && m_edit_index < int(m_doc.features.size())) {
|
|
const CadFeature& f = m_doc.features[m_edit_index];
|
|
if (f.type == CadFeatureType::Transform && !f.xf_copy)
|
|
undo = xf_compose(f.xf_translate, f.xf_axis, f.xf_pivot, f.xf_angle_deg).inverse();
|
|
}
|
|
m_body_xform[b] = want * undo * m_xf_prev_base;
|
|
feed_bodies();
|
|
}
|
|
|
|
// Hand a previewed body back to the pose it had before the card touched it. Every exit from the
|
|
// Transform card passes through here, so a preview can never survive into the committed document.
|
|
void DesignPanel::xf_clear_preview()
|
|
{
|
|
if (m_xf_prev_body < 0) return;
|
|
sync_body_xform();
|
|
if (m_xf_prev_body < int(m_body_xform.size()))
|
|
m_body_xform[m_xf_prev_body] = m_xf_prev_base;
|
|
m_xf_prev_body = -1;
|
|
feed_bodies();
|
|
}
|
|
|
|
void DesignPanel::on_add_transform()
|
|
{
|
|
xf_clear_preview(); // the feature performs this motion parametrically; the preview must go
|
|
// The gizmo baked its drag into the display transform so the body followed the cursor.
|
|
// The feature about to be created performs that same motion parametrically, so hand the
|
|
// body back to its pre-drag pose first or it moves twice.
|
|
if (m_xf_gizmo_body >= 0) {
|
|
sync_body_xform();
|
|
if (m_xf_gizmo_body < int(m_body_xform.size()))
|
|
m_body_xform[m_xf_gizmo_body] = m_xf_gizmo_base;
|
|
if (m_viewport) m_viewport->clear_move_gizmo();
|
|
feed_bodies(); // set_status_ok() re-feeds on success, but the recompute-ERROR path
|
|
// below does not — without this the body would keep showing the
|
|
// dragged pose after a Transform that failed to build.
|
|
m_xf_gizmo_body = -1;
|
|
m_move_body = -1;
|
|
}
|
|
if (m_doc.bodies.empty()) {
|
|
set_status(_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…")))
|
|
set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error));
|
|
else
|
|
set_status_ok();
|
|
refresh_tree();
|
|
}
|
|
|
|
void DesignPanel::on_add_mirror()
|
|
{
|
|
if (m_doc.bodies.empty()) {
|
|
set_status(_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…")))
|
|
set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error));
|
|
else
|
|
set_status_ok();
|
|
refresh_tree();
|
|
}
|
|
|
|
void DesignPanel::on_add_thicken()
|
|
{
|
|
if (m_doc.bodies.empty()) {
|
|
set_status(_L("Thicken needs a solid body — add or import one first"));
|
|
return;
|
|
}
|
|
if (m_sel_solid_face < 0) {
|
|
m_status->SetForegroundColour(wxColour(235, 110, 110));
|
|
set_status(_L("Pick a solid face to thicken first"));
|
|
m_status->Refresh();
|
|
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…")))
|
|
set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error));
|
|
else
|
|
set_status_ok();
|
|
refresh_tree();
|
|
}
|
|
|
|
void DesignPanel::on_add_rib()
|
|
{
|
|
if (m_doc.bodies.empty()) {
|
|
set_status(_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) {
|
|
set_status(_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…")))
|
|
set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error));
|
|
else
|
|
set_status_ok();
|
|
refresh_tree();
|
|
}
|
|
|
|
void DesignPanel::on_add_project()
|
|
{
|
|
if (m_doc.bodies.empty()) {
|
|
set_status(_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…")))
|
|
set_status(_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()) {
|
|
set_status(_L("Delete Face needs a body — add or import one first"));
|
|
return;
|
|
}
|
|
if (m_del_faces.empty()) {
|
|
m_status->SetForegroundColour(wxColour(235, 110, 110));
|
|
set_status(_L("Add at least one face to delete first"));
|
|
m_status->Refresh();
|
|
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…")))
|
|
set_status(_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…")))
|
|
set_status(_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));
|
|
set_status(_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));
|
|
set_status(_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));
|
|
set_status(_L("Mate rejected"));
|
|
m_status->Refresh();
|
|
return;
|
|
}
|
|
if (!recompute_guarded(_L("Rebuilding model…")))
|
|
set_status(_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);
|
|
set_status(_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);
|
|
set_status(_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);
|
|
set_status(wxString::Format(_L("%zu interference pairs, worst %.2f mm³"),
|
|
pairs.size(), worst));
|
|
m_status->Refresh();
|
|
wxString msg = _L("Interference pairs:\n\n");
|
|
for (const auto& p : pairs) {
|
|
// 1-based, like every other body label in this panel and in the parts tree:
|
|
// reporting "Body 1" for what the tree calls "Body 2" is worse than no name.
|
|
wxString na = wxString::Format(_L("Body %d"), p.body_a + 1);
|
|
wxString nb = wxString::Format(_L("Body %d"), p.body_b + 1);
|
|
if (p.body_a >= 0 && p.body_a < int(m_doc.bodies.size()) && !m_doc.bodies[p.body_a].name.empty())
|
|
na = wxString::FromUTF8(m_doc.bodies[p.body_a].name);
|
|
if (p.body_b >= 0 && p.body_b < int(m_doc.bodies.size()) && !m_doc.bodies[p.body_b].name.empty())
|
|
nb = wxString::FromUTF8(m_doc.bodies[p.body_b].name);
|
|
msg += wxString::Format("%s <-> %s: %.4f mm³\n", na, nb, p.volume);
|
|
}
|
|
wxMessageBox(msg, _L("Interference"), wxOK, this);
|
|
}
|
|
|
|
// Mass properties of the selected solid. A report, not a feature: it never checkpoints, never
|
|
// recomputes and never opens a card, which is why it sits beside the interference check rather
|
|
// than in the on_add_* family. The caller only reaches us with m_sel_solid_body in range.
|
|
void DesignPanel::on_mass_properties()
|
|
{
|
|
// This bounds check is not defensive padding — it is what makes the verb safe to fire from
|
|
// the socket, which has no offer menu to grey the row out. The menu-only route never reached
|
|
// here with nothing selected; run_verb does. Nothing selected is not an error, hence the
|
|
// neutral colour, not the error red.
|
|
if (m_sel_solid_body < 0 || m_sel_solid_body >= int(m_doc.bodies.size())) {
|
|
m_status->SetForegroundColour(wxNullColour);
|
|
set_status(_L("Select a solid body first — its mass properties are what is reported"));
|
|
m_status->Refresh();
|
|
return;
|
|
}
|
|
const auto mp = GeometryEngine::mass_properties(m_doc.bodies[m_sel_solid_body].shape);
|
|
if (!mp.valid) {
|
|
m_status->SetForegroundColour(wxColour(235, 110, 110));
|
|
set_status(_L("Mass properties could not be computed for this body"));
|
|
m_status->Refresh();
|
|
return;
|
|
}
|
|
// 1-based, and the body's own name when it has one — the same wording the parts list uses.
|
|
wxString name = wxString::Format(_L("Body %d"), m_sel_solid_body + 1);
|
|
if (!m_doc.bodies[m_sel_solid_body].name.empty())
|
|
name = wxString::FromUTF8(m_doc.bodies[m_sel_solid_body].name);
|
|
if (!mp.is_solid) {
|
|
// Sheet body: quoting a volume here would be inventing material that is not there.
|
|
m_status->SetForegroundColour(wxNullColour);
|
|
set_status(wxString::Format(_L("%s: sheet body — %.2f cm² of surface, no volume"),
|
|
name, mp.surface_area / 100.0));
|
|
m_status->Refresh();
|
|
wxMessageBox(wxString::Format(_L("%s\n\nSheet body (open shell)\nSurface area: %.2f cm²\n\n"
|
|
"A sheet encloses no material, so it has no volume. "
|
|
"Thicken it into a solid to get one."),
|
|
name, mp.surface_area / 100.0),
|
|
_L("Mass properties"), wxOK, this);
|
|
return;
|
|
}
|
|
m_status->SetForegroundColour(wxNullColour);
|
|
set_status(wxString::Format(_L("%s: %.3f cm³, %.2f cm²"),
|
|
name, mp.volume / 1000.0, mp.surface_area / 100.0));
|
|
m_status->Refresh();
|
|
wxMessageBox(wxString::Format(_L("%s\n\nVolume: %.3f cm³\nSurface area: %.2f cm²"),
|
|
name, mp.volume / 1000.0, mp.surface_area / 100.0),
|
|
_L("Mass properties"), wxOK, this);
|
|
}
|
|
|
|
// 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;
|
|
combo_append_index(c, n.empty() ? wxString::Format(_L("Body %zu"), i + 1)
|
|
: wxString::FromUTF8(n), int(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()) {
|
|
set_status(_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…")))
|
|
set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error));
|
|
else
|
|
set_status_ok();
|
|
|
|
refresh_tree();
|
|
}
|
|
|
|
int DesignPanel::selected_body_default() const
|
|
{
|
|
const int n = int(m_doc.bodies.size());
|
|
if (n <= 0) return 0;
|
|
return (m_sel_solid_body >= 0 && m_sel_solid_body < n) ? m_sel_solid_body : 0;
|
|
}
|
|
|
|
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
|
|
};
|
|
const int picked = selected_body_default();
|
|
fill(m_bool_target, picked);
|
|
// …and the tool body is a DIFFERENT one: defaulting both to the same body is a no-op the
|
|
// user has to notice and undo. Fall back to the neighbour of whatever was picked.
|
|
fill(m_bool_tool, picked == 0 ? 1 : 0);
|
|
fill(m_cut_target, picked);
|
|
}
|
|
|
|
void DesignPanel::fill_body_choice(ComboBox* c, int as_of_feature, int want)
|
|
{
|
|
if (!c) return;
|
|
// Same replay as populate_body_choices, for the single-combo tools. A stored target_body
|
|
// indexes the body list AS IT WAS just before that feature ran; listing the final bodies
|
|
// instead makes the saved index select whatever now sits at that position, which is a
|
|
// different body as soon as a later Cut splits one (indices shift up) or a later Boolean
|
|
// consumes its tool body (indices shift down).
|
|
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; }
|
|
}
|
|
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 (want >= 0 && want < int(c->GetCount())) c->SetSelection(want);
|
|
else if (c->GetCount() > 0) c->SetSelection(0);
|
|
}
|
|
|
|
void DesignPanel::on_add_boolean()
|
|
{
|
|
if (m_doc.bodies.size() < 2) {
|
|
set_status(_L("Boolean needs two solid bodies — add or import a second one"));
|
|
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…")))
|
|
set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error));
|
|
else
|
|
set_status_ok();
|
|
refresh_tree();
|
|
}
|
|
|
|
void DesignPanel::on_add_cut()
|
|
{
|
|
if (m_doc.bodies.empty()) {
|
|
set_status(_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…")))
|
|
set_status(_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);
|
|
}
|
|
|
|
wxString DesignPanel::ref_plane_name(int row) const
|
|
{
|
|
if (row == 1) return "XZ";
|
|
if (row == 2) return "YZ";
|
|
if (row >= 3) {
|
|
const auto datums = m_doc.resolve_datum_planes();
|
|
const int di = row - 3;
|
|
if (di < int(datums.size())) return wxString::FromUTF8(datums[di].first);
|
|
}
|
|
return "XY";
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// A sketch goes where the user pointed. A planar face picked in the viewport wins outright; only
|
|
// when nothing is picked do we fall back to the reference plane, which itself is normally set by
|
|
// clicking one of the ghost planes in 3D (on_datum_base_picked) rather than by opening the combo.
|
|
// Before this, a picked face was ignored and the only way onto it was to build a Coincident datum
|
|
// plane first and then find it in a dropdown — three steps and a junk feature in the tree for the
|
|
// most common gesture in solid modelling. snaporca-3a2.
|
|
SketchPlane DesignPanel::sketch_plane_from_selection(wxString& what) const
|
|
{
|
|
SketchPlane p;
|
|
// An explicitly face-level selection first, then the face merely CLICKED ON while the whole
|
|
// body was selected. The second case is the common one: one click on a face is what a user
|
|
// means by "select this face", and requiring the cycle's second click to make it count is the
|
|
// whole reason this looked unfixed.
|
|
const int fb = (m_sel_solid_face >= 0 && m_sel_solid_body >= 0) ? m_sel_solid_body : m_pick_face_body;
|
|
const int fi = (m_sel_solid_face >= 0 && m_sel_solid_body >= 0) ? m_sel_solid_face : m_pick_face;
|
|
if (fb >= 0 && fi >= 0 && m_doc.plane_of_face(fb, fi, p)) {
|
|
what = (m_doc.bodies.size() > 1)
|
|
? wxString::Format(_L("the picked face of Body %d"), fb + 1)
|
|
: _L("the picked face");
|
|
return p;
|
|
}
|
|
what = ref_plane_name(m_ref_plane);
|
|
return plane_from_choice(m_ref_plane);
|
|
}
|
|
|
|
// Is there a sketch target the USER chose, and what is it called? Distinct from
|
|
// sketch_plane_from_selection, which always answers because it falls back to m_ref_plane —
|
|
// a caller that wants to say "sketching on X" needs to know whether there is anything to fall
|
|
// back FROM, so it can ask for a plane instead of claiming one the user never picked.
|
|
bool DesignPanel::sketch_plane_target(wxString& what) const
|
|
{
|
|
const int fb = (m_sel_solid_face >= 0 && m_sel_solid_body >= 0) ? m_sel_solid_body : m_pick_face_body;
|
|
const int fi = (m_sel_solid_face >= 0 && m_sel_solid_body >= 0) ? m_sel_solid_face : m_pick_face;
|
|
SketchPlane p;
|
|
if (fb >= 0 && fi >= 0 && m_doc.plane_of_face(fb, fi, p)) {
|
|
what = (m_doc.bodies.size() > 1)
|
|
? wxString::Format(_L("the picked face of Body %d"), fb + 1)
|
|
: _L("the picked face");
|
|
return true;
|
|
}
|
|
if (m_plane_picked) { what = ref_plane_name(m_ref_plane); return true; }
|
|
return false;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------------
|
|
// The object-driven offer (charter §4.1). Right-click the geometry and get a vertical list in the
|
|
// ratified row order, with the verbs that do not apply DISABLED IN PLACE carrying their reason.
|
|
// The rows come from DesignOffer.hpp, generated from docs/ux/tool_atlas.json — the same file the
|
|
// mockups are drawn from, so a drawing and the product cannot drift apart.
|
|
// ---------------------------------------------------------------------------------------------
|
|
|
|
bool DesignPanel::sketch_map_applies() const
|
|
{
|
|
return m_ui_mode == UiMode::Sketch || (m_viewport && m_viewport->is_sketching());
|
|
}
|
|
|
|
// Which kind of thing is selected, as an OfferSel. Classified by the level the pick cycle has
|
|
// actually REACHED, so the menu describes what is highlighted — a header that names a face while
|
|
// the whole body is lit would be lying, and this menu's whole value is that it tells the truth
|
|
// about the selection. (Sketching on the face you merely clicked is unaffected: that path is
|
|
// sketch_plane_from_selection, which deliberately uses m_pick_face. snaporca-3a2.)
|
|
int DesignPanel::offer_selection_kind() const
|
|
{
|
|
if (sketch_map_applies()) {
|
|
// Classify WHAT is selected in the sketch, rather than collapsing every sketch state to
|
|
// SkNone. The offer table has always described verbs for a selected line, arc, point or
|
|
// pair — Trim, Extend, Fillet, Chamfer, Offset, Mirror, the arrays, Move/Rotate/Scale,
|
|
// Constrain, Delete — but nothing ever RETURNED those kinds, so all thirteen were
|
|
// unreachable from the menu and the sketch-editing vocabulary did not exist in the one
|
|
// place this app tells users to look. A user comparing against Onshape reported exactly
|
|
// that about constraints (OrcaSlicer PR #15238).
|
|
const int n = m_viewport ? m_viewport->sketch_selection_count() : 0;
|
|
if (n >= 2) return int(OfferSel::Sk2Ent);
|
|
if (n == 1) {
|
|
SketchEntity::Type t = SketchEntity::Type::Line;
|
|
if (m_viewport && m_viewport->sketch_first_selected_type(t)) {
|
|
switch (t) {
|
|
case SketchEntity::Type::Line: return int(OfferSel::SkLine);
|
|
case SketchEntity::Type::Point: return int(OfferSel::SkPoint);
|
|
// Arc, Circle, Ellipse, EllipseArc, BSpline all take the curve vocabulary.
|
|
default: return int(OfferSel::SkArc);
|
|
}
|
|
}
|
|
}
|
|
return int(OfferSel::SkNone);
|
|
}
|
|
|
|
const int nb = int(m_doc.bodies.size());
|
|
if (m_sel_solid_vertex && m_sel_solid_body >= 0 && m_sel_solid_body < nb)
|
|
return int(OfferSel::Vertex);
|
|
if (m_sel_solid_edge >= 0 && m_sel_solid_body >= 0 && m_sel_solid_body < nb) {
|
|
const TopoDS_Edge e = GeometryEngine::edge_by_index(m_doc.bodies[m_sel_solid_body].shape,
|
|
m_sel_solid_edge);
|
|
return int(GeometryEngine::circle_of_edge(e).ok ? OfferSel::EdgeCirc : OfferSel::EdgeStr);
|
|
}
|
|
if (m_sel_solid_face >= 0 && m_sel_solid_body >= 0 && m_sel_solid_body < nb) {
|
|
SketchPlane p;
|
|
if (m_doc.plane_of_face(m_sel_solid_body, m_sel_solid_face, p))
|
|
return int(OfferSel::FacePlanar);
|
|
const TopoDS_Face f = GeometryEngine::face_by_index(m_doc.bodies[m_sel_solid_body].shape,
|
|
m_sel_solid_face);
|
|
return int(GeometryEngine::cylinder_of_face(f).ok ? OfferSel::FaceCyl : OfferSel::FaceOther);
|
|
}
|
|
if (m_sel_sketch_region >= 0)
|
|
return int(OfferSel::SkLoop);
|
|
if (m_sel_solid_body >= 0 && m_sel_solid_body < nb)
|
|
return int(CadDocument::is_sheet_shape(m_doc.bodies[m_sel_solid_body].shape)
|
|
? OfferSel::BodySheet : OfferSel::BodySolid);
|
|
return int(OfferSel::None);
|
|
}
|
|
|
|
// Route a row to the code that already implements the verb. Nothing here re-implements a tool:
|
|
// the offer is a second door onto the same room, which is what keeps the toolbar, the shortcuts
|
|
// and the menu from drifting into three behaviours.
|
|
void DesignPanel::run_offer_action(const char* action)
|
|
{
|
|
if (!action || !*action)
|
|
return;
|
|
const std::string a(action);
|
|
if (a.rfind("key:", 0) == 0) {
|
|
const std::string k = a.substr(4);
|
|
if (k.size() >= 3 && k[0] == 'S' && k[1] == '+') { // "S+E" -> Shift+E, model mode
|
|
auto it = m_keys_feature.find(int(k[2]) | SC_SHIFT);
|
|
if (it != m_keys_feature.end() && it->second) it->second();
|
|
} else if (!k.empty()) { // "L" -> sketch-mode letter
|
|
auto it = m_keys_sketch.find(int(k[0]));
|
|
if (it != m_keys_sketch.end() && it->second) it->second();
|
|
}
|
|
return;
|
|
}
|
|
auto it = m_verb_actions.find(a);
|
|
if (it != m_verb_actions.end() && it->second)
|
|
it->second();
|
|
}
|
|
|
|
// Look a verb up by its offer id and dispatch it — the "run_verb" half of the MCP offer surface.
|
|
// Unknown ids and rows whose action string is null (kernel support, no GUI route yet) return
|
|
// false without touching anything, so the caller can tell "no such verb" from "not wired yet".
|
|
bool DesignPanel::mcp_run_verb(const char* verb_id)
|
|
{
|
|
if (!verb_id) return false;
|
|
for (int i = 0; i < kOfferVerbCount; ++i) {
|
|
const OfferVerb& v = kOfferVerbs[i];
|
|
if (std::string(v.id) == verb_id) {
|
|
if (v.action == nullptr) return false;
|
|
run_offer_action(v.action);
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
wxPoint DesignPanel::offer_anchor() const
|
|
{
|
|
const wxPoint mouse = wxGetMousePosition();
|
|
if (!m_viewport)
|
|
return mouse;
|
|
const wxRect r = m_viewport->GetScreenRect();
|
|
if (r.Contains(mouse))
|
|
return mouse;
|
|
return wxPoint(r.x + r.width / 2, r.y + r.height / 2);
|
|
}
|
|
|
|
// Append a verb row carrying the same glyph its toolbar button shows. The icon name comes from
|
|
// the atlas, so a verb and its icon are declared in one place; a null or unknown name leaves
|
|
// the row text-only rather than drawing a blank square.
|
|
//
|
|
// THE BITMAP MUST BE SET BEFORE Append(). wxGTK builds the GtkMenuItem inside Append and reads
|
|
// GetBitmap() right there (gtk/menu.cpp) — it makes a gtk_image_menu_item only if a bitmap is
|
|
// already present, and a plain one otherwise. Setting it on the item Append() returns is too
|
|
// late and silently does nothing, which is exactly how the first attempt failed. This is why
|
|
// Orca's own append_menu_item() constructs, sets, then appends.
|
|
// One place that writes the status line, so every hint wraps instead of clipping at the panel
|
|
// edge. wxStaticText::Wrap() is destructive, which is fine here: the label is replaced whole
|
|
// each time, never appended to.
|
|
void DesignPanel::set_status(const wxString& text)
|
|
{
|
|
if (m_status == nullptr) return;
|
|
m_status->SetLabel(text); // the ONE place that may call SetLabel directly
|
|
// m_status is HIDDEN and kept only as the owner of the text and its colour — every caller
|
|
// sets the colour on it just before calling here, so this stays the one place that knows
|
|
// both. What the user reads is drawn along the BASE OF THE VIEWPORT: in the panel the line
|
|
// was clipped at ~73 characters with no warning and no wrap (Wrap() never took effect —
|
|
// snaporca-8cc), which silently length-limited every hint in the tab. The viewport's bottom
|
|
// margin has the whole window width, so a sentence can be a sentence.
|
|
if (m_viewport != nullptr) {
|
|
// wxNullColour means "no opinion", and the dark default text colour is nearly invisible
|
|
// on the dark HUD; only a colour a caller actually chose (the error red, the plane-pick
|
|
// green) is carried over. Compared against the colour the label was CREATED with —
|
|
// comparing against the parent's foreground instead reported "chosen" for every line,
|
|
// and the neutral text came out the panel's grey.
|
|
const wxColour fg = m_status->GetForegroundColour();
|
|
m_viewport->set_status_text(text, fg != m_status_default_fg ? fg
|
|
: wxColour(0xDD, 0xE1, 0xE6));
|
|
}
|
|
}
|
|
|
|
|
|
// The sentence for the step the armed sketch tool is on (snaporca-1c0c). One table, so a tool's
|
|
// gesture is described in one place and the description cannot drift from the code that reads the
|
|
// clicks: the step counts here are the ones DesignSketchTool::render previews and on_mouse
|
|
// consumes. `step` = anchors already placed (edit-ops: 0 none, 1 first pick down, 2 ready to
|
|
// apply); `picks` = the size of the set the gesture accumulates.
|
|
//
|
|
// It is deliberately explicit about the gesture that ENDS each tool, because none of them is
|
|
// discoverable: a click on empty space applies an edit-op or a transform, right-click cancels it,
|
|
// and Esc downgrades an armed tool to Select before it ever exits the sketch.
|
|
static wxString sketch_step_prompt(DesignSketchTool::Mode m, int step, int picks)
|
|
{
|
|
using Mode = DesignSketchTool::Mode;
|
|
auto pick_more = [](const wxString& lead, int n) {
|
|
return n <= 0 ? lead
|
|
: lead + wxString::Format(_L(" · %d picked"), n);
|
|
};
|
|
switch (m) {
|
|
case Mode::Select:
|
|
return picks > 0
|
|
? wxString::Format(_L("%d selected · Del removes them · Shift-click adds · "
|
|
"double-click takes the whole loop"), picks)
|
|
: _L("Select — click an entity to pick it · drag an endpoint or centre to move it · "
|
|
"Shift-click adds · Del removes");
|
|
case Mode::Constrain:
|
|
return picks > 0
|
|
? wxString::Format(_L("%d picked · now choose a constraint (Horizontal, Parallel, "
|
|
"Tangent, Equal…)"), picks)
|
|
: _L("Constrain — click one or two entities, then choose a constraint");
|
|
case Mode::Dimension:
|
|
return step == 0 ? _L("Dimension — click an entity, or the first of two points")
|
|
: _L("Dimension — click the second point");
|
|
case Mode::Line:
|
|
return step == 0 ? _L("Line — click the start point")
|
|
: _L("Line — click the end point, or type the length");
|
|
case Mode::Polyline:
|
|
return step == 0 ? _L("Polyline — click the first point")
|
|
: pick_more(_L("Polyline — click the next point · click the start point "
|
|
"to close it · right-click to end the chain"), step);
|
|
case Mode::CornerRect:
|
|
return step == 0 ? _L("Rectangle — click one corner")
|
|
: _L("Rectangle — click the opposite corner");
|
|
case Mode::CenterRect:
|
|
return step == 0 ? _L("Centre rectangle — click the centre")
|
|
: _L("Centre rectangle — click a corner");
|
|
case Mode::ObliqueRect:
|
|
return step == 0 ? _L("Oblique rectangle — click the start of the base edge")
|
|
: step == 1 ? _L("Oblique rectangle — click the end of the base edge (this sets the angle)")
|
|
: _L("Oblique rectangle — click to set the width");
|
|
case Mode::RoundedRect:
|
|
return step == 0 ? _L("Rounded rectangle — click one corner")
|
|
: step == 1 ? _L("Rounded rectangle — click the opposite corner")
|
|
: _L("Rounded rectangle — click to set the corner radius");
|
|
case Mode::CenterCircle:
|
|
return step == 0 ? _L("Circle — click the centre")
|
|
: _L("Circle — click to set the radius, or type it");
|
|
case Mode::TwoPointCircle:
|
|
return step == 0 ? _L("Circle (2 points) — click one end of the diameter")
|
|
: _L("Circle (2 points) — click the other end of the diameter");
|
|
case Mode::ThreePointCircle:
|
|
return step == 0 ? _L("Circle (3 points) — click the first point on the circle")
|
|
: step == 1 ? _L("Circle (3 points) — click the second point")
|
|
: _L("Circle (3 points) — click the third point");
|
|
case Mode::ThreePointArc:
|
|
return step == 0 ? _L("Arc — click the start point")
|
|
: step == 1 ? _L("Arc — click the end point")
|
|
: _L("Arc — click a point the arc passes through");
|
|
case Mode::TangentArc:
|
|
return step == 0 ? _L("Tangent arc — click the endpoint it leaves from")
|
|
: _L("Tangent arc — click its far end");
|
|
case Mode::CenterArc:
|
|
return step == 0 ? _L("Centre arc — click the centre")
|
|
: step == 1 ? _L("Centre arc — click the start point (this sets the radius)")
|
|
: _L("Centre arc — click the end point");
|
|
case Mode::Slot:
|
|
return step == 0 ? _L("Slot — click one end of the centreline")
|
|
: step == 1 ? _L("Slot — click the other end of the centreline")
|
|
: _L("Slot — click to set the width");
|
|
case Mode::ArcSlot:
|
|
return step == 0 ? _L("Arc slot — click the centre the slot curves about")
|
|
: step == 1 ? _L("Arc slot — click the start of the centreline (this sets the radius)")
|
|
: step == 2 ? _L("Arc slot — click the end of the centreline")
|
|
: _L("Arc slot — click to set the width");
|
|
case Mode::Polygon:
|
|
return step == 0 ? _L("Polygon — click the centre")
|
|
: _L("Polygon — click a vertex (this sets size and orientation)");
|
|
case Mode::Ellipse:
|
|
return step == 0 ? _L("Ellipse — click the centre")
|
|
: step == 1 ? _L("Ellipse — click the end of the major axis")
|
|
: _L("Ellipse — click a point on the minor axis");
|
|
case Mode::EllipseArc:
|
|
return step == 0 ? _L("Elliptical arc — click the centre")
|
|
: step == 1 ? _L("Elliptical arc — click the end of the major axis")
|
|
: step == 2 ? _L("Elliptical arc — click a point on the minor axis")
|
|
: step == 3 ? _L("Elliptical arc — click where the arc starts")
|
|
: _L("Elliptical arc — click where the arc ends");
|
|
case Mode::BSpline:
|
|
return step == 0 ? _L("Spline — click the first control point")
|
|
: pick_more(_L("Spline — click the next control point · right-click to "
|
|
"finish the curve"), step);
|
|
case Mode::Point:
|
|
return _L("Point — click to place one; the tool stays armed for more");
|
|
case Mode::Trim:
|
|
return _L("Trim — click a segment where it crosses another entity · right-click to exit");
|
|
case Mode::Extend:
|
|
return _L("Extend — click a line or arc to grow it out to the nearest entity · "
|
|
"right-click to exit");
|
|
case Mode::Fillet:
|
|
return step == 0 ? _L("Fillet — click the first of two lines that meet")
|
|
: step == 1 ? _L("Fillet — click the second line")
|
|
: _L("Fillet — drag the arrow, or click the number to type the radius · "
|
|
"click empty space to apply · right-click cancels");
|
|
case Mode::Chamfer:
|
|
return step == 0 ? _L("Chamfer — click the first of two lines that meet")
|
|
: step == 1 ? _L("Chamfer — click the second line")
|
|
: _L("Chamfer — drag the arrow, or click the number to type the setback · "
|
|
"click empty space to apply · right-click cancels");
|
|
case Mode::Offset:
|
|
return step == 0 ? _L("Offset — click the entity to offset")
|
|
: _L("Offset — drag the arrow to either side, or click the number to type "
|
|
"the distance · click empty space to apply · right-click cancels");
|
|
case Mode::Mirror:
|
|
// The two-phase pick is the one gesture users reported as unguided: nothing said the
|
|
// AXIS comes first, and nothing said an empty click is what applies it.
|
|
return step == 0
|
|
? _L("Mirror — first click the LINE to mirror about (a construction line works)")
|
|
: picks == 0
|
|
? _L("Mirror — axis set · now click the entities to mirror · right-click cancels")
|
|
: wxString::Format(_L("Mirror — axis set · %d to mirror · click another to add or "
|
|
"remove it · click empty space to apply"), picks);
|
|
case Mode::Move:
|
|
return step == 0 ? _L("Move — click the entities to move")
|
|
: pick_more(_L("Move — drag the handle, or click the number to type the "
|
|
"distance · click empty space to apply"), picks);
|
|
case Mode::Rotate:
|
|
return step == 0 ? _L("Rotate — click the entities to rotate")
|
|
: pick_more(_L("Rotate — drag the handle, or click the number to type the "
|
|
"angle · click empty space to apply"), picks);
|
|
case Mode::Scale:
|
|
return step == 0 ? _L("Scale — click the entities to scale")
|
|
: pick_more(_L("Scale — drag the handle, or click the number to type the "
|
|
"factor · click empty space to apply"), picks);
|
|
case Mode::Array:
|
|
return step == 0 ? _L("Array — click the entities to repeat")
|
|
: pick_more(_L("Array — drag the handle to set the step, click the count to "
|
|
"type it · click empty space to apply"), picks);
|
|
case Mode::PolarArray:
|
|
return step == 0 ? _L("Polar array — click the entities to repeat")
|
|
: pick_more(_L("Polar array — drag the handle to set the sweep, click the "
|
|
"count to type it · click empty space to apply"), picks);
|
|
case Mode::TransformArt:
|
|
return _L("Drag a corner to scale, the centre to move · right-click when done");
|
|
}
|
|
return wxString();
|
|
}
|
|
|
|
void DesignPanel::on_sketch_step(int mode, int step, int picks)
|
|
{
|
|
wxString text = sketch_step_prompt(DesignSketchTool::Mode(mode), step, picks);
|
|
// Esc is layered (DesignSketchTool::request_exit): it drops the anchors down, then downgrades
|
|
// the armed tool to Select, and only then leaves the sketch. Nothing in the UI said so, so
|
|
// the route back to selecting existing geometry was invisible. Said once, on the step where
|
|
// the gesture has not started yet, so it does not crowd the instruction that matters.
|
|
if (step == 0 && picks == 0 && DesignSketchTool::Mode(mode) != DesignSketchTool::Mode::Select
|
|
&& !text.IsEmpty())
|
|
text += _L(" · Esc goes back to Select");
|
|
m_sketch_step = text;
|
|
if (text.IsEmpty() || m_status == nullptr) return;
|
|
m_status->SetForegroundColour(wxNullColour);
|
|
set_status(text);
|
|
m_status->Refresh();
|
|
}
|
|
|
|
wxMenuItem* DesignPanel::append_offer_item(wxMenu* menu, int id, const wxString& text,
|
|
const OfferVerb& v)
|
|
{
|
|
auto* item = new wxMenuItem(menu, id, text);
|
|
if (v.icon != nullptr && *v.icon != '\0') {
|
|
const wxBitmap bmp = create_scaled_bitmap(v.icon, this, 16);
|
|
if (bmp.IsOk())
|
|
item->SetBitmap(bmp);
|
|
}
|
|
menu->Append(item);
|
|
return item;
|
|
}
|
|
|
|
// Diagnostic only: what the offer is about to show, line per row, on stderr. Costs one getenv
|
|
// per menu when off. The ladder that drives right-click needs to assert the ROW SET, and the only
|
|
// honest source for that is the loop that builds the rows.
|
|
static void offer_trace(const char* fmt, ...)
|
|
{
|
|
static const bool on = std::getenv("SNAPORCA_KEYTRACE") != nullptr;
|
|
if (!on) return;
|
|
va_list ap;
|
|
va_start(ap, fmt);
|
|
fprintf(stderr, "[OFFER] ");
|
|
vfprintf(stderr, fmt, ap);
|
|
fprintf(stderr, "\n");
|
|
va_end(ap);
|
|
fflush(stderr);
|
|
}
|
|
|
|
void DesignPanel::show_offer_menu(const wxPoint& screen_pos)
|
|
{
|
|
const int kind = offer_selection_kind();
|
|
const uint32_t bit = offer_bit(OfferSel(kind));
|
|
// Which verb MAP applies is a question about the MODE, not about whether a session is
|
|
// running — the same distinction the keyboard already had to learn (snaporca-0ud). Gated on
|
|
// is_sketching() the offer opened on entering a sketch showing the FEATURE rows, every one
|
|
// of them refusing the sketch selection, so it read as a menu of nine dead entries.
|
|
const bool sketching = sketch_map_applies();
|
|
// The offer ladder reads THIS, not the pixels: the trace is emitted from the same loop that
|
|
// builds the menu, so it cannot drift from what the user is shown. Gated on the existing
|
|
// SNAPORCA_KEYTRACE so a rig run needs one env var, not two. snaporca-<offer ladder>.
|
|
offer_trace("open kind=%d sketching=%d bodies=%d", kind, sketching ? 1 : 0,
|
|
int(m_doc.bodies.size()));
|
|
|
|
const int bodies = int(m_doc.bodies.size());
|
|
int sketches = 0;
|
|
for (const auto& f : m_doc.features)
|
|
if (f.type == CadFeatureType::Sketch) ++sketches;
|
|
bool sheet = false;
|
|
for (const auto& b : m_doc.bodies)
|
|
if (CadDocument::is_sheet_shape(b.shape)) { sheet = true; break; }
|
|
|
|
auto applies = [&](const OfferVerb& v) {
|
|
return (v.accepts & bit) && v.need_bodies <= bodies && v.need_sketches <= sketches
|
|
&& (!v.need_sheet || sheet);
|
|
};
|
|
// Names and reasons live in the generated table as plain literals; they are the same strings
|
|
// the toolbar already ships, so the catalogue already carries their translations.
|
|
// Scope the lookup to the APPLICATION catalog. A bare wxGetTranslation() searches every
|
|
// loaded catalog, wxWidgets' own wxstd included — so on a non-English system the two row
|
|
// names that happen to be wx standard strings came back translated while the other six,
|
|
// which wx does not know, stayed English. On an Italian desktop the offer read
|
|
// "Create / Add material / Rimuovi / Fillet / chamfer / draft / Repeat / Transform /
|
|
// Reference / Modify": one menu, two languages, and not because anything was mistranslated.
|
|
// The same trap is waiting for any locale — Supprimer, Löschen, Eliminar. Naming the domain
|
|
// means these strings are translated by OUR catalogue or not at all, which is consistent
|
|
// either way.
|
|
auto tr = [](const char* s) {
|
|
return wxGetTranslation(wxString::FromUTF8(s), SLIC3R_APP_KEY);
|
|
};
|
|
auto label = [&](const OfferVerb& v) {
|
|
wxString s = tr(v.name);
|
|
if (v.key && *v.key) s += "\t" + wxString::FromUTF8(v.key);
|
|
return s;
|
|
};
|
|
|
|
wxMenu menu;
|
|
std::vector<const OfferVerb*> bound; // menu id offset -> verb
|
|
const int base = wxID_HIGHEST + 4200;
|
|
|
|
for (int row = 0; row < kOfferRowCount; ++row) {
|
|
std::vector<const OfferVerb*> live, family;
|
|
for (int i = 0; i < kOfferVerbCount; ++i) {
|
|
const OfferVerb& v = kOfferVerbs[i];
|
|
if (v.row != row || v.sketch_mode != sketching) continue;
|
|
family.push_back(&v);
|
|
if (applies(v)) live.push_back(&v);
|
|
}
|
|
if (family.empty())
|
|
continue; // no verb of this family in this mode
|
|
const wxString fam = tr(kOfferRowNames[row]);
|
|
|
|
if (live.empty()) {
|
|
// DISABLED IN PLACE, with the reason. This is the row that makes the list worth
|
|
// having: a control that cannot be used still says what it is and what you would
|
|
// have to do first, in the product's own words (L7).
|
|
//
|
|
// The reason must be TRUE for the situation in front of the user. Taking the first
|
|
// refusal in the family printed "Transform needs a body — add or import one first"
|
|
// on a document that has a body, because the real obstacle was that nothing was
|
|
// selected. So: prefer the refusal of a verb that accepts THIS selection and fails
|
|
// only on document state — that message is about the actual blocker. If no verb in
|
|
// the family accepts this selection at all, the honest thing is to say so, or say
|
|
// nothing.
|
|
const char* why = nullptr;
|
|
for (const OfferVerb* v : family)
|
|
if ((v->accepts & bit) && v->refusal) { why = v->refusal; break; }
|
|
wxString s = fam;
|
|
if (why)
|
|
s += wxString::FromUTF8(" — ") + tr(why);
|
|
else if (OfferSel(kind) == OfferSel::None)
|
|
s += wxString::FromUTF8(" — ") + _L("select something first");
|
|
offer_trace("row=%d %s DISABLED (%s)", row, kOfferRowNames[row],
|
|
why ? why : "no verb accepts this selection");
|
|
menu.Append(base + int(bound.size()), s)->Enable(false);
|
|
bound.push_back(nullptr);
|
|
} else if (live.size() == 1) {
|
|
offer_trace("row=%d %s -> %s%s", row, kOfferRowNames[row], live[0]->id,
|
|
live[0]->action ? "" : " (no GUI route)");
|
|
append_offer_item(&menu, base + int(bound.size()), label(*live[0]), *live[0])
|
|
->Enable(live[0]->action != nullptr);
|
|
bound.push_back(live[0]);
|
|
} else {
|
|
// Two levels, not one. A verb with a `family` joins a nested submenu of that name
|
|
// (Rectangle -> corner / centre / oblique / rounded); one without sits directly in
|
|
// the row. Families keep the order of their first member, so the row's layout is
|
|
// stable across selections — the whole point of a fixed address.
|
|
auto* sub = new wxMenu();
|
|
std::vector<std::pair<std::string, wxMenu*>> groups; // insertion-ordered
|
|
for (const OfferVerb* v : live) {
|
|
wxMenu* target = sub;
|
|
if (v->family && *v->family) {
|
|
auto it = std::find_if(groups.begin(), groups.end(),
|
|
[&](const auto& g) { return g.first == v->family; });
|
|
if (it == groups.end()) {
|
|
auto* g = new wxMenu();
|
|
groups.emplace_back(v->family, g);
|
|
sub->AppendSubMenu(g, tr(v->family));
|
|
target = g;
|
|
} else {
|
|
target = it->second;
|
|
}
|
|
}
|
|
offer_trace("row=%d %s > %s%s%s%s", row, kOfferRowNames[row],
|
|
(v->family && *v->family) ? v->family : "",
|
|
(v->family && *v->family) ? " > " : "", v->id,
|
|
v->action ? "" : " (no GUI route)");
|
|
append_offer_item(target, base + int(bound.size()), label(*v), *v)
|
|
->Enable(v->action != nullptr);
|
|
bound.push_back(v);
|
|
}
|
|
menu.AppendSubMenu(sub, fam);
|
|
}
|
|
}
|
|
|
|
// --- Mate palette section (snaporca-lukg part B) ---
|
|
// Fed by CadDocument::mate_options() so the offer can never disagree with the kernel about
|
|
// which assembly mates a connector pair admits. Shown only when the document holds at least
|
|
// two ENABLED CoordSys features: below that the whole section would be one permanently dead
|
|
// row, which is noise rather than information.
|
|
// Set while a hovered mate row is showing its ghost, so the cleanup after PopupMenu can tell
|
|
// "I put that there" from "some other tool's preview was already on screen".
|
|
bool mate_ghost = false;
|
|
std::vector<int> cs_features;
|
|
for (int i = 0; i < int(m_doc.features.size()); ++i)
|
|
if (m_doc.features[i].type == CadFeatureType::CoordSys && m_doc.features[i].enabled)
|
|
cs_features.push_back(i);
|
|
if (cs_features.size() >= 2) {
|
|
// Which two connectors the types would apply to. The Mate card's combos when it is open —
|
|
// so the offer and the card can never disagree about the pair — otherwise the first two
|
|
// enabled CoordSys features, which is a defensible default precisely because the header row
|
|
// below NAMES it. An offer that acts on an unnamed pair would be worse than no offer.
|
|
int cs_a = -1, cs_b = -1;
|
|
if (m_active == Tool::Mate && m_mate_cs_a && m_mate_cs_b
|
|
&& m_mate_cs_a->GetSelection() != wxNOT_FOUND
|
|
&& m_mate_cs_b->GetSelection() != wxNOT_FOUND) {
|
|
cs_a = int(reinterpret_cast<intptr_t>(m_mate_cs_a->GetClientData(m_mate_cs_a->GetSelection())));
|
|
cs_b = int(reinterpret_cast<intptr_t>(m_mate_cs_b->GetClientData(m_mate_cs_b->GetSelection())));
|
|
}
|
|
if (cs_a < 0 || cs_b < 0) { cs_a = cs_features[0]; cs_b = cs_features[1]; }
|
|
|
|
// The generated verb rows own [base, base + bound.size()); kOfferVerbCount is 87, so a
|
|
// gap of 500 keeps this section's ids clear of that range and lets its own handler index
|
|
// by a distinct offset.
|
|
const int mate_base = base + 500;
|
|
|
|
menu.AppendSeparator();
|
|
// B is the connector on the body that MOVES (CadDocument.hpp:317), so B is the arrow's
|
|
// destination — "A first" invites the opposite guess.
|
|
const wxString name_a = wxString::FromUTF8(m_doc.features[cs_a].name);
|
|
const wxString name_b = wxString::FromUTF8(m_doc.features[cs_b].name);
|
|
menu.Append(mate_base, wxString::Format(_L("Mate: %s → %s"), name_a, name_b))->Enable(false);
|
|
|
|
// A switch of literal _L() calls, not an array indexed by kind. _L is a gettext macro:
|
|
// the extractor scans the SOURCE for literals, so _L(table[i]) compiles fine and then
|
|
// silently ships five strings that are never in the catalogue and can never be
|
|
// translated. These names appear nowhere else in the tree, so the array version would
|
|
// have been their only occurrence.
|
|
auto mate_kind_name = [](int kind) -> wxString {
|
|
switch (kind) {
|
|
case 0: return _L("Fastened");
|
|
case 1: return _L("Planar");
|
|
case 2: return _L("Revolute");
|
|
case 3: return _L("Slider");
|
|
default: return _L("Cylindrical");
|
|
}
|
|
};
|
|
// Five rows, always, in kind order. Never reordered, never filtered — a menu that changes
|
|
// shape between invocations destroys the motor memory experts rely on.
|
|
const std::vector<CadDocument::MateOption> opts = m_doc.mate_options(cs_a, cs_b);
|
|
for (int i = 0; i < int(opts.size()); ++i) {
|
|
const CadDocument::MateOption& o = opts[i];
|
|
wxString label = mate_kind_name(o.kind);
|
|
if (!o.viable)
|
|
label += wxString::FromUTF8(" — ") + wxString::FromUTF8(o.reason);
|
|
menu.Append(mate_base + 1 + i, label)->Enable(o.viable);
|
|
}
|
|
|
|
// Hovering a row shows the RESULT, not a description of it (epic gap G3). preview() moves
|
|
// the body on a throwaway copy of the document, so nothing is written until the click —
|
|
// the "commit nothing until you choose" behaviour the palette was asked for.
|
|
auto drop_ghost = [this, &mate_ghost]() {
|
|
if (!mate_ghost) return;
|
|
m_viewport->clear_preview();
|
|
m_viewport->set_body_hidden(false);
|
|
m_viewport->repaint_now();
|
|
mate_ghost = false;
|
|
};
|
|
menu.Bind(wxEVT_MENU_HIGHLIGHT,
|
|
[this, cs_a, cs_b, opts, mate_base, &mate_ghost, drop_ghost](wxMenuEvent& e) {
|
|
const int i = e.GetMenuId() - (mate_base + 1);
|
|
// Off the palette (a verb row, the header, or nothing) — a stale ghost from the row
|
|
// you just left is worse than none, so it goes as soon as the cursor does.
|
|
if (i < 0 || i >= int(opts.size()) || !opts[i].viable) { drop_ghost(); return; }
|
|
std::string err;
|
|
mate_ghost = show_mate_ghost(opts[i].kind, cs_a, cs_b, 0.0, 0.0, false, err);
|
|
m_viewport->repaint_now(); // synchronous: the popup owns the loop, a queued repaint is never serviced
|
|
});
|
|
|
|
menu.Bind(wxEVT_MENU, [this, cs_a, cs_b, opts, mate_base, drop_ghost](wxCommandEvent& e) {
|
|
const int i = e.GetId() - (mate_base + 1);
|
|
if (i < 0 || i >= int(opts.size()) || !opts[i].viable) return;
|
|
drop_ghost(); // the real bodies are about to become the ghost's pose
|
|
m_doc.checkpoint(); // undo boundary: committing a mate from the offer
|
|
int idx = m_doc.add_mate(opts[i].kind, cs_a, cs_b, 0.0, 0.0, false,
|
|
"Mate" + std::to_string(++m_feature_counter));
|
|
if (idx < 0) {
|
|
m_status->SetForegroundColour(wxColour(235, 110, 110));
|
|
set_status(_L("Mate rejected"));
|
|
m_status->Refresh();
|
|
return;
|
|
}
|
|
if (!recompute_guarded(_L("Rebuilding model…")))
|
|
set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error));
|
|
else
|
|
set_status_ok();
|
|
refresh_tree();
|
|
});
|
|
}
|
|
|
|
// Hovering a row explains it. The offer is the only door to these tools now, so a bare
|
|
// name is not enough — and the hint arrives while you are still choosing.
|
|
// BOUND TO THE VERB ID RANGE, NOT THE WHOLE MENU. These two used to be unfiltered, and
|
|
// wxWidgets pushes dynamic entries to the FRONT of the handler list, so being bound LAST made
|
|
// them run FIRST for every id — including the mate rows at mate_base+1 (base+501) above. Both
|
|
// fall out of `bound`'s range there and return WITHOUT e.Skip(), which wx reads as "handled",
|
|
// so the mate handlers never ran: hovering a mate kind showed no ghost and left the previous
|
|
// status message on screen, and clicking one created nothing at all. The whole mate palette
|
|
// enumerated perfectly and fired nothing. Restricting the range keeps each half to its own ids
|
|
// regardless of bind order.
|
|
menu.Bind(wxEVT_MENU_HIGHLIGHT, [this, &bound, base](wxMenuEvent& e) {
|
|
const int i = e.GetMenuId() - base;
|
|
if (i < 0 || i >= int(bound.size()) || bound[i] == nullptr || bound[i]->hint == nullptr)
|
|
return;
|
|
m_status->SetForegroundColour(wxNullColour);
|
|
set_status(wxGetTranslation(wxString::FromUTF8(bound[i]->hint)));
|
|
m_status->Update(); // the popup owns the loop; without this the line repaints late
|
|
}, base, base + 499); // 499: the mate section starts at base + 500 (see mate_base)
|
|
menu.Bind(wxEVT_MENU, [this, &bound, base](wxCommandEvent& e) {
|
|
const int i = e.GetId() - base;
|
|
if (i >= 0 && i < int(bound.size()) && bound[i])
|
|
run_offer_action(bound[i]->action);
|
|
}, base, base + 499); // 499: the mate section starts at base + 500 (see mate_base)
|
|
PopupMenu(&menu, ScreenToClient(screen_pos));
|
|
// PopupMenu is modal, so by here the menu is gone and any command it raised has already run.
|
|
// A hover ghost that outlived the menu it belonged to would leave the committed bodies hidden
|
|
// behind a preview nothing can dismiss — bind nothing to the close event, just clean up here.
|
|
if (mate_ghost) {
|
|
m_viewport->clear_preview();
|
|
m_viewport->set_body_hidden(false);
|
|
m_viewport->request_repaint();
|
|
}
|
|
}
|
|
|
|
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;
|
|
if (m_viewport) m_viewport->set_escalate_on_repick(true); // pick abandoned
|
|
refresh_plane_labels();
|
|
}
|
|
|
|
void DesignPanel::arm_plane_pick(PlanePick target)
|
|
{
|
|
m_plane_pick = target;
|
|
// While a pick is armed, a click must CAPTURE the face under the cursor, never escalate to
|
|
// the whole body. Without this, clicking a face that already happens to be selected reads as
|
|
// a repeat pick, selects the body, and the capture is silently lost — the label stays
|
|
// "(none)" and the user has no idea why. The capture path below already restores the flag,
|
|
// and reset_plane_refs() restores it when the pick is abandoned; only the arm side was missing.
|
|
if (m_viewport) m_viewport->set_escalate_on_repick(false);
|
|
const bool face = (target == PlanePick::FaceA || target == PlanePick::FaceB);
|
|
m_status->SetForegroundColour(wxNullColour);
|
|
set_status(face ? _L("Click a solid FACE in the viewport")
|
|
: _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;
|
|
if (m_viewport) m_viewport->set_escalate_on_repick(true); // pick abandoned
|
|
refresh_axis_labels();
|
|
}
|
|
|
|
void DesignPanel::arm_axis_pick(AxisPick target)
|
|
{
|
|
m_axis_pick = target;
|
|
if (m_viewport) m_viewport->set_escalate_on_repick(false); // same as arm_plane_pick
|
|
m_status->SetForegroundColour(wxNullColour);
|
|
set_status(target == AxisPick::Face ? _L("Click a solid FACE in the viewport")
|
|
: _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::refresh_cs_body_choice()
|
|
{
|
|
if (!m_cs_body) return;
|
|
const int keep = m_cs_body->GetSelection();
|
|
m_cs_body->Clear();
|
|
m_cs_body->Append(_L("(all)"));
|
|
for (size_t b = 0; b < m_doc.bodies.size(); ++b)
|
|
m_cs_body->Append(wxString::Format(_L("Body %d"), int(b) + 1));
|
|
const int sel = (keep > 0 && keep < int(m_cs_body->GetCount())) ? keep : 0;
|
|
m_cs_body->SetSelection(sel);
|
|
// The combo and the viewport focus are ONE state, so they must not be written separately.
|
|
// When the body list shrinks, `keep` falls out of range and the selection silently drops to
|
|
// "(all)" — while the viewport stayed focused on the old index, leaving every other body at
|
|
// 25% alpha and picking locked to a body that may no longer exist. That is the exact mirror
|
|
// of the open_tool ordering bug (combo says Body N, viewport opaque); this one says "(all)"
|
|
// and stays dimmed.
|
|
//
|
|
// Guarded on the CoordSys card being the ACTIVE tool because it is the only card that owns
|
|
// this focus. In the edit path this function runs BEFORE open_tool, with the previous tool
|
|
// still active, so the guard is false and the caller's explicit set_xray_focus still wins.
|
|
if (m_viewport != nullptr && m_active == Tool::CoordSys)
|
|
m_viewport->set_xray_focus(sel - 1);
|
|
}
|
|
|
|
void DesignPanel::reset_coordsys_refs()
|
|
{
|
|
m_cs_face_body = m_cs_face = -1;
|
|
m_cs_edge = -1;
|
|
m_coordsys_pick = CoordSysPick::None;
|
|
if (m_viewport) m_viewport->set_escalate_on_repick(true); // pick abandoned
|
|
refresh_coordsys_labels();
|
|
if (m_cs_body) m_cs_body->SetSelection(0);
|
|
if (m_viewport) m_viewport->set_xray_focus(-1);
|
|
refresh_cs_body_choice();
|
|
}
|
|
|
|
void DesignPanel::arm_coordsys_pick(CoordSysPick target)
|
|
{
|
|
m_coordsys_pick = target;
|
|
// While this pick is armed the click the card asked for must reach it, so the whole-body
|
|
// escalation is off: clicking the face the card is pointing at is the ANSWER here, not a
|
|
// request for its body.
|
|
if (m_viewport) m_viewport->set_escalate_on_repick(false);
|
|
m_status->SetForegroundColour(wxNullColour);
|
|
set_status(target == CoordSysPick::Face ? _L("Click a solid FACE in the viewport")
|
|
: _L("Click a solid EDGE in the viewport"));
|
|
m_status->Refresh();
|
|
}
|
|
|
|
bool DesignPanel::on_add_plane()
|
|
{
|
|
// Refuse here rather than let the kernel substitute. Every method in CadDocument's plane
|
|
// dispatch falls back to offset_angle_plane() when its references are missing, so picking
|
|
// Tangent and confirming with nothing selected used to produce an offset plane reported as
|
|
// a success — the user asks for one construction and silently receives another. The kernel
|
|
// keeps its fallback (it must return SOMETHING), but no user gesture should reach it.
|
|
auto refuse = [this](const wxString& why) {
|
|
m_status->SetForegroundColour(wxColour(235, 110, 110));
|
|
set_status(why);
|
|
m_status->Refresh();
|
|
};
|
|
switch ((PlaneType)m_plane_type->GetSelection()) {
|
|
case PlaneType::Angle:
|
|
if (m_pl_edgeA < 0) { refuse(_L("An angled plane needs an edge to tilt about — pick Edge A")); return false; }
|
|
break;
|
|
case PlaneType::Midplane:
|
|
if (m_pl_faceA < 0 || m_pl_faceB < 0) { refuse(_L("A midplane needs two faces — pick Face A and Face B")); return false; }
|
|
if (m_pl_faceA == m_pl_faceB && m_pl_faceA_body == m_pl_faceB_body) {
|
|
// Well-defined but useless: the midplane of a face with itself is that same face.
|
|
refuse(_L("Face A and Face B are the same face — a midplane needs two different faces"));
|
|
return false;
|
|
}
|
|
break;
|
|
case PlaneType::Tangent:
|
|
// The face must also be cylindrical; that check stays in the kernel, which has the geometry.
|
|
if (m_pl_faceA < 0) { refuse(_L("A tangent plane needs a cylindrical face — pick Face A")); return false; }
|
|
break;
|
|
case PlaneType::TwoEdges:
|
|
if (m_pl_edgeA < 0 || m_pl_edgeB < 0) { refuse(_L("This plane needs two edges — pick Edge A and Edge B")); return false; }
|
|
break;
|
|
default:
|
|
break; // Offset and Coincident are meaningful with no reference: they use the base 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);
|
|
set_status(_L("Plane added — pick it as a sketch plane"));
|
|
refresh_tree();
|
|
return true;
|
|
}
|
|
|
|
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);
|
|
set_status(_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);
|
|
set_status(_L("Coord Sys added"));
|
|
refresh_tree();
|
|
}
|
|
|
|
void DesignPanel::on_add_shell()
|
|
{
|
|
if (m_doc.body.IsNull()) {
|
|
set_status(_L("Shell needs a solid body — add or import one first"));
|
|
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…")))
|
|
set_status(_L("Recompute error: ") + wxString::FromUTF8(m_doc.error));
|
|
else
|
|
set_status_ok();
|
|
|
|
refresh_tree();
|
|
}
|
|
|
|
void DesignPanel::on_add_draft()
|
|
{
|
|
if (m_doc.body.IsNull()) {
|
|
set_status(_L("Draft needs a solid body — add or import one first"));
|
|
return;
|
|
}
|
|
if (m_sel_solid_face < 0) {
|
|
m_status->SetForegroundColour(wxColour(235, 110, 110));
|
|
set_status(_L("Draft needs a picked face — click a side face first"));
|
|
m_status->Refresh();
|
|
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…")))
|
|
set_status(_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;
|
|
}
|
|
|
|
// The reason detect_mate_conflicts() recorded for this feature, or nullptr. A linear scan: an
|
|
// assembly has a handful of mates and at most that many conflicts, so a map would cost more to
|
|
// build than the scans it saves.
|
|
const std::string* DesignPanel::mate_conflict_reason(int feature) const
|
|
{
|
|
for (const auto& c : m_doc.mate_conflicts)
|
|
if (c.first == feature) return &c.second;
|
|
return nullptr;
|
|
}
|
|
|
|
// What to say when nothing is selected and no tool is open. Lives in one place because it is
|
|
// needed from two: after an edit empties the document, and at startup — where after_tree_edit()
|
|
// has never run, which is exactly why a fresh tab used to show a blank line.
|
|
wxString DesignPanel::idle_hint() const
|
|
{
|
|
return m_doc.features.empty()
|
|
? _L("Nothing yet — import a STEP or a mesh from the toolbar,\n"
|
|
"or click a reference plane and right-click it to start a sketch.")
|
|
: _L("No solid yet — select a sketch and right-click it to Extrude.");
|
|
}
|
|
|
|
// The Design tab is no longer the visible page (snaporca-dlj). The status line is a popup floating
|
|
// over the GL canvas, so it does NOT go away when this page does — it stayed up over Prepare and
|
|
// over the home screen, still reading like a live Design selection ("selected (whole body) —
|
|
// right-click for what applies to it") on a tab that has no such selection and no such menu.
|
|
// Nothing else in the panel needs to know: the popup keeps its text and comes straight back.
|
|
void DesignPanel::on_tab_hidden()
|
|
{
|
|
if (m_viewport) {
|
|
m_viewport->show_status_hud(false);
|
|
m_viewport->leave_viewport(); // hand the shared camera back to the editor tabs
|
|
}
|
|
}
|
|
|
|
void DesignPanel::on_tab_shown()
|
|
{
|
|
if (m_viewport) {
|
|
m_viewport->show_status_hud(true); // ...and back on the way in
|
|
m_viewport->enter_viewport(); // borrow the shared camera; on_tab_hidden gives it back
|
|
}
|
|
|
|
if (m_active == Tool::None && m_doc.display_mesh.its.indices.empty())
|
|
set_status(idle_hint()); // first paint: the tab has never been edited
|
|
|
|
if (m_viewport) m_viewport->refresh_bed();
|
|
|
|
// Modeling origin = bed centre, set BEFORE any recompute/datum-resolve so sketches and datums
|
|
// land in the middle of the bed (not the bed corner = world 0).
|
|
if (Plater* pl = wxGetApp().plater()) {
|
|
const Vec2d bc = pl->build_volume().bed_center();
|
|
m_doc.modeling_origin = Vec3d(bc.x(), bc.y(), 0.0);
|
|
}
|
|
|
|
// Rehydrate the parametric model from a freshly loaded project (the 3MF carried the
|
|
// recipe in Metadata/orca_cad.bin). Only when nothing is in progress here, so we
|
|
// never clobber an active design when the user just toggles back to the Design tab.
|
|
if (m_doc.features.empty()) {
|
|
if (Plater* plater = wxGetApp().plater()) {
|
|
const std::string& blob = plater->model().cad_recipe;
|
|
if (!blob.empty()) load_recipe(blob);
|
|
}
|
|
}
|
|
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)) {
|
|
// Carry the kernel's reason. deserialize_recipe distinguishes three cases that matter
|
|
// very differently to the person reading this — saved by a NEWER build, saved by an
|
|
// OLDER one, or genuinely unreadable — and replacing all three with one sentence left
|
|
// the user unable to tell "update OrcaSlicer" from "your file is damaged". Same
|
|
// error-loss class as the 31 McpControl sites (1de72de9ed): the message exists, it was
|
|
// simply not passed on.
|
|
m_status->SetForegroundColour(wxColour(235, 110, 110));
|
|
set_status(m_doc.error.empty()
|
|
? _L("Could not restore the CAD model from this project")
|
|
: _L("Could not restore the CAD model: ") + wxString::FromUTF8(m_doc.error));
|
|
m_status->Refresh();
|
|
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()
|
|
{
|
|
// The recipe mirrors the FEATURE LIST, and this is the moment the feature list changed —
|
|
// every add, delete, reorder, rename and suppression ends here to redraw the tree. Putting
|
|
// the sync in recompute_guarded instead tied it to "a solid was built", and CadDocument::
|
|
// recompute() returns FALSE for a document that has no solid ("no solid-producing features",
|
|
// CadDocument.cpp) — which is precisely a document the user has only drawn sketches in. So
|
|
// drawing a profile, pressing Confirm and saving wrote a 3MF with no orca_cad.bin in it
|
|
// at all, and the app reported success: the whole design was gone on reopen (snaporca-mtav).
|
|
// The three sites that say "a lone sketch yields an empty body; that is expected" call
|
|
// m_doc.recompute() directly and so never reached the sync either. One hook here covers all
|
|
// of them, including the live sketch tool's own commit path.
|
|
//
|
|
// ONLY when the document has something in it. sync_recipe_to_model() CLEARS the blob for an
|
|
// empty document, and the tree is also refreshed while the Design tab is still empty — before
|
|
// the deferred load at on_show() has had the chance to read the blob the project arrived
|
|
// with. Clearing there would destroy the recipe of every project being opened. Deleting the
|
|
// last feature still clears it, through the tree-edit call site that always did.
|
|
if (!m_doc.features.empty()) sync_recipe_to_model();
|
|
|
|
// Preserve the selected row across the rebuild — wxTreeCtrl::DeleteAllItems
|
|
// drops the selection, which made every edit/add feel like it "lost" the
|
|
// selection (and broke Edit/Move/Delete on the just-touched feature).
|
|
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 (size_t fi = 0; fi < m_doc.features.size(); ++fi) {
|
|
const CadFeature& f = m_doc.features[fi];
|
|
const int img = tree_icon_for(f.type);
|
|
wxTreeItemId id = m_tree->AppendItem(root, wxString::FromUTF8(f.name), img, img);
|
|
// Three states, in this order of precedence:
|
|
// disabled -> dim. A SUPPRESSED mate is the user's answer to a conflict, so it must
|
|
// read as suppressed rather than keep shouting about the conflict.
|
|
// conflict -> warn. detect_mate_conflicts() refills m_doc.mate_conflicts on every
|
|
// recompute; the mark goes on the row that carries it, because the tree
|
|
// is where the user is already looking for which feature to change.
|
|
// otherwise -> normal.
|
|
// A conflict is NOT a document error — the document still evaluates — so the row is
|
|
// marked and never hidden, and the reason goes to the status line on selection rather
|
|
// than into a modal that interrupts without offering an action.
|
|
m_tree->SetItemTextColour(id, !f.enabled ? dp_item_dim()
|
|
: mate_conflict_reason(int(fi)) != nullptr ? wxColour(235, 110, 110)
|
|
: dp_item_text());
|
|
m_tree_items.push_back(id);
|
|
}
|
|
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) {
|
|
// "Body N" keeps the positional identity every status line and message uses ("Body 2
|
|
// selected", the interference report), and the NAME follows it because that is the part
|
|
// a rename can change: a body's name is derived from the feature that produced it, so
|
|
// renaming through this row and seeing the label sit unchanged at "Body 1" would read as
|
|
// a rename that did nothing.
|
|
const bool vis = b >= m_body_visible.size() || m_body_visible[b];
|
|
// The user's name wins over the derived one (the maker's name, restamped every
|
|
// recompute); "Body N" still leads, because every status line, the interference report
|
|
// and the mate errors identify a body by its number.
|
|
const wxString bname = m_doc.bodies[b].has_user_name
|
|
? wxString::FromUTF8(m_doc.bodies[b].user_name)
|
|
: wxString::FromUTF8(m_doc.bodies[b].name);
|
|
wxTreeItemId id = m_parts->AppendItem(proot,
|
|
bname.IsEmpty() ? wxString::Format(_L("Body %zu"), b + 1)
|
|
: wxString::Format(_L("Body %zu — %s"), b + 1, bname));
|
|
// Hidden bodies are greyed so the show/hide state reads at a glance (eye toggle).
|
|
m_parts->SetItemTextColour(id, vis ? dp_item_text() : dp_item_dim());
|
|
m_tree_body_items.push_back(id);
|
|
}
|
|
|
|
// 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);
|
|
set_status(_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);
|
|
set_status(_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);
|
|
set_status(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()
|
|
{
|
|
// The body count just changed, so the tools that consume a body may have become reachable or
|
|
// unreachable. Before the early return below: the gating is about the toolbar, not the canvas.
|
|
update_body_gates();
|
|
// 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));
|
|
set_status(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);
|
|
set_status(_L("Drag the arrows to move, the rings to rotate — then Confirm (Esc cancels)"));
|
|
m_status->Refresh();
|
|
}
|
|
|
|
// Transform card (add mode): arm the same move gizmo on the card's body so the geometry-first
|
|
// control is what the user drags, while the card's numeric fields mirror the result. The card
|
|
// stays visible as the typed half — the gizmo owns the drag, the fields own the numbers.
|
|
void DesignPanel::arm_transform_gizmo()
|
|
{
|
|
int b = tree_body_selection();
|
|
if (b < 0) b = m_sel_solid_body;
|
|
if (b < 0 && m_xf_body) b = m_xf_body->GetSelection();
|
|
if (b < 0 || b >= int(m_doc.display_body_meshes.size())) return; // card alone still works
|
|
if (m_xf_body && b < int(m_xf_body->GetCount())) m_xf_body->SetSelection(b); // feature must be created against the gizmo's body
|
|
sync_body_xform();
|
|
const Transform3d base = m_body_xform[b];
|
|
const BoundingBoxf3 bb = m_doc.display_body_meshes[b].bounding_box();
|
|
const Vec3d pivot = base * bb.center();
|
|
const double radius = (base.linear() * (bb.size() * 0.5)).norm();
|
|
if (m_viewport) m_viewport->begin_move_body(b, pivot, base, radius);
|
|
m_xf_gizmo_body = b;
|
|
m_xf_gizmo_base = base;
|
|
m_move_body = b; // the existing revert paths key off these two
|
|
m_move_prev = base;
|
|
// Write the pivot into the card so the parametric feature reproduces what was dragged;
|
|
// dx/dy/dz and angle start at zero (the gizmo reports deltas from this pose).
|
|
if (m_xf_pivot_x) m_xf_pivot_x->SetValue(pivot.x());
|
|
if (m_xf_pivot_y) m_xf_pivot_y->SetValue(pivot.y());
|
|
if (m_xf_pivot_z) m_xf_pivot_z->SetValue(pivot.z());
|
|
if (m_xf_dx) m_xf_dx->SetValue(0.0);
|
|
if (m_xf_dy) m_xf_dy->SetValue(0.0);
|
|
if (m_xf_dz) m_xf_dz->SetValue(0.0);
|
|
if (m_xf_angle) m_xf_angle->SetValue(0.0);
|
|
m_status->SetForegroundColour(wxNullColour);
|
|
set_status(_L("Drag the arrows to move, the rings to rotate — the numbers follow"));
|
|
m_status->Refresh();
|
|
}
|
|
|
|
// 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));
|
|
set_status(_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);
|
|
set_status(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));
|
|
set_status(_L("Click a face on the solid, 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);
|
|
set_status(_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));
|
|
set_status(_L("Edit rejected: ") + wxString::FromUTF8(m_doc.error));
|
|
m_status->Refresh();
|
|
return;
|
|
}
|
|
sync_recipe_to_model(); // deletes, reorders and suppressions change the document too
|
|
m_status->SetForegroundColour(wxNullColour);
|
|
if (m_doc.display_mesh.its.indices.empty()) {
|
|
if (m_viewport != nullptr) m_viewport->clear_mesh();
|
|
sync_sketch_display(); // empty body: show any un-consumed committed sketch
|
|
// An empty document used to blank this line — no guidance at the one moment a newcomer
|
|
// has none. Name the three real ways in, in the order they are reachable on screen.
|
|
set_status(idle_hint());
|
|
} 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;
|
|
clear_document();
|
|
}
|
|
|
|
// The teardown behind New Design, without the confirmation. Also what New Project / Open
|
|
// Project run through Plater::priv::reset: the document lives here rather than in the Model,
|
|
// so without this it survives the project that produced it and the next Design edit writes
|
|
// the previous project's feature tree into the new one.
|
|
void DesignPanel::clear_document()
|
|
{
|
|
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();
|
|
}
|
|
|
|
// The verb the offer names when you point at a body, or at any face/edge/vertex of one. A body
|
|
// is a recomputed RESULT, so what actually gets deleted is the feature that created it
|
|
// (CadBody::source_feature). That is an edit to the recipe and can take other features with it,
|
|
// so it asks first and NAMES the feature: a body disappearing from the viewport is not by itself
|
|
// evidence of which feature went, and this is the one action here that cannot be eyeballed.
|
|
void DesignPanel::on_delete_body()
|
|
{
|
|
const int nb = int(m_doc.bodies.size());
|
|
if (m_sel_solid_body < 0 || m_sel_solid_body >= nb) {
|
|
m_status->SetForegroundColour(wxColour(235, 110, 110));
|
|
set_status(_L("Select a body first"));
|
|
m_status->Refresh();
|
|
return;
|
|
}
|
|
const int src = m_doc.bodies[m_sel_solid_body].source_feature;
|
|
if (src < 0 || src >= int(m_doc.features.size())) {
|
|
// Only reachable for a body no feature claims — a stale recipe, or a feature type that
|
|
// broke the "never replace a whole CadBody" invariant recompute() relies on.
|
|
m_status->SetForegroundColour(wxColour(235, 110, 110));
|
|
set_status(_L("This body has no feature to delete — use New Design to start over"));
|
|
m_status->Refresh();
|
|
return;
|
|
}
|
|
const std::string& raw = m_doc.features[src].name;
|
|
const wxString fname = raw.empty() ? wxString::Format(_L("feature %d"), src + 1)
|
|
: wxString::FromUTF8(raw);
|
|
if (wxMessageBox(wxString::Format(
|
|
_L("Delete %s?\n\nThat is the feature this body was made from. "
|
|
"Features built on it may be removed or stop working."), fname),
|
|
_L("Delete body"), wxYES_NO | wxNO_DEFAULT | wxICON_QUESTION, this) != wxYES)
|
|
return;
|
|
// A card left open over a feature that is about to vanish goes stale — same reason
|
|
// on_delete_feature() closes it.
|
|
if (m_active != Tool::None || m_edit_index >= 0) {
|
|
reset_edit_state();
|
|
close_tool();
|
|
}
|
|
m_doc.checkpoint(); // undo boundary: deleting a body's feature
|
|
m_sel_solid_body = m_sel_solid_face = m_sel_solid_edge = -1; // the selection is about to
|
|
m_sel_solid_vertex = false; // name a body that is gone
|
|
after_tree_edit(m_doc.remove_feature(src));
|
|
}
|
|
|
|
void DesignPanel::on_delete_feature()
|
|
{
|
|
// A Body row has no directly-removable feature (bodies are recomputed results); guide the
|
|
// user to delete the feature that created it, or use New Design to wipe everything.
|
|
if (tree_body_selection() >= 0) {
|
|
m_status->SetForegroundColour(wxColour(235, 110, 110));
|
|
set_status(_L("Select the FEATURE that created this body (or use New Design)"));
|
|
m_status->Refresh();
|
|
return;
|
|
}
|
|
int sel = tree_selection();
|
|
if (sel == wxNOT_FOUND) {
|
|
set_status(_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();
|
|
// Keep the row selected for repeat toggles. m_parts, NOT m_tree: these ids belong
|
|
// to the Bodies list, and handing a foreign item to the feature tree left the row
|
|
// unselected — so the second press of the eye found tree_body_selection() == -1 and
|
|
// fell through to the FEATURE-level branch below instead of un-hiding the body.
|
|
if (m_parts != nullptr && bsel < int(m_tree_body_items.size()))
|
|
m_parts->SelectItem(m_tree_body_items[bsel]);
|
|
m_status->SetForegroundColour(wxNullColour);
|
|
set_status(wxString::Format(now_visible ? _L("Body %d shown")
|
|
: _L("Body %d hidden"), bsel + 1));
|
|
m_status->Refresh();
|
|
}
|
|
return;
|
|
}
|
|
|
|
int sel = tree_selection();
|
|
if (sel == wxNOT_FOUND || sel >= int(m_doc.features.size())) {
|
|
m_status->SetForegroundColour(wxColour(235, 110, 110));
|
|
set_status(_L("Select a feature in the tree first"));
|
|
m_status->Refresh();
|
|
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);
|
|
set_status(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) {
|
|
set_status(_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));
|
|
set_status(_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();
|
|
// Fall back to the sketch owning the region picked in the viewport. The offer menu reaches
|
|
// this verb from a SkLoop selection (a region clicked on screen), which carries no tree
|
|
// selection — without this, choosing "Constrain sketch" from the offer would answer
|
|
// "Select a sketch in the tree first" about a sketch the user has visibly selected.
|
|
if ((sel == wxNOT_FOUND || sel >= int(m_doc.features.size())) && m_sel_sketch_feat >= 0
|
|
&& m_sel_sketch_feat < int(m_doc.features.size())) {
|
|
sel = m_sel_sketch_feat;
|
|
set_tree_selection(sel); // keep the tree in step with what the viewport says
|
|
}
|
|
if (sel == wxNOT_FOUND || sel >= int(m_doc.features.size())) {
|
|
m_status->SetForegroundColour(wxColour(235, 110, 110));
|
|
set_status(_L("Select a sketch in the tree first"));
|
|
m_status->Refresh();
|
|
return;
|
|
}
|
|
CadFeature& f = m_doc.features[sel];
|
|
if (f.type != CadFeatureType::Sketch) {
|
|
m_status->SetForegroundColour(wxColour(235, 110, 110));
|
|
set_status(_L("Selected feature is not a sketch"));
|
|
m_status->Refresh();
|
|
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);
|
|
set_status(_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));
|
|
set_status(_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);
|
|
set_status(_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));
|
|
set_status(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();
|
|
// Undo boundary: adding a constraint. Without it Ctrl+Z reached PAST this edit to the
|
|
// previous boundary and threw away whatever happened in between — a constraint was the
|
|
// one document mutation the user could not take back on its own.
|
|
m_doc.checkpoint();
|
|
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_doc.abandon_checkpoint(); // fully restored above: nothing happened, so nothing to undo
|
|
m_status->SetForegroundColour(wxColour(235, 110, 110));
|
|
set_status(_L("Constraint rejected (over-constrained)"));
|
|
m_status->Refresh();
|
|
return;
|
|
}
|
|
m_doc.recompute();
|
|
// The constraint lives in the recipe, so the save path has to be told the recipe moved;
|
|
// otherwise saving after a constraint edit wrote the blob from before it.
|
|
sync_recipe_to_model();
|
|
update_undo_redo_buttons();
|
|
m_viewport->update_constrain_entities(m_doc.features[m_constrain_feat].entities);
|
|
if (!m_doc.display_mesh.its.indices.empty())
|
|
feed_bodies();
|
|
m_status->SetForegroundColour(wxNullColour);
|
|
set_status(_L("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_cards, 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_cards, 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_cards, 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;
|
|
m_doc.checkpoint(); // undo boundary: deleting a constraint
|
|
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();
|
|
sync_recipe_to_model(); // the removal is part of the recipe
|
|
update_undo_redo_buttons();
|
|
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);
|
|
set_status(_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));
|
|
set_status(_L("Press Constrain on a sketch first"));
|
|
m_status->Refresh();
|
|
return;
|
|
}
|
|
auto fail = [this](const wxString& msg) {
|
|
m_status->SetForegroundColour(wxColour(235, 110, 110));
|
|
set_status(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));
|
|
set_status(_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));
|
|
set_status(_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));
|
|
set_status(_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));
|
|
set_status(_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));
|
|
set_status(_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));
|
|
set_status(_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));
|
|
set_status(_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));
|
|
set_status(_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);
|
|
set_status(_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);
|
|
set_status(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);
|
|
set_status(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));
|
|
set_status(_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));
|
|
set_status(_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));
|
|
set_status(_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;
|
|
m_doc.checkpoint(); // undo boundary: adding a constraint (profile sketches)
|
|
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_doc.abandon_checkpoint(); // restored: no state change, so no undo step
|
|
m_status->SetForegroundColour(wxColour(235, 110, 110));
|
|
set_status(_L("Constraint rejected (over-constrained)"));
|
|
m_status->Refresh();
|
|
return;
|
|
}
|
|
m_doc.recompute();
|
|
sync_recipe_to_model();
|
|
update_undo_redo_buttons();
|
|
m_viewport->update_constrain_profile(m_doc.features[m_constrain_feat].profile.points);
|
|
if (!m_doc.display_mesh.its.indices.empty())
|
|
feed_bodies();
|
|
m_status->SetForegroundColour(wxNullColour);
|
|
set_status(type == SketchConstraintType::Horizontal ? _L("Applied Horizontal")
|
|
: _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_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
|
|
sync_dressup_target(); // and say which of the two the re-edit is targeting
|
|
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);
|
|
// Re-latch the on-face state FROM THE STORED FEATURE (snaporca-uif9). m_hole_on_face is
|
|
// only ever cleared by the Hole flyout and by the plane combo, so after any on-face hole
|
|
// it stays true — and a re-edit then drilled on whatever face was latched last, which may
|
|
// be a different face, a different body, or a body since rebuilt. f is the only source
|
|
// guaranteed to describe THIS hole. Not the dropdown row just set above: index_from_plane
|
|
// snaps an arbitrary face plane to the nearest XY/XZ/YZ, so driving the re-edit from the
|
|
// row would MOVE a hole drilled on a slanted or offset face.
|
|
m_hole_on_face = !is_base_plane(f.plane, m_doc.modeling_origin);
|
|
m_hole_face_plane = f.plane;
|
|
m_hole_face_body = m_hole_on_face ? f.target_body : -1;
|
|
// The face's (u,v) extent is not serialized, so the gizmo's footprint clamp has nothing
|
|
// to stand on. Unbounded is the honest state; the stale bounds of another face are not.
|
|
m_hole_has_bounds = false;
|
|
if (m_hole_target_label)
|
|
m_hole_target_label->SetLabel(m_hole_on_face
|
|
// No face index survives in the feature, and inventing one would be worse than
|
|
// saying so. "(none — uses Hole plane)" is the one thing that is definitely false.
|
|
? (f.target_body >= 0 ? wxString::Format(_L("On a face of Body %d"), f.target_body + 1)
|
|
: _L("On a stored face"))
|
|
: _L("(none — uses Hole plane)"));
|
|
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
|
|
// Same latch, same failure, same fix as Hole above (snaporca-uif9).
|
|
m_thread_on_face = !is_base_plane(f.plane, m_doc.modeling_origin);
|
|
m_thread_face_plane = f.plane;
|
|
m_thread_face_body = m_thread_on_face ? f.target_body : -1;
|
|
if (m_thread_target_label)
|
|
m_thread_target_label->SetLabel(m_thread_on_face
|
|
? (f.target_body >= 0 ? wxString::Format(_L("On a face of Body %d"), f.target_body + 1)
|
|
: _L("On a stored face"))
|
|
: _L("(none — uses Thread plane)"));
|
|
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;
|
|
if (m_viewport) m_viewport->set_escalate_on_repick(true); // pick abandoned
|
|
if (m_viewport) m_viewport->set_escalate_on_repick(true); // pick abandoned
|
|
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;
|
|
if (m_viewport) m_viewport->set_escalate_on_repick(true); // pick abandoned
|
|
if (m_viewport) m_viewport->set_escalate_on_repick(true); // pick abandoned
|
|
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;
|
|
if (m_viewport) m_viewport->set_escalate_on_repick(true); // pick abandoned
|
|
if (m_viewport) m_viewport->set_escalate_on_repick(true); // pick abandoned
|
|
refresh_coordsys_labels();
|
|
refresh_cs_body_choice();
|
|
if (m_cs_body && f.coordsys_body >= 0) {
|
|
m_cs_body->SetSelection(f.coordsys_body + 1);
|
|
if (m_viewport) m_viewport->set_xray_focus(f.coordsys_body);
|
|
}
|
|
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::Cut:
|
|
// Same as-of-timeline reasoning as Boolean: a Cut splits one body into two, so the
|
|
// live body list does not match the one this feature's target index was recorded
|
|
// against. Replay to just before it and the stored index lands on the right entry.
|
|
populate_body_choices(m_edit_index);
|
|
if (f.target_body >= 0 && f.target_body < int(m_cut_target->GetCount()))
|
|
m_cut_target->SetSelection(f.target_body);
|
|
else if (m_cut_target->GetCount() > 0)
|
|
m_cut_target->SetSelection(0);
|
|
populate_plane_choices(m_cut_plane);
|
|
m_cut_plane->SetSelection(index_from_plane(f.plane));
|
|
m_cut_offset->SetValue(f.cut_offset);
|
|
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: {
|
|
fill_body_choice(m_xf_body, m_edit_index, f.target_body);
|
|
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: {
|
|
fill_body_choice(m_mirror_body, m_edit_index, f.target_body);
|
|
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: {
|
|
fill_body_choice(m_thicken_body, m_edit_index, f.target_body);
|
|
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: {
|
|
fill_body_choice(m_rib_body, m_edit_index, f.target_body);
|
|
{
|
|
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 = combo_append_index(m_rib_sketch,
|
|
wxString::FromUTF8(m_doc.features[i].name), 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: {
|
|
fill_body_choice(m_proj_source_body, m_edit_index, f.project_source_body);
|
|
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: {
|
|
fill_body_choice(m_del_face_body, m_edit_index, f.target_body);
|
|
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) {
|
|
set_status(_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);
|
|
set_status(_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;
|
|
case CadFeatureType::Cut:
|
|
m_edit_index = sel;
|
|
load_feature_into_dialog(f);
|
|
open_tool(Tool::Cut);
|
|
break;
|
|
case CadFeatureType::Import:
|
|
// An imported solid has no parameters to re-edit: its geometry is rigid data read
|
|
// from the file, not something rebuilt from numbers. Repositioning it is Transform's
|
|
// job, and that feature already exists — so point there rather than invent a dialog
|
|
// that would only duplicate it. (Imported 2D Text/SVG art is different and IS
|
|
// re-editable; it arrives as a Sketch feature with imported_regions, handled above.)
|
|
m_status->SetForegroundColour(wxNullColour);
|
|
set_status(_L("An imported solid has no parameters — use Transform to move or rotate it"));
|
|
m_status->Refresh();
|
|
break;
|
|
default:
|
|
// Every CadFeatureType now has a case. Kept as a guard so a type added later
|
|
// announces itself instead of silently swallowing the Edit click.
|
|
m_status->SetForegroundColour(wxNullColour);
|
|
set_status(_L("This feature type can't be edited yet"));
|
|
m_status->Refresh();
|
|
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);
|
|
set_status(_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));
|
|
set_status(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()) {
|
|
set_status(_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
|
|
set_status(_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.
|
|
sync_recipe_to_model();
|
|
|
|
if (wxGetApp().mainframe != nullptr)
|
|
wxGetApp().mainframe->select_tab(TAB_ID_PREPARE);
|
|
}
|
|
|
|
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;
|
|
// The plane is STRUCTURAL, like Extrude's profile source. While EDITING it is preserved
|
|
// from the seeded original — the card carries no plane control and the old combo silently
|
|
// collapsed a face plane to a base plane through the modeling origin. While ADDING it
|
|
// comes from what is picked in the viewport. snaporca-e1p.
|
|
if (!editing) { wxString where; f.plane = sketch_plane_from_selection(where); }
|
|
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).
|
|
// HAZARD: this is a negative list, so a tool that picks its own body is broken by OMISSION —
|
|
// the card computes target_body and this line then throws it away. SurfaceOffset and
|
|
// ThickenSurface were missing: both read a SHEET body from their own combo and both had it
|
|
// overwritten with the picked SOLID's index. Any new card that picks a body belongs here.
|
|
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 && m_active != Tool::SurfaceOffset
|
|
&& m_active != Tool::ThickenSurface)
|
|
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.
|
|
// Keep the Dress-up card honest about what Confirm will actually round: the single edge the
|
|
// user picked in the viewport, or the face-group. build_dressup reads m_sel_solid_edge first and
|
|
// only falls back to the group, so when an edge is picked the group combo is inert — grey it out
|
|
// rather than leave it showing a value it will not use.
|
|
void DesignPanel::sync_dressup_target()
|
|
{
|
|
if (m_dressup_edge_label == nullptr) return;
|
|
const bool have_edge = (m_sel_solid_edge >= 0);
|
|
m_dressup_edge_label->SetLabel(have_edge
|
|
? wxString::Format(_L("Edge %d"), m_sel_solid_edge)
|
|
: _L("(no edge picked — group below)"));
|
|
if (m_face_group != nullptr) m_face_group->Enable(!have_edge);
|
|
m_dressup_edge_label->Refresh();
|
|
}
|
|
|
|
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.
|
|
// Grey the FEATURE buttons whose tool cannot run yet, and say why in the tooltip (snaporca-o9j).
|
|
// Tommaso reported the array controls as MISSING; they were not, but Pattern with no body
|
|
// accepted the click, opened nothing, and wrote its refusal somewhere other than where the click
|
|
// happened — from the user's seat that is indistinguishable from a dead button. A control that
|
|
// cannot act should look like it cannot act, before it is pressed.
|
|
//
|
|
// Only the three top-level buttons that carry a body-count guard are gated. The same guard also
|
|
// appears on rows INSIDE the flyouts, and those stay live: a drawer holds sketch-only entries too,
|
|
// so disabling the drawer would hide tools that are perfectly usable. Their refusal message is
|
|
// still written, and now next to the geometry.
|
|
//
|
|
// The keyboard shortcuts (Shift+N / Shift+X / Shift+B) deliberately keep running the guarded
|
|
// action rather than being gated: a key press has no greyed-out state to see, so the sentence is
|
|
// the only feedback there is.
|
|
void DesignPanel::update_body_gates()
|
|
{
|
|
const int n = int(m_doc.bodies.size());
|
|
for (const BodyGate& g : m_body_gates) {
|
|
if (g.btn == nullptr) continue;
|
|
const bool live = n >= g.min_bodies;
|
|
g.btn->Enable(live);
|
|
g.btn->SetToolTip(live ? g.tip_live : g.tip_gated);
|
|
}
|
|
}
|
|
|
|
// 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_active != Tool::SurfaceExtrude
|
|
&& m_active != Tool::Thicken && m_active != Tool::Rib
|
|
&& m_active != Tool::SurfaceOffset && m_active != Tool::ThickenSurface) {
|
|
m_viewport->clear_extrude_gizmo(); return;
|
|
}
|
|
|
|
// SurfaceOffset and ThickenSurface: one distance each, applied to a whole SHEET body chosen
|
|
// from a combo. A sheet has no single normal, so there is no frame to anchor an arrow on —
|
|
// which is why these were the two tools left with no handle at all.
|
|
//
|
|
// The way out is not to change what the tool operates on. Both still offset or thicken the
|
|
// ENTIRE sheet named in the combo; the picked face only says WHERE TO STAND THE ARROW. So
|
|
// the arrow appears whenever the user has a face of that sheet under selection, and its
|
|
// absence costs nothing — the card alone still works exactly as before. Purely additive, so
|
|
// no existing flow changes and there is no new precondition to learn.
|
|
if (m_active == Tool::SurfaceOffset || m_active == Tool::ThickenSurface) {
|
|
const bool off = (m_active == Tool::SurfaceOffset);
|
|
const int sheet = sheet_choice_body(off ? m_surf_offset_body : m_surf_thicken_body);
|
|
// Only when the picked face belongs to the sheet the tool will actually act on:
|
|
// an arrow standing on a different body would name the wrong thing.
|
|
if (sheet < 0 || sheet >= int(m_doc.bodies.size())
|
|
|| m_sel_solid_body != sheet || m_sel_solid_face < 0) {
|
|
m_viewport->clear_extrude_gizmo(); return;
|
|
}
|
|
TopoDS_Face f = GeometryEngine::face_by_index(m_doc.bodies[sheet].shape, m_sel_solid_face);
|
|
if (f.IsNull()) { m_viewport->clear_extrude_gizmo(); return; }
|
|
SketchPlane plane = SketchPlane::from_face(f);
|
|
Vec3d c = GeometryEngine::face_centroid_world(f);
|
|
Vec3d n = GeometryEngine::face_normal_world(f);
|
|
if (f.Orientation() == TopAbs_REVERSED) n = -n;
|
|
sync_body_xform();
|
|
if (sheet < int(m_body_xform.size())) {
|
|
c = m_body_xform[sheet] * c;
|
|
n = m_body_xform[sheet].linear() * n;
|
|
}
|
|
plane.origin = c;
|
|
if (n.norm() > 1e-9) plane.normal = n.normalized();
|
|
wxSpinCtrlDouble* spin = off ? m_surf_offset_distance : m_surf_thicken_thickness;
|
|
m_viewport->set_extrude_gizmo(plane, Vec2d(0, 0), spin ? spin->GetValue() : 0.0,
|
|
0.0, false,
|
|
!off && m_surf_thicken_flip && m_surf_thicken_flip->GetValue());
|
|
return;
|
|
}
|
|
|
|
if (m_active == Tool::Rib) {
|
|
// Rib's DEPTH is a distance along the sketch plane normal — the same arrow again,
|
|
// anchored at the midpoint of the line the rib is built on rather than at a profile
|
|
// centroid, because a rib's line is the whole profile.
|
|
//
|
|
// This covers only half of L2 for Rib: the THICKNESS is an in-plane offset either side
|
|
// of that line and has no handle, which needs an affordance that does not exist yet.
|
|
// Half a tool made draggable is still strictly better than none — the remaining half is
|
|
// filed separately rather than left implied.
|
|
const int ssel = m_rib_sketch ? m_rib_sketch->GetSelection() : wxNOT_FOUND;
|
|
const int ref = (ssel != wxNOT_FOUND)
|
|
? int(reinterpret_cast<intptr_t>(m_rib_sketch->GetClientData(ssel))) : -1;
|
|
if (ref < 0 || ref >= int(m_doc.features.size())) { m_viewport->clear_extrude_gizmo(); return; }
|
|
const CadFeature& sk = m_doc.features[ref];
|
|
const int ei = m_rib_entity ? m_rib_entity->GetValue() : 0;
|
|
if (ei < 0 || ei >= int(sk.entities.size())) { m_viewport->clear_extrude_gizmo(); return; }
|
|
const SketchEntity& e = sk.entities[ei];
|
|
const Vec2d mid = (e.type == SketchEntity::Type::Line) ? Vec2d(0.5 * (e.p0 + e.p1))
|
|
: Vec2d(e.center);
|
|
m_viewport->set_extrude_gizmo(sk.plane, mid,
|
|
m_rib_depth ? m_rib_depth->GetValue() : 0.0,
|
|
0.0, false, false);
|
|
return;
|
|
}
|
|
|
|
if (m_active == Tool::SurfaceExtrude) {
|
|
const int ref = m_surf_extrude_sketch_ref;
|
|
if (ref < 0 || ref >= int(m_doc.features.size())) { m_viewport->clear_extrude_gizmo(); return; }
|
|
const CadFeature& sk = m_doc.features[ref];
|
|
Vec2d c(0, 0);
|
|
if (!sk.profile.points.empty()) {
|
|
for (const Vec2d& p : sk.profile.points) c += p;
|
|
c /= double(sk.profile.points.size());
|
|
}
|
|
m_viewport->set_extrude_gizmo(sk.plane, c,
|
|
m_surf_extrude_distance ? m_surf_extrude_distance->GetValue() : 0.0,
|
|
0.0, false, false);
|
|
return;
|
|
}
|
|
|
|
if (m_active == Tool::Thicken) {
|
|
const int b = m_sel_solid_body;
|
|
if (b < 0 || b >= int(m_doc.bodies.size()) || m_sel_solid_face < 0) {
|
|
m_viewport->clear_extrude_gizmo(); return;
|
|
}
|
|
TopoDS_Face f = GeometryEngine::face_by_index(m_doc.bodies[b].shape, m_sel_solid_face);
|
|
if (f.IsNull()) { m_viewport->clear_extrude_gizmo(); return; }
|
|
SketchPlane plane = SketchPlane::from_face(f);
|
|
Vec3d c = GeometryEngine::face_centroid_world(f);
|
|
Vec3d n = GeometryEngine::face_normal_world(f);
|
|
if (f.Orientation() == TopAbs_REVERSED) n = -n; // outward
|
|
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();
|
|
m_viewport->set_extrude_gizmo(plane, Vec2d(0, 0),
|
|
m_thicken_thickness ? m_thicken_thickness->GetValue() : 0.0,
|
|
0.0, false,
|
|
m_thicken_flip && m_thicken_flip->GetValue());
|
|
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));
|
|
refresh_mate_connectors();
|
|
}
|
|
|
|
// Feed the connector frames to the viewport. resolve_datum_coordsys() emits them in document order
|
|
// over enabled CoordSys features, so the feature index can be recovered by walking the same filter —
|
|
// which is what tells us whether a given frame is the one the open Mate card will DRIVE.
|
|
void DesignPanel::refresh_mate_connectors()
|
|
{
|
|
if (!m_viewport) return;
|
|
// Polarity is a property of the MATE, not of an open dialog: a connector that some committed
|
|
// mate drives must read as driven whenever it is on screen, or the glyph only tells the truth
|
|
// while a card happens to be open. The card, when open, wins — it is the live intent.
|
|
std::map<int, int> role_by_feature; // feature index -> 1 fixed, 2 driven
|
|
for (const CadFeature& mf : m_doc.features) {
|
|
if (mf.type != CadFeatureType::Mate || !mf.enabled) continue;
|
|
if (mf.mate_cs_a >= 0) role_by_feature[mf.mate_cs_a] = 1;
|
|
if (mf.mate_cs_b >= 0) role_by_feature[mf.mate_cs_b] = 2;
|
|
}
|
|
const int cs_a = (m_active == Tool::Mate && m_mate_cs_a) ? m_mate_cs_a->GetSelection() : -1;
|
|
const int cs_b = (m_active == Tool::Mate && m_mate_cs_b) ? m_mate_cs_b->GetSelection() : -1;
|
|
|
|
std::vector<DesignSketchTool::MateConnectorGlyph> out;
|
|
// Origins, keyed both ways: a committed mate names its connectors by FEATURE index, the open
|
|
// card's combos by ordinal. Only resolved frames land here, so an unresolved end simply draws
|
|
// no pair line rather than a line to the origin of the world.
|
|
std::map<int, Vec3d> origin_by_feature, origin_by_ordinal;
|
|
const auto frames = m_doc.resolve_datum_coordsys();
|
|
size_t k = 0;
|
|
for (size_t i = 0; i < m_doc.features.size() && k < frames.size(); ++i) {
|
|
const CadFeature& f = m_doc.features[i];
|
|
if (f.type != CadFeatureType::CoordSys || !f.enabled) continue;
|
|
const auto& fr = frames[k];
|
|
const int ordinal = int(k);
|
|
++k;
|
|
if (!fr.error.empty()) continue; // unresolved: draw nothing, never a guess
|
|
DesignSketchTool::MateConnectorGlyph g;
|
|
g.origin = fr.origin;
|
|
g.x = fr.x;
|
|
g.y = fr.y;
|
|
const auto it = role_by_feature.find(int(i));
|
|
g.role = (ordinal == cs_b) ? 2 : (ordinal == cs_a ? 1
|
|
: (it != role_by_feature.end() ? it->second : 0));
|
|
// A face-only connector whose face gave no usable in-plane edge fell back to the hint; that
|
|
// is the case the glyph has to confess rather than absorb.
|
|
g.roll_undefined = (f.coordsys_type == CoordSysType::FaceAndDirection
|
|
&& f.coordsys_edge < 0);
|
|
origin_by_feature[int(i)] = g.origin;
|
|
origin_by_ordinal[ordinal] = g.origin;
|
|
out.push_back(g);
|
|
}
|
|
|
|
std::vector<std::pair<Vec3d, Vec3d>> links;
|
|
auto add_link = [&links](const std::map<int, Vec3d>& m, int a, int b) {
|
|
const auto ia = m.find(a), ib = m.find(b);
|
|
if (ia != m.end() && ib != m.end()) links.emplace_back(ia->second, ib->second);
|
|
};
|
|
for (const CadFeature& mf : m_doc.features)
|
|
if (mf.type == CadFeatureType::Mate && mf.enabled)
|
|
add_link(origin_by_feature, mf.mate_cs_a, mf.mate_cs_b);
|
|
if (cs_a >= 0 && cs_b >= 0 && cs_a != cs_b)
|
|
add_link(origin_by_ordinal, cs_a, cs_b); // the live pick, not yet committed
|
|
|
|
m_viewport->set_mate_connectors(std::move(out));
|
|
m_viewport->set_mate_links(std::move(links));
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
void DesignPanel::update_helix_gizmo()
|
|
{
|
|
if (!m_viewport) return;
|
|
if (m_active != Tool::Helix) { m_viewport->clear_helix_gizmo(); return; }
|
|
m_viewport->set_helix_gizmo(plane_from_choice(m_helix_plane->GetSelection()),
|
|
m_helix_radius ? m_helix_radius->GetValue() : 0.0,
|
|
m_helix_pitch ? m_helix_pitch->GetValue() : 1.0,
|
|
m_helix_height ? m_helix_height->GetValue() : 0.0,
|
|
m_helix_taper ? m_helix_taper->GetValue() : 0.0,
|
|
m_helix_left_handed && m_helix_left_handed->GetValue());
|
|
}
|
|
|
|
void DesignPanel::update_rib_gizmo()
|
|
{
|
|
if (!m_viewport) return;
|
|
if (m_active != Tool::Rib) { m_viewport->clear_rib_gizmo(); return; }
|
|
// Same resolution the Tool::Rib branch of update_extrude_gizmo() uses — the depth arrow and
|
|
// this share one sketch and one entity, and must never disagree about which line that is.
|
|
const int ssel = m_rib_sketch ? m_rib_sketch->GetSelection() : wxNOT_FOUND;
|
|
const int ref = (ssel != wxNOT_FOUND)
|
|
? int(reinterpret_cast<intptr_t>(m_rib_sketch->GetClientData(ssel))) : -1;
|
|
if (ref < 0 || ref >= int(m_doc.features.size())) { m_viewport->clear_rib_gizmo(); return; }
|
|
const CadFeature& sk = m_doc.features[ref];
|
|
const int ei = m_rib_entity ? m_rib_entity->GetValue() : 0;
|
|
if (ei < 0 || ei >= int(sk.entities.size())) { m_viewport->clear_rib_gizmo(); return; }
|
|
const SketchEntity& e = sk.entities[ei];
|
|
if (e.type != SketchEntity::Type::Line) { m_viewport->clear_rib_gizmo(); return; } // rib is line-only
|
|
m_viewport->set_rib_gizmo(sk.plane, e.p0, e.p1,
|
|
m_rib_thickness ? m_rib_thickness->GetValue() : 0.0);
|
|
}
|
|
|
|
// Onshape default planes: the XY/XZ/YZ reference planes are persistent, transparent, labelled, and
|
|
// larger than the bed — shown as the FALLBACK when there is no object yet. When the Plane tool is
|
|
// open they additionally surface existing datums so a base can be picked. Single authority for the
|
|
// reference-plane overlay (set/clear_base_pick).
|
|
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).
|
|
// Available while there is no solid yet OR while the user is actually choosing a sketch
|
|
// plane. The second half fixes a dead end: delete a sketch on a document that still has a
|
|
// body, press Sketch, and act_sketch says "click a face or a reference plane" — with the
|
|
// reference planes already taken away, because a body existed. The instruction was
|
|
// impossible to follow and there was no way to start a sketch at all short of finding a
|
|
// face to click.
|
|
//
|
|
// Not simply always-on: m_dbp_active both RENDERS and picks, so three translucent planes
|
|
// would otherwise float over every finished model. Tying them to Sketch mode shows them
|
|
// exactly when they are the thing being chosen, and hides them again on Finish.
|
|
if (m_doc.bodies.empty() || m_ui_mode == UiMode::Sketch)
|
|
m_viewport->set_base_pick(std::move(bp), std::move(bi), std::move(bl));
|
|
else
|
|
m_viewport->clear_base_pick();
|
|
}
|
|
|
|
TriangleMesh DesignPanel::ghost_from_bodies(const std::vector<TriangleMesh>& per_body) const
|
|
{
|
|
TriangleMesh out;
|
|
for (size_t b = 0; b < per_body.size(); ++b) {
|
|
TriangleMesh m = per_body[b];
|
|
if (b < m_body_xform.size()) m.transform(m_body_xform[b]);
|
|
out.merge(m);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
bool DesignPanel::show_mate_ghost(int kind, int cs_a, int cs_b,
|
|
double offset, double angle_deg, bool flip, std::string& err)
|
|
{
|
|
CadFeature cand;
|
|
cand.type = CadFeatureType::Mate;
|
|
cand.mate_kind = kind;
|
|
cand.mate_cs_a = cs_a;
|
|
cand.mate_cs_b = cs_b;
|
|
cand.mate_offset = offset;
|
|
cand.mate_angle = angle_deg;
|
|
cand.mate_flip = flip;
|
|
|
|
TriangleMesh mesh;
|
|
std::vector<TriangleMesh> pbm;
|
|
if (!m_doc.preview(cand, mesh, pbm, err)) {
|
|
m_viewport->clear_preview();
|
|
m_viewport->set_body_hidden(false);
|
|
return false;
|
|
}
|
|
m_viewport->set_preview_mesh(ghost_from_bodies(pbm));
|
|
// The ghost is the WHOLE assembly in its post-mate pose, not an added lump, so the committed
|
|
// bodies have to go: left visible they would draw the mated body in both places at once and
|
|
// z-fight everything else against its own copy. Same reason Dressup and Draft hide them.
|
|
m_viewport->set_body_hidden(true);
|
|
return true;
|
|
}
|
|
|
|
void DesignPanel::refresh_preview()
|
|
{
|
|
if (m_active == Tool::None) { m_viewport->clear_preview(); return; }
|
|
|
|
// Transform has no ghost: it moves an existing body rather than building a new one, so its
|
|
// preview IS the body, shown through the display transform the gizmo already drives.
|
|
if (m_active == Tool::Transform) { xf_live_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;
|
|
}
|
|
set_status(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
|
|
update_helix_gizmo(); // Helix card: draw the live curve + drag handles (no solid ghost)
|
|
return;
|
|
}
|
|
|
|
if (m_active == Tool::Mate) {
|
|
// A mate produces no NEW geometry, but it MOVES a body — and the moved assembly is
|
|
// exactly the ghost worth showing. This branch used to clear the preview outright,
|
|
// saying a mate has no 3D ghost; the kernel had no such opinion (preview() routes a
|
|
// Mate candidate through apply_mate on a throwaway copy), so that one line was the
|
|
// whole of gap G3. The card's own gate stays: Confirm is disabled when <2 CoordSys
|
|
// exist or when A == B, because neither of those is a kernel error, only an unfinished
|
|
// form, and the kernel's message for them would be worse than these.
|
|
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_viewport->clear_preview();
|
|
m_viewport->set_body_hidden(false); // the ghost replaced them; give them back
|
|
m_status->SetForegroundColour(wxColour(235, 110, 110));
|
|
set_status(_L("Mate needs at least two CoordSys features"));
|
|
} else if (same) {
|
|
m_viewport->clear_preview();
|
|
m_viewport->set_body_hidden(false);
|
|
m_status->SetForegroundColour(wxColour(235, 110, 110));
|
|
set_status(_L("Mate: CS A and CS B must be different"));
|
|
} else {
|
|
sync_body_xform(); // same reason as the solid path below: drop stale per-body poses
|
|
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)));
|
|
std::string err;
|
|
ok = show_mate_ghost(m_mate_kind ? m_mate_kind->GetSelection() : 0, cs_a, cs_b,
|
|
m_mate_offset ? m_mate_offset->GetValue() : 0.0,
|
|
m_mate_angle ? m_mate_angle->GetValue() : 0.0,
|
|
m_mate_flip && m_mate_flip->GetValue(), err);
|
|
if (ok) {
|
|
m_status->SetForegroundColour(wxColour(120, 210, 120));
|
|
set_status(_L("Mate ready"));
|
|
} else {
|
|
m_status->SetForegroundColour(wxColour(235, 110, 110));
|
|
set_status(_L("Invalid: ") + wxString::FromUTF8(err));
|
|
}
|
|
}
|
|
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) { return ghost_from_bodies(pbm); };
|
|
|
|
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
|
|
set_status(wxString::Format(_L("Preview — %zu triangles"), mesh.its.indices.size()));
|
|
} else {
|
|
m_viewport->clear_preview();
|
|
m_status->SetForegroundColour(wxColour(235, 110, 110)); // invalid = red
|
|
set_status(_L("Invalid: ") + wxString::FromUTF8(err));
|
|
}
|
|
// 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();
|
|
// Helix curve + handles (self-gates: only while the Helix card is open).
|
|
update_helix_gizmo();
|
|
// Rib slab footprint + thickness handles (self-gates: only while the Rib card is open).
|
|
update_rib_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);
|
|
set_status(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(); }
|
|
}
|
|
|
|
// Push the tool's current parameters into the live sketch tool before Polygon starts. They
|
|
// come from the offer's Polygon submenu (or from the last choice made there), never from a card.
|
|
void DesignPanel::push_polygon_params()
|
|
{
|
|
if (m_viewport == nullptr) return;
|
|
m_viewport->set_sketch_polygon_sides(m_poly_sides);
|
|
m_viewport->set_sketch_polygon_circumscribed(m_poly_circumscribed);
|
|
}
|
|
|
|
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.
|
|
// Body focus follows the CoordSys card: entering any other tool drops it. Read it back
|
|
// from the combo rather than clearing outright — editing a CoordSys feature loads the
|
|
// card (and its body) BEFORE open_tool runs, and a blind clear would strand the viewport
|
|
// showing "Body N" while picking stayed unrestricted.
|
|
if (m_viewport) { m_viewport->set_body_translucent(false); m_viewport->set_body_hidden(false);
|
|
m_viewport->set_xray_focus(t == Tool::CoordSys && m_cs_body ? m_cs_body->GetSelection() - 1 : -1); }
|
|
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);
|
|
const int pos_a = combo_append_index(m_mate_cs_a, nm, i);
|
|
const int pos_b = combo_append_index(m_mate_cs_b, nm, i);
|
|
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;
|
|
const int pos = combo_append_index(m_sweep_path, wxString::FromUTF8(sf.name), 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));
|
|
}
|
|
|
|
// Shell and Draft both take their face from the live pick at Confirm time, but until now
|
|
// only the PICK handler wrote their labels. So the natural order — pick the face, then open
|
|
// the card — left Shell reading "(all faces — closed hollow)" and Draft "(pick a side face)"
|
|
// while Confirm went on to use m_sel_solid_face regardless: the card described one operation
|
|
// and performed another. Initialise from the current selection here, exactly as Extrude does
|
|
// with m_extrude_face_src below. Skipped while re-editing, because load_feature_into_dialog
|
|
// has already written the label from the feature's own stored face and runs before this.
|
|
// Same reasoning for Dress-up, whose target is the picked EDGE (falling back to the group).
|
|
if (t == Tool::Dressup && m_edit_index < 0)
|
|
sync_dressup_target();
|
|
|
|
if (t == Tool::Shell && m_edit_index < 0)
|
|
m_shell_face_label->SetLabel(m_sel_solid_face >= 0
|
|
? wxString::Format(_L("Face %d"), m_sel_solid_face)
|
|
: _L("(all faces — closed hollow)"));
|
|
|
|
if (t == Tool::Draft && m_edit_index < 0)
|
|
m_draft_face_label->SetLabel(m_sel_solid_face >= 0
|
|
? wxString::Format(_L("Face %d"), m_sel_solid_face)
|
|
: _L("(pick a side face)"));
|
|
|
|
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;
|
|
}
|
|
|
|
// A card that consumes a face must say WHICH face the moment it appears. These labels used to
|
|
// be written only by the pick handler, which runs while a card is already open — so a card
|
|
// opened FROM a selection (the offer's whole premise) showed the previous pick, or the "(pick
|
|
// one)" placeholder over a face it was in fact about to use. Same law as the Construction
|
|
// toggle: a control that carries state has to show it. One writer, on open, for all three.
|
|
{
|
|
const bool have = m_sel_solid_face >= 0;
|
|
const wxString face_name = have ? wxString::Format(_L("Face %d"), m_sel_solid_face) : wxString();
|
|
if (t == Tool::Thicken && m_thicken_face_label)
|
|
m_thicken_face_label->SetLabel(have ? face_name : _L("(pick a solid face)"));
|
|
if (t == Tool::Shell && m_shell_face_label)
|
|
m_shell_face_label->SetLabel(have ? face_name : _L("(all faces — closed hollow)"));
|
|
if (t == Tool::Draft && m_draft_face_label)
|
|
m_draft_face_label->SetLabel(have ? face_name : _L("(pick a side face)"));
|
|
}
|
|
|
|
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();
|
|
|
|
// Add-mode Transform arms the move gizmo on the card's body so the prominent verb is the
|
|
// geometry-first control; the card mirrors the drag. Edit mode keeps today's card-only path.
|
|
if (t == Tool::Transform && m_edit_index < 0)
|
|
arm_transform_gizmo();
|
|
// Opening Boolean re-aims the viewport picks at the target slot, so the first click after
|
|
// the card appears always means "keep this one" regardless of what the last session did.
|
|
if (t == Tool::Boolean)
|
|
m_bool_next_slot = 0;
|
|
}
|
|
|
|
void DesignPanel::close_tool()
|
|
{
|
|
m_active = Tool::None;
|
|
set_active_tool_btn(nullptr); // clear the active-tool teal highlight
|
|
// Single revert point for the Transform gizmo: Esc, switching tools, and Cancel all pass
|
|
// through here, so the body is never left displaced by a Transform that wasn't committed.
|
|
xf_clear_preview();
|
|
if (m_xf_gizmo_body >= 0) {
|
|
sync_body_xform();
|
|
if (m_xf_gizmo_body < int(m_body_xform.size()))
|
|
m_body_xform[m_xf_gizmo_body] = m_xf_gizmo_base;
|
|
if (m_viewport) m_viewport->clear_move_gizmo();
|
|
feed_bodies();
|
|
m_xf_gizmo_body = -1;
|
|
m_move_body = -1;
|
|
}
|
|
if (m_viewport) { m_viewport->set_body_translucent(false); m_viewport->set_body_hidden(false); m_viewport->set_xray_focus(-1); } // restore the opaque solid
|
|
wxSizer* s = m_cards->GetSizer();
|
|
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: if (!on_add_plane()) return; break; // refused: keep the card and its picks
|
|
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);
|
|
set_status(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_active == Tool::None && m_viewport && m_viewport->moving_body()) { // keep the placement, drop the gizmo
|
|
m_viewport->clear_move_gizmo();
|
|
m_move_body = -1;
|
|
show_move_card(false);
|
|
update_action_bar();
|
|
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);
|
|
set_status(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_active == Tool::None && m_viewport && m_viewport->moving_body()) { // revert to the pose at move-start
|
|
sync_body_xform();
|
|
if (m_move_body >= 0 && m_move_body < int(m_body_xform.size()))
|
|
m_body_xform[m_move_body] = m_move_prev;
|
|
m_viewport->clear_move_gizmo();
|
|
m_move_body = -1;
|
|
show_move_card(false);
|
|
feed_bodies(); // re-render the reverted placement
|
|
update_action_bar();
|
|
m_status->SetForegroundColour(wxNullColour);
|
|
set_status(_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) {
|
|
// Escape is how anyone dismisses the inline dimension field, and it used to cascade
|
|
// straight through to here: first press disarmed the tool, second press dropped the
|
|
// whole live session — a drawn rectangle gone, silently, with no undo prompt. That is
|
|
// the "I cannot add the circle after the rectangle" report: the sketch was already
|
|
// destroyed. Discarding real work needs the explicit Cancel button, not a key people
|
|
// press to close a text field.
|
|
if (m_viewport && m_viewport->live_sketch_has_work()) {
|
|
m_status->SetForegroundColour(wxNullColour);
|
|
set_status(_L("Sketch kept — use Confirm to keep it, Cancel to discard"));
|
|
m_status->Refresh();
|
|
return;
|
|
}
|
|
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);
|
|
set_status(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);
|
|
set_status(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);
|
|
// HIDING THE BAR ORPHANS THE KEYBOARD, and that is the "app does not consent to sketch"
|
|
// report. The ✓/✗ live in this bar, so the click that confirms a feature leaves focus on a
|
|
// button that this very call then hides. wx does not hand that focus anywhere useful, and
|
|
// wxEVT_CHAR_HOOK is bound on THIS PANEL — it is delivered to the focused window and
|
|
// propagates up the parent chain, so with focus outside the panel every Design shortcut
|
|
// stops arriving. Measured on the rig: after Confirm, shift+S produced ZERO CHAR_HOOK lines
|
|
// (not even the modifier), while X kept focus on the main window throughout — so it was
|
|
// never a window-manager problem. One bare canvas click restored it, which is exactly the
|
|
// workaround users found and reported as "shift+s works but it is not intuitive".
|
|
// The canvas is the right owner of the keyboard in this tab, so give it back explicitly.
|
|
if (!active && m_viewport != nullptr) {
|
|
wxWindow* f = wxWindow::FindFocus();
|
|
bool in_bar = (f == nullptr);
|
|
for (wxWindow* w = f; w != nullptr && !in_bar; w = w->GetParent())
|
|
if (w == m_toolbar) in_bar = true; // the bar is a sizer; its buttons parent to m_toolbar
|
|
if (in_bar) m_viewport->SetFocus();
|
|
}
|
|
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);
|
|
set_status(_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);
|
|
set_status(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;
|
|
m_pick_face = m_pick_face_body = -1; // recompute() invalidated the face ids too
|
|
reset_edit_state();
|
|
after_tree_edit(true); // refresh tree + viewport meshes + status from the restored doc
|
|
m_status->SetForegroundColour(wxNullColour);
|
|
set_status(wxString::Format(redo ? _L("Redo (%zu more)") : _L("Undo (%zu more)"),
|
|
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));
|
|
set_status(_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) {
|
|
set_status(_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) {
|
|
set_status(_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));
|
|
set_status(_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
|