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 e0b081deb2..2a6ed8f0a4 100644 --- a/.github/workflows/check_profiles.yml +++ b/.github/workflows/check_profiles.yml @@ -74,8 +74,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/.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/resources/profiles/Anycubic/machine/Anycubic Kobra X 0.4 nozzle.json b/resources/profiles/Anycubic/machine/Anycubic Kobra X 0.4 nozzle.json index 0d37f718ac..3bbdb12fc6 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": [ 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", 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", 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/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/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/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/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()) { 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/libslic3r/CMakeLists.txt b/tests/libslic3r/CMakeLists.txt index 42facbc65c..c94f96f705 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 @@ -36,6 +37,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 @@ -71,7 +73,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)); +} 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)); + } +} 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)