mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-16 13:32:44 +00:00
Merge
This commit is contained in:
@@ -1,8 +1,12 @@
|
|||||||
name: Check profiles
|
name: Check profiles
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
|
# release/* is included because pr-merge-bot.yml lets delegates merge into
|
||||||
|
# it, and it gates on this workflow's result. Without it a delegated merge
|
||||||
|
# into a release branch would run no profile validation at all.
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
- release/*
|
||||||
paths:
|
paths:
|
||||||
- 'resources/profiles/**'
|
- 'resources/profiles/**'
|
||||||
- ".github/workflows/check_profiles.yml"
|
- ".github/workflows/check_profiles.yml"
|
||||||
@@ -20,6 +24,8 @@ permissions:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check_profiles:
|
check_profiles:
|
||||||
|
# This job name is the check-run name pr-merge-bot.yml requires before a
|
||||||
|
# delegated merge. Renaming it silently disables that gate.
|
||||||
name: Check profiles
|
name: Check profiles
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -0,0 +1,510 @@
|
|||||||
|
name: PR Merge Bot
|
||||||
|
|
||||||
|
# Merges a pull request on request from a delegated vendor profile maintainer.
|
||||||
|
# The merge is performed by this workflow's GITHUB_TOKEN, so a delegate needs no
|
||||||
|
# repository access.
|
||||||
|
#
|
||||||
|
# Commands, posted as a comment on the PR:
|
||||||
|
# /bot merge squash-merge the PR
|
||||||
|
# /bot merge --dry-run report the verdict without merging
|
||||||
|
#
|
||||||
|
# Merges only when the commenter holds a grant covering every changed path, the
|
||||||
|
# PR targets main or release/*, and CI is green on the head commit. Otherwise it
|
||||||
|
# comments naming the files that fell outside the grant.
|
||||||
|
#
|
||||||
|
# Grants come from the FOLDER_MERGERS variable in the `merge-delegation`
|
||||||
|
# environment: one per line, `account: path`, `#` comments and blank lines
|
||||||
|
# allowed. Paths may contain spaces. A vendor takes two grants, the folder and
|
||||||
|
# its sibling bundle JSON:
|
||||||
|
#
|
||||||
|
# # Acme profiles
|
||||||
|
# vendor-maintainer: resources/profiles/Acme/
|
||||||
|
# vendor-maintainer: resources/profiles/Acme.json
|
||||||
|
#
|
||||||
|
# Edit the grant list (environment scope, so admin only):
|
||||||
|
# gh variable set FOLDER_MERGERS --env merge-delegation --body "$(cat folder-mergers.txt)"
|
||||||
|
# gh variable get FOLDER_MERGERS --env merge-delegation
|
||||||
|
#
|
||||||
|
# Stop all merging without touching this file:
|
||||||
|
# gh variable set MERGE_BOT_DRY_RUN --body true
|
||||||
|
|
||||||
|
on:
|
||||||
|
issue_comment:
|
||||||
|
types:
|
||||||
|
- created
|
||||||
|
|
||||||
|
# One merge attempt per PR at a time, so two quick comments cannot race.
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.event.issue.number }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
merge:
|
||||||
|
# Skips the job unless a PR comment mentions the command.
|
||||||
|
if: >-
|
||||||
|
github.repository == 'OrcaSlicer/OrcaSlicer'
|
||||||
|
&& github.event.issue.pull_request != null
|
||||||
|
&& contains(github.event.comment.body, '/bot merge')
|
||||||
|
permissions:
|
||||||
|
contents: write # pulls.merge
|
||||||
|
pull-requests: write # pulls.merge
|
||||||
|
issues: write # feedback comment + reactions
|
||||||
|
actions: write # re-dispatch build_all.yml after the merge
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
# Supplies FOLDER_MERGERS. Must carry no protection rules, or every
|
||||||
|
# delegated merge would wait for a human reviewer.
|
||||||
|
environment: merge-delegation
|
||||||
|
steps:
|
||||||
|
- name: Merge PR on behalf of a folder delegate
|
||||||
|
uses: actions/github-script@v9
|
||||||
|
env:
|
||||||
|
# Read as env vars, never interpolated into the script body.
|
||||||
|
FOLDER_MERGERS: ${{ vars.FOLDER_MERGERS }}
|
||||||
|
MERGE_BOT_DRY_RUN: ${{ vars.MERGE_BOT_DRY_RUN }}
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
function isPermissionDenied(error) {
|
||||||
|
return error && error.status === 403 && /Resource not accessible by integration/i.test(error.message || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
const MARKER = '<!-- pr-merge-bot -->';
|
||||||
|
// No grant may reach outside this root.
|
||||||
|
const DELEGATABLE_ROOT = 'resources/profiles/';
|
||||||
|
const ALLOWED_BASE_BRANCH = /^(?:main|release\/.+)$/;
|
||||||
|
const MERGE_METHOD = 'squash';
|
||||||
|
const REQUIRED_CHECK = 'Check profiles'; // job name in check_profiles.yml
|
||||||
|
const MAX_CHANGED_FILES = 500; // policy cap, well under listFiles' 3000
|
||||||
|
const LISTFILES_CAP = 3000;
|
||||||
|
const MAX_REPORTED_FILES = 12;
|
||||||
|
const MERGEABLE_ATTEMPTS = 5;
|
||||||
|
const MERGEABLE_DELAY_MS = 2000;
|
||||||
|
const OK_CONCLUSIONS = new Set(['success', 'neutral', 'skipped']);
|
||||||
|
const REGULAR_FILE_MODES = new Set(['100644', '100755']);
|
||||||
|
|
||||||
|
// Paths refused whatever the grants say. Checked before grants, so
|
||||||
|
// delegating a new root means removing it from this list too.
|
||||||
|
const DENIED_PATTERNS = [
|
||||||
|
/^\.github\//,
|
||||||
|
/(^|\/)\.git(attributes|modules|ignore|config)$/,
|
||||||
|
/^(?:src|deps|deps_src|tests|tools|cmake|sandboxes|scripts|docs?|localization|bbl)\//,
|
||||||
|
/(^|\/)cmakelists\.txt$/,
|
||||||
|
/\.cmake$/,
|
||||||
|
/^build_[^/]*\.(?:sh|bat)$/,
|
||||||
|
/^version\.inc$/,
|
||||||
|
// Executables, including those inside the delegatable root.
|
||||||
|
/\.(?:sh|bash|bat|cmd|ps1|py|js|mjs|cjs|ts|rb|pl|php)$/
|
||||||
|
];
|
||||||
|
|
||||||
|
function parseGrants(raw) {
|
||||||
|
// GitHub login: 1-39 chars, alphanumerics with single interior hyphens.
|
||||||
|
const loginPattern = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/;
|
||||||
|
const grantsByLogin = new Map();
|
||||||
|
const problems = [];
|
||||||
|
|
||||||
|
(raw || '').split(/\r?\n/).forEach((rawLine, index) => {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (!line || line.startsWith('#')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Splits on the first colon only, so paths may contain ':' and spaces.
|
||||||
|
const separator = line.indexOf(':');
|
||||||
|
if (separator === -1) {
|
||||||
|
problems.push(`line ${index + 1}: expected \`account: path\``);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const login = line.slice(0, separator).trim().replace(/^@/, '');
|
||||||
|
const path = line.slice(separator + 1).trim().replace(/\/+$/, '');
|
||||||
|
|
||||||
|
if (!loginPattern.test(login)) {
|
||||||
|
problems.push(`line ${index + 1}: \`${login}\` is not a valid GitHub account name`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (/[\\*?\u0000-\u001f\u007f]/.test(path) || path.split('/').includes('..') || path.includes('//')) {
|
||||||
|
problems.push(`line ${index + 1}: invalid path (no globs, \`..\`, \`//\`, backslashes or control characters)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Rejects anything outside the root, and the bare root itself.
|
||||||
|
if (!path.startsWith(DELEGATABLE_ROOT) || path.length <= DELEGATABLE_ROOT.length) {
|
||||||
|
problems.push(`line ${index + 1}: \`${path}\` is not inside \`${DELEGATABLE_ROOT}\``);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = login.toLowerCase();
|
||||||
|
grantsByLogin.set(key, (grantsByLogin.get(key) || []).concat(path));
|
||||||
|
});
|
||||||
|
|
||||||
|
return { grantsByLogin, problems };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDenied(path) {
|
||||||
|
if (/[\\\u0000-\u001f\u007f]/.test(path) || path.startsWith('/') || path.split('/').includes('..')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = path.normalize('NFKC').toLowerCase();
|
||||||
|
return DENIED_PATTERNS.some((pattern) => pattern.test(normalized));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Byte-exact match on directory boundaries, so a grant of
|
||||||
|
// `.../Acme` covers neither `.../Acme Labs/x.json` nor `.../Acme.json`.
|
||||||
|
function isGranted(path, grants) {
|
||||||
|
return grants.some((grant) => path === grant || path.startsWith(`${grant}/`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both endpoints of a rename; both must satisfy the grant.
|
||||||
|
function pathsFor(file) {
|
||||||
|
return [file.filename, file.previous_filename].filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatList(items) {
|
||||||
|
const unique = [...new Set(items)];
|
||||||
|
const shown = unique.slice(0, MAX_REPORTED_FILES).map((item) => `- \`${item}\``);
|
||||||
|
if (unique.length > MAX_REPORTED_FILES) {
|
||||||
|
shown.push(`- …and ${unique.length - MAX_REPORTED_FILES} more`);
|
||||||
|
}
|
||||||
|
return shown.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { owner, repo } = context.repo;
|
||||||
|
const issue = context.payload.issue;
|
||||||
|
const comment = context.payload.comment;
|
||||||
|
|
||||||
|
if (!issue.pull_request) {
|
||||||
|
core.info('Ignoring comment that is not on a pull request.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Ignores a comment whose sender is not its author.
|
||||||
|
if (context.payload.action !== 'created' || context.payload.sender.login !== comment.user.login) {
|
||||||
|
core.warning('Ignoring comment whose sender does not match its author.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (comment.user.type !== 'User') {
|
||||||
|
core.info('Ignoring bot-authored command.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const commandLine = (comment.body || '')
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.find((line) => /^\/bot\s+merge\b/i.test(line));
|
||||||
|
|
||||||
|
if (!commandLine) {
|
||||||
|
core.info('No /bot merge command found.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const commenter = comment.user.login;
|
||||||
|
const { grantsByLogin, problems } = parseGrants(process.env.FOLDER_MERGERS);
|
||||||
|
const grants = grantsByLogin.get(commenter.toLowerCase()) || [];
|
||||||
|
|
||||||
|
for (const problem of problems) {
|
||||||
|
core.warning(`FOLDER_MERGERS ${problem}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Says nothing to accounts with no grant, so it cannot be used to spam.
|
||||||
|
if (!grants.length) {
|
||||||
|
core.info(`Ignoring /bot merge from @${commenter}: not listed in FOLDER_MERGERS.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warns instead of failing when the token cannot post feedback.
|
||||||
|
async function bestEffort(call, warning) {
|
||||||
|
try {
|
||||||
|
await call();
|
||||||
|
} catch (error) {
|
||||||
|
if (isPermissionDenied(error)) {
|
||||||
|
core.warning(warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const react = (content) => bestEffort(
|
||||||
|
() => github.rest.reactions.createForIssueComment({ owner, repo, comment_id: comment.id, content }),
|
||||||
|
`Cannot add the "${content}" reaction because the token cannot write.`);
|
||||||
|
|
||||||
|
const say = (body) => bestEffort(
|
||||||
|
() => github.rest.issues.createComment({ owner, repo, issue_number: issue.number, body: `${MARKER}\n${body}` }),
|
||||||
|
'Cannot post a comment because the token cannot write comments.');
|
||||||
|
|
||||||
|
// Declines the command: warns in the log, reacts, explains on the PR.
|
||||||
|
async function refuse(reason) {
|
||||||
|
const configNote = problems.length
|
||||||
|
? `\n\n\`FOLDER_MERGERS\` also has problems a maintainer needs to fix:\n${problems.map((problem) => `- ${problem}`).join('\n')}`
|
||||||
|
: '';
|
||||||
|
const grantsNote = `\n\n<details><summary>Your current grants</summary>\n\n${formatList(grants)}\n\n</details>`;
|
||||||
|
|
||||||
|
core.warning(`Refused /bot merge from @${commenter}: ${reason}`);
|
||||||
|
await react('-1');
|
||||||
|
await say(`@${commenter} I can't merge this PR: ${reason}${configNote}${grantsNote}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await react('eyes');
|
||||||
|
|
||||||
|
const args = (commandLine.match(/^\/bot\s+merge\s*(.*)$/i)[1] || '').trim().split(/\s+/).filter(Boolean);
|
||||||
|
const unknownArgs = args.filter((arg) => arg.toLowerCase() !== '--dry-run');
|
||||||
|
const dryRun = String(process.env.MERGE_BOT_DRY_RUN || '').toLowerCase() === 'true'
|
||||||
|
|| unknownArgs.length !== args.length;
|
||||||
|
|
||||||
|
if (unknownArgs.length) {
|
||||||
|
return refuse(
|
||||||
|
`I don't understand ${unknownArgs.map((arg) => `\`${arg}\``).join(', ')}. ` +
|
||||||
|
'Usage: `/bot merge` or `/bot merge --dry-run`.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refuses everything while the grant list is malformed.
|
||||||
|
if (problems.length) {
|
||||||
|
return refuse(
|
||||||
|
'the `FOLDER_MERGERS` grant list has malformed lines, so I refuse every merge until it is fixed.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data: pr } = await github.rest.pulls.get({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
pull_number: issue.number
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pr.merged) {
|
||||||
|
return refuse('it is already merged.');
|
||||||
|
}
|
||||||
|
if (pr.state !== 'open') {
|
||||||
|
return refuse(`its state is \`${pr.state}\`, not \`open\`.`);
|
||||||
|
}
|
||||||
|
if (pr.draft) {
|
||||||
|
return refuse('it is still a draft. Mark it ready for review first.');
|
||||||
|
}
|
||||||
|
if (!ALLOWED_BASE_BRANCH.test(pr.base.ref)) {
|
||||||
|
return refuse(`it targets \`${pr.base.ref}\`. Delegated merges are only allowed into \`main\` and \`release/*\`.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- folder scope ----
|
||||||
|
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
pull_number: pr.number,
|
||||||
|
per_page: 100
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!files.length) {
|
||||||
|
return refuse('it changes no files, so there is nothing to verify or merge.');
|
||||||
|
}
|
||||||
|
// Refuses when the file list is truncated or disagrees with the PR.
|
||||||
|
if (files.length >= LISTFILES_CAP || files.length !== pr.changed_files) {
|
||||||
|
return refuse(
|
||||||
|
`it reports ${pr.changed_files} changed files but the API listed ${files.length}, ` +
|
||||||
|
'so the file list is truncated and I cannot verify the folder scope. A maintainer must merge this one.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (pr.changed_files > MAX_CHANGED_FILES) {
|
||||||
|
return refuse(`it changes ${pr.changed_files} files; delegated merges are capped at ${MAX_CHANGED_FILES}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deniedFiles = [];
|
||||||
|
const outsideFiles = [];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
for (const path of pathsFor(file)) {
|
||||||
|
if (isDenied(path)) {
|
||||||
|
deniedFiles.push(path);
|
||||||
|
} else if (!isGranted(path, grants)) {
|
||||||
|
outsideFiles.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deniedFiles.length) {
|
||||||
|
core.error(`@${commenter} attempted a delegated merge touching protected paths: ${deniedFiles.join(', ')}`);
|
||||||
|
return refuse(
|
||||||
|
'it touches paths that are never delegatable, whatever the grants say ' +
|
||||||
|
`(CI, build, scripts or executable files):\n\n${formatList(deniedFiles)}\n\nA maintainer should look at this before it goes any further.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (outsideFiles.length) {
|
||||||
|
return refuse(
|
||||||
|
`${outsideFiles.length} changed path(s) fall outside your grants:\n\n${formatList(outsideFiles)}\n\n` +
|
||||||
|
'A vendor needs both grants: `resources/profiles/<Vendor>/` **and** `resources/profiles/<Vendor>.json`.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- file modes: rejects symlinks and submodules ----
|
||||||
|
// Fetches the delegatable subtree only; listFiles does not report modes.
|
||||||
|
const headSha = pr.head.sha;
|
||||||
|
const { data: tree } = await github.rest.git.getTree({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
tree_sha: `${headSha}:${DELEGATABLE_ROOT.replace(/\/$/, '')}`,
|
||||||
|
recursive: 'true'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (tree.truncated) {
|
||||||
|
return refuse('the git tree is too large to verify file modes. A maintainer must merge this one.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entry paths are subtree-relative.
|
||||||
|
const modesByPath = new Map(tree.tree.map((entry) => [`${DELEGATABLE_ROOT}${entry.path}`, entry.mode]));
|
||||||
|
const irregularFiles = files
|
||||||
|
.filter((file) => file.status !== 'removed')
|
||||||
|
.map((file) => [file.filename, modesByPath.get(file.filename)])
|
||||||
|
.filter(([, mode]) => !REGULAR_FILE_MODES.has(mode))
|
||||||
|
.map(([path, mode]) => `${path} (mode ${mode || 'missing'})`);
|
||||||
|
|
||||||
|
if (irregularFiles.length) {
|
||||||
|
core.error(`@${commenter} attempted a delegated merge with non-regular files: ${irregularFiles.join(', ')}`);
|
||||||
|
return refuse(
|
||||||
|
`it adds symlinks, submodules or files I cannot verify:\n\n${formatList(irregularFiles)}\n\nA maintainer should look at this before it goes any further.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- mergeability: waits for GitHub to compute it ----
|
||||||
|
for (let attempt = 0; pr.mergeable === null && attempt < MERGEABLE_ATTEMPTS; attempt += 1) {
|
||||||
|
core.info(`Mergeability not computed yet; retrying in ${MERGEABLE_DELAY_MS}ms.`);
|
||||||
|
await sleep(MERGEABLE_DELAY_MS);
|
||||||
|
({ data: pr } = await github.rest.pulls.get({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
pull_number: pr.number
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pr.mergeable === null) {
|
||||||
|
return refuse('GitHub is still working out whether it can be merged. Try `/bot merge` again in a minute.');
|
||||||
|
}
|
||||||
|
if (!pr.mergeable) {
|
||||||
|
return refuse(`it is not mergeable (\`${pr.mergeable_state}\`) - most likely a conflict with \`${pr.base.ref}\`.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- CI on the head commit ----
|
||||||
|
const checkRuns = await github.paginate(github.rest.checks.listForRef, {
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
ref: headSha,
|
||||||
|
filter: 'latest',
|
||||||
|
per_page: 100
|
||||||
|
});
|
||||||
|
const pendingChecks = checkRuns.filter((run) => run.status !== 'completed');
|
||||||
|
const failedChecks = checkRuns.filter((run) => run.status === 'completed' && !OK_CONCLUSIONS.has(run.conclusion));
|
||||||
|
|
||||||
|
if (pendingChecks.length) {
|
||||||
|
return refuse(
|
||||||
|
`${pendingChecks.length} check(s) are still running on \`${headSha.slice(0, 7)}\`:\n\n` +
|
||||||
|
`${formatList(pendingChecks.map((run) => run.name))}\n\nRe-run \`/bot merge\` once they finish.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (failedChecks.length) {
|
||||||
|
return refuse(
|
||||||
|
`${failedChecks.length} check(s) are not green on \`${headSha.slice(0, 7)}\`:\n\n` +
|
||||||
|
formatList(failedChecks.map((run) => `${run.name} (${run.conclusion})`))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: combined } = await github.rest.repos.getCombinedStatusForRef({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
ref: headSha
|
||||||
|
});
|
||||||
|
// total_count 0 only means there are no legacy statuses.
|
||||||
|
if (combined.total_count > 0 && combined.state !== 'success') {
|
||||||
|
return refuse(
|
||||||
|
`the combined commit status on \`${headSha.slice(0, 7)}\` is \`${combined.state}\`:\n\n` +
|
||||||
|
formatList(combined.statuses.filter((status) => status.state !== 'success')
|
||||||
|
.map((status) => `${status.context} (${status.state})`))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Requires the check to have actually run, not merely to have not failed.
|
||||||
|
const requiredCheck = checkRuns.find((run) =>
|
||||||
|
run.name === REQUIRED_CHECK &&
|
||||||
|
run.app && run.app.slug === 'github-actions' &&
|
||||||
|
run.status === 'completed' && OK_CONCLUSIONS.has(run.conclusion));
|
||||||
|
|
||||||
|
if (!requiredCheck) {
|
||||||
|
return refuse(
|
||||||
|
`the \`${REQUIRED_CHECK}\` check has not succeeded on \`${headSha.slice(0, 7)}\`. ` +
|
||||||
|
'If it never ran, a maintainer needs to approve the workflow run first.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const scopeSummary = `${files.length} file(s), all within:\n${formatList(grants)}`;
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
core.info('Dry run: every gate passed, not merging.');
|
||||||
|
await react('+1');
|
||||||
|
await say(
|
||||||
|
`@${commenter} **dry run** - this PR passes every gate and I *would* squash-merge it ` +
|
||||||
|
`at \`${headSha.slice(0, 7)}\`.\n\nVerified scope: ${scopeSummary}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- re-validate, then merge ----
|
||||||
|
// An unchanged head SHA means the verified file list still holds.
|
||||||
|
const { data: fresh } = await github.rest.pulls.get({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
pull_number: pr.number
|
||||||
|
});
|
||||||
|
|
||||||
|
if (fresh.head.sha !== headSha || fresh.base.ref !== pr.base.ref || fresh.state !== 'open' || fresh.merged || fresh.draft) {
|
||||||
|
return refuse('it changed while I was checking it. Nothing was merged - re-run `/bot merge`.');
|
||||||
|
}
|
||||||
|
|
||||||
|
let merged;
|
||||||
|
try {
|
||||||
|
// Pinned to the verified head: a moved head fails with 409.
|
||||||
|
({ data: merged } = await github.rest.pulls.merge({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
pull_number: pr.number,
|
||||||
|
sha: headSha,
|
||||||
|
merge_method: MERGE_METHOD,
|
||||||
|
commit_title: `${pr.title} (#${pr.number})`,
|
||||||
|
commit_message:
|
||||||
|
`Merged by /bot merge on behalf of @${commenter} (id ${comment.user.id}).\n` +
|
||||||
|
`Grants: ${grants.join(', ')}\nHead: ${headSha}\n`
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
const hint = {
|
||||||
|
403: 'the workflow token cannot write to the repository.',
|
||||||
|
405: 'GitHub refused the merge - branch protection, a required review or check, a newly added CODEOWNERS file, or squash merging being disabled.',
|
||||||
|
409: `the head commit moved after I verified it (was \`${headSha.slice(0, 7)}\`).`,
|
||||||
|
422: 'GitHub rejected the merge as invalid.'
|
||||||
|
}[error.status];
|
||||||
|
|
||||||
|
if (!hint) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
await refuse(`${hint}\n\n> ${error.message}\n\nNothing was merged.`);
|
||||||
|
core.setFailed(`Delegated merge failed: ${error.status} ${error.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
core.info(`Merged #${pr.number} as ${merged.sha}.`);
|
||||||
|
await react('rocket');
|
||||||
|
await say(
|
||||||
|
`@${commenter} squash-merged into \`${pr.base.ref}\` as ${merged.sha}.\n\nVerified scope: ${scopeSummary}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- re-kick the build ----
|
||||||
|
// A GITHUB_TOKEN merge fires no push event, so build_all.yml would
|
||||||
|
// otherwise never see these files.
|
||||||
|
try {
|
||||||
|
await github.rest.actions.createWorkflowDispatch({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
workflow_id: 'build_all.yml',
|
||||||
|
ref: pr.base.ref
|
||||||
|
});
|
||||||
|
core.info(`Dispatched build_all.yml on ${pr.base.ref}.`);
|
||||||
|
} catch (error) {
|
||||||
|
core.warning(`Merged successfully, but dispatching build_all.yml failed: ${error.message}`);
|
||||||
|
}
|
||||||
@@ -56,6 +56,7 @@ ctest --test-dir ./tests/fff_print
|
|||||||
- Add helper functions or utilities only when existing code cannot reasonably be reused. Avoid duplication.
|
- Add helper functions or utilities only when existing code cannot reasonably be reused. Avoid duplication.
|
||||||
- Keep code concise and clear. Manually simplify AI generated bloated codes before review.
|
- Keep code concise and clear. Manually simplify AI generated bloated codes before review.
|
||||||
- Include targeted tests or documented verification for behavior changes, especially in slicing logic, profiles, formats, and GUI defaults.
|
- Include targeted tests or documented verification for behavior changes, especially in slicing logic, profiles, formats, and GUI defaults.
|
||||||
|
- For profile changes (`resources/profiles/<Vendor>/**`), check that `version` in the sibling `resources/profiles/<Vendor>.json` was bumped.
|
||||||
- For translation changes (`localization/i18n/**/*.po`), check that recurring terms match the [Localization glossary](https://github.com/OrcaSlicer/OrcaSlicer_WIKI/blob/main/guides/localization_glossary.md) for that language.
|
- For translation changes (`localization/i18n/**/*.po`), check that recurring terms match the [Localization glossary](https://github.com/OrcaSlicer/OrcaSlicer_WIKI/blob/main/guides/localization_glossary.md) for that language.
|
||||||
|
|
||||||
## Localization & translations
|
## Localization & translations
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
|
#include <functional>
|
||||||
#include <limits>
|
#include <limits>
|
||||||
#include <numeric>
|
#include <numeric>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
@@ -39,7 +40,11 @@ std::vector<ExtendedPoint<L::Dim>> estimate_points_properties(const POINTS&
|
|||||||
const AABBTreeLines::LinesDistancer<L>& unscaled_prev_layer,
|
const AABBTreeLines::LinesDistancer<L>& unscaled_prev_layer,
|
||||||
float flow_width,
|
float flow_width,
|
||||||
float max_line_length = -1.0f,
|
float max_line_length = -1.0f,
|
||||||
float min_distance = -1.0f)
|
float min_distance = -1.0f,
|
||||||
|
// Maps an overhang distance onto the speed it will be printed at. Interior sampling
|
||||||
|
// needs it to tell which of the points it could add would change the G-code, and is
|
||||||
|
// skipped without it.
|
||||||
|
const std::function<float(float)>& distance_to_speed = {})
|
||||||
{
|
{
|
||||||
bool looped = input_points.front() == input_points.back();
|
bool looped = input_points.front() == input_points.back();
|
||||||
std::function<size_t(size_t,size_t)> get_prev_index = [](size_t idx, size_t count) {
|
std::function<size_t(size_t,size_t)> get_prev_index = [](size_t idx, size_t count) {
|
||||||
@@ -120,6 +125,107 @@ std::vector<ExtendedPoint<L::Dim>> estimate_points_properties(const POINTS&
|
|||||||
points.push_back(next_point);
|
points.push_back(next_point);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ORCA: Interior sampling
|
||||||
|
// The passes below infer the support under a span from its endpoints alone, so an interior that is supported
|
||||||
|
// differently from both ends is invisible to them: the outer perimeter of an overhang whose ends are caged by
|
||||||
|
// full height walls reads as supported along its whole length. Probe the interior, keep the samples the
|
||||||
|
// endpoint interpolation fails to predict, and bisect either side of each one, so a span that is only partly
|
||||||
|
// unsupported gets points where its support actually changes instead of one reading spread across all of it.
|
||||||
|
if (PREV_LAYER_BOUNDARY_OFFSET && ADD_INTERSECTIONS && min_distance > 0 && distance_to_speed) {
|
||||||
|
// Probe at least this densely before treating matching samples as evidence that a span is uniform. The
|
||||||
|
// segmentation pass below only splits lines of 2mm or more, and every pass here drops points closer
|
||||||
|
// together than min_spacing, so finer discovery would not produce a more precise speed transition.
|
||||||
|
const double max_probe_spacing = std::max(2., 4. * min_spacing);
|
||||||
|
// A backstop for that length test, which on a non-finite length would never be met.
|
||||||
|
constexpr int max_bisection_depth = 10;
|
||||||
|
// Whether two readings are interchangeable. A segment is printed at the lower of the speeds its ends
|
||||||
|
// read, so a sample that agrees on speed with what is already known cannot change the G-code, whatever
|
||||||
|
// its distance says. The distances themselves are far too coarse a stand-in for this: the speed sections
|
||||||
|
// interpolate, so readings a small fraction of min_distance apart can still be tens of mm/s apart.
|
||||||
|
// The tolerance matches the one GCode.cpp applies when it decides a path has a variable speed at all.
|
||||||
|
auto same_speed = [&distance_to_speed](float a, float b) {
|
||||||
|
return std::abs(distance_to_speed(a) - distance_to_speed(b)) <= 1.f;
|
||||||
|
};
|
||||||
|
// Whether the first reading is printed slower than the second, once they are known to differ.
|
||||||
|
auto prints_slower = [&distance_to_speed](float a, float b) { return distance_to_speed(a) < distance_to_speed(b); };
|
||||||
|
|
||||||
|
// Part of a segment still to bisect: its positions along the segment and bisections left.
|
||||||
|
struct Subspan { double t0, t1; int depth; };
|
||||||
|
|
||||||
|
std::vector<ExtendedPoint<L::Dim>> sampled_points; // Populated lazily, on the first insertion
|
||||||
|
std::vector<std::pair<double, float>> interior; // Samples of one segment, keyed by position along it
|
||||||
|
std::vector<Subspan> pending;
|
||||||
|
|
||||||
|
for (size_t point_idx = 0; point_idx + 1 < points.size(); ++point_idx) {
|
||||||
|
const ExtendedPoint<L::Dim>& curr = points[point_idx];
|
||||||
|
const ExtendedPoint<L::Dim>& next = points[point_idx + 1];
|
||||||
|
const Vec step = next.position - curr.position;
|
||||||
|
const double line_len = step.norm();
|
||||||
|
|
||||||
|
interior.clear();
|
||||||
|
if (line_len >= max_probe_spacing)
|
||||||
|
pending.push_back({0., 1., max_bisection_depth});
|
||||||
|
|
||||||
|
while (!pending.empty()) {
|
||||||
|
const Subspan subspan = pending.back();
|
||||||
|
pending.pop_back();
|
||||||
|
if (subspan.depth <= 0 || (subspan.t1 - subspan.t0) * line_len < max_probe_spacing)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
const double t = 0.5 * (subspan.t0 + subspan.t1);
|
||||||
|
auto [distance, nearest_line, x] = unscaled_prev_layer.template distance_from_lines_extra<SIGNED_DISTANCE>(
|
||||||
|
(curr.position + t * step).template cast<AABBScalar>());
|
||||||
|
const float sampled = float(distance + boundary_offset);
|
||||||
|
|
||||||
|
interior.emplace_back(t, sampled);
|
||||||
|
pending.push_back({subspan.t0, t, subspan.depth - 1});
|
||||||
|
pending.push_back({t, subspan.t1, subspan.depth - 1});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!interior.empty()) {
|
||||||
|
std::sort(interior.begin(), interior.end(),
|
||||||
|
[](const std::pair<double, float>& l, const std::pair<double, float>& r) { return l.first < r.first; });
|
||||||
|
// Coarse probing keeps every sample it took until this pass can see which ones bracket a speed
|
||||||
|
// transition. Matching samples cannot be discarded during discovery: one may be the last
|
||||||
|
// supported point before a narrow unsupported pocket found by a later probe.
|
||||||
|
size_t kept = 0;
|
||||||
|
for (size_t i = 0; i < interior.size(); ++i) {
|
||||||
|
const float sample = interior[i].second;
|
||||||
|
const bool at_start = kept == 0; // Nothing kept yet, so the segment's own start precedes it
|
||||||
|
const bool at_end = i + 1 == interior.size(); // And nothing follows the last sample but the segment's end
|
||||||
|
const float before = at_start ? curr.distance : interior[kept - 1].second;
|
||||||
|
const float after = at_end ? next.distance : interior[i + 1].second;
|
||||||
|
// A sample is worth a point in the path only where it prints at a different speed from the
|
||||||
|
// readings either side of it. Differing from one of the segment's own ends is not enough on
|
||||||
|
// its own where the sample is the faster of the two: the segmentation pass below already
|
||||||
|
// ends the slowdown an end reads, at a distance taken from how far out that end is rather
|
||||||
|
// than from wherever bisection happened to stop, and a point here would leave the span
|
||||||
|
// beside the end too short for that pass to run at all. Support an end cannot account for,
|
||||||
|
// where the interior is the slower reading, is exactly what this pass is here to find.
|
||||||
|
const bool worth_before = !same_speed(sample, before) && (!at_start || prints_slower(sample, before));
|
||||||
|
const bool worth_after = !same_speed(sample, after) && (!at_end || prints_slower(sample, after));
|
||||||
|
if (worth_before || worth_after)
|
||||||
|
interior[kept++] = interior[i];
|
||||||
|
}
|
||||||
|
interior.resize(kept);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!interior.empty() && sampled_points.empty()) {
|
||||||
|
sampled_points.reserve(points.size() + 8);
|
||||||
|
sampled_points.assign(points.begin(), points.begin() + point_idx + 1);
|
||||||
|
}
|
||||||
|
if (!sampled_points.empty()) {
|
||||||
|
// Only a sub-span of max_probe_spacing or more is ever bisected, so these sit at least
|
||||||
|
// 2 * min_spacing apart, and need none of the filtering the passes either side of this one do.
|
||||||
|
for (const auto& [t, distance] : interior)
|
||||||
|
sampled_points.push_back({curr.position + t * step, distance});
|
||||||
|
sampled_points.push_back(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!sampled_points.empty())
|
||||||
|
points = std::move(sampled_points);
|
||||||
|
}
|
||||||
|
|
||||||
// Segmentation handling
|
// Segmentation handling
|
||||||
if (PREV_LAYER_BOUNDARY_OFFSET && ADD_INTERSECTIONS) {
|
if (PREV_LAYER_BOUNDARY_OFFSET && ADD_INTERSECTIONS) {
|
||||||
std::vector<ExtendedPoint<L::Dim>> new_points;
|
std::vector<ExtendedPoint<L::Dim>> new_points;
|
||||||
@@ -362,9 +468,28 @@ public:
|
|||||||
smallest_distance_with_lower_speed=-1.f;
|
smallest_distance_with_lower_speed=-1.f;
|
||||||
|
|
||||||
// Orca: Pass to the point properties estimator the smallest ovehang distance that triggers a slowdown (smallest_distance_with_lower_speed)
|
// Orca: Pass to the point properties estimator the smallest ovehang distance that triggers a slowdown (smallest_distance_with_lower_speed)
|
||||||
|
auto calculate_speed = [&speed_sections, &original_speed](float distance) {
|
||||||
|
float final_speed;
|
||||||
|
if (distance <= speed_sections.front().first) {
|
||||||
|
final_speed = original_speed;
|
||||||
|
} else if (distance >= speed_sections.back().first) {
|
||||||
|
final_speed = speed_sections.back().second;
|
||||||
|
} else {
|
||||||
|
size_t section_idx = 0;
|
||||||
|
while (distance > speed_sections[section_idx + 1].first) {
|
||||||
|
section_idx++;
|
||||||
|
}
|
||||||
|
float t = (distance - speed_sections[section_idx].first) /
|
||||||
|
(speed_sections[section_idx + 1].first - speed_sections[section_idx].first);
|
||||||
|
t = std::clamp(t, 0.0f, 1.0f);
|
||||||
|
final_speed = (1.0f - t) * speed_sections[section_idx].second + t * speed_sections[section_idx + 1].second;
|
||||||
|
}
|
||||||
|
return round(final_speed);
|
||||||
|
};
|
||||||
|
|
||||||
std::vector<ExtendedPoint<3>> extended_points =
|
std::vector<ExtendedPoint<3>> extended_points =
|
||||||
estimate_points_properties<true, true, true, true>(path.polyline.points, prev_layer_boundaries[current_object], path.width, -1,
|
estimate_points_properties<true, true, true, true>(path.polyline.points, prev_layer_boundaries[current_object], path.width, -1,
|
||||||
smallest_distance_with_lower_speed);
|
smallest_distance_with_lower_speed, calculate_speed);
|
||||||
const auto width_inv = 1.0f / path.width;
|
const auto width_inv = 1.0f / path.width;
|
||||||
std::vector<ProcessedPoint> processed_points;
|
std::vector<ProcessedPoint> processed_points;
|
||||||
processed_points.reserve(extended_points.size());
|
processed_points.reserve(extended_points.size());
|
||||||
@@ -423,25 +548,6 @@ public:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
auto calculate_speed = [&speed_sections, &original_speed](float distance) {
|
|
||||||
float final_speed;
|
|
||||||
if (distance <= speed_sections.front().first) {
|
|
||||||
final_speed = original_speed;
|
|
||||||
} else if (distance >= speed_sections.back().first) {
|
|
||||||
final_speed = speed_sections.back().second;
|
|
||||||
} else {
|
|
||||||
size_t section_idx = 0;
|
|
||||||
while (distance > speed_sections[section_idx + 1].first) {
|
|
||||||
section_idx++;
|
|
||||||
}
|
|
||||||
float t = (distance - speed_sections[section_idx].first) /
|
|
||||||
(speed_sections[section_idx + 1].first - speed_sections[section_idx].first);
|
|
||||||
t = std::clamp(t, 0.0f, 1.0f);
|
|
||||||
final_speed = (1.0f - t) * speed_sections[section_idx].second + t * speed_sections[section_idx + 1].second;
|
|
||||||
}
|
|
||||||
return round(final_speed);
|
|
||||||
};
|
|
||||||
|
|
||||||
float extrusion_speed = std::min(calculate_speed(curr.distance), calculate_speed(next.distance));
|
float extrusion_speed = std::min(calculate_speed(curr.distance), calculate_speed(next.distance));
|
||||||
// ORCA: Clamp resulting speed to lowest of calculated speed based on the overhang values and the current speed
|
// ORCA: Clamp resulting speed to lowest of calculated speed based on the overhang values and the current speed
|
||||||
// Fixes bug where resulting overhang speed is higher than the current speed due to (for example) volumetric flow limits.
|
// Fixes bug where resulting overhang speed is higher than the current speed due to (for example) volumetric flow limits.
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ static std::vector<std::string> s_project_options {
|
|||||||
"filament_multi_colour",
|
"filament_multi_colour",
|
||||||
"wipe_tower_x",
|
"wipe_tower_x",
|
||||||
"wipe_tower_y",
|
"wipe_tower_y",
|
||||||
"wipe_tower_rotation_angle",
|
|
||||||
"curr_bed_type",
|
"curr_bed_type",
|
||||||
"flush_multiplier",
|
"flush_multiplier",
|
||||||
// Fast-purge mode: project-level purge control, inert at Default.
|
// Fast-purge mode: project-level purge control, inert at Default.
|
||||||
|
|||||||
@@ -2887,7 +2887,7 @@ void GLCanvas3D::reload_scene(bool refresh_immediately, bool force_full_scene_re
|
|||||||
float x = dynamic_cast<const ConfigOptionFloats*>(proj_cfg.option("wipe_tower_x"))->get_at(plate_id);
|
float x = dynamic_cast<const ConfigOptionFloats*>(proj_cfg.option("wipe_tower_x"))->get_at(plate_id);
|
||||||
float y = dynamic_cast<const ConfigOptionFloats*>(proj_cfg.option("wipe_tower_y"))->get_at(plate_id);
|
float y = dynamic_cast<const ConfigOptionFloats*>(proj_cfg.option("wipe_tower_y"))->get_at(plate_id);
|
||||||
float w = dynamic_cast<const ConfigOptionFloat*>(m_config->option("prime_tower_width"))->value;
|
float w = dynamic_cast<const ConfigOptionFloat*>(m_config->option("prime_tower_width"))->value;
|
||||||
float a = dynamic_cast<const ConfigOptionFloat*>(proj_cfg.option("wipe_tower_rotation_angle"))->value;
|
float a = dynamic_cast<const ConfigOptionFloat*>(m_config->option("wipe_tower_rotation_angle"))->value;
|
||||||
// BBS
|
// BBS
|
||||||
float v = dynamic_cast<const ConfigOptionFloat*>(m_config->option("prime_volume"))->value;
|
float v = dynamic_cast<const ConfigOptionFloat*>(m_config->option("prime_volume"))->value;
|
||||||
Vec3d plate_origin = ppl.get_plate(plate_id)->get_origin();
|
Vec3d plate_origin = ppl.get_plate(plate_id)->get_origin();
|
||||||
|
|||||||
@@ -3273,15 +3273,12 @@ bool GUI_App::on_init_inner()
|
|||||||
}
|
}
|
||||||
} */
|
} */
|
||||||
|
|
||||||
copy_network_if_available();
|
|
||||||
|
|
||||||
if (scrn) {
|
if (scrn) {
|
||||||
scrn->SetText(_L("Loading Plugins") + dots, 20);
|
scrn->SetText(_L("Loading Plugins") + dots, 20);
|
||||||
wxYield();
|
wxYield();
|
||||||
}
|
}
|
||||||
|
|
||||||
on_init_network();
|
|
||||||
|
|
||||||
// Initialize plugins after network then register on_load callbacks so once the plugin loads finish, it gets registered automatically.
|
// Initialize plugins after network then register on_load callbacks so once the plugin loads finish, it gets registered automatically.
|
||||||
// initialize() also installs the libslic3r hooks (capability resolver,
|
// initialize() also installs the libslic3r hooks (capability resolver,
|
||||||
// slicing-pipeline dispatcher) via plugin_hooks::install() -- no
|
// slicing-pipeline dispatcher) via plugin_hooks::install() -- no
|
||||||
@@ -3310,6 +3307,9 @@ bool GUI_App::on_init_inner()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
copy_network_if_available();
|
||||||
|
on_init_network();
|
||||||
|
|
||||||
if (m_agent)
|
if (m_agent)
|
||||||
plugin_mgr.set_cloud_agent(std::dynamic_pointer_cast<OrcaCloudServiceAgent>(m_agent->get_cloud_agent()));
|
plugin_mgr.set_cloud_agent(std::dynamic_pointer_cast<OrcaCloudServiceAgent>(m_agent->get_cloud_agent()));
|
||||||
|
|
||||||
|
|||||||
@@ -12777,7 +12777,7 @@ void Plater::priv::take_snapshot(const std::string& snapshot_name, const UndoRed
|
|||||||
ModelWipeTower& tower = model.wipe_tower;
|
ModelWipeTower& tower = model.wipe_tower;
|
||||||
|
|
||||||
tower.positions[plate_idx] = Vec2d(tower_x_opt->get_at(plate_idx), tower_y_opt->get_at(plate_idx));
|
tower.positions[plate_idx] = Vec2d(tower_x_opt->get_at(plate_idx), tower_y_opt->get_at(plate_idx));
|
||||||
tower.rotation = proj_cfg.opt_float("wipe_tower_rotation_angle");
|
tower.rotation = config.opt_float("wipe_tower_rotation_angle");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const GLGizmosManager& gizmos = get_current_canvas3D()->get_canvas_type() == GLCanvas3D::CanvasAssembleView ? assemble_view->get_canvas3d()->get_gizmos_manager() : view3D->get_canvas3d()->get_gizmos_manager();
|
const GLGizmosManager& gizmos = get_current_canvas3D()->get_canvas_type() == GLCanvas3D::CanvasAssembleView ? assemble_view->get_canvas3d()->get_gizmos_manager() : view3D->get_canvas3d()->get_gizmos_manager();
|
||||||
@@ -12887,7 +12887,7 @@ void Plater::priv::undo_redo_to(std::vector<UndoRedo::Snapshot>::const_iterator
|
|||||||
ModelWipeTower& tower = model.wipe_tower;
|
ModelWipeTower& tower = model.wipe_tower;
|
||||||
|
|
||||||
tower.positions[plate_idx] = Vec2d(tower_x_opt->get_at(plate_idx), tower_y_opt->get_at(plate_idx));
|
tower.positions[plate_idx] = Vec2d(tower_x_opt->get_at(plate_idx), tower_y_opt->get_at(plate_idx));
|
||||||
tower.rotation = proj_cfg.opt_float("wipe_tower_rotation_angle");
|
tower.rotation = config.opt_float("wipe_tower_rotation_angle");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const int layer_range_idx = it_snapshot->snapshot_data.layer_range_idx;
|
const int layer_range_idx = it_snapshot->snapshot_data.layer_range_idx;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ add_executable(${_TEST_NAME}_tests
|
|||||||
test_helpers.hpp
|
test_helpers.hpp
|
||||||
test_cooling.cpp
|
test_cooling.cpp
|
||||||
test_extrusion_entity.cpp
|
test_extrusion_entity.cpp
|
||||||
|
test_extrusion_processor.cpp
|
||||||
test_fill.cpp
|
test_fill.cpp
|
||||||
test_flow.cpp
|
test_flow.cpp
|
||||||
test_gcode_timing.cpp
|
test_gcode_timing.cpp
|
||||||
|
|||||||
@@ -0,0 +1,441 @@
|
|||||||
|
#include <catch2/catch_all.hpp>
|
||||||
|
|
||||||
|
#include "libslic3r/AABBTreeLines.hpp"
|
||||||
|
#include "libslic3r/GCode/ExtrusionProcessor.hpp"
|
||||||
|
#include "libslic3r/GCodeReader.hpp"
|
||||||
|
#include "libslic3r/TriangleMesh.hpp"
|
||||||
|
|
||||||
|
#include "test_helpers.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
using namespace Slic3r;
|
||||||
|
using namespace Slic3r::Test;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Print settings the assertions below are derived from.
|
||||||
|
constexpr double caged_layer_height = 0.2; // mm
|
||||||
|
constexpr double caged_wall_width = 0.42; // mm, outer wall line width
|
||||||
|
constexpr double caged_outer_wall_speed = 200.; // mm/s
|
||||||
|
constexpr double caged_slow_speed = 100.; // mm/s, between every configured overhang speed (<= 50) and the wall speed
|
||||||
|
|
||||||
|
// A wall running 0.2mm out over a previous layer whose edge dishes 0.03mm away from it in the middle,
|
||||||
|
// standing in for the endpoint readings a caged overhang perimeter takes: enough of a difference to
|
||||||
|
// print at another speed, but only a fraction of the distance at which slowdown begins.
|
||||||
|
constexpr double dished_wall_gap = 0.2; // mm, how far the wall runs out past the previous layer's edge
|
||||||
|
constexpr double dished_layer_depth = 0.03; // mm, how much further out the middle of it reads
|
||||||
|
constexpr double dished_min_distance = 0.042; // mm, the reading at which the configured speeds begin to slow down
|
||||||
|
// Every reading here is past that, so the whole wall is slowed and only the amount is in question.
|
||||||
|
constexpr float dished_end_reading = float(dished_wall_gap + 0.5 * caged_wall_width);
|
||||||
|
constexpr float dished_mid_reading = float(dished_end_reading + dished_layer_depth);
|
||||||
|
// The two readings are dished_layer_depth apart, so half of that tells them apart while still allowing
|
||||||
|
// for the points the passes after sampling add, which read a little further out than the ends do.
|
||||||
|
constexpr double dished_reading_tolerance = 0.5 * dished_layer_depth;
|
||||||
|
|
||||||
|
// A 40 x 20 x 20 mm box with a 45 degree overhang cut into the y = 0 side. The sloped face spans
|
||||||
|
// x = 5.086 .. 34.914 only, so the full-height walls of the box cage both ends of every overhang
|
||||||
|
// perimeter: the endpoints look supported even though the span between them is not.
|
||||||
|
TriangleMesh caged_overhang_mesh()
|
||||||
|
{
|
||||||
|
return TriangleMesh(
|
||||||
|
{
|
||||||
|
{5.0859987f, 10.167065f, 5.711731f}, {34.914257f, 10.167065f, 5.711731f},
|
||||||
|
{34.914257f, 0.f, 15.878796f}, {5.0859995f, 0.f, 15.878796f},
|
||||||
|
{0.f, 0.f, 0.f}, {0.f, 0.f, 20.f},
|
||||||
|
{0.f, 20.f, 20.f}, {0.f, 20.f, 0.f},
|
||||||
|
{40.f, 20.f, 20.f}, {40.f, 20.f, 0.f},
|
||||||
|
{40.f, 0.f, 20.f}, {40.f, 0.f, 0.f},
|
||||||
|
{34.914257f, 0.f, 0.f}, {5.0859995f, 0.f, 0.f},
|
||||||
|
{34.914257f, 10.167065f, 0.f}, {5.0859995f, 10.167065f, 0.f},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
{0, 1, 2}, {0, 2, 3}, {4, 5, 6}, {4, 6, 7}, {7, 6, 8}, {7, 8, 9},
|
||||||
|
{9, 8, 10}, {9, 10, 11}, {12, 11, 10}, {5, 4, 13}, {5, 13, 3}, {2, 12, 10},
|
||||||
|
{5, 3, 2}, {10, 5, 2}, {9, 11, 12}, {9, 12, 14}, {13, 4, 7}, {9, 14, 15},
|
||||||
|
{15, 13, 7}, {7, 9, 15}, {8, 6, 5}, {8, 5, 10}, {14, 1, 0}, {14, 0, 15},
|
||||||
|
{2, 1, 14}, {2, 14, 12}, {15, 0, 3}, {15, 3, 13},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mesh geometry the wall filters below are derived from.
|
||||||
|
constexpr double caged_box_depth = 20.; // mm, the box spans y = 0 .. 20
|
||||||
|
constexpr double caged_slope_face_sum = 15.878796; // mm, y + z of the sloped face, from its corners
|
||||||
|
// The sloped face spans this x range; outside it the box walls run full height.
|
||||||
|
constexpr double caged_slope_x_min = 5.0859995;
|
||||||
|
constexpr double caged_slope_x_max = 34.914257;
|
||||||
|
constexpr double caged_slope_span = caged_slope_x_max - caged_slope_x_min; // ~29.8 mm
|
||||||
|
// The z range the sloped face occupies, from the same fixture vertices.
|
||||||
|
constexpr double caged_slope_z_min = 5.711731;
|
||||||
|
constexpr double caged_slope_z_max = 15.878796;
|
||||||
|
// The lowest slope layer still sits on the solid body below the notch, so it is fully supported and
|
||||||
|
// runs at the outer wall speed by design. The caged span proper begins one layer above it.
|
||||||
|
constexpr double caged_span_z_min = caged_slope_z_min + caged_layer_height;
|
||||||
|
|
||||||
|
// A layer printed at z is sliced at z - layer_height / 2, and the outer wall centreline sits half a
|
||||||
|
// line width inside the contour, so the wall on the slope satisfies y + z = 16.189.
|
||||||
|
constexpr double caged_slope_wall_sum = caged_slope_face_sum + 0.5 * caged_layer_height + 0.5 * caged_wall_width;
|
||||||
|
// Same inset on the fully supported y = 20 face, vertical over the whole height.
|
||||||
|
constexpr double caged_back_wall_y = caged_box_depth - 0.5 * caged_wall_width;
|
||||||
|
// And on the y = 0 face, which runs full height only outside the slope's x range.
|
||||||
|
constexpr double caged_front_wall_y = 0.5 * caged_wall_width;
|
||||||
|
// Arachne varies the wall width along a face, and the centreline inset is half that width, so a
|
||||||
|
// wall sits within about half a line width of where the nominal inset alone would put it. The
|
||||||
|
// faces being selected are millimetres apart, so this stays far from ambiguous.
|
||||||
|
constexpr double caged_wall_tolerance = 0.5 * caged_wall_width;
|
||||||
|
|
||||||
|
// Feed rates in mm/min of the long outer wall extrusions `keep_line` selects.
|
||||||
|
template<typename KeepLine> std::vector<double> outer_wall_feed_rates(const std::string& gcode, KeepLine keep_line)
|
||||||
|
{
|
||||||
|
std::vector<double> feed_rates;
|
||||||
|
bool outer_wall = false;
|
||||||
|
GCodeReader parser;
|
||||||
|
parser.parse_buffer(gcode, [&feed_rates, &outer_wall, &keep_line](GCodeReader& self, const GCodeReader::GCodeLine& line) {
|
||||||
|
const std::string_view comment = line.comment();
|
||||||
|
if (comment.find("FEATURE:") != std::string_view::npos || comment.find("TYPE:") != std::string_view::npos)
|
||||||
|
outer_wall = comment.find("Outer wall") != std::string_view::npos ||
|
||||||
|
comment.find("External perimeter") != std::string_view::npos;
|
||||||
|
|
||||||
|
if (outer_wall && line.extruding(self) && line.dist_XY(self) > 1.0 && keep_line(self, line))
|
||||||
|
feed_rates.push_back(line.new_F(self));
|
||||||
|
});
|
||||||
|
|
||||||
|
return feed_rates;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The caged 45 degree overhang: outer walls crossing the sloped face for most of its width, on the
|
||||||
|
// layers where the face genuinely overhangs.
|
||||||
|
// Both ends are tested against the slope plane rather than requiring a constant Y. Arachne's
|
||||||
|
// variable-width walls drift slightly in Y along the same slope (Y6.186 -> Y6.189 on one move), so
|
||||||
|
// a constant-Y filter matches almost nothing under Arachne and silently reduces its coverage.
|
||||||
|
// The length test excludes the cage walls: they are only as wide as the box is either side of the
|
||||||
|
// slope, but being vertical their y + z sweeps through the slope plane as z rises, so a couple of
|
||||||
|
// their fully supported moves would otherwise be counted as part of the span.
|
||||||
|
std::vector<double> caged_slope_feed_rates(const std::string& gcode)
|
||||||
|
{
|
||||||
|
return outer_wall_feed_rates(gcode, [](const GCodeReader& self, const GCodeReader::GCodeLine& line) {
|
||||||
|
const double z = line.new_Z(self);
|
||||||
|
return z > caged_span_z_min && z < caged_slope_z_max &&
|
||||||
|
line.dist_XY(self) > 0.5 * caged_slope_span &&
|
||||||
|
std::abs(self.y() + z - caged_slope_wall_sum) < caged_wall_tolerance &&
|
||||||
|
std::abs(line.new_Y(self) + z - caged_slope_wall_sum) < caged_wall_tolerance;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// The opposite, fully supported face, skipping the initial layer and its own speed settings.
|
||||||
|
std::vector<double> back_wall_feed_rates(const std::string& gcode)
|
||||||
|
{
|
||||||
|
return outer_wall_feed_rates(gcode, [](const GCodeReader& self, const GCodeReader::GCodeLine& line) {
|
||||||
|
return line.new_Z(self) > 1.5 * caged_layer_height &&
|
||||||
|
std::abs(self.y() - caged_back_wall_y) < caged_wall_tolerance &&
|
||||||
|
std::abs(line.new_Y(self) - caged_back_wall_y) < caged_wall_tolerance;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// The first layer printed entirely above the slope. Its y = 0 wall runs the full width of the box.
|
||||||
|
const double caged_layer_above_slope_z = std::ceil(caged_slope_z_max / caged_layer_height) * caged_layer_height;
|
||||||
|
|
||||||
|
// The parts of that wall standing on the cage rather than the slope, so on a contour identical to their own.
|
||||||
|
// Where the support changes is found by bisection, which stops at spans of 2mm, so the move spanning each end of
|
||||||
|
// the slope reaches a little way into the cage. Taking only the moves lying wholly outside the slope's x range
|
||||||
|
// leaves the wall that is unambiguously supported, without asserting how closely the bisection converged.
|
||||||
|
std::vector<double> cage_shoulder_feed_rates(const std::string& gcode)
|
||||||
|
{
|
||||||
|
return outer_wall_feed_rates(gcode, [](const GCodeReader& self, const GCodeReader::GCodeLine& line) {
|
||||||
|
return std::abs(line.new_Z(self) - caged_layer_above_slope_z) < 0.5 * caged_layer_height &&
|
||||||
|
std::abs(self.y() - caged_front_wall_y) < caged_wall_tolerance &&
|
||||||
|
std::abs(line.new_Y(self) - caged_front_wall_y) < caged_wall_tolerance &&
|
||||||
|
(std::max(self.x(), line.new_X(self)) <= caged_slope_x_min ||
|
||||||
|
std::min(self.x(), line.new_X(self)) >= caged_slope_x_max);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// The readings a 40mm wall takes over a previous layer whose edge falls away by 0.03mm towards the
|
||||||
|
// middle: both ends read the same, and the middle reads slightly further out over air. Whether that
|
||||||
|
// middle reading survives is what decides the speed the wall is printed at.
|
||||||
|
std::vector<ExtendedPoint<2>> sampled_wall_over_dished_layer(const std::function<float(float)>& distance_to_speed)
|
||||||
|
{
|
||||||
|
const AABBTreeLines::LinesDistancer<Linef> prev_layer(std::vector<Linef>{
|
||||||
|
{{0., 0.}, {20., -dished_layer_depth}},
|
||||||
|
{{20., -dished_layer_depth}, {40., 0.}},
|
||||||
|
{{40., 0.}, {40., -10.}},
|
||||||
|
{{40., -10.}, {0., -10.}},
|
||||||
|
{{0., -10.}, {0., 0.}},
|
||||||
|
});
|
||||||
|
const Points wall{Point::new_scale(0., dished_wall_gap), Point::new_scale(40., dished_wall_gap)};
|
||||||
|
|
||||||
|
return estimate_points_properties<true, true, true, true>(wall, prev_layer, caged_wall_width, -1.f,
|
||||||
|
dished_min_distance, distance_to_speed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A straight, otherwise supported wall over a previous-layer boundary with a 2mm-wide pocket. Moving the
|
||||||
|
// pocket between x = 10 and x = 20 covers both discovery away from the wall's midpoint and refinement around
|
||||||
|
// a midpoint that has already been discovered. The current wall is inset half its width from the flat boundary,
|
||||||
|
// so its supported readings are zero after the estimator applies its boundary offset.
|
||||||
|
constexpr double narrow_pocket_wall_length = 40.;
|
||||||
|
constexpr double narrow_pocket_width = 2.;
|
||||||
|
constexpr double narrow_pocket_depth = 0.3;
|
||||||
|
|
||||||
|
std::vector<ExtendedPoint<2>> sampled_wall_over_narrow_pocket(
|
||||||
|
double pocket_center, const std::function<float(float)>& distance_to_speed)
|
||||||
|
{
|
||||||
|
const double pocket_left = pocket_center - 0.5 * narrow_pocket_width;
|
||||||
|
const double pocket_right = pocket_center + 0.5 * narrow_pocket_width;
|
||||||
|
const AABBTreeLines::LinesDistancer<Linef> prev_layer(std::vector<Linef>{
|
||||||
|
{{0., 0.}, {pocket_left, 0.}},
|
||||||
|
{{pocket_left, 0.}, {pocket_left, -narrow_pocket_depth}},
|
||||||
|
{{pocket_left, -narrow_pocket_depth}, {pocket_right, -narrow_pocket_depth}},
|
||||||
|
{{pocket_right, -narrow_pocket_depth}, {pocket_right, 0.}},
|
||||||
|
{{pocket_right, 0.}, {narrow_pocket_wall_length, 0.}},
|
||||||
|
{{narrow_pocket_wall_length, 0.}, {narrow_pocket_wall_length, -10.}},
|
||||||
|
{{narrow_pocket_wall_length, -10.}, {0., -10.}},
|
||||||
|
{{0., -10.}, {0., 0.}},
|
||||||
|
});
|
||||||
|
const double wall_y = -0.5 * caged_wall_width;
|
||||||
|
const Points wall{Point::new_scale(0., wall_y), Point::new_scale(narrow_pocket_wall_length, wall_y)};
|
||||||
|
|
||||||
|
return estimate_points_properties<true, true, true, true>(wall, prev_layer, caged_wall_width, -1.f,
|
||||||
|
dished_min_distance, distance_to_speed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A cross section that grows a layer's worth on the two faces meeting at either end of a wall, as any
|
||||||
|
// 45 degree overhang does. The wall itself stands on a contour identical to its own, but its ends sit
|
||||||
|
// where the growing faces cut the corners off, and the previous layer's edge there is nearer than the
|
||||||
|
// half line width the centreline is inset by. Both ends therefore read an overhang while everything
|
||||||
|
// between them reads supported: the reverse of the caged span, and the case the sampling above must
|
||||||
|
// leave to the passes after it.
|
||||||
|
constexpr double stepped_wall_inset = 0.5 * caged_wall_width; // mm, centreline inset from the contour
|
||||||
|
constexpr double stepped_end_gap = stepped_wall_inset - caged_layer_height; // mm, how far inside the corner ends up
|
||||||
|
constexpr double stepped_wall_span = 30.; // mm, the length of the wall
|
||||||
|
|
||||||
|
std::vector<ExtendedPoint<2>> sampled_wall_between_growing_corners(const std::function<float(float)>& distance_to_speed)
|
||||||
|
{
|
||||||
|
const AABBTreeLines::LinesDistancer<Linef> prev_layer(std::vector<Linef>{
|
||||||
|
{{0., 0.}, {32., 0.}},
|
||||||
|
{{32., 0.}, {32., -stepped_wall_span}},
|
||||||
|
{{32., -stepped_wall_span}, {0., -stepped_wall_span}},
|
||||||
|
{{0., -stepped_wall_span}, {0., 0.}},
|
||||||
|
});
|
||||||
|
const Points wall{Point::new_scale(stepped_wall_inset, -stepped_end_gap),
|
||||||
|
Point::new_scale(stepped_wall_inset, stepped_end_gap - stepped_wall_span)};
|
||||||
|
|
||||||
|
return estimate_points_properties<true, true, true, true>(wall, prev_layer, caged_wall_width, -1.f,
|
||||||
|
dished_min_distance, distance_to_speed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// How much of a path is printed below the speed a fully supported reading gives. A segment is printed
|
||||||
|
// at the lower of the speeds its ends read.
|
||||||
|
double slowed_length(const std::vector<ExtendedPoint<2>>& points, const std::function<float(float)>& distance_to_speed)
|
||||||
|
{
|
||||||
|
double length = 0.;
|
||||||
|
for (size_t i = 0; i + 1 < points.size(); ++i)
|
||||||
|
if (std::min(distance_to_speed(points[i].distance), distance_to_speed(points[i + 1].distance)) < distance_to_speed(0.f))
|
||||||
|
length += (points[i + 1].position - points[i].position).norm();
|
||||||
|
return length;
|
||||||
|
}
|
||||||
|
|
||||||
|
float furthest_reading(const std::vector<ExtendedPoint<2>>& points)
|
||||||
|
{
|
||||||
|
return std::max_element(points.begin(), points.end(), [](const ExtendedPoint<2>& l, const ExtendedPoint<2>& r) {
|
||||||
|
return l.distance < r.distance;
|
||||||
|
})->distance;
|
||||||
|
}
|
||||||
|
|
||||||
|
DynamicPrintConfig caged_overhang_config(const char* wall_generator){
|
||||||
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||||
|
config.set_deserialize_strict({
|
||||||
|
{"nozzle_diameter", "0.4"},
|
||||||
|
{"initial_layer_print_height", caged_layer_height},
|
||||||
|
{"layer_height", caged_layer_height},
|
||||||
|
{"line_width", caged_wall_width},
|
||||||
|
{"outer_wall_line_width", caged_wall_width},
|
||||||
|
{"inner_wall_line_width", "0.45"},
|
||||||
|
{"wall_loops", "2"},
|
||||||
|
{"wall_generator", wall_generator},
|
||||||
|
{"wall_sequence", "inner wall/outer wall"},
|
||||||
|
{"sparse_infill_density", "15%"},
|
||||||
|
{"detect_overhang_wall", "1"},
|
||||||
|
{"enable_overhang_speed", "1"},
|
||||||
|
{"slowdown_for_curled_perimeters", "0"},
|
||||||
|
{"zaa_enabled", "0"},
|
||||||
|
{"outer_wall_speed", caged_outer_wall_speed},
|
||||||
|
{"inner_wall_speed", "300"},
|
||||||
|
{"overhang_1_4_speed", "0"},
|
||||||
|
{"overhang_2_4_speed", "50"},
|
||||||
|
{"overhang_3_4_speed", "30"},
|
||||||
|
{"overhang_4_4_speed", "10"},
|
||||||
|
{"bridge_speed", "50"},
|
||||||
|
{"filament_max_volumetric_speed", "22"},
|
||||||
|
{"slow_down_for_layer_cooling", "0"},
|
||||||
|
{"slow_down_layers", "0"}, // Nothing but the overhang settings may lower a wall speed
|
||||||
|
});
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string caged_overhang_gcode(const char* wall_generator)
|
||||||
|
{
|
||||||
|
Print print;
|
||||||
|
Model model;
|
||||||
|
init_print(std::vector<TriangleMesh>{caged_overhang_mesh()}, print, model, caged_overhang_config(wall_generator), nullptr,
|
||||||
|
false);
|
||||||
|
return gcode(print);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reports the matched move count alongside the extremes, so a filter that selected nothing is
|
||||||
|
// distinguishable from a span that simply was not slowed.
|
||||||
|
void info_feed_rates(const char* span, const std::vector<double>& feed_rates)
|
||||||
|
{
|
||||||
|
UNSCOPED_INFO("matched " << feed_rates.size() << " " << span << " moves");
|
||||||
|
if (!feed_rates.empty()) {
|
||||||
|
const auto extremes = std::minmax_element(feed_rates.begin(), feed_rates.end());
|
||||||
|
UNSCOPED_INFO("slowest " << *extremes.first / MM_PER_MIN << " mm/s, fastest " << *extremes.second / MM_PER_MIN << " mm/s");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
// Classic reproduces the endpoint-sampling bug: it emits the span as one long move whose endpoints
|
||||||
|
// both read as supported, so endpoint-only sampling never slows it. Arachne's endpoints already read
|
||||||
|
// as overhanging, but their placement near the cage makes the inferred support vary by layer. Arachne
|
||||||
|
// parity is therefore part of this regression's scope: both generators must classify the unsupported
|
||||||
|
// interior of the same 45-degree span consistently.
|
||||||
|
TEST_CASE("Caged external overhangs are slowed along their span", "[ExtrusionProcessor][Regression]")
|
||||||
|
{
|
||||||
|
const char* wall_generator = GENERATE("classic", "arachne");
|
||||||
|
INFO("wall generator: " << wall_generator);
|
||||||
|
|
||||||
|
const std::vector<double> feed_rates = caged_slope_feed_rates(caged_overhang_gcode(wall_generator));
|
||||||
|
info_feed_rates("caged slope", feed_rates);
|
||||||
|
|
||||||
|
REQUIRE_FALSE(feed_rates.empty());
|
||||||
|
|
||||||
|
// The endpoint bug left Classic at the full wall speed, while Arachne's cage-adjacent endpoint
|
||||||
|
// samples selected much faster bands on some layers. The whole span must stay in the slowed range
|
||||||
|
// for both generators, without requiring their different path segmentations to match.
|
||||||
|
const double fastest = *std::max_element(feed_rates.begin(), feed_rates.end());
|
||||||
|
REQUIRE(fastest < caged_slow_speed * MM_PER_MIN);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The other side of the fix: the midpoint probe fires on every long external perimeter, so a
|
||||||
|
// regression that over-slows would leave the test above green. A fully supported wall must keep the
|
||||||
|
// speed it was configured with.
|
||||||
|
TEST_CASE("Supported vertical walls keep their normal speed", "[ExtrusionProcessor][Regression]")
|
||||||
|
{
|
||||||
|
const char* wall_generator = GENERATE("classic", "arachne");
|
||||||
|
INFO("wall generator: " << wall_generator);
|
||||||
|
|
||||||
|
const std::vector<double> feed_rates = back_wall_feed_rates(caged_overhang_gcode(wall_generator));
|
||||||
|
info_feed_rates("back wall", feed_rates);
|
||||||
|
|
||||||
|
REQUIRE_FALSE(feed_rates.empty());
|
||||||
|
|
||||||
|
const double slowest = *std::min_element(feed_rates.begin(), feed_rates.end());
|
||||||
|
REQUIRE(slowest >= caged_slow_speed * MM_PER_MIN);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The slope's top edge falls mid layer, so the first layer above it still stands 0.179mm proud of the layer
|
||||||
|
// below wherever that layer was still on the slope. That is a real overhang and is slowed, but it ends with the
|
||||||
|
// slope: outside the slope's x range the box runs full height, so the same wall stands on a contour identical to
|
||||||
|
// its own. Sampling the interior of that wall at a single point reported one support reading for all of it and
|
||||||
|
// slowed these fully supported ends along with the rest.
|
||||||
|
TEST_CASE("Wall sections beside a caged overhang keep their normal speed", "[ExtrusionProcessor][Regression]")
|
||||||
|
{
|
||||||
|
const char* wall_generator = GENERATE("classic", "arachne");
|
||||||
|
INFO("wall generator: " << wall_generator);
|
||||||
|
|
||||||
|
const std::vector<double> feed_rates = cage_shoulder_feed_rates(caged_overhang_gcode(wall_generator));
|
||||||
|
info_feed_rates("cage shoulder", feed_rates);
|
||||||
|
|
||||||
|
REQUIRE_FALSE(feed_rates.empty());
|
||||||
|
|
||||||
|
const double slowest = *std::min_element(feed_rates.begin(), feed_rates.end());
|
||||||
|
REQUIRE_THAT(slowest / MM_PER_MIN, Catch::Matchers::WithinRel(caged_outer_wall_speed, 0.01));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A wall is printed at the lower of the speeds its ends read, so a reading only earns a point in the
|
||||||
|
// path where it prints at a different speed from the readings around it. Judging that on the readings
|
||||||
|
// themselves rather than the speeds they produce was too coarse: the configured speeds interpolate
|
||||||
|
// between their sections, so readings a fraction of the slowdown threshold apart still print more than
|
||||||
|
// 10% apart, and a real 45 degree overhang had its true reading dropped as if it agreed with its ends.
|
||||||
|
// The ends then chose the speed on their own, and being next to the walls either side of the overhang
|
||||||
|
// they read differently from layer to layer, banding an overhang that should have been uniform.
|
||||||
|
TEST_CASE("An overhang reading is kept whenever it changes the speed", "[ExtrusionProcessor][Regression]")
|
||||||
|
{
|
||||||
|
// A steep speed curve, of the kind the configured overhang speeds interpolate across.
|
||||||
|
const std::vector<ExtendedPoint<2>> points =
|
||||||
|
sampled_wall_over_dished_layer([](float distance) { return std::round(200.f - 400.f * distance); });
|
||||||
|
|
||||||
|
REQUIRE_THAT(furthest_reading(points), Catch::Matchers::WithinAbs(dished_mid_reading, dished_reading_tolerance));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The complement, and why the readings alone were tempting: a reading that prints at the same speed as
|
||||||
|
// its neighbours cannot change the G-code, so sampling must leave the path alone however far out it is.
|
||||||
|
TEST_CASE("An overhang reading is dropped when the speed is unchanged", "[ExtrusionProcessor]")
|
||||||
|
{
|
||||||
|
// A flat speed curve, of the kind a single configured overhang speed produces.
|
||||||
|
const std::vector<ExtendedPoint<2>> points = sampled_wall_over_dished_layer([](float) { return 50.f; });
|
||||||
|
|
||||||
|
REQUIRE_THAT(furthest_reading(points), Catch::Matchers::WithinAbs(dished_end_reading, dished_reading_tolerance));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Coarse probing detects an unsupported pocket away from the wall midpoint",
|
||||||
|
"[ExtrusionProcessor][Regression]")
|
||||||
|
{
|
||||||
|
const std::function<float(float)> distance_to_speed = [](float distance) { return distance <= 0.2f ? 100.f : 50.f; };
|
||||||
|
const std::vector<ExtendedPoint<2>> points =
|
||||||
|
sampled_wall_over_narrow_pocket(0.25 * narrow_pocket_wall_length, distance_to_speed);
|
||||||
|
const double slowed = slowed_length(points, distance_to_speed);
|
||||||
|
|
||||||
|
REQUIRE(slowed > 0.);
|
||||||
|
REQUIRE(slowed < 5.);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Coarse probing brackets a narrow slowdown at the wall midpoint",
|
||||||
|
"[ExtrusionProcessor][Regression]")
|
||||||
|
{
|
||||||
|
// Half of the pocket reading still maps to full speed. A matching probe in either half therefore must not
|
||||||
|
// prune that half before a supported point has been found close enough to bracket the slow midpoint.
|
||||||
|
const std::function<float(float)> distance_to_speed = [](float distance) { return distance <= 0.2f ? 100.f : 50.f; };
|
||||||
|
const std::vector<ExtendedPoint<2>> points =
|
||||||
|
sampled_wall_over_narrow_pocket(0.5 * narrow_pocket_wall_length, distance_to_speed);
|
||||||
|
const double slowed = slowed_length(points, distance_to_speed);
|
||||||
|
|
||||||
|
REQUIRE(slowed > 0.);
|
||||||
|
REQUIRE(slowed < 5.);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sampling probes the interior, so it must not answer for the ends. On a supported wall between two
|
||||||
|
// corners that read an overhang, the reading that differs is the end's own, and the pass that ends a
|
||||||
|
// slowdown an end reads places its point from how far out that end is. Sampling took the difference as
|
||||||
|
// its own to report and put a point at the nearest position bisection had reached instead, which both
|
||||||
|
// sits further along the wall and leaves too little of it for that pass to run on, so the corner
|
||||||
|
// slowdown ran millimetres up an otherwise supported wall. Its length grows with the wall, so on a
|
||||||
|
// model whose cross section keeps growing it reads as a stair stepped band up the corner.
|
||||||
|
TEST_CASE("A supported wall between overhanging corners is slowed no further than its ends require",
|
||||||
|
"[ExtrusionProcessor][Regression]")
|
||||||
|
{
|
||||||
|
// A steep speed curve, so the ends and the interior between them print at clearly different speeds.
|
||||||
|
const std::function<float(float)> distance_to_speed = [](float distance) {
|
||||||
|
return std::round(float(caged_outer_wall_speed) - 400.f * distance);
|
||||||
|
};
|
||||||
|
|
||||||
|
const double sampled = slowed_length(sampled_wall_between_growing_corners(distance_to_speed), distance_to_speed);
|
||||||
|
// The same wall with sampling switched off: what the endpoint driven passes alone make of the corners.
|
||||||
|
const double unsampled = slowed_length(sampled_wall_between_growing_corners({}), distance_to_speed);
|
||||||
|
|
||||||
|
// The corners do read an overhang, so there is a slowdown for sampling to have lengthened.
|
||||||
|
REQUIRE(unsampled > 0.);
|
||||||
|
REQUIRE(sampled <= unsampled);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Benchmark caged overhang interior sampling", "[ExtrusionProcessor][!benchmark]"){
|
||||||
|
const char* wall_generator = GENERATE("classic", "arachne");
|
||||||
|
|
||||||
|
BENCHMARK(wall_generator)
|
||||||
|
{
|
||||||
|
return caged_overhang_gcode(wall_generator);
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user