Design tab: reference planes at bed centre, slot dims, hole cube handle, real threads

Port of the snaporca CAD work to the mainline fork.

- Onshape default planes (XY/XZ/YZ) at the bed centre (transparent, labelled);
  modeling origin unified to the bed centre (CadDocument::modeling_origin);
  world-axis triad moved to the bed centre on the Design canvas only.
- Datum plane: clickable ghost-plane base pick + draggable offset arrow.
- Slot: dims reassessed to inter-centre distance / radius / angle; fixed the
  duplicate cap-arc radius quote.
- Hole: 3D cube move-handle on the face; binds to the face on the first click;
  decluttered side-distance construction lines.
- Thread: derive the M spec (diameter/pitch/depth) from a picked cylindrical
  surface or circular edge (GeometryEngine::circle_of_edge); fuse the helical
  ridge onto the existing body; MakePipeShell fixed-binormal sweep (uniform, no
  twist) + self-intersection/param guards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
This commit is contained in:
Tommaso Bianchi
2026-06-30 00:24:39 +02:00
co-authored by Claude Opus 4.8
parent 852804450c
commit 71b7a73e86
14 changed files with 1383 additions and 179 deletions
+488 -79
View File
@@ -2,6 +2,7 @@
#include "GLCanvas3D.hpp"
#include "GUI_App.hpp"
#include "Plater.hpp"
#include "libslic3r/BuildVolume.hpp"
#include "Camera.hpp"
#include "3DScene.hpp"
#include "GLShader.hpp"
@@ -1165,6 +1166,17 @@ void DesignSketchTool::open_primary_autoedit()
m_autoedit_dims.push_back({ m_live_aslot_r_label, Rc, [this, fi](double v){ const Feature& g = m_features[fi]; set_arc_slot(fi, v, g.param); }, span(fi) });
m_autoedit_dims.push_back({ m_live_aslot_w_label, fw, [this, fi](double v){ const Feature& g = m_features[fi]; set_arc_slot(fi, (g.c1-g.c0).norm(), std::max(1e-3, v*0.5)); }, span(fi) });
}
if (m_live_slot_fi >= 0) {
const Feature& f = m_features[m_live_slot_fi];
const int fi = m_live_slot_fi;
// Slot dims, in order: (1) inter-centre distance, (2) radius (= half-width), (3) angle.
const Vec2d d = f.c1 - f.c0;
const double Lc = d.norm();
double deg = std::atan2(d.y(), d.x()) * 180.0 / M_PI; if (deg < 0.0) deg += 360.0;
m_autoedit_dims.push_back({ m_live_slot_len_label, Lc, [this, fi](double v){ const Feature& g = m_features[fi]; set_slot(fi, v, g.param); }, span(fi) });
m_autoedit_dims.push_back({ m_live_slot_w_label, f.param, [this, fi](double v){ const Feature& g = m_features[fi]; set_slot(fi, (g.c1-g.c0).norm(), std::max(1e-3, v)); }, span(fi) });
m_autoedit_dims.push_back({ m_live_slot_angle_label, deg, [this, fi](double v){ set_slot_angle(fi, v); }, span(fi) });
}
if (m_live_arc_ei >= 0) { // arc Radius is already a scalar step above; add its sweep angle
const int ei = m_live_arc_ei;
const SketchEntity& e = m_entities[ei];
@@ -1734,6 +1746,70 @@ void DesignSketchTool::set_arc_slot(int fi, double Rc, double w)
resolve_live();
}
// Open the inline editor for a straight slot's dimension: which 0 = inter-centre distance,
// 1 = radius (half-width), 2 = centreline angle. Drives set_slot / set_slot_angle geometrically.
void DesignSketchTool::open_slot_editor(int fi, int which)
{
if (fi < 0 || fi >= int(m_features.size()) || !on_inline_edit) return;
const Feature& f = m_features[fi];
const Vec2d d = f.c1 - f.c0;
double deg = std::atan2(d.y(), d.x()) * 180.0 / M_PI; if (deg < 0.0) deg += 360.0;
const double v = (which == 0) ? d.norm() : (which == 1) ? f.param : deg;
const wxPoint px(m_last_mouse_x, m_last_mouse_y);
on_inline_edit(px, v,
[this, fi, which](double nv) {
const Feature& g = m_features[fi];
if (which == 0) set_slot(fi, nv, g.param);
else if (which == 1) set_slot(fi, (g.c1 - g.c0).norm(), std::max(1e-3, nv));
else set_slot_angle(fi, nv);
},
[]() {});
}
// Rebuild the straight slot's 4 entities in place for a new centreline length / half-width.
// Centre c0 and the centreline direction are kept; only c1 (length) or param (width) change.
// Geometric; entity count preserved so constraint refs stay valid.
void DesignSketchTool::set_slot(int fi, double length, double w)
{
if (fi < 0 || fi >= int(m_features.size())) return;
Feature& f = m_features[fi];
if (f.begin < 0 || f.end > int(m_entities.size()) || f.end - f.begin != 4) return;
Vec2d dir = f.c1 - f.c0;
if (dir.squaredNorm() < 1e-12) return;
dir.normalize();
length = std::max(length, 2e-3);
w = std::max(1e-3, w);
const Vec2d c0 = f.c0, c1 = f.c0 + length * dir;
std::vector<SketchEntity> rebuilt = make_slot(c0, c1, w);
if (int(rebuilt.size()) != 4) return;
for (int i = 0; i < 4; ++i) {
rebuilt[i].construction = m_entities[f.begin + i].construction;
m_entities[f.begin + i] = rebuilt[i];
}
f.c1 = c1; f.param = w;
resolve_live();
}
// Rotate a straight slot about c0 to a new centreline angle (degrees), keeping length + radius.
void DesignSketchTool::set_slot_angle(int fi, double deg)
{
if (fi < 0 || fi >= int(m_features.size())) return;
Feature& f = m_features[fi];
if (f.begin < 0 || f.end > int(m_entities.size()) || f.end - f.begin != 4) return;
const double L = (f.c1 - f.c0).norm();
if (L < 1e-9) return;
const double a = deg * M_PI / 180.0;
const Vec2d c1 = f.c0 + L * Vec2d(std::cos(a), std::sin(a));
std::vector<SketchEntity> rebuilt = make_slot(f.c0, c1, f.param);
if (int(rebuilt.size()) != 4) return;
for (int i = 0; i < 4; ++i) {
rebuilt[i].construction = m_entities[f.begin + i].construction;
m_entities[f.begin + i] = rebuilt[i];
}
f.c1 = c1;
resolve_live();
}
// Resize an axis-aligned rectangle by dragging a corner: the diagonally-opposite corner
// (captured at grab as m_drag_rect_anchor) stays fixed; the box becomes [anchor, cursor].
// Geometric rebuild in place (4 lines, same order) — edges stay axis-aligned so the
@@ -2793,7 +2869,7 @@ void DesignSketchTool::render_datum_planes()
if (m_datum_planes.empty()) return;
using EPT = GLModel::Geometry::EPrimitiveType;
using EVL = GLModel::Geometry::EVertexLayout;
const double H = 40.0; // half-extent of the drawn rectangle (mm)
const double H = 40.0; // default half-extent when no per-plane size is given (mm)
const Camera& cam = wxGetApp().plater()->get_camera();
const Vec3d vd = cam.get_dir_forward();
const double hw = 1.5 / std::max(cam.get_zoom(), 1e-6); // ~1.5 px border ribbon
@@ -2801,9 +2877,16 @@ void DesignSketchTool::render_datum_planes()
GLModel::Geometry fill; fill.format = { EPT::Triangles, EVL::P3 };
GLModel::Geometry border; border.format = { EPT::Triangles, EVL::P3 };
unsigned int fb = 0, bb = 0;
for (const SketchPlane& p : m_datum_planes) {
const Vec3d c[4] = { p.to_world(Vec2d(-H, -H)), p.to_world(Vec2d(H, -H)),
p.to_world(Vec2d(H, H)), p.to_world(Vec2d(-H, H)) };
for (size_t pi = 0; pi < m_datum_planes.size(); ++pi) {
const SketchPlane& p = m_datum_planes[pi];
// Per-plane u/v half-extent (the GUI Size U/V + drag handles drive these); fall
// back to the square default when no size was supplied.
const double hu = (pi < m_datum_sizes.size() && m_datum_sizes[pi].x() > 1e-6)
? m_datum_sizes[pi].x() * 0.5 : H;
const double hv = (pi < m_datum_sizes.size() && m_datum_sizes[pi].y() > 1e-6)
? m_datum_sizes[pi].y() * 0.5 : H;
const Vec3d c[4] = { p.to_world(Vec2d(-hu, -hv)), p.to_world(Vec2d(hu, -hv)),
p.to_world(Vec2d(hu, hv)), p.to_world(Vec2d(-hu, hv)) };
fill.add_vertex((Vec3f)c[0].cast<float>()); fill.add_vertex((Vec3f)c[1].cast<float>());
fill.add_vertex((Vec3f)c[2].cast<float>()); fill.add_triangle(fb, fb + 1, fb + 2); fb += 3;
fill.add_vertex((Vec3f)c[0].cast<float>()); fill.add_vertex((Vec3f)c[2].cast<float>());
@@ -2955,6 +3038,276 @@ void DesignSketchTool::open_extrude_editor(int which)
[]() {});
}
// ---- Datum-plane resize gizmo (C3) ----------------------------------------------------
void DesignSketchTool::set_datum_gizmo(const SketchPlane& plane, double usize, double vsize,
const Vec3d& base_origin, const Vec3d& base_normal,
double offset, bool offset_on)
{
m_dz_active = true;
m_dz_plane = plane;
m_dz_usize = std::max(1.0, usize);
m_dz_vsize = std::max(1.0, vsize);
m_dz_anchor = base_origin;
m_dz_normal = base_normal.normalized();
m_dz_offset = offset;
m_dz_offset_on = offset_on;
}
void DesignSketchTool::clear_datum_gizmo()
{
m_dz_active = false;
m_dz_drag = -1;
}
// Draw the datum rectangle outline + 4 camera-billboarded edge-midpoint handles. Self-contained
// so it shows even for an uncommitted (not-yet-Confirmed) datum that render_datum_planes can't draw.
void DesignSketchTool::render_datum_gizmo()
{
if (!m_dz_active) return;
using EPT = GLModel::Geometry::EPrimitiveType;
using EVL = GLModel::Geometry::EVertexLayout;
const Camera& cam = wxGetApp().plater()->get_camera();
const Vec3d right = cam.get_dir_right().normalized();
const Vec3d up = cam.get_dir_up().normalized();
const Vec3d vd = cam.get_dir_forward();
const double upp = 1.0 / std::max(cam.get_zoom(), 1e-6);
const double hs = 6.0 * upp; // handle half-size (~6 px)
const double hw = 1.5 * upp; // outline ribbon half-width
const double hu = m_dz_usize * 0.5, hv = m_dz_vsize * 0.5;
const SketchPlane& p = m_dz_plane;
// Rectangle outline (4 thin camera-facing ribbons).
const Vec3d c[4] = { p.to_world(Vec2d(-hu, -hv)), p.to_world(Vec2d(hu, -hv)),
p.to_world(Vec2d(hu, hv)), p.to_world(Vec2d(-hu, hv)) };
GLModel::Geometry border; border.format = { EPT::Triangles, EVL::P3 };
unsigned int bb = 0;
for (int s = 0; s < 4; ++s) {
const Vec3d a = c[s], b = c[(s + 1) & 3];
Vec3d dir = b - a; if (dir.norm() < 1e-9) continue; dir.normalize();
Vec3d off = dir.cross(vd);
if (off.norm() < 1e-9) off = dir.cross(up);
if (off.norm() < 1e-9) continue;
off.normalize(); off *= hw;
border.add_vertex((Vec3f)(a + off).cast<float>()); border.add_vertex((Vec3f)(b + off).cast<float>());
border.add_vertex((Vec3f)(b - off).cast<float>()); border.add_vertex((Vec3f)(a - off).cast<float>());
border.add_triangle(bb, bb + 1, bb + 2); border.add_triangle(bb, bb + 2, bb + 3); bb += 4;
}
// 4 edge-midpoint handle squares.
const Vec2d hpos[4] = { Vec2d(hu, 0), Vec2d(-hu, 0), Vec2d(0, hv), Vec2d(0, -hv) };
GLModel::Geometry handles; handles.format = { EPT::Triangles, EVL::P3 };
unsigned int hb = 0;
for (int i = 0; i < 4; ++i) {
const Vec3d ctr = p.to_world(hpos[i]);
const Vec3d q0 = ctr - right * hs - up * hs, q1 = ctr + right * hs - up * hs,
q2 = ctr + right * hs + up * hs, q3 = ctr - right * hs + up * hs;
handles.add_vertex((Vec3f)q0.cast<float>()); handles.add_vertex((Vec3f)q1.cast<float>());
handles.add_vertex((Vec3f)q2.cast<float>()); handles.add_vertex((Vec3f)q3.cast<float>());
handles.add_triangle(hb, hb + 1, hb + 2); handles.add_triangle(hb, hb + 2, hb + 3); hb += 4;
}
glsafe(::glDisable(GL_DEPTH_TEST));
if (bb > 0) {
GLModel m; m.init_from(std::move(border));
m.set_color(ColorRGBA(1.0f, 0.62f, 0.16f, 0.9f)); // CAD amber
m.render();
}
if (hb > 0) {
GLModel m; m.init_from(std::move(handles));
m.set_color(ColorRGBA(1.0f, 0.72f, 0.28f, 1.0f));
m.render();
}
// Offset arrow: a camera-facing ribbon from the base origin along the base normal to the
// datum origin, with a grabbable square at the tip. Drag the tip to set the offset distance.
if (m_dz_offset_on) {
const Vec3d tip = m_dz_anchor + m_dz_normal * m_dz_offset;
Vec3d off = m_dz_normal.cross(vd);
if (off.norm() < 1e-9) off = m_dz_normal.cross(up);
GLModel::Geometry shaft; shaft.format = { EPT::Triangles, EVL::P3 };
if (off.norm() > 1e-9 && (tip - m_dz_anchor).norm() > 1e-9) {
off.normalize(); off *= hw;
shaft.add_vertex((Vec3f)(m_dz_anchor + off).cast<float>());
shaft.add_vertex((Vec3f)(tip + off).cast<float>());
shaft.add_vertex((Vec3f)(tip - off).cast<float>());
shaft.add_vertex((Vec3f)(m_dz_anchor - off).cast<float>());
shaft.add_triangle(0, 1, 2); shaft.add_triangle(0, 2, 3);
GLModel sm; sm.init_from(std::move(shaft));
sm.set_color(ColorRGBA(0.30f, 0.78f, 1.0f, 0.95f)); // cyan offset axis
sm.render();
}
GLModel::Geometry tipsq; tipsq.format = { EPT::Triangles, EVL::P3 };
const Vec3d t0 = tip - right * hs - up * hs, t1 = tip + right * hs - up * hs,
t2 = tip + right * hs + up * hs, t3 = tip - right * hs + up * hs;
tipsq.add_vertex((Vec3f)t0.cast<float>()); tipsq.add_vertex((Vec3f)t1.cast<float>());
tipsq.add_vertex((Vec3f)t2.cast<float>()); tipsq.add_vertex((Vec3f)t3.cast<float>());
tipsq.add_triangle(0, 1, 2); tipsq.add_triangle(0, 2, 3);
GLModel tm; tm.init_from(std::move(tipsq));
tm.set_color(ColorRGBA(0.45f, 0.86f, 1.0f, 1.0f));
tm.render();
}
}
bool DesignSketchTool::hit_test_datum_handle(GLCanvas3D& canvas, const wxMouseEvent& evt, int& which) const
{
if (!m_dz_active) return false;
const Linef3 r = canvas.mouse_ray(Point(evt.GetX(), evt.GetY()));
const Vec3d ro = r.a, rd = r.b - r.a;
const Camera& cam = wxGetApp().plater()->get_camera();
const double tol = 9.0 / std::max(cam.get_zoom(), 1e-6); // ~9 px in world units
const double hu = m_dz_usize * 0.5, hv = m_dz_vsize * 0.5;
const Vec2d hpos[4] = { Vec2d(hu, 0), Vec2d(-hu, 0), Vec2d(0, hv), Vec2d(0, -hv) };
double best = tol; which = -1;
for (int i = 0; i < 4; ++i) {
const Vec3d pt = m_dz_plane.to_world(hpos[i]);
const Vec3d w = pt - ro;
const double t = w.dot(rd) / std::max(rd.dot(rd), 1e-12);
const double d = (w - rd * t).norm();
if (d < best) { best = d; which = i; }
}
if (m_dz_offset_on) { // offset arrow tip = handle 4
const Vec3d pt = m_dz_anchor + m_dz_normal * m_dz_offset;
const Vec3d w = pt - ro;
const double t = w.dot(rd) / std::max(rd.dot(rd), 1e-12);
const double d = (w - rd * t).norm();
if (d < best) { best = d; which = 4; }
}
return which >= 0;
}
// Drag a handle: closest point of the mouse ray to the plane axis (u or v) through the origin,
// |param| -> new half-extent, doubled to the full size.
void DesignSketchTool::drag_datum_handle(GLCanvas3D& canvas, const wxMouseEvent& evt, int which)
{
const Linef3 r = canvas.mouse_ray(Point(evt.GetX(), evt.GetY()));
const Vec3d ro = r.a, rd = r.b - r.a;
if (which == 4) { // offset arrow: drag along base normal
const Vec3d e = m_dz_normal; // signed offset, may go negative
const Vec3d w0 = m_dz_anchor - ro;
const double a = e.dot(e), b = e.dot(rd), c = rd.dot(rd), dd = e.dot(w0), ee = rd.dot(w0);
const double denom = a * c - b * b;
if (std::abs(denom) < 1e-7) return; // camera ∥ normal: leave offset as-is
m_dz_offset = (b * ee - c * dd) / denom; // signed distance along the normal
m_dz_plane.origin = m_dz_anchor + m_dz_normal * m_dz_offset; // rectangle follows live
if (on_datum_offset_changed) on_datum_offset_changed(m_dz_offset);
return;
}
const bool uaxis = (which == 0 || which == 1);
const Vec3d e = (uaxis ? m_dz_plane.x_axis : m_dz_plane.y_axis).normalized();
const Vec3d base = m_dz_plane.origin;
const Vec3d w0 = base - ro;
const double a = e.dot(e), b = e.dot(rd), c = rd.dot(rd), dd = e.dot(w0), ee = rd.dot(w0);
const double denom = a * c - b * b;
if (std::abs(denom) < 1e-7) return; // camera ∥ axis: leave size as-is
const double s = (b * ee - c * dd) / denom; // signed coord along e of closest pt
const double size = std::max(2.0, 2.0 * std::abs(s));
if (uaxis) m_dz_usize = size; else m_dz_vsize = size;
if (on_datum_size_changed) on_datum_size_changed(m_dz_usize, m_dz_vsize);
}
// ---- Reference/base planes (Onshape-style default planes) -----------------------------
void DesignSketchTool::set_base_pick(std::vector<SketchPlane> planes, std::vector<int> bases,
std::vector<std::string> labels)
{
m_dbp_active = !planes.empty();
m_dbp_planes = std::move(planes);
m_dbp_base = std::move(bases);
m_dbp_labels = std::move(labels);
if (m_dbp_hover >= int(m_dbp_planes.size())) m_dbp_hover = -1;
}
void DesignSketchTool::clear_base_pick()
{
m_dbp_active = false;
m_dbp_planes.clear();
m_dbp_base.clear();
m_dbp_labels.clear();
m_dbp_hover = -1;
}
// Reference planes are larger than the bed (Onshape default-plane feel). Half-extent = 0.6 * the
// bed's larger side, so the square fully overhangs the print area. Falls back to 150mm if the bed
// isn't queryable yet.
double DesignSketchTool::dbp_half_extent() const
{
double half = 150.0;
if (auto* pl = wxGetApp().plater()) {
const BoundingBoxf bb = pl->build_volume().bounding_volume2d();
const double w = bb.max.x() - bb.min.x(), d = bb.max.y() - bb.min.y();
if (w > 1.0 && d > 1.0) half = 0.6 * std::max(w, d);
}
return half;
}
// Draw the reference planes as large translucent labelled squares; the hovered one brightens.
void DesignSketchTool::render_base_pick()
{
if (!m_dbp_active || m_dbp_planes.empty()) return;
using EPT = GLModel::Geometry::EPrimitiveType;
using EVL = GLModel::Geometry::EVertexLayout;
const double H = dbp_half_extent();
// Onshape-ish per-plane tints: XY blue, XZ green, YZ red (keyed by base index 0/1/2; datums grey).
auto tint = [](int base, bool hot) -> ColorRGBA {
float a = hot ? 0.10f : 0.047f; // base planes kept faint (reduced ~2/3 from 0.30/0.14)
if (base == 0) return ColorRGBA(0.30f, 0.55f, 0.95f, a);
if (base == 1) return ColorRGBA(0.35f, 0.80f, 0.45f, a);
if (base == 2) return ColorRGBA(0.92f, 0.42f, 0.42f, a);
return ColorRGBA(0.70f, 0.72f, 0.78f, a);
};
glsafe(::glDisable(GL_DEPTH_TEST));
glsafe(::glDisable(GL_CULL_FACE));
glsafe(::glEnable(GL_BLEND)); // alpha is ignored without this
glsafe(::glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA));
const SketchPlane saved_plane = m_plane;
for (size_t i = 0; i < m_dbp_planes.size(); ++i) {
const SketchPlane& p = m_dbp_planes[i];
const Vec3d q0 = p.to_world(Vec2d(-H, -H)), q1 = p.to_world(Vec2d(H, -H)),
q2 = p.to_world(Vec2d(H, H)), q3 = p.to_world(Vec2d(-H, H));
GLModel::Geometry quad; quad.format = { EPT::Triangles, EVL::P3 };
quad.add_vertex((Vec3f)q0.cast<float>()); quad.add_vertex((Vec3f)q1.cast<float>());
quad.add_vertex((Vec3f)q2.cast<float>()); quad.add_vertex((Vec3f)q3.cast<float>());
quad.add_triangle(0, 1, 2); quad.add_triangle(0, 2, 3);
GLModel m; m.init_from(std::move(quad));
const bool hot = (int(i) == m_dbp_hover);
const int base = (i < m_dbp_base.size()) ? m_dbp_base[i] : -1;
m.set_color(tint(base, hot));
m.render();
// Label near the top-left corner, drawn in the plane (draw_text lifts through m_plane).
if (i < m_dbp_labels.size() && !m_dbp_labels[i].empty()) {
m_plane = p;
const double th = H * 0.10;
const ColorRGBA lc = tint(base, true); ColorRGBA lcs(lc.r(), lc.g(), lc.b(), 1.0f);
draw_text(m_line_model, m_dbp_labels[i], Vec2d(-H + th * 2.0, H - th * 1.6), th, lcs);
}
}
m_plane = saved_plane; // draw_text renders each label immediately (draw_strokes self-renders)
glsafe(::glDisable(GL_BLEND));
}
// Ray-pick the reference planes: intersect the mouse ray with each plane, keep hits inside the
// square, return the index of the nearest by |t|. -1 on miss.
int DesignSketchTool::hit_test_base_pick(GLCanvas3D& canvas, const wxMouseEvent& evt) const
{
if (!m_dbp_active) return -1;
const double H = dbp_half_extent();
const Linef3 r = canvas.mouse_ray(Point(evt.GetX(), evt.GetY()));
const Vec3d ro = r.a, rd = r.b - r.a;
int best = -1; double best_t = 1e30;
for (size_t i = 0; i < m_dbp_planes.size(); ++i) {
const SketchPlane& p = m_dbp_planes[i];
const double dn = rd.dot(p.normal);
if (std::abs(dn) < 1e-9) continue; // ray parallel to plane
const double t = (p.origin - ro).dot(p.normal) / dn;
if (t < 0) continue; // behind the camera
const Vec3d hit = ro + rd * t;
const Vec3d d = hit - p.origin;
if (std::abs(d.dot(p.x_axis)) > H || std::abs(d.dot(p.y_axis)) > H) continue;
if (t < best_t) { best_t = t; best = int(i); }
}
return best;
}
// ---- Move-body gizmo (M5) -------------------------------------------------------------
void DesignSketchTool::set_move_gizmo(int body, const Vec3d& pivot, const Transform3d& base_xform)
{
@@ -3365,29 +3718,6 @@ void DesignSketchTool::render_hole_gizmo()
m_plane = saved;
}
// (1b) #2 Part B: construction-line dimensions positioning the hole from the face SIDES — a
// horizontal leg from the u-side (umin = one edge) and a vertical leg from the v-side (vmin =
// the adjacent edge) to the hole centre, each with an editable distance label (what users ask
// for: distance from the sides, not x/y from the centre). Falls back to the datum (0,0) when no
// face bounds are known (dropdown-plane hole). Drawn ON the plane, construction green.
{
m_plane = m_hl_plane;
const ColorRGBA con(0.55f, 0.85f, 0.55f, 1.0f);
const double o = 16.0 * upp;
const double ru = m_hl_has_bounds ? m_hl_umin : 0.0; // reference u-side (face edge / datum)
const double rv = m_hl_has_bounds ? m_hl_vmin : 0.0; // reference v-side (face edge / datum)
std::vector<std::pair<Vec2d, Vec2d>> segs;
segs.emplace_back(Vec2d(ru, m_hl_y), Vec2d(m_hl_x, m_hl_y)); // from the u-side to the hole
segs.emplace_back(Vec2d(m_hl_x, rv), Vec2d(m_hl_x, m_hl_y)); // from the v-side to the hole
glsafe(::glDisable(GL_DEPTH_TEST));
draw_strokes(m_hl_stroke_model, segs, std::max(0.5 * upp, 1e-4), con);
DimAnnot dx; dx.kind = DimType::Distance; dx.value = m_hl_x - ru; // distance from the u-side
draw_text(m_line_model, dim_text(dx), Vec2d((ru + m_hl_x) * 0.5, m_hl_y - o), th, con);
DimAnnot dy; dy.kind = DimType::Distance; dy.value = m_hl_y - rv; // distance from the v-side
draw_text(m_line_model, dim_text(dy), Vec2d(m_hl_x + o, (rv + m_hl_y) * 0.5), th, con);
m_plane = saved;
}
// (2) Billboarded handles (centre marker + diameter arrow + depth arrow), screen-facing frame
// at the centre so draw_strokes/draw_text read on top regardless of orientation.
SketchPlane bb; bb.origin = centre; bb.x_axis = right; bb.y_axis = up; bb.normal = cam.get_dir_forward().normalized();
@@ -3424,19 +3754,35 @@ void DesignSketchTool::render_hole_gizmo()
arrow_to(centre + nrm * L, blue, da);
}
// Centre marker: a small billboarded square so the reposition handle is visible + grabbable.
{
const double s = 7.0 * upp;
std::vector<std::pair<Vec2d, Vec2d>> segs;
segs.emplace_back(Vec2d(-s, -s), Vec2d(s, -s));
segs.emplace_back(Vec2d( s, -s), Vec2d(s, s));
segs.emplace_back(Vec2d( s, s), Vec2d(-s, s));
segs.emplace_back(Vec2d(-s, s), Vec2d(-s, -s));
glsafe(::glDisable(GL_DEPTH_TEST));
draw_strokes(m_hl_stroke_model, segs, std::max(0.7 * upp, 1e-4), amber);
}
m_plane = saved;
// Move handle: a small 3D CUBE at the hole centre (Orca text/SVG-on-face feel) — grab and drag
// it to slide the hole across the face. Built from the plane axes so it sits flat on the face.
{
using EPT = GLModel::Geometry::EPrimitiveType;
using EVL = GLModel::Geometry::EVertexLayout;
const double hs = 9.0 * upp; // cube half-size (~9 px); hit-test uses the same below
const Vec3d U = ddir * hs, V = m_hl_plane.y_axis.normalized() * hs, Nn = nrm * hs;
// Cube centred EXACTLY on the surface point (= the hole). Depth-test ON occludes the inner
// half inside the solid, so the visible half-cube reads as planted at the hole — no depth-off
// float that looked offset from the on-surface footprint. Hit-test targets the same `centre`.
Vec3d c8[8];
for (int i = 0; i < 8; ++i)
c8[i] = centre + ((i & 1) ? U : -U) + ((i & 2) ? V : -V) + ((i & 4) ? Nn : -Nn);
// 6 faces (CCW), each as 2 triangles, lightly shaded so the box reads as a cube.
const int faces[6][4] = { {0,1,3,2},{4,6,7,5},{0,4,5,1},{2,3,7,6},{0,2,6,4},{1,5,7,3} };
const float shade[6] = { 0.78f, 1.0f, 0.86f, 0.92f, 0.70f, 0.96f };
glsafe(::glEnable(GL_DEPTH_TEST));
glsafe(::glDisable(GL_CULL_FACE));
for (int f = 0; f < 6; ++f) {
GLModel::Geometry g; g.format = { EPT::Triangles, EVL::P3 };
for (int k = 0; k < 4; ++k) g.add_vertex((Vec3f)c8[faces[f][k]].cast<float>());
g.add_triangle(0, 1, 2); g.add_triangle(0, 2, 3);
GLModel m; m.init_from(std::move(g));
m.set_color(ColorRGBA(1.0f * shade[f], 0.62f * shade[f], 0.16f * shade[f], 1.0f));
m.render();
}
}
}
// Best-matching hole handle under the cursor: centre (0) / diameter (1) / depth (2), or -1.
@@ -3456,11 +3802,10 @@ int DesignSketchTool::hit_test_hole_handle(GLCanvas3D& canvas, const wxMouseEven
const double depL = std::max(m_hl_depth, 40.0 * upp);
const double tol = 12.0 * upp;
// Centre wins at the shared base: all three handles spring from the centre, so a grab within
// tolerance of the centre point is a reposition (the arrows are only grabbable along the shaft
// that extends outward from here). This also keeps an edge-on arrow — e.g. the depth arrow in
// top view, which collapses onto the centre — from stealing the reposition grab.
if (ray_segment_dist3(ro, rd, centre, centre) <= tol) return 0;
// Centre wins at the shared base: the move cube straddles the centre, so a grab within the cube
// half-size is a reposition. The arrows are only grabbable along the shaft extending outward,
// which also keeps an edge-on arrow (e.g. depth in top view) from stealing the reposition grab.
if (ray_segment_dist3(ro, rd, centre, centre) <= 9.0 * upp) return 0; // cube half-size
int best = -1; double bestd = tol;
const double dD = ray_segment_dist3(ro, rd, centre, centre + ddir * rad);
@@ -3469,20 +3814,7 @@ int DesignSketchTool::hit_test_hole_handle(GLCanvas3D& canvas, const wxMouseEven
const double dZ = ray_segment_dist3(ro, rd, centre, centre + nrm * depL);
if (dZ < bestd) { bestd = dZ; best = 2; }
}
if (best >= 0) return best;
// #2 Part B: the X/Y construction-dim labels (edit-only) — only when no arrow handle was hit.
// Larger tolerance since they are text; positions mirror render_hole_gizmo's label offsets.
const double o = 16.0 * upp, ltol = 16.0 * upp;
const double ru = m_hl_has_bounds ? m_hl_umin : 0.0;
const double rv = m_hl_has_bounds ? m_hl_vmin : 0.0;
const Vec3d xlbl = m_hl_plane.to_world(Vec2d((ru + m_hl_x) * 0.5, m_hl_y - o));
const Vec3d ylbl = m_hl_plane.to_world(Vec2d(m_hl_x + o, (rv + m_hl_y) * 0.5));
const double dXl = ray_segment_dist3(ro, rd, xlbl, xlbl);
const double dYl = ray_segment_dist3(ro, rd, ylbl, ylbl);
if (dXl <= ltol && dXl <= dYl) return 3;
if (dYl <= ltol) return 4;
return -1;
return best;
}
// Skew-line closest point of the mouse ray to an axis (anchor + t*dir) -> signed distance along
@@ -4541,6 +4873,21 @@ void glyph_strokes(char c, std::vector<std::pair<Vec2d, Vec2d>>& out, double& ad
poly(out, {Vec2d(0.30, 0.50), Vec2d(0.58, 0.0)});
advance = 0.80;
break;
case 'X':
poly(out, {Vec2d(0.06, 1.0), Vec2d(0.58, 0.0)});
poly(out, {Vec2d(0.58, 1.0), Vec2d(0.06, 0.0)});
advance = 0.72;
break;
case 'Y':
poly(out, {Vec2d(0.06, 1.0), Vec2d(0.32, 0.52)});
poly(out, {Vec2d(0.58, 1.0), Vec2d(0.32, 0.52)});
poly(out, {Vec2d(0.32, 0.52), Vec2d(0.32, 0.0)});
advance = 0.72;
break;
case 'Z':
poly(out, {Vec2d(0.06, 1.0), Vec2d(0.58, 1.0), Vec2d(0.06, 0.0), Vec2d(0.58, 0.0)});
advance = 0.72;
break;
case ' ':
advance = 0.5;
break;
@@ -4734,6 +5081,8 @@ void DesignSketchTool::render_live_quotes(double unit_per_px)
m_live_rrect_w_label = m_live_rrect_h_label = m_live_rrect_r_label = Vec2d(1e18, 1e18);
m_live_aslot_fi = -1;
m_live_aslot_r_label = m_live_aslot_w_label = Vec2d(1e18, 1e18);
m_live_slot_fi = -1;
m_live_slot_len_label = m_live_slot_w_label = m_live_slot_angle_label = Vec2d(1e18, 1e18);
// Edit-op tools (Fillet/Chamfer/Offset/Mirror) put their picks in m_selection for the
// highlight, but their own arrow/label gizmo is the value affordance — don't also draw
// the picked entity's characteristic quotes (Length/Angle/…) or the view gets cluttered.
@@ -4790,24 +5139,33 @@ void DesignSketchTool::render_live_quotes(double unit_per_px)
break;
}
case FeatureKind::Slot: {
// make_slot order: [top line, cap@c1, bottom line, cap@c0]. Centre-distance =
// Distance between the two cap-arc centres; Width = cap Radius (half-width).
const int cap_c1 = f.begin + 1, cap_c0 = f.begin + 3;
if (cap_c0 < int(m_entities.size()) && cap_c1 < int(m_entities.size())) {
DimAnnot dst; dst.kind = DimType::Distance;
dst.ea = cap_c0; dst.ra = SketchPointRole::Center;
dst.eb = cap_c1; dst.rb = SketchPointRole::Center;
// Push the centre-distance label clear ABOVE the slot (past the cap
// half-width) so it sits outside the fillable face — otherwise clicking
// it would hit the interior and trigger face-select. a.side scales the
// quote offset (draw_dim_quote: off = side * max(L*0.18, 8)).
const double Lc = (f.c1 - f.c0).norm();
const double unit = std::max(Lc * 0.18, 8.0);
const double th = std::max(15.0 * unit_per_px, 1e-4);
dst.side = (f.param + th * 2.5) / unit; // clear cap + label height
protos.push_back(dst);
DimAnnot rad; rad.kind = DimType::Radius; rad.ea = cap_c1; // width
protos.push_back(rad);
// Straight slot edits GEOMETRICALLY (like the arc-slot / rounded-rect), NOT through
// the constraint-based scalar quotes. Its dims are the centreline LENGTH (distance
// between the two cap centres c0,c1) and the WIDTH (2*param). The old
// Distance-between-arc-centres + Radius quotes never registered as editable, so the
// labels did nothing on click (nde #6). Draw both labels clear of the fillable face
// and remember the feature so open_primary_autoedit / click-to-promote drive set_slot.
// Slot dims: (1) inter-centre distance, (2) radius (= half-width), (3) centreline angle.
const ColorRGBA dc(0.30f, 0.88f, 0.66f, 1.0f);
const double th = std::max(15.0 * unit_per_px, 1e-4);
Vec2d u = f.c1 - f.c0;
const double Lc = u.norm();
if (Lc > 1e-9) {
u /= Lc;
const Vec2d n(-u.y(), u.x());
const double w = f.param;
DimAnnot len; len.kind = DimType::Length; len.value = Lc;
m_live_slot_len_label = 0.5 * (f.c0 + f.c1) + n * (w + th * 2.0);
draw_text(m_line_model, dim_text(len), m_live_slot_len_label, th, dc);
DimAnnot rd; rd.kind = DimType::Radius; rd.value = w;
m_live_slot_w_label = f.c1 + u * (w + th * 2.0);
draw_text(m_line_model, dim_text(rd), m_live_slot_w_label, th, dc);
double deg = std::atan2(f.c1.y() - f.c0.y(), f.c1.x() - f.c0.x()) * 180.0 / M_PI;
if (deg < 0.0) deg += 360.0;
DimAnnot an; an.kind = DimType::Angle; an.value = deg;
m_live_slot_angle_label = f.c0 - u * (w + th * 2.0);
draw_text(m_line_model, dim_text(an), m_live_slot_angle_label, th, dc);
m_live_slot_fi = fi;
}
break;
}
@@ -4938,7 +5296,7 @@ void DesignSketchTool::render_live_quotes(double unit_per_px)
}
if (protos.empty() && m_live_poly_fi < 0 && m_live_rrect_fi < 0 &&
m_live_aslot_fi < 0) { // ungrouped single entity
m_live_aslot_fi < 0 && m_live_slot_fi < 0) { // ungrouped single entity
switch (e.type) {
case SketchEntity::Type::Line: {
DimAnnot len; len.kind = DimType::Length; len.ea = ei; len.side = 1.0; protos.push_back(len);
@@ -4962,7 +5320,7 @@ void DesignSketchTool::render_live_quotes(double unit_per_px)
// GEOMETRIC edit (SLVS angle constraints are line-to-line). A wedge spans the arc's
// start->end angles just OUTSIDE the radius; its label shows the included angle and is
// clickable to type a new sweep. The radius quote is still emitted via `protos`.
if (m_live_poly_fi < 0 && m_live_rrect_fi < 0 && m_live_aslot_fi < 0 &&
if (m_live_poly_fi < 0 && m_live_rrect_fi < 0 && m_live_aslot_fi < 0 && m_live_slot_fi < 0 &&
e.type == SketchEntity::Type::Arc && e.radius > 1e-6) {
const ColorRGBA dc(0.30f, 0.88f, 0.66f, 1.0f);
const double th = std::max(15.0 * unit_per_px, 1e-4);
@@ -5999,6 +6357,8 @@ void DesignSketchTool::render(GLCanvas3D& canvas)
if (!m_active) {
render_datum_planes();
render_solid_highlight();
if (m_dbp_active) render_base_pick();
if (m_dz_active) render_datum_gizmo();
if (m_ex_active) render_extrude_gizmo();
if (m_mv_active) render_move_gizmo();
if (m_fl_active) render_fillet_gizmo();
@@ -6749,6 +7109,33 @@ bool DesignSketchTool::on_mouse(wxMouseEvent& evt, GLCanvas3D& canvas)
}
}
}
// Datum-plane resize gizmo (C3): while the Plane card is open the 4 edge handles are
// grabbable — drag changes the u/v extent live. A LeftDown that misses falls through.
if (m_dz_active) {
if (m_dz_drag >= 0 && evt.Dragging() && evt.LeftIsDown()) {
drag_datum_handle(canvas, evt, m_dz_drag);
return true;
}
if (evt.LeftUp() && m_dz_drag >= 0) { m_dz_drag = -1; return true; }
if (evt.LeftDown()) {
int which = -1;
if (hit_test_datum_handle(canvas, evt, which)) {
m_dz_drag = which; m_dz_press_x = evt.GetX(); m_dz_press_y = evt.GetY();
return true;
}
}
}
// Datum base picker: HOVER highlight only here. The CLICK is handled at the very end of the
// selection fall-through (below), so picking existing geometry (committed sketch loops,
// solid faces/edges) always wins over a base-plane click — the planes never block selection.
if (m_dbp_active && evt.Moving() && !evt.LeftIsDown()) {
const int h = hit_test_base_pick(canvas, evt);
if (h != m_dbp_hover) {
m_dbp_hover = h;
canvas.set_as_dirty();
if (h >= 0) return true; // caller render()s on true -> hover repaints on software GL
}
}
if (m_ex_active) {
if (m_ex_drag >= 0 && evt.Dragging() && evt.LeftIsDown()) {
drag_extrude_arrow(canvas, evt, m_ex_drag);
@@ -6922,6 +7309,14 @@ bool DesignSketchTool::on_mouse(wxMouseEvent& evt, GLCanvas3D& canvas)
return true;
}
m_display_pick = -1; m_display_pick_region = -1; // clicked bare plate -> drop highlight
// Last resort: a click that hit no geometry but landed on a reference/base plane picks it.
if (m_dbp_active) {
const int h = hit_test_base_pick(canvas, evt);
if (h >= 0 && h < int(m_dbp_base.size())) {
if (on_datum_base_picked) on_datum_base_picked(m_dbp_base[h]);
return true;
}
}
return false; // let the stock canvas orbit / deselect
}
@@ -6938,7 +7333,16 @@ bool DesignSketchTool::on_mouse(wxMouseEvent& evt, GLCanvas3D& canvas)
Vec2d p; screen_to_plane(canvas, evt, p);
const Linef3 r2 = canvas.mouse_ray(Point(evt.GetX() + 8, evt.GetY()));
const double tol = std::max(1e-3, (m_plane.project(r2.a, r2.vector()) - p).norm());
if (apply_live_trim(p, tol * 3.0, m_mode == Mode::Extend)) resolve_live();
if (apply_live_trim(p, tol * 3.0, m_mode == Mode::Extend)) {
resolve_live();
} else if (on_readout) {
// nde #15: don't fail silently. The pick found nothing to cut/extend — either
// the click missed every live segment, or the picked segment has no crossing /
// target among the OTHER live entities (committed sketches aren't trimmed).
on_readout(m_mode == Mode::Extend
? std::string("Extend: click a line/arc that can reach another live entity")
: std::string("Trim: click a segment where it crosses another live entity"));
}
return true;
}
if (evt.RightDown()) { request_exit(); return true; }
@@ -7279,6 +7683,11 @@ bool DesignSketchTool::on_mouse(wxMouseEvent& evt, GLCanvas3D& canvas)
if ((m_live_aslot_r_label - p).norm() <= ltol) { open_arc_slot_editor(m_live_aslot_fi, true); return true; }
if ((m_live_aslot_w_label - p).norm() <= ltol) { open_arc_slot_editor(m_live_aslot_fi, false); return true; }
}
if (m_live_slot_fi >= 0) {
if ((m_live_slot_len_label - p).norm() <= ltol) { open_slot_editor(m_live_slot_fi, 0); return true; }
if ((m_live_slot_w_label - p).norm() <= ltol) { open_slot_editor(m_live_slot_fi, 1); return true; }
if ((m_live_slot_angle_label - p).norm() <= ltol) { open_slot_editor(m_live_slot_fi, 2); return true; }
}
}
// A derived handle (the circle RadiusHandle — not an entity point, so