diff --git a/README.md b/README.md index 408efb57b7..cf1b084e4c 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,8 @@ If you come across any of these in search results, please report them as Supports a broad range of printers: Bambu Lab, Prusa, Creality, Voron, and more. - **[Belt Printer Support](https://www.orcaslicer.com/wiki/belt_printing)** Slice for belt / conveyor (infinite-Z) printers, with belt-aware supports and a tilted-bed preview. Contributed by [Joseph Robertson (@HarrierPigeon)](https://github.com/HarrierPigeon). +- **[IDEX/IQEX Parallel Printing Support](https://www.orcaslicer.com/wiki/idex_iqex_parallel_printing)** + Print copies or mirror images of a part on every carriage of an IDEX or IQEX printer at once, with the mode chosen per plate and nozzle clearance zones shown on the bed. Contributed by [Clifford (@cgarwood82)](https://github.com/cgarwood82). - Additional features can be found in the [change notes](https://github.com/OrcaSlicer/OrcaSlicer/releases/). # Wiki diff --git a/localization/i18n/list.txt b/localization/i18n/list.txt index b1ebb54f5e..1d41680deb 100644 --- a/localization/i18n/list.txt +++ b/localization/i18n/list.txt @@ -164,6 +164,7 @@ src/slic3r/GUI/OptionsGroup.cpp src/slic3r/GUI/PrintOptionsDialog.cpp src/slic3r/GUI/SafetyOptionsDialog.cpp src/slic3r/GUI/ParamsPanel.cpp +src/slic3r/GUI/IMEXModesCtrl.cpp src/slic3r/GUI/PartPlate.cpp src/slic3r/GUI/Plater.cpp src/slic3r/GUI/Preferences.cpp @@ -212,6 +213,7 @@ src/libslic3r/ExtrusionEntity.cpp src/libslic3r/Flow.cpp src/libslic3r/Format/AMF.cpp src/libslic3r/miniz_extension.cpp +src/libslic3r/IMEXHelpers.cpp src/libslic3r/Preset.cpp src/libslic3r/Print.cpp src/libslic3r/PrintBase.cpp diff --git a/resources/images/plate_imex_copy_mode.svg b/resources/images/plate_imex_copy_mode.svg new file mode 100644 index 0000000000..b938e909a1 --- /dev/null +++ b/resources/images/plate_imex_copy_mode.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/resources/images/plate_imex_copy_mode_dark.svg b/resources/images/plate_imex_copy_mode_dark.svg new file mode 100644 index 0000000000..c87b0e0ffe --- /dev/null +++ b/resources/images/plate_imex_copy_mode_dark.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/resources/images/plate_imex_copy_mode_hover.svg b/resources/images/plate_imex_copy_mode_hover.svg new file mode 100644 index 0000000000..4238552426 --- /dev/null +++ b/resources/images/plate_imex_copy_mode_hover.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/resources/images/plate_imex_copy_mode_hover_dark.svg b/resources/images/plate_imex_copy_mode_hover_dark.svg new file mode 100644 index 0000000000..4633b747fe --- /dev/null +++ b/resources/images/plate_imex_copy_mode_hover_dark.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/resources/images/plate_imex_custom_mode.svg b/resources/images/plate_imex_custom_mode.svg new file mode 100644 index 0000000000..99bc534382 --- /dev/null +++ b/resources/images/plate_imex_custom_mode.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/resources/images/plate_imex_custom_mode_dark.svg b/resources/images/plate_imex_custom_mode_dark.svg new file mode 100644 index 0000000000..d31da3bb3a --- /dev/null +++ b/resources/images/plate_imex_custom_mode_dark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/resources/images/plate_imex_custom_mode_hover.svg b/resources/images/plate_imex_custom_mode_hover.svg new file mode 100644 index 0000000000..c0cc65a2a4 --- /dev/null +++ b/resources/images/plate_imex_custom_mode_hover.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/resources/images/plate_imex_custom_mode_hover_dark.svg b/resources/images/plate_imex_custom_mode_hover_dark.svg new file mode 100644 index 0000000000..4b5682dbb2 --- /dev/null +++ b/resources/images/plate_imex_custom_mode_hover_dark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/resources/images/plate_imex_mirror_mode.svg b/resources/images/plate_imex_mirror_mode.svg new file mode 100644 index 0000000000..92f57755b3 --- /dev/null +++ b/resources/images/plate_imex_mirror_mode.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/resources/images/plate_imex_mirror_mode_dark.svg b/resources/images/plate_imex_mirror_mode_dark.svg new file mode 100644 index 0000000000..b0ab1ee257 --- /dev/null +++ b/resources/images/plate_imex_mirror_mode_dark.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/resources/images/plate_imex_mirror_mode_hover.svg b/resources/images/plate_imex_mirror_mode_hover.svg new file mode 100644 index 0000000000..1772d2c7fb --- /dev/null +++ b/resources/images/plate_imex_mirror_mode_hover.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/resources/images/plate_imex_mirror_mode_hover_dark.svg b/resources/images/plate_imex_mirror_mode_hover_dark.svg new file mode 100644 index 0000000000..d50131ed4b --- /dev/null +++ b/resources/images/plate_imex_mirror_mode_hover_dark.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/resources/images/plate_imex_normal_mode.svg b/resources/images/plate_imex_normal_mode.svg new file mode 100644 index 0000000000..2d2324236f --- /dev/null +++ b/resources/images/plate_imex_normal_mode.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/resources/images/plate_imex_normal_mode_dark.svg b/resources/images/plate_imex_normal_mode_dark.svg new file mode 100644 index 0000000000..86d4b2ec0e --- /dev/null +++ b/resources/images/plate_imex_normal_mode_dark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/resources/images/plate_imex_normal_mode_hover.svg b/resources/images/plate_imex_normal_mode_hover.svg new file mode 100644 index 0000000000..3cdfb350fa --- /dev/null +++ b/resources/images/plate_imex_normal_mode_hover.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/resources/images/plate_imex_normal_mode_hover_dark.svg b/resources/images/plate_imex_normal_mode_hover_dark.svg new file mode 100644 index 0000000000..70b26ed81b --- /dev/null +++ b/resources/images/plate_imex_normal_mode_hover_dark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/resources/profiles/Vivedino.json b/resources/profiles/Vivedino.json index c8f6b65c3e..11b384aabb 100644 --- a/resources/profiles/Vivedino.json +++ b/resources/profiles/Vivedino.json @@ -1,6 +1,6 @@ { "name": "Vivedino", - "version": "02.04.00.02", + "version": "02.04.00.13", "force_update": "0", "description": "Vivedino configurations", "machine_model_list": [ @@ -11,6 +11,22 @@ { "name": "Troodon 2.0 - RRF", "sub_path": "machine/Troodon2RRF.json" + }, + { + "name": "Xplorer Dual Gantry", + "sub_path": "machine/XplorerDualGantry.json" + }, + { + "name": "Xplorer IDEX", + "sub_path": "machine/XplorerIDEX.json" + }, + { + "name": "Xplorer IQEX", + "sub_path": "machine/XplorerIQEX.json" + }, + { + "name": "Xplorer Single", + "sub_path": "machine/XplorerSingle.json" } ], "process_list": [ @@ -45,6 +61,86 @@ { "name": "0.28mm Extra Draft @Troodon2", "sub_path": "process/0.28mm Extra Draft @Troodon2.json" + }, + { + "name": "fdm_process_xplorer_common", + "sub_path": "process/fdm_process_xplorer_common.json" + }, + { + "name": "0.04mm Ultra Fine @Xplorer 0.4", + "sub_path": "process/0.04mm Ultra Fine @Xplorer 0.4.json" + }, + { + "name": "0.08mm Extra Fine @Xplorer 0.4", + "sub_path": "process/0.08mm Extra Fine @Xplorer 0.4.json" + }, + { + "name": "0.12mm Fine @Xplorer 0.4", + "sub_path": "process/0.12mm Fine @Xplorer 0.4.json" + }, + { + "name": "0.16mm Optimal @Xplorer 0.4", + "sub_path": "process/0.16mm Optimal @Xplorer 0.4.json" + }, + { + "name": "0.20mm Standard @Xplorer 0.4", + "sub_path": "process/0.20mm Standard @Xplorer 0.4.json" + }, + { + "name": "0.28mm Draft @Xplorer 0.4", + "sub_path": "process/0.28mm Draft @Xplorer 0.4.json" + }, + { + "name": "0.32mm Extra Draft @Xplorer 0.4", + "sub_path": "process/0.32mm Extra Draft @Xplorer 0.4.json" + }, + { + "name": "fdm_process_xplorer_common_0_6", + "sub_path": "process/fdm_process_xplorer_common_0_6.json" + }, + { + "name": "fdm_process_xplorer_common_0_8", + "sub_path": "process/fdm_process_xplorer_common_0_8.json" + }, + { + "name": "0.18mm Fine @Xplorer 0.6", + "sub_path": "process/0.18mm Fine @Xplorer 0.6.json" + }, + { + "name": "0.24mm Optimal @Xplorer 0.6", + "sub_path": "process/0.24mm Optimal @Xplorer 0.6.json" + }, + { + "name": "0.30mm Standard @Xplorer 0.6", + "sub_path": "process/0.30mm Standard @Xplorer 0.6.json" + }, + { + "name": "0.36mm Draft @Xplorer 0.6", + "sub_path": "process/0.36mm Draft @Xplorer 0.6.json" + }, + { + "name": "0.42mm Extra Draft @Xplorer 0.6", + "sub_path": "process/0.42mm Extra Draft @Xplorer 0.6.json" + }, + { + "name": "0.24mm Fine @Xplorer 0.8", + "sub_path": "process/0.24mm Fine @Xplorer 0.8.json" + }, + { + "name": "0.32mm Optimal @Xplorer 0.8", + "sub_path": "process/0.32mm Optimal @Xplorer 0.8.json" + }, + { + "name": "0.40mm Standard @Xplorer 0.8", + "sub_path": "process/0.40mm Standard @Xplorer 0.8.json" + }, + { + "name": "0.48mm Draft @Xplorer 0.8", + "sub_path": "process/0.48mm Draft @Xplorer 0.8.json" + }, + { + "name": "0.56mm Extra Draft @Xplorer 0.8", + "sub_path": "process/0.56mm Extra Draft @Xplorer 0.8.json" } ], "filament_list": [], @@ -65,9 +161,61 @@ "name": "Troodon 2.0 Klipper 0.4 nozzle", "sub_path": "machine/Troodon 2.0 Klipper 0.4 nozzle.json" }, + { + "name": "Xplorer common", + "sub_path": "machine/Xplorer common.json" + }, { "name": "Troodon 2.0 RRF 0.4 nozzle", "sub_path": "machine/Troodon 2.0 RRF 0.4 nozzle.json" + }, + { + "name": "Xplorer Dual Gantry 0.4 nozzle", + "sub_path": "machine/Xplorer Dual Gantry 0.4 nozzle.json" + }, + { + "name": "Xplorer Dual Gantry 0.6 nozzle", + "sub_path": "machine/Xplorer Dual Gantry 0.6 nozzle.json" + }, + { + "name": "Xplorer Dual Gantry 0.8 nozzle", + "sub_path": "machine/Xplorer Dual Gantry 0.8 nozzle.json" + }, + { + "name": "Xplorer IDEX 0.4 nozzle", + "sub_path": "machine/Xplorer IDEX 0.4 nozzle.json" + }, + { + "name": "Xplorer IDEX 0.6 nozzle", + "sub_path": "machine/Xplorer IDEX 0.6 nozzle.json" + }, + { + "name": "Xplorer IDEX 0.8 nozzle", + "sub_path": "machine/Xplorer IDEX 0.8 nozzle.json" + }, + { + "name": "Xplorer IQEX 0.4 nozzle", + "sub_path": "machine/Xplorer IQEX 0.4 nozzle.json" + }, + { + "name": "Xplorer IQEX 0.6 nozzle", + "sub_path": "machine/Xplorer IQEX 0.6 nozzle.json" + }, + { + "name": "Xplorer IQEX 0.8 nozzle", + "sub_path": "machine/Xplorer IQEX 0.8 nozzle.json" + }, + { + "name": "Xplorer Single 0.4 nozzle", + "sub_path": "machine/Xplorer Single 0.4 nozzle.json" + }, + { + "name": "Xplorer Single 0.6 nozzle", + "sub_path": "machine/Xplorer Single 0.6 nozzle.json" + }, + { + "name": "Xplorer Single 0.8 nozzle", + "sub_path": "machine/Xplorer Single 0.8 nozzle.json" } ] } diff --git a/resources/profiles/Vivedino/Xplorer Dual Gantry_cover.png b/resources/profiles/Vivedino/Xplorer Dual Gantry_cover.png new file mode 100644 index 0000000000..cc82af425a Binary files /dev/null and b/resources/profiles/Vivedino/Xplorer Dual Gantry_cover.png differ diff --git a/resources/profiles/Vivedino/Xplorer IDEX_cover.png b/resources/profiles/Vivedino/Xplorer IDEX_cover.png new file mode 100644 index 0000000000..2462c32015 Binary files /dev/null and b/resources/profiles/Vivedino/Xplorer IDEX_cover.png differ diff --git a/resources/profiles/Vivedino/Xplorer IQEX_cover.png b/resources/profiles/Vivedino/Xplorer IQEX_cover.png new file mode 100644 index 0000000000..89c6092e5d Binary files /dev/null and b/resources/profiles/Vivedino/Xplorer IQEX_cover.png differ diff --git a/resources/profiles/Vivedino/Xplorer Single_cover.png b/resources/profiles/Vivedino/Xplorer Single_cover.png new file mode 100644 index 0000000000..9a86cf6f9f Binary files /dev/null and b/resources/profiles/Vivedino/Xplorer Single_cover.png differ diff --git a/resources/profiles/Vivedino/Xplorer_bed_texture_dualgantry.svg b/resources/profiles/Vivedino/Xplorer_bed_texture_dualgantry.svg new file mode 100644 index 0000000000..b73c3c529f --- /dev/null +++ b/resources/profiles/Vivedino/Xplorer_bed_texture_dualgantry.svg @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/profiles/Vivedino/Xplorer_bed_texture_idex.svg b/resources/profiles/Vivedino/Xplorer_bed_texture_idex.svg new file mode 100644 index 0000000000..ed0d3182f8 --- /dev/null +++ b/resources/profiles/Vivedino/Xplorer_bed_texture_idex.svg @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/profiles/Vivedino/Xplorer_bed_texture_iqex.svg b/resources/profiles/Vivedino/Xplorer_bed_texture_iqex.svg new file mode 100644 index 0000000000..768653f848 --- /dev/null +++ b/resources/profiles/Vivedino/Xplorer_bed_texture_iqex.svg @@ -0,0 +1,154 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/profiles/Vivedino/Xplorer_buildplate_model.stl b/resources/profiles/Vivedino/Xplorer_buildplate_model.stl new file mode 100644 index 0000000000..0f4f99f052 Binary files /dev/null and b/resources/profiles/Vivedino/Xplorer_buildplate_model.stl differ diff --git a/resources/profiles/Vivedino/machine/Xplorer Dual Gantry 0.4 nozzle.json b/resources/profiles/Vivedino/machine/Xplorer Dual Gantry 0.4 nozzle.json new file mode 100644 index 0000000000..1f8ea0fd3a --- /dev/null +++ b/resources/profiles/Vivedino/machine/Xplorer Dual Gantry 0.4 nozzle.json @@ -0,0 +1,261 @@ +{ + "type": "machine", + "name": "Xplorer Dual Gantry 0.4 nozzle", + "inherits": "Xplorer common", + "from": "system", + "setting_id": "DgYk8X6L1R0MKcfg", + "instantiation": "true", + "before_layer_change_gcode": ";BEFORE_LAYER_CHANGE\n;[layer_z]\n{if layer_num != 0}\nG92 E0\n{endif}\n{if layer_num <= 0}\nG91\nG1 Z+10 F1200\nG90\n{endif}", + "change_filament_gcode": ";CHANGE_FILAMENT - Start\n;Layer#=[layer_num] - Z=[layer_z]\n{if layer_z >first_layer_height}\nG91\nG1 Z+2 F1200\nG90\n{endif}\n{if layer_num != -1 || next_extruder != 0}\n Tool{next_extruder}\n{endif}\n; <----\nG1 Z[layer_z] F1200\n{if layer_num == -1 || layer_num == 0}\n M109 S[first_layer_temperature[next_extruder]] T[next_extruder]\n{else}\n M104 S[temperature[next_extruder]] T[next_extruder]\n{endif}\n; ---->\nM83\n;Layer#=[layer_num] - Z=[layer_z]\nLOG_EXTR\n;CHANGE_FILAMENT - End", + "default_nozzle_volume_type": [ + "High Flow", + "High Flow" + ], + "default_print_profile": "0.20mm Standard @Xplorer 0.4", + "deretraction_speed": [ + "120", + "120" + ], + "extruder_colour": [ + "#FCE94F", + "#729FCF" + ], + "extruder_offset": [ + "0x0", + "0x0" + ], + "extruder_type": [ + "Direct Drive", + "Direct Drive" + ], + "extruder_variant_list": [ + "Direct Drive High Flow", + "Direct Drive High Flow" + ], + "imex_gantry_count": "2", + "imex_mode_active_tools": [ + "0:P", + "0:P,1:C", + "0:P,1:M" + ], + "imex_mode_gcodes": [ + "SET_PRINT_MODE MODE=PRIMARY", + "SET_PRINT_MODE MODE=COPY", + "SET_PRINT_MODE MODE=MIRROR" + ], + "imex_mode_names": [ + "primary", + "copy", + "mirror" + ], + "imex_tool_layout": "rear-left", + "imex_tools_per_gantry": "1", + "is_imex": "1", + "layer_change_gcode": ";AFTER_LAYER_CHANGE\n;[layer_z]\nLOG_RESUME\nLOG_EXTR", + "long_retractions_when_cut": [ + "0", + "0" + ], + "machine_max_acceleration_e": [ + "6000", + "5000", + "6000", + "5000" + ], + "machine_max_acceleration_extruding": [ + "5000", + "2500", + "5000", + "2500" + ], + "machine_max_acceleration_retracting": [ + "5000", + "5000", + "5000", + "5000" + ], + "machine_max_acceleration_travel": [ + "5000", + "2500", + "5000", + "2500" + ], + "machine_max_acceleration_x": [ + "5000", + "2500", + "5000", + "2500" + ], + "machine_max_acceleration_y": [ + "5000", + "2500", + "5000", + "2500" + ], + "machine_max_acceleration_z": [ + "100", + "50", + "100", + "50" + ], + "machine_max_jerk_e": [ + "2.5", + "1.25", + "2.5", + "1.25" + ], + "machine_max_jerk_x": [ + "5", + "2.5", + "5", + "2.5" + ], + "machine_max_jerk_y": [ + "5", + "2.5", + "5", + "2.5" + ], + "machine_max_jerk_z": [ + "0.4", + "0.2", + "0.4", + "0.2" + ], + "machine_max_junction_deviation": [ + "0", + "0", + "0", + "0" + ], + "machine_max_speed_e": [ + "120", + "60", + "120", + "60" + ], + "machine_max_speed_x": [ + "300", + "150", + "300", + "150" + ], + "machine_max_speed_y": [ + "300", + "150", + "300", + "150" + ], + "machine_max_speed_z": [ + "40", + "20", + "40", + "20" + ], + "machine_start_gcode": "M104 S{first_layer_temperature[0]} T0\nM104 S{first_layer_temperature[1]} T1\nM140 S[first_layer_bed_temperature] ; set bed temp\nM190 S[first_layer_bed_temperature] ; wait for bed temp\nM109 S{first_layer_temperature[0]} T0\nM109 S{first_layer_temperature[1]} T1\nstart_print\nG1 F5000\n{if first_layer_temperature[1] !=0 }\nTool1\nbrush_Tool1\n{endif}\n{if first_layer_temperature[0] !=0 }\nTool0\nbrush_Tool0\n{endif}\n{if first_layer_temperature[1] !=0 }\nTool1\nG1 Y280 X10.0 Z0.38 F5000.0 \nG1 Y100 X10.0 Z0.38 F1500.0 E15 \nG1 Y100 X10.4 Z0.38 F5000.0 \nG1 Y280 X10.4 Z0.38 F1500.0 E30\nG92 E0\nG1 Z2 F2000\n{endif}\nG1 F5000\n{if first_layer_temperature[0] !=0 }\nTool0\nG1 Y280 X11.0 Z0.38 F5000.0 \nG1 Y100 X11.0 Z0.38 F1500.0 E15 \nG1 Y100 X11.4 Z0.38 F5000.0 \nG1 Y280 X11.4 Z0.38 F1500.0 E30\nG92 E0\nG1 Z2 F2000\n{endif}\nG1 F5000\nM400\nSKEW_PROFILE LOAD=SKEW1\nG28 X\nG28 Y\nTool{initial_extruder}\nSTART_PRINTING_MODE\n\n", + "max_layer_height": [ + "0.32", + "0.32" + ], + "min_layer_height": [ + "0.04", + "0.04" + ], + "nozzle_diameter": [ + "0.4", + "0.4" + ], + "nozzle_type": [ + "hardened_steel", + "hardened_steel" + ], + "nozzle_volume": [ + "0", + "0" + ], + "printable_area": [ + "10x0", + "410x0", + "410x342.5", + "10x342.5" + ], + "printable_height": "401", + "printer_extruder_id": [ + "1", + "2" + ], + "printer_extruder_variant": [ + "Direct Drive High Flow", + "Direct Drive High Flow" + ], + "printer_model": "Xplorer Dual Gantry", + "printer_variant": "0.4", + "retract_before_wipe": [ + "70%", + "70%" + ], + "retract_length_toolchange": [ + "2", + "2" + ], + "retract_lift_above": [ + "0.2", + "0.2" + ], + "retract_lift_below": [ + "400", + "400" + ], + "retract_lift_enforce": [ + "All Surfaces", + "All Surfaces" + ], + "retract_restart_extra": [ + "0", + "0" + ], + "retract_restart_extra_toolchange": [ + "0", + "0" + ], + "retract_when_changing_layer": [ + "1", + "1" + ], + "retraction_distances_when_cut": [ + "18", + "18" + ], + "retraction_length": [ + "1.35", + "1.35" + ], + "retraction_minimum_travel": [ + "2", + "2" + ], + "retraction_speed": [ + "120", + "120" + ], + "travel_slope": [ + "1", + "1" + ], + "wipe": [ + "1", + "1" + ], + "wipe_distance": [ + "1", + "1" + ], + "z_hop": [ + "1", + "1" + ], + "z_hop_types": [ + "Normal Lift", + "Normal Lift" + ] +} diff --git a/resources/profiles/Vivedino/machine/Xplorer Dual Gantry 0.6 nozzle.json b/resources/profiles/Vivedino/machine/Xplorer Dual Gantry 0.6 nozzle.json new file mode 100644 index 0000000000..39e9ecead3 --- /dev/null +++ b/resources/profiles/Vivedino/machine/Xplorer Dual Gantry 0.6 nozzle.json @@ -0,0 +1,261 @@ +{ + "type": "machine", + "name": "Xplorer Dual Gantry 0.6 nozzle", + "inherits": "Xplorer common", + "from": "system", + "setting_id": "M1ZyjMxGz0TpRDcv", + "instantiation": "true", + "before_layer_change_gcode": ";BEFORE_LAYER_CHANGE\n;[layer_z]\n{if layer_num != 0}\nG92 E0\n{endif}\n{if layer_num <= 0}\nG91\nG1 Z+10 F1200\nG90\n{endif}", + "change_filament_gcode": ";CHANGE_FILAMENT - Start\n;Layer#=[layer_num] - Z=[layer_z]\n{if layer_z >first_layer_height}\nG91\nG1 Z+2 F1200\nG90\n{endif}\n{if layer_num != -1 || next_extruder != 0}\n Tool{next_extruder}\n{endif}\n; <----\nG1 Z[layer_z] F1200\n{if layer_num == -1 || layer_num == 0}\n M109 S[first_layer_temperature[next_extruder]] T[next_extruder]\n{else}\n M104 S[temperature[next_extruder]] T[next_extruder]\n{endif}\n; ---->\nM83\n;Layer#=[layer_num] - Z=[layer_z]\nLOG_EXTR\n;CHANGE_FILAMENT - End", + "default_nozzle_volume_type": [ + "High Flow", + "High Flow" + ], + "default_print_profile": "0.30mm Standard @Xplorer 0.6", + "deretraction_speed": [ + "120", + "120" + ], + "extruder_colour": [ + "#FCE94F", + "#729FCF" + ], + "extruder_offset": [ + "0x0", + "0x0" + ], + "extruder_type": [ + "Direct Drive", + "Direct Drive" + ], + "extruder_variant_list": [ + "Direct Drive High Flow", + "Direct Drive High Flow" + ], + "imex_gantry_count": "2", + "imex_mode_active_tools": [ + "0:P", + "0:P,1:C", + "0:P,1:M" + ], + "imex_mode_gcodes": [ + "SET_PRINT_MODE MODE=PRIMARY", + "SET_PRINT_MODE MODE=COPY", + "SET_PRINT_MODE MODE=MIRROR" + ], + "imex_mode_names": [ + "primary", + "copy", + "mirror" + ], + "imex_tool_layout": "rear-left", + "imex_tools_per_gantry": "1", + "is_imex": "1", + "layer_change_gcode": ";AFTER_LAYER_CHANGE\n;[layer_z]\nLOG_RESUME\nLOG_EXTR", + "long_retractions_when_cut": [ + "0", + "0" + ], + "machine_max_acceleration_e": [ + "6000", + "5000", + "6000", + "5000" + ], + "machine_max_acceleration_extruding": [ + "5000", + "2500", + "5000", + "2500" + ], + "machine_max_acceleration_retracting": [ + "5000", + "5000", + "5000", + "5000" + ], + "machine_max_acceleration_travel": [ + "5000", + "2500", + "5000", + "2500" + ], + "machine_max_acceleration_x": [ + "5000", + "2500", + "5000", + "2500" + ], + "machine_max_acceleration_y": [ + "5000", + "2500", + "5000", + "2500" + ], + "machine_max_acceleration_z": [ + "100", + "50", + "100", + "50" + ], + "machine_max_jerk_e": [ + "2.5", + "1.25", + "2.5", + "1.25" + ], + "machine_max_jerk_x": [ + "5", + "2.5", + "5", + "2.5" + ], + "machine_max_jerk_y": [ + "5", + "2.5", + "5", + "2.5" + ], + "machine_max_jerk_z": [ + "0.4", + "0.2", + "0.4", + "0.2" + ], + "machine_max_junction_deviation": [ + "0", + "0", + "0", + "0" + ], + "machine_max_speed_e": [ + "120", + "60", + "120", + "60" + ], + "machine_max_speed_x": [ + "300", + "150", + "300", + "150" + ], + "machine_max_speed_y": [ + "300", + "150", + "300", + "150" + ], + "machine_max_speed_z": [ + "40", + "20", + "40", + "20" + ], + "machine_start_gcode": "M104 S{first_layer_temperature[0]} T0\nM104 S{first_layer_temperature[1]} T1\nM140 S[first_layer_bed_temperature] ; set bed temp\nM190 S[first_layer_bed_temperature] ; wait for bed temp\nM109 S{first_layer_temperature[0]} T0\nM109 S{first_layer_temperature[1]} T1\nstart_print\nG1 F5000\n{if first_layer_temperature[1] !=0 }\nTool1\nbrush_Tool1\n{endif}\n{if first_layer_temperature[0] !=0 }\nTool0\nbrush_Tool0\n{endif}\n{if first_layer_temperature[1] !=0 }\nTool1\nG1 Y280 X10.0 Z0.38 F5000.0 \nG1 Y100 X10.0 Z0.38 F1500.0 E15 \nG1 Y100 X10.4 Z0.38 F5000.0 \nG1 Y280 X10.4 Z0.38 F1500.0 E30\nG92 E0\nG1 Z2 F2000\n{endif}\nG1 F5000\n{if first_layer_temperature[0] !=0 }\nTool0\nG1 Y280 X11.0 Z0.38 F5000.0 \nG1 Y100 X11.0 Z0.38 F1500.0 E15 \nG1 Y100 X11.4 Z0.38 F5000.0 \nG1 Y280 X11.4 Z0.38 F1500.0 E30\nG92 E0\nG1 Z2 F2000\n{endif}\nG1 F5000\nM400\nSKEW_PROFILE LOAD=SKEW1\nG28 X\nG28 Y\nTool{initial_extruder}\nSTART_PRINTING_MODE\n\n", + "max_layer_height": [ + "0.45", + "0.45" + ], + "min_layer_height": [ + "0.12", + "0.12" + ], + "nozzle_diameter": [ + "0.6", + "0.6" + ], + "nozzle_type": [ + "hardened_steel", + "hardened_steel" + ], + "nozzle_volume": [ + "0", + "0" + ], + "printable_area": [ + "10x0", + "410x0", + "410x342.5", + "10x342.5" + ], + "printable_height": "401", + "printer_extruder_id": [ + "1", + "2" + ], + "printer_extruder_variant": [ + "Direct Drive High Flow", + "Direct Drive High Flow" + ], + "printer_model": "Xplorer Dual Gantry", + "printer_variant": "0.6", + "retract_before_wipe": [ + "70%", + "70%" + ], + "retract_length_toolchange": [ + "2", + "2" + ], + "retract_lift_above": [ + "0.2", + "0.2" + ], + "retract_lift_below": [ + "400", + "400" + ], + "retract_lift_enforce": [ + "All Surfaces", + "All Surfaces" + ], + "retract_restart_extra": [ + "0", + "0" + ], + "retract_restart_extra_toolchange": [ + "0", + "0" + ], + "retract_when_changing_layer": [ + "1", + "1" + ], + "retraction_distances_when_cut": [ + "18", + "18" + ], + "retraction_length": [ + "1.35", + "1.35" + ], + "retraction_minimum_travel": [ + "2", + "2" + ], + "retraction_speed": [ + "120", + "120" + ], + "travel_slope": [ + "1", + "1" + ], + "wipe": [ + "1", + "1" + ], + "wipe_distance": [ + "1", + "1" + ], + "z_hop": [ + "1", + "1" + ], + "z_hop_types": [ + "Normal Lift", + "Normal Lift" + ] +} diff --git a/resources/profiles/Vivedino/machine/Xplorer Dual Gantry 0.8 nozzle.json b/resources/profiles/Vivedino/machine/Xplorer Dual Gantry 0.8 nozzle.json new file mode 100644 index 0000000000..4b6e2ed54d --- /dev/null +++ b/resources/profiles/Vivedino/machine/Xplorer Dual Gantry 0.8 nozzle.json @@ -0,0 +1,261 @@ +{ + "type": "machine", + "name": "Xplorer Dual Gantry 0.8 nozzle", + "inherits": "Xplorer common", + "from": "system", + "setting_id": "fWRcMcf9H5NP6zrX", + "instantiation": "true", + "before_layer_change_gcode": ";BEFORE_LAYER_CHANGE\n;[layer_z]\n{if layer_num != 0}\nG92 E0\n{endif}\n{if layer_num <= 0}\nG91\nG1 Z+10 F1200\nG90\n{endif}", + "change_filament_gcode": ";CHANGE_FILAMENT - Start\n;Layer#=[layer_num] - Z=[layer_z]\n{if layer_z >first_layer_height}\nG91\nG1 Z+2 F1200\nG90\n{endif}\n{if layer_num != -1 || next_extruder != 0}\n Tool{next_extruder}\n{endif}\n; <----\nG1 Z[layer_z] F1200\n{if layer_num == -1 || layer_num == 0}\n M109 S[first_layer_temperature[next_extruder]] T[next_extruder]\n{else}\n M104 S[temperature[next_extruder]] T[next_extruder]\n{endif}\n; ---->\nM83\n;Layer#=[layer_num] - Z=[layer_z]\nLOG_EXTR\n;CHANGE_FILAMENT - End", + "default_nozzle_volume_type": [ + "High Flow", + "High Flow" + ], + "default_print_profile": "0.40mm Standard @Xplorer 0.8", + "deretraction_speed": [ + "120", + "120" + ], + "extruder_colour": [ + "#FCE94F", + "#729FCF" + ], + "extruder_offset": [ + "0x0", + "0x0" + ], + "extruder_type": [ + "Direct Drive", + "Direct Drive" + ], + "extruder_variant_list": [ + "Direct Drive High Flow", + "Direct Drive High Flow" + ], + "imex_gantry_count": "2", + "imex_mode_active_tools": [ + "0:P", + "0:P,1:C", + "0:P,1:M" + ], + "imex_mode_gcodes": [ + "SET_PRINT_MODE MODE=PRIMARY", + "SET_PRINT_MODE MODE=COPY", + "SET_PRINT_MODE MODE=MIRROR" + ], + "imex_mode_names": [ + "primary", + "copy", + "mirror" + ], + "imex_tool_layout": "rear-left", + "imex_tools_per_gantry": "1", + "is_imex": "1", + "layer_change_gcode": ";AFTER_LAYER_CHANGE\n;[layer_z]\nLOG_RESUME\nLOG_EXTR", + "long_retractions_when_cut": [ + "0", + "0" + ], + "machine_max_acceleration_e": [ + "6000", + "5000", + "6000", + "5000" + ], + "machine_max_acceleration_extruding": [ + "5000", + "2500", + "5000", + "2500" + ], + "machine_max_acceleration_retracting": [ + "5000", + "5000", + "5000", + "5000" + ], + "machine_max_acceleration_travel": [ + "5000", + "2500", + "5000", + "2500" + ], + "machine_max_acceleration_x": [ + "5000", + "2500", + "5000", + "2500" + ], + "machine_max_acceleration_y": [ + "5000", + "2500", + "5000", + "2500" + ], + "machine_max_acceleration_z": [ + "100", + "50", + "100", + "50" + ], + "machine_max_jerk_e": [ + "2.5", + "1.25", + "2.5", + "1.25" + ], + "machine_max_jerk_x": [ + "5", + "2.5", + "5", + "2.5" + ], + "machine_max_jerk_y": [ + "5", + "2.5", + "5", + "2.5" + ], + "machine_max_jerk_z": [ + "0.4", + "0.2", + "0.4", + "0.2" + ], + "machine_max_junction_deviation": [ + "0", + "0", + "0", + "0" + ], + "machine_max_speed_e": [ + "120", + "60", + "120", + "60" + ], + "machine_max_speed_x": [ + "300", + "150", + "300", + "150" + ], + "machine_max_speed_y": [ + "300", + "150", + "300", + "150" + ], + "machine_max_speed_z": [ + "40", + "20", + "40", + "20" + ], + "machine_start_gcode": "M104 S{first_layer_temperature[0]} T0\nM104 S{first_layer_temperature[1]} T1\nM140 S[first_layer_bed_temperature] ; set bed temp\nM190 S[first_layer_bed_temperature] ; wait for bed temp\nM109 S{first_layer_temperature[0]} T0\nM109 S{first_layer_temperature[1]} T1\nstart_print\nG1 F5000\n{if first_layer_temperature[1] !=0 }\nTool1\nbrush_Tool1\n{endif}\n{if first_layer_temperature[0] !=0 }\nTool0\nbrush_Tool0\n{endif}\n{if first_layer_temperature[1] !=0 }\nTool1\nG1 Y280 X10.0 Z0.38 F5000.0 \nG1 Y100 X10.0 Z0.38 F1500.0 E15 \nG1 Y100 X10.4 Z0.38 F5000.0 \nG1 Y280 X10.4 Z0.38 F1500.0 E30\nG92 E0\nG1 Z2 F2000\n{endif}\nG1 F5000\n{if first_layer_temperature[0] !=0 }\nTool0\nG1 Y280 X11.0 Z0.38 F5000.0 \nG1 Y100 X11.0 Z0.38 F1500.0 E15 \nG1 Y100 X11.4 Z0.38 F5000.0 \nG1 Y280 X11.4 Z0.38 F1500.0 E30\nG92 E0\nG1 Z2 F2000\n{endif}\nG1 F5000\nM400\nSKEW_PROFILE LOAD=SKEW1\nG28 X\nG28 Y\nTool{initial_extruder}\nSTART_PRINTING_MODE\n\n", + "max_layer_height": [ + "0.64", + "0.64" + ], + "min_layer_height": [ + "0.2", + "0.2" + ], + "nozzle_diameter": [ + "0.8", + "0.8" + ], + "nozzle_type": [ + "hardened_steel", + "hardened_steel" + ], + "nozzle_volume": [ + "0", + "0" + ], + "printable_area": [ + "10x0", + "410x0", + "410x342.5", + "10x342.5" + ], + "printable_height": "401", + "printer_extruder_id": [ + "1", + "2" + ], + "printer_extruder_variant": [ + "Direct Drive High Flow", + "Direct Drive High Flow" + ], + "printer_model": "Xplorer Dual Gantry", + "printer_variant": "0.8", + "retract_before_wipe": [ + "70%", + "70%" + ], + "retract_length_toolchange": [ + "2", + "2" + ], + "retract_lift_above": [ + "0.2", + "0.2" + ], + "retract_lift_below": [ + "400", + "400" + ], + "retract_lift_enforce": [ + "All Surfaces", + "All Surfaces" + ], + "retract_restart_extra": [ + "0", + "0" + ], + "retract_restart_extra_toolchange": [ + "0", + "0" + ], + "retract_when_changing_layer": [ + "1", + "1" + ], + "retraction_distances_when_cut": [ + "18", + "18" + ], + "retraction_length": [ + "1.35", + "1.35" + ], + "retraction_minimum_travel": [ + "2", + "2" + ], + "retraction_speed": [ + "120", + "120" + ], + "travel_slope": [ + "1", + "1" + ], + "wipe": [ + "1", + "1" + ], + "wipe_distance": [ + "1", + "1" + ], + "z_hop": [ + "1", + "1" + ], + "z_hop_types": [ + "Normal Lift", + "Normal Lift" + ] +} diff --git a/resources/profiles/Vivedino/machine/Xplorer IDEX 0.4 nozzle.json b/resources/profiles/Vivedino/machine/Xplorer IDEX 0.4 nozzle.json new file mode 100644 index 0000000000..1529f22b4b --- /dev/null +++ b/resources/profiles/Vivedino/machine/Xplorer IDEX 0.4 nozzle.json @@ -0,0 +1,254 @@ +{ + "type": "machine", + "name": "Xplorer IDEX 0.4 nozzle", + "inherits": "Xplorer common", + "from": "system", + "setting_id": "lOZ2qJY7SsNRW03z", + "instantiation": "true", + "before_layer_change_gcode": ";BEFORE_LAYER_CHANGE\n;[layer_z]\n{if layer_num != 0}\nG92 E0\n{endif}\n{if layer_num <= 0}\nG91\nG1 Z+10 F1200\nG90\n{endif}", + "change_filament_gcode": ";CHANGE_FILAMENT - Start\n;Layer#=[layer_num] - Z=[layer_z]\n{if layer_z >first_layer_height}\nG91\nG1 Z+2 F1200\nG90\n{endif}\n{if layer_num != -1 || next_extruder != 0}\n Tool{next_extruder}\n{endif}\n; <----\nG1 Z[layer_z] F1200\n{if layer_num == -1 || layer_num == 0}\n M109 S[first_layer_temperature[next_extruder]] T[next_extruder]\n{else}\n M104 S[temperature[next_extruder]] T[next_extruder]\n{endif}\n; ---->\nM83\n;Layer#=[layer_num] - Z=[layer_z]\nLOG_EXTR\n;CHANGE_FILAMENT - End", + "default_nozzle_volume_type": [ + "High Flow", + "High Flow" + ], + "default_print_profile": "0.20mm Standard @Xplorer 0.4", + "deretraction_speed": [ + "120", + "120" + ], + "extruder_colour": [ + "#FCE94F", + "#729FCF" + ], + "extruder_offset": [ + "0x0", + "0x0" + ], + "extruder_type": [ + "Direct Drive", + "Direct Drive" + ], + "extruder_variant_list": [ + "Direct Drive High Flow", + "Direct Drive High Flow" + ], + "imex_mode_active_tools": [ + "0:P", + "0:P,1:C", + "0:P,1:M" + ], + "imex_mode_gcodes": [ + "SET_PRINT_MODE MODE=PRIMARY", + "SET_PRINT_MODE MODE=COPY", + "SET_PRINT_MODE MODE=MIRROR" + ], + "imex_mode_names": [ + "primary", + "copy", + "mirror" + ], + "imex_nozzle_clearance_x": "40", + "imex_tool_layout": "front-left", + "is_imex": "1", + "layer_change_gcode": ";AFTER_LAYER_CHANGE\n;[layer_z]\nLOG_RESUME\nLOG_EXTR", + "long_retractions_when_cut": [ + "0", + "0" + ], + "machine_max_acceleration_e": [ + "6000", + "5000", + "6000", + "5000" + ], + "machine_max_acceleration_extruding": [ + "5000", + "2500", + "5000", + "2500" + ], + "machine_max_acceleration_retracting": [ + "5000", + "5000", + "5000", + "5000" + ], + "machine_max_acceleration_travel": [ + "5000", + "2500", + "5000", + "2500" + ], + "machine_max_acceleration_x": [ + "5000", + "2500", + "5000", + "2500" + ], + "machine_max_acceleration_y": [ + "5000", + "2500", + "5000", + "2500" + ], + "machine_max_acceleration_z": [ + "100", + "50", + "100", + "50" + ], + "machine_max_jerk_e": [ + "2.5", + "1.25", + "2.5", + "1.25" + ], + "machine_max_jerk_x": [ + "5", + "2.5", + "5", + "2.5" + ], + "machine_max_jerk_y": [ + "5", + "2.5", + "5", + "2.5" + ], + "machine_max_jerk_z": [ + "0.4", + "0.2", + "0.4", + "0.2" + ], + "machine_max_junction_deviation": [ + "0", + "0", + "0", + "0" + ], + "machine_max_speed_e": [ + "120", + "60", + "120", + "60" + ], + "machine_max_speed_x": [ + "300", + "150", + "300", + "150" + ], + "machine_max_speed_y": [ + "300", + "150", + "300", + "150" + ], + "machine_max_speed_z": [ + "40", + "20", + "40", + "20" + ], + "machine_start_gcode": "M104 S{first_layer_temperature[0]} T0\nM104 S{first_layer_temperature[1]} T1\nM140 S[first_layer_bed_temperature] ; set bed temp\nM190 S[first_layer_bed_temperature] ; wait for bed temp\nM109 S{first_layer_temperature[0]} T0\nM109 S{first_layer_temperature[1]} T1\nstart_print\nG1 F5000\n{if first_layer_temperature[1] !=0 }\nTool1\nbrush_Tool1\n{endif}\n{if first_layer_temperature[0] !=0 }\nTool0\nbrush_Tool0\n{endif}\n{if first_layer_temperature[1] !=0 }\nTool1\nG1 X280.1 Y3.0 Z0.38 F5000.0 \nG1 X100.1 Y3.0 Z0.38 F1500.0 E15 \nG1 X100.1 Y3.4 Z0.38 F5000.0 \nG1 X280.1 Y3.4 Z0.38 F1500.0 E30\nG92 E0\nG1 Z2 F2000\n{endif}\n{if first_layer_temperature[0] !=0 }\nTool0\nG1 X100.1 Y2.0 Z0.38 F5000.0 \nG1 X280.1 Y2.0 Z0.38 F1500.0 E15 \nG1 X280.1 Y2.4 Z0.38 F5000.0 \nG1 X100.1 Y2.4 Z0.38 F1500.0 E30\nG92 E0\nG1 Z2 F2000\n{endif}\nM400\nSKEW_PROFILE LOAD=SKEW1\nG28 X\nTool{initial_extruder}\nSTART_PRINTING_MODE\n\n", + "max_layer_height": [ + "0.32", + "0.32" + ], + "min_layer_height": [ + "0.04", + "0.04" + ], + "nozzle_diameter": [ + "0.4", + "0.4" + ], + "nozzle_type": [ + "hardened_steel", + "hardened_steel" + ], + "nozzle_volume": [ + "0", + "0" + ], + "printable_height": "401", + "printer_extruder_id": [ + "1", + "2" + ], + "printer_extruder_variant": [ + "Direct Drive High Flow", + "Direct Drive High Flow" + ], + "printer_model": "Xplorer IDEX", + "printer_variant": "0.4", + "retract_before_wipe": [ + "70%", + "70%" + ], + "retract_length_toolchange": [ + "2", + "2" + ], + "retract_lift_above": [ + "0.2", + "0.2" + ], + "retract_lift_below": [ + "400", + "400" + ], + "retract_lift_enforce": [ + "All Surfaces", + "All Surfaces" + ], + "retract_restart_extra": [ + "0", + "0" + ], + "retract_restart_extra_toolchange": [ + "0", + "0" + ], + "retract_when_changing_layer": [ + "1", + "1" + ], + "retraction_distances_when_cut": [ + "18", + "18" + ], + "retraction_length": [ + "1.35", + "1.35" + ], + "retraction_minimum_travel": [ + "2", + "2" + ], + "retraction_speed": [ + "120", + "120" + ], + "travel_slope": [ + "1", + "1" + ], + "wipe": [ + "1", + "1" + ], + "wipe_distance": [ + "1", + "1" + ], + "z_hop": [ + "1", + "1" + ], + "z_hop_types": [ + "Normal Lift", + "Normal Lift" + ] +} diff --git a/resources/profiles/Vivedino/machine/Xplorer IDEX 0.6 nozzle.json b/resources/profiles/Vivedino/machine/Xplorer IDEX 0.6 nozzle.json new file mode 100644 index 0000000000..b6cfd97d54 --- /dev/null +++ b/resources/profiles/Vivedino/machine/Xplorer IDEX 0.6 nozzle.json @@ -0,0 +1,254 @@ +{ + "type": "machine", + "name": "Xplorer IDEX 0.6 nozzle", + "inherits": "Xplorer common", + "from": "system", + "setting_id": "S4ZLGZuPtR3yC4IZ", + "instantiation": "true", + "before_layer_change_gcode": ";BEFORE_LAYER_CHANGE\n;[layer_z]\n{if layer_num != 0}\nG92 E0\n{endif}\n{if layer_num <= 0}\nG91\nG1 Z+10 F1200\nG90\n{endif}", + "change_filament_gcode": ";CHANGE_FILAMENT - Start\n;Layer#=[layer_num] - Z=[layer_z]\n{if layer_z >first_layer_height}\nG91\nG1 Z+2 F1200\nG90\n{endif}\n{if layer_num != -1 || next_extruder != 0}\n Tool{next_extruder}\n{endif}\n; <----\nG1 Z[layer_z] F1200\n{if layer_num == -1 || layer_num == 0}\n M109 S[first_layer_temperature[next_extruder]] T[next_extruder]\n{else}\n M104 S[temperature[next_extruder]] T[next_extruder]\n{endif}\n; ---->\nM83\n;Layer#=[layer_num] - Z=[layer_z]\nLOG_EXTR\n;CHANGE_FILAMENT - End", + "default_nozzle_volume_type": [ + "High Flow", + "High Flow" + ], + "default_print_profile": "0.30mm Standard @Xplorer 0.6", + "deretraction_speed": [ + "120", + "120" + ], + "extruder_colour": [ + "#FCE94F", + "#729FCF" + ], + "extruder_offset": [ + "0x0", + "0x0" + ], + "extruder_type": [ + "Direct Drive", + "Direct Drive" + ], + "extruder_variant_list": [ + "Direct Drive High Flow", + "Direct Drive High Flow" + ], + "imex_mode_active_tools": [ + "0:P", + "0:P,1:C", + "0:P,1:M" + ], + "imex_mode_gcodes": [ + "SET_PRINT_MODE MODE=PRIMARY", + "SET_PRINT_MODE MODE=COPY", + "SET_PRINT_MODE MODE=MIRROR" + ], + "imex_mode_names": [ + "primary", + "copy", + "mirror" + ], + "imex_nozzle_clearance_x": "40", + "imex_tool_layout": "front-left", + "is_imex": "1", + "layer_change_gcode": ";AFTER_LAYER_CHANGE\n;[layer_z]\nLOG_RESUME\nLOG_EXTR", + "long_retractions_when_cut": [ + "0", + "0" + ], + "machine_max_acceleration_e": [ + "6000", + "5000", + "6000", + "5000" + ], + "machine_max_acceleration_extruding": [ + "5000", + "2500", + "5000", + "2500" + ], + "machine_max_acceleration_retracting": [ + "5000", + "5000", + "5000", + "5000" + ], + "machine_max_acceleration_travel": [ + "5000", + "2500", + "5000", + "2500" + ], + "machine_max_acceleration_x": [ + "5000", + "2500", + "5000", + "2500" + ], + "machine_max_acceleration_y": [ + "5000", + "2500", + "5000", + "2500" + ], + "machine_max_acceleration_z": [ + "100", + "50", + "100", + "50" + ], + "machine_max_jerk_e": [ + "2.5", + "1.25", + "2.5", + "1.25" + ], + "machine_max_jerk_x": [ + "5", + "2.5", + "5", + "2.5" + ], + "machine_max_jerk_y": [ + "5", + "2.5", + "5", + "2.5" + ], + "machine_max_jerk_z": [ + "0.4", + "0.2", + "0.4", + "0.2" + ], + "machine_max_junction_deviation": [ + "0", + "0", + "0", + "0" + ], + "machine_max_speed_e": [ + "120", + "60", + "120", + "60" + ], + "machine_max_speed_x": [ + "300", + "150", + "300", + "150" + ], + "machine_max_speed_y": [ + "300", + "150", + "300", + "150" + ], + "machine_max_speed_z": [ + "40", + "20", + "40", + "20" + ], + "machine_start_gcode": "M104 S{first_layer_temperature[0]} T0\nM104 S{first_layer_temperature[1]} T1\nM140 S[first_layer_bed_temperature] ; set bed temp\nM190 S[first_layer_bed_temperature] ; wait for bed temp\nM109 S{first_layer_temperature[0]} T0\nM109 S{first_layer_temperature[1]} T1\nstart_print\nG1 F5000\n{if first_layer_temperature[1] !=0 }\nTool1\nbrush_Tool1\n{endif}\n{if first_layer_temperature[0] !=0 }\nTool0\nbrush_Tool0\n{endif}\n{if first_layer_temperature[1] !=0 }\nTool1\nG1 X280.1 Y3.0 Z0.38 F5000.0 \nG1 X100.1 Y3.0 Z0.38 F1500.0 E15 \nG1 X100.1 Y3.4 Z0.38 F5000.0 \nG1 X280.1 Y3.4 Z0.38 F1500.0 E30\nG92 E0\nG1 Z2 F2000\n{endif}\n{if first_layer_temperature[0] !=0 }\nTool0\nG1 X100.1 Y2.0 Z0.38 F5000.0 \nG1 X280.1 Y2.0 Z0.38 F1500.0 E15 \nG1 X280.1 Y2.4 Z0.38 F5000.0 \nG1 X100.1 Y2.4 Z0.38 F1500.0 E30\nG92 E0\nG1 Z2 F2000\n{endif}\nM400\nSKEW_PROFILE LOAD=SKEW1\nG28 X\nTool{initial_extruder}\nSTART_PRINTING_MODE\n\n", + "max_layer_height": [ + "0.45", + "0.45" + ], + "min_layer_height": [ + "0.12", + "0.12" + ], + "nozzle_diameter": [ + "0.6", + "0.6" + ], + "nozzle_type": [ + "hardened_steel", + "hardened_steel" + ], + "nozzle_volume": [ + "0", + "0" + ], + "printable_height": "401", + "printer_extruder_id": [ + "1", + "2" + ], + "printer_extruder_variant": [ + "Direct Drive High Flow", + "Direct Drive High Flow" + ], + "printer_model": "Xplorer IDEX", + "printer_variant": "0.6", + "retract_before_wipe": [ + "70%", + "70%" + ], + "retract_length_toolchange": [ + "2", + "2" + ], + "retract_lift_above": [ + "0.2", + "0.2" + ], + "retract_lift_below": [ + "400", + "400" + ], + "retract_lift_enforce": [ + "All Surfaces", + "All Surfaces" + ], + "retract_restart_extra": [ + "0", + "0" + ], + "retract_restart_extra_toolchange": [ + "0", + "0" + ], + "retract_when_changing_layer": [ + "1", + "1" + ], + "retraction_distances_when_cut": [ + "18", + "18" + ], + "retraction_length": [ + "1.35", + "1.35" + ], + "retraction_minimum_travel": [ + "2", + "2" + ], + "retraction_speed": [ + "120", + "120" + ], + "travel_slope": [ + "1", + "1" + ], + "wipe": [ + "1", + "1" + ], + "wipe_distance": [ + "1", + "1" + ], + "z_hop": [ + "1", + "1" + ], + "z_hop_types": [ + "Normal Lift", + "Normal Lift" + ] +} diff --git a/resources/profiles/Vivedino/machine/Xplorer IDEX 0.8 nozzle.json b/resources/profiles/Vivedino/machine/Xplorer IDEX 0.8 nozzle.json new file mode 100644 index 0000000000..599717aba4 --- /dev/null +++ b/resources/profiles/Vivedino/machine/Xplorer IDEX 0.8 nozzle.json @@ -0,0 +1,254 @@ +{ + "type": "machine", + "name": "Xplorer IDEX 0.8 nozzle", + "inherits": "Xplorer common", + "from": "system", + "setting_id": "U50ftmONKAXI4OQ9", + "instantiation": "true", + "before_layer_change_gcode": ";BEFORE_LAYER_CHANGE\n;[layer_z]\n{if layer_num != 0}\nG92 E0\n{endif}\n{if layer_num <= 0}\nG91\nG1 Z+10 F1200\nG90\n{endif}", + "change_filament_gcode": ";CHANGE_FILAMENT - Start\n;Layer#=[layer_num] - Z=[layer_z]\n{if layer_z >first_layer_height}\nG91\nG1 Z+2 F1200\nG90\n{endif}\n{if layer_num != -1 || next_extruder != 0}\n Tool{next_extruder}\n{endif}\n; <----\nG1 Z[layer_z] F1200\n{if layer_num == -1 || layer_num == 0}\n M109 S[first_layer_temperature[next_extruder]] T[next_extruder]\n{else}\n M104 S[temperature[next_extruder]] T[next_extruder]\n{endif}\n; ---->\nM83\n;Layer#=[layer_num] - Z=[layer_z]\nLOG_EXTR\n;CHANGE_FILAMENT - End", + "default_nozzle_volume_type": [ + "High Flow", + "High Flow" + ], + "default_print_profile": "0.40mm Standard @Xplorer 0.8", + "deretraction_speed": [ + "120", + "120" + ], + "extruder_colour": [ + "#FCE94F", + "#729FCF" + ], + "extruder_offset": [ + "0x0", + "0x0" + ], + "extruder_type": [ + "Direct Drive", + "Direct Drive" + ], + "extruder_variant_list": [ + "Direct Drive High Flow", + "Direct Drive High Flow" + ], + "imex_mode_active_tools": [ + "0:P", + "0:P,1:C", + "0:P,1:M" + ], + "imex_mode_gcodes": [ + "SET_PRINT_MODE MODE=PRIMARY", + "SET_PRINT_MODE MODE=COPY", + "SET_PRINT_MODE MODE=MIRROR" + ], + "imex_mode_names": [ + "primary", + "copy", + "mirror" + ], + "imex_nozzle_clearance_x": "40", + "imex_tool_layout": "front-left", + "is_imex": "1", + "layer_change_gcode": ";AFTER_LAYER_CHANGE\n;[layer_z]\nLOG_RESUME\nLOG_EXTR", + "long_retractions_when_cut": [ + "0", + "0" + ], + "machine_max_acceleration_e": [ + "6000", + "5000", + "6000", + "5000" + ], + "machine_max_acceleration_extruding": [ + "5000", + "2500", + "5000", + "2500" + ], + "machine_max_acceleration_retracting": [ + "5000", + "5000", + "5000", + "5000" + ], + "machine_max_acceleration_travel": [ + "5000", + "2500", + "5000", + "2500" + ], + "machine_max_acceleration_x": [ + "5000", + "2500", + "5000", + "2500" + ], + "machine_max_acceleration_y": [ + "5000", + "2500", + "5000", + "2500" + ], + "machine_max_acceleration_z": [ + "100", + "50", + "100", + "50" + ], + "machine_max_jerk_e": [ + "2.5", + "1.25", + "2.5", + "1.25" + ], + "machine_max_jerk_x": [ + "5", + "2.5", + "5", + "2.5" + ], + "machine_max_jerk_y": [ + "5", + "2.5", + "5", + "2.5" + ], + "machine_max_jerk_z": [ + "0.4", + "0.2", + "0.4", + "0.2" + ], + "machine_max_junction_deviation": [ + "0", + "0", + "0", + "0" + ], + "machine_max_speed_e": [ + "120", + "60", + "120", + "60" + ], + "machine_max_speed_x": [ + "300", + "150", + "300", + "150" + ], + "machine_max_speed_y": [ + "300", + "150", + "300", + "150" + ], + "machine_max_speed_z": [ + "40", + "20", + "40", + "20" + ], + "machine_start_gcode": "M104 S{first_layer_temperature[0]} T0\nM104 S{first_layer_temperature[1]} T1\nM140 S[first_layer_bed_temperature] ; set bed temp\nM190 S[first_layer_bed_temperature] ; wait for bed temp\nM109 S{first_layer_temperature[0]} T0\nM109 S{first_layer_temperature[1]} T1\nstart_print\nG1 F5000\n{if first_layer_temperature[1] !=0 }\nTool1\nbrush_Tool1\n{endif}\n{if first_layer_temperature[0] !=0 }\nTool0\nbrush_Tool0\n{endif}\n{if first_layer_temperature[1] !=0 }\nTool1\nG1 X280.1 Y3.0 Z0.38 F5000.0 \nG1 X100.1 Y3.0 Z0.38 F1500.0 E15 \nG1 X100.1 Y3.4 Z0.38 F5000.0 \nG1 X280.1 Y3.4 Z0.38 F1500.0 E30\nG92 E0\nG1 Z2 F2000\n{endif}\n{if first_layer_temperature[0] !=0 }\nTool0\nG1 X100.1 Y2.0 Z0.38 F5000.0 \nG1 X280.1 Y2.0 Z0.38 F1500.0 E15 \nG1 X280.1 Y2.4 Z0.38 F5000.0 \nG1 X100.1 Y2.4 Z0.38 F1500.0 E30\nG92 E0\nG1 Z2 F2000\n{endif}\nM400\nSKEW_PROFILE LOAD=SKEW1\nG28 X\nTool{initial_extruder}\nSTART_PRINTING_MODE\n\n", + "max_layer_height": [ + "0.64", + "0.64" + ], + "min_layer_height": [ + "0.2", + "0.2" + ], + "nozzle_diameter": [ + "0.8", + "0.8" + ], + "nozzle_type": [ + "hardened_steel", + "hardened_steel" + ], + "nozzle_volume": [ + "0", + "0" + ], + "printable_height": "401", + "printer_extruder_id": [ + "1", + "2" + ], + "printer_extruder_variant": [ + "Direct Drive High Flow", + "Direct Drive High Flow" + ], + "printer_model": "Xplorer IDEX", + "printer_variant": "0.8", + "retract_before_wipe": [ + "70%", + "70%" + ], + "retract_length_toolchange": [ + "2", + "2" + ], + "retract_lift_above": [ + "0.2", + "0.2" + ], + "retract_lift_below": [ + "400", + "400" + ], + "retract_lift_enforce": [ + "All Surfaces", + "All Surfaces" + ], + "retract_restart_extra": [ + "0", + "0" + ], + "retract_restart_extra_toolchange": [ + "0", + "0" + ], + "retract_when_changing_layer": [ + "1", + "1" + ], + "retraction_distances_when_cut": [ + "18", + "18" + ], + "retraction_length": [ + "1.35", + "1.35" + ], + "retraction_minimum_travel": [ + "2", + "2" + ], + "retraction_speed": [ + "120", + "120" + ], + "travel_slope": [ + "1", + "1" + ], + "wipe": [ + "1", + "1" + ], + "wipe_distance": [ + "1", + "1" + ], + "z_hop": [ + "1", + "1" + ], + "z_hop_types": [ + "Normal Lift", + "Normal Lift" + ] +} diff --git a/resources/profiles/Vivedino/machine/Xplorer IQEX 0.4 nozzle.json b/resources/profiles/Vivedino/machine/Xplorer IQEX 0.4 nozzle.json new file mode 100644 index 0000000000..d98f63e703 --- /dev/null +++ b/resources/profiles/Vivedino/machine/Xplorer IQEX 0.4 nozzle.json @@ -0,0 +1,399 @@ +{ + "type": "machine", + "name": "Xplorer IQEX 0.4 nozzle", + "inherits": "Xplorer common", + "from": "system", + "setting_id": "fbUJR4RMoMczxSkH", + "instantiation": "true", + "before_layer_change_gcode": ";BEFORE_LAYER_CHANGE\n;[layer_z]\n{if layer_num != 0}\nG92 E0\n{endif}\n{if layer_num <= 0}\nG91\nG1 Z+10 F1200\nG90\n{endif}", + "change_filament_gcode": ";CHANGE_FILAMENT - Start\n;Layer#=[layer_num] - Z=[layer_z]\n{if layer_z >first_layer_height}\nG91\nG1 Z+2 F1200\nG90\n{endif}\n{if layer_num != -1 || next_extruder != 0}\n Tool{next_extruder}\n{endif}\n; <----\nG1 Z[layer_z] F1200\n{if layer_num == -1 || layer_num == 0}\n M109 S[first_layer_temperature[next_extruder]] T[next_extruder]\n{else}\n M104 S[temperature[next_extruder]] T[next_extruder]\n{endif}\n; ---->\nM83\n;Layer#=[layer_num] - Z=[layer_z]\nLOG_EXTR\n;CHANGE_FILAMENT - End", + "default_nozzle_volume_type": [ + "High Flow", + "High Flow", + "High Flow", + "High Flow" + ], + "default_print_profile": "0.20mm Standard @Xplorer 0.4", + "deretraction_speed": [ + "120", + "120", + "120", + "120" + ], + "extruder_colour": [ + "#FCE94F", + "#729FCF", + "#AD7FA8", + "#F57900" + ], + "extruder_offset": [ + "0x0", + "0x0", + "0x0", + "0x0" + ], + "extruder_type": [ + "Direct Drive", + "Direct Drive", + "Direct Drive", + "Direct Drive" + ], + "extruder_variant_list": [ + "Direct Drive High Flow", + "Direct Drive High Flow", + "Direct Drive High Flow", + "Direct Drive High Flow" + ], + "imex_gantry_count": "2", + "imex_mode_active_tools": [ + "0:P", + "0:P,1:C", + "0:P,1:M", + "0:P,1:C,2:C,3:C", + "0:P,1:C,2:M,3:M", + "0:P,1:S,2:C,3:C", + "0:P,1:S,2:M,3:M" + ], + "imex_mode_gcodes": [ + "SET_PRINT_MODE MODE=PRIMARY", + "SET_PRINT_MODE MODE=COPY01", + "SET_PRINT_MODE MODE=MIRROR01", + "SET_PRINT_MODE MODE=COPY0123", + "SET_PRINT_MODE MODE=MIRROR0123", + "SET_PRINT_MODE MODE=MULTICOLOR_COPY01", + "SET_PRINT_MODE MODE=MULTICOLOR_MIRROR01" + ], + "imex_mode_names": [ + "primary", + "copy", + "mirror", + "iq-copy", + "iq-mirror", + "mc-copy", + "mc-mirror" + ], + "imex_nozzle_clearance_x": "40", + "imex_tool_layout": "rear-left", + "is_imex": "1", + "layer_change_gcode": ";AFTER_LAYER_CHANGE\n;[layer_z]\nLOG_RESUME\nLOG_EXTR", + "long_retractions_when_cut": [ + "0", + "0", + "0", + "0" + ], + "machine_max_acceleration_e": [ + "6000", + "5000", + "6000", + "5000", + "6000", + "5000", + "6000", + "5000" + ], + "machine_max_acceleration_extruding": [ + "5000", + "2500", + "5000", + "2500", + "5000", + "2500", + "5000", + "2500" + ], + "machine_max_acceleration_retracting": [ + "5000", + "5000", + "5000", + "5000", + "5000", + "5000", + "5000", + "5000" + ], + "machine_max_acceleration_travel": [ + "5000", + "2500", + "5000", + "2500", + "5000", + "2500", + "5000", + "2500" + ], + "machine_max_acceleration_x": [ + "5000", + "2500", + "5000", + "2500", + "5000", + "2500", + "5000", + "2500" + ], + "machine_max_acceleration_y": [ + "5000", + "2500", + "5000", + "2500", + "5000", + "2500", + "5000", + "2500" + ], + "machine_max_acceleration_z": [ + "100", + "50", + "100", + "50", + "100", + "50", + "100", + "50" + ], + "machine_max_jerk_e": [ + "2.5", + "1.25", + "2.5", + "1.25", + "2.5", + "1.25", + "2.5", + "1.25" + ], + "machine_max_jerk_x": [ + "5", + "2.5", + "5", + "2.5", + "5", + "2.5", + "5", + "2.5" + ], + "machine_max_jerk_y": [ + "5", + "2.5", + "5", + "2.5", + "5", + "2.5", + "5", + "2.5" + ], + "machine_max_jerk_z": [ + "0.4", + "0.2", + "0.4", + "0.2", + "0.4", + "0.2", + "0.4", + "0.2" + ], + "machine_max_junction_deviation": [ + "0", + "0", + "0", + "0", + "0", + "0", + "0", + "0" + ], + "machine_max_speed_e": [ + "120", + "60", + "120", + "60", + "120", + "60", + "120", + "60" + ], + "machine_max_speed_x": [ + "300", + "150", + "300", + "150", + "300", + "150", + "300", + "150" + ], + "machine_max_speed_y": [ + "300", + "150", + "300", + "150", + "300", + "150", + "300", + "150" + ], + "machine_max_speed_z": [ + "40", + "20", + "40", + "20", + "40", + "20", + "40", + "20" + ], + "machine_start_gcode": "M104 S{first_layer_temperature[0]} T0\nM104 S{first_layer_temperature[1]} T1\nM104 S{first_layer_temperature[2]} T2\nM104 S{first_layer_temperature[3]} T3\nM140 S[first_layer_bed_temperature] ; set bed temp\nM190 S[first_layer_bed_temperature] ; wait for bed temp\nM109 S{first_layer_temperature[0]} T0\nM109 S{first_layer_temperature[1]} T1\nM109 S{first_layer_temperature[2]} T2\nM109 S{first_layer_temperature[3]} T3\nstart_print\nG1 F5000\n{if first_layer_temperature[3] !=0 }\nTool3\nbrush_Tool3\n{endif}\n{if first_layer_temperature[2] !=0 }\nTool2\nbrush_Tool2\n{endif}\n{if first_layer_temperature[1] !=0 }\nTool1\nbrush_Tool1\n{endif}\n{if first_layer_temperature[0] !=0 }\nTool0\nbrush_Tool0\n{endif}\n{if first_layer_temperature[3] !=0 }\nTool3\nG1 Y280 X407.0 Z0.38 F5000.0 \nG1 Y175 X407.0 Z0.38 F1500.0 E15 \nG1 Y175 X407.4 Z0.38 F5000.0 \nG1 Y280 X407.4 Z0.38 F1500.0 E30\nG92 E0\nG1 Z2 F2000\n{endif}\n{if first_layer_temperature[2] !=0 }\nTool2\nG1 Y280 X12.0 Z0.38 F5000.0 \nG1 Y175 X12.0 Z0.38 F1500.0 E15 \nG1 Y175 X12.4 Z0.38 F5000.0 \nG1 Y280 X12.4 Z0.38 F1500.0 E30\nG92 E0\nG1 Z2 F2000\n{endif}\n{if first_layer_temperature[1] !=0 }\nTool1\nG1 Y280 X408.0 Z0.38 F5000.0 \nG1 Y175 X408.0 Z0.38 F1500.0 E15 \nG1 Y175 X408.4 Z0.38 F5000.0 \nG1 Y280 X408.4 Z0.38 F1500.0 E30\nG92 E0\nG1 Z2 F2000\n{endif}\nG1 F5000\n{if first_layer_temperature[0] !=0 }\nTool0\nG1 Y280 X11.0 Z0.38 F5000.0 \nG1 Y175 X11.0 Z0.38 F1500.0 E15 \nG1 Y175 X11.4 Z0.38 F5000.0 \nG1 Y280 X11.4 Z0.38 F1500.0 E30\nG92 E0\nG1 Z2 F2000\n{endif}\nG1 F5000\nM400\nSKEW_PROFILE LOAD=SKEW1\nG28 X\nG28 Y\nTool{initial_extruder}\nSTART_PRINTING_MODE\n\n", + "max_layer_height": [ + "0.32", + "0.32", + "0.32", + "0.32" + ], + "min_layer_height": [ + "0.04", + "0.04", + "0.04", + "0.04" + ], + "nozzle_diameter": [ + "0.4", + "0.4", + "0.4", + "0.4" + ], + "nozzle_type": [ + "hardened_steel", + "hardened_steel", + "hardened_steel", + "hardened_steel" + ], + "nozzle_volume": [ + "0", + "0", + "0", + "0" + ], + "printable_area": [ + "10x0", + "410x0", + "410x342.5", + "10x342.5" + ], + "printable_height": "401", + "printer_extruder_id": [ + "1", + "2", + "3", + "4" + ], + "printer_extruder_variant": [ + "Direct Drive High Flow", + "Direct Drive High Flow", + "Direct Drive High Flow", + "Direct Drive High Flow" + ], + "printer_model": "Xplorer IQEX", + "printer_variant": "0.4", + "retract_before_wipe": [ + "70%", + "70%", + "70%", + "70%" + ], + "retract_length_toolchange": [ + "2", + "2", + "2", + "2" + ], + "retract_lift_above": [ + "0.2", + "0.2", + "0.2", + "0.2" + ], + "retract_lift_below": [ + "400", + "400", + "400", + "400" + ], + "retract_lift_enforce": [ + "All Surfaces", + "All Surfaces", + "All Surfaces", + "All Surfaces" + ], + "retract_restart_extra": [ + "0", + "0", + "0", + "0" + ], + "retract_restart_extra_toolchange": [ + "0", + "0", + "0", + "0" + ], + "retract_when_changing_layer": [ + "1", + "1", + "1", + "1" + ], + "retraction_distances_when_cut": [ + "18", + "18", + "18", + "18" + ], + "retraction_length": [ + "1.35", + "1.35", + "1.35", + "1.35" + ], + "retraction_minimum_travel": [ + "2", + "2", + "2", + "2" + ], + "retraction_speed": [ + "120", + "120", + "120", + "120" + ], + "travel_slope": [ + "1", + "1", + "1", + "1" + ], + "wipe": [ + "1", + "1", + "1", + "1" + ], + "wipe_distance": [ + "1", + "1", + "1", + "1" + ], + "z_hop": [ + "1", + "1", + "1", + "1" + ], + "z_hop_types": [ + "Normal Lift", + "Normal Lift", + "Normal Lift", + "Normal Lift" + ] +} diff --git a/resources/profiles/Vivedino/machine/Xplorer IQEX 0.6 nozzle.json b/resources/profiles/Vivedino/machine/Xplorer IQEX 0.6 nozzle.json new file mode 100644 index 0000000000..97ab9406c5 --- /dev/null +++ b/resources/profiles/Vivedino/machine/Xplorer IQEX 0.6 nozzle.json @@ -0,0 +1,399 @@ +{ + "type": "machine", + "name": "Xplorer IQEX 0.6 nozzle", + "inherits": "Xplorer common", + "from": "system", + "setting_id": "hOYAHBik6rxU1B0d", + "instantiation": "true", + "before_layer_change_gcode": ";BEFORE_LAYER_CHANGE\n;[layer_z]\n{if layer_num != 0}\nG92 E0\n{endif}\n{if layer_num <= 0}\nG91\nG1 Z+10 F1200\nG90\n{endif}", + "change_filament_gcode": ";CHANGE_FILAMENT - Start\n;Layer#=[layer_num] - Z=[layer_z]\n{if layer_z >first_layer_height}\nG91\nG1 Z+2 F1200\nG90\n{endif}\n{if layer_num != -1 || next_extruder != 0}\n Tool{next_extruder}\n{endif}\n; <----\nG1 Z[layer_z] F1200\n{if layer_num == -1 || layer_num == 0}\n M109 S[first_layer_temperature[next_extruder]] T[next_extruder]\n{else}\n M104 S[temperature[next_extruder]] T[next_extruder]\n{endif}\n; ---->\nM83\n;Layer#=[layer_num] - Z=[layer_z]\nLOG_EXTR\n;CHANGE_FILAMENT - End", + "default_nozzle_volume_type": [ + "High Flow", + "High Flow", + "High Flow", + "High Flow" + ], + "default_print_profile": "0.30mm Standard @Xplorer 0.6", + "deretraction_speed": [ + "120", + "120", + "120", + "120" + ], + "extruder_colour": [ + "#FCE94F", + "#729FCF", + "#AD7FA8", + "#F57900" + ], + "extruder_offset": [ + "0x0", + "0x0", + "0x0", + "0x0" + ], + "extruder_type": [ + "Direct Drive", + "Direct Drive", + "Direct Drive", + "Direct Drive" + ], + "extruder_variant_list": [ + "Direct Drive High Flow", + "Direct Drive High Flow", + "Direct Drive High Flow", + "Direct Drive High Flow" + ], + "imex_gantry_count": "2", + "imex_mode_active_tools": [ + "0:P", + "0:P,1:C", + "0:P,1:M", + "0:P,1:C,2:C,3:C", + "0:P,1:C,2:M,3:M", + "0:P,1:S,2:C,3:C", + "0:P,1:S,2:M,3:M" + ], + "imex_mode_gcodes": [ + "SET_PRINT_MODE MODE=PRIMARY", + "SET_PRINT_MODE MODE=COPY01", + "SET_PRINT_MODE MODE=MIRROR01", + "SET_PRINT_MODE MODE=COPY0123", + "SET_PRINT_MODE MODE=MIRROR0123", + "SET_PRINT_MODE MODE=MULTICOLOR_COPY01", + "SET_PRINT_MODE MODE=MULTICOLOR_MIRROR01" + ], + "imex_mode_names": [ + "primary", + "copy", + "mirror", + "iq-copy", + "iq-mirror", + "mc-copy", + "mc-mirror" + ], + "imex_nozzle_clearance_x": "40", + "imex_tool_layout": "rear-left", + "is_imex": "1", + "layer_change_gcode": ";AFTER_LAYER_CHANGE\n;[layer_z]\nLOG_RESUME\nLOG_EXTR", + "long_retractions_when_cut": [ + "0", + "0", + "0", + "0" + ], + "machine_max_acceleration_e": [ + "6000", + "5000", + "6000", + "5000", + "6000", + "5000", + "6000", + "5000" + ], + "machine_max_acceleration_extruding": [ + "5000", + "2500", + "5000", + "2500", + "5000", + "2500", + "5000", + "2500" + ], + "machine_max_acceleration_retracting": [ + "5000", + "5000", + "5000", + "5000", + "5000", + "5000", + "5000", + "5000" + ], + "machine_max_acceleration_travel": [ + "5000", + "2500", + "5000", + "2500", + "5000", + "2500", + "5000", + "2500" + ], + "machine_max_acceleration_x": [ + "5000", + "2500", + "5000", + "2500", + "5000", + "2500", + "5000", + "2500" + ], + "machine_max_acceleration_y": [ + "5000", + "2500", + "5000", + "2500", + "5000", + "2500", + "5000", + "2500" + ], + "machine_max_acceleration_z": [ + "100", + "50", + "100", + "50", + "100", + "50", + "100", + "50" + ], + "machine_max_jerk_e": [ + "2.5", + "1.25", + "2.5", + "1.25", + "2.5", + "1.25", + "2.5", + "1.25" + ], + "machine_max_jerk_x": [ + "5", + "2.5", + "5", + "2.5", + "5", + "2.5", + "5", + "2.5" + ], + "machine_max_jerk_y": [ + "5", + "2.5", + "5", + "2.5", + "5", + "2.5", + "5", + "2.5" + ], + "machine_max_jerk_z": [ + "0.4", + "0.2", + "0.4", + "0.2", + "0.4", + "0.2", + "0.4", + "0.2" + ], + "machine_max_junction_deviation": [ + "0", + "0", + "0", + "0", + "0", + "0", + "0", + "0" + ], + "machine_max_speed_e": [ + "120", + "60", + "120", + "60", + "120", + "60", + "120", + "60" + ], + "machine_max_speed_x": [ + "300", + "150", + "300", + "150", + "300", + "150", + "300", + "150" + ], + "machine_max_speed_y": [ + "300", + "150", + "300", + "150", + "300", + "150", + "300", + "150" + ], + "machine_max_speed_z": [ + "40", + "20", + "40", + "20", + "40", + "20", + "40", + "20" + ], + "machine_start_gcode": "M104 S{first_layer_temperature[0]} T0\nM104 S{first_layer_temperature[1]} T1\nM104 S{first_layer_temperature[2]} T2\nM104 S{first_layer_temperature[3]} T3\nM140 S[first_layer_bed_temperature] ; set bed temp\nM190 S[first_layer_bed_temperature] ; wait for bed temp\nM109 S{first_layer_temperature[0]} T0\nM109 S{first_layer_temperature[1]} T1\nM109 S{first_layer_temperature[2]} T2\nM109 S{first_layer_temperature[3]} T3\nstart_print\nG1 F5000\n{if first_layer_temperature[3] !=0 }\nTool3\nbrush_Tool3\n{endif}\n{if first_layer_temperature[2] !=0 }\nTool2\nbrush_Tool2\n{endif}\n{if first_layer_temperature[1] !=0 }\nTool1\nbrush_Tool1\n{endif}\n{if first_layer_temperature[0] !=0 }\nTool0\nbrush_Tool0\n{endif}\n{if first_layer_temperature[3] !=0 }\nTool3\nG1 Y280 X407.0 Z0.38 F5000.0 \nG1 Y175 X407.0 Z0.38 F1500.0 E15 \nG1 Y175 X407.4 Z0.38 F5000.0 \nG1 Y280 X407.4 Z0.38 F1500.0 E30\nG92 E0\nG1 Z2 F2000\n{endif}\n{if first_layer_temperature[2] !=0 }\nTool2\nG1 Y280 X12.0 Z0.38 F5000.0 \nG1 Y175 X12.0 Z0.38 F1500.0 E15 \nG1 Y175 X12.4 Z0.38 F5000.0 \nG1 Y280 X12.4 Z0.38 F1500.0 E30\nG92 E0\nG1 Z2 F2000\n{endif}\n{if first_layer_temperature[1] !=0 }\nTool1\nG1 Y280 X408.0 Z0.38 F5000.0 \nG1 Y175 X408.0 Z0.38 F1500.0 E15 \nG1 Y175 X408.4 Z0.38 F5000.0 \nG1 Y280 X408.4 Z0.38 F1500.0 E30\nG92 E0\nG1 Z2 F2000\n{endif}\nG1 F5000\n{if first_layer_temperature[0] !=0 }\nTool0\nG1 Y280 X11.0 Z0.38 F5000.0 \nG1 Y175 X11.0 Z0.38 F1500.0 E15 \nG1 Y175 X11.4 Z0.38 F5000.0 \nG1 Y280 X11.4 Z0.38 F1500.0 E30\nG92 E0\nG1 Z2 F2000\n{endif}\nG1 F5000\nM400\nSKEW_PROFILE LOAD=SKEW1\nG28 X\nG28 Y\nTool{initial_extruder}\nSTART_PRINTING_MODE\n\n", + "max_layer_height": [ + "0.45", + "0.45", + "0.45", + "0.45" + ], + "min_layer_height": [ + "0.12", + "0.12", + "0.12", + "0.12" + ], + "nozzle_diameter": [ + "0.6", + "0.6", + "0.6", + "0.6" + ], + "nozzle_type": [ + "hardened_steel", + "hardened_steel", + "hardened_steel", + "hardened_steel" + ], + "nozzle_volume": [ + "0", + "0", + "0", + "0" + ], + "printable_area": [ + "10x0", + "410x0", + "410x342.5", + "10x342.5" + ], + "printable_height": "401", + "printer_extruder_id": [ + "1", + "2", + "3", + "4" + ], + "printer_extruder_variant": [ + "Direct Drive High Flow", + "Direct Drive High Flow", + "Direct Drive High Flow", + "Direct Drive High Flow" + ], + "printer_model": "Xplorer IQEX", + "printer_variant": "0.6", + "retract_before_wipe": [ + "70%", + "70%", + "70%", + "70%" + ], + "retract_length_toolchange": [ + "2", + "2", + "2", + "2" + ], + "retract_lift_above": [ + "0.2", + "0.2", + "0.2", + "0.2" + ], + "retract_lift_below": [ + "400", + "400", + "400", + "400" + ], + "retract_lift_enforce": [ + "All Surfaces", + "All Surfaces", + "All Surfaces", + "All Surfaces" + ], + "retract_restart_extra": [ + "0", + "0", + "0", + "0" + ], + "retract_restart_extra_toolchange": [ + "0", + "0", + "0", + "0" + ], + "retract_when_changing_layer": [ + "1", + "1", + "1", + "1" + ], + "retraction_distances_when_cut": [ + "18", + "18", + "18", + "18" + ], + "retraction_length": [ + "1.35", + "1.35", + "1.35", + "1.35" + ], + "retraction_minimum_travel": [ + "2", + "2", + "2", + "2" + ], + "retraction_speed": [ + "120", + "120", + "120", + "120" + ], + "travel_slope": [ + "1", + "1", + "1", + "1" + ], + "wipe": [ + "1", + "1", + "1", + "1" + ], + "wipe_distance": [ + "1", + "1", + "1", + "1" + ], + "z_hop": [ + "1", + "1", + "1", + "1" + ], + "z_hop_types": [ + "Normal Lift", + "Normal Lift", + "Normal Lift", + "Normal Lift" + ] +} diff --git a/resources/profiles/Vivedino/machine/Xplorer IQEX 0.8 nozzle.json b/resources/profiles/Vivedino/machine/Xplorer IQEX 0.8 nozzle.json new file mode 100644 index 0000000000..68a22685ed --- /dev/null +++ b/resources/profiles/Vivedino/machine/Xplorer IQEX 0.8 nozzle.json @@ -0,0 +1,399 @@ +{ + "type": "machine", + "name": "Xplorer IQEX 0.8 nozzle", + "inherits": "Xplorer common", + "from": "system", + "setting_id": "e048HgBDJy6XnaaO", + "instantiation": "true", + "before_layer_change_gcode": ";BEFORE_LAYER_CHANGE\n;[layer_z]\n{if layer_num != 0}\nG92 E0\n{endif}\n{if layer_num <= 0}\nG91\nG1 Z+10 F1200\nG90\n{endif}", + "change_filament_gcode": ";CHANGE_FILAMENT - Start\n;Layer#=[layer_num] - Z=[layer_z]\n{if layer_z >first_layer_height}\nG91\nG1 Z+2 F1200\nG90\n{endif}\n{if layer_num != -1 || next_extruder != 0}\n Tool{next_extruder}\n{endif}\n; <----\nG1 Z[layer_z] F1200\n{if layer_num == -1 || layer_num == 0}\n M109 S[first_layer_temperature[next_extruder]] T[next_extruder]\n{else}\n M104 S[temperature[next_extruder]] T[next_extruder]\n{endif}\n; ---->\nM83\n;Layer#=[layer_num] - Z=[layer_z]\nLOG_EXTR\n;CHANGE_FILAMENT - End", + "default_nozzle_volume_type": [ + "High Flow", + "High Flow", + "High Flow", + "High Flow" + ], + "default_print_profile": "0.40mm Standard @Xplorer 0.8", + "deretraction_speed": [ + "120", + "120", + "120", + "120" + ], + "extruder_colour": [ + "#FCE94F", + "#729FCF", + "#AD7FA8", + "#F57900" + ], + "extruder_offset": [ + "0x0", + "0x0", + "0x0", + "0x0" + ], + "extruder_type": [ + "Direct Drive", + "Direct Drive", + "Direct Drive", + "Direct Drive" + ], + "extruder_variant_list": [ + "Direct Drive High Flow", + "Direct Drive High Flow", + "Direct Drive High Flow", + "Direct Drive High Flow" + ], + "imex_gantry_count": "2", + "imex_mode_active_tools": [ + "0:P", + "0:P,1:C", + "0:P,1:M", + "0:P,1:C,2:C,3:C", + "0:P,1:C,2:M,3:M", + "0:P,1:S,2:C,3:C", + "0:P,1:S,2:M,3:M" + ], + "imex_mode_gcodes": [ + "SET_PRINT_MODE MODE=PRIMARY", + "SET_PRINT_MODE MODE=COPY01", + "SET_PRINT_MODE MODE=MIRROR01", + "SET_PRINT_MODE MODE=COPY0123", + "SET_PRINT_MODE MODE=MIRROR0123", + "SET_PRINT_MODE MODE=MULTICOLOR_COPY01", + "SET_PRINT_MODE MODE=MULTICOLOR_MIRROR01" + ], + "imex_mode_names": [ + "primary", + "copy", + "mirror", + "iq-copy", + "iq-mirror", + "mc-copy", + "mc-mirror" + ], + "imex_nozzle_clearance_x": "40", + "imex_tool_layout": "rear-left", + "is_imex": "1", + "layer_change_gcode": ";AFTER_LAYER_CHANGE\n;[layer_z]\nLOG_RESUME\nLOG_EXTR", + "long_retractions_when_cut": [ + "0", + "0", + "0", + "0" + ], + "machine_max_acceleration_e": [ + "6000", + "5000", + "6000", + "5000", + "6000", + "5000", + "6000", + "5000" + ], + "machine_max_acceleration_extruding": [ + "5000", + "2500", + "5000", + "2500", + "5000", + "2500", + "5000", + "2500" + ], + "machine_max_acceleration_retracting": [ + "5000", + "5000", + "5000", + "5000", + "5000", + "5000", + "5000", + "5000" + ], + "machine_max_acceleration_travel": [ + "5000", + "2500", + "5000", + "2500", + "5000", + "2500", + "5000", + "2500" + ], + "machine_max_acceleration_x": [ + "5000", + "2500", + "5000", + "2500", + "5000", + "2500", + "5000", + "2500" + ], + "machine_max_acceleration_y": [ + "5000", + "2500", + "5000", + "2500", + "5000", + "2500", + "5000", + "2500" + ], + "machine_max_acceleration_z": [ + "100", + "50", + "100", + "50", + "100", + "50", + "100", + "50" + ], + "machine_max_jerk_e": [ + "2.5", + "1.25", + "2.5", + "1.25", + "2.5", + "1.25", + "2.5", + "1.25" + ], + "machine_max_jerk_x": [ + "5", + "2.5", + "5", + "2.5", + "5", + "2.5", + "5", + "2.5" + ], + "machine_max_jerk_y": [ + "5", + "2.5", + "5", + "2.5", + "5", + "2.5", + "5", + "2.5" + ], + "machine_max_jerk_z": [ + "0.4", + "0.2", + "0.4", + "0.2", + "0.4", + "0.2", + "0.4", + "0.2" + ], + "machine_max_junction_deviation": [ + "0", + "0", + "0", + "0", + "0", + "0", + "0", + "0" + ], + "machine_max_speed_e": [ + "120", + "60", + "120", + "60", + "120", + "60", + "120", + "60" + ], + "machine_max_speed_x": [ + "300", + "150", + "300", + "150", + "300", + "150", + "300", + "150" + ], + "machine_max_speed_y": [ + "300", + "150", + "300", + "150", + "300", + "150", + "300", + "150" + ], + "machine_max_speed_z": [ + "40", + "20", + "40", + "20", + "40", + "20", + "40", + "20" + ], + "machine_start_gcode": "M104 S{first_layer_temperature[0]} T0\nM104 S{first_layer_temperature[1]} T1\nM104 S{first_layer_temperature[2]} T2\nM104 S{first_layer_temperature[3]} T3\nM140 S[first_layer_bed_temperature] ; set bed temp\nM190 S[first_layer_bed_temperature] ; wait for bed temp\nM109 S{first_layer_temperature[0]} T0\nM109 S{first_layer_temperature[1]} T1\nM109 S{first_layer_temperature[2]} T2\nM109 S{first_layer_temperature[3]} T3\nstart_print\nG1 F5000\n{if first_layer_temperature[3] !=0 }\nTool3\nbrush_Tool3\n{endif}\n{if first_layer_temperature[2] !=0 }\nTool2\nbrush_Tool2\n{endif}\n{if first_layer_temperature[1] !=0 }\nTool1\nbrush_Tool1\n{endif}\n{if first_layer_temperature[0] !=0 }\nTool0\nbrush_Tool0\n{endif}\n{if first_layer_temperature[3] !=0 }\nTool3\nG1 Y280 X407.0 Z0.38 F5000.0 \nG1 Y175 X407.0 Z0.38 F1500.0 E15 \nG1 Y175 X407.4 Z0.38 F5000.0 \nG1 Y280 X407.4 Z0.38 F1500.0 E30\nG92 E0\nG1 Z2 F2000\n{endif}\n{if first_layer_temperature[2] !=0 }\nTool2\nG1 Y280 X12.0 Z0.38 F5000.0 \nG1 Y175 X12.0 Z0.38 F1500.0 E15 \nG1 Y175 X12.4 Z0.38 F5000.0 \nG1 Y280 X12.4 Z0.38 F1500.0 E30\nG92 E0\nG1 Z2 F2000\n{endif}\n{if first_layer_temperature[1] !=0 }\nTool1\nG1 Y280 X408.0 Z0.38 F5000.0 \nG1 Y175 X408.0 Z0.38 F1500.0 E15 \nG1 Y175 X408.4 Z0.38 F5000.0 \nG1 Y280 X408.4 Z0.38 F1500.0 E30\nG92 E0\nG1 Z2 F2000\n{endif}\nG1 F5000\n{if first_layer_temperature[0] !=0 }\nTool0\nG1 Y280 X11.0 Z0.38 F5000.0 \nG1 Y175 X11.0 Z0.38 F1500.0 E15 \nG1 Y175 X11.4 Z0.38 F5000.0 \nG1 Y280 X11.4 Z0.38 F1500.0 E30\nG92 E0\nG1 Z2 F2000\n{endif}\nG1 F5000\nM400\nSKEW_PROFILE LOAD=SKEW1\nG28 X\nG28 Y\nTool{initial_extruder}\nSTART_PRINTING_MODE\n\n", + "max_layer_height": [ + "0.64", + "0.64", + "0.64", + "0.64" + ], + "min_layer_height": [ + "0.2", + "0.2", + "0.2", + "0.2" + ], + "nozzle_diameter": [ + "0.8", + "0.8", + "0.8", + "0.8" + ], + "nozzle_type": [ + "hardened_steel", + "hardened_steel", + "hardened_steel", + "hardened_steel" + ], + "nozzle_volume": [ + "0", + "0", + "0", + "0" + ], + "printable_area": [ + "10x0", + "410x0", + "410x342.5", + "10x342.5" + ], + "printable_height": "401", + "printer_extruder_id": [ + "1", + "2", + "3", + "4" + ], + "printer_extruder_variant": [ + "Direct Drive High Flow", + "Direct Drive High Flow", + "Direct Drive High Flow", + "Direct Drive High Flow" + ], + "printer_model": "Xplorer IQEX", + "printer_variant": "0.8", + "retract_before_wipe": [ + "70%", + "70%", + "70%", + "70%" + ], + "retract_length_toolchange": [ + "2", + "2", + "2", + "2" + ], + "retract_lift_above": [ + "0.2", + "0.2", + "0.2", + "0.2" + ], + "retract_lift_below": [ + "400", + "400", + "400", + "400" + ], + "retract_lift_enforce": [ + "All Surfaces", + "All Surfaces", + "All Surfaces", + "All Surfaces" + ], + "retract_restart_extra": [ + "0", + "0", + "0", + "0" + ], + "retract_restart_extra_toolchange": [ + "0", + "0", + "0", + "0" + ], + "retract_when_changing_layer": [ + "1", + "1", + "1", + "1" + ], + "retraction_distances_when_cut": [ + "18", + "18", + "18", + "18" + ], + "retraction_length": [ + "1.35", + "1.35", + "1.35", + "1.35" + ], + "retraction_minimum_travel": [ + "2", + "2", + "2", + "2" + ], + "retraction_speed": [ + "120", + "120", + "120", + "120" + ], + "travel_slope": [ + "1", + "1", + "1", + "1" + ], + "wipe": [ + "1", + "1", + "1", + "1" + ], + "wipe_distance": [ + "1", + "1", + "1", + "1" + ], + "z_hop": [ + "1", + "1", + "1", + "1" + ], + "z_hop_types": [ + "Normal Lift", + "Normal Lift", + "Normal Lift", + "Normal Lift" + ] +} diff --git a/resources/profiles/Vivedino/machine/Xplorer Single 0.4 nozzle.json b/resources/profiles/Vivedino/machine/Xplorer Single 0.4 nozzle.json new file mode 100644 index 0000000000..2d629ad93a --- /dev/null +++ b/resources/profiles/Vivedino/machine/Xplorer Single 0.4 nozzle.json @@ -0,0 +1,167 @@ +{ + "type": "machine", + "name": "Xplorer Single 0.4 nozzle", + "inherits": "Xplorer common", + "from": "system", + "setting_id": "P4sDniCHVUne9JJF", + "instantiation": "true", + "before_layer_change_gcode": ";BEFORE_LAYER_CHANGE\n;[layer_z]\n{if layer_num != 0}\nG92 E0\n{endif}", + "change_filament_gcode": "M600", + "default_nozzle_volume_type": [ + "High Flow" + ], + "default_print_profile": "0.20mm Standard @Xplorer 0.4", + "deretraction_speed": [ + "120" + ], + "extruder_colour": [ + "#FCE94F" + ], + "extruder_offset": [ + "0x0" + ], + "extruder_type": [ + "Direct Drive" + ], + "extruder_variant_list": [ + "Direct Drive High Flow" + ], + "layer_change_gcode": ";AFTER_LAYER_CHANGE\n;[layer_z]\nLOG_RESUME\nLOG_EXTR", + "long_retractions_when_cut": [ + "0" + ], + "machine_max_acceleration_e": [ + "6000", + "5000" + ], + "machine_max_acceleration_extruding": [ + "5000", + "2500" + ], + "machine_max_acceleration_retracting": [ + "5000", + "5000" + ], + "machine_max_acceleration_travel": [ + "5000", + "2500" + ], + "machine_max_acceleration_x": [ + "5000", + "2500" + ], + "machine_max_acceleration_y": [ + "5000", + "2500" + ], + "machine_max_acceleration_z": [ + "100", + "50" + ], + "machine_max_jerk_e": [ + "2.5", + "1.25" + ], + "machine_max_jerk_x": [ + "5", + "2.5" + ], + "machine_max_jerk_y": [ + "5", + "2.5" + ], + "machine_max_jerk_z": [ + "0.4", + "0.2" + ], + "machine_max_junction_deviation": [ + "0", + "0" + ], + "machine_max_speed_e": [ + "120", + "60" + ], + "machine_max_speed_x": [ + "300", + "150" + ], + "machine_max_speed_y": [ + "300", + "150" + ], + "machine_max_speed_z": [ + "40", + "20" + ], + "machine_start_gcode": "M104 S[first_layer_temperature] T[initial_tool]\nM140 S[first_layer_bed_temperature] ; set bed temp\nM190 S[first_layer_bed_temperature] ; wait for bed temp\nG4 P10000 ; wait for temp stabilisation\nstart_print \nTool[initial_tool]\nG90\nM109 S[first_layer_temperature] T[initial_tool]\nBrush_Tool[initial_tool]\nG1 Z5.0 F3000 \nG1 X100 Y1.0 Z0.38 F5000.0 \nG1 X280 Y1.0 Z0.38 F1500.0 E15 \nG1 X280 Y1.4 Z0.38 F5000.0 \nG1 X100 Y1.4 Z0.38 F1500.0 E30\nG92 E0\nG1 Z5.0 F3000\nSKEW_PROFILE LOAD=SKEW1\n", + "max_layer_height": [ + "0.32" + ], + "min_layer_height": [ + "0.04" + ], + "nozzle_diameter": [ + "0.4" + ], + "printable_height": "401", + "printer_extruder_id": [ + "1" + ], + "printer_extruder_variant": [ + "Direct Drive High Flow" + ], + "printer_model": "Xplorer Single", + "printer_variant": "0.4", + "retract_before_wipe": [ + "70%" + ], + "retract_length_toolchange": [ + "2" + ], + "retract_lift_above": [ + "0.2" + ], + "retract_lift_below": [ + "400" + ], + "retract_lift_enforce": [ + "All Surfaces" + ], + "retract_restart_extra": [ + "0" + ], + "retract_restart_extra_toolchange": [ + "0" + ], + "retract_when_changing_layer": [ + "1" + ], + "retraction_distances_when_cut": [ + "18" + ], + "retraction_length": [ + "1.35" + ], + "retraction_minimum_travel": [ + "2" + ], + "retraction_speed": [ + "120" + ], + "travel_slope": [ + "1" + ], + "wipe": [ + "1" + ], + "wipe_distance": [ + "1" + ], + "z_hop": [ + "1" + ], + "z_hop_types": [ + "Normal Lift" + ] +} diff --git a/resources/profiles/Vivedino/machine/Xplorer Single 0.6 nozzle.json b/resources/profiles/Vivedino/machine/Xplorer Single 0.6 nozzle.json new file mode 100644 index 0000000000..da3f85454a --- /dev/null +++ b/resources/profiles/Vivedino/machine/Xplorer Single 0.6 nozzle.json @@ -0,0 +1,167 @@ +{ + "type": "machine", + "name": "Xplorer Single 0.6 nozzle", + "inherits": "Xplorer common", + "from": "system", + "setting_id": "AN4SjJRQBVxztQoT", + "instantiation": "true", + "before_layer_change_gcode": ";BEFORE_LAYER_CHANGE\n;[layer_z]\n{if layer_num != 0}\nG92 E0\n{endif}", + "change_filament_gcode": "M600", + "default_nozzle_volume_type": [ + "High Flow" + ], + "default_print_profile": "0.30mm Standard @Xplorer 0.6", + "deretraction_speed": [ + "120" + ], + "extruder_colour": [ + "#FCE94F" + ], + "extruder_offset": [ + "0x0" + ], + "extruder_type": [ + "Direct Drive" + ], + "extruder_variant_list": [ + "Direct Drive High Flow" + ], + "layer_change_gcode": ";AFTER_LAYER_CHANGE\n;[layer_z]\nLOG_RESUME\nLOG_EXTR", + "long_retractions_when_cut": [ + "0" + ], + "machine_max_acceleration_e": [ + "6000", + "5000" + ], + "machine_max_acceleration_extruding": [ + "5000", + "2500" + ], + "machine_max_acceleration_retracting": [ + "5000", + "5000" + ], + "machine_max_acceleration_travel": [ + "5000", + "2500" + ], + "machine_max_acceleration_x": [ + "5000", + "2500" + ], + "machine_max_acceleration_y": [ + "5000", + "2500" + ], + "machine_max_acceleration_z": [ + "100", + "50" + ], + "machine_max_jerk_e": [ + "2.5", + "1.25" + ], + "machine_max_jerk_x": [ + "5", + "2.5" + ], + "machine_max_jerk_y": [ + "5", + "2.5" + ], + "machine_max_jerk_z": [ + "0.4", + "0.2" + ], + "machine_max_junction_deviation": [ + "0", + "0" + ], + "machine_max_speed_e": [ + "120", + "60" + ], + "machine_max_speed_x": [ + "300", + "150" + ], + "machine_max_speed_y": [ + "300", + "150" + ], + "machine_max_speed_z": [ + "40", + "20" + ], + "machine_start_gcode": "M104 S[first_layer_temperature] T[initial_tool]\nM140 S[first_layer_bed_temperature] ; set bed temp\nM190 S[first_layer_bed_temperature] ; wait for bed temp\nG4 P10000 ; wait for temp stabilisation\nstart_print \nTool[initial_tool]\nG90\nM109 S[first_layer_temperature] T[initial_tool]\nBrush_Tool[initial_tool]\nG1 Z5.0 F3000 \nG1 X100 Y1.0 Z0.38 F5000.0 \nG1 X280 Y1.0 Z0.38 F1500.0 E15 \nG1 X280 Y1.4 Z0.38 F5000.0 \nG1 X100 Y1.4 Z0.38 F1500.0 E30\nG92 E0\nG1 Z5.0 F3000\nSKEW_PROFILE LOAD=SKEW1\n", + "max_layer_height": [ + "0.45" + ], + "min_layer_height": [ + "0.12" + ], + "nozzle_diameter": [ + "0.6" + ], + "printable_height": "401", + "printer_extruder_id": [ + "1" + ], + "printer_extruder_variant": [ + "Direct Drive High Flow" + ], + "printer_model": "Xplorer Single", + "printer_variant": "0.6", + "retract_before_wipe": [ + "70%" + ], + "retract_length_toolchange": [ + "2" + ], + "retract_lift_above": [ + "0.2" + ], + "retract_lift_below": [ + "400" + ], + "retract_lift_enforce": [ + "All Surfaces" + ], + "retract_restart_extra": [ + "0" + ], + "retract_restart_extra_toolchange": [ + "0" + ], + "retract_when_changing_layer": [ + "1" + ], + "retraction_distances_when_cut": [ + "18" + ], + "retraction_length": [ + "1.35" + ], + "retraction_minimum_travel": [ + "2" + ], + "retraction_speed": [ + "120" + ], + "travel_slope": [ + "1" + ], + "wipe": [ + "1" + ], + "wipe_distance": [ + "1" + ], + "z_hop": [ + "1" + ], + "z_hop_types": [ + "Normal Lift" + ] +} diff --git a/resources/profiles/Vivedino/machine/Xplorer Single 0.8 nozzle.json b/resources/profiles/Vivedino/machine/Xplorer Single 0.8 nozzle.json new file mode 100644 index 0000000000..76e26d31bf --- /dev/null +++ b/resources/profiles/Vivedino/machine/Xplorer Single 0.8 nozzle.json @@ -0,0 +1,167 @@ +{ + "type": "machine", + "name": "Xplorer Single 0.8 nozzle", + "inherits": "Xplorer common", + "from": "system", + "setting_id": "rGCuBhLqBfV0zFLJ", + "instantiation": "true", + "before_layer_change_gcode": ";BEFORE_LAYER_CHANGE\n;[layer_z]\n{if layer_num != 0}\nG92 E0\n{endif}", + "change_filament_gcode": "M600", + "default_nozzle_volume_type": [ + "High Flow" + ], + "default_print_profile": "0.40mm Standard @Xplorer 0.8", + "deretraction_speed": [ + "120" + ], + "extruder_colour": [ + "#FCE94F" + ], + "extruder_offset": [ + "0x0" + ], + "extruder_type": [ + "Direct Drive" + ], + "extruder_variant_list": [ + "Direct Drive High Flow" + ], + "layer_change_gcode": ";AFTER_LAYER_CHANGE\n;[layer_z]\nLOG_RESUME\nLOG_EXTR", + "long_retractions_when_cut": [ + "0" + ], + "machine_max_acceleration_e": [ + "6000", + "5000" + ], + "machine_max_acceleration_extruding": [ + "5000", + "2500" + ], + "machine_max_acceleration_retracting": [ + "5000", + "5000" + ], + "machine_max_acceleration_travel": [ + "5000", + "2500" + ], + "machine_max_acceleration_x": [ + "5000", + "2500" + ], + "machine_max_acceleration_y": [ + "5000", + "2500" + ], + "machine_max_acceleration_z": [ + "100", + "50" + ], + "machine_max_jerk_e": [ + "2.5", + "1.25" + ], + "machine_max_jerk_x": [ + "5", + "2.5" + ], + "machine_max_jerk_y": [ + "5", + "2.5" + ], + "machine_max_jerk_z": [ + "0.4", + "0.2" + ], + "machine_max_junction_deviation": [ + "0", + "0" + ], + "machine_max_speed_e": [ + "120", + "60" + ], + "machine_max_speed_x": [ + "300", + "150" + ], + "machine_max_speed_y": [ + "300", + "150" + ], + "machine_max_speed_z": [ + "40", + "20" + ], + "machine_start_gcode": "M104 S[first_layer_temperature] T[initial_tool]\nM140 S[first_layer_bed_temperature] ; set bed temp\nM190 S[first_layer_bed_temperature] ; wait for bed temp\nG4 P10000 ; wait for temp stabilisation\nstart_print \nTool[initial_tool]\nG90\nM109 S[first_layer_temperature] T[initial_tool]\nBrush_Tool[initial_tool]\nG1 Z5.0 F3000 \nG1 X100 Y1.0 Z0.38 F5000.0 \nG1 X280 Y1.0 Z0.38 F1500.0 E15 \nG1 X280 Y1.4 Z0.38 F5000.0 \nG1 X100 Y1.4 Z0.38 F1500.0 E30\nG92 E0\nG1 Z5.0 F3000\nSKEW_PROFILE LOAD=SKEW1\n", + "max_layer_height": [ + "0.64" + ], + "min_layer_height": [ + "0.2" + ], + "nozzle_diameter": [ + "0.8" + ], + "printable_height": "401", + "printer_extruder_id": [ + "1" + ], + "printer_extruder_variant": [ + "Direct Drive High Flow" + ], + "printer_model": "Xplorer Single", + "printer_variant": "0.8", + "retract_before_wipe": [ + "70%" + ], + "retract_length_toolchange": [ + "2" + ], + "retract_lift_above": [ + "0.2" + ], + "retract_lift_below": [ + "400" + ], + "retract_lift_enforce": [ + "All Surfaces" + ], + "retract_restart_extra": [ + "0" + ], + "retract_restart_extra_toolchange": [ + "0" + ], + "retract_when_changing_layer": [ + "1" + ], + "retraction_distances_when_cut": [ + "18" + ], + "retraction_length": [ + "1.35" + ], + "retraction_minimum_travel": [ + "2" + ], + "retraction_speed": [ + "120" + ], + "travel_slope": [ + "1" + ], + "wipe": [ + "1" + ], + "wipe_distance": [ + "1" + ], + "z_hop": [ + "1" + ], + "z_hop_types": [ + "Normal Lift" + ] +} diff --git a/resources/profiles/Vivedino/machine/Xplorer common.json b/resources/profiles/Vivedino/machine/Xplorer common.json new file mode 100644 index 0000000000..c7b2054c7b --- /dev/null +++ b/resources/profiles/Vivedino/machine/Xplorer common.json @@ -0,0 +1,51 @@ +{ + "type": "machine", + "name": "Xplorer common", + "inherits": "fdm_klipper_common", + "from": "system", + "instantiation": "false", + "default_filament_profile": [ + "Generic PLA @System" + ], + "adaptive_bed_mesh_margin": "0", + "bed_mesh_max": "99999,99999", + "bed_mesh_min": "-99999,-99999", + "bed_mesh_probe_distance": "50,50", + "best_object_pos": "0.5,0.5", + "disable_m73": "0", + "emit_machine_limits_to_gcode": "0", + "enable_long_retraction_when_cut": "0", + "extruder_clearance_height_to_rod": "43", + "extruder_clearance_radius": "70", + "fan_kickstart": "0", + "fan_speedup_overhangs": "1", + "fan_speedup_time": "0", + "head_wrap_detect_zone": [], + "machine_end_gcode": "end_print", + "max_resonance_avoidance_speed": "120", + "min_resonance_avoidance_speed": "70", + "nozzle_height": "2.5", + "nozzle_hrc": "0", + "nozzle_type": "hardened_steel", + "nozzle_volume": "0", + "pellet_modded_printer": "0", + "preferred_orientation": "0", + "printable_area": [ + "10x0", + "410x0", + "410x400", + "10x400" + ], + "purge_in_prime_tower": "1", + "resonance_avoidance": "0", + "single_extruder_multi_material": "0", + "support_air_filtration": "1", + "support_chamber_temp_control": "1", + "support_multi_bed_types": "0", + "thumbnails": "48x48/PNG, 300x300/PNG", + "thumbnails_format": "PNG", + "time_cost": "0", + "use_firmware_retraction": "0", + "use_relative_e_distances": "1", + "z_offset": "0" +} diff --git a/resources/profiles/Vivedino/machine/XplorerDualGantry.json b/resources/profiles/Vivedino/machine/XplorerDualGantry.json new file mode 100644 index 0000000000..bf57b9137b --- /dev/null +++ b/resources/profiles/Vivedino/machine/XplorerDualGantry.json @@ -0,0 +1,12 @@ +{ + "type": "machine_model", + "name": "Xplorer Dual Gantry", + "model_id": "XplorerDualGantry", + "nozzle_diameter": "0.4;0.6;0.8", + "machine_tech": "FFF", + "family": "Vivedino", + "bed_model": "Xplorer_buildplate_model.stl", + "bed_texture": "Xplorer_bed_texture_dualgantry.svg", + "hotend_model": "", + "default_materials": "Generic ABS @System;Generic PLA @System;Generic PLA-CF @System;Generic PETG @System;Generic TPU @System;Generic ASA @System;Generic PC @System;Generic PVA @System;Generic PA @System;Generic PA-CF @System" +} diff --git a/resources/profiles/Vivedino/machine/XplorerIDEX.json b/resources/profiles/Vivedino/machine/XplorerIDEX.json new file mode 100644 index 0000000000..ffa5eee149 --- /dev/null +++ b/resources/profiles/Vivedino/machine/XplorerIDEX.json @@ -0,0 +1,12 @@ +{ + "type": "machine_model", + "name": "Xplorer IDEX", + "model_id": "XplorerIDEX", + "nozzle_diameter": "0.4;0.6;0.8", + "machine_tech": "FFF", + "family": "Vivedino", + "bed_model": "Xplorer_buildplate_model.stl", + "bed_texture": "Xplorer_bed_texture_idex.svg", + "hotend_model": "", + "default_materials": "Generic ABS @System;Generic PLA @System;Generic PLA-CF @System;Generic PETG @System;Generic TPU @System;Generic ASA @System;Generic PC @System;Generic PVA @System;Generic PA @System;Generic PA-CF @System" +} diff --git a/resources/profiles/Vivedino/machine/XplorerIQEX.json b/resources/profiles/Vivedino/machine/XplorerIQEX.json new file mode 100644 index 0000000000..95a45c221d --- /dev/null +++ b/resources/profiles/Vivedino/machine/XplorerIQEX.json @@ -0,0 +1,12 @@ +{ + "type": "machine_model", + "name": "Xplorer IQEX", + "model_id": "XplorerIQEX", + "nozzle_diameter": "0.4;0.6;0.8", + "machine_tech": "FFF", + "family": "Vivedino", + "bed_model": "Xplorer_buildplate_model.stl", + "bed_texture": "Xplorer_bed_texture_iqex.svg", + "hotend_model": "", + "default_materials": "Generic ABS @System;Generic PLA @System;Generic PLA-CF @System;Generic PETG @System;Generic TPU @System;Generic ASA @System;Generic PC @System;Generic PVA @System;Generic PA @System;Generic PA-CF @System" +} diff --git a/resources/profiles/Vivedino/machine/XplorerSingle.json b/resources/profiles/Vivedino/machine/XplorerSingle.json new file mode 100644 index 0000000000..d3ad22af7a --- /dev/null +++ b/resources/profiles/Vivedino/machine/XplorerSingle.json @@ -0,0 +1,12 @@ +{ + "type": "machine_model", + "name": "Xplorer Single", + "model_id": "XplorerSingle", + "nozzle_diameter": "0.4;0.6;0.8", + "machine_tech": "FFF", + "family": "Vivedino", + "bed_model": "Xplorer_buildplate_model.stl", + "bed_texture": "", + "hotend_model": "", + "default_materials": "Generic ABS @System;Generic PLA @System;Generic PLA-CF @System;Generic PETG @System;Generic TPU @System;Generic ASA @System;Generic PC @System;Generic PVA @System;Generic PA @System;Generic PA-CF @System" +} diff --git a/resources/profiles/Vivedino/process/0.04mm Ultra Fine @Xplorer 0.4.json b/resources/profiles/Vivedino/process/0.04mm Ultra Fine @Xplorer 0.4.json new file mode 100644 index 0000000000..22e6bd1a33 --- /dev/null +++ b/resources/profiles/Vivedino/process/0.04mm Ultra Fine @Xplorer 0.4.json @@ -0,0 +1,28 @@ +{ + "type": "process", + "name": "0.04mm Ultra Fine @Xplorer 0.4", + "inherits": "fdm_process_xplorer_common", + "from": "system", + "setting_id": "7SZOHFM1nfZsGYHe", + "instantiation": "true", + "bottom_shell_layers": "7", + "initial_layer_print_height": "0.3", + "inner_wall_speed": "190", + "internal_solid_infill_acceleration": "100%", + "min_skirt_length": "0", + "outer_wall_speed": "130", + "skirt_distance": "3", + "skirt_loops": "1", + "slowdown_for_curled_perimeters": "1", + "small_perimeter_speed": "50%", + "sparse_infill_acceleration": "100%", + "top_shell_layers": "9", + "top_solid_infill_flow_ratio": "1", + "layer_height": "0.04", + "compatible_printers": [ + "Xplorer Single 0.4 nozzle", + "Xplorer IDEX 0.4 nozzle", + "Xplorer Dual Gantry 0.4 nozzle", + "Xplorer IQEX 0.4 nozzle" + ] +} diff --git a/resources/profiles/Vivedino/process/0.08mm Extra Fine @Xplorer 0.4.json b/resources/profiles/Vivedino/process/0.08mm Extra Fine @Xplorer 0.4.json new file mode 100644 index 0000000000..f272186218 --- /dev/null +++ b/resources/profiles/Vivedino/process/0.08mm Extra Fine @Xplorer 0.4.json @@ -0,0 +1,28 @@ +{ + "type": "process", + "name": "0.08mm Extra Fine @Xplorer 0.4", + "inherits": "fdm_process_xplorer_common", + "from": "system", + "setting_id": "OvD7mwbTHKOUsgiT", + "instantiation": "true", + "bottom_shell_layers": "4", + "initial_layer_print_height": "0.3", + "inner_wall_speed": "190", + "internal_solid_infill_acceleration": "100%", + "min_skirt_length": "0", + "outer_wall_speed": "130", + "skirt_distance": "3", + "skirt_loops": "1", + "slowdown_for_curled_perimeters": "1", + "small_perimeter_speed": "50%", + "sparse_infill_acceleration": "100%", + "top_shell_layers": "5", + "top_solid_infill_flow_ratio": "0.87", + "layer_height": "0.08", + "compatible_printers": [ + "Xplorer Single 0.4 nozzle", + "Xplorer IDEX 0.4 nozzle", + "Xplorer Dual Gantry 0.4 nozzle", + "Xplorer IQEX 0.4 nozzle" + ] +} diff --git a/resources/profiles/Vivedino/process/0.12mm Fine @Xplorer 0.4.json b/resources/profiles/Vivedino/process/0.12mm Fine @Xplorer 0.4.json new file mode 100644 index 0000000000..fc742230b6 --- /dev/null +++ b/resources/profiles/Vivedino/process/0.12mm Fine @Xplorer 0.4.json @@ -0,0 +1,28 @@ +{ + "type": "process", + "name": "0.12mm Fine @Xplorer 0.4", + "inherits": "fdm_process_xplorer_common", + "from": "system", + "setting_id": "9iNid6X4xIPh267R", + "instantiation": "true", + "bottom_shell_layers": "4", + "initial_layer_print_height": "0.3", + "inner_wall_speed": "190", + "internal_solid_infill_acceleration": "100%", + "min_skirt_length": "0", + "outer_wall_speed": "130", + "skirt_distance": "3", + "skirt_loops": "1", + "slowdown_for_curled_perimeters": "1", + "small_perimeter_speed": "50%", + "sparse_infill_acceleration": "100%", + "top_shell_layers": "5", + "top_solid_infill_flow_ratio": "0.87", + "layer_height": "0.12", + "compatible_printers": [ + "Xplorer Single 0.4 nozzle", + "Xplorer IDEX 0.4 nozzle", + "Xplorer Dual Gantry 0.4 nozzle", + "Xplorer IQEX 0.4 nozzle" + ] +} diff --git a/resources/profiles/Vivedino/process/0.16mm Optimal @Xplorer 0.4.json b/resources/profiles/Vivedino/process/0.16mm Optimal @Xplorer 0.4.json new file mode 100644 index 0000000000..27a67f5a48 --- /dev/null +++ b/resources/profiles/Vivedino/process/0.16mm Optimal @Xplorer 0.4.json @@ -0,0 +1,28 @@ +{ + "type": "process", + "name": "0.16mm Optimal @Xplorer 0.4", + "inherits": "fdm_process_xplorer_common", + "from": "system", + "setting_id": "6eUjAhfHVuxRSCQr", + "instantiation": "true", + "bottom_shell_layers": "4", + "initial_layer_print_height": "0.3", + "inner_wall_speed": "190", + "internal_solid_infill_acceleration": "100%", + "min_skirt_length": "0", + "outer_wall_speed": "130", + "skirt_distance": "3", + "skirt_loops": "1", + "slowdown_for_curled_perimeters": "1", + "small_perimeter_speed": "50%", + "sparse_infill_acceleration": "100%", + "top_shell_layers": "5", + "top_solid_infill_flow_ratio": "0.87", + "layer_height": "0.16", + "compatible_printers": [ + "Xplorer Single 0.4 nozzle", + "Xplorer IDEX 0.4 nozzle", + "Xplorer Dual Gantry 0.4 nozzle", + "Xplorer IQEX 0.4 nozzle" + ] +} diff --git a/resources/profiles/Vivedino/process/0.18mm Fine @Xplorer 0.6.json b/resources/profiles/Vivedino/process/0.18mm Fine @Xplorer 0.6.json new file mode 100644 index 0000000000..8013e9a901 --- /dev/null +++ b/resources/profiles/Vivedino/process/0.18mm Fine @Xplorer 0.6.json @@ -0,0 +1,28 @@ +{ + "type": "process", + "name": "0.18mm Fine @Xplorer 0.6", + "inherits": "fdm_process_xplorer_common_0_6", + "from": "system", + "setting_id": "tQHKzSbBNyoPQkup", + "instantiation": "true", + "bottom_shell_layers": "4", + "initial_layer_print_height": "0.3", + "inner_wall_speed": "190", + "internal_solid_infill_acceleration": "100%", + "min_skirt_length": "0", + "outer_wall_speed": "130", + "skirt_distance": "3", + "skirt_loops": "1", + "slowdown_for_curled_perimeters": "1", + "small_perimeter_speed": "50%", + "sparse_infill_acceleration": "100%", + "top_shell_layers": "5", + "top_solid_infill_flow_ratio": "0.87", + "layer_height": "0.18", + "compatible_printers": [ + "Xplorer Single 0.6 nozzle", + "Xplorer IDEX 0.6 nozzle", + "Xplorer Dual Gantry 0.6 nozzle", + "Xplorer IQEX 0.6 nozzle" + ] +} diff --git a/resources/profiles/Vivedino/process/0.20mm Standard @Xplorer 0.4.json b/resources/profiles/Vivedino/process/0.20mm Standard @Xplorer 0.4.json new file mode 100644 index 0000000000..d880d2ba5a --- /dev/null +++ b/resources/profiles/Vivedino/process/0.20mm Standard @Xplorer 0.4.json @@ -0,0 +1,26 @@ +{ + "type": "process", + "name": "0.20mm Standard @Xplorer 0.4", + "inherits": "fdm_process_xplorer_common", + "from": "system", + "setting_id": "CiJAkwpq5pR6MfYD", + "instantiation": "true", + "initial_layer_print_height": "0.3", + "inner_wall_speed": "190", + "internal_solid_infill_acceleration": "100%", + "min_skirt_length": "10", + "outer_wall_speed": "130", + "skirt_loops": "2", + "slowdown_for_curled_perimeters": "1", + "small_perimeter_speed": "50%", + "sparse_infill_acceleration": "100%", + "top_shell_layers": "4", + "top_solid_infill_flow_ratio": "0.87", + "layer_height": "0.20", + "compatible_printers": [ + "Xplorer Single 0.4 nozzle", + "Xplorer IDEX 0.4 nozzle", + "Xplorer Dual Gantry 0.4 nozzle", + "Xplorer IQEX 0.4 nozzle" + ] +} diff --git a/resources/profiles/Vivedino/process/0.24mm Fine @Xplorer 0.8.json b/resources/profiles/Vivedino/process/0.24mm Fine @Xplorer 0.8.json new file mode 100644 index 0000000000..cd2be9355d --- /dev/null +++ b/resources/profiles/Vivedino/process/0.24mm Fine @Xplorer 0.8.json @@ -0,0 +1,26 @@ +{ + "type": "process", + "name": "0.24mm Fine @Xplorer 0.8", + "inherits": "fdm_process_xplorer_common_0_8", + "from": "system", + "setting_id": "yv9TMduh7bgD1IjO", + "instantiation": "true", + "initial_layer_print_height": "0.3", + "inner_wall_speed": "190", + "internal_solid_infill_acceleration": "100%", + "min_skirt_length": "10", + "outer_wall_speed": "130", + "skirt_loops": "2", + "slowdown_for_curled_perimeters": "1", + "small_perimeter_speed": "50%", + "sparse_infill_acceleration": "100%", + "top_shell_layers": "4", + "top_solid_infill_flow_ratio": "0.87", + "layer_height": "0.24", + "compatible_printers": [ + "Xplorer Single 0.8 nozzle", + "Xplorer IDEX 0.8 nozzle", + "Xplorer Dual Gantry 0.8 nozzle", + "Xplorer IQEX 0.8 nozzle" + ] +} diff --git a/resources/profiles/Vivedino/process/0.24mm Optimal @Xplorer 0.6.json b/resources/profiles/Vivedino/process/0.24mm Optimal @Xplorer 0.6.json new file mode 100644 index 0000000000..d628e1a121 --- /dev/null +++ b/resources/profiles/Vivedino/process/0.24mm Optimal @Xplorer 0.6.json @@ -0,0 +1,26 @@ +{ + "type": "process", + "name": "0.24mm Optimal @Xplorer 0.6", + "inherits": "fdm_process_xplorer_common_0_6", + "from": "system", + "setting_id": "WKjOeNEZndeHrbQw", + "instantiation": "true", + "initial_layer_print_height": "0.3", + "inner_wall_speed": "190", + "internal_solid_infill_acceleration": "100%", + "min_skirt_length": "10", + "outer_wall_speed": "130", + "skirt_loops": "2", + "slowdown_for_curled_perimeters": "1", + "small_perimeter_speed": "50%", + "sparse_infill_acceleration": "100%", + "top_shell_layers": "4", + "top_solid_infill_flow_ratio": "0.87", + "layer_height": "0.24", + "compatible_printers": [ + "Xplorer Single 0.6 nozzle", + "Xplorer IDEX 0.6 nozzle", + "Xplorer Dual Gantry 0.6 nozzle", + "Xplorer IQEX 0.6 nozzle" + ] +} diff --git a/resources/profiles/Vivedino/process/0.28mm Draft @Xplorer 0.4.json b/resources/profiles/Vivedino/process/0.28mm Draft @Xplorer 0.4.json new file mode 100644 index 0000000000..eaad4d5f45 --- /dev/null +++ b/resources/profiles/Vivedino/process/0.28mm Draft @Xplorer 0.4.json @@ -0,0 +1,26 @@ +{ + "type": "process", + "name": "0.28mm Draft @Xplorer 0.4", + "inherits": "fdm_process_xplorer_common", + "from": "system", + "setting_id": "zjvcZ59oVnh73fij", + "instantiation": "true", + "initial_layer_print_height": "0.32", + "inner_wall_speed": "190", + "internal_solid_infill_acceleration": "100%", + "min_skirt_length": "0", + "outer_wall_speed": "130", + "skirt_loops": "1", + "slowdown_for_curled_perimeters": "0", + "small_perimeter_speed": "50%", + "sparse_infill_acceleration": "100%", + "top_shell_layers": "4", + "top_solid_infill_flow_ratio": "0.87", + "layer_height": "0.28", + "compatible_printers": [ + "Xplorer Single 0.4 nozzle", + "Xplorer IDEX 0.4 nozzle", + "Xplorer Dual Gantry 0.4 nozzle", + "Xplorer IQEX 0.4 nozzle" + ] +} diff --git a/resources/profiles/Vivedino/process/0.30mm Standard @Xplorer 0.6.json b/resources/profiles/Vivedino/process/0.30mm Standard @Xplorer 0.6.json new file mode 100644 index 0000000000..4afd997862 --- /dev/null +++ b/resources/profiles/Vivedino/process/0.30mm Standard @Xplorer 0.6.json @@ -0,0 +1,26 @@ +{ + "type": "process", + "name": "0.30mm Standard @Xplorer 0.6", + "inherits": "fdm_process_xplorer_common_0_6", + "from": "system", + "setting_id": "1NEAZrryKhhZNWoB", + "instantiation": "true", + "initial_layer_print_height": "0.32", + "inner_wall_speed": "190", + "internal_solid_infill_acceleration": "100%", + "min_skirt_length": "0", + "outer_wall_speed": "130", + "skirt_loops": "1", + "slowdown_for_curled_perimeters": "0", + "small_perimeter_speed": "50%", + "sparse_infill_acceleration": "100%", + "top_shell_layers": "4", + "top_solid_infill_flow_ratio": "0.87", + "layer_height": "0.30", + "compatible_printers": [ + "Xplorer Single 0.6 nozzle", + "Xplorer IDEX 0.6 nozzle", + "Xplorer Dual Gantry 0.6 nozzle", + "Xplorer IQEX 0.6 nozzle" + ] +} diff --git a/resources/profiles/Vivedino/process/0.32mm Extra Draft @Xplorer 0.4.json b/resources/profiles/Vivedino/process/0.32mm Extra Draft @Xplorer 0.4.json new file mode 100644 index 0000000000..21ac5b0f55 --- /dev/null +++ b/resources/profiles/Vivedino/process/0.32mm Extra Draft @Xplorer 0.4.json @@ -0,0 +1,27 @@ +{ + "type": "process", + "name": "0.32mm Extra Draft @Xplorer 0.4", + "inherits": "fdm_process_xplorer_common", + "from": "system", + "setting_id": "IrvXoa4zZ1b2Y5cX", + "instantiation": "true", + "initial_layer_print_height": "0.32", + "inner_wall_speed": "190", + "internal_solid_infill_acceleration": "100%", + "min_skirt_length": "0", + "outer_wall_speed": "130", + "skirt_loops": "1", + "slowdown_for_curled_perimeters": "0", + "small_perimeter_speed": "50%", + "sparse_infill_acceleration": "100%", + "top_shell_layers": "4", + "top_solid_infill_flow_ratio": "0.87", + "top_surface_line_width": "0.45", + "layer_height": "0.32", + "compatible_printers": [ + "Xplorer Single 0.4 nozzle", + "Xplorer IDEX 0.4 nozzle", + "Xplorer Dual Gantry 0.4 nozzle", + "Xplorer IQEX 0.4 nozzle" + ] +} diff --git a/resources/profiles/Vivedino/process/0.32mm Optimal @Xplorer 0.8.json b/resources/profiles/Vivedino/process/0.32mm Optimal @Xplorer 0.8.json new file mode 100644 index 0000000000..57571e080b --- /dev/null +++ b/resources/profiles/Vivedino/process/0.32mm Optimal @Xplorer 0.8.json @@ -0,0 +1,26 @@ +{ + "type": "process", + "name": "0.32mm Optimal @Xplorer 0.8", + "inherits": "fdm_process_xplorer_common_0_8", + "from": "system", + "setting_id": "N3z9IBRaaeXwVLbl", + "instantiation": "true", + "initial_layer_print_height": "0.32", + "inner_wall_speed": "190", + "internal_solid_infill_acceleration": "100%", + "min_skirt_length": "0", + "outer_wall_speed": "130", + "skirt_loops": "1", + "slowdown_for_curled_perimeters": "0", + "small_perimeter_speed": "50%", + "sparse_infill_acceleration": "100%", + "top_shell_layers": "4", + "top_solid_infill_flow_ratio": "0.87", + "layer_height": "0.32", + "compatible_printers": [ + "Xplorer Single 0.8 nozzle", + "Xplorer IDEX 0.8 nozzle", + "Xplorer Dual Gantry 0.8 nozzle", + "Xplorer IQEX 0.8 nozzle" + ] +} diff --git a/resources/profiles/Vivedino/process/0.36mm Draft @Xplorer 0.6.json b/resources/profiles/Vivedino/process/0.36mm Draft @Xplorer 0.6.json new file mode 100644 index 0000000000..73783a0e5f --- /dev/null +++ b/resources/profiles/Vivedino/process/0.36mm Draft @Xplorer 0.6.json @@ -0,0 +1,26 @@ +{ + "type": "process", + "name": "0.36mm Draft @Xplorer 0.6", + "inherits": "fdm_process_xplorer_common_0_6", + "from": "system", + "setting_id": "uv3jUBdJRkJ4dWVd", + "instantiation": "true", + "initial_layer_print_height": "0.32", + "inner_wall_speed": "190", + "internal_solid_infill_acceleration": "100%", + "min_skirt_length": "0", + "outer_wall_speed": "130", + "skirt_loops": "1", + "slowdown_for_curled_perimeters": "0", + "small_perimeter_speed": "50%", + "sparse_infill_acceleration": "100%", + "top_shell_layers": "4", + "top_solid_infill_flow_ratio": "0.87", + "layer_height": "0.36", + "compatible_printers": [ + "Xplorer Single 0.6 nozzle", + "Xplorer IDEX 0.6 nozzle", + "Xplorer Dual Gantry 0.6 nozzle", + "Xplorer IQEX 0.6 nozzle" + ] +} diff --git a/resources/profiles/Vivedino/process/0.40mm Standard @Xplorer 0.8.json b/resources/profiles/Vivedino/process/0.40mm Standard @Xplorer 0.8.json new file mode 100644 index 0000000000..2082554b0c --- /dev/null +++ b/resources/profiles/Vivedino/process/0.40mm Standard @Xplorer 0.8.json @@ -0,0 +1,26 @@ +{ + "type": "process", + "name": "0.40mm Standard @Xplorer 0.8", + "inherits": "fdm_process_xplorer_common_0_8", + "from": "system", + "setting_id": "ZP9WXfiKDvSAjqU2", + "instantiation": "true", + "initial_layer_print_height": "0.32", + "inner_wall_speed": "190", + "internal_solid_infill_acceleration": "100%", + "min_skirt_length": "0", + "outer_wall_speed": "130", + "skirt_loops": "1", + "slowdown_for_curled_perimeters": "0", + "small_perimeter_speed": "50%", + "sparse_infill_acceleration": "100%", + "top_shell_layers": "4", + "top_solid_infill_flow_ratio": "0.87", + "layer_height": "0.40", + "compatible_printers": [ + "Xplorer Single 0.8 nozzle", + "Xplorer IDEX 0.8 nozzle", + "Xplorer Dual Gantry 0.8 nozzle", + "Xplorer IQEX 0.8 nozzle" + ] +} diff --git a/resources/profiles/Vivedino/process/0.42mm Extra Draft @Xplorer 0.6.json b/resources/profiles/Vivedino/process/0.42mm Extra Draft @Xplorer 0.6.json new file mode 100644 index 0000000000..9641ffb5ef --- /dev/null +++ b/resources/profiles/Vivedino/process/0.42mm Extra Draft @Xplorer 0.6.json @@ -0,0 +1,26 @@ +{ + "type": "process", + "name": "0.42mm Extra Draft @Xplorer 0.6", + "inherits": "fdm_process_xplorer_common_0_6", + "from": "system", + "setting_id": "JMbldCAibS7WnG1D", + "instantiation": "true", + "initial_layer_print_height": "0.32", + "inner_wall_speed": "190", + "internal_solid_infill_acceleration": "100%", + "min_skirt_length": "0", + "outer_wall_speed": "130", + "skirt_loops": "1", + "slowdown_for_curled_perimeters": "0", + "small_perimeter_speed": "50%", + "sparse_infill_acceleration": "100%", + "top_shell_layers": "4", + "top_solid_infill_flow_ratio": "0.87", + "layer_height": "0.42", + "compatible_printers": [ + "Xplorer Single 0.6 nozzle", + "Xplorer IDEX 0.6 nozzle", + "Xplorer Dual Gantry 0.6 nozzle", + "Xplorer IQEX 0.6 nozzle" + ] +} diff --git a/resources/profiles/Vivedino/process/0.48mm Draft @Xplorer 0.8.json b/resources/profiles/Vivedino/process/0.48mm Draft @Xplorer 0.8.json new file mode 100644 index 0000000000..a7942c6387 --- /dev/null +++ b/resources/profiles/Vivedino/process/0.48mm Draft @Xplorer 0.8.json @@ -0,0 +1,26 @@ +{ + "type": "process", + "name": "0.48mm Draft @Xplorer 0.8", + "inherits": "fdm_process_xplorer_common_0_8", + "from": "system", + "setting_id": "OhseWRzfSjom4gC9", + "instantiation": "true", + "initial_layer_print_height": "0.32", + "inner_wall_speed": "190", + "internal_solid_infill_acceleration": "100%", + "min_skirt_length": "0", + "outer_wall_speed": "130", + "skirt_loops": "1", + "slowdown_for_curled_perimeters": "0", + "small_perimeter_speed": "50%", + "sparse_infill_acceleration": "100%", + "top_shell_layers": "4", + "top_solid_infill_flow_ratio": "0.87", + "layer_height": "0.48", + "compatible_printers": [ + "Xplorer Single 0.8 nozzle", + "Xplorer IDEX 0.8 nozzle", + "Xplorer Dual Gantry 0.8 nozzle", + "Xplorer IQEX 0.8 nozzle" + ] +} diff --git a/resources/profiles/Vivedino/process/0.56mm Extra Draft @Xplorer 0.8.json b/resources/profiles/Vivedino/process/0.56mm Extra Draft @Xplorer 0.8.json new file mode 100644 index 0000000000..065cbf975d --- /dev/null +++ b/resources/profiles/Vivedino/process/0.56mm Extra Draft @Xplorer 0.8.json @@ -0,0 +1,26 @@ +{ + "type": "process", + "name": "0.56mm Extra Draft @Xplorer 0.8", + "inherits": "fdm_process_xplorer_common_0_8", + "from": "system", + "setting_id": "EXEeYQRkvcwYeLDA", + "instantiation": "true", + "initial_layer_print_height": "0.32", + "inner_wall_speed": "190", + "internal_solid_infill_acceleration": "100%", + "min_skirt_length": "0", + "outer_wall_speed": "130", + "skirt_loops": "1", + "slowdown_for_curled_perimeters": "0", + "small_perimeter_speed": "50%", + "sparse_infill_acceleration": "100%", + "top_shell_layers": "4", + "top_solid_infill_flow_ratio": "0.87", + "layer_height": "0.56", + "compatible_printers": [ + "Xplorer Single 0.8 nozzle", + "Xplorer IDEX 0.8 nozzle", + "Xplorer Dual Gantry 0.8 nozzle", + "Xplorer IQEX 0.8 nozzle" + ] +} diff --git a/resources/profiles/Vivedino/process/fdm_process_xplorer_common.json b/resources/profiles/Vivedino/process/fdm_process_xplorer_common.json new file mode 100644 index 0000000000..c8f4cc55a7 --- /dev/null +++ b/resources/profiles/Vivedino/process/fdm_process_xplorer_common.json @@ -0,0 +1,265 @@ +{ + "type": "process", + "name": "fdm_process_xplorer_common", + "inherits": "fdm_process_klipper_common", + "from": "system", + "instantiation": "false", + "accel_to_decel_enable": "1", + "accel_to_decel_factor": "50%", + "align_infill_direction_to_model": "0", + "alternate_extra_wall": "0", + "bottom_shell_thickness": "1", + "bottom_solid_infill_flow_ratio": "1", + "bottom_surface_density": "100%", + "bottom_surface_pattern": "monotonicline", + "bridge_acceleration": "1500", + "bridge_angle": "0", + "bridge_density": "100%", + "bridge_flow": "1.5", + "brim_ears_detection_length": "1", + "brim_ears_max_angle": "125", + "brim_type": "no_brim", + "calib_flowrate_topinfill_special_order": "0", + "counterbore_hole_bridging": "none", + "default_acceleration": "5000", + "default_jerk": "5", + "default_junction_deviation": "0", + "detect_narrow_internal_solid_infill": "0", + "dont_filter_internal_bridges": "limited", + "elefant_foot_compensation": "0.1", + "elefant_foot_compensation_layers": "2", + "enable_extra_bridge_layer": "disabled", + "enable_overhang_speed": "1", + "enable_prime_tower": "0", + "enforce_support_layers": "0", + "ensure_vertical_shell_thickness": "ensure_moderate", + "extra_perimeters_on_overhangs": "1", + "extra_solid_infills": "", + "extrusion_rate_smoothing_external_perimeter_only": "0", + "fill_multiline": "1", + "filter_out_gap_fill": "2", + "flush_into_infill": "0", + "flush_into_objects": "0", + "flush_into_support": "1", + "fuzzy_skin": "none", + "fuzzy_skin_first_layer": "0", + "fuzzy_skin_mode": "displacement", + "fuzzy_skin_noise_type": "classic", + "fuzzy_skin_octaves": "4", + "fuzzy_skin_persistence": "0.5", + "fuzzy_skin_point_distance": "0.8", + "fuzzy_skin_scale": "1", + "fuzzy_skin_thickness": "0.3", + "gap_fill_target": "nowhere", + "gap_infill_speed": "10", + "gcode_add_line_number": "0", + "gcode_comments": "0", + "gcode_label_objects": "1", + "hole_to_polyhole": "0", + "hole_to_polyhole_threshold": "0.01", + "hole_to_polyhole_twisted": "1", + "independent_support_layer_height": "1", + "infill_anchor": "10", + "infill_anchor_max": "10", + "infill_combination_max_layer_height": "100%", + "infill_jerk": "0", + "infill_lock_depth": "1", + "infill_overhang_angle": "60", + "infill_shift_step": "0.4", + "infill_wall_overlap": "20%", + "initial_layer_acceleration": "1000", + "initial_layer_jerk": "0", + "initial_layer_line_width": "120%", + "initial_layer_min_bead_width": "85%", + "initial_layer_travel_speed": "200", + "inner_wall_acceleration": "3000", + "inner_wall_jerk": "0", + "inner_wall_line_width": "110%", + "interlocking_beam": "0", + "interlocking_beam_layer_count": "2", + "interlocking_beam_width": "0.8", + "interlocking_boundary_avoidance": "2", + "interlocking_depth": "2", + "interlocking_orientation": "22.5", + "internal_bridge_angle": "0", + "internal_bridge_density": "100%", + "internal_bridge_flow": "1.5", + "internal_bridge_speed": "150%", + "internal_solid_infill_line_width": "120%", + "internal_solid_infill_pattern": "monotonicline", + "ironing_angle": "-1", + "ironing_inset": "0", + "ironing_pattern": "rectilinear", + "is_infill_first": "0", + "lateral_lattice_angle_1": "-45", + "lateral_lattice_angle_2": "45", + "line_width": "110%", + "make_overhang_printable": "0", + "make_overhang_printable_angle": "55", + "make_overhang_printable_hole_size": "0", + "max_bridge_length": "10", + "max_volumetric_extrusion_rate_slope": "0", + "max_volumetric_extrusion_rate_slope_segment_length": "1", + "min_bead_width": "85%", + "min_feature_size": "25%", + "min_length_factor": "0.5", + "min_width_top_surface": "0", + "mmu_segmented_region_interlocking_depth": "0", + "mmu_segmented_region_max_width": "0", + "notes": "", + "only_one_wall_first_layer": "0", + "only_one_wall_top": "1", + "ooze_prevention": "0", + "outer_wall_acceleration": "1000", + "outer_wall_jerk": "0", + "outer_wall_line_width": "100%", + "overhang_1_4_speed": "100", + "overhang_reverse": "0", + "overhang_reverse_internal_only": "0", + "overhang_reverse_threshold": "50%", + "post_process": [], + "precise_outer_wall": "1", + "precise_z_height": "0", + "preheat_steps": "1", + "preheat_time": "10", + "prime_tower_brim_width": "10", + "prime_tower_width": "30", + "prime_volume": "10", + "print_flow_ratio": "1", + "print_order": "default", + "raft_contact_distance": "0.1", + "raft_expansion": "1.5", + "raft_first_layer_density": "90%", + "raft_first_layer_expansion": "2", + "reduce_crossing_wall": "1", + "role_based_wipe_speed": "1", + "scarf_angle_threshold": "155", + "scarf_joint_flow_ratio": "1", + "scarf_joint_speed": "100%", + "scarf_overhang_threshold": "40%", + "seam_gap": "10%", + "seam_slope_conditional": "0", + "seam_slope_entire_loop": "0", + "seam_slope_inner_walls": "0", + "seam_slope_min_length": "20", + "seam_slope_start_height": "0", + "seam_slope_steps": "10", + "seam_slope_type": "none", + "single_extruder_multi_material_priming": "0", + "single_loop_draft_shield": "0", + "skeleton_infill_density": "25%", + "skeleton_infill_line_width": "100%", + "skin_infill_density": "25%", + "skin_infill_depth": "2", + "skin_infill_line_width": "100%", + "skirt_speed": "50", + "skirt_start_angle": "-135", + "skirt_type": "combined", + "slice_closing_radius": "0.049", + "slicing_mode": "regular", + "slow_down_layers": "0", + "small_area_infill_flow_compensation": "1", + "small_area_infill_flow_compensation_model": [ + "0,0", + "\n0.2,0.4444", + "\n0.4,0.6145", + "\n0.6,0.7059", + "\n0.8,0.7619", + "\n1.5,0.8571", + "\n2,0.8889", + "\n3,0.9231", + "\n5,0.9520", + "\n10,1" + ], + "small_perimeter_threshold": "0", + "solid_infill_direction": "45", + "solid_infill_filament": "1", + "solid_infill_rotate_template": "45,135", + "sparse_infill_filament": "1", + "sparse_infill_line_width": "110%", + "sparse_infill_pattern": "gyroid", + "sparse_infill_rotate_template": "", + "spiral_finishing_flow_ratio": "0", + "spiral_mode_max_xy_smoothing": "200%", + "spiral_mode_smooth": "0", + "spiral_starting_flow_ratio": "0", + "staggered_inner_seams": "1", + "support_angle": "0", + "support_bottom_interface_spacing": "0.5", + "support_bottom_z_distance": "0.2", + "support_critical_regions_only": "0", + "support_expansion": "0", + "support_interface_not_for_body": "1", + "support_interface_pattern": "auto", + "support_ironing": "0", + "support_ironing_flow": "10%", + "support_ironing_pattern": "rectilinear", + "support_ironing_spacing": "0.1", + "support_line_width": "100%", + "support_object_first_layer_gap": "0.2", + "support_on_build_plate_only": "1", + "support_remove_small_overhang": "1", + "support_threshold_overlap": "50%", + "support_type": "tree(auto)", + "symmetric_infill_y_axis": "0", + "thick_bridges": "1", + "thick_internal_bridges": "1", + "timelapse_type": "0", + "top_bottom_infill_wall_overlap": "15%", + "top_surface_acceleration": "1000", + "top_surface_density": "100%", + "top_surface_jerk": "0", + "top_surface_line_width": "80%", + "top_surface_speed": "50", + "travel_acceleration": "5000", + "travel_jerk": "0", + "travel_speed": "250", + "travel_speed_z": "0", + "tree_support_adaptive_layer_height": "1", + "tree_support_angle_slow": "25", + "tree_support_auto_brim": "1", + "tree_support_branch_angle_organic": "40", + "tree_support_branch_diameter": "5", + "tree_support_branch_diameter_angle": "5", + "tree_support_branch_diameter_organic": "2", + "tree_support_branch_distance": "5", + "tree_support_branch_distance_organic": "1", + "tree_support_brim_width": "3", + "tree_support_tip_diameter": "0.8", + "tree_support_top_rate": "30%", + "wall_direction": "auto", + "wall_distribution_count": "1", + "wall_filament": "1", + "wall_generator": "arachne", + "wall_sequence": "inner wall/outer wall", + "wall_transition_angle": "10", + "wall_transition_filter_deviation": "25%", + "wall_transition_length": "100%", + "wipe_before_external_loop": "0", + "wipe_on_loops": "1", + "wipe_speed": "80%", + "wipe_tower_bridging": "10", + "wipe_tower_cone_angle": "10", + "wipe_tower_extra_flow": "100%", + "wipe_tower_extra_rib_length": "0", + "wipe_tower_extra_spacing": "100%", + "wipe_tower_filament": "0", + "wipe_tower_fillet_wall": "1", + "wipe_tower_max_purge_speed": "90", + "wipe_tower_rib_width": "8", + "wipe_tower_rotation_angle": "0", + "wipe_tower_wall_type": "rectangle", + "wiping_volumes_extruders": [ + "70", + "70", + "70", + "70", + "70", + "70", + "70", + "70", + "70", + "70" + ], + "xy_hole_compensation": "0.05" +} diff --git a/resources/profiles/Vivedino/process/fdm_process_xplorer_common_0_6.json b/resources/profiles/Vivedino/process/fdm_process_xplorer_common_0_6.json new file mode 100644 index 0000000000..3158e4f186 --- /dev/null +++ b/resources/profiles/Vivedino/process/fdm_process_xplorer_common_0_6.json @@ -0,0 +1,8 @@ +{ + "type": "process", + "name": "fdm_process_xplorer_common_0_6", + "inherits": "fdm_process_xplorer_common", + "from": "system", + "instantiation": "false", + "initial_layer_print_height": "0.3" +} diff --git a/resources/profiles/Vivedino/process/fdm_process_xplorer_common_0_8.json b/resources/profiles/Vivedino/process/fdm_process_xplorer_common_0_8.json new file mode 100644 index 0000000000..a6b36e2947 --- /dev/null +++ b/resources/profiles/Vivedino/process/fdm_process_xplorer_common_0_8.json @@ -0,0 +1,8 @@ +{ + "type": "process", + "name": "fdm_process_xplorer_common_0_8", + "inherits": "fdm_process_xplorer_common", + "from": "system", + "instantiation": "false", + "initial_layer_print_height": "0.4" +} diff --git a/src/libslic3r/CMakeLists.txt b/src/libslic3r/CMakeLists.txt index 1f9d4b7c53..16f08e6a56 100644 --- a/src/libslic3r/CMakeLists.txt +++ b/src/libslic3r/CMakeLists.txt @@ -325,6 +325,12 @@ set(lisbslic3r_sources Geometry/VoronoiUtils.cpp Geometry/VoronoiUtils.hpp Geometry/VoronoiVisualUtils.hpp + IMEXArrange.cpp + IMEXArrange.hpp + IMEXHelpers.cpp + IMEXHelpers.hpp + IMEXZones.cpp + IMEXZones.hpp InstanceLock.cpp InstanceLock.hpp Int128.hpp diff --git a/src/libslic3r/Format/bbs_3mf.cpp b/src/libslic3r/Format/bbs_3mf.cpp index 4ae316a055..8a22201c51 100644 --- a/src/libslic3r/Format/bbs_3mf.cpp +++ b/src/libslic3r/Format/bbs_3mf.cpp @@ -1,5 +1,6 @@ #include "../libslic3r.h" #include "../Exception.hpp" +#include "../IMEXHelpers.hpp" #include "../Model.hpp" #include "../Preset.hpp" #include "../Utils.hpp" @@ -368,6 +369,12 @@ static constexpr const char* FIRST_LAYER_PRINT_SEQUENCE_ATTR = "first_layer_prin static constexpr const char* OTHER_LAYERS_PRINT_SEQUENCE_ATTR = "other_layers_print_sequence"; static constexpr const char* OTHER_LAYERS_PRINT_SEQUENCE_NUMS_ATTR = "other_layers_print_sequence_nums"; static constexpr const char* SPIRAL_VASE_MODE = "spiral_mode"; +static constexpr const char* IMEX_PARALLEL_MODE_ATTR = "imex_parallel_mode"; +// The same attribute before the feature was renamed IMEX. Plate metadata is matched by exact +// string and written with set_key_value, so it never passes through handle_legacy: without this +// a project saved in that window loads every plate back on the Primary mode, silently. +static constexpr const char* IXEX_PARALLEL_MODE_ATTR_LEGACY = "ixex_parallel_mode"; +static constexpr const char* IMEX_HEAD_FILAMENT_MAP_ATTR = "imex_head_filament_map"; static constexpr const char* FILAMENT_MAP_MODE_ATTR = "filament_map_mode"; static constexpr const char* FILAMENT_MAP_ATTR = "filament_maps"; static constexpr const char* FILAMENT_VOL_MAP_ATTR = "filament_volume_maps"; @@ -4576,6 +4583,12 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) std::istringstream(value) >> std::boolalpha >> spiral_mode; m_curr_plater->config.set_key_value("spiral_mode", new ConfigOptionBool(spiral_mode)); } + else if (key == IMEX_PARALLEL_MODE_ATTR || key == IXEX_PARALLEL_MODE_ATTR_LEGACY) { + m_curr_plater->config.set_key_value("imex_parallel_mode", new ConfigOptionString(value)); + } + else if (key == IMEX_HEAD_FILAMENT_MAP_ATTR) { + m_curr_plater->config.set_key_value("imex_head_filament_map", new ConfigOptionString(value)); + } else if (key == FILAMENT_MAP_MODE_ATTR) { FilamentMapMode map_mode = FilamentMapMode::fmmAutoForFlush; @@ -8277,6 +8290,23 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) if (spiral_mode_opt) stream << " <" << METADATA_TAG << " " << KEY_ATTR << "=\"" << SPIRAL_VASE_MODE << "\" " << VALUE_ATTR << "=\"" << spiral_mode_opt->getBool() << "\"/>\n"; + { + // Mode names are user supplied free text, so the value has to be escaped for an + // attribute. xml_escape_double_quotes_attribute_value() is used rather than + // xml_escape() because it also emits tab/CR/LF as numeric character references: + // XML normalizes literal whitespace in attribute values on read, which would + // silently rename the mode. The reader takes the value straight from expat, + // which resolves both entities and character references, so this round-trips. + auto* imex_mode_opt = plate_data->config.option("imex_parallel_mode"); + if (imex_mode_opt && !imex_mode_opt->value.empty() && imex_mode_opt->value != kImexPrimaryMode) + stream << " <" << METADATA_TAG << " " << KEY_ATTR << "=\"" << IMEX_PARALLEL_MODE_ATTR << "\" " << VALUE_ATTR << "=\"" << xml_escape_double_quotes_attribute_value(imex_mode_opt->value) << "\"/>\n"; + } + { + auto* imex_hfm_opt = plate_data->config.option("imex_head_filament_map"); + if (imex_hfm_opt && !imex_hfm_opt->value.empty()) + stream << " <" << METADATA_TAG << " " << KEY_ATTR << "=\"" << IMEX_HEAD_FILAMENT_MAP_ATTR << "\" " << VALUE_ATTR << "=\"" << xml_escape_double_quotes_attribute_value(imex_hfm_opt->value) << "\"/>\n"; + } + //filament map related ConfigOption* filament_map_mode_opt = plate_data->config.option("filament_map_mode"); t_config_enum_names filament_map_mode_names = ConfigOptionEnum::get_enum_names(); diff --git a/src/libslic3r/GCode.cpp b/src/libslic3r/GCode.cpp index accb67b3da..3c6fcee856 100644 --- a/src/libslic3r/GCode.cpp +++ b/src/libslic3r/GCode.cpp @@ -29,6 +29,7 @@ #include "enum_bitmask.hpp" #include "libslic3r.h" #include "I18N.hpp" +#include "IMEXHelpers.hpp" #include "GCode.hpp" #include #include "Exception.hpp" @@ -87,6 +88,7 @@ #include #include #include +#include #include #include @@ -1710,7 +1712,16 @@ static std::vector get_path_of_change_filament(const Print& print) : gcodegen.config().nozzle_temperature.get_at(new_fi); if (std::abs(tcr.print_z) < EPSILON) base_temp = gcodegen.config().nozzle_temperature_initial_layer.get_at(new_fi); - const std::string t_token = " T" + std::to_string(new_extruder_id); + // This pass strips the M109 that OozePrevention::post_toolchange just emitted into + // toolchange_gcode_str, so it must look for the tool index that emission actually + // wrote -- physical, translated by GCodeWriter::set_temperature -- not the logical + // id the temperature lookups above use. Matching on the logical id would leave the + // blocking M109 in place and silently defeat the tower interface temperature. + // (The sibling scan further down reads WipeTower2 output, whose set_extruder_temp + // emits no T at all, so it is deliberately left alone.) + const int heater_id = imex_physical_heater_for( + gcodegen.config().is_imex.value, gcodegen.config().physical_extruder_map, new_extruder_id); + const std::string t_token = " T" + std::to_string(heater_id); std::string out; out.reserve(toolchange_gcode_str.size()); size_t pos = 0; @@ -1731,7 +1742,7 @@ static std::vector get_path_of_change_filament(const Print& print) const std::string t_val = trimmed.substr(t_pos + 1, t_end == std::string::npos ? std::string::npos : t_end - (t_pos + 1)); if (!t_val.empty()) { try { - matches_extruder = std::stoi(t_val) == new_extruder_id; + matches_extruder = std::stoi(t_val) == heater_id; } catch (...) { matches_extruder = false; } @@ -1763,44 +1774,6 @@ static std::vector get_path_of_change_filament(const Print& print) toolchange_gcode_str.swap(out); } - if (toolchange_temp_override > 0) { - const std::string preheat_token = "preheat T" + std::to_string(new_extruder_id); - const int preheat_temp = interface_temp > 0 ? interface_temp : toolchange_temp_override; - std::string out; - out.reserve(tcr_rotated_gcode.size()); - size_t pos = 0; - while (pos < tcr_rotated_gcode.size()) { - size_t line_end = tcr_rotated_gcode.find('\n', pos); - if (line_end == std::string::npos) - line_end = tcr_rotated_gcode.size(); - std::string line = tcr_rotated_gcode.substr(pos, line_end - pos); - std::string trimmed = line; - trimmed.erase(0, trimmed.find_first_not_of(" \t")); - const bool is_preheat_line = (trimmed.find(preheat_token) != std::string::npos); - if (is_preheat_line) { - // Preserve early-preheat timing while forcing interface temp for contact toolchanges. - size_t s_pos = trimmed.find('S'); - if (s_pos != std::string::npos) { - size_t s_end = trimmed.find_first_not_of("0123456789", s_pos + 1); - trimmed.replace(s_pos + 1, - (s_end == std::string::npos ? trimmed.size() : s_end) - (s_pos + 1), - std::to_string(preheat_temp)); - // Reapply left indentation from the original line. - size_t line_prefix = line.find_first_not_of(" \t"); - if (line_prefix != std::string::npos) - line = line.substr(0, line_prefix) + trimmed; - else - line = trimmed; - } - } - out.append(line); - if (line_end < tcr_rotated_gcode.size()) - out.push_back('\n'); - pos = line_end + 1; - } - tcr_rotated_gcode.swap(out); - } - if (toolchange_temp_override > 0 && interface_temp > 0) { const std::string t_token = " T" + std::to_string(new_extruder_id); std::string out; @@ -3251,6 +3224,38 @@ static BambuBedType to_bambu_bed_type(BedType type) return bambu_bed_type; } +// Orca IMEX: Returns the PHYSICAL tool indices the active IMEX mode drives, or an EMPTY +// vector in primary mode -- a single tool, so there are no parallel carriages to list. +// The mode's Primary head IS included in parallel modes; callers handle it themselves. +// In copy/mirror parallel modes, secondary carriages never receive tool-change +// commands — the firmware mirrors the primary's moves — so they don't appear in +// tool_ordering.all_extruders(). This helper extracts the active mode's tool string +// from the Print and delegates parsing to IMEXHelpers::parse_imex_active_tools so the +// "phys[:role]" tokenization matches every other IMEX consumer (PartPlate zones, +// GCodeViewer legend, Plater warnings). +// Indices are PHYSICAL T-indices. Callers that need a filament-slot (for PA / temp +// lookups) must resolve via first_filament_for_physical_head or resolve_filament_for_head. +static std::vector get_imex_active_tools(const Print& print) +{ + std::vector active_tools; + + if (!print.config().is_imex.value || print.objects().empty()) + return active_tools; + + const std::string& raw_mode = print.objects().front()->config().imex_parallel_mode.value; + const std::string active_mode = raw_mode.empty() ? kImexPrimaryMode : raw_mode; + + // Primary mode drives a single tool, so there are no parallel carriages to list. + if (active_mode == kImexPrimaryMode) + return active_tools; + + // An unresolved mode, and a mode the tools array is too short to cover, both hand back an + // empty tools string, which parses to no tools. + for (const auto& [phys, role] : parse_imex_active_tools(find_imex_mode(print.config(), active_mode).active_tools)) + active_tools.push_back(phys); + return active_tools; +} + void GCode::_do_export(Print& print, GCodeOutputStream &file, ThumbnailsGeneratorCallback thumbnail_cb) { PROFILE_FUNC(); @@ -3660,6 +3665,51 @@ void GCode::_do_export(Print& print, GCodeOutputStream &file, ThumbnailsGenerato std::vector is_extruder_used(std::max(size_t(MAXIMUM_EXTRUDER_NUMBER), print.config().filament_diameter.size()), 0); for (unsigned int extruder : tool_ordering.all_extruders()) is_extruder_used[extruder] = true; + + // Orca IMEX: mark the LOGICAL filament slot each active SECONDARY carriage + // will load during the print. is_extruder_used is logical-slot indexed + // (consumed as `is_extruder_used[N]` in machine_start_gcode templates), but + // get_imex_active_tools returns PHYSICAL extruder indices. On any printer + // with physical_extruder_map size > 1 (MMU/AFC), writing the physical index + // into a logical-indexed array marks the wrong slot. + // + // The primary is intentionally SKIPPED here — its filament is already + // covered by tool_ordering.all_extruders() above (which lists the slots the + // objects on the plate are actually assigned to). Marking the primary + // again via the pem `first_filament_for_physical_head` fallback would + // pollute is_extruder_used with the *first* slot routed to the primary's + // physical extruder, which is generally not the slot the user assigned to + // the printing object. Same skip-primary pattern as the IMEX PA emission + // path in _do_export(). + // + // For secondaries: translate physical -> logical via the per-plate + // imex_head_filament_map (set by the IMEX ghost picker), with + // first_filament_for_physical_head as the fallback when no override is set. + if (print.config().is_imex.value && !print.objects().empty() + && initial_extruder_id != (unsigned int)-1) { + const auto plate_head_map = parse_imex_head_filament_map( + print.objects().front()->config().imex_head_filament_map.value); + const ConfigOptionInts& pem = print.config().physical_extruder_map; + // Bounds-checked, not get_at(): a clamp would suppress whichever head sits at pem[0]. + // A miss stays -1 and matches no head, so nothing is skipped -- see IMEXHelpers.hpp. + const int primary_physical = + ((int) initial_extruder_id >= 0 && + (int) initial_extruder_id < (int) pem.values.size()) + ? pem.values[(int) initial_extruder_id] + : -1; + // Bound by the array, not by the filament count. The array is padded to + // MAXIMUM_EXTRUDER_NUMBER on purpose: start G-code addresses HEADS through it + // (fdm_toolchanger_common.json gates M104 T0..T5 on it), and a parallel copy print has + // more heads than filaments by definition. PlaceholderParser clamps an out-of-range + // first_layer_temperature read to filament 0, which is the right temperature when every + // head is printing the same filament. Narrowing this to the filament count leaves the + // secondary carriages unheated. + for (int logical : imex_secondary_logical_slots( + get_imex_active_tools(print), primary_physical, plate_head_map, pem)) + if (logical >= 0 && logical < (int) is_extruder_used.size()) + is_extruder_used[logical] = true; + } + this->placeholder_parser().set("is_extruder_used", new ConfigOptionBools(is_extruder_used)); { @@ -3900,6 +3950,103 @@ void GCode::_do_export(Print& print, GCodeOutputStream &file, ThumbnailsGenerato // Sync variant-mapped params into placeholder_parser before processing start gcode update_placeholder_parser_with_variant_params(); + // IDEX/IQEX: set {imex_mode}, {imex_mode_index}, {imex_mode_gcode} placeholders before + // any G-code script is processed so they are available in machine_start_gcode, + // filament_start_gcode, filament_end_gcode, change_filament_gcode, etc. + // {imex_mode_names} and {imex_mode_gcodes} array placeholders are already accessible + // via the printer config (e.g. {imex_mode_names[0]}, {imex_mode_gcodes[1]}). + std::string imex_active_mode; + int imex_active_mode_index = 0; + std::string imex_active_mode_gcode; + m_imex_parallel_mode.clear(); + m_imex_head_filament_map.clear(); + if (print.config().is_imex.value && !print.objects().empty()) { + const std::string& raw = print.objects().front()->config().imex_parallel_mode.value; + imex_active_mode = raw.empty() ? kImexPrimaryMode : raw; + m_imex_head_filament_map = parse_imex_head_filament_map( + print.objects().front()->config().imex_head_filament_map.value); + // Look `name` up in the printer's mode table, filling in its index, script and + // tool roster. False means the name matches no row, i.e. the mode is unresolved. + // find_imex_mode() owns the "which row, and what if a sibling array is short" + // rule for every IMEX consumer; see IMEXHelpers.hpp. + std::string imex_active_mode_tools; + auto resolve_mode = [&](const std::string& name) { + const ImexMode mode = find_imex_mode(print.config(), name); + if (!mode.found()) + return false; + imex_active_mode_index = mode.index; + imex_active_mode_gcode = mode.gcode; + imex_active_mode_tools = mode.active_tools; + return true; + }; + // An unresolved mode name falls back to Primary rather than being carried into the + // parallel-mode paths. `imex_parallel_mode` is a plain string stored on the plate and + // matched against the printer's `imex_mode_names` by value, so it goes stale whenever + // the two drift apart -- a mode renamed or deleted after a plate was set to it, or a + // project shared between printer presets that name their modes differently. + // + // Without the fallback the exporter took every "not Primary" branch while every lookup + // keyed on the mode name came back empty, and wrote a file that is wrong rather than + // merely unconfigured: the 1st->2nd layer temperature branch is mutually exclusive with + // the standard one, so an empty active-tool roster meant NO head got its transition and + // all of them held nozzle_temperature_initial_layer for the whole print; and + // imex_suppresses_bare_toolchange() dropped the initial T on the assumption that a + // mode script would select the tool, while the mode script -- also looked up by name -- + // did not exist. Print::validate()'s IMEX rules did not catch it either: they resolve + // the same name to an empty tools string, which yields no declared primary and skips + // the guard. Primary is the one interpretation that is always well-formed: single + // carriage, ordinary temperatures, ordinary tool changes. + // + // Warned rather than silent, and warned rather than blocked. Silent is not an option: + // the plate still labels itself with the stale mode and draws no zones (PartPlate's + // zone builder returns early on the same unresolved state), so a user who asked for two + // parts in copy mode would get one with nothing anywhere saying why. Blocking is not an + // option either -- opening someone else's 3MF on a differently-named preset is a + // legitimate way to arrive here, and the Primary interpretation prints correctly, so + // refusing to slice would be a regression for a case that has a good answer. + // + // The second trigger below is the ragged-table case, and it lands here rather than + // anywhere else because the damage is identical: the mode's name resolves, but the + // printer's `imex_mode_active_tools` is too short to reach its row (or the row is + // explicitly empty), so the roster every branch above keys on comes back empty and + // the export takes the parallel path with nothing in it. Nothing used to report + // that -- half the lookup sites in the tree turned a short tools array into "mode + // not found" and the other half did not, and this one did not. One rule now, in + // find_imex_mode(), and one warning, here. + const bool imex_mode_resolved = resolve_mode(imex_active_mode); + if (imex_active_mode != kImexPrimaryMode && (!imex_mode_resolved || imex_active_mode_tools.empty())) { + print.active_step_add_warning( + PrintStateBase::WarningLevel::NON_CRITICAL, + imex_mode_resolved + ? Slic3r::format(_(L("The IDEX/IQEX mode \"%1%\" has no tools assigned on the selected printer. " + "Printing in Primary mode instead. Assign the mode's tools in Printer " + "Settings, or pick another mode from the plate's IDEX/IQEX button.")), + imex_active_mode) + : Slic3r::format(_(L("This plate is set to the IDEX/IQEX mode \"%1%\", which the selected printer " + "does not define. Printing in Primary mode instead. Pick a mode from the " + "plate's IDEX/IQEX button, or restore the mode in Printer Settings.")), + imex_active_mode)); + imex_active_mode = kImexPrimaryMode; + imex_active_mode_index = 0; + imex_active_mode_gcode.clear(); + imex_active_mode_tools.clear(); + // Primary is a real row in the table (always the first one), so it may carry its own + // setup script; resolve it the same way any other selected mode would be. + resolve_mode(imex_active_mode); + } + m_imex_parallel_mode = imex_active_mode; + } + this->placeholder_parser().set("imex_mode", imex_active_mode); + this->placeholder_parser().set("imex_mode_index", imex_active_mode_index); + this->placeholder_parser().set("imex_mode_gcode", imex_active_mode_gcode); + + // IDEX/IQEX: process mode G-code BEFORE machine_start_gcode so that any + // {global abc = 1} declarations in the mode script are visible to machine_start_gcode. + // The processed result is buffered here and written to the file after temp setup below. + std::string imex_processed_gcode; + if (!imex_active_mode_gcode.empty()) + imex_processed_gcode = this->placeholder_parser_process( + "imex_mode_gcode", imex_active_mode_gcode, initial_extruder_id); // Expand the file header only after the start-up placeholders and writer state are ready. // Use the regular custom G-code path so invalid placeholders report the template name. if (!print.config().file_start_gcode.value.empty()) @@ -4073,6 +4220,10 @@ void GCode::_do_export(Print& print, GCodeOutputStream &file, ThumbnailsGenerato file.write(m_writer.set_chamber_temperature(max_chamber_temp, true)); // set chamber_temperature } + // Write IMEX mode G-code first (processed above), then machine_start_gcode. + if (!imex_processed_gcode.empty()) + file.writeln(imex_processed_gcode); + // Write the custom start G-code file.writeln(machine_start_gcode); // Mark the end of the machine start g-code so the GCodeProcessor usage-block builder knows where user @@ -4189,6 +4340,49 @@ void GCode::_do_export(Print& print, GCodeOutputStream &file, ThumbnailsGenerato // Set initial extruder only after custom start G-code. // Ugly hack: Do not set the initial extruder if the extruder is primed using the MMU priming towers at the edge of the print bed. file.write(this->set_extruder(initial_extruder_id, 0.)); + + // IMEX parallel modes only: emit PA for all secondary active tools. + // In primary mode, regular tool-change PA handles each tool as it becomes active. + // In parallel modes no tool changes occur, so every carriage must be addressed + // explicitly here before printing starts. + if (!m_imex_parallel_mode.empty() && m_imex_parallel_mode != kImexPrimaryMode + && !m_config.physical_extruder_map.values.empty()) { + // initial_physical: pem-translate the print's initial logical extruder so the + // loop can skip the primary head (which emitted PA via the normal path). + // Then pem-invert each active physical head back to its first routed filament + // for the PA setting lookup. Guarded on non-empty pem above. + // Bounds-checked, not get_at() -- see the note on imex_pem_tool_for in + // IMEXHelpers.hpp. A clamped initial_physical would skip whichever active head + // equals pem[0], leaving it with no PA at all. + const int initial_physical = + ((int) initial_extruder_id >= 0 && + (int) initial_extruder_id < (int) m_config.physical_extruder_map.values.size()) + ? m_config.physical_extruder_map.values[(int) initial_extruder_id] + : -1; + // enable_pressure_advance and pressure_advance are indexed by COLUMN, not by + // filament slot. filament_diameter is one entry per slot and is never + // variant-expanded, so it is the slot-space bound; translate the slot to its column + // with get_filament_config_index(), as the second-layer temperature loop below does. + const int num_pa_filament_slots = (int) print.config().filament_diameter.values.size(); + for (int tool_idx : get_imex_active_tools(print)) { + // Unlike the second-layer temperature loop, the primary is skipped here: + // set_extruder() above already emitted its PA with the pem tool qualifier. + if (tool_idx == initial_physical) continue; + const int logical = resolve_filament_for_head( + m_imex_head_filament_map, m_config.physical_extruder_map, tool_idx); + // resolve_filament_for_head() answers in pem's index space -- one entry per + // NOZZLE -- while these two options are indexed per FILAMENT SLOT. The spaces + // diverge when the printer has more nozzles than the project has filaments, and + // get_at() would clamp a past-the-end index to filament 0 and pin its PA onto a + // secondary carriage. Same guard the second-layer temperature loop uses. + if (logical < 0 || logical >= num_pa_filament_slots) continue; + const size_t pa_fi = get_filament_config_index(logical); + if (!print.config().enable_pressure_advance.get_at(pa_fi)) continue; + file.write(m_writer.set_pressure_advance( + print.config().pressure_advance.get_at(pa_fi), + tool_idx)); + } + } } this->m_objsWithBrim.clear(); @@ -6142,6 +6336,7 @@ std::string GCode::generate_timelapse_gcode(const Print &print, coordf_t print_z // In non-sequential mode, process_layer is called per each print_z height with all object and support layers accumulated. // For multi-material prints, this routine minimizes extruder switches by gathering extruder specific extrusion paths // and performing the extruder specific extrusions together. + LayerResult GCode::process_layer( const Print &print, // Set of object & print layers of the same PrintObject and with the same print_z. @@ -6343,6 +6538,7 @@ LayerResult GCode::process_layer( + "\n"; config.set_key_value("max_layer_z", new ConfigOptionFloat(m_max_layer_z)); } + //BBS: set layer time fan speed after layer change gcode gcode += ";_SET_FAN_SPEED_CHANGING_LAYER\n"; @@ -6506,17 +6702,56 @@ LayerResult GCode::process_layer( gcode += m_writer.set_jerk_xy(NOZZLE_CONFIG(default_jerk)); } - // Transition from 1st to 2nd layer. Adjust nozzle temperatures as prescribed by the nozzle dependent - // nozzle_temperature_initial_layer vs. temperature settings. - for (const Extruder& extruder : m_writer.extruders()) { - if ((print.config().single_extruder_multi_material.value || m_ooze_prevention.enable) && - extruder.id() != m_writer.filament()->id()) - // In single extruder multi material mode, set the temperature for the current extruder only. - continue; - size_t fi = get_filament_config_index((int)extruder.id()); - int temperature = print.config().nozzle_temperature.get_at(fi); - if (temperature > 0 && temperature != print.config().nozzle_temperature_initial_layer.get_at(fi)) - gcode += m_writer.set_temperature(temperature, false, extruder.id()); + // Transition from 1st to 2nd layer: set non-initial-layer nozzle temperatures. + // IMEX parallel modes: secondary carriages (T1-T3) never receive tool-change commands, + // so they're not in m_writer.extruders() and multiple_extruders==false (max id==0). + // Use the static set_temperature path for IMEX so the T index is always emitted. + // In primary mode, fall through to the standard path — tool changes handle temps normally. + if (!m_imex_parallel_mode.empty() && m_imex_parallel_mode != kImexPrimaryMode) { + // All active tools need explicit temps — none receive tool-change commands, + // so we can't rely on the condition used for non-IMEX (temp != initial_layer_temp). + // A tool whose initial and regular temps are the same still needs to be set here. + // Mutually exclusive with the `else` below, so a head skipped here gets no + // transition at all. `tool_idx` is physical; the printing head uses this layer's + // own filament, the parallel carriages resolve through the head map. + // nozzle_temperature is indexed by column: bound in slot space, or an out-of-slot + // logical reaches get_filament_config_index and comes back as filament 0. + // filament_diameter is the slot-space yardstick -- one entry per filament slot, + // never variant-expanded -- and is the bound the pressure advance loop uses too. + const int num_filament_slots = (int) print.config().filament_diameter.values.size(); + // Bounds-checked, not get_at() -- see IMEXHelpers.hpp. A clamp would hand the + // "initial" branch below to whichever secondary sits on pem[0], giving it the wrong + // filament's transition temperature and never its own. + const int initial_physical = + ((int) first_extruder_id >= 0 && + (int) first_extruder_id < (int) m_config.physical_extruder_map.values.size()) + ? m_config.physical_extruder_map.values[(int) first_extruder_id] + : -1; + for (int tool_idx : get_imex_active_tools(print)) { + const int logical = (tool_idx == initial_physical) + ? (int)first_extruder_id + : resolve_filament_for_head( + m_imex_head_filament_map, m_config.physical_extruder_map, tool_idx); + if (logical < 0 || logical >= num_filament_slots) continue; + // Variant-expanded printers column each filament; index as the `else` does. + int temperature = print.config().nozzle_temperature.get_at(get_filament_config_index(logical)); + if (temperature > 0) + gcode += GCodeWriter::set_temperature(temperature, m_writer.config.gcode_flavor, false, + tool_idx, "set IMEX tool temperature"); + } + } else { + // Adjust nozzle temperatures as prescribed by the nozzle dependent + // nozzle_temperature_initial_layer vs. temperature settings. + for (const Extruder& extruder : m_writer.extruders()) { + if ((print.config().single_extruder_multi_material.value || m_ooze_prevention.enable) && + extruder.id() != m_writer.filament()->id()) + // In single extruder multi material mode, set the temperature for the current extruder only. + continue; + size_t fi = get_filament_config_index((int)extruder.id()); + int temperature = print.config().nozzle_temperature.get_at(fi); + if (temperature > 0 && temperature != print.config().nozzle_temperature_initial_layer.get_at(fi)) + gcode += m_writer.set_temperature(temperature, false, extruder.id()); + } } // BBS @@ -7898,6 +8133,31 @@ void GCode::append_full_config(const Print &print, std::string &str) // banning it from the dump has no effect on the feature; the only H2C/H2D delta is the M9711/M971 // snapshot reposition. "farthest_point_timelapse"sv, + // The IMEX (IDEX/IQEX parallel printing) keys are newly-registered printer/process keys whose + // defaults are non-nil, so leaving them in the dump would add fourteen `; imex_* = ` + // lines to every printer's config block — an ordinary single-nozzle machine included. Excluding + // them keeps the config-dump byte-identical for the whole shipping fleet; the IMEX printers pay + // the same price the timelapse/prime-volume keys above already pay, and the active mode is still + // visible in the body of their g-code through the injected imex_mode_gcodes macro. Every key is + // read at slice time from m_config (get_imex_active_tools / find_imex_mode / the zone layout + // helpers) and never parsed back out of the dump, so banning them costs the feature nothing: + // the g-code viewer's overlay reads the loaded presets, not the config block, and the two + // per-plate process keys round-trip through the 3MF's model_settings.config plate metadata + // (bbs_3mf.cpp IMEX_PARALLEL_MODE_ATTR / IMEX_HEAD_FILAMENT_MAP_ATTR), not through this dump. + "is_imex"sv, + "imex_firmware_managed_zones"sv, + "imex_gantry_count"sv, + "imex_tools_per_gantry"sv, + "imex_tool_layout"sv, + "imex_nozzle_clearance_x"sv, + "imex_nozzle_clearance_y"sv, + "imex_carriage_margin"sv, + "imex_viz_theme"sv, + "imex_mode_names"sv, + "imex_mode_active_tools"sv, + "imex_mode_gcodes"sv, + "imex_parallel_mode"sv, + "imex_head_filament_map"sv, "compatible_printers"sv, "compatible_prints"sv, "filament_colour_type"sv, @@ -10304,9 +10564,21 @@ std::string GCode::set_extruder(unsigned int new_filament_id, double print_z, bo gcode += this->placeholder_parser_process("filament_start_gcode", filament_start_gcode, new_filament_id, &config); check_add_eol(gcode); } - gcode += set_filament_pressure_advance(new_filament_id); + // In IMEX parallel modes each carriage needs an explicit tool address. In primary + // mode, and on a non-IMEX printer, imex_pem_tool_for() returns -1 and the qualifier + // is omitted, because regular tool changes already handle PA there. + gcode += set_filament_pressure_advance(new_filament_id, + imex_pem_tool_for((int) new_filament_id, m_imex_parallel_mode, m_config.physical_extruder_map)); - gcode += m_writer.toolchange(new_filament_id, new_extruder_id); + // Same suppression as the long multi-extruder path: at print-start in IMEX + // parallel modes the user's mode_gcode + machine_start_gcode is responsible + // for tool selection, so the slicer's bare T would be a duplicate. Mid- + // print toolchanges (count > 1) emit normally — Print::validate() blocks + // multi-color in configurations where mid-print T wouldn't make sense + // (see imex_multicolor_block_reason). + const std::string toolchange_command = m_writer.toolchange(new_filament_id, new_extruder_id); + if (!imex_suppresses_bare_toolchange(m_imex_parallel_mode, m_toolchange_count)) + gcode += toolchange_command; if (Extruder *fil = m_writer.filament()) fil->set_config_index((int)get_filament_config_index((int)fil->id())); return gcode; @@ -10350,7 +10622,27 @@ std::string GCode::set_extruder(unsigned int new_filament_id, double print_z, bo // If ooze prevention is enabled, park current extruder in the nearest // standby point and set it to the standby temperature. - if (m_ooze_prevention.enable && m_writer.filament() != nullptr) + // + // Same-physical short-circuit: on an IMEX printer, skip the standby cool-down when the + // old and new filament both route to the same physical extruder via physical_extruder_map + // (AFC/MMU lane swaps where the active heater stays selected and only the lane changes). + // The cool-down → re-heat round trip is pointless there — same nozzle, same heater, just a + // different filament feeding it. post_toolchange below still emits M109 to the new + // filament's print temp, so per-lane temperature differences are still handled. + // + // Gated on is_imex so ooze prevention behaves exactly as upstream on every other printer. + // 73 shipping profiles enable it by default (Snapmaker, Flashforge, Prusa and others) and + // none of them are asking for this optimisation. + auto same_physical_extruder = [&](int a, int b) { + if (!m_config.is_imex.value) + return false; + const auto& pem = m_config.physical_extruder_map.values; + return !pem.empty() && a >= 0 && b >= 0 + && a < (int)pem.size() && b < (int)pem.size() + && pem[a] == pem[b]; + }; + if (m_ooze_prevention.enable && m_writer.filament() != nullptr + && !same_physical_extruder(m_writer.filament()->id(), (int)new_filament_id)) gcode += m_ooze_prevention.pre_toolchange(*this); // BBS @@ -10620,7 +10912,13 @@ std::string GCode::set_extruder(unsigned int new_filament_id, double print_z, bo std::string toolchange_command = m_writer.toolchange(new_filament_id, next_nozzle_id); if (Extruder *fil = m_writer.filament()) fil->set_config_index((int)get_filament_config_index((int)fil->id())); - if (!custom_gcode_changes_tool(toolchange_gcode_parsed, m_writer.toolchange_prefix(), new_filament_id)) + // See imex_suppresses_bare_toolchange() — only suppress the print-start initial + // T in parallel modes (the user's imex_mode_gcode + machine_start_gcode owns + // tool activation there). Mid-print T emits normally; Print::validate blocks + // multi-color setups where it wouldn't make sense. + const bool suppress_imex_bare = imex_suppresses_bare_toolchange(m_imex_parallel_mode, m_toolchange_count); + if (!custom_gcode_changes_tool(toolchange_gcode_parsed, m_writer.toolchange_prefix(), new_filament_id) + && !suppress_imex_bare) gcode += toolchange_command; else { // user provided his own toolchange gcode, no need to do anything @@ -10693,14 +10991,16 @@ std::string GCode::set_extruder(unsigned int new_filament_id, double print_z, bo if (m_ooze_prevention.enable && !defer_temp_wait) gcode += m_ooze_prevention.post_toolchange(*this); - gcode += set_filament_pressure_advance(new_filament_id); + // See the note on the single-extruder path above: -1 outside IMEX parallel modes. + gcode += set_filament_pressure_advance(new_filament_id, + imex_pem_tool_for((int) new_filament_id, m_imex_parallel_mode, m_config.physical_extruder_map)); //Orca: tool changer or IDEX's firmware may change Z position, so we set it to unknown/undefined m_last_pos_defined = false; return gcode; } -std::string GCode::set_filament_pressure_advance(unsigned int filament_id) +std::string GCode::set_filament_pressure_advance(unsigned int filament_id, int tool) { const size_t fi = get_filament_config_index(filament_id); if (!m_config.enable_pressure_advance.get_at(fi)) @@ -10708,7 +11008,9 @@ std::string GCode::set_filament_pressure_advance(unsigned int filament_id) // Orca: Adaptive PA // Reset Adaptive PA processor last PA value m_pa_processor->resetPreviousPA(m_config.pressure_advance.get_at(fi)); - return m_writer.set_pressure_advance(m_config.pressure_advance.get_at(fi)); + // tool >= 0 addresses one IMEX carriage explicitly; -1 omits the qualifier, so every + // caller that does not pass one emits exactly what it did before. + return m_writer.set_pressure_advance(m_config.pressure_advance.get_at(fi), tool); } inline std::string polygon_to_string(const Polygon &polygon, Print *print, bool is_print_space = false) { diff --git a/src/libslic3r/GCode.hpp b/src/libslic3r/GCode.hpp index da78aa0a35..c89b646ae2 100644 --- a/src/libslic3r/GCode.hpp +++ b/src/libslic3r/GCode.hpp @@ -262,6 +262,15 @@ public: void export_layer_filaments(GCodeProcessorResult* result); //BBS: set offset for gcode writer void set_gcode_offset(double x, double y) { m_writer.set_xy_offset(x, y); m_processor.set_xy_offset(x, y);} + // IMEX firmware-managed slice: writer offset is augmented by the IMEX shift so the + // emitted gcode is centered at bed origin (firmware fans copies/mirrors out from there). + // Processor offset stays at plate_origin only so the gcode-preview visualizer renders + // the centered toolpath at bed center, not at the prepare-view zone placement. When + // imex_x/imex_y are zero this is byte-identical to set_gcode_offset(x, y). + void set_gcode_offset_with_imex_shift(double x, double y, double imex_x, double imex_y) { + m_writer.set_xy_offset(x + imex_x, y + imex_y); + m_processor.set_xy_offset(x, y); + } // Exported for the helper classes (OozePrevention, Wipe) and for the Perl binding for unit tests. const Vec2d& origin() const { return m_origin; } @@ -302,7 +311,11 @@ public: std::string unretract(float extra_retract = 0.f) { return m_writer.unlift() + m_writer.unretract(extra_retract); } std::string set_extruder(unsigned int extruder_id, double print_z, bool by_object=false, int toolchange_temp_override = -1, bool defer_temp_wait = false); // Sets the pressure advance of the filament's extruder variant, if enabled for it. - std::string set_filament_pressure_advance(unsigned int filament_id); + // tool addresses one IMEX carriage explicitly. -1 means no carriage: it omits the tool + // qualifier on Klipper, Marlin and BBL, and keeps RepRapFirmware's historical `D0`, since a + // bare M572 there applies to whatever tool is selected and errors when none is. That is + // what every non-IMEX caller wants, and what imex_pem_tool_for() returns off IMEX. + std::string set_filament_pressure_advance(unsigned int filament_id, int tool = -1); bool is_BBL_Printer(); WipeTowerType wipe_tower_type(); @@ -836,6 +849,14 @@ protected: std::unique_ptr m_pa_processor; + // IMEX: active parallel mode name ("primary", "copy", "iq-copy", etc.). + // Set at the start of export. Empty string means non-IMEX or not yet set. + // PA and temperature tool-qualification is only applied when this is not "primary". + std::string m_imex_parallel_mode; + // IMEX: parsed per-plate head→filament overrides (physical T-index → 1-based filament slot). + // Cached from imex_head_filament_map at print start. Empty map means "fall back to pem". + std::map m_imex_head_filament_map; + std::unique_ptr m_wipe_tower; std::unique_ptr m_small_area_infill_flow_compensator; diff --git a/src/libslic3r/GCode/WipeTower.cpp b/src/libslic3r/GCode/WipeTower.cpp index bd68ec1abd..b2ddc75a34 100644 --- a/src/libslic3r/GCode/WipeTower.cpp +++ b/src/libslic3r/GCode/WipeTower.cpp @@ -1472,6 +1472,10 @@ public: void set_multi_nozzle_group_result(const MultiNozzleUtils::LayeredNozzleGroupResult *multi_nozzle_group_result) { m_multi_nozzle_group_result = multi_nozzle_group_result; } void set_physical_extruder_map(const std::vector &physical_extruder_map) { m_physical_extruder_map = physical_extruder_map; } + // physical_extruder_map defaults to the single element {0} and is only widened to one entry + // per extruder on IMEX printers, so indexing it by tool is out of range on any other + // multi-extruder machine. Fall back to the tool's own index, matching the bounds-checked + // form GCodeProcessor uses for the same map. private: std::string set_normal_acceleration() { std::vector accelerations = m_is_first_layer ? m_first_layer_normal_accelerations : m_normal_accelerations; diff --git a/src/libslic3r/GCode/WipeTowerEstimate.cpp b/src/libslic3r/GCode/WipeTowerEstimate.cpp index c421689b6d..9b6c7882dd 100644 --- a/src/libslic3r/GCode/WipeTowerEstimate.cpp +++ b/src/libslic3r/GCode/WipeTowerEstimate.cpp @@ -28,6 +28,28 @@ static const ConfigOption *option_of(const ConfigBase &config, const char *key) return nullptr; } +bool prime_tower_is_printed(const ConfigBase &config, int used_filaments, int num_objects, bool has_mixed_filament) +{ + const auto *ept = config.option("enable_prime_tower"); + if (ept == nullptr || !ept->value) + return false; + // normalize_fdm_2 only reconsiders the option when the plate uses a filament; below that it + // leaves the user's choice alone, and there is no tower to reason about either way. + if (used_filaments <= 0) + return true; + + const ConfigOption *timelapse = option_of(config, "timelapse_type"); + const bool smooth_timelapse = timelapse != nullptr && timelapse->getInt() == int(TimelapseType::tlSmooth); + const auto *wrapping = config.option("enable_wrapping_detection"); + const bool enable_wrapping = wrapping != nullptr && wrapping->value; + if (smooth_timelapse || enable_wrapping) + return true; + + const ConfigOption *sequence = option_of(config, "print_sequence"); + const bool by_object = sequence != nullptr && sequence->getInt() == int(PrintSequence::ByObject); + return !((used_filaments == 1 && !has_mixed_filament) || (by_object && num_objects > 1)); +} + WipeTowerType resolve_wipe_tower_type(const ConfigBase &config) { // printer_model is what the CLI keys its Bambu Lab detection on; the GUI's vendor flag diff --git a/src/libslic3r/GCode/WipeTowerEstimate.hpp b/src/libslic3r/GCode/WipeTowerEstimate.hpp index 387028649b..1f0b674a11 100644 --- a/src/libslic3r/GCode/WipeTowerEstimate.hpp +++ b/src/libslic3r/GCode/WipeTowerEstimate.hpp @@ -44,4 +44,17 @@ WipeTowerFootprint estimate_wipe_tower_footprint(const ConfigBase double layer_height, double max_object_height); +// Whether a prime tower is PRINTED for a plate. The footprint estimate above answers how big a +// tower is and never reads enable_prime_tower or print_sequence, so it reports a size for a plate +// that prints no tower at all; the authority is DynamicPrintConfig::normalize_fdm_2(), which clears +// enable_prime_tower before the plate is sliced. This mirrors that rule so pre-slice consumers can +// ask it without mutating a config, and a test pins the two together. +// +// used_filaments: the plate's filament SLOTS as authored - a mixed slot counts once, matching +// Print::extruders(), which is what normalize_fdm_2 is handed. +// num_objects: objects printed on the plate; only ByObject sequencing looks at it. +// has_mixed_filament: filament_is_mixed is a PROJECT option, so it is passed rather than read - +// `config` only has to carry the print preset's own keys. +bool prime_tower_is_printed(const ConfigBase &config, int used_filaments, int num_objects, bool has_mixed_filament); + } // namespace Slic3r diff --git a/src/libslic3r/GCodeWriter.cpp b/src/libslic3r/GCodeWriter.cpp index 6176b6693c..62382f8214 100644 --- a/src/libslic3r/GCodeWriter.cpp +++ b/src/libslic3r/GCodeWriter.cpp @@ -6,6 +6,7 @@ #include "Point.hpp" #include "Polygon.hpp" #include "PrintConfig.hpp" +#include "IMEXHelpers.hpp" #include "ClipperUtils.hpp" #include "Geometry/ArcWelder.hpp" #include "Line.hpp" @@ -358,8 +359,16 @@ std::string GCodeWriter::set_temperature(unsigned int temperature, GCodeFlavor f std::string GCodeWriter::set_temperature(unsigned int temperature, bool wait, int tool) const { // set tool to -1 to make sure we won't emit T parameter for single extruder or SEMM - if (!this->multiple_extruders || m_single_extruder_multi_material) + if (!this->multiple_extruders || m_single_extruder_multi_material) { tool = -1; + } else { + // Every caller of this overload addresses filaments by LOGICAL id, but M104/M109 + // name a physical heater -- so translate at the one point they all pass through. + // The static overload below is already physical-in (GCode.cpp:5952, + // GCode/GCodeProcessor.cpp:1410) and must not be remapped, which is why the + // translation lives here and not there. + tool = imex_physical_heater_for(this->config.is_imex.value, this->config.physical_extruder_map, tool); + } return set_temperature(temperature, this->config.gcode_flavor, wait, tool); } @@ -582,26 +591,43 @@ std::string GCodeWriter::set_junction_deviation(double junction_deviation){ return gcode.str(); } -std::string GCodeWriter::set_pressure_advance(double pa) const +std::string GCodeWriter::set_pressure_advance(double pa, int tool) const { std::ostringstream gcode; if (pa < 0) return gcode.str(); - if(m_is_bbl_printers){ - //SoftFever: set L1000 to use linear model - gcode << "M900 K" < targets a specific extruder; a bare M572 applies to whatever tool is + // currently selected and errors when there isn't one. Callers with no tool index keep + // the historical D0 rather than the bare form: PA would otherwise depend on + // tool-selection state for every RRF user, none of whom are asking for IMEX. + gcode << "M572 D" << (tool >= 0 ? tool : 0) + << " S" << std::setprecision(4) << pa << "; Override pressure advance value\n"; + } else if (FLAVOR_IS(gcfRepetier)) { + // Repetier M233: X is quadratic (K), Y is linear (L). + // Applying the value to both parameters simultaneously. + gcode << "M233 X" << std::setprecision(4) << pa << " Y" << std::setprecision(4) << pa << " ; Override pressure advance value\n"; + } else if (FLAVOR_IS(gcfMarlinFirmware)) { + // Marlin 2.x supports T parameter for per-extruder LA + gcode << "M900 K" << std::setprecision(4) << pa; + if (tool >= 0) + gcode << " T" << tool; + gcode << "; Override pressure advance value\n"; + } else { + // Marlin Legacy and everything else: single-extruder M900, no tool parameter + gcode << "M900 K" << std::setprecision(4) << pa << "; Override pressure advance value\n"; } return gcode.str(); } diff --git a/src/libslic3r/GCodeWriter.hpp b/src/libslic3r/GCodeWriter.hpp index 12a56dacdc..3c90a4e9e1 100644 --- a/src/libslic3r/GCodeWriter.hpp +++ b/src/libslic3r/GCodeWriter.hpp @@ -72,7 +72,7 @@ public: // Orca: set acceleration and jerk in one command for Klipper std::string set_accel_and_jerk(unsigned int acceleration, double jerk); std::string set_junction_deviation(double junction_deviation); - std::string set_pressure_advance(double pa) const; + std::string set_pressure_advance(double pa, int tool = -1) const; std::string set_input_shaping(char axis, float damp, float freq, std::string type) const; std::string reset_e(bool force = false); std::string update_progress(unsigned int num, unsigned int tot, bool allow_100 = false) const; diff --git a/src/libslic3r/IMEXArrange.cpp b/src/libslic3r/IMEXArrange.cpp new file mode 100644 index 0000000000..ff4007f98a --- /dev/null +++ b/src/libslic3r/IMEXArrange.cpp @@ -0,0 +1,220 @@ +#include "libslic3r/IMEXArrange.hpp" + +#include +#include +#include +#include +#include +#include + +#include + +#include "libslic3r/Arrange.hpp" +#include "libslic3r/BoundingBox.hpp" +#include "libslic3r/ClipperUtils.hpp" +#include "libslic3r/Config.hpp" +#include "libslic3r/IMEXHelpers.hpp" +#include "libslic3r/IMEXZones.hpp" +#include "libslic3r/Point.hpp" +#include "libslic3r/Polygon.hpp" +#include "libslic3r/PrintConfig.hpp" +#include "libslic3r/libslic3r.h" + +namespace Slic3r { + +namespace { + +// The arranger's names for the keep-outs. finish() leaves the fences out when it arranges a +// plate inside its zone, where the zone itself is the bed shape. +constexpr const char* kFenceName = "IMEXOutsidePrimaryZone"; +constexpr const char* kStripName = "IMEXCollisionZone"; + +// Each plate computes its zones from where it sits, so the same zone differs in the last bits. +bool same_zone(const BoundingBoxf& a, const BoundingBoxf& b) +{ + return is_approx(a.min, b.min) && is_approx(a.max, b.max); +} + +BoundingBox inset(BoundingBox box, const Point& by) +{ + box.min += by; + box.max -= by; + return box; +} + +// The parts of `bed` outside `zone`: full-depth boxes left and right of it, and the pieces in +// front of and behind it. Empty pieces are left out. +std::vector boxes_around(const BoundingBox& bed, const BoundingBox& zone) +{ + std::vector out; + auto add = [&out](coord_t x0, coord_t y0, coord_t x1, coord_t y1) { + if (x1 > x0 && y1 > y0) + out.emplace_back(Point(x0, y0), Point(x1, y1)); + }; + const coord_t x0 = std::max(zone.min.x(), bed.min.x()); + const coord_t x1 = std::min(zone.max.x(), bed.max.x()); + add(bed.min.x(), bed.min.y(), zone.min.x(), bed.max.y()); + add(zone.max.x(), bed.min.y(), bed.max.x(), bed.max.y()); + add(x0, bed.min.y(), x1, zone.min.y()); + add(x0, zone.max.y(), x1, bed.max.y()); + return out; +} + +} // namespace + +std::optional imex_arrange_zones(const ImexZoneLayout& layout) +{ + if (!layout.primary_zone_box) + return std::nullopt; + ImexArrangeZones zones{*layout.primary_zone_box, {}}; + for (const BoundingBoxf3& strip : layout.collision_zones) + zones.collision_zones.emplace_back(Vec2d(strip.min.head<2>()), Vec2d(strip.max.head<2>())); + return zones; +} + +void ImexArrangeInput::read_config(const DynamicPrintConfig& full_config) +{ + // A plate the arrange adds has no mode of its own, so it takes the process preset's. + added_plate.reset(); + if (adds_plates) { + const auto* mode = full_config.option("imex_parallel_mode"); + added_plate = imex_arrange_zones(compute_imex_zone_layout(full_config, kImexPrimaryMode, mode ? mode->value : std::string(), + get_extents(full_config.opt("printable_area")->values))); + } + + // An estimate: auto brim picks its own width, which brim_width stands in for. + const auto* brim_type = full_config.option>("brim_type"); + brim = !brim_type || brim_type->value == btNoBrim || brim_type->value == btInnerOnly ? + 0. : + full_config.opt_float("brim_width") + full_config.opt_float("brim_object_gap"); +} + +ImexArranger::ImexArranger(const ImexArrangeInput& input, const arrangement::ArrangeParams& params, const Points& bed) + : m_bed(bed) + , m_bed_bb(bed) + , m_margin(scaled(std::max(0.f, params.bed_shrink_x)), scaled(std::max(0.f, params.bed_shrink_y))) + , m_adds_plates(input.adds_plates) +{ + for (size_t i = 0; i < input.beds.size(); ++i) + if (input.beds[i]) + m_beds.push_back({int(i), *input.beds[i]}); + + // The bed shape can be a zone only when every plate being arranged has it, and so does every + // plate the arrange may add. + const auto& first = input.beds.empty() ? std::nullopt : input.beds.front(); + if (first && + std::all_of(input.beds.begin(), input.beds.end(), + [&](const std::optional& b) { return b && same_zone(b->primary_zone, first->primary_zone); }) && + (!input.adds_plates || (input.added_plate && same_zone(input.added_plate->primary_zone, first->primary_zone)))) + m_shared_room = room_in(first->primary_zone); + + // The beds past the plates being arranged become new plates. + if (input.adds_plates && input.added_plate) + for (int bed_idx = int(input.beds.size()); bed_idx < MAX_NUM_PLATES; ++bed_idx) + m_beds.push_back({bed_idx, *input.added_plate}); + + // A zone narrower than its margins leaves no room, and its fences then fill the whole bed. + const Point strip_margin = m_margin + Point::Constant(scaled(input.brim)); + for (const Bed& b : m_beds) { + if (!m_shared_room) + for (const BoundingBox& box : boxes_around(m_bed_bb, inset(scaled(b.zones.primary_zone), m_margin))) + m_keep_outs.push_back({b.bed_idx, box, true}); + for (const BoundingBoxf& strip : b.zones.collision_zones) + m_keep_outs.push_back({b.bed_idx, inset(scaled(strip), -strip_margin), false}); + } +} + +std::optional ImexArranger::room_in(const BoundingBoxf& zone) const +{ + BoundingBox room = inset(scaled(zone), m_margin); + room.min = room.min.cwiseMax(m_bed_bb.min); + room.max = room.max.cwiseMin(m_bed_bb.max); + if (room.min.x() >= room.max.x() || room.min.y() >= room.max.y()) + return std::nullopt; + return room; +} + +Points ImexArranger::bed_shape() const { return m_shared_room ? m_shared_room->polygon().points : m_bed; } + +void ImexArranger::add_keep_outs(arrangement::ArrangePolygons& fixed) const +{ + for (const KeepOut& keep_out : m_keep_outs) { + arrangement::ArrangePolygon ap; + ap.poly.contour = keep_out.box.polygon(); + ap.is_virt_object = true; + ap.bed_idx = keep_out.bed_idx; + ap.height = 1; + ap.name = keep_out.fence ? kFenceName : kStripName; + fixed.push_back(std::move(ap)); + } +} + +bool ImexArranger::crosses_keep_out(const arrangement::ArrangePolygon& part, int bed_idx) const +{ + const Polygon hull = part.transformed_poly().contour; + return std::any_of(m_keep_outs.begin(), m_keep_outs.end(), [&](const KeepOut& keep_out) { + return keep_out.bed_idx == bed_idx && !intersection(hull, keep_out.box.polygon()).empty(); + }); +} + +void ImexArranger::finish(arrangement::ArrangePolygons& items, + const arrangement::ArrangePolygons& before, + const arrangement::ArrangePolygons& fixed, + const arrangement::ArrangeParams& params) const +{ + for (arrangement::ArrangePolygon& part : items) + if (crosses_keep_out(part, part.bed_idx)) + part.bed_idx = arrangement::UNARRANGED; + + // Only fenced plates need centering, and arranging one plate never fences. + if (m_shared_room || !m_adds_plates) + return; + + arrangement::ArrangeParams one_plate = params; + one_plate.progressind = {}; + for (const Bed& b : m_beds) { + const std::optional room = room_in(b.zones.primary_zone); + std::vector on_bed; + for (size_t i = 0; i < items.size(); ++i) + if (items[i].bed_idx == b.bed_idx) + on_bed.push_back(i); + if (on_bed.empty() || !room) + continue; + + arrangement::ArrangePolygons parts, bed_fixed; + // Bed 0, where the nester takes a part as placeable rather than as one that does not fit. + for (size_t i : on_bed) { + parts.push_back(before[i]); + parts.back().bed_idx = 0; + } + for (const arrangement::ArrangePolygon& ap : fixed) + if (ap.bed_idx == b.bed_idx && ap.name != kFenceName) { + bed_fixed.push_back(ap); + bed_fixed.back().bed_idx = 0; + } + arrangement::arrange(parts, bed_fixed, room->polygon().points, one_plate); + // A canceled pass leaves parts it never reached on bed 0 with their old ids. + if (one_plate.stopcondition && one_plate.stopcondition()) + return; + + // Keep the first layout when this one does not fit. + if (!std::all_of(parts.begin(), parts.end(), [&](const arrangement::ArrangePolygon& part) { + return part.bed_idx == 0 && !crosses_keep_out(part, b.bed_idx); + })) + continue; + + // By-object printing follows the packing order, which this pass numbered 0..n-1: carry + // it over onto the ids the first pass gave these parts. + std::vector ids; + for (size_t i : on_bed) + ids.push_back(items[i].itemid); + std::sort(ids.begin(), ids.end()); + for (size_t k = 0; k < on_bed.size(); ++k) { + items[on_bed[k]].translation = parts[k].translation; + items[on_bed[k]].rotation = parts[k].rotation; + items[on_bed[k]].itemid = ids[parts[k].itemid]; + } + } +} + +} // namespace Slic3r diff --git a/src/libslic3r/IMEXArrange.hpp b/src/libslic3r/IMEXArrange.hpp new file mode 100644 index 0000000000..c4fd3877c4 --- /dev/null +++ b/src/libslic3r/IMEXArrange.hpp @@ -0,0 +1,101 @@ +#pragma once + +#include +#include + +#include "libslic3r/Arrange.hpp" +#include "libslic3r/BoundingBox.hpp" +#include "libslic3r/Point.hpp" + +namespace Slic3r { + +class DynamicPrintConfig; +struct ImexZoneLayout; + +// A plate's IDEX/IQEX zones as Arrange sees them, in plate-local millimeters. +struct ImexArrangeZones +{ + BoundingBoxf primary_zone; + std::vector collision_zones; +}; + +// The zones of `layout`, none when it has no primary zone, i.e. outside a parallel mode. +std::optional imex_arrange_zones(const ImexZoneLayout& layout); + +// What an arrange needs to know about the plates it packs into. +struct ImexArrangeInput +{ + // Per arranger bed, in order: the zones of the plate it packs into, none for a plate whose + // parts may go anywhere on the bed. + std::vector> beds; + // Whether the beds past `beds` become new plates, as when arranging every plate, and the + // zones such a plate gets. + bool adds_plates = false; + std::optional added_plate; + // Room a strip keeps for the brim: brim width plus its gap to the part, in mm. + double brim = 0.; + + // Sets `added_plate` and `brim` from the full config. Call after setting `adds_plates`. + void read_config(const DynamicPrintConfig& full_config); +}; + +// Keeps an arrange inside each IDEX/IQEX plate's primary zone and out of that zone's carriage +// collision strips. +// +// The arranger packs every bed into one bed shape. When every bed has the same primary zone, +// that shape becomes the zone, which keeps the parts centered in it. Otherwise each plate in a +// parallel mode fences off the rest of its bed with fixed items on its own bed, leaving the +// other plates the whole bed, and finish() arranges each fenced plate again inside its zone, +// since a fenced pile sits against the zone edge nearest the bed's center. +// +// A zone edge inside the bed gets the margin the bed's own edges get, since a copy printed from +// a part at the zone edge sits at the far edge of the bed. A strip also gets the brim, which +// would take the nozzle into it. +class ImexArranger +{ +public: + // `bed` is the arranger's bed shape, shrunk by params.bed_shrink_x/y. + ImexArranger(const ImexArrangeInput& input, const arrangement::ArrangeParams& params, const Points& bed); + + // Whether any bed has a zone. Nothing below has an effect otherwise. + bool active() const { return !m_beds.empty(); } + + // The bed shape to arrange in. + Points bed_shape() const; + + // Adds the fixed items that keep parts out of each bed's keep-outs. + void add_keep_outs(arrangement::ArrangePolygons& fixed) const; + + // Call after arrangement::arrange(items, fixed, bed_shape(), params), with `before` holding + // `items` as they were before it. Leaves unarranged any part across a keep-out: one that does + // not fit an empty bed is retried there without the bed's fixed items, so a part too big for + // its zone comes back across them. Then centers each fenced plate's parts in its zone. + void finish(arrangement::ArrangePolygons& items, const arrangement::ArrangePolygons& before, + const arrangement::ArrangePolygons& fixed, const arrangement::ArrangeParams& params) const; + +private: + struct Bed + { + int bed_idx; + ImexArrangeZones zones; + }; + struct KeepOut + { + int bed_idx; + BoundingBox box; + bool fence; // outside the primary zone, rather than a strip + }; + + std::optional room_in(const BoundingBoxf& zone) const; + bool crosses_keep_out(const arrangement::ArrangePolygon& part, int bed_idx) const; + + Points m_bed; + BoundingBox m_bed_bb; + Point m_margin; + bool m_adds_plates = false; + std::vector m_beds; + std::optional m_shared_room; + std::vector m_keep_outs; +}; + +} // namespace Slic3r diff --git a/src/libslic3r/IMEXHelpers.cpp b/src/libslic3r/IMEXHelpers.cpp new file mode 100644 index 0000000000..fffcdb0876 --- /dev/null +++ b/src/libslic3r/IMEXHelpers.cpp @@ -0,0 +1,712 @@ +#include "libslic3r/IMEXHelpers.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "Config.hpp" +#include "libslic3r.h" +#include "Point.hpp" +#include "BoundingBox.hpp" +#include "Polygon.hpp" +#include "libslic3r/ClipperUtils.hpp" +#include "libslic3r/I18N.hpp" +#include "libslic3r/PresetBundle.hpp" +#include "libslic3r/PrintConfig.hpp" + +// Mark string for localization and translate. +#define L(s) Slic3r::I18N::translate(s) + +namespace Slic3r { + +namespace { +// Strip every whitespace byte from a token in place. +// +// Not `::isspace` directly: it takes an int that must be representable as unsigned char +// (or equal EOF), and `char` is signed on all three targets, so any byte >= 0x80 is sign- +// extended to a negative int and the call is undefined. The strings parsed below come from +// printer profiles and 3MF metadata, neither of which is guaranteed ASCII, so the unsigned +// char cast is the same one the rest of the codebase already applies to std::toupper. +void strip_whitespace(std::string& token) +{ + token.erase(std::remove_if(token.begin(), token.end(), + [](unsigned char c) { return std::isspace(c) != 0; }), + token.end()); +} +} // namespace + +ConfigOptionInts effective_physical_extruder_map(const ConfigOptionInts* explicit_pem, int nozzle_count) +{ + // One entry per logical extruder, so a profile has authored a map only when its length + // matches. The single-element PrintConfig default does not, and is replaced rather than + // treated as a one-extruder machine. + if (explicit_pem && nozzle_count > 0 && (int) explicit_pem->values.size() == nozzle_count) + return *explicit_pem; + + ConfigOptionInts derived; + derived.values.resize(std::max(nozzle_count, 1)); + std::iota(derived.values.begin(), derived.values.end(), 0); + return derived; +} + +ConfigOptionInts effective_physical_extruder_map(const PresetBundle& pb) +{ + const ConfigOptionInts* explicit_pem = pb.project_config.option("physical_extruder_map"); + if (!explicit_pem || explicit_pem->values.size() < 2) + explicit_pem = pb.printers.get_edited_preset().config.option("physical_extruder_map"); + const auto* nozzles = pb.printers.get_edited_preset().config.option("nozzle_diameter"); + return effective_physical_extruder_map(explicit_pem, nozzles ? (int) nozzles->values.size() : 0); +} + +int imex_pem_tool_for(int filament_id, const std::string& parallel_mode, const ConfigOptionInts& pem) +{ + const bool imex_parallel = !parallel_mode.empty() && parallel_mode != kImexPrimaryMode; + if (!imex_parallel || pem.values.empty()) + return -1; + // Bounds-checked, not get_at() -- see the note in IMEXHelpers.hpp for why the two index + // spaces diverge. Emitting no tool qualifier is correct for a miss; pinning PA onto the + // primary's carriage is not. + if (filament_id < 0 || filament_id >= (int) pem.values.size()) + return -1; + return pem.values[filament_id]; +} + +int imex_physical_heater_for(bool is_imex, const ConfigOptionInts& pem, int logical_id) +{ + // Bounds-check rather than get_at(): get_at() CLAMPS to values.front(), which would + // silently retarget an out-of-range id at whatever heater sits in slot 0. + if (!is_imex || logical_id < 0 || logical_id >= (int) pem.values.size()) + return logical_id; + return pem.values[logical_id]; +} + +bool imex_suppresses_bare_toolchange(const std::string& parallel_mode, unsigned int toolchange_count) +{ + return toolchange_count <= 1 + && !parallel_mode.empty() + && parallel_mode != kImexPrimaryMode; +} + +std::string imex_multicolor_block_reason(const std::string& parallel_mode, + const std::string& active_tools_str, + int tools_per_gantry, + const std::vector& used_filaments_0b, + const ConfigOptionInts& pem) +{ + // Not an IMEX parallel print → nothing to validate here. + if (parallel_mode.empty() || parallel_mode == kImexPrimaryMode) return {}; + // Single-color prints have no toolchanges to constrain. + if (used_filaments_0b.size() <= 1) return {}; + + // MMU lane sharing detection: two or more used filaments routed to the same + // physical head means a mid-print MMU swap on that head, which IMEX parallel + // modes can't slave to the secondary gantry. + if (!pem.values.empty()) { + std::unordered_set seen_physicals; + for (int filament : used_filaments_0b) { + if (filament < 0 || filament >= (int)pem.values.size()) continue; + const int phys = pem.values[filament]; // bounds-checked above; get_at would clamp + if (!seen_physicals.insert(phys).second) { + return L("Multi-color prints in IDEX/IQEX parallel modes require each filament " + "to have its own dedicated physical extruder. Two or more of the active " + "filaments are routed to the same physical head via the printer's " + "physical extruder map (an MMU/AFC manifold), which the slaved gantry " + "cannot follow."); + } + } + } + + // Determine which physical head is the primary in this mode. + const int primary_physical = imex_primary_tool_for_mode(active_tools_str); + if (primary_physical < 0) { + return L("The active IDEX/IQEX mode does not define a primary tool, so multi-color " + "printing cannot be scheduled. Open the printer settings IDEX/IQEX Modes " + "editor and assign a Primary role to one tool."); + } + + // Walk the active tools once: collect the set of distinct gantries spanned by + // the mode and check for an explicit Span marker on the primary's gantry. + // Span declares "this tool is the multicolor partner of primary on the same + // gantry" — without it, two tools on primary's gantry mean two independent + // copies, not a within-gantry toolchange topology, which can't carry + // multicolor in a parallel mode. + const int tpg = std::max(1, tools_per_gantry); + const int primary_gantry = primary_physical / tpg; + bool span_on_primary_gantry = false; + std::set active_gantries; + for (const auto& [phys, role] : parse_imex_active_tools(active_tools_str)) { + active_gantries.insert(phys / tpg); + if (phys / tpg == primary_gantry && role == ImexRole::Span) + span_on_primary_gantry = true; + } + + // Single-gantry mode: the active tools all sit on one gantry, so no second + // gantry is being copied/mirrored to. The "Copy"/"Mirror" label is decorative + // — there's no actual parallel printing happening, just a regular multi-tool + // single-gantry print. Multi-color in this configuration is a misconfiguration + // (use Primary mode for that), and the user's mode_gcode would still emit + // parallel-print firmware setup that doesn't apply here. + if (active_gantries.size() < 2) { + return L("Multi-color in this mode isn't a parallel-print scenario — all of the " + "active tools sit on a single gantry, so there's no second gantry being " + "copied or mirrored to. Switch the plate to Primary mode for multi-color " + "printing on a single gantry, or define an IDEX/IQEX mode that includes " + "tools on a second gantry."); + } + + // Dual-gantry mode but no Span tool on the primary's gantry — the slicer would + // emit toolchanges between filaments but the mode doesn't declare a within-gantry + // multicolor partner, so the swap can't carry. The user might intend independent + // copies (each gantry tool prints its own object) which is incompatible with + // mid-print multicolor in a parallel mode. + if (!span_on_primary_gantry) { + return L("Multi-color prints in IDEX/IQEX parallel modes require a Span tool on the " + "primary's gantry — without one, the mode doesn't declare a within-gantry " + "multicolor partner. Either reduce the print to a single filament, switch to " + "Primary mode, or open the printer settings IDEX/IQEX Modes editor and mark a " + "tool on the primary's gantry as Span."); + } + // Only the primary and its Span partners print the plate's colors; every other active head + // replays them. A color routed anywhere else -- a copying head, one the mode leaves inactive, + // or past the end of the map -- has no head of its own to print from. + std::set color_heads{ primary_physical }; + for (const auto& [phys, role] : parse_imex_active_tools(active_tools_str)) + if (phys / tpg == primary_gantry && role == ImexRole::Span) + color_heads.insert(phys); + for (int filament : used_filaments_0b) { + const int phys = pem.values.empty() ? filament + : (filament >= 0 && filament < (int) pem.values.size()) ? pem.values[filament] : -1; + if (color_heads.count(phys)) + continue; + std::string tools; + for (int head : color_heads) + tools += (tools.empty() ? "T" : "/T") + std::to_string(head); + // Filaments are numbered from 1 as the sidebar shows them, heads by their T index; the map + // decides which head a filament is on, so with MMU lanes the two numbers differ. + if (phys < 0) + return (boost::format(L("In this Span mode only %1% print colors, but filament %2% isn't routed to any " + "tool. Use filaments routed to %1%, or switch this plate to Primary mode.")) % + tools % (filament + 1)).str(); + return (boost::format(L("In this Span mode only %1% print colors, but filament %2% is routed to T%3%. Use " + "filaments routed to %1%, or switch this plate to Primary mode.")) % + tools % (filament + 1) % phys).str(); + } + return {}; +} + +int first_filament_for_physical_head(const ConfigOptionInts& pem, int physical) +{ + const auto& v = pem.values; + if (v.empty()) + return (physical == 0) ? 0 : -1; // degenerate: treat as identity on head 0 + for (size_t i = 0; i < v.size(); ++i) { + if (v[i] == physical) + return static_cast(i); + } + return -1; +} + +bool has_mmu(const ConfigOptionInts& pem) +{ + if (pem.values.size() < 2) + return false; + std::unordered_set seen; + for (int pv : pem.values) { + if (!seen.insert(pv).second) + return true; + } + return false; +} + +bool has_non_primary_mmu(const ConfigOptionInts& pem, int primary_physical) +{ + if (pem.values.size() < 2) + return false; + std::unordered_set seen; + for (int pv : pem.values) { + if (pv == primary_physical) + continue; + if (!seen.insert(pv).second) + return true; + } + return false; +} + +// The def-side match is on type() rather than a dynamic_cast: ConfigOptionPercent and +// ConfigOptionFloatOrPercent both derive from ConfigOptionFloat, so a cast would accept a +// registration whose value cfg.option() had just refused, and silently return +// the default while a real value sat in the config. For the int, float and bool accessors here +// both halves therefore agree on the type. imex_cfg_enum() is the exception and goes the other +// way -- dynamic_cast on both halves -- because every ConfigOptionEnum reports coEnum and a +// type() check cannot tell one enum type from another; see the comment on it in IMEXHelpers.hpp. +// +// Reaching the final return means the key is not registered at all -- a typo in the literal, which +// the compiler cannot catch. For the clearances that would be worse than the literal it replaced: +// zero suppresses every collision strip. assert() is compiled out under NDEBUG, so it catches this +// in a debug build only; the log line is what remains in Release, and the return is still a +// degraded value. This is a programming-error path, not a runtime-input one. +void imex_cfg_report_unregistered(const char* fn, const std::string& key) +{ + BOOST_LOG_TRIVIAL(error) << fn << ": no registered default for " << key; +} + +int imex_cfg_int(const ConfigBase& cfg, const std::string& key) +{ + if (const auto* opt = cfg.option(key)) + return opt->value; + if (const ConfigOptionDef* def = print_config_def.get(key)) + if (const ConfigOption* dv = def->default_value.get()) + if (dv->type() == ConfigOptionInt::static_type()) + return static_cast(dv)->value; + assert(false && "imex_cfg_int: key not registered in print_config_def"); + imex_cfg_report_unregistered("imex_cfg_int", key); + return 0; +} + +bool imex_cfg_bool(const ConfigBase& cfg, const std::string& key) +{ + if (const auto* opt = cfg.option(key)) + return opt->value; + if (const ConfigOptionDef* def = print_config_def.get(key)) + if (const ConfigOption* dv = def->default_value.get()) + if (dv->type() == ConfigOptionBool::static_type()) + return static_cast(dv)->value; + assert(false && "imex_cfg_bool: key not registered in print_config_def"); + imex_cfg_report_unregistered("imex_cfg_bool", key); + return false; +} + +double imex_cfg_float(const ConfigBase& cfg, const std::string& key) +{ + if (const auto* opt = cfg.option(key)) + return opt->value; + if (const ConfigOptionDef* def = print_config_def.get(key)) + if (const ConfigOption* dv = def->default_value.get()) + if (dv->type() == ConfigOptionFloat::static_type()) + return static_cast(dv)->value; + assert(false && "imex_cfg_float: key not registered in print_config_def"); + imex_cfg_report_unregistered("imex_cfg_float", key); + return 0.0; +} + +char imex_role_letter(ImexRole role) +{ + for (const ImexRoleDesc& d : kImexRoleTable) + if (d.role == role) + return d.letter; + // Only reachable if a role was added to the enum but not to kImexRoleTable. Serializing + // it as Copy keeps the string parseable rather than emitting a token no parser accepts. + return 'C'; +} + +ImexRole imex_role_from_suffix(const std::string& suffix) +{ + if (suffix.size() == 1) + for (const ImexRoleDesc& d : kImexRoleTable) + if (d.letter == suffix[0]) + return d.role; + return ImexRole::Copy; +} + +std::vector> parse_imex_active_tools(const std::string& active_tools_for_mode) +{ + std::vector> out; + if (active_tools_for_mode.empty()) return out; + std::istringstream ss(active_tools_for_mode); + std::string tok; + while (std::getline(ss, tok, ',')) { + strip_whitespace(tok); + if (tok.empty()) continue; + const auto colon = tok.find(':'); + const std::string idx_str = (colon == std::string::npos) ? tok : tok.substr(0, colon); + int phys = -1; + try { phys = std::stoi(idx_str); } catch (...) { continue; } + if (phys < 0 || phys >= (int)MAXIMUM_EXTRUDER_NUMBER) continue; + // A bare token (no ':') is Copy here by design -- see the header note; the + // "bare == Primary" backwards-compat rule lives in imex_primary_tool_for_mode(). + const ImexRole role = (colon == std::string::npos) + ? ImexRole::Copy + : imex_role_from_suffix(tok.substr(colon + 1)); + out.emplace_back(phys, role); + } + return out; +} + +ImexModeKind imex_mode_kind(const std::string& active_tools_for_mode) +{ + const int primary = imex_primary_tool_for_mode(active_tools_for_mode); + std::set roles; + for (const auto& [phys, role] : parse_imex_active_tools(active_tools_for_mode)) + if (phys != primary && role != ImexRole::Primary) + roles.insert(role); + if (roles.empty()) + return ImexModeKind::Primary; + if (roles.count(ImexRole::Span)) + return ImexModeKind::Custom; + return roles.count(ImexRole::Mirror) ? ImexModeKind::Mirror : ImexModeKind::Copy; +} + +ImexGantryGrouping group_imex_active_tools_by_gantry(const std::string& active_tools_for_mode, + int tools_per_gantry) +{ + ImexGantryGrouping out; + const int tpg = std::max(1, tools_per_gantry); + + const int primary = imex_primary_tool_for_mode(active_tools_for_mode); + if (primary < 0) + return out; + out.primary_phys = primary; + out.primary_gantry = primary / tpg; + + const int primary_col = primary % tpg; + + // Bucket every active tool by its gantry index. Skip the primary entry itself + // since it carries no role-driven semantics for grouping (it's just the source). + std::map>> by_gantry; + bool span_seen = false; + for (const auto& [phys, role] : parse_imex_active_tools(active_tools_for_mode)) { + if (phys < 0) continue; + const int g = phys / tpg; + by_gantry[g].emplace_back(phys, role); + if (g == out.primary_gantry && role == ImexRole::Span) + span_seen = true; + } + out.span_on_primary = span_seen; + + for (auto& [g, tools] : by_gantry) { + ImexGantryGroup grp; + grp.gantry_index = g; + grp.tools = tools; + + // Pick representative: column-paired to primary if active, else lowest phys. + const int paired_phys = g * tpg + primary_col; + auto pick = std::find_if(tools.begin(), tools.end(), + [&](const auto& pr) { return pr.first == paired_phys; }); + if (pick == tools.end()) + pick = std::min_element(tools.begin(), tools.end(), + [](const auto& a, const auto& b) { return a.first < b.first; }); + grp.representative_phys = pick->first; + grp.representative_role = pick->second; + + // Aggregate iff Span is on primary's gantry, this is a non-primary gantry, + // it has ≥2 tools, and all those tools share the same role (so the merged + // visualization is unambiguous). Mixed-role gantries fall back to per-tool. + const bool same_role = std::all_of(tools.begin(), tools.end(), + [&](const auto& pr) { return pr.second == grp.representative_role; }); + grp.aggregate = span_seen + && g != out.primary_gantry + && tools.size() >= 2 + && same_role; + + out.groups.push_back(std::move(grp)); + } + return out; +} + +int imex_primary_tool_for_mode(const std::string& active_tools_for_mode) +{ + if (active_tools_for_mode.empty()) + return -1; + std::istringstream ss(active_tools_for_mode); + std::string token; + int first_bare = -1; + while (std::getline(ss, token, ',')) { + strip_whitespace(token); + if (token.empty()) + continue; + const auto colon = token.find(':'); + const std::string idx_str = (colon == std::string::npos) ? token : token.substr(0, colon); + int idx = -1; + try { + idx = std::stoi(idx_str); + } catch (...) { + continue; + } + if (idx < 0) + continue; + if (colon == std::string::npos) { + // Backwards-compat: a bare index is Primary. + if (first_bare < 0) + first_bare = idx; + continue; + } + // Same suffix reading as parse_imex_active_tools(), so the two can never disagree + // about which letter means Primary. + if (imex_role_from_suffix(token.substr(colon + 1)) == ImexRole::Primary) + return idx; + } + return first_bare; +} + +std::map parse_imex_head_filament_map(const std::string& s) +{ + std::map result; + if (s.empty()) return result; + std::istringstream ss(s); + std::string token; + while (std::getline(ss, token, ',')) { + strip_whitespace(token); + if (token.empty()) continue; + auto colon = token.find(':'); + if (colon == std::string::npos) continue; + try { + int phys = std::stoi(token.substr(0, colon)); + int slot = std::stoi(token.substr(colon + 1)); + // Same absolute sanity cap parse_imex_active_tools applies to its own physical + // index: neither can exceed the slicer-wide extruder ceiling. This only rejects + // nonsense; the bound that matters (slot must index a filament that exists) is + // in resolve_filament_for_head, which is the one that knows the count. + if (phys >= 0 && phys < (int) MAXIMUM_EXTRUDER_NUMBER + && slot >= 1 && slot <= (int) MAXIMUM_EXTRUDER_NUMBER) + result[phys] = slot; + } catch (...) {} + } + return result; +} + +int imex_primary_logical_from_objects(const std::vector& used_slots_1b, + const ConfigOptionInts& pem, + int primary_physical) +{ + if (pem.values.empty()) return -1; + const int pem_size = (int)pem.values.size(); + for (int slot_1b : used_slots_1b) { + const int slot_0b = slot_1b - 1; + if (slot_0b >= 0 && slot_0b < pem_size && pem.values[slot_0b] == primary_physical) + return slot_0b; + } + return -1; +} + +ImexRouting imex_resolve_routing(const ConfigBase& cfg, + const std::string& parallel_mode, + const std::vector& used_slots_1b, + const ConfigOptionInts& pem) +{ + ImexRouting out; + // An empty mode is a plate that never carried one; kImexPrimaryMode is the reserved + // "no parallel printing" sentinel. Neither resolves a row, so neither is looked up. + if (parallel_mode.empty() || parallel_mode == kImexPrimaryMode) + return out; + out.parallel = true; + + // One lookup of the mode table, one parse of its roster, one search for the Primary + // marker. Every field below is derived from these, so no caller can re-derive one of + // them differently. + out.active_tools = find_imex_mode(cfg, parallel_mode).active_tools; + out.tools = parse_imex_active_tools(out.active_tools); + out.primary_phys = imex_primary_tool_for_mode(out.active_tools); + + if (out.primary_phys >= 0) + out.primary_logical = imex_primary_logical_from_objects(used_slots_1b, pem, out.primary_phys); + + // Bounds-checked exactly as imex_primary_logical_from_objects checks: get_at() would + // clamp an out-of-range slot to values.front(), which is how a head that nothing routes + // to can end up listed as a head something routes to. + out.routed_heads.reserve(used_slots_1b.size()); + for (int slot_1b : used_slots_1b) { + const int slot_0b = slot_1b - 1; + if (slot_0b >= 0 && slot_0b < (int) pem.values.size()) + out.routed_heads.push_back(pem.values[slot_0b]); + } + std::sort(out.routed_heads.begin(), out.routed_heads.end()); + out.routed_heads.erase(std::unique(out.routed_heads.begin(), out.routed_heads.end()), + out.routed_heads.end()); + + // The pem emptiness test is not folded into primary_logical: an unpopulated map means + // the printer has declared no routing at all, which is not the same claim as "this + // plate's filaments go somewhere else", and only the latter is an error. + out.primary_unrouted = out.primary_phys >= 0 && !pem.values.empty() && out.primary_logical < 0; + return out; +} + +std::vector imex_secondary_logical_slots(const std::vector& active_physicals, + int primary_physical, + const std::map& plate_head_filament_map, + const ConfigOptionInts& pem) +{ + std::vector out; + std::unordered_set seen; + for (int phys : active_physicals) { + if (phys == primary_physical) continue; + const int logical = resolve_filament_for_head(plate_head_filament_map, pem, phys); + if (logical < 0) continue; + if (seen.insert(logical).second) + out.push_back(logical); + } + return out; +} + +int resolve_filament_for_head(const std::map& plate_map, + const ConfigOptionInts& pem, + int physical) +{ + auto it = plate_map.find(physical); + if (it != plate_map.end()) { + const int zero_based = it->second - 1; + // pem is indexed by filament slot here, so an override outside it names a filament + // this printer cannot route. Bounding here rather + // than at the parse site is deliberate: the parser is handed a raw string with no + // notion of how many filaments the project has, while every consumer of this + // function's result indexes a per-filament array. The picker only ever offers + // indices into pem (IMEXFilamentPickerPopover::build_row), so nothing the UI can + // author is rejected -- only a hand-edited or corrupt 3MF. Such an override falls + // through to the printer's own routing, i.e. it behaves as if it were absent, + // rather than resolving to a wrong filament that get_at() would silently clamp. + if (zero_based >= 0 && zero_based < (int) pem.values.size()) + return zero_based; + } + return first_filament_for_physical_head(pem, physical); +} + +ImexMirrorAxis imex_mirror_axis_for(int primary_phys, int target_phys, int tools_per_gantry) +{ + const int tpg = std::max(1, tools_per_gantry); + return (target_phys / tpg) == (primary_phys / tpg) ? ImexMirrorAxis::X : ImexMirrorAxis::Y; +} + +Transform3d imex_head_transform(int /*primary*/, int /*target*/, ImexRole role, + const Vec2d& gantry_offset, + const Vec2d& primary_zone_center, + ImexMirrorAxis mirror_axis) +{ + switch (role) { + case ImexRole::Primary: + case ImexRole::Span: + // Span is the within-gantry multicolor partner of Primary: it prints the same + // objects in the same zone via mid-print toolchanges, so it has no zone of its own + // to be translated or reflected into. Identity is the only transform that keeps it + // on top of the primary. calc_imex_ghosts skips Span outright (no ghost is baked + // for it, since the primary's ghost already covers that zone), so today this case + // is unreachable — it is here so that a future caller which does reach it gets the + // right answer rather than the fall-through, and so -Wswitch stays quiet. + return Transform3d::Identity(); + case ImexRole::Copy: + return Eigen::Translation3d(gantry_offset.x(), gantry_offset.y(), 0.0) + * Transform3d::Identity(); + case ImexRole::Mirror: { + const double len2 = gantry_offset.squaredNorm(); + if (len2 < 1e-12) + return Transform3d::Identity(); + // True reflection about the zone-boundary plane between the two zones: perpendicular + // to `mirror_axis`, passing through the midpoint of the zone centers along it. The + // ghost lands at the mirrored position within the target zone — matching where the + // mirror tool actually prints — and drag reflects across that plane, so the mirrored + // axis inverts while the other translates 1:1. + // X: linear = diag(-1, 1, 1), translation = (2*center.x + off.x, off.y, 0) + // Y: linear = diag( 1,-1, 1), translation = (off.x, 2*center.y + off.y, 0) + // Both have det = -1: a real mirror image, not a 180° rotation (which would be + // diag(-1,-1,1), det = +1, and would print the primary's part merely turned around). + Transform3d out = Transform3d::Identity(); + if (mirror_axis == ImexMirrorAxis::Y) { + out.linear() = Eigen::DiagonalMatrix(1.0, -1.0, 1.0); + out.translation() = Vec3d(gantry_offset.x(), + 2.0 * primary_zone_center.y() + gantry_offset.y(), + 0.0); + } else { + out.linear() = Eigen::DiagonalMatrix(-1.0, 1.0, 1.0); + out.translation() = Vec3d(2.0 * primary_zone_center.x() + gantry_offset.x(), + gantry_offset.y(), + 0.0); + } + return out; + } + } + return Transform3d::Identity(); +} + +Vec2d compute_imex_slice_offset(bool firmware_managed, + const std::string& parallel_mode, + const std::optional& primary_zone_box) +{ + if (!firmware_managed) return Vec2d::Zero(); + if (parallel_mode.empty()) return Vec2d::Zero(); + if (parallel_mode == kImexPrimaryMode) return Vec2d::Zero(); + if (!primary_zone_box.has_value()) return Vec2d::Zero(); + return primary_zone_box->center(); +} + +namespace { +// Builds the ImexMode for row `i`, padding whatever sibling array does not reach it. +// `names` is known non-null and `i` known in range; `tools` / `gcodes` may be either. +ImexMode imex_mode_at(const ConfigOptionStrings* names, + const ConfigOptionStrings* tools, + const ConfigOptionStrings* gcodes, + size_t i) +{ + ImexMode m; + m.index = (int) i; + m.name = names->values[i]; + const bool has_tools = tools != nullptr && i < tools->values.size(); + const bool has_gcode = gcodes != nullptr && i < gcodes->values.size(); + if (has_tools) m.active_tools = tools->values[i]; + if (has_gcode) m.gcode = gcodes->values[i]; + m.ragged = !has_tools || !has_gcode; + return m; +} +} // namespace + +ImexMode find_imex_mode(const ConfigBase& cfg, const std::string& name) +{ + const auto* names = cfg.option("imex_mode_names"); + if (names == nullptr) + return {}; + // The siblings are fetched once, not per row: a missing option is the same answer for + // every row, and it is padding rather than a reason to fail the lookup. + const auto* tools = cfg.option("imex_mode_active_tools"); + const auto* gcodes = cfg.option("imex_mode_gcodes"); + for (size_t i = 0; i < names->values.size(); ++i) + if (names->values[i] == name) + return imex_mode_at(names, tools, gcodes, i); + return {}; +} + +std::vector imex_mode_table(const ConfigBase& cfg) +{ + std::vector table; + const auto* names = cfg.option("imex_mode_names"); + if (names == nullptr) + return table; + const auto* tools = cfg.option("imex_mode_active_tools"); + const auto* gcodes = cfg.option("imex_mode_gcodes"); + table.reserve(names->values.size()); + for (size_t i = 0; i < names->values.size(); ++i) + table.push_back(imex_mode_at(names, tools, gcodes, i)); + return table; +} + +std::vector imex_plate_mode_choices(const ConfigBase& cfg) +{ + std::vector choices{ kImexPrimaryMode }; + for (const ImexMode& m : imex_mode_table(cfg)) + if (!m.name.empty() && std::find(choices.begin(), choices.end(), m.name) == choices.end()) + choices.push_back(m.name); + return choices; +} + +bool imex_hull_violates_zones(const std::vector& zones, const Polygon& hull) +{ + if (hull.points.empty()) + return false; + for (const auto& box : zones) { + if (!intersection({box.polygon(true)}, {hull}).empty()) + return true; + } + return false; +} + +} // namespace Slic3r diff --git a/src/libslic3r/IMEXHelpers.hpp b/src/libslic3r/IMEXHelpers.hpp new file mode 100644 index 0000000000..bc3d7b0bc9 --- /dev/null +++ b/src/libslic3r/IMEXHelpers.hpp @@ -0,0 +1,609 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include +#include "Config.hpp" +#include "libslic3r/BoundingBox.hpp" +#include "libslic3r/PrintConfig.hpp" +#include "libslic3r/Point.hpp" +#include "libslic3r/Polygon.hpp" + +namespace Slic3r { + +class PresetBundle; + +// Reserved sentinel name for the always-present, non-deletable Primary mode in +// `imex_mode_names`. Comparison with this constant indicates "no parallel printing +// in effect" — IMEX zone visualization, ghost rendering, secondary-tool PA / temp +// emission, and the 3MF metadata serialization all short-circuit when the active +// plate mode equals this. Treat any value NOT equal to this as a parallel mode. +inline constexpr const char* kImexPrimaryMode = "primary"; + +// ============================================================================= +// The printer's IMEX mode table +// ============================================================================= +// `imex_mode_names`, `imex_mode_active_tools` and `imex_mode_gcodes` are three +// independent ConfigOptionStrings coupled only by POSITION: entry i of each describes +// mode i. Nothing in the config system enforces equal length, and all three are on-disk +// format, so a hand-edited preset, a profile written before a key existed, or a 3MF from +// another build can arrive with a sibling array shorter than `imex_mode_names`. +// +// ImexMode is the in-memory view of one row. It is NOT a config type — nothing +// serializes it, and the three keys keep their names, their types and their on-disk +// representation exactly as before. +// +// Resolution rule. Applied by find_imex_mode() / imex_mode_table() and NOWHERE else; +// every consumer in the tree goes through one of the two: +// * `imex_mode_names` is the roster. A mode exists iff a row of that array carries its +// name, and the FIRST such row wins. (The modes editor makes a name unique when it is +// committed, so duplicates reach here from a hand-edited profile; first-match is what a +// std::find over the names array already did, and what the editor's own row order +// means.) +// * A sibling array too short to reach that row yields an EMPTY string for that field +// and sets `ragged`. It never yields an out-of-range read, and it never degrades to +// "no such mode": a missing script is a mode with no script, and the row's index +// still has to be reported to {imex_mode_index}. Callers that need tools already +// treat an empty tools string as "no tools" (imex_primary_tool_for_mode() returns -1, +// parse_imex_active_tools() returns {}), which is the behavior they had before. +// * `ragged` separates "the profile omitted this row" from "the author wrote an empty +// entry" — a distinction every open-coded call site used to lose. +// +// Reporting: a non-Primary mode that resolves to an empty tool roster produces G-code +// that is wrong rather than merely unconfigured (no per-head temperature transition, and +// a suppressed initial T), so GCode::_do_export() warns and falls back to Primary, +// exactly as it already does for a name that resolves to no row at all. +struct ImexMode +{ + // Position in `imex_mode_names`. -1 means no row carries the requested name — the + // not-found case, explicit rather than implied by an out-of-range index. + int index = -1; + // The row's entry in `imex_mode_names`. Empty when !found(). + std::string name; + // `imex_mode_active_tools[index]`, or "" when that array is shorter than index + 1. + std::string active_tools; + // `imex_mode_gcodes[index]`, or "" when that array is shorter than index + 1. + std::string gcode; + // At least one sibling array was too short to cover `index`. Always false when + // !found(). + bool ragged = false; + + bool found() const { return this->index >= 0; } + explicit operator bool() const { return this->found(); } +}; + +// Resolves `name` against the printer's mode table; see the rule above. `cfg` is any +// config carrying the printer keys (a DynamicPrintConfig, a PrintConfig, ...). A config +// with no `imex_mode_names` yields a not-found ImexMode rather than throwing, so callers +// need no null check of their own. +ImexMode find_imex_mode(const ConfigBase& cfg, const std::string& name); + +// The whole roster: one ImexMode per entry of `imex_mode_names`, in config order, padded +// by the same rule. Empty when `imex_mode_names` is absent. Use this where the caller +// wants every mode (the modes editor, the plate's mode menu) rather than one by name. +std::vector imex_mode_table(const ConfigBase& cfg); + +// The modes a plate can be set to, in the order its mode button cycles them: kImexPrimaryMode, +// then each name in the table once. A repeated name is listed at its first row only, the one +// find_imex_mode() resolves it to, and empty names and rows named kImexPrimaryMode are left out. +std::vector imex_plate_mode_choices(const ConfigBase& cfg); + +// ============================================================================= +// PHYSICAL vs LOGICAL extruder indices — read this before adding a new IMEX call site +// ============================================================================= +// IMEX has two distinct index spaces and they are NOT interchangeable on MMU/AFC +// printers (where multiple logical filament slots share one physical extruder): +// +// * PHYSICAL extruder index — identifies a hardware carriage / hotend. +// Source: `imex_mode_active_tools` ("0:P,1:C,2:M") and `printer_extruder_id`. +// Use for: user-facing labels (shown as "T0", "T1"...), per-firmware tool +// qualifiers (Klipper EXTRUDER=extruderN, RRF M572 D, Marlin T), and +// anything that addresses hardware. +// +// * LOGICAL filament slot index — identifies one filament in the project. +// Source: indexing into per-filament arrays — `filament_presets`, +// `nozzle_temperature_initial_layer`, bed_temp arrays per plate type, etc. +// Use for: looking up filament data by slot. +// +// On a single-extruder or pure-IDEX printer the two indices coincide. On any +// printer with `physical_extruder_map` size > 1 they diverge. Confusing them +// produces silent wrong-filament behavior — the slicer reads the wrong filament +// preset / temperature for a given carriage with no error or log line. +// +// Translate physical → logical via: +// resolve_filament_for_head(plate_head_filament_map, pem, physical_idx) +// (or the simpler `first_filament_for_physical_head` if no per-plate override). +// +// The reverse translation (logical → physical) is `imex_pem_tool_for`. Do NOT reach for +// `pem.get_at(logical_idx)`: get_at() CLAMPS an out-of-range index to values.front(), silently +// answering "physical head pem[0]" for a logical id that has no mapping at all. The map is one +// entry per nozzle while logical ids index filament slots, and nothing caps the slot count at +// the nozzle count, so out-of-range is reachable. Bounds-check and treat the miss as "no +// mapping" (-1). +// +// The same nozzle-vs-slot divergence bites in the other direction: anything derived from pem +// -- `resolve_filament_for_head`, `first_filament_for_physical_head` -- answers in NOZZLE index +// space, so bound it against the FILAMENT SLOT COUNT before using it as a filament id. Not +// against the option you are about to read: a variant-expanded option such as +// nozzle_temperature is as long as its columns, and is_extruder_used is padded to +// MAXIMUM_EXTRUDER_NUMBER, so both admit slots the project does not have. +// +// A miss is -1, and -1 is a correct tool QUALIFIER but not a skip-the-primary sentinel: no +// physical head equals it, so a test written as `head == primary` stops skipping anything. Do +// not substitute the mode's declared primary there. Which head prints a filament is answered by +// the filament and pem, never by a mode role: a primary-mode print may use any or all tools, one +// at a time, and pem is what says which. When pem cannot answer, the head is genuinely unknown - +// an omitted qualifier then applies to the active tool, which is the head printing that filament, +// so the honest options are to leave it unqualified or to emit nothing, not to name a carriage. +// Known gap, unreported: the two skip sites in GCode.cpp therefore skip nothing for a filament +// slot past the end of pem, so a plate with more slots than nozzles double-writes the primary's +// pressure advance and hands it another filament's transition temperature. +// +// -1 as a tool qualifier reaches GCodeWriter::set_pressure_advance, which omits the qualifier +// on Klipper, Marlin and BBL but substitutes the historical `D0` on RepRapFirmware +// (GCodeWriter.cpp, set_pressure_advance) -- deliberate, not a miss. +// +// Past bugs in this class: +// - GCode PA emission used the inline `pem.get_at(filament_id)` form at two +// sites (consolidated into `imex_pem_tool_for` in c2492ccc47). +// - Pre-slice warnings indexed `filament_presets` directly by physical idx, +// causing wrong-filament names on AFC/MMU layouts (fixed in fbc58d2a1d). +// - Ghost color resolution had its own inline pem lookup with a stale default +// (centralized in `effective_physical_extruder_map` in 6a3de6a28f). +// +// New call sites: if you index a per-filament array, you need a logical index. +// If you label a hardware carriage, use the physical index. When in doubt, +// route through one of the helpers below. +// ============================================================================= + +// physical_extruder_map has one entry per LOGICAL extruder -- the index space of +// nozzle_diameter -- with each value a physical extruder index. It is NOT derivable from +// printer_extruder_id, which is indexed by variant slot (one entry per extruder+variant pair): +// an X1 Carbon has one nozzle and printer_extruder_id {1,1}, an H2D two nozzles and {1,1,2,2,2}. +// +// A profile has authored a map only when its length matches nozzle_count; otherwise the identity +// is returned, which is the same default upstream applies (see Plater.cpp's extruder_map). +ConfigOptionInts effective_physical_extruder_map(const ConfigOptionInts* explicit_pem, int nozzle_count); + +// GUI overload: resolves the effective pem from a live PresetBundle using the project_config +// → printer preset fallback. Use this instead of open-coding the lookup at ghost-color, +// tooltip, click-gate and cache-key call sites so they all agree with what the slicer sees. +ConfigOptionInts effective_physical_extruder_map(const PresetBundle& pb); + +// Returns the physical extruder index to emit as a per-tool qualifier (PA / temperature) +// for the given filament slot, or -1 when the current IMEX state does not warrant a +// per-tool qualification: non-IMEX / primary mode, or the pem is empty / unpopulated. +// Used at tool-change and second-layer transition call sites where IMEX parallel modes +// route emission through the physical extruder, while ordinary tool changes emit bare +// firmware commands like any non-IMEX printer. +int imex_pem_tool_for(int filament_id, const std::string& parallel_mode, const ConfigOptionInts& pem); + +// Translate a logical filament id into the physical heater that an M104/M109 `T` must +// name. Applies in every IMEX mode including Primary -- a heater command names hardware, +// so it does not depend on a parallel mode being active the way imex_pem_tool_for does. +// +// Gated on is_imex because physical_extruder_map carries two readings in this tree: the +// BBL paths index it by extruder id (GCode.cpp:3295, WipeTower.cpp:1353), the IMEX paths +// by filament id. Those coincide only when the filament and nozzle counts match, so an +// ungated mapping would impose the IMEX reading on profiles that mean the other one -- +// fdm_bbl_3dp_002_common ships a non-identity [1,0] and is spared today only because +// single_extruder_multi_material suppresses the T qualifier entirely. +// +// Out-of-range ids pass through unchanged, the same rule GCodeProcessor's preheat rewrite +// uses (GCode/GCodeProcessor.cpp:1407), so a printer whose map is the registered +// single-entry default is unaffected. +int imex_physical_heater_for(bool is_imex, const ConfigOptionInts& pem, int logical_id); + +// True when GCode::set_extruder should suppress its bare T at the print-start +// initial-tool selection because the active IMEX parallel mode's setup macro +// (imex_mode_gcode) and machine_start_gcode already activate the primary tool +// (Klipper SET_PRINT_MODE / RRF M567 — both reference an active tool by definition), +// so any slicer-emitted T at print-start is a duplicate. +// +// Mid-print toolchanges in parallel mode are intentionally NOT suppressed: +// - Single-color prints have no mid-print toolchanges anyway +// - Multi-color prints are blocked at slice-time when the active IMEX configuration +// can't physically support multi-color (see imex_multicolor_block_reason). The only +// not-blocked case is IQEX with 2+ tools active on the primary gantry, where mid- +// print T is legitimately needed and the firmware handles the slaved gantry. +// +// Returns false for: non-IMEX printers (empty parallel_mode), Primary mode, and any +// toolchange after the first (toolchange_count > 1). Custom change_filament_gcode that +// contains its own T is handled separately by GCode.cpp's custom_gcode_changes_tool(). +bool imex_suppresses_bare_toolchange(const std::string& parallel_mode, unsigned int toolchange_count); + +// Returns a user-facing block reason when multi-color printing is incompatible with +// the active IMEX parallel mode, or an empty string when the configuration is OK. +// +// Multi-color in a parallel mode requires the firmware to swap tools mid-print on the +// primary gantry while the slaved gantry follows automatically. That works only when +// every used filament has its own dedicated physical head (no MMU lane sharing), the +// active mode definition explicitly marks a Span tool on the primary's gantry (Span +// declares "this tool is the within-gantry multicolor partner of Primary"; it +// disambiguates the multicolor topology from independent same-gantry copies), AND every +// used filament routes to the primary or one of its Span tools, the only heads that print +// colors of their own. +// +// Catches: +// - IDEX (1 tool per gantry): primary's gantry can never carry a Span partner → blocked +// - IQEX 2-tool-active (e.g. T0 primary + T2 copy on different gantries): no Span on +// primary's gantry → blocked +// - MMU/AFC sharing among used filaments: multiple used filaments routed to same +// physical head → blocked (the slaved gantry can't follow MMU lane swaps) +// - IQEX 4-tool independent copies (T0:P,T1:C,T2:M,T3:M): no Span declared, T1 is an +// independent copy → blocked +// - IQEX paired-gantry multicolor (T0:P,T1:S,T2:M,T3:M): Span on T1 declares the +// multicolor partner, no MMU sharing → ALLOWED for colors on T0 and T1; a color +// routed to T2, T3 or past the end of the map → blocked +// +// Returns empty string for: non-IMEX (empty parallel_mode), Primary mode, single-color +// prints, or any configuration where multi-color is physically supportable. +// +// Inputs: +// parallel_mode — m_imex_parallel_mode at slice time +// active_tools_str — the imex_mode_active_tools entry for the active mode, e.g. +// "0:P,1:C,2:M,3:M" +// tools_per_gantry — imex_tools_per_gantry from printer config (>= 1) +// used_filaments_0b — 0-based logical filament indices used by the print +// pem — physical_extruder_map (logical idx → physical head) +std::string imex_multicolor_block_reason(const std::string& parallel_mode, + const std::string& active_tools_str, + int tools_per_gantry, + const std::vector& used_filaments_0b, + const ConfigOptionInts& pem); + +// Returns the lowest 0-based logical filament index L such that pem[L] == physical. +// Returns -1 if no filament routes to `physical`. +// Degenerate case: an empty pem returns 0 when `physical == 0` (identity-on-head-0 +// fallback for printers that never configured a pem) and -1 otherwise. +// With identity pem, the caller sees logical == physical behavior because +// position L holds value L. +int first_filament_for_physical_head(const ConfigOptionInts& pem, int physical); + +// Returns true if the printer has at least one physical head fed by >= 2 logical +// filaments (i.e., an MMU/AFC is present on some head). Drives the plater's +// click-handler branch: true -> open popover; false -> retain cycle-through. +bool has_mmu(const ConfigOptionInts& pem); + +// Returns true if any physical head OTHER than `primary_physical` is fed by +// >= 2 logical filaments. Primary is the head whose filament is already +// controlled by the left sidebar's object->filament assignment, so an MMU +// there needs no popover row; only non-primary MMU heads do. +// Returns false when pem is empty or size < 2. +bool has_non_primary_mmu(const ConfigOptionInts& pem, int primary_physical); + +// Parses one mode's entry from `imex_mode_active_tools` and returns the +// 0-based physical T-index carrying the `:P` (Primary) role marker. +// Accepts two forms: +// "0:P,1:C,2:C" (explicit role suffix) +// "0" (backwards-compat: a bare index == Primary) +// Returns -1 on empty/malformed input or if no primary is found. +// The caller is responsible for indexing into imex_mode_active_tools by mode. +int imex_primary_tool_for_mode(const std::string& active_tools_for_mode); + +// Parses the "phys:slot,phys:slot" serialization of imex_head_filament_map. +// Keys are physical T-indices (0-based); values are 1-based filament slots. +// Returns empty map on empty/malformed input. This string arrives straight from 3MF +// metadata, so tokens outside [0, MAXIMUM_EXTRUDER_NUMBER) are dropped here as an absolute +// sanity cap; the bound against the *project's* filament count lives in +// resolve_filament_for_head, which is where that count is actually known. +std::map parse_imex_head_filament_map(const std::string& s); + +// Resolves which 0-based logical filament to use for a physical head. +// 1. If the plate map overrides `physical` with a slot that indexes an existing filament, +// returns (slot - 1) — 1-based → 0-based. +// 2. Else falls back to first_filament_for_physical_head(pem, physical). +// Returns -1 if neither source yields a valid filament. +// +// `pem` has one entry per logical filament slot, so its size is the slot count and the +// result is always a valid index into it (or -1). An override naming a slot at or past +// that size cannot be honored — every consumer indexes a per-filament array, and the +// ConfigOption get_at() several of them use clamps rather than failing, which would turn a +// corrupt 3MF's "1:9999" into a silently wrong filament. Such an override is ignored and +// the printer's own routing applies, exactly as if the override were absent. +int resolve_filament_for_head(const std::map& plate_map, + const ConfigOptionInts& pem, + int physical); + +// Returns the 0-based logical filament slot the IMEX *primary* tool prints with, +// based on what the plate's objects are actually assigned to. Walks `used_slots_1b` +// (1-based filament indices, e.g. from PartPlate::get_extruders) and returns the +// first one whose pem entry maps to `primary_physical`. Returns -1 if no object on +// the plate routes to the primary's physical extruder, or if pem is empty. +// +// On non-MMU/non-AFC printers (one logical per physical) this is unambiguous; on +// AFC layouts where multiple logicals route to one physical, the first match wins +// — sufficient for warning labels and is_extruder_used marking. Multi-color +// primaries on the AFC manifold may want all matches; that's a separate iteration. +int imex_primary_logical_from_objects(const std::vector& used_slots_1b, + const ConfigOptionInts& pem, + int primary_physical); + +// Returns the 0-based logical filament slots that IMEX *secondary* carriages +// will load during the print. Iterates `active_physicals` (the set returned by +// imex_mode_active_tools parsing — physical extruder indices), skips entries +// equal to `primary_physical` (the primary is owned by tool_ordering / +// per-object filament assignment, not enumerated here), and resolves each +// remaining physical via `resolve_filament_for_head` (per-plate override +// first, then first_filament_for_physical_head as fallback). +// +// Returned slots are deduplicated and -1 entries (no routing found) are +// dropped. Use this for is_extruder_used marking and pre-slice warnings' +// secondary lookup; the caller still owns whatever it does with the slots +// (mark a bool array, compare temps, etc.). +std::vector imex_secondary_logical_slots(const std::vector& active_physicals, + int primary_physical, + const std::map& plate_head_filament_map, + const ConfigOptionInts& pem); + +enum class ImexRole { Primary, Copy, Mirror, Span }; + +// The one table a new role has to be added to. +// +// `letter` is the suffix the role carries in the `imex_mode_active_tools` on-disk format +// ("0:P,1:C,2:M,3:S"). That string is written into printer profiles and into 3MF projects, +// so the letters are FORMAT: never renumber, rename or reorder them, only append. The table +// order is also the order the modes editor cycles its tiles through and lists its color +// legend in, which is why Primary comes first. +// +// Everything that used to open-code the letters or a parallel small-int encoding of the +// enum now goes through this table or through ImexRole itself, so adding a fifth role means +// editing the enum, this table, and the places that must genuinely make a new decision +// about it (the transform switch, the zone/marker classification, the editor's per-role +// color + label) — not a scattered set of int mappings that fail silently when missed. +struct ImexRoleDesc { + ImexRole role; + char letter; +}; +inline constexpr ImexRoleDesc kImexRoleTable[] = { + { ImexRole::Primary, 'P' }, + { ImexRole::Copy, 'C' }, + { ImexRole::Mirror, 'M' }, + { ImexRole::Span, 'S' }, +}; + +// Reads an IMEX geometry key, falling back to the value registered for that option in +// print_config_def when the key is absent from `cfg`, rather than to a literal repeated at the +// call site. Only a partial or hand-built config reaches the fallback -- anything instantiated +// from the ConfigDef carries every key -- but that is exactly the case a literal gets wrong. +int imex_cfg_int(const ConfigBase& cfg, const std::string& key); +double imex_cfg_float(const ConfigBase& cfg, const std::string& key); + +// Enum sibling. ConfigOptionEnum derives from ConfigOptionSingle, not from ConfigOptionInt, +// so it cannot go through imex_cfg_int(). Header-inline because it is a template. +bool imex_cfg_bool(const ConfigBase& cfg, const std::string& key); + +// Logs an unregistered-key lookup. Out-of-line to keep boost.log out of this header. +void imex_cfg_report_unregistered(const char* fn, const std::string& key); + +template +T imex_cfg_enum(const ConfigBase& cfg, const std::string& key) +{ + // Matched by type, not by the coEnum tag. Every ConfigOptionEnum reports coEnum, so the + // type() comparison that option() (Config.hpp:2656) and the int/float accessors rely on + // cannot tell one enum type from another: it would accept a ConfigOptionEnum and + // static_cast it to this T. ConfigOptionEnum and ConfigOptionEnum are unrelated + // siblings (both derive from ConfigOptionSingle), so a dynamic_cast rejects the mismatch. + // + // A coEnum value has a second representation, ConfigOptionEnumGeneric, which derives from + // ConfigOptionInt rather than ConfigOptionSingle - the enum analogue of the + // ConfigOptionPercent : ConfigOptionFloat inheritance that rules dynamic_cast out for + // imex_cfg_float. It is what a config not seeded from the static PrinterConfig holds, so it + // is read here too, keyed on the value map it carries: only T's own map yields a T. + const ConfigOptionDef* def = print_config_def.get(key); + if (const ConfigOption* opt = cfg.option(key)) { + if (const auto* e = dynamic_cast*>(opt)) + return e->value; + // The map the generic option was built from identifies the enum it holds: T's own map + // means T's own enumerators, so the stored int is a T. A key whose definition names a + // different enum carries a different map and falls through to the default below, + // exactly as the typed cast above would reject it. + if (const auto* g = dynamic_cast(opt)) + if (g->keys_map == &ConfigOptionEnum::get_enum_values()) + return static_cast(g->value); + } + if (def) + if (const ConfigOption* dv = def->default_value.get()) + if (const auto* e = dynamic_cast*>(dv)) + return e->value; + // Same policy as imex_cfg_int/_float: an unregistered key is a typo the compiler cannot + // catch, so say so rather than return a silent zero that reads as a legitimate enumerator. + // The log call is out-of-line so this header, which much of libslic3r and the GUI includes, + // does not pull in boost.log for a path that only a typo reaches. + assert(false && "imex_cfg_enum: key not registered in print_config_def"); + imex_cfg_report_unregistered("imex_cfg_enum", key); + return static_cast(0); +} + +// The on-disk suffix letter for `role`. Inverse of imex_role_from_suffix(). +char imex_role_letter(ImexRole role); + +// Reads a whole token suffix — everything after the ':' — back into a role. Anything that +// is not exactly one of the table's letters is Copy: that covers "C" itself, an empty +// suffix ("3:"), a multi-character suffix, and any letter a future build might write that +// this one does not know. Copy is the historical fallback for an unrecognised suffix and +// must stay so, or a project saved by a newer build changes meaning when reopened here. +ImexRole imex_role_from_suffix(const std::string& suffix); + +// Parses `imex_mode_active_tools[mode]` into a list of (physical_head, role) pairs. +// Accepted token forms (comma-separated, whitespace-tolerant): +// "phys" — bare index, role defaults to Copy +// "phys:P" — Primary +// "phys:C" — Copy +// "phys:M" — Mirror +// "phys:S" — Span (multicolor partner of Primary on the same gantry; only +// meaningful on tools sharing primary's gantry, and only on a +// multi-gantry printer. Drives the multicolor-allow path and +// paired-gantry ghost/zone aggregation.) +// "phys:???" — unknown role suffix, treated as Copy +// Malformed tokens (unparseable int, negative phys) are skipped. +// NOTE: the bare-token → Copy default differs from `imex_primary_tool_for_mode`, +// which separately scans for a Primary; callers needing the primary index should +// use that helper. This parser is for call sites that already have the primary +// in hand and need the full head/role list (e.g. ghost factory/updater). +std::vector> parse_imex_active_tools(const std::string& active_tools_for_mode); + +// What a mode does, as the plate's mode icon shows it. The heads beside the primary that +// imex_primary_tool_for_mode() finds decide: none is Primary, any Span head is Custom (the +// multicolor modes), any Mirror head is Mirror, and all heads copying is Copy. One Mirror head is +// enough because an IQEX mirror mode copies within the primary's gantry. +enum class ImexModeKind { Primary, Copy, Mirror, Custom }; +inline constexpr size_t kImexModeKindCount = 4; +static_assert(size_t(ImexModeKind::Custom) + 1 == kImexModeKindCount); +ImexModeKind imex_mode_kind(const std::string& active_tools_for_mode); + +// ============================================================================= +// The one derivation of "what does this plate's mode do, and does its filament reach +// the primary" — shared by the hard block and the pre-slice warning +// ============================================================================= +// Print::validate() refuses a plate whose filaments do not route to the mode's declared +// primary; Plater's collect_imex_warnings() names that same primary's filament in its +// bed-temperature and filament-type warnings. Both need the identical chain: resolve the +// mode row -> parse its tool roster -> find the declared primary -> ask whether any used +// filament slot routes there. Computed twice, the two drift, and the warning ends up +// describing a plate the block describes differently. Computed here, they cannot. +// +// Everything in ImexRouting is a pure function of the four inputs. Deliberately absent: +// the `is_imex` gate (Print::validate's, and only its, outer condition) and any bound on +// the tool roster against the project's filament count (the plater's, and only its, way of +// dropping tools it has no filament preset to name). Those are genuinely per-caller and +// each stays with the caller that owns it. +struct ImexRouting +{ + // The mode is a parallel mode: non-empty and not kImexPrimaryMode. False leaves every + // other field at its default — no mode row is even looked up. + bool parallel = false; + // `imex_mode_active_tools` for this mode, or "" when the mode resolves to no row / a + // ragged table. See find_imex_mode(). + std::string active_tools; + // The parsed roster, physical head + role, in the order the string lists them. + std::vector> tools; + // Declared primary PHYSICAL head, from imex_primary_tool_for_mode(). -1 when the mode + // resolves to no row, an empty roster, or a roster with no Primary marker. + int primary_phys = -1; + // The 0-based LOGICAL filament slot the primary prints with: the first entry of + // `used_slots_1b` whose pem entry maps to `primary_phys`. -1 when nothing on the plate + // routes there (which is exactly what `primary_unrouted` reports as a hard error, and + // what the plater's warning path falls back out of so it still has a filament to name). + int primary_logical = -1; + // Sorted, deduplicated PHYSICAL heads that `used_slots_1b` actually reach. Slots outside + // the pem are dropped rather than clamped: ConfigOption::get_at() clamps to values front, + // which would let an error message name the very head it just said nothing routes to. + std::vector routed_heads; + // The mode declares a primary, the printer has a routing map, and no used filament + // reaches that primary. The precise condition Print::validate refuses the plate on. + bool primary_unrouted = false; + + bool routes_to_primary() const { return this->primary_logical >= 0; } +}; + +// `cfg` is any config carrying the printer's mode-table keys — the Print's applied config +// in the slicer, the edited printer preset in the GUI. `used_slots_1b` is 1-based filament +// indices (Print::extruders() + 1, or PartPlate::get_extruders()). `pem` is the effective +// physical_extruder_map: already normalised on the Print's config by Print::apply(), and +// obtained from effective_physical_extruder_map(PresetBundle) in the GUI. +ImexRouting imex_resolve_routing(const ConfigBase& cfg, + const std::string& parallel_mode, + const std::vector& used_slots_1b, + const ConfigOptionInts& pem); + +// Per-gantry grouping derived from the active_tools string. The single source of +// truth for paired-gantry visualization aggregation: when the primary's gantry +// has at least one Span tool, every non-primary gantry's representative tool +// stands in for the whole gantry (one ghost, one zone strip). Gantries without +// the aggregation trigger keep per-tool semantics. +// +// `representative_phys` is the column-paired tool to primary on that gantry — +// i.e. `gantry_index * tools_per_gantry + (primary_phys % tools_per_gantry)` if +// active, else the lowest active phys on that gantry. Aggregation falls back +// when tools on the same non-primary gantry carry mixed roles (e.g. one Copy +// + one Mirror), since the user explicitly authored two distinct topologies. +struct ImexGantryGroup { + int gantry_index = -1; + int representative_phys = -1; + ImexRole representative_role = ImexRole::Copy; + bool aggregate = false; // collapse this gantry to one ghost/zone + std::vector> tools; // every active tool on this gantry +}; + +struct ImexGantryGrouping { + int primary_phys = -1; + int primary_gantry = -1; + bool span_on_primary = false; // any Span tool on primary's gantry + std::vector groups; // every gantry with ≥1 active tool, sorted by index +}; + +// Groups parsed active tools by gantry (`phys / tools_per_gantry`). Aggregation +// triggers iff `span_on_primary` is true AND a non-primary gantry's tools all +// carry the same role; mixed-role non-primary gantries fall back to per-tool. +// Returns an empty grouping when active_tools_str is empty / has no primary. +ImexGantryGrouping group_imex_active_tools_by_gantry(const std::string& active_tools_for_mode, + int tools_per_gantry); + +// World-space transform composed as `head_xf * primary_instance_world` to place a ghost +// copy of the primary into `target`'s frame under `role`. +// Which boundary a Mirror reflects across. Tools on the primary's own gantry sit beside +// it along X, so they mirror across the vertical boundary between their zones. Tools on a +// different gantry sit in front of / behind it along Y, so they mirror across the +// horizontal boundary between the gantry row strips — the part comes off that gantry as a +// Y-reflection of the tool directly behind it, not an X-reflection. Single-gantry printers +// only ever use X. +enum class ImexMirrorAxis { X, Y }; + +// The single source of truth for that choice. Gantry row is `phys / tools_per_gantry`, the +// same derivation PartPlate and GCodeViewer use to build their zone grids. Keep every caller +// on this helper: if the three of them ever disagree about the axis, the plate ghosts and the +// preview markers place the same tool in different spots for the same mode. +// `tools_per_gantry` is clamped to >= 1, so a zero/negative config divides safely. Note the +// clamp lands on "one tool per gantry", meaning every secondary is then cross-gantry and +// mirrors on Y — not "everything on one gantry". That is the honest reading of tpg = 1. +ImexMirrorAxis imex_mirror_axis_for(int primary_phys, int target_phys, int tools_per_gantry); + +// `gantry_offset` = center_for(target) - center_for(primary) (XY, in mm). +// `primary_zone_center` = XY center of the primary head's zone in world coords. Only +// consulted for Mirror; Copy/Primary ignore it. +// `mirror_axis` = axis the Mirror reflection negates; get it from imex_mirror_axis_for(). +// Deliberately NOT defaulted: a default would silently hand a forgetful caller the X +// reflection, which is wrong for every cross-gantry tool and would fail silently — the +// ghosts would just quietly go back to mirroring on the wrong axis. Ignored by Copy/Primary. +// Copy: pure translation by gantry_offset. Ghost tracks primary 1:1 during drag. +// Mirror: true reflection (det = -1, so chirality flips — a real mirror image) about the +// zone-boundary plane between primary and target, perpendicular to `mirror_axis` +// and passing through the midpoint of the two zone centers along that axis. The +// ghost origin lands at the mirrored position within the target zone, and primary +// drag reflects across that plane, so the mirrored axis moves opposite the primary +// while the other axis tracks 1:1. +// Zero-length gantry_offset degenerates to identity. +// Primary: identity. +// Span: identity, same as Primary. A Span tool shares the primary's gantry AND its zone, +// printing the same objects through mid-print toolchanges rather than a duplicate +// placed elsewhere, so there is nothing to translate or reflect. calc_imex_ghosts +// bakes no ghost for a Span head, so no caller reaches this today. +Transform3d imex_head_transform(int primary, int target, ImexRole role, + const Vec2d& gantry_offset, + const Vec2d& primary_zone_center, + ImexMirrorAxis mirror_axis); + +// Slice-time XY shift for printers that delegate copy/mirror placement to firmware. +// When `imex_firmware_managed_zones` is on AND the active mode is non-primary, returns +// the primary zone's plate-local center so gcode emission can subtract it (yielding a +// centered slice the firmware can fan out). Returns Vec2d::Zero() in every other case: +// flag off, primary/empty mode, or no zone box (IMEX disabled, primary-only, etc.). +Vec2d compute_imex_slice_offset(bool firmware_managed, + const std::string& parallel_mode, + const std::optional& primary_zone_box); + +// True if `hull` (scaled Clipper coords) overlaps any of `zones` (unscaled mm) — +// the one definition of "overlap" shared by the object and prime-tower placement +// checks. Area-based: Clipper yields no result for shapes sharing only an edge, so +// a hull flush against a zone boundary is NOT reported, it has to cross. That +// matches the long-standing object behavior. An empty hull never violates. +bool imex_hull_violates_zones(const std::vector& zones, const Polygon& hull); + +} // namespace Slic3r diff --git a/src/libslic3r/IMEXZones.cpp b/src/libslic3r/IMEXZones.cpp new file mode 100644 index 0000000000..f5164f0500 --- /dev/null +++ b/src/libslic3r/IMEXZones.cpp @@ -0,0 +1,379 @@ +#include "libslic3r/IMEXZones.hpp" + +#include +#include +#include +#include +#include +#include + +#include "PrintConfig.hpp" +#include "BoundingBox.hpp" +#include "Config.hpp" +#include "libslic3r/IMEXHelpers.hpp" + +namespace Slic3r { + +ImexZoneLayout compute_imex_zone_layout(const DynamicPrintConfig& printer_cfg, + const std::string& plate_mode, + const std::string& process_mode, + const BoundingBoxf& bed_extents) +{ + ImexZoneLayout out; + + auto* is_imex_opt = printer_cfg.option("is_imex"); + if (!is_imex_opt || !is_imex_opt->value) + return out; + + // Per-plate mode takes priority over the process preset. + std::string active_mode = plate_mode; + if (active_mode == kImexPrimaryMode && !process_mode.empty()) + active_mode = process_mode; + if (active_mode == kImexPrimaryMode || active_mode.empty()) + return out; + + // Grid dimensions and tool layout from printer config + + int n_cols = std::max(1, imex_cfg_int(printer_cfg, "imex_tools_per_gantry")); + int n_rows = std::max(1, imex_cfg_int(printer_cfg, "imex_gantry_count")); + + // Which corner is T0? flip_x: col 0 is right(max-X); flip_y: row 0 is rear(max-Y) + const ImexToolLayout layout = imex_cfg_enum(printer_cfg, "imex_tool_layout"); + bool flip_x = (layout == ImexToolLayout::FrontRight || layout == ImexToolLayout::RearRight); + bool flip_y = (layout == ImexToolLayout::RearLeft || layout == ImexToolLayout::RearRight); + + // Convert tool index to physical (col=X-index, row=Y-index), col/row 0 = min-X/min-Y + auto tool_to_phys = [&](int idx) -> std::pair { + int raw_col = idx % n_cols, raw_row = idx / n_cols; + return { flip_x ? (n_cols - 1 - raw_col) : raw_col, + flip_y ? (n_rows - 1 - raw_row) : raw_row }; + }; + + int pri_col = 0, pri_row = 0; + + if (n_cols == 1 && n_rows == 1) + return out; // nothing to dim with a single zone + + // Look up secondary tool indices (active in mode, but NOT the primary tool). + // An unresolved mode, and a mode whose row the tools array does not reach, both come + // back as an empty string; the zone_roles guard below then returns an empty layout. + const std::string active_tools_str = find_imex_mode(printer_cfg, active_mode).active_tools; + + // Which heads own a zone cell, and in what role. Roles carry through from + // parse_imex_active_tools() rather than being flattened into small ints, so the + // classification below is a comparison the compiler checks and a new role cannot slip + // through as an unhandled number. + // + // imex_primary_tool_for_mode handles the Primary slot (it owns the bare-legacy-token → + // Primary rule); parse_imex_active_tools fills in the Copy/Mirror secondaries. Mode + // strings use physical T-indices directly; filament routing is separate + // (imex_head_filament_map). + // + // Only Primary / Copy / Mirror land in this map. A Span head shares the primary's zone + // instead of owning one, so it deliberately gets no entry (and hence no zone center -- + // see ImexZoneLayout::head_zone_centers), and an extra Primary entry beyond the first is + // ignored. Any further role must decide here whether it owns a cell. + std::map zone_roles; + { + const int primary = imex_primary_tool_for_mode(active_tools_str); + if (primary >= 0 && primary < n_rows * n_cols) + zone_roles[primary] = ImexRole::Primary; + for (const auto& [phys_idx, role] : parse_imex_active_tools(active_tools_str)) { + if (phys_idx < 0 || phys_idx >= n_rows * n_cols) continue; + if (phys_idx == primary) continue; + // Exhaustive on purpose, with no default: a role added to the enum has to answer + // "does this own a zone cell?" here, and -Wswitch asks the question at compile + // time instead of letting the head silently vanish from the layout. + switch (role) { + case ImexRole::Copy: + case ImexRole::Mirror: + zone_roles[phys_idx] = role; + break; + case ImexRole::Primary: // an extra Primary beyond the first is ignored + case ImexRole::Span: // shares the primary's zone rather than owning one + break; + } + } + } + + // If the mode name was found but has no tools (e.g. stale process-preset mode on a new + // printer that has no modes defined yet), there is nothing to compute. + if (zone_roles.empty()) + return out; + + // Identify the Primary tool from the mode definition + for (const auto& [idx, role] : zone_roles) { + if (role == ImexRole::Primary) { auto [c, r] = tool_to_phys(idx); pri_col = c; pri_row = r; + out.primary_head = idx; break; } + } + + // No Primary landed inside the grid. Either the mode declares none at all, or its `:P` + // index is off-grid: the modes editor deliberately preserves tool assignments across + // imex_gantry_count changes, so a mode authored on a 4-tool IQEX with Primary on T3 keeps + // that assignment when the printer is reconfigured as a 2-tool IDEX, and the + // `primary < n_rows * n_cols` bound above then drops it from zone_roles. + // + // Every zone, strip, ghost center and slice offset below is derived from pri_col/pri_row, + // which would still be sitting at their (0,0) initializers — a position no active tool + // occupies. That yields a primary zone and a secondary zone that both claim the whole bed: + // imex_primary_zone() reports the plate clear while the blocking boxes report it full, and + // under imex_firmware_managed_zones compute_imex_slice_offset() shifts the slice by the bed + // center. Degrade to "this plate has no IMEX zones" instead — the same answer the earlier + // early returns give, and the one every consumer already handles. + if (out.primary_head < 0) + return ImexZoneLayout{}; + + // Per-gantry grouping drives Span-based aggregation. When the mode declares a + // Span partner on primary's gantry, non-primary gantries with multiple same-role + // tools collapse to one cell each (placed at primary's column so has_col_sep + // stays false and make_boxes expands the cell into a full-X row-strip). + // Mixed-role / single-tool / no-Span configurations keep per-tool cells. + const ImexGantryGrouping grouping = + group_imex_active_tools_by_gantry(active_tools_str, n_cols); + std::map group_by_gantry; + for (const auto& grp : grouping.groups) + group_by_gantry[grp.gantry_index] = &grp; + + // Separate copy and mirror secondary cells using physical coordinates + std::set> copy_cells, mirror_cells; + for (const auto& [idx, role] : zone_roles) { + if (role == ImexRole::Primary) continue; // primary handled separately + + const int phys_gantry = idx / n_cols; + auto git = group_by_gantry.find(phys_gantry); + const ImexGantryGroup* grp = (git == group_by_gantry.end()) ? nullptr : git->second; + + if (grp && grp->aggregate) { + // Only the representative contributes a cell; non-reps are folded into + // the same row-strip and skipped entirely. + if (idx != grp->representative_phys) continue; + auto [rep_c, r] = tool_to_phys(idx); + (void)rep_c; // intentionally discarded — aggregated cell pins to pri_col + if (role == ImexRole::Copy) copy_cells.insert({pri_col, r}); + else if (role == ImexRole::Mirror) mirror_cells.insert({pri_col, r}); + } else { + auto [c, r] = tool_to_phys(idx); + if (role == ImexRole::Copy) copy_cells.insert({c, r}); + else if (role == ImexRole::Mirror) mirror_cells.insert({c, r}); + } + } + + // All active secondary cells combined (for separation axis computation) + std::set> all_secondary; + for (auto& p : copy_cells) all_secondary.insert(p); + for (auto& p : mirror_cells) all_secondary.insert(p); + + double x_min = bed_extents.min(0), x_max = bed_extents.max(0); + double y_min = bed_extents.min(1), y_max = bed_extents.max(1); + + // Zone sizing is based on ACTIVE tool count per axis, not total grid dimensions. + // Inactive tools (absent from zone_roles) donate their bed share to active neighbors. + // Sorted active col/row lists map physical index k → zone index k. + std::vector active_cols_v, active_rows_v; + { + std::set ac_set, ar_set; + for (const auto& [idx, role] : zone_roles) { + auto [c, r] = tool_to_phys(idx); + ac_set.insert(c); ar_set.insert(r); + } + active_cols_v.assign(ac_set.begin(), ac_set.end()); // sorted ascending + active_rows_v.assign(ar_set.begin(), ar_set.end()); + } + int n_active_cols = std::max(1, (int)active_cols_v.size()); + int n_active_rows = std::max(1, (int)active_rows_v.size()); + + std::map col_to_zone, row_to_zone; + for (int k = 0; k < n_active_cols; ++k) col_to_zone[active_cols_v[k]] = k; + for (int k = 0; k < n_active_rows; ++k) row_to_zone[active_rows_v[k]] = k; + + double zone_w = (x_max - x_min) / n_active_cols; + double zone_h = (y_max - y_min) / n_active_rows; + + // Zone index of the primary tool + int pri_col_k = col_to_zone.count(pri_col) ? col_to_zone[pri_col] : 0; + int pri_row_k = row_to_zone.count(pri_row) ? row_to_zone[pri_row] : 0; + + // Zone center per physical head — ghost placement consumes this so that + // ghosts land in their own secondary zone instead of stacking on primary. + // Every active tool (including primary) gets an entry; ghost offset math is + // simply center[head] - center[primary]. + for (const auto& [idx, role] : zone_roles) { + auto [c, r] = tool_to_phys(idx); + int ck = col_to_zone.count(c) ? col_to_zone.at(c) : 0; + int rk = row_to_zone.count(r) ? row_to_zone.at(r) : 0; + out.head_zone_centers[idx] = Vec2d( + x_min + (ck + 0.5) * zone_w, + y_min + (rk + 0.5) * zone_h); + } + + // Separation axes: row-sep = secondaries on a different gantry, col-sep = different column + bool has_row_sep = false, has_col_sep = false; + for (const auto& [sc, sr] : all_secondary) { + if (sr != pri_row) has_row_sep = true; + if (sc != pri_col) has_col_sep = true; + } + + // Build expanded zone rects for a set of cells. + // When secondaries share only a row difference (same column as primary) → expand to full X width. + // When secondaries share only a column difference → expand to full Y height. + // When both axes differ → per-quadrant. + auto make_boxes = [&](const std::set>& cells) -> std::vector { + std::vector boxes; + if (cells.empty()) return boxes; + auto ck = [&](int c) { return col_to_zone.count(c) ? col_to_zone.at(c) : 0; }; + auto rk = [&](int r) { return row_to_zone.count(r) ? row_to_zone.at(r) : 0; }; + if (has_row_sep && !has_col_sep) { + std::set rows; for (auto& [c,r] : cells) rows.insert(r); + for (int sr : rows) { + int k = rk(sr); + boxes.emplace_back(Vec2d(x_min, y_min + k*zone_h), Vec2d(x_max, y_min + (k+1)*zone_h)); + } + } else if (has_col_sep && !has_row_sep) { + std::set cols; for (auto& [c,r] : cells) cols.insert(c); + for (int sc : cols) { + int k = ck(sc); + boxes.emplace_back(Vec2d(x_min + k*zone_w, y_min), Vec2d(x_min + (k+1)*zone_w, y_max)); + } + } else { + for (auto& [sc,sr] : cells) { + int ck_ = ck(sc), rk_ = rk(sr); + boxes.emplace_back(Vec2d(x_min + ck_*zone_w, y_min + rk_*zone_h), + Vec2d(x_min + (ck_+1)*zone_w, y_min + (rk_+1)*zone_h)); + } + } + return boxes; + }; + + out.copy_zones = make_boxes(copy_cells); + out.mirror_zones = make_boxes(mirror_cells); + + // --- Secondary zone blocking --- + // The expanded bounding boxes for all secondary (copy+mirror) zones. check_outside() + // uses these to prevent objects being placed outside the primary zone. + auto push_secondary_box = [&](const std::vector& boxes) { + for (const auto& b : boxes) + out.secondary_zone_boxes.emplace_back(Vec3d(b.min.x(), b.min.y(), -1.0), + Vec3d(b.max.x(), b.max.y(), 1e4)); + }; + push_secondary_box(out.copy_zones); + push_secondary_box(out.mirror_zones); + + // --- Carriage collision danger strips --- + // Only add strips at boundaries of the PRIMARY zone — objects are only placed in the + // primary zone, so secondary-to-secondary boundaries have no relevance. + // + // Strip width is the literal nozzle clearance value on the primary side only: + // right X boundary: [bnd_x - nozzle_clearance_x, bnd_x] + // left X boundary: [bnd_x, bnd_x + nozzle_clearance_x] + // top Y boundary: [bnd_y - nozzle_clearance_y, bnd_y] + // bottom Y boundary:[bnd_y, bnd_y + nozzle_clearance_y] + // + // Strip length matches the primary zone extent (same expansion logic as make_boxes): + // !has_row_sep → full bed height; has_row_sep → primary row only + // !has_col_sep → full bed width; has_col_sep → primary column only + + // Both strip blocks below are gated on `carriage_w > 0.0` / `carriage_h > 0.0`, so a zero + // clearance emits no collision strips at all while the preview still draws toolhead boxes. + // That is why these read through imex_cfg_float() rather than carrying a literal: the value + // for an absent key comes from the option's own registration, not from a number repeated here. + double carriage_w = imex_cfg_float(printer_cfg, "imex_nozzle_clearance_x"); + double carriage_h = imex_cfg_float(printer_cfg, "imex_nozzle_clearance_y"); + double margin = imex_cfg_float(printer_cfg, "imex_carriage_margin"); + + // Primary zone extent (the clear printable area): + // row-sep only → full bed width × primary row's Y band + // col-sep only → primary col's X band × full bed height + // both → primary quadrant + // Uses zone indices so inactive tools don't shrink the zone. + double pz_x0 = has_col_sep ? x_min + pri_col_k * zone_w : x_min; + double pz_x1 = has_col_sep ? x_min + (pri_col_k + 1) * zone_w : x_max; + double pz_y0 = has_row_sep ? y_min + pri_row_k * zone_h : y_min; + double pz_y1 = has_row_sep ? y_min + (pri_row_k + 1) * zone_h : y_max; + out.primary_zone_box = BoundingBoxf(Vec2d(pz_x0, pz_y0), Vec2d(pz_x1, pz_y1)); + + auto add_strip = [&](double sx0, double sx1, double sy0, double sy1) { + out.collision_zones.emplace_back(Vec3d(sx0, sy0, -1.0), Vec3d(sx1, sy1, 1e4)); + }; + auto add_margin_band = [&](double sx0, double sx1, double sy0, double sy1) { + out.margin_bands.emplace_back(Vec2d(sx0, sy0), Vec2d(sx1, sy1)); + }; + + // Determine which directions have mirror secondaries adjacent to the primary. + // Only mirror tools can cause carriage collisions — they move toward each other. + // Copy tools always move in the same direction, so no collision strip is needed. + // + // Driven by `mirror_cells`, not by zone_roles: on an AGGREGATED gantry the cell is + // pinned to the primary's column and make_boxes then expands it into a full-width row + // strip, so the painted zone — not any one head's own cell — is what the primary's + // carriage can meet. Reading each head's own cell instead lost the strip whenever the + // representative's column differed from the primary's: at tpg=3, gantry_count=2, mode + // "0:P,1:S,4:M,5:M" the rear gantry paints a full-width mirror strip against the + // primary's rear boundary, yet neither T4 (col 1) nor T5 (col 2) is in the primary's + // column, so no strip and no margin band were emitted at all. + // + // Both axes require zone-adjacent AND same row/column on the OTHER axis: a mirror + // diagonally offset from primary (different row AND different column) can't collide + // with primary's carriage on either axis since the gantries don't overlap there. + // Without these checks, paired-gantry mc-mirror (`0:P,1:S,2:M,3:M`) would draw a + // spurious right-edge strip from T3 even though T3 lives on the other gantry's row. + // + // The adjacency tests (±1) are in ZONE indices so an inactive column or row between + // two active ones cannot hide the boundary they really share (see "inactive tools + // donate their bed share"), while the co-linearity test on the other axis stays in + // physical indices. The two forms agree for any cell that owns a zone — col_to_zone + // and row_to_zone are order-preserving bijections over exactly the active indices — + // so this is one rule written two ways, not two rules. + bool has_right_sec = false, has_left_sec = false; + bool has_top_sec = false, has_bottom_sec = false; + for (const auto& [sc, sr] : mirror_cells) { + int zc = col_to_zone.count(sc) ? col_to_zone.at(sc) : -1; + int zr = row_to_zone.count(sr) ? row_to_zone.at(sr) : -1; + if (zc < 0 || zr < 0) continue; // no zone of its own, so no boundary with primary + // X-boundary strips: mirror zone-adjacent in X, same physical row as primary. + if (zr == pri_row_k && zc == pri_col_k + 1) has_right_sec = true; + if (zr == pri_row_k && zc == pri_col_k - 1) has_left_sec = true; + // Y-boundary strips: mirror zone-adjacent in Y, same physical column as primary. + if (zr == pri_row_k + 1 && sc == pri_col) has_top_sec = true; + if (zr == pri_row_k - 1 && sc == pri_col) has_bottom_sec = true; + } + + // X-axis boundaries (vertical strips, width = nozzle_clearance_x on primary side) + if (carriage_w > 0.0) { + if (has_right_sec) { + double bnd_x = x_min + (pri_col_k + 1) * zone_w; + double strip_inner = bnd_x - carriage_w; + add_strip(strip_inner, bnd_x, pz_y0, pz_y1); + if (margin > 0.0) + add_margin_band(strip_inner - margin, strip_inner, pz_y0, pz_y1); + } + if (has_left_sec) { + double bnd_x = x_min + pri_col_k * zone_w; + double strip_inner = bnd_x + carriage_w; + add_strip(bnd_x, strip_inner, pz_y0, pz_y1); + if (margin > 0.0) + add_margin_band(strip_inner, strip_inner + margin, pz_y0, pz_y1); + } + } + + // Y-axis boundaries (horizontal strips, width = nozzle_clearance_y on primary side) + if (carriage_h > 0.0) { + if (has_top_sec) { + double bnd_y = y_min + (pri_row_k + 1) * zone_h; + double strip_inner = bnd_y - carriage_h; + add_strip(pz_x0, pz_x1, strip_inner, bnd_y); + if (margin > 0.0) + add_margin_band(pz_x0, pz_x1, strip_inner - margin, strip_inner); + } + if (has_bottom_sec) { + double bnd_y = y_min + pri_row_k * zone_h; + double strip_inner = bnd_y + carriage_h; + add_strip(pz_x0, pz_x1, bnd_y, strip_inner); + if (margin > 0.0) + add_margin_band(pz_x0, pz_x1, strip_inner, strip_inner + margin); + } + } + + return out; +} + +} // namespace Slic3r diff --git a/src/libslic3r/IMEXZones.hpp b/src/libslic3r/IMEXZones.hpp new file mode 100644 index 0000000000..45bc959101 --- /dev/null +++ b/src/libslic3r/IMEXZones.hpp @@ -0,0 +1,94 @@ +#pragma once + +#include +#include +#include +#include + +#include "libslic3r/BoundingBox.hpp" +#include "libslic3r/Point.hpp" +#include "libslic3r/PrintConfig.hpp" + +namespace Slic3r { + +// Where every IMEX bed zone, carriage collision strip and advisory margin band sits, in +// plate-local millimetres. Purely geometric: no rendering, no clipping against the bed +// outline, no GUI types. PartPlate turns the rectangles into GLModels (clipping each one +// to the bed polygon as it goes) and copies the boxes straight into the members that +// check_outside() consults, so this struct is the whole answer to "which boxes". +// +// Every list is empty and `primary_zone_box` unset when no parallel mode is in effect — +// IMEX off, Primary mode, a 1x1 tool grid, a mode whose tool roster is empty, or a mode +// whose Primary tool is not on the current grid (no `:P` marker, or a `:P` index left +// over from a wider grid). The whole layout hangs off the primary's cell, so without one +// there is nothing to divide and consumers must see the same "no zones" answer. +struct ImexZoneLayout +{ + // Physical T-index of the active mode's Primary tool; -1 when the layout is empty. + int primary_head = -1; + + // Physical head -> XY center of that head's zone. One entry per tool the mode makes + // active, Primary included. A Span tool has no entry: it shares the primary's zone + // rather than owning one. Ghost placement offsets by center[head] - center[primary]. + // + // EXCEPTION, read before using the X component. These centers are built from the head's + // OWN grid cell, but an AGGREGATED representative's zone rectangle is pinned to the + // primary's column and then expanded to a full-width row strip. So for an aggregated head + // whose own column differs from the primary's, center[head].x names a column its painted + // zone does not have, and `center[head] - center[primary]` carries a bogus X offset. + // Reachable at imex_tools_per_gantry >= 3 when the primary-column tool on the aggregated + // gantry is inactive (e.g. tpg=3, gantry_count=2, mode "0:P,1:S,4:M,5:M"). + // Both current consumers pin X to the primary's center for aggregated heads -- + // PartPlate::calc_imex_ghosts() via bed_x_center, GCodeViewer via sec_center_of() -- and a + // new consumer must do the same until this is either pinned here or the aggregate flag is + // exposed on the layout. + std::map head_zone_centers; + + // The clear printable area — the primary tool's zone. Objects go here and nowhere else. + std::optional primary_zone_box; + + // Copy / Mirror zone rectangles, already expanded along whichever axis carries no + // separation: secondaries differing from primary only by row give full-bed-width + // strips, only by column give full-bed-height strips, and differing on both axes give + // per-tool quadrants. + std::vector copy_zones; + std::vector mirror_zones; + + // `copy_zones` followed by `mirror_zones`, as full-height boxes for the placement + // check. An object overlapping one of these is outside the primary zone. + std::vector secondary_zone_boxes; + + // Carriage danger strips lying just inside the primary zone's boundary, one per + // boundary that faces an adjacent Mirror ZONE -- an aggregated gantry contributes the + // one pinned zone it paints, not one per head -- in right / left / top / bottom order. + // Width is the literal nozzle clearance for that axis. Copy tools never contribute: + // they travel in the same direction as the primary and cannot close on it. + std::vector collision_zones; + + // Advisory (non-blocking) bands immediately inside each collision strip, present only + // when imex_carriage_margin > 0. Same boundary order as `collision_zones`. + std::vector margin_bands; +}; + +// Computes the IMEX zone layout for one plate. +// +// `printer_cfg` — the edited printer preset's config. Read for is_imex, the tool grid +// (imex_gantry_count / imex_tools_per_gantry / imex_tool_layout), the +// mode roster (imex_mode_names / imex_mode_active_tools) and the strip +// widths (imex_nozzle_clearance_x / _y, imex_carriage_margin). A key absent +// from the config falls back to the value registered for that option in +// print_config_def, via the imex_cfg_* accessors. +// `plate_mode` — the plate's own IMEX mode. Wins over the process preset unless it is +// `kImexPrimaryMode`. +// `process_mode` — the process preset's `imex_parallel_mode`, used only as the fallback +// when `plate_mode` is `kImexPrimaryMode`. Pass "" when unavailable. +// `bed_extents` — XY extents of the plate shape, in plate-local mm. Zones subdivide it. +// +// Zone sizing keys off the count of ACTIVE tools per axis rather than the grid dimensions, +// so a tool the mode leaves out donates its share of the bed to its active neighbours. +ImexZoneLayout compute_imex_zone_layout(const DynamicPrintConfig& printer_cfg, + const std::string& plate_mode, + const std::string& process_mode, + const BoundingBoxf& bed_extents); + +} // namespace Slic3r diff --git a/src/libslic3r/Preset.cpp b/src/libslic3r/Preset.cpp index 5b8127b122..78fa0785ef 100644 --- a/src/libslic3r/Preset.cpp +++ b/src/libslic3r/Preset.cpp @@ -1473,6 +1473,9 @@ static std::vector s_Preset_print_options{ "interlocking_boundary_avoidance", "interlocking_beam_width", "calib_flowrate_topinfill_special_order", + // IDEX/IQEX parallel print mode (per-print selection) + "imex_parallel_mode", + "imex_head_filament_map", // Z Anti-Aliasing (ZAA) "zaa_enabled", "zaa_minimize_perimeter_height", @@ -1591,6 +1594,11 @@ static std::vector s_Preset_printer_options { "z_offset", "disable_m73", "preferred_orientation", "emit_machine_limits_to_gcode", "pellet_modded_printer", "support_multi_bed_types", "use_3mf", "default_bed_type", "bed_mesh_min","bed_mesh_max","bed_mesh_probe_distance", "adaptive_bed_mesh_margin", "enable_long_retraction_when_cut","long_retractions_when_cut","retraction_distances_when_cut", "bed_temperature_formula", "nozzle_flush_dataset", + // IDEX/IQEX (independent X extruder) — printer capability and user-defined modes + "is_imex", "imex_firmware_managed_zones", + "imex_gantry_count", "imex_tools_per_gantry", "imex_tool_layout", + "imex_nozzle_clearance_x", "imex_nozzle_clearance_y", "imex_carriage_margin", "imex_viz_theme", + "imex_mode_names", "imex_mode_active_tools", "imex_mode_gcodes", // Multi-nozzle count + pre-heat model printer options "extruder_max_nozzle_count", "group_algo_with_time", "enable_pre_heating", "hotend_heating_rate", "hotend_cooling_rate", "machine_hotend_change_time", "machine_prepare_compensation_time", diff --git a/src/libslic3r/PresetBundle.cpp b/src/libslic3r/PresetBundle.cpp index 7ec2b59ca0..572e7bb1c3 100644 --- a/src/libslic3r/PresetBundle.cpp +++ b/src/libslic3r/PresetBundle.cpp @@ -114,6 +114,12 @@ static std::vector s_project_options { "nozzle_volume_type", "filament_map_mode", "filament_map", + // physical_extruder_map intentionally NOT here: it's owned by the printer + // preset (s_Preset_printer_options). Listing it project-scoped caused + // project_config's default [0] to clobber the preset's authored value + // (e.g. AFC-shaped [0,1,1,1,1]) during full_fff_config() merge, and the + // clobbered value then rode into saved 3mfs and back into the edited + // preset on reload. // Per-filament nozzle-volume choice; project-level like filament_map so the per-filament // slot resolution survives preset switches. "filament_volume_map", diff --git a/src/libslic3r/Print.cpp b/src/libslic3r/Print.cpp index 5603716064..026a6fce9b 100644 --- a/src/libslic3r/Print.cpp +++ b/src/libslic3r/Print.cpp @@ -55,6 +55,8 @@ #include "TriangleMesh.hpp" #include "Config.hpp" #include "Exception.hpp" +#include "IMEXHelpers.hpp" +#include "IMEXZones.hpp" #include "Print.hpp" #include "BoundingBox.hpp" #include "Brim.hpp" @@ -519,6 +521,12 @@ bool Print::invalidate_state_by_config_options(const ConfigOptionResolver & /* n } else if (opt_key == "z_hop_types") { osteps.emplace_back(posDetectOverhangsForLift); + } + // The bed-zone colour scheme and the advisory margin bands are drawn from the printer + // config but never reach the slice: compute_imex_zone_layout keeps the margin out of + // primary_zone_box, which is the only part of the layout update_imex_slice_offset reads. + // imex_tool_layout does move that box, so it is left to the fallback below. + else if (opt_key == "imex_viz_theme" || opt_key == "imex_carriage_margin") { } else { // for legacy, if we can't handle this option let's invalidate all steps //FIXME invalidate all steps of all objects as well? @@ -1851,6 +1859,132 @@ StringObjectException Print::validate(std::vector *warnin if (extruders.empty()) return { L("No extrusions under current settings.") }; + // IDEX/IQEX: block multi-color in non-primary parallel modes when the active + // configuration can't physically support it (no within-gantry toolchange path + // or MMU lane sharing). See imex_multicolor_block_reason() for the full rule. + // Both IMEX checks below need the active mode's tools string and its declared primary, + // so imex_resolve_routing() derives them once for the whole block -- and is the same call + // the plater's pre-slice warning makes, so the two cannot describe the plate differently. + // Note this block is NOT gated on extruders.size() > 1: the primary + // routing check applies to a single-filament plate too, which is its most common case. + if (m_config.is_imex.value && !m_objects.empty()) { + const std::string& parallel_mode = m_objects.front()->config().imex_parallel_mode.value; + if (!parallel_mode.empty() && parallel_mode != kImexPrimaryMode) { + // Tool changes added from the layer slider switch heads mid-print as a painted color + // does, so they count here, as the plate's warning badge counts them. + const std::vector imex_extruders = this->extruders(/*conside_custom_gcode=*/true); + std::vector used_filaments_0b; + std::vector used_slots_1b; + used_filaments_0b.reserve(imex_extruders.size()); + used_slots_1b.reserve(imex_extruders.size()); + for (unsigned int e : imex_extruders) { + used_filaments_0b.push_back((int)e); + used_slots_1b.push_back((int)e + 1); + } + + // The active mode's tools string, its declared primary, and whether this plate's + // filaments reach that primary are all derived ONCE, here. The plater's pre-slice + // warning (collect_imex_warnings) makes the identical call, so the sentence it + // shows before slicing and the refusal below cannot describe the plate + // differently. An unresolved mode, and a mode the tools array is too short to + // cover, both yield an empty roster and no primary. + const ImexRouting routing = imex_resolve_routing(m_config, parallel_mode, used_slots_1b, + m_config.physical_extruder_map); + + // A mixed filament is blended at the nozzle by its component toolheads, and a + // parallel mode is already using those toolheads to print copies or mirrors, so + // the two cannot run at once -- regardless of where the components are routed. + // + // Runs before BOTH rules below. Against the routing rule it would otherwise be + // reported as merely unrouted: mixed slots are kept at the tail of the filament + // arrays, past the physical filament count, so on a printer whose logical extruder + // count equals that count they fall outside physical_extruder_map and resolve to no + // head. Against the multi-color rule, a mixed slot plus any second filament trips + // the >1 gate and earns a lecture about gantry topology the user never configured. + // Being unsupported outright, this dominates both. + { + const auto& is_mixed = m_config.filament_is_mixed.values; + if (std::any_of(used_filaments_0b.begin(), used_filaments_0b.end(), + [&](int slot) { return slot >= 0 && slot < (int) is_mixed.size() && is_mixed[slot]; })) { + StringObjectException err; + // "Mixed filament" is the term the rest of the UI uses -- the button that + // creates one, and the sibling refusal for the wipe tower filament. + err.string = L("Mixed filaments are not supported in IDEX/IQEX parallel modes. " + "Switch this plate to Primary mode."); + err.object = m_objects.front(); + return err; + } + } + + // Multi-color has the more specific rule and its remedies are self-contained + // (an MMU manifold sharing one head cannot be fixed by switching mode), so it runs + // ahead of the routing block below, which would otherwise mask it with advice that + // leads to this error on the next slice. validate() returns on the first error. + if (used_filaments_0b.size() > 1) { + const std::string reason = imex_multicolor_block_reason( + parallel_mode, + routing.active_tools, + m_config.imex_tools_per_gantry.value, + used_filaments_0b, + m_config.physical_extruder_map); + if (!reason.empty()) + return { reason }; + } + + // The IMEX Primary tool prints the sliced paths directly, so it can only use a + // filament the printer's physical_extruder_map routes to it. The ghost filament + // picker already enforces this for the secondary tools; the primary's filament + // comes from the ordinary object filament selector, which has no IMEX awareness, + // so nothing detected the mismatch. collect_imex_warnings() reads the SAME + // routing.primary_logical off the same derivation, and discards it into a display + // fallback so its warning still has a filament to name. + // + // Blocks rather than warns, matching the multi-color rule above -- but on intent, + // not on physics, and the distinction matters to anyone tempted to relax it. The + // emitter does not use the declared primary: it re-derives an effective one from + // the filament actually in use (GCode.cpp's initial_extruder_id -> pem lookups), so + // a plate whose only filament sits on a Span tool sharing the primary's gantry does + // produce coherent G-code. It is refused anyway. A parallel mode exists to run + // carriages in parallel; a single-colour plate riding one span lane is not that, and + // silently accepting it would make the mode's declared roster meaningless. + // + // Where the routed head is absent from the mode's active tools the plate is broken + // outright, not merely off-intent: the 1st->2nd layer temperature + // branch (GCode.cpp, mutually exclusive with the standard path) skips it too, so + // that head holds nozzle_temperature_initial_layer for the whole job. First-layer + // temperatures are unaffected -- _print_first_layer_extruder_temperatures is not + // IMEX-branched -- so this is a stuck-hot nozzle, not a cold one. + // + // `primary_unrouted` is the whole condition: a declared primary, a populated + // routing map, and nothing used reaching it. Blended slots would also read as + // unrouted -- they sit past the map by construction -- but they never get here, + // the mixed-filament rule above returns first. + if (routing.primary_unrouted) { + std::string routed_list; + for (int head : routing.routed_heads) + routed_list += (routed_list.empty() ? "T" : ", T") + std::to_string(head); + // Print::apply() normalises physical_extruder_map through + // effective_physical_extruder_map (PrintApply.cpp) before validate() runs, so + // an unauthored map arrives here as the identity and every slot is in range. + // What is left is a printer with more filaments than logical extruders, where + // the tail slots fall outside the map. Narrow, but without this the sentence + // ends in a dangling "on .". + if (routed_list.empty()) + routed_list = L("no configured tool"); + + StringObjectException err; + err.string = Slic3r::format( + L("IDEX/IQEX mode \"%1%\" prints with T%2%, but this plate's filaments are on %3%. " + "Assign a filament loaded on T%2%, or switch this plate to Primary mode."), + parallel_mode, routing.primary_phys, routed_list); + // Gives the notification a "Jump to " link, which selects the object + // and switches to Prepare -- directly enabling the first suggested remedy. + err.object = m_objects.front(); + return err; + } + } + } + // Belt printer validation: incompatible features. if (m_config.belt_printer.value) { for (const PrintObject *object : m_objects) { @@ -2969,6 +3103,10 @@ void Print::process(long long *time_cost_with_cache, bool use_cache) name_tbb_thread_pool_threads_set_locale(); + // IMEX firmware-managed zones: settle the emission-frame shift from the applied config + // before anything runs. Zero for every printer that is not IMEX + firmware-managed. + this->update_imex_slice_offset(); + //compute the PrintObject with the same geometries BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": this=%1%, enter, use_cache=%2%, object size=%3%")%this%use_cache%m_objects.size(); if (m_objects.empty()) @@ -3688,7 +3826,16 @@ std::string Print::export_gcode(const std::string& path_template, GCodeProcessor gcode = std::make_unique(); //BBS: compute plate offset for gcode-generator const Vec3d origin = this->get_plate_origin(); - gcode->set_gcode_offset(origin(0), origin(1)); + // IMEX firmware-managed zones: writer offset gets the IMEX shift so emitted gcode is + // centered at bed origin; processor offset stays at plate_origin so the gcode-preview + // visualizer renders the centered slice at the bed center rather than the prepare-view + // zone placement. Both arms reduce to set_gcode_offset() semantics when shift is zero. + // Derived here rather than read as pushed-in state, so an exporter reached without a + // preceding process() (or after a config change that only invalidated psGCodeExport) + // still emits in the frame the current config asks for. + this->update_imex_slice_offset(); + const Vec2d imex_off = this->get_imex_slice_offset(); + gcode->set_gcode_offset_with_imex_shift(origin(0), origin(1), imex_off.x(), imex_off.y()); gcode->do_export(this, path.c_str(), result, thumbnail_cb); gcode->export_layer_filaments(result); //BBS @@ -4116,14 +4263,60 @@ Points Print::first_layer_wipe_tower_corners(bool check_wipe_tower_existance) co return corners; } +// IMEX firmware-managed zones: derive the slice-time XY shift from the config that has +// already been applied to this Print, so the engine never has to be told what it can work +// out. Deriving it here rather than taking a push from PartPlate is what makes a headless +// slice correct: the CLI builds a PartPlateList but drives no plater, so nothing ever ran +// the GUI-side handoff and the shift silently stayed at zero while the mode G-code told the +// firmware to fan copies out — parts in the wrong place, no diagnostic. +// +// Inputs, all reachable without a GUI: +// * m_full_print_config — the printer preset's IMEX keys, read from the full config so this +// sees the same values the preset holds whichever representation carries them. +// * the plate's `imex_parallel_mode`, read off the object config. That is the same source +// GCode.cpp and validate() resolve the active mode from (the plate's own config is +// merged into the full config before apply() by BackgroundSlicingProcess in the GUI and +// by CLI_Main in the CLI), so the shift can never disagree with the mode actually +// emitted. compute_imex_zone_layout() yields no zones for a mode the printer does not +// define, matching GCode.cpp's fallback to Primary for an unresolved mode name. +// * printable_area — the bed in PLATE-LOCAL mm. This must not be the plate's world-frame +// outline: translate_to_print_space() and the writer offset already subtract m_origin, +// so an offset carrying the plate origin would subtract it twice and put every plate +// but the first one a full plate stride out. +// +// A non-IMEX printer, or one with firmware-managed zones off, costs two bool reads and +// keeps m_imex_slice_offset at exactly Vec2d::Zero() — which is what keeps its G-code +// byte-identical. +void Print::update_imex_slice_offset() +{ + m_imex_slice_offset = Vec2d::Zero(); + if (!m_config.is_imex.value || !m_config.imex_firmware_managed_zones.value || m_objects.empty()) + return; + + // Already the resolved plate-or-process mode: the plate config overrides the process + // preset's key on the way in, exactly as PartPlate::get_imex_mode() prefers the plate. + const std::string& active_mode = m_objects.front()->config().imex_parallel_mode.value; + const ImexZoneLayout layout = compute_imex_zone_layout(m_full_print_config, active_mode, + std::string(), + get_extents(m_config.printable_area.values)); + // Empty / Primary mode and an empty layout both come back as Vec2d::Zero() here. + m_imex_slice_offset = compute_imex_slice_offset(true, active_mode, layout.primary_zone_box); +} + //SoftFever +// "Print space" is the frame the emitted gcode uses. IMEX firmware-managed mode +// shifts that frame by `m_imex_slice_offset` (the primary zone center in plate-local +// coords) so the centered slice can be fanned out by firmware. Offset is zero in +// every other case → identical behavior for non-firmware-managed printers. Vec2d Print::translate_to_print_space(const Vec2d &point) const { //const BoundingBoxf bed_bbox(config().printable_area.values); - return Vec2d(point(0) - m_origin(0), point(1) - m_origin(1)); + return Vec2d(point(0) - m_origin(0) - m_imex_slice_offset.x(), + point(1) - m_origin(1) - m_imex_slice_offset.y()); } Vec2d Print::translate_to_print_space(const Point &point) const { - return Vec2d(unscaled(point.x()) - m_origin(0), unscaled(point.y()) - m_origin(1)); + return Vec2d(unscaled(point.x()) - m_origin(0) - m_imex_slice_offset.x(), + unscaled(point.y()) - m_origin(1) - m_imex_slice_offset.y()); } FilamentTempType Print::get_filament_temp_type(const std::string& filament_type) diff --git a/src/libslic3r/Print.hpp b/src/libslic3r/Print.hpp index 45e8703168..735edac824 100644 --- a/src/libslic3r/Print.hpp +++ b/src/libslic3r/Print.hpp @@ -1287,6 +1287,18 @@ public: //BBS: plate's origin related functions void set_plate_origin(Vec3d origin) { m_origin = origin; } const Vec3d get_plate_origin() const { return m_origin; } + // IMEX firmware-managed zones: additional XY shift applied at gcode emission so the + // primary zone's contents land at bed origin (0,0). Non-zero only when + // `imex_firmware_managed_zones` is on AND the active mode is non-primary; Vec2d::Zero() + // in every other case → byte-identical gcode output for everyone else. + // + // Derived, never pushed in: update_imex_slice_offset() recomputes it from the applied + // config, so a headless slice gets the same shift a GUI slice does. It is a pure + // function of the config + printable_area, so calling it twice is a no-op; process() + // and export_gcode() both call it, which is what settles it ahead of every consumer + // (the writer offset in export_gcode(), and translate_to_print_space()). + void update_imex_slice_offset(); + Vec2d get_imex_slice_offset() const { return m_imex_slice_offset; } //BBS: export gcode from previous gcode file from 3mf void set_gcode_file_ready(); void set_gcode_file_invalidated(); @@ -1539,6 +1551,9 @@ private: //BBS: plate's origin Vec3d m_origin {0, 0, 0}; + // IMEX firmware-managed slice offset (plate-local primary-zone center, or zero). + // Cache of update_imex_slice_offset(); nothing outside that function writes it. + Vec2d m_imex_slice_offset { Vec2d::Zero() }; //BBS: modified_count int m_modified_count {0}; //BBS diff --git a/src/libslic3r/PrintApply.cpp b/src/libslic3r/PrintApply.cpp index 096f505542..5fed82211d 100644 --- a/src/libslic3r/PrintApply.cpp +++ b/src/libslic3r/PrintApply.cpp @@ -1,4 +1,5 @@ #include "ClipperUtils.hpp" +#include "IMEXHelpers.hpp" #include "Geometry.hpp" #include "CustomGCode.hpp" #include "Config.hpp" @@ -1385,6 +1386,35 @@ Print::ApplyStatus Print::apply(const Model &model, DynamicPrintConfig new_full_ } } + // Fill in physical_extruder_map when the printer profile has not authored one. It has one + // entry per logical extruder, so it is sized from the nozzle count -- not from + // printer_extruder_id, which is indexed by variant slot. + // + // IMEX printers only. physical_extruder_map carries two readings in this tree (see + // IMEXHelpers.hpp): the IMEX paths need one entry per logical extruder, the inherited BBL + // paths read it through the clamping get_at(), which collapses every logical extruder to + // physical 0 while the profile default is the single-element {0}. Deriving the identity + // unconditionally would impose the IMEX reading on printers that mean the other one and + // change what they emit -- {first_tools}/{first_filaments}/{first_non_support_*} and + // {most_used_physical_extruder_id}/{curr_physical_extruder_id} in custom start G-code, plus + // the `; physical_extruder_map =` line in the G-code config block -- for every multi-nozzle + // profile that never authored a map. Non-IMEX printers therefore keep the config value + // exactly as it arrives, which is what upstream slices with. + const auto* is_imex_opt = new_full_config.option("is_imex"); + if (is_imex_opt && is_imex_opt->value) { + auto* pem = new_full_config.option("physical_extruder_map", true); + if (pem) { + pem->values = effective_physical_extruder_map(pem, extruder_count).values; + // m_ori_full_print_config was snapshotted above, before this derivation, and the + // selector write-back path rebuilds m_full_print_config from that snapshot. Without + // mirroring the derived map into it, m_full_print_config keeps the unexpanded default + // while every later apply re-derives the expanded one, so an unchanged config diffs + // on physical_extruder_map forever and invalidates all steps on every re-apply. + if (auto* ori_pem = m_ori_full_print_config.option("physical_extruder_map", true)) + ori_pem->values = pem->values; + } + } + auto opt_filament_map = new_full_config.option("filament_map"); std::vector filament_maps = opt_filament_map ? opt_filament_map->values : std::vector(); diff --git a/src/libslic3r/PrintConfig.cpp b/src/libslic3r/PrintConfig.cpp index cf9b79367e..cf4f2db88d 100644 --- a/src/libslic3r/PrintConfig.cpp +++ b/src/libslic3r/PrintConfig.cpp @@ -274,6 +274,7 @@ static t_config_enum_values s_keys_map_WipeTowerType { }; CONFIG_OPTION_ENUM_DEFINE_STATIC_MAPS(WipeTowerType) + static t_config_enum_values s_keys_map_FuzzySkinMode { { "displacement", int(FuzzySkinMode::Displacement) }, { "extrusion", int(FuzzySkinMode::Extrusion) }, @@ -720,6 +721,24 @@ static const t_config_enum_values s_keys_map_FilamentMapMode = { }; CONFIG_OPTION_ENUM_DEFINE_STATIC_MAPS(FilamentMapMode) +// IMEX: keep the legacy hyphenated wire strings so existing presets / 3MFs +// deserialize unchanged after the coString -> coEnum migration. +static const t_config_enum_values s_keys_map_ImexToolLayout = { + { "front-left", int(ImexToolLayout::FrontLeft) }, + { "front-right", int(ImexToolLayout::FrontRight) }, + { "rear-left", int(ImexToolLayout::RearLeft) }, + { "rear-right", int(ImexToolLayout::RearRight) } +}; +CONFIG_OPTION_ENUM_DEFINE_STATIC_MAPS(ImexToolLayout) + +static const t_config_enum_values s_keys_map_ImexVizTheme = { + { "standard", int(ImexVizTheme::Standard) }, + { "deuteranopia", int(ImexVizTheme::Deuteranopia) }, + { "tritanopia", int(ImexVizTheme::Tritanopia) }, + { "high_contrast", int(ImexVizTheme::HighContrast) } +}; +CONFIG_OPTION_ENUM_DEFINE_STATIC_MAPS(ImexVizTheme) + // PrimeVolumeMode. Serialized string keys must stay stable; they round-trip through .3mf. static const t_config_enum_values s_keys_map_PrimeVolumeMode = { { "Default", pvmDefault }, @@ -6904,6 +6923,139 @@ void PrintConfigDef::init_fff_params() def->mode = comAdvanced; def->set_default_value(new ConfigOptionBool(true)); + // IDEX/IQEX (independent X extruder) — parallel printing support for IDEX/IQEX printers. + // Every key in this group is read at slice time from m_config, and the two per-plate process + // keys (imex_parallel_mode, imex_head_filament_map) round-trip through the 3MF's + // model_settings.config plate metadata, so none of them needs to appear in the exported + // g-code. They all carry non-nil defaults, so emitting them would add a line to every + // printer's dump. Kept out of the g-code config block (banned_keys). + def = this->add("is_imex", coBool); + def->label = L("IDEX/IQEX Printer"); + def->tooltip = L("Enable parallel printing for printers with multiple independent carriages (IDEX, IQEX, and similar)."); + def->mode = comAdvanced; + def->set_default_value(new ConfigOptionBool(false)); + + // Firmware-managed zones: slice the primary zone's contents at bed origin (0,0) + // so a center-origin printer firmware can apply its own copy/mirror offsets to + // each toolhead. The slicer's zone overlay, placement check, and mode signaling + // are unchanged; only the gcode emission frame shifts. + def = this->add("imex_firmware_managed_zones", coBool); + def->label = L("Firmware-managed zones"); + def->tooltip = L("When enabled, the slicer emits g-code with the primary zone's " + "contents centered on bed origin (0,0). The printer firmware is " + "responsible for placing copies/mirrors at each physical zone " + "position. Enable only on printers whose firmware applies its own " + "zone offsets in copy/mirror mode (e.g. RepRapFirmware IDEX " + "duplication mode). Has no effect in Primary mode."); + def->mode = comAdvanced; + def->set_default_value(new ConfigOptionBool(false)); + + def = this->add("imex_gantry_count", coInt); + def->label = L("Gantry Count"); + def->tooltip = L("Number of independent Y-axis gantries. 1 for IDEX/single-rail IQEX. 2 for dual-gantry systems (divides bed into rows)."); + def->min = 1; + def->max = 2; + def->mode = comAdvanced; + def->set_default_value(new ConfigOptionInt(1)); + + def = this->add("imex_tools_per_gantry", coInt); + def->label = L("Tools per Gantry"); + def->tooltip = L("Number of independent toolheads per gantry along X. 2 for IDEX-style."); + def->min = 1; + def->max = 4; + def->mode = comAdvanced; + def->set_default_value(new ConfigOptionInt(2)); + + def = this->add("imex_tool_layout", coEnum); + def->label = L("Tool 0 Position"); + def->tooltip = L("Physical position on the bed where tool T0 (index 0) is located. " + "Determines how tool indices map to bed zones. " + "For single-gantry setups (IDEX) only left/right matters; " + "for dual-gantry setups (IQEX) all four corners are selectable " + "(front = lower Y / near the operator, rear = higher Y / back of machine)."); + def->mode = comAdvanced; + def->enum_keys_map = &ConfigOptionEnum::get_enum_values(); + def->enum_values.push_back("front-left"); + def->enum_values.push_back("front-right"); + def->enum_values.push_back("rear-left"); + def->enum_values.push_back("rear-right"); + def->enum_labels.push_back(L("Front-left")); + def->enum_labels.push_back(L("Front-right")); + def->enum_labels.push_back(L("Rear-left")); + def->enum_labels.push_back(L("Rear-right")); + def->set_default_value(new ConfigOptionEnum(ImexToolLayout::FrontLeft)); + + def = this->add("imex_nozzle_clearance_x", coFloat); + def->label = L("Nozzle Clearance X"); + def->tooltip = L("Distance (mm) from the nozzle to the collision-side carriage edge in X. Used to calculate the width of the collision exclusion strip at each X boundary."); + def->sidetext = L("mm"); + def->min = 0; + def->mode = comAdvanced; + def->set_default_value(new ConfigOptionFloat(30.0)); + + def = this->add("imex_nozzle_clearance_y", coFloat); + def->label = L("Nozzle Clearance Y"); + def->tooltip = L("Distance (mm) from the nozzle to the collision-side carriage edge in Y. Used to calculate the width of the collision exclusion strip at each Y boundary."); + def->sidetext = L("mm"); + def->min = 0; + def->mode = comAdvanced; + def->set_default_value(new ConfigOptionFloat(30.0)); + + def = this->add("imex_carriage_margin", coFloat); + def->label = L("Safety Margin"); + def->tooltip = L("Non-blocking advisory clearance strip (mm) drawn inside the primary zone at each carriage boundary. Parts placed within this strip will still slice; it is a visual reminder to leave extra clearance near the zone edge."); + def->sidetext = L("mm"); + def->min = 0; + def->max = 200; + def->mode = comAdvanced; + def->set_default_value(new ConfigOptionFloat(0.0)); + + def = this->add("imex_viz_theme", coEnum); + def->label = L("Visualization Theme"); + def->tooltip = L("Color theme for IDEX/IQEX bed zone visualization. Choose a colorblind-friendly theme if needed."); + def->mode = comAdvanced; + def->enum_keys_map = &ConfigOptionEnum::get_enum_values(); + def->enum_values.push_back("standard"); + def->enum_values.push_back("deuteranopia"); + def->enum_values.push_back("tritanopia"); + def->enum_values.push_back("high_contrast"); + def->enum_labels.push_back(L("Standard")); + def->enum_labels.push_back(L("Deuteranopia / Protanopia (red-green)")); + def->enum_labels.push_back(L("Tritanopia (blue-yellow)")); + def->enum_labels.push_back(L("High Contrast")); + def->set_default_value(new ConfigOptionEnum(ImexVizTheme::Standard)); + + def = this->add("imex_parallel_mode", coString); + def->label = L("IDEX/IQEX Print Mode"); + def->tooltip = L("Name of the active IDEX/IQEX parallel print mode, or \"primary\" for single-carriage printing. Requires IDEX/IQEX Printer enabled in the Printer preset (Printer \u2192 Multimaterial \u2192 IDEX/IQEX Configuration)."); + def->mode = comAdvanced; + def->set_default_value(new ConfigOptionString("primary")); + + def = this->add("imex_head_filament_map", coString); + def->label = L("IDEX/IQEX head filament map"); + def->tooltip = L("Per-plate override mapping physical heads to filament slots " + "(1-based). Empty means use pem-inversion defaults."); + def->mode = comDevelop; + def->set_default_value(new ConfigOptionString("")); + + def = this->add("imex_mode_names", coStrings); + def->label = L("IDEX/IQEX Mode Names"); + def->tooltip = L("Display names for each user-defined IDEX/IQEX parallel print mode."); + def->mode = comAdvanced; + def->set_default_value(new ConfigOptionStrings()); + + def = this->add("imex_mode_active_tools", coStrings); + def->label = L("IDEX/IQEX Mode Active Tools"); + def->tooltip = L("Tool role assignments for each mode. Format: \"idx:P,idx:C,idx:M\" where P=Primary, C=Copy, M=Mirror (e.g. \"0:P,1:C,2:M,3:M\"). Managed by the IDEX/IQEX Modes editor in the Printer preset."); + def->mode = comAdvanced; + def->set_default_value(new ConfigOptionStrings()); + + def = this->add("imex_mode_gcodes", coStrings); + def->label = L("IDEX/IQEX Mode G-codes"); + def->tooltip = L("G-code or macro call to inject at print start for each mode."); + def->mode = comAdvanced; + def->set_default_value(new ConfigOptionStrings()); + def = this->add("manual_filament_change", coBool); def->label = L("Manual Filament Change"); def->tooltip = L("Enable this option to omit the custom Change filament G-code only at the beginning of the print. " @@ -9711,6 +9863,24 @@ void PrintConfigDef::handle_legacy(t_config_option_key &opt_key, std::string &va else if (opt_key == "wall_direction" && value == "auto") { value = "ccw"; } + // Orca: the IDEX/IQEX parallel printing keys shipped to testers as ixex_* before the feature + // was renamed IMEX, and the two clearance keys were renamed again to say what they measure: + // nozzle to carriage edge on the collision side, not the carriage's full width. Without this + // an existing printer profile loses every one of these values silently. + // is_ixex is the master gate: without it every other key below migrates into a feature that + // stays switched off, which is worse than losing them all, because the settings then look + // configured. ixex_primary_col/_row are the only era-1 keys with no modern counterpart (the + // primary is a role in the mode's tools string now); they were never in an option list, so no + // saved file carries them, and the has() check at the end of this function drops them anyway. + else if (opt_key == "is_ixex") { + opt_key = "is_imex"; + } else if (opt_key.compare(0, 5, "ixex_") == 0) { + opt_key = "imex_" + opt_key.substr(5); + if (opt_key == "imex_carriage_width_x") + opt_key = "imex_nozzle_clearance_x"; + else if (opt_key == "imex_carriage_width_y") + opt_key = "imex_nozzle_clearance_y"; + } // Ignore the following obsolete configuration keys: static std::set ignore = { @@ -13064,6 +13234,9 @@ OtherSlicingStatesConfigDef::OtherSlicingStatesConfigDef() new_def("initial_no_support_extruder", coInt, "Initial no support extruder", "Zero-based index of the first extruder used for printing without support. Same as initial_no_support_tool."); new_def("in_head_wrap_detect_zone", coBool, "In head wrap detect zone", "Indicates if the first layer overlaps with the head wrap zone."); new_def("curr_bed_type", coString, "Current bed type", "Name of the currently selected bed plate type (e.g. 'Textured PEI Plate', 'Smooth High Temp Plate')."); + new_def("imex_mode", coString, "IDEX/IQEX active mode", "Name of the active IDEX/IQEX parallel print mode for this plate (e.g. 'primary', 'mirror', 'copy'). Empty string if IDEX/IQEX is not enabled."); + new_def("imex_mode_index", coInt, "IDEX/IQEX active mode index", "Zero-based index of the active IDEX/IQEX parallel print mode within imex_mode_names."); + new_def("imex_mode_gcode", coString, "IDEX/IQEX active mode G-code", "The raw mode G-code template for the active IDEX/IQEX parallel print mode, after placeholder evaluation. Globals defined here flow into machine_start_gcode."); } PrintStatisticsConfigDef::PrintStatisticsConfigDef() diff --git a/src/libslic3r/PrintConfig.hpp b/src/libslic3r/PrintConfig.hpp index 0e1e146c72..8b724a7058 100644 --- a/src/libslic3r/PrintConfig.hpp +++ b/src/libslic3r/PrintConfig.hpp @@ -250,6 +250,26 @@ enum class WallDirection Count, }; +// IMEX: physical bed corner where tool T0 sits. Determines how tool indices +// map to bed zones (front = lower Y near operator, rear = higher Y). +enum class ImexToolLayout { + FrontLeft, + FrontRight, + RearLeft, + RearRight, + Count, +}; + +// IMEX: color theme for the bed-zone visualization. Colorblind-friendly variants +// are provided for users with color-vision differences. +enum class ImexVizTheme { + Standard, + Deuteranopia, + Tritanopia, + HighContrast, + Count, +}; + // Orca: print order of surface fill loops/fragments for center-based fill patterns // (Concentric, Archimedean Chords, Octagram Spiral). enum class SurfaceFillOrder { @@ -1396,6 +1416,13 @@ PRINT_CONFIG_CLASS_DEFINE( // Orca: internal use only ((ConfigOptionBool, calib_flowrate_topinfill_special_order)) // ORCA: special flag for flow rate calibration + + // IDEX/IQEX parallel print mode (per-print selection, stores mode name or "primary") + ((ConfigOptionString, imex_parallel_mode)) + // Per-plate head→filament override for MMU-equipped printers. + // Serialized as compact "phys:slot,phys:slot" (1-based filament slots, matches UI). + // Empty string means "use first_filament_for_physical_head defaults everywhere". + ((ConfigOptionString, imex_head_filament_map)) ) // This object is mapped to Perl as Slic3r::Config::PrintRegion. @@ -1768,6 +1795,19 @@ PRINT_CONFIG_CLASS_DEFINE( ((ConfigOptionStrings, filament_start_gcode)) ((ConfigOptionBool, single_extruder_multi_material)) ((ConfigOptionBool, manual_filament_change)) + // IDEX/IQEX (independent X extruder) — parallel printing support for IDEX/IQEX printers + ((ConfigOptionBool, is_imex)) + ((ConfigOptionBool, imex_firmware_managed_zones)) + ((ConfigOptionInt, imex_gantry_count)) + ((ConfigOptionInt, imex_tools_per_gantry)) + ((ConfigOptionFloat, imex_nozzle_clearance_x)) + ((ConfigOptionFloat, imex_nozzle_clearance_y)) + ((ConfigOptionFloat, imex_carriage_margin)) + ((ConfigOptionEnum, imex_tool_layout)) + ((ConfigOptionEnum, imex_viz_theme)) + ((ConfigOptionStrings, imex_mode_names)) + ((ConfigOptionStrings, imex_mode_active_tools)) + ((ConfigOptionStrings, imex_mode_gcodes)) ((ConfigOptionBool, single_extruder_multi_material_priming)) ((ConfigOptionEnum, toolchange_ordering)) ((ConfigOptionString, toolchange_cyclic_order)) diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index de3b69565d..4c63c11e30 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -283,6 +283,10 @@ set(SLIC3R_GUI_SOURCES GUI/FilamentMapDialog.hpp GUI/IconManager.cpp GUI/IconManager.hpp + GUI/IMEXFilamentPickerPopover.cpp + GUI/IMEXFilamentPickerPopover.hpp + GUI/IMEXModesCtrl.cpp + GUI/IMEXModesCtrl.hpp GUI/IdleScheduler.cpp GUI/IdleScheduler.hpp GUI/ImageGrid.cpp diff --git a/src/slic3r/GUI/GCodeViewer.cpp b/src/slic3r/GUI/GCodeViewer.cpp index 988fe4d22d..44201b34ae 100644 --- a/src/slic3r/GUI/GCodeViewer.cpp +++ b/src/slic3r/GUI/GCodeViewer.cpp @@ -4,10 +4,13 @@ #include "libslic3r/BuildVolume.hpp" #include "libslic3r/Print.hpp" #include "libslic3r/Geometry.hpp" +#include "libslic3r/TriangleMesh.hpp" #include "libslic3r/Model.hpp" #include "libslic3r/Utils.hpp" #include "libslic3r/LocalesUtils.hpp" #include "libslic3r/PresetBundle.hpp" +#include "libslic3r/IMEXHelpers.hpp" +#include "libslic3r/IMEXZones.hpp" //BBS: add convex hull logic for toolpath check #include "GUI_App.hpp" @@ -25,6 +28,7 @@ #include "MsgDialog.hpp" #include #include "slic3r/GUI/MeshUtils.hpp" +#include #include #include "libvgcode/include/Types.hpp" #include @@ -89,6 +93,7 @@ #include #include #include +#include #include #include #include @@ -320,7 +325,7 @@ void GCodeViewer::SequentialView::Marker::init(std::string filename) } else { m_model.init_from_file(filename); } - m_model.set_color({ 1.0f, 1.0f, 1.0f, 0.5f }); + m_model.set_color(default_color()); } //BBS: GUI refactor: add canvas size from parameters @@ -1094,9 +1099,13 @@ void GCodeViewer::SequentialView::GCodeWindow::stop_mapping_file() } void GCodeViewer::SequentialView::render_marker(const bool has_render_path, int canvas_width, int canvas_height, const libvgcode::EViewType& view_type) { - if (has_render_path && m_show_marker) + if (has_render_path && m_show_marker) { // marker.set_world_offset(current_offset); marker.render(canvas_width, canvas_height, view_type); + // IDEX/IQEX secondary carriage markers + for (auto& sec : m_imex_secondary_markers) + sec.render(canvas_width, canvas_height, view_type); + } } void GCodeViewer::SequentialView::render_overlay(const bool has_render_path, float legend_height, const libvgcode::Viewer* viewer, uint32_t gcode_id, int canvas_width, int canvas_height, int right_margin, const libvgcode::EViewType& view_type) @@ -1156,6 +1165,7 @@ void GCodeViewer::init(ConfigOptionMode mode, PresetBundle* preset_bundle) } } + m_marker_filename = filename; m_sequential_view.marker.init(filename); // initializes point sizes @@ -1973,6 +1983,223 @@ void GCodeViewer::reset() m_contained_in_bed = true; } +// IDEX/IQEX: resolve where every secondary carriage marker sits, relative to the primary, +// for one (printer preset, active mode, plate bed) triple. This parses the mode string +// several times over and rebuilds the zone grid, and every one of its inputs is preset or +// plate state, so render_scene() runs it only when GCodeViewer::ImexMarkerKey changes and replays +// the returned plan on all the frames in between. +// +// `mode` is already resolved (the plate's mode beats the process preset). An empty +// `carriages` list means "no secondary markers for this configuration" — IMEX off in this +// mode, firmware-managed zones, or a mode whose roster has no zone-owning secondary. +GCodeViewer::ImexMarkerPlan GCodeViewer::resolve_imex_marker_plan(const DynamicPrintConfig& printer_cfg, + const std::string& mode, + const BoundingBoxf& bed_extents) +{ + ImexMarkerPlan plan; + + // Firmware-managed-zones centers the slice at bed origin, so prim_pos + // and the preview toolpaths sit in a shifted frame relative to the + // plate-local bed bounds the zone math uses. Computing secondaries here + // would place them off-bed. The firmware physically fans the centered + // toolpath out into the zones, so the centered preview with no + // secondaries is the honest representation. + if (imex_cfg_bool(printer_cfg, "imex_firmware_managed_zones")) + return plan; + if (mode.empty() || mode == kImexPrimaryMode) + return plan; + + // Only the gantry grouping and the mirror axis are decided here; the zone grid itself comes + // from compute_imex_zone_layout() below, which reads the same keys through the same accessor. + const int tools_per_gantry = std::max(1, imex_cfg_int(printer_cfg, "imex_tools_per_gantry")); + plan.box_wx = (float)imex_cfg_float(printer_cfg, "imex_nozzle_clearance_x"); + plan.box_wy = (float)imex_cfg_float(printer_cfg, "imex_nozzle_clearance_y"); + + // Parse "idx:P/C/M" via shared helpers — matches PartPlate::calc_imex_zones. + // The role travels as an ImexRole all the way to the marker placement + // below; there is no int encoding in between, so a role added to the enum + // cannot silently fall into the "not Mirror, therefore Copy" branch. + int pri_tool = -1; + std::vector sec_tool_ids; + std::map sec_tool_roles; // tool_id -> Copy or Mirror + std::set sec_aggregated; // representatives standing in for a whole gantry + { + // Same lookup rule as PartPlate's zones and ghosts, and as the slicer: + // an unresolved mode, and a mode the tools array is too short to cover, + // both come back empty and leave the marker roster empty. + const std::string entry = find_imex_mode(printer_cfg, mode).active_tools; + pri_tool = imex_primary_tool_for_mode(entry); + // Aggregate per gantry via the same source of truth as PartPlate's + // zone/ghost aggregation: in Span modes only one tool prints per + // zone at a time, so a non-primary gantry collapses to one + // representative marker. Non-Span gantries keep per-tool markers. + const ImexGantryGrouping grouping = + group_imex_active_tools_by_gantry(entry, tools_per_gantry); + auto add_tool = [&](int phys_idx, ImexRole role, bool aggregated) { + if (phys_idx < 0 || phys_idx == pri_tool) return; + // Only Copy and Mirror print in a zone of their own, so only they + // get a marker. Exhaustive with no default so -Wswitch makes a new + // role answer "does this carriage get its own marker?" rather than + // defaulting into a Copy-shaped one. + switch (role) { + case ImexRole::Copy: + case ImexRole::Mirror: + break; + case ImexRole::Primary: // not a secondary carriage + case ImexRole::Span: // rides in the primary's zone + return; + } + sec_tool_roles[phys_idx] = role; + sec_tool_ids.push_back(phys_idx); + if (aggregated) sec_aggregated.insert(phys_idx); + }; + // Out-of-grid tool indices (a stale mode string carried over from a + // printer with more tools) are handled inconsistently by the code we + // must agree with: compute_imex_zone_layout drops them, so they own no + // zone, while calc_imex_ghosts still bakes a ghost for them. No single + // policy matches both, so the marker roster is left exactly as it was + // and such a tool simply falls back to the first zone below. Clamping + // pri_tool instead is a trap: the -1 sentinel truncates to gantry 0 in + // imex_mirror_axis_for and flips the marker to the opposite mirror + // axis from the ghost. It is also why the layout's own `primary_head` + // cannot stand in for `pri_tool` here — the layout reports -1 for an + // off-grid primary, this roster has to keep the authored index. + for (const auto& grp : grouping.groups) { + // Markers: an aggregated non-primary gantry shows only its rep. + if (grp.aggregate && grp.gantry_index != grouping.primary_gantry) + add_tool(grp.representative_phys, grp.representative_role, true); + else + for (const auto& [phys_idx, role] : grp.tools) + add_tool(phys_idx, role, false); + } + } + const int sec_count = (int)sec_tool_ids.size(); + if (sec_count == 0) + return plan; + + // The zone grid is not re-derived here: this is the same call + // PartPlate::calc_imex_zones() makes, with the same printer config and the + // same bed extents, so the preview and the plate cannot place a tool + // differently for the same mode. The T0-corner flips, the active + // column/row sets and the zone pitch all live inside the library; the + // markers need nothing from it but `head_zone_centers`. + // `mode` is already resolved (per-plate mode beats the process preset), + // so it goes in as the plate mode with no process fallback. + const ImexZoneLayout zone_layout = + compute_imex_zone_layout(printer_cfg, mode, std::string(), bed_extents); + + // Centre of zone (0,0), the fallback for any head the layout gave no zone — + // a mode string naming a tool outside this printer's grid. Zone indices are + // dense, so the lowest centre on each axis IS zone 0's. An empty layout + // means one undivided zone, whose centre is the bed's. + Vec2d zone0_center = bed_extents.center(); + if (!zone_layout.head_zone_centers.empty()) { + zone0_center = zone_layout.head_zone_centers.begin()->second; + for (const auto& head_center : zone_layout.head_zone_centers) { + zone0_center.x() = std::min(zone0_center.x(), head_center.second.x()); + zone0_center.y() = std::min(zone0_center.y(), head_center.second.y()); + } + } + auto zone_center_of = [&](int tid) -> Vec2d { + auto it = zone_layout.head_zone_centers.find(tid); + return (it == zone_layout.head_zone_centers.end()) ? zone0_center : it->second; + }; + const Vec2d pri_center = zone_center_of(pri_tool); + + // An aggregated gantry's zone is a full-X row strip with no column of its + // own, so its marker tracks primary's X. calc_imex_ghosts() reaches the same + // zero X offset by putting both frames on the bed centreline; for a single + // marker, pinning the centre says it directly. + auto sec_center_of = [&](int tid) -> Vec2d { + Vec2d c = zone_center_of(tid); + if (sec_aggregated.count(tid)) + c.x() = pri_center.x(); + return c; + }; + + // Primary carriage box: seeded from whether primary holds the leftmost zone + // column, then turned by any secondary sitting to one side of it. + plan.pri_box_offset_x = (pri_center.x() <= zone0_center.x()) ? 0.0f : -plan.box_wx; + plan.pri_box_offset_y = -plan.box_wy; + for (int i = 0; i < sec_count; ++i) { + const Vec2d sc = sec_center_of(sec_tool_ids[i]); + if (sc.x() > pri_center.x()) { plan.pri_box_offset_x = 0.0f; } + else if (sc.x() < pri_center.x()) { plan.pri_box_offset_x = -plan.box_wx; } + if (sc.y() > pri_center.y()) { plan.pri_box_offset_y = 0.0f; } + else if (sc.y() < pri_center.y()) { plan.pri_box_offset_y = -plan.box_wy; } + } + + plan.pri_head = pri_tool; + plan.carriages.reserve(sec_count); + for (int i = 0; i < sec_count; ++i) { + const int sec_tool = sec_tool_ids[i]; + const Vec2d sec_center = sec_center_of(sec_tool); + const ImexRole sec_role = sec_tool_roles[sec_tool]; + + // A carriage stays inside its own zone; a Mirror reflects its + // zone-relative offset about that zone's centerline, on the axis of the + // boundary it shares with primary — the same rule the ghosts use: + // Copy → tracks primary on both axes. + // Mirror, same gantry → reflect X (zones sit side by side). + // Mirror, other gantry → reflect Y (zones sit front-to-back); X + // tracks primary, since the part off that + // gantry is a Y-reflection of the tool + // directly behind it. + // The axis comes from the shared helper, so the markers can never drift + // out of step with the plate ghosts. + const bool is_mirror = (sec_role == ImexRole::Mirror); + const bool cross_gantry = imex_mirror_axis_for(pri_tool, sec_tool, + tools_per_gantry) == ImexMirrorAxis::Y; + + // Tracking an axis translates by the gap between the two zone centres. + // Mirroring it reflects primary about the midpoint of those centres — + // the zones being equal-sized, that midpoint is the boundary between + // them, so the carriage still lands inside its own zone. Both are a + // single term applied to the live primary position each frame: + // `term - pos` when mirrored, `pos + term` when tracked. + ImexMarkerPlan::Carriage carriage; + carriage.phys_head = sec_tool; + carriage.mirror_x = is_mirror && !cross_gantry; + carriage.mirror_y = is_mirror && cross_gantry; + carriage.x_term = carriage.mirror_x ? (float)(sec_center.x() + pri_center.x()) + : (float)(sec_center.x() - pri_center.x()); + carriage.y_term = carriage.mirror_y ? (float)(sec_center.y() + pri_center.y()) + : (float)(sec_center.y() - pri_center.y()); + + // The box shows the side a toolhead could COLLIDE from, not merely which + // way its body hangs. Tools sharing a gantry share an X rail and can + // actually run into each other, and only in a same-gantry (X-axis) mirror + // do they converge — so that is the one case where the secondary's box + // flips to face the primary. Tools on different gantries cannot collide + // in X at all, so a cross-gantry mirror keeps the primary's facing, the + // same as a Copy. Do not "fix" this to follow carriage geometry: a box on + // the far side would point away from the only tool it can hit. + if (is_mirror && !cross_gantry) { + if (sec_center.x() > pri_center.x()) carriage.box_offset_x = -plan.box_wx; + else if (sec_center.x() < pri_center.x()) carriage.box_offset_x = 0.0f; + else carriage.box_offset_x = plan.pri_box_offset_x; + } else { + carriage.box_offset_x = plan.pri_box_offset_x; + } + // Y follows the same collision rule: face the gantry you could hit. A tool + // on ANOTHER row faces the primary's row; a tool on the primary's OWN row + // shares its gantry and can only hit the same other gantry, so it faces + // wherever the primary faces. The old `else` hardcoded -imex_box_wy, which + // happens to equal pri_box_offset_y on the rear-* layouts (primary's row + // sits above the others, so the loop above settles on -box_wy anyway) + // — hence a no-op there. On the front-* layouts the primary flips to 0.0f + // and the hardcoded value pointed the same-gantry secondary away from the + // only tools it could run into. + if (sec_center.y() > pri_center.y()) carriage.box_offset_y = -plan.box_wy; + else if (sec_center.y() < pri_center.y()) carriage.box_offset_y = 0.0f; + else carriage.box_offset_y = plan.pri_box_offset_y; + + plan.carriages.push_back(carriage); + } + + return plan; +} + //BBS: GUI refactor: add canvas width and height void GCodeViewer::render_scene(int canvas_width, int canvas_height) { @@ -1990,7 +2217,242 @@ void GCodeViewer::render_scene(int canvas_width, int canvas_height) const libvgcode::PathVertex& curr_vertex = m_viewer.get_current_vertex(); m_sequential_view.marker.set_world_position(libvgcode::convert(curr_vertex.position)); m_sequential_view.marker.set_z_offset(m_z_offset + 0.5f); + + // IDEX/IQEX: compute all carriage positions; update secondary nozzle markers; + // carriage_box_draws is populated for toolhead footprint rendering after render_marker(). + // Okabe-Ito colorblind-safe palette — excludes orange (#E69F00) and sky blue (#56B4E9) + // which are used by the bed zone fills, ensuring the markers contrast against the background. + // It identifies the carriages in the views that colour by anything but filament; in Filament + // view a carriage takes its own head's filament colour instead, so a marker, the ghost of + // what it prints and the toolpaths under it all agree on what is loaded there. + static const std::array s_carriage_colors = {{ + { 1.000f, 1.000f, 1.000f, 0.65f }, // primary — white + { 0.941f, 0.894f, 0.259f, 0.65f }, // secondary 1 — yellow (#F0E442) + { 0.835f, 0.369f, 0.000f, 0.65f }, // secondary 2 — vermilion (#D55E00) + { 0.800f, 0.475f, 0.655f, 0.65f }, // secondary 3 — reddish purple (#CC79A7) + }}; + struct CarriageDraw { Vec3f pos; ColorRGBA color; float box_offset_x = 0.0f; float box_offset_y = 0.0f; }; + std::vector carriage_box_draws; + float imex_box_wx = 0.0f, imex_box_wy = 0.0f; + { + PresetBundle* preset_bundle = wxGetApp().preset_bundle; + bool imex_active = false; + if (preset_bundle && m_sequential_view.m_show_marker) { + const DynamicPrintConfig& printer_cfg = preset_bundle->printers.get_edited_preset().config; + auto* is_imex_opt = printer_cfg.opt("is_imex"); + if (is_imex_opt && is_imex_opt->value) { + const DynamicPrintConfig& process_cfg = preset_bundle->prints.get_edited_preset().config; + auto* mode_opt = process_cfg.opt("imex_parallel_mode"); + std::string mode = mode_opt ? mode_opt->value : kImexPrimaryMode; + // Per-plate mode overrides the process preset. + PartPlateList& plate_list = wxGetApp().plater()->get_partplate_list(); + PartPlate* curr_plate = plate_list.get_curr_plate(); + if (curr_plate) { + // Same resolution the plate itself uses, rather than a fourth copy of it. + const std::string plate_mode = curr_plate->get_effective_imex_mode(); + if (plate_mode != kImexPrimaryMode) + mode = plate_mode; + } + + // Bed X and Y bounds — read from the current plate's shape, which is + // in world/GL coordinates (same space as curr_vertex.position), and is + // the exact same source PartPlate::calc_imex_zones() hands to the library. + BoundingBoxf bed_extents; + { + const Pointfs& plate_shape = curr_plate ? curr_plate->get_shape() : Pointfs{}; + if (!plate_shape.empty()) { + bed_extents = get_extents(plate_shape); + } else { + // Fallback: use toolpath bounding box extent + bed_extents = BoundingBoxf(Vec2d(m_paths_bounding_box.min.x(), m_paths_bounding_box.min.y()), + Vec2d(m_paths_bounding_box.max.x(), m_paths_bounding_box.max.y())); + } + } + + // Cache guard. The marker plan below is resolved from preset/plate state only, + // so it is rebuilt when that state moves and reused otherwise — the sequential + // slider is sticky, and re-resolving the zone layout on every frame of the rest + // of the session showed up as a measurable per-frame cost. The comparison is + // deliberately made against the live config rather than against a freshly built + // key, so an unchanged frame allocates nothing at all. + static const std::vector s_no_strings; + auto* names_opt = printer_cfg.opt("imex_mode_names"); + auto* tools_opt = printer_cfg.opt("imex_mode_active_tools"); + const std::vector& mode_names = names_opt ? names_opt->values : s_no_strings; + const std::vector& active_tools = tools_opt ? tools_opt->values : s_no_strings; + + ImexMarkerKey::Scalars key; + key.plate_index = plate_list.get_curr_plate_index(); + // Same accessor the plan is built from: a key holding a different fallback than + // the value actually laid out would compare equal across a real change. + key.gantry_count = imex_cfg_int(printer_cfg, "imex_gantry_count"); + key.tools_per_gantry = imex_cfg_int(printer_cfg, "imex_tools_per_gantry"); + key.tool_layout = (int)imex_cfg_enum(printer_cfg, "imex_tool_layout"); + key.clearance_x = imex_cfg_float(printer_cfg, "imex_nozzle_clearance_x"); + key.clearance_y = imex_cfg_float(printer_cfg, "imex_nozzle_clearance_y"); + key.firmware_managed = imex_cfg_bool(printer_cfg, "imex_firmware_managed_zones"); + key.bed_min_x = bed_extents.min.x(); + key.bed_min_y = bed_extents.min.y(); + key.bed_max_x = bed_extents.max.x(); + key.bed_max_y = bed_extents.max.y(); + + if (!(m_imex_marker_key.s == key) || m_imex_marker_key.mode != mode || + m_imex_marker_key.mode_names != mode_names || + m_imex_marker_key.mode_active_tools != active_tools) { + m_imex_marker_key = ImexMarkerKey{ key, mode, mode_names, active_tools }; + m_imex_marker_plan = resolve_imex_marker_plan(printer_cfg, mode, bed_extents); + // The carriage roster — and with it the marker count — can change with any + // of those inputs, so drop the markers and let the lazy init below rebuild + // them. Their colours are restamped every frame regardless. + m_sequential_view.m_imex_secondary_markers.clear(); + } + + const ImexMarkerPlan& plan = m_imex_marker_plan; + const int sec_count = (int)plan.carriages.size(); + + // Filament view colours the toolpaths by the filament printing them, so a + // carriage takes its own head's filament colour there and the palette + // everywhere else. get_imex_head_filament_color() answers UNPRINTABLE_COLOR + // for a head it cannot resolve, which is alpha 0.5 where a filament colour + // decodes opaque - that difference, not the black, is what distinguishes it, + // and a head with no filament keeps the palette rather than borrowing a + // colour that would read as a real assignment - the one point where a marker + // and the ghost of what it prints differ, the ghost having no palette to fall + // back to. + // + // The map and the pem are hoisted and each head resolved once, as + // PartPlate::update_imex_ghost_colors() does for the ghosts: both the marker + // and the toolhead box of a carriage want the same answer, and rebuilding it + // per draw parses the plate's map string and rebuilds the pem vector. + const bool color_by_filament = m_viewer.get_view_type() == libvgcode::EViewType::ColorPrint; + std::vector carriage_colors(sec_count + 1); + if (sec_count > 0) { + const ConfigOptionInts pem = effective_physical_extruder_map(*preset_bundle); + const auto plate_map = curr_plate ? curr_plate->get_imex_head_filament_map() + : std::map{}; + std::map head_colors; + auto color_for = [&](int phys_head, size_t palette_index) { + const ColorRGBA fallback = s_carriage_colors[palette_index % s_carriage_colors.size()]; + if (!color_by_filament || curr_plate == nullptr || phys_head < 0) + return fallback; + auto hc = head_colors.find(phys_head); + if (hc == head_colors.end()) + hc = head_colors.emplace(phys_head, + curr_plate->get_imex_head_filament_color(phys_head, pem, plate_map)).first; + if (hc->second == GLVolume::UNPRINTABLE_COLOR) + return fallback; + ColorRGBA c = hc->second; + c.a(fallback.a()); // as translucent as the palette makes them + return c; + }; + carriage_colors[0] = color_for(plan.pri_head, 0); + for (int i = 0; i < sec_count; ++i) + carriage_colors[i + 1] = color_for(plan.carriages[i].phys_head, i + 1); + } + if (sec_count > 0) { + imex_active = true; + imex_box_wx = plan.box_wx; + imex_box_wy = plan.box_wy; + + // Lazy init secondary nozzle markers + if ((int)m_sequential_view.m_imex_secondary_markers.size() != sec_count) { + m_sequential_view.m_imex_secondary_markers.resize(sec_count); + for (int i = 0; i < sec_count; ++i) { + m_sequential_view.m_imex_secondary_markers[i].init(m_marker_filename); + } + } + // Set every frame, not once at init: the view type and the filament palette + // both change without the marker list being rebuilt. The primary marker is + // one of the carriages here, so it is coloured alongside them - and restored + // below when no mode is active, since it is the same marker every non-IMEX + // preview draws. + m_sequential_view.marker.set_color(carriage_colors[0]); + for (int i = 0; i < sec_count; ++i) + m_sequential_view.m_imex_secondary_markers[i].set_color(carriage_colors[i + 1]); + + const Vec3f prim_pos = libvgcode::convert(curr_vertex.position); + carriage_box_draws.push_back({ prim_pos, carriage_colors[0], + plan.pri_box_offset_x, plan.pri_box_offset_y }); + + for (int i = 0; i < sec_count; ++i) { + const ImexMarkerPlan::Carriage& carriage = plan.carriages[i]; + // Tracking an axis translates the primary by the gap between the two + // zone centres; mirroring reflects it about the midpoint of those + // centres — the zones being equal-sized, that midpoint is the boundary + // between them, so the carriage still lands inside its own zone. Which + // axis does which, and both terms, come from resolve_imex_marker_plan(). + const Vec3f sec_pos{ carriage.mirror_x ? carriage.x_term - prim_pos.x() + : prim_pos.x() + carriage.x_term, + carriage.mirror_y ? carriage.y_term - prim_pos.y() + : prim_pos.y() + carriage.y_term, + prim_pos.z() }; + m_sequential_view.m_imex_secondary_markers[i].set_world_position(sec_pos); + m_sequential_view.m_imex_secondary_markers[i].set_z_offset(m_z_offset + 0.5f); + carriage_box_draws.push_back({ + sec_pos, carriage_colors[i + 1], + carriage.box_offset_x, carriage.box_offset_y }); + } + } + } + } + if (!imex_active) { + m_sequential_view.m_imex_secondary_markers.clear(); + // The primary marker is the one every preview draws, so the carriage colour it may + // have been given has to come back off when no parallel mode is active. + m_sequential_view.marker.set_color(SequentialView::Marker::default_color()); + } + } m_sequential_view.render_marker(!m_no_render_path, canvas_width, sequential_view_height(canvas_height), m_viewer.get_view_type()); + + // IDEX/IQEX: render toolhead footprint boxes for each active carriage. + // Each box is imex_nozzle_clearance_x × imex_nozzle_clearance_y, sitting above the nozzle tip. + // Hidden when the user toggles off "Show IDEX/IQEX Toolhead Boxes" in the View menu — gives + // an unobstructed view of the toolpaths during sequential playback. Default-on; the absence + // of an app_config entry is also treated as on. + const bool show_toolhead_boxes = wxGetApp().app_config->get("show_imex_toolhead_boxes") != "false"; + if (show_toolhead_boxes && !carriage_box_draws.empty() && imex_box_wx > 0.0f && imex_box_wy > 0.0f) { + // The mesh depends only on the two clearance dimensions, which a config edit can + // change without a G-code reload — so rebuild it when they move (or when it has not + // been built yet) rather than on every frame. + // Mesh origin: nozzle at x=0, centered in Y, Z starts at nozzle tip level. + // Per-carriage box_offset_x shifts the mesh left or right so the nozzle lands + // at the correct (collision-side) edge. + if (!m_imex_toolhead_box.is_initialized() || + m_imex_box_mesh_wx != imex_box_wx || m_imex_box_mesh_wy != imex_box_wy) { + const float box_h = std::max(imex_box_wx, imex_box_wy); + indexed_triangle_set its = its_make_cube((double)imex_box_wx, (double)imex_box_wy, (double)box_h); + // No vertex pre-shifting — box_offset_x/y in the per-carriage transform + // positions the nozzle at the correct collision-side edge. + m_imex_toolhead_box.reset(); + m_imex_toolhead_box.init_from(its); + m_imex_box_mesh_wx = imex_box_wx; + m_imex_box_mesh_wy = imex_box_wy; + } + + GLShaderProgram* shader = wxGetApp().get_shader("gouraud_light"); + if (shader) { + glsafe(::glEnable(GL_BLEND)); + glsafe(::glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)); + shader->start_using(); + shader->set_uniform("emission_factor", 0.0f); + const Camera& camera = wxGetApp().plater()->get_camera(); + shader->set_uniform("projection_matrix", camera.get_projection_matrix()); + const Transform3d& view_matrix = camera.get_view_matrix(); + for (const auto& draw : carriage_box_draws) { + const Transform3d model_matrix = Geometry::translation_transform( + (draw.pos + Vec3f(draw.box_offset_x, draw.box_offset_y, m_z_offset)).cast()); + 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); + m_imex_toolhead_box.set_color(draw.color); + m_imex_toolhead_box.render(); + } + shader->stop_using(); + glsafe(::glDisable(GL_BLEND)); + } + } } size_t GCodeViewer::shadow_casters_signature() const @@ -3291,6 +3753,7 @@ void GCodeViewer::render_all_plates_stats(const std::vector #include "slic3r/GUI/MeshUtils.hpp" #include +#include #include #include #include @@ -116,6 +117,9 @@ public: bool is_visible() const { return m_visible; } void set_visible(bool visible) { m_visible = visible; } + // What a marker looks like when no IDEX/IQEX carriage colour applies to it. + static ColorRGBA default_color() { return { 1.0f, 1.0f, 1.0f, 0.5f }; } + void set_color(const ColorRGBA& color) { m_model.set_color(color); } void render(int canvas_width, int canvas_height, const libvgcode::EViewType& view_type); void render_position_window(const libvgcode::Viewer* viewer, int canvas_width, int canvas_height, const libvgcode::EViewType& view_type); @@ -164,6 +168,7 @@ public: }; Marker marker; + std::vector m_imex_secondary_markers; // one per active secondary carriage in IDEX/IQEX mode GCodeWindow gcode_window; float m_scale = 1.0; bool m_show_marker = false; @@ -228,6 +233,95 @@ private: ConfigOptionMode m_user_mode; bool m_fold = {false}; + std::string m_marker_filename; // cached for lazy secondary marker init + + // IDEX/IQEX: everything render_scene() needs to place the secondary carriage markers and the + // toolhead footprint boxes, resolved from the printer preset, the active mode and the + // plate bed. Resolving it walks the mode string through compute_imex_zone_layout() + // (several string parses plus a zone-grid rebuild), and none of its inputs change + // between frames, so it is resolved once per input change -- see ImexMarkerKey -- and + // replayed on every other frame. + struct ImexMarkerPlan + { + // One entry per secondary carriage that owns a zone, in marker order. Each axis + // either TRACKS the primary (pos + term) or MIRRORS it about the boundary the two + // zones share (term - pos); which of the two, and the term itself, depend only on + // the zone geometry, so both are resolved up front. + struct Carriage + { + int phys_head = -1; // physical head this carriage is, for its filament colour + bool mirror_x = false; + bool mirror_y = false; + float x_term = 0.0f; + float y_term = 0.0f; + float box_offset_x = 0.0f; + float box_offset_y = 0.0f; + }; + std::vector carriages; // empty => no secondary carriages to draw + int pri_head = -1; // the primary's physical head, same purpose + float pri_box_offset_x = 0.0f; + float pri_box_offset_y = 0.0f; + float box_wx = 0.0f; // imex_nozzle_clearance_x / _y + float box_wy = 0.0f; + }; + + // Invalidation key for the plan above: every input the plan is derived from, and + // nothing that changes between frames. A stale plan would put the preview markers + // somewhere the plate's own zones and ghosts do not agree with, which is exactly the + // drift the shared layout call exists to prevent -- so this deliberately mirrors + // PartPlate::build_imex_cache_key(). The two keys are not field-for-field identical, and + // the differences are deliberate rather than incidental: + // - bed extents and plate_index are here and not there. Zone centres scale with the + // extents, and one preview serves every plate, so the preview must key what the plate + // gets for free -- it re-bakes on set_shape() and is keyed by being that plate. + // - mode_names / mode_active_tools hold the printer's WHOLE mode table; the plate resolves + // one active mode and keys that roster plus its primary head. Same information reached + // two ways, so a change to the active mode moves both keys. + // - imex_carriage_margin is there and not here: it only sizes the plate's advisory bands, + // which the preview never draws. + // imex_tool_layout is in both, which the plate's key gained for the reason this one has it: + // the T0-corner flip moves every zone rectangle while nothing else keyed changes. + struct ImexMarkerKey + { + // Scalar half. Built fresh on the stack each frame -- it allocates nothing -- and + // compared as a tuple. + struct Scalars + { + int plate_index = -1; + int gantry_count = 0; + int tools_per_gantry = 0; + int tool_layout = -1; + double clearance_x = 0.0; + double clearance_y = 0.0; + bool firmware_managed = false; + double bed_min_x = 0.0, bed_min_y = 0.0, bed_max_x = 0.0, bed_max_y = 0.0; + + auto tied() const { + return std::tie(plate_index, gantry_count, tools_per_gantry, tool_layout, + clearance_x, clearance_y, firmware_managed, + bed_min_x, bed_min_y, bed_max_x, bed_max_y); + } + bool operator==(const Scalars& rhs) const { return tied() == rhs.tied(); } + }; + Scalars s; + // Resolved active mode (the plate's mode beats the process preset), plus the printer + // preset's whole mode table. The name alone is not identity: two presets can carry + // the same mode name over different tool rosters, and editing a roster in place + // moves neither the name nor any scalar above. Compared by value, never copied + // unless something actually changed. + std::string mode; + std::vector mode_names; + std::vector mode_active_tools; + }; + ImexMarkerKey m_imex_marker_key; + ImexMarkerPlan m_imex_marker_plan; + static ImexMarkerPlan resolve_imex_marker_plan(const DynamicPrintConfig& printer_cfg, + const std::string& mode, + const BoundingBoxf& bed_extents); + + GLModel m_imex_toolhead_box; // shared box mesh for all carriage footprint overlays + float m_imex_box_mesh_wx{ 0.0f }; // clearance dimensions m_imex_toolhead_box was built for + float m_imex_box_mesh_wy{ 0.0f }; size_t m_extruders_count; std::vector m_filament_diameters; @@ -306,7 +400,7 @@ public: //BBS: add all plates filament statistics void render_all_plates_stats(const std::vector& gcode_result_list, bool show = true) const; //BBS: GUI refactor: add canvas width and height - // Shells, toolpaths and the sequential marker, drawn in 3D. + // Shells, toolpaths, the sequential markers and the IDEX/IQEX toolhead boxes, drawn in 3D. void render_scene(int canvas_width, int canvas_height); // Legend, sliders, the marker's position window and the G-code window, all ImGui. void render_overlay(int canvas_width, int canvas_height, int right_margin); diff --git a/src/slic3r/GUI/GLCanvas3D.cpp b/src/slic3r/GUI/GLCanvas3D.cpp index e1c8b9935c..c509e5d6f8 100644 --- a/src/slic3r/GUI/GLCanvas3D.cpp +++ b/src/slic3r/GUI/GLCanvas3D.cpp @@ -1,5 +1,6 @@ #include "libslic3r/libslic3r.h" #include "libslic3r/Point.hpp" +#include #include #include #include @@ -2658,11 +2659,19 @@ void GLCanvas3D::_render_frame(bool scene_dirty, 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) { @@ -5066,6 +5075,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) { if (fff_print()->config().print_sequence == PrintSequence::ByObject) update_sequential_clearance(); @@ -5239,6 +5250,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) @@ -5283,9 +5301,10 @@ void GLCanvas3D::on_mouse(wxMouseEvent& evt) } //BBS change plate selection + bool plate_icon_popup_shown = false; if (!m_hover_plate_idxs.empty() && (m_canvas_type == CanvasView3D) && !m_mouse.dragging) { int hover_idx = m_hover_plate_idxs.front(); - wxGetApp().plater()->select_plate_by_hover_id(hover_idx, true); + plate_icon_popup_shown = (wxGetApp().plater()->select_plate_by_hover_id(hover_idx, true) == 1); if (m_hover_volume_idxs.empty()) deselect_all(); render(); @@ -5305,10 +5324,10 @@ void GLCanvas3D::on_mouse(wxMouseEvent& evt) if (!m_mouse.ignore_right_up && m_gizmos.get_current_type() == GLGizmosManager::EType::Undefined) { //BBS post right click event - if (!m_hover_plate_idxs.empty()) { + if (!m_hover_plate_idxs.empty() && !plate_icon_popup_shown) { post_event(RBtnPlateEvent(EVT_GLCANVAS_PLATE_RIGHT_CLICK, { logical_pos, m_hover_plate_idxs.front() })); } - else { + else if (!plate_icon_popup_shown) { // do not post the event if the user is panning the scene // or if right click was done over the wipe tower bool post_right_click_event = m_hover_volume_idxs.empty() || !m_volumes.volumes[get_first_hover_volume_idx()]->is_wipe_tower; @@ -5618,6 +5637,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; } @@ -5728,6 +5751,9 @@ void GLCanvas3D::do_rotate(const std::string& snapshot_type) 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; } @@ -5828,6 +5854,9 @@ void GLCanvas3D::do_scale(const std::string& snapshot_type) 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; } @@ -5939,6 +5968,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; } @@ -7961,6 +7993,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); @@ -8061,6 +8098,180 @@ 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(m_mouse.position.x()), + static_cast(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::infinity(); + double tmax = std::numeric_limits::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; + }; + + // Match _render_imex_ghosts: only hit-test ghosts on the active plate. Picking + // through ghosts on background plates would hand the user a stale plate index + // for the head-filament popover and let them edit a plate they aren't looking at. + PartPlateList& ppl = wxGetApp().plater()->get_partplate_list(); + PartPlate* active_plate = ppl.get_curr_plate(); + if (active_plate) { + const auto& ghosts = active_plate->get_imex_ghost_volumes(); + 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 = ppl.get_curr_plate_index(); + return; // first hit wins + } + } + } +} + +void GLCanvas3D::_render_imex_ghosts(bool xray_pass) +{ + // Draws through whichever shader the caller bound: the shaded pass leaves gouraud current, + // the X-Ray pass its own program. Both are fed per volume below; the uniforms a pass sets + // once for itself are the caller's (see _render_imex_ghosts_xray for the X-Ray pass's). + // 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 { + 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); + + // Only render ghosts for the active plate. Iterating every plate here causes + // ghosts from background plates to bleed through into the active scene + // (e.g. when entering paint mode), since the GL state is shared across the + // whole canvas. Per-plate ghost volumes still live on each PartPlate so they + // round-trip through 3MF saves; we just don't draw them when their plate + // isn't the one the user is currently looking at. + PartPlateList& ppl = wxGetApp().plater()->get_partplate_list(); + PartPlate* active_plate = ppl.get_curr_plate(); + if (active_plate) { + // Refresh per-frame so ghost positions reflect the primary's live drag state, + // and restamp ghost RGB from the live filament palette so color-only changes + // (palette edits, late-loading project colors) never leave stale ghosts. + active_plate->update_imex_ghost_transforms(primary_live_xf); + active_plate->update_imex_ghost_colors(); + const auto& ghosts = active_plate->get_imex_ghost_volumes(); + 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(model_matrix.matrix().block(0, 0, 3, 3).inverse().transpose().cast())); + 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. + // X-Ray derives its own coverage from view angle and multiplies the colour's alpha + // into it, so a ghost carrying its translucency as well composites about three times + // fainter than the body it mirrors. Hand that pass an opaque colour and let its + // density be the only source of translucency. + ColorRGBA ghost_color = g->render_color; + if (xray_pass) + ghost_color.a(1.0f); + g->model.set_color(ghost_color); + g->render(); + } + } +} + +void GLCanvas3D::_render_imex_ghost_tooltip() +{ + if (m_hover_ghost_head < 0) + return; + + // Hover state can outlive the ghosts that produced it: _picking_pass_imex_ghosts + // only resets m_hover_ghost_head when it runs, and _picking_pass early-returns + // (mouse drag, mouse off-canvas, gizmo drag) skip that reset. If the user switches + // from an IMEX printer to a non-IMEX one during such a window, the plate clears its + // ghost volumes but the stale head index survives — producing an orphan tooltip. + // Validate against live ghosts and self-heal before rendering. + const PartPlate* plate = wxGetApp().plater()->get_partplate_list().get_plate(m_hover_ghost_plate); + if (!plate || plate->get_imex_ghost_volumes().empty()) { + m_hover_ghost_head = -1; + m_hover_ghost_plate = -1; + 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(); + const float cursor_offset = imgui.scaled(1.6f); // ~16px @ 100%, scales with DPI + imgui.set_next_window_pos(float(mouse.x()) + cursor_offset, float(mouse.y()) + cursor_offset, + 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); + // Color swatch + label on the same row. imgui.scaled() keeps it readable on high-DPI. + const ImVec4 col(t.swatch.r(), t.swatch.g(), t.swatch.b(), t.swatch.a()); + const float swatch_size = imgui.scaled(1.4f); + ImGui::ColorButton("##swatch", col, + ImGuiColorEditFlags_NoBorder | ImGuiColorEditFlags_NoTooltip, + ImVec2(swatch_size, swatch_size)); + ImGui::SameLine(); + imgui.text(t.label); + imgui.end(); +} + void GLCanvas3D::_rectangular_selection_picking_pass() { m_gizmos.set_hover_id(-1); @@ -9210,6 +9421,8 @@ void GLCanvas3D::_render_objects(GLVolumeCollection::ERenderType type, bool with if (_is_xray_view_active()) { if (type == GLVolumeCollection::ERenderType::Opaque) _render_xray_volumes(); + else + _render_imex_ghosts_xray(); m_camera_clipping_plane = ClippingPlane::ClipsNothing(); return; } @@ -9337,6 +9550,9 @@ void GLCanvas3D::_render_objects(GLVolumeCollection::ERenderType type, bool with } }, partly_inside_enable, printable_heights); + // 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(); break; } } @@ -9439,6 +9655,36 @@ void GLCanvas3D::_render_xray_volumes() glsafe(::glDepthMask(GL_TRUE)); } +// The IDEX/IQEX ghosts are not in m_volumes, so the X-Ray pass that replaces both shaded passes +// does not reach them on its own. They go through the X-Ray shader here rather than their own, +// which is what makes a ghost read as one more see-through body. +void GLCanvas3D::_render_imex_ghosts_xray() +{ + GLShaderProgram* shader = wxGetApp().get_shader("xray"); + if (shader == nullptr) + return; + + // The blend and depth state _render_xray_volumes() uses, plus its two-sided drawing: the + // shader shades back faces too, so a hollow ghost shows its far wall like a real body does. + glsafe(::glDepthMask(GL_FALSE)); + glsafe(::glEnable(GL_BLEND)); + glsafe(::glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)); + glsafe(::glDisable(GL_CULL_FACE)); + + shader->start_using(); + // _render_imex_ghosts() sets the per-volume matrices and the color, but the two uniforms + // the volume collection would have set for the whole pass are this pass's to supply, and + // the vertex shader discards everything outside z_range - an unset one hides every ghost. + shader->set_uniform("z_range", m_volumes.get_z_range()); + shader->set_uniform("clipping_plane", m_volumes.get_clipping_plane()); + _render_imex_ghosts(/*xray_pass=*/true); + shader->stop_using(); + + glsafe(::glEnable(GL_CULL_FACE)); + glsafe(::glDisable(GL_BLEND)); + glsafe(::glDepthMask(GL_TRUE)); +} + void GLCanvas3D::_render_wireframe_overlay() { if (!wxGetApp().plater()->is_show_wireframe()) diff --git a/src/slic3r/GUI/GLCanvas3D.hpp b/src/slic3r/GUI/GLCanvas3D.hpp index 70ef0d52b3..bb148234d1 100644 --- a/src/slic3r/GUI/GLCanvas3D.hpp +++ b/src/slic3r/GUI/GLCanvas3D.hpp @@ -776,6 +776,9 @@ private: //BBS:add plate related logic mutable std::vector m_hover_volume_idxs; std::vector m_hover_plate_idxs; + // IMEX ghost hover state (plate-owned transparent ghost volumes). + int m_hover_ghost_head { -1 }; // physical head index, -1 when not hovering a ghost + int m_hover_ghost_plate { -1 }; // plate index for the hovered ghost, -1 when none //BBS if explosion_ratio is changed, need to update volume bounding box mutable float m_explosion_ratio = 1.0; mutable Vec3d m_rotation_center{ 0.0, 0.0, 0.0}; @@ -1313,6 +1316,8 @@ public: int get_move_volume_id() const { return m_mouse.drag.move_volume_idx; } int get_first_hover_volume_idx() const { return m_hover_volume_idxs.empty() ? -1 : m_hover_volume_idxs.front(); } + int get_hover_ghost_head() const { return m_hover_ghost_head; } + int get_hover_ghost_plate() const { return m_hover_ghost_plate; } void set_selected_extruder(int extruder) { m_selected_extruder = extruder;} class WipeTowerInfo { @@ -1480,6 +1485,8 @@ private: void _picking_pass(); void _rectangular_selection_picking_pass(); + // IMEX ghost picking (ray vs. ghost bbox). Runs at the end of _picking_pass. + void _picking_pass_imex_ghosts(); bool _is_fxaa_enabled() const; bool _is_realistic_view_enabled() const; bool _is_ssao_enabled() const; @@ -1521,6 +1528,12 @@ private: void _render_cad_grid(const Transform3d& view_matrix, const Transform3d& projection_matrix); //BBS: add outline drawing logic void _render_objects(GLVolumeCollection::ERenderType type, bool with_outline = true); + // IMEX ghost volumes owned by PartPlate, drawn through the shader the caller bound: + // the shaded pass's transparent half, or the X-Ray pass below. + void _render_imex_ghosts(bool xray_pass = false); + void _render_imex_ghosts_xray(); + // IMEX ghost hover tooltip: filament swatch + label drawn as an ImGui overlay. + void _render_imex_ghost_tooltip(); void _render_section_view_caps(); void _render_wireframe_overlay(); bool _is_xray_view_active() const; diff --git a/src/slic3r/GUI/IMEXFilamentPickerPopover.cpp b/src/slic3r/GUI/IMEXFilamentPickerPopover.cpp new file mode 100644 index 0000000000..d0fb83f869 --- /dev/null +++ b/src/slic3r/GUI/IMEXFilamentPickerPopover.cpp @@ -0,0 +1,146 @@ +#include "slic3r/GUI/IMEXFilamentPickerPopover.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "libslic3r/IMEXHelpers.hpp" +#include "libslic3r/PresetBundle.hpp" +#include "slic3r/GUI/BitmapComboBox.hpp" +#include "slic3r/GUI/PartPlate.hpp" +#include "slic3r/GUI/GUI_App.hpp" +#include "slic3r/GUI/wxExtensions.hpp" + +namespace Slic3r { namespace GUI { + +IMEXFilamentPickerPopover::IMEXFilamentPickerPopover( + wxWindow* parent, PartPlate* plate, + const ConfigOptionInts& pem, int physical_head, + CommitCallback on_commit) + : wxPopupTransientWindow(parent, wxBORDER_SIMPLE) + , m_plate(plate) + , m_pem(pem) + , m_physical_head(physical_head) + , m_on_commit(std::move(on_commit)) +{ + m_root_sizer = new wxBoxSizer(wxHORIZONTAL); + SetSizer(m_root_sizer); + + build_row(); + + m_root_sizer->SetSizeHints(this); + SetClientSize(m_root_sizer->ComputeFittingClientSize(this)); + Layout(); +} + +void IMEXFilamentPickerPopover::popup_at_cursor() +{ + Position(wxGetMousePosition(), wxSize(0, 0)); + Popup(); +} + +void IMEXFilamentPickerPopover::OnDismiss() +{ + wxPopupTransientWindow::OnDismiss(); + // No CallAfter needed: wxPopupTransientWindowBase::Destroy() already defers by appending to + // wxPendingDelete, and guards a second call with a wxCHECK rather than double-freeing. + Destroy(); +} + +void IMEXFilamentPickerPopover::build_row() +{ + m_root_sizer->Clear(true); + + m_root_sizer->AddSpacer(10); + + auto* label = new wxStaticText(this, wxID_ANY, + wxString::Format("T%d", m_physical_head)); + m_root_sizer->Add(label, 0, wxALIGN_CENTER_VERTICAL | wxTOP | wxBOTTOM, 6); + + m_root_sizer->AddSpacer(10); + + std::vector lane_logicals; + for (size_t L = 0; L < m_pem.values.size(); ++L) + if (m_pem.values[L] == m_physical_head) + lane_logicals.push_back((int)L); + + std::vector bmps = get_extruder_color_icons(false /*thin_icon*/); + + auto* choice = new BitmapComboBox(this, wxID_ANY, wxEmptyString, + wxDefaultPosition, wxSize(140, -1), + 0, nullptr, wxCB_READONLY); + const int left_pad_px = 8; + auto pad_left = [&](const wxBitmap& src) -> wxBitmap { + if (!src.IsOk()) return src; + wxImage im(src.GetWidth() + left_pad_px, src.GetHeight()); + im.InitAlpha(); + unsigned char* a = im.GetAlpha(); + std::fill(a, a + im.GetWidth() * im.GetHeight(), (unsigned char)0); + wxBitmap out(im); + wxMemoryDC dc(out); + dc.DrawBitmap(src, left_pad_px, 0, true); + dc.SelectObject(wxNullBitmap); + return out; + }; + + for (int L : lane_logicals) { + const wxString label = wxString::Format("filament %d", L + 1); + if (L >= 0 && L < (int)bmps.size() && bmps[L] && bmps[L]->IsOk()) + choice->Append(label, pad_left(*bmps[L])); + else + choice->Append(label, wxNullBitmap); + } + + const auto map = m_plate->get_imex_head_filament_map(); + auto it = map.find(m_physical_head); + int selected_slot_1based = (it != map.end()) + ? it->second + : (lane_logicals.empty() ? -1 : lane_logicals.front() + 1); + + for (int i = 0; i < (int)lane_logicals.size(); ++i) { + if (lane_logicals[i] + 1 == selected_slot_1based) { + choice->SetSelection(i); + break; + } + } + + choice->Bind(wxEVT_COMBOBOX, [this, lane_logicals, choice](wxCommandEvent&) { + int sel = choice->GetSelection(); + if (sel < 0 || sel >= (int)lane_logicals.size()) return; + on_filament_selected(lane_logicals[sel] + 1); + // DismissAndNotify(), not Dismiss(): Dismiss() is only PopHandlers()+Hide() and never + // reaches OnDismiss(), so destroying from OnDismiss() would miss this path entirely -- + // which is the one users take on every successful pick. + DismissAndNotify(); + }); + + m_root_sizer->Add(choice, 0, wxALIGN_CENTER_VERTICAL | wxALL, 6); + m_root_sizer->AddSpacer(4); +} + +void IMEXFilamentPickerPopover::on_filament_selected(int slot_1_based) +{ + auto map = m_plate->get_imex_head_filament_map(); + // Erase when the user reselects the pem default for this head, so the 3mf + // doesn't carry no-op overrides (same invariant as PR3b). + const int default_logical = first_filament_for_physical_head(m_pem, m_physical_head); + if (default_logical >= 0 && slot_1_based == default_logical + 1) + map.erase(m_physical_head); + else + map[m_physical_head] = slot_1_based; + m_plate->set_imex_head_filament_map(map); + if (m_on_commit) m_on_commit(); +} + +}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/IMEXFilamentPickerPopover.hpp b/src/slic3r/GUI/IMEXFilamentPickerPopover.hpp new file mode 100644 index 0000000000..0a51b91f86 --- /dev/null +++ b/src/slic3r/GUI/IMEXFilamentPickerPopover.hpp @@ -0,0 +1,50 @@ +#ifndef slic3r_IMEXFilamentPickerPopover_hpp_ +#define slic3r_IMEXFilamentPickerPopover_hpp_ + +#include +#include +#include +#include + +#include + +#include "libslic3r/PrintConfig.hpp" + +namespace Slic3r { +namespace GUI { + +class PartPlate; + +class IMEXFilamentPickerPopover : public wxPopupTransientWindow +{ +public: + using CommitCallback = std::function; + + IMEXFilamentPickerPopover(wxWindow* parent, PartPlate* plate, + const ConfigOptionInts& pem, int physical_head, + CommitCallback on_commit); + + void popup_at_cursor(); + +private: + // Destroys the popover: the ghost-click handler creates one per click and keeps no + // reference, so every click would otherwise leak a live top-level window parented to the + // GL canvas. Only DismissAndNotify() reaches this -- plain Dismiss() just hides. + void OnDismiss() override; + void build_row(); + void on_filament_selected(int slot_1_based); + + PartPlate* m_plate; + // Owned by value: callers may pass a stack-local derived pem + // (e.g. effective_physical_extruder_map result) that won't outlive + // the popover's async lifetime. + ConfigOptionInts m_pem; + int m_physical_head; + CommitCallback m_on_commit; + + wxSizer* m_root_sizer = nullptr; +}; + +}} // namespace Slic3r::GUI + +#endif diff --git a/src/slic3r/GUI/IMEXModesCtrl.cpp b/src/slic3r/GUI/IMEXModesCtrl.cpp new file mode 100644 index 0000000000..ae6dee7085 --- /dev/null +++ b/src/slic3r/GUI/IMEXModesCtrl.cpp @@ -0,0 +1,897 @@ +#include "slic3r/GUI/IMEXModesCtrl.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "slic3r/GUI/EditGCodeDialog.hpp" +#include "slic3r/GUI/GUI.hpp" +#include "slic3r/GUI/GUI_App.hpp" +#include "slic3r/GUI/I18N.hpp" +#include "slic3r/GUI/Widgets/Button.hpp" +#include "slic3r/GUI/Widgets/ComboBox.hpp" +#include "slic3r/GUI/Widgets/StateColor.hpp" +#include "slic3r/GUI/wxExtensions.hpp" + +namespace Slic3r { +namespace GUI { + +// Icon size for every button in this control -- the help button beside the legend and the +// edit / remove buttons on each row. One constant so the three cannot drift apart. +static constexpr int kImexIconPx = 20; + +// Mode column width. The name field carries a drop-down arrow, which eats interior space, so +// this is wider than a bare text field needed. The header spacer below derives from it. +static constexpr int kNameColPx = 176; + +IMEXModesCtrl::IMEXModesCtrl(wxWindow* parent, int n_cols, int n_rows, int layout) + : wxPanel(parent, wxID_ANY), m_n_cols(std::max(1, n_cols)), m_n_rows(std::max(1, n_rows)), m_layout(layout) +{ + // Set explicitly: GTK otherwise gives child wxPanels a slightly lighter "widget bg", + // which shows as a light box around the chromeless ScalableButtons. The page's own + // color, as OG_CustomCtrl takes it, because the dark-mode walk only remaps palette + // colors: the app's window default is not one, so it never matched the page and + // froze in whichever theme the editor was last built in. Sub-panels inherit this color. + SetBackgroundColour(parent->GetBackgroundColour()); + + m_outer = new wxBoxSizer(wxVERTICAL); + m_rows_sizer = new wxBoxSizer(wxVERTICAL); + + // Info panel (instructions + legend) and the column-header row. Both depend on the grid + // shape, so their CONTENTS are (re)built by rebuild_info_and_header(), which set_grid_size() + // calls again whenever the grid changes. The panels themselves are stable so m_outer's + // ordering never has to be rearranged. + m_info_panel = new wxPanel(this, wxID_ANY); + m_info_panel->SetBackgroundColour(GetBackgroundColour()); + m_hdr_panel = new wxPanel(this, wxID_ANY); + m_hdr_panel->SetBackgroundColour(GetBackgroundColour()); + rebuild_info_and_header(); + + // Add Mode is built by rebuild_info_and_header() into the info panel, above the rows: + // at the bottom it moved down the page every time a mode was added, so its position + // depended on how many modes you already had. + m_outer->Add(m_info_panel, 0, wxEXPAND | wxBOTTOM, FromDIP(4)); + m_outer->Add(m_hdr_panel, 0, wxEXPAND | wxBOTTOM, FromDIP(2)); + m_outer->Add(m_rows_sizer, 0, wxEXPAND); + m_outer->AddSpacer(FromDIP(4)); + // SetSizer(), not SetSizerAndFit(): no row exists yet, so there is nothing to fit to -- + // rows arrive from add_row() / load_from_config() / set_grid_size(). Deliberately NOT + // followed by m_outer->SetSizeHints(this) once they do: this panel owns a sizer, so + // wxWindow::GetBestSize() bypasses the best-size cache and re-runs m_outer->CalcMin() + // on every parent layout, and that is what the enclosing sizer already reserves space + // for via GetEffectiveMinSize(). An explicitly set min size takes PRIORITY over the best + // size in that call, so a size hint would pin the reserved height to the row count that + // happened to be on screen when it ran -- adding a mode after that would clip the bottom + // row, which is the very failure the hint is meant to avoid. + SetSizer(m_outer); +} + +// A wxTextCtrl cannot paint its own border on GTK -- Orca sidesteps that in ::TextInput by +// drawing one on a StaticBox, but that has no multiline form and nothing in the tree uses it +// that way. A one-pixel panel behind the control gives the same visible edge for both the +// single-line name and the multiline G-code box, in the color the settings fields above use: +// the inputs here were landing on GTK's near-black default, invisible against the panel. +static wxPanel* framed_input(wxWindow* parent) +{ + wxColour clr(0xDB, 0xDB, 0xDB); + if (wxGetApp().dark_mode()) + clr = StateColor::darkModeColorFor(clr); + auto* frame = new wxPanel(parent, wxID_ANY); + frame->SetBackgroundColour(clr); + frame->SetSizer(new wxBoxSizer(wxVERTICAL)); + return frame; +} + +// The settings page's label color, set before the walk as Tab's labels are, so the theme +// remaps it with the page. Left on the system text color, a label follows the OS theme on MSW +// rather than Orca's, and on GTK a dark walk pins a color the light map cannot undo. +static wxStaticText* page_label(wxStaticText* lbl) +{ + lbl->SetForegroundColour(wxColour("#363636")); + wxGetApp().UpdateDarkUI(lbl); + return lbl; +} + +void IMEXModesCtrl::rebuild_info_and_header() { + // --- Info panel: help button + color legend --- + m_info_panel->DestroyChildren(); + auto* info_sizer = new wxBoxSizer(wxVERTICAL); + + // The overview rides on the "?" button at the head of the legend rather than sitting in + // the panel as body text: it is read once and then only gets in the way, while the legend + // beside it is the part worth keeping on screen. Per-role detail is on the swatches. + const wxString instructions = + _L("Each mode names the tool heads that take part and the role each one plays. " + "Click a tool button to cycle its role — hover a color below for what each role does."); + + // Color legend — swatches sized to the body text height so they read as + // matched pairs with their labels regardless of system DPI / font scale, + // matching the on-hover ghost tooltip swatch's visual weight. + // One entry per role this printer offers, in kImexRoleTable order — the same order the + // tiles cycle in. A role added to that table appears here with no edit of its own. + auto* leg_sizer = new wxBoxSizer(wxHORIZONTAL); + const int swatch_side = m_info_panel->GetCharHeight(); + + // Action row: Add Mode, then the "?" carrying the overview. Width-matched to the mode + // column so the button sits over the field it creates. + auto* act_sizer = new wxBoxSizer(wxHORIZONTAL); + auto* add_btn = new Button(m_info_panel, _L("Add Mode")); + add_btn->SetStyle(ButtonStyle::Confirm, ButtonType::Window); + // Width-matched to the mode column. The height has to be a real number: Button takes its + // min size literally, so a -1 there collapses it to a sliver rather than meaning "auto". + add_btn->SetMinSize(wxSize(FromDIP(130), add_btn->GetBestSize().GetHeight())); + add_btn->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { add_row(); notify(); }); + act_sizer->Add(add_btn, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(6)); + + auto* help_btn = new ScalableButton(m_info_panel, wxID_ANY, "icon_qusetion", wxEmptyString, + wxDefaultSize, wxDefaultPosition, + wxBU_EXACTFIT | wxNO_BORDER, /*use_default_disabled_bitmap=*/true, kImexIconPx); + help_btn->SetToolTip(instructions); + act_sizer->Add(help_btn, 0, wxALIGN_CENTER_VERTICAL); + info_sizer->Add(act_sizer, 0, wxBOTTOM, FromDIP(6)); + for (const ImexRoleDesc& d : kImexRoleTable) { + if (!role_offered(d.role)) continue; + const RoleStyle style = role_style(d.role); + auto* swatch = new wxPanel(m_info_panel, wxID_ANY, wxDefaultPosition, wxSize(swatch_side, swatch_side)); + swatch->SetBackgroundColour(style.bg); + auto* leg_label = page_label(new wxStaticText(m_info_panel, wxID_ANY, style.label)); + const wxString hint = role_hint(d.role); + swatch->SetToolTip(hint); + leg_label->SetToolTip(hint); + leg_sizer->Add(swatch, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(4)); + leg_sizer->Add(leg_label, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(16)); + } + info_sizer->Add(leg_sizer, 0, wxBOTTOM, FromDIP(6)); + m_info_panel->SetSizerAndFit(info_sizer); + + // --- Column header row --- + // Spacers sized to align with the mode-row fields below. Name field: FromDIP(130) + FromDIP(6) + // gap; tool grid: n_cols*(FromDIP(24)+FromDIP(2))-FromDIP(2) + FromDIP(6) gap. grid_px tracks + // the live column count, so the G-code header stays aligned after a grid change. + m_hdr_panel->DestroyChildren(); + auto* hdr_sizer = new wxBoxSizer(wxHORIZONTAL); + auto* hdr_name = page_label(new wxStaticText(m_hdr_panel, wxID_ANY, _L("Mode"))); + auto* hdr_tools = page_label(new wxStaticText(m_hdr_panel, wxID_ANY, _L("Tools"))); + auto* hdr_gcode = page_label(new wxStaticText(m_hdr_panel, wxID_ANY, _L("G-code"))); + hdr_name->SetToolTip(_L("How the mode is labeled in the plate's IDEX/IQEX mode selector. " + "Required — a mode with no name cannot be selected.")); + hdr_tools->SetToolTip(_L("Which tool heads take part in the mode and what role each one plays. " + "Click a tile to cycle its role.")); + hdr_gcode->SetToolTip(_L("G-code run at print start to put the printer into this mode " + "(for example a Klipper SET_PRINT_MODE call). The slicer only emits " + "the Primary tool's paths; the firmware drives the others.")); + const int grid_px = m_n_cols * FromDIP(26) - FromDIP(2); // tile 24 + 2px gap, less the trailing gap + const int name_col_px = FromDIP(kNameColPx + 6); + hdr_sizer->Add(hdr_name, 0, wxALIGN_CENTER_VERTICAL); + hdr_sizer->AddSpacer(std::max(0, name_col_px - hdr_name->GetBestSize().x)); + hdr_sizer->Add(hdr_tools, 0, wxALIGN_CENTER_VERTICAL); + hdr_sizer->AddSpacer(std::max(0, grid_px + FromDIP(6) - hdr_tools->GetBestSize().x)); + hdr_sizer->Add(hdr_gcode, 1, wxALIGN_CENTER_VERTICAL); + m_hdr_panel->SetSizerAndFit(hdr_sizer); +} + +void IMEXModesCtrl::set_grid_size(int n_cols, int n_rows, int layout) { + n_cols = std::max(1, n_cols); + n_rows = std::max(1, n_rows); + if (layout < 0) layout = m_layout; + if (n_cols == m_n_cols && n_rows == m_n_rows && layout == m_layout) return; + auto [names, tools, gcodes] = get_mode_data(); + clear_rows(); + m_n_cols = n_cols; + m_n_rows = n_rows; + m_layout = layout; + // Span becomes available the moment a second gantry exists, and the header spacing tracks the + // column count -- so the legend and headers are rebuilt here rather than only at construction. + // Without this, adding a gantry gives the tiles a Span role the legend never explains until + // the preset is saved and the page reopened. + rebuild_info_and_header(); + for (size_t i = 0; i < names.size(); ++i) + add_row(names[i], tools[i], gcodes[i], /*is_primary=*/(names[i] == kImexPrimaryMode)); + Layout(); +} + +void IMEXModesCtrl::load_from_config(const DynamicPrintConfig& cfg) { + clear_rows(); + // One ImexMode per row of imex_mode_names, with any sibling array too short for a row + // padded to an empty string -- exactly what the three per-index bounds checks this + // replaced were doing, now in one place shared with every other IMEX consumer. + const std::vector table = imex_mode_table(cfg); + + // Primary row is always first and non-deletable. Look for an existing + // "primary" entry in the config (present in configs saved after #8 was + // implemented); fall back to empty tool/gcode for older configs. + const ImexMode primary = find_imex_mode(cfg, kImexPrimaryMode); + add_row(kImexPrimaryMode, primary.active_tools, primary.gcode, /*is_primary=*/true); + + for (const ImexMode& m : table) { + if (m.index == primary.index) continue; // already added above + add_row(m.name, m.active_tools, m.gcode); + } + refresh_reset_buttons(); + Layout(); +} + +std::tuple, std::vector, std::vector> +IMEXModesCtrl::get_mode_data() const { + std::vector names, tools, gcodes; + for (auto& r : m_rows) { + // into_u8(), not ToStdString(): everything downstream of here -- the preset, the 3MF + // metadata, the plate's stored mode name -- is UTF-8, while ToStdString() encodes + // through wxConvLibc, which on Windows is the ANSI codepage. The two agree only on a + // UTF-8 locale, so mixing them turns a non-ASCII mode name into mojibake (or an empty + // field, once from_u8() rejects it) on the round trip through the preset. + std::string nm = r.is_primary ? std::string(kImexPrimaryMode) : into_u8(r.name->GetTextCtrl()->GetValue()); + if (nm.empty()) nm = unique_mode_name(names); + names.push_back(nm); + tools.push_back(active_tools_string(r)); + gcodes.push_back(into_u8(r.gcode->GetValue())); + } + return {names, tools, gcodes}; +} + +bool IMEXModesCtrl::matches_config(const DynamicPrintConfig& cfg) const { + auto [names, tools, gcodes] = get_mode_data(); + auto cfg_strings = [&cfg](const char* key) { + std::vector v; + if (auto* o = cfg.option(key)) v = o->values; + return v; + }; + return names == cfg_strings("imex_mode_names") + && tools == cfg_strings("imex_mode_active_tools") + && gcodes == cfg_strings("imex_mode_gcodes"); +} + +std::map IMEXModesCtrl::roles_for_mode(const std::string& active_tools) { + std::map roles; + for (const auto& [phys, role] : parse_imex_active_tools(active_tools)) + roles[phys] = role; + // Promote the mode's primary, but never invent a tool that isn't in the + // slicer's own active-tool list: parse_imex_active_tools() drops negative and + // out-of-range indices, imex_primary_tool_for_mode() does not range-check the + // upper bound, and a junk index round-tripped back out on save would be a + // token the slicer ignores. + const int primary = imex_primary_tool_for_mode(active_tools); + if (primary >= 0) { + auto it = roles.find(primary); + if (it != roles.end()) + it->second = ImexRole::Primary; + } + return roles; +} + +std::string IMEXModesCtrl::unique_mode_name(const std::vector& also_taken, const std::string& base) const { + auto is_taken = [&](const std::string& cand) { + if (std::find(also_taken.begin(), also_taken.end(), cand) != also_taken.end()) + return true; + for (const auto& r : m_rows) + if (!r.is_primary && r.name && into_u8(r.name->GetTextCtrl()->GetValue()) == cand) + return true; + return false; + }; + for (int n = 2; ; ++n) { + std::string cand = base + " " + std::to_string(n); + if (!is_taken(cand)) + return cand; + } +} + +bool IMEXModesCtrl::fix_row_name(Row& r) { + if (r.is_primary || !r.name) + return false; + const std::string name = into_u8(r.name->GetTextCtrl()->GetValue()); + // Case-insensitive: a row named "Primary" works, but its menu entry reads the same as the + // built-in Primary's. + const bool reserved = boost::iequals(name, kImexPrimaryMode); + bool taken = false; + for (const Row& other : m_rows) + if (&other != &r && !other.is_primary && other.name && into_u8(other.name->GetTextCtrl()->GetValue()) == name) + taken = true; + if (!name.empty() && !reserved && !taken) + return false; + std::string base = name; + // "copy 2" taken becomes "copy 3", not "copy 2 2". + if (const size_t sp = base.find_last_of(' '); sp != std::string::npos && sp > 0 && sp + 1 < base.size() && + base.find_first_not_of("0123456789", sp + 1) == std::string::npos) + base.erase(sp); + // ChangeValue(), not SetValue(): no nested wxEVT_TEXT. + r.name->GetTextCtrl()->ChangeValue(from_u8(name.empty() || reserved ? unique_mode_name({}) : unique_mode_name({}, base))); + return true; +} + +IMEXModesCtrl::RoleStyle IMEXModesCtrl::role_style(std::optional role) { + // Gray / "Inactive" is the no-role answer; every role gets an explicit case so a new + // one is a compile-time -Wswitch prompt rather than a tile that silently renders gray. + if (!role) + return { wxColour(90, 90, 90), *wxWHITE, "Inactive" }; + switch (*role) { + case ImexRole::Primary: return { wxColour(50, 160, 50), *wxWHITE, "Primary" }; // green + case ImexRole::Copy: return { wxColour(60, 120, 210), *wxWHITE, "Copy" }; // blue + case ImexRole::Mirror: return { wxColour(210, 130, 20), *wxWHITE, "Mirror" }; // amber + case ImexRole::Span: return { wxColour(180, 180, 40), *wxWHITE, "Span" }; // yellow + } + return { wxColour(90, 90, 90), *wxWHITE, "Inactive" }; // keeps every compiler quiet +} + +wxString IMEXModesCtrl::role_hint(ImexRole role) { + switch (role) { + case ImexRole::Primary: + return _L("Drives every sliced path. Always tool 0 — use Tool 0 Position above to choose " + "which corner of the bed it occupies. It cannot be moved from the grid."); + case ImexRole::Copy: + return _L("Follows the Primary at the firmware level, printing the same paths offset into " + "its own zone."); + case ImexRole::Mirror: + return _L("Follows the Primary at the firmware level, printing the same paths reflected " + "across the boundary between the two zones."); + case ImexRole::Span: + return _L("Marks a tool on the Primary's gantry as its multicolor partner. Required to " + "allow multi-color printing in paired-gantry modes."); + } + return wxEmptyString; +} + +bool IMEXModesCtrl::role_offered(ImexRole role) const { + // Span declares a within-gantry multicolor partner of the Primary, which only means + // something when there is a second gantry running in parallel with it. + if (role == ImexRole::Span) + return m_n_rows >= 2; + return true; +} + +bool IMEXModesCtrl::role_allowed_on_tile(ImexRole role, int tool_idx, + bool other_primary, int primary_gantry) const { + if (!role_offered(role)) + return false; + // Exactly one tile drives the sliced paths. + if (role == ImexRole::Primary) + return !other_primary; + // Span only on a tile sharing the Primary's gantry — that is what the marker declares. + if (role == ImexRole::Span) + return primary_gantry >= 0 && (tool_idx / m_n_cols) == primary_gantry; + return true; +} + +std::optional IMEXModesCtrl::next_tile_role(std::optional current, + const std::function& allowed) { + const int n = (int)(sizeof(kImexRoleTable) / sizeof(kImexRoleTable[0])); + int start = 0; // no current role (Inactive) → start at the first role + if (current) { + for (int i = 0; i < n; ++i) + if (kImexRoleTable[i].role == *current) { start = i + 1; break; } + } + for (int i = start; i < n; ++i) + if (allowed(kImexRoleTable[i].role)) + return kImexRoleTable[i].role; + return std::nullopt; // walked off the end → back to Inactive +} + +// The tool grid says which topologies this printer can express, so a two-tool machine is not +// offered the four-carriage modes. Names a row already uses are dropped, leaving only what is +// still free. These are conventions, not keywords -- nothing in the slicer reads a mode's name +// except as the key a plate stores -- so the field stays typeable and this is only a shortcut. +std::vector IMEXModesCtrl::suggested_mode_names(const ::ComboBox* skip) const { + std::vector pool = { "copy", "mirror" }; + // Four carriages to drive, however they are arranged. + if (m_n_cols * m_n_rows >= 4) + for (const char* n : { "iq-copy", "iq-mirror" }) + pool.emplace_back(n); + // Multicolor needs a Span partner beside the primary, so two tools on a gantry, and a + // second gantry to copy the pair onto. imex_resolve_routing() refuses a multicolor mode + // with no Span on the primary's gantry, so offering one here that it would reject is + // worse than not offering it at all. + if (m_n_cols >= 2 && m_n_rows >= 2) + for (const char* n : { "mc-copy", "mc-mirror" }) + pool.emplace_back(n); + + std::vector out; + for (const std::string& cand : pool) { + bool taken = false; + for (const Row& r : m_rows) + if (!r.is_primary && r.name && r.name != skip && + into_u8(r.name->GetTextCtrl()->GetValue()) == cand) { + taken = true; + break; + } + if (!taken) + out.push_back(from_u8(cand)); + } + return out; +} + +void IMEXModesCtrl::apply_btn(wxButton* btn, int tool_idx, std::optional role) { + // Always label as T{n} — the button color already encodes the role. + const RoleStyle style = role_style(role); + btn->SetLabel(wxString::Format("T%d", tool_idx)); + btn->SetBackgroundColour(style.bg); + btn->SetForegroundColour(style.fg); + btn->Refresh(); +} + +IMEXModesCtrl::RowSnapshot IMEXModesCtrl::snapshot_row(const Row& r) const { + RowSnapshot s; + // UTF-8 throughout -- this snapshot is compared against the preset's own values. + s.name = r.is_primary ? std::string(kImexPrimaryMode) : into_u8(r.name->GetTextCtrl()->GetValue()); + s.tools = active_tools_string(r); + s.gcode = into_u8(r.gcode->GetValue()); + return s; +} + +bool IMEXModesCtrl::row_differs_from_parent(int row_idx) const { + if (!m_parent_lookup) return false; + const DynamicPrintConfig* parent = m_parent_lookup(); + if (!parent) return false; + auto* p_names = parent->option("imex_mode_names"); + auto* p_tools = parent->option("imex_mode_active_tools"); + auto* p_gcodes = parent->option("imex_mode_gcodes"); + if (!p_names || row_idx < 0 || row_idx >= (int)p_names->values.size()) return false; + const Row& r = m_rows[row_idx]; + const RowSnapshot s = snapshot_row(r); + if (!r.is_primary && s.name != p_names->values[row_idx]) return true; + if (p_tools && row_idx < (int)p_tools->values.size() && s.tools != p_tools->values[row_idx]) return true; + if (p_gcodes && row_idx < (int)p_gcodes->values.size() && s.gcode != p_gcodes->values[row_idx]) return true; + return false; +} + +bool IMEXModesCtrl::row_has_parent_counterpart(int row_idx) const { + if (!m_parent_lookup) return false; + const DynamicPrintConfig* parent = m_parent_lookup(); + if (!parent) return false; + auto* p_names = parent->option("imex_mode_names"); + return p_names && row_idx >= 0 && row_idx < (int)p_names->values.size(); +} + +void IMEXModesCtrl::add_row(const std::string& name, + const std::string& active_tools, + const std::string& gcode, + bool is_primary) +{ + Row r; + r.is_primary = is_primary; + r.panel = new wxPanel(this, wxID_ANY); + r.panel->SetBackgroundColour(GetBackgroundColour()); + auto* sizer = new wxBoxSizer(wxHORIZONTAL); + // The mode column is vertical: name on top, and for a deletable row the remove button + // beneath it. Remove used to sit next to Edit in the right-hand column, one icon apart + // from a button people press often -- a destructive action does not belong there. + auto* name_col = new wxBoxSizer(wxVERTICAL); + + if (is_primary) { + r.orig_name = kImexPrimaryMode; + r.name = nullptr; + auto* lbl = page_label(new wxStaticText(r.panel, wxID_ANY, _L("Primary"), + wxDefaultPosition, FromDIP(wxSize(kNameColPx, -1)))); + wxFont f = lbl->GetFont(); + f.SetWeight(wxFONTWEIGHT_BOLD); + lbl->SetFont(f); + name_col->Add(lbl, 0); // the combo below carries no frame, so no inset to match + } else { + // A nameless mode cannot be selected on a plate and used to be dropped on + // save together with its tool roles and G-code, so a name is pre-filled + // here and restored below if the field is left empty. That keeps the + // "Add Mode → assign tools → never typed a name" path from losing work + // without ever showing an error. + const std::string nm = name.empty() ? unique_mode_name({}) : name; + // The name as first shown, i.e. the one the plate's mode selector offered while this + // row looked like this. remove_row() reports it alongside the current name, because + // renaming is not routed through on_mode_removed and the two then diverge. + r.orig_name = nm; + // Editable: no wxCB_READONLY, so the inner text control stays live and any name can + // still be typed, which the mode table needs -- it is authored for whatever hardware + // the user has. ComboBox derives from TextInput and paints the same border the settings + // fields do, so it needs no frame panel of its own. + r.name = new ::ComboBox(r.panel, wxID_ANY, wxEmptyString, wxDefaultPosition, + FromDIP(wxSize(kNameColPx, -1)), 0, nullptr, 0); + for (const wxString& sug : suggested_mode_names()) + r.name->Append(sug); + // Rebuilt on every click that could open the list: add_row() runs before this row joins + // m_rows and before the rows below it exist, so a list built once there filters against + // only part of the table and offers names that are already taken. By then every row is + // present -- and this row's own name is excluded from "taken" so it stays offered. It + // has to be BEFORE ComboBox opens it, not in wxEVT_COMBOBOX_DROPDOWN, which ComboBox + // sends after sizing and showing the popup from the old items: with every name taken + // that showed an empty popup. Bound handlers run ahead of ComboBox's own event-table + // entries, so this rebuilds first and, with nothing to offer, keeps it shut. Only clicks + // can open it: focus lives in the inner text control, so keys never reach the combo. + const auto on_open_click = [this, cb = r.name](wxMouseEvent& e) { + if (!cb->is_drop_down()) { + cb->Clear(); + for (const wxString& sug : suggested_mode_names(cb)) + cb->Append(sug); + if (cb->GetCount() == 0) { + cb->SetFocus(); // what ComboBox's own click would have done, short of the list + return; + } + } + e.Skip(); + }; + r.name->Bind(wxEVT_LEFT_DOWN, on_open_click); + r.name->Bind(wxEVT_LEFT_DCLICK, on_open_click); + // Constructed empty on purpose: ComboBox hands its value to TextInput as the LABEL -- + // the small right-aligned slot a unit like "mm" lives in -- because a read-only combo + // hides the text control and shows that label instead. This one is editable, so the + // value belongs in the text control; left in the label it renders as a second, greyed + // copy of the name beside the hint. + r.name->GetTextCtrl()->ChangeValue(from_u8(nm)); + r.name->GetTextCtrl()->SetHint(_L("Mode name (required)")); + r.name->SetToolTip(_L("Name of this parallel mode, as it appears in the plate's IDEX/IQEX mode " + "selector. Stored in the project by name, so renaming a mode that " + "plates already use makes them fall back to Primary. Cannot be empty " + "— a blank name is replaced with a generated one.")); + r.name->GetTextCtrl()->Bind(wxEVT_TEXT, [this](wxCommandEvent&) { notify(); }); + // Picking a suggestion writes it into the text control, so everything downstream keeps + // reading one place for the name. + r.name->Bind(wxEVT_COMBOBOX, [this, cb = r.name](wxCommandEvent& e) { + // Do NOT call SetLabel() here. It is overridden, and on an editable combo it + // writes the TEXT CONTROL rather than the label (ComboBox.cpp, the IsShown() + // branch) -- clearing it would erase the name that was just picked. The label + // stays empty for this control's whole life anyway: it is constructed empty and + // every write routes through the same override. + // ChangeValue is still needed: SetSelection() early-returns when the index is + // unchanged, so re-picking the current item would otherwise leave typed-over text. + cb->GetTextCtrl()->ChangeValue(cb->GetDropDown().GetValue()); + notify(); + }); + // Restore a name rather than let the row reach get_mode_data() unnamed. + // Row is located by panel pointer (stable across add/remove) so a + // kill-focus delivered while the rows are being torn down is a no-op. + r.name->GetTextCtrl()->Bind(wxEVT_SET_FOCUS, [this, panel = r.panel](wxFocusEvent& e) { + e.Skip(); + for (auto& row_ref : m_rows) + if (row_ref.panel == panel && row_ref.name) + row_ref.focus_name = into_u8(row_ref.name->GetTextCtrl()->GetValue()); + }); + r.name->GetTextCtrl()->Bind(wxEVT_KILL_FOCUS, [this, panel = r.panel](wxFocusEvent& e) { + e.Skip(); + if (m_clearing_rows) return; // focus-out emitted while the rows are being deleted + for (auto& row_ref : m_rows) { + if (row_ref.panel != panel) continue; + // Only an edit is checked. Tabbing through a hand-edited profile's duplicate + // leaves it alone rather than renaming the row plates resolve to. + const wxString value = row_ref.name ? row_ref.name->GetTextCtrl()->GetValue() : wxString(); + if (!value.empty() && into_u8(value) == row_ref.focus_name) + return; + if (fix_row_name(row_ref)) + notify(); + return; + } + }); + } + + auto* grid_panel = new wxPanel(r.panel, wxID_ANY); + grid_panel->SetBackgroundColour(GetBackgroundColour()); + auto* grid_sizer = new wxGridSizer(m_n_rows, m_n_cols, FromDIP(2), FromDIP(2)); + + auto tool_roles = roles_for_mode(active_tools); + if (tool_roles.empty()) + tool_roles[0] = ImexRole::Primary; // default T0 → Primary when no assignment is stored + r.all_tool_roles = tool_roles; // preserve all assignments, including off-screen tools + wxPanel* this_panel = r.panel; + + // Buttons rendered top=rear (high Y), bottom=front (low Y). + // Layout remapping: m_layout encodes which corner T0 is at physically. + // flip_x (layout 1,3): col 0 is on the right (max-X) instead of left + // flip_y (layout 2,3): row 0 is at the rear (max-Y) instead of front + // Display iterates: row from n_rows-1 down to 0 (rear→front = top→bottom). + // raw_row = flip_y ? (n_rows-1-row) : row + // raw_col = flip_x ? (n_cols-1-col) : col + // + // Render the whole grid: m_n_rows is the gantry count, so the valid tool indices are + // 0 .. m_n_rows*m_n_cols-1 and a window this tall only fits inside the grid at row 0. + // A Primary outside the grid is a data problem the zone layout already reports by + // producing no zones (compute_imex_zone_layout, IMEXZones.cpp). + + bool flip_x = (m_layout == 1 || m_layout == 3); + bool flip_y = (m_layout == 2 || m_layout == 3); + for (int row = m_n_rows - 1; row >= 0; --row) { + for (int col = 0; col < m_n_cols; ++col) { + int raw_row = flip_y ? (m_n_rows - 1 - row) : row; + int raw_col = flip_x ? (m_n_cols - 1 - col) : col; + int tool_idx = raw_row * m_n_cols + raw_col; + std::optional role; // nullopt == Inactive + auto it = tool_roles.find(tool_idx); + if (it != tool_roles.end()) role = it->second; + + // wxBU_AUTODRAW, which wx ignores on a text button, is what GUI_App::UpdateDarkUI + // skips: without it every theme change ran the role colors through the palette + // map and left the labels gray. apply_btn() owns these colors in both themes. + auto* btn = new wxButton(grid_panel, wxID_ANY, wxEmptyString, + wxDefaultPosition, FromDIP(wxSize(24, 24)), wxBU_EXACTFIT | wxBU_AUTODRAW); + apply_btn(btn, tool_idx, role); + + int btn_pos = (int)r.btns.size(); + r.btns.push_back(btn); + r.btn_roles.push_back(role); + r.btn_tool_idx.push_back(tool_idx); + + // Two tiles are read-only. + // + // The whole reserved `primary` row: it is the IMEX-off mode, so cycling roles there + // would be a no-op and confusing. + // + // And, on any row, the tile that currently HOLDS Primary. Primary is tool 0 and moves + // only via Tool 0 Position; letting a click demote it would leave the mode with no + // Primary at all, which parses to -1 and degrades the plate to an ordinary single-tool + // print with no zones — authored silently, in one click, with nothing in the editor + // showing what is wrong. The other tiles already never OFFER Primary while one is held + // (role_allowed_on_tile), so this closes the only remaining route to that state. + // A mode that reaches us WITHOUT a Primary (hand-edited preset, foreign 3MF) still has + // every tile live, so it can be repaired by promoting one. + if (is_primary || role == ImexRole::Primary) { + btn->Disable(); + } else { + btn->Bind(wxEVT_BUTTON, [this, this_panel, btn_pos](wxCommandEvent&) { + // Find the row by panel pointer (stable across add/remove) + for (auto& row_ref : m_rows) { + if (row_ref.panel != this_panel) continue; + std::optional& tile = row_ref.btn_roles[btn_pos]; + const int tidx = row_ref.btn_tool_idx[btn_pos]; + + // Check if another button already holds the Primary role, + // and locate the primary's gantry row for Span eligibility. + bool other_primary = false; + int primary_gantry = -1; + for (int j = 0; j < (int)row_ref.btn_roles.size(); ++j) { + if (row_ref.btn_roles[j] == ImexRole::Primary) { + if (j != btn_pos) other_primary = true; + primary_gantry = row_ref.btn_tool_idx[j] / m_n_cols; + } + } + + // Cycle Inactive → Primary → Copy → Mirror → Span → Inactive, + // skipping whatever this tile may not take right now. + tile = next_tile_role(tile, [&](ImexRole cand) { + return role_allowed_on_tile(cand, tidx, other_primary, primary_gantry); + }); + + // Keep all_tool_roles in sync so off-screen tools are preserved + if (tile) + row_ref.all_tool_roles[tidx] = *tile; + else + row_ref.all_tool_roles.erase(tidx); + + apply_btn(row_ref.btns[btn_pos], row_ref.btn_tool_idx[btn_pos], tile); + notify(); + break; + } + }); + } + + grid_sizer->Add(btn, 0); + } + } + grid_panel->SetSizerAndFit(grid_sizer); + + // from_u8(): `gcode` arrives from the preset as UTF-8. Handing the raw std::string to + // wxString would decode it through the current locale's encoding instead. + auto* gcode_frame = framed_input(r.panel); + // wxBORDER_NONE matters off GTK: a wxTextCtrl's default border resolves to a themed or + // sunken edge on Windows and to NSBezelBorder on macOS, which would sit immediately inside + // the frame below and read as two borders. + r.gcode = new wxTextCtrl(gcode_frame, wxID_ANY, from_u8(gcode), + wxDefaultPosition, FromDIP(wxSize(220, 54)), + wxTE_MULTILINE | wxBORDER_NONE); + gcode_frame->GetSizer()->Add(r.gcode, 1, wxEXPAND | wxALL, 1); + // Same monospace face EditGCodeDialog gives its editor, so G-code reads the same + // wherever it is edited; and the same explicit-light-then-UpdateDarkUI treatment as the + // name field above, for the same reason. + r.gcode->SetFont(wxGetApp().code_font()); + r.gcode->SetBackgroundColour(*wxWHITE); + r.gcode->SetForegroundColour(wxColour("#262E30")); // the palette's input text color + wxGetApp().UpdateDarkUI(r.gcode); + // Tooltip only, no SetHint(): wxTextEntry has no native placeholder for a + // multiline control, so wxWidgets emulates one by writing the hint into the + // control as gray text — indistinguishable from real G-code in this box. + r.gcode->SetToolTip(_L("G-code emitted once at the start of a print that uses this mode, before " + "the machine start G-code. This is where the printer is put into the " + "matching firmware mode — for example a Klipper SET_PRINT_MODE call or a " + "RepRapFirmware M567 — since the slicer only emits the Primary tool's " + "paths and the firmware drives the Copy / Mirror tools. Placeholders are " + "supported; use the edit button to browse them. Leave empty if the mode " + "needs no firmware setup.")); + r.gcode->Bind(wxEVT_TEXT, [this](wxCommandEvent&) { notify(); }); + + if (!is_primary) + name_col->Add(r.name, 0); + sizer->Add(name_col, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(6)); + sizer->Add(grid_panel, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(6)); + sizer->Add(gcode_frame, 1, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(4)); + + // Edit on top, per-row reset under it, in one column top-aligned with the G-code box. + // Remove is not here -- it sits under the name field, away from the button people press + // most. The column is top- rather than centre-justified so the edit icon keeps the same + // position whatever a row's height turns out to be. + auto* btn_col = new wxBoxSizer(wxVERTICAL); + wxTextCtrl* gcode_ctrl = r.gcode; + auto* ph_btn = new ScalableButton(r.panel, wxID_ANY, "edit", wxEmptyString, + wxDefaultSize, wxDefaultPosition, + wxBU_EXACTFIT | wxNO_BORDER, /*use_default_disabled_bitmap=*/true, kImexIconPx); + ph_btn->SetToolTip(_L("Edit G-code / browse placeholders")); + ph_btn->Bind(wxEVT_BUTTON, [this, gcode_ctrl](wxCommandEvent&) { + // EditGCodeDialog takes and returns UTF-8 (get_edited_gcode() is a ToUTF8()). + EditGCodeDialog dlg(this, "imex_mode_gcode", into_u8(gcode_ctrl->GetValue())); + if (dlg.ShowModal() == wxID_OK) + gcode_ctrl->SetValue(from_u8(dlg.get_edited_gcode())); + }); + btn_col->Add(ph_btn, 0, wxBOTTOM, FromDIP(2)); + + if (!is_primary) { + auto* rm = new ScalableButton(r.panel, wxID_ANY, "delete", wxEmptyString, + wxDefaultSize, wxDefaultPosition, + wxBU_EXACTFIT | wxNO_BORDER, /*use_default_disabled_bitmap=*/true, kImexIconPx); + rm->SetToolTip(_L("Remove mode")); + rm->Bind(wxEVT_BUTTON, [this, this_panel](wxCommandEvent&) { + const std::vector removed = remove_row(this_panel); + // Copied before notify(): on_change ends in on_value_change, which can send + // the tab through update() and hence load_from_config(), and that rebuilds + // every row. Reading the member afterwards would be reading through a + // handler whose owner has just been rebuilt underneath it. + auto removed_cb = on_mode_removed; + notify(); + if (removed_cb && !removed.empty()) + removed_cb(removed); + }); + name_col->Add(rm, 0, wxALIGN_RIGHT | wxTOP, FromDIP(2)); + } + // Reset only renders when this row has a counterpart in the saved preset (user-added rows + // beyond the saved mode count get no reset — remove covers "drop the row I just added"). + wxPanel* this_panel_for_reset = r.panel; + if (row_has_parent_counterpart(static_cast(m_rows.size()))) { + r.reset_btn = new ScalableButton(r.panel, wxID_ANY, "dot", wxEmptyString, + wxDefaultSize, wxDefaultPosition, + wxBU_EXACTFIT | wxNO_BORDER, /*use_default_disabled_bitmap=*/true, kImexIconPx); + r.reset_btn->SetToolTip(_L("Discard in-session edits to this mode (snap back to saved value)")); + r.reset_btn->Bind(wxEVT_BUTTON, [this, this_panel_for_reset](wxCommandEvent&) { + reset_row_to_parent(this_panel_for_reset); + }); + btn_col->Add(r.reset_btn, 0, wxTOP, FromDIP(2)); + } + sizer->Add(btn_col, 0, wxALIGN_TOP | wxRIGHT, FromDIP(4)); + r.panel->SetSizerAndFit(sizer); + + m_rows_sizer->Add(r.panel, 0, wxEXPAND | wxBOTTOM, FromDIP(4)); + m_rows.push_back(std::move(r)); + Layout(); +} + +std::vector IMEXModesCtrl::remove_row(wxPanel* panel) { + for (size_t i = 0; i < m_rows.size(); ++i) { + if (m_rows[i].panel != panel) continue; + if (m_rows[i].is_primary) return {}; // primary row is non-deletable + + // Both names a plate can be holding for this row: the one the row was built with and + // the one in the field now. They diverge as soon as the row is renamed, because a + // rename does not rebuild the row and is deliberately not reported as a removal (the + // name field notifies on every keystroke). Reporting only the current name is what + // let "rename, then delete" leave the plate on a mode that no longer exists. + std::vector removed_names; + const std::string current = m_rows[i].name ? into_u8(m_rows[i].name->GetTextCtrl()->GetValue()) + : std::string(); + for (const std::string& n : {m_rows[i].orig_name, current}) + if (!n.empty() && std::find(removed_names.begin(), removed_names.end(), n) == removed_names.end()) + removed_names.push_back(n); + + m_rows_sizer->Detach(panel); + m_rows.erase(m_rows.begin() + i); + + // A name a surviving row still carries is not stale: the user can rename row A to + // row B's old name and then delete B. Plates holding it now resolve to A, so drop it + // rather than reset them. + auto still_in_use = [this](const std::string& n) { + return std::any_of(m_rows.begin(), m_rows.end(), [&n](const Row& r) { + return r.is_primary ? n == kImexPrimaryMode + : r.name && into_u8(r.name->GetTextCtrl()->GetValue()) == n; + }); + }; + removed_names.erase(std::remove_if(removed_names.begin(), removed_names.end(), still_in_use), + removed_names.end()); + + Layout(); + // Defer widget destruction so any in-flight GTK events for + // panel's children (including the remove button we're inside) finish + // processing before the wxEvtHandlers are freed. + wxTheApp->CallAfter([panel]() { panel->Destroy(); }); + return removed_names; + } + return {}; +} + +void IMEXModesCtrl::clear_rows() { + // Destroying a focused wxTextCtrl emits wxEVT_KILL_FOCUS, and the Name field's + // handler would then read m_rows while it is half-destroyed. Flag the teardown + // so that handler stays out of the way. + m_clearing_rows = true; + for (auto& r : m_rows) { m_rows_sizer->Detach(r.panel); r.panel->Destroy(); } + m_rows.clear(); + m_clearing_rows = false; +} + +std::string IMEXModesCtrl::active_tools_string(const Row& r) const { + // Serialize from all_tool_roles (not just visible buttons) so assignments + // for off-screen tools are preserved across grid size changes. Inactive tools are + // simply absent from the map, so there is nothing to filter out here. + // + // The letters come from imex_role_letter(), the same table parse_imex_active_tools() + // reads back — this used to be a second, independent int → letter switch, which is + // exactly where the editor and the slicer could come to disagree about a mode string. + std::string s; + for (const auto& [idx, role] : r.all_tool_roles) { + if (!s.empty()) s += ','; + s += std::to_string(idx) + ':' + imex_role_letter(role); + } + return s; +} + +void IMEXModesCtrl::notify() { + refresh_reset_buttons(); + if (on_change) on_change(); +} + +void IMEXModesCtrl::refresh_reset_buttons() { + for (size_t i = 0; i < m_rows.size(); ++i) { + Row& r = m_rows[i]; + if (!r.reset_btn || !r.reset_btn->IsEnabled()) continue; + const bool dirty = row_differs_from_parent(static_cast(i)); + if (dirty == r.reset_dirty_cached) continue; + r.reset_dirty_cached = dirty; + r.reset_btn->SetBitmap_(dirty ? "undo" : "dot"); + } +} + +void IMEXModesCtrl::reset_row_to_parent(wxPanel* panel) { + if (!m_parent_lookup) return; + const DynamicPrintConfig* parent = m_parent_lookup(); + if (!parent) return; + auto* p_names = parent->option("imex_mode_names"); + auto* p_tools = parent->option("imex_mode_active_tools"); + auto* p_gcodes = parent->option("imex_mode_gcodes"); + for (size_t i = 0; i < m_rows.size(); ++i) { + Row& r = m_rows[i]; + if (r.panel != panel) continue; + if (!p_names || i >= p_names->values.size()) return; + if (!r.is_primary && r.name) { + r.name->GetTextCtrl()->ChangeValue(from_u8(p_names->values[i])); + // The saved name may since have been given to another row, which keeps it. + fix_row_name(r); + } + if (p_gcodes && i < p_gcodes->values.size()) + r.gcode->ChangeValue(from_u8(p_gcodes->values[i])); + if (p_tools && i < p_tools->values.size()) { + // Reapply tool roles from the parent's serialized form. all_tool_roles + // is the source of truth for round-tripping; rebuild it then re-paint + // the visible buttons. + r.all_tool_roles = roles_for_mode(p_tools->values[i]); + for (size_t j = 0; j < r.btns.size(); ++j) { + int tidx = r.btn_tool_idx[j]; + auto it = r.all_tool_roles.find(tidx); + std::optional role; + if (it != r.all_tool_roles.end()) role = it->second; + r.btn_roles[j] = role; + apply_btn(r.btns[j], tidx, role); + } + } + notify(); + return; + } +} + +}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/IMEXModesCtrl.hpp b/src/slic3r/GUI/IMEXModesCtrl.hpp new file mode 100644 index 0000000000..f50327ad35 --- /dev/null +++ b/src/slic3r/GUI/IMEXModesCtrl.hpp @@ -0,0 +1,270 @@ +#ifndef slic3r_IMEXModesCtrl_hpp_ +#define slic3r_IMEXModesCtrl_hpp_ + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "libslic3r/IMEXHelpers.hpp" + +class ScalableButton; + +class ComboBox; + +namespace Slic3r { +namespace GUI { + +// --------------------------------------------------------------------------- +// IMEXModesCtrl — visual parallel-mode editor for the IDEX/IQEX printer tab +// +// A tool tile holds an `std::optional`: the role the mode gives that tool, or +// nullopt for Inactive (gray — the tool does not take part in this mode). Inactive is the +// editor's own state and the only reason the tile is not a bare ImexRole; it is the absence +// of a role, so it is spelled as one rather than as an extra enumerator that libslic3r would +// then have to keep rejecting. Roles themselves are never re-encoded here — no int state +// table, no second copy of the on-disk letters. +// +// nullopt (gray) — not participating in this mode +// ImexRole::Primary (green) — the tool the slicer generates paths for +// ImexRole::Copy (blue) — firmware duplicates Primary at an offset +// ImexRole::Mirror (amber) — firmware mirrors Primary about an axis +// ImexRole::Span (yellow)— multicolor partner of Primary on the same gantry; +// declares paired-gantry multicolor topology and +// unlocks the multicolor block at slice time. Only +// offered on tools sharing primary's gantry, and +// only on multi-gantry printers (n_rows >= 2). +// +// Color and legend text are the editor's business and live in role_style(); the tile cycle +// order and the serialized letters come from kImexRoleTable in IMEXHelpers.hpp, so the +// editor cannot drift from the slicer's reading of a mode string. +// +// Active-tools string format: "idx:P,idx:C,idx:M,idx:S" (backwards-compat: plain "idx" = Primary) +// --------------------------------------------------------------------------- +class IMEXModesCtrl : public wxPanel { +public: + std::function on_change; + + // Fired after on_change has written the shortened table back to the preset, with EVERY + // name the deleted row could be known by: the name the row was built with (what a plate + // set from the saved preset holds) and the name in the field at delete time (what a plate + // set after an in-session rename holds). Renaming is deliberately not routed through here + // -- the name field notifies on every keystroke -- so by the time the row is deleted the + // two can differ, and reporting only the current name leaves the plate pointing at a mode + // that no longer exists. Names a surviving row still carries are filtered out, so renaming + // one row to another's old name and then deleting cannot reset a plate that legitimately + // resolves elsewhere. Plates still set to any reported name are stale from this point on + // -- see the handler in TabPrinter::build_fff. + std::function&)> on_mode_removed; + + // Lazy lookup so the widget always sees the current parent (preset switches + // change the parent under us). Returns nullptr when the active preset has no + // parent (system preset itself), which disables per-row reset arrows. + using ParentConfigLookup = std::function; + void set_parent_config_lookup(ParentConfigLookup f) { m_parent_lookup = std::move(f); } + + // layout: 0=front-left, 1=front-right, 2=rear-left, 3=rear-right (T0 corner). + // The enum values intentionally line up 1:1 so this is a no-op cast — the helper + // is kept to make the conversion explicit at call sites. + static int parse_layout(ImexToolLayout layout) { return static_cast(layout); } + + IMEXModesCtrl(wxWindow* parent, int n_cols, int n_rows, int layout = 0); + + void set_grid_size(int n_cols, int n_rows, int layout = -1); + + void load_from_config(const DynamicPrintConfig& cfg); + + // Every row on screen produces exactly one entry — a row is never skipped. + // Skipping empty-named rows used to drop the row's tool roles and G-code + // silently, and because matches_config() compares against this same filtered + // output the preset did not even go dirty to hint that something had gone + // missing; the row stayed on screen until the next load_from_config() quietly + // removed it. add_row() pre-fills a name and the Name field restores one when + // it is left empty, so the substitution below is the last-resort guard for a + // name that arrives empty from a hand-edited preset or 3MF. + std::tuple, std::vector, std::vector> + get_mode_data() const; + + // True when the widget's current rows already mirror the three IMEX-mode option + // vectors in cfg. Used by TabPrinter::update_fff() to skip a destroy-and-rebuild + // pass when the only thing that just changed was the widget itself writing back + // to config — without this guard, every keystroke in the gcode textbox triggers + // a load_from_config() that detaches the textbox the user is typing in. + bool matches_config(const DynamicPrintConfig& cfg) const; + + // Whether the printer is an IMEX printer. The grid sits in an options group that shows or hides + // everything in it by mode, so Show() keeps it hidden on other printers whatever the group asks. + void set_applicable(bool applicable) + { + m_applicable = applicable; + if (!applicable) + wxPanel::Show(false); // showing is left to the group, which knows the mode + } + bool Show(bool show = true) override { return wxPanel::Show(show && m_applicable); } + +private: + bool m_applicable = true; + + // map for one `imex_mode_active_tools` entry. A tool absent + // from the map is Inactive — the same convention the serialized string uses, so the + // round trip needs no separate "off" value. + // + // Deliberately NOT a parser of its own: the slicer decides what actually prints, + // so the editor has to read the string exactly the way the slicer does or the + // same mode means two different things in the two places. Roles come from + // parse_imex_active_tools(); the primary index comes from + // imex_primary_tool_for_mode(), which is where the "a bare index is Primary" + // backwards-compat rule lives. Both are needed: parse_imex_active_tools() + // defaults a bare token to Copy (deliberately — see the note above + // parse_imex_active_tools() in IMEXHelpers.hpp), so only the combination + // reproduces the slicer's reading of a legacy "0,1,2" string, which is + // "T0 primary, the rest copies". + // + // Indices are PHYSICAL tool indices (T0..TN-1). all_tool_roles preserves + // off-grid entries so the tile widget can round-trip indices that fall + // outside the currently visible rows × cols without losing data on save. + static std::map roles_for_mode(const std::string& active_tools); + + // `base` + " N" for the lowest N >= 2 not already used by a row or by `also_taken` + // (N == 1 is the fixed Primary row, or the name being made unique). Deterministic for a + // given set of rows, so get_mode_data() is stable across calls and matches_config() stays + // honest. Intentionally NOT translated: mode names are identifiers — objects + // store one in `imex_parallel_mode` and GCode.cpp matches it against + // `imex_mode_names` by string — so a locale-dependent name would break a project + // opened under a different language. + std::string unique_mode_name(const std::vector& also_taken, const std::string& base = "Mode") const; + + // Everything the editor draws for one role, in ONE place: a new role needs a color and + // a legend name here and nowhere else in this file. nullopt is Inactive (gray). + // `label` is deliberately untranslated — it names a role, and the legend sits next to + // the same letters the preset stores. + struct RoleStyle { wxColour bg; wxColour fg; const char* label; }; + static RoleStyle role_style(std::optional role); + + // True when this printer offers `role` at all, regardless of which tile is clicked. + // Span needs a second gantry to be a partner across, so a single-gantry printer neither + // cycles to it nor lists it in the legend. + bool role_offered(ImexRole role) const; + + // True when the tile for `tool_idx` may take `role` right now. Adds the positional rules + // on top of role_offered(): only one tile may hold Primary, and Span only on a tile + // sharing the Primary's gantry. + bool role_allowed_on_tile(ImexRole role, int tool_idx, + bool other_primary, int primary_gantry) const; + + // Next state in the tile cycle: Inactive → kImexRoleTable order → Inactive, stepping + // over any role `allowed` rejects. Inactive is always reachable, so it terminates. + static std::optional next_tile_role(std::optional current, + const std::function& allowed); + + // Mode names this printer's tool grid can actually carry, minus those already taken. + std::vector suggested_mode_names(const ::ComboBox* skip = nullptr) const; + + void apply_btn(wxButton* btn, int tool_idx, std::optional role); + + struct Row { + wxPanel* panel; + ::ComboBox* name; // nullptr for primary row (name is fixed) + wxTextCtrl* gcode; + std::vector btns; + std::vector> btn_roles; // nullopt == Inactive + std::vector btn_tool_idx; + std::map all_tool_roles; // absent == Inactive + // Name the row was built with (UTF-8), i.e. the name that was in the preset — and so + // in the plate's mode selector — when the row appeared. Kept because the name field is + // free to drift from it without the row being rebuilt; remove_row() needs both to find + // the plates a delete strands. Rebuilding the rows (load_from_config / set_grid_size) + // re-seeds it, which is correct: config and rows agree again at that point. + std::string orig_name; + // The name when the field last took focus, so a commit that leaves it unchanged is not + // treated as an edit. + std::string focus_name; + bool is_primary {false}; + ScalableButton* reset_btn {nullptr}; // nullptr when row has no parent counterpart + bool reset_dirty_cached {false}; + }; + + // Snapshot of row content as it would be saved (matches get_mode_data() per-row). + struct RowSnapshot { std::string name, tools, gcode; }; + RowSnapshot snapshot_row(const Row& r) const; + + // True when row at `row_idx` differs from the parent preset's value at the same + // index. Returns false when there is no parent (system preset), when the row is + // beyond the parent's mode count (user added it — no defined "default"), or when + // the lookup callback isn't wired. The Primary row's name is sentinel-fixed so + // a name diff doesn't count for it; only tools/gcode do. + bool row_differs_from_parent(int row_idx) const; + + // True when row index has a parent counterpart at all (i.e., the reset arrow + // should be present on the row at all, regardless of dirty state). + bool row_has_parent_counterpart(int row_idx) const; + + void add_row(const std::string& name = "", + const std::string& active_tools = "", + const std::string& gcode = "", + bool is_primary = false); + + // Returns every name the removed row was known by (UTF-8, deduplicated: its build-time + // name and its current name, which differ after an in-session rename), minus any name a + // surviving row still carries. The caller uses them to find the plates the delete + // stranded. Empty when nothing was removed, when the row is the non-deletable Primary, + // or when both names are blank -- a blank name is only reachable on a row that was never + // named, which no plate can be holding. + std::vector remove_row(wxPanel* panel); + + void clear_rows(); + + std::string active_tools_string(const Row& r) const; + + // Replaces an edited row's name when it is empty, the reserved Primary name in any case, or + // one another row already carries: a plate stores the name and find_imex_mode() takes its + // first row, so the edited row yields. Returns whether it changed the name; the caller notifies. + bool fix_row_name(Row& r); + + void notify(); + + // Update each row's reset bitmap to reflect current dirty state. Cached so we + // only swap the bitmap when state actually transitions — avoids flicker on + // every keystroke. Disabled buttons (no saved counterpart) keep their dot. + void refresh_reset_buttons(); + + // Reset row identified by `panel` (stable across add/remove) to the parent + // preset's value at the same index. Primary row's name stays sentinel-fixed — + // only tools and gcode are restored. + void reset_row_to_parent(wxPanel* panel); + + // Rebuild the instruction text, color legend and column headers for the CURRENT grid. + // These depend on the grid shape -- the Span role is only offered on a multi-gantry printer, + // and the header spacing is derived from the column count -- so they cannot be built once in + // the constructor: adding a second gantry has to reveal Span without a save-and-reopen cycle. + void rebuild_info_and_header(); + + // One-line explanation per role, shown as a tooltip on that role's legend swatch and label. + // Keeps the per-role detail out of the instruction paragraph. + static wxString role_hint(ImexRole role); + + wxBoxSizer* m_outer; + wxBoxSizer* m_rows_sizer; + wxPanel* m_info_panel = nullptr; + wxPanel* m_hdr_panel = nullptr; + std::vector m_rows; + int m_n_cols, m_n_rows; + int m_layout {0}; // 0=front-left, 1=front-right, 2=rear-left, 3=rear-right + bool m_clearing_rows {false}; // guards handlers against teardown-time events + ParentConfigLookup m_parent_lookup; +}; + +}} // namespace Slic3r::GUI + +#endif // slic3r_IMEXModesCtrl_hpp_ diff --git a/src/slic3r/GUI/Jobs/ArrangeJob.cpp b/src/slic3r/GUI/Jobs/ArrangeJob.cpp index 8f9d013bb6..4fb66176b6 100644 --- a/src/slic3r/GUI/Jobs/ArrangeJob.cpp +++ b/src/slic3r/GUI/Jobs/ArrangeJob.cpp @@ -5,6 +5,7 @@ #include "libslic3r/MTUtils.hpp" #include "libslic3r/PresetBundle.hpp" #include "libslic3r/ModelArrange.hpp" +#include "libslic3r/IMEXArrange.hpp" #include "slic3r/GUI/PartPlate.hpp" #include "slic3r/GUI/GLCanvas3D.hpp" @@ -17,6 +18,7 @@ #include "libnest2d/common.hpp" #include "libslic3r/Arrange.hpp" +#include #include #include "libslic3r/Point.hpp" #include "libslic3r/Polygon.hpp" @@ -536,6 +538,36 @@ void ArrangeJob::prepare_partplate() { prepare_belt_regions(current_plate_index + 1); } +// Snapshot each plate's IMEX zones on the main thread, so process() never has to touch +// PartPlate's IMEX cache; see the members' declaration for why that matters. The beds are the +// arranger's: arranging one plate packs into bed 0, and arranging all of them numbers the beds +// over the unlocked plates, as postprocess_bed_index_for_selected() maps them back. +void ArrangeJob::prepare_imex_zones() +{ + m_imex = {}; + PartPlateList& plate_list = m_plater->get_partplate_list(); + std::vector plates; + if (only_on_partplate) + plates.push_back(plate_list.get_curr_plate()); + else + for (int i = 0; i < plate_list.get_plate_count(); ++i) + if (!plate_list.get_plate(i)->is_locked()) + plates.push_back(plate_list.get_plate(i)); + + for (PartPlate* plate : plates) { + std::optional zones; + if (const std::optional pz = plate ? plate->imex_primary_zone() : std::nullopt) { + const Vec2d o = plate->get_origin().head<2>(); + zones = ImexArrangeZones{BoundingBoxf(Vec2d(pz->min - o), Vec2d(pz->max - o)), {}}; + for (const BoundingBoxf3& strip : plate->imex_collision_zones()) + zones->collision_zones.emplace_back(Vec2d(strip.min.head<2>() - o), Vec2d(strip.max.head<2>() - o)); + } + m_imex.beds.push_back(std::move(zones)); + } + m_imex.adds_plates = !only_on_partplate; + m_imex.read_config(wxGetApp().preset_bundle->full_config()); +} + //BBS: add partplate logic void ArrangeJob::prepare() { @@ -564,6 +596,9 @@ void ArrangeJob::prepare() prepare_partplate(); } + // After prepare_all(), which locks the plates it must leave alone. + prepare_imex_zones(); + #if SAVE_ARRANGE_POLY if (1) @@ -645,6 +680,11 @@ void ArrangeJob::process(Ctl &ctl) Points bedpts = get_shrink_bedpts(m_plater->config(),params); + // Keep the parts on IDEX/IQEX plates in their primary zones; see ImexArranger. + const ImexArranger imex(m_imex, params, bedpts); + bedpts = imex.bed_shape(); + imex.add_keep_outs(m_unselected); + bool enable_wrapping = global_config.option("enable_wrapping_detection")->value; partplate_list.preprocess_exclude_areas(params.excluded_regions, enable_wrapping, 1, scale_(1)); @@ -669,7 +709,10 @@ void ArrangeJob::process(Ctl &ctl) <<", bbox:"< m_selected_groups; // groups of selected items for sequential printing std::vector m_uncompatible_plates; // plate indices with different printing sequence than global + // IMEX zone snapshot, taken on the main thread in prepare(). + // + // process() runs on the worker thread (see Job::process). PartPlate::imex_primary_zone() is + // non-const and calls ensure_imex_zones(), which reads wxGetApp().preset_bundle -- main-thread + // GUI state -- on every call, and on a cache miss calls calc_imex_zones(), which destroys and + // rebuilds std::vector members. ~GLModel issues glDeleteBuffers/glDeleteVertexArrays, + // and no GL context is current on the worker, while the GUI thread may be painting those same + // vectors from GLCanvas3D::on_paint. (imex_collision_zones() is itself const and merely reads + // the member -- but it is only valid once imex_primary_zone() has warmed the cache, so it + // cannot be moved off the main thread on its own.) Snapshotting plain geometry here keeps + // every one of those touches on the main thread. + // + // The zones are stored already converted to plate-local coordinates, which is the space the + // arranger works in. + ImexArrangeInput m_imex; + arrangement::ArrangeParams params; int current_plate_index = 0; Polygon bed_poly; @@ -51,6 +69,7 @@ class ArrangeJob : public Job void prepare_partplate(); void prepare_wipe_tower(); void prepare_belt_regions(int num_plates); + void prepare_imex_zones(); ArrangePolygon prepare_arrange_polygon(void* instance); diff --git a/src/slic3r/GUI/MainFrame.cpp b/src/slic3r/GUI/MainFrame.cpp index bb5ffc87de..6ef7dedaa8 100644 --- a/src/slic3r/GUI/MainFrame.cpp +++ b/src/slic3r/GUI/MainFrame.cpp @@ -3401,6 +3401,30 @@ void MainFrame::init_menubar_as_editor() [this]() { return is_prepare_or_preview_tab(); }, []() { return wxGetApp().show_plate_gridlines(); }, this); + // IDEX/IQEX: toggle the per-carriage toolhead representation that appears during + // G-code preview playback. Today this is the symbolic footprint box; if a future + // change adds true toolhead model support (cf. printer bed models) this same toggle + // governs that. Only relevant on Preview, and only meaningful when the active + // printer is IDEX/IQEX — gated to keep the menu uncluttered for everyone else. + append_menu_check_item(viewMenu, wxID_ANY, _L("Show IDEX/IQEX Toolhead"), + _L("Show the IDEX/IQEX toolhead representation around each active nozzle in the G-code preview."), + [this](wxCommandEvent&) { + const bool current = wxGetApp().app_config->get("show_imex_toolhead_boxes") != "false"; + wxGetApp().app_config->set("show_imex_toolhead_boxes", current ? "false" : "true"); + m_plater->get_current_canvas3D()->post_event(SimpleEvent(wxEVT_PAINT)); + }, + this, + [this]() { + if (m_tabpanel->GetSelectedPageName() != TAB_ID_PREVIEW) return false; + if (auto* pb = wxGetApp().preset_bundle) { + auto* o = pb->printers.get_edited_preset().config.option("is_imex"); + return o && o->value; + } + return false; + }, + []() { return wxGetApp().app_config->get("show_imex_toolhead_boxes") != "false"; }, + this); + append_menu_item( viewMenu, wxID_ANY, _L("Reset Window Layout"), _L("Reset to default window layout"), [this](wxCommandEvent&) { m_plater->reset_window_layout(); }, "", this, diff --git a/src/slic3r/GUI/OG_CustomCtrl.cpp b/src/slic3r/GUI/OG_CustomCtrl.cpp index f51737014f..7a7d4a4b1a 100644 --- a/src/slic3r/GUI/OG_CustomCtrl.cpp +++ b/src/slic3r/GUI/OG_CustomCtrl.cpp @@ -214,6 +214,11 @@ wxPoint OG_CustomCtrl::get_pos(const Line& line, Field* field_in/* = nullptr*/) if (line.widget) { #ifndef DISABLE_BLINKING h_pos += (line.has_undo_ui() ? 3 : 1) * blinking_button_width; +#else + // When blinking is disabled, widget lines still need to honour + // option_label_at_right the same way regular lines do below. + if (opt_group->option_label_at_right) + add_buttons_width(blinking_button_width); #endif for (auto child : line.widget_sizer->GetChildren()) @@ -740,6 +745,19 @@ void OG_CustomCtrl::CtrlLine::update_visibility(ConfigOptionMode mode) return; const std::vector