mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-25 09:50:59 +00:00
Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b73e9df4f4 | ||
|
|
9d320954a0 | ||
|
|
ca093d9bf4 | ||
|
|
da951ad7f3 | ||
|
|
db10c719f6 | ||
|
|
7cb7465ed8 | ||
|
|
ddf9b85169 | ||
|
|
af52da061f | ||
|
|
87a5d20d4c | ||
|
|
0db1dc6480 | ||
|
|
42009cf385 | ||
|
|
9859d788d4 | ||
|
|
879f6b67e9 | ||
|
|
4ccb5648e6 | ||
|
|
aed0164ea1 | ||
|
|
0ee529e283 | ||
|
|
6e055e5d8b | ||
|
|
15b64522a4 | ||
|
|
7ae76e44b1 | ||
|
|
54c32a54b4 | ||
|
|
0030bed519 |
@@ -0,0 +1,146 @@
|
|||||||
|
name: Daily OFL OTA Update
|
||||||
|
|
||||||
|
# This workflow is intended for creating and publishing the OrcaFilamentLibrary (OFL) OPC package to
|
||||||
|
# https://github.com/OrcaSlicer/orcaslicer-profiles, which generates an OTA update.
|
||||||
|
# This cronjob runs daily at 00:00 UTC every day and scans main plus every release/vX.Y.Z branch for
|
||||||
|
# changes to resources/profiles/OrcaFilamentLibrary since that branch's own last successful run. Any
|
||||||
|
# branch with no changes is skipped; each changed branch gets its own post_merge_profiles.yml dispatch.
|
||||||
|
#
|
||||||
|
# OFL has no dedicated FOLDER_MERGERS grant (it isn't merged through the PR merge-bot delegation
|
||||||
|
# scheme), so post_merge_profiles.yml is dispatched with an explicit `vendor` input, which that
|
||||||
|
# workflow trusts and uses to bypass the FOLDER_MERGERS check for this trigger. That same explicit-
|
||||||
|
# vendor-dispatch path is also what makes post_merge_profiles.yml call the OTA auto-publish API after
|
||||||
|
# uploading - see post_merge_profiles.yml for both sides of that contract.
|
||||||
|
#
|
||||||
|
# If at least one branch was dispatched this run, a final step clears OFL's pending-publish
|
||||||
|
# table (POST /api/v1/ota/ofl/pending/clear) - the daily "published everything, reset" signal.
|
||||||
|
# That table is populated only by this pipeline's own auto-publish calls.
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 0 * * *"
|
||||||
|
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
actions: write # list this workflow's past runs and dispatch post_merge_profiles.yml
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
VENDOR: OrcaFilamentLibrary
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
daily-job:
|
||||||
|
if: ${{ github.repository == 'OrcaSlicer/OrcaSlicer' }}
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
# Full history: the per-branch "since last successful run" check below
|
||||||
|
# needs to look arbitrarily far back if a prior run failed or was skipped.
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Fetch all branches
|
||||||
|
shell: bash
|
||||||
|
run: git fetch origin '+refs/heads/*:refs/remotes/origin/*'
|
||||||
|
|
||||||
|
- name: Scan branches and publish changed OFL profiles
|
||||||
|
id: scan
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
published_any=false
|
||||||
|
|
||||||
|
mapfile -t branches < <(
|
||||||
|
gh api "repos/${{ github.repository }}/branches" --paginate --jq '.[].name' \
|
||||||
|
| grep -E '^(main|release/v[0-9]+\.[0-9]+\.[0-9]+)$' | sort -u
|
||||||
|
)
|
||||||
|
|
||||||
|
for branch in "${branches[@]}"; do
|
||||||
|
echo "::group::$branch"
|
||||||
|
|
||||||
|
# post_merge_profiles.yml's own run history, not this workflow's: this
|
||||||
|
# workflow only ever runs against main (schedule, or workflow_dispatch
|
||||||
|
# --ref main), so its head branch never varies - filtering ITS history
|
||||||
|
# by $branch would never match anything except main. post_merge_profiles.yml
|
||||||
|
# genuinely runs per-branch (this dispatch below sets --ref "$branch"),
|
||||||
|
# so its history is the real per-branch checkpoint. It also means a
|
||||||
|
# failed publish naturally gets retried tomorrow: the checkpoint only
|
||||||
|
# advances on a run that actually succeeded.
|
||||||
|
# --method GET is required, not cosmetic: gh api defaults to POST
|
||||||
|
# whenever -f fields are present unless a method is given
|
||||||
|
# explicitly, and POST on this list-runs endpoint 404s - confirmed
|
||||||
|
# on real Actions infrastructure, not just reasoned about.
|
||||||
|
since="$(gh api --method GET "repos/${{ github.repository }}/actions/workflows/post_merge_profiles.yml/runs" \
|
||||||
|
-f status=success -f branch="$branch" -f per_page=1 \
|
||||||
|
--jq '.workflow_runs[0].run_started_at // empty')"
|
||||||
|
|
||||||
|
if [ -z "$since" ]; then
|
||||||
|
echo "No prior successful run for $branch; treating OFL as changed."
|
||||||
|
changed=true
|
||||||
|
else
|
||||||
|
changed_files="$(git log --since="$since" --name-only --pretty=format: "origin/$branch" -- \
|
||||||
|
resources/profiles/OrcaFilamentLibrary resources/profiles/OrcaFilamentLibrary.json \
|
||||||
|
| sed '/^$/d')"
|
||||||
|
if [ -n "$changed_files" ]; then
|
||||||
|
echo "OFL changed on $branch since $since:"
|
||||||
|
echo "$changed_files"
|
||||||
|
changed=true
|
||||||
|
else
|
||||||
|
echo "No OFL changes on $branch since $since."
|
||||||
|
changed=false
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$changed" = true ]; then
|
||||||
|
# Tolerate a per-branch failure (e.g. a pre-existing release branch
|
||||||
|
# whose post_merge_profiles.yml predates the vendor/auto_publish
|
||||||
|
# inputs) rather than aborting the whole scan under set -e.
|
||||||
|
if gh workflow run post_merge_profiles.yml \
|
||||||
|
--repo "${{ github.repository }}" \
|
||||||
|
--ref "$branch" \
|
||||||
|
-f vendor="$VENDOR" -f auto_publish=true; then
|
||||||
|
published_any=true
|
||||||
|
else
|
||||||
|
echo "::warning::failed to dispatch post_merge_profiles.yml for $branch - its post_merge_profiles.yml at this ref may predate the vendor/auto_publish inputs"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "::endgroup::"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "published_any=$published_any" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Clear OFL pending queue
|
||||||
|
# Only when this run actually kicked off at least one publish - the
|
||||||
|
# daily reset is scoped to today's real activity, not called on a day
|
||||||
|
# where every branch reported no changes. Note "published_any" reflects
|
||||||
|
# a successful DISPATCH, not a confirmed live publish: gh workflow run
|
||||||
|
# is fire-and-forget, so this workflow never learns whether the
|
||||||
|
# dispatched post_merge_profiles.yml run actually reached its own
|
||||||
|
# auto-publish call. Acceptable since the table is populated only by
|
||||||
|
# our own auto-publish calls, not by anything else.
|
||||||
|
if: steps.scan.outputs.published_any == 'true'
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
OTA_API_BASE_URL: ${{ vars.OTA_API_BASE_URL }}
|
||||||
|
OTA_API_KEY: ${{ secrets.OFL_OTA_PUBLISH_KEY }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
[ -n "$OTA_API_BASE_URL" ] || { echo "::error::vars.OTA_API_BASE_URL is not set"; exit 1; }
|
||||||
|
[ -n "$OTA_API_KEY" ] || { echo "::error::secrets.OFL_OTA_PUBLISH_KEY is not set"; exit 1; }
|
||||||
|
|
||||||
|
resp_file="$RUNNER_TEMP/ota-pending-clear-response.json"
|
||||||
|
status="$(curl -sS -o "$resp_file" -w '%{http_code}' -X POST \
|
||||||
|
"${OTA_API_BASE_URL%/}/api/v1/ota/ofl/pending/clear" \
|
||||||
|
-H "Authorization: Bearer $OTA_API_KEY")"
|
||||||
|
body="$(cat "$resp_file")"
|
||||||
|
echo "$body"
|
||||||
|
|
||||||
|
if [ "$status" != "200" ]; then
|
||||||
|
echo "::error::OTA pending-clear call failed with HTTP $status"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -8,6 +8,19 @@ name: Post-merge profiles
|
|||||||
# only then does it become a live OTA update - this workflow does none of that
|
# only then does it become a live OTA update - this workflow does none of that
|
||||||
# last part (no changelog, no R2, no webhook).
|
# last part (no changelog, no R2, no webhook).
|
||||||
#
|
#
|
||||||
|
# A workflow_dispatch carrying a `vendor` input (e.g. the daily OFL cron - OFL has
|
||||||
|
# no FOLDER_MERGERS grant, since it isn't merged through the PR merge-bot delegation
|
||||||
|
# scheme) publishes that vendor directly and skips the FOLDER_MERGERS check below.
|
||||||
|
# workflow_dispatch is already a trusted, explicit trigger, unlike the automatic
|
||||||
|
# push-diff path the FOLDER_MERGERS check exists to gate.
|
||||||
|
#
|
||||||
|
# Separately, an ordinary push whose diff touches an OrcaFilamentLibrary company
|
||||||
|
# folder (resources/profiles/OrcaFilamentLibrary/filament/<Company>/**) records
|
||||||
|
# that PR as pending via POST /api/v1/ota/ofl/pending, regardless of whether
|
||||||
|
# OrcaFilamentLibrary as a whole is authorized to publish in this same run - a
|
||||||
|
# partner's OTA Manager dashboard should see a merged PR immediately, well
|
||||||
|
# before the daily cron actually builds and publishes it.
|
||||||
|
#
|
||||||
# Asset contract expected by OrcaCloud's release scanner:
|
# Asset contract expected by OrcaCloud's release scanner:
|
||||||
# ^(\d+\.\d+\.\d+)_([^_]+)_(\d+(?:\.\d+){3})_(\d{12})\.zip$
|
# ^(\d+\.\d+\.\d+)_([^_]+)_(\d+(?:\.\d+){3})_(\d{12})\.zip$
|
||||||
# <orca_ver>_<vendor>_<profile_version>_<UTC yyyymmddHHMM>.zip (zip root: <vendor>.opc)
|
# <orca_ver>_<vendor>_<profile_version>_<UTC yyyymmddHHMM>.zip (zip root: <vendor>.opc)
|
||||||
@@ -17,16 +30,39 @@ name: Post-merge profiles
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
|
# once v2.5.0 stable is released, this will be removed, so nightly won't receive OTA updates.
|
||||||
- main
|
- main
|
||||||
- release/*
|
# release/vX.Y.Z point-release branches only, not the release/vX.Y working
|
||||||
|
# branch profile PRs land on first - "v*.*.*" requires two literal dots,
|
||||||
|
# which release/vX.Y (one dot) doesn't have.
|
||||||
|
- release/v*.*.*
|
||||||
paths:
|
paths:
|
||||||
- 'resources/profiles/**'
|
- 'resources/profiles/**'
|
||||||
- '.github/workflows/post_merge_profiles.yml'
|
- '.github/workflows/post_merge_profiles.yml'
|
||||||
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
vendor:
|
||||||
|
description: >-
|
||||||
|
Publish only this vendor, bypassing the FOLDER_MERGERS grant check.
|
||||||
|
For trusted explicit dispatches only (e.g. the OFL nightly cron).
|
||||||
|
Leave empty to fall back to diffing the triggering commit.
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
auto_publish:
|
||||||
|
description: >-
|
||||||
|
After publishing, also call the OTA auto-publish API to go live
|
||||||
|
immediately, skipping the human changelog/Publish step. Separate
|
||||||
|
from `vendor` on purpose: a maintainer can dispatch with just
|
||||||
|
`vendor` set to rebuild/republish an asset without it going live.
|
||||||
|
Only the OFL nightly cron should set this to true.
|
||||||
|
required: false
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
pull-requests: read # commits/{sha}/pulls lookup in the OFL-pending step
|
||||||
|
|
||||||
# One run per branch; let a run finish rather than cancel it, since it publishes.
|
# One run per branch; let a run finish rather than cancel it, since it publishes.
|
||||||
concurrency:
|
concurrency:
|
||||||
@@ -66,8 +102,38 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
FOLDER_MERGERS: ${{ vars.FOLDER_MERGERS }}
|
FOLDER_MERGERS: ${{ vars.FOLDER_MERGERS }}
|
||||||
|
DISPATCH_VENDOR: ${{ github.event_name == 'workflow_dispatch' && inputs.vendor || '' }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
# A vendor has a manifest plus either a preset directory or a version
|
||||||
|
# field; this drops non-vendor files such as blacklist.json. Shared by
|
||||||
|
# both the explicit-dispatch path below and the push-diff path further
|
||||||
|
# down, so the definition of "valid vendor" can't drift between them.
|
||||||
|
is_valid_vendor() {
|
||||||
|
local v="$1"
|
||||||
|
local json="resources/profiles/$v.json"
|
||||||
|
[ -f "$json" ] && { [ -d "resources/profiles/$v" ] || jq -e '.version' "$json" >/dev/null 2>&1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Explicit vendor dispatch (e.g. the OFL cron): trust the caller and
|
||||||
|
# skip both the git-diff detection and the FOLDER_MERGERS check below.
|
||||||
|
if [ -n "$DISPATCH_VENDOR" ]; then
|
||||||
|
v="$DISPATCH_VENDOR"
|
||||||
|
# Becomes part of the release asset filename and the OTA API's
|
||||||
|
# payload; keep it to the same charset every real vendor name uses.
|
||||||
|
if ! [[ "$v" =~ ^[A-Za-z0-9]+$ ]]; then
|
||||||
|
echo "::error::vendor '$v' must be alphanumeric"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! is_valid_vendor "$v"; then
|
||||||
|
echo "::error::vendor '$v' has no resources/profiles/$v.json with a profile directory or version field"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "vendors=$v" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
base='${{ github.event.before }}'
|
base='${{ github.event.before }}'
|
||||||
head='${{ github.sha }}'
|
head='${{ github.sha }}'
|
||||||
# Zero SHA (branch created / force push) or manual dispatch: fall back
|
# Zero SHA (branch created / force push) or manual dispatch: fall back
|
||||||
@@ -75,6 +141,10 @@ jobs:
|
|||||||
if [ -z "$base" ] || [ "$base" = "0000000000000000000000000000000000000000" ] || ! git cat-file -e "$base^{commit}" 2>/dev/null; then
|
if [ -z "$base" ] || [ "$base" = "0000000000000000000000000000000000000000" ] || ! git cat-file -e "$base^{commit}" 2>/dev/null; then
|
||||||
base="$head^"
|
base="$head^"
|
||||||
fi
|
fi
|
||||||
|
# Exposed so the OFL-pending step below can reuse this exact diff
|
||||||
|
# range instead of re-deriving it (and drifting from this logic).
|
||||||
|
echo "base=$base" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "head=$head" >> "$GITHUB_OUTPUT"
|
||||||
mapfile -t candidates < <(
|
mapfile -t candidates < <(
|
||||||
git diff --name-only "$base" "$head" -- resources/profiles \
|
git diff --name-only "$base" "$head" -- resources/profiles \
|
||||||
| sed -nE 's#^resources/profiles/([^/]+)/.*#\1#p; s#^resources/profiles/([^/]+)\.json$#\1#p' \
|
| sed -nE 's#^resources/profiles/([^/]+)/.*#\1#p; s#^resources/profiles/([^/]+)\.json$#\1#p' \
|
||||||
@@ -84,10 +154,7 @@ jobs:
|
|||||||
vendors=()
|
vendors=()
|
||||||
for v in "${candidates[@]:-}"; do
|
for v in "${candidates[@]:-}"; do
|
||||||
[ -n "$v" ] || continue
|
[ -n "$v" ] || continue
|
||||||
json="resources/profiles/$v.json"
|
if is_valid_vendor "$v"; then
|
||||||
# A vendor has a manifest plus either a preset directory or a version
|
|
||||||
# field; this drops non-vendor files such as blacklist.json.
|
|
||||||
if [ -f "$json" ] && { [ -d "resources/profiles/$v" ] || jq -e '.version' "$json" >/dev/null 2>&1; }; then
|
|
||||||
vendors+=("$v")
|
vendors+=("$v")
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
@@ -101,6 +168,9 @@ jobs:
|
|||||||
# sibling bundle JSON are covered by at least one FOLDER_MERGERS
|
# sibling bundle JSON are covered by at least one FOLDER_MERGERS
|
||||||
# grant. The account part is intentionally ignored here: this is a
|
# grant. The account part is intentionally ignored here: this is a
|
||||||
# post-merge safety check, not an authorization check for a command.
|
# post-merge safety check, not an authorization check for a command.
|
||||||
|
# An ineligible vendor (e.g. OrcaFilamentLibrary, which has no grant)
|
||||||
|
# is dropped on its own - it never blocks other vendors in the same
|
||||||
|
# push from publishing.
|
||||||
grants=()
|
grants=()
|
||||||
while IFS= read -r raw_line; do
|
while IFS= read -r raw_line; do
|
||||||
line="${raw_line#"${raw_line%%[![:space:]]*}"}"
|
line="${raw_line#"${raw_line%%[![:space:]]*}"}"
|
||||||
@@ -127,23 +197,29 @@ jobs:
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
authorized=()
|
||||||
unauthorized=()
|
unauthorized=()
|
||||||
for v in "${vendors[@]}"; do
|
for v in "${vendors[@]}"; do
|
||||||
if ! is_granted "resources/profiles/$v" || ! is_granted "resources/profiles/$v.json"; then
|
if is_granted "resources/profiles/$v" && is_granted "resources/profiles/$v.json"; then
|
||||||
|
authorized+=("$v")
|
||||||
|
else
|
||||||
unauthorized+=("$v")
|
unauthorized+=("$v")
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
if [ "${#unauthorized[@]}" -ne 0 ]; then
|
if [ "${#unauthorized[@]}" -ne 0 ]; then
|
||||||
echo "vendors=" >> "$GITHUB_OUTPUT"
|
echo "::warning::skipping vendor(s) with no FOLDER_MERGERS grant (no asset built or published for them this run): ${unauthorized[*]}"
|
||||||
exit 0
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "vendors=${vendors[*]}" >> "$GITHUB_OUTPUT"
|
echo "vendors=${authorized[*]}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Resolve Orca version
|
- name: Resolve Orca version
|
||||||
id: orca
|
id: orca
|
||||||
if: steps.vendors.outputs.vendors != ''
|
# Unconditional: needed both by the vendor-publish pipeline below (only
|
||||||
|
# when vendors is non-empty) and by the OFL-pending step at the end
|
||||||
|
# (which runs whenever OFL itself changed, even if vendors ends up
|
||||||
|
# empty because OFL has no FOLDER_MERGERS grant). Cheap and harmless
|
||||||
|
# to always resolve - version.inc is present on every commit.
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -242,3 +318,132 @@ jobs:
|
|||||||
echo "### Published to \`$repo\` release \`$RELEASE_TAG\`"
|
echo "### Published to \`$repo\` release \`$RELEASE_TAG\`"
|
||||||
for f in "$ASSET_DIR"/*.zip; do echo "- \`$(basename "$f")\`"; done
|
for f in "$ASSET_DIR"/*.zip; do echo "- \`$(basename "$f")\`"; done
|
||||||
} >> "$GITHUB_STEP_SUMMARY"
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
|
- name: Notify OTA auto-publish
|
||||||
|
# Gated on auto_publish specifically, not just "vendor was dispatched":
|
||||||
|
# a maintainer manually dispatching with vendor=OrcaFilamentLibrary (e.g.
|
||||||
|
# to rebuild/republish an asset while debugging) must not silently go
|
||||||
|
# live. Only a caller that explicitly opts in with auto_publish=true
|
||||||
|
# (the OFL nightly cron) skips the human changelog/Publish step.
|
||||||
|
if: >-
|
||||||
|
steps.vendors.outputs.vendors != '' && github.event_name == 'workflow_dispatch'
|
||||||
|
&& (inputs.auto_publish == true || inputs.auto_publish == 'true')
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
OTA_API_BASE_URL: ${{ vars.OTA_API_BASE_URL }}
|
||||||
|
OTA_API_KEY: ${{ secrets.OFL_OTA_PUBLISH_KEY }}
|
||||||
|
ASSET_DIR: ${{ steps.pkg.outputs.dir }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
[ -n "$OTA_API_BASE_URL" ] || { echo "::error::vars.OTA_API_BASE_URL is not set"; exit 1; }
|
||||||
|
[ -n "$OTA_API_KEY" ] || { echo "::error::secrets.OFL_OTA_PUBLISH_KEY is not set"; exit 1; }
|
||||||
|
|
||||||
|
mapfile -t zip_files < <(cd "$ASSET_DIR" && ls -1 *.zip)
|
||||||
|
filenames_json="$(printf '%s\n' "${zip_files[@]}" | jq -R . | jq -s .)"
|
||||||
|
payload="$(jq -n --argjson filenames "$filenames_json" '{filenames: $filenames}')"
|
||||||
|
|
||||||
|
resp_file="$RUNNER_TEMP/ota-auto-publish-response.json"
|
||||||
|
status="$(curl -sS -o "$resp_file" -w '%{http_code}' -X POST \
|
||||||
|
"${OTA_API_BASE_URL%/}/api/v1/ota/auto-publish" \
|
||||||
|
-H "Authorization: Bearer $OTA_API_KEY" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "$payload")"
|
||||||
|
body="$(cat "$resp_file")"
|
||||||
|
echo "$body"
|
||||||
|
|
||||||
|
if [ "$status" != "200" ]; then
|
||||||
|
echo "::error::OTA auto-publish call failed with HTTP $status"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# A 200 can still carry per-file "error" results (e.g. NOT_FOUND); the
|
||||||
|
# asset is already safely published to the profiles release above, but
|
||||||
|
# it never went live, so treat that as a failure worth surfacing loudly.
|
||||||
|
error_count="$(jq '[.results[] | select(.status == "error")] | length' <<< "$body")"
|
||||||
|
if [ "$error_count" != "0" ]; then
|
||||||
|
jq -r '.results[] | select(.status == "error") | "::error::\(.filename): \(.code) - \(.message)"' <<< "$body"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Record OFL pending changes
|
||||||
|
# A real merge, never the cron's explicit-vendor dispatch (that's
|
||||||
|
# automation publishing, not a new merge to report). This covers two
|
||||||
|
# trigger shapes: an ordinary push, and a vendor-less workflow_dispatch
|
||||||
|
# - the latter is exactly what pr-merge-bot.yml's re-dispatch after a
|
||||||
|
# successful /bot merge looks like (a GITHUB_TOKEN-authored merge fires
|
||||||
|
# no push event at all, which is why that re-dispatch exists). Both
|
||||||
|
# land in the same diff-fallback path in "Resolve changed vendors", so
|
||||||
|
# base/head/orca_ver are already correctly populated either way - only
|
||||||
|
# this condition needs widening.
|
||||||
|
# Placed last in the job on purpose: a failure here must never block
|
||||||
|
# the vendor-publish pipeline above, which a step failing earlier in
|
||||||
|
# the job would do (subsequent steps without always() get skipped).
|
||||||
|
if: >-
|
||||||
|
github.event_name == 'push' ||
|
||||||
|
(github.event_name == 'workflow_dispatch' && !inputs.vendor)
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
OTA_API_BASE_URL: ${{ vars.OTA_API_BASE_URL }}
|
||||||
|
OTA_API_KEY: ${{ secrets.OFL_OTA_PUBLISH_KEY }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
base='${{ steps.vendors.outputs.base }}'
|
||||||
|
head='${{ steps.vendors.outputs.head }}'
|
||||||
|
orca_ver='${{ steps.orca.outputs.orca_ver }}'
|
||||||
|
|
||||||
|
# Only real vendor subdirectories under filament/, e.g.
|
||||||
|
# .../filament/Qidi/x.json -> "Qidi". This naturally excludes loose
|
||||||
|
# top-level files (.../filament/Generic PLA @System.json - no further
|
||||||
|
# slash to match) and is further filtered below to drop "base", the
|
||||||
|
# shared @base/@System inheritance folder, not a partner company.
|
||||||
|
mapfile -t ofl_companies < <(
|
||||||
|
git diff --name-only "$base" "$head" -- resources/profiles/OrcaFilamentLibrary/filament \
|
||||||
|
| sed -nE 's#^resources/profiles/OrcaFilamentLibrary/filament/([^/]+)/.*#\1#p' \
|
||||||
|
| grep -vx 'base' \
|
||||||
|
| sort -u
|
||||||
|
)
|
||||||
|
|
||||||
|
if [ "${#ofl_companies[@]}" -eq 0 ]; then
|
||||||
|
echo "No OFL company folders changed in this push."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ -n "$OTA_API_BASE_URL" ] || { echo "::error::vars.OTA_API_BASE_URL is not set"; exit 1; }
|
||||||
|
[ -n "$OTA_API_KEY" ] || { echo "::error::secrets.OFL_OTA_PUBLISH_KEY is not set"; exit 1; }
|
||||||
|
|
||||||
|
# The head commit's own merged PR, not a per-commit walk: this
|
||||||
|
# assumes the ordinary one-PR-per-push shape every other merge path
|
||||||
|
# in this repo already assumes (pr-merge-bot.yml's re-dispatch logic
|
||||||
|
# does the same). A merge commit's parents don't matter here - this
|
||||||
|
# API call works the same regardless of merge strategy.
|
||||||
|
pr_json="$(gh api "repos/${{ github.repository }}/commits/$head/pulls" \
|
||||||
|
--jq '[.[] | select(.merged_at != null)] | sort_by(.merged_at) | last // empty')"
|
||||||
|
|
||||||
|
if [ -z "$pr_json" ]; then
|
||||||
|
echo "::warning::push $head touches OFL compan(y/ies) (${ofl_companies[*]}) but has no associated merged PR; skipping pending record(s)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
pr_number="$(jq -r '.number' <<< "$pr_json")"
|
||||||
|
pr_url="$(jq -r '.html_url' <<< "$pr_json")"
|
||||||
|
pr_title="$(jq -r '.title' <<< "$pr_json")"
|
||||||
|
|
||||||
|
for company in "${ofl_companies[@]}"; do
|
||||||
|
payload="$(jq -n --arg vendor "$company" --arg ver "$orca_ver" --argjson pr "$pr_number" \
|
||||||
|
--arg url "$pr_url" --arg title "$pr_title" \
|
||||||
|
'{vendor: $vendor, orcaSlicerVersion: $ver, prNumber: $pr, prUrl: $url, prTitle: $title}')"
|
||||||
|
|
||||||
|
resp_file="$RUNNER_TEMP/ofl-pending-$company.json"
|
||||||
|
status="$(curl -sS -o "$resp_file" -w '%{http_code}' -X POST \
|
||||||
|
"${OTA_API_BASE_URL%/}/api/v1/ota/ofl/pending" \
|
||||||
|
-H "Authorization: Bearer $OTA_API_KEY" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "$payload")"
|
||||||
|
body="$(cat "$resp_file")"
|
||||||
|
echo "$body"
|
||||||
|
|
||||||
|
if [ "$status" != "200" ]; then
|
||||||
|
echo "::error::OFL pending record failed for vendor=$company (PR #$pr_number): HTTP $status"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ if (POLICY CMP0092)
|
|||||||
cmake_policy(SET CMP0092 NEW)
|
cmake_policy(SET CMP0092 NEW)
|
||||||
endif ()
|
endif ()
|
||||||
|
|
||||||
|
# project() reads this, so set it first.
|
||||||
|
set(CMAKE_USER_MAKE_RULES_OVERRIDE "${CMAKE_CURRENT_LIST_DIR}/cmake/modules/ClangClShowIncludes.cmake")
|
||||||
|
|
||||||
project(OrcaSlicer)
|
project(OrcaSlicer)
|
||||||
|
|
||||||
# Backward compatibility for old CMake versions
|
# Backward compatibility for old CMake versions
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# ccache does not parse the -clang: arguments CMake uses for clang-cl's gcc-style
|
||||||
|
# depfile, so a cache hit writes the object and no depfile, and Ninja then records
|
||||||
|
# no headers for that object. ccache reproduces /showIncludes output on a hit.
|
||||||
|
foreach (_lang C CXX)
|
||||||
|
if (CMAKE_${_lang}_COMPILER_ID STREQUAL "Clang" AND
|
||||||
|
CMAKE_${_lang}_COMPILER_FRONTEND_VARIANT STREQUAL "MSVC")
|
||||||
|
set(CMAKE_DEPFILE_FLAGS_${_lang} "/showIncludes")
|
||||||
|
set(CMAKE_${_lang}_DEPFILE_FORMAT msvc)
|
||||||
|
endif ()
|
||||||
|
endforeach ()
|
||||||
Vendored
+10
@@ -38,6 +38,14 @@ if(POLICY CMP0135) # DOWNLOAD_EXTRACT_TIMESTAMP
|
|||||||
cmake_policy(SET CMP0135 NEW)
|
cmake_policy(SET CMP0135 NEW)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
# project() reads this, so set it first. scripts/flatpak/make_deps_tar.sh packs deps/
|
||||||
|
# without cmake/, so the file is missing in a Flatpak build.
|
||||||
|
set(_rules_override "${CMAKE_CURRENT_LIST_DIR}/../cmake/modules/ClangClShowIncludes.cmake")
|
||||||
|
if (EXISTS "${_rules_override}")
|
||||||
|
set(CMAKE_USER_MAKE_RULES_OVERRIDE "${_rules_override}")
|
||||||
|
endif ()
|
||||||
|
unset(_rules_override)
|
||||||
|
|
||||||
project(OrcaSlicer-deps)
|
project(OrcaSlicer-deps)
|
||||||
|
|
||||||
# Backward compatibility for old CMake versions
|
# Backward compatibility for old CMake versions
|
||||||
@@ -220,6 +228,7 @@ if (NOT IS_CROSS_COMPILE OR NOT APPLE)
|
|||||||
-DCMAKE_CXX_COMPILER:STRING=${CMAKE_CXX_COMPILER}
|
-DCMAKE_CXX_COMPILER:STRING=${CMAKE_CXX_COMPILER}
|
||||||
-DCMAKE_C_COMPILER_LAUNCHER:STRING=${CMAKE_C_COMPILER_LAUNCHER}
|
-DCMAKE_C_COMPILER_LAUNCHER:STRING=${CMAKE_C_COMPILER_LAUNCHER}
|
||||||
-DCMAKE_CXX_COMPILER_LAUNCHER:STRING=${CMAKE_CXX_COMPILER_LAUNCHER}
|
-DCMAKE_CXX_COMPILER_LAUNCHER:STRING=${CMAKE_CXX_COMPILER_LAUNCHER}
|
||||||
|
-DCMAKE_USER_MAKE_RULES_OVERRIDE:STRING=${CMAKE_USER_MAKE_RULES_OVERRIDE}
|
||||||
-DCMAKE_TOOLCHAIN_FILE:STRING=${CMAKE_TOOLCHAIN_FILE}
|
-DCMAKE_TOOLCHAIN_FILE:STRING=${CMAKE_TOOLCHAIN_FILE}
|
||||||
-DCMAKE_EXE_LINKER_FLAGS:STRING=${CMAKE_EXE_LINKER_FLAGS}
|
-DCMAKE_EXE_LINKER_FLAGS:STRING=${CMAKE_EXE_LINKER_FLAGS}
|
||||||
-DCMAKE_SHARED_LINKER_FLAGS:STRING=${CMAKE_SHARED_LINKER_FLAGS}
|
-DCMAKE_SHARED_LINKER_FLAGS:STRING=${CMAKE_SHARED_LINKER_FLAGS}
|
||||||
@@ -267,6 +276,7 @@ else()
|
|||||||
-DCMAKE_IGNORE_PREFIX_PATH:STRING=${CMAKE_IGNORE_PREFIX_PATH}
|
-DCMAKE_IGNORE_PREFIX_PATH:STRING=${CMAKE_IGNORE_PREFIX_PATH}
|
||||||
-DCMAKE_C_COMPILER_LAUNCHER:STRING=${CMAKE_C_COMPILER_LAUNCHER}
|
-DCMAKE_C_COMPILER_LAUNCHER:STRING=${CMAKE_C_COMPILER_LAUNCHER}
|
||||||
-DCMAKE_CXX_COMPILER_LAUNCHER:STRING=${CMAKE_CXX_COMPILER_LAUNCHER}
|
-DCMAKE_CXX_COMPILER_LAUNCHER:STRING=${CMAKE_CXX_COMPILER_LAUNCHER}
|
||||||
|
-DCMAKE_USER_MAKE_RULES_OVERRIDE:STRING=${CMAKE_USER_MAKE_RULES_OVERRIDE}
|
||||||
-DBUILD_SHARED_LIBS:BOOL=OFF
|
-DBUILD_SHARED_LIBS:BOOL=OFF
|
||||||
${_cmake_osx_arch}
|
${_cmake_osx_arch}
|
||||||
"${_configs_line}"
|
"${_configs_line}"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Elegoo",
|
"name": "Elegoo",
|
||||||
"version": "02.04.00.10",
|
"version": "02.04.00.11",
|
||||||
"force_update": "0",
|
"force_update": "0",
|
||||||
"description": "Elegoo configurations",
|
"description": "Elegoo configurations",
|
||||||
"machine_model_list": [
|
"machine_model_list": [
|
||||||
|
|||||||
@@ -15,6 +15,6 @@
|
|||||||
"Elegoo"
|
"Elegoo"
|
||||||
],
|
],
|
||||||
"filament_start_gcode": [
|
"filament_start_gcode": [
|
||||||
"; filament start gcode\n"
|
"; Elegoo PLA filament start gcode\n"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "OrcaFilamentLibrary",
|
"name": "OrcaFilamentLibrary",
|
||||||
"version": "02.04.00.11",
|
"version": "02.04.00.12",
|
||||||
"force_update": "0",
|
"force_update": "0",
|
||||||
"description": "Orca Filament Library",
|
"description": "Orca Filament Library",
|
||||||
"filament_list": [
|
"filament_list": [
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
"60"
|
"60"
|
||||||
],
|
],
|
||||||
"filament_start_gcode": [
|
"filament_start_gcode": [
|
||||||
"; filament start gcode\n"
|
"; Elegoo PLA filament start gcode\n"
|
||||||
],
|
],
|
||||||
"filament_end_gcode": [
|
"filament_end_gcode": [
|
||||||
"; filament end gcode \n"
|
"; filament end gcode \n"
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ var USER_MODE = "simple";
|
|||||||
var MODE_RANK = { simple: 0, advanced: 1, expert: 2, develop: 3 };
|
var MODE_RANK = { simple: 0, advanced: 1, expert: 2, develop: 3 };
|
||||||
|
|
||||||
// Search ranking weights: every contiguous match must outrank every fuzzy one regardless of field,
|
// Search ranking weights: every contiguous match must outrank every fuzzy one regardless of field,
|
||||||
// and title must outrank group, which outranks source.
|
// and title must outrank group/category, which outranks source.
|
||||||
var SCORE_CONTIGUOUS = 100000;
|
var SCORE_CONTIGUOUS = 100000;
|
||||||
var SCORE_TITLE = 2000;
|
var SCORE_TITLE = 2000;
|
||||||
var SCORE_GROUP = 1000;
|
var SCORE_GROUP = 1000;
|
||||||
@@ -118,6 +118,18 @@ function sourceNorm(a) {
|
|||||||
a._sn = NormText(a.source || "", false);
|
a._sn = NormText(a.source || "", false);
|
||||||
return a._sn;
|
return a._sn;
|
||||||
}
|
}
|
||||||
|
function pluginCategoryNorm(a) {
|
||||||
|
if (a._pcn === undefined)
|
||||||
|
a._pcn = a.kind === "plugin" ? NormText(T("sd_plugins", "Plugins"), false) : "";
|
||||||
|
return a._pcn;
|
||||||
|
}
|
||||||
|
// Plugin type is searchable metadata, so typing "plugin" can find runnable plugin actions even
|
||||||
|
// when neither their capability nor plugin name contains that word. Keep both category forms.
|
||||||
|
function pluginTypeNorm(a) {
|
||||||
|
if (a._pn === undefined)
|
||||||
|
a._pn = a.kind === "plugin" ? NormText("plugin plugins", false) : "";
|
||||||
|
return a._pn;
|
||||||
|
}
|
||||||
// Search-only alias for the descriptive name when the title differs (e.g. "Reverse on even" vs
|
// Search-only alias for the descriptive name when the title differs (e.g. "Reverse on even" vs
|
||||||
// "Overhang reversal"). Never rendered, so no highlight ranges.
|
// "Overhang reversal"). Never rendered, so no highlight ranges.
|
||||||
function fullNorm(a) {
|
function fullNorm(a) {
|
||||||
@@ -152,7 +164,7 @@ function fieldMatchScore(norm, needle, wwRe) {
|
|||||||
|
|
||||||
// Split a query into normalized (folded+lowercased) whitespace-separated tokens. Empty for a blank
|
// Split a query into normalized (folded+lowercased) whitespace-separated tokens. Empty for a blank
|
||||||
// query. These drive the multi-token path: every token must match some field, but different tokens
|
// query. These drive the multi-token path: every token must match some field, but different tokens
|
||||||
// may match different fields (the title, the group, or the source breadcrumb).
|
// may match different fields (the title, group, source breadcrumb, or plugin kind).
|
||||||
function queryTokens(query) {
|
function queryTokens(query) {
|
||||||
var norm = NormText(String(query || "").trim(), false);
|
var norm = NormText(String(query || "").trim(), false);
|
||||||
return norm ? norm.split(/\s+/).filter(Boolean) : [];
|
return norm ? norm.split(/\s+/).filter(Boolean) : [];
|
||||||
@@ -170,14 +182,19 @@ function tokenMatch(a, token, wwRe) {
|
|||||||
var g = fieldMatchScore(groupNorm(a), token, wwRe);
|
var g = fieldMatchScore(groupNorm(a), token, wwRe);
|
||||||
var s = fieldMatchScore(sourceNorm(a), token, wwRe);
|
var s = fieldMatchScore(sourceNorm(a), token, wwRe);
|
||||||
var f = fieldMatchScore(fullNorm(a), token, wwRe);
|
var f = fieldMatchScore(fullNorm(a), token, wwRe);
|
||||||
if (!t && !g && !s && !f) return null;
|
var p = fieldMatchScore(pluginCategoryNorm(a), token, wwRe);
|
||||||
|
var typeMatch = fieldMatchScore(pluginTypeNorm(a), token, wwRe);
|
||||||
|
if (!t && !g && !s && !f && !p && !typeMatch) return null;
|
||||||
var score = Math.max(
|
var score = Math.max(
|
||||||
t ? (t.contiguous ? SCORE_CONTIGUOUS : 0) + SCORE_TITLE + t.score : -Infinity,
|
t ? (t.contiguous ? SCORE_CONTIGUOUS : 0) + SCORE_TITLE + t.score : -Infinity,
|
||||||
g ? (g.contiguous ? SCORE_CONTIGUOUS : 0) + SCORE_GROUP + g.score : -Infinity,
|
g ? (g.contiguous ? SCORE_CONTIGUOUS : 0) + SCORE_GROUP + g.score : -Infinity,
|
||||||
s ? (s.contiguous ? SCORE_CONTIGUOUS : 0) + s.score : -Infinity,
|
s ? (s.contiguous ? SCORE_CONTIGUOUS : 0) + s.score : -Infinity,
|
||||||
f ? (f.contiguous ? SCORE_CONTIGUOUS : 0) + f.score : -Infinity
|
f ? (f.contiguous ? SCORE_CONTIGUOUS : 0) + f.score : -Infinity,
|
||||||
|
p ? (p.contiguous ? SCORE_CONTIGUOUS : 0) + SCORE_GROUP + p.score : -Infinity,
|
||||||
|
typeMatch ? (typeMatch.contiguous ? SCORE_CONTIGUOUS : 0) + SCORE_GROUP + typeMatch.score : -Infinity
|
||||||
);
|
);
|
||||||
return { score: score, title: t ? t.ranges : null, group: g ? g.ranges : null, source: s ? s.ranges : null };
|
return { score: score, title: t ? t.ranges : null, group: g ? g.ranges : null, source: s ? s.ranges : null,
|
||||||
|
plugin: p ? p.ranges : null };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge per-field token ranges into sorted, coalesced ranges for highlighting. Overlapping or adjacent
|
// Merge per-field token ranges into sorted, coalesced ranges for highlighting. Overlapping or adjacent
|
||||||
@@ -201,8 +218,8 @@ function mergeRanges(ranges) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Combine per-field scores into one value, or null when nothing matched.
|
// Combine per-field scores into one value, or null when nothing matched.
|
||||||
// Ranking: contiguous > fuzzy, then title > group > source/full alias, then start/gaps.
|
// Ranking: contiguous > fuzzy, then title > group/category > source/full alias, then start/gaps.
|
||||||
function scoreFields(t, g, s, f) {
|
function scoreFields(t, g, s, f, p, typeMatch) {
|
||||||
var best = null;
|
var best = null;
|
||||||
function consider(m, weight) {
|
function consider(m, weight) {
|
||||||
if (!m) return;
|
if (!m) return;
|
||||||
@@ -213,6 +230,8 @@ function scoreFields(t, g, s, f) {
|
|||||||
consider(g, SCORE_GROUP);
|
consider(g, SCORE_GROUP);
|
||||||
consider(s, 0);
|
consider(s, 0);
|
||||||
consider(f, 0);
|
consider(f, 0);
|
||||||
|
consider(p, SCORE_GROUP);
|
||||||
|
consider(typeMatch, SCORE_GROUP);
|
||||||
return best;
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,7 +245,8 @@ function scoreFields(t, g, s, f) {
|
|||||||
// - tokens: every whitespace-separated word must match SOME field, but different words may match
|
// - tokens: every whitespace-separated word must match SOME field, but different words may match
|
||||||
// different fields. This is what lets "speed acceleration inner" find "Inner wall" whose path is
|
// different fields. This is what lets "speed acceleration inner" find "Inner wall" whose path is
|
||||||
// "Process : Speed : Acceleration" (title + source breadcrumb together).
|
// "Process : Speed : Acceleration" (title + source breadcrumb together).
|
||||||
// full_label is searchable too but never highlighted, since it is not rendered.
|
// full_label and canonical plugin kind are searchable aliases. The visible category is also searchable
|
||||||
|
// so its own match can be highlighted in plugin rows.
|
||||||
// A phrase match always outranks a distributed token match.
|
// A phrase match always outranks a distributed token match.
|
||||||
function searchActions(actions, query) {
|
function searchActions(actions, query) {
|
||||||
var q = (query || "").trim();
|
var q = (query || "").trim();
|
||||||
@@ -251,26 +271,31 @@ function searchActions(actions, query) {
|
|||||||
var g = fieldMatchScore(groupNorm(a), searchNeedle, wwRe);
|
var g = fieldMatchScore(groupNorm(a), searchNeedle, wwRe);
|
||||||
var s = fieldMatchScore(sourceNorm(a), searchNeedle, wwRe);
|
var s = fieldMatchScore(sourceNorm(a), searchNeedle, wwRe);
|
||||||
var f = fieldMatchScore(fullNorm(a), searchNeedle, wwRe);
|
var f = fieldMatchScore(fullNorm(a), searchNeedle, wwRe);
|
||||||
var phrase = scoreFields(t, g, s, f);
|
var p = fieldMatchScore(pluginCategoryNorm(a), searchNeedle, wwRe);
|
||||||
|
var typeMatch = fieldMatchScore(pluginTypeNorm(a), searchNeedle, wwRe);
|
||||||
|
var phrase = scoreFields(t, g, s, f, p, typeMatch);
|
||||||
var score, ranges;
|
var score, ranges;
|
||||||
if (phrase !== null) {
|
if (phrase !== null) {
|
||||||
score = phrase + SCORE_PHRASE;
|
score = phrase + SCORE_PHRASE;
|
||||||
ranges = { title: t ? t.ranges : null, group: g ? g.ranges : null, source: s ? s.ranges : null };
|
ranges = { title: t ? t.ranges : null, group: g ? g.ranges : null, source: s ? s.ranges : null,
|
||||||
|
plugin: p ? p.ranges : null };
|
||||||
} else {
|
} else {
|
||||||
// Require every token; a token that matches nothing drops the action immediately. Ranges
|
// Require every token; a token that matches nothing drops the action immediately. Ranges
|
||||||
// from all matching tokens are merged per field so each matched word highlights.
|
// from all matching tokens are merged per field so each matched word highlights.
|
||||||
var sum = 0, titleR = null, groupR = null, sourceR = null, all = true;
|
var sum = 0, titleR = null, groupR = null, sourceR = null, pluginR = null, all = true;
|
||||||
for (var k = 0; k < searchTokens.length; k++) {
|
for (var tokenIndex = 0; tokenIndex < searchTokens.length; tokenIndex++) {
|
||||||
var m = tokenMatch(a, searchTokens[k], searchTokenRes[k]);
|
var m = tokenMatch(a, searchTokens[tokenIndex], searchTokenRes[tokenIndex]);
|
||||||
if (!m) { all = false; break; }
|
if (!m) { all = false; break; }
|
||||||
sum += m.score;
|
sum += m.score;
|
||||||
if (m.title) (titleR || (titleR = [])).push(m.title);
|
if (m.title) (titleR || (titleR = [])).push(m.title);
|
||||||
if (m.group) (groupR || (groupR = [])).push(m.group);
|
if (m.group) (groupR || (groupR = [])).push(m.group);
|
||||||
if (m.source) (sourceR || (sourceR = [])).push(m.source);
|
if (m.source) (sourceR || (sourceR = [])).push(m.source);
|
||||||
|
if (m.plugin) (pluginR || (pluginR = [])).push(m.plugin);
|
||||||
}
|
}
|
||||||
if (!all) continue;
|
if (!all) continue;
|
||||||
score = sum;
|
score = sum;
|
||||||
ranges = { title: mergeRanges(titleR), group: mergeRanges(groupR), source: mergeRanges(sourceR) };
|
ranges = { title: mergeRanges(titleR), group: mergeRanges(groupR), source: mergeRanges(sourceR),
|
||||||
|
plugin: mergeRanges(pluginR) };
|
||||||
}
|
}
|
||||||
// Ranges are per-field against the ACTUAL text drawn: title for the row-name, and group (or
|
// Ranges are per-field against the ACTUAL text drawn: title for the row-name, and group (or
|
||||||
// source when group is empty) for the eyebrow - so highlight offsets stay aligned to the label.
|
// source when group is empty) for the eyebrow - so highlight offsets stay aligned to the label.
|
||||||
@@ -278,6 +303,7 @@ function searchActions(actions, query) {
|
|||||||
title: ranges.title,
|
title: ranges.title,
|
||||||
group: ranges.group,
|
group: ranges.group,
|
||||||
source: ranges.source,
|
source: ranges.source,
|
||||||
|
plugin: ranges.plugin,
|
||||||
useEyebrowGroup: !!(a.group)
|
useEyebrowGroup: !!(a.group)
|
||||||
};
|
};
|
||||||
scored.push({ a: a, s: score });
|
scored.push({ a: a, s: score });
|
||||||
@@ -390,7 +416,7 @@ function favDigitFromEvent(e) {
|
|||||||
|
|
||||||
function resultCountText(total, shown, query) {
|
function resultCountText(total, shown, query) {
|
||||||
return (query || "").trim() ?
|
return (query || "").trim() ?
|
||||||
T("sd_result_count", "Showing %s of %s actions", shown, total) :
|
T("sd_result_count", "Showing %s actions", shown) :
|
||||||
T("sd_result_count_all", "%s actions", total);
|
T("sd_result_count_all", "%s actions", total);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,6 +487,12 @@ function actionCategory(a) {
|
|||||||
return cat || T("sd_other", "Other");
|
return cat || T("sd_other", "Other");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function actionEyebrow(a, typedQuery, isRecent) {
|
||||||
|
if (a && a.kind === "plugin" && (String(typedQuery || "").trim() || isRecent))
|
||||||
|
return T("sd_plugins", "Plugins");
|
||||||
|
return (a && (a.group || a.source)) || "";
|
||||||
|
}
|
||||||
|
|
||||||
// Stable-bucket actions by category, then order the groups alphabetically. Within a group the incoming
|
// Stable-bucket actions by category, then order the groups alphabetically. Within a group the incoming
|
||||||
// (frecency) order is kept. Pure so the node-vm test can exercise grouping.
|
// (frecency) order is kept. Pure so the node-vm test can exercise grouping.
|
||||||
function groupActions(list) {
|
function groupActions(list) {
|
||||||
@@ -711,7 +743,7 @@ window.HandleStudio = function (payload) {
|
|||||||
builtKey = "";
|
builtKey = "";
|
||||||
if (qEl) {
|
if (qEl) {
|
||||||
qEl.value = "";
|
qEl.value = "";
|
||||||
qEl.placeholder = T("sd_search_n", "Search %s actions", ACTIONS.length);
|
qEl.placeholder = T("sd_search", "Search actions");
|
||||||
syncClearButton();
|
syncClearButton();
|
||||||
}
|
}
|
||||||
render({ resize: true, resetScroll: true });
|
render({ resize: true, resetScroll: true });
|
||||||
@@ -931,12 +963,14 @@ function beginRow(item, i, mono, ariaLabel) {
|
|||||||
function renderActionRow(a, i) {
|
function renderActionRow(a, i) {
|
||||||
var on = FAVS.indexOf(a.id) !== -1;
|
var on = FAVS.indexOf(a.id) !== -1;
|
||||||
var shell = beginRow(a, i, false, actionLabel(a, ACTIONS));
|
var shell = beginRow(a, i, false, actionLabel(a, ACTIONS));
|
||||||
var mi = matchIndex[a.id];
|
var typedQuery = String(query || "").trim();
|
||||||
// The eyebrow shows group when present, else source. Highlight with the ranges of whichever of the
|
var isRecent = !typedQuery && i < RECENTS.length && RECENTS[i].id === a.id;
|
||||||
// two the eyebrow actually renders (so a "Recent Projects"/"Object" header match lights up like a
|
var mi = typedQuery ? matchIndex[a.id] : null;
|
||||||
// setting path does - the offsets are computed against the same string we are marking).
|
// Plugin search/recent rows show their category; other rows show their group or source breadcrumb.
|
||||||
var eyebrow = a.group || a.source;
|
// Use match ranges from the visible field so category and breadcrumb highlights stay aligned.
|
||||||
var eyebrowMatch = mi ? (mi.useEyebrowGroup ? mi.group : mi.source) : null;
|
var showPluginCategory = a.kind === "plugin" && (typedQuery || isRecent);
|
||||||
|
var eyebrow = actionEyebrow(a, query, isRecent);
|
||||||
|
var eyebrowMatch = mi ? (showPluginCategory ? mi.plugin : (mi.useEyebrowGroup ? mi.group : mi.source)) : null;
|
||||||
shell.left.insertBefore(markedText("row-eyebrow", eyebrow, eyebrowMatch), shell.line);
|
shell.left.insertBefore(markedText("row-eyebrow", eyebrow, eyebrowMatch), shell.line);
|
||||||
shell.line.appendChild(markedText("row-name", a.title, mi ? mi.title : null));
|
shell.line.appendChild(markedText("row-name", a.title, mi ? mi.title : null));
|
||||||
var badge = modeBadge(a, USER_MODE);
|
var badge = modeBadge(a, USER_MODE);
|
||||||
@@ -1376,7 +1410,7 @@ function exitPhase() {
|
|||||||
// It survives a second-phase exit (which never goes through exitPhase from the commands view),
|
// It survives a second-phase exit (which never goes through exitPhase from the commands view),
|
||||||
// so without a reset the cached empty-query key would skip the rebuild and leave stale content.
|
// so without a reset the cached empty-query key would skip the rebuild and leave stale content.
|
||||||
builtKey = "";
|
builtKey = "";
|
||||||
qEl.placeholder = T("sd_search_n", "Search %s actions", ACTIONS.length);
|
qEl.placeholder = T("sd_search", "Search actions");
|
||||||
render({ resize: true, resetScroll: true });
|
render({ resize: true, resetScroll: true });
|
||||||
qEl.focus();
|
qEl.focus();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,20 @@ assert.equal(ctx.searchActions(pool, "layer").length >= 2, true,
|
|||||||
assert.equal(ctx.searchActions(pool, "surface")[0].id, "c2",
|
assert.equal(ctx.searchActions(pool, "surface")[0].id, "c2",
|
||||||
"a later-but-precise match still ranks by relevance, not by pool type");
|
"a later-but-precise match still ranks by relevance, not by pool type");
|
||||||
|
|
||||||
|
const pluginPool = [
|
||||||
|
{ id: "plugin-action", title: "Optimize G-code", source: "Gcode Optimizer", group: "", kind: "plugin" },
|
||||||
|
{ id: "command-action", title: "Open Preferences", source: "OrcaSlicer", group: "Commands", kind: "command" }
|
||||||
|
];
|
||||||
|
assert.deepEqual(ctx.searchActions(pluginPool, "plugin").map(function (a) { return a.id; }), ["plugin-action"],
|
||||||
|
"the plugin kind makes runnable plugin actions searchable by plugin");
|
||||||
|
assert.deepEqual(ctx.searchActions(pluginPool, "plugins").map(function (a) { return a.id; }), ["plugin-action"],
|
||||||
|
"the plural Plugins category also finds plugin actions");
|
||||||
|
ctx.searchActions(pluginPool, "plugins");
|
||||||
|
assert.deepEqual(ctx.matchIndex["plugin-action"].plugin, [[0, 7]],
|
||||||
|
"a category match highlights the visible Plugins label");
|
||||||
|
assert.deepEqual(ctx.searchActions(pluginPool, "plugin optimize").map(function (a) { return a.id; }), ["plugin-action"],
|
||||||
|
"plugin kind can match one token while the action title matches another");
|
||||||
|
|
||||||
// A perfect match (the needle as one contiguous run) outranks a fuzzy match of the same field - and a
|
// A perfect match (the needle as one contiguous run) outranks a fuzzy match of the same field - and a
|
||||||
// contiguous GROUP/header hit ("Recent Projects") beats a scattered fuzzy TITLE hit ("Retraction Length"),
|
// contiguous GROUP/header hit ("Recent Projects") beats a scattered fuzzy TITLE hit ("Retraction Length"),
|
||||||
// which is what the old flat title-bonus ranking got backwards.
|
// which is what the old flat title-bonus ranking got backwards.
|
||||||
@@ -188,6 +202,12 @@ assert.equal(ctx.actionCategory({ id: "s", group: "", source: "Filament : Coolin
|
|||||||
"a Filament setting groups under Filament");
|
"a Filament setting groups under Filament");
|
||||||
assert.equal(ctx.actionCategory({ id: "plugin_script_action:Foo:bar.py", group: "", source: "Gcode Optimizer", kind: "plugin" }), "Plugins",
|
assert.equal(ctx.actionCategory({ id: "plugin_script_action:Foo:bar.py", group: "", source: "Gcode Optimizer", kind: "plugin" }), "Plugins",
|
||||||
"every plugin shares one Plugins header");
|
"every plugin shares one Plugins header");
|
||||||
|
assert.equal(ctx.actionEyebrow({ group: "", source: "Gcode Optimizer", kind: "plugin" }, "plugin"), "Plugins",
|
||||||
|
"typed results show only the Plugins category");
|
||||||
|
assert.equal(ctx.actionEyebrow({ group: "", source: "Gcode Optimizer", kind: "plugin" }, "", true), "Plugins",
|
||||||
|
"recent plugin actions show only the Plugins category");
|
||||||
|
assert.equal(ctx.actionEyebrow({ group: "", source: "Gcode Optimizer", kind: "plugin" }, ""), "Gcode Optimizer",
|
||||||
|
"the unfiltered plugin section keeps the source name on non-recent rows");
|
||||||
assert.equal(ctx.actionCategory({ id: "x", group: "", source: "", kind: "command" }), "Other",
|
assert.equal(ctx.actionCategory({ id: "x", group: "", source: "", kind: "command" }), "Other",
|
||||||
"a category-less action falls back to Other");
|
"a category-less action falls back to Other");
|
||||||
|
|
||||||
@@ -436,4 +456,9 @@ assert.equal(ctx.stateFromPayload({}).tooltipExpanded, true, "expansion defaults
|
|||||||
assert.equal(ctx.stateFromPayload({ tooltip_expanded: false }).tooltipExpanded, false, "a collapsed payload is honored");
|
assert.equal(ctx.stateFromPayload({ tooltip_expanded: false }).tooltipExpanded, false, "a collapsed payload is honored");
|
||||||
assert.equal(ctx.stateFromPayload({ tooltip_expanded: true }).tooltipExpanded, true, "an expanded payload is honored");
|
assert.equal(ctx.stateFromPayload({ tooltip_expanded: true }).tooltipExpanded, true, "an expanded payload is honored");
|
||||||
|
|
||||||
|
// resultCountText: a search counts the shown matches only ("Showing N actions"); the total is used
|
||||||
|
// solely for the empty-query count.
|
||||||
|
assert.equal(ctx.resultCountText(100, 3, "lay"), "Showing 3 actions", "a search reports the shown match count only");
|
||||||
|
assert.equal(ctx.resultCountText(100, 100, ""), "100 actions", "an empty query reports the total");
|
||||||
|
|
||||||
console.log("ok");
|
console.log("ok");
|
||||||
|
|||||||
+31
-5
@@ -5527,12 +5527,28 @@ int CLI::run(int argc, char **argv)
|
|||||||
//add the virtual object into unselect list if has
|
//add the virtual object into unselect list if has
|
||||||
partplate_list.preprocess_exclude_areas(unselected, enable_wrapping_detect);
|
partplate_list.preprocess_exclude_areas(unselected, enable_wrapping_detect);
|
||||||
|
|
||||||
if (used_filament_set.size() > 0)
|
// Filament ids given on the command line size the tower for STL input. A project
|
||||||
|
// records its filament use per plate, so count there and keep its tower positions.
|
||||||
|
const int plate_count = partplate_list.get_plate_count();
|
||||||
|
const bool from_project = used_filament_set.empty();
|
||||||
|
std::vector<int> plate_filament_counts(plate_count, static_cast<int>(used_filament_set.size()));
|
||||||
|
if (from_project)
|
||||||
|
for (int plate_index = 0; plate_index < plate_count; ++plate_index)
|
||||||
|
plate_filament_counts[plate_index] = static_cast<int>(partplate_list.get_plate(plate_index)->get_extruders_under_cli(true, m_print_config).size());
|
||||||
|
// A project only gets a tower the slicer will print: the prime tower enabled, and not
|
||||||
|
// a by-object print unless a smooth timelapse needs it, as the per-plate arrange decides.
|
||||||
|
const bool project_tower_allowed = m_print_config.option<ConfigOptionBool>("enable_prime_tower", true)->value &&
|
||||||
|
(is_smooth_timelapse || !arrange_cfg.is_seq_print);
|
||||||
|
const auto plate_needs_wipe_tower = [from_project, project_tower_allowed, is_smooth_timelapse](int filament_count) {
|
||||||
|
if (!from_project)
|
||||||
|
return filament_count > 0;
|
||||||
|
return project_tower_allowed && (filament_count > 1 || (filament_count > 0 && is_smooth_timelapse));
|
||||||
|
};
|
||||||
|
const int max_filament_count = plate_count > 0 ? *std::max_element(plate_filament_counts.begin(), plate_filament_counts.end()) : 0;
|
||||||
|
|
||||||
|
if (plate_needs_wipe_tower(max_filament_count))
|
||||||
{
|
{
|
||||||
//prepare the wipe tower
|
//prepare the wipe tower
|
||||||
int plate_count = partplate_list.get_plate_count();
|
|
||||||
int extruder_size = used_filament_set.size();
|
|
||||||
|
|
||||||
auto printer_structure_opt = m_print_config.option<ConfigOptionEnum<PrinterStructure>>("printer_structure");
|
auto printer_structure_opt = m_print_config.option<ConfigOptionEnum<PrinterStructure>>("printer_structure");
|
||||||
// This margin only pre-adjusts the default away from the near edges;
|
// This margin only pre-adjusts the default away from the near edges;
|
||||||
// estimate_wipe_tower_polygon below computes the real clamped position.
|
// estimate_wipe_tower_polygon below computes the real clamped position.
|
||||||
@@ -5568,7 +5584,11 @@ int CLI::run(int argc, char **argv)
|
|||||||
|
|
||||||
for (int bedid = 0; bedid < MAX_PLATE_COUNT; bedid++) {
|
for (int bedid = 0; bedid < MAX_PLATE_COUNT; bedid++) {
|
||||||
int plate_index_valid = std::min(bedid, plate_count - 1);
|
int plate_index_valid = std::min(bedid, plate_count - 1);
|
||||||
if (bedid < plate_count) {
|
// Overflow beds may receive objects from any plate, so size them for the busiest one.
|
||||||
|
const int extruder_size = bedid < plate_count ? plate_filament_counts[bedid] : max_filament_count;
|
||||||
|
if (!plate_needs_wipe_tower(extruder_size))
|
||||||
|
continue;
|
||||||
|
if (bedid < plate_count && !from_project) {
|
||||||
wipe_x_option->set_at(&wt_x_opt, plate_index_valid, 0);
|
wipe_x_option->set_at(&wt_x_opt, plate_index_valid, 0);
|
||||||
wipe_y_option->set_at(&wt_y_opt, plate_index_valid, 0);
|
wipe_y_option->set_at(&wt_y_opt, plate_index_valid, 0);
|
||||||
}
|
}
|
||||||
@@ -7024,6 +7044,12 @@ int CLI::run(int argc, char **argv)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
sliced_info.sliced_plates.push_back(sliced_plate_info);
|
sliced_info.sliced_plates.push_back(sliced_plate_info);
|
||||||
|
} catch (const Slic3r::SlicingErrors &exs) {
|
||||||
|
const std::string message = print_fff ? print_fff->slicing_errors_message(exs) : std::string(exs.what());
|
||||||
|
BOOST_LOG_TRIVIAL(error) << "found slicing or export error for partplate " << index+1 << ": " << message;
|
||||||
|
boost::nowide::cerr << message << std::endl;
|
||||||
|
record_exit_reson(outfile_dir, CLI_SLICING_ERROR, index+1, message, sliced_info);
|
||||||
|
flush_and_exit(CLI_SLICING_ERROR);
|
||||||
} catch (const std::exception &ex) {
|
} catch (const std::exception &ex) {
|
||||||
BOOST_LOG_TRIVIAL(error) << "found slicing or export error for partplate "<<index+1 << std::endl;
|
BOOST_LOG_TRIVIAL(error) << "found slicing or export error for partplate "<<index+1 << std::endl;
|
||||||
boost::nowide::cerr << ex.what() << std::endl;
|
boost::nowide::cerr << ex.what() << std::endl;
|
||||||
|
|||||||
@@ -423,6 +423,74 @@ bool ExtrusionLoop::is_smooth(double angle_threshold, double min_arm_length) con
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The seam is inserted into the loop unless a vertex lies within the G-code resolution of it, so a
|
||||||
|
// loop can begin and end with a segment of a few micrometres. A plain loop stops there anyway; a
|
||||||
|
// scarf extrudes through both ends, and the planner nearly halts on a block that short. Drop the
|
||||||
|
// vertex next to the seam point instead, so the loop still starts and ends at the seam. A trimmed
|
||||||
|
// path loses its arc fitting; its geometry is unchanged, it just prints as line segments.
|
||||||
|
static void trim_seam_ends(ExtrusionPaths &paths, double tolerance)
|
||||||
|
{
|
||||||
|
const auto shorter = [tolerance](const Point3 &a, const Point3 &b) { return (b - a).cast<double>().norm() < tolerance; };
|
||||||
|
|
||||||
|
while (!paths.empty()) {
|
||||||
|
Points3 &points = paths.front().polyline.points;
|
||||||
|
if (points.size() < 2 || !shorter(points[0], points[1]))
|
||||||
|
break;
|
||||||
|
if (points.size() > 2) {
|
||||||
|
points.erase(points.begin() + 1);
|
||||||
|
paths.front().polyline.fitting_result.clear();
|
||||||
|
} else if (paths.size() > 1) {
|
||||||
|
const Point3 seam = points.front();
|
||||||
|
paths.erase(paths.begin());
|
||||||
|
paths.front().polyline.points.front() = seam;
|
||||||
|
paths.front().polyline.fitting_result.clear();
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (!paths.empty()) {
|
||||||
|
Points3 &points = paths.back().polyline.points;
|
||||||
|
if (points.size() < 2 || !shorter(points[points.size() - 2], points.back()))
|
||||||
|
break;
|
||||||
|
if (points.size() > 2) {
|
||||||
|
points.erase(points.end() - 2);
|
||||||
|
paths.back().polyline.fitting_result.clear();
|
||||||
|
} else if (paths.size() > 1) {
|
||||||
|
const Point3 seam = points.back();
|
||||||
|
paths.pop_back();
|
||||||
|
paths.back().polyline.points.back() = seam;
|
||||||
|
paths.back().polyline.fitting_result.clear();
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split `polyline` where the scarf ramp ends. When the split would leave a remainder shorter
|
||||||
|
// than half a slope step before the next vertex, the ramp is extended to that vertex instead:
|
||||||
|
// a stub that short makes the motion planner slow down at the end of the ramp. Planners treat
|
||||||
|
// moves of a millimetre and more as ordinary, so the ramp never grows by more than that.
|
||||||
|
static void split_at_slope_end(const Polyline3 &polyline, double length, double slope_max_segment_length, Polyline3 &slope, Polyline3 &flat)
|
||||||
|
{
|
||||||
|
const double snap_distance = std::min(0.5 * slope_max_segment_length, scale_(1.));
|
||||||
|
double acc_length = 0.;
|
||||||
|
size_t line_idx = 0;
|
||||||
|
for (const Line3 &line : polyline.lines()) {
|
||||||
|
const double end_length = acc_length + line.length();
|
||||||
|
if (end_length >= length) {
|
||||||
|
if (end_length - length < snap_distance) {
|
||||||
|
polyline.split_at_index(line_idx + 1, &slope, &flat);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
acc_length = end_length;
|
||||||
|
++line_idx;
|
||||||
|
}
|
||||||
|
polyline.split_at_length(length, &slope, &flat);
|
||||||
|
}
|
||||||
|
|
||||||
ExtrusionLoopSloped::ExtrusionLoopSloped(ExtrusionPaths& original_paths,
|
ExtrusionLoopSloped::ExtrusionLoopSloped(ExtrusionPaths& original_paths,
|
||||||
double seam_gap,
|
double seam_gap,
|
||||||
double slope_min_length,
|
double slope_min_length,
|
||||||
@@ -431,6 +499,14 @@ ExtrusionLoopSloped::ExtrusionLoopSloped(ExtrusionPaths& original_paths,
|
|||||||
ExtrusionLoopRole role)
|
ExtrusionLoopRole role)
|
||||||
: ExtrusionLoop(role)
|
: ExtrusionLoop(role)
|
||||||
{
|
{
|
||||||
|
// An eighth of a common line width: the path moves by less than that at the seam.
|
||||||
|
trim_seam_ends(original_paths, scale_(0.05));
|
||||||
|
// The caller measured the loop before the trim; a scarf that covers the whole loop must still end at 1.
|
||||||
|
double trimmed_length = 0.;
|
||||||
|
for (const ExtrusionPath &path : original_paths)
|
||||||
|
trimmed_length += unscale_(path.length());
|
||||||
|
slope_min_length = std::min(slope_min_length, trimmed_length);
|
||||||
|
|
||||||
// create slopes
|
// create slopes
|
||||||
const auto add_slop = [this, slope_max_segment_length, seam_gap](const ExtrusionPath &path, const Polyline3 &poly, double ratio_begin, double ratio_end) {
|
const auto add_slop = [this, slope_max_segment_length, seam_gap](const ExtrusionPath &path, const Polyline3 &poly, double ratio_begin, double ratio_end) {
|
||||||
if (poly.empty()) { return; }
|
if (poly.empty()) { return; }
|
||||||
@@ -487,11 +563,12 @@ ExtrusionLoopSloped::ExtrusionLoopSloped(ExtrusionPaths& original_paths,
|
|||||||
// Split current path into slope and non-slope part
|
// Split current path into slope and non-slope part
|
||||||
Polyline3 slope_path;
|
Polyline3 slope_path;
|
||||||
Polyline3 flat_path;
|
Polyline3 flat_path;
|
||||||
path->polyline.split_at_length(scale_(remaining_length), &slope_path, &flat_path);
|
split_at_slope_end(path->polyline, scale_(remaining_length), slope_max_segment_length, slope_path, flat_path);
|
||||||
|
|
||||||
add_slop(*path, slope_path, start_ratio, 1);
|
add_slop(*path, slope_path, start_ratio, 1);
|
||||||
start_ratio = 1;
|
start_ratio = 1;
|
||||||
|
|
||||||
|
if (flat_path.size() > 1)
|
||||||
paths.emplace_back(std::move(flat_path), *path);
|
paths.emplace_back(std::move(flat_path), *path);
|
||||||
remaining_length = 0;
|
remaining_length = 0;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1705,6 +1705,25 @@ StringObjectException Print::check_multi_filament_valid(const Print& print)
|
|||||||
|
|
||||||
// Precondition: Print::validate() requires the Print::apply() to be called its invocation.
|
// Precondition: Print::validate() requires the Print::apply() to be called its invocation.
|
||||||
//BBS: refine seq-print validation logic
|
//BBS: refine seq-print validation logic
|
||||||
|
// The exception's own message is just "Errors"; the detail is in the per-object errors,
|
||||||
|
// whose object id is the PrintObject's.
|
||||||
|
std::string Print::slicing_errors_message(const SlicingErrors &errors) const
|
||||||
|
{
|
||||||
|
std::string message;
|
||||||
|
for (const SlicingError &error : errors.errors_) {
|
||||||
|
std::string object_name;
|
||||||
|
for (const PrintObject *object : m_objects)
|
||||||
|
if (object->id().id == error.objectId()) {
|
||||||
|
object_name = object->model_object()->name;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!message.empty())
|
||||||
|
message += "\n";
|
||||||
|
message += object_name.empty() ? std::string(error.what()) : object_name + ": " + error.what();
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
StringObjectException Print::validate(std::vector<StringObjectException> *warnings, Polygons* collison_polygons, std::vector<std::pair<Polygon, float>>* height_polygons) const
|
StringObjectException Print::validate(std::vector<StringObjectException> *warnings, Polygons* collison_polygons, std::vector<std::pair<Polygon, float>>* height_polygons) const
|
||||||
{
|
{
|
||||||
auto add_warning = [warnings](StringObjectException w) {
|
auto add_warning = [warnings](StringObjectException w) {
|
||||||
|
|||||||
@@ -30,6 +30,8 @@
|
|||||||
|
|
||||||
namespace Slic3r {
|
namespace Slic3r {
|
||||||
|
|
||||||
|
class SlicingErrors;
|
||||||
|
|
||||||
class GCode;
|
class GCode;
|
||||||
class Layer;
|
class Layer;
|
||||||
class ModelObject;
|
class ModelObject;
|
||||||
@@ -967,6 +969,8 @@ public:
|
|||||||
|
|
||||||
// Returns an empty string if valid, otherwise returns an error message.
|
// Returns an empty string if valid, otherwise returns an error message.
|
||||||
StringObjectException validate(std::vector<StringObjectException> *warnings = nullptr, Polygons* collison_polygons = nullptr, std::vector<std::pair<Polygon, float>>* height_polygons = nullptr) const override;
|
StringObjectException validate(std::vector<StringObjectException> *warnings = nullptr, Polygons* collison_polygons = nullptr, std::vector<std::pair<Polygon, float>>* height_polygons = nullptr) const override;
|
||||||
|
// The per-object messages of a SlicingErrors, each prefixed with its object's name.
|
||||||
|
std::string slicing_errors_message(const SlicingErrors &errors) const;
|
||||||
double skirt_first_layer_height() const;
|
double skirt_first_layer_height() const;
|
||||||
Flow brim_flow() const;
|
Flow brim_flow() const;
|
||||||
Flow skirt_flow() const;
|
Flow skirt_flow() const;
|
||||||
|
|||||||
@@ -2354,6 +2354,13 @@ void GLCanvas3D::render_thumbnail(ThumbnailData & thumbnail_data,
|
|||||||
render_thumbnail(thumbnail_data, w, h, thumbnail_params, model_objects, m_volumes, camera_type, camera_view_angle_type, for_picking, ban_light);
|
render_thumbnail(thumbnail_data, w, h, thumbnail_params, model_objects, m_volumes, camera_type, camera_view_angle_type, for_picking, ban_light);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool GLCanvas3D::_set_shown_canvas_current()
|
||||||
|
{
|
||||||
|
// Thumbnails also render outside render(), where another library's GL context (e.g. WebKitGTK's) can be current.
|
||||||
|
// Inside render(), the shown canvas is the one already bound.
|
||||||
|
return wxGetApp().plater()->get_current_canvas3D()->_set_current();
|
||||||
|
}
|
||||||
|
|
||||||
void GLCanvas3D::render_thumbnail(ThumbnailData & thumbnail_data,
|
void GLCanvas3D::render_thumbnail(ThumbnailData & thumbnail_data,
|
||||||
unsigned int w,
|
unsigned int w,
|
||||||
unsigned int h,
|
unsigned int h,
|
||||||
@@ -2365,6 +2372,9 @@ void GLCanvas3D::render_thumbnail(ThumbnailData & thumbnail_data,
|
|||||||
bool for_picking,
|
bool for_picking,
|
||||||
bool ban_light)
|
bool ban_light)
|
||||||
{
|
{
|
||||||
|
if (!_set_shown_canvas_current())
|
||||||
|
return;
|
||||||
|
|
||||||
GLShaderProgram* shader = nullptr;
|
GLShaderProgram* shader = nullptr;
|
||||||
if (for_picking)
|
if (for_picking)
|
||||||
shader = wxGetApp().get_shader("flat");
|
shader = wxGetApp().get_shader("flat");
|
||||||
@@ -2403,6 +2413,9 @@ void GLCanvas3D::render_thumbnail(ThumbnailData & thumbnail_d
|
|||||||
bool for_picking,
|
bool for_picking,
|
||||||
bool ban_light)
|
bool ban_light)
|
||||||
{
|
{
|
||||||
|
if (!_set_shown_canvas_current())
|
||||||
|
return;
|
||||||
|
|
||||||
GLShaderProgram *shader = wxGetApp().get_shader("thumbnail");
|
GLShaderProgram *shader = wxGetApp().get_shader("thumbnail");
|
||||||
switch (OpenGLManager::get_framebuffers_type()) {
|
switch (OpenGLManager::get_framebuffers_type()) {
|
||||||
case OpenGLManager::EFramebufferType::Arb: {
|
case OpenGLManager::EFramebufferType::Arb: {
|
||||||
|
|||||||
@@ -1320,6 +1320,7 @@ private:
|
|||||||
bool _init_collapse_toolbar();
|
bool _init_collapse_toolbar();
|
||||||
|
|
||||||
bool _set_current();
|
bool _set_current();
|
||||||
|
bool _set_shown_canvas_current();
|
||||||
void _resize(unsigned int w, unsigned int h);
|
void _resize(unsigned int w, unsigned int h);
|
||||||
|
|
||||||
//BBS: add part plate related logic
|
//BBS: add part plate related logic
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
#include "Plater.hpp"
|
#include "Plater.hpp"
|
||||||
#include "Widgets/WebViewHostDialog.hpp"
|
#include "Widgets/WebViewHostDialog.hpp"
|
||||||
|
|
||||||
|
#include "slic3r/Utils/MacDarkMode.hpp"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
|
||||||
#include <wx/dcmemory.h>
|
#include <wx/dcmemory.h>
|
||||||
@@ -84,7 +86,6 @@ nlohmann::json speed_dial_ui_strings()
|
|||||||
|
|
||||||
{"sd_search", _u8L("Search actions")},
|
{"sd_search", _u8L("Search actions")},
|
||||||
{"sd_clear", _u8L("Clear")},
|
{"sd_clear", _u8L("Clear")},
|
||||||
{"sd_search_n", _u8L("Search %s actions")},
|
|
||||||
{"sd_recent", _u8L("Recent")},
|
{"sd_recent", _u8L("Recent")},
|
||||||
{"sd_plugins", _u8L("Plugins")},
|
{"sd_plugins", _u8L("Plugins")},
|
||||||
{"sd_other", _u8L("Other")},
|
{"sd_other", _u8L("Other")},
|
||||||
@@ -92,7 +93,7 @@ nlohmann::json speed_dial_ui_strings()
|
|||||||
{"sd_no_actions", _u8L("No actions yet")},
|
{"sd_no_actions", _u8L("No actions yet")},
|
||||||
{"sd_no_tabs_match", _u8L("No tabs match")},
|
{"sd_no_tabs_match", _u8L("No tabs match")},
|
||||||
{"sd_no_tabs", _u8L("No tabs")},
|
{"sd_no_tabs", _u8L("No tabs")},
|
||||||
{"sd_result_count", _u8L("Showing %s of %s actions")},
|
{"sd_result_count", _u8L("Showing %s actions")},
|
||||||
{"sd_result_count_all", _u8L("%s actions")},
|
{"sd_result_count_all", _u8L("%s actions")},
|
||||||
{"sd_tab_count", _u8L("%s tabs")},
|
{"sd_tab_count", _u8L("%s tabs")},
|
||||||
{"sd_tab_match_count", _u8L("%s matches")},
|
{"sd_tab_match_count", _u8L("%s matches")},
|
||||||
@@ -178,6 +179,7 @@ void SpeedDialWebDialog::request_show()
|
|||||||
if (IsShown()) {
|
if (IsShown()) {
|
||||||
Raise();
|
Raise();
|
||||||
focus_webview(browser(), m_page_ready);
|
focus_webview(browser(), m_page_ready);
|
||||||
|
repaint_webview();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,6 +191,7 @@ void SpeedDialWebDialog::request_show()
|
|||||||
// Grab focus now and again on wxEVT_ACTIVATE; grabbing directly on the WebKit widget is
|
// Grab focus now and again on wxEVT_ACTIVATE; grabbing directly on the WebKit widget is
|
||||||
// what makes typing reach the search field immediately on open.
|
// what makes typing reach the search field immediately on open.
|
||||||
focus_webview(browser(), m_page_ready);
|
focus_webview(browser(), m_page_ready);
|
||||||
|
repaint_webview();
|
||||||
}
|
}
|
||||||
|
|
||||||
void SpeedDialWebDialog::on_script_message(const nlohmann::json& payload)
|
void SpeedDialWebDialog::on_script_message(const nlohmann::json& payload)
|
||||||
@@ -265,14 +268,36 @@ void SpeedDialWebDialog::resize_to_content(int height)
|
|||||||
Layout();
|
Layout();
|
||||||
#ifdef __WXOSX__
|
#ifdef __WXOSX__
|
||||||
// WKWebView can lag the dialog's new client size; force the viewport to match so the page is
|
// WKWebView can lag the dialog's new client size; force the viewport to match so the page is
|
||||||
// never painted (and clipped by the rounded layer) below the footer.
|
// never painted (and clipped by the rounded layer) below the footer. Unconditional: on a
|
||||||
if (wxWebView* wv = browser()) {
|
// re-open the size is often unchanged, and skipping the sync leaves the fresh render unpainted.
|
||||||
const wxSize client = GetClientSize();
|
if (wxWebView* wv = browser())
|
||||||
if (wv->GetSize() != client)
|
wv->SetSize(GetClientSize());
|
||||||
wv->SetSize(client);
|
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
apply_rounded_shape();
|
apply_rounded_shape();
|
||||||
|
// A re-open re-renders at (usually) the same size, so nothing above may generate damage.
|
||||||
|
// Repaint explicitly so the newly rendered list is shown without needing user input.
|
||||||
|
repaint_webview();
|
||||||
|
}
|
||||||
|
|
||||||
|
void SpeedDialWebDialog::repaint_webview()
|
||||||
|
{
|
||||||
|
wxWebView* wv = browser();
|
||||||
|
if (!wv)
|
||||||
|
return;
|
||||||
|
// Portable invalidate; the platform blocks below reach the widget/layer that actually paints.
|
||||||
|
wv->Refresh();
|
||||||
|
#ifdef __WXOSX__
|
||||||
|
if (void* nb = wv->GetNativeBackend())
|
||||||
|
WKWebView_force_display(nb);
|
||||||
|
wv->Update();
|
||||||
|
#elif defined(__linux__)
|
||||||
|
// WebKitGTK's WebKitWebView owns its own GdkWindow, so invalidating the wxWebView wrapper
|
||||||
|
// (the GtkScrolledWindow) does not redraw it.
|
||||||
|
if (void* nb = wv->GetNativeBackend())
|
||||||
|
gtk_widget_queue_draw((GtkWidget*) nb);
|
||||||
|
#else
|
||||||
|
wv->Update();
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rounded corners: the webview paints an opaque rectangle, so round the whole top-level window.
|
// Rounded corners: the webview paints an opaque rectangle, so round the whole top-level window.
|
||||||
@@ -341,16 +366,15 @@ void SpeedDialWebDialog::run_action(const std::string& id, const std::string& ti
|
|||||||
const wxString setting = title.empty() ? from_u8(atitle) : from_u8(title);
|
const wxString setting = title.empty() ? from_u8(atitle) : from_u8(title);
|
||||||
if (required == comDevelop) {
|
if (required == comDevelop) {
|
||||||
RichMessageDialog dlg(wxGetApp().mainframe,
|
RichMessageDialog dlg(wxGetApp().mainframe,
|
||||||
wxString::Format(_L("\"%s\" is a Developer setting. Enable Developer mode to edit it?"),
|
wxString::Format(_L("\"%s\" is a Developer setting. Enable Developer mode to edit it?"), setting),
|
||||||
setting),
|
|
||||||
_L("Developer setting"), wxOK | wxCANCEL);
|
_L("Developer setting"), wxOK | wxCANCEL);
|
||||||
if (dlg.ShowModal() != wxID_OK)
|
if (dlg.ShowModal() != wxID_OK)
|
||||||
return;
|
return;
|
||||||
wxGetApp().enable_developer_mode();
|
wxGetApp().enable_developer_mode();
|
||||||
} else {
|
} else {
|
||||||
RichMessageDialog dlg(wxGetApp().mainframe,
|
RichMessageDialog dlg(wxGetApp().mainframe,
|
||||||
wxString::Format(_L("\"%s\" is a %s setting. Switch from %s mode to %s mode to edit it?"),
|
wxString::Format(_L("\"%s\" is a %s setting. Switch from %s mode to %s mode to edit it?"), setting,
|
||||||
setting, mode_label(required), mode_label(wxGetApp().get_mode()), mode_label(required)),
|
mode_label(required), mode_label(wxGetApp().get_mode()), mode_label(required)),
|
||||||
_L("Switch settings mode"), wxOK | wxCANCEL);
|
_L("Switch settings mode"), wxOK | wxCANCEL);
|
||||||
if (dlg.ShowModal() != wxID_OK)
|
if (dlg.ShowModal() != wxID_OK)
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ private:
|
|||||||
void send_actions();
|
void send_actions();
|
||||||
void search_tabs();
|
void search_tabs();
|
||||||
void apply_rounded_shape();
|
void apply_rounded_shape();
|
||||||
|
// Forces the webview to repaint after it is mapped / re-rendered. The popup is transparent and
|
||||||
|
// chrome-less, so a missed frame leaves it blank until input; platform-specific because the
|
||||||
|
// widget that actually paints is not always the wxWebView wrapper.
|
||||||
|
void repaint_webview();
|
||||||
void on_dpi_changed(const wxRect& suggested_rect) override;
|
void on_dpi_changed(const wxRect& suggested_rect) override;
|
||||||
|
|
||||||
bool m_page_ready{false};
|
bool m_page_ready{false};
|
||||||
|
|||||||
@@ -7184,6 +7184,14 @@ void Tab::activate_selected_page(std::function<void()> throw_if_canceled)
|
|||||||
if (!m_active_page)
|
if (!m_active_page)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
#ifdef __WXGTK__
|
||||||
|
// Builds the page off screen, since GTK crashes when it desensitizes a multiline text view
|
||||||
|
// that was built on screen and hidden before its first size allocation.
|
||||||
|
const bool hide_view = m_active_page->build_pending() && m_page_view->IsShown();
|
||||||
|
if (hide_view)
|
||||||
|
m_page_view->Hide();
|
||||||
|
ScopeGuard show_view([this, hide_view] { if (hide_view) m_page_view->Show(); });
|
||||||
|
#endif
|
||||||
m_active_page->activate(m_mode, throw_if_canceled);
|
m_active_page->activate(m_mode, throw_if_canceled);
|
||||||
update_changed_ui();
|
update_changed_ui();
|
||||||
update_description_lines();
|
update_description_lines();
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ 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 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);
|
||||||
void initGestures(void * view, wxEvtHandler * handler);
|
void initGestures(void * view, wxEvtHandler * handler);
|
||||||
|
|||||||
@@ -102,6 +102,20 @@ void WKWebView_setTransparentBackground(void * web)
|
|||||||
[webView registerForDraggedTypes: @[NSFilenamesPboardType]];
|
[webView registerForDraggedTypes: @[NSFilenamesPboardType]];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Force a WKWebView to re-lay-out and repaint. Needed for chrome-less popups: the window is
|
||||||
|
// transparent, so a WKWebView whose layer has no pending frame leaves the whole window invisible
|
||||||
|
// until the user generates input (scroll/arrow). setNeedsDisplay alone does not always reach the
|
||||||
|
// web-content layer, so flag layout and both the view and its layer.
|
||||||
|
void WKWebView_force_display(void * web)
|
||||||
|
{
|
||||||
|
WKWebView * webView = (WKWebView*)web;
|
||||||
|
if (!webView)
|
||||||
|
return;
|
||||||
|
[webView setNeedsLayout:YES];
|
||||||
|
[webView setNeedsDisplay:YES];
|
||||||
|
[[webView layer] setNeedsDisplay];
|
||||||
|
}
|
||||||
|
|
||||||
void openFolderForFile(wxString const & file)
|
void openFolderForFile(wxString const & file)
|
||||||
{
|
{
|
||||||
NSArray *fileURLs = [NSArray arrayWithObjects:wxCFStringRef(file).AsNSString(), /* ... */ nil];
|
NSArray *fileURLs = [NSArray arrayWithObjects:wxCFStringRef(file).AsNSString(), /* ... */ nil];
|
||||||
|
|||||||
@@ -1098,17 +1098,15 @@ void PresetUpdater::priv::check_installed_vendor_profiles() const
|
|||||||
const auto enabled_vendors = app_config->vendors();
|
const auto enabled_vendors = app_config->vendors();
|
||||||
|
|
||||||
std::set<std::string> bundles;
|
std::set<std::string> bundles;
|
||||||
// Orca: always install filament library
|
|
||||||
bundles.insert(PresetBundle::ORCA_FILAMENT_LIBRARY);
|
|
||||||
// A vendor is named by its profile or, where the build ships preset caches
|
// A vendor is named by its profile or, where the build ships preset caches
|
||||||
// instead of the raw profile JSONs, by its cache alone.
|
// instead of the raw profile JSONs, by its cache alone.
|
||||||
for (const std::string &vendor_name : vendor_names_in(rsrc_path)) {
|
for (const std::string &vendor_name : vendor_names_in(rsrc_path)) {
|
||||||
if (bundles.find(vendor_name) != bundles.end())continue;
|
// enabled_vendors lists the vendors whose printer models the user picked, and
|
||||||
|
// neither of these two is ever in it.
|
||||||
const auto is_vendor_enabled = (vendor_name == PresetBundle::ORCA_DEFAULT_BUNDLE) // always update configs from resource to vendor for ORCA_DEFAULT_BUNDLE
|
const auto is_vendor_enabled = (vendor_name == PresetBundle::ORCA_DEFAULT_BUNDLE)
|
||||||
|
|| (vendor_name == PresetBundle::ORCA_FILAMENT_LIBRARY)
|
||||||
|| (enabled_vendors.find(vendor_name) != enabled_vendors.end());
|
|| (enabled_vendors.find(vendor_name) != enabled_vendors.end());
|
||||||
if (is_vendor_installed(vendor_name)) {
|
if (is_vendor_installed(vendor_name)) {
|
||||||
if (enabled_config_update) {
|
|
||||||
if (is_vendor_enabled) {
|
if (is_vendor_enabled) {
|
||||||
// Orca: whichever form of the vendor resources ships at the newer
|
// Orca: whichever form of the vendor resources ships at the newer
|
||||||
// version is the one installing lays down, and the one to judge
|
// version is the one installing lays down, and the one to judge
|
||||||
@@ -1127,7 +1125,6 @@ void PresetUpdater::priv::check_installed_vendor_profiles() const
|
|||||||
// need to be removed because not installed
|
// need to be removed because not installed
|
||||||
remove_installed_vendor(vendor_name);
|
remove_installed_vendor(vendor_name);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} else if (is_vendor_enabled) {
|
} else if (is_vendor_enabled) {
|
||||||
bundles.insert(vendor_name);
|
bundles.insert(vendor_name);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,3 +84,104 @@ SCENARIO("Polygon flattening", "[ExtrusionEntity]") {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static ExtrusionPaths straight_path(const std::vector<double> &xs)
|
||||||
|
{
|
||||||
|
ExtrusionPath path{erExternalPerimeter, 1.0, 0.45f, 0.2f};
|
||||||
|
for (double x : xs)
|
||||||
|
path.polyline.append(Point3::new_scale(x, 0., 0.));
|
||||||
|
return {path};
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Scarf ramp ends on the next loop vertex instead of leaving a short stub", "[ExtrusionEntity]")
|
||||||
|
{
|
||||||
|
using Catch::Matchers::WithinAbs;
|
||||||
|
// A 20 mm scarf in 10 steps: a remainder shorter than half a 2 mm step is snapped forward.
|
||||||
|
const double slope_length = 20.;
|
||||||
|
const double max_segment = scale_(slope_length / 10);
|
||||||
|
|
||||||
|
SECTION("a 0.09 mm remainder extends the ramp to the vertex") {
|
||||||
|
ExtrusionPaths paths = straight_path({0., 5., 10., 15., 20.09, 25., 30.});
|
||||||
|
ExtrusionLoopSloped loop(paths, 0., slope_length, max_segment, 0.);
|
||||||
|
REQUIRE(loop.starts.size() == 1);
|
||||||
|
REQUIRE(loop.ends.size() == 1);
|
||||||
|
REQUIRE(loop.paths.size() == 1);
|
||||||
|
CHECK_THAT(unscale_(loop.starts.front().polyline.last_point().x()), WithinAbs(20.09, 1e-3));
|
||||||
|
CHECK_THAT(unscale_(loop.ends.front().polyline.last_point().x()), WithinAbs(20.09, 1e-3));
|
||||||
|
CHECK_THAT(unscale_(loop.paths.front().polyline.first_point().x()), WithinAbs(20.09, 1e-3));
|
||||||
|
CHECK_THAT(unscale_(loop.paths.front().polyline.lines().front().length()), WithinAbs(4.91, 1e-3));
|
||||||
|
}
|
||||||
|
|
||||||
|
SECTION("a remainder longer than half a step keeps the exact scarf length") {
|
||||||
|
ExtrusionPaths paths = straight_path({0., 5., 10., 15., 21.5, 25., 30.});
|
||||||
|
ExtrusionLoopSloped loop(paths, 0., slope_length, max_segment, 0.);
|
||||||
|
REQUIRE(loop.starts.size() == 1);
|
||||||
|
REQUIRE(loop.paths.size() == 1);
|
||||||
|
CHECK_THAT(unscale_(loop.starts.front().polyline.last_point().x()), WithinAbs(20., 1e-3));
|
||||||
|
CHECK_THAT(unscale_(loop.paths.front().polyline.first_point().x()), WithinAbs(20., 1e-3));
|
||||||
|
CHECK_THAT(unscale_(loop.paths.front().polyline.lines().front().length()), WithinAbs(1.5, 1e-3));
|
||||||
|
}
|
||||||
|
|
||||||
|
SECTION("the ramp never grows by more than a millimetre, whatever the step size") {
|
||||||
|
ExtrusionPaths paths = straight_path({0., 5., 10., 15., 21.5, 25., 30.});
|
||||||
|
ExtrusionLoopSloped loop(paths, 0., slope_length, scale_(slope_length), 0.); // a single 20 mm step
|
||||||
|
REQUIRE(loop.paths.size() == 1);
|
||||||
|
CHECK_THAT(unscale_(loop.starts.front().polyline.last_point().x()), WithinAbs(20., 1e-3));
|
||||||
|
}
|
||||||
|
|
||||||
|
SECTION("snapping onto the path's last vertex leaves no single-point flat path") {
|
||||||
|
ExtrusionPaths paths = straight_path({0., 5., 10., 15., 20.5});
|
||||||
|
ExtrusionLoopSloped loop(paths, 0., slope_length, max_segment, 0.);
|
||||||
|
REQUIRE(loop.starts.size() == 1);
|
||||||
|
CHECK(loop.paths.empty());
|
||||||
|
CHECK_THAT(unscale_(loop.starts.front().polyline.last_point().x()), WithinAbs(20.5, 1e-3));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Scarf loop drops the micro segments the seam insertion leaves at both ends", "[ExtrusionEntity]")
|
||||||
|
{
|
||||||
|
using Catch::Matchers::WithinAbs;
|
||||||
|
const double slope_length = 20.;
|
||||||
|
const double max_segment = scale_(slope_length / 10);
|
||||||
|
|
||||||
|
SECTION("a 3 um segment at each end of a single path is removed, the seam point stays") {
|
||||||
|
ExtrusionPaths paths = straight_path({0., 0.003, 5., 10., 15., 21.5, 25., 29.997, 30.});
|
||||||
|
ExtrusionLoopSloped loop(paths, 0., slope_length, max_segment, 0.);
|
||||||
|
REQUIRE(loop.starts.size() == 1);
|
||||||
|
REQUIRE(loop.paths.size() == 1);
|
||||||
|
const Polyline3 &start = loop.starts.front().polyline;
|
||||||
|
CHECK_THAT(unscale_(start.first_point().x()), WithinAbs(0., 1e-4));
|
||||||
|
CHECK_THAT(unscale_(start.lines().front().length()), WithinAbs(1.25, 1e-3)); // 5 mm halved twice
|
||||||
|
const Polyline3 &flat = loop.paths.front().polyline;
|
||||||
|
CHECK_THAT(unscale_(flat.last_point().x()), WithinAbs(30., 1e-4));
|
||||||
|
CHECK_THAT(unscale_(flat.lines().back().length()), WithinAbs(5., 1e-3));
|
||||||
|
}
|
||||||
|
|
||||||
|
SECTION("a micro path of its own is dropped and the neighbour ends at the seam point") {
|
||||||
|
ExtrusionPaths paths = straight_path({0., 0.003});
|
||||||
|
ExtrusionPaths rest = straight_path({0.003, 5., 10., 15., 21.5, 25., 30.});
|
||||||
|
paths.push_back(rest.front());
|
||||||
|
ExtrusionLoopSloped loop(paths, 0., slope_length, max_segment, 0.);
|
||||||
|
REQUIRE(loop.starts.size() == 1);
|
||||||
|
CHECK_THAT(unscale_(loop.starts.front().polyline.first_point().x()), WithinAbs(0., 1e-4));
|
||||||
|
CHECK_THAT(unscale_(loop.starts.front().polyline.lines().front().length()), WithinAbs(1.25, 1e-3));
|
||||||
|
}
|
||||||
|
|
||||||
|
SECTION("a scarf covering the whole loop still ends at full flow after a trim") {
|
||||||
|
// The caller sizes the scarf from the untrimmed loop: 10.003 mm here, 10 mm after the trim.
|
||||||
|
ExtrusionPaths paths = straight_path({0., 0.003, 5., 10.});
|
||||||
|
ExtrusionLoopSloped loop(paths, 0., 10.003, max_segment, 0.);
|
||||||
|
REQUIRE(loop.starts.size() == 1);
|
||||||
|
CHECK(loop.paths.empty());
|
||||||
|
CHECK_THAT(loop.starts.back().slope_end.e_ratio, WithinAbs(1., 1e-9));
|
||||||
|
CHECK_THAT(unscale_(loop.starts.back().polyline.last_point().x()), WithinAbs(10., 1e-4));
|
||||||
|
}
|
||||||
|
|
||||||
|
SECTION("segments longer than the tolerance are kept") {
|
||||||
|
ExtrusionPaths paths = straight_path({0., 0.3, 5., 10., 15., 21.5, 25., 29.7, 30.});
|
||||||
|
ExtrusionLoopSloped loop(paths, 0., slope_length, max_segment, 0.);
|
||||||
|
REQUIRE(loop.paths.size() == 1);
|
||||||
|
CHECK_THAT(unscale_(loop.starts.front().polyline.lines().front().length()), WithinAbs(0.3, 1e-3));
|
||||||
|
CHECK_THAT(unscale_(loop.paths.front().polyline.lines().back().length()), WithinAbs(0.3, 1e-3));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -505,3 +505,29 @@ TEST_CASE("Sequential printing publishes the nozzle group result", "[Print][Mult
|
|||||||
CHECK(gcode.find("; SEQ-ND-OK") != std::string::npos);
|
CHECK(gcode.find("; SEQ-ND-OK") != std::string::npos);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Slicing errors are reported per object with the object's name", "[Print]")
|
||||||
|
{
|
||||||
|
Print print;
|
||||||
|
Model model;
|
||||||
|
init_print({Slic3r::Test::cube(20.)}, print, model);
|
||||||
|
// Lift the cube off the bed: its first layer is empty, which G-code export reports per object.
|
||||||
|
ModelObject *object = model.objects.front();
|
||||||
|
object->name = "floating cube";
|
||||||
|
object->instances.front()->set_offset(object->instances.front()->get_offset() + Vec3d(0., 0., 2.));
|
||||||
|
print.apply(model, DynamicPrintConfig::full_print_config());
|
||||||
|
print.set_status_silent();
|
||||||
|
|
||||||
|
ScopedTemporaryFile temp(".gcode");
|
||||||
|
std::string message;
|
||||||
|
try {
|
||||||
|
print.process();
|
||||||
|
print.export_gcode(temp.string(), nullptr, nullptr);
|
||||||
|
FAIL("slicing did not report the empty first layer");
|
||||||
|
} catch (const SlicingErrors &errors) {
|
||||||
|
REQUIRE(errors.errors_.size() == 1);
|
||||||
|
message = print.slicing_errors_message(errors);
|
||||||
|
}
|
||||||
|
CHECK(message.rfind("floating cube: ", 0) == 0);
|
||||||
|
CHECK(message.find("empty first layer") != std::string::npos);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user