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\nYour 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);
+ };
+}