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:
Clifford Garwood committed 2026-04-21 14:56:22 -04:00
1 parent 0530d6c4da
commit 77c32a2e15
22 files changed
+1458 -28

No files matched your search

+181
View File
@@ -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();