Compare commits

...
Author SHA1 Message Date
Hanif Koh ec5580667d Address Review of the Reworked Unit Tests
Recompute the v4 recipe comparison once instead of per feature (148 s to 6 s) and compare bounding boxes as well as volumes; drop the incidental NaN loop from the unrepairable Voronoi case; prove the renamed preset is the one selected instead of checking a dirty flag that selecting clears; tell sparse from empty infill in the solid-shell test; plus smaller cleanups.
2026-10-19 18:12:40 +08:00
Hanif Koh 32d92a55d3 Make Unit Tests That Could Never Fail Check Real Behavior
Rework tests whose assertions held whatever the production code did: inverted or self-referential checks, checks on values the test had just set, branches that never ran, symmetric fixtures that hid the behavior under test, font paths that skipped silently, and Voronoi reproducers that only exercised boost. Each now asserts the behavior its name describes.
2026-10-19 17:02:49 +08:00
HanifKoh d3699dd9f6 Keep Python Bytecode out of the Bundled Python Home (#16550)
* Keep Python Bytecode out of the Bundled Python Home

The bundled stdlib ships .pyc files that are stale against their sources:
they are compiled when the Python deps are installed, and the copy into the
app does not keep the sources' mtimes. CPython then recompiles every module
it imports and writes the result next to the source. A drag-installed macOS
app rewrites files inside its own bundle on first launch, so its signature
no longer verifies, and read-only installs (AppImage, Flatpak, Program Files)
recompile the same modules on every launch.

Point pycache_prefix at <data_dir>/python/pycache so the interpreter reads
and writes bytecode there. The bundle is never written, the cache survives
between launches, and the data directory is already a plugin read/write root.

* Drop Cached Bytecode Through the Interpreter in the Module Release Test
2026-10-19 13:48:53 +08:00
HanifKoh a315a96c5c Show "No Available Printers" Instead of an Empty Printer Dropdown (#16611)
* Show "No Available Printers" Instead of an Empty Printer Dropdown

A ComboBox with no items opened a one-row-high popup that DropDown never
paints, leaving a blank strip with stale pixels under the box. Don't open
the list when it is empty, and label the printer box in the print and
send-to-printer dialogs when no printer is available.

* Add "No Available Printers" to the Translation Template
2026-10-19 13:30:36 +08:00
HanifKoh 24cf1c6bcc Fix Crash Selecting a User Printer Whose Parent Preset Is Not Loaded (#16579)
A user preset can inherit a system preset from a vendor that is not
installed. get_preset_base() returns null for it, and
get_selected_preset_base() dereferenced that unconditionally, so picking
the printer crashed while building the shared profiles notification URL.
Fall back to the selected preset itself. The create printer and create
filament dialogs had the same null dereference on such presets.
2026-10-19 13:29:26 +08:00
Kris Austin d16ca97380 Snapmaker U1: set the top cover purifier mode and match Snapmaker's start G-code (#16590)
Port the U1 top cover purifier mode and the related start G-code
changes from Snapmaker's Orca (Snapmaker/OrcaSlicer#504 and
Snapmaker/OrcaSlicer#1005, as of 8931178229).

The start G-code sends SET_PURIFIER_MODE for strong cooling, weak
cooling or keep warm, picked from the lowest softening temperature of
the filaments used through min_vitrification_temperature, with
Snapmaker's thresholds and parameters. It also pre-extrudes the first
tool, and turns the part fan off for probing and back on for nozzle
cleaning.

U1 filament presets use Snapmaker's softening temperatures. U1 presets
that have no U1 version in Snapmaker's Orca use the value Snapmaker
gives that material on the U1.
2026-10-18 21:35:32 -05:00
Kris Austin da4d766d46 Fix Moonraker status stream hanging app exit and missing dead printers (#16604)
The status stream read the websocket synchronously. Beast's
expires_after() only applies to async operations, so when a printer
stopped sending, ws.read() blocked forever, and stop_status_stream()
joined that thread when the app closed. On Windows the stuck process
also froze the next launch through the shared WebView2 browser.

Run the whole stream on one io_context owned by the stream thread, and
have stop_status_stream() post an abort to it. A stop on an open
websocket sends a close frame and waits up to 1 s for the answer.
Keep-alive pings detect a dead printer, and the stream reconnects until
stopped, with the backoff capped at 32 s. When a quiet printer still
answers pings, its last status is sent again. The stream starts under
connect_mutex after a staleness check, and a stop from the stream's own
callback no longer joins its own thread.

The Moonraker tests move into test_moonraker_printer_agent.cpp.
2026-10-18 19:33:58 -05:00
Kris Austin 06665d0558 Post a readable diff of changed custom G-code on profile PRs (#16605)
A profile stores each custom G-code setting as one JSON string, so GitHub
shows a G-code edit as one long changed line and reviewers cannot see
what changed.

Add a gcode-diff command to orca_profile_tool.py. For each changed
profile file it decodes, on both sides, every *_gcode and multiline
string option PrintConfig.cpp defines, the options Tab.cpp edits as
code, and any string value that spans several lines. It prints one
collapsed unified diff per setting as markdown. Files with the same diff
for a setting share one section. Trailing spaces are removed, file names
cannot mention GitHub users, and the output is cut to whole sections to
fit a GitHub comment.

Check profiles runs it between the PR's merge commit and its first
parent and adds the result to its artifact, with a line naming the PR
commit. Post profile check comment posts it under its own marker. It
keeps the comment while the diff is unchanged, edits it in place when
only the commit changed, replaces it when the diff changes, and deletes
it when the PR no longer changes any G-code.

The posting job still runs no PR code. It now posts only when the run
checked the head of the PR the artifact names, which covers the
validation comment too.
2026-10-18 13:33:53 -05:00
Kris Austin 0f59cd5d05 Let Global shortcuts run while typing, except keys that edit text (#16558)
#16505 skipped every Global shortcut while ImGui had the keyboard, so
Ctrl+S, Ctrl+R and the others did nothing while the cursor was in the
text tool's box. Skip only the chords that edit text, and only while a
text field (a wx text entry, a spin control, a web view, or an active
ImGui InputText) has the focus. Skip every Global shortcut while an
ImGui modal is open on the focused canvas.

KeyChord::edits_text() defines the text-editing chords. The Global page
of the shortcuts dialog no longer says these shortcuts apply while
typing.
2026-10-18 13:32:41 -05:00
yw4z c1fb801ff3 Linux: Show display backend of app on Troubleshoot dialog (#16615)
* init

* Update TroubleshootDialog.cpp

* fix build
2026-10-18 22:45:30 +08:00
68 changed files with 2375 additions and 455 deletions

No files matched your search

+25
View File
@@ -232,6 +232,31 @@ jobs:
mkdir -p ${{ runner.temp }}/profile-check-results mkdir -p ${{ runner.temp }}/profile-check-results
echo "${{ github.event.pull_request.number }}" > ${{ runner.temp }}/profile-check-results/pr_number.txt echo "${{ github.event.pull_request.number }}" > ${{ runner.temp }}/profile-check-results/pr_number.txt
# Builds a readable diff of the custom G-code the PR changes, which GitHub shows as
# one long line per setting, for check_profiles_comment.yml to post.
- name: Diff custom G-code
if: ${{ always() && github.event_name == 'pull_request' }}
continue-on-error: true
env:
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
git fetch --no-tags --depth=2 origin "$(git rev-parse HEAD)"
results=${{ runner.temp }}/profile-check-results
diff=${{ runner.temp }}/gcode_diff.md
# HEAD is the PR's merge commit, so HEAD^1 is the base it merges onto.
python3 ./scripts/orca_profile_tool.py gcode-diff --base HEAD^1 --head HEAD > "$diff"
if [ -s "$diff" ]; then
{
echo "<!-- profile-gcode-diff-comment -->"
cat "$diff"
echo ""
# The comment workflow finds this line as the only one starting with <sub>.
echo "<sub>For commit ${PR_HEAD_SHA} of this PR.</sub>"
} > "$results/gcode_diff.md"
fi
# Tells the comment workflow that a missing gcode_diff.md means no G-code changed.
touch "$results/gcode_diff_done.txt"
- name: Prepare comment artifact - name: Prepare comment artifact
if: ${{ always() && github.event_name == 'pull_request' && (steps.profile_tool.outcome == 'failure' || steps.legacy_compat.outcome == 'failure' || steps.validate_system.outcome == 'failure' || steps.validate_slice.outcome == 'failure' || steps.validate_filament_subtypes.outcome == 'failure' || steps.validate_custom.outcome == 'failure') }} if: ${{ always() && github.event_name == 'pull_request' && (steps.profile_tool.outcome == 'failure' || steps.legacy_compat.outcome == 'failure' || steps.validate_system.outcome == 'failure' || steps.validate_slice.outcome == 'failure' || steps.validate_filament_subtypes.outcome == 'failure' || steps.validate_custom.outcome == 'failure') }}
run: | run: |
+58 -3
View File
@@ -38,6 +38,7 @@ jobs:
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }} GH_REPO: ${{ github.repository }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
run: | run: |
if [ ! -f pr_number.txt ]; then if [ ! -f pr_number.txt ]; then
echo "No pr_number.txt in artifact, skipping." echo "No pr_number.txt in artifact, skipping."
@@ -49,18 +50,72 @@ jobs:
echo "Invalid PR number: $PR_NUMBER" echo "Invalid PR number: $PR_NUMBER"
exit 1 exit 1
fi fi
# PR code wrote the artifact, so only a run for the named PR's current head may post.
if [ -z "$HEAD_SHA" ]; then
echo "The triggering run names no head commit."
exit 1
fi
if ! PR_HEAD=$(gh api "repos/${GH_REPO}/pulls/${PR_NUMBER}" --jq .head.sha); then
echo "Cannot look up PR #${PR_NUMBER}."
exit 1
fi
if [ "$HEAD_SHA" != "$PR_HEAD" ]; then
echo "${HEAD_SHA} is not the head of PR #${PR_NUMBER}; skipping."
exit 0
fi
# Lists every bot comment on the PR, one JSON object per line.
COMMENTS=$(gh api --paginate "repos/${GH_REPO}/issues/${PR_NUMBER}/comments" \
--jq '.[] | select(.user.login == "github-actions[bot]") | {id, body} | @json')
# Delete prior comments matching the marker (from check_profiles.yml) or the legacy heading. # Delete prior comments matching the marker (from check_profiles.yml) or the legacy heading.
OLD_IDS=$(gh api --paginate "repos/${GH_REPO}/issues/${PR_NUMBER}/comments" \ OLD_IDS=$(printf '%s\n' "$COMMENTS" | jq -r 'select((.body | startswith("<!-- profile-validation-comment -->")) or (.body | startswith("## :x: Profile Validation Errors"))) | .id')
--jq '.[] | select(.user.login == "github-actions[bot]") | select((.body | startswith("<!-- profile-validation-comment -->")) or (.body | startswith("## :x: Profile Validation Errors"))) | .id')
for comment_id in $OLD_IDS; do for comment_id in $OLD_IDS; do
echo "Deleting outdated profile-validation comment ${comment_id}" echo "Deleting outdated profile-validation comment ${comment_id}"
gh api -X DELETE "repos/${GH_REPO}/issues/comments/${comment_id}" || true gh api -X DELETE "repos/${GH_REPO}/issues/comments/${comment_id}" || true
done done
# Post a new comment only when validation failed (pr_comment.md present). # Post a new comment only when validation failed (pr_comment.md present).
# A failed post fails the job only after the G-code diff is handled.
failed=0
if [ -f pr_comment.md ]; then if [ -f pr_comment.md ]; then
gh pr comment "$PR_NUMBER" --body-file pr_comment.md gh pr comment "$PR_NUMBER" --body-file pr_comment.md || failed=1
else else
echo "Validation succeeded; cleaned up prior comments without posting." echo "Validation succeeded; cleaned up prior comments without posting."
fi fi
# Makes the G-code diff comment match gcode_diff.md, or deletes it if there is none.
update_gcode_diff_comment() {
if [ ! -f gcode_diff_done.txt ]; then
echo "The G-code diff step did not finish; leaving any G-code diff comment as it is."
return 0
fi
GCODE_IDS=$(printf '%s\n' "$COMMENTS" | jq -r 'select(.body | startswith("<!-- profile-gcode-diff-comment -->")) | .id')
if [ -f gcode_diff.md ] && [ "$(wc -c < gcode_diff.md)" -gt 65536 ]; then
echo "gcode_diff.md is over GitHub's comment limit; not posting it."
rm gcode_diff.md
fi
if [ -f gcode_diff.md ] && [ "$(echo "$GCODE_IDS" | wc -w)" -eq 1 ]; then
OLD_BODY=$(printf '%s\n' "$COMMENTS" | jq -r --argjson id "$GCODE_IDS" 'select(.id == $id) | .body' | tr -d '\r')
NEW_BODY=$(tr -d '\r' < gcode_diff.md)
if [ "$OLD_BODY" = "$NEW_BODY" ]; then
echo "G-code diff unchanged; keeping comment ${GCODE_IDS}."
return 0
fi
# Edits in place when only the <sub> line differs, since edits notify nobody.
if [ "$(grep -v '^<sub>' <<< "$OLD_BODY")" = "$(grep -v '^<sub>' <<< "$NEW_BODY")" ]; then
echo "G-code diff unchanged at a new commit; updating comment ${GCODE_IDS}."
gh api -X PATCH "repos/${GH_REPO}/issues/comments/${GCODE_IDS}" -F body=@gcode_diff.md > /dev/null
return 0
fi
fi
for comment_id in $GCODE_IDS; do
echo "Deleting outdated G-code diff comment ${comment_id}"
gh api -X DELETE "repos/${GH_REPO}/issues/comments/${comment_id}" || true
done
if [ -f gcode_diff.md ]; then
gh pr comment "$PR_NUMBER" --body-file gcode_diff.md
fi
}
update_gcode_diff_comment
exit "$failed"
+7
View File
@@ -53,6 +53,13 @@ Space counts as typing. The speed dial's default is the one bare Space, and
`MainFrame` leaves it to a focused control that uses Space itself (text fields, buttons, `MainFrame` leaves it to a focused control that uses Space itself (text fields, buttons,
combo boxes), so it opens the dial from the canvases and the tab strip only. combo boxes), so it opens the dial from the canvases and the tab strip only.
`MainFrame::handle_global_shortcut` skips a chord that `KeyChord::edits_text()` reports
while a text field has the focus (a wx text entry, spin control, web view or ImGui
InputText), and skips every chord while an ImGui modal is open, as a wx modal dialog
would. `ImGuiWrapper::owns_key()` reads the active InputText and the popup stack
directly, since `WantCaptureKeyboard` lags a frame, and is called only when the focus is on
a visible canvas from `OpenGLManager::create_wxglcanvas()`.
## Which event a chord matches ## Which event a chord matches
Letters, digits and special keys match on `wxEVT_KEY_DOWN`. Its key codes do not depend Letters, digits and special keys match on `wxEVT_KEY_DOWN`. Its key codes do not depend
+3
View File
@@ -10657,6 +10657,9 @@ msgstr ""
msgid "Send Failed" msgid "Send Failed"
msgstr "" msgstr ""
msgid "No available printers"
msgstr ""
msgid "The name length exceeds the limit." msgid "The name length exceeds the limit."
msgstr "" msgstr ""
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "Snapmaker", "name": "Snapmaker",
"version": "02.04.00.17", "version": "02.04.00.18",
"force_update": "0", "force_update": "0",
"description": "Snapmaker configurations", "description": "Snapmaker configurations",
"machine_model_list": [ "machine_model_list": [
@@ -49,5 +49,8 @@
], ],
"pressure_advance": [ "pressure_advance": [
"0.02" "0.02"
],
"temperature_vitrification": [
"100"
] ]
} }
@@ -46,5 +46,8 @@
], ],
"fan_min_speed": [ "fan_min_speed": [
"0" "0"
],
"temperature_vitrification": [
"100"
] ]
} }
@@ -45,7 +45,7 @@
"250" "250"
], ],
"temperature_vitrification": [ "temperature_vitrification": [
"178" "70"
], ],
"fan_max_speed": [ "fan_max_speed": [
"20" "20"
@@ -4,5 +4,8 @@
"inherits": "fdm_filament_petg", "inherits": "fdm_filament_petg",
"from": "system", "from": "system",
"filament_id": "OFW8lqnb", "filament_id": "OFW8lqnb",
"instantiation": "false" "instantiation": "false",
"temperature_vitrification": [
"70"
]
} }
@@ -60,7 +60,7 @@
"250" "250"
], ],
"temperature_vitrification": [ "temperature_vitrification": [
"178" "70"
], ],
"fan_max_speed": [ "fan_max_speed": [
"20" "20"
@@ -34,5 +34,8 @@
], ],
"nozzle_temperature": [ "nozzle_temperature": [
"220" "220"
],
"temperature_vitrification": [
"45"
] ]
} }
@@ -34,5 +34,8 @@
], ],
"filament_cooling_final_speed": [ "filament_cooling_final_speed": [
"60" "60"
],
"temperature_vitrification": [
"45"
] ]
} }
@@ -34,5 +34,8 @@
], ],
"nozzle_temperature": [ "nozzle_temperature": [
"220" "220"
],
"temperature_vitrification": [
"45"
] ]
} }
@@ -40,5 +40,8 @@
], ],
"nozzle_temperature": [ "nozzle_temperature": [
"220" "220"
],
"temperature_vitrification": [
"45"
] ]
} }
@@ -31,5 +31,8 @@
], ],
"slow_down_layer_time": [ "slow_down_layer_time": [
"8" "8"
],
"temperature_vitrification": [
"45"
] ]
} }
@@ -50,5 +50,8 @@
"additional_cooling_fan_speed": [ "additional_cooling_fan_speed": [
"70", "70",
"70" "70"
],
"temperature_vitrification": [
"30"
] ]
} }
@@ -137,7 +137,7 @@
"240" "240"
], ],
"temperature_vitrification": [ "temperature_vitrification": [
"60" "30"
], ],
"nozzle_temperature_range_low": [ "nozzle_temperature_range_low": [
"200" "200"
@@ -302,7 +302,7 @@
"-1" "-1"
], ],
"temperature_vitrification": [ "temperature_vitrification": [
"60" "30"
], ],
"textured_cool_plate_temp": [ "textured_cool_plate_temp": [
"40" "40"
@@ -29,5 +29,8 @@
"additional_cooling_fan_speed": [ "additional_cooling_fan_speed": [
"70", "70",
"70" "70"
],
"temperature_vitrification": [
"30"
] ]
} }
@@ -49,5 +49,8 @@
"additional_cooling_fan_speed": [ "additional_cooling_fan_speed": [
"70", "70",
"70" "70"
],
"temperature_vitrification": [
"30"
] ]
} }
@@ -97,7 +97,7 @@
"20", "20",
"12" "12"
], ],
"machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0\n{endif} \n\n\n;===== 画起始线 =================\nG90\nG1 Z1.5\nG0 X85 Y1 Z2 F18000\nM109 S{nozzle_temperature_initial_layer[initial_extruder]}\nG1 Z0.2\nM83\nG1 X185 E15 F360\nG1 Z1.5\n\nG90\nM106 S0", "machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\n;===== top cover purifier =====\n{if min_vitrification_temperature <= 50}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=42 ALARM_TEMP=48 FAN_SPEED=0.6 DELAY_OFF=0 ; strong cooling\n{elsif min_vitrification_temperature <= 70}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=45 ALARM_TEMP=51 FAN_SPEED=0.6 DELAY_OFF=0 ; weak cooling\n{else}\nSET_PURIFIER_MODE MODE=3 DESIRE_TEMP=45 FAN_SPEED=0.6 DELAY_OFF=600 ; keep warm\n{endif}\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\n\n;==== 预挤出耗材 =====\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nT{initial_extruder}\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nSM_PRINT_PREEXTRUDE_FILAMENT INDEX={initial_extruder}\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nM107\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nM107\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nM106 S255\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\nM107\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\nM107\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; AlwLine truncated
"machine_tool_change_time": "5", "machine_tool_change_time": "5",
"max_layer_height": [ "max_layer_height": [
"0.14", "0.14",
@@ -97,7 +97,7 @@
"20", "20",
"12" "12"
], ],
"machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0\n{endif} \n\n\n;===== 画起始线 =================\nG90\nG1 Z1.5\nG0 X85 Y1 Z2 F18000\nM109 S{nozzle_temperature_initial_layer[initial_extruder]}\nG1 Z0.2\nM83\nG1 X185 E15 F360\nG1 Z1.5\n\nG90\nM106 S0", "machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\n;===== top cover purifier =====\n{if min_vitrification_temperature <= 50}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=42 ALARM_TEMP=48 FAN_SPEED=0.6 DELAY_OFF=0 ; strong cooling\n{elsif min_vitrification_temperature <= 70}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=45 ALARM_TEMP=51 FAN_SPEED=0.6 DELAY_OFF=0 ; weak cooling\n{else}\nSET_PURIFIER_MODE MODE=3 DESIRE_TEMP=45 FAN_SPEED=0.6 DELAY_OFF=600 ; keep warm\n{endif}\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\n\n;==== 预挤出耗材 =====\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nT{initial_extruder}\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nSM_PRINT_PREEXTRUDE_FILAMENT INDEX={initial_extruder}\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nM107\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nM107\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nM106 S255\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\nM107\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\nM107\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; AlwLine truncated
"machine_tool_change_time": "5", "machine_tool_change_time": "5",
"max_layer_height": [ "max_layer_height": [
"0.32", "0.32",
@@ -97,7 +97,7 @@
"20", "20",
"12" "12"
], ],
"machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0\n{endif} \n\n\n;===== 画起始线 =================\nG90\nG1 Z1.5\nG0 X85 Y1 Z2 F18000\nM109 S{nozzle_temperature_initial_layer[initial_extruder]}\nG1 Z0.2\nM83\nG1 X185 E15 F360\nG1 Z1.5\n\nG90\nM106 S0", "machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\n;===== top cover purifier =====\n{if min_vitrification_temperature <= 50}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=42 ALARM_TEMP=48 FAN_SPEED=0.6 DELAY_OFF=0 ; strong cooling\n{elsif min_vitrification_temperature <= 70}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=45 ALARM_TEMP=51 FAN_SPEED=0.6 DELAY_OFF=0 ; weak cooling\n{else}\nSET_PURIFIER_MODE MODE=3 DESIRE_TEMP=45 FAN_SPEED=0.6 DELAY_OFF=600 ; keep warm\n{endif}\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\n\n;==== 预挤出耗材 =====\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nT{initial_extruder}\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nSM_PRINT_PREEXTRUDE_FILAMENT INDEX={initial_extruder}\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nM107\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nM107\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nM106 S255\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\nM107\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\nM107\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; AlwLine truncated
"machine_tool_change_time": "5", "machine_tool_change_time": "5",
"max_layer_height": [ "max_layer_height": [
"0.42", "0.42",
@@ -97,7 +97,7 @@
"20", "20",
"12" "12"
], ],
"machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0\n{endif} \n\n\n;===== 画起始线 =================\nG90\nG1 Z1.5\nG0 X85 Y1 Z2 F18000\nM109 S{nozzle_temperature_initial_layer[initial_extruder]}\nG1 Z0.2\nM83\nG1 X185 E15 F360\nG1 Z1.5\n\nG90\nM106 S0", "machine_start_gcode": "SET_PRINT_AUTO_BED_LEVELING ENABLE=1\nSET_TIME_LAPSE_CAMERA ENABLE=1\n;===== date: 20260128 =====================\n\nPRINT_START\nDEFECT_DETECTION_START\nSET_PRINT_STATS_INFO TOTAL_LAYER={total_layer_count} CURRENT_LAYER=0\nTIMELAPSE_START\nM140 S{bed_temperature_initial_layer_single}\nM104 T{initial_extruder} S140\nM204 S10000\n;===== top cover purifier =====\n{if min_vitrification_temperature <= 50}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=42 ALARM_TEMP=48 FAN_SPEED=0.6 DELAY_OFF=0 ; strong cooling\n{elsif min_vitrification_temperature <= 70}\nSET_PURIFIER_MODE MODE=1 DESIRE_TEMP=45 ALARM_TEMP=51 FAN_SPEED=0.6 DELAY_OFF=0 ; weak cooling\n{else}\nSET_PURIFIER_MODE MODE=3 DESIRE_TEMP=45 FAN_SPEED=0.6 DELAY_OFF=600 ; keep warm\n{endif}\nG28 X Y\nDEFECT_DETECT_NOODLE_FIRST\n;===== 床面异物检测 ========\nT{initial_extruder}\nG90\nDEFECT_DETECTION_DETECT_BED\n;===== 取放头检测 =================\nSM_PRINT_CHECK_SWITCH_EXTRUDER\n\n;===== 自动进料 & 挤出流量 & 预挤出 ======================\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=1 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=0\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=0\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=2 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=1\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=1\nSM_PRINT_EXTRUDER_PREHEAT EXTRUDER=3 TEMP=140\nSM_PRINT_AUTO_FEED EXTRUDER=2\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=2\nSM_PRINT_AUTO_FEED EXTRUDER=3\nSM_PRINT_FLOW_CALIBRATE EXTRUDER=3\nM104 S0 T0 A0\nM104 S0 T1 A0\nM104 S0 T2 A0\nM104 S0 T3 A0\n\n;==== 预挤出耗材 =====\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nT{initial_extruder}\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder]}\nSM_PRINT_PREEXTRUDE_FILAMENT INDEX={initial_extruder}\nM104 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\n\n;===== 粗回零 =================\nT{initial_extruder}\nM106 S255\nM106 P2 S0\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 T{initial_extruder} S{nozzle_temperature[initial_extruder] - 90}\nROUGHLY_CLEAN_NOZZLE_WITH_DISCARD\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nM107\nG28 Z I140 J140\n\n;===== 检测钢板 =================\nM107\nDETECT_BED_PLATE\n\n;===== 深度清洁喷嘴 =================\nM106 S255\nG90\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nM109 S{nozzle_temperature[initial_extruder] - 50}\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_1\nM104 S{nozzle_temperature[initial_extruder] - 90}\nG0 Z5 F10000\nMOVE_TO_DISCARD_FILAMENT_POSITION\nROUGHLY_CLEAN_NOZZLE\nMOVE_TO_XY_IDLE_POSITION_EXTRUDER\nFINELY_CLEAN_NOZZLE_STAGE_2\n\n;===== 精回零 =================\nM106 S255\nM109 S{nozzle_temperature[initial_extruder] - 90}\nM190 S{bed_temperature_initial_layer_single}\nM107 P2\nG90\nG0 Z5 F10000\nWAIT_CHAMBER_TEMP TIMEOUT=180\nM107\n{if curr_bed_type==\"High Temp Plate\"} \nG28 Z Z_OFFSET -0.07 \n{else} \nG28 Z \n{endif} \n\n\n;===== 热床调平 =================\nM107\n{if curr_bed_type==\"High Temp Plate\"} \n; Always pass `ADAPTIVE_MARGIN=0` because Orca has already handled `adaptive_bed_mesh_margin` internally\n; Make sure to set ADAPTIVE to 0 otherwise Klipper will use it's own adaptive bed mesh logic\nBED_MESH_CALIBRATE mesh_min={adaptive_bed_mesh_min[0]},{adaptive_bed_mesh_min[1]} mesh_max={adaptive_bed_mesh_max[0]},{adaptive_bed_mesh_max[1]} ALGORITHM=[bed_mesh_algo] PROBE_COUNT={bed_mesh_probe_count[0]},{bed_mesh_probe_count[1]} ADAPTIVE=0 ADAPTIVE_MARGIN=0 Z_OFFSET=-0.07\n{else} \n; AlwLine truncated
"machine_tool_change_time": "5", "machine_tool_change_time": "5",
"max_layer_height": [ "max_layer_height": [
"0.56", "0.56",
+251 -4
View File
@@ -12,6 +12,7 @@ commands:
trim delete profile files no <vendor>.json list references trim delete profile files no <vendor>.json list references
update-index regenerate the *_list sections of <vendor>.json update-index regenerate the *_list sections of <vendor>.json
legacy-compat write the printers user presets can be migrated back to legacy-compat write the printers user presets can be migrated back to
gcode-diff print a readable markdown diff of changed custom G-code
options shared by several commands: options shared by several commands:
--vendor VENDOR act on one vendor bundle only; repeatable, empty means all --vendor VENDOR act on one vendor bundle only; repeatable, empty means all
@@ -94,11 +95,14 @@ parent in the OFL map. filament_vendor / filament_type resolve the same way.
""" """
import argparse import argparse
import difflib
import functools import functools
import html
import json import json
import os import os
import posixpath import posixpath
import re import re
import subprocess
import sys import sys
import uuid import uuid
from collections import Counter, defaultdict from collections import Counter, defaultdict
@@ -192,6 +196,7 @@ VECTOR_KEYS = {
PRINT_CONFIG_CPP = os.path.normpath( PRINT_CONFIG_CPP = os.path.normpath(
os.path.join(SCRIPTS_DIR, "..", "src", "libslic3r", "PrintConfig.cpp")) os.path.join(SCRIPTS_DIR, "..", "src", "libslic3r", "PrintConfig.cpp"))
TAB_CPP = os.path.normpath(os.path.join(SCRIPTS_DIR, "..", "src", "slic3r", "GUI", "Tab.cpp"))
@functools.lru_cache(maxsize=None) @functools.lru_cache(maxsize=None)
@@ -313,11 +318,11 @@ REMEDY_HINTS = {
} }
def print_error(msg): def print_error(msg, file=None):
print(f"\033[91m[ERROR]\033[0m {msg}") # Red print(f"\033[91m[ERROR]\033[0m {msg}", file=file) # Red
def print_warning(msg): def print_warning(msg, file=None):
print(f"\033[93m[WARNING]\033[0m {msg}") # Yellow print(f"\033[93m[WARNING]\033[0m {msg}", file=file) # Yellow
def print_info(msg): def print_info(msg):
print(f"\033[94m[INFO]\033[0m {msg}") # Blue print(f"\033[94m[INFO]\033[0m {msg}") # Blue
@@ -3209,6 +3214,223 @@ def run_legacy_compat(base_dir, profiles_dir, output, check):
return 0 return 0
# ---------------------------------------------------------------------------
# gcode-diff
# ---------------------------------------------------------------------------
# UTF-8 bytes, kept well under GitHub's 65,536-character comment limit.
GCODE_DIFF_BUDGET = 60000
# The file names a section's summary shows before "and N more".
GCODE_DIFF_SUMMARY_FILES = 3
GCODE_DIFF_HEADER = (
"## Custom G-code changes\n"
"\n"
"GitHub shows custom G-code in a profile as one long line, so below is a line by line "
"diff of each changed G-code or other text setting.\n")
@functools.lru_cache(maxsize=None)
def _text_option_keys():
"""The string options PrintConfig.cpp defines as G-code or other multi-line text
(every *_gcode option, and every one it marks multiline, such as notes), plus the
ones Tab.cpp edits as code, such as filename_format."""
with open(PRINT_CONFIG_CPP, encoding="utf-8") as f:
source = f.read()
keys = set()
for body in re.split(r"\bdef\s*=\s*this->add(?:_nullable)?\(", source)[1:]:
match = re.match(r'"(\w+)",\s*coStrings?\b', body)
if match and (match.group(1).endswith("_gcode")
or re.search(r"def->multiline\s*=\s*true", body)):
keys.add(match.group(1))
if "machine_start_gcode" not in keys:
raise RuntimeError(f"{PRINT_CONFIG_CPP} no longer defines machine_start_gcode")
# Tab.cpp only adds keys, so a missing file is not an error.
try:
with open(TAB_CPP, encoding="utf-8") as f:
gui = f.read()
except OSError:
gui = ""
keys.update(re.findall(r'get_option\("(\w+)"\);(?:(?!get_option\().)*?\bis_code\s*=\s*true',
gui, re.DOTALL))
return frozenset(keys)
def _git(cwd, *args, stdin=None):
return subprocess.run(["git", "-C", cwd, *args], input=stdin, check=True,
capture_output=True).stdout
def _blobs_at(profiles_dir, specs):
"""{(ref, path): bytes, or None if not a file there} for (ref, path relative to
profiles_dir) pairs, all read by one git cat-file process."""
prefix = _git(profiles_dir, "rev-parse", "--show-prefix").decode("utf-8").strip()
out = _git(profiles_dir, "cat-file", "--batch",
stdin="".join(f"{ref}:{prefix}{rel}\n" for ref, rel in specs).encode("utf-8"))
blobs, pos = {}, 0
for spec in specs:
end = out.index(b"\n", pos)
header = out[pos:end]
pos = end + 1
# "<name> missing" and "<name> ambiguous" have no content; "<oid> <type> <size>" does.
if header.endswith((b" missing", b" ambiguous")):
blobs[spec] = None
continue
_oid, kind, size = header.split()
blobs[spec] = out[pos:pos + int(size)] if kind == b"blob" else None
pos += int(size) + 1
return blobs
def _parse_profile(raw, rel, where):
"""The JSON in raw, or None with a warning when it is absent or not JSON."""
if raw is None:
print_warning(f"{rel}: skipped, not found at {where}", file=sys.stderr)
return None
try:
return json.loads(raw.decode("utf-8-sig"))
except ValueError as e:
print_warning(f"{rel}: skipped, cannot read it at {where}: {e}", file=sys.stderr)
return None
def _changed_profiles(profiles_dir, base, head):
"""(old path, new path) of each JSON file under profiles_dir that differs between
base and head (the working tree, untracked files included, when None); the absent
side of an added or deleted file is None, and so is a side that is not JSON. Renames
are paired."""
out = _git(profiles_dir, "diff", "--name-status", "-z", "-M", "--relative", base,
*([head] if head else []), "--", ".")
fields = out.decode("utf-8").split("\0")
pairs, i = [], 0
while i + 1 < len(fields):
status = fields[i]
old = new = fields[i + 1]
if status[0] in "RC":
new = fields[i + 2]
i += 3
else:
i += 2
old = None if status[0] == "A" or not old.endswith(".json") else old
new = None if status[0] == "D" or not new.endswith(".json") else new
if old or new:
pairs.append((old, new))
if head is None:
out = _git(profiles_dir, "ls-files", "-z", "--others", "--exclude-standard", "--", ".")
pairs += [(None, rel) for rel in out.decode("utf-8").split("\0")
if rel.endswith(".json") and rel.split("/")[0] != USER_DIR]
return pairs
def _text_lines(value):
return [line.rstrip() for line in value.split("\n")] if value else []
def _text_changes(old, new):
"""(setting, unified diff) for every G-code or other text value that differs
between two profiles; a vector option with several values gives one per value."""
changes = []
for key in list(new) + [k for k in old if k not in new]:
a, b = _as_list(old.get(key, [])), _as_list(new.get(key, []))
if a == b or not all(isinstance(v, str) for v in a + b):
continue
if key not in _text_option_keys() and not any("\n" in v for v in a + b):
continue
width = max(len(a), len(b))
for i in range(width):
diff = list(difflib.unified_diff(
_text_lines(a[i] if i < len(a) else ""), _text_lines(b[i] if i < len(b) else ""),
lineterm=""))[2:] # drops the ---/+++ file header
if diff:
changes.append((key if width == 1 else f"{key}[{i}]", "\n".join(diff)))
return changes
def _gcode_diff_section(files, setting, diff):
# A fence longer than any backtick run in the G-code cannot be closed by it.
fence = "`" * max([3] + [len(run) + 1 for run in re.findall(r"`+", diff)])
# In <code>, the "@BBL" of a filament file name does not mention a GitHub user.
def code(text):
return f"<code>{html.escape(text, quote=False)}</code>"
names, listing = ", ".join(map(code, files)), ""
if len(files) > GCODE_DIFF_SUMMARY_FILES + 1: # never "and 1 more"
listing = f"<p>{names}</p>\n\n" # an HTML block, so markdown in names stays literal
names = (", ".join(map(code, files[:GCODE_DIFF_SUMMARY_FILES]))
+ f" and {len(files) - GCODE_DIFF_SUMMARY_FILES} more")
return (f"\n<details>\n<summary><b>{names} - {code(setting)} (click to expand)</b>"
f"</summary>\n\n{listing}{fence}diff\n{diff}\n{fence}\n\n</details>\n")
def gcode_diff(profiles_dir, base, head=None, vendors=None):
"""Markdown with one collapsed diff per changed G-code setting between base and
head, or "" when none changed. Files whose diff of a setting is identical share
one section. Cut to whole sections under GCODE_DIFF_BUDGET."""
pairs = [(old, new) for old, new in _changed_profiles(profiles_dir, base, head)
if not vendors or any(p and p.split("/")[0] in vendors for p in (old, new))]
specs = ([(base, old) for old, _ in pairs if old]
+ [(head, new) for _, new in pairs if new and head])
blobs = _blobs_at(profiles_dir, specs) if specs else {}
def load(ref, rel):
if rel is None:
return {}
if ref:
return _parse_profile(blobs[(ref, rel)], rel, ref)
try:
return _parse_profile(Path(profiles_dir, rel).read_bytes(), rel, "the working tree")
except OSError as e:
print_warning(f"{rel}: skipped, cannot read it: {e}", file=sys.stderr)
return None
groups = {} # (setting, diff) -> files, in the order git lists them
for old_rel, new_rel in pairs:
old, new = load(base, old_rel), load(head, new_rel)
if not isinstance(old, dict) or not isinstance(new, dict):
continue
for setting, diff in _text_changes(old, new):
groups.setdefault((setting, diff), []).append(new_rel or old_rel)
if not groups:
return ""
sections = [_gcode_diff_section(files, setting, diff)
for (setting, diff), files in groups.items()]
budget = GCODE_DIFF_BUDGET - len(GCODE_DIFF_HEADER.encode("utf-8"))
shown = 0
for section in sections:
budget -= len(section.encode("utf-8"))
if budget < 0:
break
shown += 1
note, hidden = "", len(sections) - shown
if hidden:
note = (f"\n{hidden} of {len(sections)} sections {'is' if hidden == 1 else 'are'} not "
"shown because the comment would be too long for GitHub. See the Files "
"changed tab for the rest.\n")
return GCODE_DIFF_HEADER + "".join(sections[:shown]) + note
def _default_gcode_diff_base(profiles_dir, head):
for main in ("upstream/main", "origin/main"):
try:
return _git(profiles_dir, "merge-base", head or "HEAD", main).decode().strip()
except subprocess.CalledProcessError:
continue
raise RuntimeError("no --base given, and neither upstream/main nor origin/main exists")
def run_gcode_diff(profiles_dir, vendors, base, head):
try:
markdown = gcode_diff(profiles_dir, base or _default_gcode_diff_base(profiles_dir, head),
head, vendors)
# UnicodeDecodeError means git listed a file name that is not UTF-8.
except (OSError, RuntimeError, UnicodeDecodeError, subprocess.CalledProcessError) as e:
stderr = getattr(e, "stderr", None)
print_error(stderr.decode(errors="replace").strip() if stderr else str(e), file=sys.stderr)
return 1
sys.stdout.write(markdown)
return 0
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# CLI # CLI
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -3233,6 +3455,9 @@ examples:
orca_profile_tool.py legacy-compat --base /tmp/v2.4.2/resources/profiles orca_profile_tool.py legacy-compat --base /tmp/v2.4.2/resources/profiles
rewrite resources/info/legacy_compatible_printers.json against 2.4.2's rewrite resources/info/legacy_compatible_printers.json against 2.4.2's
profiles (git archive v2.4.2 resources/profiles | tar -x -C /tmp/v2.4.2) profiles (git archive v2.4.2 resources/profiles | tar -x -C /tmp/v2.4.2)
orca_profile_tool.py gcode-diff > gcode.md
the changed custom G-code of the working tree since it left upstream/main,
as markdown with one collapsed diff per setting
after adding, renaming or deleting profile files, run in this order: after adding, renaming or deleting profile files, run in this order:
normalize -> update-index -> generate-id -> check normalize -> update-index -> generate-id -> check
@@ -3387,6 +3612,25 @@ def build_parser():
legacy_cmd.add_argument("--check", action="store_true", legacy_cmd.add_argument("--check", action="store_true",
help="fail if the map differs from what --base implies; write nothing") help="fail if the map differs from what --base implies; write nothing")
gcode_cmd = add(
"gcode-diff", [vendor_opt, profiles_opt],
"print a readable markdown diff of changed custom G-code",
"Print, as markdown, a unified diff of every G-code or other multi-line\n"
"setting that differs between --base and --head in each changed profile\n"
"file, one collapsed section per setting. Files whose diff of a setting is\n"
"identical share a section. Trailing spaces are removed from every line, and\n"
"the output is cut to whole sections to fit a GitHub comment. Prints nothing\n"
"when no such setting changed. CI posts it on profile PRs.\n"
"\n"
"The settings are the *_gcode and multiline string options PrintConfig.cpp\n"
"defines, the options Tab.cpp edits as code (such as filename_format), and any\n"
"string value that spans several lines.")
gcode_cmd.add_argument("--base", metavar="REF",
help="compare against this git ref (default: the merge base "
"with upstream/main, else origin/main)")
gcode_cmd.add_argument("--head", metavar="REF",
help="compare this git ref (default: the working tree)")
return parser return parser
@@ -3423,6 +3667,9 @@ def main(argv=None):
if args.command == "legacy-compat": if args.command == "legacy-compat":
return run_legacy_compat(args.base, profiles_dir, args.output, args.check) return run_legacy_compat(args.base, profiles_dir, args.output, args.check)
if args.command == "gcode-diff":
return run_gcode_diff(profiles_dir, vendors, args.base, args.head)
if args.command == "fix-variant": if args.command == "fix-variant":
_changed, errors = fix_variant_arrays(profiles_dir, vendors, dry_run=args.dry_run, _changed, errors = fix_variant_arrays(profiles_dir, vendors, dry_run=args.dry_run,
strict=args.strict) strict=args.strict)
+309 -3
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Tests for the tree-maintenance half of scripts/orca_profile_tool.py: the """Tests for the tree-maintenance half of scripts/orca_profile_tool.py: the
normalize, trim, update-index, fix-variant and check commands, and the subcommand normalize, trim, update-index, fix-variant, check and gcode-diff commands, and the
dispatch that reaches them (stdlib unittest, no external deps). subcommand dispatch that reaches them (stdlib unittest, no external deps).
The id halves are covered by test_filament_id.py and test_setting_id.py. The id halves are covered by test_filament_id.py and test_setting_id.py.
@@ -14,9 +14,12 @@ import json
import os import os
import re import re
import shutil import shutil
import stat
import subprocess
import sys import sys
import tempfile import tempfile
import unittest import unittest
from unittest import mock
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
@@ -41,7 +44,24 @@ class Tree:
os.makedirs(self.profiles) os.makedirs(self.profiles)
def cleanup(self): def cleanup(self):
shutil.rmtree(self.dir, ignore_errors=True) root = os.path.abspath(self.dir)
def writable_retry(func, path, _exc):
# Windows refuses to delete read-only files such as git's objects, and POSIX
# refuses to delete the entries of a read-only directory.
if not os.path.lexists(path): # already removed, as on a second cleanup
return
parent = os.path.dirname(os.path.abspath(path))
try:
if os.path.commonpath([parent, root]) == root: # never the temp dir itself
os.chmod(parent, stat.S_IRWXU)
if not os.path.islink(path): # chmod would follow it out of the tree
os.chmod(path, stat.S_IRWXU)
func(path)
except (OSError, ValueError): # commonpath raises ValueError across drives
pass
hook = "onexc" if sys.version_info >= (3, 12) else "onerror"
shutil.rmtree(self.dir, **{hook: writable_retry})
def index_path(self, vendor): def index_path(self, vendor):
return os.path.join(self.profiles, vendor + ".json") return os.path.join(self.profiles, vendor + ".json")
@@ -1409,6 +1429,292 @@ class TestDispatch(TreeCase):
self.assertEqual(self.t.read("V", "filament/A.json")["type"], "filament") self.assertEqual(self.t.read("V", "filament/A.json")["type"], "filament")
# ---------------------------------------------------------------------------
# gcode-diff
# ---------------------------------------------------------------------------
@unittest.skipUnless(shutil.which("git"), "git not installed")
class TestGcodeDiff(TreeCase):
START = "G28\nM104 S200\nG1 Z5"
def git(self, *args):
subprocess.run(["git", "-C", self.t.dir, "-c", "user.name=t", "-c", "user.email=t@t",
"-c", "commit.gpgsign=false", *args], check=True, capture_output=True)
def printer(self, name, **extra):
self.t.write("V", f"machine/{name}.json", {"type": "machine", "name": name, **extra})
def commit(self):
self.git("add", "-A")
self.git("commit", "-q", "--allow-empty", "-m", "x")
@classmethod
def setUpClass(cls):
# git's list of the variables that tie a process to a repository, asked with no
# GIT_* set since a stray GIT_DIR fails the query.
clean = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
cls.repo_env_vars = set(subprocess.run(
["git", "rev-parse", "--local-env-vars"], env=clean, check=True,
capture_output=True, text=True).stdout.split())
def setUp(self):
super().setUp()
# A git hook's GIT_DIR or GIT_INDEX_FILE would point every git call here, the
# tool's included, at the real repository instead of the temp tree.
env = {k: v for k, v in os.environ.items() if k not in self.repo_env_vars}
patcher = mock.patch.dict(os.environ, env, clear=True)
patcher.start()
self.addCleanup(patcher.stop)
self.git("init", "-q")
for name in ("A", "B", "C"):
self.printer(name, machine_start_gcode=self.START, nozzle_diameter=["0.4"])
self.printer("Gone", machine_end_gcode="M84")
self.commit()
def diff(self):
rc, out = self.run_command("gcode-diff", "--base", "HEAD")
self.assertEqual(rc, 0, out)
return out
def test_identical_edits_share_one_section(self):
for name in ("A", "B"):
self.printer(name, machine_start_gcode="G28\nM104 S210 \nG1 Z5")
self.printer("C", machine_start_gcode="G28\nM104 S220\nG1 Z5")
out = self.diff()
self.assertEqual(out.count("<details>"), 2)
self.assertIn("<code>V/machine/A.json</code>, <code>V/machine/B.json</code> - "
"<code>machine_start_gcode</code>", out)
self.assertIn("<code>V/machine/C.json</code> - <code>machine_start_gcode</code>", out)
# Trailing spaces are removed.
self.assertIn("\n-M104 S200\n+M104 S210\n", out)
self.assertIn("\n\n```diff\n@@ ", out)
self.assertIn("\n```\n\n</details>", out)
def test_nothing_is_printed_without_a_text_change(self):
self.printer("A", machine_start_gcode=self.START, nozzle_diameter=["0.6"])
self.assertEqual(self.diff(), "")
def test_added_and_deleted_files_show_the_whole_value(self):
# An untracked file counts as added; the validator's user dir is not a profile.
self.printer("New", machine_start_gcode="G28\nG29")
self.t.write("user", "default/machine/U.json", {"machine_start_gcode": "G28"})
os.remove(self.t.path("V", "machine/Gone.json"))
out = self.diff()
self.assertIn("@@ -0,0 +1,2 @@\n+G28\n+G29\n", out)
self.assertIn("<code>V/machine/Gone.json</code> - <code>machine_end_gcode</code>", out)
self.assertIn("@@ -1 +0,0 @@\n-M84\n", out)
self.assertNotIn("U.json", out)
def test_a_multi_line_value_counts_whatever_its_key(self):
self.printer("A", machine_start_gcode=self.START, custom_note="one\ntwo")
self.assertIn("<code>V/machine/A.json</code> - <code>custom_note</code>", self.diff())
def test_a_file_name_cannot_mention_a_user(self):
self.t.write("V", "filament/PLA @BBL A1.json", {"filament_start_gcode": ["M106"]})
self.assertIn("<code>V/filament/PLA @BBL A1.json</code>", self.diff())
def test_head_compares_two_commits(self):
self.printer("A", machine_start_gcode="G28")
self.commit()
rc, out = self.run_command("gcode-diff", "--base", "HEAD~1", "--head", "HEAD")
self.assertEqual(rc, 0, out)
self.assertIn("<code>V/machine/A.json</code> - <code>machine_start_gcode</code>", out)
def test_a_renamed_file_diffs_against_its_old_self(self):
os.rename(self.t.path("V", "machine/A.json"), self.t.path("V", "machine/A2.json"))
self.printer("A2", machine_start_gcode="G28\nM104 S210\nG1 Z5", nozzle_diameter=["0.4"])
self.commit()
rc, out = self.run_command("gcode-diff", "--base", "HEAD~1", "--head", "HEAD")
self.assertEqual(rc, 0, out)
self.assertIn("<code>V/machine/A2.json</code> - <code>machine_start_gcode</code>", out)
self.assertIn("\n G28\n-M104 S200\n+M104 S210\n G1 Z5\n", out)
def test_a_file_renamed_off_json_loses_its_values(self):
os.rename(self.t.path("V", "machine/Gone.json"), self.t.path("V", "machine/Gone.json.bak"))
self.commit()
rc, out = self.run_command("gcode-diff", "--base", "HEAD~1", "--head", "HEAD")
self.assertEqual(rc, 0, out)
self.assertIn("<code>V/machine/Gone.json</code> - <code>machine_end_gcode</code>", out)
self.assertIn("@@ -1 +0,0 @@\n-M84\n", out)
def test_a_file_renamed_onto_json_is_new(self):
with open(self.t.path("V", "machine/Draft.txt"), "w", encoding="utf-8") as f:
json.dump({"type": "machine", "machine_start_gcode": self.START}, f)
self.commit()
os.rename(self.t.path("V", "machine/Draft.txt"), self.t.path("V", "machine/Draft.json"))
self.commit()
rc, out = self.run_command("gcode-diff", "--base", "HEAD~1", "--head", "HEAD")
self.assertEqual(rc, 0, out)
self.assertIn("@@ -0,0 +1,3 @@\n+G28\n+M104 S200\n+G1 Z5\n", out)
def test_a_batch_read_survives_paths_that_are_not_files(self):
blobs = apt._blobs_at(self.t.profiles, [("HEAD", "V/machine"), ("HEAD", "V/no such.json"),
("HEAD", "V/machine/Gone.json")])
self.assertIsNone(blobs[("HEAD", "V/machine")])
self.assertIsNone(blobs[("HEAD", "V/no such.json")])
self.assertEqual(json.loads(blobs[("HEAD", "V/machine/Gone.json")])["machine_end_gcode"],
"M84")
def test_each_value_of_a_vector_option_is_its_own_setting(self):
self.t.write("V", "filament/F.json", {"filament_start_gcode": ["M106", "M107"]})
self.commit()
self.t.write("V", "filament/F.json", {"filament_start_gcode": ["M106", "M106 S0"]})
out = self.diff()
self.assertEqual(out.count("<details>"), 1)
self.assertIn("<code>filament_start_gcode[1]</code>", out)
def test_vendor_limits_the_files(self):
self.printer("A", machine_start_gcode="G28")
self.t.write("W", "machine/X.json", {"machine_start_gcode": "G28"})
rc, out = self.run_command("gcode-diff", "--base", "HEAD", "--vendor", "V")
self.assertEqual(rc, 0, out)
self.assertIn("V/machine/A.json", out)
self.assertNotIn("W/machine/X.json", out)
def test_vendor_includes_files_moved_out_of_it(self):
self.t.add_vendor("W")
os.rename(self.t.path("V", "machine/A.json"), self.t.path("W", "machine/A.json"))
data = self.t.read("W", "machine/A.json")
self.t.write("W", "machine/A.json", {**data, "machine_start_gcode": "G28\nM104 S210\nG1 Z5"})
self.commit()
rc, out = self.run_command("gcode-diff", "--base", "HEAD~1", "--head", "HEAD",
"--vendor", "V")
self.assertEqual(rc, 0, out)
self.assertIn("<code>W/machine/A.json</code> - <code>machine_start_gcode</code>", out)
def same_edit(self, names):
"""The diff after one start G-code edit to each of names; returns (summary, output)."""
for name in names:
self.printer(name, machine_start_gcode=self.START, nozzle_diameter=["0.4"])
self.commit()
for name in names:
self.printer(name, machine_start_gcode="G28")
out = self.diff()
return re.search(r"<summary>(.*)</summary>", out).group(1), out
def test_a_long_file_list_moves_into_the_section(self):
summary, out = self.same_edit("ABCDE")
self.assertIn("<code>V/machine/C.json</code> and 2 more - ", summary)
self.assertNotIn("D.json", summary)
self.assertIn("<code>V/machine/D.json</code>, <code>V/machine/E.json</code></p>\n\n```diff",
out)
def test_four_files_are_all_named_in_the_summary(self):
summary, _out = self.same_edit("ABCD")
self.assertIn("<code>V/machine/D.json</code> - ", summary)
self.assertNotIn("more", summary)
def test_an_error_goes_to_stderr_only(self):
err = io.StringIO()
with contextlib.redirect_stderr(err):
rc, out = self.run_command("gcode-diff", "--base", "no-such-ref")
self.assertEqual(rc, 1)
self.assertEqual(out, "")
self.assertIn("no-such-ref", err.getvalue())
def test_a_file_name_that_is_not_utf8_is_an_error(self):
bad = UnicodeDecodeError("utf-8", b"\xe9.json", 0, 1, "invalid continuation byte")
err = io.StringIO()
with mock.patch.object(apt, "_changed_profiles", side_effect=bad), \
contextlib.redirect_stderr(err):
rc, out = self.run_command("gcode-diff", "--base", "HEAD")
self.assertEqual((rc, out), (1, ""))
self.assertIn("invalid continuation byte", err.getvalue())
def test_output_is_cut_to_whole_sections(self):
for name in ("A", "B", "C"):
self.printer(name, machine_start_gcode=name * 300)
size = len(apt.gcode_diff(self.t.profiles, "HEAD").encode("utf-8"))
with mock.patch.object(apt, "GCODE_DIFF_BUDGET", size - 1):
out = self.diff()
self.assertEqual(out.count("<details>"), 2)
self.assertEqual(out.count("</details>"), 2)
self.assertIn("\n1 of 3 sections is not shown", out)
with mock.patch.object(apt, "GCODE_DIFF_BUDGET", 1):
self.assertIn("\n3 of 3 sections are not shown", self.diff())
def test_cleanup_removes_the_repo(self):
# setUp's commit leaves read-only git objects, and a read-only directory blocks
# deletion on POSIX.
os.chmod(self.t.path("V", "machine"), stat.S_IRUSR | stat.S_IXUSR)
self.t.cleanup()
self.assertFalse(os.path.exists(self.t.dir))
with mock.patch.object(os, "chmod") as chmod:
self.t.cleanup() # a second cleanup, as addCleanup runs, must not touch the temp dir
chmod.assert_not_called()
def test_only_repository_variables_are_stripped(self):
self.assertTrue({"GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"} <= self.repo_env_vars)
self.assertFalse({"GIT_SSH", "GIT_EXEC_PATH", "GIT_EDITOR"} & self.repo_env_vars)
def test_cleanup_leaves_symlink_targets_alone(self):
outside = tempfile.mkdtemp(prefix="profile_tool_outside_")
self.addCleanup(shutil.rmtree, outside, True)
target = os.path.join(outside, "f")
open(target, "w").close()
os.chmod(target, stat.S_IRUSR)
self.addCleanup(os.chmod, target, stat.S_IRWXU) # runs before the rmtree above
machine = self.t.path("V", "machine")
try:
os.symlink(target, os.path.join(machine, "link"))
except (OSError, NotImplementedError):
self.skipTest("cannot create symlinks here")
os.chmod(machine, stat.S_IRUSR | stat.S_IXUSR) # so removing the link fails at first
self.t.cleanup()
self.assertFalse(os.path.exists(self.t.dir))
self.assertEqual(stat.S_IMODE(os.stat(target).st_mode) & stat.S_IWUSR, 0)
def test_a_git_hook_environment_is_ignored(self):
scripts = os.path.join(REPO_ROOT, "scripts")
result = subprocess.run(
[sys.executable, "-m", "unittest",
"tests.test_profile_tool.TestGcodeDiff.test_head_compares_two_commits"],
cwd=scripts, capture_output=True, text=True,
env={**os.environ, "GIT_DIR": os.path.join(self.t.dir, "nowhere")})
self.assertEqual(result.returncode, 0, result.stderr)
def test_the_options_are_read_from_the_engine(self):
keys = apt._text_option_keys()
self.assertTrue({"machine_start_gcode", "filament_start_gcode", "printing_by_object_gcode",
"notes", "post_process"} <= keys)
self.assertNotIn("emit_machine_limits_to_gcode", keys) # a coBool
START_DEF = 'def = this->add("machine_start_gcode", coString);\n'
def keys_from(self, print_config, tab=None):
"""_text_option_keys read from these sources; no tab means no Tab.cpp."""
paths = [os.path.join(self.t.dir, "PrintConfig.cpp"), os.path.join(self.t.dir, "Tab.cpp")]
for path, text in zip(paths, (print_config, tab)):
if text is not None:
with open(path, "w", encoding="utf-8") as f:
f.write(text)
apt._text_option_keys.cache_clear()
self.addCleanup(apt._text_option_keys.cache_clear)
with mock.patch.object(apt, "PRINT_CONFIG_CPP", paths[0]), \
mock.patch.object(apt, "TAB_CPP", paths[1]):
return apt._text_option_keys()
def test_multiline_is_read_whatever_its_spacing(self):
keys = self.keys_from(self.START_DEF + 'def = this->add("printer_notes", coString);\n'
' def->multiline = true;\n')
self.assertIn("printer_notes", keys)
def test_the_code_options_of_tab_cpp_are_read(self):
keys = self.keys_from(self.START_DEF, 'option = optgroup->get_option("filename_format");\n'
'option.opt.is_code = true;\n'
'option = optgroup->get_option("layer_height");\n')
self.assertIn("filename_format", keys)
self.assertNotIn("layer_height", keys)
def test_a_missing_tab_cpp_is_not_an_error(self):
self.assertIn("machine_start_gcode", self.keys_from(self.START_DEF))
def test_markdown_in_a_file_name_stays_literal(self):
section = apt._gcode_diff_section(["V/a *b*.json", "V/c.json", "V/d.json", "V/e.json",
"V/f.json"], "machine_start_gcode", "@@ -1 +1 @@")
self.assertIn("\n<p><code>V/a *b*.json</code>, ", section)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# the real tree # the real tree
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+8 -1
View File
@@ -756,7 +756,14 @@ public:
Preset& get_edited_preset() { return m_edited_preset; } Preset& get_edited_preset() { return m_edited_preset; }
const Preset& get_edited_preset() const { return m_edited_preset; } const Preset& get_edited_preset() const { return m_edited_preset; }
const Preset& get_selected_preset_base() const { return *get_preset_base(m_presets[m_idx_selected]); } // The root of the selected preset's inherits chain, or the selected preset itself when
// the chain names a preset that is not loaded (e.g. a vendor that is not installed).
const Preset& get_selected_preset_base() const
{
const Preset &selected = m_presets[m_idx_selected];
const Preset *base = get_preset_base(selected);
return base ? *base : selected;
}
// Return the last saved preset. // Return the last saved preset.
// const Preset& get_saved_preset() const { return m_saved_preset; } // const Preset& get_saved_preset() const { return m_saved_preset; }
+2 -2
View File
@@ -3151,7 +3151,7 @@ void CreatePrinterPresetDialog::set_current_visible_printer()
m_printer_name_to_preset.clear(); m_printer_name_to_preset.clear();
for (const Preset &printer_preset : printer_presets) { for (const Preset &printer_preset : printer_presets) {
if (!printer_preset.is_visible) continue; if (!printer_preset.is_visible) continue;
if (preset_bundle->printers.get_preset_base(printer_preset)->name != printer_preset.name) continue; if (preset_bundle->printers.get_preset_base(printer_preset) != &printer_preset) continue;
if (auto printer_model = dynamic_cast<ConfigOptionString*>(const_cast<Preset&>(printer_preset).config.option("printer_model", false))) { if (auto printer_model = dynamic_cast<ConfigOptionString*>(const_cast<Preset&>(printer_preset).config.option("printer_model", false))) {
if (m_printer_name_to_preset.find(printer_model->value) == m_printer_name_to_preset.end()) { if (m_printer_name_to_preset.find(printer_model->value) == m_printer_name_to_preset.end()) {
printer_choice.push_back(from_u8(printer_model->value)); printer_choice.push_back(from_u8(printer_model->value));
@@ -4962,7 +4962,7 @@ void CreatePresetForPrinterDialog::get_visible_printer_and_compatible_filament_p
if (filament_preset.is_default || !filament_preset.is_compatible || filament_preset.is_project_embedded) continue; if (filament_preset.is_default || !filament_preset.is_compatible || filament_preset.is_project_embedded) continue;
ConfigOptionStrings *filament_types; ConfigOptionStrings *filament_types;
const Preset * filament_preset_base = m_preset_bundle->filaments.get_preset_base(filament_preset); const Preset * filament_preset_base = m_preset_bundle->filaments.get_preset_base(filament_preset);
if (filament_preset_base == &filament_preset) { if (filament_preset_base == &filament_preset || filament_preset_base == nullptr) {
filament_types = dynamic_cast<ConfigOptionStrings *>(const_cast<Preset *>(&filament_preset)->config.option("filament_type")); filament_types = dynamic_cast<ConfigOptionStrings *>(const_cast<Preset *>(&filament_preset)->config.option("filament_type"));
} else { } else {
filament_types = dynamic_cast<ConfigOptionStrings *>(const_cast<Preset *>(filament_preset_base)->config.option("filament_type")); filament_types = dynamic_cast<ConfigOptionStrings *>(const_cast<Preset *>(filament_preset_base)->config.option("filament_type"));
+22
View File
@@ -62,6 +62,7 @@
#include "Search.hpp" #include "Search.hpp"
#include "BitmapCache.hpp" #include "BitmapCache.hpp"
#include "GUI_App.hpp" #include "GUI_App.hpp"
#include "KeyChord.hpp"
#include <nanosvg/nanosvg.h> #include <nanosvg/nanosvg.h>
#include <nanosvg/nanosvgrast.h> #include <nanosvg/nanosvgrast.h>
@@ -2134,6 +2135,27 @@ bool ImGuiWrapper::want_any_input() const
return io.WantCaptureMouse || io.WantCaptureKeyboard || io.WantTextInput; return io.WantCaptureMouse || io.WantCaptureKeyboard || io.WantTextInput;
} }
bool ImGuiWrapper::text_input_active() const
{
const ImGuiID id = ImGui::GetActiveID();
return id != 0 && ImGui::GetInputTextState(id) != nullptr;
}
bool ImGuiWrapper::modal_popup_open() const
{
// A modal stays on the popup stack while its canvas is hidden, so it counts only if the last finished
// frame drew it, which NewFrame() moves from Active to WasActive.
const ImGuiWindow* modal = ImGui::GetTopMostPopupModal();
if (modal == nullptr)
return false;
return ImGui::GetCurrentContext()->WithinFrameScope ? modal->WasActive : modal->Active;
}
bool ImGuiWrapper::owns_key(const KeyChord& chord) const
{
return modal_popup_open() || (chord.edits_text() && text_input_active());
}
ImFontAtlasCustomRect* ImGuiWrapper::GetTextureCustomRect(const wchar_t& tex_id) ImFontAtlasCustomRect* ImGuiWrapper::GetTextureCustomRect(const wchar_t& tex_id)
{ {
auto item = m_custom_glyph_rects_ids.find(tex_id); auto item = m_custom_glyph_rects_ids.find(tex_id);
+7
View File
@@ -35,6 +35,7 @@ struct ImRect;
namespace Slic3r { namespace Slic3r {
namespace GUI { namespace GUI {
struct KeyChord;
bool get_data_from_svg(const std::string &filename, unsigned int max_size_px, ThumbnailData &thumbnail_data); bool get_data_from_svg(const std::string &filename, unsigned int max_size_px, ThumbnailData &thumbnail_data);
@@ -219,6 +220,12 @@ public:
bool want_keyboard() const; bool want_keyboard() const;
bool want_text_input() const; bool want_text_input() const;
bool want_any_input() const; bool want_any_input() const;
// True while an ImGui InputText is active, without the one-frame lag of want_text_input().
bool text_input_active() const;
// True while an ImGui modal popup is on screen.
bool modal_popup_open() const;
// True for every key while a modal is open, and for text-editing keys while an InputText is active.
bool owns_key(const KeyChord& chord) const;
// Optional inputs are used for set up value inside of an optional, with default value // Optional inputs are used for set up value inside of an optional, with default value
// //
+4 -5
View File
@@ -173,7 +173,7 @@ void KBShortcutsDialog::fill_pages()
using Section = ShortcutSection; using Section = ShortcutSection;
if (wxGetApp().is_editor()) { if (wxGetApp().is_editor()) {
page(_L("Global"), _L("Available anywhere in the window, even while typing in a text field."), ShortcutContext::Global, { page(_L("Global"), _L("Available anywhere in the window."), ShortcutContext::Global, {
fixed(Section::SpeedDial, { alt, "1-9, 0" }, L("Run favorite 1 to 10")), fixed(Section::SpeedDial, { alt, "1-9, 0" }, L("Run favorite 1 to 10")),
fixed(Section::SpeedDial, { ctrl, "B" }, L("Pin or unpin the selected action")), fixed(Section::SpeedDial, { ctrl, "B" }, L("Pin or unpin the selected action")),
// wx cycles notebook pages on Ctrl+Tab, which is Cmd+Tab on macOS and never arrives there. // wx cycles notebook pages on Ctrl+Tab, which is Cmd+Tab on macOS and never arrives there.
@@ -438,17 +438,16 @@ ShortcutCaptureDialog::ShortcutCaptureDialog(wxWindow* parent, Shortcut shortcut
SetBackgroundColour(*wxWHITE); SetBackgroundColour(*wxWHITE);
wxBoxSizer* sizer = new wxBoxSizer(wxVERTICAL); wxBoxSizer* sizer = new wxBoxSizer(wxVERTICAL);
// A Global shortcut also runs while a text field has the focus, so its hint names the keys it can use. // A Global shortcut runs before the focused control gets the key, so its hint names the keys it can use.
const bool global = (shortcut_info(shortcut).contexts & context_bit(ShortcutContext::Global)) != 0; const bool global = (shortcut_info(shortcut).contexts & context_bit(ShortcutContext::Global)) != 0;
const wxString advice = global_key_advice(); const wxString advice = global_key_advice();
const wxString typing = _L("Global shortcuts also apply while typing.");
const wxString rule = _L("A key that types a character cannot be a global shortcut."); const wxString rule = _L("A key that types a character cannot be a global shortcut.");
m_hint = global ? typing + "\n" + advice : _L("Esc cancels, Enter confirms."); m_hint = global ? advice : _L("Esc cancels, Enter confirms.");
m_rejection = rule + "\n" + advice; m_rejection = rule + "\n" + advice;
// Wide enough for each sentence on a line of its own where the translation allows, within limits. // Wide enough for each sentence on a line of its own where the translation allows, within limits.
int width = FromDIP(450); int width = FromDIP(450);
for (const wxString& sentence : { typing, rule, advice }) { for (const wxString& sentence : { rule, advice }) {
int extent = 0; int extent = 0;
GetTextExtent(sentence, &extent, nullptr, nullptr, nullptr, &wxGetApp().normal_font()); GetTextExtent(sentence, &extent, nullptr, nullptr, nullptr, &wxGetApp().normal_font());
width = std::max(width, extent); width = std::max(width, extent);
+34
View File
@@ -187,6 +187,40 @@ bool KeyChord::is_menu_accelerator() const
return valid() && ((modifiers & (wxMOD_CONTROL | wxMOD_ALT | wxMOD_RAW_CONTROL)) != 0 || (!is_printable(key) && key != WXK_SPACE)); return valid() && ((modifiers & (wxMOD_CONTROL | wxMOD_ALT | wxMOD_RAW_CONTROL)) != 0 || (!is_printable(key) && key != WXK_SPACE));
} }
bool KeyChord::edits_text() const
{
if (!valid())
return false;
if (!is_menu_accelerator())
return true;
const int mods = modifiers & ~wxMOD_SHIFT;
switch (key) {
case WXK_LEFT: case WXK_RIGHT: case WXK_UP: case WXK_DOWN:
case WXK_HOME: case WXK_END: case WXK_BACK: case WXK_DELETE:
#ifdef __APPLE__
return mods == wxMOD_NONE || mods == wxMOD_CONTROL || mods == wxMOD_ALT; // Option moves by word
#else
return mods == wxMOD_NONE || mods == wxMOD_CONTROL;
#endif
case WXK_INSERT:
return mods == wxMOD_NONE || mods == wxMOD_CONTROL;
case WXK_RETURN: case WXK_TAB: case WXK_ESCAPE: case WXK_PAGEUP: case WXK_PAGEDOWN:
return mods == wxMOD_NONE;
default:
break;
}
if (!is_printable(key))
return false;
#ifdef __APPLE__
if (mods == wxMOD_ALT || mods == wxMOD_RAW_CONTROL)
return true; // Option types a character, Control runs the Cocoa text key bindings
#else
if (mods == (wxMOD_CONTROL | wxMOD_ALT))
return true; // AltGr types a character
#endif
return mods == wxMOD_CONTROL && (key == 'A' || key == 'C' || key == 'V' || key == 'X' || key == 'Y' || key == 'Z');
}
// Only a chord the desktop acts on while still delivering it to the app belongs here. // Only a chord the desktop acts on while still delivering it to the app belongs here.
bool KeyChord::is_system_shortcut() const bool KeyChord::is_system_shortcut() const
{ {
+3
View File
@@ -35,6 +35,9 @@ struct KeyChord
// True when Ctrl or Alt is held or the key is non-printable, the chords a menu can own without // True when Ctrl or Alt is held or the key is non-printable, the chords a menu can own without
// swallowing typing in text fields. // swallowing typing in text fields.
bool is_menu_accelerator() const; bool is_menu_accelerator() const;
// True for a chord a focused text field acts on: typing a character (AltGr and macOS Option
// characters included), moving the caret, selecting, deleting, or the clipboard and undo chords.
bool edits_text() const;
// True for a chord the desktop acts on although the app receives it, so a binding would // True for a chord the desktop acts on although the app receives it, so a binding would
// take it from the system. // take it from the system.
bool is_system_shortcut() const; bool is_system_shortcut() const;
+28 -16
View File
@@ -50,6 +50,7 @@
#include <wx/panel.h> #include <wx/panel.h>
#include <wx/settings.h> #include <wx/settings.h>
#include <wx/textentry.h> #include <wx/textentry.h>
#include <wx/spinctrl.h>
#include <wx/notebook.h> #include <wx/notebook.h>
#include <wx/listbook.h> #include <wx/listbook.h>
#include <wx/simplebook.h> #include <wx/simplebook.h>
@@ -107,6 +108,8 @@
#include "../Utils/PrintHost.hpp" #include "../Utils/PrintHost.hpp"
#include "GUI_App.hpp" #include "GUI_App.hpp"
#include "ImGuiWrapper.hpp"
#include "OpenGLManager.hpp"
#include "Shortcuts.hpp" #include "Shortcuts.hpp"
#include "UnsavedChangesDialog.hpp" #include "UnsavedChangesDialog.hpp"
#include "PublishSettingsDialog.hpp" #include "PublishSettingsDialog.hpp"
@@ -184,18 +187,31 @@ enum class ERescaleTarget
namespace { namespace {
bool focus_is_text_field(wxWindow* focus)
{
if (dynamic_cast<wxTextEntryBase*>(focus) || dynamic_cast<wxWebView*>(focus))
return true; // a web page can have its own text fields
if (dynamic_cast<wxSpinCtrl*>(focus) || dynamic_cast<wxSpinCtrlDouble*>(focus))
return true; // the native Windows spin control is not a wxTextEntry
return false;
}
// Only a scene canvas draws ImGui, and a hidden one can keep the wx focus after a tab switch.
const ImGuiWrapper* focused_canvas_imgui(wxWindow* focus)
{
if (!OpenGLManager::is_scene_canvas(focus) || !focus->IsShownOnScreen())
return nullptr;
return wxGetApp().imgui();
}
// Space opens the speed dial, but it is the activation key for buttons, checkboxes and other // Space opens the speed dial, but it is the activation key for buttons, checkboxes and other
// controls. CHAR_HOOK runs before the focused child, so only take Space when the focused window has // controls, so only take Space when the focused window has no keyboard-activation meaning of its
// no keyboard-activation meaning of its own. Canvases (GLCanvas3D) and panels are not controls and // own. Canvases (GLCanvas3D) and panels are not controls and fall through to "open"; the Notebook
// fall through to "open"; the Notebook itself does too, so Space still opens the dial on any page. // itself does too, so Space still opens the dial on any page.
bool focus_keeps_space(wxWindow* focus) bool focus_keeps_space(wxWindow* focus)
{ {
if (!focus) if (!focus)
return false; return false;
if (dynamic_cast<wxTextEntryBase*>(focus))
return true; // typing a space into a text field
if (dynamic_cast<wxWebView*>(focus))
return true; // web content scrolls and hosts its own text fields
if (dynamic_cast<::Button*>(focus)) if (dynamic_cast<::Button*>(focus))
return true; // custom button: Space clicks it (it is a wxWindow, not a wxControl) return true; // custom button: Space clicks it (it is a wxWindow, not a wxControl)
if (dynamic_cast<StaticBox*>(focus)) if (dynamic_cast<StaticBox*>(focus))
@@ -816,16 +832,12 @@ DPIFrame(NULL, wxID_ANY, "", wxDefaultPosition, wxDefaultSize, BORDERLESS_FRAME_
bool MainFrame::handle_global_shortcut(const KeyChord& chord) bool MainFrame::handle_global_shortcut(const KeyChord& chord)
{ {
// ImGui draws its text fields (the text tool's box, a sketch value field) on the GL canvas, so // CHAR_HOOK runs before the focused window, so leave text-editing keys to a focused text field and
// while one of them is typed into the focused wx window is that canvas or an in-canvas frame: // the keys ImGui owns to the focused canvas.
// neither is a wxControl, so focus_keeps_space() cannot see it and CHAR_HOOK takes the chord
// here first -- Space opened the Speed Dial, Ctrl+P the Preferences, and the field never saw
// the key. ImGui raises WantCaptureKeyboard while one of its items owns the keyboard, so leave
// the key to it then. A focused wxControl (text field, list, web view) is untouched: those
// keep their own keys, and ImGui's flag can outlive the focus moving to one of them.
wxWindow* const focus = wxWindow::FindFocus(); wxWindow* const focus = wxWindow::FindFocus();
ImGuiWrapper* const imgui = wxGetApp().imgui(); if (chord.edits_text() && focus_is_text_field(focus))
if (focus != nullptr && imgui != nullptr && imgui->want_keyboard() && dynamic_cast<wxControl*>(focus) == nullptr) return false;
if (const ImGuiWrapper* imgui = focused_canvas_imgui(focus); imgui != nullptr && imgui->owns_key(chord))
return false; return false;
const std::optional<Shortcut> shortcut = wxGetApp().shortcuts().lookup(ShortcutContext::Global, chord); const std::optional<Shortcut> shortcut = wxGetApp().shortcuts().lookup(ShortcutContext::Global, chord);
+8 -1
View File
@@ -427,6 +427,8 @@ wxGLContext* OpenGLManager::init_glcontext(wxGLCanvas& canvas, const std::pair<i
return m_context; return m_context;
} }
static const char* const SCENE_CANVAS_NAME = "OrcaSceneCanvas";
wxGLCanvas* OpenGLManager::create_wxglcanvas(wxWindow& parent) wxGLCanvas* OpenGLManager::create_wxglcanvas(wxWindow& parent)
{ {
int antialiasing_samples = 4; int antialiasing_samples = 4;
@@ -470,12 +472,17 @@ wxGLCanvas* OpenGLManager::create_wxglcanvas(wxWindow& parent)
attribList[samples_value_idx] = 0; attribList[samples_value_idx] = 0;
} }
wxGLCanvas* canvas = new wxGLCanvas(&parent, wxID_ANY, attribList, wxDefaultPosition, wxDefaultSize, wxWANTS_CHARS); wxGLCanvas* canvas = new wxGLCanvas(&parent, wxID_ANY, attribList, wxDefaultPosition, wxDefaultSize, wxWANTS_CHARS, SCENE_CANVAS_NAME);
// The GL canvas paints its entire surface, so background erasing is unnecessary. // The GL canvas paints its entire surface, so background erasing is unnecessary.
canvas->SetBackgroundStyle(wxBG_STYLE_PAINT); canvas->SetBackgroundStyle(wxBG_STYLE_PAINT);
return canvas; return canvas;
} }
bool OpenGLManager::is_scene_canvas(const wxWindow* window)
{
return window != nullptr && window->GetName() == SCENE_CANVAS_NAME;
}
void OpenGLManager::detect_multisample(int* attribList) void OpenGLManager::detect_multisample(int* attribList)
{ {
int wxVersion = wxMAJOR_VERSION * 10000 + wxMINOR_VERSION * 100 + wxRELEASE_NUMBER; int wxVersion = wxMAJOR_VERSION * 10000 + wxMINOR_VERSION * 100 + wxRELEASE_NUMBER;
+2
View File
@@ -101,6 +101,8 @@ public:
static bool are_framebuffers_supported() { return (s_framebuffers_type != EFramebufferType::Unknown); } static bool are_framebuffers_supported() { return (s_framebuffers_type != EFramebufferType::Unknown); }
static EFramebufferType get_framebuffers_type() { return s_framebuffers_type; } static EFramebufferType get_framebuffers_type() { return s_framebuffers_type; }
static wxGLCanvas* create_wxglcanvas(wxWindow& parent); static wxGLCanvas* create_wxglcanvas(wxWindow& parent);
// True for a canvas made by create_wxglcanvas(), the kind a GLCanvas3D draws its scene and ImGui into.
static bool is_scene_canvas(const wxWindow* window);
static const GLInfo& get_gl_info() { return s_gl_info; } static const GLInfo& get_gl_info() { return s_gl_info; }
static bool force_power_of_two_textures() { return s_force_power_of_two_textures; } static bool force_power_of_two_textures() { return s_force_power_of_two_textures; }
+1 -1
View File
@@ -4087,7 +4087,7 @@ void SelectMachineDialog::update_user_printer()
{ {
m_printer_last_select = ""; m_printer_last_select = "";
update_select_layout(nullptr); update_select_layout(nullptr);
m_printer_box->GetPrinterComboBox()->SetTextLabel(""); m_printer_box->GetPrinterComboBox()->SetValue(_L("No available printers"));
} }
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << "for send task, current printer id = " << m_printer_last_select << std::endl; BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << "for send task, current printer id = " << m_printer_last_select << std::endl;
+1 -1
View File
@@ -1229,7 +1229,7 @@ void SendToPrinterDialog::update_user_printer()
} }
else { else {
m_printer_last_select = ""; m_printer_last_select = "";
m_comboBox_printer->SetTextLabel(""); m_comboBox_printer->SetValue(_L("No available printers"));
} }
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << "for send task, current printer id = " << m_printer_last_select << std::endl; BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << "for send task, current printer id = " << m_printer_last_select << std::endl;
+23 -1
View File
@@ -74,6 +74,7 @@
#endif #endif
#ifdef __LINUX__ #ifdef __LINUX__
#include "LinuxDisplayBackend.hpp"
#include <wx/textfile.h> #include <wx/textfile.h>
#include <wx/tokenzr.h> #include <wx/tokenzr.h>
#include <cstdlib> #include <cstdlib>
@@ -201,7 +202,11 @@ TroubleshootDialog::TroubleshootDialog()
return std::vector<wxString>{GetOStype()}; return std::vector<wxString>{GetOStype()};
return std::vector<wxString>{ return std::vector<wxString>{
GetOSinfo(), GetOSinfo(),
#ifdef __LINUX__
GetPackageType() + GetAppDisplayBackend(),
#else
GetPackageType(), GetPackageType(),
#endif
GetCPUinfo(), GetCPUinfo(),
GetRAMinfo() + " RAM", GetRAMinfo() + " RAM",
GetGPUinfo(), GetGPUinfo(),
@@ -434,10 +439,15 @@ wxString TroubleshootDialog::GetTimestamp()
wxString TroubleshootDialog::GetSysInfoAll() wxString TroubleshootDialog::GetSysInfoAll()
{ {
wxString package = GetPackageType();
#ifdef __LINUX__
package += GetAppDisplayBackend();
#endif
wxString info; wxString info;
info += "Version : " + wxString(SoftFever_VERSION) + "\n" info += "Version : " + wxString(SoftFever_VERSION) + "\n"
+ "Build : " + wxString(build_commit_label) + "\n" + "Build : " + wxString(build_commit_label) + "\n"
+ "Package : " + GetPackageType() + "\n" + "Package : " + package + "\n"
+ "Platform : " + GetOSinfo() + "\n" + "Platform : " + GetOSinfo() + "\n"
+ "Processor : " + GetCPUinfo() + "\n" + "Processor : " + GetCPUinfo() + "\n"
+ "Memory : " + GetRAMinfo() + "RAM" + "\n" + "Memory : " + GetRAMinfo() + "RAM" + "\n"
@@ -751,6 +761,18 @@ wxString TroubleshootDialog::GetLinuxDisplayServer()
return ""; return "";
} }
wxString TroubleshootDialog::GetAppDisplayBackend()
{
// Display backend actually used by the app (GDK), not the session type
wxString display;
switch (get_linux_display_backend()) {
case LinuxDisplayBackend::X11: display = "X11"; break;
case LinuxDisplayBackend::Wayland: display = "Wayland"; break;
default: break;
}
return display.IsEmpty() ? "" : (" (" + display + ")"); // spacing included here to prevent non centered text
}
#endif #endif
wxString TroubleshootDialog::GetPackageType() wxString TroubleshootDialog::GetPackageType()
+1
View File
@@ -68,6 +68,7 @@ protected:
#elif defined(__LINUX__) #elif defined(__LINUX__)
wxString GetLinuxDistroName(); wxString GetLinuxDistroName();
wxString GetLinuxDisplayServer(); wxString GetLinuxDisplayServer();
wxString GetAppDisplayBackend();
#endif #endif
wxString GetPackageType(); wxString GetPackageType();
wxString GetCPUinfo(); wxString GetCPUinfo();
+3 -3
View File
@@ -287,7 +287,7 @@ void ComboBox::DoDeleteOneItem(unsigned int pos)
void ComboBox::ForceDropdownOpen() void ComboBox::ForceDropdownOpen()
{ {
if (!IsEnabled()) if (!IsEnabled() || items.empty())
return; return;
if (!drop_down) { if (!drop_down) {
@@ -398,7 +398,7 @@ void ComboBox::mouseDown(wxMouseEvent &event)
SetFocus(); SetFocus();
if (drop_down) { if (drop_down) {
drop.Hide(); drop.Hide();
} else if (drop.HasDismissLongTime()) { } else if (!items.empty() && drop.HasDismissLongTime()) { // DropDown paints nothing for an empty list
drop.autoPosition(); drop.autoPosition();
drop_down = true; drop_down = true;
drop.Popup(&drop); drop.Popup(&drop);
@@ -426,7 +426,7 @@ void ComboBox::keyDown(wxKeyEvent& event)
case WXK_SPACE: case WXK_SPACE:
if (drop_down) { if (drop_down) {
drop.DismissAndNotify(); drop.DismissAndNotify();
} else if (drop.HasDismissLongTime()) { } else if (!items.empty() && drop.HasDismissLongTime()) {
drop.autoPosition(); drop.autoPosition();
drop_down = true; drop_down = true;
drop.Popup(); drop.Popup();
+215 -55
View File
@@ -5,6 +5,7 @@
#include "bambu_networking.hpp" #include "bambu_networking.hpp"
#include "libslic3r/Preset.hpp" #include "libslic3r/Preset.hpp"
#include "libslic3r/PresetBundle.hpp" #include "libslic3r/PresetBundle.hpp"
#include "libslic3r/Utils.hpp"
#include "slic3r/GUI/GUI_App.hpp" #include "slic3r/GUI/GUI_App.hpp"
#include "slic3r/GUI/I18N.hpp" #include "slic3r/GUI/I18N.hpp"
#include "slic3r/GUI/DeviceManager.hpp" #include "slic3r/GUI/DeviceManager.hpp"
@@ -19,14 +20,19 @@
#include <boost/algorithm/string/trim.hpp> #include <boost/algorithm/string/trim.hpp>
#include <boost/asio/buffer.hpp> #include <boost/asio/buffer.hpp>
#include <boost/asio/connect.hpp> #include <boost/asio/connect.hpp>
#include <boost/asio/error.hpp>
#include <boost/asio/io_context.hpp> #include <boost/asio/io_context.hpp>
#include <boost/asio/ip/tcp.hpp> #include <boost/asio/ip/tcp.hpp>
#include <boost/asio/post.hpp>
#include <boost/asio/steady_timer.hpp>
#include <boost/beast/core.hpp> #include <boost/beast/core.hpp>
#include <boost/beast/core/tcp_stream.hpp> #include <boost/beast/core/tcp_stream.hpp>
#include <boost/beast/http/field.hpp> #include <boost/beast/http/field.hpp>
#include <boost/beast/core/flat_buffer.hpp> #include <boost/beast/core/flat_buffer.hpp>
#include <boost/beast/core/error.hpp> #include <boost/beast/core/error.hpp>
#include <boost/beast/core/buffers_to_string.hpp> #include <boost/beast/core/buffers_to_string.hpp>
#include <boost/beast/core/stream_traits.hpp>
#include <boost/beast/core/string_type.hpp>
#include <boost/beast/websocket.hpp> #include <boost/beast/websocket.hpp>
#include <boost/beast/websocket/stream.hpp> #include <boost/beast/websocket/stream.hpp>
#include <boost/beast/websocket/stream_base.hpp> #include <boost/beast/websocket/stream_base.hpp>
@@ -37,6 +43,7 @@
#include <boost/filesystem/operations.hpp> #include <boost/filesystem/operations.hpp>
#include <boost/log/trivial.hpp> #include <boost/log/trivial.hpp>
#include <algorithm> #include <algorithm>
#include <atomic>
#include <chrono> #include <chrono>
#include <cstdint> #include <cstdint>
#include <cctype> #include <cctype>
@@ -120,6 +127,31 @@ std::string map_moonraker_state(std::string state)
return "IDLE"; return "IDLE";
} }
// Runs one asynchronous operation to completion, because synchronous Beast calls ignore timeouts and cannot be aborted
template<class Initiate>
beast::error_code run_until_done(net::io_context& ioc, const std::atomic<bool>& stop, Initiate&& initiate)
{
// An abort that runs before the operation starts finds nothing to cancel
if (stop.load()) {
return net::error::operation_aborted;
}
beast::error_code result;
bool done = false;
initiate([&](beast::error_code ec, auto&&...) {
result = ec;
done = true;
});
ioc.restart();
while (!done && ioc.run_one() != 0) {} // run() never returns while the websocket idle timer is armed
return done ? result : beast::error_code{net::error::operation_aborted};
}
uint64_t steady_now_ms()
{
return static_cast<uint64_t>(
std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now().time_since_epoch()).count());
}
} // namespace } // namespace
namespace Slic3r { namespace Slic3r {
@@ -1298,6 +1330,8 @@ bool MoonrakerPrinterAgent::fetch_object_list(const std::string& base_url,
} }
http.timeout_connect(5) http.timeout_connect(5)
.timeout_max(10) .timeout_max(10)
// Gives up once the status stream, the only caller, stops
.on_progress([this](Http::Progress, bool& cancel) { cancel = ws_stop.load(); })
.on_complete([&](std::string body, unsigned status) { .on_complete([&](std::string body, unsigned status) {
if (status == 200) { if (status == 200) {
response_body = body; response_body = body;
@@ -1468,19 +1502,47 @@ void MoonrakerPrinterAgent::start_status_stream(const std::string& dev_id, const
return; return;
} }
std::lock_guard<std::mutex> lock(ws_abort_mutex);
ws_stop.store(false); ws_stop.store(false);
ws_thread = std::thread([this, dev_id, base_url, api_key]() { run_status_stream(dev_id, base_url, api_key); }); ws_thread = std::thread(
[this, dev_id, base_url, api_key, timings = ws_timings]() { run_status_stream(dev_id, base_url, api_key, timings); });
} }
void MoonrakerPrinterAgent::stop_status_stream() void MoonrakerPrinterAgent::stop_status_stream()
{ {
std::thread stopped;
{
std::lock_guard<std::mutex> lock(ws_abort_mutex);
ws_stop.store(true); ws_stop.store(true);
if (ws_thread.joinable()) { if (ws_abort) {
ws_thread.join(); ws_abort();
}
// A callback on the stream thread can stop the stream, which then ends on its own and is joined later, outside connect_mutex
if (ws_thread.get_id() != std::this_thread::get_id()) {
stopped = std::move(ws_thread);
}
}
if (stopped.joinable()) {
stopped.join();
} }
} }
void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string base_url, std::string api_key) void MoonrakerPrinterAgent::join_stopped_status_stream()
{
std::thread stopped;
{
std::lock_guard<std::mutex> lock(ws_abort_mutex);
// A running stream belongs to a newer connection, so only one that is already stopping is joined
if (ws_stop.load() && ws_thread.get_id() != std::this_thread::get_id()) {
stopped = std::move(ws_thread);
}
}
if (stopped.joinable()) {
stopped.join();
}
}
void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string base_url, std::string api_key, StatusStreamTimings timings)
{ {
WsEndpoint endpoint; WsEndpoint endpoint;
if (!parse_ws_endpoint(base_url, endpoint)) { if (!parse_ws_endpoint(base_url, endpoint)) {
@@ -1492,25 +1554,108 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
return; return;
} }
net::io_context ioc;
net::steady_timer backoff{ioc};
std::function<void()> abort_connection; // Set while a connection attempt runs
const auto run = [&](auto&& initiate) { return run_until_done(ioc, ws_stop, initiate); };
// Registered under the lock stop_status_stream() takes, so a stop either finds this abort or has already set ws_stop
{
std::lock_guard<std::mutex> lock(ws_abort_mutex);
if (ws_stop.load()) {
return;
}
ws_abort = [&] {
net::post(ioc, [&] {
backoff.cancel();
if (abort_connection) {
abort_connection();
}
});
};
}
const ScopeGuard clear_abort([this] {
std::lock_guard<std::mutex> lock(ws_abort_mutex);
ws_abort = nullptr;
});
// Reconnection logic // Reconnection logic
ws_reconnect_requested.store(false); // Reset reconnect flag ws_reconnect_requested.store(false); // Reset reconnect flag
int retry_count = 0; int retry_count = 0;
const int max_retries = 10;
const int base_delay_ms = 1000;
while (!ws_stop.load() && retry_count < max_retries) { while (!ws_stop.load()) {
bool connection_lost = false; // Flag to distinguish clean shutdown from unexpected disconnect bool connection_lost = false; // Flag to distinguish clean shutdown from unexpected disconnect
try {
net::io_context ioc;
tcp::resolver resolver{ioc}; tcp::resolver resolver{ioc};
beast::tcp_stream stream{ioc}; websocket::stream<beast::tcp_stream> ws{ioc};
beast::tcp_stream& stream = beast::get_lowest_layer(ws);
net::steady_timer close_deadline{ioc};
bool closing = false;
bool deadline_pending = false;
auto last_message = std::chrono::steady_clock::now();
// Sends a close frame if the websocket is open, and drops the socket when the printer has not answered within timings.close
const auto close_politely = [&](websocket::close_code code) {
if (!ws.is_open() || closing) {
return;
}
closing = true;
deadline_pending = true;
close_deadline.expires_after(timings.close);
close_deadline.async_wait([&](beast::error_code ec) {
deadline_pending = false;
if (!ec) {
beast::error_code ignored;
stream.socket().close(ignored);
}
});
ws.async_close(code, [&](beast::error_code) {
closing = false;
close_deadline.cancel();
});
};
abort_connection = [&] {
// Beast reports a closing websocket as not open, and dropping its socket would cut off the close frame
if (closing) {
return;
}
if (ws.is_open()) {
close_politely(websocket::close_code::going_away);
} else {
beast::error_code ignored;
stream.socket().close(ignored);
}
};
const ScopeGuard clear_connection_abort([&] { abort_connection = nullptr; });
stream.expires_after(std::chrono::seconds(10)); try {
auto const results = resolver.resolve(endpoint.host, endpoint.port); beast::error_code connect_ec = net::error::host_not_found;
stream.connect(results); for (const auto& entry : resolver.resolve(endpoint.host, endpoint.port)) {
beast::error_code ignored;
stream.socket().close(ignored); // A failed attempt leaves the socket open
stream.expires_after(std::chrono::seconds(5));
connect_ec = run([&](auto&& handler) { stream.async_connect(entry.endpoint(), handler); });
if (!connect_ec || ws_stop.load()) {
break;
}
}
if (connect_ec) {
throw beast::system_error{connect_ec};
}
// Clears the connect deadline, which would otherwise close the socket 5 s after connecting
stream.expires_never();
websocket::stream<beast::tcp_stream> ws{std::move(stream)}; websocket::stream_base::timeout timeouts{};
timeouts.handshake_timeout = timings.handshake;
timeouts.idle_timeout = timings.idle;
timeouts.keep_alive_pings = true;
ws.set_option(timeouts);
// Beast pings on a fixed schedule, so the heartbeat also checks how long the printer has sent no message
ws.control_callback([&](websocket::frame_type kind, beast::string_view) {
if (kind == websocket::frame_type::pong && !closing &&
std::chrono::steady_clock::now() - last_message >= timings.heartbeat) {
send_heartbeat_if_due(dev_id, timings.heartbeat);
}
});
ws.set_option(websocket::stream_base::decorator([&](websocket::request_type& req) { ws.set_option(websocket::stream_base::decorator([&](websocket::request_type& req) {
req.set(http::field::user_agent, "OrcaSlicer"); req.set(http::field::user_agent, "OrcaSlicer");
if (!api_key.empty()) { if (!api_key.empty()) {
@@ -1522,7 +1667,9 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
if (!endpoint.port.empty() && endpoint.port != "80") { if (!endpoint.port.empty() && endpoint.port != "80") {
host_header += ":" + endpoint.port; host_header += ":" + endpoint.port;
} }
ws.handshake(host_header, endpoint.target); if (const auto ec = run([&](auto&& handler) { ws.async_handshake(host_header, endpoint.target, handler); })) {
throw beast::system_error{ec};
}
ws.text(true); ws.text(true);
// Send client identification // Send client identification
@@ -1534,7 +1681,10 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
identify["params"]["type"] = "agent"; identify["params"]["type"] = "agent";
identify["params"]["url"] = "https://github.com/SoftFever/OrcaSlicer"; identify["params"]["url"] = "https://github.com/SoftFever/OrcaSlicer";
identify["id"] = 0; identify["id"] = 0;
ws.write(net::buffer(identify.dump())); const std::string identify_payload = identify.dump();
if (const auto ec = run([&](auto&& handler) { ws.async_write(net::buffer(identify_payload), handler); })) {
throw beast::system_error{ec};
}
std::set<std::string> subscribe_objects = {"print_stats", "virtual_sdcard"}; std::set<std::string> subscribe_objects = {"print_stats", "virtual_sdcard"};
std::set<std::string> available_objects; std::set<std::string> available_objects;
@@ -1586,28 +1736,17 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
} }
subscribe["params"]["objects"] = std::move(objects); subscribe["params"]["objects"] = std::move(objects);
subscribe["id"] = 1; subscribe["id"] = 1;
ws.write(net::buffer(subscribe.dump())); const std::string subscribe_payload = subscribe.dump();
if (const auto ec = run([&](auto&& handler) { ws.async_write(net::buffer(subscribe_payload), handler); })) {
throw beast::system_error{ec};
}
// Read loop // Read loop
while (!ws_stop.load()) { while (!ws_stop.load()) {
ws.next_layer().expires_after(std::chrono::seconds(2));
beast::flat_buffer buffer; beast::flat_buffer buffer;
beast::error_code ec; const auto ec = run([&](auto&& handler) { ws.async_read(buffer, handler); });
ws.read(buffer, ec); if (ws_stop.load()) {
if (ec == beast::error::timeout) { break;
const auto now_ms = static_cast<uint64_t>(
std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now().time_since_epoch()).count());
const auto last_ms = ws_last_emit_ms.load();
if (last_ms == 0 || now_ms - last_ms >= 10000) {
nlohmann::json message;
{
std::lock_guard<std::recursive_mutex> lock(payload_mutex);
message = build_print_payload_locked();
}
dispatch_message(dev_id, message.dump());
ws_last_emit_ms.store(now_ms);
}
continue;
} }
if (ec == websocket::error::closed) { if (ec == websocket::error::closed) {
connection_lost = true; connection_lost = true;
@@ -1618,6 +1757,9 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
connection_lost = true; connection_lost = true;
break; break;
} }
last_message = std::chrono::steady_clock::now();
// A message shows the printer is answering, so the next drop starts the backoff from reconnect_delay
retry_count = 0;
handle_ws_message(dev_id, beast::buffers_to_string(buffer.data())); handle_ws_message(dev_id, beast::buffers_to_string(buffer.data()));
// Check if handle_ws_message triggered reconnection request // Check if handle_ws_message triggered reconnection request
if (ws_reconnect_requested.exchange(false)) { if (ws_reconnect_requested.exchange(false)) {
@@ -1625,20 +1767,18 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
break; break;
} }
} }
beast::error_code ec;
ws.close(websocket::close_code::normal, ec);
// Only reset retry count on clean shutdown (not connection_lost)
if (!connection_lost && !ws_stop.load()) {
retry_count = 0;
}
} catch (const std::exception& e) { } catch (const std::exception& e) {
if (!ws_stop.load()) {
BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent: websocket disconnected: " << e.what(); BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent: websocket disconnected: " << e.what();
}
connection_lost = true; connection_lost = true;
} }
close_politely(ws_stop.load() ? websocket::close_code::going_away : websocket::close_code::normal);
ioc.restart();
// Both handlers refer to this connection, so neither may be left queued for the next one
while ((closing || deadline_pending) && ioc.run_one() != 0) {}
// Exit loop on clean shutdown // Exit loop on clean shutdown
if (!connection_lost) { if (!connection_lost) {
break; break;
@@ -1649,17 +1789,13 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
break; break;
} }
// Exponential backoff before reconnection // Exponential backoff before reconnection, capped at 32 times reconnect_delay so a printer that comes back is found again
int delay_ms = base_delay_ms * (1 << std::min(retry_count, 5)); const auto delay = timings.reconnect_delay * (1 << std::min(retry_count, 5));
BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent: Reconnecting in " << delay_ms << "ms (attempt " << (retry_count + 1) << ")"; BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent: Reconnecting in " << delay.count() << "ms (attempt " << (retry_count + 1) << ")";
std::this_thread::sleep_for(std::chrono::milliseconds(delay_ms)); backoff.expires_after(delay);
run([&](auto&& handler) { backoff.async_wait(handler); });
retry_count++; retry_count++;
} }
if (retry_count >= max_retries) {
BOOST_LOG_TRIVIAL(error) << "MoonrakerPrinterAgent: Max reconnection attempts reached";
dispatch_local_connect(ConnectStatusLost, dev_id, "max_retries");
}
} }
void MoonrakerPrinterAgent::handle_ws_message(const std::string& dev_id, const std::string& payload) void MoonrakerPrinterAgent::handle_ws_message(const std::string& dev_id, const std::string& payload)
@@ -1733,8 +1869,7 @@ void MoonrakerPrinterAgent::handle_ws_message(const std::string& dev_id, const s
} }
if (updated) { if (updated) {
const auto now_ms = static_cast<uint64_t>( const auto now_ms = steady_now_ms();
std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now().time_since_epoch()).count());
const auto last_dispatch_ms = ws_last_dispatch_ms.load(); const auto last_dispatch_ms = ws_last_dispatch_ms.load();
// Dispatch if: critical change OR throttle interval elapsed // Dispatch if: critical change OR throttle interval elapsed
@@ -1755,6 +1890,24 @@ void MoonrakerPrinterAgent::handle_ws_message(const std::string& dev_id, const s
} }
} }
void MoonrakerPrinterAgent::send_heartbeat_if_due(const std::string& dev_id, std::chrono::milliseconds interval)
{
const auto now_ms = steady_now_ms();
const auto last_ms = ws_last_emit_ms.load();
// Re-sends only a status the printer has reported at least once
if (last_ms == 0 || now_ms - last_ms < static_cast<uint64_t>(interval.count())) {
return;
}
nlohmann::json message;
{
std::lock_guard<std::recursive_mutex> lock(payload_mutex);
message = build_print_payload_locked();
}
dispatch_message(dev_id, message.dump());
ws_last_emit_ms.store(now_ms);
}
void MoonrakerPrinterAgent::update_status_cache(const nlohmann::json& updates) void MoonrakerPrinterAgent::update_status_cache(const nlohmann::json& updates)
{ {
if (!updates.is_object()) { if (!updates.is_object()) {
@@ -2139,8 +2292,15 @@ void MoonrakerPrinterAgent::perform_connection_async(const std::string& dev_id,
BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent: Initial status query failed: " << error_msg; BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent: Initial status query failed: " << error_msg;
} }
// Start WebSocket status stream join_stopped_status_stream();
// Holds connect_mutex across the staleness check and the start, so a disconnect during the queries above prevents the start
{
std::lock_guard<std::recursive_mutex> lock(connect_mutex);
if (is_stale()) {
return;
}
start_status_stream(dev_id, base_url, api_key); start_status_stream(dev_id, base_url, api_key);
}
#endif #endif
// Success! // Success!
+19 -3
View File
@@ -5,6 +5,7 @@
#include "bambu_networking.hpp" #include "bambu_networking.hpp"
#include <functional> #include <functional>
#include <chrono>
#include <cstdint> #include <cstdint>
#include <atomic> #include <atomic>
#include <memory> #include <memory>
@@ -85,6 +86,16 @@ protected:
bool use_ssl = false; bool use_ssl = false;
} device_info; } device_info;
// Copied when the stream starts, so changes apply to the next start_status_stream()
struct StatusStreamTimings
{
std::chrono::milliseconds handshake{std::chrono::seconds(10)}; // Opening and closing handshakes
std::chrono::milliseconds close{std::chrono::seconds(1)}; // Wait for the close frame's answer before dropping the socket
std::chrono::milliseconds idle{std::chrono::seconds(30)}; // Silence before giving up, with a ping every half of it
std::chrono::milliseconds heartbeat{std::chrono::seconds(10)}; // Silence before the cached status is sent again
std::chrono::milliseconds reconnect_delay{std::chrono::seconds(1)}; // First wait, doubling per consecutive failure up to 32x
} ws_timings;
// Tray data for AMS payload building // Tray data for AMS payload building
struct AmsTrayData { struct AmsTrayData {
int slot_index = 0; // 0-based slot index int slot_index = 0; // 0-based slot index
@@ -102,6 +113,8 @@ protected:
// Methods that derived classes may need to override or access // Methods that derived classes may need to override or access
virtual bool init_device_info(const std::string& dev_id, const std::string& dev_ip, const std::string& username, const std::string& password, bool use_ssl, const std::string& port); virtual bool init_device_info(const std::string& dev_id, const std::string& dev_ip, const std::string& username, const std::string& password, bool use_ssl, const std::string& port);
virtual bool fetch_device_info(const std::string& base_url, const std::string& api_key, MoonrakerDeviceInfo& info, std::string& error) const; virtual bool fetch_device_info(const std::string& base_url, const std::string& api_key, MoonrakerDeviceInfo& info, std::string& error) const;
void start_status_stream(const std::string& dev_id, const std::string& base_url, const std::string& api_key);
void stop_status_stream();
// State access for derived classes // State access for derived classes
mutable std::recursive_mutex state_mutex; mutable std::recursive_mutex state_mutex;
@@ -133,10 +146,11 @@ private:
void dispatch_local_connect(int state, const std::string& dev_id, const std::string& msg); void dispatch_local_connect(int state, const std::string& dev_id, const std::string& msg);
void dispatch_printer_connected(const std::string& dev_id); void dispatch_printer_connected(const std::string& dev_id);
void dispatch_message(const std::string& dev_id, const std::string& payload); void dispatch_message(const std::string& dev_id, const std::string& payload);
void start_status_stream(const std::string& dev_id, const std::string& base_url, const std::string& api_key); // Joins a stream stopped from its own callback, so a new stream can start without waiting on it under connect_mutex
void stop_status_stream(); void join_stopped_status_stream();
void run_status_stream(std::string dev_id, std::string base_url, std::string api_key); void run_status_stream(std::string dev_id, std::string base_url, std::string api_key, StatusStreamTimings timings);
void handle_ws_message(const std::string& dev_id, const std::string& payload); void handle_ws_message(const std::string& dev_id, const std::string& payload);
void send_heartbeat_if_due(const std::string& dev_id, std::chrono::milliseconds interval);
void update_status_cache(const nlohmann::json& updates); void update_status_cache(const nlohmann::json& updates);
nlohmann::json build_print_payload_locked() const; nlohmann::json build_print_payload_locked() const;
@@ -196,6 +210,8 @@ private:
std::atomic<bool> ws_reconnect_requested{false}; // Flag to trigger reconnection std::atomic<bool> ws_reconnect_requested{false}; // Flag to trigger reconnection
std::atomic<uint64_t> ws_last_emit_ms{0}; std::atomic<uint64_t> ws_last_emit_ms{0};
std::thread ws_thread; std::thread ws_thread;
std::mutex ws_abort_mutex; // Guards ws_thread and ws_abort
std::function<void()> ws_abort; // Interrupts whatever the stream thread is waiting on, from any thread
// Throttling configuration for WebSocket updates // Throttling configuration for WebSocket updates
// Critical changes (state transitions) dispatch immediately; telemetry is throttled // Critical changes (state transitions) dispatch immediately; telemetry is throttled
+13
View File
@@ -584,6 +584,19 @@ bool PythonInterpreter::initialize()
} }
BOOST_LOG_TRIVIAL(debug) << "Program name set successfully"; BOOST_LOG_TRIVIAL(debug) << "Program name set successfully";
// Keep bytecode out of the Python home. The shipped .pyc files are stale against their
// sources, so a writable install would rewrite them inside the app (breaking a signed
// macOS bundle's seal) and a read-only one would recompile them on every launch.
const std::string pycache_prefix = data_dir() + "/python/pycache";
#ifdef _WIN32
status = PyConfig_SetString(&config, &config.pycache_prefix, boost::nowide::widen(pycache_prefix).c_str());
#else
status = PyConfig_SetBytesString(&config, &config.pycache_prefix, pycache_prefix.c_str());
#endif
if (PyStatus_Exception(status))
BOOST_LOG_TRIVIAL(warning) << "Failed to set the Python bytecode cache to " << pycache_prefix << ": "
<< (status.err_msg ? status.err_msg : "unknown error");
// Use pybind11's scoped_interpreter which properly initializes // Use pybind11's scoped_interpreter which properly initializes
// pybind11 internals for multi-threaded use — raw Py_InitializeFromConfig // pybind11 internals for multi-threaded use — raw Py_InitializeFromConfig
// does not set up thread state tracking that pybind11 requires. // does not set up thread state tracking that pybind11 requires.
+39 -5
View File
@@ -1,6 +1,7 @@
#include <catch2/catch_all.hpp> #include <catch2/catch_all.hpp>
#include <catch2/catch_test_macros.hpp> #include <catch2/catch_test_macros.hpp>
#include <catch2/catch_message.hpp>
#include "test_helpers.hpp" #include "test_helpers.hpp"
#include <algorithm> #include <algorithm>
@@ -17,15 +18,48 @@
using namespace Slic3r; using namespace Slic3r;
using namespace Slic3r::Test; using namespace Slic3r::Test;
// The fan is held off for the first close_fan_the_first_x_layers layers, so an explicit // The fan stays off while printing the first close_fan_the_first_x_layers layers and runs afterwards.
// fan-off command is emitted.
TEST_CASE("Fan is held off for the initial layers", "[Cooling]") TEST_CASE("Fan is held off for the initial layers", "[Cooling]")
{ {
const int fan_off_layers = 5;
const std::string gcode = slice({ cube(20) }, { const std::string gcode = slice({ cube(20) }, {
{ "cooling", true }, { "close_fan_the_first_x_layers", fan_off_layers },
{ "close_fan_the_first_x_layers", 5 }, { "full_fan_speed_layer", 0 },
{ "fan_min_speed", 50 },
{ "fan_max_speed", 100 },
{ "fan_cooling_layer_time", 1000 }, // every layer is short enough to run the fan
{ "layer_height", 0.2 },
{ "initial_layer_print_height", 0.2 },
}); });
CHECK(gcode.find("M106 S0") != std::string::npos);
// Record, for each layer, whether any extrusion was printed with the part fan running.
std::vector<bool> extruded_with_fan;
int layer = -1;
bool fan_on = false;
std::istringstream input(gcode);
std::string line;
while (std::getline(input, line)) {
if (line.rfind(";LAYER_CHANGE", 0) == 0) {
++layer;
extruded_with_fan.push_back(false);
} else if (line.rfind("M107", 0) == 0) {
fan_on = false;
} else if (line.rfind("M106 ", 0) == 0 && line.find(" P") == std::string::npos) {
const size_t s = line.find(" S");
fan_on = s != std::string::npos && std::stod(line.substr(s + 2)) > 0.;
} else if (layer >= 0 && fan_on && line.rfind("G1 ", 0) == 0 && line.find(" E") != std::string::npos &&
(line.find(" X") != std::string::npos || line.find(" Y") != std::string::npos)) {
extruded_with_fan[layer] = true;
}
}
// A 20 mm cube at 0.2 mm layers has 100 layers.
REQUIRE(extruded_with_fan.size() == 100);
for (int i = 0; i < fan_off_layers; ++i) {
CAPTURE(i);
CHECK_FALSE(extruded_with_fan[i]);
}
CHECK(extruded_with_fan[fan_off_layers]);
} }
// The cooling pass resolves and strips its internal speed placeholders; none leak into // The cooling pass resolves and strips its internal speed placeholders; none leak into
+9 -10
View File
@@ -86,10 +86,9 @@ TEST_CASE("Pattern path length", "[Fill]") {
Slic3r::Polylines paths = test(Slic3r::ExPolygon(test_set)); Slic3r::Polylines paths = test(Slic3r::ExPolygon(test_set));
REQUIRE(paths.size() == 1); // one continuous path REQUIRE(paths.size() == 1); // one continuous path
// TODO: determine what the "Expected length" should be for rectilinear fill of a 100x100 polygon. // Lines are 50 mm apart and the square is inset by half a spacing, so the lines on its edges drop out
// This check only checks that it's above scale(3*100 + 2*50) + scaled_epsilon. // and a single line crosses the middle, ending within half a spacing of the edges.
// ok abs($paths->[0]->length - scale(3*100 + 2*50)) - scaled_epsilon, 'path has expected length'; CHECK_THAT(unscale<double>(paths[0].length()), Catch::Matchers::WithinAbs(100., filler->spacing / 2.));
REQUIRE(std::abs(paths[0].length() - static_cast<double>(scale_(3*100 + 2*50))) - SCALED_EPSILON > 0); // path has expected length
test_set.clear(); test_set.clear();
} }
@@ -148,10 +147,9 @@ TEST_CASE("Pattern path length", "[Fill]") {
Slic3r::Polylines paths = test(Slic3r::ExPolygon(points)); Slic3r::Polylines paths = test(Slic3r::ExPolygon(points));
REQUIRE(paths.size() == 1); // one continuous path REQUIRE(paths.size() == 1); // one continuous path
// TODO: determine what the "Expected length" should be for rectilinear fill of a 100x100 polygon. // A solid fill covers the area with lines one spacing apart, so a missing segment shortens the path.
// This check only checks that it's above scale(3*100 + 2*50) + scaled_epsilon. const double expected_length = unscale<double>(unscale<double>(Slic3r::ExPolygon(points).area())) / filler->spacing;
// ok abs($paths->[0]->length - scale(3*100 + 2*50)) - scaled_epsilon, 'path has expected length'; CHECK_THAT(unscale<double>(paths[0].length()), Catch::Matchers::WithinRel(expected_length, 0.01));
REQUIRE(std::abs(paths[0].length() - static_cast<double>(scale_(3*100 + 2*50))) - SCALED_EPSILON > 0); // path has expected length
} }
SECTION("Rotated Square") { SECTION("Rotated Square") {
@@ -168,8 +166,9 @@ TEST_CASE("Pattern path length", "[Fill]") {
fill_params.density = 1.0; fill_params.density = 1.0;
filler->spacing = flow.spacing(); filler->spacing = flow.spacing();
for (auto angle : { 0.0, 45.0}) { for (auto angle : { 0.0, PI / 4.0 }) {
surface.expolygon.rotate(angle, Point(0,0)); surface.expolygon.rotate(angle, Point(0,0));
filler->bounding_box = get_extents(surface.expolygon.contour);
Polylines paths = filler->fill_surface(&surface, fill_params); Polylines paths = filler->fill_surface(&surface, fill_params);
REQUIRE(paths.size() == 1); REQUIRE(paths.size() == 1);
} }
@@ -222,7 +221,7 @@ TEST_CASE("Pattern path length", "[Fill]") {
}; };
Slic3r::ExPolygon expolygon(points); Slic3r::ExPolygon expolygon(points);
REQUIRE(test_if_solid_surface_filled(expolygon, 0.5, 45.0, 0.99) == true); REQUIRE(test_if_solid_surface_filled(expolygon, 0.5, PI / 4.0, 0.99) == true);
} }
} }
+42 -18
View File
@@ -123,38 +123,58 @@ SCENARIO("Changing the number of solid shell layers does not make all surfaces i
config.set_deserialize_strict({ config.set_deserialize_strict({
{ "top_shell_layers", 2 }, { "top_shell_layers", 2 },
{ "bottom_shell_layers", 1 }, { "bottom_shell_layers", 1 },
{ "layer_height", 0.25 }, // get a known number of layers // Shell counts alone decide which layers are solid.
{ "top_shell_thickness", 0 },
{ "bottom_shell_thickness", 0 },
{ "sparse_infill_density", "20%" },
{ "layer_height", 0.25 }, // 80 layers: 0..79
{ "initial_layer_print_height", 0.25 } { "initial_layer_print_height", 0.25 }
}); });
Slic3r::Print print; Slic3r::Print print;
Slic3r::Model model; Slic3r::Model model;
Slic3r::Test::init_print({cube(20)}, print, model, config); Slic3r::Test::init_print({cube(20)}, print, model, config);
// Precondition: Ensure that the model has 2 solid top layers (79, 78) // True when the layer has fill surfaces and all of them are solid.
// and one solid bottom layer (0). auto is_solid_infill = [&print](size_t obj_id, size_t layer_id) {
auto test_is_solid_infill = [&print](size_t obj_id, size_t layer_id) {
const Layer &layer = *(print.objects().at(obj_id)->get_layer((int)layer_id)); const Layer &layer = *(print.objects().at(obj_id)->get_layer((int)layer_id));
// iterate over all of the regions in the layer bool has_fill = false;
for (const LayerRegion *region : layer.regions()) { for (const LayerRegion *region : layer.regions())
// for each region, iterate over the fill surfaces for (const Surface &surface : region->fill_surfaces.surfaces) {
for (const Surface &surface : region->fill_surfaces.surfaces) if (! surface.is_solid())
CHECK(surface.is_solid()); return false;
has_fill = true;
} }
return has_fill;
};
// True when the layer has a sparse fill surface, so a layer with no fill at all does not count.
auto has_sparse_infill = [&print](size_t obj_id, size_t layer_id) {
const Layer &layer = *(print.objects().at(obj_id)->get_layer(static_cast<int>(layer_id)));
for (const LayerRegion *region : layer.regions())
for (const Surface &surface : region->fill_surfaces.surfaces)
if (! surface.is_solid())
return true;
return false;
}; };
print.process(); print.process();
test_is_solid_infill(0, 0); // should be solid // Precondition: 2 solid top layers (79, 78) and one solid bottom layer (0).
test_is_solid_infill(0, 79); // should be solid REQUIRE(is_solid_infill(0, 0));
test_is_solid_infill(0, 78); // should be solid REQUIRE(is_solid_infill(0, 79));
REQUIRE(is_solid_infill(0, 78));
REQUIRE(has_sparse_infill(0, 77));
WHEN("Model is re-sliced with top_shell_layers == 3") { WHEN("Model is re-sliced with top_shell_layers == 3") {
config.set("top_shell_layers", 3); config.set("top_shell_layers", 3);
print.apply(model, config); print.apply(model, config);
print.process(); print.process();
THEN("Print object does not have 0 solid bottom layers.") { THEN("Print object does not have 0 solid bottom layers.") {
test_is_solid_infill(0, 0); CHECK(is_solid_infill(0, 0));
} }
AND_THEN("Print object has 3 top solid layers") { AND_THEN("Print object has 3 top solid layers") {
test_is_solid_infill(0, 79); CHECK(is_solid_infill(0, 79));
test_is_solid_infill(0, 78); CHECK(is_solid_infill(0, 78));
test_is_solid_infill(0, 77); CHECK(is_solid_infill(0, 77));
CHECK(has_sparse_infill(0, 76));
}
AND_THEN("Middle layers keep sparse infill") {
CHECK(has_sparse_infill(0, 40));
} }
} }
} }
@@ -672,14 +692,18 @@ TEST_CASE("gcode_skip_config_block omits the resolved-settings comment block", "
{ {
const std::string gcode = slice({ cube(20) }, { const std::string gcode = slice({ cube(20) }, {
{ "gcode_skip_config_block", true }, { "gcode_skip_config_block", true },
{ "gcode_comments", true },
}); });
CHECK(gcode.find("; CONFIG_BLOCK_START") == std::string::npos); CHECK(gcode.find("; CONFIG_BLOCK_START") == std::string::npos);
CHECK(gcode.find("; CONFIG_BLOCK_END") == std::string::npos); CHECK(gcode.find("; CONFIG_BLOCK_END") == std::string::npos);
CHECK(gcode.find("; layer_height =") == std::string::npos); CHECK(gcode.find("; layer_height =") == std::string::npos);
CHECK(gcode.find("; fill_density =") == std::string::npos); CHECK(gcode.find("; sparse_infill_density =") == std::string::npos);
CHECK(gcode.find("; HEADER_BLOCK_START") != std::string::npos); CHECK(gcode.find("; HEADER_BLOCK_START") != std::string::npos);
CHECK(gcode.find("; EXECUTABLE_BLOCK_START") != std::string::npos); CHECK(gcode.find("; EXECUTABLE_BLOCK_START") != std::string::npos);
const std::string with_block = slice({ cube(20) }, { { "gcode_skip_config_block", false } });
CHECK(with_block.find("; CONFIG_BLOCK_START") != std::string::npos);
CHECK(with_block.find("; layer_height =") != std::string::npos);
CHECK(with_block.find("; sparse_infill_density =") != std::string::npos);
} }
// Some firmwares only scan the last N lines of the file for "estimated printing time", so it // Some firmwares only scan the last N lines of the file for "estimated printing time", so it
+20 -10
View File
@@ -1,6 +1,8 @@
#include <catch2/catch_all.hpp> #include <catch2/catch_all.hpp>
#include <catch2/catch_test_macros.hpp> #include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include <catch2/matchers/catch_matchers.hpp>
#include "libslic3r/TriangleMesh.hpp" #include "libslic3r/TriangleMesh.hpp"
#include "libslic3r/TriangleMeshSlicer.hpp" #include "libslic3r/TriangleMeshSlicer.hpp"
#include "libslic3r/Point.hpp" #include "libslic3r/Point.hpp"
@@ -162,21 +164,29 @@ SCENARIO("Slice behavior", "[TriangleMesh]") {
const std::vector<Vec3f> vertices {{0,0,0},{0,0,20},{0,5,0},{0,5,20},{50,0,0},{50,0,20},{15,5,0},{35,5,0},{15,20,0},{50,5,0},{35,20,0},{15,5,10},{50,5,20},{35,5,10},{35,20,10},{15,20,10}}; const std::vector<Vec3f> vertices {{0,0,0},{0,0,20},{0,5,0},{0,5,20},{50,0,0},{50,0,20},{15,5,0},{35,5,0},{15,20,0},{50,5,0},{35,20,0},{15,5,10},{50,5,20},{35,5,10},{35,20,10},{15,20,10}};
const std::vector<Vec3i32> facets {{0,1,2},{2,1,3},{1,0,4},{5,1,4},{0,2,4},{4,2,6},{7,6,8},{4,6,7},{9,4,7},{7,8,10},{2,3,6},{11,3,12},{7,12,9},{13,12,7},{6,3,11},{11,12,13},{3,1,5},{12,3,5},{5,4,9},{12,5,9},{13,7,10},{14,13,10},{8,15,10},{10,15,14},{6,11,8},{8,11,15},{15,11,13},{14,15,13}}; const std::vector<Vec3i32> facets {{0,1,2},{2,1,3},{1,0,4},{5,1,4},{0,2,4},{4,2,6},{7,6,8},{4,6,7},{9,4,7},{7,8,10},{2,3,6},{11,3,12},{7,12,9},{13,12,7},{6,3,11},{11,12,13},{3,1,5},{12,3,5},{5,4,9},{12,5,9},{13,7,10},{14,13,10},{8,15,10},{10,15,14},{6,11,8},{8,11,15},{15,11,13},{14,15,13}};
auto cube = make_cube(); TriangleMesh mesh(vertices, facets);
// A 50x5x20 wall with a 20x15x10 block against it, so the block's top is at Z = 10.
const double wall_area = 50. * 5.;
const double wall_block_area = wall_area + 20. * 15.;
const auto area_mm2 = [](const ExPolygons &slice) { return slice.at(0).area() * SCALING_FACTOR * SCALING_FACTOR; };
WHEN(" a top tangent plane is sliced") { WHEN(" a top tangent plane is sliced") {
// At Z = 10 we have a top horizontal surface. std::vector<ExPolygons> slices = mesh.slice({5.0, 10.0});
std::vector<ExPolygons> slices = cube.slice({5.0, 10.0});
THEN( "its area is included") { THEN( "its area is included") {
REQUIRE(slices.at(0).at(0).area() > 0); REQUIRE(slices.at(0).size() == 1);
REQUIRE(slices.at(1).at(0).area() > 0); REQUIRE(slices.at(1).size() == 1);
CHECK_THAT(area_mm2(slices.at(0)), Catch::Matchers::WithinRel(wall_block_area, 1e-6));
CHECK_THAT(area_mm2(slices.at(1)), Catch::Matchers::WithinRel(wall_block_area, 1e-6));
} }
} }
WHEN(" a model that has been transformed is sliced") { WHEN(" a model that has been transformed is sliced") {
cube.mirror_z(); mesh.mirror_z();
std::vector<ExPolygons> slices = cube.slice({-5.0, -10.0}); std::vector<ExPolygons> slices = mesh.slice({-5.0, -10.0});
THEN( "it is sliced properly (mirrored bottom plane area is included)") { THEN( "it is sliced like the original, and the mirrored block's bottom plane is not part of it") {
REQUIRE(slices.at(0).at(0).area() > 0); REQUIRE(slices.at(0).size() == 1);
REQUIRE(slices.at(1).at(0).area() > 0); REQUIRE(slices.at(1).size() == 1);
CHECK_THAT(area_mm2(slices.at(0)), Catch::Matchers::WithinRel(wall_block_area, 1e-6));
// A triangle owns its top edge but not its bottom edge, so a bottom tangent plane is left out.
CHECK_THAT(area_mm2(slices.at(1)), Catch::Matchers::WithinRel(wall_area, 1e-6));
} }
} }
} }
@@ -319,9 +319,18 @@ TEST_CASE("FilamentGroup property checks", "[filament_group][property]") {
// build (config_b/config_c cases evaluate the full per-layer nozzle-aware reorder for every // build (config_b/config_c cases evaluate the full per-layer nozzle-aware reorder for every
// candidate grouping; this is a guard against hangs, not a micro-perf gate). // candidate grouping; this is a guard against hangs, not a micro-perf gate).
REQUIRE(result.elapsed_ms < 40000.0); REQUIRE(result.elapsed_ms < 40000.0);
REQUIRE(result.flush_cost >= 0);
// Every case, feasible or not, must yield a usable grouping: each used filament is
// assigned to an existing nozzle.
auto used_filaments = collect_sorted_used_filaments(tc.context.model_info.layer_filaments); auto used_filaments = collect_sorted_used_filaments(tc.context.model_info.layer_filaments);
const auto &nozzle_list = tc.context.nozzle_info.nozzle_list;
for (unsigned int fil : used_filaments) {
INFO("Filament: " << fil);
REQUIRE(fil < result.filament_map.size());
CHECK(result.filament_map[fil] >= 0);
CHECK(result.filament_map[fil] < static_cast<int>(nozzle_list.size()));
}
if (is_constraint_feasible(tc.context, used_filaments)) { if (is_constraint_feasible(tc.context, used_filaments)) {
if (!result.constraints_ok) { if (!result.constraints_ok) {
for (auto& v : result.violations) for (auto& v : result.violations)
+36 -13
View File
@@ -1033,29 +1033,52 @@ SCENARIO("Non-standard nozzle diameter survives .3mf save on a single-nozzle pri
} }
} }
// A legacy / foreign project (no multi-nozzle metadata) must load crash-safe through the BBS // A project without multi-nozzle metadata must load through the BBS importer and must not
// importer and must not fabricate a filament_volume_map. // fabricate a filament_volume_map.
SCENARIO("Legacy project loads crash-safe via load_bbs_3mf", "[3mf][MultiNozzle]") { SCENARIO("Projects without multi-nozzle metadata load without a per-filament volume map", "[3mf][MultiNozzle]") {
GIVEN("a project without any multi-nozzle metadata") {
std::string path = std::string(TEST_DATA_DIR) + "/test_3mf/Geräte/Büchse.3mf";
Model model;
DynamicPrintConfig config;
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable }; ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable };
PlateDataPtrs plates;
std::vector<Preset*> project_presets; std::vector<Preset*> project_presets;
bool is_bbl_3mf = false, is_orca_3mf = false; bool is_bbl_3mf = false, is_orca_3mf = false;
Semver file_version; Semver file_version;
GIVEN("a legacy project with a bare model and no plate metadata") {
std::string path = std::string(TEST_DATA_DIR) + "/test_3mf/Geräte/Büchse.3mf";
Model model;
DynamicPrintConfig config;
PlateDataPtrs plates;
WHEN("loaded through the BBS importer") { WHEN("loaded through the BBS importer") {
bool loaded = false; bool loaded = false;
REQUIRE_NOTHROW(loaded = load_bbs_3mf(path.c_str(), &config, &ctxt, &model, &plates, REQUIRE_NOTHROW(loaded = load_bbs_3mf(path.c_str(), &config, &ctxt, &model, &plates,
&project_presets, &is_bbl_3mf, &is_orca_3mf, &project_presets, &is_bbl_3mf, &is_orca_3mf,
&file_version, nullptr, &file_version, nullptr,
LoadStrategy::LoadModel | LoadStrategy::LoadConfig)); LoadStrategy::LoadModel | LoadStrategy::LoadConfig));
THEN("it does not crash and invents no per-filament volume map") { THEN("the model loads") {
for (PlateData* p : plates) { REQUIRE(loaded);
REQUIRE(p->config.option<ConfigOptionInts>("filament_volume_map") == nullptr); REQUIRE_FALSE(model.objects.empty());
} }
release_PlateData_list(plates);
}
}
GIVEN("a project with plate metadata but no filament volume map") {
ScopedTemporaryFile temp(".3mf");
store_painted_cube(temp.string());
ScopedTemporaryDir backup_dir("orca_volume_map_dst");
Model model;
model.set_backup_path(backup_dir.string());
DynamicPrintConfig config;
PlateDataPtrs plates;
WHEN("loaded through the BBS importer") {
const bool loaded = load_bbs_3mf(temp.string().c_str(), &config, &ctxt, &model, &plates,
&project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version,
nullptr, LoadStrategy::LoadModel | LoadStrategy::LoadConfig);
THEN("every plate loads without a per-filament volume map") {
REQUIRE(loaded);
REQUIRE_FALSE(plates.empty());
for (PlateData* p : plates)
CHECK(p->config.option<ConfigOptionInts>("filament_volume_map") == nullptr);
} }
release_PlateData_list(plates); release_PlateData_list(plates);
} }
@@ -2026,8 +2049,8 @@ SCENARIO("Published 3MF round-trips the extended material metadata", "[3mf]") {
REQUIRE(dst_model.model_info != nullptr); REQUIRE(dst_model.model_info != nullptr);
REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_MATERIAL_TAG] == material_keys_json); REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_MATERIAL_TAG] == material_keys_json);
// The value must parse back with every extended field intact. // The loaded value must parse back with every extended field intact.
nlohmann::json entries = nlohmann::json::parse(material_keys_json); nlohmann::json entries = nlohmann::json::parse(dst_model.model_info->metadata_items[ORCA_PUBLISHED_MATERIAL_TAG]);
REQUIRE(entries.is_array()); REQUIRE(entries.is_array());
REQUIRE(entries.size() == 1); REQUIRE(entries.size() == 1);
REQUIRE(entries[0]["full"].get<bool>() == true); REQUIRE(entries[0]["full"].get<bool>() == true);
+115 -90
View File
@@ -44,6 +44,7 @@
#include <cmath> #include <cmath>
#include <cstring> #include <cstring>
#include <boost/filesystem/path.hpp>
#include <fstream> #include <fstream>
#include <memory> #include <memory>
#include <set> #include <set>
@@ -53,6 +54,7 @@
#include <BRepAdaptor_Surface.hxx> #include <BRepAdaptor_Surface.hxx>
#include <TopExp_Explorer.hxx> #include <TopExp_Explorer.hxx>
#include <TopoDS.hxx> #include <TopoDS.hxx>
#include <BRepAlgoAPI_Common.hxx>
#include <BRepGProp.hxx> #include <BRepGProp.hxx>
#include <GProp_GProps.hxx> #include <GProp_GProps.hxx>
#include <BRepPrimAPI_MakeBox.hxx> #include <BRepPrimAPI_MakeBox.hxx>
@@ -772,16 +774,10 @@ TEST_CASE("entity constraints: tangent/midpoint/symmetric/angle", "[CadDocument]
TEST_CASE("imported text glyphs all extrude without failing (charset sweep)", "[CadDocument]") TEST_CASE("imported text glyphs all extrude without failing (charset sweep)", "[CadDocument]")
{ {
std::string font = resources_dir().empty() // The bundled font, from the source tree: PROFILES_DIR is <repo>/resources/profiles.
? std::string("resources/fonts/HarmonyOS_Sans_SC_Regular.ttf") const std::string font =
: resources_dir() + "/fonts/HarmonyOS_Sans_SC_Regular.ttf"; (boost::filesystem::path(PROFILES_DIR).parent_path() / "fonts" / "HarmonyOS_Sans_SC_Regular.ttf").string();
{ REQUIRE(std::ifstream(font).good());
std::ifstream probe(font);
if (!probe.good()) {
SUCCEED("bundled font not reachable in this environment; covered live on :10");
return;
}
}
const std::string charset = const std::string charset =
"ABCDEFGHIJKLMNOPQRSTUVWXYZ" "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
@@ -2853,9 +2849,10 @@ TEST_CASE("datum coordinate system: non-perpendicular inputs produce orthonormal
using Catch::Matchers::WithinAbs; using Catch::Matchers::WithinAbs;
CadDocument doc; CadDocument doc;
// Build a body so we have a face to reference for FaceAndDirection. // A cylinder's end cap is bounded by one closed circular edge, which offers no in-plane
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box"); // direction, so the frame has to fall back on coordsys_x_hint.
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "BoxExt"); int sk = doc.add_sketch(SketchShape::Circle, SketchPlane::XY(), 0, 0, 5.0, "Circle");
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Cyl");
REQUIRE(doc.recompute()); REQUIRE(doc.recompute());
REQUIRE(doc.bodies.size() == 1); REQUIRE(doc.bodies.size() == 1);
int n_faces = GeometryEngine::face_count(doc.bodies[0].shape); int n_faces = GeometryEngine::face_count(doc.bodies[0].shape);
@@ -2865,34 +2862,33 @@ TEST_CASE("datum coordinate system: non-perpendicular inputs produce orthonormal
if (fn.z() > 0.9) { top_face = i; break; } if (fn.z() > 0.9) { top_face = i; break; }
} }
REQUIRE(top_face >= 0); REQUIRE(top_face >= 0);
const Vec3d Z = GeometryEngine::face_normal_world(GeometryEngine::face_by_index(doc.bodies[0].shape, top_face));
int cs = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 0), "CS1"); int cs = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 0), "CS1");
REQUIRE(cs >= 0); REQUIRE(cs >= 0);
doc.features[cs].coordsys_type = CoordSysType::FaceAndDirection; doc.features[cs].coordsys_type = CoordSysType::FaceAndDirection;
doc.features[cs].coordsys_body = 0; doc.features[cs].coordsys_body = 0;
doc.features[cs].coordsys_face = top_face; doc.features[cs].coordsys_face = top_face;
// Deliberately non-perpendicular X hint (NOT orthogonal to face normal ~+Z). // Deliberately non-perpendicular X hint (NOT orthogonal to the face normal +Z).
doc.features[cs].coordsys_x_hint = Vec3d(3.0, -1.0, 0.5); doc.features[cs].coordsys_x_hint = Vec3d(3.0, -1.0, 0.5);
auto css = doc.resolve_datum_coordsys(); auto css = doc.resolve_datum_coordsys();
REQUIRE(css.size() == 1); REQUIRE(css.size() == 1);
REQUIRE(css[0].error.empty()); REQUIRE(css[0].error.empty());
Vec3d X = css[0].x, Y = css[0].y; const Vec3d X = css[0].x, Y = css[0].y;
// Orthonormality: each axis has unit length // X is the hint projected into the face plane.
const Vec3d expected_x = Vec3d(3.0, -1.0, 0.0).normalized();
CHECK_THAT(X.x(), WithinAbs(expected_x.x(), 1e-9));
CHECK_THAT(X.y(), WithinAbs(expected_x.y(), 1e-9));
CHECK_THAT(X.z(), WithinAbs(expected_x.z(), 1e-9));
// Orthonormal, with Z the face normal and the frame right-handed.
CHECK_THAT(X.norm(), WithinAbs(1.0, 1e-9)); CHECK_THAT(X.norm(), WithinAbs(1.0, 1e-9));
CHECK_THAT(Y.norm(), WithinAbs(1.0, 1e-9)); CHECK_THAT(Y.norm(), WithinAbs(1.0, 1e-9));
// Pairwise dot products are ~0 CHECK_THAT(X.dot(Y), WithinAbs(0.0, 1e-9));
CHECK_THAT(std::abs(X.dot(Y)), WithinAbs(0.0, 1e-9)); CHECK_THAT(X.dot(Z), WithinAbs(0.0, 1e-9));
// Z = X x Y (derived), also unit and perpendicular CHECK_THAT(Y.dot(Z), WithinAbs(0.0, 1e-9));
Vec3d Z = X.cross(Y); CHECK_THAT(X.cross(Y).dot(Z), WithinAbs(1.0, 1e-9));
CHECK_THAT(Z.norm(), WithinAbs(1.0, 1e-9));
CHECK_THAT(std::abs(X.dot(Z)), WithinAbs(0.0, 1e-9));
CHECK_THAT(std::abs(Y.dot(Z)), WithinAbs(0.0, 1e-9));
// Right-handedness: X x Y == Z
CHECK_THAT(Z.x(), WithinAbs((X.cross(Y)).x(), 1e-9));
CHECK_THAT(Z.y(), WithinAbs((X.cross(Y)).y(), 1e-9));
CHECK_THAT(Z.z(), WithinAbs((X.cross(Y)).z(), 1e-9));
} }
TEST_CASE("datum coordinate system: a face-only frame rotates with its body", "[CadDocument]") TEST_CASE("datum coordinate system: a face-only frame rotates with its body", "[CadDocument]")
@@ -3917,17 +3913,23 @@ TEST_CASE("Use/Convert Entities: construction references never become solid", "[
} }
REQUIRE(top_face >= 0); REQUIRE(top_face >= 0);
const size_t body_count_before = doc.bodies.size();
const double volume_before = doc.body_mass_properties(0).volume;
int target = doc.add_sketch_entities({}, SketchPlane::XY(), "Use"); int target = doc.add_sketch_entities({}, SketchPlane::XY(), "Use");
int appended = doc.project_edges_into_sketch(target, 0, {}, top_face); int appended = doc.project_edges_into_sketch(target, 0, {}, top_face);
REQUIRE(appended == 4); REQUIRE(appended == 4);
doc.add_extrude(target, 5.0, false, BooleanMode::New, "UseExt");
SECTION("construction edges form no profile, so the extrude fails") {
REQUIRE_FALSE(doc.recompute());
REQUIRE_FALSE(doc.error.empty());
}
SECTION("the same edges as solid geometry extrude a 20x20x5 body") {
for (auto& e : doc.features[target].entities)
e.construction = false;
REQUIRE(doc.recompute()); REQUIRE(doc.recompute());
REQUIRE(doc.error.empty()); REQUIRE(doc.error.empty());
REQUIRE(doc.bodies.size() == body_count_before); REQUIRE(doc.bodies.size() == 2);
REQUIRE_THAT(doc.body_mass_properties(0).volume, WithinRel(volume_before, 1e-9)); REQUIRE_THAT(doc.body_mass_properties(1).volume, WithinRel(20.0 * 20.0 * 5.0, 1e-6));
}
} }
TEST_CASE("Use/Convert Entities: bad references return -1 and change nothing", "[CadDocument][project]") TEST_CASE("Use/Convert Entities: bad references return -1 and change nothing", "[CadDocument][project]")
@@ -4252,7 +4254,7 @@ TEST_CASE("a previous-format recipe is refused, not silently misread", "[CadDocu
REQUIRE(doc.features.empty()); // nothing half-read was left behind REQUIRE(doc.features.empty()); // nothing half-read was left behind
} }
TEST_CASE("golden recipe v1 still deserialises", "[CadDocument]") TEST_CASE("a v4 recipe keeps every field value", "[CadDocument]")
{ {
using Catch::Matchers::WithinRel; using Catch::Matchers::WithinRel;
using Catch::Matchers::WithinAbs; using Catch::Matchers::WithinAbs;
@@ -4282,20 +4284,16 @@ TEST_CASE("golden recipe v1 still deserialises", "[CadDocument]")
} }
CadDocument expected = make_golden_doc_v1(); CadDocument expected = make_golden_doc_v1();
// Scoped tightly: this advice is ONLY valid for a count mismatch. It must not be in
// scope for the field-value assertions below, where "regenerate the fixture" is the
// one thing you must never do -- regenerating after a reorder bakes the corrupted
// layout in as the new golden and permanently disarms this test.
{ {
INFO("Feature count changed - did you add/remove features in make_golden_doc_v1()?"); INFO("Feature count changed - make_golden_doc_v1() must keep building exactly the tree "
INFO("If so: run libslic3r_tests \"[.regen]\" and re-run this test."); "frozen in cad_recipe_v4.bin, so do not add or remove features there.");
REQUIRE(features.size() == expected.features.size()); REQUIRE(features.size() == expected.features.size());
} }
// A failure BELOW this point means the on-disk serialization format changed: some field // A failure BELOW this point means the on-disk serialization format changed: some field
// in CadFeature::save/load was reordered, retyped, or removed. Fields may only ever be // in CadFeature::save/load was reordered, retyped, or removed. Fields may only ever be
// APPENDED at the end of both lists. Do NOT regenerate the fixture to make this pass -- // APPENDED at the end of both lists. Do NOT regenerate the fixture to make this pass --
// fix the field order instead. See scripts/CAD/run-kernel-tests.sh and the [.regen] case. // fix the field order instead.
// Field-by-field assertions against expected values. // Field-by-field assertions against expected values.
// Every field set to a distinctive non-default literal must be checked here. // Every field set to a distinctive non-default literal must be checked here.
@@ -4550,34 +4548,48 @@ TEST_CASE("golden recipe v1 still deserialises", "[CadDocument]")
} }
} }
// --- Layer 2: geometry check (optional — only if the document recomputes) --- // --- Layer 2: the production v4 load rebuilds the same geometry as the in-memory tree ---
// Build expected document and try to recompute it. // The golden tree is serialization coverage and does not recompute as a whole: these
// ponytail: the field-value layer above is the real tripwire; // features fail on the geometry they get, so they are left off in both trees.
// this layer is a bonus sanity check on the full recompute path. const std::set<std::string> unbuildable{"Fillet_Lat35", "Chamfer_Top2", "Draft_F3", "Shell_T1375",
"GoldenProject"};
CadDocument exp_doc = make_golden_doc_v1(); CadDocument exp_doc = make_golden_doc_v1();
bool exp_ok = exp_doc.recompute(); // Fields appended after v4 are not in the file, so a v4 project reads them as defaults
if (!exp_ok) { // (a v4 thread, for one, is not major-nominal). The reference has to match that.
INFO("Expected document from make_golden_doc_v1() could not recompute " const CadFeature def;
"(complex feature tree). Field-value checks above are sufficient."); for (CadFeature& f : exp_doc.features) {
f.thread_major_nominal = def.thread_major_nominal;
f.pattern_inclusive = def.pattern_inclusive;
f.dressup_edges = def.dressup_edges;
f.text_string = def.text_string;
f.text_font = def.text_font;
f.text_height = def.text_height;
f.revolve_axis_entity = def.revolve_axis_entity;
} }
CadDocument doc; CadDocument doc;
bool ser_ok = doc.deserialize_recipe(blob); doc.deserialize_recipe(blob);
REQUIRE(doc.features.size() == exp_doc.features.size());
if (exp_ok && ser_ok) { for (size_t i = 0; i < exp_doc.features.size(); ++i)
REQUIRE(doc.error.empty()); exp_doc.features[i].enabled = doc.features[i].enabled = unbuildable.count(exp_doc.features[i].name) == 0;
const bool exp_built = exp_doc.recompute();
INFO("reference error: " << exp_doc.error);
REQUIRE(exp_built);
const bool built = doc.recompute();
INFO("loaded error: " << doc.error);
REQUIRE(built);
REQUIRE_FALSE(exp_doc.bodies.empty());
REQUIRE(doc.bodies.size() == exp_doc.bodies.size()); REQUIRE(doc.bodies.size() == exp_doc.bodies.size());
for (size_t i = 0; i < doc.bodies.size(); ++i) { for (size_t b = 0; b < doc.bodies.size(); ++b) {
double v = double(SketchEngine::tessellate(doc.bodies[i].shape).volume()); INFO("body " << b);
double ev = double(SketchEngine::tessellate(exp_doc.bodies[i].shape).volume()); TriangleMesh mesh = SketchEngine::tessellate(doc.bodies[b].shape);
REQUIRE_THAT(v, WithinRel(ev, 1e-6)); TriangleMesh exp_mesh = SketchEngine::tessellate(exp_doc.bodies[b].shape);
CHECK_THAT(double(mesh.volume()), WithinRel(double(exp_mesh.volume()), 1e-6));
// Volume alone is blind to fields that only place a body, such as a transform.
const BoundingBoxf3 bb = mesh.bounding_box(), exp_bb = exp_mesh.bounding_box();
for (int k = 0; k < 3; ++k) {
CHECK_THAT(bb.min[k], WithinAbs(exp_bb.min[k], 1e-6));
CHECK_THAT(bb.max[k], WithinAbs(exp_bb.max[k], 1e-6));
} }
} else {
INFO("The golden recipe fixture was loaded and field-value checks passed.");
INFO("Recompute on the deserialized or expected document failed — this is");
INFO("expected for the extended golden fixture (many feature types coexist");
INFO("purely for serialization coverage). The field-value tripwire above is");
INFO("the primary format check.");
} }
} }
@@ -5781,8 +5793,9 @@ TEST_CASE("fastened mate with angle", "[CadDocument][mate]")
REQUIRE(doc.recompute()); REQUIRE(doc.recompute());
REQUIRE(doc.error.empty()); REQUIRE(doc.error.empty());
int sk2 = doc.add_sketch(SketchShape::Circle, SketchPlane::XY(), 0, 0, 3, "Cyl"); // A 20x10 plate, so a quarter turn about the connector's Z swaps its X and Y extents.
doc.add_extrude(sk2, 10.0, false, BooleanMode::New, "CylExt"); int sk2 = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 10, 0, "Plate");
doc.add_extrude(sk2, 10.0, false, BooleanMode::New, "PlateExt");
REQUIRE(doc.recompute()); REQUIRE(doc.recompute());
REQUIRE(doc.error.empty()); REQUIRE(doc.error.empty());
@@ -5801,6 +5814,13 @@ TEST_CASE("fastened mate with angle", "[CadDocument][mate]")
gp_Pnt com = props.CentreOfMass(); gp_Pnt com = props.CentreOfMass();
REQUIRE_THAT(double(com.X()), WithinAbs(5.0, 1e-4)); REQUIRE_THAT(double(com.X()), WithinAbs(5.0, 1e-4));
REQUIRE_THAT(double(com.Y()), WithinAbs(5.0, 1e-4)); REQUIRE_THAT(double(com.Y()), WithinAbs(5.0, 1e-4));
Bnd_Box bb;
BRepBndLib::Add(doc.bodies[1].shape, bb);
double x0, y0, z0, x1, y1, z1;
bb.Get(x0, y0, z0, x1, y1, z1);
REQUIRE_THAT(x1 - x0, WithinAbs(10.0, 1e-3));
REQUIRE_THAT(y1 - y0, WithinAbs(20.0, 1e-3));
} }
TEST_CASE("fastened mate with flip", "[CadDocument][mate]") TEST_CASE("fastened mate with flip", "[CadDocument][mate]")
@@ -5823,7 +5843,9 @@ TEST_CASE("fastened mate with flip", "[CadDocument][mate]")
REQUIRE(doc.recompute()); REQUIRE(doc.recompute());
REQUIRE(doc.error.empty()); REQUIRE(doc.error.empty());
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 5), "CS_Moving"); // The connector sits on the cylinder's base, so the flip decides which side of the
// fixed connector (z = 5) the 10 mm cylinder hangs: above it unflipped, below it flipped.
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 0), "CS_Moving");
doc.features[cs_moving].coordsys_body = 1; doc.features[cs_moving].coordsys_body = 1;
REQUIRE(doc.recompute()); REQUIRE(doc.recompute());
REQUIRE(doc.error.empty()); REQUIRE(doc.error.empty());
@@ -5837,8 +5859,7 @@ TEST_CASE("fastened mate with flip", "[CadDocument][mate]")
gp_Pnt com = props.CentreOfMass(); gp_Pnt com = props.CentreOfMass();
REQUIRE_THAT(double(com.X()), WithinAbs(5.0, 1e-4)); REQUIRE_THAT(double(com.X()), WithinAbs(5.0, 1e-4));
REQUIRE_THAT(double(com.Y()), WithinAbs(5.0, 1e-4)); REQUIRE_THAT(double(com.Y()), WithinAbs(5.0, 1e-4));
// Flip opposes Z axes: for a symmetric cylinder this is invisible in centroid, REQUIRE_THAT(double(com.Z()), WithinAbs(5.0 - 10.0 / 2, 1e-4));
// but the mate executed cleanly and the body moved to the target connector.
} }
TEST_CASE("planar mate: normal distance becomes mate_offset", "[CadDocument][mate]") TEST_CASE("planar mate: normal distance becomes mate_offset", "[CadDocument][mate]")
@@ -6525,23 +6546,19 @@ TEST_CASE("fastened mate with flip on asymmetric body", "[CadDocument][mate]")
REQUIRE(doc.recompute()); REQUIRE(doc.recompute());
REQUIRE(doc.error.empty()); REQUIRE(doc.error.empty());
// Fastened, NO flip // The same assembly mated once without and once with flip, each in its own copy.
doc.add_mate(0, cs_fixed, cs_moving, 0.0, 0.0, false, "MateNoFlip"); auto mated_com_z = [&](bool flip) {
REQUIRE(doc.recompute()); CadDocument d = doc;
REQUIRE(doc.error.empty()); d.add_mate(0, cs_fixed, cs_moving, 0.0, 0.0, flip, "Mate");
GProp_GProps nf_props; REQUIRE(d.recompute());
BRepGProp::VolumeProperties(doc.bodies[1].shape, nf_props); REQUIRE(d.error.empty());
double nf_z = nf_props.CentreOfMass().Z(); REQUIRE(d.mate_conflicts.empty());
GProp_GProps props;
doc.undo(); BRepGProp::VolumeProperties(d.bodies[1].shape, props);
return props.CentreOfMass().Z();
// Fastened, WITH flip };
doc.add_mate(0, cs_fixed, cs_moving, 0.0, 0.0, true, "MateFlip"); const double nf_z = mated_com_z(false);
REQUIRE(doc.recompute()); const double f_z = mated_com_z(true);
REQUIRE(doc.error.empty());
GProp_GProps f_props;
BRepGProp::VolumeProperties(doc.bodies[1].shape, f_props);
double f_z = f_props.CentreOfMass().Z();
// Flip changes the centroid Z for an asymmetric body // Flip changes the centroid Z for an asymmetric body
REQUIRE_THAT(f_z, !WithinAbs(nf_z, 1e-4)); REQUIRE_THAT(f_z, !WithinAbs(nf_z, 1e-4));
@@ -7324,13 +7341,22 @@ TEST_CASE("interference: sheet bodies are skipped", "[CadDocument][interference]
CadDocument doc; CadDocument doc;
int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA"); int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA");
doc.add_extrude(sk_a, 10.0, false, BooleanMode::New, "EA"); doc.add_extrude(sk_a, 10.0, false, BooleanMode::New, "EA");
// A sheet passing straight through the solid: it has no volume, so no interference. // Four 10x5 walls standing inside the 20x20x10 solid: a sheet has no volume, so no interference.
int sk_s = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 40, 40, 0, "Sheet"); int sk_s = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "Sheet");
doc.add_surface_extrude(sk_s, 5.0, "SE"); doc.add_surface_extrude(sk_s, 5.0, "SE");
REQUIRE(doc.recompute()); REQUIRE(doc.recompute());
REQUIRE(doc.bodies.size() == 2); REQUIRE(doc.bodies.size() == 2);
REQUIRE(CadDocument::is_sheet_shape(doc.bodies[1].shape)); REQUIRE(CadDocument::is_sheet_shape(doc.bodies[1].shape));
// The raw overlap of the walls with the solid does measure a volume, so only the sheet
// skip keeps it out of the report.
BRepAlgoAPI_Common common(doc.bodies[0].shape, doc.bodies[1].shape);
common.Build();
REQUIRE(common.IsDone());
GProp_GProps props;
BRepGProp::VolumeProperties(common.Shape(), props);
REQUIRE(std::abs(props.Mass()) > 1e-6);
REQUIRE(doc.check_interference().empty()); REQUIRE(doc.check_interference().empty());
} }
@@ -7540,9 +7566,8 @@ TEST_CASE("A cut that removes no material is an error, not a silent success", "[
CHECK(doc.error.find("removed no material") != std::string::npos); CHECK(doc.error.find("removed no material") != std::string::npos);
// And the body is left as it was, not half-applied. // And the body is left as it was, not half-applied.
CadDocument again = box(); REQUIRE(doc.bodies.size() == 1);
REQUIRE(again.recompute()); CHECK_THAT(doc.body_mass_properties(0).volume,
CHECK_THAT(again.body_mass_properties(0).volume,
Catch::Matchers::WithinAbs(solid, 1e-6)); Catch::Matchers::WithinAbs(solid, 1e-6));
} }
+7 -10
View File
@@ -75,11 +75,10 @@ SCENARIO("Config accessor functions perform as expected.", "[Config]") {
} }
} }
WHEN("A boolean option is set to a string value representing something other than 0 or 1") { WHEN("A boolean option is set to a string value representing something other than 0 or 1") {
THEN("A BadOptionTypeException exception is thrown.") { config.set("gcode_comments", true);
THEN("A BadOptionTypeException exception is thrown and the value is unchanged.") {
REQUIRE_THROWS_AS(config.set("gcode_comments", "Z"), BadOptionTypeException); REQUIRE_THROWS_AS(config.set("gcode_comments", "Z"), BadOptionTypeException);
} REQUIRE(config.opt<ConfigOptionBool>("gcode_comments")->getBool() == true);
AND_THEN("Value is unchanged.") {
REQUIRE(config.opt<ConfigOptionBool>("gcode_comments")->getBool() == false);
} }
} }
WHEN("A boolean option is set to an int value") { WHEN("A boolean option is set to an int value") {
@@ -117,12 +116,10 @@ SCENARIO("Config accessor functions perform as expected.", "[Config]") {
} }
} }
WHEN("A numeric option is set to a non-numeric value.") { WHEN("A numeric option is set to a non-numeric value.") {
auto prev_value = config.opt<ConfigOptionFloat>("max_bridge_length")->getFloat(); config.set("max_bridge_length", 7.5);
THEN("A BadOptionTypeException exception is thrown.") { THEN("A BadOptionValueException exception is thrown and the value does not change.") {
REQUIRE_THROWS_AS(config.set_deserialize_strict("max_bridge_length", "zzzz"), BadOptionValueException); REQUIRE_THROWS_AS(config.set_deserialize_strict("max_bridge_length", "zzzz"), BadOptionValueException);
} REQUIRE_THAT(config.opt<ConfigOptionFloat>("max_bridge_length")->getFloat(), Catch::Matchers::WithinAbs(7.5, 1e-9));
THEN("The value does not change.") {
REQUIRE(config.opt<ConfigOptionFloat>("max_bridge_length")->getFloat() == prev_value);
} }
} }
WHEN("A string option is set through the string interface") { WHEN("A string option is set through the string interface") {
@@ -198,7 +195,7 @@ SCENARIO("Config accessor functions perform as expected.", "[Config]") {
} }
} }
WHEN("An invalid option is requested during set.") { WHEN("An invalid option is requested during set.") {
THEN("A BadOptionTypeException exception is thrown.") { THEN("A UnknownOptionException exception is thrown.") {
REQUIRE_THROWS_AS(config.set("deadbeef_invalid_option", 1), UnknownOptionException); REQUIRE_THROWS_AS(config.set("deadbeef_invalid_option", 1), UnknownOptionException);
REQUIRE_THROWS_AS(config.set("deadbeef_invalid_option", 1.0), UnknownOptionException); REQUIRE_THROWS_AS(config.set("deadbeef_invalid_option", 1.0), UnknownOptionException);
REQUIRE_THROWS_AS(config.set("deadbeef_invalid_option", "1"), UnknownOptionException); REQUIRE_THROWS_AS(config.set("deadbeef_invalid_option", "1"), UnknownOptionException);
+6 -22
View File
@@ -65,41 +65,25 @@ TEST_CASE("expand_mixed_filaments replaces mixed slots with their components", "
} }
} }
TEST_CASE("belt purge tower island count ignores virtual mixed slots", "[FilamentMixer][belt]") TEST_CASE("A mixed slot whose components are already used adds no filaments", "[FilamentMixer][belt]")
{ {
// Regression for the "extra purge tower" on a belt printer with a mixed // The belt purge tower sizes its islands from the expanded set. A mixed slot is virtual: no
// filament (MCTEST5). The belt purge prism is sized as // nozzle carries it and ToolOrdering::resolve_mixed_filaments() replaces it with its
// n_islands = used_filaments.size() - 1 // components, so it must not count as a filament of its own.
// and GUI::ensure_belt_purge_tower() collected those filaments straight off
// the model objects' extruder assignments. A mixed slot is VIRTUAL -- no
// nozzle carries it, and ToolOrdering::resolve_mixed_filaments() replaces it
// with its components before any G-code is emitted -- so counting it as a
// filament of its own provisions one island that can never be reached.
//
// MCTEST5: five cubes on extruders 1..5, where filament 5 is a 50/50 blend of
// filaments 2 and 4. The G-code uses only T0..T3 and reports
// "filament used [g] = 53.35, 141.11, 40.84, 107.23, 0.00" -- filament 5
// consumes nothing, exactly as a virtual slot should.
const std::vector<unsigned char> is_mixed = {0, 0, 0, 0, 1}; const std::vector<unsigned char> is_mixed = {0, 0, 0, 0, 1};
const std::vector<std::string> comp_strs = {"", "", "", "", "2,4"}; const std::vector<std::string> comp_strs = {"", "", "", "", "2,4"};
// The set the sizer used to see: slots 0..4 (filaments 1..5).
const std::vector<unsigned int> assigned = {0, 1, 2, 3, 4}; const std::vector<unsigned int> assigned = {0, 1, 2, 3, 4};
const auto physical = expand_mixed_filaments(assigned, is_mixed, comp_strs); const auto physical = expand_mixed_filaments(assigned, is_mixed, comp_strs);
// Slot 4 dissolves into 1 and 3, which are already present. // Slot 4 dissolves into 1 and 3, which are already present.
REQUIRE(physical == std::vector<unsigned int>({0, 1, 2, 3})); REQUIRE(physical == std::vector<unsigned int>({0, 1, 2, 3}));
// Four physical filaments => three transitions => three islands, not four.
REQUIRE(int(physical.size()) - 1 == 3);
REQUIRE(int(assigned.size()) - 1 == 4); // what it produced before the fix
SECTION("A mixed slot whose components are otherwise unused still counts them") { SECTION("A mixed slot whose components are otherwise unused still counts them") {
// Only the mixed slot is assigned: it must still yield its two components, // Only the mixed slot is assigned: it must still yield its two components rather than
// i.e. one island, rather than collapsing to zero. // collapsing to nothing.
const auto only_mixed = expand_mixed_filaments({4}, is_mixed, comp_strs); const auto only_mixed = expand_mixed_filaments({4}, is_mixed, comp_strs);
REQUIRE(only_mixed == std::vector<unsigned int>({1, 3})); REQUIRE(only_mixed == std::vector<unsigned int>({1, 3}));
REQUIRE(int(only_mixed.size()) - 1 == 1);
} }
SECTION("No mixed filaments anywhere leaves the set untouched") { SECTION("No mixed filaments anywhere leaves the set untouched") {
+34 -15
View File
@@ -1219,6 +1219,24 @@ TEST_CASE("find_preset2 auto-matches removed Generic vendor profiles to the libr
CHECK(bundle.filaments.find_preset2("BrandX Generic PETG", /*auto_match=*/true) == nullptr); CHECK(bundle.filaments.find_preset2("BrandX Generic PETG", /*auto_match=*/true) == nullptr);
} }
TEST_CASE("The selected preset is its own base when its parent is not loaded", "[Preset]")
{
PresetCollection printers(Preset::TYPE_PRINTER, Preset::printer_options(),
static_cast<const PrintRegionConfig &>(FullPrintConfig::defaults()));
add_inmemory_preset(printers, "Printer");
add_inmemory_preset(printers, "User Printer", "Printer");
add_inmemory_preset(printers, "Orphan Printer", "Uninstalled Vendor Printer");
printers.select_preset_by_name("User Printer", true);
REQUIRE(printers.get_selected_preset_name() == "User Printer");
CHECK(printers.get_selected_preset_base().name == "Printer");
printers.select_preset_by_name("Orphan Printer", true);
REQUIRE(printers.get_selected_preset_name() == "Orphan Printer");
REQUIRE(printers.get_preset_base(printers.get_selected_preset()) == nullptr);
CHECK(printers.get_selected_preset_base().name == "Orphan Printer");
}
TEST_CASE("Renamed parent is normalized into a loaded preset's inherits", "[Preset][Rename]") TEST_CASE("Renamed parent is normalized into a loaded preset's inherits", "[Preset][Rename]")
{ {
ScopedTemporaryDir temp_dir; ScopedTemporaryDir temp_dir;
@@ -1316,12 +1334,17 @@ TEST_CASE("Renamed printer/process names are normalized into compatible lists on
CHECK(compatible_list(bundle.filaments, "My Filament", "compatible_printers") == CHECK(compatible_list(bundle.filaments, "My Filament", "compatible_printers") ==
(std::vector<std::string>{ "New Printer", "Unknown Printer" })); (std::vector<std::string>{ "New Printer", "Unknown Printer" }));
// Normalizing rewrites config in place without flagging the preset dirty. // Normalization runs before selection, so selecting copies the rewritten list into the edited preset.
CHECK_FALSE(bundle.prints.find_preset("My Process", false, true)->is_dirty); bundle.prints.select_preset_by_name("My Process", true);
REQUIRE(bundle.prints.get_selected_preset_name() == "My Process");
CHECK(bundle.prints.get_edited_preset().config.option<ConfigOptionStrings>("compatible_printers")->values ==
std::vector<std::string>{ "New Printer" });
// A system preset that already references the current name is left untouched (idempotent no-op). // Normalizing again is a no-op: the stored preset keeps the current name and still matches the
// edited copy, so the selection is not reported modified.
bundle.normalize_compatible_presets(); bundle.normalize_compatible_presets();
CHECK(compatible_list(bundle.prints, "My Process", "compatible_printers") == std::vector<std::string>{ "New Printer" }); CHECK(compatible_list(bundle.prints, "My Process", "compatible_printers") == std::vector<std::string>{ "New Printer" });
CHECK_FALSE(bundle.prints.current_is_dirty());
} }
TEST_CASE("Renamed names are normalized into a SYSTEM preset's compatible lists", "[Preset][Rename]") TEST_CASE("Renamed names are normalized into a SYSTEM preset's compatible lists", "[Preset][Rename]")
@@ -1351,11 +1374,17 @@ TEST_CASE("Renamed names are normalized into a SYSTEM preset's compatible lists"
CHECK(compatible_list(bundle.filaments, "System Filament", "compatible_prints") == CHECK(compatible_list(bundle.filaments, "System Filament", "compatible_prints") ==
std::vector<std::string>{ "New Process" }); std::vector<std::string>{ "New Process" });
// The rewrite does not flag the system preset dirty, and is idempotent. // Normalization runs before selection, so selecting copies the rewritten lists into the edited preset.
CHECK_FALSE(bundle.filaments.find_preset("System Filament", false, true)->is_dirty); bundle.filaments.select_preset_by_name("System Filament", true);
REQUIRE(bundle.filaments.get_selected_preset_name() == "System Filament");
CHECK(bundle.filaments.get_edited_preset().config.option<ConfigOptionStrings>("compatible_printers")->values ==
std::vector<std::string>{ "New Printer" });
// Normalizing again is a no-op, so the selection is not reported modified.
bundle.normalize_compatible_presets(); bundle.normalize_compatible_presets();
CHECK(compatible_list(bundle.filaments, "System Filament", "compatible_printers") == CHECK(compatible_list(bundle.filaments, "System Filament", "compatible_printers") ==
std::vector<std::string>{ "New Printer" }); std::vector<std::string>{ "New Printer" });
CHECK_FALSE(bundle.filaments.current_is_dirty());
} }
TEST_CASE("compatible_prints on SLA materials resolves against sla_prints, not prints", "[Preset][Rename]") TEST_CASE("compatible_prints on SLA materials resolves against sla_prints, not prints", "[Preset][Rename]")
@@ -5548,16 +5577,6 @@ TEST_CASE("Sizing down to the nozzle count plus mixes is what eats the mixed tai
CHECK(bundle.num_mixed_filaments() == 0); CHECK(bundle.num_mixed_filaments() == 0);
CHECK(bundle.num_physical_filaments() == target); CHECK(bundle.num_physical_filaments() == target);
} }
SECTION("applied as a floor, the mix is left alone") {
if (target > bundle.filament_presets.size())
bundle.set_num_filaments(target);
CHECK(bundle.filament_presets.size() == 6);
CHECK(bundle.num_mixed_filaments() == 1);
CHECK(bundle.is_mixed_filament(5));
CHECK(bundle.project_config.option<ConfigOptionStrings>("filament_mixed_components")->values[5] == "1,2");
}
} }
// After a published-3MF import relocated mixed-filament definitions, the freshly loaded // After a published-3MF import relocated mixed-filament definitions, the freshly loaded
+5 -13
View File
@@ -5,9 +5,9 @@
#include <catch2/matchers/catch_matchers.hpp> #include <catch2/matchers/catch_matchers.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp> #include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "libslic3r/CAD/SketchImport.hpp" #include "libslic3r/CAD/SketchImport.hpp"
#include "libslic3r/Utils.hpp" // resources_dir
#include "test_utils.hpp" // ScopedTemporaryFile #include "test_utils.hpp" // ScopedTemporaryFile
#include <boost/filesystem/path.hpp>
#include <cstdlib> #include <cstdlib>
#include <fstream> #include <fstream>
#include "libslic3r/Point.hpp" #include "libslic3r/Point.hpp"
@@ -78,18 +78,10 @@ TEST_CASE("transform_regions moves and scales independently", "[SketchImport]")
TEST_CASE("text_to_regions vectorizes glyphs with counters", "[SketchImport]") TEST_CASE("text_to_regions vectorizes glyphs with counters", "[SketchImport]")
{ {
// Locate the bundled font; resources_dir() may be unset under ctest, so // The bundled font, from the source tree: PROFILES_DIR is <repo>/resources/profiles.
// fall back to a cwd-relative path (tests run from the repo root). const std::string font =
std::string font = resources_dir().empty() (boost::filesystem::path(PROFILES_DIR).parent_path() / "fonts" / "HarmonyOS_Sans_SC_Regular.ttf").string();
? std::string("resources/fonts/HarmonyOS_Sans_SC_Regular.ttf") REQUIRE(std::ifstream(font).good());
: resources_dir() + "/fonts/HarmonyOS_Sans_SC_Regular.ttf";
{
std::ifstream probe(font);
if (!probe.good()) {
SUCCEED("bundled font not reachable in this environment; covered live on :10");
return;
}
}
// Bad input is rejected without throwing. // Bad input is rejected without throwing.
REQUIRE(text_to_regions("", 10.0, font).empty()); REQUIRE(text_to_regions("", 10.0, font).empty());
+3 -2
View File
@@ -33,8 +33,9 @@ TEST_CASE("inference: cursor near a line endpoint snaps Coincident-able to it",
TEST_CASE("inference: endpoint beats midpoint when both are in range", "[inference]") TEST_CASE("inference: endpoint beats midpoint when both are in range", "[inference]")
{ {
std::vector<SketchEntity> ents = { line({0, 0}, {2, 0}) }; std::vector<SketchEntity> ents = { line({0, 0}, {2, 0}) };
// Query equidistant-ish but closer to the endpoint: endpoint tier wins regardless. // Query 0.2 from the midpoint (1,0) and 0.8 from the endpoint (2,0): the endpoint tier
auto s = infer_point_snap(ents, {1.9, 0.0}, 5.0); // wins even though the midpoint is nearer.
auto s = infer_point_snap(ents, {1.2, 0.0}, 5.0, /*include_origin=*/false);
CHECK(s.kind == K::Endpoint); CHECK(s.kind == K::Endpoint);
CHECK(s.role == SketchPointRole::P1); CHECK(s.role == SketchPointRole::P1);
} }
+4 -7
View File
@@ -113,14 +113,11 @@ TEST_CASE("profile: offsetting a closed rectangle keeps it closed", "[SketchProf
REQUIRE(closed_wires(out) == 1); REQUIRE(closed_wires(out) == 1);
} }
TEST_CASE("profile: offset outward grows the enclosed area by the right amount", "[SketchProfile]") TEST_CASE("profile: offsetting a closed rectangle outward keeps it one closed loop", "[SketchProfile]")
{ {
// A rectangle offset outward by d is (w+2d) x (h+2d) with the corners rounded at r=d, // Outward is -d for a CCW loop. The miter join reconnects the corners, so the result is one
// so its area is w*h + 2d(w+h) + pi*d^2 whichever way the corners are healed... except // closed wire, not four disconnected segments. Its area is checked by the sign test below.
// for a sharp-corner offset, which is exactly (w+2d)*(h+2d). Either healing is defensible; const auto out = SketchEngine::offset_entities(rect(40, 20), -5.0);
// a set of four disconnected segments is not, and that is what this measures.
const double w = 40, h = 20, d = 5;
const auto out = SketchEngine::offset_entities(rect(w, h), d);
const auto ws = SketchEngine::entities_to_wires(out, xy_plane()); const auto ws = SketchEngine::entities_to_wires(out, xy_plane());
REQUIRE(ws.size() == 1); REQUIRE(ws.size() == 1);
REQUIRE(ws[0].Closed()); REQUIRE(ws[0].Closed());
@@ -60,9 +60,8 @@ std::vector<NozzleInfo> single_nozzle_per_extruder(int extruder_count)
} }
} // namespace } // namespace
TEST_CASE("Multi-nozzle gate predicate mirrors BambuStudio", "[ToolOrdering][H2C]") TEST_CASE("Multi-nozzle options exist and default to one nozzle per extruder", "[ToolOrdering][H2C]")
{ {
// The multi-nozzle gate: std::any_of(extruder_max_nozzle_count > 1).
DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
auto *opt = config.option<ConfigOptionIntsNullable>("extruder_max_nozzle_count"); auto *opt = config.option<ConfigOptionIntsNullable>("extruder_max_nozzle_count");
@@ -72,15 +71,11 @@ TEST_CASE("Multi-nozzle gate predicate mirrors BambuStudio", "[ToolOrdering][H2C
// 3mf projects round-trip the per-extruder nozzle inventory (GUI producers wire it later). // 3mf projects round-trip the per-extruder nozzle inventory (GUI producers wire it later).
REQUIRE(config.option<ConfigOptionStrings>("extruder_nozzle_stats") != nullptr); REQUIRE(config.option<ConfigOptionStrings>("extruder_nozzle_stats") != nullptr);
auto has_multiple_nozzle = [](const std::vector<int> &values) { // Default for every existing printer: 1 nozzle per extruder, so the multi-nozzle gate
return std::any_of(values.begin(), values.end(), [](int v) { return v > 1; }); // (any extruder_max_nozzle_count > 1) stays closed.
}; REQUIRE_FALSE(opt->values.empty());
for (int count : opt->values)
// Default for every existing printer: 1 nozzle per extruder => gate is closed. CHECK(count == 1);
REQUIRE_FALSE(has_multiple_nozzle(opt->values));
// Synthetic H2C-like machine: extruder 1 is a 6-nozzle cluster => gate opens.
REQUIRE(has_multiple_nozzle(std::vector<int>{1, 6}));
} }
TEST_CASE("Single-nozzle grouping: every filament maps to its extruder nozzle", "[ToolOrdering][H2C]") TEST_CASE("Single-nozzle grouping: every filament maps to its extruder nozzle", "[ToolOrdering][H2C]")
+75 -34
View File
@@ -32,6 +32,7 @@
using namespace Slic3r; using namespace Slic3r;
using VD = Geometry::VoronoiDiagram; using VD = Geometry::VoronoiDiagram;
static bool has_missing_voronoi_vertices(const Polygons &polygons, const VD &vd);
using BoostVD = boost::polygon::voronoi_diagram<double>; using BoostVD = boost::polygon::voronoi_diagram<double>;
// https://svn.boost.org/trac10/ticket/12067 // https://svn.boost.org/trac10/ticket/12067
@@ -198,8 +199,12 @@ TEST_CASE("Voronoi missing edges - Alessandro gapfill 12707", "[Voronoi]")
REQUIRE(intersecting_edges({ poly }).empty()); REQUIRE(intersecting_edges({ poly }).empty());
Lines lines = to_lines(poly); Lines lines = to_lines(poly);
BoostVD vd; VD vd;
construct_voronoi(lines.begin(), lines.end(), &vd); vd.construct_voronoi(lines.begin(), lines.end());
// Orca's detector finds nothing wrong with this input, so no repair is needed.
CHECK(VD::detect_known_issues(vd, lines.begin(), lines.end()) == VD::IssueType::NO_ISSUE_DETECTED);
CHECK(vd.get_state() == VD::State::REPAIR_NOT_NEEDED);
CHECK_FALSE(has_missing_voronoi_vertices({ poly }, vd));
#ifdef VORONOI_DEBUG_OUT #ifdef VORONOI_DEBUG_OUT
dump_voronoi_to_svg(debug_out_path("voronoi-lines.svg").c_str(), dump_voronoi_to_svg(debug_out_path("voronoi-lines.svg").c_str(),
@@ -305,9 +310,16 @@ TEST_CASE("Voronoi weirdness", "[Voronoi]")
} }
#endif #endif
BoostVD vd; VD vd;
Lines lines = to_lines(poly); Lines lines = to_lines(poly);
construct_voronoi(lines.begin(), lines.end(), &vd); vd.construct_voronoi(lines.begin(), lines.end());
// Unrepaired, the diagram is missing a Voronoi vertex. construct_voronoi detects it and repairs it
// by rotating the input.
VD raw_vd;
raw_vd.construct_voronoi(lines.begin(), lines.end(), false);
CHECK(VD::detect_known_issues(raw_vd, lines.begin(), lines.end()) == VD::IssueType::MISSING_VORONOI_VERTEX);
CHECK(vd.get_state() == VD::State::REPAIR_SUCCESSFUL);
CHECK(vd.get_issue_type() == VD::IssueType::NO_ISSUE_DETECTED);
#ifdef VORONOI_DEBUG_OUT #ifdef VORONOI_DEBUG_OUT
dump_voronoi_to_svg(debug_out_path("voronoi-weirdness.svg").c_str(), dump_voronoi_to_svg(debug_out_path("voronoi-weirdness.svg").c_str(),
@@ -347,9 +359,8 @@ TEST_CASE("Voronoi division by zero 12903", "[Voronoi]")
// https://svn.boost.org/trac10/ticket/12139 // https://svn.boost.org/trac10/ticket/12139
// Funny sample from a dental industry? // Funny sample from a dental industry?
// Vojtech confirms this test fails and rightly so, because the input data contain self intersections. // The input data contain self intersections, so no valid Voronoi diagram can be built from it.
// This test is suppressed. TEST_CASE("Voronoi NaN coordinates 12139", "[Voronoi]")
TEST_CASE("Voronoi NaN coordinates 12139", "[Voronoi][.][!mayfail]")
{ {
Lines lines = { Lines lines = {
{ { 260500,1564400 }, { 261040,1562960 } }, { { 260500,1564400 }, { 261040,1562960 } },
@@ -1327,18 +1338,13 @@ TEST_CASE("Voronoi NaN coordinates 12139", "[Voronoi][.][!mayfail]")
} }
#endif #endif
BoostVD vd; VD vd;
construct_voronoi(lines.begin(), lines.end(), &vd); vd.construct_voronoi(lines.begin(), lines.end());
// The diagram built from this self-intersecting input has non-finite vertices, and rotating the
for (const auto& edge : vd.edges()) // input does not repair it. It must be reported as invalid, which is what MedialAxis::build
if (edge.is_finite()) { // checks before falling back to a morphological closing.
auto v0 = edge.vertex0(); CHECK(vd.get_state() == VD::State::REPAIR_UNSUCCESSFUL);
auto v1 = edge.vertex1(); CHECK_FALSE(vd.is_valid());
REQUIRE((v0->x() == 0 || std::isnormal(v0->x())));
REQUIRE((v0->y() == 0 || std::isnormal(v0->y())));
REQUIRE((v1->x() == 0 || std::isnormal(v1->x())));
REQUIRE((v1->y() == 0 || std::isnormal(v1->y())));
}
#ifdef VORONOI_DEBUG_OUT #ifdef VORONOI_DEBUG_OUT
dump_voronoi_to_svg(debug_out_path("voronoi-NaNs.svg").c_str(), dump_voronoi_to_svg(debug_out_path("voronoi-NaNs.svg").c_str(),
@@ -1935,7 +1941,7 @@ TEST_CASE("Voronoi skeleton", "[VoronoiSkeleton]")
} }
// Simple detection with complexity N^2 if there is any point in the input polygons that doesn't have Voronoi vertex. // Simple detection with complexity N^2 if there is any point in the input polygons that doesn't have Voronoi vertex.
[[maybe_unused]] static bool has_missing_voronoi_vertices(const Polygons &polygons, const VD &vd) static bool has_missing_voronoi_vertices(const Polygons &polygons, const VD &vd)
{ {
auto are_equal = [](const VD::vertex_type v, const Point &p) { return (Vec2d(v.x(), v.y()) - p.cast<double>()).norm() <= SCALED_EPSILON; }; auto are_equal = [](const VD::vertex_type v, const Point &p) { return (Vec2d(v.x(), v.y()) - p.cast<double>()).norm() <= SCALED_EPSILON; };
@@ -1957,7 +1963,7 @@ TEST_CASE("Voronoi skeleton", "[VoronoiSkeleton]")
// into two parts. Even we add more points to the edge, and then for these points, the Voronoin vertex is also missing. An // into two parts. Even we add more points to the edge, and then for these points, the Voronoin vertex is also missing. An
// infinity-edge passes through the missing Voronoi vertex. Therefore, this missing Voronoi vertex and edge can be reconstructed // infinity-edge passes through the missing Voronoi vertex. Therefore, this missing Voronoi vertex and edge can be reconstructed
// using the intersection between the infinity-edge with the input polygon. // using the intersection between the infinity-edge with the input polygon.
// Rotation of the polygon solves this problem. // VoronoiDiagram::construct_voronoi repairs it by rotating the input.
TEST_CASE("Voronoi missing vertex 1", "[VoronoiMissingVertex1]") TEST_CASE("Voronoi missing vertex 1", "[VoronoiMissingVertex1]")
{ {
Polygon poly = { Polygon poly = {
@@ -1981,7 +1987,14 @@ TEST_CASE("Voronoi missing vertex 1", "[VoronoiMissingVertex1]")
dump_voronoi_to_svg(debug_out_path("voronoi-missing-vertex1-out.svg").c_str(), vd, Points(), lines); dump_voronoi_to_svg(debug_out_path("voronoi-missing-vertex1-out.svg").c_str(), vd, Points(), lines);
#endif #endif
// REQUIRE(!has_missing_voronoi_vertices({poly}, vd)); // Unrepaired, the diagram shows the defect. construct_voronoi detects it and repairs it by
// rotating the input.
VD raw_vd;
raw_vd.construct_voronoi(lines.begin(), lines.end(), false);
CHECK(has_missing_voronoi_vertices({poly}, raw_vd));
CHECK(vd.get_state() == VD::State::REPAIR_SUCCESSFUL);
CHECK(vd.get_issue_type() == VD::IssueType::NO_ISSUE_DETECTED);
CHECK_FALSE(has_missing_voronoi_vertices({poly}, vd));
} }
// This case is composed of two square polygons (contour and hole), and again one of the edges is divided into two parts by a // This case is composed of two square polygons (contour and hole), and again one of the edges is divided into two parts by a
@@ -1989,7 +2002,7 @@ TEST_CASE("Voronoi missing vertex 1", "[VoronoiMissingVertex1]")
// that for this case, through the missing Voronoi vertex is passing a finite edge between two internal Voronoin vertices. // that for this case, through the missing Voronoi vertex is passing a finite edge between two internal Voronoin vertices.
// Therefore, this missing Voronoi vertex and edge can be reconstructed using the intersection between the finite edge with the // Therefore, this missing Voronoi vertex and edge can be reconstructed using the intersection between the finite edge with the
// input polygon. // input polygon.
// Rotation of the polygons solves this problem. // VoronoiDiagram::construct_voronoi repairs it by rotating the input.
TEST_CASE("Voronoi missing vertex 2", "[VoronoiMissingVertex2]") TEST_CASE("Voronoi missing vertex 2", "[VoronoiMissingVertex2]")
{ {
Polygons poly = { Polygons poly = {
@@ -2021,13 +2034,20 @@ TEST_CASE("Voronoi missing vertex 2", "[VoronoiMissingVertex2]")
dump_voronoi_to_svg(debug_out_path("voronoi-missing-vertex2-out.svg").c_str(), vd, Points(), lines); dump_voronoi_to_svg(debug_out_path("voronoi-missing-vertex2-out.svg").c_str(), vd, Points(), lines);
#endif #endif
// REQUIRE(!has_missing_voronoi_vertices(poly, vd)); // Unrepaired, the diagram shows the defect. construct_voronoi detects it and repairs it by
// rotating the input.
VD raw_vd;
raw_vd.construct_voronoi(lines.begin(), lines.end(), false);
CHECK(has_missing_voronoi_vertices(poly, raw_vd));
CHECK(vd.get_state() == VD::State::REPAIR_SUCCESSFUL);
CHECK(vd.get_issue_type() == VD::IssueType::NO_ISSUE_DETECTED);
CHECK_FALSE(has_missing_voronoi_vertices(poly, vd));
} }
// This case is composed of two polygons, and again one of the edges is divided into two parts by a point that lies on this edge, // This case is composed of two polygons, and again one of the edges is divided into two parts by a point that lies on this edge,
// and for this point is Voronoi vertex missing. A difference between the previous cases and this case through the missing // and for this point is Voronoi vertex missing. A difference between the previous cases and this case through the missing
// Voronoi vertex is passing finite edge between one inner Voronoi vertex and one outer Voronoi vertex. // Voronoi vertex is passing finite edge between one inner Voronoi vertex and one outer Voronoi vertex.
// Rotating the polygon also help solve this problem. // VoronoiDiagram::construct_voronoi repairs it by rotating the input.
TEST_CASE("Voronoi missing vertex 3", "[VoronoiMissingVertex3]") TEST_CASE("Voronoi missing vertex 3", "[VoronoiMissingVertex3]")
{ {
Polygons poly = { Polygons poly = {
@@ -2055,20 +2075,27 @@ TEST_CASE("Voronoi missing vertex 3", "[VoronoiMissingVertex3]")
// polygons_rotate(poly, PI/180); // polygons_rotate(poly, PI/180);
// polygons_rotate(poly, PI/6); // polygons_rotate(poly, PI/6);
BoostVD vd; VD vd;
Lines lines = to_lines(poly); Lines lines = to_lines(poly);
construct_voronoi(lines.begin(), lines.end(), &vd); vd.construct_voronoi(lines.begin(), lines.end());
#ifdef VORONOI_DEBUG_OUT #ifdef VORONOI_DEBUG_OUT
dump_voronoi_to_svg(debug_out_path("voronoi-missing-vertex3-out.svg").c_str(), vd, Points(), lines); dump_voronoi_to_svg(debug_out_path("voronoi-missing-vertex3-out.svg").c_str(), vd, Points(), lines);
#endif #endif
// REQUIRE(!has_missing_voronoi_vertices(poly, vd)); // Unrepaired, the diagram shows the defect. construct_voronoi detects it and repairs it by
// rotating the input.
VD raw_vd;
raw_vd.construct_voronoi(lines.begin(), lines.end(), false);
CHECK(has_missing_voronoi_vertices(poly, raw_vd));
CHECK(vd.get_state() == VD::State::REPAIR_SUCCESSFUL);
CHECK(vd.get_issue_type() == VD::IssueType::NO_ISSUE_DETECTED);
CHECK_FALSE(has_missing_voronoi_vertices(poly, vd));
} }
// In this case, the Voronoi vertex (146873, -146873) is included twice. // In this case, the Voronoi vertex (146873, -146873) is included twice.
// Also, near to those duplicate Voronoi vertices is another Voronoi vertex (146872, -146872). // Also, near to those duplicate Voronoi vertices is another Voronoi vertex (146872, -146872).
// Rotating the polygon will help solve this problem, but then there arise three very close Voronoi vertices. // Rotating the polygon will help solve this problem, but then there arise three very close Voronoi vertices.
// Rotating of the input polygon will help solve this problem. // VoronoiDiagram::construct_voronoi repairs it by rotating the input.
TEST_CASE("Duplicate Voronoi vertices", "[Voronoi]") TEST_CASE("Duplicate Voronoi vertices", "[Voronoi]")
{ {
Polygon poly = { Polygon poly = {
@@ -2095,7 +2122,7 @@ TEST_CASE("Duplicate Voronoi vertices", "[Voronoi]")
dump_voronoi_to_svg(debug_out_path("voronoi-duplicate-vertices-out.svg").c_str(), vd, Points(), lines); dump_voronoi_to_svg(debug_out_path("voronoi-duplicate-vertices-out.svg").c_str(), vd, Points(), lines);
#endif #endif
[[maybe_unused]] auto has_duplicate_vertices = [](const VD &vd) -> bool { auto has_duplicate_vertices = [](const VD &vd) -> bool {
std::vector<Vec2d> vertices; std::vector<Vec2d> vertices;
for (const auto &vertex : vd.vertices()) for (const auto &vertex : vd.vertices())
vertices.emplace_back(Vec2d(vertex.x(), vertex.y())); vertices.emplace_back(Vec2d(vertex.x(), vertex.y()));
@@ -2104,14 +2131,21 @@ TEST_CASE("Duplicate Voronoi vertices", "[Voronoi]")
return std::unique(vertices.begin(), vertices.end()) != vertices.end(); return std::unique(vertices.begin(), vertices.end()) != vertices.end();
}; };
// REQUIRE(!has_duplicate_vertices(vd)); // Unrepaired, the diagram shows the defect. construct_voronoi detects it and repairs it by
// rotating the input.
VD raw_vd;
raw_vd.construct_voronoi(lines.begin(), lines.end(), false);
CHECK(has_duplicate_vertices(raw_vd));
CHECK(vd.get_state() == VD::State::REPAIR_SUCCESSFUL);
CHECK(vd.get_issue_type() == VD::IssueType::NO_ISSUE_DETECTED);
CHECK_FALSE(has_duplicate_vertices(vd));
} }
// In this case, there are three very close Voronoi vertices like in the previous test case after rotation. There is also one // In this case, there are three very close Voronoi vertices like in the previous test case after rotation. There is also one
// missing Voronoi vertex. One infinity-edge (after clip [(146872, -70146871), (146872, -146871)]) passes through this missing // missing Voronoi vertex. One infinity-edge (after clip [(146872, -70146871), (146872, -146871)]) passes through this missing
// Voronoi vertex. This infinite edge [(146872, -70146871), (146872, -146871)] and edge [(146873, -146873), (0, 0)] are intersecting. // Voronoi vertex. This infinite edge [(146872, -70146871), (146872, -146871)] and edge [(146873, -146873), (0, 0)] are intersecting.
// They intersect probably because the three points are very close to each other, with a combination of the missing Voronoi vertex. // They intersect probably because the three points are very close to each other, with a combination of the missing Voronoi vertex.
// Rotating of the input polygon will help solve this problem. // VoronoiDiagram::construct_voronoi repairs it by rotating the input.
TEST_CASE("Intersecting Voronoi edges", "[Voronoi]") TEST_CASE("Intersecting Voronoi edges", "[Voronoi]")
{ {
Polygon poly = { Polygon poly = {
@@ -2135,7 +2169,7 @@ TEST_CASE("Intersecting Voronoi edges", "[Voronoi]")
dump_voronoi_to_svg(debug_out_path("voronoi-intersecting-edges-out.svg").c_str(), vd, Points(), lines); dump_voronoi_to_svg(debug_out_path("voronoi-intersecting-edges-out.svg").c_str(), vd, Points(), lines);
#endif #endif
[[maybe_unused]] auto has_intersecting_edges = [](const Polygon &poly, const VD &vd) -> bool { auto has_intersecting_edges = [](const Polygon &poly, const VD &vd) -> bool {
BoundingBox bbox = get_extents(poly); BoundingBox bbox = get_extents(poly);
const double bbox_dim_max = double(std::max(bbox.size().x(), bbox.size().y())); const double bbox_dim_max = double(std::max(bbox.size().x(), bbox.size().y()));
@@ -2166,5 +2200,12 @@ TEST_CASE("Intersecting Voronoi edges", "[Voronoi]")
return false; return false;
}; };
// REQUIRE(!has_intersecting_edges(poly, vd)); // Unrepaired, the diagram shows the defect. construct_voronoi detects it and repairs it by
// rotating the input.
VD raw_vd;
raw_vd.construct_voronoi(lines.begin(), lines.end(), false);
CHECK(has_intersecting_edges(poly, raw_vd));
CHECK(vd.get_state() == VD::State::REPAIR_SUCCESSFUL);
CHECK(vd.get_issue_type() == VD::IssueType::NO_ISSUE_DETECTED);
CHECK_FALSE(has_intersecting_edges(poly, vd));
} }
+1 -1
View File
@@ -6,7 +6,7 @@ add_executable(${_TEST_NAME}_tests
test_creality_cfs_match.cpp test_creality_cfs_match.cpp
test_dev_mapping.cpp test_dev_mapping.cpp
test_device_access.cpp test_device_access.cpp
test_moonraker_discovery.cpp test_moonraker_printer_agent.cpp
test_network_discovery.cpp test_network_discovery.cpp
test_filament_bitmap_utils.cpp test_filament_bitmap_utils.cpp
test_colour_picker_field.cpp test_colour_picker_field.cpp
+105 -3
View File
@@ -1,6 +1,7 @@
// How ImGuiWrapper turns wx mouse events into ImGui input. ImGuiWrapper lives in libslic3r_gui; this is // How ImGuiWrapper turns wx mouse events into ImGui input, and when it reports that ImGui owns a key.
// the suite that links it. Same Windows include prologue as test_texture_color_palette.cpp (wx pulls in // ImGuiWrapper lives in libslic3r_gui; this is the suite that links it. Same Windows include prologue
// <windows.h>; keep WIN32_LEAN_AND_MEAN / NOMINMAX ahead of it). // as test_texture_color_palette.cpp (wx pulls in <windows.h>; keep WIN32_LEAN_AND_MEAN / NOMINMAX
// ahead of it).
#ifdef WIN32 #ifdef WIN32
#ifndef WIN32_LEAN_AND_MEAN #ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN #define WIN32_LEAN_AND_MEAN
@@ -17,12 +18,15 @@
#include <catch2/matchers/catch_matchers_floating_point.hpp> #include <catch2/matchers/catch_matchers_floating_point.hpp>
#include <imgui/imgui.h> #include <imgui/imgui.h>
#include <imgui/imgui_internal.h>
#include <wx/event.h> #include <wx/event.h>
#include "slic3r/GUI/ImGuiWrapper.hpp" #include "slic3r/GUI/ImGuiWrapper.hpp"
#include "slic3r/GUI/KeyChord.hpp"
using Catch::Matchers::WithinAbs; using Catch::Matchers::WithinAbs;
using Slic3r::GUI::ImGuiWrapper; using Slic3r::GUI::ImGuiWrapper;
using Slic3r::GUI::KeyChord;
namespace { namespace {
@@ -41,6 +45,45 @@ float wheel_after(int rotation, wxMouseWheelAxis axis)
return ImGui::GetIO().MouseWheel; return ImGui::GetIO().MouseWheel;
} }
// Runs ImGui frames without OpenGL by building the font atlas on the CPU.
struct ImGuiFrames
{
ImGuiWrapper imgui;
ImGuiFrames()
{
imgui.set_display_size(800.f, 600.f);
unsigned char* pixels = nullptr;
int width = 0, height = 0;
ImGui::GetIO().Fonts->GetTexDataAsRGBA32(&pixels, &width, &height);
}
template<class Draw> void frame(Draw draw)
{
ImGui::NewFrame();
draw();
ImGui::EndFrame();
}
// SetKeyboardFocusHere() activates the field in the next frame that draws it.
void draw_input(bool focus)
{
ImGui::Begin("window");
if (focus)
ImGui::SetKeyboardFocusHere();
ImGui::InputText("##text", text, sizeof(text));
ImGui::End();
}
char text[16] = "";
};
void draw_modal()
{
if (ImGui::BeginPopupModal("modal"))
ImGui::EndPopup();
}
} // namespace } // namespace
TEST_CASE("a vertical wheel event steps ImGui's wheel in its own direction", "[ImGuiWrapper]") TEST_CASE("a vertical wheel event steps ImGui's wheel in its own direction", "[ImGuiWrapper]")
@@ -59,3 +102,62 @@ TEST_CASE("a wheel event with no rotation or on the horizontal axis does not ste
})); }));
CHECK_THAT(wheel_after(rotation, axis), WithinAbs(0.0, 0.0)); CHECK_THAT(wheel_after(rotation, axis), WithinAbs(0.0, 0.0));
} }
TEST_CASE("an InputText counts as active only while it holds the keyboard", "[ImGuiWrapper]")
{
ImGuiFrames f;
f.frame([&] { f.draw_input(false); });
CHECK_FALSE(f.imgui.text_input_active());
f.frame([&] { f.draw_input(true); });
f.frame([&] { f.draw_input(false); });
CHECK(f.imgui.text_input_active());
f.frame([] {});
f.frame([] {});
CHECK_FALSE(f.imgui.text_input_active());
}
TEST_CASE("a modal counts as open from its first frame until a frame no longer draws it", "[ImGuiWrapper]")
{
ImGuiFrames f;
CHECK_FALSE(f.imgui.modal_popup_open());
f.frame([] {
ImGui::OpenPopup("modal");
draw_modal();
});
CHECK(f.imgui.modal_popup_open());
// A frame opened by input, before anything is drawn in it.
ImGui::NewFrame();
CHECK(f.imgui.modal_popup_open());
draw_modal();
ImGui::EndFrame();
CHECK(f.imgui.modal_popup_open());
// A frame that no longer draws it, as when its canvas is hidden.
f.frame([] {});
REQUIRE(ImGui::GetTopMostPopupModal() != nullptr);
CHECK_FALSE(f.imgui.modal_popup_open());
}
TEST_CASE("ImGui owns every key under a modal and text-editing keys in an active InputText", "[ImGuiWrapper]")
{
ImGuiFrames f;
const KeyChord letter{ 'A' };
const KeyChord save{ 'S', wxMOD_CONTROL };
CHECK_FALSE(f.imgui.owns_key(letter));
CHECK_FALSE(f.imgui.owns_key(save));
f.frame([&] { f.draw_input(true); });
f.frame([&] { f.draw_input(false); });
CHECK(f.imgui.owns_key(letter));
CHECK_FALSE(f.imgui.owns_key(save));
f.frame([] {
ImGui::OpenPopup("modal");
draw_modal();
});
CHECK(f.imgui.owns_key(save));
}
@@ -1,51 +0,0 @@
#include <catch2/catch_test_macros.hpp>
#include <string>
#include "slic3r/Utils/MoonrakerPrinterAgent.hpp"
using namespace Slic3r;
namespace {
class DiscoveryAgent : public MoonrakerPrinterAgent
{
public:
using MoonrakerPrinterAgent::MoonrakerPrinterAgent;
using MoonrakerPrinterAgent::device_info;
mutable unsigned fetch_count = 0;
protected:
bool fetch_device_info(const std::string&, const std::string&, MoonrakerDeviceInfo& info, std::string&) const override
{
++fetch_count;
info.dev_name = "Test printer";
return true;
}
};
} // namespace
TEST_CASE("Moonraker discovery waits for a complete device identity", "[MoonrakerDiscovery]")
{
DiscoveryAgent agent("");
unsigned announcements = 0;
nlohmann::json announcement;
agent.set_on_ssdp_msg_fn([&](std::string payload) {
++announcements;
announcement = nlohmann::json::parse(payload);
});
agent.start_discovery(true, false);
REQUIRE(announcements == 0);
REQUIRE(agent.fetch_count == 0);
agent.device_info.dev_id = "127.0.0.1:7125";
agent.device_info.dev_ip = agent.device_info.dev_id;
agent.device_info.base_url = "http://127.0.0.1:7125";
agent.start_discovery(true, false);
REQUIRE(announcements == 1);
REQUIRE(agent.fetch_count == 1);
REQUIRE(announcement.at("dev_id") == agent.device_info.dev_id);
REQUIRE(announcement.at("dev_ip") == agent.device_info.dev_ip);
REQUIRE(announcement.at("dev_name") == "Test printer");
}
@@ -0,0 +1,580 @@
#include <catch2/catch_message.hpp>
#include <catch2/catch_test_macros.hpp>
#include <catch2/generators/catch_generators.hpp>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cstddef>
#include <functional>
#include <future>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
#include <utility>
#include <vector>
#include <boost/asio/buffer.hpp>
#include <boost/asio/io_context.hpp>
#include <boost/asio/ip/address.hpp>
#include <boost/asio/ip/tcp.hpp>
#include <boost/beast/core/error.hpp>
#include <boost/beast/core/flat_buffer.hpp>
#include <boost/beast/http/message.hpp>
#include <boost/beast/http/read.hpp>
#include <boost/beast/http/status.hpp>
#include <boost/beast/http/string_body.hpp>
#include <boost/beast/http/write.hpp>
#include <boost/beast/websocket/error.hpp>
#include <boost/beast/websocket/rfc6455.hpp>
#include <boost/beast/websocket/stream.hpp>
#include <nlohmann/json.hpp>
#include "slic3r/Utils/IPrinterAgent.hpp"
#include "slic3r/Utils/MoonrakerPrinterAgent.hpp"
using namespace Slic3r;
namespace {
namespace beast = boost::beast;
namespace http = beast::http;
namespace websocket = beast::websocket;
namespace net = boost::asio;
using tcp = net::ip::tcp;
using namespace std::chrono_literals;
class DiscoveryAgent : public MoonrakerPrinterAgent
{
public:
using MoonrakerPrinterAgent::MoonrakerPrinterAgent;
using MoonrakerPrinterAgent::device_info;
mutable unsigned fetch_count = 0;
protected:
bool fetch_device_info(const std::string&, const std::string&, MoonrakerDeviceInfo& info, std::string&) const override
{
++fetch_count;
info.dev_name = "Test printer";
return true;
}
};
class StreamAgent : public MoonrakerPrinterAgent
{
public:
using MoonrakerPrinterAgent::MoonrakerPrinterAgent;
using MoonrakerPrinterAgent::start_status_stream;
using MoonrakerPrinterAgent::stop_status_stream;
using MoonrakerPrinterAgent::ws_timings;
protected:
bool init_device_info(
const std::string& dev_id, const std::string& dev_ip, const std::string&, const std::string&, bool, const std::string& port) override
{
device_info = MoonrakerDeviceInfo{};
device_info.dev_id = dev_id;
device_info.base_url = "http://" + dev_ip + ":" + port;
return true;
}
bool fetch_device_info(const std::string&, const std::string&, MoonrakerDeviceInfo& info, std::string&) const override
{
info.dev_name = "Test printer";
return true;
}
};
enum class Behavior {
SilentBeforeHandshake, // Accepts the connection and never answers
Silent, // Completes the upgrade, then never reads or sends
AnswersPings, // Reports a status, then answers pings and sends nothing else
AnswersPingsWithoutStatus, // Answers pings but never reports a status
Chatters, // Reports a status, then answers pings and sends notifications that carry no status
HangsUp, // Reports a status, then closes the websocket
HangsUpWithoutAnswer, // Closes the websocket without sending anything
IgnoresClose, // Reports Klippy disconnected, then never answers the close
StallsObjectList, // Never answers the stream's object list request
StallsStatusQuery, // Never answers HTTP requests until released, then hangs up on them
};
class FakePrinter
{
public:
explicit FakePrinter(Behavior behavior) : m_behavior(behavior)
{
m_accept_thread = std::thread([this] { accept_loop(); });
}
~FakePrinter()
{
release();
m_stopping = true;
// Wakes the blocking accept.
tcp::socket wake{m_accept_ioc};
beast::error_code ec;
wake.connect(m_acceptor.local_endpoint(), ec);
m_accept_thread.join();
for (std::thread& connection : m_connections)
connection.join();
}
std::string host() const { return "127.0.0.1"; }
std::string port() const { return std::to_string(m_acceptor.local_endpoint().port()); }
std::string base_url() const { return "http://" + host() + ":" + port(); }
// A connection is ready once it reaches the state its behavior waits in.
bool wait_for_ready_connections(int count, std::chrono::milliseconds timeout = 10s)
{
std::unique_lock<std::mutex> lock(m_mutex);
return m_changed.wait_for(lock, timeout, [&] { return m_ready >= count; });
}
bool wait_for_http_requests(int count)
{
std::unique_lock<std::mutex> lock(m_mutex);
return m_changed.wait_for(lock, 10s, [&] { return m_http_requests >= count; });
}
// Waits until the printer, once released, has hung up on `count` stalled HTTP requests.
bool wait_for_http_hang_ups(int count)
{
std::unique_lock<std::mutex> lock(m_mutex);
return m_changed.wait_for(lock, 10s, [&] { return m_http_hung_up >= count; });
}
int close_frames()
{
std::lock_guard<std::mutex> lock(m_mutex);
return m_close_frames;
}
// Hangs up every connection that is waiting.
void release()
{
std::lock_guard<std::mutex> lock(m_mutex);
m_released = true;
m_changed.notify_all();
}
private:
void accept_loop()
{
for (;;) {
// Each connection gets its own io_context, so a connection that runs one never runs another's handlers.
auto ioc = std::make_unique<net::io_context>();
tcp::socket socket{*ioc};
beast::error_code ec;
m_acceptor.accept(socket, ec);
if (ec || m_stopping)
return;
m_connections.emplace_back(
[this, ioc = std::move(ioc), socket = std::move(socket)]() mutable { serve(*ioc, std::move(socket)); });
}
}
void serve(net::io_context& ioc, tcp::socket socket)
{
if (m_behavior == Behavior::SilentBeforeHandshake) {
mark_ready();
wait_for_release();
return;
}
beast::flat_buffer buffer;
http::request<http::string_body> request;
beast::error_code ec;
http::read(socket, buffer, request, ec);
if (ec)
return;
// The agent also queries the printer's status and object list over plain HTTP.
if (!websocket::is_upgrade(request)) {
serve_http(socket, request);
return;
}
websocket::stream<tcp::socket> ws{std::move(socket)};
ws.accept(request, ec);
// Without its object list the stream never sends the subscribe request.
const int requests = m_behavior == Behavior::StallsObjectList ? 1 : 2;
for (int i = 0; i < requests && !ec; ++i) {
beast::flat_buffer message;
ws.read(message, ec);
}
if (!ec && (m_behavior == Behavior::AnswersPings || m_behavior == Behavior::Chatters || m_behavior == Behavior::HangsUp)) {
const std::string status =
nlohmann::json{{"jsonrpc", "2.0"}, {"id", 1}, {"result", {{"status", {{"print_stats", {{"state", "standby"}}}}}}}}.dump();
ws.write(net::buffer(status), ec);
}
if (ec)
return;
switch (m_behavior) {
case Behavior::AnswersPings:
case Behavior::AnswersPingsWithoutStatus:
case Behavior::Chatters:
mark_ready();
answer_pings(ioc, ws, m_behavior == Behavior::Chatters);
break;
case Behavior::HangsUp:
case Behavior::HangsUpWithoutAnswer:
ws.close(websocket::close_code::normal, ec);
mark_ready();
break;
case Behavior::IgnoresClose: {
const std::string notification = nlohmann::json{{"jsonrpc", "2.0"}, {"method", "notify_klippy_disconnected"}}.dump();
ws.write(net::buffer(notification), ec);
mark_ready();
wait_for_release();
break;
}
case Behavior::StallsObjectList: answer_pings(ioc, ws, false); break;
default: mark_ready(); wait_for_release();
}
}
void serve_http(tcp::socket& socket, const http::request<http::string_body>& request)
{
{
std::lock_guard<std::mutex> lock(m_mutex);
++m_http_requests;
m_changed.notify_all();
}
if (m_behavior == Behavior::StallsObjectList) {
mark_ready();
wait_for_release();
return;
}
if (m_behavior == Behavior::StallsStatusQuery) {
wait_for_release();
beast::error_code ec;
socket.close(ec);
std::lock_guard<std::mutex> lock(m_mutex);
++m_http_hung_up;
m_changed.notify_all();
return;
}
http::response<http::string_body> response{http::status::not_found, request.version()};
response.prepare_payload();
beast::error_code ec;
http::write(socket, response, ec);
}
// Keeps a read pending, which answers pings, until released or closed, and with `chatter` also sends a notification every 50 ms.
void answer_pings(net::io_context& ioc, websocket::stream<tcp::socket>& ws, bool chatter)
{
const std::string notification = nlohmann::json{{"jsonrpc", "2.0"},
{"method", "notify_proc_stat_update"},
{"params", nlohmann::json::array({nlohmann::json::object()})}}
.dump();
beast::flat_buffer message;
bool reading = true;
bool writing = false;
std::function<void()> read_next = [&] {
ws.async_read(message, [&](beast::error_code ec, std::size_t) {
message.clear();
if (ec == websocket::error::closed) {
std::lock_guard<std::mutex> lock(m_mutex);
++m_close_frames;
}
if (ec)
reading = false;
else
read_next();
});
};
read_next();
auto next_notification = std::chrono::steady_clock::now();
while (reading && !wait_for_release(10ms)) {
if (chatter && !writing && std::chrono::steady_clock::now() >= next_notification) {
writing = true;
next_notification += 50ms;
ws.async_write(net::buffer(notification), [&](beast::error_code, std::size_t) { writing = false; });
}
ioc.restart();
ioc.run_for(20ms);
}
beast::error_code ignored;
ws.next_layer().close(ignored);
while (reading || writing) {
ioc.restart();
ioc.run_for(20ms);
}
}
void mark_ready()
{
std::lock_guard<std::mutex> lock(m_mutex);
++m_ready;
m_changed.notify_all();
}
void wait_for_release()
{
std::unique_lock<std::mutex> lock(m_mutex);
m_changed.wait(lock, [this] { return m_released; });
}
bool wait_for_release(std::chrono::milliseconds timeout)
{
std::unique_lock<std::mutex> lock(m_mutex);
return m_changed.wait_for(lock, timeout, [this] { return m_released; });
}
const Behavior m_behavior;
net::io_context m_accept_ioc;
tcp::acceptor m_acceptor{m_accept_ioc, {net::ip::make_address("127.0.0.1"), 0}};
std::atomic<bool> m_stopping{false};
std::thread m_accept_thread;
std::vector<std::thread> m_connections;
std::mutex m_mutex;
std::condition_variable m_changed;
int m_ready = 0;
int m_http_requests = 0;
int m_http_hung_up = 0;
int m_close_frames = 0;
bool m_released = false;
};
// Records when the agent dispatches a status report.
class Reports
{
public:
void attach(StreamAgent& agent)
{
agent.set_on_local_message_fn([this](std::string, std::string) {
std::lock_guard<std::mutex> lock(m_mutex);
m_times.push_back(std::chrono::steady_clock::now());
m_changed.notify_all();
});
}
bool wait_for(std::size_t count)
{
std::unique_lock<std::mutex> lock(m_mutex);
return m_changed.wait_for(lock, 10s, [&] { return m_times.size() >= count; });
}
std::vector<std::chrono::steady_clock::time_point> times()
{
std::lock_guard<std::mutex> lock(m_mutex);
return m_times;
}
private:
std::mutex m_mutex;
std::condition_variable m_changed;
std::vector<std::chrono::steady_clock::time_point> m_times;
};
// A printer and a stream agent connected to it.
struct StreamFixture
{
explicit StreamFixture(Behavior behavior) : printer(behavior) {}
// Runs before the members are destroyed, so the agent's destructor never joins a stream the printer still holds open.
~StreamFixture() { printer.release(); }
void start() { agent.start_status_stream("printer", printer.base_url(), ""); }
// Stops the stream, giving it `limit` to return before the printer hangs up on it.
bool stops_within(std::chrono::milliseconds limit)
{
auto stopped = std::async(std::launch::async, [this] { agent.stop_status_stream(); });
const bool in_time = stopped.wait_for(limit) == std::future_status::ready;
printer.release();
stopped.wait();
return in_time;
}
FakePrinter printer;
StreamAgent agent{""};
};
} // namespace
TEST_CASE("Moonraker discovery waits for a complete device identity", "[MoonrakerPrinterAgent][Discovery]")
{
DiscoveryAgent agent("");
unsigned announcements = 0;
nlohmann::json announcement;
agent.set_on_ssdp_msg_fn([&](std::string payload) {
++announcements;
announcement = nlohmann::json::parse(payload);
});
agent.start_discovery(true, false);
REQUIRE(announcements == 0);
REQUIRE(agent.fetch_count == 0);
agent.device_info.dev_id = "127.0.0.1:7125";
agent.device_info.dev_ip = agent.device_info.dev_id;
agent.device_info.base_url = "http://127.0.0.1:7125";
agent.start_discovery(true, false);
REQUIRE(announcements == 1);
REQUIRE(agent.fetch_count == 1);
REQUIRE(announcement.at("dev_id") == agent.device_info.dev_id);
REQUIRE(announcement.at("dev_ip") == agent.device_info.dev_ip);
REQUIRE(announcement.at("dev_name") == "Test printer");
}
TEST_CASE("Stopping the status stream gives a silent printer no more than its timeouts", "[MoonrakerPrinterAgent][StatusStream]")
{
// An open websocket gets the 200 ms close timeout, and a stalled object list request up to 1 s of curl progress to see the stop.
const auto [behavior, limit] = GENERATE(table<Behavior, std::chrono::milliseconds>({
{Behavior::SilentBeforeHandshake, 500ms},
{Behavior::Silent, 1000ms},
{Behavior::StallsObjectList, 2000ms},
}));
CAPTURE(static_cast<int>(behavior));
StreamFixture f(behavior);
f.agent.ws_timings.close = 200ms;
f.start();
REQUIRE(f.printer.wait_for_ready_connections(1));
CHECK(f.stops_within(limit));
}
TEST_CASE("Stopping the status stream sends the printer a close frame", "[MoonrakerPrinterAgent][StatusStream]")
{
// A stop while the stream reads, and one while it waits on the object list request.
const Behavior behavior = GENERATE(Behavior::AnswersPings, Behavior::StallsObjectList);
CAPTURE(static_cast<int>(behavior));
StreamFixture f(behavior);
f.start();
REQUIRE(f.printer.wait_for_ready_connections(1));
CHECK(f.stops_within(3s));
CHECK(f.printer.close_frames() == 1);
}
TEST_CASE("Stopping the status stream does not wait out the reconnect delay", "[MoonrakerPrinterAgent][StatusStream]")
{
StreamFixture f(Behavior::HangsUp);
f.agent.ws_timings.reconnect_delay = 10s;
f.start();
REQUIRE(f.printer.wait_for_ready_connections(1));
// Lets the stream reach its reconnect wait.
std::this_thread::sleep_for(200ms);
CHECK(f.stops_within(3s));
}
TEST_CASE("The status stream reconnects when the printer stops answering", "[MoonrakerPrinterAgent][StatusStream]")
{
// An unanswered close ends at whichever of the handshake and close timeouts is shorter.
const auto [behavior, handshake, close] = GENERATE(table<Behavior, std::chrono::milliseconds, std::chrono::milliseconds>({
{Behavior::Silent, 300ms, 1000ms},
{Behavior::IgnoresClose, 300ms, 1000ms},
{Behavior::IgnoresClose, 1000ms, 200ms},
}));
CAPTURE(static_cast<int>(behavior), handshake.count(), close.count());
StreamFixture f(behavior);
f.agent.ws_timings.handshake = handshake;
f.agent.ws_timings.close = close;
f.agent.ws_timings.idle = 400ms;
f.agent.ws_timings.reconnect_delay = 20ms;
f.start();
CHECK(f.printer.wait_for_ready_connections(2));
CHECK(f.stops_within(3s));
}
TEST_CASE("The status stream reconnects at the shortest delay while the printer answers", "[MoonrakerPrinterAgent][StatusStream]")
{
StreamFixture f(Behavior::HangsUp);
f.agent.ws_timings.reconnect_delay = 50ms;
f.start();
// Twelve 50 ms waits; a backoff that kept doubling would need over 10 s.
CHECK(f.printer.wait_for_ready_connections(12, 3s));
CHECK(f.stops_within(3s));
}
TEST_CASE("The status stream keeps retrying a printer that never answers", "[MoonrakerPrinterAgent][StatusStream]")
{
StreamFixture f(Behavior::HangsUpWithoutAnswer);
f.agent.ws_timings.reconnect_delay = 1ms;
f.start();
// The backoff stops doubling at 32 ms, so 20 attempts take well under the wait.
CHECK(f.printer.wait_for_ready_connections(20));
CHECK(f.stops_within(3s));
}
TEST_CASE("A callback on the stream thread can stop the status stream", "[MoonrakerPrinterAgent][StatusStream]")
{
std::promise<void> stopped;
std::once_flag stopped_once;
StreamFixture f(Behavior::AnswersPings);
f.agent.set_on_local_message_fn([&](std::string, std::string) {
std::call_once(stopped_once, [&] {
f.agent.stop_status_stream();
stopped.set_value();
});
});
f.start();
REQUIRE(stopped.get_future().wait_for(5s) == std::future_status::ready);
CHECK(f.stops_within(3s));
}
TEST_CASE("A quiet printer that is still up keeps reporting its status", "[MoonrakerPrinterAgent][StatusStream]")
{
Reports reports;
StreamFixture f(Behavior::AnswersPings);
f.agent.ws_timings.idle = 400ms;
f.agent.ws_timings.heartbeat = 300ms;
reports.attach(f.agent);
f.start();
// The status the printer sent, then two heartbeats.
REQUIRE(reports.wait_for(3));
const auto times = reports.times();
// The steady clock is compared in whole milliseconds, so allow 1 ms of rounding.
CHECK(times[1] - times[0] >= 299ms);
CHECK(times[2] - times[1] >= 299ms);
CHECK(f.stops_within(3s));
}
TEST_CASE("The status stream sends no heartbeat while the printer has nothing new to report", "[MoonrakerPrinterAgent][StatusStream]")
{
const Behavior behavior = GENERATE(Behavior::Chatters, Behavior::AnswersPingsWithoutStatus);
CAPTURE(static_cast<int>(behavior));
Reports reports;
StreamFixture f(behavior);
f.agent.ws_timings.idle = 400ms;
f.agent.ws_timings.heartbeat = 800ms;
reports.attach(f.agent);
f.start();
REQUIRE(f.printer.wait_for_ready_connections(1));
// The chattering printer's status, and nothing from the one that never sent a status.
const std::size_t expected = behavior == Behavior::Chatters ? 1 : 0;
std::this_thread::sleep_for(1500ms);
CHECK(reports.times().size() == expected);
CHECK(f.stops_within(3s));
}
TEST_CASE("A disconnect during the connection queries keeps the status stream from starting", "[MoonrakerPrinterAgent][StatusStream]")
{
StreamFixture f(Behavior::StallsStatusQuery);
PrinterConnectionParams params;
params.dev_id = "printer";
params.host = f.printer.host();
params.port = f.printer.port();
f.agent.connect_printer(params);
REQUIRE(f.printer.wait_for_http_requests(1));
f.agent.disconnect_printer();
f.printer.release();
// The query has returned once the printer hangs up on it, and a stream would connect right after.
REQUIRE(f.printer.wait_for_http_hang_ups(1));
CHECK_FALSE(f.printer.wait_for_ready_connections(1, 500ms));
}
@@ -8,6 +8,7 @@
#include <slic3r/plugin/PythonPluginInterface.hpp> #include <slic3r/plugin/PythonPluginInterface.hpp>
#include <catch2/catch_test_macros.hpp> #include <catch2/catch_test_macros.hpp>
#include <catch2/catch_message.hpp>
#include "plugin_test_utils.hpp" #include "plugin_test_utils.hpp"
#include <pybind11/pytypes.h> #include <pybind11/pytypes.h>
#include <pybind11/eval.h> #include <pybind11/eval.h>
@@ -17,6 +18,12 @@
#include <pybind11/embed.h> #include <pybind11/embed.h>
#include <pybind11/pybind11.h> #include <pybind11/pybind11.h>
#include <boost/filesystem/operations.hpp>
#include <boost/filesystem/path.hpp>
#include <chrono>
#include <fstream>
#include <ios>
#include <memory> #include <memory>
#include <string> #include <string>
#include <pybind11/cast.h> #include <pybind11/cast.h>
@@ -105,6 +112,72 @@ PluginCapabilityId capability_id(PluginCapabilityType type, const char* name, co
return {type, name, plugin_key}; return {type, name, plugin_key};
} }
// restore_config_response looks the capability up in PluginManager, so the restore tests load a real
// plugin rather than building a detached capability.
const char* const RESTORE_PLUGIN_KEY = "Restore_Plugin";
const char* const RESTORE_PLUGIN_SOURCE = R"PY(# /// script
# requires-python = ">=3.12"
#
# [tool.orcaslicer.plugin]
# name = "Restore Plugin"
# description = "Restore defaults fixture"
# author = "OrcaSlicer"
# version = "1.0"
# type = "script"
# ///
import orca
class Target(orca.script.ScriptPluginCapabilityBase):
def get_name(self): return "Target"
def execute(self, ctx): return orca.ExecutionResult.success()
def get_default_config(self): return {"speed": 1}
class Bystander(orca.script.ScriptPluginCapabilityBase):
def get_name(self): return "Bystander"
def execute(self, ctx): return orca.ExecutionResult.success()
def get_default_config(self): return {"speed": 2}
# Loading seeds defaults through get_default_config and fails the plugin if it raises, so this one
# answers the load and raises from then on.
class Raising(orca.script.ScriptPluginCapabilityBase):
calls = 0
def get_name(self): return "Raising"
def execute(self, ctx): return orca.ExecutionResult.success()
def get_default_config(self):
Raising.calls += 1
if Raising.calls > 1:
raise RuntimeError("boom")
return {}
@orca.plugin
class RestorePackage(orca.base):
def register_capabilities(self):
orca.register_capability(Target)
orca.register_capability(Bystander)
orca.register_capability(Raising)
)PY";
// Writes the restore fixture into {data_dir}/orca_plugins and loads it, returning the load error
// (empty on success). Call without holding the GIL: the load runs on a worker thread.
std::string load_restore_plugin(const ScopedDataDir& data_dir_guard)
{
const boost::filesystem::path plugin_dir = data_dir_guard.plugins_dir() / RESTORE_PLUGIN_KEY;
boost::filesystem::create_directories(plugin_dir);
{
std::ofstream out((plugin_dir / (std::string(RESTORE_PLUGIN_KEY) + ".py")).string(), std::ios::binary);
out << RESTORE_PLUGIN_SOURCE;
}
PluginManager& manager = PluginManager::instance();
manager.discover_plugins(/*async=*/false, /*clear=*/true);
manager.load_plugin(RESTORE_PLUGIN_KEY, /*skip_deps=*/true);
std::string error;
if (!manager.wait_for_plugin_load(RESTORE_PLUGIN_KEY, std::chrono::seconds(120), error) && error.empty())
error = "the plugin did not load";
return error;
}
} // namespace } // namespace
TEST_CASE("Capability config API is exposed on every Python capability", "[PluginConfig][Python]") TEST_CASE("Capability config API is exposed on every Python capability", "[PluginConfig][Python]")
@@ -357,26 +430,27 @@ TEST_CASE("Restoring defaults overwrites only the target capability", "[PluginCo
ScopedPluginManager plugin_system; // declared first: destroyed last ScopedPluginManager plugin_system; // declared first: destroyed last
if (!plugin_system.initialized) if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
py::gil_scoped_acquire gil; // released before plugin_system's destructor shuts Python down
ScopedDataDir data_dir_guard("plugin-config-py-restore"); ScopedDataDir data_dir_guard("plugin-config-py-restore");
host_config().load(); host_config().load();
const std::string load_error = load_restore_plugin(data_dir_guard);
INFO("load error: " << load_error);
REQUIRE(load_error.empty());
const std::string defaults_body = " def get_name(self): return 'cap'\n" const PluginCapabilityId target = capability_id(PluginCapabilityType::Script, "Target", RESTORE_PLUGIN_KEY);
" def get_default_config(self): return {'speed': 1}\n"; const PluginCapabilityId bystander = capability_id(PluginCapabilityType::Script, "Bystander", RESTORE_PLUGIN_KEY);
py::object target = make_capability("RestoreTargetCap", defaults_body, "plugin_a", "cap_a");
py::object bystander = make_capability("RestoreBystanderCap", defaults_body, "plugin_b", "cap_a");
const json edited = json{{"speed", 99}}; const json edited = json{{"speed", 99}};
REQUIRE(py_save_config(target, edited)); REQUIRE(host_config().store_capability_config(target, edited));
REQUIRE(py_save_config(bystander, edited)); REQUIRE(host_config().store_capability_config(bystander, edited));
// What PluginsDialog::restore_capability_config does: ask the capability, store the answer. const json response = PluginConfig::restore_config_response(target);
auto iface = as_interface(target);
REQUIRE(host_config().store_capability_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"), iface->get_default_config()));
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->config == json{{"speed", 1}}); CHECK(response["ok"] == true);
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_b"))->config == json{{"speed", 99}}); CHECK(response["config"] == json{{"speed", 1}});
CHECK(host_config().get_config(target)->config == json{{"speed", 1}});
CHECK(host_config().get_config(bystander)->config == edited);
PluginManager::instance().unload_plugin(RESTORE_PLUGIN_KEY);
} }
TEST_CASE("A raising get_default_config leaves the stored config untouched", "[PluginConfig][Python]") TEST_CASE("A raising get_default_config leaves the stored config untouched", "[PluginConfig][Python]")
@@ -384,24 +458,23 @@ TEST_CASE("A raising get_default_config leaves the stored config untouched", "[P
ScopedPluginManager plugin_system; // declared first: destroyed last ScopedPluginManager plugin_system; // declared first: destroyed last
if (!plugin_system.initialized) if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
py::gil_scoped_acquire gil; // released before plugin_system's destructor shuts Python down
ScopedDataDir data_dir_guard("plugin-config-py-restore-raise"); ScopedDataDir data_dir_guard("plugin-config-py-restore-raise");
host_config().load(); host_config().load();
const std::string load_error = load_restore_plugin(data_dir_guard);
INFO("load error: " << load_error);
REQUIRE(load_error.empty());
py::object cap = make_capability("RaisingDefaultsCap", const PluginCapabilityId raising = capability_id(PluginCapabilityType::Script, "Raising", RESTORE_PLUGIN_KEY);
" def get_name(self): return 'cap_a'\n" REQUIRE(host_config().store_capability_config(raising, json{{"keep", "me"}}));
" def get_default_config(self): raise RuntimeError('boom')\n",
"plugin_a", "cap_a");
REQUIRE(py_save_config(cap, json{{"keep", "me"}})); const json response = PluginConfig::restore_config_response(raising);
auto iface = as_interface(cap); CHECK(response["ok"] == false);
REQUIRE(iface); CHECK_FALSE(response["error"].get<std::string>().empty());
CHECK_THROWS_AS(iface->get_default_config(), py::error_already_set); CHECK(host_config().get_config(raising)->config == json{{"keep", "me"}});
// The dialog stores nothing when the hook throws: a broken plugin must not wipe user settings. PluginManager::instance().unload_plugin(RESTORE_PLUGIN_KEY);
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->config == json{{"keep", "me"}});
} }
TEST_CASE("A raising config UI hook surfaces as an exception the host can catch", "[PluginConfig][Python]") TEST_CASE("A raising config UI hook surfaces as an exception the host can catch", "[PluginConfig][Python]")
@@ -76,12 +76,12 @@ TEST_CASE("plugin latest version uses the authoritative catalog field", "[Plugin
{ {
PluginDescriptor descriptor; PluginDescriptor descriptor;
descriptor.version = "1.3.0"; descriptor.version = "1.3.0";
descriptor.latest_version = "1.3.0"; descriptor.latest_version = "1.4.0";
PluginChangelog changelog; PluginChangelog changelog;
changelog.version = "1.2.0"; changelog.version = "1.2.0";
descriptor.changelog.push_back(changelog); descriptor.changelog.push_back(changelog);
CHECK(descriptor.latest_available_version() == "1.3.0"); CHECK(descriptor.latest_available_version() == "1.4.0");
} }
TEST_CASE("plugin latest version falls back to the descriptor version", "[PluginDescriptor]") TEST_CASE("plugin latest version falls back to the descriptor version", "[PluginDescriptor]")
+15 -1
View File
@@ -615,8 +615,22 @@ TEST_CASE("Python module release removes package submodules and owned sys.path",
// Ensure the next import executes the new submodule rather than reusing a stale package child. // Ensure the next import executes the new submodule rather than reusing a stale package child.
write_package("'new'"); write_package("'new'");
{
// The rewrite can keep the old mtime and size, so drop the bytecode wherever the
// interpreter caches it.
PythonGILState gil;
REQUIRE(gil);
PyObjectPtr util(PyImport_ImportModule("importlib.util"));
REQUIRE(util);
for (const char* file : {"__init__.py", "sub.py"}) {
PyObjectPtr source(PyUnicode_DecodeFSDefault((package_root / file).string().c_str()));
REQUIRE(source);
PyObjectPtr cached(PyObject_CallMethod(util.get(), "cache_from_source", "O", source.get()));
REQUIRE(cached);
boost::system::error_code ec; boost::system::error_code ec;
fs::remove_all(package_root / "__pycache__", ec); fs::remove(fs::path(PyUnicode_AsUTF8(cached.get())), ec);
}
}
paths.clear(); paths.clear();
modules.clear(); modules.clear();
+64
View File
@@ -126,6 +126,55 @@ TEST_CASE("Only modified or non-printable chords qualify as menu accelerators",
CHECK(registry.accelerator(Shortcut::KeyboardShortcuts).empty()); CHECK(registry.accelerator(Shortcut::KeyboardShortcuts).empty());
} }
TEST_CASE("Typing, caret, deletion and clipboard chords edit text", "[Shortcuts]")
{
CHECK(KeyChord{ 'A' }.edits_text());
CHECK(KeyChord{ 'A', wxMOD_SHIFT }.edits_text());
CHECK(KeyChord{ WXK_SPACE }.edits_text());
CHECK(KeyChord{ WXK_BACK }.edits_text());
CHECK(KeyChord{ WXK_DELETE }.edits_text());
CHECK(KeyChord{ WXK_BACK, wxMOD_CONTROL }.edits_text());
CHECK(KeyChord{ WXK_LEFT, wxMOD_SHIFT }.edits_text());
CHECK(KeyChord{ WXK_RIGHT, wxMOD_CONTROL | wxMOD_SHIFT }.edits_text());
CHECK(KeyChord{ WXK_HOME }.edits_text());
CHECK(KeyChord{ WXK_END, wxMOD_CONTROL }.edits_text());
CHECK(KeyChord{ WXK_DELETE, wxMOD_SHIFT }.edits_text());
CHECK(KeyChord{ WXK_INSERT }.edits_text());
CHECK(KeyChord{ WXK_INSERT, wxMOD_SHIFT }.edits_text());
CHECK(KeyChord{ WXK_INSERT, wxMOD_CONTROL }.edits_text());
CHECK(KeyChord{ WXK_PAGEUP, wxMOD_SHIFT }.edits_text());
CHECK(KeyChord{ WXK_RETURN }.edits_text());
CHECK(KeyChord{ WXK_TAB, wxMOD_SHIFT }.edits_text());
CHECK(KeyChord{ WXK_ESCAPE }.edits_text());
CHECK(KeyChord{ WXK_PAGEDOWN }.edits_text());
for (int key : { 'A', 'C', 'V', 'X', 'Y', 'Z' })
CHECK(KeyChord{ key, wxMOD_CONTROL }.edits_text());
CHECK(KeyChord{ 'Z', wxMOD_CONTROL | wxMOD_SHIFT }.edits_text());
CHECK_FALSE(KeyChord{ WXK_TAB, wxMOD_CONTROL }.edits_text());
CHECK_FALSE(KeyChord{ WXK_RETURN, wxMOD_CONTROL }.edits_text());
CHECK_FALSE(KeyChord{ WXK_PAGEDOWN, wxMOD_CONTROL }.edits_text());
CHECK_FALSE(KeyChord{ WXK_ESCAPE, wxMOD_CONTROL }.edits_text());
CHECK_FALSE(KeyChord{ 'S', wxMOD_CONTROL }.edits_text());
CHECK_FALSE(KeyChord{ 'S', wxMOD_CONTROL | wxMOD_SHIFT }.edits_text());
CHECK_FALSE(KeyChord{ '0', wxMOD_CONTROL }.edits_text());
CHECK_FALSE(KeyChord{ WXK_F5 }.edits_text());
CHECK_FALSE(KeyChord{}.edits_text());
#ifdef __APPLE__
CHECK(KeyChord{ 'E', wxMOD_ALT }.edits_text());
CHECK(KeyChord{ 'K', wxMOD_RAW_CONTROL }.edits_text());
CHECK(KeyChord{ WXK_LEFT, wxMOD_ALT }.edits_text());
CHECK(KeyChord{ WXK_LEFT, wxMOD_CONTROL }.edits_text());
CHECK_FALSE(KeyChord{ WXK_LEFT, wxMOD_RAW_CONTROL }.edits_text());
CHECK_FALSE(KeyChord{ 'E', wxMOD_CONTROL | wxMOD_ALT }.edits_text());
#else
CHECK(KeyChord{ 'Q', wxMOD_CONTROL | wxMOD_ALT }.edits_text());
CHECK_FALSE(KeyChord{ 'E', wxMOD_ALT }.edits_text());
CHECK_FALSE(KeyChord{ WXK_LEFT, wxMOD_ALT }.edits_text());
CHECK_FALSE(KeyChord{ WXK_LEFT, wxMOD_CONTROL | wxMOD_ALT }.edits_text());
#endif
}
TEST_CASE("Chords the desktop keeps for itself are recognized", "[Shortcuts]") TEST_CASE("Chords the desktop keeps for itself are recognized", "[Shortcuts]")
{ {
#ifdef _WIN32 #ifdef _WIN32
@@ -186,6 +235,21 @@ TEST_CASE("Default bindings never collide inside a context", "[Shortcuts]")
} }
} }
TEST_CASE("No default Global binding except the Speed Dial's Space edits text", "[Shortcuts]")
{
ShortcutRegistry registry;
size_t checked = 0;
for (size_t i = 0; i < size_t(Shortcut::Count); ++i) {
const ShortcutInfo& info = shortcut_info(Shortcut(i));
if ((info.contexts & context_bit(ShortcutContext::Global)) == 0 || info.id == Shortcut::SpeedDial)
continue;
CAPTURE(info.key);
CHECK_FALSE(registry.binding(info.id).edits_text());
++checked;
}
CHECK(checked > 0);
}
TEST_CASE("Shift and Ctrl variants of stepping shortcuts are left unbound", "[Shortcuts]") TEST_CASE("Shift and Ctrl variants of stepping shortcuts are left unbound", "[Shortcuts]")
{ {
ShortcutRegistry registry; ShortcutRegistry registry;
@@ -615,12 +615,19 @@ TEST_CASE("orca.host: SurfaceCollection.set mutates geometry; lslices via make_s
.attr("expolygon").attr("contour").attr("as_array")().cast<py::array>(); .attr("expolygon").attr("contour").attr("as_array")().cast<py::array>();
CHECK(c.shape(0) == 4); CHECK(c.shape(0) == 4);
// lslices are derived: make_slices() re-derives them + refreshes the bbox cache. // lslices are derived: make_slices() re-derives them from the region slices and refreshes the
// bbox cache.
TestLayer layer; TestLayer layer;
layer.add_region(nullptr);
py::object ly = py::cast(static_cast<Slic3r::Layer*>(&layer), py::return_value_policy::reference); py::object ly = py::cast(static_cast<Slic3r::Layer*>(&layer), py::return_value_policy::reference);
// (A hand-built layer has no regions, so make_slices() yields empty lslices — still null-safe.) ly.attr("regions")().cast<py::list>()[0].attr("slices").attr("set")(expolys, ST.attr("stInternalSolid"));
REQUIRE(layer.lslices.empty());
ly.attr("make_slices")(); ly.attr("make_slices")();
CHECK(layer.lslices_bboxes.size() == layer.lslices.size()); REQUIRE(layer.lslices.size() == 1);
CHECK_THAT(layer.lslices.front().area(), WithinRel((double) s * (double) s, 1e-9));
REQUIRE(layer.lslices_bboxes.size() == 1);
CHECK(layer.lslices_bboxes.front().min == Point(0, 0));
CHECK(layer.lslices_bboxes.front().max == Point(s, s));
} }
TEST_CASE("orca.host ExPolygon in-place transforms + SurfaceCollection.append (sample ops)", "[slicing_pipeline]") { TEST_CASE("orca.host ExPolygon in-place transforms + SurfaceCollection.append (sample ops)", "[slicing_pipeline]") {