Compare commits

...
Author SHA1 Message Date
Ian Chua b73e9df4f4 test: update profiles for ota update 2026-09-25 14:29:48 +08:00
Ian Chua 9d320954a0 feat: daily OTA publish for OFL (OrcaFilamentLibrary) (#15870)
# Description

<!--
> Please provide a summary of the changes made in this PR. Include
details such as:
  > * What issue does this PR address or fix?
  > * What new features or enhancements does this PR introduce?
> * Are there any breaking changes or dependencies that need to be
considered?
-->
- `OrcaFilamentLibrary` (OFL) has no `FOLDER_MERGERS` delegation and
changes far more often than other vendors, so it can't go through the
existing merge-triggered, human-published OTA flow.
`post_merge_profiles.yml` gains an explicit `vendor` + `auto_publish`
`workflow_dispatch` path that bypasses the `FOLDER_MERGERS` check and
calls the OTA auto-publish API directly, making the update live with no
human step.
- `ofl-ota-cronjob.yml` drives that path daily across `main` and every
`release/vX.Y.Z` branch, checkpointed against
`post_merge_profiles.yml`'s own per-branch run history so a failed
publish is retried rather than silently dropped.
- Fixes a pre-existing bug in `post_merge_profiles.yml`: a single vendor
with no `FOLDER_MERGERS` grant in a push used to zero out the *entire*
vendor batch, silently dropping other, properly-authorized vendors'
publishes too. It now drops only the ungranted vendor, with a warning.

## Notes
Once v2.5.0 stable is released, we need to remove branch checking
against main in both these workflows.

# Screenshots/Recordings/Graphs

<!--
> Please attach relevant screenshots to showcase the UI changes.
> Please attach images that can help explain the changes.
-->

## Tests

<!--
> Please describe the tests that you have conducted to verify the
changes made in this PR.
-->

<!--
> A guide for users on how to download the artifacts from this PR.
-->

[How to Download Pull Requests Artifacts for
Testing](https://www.orcaslicer.com/wiki/how_to_download_pr_artifacts)
2026-09-25 14:17:03 +08:00
Ian Chua ca093d9bf4 Merge branch 'main' into fix/ota-updates-workflow 2026-09-25 14:16:56 +08:00
SoftFever da951ad7f3 Load profile includes so synced Bambu Lab presets get their G-code back (#15869)
# Description

This brings back the Bambu Lab profile sync with BambuStudio, reverted
from main, along with its bed-model offset fix. The synced printers and
filaments share their start and end G-code and dual-nozzle settings
through template files that Orca did not read, so those printers had no
machine G-code. Orca now loads these templates the same way BambuStudio
does, so every synced preset comes in complete. `profile_include_dump`,
a new developer tool, compares the result with BambuStudio's.

Other vendors' profiles are unaffected. Bambu Lab profiles move to
version 02.08.00.10, so installs that took the earlier sync update too.

<!--
> Please provide a summary of the changes made in this PR. Include
details such as:
  > * What issue does this PR address or fix?
  > * What new features or enhancements does this PR introduce?
> * Are there any breaking changes or dependencies that need to be
considered?
-->

# Screenshots/Recordings/Graphs

<!--
> Please attach relevant screenshots to showcase the UI changes.
> Please attach images that can help explain the changes.
-->

## Tests

Unit tests cover how template settings combine with inherited and preset
settings, loaded from JSON and from the preset cache. The profile
validator passes on every shipped vendor, and every Bambu Lab preset
gets the same template values in Orca as in BambuStudio.

<!--
> A guide for users on how to download the artifacts from this PR.
-->

[How to Download Pull Requests Artifacts for
Testing](https://www.orcaslicer.com/wiki/how_to_download_pr_artifacts)
2026-09-25 13:17:43 +08:00
HanifKoh db10c719f6 Keep the Scarf Seam Free of Sub-Millimetre Segments (#15832)
A scarf joint split the loop at exactly the scarf length, so the remainder
of the segment the split landed in became the first flat segment, often a
fraction of a millimetre. The seam insertion also leaves segments of a few
micrometres at both ends of the loop, which a scarf extrudes through where a
plain loop would start or stop. With junction deviation the planner treats
such short moves as tight corners, limited by the Z axis acceleration at the
end of the ramp, and slows to a third of the wall speed or nearly halts at
the seam.

Extend the ramp to the next vertex when the remainder would be shorter than
half a scarf step, capped at a millimetre, beyond which planners treat a move
as ordinary; the scarf only grows, never shrinks. Drop the vertex next to the
seam point at either end when that segment is shorter than an eighth of a
common line width, so the loop still starts and ends at the seam; a trimmed
path loses its arc fitting and prints as line segments. Clamp the scarf
length to the trimmed loop so a scarf covering a whole loop still ends at
full flow. The descending pass reuses the same path, so both wedges stay
consistent, and a flat part that would collapse to a single point is dropped.
2026-09-25 12:32:05 +08:00
Lam Wei Lun 7cb7465ed8 Speed Dial Fixes (#15867)
# Description

- Force repaint on resize in an attempt to fix random occurrences of it
now working on macOS.
- Updated some text in the speed dial
2026-09-25 11:18:20 +08:00
HanifKoh ddf9b85169 Reserve the Prime Tower When the CLI Arranges a Project (#15837)
The global arrange branch, taken by --arrange with all plates selected, only
reserved the prime tower when filament ids had been given on the command
line for STL input. A project carries its filament use per plate and its own
tower positions, but that set was empty for it, so the tower was never an
obstacle: the arranged pile was centred over it and the slice then failed on
a G-code path conflict.

When no filament ids were given, count the filaments each plate uses and
reserve a tower on every plate that needs one, keeping the project's tower
position instead of resetting it to the default. Only a tower the slicer will
print is reserved: the prime tower must be enabled, and a by-object print
gets none unless a smooth timelapse needs it, as the per-plate arrange
decides. Overflow beds are sized for the busiest plate. The STL route is
unchanged.
2026-09-25 08:39:52 +08:00
HanifKoh af52da061f Report Per-Object Slicing Errors in the CLI (#15834)
G-code generation collects errors raised per object, such as an empty
first layer, into one SlicingErrors exception whose own message is just
"Errors". The CLI's generic handler printed that word and recorded the
generic slicing error text, so a headless caller had nothing to act on.

Let Print render the per-object messages with each object's name, and have
the CLI catch SlicingErrors ahead of the generic handler, print that text
and record it as the result's error string. The exit code is unchanged. A
unit test lifts a cube off the bed and checks the message names the object.
2026-09-25 08:37:28 +08:00
Kris Austin 87a5d20d4c fix(gtk): crash at startup when opening a project with Home as the start page (#15876)
Since #15811 the settings page is built when its tab is shown, so on a
Home start that opens a project the Quality page is built on screen. Its
flow-compensation-model field is a multiline text view that GTK maps as
it is created and that is hidden, because compensation is off, before
GTK first allocates it. The loading dialog's wxWindowDisabler then
desensitizes the frame, and GTK crashes in
gtk_text_layout_cursors_changed() on that text view.

On GTK the page view is now hidden while a page is built, so a control
that starts hidden is never realized and GTK realizes it when it is
shown. The deferred build is unchanged.
2026-09-24 17:37:46 -03:00
Kris Austin 0db1dc6480 build: keep header dependencies through a compiler cache hit on clang-cl (#15875)
For clang-cl, CMake sets the depfile flags to the gcc-style -MD, -MT and -MF,
passed through as -clang: arguments. ccache does not parse those, so a cache
hit writes only the object. Ninja has no depfile to read, so it records zero
header dependencies for that object and does not rebuild it after a header
edit. The stale object is still linked into the library and the DLL. sccache
0.15.0 reproduces the depfile on a hit and is unaffected.

Use /showIncludes instead. CMake already does that for MSVC, and ccache
reproduces it on a hit. A make-rules override sets the flags, because CMake
includes the override after Platform/Windows-Clang.cmake. It is forwarded to
each dependency because every one configures as its own CMake project, and it
is only set when the file exists, because scripts/flatpak/make_deps_tar.sh
packs deps/ alone.

In a build with a warm cache, 678 of 790 objects had no recorded headers.
Object code does not change. One GUI translation unit compiled both ways is
byte-identical apart from the COFF timestamp.
2026-09-24 14:06:24 -03:00
Kris Austin 42009cf385 fix: Linux Flatpak crash rendering plate thumbnails while slicing or saving (#15873) 2026-09-24 13:41:56 -03:00
Kris Austin 9859d788d4 fix: installed vendor profiles only update when OTA is enabled (#15831)
* preset updater: refresh installed vendors from resources regardless of enable_ota

Since c4fea8ad24 the resources check in check_installed_vendor_profiles()
sat behind enabled_config_update, which now follows enable_ota, a hidden
flag that defaults to off. The hotfix a93c6ea67b then made that gate skip
installed vendors entirely, so a new build's newer vendor profiles were
never installed over an existing vendor unless OTA had been turned on.
Before the gating the update URL always had a default, so the comparison
effectively always ran.

The resources shipped with a build are not an over-the-air update. Judge
installed vendors against them, and drop the ones no longer enabled,
regardless of the flag; enable_ota keeps gating the online sync.

* preset updater: stop reinstalling the filament library on every launch

check_installed_vendor_profiles() put OrcaFilamentLibrary on the install
list unconditionally, so every launch recopied the whole vendor from
resources and, in a build that ships the profile JSONs rather than a
preset cache, then re-parsed and re-cached it: about 0.3 s of a dev
build's startup, and a 3 MB copy in a release build, for a vendor that
had not changed.

The library was special-cased because it is never in the enabled-vendor
list. Treat it like the default bundle instead: always wanted, and
reinstalled only when the resources carry a newer version.
2026-09-24 09:05:41 -03:00
Ian Chua 879f6b67e9 fix: use --method GET 2026-09-24 19:56:51 +08:00
Ian Chua 4ccb5648e6 fix: OFL pending-record step, missing permission and event-type gap 2026-09-24 19:37:16 +08:00
Ian Chua aed0164ea1 fix: update pending db on merge 2026-09-24 16:15:19 +08:00
Lam Wei Lun 0ee529e283 Fixes missing Plugins category for installed plugins 2026-09-24 15:25:11 +08:00
Ian Chua 6e055e5d8b fix: invoke endpoint to clear pending queue 2026-09-24 14:59:53 +08:00
Ian Chua 15b64522a4 fix: single vendor with no grant in a push will zero out the entire vendor batch 2026-09-24 13:36:37 +08:00
Lam Wei Lun 7ae76e44b1 Merge branch 'main' into feat/speed-dial-fixes 2026-09-24 10:42:19 +08:00
Lam Wei Lun 54c32a54b4 Change some search actions and results text. Force repaint for when resizing and show happens 2026-09-24 10:41:55 +08:00
Ian Chua 0030bed519 fix: ota updates workflow 2026-09-23 20:37:25 +08:00
25 changed files with 858 additions and 110 deletions
+146
View File
@@ -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
+215 -10
View File
@@ -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
+3
View File
@@ -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
+10
View File
@@ -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 ()
+10
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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"
+57 -23
View File
@@ -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
View File
@@ -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;
+78 -1
View File
@@ -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 {
+19
View File
@@ -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) {
+4
View File
@@ -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;
+13
View File
@@ -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: {
+1
View File
@@ -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
+36 -12
View File
@@ -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;
+4
View File
@@ -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};
+8
View File
@@ -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();
+1
View File
@@ -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);
+14
View File
@@ -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];
+4 -7
View File
@@ -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);
} }
+101
View File
@@ -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));
}
}
+26
View File
@@ -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);
}