mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-22 14:50:59 +00:00
feat(imex): plate ghost renderer + per-plate filament map
Replaces the plater-icon popover with colored transparent ghost copies
of primary-head instances on the plate, one per secondary active head
under its Copy/Mirror role transform. Left-click on a ghost opens a
compact filament picker popover for the ghost's head (MMU lane override).
Ghosts track the primary through drag/rotate/scale/mirror and invalidate
on mode or pem changes.
Key pieces:
IMEXHelpers -- imex_head_transform (Primary/Copy/Mirror), shared role
parser, per-head filament resolution with X-axis Mirror anchor.
PartPlate -- ghost state, volume rebuild on mode/map/object mutation,
primary_origin plumbed for Mirror reflection across the primary-row
gantry plane.
GLCanvas3D -- ghost rendering with per-head filament color and
translucent blending; picking routed via volume composite id.
Plater -- ghost click + tooltip; plater icon left-click always cycles.
IMEXFilamentPickerPopover -- BitmapComboBox row for one secondary head,
writes imex_head_filament_map on selection.
bbs_3mf -- round-trip the per-plate imex_head_filament_map option.
PrintConfig -- add imex_head_filament_map as a plate option.
MMU/AFC routing for parallel modes relies on the printer profile's
physical_extruder_map (see prior commit for authoring format). Primary-
row heads and their per-plate filament overrides are resolved through
that map, so PA and temperature emission address the correct physical
extruder when multiple logical slots share one carriage.
Tests: IMEXHelpers coverage for Primary/Copy/Mirror transforms
including a 2x2 off-row regression guard for the X-axis reflection fix.
This commit is contained in:
1 parent
0530d6c4da
commit
77c32a2e15
22 files changed
+1458
-28
No files matched your search
@@ -2169,11 +2169,19 @@ void GLCanvas3D::render(bool only_init)
|
||||
#endif
|
||||
}
|
||||
|
||||
// Suppress the regular object-name/toolbar tooltip while hovering an IMEX
|
||||
// ghost; the swatch overlay below stands in for it.
|
||||
if (m_hover_ghost_head >= 0)
|
||||
tooltip.clear();
|
||||
|
||||
set_tooltip(tooltip);
|
||||
|
||||
if (m_tooltip_enabled)
|
||||
m_tooltip.render(m_mouse.position, *this);
|
||||
|
||||
// IMEX ghost hover overlay: layered on top of the normal tooltip pass.
|
||||
_render_imex_ghost_tooltip();
|
||||
|
||||
wxGetApp().plater()->get_mouse3d_controller().render_settings_dialog(*this);
|
||||
|
||||
if (m_canvas_type != ECanvasType::CanvasAssembleView) {
|
||||
@@ -4460,6 +4468,8 @@ void GLCanvas3D::on_mouse(wxMouseEvent& evt)
|
||||
TransformationType trafo_type;
|
||||
trafo_type.set_relative();
|
||||
m_selection.translate(cur_pos - m_mouse.drag.start_position_3D, trafo_type);
|
||||
// Ghost transforms refresh from _render_imex_ghosts via the live GLVolume
|
||||
// lookup, so no explicit update call is needed here.
|
||||
if (current_printer_technology() == ptFFF && (fff_print()->config().print_sequence == PrintSequence::ByObject))
|
||||
update_sequential_clearance();
|
||||
// BBS
|
||||
@@ -4617,6 +4627,13 @@ void GLCanvas3D::on_mouse(wxMouseEvent& evt)
|
||||
|
||||
m_rectangle_selection.stop_dragging();
|
||||
}
|
||||
else if (evt.LeftUp() && !m_mouse.dragging && m_hover_ghost_head >= 0) {
|
||||
// IMEX ghost click: dispatch to Plater (filament picker). The else-if chain
|
||||
// already prevents deselect/plate-select from firing on the same event; we
|
||||
// fall through to mouse_up_cleanup() below so mouse capture and drag state
|
||||
// get reset like every other branch in this chain.
|
||||
wxGetApp().plater()->on_imex_ghost_click(m_hover_ghost_head);
|
||||
}
|
||||
else if (evt.LeftUp() && !m_mouse.ignore_left_up && !m_mouse.dragging && m_hover_volume_idxs.empty() && m_hover_plate_idxs.empty() && !is_layers_editing_enabled()) {
|
||||
// deselect and propagate event through callback
|
||||
if (!evt.ShiftDown() && (!any_gizmo_active || !evt.CmdDown()) && m_picking_enabled)
|
||||
@@ -4956,6 +4973,10 @@ void GLCanvas3D::do_move(const std::string& snapshot_type)
|
||||
|
||||
reset_sequential_print_clearance();
|
||||
|
||||
// IMEX: selection commit may have moved/added/removed objects — ghost cache key
|
||||
// doesn't encode per-instance transforms, so force a full rebuild on next render.
|
||||
wxGetApp().plater()->get_partplate_list().invalidate_all_imex_ghosts();
|
||||
|
||||
m_dirty = true;
|
||||
}
|
||||
|
||||
@@ -5058,6 +5079,9 @@ void GLCanvas3D::do_rotate(const std::string& snapshot_type)
|
||||
if (!done.empty())
|
||||
post_event(SimpleEvent(EVT_GLCANVAS_INSTANCE_ROTATED));
|
||||
|
||||
// IMEX: rotate changes per-instance transforms without touching the ghost cache key.
|
||||
wxGetApp().plater()->get_partplate_list().invalidate_all_imex_ghosts();
|
||||
|
||||
m_dirty = true;
|
||||
}
|
||||
|
||||
@@ -5150,6 +5174,9 @@ void GLCanvas3D::do_scale(const std::string& snapshot_type)
|
||||
if (!done.empty())
|
||||
post_event(SimpleEvent(EVT_GLCANVAS_INSTANCE_SCALED));
|
||||
|
||||
// IMEX: scale changes per-instance transforms without touching the ghost cache key.
|
||||
wxGetApp().plater()->get_partplate_list().invalidate_all_imex_ghosts();
|
||||
|
||||
m_dirty = true;
|
||||
}
|
||||
|
||||
@@ -5261,6 +5288,9 @@ void GLCanvas3D::do_mirror(const std::string& snapshot_type)
|
||||
|
||||
post_event(SimpleEvent(EVT_GLCANVAS_SCHEDULE_BACKGROUND_PROCESS));
|
||||
|
||||
// IMEX: mirror changes per-instance transforms without touching the ghost cache key.
|
||||
wxGetApp().plater()->get_partplate_list().invalidate_all_imex_ghosts();
|
||||
|
||||
m_dirty = true;
|
||||
}
|
||||
|
||||
@@ -7164,6 +7194,11 @@ void GLCanvas3D::_picking_pass()
|
||||
|
||||
_update_volumes_hover_state();
|
||||
|
||||
// IMEX ghost picking runs unconditionally after the normal pass: ghosts visually
|
||||
// occlude the main volumes, so their tooltip/click handling must fire even when a
|
||||
// regular volume hit also occurred underneath.
|
||||
_picking_pass_imex_ghosts();
|
||||
|
||||
#if ENABLE_RAYCAST_PICKING_DEBUG
|
||||
ImGuiWrapper& imgui = *wxGetApp().imgui();
|
||||
imgui.begin(std::string("Hit result"), ImGuiWindowFlags_AlwaysAutoResize);
|
||||
@@ -7264,6 +7299,149 @@ void GLCanvas3D::_picking_pass()
|
||||
#endif // ENABLE_RAYCAST_PICKING_DEBUG
|
||||
}
|
||||
|
||||
void GLCanvas3D::_picking_pass_imex_ghosts()
|
||||
{
|
||||
m_hover_ghost_head = -1;
|
||||
m_hover_ghost_plate = -1;
|
||||
|
||||
if (!m_picking_enabled || m_mouse.dragging || m_mouse.position == Vec2d(DBL_MAX, DBL_MAX) || m_gizmos.is_dragging())
|
||||
return;
|
||||
|
||||
// Build a world-space ray from the mouse: mouse_ray(pos) returns the near/far
|
||||
// world-space points, so direction is b - a.
|
||||
const Linef3 ray = mouse_ray(Point(static_cast<coord_t>(m_mouse.position.x()),
|
||||
static_cast<coord_t>(m_mouse.position.y())));
|
||||
const Vec3d ray_origin = ray.a;
|
||||
const Vec3d ray_dir = ray.b - ray.a;
|
||||
if (ray_dir.squaredNorm() == 0.0)
|
||||
return;
|
||||
|
||||
// Standard slab ray-vs-AABB test. Accept a hit when the nearest plane entry is
|
||||
// closer than the farthest plane exit and the exit is in front of the origin.
|
||||
auto ray_hits_bbox = [](const Vec3d& o, const Vec3d& d, const BoundingBoxf3& bb) -> bool {
|
||||
double tmin = -std::numeric_limits<double>::infinity();
|
||||
double tmax = std::numeric_limits<double>::infinity();
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
if (std::abs(d[i]) < 1e-12) {
|
||||
if (o[i] < bb.min[i] || o[i] > bb.max[i])
|
||||
return false;
|
||||
}
|
||||
else {
|
||||
double t1 = (bb.min[i] - o[i]) / d[i];
|
||||
double t2 = (bb.max[i] - o[i]) / d[i];
|
||||
if (t1 > t2) std::swap(t1, t2);
|
||||
tmin = std::max(tmin, t1);
|
||||
tmax = std::min(tmax, t2);
|
||||
if (tmin > tmax) return false;
|
||||
}
|
||||
}
|
||||
return tmax >= 0.0;
|
||||
};
|
||||
|
||||
PartPlateList& ppl = wxGetApp().plater()->get_partplate_list();
|
||||
for (int pi = 0; pi < ppl.get_plate_count(); ++pi) {
|
||||
PartPlate* plate = ppl.get_plate(pi);
|
||||
if (!plate) continue;
|
||||
const auto& ghosts = plate->get_imex_ghost_volumes();
|
||||
if (ghosts.empty()) continue;
|
||||
for (const auto& g : ghosts) {
|
||||
if (!g || !g->is_active || !g->picking) continue;
|
||||
const BoundingBoxf3 bbox = g->transformed_bounding_box();
|
||||
if (ray_hits_bbox(ray_origin, ray_dir, bbox)) {
|
||||
m_hover_ghost_head = PartPlate::imex_ghost_head_from_composite_id(g->composite_id.object_id);
|
||||
m_hover_ghost_plate = pi;
|
||||
return; // first hit wins
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void GLCanvas3D::_render_imex_ghosts()
|
||||
{
|
||||
// Called from inside _render_objects' Transparent branch while the gouraud
|
||||
// shader is bound and globals (z_far/z_near/z_range/clipping_plane) are set.
|
||||
// GLVolume::render() only binds its mesh, so we must set the per-volume
|
||||
// matrices AND uniform_color that GLVolumeCollection::render would normally
|
||||
// set; otherwise ghosts pick up whatever the last main volume left behind.
|
||||
GLShaderProgram* shader = wxGetApp().get_current_shader();
|
||||
if (shader == nullptr)
|
||||
return;
|
||||
|
||||
// Primary-volume live transform lookup: during a gizmo drag the GLVolume's
|
||||
// instance_transformation is the source of truth (the ModelInstance matrix
|
||||
// only catches up on mouse-up). Returning it from here makes ghosts track
|
||||
// the drag every frame instead of snapping when the user releases.
|
||||
auto primary_live_xf = [this](int obj_idx, int inst_idx) -> std::optional<Transform3d> {
|
||||
for (const GLVolume* v : m_volumes.volumes) {
|
||||
if (!v) continue;
|
||||
if (v->composite_id.object_id == obj_idx &&
|
||||
v->composite_id.instance_id == inst_idx)
|
||||
return v->get_instance_transformation().get_matrix();
|
||||
}
|
||||
return std::nullopt;
|
||||
};
|
||||
|
||||
const Camera& camera = wxGetApp().plater()->get_camera();
|
||||
const Transform3d& view_matrix = camera.get_view_matrix();
|
||||
shader->set_uniform("projection_matrix", camera.get_projection_matrix());
|
||||
// Ghosts live outside the build volume check; disable the partly-inside path.
|
||||
shader->set_uniform("print_volume.type", -1);
|
||||
shader->set_uniform("slope.actived", false);
|
||||
|
||||
PartPlateList& ppl = wxGetApp().plater()->get_partplate_list();
|
||||
for (int pi = 0; pi < ppl.get_plate_count(); ++pi) {
|
||||
PartPlate* plate = ppl.get_plate(pi);
|
||||
if (!plate) continue;
|
||||
// Refresh per-frame so ghost positions reflect the primary's live drag state.
|
||||
plate->update_imex_ghost_transforms(primary_live_xf);
|
||||
const auto& ghosts = plate->get_imex_ghost_volumes();
|
||||
if (ghosts.empty()) continue;
|
||||
for (const auto& g : ghosts) {
|
||||
if (!g || !g->is_active) continue;
|
||||
const Transform3d model_matrix = g->world_matrix();
|
||||
shader->set_uniform("volume_world_matrix", model_matrix);
|
||||
shader->set_uniform("slope.volume_world_normal_matrix",
|
||||
static_cast<Matrix3f>(model_matrix.matrix().block(0, 0, 3, 3).inverse().transpose().cast<float>()));
|
||||
shader->set_uniform("view_model_matrix", view_matrix * model_matrix);
|
||||
const Matrix3d view_normal_matrix = view_matrix.matrix().block(0, 0, 3, 3)
|
||||
* model_matrix.matrix().block(0, 0, 3, 3).inverse().transpose();
|
||||
shader->set_uniform("view_normal_matrix", view_normal_matrix);
|
||||
g->set_render_color();
|
||||
// GLModel::render() pushes its own data.color into uniform_color,
|
||||
// so we must stamp the ghost's color onto the model before render
|
||||
// or it draws black. Pattern matches 3DScene.cpp:1099.
|
||||
g->model.set_color(g->render_color);
|
||||
g->render();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void GLCanvas3D::_render_imex_ghost_tooltip()
|
||||
{
|
||||
if (m_hover_ghost_head < 0)
|
||||
return;
|
||||
|
||||
const auto t = wxGetApp().plater()->format_imex_ghost_tooltip(m_hover_ghost_head);
|
||||
|
||||
ImGuiWrapper& imgui = *wxGetApp().imgui();
|
||||
const Vec2i32 mouse = m_mouse.position.cast<int>();
|
||||
imgui.set_next_window_pos(float(mouse.x() + 16), float(mouse.y() + 16),
|
||||
ImGuiCond_Always, 0.0f, 0.0f);
|
||||
imgui.begin(std::string("##imex_ghost_tooltip"),
|
||||
ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
|
||||
ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoMove |
|
||||
ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoSavedSettings |
|
||||
ImGuiWindowFlags_NoMouseInputs);
|
||||
// 14px color swatch + label on the same row.
|
||||
const ImVec4 col(t.swatch.r(), t.swatch.g(), t.swatch.b(), t.swatch.a());
|
||||
ImGui::ColorButton("##swatch", col,
|
||||
ImGuiColorEditFlags_NoBorder | ImGuiColorEditFlags_NoTooltip,
|
||||
ImVec2(14, 14));
|
||||
ImGui::SameLine();
|
||||
imgui.text(t.label);
|
||||
imgui.end();
|
||||
}
|
||||
|
||||
void GLCanvas3D::_rectangular_selection_picking_pass()
|
||||
{
|
||||
m_gizmos.set_hover_id(-1);
|
||||
@@ -7685,6 +7863,9 @@ void GLCanvas3D::_render_objects(GLVolumeCollection::ERenderType type, bool with
|
||||
}
|
||||
},
|
||||
partly_inside_enable);
|
||||
// IMEX ghosts are transparent and share the same shader/camera state;
|
||||
// render them right after the main transparent pass while the shader is still bound.
|
||||
_render_imex_ghosts();
|
||||
if (m_canvas_type == CanvasAssembleView && m_gizmos.m_assemble_view_data->model_objects_clipper()->get_position() > 0) {
|
||||
const GLGizmosManager& gm = get_gizmos_manager();
|
||||
shader->stop_using();
|
||||
|
||||
Reference in new issue
Block a user