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/build_release_vs.bat b/build_release_vs.bat index 3288beda4c..78419dadf5 100644 --- a/build_release_vs.bat +++ b/build_release_vs.bat @@ -152,7 +152,7 @@ echo on set CMAKE_POLICY_VERSION_MINIMUM=3.5 if "%USE_NINJA%"=="1" ( cmake .. -G %CMAKE_GENERATOR% -DORCA_TOOLS=ON %SIG_FLAG% -DBUILD_TESTS=%BUILD_TESTS% -DCMAKE_BUILD_TYPE=%build_type% - cmake --build . --config %build_type% --target ALL_BUILD + cmake --build . --config %build_type% --target all ) else ( cmake .. -G %CMAKE_GENERATOR% -A %arch% -DORCA_TOOLS=ON %SIG_FLAG% -DBUILD_TESTS=%BUILD_TESTS% -DCMAKE_BUILD_TYPE=%build_type% cmake --build . --config %build_type% --target ALL_BUILD -- -m diff --git a/deps/TBB/MSVC.cmake b/deps/TBB/MSVC.cmake new file mode 100644 index 0000000000..d7984bff80 --- /dev/null +++ b/deps/TBB/MSVC.cmake @@ -0,0 +1,98 @@ +# Copyright (c) 2020-2021 Intel Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set(TBB_LINK_DEF_FILE_FLAG ${CMAKE_LINK_DEF_FILE_FLAG}) +set(TBB_DEF_FILE_PREFIX win${TBB_ARCH}) + +# Workaround for CMake issue https://gitlab.kitware.com/cmake/cmake/issues/18317. +# TODO: consider use of CMP0092 CMake policy. +string(REGEX REPLACE "/W[0-4]" "" CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS}") + +set(TBB_WARNING_LEVEL $<$:/W4> $<$:/WX>) + +# Warning suppression C4324: structure was padded due to alignment specifier +set(TBB_WARNING_SUPPRESS /wd4324) +set(TBB_TEST_COMPILE_FLAGS /bigobj) + +if (MSVC_VERSION LESS_EQUAL 1900) + # Warning suppression C4503 for VS2015 and earlier: + # decorated name length exceeded, name was truncated. + # More info can be found at + # https://docs.microsoft.com/en-us/cpp/error-messages/compiler-warnings/compiler-warning-level-1-c4503 + set(TBB_TEST_COMPILE_FLAGS ${TBB_TEST_COMPILE_FLAGS} /wd4503) +endif() + +set(TBB_LIB_COMPILE_FLAGS -D_CRT_SECURE_NO_WARNINGS /GS) +set(TBB_COMMON_COMPILE_FLAGS /volatile:iso /FS /EHsc) + +# Ignore /WX set through add_compile_options() or added to CMAKE_CXX_FLAGS if TBB_STRICT is disabled. +if (NOT TBB_STRICT AND COMMAND tbb_remove_compile_flag) + tbb_remove_compile_flag(/WX) +endif() + +if (WINDOWS_STORE OR TBB_WINDOWS_DRIVER) + set(TBB_COMMON_COMPILE_FLAGS ${TBB_COMMON_COMPILE_FLAGS} /D_WIN32_WINNT=0x0A00) + set(TBB_COMMON_LINK_FLAGS -NODEFAULTLIB:kernel32.lib -INCREMENTAL:NO) + set(TBB_COMMON_LINK_LIBS OneCore.lib) +endif() + +if (WINDOWS_STORE) + if (NOT CMAKE_SYSTEM_VERSION EQUAL 10.0) + message(FATAL_ERROR "CMAKE_SYSTEM_VERSION must be equal to 10.0") + endif() + set(TBB_COMMON_COMPILE_FLAGS ${TBB_COMMON_COMPILE_FLAGS} /ZW /ZW:nostdlib) + # CMake define this extra lib, remove it for this build type + string(REGEX REPLACE "WindowsApp.lib" "" CMAKE_CXX_STANDARD_LIBRARIES "${CMAKE_CXX_STANDARD_LIBRARIES}") + + if (TBB_NO_APPCONTAINER) + set(TBB_LIB_LINK_FLAGS ${TBB_LIB_LINK_FLAGS} -APPCONTAINER:NO) + endif() +endif() + +if (TBB_WINDOWS_DRIVER) + # Since this is universal driver disable this variable + set(CMAKE_SYSTEM_PROCESSOR "") + # CMake define list additional libs, remove it for this build type + set(CMAKE_CXX_STANDARD_LIBRARIES "") + set(TBB_COMMON_COMPILE_FLAGS ${TBB_COMMON_COMPILE_FLAGS} /D _UNICODE /DUNICODE /DWINAPI_FAMILY=WINAPI_FAMILY_APP /D__WRL_NO_DEFAULT_LIB__) +endif() + +if (NOT DEFINED TBB_ENABLE_IPO) + if (DEFINED CMAKE_INTERPROCEDURAL_OPTIMIZATION) + set(TBB_ENABLE_IPO ${CMAKE_INTERPROCEDURAL_OPTIMIZATION}) + else() + set(TBB_ENABLE_IPO ON) + endif() +endif() + +if (TBB_ENABLE_IPO) + if (CMAKE_CXX_COMPILER_ID MATCHES "(Clang|IntelLLVM)") + if (CMAKE_SYSTEM_PROCESSOR MATCHES "(x86|AMD64)") + set(TBB_COMMON_COMPILE_FLAGS ${TBB_COMMON_COMPILE_FLAGS} -mrtm -mwaitpkg) + endif() + set(TBB_OPENMP_NO_LINK_FLAG TRUE) + set(TBB_IPO_COMPILE_FLAGS $<$>:-flto>) + else() + set(TBB_IPO_COMPILE_FLAGS $<$>:/GL>) + set(TBB_IPO_LINK_FLAGS $<$>:-LTCG> $<$>:-INCREMENTAL:NO>) + endif() +else() + if (CMAKE_CXX_COMPILER_ID MATCHES "(Clang|IntelLLVM)" AND CMAKE_SYSTEM_PROCESSOR MATCHES "(x86|AMD64)") + set(TBB_COMMON_COMPILE_FLAGS ${TBB_COMMON_COMPILE_FLAGS} -mrtm -mwaitpkg) + endif() + set(TBB_IPO_COMPILE_FLAGS "") + set(TBB_IPO_LINK_FLAGS "") +endif() + +set(TBB_OPENMP_FLAG /openmp) diff --git a/deps/TBB/TBB.cmake b/deps/TBB/TBB.cmake index 9b1452d33e..dac2ed63e6 100644 --- a/deps/TBB/TBB.cmake +++ b/deps/TBB/TBB.cmake @@ -1,4 +1,6 @@ -if (FLATPAK AND "${CMAKE_CXX_COMPILER_ID}" STREQUAL "GNU") +if (MSVC) + set(_patch_command ${CMAKE_COMMAND} -E copy ${CMAKE_CURRENT_LIST_DIR}/MSVC.cmake ./cmake/compilers/MSVC.cmake) +elseif (FLATPAK AND "${CMAKE_CXX_COMPILER_ID}" STREQUAL "GNU") set(_patch_command ${CMAKE_COMMAND} -E copy ${CMAKE_CURRENT_LIST_DIR}/GNU.cmake ./cmake/compilers/GNU.cmake) else() set(_patch_command "") @@ -13,6 +15,8 @@ orcaslicer_add_cmake_project( -DTBB_BUILD_SHARED=OFF -DTBB_BUILD_TESTS=OFF -DTBB_TEST=OFF + -DTBB_ENABLE_IPO=OFF + -DCMAKE_INTERPROCEDURAL_OPTIMIZATION=OFF -DCMAKE_POSITION_INDEPENDENT_CODE=ON -DCMAKE_DEBUG_POSTFIX=_debug ) diff --git a/deps/wxWidgets/0001-Clang-CL-fix.patch b/deps/wxWidgets/0001-Clang-CL-fix.patch new file mode 100644 index 0000000000..23bf23b3f4 --- /dev/null +++ b/deps/wxWidgets/0001-Clang-CL-fix.patch @@ -0,0 +1,28 @@ +--- + build/cmake/wxWidgetsConfig.cmake.in | 10 +++++++++- + 1 file changed, 10 insertions(+), 1 deletion(-) + +diff --git a/build/cmake/wxWidgetsConfig.cmake.in b/build/cmake/wxWidgetsConfig.cmake.in +index 1a83f36..70ad8a4 100644 +--- a/build/cmake/wxWidgetsConfig.cmake.in ++++ b/build/cmake/wxWidgetsConfig.cmake.in +@@ -58,7 +58,16 @@ if(WIN32_MSVC_NAMING) + endif() + endif() + +-include("${CMAKE_CURRENT_LIST_DIR}${wxPLATFORM_LIB_DIR}/@PROJECT_NAME@Targets.cmake") ++if (CMAKE_CXX_COMPILER_ID STREQUAL "Clang" AND CMAKE_CXX_COMPILER_FRONTEND_VARIANT STREQUAL "MSVC") ++ if (CMAKE_GENERATOR_PLATFORM STREQUAL "ARM64" OR CMAKE_VS_PLATFORM_NAME STREQUAL "ARM64" OR CMAKE_SYSTEM_PROCESSOR MATCHES "^(ARM64|arm64|aarch64)$") ++ set(_wx_clang_msvc_lib_dir "vc_arm64_lib") ++ else() ++ set(_wx_clang_msvc_lib_dir "vc_x64_lib") ++ endif() ++ include("${CMAKE_CURRENT_LIST_DIR}${wxPLATFORM_LIB_DIR}/${_wx_clang_msvc_lib_dir}/@PROJECT_NAME@Targets.cmake") ++else() ++ include("${CMAKE_CURRENT_LIST_DIR}${wxPLATFORM_LIB_DIR}/@PROJECT_NAME@Targets.cmake") ++endif() + + macro(wx_inherit_property source dest name) + # property name without _ +-- +2.43.0 diff --git a/deps/wxWidgets/wxWidgets.cmake b/deps/wxWidgets/wxWidgets.cmake index 1e2cc85f78..07bb31d8be 100644 --- a/deps/wxWidgets/wxWidgets.cmake +++ b/deps/wxWidgets/wxWidgets.cmake @@ -28,6 +28,7 @@ orcaslicer_add_cmake_project( GIT_SHALLOW ON GIT_SUBMODULES 3rdparty/catch 3rdparty/pcre 3rdparty/libwebp DEPENDS ${PNG_PKG} ${ZLIB_PKG} ${EXPAT_PKG} ${JPEG_PKG} + PATCH_COMMAND git apply --verbose --ignore-space-change --whitespace=fix ${CMAKE_CURRENT_LIST_DIR}/0001-Clang-CL-fix.patch CMAKE_ARGS -DwxBUILD_PRECOMP=ON ${_wx_toolkit} diff --git a/deps_src/clipper2/CMakeLists.txt b/deps_src/clipper2/CMakeLists.txt index c604002da7..86c9a9efab 100644 --- a/deps_src/clipper2/CMakeLists.txt +++ b/deps_src/clipper2/CMakeLists.txt @@ -37,7 +37,11 @@ target_include_directories(Clipper2 ) if (WIN32) - target_compile_options(Clipper2 PRIVATE /W4 /WX) + if (MSVC AND NOT CMAKE_CXX_COMPILER_ID STREQUAL "Clang") + target_compile_options(Clipper2 PRIVATE /W4 /WX) + elseif (CMAKE_CXX_COMPILER_ID STREQUAL "Clang" AND CMAKE_CXX_COMPILER_FRONTEND_VARIANT STREQUAL "MSVC") + target_compile_options(Clipper2 PRIVATE /W4) + endif() else() target_compile_options(Clipper2 PRIVATE -Wall -Wextra -Wpedantic -Werror) target_link_libraries(Clipper2 PUBLIC -lm) diff --git a/deps_src/miniz/CMakeLists.txt b/deps_src/miniz/CMakeLists.txt index e02d8a4885..7e060a180f 100644 --- a/deps_src/miniz/CMakeLists.txt +++ b/deps_src/miniz/CMakeLists.txt @@ -11,6 +11,8 @@ add_library(miniz_static STATIC if(${CMAKE_C_COMPILER_ID} STREQUAL "GNU") target_compile_definitions(miniz_static PRIVATE _GNU_SOURCE) +elseif (CMAKE_C_COMPILER_ID STREQUAL "Clang" AND CMAKE_C_COMPILER_FRONTEND_VARIANT STREQUAL "MSVC") + target_compile_options(miniz_static PRIVATE /clang:-Wno-error=incompatible-pointer-types) endif() target_link_libraries(miniz INTERFACE miniz_static) diff --git a/localization/i18n/tr/OrcaSlicer_tr.po b/localization/i18n/tr/OrcaSlicer_tr.po index 467b3c355b..63d1e5bc75 100644 --- a/localization/i18n/tr/OrcaSlicer_tr.po +++ b/localization/i18n/tr/OrcaSlicer_tr.po @@ -4,7 +4,7 @@ msgstr "" "Project-Id-Version: Orca Slicer\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2026-07-29 17:40-0300\n" -"PO-Revision-Date: 2026-08-01 20:32+0300\n" +"PO-Revision-Date: 2026-08-04 19:36+0300\n" "Last-Translator: GlauTech\n" "Language-Team: \n" "Language: tr\n" @@ -738,9 +738,8 @@ msgstr "Sabit adım sürükleme" msgid "Context Menu" msgstr "Bağlam Menüsü" -# AI Translated msgid "Toggle Auto-Drop" -msgstr "Otomatik Bırakmayı Aç/Kapat" +msgstr "Otomatik düşürmeyi aç / kapat" msgid "Single sided scaling" msgstr "Tek taraflı ölçekleme" @@ -791,9 +790,8 @@ msgstr "Nesne" msgid "Part" msgstr "Parça" -# AI Translated msgid "Relative" -msgstr "Göreli" +msgstr "Göreceli" # AI Translated msgid "Coordinate system used for transform actions." @@ -2306,7 +2304,7 @@ msgid "new or open project file is not allowed during the slicing process!" msgstr "dilimleme işlemi sırasında yeni veya açık proje dosyasına izin verilmez!" msgid "Open Project" -msgstr "Projeyi Aç" +msgstr "Projeyi aç" msgid "The version of Orca Slicer is too low and needs to be updated to the latest version before it can be used normally." msgstr "Orca Slicer'ın sürümü çok düşük ve normal şekilde kullanılabilmesi için en son sürüme güncellenmesi gerekiyor." @@ -2734,16 +2732,15 @@ msgstr "Simit" msgid "Orca Cube" msgstr "Orca Küpü" -# AI Translated msgid "OrcaSliced Combo" -msgstr "OrcaSliced Combo" +msgstr "Orca Dilimleme Paketi" # AI Translated msgid "Orca Badge" msgstr "Orca Rozeti" msgid "Orca Tolerance Test" -msgstr "Orca tolerans testi" +msgstr "Orca Tolerans Testi" msgid "3DBenchy" msgstr "3DBenchy" @@ -2807,7 +2804,7 @@ msgid "Set as Individual Objects" msgstr "Bireysel nesneler olarak ayarla" msgid "Fill bed with copies" -msgstr "Yatağı kopyalarla doldurun" +msgstr "Tablayı kopyalarla doldur" msgid "Fill the remaining area of bed with copies of the selected object" msgstr "Yatağın kalan alanını seçilen nesnenin kopyalarıyla doldurun" @@ -2815,9 +2812,8 @@ msgstr "Yatağın kalan alanını seçilen nesnenin kopyalarıyla doldurun" msgid "Printable" msgstr "Yazdırılabilir" -# AI Translated msgid "Auto Drop" -msgstr "Otomatik Bırakma" +msgstr "Otomatik düşür" # AI Translated msgid "Automatically drops the selected object to the build plate." @@ -2967,7 +2963,7 @@ msgid "Add Models" msgstr "Model ekle" msgid "Show Labels" -msgstr "Etiketleri Göster" +msgstr "Etiketleri göster" msgid "To Objects" msgstr "Nesnelere" @@ -3009,7 +3005,7 @@ msgid "Select all objects on the current plate" msgstr "Mevcut plakadaki tüm nesneleri seç" msgid "Select All Plates" -msgstr "Tüm Plakaları Seç" +msgstr "Tüm plakaları seç" msgid "Select all objects on all plates" msgstr "Tüm plakalardaki tüm nesneleri seç" @@ -3045,13 +3041,13 @@ msgid "Remove the selected plate" msgstr "Seçilen plakayı kaldır" msgid "Add instance" -msgstr "Örnek ekle" +msgstr "Kopya ekle" msgid "Add one more instance of the selected object" msgstr "Seçilen nesnenin bir örneğini daha ekle" msgid "Remove instance" -msgstr "Örneği kaldır" +msgstr "Kopyayı kaldır" msgid "Remove one instance of the selected object" msgstr "Seçilen nesnenin bir örneğini kaldır" @@ -3060,10 +3056,10 @@ msgid "Set number of instances" msgstr "Örnek sayısını ayarlayın" msgid "Change the number of instances of the selected object" -msgstr "Seçilen nesnenin örnek sayısını değiştirme" +msgstr "Seçilen nesnenin kopya sayısını değiştirme" msgid "Fill bed with instances" -msgstr "Yatağı örneklerle doldurun" +msgstr "Tablayı kopyalarla doldur" msgid "Fill the remaining area of bed with instances of the selected object" msgstr "Yatağın kalan alanını seçilen nesnenin örnekleriyle doldurun" @@ -3075,7 +3071,7 @@ msgid "Simplify Model" msgstr "Modeli basitleştir" msgid "Subdivision mesh" -msgstr "Alt bölüm ağı" +msgstr "Poligon artırma" msgid "(Lost color)" msgstr "(Renk kaybı)" @@ -3090,10 +3086,10 @@ msgid "Edit Process Settings" msgstr "İşlem ayarlarını düzenle" msgid "Copy Process Settings" -msgstr "İşlem Ayarlarını Kopyala" +msgstr "İşlem ayarlarını kopyala" msgid "Paste Process Settings" -msgstr "İşlem Ayarlarını Yapıştır" +msgstr "İşlem ayarlarını yapıştır" msgid "Edit print parameters for a single object" msgstr "Tek bir nesne için yazdırma parametrelerini düzenleme" @@ -3478,7 +3474,7 @@ msgid "More" msgstr "Daha" msgid "Open Preferences" -msgstr "Tercihleri Aç" +msgstr "Tercihleri aç" msgid "Open next tip" msgstr "Sonraki ipucunu aç" @@ -5450,10 +5446,10 @@ msgid "Acceleration" msgstr "Hızlanma" msgid "Jerk" -msgstr "Jerk" +msgstr "Sarsıntı" msgid "Fan Speed" -msgstr "Fan hızı" +msgstr "Fan Hızı" msgid "Flow" msgstr "Akış" @@ -5468,7 +5464,7 @@ msgid "Layer Time" msgstr "Katman Süresi" msgid "Layer Time (log)" -msgstr "Katman Süresi (günlük)" +msgstr "Katman Süresi (log)" msgid "Pressure Advance" msgstr "Basınç İlerlemesi" @@ -5477,10 +5473,10 @@ msgid "Noop" msgstr "Hayır" msgid "Retract" -msgstr "Geri Çekme" +msgstr "Geri çekme" msgid "Unretract" -msgstr "İleri İtme" +msgstr "İleri itme" msgid "Seam" msgstr "Dikiş" @@ -5578,7 +5574,7 @@ msgid "Acceleration: " msgstr "İvme: " msgid "Jerk: " -msgstr "Jerk: " +msgstr "Sarsıntı: " msgid "PA: " msgstr "PA: " @@ -5608,7 +5604,7 @@ msgid "Actual speed profile" msgstr "Gerçek hız profili" msgid "Statistics of All Plates" -msgstr "Tüm Plakaların İstatistikleri" +msgstr "Tüm plakaların istatistikleri" msgid "Display" msgstr "Ekran" @@ -5708,7 +5704,7 @@ msgid "Acceleration (mm/s²)" msgstr "İvme (mm/s²)" msgid "Jerk (mm/s)" -msgstr "Jerk (mm/s)" +msgstr "Sarsıntı (mm/s)" msgid "Fan speed (%)" msgstr "Fan hızı (%)" @@ -5759,9 +5755,8 @@ msgstr "Normal mod" msgid "Total Filament" msgstr "Toplam filament" -# AI Translated msgid "Model Filament" -msgstr "Model Filamenti" +msgstr "Model filamenti" msgid "Prepare time" msgstr "Hazırlık süresi" @@ -6282,20 +6277,19 @@ msgid "Setup Wizard" msgstr "Kurulum sihirbazı" msgid "Show Configuration Folder" -msgstr "Yapılandırma Klasörünü Göster" +msgstr "Yapılandırma klasörünü göster" -# AI Translated msgid "Troubleshoot Center" -msgstr "Sorun Giderme Merkezi" +msgstr "Sorun giderme merkezi" msgid "Open Network Test" -msgstr "Ağ Testini Aç" +msgstr "Ağ testini aç" msgid "Show Tip of the Day" -msgstr "Günün İpucunu Göster" +msgstr "Günün ipucunu göster" msgid "Check for Updates" -msgstr "Güncellemeleri Kontrol Et" +msgstr "Güncellemeleri kontrol et" #, c-format, boost-format msgid "&About %s" @@ -6349,7 +6343,7 @@ msgid "Recent files" msgstr "Son dosyalar" msgid "Save Project" -msgstr "Projeyi Kaydet" +msgstr "Projeyi kaydet" msgid "Save current project to file" msgstr "Mevcut projeyi dosyaya kaydet" @@ -6415,13 +6409,13 @@ msgid "Export toolpaths as OBJ" msgstr "Takımyollarını OBJ olarak dışa aktar" msgid "Export Preset Bundle" -msgstr "Ön Ayar Paketini Dışa Aktar" +msgstr "Ön ayar paketini dışa aktar" msgid "Export current configuration to files" msgstr "Geçerli yapılandırmayı dosyalara aktar" msgid "Export" -msgstr "Dışa Aktar" +msgstr "Dışa aktar" msgid "Quit" msgstr "Çıkış" @@ -6478,13 +6472,13 @@ msgid "Deselects all objects" msgstr "Tüm nesnelerin seçimini kaldırır" msgid "Use Perspective View" -msgstr "Perspektif Görünüm" +msgstr "Perspektif görünüm" msgid "Use Orthogonal View" -msgstr "Ortogonal Görünüm" +msgstr "Ortogonal görünüm" msgid "Auto Perspective" -msgstr "Otomatik Perspektif" +msgstr "Otomatik perspektif" msgid "Automatically switch between orthographic and perspective when changing from top/bottom/side views." msgstr "Üst/Alt/Yan görünümler arasında geçiş yaparken ortografik ve perspektif arasında otomatik olarak geçiş yapın." @@ -6496,37 +6490,37 @@ msgid "Show G-code window in Preview scene." msgstr "Previce sahnesinde G-kodu penceresini göster." msgid "Show 3D Navigator" -msgstr "3D Gezgini Göster" +msgstr "3D gezgini göster" msgid "Show 3D navigator in Prepare and Preview scene." msgstr "Hazırlama ve Önizleme sahnesinde 3D gezgini göster." msgid "Show Gridlines" -msgstr "Kılavuz Çizgilerini Göster" +msgstr "Kılavuz çizgilerini göster" msgid "Show Gridlines on plate" msgstr "Kılavuz Çizgilerini plaka üzerinde göster" msgid "Reset Window Layout" -msgstr "Pencere Düzenini Sıfırla" +msgstr "Pencere düzenini sıfırla" msgid "Reset to default window layout" msgstr "Varsayılan pencere düzenine sıfırla" msgid "Show &Labels" -msgstr "Etiketleri Göster" +msgstr "Etiketleri göster" msgid "Show object labels in 3D scene." msgstr "3B sahnede nesne etiketlerini göster." msgid "Show &Overhang" -msgstr "Çıkıntıyı Göster" +msgstr "Çıkıntıyı göster" msgid "Show object overhang highlight in 3D scene." msgstr "3B sahnede nesne çıkıntısı vurgusunu göster." msgid "Show Selected Outline (beta)" -msgstr "Seçilen Taslağı Göster (Deneysel)" +msgstr "Seçilen taslağı göster (deneysel)" msgid "Show outline around selected object in 3D scene." msgstr "3D sahnede seçilen nesnenin etrafındaki ana hatları göster." @@ -6542,13 +6536,11 @@ msgstr "Düzen" msgid "View" msgstr "Görünüm" -# AI Translated msgid "Preset Bundle" -msgstr "Ön Ayar Paketi" +msgstr "Ön ayar paketi" -# AI Translated msgid "Sync Presets" -msgstr "Ön Ayarları Eşitle" +msgstr "Ön ayarları eşitle" # AI Translated msgid "Pull and apply the latest presets from OrcaCloud" @@ -6590,10 +6582,10 @@ msgid "Cornering calibration" msgstr "Viraj kalibrasyonu" msgid "Input Shaping Frequency" -msgstr "Input shaping Frekansı" +msgstr "Input shaping frekansı" msgid "Input Shaping Damping/zeta factor" -msgstr "Input shaping Sönümleme/zeta faktörü" +msgstr "Input shaping sönümleme/zeta faktörü" msgid "Input Shaping" msgstr "Input shaping" @@ -6601,9 +6593,8 @@ msgstr "Input shaping" msgid "VFA" msgstr "VFA" -# AI Translated msgid "Calibration Guide" -msgstr "Kalibrasyon Kılavuzu" +msgstr "Kalibrasyon kılavuzu" msgid "&Open G-code" msgstr "&G kodunu aç" @@ -8219,9 +8210,8 @@ msgstr "Bu dosyalar birden fazla parçadan oluşan tek bir nesne olarak mı yük msgid "An object with multiple parts was detected" msgstr "Birden fazla parçaya sahip nesne algılandı" -# AI Translated msgid "Auto-Drop" -msgstr "Otomatik Bırakma" +msgstr "Otomatik düşür" #, c-format, boost-format msgid "Connected printer is %s. It must match the project preset for printing.\n" @@ -8296,9 +8286,8 @@ msgstr "Seçilen nesne bölünemedi." msgid "Split to Objects" msgstr "Nesnelere Ayır" -# AI Translated msgid "Disable Auto-Drop to preserve Z positioning?\n" -msgstr "Z konumunu korumak için Otomatik Bırakma devre dışı bırakılsın mı?\n" +msgstr "Z konumunu korumak için Otomatik düşürme devre dışı bırakılsın mı?\n" # AI Translated msgid "Object with floating parts was detected" @@ -8433,7 +8422,7 @@ msgid "Creating a new project" msgstr "Yeni bir proje oluşturma" msgid "Load project" -msgstr "Projeyi Aç" +msgstr "Projeyi aç" msgid "" "Failed to save the project.\n" @@ -8869,10 +8858,10 @@ msgid "Current Association: " msgstr "Mevcut Bağlantı: " msgid "Current Instance" -msgstr "Mevcut Örnek" +msgstr "Mevcut Kopya" msgid "Current Instance Path: " -msgstr "Mevcut Örnek Yolu: " +msgstr "Mevcut Kopya Yolu: " msgid "General" msgstr "Genel" @@ -10708,7 +10697,7 @@ msgid "Reserved keywords found" msgstr "Ayrılmış anahtar kelimeler bulundu" msgid "Setting Overrides" -msgstr "Ayarların Üzerine Yazma" +msgstr "Ayarların Üzerine Yaz" msgid "Basic information" msgstr "Temel Bilgiler" @@ -12227,7 +12216,7 @@ msgid "Group error in manual mode. Please check nozzle count or regroup." msgstr "Elle modda gruplama hatası. Lütfen nozul sayısını denetleyin veya yeniden gruplayın." msgid "Internal Bridge" -msgstr "İç Köprü" +msgstr "İç köprü" msgid "undefined error" msgstr "bilinmeyen hata" @@ -12920,7 +12909,6 @@ msgstr "Çıkıntı bu belirtilen eşiği aştığında, soğutma fanını aşa msgid "External bridge infill direction" msgstr "Dış köprü dolgu yönü" -# AI Translated #, no-c-format, no-boost-format msgid "" "External Bridging angle override.\n" @@ -12937,14 +12925,13 @@ msgstr "" "Aksi hâlde verilen açı şuna göre kullanılır:\n" " - Mutlak koordinatlar\n" " - Mutlak koordinatlar + Model dönüşü: Yönleri modele hizala etkinse\n" -" - En uygun otomatik açı + bu değer: 'Göreli Köprü Açısı' etkinse\n" +" - En uygun otomatik açı + bu değer: ‘Göreceli Köprü Açısı' etkinse\n" "\n" "Sıfır mutlak açı için 180° kullanın." msgid "Internal bridge infill direction" msgstr "İç köprü dolgu yönü" -# AI Translated msgid "" "Internal Bridging angle override.\n" "If left to zero, the bridging angle will be calculated automatically for each specific bridge.\n" @@ -12960,13 +12947,12 @@ msgstr "" "Aksi hâlde verilen açı şuna göre kullanılır:\n" " - Mutlak koordinatlar\n" " - Mutlak koordinatlar + Model dönüşü: Yönleri modele hizala etkinse\n" -" - En uygun otomatik açı + bu değer: 'Göreli Köprü Açısı' etkinse\n" +" - En uygun otomatik açı + bu değer: 'Göreceli Köprü Açısı' etkinse\n" "\n" "Sıfır mutlak açı için 180° kullanın." -# AI Translated msgid "Relative bridge angle" -msgstr "Göreli köprü açısı" +msgstr "Göreceli köprü açısı" # AI Translated msgid "When enabled, the bridge angle values are added to the automatically calculated bridge direction instead of overriding it." @@ -13413,7 +13399,7 @@ msgstr "" "Not: Elde edilen değer ilk katman akış oranından etkilenmez." msgid "Brim follows compensated outline" -msgstr "Kenar telafi edilen taslağı takip ediyor" +msgstr "Kenar toleranslı dış sınırı takip etsin" # AI Translated msgid "" @@ -13724,13 +13710,13 @@ msgid "" msgstr "" "Dikey kabuk kalınlığını garanti etmek için eğimli yüzeylerin yakınına katı dolgu ekleyin (üst + alt katı katmanlar)\n" "Yok: Hiçbir yere katı dolgu eklenmez. Dikkat: Modelinizin eğimli yüzeyleri varsa bu seçeneği dikkatli kullanın.\n" -"Yalnızca kritik: Duvarlar için katı dolgu eklemekten kaçının\n" +"Kritik: Duvarlar için katı dolgu eklemekten kaçının\n" "Orta: Yalnızca çok eğimli yüzeyler için katı dolgu ekleyin\n" "Hepsi: Tüm uygun eğimli yüzeyler için katı dolgu ekleyin\n" "Varsayılan değer Tümü'dür." msgid "Critical Only" -msgstr "Yalnızca kritik" +msgstr "Kritik" msgid "Moderate" msgstr "Orta" @@ -14247,7 +14233,7 @@ msgid "By First filament" msgstr "İlk filamente göre" msgid "By Highest Temp" -msgstr "En Yüksek Sıcaklığa Göre" +msgstr "En yüksek sıcaklığa göre" msgid "Filament diameter is used to calculate extrusion variables in G-code, so it is important that this is accurate and precise." msgstr "Filament çapı, gcode'da ekstrüzyonu hesaplamak için kullanılır; bu nedenle önemlidir ve doğru olmalıdır." @@ -14685,10 +14671,10 @@ msgid "Marlin Firmware Junction Deviation (replaces the traditional XY Jerk sett msgstr "Marlin Firmware Köşe Sapması (geleneksel XY Sarsıntı ayarının yerini alır)" msgid "Jerk of outer walls." -msgstr "Dış duvar JERK değeri." +msgstr "Dış duvar sarsıntı değeri." msgid "Jerk of inner walls." -msgstr "İç duvarlar JERK değeri." +msgstr "İç duvarlar sarsıntı değeri." msgid "Jerk for top surface." msgstr "Üst yüzey için JERK değeri." @@ -15721,7 +15707,7 @@ msgid "" "If your Marlin 2 printer uses Classic Jerk set this value to 0.)" msgstr "" "Maksimum bağlantı sapması (M205 J, yalnızca Marlin Aygıt Yazılımı için JD > 0 ise geçerlidir)\n" -"Marlin 2 yazıcınız Classic Jerk kullanıyorsa bu değeri 0 olarak ayarlayın.)" +"Marlin 2 yazıcınız Classic sarsıntı kullanıyorsa bu değeri 0 olarak ayarlayın.)" msgid "Minimum speed for extruding" msgstr "Ekstrüzyon için minimum hız" @@ -16914,7 +16900,7 @@ msgid "This setting only generates supports that begin on the build plate." msgstr "Model yüzeyinde destek oluşturmayın, yalnızca baskı plakasında." msgid "Support critical regions only" -msgstr "Yalnızca kritik bölgeleri destekleyin" +msgstr "Kritik bölgeleri destekleyin" msgid "Only create support for critical regions including sharp tail, cantilever, etc." msgstr "Yalnızca keskin kuyruk, konsol vb. gibi kritik bölgeler için destek oluşturun." @@ -19288,13 +19274,13 @@ msgid "" "To test Classic Jerk, set 'Maximum Junction Deviation' in Motion ability to 0." msgstr "" "Marlin 2 Kavşak Sapması tespit edildi:\n" -"Classic Jerk'i test etmek için Hareket yeteneğinde 'Maksimum Kavşak Sapması'nı 0'a ayarlayın." +"Classic sarsıntıyı test etmek için Hareket yeteneğinde 'Maksimum Kavşak Sapması'nı 0'a ayarlayın." msgid "" "Marlin 2 Classic Jerk detected:\n" "To test Junction Deviation, set 'Maximum Junction Deviation' in Motion ability to a value > 0." msgstr "" -"Marlin 2 Classic Jerk tespit edildi:\n" +"Marlin 2 Classic sarsıntı tespit edildi:\n" "Kavşak Sapmasını test etmek için Hareket yeteneğinde 'Maksimum Kavşak Sapması'nı > 0 değerine ayarlayın." msgid "" @@ -19538,7 +19524,7 @@ msgid "Start Test Single-Thread" msgstr "Tek İş Parçacığı Testini Başlat" msgid "Export Log" -msgstr "Logu Dışa Aktar" +msgstr "Logu dışa aktar" msgid "OrcaSlicer Version:" msgstr "OrcaSlicer Sürümü:" @@ -20281,9 +20267,8 @@ msgstr "Sistem klasörü silinemedi..." msgid "Failed to determine executable path." msgstr "Yürütülebilir dosya yolu belirlenemedi." -# AI Translated msgid "Failed to launch a new instance." -msgstr "Yeni bir örnek başlatılamadı." +msgstr "Yeni bir kopya başlatılamadı." # AI Translated msgid "log(s)" diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 79b49cfd16..0082b29831 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -256,14 +256,6 @@ if (WIN32) VERBATIM ) endforeach () - - if ("${CMAKE_BUILD_TYPE}" STREQUAL "Debug") - orcaslicer_copy_dlls(COPY_DLLS "Debug" "d" output_dlls_Debug) - elseif("${CMAKE_BUILD_TYPE}" STREQUAL "RelWithDebInfo") - orcaslicer_copy_dlls(COPY_DLLS "RelWithDebInfo" "" output_dlls_Release) - else() - orcaslicer_copy_dlls(COPY_DLLS "Release" "" output_dlls_Release) - endif() else () file(TO_NATIVE_PATH "${CMAKE_CURRENT_BINARY_DIR}/resources" WIN_RESOURCES_SYMLINK) add_custom_command(TARGET OrcaSlicer POST_BUILD @@ -279,6 +271,27 @@ if (WIN32) COMMENT "Copying Python runtime into the build tree" VERBATIM) + if (CMAKE_CONFIGURATION_TYPES) + # Multi-config generators (Visual Studio, Ninja Multi-Config): copy per config. + foreach (cfg ${CMAKE_CONFIGURATION_TYPES}) + if ("${cfg}" STREQUAL "Debug") + orcaslicer_copy_dlls(COPY_DLLS "Debug" "d" output_dlls_Debug) + elseif("${cfg}" STREQUAL "RelWithDebInfo") + orcaslicer_copy_dlls(COPY_DLLS "RelWithDebInfo" "" output_dlls_RelWithDebInfo) + else() + orcaslicer_copy_dlls(COPY_DLLS "${cfg}" "" output_dlls_${cfg}) + endif() + endforeach() + else() + # Single-config generators (Ninja): use CMAKE_BUILD_TYPE. + if ("${CMAKE_BUILD_TYPE}" STREQUAL "Debug") + orcaslicer_copy_dlls(COPY_DLLS "Debug" "d" output_dlls_Debug) + elseif("${CMAKE_BUILD_TYPE}" STREQUAL "RelWithDebInfo") + orcaslicer_copy_dlls(COPY_DLLS "RelWithDebInfo" "" output_dlls_RelWithDebInfo) + else() + orcaslicer_copy_dlls(COPY_DLLS "Release" "" output_dlls_Release) + endif() + endif() else () if (APPLE AND NOT CMAKE_MACOSX_BUNDLE) diff --git a/src/dev-utils/BaseException.cpp b/src/dev-utils/BaseException.cpp index f33c8f635f..efb7a98245 100644 --- a/src/dev-utils/BaseException.cpp +++ b/src/dev-utils/BaseException.cpp @@ -69,7 +69,7 @@ void CBaseException::OutputString(LPCTSTR lpszFormat, ...) //WriteConsole(GetStdHandle(STD_OUTPUT_HANDLE), szBuf, _tcslen(szBuf), NULL, NULL); //output it to the current directory of binary - std::string output_str = textconv_helper::T2A_(szBuf); + std::string output_str = static_cast(textconv_helper::T2A_(szBuf)); *output_file << output_str; output_file->flush(); } diff --git a/src/libslic3r/AppConfig.cpp b/src/libslic3r/AppConfig.cpp index a5d0e24eac..4dc838172c 100644 --- a/src/libslic3r/AppConfig.cpp +++ b/src/libslic3r/AppConfig.cpp @@ -280,6 +280,9 @@ void AppConfig::set_defaults() set(SETTING_OPENGL_FPS_CAP, std::to_string(fps_cap)); } + // The getter already defaults, parses and clamps; write back what it resolves to. + set(SETTING_PLUGIN_PAGES_VISIBLE_COUNT, std::to_string(get_plugin_pages_visible_count())); + if (get(SETTING_OPENGL_SHOW_FPS_OVERLAY).empty()) set_bool(SETTING_OPENGL_SHOW_FPS_OVERLAY, false); @@ -1630,6 +1633,22 @@ void AppConfig::set_network_plugin_version(const std::string& version) set(SETTING_NETWORK_PLUGIN_VERSION, version); } +int AppConfig::get_plugin_pages_visible_count() const +{ + std::string value = get(SETTING_PLUGIN_PAGES_VISIBLE_COUNT); + if (value.empty()) + return PLUGIN_PAGES_VISIBLE_COUNT_DEFAULT; + + int visible_count = PLUGIN_PAGES_VISIBLE_COUNT_DEFAULT; + try { + visible_count = std::stoi(value); + } + catch (...) { + return PLUGIN_PAGES_VISIBLE_COUNT_DEFAULT; + } + return std::clamp(visible_count, PLUGIN_PAGES_VISIBLE_COUNT_MIN, PLUGIN_PAGES_VISIBLE_COUNT_MAX); +} + std::vector AppConfig::get_skipped_network_versions() const { std::vector result; diff --git a/src/libslic3r/AppConfig.hpp b/src/libslic3r/AppConfig.hpp index 2c83ebb488..65c57cdb30 100644 --- a/src/libslic3r/AppConfig.hpp +++ b/src/libslic3r/AppConfig.hpp @@ -41,6 +41,11 @@ using namespace nlohmann; #define SETTING_OPENGL_PHONG_SSAO "opengl_phong_ssao" #define SETTING_OPENGL_PHONG_SMOOTH_NORMALS "opengl_phong_smooth_normals" +#define SETTING_PLUGIN_PAGES_VISIBLE_COUNT "plugin_pages_visible_count" +#define PLUGIN_PAGES_VISIBLE_COUNT_MIN 1 +#define PLUGIN_PAGES_VISIBLE_COUNT_DEFAULT 5 +#define PLUGIN_PAGES_VISIBLE_COUNT_MAX 10 + #if defined(_WIN32) || defined(_WIN64) #define BAMBU_NETWORK_AGENT_VERSION_LEGACY "01.10.01.09" #else @@ -374,6 +379,10 @@ public: std::string get_network_plugin_version() const; void set_network_plugin_version(const std::string& version); + // Number of plugin pages shown as fixed tabs before the rest are collapsed into a + // dropdown on the last tab. + int get_plugin_pages_visible_count() const; + std::vector get_skipped_network_versions() const; void add_skipped_network_version(const std::string& version); bool is_network_version_skipped(const std::string& version) const; diff --git a/src/libslic3r/BoundingBox.cpp b/src/libslic3r/BoundingBox.cpp index a2a510b64c..cf5441dace 100644 --- a/src/libslic3r/BoundingBox.cpp +++ b/src/libslic3r/BoundingBox.cpp @@ -8,6 +8,8 @@ namespace Slic3r { template BoundingBoxBase::BoundingBoxBase(const Points &points); +template void BoundingBoxBase::construct<0, BoundingBox, Points::const_iterator>(BoundingBox&, Points::const_iterator, Points::const_iterator); +template void BoundingBoxBase::construct<1, BoundingBox, Points::const_iterator>(BoundingBox&, Points::const_iterator, Points::const_iterator); template BoundingBoxBase::BoundingBoxBase(const std::vector &points); template BoundingBox3Base::BoundingBox3Base(const std::vector &points); diff --git a/src/libslic3r/CMakeLists.txt b/src/libslic3r/CMakeLists.txt index 812d28e088..f7b4de6e25 100644 --- a/src/libslic3r/CMakeLists.txt +++ b/src/libslic3r/CMakeLists.txt @@ -149,6 +149,8 @@ set(lisbslic3r_sources Fill/FillConcentric.hpp Fill/FillConcentricInternal.cpp Fill/FillConcentricInternal.hpp + Fill/FillCornerSmoothing.cpp + Fill/FillCornerSmoothing.hpp Fill/Fill.cpp Fill/FillCrossHatch.cpp Fill/FillCrossHatch.hpp diff --git a/src/libslic3r/Fill/Fill.cpp b/src/libslic3r/Fill/Fill.cpp index 88d87ddb26..0888e1bb55 100644 --- a/src/libslic3r/Fill/Fill.cpp +++ b/src/libslic3r/Fill/Fill.cpp @@ -970,9 +970,9 @@ std::vector group_fills(const Layer &layer, LockRegionParam &lock_p region_config.sparse_infill_rotate_template.value); params.fixed_angle = !region_config.sparse_infill_rotate_template.value.empty(); - // Orca: special case; apply smoothing factor only for Hilbert Curve sparse infill. - // FillHilbertCurve::generate clamps and validates the value itself. - if (params.pattern == ipHilbertCurve) + // Orca: the smoothing factor only applies to the sparse infill patterns that + // implement it. The fills clamp and validate the value themselves. + if (is_smoothable_infill_pattern(params.pattern, params.multiline)) params.smooth_factor = 0.01 * region_config.sparse_infill_smooth_factor.value; } else { const bool top_layer_direction_set = surface.is_top() && region_config.top_layer_direction.value >= 0.; diff --git a/src/libslic3r/Fill/Fill3DHoneycomb.cpp b/src/libslic3r/Fill/Fill3DHoneycomb.cpp index 5908f854de..ad5f8918fd 100644 --- a/src/libslic3r/Fill/Fill3DHoneycomb.cpp +++ b/src/libslic3r/Fill/Fill3DHoneycomb.cpp @@ -2,6 +2,7 @@ #include "../ShortestPath.hpp" #include "../Surface.hpp" #include "FillBase.hpp" +#include "FillCornerSmoothing.hpp" #include "Fill3DHoneycomb.hpp" namespace Slic3r { @@ -271,6 +272,9 @@ void Fill3DHoneycomb::_fill_surface_single( for (Polyline &pl : polylines){ pl.translate(bb.min); pl.simplify(5 * spacing); // simplify to 5x line width + // Orca: round the corners of the octahedral wave. The layers where the wave degenerates to a + // straight line have no corner to round. + smooth_polyline_corners(pl, params.smooth_factor, scaled(params.resolution)); } // Apply multiline offset if needed diff --git a/src/libslic3r/Fill/FillConcentric.cpp b/src/libslic3r/Fill/FillConcentric.cpp index a75d2ed7d3..1882f7a656 100644 --- a/src/libslic3r/Fill/FillConcentric.cpp +++ b/src/libslic3r/Fill/FillConcentric.cpp @@ -5,6 +5,7 @@ #include "Arachne/WallToolPaths.hpp" #include "FillConcentric.hpp" +#include "FillCornerSmoothing.hpp" #include namespace Slic3r { @@ -32,12 +33,32 @@ void FillConcentric::_fill_surface_single( Polygons loops = to_polygons(contracted); - ExPolygons last { std::move(contracted) }; + ExPolygons last { contracted }; while (! last.empty()) { last = offset2_ex(last, -(distance + min_spacing/2), +min_spacing/2); append(loops, to_polygons(last)); } + // Orca: round the corners of the loops. Unlike the other patterns these are never clipped to the + // fill region - they are its offsets - so a corner may only be rounded where the curve replacing it + // stays inside. Rounding cuts toward the inside of the turn, which around a hole, at a concave + // feature or across a thin region is outside the fill and would put the extrusion over a wall. + // The reach is capped at half the distance between two loops as well: a loop is as long as the + // object, and a corner cut by half of its side would swallow the neighbouring loops. + auto corner_stays_inside = [&contracted](const Vec2d &from, const Vec2d &to) { + // The straight chord between the ends of the curve is the deepest the curve can cut. + for (const double t : { 0.25, 0.5, 0.75 }) { + const Vec2d sample = from + t * (to - from); + const Point point(coord_t(sample.x()), coord_t(sample.y())); + if (std::none_of(contracted.begin(), contracted.end(), + [&point](const ExPolygon ®ion) { return region.contains(point); })) + return false; + } + return true; + }; + smooth_polygons_corners(loops, params.smooth_factor, scaled(params.resolution), 0.5 * distance, + corner_stays_inside); + // generate paths from the outermost to the innermost, to avoid // adhesion problems of the first central tiny loops loops = union_pt_chained_outside_in(loops); diff --git a/src/libslic3r/Fill/FillCornerSmoothing.cpp b/src/libslic3r/Fill/FillCornerSmoothing.cpp new file mode 100644 index 0000000000..2af9f6bb9c --- /dev/null +++ b/src/libslic3r/Fill/FillCornerSmoothing.cpp @@ -0,0 +1,226 @@ +#include + +#include "FillCornerSmoothing.hpp" + +namespace Slic3r { + +// Turns sharper than this are left untouched: both ends of the curve replacing such a corner nearly +// coincide, so the corner would be rounded into a degenerate loop instead of a hairpin. +static constexpr const double min_smoothed_turn_cosine = -0.9; + +// The control points are expressed in the (incoming, outgoing) basis of the corner, which is not +// orthonormal for turns other than a right angle. +using QuinticBezier = std::array; + +static bool is_bezier_flat(const QuinticBezier &curve, const Vec2d &incoming, const Vec2d &outgoing, const double deviation) +{ + // A Bezier curve stays inside the convex hull of its control points. Therefore, keeping every + // control point within a deviation-wide strip around the endpoint chord conservatively bounds the + // flattening error. The cross product is the perpendicular distance scaled by the chord length; + // comparing squared values avoids a square root. + auto in_plane = [&incoming, &outgoing](const Vec2d &c) { return c.x() * incoming + c.y() * outgoing; }; + const Vec2d chord = in_plane(curve.back() - curve.front()); + const double chord_length_sq = chord.squaredNorm(); + const double max_cross_sq = deviation * deviation * chord_length_sq; + + for (size_t i = 1; i + 1 < curve.size(); ++i) { + const Vec2d offset = in_plane(curve[i] - curve.front()); + const double cross = chord.x() * offset.y() - chord.y() * offset.x(); + if (cross * cross > max_cross_sq) + return false; + } + return true; +} + +static void subdivide_bezier(const QuinticBezier &curve, QuinticBezier &left, QuinticBezier &right) +{ + // Split the curve at t = 0.5 using de Casteljau's algorithm. Each averaging level contributes one + // control point to the left half and one to the right half; the latter is filled backwards to keep + // both resulting control polygons in their original parameter direction. + QuinticBezier subdivision = curve; + left.front() = subdivision.front(); + right.back() = subdivision.back(); + for (size_t level = 1; level < curve.size(); ++level) { + for (size_t i = 0; i + level < curve.size(); ++i) + subdivision[i] = 0.5 * (subdivision[i] + subdivision[i + 1]); + left[level] = subdivision.front(); + right[curve.size() - level - 1] = subdivision[curve.size() - level - 1]; + } +} + +static void flatten_bezier( + const QuinticBezier &curve, const Vec2d &incoming, const Vec2d &outgoing, const double deviation, std::vector &output) +{ + // Subdivide to at least depth 1 so a rounded corner cannot collapse to a single diagonal chord. + // A uniform subdivision depth keeps samples at equal parameter intervals t = k / 2^depth, + // avoiding abrupt segment-length jumps at adaptive-depth boundaries. + static constexpr size_t max_depth = 16; + + std::vector subcurves(2); + subdivide_bezier(curve, subcurves[0], subcurves[1]); + + for (size_t depth = 1; depth < max_depth; ++depth) { + bool all_flat = true; + for (const QuinticBezier &c : subcurves) + if (!is_bezier_flat(c, incoming, outgoing, deviation)) { + all_flat = false; + break; + } + if (all_flat) + break; + std::vector finer(subcurves.size() * 2); + for (size_t i = 0; i < subcurves.size(); ++i) + subdivide_bezier(subcurves[i], finer[i * 2], finer[i * 2 + 1]); + subcurves = std::move(finer); + } + + // The curve start is deliberately omitted so it can be shared with the straight leg feeding into it. + output.clear(); + output.reserve(subcurves.size()); + for (const QuinticBezier &c : subcurves) + output.emplace_back(c.back()); +} + +const std::vector& CornerSmoother::curve_coefficients( + const double corner_distance, const Vec2d &incoming, const Vec2d &outgoing) +{ + const double cosine = incoming.dot(outgoing); + // Corners of the same size and turn angle are congruent, so they flatten identically. An infill + // path walks over the very same corner over and over again, the Hilbert curve over a single one. + if (m_has_cached_coefficients && corner_distance == m_cached_distance && cosine == m_cached_cosine) + return m_cached_coefficients; + + // One canonical corner running from -corner_distance along the incoming leg to corner_distance + // along the outgoing one. At each end, the first three control points are collinear and equally + // spaced: the tangent follows the adjoining straight leg and the second derivative is zero. The + // endpoint curvature is therefore zero, giving G2 joins to both legs. + const double d = corner_distance; + const QuinticBezier corner_curve {{ + {-d, 0.}, {-0.7 * d, 0.}, {-0.4 * d, 0.}, {0., 0.4 * d}, {0., 0.7 * d}, {0., d} + }}; + // Retain a finite positive tolerance if the smoother was set up with an invalid one. + const double deviation = m_tolerance > 0. && std::isfinite(m_tolerance) ? m_tolerance : EPSILON; + flatten_bezier(corner_curve, incoming, outgoing, deviation, m_cached_coefficients); + + m_cached_distance = corner_distance; + m_cached_cosine = cosine; + m_has_cached_coefficients = true; + return m_cached_coefficients; +} + +void CornerSmoother::round_corner(const Vec2d &previous, const Vec2d &corner, const Vec2d &next) +{ + m_corner_points.clear(); + + const Vec2d incoming_leg = corner - previous; + const Vec2d outgoing_leg = next - corner; + const double incoming_length = incoming_leg.norm(); + const double outgoing_length = outgoing_leg.norm(); + if (incoming_length < EPSILON || outgoing_length < EPSILON) { + m_corner_points.emplace_back(corner); + return; + } + + const Vec2d incoming = incoming_leg / incoming_length; + const Vec2d outgoing = outgoing_leg / outgoing_length; + const double cross = incoming.x() * outgoing.y() - incoming.y() * outgoing.x(); + // A collinear vertex is no corner at all, and a hairpin cannot be rounded, see above. + if (std::abs(cross) < EPSILON || incoming.dot(outgoing) < min_smoothed_turn_cosine) { + m_corner_points.emplace_back(corner); + return; + } + + // Consuming at most half of the shorter leg keeps the curves of two adjacent corners apart. + double corner_distance = m_corner_distance_ratio * std::min(incoming_length, outgoing_length); + if (m_max_corner_distance > 0.) + corner_distance = std::min(corner_distance, m_max_corner_distance); + + const Vec2d curve_start = corner - corner_distance * incoming; + const Vec2d curve_end = corner + corner_distance * outgoing; + if (m_corner_filter && !m_corner_filter(curve_start, curve_end)) { + m_corner_points.emplace_back(corner); + return; + } + + const std::vector &coefficients = curve_coefficients(corner_distance, incoming, outgoing); + m_corner_points.reserve(coefficients.size() + 1); + m_corner_points.emplace_back(curve_start); + for (const Vec2d &coefficient : coefficients) + m_corner_points.emplace_back(corner + coefficient.x() * incoming + coefficient.y() * outgoing); +} + +// Rounds the corners of a scaled point sequence. A polygon closes implicitly, so all of its vertices +// are corners; a polyline is an open path that keeps both of its ends, even where they coincide - a +// path returning to where it started retraces its way back and is not a loop. +static Points smooth_corners(const Points &points, const bool polygon, CornerSmoother &smoother) +{ + // A polygon has no free ends, so its first vertex is a corner like any other. Rounding it takes + // feeding the smoother the last vertex first, whose own output point is then dropped again. + size_t skip = polygon ? 1 : 0; + + Points smoothed; + smoothed.reserve(2 * points.size()); + auto emit = [&smoothed, &skip](const Vec2d &point) { + if (skip > 0) { + --skip; + return; + } + smoothed.emplace_back(coord_t(std::floor(point.x() + 0.5)), coord_t(std::floor(point.y() + 0.5))); + }; + + if (polygon) + smoother.push(points.back().cast(), emit); + for (const Point &point : points) + smoother.push(point.cast(), emit); + if (polygon) + // Wrap the first vertex around, so that the last one is a corner as well. + smoother.push(points.front().cast(), emit); + smoother.flush(emit); + + if (polygon) + // The flushed point is the wrapped first vertex, which a polygon does not store. + smoothed.pop_back(); + return smoothed; +} + +void smooth_polyline_corners(Polyline &polyline, const double smooth_factor, const double tolerance, + const double max_corner_distance, const CornerFilter &corner_filter) +{ + CornerSmoother smoother(smooth_factor, tolerance, max_corner_distance, corner_filter); + if (!smoother.enabled() || polyline.size() < 3) + return; + + polyline.points = smooth_corners(polyline.points, false, smoother); + // Rounding back to the integer grid may collapse neighbouring samples of a curve. + polyline.remove_duplicate_points(); +} + +void smooth_polylines_corners(Polylines &polylines, const double smooth_factor, const double tolerance, + const double max_corner_distance, const CornerFilter &corner_filter) +{ + if (sanitize_smooth_factor(smooth_factor) == 0.) + return; + for (Polyline &polyline : polylines) + smooth_polyline_corners(polyline, smooth_factor, tolerance, max_corner_distance, corner_filter); +} + +void smooth_polygons_corners(Polygons &polygons, const double smooth_factor, const double tolerance, + const double max_corner_distance, const CornerFilter &corner_filter) +{ + CornerSmoother smoother(smooth_factor, tolerance, max_corner_distance, corner_filter); + if (!smoother.enabled()) + return; + + for (Polygon &polygon : polygons) { + if (polygon.size() < 3) + continue; + polygon.points = smooth_corners(polygon.points, true, smoother); + polygon.remove_duplicate_points(); + // The curves of the first and of the last corner may have met on the segment they share. A + // polygon closes implicitly, so it must not repeat its first vertex at the end. + if (polygon.points.size() > 1 && polygon.points.front() == polygon.points.back()) + polygon.points.pop_back(); + } +} + +} // namespace Slic3r diff --git a/src/libslic3r/Fill/FillCornerSmoothing.hpp b/src/libslic3r/Fill/FillCornerSmoothing.hpp new file mode 100644 index 0000000000..1852fc4c67 --- /dev/null +++ b/src/libslic3r/Fill/FillCornerSmoothing.hpp @@ -0,0 +1,108 @@ +#pragma once + +#include +#include +#include +#include + +#include "../libslic3r.h" +#include "../Point.hpp" +#include "../Polygon.hpp" +#include "../Polyline.hpp" + +namespace Slic3r { + +// Orca: NaN or infinite factors disable the smoothing, everything else is clamped to <0, 1>. +inline double sanitize_smooth_factor(double smooth_factor) +{ + return std::isfinite(smooth_factor) ? std::clamp(smooth_factor, 0., 1.) : 0.; +} + +// Decides whether a corner may be replaced by the curve that leaves the path at `from` and rejoins it +// at `to`, both in the coordinate system of the pushed points. Rounding cuts toward the inside of the +// turn, so a path that is not clipped to the fill region afterwards needs this to stay inside it. +using CornerFilter = std::function; + +// Orca: Replaces the sharp vertices of an infill path with curves that join the adjoining straight +// legs with a continuous curvature, so the toolhead does not have to stop in every corner. +// Points are pushed one by one, because the plane path fills produce their path on the fly, and +// every point of the smoothed path is handed over to the caller supplied emit callback. +// Fully smoothed adjacent corners meet at the midpoint of the segment they share, so the emitted +// points may collapse onto each other once rounded to the integer grid of the caller. Dropping such +// duplicates is left to the caller, which is the only one knowing that grid. +class CornerSmoother +{ +public: + // tolerance is the maximum chordal deviation of the flattened curves, in the units of the pushed + // points. max_corner_distance caps how far a curve may reach along a leg, in the same units; it + // bounds how far a rounded corner moves away from the original path, which matters where the legs + // are much longer than the spacing of the pattern. Zero leaves the reach uncapped. + CornerSmoother(double smooth_factor, double tolerance, double max_corner_distance = 0., + CornerFilter corner_filter = {}) + : m_corner_distance_ratio(0.5 * sanitize_smooth_factor(smooth_factor)), m_tolerance(tolerance), + m_max_corner_distance(max_corner_distance), m_corner_filter(std::move(corner_filter)) + {} + + bool enabled() const { return m_corner_distance_ratio > 0.; } + + template void push(const Vec2d &point, Emit &emit) + { + if (m_pending == 0) { + emit(point); + m_previous = point; + } else if (m_pending > 1) { + round_corner(m_previous, m_corner, point); + for (const Vec2d &corner_point : m_corner_points) + emit(corner_point); + m_previous = m_corner; + } + m_corner = point; + m_pending = std::min(m_pending + 1, 2); + } + + // Emits the last point of the path and prepares the smoother for a new one. + template void flush(Emit &emit) + { + if (m_pending > 1) + emit(m_corner); + m_pending = 0; + } + +private: + // Fills m_corner_points with the points replacing the corner vertex. + void round_corner(const Vec2d &previous, const Vec2d &corner, const Vec2d &next); + // Flattens the canonical corner curve of the given size and turn into coordinates of the + // (incoming, outgoing) basis of the corner. Cached, as an infill path repeats the same corner. + const std::vector& curve_coefficients(double corner_distance, const Vec2d &incoming, const Vec2d &outgoing); + + // Fraction of the shorter adjoining segment consumed on each side of a corner. Half of a segment + // is the maximum, otherwise the curves of two adjacent corners would overlap. + const double m_corner_distance_ratio; + const double m_tolerance; + const double m_max_corner_distance; + const CornerFilter m_corner_filter; + std::vector m_corner_points; + // Cached flattening of the last corner, valid for corners of the same size and turn angle. + std::vector m_cached_coefficients; + double m_cached_distance { 0. }; + double m_cached_cosine { 0. }; + bool m_has_cached_coefficients { false }; + + Vec2d m_previous { Vec2d::Zero() }; + Vec2d m_corner { Vec2d::Zero() }; + // Number of points held back: none, the first point of a path, or a corner candidate. + int m_pending { 0 }; +}; + +// Rounds the corners of already scaled paths in place. Paths of less than three points are left alone. +// Both ends of a polyline are kept where they are, even when they coincide: such a path retraces its +// way back and joining its ends would turn it into a loop. See CornerSmoother for max_corner_distance. +void smooth_polyline_corners(Polyline &polyline, double smooth_factor, double tolerance, + double max_corner_distance = 0., const CornerFilter &corner_filter = {}); +void smooth_polylines_corners(Polylines &polylines, double smooth_factor, double tolerance, + double max_corner_distance = 0., const CornerFilter &corner_filter = {}); +// Polygons close implicitly, so every one of their vertices is a corner. +void smooth_polygons_corners(Polygons &polygons, double smooth_factor, double tolerance, + double max_corner_distance = 0., const CornerFilter &corner_filter = {}); + +} // namespace Slic3r diff --git a/src/libslic3r/Fill/FillCrossHatch.cpp b/src/libslic3r/Fill/FillCrossHatch.cpp index 571095eca4..98be5ef46b 100644 --- a/src/libslic3r/Fill/FillCrossHatch.cpp +++ b/src/libslic3r/Fill/FillCrossHatch.cpp @@ -3,6 +3,7 @@ #include "../Surface.hpp" #include #include "FillBase.hpp" +#include "FillCornerSmoothing.hpp" #include "FillCrossHatch.hpp" namespace Slic3r { @@ -205,6 +206,9 @@ void FillCrossHatch ::_fill_surface_single( // shift the pattern to the actual space for (Polyline &pl : polylines) { pl.translate(bb.min); } + // Orca: round the corners of the transition layers. The repeat layers are straight lines and stay as they are. + smooth_polylines_corners(polylines, params.smooth_factor, scaled(params.resolution)); + // Apply multiline offset if needed multiline_fill(polylines, params, spacing); diff --git a/src/libslic3r/Fill/FillHoneycomb.cpp b/src/libslic3r/Fill/FillHoneycomb.cpp index a595cdb664..82679541da 100644 --- a/src/libslic3r/Fill/FillHoneycomb.cpp +++ b/src/libslic3r/Fill/FillHoneycomb.cpp @@ -2,6 +2,7 @@ #include "../ShortestPath.hpp" #include "../Surface.hpp" +#include "FillCornerSmoothing.hpp" #include "FillHoneycomb.hpp" namespace Slic3r { @@ -70,6 +71,9 @@ void FillHoneycomb::_fill_surface_single( } p.rotate(-direction.first, m.hex_center); p.simplify(5 * spacing); // simplify to 5x line width + // Orca: round the corners of the honeycomb cells. Done before the clipping, so that the + // curves are cut by the region boundary just like the sharp path would be. + smooth_polyline_corners(p, params.smooth_factor, scaled(params.resolution)); all_polylines.push_back(p); } } diff --git a/src/libslic3r/Fill/FillLightning.cpp b/src/libslic3r/Fill/FillLightning.cpp index 7937b9d129..77031b42e0 100644 --- a/src/libslic3r/Fill/FillLightning.cpp +++ b/src/libslic3r/Fill/FillLightning.cpp @@ -2,6 +2,7 @@ #include "../Print.hpp" #include "../ShortestPath.hpp" #include "FillBase.hpp" +#include "FillCornerSmoothing.hpp" #include "FillLightning.hpp" #include "Lightning/Generator.hpp" @@ -17,6 +18,19 @@ void Filler::_fill_surface_single( const Layer &layer = generator->getTreesForLayer(this->layer_id); Polylines fill_lines = layer.convertToLines(to_polygons(expolygon), scaled(0.5 * this->spacing - this->overlap)); + // Orca: round the turns of the branches. Hairpins are left sharp, as they cannot be rounded, and + // the reach is capped: cutting a corner moves the branch, and a branch is as long as the object + // rather than as long as one cell of a pattern, so half of a leg would merge it with its neighbour + // instead of rounding the turn between them. Half the distance between two branches keeps them + // apart. With more than one line per infill wall the branches are printed as outlines drawn around + // them, and the outlines of branches that run into each other merge into a single one; moving a + // branch by more than a fraction of its printed width breaks such an outline up into separate + // loops, so that width bounds the reach as well. + const double branch_width = scaled(this->spacing) * params.multiline; + const double branch_spacing = branch_width / std::max(double(params.density), EPSILON); + const double max_reach = 0.5 * (params.multiline > 1 ? branch_width : branch_spacing); + smooth_polylines_corners(fill_lines, params.smooth_factor, scaled(params.resolution), max_reach); + // Apply multiline offset if needed multiline_fill(fill_lines, params, spacing); diff --git a/src/libslic3r/Fill/FillPlanePath.cpp b/src/libslic3r/Fill/FillPlanePath.cpp index 7c4f285ac6..577aef0600 100644 --- a/src/libslic3r/Fill/FillPlanePath.cpp +++ b/src/libslic3r/Fill/FillPlanePath.cpp @@ -2,6 +2,7 @@ #include "../ShortestPath.hpp" #include "../Surface.hpp" +#include "FillCornerSmoothing.hpp" #include "FillPlanePath.hpp" namespace Slic3r { @@ -288,145 +289,60 @@ static void generate_hilbert_curve(coord_t min_x, coord_t min_y, coord_t max_x, } } -using QuinticBezier = std::array; - -static bool is_bezier_flat(const QuinticBezier &curve, const double deviation) -{ - // A Bezier curve stays inside the convex hull of its control points. Therefore, keeping every - // control point within a deviation-wide strip around the endpoint chord conservatively bounds the - // flattening error. The cross product is the perpendicular distance scaled by the chord length; - // comparing squared values avoids a square root. - const Vec2d chord = curve.back() - curve.front(); - const double chord_length_sq = chord.squaredNorm(); - const double max_cross_sq = deviation * deviation * chord_length_sq; - - for (size_t i = 1; i + 1 < curve.size(); ++i) { - const Vec2d offset = curve[i] - curve.front(); - const double cross = chord.x() * offset.y() - chord.y() * offset.x(); - if (cross * cross > max_cross_sq) - return false; - } - return true; -} - -static void subdivide_bezier(const QuinticBezier &curve, QuinticBezier &left, QuinticBezier &right) -{ - // Split the curve at t = 0.5 using de Casteljau's algorithm. Each averaging level contributes one - // control point to the left half and one to the right half; the latter is filled backwards to keep - // both resulting control polygons in their original parameter direction. - QuinticBezier subdivision = curve; - left.front() = subdivision.front(); - right.back() = subdivision.back(); - for (size_t level = 1; level < curve.size(); ++level) { - for (size_t i = 0; i + level < curve.size(); ++i) - subdivision[i] = 0.5 * (subdivision[i] + subdivision[i + 1]); - left[level] = subdivision.front(); - right[curve.size() - level - 1] = subdivision[curve.size() - level - 1]; - } -} - -static void flatten_bezier(const QuinticBezier &curve, const double deviation, std::vector &output) -{ - // Subdivide to at least depth 1 so a rounded corner cannot collapse to a single diagonal chord. - // A uniform subdivision depth keeps samples at equal parameter intervals t = k / 2^depth, - // avoiding abrupt segment-length jumps at adaptive-depth boundaries. - static constexpr size_t max_depth = 16; - - std::vector subcurves(2); - subdivide_bezier(curve, subcurves[0], subcurves[1]); - - for (size_t depth = 1; depth < max_depth; ++depth) { - bool all_flat = true; - for (const QuinticBezier &c : subcurves) - if (!is_bezier_flat(c, deviation)) { - all_flat = false; - break; - } - if (all_flat) - break; - std::vector finer(subcurves.size() * 2); - for (size_t i = 0; i < subcurves.size(); ++i) - subdivide_bezier(subcurves[i], finer[i * 2], finer[i * 2 + 1]); - subcurves = std::move(finer); - } - - // The curve start is deliberately omitted so consecutive curve pieces can share it without duplication. - output.reserve(output.size() + subcurves.size()); - for (const QuinticBezier &c : subcurves) - output.emplace_back(c.back()); -} - +// Rounds the corners of the generated path on its way to the infill output. template -static void generate_smooth_hilbert_curve( - coord_t min_x, coord_t min_y, coord_t max_x, coord_t max_y, const double resolution, - const double corner_distance, Output &output) +class SmoothingPolylineOutput { - // A Hilbert curve is defined on a square grid whose side is a power of two. As in the unsmoothed - // generator, expand the larger requested dimension to the next valid Hilbert grid size. The output - // clipper or the later region intersection removes the padded part of the traversal. - size_t sz = 2; - const size_t sz0 = std::max(max_x + 1 - min_x, max_y + 1 - min_y); - while (sz < sz0) - sz <<= 1; +public: + SmoothingPolylineOutput(Output &output, const double smooth_factor, const double tolerance) + : m_output(output), m_smoother(smooth_factor, tolerance) {} - const size_t point_count = sz * sz; - output.reserve(point_count); + void reserve(size_t n) { m_output.reserve(n); } + void add_point(const Vec2d &pt) { auto emit = emitter(); m_smoother.push(pt, emit); } + // The smoother holds back the last point of the path until it knows there is no corner left to round. + void finish() { auto emit = emitter(); m_smoother.flush(emit); } - // The caller normalizes resolution to the unit Hilbert grid; retain a finite positive tolerance - // if this helper is invoked with an invalid resolution. - const double deviation = resolution > 0. && std::isfinite(resolution) ? resolution : EPSILON; - // Construct one canonical 90-degree corner from (-corner_distance, 0) to (0, corner_distance). - // At each end, the first three control points are collinear and equally spaced: the tangent follows - // the adjoining straight leg and the second derivative is zero. The endpoint curvature is therefore - // zero, giving G2 joins to both legs. Every Hilbert turn is an oriented copy of this curve, so flatten - // it only once to the requested chordal-deviation tolerance. - const QuinticBezier corner_curve {{ - {-corner_distance, 0.}, {-0.7 * corner_distance, 0.}, {-0.4 * corner_distance, 0.}, - {0., 0.4 * corner_distance}, {0., 0.7 * corner_distance}, {0., corner_distance} - }}; - std::vector curve_coefficients; - flatten_bezier(corner_curve, deviation, curve_coefficients); - - auto translated_point = [min_x, min_y](size_t idx) { - Point p = hilbert_n_to_xy(idx); - return Point(p.x() + min_x, p.y() + min_y); - }; - auto to_vec2d = [](const Point &p) { return Vec2d(double(p.x()), double(p.y())); }; - bool has_last_output = false; - Vec2d last_output; - // Fully smoothed adjacent corners may meet at the same segment midpoint. Suppress such duplicates - // to avoid emitting zero-length extrusion segments. - auto add_point = [&output, &has_last_output, &last_output](const Vec2d &point) { - if (!has_last_output || point.x() != last_output.x() || point.y() != last_output.y()) { - output.add_point(point); - last_output = point; - has_last_output = true; - } - }; - - Vec2d previous = to_vec2d(translated_point(0)); - Vec2d corner = to_vec2d(translated_point(1)); - add_point(previous); - // Replace each non-collinear Hilbert vertex by the canonical curve expressed in the local basis of - // its incoming and outgoing unit vectors. Collinear vertices remain part of the straight polyline. - for (size_t i = 1; i + 1 < point_count; ++i) { - const Vec2d next = to_vec2d(translated_point(i + 1)); - const Vec2d incoming = (corner - previous).normalized(); - const Vec2d outgoing = (next - corner).normalized(); - const double cross = incoming.x() * outgoing.y() - incoming.y() * outgoing.x(); - - if (std::abs(cross) < EPSILON) { - add_point(corner); - } else { - add_point(corner - corner_distance * incoming); - for (const Vec2d &coefficient : curve_coefficients) - add_point(corner + coefficient.x() * incoming + coefficient.y() * outgoing); - } - - previous = corner; - corner = next; +private: + // The curves of two adjacent corners meet at the midpoint of the segment they share, where they + // may round to the very same output point. Drop those, they would be zero length extrusions. + auto emitter() + { + return [this](const Vec2d &pt) { + const Point snapped = m_output.scaled(pt); + if (m_has_last_snapped && snapped == m_last_snapped) + return; + m_last_snapped = snapped; + m_has_last_snapped = true; + m_output.add_point(pt); + }; } - add_point(corner); + + Output &m_output; + CornerSmoother m_smoother; + Point m_last_snapped { Point::Zero() }; + bool m_has_last_snapped { false }; +}; + +// Runs the path generator against the concrete output type, optionally through the corner smoother. +// The outputs do not share a virtual add_point(), so the type has to be resolved here. +template +static void generate_path(InfillPolylineOutput &output, const FillParams ¶ms, const double resolution, GenerateFn generate) +{ + const double smooth_factor = sanitize_smooth_factor(params.smooth_factor); + auto run = [smooth_factor, resolution, &generate](auto &out) { + if (smooth_factor == 0.) { + generate(out); + } else { + SmoothingPolylineOutput> smoothing(out, smooth_factor, resolution); + generate(smoothing); + smoothing.finish(); + } + }; + + if (output.clips()) + run(static_cast(output)); + else + run(output); } void FillHilbertCurve::generate(coord_t min_x, coord_t min_y, coord_t max_x, coord_t max_y, const double /* resolution */, InfillPolylineOutput &output) @@ -440,19 +356,8 @@ void FillHilbertCurve::generate(coord_t min_x, coord_t min_y, coord_t max_x, coo void FillHilbertCurve::generate(coord_t min_x, coord_t min_y, coord_t max_x, coord_t max_y, const double resolution, const FillParams ¶ms, InfillPolylineOutput &output) { - const double smooth_factor = std::isfinite(params.smooth_factor) ? - std::clamp(params.smooth_factor, 0., 1.) : 0.; - if (smooth_factor == 0.) { - this->generate(min_x, min_y, max_x, max_y, resolution, output); - return; - } - - const double corner_distance = 0.5 * smooth_factor; - if (output.clips()) - generate_smooth_hilbert_curve( - min_x, min_y, max_x, max_y, resolution, corner_distance, static_cast(output)); - else - generate_smooth_hilbert_curve(min_x, min_y, max_x, max_y, resolution, corner_distance, output); + generate_path(output, params, resolution, + [min_x, min_y, max_x, max_y](auto &out) { generate_hilbert_curve(min_x, min_y, max_x, max_y, out); }); } template @@ -495,4 +400,11 @@ void FillOctagramSpiral::generate(coord_t min_x, coord_t min_y, coord_t max_x, c generate_octagram_spiral(min_x, min_y, max_x, max_y, output); } +void FillOctagramSpiral::generate(coord_t min_x, coord_t min_y, coord_t max_x, coord_t max_y, const double resolution, + const FillParams ¶ms, InfillPolylineOutput &output) +{ + generate_path(output, params, resolution, + [min_x, min_y, max_x, max_y](auto &out) { generate_octagram_spiral(min_x, min_y, max_x, max_y, out); }); +} + } // namespace Slic3r diff --git a/src/libslic3r/Fill/FillPlanePath.hpp b/src/libslic3r/Fill/FillPlanePath.hpp index b4b25b73ae..a1e9068ca9 100644 --- a/src/libslic3r/Fill/FillPlanePath.hpp +++ b/src/libslic3r/Fill/FillPlanePath.hpp @@ -21,10 +21,10 @@ public: void add_point(const Vec2d& pt) { m_out.emplace_back(this->scaled(pt)); } Points&& result() { return std::move(m_out); } virtual bool clips() const { return false; } - -protected: + // The output grid the generated points are snapped to. const Point scaled(const Vec2d& fpt) const { return { coord_t(floor(fpt.x() * m_scale_out + 0.5)), coord_t(floor(fpt.y() * m_scale_out + 0.5)) }; } +protected: // Output polyline. Points m_out; @@ -93,6 +93,8 @@ public: protected: bool centered() const override { return true; } void generate(coord_t min_x, coord_t min_y, coord_t max_x, coord_t max_y, const double resolution, InfillPolylineOutput &output) override; + void generate(coord_t min_x, coord_t min_y, coord_t max_x, coord_t max_y, const double resolution, + const FillParams ¶ms, InfillPolylineOutput &output) override; }; } // namespace Slic3r diff --git a/src/libslic3r/Fill/FillRectilinear.cpp b/src/libslic3r/Fill/FillRectilinear.cpp index 8b40b8753c..0c82354b38 100644 --- a/src/libslic3r/Fill/FillRectilinear.cpp +++ b/src/libslic3r/Fill/FillRectilinear.cpp @@ -18,6 +18,7 @@ #include "../ShortestPath.hpp" #include "../VariableWidth.hpp" +#include "FillCornerSmoothing.hpp" #include "FillRectilinear.hpp" // #define SLIC3R_DEBUG @@ -3364,6 +3365,10 @@ bool FillRectilinear::fill_surface_trapezoidal( for (Polyline &pl : polylines) pl.translate(rotate_vector.second); + // Orca: round the corners of the trapezoids. The straight base lines of the triangular family + // have no corner to round. + smooth_polylines_corners(polylines, params.smooth_factor, scaled(params.resolution)); + // Apply multiline fill multiline_fill(polylines, params, spacing); diff --git a/src/libslic3r/PresetBundle.cpp b/src/libslic3r/PresetBundle.cpp index 29b3d1e677..60c90155a9 100644 --- a/src/libslic3r/PresetBundle.cpp +++ b/src/libslic3r/PresetBundle.cpp @@ -50,7 +50,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/libslic3r/PrintConfig.cpp b/src/libslic3r/PrintConfig.cpp index f9d895332a..8083da954e 100644 --- a/src/libslic3r/PrintConfig.cpp +++ b/src/libslic3r/PrintConfig.cpp @@ -3469,9 +3469,8 @@ void PrintConfigDef::init_fff_params() def = this->add("sparse_infill_smooth_factor", coPercent); def->label = L("Sparse infill smooth factor"); def->category = L("Strength"); - def->tooltip = L("Controls how strongly sparse infill corners are rounded. 0% keeps the original right-angle path, " - "while 100% produces the largest possible curves between adjacent infill lines. " - "Currently applies only to the Hilbert Curve."); + def->tooltip = L("Controls how strongly sparse infill corners are rounded. 0% keeps the original sharp path, " + "while 100% produces the largest possible curves between adjacent infill lines."); def->sidetext = "%"; def->min = 0; def->max = 100; diff --git a/src/libslic3r/PrintConfig.hpp b/src/libslic3r/PrintConfig.hpp index f51c1c6411..26a708b78d 100644 --- a/src/libslic3r/PrintConfig.hpp +++ b/src/libslic3r/PrintConfig.hpp @@ -146,6 +146,29 @@ inline bool is_separable_infill_pattern(InfillPattern pattern) } } +// Orca: Infill patterns that round their corners by the "sparse_infill_smooth_factor" option. +// Grid, Triangles and Tri-hexagon only do so in their trapezoidal form, which is generated with more +// than one line per infill wall; a single line makes them plain crossing lines with nothing to round. +inline bool is_smoothable_infill_pattern(InfillPattern pattern, int multiline = 1) +{ + switch (pattern) { + case ipHilbertCurve: + case ipOctagramSpiral: + case ipLightning: + case ipHoneycomb: + case ip3DHoneycomb: + case ipConcentric: + case ipCrossHatch: + return true; + case ipGrid: + case ipTriangles: + case ipStars: + return multiline > 1; + default: + return false; + } +} + enum class IroningType { NoIroning, TopSurfaces, diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index b98396f943..9174b044ec 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -620,6 +620,8 @@ set(SLIC3R_GUI_SOURCES plugin/host/PluginHostSlicing.cpp plugin/host/PluginHostUi.cpp plugin/host/PluginHostUi.hpp + plugin/host/PluginPages.cpp + plugin/host/PluginPages.hpp plugin/CloudPluginService.cpp plugin/CloudPluginService.hpp plugin/PluginFsUtils.cpp @@ -640,6 +642,9 @@ set(SLIC3R_GUI_SOURCES plugin/pluginTypes/printerAgent/PrinterAgentPluginCapability.hpp plugin/pluginTypes/printerAgent/PrinterAgentPluginCapability.cpp plugin/pluginTypes/printerAgent/PrinterAgentPluginCapabilityTrampoline.hpp + plugin/pluginTypes/pages/PagesPluginCapability.hpp + plugin/pluginTypes/pages/PagesPluginCapability.cpp + plugin/pluginTypes/pages/PagesPluginCapabilityTrampoline.hpp plugin/pluginTypes/script/ScriptPluginCapability.hpp plugin/pluginTypes/script/ScriptPluginCapability.cpp plugin/pluginTypes/script/ScriptPluginCapabilityTrampoline.hpp diff --git a/src/slic3r/GUI/Auxiliary.cpp b/src/slic3r/GUI/Auxiliary.cpp index 95244436a3..13ca173eb6 100644 --- a/src/slic3r/GUI/Auxiliary.cpp +++ b/src/slic3r/GUI/Auxiliary.cpp @@ -869,11 +869,11 @@ void AuxiliaryPanel::init_tabpanel() m_assembly_panel = new AuFolderPanel(m_tabpanel, AuxiliaryFolderType::ASSEMBLY_GUIDE); m_others_panel = new AuFolderPanel(m_tabpanel, AuxiliaryFolderType::OTHERS); - m_tabpanel->AddPage(m_designer_panel, _L("Basic Info"), "", true); - m_tabpanel->AddPage(m_pictures_panel, _L("Pictures"), "", false); - m_tabpanel->AddPage(m_bill_of_materials_panel, _L("Bill of Materials"), "", false); - m_tabpanel->AddPage(m_assembly_panel, _L("Assembly Guide"), "", false); - m_tabpanel->AddPage(m_others_panel, _L("Others"), "", false); + m_tabpanel->AddPage(m_designer_panel, _L("Basic Info"), true); + m_tabpanel->AddPage(m_pictures_panel, _L("Pictures"), false); + m_tabpanel->AddPage(m_bill_of_materials_panel, _L("Bill of Materials"), false); + m_tabpanel->AddPage(m_assembly_panel, _L("Assembly Guide"), false); + m_tabpanel->AddPage(m_others_panel, _L("Others"), false); } wxWindow *AuxiliaryPanel::create_side_tools() diff --git a/src/slic3r/GUI/CalibrationPanel.cpp b/src/slic3r/GUI/CalibrationPanel.cpp index b006509adf..bdc79c1c8e 100644 --- a/src/slic3r/GUI/CalibrationPanel.cpp +++ b/src/slic3r/GUI/CalibrationPanel.cpp @@ -488,7 +488,6 @@ void CalibrationPanel::init_tabpanel() { selected = true; m_tabpanel->AddPage(m_cali_panels[i], get_calibration_type_name(m_cali_panels[i]->get_calibration_mode()), - "", selected); } diff --git a/src/slic3r/GUI/ConfigManipulation.cpp b/src/slic3r/GUI/ConfigManipulation.cpp index 3885a391b8..de94bb6b4b 100644 --- a/src/slic3r/GUI/ConfigManipulation.cpp +++ b/src/slic3r/GUI/ConfigManipulation.cpp @@ -752,7 +752,7 @@ void ConfigManipulation::toggle_print_fff_options(DynamicPrintConfig *config, in bool has_top_shell = has_top_shell_layers && config->option("top_surface_density")->value > 0; bool has_bottom_shell = config->opt_int("bottom_shell_layers") > 0; bool has_solid_infill = has_top_shell_layers || has_bottom_shell; - toggle_line("sparse_infill_smooth_factor", pattern == ipHilbertCurve); + toggle_line("sparse_infill_smooth_factor", is_smoothable_infill_pattern(pattern, config->opt_int("fill_multiline"))); toggle_field("top_surface_pattern", has_top_shell); toggle_field("bottom_surface_pattern", has_bottom_shell); toggle_field("top_surface_density", has_top_shell_layers); diff --git a/src/slic3r/GUI/Downloader.cpp b/src/slic3r/GUI/Downloader.cpp index c61b2716fc..0d37a0eca6 100644 --- a/src/slic3r/GUI/Downloader.cpp +++ b/src/slic3r/GUI/Downloader.cpp @@ -134,7 +134,7 @@ void Downloader::start_download(const std::string& full_url) Plater* plater = wxGetApp().plater(); mainframe->Freeze(); - mainframe->select_tab((size_t)MainFrame::TabPosition::tp3DEditor); + mainframe->select_tab(TAB_ID_PREPARE); plater->select_view_3D("3D"); plater->select_view("plate"); plater->get_current_canvas3D()->zoom_to_bed(); diff --git a/src/slic3r/GUI/Field.cpp b/src/slic3r/GUI/Field.cpp index 8d05de13a4..dbbf5ec5e2 100644 --- a/src/slic3r/GUI/Field.cpp +++ b/src/slic3r/GUI/Field.cpp @@ -331,8 +331,10 @@ void Field::PostInitialize() } default: break; } - if (tab_id >= 0) - wxGetApp().mainframe->select_tab(tab_id); + if (tab_id >= 0) { + static constexpr const char* kShortcutTabIds[] = {TAB_ID_HOME, TAB_ID_PREPARE, TAB_ID_PREVIEW, TAB_ID_MONITOR}; + wxGetApp().mainframe->select_tab(kShortcutTabIds[tab_id]); + } if (tab_id > 0) // tab panel should be focused for correct navigation between tabs wxGetApp().tab_panel()->SetFocus(); diff --git a/src/slic3r/GUI/GLCanvas3D.cpp b/src/slic3r/GUI/GLCanvas3D.cpp index a51a10296c..fa6902de8d 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(); @@ -9196,7 +9196,7 @@ void GLCanvas3D::_render_imgui_select_plate_toolbar() view3d_canvas->get_gizmos_manager().reset_all_states(); // close all gizmos view3d_canvas->reload_scene(true); } - app.mainframe->select_tab((size_t)MainFrame::TabPosition::tp3DEditor); + app.mainframe->select_tab(TAB_ID_PREPARE); } } }); diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index 0d4e3f35cf..ffde09dbca 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -813,12 +813,12 @@ void GUI_App::post_init() m_open_method = "url"; } else { if (this->init_params->input_gcode) { - mainframe->select_tab(size_t(MainFrame::tp3DEditor)); + mainframe->select_tab(TAB_ID_PREPARE); plater_->select_view_3D("3D"); this->plater()->load_gcode(from_u8(this->init_params->input_files.front())); m_open_method = "gcode"; } else { - mainframe->select_tab(size_t(MainFrame::tp3DEditor)); + mainframe->select_tab(TAB_ID_PREPARE); plater_->select_view_3D("3D"); wxArrayString input_files; for (auto& file : this->init_params->input_files) { @@ -852,7 +852,7 @@ void GUI_App::post_init() mainframe->Freeze(); #endif plater_->canvas3D()->enable_render(false); - mainframe->select_tab(size_t(MainFrame::tp3DEditor)); + mainframe->select_tab(TAB_ID_PREPARE); plater_->select_view_3D("3D"); //BBS init the opengl resource here if (!plater_->canvas3D()->get_wxglcanvas()->IsShownOnScreen() || @@ -890,9 +890,9 @@ void GUI_App::post_init() } } if (is_editor()) - mainframe->select_tab(size_t(0)); + mainframe->select_tab(TAB_ID_HOME); if (app_config->get("default_page") == "1") - mainframe->select_tab(size_t(1)); + mainframe->select_tab(TAB_ID_PREPARE); #ifndef __linux__ mainframe->Thaw(); #endif @@ -1829,10 +1829,10 @@ bool GUI_App::hot_reload_network_plugin() wxWindowDisabler disabler; if (mainframe) { - int current_tab = mainframe->m_tabpanel->GetSelection(); - if (current_tab == MainFrame::TabPosition::tpMonitor) { + wxString current_tab = mainframe->m_tabpanel->GetSelectedPageName(); + if (current_tab == TAB_ID_MONITOR) { BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": navigating away from Monitor tab before unload"; - mainframe->m_tabpanel->SetSelection(MainFrame::TabPosition::tp3DEditor); + mainframe->m_tabpanel->SelectPageByName(TAB_ID_PREPARE); } } @@ -2851,6 +2851,16 @@ void GUI_App::init_plugin_gui_wiring() plugin_mgr.subscribe_on_unload_callback([refresh_plugins_dialog](const std::string&) { refresh_plugins_dialog(); }); plugin_mgr.subscribe_on_load_callback(NetworkAgentFactory::register_python_plugin); plugin_mgr.subscribe_on_unload_callback(NetworkAgentFactory::deregister_python_plugin); + plugin_mgr.subscribe_on_load_callback([](const std::string& plugin_key) { + if (wxTheApp == nullptr || wxGetApp().is_closing() || wxGetApp().mainframe == nullptr) + return; + wxGetApp().mainframe->plugin_pages().on_plugin_register(plugin_key); + }); + plugin_mgr.subscribe_on_unload_callback([](const std::string& plugin_key) { + if (wxTheApp == nullptr || wxGetApp().is_closing() || wxGetApp().mainframe == nullptr) + return; + wxGetApp().mainframe->plugin_pages().on_plugin_deregister(plugin_key); + }); plugin_mgr.subscribe_on_load_callback(refresh_printer_agent_dropdown_after_load); plugin_mgr.subscribe_on_unload_callback(switch_printer_agent_after_unload); plugin_mgr.subscribe_on_capability_load_callback( @@ -2866,11 +2876,15 @@ void GUI_App::init_plugin_gui_wiring() if (Plater* plater = wxGetApp().plater()) plater->revalidate_current_plate_if_plugins_missing(); }); + if (capability.type == PluginCapabilityType::Pages && wxTheApp && !wxGetApp().is_closing() && wxGetApp().mainframe) + wxGetApp().mainframe->plugin_pages().on_cap_register(capability); }); plugin_mgr.subscribe_on_capability_unload_callback( [refresh_plugins_dialog, switch_printer_agent_after_unload](const PluginCapabilityId& capability) { if (capability.type == PluginCapabilityType::PrinterConnection) NetworkAgentFactory::deregister_python_printer_agent(capability.plugin_key, capability.name); + if (capability.type == PluginCapabilityType::Pages && wxTheApp && !wxGetApp().is_closing() && wxGetApp().mainframe) + wxGetApp().mainframe->plugin_pages().on_cap_deregister(capability); refresh_plugins_dialog(); switch_printer_agent_after_unload(capability.plugin_key); }); @@ -3382,7 +3396,7 @@ bool GUI_App::on_init_inner() mainframe = new MainFrame(); // hide settings tabs after first Layout if (is_editor()) { - mainframe->select_tab(size_t(0)); + mainframe->select_tab(TAB_ID_HOME); } sidebar().obj_list()->init(); @@ -4590,7 +4604,7 @@ void GUI_App::recreate_GUI(const wxString &msg_name) mainframe = new MainFrame(); if (is_editor()) // hide settings tabs after first Layout - mainframe->select_tab(size_t(MainFrame::tp3DEditor)); + mainframe->select_tab(TAB_ID_PREPARE); // Propagate model objects to object list. sidebar().obj_list()->init(); //sidebar().aux_list()->init_auxiliary(); @@ -9849,7 +9863,7 @@ bool GUI_App::check_url_association(std::wstring url_prefix, std::wstring& reg_b { reg_bin = L""; #ifdef WIN32 - wxRegKey key_full(wxRegKey::HKCU, "Software\\Classes\\" + url_prefix + "\\shell\\open\\command"); + wxRegKey key_full(wxRegKey::HKCU, L"Software\\Classes\\" + url_prefix + L"\\shell\\open\\command"); if (!key_full.Exists()) { return false; } @@ -9875,8 +9889,8 @@ void GUI_App::associate_url(std::wstring url_prefix) wxString key_string = "\"" + wbinary + "\" \"%1\""; - wxRegKey key_first(wxRegKey::HKCU, "Software\\Classes\\" + url_prefix); - wxRegKey key_full(wxRegKey::HKCU, "Software\\Classes\\" + url_prefix + "\\shell\\open\\command"); + wxRegKey key_first(wxRegKey::HKCU, L"Software\\Classes\\" + url_prefix); + wxRegKey key_full(wxRegKey::HKCU, L"Software\\Classes\\" + url_prefix + L"\\shell\\open\\command"); if (!key_first.Exists()) { key_first.Create(false); } @@ -9896,7 +9910,7 @@ void GUI_App::disassociate_url(std::wstring url_prefix) #ifdef WIN32 if (is_running_in_msix()) return; - wxRegKey key_full(wxRegKey::HKCU, "Software\\Classes\\" + url_prefix + "\\shell\\open\\command"); + wxRegKey key_full(wxRegKey::HKCU, L"Software\\Classes\\" + url_prefix + L"\\shell\\open\\command"); if (!key_full.Exists()) { return; } diff --git a/src/slic3r/GUI/HMS.cpp b/src/slic3r/GUI/HMS.cpp index 4d67a398f7..5471c16064 100644 --- a/src/slic3r/GUI/HMS.cpp +++ b/src/slic3r/GUI/HMS.cpp @@ -1,6 +1,7 @@ #include "HMS.hpp" #include "GUI.hpp" +#include "GUI_App.hpp" #include "DeviceManager.hpp" #include "DeviceCore/DevManager.h" #include "DeviceCore/DevUtil.h" diff --git a/src/slic3r/GUI/HMS.hpp b/src/slic3r/GUI/HMS.hpp index d2a87ebf42..c494539b36 100644 --- a/src/slic3r/GUI/HMS.hpp +++ b/src/slic3r/GUI/HMS.hpp @@ -1,7 +1,6 @@ #ifndef slic3r_HMS_hpp_ #define slic3r_HMS_hpp_ -#include "GUI_App.hpp" #include "GUI.hpp" #include "I18N.hpp" #include "Widgets/Label.hpp" @@ -11,7 +10,11 @@ #include "slic3r/Utils/Http.hpp" #include "libslic3r/Thread.hpp" #include "nlohmann/json.hpp" +#include #include +#include +#include +#include namespace Slic3r { @@ -26,12 +29,12 @@ namespace GUI { class HMSQuery { protected: - std::unordered_map m_hms_info_jsons; // key-> device id type, the first three digits of SN number - std::unordered_map m_hms_action_jsons;// key-> device id type + std::unordered_map m_hms_info_jsons; // key-> device id type, the first three digits of SN number + std::unordered_map m_hms_action_jsons;// key-> device id type std::unordered_map m_hms_local_images; // key-> image name mutable std::mutex m_hms_mutex; - std::unordered_map m_cloud_hms_last_update_time; + std::unordered_map m_cloud_hms_last_update_time; public: HMSQuery() { } @@ -61,18 +64,18 @@ private: // load hms void init_hms_info(const std::string& dev_type_id); void copy_from_data_dir_to_local(); - int download_hms_related(const std::string& hms_type, const std::string& dev_id_type, json* receive_json); - int load_from_local(const std::string& hms_type, const std::string& dev_id_type, json* receive_json, std::string& version_info); - int save_to_local(std::string lang, std::string hms_type, std::string dev_id_type, json save_json); + int download_hms_related(const std::string& hms_type, const std::string& dev_id_type, nlohmann::json* receive_json); + int load_from_local(const std::string& hms_type, const std::string& dev_id_type, nlohmann::json* receive_json, std::string& version_info); + int save_to_local(std::string lang, std::string hms_type, std::string dev_id_type, nlohmann::json save_json); std::string get_hms_file(std::string hms_type, std::string lang = std::string("en"), std::string dev_id_type = ""); // internal query - string get_dev_id_type(const MachineObject* obj) const; - wxString _query_hms_msg(const string& dev_id_type, const string& long_error_code, const string& lang_code = std::string("en")); + std::string get_dev_id_type(const MachineObject* obj) const; + wxString _query_hms_msg(const std::string& dev_id_type, const std::string& long_error_code, const std::string& lang_code = std::string("en")); - bool _is_internal_error(const string &dev_id_type, const string &long_error_code, const string &lang_code = std::string("en")); - wxString _query_error_msg(const string& dev_id_type, const std::string& long_error_code, const std::string& lang_code = std::string("en")); - wxString _query_error_image_action(const string& dev_id_type, const std::string& long_error_code, std::vector& button_action); + bool _is_internal_error(const std::string &dev_id_type, const std::string &long_error_code, const std::string &lang_code = std::string("en")); + wxString _query_error_msg(const std::string& dev_id_type, const std::string& long_error_code, const std::string& lang_code = std::string("en")); + wxString _query_error_image_action(const std::string& dev_id_type, const std::string& long_error_code, std::vector& button_action); }; int get_hms_info_version(std::string &version); @@ -85,4 +88,4 @@ std::string get_error_message(int error_code); } -#endif \ No newline at end of file +#endif diff --git a/src/slic3r/GUI/MainFrame.cpp b/src/slic3r/GUI/MainFrame.cpp index 5a0e70b74c..3b1cf1dffd 100644 --- a/src/slic3r/GUI/MainFrame.cpp +++ b/src/slic3r/GUI/MainFrame.cpp @@ -493,9 +493,8 @@ DPIFrame(NULL, wxID_ANY, "", wxDefaultPosition, wxDefaultSize, BORDERLESS_FRAME_ }); //BBS - Bind(EVT_SELECT_TAB, [this](wxCommandEvent&evt) { - TabPosition pos = (TabPosition)evt.GetInt(); - m_tabpanel->SetSelection(pos); + Bind(EVT_SELECT_TAB, [this](wxCommandEvent& evt) { + m_tabpanel->SelectPageByName(evt.GetString()); }); Bind(EVT_SYNC_CLOUD_PRESET, &MainFrame::on_select_default_preset, this); @@ -702,7 +701,7 @@ DPIFrame(NULL, wxID_ANY, "", wxDefaultPosition, wxDefaultSize, BORDERLESS_FRAME_ } return;} #endif - if (evt.CmdDown() && evt.GetKeyCode() == 'R') { if (m_slice_enable) { wxGetApp().plater()->update(true, true); wxPostEvent(m_plater, SimpleEvent(EVT_GLTOOLBAR_SLICE_PLATE)); this->m_tabpanel->SetSelection(tpPreview); } return; } + if (evt.CmdDown() && evt.GetKeyCode() == 'R') { if (m_slice_enable) { wxGetApp().plater()->update(true, true); wxPostEvent(m_plater, SimpleEvent(EVT_GLTOOLBAR_SLICE_PLATE)); this->m_tabpanel->SelectPageByName(TAB_ID_PREVIEW); } return; } if (evt.CmdDown() && evt.ShiftDown() && evt.GetKeyCode() == 'G') { m_plater->apply_background_progress(); m_print_enable = get_enable_print_status(); @@ -723,7 +722,7 @@ DPIFrame(NULL, wxID_ANY, "", wxDefaultPosition, wxDefaultSize, BORDERLESS_FRAME_ if (evt.CmdDown() && evt.ShiftDown() && evt.GetKeyCode() == 'S') { if (can_save_as()) m_plater->save_project(true); return;} else if (evt.CmdDown() && evt.GetKeyCode() == 'S') { if (can_save()) m_plater->save_project(); return;} if (evt.CmdDown() && evt.GetKeyCode() == 'F') { - if (m_plater && (m_tabpanel->GetSelection() == TabPosition::tp3DEditor || m_tabpanel->GetSelection() == TabPosition::tpPreview)) { + if (m_plater && is_prepare_or_preview_tab()) { m_plater->sidebar().can_search(); } } @@ -1007,8 +1006,8 @@ void MainFrame::update_layout() m_layout = layout; // From the very beginning the Print settings should be selected - //m_last_selected_tab = m_layout == ESettingsLayout::Dlg ? 0 : 1; - m_last_selected_tab = 1; + //m_last_selected_tab = m_layout == ESettingsLayout::Dlg ? TAB_ID_HOME : TAB_ID_PREPARE; + m_last_selected_tab = TAB_ID_PREPARE; // Set new settings switch (m_layout) @@ -1016,14 +1015,18 @@ void MainFrame::update_layout() case ESettingsLayout::Old: { m_plater->Reparent(m_tabpanel); - m_tabpanel->InsertPage(tp3DEditor, m_plater, _L("Prepare"), std::string("tab_3d_active"), std::string("tab_3d_active"), false); - m_tabpanel->InsertPage(tpPreview, m_plater, _L("Preview"), std::string("tab_preview_active"), std::string("tab_preview_active"), false); + // Right after Home — or first, when there is no Home tab (PositionAfter() would + // append instead, and by now the other built-in tabs are already in place). + const int home_idx = m_tabpanel->FindPageByName(TAB_ID_HOME); + const size_t prepare_pos = (home_idx == wxNOT_FOUND) ? 0 : static_cast(home_idx) + 1; + m_tabpanel->InsertPage(prepare_pos, TAB_ID_PREPARE, m_plater, _L("Prepare"), "tab_3d_active"); + m_tabpanel->InsertPage(prepare_pos + 1, TAB_ID_PREVIEW, m_plater, _L("Preview"), "tab_preview_active"); m_main_sizer->Add(m_tabpanel, 1, wxEXPAND | wxTOP, 0); m_tabpanel->Bind(wxCUSTOMEVT_NOTEBOOK_SEL_CHANGED, [this](wxCommandEvent& evt) { // jump to 3deditor under preview_only mode - if (evt.GetId() == tp3DEditor){ + if (evt.GetId() == m_tabpanel->FindPageByName(TAB_ID_PREPARE)) { Sidebar& sidebar = GUI::wxGetApp().sidebar(); if (sidebar.need_auto_sync_after_connect_printer()) { sidebar.set_need_auto_sync_after_connect_printer(false); @@ -1107,6 +1110,9 @@ void MainFrame::update_edge_panels() void MainFrame::shutdown() { BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << "MainFrame::shutdown enter"; + if (m_project != nullptr) + m_project->shutdown(); + m_plugin_pages.shutdown(); #ifdef __WXGTK__ // Edge panels are child windows — wxWidgets destroys them automatically. m_edge_bottom = nullptr; @@ -1252,15 +1258,14 @@ void MainFrame::init_tabpanel() { #endif //BBS wxWindow* panel = m_tabpanel->GetCurrentPage(); - int sel = m_tabpanel->GetSelection(); //wxString page_text = m_tabpanel->GetPageText(sel); - m_last_selected_tab = m_tabpanel->GetSelection(); + m_last_selected_tab = m_tabpanel->GetSelectedPageName(); if (panel == m_plater) { - if (sel == tp3DEditor) { + if (m_last_selected_tab == TAB_ID_PREPARE) { wxPostEvent(m_plater, SimpleEvent(EVT_GLVIEWTOOLBAR_3D)); m_param_panel->OnActivate(); } - else if (sel == tpPreview) { + else if (m_last_selected_tab == TAB_ID_PREVIEW) { m_plater->reset_check_status(); if (!m_plater->check_ams_status(m_slice_select == eSliceAll)) return; @@ -1275,7 +1280,7 @@ void MainFrame::init_tabpanel() { //monitor } #ifndef __APPLE__ - if (sel == tp3DEditor) { + if (m_last_selected_tab == TAB_ID_PREPARE) { m_topbar->EnableUndoRedoItems(); } else { @@ -1285,34 +1290,16 @@ void MainFrame::init_tabpanel() { if (panel) panel->SetFocus(); - - /*switch (sel) { - case TabPosition::tpHome: - show_option(false); - break; - case TabPosition::tp3DEditor: - show_option(true); - break; - case TabPosition::tpPreview: - show_option(true); - break; - case TabPosition::tpMonitor: - show_option(false); - break; - default: - show_option(false); - break; - }*/ }); if (wxGetApp().is_editor()) { m_webview = new WebViewPanel(m_tabpanel); Bind(EVT_LOAD_URL, [this](wxCommandEvent &evt) { wxString url = evt.GetString(); - select_tab(MainFrame::tpHome); + select_tab(TAB_ID_HOME); m_webview->load_url(url); }); - m_tabpanel->AddPage(m_webview, "", "tab_home_active", "tab_home_active", false); + m_tabpanel->AddPage(TAB_ID_HOME, m_webview, "", "tab_home_active"); m_param_panel = new ParamsPanel(m_tabpanel, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxBK_LEFT | wxTAB_TRAVERSAL); } @@ -1327,7 +1314,7 @@ void MainFrame::init_tabpanel() { //BBS add pages m_monitor = new MonitorPanel(m_tabpanel, wxID_ANY, wxDefaultPosition, wxDefaultSize); m_monitor->SetBackgroundColour(*wxWHITE); - m_tabpanel->AddPage(m_monitor, _L("Device"), std::string("tab_monitor_active"), std::string("tab_monitor_active"), false); + m_tabpanel->AddPage(TAB_ID_MONITOR, m_monitor, _L("Device"), "tab_monitor_active"); m_printer_view = new PrinterWebView(m_tabpanel); Bind(EVT_LOAD_PRINTER_URL, [this](LoadPrinterViewEvent &evt) { @@ -1342,16 +1329,20 @@ void MainFrame::init_tabpanel() { m_multi_machine = new MultiMachinePage(m_tabpanel, wxID_ANY, wxDefaultPosition, wxDefaultSize); m_multi_machine->SetBackgroundColour(*wxWHITE); // TODO: change the bitmap - m_tabpanel->AddPage(m_multi_machine, _L("Multi-device"), std::string("tab_multi_active"), std::string("tab_multi_active"), false); + m_tabpanel->AddPage(TAB_ID_MULTI_DEVICE, m_multi_machine, _L("Multi-device"), "tab_multi_active"); } m_project = new ProjectPanel(m_tabpanel, wxID_ANY, wxDefaultPosition, wxDefaultSize); m_project->SetBackgroundColour(*wxWHITE); - m_tabpanel->AddPage(m_project, _L("Project"), std::string("tab_auxiliary_active"), std::string("tab_auxiliary_active"), false); + m_tabpanel->AddPage(TAB_ID_PROJECT, m_project, _L("Project"), "tab_auxiliary_active"); m_calibration = new CalibrationPanel(m_tabpanel, wxID_ANY, wxDefaultPosition, wxDefaultSize); m_calibration->SetBackgroundColour(*wxWHITE); - m_tabpanel->AddPage(m_calibration, _L("Calibration"), std::string("tab_calibration_active"), std::string("tab_calibration_active"), false); + m_tabpanel->AddPage(TAB_ID_CALIBRATION, m_calibration, _L("Calibration"), "tab_calibration_active"); + + // Plugin pages are appended after the built-in tabs; their ids are namespaced + // (plugin..) so they can't collide with the built-in TAB_ID_* constants. + m_plugin_pages.initialize(m_tabpanel); if (m_plater) { // load initial config @@ -1373,10 +1364,15 @@ void MainFrame::show_device(bool should_use_native) { const bool use_printer_agents = wxGetApp().app_config->get_bool("use_printer_agents"); - // The web page is appended when printer agents are enabled. Remove that - // extra page before switching back to the normal native/Web layout. - if (!use_printer_agents) { - if ((idx = m_tabpanel->FindPage(m_printer_view)) != wxNOT_FOUND && idx != tpMonitor) { + // The web Device page is the extra tab printer-agents mode shows alongside the native one. + // Printers that drive the native Bambu device tab have nothing to put in it, so they don't + // get it — otherwise a Bambu user sees two Device tabs, one of them permanently empty. + const bool want_web_device_tab = use_printer_agents && wxGetApp().preset_bundle != nullptr && + !wxGetApp().preset_bundle->use_bbl_device_tab(); + + // Remove the extra page before switching to any layout that shouldn't have it. + if (!want_web_device_tab) { + if ((idx = m_tabpanel->FindPageByName(TAB_ID_MONITOR_WEB)) != wxNOT_FOUND) { m_printer_view->Show(false); m_tabpanel->RemovePage(idx); } @@ -1394,8 +1390,8 @@ void MainFrame::show_device(bool should_use_native) { m_tabpanel->RemovePage(idx); } m_monitor->Show(false); - m_tabpanel->InsertPage(tpMonitor, m_monitor, _L("Device"), std::string("tab_monitor_active"), - std::string("tab_monitor_active")); + m_tabpanel->InsertPage(m_tabpanel->PositionAfter({TAB_ID_PREVIEW}), TAB_ID_MONITOR, m_monitor, + _L("Device"), "tab_monitor_active"); } if (m_printer_view == nullptr) { @@ -1416,28 +1412,31 @@ void MainFrame::show_device(bool should_use_native) { // TODO: change the bitmap if (m_tabpanel->FindPage(m_multi_machine) == wxNOT_FOUND) { m_multi_machine->Show(false); - m_tabpanel->InsertPage(tpMultiDevice, m_multi_machine, _L("Multi-device"), std::string("tab_multi_active"), - std::string("tab_multi_active"), false); + // Past the web Device tab when it is already there, so enabling multi-machine + // later can't wedge this page between the two Device tabs. + m_tabpanel->InsertPage(m_tabpanel->PositionAfter({TAB_ID_MONITOR_WEB, TAB_ID_MONITOR}), + TAB_ID_MULTI_DEVICE, m_multi_machine, _L("Multi-device"), "tab_multi_active"); } } if (!m_calibration) { m_calibration = new CalibrationPanel(m_tabpanel, wxID_ANY, wxDefaultPosition, wxDefaultSize); m_calibration->SetBackgroundColour(*wxWHITE); } - // Calibration is always the last page, so don't use InsertPage here. Otherwise, if multi_machine page is not enabled, - // the calibration tab won't be properly added as well, due to the TabPosition::tpCalibration no longer matches the real tab position. if (m_tabpanel->FindPage(m_calibration) == wxNOT_FOUND) { m_calibration->Show(false); - m_tabpanel->AddPage(m_calibration, _L("Calibration"), std::string("tab_calibration_active"), - std::string("tab_calibration_active"), false); + m_tabpanel->InsertPage(m_tabpanel->PositionAfter({TAB_ID_PROJECT}), TAB_ID_CALIBRATION, m_calibration, + _L("Calibration"), "tab_calibration_active"); } - if ((idx = m_tabpanel->FindPage(m_printer_view)) == wxNOT_FOUND) { - m_printer_view->Show(false); - m_tabpanel->AddPage(m_printer_view, _L("Device (Web)"), std::string("tab_monitor_active"), - std::string("tab_monitor_active"), false); - } else { - m_tabpanel->SetPageText(idx, _L("Device (Web)")); + if (want_web_device_tab) { + if ((idx = m_tabpanel->FindPage(m_printer_view)) == wxNOT_FOUND) { + m_printer_view->Show(false); + // Immediately right of the native Device tab, not at the end of the tab bar. + m_tabpanel->InsertPage(m_tabpanel->PositionAfter({TAB_ID_MONITOR}), TAB_ID_MONITOR_WEB, + m_printer_view, _L("Device (Web)"), "tab_monitor_active"); + } else { + m_tabpanel->SetPageText(idx, _L("Device (Web)")); + } } #ifdef _MSW_DARK_MODE @@ -1445,6 +1444,7 @@ void MainFrame::show_device(bool should_use_native) { #endif // _MSW_DARK_MODE fit_tab_labels(); // ORCA on printer change + m_plugin_pages.relayout(); // re-sync plugin tabs against the native tabs just mutated above return; } @@ -1466,7 +1466,8 @@ void MainFrame::show_device(bool should_use_native) { m_monitor->SetBackgroundColour(*wxWHITE); } m_monitor->Show(false); - m_tabpanel->InsertPage(tpMonitor, m_monitor, _L("Device"), std::string("tab_monitor_active"), std::string("tab_monitor_active")); + m_tabpanel->InsertPage(m_tabpanel->PositionAfter({TAB_ID_PREVIEW}), TAB_ID_MONITOR, m_monitor, + _L("Device"), "tab_monitor_active"); if (wxGetApp().is_enable_multi_machine()) { if (!m_multi_machine) { @@ -1475,18 +1476,18 @@ void MainFrame::show_device(bool should_use_native) { } // TODO: change the bitmap m_multi_machine->Show(false); - m_tabpanel->InsertPage(tpMultiDevice, m_multi_machine, _L("Multi-device"), std::string("tab_multi_active"), - std::string("tab_multi_active"), false); + m_tabpanel->InsertPage(m_tabpanel->PositionAfter({TAB_ID_MONITOR}), TAB_ID_MULTI_DEVICE, m_multi_machine, + _L("Multi-device"), "tab_multi_active"); } if (!m_calibration) { m_calibration = new CalibrationPanel(m_tabpanel, wxID_ANY, wxDefaultPosition, wxDefaultSize); m_calibration->SetBackgroundColour(*wxWHITE); } m_calibration->Show(false); - // Calibration is always the last page, so don't use InsertPage here. Otherwise, if multi_machine page is not enabled, - // the calibration tab won't be properly added as well, due to the TabPosition::tpCalibration no longer matches the real tab position. - m_tabpanel->AddPage(m_calibration, _L("Calibration"), std::string("tab_calibration_active"), - std::string("tab_calibration_active"), false); + // Last of the built-in tabs, but plugin tabs already sit past it — anchor rather than + // append, so its position doesn't depend on the relayout() below running afterwards. + m_tabpanel->InsertPage(m_tabpanel->PositionAfter({TAB_ID_PROJECT}), TAB_ID_CALIBRATION, m_calibration, + _L("Calibration"), "tab_calibration_active"); #ifdef _MSW_DARK_MODE wxGetApp().UpdateDarkUIWin(this); @@ -1519,10 +1520,17 @@ void MainFrame::show_device(bool should_use_native) { }); } m_printer_view->Show(false); - m_tabpanel->InsertPage(tpMonitor, m_printer_view, _L("Device"), std::string("tab_monitor_active"), - std::string("tab_monitor_active")); + m_tabpanel->InsertPage(m_tabpanel->PositionAfter({TAB_ID_PREVIEW}), TAB_ID_MONITOR, m_printer_view, + _L("Device"), "tab_monitor_active"); } fit_tab_labels(); // ORCA on printer change + m_plugin_pages.relayout(); // re-sync plugin tabs against the native tabs just mutated above +} + +bool MainFrame::is_prepare_or_preview_tab() const +{ + const wxString tab = m_tabpanel->GetSelectedPageName(); + return tab == TAB_ID_PREPARE || tab == TAB_ID_PREVIEW; } void MainFrame::fit_tab_labels() @@ -1554,7 +1562,7 @@ void MainFrame::fit_tab_labels() bool MainFrame::preview_only_hint() { if (m_plater && (m_plater->only_gcode_mode() || (m_plater->using_exported_file()))) { - BOOST_LOG_TRIVIAL(info) << boost::format("skipped tab switch from %1% to %2% in preview mode")%m_tabpanel->GetSelection() %tp3DEditor; + BOOST_LOG_TRIVIAL(info) << boost::format("skipped tab switch from %1% to %2% in preview mode")%m_tabpanel->GetSelectedPageName() %wxString(TAB_ID_PREPARE); ConfirmBeforeSendDialog confirm_dlg(this, wxID_ANY, _L("Warning")); confirm_dlg.Bind(EVT_SECONDARY_CHECK_CONFIRM, [this](wxCommandEvent& e) { @@ -1872,22 +1880,22 @@ bool MainFrame::can_clone() const { bool MainFrame::can_select() const { - return (m_plater != nullptr) && (m_tabpanel->GetSelection() == TabPosition::tp3DEditor) && !m_plater->model().objects.empty(); + return (m_plater != nullptr) && (m_tabpanel->GetSelectedPageName() == TAB_ID_PREPARE) && !m_plater->model().objects.empty(); } bool MainFrame::can_deselect() const { - return (m_plater != nullptr) && (m_tabpanel->GetSelection() == TabPosition::tp3DEditor) && !m_plater->is_selection_empty(); + return (m_plater != nullptr) && (m_tabpanel->GetSelectedPageName() == TAB_ID_PREPARE) && !m_plater->is_selection_empty(); } bool MainFrame::can_delete() const { - return (m_plater != nullptr) && (m_tabpanel->GetSelection() == TabPosition::tp3DEditor) && !m_plater->is_selection_empty(); + return (m_plater != nullptr) && (m_tabpanel->GetSelectedPageName() == TAB_ID_PREPARE) && !m_plater->is_selection_empty(); } bool MainFrame::can_delete_all() const { - return (m_plater != nullptr) && (m_tabpanel->GetSelection() == TabPosition::tp3DEditor) && !m_plater->model().objects.empty(); + return (m_plater != nullptr) && (m_tabpanel->GetSelectedPageName() == TAB_ID_PREPARE) && !m_plater->model().objects.empty(); } bool MainFrame::can_reslice() const @@ -1996,7 +2004,7 @@ wxBoxSizer* MainFrame::create_side_tools() wxPostEvent(m_plater, SimpleEvent(EVT_GLTOOLBAR_SLICE_ALL)); else wxPostEvent(m_plater, SimpleEvent(EVT_GLTOOLBAR_SLICE_PLATE)); - this->m_tabpanel->SetSelection(tpPreview); + this->m_tabpanel->SelectPageByName(TAB_ID_PREVIEW); } }); @@ -3143,7 +3151,7 @@ void MainFrame::init_menubar_as_editor() wxGetApp().app_config->set_bool("auto_perspective", !wxGetApp().app_config->get_bool("auto_perspective")); m_plater->get_current_canvas3D()->post_event(SimpleEvent(wxEVT_PAINT)); }, - this, [this]() { return m_tabpanel->GetSelection() == TabPosition::tp3DEditor || m_tabpanel->GetSelection() == TabPosition::tpPreview; }, + this, [this]() { return is_prepare_or_preview_tab(); }, [this]() { return wxGetApp().app_config->get_bool("auto_perspective"); }, this); viewMenu->AppendSeparator(); @@ -3152,7 +3160,7 @@ void MainFrame::init_menubar_as_editor() wxGetApp().toggle_show_gcode_window(); m_plater->get_current_canvas3D()->post_event(SimpleEvent(wxEVT_PAINT)); }, - this, [this]() { return m_tabpanel->GetSelection() == tpPreview; }, + this, [this]() { return m_tabpanel->GetSelectedPageName() == TAB_ID_PREVIEW; }, [this]() { return wxGetApp().show_gcode_window(); }, this); append_menu_check_item( @@ -3161,7 +3169,7 @@ void MainFrame::init_menubar_as_editor() wxGetApp().toggle_show_3d_navigator(); m_plater->get_current_canvas3D()->post_event(SimpleEvent(wxEVT_PAINT)); }, - this, [this]() { return m_tabpanel->GetSelection() == TabPosition::tp3DEditor || m_tabpanel->GetSelection() == TabPosition::tpPreview; }, + this, [this]() { return is_prepare_or_preview_tab(); }, [this]() { return wxGetApp().show_3d_navigator(); }, this); append_menu_check_item(viewMenu, wxID_ANY, _L("Show Gridlines"), _L("Show Gridlines on plate"), @@ -3169,15 +3177,14 @@ void MainFrame::init_menubar_as_editor() wxGetApp().toggle_show_plate_gridlines(); m_plater->get_current_canvas3D()->post_event(SimpleEvent(wxEVT_PAINT)); }, this, - [this]() { return m_tabpanel->GetSelection() == TabPosition::tp3DEditor || m_tabpanel->GetSelection() == TabPosition::tpPreview; }, + [this]() { return is_prepare_or_preview_tab(); }, [this]() { return wxGetApp().show_plate_gridlines(); }, this); append_menu_item( viewMenu, wxID_ANY, _L("Reset Window Layout"), _L("Reset to default window layout"), [this](wxCommandEvent&) { m_plater->reset_window_layout(); }, "", this, [this]() { - return (m_tabpanel->GetSelection() == TabPosition::tp3DEditor || m_tabpanel->GetSelection() == TabPosition::tpPreview) && - m_plater->is_sidebar_enabled(); + return is_prepare_or_preview_tab() && m_plater->is_sidebar_enabled(); }, this); @@ -3199,7 +3206,7 @@ void MainFrame::init_menubar_as_editor() wxGetApp().toggle_show_outline(); m_plater->get_current_canvas3D()->post_event(SimpleEvent(wxEVT_PAINT)); }, - this, [this]() { return m_tabpanel->GetSelection() == TabPosition::tp3DEditor; }, + this, [this]() { return m_tabpanel->GetSelectedPageName() == TAB_ID_PREPARE; }, [this]() { return wxGetApp().show_outline(); }, this); /*viewMenu->AppendSeparator(); @@ -4000,13 +4007,16 @@ void MainFrame::select_tab(wxPanel* panel) wxGetApp().params_dialog()->Popup(); return; } + // Not panel->GetName(): Prepare and Preview share the single m_plater window, so the + // window has no one correct name. The slot -> id lookup is the only correct resolution. int page_idx = m_tabpanel->FindPage(panel); - if (page_idx == tp3DEditor && m_tabpanel->GetSelection() == tpPreview) + wxString page_name = (page_idx == wxNOT_FOUND) ? wxString() : m_tabpanel->GetPageName(static_cast(page_idx)); + if (page_name == TAB_ID_PREPARE && m_tabpanel->GetSelectedPageName() == TAB_ID_PREVIEW) return; //BBS GUI refactor: remove unused layout new/dlg /*if (page_idx != wxNOT_FOUND && m_layout == ESettingsLayout::Dlg) page_idx++;*/ - select_tab(size_t(page_idx)); + select_tab(page_name); } //BBS @@ -4014,7 +4024,7 @@ void MainFrame::jump_to_monitor(std::string dev_id) { if(!m_monitor) return; - m_tabpanel->SetSelection(tpMonitor); + m_tabpanel->SelectPageByName(TAB_ID_MONITOR); if (!dev_id.empty()) { ((MonitorPanel*)m_monitor)->select_machine(dev_id); } @@ -4024,26 +4034,26 @@ void MainFrame::jump_to_multipage() { if(!m_multi_machine) return; - m_tabpanel->SetSelection(tpMultiDevice); + m_tabpanel->SelectPageByName(TAB_ID_MULTI_DEVICE); ((MultiMachinePage*)m_multi_machine)->jump_to_send_page(); } //BBS GUI refactor: remove unused layout new/dlg -void MainFrame::select_tab(size_t tab/* = size_t(-1)*/) +void MainFrame::select_tab(const wxString& id/* = wxString()*/) { //bool tabpanel_was_hidden = false; // Controls on page are created on active page of active tab now. // We should select/activate tab before its showing to avoid an UI-flickering - auto select = [this, tab](bool was_hidden) { - // when tab == -1, it means we should show the last selected tab + auto select = [this, id](bool was_hidden) { + // when id is empty, it means we should show the last selected tab //BBS GUI refactor: remove unused layout new/dlg //size_t new_selection = tab == (size_t)(-1) ? m_last_selected_tab : (m_layout == ESettingsLayout::Dlg && tab != 0) ? tab - 1 : tab; - size_t new_selection = tab == (size_t)(-1) ? m_last_selected_tab : tab; + wxString new_selection = id.empty() ? m_last_selected_tab : id; - if (m_tabpanel->GetSelection() != (int)new_selection) - m_tabpanel->SetSelection(new_selection); + if (m_tabpanel->GetSelectedPageName() != new_selection) + m_tabpanel->SelectPageByName(new_selection); #ifdef _MSW_DARK_MODE /*if (wxGetApp().tabs_as_menu()) { if (Tab* cur_tab = dynamic_cast(m_tabpanel->GetPage(new_selection))) @@ -4052,10 +4062,12 @@ void MainFrame::select_tab(size_t tab/* = size_t(-1)*/) m_plater->get_current_canvas3D()->render(); }*/ #endif - if (tab == MainFrame::tp3DEditor && m_layout == ESettingsLayout::Old) + // Intentionally `id`, not `new_selection`: the fallback-to-last-tab path must not + // trigger this render even when the last selected tab was Prepare. + if (id == TAB_ID_PREPARE && m_layout == ESettingsLayout::Old) m_plater->canvas3D()->render(); else if (was_hidden) { - Tab* cur_tab = dynamic_cast(m_tabpanel->GetPage(new_selection)); + Tab* cur_tab = dynamic_cast(m_tabpanel->GetPageByName(new_selection)); if (cur_tab) cur_tab->OnActivate(); } @@ -4064,10 +4076,10 @@ void MainFrame::select_tab(size_t tab/* = size_t(-1)*/) select(false); } -void MainFrame::request_select_tab(TabPosition pos) +void MainFrame::request_select_tab(const wxString& id) { wxCommandEvent* evt = new wxCommandEvent(EVT_SELECT_TAB); - evt->SetInt(pos); + evt->SetString(id); wxQueueEvent(this, evt); } @@ -4359,7 +4371,7 @@ void MainFrame::load_printer_url() } } -bool MainFrame::is_printer_view() const { return m_tabpanel->GetSelection() == TabPosition::tpMonitor; } +bool MainFrame::is_printer_view() const { return m_tabpanel->GetSelectedPageName() == TAB_ID_MONITOR; } void MainFrame::refresh_plugin_tips() diff --git a/src/slic3r/GUI/MainFrame.hpp b/src/slic3r/GUI/MainFrame.hpp index a614783c31..2052860d80 100644 --- a/src/slic3r/GUI/MainFrame.hpp +++ b/src/slic3r/GUI/MainFrame.hpp @@ -35,6 +35,21 @@ #include "PrinterWebView.hpp" #include "calib_dlg.hpp" #include "MultiMachinePage.hpp" +#include "slic3r/plugin/host/PluginPages.hpp" + +// Stable identifiers for MainFrame::m_tabpanel's built-in pages. These are +// names rather than positional indices so optional pages cannot shift them. +#define TAB_ID_HOME "home" +#define TAB_ID_PREPARE "prepare" +#define TAB_ID_PREVIEW "preview" +#define TAB_ID_MONITOR "monitor" +// Printer-agents mode shows the legacy web page alongside the native Device tab, so it needs an +// id of its own: sharing TAB_ID_MONITOR makes every name lookup resolve to whichever of the two +// comes first, which silently defeats PluginPages' selection round-trip across a tab relayout. +#define TAB_ID_MONITOR_WEB "monitor_web" +#define TAB_ID_MULTI_DEVICE "multi_device" +#define TAB_ID_PROJECT "project" +#define TAB_ID_CALIBRATION "calibration" #define ENABEL_PRINT_ALL 0 @@ -115,7 +130,7 @@ class MainFrame : public DPIFrame wxMenuItem* m_menu_item_reslice_now { nullptr }; wxSizer* m_main_sizer{ nullptr }; - size_t m_last_selected_tab; + wxString m_last_selected_tab; std::string get_base_name(const wxString &full_name, const char *extension = nullptr) const; std::string get_dir_name(const wxString &full_name) const; @@ -214,19 +229,6 @@ public: #ifdef __APPLE__ bool get_mac_full_screen() { return m_mac_fullscreen; } #endif - //BBS GUI refactor - enum TabPosition - { - tpHome = 0, - tp3DEditor = 1, - tpPreview = 2, - tpMonitor = 3, - tpMultiDevice = 4, - tpProject = 5, - tpCalibration = 6, - tpAuxiliary = 7, - toDebugTool = 8, - }; //BBS: add slice&&print status update logic enum SlicePrintEventType @@ -326,8 +328,8 @@ public: // When tab == -1, will be selected last selected tab //BBS: GUI refactor void select_tab(wxPanel* panel); - void select_tab(size_t tab = size_t(-1)); - void request_select_tab(TabPosition pos); + void select_tab(const wxString& id = wxString()); + void request_select_tab(const wxString& id); int get_calibration_curr_tab(); void select_view(const std::string& direction); // Propagate changed configuration from the Tab to the Plater and save changes to the AppConfig @@ -360,6 +362,9 @@ public: //SoftFever void show_device(bool should_use_native); void fit_tab_labels(); // ORCA + // True while either of the two tabs backed by m_plater is selected. + bool is_prepare_or_preview_tab() const; + PluginPages& plugin_pages() { return m_plugin_pages; } PA_Calibration_Dlg* m_pa_calib_dlg{ nullptr }; FlowRateCalibrationDialog* m_flow_rate_calib_dlg{ nullptr }; @@ -385,7 +390,8 @@ public: CalibrationPanel* m_calibration{ nullptr }; WebViewPanel* m_webview { nullptr }; PrinterWebView* m_printer_view{nullptr}; - wxLogWindow* m_log_window { nullptr }; + PluginPages m_plugin_pages; + wxLogWindow* m_log_window { nullptr }; // BBS //wxBookCtrlBase* m_tabpanel { nullptr }; Notebook* m_tabpanel{ nullptr }; diff --git a/src/slic3r/GUI/Monitor.cpp b/src/slic3r/GUI/Monitor.cpp index 9f074d3f93..1a6d969988 100644 --- a/src/slic3r/GUI/Monitor.cpp +++ b/src/slic3r/GUI/Monitor.cpp @@ -186,17 +186,17 @@ void MonitorPanel::init_tabpanel() //m_status_add_machine_panel = new AddMachinePanel(m_tabpanel); m_status_info_panel = new StatusPanel(m_tabpanel); - m_tabpanel->AddPage(m_status_info_panel, _L("Status"), "", true); + m_tabpanel->AddPage(m_status_info_panel, _L("Status"), true); m_media_file_panel = new MediaFilePanel(m_tabpanel); - m_tabpanel->AddPage(m_media_file_panel, _L("Storage"), "", false); - //m_tabpanel->AddPage(m_media_file_panel, _L("Internal Storage"), "", false); + m_tabpanel->AddPage(m_media_file_panel, _L("Storage"), false); + //m_tabpanel->AddPage(m_media_file_panel, _L("Internal Storage"), false); m_upgrade_panel = new UpgradePanel(m_tabpanel); - m_tabpanel->AddPage(m_upgrade_panel, _L_CONTEXT(L_CONTEXT("Update", "Firmware"), "Firmware"), "", false); + m_tabpanel->AddPage(m_upgrade_panel, _L_CONTEXT(L_CONTEXT("Update", "Firmware"), "Firmware"), false); m_hms_panel = new HMSPanel(m_tabpanel); - m_tabpanel->AddPage(m_hms_panel, _L("Assistant(HMS)"), "", false); + m_tabpanel->AddPage(m_hms_panel, _L("Assistant(HMS)"), false); std::string network_ver = Slic3r::NetworkAgent::get_version(); if (!network_ver.empty()) { @@ -413,7 +413,10 @@ void MonitorPanel::update_hms_tag() bool MonitorPanel::Show(bool show) { #ifdef __APPLE__ - wxGetApp().mainframe->SetMinSize(wxGetApp().plater()->GetMinSize()); + // Notebook::InsertPage() hides every page it appends, so this also runs while MainFrame is + // still constructing, before GUI_App::mainframe is assigned. Same guard as Plater::Show(). + if (wxGetApp().mainframe) + wxGetApp().mainframe->SetMinSize(wxGetApp().plater()->GetMinSize()); #endif NetworkAgent* m_agent = wxGetApp().getAgent(); diff --git a/src/slic3r/GUI/MultiMachinePage.cpp b/src/slic3r/GUI/MultiMachinePage.cpp index b9b71ad670..88d03007b9 100644 --- a/src/slic3r/GUI/MultiMachinePage.cpp +++ b/src/slic3r/GUI/MultiMachinePage.cpp @@ -86,9 +86,9 @@ void MultiMachinePage::init_tabpanel() m_cloud_task_manager = new CloudTaskManagerPage(m_tabpanel); m_machine_manager = new MultiMachineManagerPage(m_tabpanel); - m_tabpanel->AddPage(m_machine_manager, _L("Device"), "", true); - m_tabpanel->AddPage(m_local_task_manager, _L("Task Sending"), "", false); - m_tabpanel->AddPage(m_cloud_task_manager, _L("Task Sent"), "", false); + m_tabpanel->AddPage(m_machine_manager, _L("Device"), true); + m_tabpanel->AddPage(m_local_task_manager, _L("Task Sending"), false); + m_tabpanel->AddPage(m_cloud_task_manager, _L("Task Sent"), false); } void MultiMachinePage::init_timer() diff --git a/src/slic3r/GUI/Notebook.cpp b/src/slic3r/GUI/Notebook.cpp index ceda3fc0d6..673508454a 100644 --- a/src/slic3r/GUI/Notebook.cpp +++ b/src/slic3r/GUI/Notebook.cpp @@ -120,11 +120,11 @@ void ButtonsListCtrl::Rescale() void ButtonsListCtrl::SetSelection(int sel) { - if (m_selection == sel) + if (m_selection == sel && sel >= 0 && sel < static_cast(m_pageButtons.size())) return; // BBS: change button color wxColour selected_btn_bg("#009688"); // Gradient #009688 - if (m_selection >= 0) { + if (m_selection >= 0 && m_selection < static_cast(m_pageButtons.size())) { StateColor bg_color = StateColor( std::pair{wxColour(107, 107, 107), (int) StateColor::Hovered}, std::pair{wxColour(59, 68, 70), (int) StateColor::Normal}); @@ -132,9 +132,15 @@ void ButtonsListCtrl::SetSelection(int sel) StateColor text_color = StateColor( std::pair{wxColour(254,254, 254), (int) StateColor::Normal} ); - m_pageButtons[m_selection]->SetSelected(false); m_pageButtons[m_selection]->SetTextColor(text_color); } + + if (sel < 0 || sel >= static_cast(m_pageButtons.size())) { + m_selection = -1; + Refresh(); + return; + } + m_selection = sel; StateColor bg_color = StateColor( @@ -145,17 +151,19 @@ void ButtonsListCtrl::SetSelection(int sel) StateColor text_color = StateColor( std::pair{wxColour(254, 254, 254), (int) StateColor::Normal} ); - m_pageButtons[m_selection]->SetSelected(true); m_pageButtons[m_selection]->SetTextColor(text_color); Refresh(); } -bool ButtonsListCtrl::InsertPage(size_t n, const wxString &text, bool bSelect /* = false*/, const std::string &bmp_name /* = ""*/, const std::string &inactive_bmp_name) +bool ButtonsListCtrl::InsertPage(size_t n, const wxString &text, bool bSelect /* = false*/, const std::string &bmp_name /* = ""*/, const wxBitmap &bmp /* = wxNullBitmap */) { Button * btn = new Button(this, text.empty() ? text : " " + text, bmp_name, wxNO_BORDER); btn->SetCornerRadius(0); + if (bmp_name.empty() && bmp.IsOk()) + btn->SetIcon(bmp); + int em = em_unit(this); //BBS set size for button btn->SetMinSize({(text.empty() ? 40 : 136) * em / 10, 36 * em / 10}); @@ -168,8 +176,6 @@ bool ButtonsListCtrl::InsertPage(size_t n, const wxString &text, bool bSelect /* StateColor text_color = StateColor( std::pair{wxColour(254,254, 254), (int) StateColor::Normal}); btn->SetTextColor(text_color); - btn->SetInactiveIcon(inactive_bmp_name); - btn->SetSelected(false); btn->Bind(wxEVT_BUTTON, [this, btn](wxCommandEvent& event) { if (auto it = std::find(m_pageButtons.begin(), m_pageButtons.end(), btn); it != m_pageButtons.end()) { auto sel = it - m_pageButtons.begin(); @@ -192,6 +198,14 @@ bool ButtonsListCtrl::InsertPage(size_t n, const wxString &text, bool bSelect /* void ButtonsListCtrl::RemovePage(size_t n) { + if (n >= m_pageButtons.size()) + return; + + if (m_selection == static_cast(n)) + m_selection = -1; + else if (m_selection > static_cast(n)) + --m_selection; + Button* btn = m_pageButtons[n]; m_pageButtons.erase(m_pageButtons.begin() + n); m_pageLabels.erase(m_pageLabels.begin() + n); // ORCA @@ -240,6 +254,24 @@ wxString ButtonsListCtrl::GetPageText(size_t n) const return btn->GetLabel(); } +// ORCA +void ButtonsListCtrl::SetOverflowButton(wxWindow* button) +{ + if (m_overflow_button == button) + return; + + if (m_overflow_button != nullptr) + m_sizer->Detach(m_overflow_button); + + m_overflow_button = button; + + if (m_overflow_button != nullptr) + // Right after the tab buttons (index 0), ahead of any stretch spacer / side_tools. + m_sizer->Insert(1, m_overflow_button, 0, wxALIGN_CENTER_VERTICAL | wxLEFT | wxBOTTOM, m_btn_margin); + + m_sizer->Layout(); +} + //#endif // _WIN32 void Notebook::Init() @@ -253,6 +285,8 @@ void Notebook::Init() m_showTimeout = m_hideTimeout = 0; + m_pageNames.clear(); + /* On Linux, Gstreamer wxMediaCtrl does not seem to get along well with * 32-bit X11 visuals (the overlay does not work). Is this a wxWindows * bug? Is this a Gstreamer bug? No idea, but it is our problem ... diff --git a/src/slic3r/GUI/Notebook.hpp b/src/slic3r/GUI/Notebook.hpp index d333956561..4734122b18 100644 --- a/src/slic3r/GUI/Notebook.hpp +++ b/src/slic3r/GUI/Notebook.hpp @@ -3,7 +3,11 @@ //#ifdef _WIN32 +#include +#include +#include #include +#include #include class ScalableButton; @@ -23,13 +27,16 @@ public: void SetSelection(int sel); void UpdateMode(); void Rescale(); - bool InsertPage(size_t n, const wxString &text, bool bSelect = false, const std::string &bmp_name = "", const std::string &inactive_bmp_name = ""); + bool InsertPage(size_t n, const wxString &text, bool bSelect = false, const std::string &bmp_name = "", const wxBitmap &bmp = wxNullBitmap); void RemovePage(size_t n); bool SetPageImage(size_t n, const std::string& bmp_name) const; void SetPageText(size_t n, const wxString& strText); void SetCompact(size_t n, bool compact); // ORCA wxString GetPageText(size_t n) const; wxFlexGridSizer* GetBtnsSizer(){return m_buttons_sizer;}; // ORCA + // ORCA: a companion widget shown right after the tab buttons (before any side_tools), e.g. + // an overflow indicator. Pass nullptr to remove it; ownership stays with the caller. + void SetOverflowButton(wxWindow* button); private: wxFlexGridSizer* m_buttons_sizer; @@ -40,9 +47,10 @@ private: int m_btn_margin; int m_line_margin; std::vector m_pageLabels; // ORCA + wxWindow* m_overflow_button{nullptr}; // ORCA }; -class Notebook: public wxBookCtrlBase +class Notebook : public wxBookCtrlBase { public: Notebook(wxWindow * parent, @@ -103,7 +111,7 @@ public: // by this control) and show it immediately. bool ShowNewPage(wxWindow * page) { - return AddPage(page, wxString(), "", ""); + return AddPage(page, wxString(), false, NO_IMAGE); } @@ -135,51 +143,56 @@ public: // Implement base class pure virtual methods. - // adds a new page to the control - bool AddPage(wxWindow* page, + // Page management. Every insertion funnels through the InsertPage() below; `id` is the + // stable page name FindPageByName() resolves. Built-in tabs name a resource bitmap, + // plugin pages hand over a ready wxBitmap; wx's own imageId overloads carry neither. + bool AddPage(const wxString& id, + wxWindow* page, const wxString& text, - const std::string& bmp_name, - const std::string& inactive_bmp_name, + const std::string& bmp_name = "", bool bSelect = false) { DoInvalidateBestSize(); - return InsertPage(GetPageCount(), page, text, bmp_name, inactive_bmp_name, bSelect); + return InsertPage(GetPageCount(), id, page, text, bmp_name, bSelect); } - // Page management - virtual bool InsertPage(size_t n, - wxWindow * page, - const wxString & text, - bool bSelect = false, - int imageId = NO_IMAGE) override + bool AddPage(wxWindow* page, const wxString& text, bool bSelect = false, int imageId = NO_IMAGE) override { - if (!wxBookCtrlBase::InsertPage(n, page, text, bSelect, imageId)) + DoInvalidateBestSize(); + return InsertPage(GetPageCount(), page, text, bSelect, imageId); + } + + bool InsertPage(size_t n, + const wxString& id, + wxWindow * page, + const wxString & text, + const std::string& bmp_name = "", + bool bSelect = false, + const wxBitmap& bmp = wxNullBitmap) + { + if (!wxBookCtrlBase::InsertPage(n, page, text, bSelect)) return false; - GetBtnsListCtrl()->InsertPage(n, text, bSelect); + m_pageNames.insert(m_pageNames.begin() + n, id); + GetBtnsListCtrl()->InsertPage(n, text, bSelect, bmp_name, bmp); + // wxBookCtrlBase::InsertPage() only inserts into the page list and sizes the new + // page to the current page's rect — it never touches visibility, and a freshly + // constructed page defaults to shown. Without this it renders on top of whatever + // page is currently selected until the next SetSelection() call hides it. if (!DoSetSelectionAfterInsertion(n, bSelect)) page->Hide(); return true; } - bool InsertPage(size_t n, - wxWindow * page, - const wxString & text, - const std::string& bmp_name = "", - const std::string& inactive_bmp_name = "", - bool bSelect = false) + virtual bool InsertPage(size_t n, + wxWindow * page, + const wxString & text, + bool bSelect = false, + int WXUNUSED(imageId) = NO_IMAGE) override { - if (!wxBookCtrlBase::InsertPage(n, page, text, bSelect)) - return false; - - GetBtnsListCtrl()->InsertPage(n, text, bSelect, bmp_name, inactive_bmp_name); - - if (bSelect) - SetSelection(n); - - return true; + return InsertPage(n, wxString(), page, text, "", bSelect); } virtual int SetSelection(size_t n) override @@ -211,8 +224,8 @@ public: return DoSetSelection(n); } - // Neither labels nor images are supported but we still store the labels - // just in case the user code attaches some importance to them. + // Labels are stored by the custom button list; wx's image-list API is unused — tab icons + // are set directly on the buttons, either from a resource name or a ready wxBitmap. virtual bool SetPageText(size_t n, const wxString & strText) override { wxCHECK_MSG(n < GetPageCount(), false, wxS("Invalid page")); @@ -251,7 +264,64 @@ public: page->SetFocus(); } + // The base clears its page list directly instead of calling DoRemovePage() per page, + // which would leave m_pageNames behind. No caller today; kept in sync regardless. + virtual bool DeleteAllPages() override + { + m_pageNames.clear(); + return wxBookCtrlBase::DeleteAllPages(); + } + ButtonsListCtrl* GetBtnsListCtrl() const { return static_cast(m_bookctrl); } + void SetOverflowButton(wxWindow* button) { GetBtnsListCtrl()->SetOverflowButton(button); } + + // Insertion index just past the first of `ids` that is present, or the end of the bar + // if none is — lets call sites state tab order as "after X" instead of re-deriving it. + size_t PositionAfter(std::initializer_list ids) const + { + for (const char* id : ids) + if (const int idx = FindPageByName(id); idx != wxNOT_FOUND) + return static_cast(idx) + 1; + return GetPageCount(); + } + + int FindPageByName(const wxString& id) const + { + if (id.empty()) + return wxNOT_FOUND; + for (size_t i = 0; i < m_pageNames.size(); ++i) + if (m_pageNames[i] == id) + return static_cast(i); + return wxNOT_FOUND; + } + + wxWindow* GetPageByName(const wxString& id) const + { + const int idx = FindPageByName(id); + return idx == wxNOT_FOUND ? nullptr : GetPage(static_cast(idx)); + } + + bool SelectPageByName(const wxString& id) + { + const int idx = FindPageByName(id); + if (idx == wxNOT_FOUND) + return false; + SetSelection(static_cast(idx)); + return true; + } + + // Inverse of FindPageByName: index -> id. Empty string for an out-of-range + // index or a page that was never given an id (e.g. settings Tab pages). + wxString GetPageName(size_t n) const + { + return n < m_pageNames.size() ? m_pageNames[n] : wxString(); + } + + wxString GetSelectedPageName() const + { + const int sel = GetSelection(); + return sel < 0 ? wxString() : GetPageName(static_cast(sel)); + } void UpdateMode() { @@ -369,6 +439,7 @@ protected: wxWindow* const win = wxBookCtrlBase::DoRemovePage(page); if (win) { + m_pageNames.erase(m_pageNames.begin() + page); GetBtnsListCtrl()->RemovePage(page); DoSetSelectionAfterRemoval(page); } @@ -394,6 +465,8 @@ protected: private: void Init(); + std::vector m_pageNames; // index-parallel to wxBookCtrlBase::m_pages + wxShowEffect m_showEffect, m_hideEffect; diff --git a/src/slic3r/GUI/NotificationManager.cpp b/src/slic3r/GUI/NotificationManager.cpp index 8e81f0654c..5e83ad845f 100644 --- a/src/slic3r/GUI/NotificationManager.cpp +++ b/src/slic3r/GUI/NotificationManager.cpp @@ -1918,7 +1918,7 @@ void NotificationManager::push_validate_error_notification(StringObjectException wxGetApp().sidebar().jump_to_option(opt, Preset::TYPE_PRINT, L""); } else { - wxGetApp().mainframe->select_tab(MainFrame::tp3DEditor); + wxGetApp().mainframe->select_tab(TAB_ID_PREPARE); } return false; }; @@ -1985,7 +1985,7 @@ void NotificationManager::push_validate_error_notification(StringObjectException wxGetApp().sidebar().jump_to_option(opt, opt_type, L""); } else { - wxGetApp().mainframe->select_tab(MainFrame::tp3DEditor); + wxGetApp().mainframe->select_tab(TAB_ID_PREPARE); } return false; }; @@ -2015,7 +2015,7 @@ void NotificationManager::push_slicing_error_notification(const std::string &tex if (iter != objects.end()) { ovs.push_back({ *iter, nullptr }); } } if (!ovs.empty()) { - wxGetApp().mainframe->select_tab(MainFrame::tp3DEditor); + wxGetApp().mainframe->select_tab(TAB_ID_PREPARE); wxGetApp().obj_list()->select_items(ovs); } return false; @@ -2046,7 +2046,7 @@ void NotificationManager::push_slicing_warning_notification(const std::string& t auto& objects = wxGetApp().model().objects; auto iter = std::find_if(objects.begin(), objects.end(), [id](auto o) { return o->id() == id; }); if (iter != objects.end()) { - wxGetApp().mainframe->select_tab(MainFrame::tp3DEditor); + wxGetApp().mainframe->select_tab(TAB_ID_PREPARE); wxGetApp().obj_list()->select_items({ {*iter, nullptr} }); } return false; @@ -2693,7 +2693,7 @@ void NotificationManager::push_slicing_serious_warning_notification(const std::s if (iter != objects.end()) { ovs.push_back({ *iter, nullptr }); } } if (!ovs.empty()) { - wxGetApp().mainframe->select_tab(MainFrame::tp3DEditor); + wxGetApp().mainframe->select_tab(TAB_ID_PREPARE); wxGetApp().obj_list()->select_items(ovs); wxGetApp().obj_list()->update_selections_on_canvas(); } @@ -2777,7 +2777,7 @@ void NotificationManager::push_slicing_serious_warning_notification(const std::s } } - wxGetApp().mainframe->select_tab(MainFrame::tp3DEditor); + wxGetApp().mainframe->select_tab(TAB_ID_PREPARE); if (!sel_items.empty()) { obj_list->select_items(sel_items); diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index 583f7d1528..b8cc0c738f 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -5831,6 +5831,8 @@ private: bool show_warning_dialog { false }; }; +Plater::~Plater() = default; + const std::regex Plater::priv::pattern_bundle(".*[.](amf|amf[.]xml|zip[.]amf|3mf)", std::regex::icase); const std::regex Plater::priv::pattern_3mf(".*3mf", std::regex::icase); const std::regex Plater::priv::pattern_zip_amf(".*[.]zip[.]amf", std::regex::icase); @@ -5845,7 +5847,7 @@ bool PlaterDropTarget::OnDropFiles(wxCoord x, wxCoord y, const wxArrayString &fi #endif // WIN32 m_mainframe.Raise(); - m_mainframe.select_tab(size_t(MainFrame::tp3DEditor)); + m_mainframe.select_tab(TAB_ID_PREPARE); if (wxGetApp().is_editor()) m_plater.select_view_3D("3D"); @@ -6627,9 +6629,9 @@ void Plater::priv::select_next_view_3D() { if (current_panel == view3D) - wxGetApp().mainframe->select_tab(size_t(MainFrame::tpPreview)); + wxGetApp().mainframe->select_tab(TAB_ID_PREVIEW); else if (current_panel == preview) - wxGetApp().mainframe->select_tab(size_t(MainFrame::tp3DEditor)); + wxGetApp().mainframe->select_tab(TAB_ID_PREPARE); // else if (current_panel == assemble_view) // set_current_panel(view3D); } @@ -7928,7 +7930,7 @@ std::vector Plater::priv::load_files(const std::vector& input_ q->select_plate(first_plate_index); //set to 3d tab q->select_view_3D("Preview"); - wxGetApp().mainframe->select_tab(MainFrame::tpPreview); + wxGetApp().mainframe->select_tab(TAB_ID_PREVIEW); } else { //set to 3d tab @@ -7947,7 +7949,7 @@ std::vector Plater::priv::load_files(const std::vector& input_ else { //always set to 3D after loading files q->select_view_3D("3D"); - wxGetApp().mainframe->select_tab(MainFrame::tp3DEditor); + wxGetApp().mainframe->select_tab(TAB_ID_PREPARE); } if (load_model) { @@ -8855,7 +8857,7 @@ void Plater::priv::process_validation_warning(StringObjectException const &warni } } - wxGetApp().mainframe->select_tab(MainFrame::tp3DEditor); + wxGetApp().mainframe->select_tab(TAB_ID_PREPARE); if (inst_idx != -1) { auto* model = wxGetApp().obj_list()->GetModel(); @@ -8884,7 +8886,7 @@ void Plater::priv::process_validation_warning(StringObjectException const &warni } else { auto iter = id.id ? std::find_if(objects.begin(), objects.end(), [id](auto o) { return o->id() == id; }) : objects.end(); if (iter != objects.end()) { - wxGetApp().mainframe->select_tab(MainFrame::tp3DEditor); + wxGetApp().mainframe->select_tab(TAB_ID_PREPARE); wxGetApp().obj_list()->select_items({{*iter, nullptr}}); wxGetApp().obj_list()->update_selections_on_canvas(); } @@ -11268,13 +11270,19 @@ void Plater::priv::on_tab_selection_changing(wxBookCtrlEvent& e) } const int new_sel = e.GetSelection(); - sidebar_layout.show = new_sel == MainFrame::tp3DEditor || new_sel == MainFrame::tpPreview; + if (new_sel == wxNOT_FOUND) { + // GetPage(new_sel) below needs a valid index. + e.Skip(); + return; + } + const wxString new_name = main_frame->m_tabpanel->GetPageName(new_sel); + sidebar_layout.show = new_name == TAB_ID_PREPARE || new_name == TAB_ID_PREVIEW; update_sidebar(); int old_sel = e.GetOldSelection(); const bool use_printer_agents = wxGetApp().app_config->get_bool("use_printer_agents"); const bool use_native_device_tab = wxGetApp().preset_bundle && (wxGetApp().preset_bundle->use_bbl_device_tab() || use_printer_agents); - if (use_native_device_tab && new_sel == MainFrame::tpMonitor) { + if (use_native_device_tab && new_name == TAB_ID_MONITOR) { // BBL network module is only required for BBL-vendor printers. // Non-BBL Python plugins (e.g. moonraker) drive the Device tab without it. if (!use_printer_agents && wxGetApp().preset_bundle->is_bbl_vendor() && !Slic3r::NetworkAgent::is_network_module_loaded()) { @@ -11286,12 +11294,15 @@ void Plater::priv::on_tab_selection_changing(wxBookCtrlEvent& e) } } } else { + // Pointer test, not a name lookup: in printer-agents mode this page is TAB_ID_MONITOR_WEB + // while the native Device tab holds TAB_ID_MONITOR, and in legacy-web mode it holds + // TAB_ID_MONITOR itself. const bool selecting_web_device_tab = main_frame->m_printer_view && main_frame->m_tabpanel->GetPage(new_sel) == main_frame->m_printer_view; if (selecting_web_device_tab) { // Use the selected discovered machine when the preset has no host. main_frame->load_printer_url(); - } else if (new_sel == MainFrame::tpMonitor && wxGetApp().preset_bundle != nullptr) { + } else if (new_name == TAB_ID_MONITOR && wxGetApp().preset_bundle != nullptr) { auto cfg = wxGetApp().preset_bundle->printers.get_edited_preset().config; wxString url = from_u8(PrintHost::get_print_host_webui(&cfg)); if (main_frame->m_printer_view && url.empty()) { @@ -12154,7 +12165,7 @@ bool Plater::priv::check_ams_status_impl(bool is_slice_all) wxPostEvent(q, SimpleEvent(EVT_GLTOOLBAR_SLICE_ALL)); else wxPostEvent(q, SimpleEvent(EVT_GLTOOLBAR_SLICE_PLATE)); - wxGetApp().mainframe->m_tabpanel->SetSelection(MainFrame::TabPosition::tpPreview); + wxGetApp().mainframe->m_tabpanel->SelectPageByName(TAB_ID_PREVIEW); } return false; } @@ -12722,7 +12733,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(); @@ -12832,7 +12843,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; @@ -13111,7 +13122,7 @@ int Plater::new_project(bool skip_confirm, bool silent, const wxString& project_ get_notification_manager()->clear_all(); if (!silent) - wxGetApp().mainframe->select_tab(MainFrame::tp3DEditor); + wxGetApp().mainframe->select_tab(TAB_ID_PREPARE); //get_partplate_list().reinit(); //get_partplate_list().update_slice_context_to_current_plate(p->background_process); @@ -13260,7 +13271,7 @@ void Plater::load_project(wxString const& filename2, if (!m_exported_file) { p->select_view("topfront"); p->camera.requires_zoom_to_plate = REQUIRES_ZOOM_TO_ALL_PLATE; - wxGetApp().mainframe->select_tab(MainFrame::tp3DEditor); + wxGetApp().mainframe->select_tab(TAB_ID_PREPARE); } else { p->partplate_list.select_plate_view(); @@ -13374,7 +13385,7 @@ void Plater::import_model_id(wxString download_info) const int max_retries = 3; /* jump to 3D eidtor */ - wxGetApp().mainframe->select_tab((size_t)MainFrame::TabPosition::tp3DEditor); + wxGetApp().mainframe->select_tab(TAB_ID_PREPARE); /* prepare progress dialog */ bool cont = true; @@ -13683,7 +13694,7 @@ void Plater::calib_pa(const Calib_Params& params) { const auto calib_pa_name = wxString::Format(L"Pressure Advance Test"); new_project(false, false, calib_pa_name); - wxGetApp().mainframe->select_tab(size_t(MainFrame::tp3DEditor)); + wxGetApp().mainframe->select_tab(TAB_ID_PREPARE); auto print_config = &wxGetApp().preset_bundle->prints.get_edited_preset().config; auto printer_config = &wxGetApp().preset_bundle->printers.get_edited_preset().config; print_config->set_key_value("overhang_reverse", new ConfigOptionBool(false)); @@ -14164,7 +14175,7 @@ void Plater::calib_flowrate(bool is_linear, int pass, InfillPattern pattern) { if (new_project(false, false, calib_name) == wxID_CANCEL) return; - wxGetApp().mainframe->select_tab(size_t(MainFrame::tp3DEditor)); + wxGetApp().mainframe->select_tab(TAB_ID_PREPARE); if (is_linear) { if (pass == 1) @@ -14201,7 +14212,7 @@ void Plater::calib_temp(const Calib_Params& params) { const auto calib_temp_name = wxString::Format(L"Nozzle temperature test"); new_project(false, false, calib_temp_name); - wxGetApp().mainframe->select_tab(size_t(MainFrame::tp3DEditor)); + wxGetApp().mainframe->select_tab(TAB_ID_PREPARE); if (params.mode != CalibMode::Calib_Temp_Tower) return; if (!add_model(false, Slic3r::resources_dir() + "/calib/temperature_tower/temperature_tower.drc")) @@ -14281,7 +14292,7 @@ void Plater::calib_max_vol_speed(const Calib_Params& params) { const auto calib_vol_speed_name = wxString::Format(L"Max volumetric speed test"); new_project(false, false, calib_vol_speed_name); - wxGetApp().mainframe->select_tab(size_t(MainFrame::tp3DEditor)); + wxGetApp().mainframe->select_tab(TAB_ID_PREPARE); if (params.mode != CalibMode::Calib_Vol_speed_Tower) return; if (!add_model(false, Slic3r::resources_dir() + "/calib/volumetric_speed/SpeedTestStructure.drc")) @@ -14360,7 +14371,7 @@ void Plater::calib_retraction(const Calib_Params& params) { const auto calib_retraction_name = wxString::Format(L"Retraction"); new_project(false, false, calib_retraction_name); - wxGetApp().mainframe->select_tab(size_t(MainFrame::tp3DEditor)); + wxGetApp().mainframe->select_tab(TAB_ID_PREPARE); if (params.mode != CalibMode::Calib_Retraction_tower) return; @@ -14420,7 +14431,7 @@ void Plater::calib_VFA(const Calib_Params& params) { const auto calib_vfa_name = wxString::Format(L"VFA test"); new_project(false, false, calib_vfa_name); - wxGetApp().mainframe->select_tab(size_t(MainFrame::tp3DEditor)); + wxGetApp().mainframe->select_tab(TAB_ID_PREPARE); if (params.mode != CalibMode::Calib_VFA_Tower) return; @@ -14503,7 +14514,7 @@ void Plater::calib_input_shaping_freq(const Calib_Params& params) { const auto calib_input_shaping_name = wxString::Format(L"Input shaping Frequency test"); new_project(false, false, calib_input_shaping_name); - wxGetApp().mainframe->select_tab(size_t(MainFrame::tp3DEditor)); + wxGetApp().mainframe->select_tab(TAB_ID_PREPARE); if (params.mode != CalibMode::Calib_Input_shaping_freq) return; @@ -14569,7 +14580,7 @@ void Plater::calib_input_shaping_damp(const Calib_Params& params) { const auto calib_input_shaping_name = wxString::Format(L"Input shaping Damping test"); new_project(false, false, calib_input_shaping_name); - wxGetApp().mainframe->select_tab(size_t(MainFrame::tp3DEditor)); + wxGetApp().mainframe->select_tab(TAB_ID_PREPARE); if (params.mode != CalibMode::Calib_Input_shaping_damp) return; @@ -14634,7 +14645,7 @@ void Plater::Calib_Cornering(const Calib_Params& params) { const auto Calib_Cornering = wxString::Format(L"Cornering test"); new_project(false, false, Calib_Cornering); - wxGetApp().mainframe->select_tab(size_t(MainFrame::tp3DEditor)); + wxGetApp().mainframe->select_tab(TAB_ID_PREPARE); if (params.mode != CalibMode::Calib_Cornering) return; @@ -14767,7 +14778,7 @@ void Plater::load_gcode(const wxString& filename) //p->gcode_result.reset(); //reset_gcode_toolpaths(); p->preview->reload_print(m_only_gcode); - wxGetApp().mainframe->select_tab(MainFrame::tpPreview); + wxGetApp().mainframe->select_tab(TAB_ID_PREVIEW); p->set_current_panel(p->preview, true); p->get_current_canvas3D()->render(); //p->notification_manager->bbl_show_plateinfo_notification(into_u8(_L("Preview only mode for gcode file."))); @@ -15440,7 +15451,7 @@ LoadType determine_load_type(std::string filename, std::string override_setting) wxGetApp().app_config->set("import_project_action", std::to_string(choice)); // BBS: jump to plater panel - wxGetApp().mainframe->select_tab(MainFrame::tp3DEditor); + wxGetApp().mainframe->select_tab(TAB_ID_PREPARE); return load_type; } @@ -15669,7 +15680,7 @@ void Plater::reset_with_confirm() .ShowModal() == wxID_YES) { reset(); // BBS: jump to plater panel - wxGetApp().mainframe->select_tab(size_t(0)); + wxGetApp().mainframe->select_tab(TAB_ID_HOME); } } @@ -17483,7 +17494,7 @@ int Plater::export_config_3mf(int plate_idx, Export3mfProgressFn proFn) //BBS void Plater::send_calibration_job_finished(wxCommandEvent & evt) { - p->main_frame->request_select_tab(MainFrame::TabPosition::tpCalibration); + p->main_frame->request_select_tab(TAB_ID_CALIBRATION); auto calibration_panel = p->main_frame->m_calibration; if (calibration_panel) { auto curr_wizard = static_cast(calibration_panel->get_tabpanel()->GetPage(evt.GetInt())); @@ -17515,7 +17526,7 @@ void Plater::print_job_finished(wxCommandEvent &evt) if (!dev) return; dev->set_selected_machine(evt.GetString().ToStdString()); - p->main_frame->request_select_tab(MainFrame::TabPosition::tpMonitor); + p->main_frame->request_select_tab(TAB_ID_MONITOR); //jump to monitor and select device status panel MonitorPanel* curr_monitor = p->main_frame->m_monitor; if(curr_monitor) @@ -17530,7 +17541,7 @@ void Plater::send_job_finished(wxCommandEvent& evt) send_gcode_finish(evt.GetString()); p->hide_send_to_printer_dlg(); - //p->main_frame->request_select_tab(MainFrame::TabPosition::tpMonitor); + //p->main_frame->request_select_tab(TAB_ID_MONITOR); ////jump to monitor and select device status panel //MonitorPanel* curr_monitor = p->main_frame->m_monitor; //if (curr_monitor) @@ -18424,7 +18435,7 @@ void Plater::pop_warning_and_go_to_device_page(wxString printer_name, PrinterWar MessageDialog dlg(this, content, title, wxOK | wxFORWARD | wxICON_WARNING, _L("Device Page")); auto result = dlg.ShowModal(); if (result == wxFORWARD) { - wxGetApp().mainframe->select_tab(size_t(MainFrame::tpMonitor)); + wxGetApp().mainframe->select_tab(TAB_ID_MONITOR); } } diff --git a/src/slic3r/GUI/Plater.hpp b/src/slic3r/GUI/Plater.hpp index b60c0eb242..6a42f61fd5 100644 --- a/src/slic3r/GUI/Plater.hpp +++ b/src/slic3r/GUI/Plater.hpp @@ -290,7 +290,7 @@ public: Plater(const Plater &) = delete; Plater &operator=(Plater &&) = delete; Plater &operator=(const Plater &) = delete; - ~Plater() = default; + ~Plater(); bool Show(bool show = true); @@ -1020,4 +1020,4 @@ wxArrayString get_all_camera_view_type(); } // namespace GUI } // namespace Slic3r -#endif \ No newline at end of file +#endif diff --git a/src/slic3r/GUI/PluginWebDialog.cpp b/src/slic3r/GUI/PluginWebDialog.cpp index 1808f21ce9..d89aac7270 100644 --- a/src/slic3r/GUI/PluginWebDialog.cpp +++ b/src/slic3r/GUI/PluginWebDialog.cpp @@ -15,39 +15,6 @@ namespace Slic3r { namespace GUI { namespace { -// Low-specificity element defaults (no !important) for UNSTYLED plugin HTML, so a bare -// plugin page looks native while any CSS the plugin ships still wins. Built on the -// --orca-* variables the host injects (see WebViewHostDialog); document-start injected -// AFTER the host contract so the variables are defined (shares the base injector's -// WebView2 timing guard). -std::string plugin_defaults_user_script() -{ - std::string css; - css += ""; - return WebViewHostDialog::document_start_injector(css, "orca-plugin-defaults", "beforeend"); -} - // Injected into the top-level page at document start (before the plugin's own // scripts). Defines window.orca as the only host surface the page may use. It // references window.wx lazily (at call time) so it never races the backend's @@ -129,7 +96,7 @@ PluginWebDialog::PluginWebDialog(wxWindow* parent, void PluginWebDialog::add_user_scripts() { if (wxWebView* wv = browser()) { - wv->AddUserScript(wxString::FromUTF8(plugin_defaults_user_script())); + wv->AddUserScript(wxString::FromUTF8(WebViewHostDialog::plugin_defaults_user_script())); wv->AddUserScript(ORCA_BRIDGE_JS); } } diff --git a/src/slic3r/GUI/Preferences.cpp b/src/slic3r/GUI/Preferences.cpp index f802ba6ecb..ca115b9773 100644 --- a/src/slic3r/GUI/Preferences.cpp +++ b/src/slic3r/GUI/Preferences.cpp @@ -1748,11 +1748,26 @@ void PreferencesDialog::create_items() g_sizer->Add(item_pop_up_filament_map_dialog); #endif + //// GENERAL > Plugins + g_sizer->Add(create_item_title(_L("Plugins")), 1, wxEXPAND); + + auto item_plugin_pages_visible_count = create_item_spinctrl( + _L("Visible plugin pages"), + "", + _L("pages"), + _L("Number of plugin pages shown as fixed tabs before the remaining pages collapse into a dropdown on the last tab."), + SETTING_PLUGIN_PAGES_VISIBLE_COUNT, + PLUGIN_PAGES_VISIBLE_COUNT_MIN, + PLUGIN_PAGES_VISIBLE_COUNT_MAX, + [](int value) { wxGetApp().mainframe->plugin_pages().set_visible_page_count(value); } + ); + g_sizer->Add(item_plugin_pages_visible_count); + g_sizer->AddSpacer(FromDIP(10)); sizer_page->Add(g_sizer, 0, wxEXPAND); ////////////////////////// - //// CONTROL TAB + //// CONTROL TAB ///////////////////////////////////// m_pref_tabs->AppendItem(_L("Control")); f_sizers.push_back(new wxFlexGridSizer(1, 1, v_gap, 0)); diff --git a/src/slic3r/GUI/PresetComboBoxes.cpp b/src/slic3r/GUI/PresetComboBoxes.cpp index 27653790dd..2f82a5630e 100644 --- a/src/slic3r/GUI/PresetComboBoxes.cpp +++ b/src/slic3r/GUI/PresetComboBoxes.cpp @@ -1048,7 +1048,7 @@ bool PlaterPresetComboBox::switch_to_tab() //BBS Select NoteBook Tab params if (tab->GetParent() == wxGetApp().params_panel()) - wxGetApp().mainframe->select_tab(MainFrame::tp3DEditor); + wxGetApp().mainframe->select_tab(TAB_ID_PREPARE); else { wxGetApp().params_dialog()->Popup(); tab->OnActivate(); diff --git a/src/slic3r/GUI/PresetComboBoxes.hpp b/src/slic3r/GUI/PresetComboBoxes.hpp index dcd5457dfd..b63f51dd70 100644 --- a/src/slic3r/GUI/PresetComboBoxes.hpp +++ b/src/slic3r/GUI/PresetComboBoxes.hpp @@ -39,7 +39,7 @@ public: PresetComboBox(wxWindow* parent, Preset::Type preset_type, const wxSize& size = wxDefaultSize, PresetBundle* preset_bundle = nullptr); ~PresetComboBox(); - enum LabelItemType { + enum LabelItemType : std::size_t { LABEL_ITEM_PHYSICAL_PRINTER = 0xffffff01, LABEL_ITEM_PRINTER_MODELS, LABEL_ITEM_DISABLED, diff --git a/src/slic3r/GUI/Project.cpp b/src/slic3r/GUI/Project.cpp index 57410b1202..6d1eb0e180 100644 --- a/src/slic3r/GUI/Project.cpp +++ b/src/slic3r/GUI/Project.cpp @@ -74,7 +74,18 @@ ProjectPanel::ProjectPanel(wxWindow *parent, wxWindowID id, const wxPoint &pos, Fit(); } -ProjectPanel::~ProjectPanel() {} +ProjectPanel::~ProjectPanel() +{ + shutdown(); +} + +void ProjectPanel::shutdown() +{ + m_reload_cancel_token->store(true, std::memory_order_release); + if (m_reload_task && m_reload_task->joinable()) + m_reload_task->join(); + m_reload_task.reset(); +} // Helper to convert newlines to
static std::string convert_newlines_to_br(const std::string& text) { @@ -101,7 +112,17 @@ void ProjectPanel::onWebNavigating(wxWebViewEvent& evt) void ProjectPanel::on_reload(wxCommandEvent& evt) { - boost::thread reload = boost::thread([this] { + if (wxTheApp == nullptr || wxGetApp().is_closing() || + m_reload_cancel_token->load(std::memory_order_acquire)) + return; + + if (m_reload_task && m_reload_task->joinable()) + m_reload_task->join(); + + const auto cancel_token = m_reload_cancel_token; + m_reload_task = std::make_unique([this, cancel_token] { + if (cancel_token->load(std::memory_order_acquire) || wxTheApp == nullptr || wxGetApp().is_closing()) + return; std::string update_type; std::string license; std::string model_name; @@ -115,6 +136,9 @@ void ProjectPanel::on_reload(wxCommandEvent& evt) std::map> files; + if (wxGetApp().plater() == nullptr) + return; + Model model = wxGetApp().plater()->model(); auto model_info = model.model_info; @@ -156,7 +180,14 @@ void ProjectPanel::on_reload(wxCommandEvent& evt) std::string file_path = encode_path(wxGetApp().plater()->model().get_auxiliary_file_temp_path().c_str()); if (!file_path.empty()) { files = Reload(file_path); - wxGetApp().CallAfter([this, file_path, files] { m_auxiliary->Reload(file_path, files); }); + if (cancel_token->load(std::memory_order_acquire) || wxTheApp == nullptr || wxGetApp().is_closing()) + return; + + wxGetApp().CallAfter([this, cancel_token, file_path, files] { + if (cancel_token->load(std::memory_order_acquire) || wxTheApp == nullptr || wxGetApp().is_closing()) + return; + m_auxiliary->Reload(file_path, files); + }); } else { clear_model_info(); return; @@ -215,15 +246,18 @@ void ProjectPanel::on_reload(wxCommandEvent& evt) json m_Res = json::object(); m_Res["command"] = "show_3mf_info"; - m_Res["sequence_id"] = std::to_string(ProjectPanel::m_sequence_id++); + m_Res["sequence_id"] = std::to_string(ProjectPanel::m_sequence_id.fetch_add(1, std::memory_order_relaxed)); m_Res["model"] = j; wxString strJS = wxString::Format("HandleStudio(%s)", m_Res.dump(-1, ' ', false, json::error_handler_t::ignore)); - if (m_web_init_completed) { - wxGetApp().CallAfter([this, strJS] { + if (m_web_init_completed.load(std::memory_order_acquire) && + !cancel_token->load(std::memory_order_acquire) && wxTheApp != nullptr && !wxGetApp().is_closing()) { + wxGetApp().CallAfter([this, cancel_token, strJS] { + if (cancel_token->load(std::memory_order_acquire) || wxTheApp == nullptr || wxGetApp().is_closing()) + return; RunScript(strJS.ToStdString()); - }); + }); } }); } @@ -264,7 +298,7 @@ void ProjectPanel::OnScriptMessage(wxWebViewEvent& evt) } } else if (strCmd == "request_3mf_info") { - m_web_init_completed = true; + m_web_init_completed.store(true, std::memory_order_release); } else if (strCmd == "edit_project_info") { show_info_editor(true); @@ -307,13 +341,20 @@ void ProjectPanel::update_model_data() void ProjectPanel::clear_model_info() { + if (wxTheApp == nullptr || wxGetApp().is_closing() || + m_reload_cancel_token->load(std::memory_order_acquire)) + return; + json m_Res = json::object(); m_Res["command"] = "clear_3mf_info"; - m_Res["sequence_id"] = std::to_string(ProjectPanel::m_sequence_id++); + m_Res["sequence_id"] = std::to_string(ProjectPanel::m_sequence_id.fetch_add(1, std::memory_order_relaxed)); wxString strJS = wxString::Format("HandleStudio(%s)", m_Res.dump(-1, ' ', false, json::error_handler_t::ignore)); - wxGetApp().CallAfter([this, strJS] { + const auto cancel_token = m_reload_cancel_token; + wxGetApp().CallAfter([this, cancel_token, strJS] { + if (cancel_token->load(std::memory_order_acquire) || wxTheApp == nullptr || wxGetApp().is_closing()) + return; RunScript(strJS.ToStdString()); }); } diff --git a/src/slic3r/GUI/Project.hpp b/src/slic3r/GUI/Project.hpp index 0071685e7d..a41f76ba7e 100644 --- a/src/slic3r/GUI/Project.hpp +++ b/src/slic3r/GUI/Project.hpp @@ -26,9 +26,11 @@ #include "nlohmann/json.hpp" #include "slic3r/Utils/json_diff.hpp" +#include #include #include #include +#include #include "Event.hpp" #include "libslic3r/ProjectTask.hpp" #include "wxExtensions.hpp" @@ -60,14 +62,17 @@ struct project_file{ class ProjectPanel : public wxPanel { private: - bool m_web_init_completed = {false}; + std::atomic m_web_init_completed{false}; bool m_reload_already = {false}; + std::shared_ptr> m_reload_cancel_token{std::make_shared>(false)}; + std::unique_ptr m_reload_task; + wxWebView* m_browser = {nullptr}; AuxiliaryPanel* m_auxiliary{nullptr}; wxString m_project_home_url; wxString m_root_dir; - static inline int m_sequence_id = 8000; + static inline std::atomic m_sequence_id{8000}; void show_info_editor(bool show); @@ -75,6 +80,7 @@ private: public: ProjectPanel(wxWindow *parent, wxWindowID id = wxID_ANY, const wxPoint &pos = wxDefaultPosition, const wxSize &size = wxDefaultSize, long style = wxTAB_TRAVERSAL); ~ProjectPanel(); + void shutdown(); void onWebNavigating(wxWebViewEvent& evt); diff --git a/src/slic3r/GUI/SelectMachine.cpp b/src/slic3r/GUI/SelectMachine.cpp index 0159e10350..51b70d1f7e 100644 --- a/src/slic3r/GUI/SelectMachine.cpp +++ b/src/slic3r/GUI/SelectMachine.cpp @@ -1088,8 +1088,8 @@ void SelectMachineDialog::sync_ams_mapping_result(std::vector &res } } relayout_nozzle_cards(); - auto tab_index = (MainFrame::TabPosition) dynamic_cast(wxGetApp().tab_panel())->GetSelection(); - if (tab_index == MainFrame::TabPosition::tp3DEditor || tab_index == MainFrame::TabPosition::tpPreview) { + wxString tab_name = wxGetApp().tab_panel()->GetSelectedPageName(); + if (tab_name == TAB_ID_PREPARE || tab_name == TAB_ID_PREVIEW) { updata_thumbnail_data_after_connected_printer(); } } diff --git a/src/slic3r/GUI/SyncAmsInfoDialog.cpp b/src/slic3r/GUI/SyncAmsInfoDialog.cpp index 43922e3fac..bee55d53f0 100644 --- a/src/slic3r/GUI/SyncAmsInfoDialog.cpp +++ b/src/slic3r/GUI/SyncAmsInfoDialog.cpp @@ -1218,8 +1218,8 @@ void SyncAmsInfoDialog::sync_ams_mapping_result(std::vector &resul iter++; } } - auto tab_index = (MainFrame::TabPosition) dynamic_cast(wxGetApp().tab_panel())->GetSelection(); - if (tab_index == MainFrame::TabPosition::tp3DEditor || tab_index == MainFrame::TabPosition::tpPreview) { + wxString tab_name = wxGetApp().tab_panel()->GetSelectedPageName(); + if (tab_name == TAB_ID_PREPARE || tab_name == TAB_ID_PREVIEW) { updata_thumbnail_data_after_connected_printer(); } } diff --git a/src/slic3r/GUI/Tab.cpp b/src/slic3r/GUI/Tab.cpp index 1a31355d0e..bec3bed20b 100644 --- a/src/slic3r/GUI/Tab.cpp +++ b/src/slic3r/GUI/Tab.cpp @@ -2790,7 +2790,7 @@ void TabPrint::build() optgroup->append_single_option_line("fill_multiline", "strength_settings_infill#fill-multiline"); optgroup->append_single_option_line("sparse_infill_pattern", "strength_settings_infill#sparse-infill-pattern"); optgroup->append_single_option_line("gyroid_optimized", "strength_settings_patterns#gyroid-optimized"); - optgroup->append_single_option_line("sparse_infill_smooth_factor", "strength_settings_patterns#sparse-infill-smooth-factor"); + optgroup->append_single_option_line("sparse_infill_smooth_factor", "strength_settings_infill#sparse-infill-smooth-factor"); optgroup->append_single_option_line("infill_direction", "strength_settings_infill#direction"); optgroup->append_single_option_line("sparse_infill_rotate_template", "strength_settings_infill_rotation_template_metalanguage"); optgroup->append_single_option_line("skin_infill_density", "strength_settings_patterns#locked-zag"); @@ -6458,7 +6458,7 @@ void Tab::load_current_preset() std::string bmp_name = tab->type() == Slic3r::Preset::TYPE_FILAMENT ? "spool" : tab->type() == Slic3r::Preset::TYPE_SLA_MATERIAL ? "" : "cog"; tab->Hide(); // #ys_WORKAROUND : Hide tab before inserting to avoid unwanted rendering of the tab - dynamic_cast(wxGetApp().tab_panel())->InsertPage(wxGetApp().tab_panel()->FindPage(this), tab, tab->title(), bmp_name); + dynamic_cast(wxGetApp().tab_panel())->InsertPage(wxGetApp().tab_panel()->FindPage(this), wxString(), tab, tab->title(), bmp_name); } else #endif @@ -8537,8 +8537,12 @@ void Page::activate(ConfigOptionMode mode, std::function throw_if_cancel #ifdef __WXMSW__ // BBS: fix field control position - wxTheApp->CallAfter([this]() { - for (auto group : m_optgroups) { + wxTheApp->CallAfter([wp = std::weak_ptr(shared_from_this())]() { + auto page = wp.lock(); + if (!page) + return; + + for (auto group : page->m_optgroups) { if (group->custom_ctrl) group->custom_ctrl->fixup_items_positions(); } diff --git a/src/slic3r/GUI/Tabbook.hpp b/src/slic3r/GUI/Tabbook.hpp index 0cea1b8326..7f10e9dd8d 100644 --- a/src/slic3r/GUI/Tabbook.hpp +++ b/src/slic3r/GUI/Tabbook.hpp @@ -108,7 +108,7 @@ public: // by this control) and show it immediately. bool ShowNewPage(wxWindow * page) { - return AddPage(page, wxString(), ""/*true *//* select it */); + return AddPage(page, wxString()); } // Set effect to use for showing/hiding pages. @@ -139,14 +139,13 @@ public: // Implement base class pure virtual methods. - // adds a new page to the control bool AddPage(wxWindow* page, const wxString& text, - const std::string& bmp_name, - bool bSelect = false) + bool bSelect = false, + int imageId = NO_IMAGE) override { DoInvalidateBestSize(); - return InsertNewPage(GetPageCount(), page, text, bmp_name, bSelect); + return InsertPage(GetPageCount(), page, text, bSelect, imageId); } //// Page management @@ -167,23 +166,6 @@ public: return true; } - bool InsertNewPage(size_t n, - wxWindow * page, - const wxString & text, - const std::string& bmp_name = "", - bool bSelect = false) - { - if (!wxBookCtrlBase::InsertPage(n, page, text, bSelect)) - return false; - - GetBtnsListCtrl()->InsertPage(n, text, bSelect, bmp_name); - - if (bSelect) - SetSelection(n); - - return true; - } - bool RemovePage(size_t n) { if (!wxBookCtrlBase::RemovePage(n)) diff --git a/src/slic3r/GUI/Widgets/Button.cpp b/src/slic3r/GUI/Widgets/Button.cpp index e236c84e67..74ed2cbadd 100644 --- a/src/slic3r/GUI/Widgets/Button.cpp +++ b/src/slic3r/GUI/Widgets/Button.cpp @@ -95,14 +95,11 @@ void Button::SetIcon(const wxString& icon) } } -void Button::SetInactiveIcon(const wxString &icon) +void Button::SetIcon(const wxBitmap& icon) { - if (!icon.IsEmpty()) { - // BBS set button icon default size to 20 - this->inactive_icon = ScalableBitmap(this, icon.ToStdString(), this->active_icon.px_cnt()); - } else { - this->inactive_icon = ScalableBitmap(); - } + this->active_icon = ScalableBitmap(); + this->active_icon.bmp() = icon; + messureSize(); Refresh(); } @@ -257,12 +254,10 @@ void Button::SetStyle(const ButtonStyle style, const ButtonType type) void Button::Rescale() { - if (this->active_icon.bmp().IsOk()) + // Only a named icon can be re-rasterized; one set from a wxBitmap has no source file, + if (!this->active_icon.name().empty()) this->active_icon.msw_rescale(); - if (this->inactive_icon.bmp().IsOk()) - this->inactive_icon.msw_rescale(); - messureSize(); if(m_has_style) @@ -293,11 +288,7 @@ void Button::render(wxDC& dc) wxSize szIcon; wxSize textSize = this->textSize.GetSize(); - ScalableBitmap icon; - if (m_selected || ((states & (int)StateColor::State::Hovered) != 0)) - icon = active_icon; - else - icon = inactive_icon; + const ScalableBitmap& icon = active_icon; wxSize padding = this->paddingSize; int spacing = 5; // Wrap text diff --git a/src/slic3r/GUI/Widgets/Button.hpp b/src/slic3r/GUI/Widgets/Button.hpp index 94b245a75b..c98d583c34 100644 --- a/src/slic3r/GUI/Widgets/Button.hpp +++ b/src/slic3r/GUI/Widgets/Button.hpp @@ -34,7 +34,6 @@ class Button : public StaticBox wxSize minSize; // set by outer wxSize paddingSize; ScalableBitmap active_icon; - ScalableBitmap inactive_icon; StateColor text_color; @@ -61,8 +60,7 @@ public: bool SetFont(const wxFont& font) override; void SetIcon(const wxString& icon); - - void SetInactiveIcon(const wxString& icon); + void SetIcon(const wxBitmap& icon); void SetMinSize(const wxSize& size) override; void SetMaxSize(const wxSize& size) override; diff --git a/src/slic3r/GUI/Widgets/WebViewHostDialog.cpp b/src/slic3r/GUI/Widgets/WebViewHostDialog.cpp index 5e6026d1cf..044fe33cde 100644 --- a/src/slic3r/GUI/Widgets/WebViewHostDialog.cpp +++ b/src/slic3r/GUI/Widgets/WebViewHostDialog.cpp @@ -57,18 +57,6 @@ std::string host_theme_vars_css() return s; } -// Document-start user script: injects the contract "; - return WebViewHostDialog::document_start_injector( - style, "orca-host-theme-vars", "afterbegin", - "window.__orcaHostThemed=true;var theme=\"" + host_theme_name() + "\";", - "if(document.documentElement)document.documentElement.setAttribute('data-orca-theme',theme);"); -} - // JS to re-theme an already-loaded document live (no reload): replace the injected // style's contents and update data-orca-theme. Everything downstream (theme.css // tokens, plugin element defaults, page layout) re-cascades from these values. @@ -87,6 +75,46 @@ if(document.documentElement) } // namespace +// Document-start user script: injects the contract "; + return document_start_injector( + style, "orca-host-theme-vars", "afterbegin", + "window.__orcaHostThemed=true;var theme=\"" + host_theme_name() + "\";", + "if(document.documentElement)document.documentElement.setAttribute('data-orca-theme',theme);"); +} + +std::string WebViewHostDialog::plugin_defaults_user_script() +{ + std::string css; + css += ""; + return document_start_injector(css, "orca-plugin-defaults", "beforeend"); +} + std::string WebViewHostDialog::document_start_injector(const std::string& markup, const char* dom_id, const char* position, @@ -244,7 +272,7 @@ void WebViewHostDialog::register_theme_user_scripts() // script message handler is registered separately (AddScriptMessageHandler), but on // some backends RemoveAllUserScripts() drops it too, which would break // window.wx.postMessage / HandleStudio. Live re-theme goes through apply_theme_live(). - m_browser->AddUserScript(wxString::FromUTF8(host_theme_user_script())); + m_browser->AddUserScript(wxString::FromUTF8(theme_user_script())); add_user_scripts(); } diff --git a/src/slic3r/GUI/Widgets/WebViewHostDialog.hpp b/src/slic3r/GUI/Widgets/WebViewHostDialog.hpp index ed21f15f94..119e3b5955 100644 --- a/src/slic3r/GUI/Widgets/WebViewHostDialog.hpp +++ b/src/slic3r/GUI/Widgets/WebViewHostDialog.hpp @@ -49,6 +49,10 @@ public: const std::string& prelude = {}, const std::string& on_inject = {}); + // Shared by modeless Pages tabs and PluginWebDialog. + static std::string theme_user_script(); + static std::string plugin_defaults_user_script(); + protected: wxWebView* browser() const { return m_browser; } diff --git a/src/slic3r/Utils/PrintHost.cpp b/src/slic3r/Utils/PrintHost.cpp index 7f69d5e087..cc15805256 100644 --- a/src/slic3r/Utils/PrintHost.cpp +++ b/src/slic3r/Utils/PrintHost.cpp @@ -368,7 +368,7 @@ void PrintHostJobQueue::priv::perform_job(PrintHostJob the_job) emit_progress(100); if (the_job.switch_to_device_tab) { const auto mainframe = GUI::wxGetApp().mainframe; - mainframe->request_select_tab(MainFrame::TabPosition::tpMonitor); + mainframe->request_select_tab(TAB_ID_MONITOR); } } } diff --git a/src/slic3r/Utils/SimplyPrint.cpp b/src/slic3r/Utils/SimplyPrint.cpp index c1e5235d98..bbfd5209c9 100644 --- a/src/slic3r/Utils/SimplyPrint.cpp +++ b/src/slic3r/Utils/SimplyPrint.cpp @@ -325,7 +325,7 @@ bool SimplyPrint::do_temp_upload(const boost::filesystem::path& file_path, wxLaunchDefaultBrowser(url); } else { const auto mainframe = GUI::wxGetApp().mainframe; - mainframe->request_select_tab(MainFrame::TabPosition::tpMonitor); + mainframe->request_select_tab(TAB_ID_MONITOR); mainframe->load_printer_url(url); } diff --git a/src/slic3r/plugin/PythonPluginBridge.cpp b/src/slic3r/plugin/PythonPluginBridge.cpp index 328ebbace1..40f317c016 100644 --- a/src/slic3r/plugin/PythonPluginBridge.cpp +++ b/src/slic3r/plugin/PythonPluginBridge.cpp @@ -19,6 +19,7 @@ #include "PyPluginPackage.hpp" #include "PyPluginTrampoline.hpp" #include "pluginTypes/printerAgent/PrinterAgentPluginCapability.hpp" +#include "pluginTypes/pages/PagesPluginCapability.hpp" #include "pluginTypes/script/ScriptPluginCapability.hpp" #include "pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp" @@ -319,17 +320,17 @@ void bind_python_api(pybind11::module_& m) { m.doc() = "OrcaSlicer plugin API"; - auto pluginTypes = py::enum_(m, "PluginType", "Available plugin capability groups") - .value("PrinterConnection", PluginCapabilityType::PrinterConnection) - .value("Automation", PluginCapabilityType::Automation) - .value("Analysis", PluginCapabilityType::Analysis) - .value("Importer", PluginCapabilityType::Importer) - .value("Exporter", PluginCapabilityType::Exporter) - .value("Visualization", PluginCapabilityType::Visualization) - .value("Script", PluginCapabilityType::Script) - .value("SlicingPipeline", PluginCapabilityType::SlicingPipeline) - .value("Unknown", PluginCapabilityType::Unknown) - .export_values(); + py::enum_(m, "PluginType", "Available plugin capability groups") + .value("PrinterConnection", PluginCapabilityType::PrinterConnection) + .value("Pages", PluginCapabilityType::Pages) + .value("Analysis", PluginCapabilityType::Analysis) + .value("Importer", PluginCapabilityType::Importer) + .value("Exporter", PluginCapabilityType::Exporter) + .value("Visualization", PluginCapabilityType::Visualization) + .value("Script", PluginCapabilityType::Script) + .value("SlicingPipeline", PluginCapabilityType::SlicingPipeline) + .value("Unknown", PluginCapabilityType::Unknown) + .export_values(); py::enum_(m, "PluginResult", "Execution summary code") .value("Success", PluginResult::Success) @@ -419,9 +420,10 @@ void bind_python_api(pybind11::module_& m) BOOST_LOG_TRIVIAL(debug) << "Registering embedded Python plugin type bindings"; // Make sure you register your bindings here - PrinterAgentPluginCapability::RegisterBindings(m, pluginTypes); - ScriptPluginCapability::RegisterBindings(m, pluginTypes); - SlicingPipelinePluginCapability::RegisterBindings(m, pluginTypes); + PrinterAgentPluginCapability::RegisterBindings(m); + PagesPluginCapability::RegisterBindings(m); + ScriptPluginCapability::RegisterBindings(m); + SlicingPipelinePluginCapability::RegisterBindings(m); PluginHost::RegisterBindings(m); BOOST_LOG_TRIVIAL(debug) << "Registered ScriptPluginCapability Python bindings"; diff --git a/src/slic3r/plugin/PythonPluginInterface.hpp b/src/slic3r/plugin/PythonPluginInterface.hpp index 4a6df06441..8b7518cf1c 100644 --- a/src/slic3r/plugin/PythonPluginInterface.hpp +++ b/src/slic3r/plugin/PythonPluginInterface.hpp @@ -12,7 +12,7 @@ namespace Slic3r { -enum class PluginCapabilityType { PrinterConnection = 0, Automation, Analysis, Importer, Exporter, Visualization, Script, SlicingPipeline, Unknown }; +enum class PluginCapabilityType { PrinterConnection = 0, Pages, Analysis, Importer, Exporter, Visualization, Script, SlicingPipeline, Unknown }; struct PluginCapabilityId { @@ -39,7 +39,7 @@ inline std::string plugin_capability_type_to_string(PluginCapabilityType type) { switch (type) { case PluginCapabilityType::PrinterConnection: return "printer-connection"; - case PluginCapabilityType::Automation: return "automation"; + case PluginCapabilityType::Pages: return "pages"; case PluginCapabilityType::Analysis: return "analysis"; case PluginCapabilityType::Importer: return "importer"; case PluginCapabilityType::Exporter: return "exporter"; @@ -54,7 +54,7 @@ inline std::string plugin_capability_type_display_name(PluginCapabilityType type { switch (type) { case PluginCapabilityType::PrinterConnection: return "Printer connection"; - case PluginCapabilityType::Automation: return "Automation"; + case PluginCapabilityType::Pages: return "Pages"; case PluginCapabilityType::Analysis: return "Analysis"; case PluginCapabilityType::Importer: return "Importer"; case PluginCapabilityType::Exporter: return "Exporter"; @@ -76,8 +76,8 @@ inline PluginCapabilityType plugin_capability_type_from_string(std::string_view if (lowered == "printer-connection") return PluginCapabilityType::PrinterConnection; - if (lowered == "automation") - return PluginCapabilityType::Automation; + if (lowered == "pages") + return PluginCapabilityType::Pages; if (lowered == "analysis") return PluginCapabilityType::Analysis; if (lowered == "importer") diff --git a/src/slic3r/plugin/host/PluginPages.cpp b/src/slic3r/plugin/host/PluginPages.cpp new file mode 100644 index 0000000000..fed16ee416 --- /dev/null +++ b/src/slic3r/plugin/host/PluginPages.cpp @@ -0,0 +1,469 @@ +#include "PluginPages.hpp" + +#include "libslic3r/AppConfig.hpp" +#include "slic3r/GUI/GUI.hpp" +#include "slic3r/GUI/Notebook.hpp" +#include "slic3r/GUI/GUI_App.hpp" +#include "slic3r/GUI/Widgets/Button.hpp" +#include "slic3r/GUI/Widgets/WebView.hpp" +#include "slic3r/GUI/Widgets/WebViewHostDialog.hpp" +#include "slic3r/GUI/wxExtensions.hpp" +#include "slic3r/plugin/PluginManager.hpp" + +#include + +#include + +#include +#include +#include + +#include +#include +#include +#include + +#include + +namespace Slic3r { +namespace { + +constexpr char PLUGIN_PAGE_BRIDGE_JS[] = R"JS( +(function () { + if (window.top !== window.self) return; + if (window.orca) return; + var handlers = []; + function deliver(payload, attempts) { + try { + if (window.wx && typeof window.wx.postMessage === 'function') { + window.wx.postMessage(payload); + return; + } + } catch (e) { /* retry while the native handler is being registered */ } + if (attempts < 100) + window.setTimeout(function () { deliver(payload, attempts + 1); }, 25); + } + function send(data) { + deliver(JSON.stringify({ + channel: 'orca', kind: 'message', data: (data === undefined ? null : data) + }), 0); + } + window.orca = { + postMessage: function (data) { send(data); }, + onMessage: function (callback) { + if (typeof callback === 'function') handlers.push(callback); + } + }; + window.__orcaDispatch = function (payload) { + var data = payload ? payload.data : null; + for (var i = 0; i < handlers.length; i++) { + try { handlers[i](data); } catch (e) {} + } + }; +})(); +)JS"; + +} // namespace + +PluginPage::PluginPage(wxWindow* parent, std::shared_ptr capability) + : wxPanel(parent, wxID_ANY, wxDefaultPosition, wxDefaultSize) + , m_cap(std::move(capability)) + , m_lifetime(std::make_shared>(this)) +{ + auto* topsizer = new wxBoxSizer(wxVERTICAL); + SetSizer(topsizer); + + m_browser = WebView::CreateWebView(this, bootstrap_url()); + if (m_browser == nullptr) { + wxLogError("Could not initialize plugin page web view"); + return; + } + + topsizer->Add(m_browser, wxSizerFlags().Expand().Proportion(1)); + m_browser->Bind(wxEVT_WEBVIEW_LOADED, &PluginPage::on_bootstrap_event, this); + m_browser->Bind(wxEVT_WEBVIEW_ERROR, &PluginPage::on_bootstrap_event, this); + m_browser->Bind(wxEVT_WEBVIEW_NEWWINDOW, &PluginPage::on_new_window, this); + m_browser->Bind(wxEVT_WEBVIEW_SCRIPT_MESSAGE_RECEIVED, &PluginPage::on_script_message, this); + m_browser->AddUserScript(wxString::FromUTF8(GUI::WebViewHostDialog::theme_user_script())); + m_browser->AddUserScript(wxString::FromUTF8(GUI::WebViewHostDialog::plugin_defaults_user_script())); + m_browser->AddUserScript(PLUGIN_PAGE_BRIDGE_JS); + + const std::shared_ptr> lifetime = m_lifetime; + m_cap->set_message_sender([lifetime](const std::string& message) { + if (wxTheApp == nullptr) + return; + + GUI::wxGetApp().CallAfter([lifetime, message] { + if (PluginPage* page = lifetime->load(std::memory_order_acquire)) + page->push_message(message); + }); + }); + +} + +PluginPage::~PluginPage() +{ + detach_capability(); + if (m_lifetime) + m_lifetime->store(nullptr, std::memory_order_release); +} + +void PluginPage::detach_capability() +{ + if (m_lifetime) + m_lifetime->store(nullptr, std::memory_order_release); + if (m_cap) + m_cap->clear_message_sender(); + m_cap.reset(); +} + +wxString PluginPage::web_base_url() const +{ + const auto path = (boost::filesystem::path(resources_dir()) / "web").make_preferred().string(); + return wxString("file://") + GUI::from_u8(path) + "/"; +} + +wxString PluginPage::bootstrap_url() const +{ + const auto path = (boost::filesystem::path(resources_dir()) / "web/dialog/PluginWebDialog/blank.html").make_preferred().string(); + return wxString("file://") + GUI::from_u8(path); +} + +void PluginPage::on_bootstrap_event(wxWebViewEvent& event) +{ + load_plugin_content(); + event.Skip(); +} + +void PluginPage::load_plugin_content() +{ + if (m_content_loaded || m_browser == nullptr || m_cap == nullptr) + return; + + m_content_loaded = true; + try { + m_browser->SetPage(wxString::FromUTF8(m_cap->get_ui()), web_base_url()); + } catch (const std::exception& error) { + BOOST_LOG_TRIVIAL(error) << "Failed to load plugin page '" << m_cap->name() << "': " << error.what(); + detach_capability(); + } catch (...) { + BOOST_LOG_TRIVIAL(error) << "Failed to load plugin page '" << m_cap->name() << "'"; + detach_capability(); + } +} + +void PluginPage::on_new_window(wxWebViewEvent& event) +{ + const wxString url = event.GetURL(); + if (!url.empty() && m_browser != nullptr) + m_browser->LoadURL(url); + event.Veto(); +} + +void PluginPage::on_script_message(wxWebViewEvent& event) +{ + if (!m_cap) + return; + + const wxString payload = event.GetString(); + nlohmann::json root = nlohmann::json::parse(payload.utf8_string(), nullptr, false); + if (root.is_discarded() || root.value("channel", std::string()) != "orca" || + root.value("kind", std::string()) != "message") + return; + + const auto data = root.find("data"); + try { + m_cap->on_message(data == root.end() + ? "null" + : data->dump(-1, ' ', false, nlohmann::json::error_handler_t::replace)); + } catch (const std::exception& error) { + BOOST_LOG_TRIVIAL(error) << "Plugin page message handler failed for '" << m_cap->name() << "': " << error.what(); + } catch (...) { + BOOST_LOG_TRIVIAL(error) << "Plugin page message handler failed for '" << m_cap->name() << "'"; + } +} + +void PluginPage::push_message(const std::string& message) +{ + if (m_browser == nullptr) + return; + + // PagesPluginCapability::post_message() already dumps JSON, so accept it as-is; only a + // non-JSON payload needs wrapping as a string literal. + const std::string payload = nlohmann::json::accept(message) + ? message + : nlohmann::json(message).dump(-1, ' ', false, nlohmann::json::error_handler_t::replace); + + WebView::RunScript(m_browser, wxString::Format( + "(function dispatch(payload, attempts) {\n" + " if (typeof window.__orcaDispatch === 'function') { window.__orcaDispatch(payload); return; }\n" + " if (attempts < 100) window.setTimeout(function() { dispatch(payload, attempts + 1); }, 25);\n" + "})({data: %s}, 0);", + wxString::FromUTF8(payload))); +} + +PluginPages::~PluginPages() +{ + shutdown(); +} + +void PluginPages::initialize(Notebook* parent) +{ + shutdown(); + m_parent = parent; + if (m_parent == nullptr) + return; + + m_visible_page_count = GUI::wxGetApp().app_config->get_plugin_pages_visible_count(); + + for (const auto& capability : PluginManager::instance().get_plugin_capabilities("", PluginCapabilityType::Pages)) { + if (capability) + create_page(capability->identity()); + } + relayout(); +} + +void PluginPages::shutdown() +{ + while (!m_pages.empty()) + remove_page(m_pages.begin()->first); + m_parent = nullptr; +} + +void PluginPages::set_visible_page_count(int count) +{ + const int clamped = std::clamp(count, PLUGIN_PAGES_VISIBLE_COUNT_MIN, PLUGIN_PAGES_VISIBLE_COUNT_MAX); + if (clamped == m_visible_page_count) + return; + + m_visible_page_count = clamped; + relayout(); +} + +std::shared_ptr PluginPages::get_pages_cap(const PluginCapabilityId& id, bool is_enabled) const +{ + auto capability = PluginManager::instance().get_plugin_capability(id, /*only_enabled=*/false); + if (!capability || capability->is_enabled() != is_enabled || capability->type() != PluginCapabilityType::Pages) + return nullptr; + + return std::dynamic_pointer_cast(capability); +} + +bool PluginPages::create_page(const PluginCapabilityId& id) +{ + if (m_pages.find(id) != m_pages.end()) + return false; + + auto capability = get_pages_cap(id, true); + if (!capability) + return false; + + std::string icon; + try { + icon = capability->get_icon(); + } catch (const std::exception& error) { + BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << " Failed to get icon for plugin " << id.plugin_key << ": " << error.what(); + } catch (...) { + BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << " Failed to get icon for plugin " << id.plugin_key; + } + + auto* page = new PluginPage(m_parent, std::move(capability)); + if (!page->is_valid()) { + page->Destroy(); + return false; + } + + if (!icon.empty()) { + try { + boost::filesystem::path icon_path(icon); + const std::string extension = icon_path.extension().string(); + if (extension == ".svg" || extension == ".png") + icon_path.replace_extension(); + + page->set_icon(create_scaled_bitmap(icon_path.string(), m_parent, 20)); + } catch (const std::exception& error) { + BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << " Failed to load icon for plugin " << id.plugin_key << ": " << error.what(); + } catch (...) { + BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << " Failed to load icon for plugin " << id.plugin_key; + } + } + + m_pages.emplace(id, page); + m_order.push_back(id); + return true; +} + +void PluginPages::on_cap_register(const PluginCapabilityId& id) +{ + if (m_parent == nullptr) + return; + + if (create_page(id)) + relayout(); +} + +void PluginPages::on_cap_deregister(const PluginCapabilityId& id) +{ + remove_page(id); +} + +void PluginPages::on_plugin_register(const std::string& plugin_key) +{ + for (const auto& capability : PluginManager::instance().get_plugin_capabilities(plugin_key, PluginCapabilityType::Pages)) { + if (capability) + on_cap_register(capability->identity()); + } +} + +void PluginPages::on_plugin_deregister(const std::string& plugin_key) +{ + for (auto it = m_pages.begin(); it != m_pages.end();) { + if (it->first.plugin_key != plugin_key) { + ++it; + continue; + } + + const PluginCapabilityId id = it->first; + ++it; + remove_page(id); + } +} + +void PluginPages::remove_page(const PluginCapabilityId& id) +{ + auto it = m_pages.find(id); + if (it == m_pages.end()) + return; + + PluginPage* page = it->second; + page->detach_capability(); + + m_pages.erase(it); + m_order.erase(std::remove(m_order.begin(), m_order.end(), id), m_order.end()); + + const int idx = m_parent != nullptr ? m_parent->FindPage(page) : wxNOT_FOUND; + if (idx != wxNOT_FOUND) + m_parent->RemovePage(idx); + + relayout(); + page->Destroy(); +} + +wxString PluginPages::page_tab_id(const PluginCapabilityId& id) +{ + return wxString::FromUTF8("plugin." + id.plugin_key + "." + id.name); +} + +void PluginPages::relayout() +{ + if (m_parent == nullptr) + return; + + m_order.erase(std::remove_if(m_order.begin(), m_order.end(), + [this](const PluginCapabilityId& id) { + const bool orphaned = m_pages.find(id) == m_pages.end(); + if (orphaned) + BOOST_LOG_TRIVIAL(error) << "PluginPages::relayout: '" << id.name << "' was in m_order but not m_pages, dropping"; + return orphaned; + }), + m_order.end()); + + const int visible_slots = std::max(1, m_visible_page_count); + const bool need_overflow = static_cast(m_order.size()) > visible_slots; + + // Every visible slot is a normal, individual tab hosting its own page. When there's + // overflow, the last slot's page is swappable via m_overflow_button/show_overflow_menu() + // rather than being a fixed page — m_swapped_in_id tracks which one currently sits there. + std::vector tab_ids; + if (!need_overflow) { + tab_ids = m_order; + m_swapped_in_id.reset(); + } else { + const auto overflow_begin = m_order.begin() + (visible_slots - 1); + tab_ids.assign(m_order.begin(), overflow_begin); + + if (!m_swapped_in_id || std::find(overflow_begin, m_order.end(), *m_swapped_in_id) == m_order.end()) + m_swapped_in_id = *overflow_begin; + tab_ids.push_back(*m_swapped_in_id); + } + + // MainFrame::show_device() relayouts on every printer change and most of those change + // nothing, so only touch the notebook when the trailing slots don't already spell out + // tab_ids — a rebuild destroys and recreates every tab button and rasterizes every icon. + const size_t page_count = m_parent->GetPageCount(); + bool up_to_date = page_count >= tab_ids.size(); + for (size_t i = 0; up_to_date && i < tab_ids.size(); ++i) + up_to_date = m_parent->GetPageName(page_count - tab_ids.size() + i) == page_tab_id(tab_ids[i]); + for (const auto& [id, page] : m_pages) { + if (!up_to_date) + break; + const bool wanted = std::find(tab_ids.begin(), tab_ids.end(), id) != tab_ids.end(); + up_to_date = (m_parent->FindPage(page) != wxNOT_FOUND) == wanted; + } + + if (!up_to_date) { + const wxString id_to_reselect = m_parent->GetSelectedPageName(); + + for (const auto& [id, page] : m_pages) { + const int idx = m_parent->FindPage(page); + if (idx != wxNOT_FOUND) + m_parent->RemovePage(idx); + } + + for (const auto& id : tab_ids) { + PluginPage* page = m_pages.at(id); + m_parent->InsertPage(m_parent->GetPageCount(), page_tab_id(id), page, wxString::FromUTF8(id.name), "", + false, page->icon()); + } + + if (!id_to_reselect.empty()) + m_parent->SelectPageByName(id_to_reselect); + } + + if (need_overflow) { + if (m_overflow_button == nullptr) { + auto* btn = new Button(m_parent->GetBtnsListCtrl(), wxString(L"\u25BE"), wxString(), wxNO_BORDER); + btn->SetCornerRadius(0); + const int em = em_unit(m_parent); + btn->SetMinSize({40 * em / 10, 36 * em / 10}); + btn->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { show_overflow_menu(); }); + GUI::wxGetApp().UpdateDarkUI(btn); + m_overflow_button = btn; + } + m_parent->SetOverflowButton(m_overflow_button); + } else if (m_overflow_button != nullptr) { + m_parent->SetOverflowButton(nullptr); + m_overflow_button->Destroy(); + m_overflow_button = nullptr; + } +} + +void PluginPages::show_overflow_menu() +{ + const int visible_slots = std::max(1, m_visible_page_count); + if (m_overflow_button == nullptr || static_cast(m_order.size()) <= visible_slots) + return; + + const std::vector overflow_ids(m_order.begin() + (visible_slots - 1), m_order.end()); + + wxMenu menu; + for (size_t i = 0; i < overflow_ids.size(); ++i) + menu.AppendRadioItem(static_cast(wxID_HIGHEST + 1 + i), wxString::FromUTF8(overflow_ids[i].name)); + if (m_swapped_in_id) { + const auto it = std::find(overflow_ids.begin(), overflow_ids.end(), *m_swapped_in_id); + if (it != overflow_ids.end()) + menu.Check(static_cast(wxID_HIGHEST + 1 + (it - overflow_ids.begin())), true); + } + + menu.Bind(wxEVT_MENU, [this, overflow_ids](wxCommandEvent& evt) { + const size_t index = static_cast(evt.GetId() - (wxID_HIGHEST + 1)); + if (index >= overflow_ids.size()) + return; + m_swapped_in_id = overflow_ids[index]; + relayout(); + m_parent->SelectPageByName(page_tab_id(*m_swapped_in_id)); + }); + m_overflow_button->PopupMenu(&menu); +} + +} // namespace Slic3r diff --git a/src/slic3r/plugin/host/PluginPages.hpp b/src/slic3r/plugin/host/PluginPages.hpp new file mode 100644 index 0000000000..4d00de6df9 --- /dev/null +++ b/src/slic3r/plugin/host/PluginPages.hpp @@ -0,0 +1,89 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +class Notebook; + +namespace Slic3r { + +class PluginPage : public wxPanel +{ +public: + PluginPage(wxWindow* parent, std::shared_ptr capability); + ~PluginPage() override; + + PluginPage() = delete; + + bool is_valid() const { return m_browser != nullptr && m_cap != nullptr; } + void detach_capability(); + void on_bootstrap_event(wxWebViewEvent& event); + void on_new_window(wxWebViewEvent& event); + void on_script_message(wxWebViewEvent& event); + void push_message(const std::string& message); + void set_icon(const wxBitmap& icon) { m_icon = icon; } + const wxBitmap& icon() const { return m_icon; } + +private: + void load_plugin_content(); + wxString bootstrap_url() const; + wxString web_base_url() const; + + wxWebView* m_browser{nullptr}; + std::shared_ptr m_cap; + std::shared_ptr> m_lifetime; + bool m_content_loaded{false}; + wxBitmap m_icon; +}; + +class PluginPages +{ +public: + PluginPages() = default; + ~PluginPages(); + + PluginPages(const PluginPages&) = delete; + PluginPages& operator=(const PluginPages&) = delete; + + void initialize(Notebook* parent); + void shutdown(); + + void on_cap_register(const PluginCapabilityId& id); + void on_cap_deregister(const PluginCapabilityId& id); + void on_plugin_register(const std::string& plugin_key); + void on_plugin_deregister(const std::string& plugin_key); + + void set_visible_page_count(int count); + + void relayout(); + +private: + std::shared_ptr get_pages_cap(const PluginCapabilityId& id, bool is_enabled) const; + bool create_page(const PluginCapabilityId& id); + void remove_page(const PluginCapabilityId& id); + + void show_overflow_menu(); + static wxString page_tab_id(const PluginCapabilityId& id); + + std::map m_pages; + std::vector m_order; + Notebook* m_parent{nullptr}; + + int m_visible_page_count{0}; + + std::optional m_swapped_in_id; + wxWindow* m_overflow_button{nullptr}; +}; + +} // namespace Slic3r diff --git a/src/slic3r/plugin/pluginTypes/pages/PagesPluginCapability.cpp b/src/slic3r/plugin/pluginTypes/pages/PagesPluginCapability.cpp new file mode 100644 index 0000000000..e009f4426f --- /dev/null +++ b/src/slic3r/plugin/pluginTypes/pages/PagesPluginCapability.cpp @@ -0,0 +1,60 @@ +#include "PagesPluginCapability.hpp" +#include "PagesPluginCapabilityTrampoline.hpp" + +#include "../../PluginFsUtils.hpp" + +#include +#include + +#include + +namespace py = pybind11; + +namespace Slic3r { + +void PagesPluginCapability::RegisterBindings(pybind11::module_& module) +{ + BOOST_LOG_TRIVIAL(debug) << "Registering orca.pages bindings"; + + auto pages = module.def_submodule("pages", "Plugin page API"); + + py::class_>(pages, "PagesPluginCapabilityBase") + .def(py::init<>()) + .def("get_type", &PagesPluginCapability::get_type) + .def("get_ui", &PagesPluginCapability::get_ui) + .def("get_icon", &PagesPluginCapability::get_icon) + .def("on_message", &PagesPluginCapability::on_message) + .def( + "post_message", + [](PagesPluginCapability& capability, py::object data) { + capability.post_message(py_to_json(data).dump()); + }, + py::arg("data"), "Send a JSON-compatible value to the page's window.orca.onMessage handlers."); +} + +void PagesPluginCapability::post_message(std::string message) +{ + std::function sender; + { + std::lock_guard lock(m_message_mutex); + sender = m_message_sender; + } + + if (sender) + sender(message); +} + +void PagesPluginCapability::set_message_sender(std::function sender) +{ + std::lock_guard lock(m_message_mutex); + m_message_sender = std::move(sender); +} + +void PagesPluginCapability::clear_message_sender() +{ + std::lock_guard lock(m_message_mutex); + m_message_sender = nullptr; +} + +} // namespace Slic3r diff --git a/src/slic3r/plugin/pluginTypes/pages/PagesPluginCapability.hpp b/src/slic3r/plugin/pluginTypes/pages/PagesPluginCapability.hpp new file mode 100644 index 0000000000..978492006c --- /dev/null +++ b/src/slic3r/plugin/pluginTypes/pages/PagesPluginCapability.hpp @@ -0,0 +1,33 @@ +#ifndef slic3r_PagesPluginCapability_hpp_ +#define slic3r_PagesPluginCapability_hpp_ + +#include "../../PythonPluginInterface.hpp" +#include "pybind11/pybind11.h" + +#include +#include +#include + +namespace Slic3r { +class PagesPluginCapability : public PluginCapabilityInterface +{ +public: + static void RegisterBindings(pybind11::module_& module); + + PluginCapabilityType get_type() const override { return PluginCapabilityType::Pages; } + + virtual std::string get_ui() = 0; + virtual void on_message(std::string message) { (void) message; } + virtual std::string get_icon() { return {}; } + + void post_message(std::string message); + void set_message_sender(std::function sender); + void clear_message_sender(); + +private: + mutable std::mutex m_message_mutex; + std::function m_message_sender; +}; +} // namespace Slic3r + +#endif diff --git a/src/slic3r/plugin/pluginTypes/pages/PagesPluginCapabilityTrampoline.hpp b/src/slic3r/plugin/pluginTypes/pages/PagesPluginCapabilityTrampoline.hpp new file mode 100644 index 0000000000..3fb476c228 --- /dev/null +++ b/src/slic3r/plugin/pluginTypes/pages/PagesPluginCapabilityTrampoline.hpp @@ -0,0 +1,59 @@ +#pragma once + +#include "PagesPluginCapability.hpp" +#include "../../PluginFsUtils.hpp" +#include "../../PyPluginTrampoline.hpp" + +#include + +namespace Slic3r { + +class PyPagesPluginCapabilityTrampoline : public PyPluginCommonTrampoline +{ +public: + using PyPluginCommonTrampoline::PyPluginCommonTrampoline; + + std::string get_icon() override + { + ORCA_PY_OVERRIDE_AUDITED( + ::Slic3r::PluginAuditManager::AuditMode::Loading, + [] {}, + PYBIND11_OVERRIDE, + std::string, + PagesPluginCapability, + get_icon); + } + + std::string get_ui() override + { + ORCA_PY_OVERRIDE_AUDITED( + ::Slic3r::PluginAuditManager::AuditMode::Loading, + [] {}, + PYBIND11_OVERRIDE_PURE, + std::string, + PagesPluginCapability, + get_ui); + } + + void on_message(std::string message) override + { + PluginCapabilityInterface::RefCounter ref_counter(*this); + PythonGILState gil; + if (!gil) + throw std::runtime_error("Python interpreter is shutting down"); + + ORCA_PY_AUDIT_SCOPE(::Slic3r::PluginAuditManager::AuditMode::Loading); + + pybind11::function override = pybind11::get_override(static_cast(this), "on_message"); + if (!override) + return; + + nlohmann::json data = nlohmann::json::parse(message, nullptr, false); + if (data.is_discarded()) + data = message; + + ORCA_PY_LOGGED_OVERRIDE_BODY(override(::Slic3r::json_to_py(data))); + } +}; + +} // namespace Slic3r diff --git a/src/slic3r/plugin/pluginTypes/printerAgent/PrinterAgentPluginCapability.cpp b/src/slic3r/plugin/pluginTypes/printerAgent/PrinterAgentPluginCapability.cpp index b3d3d5d44c..d428775c12 100644 --- a/src/slic3r/plugin/pluginTypes/printerAgent/PrinterAgentPluginCapability.cpp +++ b/src/slic3r/plugin/pluginTypes/printerAgent/PrinterAgentPluginCapability.cpp @@ -13,10 +13,8 @@ namespace py = pybind11; namespace Slic3r { -void PrinterAgentPluginCapability::RegisterBindings(pybind11::module_& module, pybind11::enum_& pluginTypes) +void PrinterAgentPluginCapability::RegisterBindings(pybind11::module_& module) { - (void) pluginTypes; - auto printer_agent_module = module.def_submodule("printer_agent", "Printer Agent API"); py::enum_(printer_agent_module, "FilamentSyncMode") diff --git a/src/slic3r/plugin/pluginTypes/printerAgent/PrinterAgentPluginCapability.hpp b/src/slic3r/plugin/pluginTypes/printerAgent/PrinterAgentPluginCapability.hpp index 39ca7c2dfd..af869b68f3 100644 --- a/src/slic3r/plugin/pluginTypes/printerAgent/PrinterAgentPluginCapability.hpp +++ b/src/slic3r/plugin/pluginTypes/printerAgent/PrinterAgentPluginCapability.hpp @@ -19,7 +19,7 @@ namespace Slic3r { class PrinterAgentPluginCapability : public PluginCapabilityInterface, public IPrinterAgent { public: - static void RegisterBindings(pybind11::module_& module, pybind11::enum_& pluginTypes); + static void RegisterBindings(pybind11::module_& module); PluginCapabilityType get_type() const override { return PluginCapabilityType::PrinterConnection; } diff --git a/src/slic3r/plugin/pluginTypes/script/ScriptPluginCapability.cpp b/src/slic3r/plugin/pluginTypes/script/ScriptPluginCapability.cpp index 35a259edf6..712ba9b653 100644 --- a/src/slic3r/plugin/pluginTypes/script/ScriptPluginCapability.cpp +++ b/src/slic3r/plugin/pluginTypes/script/ScriptPluginCapability.cpp @@ -9,9 +9,8 @@ namespace py = pybind11; namespace Slic3r { -void ScriptPluginCapability::RegisterBindings(pybind11::module_& module, pybind11::enum_& pluginTypes) +void ScriptPluginCapability::RegisterBindings(pybind11::module_& module) { - (void) pluginTypes; BOOST_LOG_TRIVIAL(debug) << "Registering orca.script bindings"; auto script = module.def_submodule("script", "Script Plugins API"); diff --git a/src/slic3r/plugin/pluginTypes/script/ScriptPluginCapability.hpp b/src/slic3r/plugin/pluginTypes/script/ScriptPluginCapability.hpp index cb5bc45c08..fb1319e560 100644 --- a/src/slic3r/plugin/pluginTypes/script/ScriptPluginCapability.hpp +++ b/src/slic3r/plugin/pluginTypes/script/ScriptPluginCapability.hpp @@ -11,8 +11,7 @@ public: virtual ExecutionResult execute() = 0; - static void RegisterBindings(pybind11::module_ &module, - pybind11::enum_ &pluginTypes); + static void RegisterBindings(pybind11::module_ &module); }; } // namespace Slic3r diff --git a/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.cpp b/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.cpp index f4569aebba..d2e630242d 100644 --- a/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.cpp +++ b/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.cpp @@ -8,8 +8,7 @@ namespace Slic3r { bool SlicingPipelineContext::cancelled() const { return print && print->canceled(); } -void SlicingPipelinePluginCapability::RegisterBindings(py::module_& module, py::enum_& pluginTypes) { - (void) pluginTypes; // unused: this capability defines its own Step enum (below) rather than extending the shared PluginCapabilityType enum. +void SlicingPipelinePluginCapability::RegisterBindings(py::module_& module) { auto slicing = module.def_submodule("slicing", "Slicing pipeline API (research/experimental)."); py::enum_(slicing, "Step") diff --git a/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp b/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp index 639c087371..da0dbcbcbd 100644 --- a/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp +++ b/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp @@ -37,7 +37,7 @@ public: // Runs on the slicing worker thread. Do not call orca.host.ui.* here: the UI thread can be // blocked waiting on the slicing worker, so a marshaled UI call from this thread can deadlock. virtual ExecutionResult execute(SlicingPipelineContext& ctx) = 0; - static void RegisterBindings(pybind11::module_& module, pybind11::enum_& pluginTypes); + static void RegisterBindings(pybind11::module_& module); }; } // namespace Slic3r diff --git a/tests/fff_print/test_fill.cpp b/tests/fff_print/test_fill.cpp index 5fbce5a342..07460d3990 100644 --- a/tests/fff_print/test_fill.cpp +++ b/tests/fff_print/test_fill.cpp @@ -698,3 +698,290 @@ TEST_CASE("Solid infill direction offsets every layer when no template is set", CHECK(delta == 30); } } + +TEST_CASE("Honeycomb infill rounds its cell corners with the smooth factor", "[Fill]") +{ + // A cell whose sides are several times the line width, so that the corners have room to be rounded. + const double spacing = 0.45; + const double density = 0.1; + auto fill = [spacing, density](double smooth_factor) { + std::unique_ptr filler(Slic3r::Fill::new_from_type("honeycomb")); + filler->spacing = spacing; + + FillParams params; + params.density = float(density); + params.dont_adjust = true; + // Keep the fragments apart, so that only the turns of the pattern itself are measured. + params.anchor_length_max = 0.f; + params.smooth_factor = smooth_factor; + + Slic3r::ExPolygon square{ Slic3r::Points{ + Point::new_scale(0., 0.), Point::new_scale(50., 0.), Point::new_scale(50., 50.), Point::new_scale(0., 50.) } }; + Slic3r::Surface surface(stInternal, square); + return filler->fill_surface(&surface, params); + }; + + // Cosine of the sharpest turn of any of the paths, 1 meaning none of them turns at all. + auto sharpest_turn_cosine = [](const Slic3r::Polylines &polylines) { + double sharpest = 1.; + for (const Polyline &polyline : polylines) + for (size_t i = 1; i + 1 < polyline.size(); ++i) { + const Vec2d incoming = (polyline[i] - polyline[i - 1]).cast().normalized(); + const Vec2d outgoing = (polyline[i + 1] - polyline[i]).cast().normalized(); + sharpest = std::min(sharpest, incoming.dot(outgoing)); + } + return sharpest; + }; + auto point_count = [](const Slic3r::Polylines &polylines) { + return std::accumulate(polylines.begin(), polylines.end(), size_t(0), + [](size_t count, const Polyline &polyline) { return count + polyline.size(); }); + }; + + const Slic3r::Polylines sharp = fill(0.); + const Slic3r::Polylines smooth = fill(1.); + + REQUIRE(!sharp.empty()); + REQUIRE(smooth.size() == sharp.size()); + REQUIRE(point_count(smooth) > point_count(sharp)); + // The cell corners turn by 60 degrees; smoothing replaces them by gentle curves. + REQUIRE(sharpest_turn_cosine(sharp) < 0.6); + REQUIRE(sharpest_turn_cosine(smooth) > 0.9); +} + +// Point count, number of turns sharper than 25 degrees and length of the sparse infill of a print. +// A rounded corner is a run of much gentler turns, so smoothing shows up as fewer sharp ones. +struct SparseInfillShape { + size_t point_count { 0 }; + size_t sharp_turns { 0 }; + size_t path_count { 0 }; + double length { 0. }; +}; + +static SparseInfillShape sparse_infill_shape(const Print &print) +{ + SparseInfillShape shape; + + auto account = [&shape](const ExtrusionPath &path) { + if (!sparse_role(path.role())) + return; + const Points3 &pts = path.polyline.points; + ++shape.path_count; + shape.point_count += pts.size(); + for (size_t i = 1; i < pts.size(); ++i) + shape.length += (pts[i] - pts[i - 1]).head<2>().cast().norm(); + for (size_t i = 1; i + 1 < pts.size(); ++i) { + const Vec2d incoming = (pts[i] - pts[i - 1]).head<2>().cast(); + const Vec2d outgoing = (pts[i + 1] - pts[i]).head<2>().cast(); + if (incoming.squaredNorm() > 0. && outgoing.squaredNorm() > 0. && + incoming.normalized().dot(outgoing.normalized()) < 0.9) + ++shape.sharp_turns; + } + }; + + for (const Layer *layer : print.objects().front()->layers()) + for (const LayerRegion *region : layer->regions()) + for (const ExtrusionEntity *entity : region->fills.flatten().entities) { + if (auto *path = dynamic_cast(entity)) + account(*path); + else if (auto *multi = dynamic_cast(entity)) + for (const ExtrusionPath &p : multi->paths) + account(p); + else if (auto *loop = dynamic_cast(entity)) + for (const ExtrusionPath &p : loop->paths) + account(p); + } + return shape; +} + +TEST_CASE("Lightning infill rounds the turns of its branches with the smooth factor", "[Fill]") +{ + auto shape_for = [](const std::string &smooth_factor) { + Print print; + Slic3r::Test::init_and_process_print({Slic3r::Test::cube(20)}, print, + {{"sparse_infill_pattern", "lightning"}, + {"sparse_infill_density", "15%"}, + {"sparse_infill_smooth_factor", smooth_factor}, + {"layer_height", 0.2}}); + return sparse_infill_shape(print); + }; + + const SparseInfillShape sharp = shape_for("0%"); + const SparseInfillShape smooth = shape_for("100%"); + + REQUIRE(sharp.point_count > 0); + // The branch turns are replaced by curves, which cut the corners off and take more points to + // describe. The turns where two branches are joined into one path stay sharp. + REQUIRE(smooth.point_count > sharp.point_count); + REQUIRE(smooth.sharp_turns < sharp.sharp_turns); + REQUIRE(smooth.length < sharp.length); +} + +TEST_CASE("Concentric infill rounds its loops with the smooth factor", "[Fill]") +{ + auto shape_for = [](const std::string &smooth_factor) { + Print print; + Slic3r::Test::init_and_process_print({Slic3r::Test::cube(20)}, print, + {{"sparse_infill_pattern", "concentric"}, + {"sparse_infill_density", "20%"}, + {"sparse_infill_smooth_factor", smooth_factor}, + {"layer_height", 0.2}}); + return sparse_infill_shape(print); + }; + + const SparseInfillShape sharp = shape_for("0%"); + const SparseInfillShape smooth = shape_for("100%"); + + REQUIRE(sharp.point_count > 0); + REQUIRE(smooth.point_count > sharp.point_count); + REQUIRE(smooth.sharp_turns < sharp.sharp_turns); + REQUIRE(smooth.length < sharp.length); +} + +TEST_CASE("Cross hatch infill rounds its transition layers with the smooth factor", "[Fill]") +{ + auto shape_for = [](const std::string &smooth_factor) { + Print print; + Slic3r::Test::init_and_process_print({Slic3r::Test::cube(20)}, print, + {{"sparse_infill_pattern", "crosshatch"}, + {"sparse_infill_density", "20%"}, + {"sparse_infill_smooth_factor", smooth_factor}, + {"layer_height", 0.2}}); + return sparse_infill_shape(print); + }; + + const SparseInfillShape sharp = shape_for("0%"); + const SparseInfillShape smooth = shape_for("100%"); + + REQUIRE(sharp.point_count > 0); + REQUIRE(smooth.point_count > sharp.point_count); + REQUIRE(smooth.sharp_turns < sharp.sharp_turns); + REQUIRE(smooth.length < sharp.length); +} + +TEST_CASE("Trapezoidal grid infill rounds its corners only with more than one line", "[Fill]") +{ + auto shape_for = [](int multiline, const std::string &smooth_factor) { + Print print; + Slic3r::Test::init_and_process_print({Slic3r::Test::cube(20)}, print, + {{"sparse_infill_pattern", "grid"}, + {"sparse_infill_density", "20%"}, + {"fill_multiline", multiline}, + {"sparse_infill_smooth_factor", smooth_factor}, + {"layer_height", 0.2}}); + return sparse_infill_shape(print); + }; + + const SparseInfillShape sharp = shape_for(2, "0%"); + const SparseInfillShape smooth = shape_for(2, "100%"); + + REQUIRE(sharp.point_count > 0); + REQUIRE(smooth.point_count > sharp.point_count); + REQUIRE(smooth.sharp_turns < sharp.sharp_turns); + REQUIRE(smooth.length < sharp.length); + + // A single line per infill wall is the plain crossing line grid, which has no corner of its own. + const SparseInfillShape single_sharp = shape_for(1, "0%"); + const SparseInfillShape single_smooth = shape_for(1, "100%"); + REQUIRE(single_sharp.point_count > 0); + REQUIRE(single_smooth.point_count == single_sharp.point_count); + REQUIRE(single_smooth.length == single_sharp.length); +} + +TEST_CASE("3D honeycomb infill rounds its octahedral waves with the smooth factor", "[Fill]") +{ + auto shape_for = [](const std::string &smooth_factor) { + Print print; + Slic3r::Test::init_and_process_print({Slic3r::Test::cube(20)}, print, + {{"sparse_infill_pattern", "3dhoneycomb"}, + {"sparse_infill_density", "20%"}, + {"sparse_infill_smooth_factor", smooth_factor}, + {"layer_height", 0.2}}); + return sparse_infill_shape(print); + }; + + const SparseInfillShape sharp = shape_for("0%"); + const SparseInfillShape smooth = shape_for("100%"); + + REQUIRE(sharp.point_count > 0); + REQUIRE(smooth.point_count > sharp.point_count); + REQUIRE(smooth.sharp_turns < sharp.sharp_turns); + REQUIRE(smooth.length < sharp.length); +} + +TEST_CASE("Smoothed concentric infill stays inside the fill region", "[Fill][Regression]") +{ + // The concentric loops are offsets of the fill region and are never clipped to it, so a corner + // rounded across its boundary ends up in a hole or over a wall. Rounding cuts toward the inside of + // the turn, which leaves the region at every corner of a hole, and in a region thinner than the + // curve even at a corner turning inwards. + const bool thin_region = GENERATE(false, true); + ExPolygon region; + if (thin_region) { + // An L of two 1.2mm wide arms: cutting the corner they meet at crosses both of them. + region = ExPolygon{ Slic3r::Points{ + Point::new_scale(0., 0.), Point::new_scale(20., 0.), Point::new_scale(20., 1.2), + Point::new_scale(1.2, 1.2), Point::new_scale(1.2, 20.), Point::new_scale(0., 20.) } }; + } else { + region = ExPolygon{ Slic3r::Points{ Point::new_scale(0., 0.), Point::new_scale(50., 0.), + Point::new_scale(50., 50.), Point::new_scale(0., 50.) }, + Slic3r::Points{ Point::new_scale(30., 20.), Point::new_scale(30., 30.), + Point::new_scale(20., 30.), Point::new_scale(20., 20.) } }; + } + CAPTURE(thin_region); + + auto fill = [®ion](double smooth_factor) { + std::unique_ptr filler(Slic3r::Fill::new_from_type("concentric")); + filler->spacing = 0.45; + + FillParams params; + params.density = 0.1f; + params.dont_adjust = true; + params.smooth_factor = smooth_factor; + + Slic3r::Surface surface(stInternal, region); + return filler->fill_surface(&surface, params); + }; + auto point_count = [](const Slic3r::Polylines &polylines) { + return std::accumulate(polylines.begin(), polylines.end(), size_t(0), + [](size_t count, const Polyline &polyline) { return count + polyline.size(); }); + }; + + const Slic3r::Polylines sharp = fill(0.); + const Slic3r::Polylines smooth = fill(1.); + REQUIRE(!sharp.empty()); + + // Nothing leaves the fill region, which the unrounded loops already touch from the inside. + const ExPolygons bounds = offset_ex(region, float(SCALED_EPSILON)); + REQUIRE(diff_pl(sharp, bounds).empty()); + REQUIRE(diff_pl(smooth, bounds).empty()); + // The corners that the region has room for are still rounded. + if (!thin_region) + REQUIRE(point_count(smooth) > point_count(sharp)); +} + +TEST_CASE("Smoothing multiline lightning infill keeps its outlines connected", "[Fill][Regression]") +{ + // With more than one line per infill wall, the branches are printed as outlines drawn around them, + // and the outlines of branches that run close to each other merge into one. Rounding the branches + // before those outlines are built moves them apart, which breaks the merged outlines up into + // separate loops - many more of them, each needing its own travel move. + auto shape_for = [](const std::string &smooth_factor) { + Print print; + Slic3r::Test::init_and_process_print({Slic3r::Test::cube(20)}, print, + {{"sparse_infill_pattern", "lightning"}, + {"sparse_infill_density", "50%"}, + {"fill_multiline", 2}, + {"sparse_infill_smooth_factor", smooth_factor}, + {"layer_height", 0.2}}); + return sparse_infill_shape(print); + }; + + const SparseInfillShape sharp = shape_for("0%"); + const SparseInfillShape smooth = shape_for("100%"); + + REQUIRE(sharp.path_count > 0); + REQUIRE(smooth.path_count <= sharp.path_count); + // The outlines are still rounded. + REQUIRE(smooth.point_count > sharp.point_count); + REQUIRE(smooth.sharp_turns < sharp.sharp_turns); +} diff --git a/tests/libslic3r/CMakeLists.txt b/tests/libslic3r/CMakeLists.txt index 1ad299473c..bc10bb4f73 100644 --- a/tests/libslic3r/CMakeLists.txt +++ b/tests/libslic3r/CMakeLists.txt @@ -18,6 +18,7 @@ add_executable(${_TEST_NAME}_tests test_preset_setting_id.cpp test_preset_diff.cpp test_elephant_foot_compensation.cpp + test_fill_corner_smoothing.cpp test_fill_plane_path.cpp test_geometry.cpp test_multimaterial_segmentation.cpp diff --git a/tests/libslic3r/test_fill_corner_smoothing.cpp b/tests/libslic3r/test_fill_corner_smoothing.cpp new file mode 100644 index 0000000000..f2c25e816d --- /dev/null +++ b/tests/libslic3r/test_fill_corner_smoothing.cpp @@ -0,0 +1,173 @@ +#include + +#include +#include +#include + +#include "libslic3r/Fill/FillCornerSmoothing.hpp" +#include "libslic3r/Polyline.hpp" +#include "libslic3r/libslic3r.h" + +using namespace Slic3r; + +namespace { + +// A right angle turn, with the outgoing leg ten times longer than the incoming one. +Polyline asymmetric_corner() +{ + return Polyline{ Point::new_scale(0., 0.), Point::new_scale(10., 0.), Point::new_scale(10., 100.) }; +} + +double max_turn_cosine(const Polyline &polyline) +{ + double sharpest = 1.; + for (size_t i = 1; i + 1 < polyline.size(); ++i) { + const Vec2d incoming = (polyline[i] - polyline[i - 1]).cast().normalized(); + const Vec2d outgoing = (polyline[i + 1] - polyline[i]).cast().normalized(); + sharpest = std::min(sharpest, incoming.dot(outgoing)); + } + return sharpest; +} + +bool contains(const Polyline &polyline, const Point &point) +{ + return std::find(polyline.points.begin(), polyline.points.end(), point) != polyline.points.end(); +} + +const double tolerance = scaled(0.0125); + +} // namespace + +TEST_CASE("Corner smoothing replaces a sharp vertex by a curve", "[FillCornerSmoothing]") +{ + const Polyline sharp = asymmetric_corner(); + Polyline smooth = sharp; + smooth_polyline_corners(smooth, 1., tolerance); + + REQUIRE(smooth.size() > sharp.size()); + REQUIRE(smooth.front() == sharp.front()); + REQUIRE(smooth.back() == sharp.back()); + // The right angle is gone, every remaining turn is a gentle one. + REQUIRE(max_turn_cosine(sharp) < 0.1); + REQUIRE(max_turn_cosine(smooth) > 0.9); + REQUIRE(smooth.length() < sharp.length()); +} + +TEST_CASE("Corner smoothing keeps the path untouched at a zero factor", "[FillCornerSmoothing]") +{ + const Polyline sharp = asymmetric_corner(); + + Polyline none = sharp; + smooth_polyline_corners(none, 0., tolerance); + REQUIRE(none.points == sharp.points); + + Polyline invalid = sharp; + smooth_polyline_corners(invalid, std::numeric_limits::quiet_NaN(), tolerance); + REQUIRE(invalid.points == sharp.points); +} + +TEST_CASE("Corner smoothing consumes at most half of the shorter leg", "[FillCornerSmoothing]") +{ + // The curve must not reach beyond the middle of either adjoining segment, otherwise the curves of + // two adjacent corners would overlap. The shorter leg is 10mm long, so the corner at (10, 0) is + // left 5mm before it and rejoined 5mm past it, even though the other leg is 100mm long. + Polyline smooth = asymmetric_corner(); + smooth_polyline_corners(smooth, 1., tolerance); + + REQUIRE(contains(smooth, Point::new_scale(5., 0.))); + REQUIRE(contains(smooth, Point::new_scale(10., 5.))); + // A Bezier curve stays within the convex hull of its control points, so the rounded path stays + // inside the box spanned by the two legs. + for (const Point &point : smooth.points) { + REQUIRE(point.x() >= 0); + REQUIRE(point.y() >= 0); + REQUIRE(point.x() <= Point::new_scale(10., 0.).x()); + REQUIRE(point.y() <= Point::new_scale(0., 100.).y()); + } +} + +TEST_CASE("Corner smoothing scales the curve with the factor", "[FillCornerSmoothing]") +{ + Polyline half = asymmetric_corner(); + smooth_polyline_corners(half, 0.5, tolerance); + Polyline full = asymmetric_corner(); + smooth_polyline_corners(full, 1., tolerance); + + // Half of the factor leaves the 10mm leg half as far from the corner. + REQUIRE(contains(half, Point::new_scale(7.5, 0.))); + REQUIRE(contains(full, Point::new_scale(5., 0.))); + // A larger factor rounds a wider portion of the legs, cutting more of the corner off. + REQUIRE(full.length() < half.length()); +} + +TEST_CASE("Corner smoothing leaves hairpins sharp", "[FillCornerSmoothing]") +{ + // Both ends of a curve replacing a nearly reversing turn coincide, which would round the hairpin + // into a degenerate loop instead of a tip. + Polyline hairpin{ Point::new_scale(0., 0.), Point::new_scale(10., 0.), Point::new_scale(0., 0.5) }; + const Polyline sharp = hairpin; + smooth_polyline_corners(hairpin, 1., tolerance); + REQUIRE(hairpin == sharp); +} + +TEST_CASE("Corner smoothing follows the flattening tolerance", "[FillCornerSmoothing]") +{ + Polyline coarse = asymmetric_corner(); + smooth_polyline_corners(coarse, 1., scaled(0.2)); + Polyline fine = asymmetric_corner(); + smooth_polyline_corners(fine, 1., scaled(0.001)); + + REQUIRE(fine.size() > coarse.size()); + REQUIRE(fine.front() == coarse.front()); + REQUIRE(fine.back() == coarse.back()); +} + +TEST_CASE("Corner smoothing emits no zero length segments", "[FillCornerSmoothing]") +{ + // Fully smoothed adjacent corners meet at the midpoint of the segment they share. + Polyline zigzag; + for (int i = 0; i < 8; ++i) + zigzag.points.emplace_back(Point::new_scale(i, i % 2 ? 1. : 0.)); + smooth_polyline_corners(zigzag, 1., tolerance); + + for (size_t i = 1; i < zigzag.size(); ++i) + REQUIRE((zigzag[i] - zigzag[i - 1]).cast().squaredNorm() > 0.); +} + +TEST_CASE("Corner smoothing rounds every vertex of a polygon", "[FillCornerSmoothing]") +{ + // A polygon closes implicitly, so none of its corners may stay sharp, not even the first one. + const Polygon square{ Point::new_scale(0., 0.), Point::new_scale(10., 0.), Point::new_scale(10., 10.), + Point::new_scale(0., 10.) }; + Polygons smooth{ square }; + smooth_polygons_corners(smooth, 1., tolerance); + const Polyline rounded = smooth.front().split_at_first_point(); + + REQUIRE(smooth.front().size() > square.size()); + REQUIRE(max_turn_cosine(rounded) > 0.9); + // The turn from the closing segment back into the first one must be gentle as well. + const Vec2d incoming = (rounded[rounded.size() - 1] - rounded[rounded.size() - 2]).cast().normalized(); + const Vec2d outgoing = (rounded[1] - rounded[0]).cast().normalized(); + REQUIRE(incoming.dot(outgoing) > 0.9); + // None of the corners is cut by more than half of a 10mm side. + for (const Point &point : smooth.front().points) { + REQUIRE(point.x() >= 0); + REQUIRE(point.y() >= 0); + REQUIRE(point.x() <= Point::new_scale(10., 0.).x()); + REQUIRE(point.y() <= Point::new_scale(0., 10.).y()); + } +} + +TEST_CASE("Corner smoothing keeps the ends of a path that returns to its start", "[FillCornerSmoothing][Regression]") +{ + // A branch of a lightning tree walks out and retraces its way back, ending where it started. Its + // ends are two free ends that happen to coincide, and joining them would close it into a loop. + Polyline retrace{ Point::new_scale(0., 0.), Point::new_scale(10., 0.), Point::new_scale(10., 10.), + Point::new_scale(5., 10.), Point::new_scale(0., 0.) }; + const Polyline sharp = retrace; + smooth_polyline_corners(retrace, 1., tolerance); + + REQUIRE(retrace.size() > sharp.size()); + REQUIRE(retrace.front() == sharp.front()); + REQUIRE(retrace.back() == sharp.back()); +} diff --git a/tests/libslic3r/test_fill_plane_path.cpp b/tests/libslic3r/test_fill_plane_path.cpp index bbb75dce58..7fc7f4a6b0 100644 --- a/tests/libslic3r/test_fill_plane_path.cpp +++ b/tests/libslic3r/test_fill_plane_path.cpp @@ -27,6 +27,31 @@ public: } }; +class TestableOctagramSpiral : public FillOctagramSpiral +{ +public: + Points generate_points(double resolution, double smooth_factor = 0., coord_t max_coordinate = 7) + { + InfillPolylineOutput output(output_scale); + FillParams params; + params.smooth_factor = smooth_factor; + FillOctagramSpiral::generate(-max_coordinate, -max_coordinate, max_coordinate, max_coordinate, resolution, params, output); + return std::move(output.result()); + } +}; + +// Cosine of the sharpest turn of a path, 1 meaning it has no turn at all. +double sharpest_turn_cosine(const Points &points) +{ + double sharpest = 1.; + for (size_t i = 1; i + 1 < points.size(); ++i) { + const Vec2d incoming = (points[i] - points[i - 1]).cast().normalized(); + const Vec2d outgoing = (points[i + 1] - points[i]).cast().normalized(); + sharpest = std::min(sharpest, incoming.dot(outgoing)); + } + return sharpest; +} + double path_length(const Points &points) { double length = 0.; @@ -146,6 +171,35 @@ TEST_CASE("Hilbert smoothing joins straight segments with continuous curvature", REQUIRE(fine_entry_curvature < 0.25 * coarse_entry_curvature); } +TEST_CASE("Octagram spiral smoothing rounds the turns of the spiral", "[FillPlanePath]") +{ + const Points sharp = TestableOctagramSpiral().generate_points(0.005); + const Points smooth = TestableOctagramSpiral().generate_points(0.005, 1.); + + REQUIRE(smooth.size() > sharp.size()); + REQUIRE(smooth.front() == sharp.front()); + REQUIRE(smooth.back() == sharp.back()); + // The spiral alternates between 90 and 135 degree turns; both are rounded into gentle ones. + REQUIRE(sharpest_turn_cosine(sharp) < -0.7); + REQUIRE(sharpest_turn_cosine(smooth) > 0.9); + + for (size_t i = 1; i < smooth.size(); ++i) + REQUIRE((smooth[i] - smooth[i - 1]).cast().squaredNorm() > 0.); +} + +TEST_CASE("Octagram spiral smooth factor controls corner curvature", "[FillPlanePath]") +{ + const Points sharp = TestableOctagramSpiral().generate_points(0.005); + const Points half_smooth = TestableOctagramSpiral().generate_points(0.005, 0.5); + const Points full_smooth = TestableOctagramSpiral().generate_points(0.005, 1.); + const Points invalid_factor = TestableOctagramSpiral().generate_points( + 0.005, std::numeric_limits::quiet_NaN()); + + REQUIRE(path_length(full_smooth) < path_length(half_smooth)); + REQUIRE(path_length(half_smooth) < path_length(sharp)); + REQUIRE(invalid_factor == sharp); +} + TEST_CASE("Hilbert curve smooth factor controls corner curvature", "[FillPlanePath]") { const Points sharp = TestableHilbertCurve().generate_points(0.005);