diff --git a/.github/workflows/check_profiles.yml b/.github/workflows/check_profiles.yml index 59c92e3ec0..db3ae6c4e8 100644 --- a/.github/workflows/check_profiles.yml +++ b/.github/workflows/check_profiles.yml @@ -1,8 +1,12 @@ name: Check profiles on: 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: - main + - release/* paths: - 'resources/profiles/**' - ".github/workflows/check_profiles.yml" @@ -20,6 +24,8 @@ permissions: jobs: 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 runs-on: ubuntu-24.04 steps: diff --git a/.github/workflows/pr-merge-bot.yml b/.github/workflows/pr-merge-bot.yml new file mode 100644 index 0000000000..9b5ff2dfaf --- /dev/null +++ b/.github/workflows/pr-merge-bot.yml @@ -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 = ''; + // 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
Your current grants\n\n${formatList(grants)}\n\n
`; + + 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//` **and** `resources/profiles/.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}`); + } diff --git a/AGENTS.md b/AGENTS.md index fbc624b958..236aa54c05 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. - 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. +- For profile changes (`resources/profiles//**`), check that `version` in the sibling `resources/profiles/.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. ## Localization & translations diff --git a/src/libslic3r/GCode/ExtrusionProcessor.hpp b/src/libslic3r/GCode/ExtrusionProcessor.hpp index b282af8f4e..1d65e83f3e 100644 --- a/src/libslic3r/GCode/ExtrusionProcessor.hpp +++ b/src/libslic3r/GCode/ExtrusionProcessor.hpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -39,7 +40,11 @@ std::vector> estimate_points_properties(const POINTS& const AABBTreeLines::LinesDistancer& unscaled_prev_layer, float flow_width, 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& distance_to_speed = {}) { bool looped = input_points.front() == input_points.back(); std::function get_prev_index = [](size_t idx, size_t count) { @@ -120,6 +125,107 @@ std::vector> estimate_points_properties(const POINTS& 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> sampled_points; // Populated lazily, on the first insertion + std::vector> interior; // Samples of one segment, keyed by position along it + std::vector pending; + + for (size_t point_idx = 0; point_idx + 1 < points.size(); ++point_idx) { + const ExtendedPoint& curr = points[point_idx]; + const ExtendedPoint& 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( + (curr.position + t * step).template cast()); + 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& l, const std::pair& 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 if (PREV_LAYER_BOUNDARY_OFFSET && ADD_INTERSECTIONS) { std::vector> new_points; @@ -362,9 +468,28 @@ public: 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) + 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> extended_points = estimate_points_properties(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; std::vector processed_points; 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)); // 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. diff --git a/src/libslic3r/PresetBundle.cpp b/src/libslic3r/PresetBundle.cpp index 8a047fea00..c58e6c30e7 100644 --- a/src/libslic3r/PresetBundle.cpp +++ b/src/libslic3r/PresetBundle.cpp @@ -52,7 +52,6 @@ static std::vector s_project_options { "filament_multi_colour", "wipe_tower_x", "wipe_tower_y", - "wipe_tower_rotation_angle", "curr_bed_type", "flush_multiplier", // Fast-purge mode: project-level purge control, inert at Default. diff --git a/src/slic3r/GUI/GLCanvas3D.cpp b/src/slic3r/GUI/GLCanvas3D.cpp index a51a10296c..f6ffb75405 100644 --- a/src/slic3r/GUI/GLCanvas3D.cpp +++ b/src/slic3r/GUI/GLCanvas3D.cpp @@ -2887,7 +2887,7 @@ void GLCanvas3D::reload_scene(bool refresh_immediately, bool force_full_scene_re float x = dynamic_cast(proj_cfg.option("wipe_tower_x"))->get_at(plate_id); float y = dynamic_cast(proj_cfg.option("wipe_tower_y"))->get_at(plate_id); float w = dynamic_cast(m_config->option("prime_tower_width"))->value; - float a = dynamic_cast(proj_cfg.option("wipe_tower_rotation_angle"))->value; + float a = dynamic_cast(m_config->option("wipe_tower_rotation_angle"))->value; // BBS float v = dynamic_cast(m_config->option("prime_volume"))->value; Vec3d plate_origin = ppl.get_plate(plate_id)->get_origin(); diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index db51bd9d8e..0d4e3f35cf 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -3273,15 +3273,12 @@ bool GUI_App::on_init_inner() } } */ - copy_network_if_available(); if (scrn) { scrn->SetText(_L("Loading Plugins") + dots, 20); wxYield(); } - on_init_network(); - // 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, // 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) plugin_mgr.set_cloud_agent(std::dynamic_pointer_cast(m_agent->get_cloud_agent())); diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index 28ba184d1c..cef3613ae9 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -12777,7 +12777,7 @@ void Plater::priv::take_snapshot(const std::string& snapshot_name, const UndoRed 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.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(); @@ -12887,7 +12887,7 @@ void Plater::priv::undo_redo_to(std::vector::const_iterator 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.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; diff --git a/tests/fff_print/CMakeLists.txt b/tests/fff_print/CMakeLists.txt index 08f86de8a7..43afd4281d 100644 --- a/tests/fff_print/CMakeLists.txt +++ b/tests/fff_print/CMakeLists.txt @@ -5,6 +5,7 @@ add_executable(${_TEST_NAME}_tests test_helpers.hpp test_cooling.cpp test_extrusion_entity.cpp + test_extrusion_processor.cpp test_fill.cpp test_flow.cpp test_gcode_timing.cpp diff --git a/tests/fff_print/test_extrusion_processor.cpp b/tests/fff_print/test_extrusion_processor.cpp new file mode 100644 index 0000000000..76e331d66a --- /dev/null +++ b/tests/fff_print/test_extrusion_processor.cpp @@ -0,0 +1,441 @@ +#include + +#include "libslic3r/AABBTreeLines.hpp" +#include "libslic3r/GCode/ExtrusionProcessor.hpp" +#include "libslic3r/GCodeReader.hpp" +#include "libslic3r/TriangleMesh.hpp" + +#include "test_helpers.hpp" + +#include +#include +#include +#include + +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 std::vector outer_wall_feed_rates(const std::string& gcode, KeepLine keep_line) +{ + std::vector 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 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 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 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> sampled_wall_over_dished_layer(const std::function& distance_to_speed) +{ + const AABBTreeLines::LinesDistancer prev_layer(std::vector{ + {{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(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> sampled_wall_over_narrow_pocket( + double pocket_center, const std::function& 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 prev_layer(std::vector{ + {{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(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> sampled_wall_between_growing_corners(const std::function& distance_to_speed) +{ + const AABBTreeLines::LinesDistancer prev_layer(std::vector{ + {{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(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>& points, const std::function& 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>& 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{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& 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 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 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 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> 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> 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 distance_to_speed = [](float distance) { return distance <= 0.2f ? 100.f : 50.f; }; + const std::vector> 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 distance_to_speed = [](float distance) { return distance <= 0.2f ? 100.f : 50.f; }; + const std::vector> 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 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); + }; +}