mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-19 21:51:06 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec5580667d | ||
|
|
32d92a55d3 | ||
|
|
d3699dd9f6 | ||
|
|
a315a96c5c | ||
|
|
24cf1c6bcc | ||
|
|
d16ca97380 | ||
|
|
da4d766d46 | ||
|
|
06665d0558 | ||
|
|
0f59cd5d05 | ||
|
|
c1fb801ff3 |
No files matched your search
@@ -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: |
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
ws_stop.store(true);
|
std::thread stopped;
|
||||||
if (ws_thread.joinable()) {
|
{
|
||||||
ws_thread.join();
|
std::lock_guard<std::mutex> lock(ws_abort_mutex);
|
||||||
|
ws_stop.store(true);
|
||||||
|
if (ws_abort) {
|
||||||
|
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
|
||||||
|
|
||||||
|
tcp::resolver resolver{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; });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
net::io_context ioc;
|
beast::error_code connect_ec = net::error::host_not_found;
|
||||||
tcp::resolver resolver{ioc};
|
for (const auto& entry : resolver.resolve(endpoint.host, endpoint.port)) {
|
||||||
beast::tcp_stream stream{ioc};
|
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();
|
||||||
|
|
||||||
stream.expires_after(std::chrono::seconds(10));
|
websocket::stream_base::timeout timeouts{};
|
||||||
auto const results = resolver.resolve(endpoint.host, endpoint.port);
|
timeouts.handshake_timeout = timings.handshake;
|
||||||
stream.connect(results);
|
timeouts.idle_timeout = timings.idle;
|
||||||
|
timeouts.keep_alive_pings = true;
|
||||||
websocket::stream<beast::tcp_stream> ws{std::move(stream)};
|
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) {
|
||||||
BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent: websocket disconnected: " << e.what();
|
if (!ws_stop.load()) {
|
||||||
|
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();
|
||||||
start_status_stream(dev_id, base_url, api_key);
|
// 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);
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// Success!
|
// Success!
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
if (! 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)
|
for (const Surface &surface : region->fill_surfaces.surfaces)
|
||||||
CHECK(surface.is_solid());
|
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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -1033,18 +1033,19 @@ 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") {
|
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable };
|
||||||
std::string path = std::string(TEST_DATA_DIR) + "/test_3mf/Geräte/Büchse.3mf";
|
std::vector<Preset*> project_presets;
|
||||||
Model model;
|
bool is_bbl_3mf = false, is_orca_3mf = false;
|
||||||
DynamicPrintConfig config;
|
Semver file_version;
|
||||||
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable };
|
|
||||||
PlateDataPtrs plates;
|
GIVEN("a legacy project with a bare model and no plate metadata") {
|
||||||
std::vector<Preset*> project_presets;
|
std::string path = std::string(TEST_DATA_DIR) + "/test_3mf/Geräte/Büchse.3mf";
|
||||||
bool is_bbl_3mf = false, is_orca_3mf = false;
|
Model model;
|
||||||
Semver file_version;
|
DynamicPrintConfig config;
|
||||||
|
PlateDataPtrs plates;
|
||||||
|
|
||||||
WHEN("loaded through the BBS importer") {
|
WHEN("loaded through the BBS importer") {
|
||||||
bool loaded = false;
|
bool loaded = false;
|
||||||
@@ -1052,10 +1053,32 @@ SCENARIO("Legacy project loads crash-safe via load_bbs_3mf", "[3mf][MultiNozzle]
|
|||||||
&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);
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
REQUIRE(doc.recompute());
|
SECTION("construction edges form no profile, so the extrude fails") {
|
||||||
REQUIRE(doc.error.empty());
|
REQUIRE_FALSE(doc.recompute());
|
||||||
REQUIRE(doc.bodies.size() == body_count_before);
|
REQUIRE_FALSE(doc.error.empty());
|
||||||
REQUIRE_THAT(doc.body_mass_properties(0).volume, WithinRel(volume_before, 1e-9));
|
}
|
||||||
|
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.error.empty());
|
||||||
|
REQUIRE(doc.bodies.size() == 2);
|
||||||
|
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;
|
||||||
REQUIRE(doc.bodies.size() == exp_doc.bodies.size());
|
const bool exp_built = exp_doc.recompute();
|
||||||
for (size_t i = 0; i < doc.bodies.size(); ++i) {
|
INFO("reference error: " << exp_doc.error);
|
||||||
double v = double(SketchEngine::tessellate(doc.bodies[i].shape).volume());
|
REQUIRE(exp_built);
|
||||||
double ev = double(SketchEngine::tessellate(exp_doc.bodies[i].shape).volume());
|
const bool built = doc.recompute();
|
||||||
REQUIRE_THAT(v, WithinRel(ev, 1e-6));
|
INFO("loaded error: " << doc.error);
|
||||||
|
REQUIRE(built);
|
||||||
|
REQUIRE_FALSE(exp_doc.bodies.empty());
|
||||||
|
REQUIRE(doc.bodies.size() == exp_doc.bodies.size());
|
||||||
|
for (size_t b = 0; b < doc.bodies.size(); ++b) {
|
||||||
|
INFO("body " << b);
|
||||||
|
TriangleMesh mesh = SketchEngine::tessellate(doc.bodies[b].shape);
|
||||||
|
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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") {
|
||||||
|
|||||||
@@ -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,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());
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]")
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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]")
|
||||||
|
|||||||
@@ -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'");
|
||||||
boost::system::error_code ec;
|
{
|
||||||
fs::remove_all(package_root / "__pycache__", ec);
|
// 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;
|
||||||
|
fs::remove(fs::path(PyUnicode_AsUTF8(cached.get())), ec);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
paths.clear();
|
paths.clear();
|
||||||
modules.clear();
|
modules.clear();
|
||||||
|
|||||||
@@ -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]") {
|
||||||
|
|||||||
Reference in new issue
Block a user