From 05da6bbcc87bc687bf45a78b4f00400ff28d5bdf Mon Sep 17 00:00:00 2001 From: SoftFever Date: Tue, 29 Sep 2026 01:26:10 +0800 Subject: [PATCH 1/7] Remove unreachable ACE block from the Kobra X filament change G-code The block ran only when flush_length_4 is -1392 and read ace_t_box_vector / ace_t_slot_vector, which only Anycubic's own slicer defines. It emitted comments only, so the printed G-code is unchanged. --- resources/profiles/Anycubic.json | 2 +- .../profiles/Anycubic/machine/Anycubic Kobra X 0.4 nozzle.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/profiles/Anycubic.json b/resources/profiles/Anycubic.json index bb0eedbd91..731d2b32eb 100644 --- a/resources/profiles/Anycubic.json +++ b/resources/profiles/Anycubic.json @@ -1,6 +1,6 @@ { "name": "Anycubic", - "version": "02.04.00.07", + "version": "02.04.00.08", "force_update": "0", "description": "Anycubic configurations", "machine_model_list": [ diff --git a/resources/profiles/Anycubic/machine/Anycubic Kobra X 0.4 nozzle.json b/resources/profiles/Anycubic/machine/Anycubic Kobra X 0.4 nozzle.json index 8e2952b123..c7b3ce89a4 100644 --- a/resources/profiles/Anycubic/machine/Anycubic Kobra X 0.4 nozzle.json +++ b/resources/profiles/Anycubic/machine/Anycubic Kobra X 0.4 nozzle.json @@ -53,7 +53,7 @@ "before_layer_change_gcode": "", "best_object_pos": "0.5,0.5", "change_extrusion_role_gcode": "", - "change_filament_gcode": "; FLUSH_START\n;@2026-04-03 17:46:45 3+n换料gcode,包括7=3+4,19=3+4×4\n;_GP_INLINE_ESTIMATED_PRINTING_TIME_PLACEHOLDER\n;;; SET_VELOCITY_LIMIT VELOCITY=350 ACCEL=10000\n;;; G1 E-2 F4800\n;;; G1 Z{toolchange_z+0.6} F1800\n;;; M106 S0\n;;; G1 X265 F21000\n;;; M400 P370\n;;; G1 X277.5 F600\n;;; G1 E-2 F300\n;;; M400 P361\n;;; G1 Z{toolchange_z+3} F1200\n;;; G1 X0 F21000\n;;; G1 X-17.5 F5250\n;;; M400 P1569\n\n{local minimal_extrude_ = 5.0}\n{if one_of(filament_type[current_extruder], \"TPU\", \"PVA\")}\n{local minimal_extrude_ = 8.0}\n{endif}\n;;; G1 E{minimal_extrude_} F300\n;;; G1 E-33 F600\n\n{ local curr_t = current_extruder; local next_t = next_extruder;}\n{ local tab_step_time_ = (0, 850, 1350, 850, 850, 0, 1350, 1350, 1350, 1350, 0, 850, 850, 1350, 850, 0)};\n{ if (0 <= curr_t && curr_t < 3) then local step_from_=curr_t else local step_from_=3 endif}; from {step_from_}\n{ if (0 <= next_t && next_t < 3) then local step_into_=next_t else local step_into_=3 endif}; from {step_into_}\n;;; M400 P{tab_step_time_[step_from_ * 4 + step_into_]}\n\n{ local magic_mask_with_box = flush_length_4}\n{ if magic_mask_with_box == -1392 }\n { local is_curr_in_box = size(ace_t_box_vector) > curr_t ? ace_t_box_vector[curr_t] >= 0 ? true : false : false}; is_curr_in_box = {is_curr_in_box}\n { local is_next_in_box = size(ace_t_box_vector) > next_t ? ace_t_box_vector[next_t] >= 0 ? true : false : false}; is_next_in_box = {is_next_in_box}\n\n; curr_t box={ace_t_box_vector[curr_t]}, slot={ace_t_slot_vector[curr_t]}; next_t box={ace_t_box_vector[next_t]}, slot={ace_t_slot_vector[next_t]}\n { if ace_t_box_vector[curr_t] < 0 && ace_t_box_vector[next_t] < 0}\n;;; M400 P0 ; 料架→料架\n { elsif ace_t_box_vector[curr_t] < 0 && ace_t_box_vector[next_t] >= 0 }\n;;; M400 P40409 ; 料架→盒子\n { elsif ace_t_box_vector[curr_t] >= 0 && ace_t_box_vector[next_t] < 0 }\n;;; M400 P51591 ; 盒子→料架\n { elsif ace_t_box_vector[curr_t] >= 0 && ace_t_box_vector[next_t] == ace_t_box_vector[curr_t] }\n { if 0 < ace_t_slot_vector[curr_t] && ace_t_slot_vector[curr_t] <= 4 && 0 < ace_t_slot_vector[next_t] && ace_t_slot_vector[next_t] <= 4}\n;;; M400 P2700 ; 四进四→料盒\n { else }\n;;; M400 P91279 ; 盒子→盒子\n { endif }\n { elsif ace_t_box_vector[curr_t] >= 0 && ace_t_box_vector[next_t] != ace_t_box_vector[curr_t] }\n;;; M400 P93746 ; 盒子→不同盒子\n { else }\n; 未知\n { endif }\n{ endif }\n\n\nT[next_extruder]\n\n;;; G1 E8 F300\n;;; M400 P3643\n;;; G1 E13 F1200\n;;; M400 P1000\n\n{local flush_length_= flush_length}\n{local loops_=max(1,int((flush_length_-70+150) / 150))}\n{local extrude_length_=flush_length_ / loops_}\n{local EXTRUDE_SPEED_ = 5 * 60}\n{local UNWIND_SPEED_ = 20 *60}\n{local index_ = 0}\n\n{ if (loops_ > 0) }\n{ local loops_ = loops_ - 1}\n{ local index_ = index_ + 1}\n; {index_} + {loops_}\n;;; M106 S0\n;;; M400 P1000\n;;; G1 E{extrude_length_*0.18} F{EXTRUDE_SPEED_}\n;;; G1 E{extrude_length_*0.02} F{EXTRUDE_SPEED_}\n;;; G1 E{extrude_length_*0.18} F{EXTRUDE_SPEED_}\n;;; G1 E{extrude_length_*0.02} F{EXTRUDE_SPEED_}\n;;; G1 E{extrude_length_*0.18} F{EXTRUDE_SPEED_}\n;;; G1 E{extrude_length_*0.02} F{EXTRUDE_SPEED_}\n;;; G1 E{extrude_length_*0.18} F{EXTRUDE_SPEED_}\n;;; G1 E{extrude_length_*0.02} F{EXTRUDE_SPEED_}\n;;; G1 E{extrude_length_*0.18} F{EXTRUDE_SPEED_}\n;;; G1 E{extrude_length_*0.02} F{EXTRUDE_SPEED_}\n;;; M400 P{extrude_length_/0.03}\n;;; G1 E0.000001\n;;; M106 S255\n;;; M400 P2005\n;;; G1 E-2 F{UNWIND_SPEED_}\n;;; M400 P414\n{endif}\n{ if (loops_ > 0) }\n{ loops_ = loops_ - 1}\n{ index_ = index_ + 1}\n; {index_} + {loops_}\n;;; M106 S0\n;;; M400 P1000\n;;; G1 E{extrude_length_*0.18} F{EXTRUDE_SPEED_}\n;;; G1 E{extrude_length_*0.02} F{EXTRUDE_SPEED_}\n;;; G1 E{extrude_length_*0.18} F{EXTRUDE_SPEED_}\n;;; G1 E{extrude_length_*0.02} F{EXTRUDE_SPEED_}\n;;; G1 E{extrude_length_*0.18} F{EXTRUDE_SPEED_}\n;;; G1 E{extrude_length_*0.02} F{EXTRUDE_SPEED_}\n;;; G1 E{extrude_length_*0.18} F{EXTRUDE_SPEED_}\n;;; G1 E{extrude_length_*0.02} F{EXTRUDE_SPEED_}\n;;; G1 E{extrude_length_*0.18} F{EXTRUDE_SPEED_}\n;;; G1 E{extrude_length_*0.02} F{EXTRUDE_SPEED_}\n;;; M400 P{extrude_length_/0.03}\n;;; M106 S255\n;;; M400 P2005\n;;; G1 E-2 F{UNWIND_SPEED_}\n;;; M400 P414\n{endif}\n{ if (loops_ > 0) }\n{ loops_ = loops_ - 1}\n{ index_ = index_ + 1}\n; {index_} + {loops_}\n;;; M106 S0\n;;; M400 P1000\n;;; G1 E{extrude_length_*0.18} F{EXTRUDE_SPEED_}\n;;; G1 E{extrude_length_*0.02} F{EXTRUDE_SPEED_}\n;;; G1 E{extrude_length_*0.18} F{EXTRUDE_SPEED_}\n;;; G1 E{extrude_length_*0.02} F{EXTRUDE_SPEED_}\n;;; G1 E{extrude_length_*0.18} F{EXTRUDE_SPEED_}\n;;; G1 E{extrude_length_*0.02} F{EXTRUDE_SPEED_}\n;;; G1 E{extrude_length_*0.18} F{EXTRUDE_SPEED_}\n;;; G1 E{extrude_length_*0.02} F{EXTRUDE_SPEED_}\n;;; G1 E{extrude_length_*0.18} F{EXTRUDE_SPEED_}\n;;; G1 E{extrude_length_*0.02} F{EXTRUDE_SPEED_}\n;;; M400 P{extrude_length_/0.03}\n;;; M106 S255\n;;; M400 P2005\n;;; G1 E-2 F{UNWIND_SPEED_}\n;;; M400 P414\n{endif}\n\n; G1 X{x_after_toolchange} Y{y_after_toolchange} F12000\n; G1 Z{toolchange_z} F1200\n;;; G1 E2 F1800\n; SET_VELOCITY_LIMIT VELOCITY=450 ACCEL=10000\n;;; M400 P0\n;_GP_INLINE_ESTIMATED_PRINTING_TIME_PLACEHOLDER\n; FLUSH_END", + "change_filament_gcode": "; FLUSH_START\n;@2026-04-03 17:46:45 3+n换料gcode,包括7=3+4,19=3+4×4\n;_GP_INLINE_ESTIMATED_PRINTING_TIME_PLACEHOLDER\n;;; SET_VELOCITY_LIMIT VELOCITY=350 ACCEL=10000\n;;; G1 E-2 F4800\n;;; G1 Z{toolchange_z+0.6} F1800\n;;; M106 S0\n;;; G1 X265 F21000\n;;; M400 P370\n;;; G1 X277.5 F600\n;;; G1 E-2 F300\n;;; M400 P361\n;;; G1 Z{toolchange_z+3} F1200\n;;; G1 X0 F21000\n;;; G1 X-17.5 F5250\n;;; M400 P1569\n\n{local minimal_extrude_ = 5.0}\n{if one_of(filament_type[current_extruder], \"TPU\", \"PVA\")}\n{local minimal_extrude_ = 8.0}\n{endif}\n;;; G1 E{minimal_extrude_} F300\n;;; G1 E-33 F600\n\n{ local curr_t = current_extruder; local next_t = next_extruder;}\n{ local tab_step_time_ = (0, 850, 1350, 850, 850, 0, 1350, 1350, 1350, 1350, 0, 850, 850, 1350, 850, 0)};\n{ if (0 <= curr_t && curr_t < 3) then local step_from_=curr_t else local step_from_=3 endif}; from {step_from_}\n{ if (0 <= next_t && next_t < 3) then local step_into_=next_t else local step_into_=3 endif}; from {step_into_}\n;;; M400 P{tab_step_time_[step_from_ * 4 + step_into_]}\n\n\nT[next_extruder]\n\n;;; G1 E8 F300\n;;; M400 P3643\n;;; G1 E13 F1200\n;;; M400 P1000\n\n{local flush_length_= flush_length}\n{local loops_=max(1,int((flush_length_-70+150) / 150))}\n{local extrude_length_=flush_length_ / loops_}\n{local EXTRUDE_SPEED_ = 5 * 60}\n{local UNWIND_SPEED_ = 20 *60}\n{local index_ = 0}\n\n{ if (loops_ > 0) }\n{ local loops_ = loops_ - 1}\n{ local index_ = index_ + 1}\n; {index_} + {loops_}\n;;; M106 S0\n;;; M400 P1000\n;;; G1 E{extrude_length_*0.18} F{EXTRUDE_SPEED_}\n;;; G1 E{extrude_length_*0.02} F{EXTRUDE_SPEED_}\n;;; G1 E{extrude_length_*0.18} F{EXTRUDE_SPEED_}\n;;; G1 E{extrude_length_*0.02} F{EXTRUDE_SPEED_}\n;;; G1 E{extrude_length_*0.18} F{EXTRUDE_SPEED_}\n;;; G1 E{extrude_length_*0.02} F{EXTRUDE_SPEED_}\n;;; G1 E{extrude_length_*0.18} F{EXTRUDE_SPEED_}\n;;; G1 E{extrude_length_*0.02} F{EXTRUDE_SPEED_}\n;;; G1 E{extrude_length_*0.18} F{EXTRUDE_SPEED_}\n;;; G1 E{extrude_length_*0.02} F{EXTRUDE_SPEED_}\n;;; M400 P{extrude_length_/0.03}\n;;; G1 E0.000001\n;;; M106 S255\n;;; M400 P2005\n;;; G1 E-2 F{UNWIND_SPEED_}\n;;; M400 P414\n{endif}\n{ if (loops_ > 0) }\n{ loops_ = loops_ - 1}\n{ index_ = index_ + 1}\n; {index_} + {loops_}\n;;; M106 S0\n;;; M400 P1000\n;;; G1 E{extrude_length_*0.18} F{EXTRUDE_SPEED_}\n;;; G1 E{extrude_length_*0.02} F{EXTRUDE_SPEED_}\n;;; G1 E{extrude_length_*0.18} F{EXTRUDE_SPEED_}\n;;; G1 E{extrude_length_*0.02} F{EXTRUDE_SPEED_}\n;;; G1 E{extrude_length_*0.18} F{EXTRUDE_SPEED_}\n;;; G1 E{extrude_length_*0.02} F{EXTRUDE_SPEED_}\n;;; G1 E{extrude_length_*0.18} F{EXTRUDE_SPEED_}\n;;; G1 E{extrude_length_*0.02} F{EXTRUDE_SPEED_}\n;;; G1 E{extrude_length_*0.18} F{EXTRUDE_SPEED_}\n;;; G1 E{extrude_length_*0.02} F{EXTRUDE_SPEED_}\n;;; M400 P{extrude_length_/0.03}\n;;; M106 S255\n;;; M400 P2005\n;;; G1 E-2 F{UNWIND_SPEED_}\n;;; M400 P414\n{endif}\n{ if (loops_ > 0) }\n{ loops_ = loops_ - 1}\n{ index_ = index_ + 1}\n; {index_} + {loops_}\n;;; M106 S0\n;;; M400 P1000\n;;; G1 E{extrude_length_*0.18} F{EXTRUDE_SPEED_}\n;;; G1 E{extrude_length_*0.02} F{EXTRUDE_SPEED_}\n;;; G1 E{extrude_length_*0.18} F{EXTRUDE_SPEED_}\n;;; G1 E{extrude_length_*0.02} F{EXTRUDE_SPEED_}\n;;; G1 E{extrude_length_*0.18} F{EXTRUDE_SPEED_}\n;;; G1 E{extrude_length_*0.02} F{EXTRUDE_SPEED_}\n;;; G1 E{extrude_length_*0.18} F{EXTRUDE_SPEED_}\n;;; G1 E{extrude_length_*0.02} F{EXTRUDE_SPEED_}\n;;; G1 E{extrude_length_*0.18} F{EXTRUDE_SPEED_}\n;;; G1 E{extrude_length_*0.02} F{EXTRUDE_SPEED_}\n;;; M400 P{extrude_length_/0.03}\n;;; M106 S255\n;;; M400 P2005\n;;; G1 E-2 F{UNWIND_SPEED_}\n;;; M400 P414\n{endif}\n\n; G1 X{x_after_toolchange} Y{y_after_toolchange} F12000\n; G1 Z{toolchange_z} F1200\n;;; G1 E2 F1800\n; SET_VELOCITY_LIMIT VELOCITY=450 ACCEL=10000\n;;; M400 P0\n;_GP_INLINE_ESTIMATED_PRINTING_TIME_PLACEHOLDER\n; FLUSH_END", "cooling_tube_length": "0", "cooling_tube_retraction": "0", "deretraction_speed": [ From 78fb4f767ad917cc909fe9472adffd851f22a0dc Mon Sep 17 00:00:00 2001 From: SoftFever Date: Tue, 29 Sep 2026 01:26:10 +0800 Subject: [PATCH 2/7] Fix the Wanhao France D12 template custom G-code failing to parse --- resources/profiles/Wanhao France.json | 2 +- .../profiles/Wanhao France/machine/fdm_machine_common.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/profiles/Wanhao France.json b/resources/profiles/Wanhao France.json index 9087899761..6392daea8d 100644 --- a/resources/profiles/Wanhao France.json +++ b/resources/profiles/Wanhao France.json @@ -1,6 +1,6 @@ { "name": "Wanhao France", - "version": "02.04.00.05", + "version": "02.04.00.06", "force_update": "0", "description": "Wanhao France D12 configurations", "machine_model_list": [ diff --git a/resources/profiles/Wanhao France/machine/fdm_machine_common.json b/resources/profiles/Wanhao France/machine/fdm_machine_common.json index e72b821fa1..dc3764c3ef 100644 --- a/resources/profiles/Wanhao France/machine/fdm_machine_common.json +++ b/resources/profiles/Wanhao France/machine/fdm_machine_common.json @@ -112,7 +112,7 @@ "1" ], "support_multi_bed_types": "1", - "template_custom_gcode": ";;;;;;;;;;;;;;;;;;;;;;;;CUSTOM G-CODE;;;;;;;;;;;;;;;;;;;;;;;;\n{if curr_bed_type==\"Textured PEI Plate\"}\n SET_GCODE_OFFSET Z=-0.00\n{else}\nSET_GCODE_OFFSET Z=0.0\n{endif}\n{if curr_bed_type==\"Cool Plate\"}\n\n;available bed types are:\n;\"Cool Plate\"\n;\"Engineering Plate\"\n;\"High Temp Plate\"\n;\"Textured PEI Plate\"\n;;;;;;;;;;;;;;;;;;;;;;;;;CUSTOM G-CODE;;;;;;;;;;;;;;;;;;;;;;;;\n", + "template_custom_gcode": ";;;;;;;;;;;;;;;;;;;;;;;;CUSTOM G-CODE;;;;;;;;;;;;;;;;;;;;;;;;\n{if curr_bed_type==\"Textured PEI Plate\"}\n SET_GCODE_OFFSET Z=-0.00\n{else}\nSET_GCODE_OFFSET Z=0.0\n{endif}\n\n;available bed types are:\n;\"Cool Plate\"\n;\"Engineering Plate\"\n;\"High Temp Plate\"\n;\"Textured PEI Plate\"\n;;;;;;;;;;;;;;;;;;;;;;;;;CUSTOM G-CODE;;;;;;;;;;;;;;;;;;;;;;;;\n", "z_hop_types": "Normal Lift", "before_layer_change_gcode": ";BEFORE_LAYER_CHANGE\n;[layer_z]\nG92 E0\n", "default_print_profile": "0.16mm Optimal @Bambu Lab X1 Carbon 0.4 nozzle", From 185cfe4323e1b527754031a59bb9fc8b69dad082 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Tue, 29 Sep 2026 01:26:10 +0800 Subject: [PATCH 3/7] Slice-validate every custom G-code and filename_format in system profiles --- .github/workflows/build_all.yml | 8 +- .github/workflows/check_profiles.yml | 6 +- .../OrcaSlicer_profile_validator.cpp | 369 +++++++++++++----- src/libslic3r/PlaceholderParser.cpp | 45 ++- src/libslic3r/PlaceholderParser.hpp | 8 + tests/libslic3r/test_placeholder_parser.cpp | 35 ++ 6 files changed, 359 insertions(+), 112 deletions(-) diff --git a/.github/workflows/build_all.yml b/.github/workflows/build_all.yml index 24317b0add..d45ec215b8 100644 --- a/.github/workflows/build_all.yml +++ b/.github/workflows/build_all.yml @@ -183,9 +183,11 @@ jobs: os: ${{ vars.SELF_HOSTED && 'orca-macos-arm64' || 'macos-14' }} artifact: ${{ github.sha }}-tests-macos-arm64 test-dir: build/arm64/tests - # Slice a two-colour cube through every shipped printer so all custom g-code - # (change_filament_gcode, machine start/end, etc.) is expanded - catches - # slicing regressions the static profile checks and unit tests can't see. + # Slice a two-colour cube through every shipped printer, and through every + # system process/filament whose templates no printer's own slice reaches, so + # every custom g-code and filename_format shipped is expanded (names in {if} + # branches not taken included) - catches slicing regressions the static + # profile checks and unit tests can't see. # Profile-only PRs are covered by check_profiles.yml's nightly binary; this # covers src/engine PRs with the PR-built binary. slice_check_linux: diff --git a/.github/workflows/check_profiles.yml b/.github/workflows/check_profiles.yml index e668482bfd..ada123c609 100644 --- a/.github/workflows/check_profiles.yml +++ b/.github/workflows/check_profiles.yml @@ -71,8 +71,10 @@ jobs: set +e ./OrcaSlicer_profile_validator -p ${{ github.workspace }}/resources/profiles -l 2 2>&1 | tee ${{ runner.temp }}/validate_system.log exit ${PIPESTATUS[0]} - # Slice a two-colour cube through every printer so all custom g-code (incl. change_filament_gcode) - # is expanded - catches undefined-placeholder / invalid-flow bugs the static checks above cannot see. + # Slice a two-colour cube through every printer, and through every system process/filament whose + # templates no printer's own slice reaches, so every custom g-code and filename_format shipped is + # expanded (names in {if} branches not taken included) - catches undefined-placeholder / + # invalid-flow bugs the static checks above cannot see. - name: validate slice (expand custom g-code) id: validate_slice continue-on-error: true diff --git a/src/dev-utils/OrcaSlicer_profile_validator.cpp b/src/dev-utils/OrcaSlicer_profile_validator.cpp index 557dda7eec..4bb7eab64d 100644 --- a/src/dev-utils/OrcaSlicer_profile_validator.cpp +++ b/src/dev-utils/OrcaSlicer_profile_validator.cpp @@ -20,6 +20,7 @@ #include "libslic3r/Model.hpp" #include "libslic3r/TriangleMesh.hpp" #include "libslic3r/Utils.hpp" +#include #include #include #include @@ -32,6 +33,7 @@ #include #include #include +#include #include using namespace Slic3r; @@ -160,37 +162,62 @@ Vec2d place_wipe_tower(DynamicPrintConfig &cfg, const Vec2d ¢er) return rigid ? move : Vec2d::Zero(); } -// Slice one cube that switches from filament 1 to filament 2 partway up, so exactly one -// filament change fires, then export. The change drives the printer's own change_filament_gcode: on a -// single-nozzle machine it rides the AMS prime tower (append_tcr), on a multi-nozzle machine it routes -// through the nozzle swap (set_extruder / append_tcr2) - the engine picks the path from the printer's -// topology, so one model covers both. An undefined placeholder in any shipped custom g-code throws -// Slic3r::PlaceholderParserError from export. -std::string slice_two_color_cube_and_export(DynamicPrintConfig cfg, bool is_bbl) +// Slice cubes that switch from filament 1 to filament 2 partway up, so a filament change fires, then +// export. The change drives the printer's own change_filament_gcode: on a single-nozzle machine it rides +// the AMS prime tower (append_tcr), on a multi-nozzle machine it routes through the nozzle swap +// (set_extruder / append_tcr2) - the engine picks the path from the printer's topology, so one model +// covers both. The layer-by-layer slice also pauses at z 2 and runs the template custom g-code at z 7, so +// machine_pause_gcode and template_custom_gcode expand, and turns clumping detection on for a printer that +// ships wrapping_detection_gcode. The by-object slice prints two cubes in turn without a prime tower: +// printing_by_object_gcode fires before the second one, and the filament change goes through set_extruder. +// Either way the output file name is built after export, which expands filename_format with the final +// print statistics. An undefined placeholder in any shipped custom g-code throws +// Slic3r::PlaceholderParserError. +std::string slice_two_color_cube_and_export(DynamicPrintConfig cfg, bool is_bbl, bool by_object) { - const Vec2d center = printable_area_center(cfg); - const Vec2d cube_min = center - Vec2d(5., 5.) + place_wipe_tower(cfg, center); - TriangleMesh m = make_cube(10, 10, 10); - m.translate(static_cast(cube_min.x()), static_cast(cube_min.y()), 0.f); + const Vec2d center = printable_area_center(cfg); + std::vector cube_mins; + if (by_object) { + // By-object printing fires the hook only without a wipe tower, and rules out clumping detection and + // smooth timelapse. No skirt, so the two cubes' own skirts cannot overlap. + cfg.set_key_value("print_sequence", new ConfigOptionEnum(PrintSequence::ByObject)); + cfg.set_key_value("enable_prime_tower", new ConfigOptionBool(false)); + cfg.set_key_value("enable_wrapping_detection", new ConfigOptionBool(false)); + cfg.set_key_value("timelapse_type", new ConfigOptionEnum(tlTraditional)); + cfg.set_key_value("skirt_loops", new ConfigOptionInt(0)); + cube_mins = {center + Vec2d(-20., -5.), center + Vec2d(10., -5.)}; + } else { + // Clumping detection changes the tower footprint, so turn it on before placing the tower. + if (!cfg.opt_string("wrapping_detection_gcode").empty()) + cfg.set_key_value("enable_wrapping_detection", new ConfigOptionBool(true)); + cube_mins = {center - Vec2d(5., 5.) + place_wipe_tower(cfg, center)}; + } Model model; Print print; - ModelObject *obj = model.add_object(); - obj->name = "cube"; // populates [input_filename_base] the way a loaded model does - obj->add_volume(m); - obj->add_instance(); - // Filament 2 is used only above z=4, so the upper layers carry a single filament change. - DynamicPrintConfig range_config; - range_config.set_key_value("extruder", new ConfigOptionInt(2)); - // Every range must carry a layer_height; use the process's own so a fine nozzle (e.g. 0.15 mm - // printing ~0.1 mm layers) isn't forced to a height its extrusion width can't support - that - // trips Flow::with_spacing. - range_config.set_key_value("layer_height", new ConfigOptionFloat(cfg.opt_float("layer_height"))); - obj->layer_config_ranges[{4.0, 10.0}].assign_config(std::move(range_config)); - print.is_BBL_printer() = is_bbl; - obj->ensure_on_bed(); - print.auto_assign_extruders(obj); + for (const Vec2d &cube_min : cube_mins) { + TriangleMesh m = make_cube(10, 10, 10); + m.translate(static_cast(cube_min.x()), static_cast(cube_min.y()), 0.f); + ModelObject *obj = model.add_object(); + obj->name = "cube"; // populates [input_filename_base] the way a loaded model does + obj->add_volume(m); + obj->add_instance(); + // Filament 2 is used only above z=4, so the upper layers carry a single filament change. + DynamicPrintConfig range_config; + range_config.set_key_value("extruder", new ConfigOptionInt(2)); + // Every range must carry a layer_height; use the process's own so a fine nozzle (e.g. 0.15 mm + // printing ~0.1 mm layers) isn't forced to a height its extrusion width can't support - that + // trips Flow::with_spacing. + range_config.set_key_value("layer_height", new ConfigOptionFloat(cfg.opt_float("layer_height"))); + obj->layer_config_ranges[{4.0, 10.0}].assign_config(std::move(range_config)); + obj->ensure_on_bed(); + print.auto_assign_extruders(obj); + } + // Custom g-codes per print_z apply to layer-by-layer printing only (ToolOrdering::assign_custom_gcodes). + if (!by_object) + model.plates_custom_gcodes[model.curr_plate_index].gcodes = {{2., CustomGCode::PausePrint, 1, "", ""}, + {7., CustomGCode::Template, 1, "", ""}}; print.apply(model, cfg); print.validate(); @@ -205,6 +232,7 @@ std::string slice_two_color_cube_and_export(DynamicPrintConfig cfg, bool is_bbl) in.close(); boost::system::error_code ec; fs::remove(tmp, ec); + print.output_filename(); // names the export as the app does, expanding filename_format return out; } @@ -264,18 +292,119 @@ void install_slice_context_log_sink() logging::core::get()->add_sink(sink); } +// Size the filament slots for the current selection and build the config every slice uses. +DynamicPrintConfig slice_config(PresetBundle &bundle) +{ + // Grow to a 2nd filament so the cube can change colour; never shrink a multi-nozzle printer + // below its nozzle count, or full_config()'s flush-volume matrix no longer matches validate(). + const size_t nozzles = bundle.printers.get_selected_preset().config.option("nozzle_diameter")->size(); + bundle.set_num_filaments((unsigned int) std::max(2, nozzles)); + + // Mirror the app's manual filament->nozzle assignment for a multi-nozzle BBL printer: put each + // filament on its own nozzle and pin the map (fmmManual) so full_config() collapses every filament to + // the variant of the nozzle it actually prints from, and the engine keeps that assignment instead of + // auto-remapping it during process(). Without this the synthetic 2nd filament keeps nozzle 1's variant + // while the auto map moves it to nozzle 2 - harmless, but on the one printer whose nozzles differ in + // type (Direct Drive + Bowden) the mismatched lookup spams [error] lines. Single-nozzle and non-BBL + // printers keep the default map (their toolchange rides the AMS/tool-changer path unchanged). + const bool pin_filament_map = bundle.is_bbl_vendor() && nozzles > 1; + if (pin_filament_map) { + auto &fmap = bundle.project_config.option("filament_map", true)->values; + for (size_t i = 0; i < fmap.size(); ++i) + fmap[i] = int(i % nozzles) + 1; + } + + DynamicPrintConfig cfg = bundle.full_config(); + cfg.set_key_value("enable_prime_tower", new ConfigOptionBool(true)); // force a purge tower so the change is detectable + // The map above drives full_config()'s per-filament variant collapse; fmmManual on the sliced config + // stops process() from auto-remapping filaments back onto a different nozzle (which would re-introduce + // the variant mismatch this pinning avoids). + if (pin_filament_map) + cfg.set_key_value("filament_map_mode", new ConfigOptionEnum(fmmManual)); + + // full_config() grows filament_extruder_variant to one entry per filament, but because the synthetic + // 2nd filament is a duplicate of the first (set_num_filaments copies the same preset), it leaves + // filament_self_index at size 1. That makes update_values_to_printer_extruders_for_multiple_filaments + // fail to resolve the 2nd filament's variant - a benign fallback that spams [error] lines. A real + // 2-colour project ships filament_self_index = 1,2,...; mirror that so the sweep log stays clean. The + // slice output is unaffected: the duplicated filament's per-variant values are identical to the first. + if (auto *variants = cfg.option("filament_extruder_variant")) { + auto &self_index = cfg.option("filament_self_index", true)->values; + if (self_index.size() != variants->size()) { + self_index.resize(variants->size()); + for (size_t i = 0; i < self_index.size(); ++i) + self_index[i] = int(i) + 1; + } + } + return cfg; +} + +// Slice the current selection and log any failure against `what` (the printer, and the extra preset if +// any). With an outdir, the g-code is also saved there as ".gcode". Returns the g-code, or an +// empty string on failure. +std::string slice_selection(PresetBundle &bundle, const std::string &what, bool by_object, const std::string &outdir, const std::string &file_base) +{ + try { + const std::string out = slice_two_color_cube_and_export(slice_config(bundle), bundle.is_bbl_vendor(), by_object); + if (!outdir.empty() && !out.empty()) + save_string_file(fs::path(outdir) / (file_base + ".gcode"), out); + if (out.empty() || out.find("G1") == std::string::npos) { + BOOST_LOG_TRIVIAL(error) << what << " produced no g-code"; + return {}; + } + return out; + } catch (const std::exception &ex) { + BOOST_LOG_TRIVIAL(error) << what << " failed to slice: " << ex.what(); + return {}; + } +} + +// The templates a preset carries, as (key, text): every non-empty *_gcode text, and filename_format. Each +// value of a per-variant key counts, though a slice expands only the values of the variants its printer uses. +using Templates = std::set>; +Templates preset_templates(const DynamicPrintConfig &cfg) +{ + Templates out; + for (const std::string &key : cfg.keys()) { + if (key != "filename_format" && !boost::algorithm::ends_with(key, "_gcode")) + continue; + const ConfigOption *opt = cfg.option(key); + if (opt->type() == coString) { + if (const std::string &text = static_cast(opt)->value; !text.empty()) + out.emplace(key, text); + } else if (opt->type() == coStrings) { + for (const std::string &text : static_cast(opt)->values) + if (!text.empty()) + out.emplace(key, text); + } + } + return out; +} + // Slice-and-export a two-colour cube through every shipped printer (optionally scoped to one vendor via // -v). Unlike the static reference/placeholder checks, this expands every custom *_gcode - including // change_filament_gcode at the one filament change - against the printer's fully-resolved config, so -// undefined-placeholder / invalid-flow bugs surface here. Reports every offending printer and returns 1 -// if any failed, 0 otherwise. When outdir is non-empty, each printer's g-code is also written there as -// "__.gcode" for manual inspection. The sweep is SEQUENTIAL by necessity: -// Print::process() keeps process-global state, so slicing printers concurrently in one process races -// even with per-slice Model+Print. Load in validation mode so the vendors are read straight from the -p -// profiles dir (no data_dir/system tree) and -v scoping is honoured for free. +// undefined-placeholder / invalid-flow bugs surface here. PlaceholderParser::check_inactive_branches is +// set for the sweep, so the names in {if} branches a slice does not take must resolve too. +// +// Each printer is sliced with its own default process and filament. Every other compatible system +// process and filament whose templates (filename_format, filament_start_gcode, ...) no slice has expanded +// yet is then sliced once on that printer - a filament that compatible_prints limits to another process +// with that process - so each template text shipped in any system preset is expanded once. That check runs on the first compatible printer the sweep reaches only, in that printer's hook +// contexts: a value-dependent error on another printer, or a hook call site there that sets fewer +// variables (a multi-nozzle tool change, say), is not covered. The first printer shipping each +// printing_by_object_gcode also slices two cubes by object, the only way that hook fires. +// +// Reports every failure and returns 1 if any slice failed, 0 otherwise. When outdir is non-empty, each +// g-code is also written there as "__[__].gcode" for manual inspection. The +// sweep is SEQUENTIAL by necessity: Print::process() keeps process-global state, so slicing printers +// concurrently in one process races even with per-slice Model+Print. Load in validation mode so the +// vendors are read straight from the -p profiles dir (no data_dir/system tree) and -v scoping is honoured +// for free. int slice_all_printers(const std::string &vendor, const std::string &outdir) { install_slice_context_log_sink(); + PlaceholderParser::check_inactive_branches = true; if (!outdir.empty()) { boost::system::error_code ec; @@ -323,7 +452,16 @@ int slice_all_printers(const std::string &vendor, const std::string &outdir) std::cout << "Slicing " << printers.size() << " printer preset(s)" << (vendor.empty() ? "" : " for vendor " + vendor) << "..." << std::endl; - int failures = 0; + Templates covered; // the process and filament templates some slice has expanded + auto cover = [&covered](const Preset &preset) { + const Templates templates = preset_templates(preset.config); + covered.insert(templates.begin(), templates.end()); + }; + auto is_covered = [&covered](const Preset &preset) { + const Templates templates = preset_templates(preset.config); + return std::includes(covered.begin(), covered.end(), templates.begin(), templates.end()); + }; + int failures = 0, extra_processes = 0, extra_filaments = 0, by_object_slices = 0; for (const auto &[vendor_name, printer] : printers) { g_slice_context = vendor_name + " / " + printer; // tag every engine log line from this slice const bool selected = bundle.printers.select_preset_by_name(printer, /*force=*/true); @@ -347,76 +485,115 @@ int slice_all_printers(const std::string &vendor, const std::string &outdir) continue; } - // Grow to a 2nd filament so the cube can change colour; never shrink a multi-nozzle printer - // below its nozzle count, or full_config()'s flush-volume matrix no longer matches validate(). - const size_t nozzles = bundle.printers.get_selected_preset().config.option("nozzle_diameter")->size(); - bundle.set_num_filaments((unsigned int) std::max(2, nozzles)); - - // Mirror the app's manual filament->nozzle assignment for a multi-nozzle BBL printer: put each - // filament on its own nozzle and pin the map (fmmManual) so full_config() collapses every filament to - // the variant of the nozzle it actually prints from, and the engine keeps that assignment instead of - // auto-remapping it during process(). Without this the synthetic 2nd filament keeps nozzle 1's variant - // while the auto map moves it to nozzle 2 - harmless, but on the one printer whose nozzles differ in - // type (Direct Drive + Bowden) the mismatched lookup spams [error] lines. Single-nozzle and non-BBL - // printers keep the default map (their toolchange rides the AMS/tool-changer path unchanged). - const bool pin_filament_map = bundle.is_bbl_vendor() && nozzles > 1; - if (pin_filament_map) { - auto &fmap = bundle.project_config.option("filament_map", true)->values; - for (size_t i = 0; i < fmap.size(); ++i) - fmap[i] = int(i % nozzles) + 1; - } - - DynamicPrintConfig cfg = bundle.full_config(); - cfg.set_key_value("enable_prime_tower", new ConfigOptionBool(true)); // force a purge tower so the change is detectable - // The map above drives full_config()'s per-filament variant collapse; fmmManual on the sliced config - // stops process() from auto-remapping filaments back onto a different nozzle (which would re-introduce - // the variant mismatch this pinning avoids). - if (pin_filament_map) - cfg.set_key_value("filament_map_mode", new ConfigOptionEnum(fmmManual)); - - // full_config() grows filament_extruder_variant to one entry per filament, but because the synthetic - // 2nd filament is a duplicate of the first (set_num_filaments copies the same preset), it leaves - // filament_self_index at size 1. That makes update_values_to_printer_extruders_for_multiple_filaments - // fail to resolve the 2nd filament's variant - a benign fallback that spams [error] lines. A real - // 2-colour project ships filament_self_index = 1,2,...; mirror that so the sweep log stays clean. The - // slice output is unaffected: the duplicated filament's per-variant values are identical to the first. - if (auto *variants = cfg.option("filament_extruder_variant")) { - auto &self_index = cfg.option("filament_self_index", true)->values; - if (self_index.size() != variants->size()) { - self_index.resize(variants->size()); - for (size_t i = 0; i < self_index.size(); ++i) - self_index[i] = int(i) + 1; - } - } - - try { - const std::string out = slice_two_color_cube_and_export(cfg, bundle.is_bbl_vendor()); - if (!outdir.empty() && !out.empty()) { - const fs::path f = fs::path(outdir) / (sanitize_filename(vendor_name) + "__" + sanitize_filename(printer) + ".gcode"); - save_string_file(f, out); - } - if (out.empty() || out.find("G1") == std::string::npos) { - BOOST_LOG_TRIVIAL(error) << "Printer \"" << printer << "\" produced no g-code"; - ++failures; - } else if (out.find("CP TOOLCHANGE START") == std::string::npos) { - // The filament change never rode the tower, so change_filament_gcode was not exercised. - BOOST_LOG_TRIVIAL(error) << "Printer \"" << printer - << "\" sliced but the filament change never fired (no CP TOOLCHANGE START)"; - ++failures; - } - } catch (const std::exception &ex) { - BOOST_LOG_TRIVIAL(error) << "Printer \"" << printer << "\" failed to slice: " << ex.what(); + const std::string print_name = bundle.prints.get_selected_preset_name(); + const std::string filament_name = bundle.filaments.get_selected_preset_name(); + const std::string what = "Printer \"" + printer + "\""; + const std::string file_base = sanitize_filename(vendor_name) + "__" + sanitize_filename(printer); + if (const std::string out = slice_selection(bundle, what, false, outdir, file_base); out.empty()) + ++failures; + else if (out.find("CP TOOLCHANGE START") == std::string::npos) { + // The filament change never rode the tower, so change_filament_gcode was not exercised. + BOOST_LOG_TRIVIAL(error) << what << " sliced but the filament change never fired (no CP TOOLCHANGE START)"; ++failures; } + cover(bundle.prints.get_selected_preset()); + cover(bundle.filaments.get_selected_preset()); + + const std::string &by_object_gcode = bundle.printers.get_selected_preset().config.opt_string("printing_by_object_gcode"); + if (!by_object_gcode.empty() && covered.emplace("printing_by_object_gcode", by_object_gcode).second) { + ++by_object_slices; + g_slice_context = vendor_name + " / " + printer + " / by object"; + if (slice_selection(bundle, what + " printing by object", true, outdir, file_base + "__by_object").empty()) + ++failures; + } + + // The first process of this printer that compatible_prints lets an otherwise incompatible filament + // use, judged as update_compatible() would with that process selected; empty if there is none. + const PresetWithVendorProfile printer_with_vendor = bundle.printers.get_edited_preset_with_vendor_profile(); + auto limiting_process = [&bundle, &printer_with_vendor](const Preset &filament) -> std::string { + const auto *processes = filament.config.option("compatible_prints"); + if (processes == nullptr || processes->values.empty()) + return {}; + const PresetWithVendorProfile filament_with_vendor = bundle.filaments.get_preset_with_vendor_profile(filament); + if (!is_compatible_with_printer(filament_with_vendor, printer_with_vendor)) + return {}; + for (const std::string &name : processes->values) + if (const Preset *process = bundle.prints.find_preset(name); + process != nullptr && process->is_visible && process->is_compatible && + is_compatible_with_print(filament_with_vendor, bundle.prints.get_preset_with_vendor_profile(*process), printer_with_vendor)) + return name; + return {}; + }; + // The compatibility flags are still this printer's, from update_compatible() above, which judged the + // filaments against the printer's own process. A hidden preset cannot be selected, so it is counted, + // not sliced. + std::vector> extras; // (extra preset, process to slice it with) + size_t hidden = 0; + for (const PresetCollection *presets : {&bundle.prints, &bundle.filaments}) + for (const Preset &preset : presets->get_presets()) { + if (!preset.is_system || preset.is_default) + continue; + std::string process; + if (preset.is_compatible) + process = preset.type == Preset::TYPE_PRINT ? preset.name : print_name; + else if (preset.type == Preset::TYPE_FILAMENT) + process = limiting_process(preset); + if (process.empty() || is_covered(preset)) + continue; + if (preset.is_visible) + extras.emplace_back(&preset, process); + else + ++hidden; + } + if (hidden > 0) + BOOST_LOG_TRIVIAL(warning) << "Printer \"" << printer << "\": " << hidden + << " compatible system preset(s) with unexpanded templates are hidden and were not sliced"; + + // Each extra slice swaps one preset into the printer's own selection. update_compatible() is not + // run again: it could swap filament slots. The filament change is not required to ride the tower + // here (a vase-mode process has none): the printer's own slice above already expanded its hooks. + for (const auto &[preset, process] : extras) { + if (is_covered(*preset)) + continue; // an earlier extra slice expanded the same texts + const bool is_print = preset->type == Preset::TYPE_PRINT; + const std::string &filament = is_print ? filament_name : preset->name; + std::string extra = std::string(is_print ? "process" : "filament") + " \"" + preset->name + "\""; + if (!is_print && process != print_name) + extra += " and process \"" + process + "\""; + g_slice_context = vendor_name + " / " + printer + " / " + extra; + if (is_print) + ++extra_processes; + else + ++extra_filaments; + cover(*preset); + bundle.prints.select_preset_by_name(process, /*force=*/true); + bundle.filaments.select_preset_by_name(filament, /*force=*/true); + bundle.filament_presets.assign(1, bundle.filaments.get_selected_preset_name()); + bundle.update_multi_material_filament_presets(); + // select_preset_by_name() falls back to another preset, and still returns true, when it cannot select this one. + if (bundle.prints.get_selected_preset_name() != process || bundle.filaments.get_selected_preset_name() != filament) { + BOOST_LOG_TRIVIAL(error) << what << " could not select " << extra; + ++failures; + continue; + } + if (slice_selection(bundle, what + " with " + extra, false, outdir, file_base + "__" + sanitize_filename(preset->name)).empty()) + ++failures; + } + // Leave the printer's own selection for the next printer to start from, as without the extra slices. + bundle.prints.select_preset_by_name(print_name, /*force=*/true); + bundle.filaments.select_preset_by_name(filament_name, /*force=*/true); } g_slice_context.clear(); + const int slices = int(printers.size()) + extra_processes + extra_filaments + by_object_slices; + std::cout << "Sliced " << printers.size() << " printer preset(s), " << extra_processes << " more process preset(s), " + << extra_filaments << " more filament preset(s) and " << by_object_slices << " printer(s) by object" << std::endl; if (failures > 0) { - std::cout << failures << " of " << printers.size() << " printer preset(s) failed to slice" << std::endl; + std::cout << failures << " of " << slices << " slice(s) failed" << std::endl; std::cout << "Validation failed" << std::endl; return 1; } - std::cout << "All " << printers.size() << " printer preset(s) sliced successfully" << std::endl; + std::cout << "All " << slices << " slice(s) succeeded" << std::endl; std::cout << "Validation completed successfully" << std::endl; return 0; } @@ -435,8 +612,8 @@ int main(int argc, char* argv[]) #endif ("vendor,v", po::value()->default_value(""), "Vendor name. Optional, all profiles present in the folder will be validated if not specified") ("generate_presets,g", po::value()->default_value(false), "Generate user presets for mock test") - ("slice,s", po::bool_switch()->default_value(false), "Slice a two-colour cube through every printer to expand all custom g-code (catches placeholder/flow errors that static checks miss). Off unless this flag is present.") - ("outdir,o", po::value()->default_value(""), "With -s, also save each printer's g-code to this folder (as __.gcode) for manual inspection. Optional.") + ("slice,s", po::bool_switch()->default_value(false), "Slice a two-colour cube through every printer, and through every other system process and filament preset whose templates no printer's own slice expands, so every custom g-code and filename_format shipped is expanded, names in {if} branches not taken included (catches placeholder/flow errors that static checks miss). Off unless this flag is present.") + ("outdir,o", po::value()->default_value(""), "With -s, also save each slice's g-code to this folder (as __[__].gcode) for manual inspection. Optional.") ("check_filament_subtypes,f", po::bool_switch()->default_value(true), "Also flag printers with duplicate (ambiguous) filament subtypes. Off unless this flag is present.") ("log_level,l", po::value()->default_value(2), "Log level. Optional, default is 2 (warning). Higher values produce more detailed logs."); // clang-format on diff --git a/src/libslic3r/PlaceholderParser.cpp b/src/libslic3r/PlaceholderParser.cpp index 3e29e0c172..1d7abcd5de 100644 --- a/src/libslic3r/PlaceholderParser.cpp +++ b/src/libslic3r/PlaceholderParser.cpp @@ -851,6 +851,8 @@ namespace client // If true, the macro processor will evaluate just a boolean condition using the full expressive power of the macro processor. bool just_boolean_expression = false; std::string error_message; + // Local variables declared in {if} branches that were not taken, see PlaceholderParser::check_inactive_branches. + mutable std::set inactive_local_variables; // Table to translate symbol tag to a human readable error message. static std::map tag_to_error_message; @@ -892,6 +894,8 @@ namespace client } // Inside a block, which is conditionally suppressed? bool skipping() const { return m_depth_suppressed > 0; } + // Are variable names resolved inside the suppressed blocks too? + bool check_inactive_names() const { return PlaceholderParser::check_inactive_branches && ! just_boolean_expression; } const ConfigOption* optptr(const t_config_option_key &opt_key) const override { @@ -927,7 +931,7 @@ namespace client static void legacy_variable_expansion(const MyContext *ctx, IteratorRange &opt_key, std::string &output) { - if (ctx->skipping()) + if (ctx->skipping() && ! ctx->check_inactive_names()) return; std::string opt_key_str(opt_key.begin(), opt_key.end()); @@ -949,7 +953,9 @@ namespace client } } if (opt == nullptr) - ctx->throw_exception("Variable does not exist", opt_key); + ctx->throw_exception(ctx->skipping() ? "Variable does not exist (in an inactive branch)" : "Variable does not exist", opt_key); + if (ctx->skipping()) + return; if (opt->is_scalar()) { if (opt->is_nil()) ctx->throw_exception("Trying to reference an undefined (nil) optional variable", opt_key); @@ -972,9 +978,10 @@ namespace client IteratorRange &opt_vector_index, std::string &output) { - if (ctx->skipping()) + if (ctx->skipping() && ! ctx->check_inactive_names()) return; + const char *not_found = ctx->skipping() ? "Variable does not exist (in an inactive branch)" : "Variable does not exist"; std::string opt_key_str(opt_key.begin(), opt_key.end()); const ConfigOption *opt = ctx->resolve_symbol(opt_key_str); if (opt == nullptr) { @@ -984,18 +991,20 @@ namespace client opt = ctx->resolve_symbol(opt_key_str); } if (opt == nullptr) - ctx->throw_exception("Variable does not exist", opt_key); + ctx->throw_exception(not_found, opt_key); } if (! opt->is_vector()) ctx->throw_exception("Trying to index a scalar variable", opt_key); + const ConfigOption *opt_index = ctx->resolve_symbol(std::string(opt_vector_index.begin(), opt_vector_index.end())); + if (opt_index == nullptr) + ctx->throw_exception(not_found, opt_key); + if (opt_index->type() != coInt) + ctx->throw_exception("Indexing variable has to be integer", opt_key); + if (ctx->skipping()) + return; const ConfigOptionVectorBase *vec = static_cast(opt); if (vec->empty()) ctx->throw_exception("Indexing an empty vector variable", opt_key); - const ConfigOption *opt_index = ctx->resolve_symbol(std::string(opt_vector_index.begin(), opt_vector_index.end())); - if (opt_index == nullptr) - ctx->throw_exception("Variable does not exist", opt_key); - if (opt_index->type() != coInt) - ctx->throw_exception("Indexing variable has to be integer", opt_key); int idx = opt_index->getInt(); if (idx < 0) ctx->throw_exception("Negative vector index", opt_key); @@ -1021,6 +1030,13 @@ namespace client output.writable = true; } output.opt = opt; + } else if (ctx->check_inactive_names()) { + // Only check the name. Back tracking may resolve the same identifier twice, so there are no side effects. + const std::string key{ opt_key.begin(), opt_key.end() }; + if (ctx->resolve_symbol(key) == nullptr && ctx->resolve_output_symbol(key) == nullptr && + ctx->inactive_local_variables.count(key) == 0 && + (ctx->context_data == nullptr || ctx->context_data->inactive_global_variables.count(key) == 0)) + ctx->throw_exception("Not a variable name (in an inactive branch)", opt_key); } output.it_range = opt_key; } @@ -1426,6 +1442,13 @@ namespace client out.opt = ctx->config_local.optptr(key); } out.name = std::move(key); + } else if (ctx->check_inactive_names()) { + // Declared in a branch that is not taken: the name still counts as defined for the names that follow. + std::string key(it_range.begin(), it_range.end()); + if (global_variable && ctx->context_data != nullptr) + ctx->context_data->inactive_global_variables.insert(std::move(key)); + else + ctx->inactive_local_variables.insert(std::move(key)); } out.it_range = it_range; } @@ -1660,8 +1683,8 @@ namespace client const OptWithPos &rhs) { if (ctx->skipping()) - // Skipping, continue parsing. - return true; + // Skipping, let conditional_expression parse the whole right hand side, which may continue after the variable reference. + return false; if (lhs.opt) { assert(lhs.opt->is_vector()); diff --git a/src/libslic3r/PlaceholderParser.hpp b/src/libslic3r/PlaceholderParser.hpp index 3f3998dc5a..e32a4de9dc 100644 --- a/src/libslic3r/PlaceholderParser.hpp +++ b/src/libslic3r/PlaceholderParser.hpp @@ -4,6 +4,7 @@ #include "libslic3r.h" #include #include +#include #include #include #include @@ -24,8 +25,15 @@ public: // If defined, then this dictionary is used by the scripts to define user variables and persist them // between PlaceholderParser evaluations. std::unique_ptr global_config; + // Global variables declared in {if} branches that were not taken, see check_inactive_branches. + std::set inactive_global_variables; }; + // Orca: when set, variable names inside {if} branches that are not taken must resolve too, so a single + // expansion checks every branch of a template. Only the profile validator's slice sweep sets it. + // It does not apply to evaluate_boolean_expression(), where an error reads as "compatible". + static inline bool check_inactive_branches = false; + PlaceholderParser(const DynamicConfig *external_config = nullptr); void clear_config() { m_config.clear(); } diff --git a/tests/libslic3r/test_placeholder_parser.cpp b/tests/libslic3r/test_placeholder_parser.cpp index 14716b0b5e..4a95156c34 100644 --- a/tests/libslic3r/test_placeholder_parser.cpp +++ b/tests/libslic3r/test_placeholder_parser.cpp @@ -356,3 +356,38 @@ SCENARIO("Placeholder parser coFloatsOrPercents vector access", "[PlaceholderPar REQUIRE(std::stod(parser.process("{pressure_advance[2]}")) == Catch::Approx(3.0)); } } + +SCENARIO("Placeholder parser names in branches that are not taken", "[PlaceholderParser]") { + PlaceholderParser parser; + auto config = DynamicPrintConfig::full_print_config(); + parser.apply_config(config); + parser.set("idx", 0); + PlaceholderParser::ContextData context; + context.global_config = std::make_unique(); + auto process = [&parser, &context](const std::string &templ) { return parser.process(templ, 0, nullptr, nullptr, &context); }; + + SECTION("a declaration continuing after a variable reference parses when not taken") { + REQUIRE(process("{if false}{local a = layer_height + 1}{endif}ok") == "ok"); + } + SECTION("names are not checked by default") { + REQUIRE(process("{if false}{no_such_var}[no_such_var]{endif}ok") == "ok"); + } + SECTION("names must resolve when check_inactive_branches is set") { + struct Restore { ~Restore() { PlaceholderParser::check_inactive_branches = false; } } restore; + PlaceholderParser::check_inactive_branches = true; + + CHECK_THROWS_WITH(process("{if false}{no_such_var}{endif}"), Catch::Matchers::ContainsSubstring("Not a variable name (in an inactive branch)")); + CHECK_THROWS_WITH(process("{if false}[no_such_var]{endif}"), Catch::Matchers::ContainsSubstring("Variable does not exist (in an inactive branch)")); + CHECK_THROWS_WITH(process("{if false}[nozzle_temperature[no_such_var]]{endif}"), Catch::Matchers::ContainsSubstring("Variable does not exist (in an inactive branch)")); + CHECK_THROWS(process("{if true}{else}{no_such_var}{endif}")); + CHECK_THROWS(process("{if false}{local a = no_such_var + 1}{endif}")); + + CHECK(process("{if false}{layer_height}[layer_height][nozzle_temperature_0][nozzle_temperature[idx]]{endif}ok") == "ok"); + CHECK(process("{if false}{local a = 1}{a = a + 1}{a}{endif}{if false}{a}{endif}ok") == "ok"); + // A global declared in a branch that is not taken counts as defined for later expansions sharing the context. + CHECK(process("{if false}{global g = 1}{endif}{if false}{g}{endif}ok") == "ok"); + CHECK(process("{if false}{g}{endif}ok") == "ok"); + // Boolean expressions are not checked, so compatibility conditions keep their behaviour. + CHECK(PlaceholderParser::evaluate_boolean_expression("false ? no_such_var == 1 : true", config)); + } +} From 79afb020db93cac7130d79c31510173f558b9af6 Mon Sep 17 00:00:00 2001 From: Santiago Postorivo <55957230+persano@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:15:29 -0300 Subject: [PATCH 4/7] fix(device): resolve current-print thumbnail placeholder in Device panel (#15911) --- src/slic3r/GUI/DeviceManager.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/slic3r/GUI/DeviceManager.cpp b/src/slic3r/GUI/DeviceManager.cpp index 6c09e05f48..013e411574 100644 --- a/src/slic3r/GUI/DeviceManager.cpp +++ b/src/slic3r/GUI/DeviceManager.cpp @@ -4896,7 +4896,8 @@ void MachineObject::update_slice_info(std::string project_id, std::string profil std::string subtask_json; unsigned http_code = 0; std::string http_body; - if (m_agent->get_subtask_info(subtask_id, &subtask_json, &http_code, &http_body) == 0) { + if (m_agent->get_subtask_info(subtask_id, &subtask_json, &http_code, &http_body, + Slic3r::GUI::wxGetApp().get_printer_cloud_provider()) == 0) { try { if (!subtask_json.empty()) { From 7d8318f2756894973d3baa130b64900173e6aa9e Mon Sep 17 00:00:00 2001 From: Paulcake Date: Tue, 29 Sep 2026 04:15:28 +0100 Subject: [PATCH 5/7] profiles: initialize Ender-3 V3 SE extrusion mode before purge (#15489) # Description The Ender-3 V3 SE machine start G-code performs its purge using absolute-style extrusion positions: ```gcode G1 ... E15 G1 ... E30 ``` However, the machine start G-code does not explicitly initialize the positioning or extrusion mode before these commands. OrcaSlicer emits the printer's custom `machine_start_gcode` before its own generated `G90` / `M82` or `M83` preamble. This means the purge can inherit the extrusion mode left active by the printer. For example, if `M83` relative extrusion is still active, such as after a cancelled print where normal end G-code was not executed: - `E15` extrudes 15 mm - `E30` extrudes another 30 mm Instead of the intended 15 mm followed by another 15 mm. This change explicitly adds: ```gcode G90 ;Absolute positioning M82 ;Absolute extrusion mode ``` before the purge sequence so startup behaviour is deterministic and does not depend on inherited printer state. The change is applied consistently to all Ender-3 V3 SE nozzle variants: - 0.2 mm - 0.4 mm - 0.6 mm - 0.8 mm No print speeds, temperatures, retraction values, machine limits, or other profile settings are changed. # Screenshots/Recordings/Graphs Not applicable. This is a machine start G-code profile fix. ## Tests - Confirmed all four Ender-3 V3 SE profiles use the same `E15` / `E30` purge sequence. - Confirmed the current profiles do not explicitly issue `G90`, `M82`, or `M83` before that purge. - Confirmed OrcaSlicer emits `machine_start_gcode` before its generated positioning/extrusion-mode preamble. - Verified the modified JSON for all four machine profiles parses successfully. - Verified the added commands make the purge explicitly use absolute XYZ and absolute extrusion state. --- .../Creality/machine/Creality Ender-3 V3 SE 0.2 nozzle.json | 2 +- .../Creality/machine/Creality Ender-3 V3 SE 0.4 nozzle.json | 2 +- .../Creality/machine/Creality Ender-3 V3 SE 0.6 nozzle.json | 2 +- .../Creality/machine/Creality Ender-3 V3 SE 0.8 nozzle.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/resources/profiles/Creality/machine/Creality Ender-3 V3 SE 0.2 nozzle.json b/resources/profiles/Creality/machine/Creality Ender-3 V3 SE 0.2 nozzle.json index 378dca3a2d..46d9ce1085 100644 --- a/resources/profiles/Creality/machine/Creality Ender-3 V3 SE 0.2 nozzle.json +++ b/resources/profiles/Creality/machine/Creality Ender-3 V3 SE 0.2 nozzle.json @@ -121,7 +121,7 @@ "Generic PLA @Creality Ender-3V3-all" ], "file_start_gcode": ";FLAVOR:Marlin\\n;TIME:{print_time_total_sec}\\n;Filament used:{used_filament_length}m\\n;Layer height:{layer_height}", - "machine_start_gcode": "M220 S100 ;Reset Feedrate \nM221 S100 ;Reset Flowrate \n \nM104 S[nozzle_temperature_initial_layer] ;Set final nozzle temp \nM190 S[bed_temperature_initial_layer_single] ;Set and wait for bed temp to stabilize \nG28 ;Home \nG92 E0 ;Reset Extruder \nG1 Z2.0 F3000 ;Move Z Axis up \nG1 X-2.1 Y20 Z0.28 F5000.0 ;Move to start position \nM109 S[nozzle_temperature_initial_layer] ;Wait for nozzle temp to stabilize \nG1 X-2.1 Y145.0 Z0.28 F1500.0 E15 ;Draw the first line \nG1 X-2.4 Y145.0 Z0.28 F5000.0 ;Move to side a little \nG1 X-2.4 Y20 Z0.28 F1500.0 E30 ;Draw the second line \nG92 E0 ;Reset Extruder \nG1 E-1.0000 F1800 ;Retract a bit \nG1 Z2.0 F3000 ;Move Z Axis up \nG1 E0.0000 F1800", + "machine_start_gcode": "M220 S100 ;Reset Feedrate \nM221 S100 ;Reset Flowrate \n \nG90 ;Absolute positioning \nM82 ;Absolute extrusion mode \n \nM104 S[nozzle_temperature_initial_layer] ;Set final nozzle temp \nM190 S[bed_temperature_initial_layer_single] ;Set and wait for bed temp to stabilize \nG28 ;Home \nG92 E0 ;Reset Extruder \nG1 Z2.0 F3000 ;Move Z Axis up \nG1 X-2.1 Y20 Z0.28 F5000.0 ;Move to start position \nM109 S[nozzle_temperature_initial_layer] ;Wait for nozzle temp to stabilize \nG1 X-2.1 Y145.0 Z0.28 F1500.0 E15 ;Draw the first line \nG1 X-2.4 Y145.0 Z0.28 F5000.0 ;Move to side a little \nG1 X-2.4 Y20 Z0.28 F1500.0 E30 ;Draw the second line \nG92 E0 ;Reset Extruder \nG1 E-1.0000 F1800 ;Retract a bit \nG1 Z2.0 F3000 ;Move Z Axis up \nG1 E0.0000 F1800", "machine_end_gcode": "G91 ;Relative positionning \nG1 E-2 F2700 ;Retract a bit \nG1 E-2 Z0.2 F2400 ;Retract and raise Z \nG1 X5 Y5 F3000 ;Wipe out \nG1 Z10 ;Raise Z more \nG90 ;Absolute positionning \n \nG1 X0 Y220 ;Present print \nM106 S0 ;Turn-off fan \nM104 S0 ;Turn-off hotend \nM140 S0 ;Turn-off bed \n \nM84 X Y E ;Disable all steppers but Z", "scan_first_layer": "0", "disable_m73": "1", diff --git a/resources/profiles/Creality/machine/Creality Ender-3 V3 SE 0.4 nozzle.json b/resources/profiles/Creality/machine/Creality Ender-3 V3 SE 0.4 nozzle.json index 97ef822dc7..b905be8d39 100644 --- a/resources/profiles/Creality/machine/Creality Ender-3 V3 SE 0.4 nozzle.json +++ b/resources/profiles/Creality/machine/Creality Ender-3 V3 SE 0.4 nozzle.json @@ -121,7 +121,7 @@ "Generic PLA @Creality Ender-3V3-all" ], "file_start_gcode": ";FLAVOR:Marlin\\n;TIME:{print_time_total_sec}\\n;Filament used:{used_filament_length}m\\n;Layer height:{layer_height}", - "machine_start_gcode": "M220 S100 ;Reset Feedrate \nM221 S100 ;Reset Flowrate \n \nM104 S[nozzle_temperature_initial_layer] ;Set final nozzle temp \nM190 S[bed_temperature_initial_layer_single] ;Set and wait for bed temp to stabilize \nG28 ;Home \nG92 E0 ;Reset Extruder \nG1 Z2.0 F3000 ;Move Z Axis up \nG1 X-2.1 Y20 Z0.28 F5000.0 ;Move to start position \nM109 S[nozzle_temperature_initial_layer] ;Wait for nozzle temp to stabilize \nG1 X-2.1 Y145.0 Z0.28 F1500.0 E15 ;Draw the first line \nG1 X-2.4 Y145.0 Z0.28 F5000.0 ;Move to side a little \nG1 X-2.4 Y20 Z0.28 F1500.0 E30 ;Draw the second line \nG92 E0 ;Reset Extruder \nG1 E-1.0000 F1800 ;Retract a bit \nG1 Z2.0 F3000 ;Move Z Axis up \nG1 E0.0000 F1800", + "machine_start_gcode": "M220 S100 ;Reset Feedrate \nM221 S100 ;Reset Flowrate \n \nG90 ;Absolute positioning \nM82 ;Absolute extrusion mode \n \nM104 S[nozzle_temperature_initial_layer] ;Set final nozzle temp \nM190 S[bed_temperature_initial_layer_single] ;Set and wait for bed temp to stabilize \nG28 ;Home \nG92 E0 ;Reset Extruder \nG1 Z2.0 F3000 ;Move Z Axis up \nG1 X-2.1 Y20 Z0.28 F5000.0 ;Move to start position \nM109 S[nozzle_temperature_initial_layer] ;Wait for nozzle temp to stabilize \nG1 X-2.1 Y145.0 Z0.28 F1500.0 E15 ;Draw the first line \nG1 X-2.4 Y145.0 Z0.28 F5000.0 ;Move to side a little \nG1 X-2.4 Y20 Z0.28 F1500.0 E30 ;Draw the second line \nG92 E0 ;Reset Extruder \nG1 E-1.0000 F1800 ;Retract a bit \nG1 Z2.0 F3000 ;Move Z Axis up \nG1 E0.0000 F1800", "machine_end_gcode": "G91 ;Relative positionning \nG1 E-2 F2700 ;Retract a bit \nG1 E-2 Z0.2 F2400 ;Retract and raise Z \nG1 X5 Y5 F3000 ;Wipe out \nG1 Z10 ;Raise Z more \nG90 ;Absolute positionning \n \nG1 X0 Y220 ;Present print \nM106 S0 ;Turn-off fan \nM104 S0 ;Turn-off hotend \nM140 S0 ;Turn-off bed \n \nM84 X Y E ;Disable all steppers but Z", "scan_first_layer": "0", "disable_m73": "1", diff --git a/resources/profiles/Creality/machine/Creality Ender-3 V3 SE 0.6 nozzle.json b/resources/profiles/Creality/machine/Creality Ender-3 V3 SE 0.6 nozzle.json index 7023c5a95a..40ad654754 100644 --- a/resources/profiles/Creality/machine/Creality Ender-3 V3 SE 0.6 nozzle.json +++ b/resources/profiles/Creality/machine/Creality Ender-3 V3 SE 0.6 nozzle.json @@ -121,7 +121,7 @@ "Generic PLA @Creality Ender-3V3-all" ], "file_start_gcode": ";FLAVOR:Marlin\\n;TIME:{print_time_total_sec}\\n;Filament used:{used_filament_length}m\\n;Layer height:{layer_height}", - "machine_start_gcode": "M220 S100 ;Reset Feedrate \nM221 S100 ;Reset Flowrate \n \nM104 S[nozzle_temperature_initial_layer] ;Set final nozzle temp \nM190 S[bed_temperature_initial_layer_single] ;Set and wait for bed temp to stabilize \nG28 ;Home \nG92 E0 ;Reset Extruder \nG1 Z2.0 F3000 ;Move Z Axis up \nG1 X-2.1 Y20 Z0.28 F5000.0 ;Move to start position \nM109 S[nozzle_temperature_initial_layer] ;Wait for nozzle temp to stabilize \nG1 X-2.1 Y145.0 Z0.28 F1500.0 E15 ;Draw the first line \nG1 X-2.4 Y145.0 Z0.28 F5000.0 ;Move to side a little \nG1 X-2.4 Y20 Z0.28 F1500.0 E30 ;Draw the second line \nG92 E0 ;Reset Extruder \nG1 E-1.0000 F1800 ;Retract a bit \nG1 Z2.0 F3000 ;Move Z Axis up \nG1 E0.0000 F1800", + "machine_start_gcode": "M220 S100 ;Reset Feedrate \nM221 S100 ;Reset Flowrate \n \nG90 ;Absolute positioning \nM82 ;Absolute extrusion mode \n \nM104 S[nozzle_temperature_initial_layer] ;Set final nozzle temp \nM190 S[bed_temperature_initial_layer_single] ;Set and wait for bed temp to stabilize \nG28 ;Home \nG92 E0 ;Reset Extruder \nG1 Z2.0 F3000 ;Move Z Axis up \nG1 X-2.1 Y20 Z0.28 F5000.0 ;Move to start position \nM109 S[nozzle_temperature_initial_layer] ;Wait for nozzle temp to stabilize \nG1 X-2.1 Y145.0 Z0.28 F1500.0 E15 ;Draw the first line \nG1 X-2.4 Y145.0 Z0.28 F5000.0 ;Move to side a little \nG1 X-2.4 Y20 Z0.28 F1500.0 E30 ;Draw the second line \nG92 E0 ;Reset Extruder \nG1 E-1.0000 F1800 ;Retract a bit \nG1 Z2.0 F3000 ;Move Z Axis up \nG1 E0.0000 F1800", "machine_end_gcode": "G91 ;Relative positionning \nG1 E-2 F2700 ;Retract a bit \nG1 E-2 Z0.2 F2400 ;Retract and raise Z \nG1 X5 Y5 F3000 ;Wipe out \nG1 Z10 ;Raise Z more \nG90 ;Absolute positionning \n \nG1 X0 Y220 ;Present print \nM106 S0 ;Turn-off fan \nM104 S0 ;Turn-off hotend \nM140 S0 ;Turn-off bed \n \nM84 X Y E ;Disable all steppers but Z", "scan_first_layer": "0", "disable_m73": "1", diff --git a/resources/profiles/Creality/machine/Creality Ender-3 V3 SE 0.8 nozzle.json b/resources/profiles/Creality/machine/Creality Ender-3 V3 SE 0.8 nozzle.json index acec590a1f..1b6053bd36 100644 --- a/resources/profiles/Creality/machine/Creality Ender-3 V3 SE 0.8 nozzle.json +++ b/resources/profiles/Creality/machine/Creality Ender-3 V3 SE 0.8 nozzle.json @@ -121,7 +121,7 @@ "Generic PLA @Creality Ender-3V3-all" ], "file_start_gcode": ";FLAVOR:Marlin\\n;TIME:{print_time_total_sec}\\n;Filament used:{used_filament_length}m\\n;Layer height:{layer_height}", - "machine_start_gcode": "M220 S100 ;Reset Feedrate \nM221 S100 ;Reset Flowrate \n \nM104 S[nozzle_temperature_initial_layer] ;Set final nozzle temp \nM190 S[bed_temperature_initial_layer_single] ;Set and wait for bed temp to stabilize \nG28 ;Home \nG92 E0 ;Reset Extruder \nG1 Z2.0 F3000 ;Move Z Axis up \nG1 X-2.1 Y20 Z0.28 F5000.0 ;Move to start position \nM109 S[nozzle_temperature_initial_layer] ;Wait for nozzle temp to stabilize \nG1 X-2.1 Y145.0 Z0.28 F1500.0 E15 ;Draw the first line \nG1 X-2.4 Y145.0 Z0.28 F5000.0 ;Move to side a little \nG1 X-2.4 Y20 Z0.28 F1500.0 E30 ;Draw the second line \nG92 E0 ;Reset Extruder \nG1 E-1.0000 F1800 ;Retract a bit \nG1 Z2.0 F3000 ;Move Z Axis up \nG1 E0.0000 F1800", + "machine_start_gcode": "M220 S100 ;Reset Feedrate \nM221 S100 ;Reset Flowrate \n \nG90 ;Absolute positioning \nM82 ;Absolute extrusion mode \n \nM104 S[nozzle_temperature_initial_layer] ;Set final nozzle temp \nM190 S[bed_temperature_initial_layer_single] ;Set and wait for bed temp to stabilize \nG28 ;Home \nG92 E0 ;Reset Extruder \nG1 Z2.0 F3000 ;Move Z Axis up \nG1 X-2.1 Y20 Z0.28 F5000.0 ;Move to start position \nM109 S[nozzle_temperature_initial_layer] ;Wait for nozzle temp to stabilize \nG1 X-2.1 Y145.0 Z0.28 F1500.0 E15 ;Draw the first line \nG1 X-2.4 Y145.0 Z0.28 F5000.0 ;Move to side a little \nG1 X-2.4 Y20 Z0.28 F1500.0 E30 ;Draw the second line \nG92 E0 ;Reset Extruder \nG1 E-1.0000 F1800 ;Retract a bit \nG1 Z2.0 F3000 ;Move Z Axis up \nG1 E0.0000 F1800", "machine_end_gcode": "G91 ;Relative positionning \nG1 E-2 F2700 ;Retract a bit \nG1 E-2 Z0.2 F2400 ;Retract and raise Z \nG1 X5 Y5 F3000 ;Wipe out \nG1 Z10 ;Raise Z more \nG90 ;Absolute positionning \n \nG1 X0 Y220 ;Present print \nM106 S0 ;Turn-off fan \nM104 S0 ;Turn-off hotend \nM140 S0 ;Turn-off bed \n \nM84 X Y E ;Disable all steppers but Z", "scan_first_layer": "0", "disable_m73": "1", From 8ffd3e514ec9b69fcc1550bec577f5ffdd5aa9dc Mon Sep 17 00:00:00 2001 From: HanifKoh <76276251+HanifKoh@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:25:16 +0800 Subject: [PATCH 6/7] Harden OBJ and DRC Import Against Malformed Files (#15948) * Validate OBJ Texture-Coordinate Indices load_obj read the texture coordinates of a face without checking the vt index, so a face referencing a vt past the end of the list read out of bounds and crashed, and a face vertex with no vt read index -1. Out-of-range or missing indices now fall back to a zero UV. The face keeps its entry in the per-face UV list, so the following faces stay aligned, and the geometry loads as before. Negative (relative) vt indices were also rebased by dividing the float count by 3, but each vt stores two floats. * Reject DRC Meshes Without Positions or with Invalid Face Indices load_drc dereferenced the POSITION attribute without checking that the mesh has one, and trusted the decoded face indices, which the Draco decoder does not check against the point count. Both now fail the load cleanly. A failed vertex conversion is treated the same way. The libslic3r tests link Draco so they can encode the malformed meshes in-test. * Keep OBJ Texture Coordinates That Carry a W Component The vt parser stopped reading the optional third component when texture coordinates were cut down to u and v, but the check that nothing is left on the line stayed. A legal "vt u v w" line was therefore rejected and silently dropped, shifting every later texture index. The w component is parsed again and discarded. The texture coordinate stride is now a named constant, OBJ_TEXCOORD_LENGTH, used by the parser and the importer, so the relative-index rebase cannot drift from the storage layout again. --- src/libslic3r/Format/DRC.cpp | 29 +++++-- src/libslic3r/Format/OBJ.cpp | 13 ++- src/libslic3r/Format/objparser.cpp | 9 +-- src/libslic3r/Format/objparser.hpp | 3 +- tests/libslic3r/CMakeLists.txt | 6 +- tests/libslic3r/test_drc.cpp | 71 +++++++++++++++++ tests/libslic3r/test_obj.cpp | 122 +++++++++++++++++++++++++++++ 7 files changed, 234 insertions(+), 19 deletions(-) create mode 100644 tests/libslic3r/test_drc.cpp create mode 100644 tests/libslic3r/test_obj.cpp diff --git a/src/libslic3r/Format/DRC.cpp b/src/libslic3r/Format/DRC.cpp index d489f04dba..c07655f69b 100644 --- a/src/libslic3r/Format/DRC.cpp +++ b/src/libslic3r/Format/DRC.cpp @@ -48,24 +48,37 @@ bool load_drc(const char *path, TriangleMesh *meshptr) indexed_triangle_set its; const PointAttribute *const positions = dracoMesh.GetNamedAttribute(GeometryAttribute::POSITION); + if (positions == nullptr) { + BOOST_LOG_TRIVIAL(error) << "load_drc: the mesh has no POSITION attribute"; + return false; + } size_t num_vertices = positions->size(); its.vertices.reserve(num_vertices); for (AttributeValueIndex i(0); i < num_vertices; ++ i) { float pos[3]; - positions->ConvertValue(i, 3, pos); + if (!positions->ConvertValue(i, 3, pos)) { + BOOST_LOG_TRIVIAL(error) << "load_drc: invalid vertex position"; + return false; + } its.vertices.emplace_back(pos[0], pos[1], pos[2]); } + // The Draco decoder does not check face indices against the point count. + const uint32_t num_points = dracoMesh.num_points(); size_t num_faces = dracoMesh.num_faces(); its.indices.reserve(num_faces); for (FaceIndex i(0); i < num_faces; ++ i) { - Mesh::Face face = dracoMesh.face(i); - - its.indices.emplace_back( - positions->mapped_index(face[0]).value(), - positions->mapped_index(face[1]).value(), - positions->mapped_index(face[2]).value() - ); + const Mesh::Face &face = dracoMesh.face(i); + stl_triangle_vertex_indices facet; + for (int k = 0; k < 3; ++ k) { + const size_t vertex_idx = face[k].value() < num_points ? positions->mapped_index(face[k]).value() : num_vertices; + if (vertex_idx >= num_vertices) { + BOOST_LOG_TRIVIAL(error) << "load_drc: invalid vertex index"; + return false; + } + facet[k] = static_cast(vertex_idx); + } + its.indices.emplace_back(facet); } *meshptr = TriangleMesh(std::move(its)); diff --git a/src/libslic3r/Format/OBJ.cpp b/src/libslic3r/Format/OBJ.cpp index 10abe8e4de..de91efb64d 100644 --- a/src/libslic3r/Format/OBJ.cpp +++ b/src/libslic3r/Format/OBJ.cpp @@ -166,10 +166,15 @@ bool load_obj(const char *path, TriangleMesh *meshptr, ObjInfo& obj_info, std::s obj_info.uv_map_pngs[face_index] = png_name; } if (data.textureCoordinates.size() > 0) { - Vec2f uv0(data.textureCoordinates[uvs[0] * 2], data.textureCoordinates[uvs[0] * 2 + 1]); - Vec2f uv1(data.textureCoordinates[uvs[1] * 2], data.textureCoordinates[uvs[1] * 2 + 1]); - Vec2f uv2(data.textureCoordinates[uvs[2] * 2], data.textureCoordinates[uvs[2] * 2 + 1]); - std::array uv_array{uv0, uv1, uv2}; + // A face vertex may omit vt or reference a missing one. Fall back to (0, 0) rather than + // skipping the face, so obj_info.uvs stays aligned with the face indices. + const int uv_count = static_cast(data.textureCoordinates.size() / OBJ_TEXCOORD_LENGTH); + auto uv_at = [&data, uv_count](int idx) -> Vec2f { + if (idx < 0 || idx >= uv_count) + return Vec2f::Zero(); + return Vec2f(data.textureCoordinates[idx * OBJ_TEXCOORD_LENGTH], data.textureCoordinates[idx * OBJ_TEXCOORD_LENGTH + 1]); + }; + std::array uv_array{uv_at(uvs[0]), uv_at(uvs[1]), uv_at(uvs[2])}; obj_info.uvs.emplace_back(uv_array); } obj_info.face_colors.emplace_back(face_color); diff --git a/src/libslic3r/Format/objparser.cpp b/src/libslic3r/Format/objparser.cpp index 886fa423bd..c5f33a7d0a 100644 --- a/src/libslic3r/Format/objparser.cpp +++ b/src/libslic3r/Format/objparser.cpp @@ -51,19 +51,18 @@ static bool obj_parseline(const char *line, ObjData &data) line = endptr; EATWS(); } - /*double w = 0; + // The optional w is accepted but not stored: only u and v are used. if (*line != 0) { - w = strtod(line, &endptr); + strtod(line, &endptr); if (endptr == 0 || (*endptr != ' ' && *endptr != '\t' && *endptr != 0)) return false; line = endptr; EATWS(); - }*/ + } if (*line != 0) return false; data.textureCoordinates.push_back((float)u); data.textureCoordinates.push_back((float)v); - //data.textureCoordinates.push_back((float)w); break; } case 'n': @@ -245,7 +244,7 @@ static bool obj_parseline(const char *line, ObjData &data) else -- vertex.normalIdx; if (vertex.textureCoordIdx < 0) - vertex.textureCoordIdx += (int)data.textureCoordinates.size() / 3; + vertex.textureCoordIdx += (int)data.textureCoordinates.size() / OBJ_TEXCOORD_LENGTH; else -- vertex.textureCoordIdx; data.vertices.push_back(vertex); diff --git a/src/libslic3r/Format/objparser.hpp b/src/libslic3r/Format/objparser.hpp index 58afd015a8..e711bdf6f4 100644 --- a/src/libslic3r/Format/objparser.hpp +++ b/src/libslic3r/Format/objparser.hpp @@ -92,6 +92,7 @@ inline bool operator==(const ObjSmoothingGroup &v1, const ObjSmoothingGroup &v2) } #define OBJ_VERTEX_COLOR_ALPHA 6 #define OBJ_VERTEX_LENGTH 7 // x, y, z, color_x,color_y,color_z,color_w +#define OBJ_TEXCOORD_LENGTH 2 // u, v #define ONE_FACE_SIZE 4//ONE_FACE format: f 8/4/6 7/3/6 6/2/6 -1/-1/-1 struct ObjData { // Version of the data structure for load / store in the private binary format. @@ -100,7 +101,7 @@ struct ObjData { // x, y, z, color_x,color_y,color_z,color_w std::vector coordinates; bool has_vertex_color{false}; - // u, v, w + // u, v std::vector textureCoordinates; // x, y, z std::vector normals; diff --git a/tests/libslic3r/CMakeLists.txt b/tests/libslic3r/CMakeLists.txt index 5ee438a0e4..2c7ac2e648 100644 --- a/tests/libslic3r/CMakeLists.txt +++ b/tests/libslic3r/CMakeLists.txt @@ -16,6 +16,7 @@ add_executable(${_TEST_NAME}_tests test_clipper_utils.cpp test_config.cpp test_config_variant_expansion.cpp + test_drc.cpp test_toolordering_nozzle_group.cpp test_preset_bundle_loading.cpp test_preset_setting_id.cpp @@ -34,6 +35,7 @@ add_executable(${_TEST_NAME}_tests test_mutable_priority_queue.cpp test_minimum_spanning_tree.cpp test_nozzle_volume_type.cpp + test_obj.cpp test_step.cpp test_stl.cpp test_triangle_selector.cpp @@ -69,7 +71,9 @@ if (TARGET OpenVDB::openvdb) target_sources(${_TEST_NAME}_tests PRIVATE test_hollowing.cpp) endif() -target_link_libraries(${_TEST_NAME}_tests test_common libslic3r Catch2::Catch2WithMain) +# libslic3r links Draco privately; test_drc.cpp encodes its own fixtures. +find_package(Draco REQUIRED) +target_link_libraries(${_TEST_NAME}_tests test_common libslic3r draco::draco Catch2::Catch2WithMain) target_include_directories(${_TEST_NAME}_tests PRIVATE ${CMAKE_SOURCE_DIR}/src) set_property(TARGET ${_TEST_NAME}_tests PROPERTY FOLDER "tests") diff --git a/tests/libslic3r/test_drc.cpp b/tests/libslic3r/test_drc.cpp new file mode 100644 index 0000000000..917fd5c714 --- /dev/null +++ b/tests/libslic3r/test_drc.cpp @@ -0,0 +1,71 @@ +#include + +#include "libslic3r/Format/DRC.hpp" +#include "libslic3r/TriangleMesh.hpp" + +#include + +#include +#include + +#include "test_utils.hpp" + +using namespace Slic3r; + +namespace { + +// Encodes one triangle whose vertices carry a single attribute of the given type. +// The decoder accepts both a mesh without POSITION and a face index past the point count. +void write_drc_triangle(const std::string &path, draco::GeometryAttribute::Type attribute_type, uint32_t second_point) +{ + draco::Mesh mesh; + mesh.set_num_points(3); + draco::GeometryAttribute attribute; + attribute.Init(attribute_type, nullptr, 3, draco::DT_FLOAT32, false, sizeof(float) * 3, 0); + const int attribute_id = mesh.AddAttribute(attribute, true, 3); + const float points[3][3] = {{0, 0, 0}, {1, 0, 0}, {0, 1, 0}}; + for (int i = 0; i < 3; ++i) + mesh.attribute(attribute_id)->SetAttributeValue(draco::AttributeValueIndex(i), points[i]); + draco::Mesh::Face face; + face[0] = draco::PointIndex(0); + face[1] = draco::PointIndex(second_point); + face[2] = draco::PointIndex(2); + mesh.AddFace(face); + + draco::Encoder encoder; + encoder.SetEncodingMethod(draco::MESH_SEQUENTIAL_ENCODING); + draco::EncoderBuffer buffer; + REQUIRE(encoder.EncodeMeshToBuffer(mesh, &buffer).ok()); + boost::nowide::ofstream out(path, std::ios::binary); + out.write(buffer.data(), static_cast(buffer.size())); +} + +} // namespace + +TEST_CASE("A Draco triangle with positions loads", "[DRC]") +{ + ScopedTemporaryFile drc(".drc"); + write_drc_triangle(drc.string(), draco::GeometryAttribute::POSITION, 1); + + TriangleMesh mesh; + REQUIRE(load_drc(drc.string().c_str(), &mesh)); + CHECK(mesh.facets_count() == 1); +} + +TEST_CASE("A Draco mesh without a POSITION attribute fails to load", "[DRC][Regression]") +{ + ScopedTemporaryFile drc(".drc"); + write_drc_triangle(drc.string(), draco::GeometryAttribute::GENERIC, 1); + + TriangleMesh mesh; + CHECK_FALSE(load_drc(drc.string().c_str(), &mesh)); +} + +TEST_CASE("A Draco face referencing a missing point fails to load", "[DRC][Regression]") +{ + ScopedTemporaryFile drc(".drc"); + write_drc_triangle(drc.string(), draco::GeometryAttribute::POSITION, 200); + + TriangleMesh mesh; + CHECK_FALSE(load_drc(drc.string().c_str(), &mesh)); +} diff --git a/tests/libslic3r/test_obj.cpp b/tests/libslic3r/test_obj.cpp new file mode 100644 index 0000000000..d198a69c8c --- /dev/null +++ b/tests/libslic3r/test_obj.cpp @@ -0,0 +1,122 @@ +#include + +#include "libslic3r/TriangleMesh.hpp" +#include "libslic3r/Format/OBJ.hpp" + +#include + +#include "test_utils.hpp" + +using namespace Slic3r; +using Catch::Matchers::WithinAbs; + +namespace { + +struct LoadedObj +{ + bool ok{false}; + TriangleMesh mesh; + ObjInfo info; +}; + +// A tetrahedron with a material and two texture coordinates, (0.25, 0.5) and (0.75, 1). +// Only the first face and the vt lines are varied; the other three faces reference vt 1. +LoadedObj load_textured_tetrahedron(const std::string &first_face, const std::string &vts = "vt 0.25 0.5\nvt 0.75 1\n") +{ + ScopedTemporaryFile obj(".obj"); + ScopedTemporaryFile mtl(".mtl"); + { + boost::nowide::ofstream out(mtl.string()); + out << "newmtl a\nKd 1 0 0\n"; + } + { + boost::nowide::ofstream out(obj.string()); + out << "mtllib " << mtl.path().filename().string() << "\n" + << "v 0 0 0\nv 10 0 0\nv 0 10 0\nv 0 0 10\n" + << vts + << "usemtl a\n" + << first_face << "\n" + << "f 1/1 2/1 4/1\nf 1/1 4/1 3/1\nf 2/1 3/1 4/1\n"; + } + LoadedObj loaded; + std::string message; + loaded.ok = load_obj(obj.string().c_str(), &loaded.mesh, loaded.info, message); + return loaded; +} + +} // namespace + +TEST_CASE("An out-of-range texture index falls back to a zero UV and keeps the geometry", "[OBJ][Regression]") +{ + const LoadedObj loaded = load_textured_tetrahedron("f 1/1000000000 3/1 2/1"); + + REQUIRE(loaded.ok); + CHECK(loaded.mesh.facets_count() == 4); + REQUIRE(loaded.info.uvs.size() == 4); + const std::array &uv = loaded.info.uvs.front(); + CHECK_THAT(uv[0].x(), WithinAbs(0., 1e-6)); + CHECK_THAT(uv[0].y(), WithinAbs(0., 1e-6)); + CHECK_THAT(uv[1].x(), WithinAbs(0.25, 1e-6)); + CHECK_THAT(uv[1].y(), WithinAbs(0.5, 1e-6)); +} + +TEST_CASE("A face without texture indices loads among faces that have them", "[OBJ][Regression]") +{ + const LoadedObj loaded = load_textured_tetrahedron("f 1 3 2"); + + REQUIRE(loaded.ok); + CHECK(loaded.mesh.facets_count() == 4); + // One UV entry per face, so later faces keep their own coordinates. + REQUIRE(loaded.info.uvs.size() == 4); + for (const Vec2f &uv : loaded.info.uvs.front()) { + CHECK_THAT(uv.x(), WithinAbs(0., 1e-6)); + CHECK_THAT(uv.y(), WithinAbs(0., 1e-6)); + } + CHECK_THAT(loaded.info.uvs[1][0].x(), WithinAbs(0.25, 1e-6)); + CHECK_THAT(loaded.info.uvs[1][0].y(), WithinAbs(0.5, 1e-6)); +} + +TEST_CASE("A negative texture index counts back from the last texture coordinate", "[OBJ]") +{ + // -1 is the most recent vt (0.75, 1), -2 the one before it (0.25, 0.5). + const LoadedObj loaded = load_textured_tetrahedron("f 1/-2 3/-1 2/-1"); + + REQUIRE(loaded.ok); + CHECK(loaded.mesh.facets_count() == 4); + REQUIRE(loaded.info.uvs.size() == 4); + const std::array &uv = loaded.info.uvs.front(); + CHECK_THAT(uv[0].x(), WithinAbs(0.25, 1e-6)); + CHECK_THAT(uv[0].y(), WithinAbs(0.5, 1e-6)); + CHECK_THAT(uv[1].x(), WithinAbs(0.75, 1e-6)); + CHECK_THAT(uv[1].y(), WithinAbs(1., 1e-6)); +} + +TEST_CASE("Texture coordinates with a w component are kept", "[OBJ][Regression]") +{ + const LoadedObj loaded = load_textured_tetrahedron("f 1/1 3/2 2/2", "vt 0.25 0.5 0\nvt 0.75 1 0\n"); + + REQUIRE(loaded.ok); + CHECK(loaded.mesh.facets_count() == 4); + REQUIRE(loaded.info.uvs.size() == 4); + const std::array &uv = loaded.info.uvs.front(); + CHECK_THAT(uv[0].x(), WithinAbs(0.25, 1e-6)); + CHECK_THAT(uv[0].y(), WithinAbs(0.5, 1e-6)); + CHECK_THAT(uv[1].x(), WithinAbs(0.75, 1e-6)); + CHECK_THAT(uv[1].y(), WithinAbs(1., 1e-6)); +} + +TEST_CASE("A texture coordinate with w does not shift the indices of the ones after it", "[OBJ][Regression]") +{ + // The w on the first vt used to drop that line, so vt 2 resolved to the third coordinate. + const LoadedObj loaded = load_textured_tetrahedron("f 1/2 3/3 2/-1", "vt 0.1 0.2 0\nvt 0.25 0.5\nvt 0.75 1\n"); + + REQUIRE(loaded.ok); + REQUIRE(loaded.info.uvs.size() == 4); + const std::array &uv = loaded.info.uvs.front(); + CHECK_THAT(uv[0].x(), WithinAbs(0.25, 1e-6)); + CHECK_THAT(uv[0].y(), WithinAbs(0.5, 1e-6)); + CHECK_THAT(uv[1].x(), WithinAbs(0.75, 1e-6)); + CHECK_THAT(uv[1].y(), WithinAbs(1., 1e-6)); + CHECK_THAT(uv[2].x(), WithinAbs(0.75, 1e-6)); + CHECK_THAT(uv[2].y(), WithinAbs(1., 1e-6)); +} From 46fb5126903578e2b32f1a3caa6cb848370a496a Mon Sep 17 00:00:00 2001 From: SoftFever <103989404+SoftFever@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:49:54 +0800 Subject: [PATCH 7/7] enable python unit test (#15593) * enable python unit test * fix Windows * Require numpy for the plugin tests in CI --- .github/workflows/unit_tests.yml | 8 ++++ .gitignore | 4 +- scripts/run_unit_tests.sh | 49 ++++++++++++++++++++++++- src/slic3r/plugin/PythonInterpreter.cpp | 15 +++++--- tests/slic3rutils/CMakeLists.txt | 27 ++++++++++++-- 5 files changed, 92 insertions(+), 11 deletions(-) diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index f5850d9fd7..41ef0b1f4b 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -44,6 +44,14 @@ jobs: uses: actions/download-artifact@v8 with: name: ${{ inputs.artifact }} + # run_unit_tests.sh installs the plugin tests' numpy with the uv the build stages + # beside them; the Windows arm64 build bundles none, so put one on PATH there. + - name: Install uv + if: runner.os == 'Windows' && runner.arch == 'ARM64' + uses: astral-sh/setup-uv@v10.2.0 + with: + version: "0.11.21" # ORCA_UV_VERSION in CMakeLists.txt + enable-cache: false - uses: lukka/get-cmake@latest with: cmakeVersion: "~4.3.0" # use most recent 4.3.x version diff --git a/.gitignore b/.gitignore index b029afa180..5224ecad6b 100644 --- a/.gitignore +++ b/.gitignore @@ -52,4 +52,6 @@ internal_docs/ __pycache__/ *.pyc *.opc -docs/superpowers/ \ No newline at end of file +/.test/ +docs/superpowers/ +ctest_results.xml diff --git a/scripts/run_unit_tests.sh b/scripts/run_unit_tests.sh index 1d3f0046fd..c4433df04d 100755 --- a/scripts/run_unit_tests.sh +++ b/scripts/run_unit_tests.sh @@ -2,7 +2,8 @@ # This file is made to support the unit tests workflow. # It should only require the directories build/tests, scripts/, and tests/ to function, -# and cmake (with ctest) installed. +# and cmake (with ctest) installed -- plus network access to PyPI whenever numpy has to +# be installed into a freshly built test tree (see below). # (otherwise, update the workflow too, but try to avoid to keep things self-contained) # # Usage: run_unit_tests.sh [TEST_DIR] [BUILD_CONFIG] @@ -18,6 +19,52 @@ cd "${ROOT_DIR}" || exit 1 TEST_DIR="${1:-build/tests}" BUILD_CONFIG="${2:-}" +# The slic3rutils plugin-host tests build numpy arrays through the CPython copied next +# to the test binary (see tests/slic3rutils/CMakeLists.txt), which ships no numpy. +# Install it with the uv staged beside that runtime -- the tool the app installs plugin +# dependencies with -- straight into the interpreter's own site-packages: no pip needed +# in the runtime, no PYTHONPATH. Re-checked every run because a rebuild of the test +# target re-copies the runtime; needs network whenever it installs. Pinned so a numpy +# release cannot change results on its own. +NUMPY_VERSION="2.5.3" + +# Without numpy those tests assert the numpy-absent error path instead, so a local run +# only warns. Under CI it fails the run, which would otherwise stay green while silently +# dropping the array coverage. (The Flatpak leg runs this inside `flatpak build`, whose +# minimal environment has no CI, and its offline build stages no uv, so numpy stays +# best-effort there.) +numpy_unavailable() { + if [ -n "${CI:-}" ]; then + echo "error: $1" >&2 + exit 1 + fi + echo "warning: $1; the numpy-backed binding tests will cover only the numpy-absent path." +} + +has_pinned_numpy() { + "${python_exe}" -c "import sys, numpy; sys.exit(numpy.__version__ != '${NUMPY_VERSION}')" >/dev/null 2>&1 +} + +find_args=("${TEST_DIR}" \( -path '*/python/bin/python3' -o -path '*/python/python.exe' \)) +# Multi-config trees hold one copy per configuration; only bootstrap the one being run. +[ -n "${BUILD_CONFIG}" ] && find_args+=(-path "*/${BUILD_CONFIG}/*") +python_exe="$(find "${find_args[@]}" -print -quit 2>/dev/null)" + +if [ -z "${python_exe}" ]; then + numpy_unavailable "no bundled Python under ${TEST_DIR}" +elif ! has_pinned_numpy; then + uv_exe="${python_exe%/python/*}/tools/uv/uv" + # Builds that bundle no uv (Windows arm64) fall back to one on PATH. + [ -x "${uv_exe}" ] || uv_exe="$(command -v uv)" + echo "Installing numpy ${NUMPY_VERSION} into the embedded test interpreter (${python_exe})..." + if [ -z "${uv_exe}" ]; then + numpy_unavailable "no uv staged beside the tests or on PATH" + elif ! "${uv_exe}" pip install --python "${python_exe}" --only-binary :all: "numpy==${NUMPY_VERSION}" \ + || ! has_pinned_numpy; then + numpy_unavailable "could not install numpy ${NUMPY_VERSION} into ${python_exe}" + fi +fi + # Run the whole suite, excluding tests tagged [NotWorking] and tests labelled RequiresApp, # which run the built orca-slicer binary that this directory does not contain. # --no-tests=error fails the job if the filter matches nothing (instead of passing green). diff --git a/src/slic3r/plugin/PythonInterpreter.cpp b/src/slic3r/plugin/PythonInterpreter.cpp index 3d180c2f2a..d82bf52a13 100644 --- a/src/slic3r/plugin/PythonInterpreter.cpp +++ b/src/slic3r/plugin/PythonInterpreter.cpp @@ -345,16 +345,21 @@ boost::filesystem::path find_bundled_python_home() fs::path bundle_python = fs::path(resources_dir()).parent_path() / "MacOS" / "python"; if (valid_python_home(bundle_python)) return bundle_python; -#elif defined(_WIN32) - fs::path exe_python = boost::dll::program_location().parent_path() / "python"; - if (valid_python_home(exe_python)) - return exe_python; -#else +#elif !defined(_WIN32) fs::path linux_python = fs::path(resources_dir()).parent_path() / "lib" / "python"; if (valid_python_home(linux_python)) return linux_python; #endif + // Next to the executable: the Windows install layout, and the runtime copied + // beside every platform's unit-test binary (tests/slic3rutils/CMakeLists.txt). + // The CI test runner only receives the build/tests tree, so the candidates + // below -- all of which point into the deps or install trees -- never resolve + // there. + fs::path exe_python = boost::dll::program_location().parent_path() / "python"; + if (valid_python_home(exe_python)) + return exe_python; + fs::path configured_python = ORCA_BUNDLED_PYTHON_ROOT; if (!configured_python.empty() && valid_python_home(configured_python)) return configured_python; diff --git a/tests/slic3rutils/CMakeLists.txt b/tests/slic3rutils/CMakeLists.txt index 58262eef99..be796e814b 100644 --- a/tests/slic3rutils/CMakeLists.txt +++ b/tests/slic3rutils/CMakeLists.txt @@ -51,9 +51,16 @@ if (WIN32) COMMENT "Copying Python runtime for slic3rutils plugin host API tests" VERBATIM ) -elseif (APPLE) - target_link_options(${_TEST_NAME}_tests PRIVATE - "LINKER:-rpath,@executable_path/python/lib") +elseif (NOT FLATPAK) + # The CI unit-test runner only receives the build/tests tree, so both the + # interpreter and the libpython the test binary links have to travel next to + # the executable; find_bundled_python_home() picks the copy up from there. + if (APPLE) + target_link_options(${_TEST_NAME}_tests PRIVATE + "LINKER:-rpath,@executable_path/python/lib") + else () + set_property(TARGET ${_TEST_NAME}_tests APPEND PROPERTY BUILD_RPATH "$ORIGIN/python/lib") + endif () add_custom_command(TARGET ${_TEST_NAME}_tests POST_BUILD COMMAND ${CMAKE_COMMAND} -E rm -rf @@ -61,7 +68,7 @@ elseif (APPLE) COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_PREFIX_PATH}/libpython" "$/python" - COMMENT "Copying Python runtime for macOS plugin host API tests" + COMMENT "Copying Python runtime for the plugin host API tests" VERBATIM ) elseif (FLATPAK) @@ -78,4 +85,16 @@ elseif (FLATPAK) ) endif() +# scripts/run_unit_tests.sh installs the tests' numpy into that runtime with this uv, +# staged where the app build tree keeps it (/tools/uv, see src/CMakeLists.txt). +if (ORCA_BUNDLED_UV_EXECUTABLE) + add_custom_command(TARGET ${_TEST_NAME}_tests POST_BUILD + COMMAND ${CMAKE_COMMAND} -E make_directory "$/tools/uv" + COMMAND ${CMAKE_COMMAND} -E copy_if_different "${ORCA_BUNDLED_UV_EXECUTABLE}" + "$/tools/uv/${ORCA_BUNDLED_UV_FILENAME}" + COMMENT "Copying uv for the plugin host API tests" + VERBATIM + ) +endif() + orcaslicer_discover_tests(${_TEST_NAME}_tests)