mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-13 19:01:21 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
682814878d | ||
|
|
30e48183e3 | ||
|
|
8ecfee6a21 | ||
|
|
fbf4652e12 | ||
|
|
be9ff50130 | ||
|
|
dee1f1715f | ||
|
|
fcb1f62bd9 |
@@ -1,65 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,476 +0,0 @@
|
|||||||
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 });
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Qidi",
|
"name": "Qidi",
|
||||||
"version": "02.04.00.17",
|
"version": "02.04.00.16",
|
||||||
"force_update": "0",
|
"force_update": "0",
|
||||||
"description": "Qidi configurations",
|
"description": "Qidi configurations",
|
||||||
"machine_model_list": [
|
"machine_model_list": [
|
||||||
|
|||||||
@@ -83,7 +83,7 @@
|
|||||||
"extruder_clearance_height_to_lid": "120",
|
"extruder_clearance_height_to_lid": "120",
|
||||||
"layer_change_gcode": ";BEFORE_LAYER_CHANGE\n;[layer_z]\nLOG_Z\nG92 E0\n",
|
"layer_change_gcode": ";BEFORE_LAYER_CHANGE\n;[layer_z]\nLOG_Z\nG92 E0\n",
|
||||||
"machine_end_gcode": "M141 S0\nM104 S0\nM140 S0\nG1 E-3 F1800\nG0 Z{min(max_print_height, max_layer_z + 3)} F600\nG0 X0 Y0 F12000\n{if max_layer_z < max_print_height / 2}G1 Z{max_print_height / 2 + 10} F600{else}G1 Z{min(max_print_height, max_layer_z + 3)}{endif}",
|
"machine_end_gcode": "M141 S0\nM104 S0\nM140 S0\nG1 E-3 F1800\nG0 Z{min(max_print_height, max_layer_z + 3)} F600\nG0 X0 Y0 F12000\n{if max_layer_z < max_print_height / 2}G1 Z{max_print_height / 2 + 10} F600{else}G1 Z{min(max_print_height, max_layer_z + 3)}{endif}",
|
||||||
"machine_start_gcode": "PRINT_START BED=[bed_temperature_initial_layer_single] HOTEND=[nozzle_temperature_initial_layer] CHAMBER=[chamber_temperature]\nM83\nM140 S[bed_temperature_initial_layer_single]\nM104 S[nozzle_temperature_initial_layer]\nG4 P3000\nG0 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0)} Y{max((min(print_bed_max[1] - 5, first_layer_print_min[1] + 80) - 85), 0)} Z5 F6000\nG0 Z[initial_layer_print_height] F600\nG1 E3 F1800\nG1 X{(min(print_bed_max[0], first_layer_print_min[0] + 80))} E{85 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1] - 5, first_layer_print_min[1] + 80) - 85), 0) + 2} E{2 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85), 0)} E{85 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1] - 5, first_layer_print_min[1] + 80) - 85), 0) + 85} E{83 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85), 0) + 2} E{2 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1] - 5, first_layer_print_min[1] + 80) - 85), 0) + 3} E{82 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85), 0) + 3} Z0\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85), 0) + 6}\nG1 Z1 F600\n",
|
"machine_start_gcode": "PRINT_START BED=[hot_plate_temp_initial_layer] HOTEND=[nozzle_temperature_initial_layer] CHAMBER=[chamber_temperature]\nM83\nM140 S[hot_plate_temp_initial_layer]\nM104 S[nozzle_temperature_initial_layer]\nG4 P3000\nG0 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0)} Y{max((min(print_bed_max[1] - 5, first_layer_print_min[1] + 80) - 85), 0)} Z5 F6000\nG0 Z[initial_layer_print_height] F600\nG1 E3 F1800\nG1 X{(min(print_bed_max[0], first_layer_print_min[0] + 80))} E{85 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1] - 5, first_layer_print_min[1] + 80) - 85), 0) + 2} E{2 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85), 0)} E{85 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1] - 5, first_layer_print_min[1] + 80) - 85), 0) + 85} E{83 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85), 0) + 2} E{2 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1] - 5, first_layer_print_min[1] + 80) - 85), 0) + 3} E{82 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85), 0) + 3} Z0\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85), 0) + 6}\nG1 Z1 F600\n",
|
||||||
"thumbnails_format": "PNG",
|
"thumbnails_format": "PNG",
|
||||||
"default_filament_profile": [
|
"default_filament_profile": [
|
||||||
"Generic PLA @Qidi Q1 Pro 0.4 nozzle"
|
"Generic PLA @Qidi Q1 Pro 0.4 nozzle"
|
||||||
|
|||||||
@@ -100,7 +100,7 @@
|
|||||||
"default_filament_profile": [
|
"default_filament_profile": [
|
||||||
"Generic PLA @Qidi"
|
"Generic PLA @Qidi"
|
||||||
],
|
],
|
||||||
"machine_start_gcode": "G28\nM140 S[bed_temperature_initial_layer_single]\nM190 S[bed_temperature_initial_layer_single]\nM109 S[nozzle_temperature_initial_layer]\nG92 E-19\nG0 Y5 Z0.3 F3600\nG1 X5 E0 F2400\n",
|
"machine_start_gcode": "G28\nM140 S[hot_plate_temp_initial_layer]\nM190 S[hot_plate_temp_initial_layer]\nM109 S[nozzle_temperature_initial_layer]\nG92 E-19\nG0 Y5 Z0.3 F3600\nG1 X5 E0 F2400\n",
|
||||||
"machine_end_gcode": "M104 S0\nM140 S0\n;Retract the filament\nG92 E0\nG1 E-3 F300\nG28\nM84\n",
|
"machine_end_gcode": "M104 S0\nM140 S0\n;Retract the filament\nG92 E0\nG1 E-3 F300\nG28\nM84\n",
|
||||||
"scan_first_layer": "0"
|
"scan_first_layer": "0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
"210x210/COLPIC",
|
"210x210/COLPIC",
|
||||||
"110x110/PNG"
|
"110x110/PNG"
|
||||||
],
|
],
|
||||||
"machine_start_gcode": "PRINT_START\nG28\nM141 S0\nG0 Z50 F600\nM190 S[bed_temperature_initial_layer_single]\nG28 Z\nG29; mesh bed leveling ,comment this code to close it\nG0 X0 Y0 Z50 F6000\nM141 S{overall_chamber_temperature}\nM109 S[nozzle_temperature_initial_layer]\nM106 P3 S255\nM83\nG4 P3000\nG0 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0)} Y{max((min(print_bed_max[1], first_layer_print_min[1] + 80) - 85),0)} Z5 F6000\nG0 Z[initial_layer_print_height] F600\nG1 E3 F1800\nG1 X{(min(print_bed_max[0], first_layer_print_min[0] + 80))} E{85 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1], first_layer_print_min[1] + 80) - 85),0) + 2} E{2 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0)} E{85 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1], first_layer_print_min[1] + 80) - 85),0) + 85} E{83 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0) + 2} E{2 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1], first_layer_print_min[1] + 80) - 85),0) + 3} E{82 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0) + 12} E{-10 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 E{10 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\n",
|
"machine_start_gcode": "PRINT_START\nG28\nM141 S0\nG0 Z50 F600\nM190 S[hot_plate_temp_initial_layer]\nG28 Z\nG29; mesh bed leveling ,comment this code to close it\nG0 X0 Y0 Z50 F6000\nM141 S{overall_chamber_temperature}\nM109 S[nozzle_temperature_initial_layer]\nM106 P3 S255\nM83\nG4 P3000\nG0 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0)} Y{max((min(print_bed_max[1], first_layer_print_min[1] + 80) - 85),0)} Z5 F6000\nG0 Z[initial_layer_print_height] F600\nG1 E3 F1800\nG1 X{(min(print_bed_max[0], first_layer_print_min[0] + 80))} E{85 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1], first_layer_print_min[1] + 80) - 85),0) + 2} E{2 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0)} E{85 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1], first_layer_print_min[1] + 80) - 85),0) + 85} E{83 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0) + 2} E{2 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1], first_layer_print_min[1] + 80) - 85),0) + 3} E{82 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0) + 12} E{-10 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 E{10 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\n",
|
||||||
"machine_end_gcode": "M141 S0\nM104 S0\nM140 S0\nG1 E-3 F1800\nG0 Z{min(max_print_height, max_layer_z + 3)} F600\nG0 X0 Y0 F12000\n{if max_layer_z < max_print_height / 2}G1 Z{max_print_height / 2 + 10} F600{else}G1 Z{min(max_print_height, max_layer_z + 3)}{endif}",
|
"machine_end_gcode": "M141 S0\nM104 S0\nM140 S0\nG1 E-3 F1800\nG0 Z{min(max_print_height, max_layer_z + 3)} F600\nG0 X0 Y0 F12000\n{if max_layer_z < max_print_height / 2}G1 Z{max_print_height / 2 + 10} F600{else}G1 Z{min(max_print_height, max_layer_z + 3)}{endif}",
|
||||||
"scan_first_layer": "0"
|
"scan_first_layer": "0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
#include "libslic3r/BrimEarsPoint.hpp"
|
#include "libslic3r/BrimEarsPoint.hpp"
|
||||||
#include "libslic3r/Config.hpp"
|
#include "libslic3r/Config.hpp"
|
||||||
#include "libslic3r/CustomGCode.hpp"
|
#include "libslic3r/CustomGCode.hpp"
|
||||||
|
#include "libslic3r/TextureDisplacement.hpp"
|
||||||
#include "libslic3r_version.h"
|
#include "libslic3r_version.h"
|
||||||
|
|
||||||
#include "bbs_3mf.hpp"
|
#include "bbs_3mf.hpp"
|
||||||
@@ -63,6 +64,7 @@
|
|||||||
#include <ostream>
|
#include <ostream>
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
#include <set>
|
#include <set>
|
||||||
|
#include <iterator>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <map>
|
#include <map>
|
||||||
@@ -354,6 +356,13 @@ static constexpr const char* CUSTOM_SUPPORTS_ATTR = "paint_supports";
|
|||||||
static constexpr const char* CUSTOM_FUZZY_SKIN_ATTR = "paint_fuzzy_skin";
|
static constexpr const char* CUSTOM_FUZZY_SKIN_ATTR = "paint_fuzzy_skin";
|
||||||
static constexpr const char* CUSTOM_SEAM_ATTR = "paint_seam";
|
static constexpr const char* CUSTOM_SEAM_ATTR = "paint_seam";
|
||||||
static constexpr const char* MMU_SEGMENTATION_ATTR = "paint_color";
|
static constexpr const char* MMU_SEGMENTATION_ATTR = "paint_color";
|
||||||
|
// Texture displacement. One paint mask per layer slot, mirroring paint_color; the layer stack itself is
|
||||||
|
// a JSON file in the archive, named by the volume metadata key below (see add_texture_displacement()).
|
||||||
|
static constexpr const char* TEXTURE_DISPLACEMENT_ATTRS[Slic3r::TEXTURE_DISPLACEMENT_MAX_LAYERS] = {
|
||||||
|
"paint_texture_0", "paint_texture_1", "paint_texture_2", "paint_texture_3",
|
||||||
|
"paint_texture_4", "paint_texture_5", "paint_texture_6", "paint_texture_7" };
|
||||||
|
static constexpr const char* TEXTURE_DISPLACEMENT_KEY = "texture_displacement";
|
||||||
|
static constexpr const char* TEXTURE_DISPLACEMENT_DIR = "Metadata/texture_displacement/";
|
||||||
// BBS
|
// BBS
|
||||||
static constexpr const char* FACE_PROPERTY_ATTR = "face_property";
|
static constexpr const char* FACE_PROPERTY_ATTR = "face_property";
|
||||||
|
|
||||||
@@ -847,11 +856,30 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
|
|||||||
std::vector<std::string> custom_seam;
|
std::vector<std::string> custom_seam;
|
||||||
std::vector<std::string> mmu_segmentation;
|
std::vector<std::string> mmu_segmentation;
|
||||||
std::vector<std::string> fuzzy_skin;
|
std::vector<std::string> fuzzy_skin;
|
||||||
|
// One per texture displacement layer slot, indexed by triangle like the masks above, but
|
||||||
|
// empty for a slot nothing paints and no longer than its last painted triangle.
|
||||||
|
// See push_texture_displacement().
|
||||||
|
std::vector<std::string> texture_displacement[TEXTURE_DISPLACEMENT_MAX_LAYERS];
|
||||||
// BBS
|
// BBS
|
||||||
std::vector<std::string> face_properties;
|
std::vector<std::string> face_properties;
|
||||||
|
|
||||||
bool empty() { return vertices.empty() || triangles.empty(); }
|
bool empty() { return vertices.empty() || triangles.empty(); }
|
||||||
|
|
||||||
|
// Records the paint mask of the triangle just appended, for one layer slot. A slot no
|
||||||
|
// triangle names keeps its vector empty instead of holding one empty string per triangle:
|
||||||
|
// eight slots over a million triangles would otherwise cost hundreds of megabytes, for
|
||||||
|
// every project, whether or not it uses the feature. The vector is padded up to the
|
||||||
|
// triangles seen so far the first time a value turns up, so it stays indexed by triangle,
|
||||||
|
// and both readers stop at its size, so trailing triangles without a value need nothing.
|
||||||
|
void push_texture_displacement(int slot, std::string &&mask_string) {
|
||||||
|
std::vector<std::string> &mask = texture_displacement[slot];
|
||||||
|
if (mask_string.empty() && mask.empty())
|
||||||
|
return;
|
||||||
|
assert(!triangles.empty());
|
||||||
|
mask.resize(triangles.size() - 1);
|
||||||
|
mask.push_back(std::move(mask_string));
|
||||||
|
}
|
||||||
|
|
||||||
// backup & restore
|
// backup & restore
|
||||||
void swap(Geometry& o) {
|
void swap(Geometry& o) {
|
||||||
std::swap(vertices, o.vertices);
|
std::swap(vertices, o.vertices);
|
||||||
@@ -867,6 +895,8 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
|
|||||||
custom_seam.clear();
|
custom_seam.clear();
|
||||||
mmu_segmentation.clear();
|
mmu_segmentation.clear();
|
||||||
fuzzy_skin.clear();
|
fuzzy_skin.clear();
|
||||||
|
for (std::vector<std::string> &slot : texture_displacement)
|
||||||
|
slot.clear();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1200,6 +1230,11 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
|
|||||||
/*IdToSlaSupportPointsMap m_sla_support_points;
|
/*IdToSlaSupportPointsMap m_sla_support_points;
|
||||||
IdToSlaDrainHolesMap m_sla_drain_holes;*/
|
IdToSlaDrainHolesMap m_sla_drain_holes;*/
|
||||||
PathToEmbossShapeFileMap m_path_to_emboss_shape_files;
|
PathToEmbossShapeFileMap m_path_to_emboss_shape_files;
|
||||||
|
// Texture displacement: every file under Metadata/texture_displacement/, by archive path. Volumes
|
||||||
|
// are built only after the whole archive has been walked, so by the time a volume names its JSON
|
||||||
|
// every file it could refer to is already in here, whatever order the archive happened to be in.
|
||||||
|
std::map<std::string, std::string> m_texture_displacement_files;
|
||||||
|
void _apply_texture_displacement(ModelVolume &volume, const std::string &json_path);
|
||||||
std::string m_curr_metadata_name;
|
std::string m_curr_metadata_name;
|
||||||
std::string m_curr_characters;
|
std::string m_curr_characters;
|
||||||
std::string m_name;
|
std::string m_name;
|
||||||
@@ -2073,6 +2108,13 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
|
|||||||
else if (_is_svg_shape_file(name)) {
|
else if (_is_svg_shape_file(name)) {
|
||||||
_extract_embossed_svg_shape_file(name, archive, stat);
|
_extract_embossed_svg_shape_file(name, archive, stat);
|
||||||
}
|
}
|
||||||
|
else if (boost::algorithm::istarts_with(name, TEXTURE_DISPLACEMENT_DIR)) {
|
||||||
|
std::string contents(stat.m_uncomp_size, '\0');
|
||||||
|
if (mz_zip_reader_extract_to_mem(&archive, stat.m_file_index, contents.data(), stat.m_uncomp_size, 0))
|
||||||
|
m_texture_displacement_files.emplace(name, std::move(contents));
|
||||||
|
else
|
||||||
|
add_error("Error while reading texture displacement data");
|
||||||
|
}
|
||||||
else if (!dont_load_config && boost::algorithm::iequals(name, SLICE_INFO_CONFIG_FILE)) {
|
else if (!dont_load_config && boost::algorithm::iequals(name, SLICE_INFO_CONFIG_FILE)) {
|
||||||
m_parsing_slice_info = true;
|
m_parsing_slice_info = true;
|
||||||
//extract slice info from archive
|
//extract slice info from archive
|
||||||
@@ -3310,6 +3352,48 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
|
|||||||
}
|
}
|
||||||
}*/
|
}*/
|
||||||
|
|
||||||
|
// Restores one volume's texture displacement stack from the JSON it named. Called while the volume is
|
||||||
|
// being built, which is after the whole archive has been walked - so every file it can refer to is
|
||||||
|
// already extracted, and no deferral is needed.
|
||||||
|
//
|
||||||
|
// A missing or malformed JSON leaves the volume with no layers but keeps the paint mask it already
|
||||||
|
// loaded, which is the same state as a project saved by a build without the feature: recoverable by
|
||||||
|
// picking the texture again rather than a hard failure.
|
||||||
|
void _BBS_3MF_Importer::_apply_texture_displacement(ModelVolume &volume, const std::string &json_path)
|
||||||
|
{
|
||||||
|
const auto json = m_texture_displacement_files.find(json_path);
|
||||||
|
if (json == m_texture_displacement_files.end()) {
|
||||||
|
add_error("Missing texture displacement data: " + json_path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::vector<TextureDisplacementLayer> layers;
|
||||||
|
TextureDisplacementOptions options;
|
||||||
|
if (!texture_displacement_layers_from_json(json->second, layers, options)) {
|
||||||
|
add_error("Malformed texture displacement data: " + json_path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The pixels come from the archive and nowhere else. A layer the archive carries no image for -
|
||||||
|
// saved while its texture had not been loaded, or written by a build that stored only the path -
|
||||||
|
// keeps its settings without an image, which the user recovers by picking the texture again.
|
||||||
|
// `layer.path` is left alone on purpose: it names a file on the machine the project was made on,
|
||||||
|
// so reading it here would let any project choose a local file to open - a UNC path among them -
|
||||||
|
// and have the next save copy it into the archive. The EmbossShape SVGs are read from the archive
|
||||||
|
// only, for the same reason.
|
||||||
|
for (TextureDisplacementLayer &layer : layers) {
|
||||||
|
if (layer.path_in_3mf.empty())
|
||||||
|
continue;
|
||||||
|
const auto image = m_texture_displacement_files.find(layer.path_in_3mf);
|
||||||
|
if (image == m_texture_displacement_files.end()) {
|
||||||
|
add_error("Missing texture displacement image: " + layer.path_in_3mf);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
layer.image_data = std::make_shared<std::vector<unsigned char>>(image->second.begin(),
|
||||||
|
image->second.end());
|
||||||
|
}
|
||||||
|
volume.texture_displacement_layers = std::move(layers);
|
||||||
|
volume.texture_displacement_options = options;
|
||||||
|
}
|
||||||
|
|
||||||
void _BBS_3MF_Importer::_extract_embossed_svg_shape_file(const std::string &filename, mz_zip_archive &archive, const mz_zip_archive_file_stat &stat){
|
void _BBS_3MF_Importer::_extract_embossed_svg_shape_file(const std::string &filename, mz_zip_archive &archive, const mz_zip_archive_file_stat &stat){
|
||||||
assert(m_path_to_emboss_shape_files.find(filename) == m_path_to_emboss_shape_files.end());
|
assert(m_path_to_emboss_shape_files.find(filename) == m_path_to_emboss_shape_files.end());
|
||||||
auto file = std::make_unique<std::string>(stat.m_uncomp_size, '\0');
|
auto file = std::make_unique<std::string>(stat.m_uncomp_size, '\0');
|
||||||
@@ -3966,6 +4050,9 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
|
|||||||
m_curr_object->geometry.custom_seam.push_back(bbs_get_attribute_value_string(attributes, num_attributes, CUSTOM_SEAM_ATTR));
|
m_curr_object->geometry.custom_seam.push_back(bbs_get_attribute_value_string(attributes, num_attributes, CUSTOM_SEAM_ATTR));
|
||||||
m_curr_object->geometry.mmu_segmentation.push_back(bbs_get_attribute_value_string(attributes, num_attributes, MMU_SEGMENTATION_ATTR));
|
m_curr_object->geometry.mmu_segmentation.push_back(bbs_get_attribute_value_string(attributes, num_attributes, MMU_SEGMENTATION_ATTR));
|
||||||
m_curr_object->geometry.fuzzy_skin.push_back(bbs_get_attribute_value_string(attributes, num_attributes, CUSTOM_FUZZY_SKIN_ATTR));
|
m_curr_object->geometry.fuzzy_skin.push_back(bbs_get_attribute_value_string(attributes, num_attributes, CUSTOM_FUZZY_SKIN_ATTR));
|
||||||
|
for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot)
|
||||||
|
m_curr_object->geometry.push_texture_displacement(
|
||||||
|
slot, bbs_get_attribute_value_string(attributes, num_attributes, TEXTURE_DISPLACEMENT_ATTRS[slot]));
|
||||||
// BBS
|
// BBS
|
||||||
m_curr_object->geometry.face_properties.push_back(bbs_get_attribute_value_string(attributes, num_attributes, FACE_PROPERTY_ATTR));
|
m_curr_object->geometry.face_properties.push_back(bbs_get_attribute_value_string(attributes, num_attributes, FACE_PROPERTY_ATTR));
|
||||||
}
|
}
|
||||||
@@ -5317,6 +5404,18 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
|
|||||||
volume->mmu_segmentation_facets.touch();
|
volume->mmu_segmentation_facets.touch();
|
||||||
volume->fuzzy_skin_facets.shrink_to_fit();
|
volume->fuzzy_skin_facets.shrink_to_fit();
|
||||||
volume->fuzzy_skin_facets.touch();
|
volume->fuzzy_skin_facets.touch();
|
||||||
|
for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot) {
|
||||||
|
const std::vector<std::string> &mask = sub_object->geometry.texture_displacement[slot];
|
||||||
|
if (mask.empty())
|
||||||
|
continue; // written by a build without the feature
|
||||||
|
FacetsAnnotation &facets = volume->texture_displacement_facet(slot);
|
||||||
|
facets.reserve(triangles_count);
|
||||||
|
for (size_t i = 0; i < triangles_count && i < mask.size(); ++i)
|
||||||
|
if (!mask[i].empty())
|
||||||
|
facets.set_triangle_from_string(i, mask[i]);
|
||||||
|
facets.shrink_to_fit();
|
||||||
|
facets.touch();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
volume->set_type(volume_data->part_type);
|
volume->set_type(volume_data->part_type);
|
||||||
@@ -5332,6 +5431,8 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
|
|||||||
for (const Metadata& metadata : volume_data->metadata) {
|
for (const Metadata& metadata : volume_data->metadata) {
|
||||||
if (metadata.key == NAME_KEY)
|
if (metadata.key == NAME_KEY)
|
||||||
volume->name = metadata.value;
|
volume->name = metadata.value;
|
||||||
|
else if (metadata.key == TEXTURE_DISPLACEMENT_KEY)
|
||||||
|
_apply_texture_displacement(*volume, metadata.value);
|
||||||
//else if ((metadata.key == MODIFIER_KEY) && (metadata.value == "1"))
|
//else if ((metadata.key == MODIFIER_KEY) && (metadata.value == "1"))
|
||||||
// volume->set_type(ModelVolumeType::PARAMETER_MODIFIER);
|
// volume->set_type(ModelVolumeType::PARAMETER_MODIFIER);
|
||||||
//for old format
|
//for old format
|
||||||
@@ -5496,12 +5597,28 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
|
|||||||
volume->seam_facets.shrink_to_fit();
|
volume->seam_facets.shrink_to_fit();
|
||||||
volume->mmu_segmentation_facets.shrink_to_fit();
|
volume->mmu_segmentation_facets.shrink_to_fit();
|
||||||
|
|
||||||
|
for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot) {
|
||||||
|
const std::vector<std::string> &mask = geometry.texture_displacement[slot];
|
||||||
|
if (mask.empty())
|
||||||
|
continue; // written by a build without the feature
|
||||||
|
FacetsAnnotation &facets = volume->texture_displacement_facet(slot);
|
||||||
|
facets.reserve(triangles_count);
|
||||||
|
for (size_t i = 0; i < triangles_count; ++i) {
|
||||||
|
const size_t index = volume_data.first_triangle_id + i;
|
||||||
|
if (index < mask.size() && !mask[index].empty())
|
||||||
|
facets.set_triangle_from_string(i, mask[index]);
|
||||||
|
}
|
||||||
|
facets.shrink_to_fit();
|
||||||
|
facets.touch();
|
||||||
|
}
|
||||||
volume->set_type(volume_data.part_type);
|
volume->set_type(volume_data.part_type);
|
||||||
|
|
||||||
// apply the remaining volume's metadata
|
// apply the remaining volume's metadata
|
||||||
for (const Metadata& metadata : volume_data.metadata) {
|
for (const Metadata& metadata : volume_data.metadata) {
|
||||||
if (metadata.key == NAME_KEY)
|
if (metadata.key == NAME_KEY)
|
||||||
volume->name = metadata.value;
|
volume->name = metadata.value;
|
||||||
|
else if (metadata.key == TEXTURE_DISPLACEMENT_KEY)
|
||||||
|
_apply_texture_displacement(*volume, metadata.value);
|
||||||
//else if ((metadata.key == MODIFIER_KEY) && (metadata.value == "1"))
|
//else if ((metadata.key == MODIFIER_KEY) && (metadata.value == "1"))
|
||||||
// volume->set_type(ModelVolumeType::PARAMETER_MODIFIER);
|
// volume->set_type(ModelVolumeType::PARAMETER_MODIFIER);
|
||||||
//for old format
|
//for old format
|
||||||
@@ -5805,6 +5922,9 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
|
|||||||
current_object->geometry.custom_seam.push_back(bbs_get_attribute_value_string(attributes, num_attributes, CUSTOM_SEAM_ATTR));
|
current_object->geometry.custom_seam.push_back(bbs_get_attribute_value_string(attributes, num_attributes, CUSTOM_SEAM_ATTR));
|
||||||
current_object->geometry.mmu_segmentation.push_back(bbs_get_attribute_value_string(attributes, num_attributes, MMU_SEGMENTATION_ATTR));
|
current_object->geometry.mmu_segmentation.push_back(bbs_get_attribute_value_string(attributes, num_attributes, MMU_SEGMENTATION_ATTR));
|
||||||
current_object->geometry.fuzzy_skin.push_back(bbs_get_attribute_value_string(attributes, num_attributes, CUSTOM_FUZZY_SKIN_ATTR));
|
current_object->geometry.fuzzy_skin.push_back(bbs_get_attribute_value_string(attributes, num_attributes, CUSTOM_FUZZY_SKIN_ATTR));
|
||||||
|
for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot)
|
||||||
|
current_object->geometry.push_texture_displacement(
|
||||||
|
slot, bbs_get_attribute_value_string(attributes, num_attributes, TEXTURE_DISPLACEMENT_ATTRS[slot]));
|
||||||
// BBS
|
// BBS
|
||||||
current_object->geometry.face_properties.push_back(bbs_get_attribute_value_string(attributes, num_attributes, FACE_PROPERTY_ATTR));
|
current_object->geometry.face_properties.push_back(bbs_get_attribute_value_string(attributes, num_attributes, FACE_PROPERTY_ATTR));
|
||||||
}
|
}
|
||||||
@@ -6808,6 +6928,9 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
|
|||||||
stream << " <Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/>\n";
|
stream << " <Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/>\n";
|
||||||
stream << " <Default Extension=\"model\" ContentType=\"application/vnd.ms-package.3dmanufacturing-3dmodel+xml\"/>\n";
|
stream << " <Default Extension=\"model\" ContentType=\"application/vnd.ms-package.3dmanufacturing-3dmodel+xml\"/>\n";
|
||||||
stream << " <Default Extension=\"png\" ContentType=\"image/png\"/>\n";
|
stream << " <Default Extension=\"png\" ContentType=\"image/png\"/>\n";
|
||||||
|
stream << " <Default Extension=\"jpg\" ContentType=\"image/jpeg\"/>\n";
|
||||||
|
stream << " <Default Extension=\"jpeg\" ContentType=\"image/jpeg\"/>\n";
|
||||||
|
stream << " <Default Extension=\"json\" ContentType=\"application/json\"/>\n";
|
||||||
stream << " <Default Extension=\"gcode\" ContentType=\"text/x.gcode\"/>\n";
|
stream << " <Default Extension=\"gcode\" ContentType=\"text/x.gcode\"/>\n";
|
||||||
stream << "</Types>";
|
stream << "</Types>";
|
||||||
|
|
||||||
@@ -7651,6 +7774,20 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
|
|||||||
output_buffer += "\"";
|
output_buffer += "\"";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One attribute per texture displacement layer slot. Older readers ignore attributes they do
|
||||||
|
// not know, so a project written here still opens in a build without the feature - it just
|
||||||
|
// loses the paint, which is also all it could have done with it.
|
||||||
|
for (int slot = 0; slot < TEXTURE_DISPLACEMENT_MAX_LAYERS; ++slot) {
|
||||||
|
const std::string texture_paint = volume->texture_displacement_facet(slot).get_triangle_as_string(i);
|
||||||
|
if (texture_paint.empty())
|
||||||
|
continue;
|
||||||
|
output_buffer += " ";
|
||||||
|
output_buffer += TEXTURE_DISPLACEMENT_ATTRS[slot];
|
||||||
|
output_buffer += "=\"";
|
||||||
|
output_buffer += texture_paint;
|
||||||
|
output_buffer += "\"";
|
||||||
|
}
|
||||||
|
|
||||||
// BBS
|
// BBS
|
||||||
if (i < its.properties.size()) {
|
if (i < its.properties.size()) {
|
||||||
std::string prop_str = its.properties[i].to_string();
|
std::string prop_str = its.properties[i].to_string();
|
||||||
@@ -8100,7 +8237,42 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
|
|||||||
return file_path;
|
return file_path;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _BBS_3MF_Exporter::_add_model_config_file_to_archive(mz_zip_archive& archive, const Model& model, PlateDataPtrs& plate_data_list, const ObjectToObjectDataMap &objects_data, const DynamicPrintConfig& config, int export_plate_idx, bool save_gcode, bool use_loaded_id)
|
// Writes a volume's texture displacement stack into the archive and points the volume's metadata at it.
|
||||||
|
// The layer settings go in as JSON and each layer's texture as the image file it was loaded from, both
|
||||||
|
// referenced by path - the same split EmbossShape makes for its SVG. The image deliberately stays out of
|
||||||
|
// the XML: it is binary and routinely megabytes, and base64 in an attribute would bloat the one file
|
||||||
|
// every reader has to parse just to list the objects.
|
||||||
|
static void add_texture_displacement(std::stringstream &stream, const ModelVolume &volume, mz_zip_archive &archive,
|
||||||
|
const std::string &id)
|
||||||
|
{
|
||||||
|
if (volume.texture_displacement_layers.empty())
|
||||||
|
return;
|
||||||
|
|
||||||
|
// A copy, because path_in_3mf is only meaningful inside the archive being written and the volume
|
||||||
|
// being exported is const (and may be saved again, elsewhere, with different paths).
|
||||||
|
std::vector<TextureDisplacementLayer> layers = volume.texture_displacement_layers;
|
||||||
|
for (TextureDisplacementLayer &layer : layers) {
|
||||||
|
layer.path_in_3mf.clear();
|
||||||
|
if (!layer.image_data || layer.image_data->empty())
|
||||||
|
continue;
|
||||||
|
// Always .png: the importer converts whatever the user picked (jpg, bmp, ...) to an 8-bit
|
||||||
|
// greyscale PNG, so image_data is PNG whatever layer.path happens to be named.
|
||||||
|
const std::string path = std::string(TEXTURE_DISPLACEMENT_DIR) + id + "_" + std::to_string(layer.slot) + ".png";
|
||||||
|
// No deflate: a PNG is already compressed, so a second pass only costs time.
|
||||||
|
if (mz_zip_writer_add_mem(&archive, path.c_str(), layer.image_data->data(), layer.image_data->size(),
|
||||||
|
MZ_NO_COMPRESSION))
|
||||||
|
layer.path_in_3mf = path;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string json = texture_displacement_layers_to_json(layers, volume.texture_displacement_options);
|
||||||
|
const std::string json_path = std::string(TEXTURE_DISPLACEMENT_DIR) + id + ".json";
|
||||||
|
if (!mz_zip_writer_add_mem(&archive, json_path.c_str(), json.data(), json.size(), MZ_DEFAULT_COMPRESSION))
|
||||||
|
return;
|
||||||
|
stream << " <" << METADATA_TAG << " " << KEY_ATTR << "=\"" << TEXTURE_DISPLACEMENT_KEY << "\" "
|
||||||
|
<< VALUE_ATTR << "=\"" << xml_escape(json_path) << "\"/>\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _BBS_3MF_Exporter::_add_model_config_file_to_archive(mz_zip_archive& archive, const Model& model, PlateDataPtrs& plate_data_list, const ObjectToObjectDataMap &objects_data, const DynamicPrintConfig& config, int export_plate_idx, bool save_gcode, bool use_loaded_id)
|
||||||
{
|
{
|
||||||
std::stringstream stream;
|
std::stringstream stream;
|
||||||
// Store mesh transformation in full precision, as the volumes are stored transformed and they need to be transformed back
|
// Store mesh transformation in full precision, as the volumes are stored transformed and they need to be transformed back
|
||||||
@@ -8211,6 +8383,8 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
|
|||||||
if (const std::optional<EmbossShape> &es = volume->emboss_shape; es.has_value()) {
|
if (const std::optional<EmbossShape> &es = volume->emboss_shape; es.has_value()) {
|
||||||
to_xml(stream, *es, *volume, archive, m_fullpath_sources);
|
to_xml(stream, *es, *volume, archive, m_fullpath_sources);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
add_texture_displacement(stream, *volume, archive, std::to_string(volume->id().id));
|
||||||
|
|
||||||
if (const std::optional<TextConfiguration> &tc = volume->text_configuration;
|
if (const std::optional<TextConfiguration> &tc = volume->text_configuration;
|
||||||
tc.has_value())
|
tc.has_value())
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <numeric>
|
#include <numeric>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
|
#include "nlohmann/json.hpp"
|
||||||
#include <queue>
|
#include <queue>
|
||||||
#include <ratio>
|
#include <ratio>
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -5166,5 +5167,256 @@ indexed_triangle_set cut_mesh_at_steps(const indexed_triangle_set &mesh, const s
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// Project persistence. See the declarations in TextureDisplacement.hpp for why this is JSON rather
|
||||||
|
// than the cereal stream next to them.
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
nlohmann::json vec2_to_json(const Vec2f &v) { return nlohmann::json::array({ v.x(), v.y() }); }
|
||||||
|
nlohmann::json vec3_to_json(const Vec3f &v) { return nlohmann::json::array({ v.x(), v.y(), v.z() }); }
|
||||||
|
|
||||||
|
Vec2f vec2_from_json(const nlohmann::json &j, const Vec2f &fallback)
|
||||||
|
{
|
||||||
|
if (!j.is_array() || j.size() != 2)
|
||||||
|
return fallback;
|
||||||
|
return Vec2f(j[0].get<float>(), j[1].get<float>());
|
||||||
|
}
|
||||||
|
Vec3f vec3_from_json(const nlohmann::json &j, const Vec3f &fallback)
|
||||||
|
{
|
||||||
|
if (!j.is_array() || j.size() != 3)
|
||||||
|
return fallback;
|
||||||
|
return Vec3f(j[0].get<float>(), j[1].get<float>(), j[2].get<float>());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reads key `k` into `out` when it is present and of the expected type, leaving `out` alone otherwise.
|
||||||
|
// That "leave it alone" is the whole point: every member starts at its struct default, so a project
|
||||||
|
// written by an older build simply keeps the defaults for whatever it did not know about.
|
||||||
|
template<class T> void read(const nlohmann::json &j, const char *k, T &out)
|
||||||
|
{
|
||||||
|
const auto it = j.find(k);
|
||||||
|
if (it == j.end())
|
||||||
|
return;
|
||||||
|
try {
|
||||||
|
out = it->get<T>();
|
||||||
|
} catch (...) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void read_enum(const nlohmann::json &j, const char *k, int &out)
|
||||||
|
{
|
||||||
|
const auto it = j.find(k);
|
||||||
|
if (it != j.end() && it->is_number_integer())
|
||||||
|
out = it->get<int>();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::string texture_displacement_layers_to_json(const std::vector<TextureDisplacementLayer> &layers,
|
||||||
|
const TextureDisplacementOptions &options)
|
||||||
|
{
|
||||||
|
nlohmann::json root;
|
||||||
|
root["version"] = 1;
|
||||||
|
|
||||||
|
nlohmann::json &opt = root["options"];
|
||||||
|
opt["displace_border"] = options.displace_border;
|
||||||
|
opt["smooth_enabled"] = options.smooth_enabled;
|
||||||
|
opt["smooth_strength"] = options.smooth_strength;
|
||||||
|
opt["smooth_iterations"] = options.smooth_iterations;
|
||||||
|
opt["smooth_skip_border"] = options.smooth_skip_border;
|
||||||
|
opt["pipeline_v2"] = options.pipeline_v2;
|
||||||
|
opt["v2_refine_mm"] = options.v2_refine_mm;
|
||||||
|
opt["v2_regularize"] = options.v2_regularize;
|
||||||
|
opt["v2_max_triangles_k"] = options.v2_max_triangles_k;
|
||||||
|
opt["v2_relocate"] = options.v2_relocate;
|
||||||
|
opt["v2_flip_edges"] = options.v2_flip_edges;
|
||||||
|
opt["color_mix_enabled"] = options.color_mix_enabled;
|
||||||
|
opt["color_despeckle"] = options.color_despeckle;
|
||||||
|
opt["color_mix_count"] = options.color_mix_count;
|
||||||
|
opt["active_slot"] = options.active_slot;
|
||||||
|
opt["panel_mode"] = options.panel_mode;
|
||||||
|
|
||||||
|
nlohmann::json &arr = root["layers"];
|
||||||
|
arr = nlohmann::json::array();
|
||||||
|
for (const TextureDisplacementLayer &l : layers) {
|
||||||
|
nlohmann::json j;
|
||||||
|
j["slot"] = l.slot;
|
||||||
|
j["name"] = l.name;
|
||||||
|
j["path"] = l.path;
|
||||||
|
j["path_in_3mf"] = l.path_in_3mf;
|
||||||
|
|
||||||
|
j["depth_mm"] = l.depth_mm;
|
||||||
|
j["tiling_scale"] = l.tiling_scale;
|
||||||
|
j["rotation_deg"] = l.rotation_deg;
|
||||||
|
j["offset"] = vec2_to_json(l.offset);
|
||||||
|
j["invert"] = l.invert;
|
||||||
|
j["midlevel"] = l.midlevel;
|
||||||
|
j["smoothing"] = l.smoothing;
|
||||||
|
|
||||||
|
j["edge_smoothing"] = l.edge_smoothing;
|
||||||
|
j["edge_smoothing_amount"] = l.edge_smoothing_amount;
|
||||||
|
j["auto_connect_islands"] = l.auto_connect_islands;
|
||||||
|
|
||||||
|
j["tile_enabled"] = l.tile_enabled;
|
||||||
|
j["tile_method"] = int(l.tile_method);
|
||||||
|
j["projection_method"] = int(l.projection_method);
|
||||||
|
j["blend_mode"] = int(l.blend_mode);
|
||||||
|
j["color_enabled"] = l.color_enabled;
|
||||||
|
|
||||||
|
j["lscm_seam_angle_deg"] = l.lscm_seam_angle_deg;
|
||||||
|
j["island_padding_mm"] = l.island_padding_mm;
|
||||||
|
|
||||||
|
j["view_project_right"] = vec3_to_json(l.view_project_right);
|
||||||
|
j["view_project_up"] = vec3_to_json(l.view_project_up);
|
||||||
|
j["view_project_projective"] = l.view_project_projective;
|
||||||
|
j["view_project_matrix"] = l.view_project_matrix;
|
||||||
|
|
||||||
|
// Flat pairs rather than nested arrays: shorter, and the reader can simply ignore a trailing
|
||||||
|
// odd element instead of having to validate every sub-array.
|
||||||
|
nlohmann::json &seams = j["lscm_seam_edges"];
|
||||||
|
seams = nlohmann::json::array();
|
||||||
|
for (const auto &[a, b] : l.lscm_seam_edges) {
|
||||||
|
seams.push_back(a);
|
||||||
|
seams.push_back(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
nlohmann::json &islands = j["islands"];
|
||||||
|
islands = nlohmann::json::array();
|
||||||
|
for (const TextureIsland &island : l.islands)
|
||||||
|
islands.push_back(nlohmann::json::array(
|
||||||
|
{ island.offset.x(), island.offset.y(), island.rotation_deg, island.scale }));
|
||||||
|
|
||||||
|
j["island_groups"] = l.island_groups;
|
||||||
|
|
||||||
|
nlohmann::json &overrides = j["lscm_uv_overrides"];
|
||||||
|
overrides = nlohmann::json::array();
|
||||||
|
for (const auto &[key, uv] : l.lscm_uv_overrides)
|
||||||
|
overrides.push_back(nlohmann::json::array({ key, uv.x(), uv.y() }));
|
||||||
|
|
||||||
|
arr.push_back(std::move(j));
|
||||||
|
}
|
||||||
|
return root.dump();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool texture_displacement_layers_from_json(const std::string &text,
|
||||||
|
std::vector<TextureDisplacementLayer> &layers,
|
||||||
|
TextureDisplacementOptions &options)
|
||||||
|
{
|
||||||
|
nlohmann::json root;
|
||||||
|
try {
|
||||||
|
root = nlohmann::json::parse(text);
|
||||||
|
} catch (...) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!root.is_object())
|
||||||
|
return false;
|
||||||
|
|
||||||
|
TextureDisplacementOptions out_options;
|
||||||
|
std::vector<TextureDisplacementLayer> out_layers;
|
||||||
|
// Past the parse, a value of the wrong type still throws: get<int>() on a string, get<float>() on
|
||||||
|
// null. Nothing between here and the project load catches that, so one bad number would fail the
|
||||||
|
// whole file instead of this one block. Report it malformed and let the volume load without it.
|
||||||
|
try {
|
||||||
|
if (const auto it = root.find("options"); it != root.end() && it->is_object()) {
|
||||||
|
const nlohmann::json &opt = *it;
|
||||||
|
read(opt, "displace_border", out_options.displace_border);
|
||||||
|
read(opt, "smooth_enabled", out_options.smooth_enabled);
|
||||||
|
read(opt, "smooth_strength", out_options.smooth_strength);
|
||||||
|
read(opt, "smooth_iterations", out_options.smooth_iterations);
|
||||||
|
read(opt, "smooth_skip_border", out_options.smooth_skip_border);
|
||||||
|
read(opt, "pipeline_v2", out_options.pipeline_v2);
|
||||||
|
read(opt, "v2_refine_mm", out_options.v2_refine_mm);
|
||||||
|
read(opt, "v2_regularize", out_options.v2_regularize);
|
||||||
|
read(opt, "v2_max_triangles_k", out_options.v2_max_triangles_k);
|
||||||
|
read(opt, "v2_relocate", out_options.v2_relocate);
|
||||||
|
read(opt, "v2_flip_edges", out_options.v2_flip_edges);
|
||||||
|
read(opt, "color_mix_enabled", out_options.color_mix_enabled);
|
||||||
|
read(opt, "color_despeckle", out_options.color_despeckle);
|
||||||
|
read(opt, "color_mix_count", out_options.color_mix_count);
|
||||||
|
read(opt, "active_slot", out_options.active_slot);
|
||||||
|
read(opt, "panel_mode", out_options.panel_mode);
|
||||||
|
out_options.active_slot = std::clamp(out_options.active_slot, 0, int(TEXTURE_DISPLACEMENT_MAX_LAYERS) - 1);
|
||||||
|
out_options.panel_mode = std::clamp(out_options.panel_mode, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (const auto it = root.find("layers"); it != root.end() && it->is_array()) {
|
||||||
|
for (const nlohmann::json &j : *it) {
|
||||||
|
if (!j.is_object())
|
||||||
|
continue;
|
||||||
|
TextureDisplacementLayer l;
|
||||||
|
read(j, "slot", l.slot);
|
||||||
|
read(j, "name", l.name);
|
||||||
|
read(j, "path", l.path);
|
||||||
|
read(j, "path_in_3mf", l.path_in_3mf);
|
||||||
|
|
||||||
|
read(j, "depth_mm", l.depth_mm);
|
||||||
|
read(j, "tiling_scale", l.tiling_scale);
|
||||||
|
read(j, "rotation_deg", l.rotation_deg);
|
||||||
|
if (const auto o = j.find("offset"); o != j.end())
|
||||||
|
l.offset = vec2_from_json(*o, l.offset);
|
||||||
|
read(j, "invert", l.invert);
|
||||||
|
read(j, "midlevel", l.midlevel);
|
||||||
|
read(j, "smoothing", l.smoothing);
|
||||||
|
|
||||||
|
read(j, "edge_smoothing", l.edge_smoothing);
|
||||||
|
read(j, "edge_smoothing_amount", l.edge_smoothing_amount);
|
||||||
|
read(j, "auto_connect_islands", l.auto_connect_islands);
|
||||||
|
|
||||||
|
read(j, "tile_enabled", l.tile_enabled);
|
||||||
|
read(j, "color_enabled", l.color_enabled);
|
||||||
|
int tile_method = int(l.tile_method), projection = int(l.projection_method), blend = int(l.blend_mode);
|
||||||
|
read_enum(j, "tile_method", tile_method);
|
||||||
|
read_enum(j, "projection_method", projection);
|
||||||
|
read_enum(j, "blend_mode", blend);
|
||||||
|
l.tile_method = TextureTileMethod(tile_method);
|
||||||
|
l.projection_method = TextureProjectionMethod(std::clamp(projection, 0, 4));
|
||||||
|
l.blend_mode = TextureBlendMode(blend);
|
||||||
|
|
||||||
|
read(j, "lscm_seam_angle_deg", l.lscm_seam_angle_deg);
|
||||||
|
read(j, "island_padding_mm", l.island_padding_mm);
|
||||||
|
|
||||||
|
if (const auto v = j.find("view_project_right"); v != j.end())
|
||||||
|
l.view_project_right = vec3_from_json(*v, l.view_project_right);
|
||||||
|
if (const auto v = j.find("view_project_up"); v != j.end())
|
||||||
|
l.view_project_up = vec3_from_json(*v, l.view_project_up);
|
||||||
|
read(j, "view_project_projective", l.view_project_projective);
|
||||||
|
if (const auto v = j.find("view_project_matrix"); v != j.end() && v->is_array() && v->size() == 12)
|
||||||
|
for (size_t i = 0; i < 12; ++i)
|
||||||
|
l.view_project_matrix[i] = (*v)[i].get<float>();
|
||||||
|
|
||||||
|
if (const auto v = j.find("lscm_seam_edges"); v != j.end() && v->is_array())
|
||||||
|
for (size_t i = 0; i + 1 < v->size(); i += 2)
|
||||||
|
l.lscm_seam_edges.emplace_back((*v)[i].get<int>(), (*v)[i + 1].get<int>());
|
||||||
|
|
||||||
|
if (const auto v = j.find("islands"); v != j.end() && v->is_array())
|
||||||
|
for (const nlohmann::json &e : *v) {
|
||||||
|
if (!e.is_array() || e.size() != 4)
|
||||||
|
continue;
|
||||||
|
TextureIsland island;
|
||||||
|
island.offset = Vec2f(e[0].get<float>(), e[1].get<float>());
|
||||||
|
island.rotation_deg = e[2].get<float>();
|
||||||
|
island.scale = e[3].get<float>();
|
||||||
|
l.islands.push_back(island);
|
||||||
|
}
|
||||||
|
|
||||||
|
read(j, "island_groups", l.island_groups);
|
||||||
|
|
||||||
|
if (const auto v = j.find("lscm_uv_overrides"); v != j.end() && v->is_array())
|
||||||
|
for (const nlohmann::json &e : *v) {
|
||||||
|
if (!e.is_array() || e.size() != 3)
|
||||||
|
continue;
|
||||||
|
l.lscm_uv_overrides.emplace_back(e[0].get<int>(), Vec2f(e[1].get<float>(), e[2].get<float>()));
|
||||||
|
}
|
||||||
|
|
||||||
|
out_layers.push_back(std::move(l));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (...) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
layers = std::move(out_layers);
|
||||||
|
options = out_options;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace Slic3r
|
} // namespace Slic3r
|
||||||
|
|||||||
@@ -397,14 +397,38 @@ struct TextureDisplacementOptions
|
|||||||
// finer than the mesh leaves behind, without eating features that are genuinely a facet wide.
|
// finer than the mesh leaves behind, without eating features that are genuinely a facet wide.
|
||||||
int color_despeckle = 2;
|
int color_despeckle = 2;
|
||||||
|
|
||||||
|
// Where the panel was left, rather than anything the bake reads. It lives with the volume because
|
||||||
|
// that is what it describes: reopening a project should put the user back on the layer they were
|
||||||
|
// editing, in the mode they were editing it in, not on slot 0 in Standard.
|
||||||
|
int active_slot = 0;
|
||||||
|
int panel_mode = 0; // 0 Standard, 1 Pro
|
||||||
|
|
||||||
template<class Archive> void serialize(Archive &ar)
|
template<class Archive> void serialize(Archive &ar)
|
||||||
{
|
{
|
||||||
ar(displace_border, smooth_enabled, smooth_strength, smooth_iterations, smooth_skip_border,
|
ar(displace_border, smooth_enabled, smooth_strength, smooth_iterations, smooth_skip_border,
|
||||||
pipeline_v2, v2_refine_mm, v2_regularize, v2_max_triangles_k,
|
pipeline_v2, v2_refine_mm, v2_regularize, v2_max_triangles_k,
|
||||||
v2_relocate, color_mix_enabled, color_despeckle, color_mix_count);
|
v2_relocate, v2_flip_edges, color_mix_enabled, color_despeckle, color_mix_count,
|
||||||
|
active_slot, panel_mode);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Project persistence (see bbs_3mf.cpp). The layer stack and the per-volume options are written to the
|
||||||
|
// .3mf as JSON rather than through the cereal save()/load() above: those two are positional and
|
||||||
|
// unversioned, which is right for the undo/redo stack they serve (one session, one binary) but would
|
||||||
|
// make every future field a project-breaking change. A JSON object tolerates both directions - an
|
||||||
|
// unknown key is ignored, a missing one keeps the member's default - so old projects keep loading and
|
||||||
|
// new ones degrade gracefully in older builds.
|
||||||
|
//
|
||||||
|
// The texture image itself is *not* in here. It is a binary blob that belongs in the archive as a file
|
||||||
|
// of its own, exactly as EmbossShape stores its SVG; `path_in_3mf` names that file, and the caller is
|
||||||
|
// responsible for writing it and for filling `image_data` back in on load.
|
||||||
|
std::string texture_displacement_layers_to_json(const std::vector<TextureDisplacementLayer> &layers,
|
||||||
|
const TextureDisplacementOptions &options);
|
||||||
|
// Returns false and leaves both outputs untouched when the text is not valid JSON.
|
||||||
|
bool texture_displacement_layers_from_json(const std::string &text,
|
||||||
|
std::vector<TextureDisplacementLayer> &layers,
|
||||||
|
TextureDisplacementOptions &options);
|
||||||
|
|
||||||
// How much detail a height texture carries: central differences of the grey image, the mean gradient
|
// How much detail a height texture carries: central differences of the grey image, the mean gradient
|
||||||
// and the share of texels steeper than 30 grey levels, mapped to how many texels one mesh edge may
|
// and the share of texels steeper than 30 grey levels, mapped to how many texels one mesh edge may
|
||||||
// span (1 for a hard-edged image, 4 for a smooth one). Cached per image, like the decode.
|
// span (1 for a hard-edged image, 4 for a smooth one). Cached per image, like the decode.
|
||||||
|
|||||||
@@ -25,9 +25,8 @@ DockPanel::DockPanel(wxWindow* parent,
|
|||||||
const std::string& html,
|
const std::string& html,
|
||||||
MessageHandler on_message,
|
MessageHandler on_message,
|
||||||
CloseHandler on_close,
|
CloseHandler on_close,
|
||||||
CloseHandler on_destroyed,
|
CloseHandler on_destroyed)
|
||||||
const wxString& profile)
|
: WebPanel(parent, web_hosting::orca_bridge_script())
|
||||||
: WebPanel(parent, web_hosting::orca_bridge_script(), profile)
|
|
||||||
, m_html(html)
|
, m_html(html)
|
||||||
, m_on_message(std::move(on_message))
|
, m_on_message(std::move(on_message))
|
||||||
, m_on_close(std::move(on_close))
|
, m_on_close(std::move(on_close))
|
||||||
|
|||||||
@@ -6,8 +6,6 @@
|
|||||||
#include <optional>
|
#include <optional>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
#include <wx/string.h>
|
|
||||||
|
|
||||||
class wxWindow;
|
class wxWindow;
|
||||||
|
|
||||||
namespace Slic3r { namespace GUI {
|
namespace Slic3r { namespace GUI {
|
||||||
@@ -29,8 +27,7 @@ public:
|
|||||||
const std::string& html,
|
const std::string& html,
|
||||||
MessageHandler on_message,
|
MessageHandler on_message,
|
||||||
CloseHandler on_close,
|
CloseHandler on_close,
|
||||||
CloseHandler on_destroyed,
|
CloseHandler on_destroyed);
|
||||||
const wxString& profile = wxEmptyString);
|
|
||||||
~DockPanel() override;
|
~DockPanel() override;
|
||||||
|
|
||||||
// Main thread only.
|
// Main thread only.
|
||||||
|
|||||||
@@ -3232,16 +3232,9 @@ bool GUI_App::on_init_inner()
|
|||||||
#ifdef _MSW_DARK_MODE
|
#ifdef _MSW_DARK_MODE
|
||||||
|
|
||||||
#ifndef __WINDOWS__
|
#ifndef __WINDOWS__
|
||||||
// The label colours and the dark mode flag were set above from the saved mode. When the system theme
|
|
||||||
// differs from it, refresh them, or dialogs that map their colours stay in the old mode for this session.
|
|
||||||
const bool saved_dark_mode = dark_mode();
|
|
||||||
wxSystemAppearance app = wxSystemSettings::GetAppearance();
|
wxSystemAppearance app = wxSystemSettings::GetAppearance();
|
||||||
GUI::wxGetApp().app_config->set("dark_color_mode", app.IsDark() ? "1" : "0");
|
GUI::wxGetApp().app_config->set("dark_color_mode", app.IsDark() ? "1" : "0");
|
||||||
GUI::wxGetApp().app_config->save();
|
GUI::wxGetApp().app_config->save();
|
||||||
if (dark_mode() != saved_dark_mode) {
|
|
||||||
init_label_colours();
|
|
||||||
Update_dark_mode_flag();
|
|
||||||
}
|
|
||||||
#endif // __APPLE__
|
#endif // __APPLE__
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3558,6 +3558,7 @@ const ModelVolume* GLGizmoTextureDisplacement::texture_volume() const
|
|||||||
|
|
||||||
void GLGizmoTextureDisplacement::update_model_object()
|
void GLGizmoTextureDisplacement::update_model_object()
|
||||||
{
|
{
|
||||||
|
store_panel_state();
|
||||||
bool updated = false;
|
bool updated = false;
|
||||||
ModelObject *mo = m_c->selection_info()->model_object();
|
ModelObject *mo = m_c->selection_info()->model_object();
|
||||||
int idx = -1;
|
int idx = -1;
|
||||||
@@ -3593,6 +3594,24 @@ void GLGizmoTextureDisplacement::update_from_model_object(bool first_update)
|
|||||||
{
|
{
|
||||||
wxBusyCursor wait;
|
wxBusyCursor wait;
|
||||||
|
|
||||||
|
// Opening the gizmo puts the panel back where it was left: the layer and the mode come off the
|
||||||
|
// volume, because they are the user's place in this model's stack, and the dock state off the
|
||||||
|
// application config, because it is about their window. Only on open - this also runs on an
|
||||||
|
// undo/redo and on a layer switch, where overriding what the user is doing now would be wrong.
|
||||||
|
if (first_update) {
|
||||||
|
if (const ModelVolume *mv = texture_volume(); mv != nullptr) {
|
||||||
|
const TextureDisplacementOptions &opts = mv->texture_displacement_options;
|
||||||
|
m_panel_mode = std::clamp(opts.panel_mode, 0, 1);
|
||||||
|
// Only if that slot still holds a layer: the stack can have been edited by anything that
|
||||||
|
// does not go through the panel, and a selection pointing at nothing shows an empty panel.
|
||||||
|
const auto &stack = mv->texture_displacement_layers;
|
||||||
|
if (std::any_of(stack.begin(), stack.end(),
|
||||||
|
[&opts](const TextureDisplacementLayer &l) { return l.slot == opts.active_slot; }))
|
||||||
|
m_active_layer_slot = opts.active_slot;
|
||||||
|
}
|
||||||
|
m_undocked = wxGetApp().app_config->get_bool(UNDOCKED_CONFIG_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
const ModelObject *mo = m_c->selection_info()->model_object();
|
const ModelObject *mo = m_c->selection_info()->model_object();
|
||||||
m_triangle_selectors.clear();
|
m_triangle_selectors.clear();
|
||||||
// The base keeps the last mesh a fill tool hovered, and render_painter_gizmo() reads it as a hover that is
|
// The base keeps the last mesh a fill tool hovered, and render_painter_gizmo() reads it as a hover that is
|
||||||
@@ -3641,6 +3660,14 @@ void GLGizmoTextureDisplacement::update_from_model_object(bool first_update)
|
|||||||
rebuild_preview();
|
rebuild_preview();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void GLGizmoTextureDisplacement::store_panel_state()
|
||||||
|
{
|
||||||
|
if (ModelVolume *mv = texture_volume(); mv != nullptr) {
|
||||||
|
mv->texture_displacement_options.active_slot = m_active_layer_slot;
|
||||||
|
mv->texture_displacement_options.panel_mode = m_panel_mode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void GLGizmoTextureDisplacement::set_active_layer(int slot)
|
void GLGizmoTextureDisplacement::set_active_layer(int slot)
|
||||||
{
|
{
|
||||||
if (slot == m_active_layer_slot)
|
if (slot == m_active_layer_slot)
|
||||||
@@ -3649,6 +3676,7 @@ void GLGizmoTextureDisplacement::set_active_layer(int slot)
|
|||||||
// reflect - otherwise they would be silently lost.
|
// reflect - otherwise they would be silently lost.
|
||||||
update_model_object();
|
update_model_object();
|
||||||
m_active_layer_slot = slot;
|
m_active_layer_slot = slot;
|
||||||
|
store_panel_state();
|
||||||
update_from_model_object(false);
|
update_from_model_object(false);
|
||||||
// The on-canvas gizmo (if on) is anchored to whichever layer is active - keep it in sync
|
// The on-canvas gizmo (if on) is anchored to whichever layer is active - keep it in sync
|
||||||
// instead of leaving it pointing at the previous layer's (now stale) paint patch.
|
// instead of leaving it pointing at the previous layer's (now stale) paint patch.
|
||||||
@@ -5986,6 +6014,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
|
|||||||
"not what you wanted and you know why."));
|
"not what you wanted and you know why."));
|
||||||
if (clicked && !on) {
|
if (clicked && !on) {
|
||||||
m_panel_mode = m;
|
m_panel_mode = m;
|
||||||
|
store_panel_state();
|
||||||
if (!pro_mode()) {
|
if (!pro_mode()) {
|
||||||
// Leaving the subdivision preview open would strand a wireframe whose controls just
|
// Leaving the subdivision preview open would strand a wireframe whose controls just
|
||||||
// disappeared, so close it as part of the switch.
|
// disappeared, so close it as part of the switch.
|
||||||
@@ -6008,6 +6037,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
|
|||||||
_L("Detach this panel so it can be dragged anywhere over the 3D view, or dock it back beside "
|
_L("Detach this panel so it can be dragged anywhere over the 3D view, or dock it back beside "
|
||||||
"the toolbar.")))
|
"the toolbar.")))
|
||||||
m_undocked = !m_undocked;
|
m_undocked = !m_undocked;
|
||||||
|
wxGetApp().app_config->set_bool(UNDOCKED_CONFIG_KEY, m_undocked);
|
||||||
}
|
}
|
||||||
ImGui::Separator();
|
ImGui::Separator();
|
||||||
|
|
||||||
|
|||||||
@@ -262,6 +262,9 @@ private:
|
|||||||
// Standard is active so what Preview shows is always what Bake will do. Returns true if it actually
|
// Standard is active so what Preview shows is always what Bake will do. Returns true if it actually
|
||||||
// changed something, so the caller can invalidate the preview.
|
// changed something, so the caller can invalidate the preview.
|
||||||
bool apply_standard_mode_presets(ModelVolume *mv);
|
bool apply_standard_mode_presets(ModelVolume *mv);
|
||||||
|
// Copies the panel's own state (active layer, Standard/Pro) onto the volume, so the project file
|
||||||
|
// can carry it. Cheap and idempotent - called wherever either of the two changes.
|
||||||
|
void store_panel_state();
|
||||||
// Standard mode's Bake: remesh to an even density, refine where the texture bends, then displace.
|
// Standard mode's Bake: remesh to an even density, refine where the texture bends, then displace.
|
||||||
// The order matters and is the whole reason this is one button - a height map can only move
|
// The order matters and is the whole reason this is one button - a height map can only move
|
||||||
// existing vertices, so the mesh has to be prepared first, and remeshing after painting would drop
|
// existing vertices, so the mesh has to be prepared first, and remeshing after painting would drop
|
||||||
@@ -971,9 +974,12 @@ private:
|
|||||||
PatchUnwrap m_uv_editor_unwrap;
|
PatchUnwrap m_uv_editor_unwrap;
|
||||||
|
|
||||||
// When set, the panel is a free-floating window the user can drag anywhere (with a title bar to
|
// When set, the panel is a free-floating window the user can drag anywhere (with a title bar to
|
||||||
// grab), instead of being pinned to the right of the gizmo toolbar. Persisted across gizmo
|
// grab), instead of being pinned to the right of the gizmo toolbar. Kept in the application
|
||||||
// open/close within a session, so the choice sticks while working.
|
// config rather than in the project: it describes the user's window, not the model, so opening
|
||||||
|
// someone else's project must not move their panel.
|
||||||
bool m_undocked = false;
|
bool m_undocked = false;
|
||||||
|
// The config key behind m_undocked.
|
||||||
|
static constexpr const char *UNDOCKED_CONFIG_KEY = "texture_displacement_panel_undocked";
|
||||||
|
|
||||||
// Smooth scrolling for the panel body (everything between the header and the pinned Bake footer).
|
// Smooth scrolling for the panel body (everything between the header and the pinned Bake footer).
|
||||||
// ImGui jumps a fixed number of lines per wheel notch, which on tall layer cards reads as a hard
|
// ImGui jumps a fixed number of lines per wheel notch, which on tall layer cards reads as a hard
|
||||||
|
|||||||
@@ -27,8 +27,7 @@ WebDialog::WebDialog(wxWindow* parent,
|
|||||||
SubmitHandler on_submit,
|
SubmitHandler on_submit,
|
||||||
CloseHandler on_close,
|
CloseHandler on_close,
|
||||||
CloseHandler on_destroyed,
|
CloseHandler on_destroyed,
|
||||||
long wx_style,
|
long wx_style)
|
||||||
const wxString& profile)
|
|
||||||
: WebViewHostDialog(parent, wxID_ANY, title, wxDefaultPosition, size, wx_style)
|
: WebViewHostDialog(parent, wxID_ANY, title, wxDefaultPosition, size, wx_style)
|
||||||
, m_html(html)
|
, m_html(html)
|
||||||
, m_on_message(std::move(on_message))
|
, m_on_message(std::move(on_message))
|
||||||
@@ -38,7 +37,7 @@ WebDialog::WebDialog(wxWindow* parent,
|
|||||||
{
|
{
|
||||||
// A tiny bundled bootstrap page brings the webview up; the real plugin HTML
|
// A tiny bundled bootstrap page brings the webview up; the real plugin HTML
|
||||||
// is swapped in via SetPage once the bootstrap finishes loading.
|
// is swapped in via SetPage once the bootstrap finishes loading.
|
||||||
create_webview(web_hosting::BOOTSTRAP_PAGE, title, size, wxSize(320, 240), profile);
|
create_webview(web_hosting::BOOTSTRAP_PAGE, title, size, wxSize(320, 240));
|
||||||
|
|
||||||
// Paint the window/webview in the themed background so there is no white
|
// Paint the window/webview in the themed background so there is no white
|
||||||
// flash before the (transparent) bootstrap page and plugin HTML render.
|
// flash before the (transparent) bootstrap page and plugin HTML render.
|
||||||
|
|||||||
@@ -49,8 +49,7 @@ public:
|
|||||||
SubmitHandler on_submit,
|
SubmitHandler on_submit,
|
||||||
CloseHandler on_close,
|
CloseHandler on_close,
|
||||||
CloseHandler on_destroyed,
|
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;
|
~WebDialog() override;
|
||||||
|
|
||||||
static void post_message(WebDialog* dialog, const nlohmann::json& data);
|
static void post_message(WebDialog* dialog, const nlohmann::json& data);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
namespace Slic3r { namespace GUI {
|
namespace Slic3r { namespace GUI {
|
||||||
|
|
||||||
WebPanel::WebPanel(wxWindow* parent, const char* bridge_script, const wxString& profile)
|
WebPanel::WebPanel(wxWindow* parent, const char* bridge_script)
|
||||||
: wxPanel(parent, wxID_ANY)
|
: wxPanel(parent, wxID_ANY)
|
||||||
{
|
{
|
||||||
SetBackgroundColour(wxGetApp().get_window_default_clr());
|
SetBackgroundColour(wxGetApp().get_window_default_clr());
|
||||||
@@ -25,7 +25,7 @@ WebPanel::WebPanel(wxWindow* parent, const char* bridge_script, const wxString&
|
|||||||
SetSizer(sizer);
|
SetSizer(sizer);
|
||||||
|
|
||||||
// Never null: WebView::CreateWebView substitutes a placeholder view when no backend is available.
|
// Never null: WebView::CreateWebView substitutes a placeholder view when no backend is available.
|
||||||
m_browser = WebView::CreateWebView(this, web_hosting::bootstrap_url(), profile);
|
m_browser = WebView::CreateWebView(this, web_hosting::bootstrap_url());
|
||||||
m_browser->SetBackgroundColour(GetBackgroundColour());
|
m_browser->SetBackgroundColour(GetBackgroundColour());
|
||||||
m_browser->AddUserScript(wxString::FromUTF8(WebViewHostDialog::theme_user_script()));
|
m_browser->AddUserScript(wxString::FromUTF8(WebViewHostDialog::theme_user_script()));
|
||||||
m_browser->AddUserScript(wxString::FromUTF8(WebViewHostDialog::element_defaults_user_script()));
|
m_browser->AddUserScript(wxString::FromUTF8(WebViewHostDialog::element_defaults_user_script()));
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
#include <wx/event.h>
|
#include <wx/event.h>
|
||||||
#include <wx/panel.h>
|
#include <wx/panel.h>
|
||||||
#include <wx/string.h>
|
|
||||||
#include <wx/webview.h>
|
#include <wx/webview.h>
|
||||||
|
|
||||||
#include <optional>
|
#include <optional>
|
||||||
@@ -23,7 +22,7 @@ namespace Slic3r { namespace GUI {
|
|||||||
class WebPanel : public wxPanel
|
class WebPanel : public wxPanel
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
WebPanel(wxWindow* parent, const char* bridge_script, const wxString& profile = wxEmptyString);
|
WebPanel(wxWindow* parent, const char* bridge_script);
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
wxWebView* browser() const { return m_browser; }
|
wxWebView* browser() const { return m_browser; }
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
#include "slic3r/GUI/GUI_App.hpp"
|
#include "slic3r/GUI/GUI_App.hpp"
|
||||||
#include "slic3r/Utils/MacDarkMode.hpp"
|
#include "slic3r/Utils/MacDarkMode.hpp"
|
||||||
|
|
||||||
#include <libslic3r/Utils.hpp>
|
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <boost/log/trivial.hpp>
|
#include <boost/log/trivial.hpp>
|
||||||
|
|
||||||
@@ -14,7 +12,6 @@
|
|||||||
#include <exception>
|
#include <exception>
|
||||||
#include <thread>
|
#include <thread>
|
||||||
|
|
||||||
#include <wx/chartype.h>
|
|
||||||
#include <wx/setup.h>
|
#include <wx/setup.h>
|
||||||
#include <wx/webview.h>
|
#include <wx/webview.h>
|
||||||
#include <wx/string.h>
|
#include <wx/string.h>
|
||||||
@@ -75,26 +72,6 @@ webkit_javascript_result_unref (WebKitJavascriptResult *js_result);
|
|||||||
}
|
}
|
||||||
#endif
|
#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__
|
#ifdef __WIN32__
|
||||||
// Run Download and Install in another thread so we don't block the UI thread
|
// Run Download and Install in another thread so we don't block the UI thread
|
||||||
DWORD DownloadAndInstallWV2RT() {
|
DWORD DownloadAndInstallWV2RT() {
|
||||||
@@ -158,9 +135,6 @@ DWORD DownloadAndInstallWV2RT() {
|
|||||||
class WebViewEdge : public wxWebViewEdge
|
class WebViewEdge : public wxWebViewEdge
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
WebViewEdge() = default;
|
|
||||||
explicit WebViewEdge(const wxWebViewConfiguration& config) : wxWebViewEdge(config) {}
|
|
||||||
|
|
||||||
bool SetUserAgent(const wxString &userAgent) override
|
bool SetUserAgent(const wxString &userAgent) override
|
||||||
{
|
{
|
||||||
bool dark = userAgent.Contains("dark");
|
bool dark = userAgent.Contains("dark");
|
||||||
@@ -239,11 +213,6 @@ public:
|
|||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
explicit WebViewWebKit(const wxWebViewConfiguration& config)
|
|
||||||
: wxWebViewWebKit(config)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
~WebViewWebKit() override
|
~WebViewWebKit() override
|
||||||
{
|
{
|
||||||
RemoveScriptMessageHandler("wx");
|
RemoveScriptMessageHandler("wx");
|
||||||
@@ -315,7 +284,7 @@ static WebViewRef *webview_ref(wxWebView *webView)
|
|||||||
return webView ? static_cast<WebViewRef *>(webView->GetRefData()) : nullptr;
|
return webView ? static_cast<WebViewRef *>(webView->GetRefData()) : nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
wxWebView* WebView::CreateWebView(wxWindow * parent, wxString const & url, wxString const & profile)
|
wxWebView* WebView::CreateWebView(wxWindow * parent, wxString const & url)
|
||||||
{
|
{
|
||||||
#if wxUSE_WEBVIEW_EDGE
|
#if wxUSE_WEBVIEW_EDGE
|
||||||
// Check if a fixed version of edge is present in
|
// Check if a fixed version of edge is present in
|
||||||
@@ -335,36 +304,12 @@ wxWebView* WebView::CreateWebView(wxWindow * parent, wxString const & url, wxStr
|
|||||||
if (!url2.empty()) { url2 = wxURI(url2).BuildURI(); }
|
if (!url2.empty()) { url2 = wxURI(url2).BuildURI(); }
|
||||||
BOOST_LOG_TRIVIAL(trace) << __FUNCTION__ << ": " << url2.ToUTF8();
|
BOOST_LOG_TRIVIAL(trace) << __FUNCTION__ << ": " << url2.ToUTF8();
|
||||||
|
|
||||||
const bool isolated = !profile.IsEmpty();
|
|
||||||
|
|
||||||
#ifdef __WIN32__
|
#ifdef __WIN32__
|
||||||
wxWebView* webView = nullptr;
|
wxWebView* webView = new WebViewEdge;
|
||||||
if (isolated) {
|
|
||||||
wxWebViewConfiguration config = wxWebView::NewConfiguration(wxWebViewBackendEdge);
|
|
||||||
config.SetDataPath(isolated_profile_path(profile));
|
|
||||||
webView = new WebViewEdge(config);
|
|
||||||
} else {
|
|
||||||
webView = new WebViewEdge;
|
|
||||||
}
|
|
||||||
#elif defined(__WXOSX__)
|
#elif defined(__WXOSX__)
|
||||||
wxWebView* webView = nullptr;
|
wxWebView* webView = new WebViewWebKit;
|
||||||
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
|
#else
|
||||||
wxWebView* webView = nullptr;
|
auto webView = wxWebView::New();
|
||||||
if (isolated) {
|
|
||||||
wxWebViewConfiguration config = wxWebView::NewConfiguration(wxWebViewBackendWebKit);
|
|
||||||
config.SetDataPath(isolated_profile_path(profile));
|
|
||||||
webView = wxWebView::New(config);
|
|
||||||
} else {
|
|
||||||
webView = wxWebView::New();
|
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
if (webView) {
|
if (webView) {
|
||||||
webView->SetBackgroundColour(StateColor::darkModeColorFor(*wxWHITE));
|
webView->SetBackgroundColour(StateColor::darkModeColorFor(*wxWHITE));
|
||||||
|
|||||||
@@ -14,11 +14,7 @@ wxDECLARE_EVENT(EVT_WEBVIEW_RECREATED, wxCommandEvent);
|
|||||||
class WebView
|
class WebView
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
// `profile` empty -> the shared default backend store used by all trusted UI.
|
static wxWebView *CreateWebView(wxWindow *parent, wxString const &url);
|
||||||
// `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
|
#if wxUSE_WEBVIEW_EDGE
|
||||||
static bool CheckWebViewRuntime();
|
static bool CheckWebViewRuntime();
|
||||||
static bool DownloadAndInstallWebViewRuntime();
|
static bool DownloadAndInstallWebViewRuntime();
|
||||||
|
|||||||
@@ -172,8 +172,7 @@ WebViewHostDialog::WebViewHostDialog(wxWindow* parent,
|
|||||||
bool WebViewHostDialog::create_webview(const std::string& resource_path,
|
bool WebViewHostDialog::create_webview(const std::string& resource_path,
|
||||||
const wxString& title,
|
const wxString& title,
|
||||||
const wxSize& dialog_size,
|
const wxSize& dialog_size,
|
||||||
const wxSize& min_size,
|
const wxSize& min_size)
|
||||||
const wxString& profile)
|
|
||||||
{
|
{
|
||||||
SetTitle(title);
|
SetTitle(title);
|
||||||
SetMinSize(FromDIP(min_size));
|
SetMinSize(FromDIP(min_size));
|
||||||
@@ -182,7 +181,7 @@ bool WebViewHostDialog::create_webview(const std::string& resource_path,
|
|||||||
wxBoxSizer* topsizer = new wxBoxSizer(wxVERTICAL);
|
wxBoxSizer* topsizer = new wxBoxSizer(wxVERTICAL);
|
||||||
|
|
||||||
// Created empty and loaded below, after the user scripts are added, so the first load already has them.
|
// Created empty and loaded below, after the user scripts are added, so the first load already has them.
|
||||||
m_browser = WebView::CreateWebView(this, wxEmptyString, profile);
|
m_browser = WebView::CreateWebView(this, wxEmptyString);
|
||||||
if (m_browser == nullptr) {
|
if (m_browser == nullptr) {
|
||||||
wxLogError("Could not init m_browser");
|
wxLogError("Could not init m_browser");
|
||||||
delete topsizer;
|
delete topsizer;
|
||||||
|
|||||||
@@ -38,8 +38,7 @@ public:
|
|||||||
bool create_webview(const std::string& resource_path,
|
bool create_webview(const std::string& resource_path,
|
||||||
const wxString& title,
|
const wxString& title,
|
||||||
const wxSize& dialog_size = wxSize(820, 660),
|
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);
|
void load_url(const wxString& url);
|
||||||
bool run_script(const wxString& script);
|
bool run_script(const wxString& script);
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ extern double mac_max_scaling_factor();
|
|||||||
extern void set_miniaturizable(void * window);
|
extern void set_miniaturizable(void * window);
|
||||||
void WKWebView_evaluateJavaScript(void * web, wxString const & script, void (*callback)(wxString const &));
|
void WKWebView_evaluateJavaScript(void * web, wxString const & script, void (*callback)(wxString const &));
|
||||||
void WKWebView_setTransparentBackground(void * web);
|
void WKWebView_setTransparentBackground(void * web);
|
||||||
void WKWebViewConfiguration_setNonPersistentDataStore(void * configuration);
|
|
||||||
void WKWebView_force_display(void * web);
|
void WKWebView_force_display(void * web);
|
||||||
void set_tag_when_enter_full_screen(bool isfullscreen);
|
void set_tag_when_enter_full_screen(bool isfullscreen);
|
||||||
void set_title_colour_after_set_title(void * window);
|
void set_title_colour_after_set_title(void * window);
|
||||||
|
|||||||
@@ -102,15 +102,6 @@ void WKWebView_setTransparentBackground(void * web)
|
|||||||
[webView registerForDraggedTypes: @[NSFilenamesPboardType]];
|
[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
|
// 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
|
// 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
|
// until the user generates input (scroll/arrow). setNeedsDisplay alone does not always reach the
|
||||||
|
|||||||
@@ -390,8 +390,7 @@ 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,
|
auto* dlg = new GUI::WebDialog(ui_parent(), wxString::FromUTF8(title), html,
|
||||||
wxSize(w, h), std::move(msg_adapter), std::move(submit_adapter),
|
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);
|
UiRegistry::instance().bind(new_id, dlg, plugin_key);
|
||||||
if (modal) {
|
if (modal) {
|
||||||
dlg->ShowModal();
|
dlg->ShowModal();
|
||||||
@@ -488,7 +487,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 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),
|
auto* panel = new GUI::DockPanel(plater, html, std::move(msg_adapter), make_close_adapter(close_holder),
|
||||||
std::move(on_destroyed), wxString::FromUTF8(plugin_key));
|
std::move(on_destroyed));
|
||||||
UiRegistry::instance().bind(new_id, panel, 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,
|
plater->add_dock_pane(panel, GUI::plugin_pane_name(plugin_key, title), wxString::FromUTF8(title), dock,
|
||||||
wxSize(w, h), [panel]() { panel->fire_close(); });
|
wxSize(w, h), [panel]() { panel->fire_close(); });
|
||||||
|
|||||||
@@ -77,8 +77,7 @@ constexpr char PLUGIN_PAGE_BRIDGE_JS[] = R"JS(
|
|||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
PluginPage::PluginPage(wxWindow* parent, std::shared_ptr<PagesPluginCapability> capability)
|
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_cap(std::move(capability))
|
||||||
, m_lifetime(std::make_shared<std::atomic<PluginPage*>>(this))
|
, m_lifetime(std::make_shared<std::atomic<PluginPage*>>(this))
|
||||||
{
|
{
|
||||||
|
|||||||
+326
-13
@@ -28,9 +28,12 @@
|
|||||||
#include <miniz.h>
|
#include <miniz.h>
|
||||||
#include "libslic3r/TriangleMesh.hpp"
|
#include "libslic3r/TriangleMesh.hpp"
|
||||||
#include "libslic3r/TriangleSelector.hpp"
|
#include "libslic3r/TriangleSelector.hpp"
|
||||||
|
#include "libslic3r/TextureDisplacement.hpp"
|
||||||
|
#include "libslic3r/PNGReadWrite.hpp"
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <ios>
|
#include <ios>
|
||||||
|
#include <iterator>
|
||||||
#include <nlohmann/json.hpp>
|
#include <nlohmann/json.hpp>
|
||||||
|
|
||||||
#include <boost/filesystem/operations.hpp>
|
#include <boost/filesystem/operations.hpp>
|
||||||
@@ -349,6 +352,24 @@ static bool replace_in_3mf_entry(const std::string& path, const std::string& suf
|
|||||||
return replaced;
|
return replaced;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Writes `model` out as a one-plate project, which is what the application's Save does with it.
|
||||||
|
static void store_project(const std::string& path, Model& model)
|
||||||
|
{
|
||||||
|
ScopedTemporaryDir backup_dir("orca_project_src");
|
||||||
|
model.set_backup_path(backup_dir.string());
|
||||||
|
|
||||||
|
DynamicPrintConfig cfg;
|
||||||
|
PlateData plate;
|
||||||
|
plate.plate_index = 0;
|
||||||
|
StoreParams sp;
|
||||||
|
sp.path = path.c_str();
|
||||||
|
sp.model = &model;
|
||||||
|
sp.config = &cfg;
|
||||||
|
sp.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence;
|
||||||
|
sp.plate_data_list.push_back(&plate);
|
||||||
|
REQUIRE(store_bbs_3mf(sp));
|
||||||
|
}
|
||||||
|
|
||||||
// Stores a one-plate project holding a cube whose first two facets are painted Extruder2 and
|
// Stores a one-plate project holding a cube whose first two facets are painted Extruder2 and
|
||||||
// Extruder3, which the exporter writes as paint_color="8" and paint_color="0C".
|
// Extruder3, which the exporter writes as paint_color="8" and paint_color="0C".
|
||||||
static void store_painted_cube(const std::string& path)
|
static void store_painted_cube(const std::string& path)
|
||||||
@@ -363,19 +384,7 @@ static void store_painted_cube(const std::string& path)
|
|||||||
selector.set_facet(1, EnforcerBlockerType::Extruder3);
|
selector.set_facet(1, EnforcerBlockerType::Extruder3);
|
||||||
REQUIRE(volume->mmu_segmentation_facets.set(selector));
|
REQUIRE(volume->mmu_segmentation_facets.set(selector));
|
||||||
}
|
}
|
||||||
ScopedTemporaryDir backup_dir("orca_paint_src");
|
store_project(path, model);
|
||||||
model.set_backup_path(backup_dir.string());
|
|
||||||
|
|
||||||
DynamicPrintConfig cfg;
|
|
||||||
PlateData plate;
|
|
||||||
plate.plate_index = 0;
|
|
||||||
StoreParams sp;
|
|
||||||
sp.path = path.c_str();
|
|
||||||
sp.model = &model;
|
|
||||||
sp.config = &cfg;
|
|
||||||
sp.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence;
|
|
||||||
sp.plate_data_list.push_back(&plate);
|
|
||||||
REQUIRE(store_bbs_3mf(sp));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loads `path` through the BBS importer into `model`, releasing the plates it returns. The
|
// Loads `path` through the BBS importer into `model`, releasing the plates it returns. The
|
||||||
@@ -2080,3 +2089,307 @@ TEST_CASE("Loading a gcode.3mf from a stream keeps the plate's pick and pattern
|
|||||||
CHECK(dst_plates.front()->pattern_bbox_file == "Metadata/plate_1.json");
|
CHECK(dst_plates.front()->pattern_bbox_file == "Metadata/plate_1.json");
|
||||||
release_PlateData_list(dst_plates);
|
release_PlateData_list(dst_plates);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Encodes a flat grayscale PNG the way the texture panel's import does, so what the archive carries
|
||||||
|
// is a real texture rather than an opaque blob.
|
||||||
|
static std::shared_ptr<std::vector<unsigned char>> make_gray_png(uint8_t value, size_t w = 4, size_t h = 4)
|
||||||
|
{
|
||||||
|
ScopedTemporaryFile file(".png");
|
||||||
|
const std::vector<uint8_t> pixels(w * h, value);
|
||||||
|
REQUIRE(png::write_gray_to_file(file.string(), w, h, pixels));
|
||||||
|
|
||||||
|
boost::nowide::ifstream in(file.string(), std::ios::binary);
|
||||||
|
std::vector<unsigned char> bytes((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
|
||||||
|
REQUIRE_FALSE(bytes.empty());
|
||||||
|
return std::make_shared<std::vector<unsigned char>>(std::move(bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every entry name in a 3mf archive, with the separators normalised the way the importer sees them.
|
||||||
|
static std::vector<std::string> zip_entry_names(const std::string& path)
|
||||||
|
{
|
||||||
|
std::vector<std::string> names;
|
||||||
|
mz_zip_archive zip;
|
||||||
|
mz_zip_zero_struct(&zip);
|
||||||
|
REQUIRE(open_zip_reader(&zip, path));
|
||||||
|
const mz_uint n = mz_zip_reader_get_num_files(&zip);
|
||||||
|
for (mz_uint i = 0; i < n; ++i) {
|
||||||
|
mz_zip_archive_file_stat st;
|
||||||
|
if (!mz_zip_reader_file_stat(&zip, i, &st) || st.m_is_directory)
|
||||||
|
continue;
|
||||||
|
std::string name(st.m_filename);
|
||||||
|
std::replace(name.begin(), name.end(), '\\', '/');
|
||||||
|
names.push_back(std::move(name));
|
||||||
|
}
|
||||||
|
close_zip_reader(&zip);
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
static const std::string TEXTURE_DISPLACEMENT_ENTRY_DIR = "Metadata/texture_displacement/";
|
||||||
|
|
||||||
|
// Stores a one-plate project holding a cube with one texture displacement layer, painted on that
|
||||||
|
// layer's own slot. The caller fills the layer in, and gets back the mask string the exporter wrote
|
||||||
|
// for `painted_facet`, which is what the load has to reproduce.
|
||||||
|
//
|
||||||
|
// The painted facet is deliberately not the first one: the importer keeps a slot's masks only as far
|
||||||
|
// as its last painted triangle, so a facet in the middle is the case where that run has to be padded
|
||||||
|
// in front and left short at the back.
|
||||||
|
static void store_texture_displaced_cube(const std::string& path, const TextureDisplacementLayer& layer,
|
||||||
|
const TextureDisplacementOptions& options, std::string& painted_mask,
|
||||||
|
int painted_facet = 5)
|
||||||
|
{
|
||||||
|
Model model;
|
||||||
|
ModelObject* object = model.add_object();
|
||||||
|
ModelVolume* volume = object->add_volume(make_cube(10., 10., 10.));
|
||||||
|
object->add_instance();
|
||||||
|
|
||||||
|
volume->texture_displacement_layers.push_back(layer);
|
||||||
|
volume->texture_displacement_options = options;
|
||||||
|
REQUIRE(painted_facet < int(volume->mesh().its.indices.size()));
|
||||||
|
{
|
||||||
|
TriangleSelector selector(volume->mesh());
|
||||||
|
selector.set_facet(painted_facet, EnforcerBlockerType::ENFORCER);
|
||||||
|
REQUIRE(volume->texture_displacement_facet(layer.slot).set(selector));
|
||||||
|
}
|
||||||
|
painted_mask = volume->texture_displacement_facet(layer.slot).get_triangle_as_string(painted_facet);
|
||||||
|
REQUIRE_FALSE(painted_mask.empty());
|
||||||
|
|
||||||
|
store_project(path, model);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the only volume of the only object in the project at `path`, loaded through the importer.
|
||||||
|
static const ModelVolume& load_single_volume(const std::string& path, Model& model, const ScopedTemporaryDir& backup_dir)
|
||||||
|
{
|
||||||
|
REQUIRE(load_project(path, model, backup_dir));
|
||||||
|
REQUIRE(model.objects.size() == 1);
|
||||||
|
REQUIRE(model.objects.front()->volumes.size() == 1);
|
||||||
|
return *model.objects.front()->volumes.front();
|
||||||
|
}
|
||||||
|
|
||||||
|
// A texture displacement stack is the one paint tool whose settings live outside the volume's config:
|
||||||
|
// the layer stack goes into the archive as its own JSON file, each layer's texture as a PNG beside it,
|
||||||
|
// and the paint as a per-triangle attribute (see add_texture_displacement). A project only reopens
|
||||||
|
// with its relief intact if all three come back together.
|
||||||
|
TEST_CASE("A texture displacement layer, its texture and its paint survive a project round trip", "[3mf][TextureDisplacement]")
|
||||||
|
{
|
||||||
|
TextureDisplacementLayer layer;
|
||||||
|
layer.slot = 3; // not the first slot, so the per-slot attribute names have to line up
|
||||||
|
layer.name = "Bark";
|
||||||
|
layer.path = "/textures/bark.png";
|
||||||
|
layer.depth_mm = 1.25f;
|
||||||
|
layer.tiling_scale = 7.5f;
|
||||||
|
layer.midlevel = 0.5f;
|
||||||
|
layer.invert = true;
|
||||||
|
layer.tile_method = TextureTileMethod::MirroredRepeat;
|
||||||
|
layer.projection_method = TextureProjectionMethod::LSCM;
|
||||||
|
layer.blend_mode = TextureBlendMode::Subtract;
|
||||||
|
layer.lscm_seam_edges = { { 1, 2 }, { 3, 5 } };
|
||||||
|
layer.image_data = make_gray_png(128);
|
||||||
|
|
||||||
|
TextureDisplacementOptions options;
|
||||||
|
options.smooth_enabled = true;
|
||||||
|
options.smooth_iterations = 4;
|
||||||
|
options.active_slot = layer.slot;
|
||||||
|
options.panel_mode = 1;
|
||||||
|
|
||||||
|
const int painted_facet = 5;
|
||||||
|
std::string painted;
|
||||||
|
ScopedTemporaryFile temp(".3mf");
|
||||||
|
store_texture_displaced_cube(temp.string(), layer, options, painted, painted_facet);
|
||||||
|
|
||||||
|
ScopedTemporaryDir backup_dir("orca_texdisp_dst");
|
||||||
|
Model model;
|
||||||
|
const ModelVolume& volume = load_single_volume(temp.string(), model, backup_dir);
|
||||||
|
|
||||||
|
REQUIRE(volume.texture_displacement_layers.size() == 1);
|
||||||
|
const TextureDisplacementLayer& read = volume.texture_displacement_layers.front();
|
||||||
|
CHECK(read.slot == layer.slot);
|
||||||
|
CHECK(read.name == layer.name);
|
||||||
|
CHECK(read.path == layer.path);
|
||||||
|
CHECK_THAT(read.depth_mm, Catch::Matchers::WithinAbs(layer.depth_mm, 1e-6f));
|
||||||
|
CHECK_THAT(read.tiling_scale, Catch::Matchers::WithinAbs(layer.tiling_scale, 1e-6f));
|
||||||
|
CHECK_THAT(read.midlevel, Catch::Matchers::WithinAbs(layer.midlevel, 1e-6f));
|
||||||
|
CHECK(read.invert == layer.invert);
|
||||||
|
CHECK(read.tile_method == layer.tile_method);
|
||||||
|
CHECK(read.projection_method == layer.projection_method);
|
||||||
|
CHECK(read.blend_mode == layer.blend_mode);
|
||||||
|
CHECK(read.lscm_seam_edges == layer.lscm_seam_edges);
|
||||||
|
|
||||||
|
// The texture rides along in the archive, named after the slot it belongs to, byte for byte.
|
||||||
|
CHECK(boost::algorithm::starts_with(read.path_in_3mf, TEXTURE_DISPLACEMENT_ENTRY_DIR));
|
||||||
|
CHECK(boost::algorithm::ends_with(read.path_in_3mf, "_" + std::to_string(layer.slot) + ".png"));
|
||||||
|
REQUIRE(read.image_data);
|
||||||
|
CHECK(*read.image_data == *layer.image_data);
|
||||||
|
|
||||||
|
CHECK(volume.texture_displacement_options.smooth_enabled);
|
||||||
|
CHECK(volume.texture_displacement_options.smooth_iterations == options.smooth_iterations);
|
||||||
|
CHECK(volume.texture_displacement_options.active_slot == options.active_slot);
|
||||||
|
CHECK(volume.texture_displacement_options.panel_mode == options.panel_mode);
|
||||||
|
|
||||||
|
// The paint comes back on the facet it was put on, on its own slot and nowhere else.
|
||||||
|
CHECK(volume.texture_displacement_facet(layer.slot).get_triangle_as_string(painted_facet) == painted);
|
||||||
|
CHECK(volume.texture_displacement_facet(layer.slot).get_triangle_as_string(0).empty());
|
||||||
|
for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot)
|
||||||
|
if (slot != layer.slot)
|
||||||
|
CHECK(volume.texture_displacement_facet(slot).empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// A layer names the file its texture was imported from, which the library panel uses to point at the
|
||||||
|
// entry the layer came from. That name must not be opened: the project may come from anyone, the path
|
||||||
|
// may name any file the user can read - on Windows a UNC path names a host as well - and the next save
|
||||||
|
// would copy whatever was found there into the archive. A layer the archive has no image for comes
|
||||||
|
// back without pixels instead, which the user fixes by picking the texture again.
|
||||||
|
TEST_CASE("A texture displacement layer whose image the archive lacks is not read from the path it names", "[3mf][TextureDisplacement][Regression]")
|
||||||
|
{
|
||||||
|
// A file that really is there, so the test fails if the importer ever reaches for it.
|
||||||
|
ScopedTemporaryFile texture(".png");
|
||||||
|
REQUIRE(png::write_gray_to_file(texture.string(), 4, 4, std::vector<uint8_t>(16, 200)));
|
||||||
|
|
||||||
|
TextureDisplacementLayer layer;
|
||||||
|
layer.slot = 0;
|
||||||
|
layer.name = "Bark";
|
||||||
|
layer.path = texture.string(); // and no image_data: nothing for the exporter to put in the archive
|
||||||
|
|
||||||
|
std::string painted;
|
||||||
|
ScopedTemporaryFile temp(".3mf");
|
||||||
|
store_texture_displaced_cube(temp.string(), layer, TextureDisplacementOptions{}, painted);
|
||||||
|
|
||||||
|
for (const std::string& name : zip_entry_names(temp.string()))
|
||||||
|
if (boost::algorithm::starts_with(name, TEXTURE_DISPLACEMENT_ENTRY_DIR))
|
||||||
|
CHECK_FALSE(boost::algorithm::ends_with(name, ".png"));
|
||||||
|
|
||||||
|
ScopedTemporaryDir backup_dir("orca_texdisp_dst");
|
||||||
|
Model model;
|
||||||
|
const ModelVolume& volume = load_single_volume(temp.string(), model, backup_dir);
|
||||||
|
|
||||||
|
REQUIRE(volume.texture_displacement_layers.size() == 1);
|
||||||
|
CHECK(volume.texture_displacement_layers.front().path == texture.string());
|
||||||
|
CHECK_FALSE(bool(volume.texture_displacement_layers.front().image_data));
|
||||||
|
CHECK(volume.texture_displacement_facet(layer.slot).get_triangle_as_string(5) == painted);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The stack's JSON is arbitrary text by the time it is read back, and a value of the wrong type in it
|
||||||
|
// throws out of the parse. That has to cost the volume its layers, not the project: the model, and the
|
||||||
|
// paint the user can still build on, load as they would from a build without the feature.
|
||||||
|
TEST_CASE("A malformed texture displacement block leaves the rest of the project loadable", "[3mf][TextureDisplacement][Regression]")
|
||||||
|
{
|
||||||
|
TextureDisplacementLayer layer;
|
||||||
|
layer.slot = 0;
|
||||||
|
layer.name = "Bark";
|
||||||
|
layer.image_data = make_gray_png(128);
|
||||||
|
|
||||||
|
std::string painted;
|
||||||
|
ScopedTemporaryFile temp(".3mf");
|
||||||
|
store_texture_displaced_cube(temp.string(), layer, TextureDisplacementOptions{}, painted);
|
||||||
|
|
||||||
|
REQUIRE(rewrite_3mf_entries(temp.string(), [](std::string& name, std::string& data) {
|
||||||
|
if (!boost::algorithm::starts_with(name, TEXTURE_DISPLACEMENT_ENTRY_DIR) ||
|
||||||
|
!boost::algorithm::ends_with(name, ".json"))
|
||||||
|
return false;
|
||||||
|
// `offset` goes through a typed accessor, so a string in it throws; a plain setting like
|
||||||
|
// depth_mm would be shrugged off by the parse instead, leaving the layer loadable.
|
||||||
|
data = R"({"version":1,"layers":[{"slot":0,"offset":["deep","deep"]}]})";
|
||||||
|
return true;
|
||||||
|
}));
|
||||||
|
|
||||||
|
ScopedTemporaryDir backup_dir("orca_texdisp_dst");
|
||||||
|
Model model;
|
||||||
|
bool loaded = false;
|
||||||
|
REQUIRE_NOTHROW(loaded = load_project(temp.string(), model, backup_dir));
|
||||||
|
REQUIRE(loaded);
|
||||||
|
REQUIRE(model.objects.size() == 1);
|
||||||
|
REQUIRE(model.objects.front()->volumes.size() == 1);
|
||||||
|
const ModelVolume& volume = *model.objects.front()->volumes.front();
|
||||||
|
|
||||||
|
CHECK(volume.texture_displacement_layers.empty());
|
||||||
|
CHECK(volume.texture_displacement_facet(layer.slot).get_triangle_as_string(5) == painted);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A slot's masks are kept only as far as its last painted triangle, so where the paint falls in the
|
||||||
|
// list decides whether that run has to be padded in front of the paint, cut short behind it, or both.
|
||||||
|
TEST_CASE("Texture displacement paint survives wherever it falls in the triangle list", "[3mf][TextureDisplacement]")
|
||||||
|
{
|
||||||
|
const int painted_facet = GENERATE(0, 5, 11); // first, middle and last facet of a cube
|
||||||
|
INFO("painted facet " << painted_facet);
|
||||||
|
|
||||||
|
TextureDisplacementLayer layer;
|
||||||
|
layer.slot = 0;
|
||||||
|
layer.name = "Bark";
|
||||||
|
layer.image_data = make_gray_png(128);
|
||||||
|
|
||||||
|
std::string painted;
|
||||||
|
ScopedTemporaryFile temp(".3mf");
|
||||||
|
store_texture_displaced_cube(temp.string(), layer, TextureDisplacementOptions{}, painted, painted_facet);
|
||||||
|
|
||||||
|
ScopedTemporaryDir backup_dir("orca_texdisp_dst");
|
||||||
|
Model model;
|
||||||
|
const ModelVolume& volume = load_single_volume(temp.string(), model, backup_dir);
|
||||||
|
|
||||||
|
CHECK(volume.texture_displacement_facet(layer.slot).get_triangle_as_string(painted_facet) == painted);
|
||||||
|
for (int facet = 0; facet < int(volume.mesh().its.indices.size()); ++facet)
|
||||||
|
if (facet != painted_facet)
|
||||||
|
CHECK(volume.texture_displacement_facet(layer.slot).get_triangle_as_string(facet).empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each volume's stack is written under the volume's own id, and the paint of a whole object arrives as
|
||||||
|
// one run of triangles that the volumes take their slices of. Two volumes using the same slot are the
|
||||||
|
// case where both of those have to tell the volumes apart.
|
||||||
|
TEST_CASE("Two volumes of one object keep their own texture displacement layers", "[3mf][TextureDisplacement]")
|
||||||
|
{
|
||||||
|
const auto first_image = make_gray_png(64);
|
||||||
|
const auto second_image = make_gray_png(192);
|
||||||
|
REQUIRE(*first_image != *second_image);
|
||||||
|
|
||||||
|
// The facet each volume is painted on, volume-local, so a mask landing on the wrong volume shows up.
|
||||||
|
const int painted_facets[2] = { 0, 7 };
|
||||||
|
std::string painted[2];
|
||||||
|
|
||||||
|
ScopedTemporaryFile temp(".3mf");
|
||||||
|
{
|
||||||
|
Model model;
|
||||||
|
ModelObject* object = model.add_object();
|
||||||
|
object->add_instance();
|
||||||
|
for (int i = 0; i < 2; ++i) {
|
||||||
|
ModelVolume* volume = object->add_volume(make_cube(10., 10., 10.));
|
||||||
|
TextureDisplacementLayer layer;
|
||||||
|
layer.slot = 0; // the same slot in both, which is what makes their names collide
|
||||||
|
layer.name = i == 0 ? "First" : "Second";
|
||||||
|
layer.depth_mm = i == 0 ? 0.5f : 1.5f;
|
||||||
|
layer.image_data = i == 0 ? first_image : second_image;
|
||||||
|
volume->texture_displacement_layers.push_back(layer);
|
||||||
|
|
||||||
|
TriangleSelector selector(volume->mesh());
|
||||||
|
selector.set_facet(painted_facets[i], EnforcerBlockerType::ENFORCER);
|
||||||
|
REQUIRE(volume->texture_displacement_facet(layer.slot).set(selector));
|
||||||
|
painted[i] = volume->texture_displacement_facet(layer.slot).get_triangle_as_string(painted_facets[i]);
|
||||||
|
REQUIRE_FALSE(painted[i].empty());
|
||||||
|
}
|
||||||
|
store_project(temp.string(), model);
|
||||||
|
}
|
||||||
|
|
||||||
|
ScopedTemporaryDir backup_dir("orca_texdisp_dst");
|
||||||
|
Model model;
|
||||||
|
REQUIRE(load_project(temp.string(), model, backup_dir));
|
||||||
|
REQUIRE(model.objects.size() == 1);
|
||||||
|
REQUIRE(model.objects.front()->volumes.size() == 2);
|
||||||
|
|
||||||
|
const std::shared_ptr<std::vector<unsigned char>> images[2] = { first_image, second_image };
|
||||||
|
const std::string names[2] = { "First", "Second" };
|
||||||
|
const float depths[2] = { 0.5f, 1.5f };
|
||||||
|
std::string entries[2];
|
||||||
|
for (int i = 0; i < 2; ++i) {
|
||||||
|
INFO("volume " << i);
|
||||||
|
const ModelVolume& volume = *model.objects.front()->volumes[i];
|
||||||
|
REQUIRE(volume.texture_displacement_layers.size() == 1);
|
||||||
|
const TextureDisplacementLayer& layer = volume.texture_displacement_layers.front();
|
||||||
|
CHECK(layer.name == names[i]);
|
||||||
|
CHECK_THAT(layer.depth_mm, Catch::Matchers::WithinAbs(depths[i], 1e-6f));
|
||||||
|
REQUIRE(layer.image_data);
|
||||||
|
CHECK(*layer.image_data == *images[i]);
|
||||||
|
CHECK(volume.texture_displacement_facet(layer.slot).get_triangle_as_string(painted_facets[i]) == painted[i]);
|
||||||
|
CHECK(volume.texture_displacement_facet(layer.slot).get_triangle_as_string(painted_facets[1 - i]).empty());
|
||||||
|
entries[i] = layer.path_in_3mf;
|
||||||
|
}
|
||||||
|
// Both volumes paint slot 0, so only the volume id keeps their textures apart in the archive.
|
||||||
|
CHECK(entries[0] != entries[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2157,6 +2157,190 @@ TEST_CASE("A second bake beside a first comes out as fine as a single bake", "[T
|
|||||||
CHECK(second <= single * 5 / 4);
|
CHECK(second <= single * 5 / 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The .3mf stores the layer stack as JSON (see texture_displacement_layers_to_json). Two properties
|
||||||
|
// matter: everything the UV editor and the panel can set has to survive a round trip, and a document
|
||||||
|
// written by a build that knew fewer fields has to keep loading, with the missing ones left at their
|
||||||
|
// defaults rather than zeroed.
|
||||||
|
TEST_CASE("Texture displacement layers survive a JSON round trip", "[TextureDisplacement]")
|
||||||
|
{
|
||||||
|
std::vector<TextureDisplacementLayer> layers(2);
|
||||||
|
TextureDisplacementLayer &a = layers[0];
|
||||||
|
a.slot = 0;
|
||||||
|
a.name = "Bark";
|
||||||
|
a.path = "/textures/bark.png";
|
||||||
|
a.depth_mm = 1.25f;
|
||||||
|
a.tiling_scale = 7.5f;
|
||||||
|
a.rotation_deg = 30.f;
|
||||||
|
a.offset = Vec2f(0.25f, -0.5f);
|
||||||
|
a.invert = true;
|
||||||
|
a.midlevel = 0.125f;
|
||||||
|
a.smoothing = 0.75f;
|
||||||
|
a.edge_smoothing = true;
|
||||||
|
a.edge_smoothing_amount = 0.25f;
|
||||||
|
a.auto_connect_islands = false;
|
||||||
|
a.tile_enabled = false;
|
||||||
|
a.tile_method = TextureTileMethod::MirroredRepeat;
|
||||||
|
a.projection_method = TextureProjectionMethod::LSCM;
|
||||||
|
a.blend_mode = TextureBlendMode::Subtract;
|
||||||
|
a.color_enabled = true;
|
||||||
|
a.lscm_seam_angle_deg = 45.f;
|
||||||
|
a.island_padding_mm = 0.5f;
|
||||||
|
a.lscm_seam_edges = { { 1, 2 }, { 3, 5 } };
|
||||||
|
a.islands = { TextureIsland{ Vec2f(1.f, 2.f), 15.f, 1.5f }, TextureIsland{} };
|
||||||
|
a.island_groups = { 0, 0 };
|
||||||
|
a.lscm_uv_overrides = { { -4, Vec2f(0.5f, 0.75f) } };
|
||||||
|
|
||||||
|
TextureDisplacementLayer &b = layers[1];
|
||||||
|
b.slot = 1;
|
||||||
|
b.projection_method = TextureProjectionMethod::ViewProjected;
|
||||||
|
b.view_project_right = Vec3f(0.f, 1.f, 0.f);
|
||||||
|
b.view_project_up = Vec3f(0.f, 0.f, 1.f);
|
||||||
|
b.view_project_projective = true;
|
||||||
|
for (size_t i = 0; i < b.view_project_matrix.size(); ++i)
|
||||||
|
b.view_project_matrix[i] = float(i) + 0.5f;
|
||||||
|
|
||||||
|
TextureDisplacementOptions options;
|
||||||
|
options.displace_border = false;
|
||||||
|
options.smooth_enabled = true;
|
||||||
|
options.smooth_strength = 0.6f;
|
||||||
|
options.smooth_iterations = 5;
|
||||||
|
options.pipeline_v2 = false;
|
||||||
|
options.v2_max_triangles_k = 250;
|
||||||
|
options.color_mix_count = 3;
|
||||||
|
options.color_despeckle = 4;
|
||||||
|
options.active_slot = 3;
|
||||||
|
options.panel_mode = 1;
|
||||||
|
|
||||||
|
const std::string json = texture_displacement_layers_to_json(layers, options);
|
||||||
|
|
||||||
|
std::vector<TextureDisplacementLayer> read_layers;
|
||||||
|
TextureDisplacementOptions read_options;
|
||||||
|
REQUIRE(texture_displacement_layers_from_json(json, read_layers, read_options));
|
||||||
|
REQUIRE(read_layers.size() == layers.size());
|
||||||
|
|
||||||
|
const TextureDisplacementLayer &ra = read_layers[0];
|
||||||
|
CHECK(ra.name == a.name);
|
||||||
|
CHECK(ra.path == a.path);
|
||||||
|
CHECK_THAT(ra.depth_mm, WithinAbs(a.depth_mm, 1e-6f));
|
||||||
|
CHECK_THAT(ra.tiling_scale, WithinAbs(a.tiling_scale, 1e-6f));
|
||||||
|
CHECK_THAT(ra.rotation_deg, WithinAbs(a.rotation_deg, 1e-6f));
|
||||||
|
CHECK(ra.offset.isApprox(a.offset));
|
||||||
|
CHECK(ra.invert == a.invert);
|
||||||
|
CHECK_THAT(ra.midlevel, WithinAbs(a.midlevel, 1e-6f));
|
||||||
|
CHECK_THAT(ra.smoothing, WithinAbs(a.smoothing, 1e-6f));
|
||||||
|
CHECK(ra.edge_smoothing == a.edge_smoothing);
|
||||||
|
CHECK_THAT(ra.edge_smoothing_amount, WithinAbs(a.edge_smoothing_amount, 1e-6f));
|
||||||
|
CHECK(ra.auto_connect_islands == a.auto_connect_islands);
|
||||||
|
CHECK(ra.tile_enabled == a.tile_enabled);
|
||||||
|
CHECK(ra.tile_method == a.tile_method);
|
||||||
|
CHECK(ra.projection_method == a.projection_method);
|
||||||
|
CHECK(ra.blend_mode == a.blend_mode);
|
||||||
|
CHECK(ra.color_enabled == a.color_enabled);
|
||||||
|
CHECK_THAT(ra.lscm_seam_angle_deg, WithinAbs(a.lscm_seam_angle_deg, 1e-6f));
|
||||||
|
CHECK_THAT(ra.island_padding_mm, WithinAbs(a.island_padding_mm, 1e-6f));
|
||||||
|
CHECK(ra.lscm_seam_edges == a.lscm_seam_edges);
|
||||||
|
REQUIRE(ra.islands.size() == a.islands.size());
|
||||||
|
CHECK(ra.islands[0].offset.isApprox(a.islands[0].offset));
|
||||||
|
CHECK_THAT(ra.islands[0].rotation_deg, WithinAbs(a.islands[0].rotation_deg, 1e-6f));
|
||||||
|
CHECK_THAT(ra.islands[0].scale, WithinAbs(a.islands[0].scale, 1e-6f));
|
||||||
|
CHECK(ra.island_groups == a.island_groups);
|
||||||
|
REQUIRE(ra.lscm_uv_overrides.size() == 1);
|
||||||
|
CHECK(ra.lscm_uv_overrides[0].first == -4);
|
||||||
|
CHECK(ra.lscm_uv_overrides[0].second.isApprox(Vec2f(0.5f, 0.75f)));
|
||||||
|
|
||||||
|
const TextureDisplacementLayer &rb = read_layers[1];
|
||||||
|
CHECK(rb.projection_method == TextureProjectionMethod::ViewProjected);
|
||||||
|
CHECK(rb.view_project_right.isApprox(b.view_project_right));
|
||||||
|
CHECK(rb.view_project_up.isApprox(b.view_project_up));
|
||||||
|
CHECK(rb.view_project_projective);
|
||||||
|
CHECK(rb.view_project_matrix == b.view_project_matrix);
|
||||||
|
|
||||||
|
CHECK(read_options.displace_border == options.displace_border);
|
||||||
|
CHECK(read_options.smooth_enabled == options.smooth_enabled);
|
||||||
|
CHECK_THAT(read_options.smooth_strength, WithinAbs(options.smooth_strength, 1e-6f));
|
||||||
|
CHECK(read_options.smooth_iterations == options.smooth_iterations);
|
||||||
|
CHECK(read_options.pipeline_v2 == options.pipeline_v2);
|
||||||
|
CHECK(read_options.v2_max_triangles_k == options.v2_max_triangles_k);
|
||||||
|
CHECK(read_options.color_mix_count == options.color_mix_count);
|
||||||
|
CHECK(read_options.color_despeckle == options.color_despeckle);
|
||||||
|
// Where the panel was left comes back with the rest, so a reopened project is on the same layer.
|
||||||
|
CHECK(read_options.active_slot == options.active_slot);
|
||||||
|
CHECK(read_options.panel_mode == options.panel_mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Texture displacement JSON keeps defaults for keys it does not carry", "[TextureDisplacement]")
|
||||||
|
{
|
||||||
|
// What a build that knew fewer fields would have written: one layer, almost nothing set.
|
||||||
|
const std::string sparse = R"({"version":1,"layers":[{"slot":2,"name":"Old","depth_mm":0.75}]})";
|
||||||
|
|
||||||
|
std::vector<TextureDisplacementLayer> layers;
|
||||||
|
TextureDisplacementOptions options;
|
||||||
|
REQUIRE(texture_displacement_layers_from_json(sparse, layers, options));
|
||||||
|
REQUIRE(layers.size() == 1);
|
||||||
|
|
||||||
|
const TextureDisplacementLayer &l = layers[0];
|
||||||
|
const TextureDisplacementLayer fresh;
|
||||||
|
const TextureDisplacementOptions defaults;
|
||||||
|
CHECK(l.slot == 2);
|
||||||
|
CHECK(l.name == "Old");
|
||||||
|
CHECK_THAT(l.depth_mm, WithinAbs(0.75f, 1e-6f));
|
||||||
|
// Everything absent keeps the struct's own default rather than becoming zero.
|
||||||
|
CHECK_THAT(l.tiling_scale, WithinAbs(fresh.tiling_scale, 1e-6f));
|
||||||
|
CHECK(l.auto_connect_islands == fresh.auto_connect_islands);
|
||||||
|
CHECK(l.tile_enabled == fresh.tile_enabled);
|
||||||
|
CHECK(l.projection_method == fresh.projection_method);
|
||||||
|
CHECK_THAT(l.lscm_seam_angle_deg, WithinAbs(fresh.lscm_seam_angle_deg, 1e-6f));
|
||||||
|
CHECK(options.color_mix_count == defaults.color_mix_count);
|
||||||
|
CHECK(options.pipeline_v2 == defaults.pipeline_v2);
|
||||||
|
CHECK(options.active_slot == defaults.active_slot);
|
||||||
|
CHECK(options.panel_mode == defaults.panel_mode);
|
||||||
|
|
||||||
|
std::vector<TextureDisplacementLayer> unused_layers;
|
||||||
|
TextureDisplacementOptions unused_options;
|
||||||
|
CHECK(!texture_displacement_layers_from_json("not json at all", unused_layers, unused_options));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The vectors, the islands and the UV overrides are read through nlohmann's typed accessors, which
|
||||||
|
// throw on a value of the wrong type rather than returning anything. Nothing between here and the
|
||||||
|
// project load catches that, so a document carrying one has to come back as "malformed block".
|
||||||
|
TEST_CASE("Texture displacement JSON reports a value of the wrong type instead of throwing", "[TextureDisplacement]")
|
||||||
|
{
|
||||||
|
const std::string json = GENERATE(
|
||||||
|
R"({"layers":[{"slot":0,"offset":["a","b"]}]})",
|
||||||
|
R"({"layers":[{"slot":0,"view_project_right":[1,null,3]}]})",
|
||||||
|
R"({"layers":[{"slot":0,"view_project_matrix":[1,2,3,4,5,6,7,8,9,10,11,"x"]}]})",
|
||||||
|
R"({"layers":[{"slot":0,"lscm_seam_edges":["a","b"]}]})",
|
||||||
|
R"({"layers":[{"slot":0,"islands":[[1,2,"a",4]]}]})",
|
||||||
|
R"({"layers":[{"slot":0,"lscm_uv_overrides":[[1,"a",3]]}]})");
|
||||||
|
INFO(json);
|
||||||
|
|
||||||
|
std::vector<TextureDisplacementLayer> layers;
|
||||||
|
TextureDisplacementOptions options;
|
||||||
|
bool parsed = true;
|
||||||
|
REQUIRE_NOTHROW(parsed = texture_displacement_layers_from_json(json, layers, options));
|
||||||
|
CHECK_FALSE(parsed);
|
||||||
|
CHECK(layers.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// A plain setting is read through a helper that shrugs a wrong type off, which is the deliberate
|
||||||
|
// other half of the case above: the field keeps its default and the rest of the layer still loads.
|
||||||
|
TEST_CASE("Texture displacement JSON keeps the default for a setting of the wrong type", "[TextureDisplacement]")
|
||||||
|
{
|
||||||
|
const std::string json = R"({"options":{"smooth_strength":"a lot"},"layers":[{"slot":"two","name":"Old","depth_mm":0.75}]})";
|
||||||
|
|
||||||
|
std::vector<TextureDisplacementLayer> layers;
|
||||||
|
TextureDisplacementOptions options;
|
||||||
|
REQUIRE(texture_displacement_layers_from_json(json, layers, options));
|
||||||
|
REQUIRE(layers.size() == 1);
|
||||||
|
|
||||||
|
const TextureDisplacementLayer fresh;
|
||||||
|
const TextureDisplacementOptions defaults;
|
||||||
|
CHECK(layers[0].slot == fresh.slot);
|
||||||
|
CHECK(layers[0].name == "Old");
|
||||||
|
CHECK_THAT(layers[0].depth_mm, WithinAbs(0.75f, 1e-6f));
|
||||||
|
CHECK_THAT(options.smooth_strength, WithinAbs(defaults.smooth_strength, 1e-6f));
|
||||||
|
}
|
||||||
|
|
||||||
TEST_CASE("whether a layer's texture has colour agrees with its decode", "[TextureDisplacement]")
|
TEST_CASE("whether a layer's texture has colour agrees with its decode", "[TextureDisplacement]")
|
||||||
{
|
{
|
||||||
TextureDisplacementLayer gray;
|
TextureDisplacementLayer gray;
|
||||||
|
|||||||
Reference in New Issue
Block a user