From 073e2d9c443d956636193e75af5f612e97b46ddf Mon Sep 17 00:00:00 2001 From: SoftFever Date: Mon, 5 Oct 2026 02:50:14 +0800 Subject: [PATCH] Highlight the faces a selected feature made in the Design tab Selections are drawn as opaque faces in the selection colour with a cased outline instead of a translucent tint over the body, so they read on a body of any colour. Selecting a Feature tree row lights the faces that feature made rather than its whole body, which also makes fillet and chamfer rows highlight again. --- docs/HLSD/design-tab.md | 24 ++ src/libslic3r/CAD/CadDocument.cpp | 246 +++++++++++++++--- src/libslic3r/CAD/CadDocument.hpp | 17 ++ src/slic3r/GUI/CAD/DesignCanvas.cpp | 147 +++++++---- src/slic3r/GUI/CAD/DesignCanvas.hpp | 25 +- src/slic3r/GUI/CAD/DesignPanel.cpp | 81 ++++-- src/slic3r/GUI/CAD/DesignPanel.hpp | 11 + src/slic3r/GUI/CAD/DesignSketchTool.cpp | 316 +++++++++++++++++------- src/slic3r/GUI/CAD/DesignSketchTool.hpp | 37 ++- tests/libslic3r/test_caddocument.cpp | 165 +++++++++++++ 10 files changed, 866 insertions(+), 203 deletions(-) diff --git a/docs/HLSD/design-tab.md b/docs/HLSD/design-tab.md index 4f8b61d755..a29061c27f 100644 --- a/docs/HLSD/design-tab.md +++ b/docs/HLSD/design-tab.md @@ -187,6 +187,30 @@ degenerate edges are left out (`GeometryEngine::display_edges`), and the polylin once per shape, keyed by its `TShape`, because a recompute that leaves a body unchanged is the common case. +## Showing what is selected + +A selection is drawn on the faces it names, never as a tint over the body: a translucent +selection colour blended into the body's own colour turns a different hue on every body and +vanishes on one close to it. Selected faces are split out of their body into a volume of their +own, which the canvas draws opaque in the selection colour through the same shader and lighting +as the body (`DesignCanvas::rebuild_bodies`); the sketch overlay outlines them with a cased line +— a dark band under a selection-coloured one — so the outline still reads on a body that wears +the selection colour itself. A body picked whole, a face picked in the viewport and the faces of +the Feature tree's selected feature all draw this way. The hover pre-highlight is the outline +alone, uncased: it promises a click, it is not one. + +Selecting a feature row lights the faces that feature made, not the whole body it sits on, so a +fillet row shows its round and the extrude under it keeps the faces the fillet trimmed. +`CadDocument::faces_made_by` answers it without per-feature history: it replays the recipe to +just before the feature and then the feature alone, and a face of the finished model belongs to +the feature when an interior point of it lies on the boundary afterwards and not before, facing +the same way — the facing keeps a block stacked on a base the owner of its bottom face. A feature +that makes no face of its own, such as a Boolean union, answers with the bodies it changed. The +replay costs up to a recompute, so the panel finds the faces once per row and topology +generation, off the UI thread, and only while no feature card is open. One selection is live at +a time: a viewport pick clears the feature row and a feature row clears the viewport pick, as the +Feature tree and Bodies list do between themselves. + ## Following the app The tab is a page of Orca's main window and answers to the same settings as Prepare. diff --git a/src/libslic3r/CAD/CadDocument.cpp b/src/libslic3r/CAD/CadDocument.cpp index df3c1f7135..e31363cdf8 100644 --- a/src/libslic3r/CAD/CadDocument.cpp +++ b/src/libslic3r/CAD/CadDocument.cpp @@ -84,6 +84,7 @@ #include #include #include +#include #include #include #include @@ -92,6 +93,11 @@ #include #include #include +#include // faces_made_by: is a point on a face +#include +#include +#include +#include #include #include #include @@ -4017,6 +4023,39 @@ void CadDocument::route_feature(std::vector& bodies, const CadFeature& } } +void CadDocument::bind_expressions() +{ + std::map varvals = evaluate_variables(variables); + for (CadFeature& f : features) + for (const auto& [field, e] : f.expr) + assign_field(f, field, eval_expr(e, varvals)); +} + +void CadDocument::replay_feature(size_t fi, std::vector& built) +{ + CadFeature& f = features[fi]; + if (!f.enabled) return; + if (f.type == CadFeatureType::Sketch) return; // consumed by an extrude + if (f.type == CadFeatureType::Helix) return; // consumed by Sweep as a path + if (f.type == CadFeatureType::Plane) return; // datum: no solid, derived on demand + if (f.type == CadFeatureType::Axis) return; // datum axis + if (f.type == CadFeatureType::CoordSys) return; // datum coordinate system + settle_body_refs(f, built, true); + if (f.type == CadFeatureType::Project) { apply_project(built, f); } + else { route_feature(built, f); } + // Record which feature made each body. "Still unset?" is the whole rule, and it is + // sufficient because of an invariant worth stating: NO feature ever replaces a whole + // CadBody. Every in-place op writes only `.shape` (boolean, cut, mirror-fuse, + // transform, dress-up — checked, all 8 sites), so an existing body keeps the stamp it + // was born with; a consumed body is erased outright, taking its stamp with it; and + // the only bodies still at -1 here are the ones THIS feature just pushed. That also + // means a feature type added later needs no change here, as long as it keeps to the + // same invariant. + for (CadBody& b : built) + if (b.source_feature < 0) + b.source_feature = int(fi); +} + bool CadDocument::recompute() { error.clear(); @@ -4030,39 +4069,13 @@ bool CadDocument::recompute() // the 3MF recipe only "on success", so nothing was written, and deserialize_recipe ends with // `return recompute()`, so a project that did carry a recipe was refused on load with // "Could not restore the CAD model" while its features sat correctly in the list. mtav. - bool any_solid_feature = false; + const bool any_solid_feature = std::any_of(features.begin(), features.end(), [](const CadFeature& f) { + return f.enabled && produces_body(f.type); + }); try { - // Parametric pass: evaluate document variables, then each feature's expression bindings, - // writing the results into the feature's numeric fields before geometry runs. - std::map varvals = evaluate_variables(variables); - for (CadFeature& f : features) - for (const auto& [field, e] : f.expr) - assign_field(f, field, eval_expr(e, varvals)); - for (size_t fi = 0; fi < features.size(); ++fi) { - CadFeature& f = features[fi]; - if (!f.enabled) continue; - if (f.type == CadFeatureType::Sketch) continue; // consumed by an extrude - if (f.type == CadFeatureType::Helix) continue; // consumed by Sweep as a path - if (f.type == CadFeatureType::Plane) continue; // datum: no solid, derived on demand - if (f.type == CadFeatureType::Axis) continue; // datum axis - if (f.type == CadFeatureType::CoordSys) continue; // datum coordinate system - // Past the skips. Project runs here too but leaves no body of its own. - if (produces_body(f.type)) any_solid_feature = true; - settle_body_refs(f, built, true); - if (f.type == CadFeatureType::Project) { apply_project(built, f); } - else { route_feature(built, f); } - // Record which feature made each body. "Still unset?" is the whole rule, and it is - // sufficient because of an invariant worth stating: NO feature ever replaces a whole - // CadBody. Every in-place op writes only `.shape` (boolean, cut, mirror-fuse, - // transform, dress-up — checked, all 8 sites), so an existing body keeps the stamp it - // was born with; a consumed body is erased outright, taking its stamp with it; and - // the only bodies still at -1 here are the ones THIS feature just pushed. That also - // means a feature type added later needs no change here, as long as it keeps to the - // same invariant. - for (CadBody& b : built) - if (b.source_feature < 0) - b.source_feature = int(fi); - } + bind_expressions(); + for (size_t fi = 0; fi < features.size(); ++fi) + replay_feature(fi, built); } catch (const Standard_Failure& e) { // OCCT raises Standard_Failure (NOT a std::exception) — must be caught // here or it escapes the event handler and terminates the app. @@ -4217,6 +4230,175 @@ bool CadDocument::preview(const CadFeature& candidate, TriangleMesh& out_mesh, s return preview(candidate, out_mesh, ignore, err); } +namespace { + +// A face of a replayed body and what the boundary test needs from it. The projector is built on +// first use: few probes pass the box test, and building one on a freeform surface samples the +// whole surface. +struct FaceProbe { + TopoDS_Face face; + Handle(Geom_Surface) surface; + Bnd_Box box; + mutable std::unique_ptr proj; +}; + +// How close a point must be to lie on a face: a face rebuilt on the same surface is within +// 1e-7 mm of it, a new face nowhere near. +constexpr double kOnFaceTol = 1e-4; + +std::vector face_probes(const std::vector& bodies) +{ + std::vector out; + for (const CadBody& b : bodies) + for (TopExp_Explorer e(b.shape, TopAbs_FACE); e.More(); e.Next()) { + FaceProbe p; + p.face = TopoDS::Face(e.Current()); + p.surface = BRep_Tool::Surface(p.face); + if (p.surface.IsNull()) continue; + BRepBndLib::Add(p.face, p.box); + p.box.Enlarge(kOnFaceTol); + out.push_back(std::move(p)); + } + return out; +} + +bool outward_normal(const TopoDS_Face& face, double u, double v, gp_Dir& n) +{ + BRepAdaptor_Surface surf(face); + BRepLProp_SLProps props(surf, u, v, 1, Precision::Confusion()); + if (!props.IsNormalDefined()) return false; + n = props.Normal(); + if (face.Orientation() == TopAbs_REVERSED) n.Reverse(); + return true; +} + +// A point inside `face` and its outward normal there: the UV centroid of the largest triangle of +// the face's triangulation, which lies inside the trimmed face even where the centre of its UV +// bounds falls in a hole. Without a triangulation, that centre, if it is inside. +bool interior_point(const TopoDS_Face& face, gp_Pnt& p, gp_Dir& n) +{ + TopLoc_Location loc; + const Handle(Poly_Triangulation) tri = BRep_Tool::Triangulation(face, loc); + double u = 0., v = 0., best = -1.; + if (!tri.IsNull() && tri->HasUVNodes()) + for (int i = 1; i <= tri->NbTriangles(); ++i) { + int a, b, c; + tri->Triangle(i).Get(a, b, c); + const gp_Pnt pa = tri->Node(a); + const double area = gp_Vec(pa, tri->Node(b)).Crossed(gp_Vec(pa, tri->Node(c))).SquareMagnitude(); + if (area <= best) continue; + best = area; + const gp_XY uv = (tri->UVNode(a).XY() + tri->UVNode(b).XY() + tri->UVNode(c).XY()) / 3.; + u = uv.X(); + v = uv.Y(); + } + if (best < 0.) { + double u0, u1, v0, v1; + BRepTools::UVBounds(face, u0, u1, v0, v1); + u = 0.5 * (u0 + u1); + v = 0.5 * (v0 + v1); + if (BRepClass_FaceClassifier(face, gp_Pnt2d(u, v), Precision::Confusion()).State() != TopAbs_IN) + return false; + } + p = BRepAdaptor_Surface(face).Value(u, v); + return outward_normal(face, u, v, n); +} + +// Does `p` lie on one of `probes`, on a face turned the same way as `n`? The facing tells apart +// coincident faces of different bodies: a block stacked on a base has its bottom in the base's +// top face, turned the other way. +bool on_boundary(const std::vector& probes, const gp_Pnt& p, const gp_Dir& n) +{ + for (const FaceProbe& fp : probes) { + if (fp.box.IsOut(p)) continue; + try { + if (!fp.proj) { + // The setup GeomAPI_ProjectPointOnSurf(p, surface) repeats for every point, done once. + double u0, u1, v0, v1; + fp.surface->Bounds(u0, u1, v0, v1); + fp.proj = std::make_unique(); + fp.proj->Init(fp.surface, u0, u1, v0, v1, Precision::Confusion()); + } + GeomAPI_ProjectPointOnSurf& proj = *fp.proj; + proj.Perform(p); + if (proj.NbPoints() == 0 || proj.LowerDistance() > kOnFaceTol) continue; + double u, v; + proj.LowerDistanceParameters(u, v); + if (BRepClass_FaceClassifier(fp.face, gp_Pnt2d(u, v), kOnFaceTol).State() == TopAbs_OUT) continue; + gp_Dir m; + if (outward_normal(fp.face, u, v, m) && m.Dot(n) > 0.) return true; + } catch (const Standard_Failure&) { + // A surface the projection cannot handle says nothing about the point. + } + } + return false; +} + +} // namespace + +std::vector> CadDocument::faces_made_by(int index) const +{ + std::vector> out; + if (index < 0 || index >= int(features.size()) || !features[index].enabled + || !produces_body(features[index].type) || bodies.empty()) + return out; + + // Replay up to the feature, then the feature alone on a copy, so a body it left alone is the + // same shape on both sides. On a scratch document, as a replay settles references in the + // features it runs; it reads only the features, the variables and the process-wide weld rule. + CadDocument tmp; + tmp.features = features; + tmp.variables = variables; + std::vector before, after; + try { + set_sketch_auto_close(auto_close_loops); + tmp.bind_expressions(); + for (size_t fi = 0; fi < size_t(index); ++fi) + tmp.replay_feature(fi, before); + after = before; + tmp.replay_feature(size_t(index), after); + } catch (...) { // OCCT's Standard_Failure as well as std::exception + return out; + } + + // The bodies the step made or replaced. A face on any other body was on the boundary before + // the step too, so only these are probed after it. + std::vector changed; + std::vector changed_bodies; + for (int j = 0; j < int(after.size()); ++j) + if (std::none_of(before.begin(), before.end(), [&](const CadBody& w) { return w.shape.IsSame(after[j].shape); })) { + changed.push_back(j); + changed_bodies.push_back(after[j]); + } + const std::vector was = face_probes(before), now = face_probes(changed_bodies); + for (int b = 0; b < int(bodies.size()); ++b) { + int id = 0; + for (TopExp_Explorer e(bodies[b].shape, TopAbs_FACE); e.More(); e.Next(), ++id) { + gp_Pnt p; + gp_Dir n; + try { + if (interior_point(TopoDS::Face(e.Current()), p, n) && on_boundary(now, p, n) && !on_boundary(was, p, n)) + out.emplace_back(b, id); + } catch (const Standard_Failure&) { + // A face that cannot be sampled is left out, not the whole answer. + } + } + } + if (!out.empty()) + return out; + + // No face of its own: answer with every face of each body the step changed, found again in + // the finished model by the id a body keeps across the rest of the history. + for (int j : changed) { + const int b = find_body(bodies, body_id_of(after, j)); + if (b < 0) continue; + const int n = GeometryEngine::face_count(bodies[b].shape); + for (int id = 0; id < n; ++id) + out.emplace_back(b, id); + } + return out; +} + std::string brep_to_string(const TopoDS_Shape& s) { if (s.IsNull()) return {}; diff --git a/src/libslic3r/CAD/CadDocument.hpp b/src/libslic3r/CAD/CadDocument.hpp index d247780d5d..8adbd7b20c 100644 --- a/src/libslic3r/CAD/CadDocument.hpp +++ b/src/libslic3r/CAD/CadDocument.hpp @@ -848,6 +848,16 @@ public: bool preview(const CadFeature& candidate, TriangleMesh& out_mesh, std::vector& out_body_meshes, std::string& err) const; + // The faces of the current bodies that features[index] made, as (body, face id) pairs: what + // the Design tab highlights when that feature is selected. A face counts when it lies on the + // model's boundary, facing the same way, right after the feature and not right before it, so + // a face a later feature trimmed still belongs to the one that made it. Positions are + // compared, no history is kept: a face a later feature rebuilt in place stays the earlier + // feature's. A feature with no face left of its own (a Boolean union, or one whose faces a + // later feature removed) answers with every face of each body it changed. Empty for a feature + // that leaves no body, a hidden one, or a history that no longer rebuilds up to it. + std::vector> faces_made_by(int index) const; + private: TopoDS_Wire build_sketch_wire(const CadFeature& sketch, bool closed_only = false) const; // The planar region an Extrude sweeps: the sketch's outer loop with its inner loops as @@ -864,6 +874,13 @@ private: // starts a new body (empty list, or an Extrude with mode New) vs mutates an existing // one, then apply_feature. Shared by recompute() (replay all) and preview() (candidate). void route_feature(std::vector& bodies, const CadFeature& f) const; + // The parametric pass every replay starts with: evaluate the variables and write each + // feature's expression bindings into its numeric fields. + void bind_expressions(); + // One step of the replay: route features[fi] into `built` and stamp the bodies it created + // with fi. A hidden feature, a sketch, a helix or a datum leaves `built` alone. Throws on + // failure. + void replay_feature(size_t fi, std::vector& built); // Boolean between two existing bodies: resolve target + tool, optionally snap the tool so // the picked faces mate, run the OCCT op (with fuzzy tolerance), write the result back to the // target and erase the consumed tool. Mutates the bodies vector directly (unlike apply_feature, diff --git a/src/slic3r/GUI/CAD/DesignCanvas.cpp b/src/slic3r/GUI/CAD/DesignCanvas.cpp index c638747b89..ad7ede3d05 100644 --- a/src/slic3r/GUI/CAD/DesignCanvas.cpp +++ b/src/slic3r/GUI/CAD/DesignCanvas.cpp @@ -16,6 +16,7 @@ #include "libslic3r/Config.hpp" #include #include +#include #include #include @@ -331,33 +332,25 @@ void DesignCanvas::reload(bool keep_view) for (int i = 0; i < (int)m_model.objects.size(); ++i) m_canvas->load_object(m_model, i); - const ColorRGBA sel_gold = design_selection_color(); // same colour as every other selection const ColorRGBA ghost(0.26f, 0.66f, 1.0f, 0.45f); const auto& volumes = m_canvas->get_volumes().volumes; for (auto* v : volumes) { int obj_idx = v->object_idx(); if (obj_idx == 0) { - // Object 0 holds one volume per body — colour each by its body index so - // multiple coexisting solids are visually distinct (Onshape per-part colour). - const int b = v->volume_idx(); + // Object 0 holds the bodies (rebuild_bodies): each volume in its body's colour, so + // coexisting solids read as distinct parts (Onshape per-part colour), or in the + // selection colour when it holds the body's selected faces. + const int vi = v->volume_idx(); + const int b = vi < int(m_volumes.size()) ? m_volumes[vi].body : vi; + const bool lit = vi < int(m_volumes.size()) && m_volumes[vi].lit; bool hidden = (b >= 0 && b < int(m_body_visible.size())) && !m_body_visible[b]; // Preview-only mode (fillet/chamfer/draft, once a valid target is picked): hide // every base body so only the result ghost is on screen until Confirm. if (m_body_hidden) hidden = true; v->is_active = !hidden; // per-body visibility toggle if (!hidden) { - // An EXPLICIT colour outranks the selection tint. m_body_selected is a - // document-wide flag raised whenever a non-Sketch feature row is selected — - // the normal resting state after any modelling operation — so painting every - // body gold on it made the Color tool look broken: the override was written, - // carried across recompute and read back correctly, and then overpainted here - // every single frame. A body the user deliberately coloured keeps its colour; - // the rest still tint, which is all the tint was ever for. - const bool overridden = m_color_bodies != nullptr && b >= 0 - && b < int(m_color_bodies->size()) - && (*m_color_bodies)[b].has_color; - ColorRGBA c = (m_body_selected && !overridden) ? sel_gold : body_color(b); + ColorRGBA c = lit ? design_selection_color() : body_color(b); if (b == m_hl_body_target) c = ColorRGBA(0.30f, 0.90f, 0.70f, 1.0f); // target = teal-green else if (b == m_hl_body_tool) c = ColorRGBA(1.00f, 0.55f, 0.15f, 1.0f); // tool = orange if (m_body_translucent) c.a(0.30f); @@ -385,45 +378,23 @@ void DesignCanvas::reload(bool keep_view) m_canvas_widget->Refresh(); } -void DesignCanvas::set_mesh(const TriangleMesh& mesh) -{ - if (m_model.objects.empty()) { - auto* obj = m_model.add_object(); - obj->add_volume(mesh); - obj->add_instance(); - } else { - ModelObject* obj = m_model.objects.front(); - obj->clear_volumes(); - obj->add_volume(mesh); - if (obj->instances.empty()) - obj->add_instance(); - } - - reload(!m_first_frame); -} - -void DesignCanvas::set_bodies(const std::vector& body_meshes, +void DesignCanvas::set_bodies(const std::vector* body_meshes, const std::vector& visible) { - // Object 0 carries one GLVolume per body so reload() can colour each distinctly. - // Falls back to a single-volume object when there's only one body (identical look - // to the old set_mesh path). Picking still uses the combined mesh via set_solid_pick. + // Object 0 is built by rebuild_bodies; picking uses the combined mesh from set_solid_pick. m_body_visible = visible; // empty => all visible; reload() reads this per volume - if (body_meshes.empty()) { clear_mesh(); return; } - - ModelObject* obj = m_model.objects.empty() ? m_model.add_object() - : m_model.objects.front(); - obj->clear_volumes(); - for (const TriangleMesh& m : body_meshes) - obj->add_volume(m); - if (obj->instances.empty()) - obj->add_instance(); + if (body_meshes == nullptr || body_meshes->empty()) { clear_mesh(); return; } + m_body_meshes = body_meshes; + m_lit_faces = m_sketch_tool.selected_faces(); + rebuild_bodies(); reload(!m_first_frame); } void DesignCanvas::clear_mesh() { + m_body_meshes = nullptr; + m_volumes.clear(); if (!m_model.objects.empty()) { m_model.delete_object((size_t)0); reload(true); @@ -682,6 +653,7 @@ void DesignCanvas::clear_loop_pick() void DesignCanvas::clear_solid_pick() { m_sketch_tool.clear_solid_selection(); + sync_selected_faces(); } void DesignCanvas::set_loop_pick(int feature, int region) @@ -711,7 +683,9 @@ void DesignCanvas::set_solid_pick(const std::vector* bodies, const Tria const std::vector* xform) { m_color_bodies = bodies; // stable address (m_doc.bodies); reload() reads colour overrides + m_tri_face = tri_face; m_sketch_tool.set_solid_pick(bodies, mesh, tri_face, tri_body, visible, xform); + sync_selected_faces(); // the tool just reset its pick } // Effective display colour for a body: per-body override (Color tool) when set, else the @@ -914,12 +888,19 @@ std::vector DesignCanvas::selected_solid_edges() const void DesignCanvas::set_on_solid_selection_changed(std::function cb) { - m_sketch_tool.on_solid_selection_changed = std::move(cb); + // Wrapped so every pick change in the tool also re-splits the filled faces. + m_on_solid_selection_changed = std::move(cb); + m_sketch_tool.on_solid_selection_changed = [this](int level, int body, int face, int edge) { + sync_selected_faces(); + if (m_on_solid_selection_changed) + m_on_solid_selection_changed(level, body, face, edge); + }; } void DesignCanvas::select_body(int body) { m_sketch_tool.select_body(body); + sync_selected_faces(); request_repaint(); } @@ -1211,11 +1192,75 @@ void DesignCanvas::render_hud() chip("##design_readout", m_hud_last, ds.x - margin, 1.f, 0.f, &ImGuiWrapper::COL_ORCA); } -void DesignCanvas::set_body_highlight(bool on) +void DesignCanvas::set_highlight_faces(const std::vector>& faces) { - if (m_body_selected == on) return; - m_body_selected = on; - reload(true); // recolours the body volume (selected = cyan tint) + m_sketch_tool.set_highlight_faces(faces); + sync_selected_faces(); + request_repaint(); +} + +void DesignCanvas::sync_selected_faces() +{ + // Deferred to the end of the current event, which may change the selection several times: + // every re-split reloads all the bodies. A set_bodies in between splits by the current + // selection itself and leaves this nothing to do. + if (m_split_pending) + return; + m_split_pending = true; + CallAfter([this] { + m_split_pending = false; + std::vector> want = m_sketch_tool.selected_faces(); + if (want == m_lit_faces) + return; + m_lit_faces = std::move(want); + if (m_body_meshes == nullptr || m_model.objects.empty()) + return; + rebuild_bodies(); + reload(true); + }); +} + +// Object 0: one volume per body, with its selected faces split off into a volume of their own. +// The per-triangle face ids (all bodies, in order) match triangles to faces; when their count +// does not match the meshes, nothing is split. +void DesignCanvas::rebuild_bodies() +{ + ModelObject* obj = m_model.objects.empty() ? m_model.add_object() : m_model.objects.front(); + obj->clear_volumes(); + m_volumes.clear(); + const std::vector& meshes = *m_body_meshes; + size_t ntri = 0; + for (const TriangleMesh& m : meshes) + ntri += m.its.indices.size(); + const bool mapped = m_tri_face != nullptr && m_tri_face->size() == ntri; + size_t off = 0; + for (int b = 0; b < int(meshes.size()); ++b) { + const TriangleMesh& mesh = meshes[b]; + const auto first = std::lower_bound(m_lit_faces.begin(), m_lit_faces.end(), std::make_pair(b, INT_MIN)); + const auto last = std::lower_bound(first, m_lit_faces.end(), std::make_pair(b + 1, INT_MIN)); + if (!mapped || first == last) { + obj->add_volume(mesh); + m_volumes.push_back({ b, false }); + } else { + indexed_triangle_set parts[2]; // [0] the rest of the body, [1] its selected faces + for (size_t i = 0; i < mesh.its.indices.size(); ++i) { + const int f = (*m_tri_face)[off + i]; + const bool lit = std::binary_search(first, last, std::make_pair(b, f)); + parts[lit].indices.push_back(mesh.its.indices[i]); + } + for (int lit = 0; lit < 2; ++lit) { + if (parts[lit].indices.empty()) + continue; + parts[lit].vertices = mesh.its.vertices; + its_compactify_vertices(parts[lit]); + obj->add_volume(TriangleMesh(std::move(parts[lit]))); + m_volumes.push_back({ b, lit == 1 }); + } + } + off += mesh.its.indices.size(); + } + if (obj->instances.empty()) + obj->add_instance(); } void DesignCanvas::set_operand_bodies(int target_body, int tool_body) @@ -1223,7 +1268,7 @@ void DesignCanvas::set_operand_bodies(int target_body, int tool_body) if (m_hl_body_target == target_body && m_hl_body_tool == tool_body) return; m_hl_body_target = target_body; m_hl_body_tool = tool_body; - reload(true); // recolours the body volumes (same idiom set_body_highlight uses) + reload(true); // recolours the body volumes } void DesignCanvas::set_highlight_sketches(std::vector> hl) diff --git a/src/slic3r/GUI/CAD/DesignCanvas.hpp b/src/slic3r/GUI/CAD/DesignCanvas.hpp index 5701bd5ec4..e10ecdb2ba 100644 --- a/src/slic3r/GUI/CAD/DesignCanvas.hpp +++ b/src/slic3r/GUI/CAD/DesignCanvas.hpp @@ -41,10 +41,10 @@ public: explicit DesignCanvas(wxWindow* parent); ~DesignCanvas() override; - void set_mesh(const TriangleMesh& mesh); // Multi-body display: one GLVolume per body, each coloured distinctly (per-body colour). - // `visible` (optional, indexed by body) hides bodies whose flag is false. - void set_bodies(const std::vector& body_meshes, + // `visible` (optional, indexed by body) hides bodies whose flag is false. `body_meshes` is kept + // by address (a stable panel member) and read again whenever the selection changes. + void set_bodies(const std::vector* body_meshes, const std::vector& visible = {}); void clear_mesh(); @@ -239,7 +239,9 @@ public: // Mate connectors, drawn as frames so their verse and polarity are visible (wgsc). void set_mate_connectors(std::vector g); void set_mate_links(std::vector> l); - void set_body_highlight(bool on); // tint the solid when its feature is tree-selected + // Draw these (body, face id) faces as selected: the faces the Feature tree's selected feature + // made (CadDocument::faces_made_by). Empty clears them. + void set_highlight_faces(const std::vector>& faces); // The status line, shown along the BASE OF THE VIEWPORT rather than in the side panel: // the panel clips it at ~73 characters with no warning (8cc), the viewport's // bottom margin has the whole window width to spare. Empty text hides it. @@ -375,6 +377,20 @@ private: void reload(bool keep_view); void swap_camera(); // enter_viewport / leave_viewport, in the one direction they share + // Selected faces are filled by the canvas: each body's selected faces become a volume of their + // own, drawn opaque in the selection colour with the body's shader and lighting, so a selection + // is the same colour on every body. The faces are the sketch tool's selected_faces() (the + // Feature tree row's and the committed face or body pick), which the tool outlines. + void rebuild_bodies(); // object 0 from m_body_meshes, split by m_lit_faces + void sync_selected_faces(); // re-split and reload, once queued, if the selection changed + struct BodyVolume { int body; bool lit; }; // an object 0 volume: its body, and whether it holds selected faces + const std::vector* m_body_meshes{nullptr}; // set_bodies + const std::vector* m_tri_face{nullptr}; // per-triangle face id, all bodies in order + std::vector> m_lit_faces; // what object 0 is split by now + std::vector m_volumes; // object 0's volumes, in order + bool m_split_pending{false}; + std::function m_on_solid_selection_changed; + wxGLCanvas* m_canvas_widget{nullptr}; GLCanvas3D* m_canvas{nullptr}; int m_sw_gl{-1}; // -1 unknown, 0 hardware GL, 1 software GL @@ -394,7 +410,6 @@ private: bool m_camera_swapped{false}; // guards a leave without an enter, and the reverse Model m_model; bool m_first_frame{true}; - bool m_body_selected{false}; // tree selected a body feature → tint the solid int m_hl_body_target{-1}; int m_hl_body_tool{-1}; bool m_body_translucent{false};// fillet/chamfer preview → render the body see-through diff --git a/src/slic3r/GUI/CAD/DesignPanel.cpp b/src/slic3r/GUI/CAD/DesignPanel.cpp index 9c84a65942..1f8ad16d5c 100644 --- a/src/slic3r/GUI/CAD/DesignPanel.cpp +++ b/src/slic3r/GUI/CAD/DesignPanel.cpp @@ -3093,9 +3093,8 @@ DesignPanel::DesignPanel(wxWindow* parent) m_tree->SetBackgroundColour(dp_panel_bg()); tree_inner->Add(m_tree, 0, wxEXPAND | wxALL, 12); - // Selecting a body-producing feature (Extrude/Fillet/Chamfer/Hole/Thread) in the - // tree highlights the solid in the viewport; a Sketch row clears the highlight - // (its face is already shown via the persistent sketch overlay). + // Selecting a feature that leaves a body (Extrude, Fillet, Chamfer, Hole, ...) lights the + // faces it made in the viewport — the fillet's round, not the whole part it sits on. m_tree->on_select = [this] { if (!m_viewport) return; // Picking a feature drops any body selection, so the two lists never both claim to be @@ -3104,10 +3103,11 @@ DesignPanel::DesignPanel(wxWindow* parent) // THIS handler, which would otherwise clear the body row the user had just clicked. if (m_parts && tree_selection() != wxNOT_FOUND) m_parts->unselect(); const int sel = tree_selection(); - const bool body = (sel >= 0 && sel < int(m_doc.features.size()) && - m_doc.features[sel].type != CadFeatureType::Sketch && - !m_doc.body.IsNull()); - m_viewport->set_body_highlight(body); + // Likewise a viewport pick, which would be drawn just like the feature's faces. Not while + // a card is open: the card reads that pick. + if (sel != wxNOT_FOUND && m_active == Tool::None) + drop_solid_pick(); // not the loop pick: clicking a sketch loop selects its row + request_feature_highlight(); // A conflicting mate says WHY on selection, and names the one action that resolves it. // Suppress is the generic per-feature enable toggle (the eye), so the answer is already // one click away on a row the user has just selected — the message points at it instead @@ -3268,7 +3268,6 @@ DesignPanel::DesignPanel(wxWindow* parent) // One selection at a time: a body row and a feature row mean different things to the // op bar, so clear the feature tree's highlight when a body takes over. if (m_tree) m_tree->unselect(); - m_viewport->set_body_highlight(false); // the per-body overlay does the tint m_viewport->select_body(b); // also drops the vertex/edge marker m_sel_solid_body = b; m_sel_solid_face = m_sel_solid_edge = -1; @@ -3623,9 +3622,12 @@ DesignPanel::DesignPanel(wxWindow* parent) // has focus; we lay the selected body face on the bed. Returns false when no face is // selected so the key can fall through to the default handler. - // Clicking a solid cycles whole -> face -> edge. The tool draws the cyan overlay for ALL - // levels now (per-body, so other bodies stay untinted) — no whole-compound set_body_highlight. + // Clicking a solid cycles whole -> face -> edge. The tool draws the selection for every + // level, per body, so other bodies keep their own look. m_viewport->set_on_solid_selection_changed([this](int level, int body, int face, int edge) { + // One selection at a time: a pick replaces the Feature tree row. Not while a card is open: + // the feature being edited keeps its row while the card's picks are made. + if (level >= 1 && m_active == Tool::None && m_tree) m_tree->unselect(); // A pick that fell through the move gizmo (clicked off the arrows) exits move mode. if (m_viewport->moving_body()) m_viewport->clear_move_gizmo(); // Remember which body + face/edge so Extrude / dress-up target the RIGHT body. @@ -7462,6 +7464,7 @@ void DesignPanel::refresh_tree() m_tree->set_rows(std::move(rows)); refresh_parts(); // bodies live in their own list below the tree, never clipped by history m_tree->select(keep); + request_feature_highlight(); // set_rows and select() say nothing when `keep` is gone if (m_form && m_form->GetSizer()) { update_cards_frame(); m_form->Layout(); m_form->FitInside(); } } @@ -7610,7 +7613,50 @@ void DesignPanel::feed_bodies() // place), so it needs no re-call here — the whole/face/edge selection survives a move drag. if (m_viewport == nullptr) return; rebuild_disp_meshes(); - m_viewport->set_bodies(m_disp_body_meshes, m_body_visible); + m_viewport->set_bodies(&m_disp_body_meshes, m_body_visible); + // A new topology may renumber faces, so the feature's faces are found again; until then the + // viewport drops those on a body whose shape has changed. + if (m_hl_generation != m_doc.topo_generation) + request_feature_highlight(); +} + +void DesignPanel::request_feature_highlight() +{ + if (m_hl_pending) return; + m_hl_pending = true; + // Not on the stack of the click or the rebuild that asked: finding the faces may put up the + // busy dialog, and the document must have settled. + CallAfter([this] { update_feature_highlight(); }); +} + +void DesignPanel::update_feature_highlight() +{ + m_hl_pending = false; + if (m_viewport == nullptr) return; + // A rebuild's busy loop runs queued events while its worker owns the document. Skipped, not + // re-queued (the loop would run it again at once and spin): the rebuild's own refresh + // (feed_bodies, refresh_tree) asks again when it is done. + if (s_doc_worker_busy.load() > 0) return; + const int sel = tree_selection(); + // Hidden while a feature card is open: the card's ghost and picks are what the view is about. + const bool wanted = sel >= 0 && sel < int(m_doc.features.size()) && m_active == Tool::None; + if (wanted && (sel != m_hl_feature || m_hl_generation != m_doc.topo_generation)) { + std::vector> faces; + const CadFeature& f = m_doc.features[sel]; + if (f.enabled && CadDocument::produces_body(f.type)) // the rest make no faces + run_off_ui_thread(this, _L("Finding the feature's faces…"), [this, sel, &faces] { + try { + faces = m_doc.faces_made_by(sel); + } catch (...) { + faces.clear(); // a highlight is not worth an escaped exception + } + }); + m_hl_faces = std::move(faces); + m_hl_feature = sel; + m_hl_generation = m_doc.topo_generation; + } + // Re-sending the same faces is cheap: the viewport reuses what it has. + m_viewport->set_highlight_faces(wanted ? m_hl_faces : std::vector>{}); } // Boolean (combine bodies) — one gate for every door onto the tool. A body-body operation @@ -7774,15 +7820,20 @@ void DesignPanel::set_tree_selection(int row) // delete, reorder) drops it, the viewport's highlights with it. The solid highlight too: a rebuild // that leaves no body never reaches set_solid_pick. The callers repaint. void DesignPanel::drop_selection() +{ + if (m_viewport != nullptr) + m_viewport->clear_loop_pick(); + drop_solid_pick(); +} + +void DesignPanel::drop_solid_pick() { m_sel_solid_body = m_sel_solid_face = m_sel_solid_edge = -1; m_sel_solid_edges.clear(); m_sel_solid_vertex = false; m_pick_face = m_pick_face_body = -1; - if (m_viewport != nullptr) { - m_viewport->clear_loop_pick(); + if (m_viewport != nullptr) m_viewport->clear_solid_pick(); - } } // The shared front of delete and reorder, which renumber the feature list. A sketch or constrain @@ -11417,6 +11468,7 @@ void DesignPanel::open_tool(Tool t) // the card appears always means "keep this one" regardless of what the last session did. if (t == Tool::Boolean) m_bool_next_slot = 0; + request_feature_highlight(); // the card's ghost and picks take the view over } void DesignPanel::close_tool() @@ -11486,6 +11538,7 @@ void DesignPanel::close_tool() update_cards_frame(); m_form->Layout(); m_form->FitInside(); update_action_bar(); // no feature tool active -> hide the bar (unless a mode keeps it) + request_feature_highlight(); // the selected row's faces come back with the card gone } void DesignPanel::confirm_tool() diff --git a/src/slic3r/GUI/CAD/DesignPanel.hpp b/src/slic3r/GUI/CAD/DesignPanel.hpp index 86fde6254e..772cb1d09f 100644 --- a/src/slic3r/GUI/CAD/DesignPanel.hpp +++ b/src/slic3r/GUI/CAD/DesignPanel.hpp @@ -294,6 +294,7 @@ private: void on_edit_feature(); // reopen the selected feature's dialog populated void after_tree_edit(bool ok); // shared post-op refresh of tree/viewport/status void drop_selection(); // forget the selection (solid, hit face, sketch loop): the feature list was replaced or renumbered + void drop_solid_pick(); // the solid and hit-face part of drop_selection bool begin_renumber(); // before delete/reorder: refuse while an index is held, else close the card, checkpoint, drop picks void load_feature_into_dialog(const CadFeature& f); void reset_edit_state(); // back to add-mode (m_edit_index = -1) @@ -905,6 +906,16 @@ private: // own Edit / Show-hide / Delete icons. Callers use row indices via // tree_selection()/set_tree_selection(); refresh_tree() rebuilds the rows. DesignRowList* m_tree{nullptr}; + // The faces the selected feature row made (CadDocument::faces_made_by), drawn as selected. + // Finding them replays the history, so they are kept per row and topology generation. + // request_feature_highlight() refreshes them after the current event; every change of row, + // card or topology calls it. + int m_hl_feature{-1}; // the row m_hl_faces were found for... + uint64_t m_hl_generation{0}; // ...on this topology + std::vector> m_hl_faces; + bool m_hl_pending{false}; + void request_feature_highlight(); + void update_feature_highlight(); // Bodies list under the feature tree: one row per body (parallel to m_doc.bodies). Selecting // one highlights that body and makes it the target for the next op. DesignRowList* m_parts{nullptr}; diff --git a/src/slic3r/GUI/CAD/DesignSketchTool.cpp b/src/slic3r/GUI/CAD/DesignSketchTool.cpp index dfbed59f55..7d78cd18b6 100644 --- a/src/slic3r/GUI/CAD/DesignSketchTool.cpp +++ b/src/slic3r/GUI/CAD/DesignSketchTool.cpp @@ -21,8 +21,11 @@ #include "libslic3r/CAD/GeometryEngine.hpp" #include +#include #include #include +#include +#include #include "libslic3r/TriangleMesh.hpp" #include @@ -3450,50 +3453,105 @@ void DesignSketchTool::set_solid_pick(const std::vector* bodies, const clear_solid_selection(); } +// The chord tolerance body edges are sampled at: a thousandth of the body's size, so round edges +// stay round at any zoom that shows the whole body, without sampling a large import into millions +// of segments. +static double display_edge_tol(const TopoDS_Shape& shape) +{ + Bnd_Box box; + BRepBndLib::Add(shape, box); + return std::max(1e-3 * (box.IsVoid() ? 1.0 : std::sqrt(box.SquareExtent())), 0.005); +} + void DesignSketchTool::refresh_body_edges() { const size_t n = m_solid_bodies != nullptr ? m_solid_bodies->size() : 0; m_body_edges.resize(n); m_body_edges_key.resize(n, nullptr); + m_body_edges_tol.resize(n, 0.0); for (size_t b = 0; b < n; ++b) { - const TopoDS_Shape& shape = (*m_solid_bodies)[b].shape; - const void* key = shape.IsNull() ? nullptr : shape.TShape().get(); + const void* key = body_key(int(b)); if (key == m_body_edges_key[b] && key != nullptr) continue; m_body_edges_key[b] = key; m_body_edges[b].clear(); if (key == nullptr) continue; - // A thousandth of the body's size: round edges stay round at any zoom that shows the - // whole body, without sampling a large import into millions of segments. - Bnd_Box box; - BRepBndLib::Add(shape, box); - const double diag = box.IsVoid() ? 1.0 : std::sqrt(box.SquareExtent()); + const TopoDS_Shape& shape = (*m_solid_bodies)[b].shape; + m_body_edges_tol[b] = display_edge_tol(shape); try { - m_body_edges[b] = GeometryEngine::display_edges(shape, std::max(1e-3 * diag, 0.005)); + m_body_edges[b] = GeometryEngine::display_edges(shape, m_body_edges_tol[b]); } catch (const Standard_Failure&) { m_body_edges[b].clear(); // an unsampleable edge costs its body the lines, nothing else } } } +// The identity of a body's current shape. Face ids and sampled edges hold while it does. +const void* DesignSketchTool::body_key(int body) const +{ + if (m_solid_bodies == nullptr || body < 0 || body >= int(m_solid_bodies->size())) + return nullptr; + const TopoDS_Shape& shape = (*m_solid_bodies)[body].shape; + return shape.IsNull() ? nullptr : shape.TShape().get(); +} + +// View-facing ribbons along a body's polylines (shape coordinates), `hw` either side of the line +// and moved by `pull` toward the eye, so a line wins the depth test against the faces meeting at +// it while a face in front of it still hides it. +void DesignSketchTool::append_ribbons(GLModel::Geometry& g, int body, const std::vector>& polylines, + const Vec3d& vd, const Vec3d& pull, double hw) const +{ + g.format = { GLModel::Geometry::EPrimitiveType::Triangles, GLModel::Geometry::EVertexLayout::P3 }; + unsigned int base = unsigned(g.vertices_count()); + for (const std::vector& pl : polylines) + for (size_t s = 1; s < pl.size(); ++s) { + const Vec3d a = body_xform_pt(body, pl[s - 1]) + pull, c = body_xform_pt(body, pl[s]) + pull; + Vec3d dir = c - a; if (dir.norm() < 1e-9) continue; dir.normalize(); + Vec3d off = dir.cross(vd); + if (off.norm() < 1e-9) continue; // edge seen end-on: a point, nothing to draw + off = off.normalized() * hw; + g.add_vertex((Vec3f)(a + off).cast()); + g.add_vertex((Vec3f)(c + off).cast()); + g.add_vertex((Vec3f)(c - off).cast()); + g.add_vertex((Vec3f)(a - off).cast()); + g.add_triangle(base, base + 1, base + 2); + g.add_triangle(base, base + 2, base + 3); base += 4; + } +} + +// Ribbons tested against the scene's depth without writing it, and blended. +static void render_ribbons(GLModel& model, GLModel::Geometry&& g, const ColorRGBA& colour) +{ + if (g.is_empty()) + return; + glsafe(::glEnable(GL_DEPTH_TEST)); + glsafe(::glDepthFunc(GL_LEQUAL)); + glsafe(::glDepthMask(GL_FALSE)); + glsafe(::glEnable(GL_BLEND)); + glsafe(::glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)); + model.reset(); + model.init_from(std::move(g)); + model.set_color(colour); + model.render(); + glsafe(::glDepthMask(GL_TRUE)); + glsafe(::glDepthFunc(GL_LESS)); + glsafe(::glDisable(GL_BLEND)); + glsafe(::glDisable(GL_DEPTH_TEST)); +} + void DesignSketchTool::render_body_edges() { if (m_body_edges_hidden || m_solid_bodies == nullptr) return; - using EPT = GLModel::Geometry::EPrimitiveType; - using EVL = GLModel::Geometry::EVertexLayout; const Camera& cam = wxGetApp().plater()->get_camera(); const Vec3d vd = cam.get_dir_forward(); const double px = 1.0 / std::max(cam.get_zoom(), 1e-6); const double hw = 1.0 * px; // ~2 px wide: at 1.5 the lines read as hairlines - // Pulled toward the eye by a few pixels, so the line wins the depth test against the two - // faces meeting at the edge while a face in front of it still hides it. const Vec3d pull = -vd * (3.0 * px); // Two passes: the edges of a body faded by body focus are fainter, like the body itself. for (int pass = 0; pass < 2; ++pass) { - GLModel::Geometry g; g.format = { EPT::Triangles, EVL::P3 }; - unsigned int base = 0; + GLModel::Geometry g; for (int b = 0; b < int(m_body_edges.size()); ++b) { if (m_solid_visible != nullptr && b < int(m_solid_visible->size()) && !(*m_solid_visible)[b]) continue; @@ -3501,36 +3559,9 @@ void DesignSketchTool::render_body_edges() && b != m_pick_only_body; if (faded != (pass == 1)) continue; - for (const std::vector& pl : m_body_edges[b]) - for (size_t s = 1; s < pl.size(); ++s) { - const Vec3d a = body_xform_pt(b, pl[s - 1]) + pull, c = body_xform_pt(b, pl[s]) + pull; - Vec3d dir = c - a; if (dir.norm() < 1e-9) continue; dir.normalize(); - Vec3d off = dir.cross(vd); - if (off.norm() < 1e-9) continue; // edge seen end-on: a point, nothing to draw - off = off.normalized() * hw; - g.add_vertex((Vec3f)(a + off).cast()); - g.add_vertex((Vec3f)(c + off).cast()); - g.add_vertex((Vec3f)(c - off).cast()); - g.add_vertex((Vec3f)(a - off).cast()); - g.add_triangle(base, base + 1, base + 2); - g.add_triangle(base, base + 2, base + 3); base += 4; - } + append_ribbons(g, b, m_body_edges[b], vd, pull, hw); } - if (base == 0) - continue; - glsafe(::glEnable(GL_DEPTH_TEST)); - glsafe(::glDepthFunc(GL_LEQUAL)); - glsafe(::glDepthMask(GL_FALSE)); - glsafe(::glEnable(GL_BLEND)); - glsafe(::glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)); - m_body_edges_model.reset(); - m_body_edges_model.init_from(std::move(g)); - m_body_edges_model.set_color(ColorRGBA(0.08f, 0.09f, 0.11f, pass == 0 ? 0.85f : 0.25f)); - m_body_edges_model.render(); - glsafe(::glDepthMask(GL_TRUE)); - glsafe(::glDepthFunc(GL_LESS)); - glsafe(::glDisable(GL_BLEND)); - glsafe(::glDisable(GL_DEPTH_TEST)); + render_ribbons(m_body_edges_model, std::move(g), ColorRGBA(0.08f, 0.09f, 0.11f, pass == 0 ? 0.85f : 0.25f)); } } @@ -3596,7 +3627,121 @@ void DesignSketchTool::select_body(int body) m_sel_edge_pts.clear(); m_sel_edges_more.clear(); m_sel_edges_more_pts.clear(); - m_solid_sel = SolidSel::Whole; // render_solid_highlight tints just this body + m_solid_sel = SolidSel::Whole; // the canvas fills just this body, render_solid_highlight outlines it +} + +void DesignSketchTool::set_highlight_faces(const std::vector>& faces) +{ + std::map> by_body; + for (const auto& [b, f] : faces) + if (body_key(b) != nullptr) + by_body[b].push_back(f); + // Reuse each body's entry, so re-sending the same faces resamples nothing. + std::vector next; + for (auto& [b, fs] : by_body) { + FaceHighlight h; + for (FaceHighlight& old : m_hl_faces) + if (old.body == b) + h = std::move(old); + cached_face_highlight(h, b, std::move(fs)); + next.push_back(std::move(h)); + } + m_hl_faces = std::move(next); +} + +DesignSketchTool::FaceHighlight DesignSketchTool::make_face_highlight(int body, std::vector faces) const +{ + FaceHighlight h; + h.body = body; + std::sort(faces.begin(), faces.end()); + faces.erase(std::unique(faces.begin(), faces.end()), faces.end()); + h.faces = std::move(faces); + h.key = body_key(body); + if (h.key == nullptr || h.faces.empty()) + return h; + // Sampled at the body edges' tolerance; a whole body reuses its body edges. + const TopoDS_Shape& shape = (*m_solid_bodies)[body].shape; + const std::vector all = GeometryEngine::faces_of(shape); + const bool cached = body < int(m_body_edges_key.size()) && m_body_edges_key[body] == h.key; + if (cached && h.faces.size() == all.size() && h.faces.front() == 0 && h.faces.back() == int(all.size()) - 1) { + h.edges = m_body_edges[body]; + return h; + } + TopoDS_Compound picked; + BRep_Builder builder; + builder.MakeCompound(picked); + for (int f : h.faces) + if (f >= 0 && f < int(all.size())) + builder.Add(picked, all[f]); + try { + h.edges = GeometryEngine::display_edges(picked, cached ? m_body_edges_tol[body] : display_edge_tol(shape)); + } catch (const Standard_Failure&) { + h.edges.clear(); // an edge that cannot be sampled costs the outline, never the fill + } + return h; +} + +std::vector> DesignSketchTool::picked_faces() const +{ + std::vector> out; + if (body_key(m_sel_body) == nullptr) + return out; + if (m_solid_sel == SolidSel::Face && m_sel_face >= 0) + out.emplace_back(m_sel_body, m_sel_face); + else if (m_solid_sel == SolidSel::Whole) + for (int f = 0, n = GeometryEngine::face_count((*m_solid_bodies)[m_sel_body].shape); f < n; ++f) + out.emplace_back(m_sel_body, f); + return out; +} + +std::vector> DesignSketchTool::selected_faces() const +{ + std::vector> out = picked_faces(); + for (const FaceHighlight& h : m_hl_faces) + if (h.key != nullptr && h.key == body_key(h.body)) + for (int f : h.faces) + out.emplace_back(h.body, f); + std::sort(out.begin(), out.end()); + out.erase(std::unique(out.begin(), out.end()), out.end()); + return out; +} + +const DesignSketchTool::FaceHighlight& DesignSketchTool::cached_face_highlight(FaceHighlight& cache, int body, + std::vector faces) const +{ + std::sort(faces.begin(), faces.end()); + faces.erase(std::unique(faces.begin(), faces.end()), faces.end()); + if (cache.body != body || cache.faces != faces || cache.key != body_key(body)) + cache = make_face_highlight(body, std::move(faces)); + return cache; +} + +// The edges of selected faces, cased: a dark band under a selection-coloured line, so the +// selection's outline reads on a body of any colour, the selection colour included. The faces +// themselves are filled by the canvas (DesignCanvas::rebuild_bodies). `quiet` is the hover's +// version: the line alone, thinner and fainter. +void DesignSketchTool::render_face_outline(const FaceHighlight& h, bool quiet) +{ + if (m_body_edges_hidden || h.key == nullptr || h.key != body_key(h.body) || !body_pickable(h.body)) + return; // hidden, or the body was rebuilt and these face ids are stale + + const Camera& cam = wxGetApp().plater()->get_camera(); + // View-facing ribbons like the body edges, pulled a pixel further toward the eye so they win + // over the dark body edges along the same curves. + const Vec3d vd = cam.get_dir_forward(); + const double px = 1.0 / std::max(cam.get_zoom(), 1e-6); + const Vec3d pull = -vd * (4.0 * px); + auto draw = [&](double hw_px, const ColorRGBA& colour) { + GLModel::Geometry g; + append_ribbons(g, h.body, h.edges, vd, pull, hw_px * px); + render_ribbons(m_solid_edge_model, std::move(g), colour); + }; + if (quiet) { + draw(1.0, design_selection_color(0.8f)); + } else { + draw(2.25, ColorRGBA(0.05f, 0.06f, 0.08f, 1.0f)); + draw(1.25, design_selection_color()); + } } // Pick tracing. Selection failures on a real desktop have repeatedly turned out to be an @@ -3918,53 +4063,19 @@ bool DesignSketchTool::update_solid_hover(GLCanvas3D& canvas, const wxMouseEvent return true; } -// One highlight, drawn from explicit arguments rather than from the selection members, so the -// committed selection and the hover pre-highlight cannot drift apart in how they look. alpha_mul -// scales every layer at once: the pre-highlight is the same shape in the same place, quieter. -void DesignSketchTool::render_solid_sel(SolidSel kind, int body, int face, - const std::vector& edge_pts, - const Vec3d& vertex_pt, - const ColorRGBA& rgb, float alpha_mul) +// An edge or vertex highlight, drawn from explicit arguments rather than from the selection +// members, so the committed selection and the hover pre-highlight cannot drift apart in how they +// look. Faces and bodies are filled by the canvas and outlined by render_face_outline. +void DesignSketchTool::render_solid_sel(SolidSel kind, const std::vector& edge_pts, + const Vec3d& vertex_pt, const ColorRGBA& rgb) { using EPT = GLModel::Geometry::EPrimitiveType; using EVL = GLModel::Geometry::EVertexLayout; // Opaque: the edge ribbon and the vertex square render with GL_BLEND OFF, so an alpha below 1 - // here would be silently ignored. Those two are quietened by a MUTED rgb from the caller - // instead; alpha_mul only reaches the face fill, which is the one layer that is blended. + // here would be silently ignored. const ColorRGBA cyan(rgb.r(), rgb.g(), rgb.b(), 1.0f); - // Whole tints the picked BODY (all its triangles, lighter alpha); Face tints just the - // picked face on that body. Both filter by `body` so other bodies stay untinted. - if ((kind == SolidSel::Face || kind == SolidSel::Whole) - && m_solid_mesh != nullptr && m_solid_tri_body != nullptr && body >= 0) { - const bool face_only = (kind == SolidSel::Face); - const indexed_triangle_set& its = m_solid_mesh->its; - GLModel::Geometry g; g.format = { EPT::Triangles, EVL::P3 }; - unsigned int base = 0; - for (size_t i = 0; i < its.indices.size(); ++i) { - if (i >= m_solid_tri_body->size() || (*m_solid_tri_body)[i] != body) continue; - if (face_only && (m_solid_tri_face == nullptr || i >= m_solid_tri_face->size() - || (*m_solid_tri_face)[i] != face)) continue; - const auto& idx = its.indices[i]; - for (int j = 0; j < 3; ++j) g.add_vertex(its.vertices[idx(j)]); - g.add_triangle(base, base + 1, base + 2); base += 3; - } - if (base > 0) { - glsafe(::glEnable(GL_DEPTH_TEST)); - glsafe(::glEnable(GL_POLYGON_OFFSET_FILL)); - glsafe(::glPolygonOffset(-2.0f, -2.0f)); - glsafe(::glEnable(GL_BLEND)); - glsafe(::glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)); - m_solid_face_model.reset(); - m_solid_face_model.init_from(std::move(g)); - m_solid_face_model.set_color(ColorRGBA(rgb.r(), rgb.g(), rgb.b(), - (face_only ? 0.40f : 0.22f) * alpha_mul)); - m_solid_face_model.render(); - glsafe(::glDisable(GL_BLEND)); - glsafe(::glDisable(GL_POLYGON_OFFSET_FILL)); - glsafe(::glDisable(GL_DEPTH_TEST)); - } - } else if (kind == SolidSel::Edge && edge_pts.size() >= 2) { + if (kind == SolidSel::Edge && edge_pts.size() >= 2) { const Camera& cam = wxGetApp().plater()->get_camera(); const Vec3d vd = cam.get_dir_forward(); const double hw = 2.0 / std::max(cam.get_zoom(), 1e-6); // ~2 px ribbon half-width @@ -4016,13 +4127,16 @@ void DesignSketchTool::render_solid_sel(SolidSel kind, int body, int face, } } -// Cyan overlay for the picked face / edge / vertex, plus the quieter pre-highlight of whatever -// the pointer is currently over. Whole-solid tint is the panel's job (set_body_highlight). +// The faces of the Feature tree's selected feature, the picked body / face / edge / vertex, and +// the quieter pre-highlight of whatever the pointer is currently over. // Called from render() while no sketch session is active. void DesignSketchTool::render_solid_highlight() { const ColorRGBA sel_cyan = design_selection_color(); + for (const FaceHighlight& h : m_hl_faces) + render_face_outline(h, false); + // The pre-highlight goes FIRST so the committed selection paints over it where the two // overlap — what you HAVE outranks what you would get. Suppressed entirely when they are the // same thing: two coats of the same colour on the same face reads as a rendering fault, and @@ -4032,17 +4146,27 @@ void DesignSketchTool::render_solid_highlight() : m_pre.kind == SolidSel::Edge ? m_pre.edge == m_sel_edge : m_pre.kind == SolidSel::Face ? m_pre.face == m_sel_face : true); - if (m_pre.kind != SolidSel::None && !pre_is_sel) - // Desaturated toward white rather than a second hue: a distinct colour would read as a - // distinct KIND of selection, when it is the same selection one moment earlier. - render_solid_sel(m_pre.kind, m_pre.body, m_pre.face, m_pre.edge_pts, m_pre.vertex_pt, - design_selection_color(), 0.45f); // hover = the same colour, quieter + if (m_pre.kind == SolidSel::Face && !pre_is_sel) + // A hovered face gets its outline alone: a promise, not a selection. + render_face_outline(cached_face_highlight(m_pre_hl, m_pre.body, { m_pre.face }), true); + else if (m_pre.kind != SolidSel::None && !pre_is_sel) + // The selection's own colour: a second hue would read as a distinct KIND of selection, + // when it is the same selection one moment earlier. + render_solid_sel(m_pre.kind, m_pre.edge_pts, m_pre.vertex_pt, sel_cyan); - render_solid_sel(m_solid_sel, m_sel_body, m_sel_face, m_sel_edge_pts, m_sel_vertex_pt, - sel_cyan, 1.0f); + // A picked body or face looks like the Feature tree's faces: the canvas fills it, this + // outlines it. + if (const std::vector> picked = picked_faces(); !picked.empty()) { + std::vector faces; + for (const auto& bf : picked) + faces.push_back(bf.second); + render_face_outline(cached_face_highlight(m_sel_hl, m_sel_body, std::move(faces)), false); + return; + } + render_solid_sel(m_solid_sel, m_sel_edge_pts, m_sel_vertex_pt, sel_cyan); if (m_solid_sel == SolidSel::Edge) for (const std::vector& pts : m_sel_edges_more_pts) - render_solid_sel(SolidSel::Edge, m_sel_body, -1, pts, Vec3d::Zero(), sel_cyan, 1.0f); + render_solid_sel(SolidSel::Edge, pts, Vec3d::Zero(), sel_cyan); } // Datum/reference planes (Plane feature) have no solid; draw each as a translucent indigo @@ -9133,7 +9257,7 @@ void DesignSketchTool::render(GLCanvas3D& canvas) } // Nothing else to draw when no live sketch session is active — except the solid - // face/edge highlight overlay (whole-solid tint is handled by set_body_highlight). + // selection and the Feature tree's faces (render_solid_highlight). if (!m_active) { render_datum_planes(); render_mate_connectors(); diff --git a/src/slic3r/GUI/CAD/DesignSketchTool.hpp b/src/slic3r/GUI/CAD/DesignSketchTool.hpp index a310a3add1..388b5b6363 100644 --- a/src/slic3r/GUI/CAD/DesignSketchTool.hpp +++ b/src/slic3r/GUI/CAD/DesignSketchTool.hpp @@ -215,6 +215,12 @@ public: // Select a whole body by index (from the Parts list) — Whole-level highlight, no face/edge. // body < 0 or out of range clears the selection. void select_body(int body); + // Outline these (body, face id) faces as selected: the faces the Feature tree's selected + // feature made. A body whose shape changes later drops out, its face ids being stale. The + // canvas fills them (DesignCanvas::rebuild_bodies). + void set_highlight_faces(const std::vector>& faces); + // Every face drawn as selected, sorted: the committed pick's and the still-valid ones above. + std::vector> selected_faces() const; // Move-body gizmo (M5): translate a whole body with three world-axis drag arrows // (X red / Y green / Z blue) anchored at the body centroid. Display-only — the host @@ -1219,9 +1225,13 @@ private: // whose shape changed (keyed by the TShape), since set_solid_pick runs on every recompute. std::vector>> m_body_edges; std::vector m_body_edges_key; + std::vector m_body_edges_tol; // the chord tolerance they were sampled at bool m_body_edges_hidden{false}; void refresh_body_edges(); void render_body_edges(); + const void* body_key(int body) const; // the body's TShape, nullptr when there is none + void append_ribbons(GLModel::Geometry& g, int body, const std::vector>& polylines, + const Vec3d& vd, const Vec3d& pull, double hw) const; bool body_pickable(int b) const; // false when the body is explicitly hidden SolidSel m_solid_sel{SolidSel::None}; int m_sel_body{-1}; // which body the face/edge selection is on @@ -1264,10 +1274,28 @@ private: bool m_right_consumed{false}; // last RightDown was a gesture terminator, not a menu bool m_escalate_repick{true}; // re-picking the same sub-element takes the whole body void render_solid_highlight(); - // The shared body of the above: one highlight from explicit arguments, so the committed - // selection and the hover pre-highlight cannot drift apart in how they look. - void render_solid_sel(SolidSel kind, int body, int face, const std::vector& edge_pts, - const Vec3d& vertex_pt, const ColorRGBA& rgb, float alpha_mul); + // The above's edge and vertex highlight, from explicit arguments, so the committed selection + // and the hover pre-highlight cannot drift apart in how they look. + void render_solid_sel(SolidSel kind, const std::vector& edge_pts, const Vec3d& vertex_pt, + const ColorRGBA& rgb); + // A set of selected faces of one body, with their edges sampled once, keyed by the body's + // TShape so a recompute that rebuilt the body retires it. + struct FaceHighlight { + int body{-1}; + std::vector faces; // sorted + const void* key{nullptr}; + std::vector> edges; // in the body's shape coordinates + }; + FaceHighlight make_face_highlight(int body, std::vector faces) const; + // The faces the committed pick names: the one face of a face pick, every face of a picked + // body. Empty for an edge or vertex pick. + std::vector> picked_faces() const; + // `cache`, rebuilt only when it no longer holds these faces of this body's current shape. + const FaceHighlight& cached_face_highlight(FaceHighlight& cache, int body, std::vector faces) const; + void render_face_outline(const FaceHighlight& h, bool quiet); + std::vector m_hl_faces; // set_highlight_faces, one entry per body + FaceHighlight m_sel_hl; // the committed Face/Whole pick + FaceHighlight m_pre_hl; // the face under the pointer void render_datum_planes(); // translucent rectangles for datum/reference planes void render_view_helpers(); // world origin planes + axis triad (P / A toggles) bool m_show_planes{false}; @@ -1284,7 +1312,6 @@ private: std::vector> m_mate_links; GLModel m_mc_stroke_model; GLModel m_mc_fill_model; // the face treatment's shaded facets - GLModel m_solid_face_model; GLModel m_solid_edge_model; GLModel m_body_edges_model; GLModel m_solid_vertex_model; diff --git a/tests/libslic3r/test_caddocument.cpp b/tests/libslic3r/test_caddocument.cpp index 56f2cfb0b3..0115111f7c 100644 --- a/tests/libslic3r/test_caddocument.cpp +++ b/tests/libslic3r/test_caddocument.cpp @@ -46,6 +46,7 @@ #include #include #include +#include #include #include #include @@ -9083,3 +9084,167 @@ TEST_CASE("revolve about a line of the sketch", "[CadDocument][revolve]") CHECK(loaded.features[1].revolve_angle == Approx(270.)); } } + +// faces_made_by is what the Design tab highlights when a feature row is selected: the faces of +// the finished model that this feature made, not the whole body it belongs to. +static GeomAbs_SurfaceType made_face_type(const CadDocument& doc, const std::pair& bf) +{ + return BRepAdaptor_Surface(GeometryEngine::face_by_index(doc.bodies[bf.first].shape, bf.second)).GetType(); +} + +TEST_CASE("A fillet owns its round face, and the extrude keeps the faces it trimmed", "[CadDocument][highlight]") +{ + CadDocument doc; + const int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Sketch"); + const int ex = doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude"); + REQUIRE(doc.recompute()); + const int fi = doc.add_fillet(2.0, 0, "Fillet"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 1); + REQUIRE(GeometryEngine::faces_of(doc.bodies[0].shape).size() == 7); + + const auto fillet = doc.faces_made_by(fi); + REQUIRE(fillet.size() == 1); + CHECK(fillet[0].first == 0); + CHECK(made_face_type(doc, fillet[0]) == GeomAbs_Cylinder); + + const auto extrude = doc.faces_made_by(ex); + CHECK(extrude.size() == 6); + for (const auto& bf : extrude) + CHECK(made_face_type(doc, bf) == GeomAbs_Plane); + + CHECK(doc.faces_made_by(sk).empty()); // a sketch leaves no face behind + + // A hidden feature made nothing in the model on screen. + REQUIRE(doc.set_feature_enabled(fi, false)); + CHECK(doc.faces_made_by(fi).empty()); +} + +TEST_CASE("A chamfer owns its bevel", "[CadDocument][highlight]") +{ + CadDocument doc; + const int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Sketch"); + const int ex = doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude"); + REQUIRE(doc.recompute()); + const int ch = doc.add_chamfer(2.0, 0, "Chamfer"); + REQUIRE(doc.recompute()); + REQUIRE(GeometryEngine::faces_of(doc.bodies[0].shape).size() == 7); + + const auto bevel = doc.faces_made_by(ch); + REQUIRE(bevel.size() == 1); + CHECK(made_face_type(doc, bevel[0]) == GeomAbs_Plane); + const auto extrude = doc.faces_made_by(ex); + CHECK(extrude.size() == 6); + CHECK(std::find(extrude.begin(), extrude.end(), bevel[0]) == extrude.end()); +} + +TEST_CASE("A boss owns its wall and cap, not the face it stands on", "[CadDocument][highlight]") +{ + CadDocument doc; + const int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 30, 20, 10, "Sketch"); + const int ex = doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude"); + REQUIRE(doc.recompute()); + SketchPlane top = SketchPlane::XY(); + top.origin = Vec3d(0, 0, 10); + const int sk2 = doc.add_sketch_entities({circle_entity({0, 0}, 5.0)}, top, "Sketch2"); + const int boss = doc.add_extrude(sk2, 8.0, false, BooleanMode::Add, "Boss"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 1); + + const auto made = doc.faces_made_by(boss); + REQUIRE_FALSE(made.empty()); + int walls = 0, caps = 0; + for (const auto& bf : made) { + const GeomAbs_SurfaceType t = made_face_type(doc, bf); + walls += t == GeomAbs_Cylinder; + // The cap is the plane at the boss's top, not the box's top face it stands on. + if (t == GeomAbs_Plane) { + ++caps; + Bnd_Box bb; + BRepBndLib::Add(GeometryEngine::face_by_index(doc.bodies[0].shape, bf.second), bb); + double x0, y0, z0, x1, y1, z1; + bb.Get(x0, y0, z0, x1, y1, z1); + CHECK(z0 > 17.9); + } + } + CHECK(walls >= 1); + CHECK(caps == 1); + CHECK(doc.faces_made_by(ex).size() == 6); // the box keeps its top face, hole and all +} + +TEST_CASE("A body stacked on another still owns the face they share", "[CadDocument][highlight]") +{ + CadDocument doc; + const int sk0 = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Sketch0"); + doc.add_extrude(sk0, 10.0, false, BooleanMode::New, "Base"); + SketchPlane top = SketchPlane::XY(); + top.origin = Vec3d(0, 0, 10); + const int sk1 = doc.add_sketch(SketchShape::Rectangle, top, 10, 10, 5, "Sketch1"); + const int ex1 = doc.add_extrude(sk1, 5.0, false, BooleanMode::New, "Block"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 2); + + // The block's bottom lies in the base's top face, facing the other way: it is the block's. + const auto made = doc.faces_made_by(ex1); + CHECK(made.size() == 6); + for (const auto& bf : made) + CHECK(bf.first == 1); +} + +TEST_CASE("A Boolean union, which makes no face of its own, answers with the body it changed", "[CadDocument][highlight]") +{ + CadDocument doc; + const int sk0 = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Sketch0"); + doc.add_extrude(sk0, 10.0, false, BooleanMode::New, "Box0"); + SketchPlane beside = SketchPlane::XY(); + beside.origin = Vec3d(10, 5, 0); + const int sk1 = doc.add_sketch(SketchShape::Rectangle, beside, 20, 20, 10, "Sketch1"); + doc.add_extrude(sk1, 10.0, false, BooleanMode::New, "Box1"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 2); + const int fuse = doc.add_boolean(BooleanMode::Add, 0, 1, false, 0.0, -1, -1, "Fuse"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 1); + + const auto made = doc.faces_made_by(fuse); + CHECK(made.size() == GeometryEngine::faces_of(doc.bodies[0].shape).size()); +} + +TEST_CASE("A Boolean subtract owns the walls its tool cut", "[CadDocument][highlight]") +{ + CadDocument doc; + const int sk0 = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Sketch0"); + doc.add_extrude(sk0, 10.0, false, BooleanMode::New, "Box0"); + SketchPlane beside = SketchPlane::XY(); + beside.origin = Vec3d(10, 5, 0); + const int sk1 = doc.add_sketch(SketchShape::Rectangle, beside, 20, 20, 10, "Sketch1"); + doc.add_extrude(sk1, 10.0, false, BooleanMode::New, "Box1"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 2); + const int cut = doc.add_boolean(BooleanMode::Cut, 0, 1, false, 0.0, -1, -1, "Cut"); + REQUIRE(doc.recompute()); + REQUIRE(doc.bodies.size() == 1); + + // The tool notches a corner out of the box: the two walls of the notch, the tool's faces + // turned inside out, are the subtract's; what is left of the box's own faces is not. + const auto made = doc.faces_made_by(cut); + CHECK(made.size() == 2); + for (const auto& bf : made) + CHECK(made_face_type(doc, bf) == GeomAbs_Plane); +} + +TEST_CASE("A hole owns its bore", "[CadDocument][highlight]") +{ + CadDocument doc; + const int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Sketch"); + const int ex = doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude"); + REQUIRE(doc.recompute()); + const int hole = doc.add_hole(6.0, 20.0, true, 0.0, 0.0, SketchPlane::XY(), "Hole"); + REQUIRE(doc.recompute()); + + const auto bore = doc.faces_made_by(hole); + REQUIRE_FALSE(bore.empty()); + for (const auto& bf : bore) + CHECK(made_face_type(doc, bf) == GeomAbs_Cylinder); + CHECK(doc.faces_made_by(ex).size() == 6); +}