Compare commits

..
Author SHA1 Message Date
Ian Chua b29f06a0ab fix: clang-tidy 2026-10-12 18:49:28 +08:00
Ian Chua 9ec0d39592 Merge branch 'main' into fix/isolate-plugin-browser-session 2026-10-12 18:35:44 +08:00
HanifKoh c55f9c2251 Add a /bot fix-tidy Command That Suggests clang-tidy's Missing Includes (#16398)
* Add a /bot fix-tidy Command That Pushes clang-tidy's Missing Includes

A maintainer comments /bot fix-tidy on a PR. A read-only job runs
scripts/run_clang_tidy.sh --fix on the PR's merge commit and returns the
patch; a second job, which never runs the PR's code, accepts it only when
it adds nothing but #include lines to C++ files the PR already changes,
then commits it on the PR head and pushes with CLANG_TIDY_BOT_TOKEN.

* Post clang-tidy's Fix as Review Suggestions Instead of Pushing It

A failed clang-tidy check now runs once more with --fix and uploads the
patch as the clang-tidy-fix artifact; the check still fails. /bot
fix-tidy downloads that patch for the PR's latest commit, checks it adds
only includes to C++ files the PR changes, and posts one review: a
one-click suggestion for each include on a line the PR's diff shows, and
the whole patch with a git apply command. Nothing is pushed and no PR
code runs in the bot, so the PR's author can run it too and no token is
needed.

* Make the clang-tidy Fix in Its Own PR-Scoped Workflow on Request

clang_tidy.yml is back to main's version. clang_tidy_fix.yml runs on
every PR push but skips its job on the first attempt; /bot fix-tidy
re-runs it, so run_clang_tidy.sh --fix runs in the PR's own context
with a read-only token and the PR's cache, never main's, and the PR's
author can ask for it. When that re-run finishes, the bot posts the
patch as review suggestions as before. When the clang-tidy step fails,
the bot also comments asking the author to run the script or the
command, edits that comment on later failures, and deletes it once the
check passes.

* Give the clang-tidy Bot Write Access to PRs It Answers

* Tell /bot fix-tidy Users How to Start a Fix Run When There Is None to Re-Run
2026-10-12 18:31:03 +08:00
Ian Chua 0b9b05ed85 fix: Isolate plugin's webview browser sessions 2026-10-12 16:44:24 +08:00
76 changed files with 736 additions and 1634 deletions
+65
View File
@@ -0,0 +1,65 @@
name: clang-tidy fix
# Makes the missing-include fix that /bot fix-tidy (pr-tidy-bot.yml) posts on a
# pull request: scripts/run_clang_tidy.sh --fix on the merge commit the
# clang-tidy check runs on, uploaded as the clang-tidy-fix artifact.
#
# Every push to a PR starts this workflow, and its job skips on that first
# attempt. The bot re-runs it on request. A re-run keeps the pull_request
# context, so the PR's code runs with a read-only token, no secrets and the
# PR's own cache, never main's, whoever asked for it.
on:
pull_request:
branches:
- main
- release/*
permissions:
contents: read
jobs:
fix:
if: github.run_attempt > 1
# A hosted runner, so nothing the PR's code leaves behind outlives the job.
runs-on: ubuntu-24.04
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v7
with:
lfs: 'false'
# The PR merge commit plus its first parent, the base it is diffed against.
fetch-depth: 2
persist-credentials: false
# Parsing needs the dependency headers, not a build of this PR's deps/, so
# a PR that changes deps/ falls back to the newest cache main has.
- name: Restore cached deps
uses: actions/cache/restore@v6
with:
path: ${{ github.workspace }}/deps/build/OrcaSlicer_dep
key: linux-clang-cache-orcaslicer_deps-build-${{ hashFiles('deps/**') }}
restore-keys: linux-clang-cache-orcaslicer_deps-build-
fail-on-cache-miss: true
- name: Apt-Install Dependencies
uses: ./.github/actions/apt-install-deps
# The status says whether findings remain that --fix cannot fix.
- name: Fix the changed lines
run: |
mkdir -p "$RUNNER_TEMP/clang-tidy-fix"
status=0
scripts/run_clang_tidy.sh --base HEAD^1 --yes --fix || status=$?
echo "$status" > "$RUNNER_TEMP/clang-tidy-fix/status"
git -c core.quotePath=false diff --no-color --no-ext-diff --src-prefix=a/ --dst-prefix=b/ \
> "$RUNNER_TEMP/clang-tidy-fix/fix.patch"
- name: Upload the fix
uses: actions/upload-artifact@v7
with:
name: clang-tidy-fix
path: ${{ runner.temp }}/clang-tidy-fix/
retention-days: 7
# A later re-run of the same run replaces the earlier fix.
overwrite: true
+476
View File
@@ -0,0 +1,476 @@
name: PR clang-tidy Bot
# Helps a pull request past the clang-tidy check.
#
# When the check fails on a PR's changes, a comment asks the author to fix it,
# either with scripts/run_clang_tidy.sh --fix or by commenting:
# /bot fix-tidy
#
# That command, from the PR's author or an account with write access, re-runs
# the PR's clang-tidy fix run (clang_tidy_fix.yml), which makes the fix in the
# PR's own context. When it finishes, the fix is posted as one review: each
# include whose place falls on a line the PR's diff shows is a suggestion GitHub
# can commit in one click, and the review's body holds the whole patch with a
# git apply command. Findings --fix cannot fix, such as a using in a header,
# are left for the check to report.
#
# No code from the PR runs here. Nothing is pushed, and the patch is only
# posted when every line it adds is an #include in a C++ file the PR changes.
#
# workflow_run matches the workflows by their name: keep "clang-tidy" and
# "clang-tidy fix" in step with clang_tidy.yml and clang_tidy_fix.yml, and
# TIDY_STEP below with the clang-tidy step's name in clang_tidy.yml.
on:
issue_comment:
types:
- created
workflow_run:
workflows:
- clang-tidy
- clang-tidy fix
types:
- completed
permissions: {}
jobs:
request:
# Skips the job unless a PR comment mentions the command.
if: >-
github.repository == 'OrcaSlicer/OrcaSlicer'
&& github.event_name == 'issue_comment'
&& github.event.issue.pull_request != null
&& contains(github.event.comment.body, '/bot fix-tidy')
permissions:
actions: write # re-run the fix run
pull-requests: write # react to and answer the comment, which is the PR's
issues: write # feedback comment + reactions
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Re-run the PR's clang-tidy fix
uses: actions/github-script@v9
with:
script: |
const MARKER = '<!-- pr-tidy-bot -->';
const FIX_WORKFLOW = 'clang_tidy_fix.yml';
const { owner, repo } = context.repo;
const issue = context.payload.issue;
const comment = context.payload.comment;
// Ignores a comment whose sender is not its author.
if (context.payload.sender.login !== comment.user.login || comment.user.type !== 'User') {
core.info('Ignoring a comment not written by its sender, or by a bot.');
return;
}
const commandLine = (comment.body || '')
.split('\n')
.map((line) => line.trim())
.find((line) => /^\/bot\s+fix-tidy\b/i.test(line));
if (!commandLine) {
core.info('No /bot fix-tidy command found.');
return;
}
const commenter = comment.user.login;
// Answers only the PR's author and maintainers, so the command cannot be used to spam.
if (commenter !== issue.user.login) {
const { data: access } = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username: commenter });
if (!['admin', 'write'].includes(access.permission)) {
core.info(`Ignoring /bot fix-tidy from @${commenter}: not the PR's author and no write access.`);
return;
}
}
const react = (content) =>
github.rest.reactions.createForIssueComment({ owner, repo, comment_id: comment.id, content });
async function refuse(reason) {
await react('-1');
await github.rest.issues.createComment({ owner, repo, issue_number: issue.number, body: `${MARKER}\n@${commenter} ${reason}` });
}
await react('eyes');
if (!/^\/bot\s+fix-tidy$/i.test(commandLine)) {
return refuse('`/bot fix-tidy` takes no arguments.');
}
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: issue.number });
if (pr.state !== 'open') {
return refuse('the PR is not open.');
}
const sha = pr.head.sha.slice(0, 10);
const { data: { workflow_runs: runs } } = await github.rest.actions.listWorkflowRuns({
owner, repo, workflow_id: FIX_WORKFLOW, event: 'pull_request', head_sha: pr.head.sha, per_page: 1
});
const run = runs[0];
// A PR last pushed before clang_tidy_fix.yml existed has no run to re-run.
if (!run) {
return refuse(`there is no clang-tidy fix run for ${sha}, the PR's latest commit. If you just pushed it, ` +
'try again in a minute. Otherwise, push a commit or merge the base branch to start one, then comment `/bot fix-tidy` again.');
}
if (run.status !== 'completed') {
// The first attempt is the one every push starts, and it makes no fix.
return refuse(run.run_attempt > 1
? `a clang-tidy fix for ${sha} is already [running](${run.html_url}). I'll post it when it finishes.`
: `the PR's checks on ${sha} are still starting. Comment \`/bot fix-tidy\` again in a minute.`);
}
// GitHub refuses to re-run a run created over 30 days ago.
try {
await github.rest.actions.reRunWorkflow({ owner, repo, run_id: run.id });
} catch (error) {
core.warning(`Re-running ${run.html_url} failed: ${error.message}`);
return refuse(`GitHub won't re-run the [clang-tidy fix run](${run.html_url}) for ${sha} (${error.message}). ` +
'Push a commit or merge the base branch to start a new one, then comment `/bot fix-tidy` again.');
}
core.info(`Re-ran ${run.html_url}`);
suggest:
# The fix run's re-runs only. Its first attempt makes no fix.
if: >-
github.repository == 'OrcaSlicer/OrcaSlicer'
&& github.event_name == 'workflow_run'
&& github.event.workflow_run.name == 'clang-tidy fix'
&& github.event.workflow_run.event == 'pull_request'
&& github.event.workflow_run.run_attempt > 1
concurrency:
group: ${{ github.workflow }}-suggest-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: false
permissions:
actions: read # download the fix
contents: read # check out the PR head
pull-requests: write # the review
issues: write # feedback comment + reactions
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Find the PR and who asked
id: find
uses: actions/github-script@v9
with:
script: |
const MARKER = '<!-- pr-tidy-bot -->';
const CPP = /^(src|tests)\/.+\.(cpp|cc|cxx|hpp|h|hxx)$/;
const { owner, repo } = context.repo;
const run = context.payload.workflow_run;
// workflow_run lists no PR for a fork's branch, so the PR is found by its branch.
const { data: prs } = await github.rest.pulls.list({
owner, repo, state: 'open', head: `${run.head_repository.owner.login}:${run.head_branch}`
});
const pr = prs[0];
if (!pr) {
core.info('No open PR for this run.');
return;
}
// The latest /bot fix-tidy comment is the one this run answers.
const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: pr.number, per_page: 100 });
const request = comments.reverse().find((c) => c.user.type === 'User' && /^\s*\/bot\s+fix-tidy\s*$/im.test(c.body || ''));
const mention = request ? `@${request.user.login} ` : '';
async function refuse(reason) {
if (request) {
await github.rest.reactions.createForIssueComment({ owner, repo, comment_id: request.id, content: '-1' });
}
await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body: `${MARKER}\n${mention}${reason}` });
}
if (pr.head.sha !== run.head_sha) {
return refuse('the PR changed while I was making its clang-tidy fix. Comment `/bot fix-tidy` again.');
}
if (run.conclusion !== 'success') {
return refuse(`I couldn't make the clang-tidy fix. See the [run log](${run.html_url}).`);
}
// The right-side lines of each changed C++ file's diff: the lines a
// review comment can be placed on. A file too large for GitHub to
// show its diff has none, and gets no suggestions.
const files = await github.paginate(github.rest.pulls.listFiles, { owner, repo, pull_number: pr.number, per_page: 100 });
const ranges = {};
for (const file of files) {
if (file.status === 'removed' || !CPP.test(file.filename)) {
continue;
}
ranges[file.filename] = [...(file.patch || '').matchAll(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/gm)]
.map((m) => [Number(m[1]), Number(m[1]) + Number(m[2] ?? 1) - 1])
.filter(([first, last]) => last >= first);
}
core.setOutput('pr', pr.number);
core.setOutput('head-sha', pr.head.sha);
core.setOutput('head-repo', run.head_repository.full_name);
core.setOutput('ranges', JSON.stringify(ranges));
core.setOutput('mention', mention);
core.setOutput('request-id', request ? request.id : '');
- name: Download the fix
if: steps.find.outputs.pr != ''
uses: actions/download-artifact@v8
with:
name: clang-tidy-fix
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}
path: ${{ runner.temp }}/clang-tidy-fix
# The PR's own files, only to apply the patch to. Nothing in them is run.
- name: Checkout the PR head
if: steps.find.outputs.pr != ''
uses: actions/checkout@v7
with:
repository: ${{ steps.find.outputs.head-repo }}
ref: ${{ steps.find.outputs.head-sha }}
path: head
fetch-depth: 1
lfs: 'false'
persist-credentials: false
sparse-checkout: |
src
tests
# The fix was made on the PR's merge commit, so it is applied to the head
# and diffed again, giving line numbers in the PR's own files.
- name: Check the fix and place its suggestions
id: place
if: steps.find.outputs.pr != ''
env:
RANGES: ${{ steps.find.outputs.ranges }}
run: |
python3 -I - "$RUNNER_TEMP/clang-tidy-fix/fix.patch" head "$RUNNER_TEMP" <<'EOF'
import json, os, re, subprocess, sys
patch_path, head, out = sys.argv[1:]
ranges = json.loads(os.environ["RANGES"])
def output(**values):
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f:
for key, value in values.items():
f.write(f"{key}={value}\n")
patch = open(patch_path, encoding="utf-8").read()
if not patch.strip():
output(result="empty")
sys.exit()
# Every line the patch adds must be an #include or blank, in a C++ file the
# PR changes, and it may not remove, create, rename or change the mode of
# anything.
include = re.compile(r'\s*#\s*include\s*(<[\w./+-]+>|"[\w./+-]+")\s*')
header = re.compile(r"(index [0-9a-f]+\.\.[0-9a-f]+( \d+)?|@@ -\d+(,\d+)? \+\d+(,\d+)? @@.*)")
problems = []
path = None
for line in patch.splitlines():
if line.startswith("diff --git "):
match = re.fullmatch(r"diff --git a/(.+) b/\1", line)
path = match.group(1) if match else None
if path not in ranges:
problems.append(f"{line}: not a C++ file the PR changes")
# git appends a tab to the --- and +++ lines of a path that contains a space.
elif line.removesuffix("\t") in (f"--- a/{path}", f"+++ b/{path}") or header.fullmatch(line):
pass
elif line.startswith(" ") or line == "\\ No newline at end of file":
pass
elif line.startswith("+") and (include.fullmatch(line[1:]) or not line[1:].strip()):
pass
else:
problems.append(f"{path}: {line}")
if problems:
for problem in problems:
print(f"::error::Refused patch line {problem}")
output(result="rejected")
sys.exit()
if subprocess.run(["git", "-C", head, "apply", "--index", os.path.abspath(patch_path)]).returncode != 0:
output(result="apply-failed")
sys.exit()
git = ["git", "-C", head, "-c", "core.quotePath=false", "diff", "--cached", "--no-color", "--no-ext-diff",
"--src-prefix=a/", "--dst-prefix=b/"]
with open(os.path.join(out, "head.patch"), "w", encoding="utf-8") as f:
f.write(subprocess.run(git, check=True, capture_output=True, encoding="utf-8").stdout)
# Each run of added lines becomes one suggestion on the line it goes after
# (before, at the top of a file): that line, replaced by itself and the
# includes. It can only sit on a line the PR's diff shows.
hunks = []
path = None
for line in subprocess.run(git + ["-U0"], check=True, capture_output=True, encoding="utf-8").stdout.splitlines():
if line.startswith("diff --git "):
path = re.fullmatch(r"diff --git a/(.+) b/\1", line).group(1)
elif line.startswith("@@ "):
hunks.append((path, int(re.match(r"@@ -(\d+)", line).group(1)), []))
elif hunks and line.startswith("+") and not line.startswith("+++ "):
hunks[-1][2].append(line[1:])
suggestions = []
total = suggested = 0
for path, after, added in hunks:
count = sum(bool(include.fullmatch(line)) for line in added)
total += count
original = subprocess.run(["git", "-C", head, "show", f"HEAD:{path}"], check=True, capture_output=True,
encoding="utf-8").stdout.splitlines()
line = max(after, 1)
if line > len(original) or not any(first <= line <= last for first, last in ranges[path]):
continue
body = "\n".join([original[line - 1], *added] if after else [*added, original[0]])
# A fence longer than any run of backticks in the line it repeats.
fence = "`" * max([3] + [len(run) + 1 for run in re.findall(r"`+", body)])
suggestions.append({"path": path, "line": line, "side": "RIGHT",
"body": f"{fence}suggestion\n{body}\n{fence}"})
suggested += count
with open(os.path.join(out, "suggestions.json"), "w", encoding="utf-8") as f:
json.dump(suggestions, f)
output(result="ok", includes=total, suggested=suggested)
EOF
- name: Post the fix
if: always() && steps.find.outputs.pr != ''
uses: actions/github-script@v9
env:
PR: ${{ steps.find.outputs.pr }}
MENTION: ${{ steps.find.outputs.mention }}
REQUEST_ID: ${{ steps.find.outputs.request-id }}
HEAD_SHA: ${{ steps.find.outputs.head-sha }}
RESULT: ${{ steps.place.outputs.result }}
INCLUDES: ${{ steps.place.outputs.includes }}
SUGGESTED: ${{ steps.place.outputs.suggested }}
with:
script: |
const fs = require('fs');
const path = require('path');
const MARKER = '<!-- pr-tidy-bot -->';
// GitHub's limit on a review body is 65536 characters.
const MAX_PATCH = 60000;
const { owner, repo } = context.repo;
const { PR, MENTION, REQUEST_ID, HEAD_SHA, RESULT, INCLUDES, SUGGESTED, RUNNER_TEMP } = process.env;
const issue_number = Number(PR);
const sha = HEAD_SHA.slice(0, 10);
const fixUrl = context.payload.workflow_run.html_url;
const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`;
const react = (content) => REQUEST_ID
? github.rest.reactions.createForIssueComment({ owner, repo, comment_id: Number(REQUEST_ID), content })
: Promise.resolve();
async function reply(body, content) {
await react(content);
await github.rest.issues.createComment({ owner, repo, issue_number, body: `${MARKER}\n${MENTION}${body}` });
}
const read = (...parts) => fs.readFileSync(path.join(RUNNER_TEMP, ...parts), 'utf8');
const passes = RESULT && read('clang-tidy-fix', 'status').trim() === '0';
if (RESULT === 'empty') {
return passes
? reply(`clang-tidy passes on ${sha}. There is nothing to fix.`, '+1')
: reply(`clang-tidy has no automatic fix for what it reports on ${sha}. The findings, in the [fix run's log](${fixUrl}), need a manual edit.`, '-1');
}
if (RESULT === 'rejected') {
return reply(`the clang-tidy fix changes more than includes, so I didn't post it. See the [run log](${runUrl}).`, '-1');
}
if (RESULT === 'apply-failed') {
return reply(`the clang-tidy fix doesn't apply to ${sha}. Merge the base branch into the PR, then comment \`/bot fix-tidy\` again.`, '-1');
}
if (RESULT !== 'ok') {
return reply(`something went wrong. See the [run log](${runUrl}).`, '-1');
}
const patch = read('head.patch');
const suggestions = JSON.parse(read('suggestions.json'));
const one = INCLUDES === '1';
const includes = one ? '1 missing include' : `${INCLUDES} missing includes`;
const inline = SUGGESTED === '0'
? (one ? 'It goes on a line this PR does not change, so I can\'t suggest it inline.'
: 'They go on lines this PR does not change, so I can\'t suggest them inline.')
: SUGGESTED === INCLUDES
? (one ? 'It is suggested inline below.' : 'Each is suggested inline below.')
: `${SUGGESTED} of them are suggested inline below; the rest go on lines this PR does not change.`;
const remaining = passes
? `With ${one ? 'it' : 'them'}, clang-tidy passes.`
: `Some findings remain that need a manual edit; see the [fix run's log](${fixUrl}).`;
// A fence longer than any run of backticks in the patch's context lines.
const fence = '`'.repeat(Math.max(3, ...[...patch.matchAll(/`+/g)].map((m) => m[0].length + 1)));
const apply = patch.length > MAX_PATCH
? `The full patch is too large to show here. Download \`fix.patch\` from the [fix run](${fixUrl}) and run \`git apply fix.patch\`.`
: `To apply ${one ? 'it' : 'all of them'}, run this in your checkout of the PR branch:\n\n${fence}sh\ngit apply <<'EOF'\n${patch}EOF\n${fence}`;
const body = (withSuggestions) => `${MARKER}\n${MENTION}clang-tidy's fix for ${sha} adds ${includes}. ` +
`${withSuggestions ? `${inline} ` : ''}${remaining}\n\n${apply}`;
const review = { owner, repo, pull_number: issue_number, commit_id: HEAD_SHA, event: 'COMMENT' };
try {
await github.rest.pulls.createReview({ ...review, body: body(true), comments: suggestions });
} catch (error) {
// A suggestion GitHub will not place still leaves the patch.
core.warning(`Posting the suggestions failed (${error.message}); posting the patch alone.`);
await github.rest.pulls.createReview({ ...review, body: body(false) });
}
await react('+1');
nudge:
if: >-
github.repository == 'OrcaSlicer/OrcaSlicer'
&& github.event_name == 'workflow_run'
&& github.event.workflow_run.name == 'clang-tidy'
&& github.event.workflow_run.event == 'pull_request'
concurrency:
group: ${{ github.workflow }}-nudge-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: false
permissions:
actions: read # the check's steps
pull-requests: write # comment on the PR
issues: write # the comment
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Ask for a fix when the check fails
uses: actions/github-script@v9
with:
script: |
const MARKER = '<!-- pr-tidy-bot-nudge -->';
// The clang-tidy step's name in clang_tidy.yml. A failure in any other
// step is the CI's, not the PR's.
const TIDY_STEP = 'Run clang-tidy on the changed lines';
const { owner, repo } = context.repo;
const run = context.payload.workflow_run;
// workflow_run lists no PR for a fork's branch, so the PR is found by its branch.
const { data: prs } = await github.rest.pulls.list({
owner, repo, state: 'open', head: `${run.head_repository.owner.login}:${run.head_branch}`
});
const pr = prs.find((p) => p.head.sha === run.head_sha);
if (!pr) {
core.info('No open PR at this run\'s commit; a newer run will answer.');
return;
}
const { data: { jobs } } = await github.rest.actions.listJobsForWorkflowRun({ owner, repo, run_id: run.id });
const failed = jobs.some((job) => (job.steps || []).some((step) => step.name === TIDY_STEP && step.conclusion === 'failure'));
const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: pr.number, per_page: 100 });
const previous = comments.find((c) => c.user.type === 'Bot' && (c.body || '').startsWith(MARKER));
if (!failed) {
// Removes the request once the check passes.
if (previous && run.conclusion === 'success') {
await github.rest.issues.deleteComment({ owner, repo, comment_id: previous.id });
}
return;
}
const body = `${MARKER}\n@${pr.user.login} the clang-tidy check found problems in this PR's changes ` +
`at ${run.head_sha.slice(0, 10)} ([log](${run.html_url})). Missing includes can be fixed either way:\n\n` +
'- run `scripts/run_clang_tidy.sh --fix` (`scripts\\run_clang_tidy.ps1 -Fix` on Windows) and push the result, or\n' +
'- comment `/bot fix-tidy`, and I\'ll suggest the fix here for you to apply.\n\n' +
'Other findings, such as a `using` in a header, need a manual edit.';
// Edits the earlier request instead of posting another, so each push does not notify again.
if (previous) {
await github.rest.issues.updateComment({ owner, repo, comment_id: previous.id, body });
} else {
await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body });
}
-131
View File
@@ -1,131 +0,0 @@
# Tooltip illustrations
The texture-displacement panel's tooltips can carry a picture under their text: a still image, or a
short animation that loops while the tooltip is open. Several settings in that panel describe a shape
rather than a number - what Rotation turns, what Tile repeats, what Invert turns inside out - and a
sentence about a shape is read twice and understood once.
## Where a tooltip is drawn
`GLGizmoTextureDisplacement::panel_tooltip()` replaces `ImGuiWrapper::tooltip()` throughout the panel.
It differs in two ways.
It places the tooltip beside the panel instead of under the mouse. The panel is pinned to the right
edge of the canvas, so a tooltip following the mouse covers the control it is explaining, and one
carrying a picture covers most of the panel with it. The tooltip goes to the left of
`m_tooltip_anchor_min`/`_max` - the panel's own screen rectangle, or the texture picker's while that
popup is up and the tooltip comes from inside it - and to the right only when there is no room on the
left. This is the placement the picker popup itself already uses.
ImGui normally keeps a tooltip glued to the mouse and ignores `SetNextWindowPos()`. Orca's imgui
branch honours a position set for a tooltip (see the `ImGuiWindowFlags_Tooltip` branch of `Begin()` in
`deps_src/imgui/imgui.cpp`) and still clamps the result inside the canvas, which is what keeps a tall
tooltip near the bottom edge on screen.
It also gives the tooltip a fixed width rather than one derived from its text, because a picture has
to be laid out against something, and a column of tooltips that are all one width is steadier to read.
## Where the pictures live
`resources/images/texture_displacement/`, as PNG, JPG, BMP or GIF. A tooltip names them by file:
```cpp
hover_tip(_u8L("Repeats the texture across the painted area. ..."),
{ { "tile_off.png", _L("Off") }, { "tile_on.png", _L("On") } });
```
Several in one tooltip are laid out side by side at equal widths, which is how a setting is shown as a
comparison - the same model at two values of it. The caption under each is translated; the file name
is not.
A file that is missing or will not decode is skipped, and a tooltip whose pictures are all missing is
exactly the text tooltip it was before. So the art for a tooltip can land after the tooltip does, and
a half-finished set never breaks the panel.
## When a tooltip appears
A tooltip waits for the pointer to rest on its control for 0.45 s. The panel is dense and the tooltip
lands on the model beside it, so with no wait a sweep from the top of the panel down to Bake flashes a
dozen large panels over the scene. Once one has just been up, the wait drops to 0.1 s for 0.7 s
afterwards, so reading several settings in a row stays quick - but not to zero, or that same sweep
would flash again the moment one tooltip had been seen.
No tooltip appears at all while a control is being used - a slider dragged, a value typed into one, a
combo open, a layer card being dragged. The pointer sits on the very thing being edited, so a tooltip
there covers the model exactly while the user is watching it change. The full wait comes back
afterwards rather than the short one: having just let go of a slider is not a reason to be shown a
picture of it.
A pointer resting on a control produces no events, and the canvas only renders when something asks it
to, so the wait has to ask for its own frames, and so does an animation once one is on screen. So does
a tooltip that has just appeared, animated or not: ImGui hides a window on the first frame it is
submitted while it measures it, and does so every time a tooltip reappears because those windows are
recycled, so the frame a tooltip is decided on is never the frame it is seen on. Both use
`GLCanvas3D::request_extra_frame()`, which ends `on_idle()` with `evt.RequestMore()` and so obliges wx
to deliver the next idle. `schedule_extra_frame()` is not enough on its own: it only arranges a timer
whose event an idle event is assumed to follow, and on_idle renders nothing unless the canvas was
already dirty when it ran. Asking for frames as fast as they come is safe here because which frame is
drawn comes from the clock, not from the frame count, so a redraw that arrives early simply draws the
same frame again.
## Decoding and playback
`slic3r/GUI/AnimatedTexture.hpp` decodes a file once and uploads one GL texture per frame. Stills go
through `wxImage`; GIFs go through `wxGIFDecoder`, whose raw palette indices are composited here frame
by frame, replaying the disposal method each frame asks for. That replay is what makes an animation
look continuous: a GIF frame is a sub-rectangle drawn over what came before it, so an animation that
only re-sends the pixels that changed draws as a flicker of disconnected patches without it.
A frame delay of 0 or 1 hundredths of a second is read as 100 ms, the convention browsers settle on,
so an animation authored against that plays at the speed it was authored at.
The animation restarts when its tooltip opens, rather than running off a free-running clock: these are
short loops of one setting going from one value to another, and joining one halfway through shows the
end of a change the user never saw the start of.
Which frame is drawn is measured on a real clock, not `ImGui::GetTime()`. Nothing in OrcaSlicer ever sets
`io.DeltaTime`, so it keeps imgui's default of 1/60 per frame and imgui's clock counts frames rather
than seconds. On a canvas that renders only when asked, that is not a clock at all: an animation driven
by it crawls while the canvas is idle and races while a moving mouse is rendering it flat out.
## Making one
`scripts/gen_texture_displacement_tips.py` draws the lot, in four languages, picked by what the
setting actually is:
- **A shaded relief**, for the settings that are about a pattern: Depth, Tile, Tile size, Rotation,
Repeat/Mirror, Smoothing, Edge fade, Invert, Blend and the Normal, Fast, Checker and Distortion
views. One motif - a ring inside a square frame - runs through all of them, because using one shape
everywhere is what lets two pictures be compared: whatever differs between them is the setting,
since nothing else is.
- **A flat mesh of triangles**, for the settings that are about the mesh or about a tool: the paint
tools, brush size, the connected-area fill, subdivision, feature-adaptive refinement, remeshing,
hard edges, slivers and the wireframe view. These carry the one accent colour in the set, meaning
"painted", because that is the only thing in such a picture that is not geometry.
- **A section**, for Midlevel and for smoothing the geometry. The reason generalises: shading reads
slopes, so a height field moved up or down as a whole shades identically to where it started, and
any setting whose meaning is *where the surface sits relative to where it was* is invisible from
above and has to be drawn from the side.
- **A flat ramp of greys**, for Mix filaments, Mixed colors and Denoise. Those are about which
filament a spot ends up as; lighting one would only add a second thing changing between two
pictures meant to differ in one.
The five Mapping pictures are one ball with a checker laid on it five ways, which is the whole of what
choosing a projection does - except Unwrap, which is not a projection onto the model at all and so is
drawn as the flat layout it produces.
Captures of the real tool are still the first choice for anything involving a real model or real
filament colour: Bake, the mixed-colour count on a photograph, Adjust placement, the frame window and
the UV editor's seam tool have no picture yet for that reason.
Either way:
- Square, around 256 px (192 for an animation, where every frame ships). The tooltip is about 20 em
wide, so that is roughly native size on a 100% display and downscales cleanly above it. A single
picture is capped at 10 em tall and centred, two side by side take half the width each.
- Neutral grey. These say what a shape does; colour in one reads as a third thing the setting does,
and has to work against both themes.
- Animations loop seamlessly and stay short - 16 frames at 100 ms, under about 150 KB. They sit in
`resources/`, which ships with every copy of the application. A sweep driven by a cosine over the
frame count loops with no step, since it starts and ends at the same value and the same rate.
- No text in the picture. Captions are translated; pixels are not.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

-793
View File
@@ -1,793 +0,0 @@
#!/usr/bin/env python3
"""Draws the illustrations the texture-displacement tooltips show.
The panel's tooltips can carry a picture under their text (see GLGizmoTextureDisplacement's
panel_tooltip()). A good many of those settings - the ones that are a shape rather than a photograph -
are better drawn from the same arithmetic the feature uses than captured from the screen, which is
what this script does: it shades a height field exactly as a relief would catch the light, so
"what Rotation turns" and "what Tile repeats" are visible rather than described.
Settings that involve the model, the mesh or colour are not here: those want a capture of the real
tool. See docs/HLSD/tooltip-illustrations.md.
Output goes to resources/images/texture_displacement/. Run it after changing one of the patterns
below; nothing in the build depends on it.
python3 scripts/gen_texture_displacement_tips.py
Needs numpy and pillow.
"""
import os
import numpy as np
from PIL import Image, ImageDraw
SIZE = 256 # px, square - the panel draws these at whatever width the tooltip has
GIF_SIZE = 192 # animations are the same picture, smaller: every frame of one ships in resources/
RELIEF = 30.0 # how hard the shading reacts to a slope; purely cosmetic
OUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "resources", "images", "texture_displacement")
# Neutral enough to sit in a tooltip in either theme: a dark ground, a mid grey for the flat surface
# and a cool near-white for the lit slopes.
SHADOW = np.array([0.13, 0.14, 0.15])
MIDTONE = np.array([0.40, 0.42, 0.43])
HIGHLIGHT = np.array([0.78, 0.84, 0.83])
LIGHT = np.array([-0.45, -0.62, 0.65])
LIGHT /= np.linalg.norm(LIGHT)
def grid(size=SIZE):
"""Unit coordinates, -0.5 .. 0.5, y down as in an image."""
a = (np.arange(size) + 0.5) / size - 0.5
return np.meshgrid(a, a)
def shade(height):
"""A height field in 0..1, lit as a relief: the picture the tooltip shows."""
# The slope has to mean the same thing at either size, so the gradient is taken per unit of the
# picture rather than per pixel - otherwise the smaller animations would come out twice as flat.
gy, gx = np.gradient(height * RELIEF * (height.shape[0] / float(SIZE)))
normal = np.dstack([-gx, -gy, np.ones_like(height)])
normal /= np.linalg.norm(normal, axis=2, keepdims=True)
lam = np.clip(normal @ LIGHT, 0.0, 1.0)
# Two ramps rather than one: the dark half separates the slopes, the lit half picks up the
# accent, and the flat ground lands in the middle of both.
low = SHADOW + (MIDTONE - SHADOW) * (lam / 0.5)[..., None]
high = MIDTONE + (HIGHLIGHT - MIDTONE) * ((lam - 0.5) / 0.5)[..., None]
rgb = np.where((lam < 0.5)[..., None], low, high)
# A touch of the height itself, so a tall feature still reads as tall where it is flat on top.
#
# Deliberately almost grey: these say what a shape does, and a relief catching the light is grey.
# A coloured one would read as a third thing the setting does, and the amount of colour would move
# with the slope - a pattern at 45 degrees would flash where the same pattern upright did not.
rgb = np.clip(rgb * (0.88 + 0.18 * height[..., None]), 0.0, 1.0)
return (rgb * 255.0 + 0.5).astype(np.uint8)
def vignette(rgb):
"""Rounds the picture off at its border, so it does not end in a hard rectangle."""
x, y = grid(rgb.shape[0])
edge = np.clip((0.5 - np.maximum(np.abs(x), np.abs(y))) / 0.045, 0.0, 1.0)
return (rgb * edge[..., None]).astype(np.uint8)
def save_png(name, rgb):
path = os.path.join(OUT_DIR, name)
Image.fromarray(vignette(rgb)).save(path, optimize=True)
print("wrote", os.path.normpath(path), os.path.getsize(path) // 1024, "KB")
def save_gif(name, frames, delay_ms):
path = os.path.join(OUT_DIR, name)
images = [Image.fromarray(vignette(f)).convert("P", palette=Image.ADAPTIVE, colors=24) for f in frames]
images[0].save(path, save_all=True, append_images=images[1:], duration=delay_ms, loop=0, optimize=True)
print("wrote", os.path.normpath(path), os.path.getsize(path) // 1024, "KB")
def smoothstep(edge0, edge1, x):
t = np.clip((x - edge0) / (edge1 - edge0), 0.0, 1.0)
return t * t * (3.0 - 2.0 * t)
def blur(a, radius):
"""Separable box blur, run twice - near enough a gaussian for a picture of a blur."""
for _ in range(2):
for axis in (0, 1):
padded = np.pad(a, [(radius, radius) if i == axis else (0, 0) for i in range(2)], mode="edge")
cumulative = np.cumsum(padded, axis=axis)
cumulative = np.concatenate([np.zeros_like(np.take(cumulative, [0], axis=axis)), cumulative], axis=axis)
n = a.shape[axis]
hi = np.take(cumulative, np.arange(n) + 2 * radius + 1, axis=axis)
lo = np.take(cumulative, np.arange(n), axis=axis)
a = (hi - lo) / (2.0 * radius + 1.0)
return a
# --- the patterns ------------------------------------------------------------------------------
#
# One motif runs through nearly all of them - a ring inside a square frame. Using the same shape for
# every setting is what lets the pictures be compared: whatever changes between two of them is the
# setting, because nothing else is different.
def _motif_cell(u, v):
"""The motif within one cell, u and v in -0.5 .. 0.5."""
r = np.hypot(u, v)
ring = np.clip(1.0 - np.abs(r - 0.26) / 0.085, 0.0, 1.0)
frame_d = np.maximum(np.abs(u), np.abs(v))
frame = np.clip(1.0 - np.abs(frame_d - 0.42) / 0.055, 0.0, 1.0)
return np.clip(np.maximum(ring, frame) ** 0.7, 0.0, 1.0)
def motif(repeats, size=SIZE):
"""The motif laid out `repeats` times across the picture."""
x, y = grid(size)
return _motif_cell((x * repeats) % 1.0 - 0.5, (y * repeats) % 1.0 - 0.5)
def decal(motif_scale=0.46):
"""One copy of the motif in the middle of a flat field - what Tile off leaves."""
x, y = grid()
u, v = x / motif_scale, y / motif_scale
inside = (np.maximum(np.abs(u), np.abs(v)) <= 0.5).astype(float)
return _motif_cell(u, v) * inside
def ridges(angle_deg, period=0.16, size=SIZE):
"""Parallel ridges at an angle: the simplest pattern whose direction is unmistakable."""
x, y = grid(size)
a = np.radians(angle_deg)
t = (x * np.cos(a) + y * np.sin(a)) / period
return 0.2 + 0.6 * (0.5 + 0.5 * np.sin(2.0 * np.pi * t))
def bosses(repeats, size=SIZE):
"""Rounded domes standing on a flat field - a shape with an unmistakable right way up."""
x, y = grid(size)
u = (x * repeats) % 1.0 - 0.5
v = (y * repeats) % 1.0 - 0.5
return smoothstep(0.36, 0.08, np.hypot(u, v))
def chevron_tiles(mirrored, repeats=3):
"""Cells of diagonal ridges, laid out repeated or mirrored.
Repeated, every cell starts the pattern over and the seam between two of them is a step; mirrored,
each cell is the reflection of its neighbour and the ridges meet across the seam as chevrons. That
seam is the whole of what the Repeat/Mirror pair does, and a motif symmetric in itself would hide
it entirely.
"""
def axis(t):
if not mirrored:
return (t * repeats) % 1.0
s = (t * repeats) % 2.0
return np.where(s < 1.0, s, 2.0 - s)
x, y = grid()
t = (axis(x + 0.5) + axis(y + 0.5)) * 1.5
return 0.22 + 0.56 * (0.5 + 0.5 * np.cos(2.0 * np.pi * t))
def painted_area(size=SIZE, radius=0.44):
"""A round painted area in the middle of bare surface, with a soft edge so it does not alias."""
x, y = grid(size)
return smoothstep(radius, radius - 1.5 / size, np.hypot(x, y))
def painted_patch(fade, size=SIZE):
"""The motif inside a painted area, either stopping at its edge or fading into it."""
x, y = grid(size)
r = np.hypot(x, y) / 0.44
amount = smoothstep(1.0, 0.55, r) if fade else 1.0
return motif(3, size) * painted_area(size) * amount
def profile_wave(size=SIZE, periods=2.5):
"""A one-dimensional height profile with clear highs, lows and a flat stretch between them."""
t = (np.arange(size) + 0.5) / size * periods * 2.0 * np.pi
h = 0.5 + 0.34 * np.cos(t) + 0.16 * np.cos(2.0 * t + 0.9)
return np.clip((h - h.min()) / (h.max() - h.min()), 0.0, 1.0)
def profile_view(height_1d, midlevel, amplitude=0.26, size=SIZE):
"""A side view of the surface: material below, air above, and the bare surface as a dashed line.
Shading cannot say how high something is - it reads slopes, and a height field moved up or down as
a whole has exactly the slopes it had before. Midlevel is entirely about where the texture sits
relative to the surface it is on, so it is drawn in section, where that is the one thing visible.
"""
base = 0.52 * size
surface = base - (height_1d - midlevel) * amplitude * size
rows = np.arange(size)[:, None]
covered = np.clip(rows - surface[None, :] + 0.5, 0.0, 1.0) # 1 where there is material
air = np.array([0.11, 0.12, 0.13])
material = np.array([0.44, 0.46, 0.47])
rgb = air * (1.0 - covered)[..., None] + material * covered[..., None]
# The lit top face, and the bare surface the texture is measured against.
edge = np.clip(1.0 - np.abs(rows - surface[None, :]) / 1.6, 0.0, 1.0)
rgb = rgb * (1.0 - edge[..., None]) + np.array([0.80, 0.86, 0.85]) * edge[..., None]
dash = ((np.arange(size) // 7) % 2 == 0)[None, :]
line = (np.abs(rows - base) < 0.9) & dash
rgb[line] = np.array([0.58, 0.62, 0.62])
return (np.clip(rgb, 0.0, 1.0) * 255.0 + 0.5).astype(np.uint8)
def dome(size=SIZE):
"""One broad swell filling the picture - the layer underneath, for the blend pictures."""
x, y = grid(size)
return smoothstep(0.52, 0.02, np.hypot(x, y))
# --- meshes, cursors and sections ---------------------------------------------------------------
#
# The second family of pictures. The settings above are about a pattern, and a shaded relief is what a
# pattern looks like; these are about a tool or about the mesh, where what matters is which triangles
# exist and which of them are painted. So they are drawn flat, the way the panel's own wireframe and
# paint overlays draw them, with one accent colour meaning "painted" and nothing else coloured.
SS = 3 # supersampling: PIL fills polygons without antialiasing, so draw big and shrink
INK = (0.62, 0.67, 0.67) # mesh edges
BARE = (0.28, 0.30, 0.31) # unpainted surface
PAINT = (0.20, 0.47, 0.45) # painted with the active layer, in the panel's own accent
CURSOR = (0.92, 0.95, 0.95)
MARGIN = 0.05 # keeps the mesh off the picture's border, where the vignette darkens it
def _px(p, size):
"""Unit coordinates to pixels, inside the margin."""
return ((MARGIN + p[0] * (1.0 - 2.0 * MARGIN)) * size, (MARGIN + p[1] * (1.0 - 2.0 * MARGIN)) * size)
def _rgb255(c):
return tuple(int(round(v * 255.0)) for v in c)
def raster(draw_fn, size=SIZE, background=BARE):
"""Runs draw_fn(draw, size_in_pixels) on a supersampled canvas and hands back the shrunk result.
`background` is a colour, or a picture already drawn by the relief half of this script - which is
how a mesh is shown sitting on the surface it refines.
"""
if isinstance(background, np.ndarray):
image = Image.fromarray(background).resize((size * SS, size * SS), Image.BILINEAR)
else:
image = Image.new("RGB", (size * SS, size * SS), _rgb255(background))
draw_fn(ImageDraw.Draw(image), size * SS)
return np.asarray(image.resize((size, size), Image.LANCZOS)).astype(np.uint8)
def tri_mesh(n, jitter=0.0, seed=1):
"""A square patch of triangles, optionally with its inner vertices nudged off the grid.
Jitter is not decoration: a perfectly regular grid reads as graph paper, and the point of these
pictures is a mesh over a surface.
"""
rng = np.random.default_rng(seed)
v = np.stack(np.meshgrid(np.linspace(0.0, 1.0, n + 1), np.linspace(0.0, 1.0, n + 1)), axis=-1)
if jitter:
v[1:n, 1:n] += (rng.random((n - 1, n - 1, 2)) - 0.5) * (jitter / n)
tris = []
for i in range(n):
for j in range(n):
tris.append((v[i, j], v[i, j + 1], v[i + 1, j + 1]))
tris.append((v[i, j], v[i + 1, j + 1], v[i + 1, j]))
return tris
def centroids(tris):
return np.array([np.mean(t, axis=0) for t in tris])
def neighbours(tris):
"""Which triangles share an edge with which, keyed on the rounded vertex positions."""
edges = {}
for i, t in enumerate(tris):
for a, b in ((0, 1), (1, 2), (2, 0)):
key = tuple(sorted((tuple(np.round(t[a], 6)), tuple(np.round(t[b], 6)))))
edges.setdefault(key, []).append(i)
out = [[] for _ in tris]
for shared in edges.values():
if len(shared) == 2:
out[shared[0]].append(shared[1])
out[shared[1]].append(shared[0])
return out
def draw_mesh(draw, size, tris, painted=None, edge=INK, edge_width=0.7):
fill_bare, fill_paint = _rgb255(BARE), _rgb255(PAINT)
for i, t in enumerate(tris):
draw.polygon([_px(p, size) for p in t],
fill=fill_paint if painted is not None and painted[i] else fill_bare,
outline=_rgb255(edge), width=max(1, int(edge_width * SS)))
def draw_brush(draw, size, centre, radius, filled=False):
"""The brush cursor: a ring, which is how the gizmo draws it on the model."""
c = _px(centre, size)
r = radius * (1.0 - 2.0 * MARGIN) * size
if filled:
draw.ellipse([c[0] - r, c[1] - r, c[0] + r, c[1] + r], fill=(255, 255, 255, 40))
draw.ellipse([c[0] - r, c[1] - r, c[0] + r, c[1] + r], outline=_rgb255(CURSOR), width=max(1, int(1.6 * SS)))
def draw_pointer(draw, size, at):
"""A small arrow, for the tools that are clicked rather than dragged."""
x, y = _px(at, size)
k = 0.055 * size
draw.polygon([(x, y), (x, y + 1.45 * k), (x + 0.38 * k, y + 1.05 * k),
(x + 0.62 * k, y + 1.55 * k), (x + 0.90 * k, y + 1.40 * k),
(x + 0.66 * k, y + 0.92 * k), (x + 1.05 * k, y + 0.84 * k)],
fill=_rgb255(CURSOR), outline=(30, 32, 34), width=max(1, int(0.9 * SS)))
def stroke_path(t):
"""The path a demonstrated brush stroke follows, t in 0..1."""
return np.array([0.14 + 0.72 * t, 0.46 + 0.22 * np.sin(2.0 * np.pi * t * 1.1)])
def brush_frames(size, erase, steps=14, radius=0.115, hold=3):
tris = tri_mesh(9, jitter=0.55)
cents = centroids(tris)
painted = np.ones(len(tris), bool) if erase else np.zeros(len(tris), bool)
frames = []
for k in range(steps):
centre = stroke_path(k / (steps - 1.0))
hit = np.linalg.norm(cents - centre, axis=1) < radius
painted = painted & ~hit if erase else painted | hit
state = painted.copy()
frames.append(raster(lambda d, s, st=state, c=centre: (draw_mesh(d, s, tris, st), draw_brush(d, s, c, radius)), size))
frames += [frames[-1]] * hold
return frames
def face_frames(size, steps=9, hold=3):
"""The Face tool: one triangle per click, under a pointer."""
tris = tri_mesh(7, jitter=0.5, seed=4)
cents = centroids(tris)
order = np.argsort(np.linalg.norm(cents - np.array([0.5, 0.5]), axis=1))[:steps]
painted = np.zeros(len(tris), bool)
frames = []
for i in order:
painted[i] = True
state = painted.copy()
at = cents[i]
frames.append(raster(lambda d, s, st=state, a=at: (draw_mesh(d, s, tris, st), draw_pointer(d, s, a)), size))
frames += [frames[-1]] * hold
return frames
def flood_frames(size, creases, steps=12, hold=3, seed=7):
"""The Connected area tool: a flood from one click, stopped by the creases it may not cross.
`creases` is a list of x positions; a triangle pair straddling one cannot pass the fill between
them, which is what an edge sharper than the angle threshold does on the model.
"""
tris = tri_mesh(9, jitter=0.5, seed=seed)
cents = centroids(tris)
adj = neighbours(tris)
blocked = lambda i, j: any((cents[i][0] - x) * (cents[j][0] - x) < 0.0 for x in creases)
seed_tri = int(np.argmin(np.linalg.norm(cents - np.array([0.22, 0.5]), axis=1)))
painted = np.zeros(len(tris), bool)
painted[seed_tri] = True
frontier = [seed_tri]
frames = []
for _ in range(steps):
state = painted.copy()
frames.append(raster(lambda d, s, st=state: (draw_mesh(d, s, tris, st),
draw_creases(d, s, creases),
draw_pointer(d, s, cents[seed_tri])), size))
nxt = []
for i in frontier:
for j in adj[i]:
if not painted[j] and not blocked(i, j):
painted[j] = True
nxt.append(j)
frontier = nxt
frames += [frames[-1]] * hold
return frames
def draw_creases(draw, size, creases):
"""A fold too sharp for the fill to cross: drawn dark with a bright crest, like a real one."""
for x in creases:
a, b = _px((x, -0.02), size), _px((x, 1.02), size)
draw.line([a, b], fill=_rgb255((0.08, 0.09, 0.10)), width=max(1, int(4.0 * SS)))
draw.line([a, b], fill=_rgb255((0.97, 0.99, 0.99)), width=max(1, int(1.4 * SS)))
def split4(tri):
a, b, c = tri
ab, bc, ca = (a + b) * 0.5, (b + c) * 0.5, (c + a) * 0.5
return [(a, ab, ca), (ab, b, bc), (ca, bc, c), (ab, bc, ca)]
def refine(tris, density, max_depth, threshold=0.45):
"""Splits a triangle into four wherever `density` at its middle asks for it, up to max_depth."""
def go(tri, depth):
if depth >= max_depth:
return [tri]
centre = np.mean(tri, axis=0)
if density(centre[0], centre[1]) < threshold:
return [tri]
return [out for part in split4(tri) for out in go(part, depth + 1)]
return [out for tri in tris for out in go(tri, 0)]
def sampler(field):
"""Looks a numpy field up at unit coordinates, for use as a density."""
n = field.shape[0]
return lambda x, y: float(field[int(np.clip(y * n, 0, n - 1)), int(np.clip(x * n, 0, n - 1))])
def feature_density(size=192):
"""High where the pattern bends, which is where feature-adaptive refinement spends triangles.
One large copy of the motif rather than a field of them: the point is that the triangles gather
somewhere and not elsewhere, and a pattern that bends everywhere has no elsewhere to show.
"""
m = motif(1, size)
gy, gx = np.gradient(m)
g = blur(np.hypot(gx, gy), 2)
return g / max(g.max(), 1e-6)
def irregular_mesh(seed=3):
"""The uneven mesh a model arrives with: coarse here, fine there, nothing of one size."""
rng = np.random.default_rng(seed)
tris = tri_mesh(4, jitter=0.9, seed=seed)
out = []
for t in tris:
out.extend(split4(t) if rng.random() < 0.45 else [t])
final = []
for t in out:
final.extend(split4(t) if rng.random() < 0.3 else [t])
return final
def snap_to_crease(tris, crease, distance=0.07):
"""Pulls every vertex near the crease onto it, so mesh edges run along it instead of across."""
(x0, y0), (x1, y1) = crease
d = np.array([x1 - x0, y1 - y0], float)
d /= np.linalg.norm(d)
def snap(p):
t = np.dot(p - np.array([x0, y0]), d)
foot = np.array([x0, y0]) + t * d
return foot if np.linalg.norm(p - foot) < distance else p
return [tuple(snap(np.asarray(v, float)) for v in t) for t in tris]
def sliver_mesh(seed=5):
"""A mesh carrying the thin triangles refinement inherits from a model's own triangulation."""
rng = np.random.default_rng(seed)
tris = tri_mesh(7, jitter=0.4, seed=seed)
out = []
for t in tris:
if rng.random() < 0.45:
a, b, c = (np.asarray(v, float) for v in t)
mid = (b + c) * 0.5
a = mid + (a - mid) * 0.08 # all but flattened onto the opposite edge
out.append((a, b, c))
else:
out.append(t)
return out
def draw_profile(draw, size, height_1d, baseline=0.62, amplitude=0.30):
"""A side view of a surface, as a filled region under its profile."""
n = len(height_1d)
top = [(i / (n - 1.0) * size, (baseline - (height_1d[i] - 0.5) * amplitude) * size) for i in range(n)]
draw.polygon(top + [(size, size * 1.1), (0, size * 1.1)], fill=_rgb255((0.44, 0.46, 0.47)))
draw.line(top, fill=_rgb255((0.82, 0.88, 0.87)), width=max(1, int(1.8 * SS)))
def bayer(size, n=4):
"""An ordered dither matrix tiled over the picture, for the mixing illustrations."""
m = np.array([[0]])
while m.shape[0] < n:
m = np.block([[4 * m, 4 * m + 2], [4 * m + 3, 4 * m + 1]])
m = (m + 0.5) / m.size
return np.tile(m, (size // m.shape[0] + 1, size // m.shape[1] + 1))[:size, :size]
def levels_image(levels, dither=False, speckle=0.0, size=SIZE, seed=11):
"""A smooth ramp reduced to a handful of greys - what a bake does to a photograph's colours.
Flat rather than lit: this family of settings is about which filament a pixel ends up as, and a
relief would only add a second thing changing between two pictures that are meant to differ in
one.
"""
x, y = grid(size)
ramp = np.clip(0.5 + 0.5 * np.cos(2.0 * np.pi * (x + 0.25 * y)), 0.0, 1.0)
noise = bayer(size) - 0.5 if dither else 0.0
q = np.round(np.clip(ramp + noise / max(levels - 1, 1), 0.0, 1.0) * (levels - 1)) / (levels - 1)
if speckle > 0.0:
rng = np.random.default_rng(seed)
hit = rng.random((size, size)) < speckle
q = np.where(hit, rng.integers(0, levels, (size, size)) / (levels - 1), q)
grey = 0.16 + 0.62 * q
return (np.dstack([grey, grey * 1.03, grey * 1.02]).clip(0.0, 1.0) * 255.0 + 0.5).astype(np.uint8)
def draw_mesh_lines(draw, size, tris, colour=(0.90, 0.94, 0.94), width=0.8):
"""Only the edges, for drawing a mesh over a picture of the surface it sits on."""
for t in tris:
pts = [_px(p, size) for p in t]
draw.line(pts + [pts[0]], fill=_rgb255(colour), width=max(1, int(width * SS)))
def draw_line(draw, size, segment, colour=(0.97, 0.99, 0.99)):
a, b = _px(segment[0], size), _px(segment[1], size)
draw.line([a, b], fill=_rgb255((0.08, 0.09, 0.10)), width=max(1, int(4.0 * SS)))
draw.line([a, b], fill=_rgb255(colour), width=max(1, int(1.4 * SS)))
def posterize(rgb, levels):
"""The shaded picture reduced to flat bands - what the fast preview's per-fragment quantizing
does to a surface that the real preview shades smoothly."""
v = rgb.astype(float) / 255.0
return (np.round(v * (levels - 1)) / (levels - 1) * 255.0 + 0.5).astype(np.uint8)
def with_checker(rgb, squares=10, strength=0.30, warp=False):
"""A checker laid over the surface, evenly or stretched.
Even squares are what the Checker view is for - a texture laid out without stretching. The warped
version is the same squares through a mapping that does stretch, which is what the Distortion view
is there to find: cells crowded in one place and pulled apart in another.
"""
size = rgb.shape[0]
x, y = grid(size)
u, v = (np.sign(x) * np.abs(2.0 * x) ** 2.4 * 0.5, y * (1.0 + 0.9 * np.abs(x))) if warp else (x, y)
cells = (np.floor((u + 0.5) * squares) + np.floor((v + 0.5) * squares)) % 2.0
return (rgb * (1.0 - strength * cells)[..., None]).astype(np.uint8)
def projected_sphere(kind, size=SIZE, squares=9):
"""A ball with a checker laid on it by one of the panel's projections.
One shape, five mappings: what differs between the pictures is only where the squares go, which is
the whole of what choosing a projection does. A sphere is the shape that shows it - every one of
these mappings is well behaved on a flat face and tells itself apart on a curved one.
"""
a = (np.arange(size) + 0.5) / size * 2.24 - 1.12
x, y = np.meshgrid(a, a)
inside = x * x + y * y <= 1.0
z = np.sqrt(np.clip(1.0 - x * x - y * y, 0.0, 1.0))
lam = np.clip(x * LIGHT[0] + y * LIGHT[1] + z * LIGHT[2], 0.0, 1.0)
rgb = SHADOW + (HIGHLIGHT - SHADOW) * (lam ** 0.85)[..., None]
around = np.arctan2(x, z) / (2.0 * np.pi) + 0.5
if kind == "sphere":
u, v = around, np.arccos(np.clip(-y, -1.0, 1.0)) / np.pi
elif kind == "cylinder":
u, v = around, (y + 1.0) * 0.5
elif kind == "view":
u, v = (x + 1.0) * 0.5, (y + 1.0) * 0.5
elif kind == "cube":
axis = np.argmax(np.abs(np.dstack([x, y, z])), axis=2)
u = np.where(axis == 0, z, x)
v = np.where(axis == 1, z, y)
u, v = (u + 1.0) * 0.5, (v + 1.0) * 0.5
else:
raise ValueError(kind)
cells = (np.floor(u * squares * 2.0) + np.floor(v * squares)) % 2.0
rgb = rgb * (1.0 - 0.34 * cells)[..., None]
rgb = np.where(inside[..., None], rgb, np.array([0.13, 0.14, 0.15]))
return (np.clip(rgb, 0.0, 1.0) * 255.0 + 0.5).astype(np.uint8)
def uv_islands(size=SIZE, squares=9, seed=12):
"""The flat layout an unwrap produces: the surface cut into islands and laid out side by side.
Not a projection onto the model like the other four, so not drawn on one - an unwrap is the model
carrying its own coordinates, and this is what those look like.
"""
rng = np.random.default_rng(seed)
x, y = grid(size)
cells = (np.floor((x + 0.5) * squares * 2.0) + np.floor((y + 0.5) * squares)) % 2.0
checker = (np.array([0.40, 0.43, 0.43]) + np.array([0.22, 0.24, 0.24]) * (1.0 - cells)[..., None])
def blob(centre, radius, lobes):
angles = np.linspace(0.0, 2.0 * np.pi, 26, endpoint=False)
wobble = 1.0 + 0.22 * np.sin(lobes * angles + rng.random() * 6.0)
return [(centre[0] + np.cos(t) * radius * w, centre[1] + np.sin(t) * radius * w)
for t, w in zip(angles, wobble)]
islands = [blob((0.31, 0.32), 0.21, 3), blob((0.72, 0.30), 0.17, 4),
blob((0.34, 0.74), 0.17, 4), blob((0.72, 0.71), 0.20, 3)]
mask = np.asarray(raster(lambda d, px: [d.polygon([(p[0] * px, p[1] * px) for p in island], fill=(255, 255, 255))
for island in islands],
size, background=(0.0, 0.0, 0.0))).astype(float)[..., 0] / 255.0
rgb = checker * mask[..., None] + np.array([0.13, 0.14, 0.15]) * (1.0 - mask)[..., None]
out = (np.clip(rgb, 0.0, 1.0) * 255.0 + 0.5).astype(np.uint8)
return raster(lambda d, px: [d.polygon([(p[0] * px, p[1] * px) for p in island], outline=_rgb255(INK),
width=max(1, int(1.1 * SS)))
for island in islands],
size, background=out)
def main():
os.makedirs(OUT_DIR, exist_ok=True)
# Tile: one copy against the pattern filling the painted area.
save_png("tile_off.png", shade(decal()))
save_png("tile_on.png", shade(motif(3)))
# Tile size: how wide one copy is.
save_png("tile_size_large.png", shade(motif(2)))
save_png("tile_size_small.png", shade(motif(5)))
# Repeat against Mirror, where the seam between two copies is the whole point.
save_png("tile_repeat.png", shade(chevron_tiles(mirrored=False)))
save_png("tile_mirror.png", shade(chevron_tiles(mirrored=True)))
# Invert: the negative of the image. Domes rather than the ring motif, which is close enough to
# its own negative that inverting it would look like nothing had happened.
save_png("invert_off.png", shade(bosses(3)))
save_png("invert_on.png", shade(1.0 - bosses(3)))
# Smoothing: a stepped image against the same one blurred before it is used. Domes again, where
# the steps show up as contour rings - what a photograph with too few grey levels does.
stepped = np.floor(bosses(3) * 5.0) / 4.0
save_png("smoothing_off.png", shade(np.clip(stepped, 0.0, 1.0)))
save_png("smoothing_on.png", shade(np.clip(blur(stepped, max(2, SIZE // 40)), 0.0, 1.0)))
# Edge fade: relief that stops at the edge of the painted area against relief that sinks into it.
save_png("edge_fade_off.png", shade(painted_patch(fade=False)))
save_png("edge_fade_on.png", shade(painted_patch(fade=True)))
# Blend: piling relief on top of the layer below against using it as a mask over it. Add keeps
# both shapes, Multiply keeps the motif only where the layer under it is high.
base = dome()
save_png("blend_add.png", shade(np.clip(0.55 * base + 0.42 * motif(3), 0.0, 1.0)))
save_png("blend_multiply.png", shade(base * motif(3)))
# Rotation: a half turn brings parallel ridges back onto themselves, so a single sweep loops with
# no jump and the file stays a fraction of the size a there-and-back one would be.
steps = 16
save_gif("rotation.gif", [shade(ridges(180.0 * i / steps, size=GIF_SIZE)) for i in range(steps)], delay_ms=100)
# Depth and Midlevel are both about where the relief sits relative to the surface it is on, so
# both are drawn as a painted area in the middle of bare surface, which is that reference. Without
# it neither would show: shading reads slopes, and a height field that moves up or down as a whole
# has exactly the same slopes it had before.
#
# A cosine over the frame count starts and ends at the same value with no step in between, so a
# single pass through it loops.
area = painted_area(GIF_SIZE)
pattern = motif(3, GIF_SIZE)
sweep = [0.5 - 0.5 * np.cos(2.0 * np.pi * i / steps) for i in range(steps)]
# Depth: the relief growing out of the surface and settling back flush with it.
frames = [shade(np.clip(0.5 + 0.45 * pattern * (0.08 + 0.92 * t) * area, 0.0, 1.0)) for t in sweep]
save_gif("depth.gif", frames, delay_ms=100)
# Midlevel: the grey that stays put, sweeping from "the whole texture pushes out" through
# "mid-grey stays where the surface is" to "the whole of it cuts in". Drawn in section - see
# profile_view() for why this one is not a relief like the rest.
wave = profile_wave(GIF_SIZE)
frames = [profile_view(wave, t, size=GIF_SIZE) for t in sweep]
save_gif("midlevel.gif", frames, delay_ms=100)
# The paint tools, each shown doing the one thing that tells it apart from the others.
save_gif("paint.gif", brush_frames(GIF_SIZE, erase=False), delay_ms=110)
save_gif("erase.gif", brush_frames(GIF_SIZE, erase=True), delay_ms=110)
save_gif("tool_face.gif", face_frames(GIF_SIZE), delay_ms=180)
save_gif("tool_smart_fill.gif", flood_frames(GIF_SIZE, creases=[0.63]), delay_ms=110)
# Brush size: the ring, and what it covers, over one size and back.
brush_tris = tri_mesh(9, jitter=0.55, seed=2)
brush_cents = centroids(brush_tris)
frames = []
for i in range(steps):
r = 0.07 + 0.16 * (0.5 - 0.5 * np.cos(2.0 * np.pi * i / steps))
under = np.linalg.norm(brush_cents - np.array([0.5, 0.5]), axis=1) < r
frames.append(raster(lambda d, sz, u=under, rr=r: (draw_mesh(d, sz, brush_tris, u),
draw_brush(d, sz, (0.5, 0.5), rr)), GIF_SIZE))
save_gif("cursor_size.gif", frames, delay_ms=90)
# Angle threshold: the same fill, let past one fold and then past both.
for name, creases in (("smart_fill_angle_low.png", [0.40, 0.70]), ("smart_fill_angle_high.png", [0.70])):
done = flood_frames(SIZE, creases=creases, steps=22, hold=0)[-1]
save_png(name, done)
# Whole model: every face painted, and every face cleared.
whole = tri_mesh(9, jitter=0.55, seed=2)
save_png("select_all.png", raster(lambda d, sz: draw_mesh(d, sz, whole, np.ones(len(whole), bool)), SIZE))
save_png("erase_all.png", raster(lambda d, sz: draw_mesh(d, sz, whole, np.zeros(len(whole), bool)), SIZE))
# The views the panel offers over the painted area.
relief = shade(motif(3))
save_png("view_fast.png", posterize(relief, 3))
# Checker and Distortion are about how the texture is laid out, not about the texture, so they go
# on a plain swell where the checker itself is what there is to look at.
plain = shade(0.25 + 0.5 * dome())
save_png("view_checker.png", with_checker(plain, squares=7, strength=0.40))
save_png("view_distortion.png", with_checker(plain, squares=7, strength=0.40, warp=True))
save_png("view_wireframe.png", raster(lambda d, sz: draw_mesh_lines(d, sz, tri_mesh(9, jitter=0.55, seed=2)),
SIZE, background=relief))
# The projections: one ball, the checker put on it five different ways.
for kind in ("cube", "cylinder", "sphere", "view"):
save_png("map_%s.png" % kind, projected_sphere(kind))
save_png("map_unwrap.png", uv_islands())
# Refinement: where the triangles go, and how many.
density_feature = sampler(feature_density())
coarse = tri_mesh(3)
save_png("adaptive_off.png", raster(lambda d, sz: draw_mesh(d, sz, refine(coarse, lambda x, y: 1.0, 2)), SIZE))
save_png("adaptive_on.png", raster(lambda d, sz: draw_mesh(d, sz, refine(coarse, density_feature, 3)), SIZE))
disc = sampler(painted_area(192, radius=0.34))
painted_mesh = refine(coarse, disc, 3)
save_png("painted_only_on.png",
raster(lambda d, sz: draw_mesh(d, sz, painted_mesh,
np.array([disc(*np.mean(t, axis=0)) > 0.5 for t in painted_mesh])), SIZE))
everywhere = refine(coarse, lambda x, y: 1.0, 3)
save_png("painted_only_off.png",
raster(lambda d, sz: draw_mesh(d, sz, everywhere,
np.array([disc(*np.mean(t, axis=0)) > 0.5 for t in everywhere])), SIZE))
# Subdivide: every triangle into four, a step at a time.
level = tri_mesh(2)
frames = []
for _ in range(4):
frames.append(raster(lambda d, sz, m=level: draw_mesh(d, sz, m), GIF_SIZE))
level = [out for t in level for out in split4(t)]
save_gif("subdivide.gif", frames, delay_ms=650)
# Remesh: the model's own uneven triangulation, rebuilt at one size.
save_png("remesh_before.png", raster(lambda d, sz: draw_mesh(d, sz, irregular_mesh()), SIZE))
save_png("remesh_after.png", raster(lambda d, sz: draw_mesh(d, sz, tri_mesh(8, jitter=0.35, seed=9)), SIZE))
# Hard edges: a fold the remesh runs across, against one it lays its edges along.
crease = ((0.06, 0.84), (0.94, 0.20))
across = tri_mesh(5, jitter=0.45, seed=6)
save_png("preserve_edges_off.png",
raster(lambda d, sz: (draw_mesh(d, sz, across), draw_line(d, sz, crease)), SIZE))
save_png("preserve_edges_on.png",
raster(lambda d, sz: (draw_mesh(d, sz, snap_to_crease(across, crease, 0.13)),
draw_line(d, sz, crease)), SIZE))
# Thin triangles, and the same mesh without them.
save_png("collapse_slivers_off.png", raster(lambda d, sz: draw_mesh(d, sz, sliver_mesh()), SIZE))
save_png("collapse_slivers_on.png", raster(lambda d, sz: draw_mesh(d, sz, tri_mesh(7, jitter=0.4, seed=5)), SIZE))
# Smoothing the geometry after the texture is in it: the steps relax out of the surface.
stepped_profile = np.floor(profile_wave(GIF_SIZE, periods=2.0) * 5.0) / 4.0
frames = []
for t in sweep:
h = stepped_profile.copy()
for _ in range(int(round(t * 6))):
h = blur(h[None, :], 2)[0]
frames.append(raster(lambda d, sz, hh=h: draw_profile(d, sz, hh), GIF_SIZE, background=(0.11, 0.12, 0.13)))
save_gif("geometry_smoothing.gif", frames, delay_ms=100)
# Mixing, how many mixes, and denoise - all about which filament a spot ends up as, so all flat.
save_png("mix_off.png", levels_image(4))
save_png("mix_on.png", levels_image(4, dither=True))
save_png("mixed_colors_few.png", levels_image(3, dither=True))
save_png("mixed_colors_many.png", levels_image(8, dither=True))
save_png("denoise_off.png", levels_image(5, dither=True, speckle=0.05))
save_png("denoise_on.png", levels_image(5, dither=True))
if __name__ == "__main__":
main()
-2
View File
@@ -20,8 +20,6 @@ set(SLIC3R_GUI_SOURCES
GUI/Widgets/FilamentLoad.hpp
GUI/AboutDialog.cpp
GUI/AboutDialog.hpp
GUI/AnimatedTexture.cpp
GUI/AnimatedTexture.hpp
GUI/AmsMappingPopup.cpp
GUI/AmsMappingPopupUpdate.cpp
GUI/AmsMappingPopup.hpp
-240
View File
@@ -1,240 +0,0 @@
#include "AnimatedTexture.hpp"
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <fstream>
#include <ios>
#include <iterator>
#include <memory>
#include <string>
#include <utility>
#include <vector>
#include <boost/filesystem/operations.hpp>
#include <boost/filesystem/path.hpp>
#include <boost/log/trivial.hpp>
#include <boost/system/error_code.hpp>
#include <wx/animdecod.h>
#include <wx/gdicmn.h>
#include <wx/gifdecod.h>
#include <wx/image.h>
#include <wx/log.h>
#include <wx/mstream.h>
#include "slic3r/GUI/GLTexture.hpp"
namespace Slic3r::GUI {
namespace {
std::vector<unsigned char> read_file_bytes(const std::string &path)
{
std::ifstream ifs(path, std::ios::binary);
return std::vector<unsigned char>(std::istreambuf_iterator<char>(ifs), std::istreambuf_iterator<char>());
}
bool has_gif_signature(const std::vector<unsigned char> &bytes)
{
return bytes.size() > 6 && bytes[0] == 'G' && bytes[1] == 'I' && bytes[2] == 'F';
}
// wxImage keeps colour and alpha in two separate buffers, and a file with no alpha at all has none;
// OpenGL wants the four channels interleaved.
std::vector<unsigned char> image_to_rgba(const wxImage &image)
{
const size_t n = size_t(image.GetWidth()) * size_t(image.GetHeight());
const unsigned char *rgb = image.GetData();
const unsigned char *a = image.HasAlpha() ? image.GetAlpha() : nullptr;
std::vector<unsigned char> out(n * 4);
for (size_t i = 0; i < n; ++i) {
out[i * 4 + 0] = rgb[i * 3 + 0];
out[i * 4 + 1] = rgb[i * 3 + 1];
out[i * 4 + 2] = rgb[i * 3 + 2];
out[i * 4 + 3] = a != nullptr ? a[i] : 255;
}
return out;
}
// One decoded frame on its way to the GPU: the whole animation's area, already composited.
struct DecodedFrame
{
std::vector<unsigned char> rgba;
double delay; // seconds
};
// Every frame of a GIF, composited onto the animation's own canvas.
//
// A GIF frame is a sub-rectangle that is drawn over what came before it, and the one before says how
// to clean up after itself (leave it, erase it, put back what it covered). Without replaying that, an
// animation that only re-sends the pixels that changed - which is most of them - draws as a flicker of
// disconnected patches. wxGIFDecoder hands over the raw palette indices, which is what makes replaying
// it here straightforward: a transparent index means "keep what is underneath", nothing else does.
bool decode_gif(const std::vector<unsigned char> &bytes, int &width, int &height, std::vector<DecodedFrame> &out)
{
wxMemoryInputStream stream(bytes.data(), bytes.size());
wxGIFDecoder decoder;
// A truncated file still decodes everything up to the cut, which is worth showing.
const wxGIFErrorCode err = decoder.LoadGIF(stream);
if ((err != wxGIF_OK && err != wxGIF_TRUNCATED) || decoder.GetFrameCount() == 0)
return false;
const wxSize size = decoder.GetAnimationSize();
width = size.GetWidth();
height = size.GetHeight();
if (width <= 0 || height <= 0)
return false;
std::vector<unsigned char> canvas(size_t(width) * size_t(height) * 4, 0);
std::vector<unsigned char> saved;
for (unsigned int i = 0; i < decoder.GetFrameCount(); ++i) {
const wxSize frame_size = decoder.GetFrameSize(i);
const wxPoint frame_pos = decoder.GetFramePosition(i);
const wxAnimationDisposal disposal = decoder.GetDisposalMethod(i);
const unsigned char *indices = decoder.GetData(i);
const unsigned char *palette = decoder.GetPalette(i);
const int transparent = decoder.GetTransparentColourIndex(i);
if (disposal == wxANIM_TOPREVIOUS)
saved = canvas;
if (indices != nullptr && palette != nullptr)
for (int y = 0; y < frame_size.GetHeight(); ++y) {
const int cy = frame_pos.y + y;
if (cy < 0 || cy >= height)
continue;
for (int x = 0; x < frame_size.GetWidth(); ++x) {
const int cx = frame_pos.x + x;
if (cx < 0 || cx >= width)
continue;
const unsigned char index = indices[size_t(y) * size_t(frame_size.GetWidth()) + size_t(x)];
if (int(index) == transparent)
continue;
unsigned char *dst = &canvas[(size_t(cy) * size_t(width) + size_t(cx)) * 4];
dst[0] = palette[3 * size_t(index) + 0];
dst[1] = palette[3 * size_t(index) + 1];
dst[2] = palette[3 * size_t(index) + 2];
dst[3] = 255;
}
}
// Browsers treat 0 and 1 hundredths of a second as "as fast as the file can mean", and settle
// on 100 ms; an animation authored against that convention plays at the intended speed only if
// this does the same.
const long delay_ms = decoder.GetDelay(i);
out.push_back({ canvas, (delay_ms <= 10 ? 100.0 : double(delay_ms)) / 1000.0 });
if (disposal == wxANIM_TOBACKGROUND) {
for (int y = 0; y < frame_size.GetHeight(); ++y) {
const int cy = frame_pos.y + y;
if (cy < 0 || cy >= height)
continue;
const int x0 = std::max(0, frame_pos.x);
const int x1 = std::min(width, frame_pos.x + frame_size.GetWidth());
if (x1 > x0)
std::fill_n(&canvas[(size_t(cy) * size_t(width) + size_t(x0)) * 4], size_t(x1 - x0) * 4, 0);
}
} else if (disposal == wxANIM_TOPREVIOUS) {
canvas = saved;
}
}
return !out.empty();
}
} // namespace
bool AnimatedTexture::load_from_file(const std::string &path)
{
reset();
boost::system::error_code ec;
if (!boost::filesystem::is_regular_file(path, ec))
return false;
const std::vector<unsigned char> bytes = read_file_bytes(path);
if (bytes.empty())
return false;
std::vector<DecodedFrame> frames;
int width = 0, height = 0;
if (has_gif_signature(bytes)) {
if (!decode_gif(bytes, width, height, frames)) {
BOOST_LOG_TRIVIAL(error) << "AnimatedTexture: cannot decode GIF " << path;
return false;
}
} else {
wxLogNull no_log; // a format wxWidgets cannot read is handled here, not in a dialog
wxImage image;
wxMemoryInputStream stream(bytes.data(), bytes.size());
if (!image.LoadFile(stream, wxBITMAP_TYPE_ANY) || !image.IsOk()) {
BOOST_LOG_TRIVIAL(error) << "AnimatedTexture: cannot decode image " << path;
return false;
}
width = image.GetWidth();
height = image.GetHeight();
frames.push_back({ image_to_rgba(image), 0.0 });
}
m_width = width;
m_height = height;
m_frames.reserve(frames.size());
for (DecodedFrame &frame : frames) {
auto texture = std::make_unique<GLTexture>();
// Mipmapped: a hint is drawn at whatever width the panel has room for, which is usually
// smaller than the source, and an unfiltered downscale of a diagram is all aliasing.
if (!texture->load_from_raw_data(std::move(frame.rgba), (unsigned int) width, (unsigned int) height, false,
/* use_mipmaps */ true)) {
BOOST_LOG_TRIVIAL(error) << "AnimatedTexture: cannot upload " << path;
reset();
return false;
}
m_frames.push_back({ std::move(texture), m_duration, frame.delay });
m_duration += frame.delay;
}
return true;
}
void AnimatedTexture::reset()
{
m_frames.clear();
m_width = 0;
m_height = 0;
m_duration = 0.;
}
unsigned int AnimatedTexture::texture_id(double time) const
{
if (m_frames.empty())
return 0;
if (m_frames.size() == 1 || m_duration <= 0.)
return m_frames.front().texture->get_id();
const double t = time - m_duration * std::floor(time / m_duration);
// Frames are few; a linear walk beats keeping an index around and getting it stale.
size_t i = 0;
while (i + 1 < m_frames.size() && t >= m_frames[i].start + m_frames[i].delay)
++i;
return m_frames[i].texture->get_id();
}
const AnimatedTexture *AnimatedTextureCache::get(const std::string &path)
{
const auto it = m_items.find(path);
if (it != m_items.end())
return it->second.get();
auto texture = std::make_unique<AnimatedTexture>();
if (!texture->load_from_file(path))
texture.reset(); // remembered as a failure, so the file is not re-read every frame
const AnimatedTexture *result = texture.get();
m_items.emplace(path, std::move(texture));
return result;
}
} // namespace Slic3r::GUI
-75
View File
@@ -1,75 +0,0 @@
#ifndef slic3r_AnimatedTexture_hpp_
#define slic3r_AnimatedTexture_hpp_
#include <map>
#include <memory>
#include <string>
#include <vector>
#include "slic3r/GUI/GLTexture.hpp"
namespace Slic3r::GUI {
// An illustration for the UI to draw: one still image, or the frames of an animated GIF together with
// the delays the file asks for. Decoded on the CPU once and uploaded as one GL texture per frame.
//
// A texture per frame rather than an atlas: these are small hints a few hundred pixels across and a
// handful of frames long, so the extra textures cost little, while drawing a frame stays a plain
// ImGui::Image with no UV arithmetic and no dependence on the maximum texture size.
//
// load_from_file() uploads to the GPU, so it needs a current GL context - call it from rendering code.
class AnimatedTexture
{
public:
AnimatedTexture() = default;
AnimatedTexture(AnimatedTexture &&) = default;
AnimatedTexture &operator=(AnimatedTexture &&) = default;
AnimatedTexture(const AnimatedTexture &) = delete;
AnimatedTexture &operator=(const AnimatedTexture &) = delete;
// PNG, JPG and BMP through wxImage; GIF, animated or not, through wxGIFDecoder. False, and nothing
// loaded, if the file is missing or cannot be decoded.
bool load_from_file(const std::string &path);
void reset();
bool is_valid() const { return !m_frames.empty(); }
bool is_animated() const { return m_frames.size() > 1; }
int width() const { return m_width; }
int height() const { return m_height; }
// Length of one loop, in seconds; 0 for a still image.
double duration() const { return m_duration; }
// The texture to draw `time` seconds into the loop, which wraps. 0 when nothing is loaded.
unsigned int texture_id(double time) const;
private:
struct Frame
{
std::unique_ptr<GLTexture> texture;
double start{0.}; // seconds into the loop this frame appears at
double delay{0.}; // seconds it stays up
};
std::vector<Frame> m_frames;
int m_width{0};
int m_height{0};
double m_duration{0.};
};
// The illustrations a panel has shown at least once, keyed by absolute path, decoded and uploaded on
// first use and kept until clear() - which has to run while the GL context is still current.
class AnimatedTextureCache
{
public:
// Null when the file is missing or cannot be decoded. The failure is remembered too, so a tooltip
// whose illustration has not been drawn yet costs one lookup, not one decode attempt per frame.
const AnimatedTexture *get(const std::string &path);
void clear() { m_items.clear(); }
private:
std::map<std::string, std::unique_ptr<AnimatedTexture>> m_items; // null value: tried once, failed
};
} // namespace Slic3r::GUI
#endif // slic3r_AnimatedTexture_hpp_
+3 -2
View File
@@ -25,8 +25,9 @@ DockPanel::DockPanel(wxWindow* parent,
const std::string& html,
MessageHandler on_message,
CloseHandler on_close,
CloseHandler on_destroyed)
: WebPanel(parent, web_hosting::orca_bridge_script())
CloseHandler on_destroyed,
const wxString& profile)
: WebPanel(parent, web_hosting::orca_bridge_script(), profile)
, m_html(html)
, m_on_message(std::move(on_message))
, m_on_close(std::move(on_close))
+4 -1
View File
@@ -6,6 +6,8 @@
#include <optional>
#include <string>
#include <wx/string.h>
class wxWindow;
namespace Slic3r { namespace GUI {
@@ -27,7 +29,8 @@ public:
const std::string& html,
MessageHandler on_message,
CloseHandler on_close,
CloseHandler on_destroyed);
CloseHandler on_destroyed,
const wxString& profile = wxEmptyString);
~DockPanel() override;
// Main thread only.
@@ -3701,189 +3701,6 @@ void GLGizmoTextureDisplacement::ensure_panel_icons()
m_panel_icon_map[names[i]] = icons[i];
}
// Seconds on a real clock, for the tooltips' own timing.
//
// Not ImGui::GetTime(): nothing in OrcaSlicer ever sets io.DeltaTime, so it keeps imgui's default of
// 1/60 per frame and its clock counts frames rather than seconds. On a canvas that renders only when
// something asks it to, that is not a clock at all - an animation driven by it crawled while the canvas
// was idle and raced while a moving mouse was rendering it flat out.
static double tip_now()
{
using clock = std::chrono::steady_clock;
static const clock::time_point base = clock::now();
return std::chrono::duration<double>(clock::now() - base).count();
}
// How long the pointer has to rest on a control before its tooltip appears. The panel is dense and the
// tooltip lands on the model beside it, so with no wait at all a sweep from the top of the panel down to
// Bake flashes a dozen large panels over the scene. Once one has just been up the wait drops to almost
// nothing, so reading several settings in a row stays quick - but not to nothing, or that same sweep
// would flash again the moment one tooltip had been seen.
static constexpr double TIP_DELAY = 0.45;
static constexpr double TIP_DELAY_AGAIN = 0.10;
static constexpr double TIP_STICKY = 0.70;
void GLGizmoTextureDisplacement::panel_tooltip(const wxString &text, const std::vector<TipMedia> &media)
{
panel_tooltip(into_u8(text), media);
}
void GLGizmoTextureDisplacement::panel_tooltip(const std::string &text, const std::vector<TipMedia> &media)
{
// Nothing explains a control while the control is being used: dragging a slider, typing a value
// into one, picking from a combo, dragging a layer card. The pointer sits on the very thing being
// edited, so without this the tooltip covers the model exactly while the user is watching it change.
// The full wait comes back afterwards rather than the short one - having just let go of a slider is
// not a reason to be shown a picture of it.
if (ImGui::IsAnyItemActive() || ImGui::IsAnyMouseDown()) {
m_tip_item = ImVec2(-1.f, -1.f);
m_tip_last_shown = -1e9;
return;
}
// The item's position stands in for its identity: moving to another control restarts the wait, and
// staying on this one carries it on.
const double now = tip_now();
const ImVec2 item = ImGui::GetItemRectMin();
if (item.x != m_tip_item.x || item.y != m_tip_item.y) {
m_tip_item = item;
m_tip_hover_since = now;
}
if (now - m_tip_hover_since < (now - m_tip_last_shown < TIP_STICKY ? TIP_DELAY_AGAIN : TIP_DELAY)) {
// A pointer resting on a control produces no events at all, so unless the wait asks for frames
// itself nothing redraws the canvas and the wait never finishes - the tooltip then appears only
// on the next pixel of mouse movement.
//
// request_extra_frame() and not schedule_extra_frame(): the latter only arranges a timer, whose
// event an idle event is assumed to follow, and GLCanvas3D::on_idle() renders nothing unless the
// canvas was already dirty when it ran. request_extra_frame() ends on_idle with
// evt.RequestMore(), which obliges wx to deliver the next one. It is what 3DBed's fade uses.
// Costing a few hundred milliseconds of frames is the cheaper half of that trade.
m_parent.request_extra_frame();
return;
}
m_tip_last_shown = now;
// ImGui hides a window on the first frame it is submitted, to measure it before showing it - and
// for a tooltip it does so every time one reappears, because the windows are recycled (see the
// Popup|Tooltip branch of Begin()). So the frame on which a tooltip is decided is never the frame
// on which it is seen, and one that then asks for nothing more stays invisible until the next pixel
// of mouse movement happens to produce another frame. Keep frames coming until it has settled.
const int shown_frame = ImGui::GetFrameCount();
m_tip_settled = (shown_frame == m_tip_shown_frame + 1) ? m_tip_settled + 1 : 0;
m_tip_shown_frame = shown_frame;
if (m_tip_settled < 2)
m_parent.request_extra_frame();
const ImGuiStyle &style = ImGui::GetStyle();
const float gap = m_imgui->scaled(0.5f);
const float wrap = m_imgui->scaled(20.f);
// The width has to be known here, not left to ImGui, because the tooltip is placed by its left edge
// (below) and an illustration has to be laid out against something. One carrying illustrations takes
// the full wrap width; a plain one takes exactly what its text needs, as it did when ImGui sized it,
// so a two-word tooltip is not a wide box with two words in it.
const float text_w = ImGui::CalcTextSize(text.c_str(), nullptr, false, wrap).x;
const float width = (media.empty() ? std::min(wrap, std::ceil(text_w)) : wrap) + 2.f * style.WindowPadding.x;
// Beside the anchor (the panel, or the texture picker when the tooltip comes from inside it), never
// over it - the same reasoning, and the same placement, as the picker popup itself. ImGui normally
// keeps a tooltip glued to the mouse; it honours a position set here instead (see the Orca branch in
// imgui's Begin()), and still clamps the result inside the canvas.
const bool left = m_tooltip_anchor_min.x - gap - width >= 0.f;
const float x = left ? m_tooltip_anchor_min.x - gap - width : m_tooltip_anchor_max.x + gap;
ImGui::SetNextWindowPos(ImVec2(x, ImGui::GetItemRectMin().y)); // level with the control it explains
ImGui::SetNextWindowSize(ImVec2(width, 0.f)); // height still follows the content
ImGui::BeginTooltip();
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.00f, 1.00f, 1.00f, 1.00f));
ImGui::PushTextWrapPos(0.f); // the window's own edge, which is what the fixed width above sets
ImGui::TextUnformatted(text.c_str());
ImGui::PopTextWrapPos();
ImGui::PopStyleColor();
render_tip_media(media, width - 2.f * style.WindowPadding.x);
ImGui::EndTooltip();
}
void GLGizmoTextureDisplacement::render_tip_media(const std::vector<TipMedia> &media, float width)
{
// Collect what is actually there first: the layout divides the width between the illustrations, so
// one that is missing has to drop out of the count rather than leave a gap.
std::vector<std::pair<const AnimatedTexture *, const TipMedia *>> shown;
std::string key;
for (const TipMedia &m : media) {
const AnimatedTexture *texture =
m_tip_media.get((boost::filesystem::path(resources_dir()) / "images" / "texture_displacement" / m.file).string());
if (texture != nullptr && texture->is_valid() && texture->width() > 0 && texture->height() > 0) {
shown.emplace_back(texture, &m);
key += m.file + "|";
}
}
if (shown.empty())
return;
// Each animation plays from its first frame when its tooltip opens: these are short loops showing
// one setting going from one value to another, and joining one halfway through shows the user the
// end of a change they never saw the start of.
// Restart on a different tooltip, and on the same one opening again - which is what a gap in the
// frames it was drawn on means, since it is drawn on every frame for as long as it is on screen.
const int this_frame = ImGui::GetFrameCount();
if (key != m_tip_media_key || this_frame != m_tip_media_frame + 1) {
m_tip_media_key = key;
m_tip_media_start = tip_now();
}
m_tip_media_frame = this_frame;
const double time = tip_now() - m_tip_media_start;
const ImGuiStyle &style = ImGui::GetStyle();
const float spacing = style.ItemSpacing.x;
const float cell_w = (width - spacing * float(shown.size() - 1)) / float(shown.size());
const ImU32 frame = ImGui::GetColorU32(ImGuiCol_Text, 0.25f);
// A single illustration would otherwise be drawn at the tooltip's full width, and a square one at
// full width is a picture as tall as the text beside it is long. Capped and centred in its column.
const float max_h = m_imgui->scaled(10.f);
ImGui::Spacing();
for (size_t i = 0; i < shown.size(); ++i) {
const AnimatedTexture *texture = shown[i].first;
const TipMedia &entry = *shown[i].second;
const float cell_x = ImGui::GetCursorPosX();
float w = cell_w;
float h = std::round(cell_w * float(texture->height()) / float(texture->width()));
if (h > max_h) {
w = std::round(max_h * float(texture->width()) / float(texture->height()));
h = max_h;
}
ImGui::BeginGroup();
const unsigned int id = texture->texture_id(time);
ImGui::SetCursorPosX(cell_x + std::round(0.5f * (cell_w - w)));
ImGui::Image((ImTextureID) (intptr_t) id, ImVec2(w, h));
ImGui::GetWindowDrawList()->AddRect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax(), frame, style.FrameRounding);
// The canvas only redraws on demand, so an animation has to ask for its own frames. It asks for
// them as fast as they come rather than pacing itself with a timer: which frame is drawn is
// decided by the clock above, so a redraw that arrives early simply draws the same frame again,
// and a guaranteed frame is worth more here than a frugal one that may never arrive.
if (texture->is_animated())
m_parent.request_extra_frame();
if (!entry.caption.empty()) {
// Centred under its own illustration, and never wider than its column.
const std::string caption = into_u8(entry.caption);
const float text_w = ImGui::CalcTextSize(caption.c_str()).x;
ImGui::SetCursorPosX(cell_x + std::max(0.f, (cell_w - text_w) * 0.5f));
ImGui::PushTextWrapPos(cell_x + cell_w);
ImGui::TextDisabled("%s", caption.c_str());
ImGui::PopTextWrapPos();
}
ImGui::EndGroup();
if (i + 1 < shown.size())
ImGui::SameLine(0.f, spacing);
}
}
void GLGizmoTextureDisplacement::add_texture_layer()
{
ModelVolume *mv = texture_volume();
@@ -4028,14 +3845,6 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane
return;
}
// While this popup is up its own tooltips belong beside it, not beside the panel it is already
// covering the left of. Restored before every exit below.
const ImVec2 prev_anchor_min = m_tooltip_anchor_min;
const ImVec2 prev_anchor_max = m_tooltip_anchor_max;
m_tooltip_anchor_min = ImGui::GetWindowPos();
m_tooltip_anchor_max = ImVec2(m_tooltip_anchor_min.x + ImGui::GetWindowSize().x,
m_tooltip_anchor_min.y + ImGui::GetWindowSize().y);
m_imgui->push_bold_font();
m_imgui->text(_L("Choose a texture"));
m_imgui->pop_bold_font();
@@ -4094,7 +3903,7 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane
if (entry.path == layer->path)
dl->AddRect(ImVec2(a.x - 1.f, a.y - 1.f), ImVec2(b.x + 1.f, b.y + 1.f), selected_col, 0.f, 0, 2.f);
if (ImGui::IsItemHovered())
panel_tooltip(entry.name);
m_imgui->tooltip(entry.name, m_imgui->scaled(18.f));
if (user && ImGui::IsMouseHoveringRect(a, b)) {
const float r = std::round(tile * 0.17f);
@@ -4107,7 +3916,7 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane
dl->AddLine(ImVec2(c.x - k, c.y - k), ImVec2(c.x + k, c.y + k), IM_COL32_WHITE, 1.5f);
dl->AddLine(ImVec2(c.x - k, c.y + k), ImVec2(c.x + k, c.y - k), IM_COL32_WHITE, 1.5f);
if (hot)
panel_tooltip(_u8L("Remove from My textures"));
m_imgui->tooltip(_u8L("Remove from My textures"), m_imgui->scaled(18.f));
if (remove_clicked)
to_remove = entry;
}
@@ -4134,9 +3943,10 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane
if (ImGui::Button("+##import_texture", ImVec2(tile, tile)))
import_clicked = true;
if (ImGui::IsItemHovered())
panel_tooltip(_u8L("Import your own image as a height map. It is converted to the format the slicer "
"bakes from and saved to your personal texture folder, kept separate from the "
"textures shipped with OrcaSlicer."));
m_imgui->tooltip(_u8L("Import your own image as a height map. It is converted to the format the slicer "
"bakes from and saved to your personal texture folder, kept separate from the "
"textures shipped with OrcaSlicer."),
m_imgui->scaled(20.f));
end_grid(origin, count + 1);
}
@@ -4146,8 +3956,6 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane
if (chosen || import_clicked)
ImGui::CloseCurrentPopup();
m_tooltip_anchor_min = prev_anchor_min;
m_tooltip_anchor_max = prev_anchor_max;
ImGui::EndPopup();
// Only once the loop over the library is done: importing, picking (which reorders the recently used)
@@ -5779,16 +5587,18 @@ void GLGizmoTextureDisplacement::render_debug_stage_panel(ModelVolume *mv)
run_stage_debug();
m_imgui->disabled_end();
if (ImGui::IsItemHovered())
panel_tooltip(_u8L("Run the same bake the Bake button runs, keeping the mesh after every "
"stage, and step through them here. Nothing is committed to the model - "
"this is for finding which stage a bad result came from."));
m_imgui->tooltip(_u8L("Run the same bake the Bake button runs, keeping the mesh after every "
"stage, and step through them here. Nothing is committed to the model - "
"this is for finding which stage a bad result came from."),
m_imgui->scaled(20.f));
ImGui::SameLine();
m_imgui->bbl_checkbox(_L("Check topology"), m_debug_check_topology);
if (ImGui::IsItemHovered())
panel_tooltip(_u8L("Count open and non-manifold edges after each stage, which is how a stage "
"that tore the mesh is spotted. It scans every edge, so it adds noticeably "
"to the capture on a fine bake."));
m_imgui->tooltip(_u8L("Count open and non-manifold edges after each stage, which is how a stage "
"that tore the mesh is spotted. It scans every edge, so it adds noticeably "
"to the capture on a fine bake."),
m_imgui->scaled(20.f));
if (m_debug_stages.empty())
return;
@@ -5856,8 +5666,9 @@ void GLGizmoTextureDisplacement::render_debug_stage_panel(ModelVolume *mv)
show_error(nullptr, _u8L("Could not write the stage meshes."));
}
if (ImGui::IsItemHovered())
panel_tooltip(_u8L("Write every captured stage out as an OBJ file, so they can be opened "
"side by side in a mesh viewer."));
m_imgui->tooltip(_u8L("Write every captured stage out as an OBJ file, so they can be opened "
"side by side in a mesh viewer."),
m_imgui->scaled(20.f));
}
void GLGizmoTextureDisplacement::queue_prepare(const TextureDisplacementPrepareParams &params,
@@ -5982,12 +5793,6 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
ImGuiWrapper::push_toolbar_style(m_parent.get_scale());
GizmoImguiBegin(get_name(), flags);
// Where this frame's tooltips place themselves (see panel_tooltip()). The size is last frame's -
// this window auto-resizes, so its height is only known once its content has been laid out - which
// is close enough for a tooltip that needs a frame of hovering to appear in the first place.
m_tooltip_anchor_min = ImGui::GetWindowPos();
m_tooltip_anchor_max = ImVec2(m_tooltip_anchor_min.x + ImGui::GetWindowSize().x,
m_tooltip_anchor_min.y + ImGui::GetWindowSize().y);
ensure_panel_icons();
process_uv_commands(); // clicks from the UV editor pane, run here where the GL context is current
const float previous_body_h = m_panel_body_h;
@@ -6011,6 +5816,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
const float gap_s = std::round(m_imgui->scaled(0.4f));
const float label_w = m_imgui->scaled(4.8f); // label column of a label + slider row
const float card_pad = std::round(m_imgui->scaled(0.55f));
const float wrap_w = m_imgui->scaled(20.f);
const ImVec4 orca = ImGuiWrapper::COL_ORCA;
const ImVec4 col_link = ImGuiWrapper::COL_ORCA;
const ImVec4 col_frame = ImGui::GetStyleColorVec4(ImGuiCol_Separator);
@@ -6024,9 +5830,9 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
ImGuiWrapper::pop_combo_style();
return changed;
};
const auto hover_tip = [&](const auto &text, const std::vector<TipMedia> &media = {}) {
const auto hover_tip = [&](const auto &text) {
if (ImGui::IsItemHovered())
panel_tooltip(text, media);
m_imgui->tooltip(text, wrap_w);
};
// Framed icon toggle: a 1 px frame, and when on a teal frame over a teal tint with the icon in its
@@ -6034,8 +5840,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
// `unavailable`, when not empty, draws it faded but still hoverable, so the tooltip can say why it
// cannot be used - a disabled ImGui item would swallow the hover and leave the user guessing.
const auto icon_toggle = [&](int uid, const std::string &iconfile, bool active, float sz, const wxString &label,
const wxString &tip_text, const std::vector<TipMedia> &media = {},
const wxString &unavailable = wxString()) -> bool {
const wxString &tip_text, const wxString &unavailable = wxString()) -> bool {
const bool na = !unavailable.empty();
const auto it = m_panel_icon_map.find(iconfile);
const float pad = std::max(1.f, std::round(sz * 0.14f));
@@ -6062,12 +5867,12 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
ImGui::PopStyleVar(2);
ImGui::PopID();
if (ImGui::IsItemHovered())
panel_tooltip(na ? tip_text + "\n\n" + unavailable : tip_text, media);
m_imgui->tooltip(na ? tip_text + "\n\n" + unavailable : tip_text, wrap_w);
return clicked && !na;
};
// Borderless icon button (non-toggle): always the grey monochrome variant.
const auto icon_button = [&](int uid, const std::string &iconfile, float sz, const wxString &label,
const wxString &tip_text, const std::vector<TipMedia> &media = {}) -> bool {
const wxString &tip_text) -> bool {
const auto it = m_panel_icon_map.find(iconfile);
const float pad = std::max(1.f, std::round(sz * 0.14f));
bool clicked = false;
@@ -6085,7 +5890,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
ImGui::PopStyleVar();
ImGui::PopID();
if (!tip_text.empty() && ImGui::IsItemHovered())
panel_tooltip(tip_text, media);
m_imgui->tooltip(tip_text, wrap_w);
return clicked;
};
// A short vertical rule between groups of buttons on one row; call it right after an item.
@@ -6287,13 +6092,11 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
ImGui::SetCursorPosX(toggles_x);
if (icon_toggle(820, "texture_displacement_add.svg", !m_erase_mode, icon_sm, _L("Paint"),
_L("Paint - add the active layer where you click or drag. The right button erases, and so does "
"holding Shift."),
{ { "paint.gif" } }))
"holding Shift.")))
m_erase_mode = false;
ImGui::SameLine(0.f, gap_s);
if (icon_toggle(821, "texture_displacement_add_negative.svg", m_erase_mode, icon_sm, _L("Erase"),
_L("Erase - remove the active layer where you click or drag. The right button paints."),
{ { "erase.gif" } }))
_L("Erase - remove the active layer where you click or drag. The right button paints.")))
m_erase_mode = true;
}
@@ -6307,22 +6110,20 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
const bool is_area_mode = m_tool_type == ToolType::SMART_FILL;
const float row_y = ImGui::GetCursorPosY();
if (icon_toggle(801, "texture_displacement_brush.svg", is_brush_mode, icon_md, _L("Brush"),
_L("Brush - paint over the surface by dragging"), { { "paint.gif" } })) {
_L("Brush - paint over the surface by dragging"))) {
m_tool_type = ToolType::BRUSH;
if (m_cursor_type == TriangleSelector::CursorType::POINTER)
m_cursor_type = TriangleSelector::CursorType::CIRCLE;
}
ImGui::SameLine(0.f, gap_s);
if (icon_toggle(802, "texture_displacement_face.svg", is_face_mode, icon_md, _L("Face"), _L("Face - click individual triangles"),
{ { "tool_face.gif" } })) {
if (icon_toggle(802, "texture_displacement_face.svg", is_face_mode, icon_md, _L("Face"), _L("Face - click individual triangles"))) {
m_tool_type = ToolType::BRUSH;
m_cursor_type = TriangleSelector::CursorType::POINTER;
}
ImGui::SameLine(0.f, gap_s);
if (icon_toggle(803, "texture_displacement_connected_area.svg", is_area_mode, icon_md, _L("Connected area"),
_L("Connected area - flood-fill the region reachable without crossing an edge sharper than the "
"angle threshold"),
{ { "tool_smart_fill.gif" } })) {
"angle threshold"))) {
m_tool_type = ToolType::SMART_FILL;
m_cursor_type = TriangleSelector::CursorType::POINTER;
}
@@ -6336,13 +6137,11 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
wxString();
ImGui::SameLine(0.f, gap_s);
if (icon_toggle(806, "texture_displacement_select_all.svg", false, icon_md, _L("Select whole model"),
_L("Select whole model - paint every face of the model with the active layer"),
{ { "select_all.png" } }, whole_na))
_L("Select whole model - paint every face of the model with the active layer"), whole_na))
select_whole_model();
ImGui::SameLine(0.f, gap_s);
if (icon_toggle(807, "texture_displacement_erase_all.svg", false, icon_md, _L("Erase whole model"),
_L("Erase whole model - clear the active layer's paint from every face"),
{ { "erase_all.png" } }, erase_na)) {
_L("Erase whole model - clear the active layer's paint from every face"), erase_na)) {
Plater::TakeSnapshot snapshot(wxGetApp().plater(), _u8L("Reset texture displacement selection"),
UndoRedo::SnapshotType::GizmoAction);
int idx = -1;
@@ -6365,7 +6164,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
centre_on_row(frame_h);
ImGui::SliderFloat("##cursor_radius", &m_cursor_radius, CursorRadiusMin, CursorRadiusMax, "%.2f mm",
ImGuiSliderFlags_AlwaysClamp);
hover_tip(m_desc.at("cursor_size"), { { "cursor_size.gif" } });
hover_tip(m_desc.at("cursor_size"));
vsep(icon_sm);
const bool is_circle = m_cursor_type == TriangleSelector::CursorType::CIRCLE;
centre_on_row(icon_sm);
@@ -6382,8 +6181,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
centre_on_row(frame_h);
ImGui::SliderFloat("##smart_fill_angle", &m_smart_fill_angle, SmartFillAngleMin, SmartFillAngleMax, "%.0f°",
ImGuiSliderFlags_AlwaysClamp);
hover_tip(_u8L("Angle threshold - the fill stops at edges sharper than this"),
{ { "smart_fill_angle_low.png", _L("Low") }, { "smart_fill_angle_high.png", _L("High") } });
hover_tip(_u8L("Angle threshold - the fill stops at edges sharper than this"));
} else {
centre_on_row(frame_h);
ImGui::AlignTextToFramePadding();
@@ -6420,21 +6218,19 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
ImGui::SameLine();
ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), x0 + m_imgui->scaled(2.6f)));
if (icon_toggle(701, "texture_displacement_real_preview.svg", cur_mode == 0, icon_md, _L("Normal"),
_L("Normal - the real geometry, exactly what Bake will produce. Slower to update"),
{ { "tile_on.png" } }))
_L("Normal - the real geometry, exactly what Bake will produce. Slower to update")))
new_mode = 0;
ImGui::SameLine(0.f, gap_s);
if (icon_toggle(702, "texture_displacement_fast_preview.svg", cur_mode == 1, icon_md, _L("Fast"),
_L("Fast - the relief is only shaded on, not built, and only for the layer you are "
"editing. Updates instantly while you paint"),
{ { "view_fast.png" } }))
"editing. Updates instantly while you paint")))
new_mode = 1;
ImGui::SameLine(0.f, gap_s);
if (icon_toggle(703, "texture_displacement_checker.svg", cur_mode == 2, icon_md, _L("Checker"),
_L("Checker - a test grid instead of the texture. Where the squares stay square the "
"texture is undistorted; where they stretch, it will too. Opens the UV editor if "
"it is closed"),
{ { "view_checker.png" } }, uv_view_na)) {
uv_view_na)) {
new_mode = 2;
open_uv_editor = true;
}
@@ -6442,14 +6238,13 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
if (icon_toggle(704, "texture_displacement_distortion.svg", cur_mode == 3, icon_md, _L("Distortion"),
_L("Distortion - blue-to-red stretch heatmap over the unwrap. Opens the UV editor if "
"it is closed"),
{ { "view_distortion.png" } }, uv_view_na)) {
uv_view_na)) {
new_mode = 3;
open_uv_editor = true;
}
vsep(icon_md);
if (icon_toggle(705, "texture_displacement_wireframe.svg", m_wireframe_overlay, icon_md, _L("Wireframe"),
_L("Wireframe - overlay the mesh edges; independent of the view above"),
{ { "view_wireframe.png" } }))
_L("Wireframe - overlay the mesh edges; independent of the view above")))
wf_toggle = true;
const std::string auto_label = _u8L("Auto");
@@ -6658,17 +6453,14 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
// Depth and tile size are logarithmic, so both ends of their wide ranges stay usable.
m_preview_params_dirty |= float_row("##depth", _L("Depth"), &layer.depth_mm, 0.01f, 10.f, "%.3f mm", true, card_pad);
hover_tip(_u8L("Height of the relief: how far white in the texture lifts the surface, in "
"millimetres. Black does not move it at all, unless Midlevel says otherwise."),
{ { "depth.gif" } });
"millimetres. Black does not move it at all, unless Midlevel says otherwise."));
m_preview_params_dirty |= float_row("##tile_size", _L("Tile size"), &layer.tiling_scale, 0.2f, 200.f, "%.2f mm", true, card_pad);
hover_tip(_u8L("How wide one copy of the texture is on the model. Smaller repeats the pattern "
"more often and makes its detail finer; with Tile off, this is the size of the "
"single copy."),
{ { "tile_size_large.png", _L("Larger") }, { "tile_size_small.png", _L("Smaller") } });
"single copy."));
m_preview_params_dirty |= float_row("##rotation", _L("Rotation"), &layer.rotation_deg, 0.f, 360.f, "%.0f°", false, card_pad);
hover_tip(_u8L("Turns the texture on the surface, in degrees - for lining a pattern up with an "
"edge of the model."),
{ { "rotation.gif" } });
"edge of the model."));
if (m_layer_expanded[size_t(layer.slot)]) {
{
@@ -6688,8 +6480,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
"outwards; at 0.5 mid-gray stays put, so darker grays cut in and lighter ones "
"still push out - one image both embosses and engraves.\n\n"
"What cuts in has to fit: inside a sharp corner or through a thin wall, a deep "
"cut can pass through the other side."),
{ { "midlevel.gif" } });
"cut can pass through the other side."));
if (layer.midlevel > 0.f && layer.depth_mm > 1.f) {
ImGui::PushTextWrapPos(content_rx - card_pad);
m_imgui->warning_text(_L("Deep inward displacement may self-intersect."));
@@ -6699,14 +6490,12 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
m_preview_params_dirty |= float_row("##smoothing", _L("Smoothing"), &layer.smoothing, 0.f, 1.f, "%.2f", false, card_pad);
hover_tip(_u8L("Blurs the image before it is used, which rounds off hard steps and removes "
"speckle from a noisy photo. Raise it if the relief looks harsh or grainy; it "
"costs fine detail."),
{ { "smoothing_off.png", _L("Off") }, { "smoothing_on.png", _L("On") } });
"costs fine detail."));
// Edge fade: the relief flattens toward the boundary of the painted area.
m_preview_params_dirty |= m_imgui->bbl_checkbox(_L("Edge fade") + "##edge_smoothing", layer.edge_smoothing);
hover_tip(_u8L("Flattens the relief as it approaches the edge of the painted area, so it "
"blends into the bare surface instead of stopping at a step."),
{ { "edge_fade_off.png", _L("Off") }, { "edge_fade_on.png", _L("On") } });
"blends into the bare surface instead of stopping at a step."));
ImGui::SameLine();
m_imgui->disabled_begin(!layer.edge_smoothing);
ImGui::SetNextItemWidth(-card_pad);
@@ -6721,8 +6510,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
const float x0 = ImGui::GetCursorPosX();
m_preview_params_dirty |= m_imgui->bbl_checkbox(_L("Invert"), layer.invert);
hover_tip(_u8L("Turns the relief inside out: what stood out is cut in, and the other way "
"round. The same as using a negative of the image."),
{ { "invert_off.png", _L("Off") }, { "invert_on.png", _L("On") } });
"round. The same as using a negative of the image."));
ImGui::SameLine();
ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), x0 + 0.5f * (ImGui::GetWindowContentRegionMax().x - card_pad - x0)));
@@ -6738,12 +6526,13 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
}
m_imgui->disabled_end();
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
panel_tooltip(has_color ?
_u8L("Prints the painted area in the texture's colors as well as its "
"relief. Each color is matched to the nearest of your loaded "
"filaments; anything you did not paint keeps the object's own.") :
_u8L("This texture is a grayscale height map, so it has no colors to "
"apply. Import a color image to use this."));
m_imgui->tooltip(has_color ?
_u8L("Prints the painted area in the texture's colors as well as its "
"relief. Each color is matched to the nearest of your loaded "
"filaments; anything you did not paint keeps the object's own.") :
_u8L("This texture is a grayscale height map, so it has no colors to "
"apply. Import a color image to use this."),
wrap_w);
// The rest of colour belongs to the whole stack, not to this layer, so it only appears once -
// under whichever layer turned colour on.
@@ -6753,8 +6542,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
m_preview_params_dirty = true;
hover_tip(_u8L("Interleaves two filaments to fake the colors in between, so a handful "
"of filaments can cover a photo or a gradient. An image of flat colors "
"prints the same either way. Off uses one filament per area."),
{ { "mix_off.png", _L("Off") }, { "mix_on.png", _L("On") } });
"prints the same either way. Off uses one filament per area."));
if (opts.color_mix_enabled) {
cached_palette(); // brings m_palette_filaments up to date
// Every mix can become a filament slot, and there are only so many beside the
@@ -6763,8 +6551,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
if (int_row("##color_mix_count", _L("Mixed colors"), &opts.color_mix_count, 1, max_mixes, "%d", card_pad))
m_preview_params_dirty = true;
hover_tip(_u8L("The most mixed filaments a bake may add. They are picked from the "
"texture's colors, and only the ones the bake actually uses are created."),
{ { "mixed_colors_few.png", _L("Few") }, { "mixed_colors_many.png", _L("Many") } });
"texture's colors, and only the ones the bake actually uses are created."));
// After the slider, so a change shows in the same frame.
ImGui::TextDisabled("%s", Slic3r::format(_u8L("%1% printable colors from %2% filaments"),
int(cached_palette().size()), int(m_palette_filaments.size())).c_str());
@@ -6773,8 +6560,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
m_preview_params_dirty = true;
hover_tip(_u8L("Cleans up single stray triangles of the wrong color, which detail finer "
"than the mesh leaves behind. Raise it if the result looks speckled, "
"lower it if small features are being swallowed."),
{ { "denoise_off.png", _L("Off") }, { "denoise_on.png", _L("On") } });
"lower it if small features are being swallowed."));
}
}
@@ -6790,35 +6576,29 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
const char *file;
wxString label;
wxString tip;
const char *picture; // the same ball with the checker laid on it this way
};
const MappingIcon mappings[] = {
{ "menu_obj_cube.svg", _L("Triplanar (blended)"),
_L("Triplanar - projects the texture from all three axes at once and blends between them, so a "
"patch wrapping around a sharp edge has no seam."),
"map_cube.png" },
"patch wrapping around a sharp edge has no seam.") },
{ "menu_obj_cylinder.svg", _L("Cylindrical"),
_L("Cylindrical - wraps the texture around the painted area's own center, for round shapes."),
"map_cylinder.png" },
_L("Cylindrical - wraps the texture around the painted area's own center, for round shapes.") },
{ "menu_obj_sphere.svg", _L("Spherical"),
_L("Spherical - wraps the texture around the painted area's own center in both directions."),
"map_sphere.png" },
_L("Spherical - wraps the texture around the painted area's own center in both directions.") },
{ "texture_displacement_map_unwrap.svg", _L("Unwrap (LSCM)"),
_L("Unwrap - flattens the painted area and maps the texture onto it with as little stretching as "
"possible. The area is cut into pieces at its sharp edges first (see Seam angle), so each "
"piece can lie flat on its own."),
"map_unwrap.png" },
"piece can lie flat on its own.") },
{ "texture_displacement_map_view.svg", _L("From view"),
_L("From view - projects straight onto the painted area from where you are looking, like a slide "
"projector."),
"map_view.png" },
"projector.") },
};
for (int mi = 0; mi < int(IM_ARRAYSIZE(mappings)); ++mi) {
if (mi > 0)
ImGui::SameLine(0.f, gap_s);
const auto method = static_cast<TextureProjectionMethod>(mi);
if (icon_toggle(830 + mi, mappings[mi].file, layer.projection_method == method, icon_sm, mappings[mi].label,
mappings[mi].tip, { { mappings[mi].picture } }) &&
mappings[mi].tip) &&
layer.projection_method != method) {
// Capture the current view the moment "From view" is chosen, so it does something
// sensible immediately rather than projecting from a stale/default direction.
@@ -6922,20 +6702,19 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
const float x0 = ImGui::GetCursorPosX();
m_preview_params_dirty |= m_imgui->bbl_checkbox(_L("Tile") + "##tile_enabled", layer.tile_enabled);
hover_tip(_u8L("Repeats the texture across the painted area. Off places one copy, like a decal, "
"at the size set by Tile size."),
{ { "tile_off.png", _L("Off") }, { "tile_on.png", _L("On") } });
"at the size set by Tile size."));
ImGui::SameLine();
ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), x0 + label_w));
const wxString tile_na = layer.tile_enabled ? wxString() : _L("Turn Tile on to choose how the texture repeats.");
if (icon_toggle(840, "texture_displacement_tile_repeat.svg", layer.tile_method == static_cast<TextureTileMethod>(0),
icon_sm, _L("Repeat"), _L("Repeat"), { { "tile_repeat.png" } }, tile_na)) {
icon_sm, _L("Repeat"), _L("Repeat"), tile_na)) {
layer.tile_method = static_cast<TextureTileMethod>(0);
m_preview_params_dirty = true;
}
ImGui::SameLine(0.f, gap_s);
if (icon_toggle(841, "menu_mirror_x.svg", layer.tile_method == static_cast<TextureTileMethod>(1), icon_sm,
_L("Mirrored repeat"), _L("Mirrored repeat - every other tile is flipped, so edges meet seamlessly"),
{ { "tile_mirror.png" } }, tile_na)) {
tile_na)) {
layer.tile_method = static_cast<TextureTileMethod>(1);
m_preview_params_dirty = true;
}
@@ -6965,16 +6744,14 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
}
m_imgui->disabled_end();
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
panel_tooltip(base_layer ?
_u8L("The base layer has nothing beneath it to combine with, so it always adds.") :
_u8L("What this layer does where it overlaps the ones below. Add and "
"Subtract pile relief on or carve it away. Multiply and Divide scale "
"what is underneath, which turns this layer into a mask over it - "
"there, Depth acts as a strength, and 1 mm leaves white areas "
"untouched."),
base_layer ? std::vector<TipMedia>{} :
std::vector<TipMedia>{ { "blend_add.png", from_u8(blend_add) },
{ "blend_multiply.png", from_u8(blend_multiply) } });
m_imgui->tooltip(base_layer ?
_u8L("The base layer has nothing beneath it to combine with, so it always adds.") :
_u8L("What this layer does where it overlaps the ones below. Add and "
"Subtract pile relief on or carve it away. Multiply and Divide scale "
"what is underneath, which turns this layer into a mask over it - "
"there, Depth acts as a strength, and 1 mm leaves white areas "
"untouched."),
wrap_w);
ImGui::SameLine(0.f, gap_s);
if (icon_toggle(842, "texture_displacement_adjust.svg", m_adjust_texture_mode, icon_sm, _L("Adjust placement"),
@@ -7157,8 +6934,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
}
hover_tip(_u8L("Adds triangles only where you painted, instead of everywhere. On a large part with a small "
"decal that is the difference between thousands of triangles and millions, and your paint "
"survives the refinement."),
{ { "painted_only_off.png", _L("Off") }, { "painted_only_on.png", _L("On") } });
"survives the refinement."));
if (m_subdivide_adaptive) {
if (m_subdivide_target_mm <= 0.f && mv != nullptr) {
@@ -7186,8 +6962,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
preview_live();
hover_tip(_u8L("Spends the triangles where the texture actually bends - packed along ridges and edges, "
"sparse over flat ground - instead of spreading them evenly. The same detail for fewer "
"triangles on most textures."),
{ { "adaptive_off.png", _L("Off") }, { "adaptive_on.png", _L("On") } });
"triangles on most textures."));
// The edge-length target is a baseline in both modes. In feature mode it is what guarantees the
// curvature test can actually see the texture: left too coarse, a big triangle over a fine
@@ -7244,8 +7019,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
}
hover_tip(_u8L("Splits every triangle of the model into four, this many times over. Each step "
"quadruples the count - 3 turns 100 k triangles into 6.4 M - so prefer refining "
"only the painted area unless you need the whole model finer."),
{ { "subdivide.gif" } });
"only the painted area unless you need the whole model finer."));
}
{
@@ -7309,14 +7083,12 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
}
float_row("##remesh_edge", _L("Target edge"), &m_remesh_target_edge_mm, 0.1f, 20.f, "%.2f mm", true, 0.f);
hover_tip(_u8L("Triangle size the whole model is rebuilt with, in millimetres. Displacement works best on an "
"even mesh; this is what makes one out of an uneven import."),
{ { "remesh_before.png", _L("Before") }, { "remesh_after.png", _L("After") } });
"even mesh; this is what makes one out of an uneven import."));
m_imgui->bbl_checkbox(_L("Keep sharp edges") + "##remesh_sharp", m_remesh_keep_sharp_edges);
hover_tip(_u8L("Holds hard edges and open borders in place while the rest is remeshed. Without it "
"the remesher slides vertices along the surface and rounds every crisp edge off - "
"a cube comes back with wobbly edges."),
{ { "preserve_edges_off.png", _L("Off") }, { "preserve_edges_on.png", _L("On") } });
"a cube comes back with wobbly edges."));
ImGui::SameLine();
m_imgui->disabled_begin(!m_remesh_keep_sharp_edges);
ImGui::SetNextItemWidth(-1.f);
@@ -7332,8 +7104,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
hover_tip(_u8L("Rebuilds the whole model with triangles close to this edge length - splitting the big "
"ones and merging the small ones - so displacement has an even density to work with. "
"Replaces the geometry; your paint is carried onto the new triangles spatially, so it "
"survives (already-baked relief is kept too)."),
{ { "remesh_before.png", _L("Before") }, { "remesh_after.png", _L("After") } });
"survives (already-baked relief is kept too)."));
// Settings for the whole stack rather than one layer. Standard mode pins them instead of showing them.
if (mv != nullptr) {
@@ -7349,8 +7120,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
m_preview_params_dirty |= m_imgui->bbl_checkbox(_L("Smooth result"), opts.smooth_enabled);
hover_tip(_u8L("Smooths the geometry after the texture has been applied, to take the hard steps out of a "
"low-resolution image. Only what the displacement moved is touched. The Smoothing slider "
"on a layer is a different thing: it blurs the image before it is used."),
{ { "geometry_smoothing.gif" } });
"on a layer is a different thing: it blurs the image before it is used."));
if (opts.smooth_enabled) {
float percent = opts.smooth_strength * 100.f;
if (float_row("##dispsmooth", _L_CONTEXT("Strength", "Texture Displacement"), &percent, 1.f, 100.f, "%.0f %%", false, 0.f)) {
@@ -7414,8 +7184,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
"Relief already baked into the rest of the model is kept on top of it, so a "
"second bake somewhere else gets the same budget as the first. Raise it if the "
"warning below Resolution says the detail will not fit; 0 keeps every triangle "
"the refinement produced, however many that is."),
{ { "subdivide.gif" } });
"the refinement produced, however many that is."));
}
if (SHOW_PIPELINE_DEV_CONTROLS && opts.pipeline_v2) {
// Keeping the relief above the plate is not a checkbox: it is unconditional, in both pipelines
@@ -7430,8 +7199,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
hover_tip(_u8L("Collapse the thin triangles refinement inherits from the model's own "
"tessellation, before displacement samples them. A sliver's three corners "
"land on three unrelated parts of the texture, which is what makes the "
"relief look jagged."),
{ { "collapse_slivers_off.png", _L("Off") }, { "collapse_slivers_on.png", _L("On") } });
"relief look jagged."));
}
// Below both pipelines' own settings, because it replays whichever of them is selected.
if (SHOW_PIPELINE_DEV_CONTROLS)
@@ -7542,8 +7310,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
"smaller than the detail you want out of the texture - a 0.5 mm groove needs "
"triangles well under 0.5 mm. Smaller costs triangles fast: halving it needs "
"four times as many, and once they no longer fit the budget the bake simplifies "
"back down and the detail goes with it."),
{ { "subdivide.gif" } });
"back down and the detail goes with it."));
// What this resolution costs over what is painted, against what the budget allows. Refining
// past the budget is not an error - the bake simplifies back down to it - but the result
@@ -7586,8 +7353,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
m_imgui->disabled_end();
hover_tip(_u8L("How many thousand triangles the refinement may add to the model. The triangle "
"that fits worst is always split first, so even a run that spends the lot has "
"spent it where it shows most. Raise it if the relief still looks coarse."),
{ { "subdivide.gif" } });
"spent it where it shows most. Raise it if the relief still looks coarse."));
}
const float button_h = std::round(frame_h * 1.25f);
@@ -7639,17 +7405,18 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
m_imgui->pop_bold_font();
GLGizmoUtils::pop_orca_button_style();
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
panel_tooltip(mv != nullptr && !mv->is_texture_displacement_painted() ?
(m_seam_edit_mode ? _u8L("Nothing is painted yet. The UV editor's seam tool is on, so "
"strokes on the model mark seams instead of painting - turn "
"it off in the pane to paint.") :
_u8L("Nothing is painted yet.")) :
pro_mode() ?
_u8L("Turn the painted height maps into real geometry, by moving the vertices that are "
"already there. Use Subdivide first if the mesh is too coarse to show the detail.") :
_u8L("Turn the painted height maps into real geometry. The mesh is remeshed to an even "
"density and refined where the texture bends first, so the detail has vertices to "
"land on - all in one step."));
m_imgui->tooltip(mv != nullptr && !mv->is_texture_displacement_painted() ?
(m_seam_edit_mode ? _u8L("Nothing is painted yet. The UV editor's seam tool is on, so "
"strokes on the model mark seams instead of painting - turn "
"it off in the pane to paint.") :
_u8L("Nothing is painted yet.")) :
pro_mode() ?
_u8L("Turn the painted height maps into real geometry, by moving the vertices that are "
"already there. Use Subdivide first if the mesh is too coarse to show the detail.") :
_u8L("Turn the painted height maps into real geometry. The mesh is remeshed to an even "
"density and refined where the texture bends first, so the detail has vertices to "
"land on - all in one step."),
wrap_w);
ImGui::SameLine();
if (busy) {
@@ -4,7 +4,6 @@
#include "GLGizmoPainterBase.hpp"
#include "libslic3r/TextureBake/TextureBakeDebug.hpp"
#include "libslic3r/TextureDisplacement.hpp"
#include "slic3r/GUI/AnimatedTexture.hpp"
#include "slic3r/GUI/GLModel.hpp"
#include "slic3r/GUI/GLTexture.hpp"
#include "slic3r/GUI/I18N.hpp"
@@ -1026,50 +1025,6 @@ private:
std::map<std::string, IconManager::Icons> m_panel_icon_map; // file name -> [normal, colour, disabled]
bool m_panel_icons_tried = false;
void ensure_panel_icons();
// One illustration under a tooltip's text: a still image or an animation (see AnimatedTexture),
// named by file within resources/images/texture_displacement, with a caption under it. Several of
// them in one tooltip are laid out side by side, which is how a setting is shown as a comparison -
// the same model at two values of it.
struct TipMedia
{
std::string file;
wxString caption;
};
// Every tooltip the panel shows. Placed beside the panel rather than under the mouse: the panel is
// pinned to the right edge of the canvas, so a tooltip left to follow the mouse covers the control
// it is explaining, and one carrying an illustration covers most of the panel with it.
//
// `media` is drawn under the text, at the tooltip's full width; files that are missing or will not
// decode are skipped, so a tooltip whose illustration has not been drawn yet is exactly the text
// tooltip it was before.
void panel_tooltip(const std::string &text, const std::vector<TipMedia> &media = {});
void panel_tooltip(const wxString &text, const std::vector<TipMedia> &media = {});
void render_tip_media(const std::vector<TipMedia> &media, float width);
// Screen rectangle a tooltip places itself beside: the panel window, or - while the texture picker
// is up and tooltips are coming from inside it - the picker popup, which already sits beside the
// panel. Both are set from the window's own rectangle as it is drawn.
ImVec2 m_tooltip_anchor_min{0.f, 0.f};
ImVec2 m_tooltip_anchor_max{0.f, 0.f};
// Illustrations any tooltip has shown, decoded and uploaded once (see AnimatedTexture.hpp).
AnimatedTextureCache m_tip_media;
// Which tooltip's illustrations are on screen, and when they first appeared: an animation plays
// from its first frame each time its tooltip opens, rather than from wherever a free-running clock
// happened to be.
std::string m_tip_media_key;
double m_tip_media_start = 0.;
int m_tip_media_frame = -2; // the frame it was last drawn on, to spot the tooltip reopening
// How long the pointer has rested on the control a tooltip belongs to, and when one was last on
// screen, both on the real clock tip_now() keeps (see the .cpp for why ImGui's own will not do).
ImVec2 m_tip_item{-1.f, -1.f};
double m_tip_hover_since = 0.;
double m_tip_last_shown = -1e9;
// The frame a tooltip was last drawn on, and how many it has been drawn on in a row: ImGui hides
// one on its first frame while it measures it, so it has to be kept on screen to be seen at all.
int m_tip_shown_frame = -2;
int m_tip_settled = 0;
};
} // namespace Slic3r::GUI
+3 -2
View File
@@ -27,7 +27,8 @@ WebDialog::WebDialog(wxWindow* parent,
SubmitHandler on_submit,
CloseHandler on_close,
CloseHandler on_destroyed,
long wx_style)
long wx_style,
const wxString& profile)
: WebViewHostDialog(parent, wxID_ANY, title, wxDefaultPosition, size, wx_style)
, m_html(html)
, m_on_message(std::move(on_message))
@@ -37,7 +38,7 @@ WebDialog::WebDialog(wxWindow* parent,
{
// A tiny bundled bootstrap page brings the webview up; the real plugin HTML
// is swapped in via SetPage once the bootstrap finishes loading.
create_webview(web_hosting::BOOTSTRAP_PAGE, title, size, wxSize(320, 240));
create_webview(web_hosting::BOOTSTRAP_PAGE, title, size, wxSize(320, 240), profile);
// Paint the window/webview in the themed background so there is no white
// flash before the (transparent) bootstrap page and plugin HTML render.
+2 -1
View File
@@ -49,7 +49,8 @@ public:
SubmitHandler on_submit,
CloseHandler on_close,
CloseHandler on_destroyed,
long wx_style = wxSYSTEM_MENU | wxCAPTION | wxCLOSE_BOX | wxMAXIMIZE_BOX | wxRESIZE_BORDER);
long wx_style = wxSYSTEM_MENU | wxCAPTION | wxCLOSE_BOX | wxMAXIMIZE_BOX | wxRESIZE_BORDER,
const wxString& profile = wxEmptyString);
~WebDialog() override;
static void post_message(WebDialog* dialog, const nlohmann::json& data);
+2 -2
View File
@@ -17,7 +17,7 @@
namespace Slic3r { namespace GUI {
WebPanel::WebPanel(wxWindow* parent, const char* bridge_script)
WebPanel::WebPanel(wxWindow* parent, const char* bridge_script, const wxString& profile)
: wxPanel(parent, wxID_ANY)
{
SetBackgroundColour(wxGetApp().get_window_default_clr());
@@ -25,7 +25,7 @@ WebPanel::WebPanel(wxWindow* parent, const char* bridge_script)
SetSizer(sizer);
// Never null: WebView::CreateWebView substitutes a placeholder view when no backend is available.
m_browser = WebView::CreateWebView(this, web_hosting::bootstrap_url());
m_browser = WebView::CreateWebView(this, web_hosting::bootstrap_url(), profile);
m_browser->SetBackgroundColour(GetBackgroundColour());
m_browser->AddUserScript(wxString::FromUTF8(WebViewHostDialog::theme_user_script()));
m_browser->AddUserScript(wxString::FromUTF8(WebViewHostDialog::element_defaults_user_script()));
+2 -1
View File
@@ -4,6 +4,7 @@
#include <wx/event.h>
#include <wx/panel.h>
#include <wx/string.h>
#include <wx/webview.h>
#include <optional>
@@ -22,7 +23,7 @@ namespace Slic3r { namespace GUI {
class WebPanel : public wxPanel
{
public:
WebPanel(wxWindow* parent, const char* bridge_script);
WebPanel(wxWindow* parent, const char* bridge_script, const wxString& profile = wxEmptyString);
protected:
wxWebView* browser() const { return m_browser; }
+59 -4
View File
@@ -3,6 +3,8 @@
#include "slic3r/GUI/GUI_App.hpp"
#include "slic3r/Utils/MacDarkMode.hpp"
#include <libslic3r/Utils.hpp>
#include <algorithm>
#include <boost/log/trivial.hpp>
@@ -12,6 +14,7 @@
#include <exception>
#include <thread>
#include <wx/chartype.h>
#include <wx/setup.h>
#include <wx/webview.h>
#include <wx/string.h>
@@ -72,6 +75,26 @@ webkit_javascript_result_unref (WebKitJavascriptResult *js_result);
}
#endif
// Private storage root for an isolated (plugin) profile. Plugin pages must not reach the shared
// default store's cookies/session (e.g. the OrcaCloud login), so each profile gets its own dir.
// macOS isolates with an in-memory store instead (see CreateWebView), so this is unused there.
#if !defined(__WXOSX__)
static wxString isolated_profile_path(const wxString& profile)
{
wxString safe;
safe.reserve(profile.size());
for (wxString::const_iterator it = profile.begin(); it != profile.end(); ++it) {
const wxChar c = *it;
const bool keep = (c >= wxT('a') && c <= wxT('z')) || (c >= wxT('A') && c <= wxT('Z')) ||
(c >= wxT('0') && c <= wxT('9')) || c == wxT('.') || c == wxT('-') || c == wxT('_');
safe += keep ? c : wxT('_');
}
if (safe.empty())
safe = wxT("default");
return wxString::FromUTF8((boost::filesystem::path(Slic3r::data_dir()) / "web_profiles" / safe.ToStdString()).string());
}
#endif
#ifdef __WIN32__
// Run Download and Install in another thread so we don't block the UI thread
DWORD DownloadAndInstallWV2RT() {
@@ -135,6 +158,9 @@ DWORD DownloadAndInstallWV2RT() {
class WebViewEdge : public wxWebViewEdge
{
public:
WebViewEdge() = default;
explicit WebViewEdge(const wxWebViewConfiguration& config) : wxWebViewEdge(config) {}
bool SetUserAgent(const wxString &userAgent) override
{
bool dark = userAgent.Contains("dark");
@@ -213,6 +239,11 @@ public:
{
}
explicit WebViewWebKit(const wxWebViewConfiguration& config)
: wxWebViewWebKit(config)
{
}
~WebViewWebKit() override
{
RemoveScriptMessageHandler("wx");
@@ -284,7 +315,7 @@ static WebViewRef *webview_ref(wxWebView *webView)
return webView ? static_cast<WebViewRef *>(webView->GetRefData()) : nullptr;
}
wxWebView* WebView::CreateWebView(wxWindow * parent, wxString const & url)
wxWebView* WebView::CreateWebView(wxWindow * parent, wxString const & url, wxString const & profile)
{
#if wxUSE_WEBVIEW_EDGE
// Check if a fixed version of edge is present in
@@ -304,12 +335,36 @@ wxWebView* WebView::CreateWebView(wxWindow * parent, wxString const & url)
if (!url2.empty()) { url2 = wxURI(url2).BuildURI(); }
BOOST_LOG_TRIVIAL(trace) << __FUNCTION__ << ": " << url2.ToUTF8();
const bool isolated = !profile.IsEmpty();
#ifdef __WIN32__
wxWebView* webView = new WebViewEdge;
wxWebView* webView = nullptr;
if (isolated) {
wxWebViewConfiguration config = wxWebView::NewConfiguration(wxWebViewBackendEdge);
config.SetDataPath(isolated_profile_path(profile));
webView = new WebViewEdge(config);
} else {
webView = new WebViewEdge;
}
#elif defined(__WXOSX__)
wxWebView* webView = new WebViewWebKit;
wxWebView* webView = nullptr;
if (isolated) {
// wx has no SetDataPath on macOS; give the view its own in-memory store instead.
wxWebViewConfiguration config = wxWebView::NewConfiguration(wxWebViewBackendWebKit);
Slic3r::GUI::WKWebViewConfiguration_setNonPersistentDataStore(config.GetNativeConfiguration());
webView = new WebViewWebKit(config);
} else {
webView = new WebViewWebKit;
}
#else
auto webView = wxWebView::New();
wxWebView* webView = nullptr;
if (isolated) {
wxWebViewConfiguration config = wxWebView::NewConfiguration(wxWebViewBackendWebKit);
config.SetDataPath(isolated_profile_path(profile));
webView = wxWebView::New(config);
} else {
webView = wxWebView::New();
}
#endif
if (webView) {
webView->SetBackgroundColour(StateColor::darkModeColorFor(*wxWHITE));
+5 -1
View File
@@ -14,7 +14,11 @@ wxDECLARE_EVENT(EVT_WEBVIEW_RECREATED, wxCommandEvent);
class WebView
{
public:
static wxWebView *CreateWebView(wxWindow *parent, wxString const &url);
// `profile` empty -> the shared default backend store used by all trusted UI.
// `profile` non-empty -> a private store keyed by `profile`, so this view shares no
// cookies/storage/session with the shared store or any other profile. Used for
// plugin-supplied (untrusted) content.
static wxWebView *CreateWebView(wxWindow *parent, wxString const &url, wxString const &profile = wxEmptyString);
#if wxUSE_WEBVIEW_EDGE
static bool CheckWebViewRuntime();
static bool DownloadAndInstallWebViewRuntime();
+3 -2
View File
@@ -172,7 +172,8 @@ WebViewHostDialog::WebViewHostDialog(wxWindow* parent,
bool WebViewHostDialog::create_webview(const std::string& resource_path,
const wxString& title,
const wxSize& dialog_size,
const wxSize& min_size)
const wxSize& min_size,
const wxString& profile)
{
SetTitle(title);
SetMinSize(FromDIP(min_size));
@@ -181,7 +182,7 @@ bool WebViewHostDialog::create_webview(const std::string& resource_path,
wxBoxSizer* topsizer = new wxBoxSizer(wxVERTICAL);
// Created empty and loaded below, after the user scripts are added, so the first load already has them.
m_browser = WebView::CreateWebView(this, wxEmptyString);
m_browser = WebView::CreateWebView(this, wxEmptyString, profile);
if (m_browser == nullptr) {
wxLogError("Could not init m_browser");
delete topsizer;
+2 -1
View File
@@ -38,7 +38,8 @@ public:
bool create_webview(const std::string& resource_path,
const wxString& title,
const wxSize& dialog_size = wxSize(820, 660),
const wxSize& min_size = wxSize(640, 640));
const wxSize& min_size = wxSize(640, 640),
const wxString& profile = wxEmptyString);
void load_url(const wxString& url);
bool run_script(const wxString& script);
+1
View File
@@ -12,6 +12,7 @@ extern double mac_max_scaling_factor();
extern void set_miniaturizable(void * window);
void WKWebView_evaluateJavaScript(void * web, wxString const & script, void (*callback)(wxString const &));
void WKWebView_setTransparentBackground(void * web);
void WKWebViewConfiguration_setNonPersistentDataStore(void * configuration);
void WKWebView_force_display(void * web);
void set_tag_when_enter_full_screen(bool isfullscreen);
void set_title_colour_after_set_title(void * window);
+9
View File
@@ -102,6 +102,15 @@ void WKWebView_setTransparentBackground(void * web)
[webView registerForDraggedTypes: @[NSFilenamesPboardType]];
}
// Give a WKWebViewConfiguration its own in-memory website data store, so the view's cookies and
// storage neither come from nor leak into the shared default store. Used to isolate plugin web views.
void WKWebViewConfiguration_setNonPersistentDataStore(void * configuration)
{
WKWebViewConfiguration * config = (WKWebViewConfiguration*)configuration;
if (config)
config.websiteDataStore = [WKWebsiteDataStore nonPersistentDataStore];
}
// Force a WKWebView to re-lay-out and repaint. Needed for chrome-less popups: the window is
// transparent, so a WKWebView whose layer has no pending frame leaves the whole window invisible
// until the user generates input (scroll/arrow). setNeedsDisplay alone does not always reach the
+3 -2
View File
@@ -390,7 +390,8 @@ py::object ui_create_window(const std::string& html, const std::string& title, i
auto* dlg = new GUI::WebDialog(ui_parent(), wxString::FromUTF8(title), html,
wxSize(w, h), std::move(msg_adapter), std::move(submit_adapter),
std::move(on_close), std::move(on_destroyed), PLUGIN_WX_STYLE);
std::move(on_close), std::move(on_destroyed), PLUGIN_WX_STYLE,
wxString::FromUTF8(plugin_key));
UiRegistry::instance().bind(new_id, dlg, plugin_key);
if (modal) {
dlg->ShowModal();
@@ -487,7 +488,7 @@ py::object ui_create_dock_panel(const std::string& html, const std::string& titl
auto on_destroyed = [new_id]() { UiRegistry::instance().remove(new_id); };
auto* panel = new GUI::DockPanel(plater, html, std::move(msg_adapter), make_close_adapter(close_holder),
std::move(on_destroyed));
std::move(on_destroyed), wxString::FromUTF8(plugin_key));
UiRegistry::instance().bind(new_id, panel, plugin_key);
plater->add_dock_pane(panel, GUI::plugin_pane_name(plugin_key, title), wxString::FromUTF8(title), dock,
wxSize(w, h), [panel]() { panel->fire_close(); });
+2 -1
View File
@@ -77,7 +77,8 @@ constexpr char PLUGIN_PAGE_BRIDGE_JS[] = R"JS(
} // namespace
PluginPage::PluginPage(wxWindow* parent, std::shared_ptr<PagesPluginCapability> capability)
: GUI::WebPanel(parent, PLUGIN_PAGE_BRIDGE_JS)
: GUI::WebPanel(parent, PLUGIN_PAGE_BRIDGE_JS,
wxString::FromUTF8(capability ? capability->identity().plugin_key : std::string()))
, m_cap(std::move(capability))
, m_lifetime(std::make_shared<std::atomic<PluginPage*>>(this))
{