diff --git a/src/libslic3r/CadDocument.cpp b/src/libslic3r/CadDocument.cpp index 79f92c3cdc..a6c331e9e0 100644 --- a/src/libslic3r/CadDocument.cpp +++ b/src/libslic3r/CadDocument.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -22,6 +23,7 @@ #include #include #include +#include #include #include #include @@ -74,19 +76,26 @@ static TopoDS_Wire make_helix_wire(const gp_Ax3& axis, double radius, // - internal: the V is CUT from the wall -> a sunken helical groove. The cut MUST // go outward into the wall to be visible; an inward V (the old behaviour) only // sweeps already-empty bore space and removes nothing. -static TopoDS_Face make_thread_profile(const gp_Pnt& origin, const gp_Dir& xdir, +static TopoDS_Wire make_thread_profile(const gp_Pnt& origin, const gp_Dir& xdir, const gp_Dir& zdir, double radius, double pitch, double depth, bool internal) { (void)internal; gp_Vec vx(xdir), vz(zdir); - double inner = radius - 0.05; // base, just inside the wall (overlaps rod / open bore) + // Root the V CLEARLY inside the wall (a real overlap, not a 0.05 mm tangency) so the boolean + // has clean intersections — near-coincident faces are what make OCCT's fuse/cut unstable. + const double over = std::min(std::max(depth, 0.25), radius * 0.4); + double inner = radius - over; // base, well inside the wall (solid overlap) double crest = radius + depth; // apex, `depth` into the surrounding material - gp_Pnt top (origin.XYZ() + (vx * inner).XYZ() + (vz * ( 0.5 * pitch)).XYZ()); - gp_Pnt bot (origin.XYZ() + (vx * inner).XYZ() + (vz * (-0.5 * pitch)).XYZ()); + // Axial half-height must be < pitch/2 so ADJACENT helix turns don't collide — a full-pitch + // profile makes the swept solid self-intersect (invalid -> never renders, or crashes the + // boolean). 0.42*pitch leaves a clean gap between turns; the V still reads as a thread. + const double half = 0.42 * pitch; + gp_Pnt top (origin.XYZ() + (vx * inner).XYZ() + (vz * ( half)).XYZ()); + gp_Pnt bot (origin.XYZ() + (vx * inner).XYZ() + (vz * (-half)).XYZ()); gp_Pnt apex(origin.XYZ() + (vx * crest).XYZ()); BRepBuilderAPI_MakePolygon poly(top, bot, apex, Standard_True); - return BRepBuilderAPI_MakeFace(poly.Wire(), Standard_True).Face(); + return poly.Wire(); // closed triangle, swept by MakePipeShell with a fixed binormal } // --------------------------------------------------------------------------- @@ -697,20 +706,173 @@ static SketchPlane offset_angle_plane(const SketchPlane& base, double offset, return p; } +// Build a full orthonormal frame from a normal + origin. +static SketchPlane frame_from(const Vec3d& origin, const Vec3d& normal) +{ + Vec3d n = normal.normalized(); + Vec3d ref = (std::abs(n.z()) < 0.9) ? Vec3d(0, 0, 1) : Vec3d(1, 0, 0); + Vec3d x = ref.cross(n); + if (x.squaredNorm() < 1e-12) x = Vec3d(1, 0, 0); + x.normalize(); + Vec3d y = n.cross(x).normalized(); + SketchPlane p; + p.origin = origin; + p.normal = n; + p.x_axis = x; + p.y_axis = y; + return p; +} + std::vector> CadDocument::resolve_datum_planes() const { std::vector> out; for (const CadFeature& f : features) { if (f.type != CadFeatureType::Plane || !f.enabled) continue; + + // Resolve base reference plane. The default XY/XZ/YZ planes pass through the modeling + // origin (bed centre); datum bases (>=3) are already in world coords from earlier passes. SketchPlane base; - if (f.plane_base == 1) base = SketchPlane::XZ(); - else if (f.plane_base == 2) base = SketchPlane::YZ(); + if (f.plane_base == 1) { base = SketchPlane::XZ(); base.origin += modeling_origin; } + else if (f.plane_base == 2) { base = SketchPlane::YZ(); base.origin += modeling_origin; } else if (f.plane_base >= 3) { - const int di = f.plane_base - 3; // index into earlier datum planes - if (di < int(out.size())) base = out[di].second; // else XY default + const int di = f.plane_base - 3; + if (di < int(out.size())) base = out[di].second; } - out.emplace_back(f.name, - offset_angle_plane(base, f.plane_offset, f.plane_angle_tilt, f.plane_axis)); + else { base = SketchPlane::XY(); base.origin += modeling_origin; } + + // --- Resolve refs from bodies --- + auto resolve_face = [&](int body_idx, int face_idx) -> TopoDS_Face { + if (face_idx < 0 || body_idx < 0 || body_idx >= int(bodies.size())) + return TopoDS_Face(); + return GeometryEngine::face_by_index(bodies[body_idx].shape, face_idx); + }; + auto resolve_edge = [&](int body_idx, int edge_idx, + Vec3d& p0, Vec3d& dir) -> bool { + if (edge_idx < 0 || body_idx < 0 || body_idx >= int(bodies.size())) + return false; + TopoDS_Edge e = GeometryEngine::edge_by_index(bodies[body_idx].shape, edge_idx); + if (e.IsNull()) return false; + auto pts = GeometryEngine::sample_edge_world(e); + if (pts.size() < 2) return false; + p0 = pts.front(); + dir = (pts.back() - pts.front()).normalized(); + return true; + }; + + // Face A + TopoDS_Face faceA = resolve_face(f.plane_face_body, f.plane_face); + SketchPlane faceA_plane; + bool has_faceA = false; + if (!faceA.IsNull()) { + faceA_plane = frame_from( + GeometryEngine::face_centroid_world(faceA), + GeometryEngine::face_normal_world(faceA)); + has_faceA = true; + } + + // Face B + TopoDS_Face faceB = resolve_face(f.plane_face2_body, f.plane_face2); + SketchPlane faceB_plane; + bool has_faceB = false; + if (!faceB.IsNull()) { + faceB_plane = frame_from( + GeometryEngine::face_centroid_world(faceB), + GeometryEngine::face_normal_world(faceB)); + has_faceB = true; + } + + // Edge A + Vec3d eA_p0, eA_dir; + bool has_edgeA = resolve_edge(f.plane_edge_body, f.plane_edge, eA_p0, eA_dir); + + // Edge B + Vec3d eB_p0, eB_dir; + bool has_edgeB = resolve_edge(f.plane_edge2_body, f.plane_edge2, eB_p0, eB_dir); + + // --- Dispatch on plane_type --- + auto fallback_offset = [&]() { + return offset_angle_plane(base, f.plane_offset, f.plane_angle_tilt, f.plane_axis); + }; + + SketchPlane result; + switch (f.plane_type) { + + case PlaneType::Offset: { + // From a picked face: pure offset along its normal. From a base/datum plane: + // offset + the legacy tilt-about-axis (keeps the old Offset/Tilt controls live). + if (has_faceA) + result = frame_from(faceA_plane.origin + faceA_plane.normal * f.plane_offset, + faceA_plane.normal); + else + result = offset_angle_plane(base, f.plane_offset, f.plane_angle_tilt, f.plane_axis); + break; + } + + case PlaneType::Coincident: { + result = has_faceA ? faceA_plane : base; + break; + } + + case PlaneType::Angle: { + if (!has_edgeA) { result = fallback_offset(); break; } + const SketchPlane& ref = has_faceA ? faceA_plane : base; + Vec3d n0 = ref.normal - eA_dir * ref.normal.dot(eA_dir); + if (n0.squaredNorm() < 1e-12) { + Vec3d perp = (std::abs(eA_dir.z()) < 0.9) ? Vec3d(0, 0, 1) : Vec3d(1, 0, 0); + n0 = perp - eA_dir * perp.dot(eA_dir); + } + n0.normalize(); + const double a = f.plane_angle_tilt * M_PI / 180.0; + Vec3d n_rot = n0 * std::cos(a) + eA_dir.cross(n0) * std::sin(a) + + eA_dir * (eA_dir.dot(n0)) * (1.0 - std::cos(a)); + result = frame_from(eA_p0, n_rot); + break; + } + + case PlaneType::Midplane: { + if (!has_faceA || !has_faceB) { result = fallback_offset(); break; } + Vec3d origin = 0.5 * (faceA_plane.origin + faceB_plane.origin); + Vec3d nB = (faceA_plane.normal.dot(faceB_plane.normal) >= 0) + ? faceB_plane.normal : -faceB_plane.normal; + Vec3d normal = (faceA_plane.normal + nB).normalized(); + result = frame_from(origin, normal); + break; + } + + case PlaneType::Tangent: { + if (!has_faceA) { result = fallback_offset(); break; } + GeometryEngine::CylinderFace cyl = GeometryEngine::cylinder_of_face(faceA); + if (!cyl.ok) { result = fallback_offset(); break; } + Vec3d refdir = (std::abs(cyl.axis.z()) < 0.9) ? Vec3d(0, 0, 1) : Vec3d(1, 0, 0); + refdir = refdir - cyl.axis * refdir.dot(cyl.axis); + refdir.normalize(); + const double theta = f.plane_angle_tilt * M_PI / 180.0; + Vec3d r = refdir * std::cos(theta) + cyl.axis.cross(refdir) * std::sin(theta); + Vec3d touch = cyl.base + r * cyl.radius; + result = frame_from(touch, r); + break; + } + + case PlaneType::TwoEdges: { + if (!has_edgeA) { result = fallback_offset(); break; } + if (!has_edgeB) { result = fallback_offset(); break; } + Vec3d cross = eA_dir.cross(eB_dir); + if (cross.squaredNorm() > 1e-12) { + result = frame_from(eA_p0, cross.normalized()); + } else { + Vec3d v = eA_dir.cross(eB_p0 - eA_p0); + if (v.squaredNorm() > 1e-12) { + result = frame_from(eA_p0, v.normalized()); + } else { + result = fallback_offset(); + } + } + break; + } + + } + + out.emplace_back(f.name, result); } return out; } @@ -1187,6 +1349,17 @@ void CadDocument::apply_feature(TopoDS_Shape& result, bool& have_body, break; } case CadFeatureType::Thread: { + // Reject degenerate parameters that make OCCT's helical sweep / boolean unstable (a tiny + // pitch, depth >= half-pitch, an enormous turn count, depth eating the whole wall). Better + // a no-op than a crash. Leave the body unchanged when the spec can't be built safely. + { + const double R = f.thread_radius, P = f.thread_pitch, H = f.thread_height, D = f.thread_depth; + // ISO external thread depth is ~0.61*P, so allow up to 0.7*P (0.49 wrongly rejected + // every real thread -> nothing rendered). Still bound it well under a full pitch. + const bool ok = R > 0.5 && P > 0.1 && D > 1e-3 && D < 0.7 * P && D < 0.45 * R + && H > 0.5 * P && (H / P) < 400.0; + if (!ok) break; // result/have_body untouched + } // Axis at the positioned point on the plane; +normal = thread rise. Vec3d c3 = f.plane.to_world(Vec2d(f.thread_x, f.thread_y)); gp_Pnt c(c3.x(), c3.y(), c3.z()); @@ -1201,17 +1374,25 @@ void CadDocument::apply_feature(TopoDS_Shape& result, bool& have_body, try { TopoDS_Wire spine = make_helix_wire(ax3, f.thread_radius, f.thread_pitch, f.thread_height); - TopoDS_Face prof = make_thread_profile(c, xdir, zdir, f.thread_radius, + TopoDS_Wire prof = make_thread_profile(c, xdir, zdir, f.thread_radius, f.thread_pitch, f.thread_depth, f.thread_internal); - BRepOffsetAPI_MakePipe pipe(spine, prof); + // MakePipeShell with a FIXED BINORMAL = cylinder axis keeps the V-profile's orientation + // constant along the helix (axial edge always parallel to the axis, V always pointing + // radially out). The plain MakePipe used a Frenet frame that TWISTED the profile around + // the helix -> the wedge inclination varied and looked mirrored. + BRepOffsetAPI_MakePipeShell pipe(spine); + pipe.SetMode(zdir); + pipe.Add(prof); pipe.Build(); - if (pipe.IsDone()) { + if (pipe.IsDone() && pipe.MakeSolid()) { ridge = pipe.Shape(); have_ridge = !ridge.IsNull(); } } catch (const std::exception&) { have_ridge = false; // fall back to the bare cylinder/bore below + } catch (const Standard_Failure&) { + have_ridge = false; // OCCT failure (not a std::exception) — must be caught here too } if (f.thread_internal) { @@ -1233,15 +1414,25 @@ void CadDocument::apply_feature(TopoDS_Shape& result, bool& have_body, result = cut_ridge.Shape(); } } else { - // External threaded rod = a New body: base cylinder + fused ridge. - TopoDS_Shape rod = BRepPrimAPI_MakeCylinder(ax2, f.thread_radius, - f.thread_height).Shape(); - if (have_ridge) { - BRepAlgoAPI_Fuse fuse(rod, ridge); - if (fuse.IsDone()) rod = fuse.Shape(); + // External thread: FUSE the helical ridge ONTO the existing body (the picked cylinder), + // leaving the rest of the part intact. Replacing the body with a bare rod — the old + // behaviour — wiped whatever the user picked; that was the "mess". With no body yet + // (a thread from scratch on a dropdown plane), fall back to a standalone threaded rod. + if (have_body && !result.IsNull()) { + if (have_ridge) { + BRepAlgoAPI_Fuse fuse(result, ridge); + if (fuse.IsDone() && !fuse.Shape().IsNull()) result = fuse.Shape(); + } + } else { + TopoDS_Shape rod = BRepPrimAPI_MakeCylinder(ax2, f.thread_radius, + f.thread_height).Shape(); + if (have_ridge) { + BRepAlgoAPI_Fuse fuse(rod, ridge); + if (fuse.IsDone()) rod = fuse.Shape(); + } + result = rod; + have_body = true; } - result = rod; - have_body = true; } break; } diff --git a/src/libslic3r/CadDocument.hpp b/src/libslic3r/CadDocument.hpp index 877d8d4244..2c45f765ee 100644 --- a/src/libslic3r/CadDocument.hpp +++ b/src/libslic3r/CadDocument.hpp @@ -19,6 +19,7 @@ namespace Slic3r { enum class CadFeatureType { Sketch, Extrude, Fillet, Chamfer, Hole, Thread, Shell, Revolve, Sweep, Pattern, Plane, Loft, Draft, Import, Boolean, Cut }; enum class SketchShape { Rectangle, Circle }; +enum class PlaneType { Offset, Angle, Midplane, Tangent, TwoEdges, Coincident }; enum class BooleanMode { New, Add, Cut, Intersect }; enum class ExtrudeEnd { Blind, Symmetric, TwoSided, ThroughAll, UpToFace, UpToVertex }; @@ -169,6 +170,17 @@ struct CadFeature { double plane_offset{20}; double plane_angle_tilt{0}; // degrees (named *_tilt to avoid clash w/ revolve) int plane_axis{0}; // tilt axis: 0 = base X, 1 = base Y + PlaneType plane_type{PlaneType::Offset}; + int plane_face_body{-1}; + int plane_face{-1}; + int plane_face2_body{-1}; + int plane_face2{-1}; + int plane_edge_body{-1}; + int plane_edge{-1}; + int plane_edge2_body{-1}; + int plane_edge2{-1}; + double plane_u_size{60}; + double plane_v_size{60}; // Boolean: combine two EXISTING bodies. `mode` reuses BooleanMode (Add = union, // Cut = subtract tool from target, Intersect = keep overlap; New unused). `target_body` @@ -208,8 +220,10 @@ struct CadFeature { pattern_circular, pattern_count, pattern_spacing, pattern_dir, pattern_angle, plane_base, plane_offset, plane_angle_tilt, plane_axis, bool_tool_body, bool_keep_tool, bool_tolerance, bool_target_face, bool_tool_face, - cut_offset, cut_flip, cut_keep_upper, cut_keep_lower, - brep); + cut_offset, cut_flip, cut_keep_upper, cut_keep_lower, + brep, + plane_type, plane_face_body, plane_face, plane_face2_body, plane_face2, + plane_edge_body, plane_edge, plane_edge2_body, plane_edge2, plane_u_size, plane_v_size); } template void load(Archive& ar) { @@ -230,7 +244,9 @@ struct CadFeature { plane_base, plane_offset, plane_angle_tilt, plane_axis, bool_tool_body, bool_keep_tool, bool_tolerance, bool_target_face, bool_tool_face, cut_offset, cut_flip, cut_keep_upper, cut_keep_lower, - brep); + brep, + plane_type, plane_face_body, plane_face, plane_face2_body, plane_face2, + plane_edge_body, plane_edge, plane_edge2_body, plane_edge2, plane_u_size, plane_v_size); imported_solid = brep_from_string(brep); } }; @@ -263,6 +279,11 @@ public: std::vector display_tri_body; // per-triangle source body index (into bodies) std::string error; // last recompute error ("" = ok) + // Modeling origin: the world point the default XY/XZ/YZ planes pass through. The GUI sets this + // to the bed centre so sketches/datums land in the middle of the bed (not the bed corner = + // world 0). Not serialized — the GUI re-applies it from the live bed on every tab show. + Vec3d modeling_origin{Vec3d::Zero()}; + double linear_deflection{0.01}; double angular_deflection{0.5}; diff --git a/src/libslic3r/GeometryEngine.cpp b/src/libslic3r/GeometryEngine.cpp index 6ecd02d20c..58f35f2671 100644 --- a/src/libslic3r/GeometryEngine.cpp +++ b/src/libslic3r/GeometryEngine.cpp @@ -23,6 +23,7 @@ #include #include #include +#include #include #include #include @@ -397,6 +398,23 @@ GeometryEngine::CylinderFace GeometryEngine::cylinder_of_face(const TopoDS_Face& return cf; } +GeometryEngine::CylinderFace GeometryEngine::circle_of_edge(const TopoDS_Edge& edge) +{ + CylinderFace cf; + if (edge.IsNull()) return cf; + BRepAdaptor_Curve curve(edge); + if (curve.GetType() != GeomAbs_Circle) return cf; + const gp_Circ c = curve.Circle(); + const gp_Ax1 ax = c.Axis(); + cf.base = Vec3d(c.Location().X(), c.Location().Y(), c.Location().Z()); + cf.axis = Vec3d(ax.Direction().X(), ax.Direction().Y(), ax.Direction().Z()); + cf.radius = c.Radius(); + cf.height = 0.0; // an edge carries no axial extent; the card keeps the current length + cf.internal = false; // ambiguous from an edge alone — default external, user can toggle + cf.ok = true; + return cf; +} + bool GeometryEngine::face_plane_bounds(const TopoDS_Face& face, const Vec3d& origin, const Vec3d& x_axis, const Vec3d& y_axis, double& umin, double& umax, double& vmin, double& vmax) diff --git a/src/libslic3r/GeometryEngine.hpp b/src/libslic3r/GeometryEngine.hpp index f65afa4d5c..6032993001 100644 --- a/src/libslic3r/GeometryEngine.hpp +++ b/src/libslic3r/GeometryEngine.hpp @@ -103,6 +103,10 @@ public: bool internal{false}; }; static CylinderFace cylinder_of_face(const TopoDS_Face& face); + // Circular edge (a cylinder's perimeter): base = circle centre, axis = circle normal, + // radius = circle radius, height = 0 (unknown from an edge), internal = false. ok=false if + // the edge is not a circle. Lets the Thread tool be driven by a picked circular rim. + static CylinderFace circle_of_edge(const TopoDS_Edge& edge); // Plane-coordinate (u,v) bounding box of a face's vertices, measured from `origin` along // `x_axis`/`y_axis`. Lets the Hole tool dimension the hole from the face SIDES (umin/vmin = diff --git a/src/slic3r/GUI/3DBed.hpp b/src/slic3r/GUI/3DBed.hpp index b791635fd3..e6335fba3a 100644 --- a/src/slic3r/GUI/3DBed.hpp +++ b/src/slic3r/GUI/3DBed.hpp @@ -134,6 +134,7 @@ public: void set_position(Vec2d& position); void set_axes_mode(bool origin); + void set_axes_origin(const Vec3d& origin) { m_axes.set_origin(origin); } // Design tab: triad at bed centre const Vec2d& get_position() const { return m_position; } // Build volume geometry for various collision detection tasks. diff --git a/src/slic3r/GUI/DesignCanvas.cpp b/src/slic3r/GUI/DesignCanvas.cpp index 5c5fe98ffc..126719862b 100644 --- a/src/slic3r/GUI/DesignCanvas.cpp +++ b/src/slic3r/GUI/DesignCanvas.cpp @@ -53,6 +53,7 @@ DesignCanvas::DesignCanvas(wxWindow* parent) m_canvas->enable_collapse_toolbar(false); m_canvas->enable_plate_chrome(false); m_canvas->enable_labels(false); + m_canvas->set_axes_at_bed_center(true); // triad at bed centre = modeling origin m_canvas->set_design_sketch_tool(&m_sketch_tool); m_sketch_tool.on_commit = [this](const SketchProfile& prof, const SketchPlane& pl) { @@ -663,6 +664,48 @@ void DesignCanvas::set_on_extrude_depth_changed(std::function cb) +{ + m_sketch_tool.on_datum_size_changed = std::move(cb); +} + +void DesignCanvas::set_on_datum_offset_changed(std::function cb) +{ + m_sketch_tool.on_datum_offset_changed = std::move(cb); +} + +void DesignCanvas::set_base_pick(std::vector planes, std::vector bases, + std::vector labels) +{ + m_sketch_tool.set_base_pick(std::move(planes), std::move(bases), std::move(labels)); + request_repaint(); +} + +void DesignCanvas::clear_base_pick() +{ + m_sketch_tool.clear_base_pick(); + request_repaint(); +} + +void DesignCanvas::set_on_datum_base_picked(std::function cb) +{ + m_sketch_tool.on_datum_base_picked = std::move(cb); +} + void DesignCanvas::set_on_sketch_exit(std::function cb) { m_sketch_tool.on_exit = std::move(cb); @@ -685,9 +728,9 @@ void DesignCanvas::set_display_sketches(std::vector planes) +void DesignCanvas::set_datum_planes(std::vector planes, std::vector sizes) { - m_sketch_tool.set_datum_planes(std::move(planes)); + m_sketch_tool.set_datum_planes(std::move(planes), std::move(sizes)); request_repaint(); } diff --git a/src/slic3r/GUI/DesignCanvas.hpp b/src/slic3r/GUI/DesignCanvas.hpp index 7ff5f993a2..5ccc578fa3 100644 --- a/src/slic3r/GUI/DesignCanvas.hpp +++ b/src/slic3r/GUI/DesignCanvas.hpp @@ -143,11 +143,22 @@ public: double depth, double depth2, bool two_sided, bool flip); void clear_extrude_gizmo(); void set_on_extrude_depth_changed(std::function cb); + void set_datum_gizmo(const SketchPlane& plane, double usize, double vsize, + const Vec3d& base_origin, const Vec3d& base_normal, + double offset, bool offset_on); // C3 resize handles + offset arrow + void clear_datum_gizmo(); + void set_on_datum_size_changed(std::function cb); + void set_on_datum_offset_changed(std::function cb); + void set_base_pick(std::vector planes, std::vector bases, + std::vector labels = {}); // clickable labelled reference planes + void clear_base_pick(); + void set_on_datum_base_picked(std::function cb); void set_on_sketch_exit(std::function cb); // Esc -> exit the tool void set_on_undo_redo(std::function cb); // Ctrl+Z / Ctrl+Shift+Z // Persistently draw committed sketches (un-consumed ones stay visible). void set_display_sketches(std::vector ds); - void set_datum_planes(std::vector planes); // draw datum/reference planes + void set_datum_planes(std::vector planes, + std::vector sizes = {}); // draw datum/reference planes (u/v extents) void set_body_highlight(bool on); // tint the solid when its feature is tree-selected void set_body_translucent(bool on); // render the solid see-through (fillet/chamfer preview) void set_body_hidden(bool on); // preview-only: hide base bodies, show only the result ghost @@ -200,15 +211,16 @@ public: // Constraint glyph badges (C3.4b): the feature's constraints, drawn as iconic // marks near their entities in Constrain mode; empty clears them. void set_constraint_glyphs(std::vector cons); - -private: - void reload(bool keep_view); // Repaint the embedded canvas the right way for the active GL backend: // hardware GL gets a scheduled wxEVT_PAINT (render() runs inside the paint // cycle); software GL (llvmpipe etc.) gets a direct render() because a // scheduled Refresh() is frequently dropped there. Backend cached on first use. + // Public: DesignPanel calls it after a tree edit to force a frame on software GL. void request_repaint(); +private: + void reload(bool keep_view); + wxGLCanvas* m_canvas_widget{nullptr}; GLCanvas3D* m_canvas{nullptr}; int m_sw_gl{-1}; // -1 unknown, 0 hardware GL, 1 software GL diff --git a/src/slic3r/GUI/DesignPanel.cpp b/src/slic3r/GUI/DesignPanel.cpp index 180dcf3ef9..5181bcb49e 100644 --- a/src/slic3r/GUI/DesignPanel.cpp +++ b/src/slic3r/GUI/DesignPanel.cpp @@ -37,6 +37,7 @@ #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" @@ -394,6 +395,7 @@ DesignPanel::DesignPanel(wxWindow* parent) auto* b_plane = icon_btn("design_plane", _L("Plane")); b_plane->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { populate_plane_choices(m_plane_base); // refresh base list w/ existing datum planes + reset_plane_refs(); // fresh datum: no captured face/edge refs open_tool(Tool::Plane); }); fadd(b_plane); @@ -474,38 +476,40 @@ DesignPanel::DesignPanel(wxWindow* parent) } open_tool(Tool::Hole); }}, - {"design_thread", _L("Thread"), _L("Thread a picked cylindrical face (hole bore or cylinder)"), + {"design_thread", _L("Thread"), _L("Thread a cylindrical surface (inner bore / outer) or a circular edge"), [this] { - // #3: invoke on a picked CYLINDRICAL face — a hole bore (internal thread) or a - // cylinder's lateral surface (external) — deriving axis, radius and internal/ - // external from it. Otherwise fall back to the plane dropdown. + // 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; - 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); - const GeometryEngine::CylinderFace cf = GeometryEngine::cylinder_of_face(face); - 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; - if (m_thread_radius) m_thread_radius->SetValue(cf.radius); - if (m_thread_height) 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_status->SetForegroundColour(wxColour(235, 110, 110)); - m_status->SetLabel(_L("Pick a cylindrical face (hole bore or cylinder) for a thread")); - m_status->Refresh(); - } + GeometryEngine::CylinderFace cf; + if (m_sel_solid_body >= 0 && m_sel_solid_body < int(m_doc.bodies.size())) { + const TopoDS_Shape& shape = m_doc.bodies[m_sel_solid_body].shape; + if (m_sel_solid_face >= 0) + cf = GeometryEngine::cylinder_of_face(GeometryEngine::face_by_index(shape, m_sel_solid_face)); + if (!cf.ok && m_sel_solid_edge >= 0) + cf = GeometryEngine::circle_of_edge(GeometryEngine::edge_by_index(shape, m_sel_solid_edge)); + } + if (cf.ok) { + SketchPlane p; // plane on the axis (origin at the base) + p.origin = cf.base; + p.normal = cf.axis; + const Vec3d ref = std::abs(cf.axis.z()) < 0.9 ? Vec3d(0, 0, 1) : Vec3d(1, 0, 0); + p.x_axis = ref.cross(cf.axis).normalized(); + p.y_axis = cf.axis.cross(p.x_axis).normalized(); + m_thread_face_plane = p; + m_thread_on_face = true; + m_thread_face_body = m_sel_solid_body; + infer_thread_spec(2.0 * cf.radius); // M diameter + pitch + depth from the cylinder + if (m_thread_height && cf.height > 1e-6) m_thread_height->SetValue(cf.height); + if (m_thread_internal) m_thread_internal->SetValue(cf.internal); + if (m_thread_x) m_thread_x->SetValue(0.0); // on the axis + if (m_thread_y) m_thread_y->SetValue(0.0); + } else if (m_sel_solid_face >= 0 || m_sel_solid_edge >= 0) { + m_status->SetForegroundColour(wxColour(235, 110, 110)); + m_status->SetLabel(_L("Pick a cylindrical surface (bore / outer) or a circular edge for a thread")); + m_status->Refresh(); } open_tool(Tool::Thread); }}, @@ -664,6 +668,13 @@ DesignPanel::DesignPanel(wxWindow* parent) _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")}, @@ -692,15 +703,6 @@ DesignPanel::DesignPanel(wxWindow* parent) if (m_viewport) m_viewport->set_sketch_polygon_circumscribed(m_poly_circ->GetValue()); }); sadd(m_poly_circ); add_sep(m_tb_sketch); - // Constrain — reachable mid-sketch: commits the live sketch in place and drops into - // Constrain mode, where the geometric/dimensional palette + Trim + Extend (scissors) - // are picked and applied. (Trim/Extend are pick-then-apply, so they live there, not as - // bare toolbar buttons.) - auto* b_constrain_sk = icon_btn("design_constrain", - _L("Constrain — add geometric/dimensional relations; Trim/Extend live here")); - b_constrain_sk->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { enter_constrain_inline(); }); - sadd(b_constrain_sk); - add_sep(m_tb_sketch); m_construction->Bind(wxEVT_CHECKBOX, [this](wxCommandEvent&) { if (m_viewport && m_viewport->is_sketching()) m_viewport->set_sketch_construction(m_construction->GetValue()); }); @@ -994,8 +996,10 @@ DesignPanel::DesignPanel(wxWindow* parent) tform->Add(new wxStaticText(m_form, wxID_ANY, _L("Standard")), 0, wxALIGN_CENTER_VERTICAL); tform->Add(m_thread_std); - m_thread_radius = make_spin(m_form, 5.0); - tform->Add(new wxStaticText(m_form, wxID_ANY, _L("Radius")), 0, wxALIGN_CENTER_VERTICAL); + // Threads are specified by DIAMETER (M6 = Ø6); the value is derived from the picked cylindrical + // surface / circular edge, so it's a readout users rarely type. Stored field holds the diameter. + m_thread_radius = make_spin(m_form, 10.0); + tform->Add(new wxStaticText(m_form, wxID_ANY, _L("Diameter")), 0, wxALIGN_CENTER_VERTICAL); tform->Add(m_thread_radius); m_thread_pitch = make_spin(m_form, 2.0); @@ -1219,6 +1223,21 @@ DesignPanel::DesignPanel(wxWindow* parent) m_box_plane->Add(card_header("design_sketch", _L("Plane"), m_hdr_plane), 0, wxLEFT | wxRIGHT | wxTOP, 12); m_box_plane->Add(new wxStaticLine(m_form), 0, wxEXPAND | wxALL, 8); { + // Plane type chooses which inputs matter (Onshape/Fusion parity): + // Offset = Base (or Face A) + Offset (+ Tilt about a base axis) + // Angle = Edge A (line) + Base/Face A reference + Angle° + // Midplane = Face A + Face B (halfway between) + // Tangent = Face A (a cylinder) + Angle° around its axis + // Two edges = Edge A + Edge B + // Coincident = Face A (lie on that face) + m_plane_type = new wxChoice(m_form, wxID_ANY); + for (const wxString& t : { _L("Offset"), _L("Angle"), _L("Midplane"), + _L("Tangent"), _L("Two edges"), _L("Coincident") }) + m_plane_type->Append(t); + m_plane_type->SetSelection(0); + m_box_plane->Add(new wxStaticText(m_form, wxID_ANY, _L("Plane type")), 0, wxLEFT | wxRIGHT | wxTOP, 12); + m_box_plane->Add(m_plane_type, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12); + auto* plform = new wxFlexGridSizer(2, 6, 8); m_plane_base = new wxChoice(m_form, wxID_ANY); @@ -1231,7 +1250,7 @@ DesignPanel::DesignPanel(wxWindow* parent) plform->Add(m_plane_offset); m_plane_tilt = make_spin(m_form, 0.0, -180.0, 180.0); - plform->Add(new wxStaticText(m_form, wxID_ANY, _L("Tilt°")), 0, wxALIGN_CENTER_VERTICAL); + plform->Add(new wxStaticText(m_form, wxID_ANY, _L("Angle°")), 0, wxALIGN_CENTER_VERTICAL); plform->Add(m_plane_tilt); m_plane_tilt_axis = new wxChoice(m_form, wxID_ANY); @@ -1241,6 +1260,26 @@ DesignPanel::DesignPanel(wxWindow* parent) plform->Add(new wxStaticText(m_form, wxID_ANY, _L("Tilt axis")), 0, wxALIGN_CENTER_VERTICAL); plform->Add(m_plane_tilt_axis); + // Contextual reference picks: arm a target, then click a solid face/edge in the canvas. + auto pick_row = [&](const wxString& label, wxButton*& btn, wxStaticText*& lbl, PlanePick target) { + btn = new wxButton(m_form, wxID_ANY, label); + lbl = new wxStaticText(m_form, wxID_ANY, _L("(none)")); + btn->Bind(wxEVT_BUTTON, [this, target](wxCommandEvent&) { arm_plane_pick(target); }); + plform->Add(btn); + plform->Add(lbl, 0, wxALIGN_CENTER_VERTICAL); + }; + pick_row(_L("Pick Face A"), m_plane_pick_faceA, m_plane_faceA_lbl, PlanePick::FaceA); + pick_row(_L("Pick Face B"), m_plane_pick_faceB, m_plane_faceB_lbl, PlanePick::FaceB); + pick_row(_L("Pick Edge A"), m_plane_pick_edgeA, m_plane_edgeA_lbl, PlanePick::EdgeA); + pick_row(_L("Pick Edge B"), m_plane_pick_edgeB, m_plane_edgeB_lbl, PlanePick::EdgeB); + + m_plane_usize = make_spin(m_form, 60.0, 1.0, 100000.0); + plform->Add(new wxStaticText(m_form, wxID_ANY, _L("Size U")), 0, wxALIGN_CENTER_VERTICAL); + plform->Add(m_plane_usize); + m_plane_vsize = make_spin(m_form, 60.0, 1.0, 100000.0); + plform->Add(new wxStaticText(m_form, wxID_ANY, _L("Size V")), 0, wxALIGN_CENTER_VERTICAL); + plform->Add(m_plane_vsize); + m_box_plane->Add(plform, 0, wxLEFT | wxRIGHT | wxTOP, 12); } root->Add(m_box_plane, 0, wxEXPAND); @@ -1685,6 +1724,60 @@ DesignPanel::DesignPanel(wxWindow* parent) : _L("(pick a side face)")); refresh_preview(); } + // Hole card open: clicking a solid FACE re-targets the hole ONTO that face (Orca-style), + // so the hole lives on the object's face — not on a stale dropdown/datum plane. Uses the + // face under the cursor from the FIRST click (handle_solid_click reports it even at the + // Whole level), so no whole->face cycle is needed. + if (m_active == Tool::Hole && face >= 0 && body >= 0 && body < int(m_doc.bodies.size())) { + const TopoDS_Face fc = GeometryEngine::face_by_index(m_doc.bodies[body].shape, face); + if (!fc.IsNull()) { + m_hole_face_plane = face_plane_inward(fc); + m_hole_on_face = true; + m_hole_face_body = body; + m_hole_has_bounds = GeometryEngine::face_plane_bounds( + fc, m_hole_face_plane.origin, m_hole_face_plane.x_axis, + m_hole_face_plane.y_axis, m_hole_umin, m_hole_umax, m_hole_vmin, m_hole_vmax); + if (m_hole_x) m_hole_x->SetValue(0.0); // centre of the picked face + if (m_hole_y) m_hole_y->SetValue(0.0); + if (m_hole_plane) m_hole_plane->SetSelection(index_from_plane(m_hole_face_plane)); + refresh_preview(); // re-place the gizmo + ghost on the new face + } + } + // Thread card open: clicking a cylindrical face or circular edge re-derives the thread. + if (m_active == Tool::Thread && body >= 0 && body < int(m_doc.bodies.size())) { + const TopoDS_Shape& shape = m_doc.bodies[body].shape; + GeometryEngine::CylinderFace cf; + if (face >= 0) + cf = GeometryEngine::cylinder_of_face(GeometryEngine::face_by_index(shape, face)); + if (!cf.ok && edge >= 0) + cf = GeometryEngine::circle_of_edge(GeometryEngine::edge_by_index(shape, edge)); + if (cf.ok) { + SketchPlane p; p.origin = cf.base; p.normal = cf.axis; + const Vec3d ref = std::abs(cf.axis.z()) < 0.9 ? Vec3d(0, 0, 1) : Vec3d(1, 0, 0); + p.x_axis = ref.cross(cf.axis).normalized(); + p.y_axis = cf.axis.cross(p.x_axis).normalized(); + m_thread_face_plane = p; + m_thread_on_face = true; + m_thread_face_body = m_sel_solid_body; + infer_thread_spec(2.0 * cf.radius); // M diameter + pitch + depth from the cylinder + if (m_thread_height && cf.height > 1e-6) m_thread_height->SetValue(cf.height); + if (m_thread_internal) m_thread_internal->SetValue(cf.internal); + refresh_preview(); + } + } + // Plane tool with a pick armed: capture the right kind of reference (face for Face A/B, + // edge for Edge A/B). If the click wasn't the right kind, stay armed so the user retries. + if (m_active == Tool::Plane && m_plane_pick != PlanePick::None) { + bool got = false; + switch (m_plane_pick) { + case PlanePick::FaceA: if (m_sel_solid_face >= 0) { m_pl_faceA_body = m_sel_solid_body; m_pl_faceA = m_sel_solid_face; got = true; } break; + case PlanePick::FaceB: if (m_sel_solid_face >= 0) { m_pl_faceB_body = m_sel_solid_body; m_pl_faceB = m_sel_solid_face; got = true; } break; + case PlanePick::EdgeA: if (m_sel_solid_edge >= 0) { m_pl_edgeA_body = m_sel_solid_body; m_pl_edgeA = m_sel_solid_edge; got = true; } break; + case PlanePick::EdgeB: if (m_sel_solid_edge >= 0) { m_pl_edgeB_body = m_sel_solid_body; m_pl_edgeB = m_sel_solid_edge; got = true; } break; + default: break; + } + if (got) { m_plane_pick = PlanePick::None; refresh_plane_labels(); } + } m_status->SetForegroundColour(wxNullColour); const int nb = int(m_doc.bodies.size()); const wxString bodytag = (nb > 1) ? wxString::Format(_L("Body %d "), body + 1) : wxString(); @@ -1703,6 +1796,53 @@ DesignPanel::DesignPanel(wxWindow* parent) refresh_preview(); }); + // Datum-plane resize handles (C3): a handle drag reports the new u/v extent. Mirror it into the + // Size spins and, when editing a committed datum, into the feature so the rendered rectangle + // follows live. SetValue doesn't emit a command event, so no refresh_preview recursion. + m_viewport->set_on_datum_size_changed([this](double u, double v) { + if (m_plane_usize) m_plane_usize->SetValue(u); + if (m_plane_vsize) m_plane_vsize->SetValue(v); + if (m_edit_index >= 0 && m_edit_index < int(m_doc.features.size()) && + m_doc.features[m_edit_index].type == CadFeatureType::Plane) { + m_doc.features[m_edit_index].plane_u_size = u; + m_doc.features[m_edit_index].plane_v_size = v; + refresh_datum_planes(); // committed datum rectangle follows the drag + } + m_viewport->request_repaint(); + }); + + // Offset arrow drag: mirror the new offset into the spin + (when editing) the committed feature. + m_viewport->set_on_datum_offset_changed([this](double off) { + if (m_plane_offset) m_plane_offset->SetValue(off); + if (m_edit_index >= 0 && m_edit_index < int(m_doc.features.size()) && + m_doc.features[m_edit_index].type == CadFeatureType::Plane) { + m_doc.features[m_edit_index].plane_offset = off; + refresh_datum_planes(); + } + m_viewport->request_repaint(); + }); + + // Clicking a ghost base plane sets the base graphically (replaces the dropdown). A base pick + // drops any offset-from-face choice so the picked base plane wins, then re-resolves the preview. + m_viewport->set_on_datum_base_picked([this](int base) { + if (m_active == Tool::Plane) { + // Plane tool open: the click sets the datum's base plane (replaces the dropdown). + if (m_plane_base && base >= 0 && base < int(m_plane_base->GetCount())) + m_plane_base->SetSelection(base); + m_pl_faceA_body = m_pl_faceA = -1; + refresh_plane_labels(); + refresh_preview(); // re-resolve the frame + move the gizmo/ghosts to the new base + } else { + // Fallback (no object yet): clicking a reference plane selects it as the sketch plane. + if (m_draw_plane && base >= 0 && base < int(m_draw_plane->GetCount())) + m_draw_plane->SetSelection(base); + const char* nm = (base == 0) ? "XY" : (base == 1) ? "XZ" : (base == 2) ? "YZ" : "datum"; + m_status->SetForegroundColour(wxColour(120, 210, 120)); + m_status->SetLabel(wxString::Format(_L("%s plane selected — press Sketch to draw on it"), nm)); + m_status->Refresh(); + } + }); + // Move-body gizmo (M5): each drag/edit reports the body's new translation. Store it as a // display-only per-body transform and re-feed the moved meshes (the OCCT shape is untouched, // so face/edge ids the dress-up ops target stay valid). @@ -1742,7 +1882,7 @@ DesignPanel::DesignPanel(wxWindow* parent) 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(radius); + 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(); }); @@ -2101,9 +2241,10 @@ void DesignPanel::add_imported_sketch( on_face = true; } } - if (!on_face) - f.plane = m_draw_plane ? plane_from_choice(m_draw_plane->GetSelection()) - : SketchPlane::XY(); + if (!on_face) { + if (m_draw_plane) f.plane = plane_from_choice(m_draw_plane->GetSelection()); + else { f.plane = SketchPlane::XY(); f.plane.origin += m_doc.modeling_origin; } + } // Drop the live face selection (its body is now remembered on import_face_body): otherwise // the next Extrude would push/pull that face instead of extruding the placed art. @@ -2289,8 +2430,10 @@ void DesignPanel::on_add_dressup() SketchPlane DesignPanel::hole_plane() const { - return m_hole_on_face ? m_hole_face_plane - : plane_from_index(m_hole_plane->GetSelection()); + 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() @@ -2323,8 +2466,10 @@ void DesignPanel::on_add_hole() SketchPlane DesignPanel::thread_plane() const { - return m_thread_on_face ? m_thread_face_plane - : plane_from_index(m_thread_plane->GetSelection()); + 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() @@ -2341,13 +2486,13 @@ void DesignPanel::apply_thread_standard() if (m_thread_pitch) m_thread_pitch->SetValue(s->pitch_mm); if (m_thread_depth) m_thread_depth->SetValue(s->thread_depth_mm()); - // Nominal radius: external rod = major radius; internal tapped bore = minor - // (tap-drill) radius. On a picked cylindrical face the radius comes from the - // real geometry, so don't override it there. + // 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(0.5 * d); + m_thread_radius->SetValue(d); } if (m_status) @@ -2355,6 +2500,24 @@ void DesignPanel::apply_thread_standard() 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(); @@ -2365,7 +2528,7 @@ void DesignPanel::on_add_thread() SketchPlane plane = thread_plane(); m_feature_counter++; - const int tidx = m_doc.add_thread(m_thread_radius->GetValue(), m_thread_pitch->GetValue(), + 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)); @@ -2554,18 +2717,63 @@ void DesignPanel::populate_plane_choices(wxChoice* c) const SketchPlane DesignPanel::plane_from_choice(int row) const { - if (row < 3) return plane_from_index(row); // 0=XY,1=XZ,2=YZ + 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; - return (di >= 0 && di < int(datums.size())) ? datums[di].second : SketchPlane::XY(); + if (di >= 0 && di < int(datums.size())) return datums[di].second; + SketchPlane p = SketchPlane::XY(); p.origin += m_doc.modeling_origin; return p; +} + +void DesignPanel::apply_plane_refs(CadFeature& f) const +{ + f.plane_type = (PlaneType)m_plane_type->GetSelection(); + f.plane_face_body = m_pl_faceA_body; f.plane_face = m_pl_faceA; + f.plane_face2_body = m_pl_faceB_body; f.plane_face2 = m_pl_faceB; + f.plane_edge_body = m_pl_edgeA_body; f.plane_edge = m_pl_edgeA; + f.plane_edge2_body = m_pl_edgeB_body; f.plane_edge2 = m_pl_edgeB; + f.plane_u_size = m_plane_usize->GetValue(); + f.plane_v_size = m_plane_vsize->GetValue(); +} + +void DesignPanel::refresh_plane_labels() +{ + auto txt = [](int idx) { return idx >= 0 ? wxString::Format("#%d", idx) : wxString(_L("(none)")); }; + if (m_plane_faceA_lbl) m_plane_faceA_lbl->SetLabel(txt(m_pl_faceA)); + if (m_plane_faceB_lbl) m_plane_faceB_lbl->SetLabel(txt(m_pl_faceB)); + if (m_plane_edgeA_lbl) m_plane_edgeA_lbl->SetLabel(txt(m_pl_edgeA)); + if (m_plane_edgeB_lbl) m_plane_edgeB_lbl->SetLabel(txt(m_pl_edgeB)); +} + +void DesignPanel::reset_plane_refs() +{ + m_pl_faceA_body = m_pl_faceA = -1; m_pl_faceB_body = m_pl_faceB = -1; + m_pl_edgeA_body = m_pl_edgeA = -1; m_pl_edgeB_body = m_pl_edgeB = -1; + m_plane_pick = PlanePick::None; + refresh_plane_labels(); +} + +void DesignPanel::arm_plane_pick(PlanePick target) +{ + m_plane_pick = target; + const bool face = (target == PlanePick::FaceA || target == PlanePick::FaceB); + m_status->SetForegroundColour(wxNullColour); + m_status->SetLabel(face ? _L("Click a solid FACE in the viewport") + : _L("Click a solid EDGE in the viewport")); + m_status->Refresh(); } void DesignPanel::on_add_plane() { m_feature_counter++; - m_doc.add_plane(m_plane_base->GetSelection(), m_plane_offset->GetValue(), + int idx = m_doc.add_plane(m_plane_base->GetSelection(), m_plane_offset->GetValue(), m_plane_tilt->GetValue(), m_plane_tilt_axis->GetSelection(), "Plane" + std::to_string(m_feature_counter)); + if (idx >= 0 && idx < int(m_doc.features.size())) apply_plane_refs(m_doc.features[idx]); m_doc.recompute(); // datum-only docs yield no body; that is expected/benign m_status->SetForegroundColour(wxNullColour); m_status->SetLabel(_L("Plane added — pick it as a sketch plane")); @@ -2644,6 +2852,13 @@ void DesignPanel::on_tab_shown() { if (m_viewport) m_viewport->refresh_bed(); + // Modeling origin = bed centre, set BEFORE any recompute/datum-resolve so sketches and datums + // land in the middle of the bed (not the bed corner = world 0). + if (Plater* pl = wxGetApp().plater()) { + const Vec2d bc = pl->build_volume().bed_center(); + m_doc.modeling_origin = Vec3d(bc.x(), bc.y(), 0.0); + } + // Rehydrate the parametric model from a freshly loaded project (the 3MF carried the // recipe in Metadata/SnapOrca_cad.bin). Only when nothing is in progress here, so we // never clobber an active design when the user just toggles back to the Design tab. @@ -2653,6 +2868,7 @@ void DesignPanel::on_tab_shown() if (!blob.empty()) load_recipe(blob); } } + update_reference_planes(); // entering the Design tab: show the XY/XZ/YZ planes if no object yet } void DesignPanel::load_recipe(const std::string& blob) @@ -2681,11 +2897,8 @@ void DesignPanel::refresh_tree() 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). - if (m_viewport) { - std::vector dplanes; - for (const auto& dp : m_doc.resolve_datum_planes()) dplanes.push_back(dp.second); - m_viewport->set_datum_planes(std::move(dplanes)); - } + refresh_datum_planes(); + update_reference_planes(); // body added/removed -> show/hide the XY/XZ/YZ origin planes for (const auto& f : m_doc.features) { const int img = tree_icon_for(f.type); wxTreeItemId id = m_tree->AppendItem(root, wxString::FromUTF8(f.name), img, img); @@ -2903,8 +3116,15 @@ void DesignPanel::after_tree_edit(bool ok) sync_sketch_display(); // empty body: show any un-consumed committed sketch m_status->SetLabel(wxString()); } else { + // nde #19/20: a delete/reorder that leaves bodies behind must re-feed the per-body + // GLVolumes — otherwise the viewport keeps showing the pre-edit solid (the deleted + // feature's artifact lingered). feed_bodies() is idempotent for the edit/replace path. + if (m_viewport != nullptr) feed_bodies(); set_status_ok(); } + // Force a frame: under software GL (llvmpipe on the :10 test box) reload()'s scheduled + // Refresh() is dropped, so a deleted solid stayed on screen until the next orbit. + if (m_viewport != nullptr) m_viewport->request_repaint(); m_status->Refresh(); } @@ -4341,7 +4561,7 @@ void DesignPanel::load_feature_into_dialog(const CadFeature& f) break; case CadFeatureType::Thread: m_thread_plane->SetSelection(index_from_plane(f.plane)); - m_thread_radius->SetValue(f.thread_radius); + 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); @@ -4382,6 +4602,15 @@ void DesignPanel::load_feature_into_dialog(const CadFeature& f) m_plane_offset->SetValue(f.plane_offset); m_plane_tilt->SetValue(f.plane_angle_tilt); m_plane_tilt_axis->SetSelection(f.plane_axis); + m_plane_type->SetSelection((int)f.plane_type); + m_pl_faceA_body = f.plane_face_body; m_pl_faceA = f.plane_face; + m_pl_faceB_body = f.plane_face2_body; m_pl_faceB = f.plane_face2; + m_pl_edgeA_body = f.plane_edge_body; m_pl_edgeA = f.plane_edge; + m_pl_edgeB_body = f.plane_edge2_body; m_pl_edgeB = f.plane_edge2; + m_plane_usize->SetValue(f.plane_u_size); + m_plane_vsize->SetValue(f.plane_v_size); + m_plane_pick = PlanePick::None; + refresh_plane_labels(); break; case CadFeatureType::Loft: m_loft_refs = f.loft_profile_refs; // show_tool re-checks these in the list @@ -4637,7 +4866,7 @@ CadFeature DesignPanel::build_candidate(Tool t) const case Tool::Thread: f.type = CadFeatureType::Thread; f.plane = thread_plane(); - f.thread_radius = m_thread_radius->GetValue(); + 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(); @@ -4688,6 +4917,7 @@ CadFeature DesignPanel::build_candidate(Tool t) const f.plane_offset = m_plane_offset->GetValue(); f.plane_angle_tilt = m_plane_tilt->GetValue(); f.plane_axis = m_plane_tilt_axis->GetSelection(); + apply_plane_refs(f); // plane_type + face/edge refs + u/v size from the card break; case Tool::Loft: { f.type = CadFeatureType::Loft; @@ -4773,7 +5003,7 @@ void DesignPanel::update_thread_gizmo() const SketchPlane plane = thread_plane(); m_viewport->begin_thread_gizmo(plane, m_thread_x->GetValue(), m_thread_y->GetValue(), - m_thread_radius->GetValue(), m_thread_height->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). @@ -4939,6 +5169,97 @@ void DesignPanel::update_extrude_gizmo() end == ExtrudeEnd::TwoSided, m_flip->GetValue()); } +void DesignPanel::refresh_datum_planes() +{ + if (!m_viewport) return; + std::vector dplanes; + for (const auto& dp : m_doc.resolve_datum_planes()) dplanes.push_back(dp.second); + // Parallel per-plane extents, in the SAME order resolve_datum_planes emits + // (enabled Plane features, document order), so each datum draws at its u/v size. + std::vector dsizes; + for (const auto& f : m_doc.features) + if (f.type == CadFeatureType::Plane && f.enabled) + dsizes.emplace_back(f.plane_u_size, f.plane_v_size); + m_viewport->set_datum_planes(std::move(dplanes), std::move(dsizes)); +} + +void DesignPanel::update_datum_gizmo() +{ + if (!m_viewport) return; + if (m_active != Tool::Plane) { m_viewport->clear_datum_gizmo(); update_reference_planes(); return; } + + // Resolve the candidate plane's FRAME against the doc. resolve_datum_planes() is const and + // doesn't rebuild bodies, so transiently swap/append the candidate to read its resolved frame, + // then restore — works for both a fresh (uncommitted) plane and an edit of a committed one. + CadFeature f = build_candidate(Tool::Plane); + const bool editing = (m_edit_index >= 0 && m_edit_index < int(m_doc.features.size()) && + m_doc.features[m_edit_index].type == CadFeatureType::Plane); + CadFeature saved; + int slot; + if (editing) { saved = m_doc.features[m_edit_index]; m_doc.features[m_edit_index] = f; slot = m_edit_index; } + else { m_doc.features.push_back(f); slot = int(m_doc.features.size()) - 1; } + + auto datums = m_doc.resolve_datum_planes(); + // Ordinal of `slot` among enabled Plane features = its index in the resolved list. + int ord = -1; + for (int i = 0; i <= slot; ++i) + if (m_doc.features[i].type == CadFeatureType::Plane && m_doc.features[i].enabled) ++ord; + const bool ok = (ord >= 0 && ord < int(datums.size())); + SketchPlane frame; if (ok) frame = datums[ord].second; + + if (editing) m_doc.features[m_edit_index] = saved; else m_doc.features.pop_back(); + + if (!ok) { m_viewport->clear_datum_gizmo(); update_reference_planes(); return; } + + // Offset arrow anchor = the base/face origin (datum origin walked back along its normal by the + // offset). Works for both Offset-from-base (no tilt) and Offset-from-face exactly. + const Vec3d anchor = frame.origin - frame.normal * f.plane_offset; + const bool offset_on = (f.plane_type == PlaneType::Offset); + m_viewport->set_datum_gizmo(frame, f.plane_u_size, f.plane_v_size, + anchor, frame.normal, f.plane_offset, offset_on); + update_reference_planes(); // base ghosts (origins + datums) follow the tool/model state +} + +// Onshape default planes: the XY/XZ/YZ reference planes are persistent, transparent, labelled, and +// larger than the bed — shown as the FALLBACK when there is no object yet. When the Plane tool is +// open they additionally surface existing datums so a base can be picked. Single authority for the +// reference-plane overlay (set/clear_base_pick). +void DesignPanel::update_reference_planes() +{ + if (!m_viewport) return; + // Default planes pass through the modeling origin (bed centre) — same point the kernel uses to + // resolve XY/XZ/YZ datum bases, so the ghosts, the datums and new sketches all coincide. + const Vec3d o = m_doc.modeling_origin; + SketchPlane xy = SketchPlane::XY(); xy.origin += o; + SketchPlane xz = SketchPlane::XZ(); xz.origin += o; + SketchPlane yz = SketchPlane::YZ(); yz.origin += o; + std::vector bp = { xy, xz, yz }; + std::vector bi = { 0, 1, 2 }; + std::vector bl = { "XY", "XZ", "YZ" }; + + if (m_active == Tool::Plane) { + // Base picking only makes sense for the Offset method (others reference faces/edges). + if (m_plane_type && (PlaneType)m_plane_type->GetSelection() == PlaneType::Offset) { + auto datums = m_doc.resolve_datum_planes(); // already in world coords (origin applied) + for (int i = 0; i < int(datums.size()); ++i) { + bp.push_back(datums[i].second); bi.push_back(3 + i); bl.push_back(datums[i].first); + } + m_viewport->set_base_pick(std::move(bp), std::move(bi), std::move(bl)); + } else { + m_viewport->clear_base_pick(); + } + return; + } + // Fallback (Onshape default planes): show the 3 reference planes while there is no SOLID body + // yet — so they persist through the 2D-sketch phase and reappear after a sketch is confirmed + // (a sketch creates no body). They no longer block selection: clicking existing geometry wins, + // a base-plane pick only fires on a click that hit nothing else (see on_mouse fall-through). + if (m_doc.bodies.empty()) + m_viewport->set_base_pick(std::move(bp), std::move(bi), std::move(bl)); + else + m_viewport->clear_base_pick(); +} + void DesignPanel::refresh_preview() { if (m_active == Tool::None) { m_viewport->clear_preview(); return; } @@ -4951,6 +5272,7 @@ void DesignPanel::refresh_preview() m_status->SetLabel(m_active == Tool::Plane ? _L("Plane ready") : _L("Sketch ready")); for (wxButton* b : m_confirm_btns) if (b) b->Enable(true); m_status->Refresh(); + update_datum_gizmo(); // Plane card: show/refresh the in-canvas resize handles return; } @@ -5027,6 +5349,8 @@ void DesignPanel::refresh_preview() update_revolve_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(); } void DesignPanel::open_tool(Tool t) @@ -5179,6 +5503,8 @@ void DesignPanel::close_tool() m_viewport->clear_shell_gizmo(); m_viewport->clear_revolve_gizmo(); m_viewport->clear_pattern_gizmo(); + m_viewport->clear_datum_gizmo(); + update_reference_planes(); // back to no-tool: show the origin planes if there is no object yet m_form->Layout(); m_form->FitInside(); update_action_bar(); // no feature tool active -> hide the bar (unless a mode keeps it) diff --git a/src/slic3r/GUI/DesignPanel.hpp b/src/slic3r/GUI/DesignPanel.hpp index 1f0c07c3cd..dec3211daa 100644 --- a/src/slic3r/GUI/DesignPanel.hpp +++ b/src/slic3r/GUI/DesignPanel.hpp @@ -39,6 +39,9 @@ public: private: enum class Tool { None, Sketch, Extrude, Dressup, Hole, Thread, Shell, Revolve, Sweep, Pattern, Plane, Loft, Draft, Boolean, Cut, Insert }; + // Plane tool: which datum reference the next solid pick fills (declared early so the + // method decls + card lambdas below can name it). + enum class PlanePick { None, FaceA, FaceB, EdgeA, EdgeB }; // Onshape-style contextual top toolbar: only the active mode's tool group is // shown (Feature = sketch/extrude/dress/hole/thread; Sketch = entity tools; @@ -57,11 +60,16 @@ private: void on_add_hole(); void on_add_thread(); void apply_thread_standard(); // fill pitch/depth/radius from m_thread_std selection + void infer_thread_spec(double diameter); // nearest M-standard from a picked cylinder diameter void on_add_revolve(); void on_add_sweep(); void on_add_loft(); void on_add_pattern(); void on_add_plane(); + void arm_plane_pick(PlanePick target); // Plane tool: next solid pick fills this reference + void apply_plane_refs(CadFeature& f) const; // copy type + face/edge refs + sizes from the card + void refresh_plane_labels(); // update the 4 pick labels from the captured refs + void reset_plane_refs(); // clear captured refs (fresh Plane add) void on_add_shell(); void on_add_draft(); void on_add_boolean(); @@ -167,6 +175,9 @@ private: void update_shell_gizmo(); // inward thickness arrow on the picked face (Shell card) void update_revolve_gizmo(); // angle-arc around the axis (Revolve card) void update_pattern_gizmo(); // linear spacing arrow / circular angle-arc (Pattern card) + void update_datum_gizmo(); // resize handles on the datum plane being created/edited (C3) + void refresh_datum_planes(); // push resolved datum frames + per-plane u/v extents to viewport + void update_reference_planes(); // persistent XY/XZ/YZ reference planes (fallback when no object) CadDocument m_doc; @@ -302,8 +313,22 @@ private: // Datum plane controls (derive a selectable sketch plane: offset + tilt from a base). wxChoice* m_plane_base{nullptr}; // 0=XY,1=XZ,2=YZ, 3+N = Nth datum plane wxSpinCtrlDouble* m_plane_offset{nullptr}; // offset along base normal (mm) - wxSpinCtrlDouble* m_plane_tilt{nullptr}; // tilt about a base axis (deg) + wxSpinCtrlDouble* m_plane_tilt{nullptr}; // tilt about a base axis (deg) / Angle / Tangent angle wxChoice* m_plane_tilt_axis{nullptr}; // 0 = base X, 1 = base Y + // Plane construction method + contextual face/edge reference picks (Onshape/Fusion parity). + wxChoice* m_plane_type{nullptr}; // PlaneType: Offset/Angle/Midplane/Tangent/TwoEdges/Coincident + wxButton* m_plane_pick_faceA{nullptr}; wxStaticText* m_plane_faceA_lbl{nullptr}; + wxButton* m_plane_pick_faceB{nullptr}; wxStaticText* m_plane_faceB_lbl{nullptr}; + wxButton* m_plane_pick_edgeA{nullptr}; wxStaticText* m_plane_edgeA_lbl{nullptr}; + wxButton* m_plane_pick_edgeB{nullptr}; wxStaticText* m_plane_edgeB_lbl{nullptr}; + wxSpinCtrlDouble* m_plane_usize{nullptr}; // datum rectangle extent u (mm) — also driven by drag handles + wxSpinCtrlDouble* m_plane_vsize{nullptr}; // datum rectangle extent v (mm) + // Captured references for the candidate datum (body index + face/edge index, -1 = none). + int m_pl_faceA_body{-1}, m_pl_faceA{-1}; + int m_pl_faceB_body{-1}, m_pl_faceB{-1}; + int m_pl_edgeA_body{-1}, m_pl_edgeA{-1}; + int m_pl_edgeB_body{-1}, m_pl_edgeB{-1}; + PlanePick m_plane_pick{PlanePick::None}; // which ref the next solid pick fills // Plate loop selection (click a committed sketch loop): the Sketch feature + the // clicked closed-region index, so Extrude builds just that one loop. -1 = none. int m_sel_sketch_feat{-1}; diff --git a/src/slic3r/GUI/DesignSketchTool.cpp b/src/slic3r/GUI/DesignSketchTool.cpp index f4784ab139..07d6daf482 100644 --- a/src/slic3r/GUI/DesignSketchTool.cpp +++ b/src/slic3r/GUI/DesignSketchTool.cpp @@ -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 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 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()); fill.add_vertex((Vec3f)c[1].cast()); fill.add_vertex((Vec3f)c[2].cast()); fill.add_triangle(fb, fb + 1, fb + 2); fb += 3; fill.add_vertex((Vec3f)c[0].cast()); fill.add_vertex((Vec3f)c[2].cast()); @@ -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()); border.add_vertex((Vec3f)(b + off).cast()); + border.add_vertex((Vec3f)(b - off).cast()); border.add_vertex((Vec3f)(a - off).cast()); + 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()); handles.add_vertex((Vec3f)q1.cast()); + handles.add_vertex((Vec3f)q2.cast()); handles.add_vertex((Vec3f)q3.cast()); + 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()); + shaft.add_vertex((Vec3f)(tip + off).cast()); + shaft.add_vertex((Vec3f)(tip - off).cast()); + shaft.add_vertex((Vec3f)(m_dz_anchor - off).cast()); + 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()); tipsq.add_vertex((Vec3f)t1.cast()); + tipsq.add_vertex((Vec3f)t2.cast()); tipsq.add_vertex((Vec3f)t3.cast()); + 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 planes, std::vector bases, + std::vector 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()); quad.add_vertex((Vec3f)q1.cast()); + quad.add_vertex((Vec3f)q2.cast()); quad.add_vertex((Vec3f)q3.cast()); + 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> 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> 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()); + 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>& 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 diff --git a/src/slic3r/GUI/DesignSketchTool.hpp b/src/slic3r/GUI/DesignSketchTool.hpp index dfac473963..36118652ce 100644 --- a/src/slic3r/GUI/DesignSketchTool.hpp +++ b/src/slic3r/GUI/DesignSketchTool.hpp @@ -97,7 +97,8 @@ public: || (m_solid_bodies != nullptr && !m_solid_bodies->empty()) || !m_datum_planes.empty() || m_ex_active || m_mv_active || m_fl_active - || m_hl_active || m_th_active || m_sh_active; } + || m_hl_active || m_th_active || m_sh_active + || m_dz_active || m_dbp_active; } // Solid topology selection on the committed bodies: clicking a solid cycles // whole-solid -> face -> edge (Onshape-style) to target fillet/chamfer/extrude. With @@ -153,6 +154,26 @@ public: // (new_depth, second_side): second_side=false drives the primary depth, true the 2nd side. std::function on_extrude_depth_changed; + // Datum-plane resize gizmo (C3). The Plane tool is a DesignPanel docked card (sketch tool + // NOT active), so the panel resolves the candidate plane's frame + current u/v extent and + // feeds them here; the tool draws the rectangle outline + 4 edge-midpoint handles. Dragging a + // handle changes the u (left/right) or v (top/bottom) extent live and fires on_datum_size_changed + // back to the panel, which writes the Size spins + re-pushes the rendered datum. + void set_datum_gizmo(const SketchPlane& plane, double usize, double vsize, + const Vec3d& base_origin, const Vec3d& base_normal, + double offset, bool offset_on); + void clear_datum_gizmo(); + std::function on_datum_size_changed; + std::function on_datum_offset_changed; + + // Graphical base/origin pick: while the Plane card is open, the candidate base planes + // (XY/XZ/YZ origin planes + existing datums) draw as translucent clickable ghosts. A click + // on one fires on_datum_base_picked(base) with that plane's base index (0/1/2 or 3+N). + void set_base_pick(std::vector planes, std::vector bases, + std::vector labels = {}); + void clear_base_pick(); + std::function on_datum_base_picked; + // Visual Fillet/Chamfer gizmo. The Dressup tool is a DesignPanel docked card, so the sketch // tool is NOT active during it; when a solid EDGE is picked the panel passes the body centroid // + current radius and the tool anchors a world-space radius arrow at the picked edge midpoint @@ -203,7 +224,9 @@ public: // Datum/reference planes (Plane feature) carry no solid; the panel feeds their resolved // SketchPlanes so they render as translucent rectangles in feature mode (otherwise a // Plane feature is invisible in the canvas). - void set_datum_planes(std::vector planes) { m_datum_planes = std::move(planes); } + void set_datum_planes(std::vector planes, std::vector sizes = {}) { + m_datum_planes = std::move(planes); m_datum_sizes = std::move(sizes); + } // Visual Revolve gizmo. The panel feeds the sketch plane + profile centroid + axis (0=plane X, // 1=plane Y) + angle + flip while its Revolve card is open; an angle-arc is drawn in the @@ -575,6 +598,10 @@ private: // Arc-slot grouped edit: centreline-radius + width labels rebuild the 4-arc span. void open_arc_slot_editor(int fi, bool radius); // true=centreline R, false=width void set_arc_slot(int fi, double Rc, double w); + // Straight-slot grouped edit: centreline-length + width labels rebuild the 4-entity span. + void open_slot_editor(int fi, int which); // 0=inter-centre distance, 1=radius, 2=angle + void set_slot(int fi, double length, double w); + void set_slot_angle(int fi, double deg); // rotate the centreline about c0, keep len+radius // Grouped derived-handle drag: resize an axis-aligned rect by a corner (opposite corner // fixed); move a slot end by its cap centre. Both rebuild the feature span geometrically. void drag_rect_corner(int fi, const Vec2d& cursor); @@ -699,6 +726,10 @@ private: Vec2d m_live_aslot_r_label{0,0}; // arc-slot centreline-radius label Vec2d m_live_aslot_w_label{0,0}; // arc-slot width label int m_live_aslot_fi{-1}; // the arc-slot Feature (rebuild edits) + Vec2d m_live_slot_len_label{0,0}; // straight-slot inter-centre distance label + Vec2d m_live_slot_w_label{0,0}; // straight-slot radius (half-width) label + Vec2d m_live_slot_angle_label{0,0}; // straight-slot centreline angle label + int m_live_slot_fi{-1}; // the straight-slot Feature (rebuild edits) std::vector m_features; // parametric groups over m_entities int m_open_feature{-1}; // index of the Feature being built, or -1 @@ -810,6 +841,7 @@ private: void render_solid_highlight(); void render_datum_planes(); // translucent rectangles for datum/reference planes std::vector m_datum_planes; + std::vector m_datum_sizes; // per-plane (u,v) full extent; empty -> default GLModel m_solid_face_model; GLModel m_solid_edge_model; int m_display_pick_region{-1}; // selected closed-region index within that feature (-1 none) @@ -830,6 +862,30 @@ private: void open_extrude_editor(int which); GLModel m_ex_arrow_model; + // Datum-plane resize gizmo state (C3). GUI-only; fed by the panel while the Plane card is open. + bool m_dz_active{false}; + SketchPlane m_dz_plane; // resolved datum frame (origin + axes) + double m_dz_usize{60.0}; // current u extent (full width) + double m_dz_vsize{60.0}; // current v extent (full height) + int m_dz_drag{-1}; // 0=+u,1=-u,2=+v,3=-v handle, 4=offset tip, -1 none + int m_dz_press_x{0}, m_dz_press_y{0}; + Vec3d m_dz_anchor{Vec3d::Zero()}; // base-plane origin (offset arrow tail) + Vec3d m_dz_normal{0.0, 0.0, 1.0}; // base normal (offset arrow direction) + double m_dz_offset{0.0}; // current signed offset along the base normal + bool m_dz_offset_on{false}; // draw/allow the offset arrow (Offset-from-base only) + void render_datum_gizmo(); + bool hit_test_datum_handle(GLCanvas3D& canvas, const wxMouseEvent& evt, int& which) const; + void drag_datum_handle(GLCanvas3D& canvas, const wxMouseEvent& evt, int which); + // Datum base picker (translucent clickable origin/datum planes) + bool m_dbp_active{false}; + std::vector m_dbp_planes; + std::vector m_dbp_base; + std::vector m_dbp_labels; + int m_dbp_hover{-1}; + double dbp_half_extent() const; // bed-derived: reference planes are larger than the bed + void render_base_pick(); + int hit_test_base_pick(GLCanvas3D& canvas, const wxMouseEvent& evt) const; + // Move-body gizmo state: 3 world-axis translate arrows + 3 world-axis rotate rings. // Delta model: offset/rot are deltas about a fixed pivot, composed onto m_mv_base_xform // (the body's pose when Move opened) so rotation works even on an already-placed body. diff --git a/src/slic3r/GUI/GLCanvas3D.cpp b/src/slic3r/GUI/GLCanvas3D.cpp index d50e1cf520..db13e135a5 100644 --- a/src/slic3r/GUI/GLCanvas3D.cpp +++ b/src/slic3r/GUI/GLCanvas3D.cpp @@ -7940,7 +7940,14 @@ void GLCanvas3D::_render_bed(const Transform3d& view_matrix, const Transform3d& */ //bool show_texture = true; //BBS set axes mode - m_bed.set_axes_mode(m_main_toolbar.is_enabled()); + if (m_axes_at_bed_center) { + // SnapOrca Design: triad at the bed centre = modeling origin (set every frame because + // set_shape/set_axes_mode otherwise reset it to the bed corner). + const Vec2d bc = m_bed.build_volume().bed_center(); + m_bed.set_axes_origin(Vec3d(bc.x(), bc.y(), 0.0)); + } else { + m_bed.set_axes_mode(m_main_toolbar.is_enabled()); + } m_bed.render(*this, view_matrix, projection_matrix, bottom, scale_factor, show_axes); } diff --git a/src/slic3r/GUI/GLCanvas3D.hpp b/src/slic3r/GUI/GLCanvas3D.hpp index 2f4e697908..84615551c2 100644 --- a/src/slic3r/GUI/GLCanvas3D.hpp +++ b/src/slic3r/GUI/GLCanvas3D.hpp @@ -545,6 +545,9 @@ private: mutable float m_paint_toolbar_width; bool m_collapse_toolbar_enabled{true}; bool m_plate_chrome_enabled{true}; + // SnapOrca Design: render the world-axis triad at the bed centre (= modeling origin) instead of + // the bed corner. Default false preserves the main editor's corner triad. + bool m_axes_at_bed_center{false}; DesignSketchTool* m_design_sketch_tool{nullptr}; //BBS: add canvas type for assemble view usage @@ -885,6 +888,7 @@ public: void enable_separator_toolbar(bool enable); void enable_collapse_toolbar(bool enable); void enable_plate_chrome(bool enable); + void set_axes_at_bed_center(bool b) { m_axes_at_bed_center = b; } void set_design_sketch_tool(DesignSketchTool* tool) { m_design_sketch_tool = tool; } DesignSketchTool* get_design_sketch_tool() const { return m_design_sketch_tool; } void enable_dynamic_background(bool enable) { m_dynamic_background_enabled = enable; } diff --git a/tests/libslic3r/test_caddocument.cpp b/tests/libslic3r/test_caddocument.cpp index d79bcd0e05..60baf8bd69 100644 --- a/tests/libslic3r/test_caddocument.cpp +++ b/tests/libslic3r/test_caddocument.cpp @@ -1516,3 +1516,90 @@ TEST_CASE("re-edit: multi-type timeline replays all downstream features", "[CadD Vec3d sz1 = doc.display_mesh.bounding_box().size(); REQUIRE(sz1.z() > sz0.z() + 1.0); } + +TEST_CASE("datum plane construction methods", "[CadDocument][plane]") +{ + using Catch::Matchers::WithinAbs; + + // Build a doc with a box (sketch rect 40x30 + extrude 20) so bodies[0] has faces/edges. + CadDocument doc; + int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 40, 30, 0, "BoxSketch"); + REQUIRE(sk >= 0); + doc.add_extrude(sk, 20.0, false, BooleanMode::New, "Extrude1"); + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.bodies.size() == 1); + const int n_faces = GeometryEngine::face_count(doc.bodies[0].shape); + REQUIRE(n_faces == 6); // a box + const int n_edges = GeometryEngine::edge_count(doc.bodies[0].shape); + REQUIRE(n_edges == 12); + + // Find top face (normal ~ +Z) and bottom face (normal ~ -Z) by scanning. + int top_idx = -1, bot_idx = -1; + for (int i = 0; i < n_faces; ++i) { + TopoDS_Face fc = GeometryEngine::face_by_index(doc.bodies[0].shape, i); + Vec3d n = GeometryEngine::face_normal_world(fc); + if (n.z() > 0.9) top_idx = i; + if (n.z() < -0.9) bot_idx = i; + } + REQUIRE(top_idx >= 0); + REQUIRE(bot_idx >= 0); + + // --- Offset from base XY by 10 --- + auto planes0 = doc.resolve_datum_planes(); + size_t initial = planes0.size(); + + CadFeature f0; + f0.type = CadFeatureType::Plane; + f0.name = "Offset10"; + f0.plane_base = 0; // XY + f0.plane_type = PlaneType::Offset; + f0.plane_offset = 10; + doc.features.push_back(f0); + + auto planes = doc.resolve_datum_planes(); + REQUIRE(planes.size() == initial + 1); + CHECK_THAT(planes.back().second.origin.z(), WithinAbs(10.0, 1e-6)); + CHECK_THAT(planes.back().second.normal.z(), WithinAbs(1.0, 1e-6)); + + // --- Coincident to top face --- + CadFeature f1; + f1.type = CadFeatureType::Plane; + f1.name = "CoincidentTop"; + f1.plane_type = PlaneType::Coincident; + f1.plane_face_body = 0; + f1.plane_face = top_idx; + doc.features.push_back(f1); + + planes = doc.resolve_datum_planes(); + REQUIRE(planes.size() == initial + 2); + CHECK_THAT(planes.back().second.origin.z(), WithinAbs(20.0, 1e-6)); // box height is 20 + CHECK_THAT(planes.back().second.normal.z(), WithinAbs(1.0, 1e-6)); + + // --- Midplane between top and bottom faces --- + CadFeature f2; + f2.type = CadFeatureType::Plane; + f2.name = "Midplane"; + f2.plane_type = PlaneType::Midplane; + f2.plane_face_body = 0; + f2.plane_face = top_idx; + f2.plane_face2_body = 0; + f2.plane_face2 = bot_idx; + doc.features.push_back(f2); + + planes = doc.resolve_datum_planes(); + REQUIRE(planes.size() == initial + 3); + CHECK_THAT(planes.back().second.origin.z(), WithinAbs(10.0, 1e-6)); // midway + CHECK_THAT(std::abs(planes.back().second.normal.z()), WithinAbs(1.0, 1e-6)); + + // --- Orthonormality check on all resolved planes --- + for (const auto& [name, sp] : planes) { + INFO("Plane: " << name); + CHECK_THAT(sp.normal.norm(), WithinAbs(1.0, 1e-6)); + CHECK_THAT(sp.x_axis.norm(), WithinAbs(1.0, 1e-6)); + CHECK_THAT(sp.y_axis.norm(), WithinAbs(1.0, 1e-6)); + CHECK_THAT(std::abs(sp.x_axis.dot(sp.y_axis)), WithinAbs(0.0, 1e-6)); + CHECK_THAT(std::abs(sp.x_axis.dot(sp.normal)), WithinAbs(0.0, 1e-6)); + CHECK_THAT(std::abs(sp.y_axis.dot(sp.normal)), WithinAbs(0.0, 1e-6)); + } +}