diff --git a/.github/workflows/clang_tidy_fix.yml b/.github/workflows/clang_tidy_fix.yml
new file mode 100644
index 0000000000..6339e17570
--- /dev/null
+++ b/.github/workflows/clang_tidy_fix.yml
@@ -0,0 +1,65 @@
+name: clang-tidy fix
+
+# Makes the missing-include fix that /bot fix-tidy (pr-tidy-bot.yml) posts on a
+# pull request: scripts/run_clang_tidy.sh --fix on the merge commit the
+# clang-tidy check runs on, uploaded as the clang-tidy-fix artifact.
+#
+# Every push to a PR starts this workflow, and its job skips on that first
+# attempt. The bot re-runs it on request. A re-run keeps the pull_request
+# context, so the PR's code runs with a read-only token, no secrets and the
+# PR's own cache, never main's, whoever asked for it.
+on:
+ pull_request:
+ branches:
+ - main
+ - release/*
+
+permissions:
+ contents: read
+
+jobs:
+ fix:
+ if: github.run_attempt > 1
+ # A hosted runner, so nothing the PR's code leaves behind outlives the job.
+ runs-on: ubuntu-24.04
+ timeout-minutes: 60
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v7
+ with:
+ lfs: 'false'
+ # The PR merge commit plus its first parent, the base it is diffed against.
+ fetch-depth: 2
+ persist-credentials: false
+
+ # Parsing needs the dependency headers, not a build of this PR's deps/, so
+ # a PR that changes deps/ falls back to the newest cache main has.
+ - name: Restore cached deps
+ uses: actions/cache/restore@v6
+ with:
+ path: ${{ github.workspace }}/deps/build/OrcaSlicer_dep
+ key: linux-clang-cache-orcaslicer_deps-build-${{ hashFiles('deps/**') }}
+ restore-keys: linux-clang-cache-orcaslicer_deps-build-
+ fail-on-cache-miss: true
+
+ - name: Apt-Install Dependencies
+ uses: ./.github/actions/apt-install-deps
+
+ # The status says whether findings remain that --fix cannot fix.
+ - name: Fix the changed lines
+ run: |
+ mkdir -p "$RUNNER_TEMP/clang-tidy-fix"
+ status=0
+ scripts/run_clang_tidy.sh --base HEAD^1 --yes --fix || status=$?
+ echo "$status" > "$RUNNER_TEMP/clang-tidy-fix/status"
+ git -c core.quotePath=false diff --no-color --no-ext-diff --src-prefix=a/ --dst-prefix=b/ \
+ > "$RUNNER_TEMP/clang-tidy-fix/fix.patch"
+
+ - name: Upload the fix
+ uses: actions/upload-artifact@v7
+ with:
+ name: clang-tidy-fix
+ path: ${{ runner.temp }}/clang-tidy-fix/
+ retention-days: 7
+ # A later re-run of the same run replaces the earlier fix.
+ overwrite: true
diff --git a/.github/workflows/pr-tidy-bot.yml b/.github/workflows/pr-tidy-bot.yml
new file mode 100644
index 0000000000..62f4a0002f
--- /dev/null
+++ b/.github/workflows/pr-tidy-bot.yml
@@ -0,0 +1,476 @@
+name: PR clang-tidy Bot
+
+# Helps a pull request past the clang-tidy check.
+#
+# When the check fails on a PR's changes, a comment asks the author to fix it,
+# either with scripts/run_clang_tidy.sh --fix or by commenting:
+# /bot fix-tidy
+#
+# That command, from the PR's author or an account with write access, re-runs
+# the PR's clang-tidy fix run (clang_tidy_fix.yml), which makes the fix in the
+# PR's own context. When it finishes, the fix is posted as one review: each
+# include whose place falls on a line the PR's diff shows is a suggestion GitHub
+# can commit in one click, and the review's body holds the whole patch with a
+# git apply command. Findings --fix cannot fix, such as a using in a header,
+# are left for the check to report.
+#
+# No code from the PR runs here. Nothing is pushed, and the patch is only
+# posted when every line it adds is an #include in a C++ file the PR changes.
+#
+# workflow_run matches the workflows by their name: keep "clang-tidy" and
+# "clang-tidy fix" in step with clang_tidy.yml and clang_tidy_fix.yml, and
+# TIDY_STEP below with the clang-tidy step's name in clang_tidy.yml.
+
+on:
+ issue_comment:
+ types:
+ - created
+ workflow_run:
+ workflows:
+ - clang-tidy
+ - clang-tidy fix
+ types:
+ - completed
+
+permissions: {}
+
+jobs:
+ request:
+ # Skips the job unless a PR comment mentions the command.
+ if: >-
+ github.repository == 'OrcaSlicer/OrcaSlicer'
+ && github.event_name == 'issue_comment'
+ && github.event.issue.pull_request != null
+ && contains(github.event.comment.body, '/bot fix-tidy')
+ permissions:
+ actions: write # re-run the fix run
+ pull-requests: write # react to and answer the comment, which is the PR's
+ issues: write # feedback comment + reactions
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ - name: Re-run the PR's clang-tidy fix
+ uses: actions/github-script@v9
+ with:
+ script: |
+ const MARKER = '';
+ const FIX_WORKFLOW = 'clang_tidy_fix.yml';
+
+ const { owner, repo } = context.repo;
+ const issue = context.payload.issue;
+ const comment = context.payload.comment;
+
+ // Ignores a comment whose sender is not its author.
+ if (context.payload.sender.login !== comment.user.login || comment.user.type !== 'User') {
+ core.info('Ignoring a comment not written by its sender, or by a bot.');
+ return;
+ }
+
+ const commandLine = (comment.body || '')
+ .split('\n')
+ .map((line) => line.trim())
+ .find((line) => /^\/bot\s+fix-tidy\b/i.test(line));
+ if (!commandLine) {
+ core.info('No /bot fix-tidy command found.');
+ return;
+ }
+
+ const commenter = comment.user.login;
+ // Answers only the PR's author and maintainers, so the command cannot be used to spam.
+ if (commenter !== issue.user.login) {
+ const { data: access } = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username: commenter });
+ if (!['admin', 'write'].includes(access.permission)) {
+ core.info(`Ignoring /bot fix-tidy from @${commenter}: not the PR's author and no write access.`);
+ return;
+ }
+ }
+
+ const react = (content) =>
+ github.rest.reactions.createForIssueComment({ owner, repo, comment_id: comment.id, content });
+ async function refuse(reason) {
+ await react('-1');
+ await github.rest.issues.createComment({ owner, repo, issue_number: issue.number, body: `${MARKER}\n@${commenter} ${reason}` });
+ }
+
+ await react('eyes');
+
+ if (!/^\/bot\s+fix-tidy$/i.test(commandLine)) {
+ return refuse('`/bot fix-tidy` takes no arguments.');
+ }
+
+ const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: issue.number });
+ if (pr.state !== 'open') {
+ return refuse('the PR is not open.');
+ }
+
+ const sha = pr.head.sha.slice(0, 10);
+ const { data: { workflow_runs: runs } } = await github.rest.actions.listWorkflowRuns({
+ owner, repo, workflow_id: FIX_WORKFLOW, event: 'pull_request', head_sha: pr.head.sha, per_page: 1
+ });
+ const run = runs[0];
+ // A PR last pushed before clang_tidy_fix.yml existed has no run to re-run.
+ if (!run) {
+ return refuse(`there is no clang-tidy fix run for ${sha}, the PR's latest commit. If you just pushed it, ` +
+ 'try again in a minute. Otherwise, push a commit or merge the base branch to start one, then comment `/bot fix-tidy` again.');
+ }
+ if (run.status !== 'completed') {
+ // The first attempt is the one every push starts, and it makes no fix.
+ return refuse(run.run_attempt > 1
+ ? `a clang-tidy fix for ${sha} is already [running](${run.html_url}). I'll post it when it finishes.`
+ : `the PR's checks on ${sha} are still starting. Comment \`/bot fix-tidy\` again in a minute.`);
+ }
+ // GitHub refuses to re-run a run created over 30 days ago.
+ try {
+ await github.rest.actions.reRunWorkflow({ owner, repo, run_id: run.id });
+ } catch (error) {
+ core.warning(`Re-running ${run.html_url} failed: ${error.message}`);
+ return refuse(`GitHub won't re-run the [clang-tidy fix run](${run.html_url}) for ${sha} (${error.message}). ` +
+ 'Push a commit or merge the base branch to start a new one, then comment `/bot fix-tidy` again.');
+ }
+ core.info(`Re-ran ${run.html_url}`);
+
+ suggest:
+ # The fix run's re-runs only. Its first attempt makes no fix.
+ if: >-
+ github.repository == 'OrcaSlicer/OrcaSlicer'
+ && github.event_name == 'workflow_run'
+ && github.event.workflow_run.name == 'clang-tidy fix'
+ && github.event.workflow_run.event == 'pull_request'
+ && github.event.workflow_run.run_attempt > 1
+ concurrency:
+ group: ${{ github.workflow }}-suggest-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
+ cancel-in-progress: false
+ permissions:
+ actions: read # download the fix
+ contents: read # check out the PR head
+ pull-requests: write # the review
+ issues: write # feedback comment + reactions
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - name: Find the PR and who asked
+ id: find
+ uses: actions/github-script@v9
+ with:
+ script: |
+ const MARKER = '';
+ const CPP = /^(src|tests)\/.+\.(cpp|cc|cxx|hpp|h|hxx)$/;
+
+ const { owner, repo } = context.repo;
+ const run = context.payload.workflow_run;
+
+ // workflow_run lists no PR for a fork's branch, so the PR is found by its branch.
+ const { data: prs } = await github.rest.pulls.list({
+ owner, repo, state: 'open', head: `${run.head_repository.owner.login}:${run.head_branch}`
+ });
+ const pr = prs[0];
+ if (!pr) {
+ core.info('No open PR for this run.');
+ return;
+ }
+
+ // The latest /bot fix-tidy comment is the one this run answers.
+ const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: pr.number, per_page: 100 });
+ const request = comments.reverse().find((c) => c.user.type === 'User' && /^\s*\/bot\s+fix-tidy\s*$/im.test(c.body || ''));
+ const mention = request ? `@${request.user.login} ` : '';
+ async function refuse(reason) {
+ if (request) {
+ await github.rest.reactions.createForIssueComment({ owner, repo, comment_id: request.id, content: '-1' });
+ }
+ await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body: `${MARKER}\n${mention}${reason}` });
+ }
+
+ if (pr.head.sha !== run.head_sha) {
+ return refuse('the PR changed while I was making its clang-tidy fix. Comment `/bot fix-tidy` again.');
+ }
+ if (run.conclusion !== 'success') {
+ return refuse(`I couldn't make the clang-tidy fix. See the [run log](${run.html_url}).`);
+ }
+
+ // The right-side lines of each changed C++ file's diff: the lines a
+ // review comment can be placed on. A file too large for GitHub to
+ // show its diff has none, and gets no suggestions.
+ const files = await github.paginate(github.rest.pulls.listFiles, { owner, repo, pull_number: pr.number, per_page: 100 });
+ const ranges = {};
+ for (const file of files) {
+ if (file.status === 'removed' || !CPP.test(file.filename)) {
+ continue;
+ }
+ ranges[file.filename] = [...(file.patch || '').matchAll(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/gm)]
+ .map((m) => [Number(m[1]), Number(m[1]) + Number(m[2] ?? 1) - 1])
+ .filter(([first, last]) => last >= first);
+ }
+
+ core.setOutput('pr', pr.number);
+ core.setOutput('head-sha', pr.head.sha);
+ core.setOutput('head-repo', run.head_repository.full_name);
+ core.setOutput('ranges', JSON.stringify(ranges));
+ core.setOutput('mention', mention);
+ core.setOutput('request-id', request ? request.id : '');
+
+ - name: Download the fix
+ if: steps.find.outputs.pr != ''
+ uses: actions/download-artifact@v8
+ with:
+ name: clang-tidy-fix
+ run-id: ${{ github.event.workflow_run.id }}
+ github-token: ${{ github.token }}
+ path: ${{ runner.temp }}/clang-tidy-fix
+
+ # The PR's own files, only to apply the patch to. Nothing in them is run.
+ - name: Checkout the PR head
+ if: steps.find.outputs.pr != ''
+ uses: actions/checkout@v7
+ with:
+ repository: ${{ steps.find.outputs.head-repo }}
+ ref: ${{ steps.find.outputs.head-sha }}
+ path: head
+ fetch-depth: 1
+ lfs: 'false'
+ persist-credentials: false
+ sparse-checkout: |
+ src
+ tests
+
+ # The fix was made on the PR's merge commit, so it is applied to the head
+ # and diffed again, giving line numbers in the PR's own files.
+ - name: Check the fix and place its suggestions
+ id: place
+ if: steps.find.outputs.pr != ''
+ env:
+ RANGES: ${{ steps.find.outputs.ranges }}
+ run: |
+ python3 -I - "$RUNNER_TEMP/clang-tidy-fix/fix.patch" head "$RUNNER_TEMP" <<'EOF'
+ import json, os, re, subprocess, sys
+
+ patch_path, head, out = sys.argv[1:]
+ ranges = json.loads(os.environ["RANGES"])
+
+ def output(**values):
+ with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f:
+ for key, value in values.items():
+ f.write(f"{key}={value}\n")
+
+ patch = open(patch_path, encoding="utf-8").read()
+ if not patch.strip():
+ output(result="empty")
+ sys.exit()
+
+ # Every line the patch adds must be an #include or blank, in a C++ file the
+ # PR changes, and it may not remove, create, rename or change the mode of
+ # anything.
+ include = re.compile(r'\s*#\s*include\s*(<[\w./+-]+>|"[\w./+-]+")\s*')
+ header = re.compile(r"(index [0-9a-f]+\.\.[0-9a-f]+( \d+)?|@@ -\d+(,\d+)? \+\d+(,\d+)? @@.*)")
+ problems = []
+ path = None
+ for line in patch.splitlines():
+ if line.startswith("diff --git "):
+ match = re.fullmatch(r"diff --git a/(.+) b/\1", line)
+ path = match.group(1) if match else None
+ if path not in ranges:
+ problems.append(f"{line}: not a C++ file the PR changes")
+ # git appends a tab to the --- and +++ lines of a path that contains a space.
+ elif line.removesuffix("\t") in (f"--- a/{path}", f"+++ b/{path}") or header.fullmatch(line):
+ pass
+ elif line.startswith(" ") or line == "\\ No newline at end of file":
+ pass
+ elif line.startswith("+") and (include.fullmatch(line[1:]) or not line[1:].strip()):
+ pass
+ else:
+ problems.append(f"{path}: {line}")
+ if problems:
+ for problem in problems:
+ print(f"::error::Refused patch line {problem}")
+ output(result="rejected")
+ sys.exit()
+
+ if subprocess.run(["git", "-C", head, "apply", "--index", os.path.abspath(patch_path)]).returncode != 0:
+ output(result="apply-failed")
+ sys.exit()
+
+ git = ["git", "-C", head, "-c", "core.quotePath=false", "diff", "--cached", "--no-color", "--no-ext-diff",
+ "--src-prefix=a/", "--dst-prefix=b/"]
+ with open(os.path.join(out, "head.patch"), "w", encoding="utf-8") as f:
+ f.write(subprocess.run(git, check=True, capture_output=True, encoding="utf-8").stdout)
+
+ # Each run of added lines becomes one suggestion on the line it goes after
+ # (before, at the top of a file): that line, replaced by itself and the
+ # includes. It can only sit on a line the PR's diff shows.
+ hunks = []
+ path = None
+ for line in subprocess.run(git + ["-U0"], check=True, capture_output=True, encoding="utf-8").stdout.splitlines():
+ if line.startswith("diff --git "):
+ path = re.fullmatch(r"diff --git a/(.+) b/\1", line).group(1)
+ elif line.startswith("@@ "):
+ hunks.append((path, int(re.match(r"@@ -(\d+)", line).group(1)), []))
+ elif hunks and line.startswith("+") and not line.startswith("+++ "):
+ hunks[-1][2].append(line[1:])
+
+ suggestions = []
+ total = suggested = 0
+ for path, after, added in hunks:
+ count = sum(bool(include.fullmatch(line)) for line in added)
+ total += count
+ original = subprocess.run(["git", "-C", head, "show", f"HEAD:{path}"], check=True, capture_output=True,
+ encoding="utf-8").stdout.splitlines()
+ line = max(after, 1)
+ if line > len(original) or not any(first <= line <= last for first, last in ranges[path]):
+ continue
+ body = "\n".join([original[line - 1], *added] if after else [*added, original[0]])
+ # A fence longer than any run of backticks in the line it repeats.
+ fence = "`" * max([3] + [len(run) + 1 for run in re.findall(r"`+", body)])
+ suggestions.append({"path": path, "line": line, "side": "RIGHT",
+ "body": f"{fence}suggestion\n{body}\n{fence}"})
+ suggested += count
+ with open(os.path.join(out, "suggestions.json"), "w", encoding="utf-8") as f:
+ json.dump(suggestions, f)
+ output(result="ok", includes=total, suggested=suggested)
+ EOF
+
+ - name: Post the fix
+ if: always() && steps.find.outputs.pr != ''
+ uses: actions/github-script@v9
+ env:
+ PR: ${{ steps.find.outputs.pr }}
+ MENTION: ${{ steps.find.outputs.mention }}
+ REQUEST_ID: ${{ steps.find.outputs.request-id }}
+ HEAD_SHA: ${{ steps.find.outputs.head-sha }}
+ RESULT: ${{ steps.place.outputs.result }}
+ INCLUDES: ${{ steps.place.outputs.includes }}
+ SUGGESTED: ${{ steps.place.outputs.suggested }}
+ with:
+ script: |
+ const fs = require('fs');
+ const path = require('path');
+ const MARKER = '';
+ // GitHub's limit on a review body is 65536 characters.
+ const MAX_PATCH = 60000;
+
+ const { owner, repo } = context.repo;
+ const { PR, MENTION, REQUEST_ID, HEAD_SHA, RESULT, INCLUDES, SUGGESTED, RUNNER_TEMP } = process.env;
+ const issue_number = Number(PR);
+ const sha = HEAD_SHA.slice(0, 10);
+ const fixUrl = context.payload.workflow_run.html_url;
+ const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`;
+
+ const react = (content) => REQUEST_ID
+ ? github.rest.reactions.createForIssueComment({ owner, repo, comment_id: Number(REQUEST_ID), content })
+ : Promise.resolve();
+ async function reply(body, content) {
+ await react(content);
+ await github.rest.issues.createComment({ owner, repo, issue_number, body: `${MARKER}\n${MENTION}${body}` });
+ }
+
+ const read = (...parts) => fs.readFileSync(path.join(RUNNER_TEMP, ...parts), 'utf8');
+ const passes = RESULT && read('clang-tidy-fix', 'status').trim() === '0';
+
+ if (RESULT === 'empty') {
+ return passes
+ ? reply(`clang-tidy passes on ${sha}. There is nothing to fix.`, '+1')
+ : reply(`clang-tidy has no automatic fix for what it reports on ${sha}. The findings, in the [fix run's log](${fixUrl}), need a manual edit.`, '-1');
+ }
+ if (RESULT === 'rejected') {
+ return reply(`the clang-tidy fix changes more than includes, so I didn't post it. See the [run log](${runUrl}).`, '-1');
+ }
+ if (RESULT === 'apply-failed') {
+ return reply(`the clang-tidy fix doesn't apply to ${sha}. Merge the base branch into the PR, then comment \`/bot fix-tidy\` again.`, '-1');
+ }
+ if (RESULT !== 'ok') {
+ return reply(`something went wrong. See the [run log](${runUrl}).`, '-1');
+ }
+
+ const patch = read('head.patch');
+ const suggestions = JSON.parse(read('suggestions.json'));
+
+ const one = INCLUDES === '1';
+ const includes = one ? '1 missing include' : `${INCLUDES} missing includes`;
+ const inline = SUGGESTED === '0'
+ ? (one ? 'It goes on a line this PR does not change, so I can\'t suggest it inline.'
+ : 'They go on lines this PR does not change, so I can\'t suggest them inline.')
+ : SUGGESTED === INCLUDES
+ ? (one ? 'It is suggested inline below.' : 'Each is suggested inline below.')
+ : `${SUGGESTED} of them are suggested inline below; the rest go on lines this PR does not change.`;
+ const remaining = passes
+ ? `With ${one ? 'it' : 'them'}, clang-tidy passes.`
+ : `Some findings remain that need a manual edit; see the [fix run's log](${fixUrl}).`;
+ // A fence longer than any run of backticks in the patch's context lines.
+ const fence = '`'.repeat(Math.max(3, ...[...patch.matchAll(/`+/g)].map((m) => m[0].length + 1)));
+ const apply = patch.length > MAX_PATCH
+ ? `The full patch is too large to show here. Download \`fix.patch\` from the [fix run](${fixUrl}) and run \`git apply fix.patch\`.`
+ : `To apply ${one ? 'it' : 'all of them'}, run this in your checkout of the PR branch:\n\n${fence}sh\ngit apply <<'EOF'\n${patch}EOF\n${fence}`;
+ const body = (withSuggestions) => `${MARKER}\n${MENTION}clang-tidy's fix for ${sha} adds ${includes}. ` +
+ `${withSuggestions ? `${inline} ` : ''}${remaining}\n\n${apply}`;
+
+ const review = { owner, repo, pull_number: issue_number, commit_id: HEAD_SHA, event: 'COMMENT' };
+ try {
+ await github.rest.pulls.createReview({ ...review, body: body(true), comments: suggestions });
+ } catch (error) {
+ // A suggestion GitHub will not place still leaves the patch.
+ core.warning(`Posting the suggestions failed (${error.message}); posting the patch alone.`);
+ await github.rest.pulls.createReview({ ...review, body: body(false) });
+ }
+ await react('+1');
+
+ nudge:
+ if: >-
+ github.repository == 'OrcaSlicer/OrcaSlicer'
+ && github.event_name == 'workflow_run'
+ && github.event.workflow_run.name == 'clang-tidy'
+ && github.event.workflow_run.event == 'pull_request'
+ concurrency:
+ group: ${{ github.workflow }}-nudge-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
+ cancel-in-progress: false
+ permissions:
+ actions: read # the check's steps
+ pull-requests: write # comment on the PR
+ issues: write # the comment
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ - name: Ask for a fix when the check fails
+ uses: actions/github-script@v9
+ with:
+ script: |
+ const MARKER = '';
+ // The clang-tidy step's name in clang_tidy.yml. A failure in any other
+ // step is the CI's, not the PR's.
+ const TIDY_STEP = 'Run clang-tidy on the changed lines';
+
+ const { owner, repo } = context.repo;
+ const run = context.payload.workflow_run;
+
+ // workflow_run lists no PR for a fork's branch, so the PR is found by its branch.
+ const { data: prs } = await github.rest.pulls.list({
+ owner, repo, state: 'open', head: `${run.head_repository.owner.login}:${run.head_branch}`
+ });
+ const pr = prs.find((p) => p.head.sha === run.head_sha);
+ if (!pr) {
+ core.info('No open PR at this run\'s commit; a newer run will answer.');
+ return;
+ }
+
+ const { data: { jobs } } = await github.rest.actions.listJobsForWorkflowRun({ owner, repo, run_id: run.id });
+ const failed = jobs.some((job) => (job.steps || []).some((step) => step.name === TIDY_STEP && step.conclusion === 'failure'));
+
+ const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: pr.number, per_page: 100 });
+ const previous = comments.find((c) => c.user.type === 'Bot' && (c.body || '').startsWith(MARKER));
+
+ if (!failed) {
+ // Removes the request once the check passes.
+ if (previous && run.conclusion === 'success') {
+ await github.rest.issues.deleteComment({ owner, repo, comment_id: previous.id });
+ }
+ return;
+ }
+
+ const body = `${MARKER}\n@${pr.user.login} the clang-tidy check found problems in this PR's changes ` +
+ `at ${run.head_sha.slice(0, 10)} ([log](${run.html_url})). Missing includes can be fixed either way:\n\n` +
+ '- run `scripts/run_clang_tidy.sh --fix` (`scripts\\run_clang_tidy.ps1 -Fix` on Windows) and push the result, or\n' +
+ '- comment `/bot fix-tidy`, and I\'ll suggest the fix here for you to apply.\n\n' +
+ 'Other findings, such as a `using` in a header, need a manual edit.';
+ // Edits the earlier request instead of posting another, so each push does not notify again.
+ if (previous) {
+ await github.rest.issues.updateComment({ owner, repo, comment_id: previous.id, body });
+ } else {
+ await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body });
+ }
diff --git a/resources/images/score_star_outline.svg b/resources/images/score_star_outline.svg
new file mode 100644
index 0000000000..eb52be9e82
--- /dev/null
+++ b/resources/images/score_star_outline.svg
@@ -0,0 +1,3 @@
+
diff --git a/resources/profiles/Qidi.json b/resources/profiles/Qidi.json
index 4b6496df31..dcb39ee957 100644
--- a/resources/profiles/Qidi.json
+++ b/resources/profiles/Qidi.json
@@ -1,6 +1,6 @@
{
"name": "Qidi",
- "version": "02.04.00.16",
+ "version": "02.04.00.17",
"force_update": "0",
"description": "Qidi configurations",
"machine_model_list": [
diff --git a/resources/profiles/Qidi/machine/Qidi Q1 Pro 0.4 nozzle.json b/resources/profiles/Qidi/machine/Qidi Q1 Pro 0.4 nozzle.json
index bd2398ae7a..30db0fc68b 100644
--- a/resources/profiles/Qidi/machine/Qidi Q1 Pro 0.4 nozzle.json
+++ b/resources/profiles/Qidi/machine/Qidi Q1 Pro 0.4 nozzle.json
@@ -83,7 +83,7 @@
"extruder_clearance_height_to_lid": "120",
"layer_change_gcode": ";BEFORE_LAYER_CHANGE\n;[layer_z]\nLOG_Z\nG92 E0\n",
"machine_end_gcode": "M141 S0\nM104 S0\nM140 S0\nG1 E-3 F1800\nG0 Z{min(max_print_height, max_layer_z + 3)} F600\nG0 X0 Y0 F12000\n{if max_layer_z < max_print_height / 2}G1 Z{max_print_height / 2 + 10} F600{else}G1 Z{min(max_print_height, max_layer_z + 3)}{endif}",
- "machine_start_gcode": "PRINT_START BED=[hot_plate_temp_initial_layer] HOTEND=[nozzle_temperature_initial_layer] CHAMBER=[chamber_temperature]\nM83\nM140 S[hot_plate_temp_initial_layer]\nM104 S[nozzle_temperature_initial_layer]\nG4 P3000\nG0 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0)} Y{max((min(print_bed_max[1] - 5, first_layer_print_min[1] + 80) - 85), 0)} Z5 F6000\nG0 Z[initial_layer_print_height] F600\nG1 E3 F1800\nG1 X{(min(print_bed_max[0], first_layer_print_min[0] + 80))} E{85 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1] - 5, first_layer_print_min[1] + 80) - 85), 0) + 2} E{2 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85), 0)} E{85 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1] - 5, first_layer_print_min[1] + 80) - 85), 0) + 85} E{83 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85), 0) + 2} E{2 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1] - 5, first_layer_print_min[1] + 80) - 85), 0) + 3} E{82 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85), 0) + 3} Z0\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85), 0) + 6}\nG1 Z1 F600\n",
+ "machine_start_gcode": "PRINT_START BED=[bed_temperature_initial_layer_single] HOTEND=[nozzle_temperature_initial_layer] CHAMBER=[chamber_temperature]\nM83\nM140 S[bed_temperature_initial_layer_single]\nM104 S[nozzle_temperature_initial_layer]\nG4 P3000\nG0 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0)} Y{max((min(print_bed_max[1] - 5, first_layer_print_min[1] + 80) - 85), 0)} Z5 F6000\nG0 Z[initial_layer_print_height] F600\nG1 E3 F1800\nG1 X{(min(print_bed_max[0], first_layer_print_min[0] + 80))} E{85 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1] - 5, first_layer_print_min[1] + 80) - 85), 0) + 2} E{2 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85), 0)} E{85 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1] - 5, first_layer_print_min[1] + 80) - 85), 0) + 85} E{83 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85), 0) + 2} E{2 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1] - 5, first_layer_print_min[1] + 80) - 85), 0) + 3} E{82 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85), 0) + 3} Z0\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85), 0) + 6}\nG1 Z1 F600\n",
"thumbnails_format": "PNG",
"default_filament_profile": [
"Generic PLA @Qidi Q1 Pro 0.4 nozzle"
diff --git a/resources/profiles/Qidi/machine/Qidi X-CF Pro 0.4 nozzle.json b/resources/profiles/Qidi/machine/Qidi X-CF Pro 0.4 nozzle.json
index a41e2b6a11..968f7f63a9 100644
--- a/resources/profiles/Qidi/machine/Qidi X-CF Pro 0.4 nozzle.json
+++ b/resources/profiles/Qidi/machine/Qidi X-CF Pro 0.4 nozzle.json
@@ -100,7 +100,7 @@
"default_filament_profile": [
"Generic PLA @Qidi"
],
- "machine_start_gcode": "G28\nM140 S[hot_plate_temp_initial_layer]\nM190 S[hot_plate_temp_initial_layer]\nM109 S[nozzle_temperature_initial_layer]\nG92 E-19\nG0 Y5 Z0.3 F3600\nG1 X5 E0 F2400\n",
+ "machine_start_gcode": "G28\nM140 S[bed_temperature_initial_layer_single]\nM190 S[bed_temperature_initial_layer_single]\nM109 S[nozzle_temperature_initial_layer]\nG92 E-19\nG0 Y5 Z0.3 F3600\nG1 X5 E0 F2400\n",
"machine_end_gcode": "M104 S0\nM140 S0\n;Retract the filament\nG92 E0\nG1 E-3 F300\nG28\nM84\n",
"scan_first_layer": "0"
}
diff --git a/resources/profiles/Qidi/machine/fdm_qidi_x3_common.json b/resources/profiles/Qidi/machine/fdm_qidi_x3_common.json
index 9796856379..8fe450c3c6 100644
--- a/resources/profiles/Qidi/machine/fdm_qidi_x3_common.json
+++ b/resources/profiles/Qidi/machine/fdm_qidi_x3_common.json
@@ -24,7 +24,7 @@
"210x210/COLPIC",
"110x110/PNG"
],
- "machine_start_gcode": "PRINT_START\nG28\nM141 S0\nG0 Z50 F600\nM190 S[hot_plate_temp_initial_layer]\nG28 Z\nG29; mesh bed leveling ,comment this code to close it\nG0 X0 Y0 Z50 F6000\nM141 S{overall_chamber_temperature}\nM109 S[nozzle_temperature_initial_layer]\nM106 P3 S255\nM83\nG4 P3000\nG0 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0)} Y{max((min(print_bed_max[1], first_layer_print_min[1] + 80) - 85),0)} Z5 F6000\nG0 Z[initial_layer_print_height] F600\nG1 E3 F1800\nG1 X{(min(print_bed_max[0], first_layer_print_min[0] + 80))} E{85 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1], first_layer_print_min[1] + 80) - 85),0) + 2} E{2 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0)} E{85 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1], first_layer_print_min[1] + 80) - 85),0) + 85} E{83 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0) + 2} E{2 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1], first_layer_print_min[1] + 80) - 85),0) + 3} E{82 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0) + 12} E{-10 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 E{10 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\n",
+ "machine_start_gcode": "PRINT_START\nG28\nM141 S0\nG0 Z50 F600\nM190 S[bed_temperature_initial_layer_single]\nG28 Z\nG29; mesh bed leveling ,comment this code to close it\nG0 X0 Y0 Z50 F6000\nM141 S{overall_chamber_temperature}\nM109 S[nozzle_temperature_initial_layer]\nM106 P3 S255\nM83\nG4 P3000\nG0 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0)} Y{max((min(print_bed_max[1], first_layer_print_min[1] + 80) - 85),0)} Z5 F6000\nG0 Z[initial_layer_print_height] F600\nG1 E3 F1800\nG1 X{(min(print_bed_max[0], first_layer_print_min[0] + 80))} E{85 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1], first_layer_print_min[1] + 80) - 85),0) + 2} E{2 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0)} E{85 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1], first_layer_print_min[1] + 80) - 85),0) + 85} E{83 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0) + 2} E{2 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1], first_layer_print_min[1] + 80) - 85),0) + 3} E{82 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0) + 12} E{-10 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 E{10 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\n",
"machine_end_gcode": "M141 S0\nM104 S0\nM140 S0\nG1 E-3 F1800\nG0 Z{min(max_print_height, max_layer_z + 3)} F600\nG0 X0 Y0 F12000\n{if max_layer_z < max_print_height / 2}G1 Z{max_print_height / 2 + 10} F600{else}G1 Z{min(max_print_height, max_layer_z + 3)}{endif}",
"scan_first_layer": "0"
}
diff --git a/src/slic3r/GUI/AMSMaterialsSetting.cpp b/src/slic3r/GUI/AMSMaterialsSetting.cpp
index 903524caaa..41047dffd9 100644
--- a/src/slic3r/GUI/AMSMaterialsSetting.cpp
+++ b/src/slic3r/GUI/AMSMaterialsSetting.cpp
@@ -16,6 +16,7 @@
#include
#include "slic3r/GUI/GUI_Utils.hpp"
#include "slic3r/GUI/Widgets/Button.hpp"
+#include "slic3r/GUI/Widgets/DropDown.hpp"
#include "slic3r/GUI/Widgets/Label.hpp"
#include
#include "slic3r/GUI/wxExtensions.hpp"
@@ -172,7 +173,26 @@ void AMSMaterialsSetting::create()
Bind(wxEVT_PAINT, &AMSMaterialsSetting::paintEvent, this);
Bind(EVT_SELECTED_COLOR, &AMSMaterialsSetting::on_picker_color, this);
- m_comboBox_filament->Connect(wxEVT_COMMAND_COMBOBOX_SELECTED, wxCommandEventHandler(AMSMaterialsSetting::on_select_filament), NULL, this);
+ m_comboBox_filament->Connect(wxEVT_COMMAND_COMBOBOX_SELECTED, wxCommandEventHandler(AMSMaterialsSetting::on_select_filament), NULL, this);
+ m_comboBox_filament->Bind(EVT_DROPDOWN_ITEM_ACTION, &AMSMaterialsSetting::on_toggle_favorite_filament, this);
+
+ // Favorites toggled in the open list are regrouped just before it opens again, so rows never move
+ // under the cursor. These handlers run ahead of ComboBox's own, which then measure and show the
+ // rebuilt rows; wxEVT_COMBOBOX_DROPDOWN would be too late, as ComboBox sends it after showing the list.
+ auto regroup_favorites = [this]() {
+ if (!m_favorites_regroup || m_comboBox_filament->is_drop_down())
+ return;
+ const FilamentInfos* info = selected_filament_info();
+ const std::string filament_id = info ? info->filament_id : std::string();
+ rebuild_filament_dropdown_items(filament_id);
+ };
+ m_comboBox_filament->Bind(wxEVT_LEFT_DOWN, [regroup_favorites](wxMouseEvent& e) { regroup_favorites(); e.Skip(); });
+ m_comboBox_filament->Bind(wxEVT_LEFT_DCLICK, [regroup_favorites](wxMouseEvent& e) { regroup_favorites(); e.Skip(); });
+ m_comboBox_filament->Bind(wxEVT_KEY_DOWN, [regroup_favorites](wxKeyEvent& e) {
+ if (e.GetKeyCode() == WXK_RETURN || e.GetKeyCode() == WXK_SPACE) // the keys that open the list
+ regroup_favorites();
+ e.Skip();
+ });
m_comboBox_cali_result->Connect(wxEVT_COMMAND_COMBOBOX_SELECTED, wxCommandEventHandler(AMSMaterialsSetting::on_select_cali_result), NULL, this);
}
@@ -191,11 +211,9 @@ void AMSMaterialsSetting::create_panel_normal(wxWindow* parent)
m_sizer_filament->Add(0, 0, 0, wxEXPAND, 0);
-#ifdef __APPLE__
- m_comboBox_filament = new wxComboBox(parent, wxID_ANY, wxEmptyString, wxDefaultPosition, AMS_MATERIALS_SETTING_COMBOX_WIDTH, 0, nullptr, wxCB_READONLY);
-#else
m_comboBox_filament = new ::ComboBox(parent, wxID_ANY, wxEmptyString, wxDefaultPosition, AMS_MATERIALS_SETTING_COMBOX_WIDTH, 0, nullptr, wxCB_READONLY);
-#endif
+ m_star_filled = ScalableBitmap(this, "score_star_light", 16);
+ m_star_empty = ScalableBitmap(this, "score_star_outline", 16);
m_sizer_filament->Add(m_comboBox_filament, 1, wxALIGN_CENTER, 0);
@@ -420,6 +438,7 @@ void AMSMaterialsSetting::paintEvent(wxPaintEvent &evt)
AMSMaterialsSetting::~AMSMaterialsSetting()
{
m_comboBox_filament->Disconnect(wxEVT_COMMAND_COMBOBOX_SELECTED, wxCommandEventHandler(AMSMaterialsSetting::on_select_filament), NULL, this);
+ m_comboBox_filament->Unbind(EVT_DROPDOWN_ITEM_ACTION, &AMSMaterialsSetting::on_toggle_favorite_filament, this);
m_comboBox_cali_result->Disconnect(wxEVT_COMMAND_COMBOBOX_SELECTED, wxCommandEventHandler(AMSMaterialsSetting::on_select_cali_result), NULL, this);
}
@@ -531,10 +550,9 @@ void AMSMaterialsSetting::on_select_reset(wxCommandEvent& event) {
std::string selected_ams_id;
PresetBundle *preset_bundle = wxGetApp().preset_bundle;
if (preset_bundle) {
+ const FilamentInfos* selected_info = selected_filament_info();
for (auto it = preset_bundle->filaments.begin(); it != preset_bundle->filaments.end(); it++) {
- auto filament_item = map_filament_items[m_comboBox_filament->GetValue().ToStdString()];
- std::string filament_id = filament_item.filament_id;
- if (it->filament_id.compare(filament_id) == 0) {
+ if (selected_info && it->filament_id.compare(selected_info->filament_id) == 0) {
selected_ams_id = it->filament_id;
break;
}
@@ -682,12 +700,13 @@ void AMSMaterialsSetting::on_select_ok(wxCommandEvent &event)
ams_setting_id = "";
// the combobox item
- auto filament_item = map_filament_items[m_comboBox_filament->GetValue().ToStdString()];
+ const FilamentInfos* selected_info = selected_filament_info();
+ const std::string filament_id = selected_info ? selected_info->filament_id : std::string();
// check filament info (rack-gated per-nozzle blacklist evaluation)
PresetBundle* preset_bundle = wxGetApp().preset_bundle;
const auto& fila_check_res = sCheckFilamentInfo(preset_bundle, obj, ams_id, slot_id,
- filament_item.filament_id, ams_filament_id, ams_setting_id);
+ filament_id, ams_filament_id, ams_setting_id);
if (const auto& prohibit_items = fila_check_res.get_items_by_action("prohibition"); !prohibit_items.empty()) {
wxString info_msg;
@@ -986,6 +1005,133 @@ static void _collect_filament_info(const wxString& shown_name,
query_filament_types[shown_name] = filament.config.get_filament_type();
}
+static const char* AMS_FAVORITE_FILAMENT_IDS_KEY = "ams_favorite_filament_ids";
+
+const AMSMaterialsSetting::FilamentInfos* AMSMaterialsSetting::selected_filament_info() const
+{
+ const int selection = m_comboBox_filament->GetSelection();
+ if (selection < 0 || selection >= int(m_filament_list_items.size()) || !m_filament_list_items[selection].is_filament)
+ return nullptr;
+ return &m_filament_list_items[selection].filament;
+}
+
+std::set AMSMaterialsSetting::load_favorite_filament_ids() const
+{
+ std::set favorite_ids;
+ std::vector values;
+ const std::string serialized = wxGetApp().app_config->get(AMS_FAVORITE_FILAMENT_IDS_KEY);
+ if (!serialized.empty() && Slic3r::unescape_strings_cstyle(serialized, values)) {
+ favorite_ids.insert(values.begin(), values.end());
+ }
+ return favorite_ids;
+}
+
+void AMSMaterialsSetting::save_favorite_filament_ids(const std::set& favorite_ids)
+{
+ std::vector values(favorite_ids.begin(), favorite_ids.end());
+ wxGetApp().app_config->set(AMS_FAVORITE_FILAMENT_IDS_KEY, Slic3r::escape_strings_cstyle(values));
+ wxGetApp().app_config->save();
+}
+
+void AMSMaterialsSetting::rebuild_filament_dropdown_items(const std::string& selected_filament_id)
+{
+ std::vector items;
+ m_filament_list_items.clear();
+
+ auto append_header = [&items, this](const wxString& label) {
+ DropDown::Item item;
+ item.text = label;
+ item.style = DD_ITEM_STYLE_SPLIT_ITEM | DD_ITEM_STYLE_DISABLED;
+ items.push_back(item);
+ m_filament_list_items.push_back(FilamentListItem{});
+ };
+
+ auto append_filament = [&items, this](const wxString& name) {
+ auto info_it = map_filament_items.find(name);
+ if (info_it == map_filament_items.end())
+ return;
+
+ DropDown::Item item;
+ item.text = name;
+ items.push_back(item);
+ m_filament_list_items.push_back(FilamentListItem{info_it->second, true});
+ };
+
+ // Favorites are listed again at the top under their own header. Without any, the list is the
+ // plain filament list.
+ const std::set favorite_ids = load_favorite_filament_ids();
+ std::vector favorites;
+ for (const wxString& name : m_sorted_filament_items) {
+ auto info_it = map_filament_items.find(name);
+ if (info_it != map_filament_items.end() && favorite_ids.count(info_it->second.filament_id) > 0)
+ favorites.push_back(name);
+ }
+ if (!favorites.empty()) {
+ append_header(_L("Favorite filaments"));
+ for (const wxString& name : favorites)
+ append_filament(name);
+ append_header(_L("All filaments"));
+ }
+ for (const wxString& name : m_sorted_filament_items)
+ append_filament(name);
+
+ int selection_idx = -1;
+ if (!selected_filament_id.empty()) {
+ for (int i = 0; i < int(m_filament_list_items.size()); ++i) {
+ if (m_filament_list_items[i].is_filament && m_filament_list_items[i].filament.filament_id == selected_filament_id) {
+ selection_idx = i;
+ break;
+ }
+ }
+ }
+
+ // Clear() first: SetItems() keeps the old selection, and SetSelection() ignores an index equal to it,
+ // which would leave the previous filament's name on the combo.
+ m_comboBox_filament->Clear();
+ m_comboBox_filament->SetItems(items);
+ update_favorite_stars();
+ m_comboBox_filament->SetSelection(selection_idx);
+ m_favorites_regroup = false;
+}
+
+void AMSMaterialsSetting::update_favorite_stars()
+{
+ const std::set favorite_ids = load_favorite_filament_ids();
+ for (size_t i = 0; i < m_filament_list_items.size(); ++i) {
+ const FilamentListItem& row = m_filament_list_items[i];
+ if (row.is_filament) {
+ const bool favorite = favorite_ids.count(row.filament.filament_id) > 0;
+ m_comboBox_filament->SetItemActionBitmap(i, favorite ? m_star_filled.bmp() : m_star_empty.bmp());
+ }
+ }
+}
+
+void AMSMaterialsSetting::toggle_favorite_filament(const std::string& filament_id)
+{
+ if (filament_id.empty())
+ return;
+
+ std::set favorite_ids = load_favorite_filament_ids();
+ auto iter = favorite_ids.find(filament_id);
+ if (iter == favorite_ids.end())
+ favorite_ids.insert(filament_id);
+ else
+ favorite_ids.erase(iter);
+
+ save_favorite_filament_ids(favorite_ids);
+}
+
+void AMSMaterialsSetting::on_toggle_favorite_filament(wxCommandEvent& evt)
+{
+ const int index = evt.GetInt();
+ if (index < 0 || index >= int(m_filament_list_items.size()) || !m_filament_list_items[index].is_filament)
+ return;
+
+ toggle_favorite_filament(m_filament_list_items[index].filament.filament_id);
+ update_favorite_stars();
+ m_favorites_regroup = true;
+}
+
void AMSMaterialsSetting::Popup(wxString filament, wxString sn, wxString temp_min, wxString temp_max, wxString k, wxString n)
{
if (!obj) return;
@@ -999,13 +1145,12 @@ void AMSMaterialsSetting::Popup(wxString filament, wxString sn, wxString temp_mi
m_input_k_val->GetTextCtrl()->SetValue(k);
m_input_n_val->GetTextCtrl()->SetValue(n);
- wxArrayString filament_items;
wxString bambu_filament_name;
- wxString hint_filament_name; // the hint type to be selected
std::unordered_map query_filament_vendors;// some information for sort
std::unordered_map query_filament_types; //
std::set filament_id_set;
+ m_sorted_filament_items.clear();
// The alias keyed map has to start empty: it is a member, so a stale alias left by an earlier
// popup (a different printer, a different nozzle) would resolve to that printer's filament_id.
map_filament_items.clear();
@@ -1035,7 +1180,7 @@ void AMSMaterialsSetting::Popup(wxString filament, wxString sn, wxString temp_mi
if (alias.empty())
continue;
- filament_items.push_back(alias);
+ m_sorted_filament_items.push_back(alias);
_collect_filament_info(alias, *filament_it, query_filament_vendors, query_filament_types);
FilamentInfos filament_infos;
@@ -1044,7 +1189,6 @@ void AMSMaterialsSetting::Popup(wxString filament, wxString sn, wxString temp_mi
map_filament_items[alias] = filament_infos;
if (filament_it->filament_id == ams_filament_id) {
- hint_filament_name = from_u8(alias);
bambu_filament_name = from_u8(alias);
@@ -1215,24 +1359,11 @@ void AMSMaterialsSetting::Popup(wxString filament, wxString sn, wxString temp_mi
return name_compare != 0 ? name_compare < 0 : left < right;
};
- std::sort(filament_items.begin(), filament_items.end(), _filament_sorter);
+ std::sort(m_sorted_filament_items.begin(), m_sorted_filament_items.end(), _filament_sorter);
}
- // traverse the hint selection idx
- int selection_idx = -1;
- {
- for(int i = 0; i < filament_items.size(); i++)
- {
- if (hint_filament_name == filament_items[i])
- {
- selection_idx = i;
- break;
- }
- }
- }
-
- m_comboBox_filament->Set(filament_items);
- m_comboBox_filament->SetSelection(selection_idx);
+ rebuild_filament_dropdown_items(ams_filament_id);
+ int selection_idx = m_comboBox_filament->GetSelection();
post_select_event(selection_idx);
if (selection_idx < 0) {
@@ -1289,6 +1420,7 @@ void AMSMaterialsSetting::on_select_filament(wxCommandEvent &evt)
int* from_printer = static_cast(m_comboBox_filament->GetClientData());
m_filament_type = "";
+ const FilamentInfos* selected_info = selected_filament_info();
PresetBundle* preset_bundle = wxGetApp().preset_bundle;
if (preset_bundle) {
std::ostringstream stream;
@@ -1309,9 +1441,8 @@ void AMSMaterialsSetting::on_select_filament(wxCommandEvent &evt)
std::string nozzle_diameter_str = stream.str();
// Resolve the selection against the same list Popup() built the dropdown from, so the two
// halves of the dialog cannot disagree about which filaments this machine can use.
- const std::string selected = m_comboBox_filament->GetValue().ToStdString();
- if (!selected.empty()) {
- const std::string filament_id = map_filament_items[selected].filament_id;
+ if (selected_info) {
+ const std::string& filament_id = selected_info->filament_id;
for (Preset *it : preset_bundle->get_filament_presets_for_machine(
DevPrinterConfigUtil::get_printer_display_name(obj->printer_type), nozzle_diameter_str, obj->is_support_user_preset)) {
if (it->filament_id != filament_id)
@@ -1364,7 +1495,7 @@ void AMSMaterialsSetting::on_select_filament(wxCommandEvent &evt)
//reset cali
int cali_select_idx = -1;
- if ( !this->obj || m_filament_selection < 0) {
+ if ( !this->obj || m_filament_selection < 0 || !selected_info) {
m_input_k_val->Enable(false);
m_input_n_val->Enable(false);
m_button_confirm->Disable(); // ORCA No need to change style
@@ -1383,21 +1514,9 @@ void AMSMaterialsSetting::on_select_filament(wxCommandEvent &evt)
ams_filament_id = "";
ams_setting_id = "";
- if (preset_bundle) {
- for (auto it = preset_bundle->filaments.begin(); it != preset_bundle->filaments.end(); it++) {
- auto itor = map_filament_items.find(m_comboBox_filament->GetValue().ToStdString());
- if ( itor != map_filament_items.end()) {
- ams_filament_id = itor->second.filament_id;
- ams_setting_id = itor->second.setting_id;
- break;
- }
-
- if (it->alias.compare(m_comboBox_filament->GetValue().ToStdString()) == 0) {
- ams_filament_id = it->filament_id;
- ams_setting_id = it->setting_id;
- break;
- }
- }
+ if (selected_info) {
+ ams_filament_id = selected_info->filament_id;
+ ams_setting_id = selected_info->setting_id;
}
wxArrayString items;
@@ -1529,6 +1648,11 @@ void AMSMaterialsSetting::on_dpi_changed(const wxRect &suggested_rect)
m_button_reset->Rescale(); // ORCA
m_button_confirm->Rescale(); // ORCA
m_button_close->Rescale(); // ORCA
+ // An open list would keep the measurements of the old stars, so close it before replacing them.
+ m_comboBox_filament->CloseDropdown();
+ m_star_filled.msw_rescale();
+ m_star_empty.msw_rescale();
+ update_favorite_stars();
this->Refresh();
}
diff --git a/src/slic3r/GUI/AMSMaterialsSetting.hpp b/src/slic3r/GUI/AMSMaterialsSetting.hpp
index 2628e6caf4..6fa215063a 100644
--- a/src/slic3r/GUI/AMSMaterialsSetting.hpp
+++ b/src/slic3r/GUI/AMSMaterialsSetting.hpp
@@ -31,6 +31,7 @@
#include "slic3r/GUI/Widgets/StateColor.hpp"
#include
#include